Recipes
A local-first recipe book that scales any recipe to the number of people at the table, prints the amounts the way a recipe prints them, and keeps the screen awake while you cook.
- Section
- productivity
- Year
- 2026
- Role
- Solo project
- Stack
- React, TypeScript, Vite, Vitest, localStorage
Summary
A recipe book that lives entirely in one browser. Every recipe on one page,
searchable by name, tag or ingredient; a stepper that recomputes the amounts for
any number of people; and a cook mode that enlarges the recipe, lets you tick off
ingredients and steps, and asks the screen to stay awake. One localStorage key,
no account, no server — and an export button that is therefore the only backup
there is.
Story
The feature that decided the architecture is scaling. Halving a recipe is only
useful if the result still reads like a recipe, which means an ingredient can’t be
a line of free text — it’s { amount: number | null, unit, item }, and the recipe
records the servings its amounts were written for. Scaling then changes a number in
component state and never rewrites what’s stored, so it stays reversible and lossy
input never compounds. null is the interesting value: it means “to taste”, it
scales to itself, and every function in the quantity module takes number | null
for that reason alone. Getting the printing right mattered as much as the maths —
recipes say 1½ kg, not 1.5 kg, so amounts are matched back onto vulgar
fractions within a small epsilon and only fall back to decimals when they land near
nothing familiar. The parser reads what a cook actually types (1 1/2, 1½,
3/4, .5) and folds anything it can’t read into the unit as free text, so a
half-understood entry still prints in the amount column instead of being dropped.
Lessons
- Structured data is worth the parser when a feature depends on it. Flattening amounts back into text would take the scaler with it.
- Reads never throw. Corrupt or unavailable storage falls back to empty and malformed recipes are dropped individually, because blanking the page mid-render is worse than losing one entry.
- Rounding cuts both ways:
1 - remaindercan round up into the next whole, so0.999has to print as1rather than0and a stray glyph. - Per-recipe cooking checkmarks live under their own key so they stay out of the export — nobody wants to import someone else’s half-ticked list.
- A base path is not a route. Vite’s
baseprefixed the emitted URLs and moved nothing;vercel.jsonin the app’s own repo maps the prefix back to root.