Case Study · 2026
DailyMeal
A meal app organised around the fridge rather than the recipe. And, underneath it, a public demo that is the real product rather than a mock of it — running in the same deployment as data it cannot reach.
Inventory matching · Trust boundaries · State consistency
The brief
Most recipe apps answer “what could I cook?”. The useful question is “what can I cook right now, with what is actually in this kitchen?”
A recipe library is a catalogue. A kitchen is an inventory. Almost every cooking app ships the first and leaves you to hold the second in your head — which is why the browsing ends in takeout roughly as often as it ends in dinner.
So the unit of this app is not the recipe. It is the match between a recipe and a shelf. Every dish carries its ingredient list; every ingredient is matched against real stock; and what the app shows you first is the fraction you already have.
- Cooking something deducts it from the pantry.
- Running low puts it on the shopping list, without being asked.
- Eating out is a meal you log, not a failure to cook.
- Three nutrients tracked at once — protein, calcium, fibre — against goals you set.
- Every dish, ingredient, unit and step exists in both Chinese and English.
Decisions
Availability, not a list
A dish is not shown as available or unavailable. It is shown as a proportion — seven ingredients of eight, with the eighth named. That distinction turns out to carry the whole product: a dish at 7/8 is a dinner plus one item on a list, where a binary “unavailable” is a dead end.
Matching an ingredient to a shelf is the fussiest code in the repository, and deliberately so. It tries an exact match in either language, then a table of thirty-four domain aliases, then a substring fallback. Real pantries do not have primary keys.
Cold storage, not delete
Eighty-five recipes, of which thirty-nine start archived rather than deleted. A dish you are bored of in March is a dish you want back in September, and a delete button is a decision you cannot undo six months later when you have forgotten the dish existed.
Eating out is a first-class meal
There is a meal category for the place on the corner. It is always one hundred percent available, it logs its protein and calcium like anything else, and it pointedly does not deduct from the pantry.
An app that only understands cooking quietly records every takeaway as a gap in the data. This one just records dinner.
One card at a time
The deciding surface is not a grid. You pick a mood — ready in the fridge, high protein, under five minutes, something warm, surprise me — and the app deals exactly one dish, with a button to deal another.
Eighty-five options presented at once is the state of indecision the app exists to end, not a feature.
Two trust levels, one deployment
A public sandbox that lives entirely in the visitor's browser, and a signed-in workspace backed by Cloudflare D1. Same Worker, same build, same components — the difference is which side a request arrives on.
That is decided at the edge, from the hostname, and injected as a request header before anything renders. Every protected endpoint checks that header before it checks authentication, and before it touches the database. Any hostname the app does not recognise gets a 404 — it fails closed rather than open.
The protected routes answer the public host with 404, not 403. A 403 tells you there is something there.
The demo is the product
The workspace is one component. What differs between the two surfaces is a
configuration object — where state loads from, where it saves to, how a recipe's
calcium is resolved. Protected state round-trips to D1 through an authenticated
API; demo state round-trips to localStorage.
The alternative — a separate marketing mock — is a thing that starts accurate and silently stops being accurate. This one cannot drift, because there is nothing for it to drift from.
The protected catalogue is absent, not hidden
The recipes and pantry on the signed-in side sit behind a server-only
import and reach the browser solely through an authenticated bootstrap endpoint.
They are not in the public bundle at all.
That claim is worth nothing unless something checks it, so a test does: it
requests the portfolio page, walks every <script> the page
actually loads, reads those files off disk, and asserts that specific protected
dish names appear in none of them. A refactor that accidentally pulls the
protected catalogue into the shared graph fails the build.
The credential never reaches the client
Authentication is an HMAC-signed, HttpOnly, SameSite=Strict session
cookie issued by the server and valid for thirty days. The signing key is a
Worker secret — not in the bundle, not in a header, not in browser storage.
Comparison is constant-time, and a failed attempt costs a fixed delay.
That boundary is asserted rather than assumed: a test reads every client chunk the build produces and fails if a credential identifier appears in any of them. A key that reaches a browser is a key you can no longer rotate out of the devices that already hold it — which makes it worth a test rather than a convention.
Under the hood
Protected state is a single JSON document in D1, read and written through one authenticated endpoint. Saves go through a sequential queue that always writes the newest snapshot, so a burst of quick edits cannot land out of order; a failed sync keeps a local copy and says so rather than pretending it saved.
Schema changes are handled by a migration that runs on load: it matches old pantry items to new ones by name, then by identifier, then by a handful of domain-specific rules, and carries the quantities across. Upgrading the recipe library does not cost you your fridge.
Loaded state is sanitised field by field before it is trusted. The current date is
re-checked on a timer and on window focus; if Safari private mode rejects
localStorage, storage falls back to memory instead of crashing.
Lint, types, a build, and the isolation tests run on every push. The isolation tests assert against the built output, which means they can only run after a real build — so the build is part of the check, not a separate step somebody might skip.
How this was built
I defined the product brief, data model, trust boundaries, matching behavior, edge cases, and acceptance criteria, and directed the work across 175 commits. An AI coding agent produced the implementation; I reviewed system behavior, tested failure cases, and drove revisions. A second AI reviewed the finished repository, and several findings were incorporated into the final implementation.
Half the repository is not code. Eighty-five recipes and a full pantry — every ingredient with its weight, its protein, its calcium, its fibre, in two languages — outweigh the logic that reads them by an order of magnitude. The matching engine is a few hundred lines. What makes its answer useful is the accuracy of the shelf behind it, not the cleverness of the search.
Where it is now
The link above is the public sandbox. It seeds a plausible day and a half of meals so the page has something to show, keeps everything in your own browser, and resets on request — it is the application itself, not a walkthrough of it.
The other half of the deployment is the signed-in workspace on D1. Nothing it holds is reachable from the public surface — not the data, and not the code paths that could reach it. That is what the tests are there to keep true.