A time-anchored Neapolitan pizza dough calculator — try it live. You enter when you want to bake; the app schedules every step backwards from that moment, auto-switches between cold and room fermentation based on available time, and gives you an on-screen schedule, an .ics you can drop into a calendar, a print-to-PDF recipe sheet for the kitchen counter, and a TRMNL e-ink view for the counter clock.
New in v5: an autolyse rest — when you're not using a pre-ferment, the app rests flour and water for 30 min before the salt and yeast go in (less kneading, a more extensible dough). It's on by default (including the beginner view); experts can switch it off. Old share-links predate it and reproduce their original schedule unchanged.
New in v4: a beginner view (just "how many, when, and how you knead" — every step explained via the schedule's short/detailed switch; experts get the full form), spiral, stand-mixer or hand kneading — each adapts the mix step and the water temperature to how efficiently it works the dough, combined pre-ferments (biga and poolish maturing in parallel, each with its own flour share and an optional cellar temperature), dry yeast (instant and active dry alongside fresh and sourdough), a cold ball proof option (divide first, balls ripen in the fridge), and recipe memory — the app restores your last recipe on a fresh visit and keeps a device-local recipe book.
Built with SvelteKit 5 + TypeScript + Tailwind v4. Fully client-side, five languages (EN / DE / IT / FR / NL), shareable recipes via URL.
- Node.js 22+ and npm on the host (enforced via
engines; CI and deploys run Node 24).
That's it. CI and deployment run on GitHub Actions; locally you just need Node.
npm install
npm run dev # Vite dev server on http://localhost:5173To stop it, hit Ctrl-C.
npm run build # static site → ./build/
npm run preview # serve ./build/ locallysrc/
├── lib/
│ ├── dough/ ← pure math, framework-free, heavily tested
│ │ ├── bakers.ts baker's percentages, mass balance
│ │ ├── fermentation.ts Q10 yeast ↔ time ↔ temperature model
│ │ ├── schedule.ts backwards schedule, cold↔room auto-switch
│ │ ├── ics.ts RFC 5545 calendar export
│ │ ├── urlState.ts compact share-link encoding
│ │ ├── types.ts shared types
│ │ └── *.test.ts colocated tests
│ ├── components/ ← Svelte 5 UI (uses runes)
│ ├── i18n/ ← messages (en/de/it/fr/nl), locale detection, runtime interpolation
│ ├── community/ ← community.md (data) + parser, rendered as a table at the bottom of the page
│ ├── pizzerias/ ← pizzerias.md (50 Top Pizza recipes) + parser, rendered below the community table
│ ├── trmnl/ ← TRMNL Private-Plugin webhook payload + client
│ ├── state.svelte.ts ← form state as a $state class
│ ├── mode.svelte.ts / storedMode.ts ← beginner/expert view mode (+ localStorage)
│ ├── verbosity.svelte.ts / storedVerbosity.ts ← schedule short/detailed switch (+ localStorage)
│ ├── storedRecipes.ts ← last-recipe restore + named recipe book (localStorage)
│ ├── format.ts ← grams, percentages, durations, datetime input glue
│ └── stepCopy.ts ← maps ScheduleStepKind → i18n key + interpolates schedule context
├── routes/
│ ├── +layout.svelte ← global styles, language bootstrap
│ ├── +layout.ts ← prerender + ssr=false (fully client-side)
│ ├── +page.svelte ← the entire calculator UI
│ └── print/[[locale]]/ ← self-contained print/PDF sheet (auto-triggers the dialog)
├── app.css ← Tailwind v4 entrypoint + @theme palette
└── app.html ← shell
.github/workflows/
├── ci.yml ← lint + check + coverage gate + build on PRs and pushes to main
├── deploy.yml ← build + publish to GitHub Pages on main
└── preview.yml ← build + publish a per-PR preview, comment the URL, clean up on close
vite.config.ts ← Vite (no test config; runtime build only)
vitest.config.ts ← Vitest (kept separate so vite types stay clean)
| Command | What it does |
|---|---|
npm run dev |
Vite dev server on port 5173 with HMR |
npm test |
Run vitest once (npm run test:watch for watch mode) |
npm run test:coverage |
Run vitest with v8 coverage → ./coverage/ |
npm run check |
svelte-kit sync + svelte-check (type & template check) |
npm run lint |
Prettier check + ESLint |
npm run format |
Prettier write |
npm run build |
Production build → ./build/ (static site) |
npm run preview |
Serve the built site locally |
Husky + lint-staged are configured (.husky/pre-commit). The hook runs lint-staged and then npm test on every commit.
- Math/logic first. Add or extend a module in
src/lib/dough/. Keep it pure (no Svelte imports). Add a*.test.tsnext to it. Runnpm testuntil green. - Wire to state. If new inputs are needed, extend
FormStateinsrc/lib/state.svelte.ts, thenSerializableInputsinsrc/lib/dough/urlState.ts(encode + decode + round-trip test). - UI. Add fields to
src/lib/components/InputForm.svelte; render results in the existing components or add a new one. Use Svelte 5 runes ($state,$derived,$effect). - i18n. Every new user-facing string goes into
src/lib/i18n/messages.tsfor all five locales. The parity test will fail loudly if a key is missing. - Verify.
npm run test:coverage && npm run check && npm run build. The CI workflow runsnpm run lint,npm run check,npm run test:coverage(the 100 % coverage gate — plainnpm testskips it), andnpm run build.
The Print / Save as PDF action opens a dedicated /print/[[locale]]?<recipe> route in a new tab (src/routes/print/[[locale]]/+page.svelte). The route is fully self-contained — inline styles in <svelte:head>, no Tailwind print variants, no shared chrome — and auto-triggers window.print() on mount. It renders a two-column header (Recipe inputs on the left, Ingredients on the right) above the full-width schedule, then a footer with a QR code of the share URL so scanning the printed sheet rehydrates the recipe in the app.
If you touch the printed layout, check it in your browser's print preview — don't rely on svelte-check. Keep it readable on a B&W printer (borders and text colour, not background fills), and keep the common shapes on one page. QR generation lives in src/lib/qr.ts (thin wrapper around qrcode-generator).
The recipe is pushed to a TRMNL device via a Private Plugin webhook, straight from the user's browser: the Send to TRMNL action in the schedule menu POSTs pre-formatted merge_variables to https://trmnl.com/api/custom_plugins/<uuid>, and the device renders them through a Liquid template at its own refresh cadence. The template picks the current step at render time with Liquid date math, so one POST per recipe change keeps the Now/Next/Done highlight moving all day.
Implementation lives in src/lib/trmnl/ (payload builder + webhook client); the setup walkthrough and the Liquid template are in docs/trmnl-setup.md. The payload uses 1–2 character keys to stay under the free tier's 2 KB cap in every locale — a regression test measures the wire size, so adding fields without measuring fails CI. There is no /trmnl route any more: the earlier screenshot-plugin approach failed because TRMNL's renderer doesn't reliably execute JS, so every capture showed build-time defaults.
- Baker's percentages: flour = 100%; water, salt, yeast (and the optional oil + sugar) are % of flour. Total dough =
pizzaCount × ballWeight. Flour is derived from total and the sum of percentages. - Mass balance is subtly different for sourdough: fresh yeast adds new mass (
pctSum = 100 + h + s + y + oil + sugar), while sourdough starter is just flour+water from the existing budget (pctSum = 100 + h + s + oil + sugar). Both produce ingredients that sum exactly topizzaCount × ballWeight— there's a test that enforces this. Oil and sugar default to 0 and stay out of any pre-ferment (they'd inhibit the culture); when > 0 they get weighed at the mainmixstep. When a pre-ferment is active the ingredient table renders as three sections (Pre-dough / Main dough / Totals) rather than one flat table — a single subtracted table reads as a math error because the totals row never matches the visible sum. - Fermentation model: ferment "units" =
yeast% × hours × temperatureFactor(T). Temperature factor follows Q10 = 2 (rate doubles every 10 °C). Reference: 0.2% fresh yeast at 22 °C ferments for ~8 h. Every fermentation phase contributes to the same equivalent-hours sum that solves for the yeast %, including the pre-ferment — see below. BothroomTempC(used during room ferment, warmup, final proof, and pre-ferment) andfridgeTempC(used during the cold-bulk leg) are user inputs. - Cold/room switch: deterministic on available time. ≥ 16 h available → cold ferment with a fixed-shape schedule (prep → mix → 1 h room bulk → long fridge bulk → divide → 3 h warmup → 1 h final proof → bake). Below that → room ferment with bulk + final proof split 2:1 inside the available window. Yeast % is then chosen so the actual ferment-unit total matches the target.
- Autolyse (no pre-ferment only): a fixed 30 min flour+water rest between prep and mix. It carries no yeast, so it's not a fermentation phase — it comes off the fixed window like the mix minutes do, and the yeast solve rises slightly to keep the same total fermentation. Default on (expert opt-out); with a pre-ferment it's skipped (the biga/poolish already rests the flour).
- Pre-ferments as real fermentation phases: biga and poolish can be enabled independently — together, if you like — each with its own flour share (5–80 % each, 80 % combined). Wall-clock durations are solved per type from
roomTempC(prefermentDurationHours— biga ~14 h, poolish ~12 h at 22 °C, Q10-scaled and clamped to [8, 24] h). All pre-ferments mature in parallel and end at prep: the schedule reserves the longest and emits onepreferment-mixstep per pre-ferment. Their legs enter the yeast solve weighted by flour share (w = share / Σ shares— a single pre-ferment hasw = 1, so old share links keep their exact yeast %). The pre-ferments carry all of the recipe's yeast for fresh-yeast recipes, split proportional to flour share — no extra yeast on baking day. Pre-ferments are mutually exclusive with sourdough (the starter is itself the pre-ferment culture); selecting sourdough empties the list ineffectivePreFerments. - Schedule window: the user picks both a start datetime (defaults to page-load time, editable, persisted in the URL) and a ready-by datetime. Everything is sized to fit inside that window. When a pre-ferment is selected, the temperature-dependent pre-ferment duration is reserved before mix-day prep.
startAtis a hard floor (issue #78): the first step always lands at or after it — durations shrink to fit rather than start earlier. The one documented exception is a degenerate window shorter than the fixed hands-on steps (prep + mix + divide): those keep their physical durations anchored toreadyBy, so the first step lands beforestartAtand the schedule is flagged infeasible with atoo-shortwarning. - Night-window guard: no baker-action step may start in
[22:00, 08:00)local time. In cold mode the scheduler shrinks the bulk-cold duration (within[0, natural]— it never extends it, which would pull the first step beforestartAt) so the pre-cold cluster (preferment-mix→ bulk-cold start) lands during waking hours. The post-colddivideis anchored toreadyByand can't be shifted; room mode has no slack. When a step can't be lifted out of the window the scheduler emits anight-stepwarning (surfaced viaWarnings.svelte) instead of silently rearranging. - Round numbers action: the button next to the ball-weight input nudges the ball weight (to 0.1 g precision; the field accepts decimals like
288.5) so the derived flour and water come out as tidy multiples of 100 g — or 50 g when 100 g would drift too far. It's idempotent (clicking twice is a no-op) and works for both fresh yeast and sourdough, branching on thepctSumdifference above. - Step copy &
.icsparity:stepCopy.tsinterpolates schedule context into each step description — divide & ball shows pizza count and per-ball weight, mix steps show the main-dough flour/water/salt/yeast weights (with the yeast label localized to fresh yeast vs. sourdough starter). With a pre-ferment,prepandmixuse type-specific templates (mix_desc_with_biga,mix_desc_with_poolish,prep_desc_with_preferment) that deliberately omit the yeast field on day two — the pre-dough is the carrier. When oil or sugar are non-zero, anextras_oil/extras_sugar/extras_oil_sugartrailer (e.g. "Plus 12 g oil and 5 g sugar.") is appended toprep(no PF) and to everymixvariant —preferment-mixstays clean. The singlepreferment-mixrow covers both the brief active mixing and the full wall-clock maturation. The.icsVEVENTDESCRIPTIONmust match the on-page step description verbatim;preferment-mixis markedTRANSP:TRANSPARENTso the calendar doesn't block out the maturation window.
Deployment is fully automated by .github/workflows/deploy.yml. Every push to main:
- Runs
npm ciand builds the static site withnpm run build. - Pushes the contents of
./build/to thegh-pagesbranch (root), preserving anypr-preview/subdirectories so open PR previews keep working. - GitHub Pages serves the
gh-pagesbranch. - Tags the commit
v<version>(frompackage.json) and pushes the tag. The step is idempotent — pushes that don't bump the version skip the tag because it already exists onorigin. This is what backs thev<version>release links in the screen and print footers.
The workflow resolves the base path automatically. A custom domain (a static/CNAME file — the app is live at kneadtime.pizza) or a user/org site (<owner>.github.io) is served from the root, so BASE_PATH stays empty; a bare project repo (<owner>/<repo>) is served from /<repo>/, so the build runs with BASE_PATH=/<repo>. To move the app onto (or off) a custom domain, add or remove static/CNAME — both deploy.yml and preview.yml branch on its presence.
static/CNAME is committed so it survives the clean: true gh-pages deploy (which would otherwise delete the file GitHub writes when you set the domain in the Pages UI, un-setting the domain on the next push).
svelte.config.js reads BASE_PATH from the env. SvelteKit also serves a 404.html fallback so deep links and refreshes resolve to the SPA shell, and a static/.nojekyll file disables GH Pages' Jekyll processing.
First-time setup on GitHub: in the repo settings, set Pages → Build and deployment → Source = Deploy from a branch, then Branch = gh-pages / / (root). The first push to main will create the branch if it doesn't already exist.
.github/workflows/preview.yml builds every PR and publishes it as an isolated preview under gh-pages:/pr-preview/pr-<number>/. The workflow uses rossjrw/pr-preview-action, which posts (and updates) a sticky comment on the PR with the preview URL and removes the directory when the PR is closed or merged.
The preview build sets BASE_PATH=/<repo>/pr-preview/pr-<number> (or /pr-preview/pr-<number> on user/org sites) so all $app/paths-relative links resolve correctly inside the subdirectory.
A separate .github/workflows/ci.yml runs lint, type-check, the coverage-gated test suite, and build on every PR and on pushes to main, but doesn't deploy. The main runs exist so Codecov gets a main-branch baseline (the badge at the top points at branch/main); branch protection already guarantees the PR check was green before merge.
The bottom of the page lists recipes other bakers have shared. Each entry is a
single row in src/lib/community/community.md:
| Name | Date | Recipe |
| --------- | ---------- | ------------------------------ |
| Your name | 2026-05-13 | https://kneadtime.pizza/?r=... |To add yours: dial in the recipe in the app, click Share to copy the URL, then open a PR appending one row to that file. The website parses the URL, shows your inputs as columns, and links the row back to the recipe.
For the Name column use either your full name (renders as plain text) or
a GitHub handle prefixed with @ (renders as a link to your profile, e.g.
@JanWelker → https://github.com/JanWelker).
Below the community table, the page lists pizzerias from the
50 Top Pizza guide (2018–2025) whose dough
recipes are publicly documented. The data lives in
src/lib/pizzerias/pizzerias.md. Each row
has seven columns:
| Pizzeria | Location | Rankings | Recipe | Timing | Notes | Source |
| ------------------------------------------------------------- | -------------- | ----------------------------------- | ------------------------------ | ------------------------------ | ------------------------------- | ----------------------------- |
| [Pepe in Grani](https://www.50toppizza.it/.../pepe-in-grani/) | Caiazzo, Italy | 2018-it:1, 2019-it:1, 2022-w:26 ... | https://kneadtime.pizza/?v=3&… | bulk-room:4-5h, final-proof:2h | Source also adds ~1.9 % starter | https://youngandfoodish.com/… |- Pizzeria name is a markdown link to the pizzeria's 50 Top Pizza profile.
- Location is
City, Country. - Rankings is a comma-separated list of
YEAR-LIST:RANKtokens.LISTisitfor the 2018–2021 guides (when 50 Top Pizza was an Italy-only ranking) andwfor 2022–2025 (the standalone World ranking). - Recipe is the Knead Time Share URL that encodes the published numbers. Open the app, dial in the recipe, click Share, paste here.
- Timing captures the proving durations the source specifies
(
step-kind:Nh,step-kind:N-Mh, orNm). The schedule tags any computed duration outside the source range with the original value. - Notes is free-form text flagging caveats — dropped ingredients, flour blends, "approximation". Leave empty when the recipe maps cleanly.
- Source is the primary source for those numbers — a chef's interview, cookbook excerpt, official video, etc. Don't submit a row without one.
- All calculation logic stays in
src/lib/dough/and is framework-free. Components only render results. - Tests live next to the code they cover (
foo.ts+foo.test.ts). The dough math is the priority surface; UI tests are not currently planned. - Comments explain why, not what. A named function or variable is the documentation for what.
- New dependencies should be small and justified. We prefer hand-rolling small things (the
.icsgenerator is hand-written) over pulling in large libraries. - The full project rationale and scope lives in
CLAUDE.md.
Copyright © 2026 Jan Welker. Licensed under the Apache License, Version 2.0.