# MealQuest architecture This mirrors the architecture section of `CONTEXT.MD` in the CTXD project `mealquest`, which is the source of record; if the two disagree, CTXD wins and this file should be updated. It describes the **planned** application. The repository today is the Feature 01 scaffold: `cmd/server`, `internal/config`, `internal/server` (`/health`), `internal/frontend` (embedded PWA with SPA fallback), the `web/` skeleton, `Makefile`, `Dockerfile` and CI. Decision numbers (D-NNN) refer to `DECISIONS.MD` in CTXD. ## Topology ``` PWA (phone / tablet / desktop) │ HTTPS ▼ Traefik v3.6 — 10.60.1.33 file route: mealquest-ccl.yml │ ▼ docker compose project "mealquest" — 10.60.1.14 ├── mealquest Go service + embedded PWA, host 8090 → container 8080 ├── searxng sidecar for recipe search; internal compose network │ only, no published port └── Litestream SQLite replication → Synology NAS 10.60.1.50 ``` One Go service serves the API, the auth flow, media and the PWA. Its SQLite database is replicated by Litestream to the NAS. Traefik terminates TLS. The service honours `X-Forwarded-For` and `X-Forwarded-Proto` only from peers listed in `MEALQUEST_TRUSTED_PROXY` (comma-separated IPs/CIDRs; production lists `10.60.1.33`); from any other peer they are ignored. The client is the rightmost `X-Forwarded-For` address that is not a trusted proxy. The resolved client IP is added as `client_ip` to every log line written while handling a request. The OIDC redirect URI and the Secure cookies are fixed by `MEALQUEST_PUBLIC_URL`, never by request headers, so sign-in works whichever peer the request arrives from. ## Repository layout | Path | Purpose | | --- | --- | | `cmd/server` | Entry point; wiring only. | | `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. | | `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. | | `internal/server` | HTTP server, router, middleware. | | `internal/frontend` | Embedded PWA, SPA fallback. | | `internal/auth` | Authentik OIDC, sessions, CSRF. | | `internal/db`, `internal/store` | SQLite connections, sqlc queries. | | `internal/secretbox` | AES-256-GCM encryption for `_ct` columns. | | `internal/household`, `internal/dinners`, `internal/recipes` | Domain. | | `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. | | `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. | | `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. | | `internal/vapid` | VAPID key generation and validation. | | `internal/jobs`, `internal/scheduler` | Background jobs and schedules. | | `migrations/` | SQL migrations (no goose or golang-migrate). | | `web/` | React PWA. | | `deploy/` | Deployment files. | | `docs/` | Documentation. | | `design/` | Requirements and UI handoff (read-only reference). | ## Backend stack and conventions - Go 1.25, `CGO_ENABLED=0`, a single static binary. - Router: `go-chi/chi/v5`. Logging: `log/slog` JSON. - SQLite via `modernc.org/sqlite`, with `journal_mode=WAL`, `foreign_keys=ON` and `busy_timeout`. **One writer connection plus a read pool.** Queries generated by `sqlc`. - IDs are ULIDs (`oklog/ulid/v2`). - Timestamps are stored in UTC; local calendar dates are `YYYY-MM-DD` strings in the household timezone. - Configuration is environment-only (`MEALQUEST_*`). Secrets come only from files named by `*_FILE` variables. Missing or invalid config stops startup. - Error responses always have the body `{"error":{"code":"…","message":"…","retryable":false}}`. ## Authentication - Authentik OIDC; the backend is a **confidential client** (`coreos/go-oidc/v3`, `golang.org/x/oauth2`). - Session cookie `__Host-mealquest_session` (HttpOnly, Secure, SameSite=Lax). Sessions expire after 30 days idle or 180 days absolute. - Non-GET requests require the CSRF header `X-MealQuest-CSRF`. - Access requires membership of the Authentik group `mealquest-admin`. There is a **single role**: one shared household, no per-person profiles. ## Data model Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). | Table | Holds | | --- | --- | | `household_settings` | One row: timezone, dinner_time (17:30), reminder_time (16:30), review_time (09:00), plan_draft day/time (Fri 09:00), suggestions day/time (Sun 08:00), chat_autosend day/time (Sat 08:00), onboarding_completed_at, updates_seen_at. | | `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes (the page's JSON-LD), yield, raw_jsonld, fetched_at; and its grounding: photo_id, method, difficulty, minutes_total (normalized prep to table). | | `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. | | `recipe_steps` | recipe_id, position, text. | | `photos` | source_url, local_path, width, height, sha256, fetched_at. | | `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. | | `suggestion_batches` | trigger, status, ai_run_id. | | `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. | | `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. | | `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | | `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). | | `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. | | `chat_settings` | One row: webhook_url_ct, space_label, last_test_at, last_test_status. | | `notification_prefs` | event (`plan_ready` / `list_ready` / `day_of_reminder` / `new_suggestions` / `rotation_review`), push, chat. | | `push_subscriptions` | endpoint (unique), p256dh, auth, user_agent, failure_count, last_success_at. | | `notification_outbox` | event_key + channel (unique), payload, status, attempts, next_attempt_at, last_error. | | `jobs` | kind, status, payload, result (progressive), error, timestamps. | | `schedule_runs` | schedule, period_key (unique). | | `ai_runs` | skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in/out, error, redacted request/response (30-day retention). | | `sessions` | id_hash, subject, email, name, groups, csrf_token, created/last_seen/expires. | `recipes` holds a fetched recipe page; `dinners` holds the household's meal record and its lifecycle, optionally linked to a recipe and a photo. ## Dinner lifecycle ``` suggested (deck) ──Try it──▶ quest_log ──planned date passes──▶ tried │ ▲ │ │ └──────── explicit decision ─────┤ │ ├──▶ regular │ └──▶ archived └──Pass──▶ archived (suggested name kept for dedupe) ``` From `tried`, an explicit decision moves the dinner to `regular`, to `archived`, or back to `quest_log`. Promotion to `regular` is **never automatic** (D-021). ## API surface - JSON API under `/api/v1`, using the error body above for every failure. It requires the session cookie and, for non-GET requests, the `X-MealQuest-CSRF` header. - `/auth/*` — the OIDC flow. - `/media/*` — cached photos, behind the session cookie. `GET /media/photos/{id}-{400|1200}.jpg` serves a stored size of a photo with a `photos` row, with `Cache-Control: private, max-age=31536000, immutable`; anything else is 404. - `GET`/`PATCH /api/v1/settings/recipe-search` — provider, base_url and the Brave key (`api_key`, write-only: responses carry only `api_key_set` and `api_key_last4`; an empty string removes it). The result must be usable: SearXNG needs a base_url, Brave a key. Responses also carry the read-only `default_base_url`, the deployment's `MEALQUEST_SEARXNG_URL` (or null), which the client pre-fills while no base_url is saved; only a PATCH of base_url stores a URL. `POST /api/v1/settings/recipe-search/test` searches "chicken tikka masala" and returns `{query, count}`, 409 `not_configured`, or 502 `search_failed` with the provider's error. - `GET`/`PATCH /api/v1/settings/ai` (`aisettings.Service`) — provider, base_url, model, temperature, system_prompt, plus the read-only `api_key_last4` and `key_verified_at`. PATCH requires a preset name as provider, temperature 0–1 in 0.1 steps, a non-empty model and a system_prompt of at most 8000 characters; a fixed-URL preset's base_url must be empty or its own. Provider and base_url cannot change while a key is stored (remove the key first), so a stored key is never sent to an endpoint it was not stored for. `PUT /api/v1/settings/ai/api-key` takes `{api_key}` (write-only, at least 8 characters, sealed with the secret box; an empty string removes it) and clears `key_verified_at`; a preset without a fixed base_url takes a key only when its base_url passes `secureurl.Check`. `POST /api/v1/settings/ai/verify` calls `Provider.Verify` and records `key_verified_at`; `GET /api/v1/settings/ai/models` returns `{models: [{id, display_name}]}` live from the provider, with the key redacted from every id and display_name. Both work before a model is chosen (`presets.ForAccount`), answer 409 `not_configured` when the settings cannot build a provider, and 502 with the provider's typed error (`auth_failed`, `rate_limited`, `bad_output`, `provider_error`; 504 `timeout`) when the call fails. `POST /api/v1/settings/ai/reset-prompt` restores `household.DefaultPersonaPrompt` (CTXD PROMPTS.MD §4). No response holds the key. `Service.HarnessConfig` reads the settings for each skill run, so a saved change applies to the next run. - Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from `{name}` or `{name, recipe_url}` (an https:// link, kept in `recipe_url` for grounding and never returned in a dinner response, as it may carry a personal token) with status and origin `regular` and grounding_status `pending`, answering 201 with the dinner. The name is always the household's: no part of a link is ever used as a dinner's name, and a link without a name is 400. A name is trimmed, at most 120 characters and must keep a word after `harness.Normalize`, which sets `normalized_name`. A name `harness.Similar` to an unarchived dinner's, or a link one already has (the source_url of the recipe it is grounded in, else its pasted `recipe_url`), is 409 `duplicate_dinner`; archived names may be added again. `GET /api/v1/dinners?status=regular|quest_log` returns `{dinners: [...]}` ordered by normalized name, each with `photo_url` (`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`; any other status is 400. The Quest log (`quest_log`) is the dinners accepted and not yet scheduled: one on the plan for the household's current week (by its timezone) or a later week is left out. `PATCH /api/v1/dinners/{id}` edits `name` (deduplicated against the other unarchived dinners), `method` (`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total` (1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values are 400 `invalid_dinner` and change nothing. `DELETE /api/v1/dinners/{id}` sets status `archived` and keeps the row; `POST /api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records the session's subject as `updated_by`; an unknown id is 404. `GET /api/v1/dinners/{id}` returns the dinner with its recipe, read in one transaction: `{id, name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, photo_urls, ingredients, steps, source, grounding_status, grounding_error, why_picked}`. `photo_urls` is `{"400": url, "1200": url}` (`/media/photos/{photo_id}-{size}.jpg`) or null with no photo; `ingredients` is `[{name, qty, unit, note, raw_text}]` and `steps` the step texts, both in recipe position order and empty (`[]`) with no recipe, as for a dinner whose grounding is `pending` or `failed`; a recipe entered by hand has only `raw_text`. Two items at one position leave recipe order unknown, so such a recipe is 500 rather than shown out of order. `source` is `{site_name}` of the page the recipe was fetched from, null for a manual recipe or none; it has no `url`, as the page's link may carry a personal token. `site_name` is the name the page gives its site (publisher or `og:site_name`) and null otherwise: a page naming no site is stored under its host, which may be the pasted link's — a token in a subdomain — or a redirect's, so any stored name that could be a host (the link's host, an IP address or a dotted host name, even a real name like "Food.com") is withheld. The detail screen shows "Recipe from {site}" only when `site_name` is not null. `why_picked` is the reason of the suggestion that created the dinner — its pending or accepted one, as declining a suggestion archives its dinner — null for one no suggestion created; a dinner with more than one such suggestion is 500, as nothing records which created it. An unknown or archived dinner is 404. - Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner` job and answers with its `grounding_job_id` without waiting for it (`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it still answers 201, with grounding_status `failed` (`grounding_error`) and `grounding_job_id` null. The job grounds the dinner's `recipe_url` directly with `GroundURL`, else its name with `Ground` (see Recipe grounding), and sets grounding_status `grounded` with the recipe's recipe_id, photo_id, method, minutes_total and difficulty, clearing the pasted `recipe_url` (the recipe replaces it), or `failed` with `grounding_error` — also the job's failure code — one of `no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`, `search_failed`, `ai_failed`, `interrupted` (the server stopped) or `grounding_error`. Each job carries the dinner's `grounding_revision` from when it was enqueued; renaming a dinner raises the revision, and renaming a `pending` one enqueues a job for the new name, clearing its `recipe_url` when the normalized name changes. A job for a dinner archived, no longer `pending` or at another revision does no work, and a job only writes a dinner still unarchived, `pending` and at its revision, so a fix-up made meanwhile wins and an earlier job for an old name — queued or running — never decides the outcome. Archiving or renaming a dinner also stops its running job where it is, so no further search, page fetch, photo download or model call is made for it. A method, minutes_total or difficulty a member saved while the dinner was `pending` is kept; grounding fills only those left empty. At boot, after the job runner fails the jobs a previous process left running, every unarchived dinner still `pending` with no `ground_dinner` job for its revision queued or running is set `failed` with `interrupted`, so no dinner stays pending with nothing left to ground it. Photo backfill. `POST /api/v1/settings/photo-backfill` (CSRF-checked) enqueues a `backfill_dinner_photos` job and answers 202 `{id}` without waiting for it; while one is queued or running it enqueues nothing and answers with that job's id, so two passes never overlap, across restarts too. The kind is refused by `POST /api/v1/jobs`. The job grounds, one at a time with `Ground` on its name, every unarchived dinner that is `failed` with no photo and no pasted `recipe_url` — grounded, `manual` and `pending` dinners are never listed, and a pasted link is never replaced by a search. Success sets grounding_status `grounded` as the `ground_dinner` job does (the recipe keeps its source URL and site); failure leaves the dinner `failed` with the new `grounding_error`, for the fix-recipe flow. One dinner's failure never stops the others; a dinner renamed, archived or grounded by hand meanwhile is left as it is. Its result is `{dinners: [{dinner_id, outcome, reason}]}`, outcome `grounded`, `failed` (with its reason) or `skipped`. A dinner whose write fails is `failed` with `grounding_error` and left as it was; the others still run, then the job fails with `internal_error`, keeping that result. The responses returning a `dinners.Dinner` representation (the list; the create, update, archive, favorite and grounding responses; the idea responses) carry `photo_backfill_eligible`, and `GET /api/v1/dinners/{id}` does not; it says whether the backfill grounds that dinner, computed by the same condition it lists dinners by, so a client offers a search only where one runs. `GET /api/v1/dinners/{id}/recipe-candidates` searches for the dinner's name and returns `{candidates: [{candidate_id, title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground` ranks them; it fetches pages but downloads no photo and runs no model, so `photo_url` is set only for a page already stored. `candidate_id` is an opaque id the server resolves to the page it last offered that dinner (kept in memory, so a restart means fetching the candidates again) at the `grounding_revision` it was searched at, so a candidate offered before any rename is refused even if the name is changed back; the page's URL, which may carry a token, is never returned or cached. An unknown dinner is 404, recipe search not set up 409 `not_configured` and a search failure 502 `search_failed`; any other failure, such as a stored recipe that cannot be read, is 500, logged with a fixed reason and the dinner id only — never a candidate's URL or dependency error text. `POST /api/v1/dinners/{id}/ground` takes exactly one of `{candidate_id}`, `{recipe_url}` (both grounded with `GroundURL`, setting `grounded`) or `{manual: {ingredients[], steps[], minutes, method}}` (1–100 ingredient lines, up to 100 steps, each non-blank and at most 500 characters; minutes 1–1440). A manual recipe is stored as typed — no model runs — as a `recipes` row whose source_url is `mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org Recipe built from it, and sets `manual` with its method and minutes, no photo and the dinner's difficulty unchanged. Either clears the dinner's pasted `recipe_url`: the recipe chosen replaces it. It answers 200 with the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot ground it 422 `ungroundable` (the dinner is unchanged), recipe search not set up 409 `not_configured`, a search or model failure 502 `search_failed` / `ai_failed` (only the reason code is logged, never the provider's text), and grounding a candidate or link that runs past 90 seconds (fetch, photo and `normalize_recipe`, all in the request) 504 `ground_timeout`, leaving the dinner unchanged. An archived dinner is 409 `dinner_archived`. A candidate or link is written only if the dinner is still as it was before grounding began: one renamed or grounded some other way meanwhile is 409 `ground_conflict`, and one archived meanwhile 409 `dinner_archived`, either leaving the dinner unchanged. A method, minutes or difficulty a member edits meanwhile is kept over the recipe's. Any other failure grounding a candidate or link, or recording it on the dinner, is 500, logged with a fixed reason and the dinner id only — never the link, which can carry a personal token, or dependency error text. - Side quest suggestions (`suggest.Service`). `POST /api/v1/suggestions/batches` creates a `manual` batch and enqueues its `suggestion_batch` job, answering 202 `{batch_id, job_id, trigger, status}` without waiting; while a batch is `pending` or `running` it answers 202 with that batch instead, so at most one runs at a time (the check, the batch and its job share one write transaction). The job reads the household context, the deck's names and every declined name, runs `suggest_side_quests` for 5 Side quests (scope: the batch id), then grounds the survivors in the model's order with `Grounder.GroundMany`, as many as are still wanted at a time, until 5 are kept or none are left. A Side quest no recipe grounds is dropped. A `replenish` batch stores each round's grounded ones before the next; a `scheduled` or `manual` batch, which notifies, holds them and stores them all in one transaction once its last round is done. Each is stored as a dinner with status `suggested`, origin `side_quest`, grounding_status `grounded` with the recipe's recipe_id, photo_id, method, minutes_total and difficulty and the model's cuisine, and a `pending` suggestion with the name it is suggested under, the reason and `cited_regular_ids` — unless, since the job read its input, a dinner it repeats was added, a suggestion it repeats passed, or a Regular it cites renamed or archived (checked in the storing transaction). The job publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`, `grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the skill or `normalize_recipe` failed; no Side quest is made up instead, D-017), `search_not_configured` or `search_failed`, and the batch is set `failed`. A failed `scheduled` or `manual` batch has stored nothing; a failed `replenish` batch's Side quests stored before the failure stay in the deck. If recording the batch's end fails, its job fails; the job runner retries a job's final status write with backoff until it lands, each attempt bounded at 5 seconds; at shutdown an attempt that fails or times out is not retried, and the next start fails the job as interrupted. Recording the batch's end is bounded the same way (one attempt, 5 seconds, made even during shutdown), so a busy writer cannot hold a stopping worker; a batch whose end timed out is left `running` with its job. Each time a batch is started, before the active batch is selected, every batch still `pending` or `running` with no job queued or running (the process stopped, or recording its end failed) is set `failed`, so it does not block the next. - Cook from what I have (`ideas.Service`, Feature 18). `POST /api/v1/ideas/from-ingredients {ingredients[], quick_only}` (both required) checks the list with `skills.CleanIngredients` — 1 to 20 entries, each non-blank, at most 80 characters, with a non-empty `harness.Normalize` form that no earlier entry shares — answering 400 `invalid_request` otherwise, then enqueues an `ideas_from_ingredients` job (service-only: `POST /jobs` refuses the kind) and answers 202 `{jobId}` without waiting. The job runs `ideas_from_ingredients` (scope: `skills.IdeasScope`, the flag and the normalized list in order), then grounds its ideas in the model's order with `Grounder.GroundEach`, as many as are still wanted at a time, until 5 are kept or none are left. An idea no recipe grounds is dropped; with `quick_only`, so is one whose grounded recipe is not a Quick dinner (`ideas.IsQuick`: method `crockpot`, or `minutes_total` ≤ 30 — the recipe's, never the model's `est_minutes`). Ideas are not stored as dinners. The job publishes `{stage, ingredients, quick_only, ideas: [{name, uses, missing, summary, method, minutes_total, difficulty, quick, recipe_id, photo_url, source_url, site_name}], dropped}` at each stage (`suggesting`, `grounding`, `done`) and again as each idea is kept, the moment its grounding finishes, so the result fills idea by idea; the ideas shown are always in the model's order, an earlier idea that grounds later taking its place before those shown. It fails with `no_ideas_found` (every idea was dropped), `ai_failed` (the skill or `normalize_recipe` failed; no idea is made up instead, D-017), `search_not_configured` or `search_failed`. A failed job shows no ideas, even those kept before the failure: the kind registers a `FailedResult` view, so `GET /api/v1/jobs/{id}` empties the `ideas` of any failed job's result on read, however it failed (including `interrupted by restart` and an `internal_error` whose clearing write failed). Before it fails it also publishes its result again with no ideas, to tidy what is stored. - Quick dinners (`ideas.Service`, Feature 18). `POST /api/v1/ideas/quick {kind, exclude[]}` (both required; `exclude` is `[]` on a first page) checks `kind` with `skills.CheckQuickKind` — `crockpot` or `under30`, no default — and `exclude` with `skills.CleanExclude` — at most 60 names, each non-blank, at most 120 characters, with a non-empty `harness.Normalize` form no earlier name shares — answering 400 `invalid_request` otherwise, then enqueues a `quick_ideas` job (service-only) and answers 202 `{jobId}` without waiting. The job reads the household context (`harness.BuildContext`, budget 16000), runs `quick_ideas` (scope: `skills.QuickIdeasScope`, the kind and the normalized excluded names in order), then grounds its ideas exactly as the ingredients job does (the same `Grounder.GroundEach` rounds, model order and failure codes) until 6 are kept or none are left. The kind is enforced on the grounded recipe, never the model's estimate (`ideas.OfQuickKind`): `crockpot` keeps method `crockpot` (`normalize_recipe` writes a slow cooker as `crockpot`), `under30` keeps a known `minutes_total` ≤ 30; every other idea is dropped. The model is asked for 12, more than a page, so dropped ideas can be replaced; when fewer than 6 survive the page shows only those, never padded with an idea not of the kind. The job publishes `{stage, kind, exclude, ideas: [{name, method, minutes_total, difficulty, recipe_id, photo_url, source_url, site_name}], dropped}` at each stage and as each idea is kept, fails with the same reasons, and a failed job shows no ideas through the same `FailedResult` view. "↻ Show me more" posts again with every name the tab has shown so far in `exclude`. - Idea actions (Feature 18). Both act on idea `{i}` (0, 1, …; anything else is 400 `invalid_request`) of a **succeeded** `ideas_from_ingredients` or `quick_ideas` job, read from its stored result (`ideas.Service.Idea`): its name and `recipe_id`, never grounded again. A job still queued or running is 409 `ideas_not_ready` (its ideas may still move); an unknown job, another kind, a failed job or an index past its ideas is 404 `not_found`. `POST /api/v1/ideas/{jobId}/items/{i}/quest-log` (no body) runs `dinners.Service.AddToQuestLog`: in one transaction, an unarchived dinner the idea repeats — the same rule that refuses a duplicate Regular (`harness.Similar` name, or the recipe's link) — is answered 200 as it is (a Regular stays a Regular); one awaiting the household's choice (`suggested`, `tried`) is 409 `duplicate_dinner` and changes nothing; otherwise it creates a dinner with status `quest_log`, origin `ingredients` or `quick`, grounded in the idea's recipe (its method, minutes, difficulty and photo), answered 201. Repeating the call answers the same dinner. `POST /api/v1/ideas/{jobId}/items/{i}/cook-tonight` (no body) takes today as the household-local date in the household timezone and its Monday (`plan.Service.Tonight`); when that week has no plan it answers 409 `no_plan` and changes nothing — no dinner, no plan (a plan holds all seven days; the household plans the week first). Otherwise it adds or finds the dinner as `quest-log` does and puts it on today through `plan.Service.Swap`, exactly like a Draft board swap (plan marked edited, revision raised, a synced plan becomes `changed`, the day's calendar event id cleared, `is_new` for a Quest log dinner), answering 200 `{dinner, plan}`. The week is always today's own, so no other day is written; a dinner already on another day of the week is 409 `dinner_already_planned`. `GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id, name, photo_url, difficulty, method, minutes_total, cuisine, reason, cited_regular_ids, status, dinner_status, decided_at, created_at}], active_batch, failed_batch}`: the `pending` suggestions whose dinner is still `suggested`, oldest first; the batch `pending` or `running` (`{batch_id, job_id, trigger, status}`, or null); and the newest batch when it `failed` (same shape, or null; a later batch, done or under way, supersedes the failure), read in one snapshot, so a client that did not start the batch can follow its job and learns of a failure it was not following. At most one of the two is set. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the suggestion `accepted` and moves its dinner to `quest_log`; `POST /api/v1/suggestions/{id}/decline` (Pass) sets it `declined` and archives the dinner; every later batch, and the household context's passed list, reads the suggestion's own name as declined, so renaming the archived dinner changes nothing. Both record the session's subject as the dinner's `updated_by` and answer 200 `{suggestion, replenish_job_id}`. Repeating a decision changes nothing and answers 200; the opposite decision is 409 `suggestion_decided`, a suggestion whose dinner left the deck some other way 409 `dinner_not_suggested`, and an unknown id 404. A decision that leaves fewer than 5 in the deck starts a `replenish` batch, or joins the batch already pending or running, and returns that batch's job id either way (`replenish_job_id` is null when the deck keeps 5 or more). The batch and its job are written in the decision's own transaction, so a decision is never recorded without the replenish it calls for; when they cannot be written, neither is the decision, and a retry decides again. - Weekly plans (`plan.Service`). `{weekStart}` is a household-local Monday (`YYYY-MM-DD`); any other value is 400 `invalid_week`. `GET /api/v1/plans/{weekStart}` returns `{id, week_start, week_label, status, ai_note, calendar_synced_at, updated_at, days}`, or 404 when the week has no plan; `days` are the seven days Monday first, each `{date, dinner: {id, name, photo, minutes, method, origin}, is_new, start_by}`. `is_new` means the dinner was planned from the Quest log; `start_by` is the household's dinner time on that date minus `minutes_total`, computed on read, for a crock-pot dinner with known minutes, else null. `POST /api/v1/plans/{weekStart}/draft` takes `{confirm?}` and answers 202 `{jobId}` for a `draft_week` job, or for the one already queued or running for the week, so a week has at most one draft in flight. A confirmed draft while the week's job was asked for without confirm is 409 `draft_running` and enqueues nothing. The job runs the `draft_week` skill on every Regular and every Quest log dinner no other plan for the current or a later week has, with the household context and last week's dinners, and stores the week: a new plan is `draft`; a redraft keeps the `calendar_event_id` of each day whose dinner stays the same and clears it for the others; a `synced` or `changed` plan becomes `changed`. Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else 409 `confirm_required`; a plan synced while an unconfirmed draft runs is left as it is and the job fails `confirm_required`. The job reads the plan's revision before draft_week runs; if the plan was edited (a swap, or another draft stored) by the time the draft is stored, the plan is left as it is and the job fails `plan_changed`. The pool is read again in the same transaction that stores the draft; if a dinner the draft planned has left it meanwhile (archived, `tried`, or a Quest log dinner stored on another current or later week's plan, as by a draft of another week), nothing is stored and the job fails `pool_changed`, which drafting again resolves. A pool under seven dinners fails the job `not_enough_dinners`, draft_week failing `ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes `{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes `is_new`, clears the day's `calendar_event_id`, moves a `synced` plan to `changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and a dinner already on another day of the week 409 `dinner_already_planned`. - Rotation check (`plan.Service`, D-021). The `rotation_check` schedule calls `MarkTried` for the household-local day just past (see Scheduler). `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any status, to `tried`, opens a `pending` `rotation_reviews` row for it and emits its `rotation_review`, in one transaction; a dinner already checked for that date is left as it is and not notified again, so it is idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the pending checks whose dinner is still tried, oldest `plan_date` first, each `{dinner: {id, name, photo}, plan_date}`. `POST /api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes the dinner a Regular, `drop` ("Not for us") archives it and `not_made` ("We didn't make it") returns it to the Quest log; its origin is kept. The check is closed with `decided_at` and the session's `updated_by`, and the answer is `{dinner, plan_date, decision, dinner_status, decided_at, updated_by}`. Any other decision is 400 `invalid_decision`, an unknown dinner or a dinner never checked 404 `not_found`, a dinner whose checks are all decided (including a second decision) 409 `no_pending_review`, and one no longer tried 409 `dinner_not_tried`. Keep is the only path from tried to Regular. A drop records the dinner's name on the check (`rotation_reviews.name`), and that name joins the passed names (`ListPassedNames`) the Side quest dedupe and household context use, so it is never suggested again, even after the dinner is renamed. - `/health` — liveness. - Everything else serves the embedded PWA with SPA fallback. ## AI layer - **Provider interface** (`ai/llm`) with adapters for OpenAI-compatible, Anthropic and Ollama endpoints, called over plain HTTP — no vendor SDKs. - **Skill harness** (`ai/harness`): prompt layering, validation of the structured output, and **one repair retry**; if the repair also fails the run fails — no silent fallback. Every run writes one `ai_runs` row when it ends, covering the repair turn too, with status `ok`, `repaired`, `bad_output` or `provider_error`; a prompt that cannot be rendered is `bad_output` and a provider that cannot be built is `provider_error`, both with no turns. The harness builds the provider itself from the settings and the decrypted API key (`presets.FromSettings`), so the key it redacts is always the key the provider sends. Request and response bodies are redacted before they are stored: the decrypted API key, Authorization and API-key header values, Google Chat webhook URLs and URLs with key-like query parameters are removed, inside JSON strings too; the skill, provider and model columns are redacted the same way. Writing the row is bounded: a run whose work ended in time must write its row before the skill's deadline or it fails with `llm.ErrTimeout` and its row records the timeout; a failed run's row has a further fixed bound, and if even that write fails the run returns the write error with its own and no row is stored. A stored row's status therefore always matches what the caller received. `harness.Purge` deletes rows older than `harness.RunRetention` (30 days), clearing the `suggestion_batches` and `week_plans` references first. The daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with its fire time less `RunRetention`. - **Concurrency guards** (`harness.Guard`, one per process): `harness.Run` takes a scope key, and concurrent runs of one skill with one scope share a single in-flight run and its result, error included (single-flight); runs with different scopes are independent. A semaphore lets at most `harness.DefaultMaxCalls` (2, set by `NewGuard`) LLM calls run at once; waiting for a slot honours the context. Each skill declares a timeout (`harness.DefaultTimeout`, 60 s, unless it needs another) bounding the whole run, slot waits and repair turn included; a run past it fails with `llm.ErrTimeout`. A caller whose context ends gets the context error; the shared run is cancelled, freeing its slot, once no caller waits for it. - **Prompt layering** (D-007): each skill version's prompt is a `text/template` file embedded from `ai/skills/prompts/` (`.v.tmpl`). The system prompt is always, in order: [1] the admin persona (`ai_settings.system_prompt`), [2] the skill's instructions, [3] the household context block, [4] the output contract with the JSON Schema — the contract is always last. The user message comes from the skill's input. `checksums.txt` pins every template; changing one without a version bump fails the tests. - **Household context** (`harness.BuildContext`): layer 3 holds today's date and weekday in the household timezone, the Regulars, the Quest log, the last 60 passed Side quests and the plans of the last four ISO weeks. A character budget trims the oldest plans first, then the oldest passes; Regulars and the Quest log are never trimmed. - **Name dedupe** (`harness.Normalize`, `harness.Similar`, `harness.Deduper`): a dinner name is normalized by lowercasing, dropping combining marks and folding precomposed letters to their base letters through a table generated from Unicode data (`gen_folds.py`), so composed and decomposed spellings match, stripping punctuation, dropping filler words (easy, best, homemade, quick, simple, the, a, recipe, classic) and collapsing whitespace. Two names are the same dinner when the token-set (Jaccard) similarity of their words is at least 0.85. A `Deduper` over the Regulars, Quest log, pending suggestions and passed names rejects a suggestion that repeats any of them. - **Side quest suggestions** (`skills.SuggestSideQuests`, skill `suggest_side_quests` v1): asks for twice the Side quests needed, at the household temperature, with the household context plus the pending Side quests. Malformed replies (bad JSON, a missing field, a bad enum, an empty name) get the one repair turn. Well-formed Side quests that break a rule are dropped, not repaired, and listed in `Dropped` with the rule: one that repeats a Regular, Quest log dinner, pending Side quest, any passed name (the whole history, not only the 60 in the context) or an earlier Side quest of the batch; one citing no Regular or a name that is not a Regular; and one whose reason is over 180 characters or names none of its cited Regulars. Cited names are rewritten as the household writes them. Grounding each survivor in a recipe is the caller's next step. - **Ideas from ingredients** (`skills.IdeasFromIngredients`, skill `ideas_from_ingredients` v1): from the household's ingredient list (the "Ingredients on hand" context section) and the Quick only flag, asks for 8 ideas `{name, uses[], missing[], est_minutes, method, difficulty, search_query}` at the household temperature. Malformed replies (a missing field, a bad enum, an empty name, more than 8 ideas) get the one repair turn. The invariant is enforced in Go: a `uses` entry is kept only when its `harness.Normalize` form is a listed ingredient's, and is then written as the household wrote it, once, in the household's order; `missing` loses blanks, repeats and anything listed. An idea left using none of the listed ingredients, or repeating an earlier idea (`harness.Similar`), is dropped into `Dropped`. Each kept idea's summary line (`skills.IdeaSummary`) is computed from the corrected lists: "Uses all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of 5 · missing: …" otherwise, with "nothing missing" in place of the list when nothing is. - **Quick ideas** (`skills.QuickIdeas`, skill `quick_ideas` v1): from the household context, a kind (`crockpot` or `under30`, which picks the prompt's rule) and the names already shown (the "Already shown" context section), asks for 12 ideas `{name, est_minutes, method, difficulty, search_query}` at the household temperature. Malformed replies (a missing field, a bad enum, an empty name, more than 12 ideas) get the one repair turn. An idea repeating an excluded name or an earlier idea (`harness.Similar`, on the normalized names) is dropped into `Dropped`, so an excluded name is never returned. Whether an idea is of the kind is left to the grounded recipe (see Quick dinners above). - **Supply run titles** (`skills.TitleArchive`, skill `archive_title` v1): from the names of the week's planned dinners in day order, writes `{title}`, the Supply run's post title in the Supply archive, at the household temperature with no household context. A title must be 1–48 characters on one line, with no emoji and no surrounding quotation marks; a reply breaking a rule gets the one repair turn, never a truncation. `shopping.Generate` (and `GeneratePushed`), on storing a list that is still untitled — a new list, or one a failed title left untitled — enqueues a `title_supply_run` job in the generation's transaction and returns without waiting for it, so neither Push to Calendar nor `POST /shopping/{weekStart}/generate` waits for a model call; the list reads untitled until the job stores its title. The job's scope is `@/`, so runs share a title only for the same names (a renamed dinner changes the names, not the revision). It writes `shopping_lists.title` only while the list is untitled and not frozen. A title is written once; a remake keeps it and enqueues no job. A failed title never fails the generation: the list stays untitled (the UI shows the week label), the failure is logged as a warning and recorded in `ai_runs`, the job fails `title_failed`, and the week's next generation tries again. `shopping.Freeze` titles a list still untitled once more before freezing it (see Supply archive), through the same skill, scope and conditional write. ## Supply archive (Feature 17) `shopping.Freeze(weekStart)` moves a week's Supply run into the Supply archive: `status` becomes `archived` and `frozen_at` is set, after which generating the list or changing an item is 409 `archived`. It freezes only a week that has ended in the household's time zone (its Monday is before the Monday of the household's current week), so a week not yet ended is `ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a frozen list does nothing. A list still untitled is titled first with `archive_title` from the week's plan, as `Generate` titles it; a title that cannot be written is logged and the list is frozen untitled, when its week label stands in for good. Titling and freezing are ordered by a per-service `RWMutex`: a `title_supply_run` job holds it shared across reading the list, the `archive_title` run and the title write, and `Freeze` holds it exclusively from reading the list to freezing it. So a freeze waits for a job in flight to store its title or fail for good; when the job failed, the freeze finds the list still untitled and makes its own `archive_title` attempt before freezing. A job that starts during a freeze waits for it, then finds the list titled (that title stands) or frozen untitled (the job fails `title_failed`), and stores nothing. The `week_rollover` schedule (`shopping.Service.RolloverSchedule`) calls `Freeze` for last week, judging the week ended by its fire time. ## Recipe grounding - Dinners are grounded in real recipe pages found through recipe search (`searxng` or `brave`, per `recipe_search_settings`). - Fetching is **SSRF-safe** (`recipes.Fetcher`): https:// only, at every redirect hop. Its dialer resolves the host once and refuses unless every answer is public — every non-globally-reachable IPv4 special-purpose range (loopback, RFC 1918, link-local, carrier-grade NAT, documentation TEST-NETs, benchmarking 198.18/15, multicast, reserved and more) is refused, IPv4-mapped forms included, and IPv6 must be global unicast (2000::/3) outside documentation, 6to4, Teredo and other special ranges — then dials the address it checked, so a rebinding DNS answer cannot swap in a private one. Keep-alives and environment proxies are off, so every request dials and checks afresh. At most 3 redirects; a body over 2 MB is an error, never truncated; 10 s for the whole fetch; a descriptive `User-Agent`; requests to one host at least 1 s apart, counted from requests actually sent (an abandoned wait holds no slot); 2xx pages cached by URL for an hour (64 pages, oldest evicted), and callers get their own copy of a cached body. - Recipe data is parsed from schema.org `Recipe` JSON-LD (`recipes.Extract`): a small targeted scan of the page's `application/ld+json` scripts and `og:site_name` (no HTML parser dependency), finding the Recipe node at a script's top level, in a top-level array or in an `@graph`, with `@type` a string or an array of terms or absolute schema.org IRIs (`https://schema.org/Recipe`). An image or publisher given as an `{"@id": ...}` reference resolves to the node with that `@id` anywhere in the page's JSON-LD. The largest image (by ImageObject width × height; the first of equals) is resolved to an absolute URL; instructions — one string of lines, or a list of strings, HowToSteps and HowToSections — are flattened in order; ISO-8601 prep/cook/total times become minutes, total derived as prep + cook when only those are given; the site name is the publisher's, else `og:site_name`, else the host. A page with no Recipe is `ErrNoRecipe`, with two is `ErrManyRecipes`; a shape schema.org does not allow — such as a non-ISO-8601 duration like "20 minutes" or an empty one, or an image `@id` no node has — is `ErrMalformedRecipe`, never guessed at. - Recipe search (`recipes.SearchProvider`: `Search(ctx, query, n)` → `[]Candidate{URL, Title, Snippet}`, n 1–20) appends " recipe" to a query without that word. SearXNG is called at `{recipe_search_settings.base_url}/search?q=…&format=json&categories=general` — directly, not through the Fetcher, since this configured endpoint may be on the internal network; Brave calls the Brave Web Search API with the key decrypted from `recipe_search_settings.api_key_ct` (`recipes.NewSearchProvider`). Redirects are not followed; 401/403 is `ErrSearchRefused`, 429 `ErrSearchRateLimited`, other non-2xx a `SearchStatusError`, and a body that is not the provider's JSON (SearXNG without a `results` array; Brave without `"type": "search"` or with a `web` object lacking `results` — Brave omits `web` when nothing matched), over 1 MB or with a result lacking an absolute http(s) URL `ErrSearchResponse`. - Ranking (`recipes.Rank`) fetches and extracts the top 5 candidates concurrently, rejects any whose recipe lacks ingredients or an image, and orders the rest by the token similarity of the recipe's title to the dish name (`harness.Similarity`, 0–1) plus a quarter for each of ingredients, image, steps and total minutes; ties keep search order. No unranked candidate is dropped silently: when some are ranked, they are returned with `ErrCandidatesUnranked`; when none are, the error is `ErrNoUsableRecipe`. Either is joined with each unranked candidate's URL and reason (fetch failure, no valid recipe, missing ingredients or image), so the caller decides per item what a failure means. - Ingredient lines are normalized by the `normalize_recipe` v1 skill (`skills.NormalizeRecipe`), always at temperature 0 whatever the household's setting. From the title, raw ingredient lines, steps and JSON-LD times it returns `{ingredients: [{name, qty, unit, note}], method, difficulty, minutes_total}`. Validation requires exactly one ingredient per input line, in order (a name matching another line but not its own is out of order), each with a name; a mismatch gets the harness's repair turn. Code-derived values win: a positive JSON-LD total is `minutes_total`; "slow cooker" or "crock pot" in the title or a step makes the method `crockpot`; a difficulty the model leaves null is derived — hard over 10 steps or 90 minutes, easy at 5 steps or fewer and 45 minutes or less, else medium, a crockpot's minutes not counting. - Photos are cached locally (`photos`) and processed with `golang.org/x/image`. `recipes.PhotoCache.DownloadPhoto` downloads through the Fetcher's `FetchImage` (JPEG, PNG, WebP or GIF content types, 8 MB cap, not cached), decodes the image (a GIF's first frame) and refuses a decoded image under 300 px on its short side, recording nothing. `CachePhoto` then records it in the caller's transaction: it scales the image so its long side is 1200 px and 400 px, and stores each as JPEG quality 82 at `$MEALQUEST_DATA_DIR/media/photos/{id}-{size}.jpg`. Images are deduplicated by the SHA-256 of the downloaded bytes; the writer's single connection serialises transactions, so one image is never recorded twice. Files written for a transaction that rolls back are removed by the caller (`RemovePhotoFiles`). - Grounding (`recipes.Grounder`) ties these together. `Ground(ctx, dishName, query)` searches for the top 5 candidates and ranks them all, stored and new alike: a candidate whose `source_url` is already stored is scored on its stored recipe (title, ingredients, steps, times, photo source) and its page is never refetched; every other candidate is fetched and extracted. Ranking alone decides between them. It then tries each ranked candidate best first. A stored one is returned as it is, so a stored recipe is reused only when it ranks above every new candidate that grounds. A new one is grounded in this order: (1) `DownloadPhoto` — the download and validation step of photo caching — (2) `normalize_recipe`, then (3) one **transaction** that runs `CachePhoto` (scales and writes the photo files and records the `photos` row) and stores the recipe, ingredients and steps (an existing `source_url` is updated in place); files written for a rolled-back transaction are removed. It returns `Grounded{recipe_id, photo_id, method, minutes_total, difficulty, source_url, site_name}`. A candidate whose photo fails or whose model output is unusable (`llm.ErrBadOutput`) is passed over; when none grounds, the error is a typed `*recipes.ErrUngroundable` listing every reason (no candidates, Rank's per-candidate reasons, photo or normalize failures). Failures that are not the candidates' — search, model provider, database — are returned as they are. `GroundURL(ctx, url)` does the same for one pasted URL. `Candidates(ctx, dishName, query, n)` returns the first n of the candidates `Ground` would try, without grounding any. Search failures wrap `recipes.ErrSearchFailed` and model provider failures `recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per item, so one failure never affects its siblings; `GroundEach` grounds the same way but reports each item, one at a time, as it finishes. A recipe is usable only with ingredients, an image and, if given, a positive total time. ## Scheduler Times are in the household timezone. The times of `day_of_reminder` (`reminder_time`) and `rotation_check` (`review_time`), and the day and time of `suggestions_weekly`, `plan_draft` and `shopping_chat_autosend`, are household settings in `household_settings` (`dinner_time` is also stored there); the table shows their defaults. `week_rollover` and the `ai_runs` purge are fixed and not configurable. | Schedule | When | Does | | --- | --- | --- | | `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). | | `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). | | `shopping_chat_autosend` | Saturday 08:00 | Sends the coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). | | `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). | | `rotation_check` | Daily 09:00 | Marks yesterday's Quest log dinners tried and asks "Keep it in rotation?" (`plan.Service.RotationCheckSchedule`). | | `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). | | `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). | Runs are recorded in `schedule_runs` and executed through `jobs`. `internal/scheduler` runs them. A schedule registers a name, a rule read from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`, or `DailyAt` for a time read from data) and a run function; settings changes reach it through `SettingsService.Subscribe` and move the next fire time without a restart. Each fire time has a period key (`Rule.PeriodKey`): the local date for a daily rule, the ISO week of the fire date for a weekly one, or of the week after for `WeeklyForNextWeek` (`plan_draft`). Inserting `schedule_runs(schedule, period_key)` is the claim; a period already claimed is skipped, so a period runs at most once and a failed run is not retried. A claim that fails is not skipped: the pass is repeated every `scheduler.ClaimRetry` until the claim succeeds. A settings change is recorded with the instant it was saved, and fire times up to that instant follow the old rule and later ones the new, however late the scheduler applies it. At boot, and whenever the scheduler wakes late, every unclaimed fire time within 24 hours (`scheduler.CatchUpWindow`) is processed oldest first, each running once. A most recent fire time that passed longer ago is claimed without running (logged as missed) so it never runs later; older ones are not looked at. DST follows `household.LocalTime`: a skipped time fires later by the gap, a repeated time fires once, at its first occurrence. Each schedule is built by the service that does its work, as a `scheduler.Schedule` value, and registered in `cmd/server` before the scheduler starts: `suggest.Service.WeeklySchedule()` is `suggestions_weekly`. Its run calls `StartScheduledBatch(period.Week)`, which starts the same batch and job as `POST /api/v1/suggestions/batches` (`StartBatch(manual)`), with the `scheduled` trigger, and returns at once; the batch runs as a job. When a batch is already pending or running the period starts none and logs it. `plan.Service.DraftSchedule()` is `plan_draft`, keyed by the ISO week after the fire date (`scheduler.WeeklyForNextWeek`). Its run calls `StartScheduledDraft(period.Week)`, which enqueues the same `draft_week` job as `POST /api/v1/plans/{weekStart}/draft` without confirm, so a stored draft emits `plan_ready:{weekStart}` from the job and a failed one (e.g. `ai_failed`, recorded in `ai_runs`) emits nothing. When the week already has a plan, in any status, it enqueues nothing; when a draft of the week is already queued or running it joins it. Either way it logs it. `plan.Service.ReminderSchedules()` is `day_of_reminder`, two schedules of one name. The first is `Daily(reminder_time)`, period `YYYY-MM-DD`, and calls `EmitDayOfReminder(period.Date)`. The second has `Key` `start`, so its periods are `start:YYYY-MM-DD`, and a `scheduler.DailyAt` rule: for each date it reads the plan and fires 15 minutes (`plan.StartReminderLead`) before the day's `start_by` (the same computation the plan API shows) when the dinner is a crock-pot dinner whose `start_by` is earlier than that day's `reminder_time`; its run calls `EmitStartReminder(period.Date)`, which checks the same again. A date whose week has no plan emits neither and logs it. A `DailyAt` fire time is looked up from the data on every pass. A committed change that can move a `start_by` calls `Scheduler.Wake` (passed to `plan.NewService` and `dinners.NewService` as `wake`): a plan swap or stored draft, and a dinner's method or minutes changed by edit or grounding. The next pass comes at once, so a crock-pot dinner swapped in for tonight is still reminded 15 minutes before its `start_by`, not at the next hourly timer (`maxWait`); a lookup that fails is logged and repeated every `ClaimRetry`. `shopping.Service.ChatAutosendSchedule()` is `shopping_chat_autosend`, `WeeklyForNextWeek(chat_autosend_dow, chat_autosend_time)`, so its period names the coming week. Its run calls `Autosend(period.Week)`, which posts with `SendToChat(week, false)`, the household's own send, only when a webhook is stored, `list_ready`'s Chat channel is on (`notify.Service.ChannelOn`) and the week has a Supply run neither sent nor with a send unconfirmed; otherwise it posts nothing and logs why, with the key `list_ready:chat:{weekStart}`. It never resends; a send that fails is the period's error. `shopping.Service.RolloverSchedule()` is `week_rollover`, `Weekly(Monday, 00:05)`; its run freezes the week before the fire date's, and a week with no Supply run freezes nothing and logs it. `plan.Service.RotationCheckSchedule()` is `rotation_check`, `Daily(review_time)`, claimed as `rotation_check:YYYY-MM-DD` of the fire date (`Schedule.PeriodKey`). Its run calls `MarkTried` for the day before the fire date, which emits one `rotation_review` per Rotation check it opens; it never makes a dinner a Regular, and a day with no Quest log dinner planned opens none and logs the count. `harness.PurgeSchedule(store, logger)` is `ai_runs_purge`, `Daily(03:00)`, deleting rows created more than 30 days before the fire time. Fake-clock tests of a schedule live in `internal/scheduler`, where `SetClock` is visible to tests. ## Notifications - Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`, `rotation_review`; each can go to Web Push and/or Google Chat per `notification_prefs`. - Where each event is emitted, with its idempotency key. Each is stored by `notify.EmitTx` in the transaction of the change it is about, so the change and its notification commit together or not at all: - `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job, asked for by the API or the `plan_draft` schedule, stores the AI's draft of a week. Only the first draft of a week notifies: a redraft (Rebuild week) has the same key, and a manual swap never drafts. Its title is "Next week's plan is ready" (or "This week's", or "The plan for the week of Oct 26") and its body the drafted dinners Monday to Sunday. - `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply run is first created, by `POST /shopping/{weekStart}/generate` or after Push to Calendar; remaking it does not notify. - `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it was scheduled for (carried in its job, so a batch caught up late is still keyed by its own week), or `new_suggestions:{batchId}` for a `manual` one (URL `/discover`), once, when the batch's last round is done, in the one transaction that stores all its Side quests, so they and their notification commit together or not at all. Its title counts every Side quest the batch stored ("5 new Side quests are in") and its body names them. A batch that fails at any round, as when `suggest_side_quests` or a later round's `normalize_recipe` fails (the failure is in `ai_runs`), stores no Side quest and sends nothing, so no stored Side quest goes unannounced. A `replenish` batch never notifies. This is the only place `new_suggestions` is emitted; the `suggestions_weekly` schedule does not emit it itself. - `day_of_reminder:{date}` (URL `/dinner/{id}`) through `plan.Service.EmitDayOfReminder(date)`, "Tonight: {dinner} · Recipe →", naming the dinner planned on the date; it emits nothing for a week with no plan. The `day_of_reminder` schedule calls it at `reminder_time`. - `day_of_reminder:start:{date}` (URL `/dinner/{id}`) through `plan.Service.EmitStartReminder(date)`, "Start the crock pot by {time}" with the dinner's name, only for a crock-pot dinner whose `start_by` is earlier than `reminder_time`. The `day_of_reminder` schedule calls it 15 minutes before `start_by`. - `rotation_review:{dinnerId}:{planDate}` (URL `/`, where the Home card asks) through `plan.Service.MarkTried(planDate)`, "How was {dinner}? Keep it in rotation?", once per Rotation check it opens. The `rotation_check` schedule calls it at `review_time` for yesterday. - Delivery goes through `notification_outbox`. - Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming webhook. - VAPID configuration (`MEALQUEST_VAPID_PUBLIC_KEY`, `MEALQUEST_VAPID_PRIVATE_KEY_FILE`, `MEALQUEST_VAPID_SUBJECT`) is required at boot, all three together. Both keys are unpadded base64url P-256 keys (`internal/vapid`): the public key the 65-byte uncompressed point, the private key the 32-byte scalar, read only from its file. Boot derives the public point from the private key and refuses a pair that does not match. The subject is `mailto:
` or an `https://` URL. `go run ./cmd/vapidgen` prints a new pair. - `internal/push` keeps the subscriptions, one row per endpoint, under the signed-in `/api/v1`: - `GET /api/v1/push/vapid-public-key` answers `{"public_key": "…"}`, the browser's `applicationServerKey`. The private key is never served. - `POST /api/v1/push/subscriptions` (CSRF-checked) takes `{"endpoint", "keys": {"p256dh", "auth"}, "user_agent"?}` — the endpoint an `https://` URL, the keys unpadded base64url as `PushSubscription.toJSON()` gives them (p256dh a P-256 point, auth 16 bytes); unknown fields such as `expirationTime`, and nulls, are refused. It upserts by endpoint: an endpoint already stored keeps its id and `created_at`, has its keys and user agent replaced and its failure count cleared. It answers 200 with `{id, endpoint, user_agent, created_at, updated_at}`, never the keys. - `DELETE /api/v1/push/subscriptions` (CSRF-checked) takes `{"endpoint"}` and answers 204 whether or not it was stored. - An invalid body is 400 `invalid_request` (not a JSON object of the known fields) or `invalid_subscription` (a field fails validation). - `POST /api/v1/settings/notifications/test-push` (CSRF-checked) checks delivery to the household's devices. It queues "MealQuest test notification" through `notification_outbox`, so it takes the real delivery path with its retries. It is a `plan_ready` event whose tap opens `/plan`. Its key is `plan_ready:test-`, new on every call, so `Emit` never deduplicates it. It goes to the `push` channel only (`notify.Service.EmitPush`), never to the family Google Chat space. It answers 202 `{event_key, subscriptions}`. Two cases send nothing and answer 409 rather than report success: no device has subscribed (`no_subscriptions`), or push is switched off for `plan_ready` in the preferences (`push_off`). - `internal/chat` keeps the Google Chat incoming webhook in `chat_settings` and is the outbox's sender for the `chat` channel. The webhook URL holds its own key and token, so it is sealed with `secretbox` in `webhook_url_ct` and never appears in a response, log line or error. Under the signed-in `/api/v1`: - `PUT /api/v1/settings/chat-webhook` (CSRF-checked) takes `{"url", "space_label"}`, both required. The URL must be exactly `https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…`: that scheme and host spelled so, no port or user info, the space id letters, digits, `-` or `_`, and the raw query exactly `key=…&token=…` (in that order, no empty segment, no second `?`, no other parameter), each decoding to at least 16 characters of letters, digits, `-`, `_`, `.`, `~` or `=`. The space label is 1–100 characters. A refused body is 400 `invalid_request` (not a JSON object of the two fields) or `invalid_webhook`. Saving replaces any stored webhook and clears its test outcome. - `GET /api/v1/settings/chat-webhook` answers `{space_label, url_masked, last_test_at, last_test_status}`, so `url_masked` is `null` exactly when no webhook is stored; `url_masked` shows the space id and only the last four characters of the decoded token (`…/spaces/AAQA…/messages?key=…&token=…ZxQ=`). Absent values are `null`. - `POST /api/v1/settings/chat-webhook/test` (CSRF-checked) posts "MealQuest · test message", unthreaded, and records `last_test_at` and `last_test_status` (`ok` for 2xx, else `failed`). Whatever status Google Chat answers, it answers 200 with that status as `http_status` and the settings as GET gives them. No webhook stored is 409 `not_configured`; a webhook replaced during the test is 409 `webhook_changed`; no answer from Google Chat is recorded as failed and answered 502 `chat_unreachable`. - A notification is posted as `{"text"}`, plus `cardsV2` with an "Open" button to the payload's path under `MEALQUEST_PUBLIC_URL` for `plan_ready` and `list_ready`, with `threadKey=mealquest-week-YYYY-Www` (the ISO week of the payload's week) and `messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`; a payload with no week is posted unthreaded. 2xx is sent; 429, 5xx or no answer is retried by the outbox; any other status, or no webhook stored, fails the row at once (`notify.ErrUndeliverable`). Redirects are not followed. - The builders in `internal/chat/messages.go` make each event's `notify.Event` (title and body shared with Web Push) and `chat.Text` its Chat message, such as "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →". ## Google Calendar - One-way push: the app is the source of truth; edits in Google Calendar do not come back. - Connected once with Google OAuth in Settings; one event per planned dinner. - `internal/gcal` owns the connection, as a confidential OAuth web client (`MEALQUEST_GOOGLE_CLIENT_ID`, `MEALQUEST_GOOGLE_CLIENT_SECRET_FILE`, both required at boot) over plain REST — no Google SDK. Every route is under the signed-in `/api/v1`: - `GET /api/v1/integrations/google/connect` redirects to Google's consent screen with scopes `calendar.events` and `calendar.calendarlist.readonly`, `access_type=offline`, `prompt=consent` and PKCE. Its `state` travels in the signed `__Host-mealquest_google` flow cookie (the same `auth.FlowCookie` mechanism as the login cookie), bound to the session by a hash of the session's CSRF token. The service also keeps each started connect's `state` in memory until the cookie expires; the callback consumes it, so a replayed cookie is refused (`invalid_state`), and a restart mid-connect means connecting again. - `GET /api/v1/integrations/google/callback` (the redirect URI: `MEALQUEST_PUBLIC_URL`'s origin plus this path) checks the state and session, exchanges the code, refuses a response with no refresh token (`reconnect_required`) or without both scopes, seals the refresh token with `secretbox` into `integrations_google.refresh_token_ct`, sets `connected_at` and redirects to `/settings/calendar`. A reconnect keeps the chosen calendar and clears `last_sync_error`. - `GET /api/v1/integrations/google` answers `{status, calendar_id, calendar_name, connected_at, last_sync_error}`, where `status` is `not_connected`, `connected` or `reconnect_required` (connected, but Google refused the grant and its refresh token was cleared); absent values are `null`. It never holds a token. - `DELETE /api/v1/integrations/google` (CSRF-checked) revokes the token at Google, then deletes the row and, in the same transaction, forgets every pushed event (see Push to Calendar); a token Google reports as `invalid_token` counts as revoked, and any other revoke failure keeps the connection and its pushed events. A connection that requires a reconnect has no token to revoke and is deleted the same way. - `GET /api/v1/integrations/google/calendars` trades the stored refresh token for an access token and reads every page of the Calendar API's `users/me/calendarList`, answering `[{id, summary, primary, accessRole}]` for the calendars whose `accessRole` is `writer` or `owner`, in Google's order. Nothing else from Google's response is passed on. - `PUT /api/v1/integrations/google/calendar` `{calendar_id}` (CSRF-checked) lists the calendars the same way and stores `calendar_id` and `calendar_name` (the calendar's `summary`) only when the id is among them, answering `{calendar_id, calendar_name}`; any other id is 422 `unknown_calendar` and leaves the stored choice as it was. A calendar chosen earlier that has since been deleted or become read-only drops out of the listing and cannot be chosen again; the stored choice stays until the household chooses another. - Listing and choosing answer 409 `not_connected` when nothing is connected, and fail on Google as every Calendar call does (below). - **Token failures**, the same for listing, choosing and pushing: - When Google refuses the grant — `invalid_grant` on the token refresh, or HTTP 401 from the Calendar API to the access token just refreshed — the connection becomes reconnect required, under the account lock: `refresh_token_ct` is cleared and `last_sync_error` set to "Google access was revoked or expired — reconnect", and the call answers 409 `google_reconnect_required`. Later calls answer the same without contacting Google, until a reconnect stores a new token (and clears `last_sync_error`) or a disconnect deletes the row. The chosen calendar and every stored `calendar_event_id` are kept: the pushed events are still on the chosen calendar, so the invariant below holds, and the first push after reconnecting updates them in place. - HTTP 429 and 5xx from the token endpoint or the Calendar API are retried at most twice (three tries), waiting 0.5 s then 1 s, still under the account lock. If the last try fails the same way the call answers 503 `google_unavailable` with `retryable: true`; nothing is cleared or disconnected. An event insert retried after Google took it is answered 409 and updated, so a retry never adds a second event. - Any other Google failure is 502 `google_failed`. - Connect's exchange-and-store, disconnect's read-revoke-delete and every use of the stored token (listing and choosing calendars) hold one lock across the Google requests and the database write: Google revokes the whole grant, so a revoke overlapping a reconnect would otherwise leave a stored token Google has already revoked. - No response, log line or error ever holds a token or Google's response text. - Push to Calendar: `POST /api/v1/plans/{weekStart}/push-calendar` (CSRF-checked, `plan.Service.Push` writing through `gcal.Service.PushEvents`) answers 200 with the plan as synced. Each day is one event in the chosen calendar: summary `Dinner: {name}`, from the household `dinner_time` on that date for 60 minutes, as `dateTime` with the household `timeZone` (so its UTC instant follows a DST change within the week), and a description of the dinner's name, `Start by {time}` for a crock-pot dinner of known minutes, and `{MEALQUEST_PUBLIC_URL}/dinner/{id}`. - **Deterministic event ids**: lowercase unpadded base32hex of SHA-256 of `mealquest|{weekStart}|{date}` — 52 characters of `a`–`v` and `0`–`9`, within Google's id rule. Each event is inserted; a 409 (the id exists, even as an event deleted in Calendar) updates it instead, with status `confirmed`, so pushing again never adds a second event. - A push writes the days whose `calendar_event_id` is empty (never pushed, swapped, or redrafted to another dinner). When none is, it writes every day again, which puts back anything changed in Calendar. - A stored `calendar_event_id` is always an event on the chosen calendar. Choosing a calendar other than the stored one, and disconnecting (which leaves none chosen; connecting again chooses none either), clear every day's `calendar_event_id` in the same transaction as the choice or the delete, and edit each plan that had one (revision raised, `synced` → `changed`), so the next push writes the whole week to the calendar then chosen and a push that read such a plan before is refused (409 `plan_changed`). A reconnect while connected keeps the chosen calendar, so it keeps the event ids. Nothing else clears the choice: a grant Google refuses clears only the stored token (see Token failures), so the event ids stay on the calendar still chosen. - Only a push that wrote all its events is recorded, in one transaction: the plan becomes `synced` with `calendar_synced_at`, and each day written stores its event id. A push that failed part way records nothing and leaves the status as it was; pushing again updates the events already written. The record runs under the account lock, in the same hold as the event writes, so the chosen calendar cannot change between them. A plan swapped or redrafted while it was being pushed, or whose events another calendar choice cleared (its revision moved), is not recorded: 409 `plan_changed`. - Errors: 409 `not_connected`, 409 `no_calendar` (connected, no calendar chosen), 409 `calendar_unavailable` (the chosen calendar was deleted — Google 404 — or is no longer writable — 403 `requiredAccessLevel`; choose another), 409 `google_reconnect_required`, 503 `google_unavailable` (retryable) and 502 `google_failed` (see Token failures), 404 when the week has no plan, 400 `invalid_week`. The push holds the account lock across its Google requests and its record, like listing and choosing. - The week's Supply run (Feature 17) is made from a pushed plan: after a successful `Push`, the handler runs `shopping.GeneratePushed` for the week at the plan revision `Push` pushed, and the 200 answer carries `supply_run: {status, error}`. `status` is `generated`; `archived` when the week's Supply run is in the Supply archive and was left as it was; or `failed` — error `plan_changed` (retryable, logged as a warning) when the plan was edited between the push and generation, so no Supply run is made from a plan Calendar does not show and pushing again makes it; or error `supply_run_failed` (retryable, logged as an error) when it could not be made, and `POST /api/v1/shopping/{weekStart}/generate` makes it again. Generation checks the revision inside its own transaction, and never fails or undoes the push — Calendar already shows the plan. ## Supply run API (Feature 17) - `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title, status, sent_to_chat_at, chat_send_unconfirmed_at, needed_count, total_count, groups: [{dinner: {id, name}, date, day, items: [{id, name, qty, needed}]}]}`: `label` is the week label (shown while `title` is null), `day` the plan day's weekday ("Monday"), `qty` null when the recipe gives none, `needed_count` the items still needed and `total_count` all items. 404 when the week has no Supply run, 400 `invalid_week`. - `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list through `shopping.Generate` and answers with the same shape plus `skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week has no plan, 409 `archived` when its list is in the Supply archive. - `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes `{}` or `{"resend": true}` and posts the Supply run to Google Chat through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and its title (or week label), each dinner with items still needed and those items, and a card whose "Open" button links to `/plan/shopping?week={weekStart}` under `MEALQUEST_PUBLIC_URL`. It records `chat_send_unconfirmed_at` before posting, then `sent_to_chat_at` (clearing it) once Google Chat accepts, and answers with the Supply run as `GET` gives it. A week already sent, or with a send unconfirmed, is answered as it is, with no second post, unless `resend` is true. No webhook stored is 409 `not_configured`; Google Chat answering with a status other than 2xx is 502 `chat_refused` (retryable only for 429 or 5xx), clearing the send begun; Google Chat not answering, or accepting a post that cannot then be recorded, is 502 `chat_unconfirmed` (not retryable: the post may have been made), which leaves `chat_send_unconfirmed_at` set, as does the process stopping mid-send. The screen shows an unconfirmed send and sends again only on request. 404 when the week has no Supply run, 409 `archived` when it is in the Supply archive, 400 `invalid_request` for a bad body. `shopping.SendToChat(weekStart, resend)` is what `shopping_chat_autosend` calls, through `Autosend`. - `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required boolean; it sets the flag, it does not flip it) and answers `{id, needed, needed_count, total_count}` with the list's counts after the change. 404 for an unknown item, 409 `archived` when the list is in the Supply archive, 400 `invalid_request` for a bad body. - `GET /api/v1/shopping/archive?by=week|month|year` returns the Supply archive `{by, periods: [{key, heading, posts: [{weekStart, label, title, dinnerCount, itemCount, onHandCount, dinners, current}]}]}`: the Supply run of every week up to the household's current week, newest first, grouped by the Monday's ISO week (`key` "2026-W41", `heading` the week label), month ("2026-10", "October 2026") or year ("2026"). The Monday is a household-local date, so the week of Monday 28 December is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the UI sets it in capitals), `title` null while untitled, `dinners` the list's dinners in day order and `dinnerCount` their number, `itemCount` all items, `onHandCount` the items marked "have it", and `current` true for the household's current week. 400 `invalid_request` for any other `by`. A frozen list is read through `GET /api/v1/shopping/{weekStart}` with `status` `archived`. ## Home API (Feature 21) - `GET /api/v1/home` (`home.Service`) aggregates the Home screen from the services that own each value, at "now" in the household's zone (never UTC), and answers: ```json { "dateLabel": "Friday, Oct 2", "timezone": "America/Chicago", "tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40}, "sideQuestCount": 10, "shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null}, "recentUpdates": {"unread": true} } ``` `tonight` is today's day of the current week's plan (`plan.Get`), null when the week has no plan; it changes at the household's local midnight, so the PWA reads Home again at the next local midnight of `timezone`, the household's IANA zone. `photo` and `minutes_total` are null when unknown. `sideQuestCount` is the pending cards of the deck, read without writing (`suggest.PendingCount`): a Home read never fails orphaned batches the way a `suggest.Deck` read does. `shoppingList` is the upcoming week's Supply run when it has one, else the current week's (`shopping.Get`); with neither, it is the current week with `ready` false. `recentUpdates.unread` is true when a notification was stored in `notification_outbox` after `household_settings.updates_seen_at`, or at all while that is null (`notify.StoredSince`); an event whose channels are all switched off is never stored and never counts. - `GET /api/v1/updates` (`home.Service.Updates`, over `notify.Recent`) answers the Recent updates sheet: the last 10 distinct events in `notification_outbox`, newest first, one per event key whatever its channels or delivery status (an event stored for push and Google Chat is one update): ```json {"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]} ``` Every field comes from the stored payload, which never holds a webhook URL or secret; `url` is the same-origin path the event opens. - `POST /api/v1/updates/seen` with `{"through": ""}` (CSRF header required) records that the household has seen its updates through the newest one it was shown, and answers 204. The last-seen instant is `household_settings.updates_seen_at` — the one place it is stored. It only moves forward (`MarkUpdatesSeen`), so a `through` older than the stored one is accepted and changes nothing, and an event stored after `through` keeps `unread` true. It is not a settings edit: `updated_by` and `updated_at` are left as they are and settings subscribers are not told. 400 `invalid_request` for a body that is not exactly that object, a `through` that is not RFC 3339, or one after now. ## Frontend - React 19, TypeScript, Vite, React Router 7, TanStack Query 5, `vite-plugin-pwa`, `lucide-react` (stroke 2.75). - CSS Modules over `tokens.css`; no Tailwind, no Framer Motion, no hard-coded hex. Self-hosted Bagel Fat One and Figtree. - Tests: Vitest, Testing Library, jsdom. - Offline cache for the current plan, shopping list and recipes. - Logout (`POST /auth/logout`) answers with `Clear-Site-Data: "storage", "cache"`, wiping the offline marker, the service worker and its caches; the worker leaves that request to the browser, as it does every network-only one. | Route | Screen | | --- | --- | | `/` | Home | | `/onboarding` | Add regular dinners | | `/plan` | Weekly plan (Draft board) | | `/plan/shopping` | Shopping list (Supply run) | | `/plan/shopping/archive` | Supply archive | | `/discover` | Swipe suggestions (Side quests) | | `/quick` | Quick dinners | | `/cook-from-pantry` | Cook from what I have | | `/dinner/:id` | Recipe / dinner detail (Kitchen reading mode on tablet) | | `/settings` | Settings home | | `/settings/household` | Household settings | | `/settings/regulars` | Regular dinners | | `/settings/calendar` | Google Calendar connection | | `/settings/notifications` | Notifications and Google Chat | | `/settings/ai` | Admin · AI assistant | `/settings/household`, `/settings/regulars` and `/settings/calendar` come from CTXD; the other routes are defined in the UI handoff. First run: while `household_settings.onboarding_completed_at` (read from `GET /api/v1/settings/household`) is null, every route redirects to `/onboarding`. Onboarding is a full screen outside the app shell, in three steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`, mock 1h, adding and archiving through the dinners API optimistically; Next waits for three saved Regulars, and a common pick is not offered once a Regular has a name `harness.Similar` to it — the PWA's copy of the name rules in `web/src/dinners/names.ts` folds letters with `web/src/dinners/letterFolds.json`, which `gen_folds.py` writes from the same table as the server's, and runs the same cases, `web/src/dinners/testdata/dedupe_cases.json`, as the server's) and notifications (`/onboarding/notifications`: what MealQuest notifies about, the iOS install hint, "Turn on notifications" and "Maybe later"). "Maybe later", and "Turn on notifications" once notifications are enabled, send `PATCH /api/v1/settings/household` with `{"onboarding_completed": true}`; the server sets `onboarding_completed_at` to the time of that update (keeping the first time if it is already set; the field itself is read-only and `false` is refused) and the app goes Home. Completing needs at least three Regulars (`household.MinRegulars`); with fewer the server answers 400 `invalid_settings` naming `onboarding_completed` and changes nothing. Step 3 can be reached with fewer — by its URL, or after another member removes a Regular — so it then shows the server's reason with an "Add Regulars" button back to step 2. Notification permission is never asked for on page load; until Feature 19 wires Web Push, "Turn on notifications" asks for nothing and says plainly that notifications are not on yet, offering "Finish setting up" (which completes onboarding as above) instead of a silent success. Offline on the saved pages the settings cannot be read (the service worker never caches `/api/v1/settings`), so the saved pages open as they are. Regular dinners (`/settings/regulars`) lists the Regulars from `GET /api/v1/dinners?status=regular`, each with its photo, "method · minutes", favorite heart and grounding badge ("Finding recipe…" while `pending`, "Recipe found" when `grounded` or `manual`, "Needs a recipe" when `failed`). The add field takes a name or a pasted `https://` recipe link. Tapping a Regular opens an edit sheet (name, method, minutes, difficulty; remove archives it after a confirmation); tapping one that needs a recipe opens "Fix recipe" (pick one of the recipe candidates, paste a link or type the ingredients and steps in). Adds, removals and the heart show at once and are undone if the server refuses them. While any Regular is `pending`, the list is read again every 3 seconds (not while a change is in flight), and polling stops once none is. ## Boundaries and gotchas - **Secrets never reach the client** — API keys, OAuth tokens and the Chat webhook stay server-side, encrypted at rest. - **The app is the source of truth for the plan**; Google Calendar is a one-way mirror. - **Never auto-promote to Regular** (D-021). - **Quick** means cooked in a crock pot **or** ≤ 30 minutes prep to table. - **Weeks run Monday–Sunday** in the household timezone. - **No silent fallbacks** — fail fast and report. - **iOS Web Push requires the PWA to be installed to the Home Screen**; show an install hint.