Skip to content
appsbynate
← productivity

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 - remainder can round up into the next whole, so 0.999 has to print as 1 rather than 0 and 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 base prefixed the emitted URLs and moved nothing; vercel.json in the app’s own repo maps the prefix back to root.