diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 43b97db..0eca1ee 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,8 +1,10 @@
name: Publish image
+# main → :latest (the stable stack) · beta → :beta (the nutrition beta stack).
+# The branch name IS the tag except main, so the mapping needs no per-branch jobs.
on:
push:
- branches: [main]
+ branches: [main, beta]
permissions:
contents: read
@@ -26,6 +28,6 @@ jobs:
file: server/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
- tags: ghcr.io/${{ github.repository_owner }}/forge:latest
+ tags: ghcr.io/${{ github.repository_owner }}/forge:${{ github.ref_name == 'main' && 'latest' || github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
index 8324af3..12ed5b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,4 +12,6 @@ ios/**/xcuserdata/
ios/**/*.xcuserstate
ios/DerivedData/
ios/**/.build/
+# the public host never lands in a tracked file — see ServerDefaults.example.plist
+ios/**/ServerDefaults.plist
*.p8
diff --git a/CLAUDE.md b/CLAUDE.md
index 75a88b7..25e25a2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,9 +18,10 @@ live in `docs/`.
- `ios/` — native SwiftUI app (started Jul 2026, in progress). One app, three modules: Train / Cook / Shop. Design decisions in `docs/native-app-design.md`, setup + phases (N0–N5) in `docs/ios-implementation-plan.md`, iOS-session working rules in `ios/CLAUDE.md`. The server is unchanged by it — the app is another API client. The PWA stays deployed as the fallback and the desktop dashboard, so any API change is checked against both clients.
- `web/` — React 19 + Vite 7 + TypeScript PWA. TanStack Query for server state; IndexedDB offline set queue in `src/queue.ts`; hand-written `src/styles.css` IS the design system — no Tailwind, keep it that way.
- Coach: server-side Claude tool-use loop; tools call route handlers directly with `(user=user, db=db)`. `propose_revision` validates content and creates `status='proposed'` revisions the user approves in-app (auto-apply only when `prefs.coach_approval == 'auto'`). Sunday 20:00 Europe/London review scheduler in `main.py` lifespan; `agent_runs` logs token spend. Model via `COACH_MODEL` (default claude-sonnet-5).
-- `/api/week` is a calendar week Mon–Sun: pass any `date` and the server snaps to that week's Monday; the response carries `today` so clients mark the current day and flag past "missed" days. The Plan screen pages weeks with prev/next (capped one week ahead); the current week auto-focuses today. Sessions can pull any strength plan day onto today's date (`plan_day` override on POST /api/sessions). `/api/history` is offset-paginated (`limit`≤100/`offset`) and takes `favorites=true` to return only starred sessions. Session management: `DELETE /api/sessions/{id}` discards a session (drops its sets + cardio series, leaves all-time records — same simplification as edit_set) — used by the unfinished-workout sheet's "Discard" (a `ConfirmSheet` in `ui.tsx`) and History's swipe-to-delete (`data.tsx::SwipeRow`, pointer-events + `touch-action:pan-y`); `PATCH /api/sessions/{id}/favorite` toggles the `WorkoutSession.favorite` star (additive column, migrated in `main.py` lifespan since create_all never alters tables). Destructive actions always route through `ConfirmSheet` (warm `--warn` CTA, never a second accent).
+- `/api/week` (and `/api/food/week`) is a calendar week Mon–Sun: pass any `date` and the server snaps to that week's Monday; the response carries `today` so clients mark the current day and flag past "missed" days. The Plan screen pages weeks with a finger-tracked swipe (three-pane prev/cur/next track in `today.tsx`, `touch-action:pan-y`, prev/next buttons reuse the eased slide; forward cap four weeks) — Food still pages with prev/next capped one week ahead; the current week auto-focuses today. Forward planning: `planned_items` (`POST/PATCH/DELETE /api/plan-items`, surfaced per-day as `planned` in `/api/week`) pencils user-authored workouts and meals onto future dates — the Plan screen renders them as pills with a per-day sheet. Sessions can pull any strength plan day onto today's date (`plan_day` override on POST /api/sessions). `/api/history` is offset-paginated (`limit`≤100/`offset`) and takes `favorites=true` to return only starred sessions. Session management: `DELETE /api/sessions/{id}` discards a session (drops its sets + cardio series, leaves all-time records — same simplification as edit_set) — used by the unfinished-workout sheet's "Discard" (a `ConfirmSheet` in `ui.tsx`) and History's swipe-to-delete (`data.tsx::SwipeRow`, pointer-events + `touch-action:pan-y`); `PATCH /api/sessions/{id}/favorite` toggles the `WorkoutSession.favorite` star (additive column, migrated in `main.py` lifespan since create_all never alters tables). Destructive actions always route through `ConfirmSheet` (warm `--warn` CTA, never a second accent).
- Phase 4: Withings OAuth+webhooks in `routers/withings.py` (`WITHINGS_CLIENT_ID/SECRET`; webhook needs public ingress). `/api/withings/connect` answers with a **302** to the authorize URL and the button navigates to it directly — never convert this back to fetch-then-`location.href`: a direct navigation to `account.withings.com` triggers the iOS Withings app's universal link and kills the OAuth flow; server-side redirects don't. Ingested Watch workouts reconcile against the planned cardio day (`ingest.py::_match_prescription`) — matched → `status='completed'` with target-vs-actual + `pct_in_zone`; unmatched stay `unplanned`. Zone-2 weekly minutes use the fixed 110–145 band (`ZONE2_BAND`); the per-plan prescription band only drives `pct_in_zone`. Desktop dashboard: `/dashboard` route (same SPA bundle) ← `/api/dashboard`.
- Run detail (E5.4): HR + GPS series live in `workout_series` (one row per cardio session, downsampled at ingest — a separate table on purpose: create_all can't add columns, and history queries never load the blobs). Re-ingesting an existing day backfills series instead of skipping (`ingest._attach_series`), so HAE manual exports enrich old runs. `/api/sessions/{id}` adds `series` + a five-zone breakdown (`training._zone_breakdown`: `prefs.hr_max`, else estimated and flagged). The route renders on a MapLibre GL basemap (Jul 2026) when a MapTiler key is set (`MAPTILER_KEY`, admin-managed via Settings → Server → Maps; `/api/map/config` hands the publishable key to signed-in clients); styles are built at runtime from the live design tokens in `web/src/routemap.tsx::forgeStyle` so both themes carry through — hosted MapTiler tiles only, never a second tile provider. maplibre-gl is lazy-loaded on the run-detail route and excluded from the PWA precache (`globIgnores` in vite.config); tiles cache via workbox runtime caching (capped, 30 days). The self-contained SVG trace remains and is the automatic fallback — no key, offline, or tile errors — so a run always shows its route. Demo runs get generated Cherry Orchard (Dublin) loops (`demo._run_series`).
+- External MCP (beta): `/mcp` in `routers/mcp_food.py` — a hand-rolled **stateless** Streamable-HTTP JSON-RPC endpoint (single JSON responses, no SSE/sessions, no SDK dependency — keep it that way, it's what makes it testable in the sqlite suite). Auth = the per-user ingest token as `Authorization: Bearer` (same token as `/ingest`; Settings → Connections). Ten tools: six food — `log_food` (eaten-now with venue/cost/currency/photos, `client_id` idempotent, slot inferred from `coach_tz` time when omitted), `get_food_log`, `delete_food_log`, `import_recipe`, `search_recipes`, `get_recipe` — plus four pantry tools that maintain the canonical `ingredients` reference (per-100g macros/aisle/pantry-flag): `list_ingredients` (read, demo-visible), `bulk_import_ingredients` (upsert-by-name, `overwrite` default true, the bulk path), `update_ingredient` (partial patch by name), `delete_ingredient` (reports recipe references). All ingredient writes reject the demo seat and share `_apply_ingredient_fields`/`_new_ingredient` with `import_recipe`'s inline creation. The seeded default pantry (`food_seed.INGREDIENTS`, ~100 items, USDA/McCance per-100g values, insert-missing every boot) is the trusted-source baseline these tools extend. Import rules: `source_url` is the import's identity (re-import updates in place), seed recipes are write-protected, macros are per-serving, steps must be rewritten in done-when voice (never verbatim source prose), and unknown ingredients / difficulty `hard` / zero kcal park the recipe `complete=0` (browsable, never proposable) — except an unknown ingredient supplied with per-100g reference macros (`kcal_100` is the gate, plus optional `aisle`/`pantry`) is added to the canonical `ingredients` table instead of parking. The library is browsable in-app: `GET /api/food/recipes` (everything incl. parked, `q`/`kind` params, shares `food.query_recipes` with the MCP search tool) ← Food → "Recipes ›" (`food.tsx::RecipeLibraryScreen`, client-side as-you-type filter, parked entries badged warm; recipe detail's Back honours `foodFrom`). Recipe seeding is opt-out: `SEED_RECIPES=false` stops the boot from seeding the curated recipes + first food week (the pantry/ingredient reference still seeds so imports complete); `DELETE /api/admin/recipes` (Settings → Server → Recipe library, ConfirmSheet) wipes the library and retires the shared food week for an MCP-populated setup — meal logs and ingredients are kept. Images (recipe heroes, step photos, meal photos) are materialized into `media_blobs` by `app/media.py` (data: URIs or URL fetch, 3 MB cap, keep-remote-URL fallback, deduped by `src_url`) and served at `/api/food/media/{id}` — recipe imagery is household-shared (`user_id` NULL), meal photos are private to their owner. The demo seat can log its own meals but can never write the shared recipe library.
- Responsive shell (Jul 2026): one DOM, CSS-only adaptation in the "responsive" block at the end of `styles.css` — ≤380px compact type/padding, ≥640px wider bordered column, ≥900px (AND min-height 520px, so landscape phones keep bottom tabs) the tab bar becomes a fixed 88px left rail, all shell bands (`.app > *:not(.tabs)`) cap to a 700px centered column, sheets center as dialogs. Signed-out screens have no rail via `.app:has(> .tabs)`. New footer-bar or shell-band elements are capped automatically — never position screen chrome outside the `.app` children pattern.
- Web Push: `notify.send_push` is the only send path and hard-rejects kinds outside proposal/reminder (the filming kind was retired with the media pipeline — form media is curated free-exercise-db photos in `web/public/media/exercises/`, wired by `seed.MEDIA_SLUGS`); reminders are once-per-day via the `notification_log` unique constraint, window `REMINDER_HOUR`(16)–`QUIET_END`(21). VAPID keys via `python -m app.vapid` → compose environment; push is silently off until set. SW push handler lives in `web/public/push-listener.js` (workbox `importScripts`).
@@ -28,7 +29,7 @@ live in `docs/`.
- Fitting: the priority-1 main lift is never trimmed; accessories trim first down to `min_sets`; cooldown shortens 5→2 min but is never dropped.
- Proposals: known exercise slugs only; exactly one priority-1 main lift per strength day; non-empty mobility-only cooldown; `content.changes` delta list and per-day `why` one-liners required (the UI renders them).
-- Every query is scoped by `user_id` — James and Shelby must never see each other's data (test_user_segregation guards this). The optional demo user (`app/demo.py`, Bruce Willis, `role='demo'`) rides the same scoping; it is excluded from the two-seat cap, admin user list, dev sign-in buttons and the Sunday scheduler, and is created/reset/removed only via `/api/admin/demo` (`test_demo_cannot_reach_member_data` probes API, chat context, coach tools, and admin). Known exposure: the demo's coach runs on the real API key with no rate limit until Phase 6. Household-shared equipment profiles (`user_id IS NULL`) are visible to every user including the demo.
+- Every query is scoped by `user_id` — James and Shelby must never see each other's data (test_user_segregation guards this). The optional demo user (`app/demo.py`, Bruce Willis, `role='demo'`) rides the same scoping; it is excluded from the two-seat cap, admin user list, dev sign-in buttons and the Sunday scheduler, and is created/reset/removed only via `/api/admin/demo` (`/api/admin/demo/enrich` tops up an existing demo with newer-feature data — currently the food beta — without resetting training history; `demo.enrich_demo` is idempotent per block and a full reset runs it too) (`test_demo_cannot_reach_member_data` probes API, chat context, coach tools, and admin). Known exposure: the demo's coach runs on the real API key with no rate limit until Phase 6. Household-shared equipment profiles (`user_id IS NULL`) are visible to every user including the demo.
- Ingest is idempotent on `(user_id, type, ts, source)` and must honor the payload's `units` field — Health Auto Export sends pounds.
- Secrets never touch tracked files: the committed `docker-compose.yml` carries `change-me` placeholders only; real values live in `docker-compose.override.yml` (chmod 600, gitignored), the Portainer stack editor, or the `app_settings` table (admin-managed via Settings → Server → `routers/admin.py`; `config.apply_overrides`/`set_override` mutate the cached settings singleton so changes apply live, and DB beats env). Admin API responses mask secrets to a 4-char tail — `ANTHROPIC_API_KEY` never reaches the frontend. Google OAuth is compose-only by design (needed before login). `ALLOWED_USERS` only seeds an empty users table; after that the users table IS the allowlist. Mask tokens when displaying them; rotating an ingest token invalidates the old one instantly.
- Design system "Void × Volt": bg `#060708`, raised `#121316`, hairlines `#191b1f`, ONE accent at a time via the `--volt` token family (actions, trends, positive status — volt lime `#bce53a` dark / moss `#5c7d0a` light), warm `#e8a360` for warnings only. Volt is fixed — there is no accent-palette picker (removed Jul 2026); the dark volt was eased ~15% off `#c9f73a` because that peak-luminance lime bloomed against the void and dimmed the neutral greys beside it. Never introduce a second simultaneous accent, never hard-code an accent hex outside the `--volt` token block in styles.css (JS reads `getComputedStyle` when it needs the hex). No borders, pill CTAs, thin tabular numerals.
@@ -43,4 +44,4 @@ and falls back to `BASE_URL` otherwise, so Host-header spoofing can't steer redi
stays anchored to `BASE_URL` (single registered callback). Uvicorn already trusts proxy headers
(`--proxy-headers --forwarded-allow-ips *` in the Dockerfile).
-Runs via Docker Compose on a single home server (`deploy/fresh-install.sh` bootstraps a clean install). As of Jul 2026 the app is on public ingress (`https://www.get-forged.com`, `BASE_URL` in the compose override) with Google OAuth active — dev sign-in buttons only appear when Google is unconfigured (fresh installs). Health Auto Export syncs from anywhere; Withings OAuth round-trips against the public callback URL. Rate limiting is still Phase 6 — the public surface is login + the demo seat.
+Runs via Docker Compose on a single home server (`deploy/fresh-install.sh` bootstraps a clean install). As of Jul 2026 the app is on public ingress with Google OAuth active — the public domain lives ONLY in `BASE_URL` in the gitignored compose override and must never be written into tracked files or commit messages; HTML entry points carry a `__BASE_URL__` token that `main.py::_html_page` substitutes at serve time (OG/share meta needs absolute URLs) — dev sign-in buttons only appear when Google is unconfigured (fresh installs). Health Auto Export syncs from anywhere; Withings OAuth round-trips against the public callback URL. Rate limiting is still Phase 6 — the public surface is login + the demo seat.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f8b041f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 James Atkinson
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md
index 9ee7347..7784118 100644
--- a/docs/implementation-plan.md
+++ b/docs/implementation-plan.md
@@ -105,6 +105,55 @@ Stories: E15.1–E15.3, remaining E12 enforcement
---
+# Beta track — Nutrition (Phases 7–9)
+
+Lives on the **`beta` branch**, deployed as a **second Docker stack** (own `FORGE_PORT`, own Postgres volume, built from source via `docker-compose.build.yml`) while `main` stays the stable workout app; merges to main phase-by-phase once proven in beta. Stories: **E16**. Mockups: `docs/mockups/38–44`. Decisions locked Jul 2026: all meals planned · plan-first one-tap logging · auto carry-over · favorites + menu-paste lunch assist · household dinners · coach-owned targets.
+
+## Phase 7 — Kitchen core (see it, log it)
+**Goal: every meal visible and loggable against targets that mean something.**
+Stories: E16.1, E16.2, E16.4, E16.10, E16.8 (schema + scoping)
+
+- Schema (new tables only, create_all-safe): `recipes`, `ingredients`, `recipe_ingredients`, `meal_revisions`, `meal_log`, `carryovers`, `lunch_favorites`. Household read scope for the food week; per-user scope for logs/targets — segregation tests extended **first**, demo user included.
+- Seed ~40 cholesterol-aligned recipes written in-house **in the HelloFresh card format** (structured steps, why-it-works, minimal ingredients) around **BBC Good Food-style classics** — James's two named tastes (easy/medium, batchable, ingredient-overlapping, done-when method steps) + ingredient macro table + a `platefig` SVG composition per recipe (shared plate/tray/bowl engine, the formfig approach — no food photography); insert-missing like the exercise seed.
+- Coach nutrition intake → `prefs.nutrition_targets`; Settings → Nutrition (targets read-only, cook nights, budgets, household toggle).
+- **Food tab** (5th tab): day view (four meters + tick rows with plate thumbnails), week view, recipe detail (plate art, done-when steps), **cook mode** (one step per screen, local countdown ring via the rest-ring pattern, batch checkpoints, finish logs the meal); dinner lines woven into Plan's hero card + day rows; offline tick queue alongside the set queue.
+- Hand-written first food week as the active revision (Phase 2's seed-plan trick).
+
+**Exit:** a full week of meals logged one-tap on the phone; meters live; Shelby sees shared dinners but her own targets; main app untouched.
+
+> **As built (Jul 2026, first pass — commits fa2cc7c + be9d952)**: everything above plus cook mode, except (a) the recipe pool starts at 25 (16 dinners + templates) — grows toward ~40 via insert-missing seed additions and Phase 8's `import_recipe`; (b) recipe ingredients live as a JSON list on `recipes` joined to the `ingredients` macro table by name in code (exercise-library style), not a join table; (c) targets are seeded prefs defaults — the coach intake conversation that proposes them moves to Phase 8 with the other coach tools; (d) the offline meal queue is localStorage + `client_id` idempotency rather than the IndexedDB set-queue machinery; (e) the day view shipped Option A (plate-first) — flip to B is cheap if preferred. Verified by driving the app headless end-to-end (dark + light).
+
+## Phase 8 — Coached food weeks & the waste loop
+**Goal: Sunday proposes the eating week; the fridge stops throwing food away.**
+Stories: E16.3, E16.5, E16.6 (list + export v1), E16.9, E16.11
+
+- Coach tools: `get_food_week`, `propose_food_week` (validators: complete recipes only, targets met on weekly average, sat-fat banking for planned nights out, carry-over consumption, cost vs budget, difficulty ceiling), `update_carryovers`, `log_meal`, **`import_recipe`** (E16.11: single-page fetch + JSON-LD parse for pasted links, vision for photographed HelloFresh cards; normalize + rewrite + attribute, confirm card before write; adapt-don't-reject).
+- **Card-box amnesty before HelloFresh cancels**: batch-photograph the keeper cards through `import_recipe` so the pool inherits the household's proven favourites, adapted to the trio where needed.
+- Sunday review extended: carry-over keep/bin step → food proposal alongside training; food ProposalCard variant (signed changes + per-day why); shared proposal push (no new kinds).
+- Shopping list generation (recipes − carry-overs − pantry), aisle grouping, cost estimate; Amazon Fresh search-link export + copy list.
+- Dashboard/Progress: fiber + sat-fat weeklies with lipid draw markers, protein adherence, waste trend.
+
+**Exit:** one Sunday review yields two approved weeks; the list matches the fridge; the card box is digitized and HelloFresh paused.
+
+> **As built (Jul 2026, first slice)**: the coach-tools core is live — `get_food_week` / `get_recipes` / `get_food_proposal` / `propose_food_week` (validators: all 28 slots planned, complete recipes only, ≥1 zero-cook dinner, weekly-average trio checks with the sat-fat cap absolute and banked −1 g for a night out, kcal ceiling), `log_meal` (chat estimates, `estimated` flag), `get_carryovers`/`update_carryovers`; household-scoped `meal_revisions` proposal flow (`/api/food/proposal` + approve/reject, exactly one pending, demo walled off); Sunday review step 4 proposes the food week; food ProposalCard (banner + sheet, signed changes, dinner-by-dinner). Still to come in Phase 8: `import_recipe` + card-box amnesty, shopping list + Fresh links, carry-over keep/bin UI in the review, dashboard fiber/sat-fat weeklies.
+
+## Phase 9 — Ordering assist & automation
+**Goal: the out-of-house meals meet the plan; the shop orders itself (almost).**
+Stories: E16.7, E16.6 (v2)
+
+- Lunch assist: favorites ledger + menu-screenshot ranking against remaining day targets + lunch cap; one-tap log from a pick; auto-promotion to favorites.
+- Amazon Fresh auto-cart: feature-flagged headless-browser job on the home server assembles the cart for **manual checkout**; retailer credentials only in `app_settings` (admin-managed), never compose. This is the riskiest integration — it ships last, degrades to Phase 8's link export, and never auto-purchases.
+- Off-plan photo estimates hardened; food-run token spend visible in `agent_runs` like coach runs.
+
+**Exit:** work lunch chosen from ranked picks in under a minute; Fresh cart built with one confirmation; a month of nutrition data on the dashboard next to the following lipid panel.
+
+### Beta-track risks
+- **Macro numbers are estimates** — per-serving values from curated ingredient data, coach estimates for off-plan/ordered food. The UI never implies lab precision; the trio (protein/fiber/sat-fat) is coached on weekly averages, not single meals.
+- **MealPal/Grubhub/Amazon Fresh have no public APIs** — every integration is workflow-shaped (paste, links, supervised browser); nothing depends on scraping that can silently rot.
+- **Two stacks, one household** — beta runs its own Postgres; nothing syncs between stable and beta until merge, so beta is the only place food data lives during the trial.
+
+---
+
## Cross-cutting definition of done
Every story: typed API schema, migration, tests for the server logic (auth/scoping always), PWA state handled offline where relevant, Void×Volt tokens only (no ad-hoc colors), and user-visible copy matching the spec's voice.
diff --git a/docs/ios-implementation-plan.md b/docs/ios-implementation-plan.md
index 038019a..7cbc3a7 100644
--- a/docs/ios-implementation-plan.md
+++ b/docs/ios-implementation-plan.md
@@ -45,7 +45,7 @@ design doc but are broken into shippable slices here.
honest without repo sprawl.
3. Capabilities on the app target: **HealthKit** (+ background delivery), **Push
Notifications**, **Background Modes** (remote notifications, workout processing on
- Watch), **App Groups**, **Associated Domains** (`applinks:www.get-forged.com`
+ Watch), **App Groups**, **Associated Domains** (`applinks:` the public host from `BASE_URL`
— lets login cookies/universal links round-trip cleanly later).
4. "Automatically manage signing" with the team from 0.1 — with two users and one team
there is no reason to hand-roll provisioning profiles.
@@ -76,7 +76,7 @@ design doc but are broken into shippable slices here.
- ForgeKit takes a base URL; debug builds default to the LAN address of the home server
(or `localhost:8000` against a dev uvicorn), release builds to
- `https://www.get-forged.com`.
+ the public host (the same origin `BASE_URL` names).
- Auth: `ASWebAuthenticationSession` → existing Google OAuth → the same session cookie
the PWA uses, held in the Keychain. Dev sign-in buttons appear only when Google is
unconfigured, same as the web. No new auth surface on the server.
diff --git a/docs/mockups/38-food-today-options.html b/docs/mockups/38-food-today-options.html
new file mode 100644
index 0000000..ca1ff56
--- /dev/null
+++ b/docs/mockups/38-food-today-options.html
@@ -0,0 +1,180 @@
+
+
+
+
+
+Food tab · day view — two options
+
+
+
+
+
+
Option A — Plate-first · the four cholesterol levers as meters up top, then today's meals as one-tap tick rows. New 5th tab: Food.
+
+
Tuesday · 22 Jul
+
Food
+
+
+
Protein94 / 160 g
+
Fiber21 / 38 g
+
Sat fat · cap8.5 / ≤18 g
+
Calories1,270 / 2,300
+
On plan — dinner closes protein and fiber.
+
+
+
+
✓
+
+ Oats №1 + yogurt
Breakfast · 420 kcal · P 34
7:40
+
✓
+
+ MealPal — shawarma bowl
Lunch · P 52 · sat 5
12:50
+
✓
+
+ Apple + peanut butter
Snack · 210 kcal · fiber 4
15:30
+
○
+
+ Salmon, puy lentils & broccoli
Dinner · 20 min · easy
Cook ›
+
+
+
Ate something else? Tell the coach — it logs it
+
+
+ ▤Plan
+ ◉Food
+ ◷History
+ ↗Progress
+ ✳Coach
+
+
+
+
+
+
Option B — Next-plate-first · tonight's cook is the hero (like Plan's workout hero), meters compressed to a strip, timeline below.
+
+
Tuesday · 22 Jul
+
Food
+
+
+
+
+
Tonight · 20 min · easy
+
Salmon, puy lentils & long-stem broccoli
+
+ P 38fiber 9sat 3.5
+
+
+
+
+
Start cooking
+
+
+
+
Protein94/160
+
Fiber21/38
+
Sat fat8.5/≤18
+
kcal1.3k/2.3
+
+
+
+
✓Oats №1 + yogurtP 34
+
✓MealPal — shawarma bowlP 52 · sat 5
+
✓Apple + peanut butterfiber 4
+
○Evening yogurt — optionalP +18
+
+
+
Week so far: 2 of 2 days on plan · Fri is your night out
Food · Week · the approved food week — dinner titles lead each row, slot dots show B/L/D/S state · totals vs targets up top
+
+
Week of 21 Jul · approved
+
Food week
+
+
+
Protein avg158g✓
+
Fiber avg39g✓
+
Sat fat avg14g✓
+
Est cost$92
+
+
+
+
Mon
21
+
+ Harissa chicken traybake ×2
BLDS
✓ on plan
+
Tue
22
+
+ Salmon, puy lentils & broccoli
BLDS
20 min ›
+
Wed
23
+
+ Traybake leftovers
zero-cook · boxed Monday
↻ batch
+
Thu
24
+
+ Turkey & black-bean chili
35 min · batch → Fri lunch
medium
+
Fri
25
+
+ Night out — enjoy it
sat-fat headroom banked Mon–Thu
out
+
Sat
26
+
+ Prawn & soba stir-fry15 min
+
Sun
27
+
+ Baked cod, white bean stew
finishes the harissa jar
30 min
+
+
+
Shopping list · 21 items
+
+
+ ▤Plan
+ ◉Food
+ ◷History
+ ↗Progress
+ ✳Coach
+
+
+
+
+
+
Plan · with dinner woven in · training stays the hero — tonight's cook is one quiet line on the hero card, dinners ride the day rows, food proposal shares the banner slot
+
+
Today + the six days ahead
+
Plan
+
+
Food week proposed alongside trainingReview ›
+
+
+
Tuesday · today · ~50 min
+
Upper A — press focus
+
ChestShoulderstriceps
+
◉ Tonight · Salmon, lentils & broccoli20 min ›
+
+
+
+
♥Wednesday
Zone 2 run · 40 min · dinner: traybake leftovers
›
+
▦Thursday
Lower A · dinner: turkey chili
›
+
·Friday
Rest · night out
›
+
▦Saturday
Upper B · dinner: prawn stir-fry
›
+
+
+
Dinner lines are quiet on purpose — Plan stays a training screen. Tapping one opens the recipe in Food.
Recipe detail · plate art up top (palette-native SVG, formfig-style — no photos), the cholesterol trio per serving, carry-over badges, steps with done-when checkpoints, cook mode as the main CTA
+
+
‹ Food week
+
Monday dinner · cook once, eat twice
+
Harissa chicken traybake
+
+ 25 mineasyserves 2 + 2 boxed
+
+
+ 520 kcalP 42fiber 11sat fat 4.5
+
+
+
+
+
Why it's here
+
Your highest-protein traybake; chickpeas carry the fiber. 4.5 g sat fat banks headroom for Friday night out. Doubles into Wednesday — one cook, two dinners.
+
+
Ingredients · 2 nights
+
+
Chicken thighs, skinless900 g
+
Chickpeas2 tins
+
Peppers ×3 · red onion ×2
+
Harissa carry-over · ⅔ jar2 tbsp
+
Spinach use by Tue½ bag
+
Olive oil · cumin · lemonpantry
+
+
+
Method · 5 steps
+
+
1Prep · 5 min
Oven to 220° fan. Pat the thighs dry — dry meat browns, wet meat steams. Peppers into strips, onions into wedges, chickpeas drained well.
+
2Dress · 2 min
Everything into the tray with harissa, oil, cumin and half the lemon. Toss until slicked; thighs on top so they roast, not stew.
+
3Roast · 22 min · timer
Done when thighs read 74° / juices run clear and the chickpeas just blister. No turning — let the edges char.
+
4Finish · 1 min
Off the heat: fold the spinach through the hot chickpeas to wilt, squeeze the rest of the lemon over.
+
5Box & plate
Half into Wednesday's box before plating — portion now, no willpower needed Wednesday night.
+
+
+
Cook step-by-step · 25 min
+
Already cooked? Log it in one tap
+
Swap this dinner
+
+
+
+
+
Lunch assist — in Coach chat · favorites answer instantly; a pasted MealPal/Grubhub menu gets ranked against targets + budget. "Log it" writes the meal, one tap.
Food week proposal · same bottom-sheet + diff grammar as the training ProposalCard — signed changes with a why, rationale leads with outcomes, day rows expand below
+
+
+
Coach
+
Sunday review
+
Training week proposed — approved ✓
+
+
+
+
+
Proposed Sun 20 Jul · food week №1 · awaiting your OK
+
+
+ What this week does
+
Protein lands 158 g/day without red meat. Fiber averages 39 g — beans, lentils, oats doing the work. Sat fat holds at 14 g/day, leaving honest room for Friday out. Est. $92 of your $110.
+
+
+
+
+Traybake cooks twice — Mon → Wedone cook night saved, zero waste
+
+Fiber +6 g/day vs last weekthe LDL lever
+
~Sunday salmon → baked codkeeps sat fat under the cap
+
−Granola breakfasts dropped2 g fiber, 9 g sugar — oats instead
+
+
+
+
Mon
+
+ Harissa chicken traybake ×2
uses your spinach before Tuesday
+
Tue
+
+ Salmon, puy lentils & broccoli
omega-3 day; lentils carry fiber
+
Wed
+
+ Traybake leftovers
zero-cook on your run day
+
Thu
+
+ Turkey & black-bean chili
batch feeds Friday lunch
+
Fri
+
+ Out — no plan, no guilt
headroom already banked
+
Sat · Sun ▾
+
+
+
Approve food week
Changes…
+
+
+
+
+
+
Carry-over check · first step of the Sunday review — confirm what last week's shop left behind; kept items must appear in the new proposal. The coach learns from bins.
+
+
Sunday review · step 1 of 2
+
Still in the fridge?
+
Last week's shop leaves these. Keep what's good — next week's meals use them up first.
+
+
+
Spinach · ½ bag
goes in Monday's traybake use by Tue
✓✕
+
Feta · 120 g
Monday's lentil salad lunch
✓✕
+
Harissa · ⅔ jar
traybake Monday, cod stew Sunday — jar done
✓✕
+
Red cabbage · ½ head
binned
✓✕
+
+
+
+ Noted
+
That's the second cabbage half we've lost. I'll stop planning whole heads — pre-shredded packs from now on, sized to the week.
+
+
+
Looks right — propose my week
+
3 carry-overs → into next week's plan · waste this week: 1 item
Shopping list · recipes minus carry-overs, grouped by aisle, every item says which meals want it · waste score up top · Amazon Fresh export now, auto-cart later
+
+
‹ Food week
+
Week of 21 Jul · 21 items · est $92 of $110
+
Shopping
+
+
+
+ Zero likely-waste items — every fresh buy is used across the week. 3 carry-overs consumed.
+
+
+
Produce
+
+
Long-stem broccoli · Tue400 g
+
Peppers · Mon ×2 nights3
+
Spring greens · Sat + Sun stew200 g
+
Berries · oats, all week400 g
+
Apples · snacks6
+
+
+
Fish · meat · dairy
+
+
Chicken thighs, skinless · Mon→Wed900 g
+
Salmon fillets · Tue2
+
Turkey mince 5% · Thu→Fri lunch500 g
+
Cod fillets · Sun2
+
Greek yogurt 0% · breakfasts1 kg
+
+
+
Cupboard
+
+
Chickpeas · black beans · fiber backbone2 + 2 tins
+
Puy lentils · soba · bulgur1 each
+
+
+
Already have · not on the list
+
+
Harissa ⅔ jar · feta 120 g · spinach ½ bagcarry-over
+
Oats · flax · peanut butter · olive oilpantry
+
+
+
Send to Amazon Fresh
+
Copy list
+
Opens Fresh with each item queued for one-tap add. Full auto-cart lands in Phase 9 — you always confirm checkout.
+
+
+
+
+
Settings → Nutrition · targets are coach-owned (chat to change), budgets and cook-nights are yours, household dinner sharing, and the HelloFresh sunset tracked honestly
+
+
‹ Settings
+
Nutrition
+
+
+
Daily targets · set by your coach
+
2,300 kcal · P 160 · fiber 38 · sat ≤18
+
Proposed from your goals, training load and April's lipid panel. Change them in chat — the coach explains the trade-offs first.
+
Discuss targets with the coach
+
+
+
+
Cook nights per week
+
3456
+
The Sunday proposal plans this many dinners; the rest are leftovers or out. Batch nights count once.
+
+
+
+
Grocery budget · week$110
+
Lunch cap · work day$15
+
+
+
+
Dinners feed the household
Portions ×2 when Shelby's in — her plate logs to her day, her targets stay hers
On
+
+
+
+
Amazon Fresh
List export live · auto-cart in Phase 9
›
+
HelloFresh
Pause after two good Fresh weeks — coach will call it
winding down
+
+
+
No new notification kinds — your food week rides the existing Sunday proposal push.
Cook mode — mid-step · one step per screen, kitchen-scale type for a propped-up phone, done-when checkpoints, and the rest-ring pattern reused as a local roast timer
+
+
‹ exitHarissa chicken traybake
+
+
+ ✓✓3
+ 45
+ Step 3 of 5
+
+
+
Roast — hands off
+
220° fan. Done when the thighs read 74° / juices run clear and the chickpeas just blister. No turning — let the edges char.
+
+
+
+
Chimes at 19:04 — a local timer, same as your rest ring.
+
+
+
While it roasts
+
Get Wednesday's box out — step 5 needs it. Spinach stays in the fridge until the tray is out of the oven.
+
+
‹ Dress
Finish ›
+
+
+
+
+
Cook mode — plated · the last step's batch checkpoint, then the kitchen's session summary: plates logged, Wednesday boxed, the day's trio closed — mirroring the workout summary
+
+
Cook mode · done in 24 min
+
Plated. Logged.
+
+
+
+
+
✓Wednesday's dinner boxed
zero-cook night locked in · fridge, 3 days
+
✓Dinner logged — 2 plates
you + Shelby · her plate on her day, her targets
+
+
+
+
Per plate
+
+ kcal520
+ Protein42
+ Fiber11
+ Sat fat4.5
+
+
Your Monday lands P 156 · fiber 41 · sat 12.5 — on plan, headroom banked for Friday.
+
+
+
Cook streak: 3 nights this week · waste so far: 0 items
Import from a link · paste a BBC Good Food URL — the server fetches that one page, reads its structured recipe data, and the coach normalizes it into your library
+ Fetched and parsed. Here's how it lands in your library:
+
+
One-pan chicken puttanesca
+
+ 30 mineasyserves 2
+ P 44fiber 9sat 3.8
+
+
+
✓Macros recomputed from your ingredient library — BBC's card says P 42, mine says 44. Close enough to trust.
+
✓9 of 10 ingredients already known · new: capers — added with per-100 g macros.
+
✓6 steps rewritten in our voice, each with a done-when. Original text isn't stored.
+
·Source kept: BBC Good Food · link on the recipe page.
+
+
Add to libraryTweak first
+
+
+
+
Add it
+
+
In the pool — proposable from Sunday. Olives and capers also nudge your fiber day up, so it'll compete well. Want it swapped in for Thursday instead of the chili?
+
+
One page fetched per ask — the coach never crawls or searches. Proposals only ever draw from the in-app pool.
+
+
Message your coach…Send
+
+
+
+
+
Rescue the HelloFresh cards · photograph the keepers before the subscription ends — the coach adapts them to the cholesterol trio instead of rejecting them
+
+
‹ Coach
+
Sunday · card box amnesty
+
+
📷 📷 📷 3 recipe cards The keepers from the HelloFresh box
+
+
+ Read all three. Two go straight in, one needs surgery:
+
+
1Prawn & courgette spaghetti
P 38 · fiber 7 · sat 2.5 — imported as-is
as-is
+
2Creamy dill chicken
sat 19 → 7: yogurt instead of the cream — same job, tang included
adapted
+
3Halloumi burgers
24 g sat fat is half your week — parked as a once-a-month treat
parked
+
+
All three keep their card names + a "card box" source tag. Originals stay in the drawer; the steps here are ours.
+
+
+
Adapt 2, park 3 — do it
+
+
Done. Pool's at 46 recipes, 45 proposable. The dill chicken lands in next week's proposal — it fills your one missing yogurt-day dinner nicely. Cancel timing: after Sunday you'll have cooked from the pool three weeks straight — I'd say the box has earned retirement.
+
+
Message your coach…Send
+
+
+
+
+
diff --git a/docs/mockups/45-design-refresh-options.html b/docs/mockups/45-design-refresh-options.html
new file mode 100644
index 0000000..2a7d8c7
--- /dev/null
+++ b/docs/mockups/45-design-refresh-options.html
@@ -0,0 +1,798 @@
+Forge — card language refresh
+
+
+
+
+
+
Forge · design refresh · options sheet
+
Bringing back the card language
+
The original mockups put every item on its own raised card — the
+ build has drifted to full-bleed hairline rows because .tap .card strips card chrome
+ from anything tappable. Five row primitives now compete (lrow, mealrow, ingrow, setline,
+ mrow), separators on cards measure 1.07:1, and rest days render text at
+ 2.4:1. Below: the shared language, then each screen — current build, faithfully
+ reproduced, next to two card treatments.
+
+
+
+
+
The common language
+
Everything rides the existing tokens — palettes and light mode inherit
+ automatically. Two additions only: an inset separator token, and one row anatomy that
+ replaces the five variants.
+
+
+
+
Surface ladder
+
+
bg — the void; shell only#060708
+
sunken — wells inside cards#0c0d0f
+
raised — every card#121316
+
sel — selected / pressed#1e2126
+
+
The rule that restores the design: if you can tap it, it sits on
+ raised. No interactive row ever renders flush on the void. The 1px
+ hairline stays for shell chrome (tabs, header) — it is not a list separator.
+
+
+
+
Text on a card · measured contrast
+
+
Token
Role
On raised
+
ink#f3f4f6
titles, values
17.3:1
+
mut#9ea4ab
subs, labels
7.4:1
+
dim#6d737a
chevrons, décor — never copy
3.9:1
+
mut @ 45% opacity
rest-day rows today
2.4:1 ✕
+
hair on a card
today's separators
1.07:1 ✕
+
hair-in#2b2f37 new
separators inside raised
1.4:1
+
+
Dimming happens on decoration, never on text: a rest day keeps
+ mut-colored words and dims only its glyph. .dimrow is deleted.
+
+
+
+
One row anatomy · replaces five
+
+
+ ⏚
+ Title — wraps, never truncates the value
+ Sub in mut · one line · 13.5px
+ 82.5 kg
+ ›
+
+
+ 18px glyph slot16/550 title
+ 13.5 sub15 tab value, nowrap
+ 13×15 pad8px card gap
+
+
+
The value column is tabular and never
+ wraps — the title side flexes instead. This one anatomy replaces
+ lrowmealrowingrowsetline
+ mrow → .item, standalone on a card
+ (A) or separated by hair-in inside a group
+ (B).
+
+
+
+
+
+
+
Plan — the week
+
Today's build mixes three treatments on one screen: hairline rows, a
+ 45%-opacity rest day, and a lone hero card. Both options make every day the same object.
CurrentThree treatments on one list.
+ Separators are invisible on OLED, Tuesday is unreadable at 45% opacity, and the value
+ column wraps long dinner names underneath it.
+ Tonight · Turkey & black-bean chili
+ 35 min ›
+
+
+
+
+ Friday 24Upper A · night out
+ ~52 min›
+
+
+
Option ACard per day — the original
+ mockup language. Every day is the same object; today upgrades with a flat
+ volt wash (gradient dropped), missed becomes a quiet warm tag, rest days stay readable.
+ The calendar bolds up: 700 labels, warn wash on missed days, volt ring on today.
+
+ Thursday 23 · TodayRest · Turkey & black-bean chili
+ 35 min ›
+
+
+
+ Friday 24Upper A · night out
+ ~52 min›
+
+
+
+
Option BOne grouped card, inset
+ separators. The week reads as a single object; hair-in separators are actually
+ visible on the raised surface; today gets an inset volt wash. Loses the hero moment.
+
+
+
+
+
+
+
History
+
Worst readability offender today: ISO dates lead every row, names wrap
+ under them, and the fixed-width value column breaks "128 bpm" onto its own line.
+
+
+
+
+
+
All sessions
+
History
+
All★ Starred
+
+
+ 2026-07-17 · Upper A
+ 5.83 t · 12 sets ›
+
+
+
+ 2026-07-15 · Zone 2 run
+ 8.0 km · 46 min · 128 bpm ›
+
+
+
+ 2026-07-13 · Lower A
+ 4.07 t · 12 sets ›
+
+
+
+ 2026-07-10 · Upper A
+ 5.78 t · 12 sets ›
+
+
+
+ 2026-07-08 · Zone 2 run
+ 8.2 km · 46 min · 129 bpm ›
+
+
+
+ 2026-07-06 · Lower A
+ 4.76 t · 12 sets ›
+
+
+
CurrentThe date is doing the name's
+ job. Every row leads with 2026-07-…, the session name
+ wraps beneath it, and run metrics fold onto a second line inside a 104px column.
Option AName first, friendly date as
+ the sub. One headline value on the right with its unit pair stacked under it —
+ nothing ever wraps. Stars ride the title. Swipe-to-delete works unchanged on cards.
+
+
+
+
+
+
All sessions
+
History
+
All★ Starred
+
July
+
+
+
+ Upper A ★Fri 17 · 5.83 t · 12 sets
+ ›
+
+
+
+ Zone 2 runWed 15 · 8.0 km · 46 min · 128 bpm
+ ›
+
+
+
+ Lower AMon 13 · 4.07 t · 12 sets
+ ›
+
+
+
+ Upper AFri 10 · 5.78 t · 12 sets
+ ›
+
+
+
+ Zone 2 runWed 8 · 8.2 km · 46 min · 129 bpm
+ ›
+
+
+
June
+
+
+
+ Lower AMon 29 · 3.89 t · 12 sets
+ ›
+
+
+
+
Option BMonth-grouped cards.
+ The section header carries the month so subs shrink to day + metrics on one line.
+ Denser than A — better for a year of history — but metrics move into the mut sub.
+
+
+
+
+
+
+
Food — the day
+
Meal rows have the faintest affordances in the app: tick targets at
+ 1.07:1, three-line wrapping subs, and the coach's order-out prose pushing rows apart.
CurrentRows dissolve into the void.
+ Tick circles are nearly invisible, the order-out row's coach prose runs three lines,
+ and "Cook ›" is the only hint anything is tappable.
+ ✓
+ Apple & peanut butterSnack · 210 kcal · P 5 · fib 4
+ ›
+
+
Ate something else? Tell the coach
+
+
Option ACard per meal, one-line subs.
+ Ticks get a visible ring; the coach's order-out prose collapses to its two facts
+ (full text lives one tap in); the dense macro grid replaces four stacked meters.
+ ✓
+ Apple & peanut butterSnack · 210 kcal · P 5 · fib 4
+ ›
+
+
+
Ate something else? Tell the coach
+
+
Option BSummary card + one meals
+ group. The macro card and the meal group read as two clear objects. Tightest
+ vertical rhythm of the three — the whole day fits above the fold.
+
+
+
+
+
+
+
It carries to Paper × Moss — deepened a step
+
The shipped light tokens are too pale, so this deepens them: paper drops
+ to #eef0ea (white cards actually register), subs to
+ #4d545d (7.6:1), accent tints strengthen from 12% to 18–20%, and
+ cards carry a soft real shadow. The today card is a flat
+ wash — no gradient — in both themes. All five accent palettes still inherit for free.
+ Tonight · Turkey & black-bean chili
+ 35 min ›
+
+
+
+
+ Friday 24Upper A · night out
+ ~52 min›
+
+
+
Option A · lightWhite cards on deeper paper
+ with a soft shadow; moss accent, bold calendar, flat today wash — same anatomy.
+
+
+
+
+
+
+
Recommendation
+
Option A for content (Plan days, History, meals, recipes) — it is the
+ language the original mockups established and gives every item one obvious tap target.
+ Option B for dense reference lists (Settings, ingredients inside a recipe, sets
+ inside a session) where items are read as a group, using the new hair-in separator.
+
+
Delete the .tap .card transparent override — tappable items render
+ as cards again, 8px gap, pressed state stays scale(.985).
+
Merge the five row primitives into one .item with the anatomy
+ above; value columns get nowrap + tabular numerals, titles flex.
+
Add --hair-in (#2b2f37 dark /
+ #d9dcd1 light) for separators inside raised surfaces;
+ --hair returns to shell chrome only.
+
Delete .dimrow — dim glyphs, never text; rest days keep mut copy.
+
History rows lead with the name, friendly date in the sub, one stacked value
+ pair on the right.
+
Status becomes a tag, not a paint job — "Missed" is a small warm chip; warm
+ text stops doubling as row color.
+
Flatten the today card — the gradient becomes a flat accent wash
+ (color-mix(in srgb, var(--volt) 12%, var(--raised))), both themes.
+
Bold the week calendar — 700 mut labels, solid volt fill when done, warn wash
+ when missed, a 2px volt ring on today.
+
Deepen the light theme a step — bg #eef0ea, mut
+ #4d545d, dim #7f8790, accent tints 12→18%, soft card
+ shadows. Dark tokens don't move.
+
+
All changes are CSS + class swaps in the row markup — no API or state
+ changes; themes and accent palettes inherit through the tokens. Sheet lives at
+ docs/mockups/45-design-refresh-options.html.
+
+
+
diff --git a/docs/user-stories.md b/docs/user-stories.md
index 6800f12..5a9eeef 100644
--- a/docs/user-stories.md
+++ b/docs/user-stories.md
@@ -265,15 +265,87 @@ kept below for the record, not as work:
**E15.3 — Export**
- AC1: Authenticated JSON export of all of a user's own data (and only theirs).
-## E16 · Appearance *(added Jul 2026, shipped)*
-
-**E16.1 — Theme & accent palette**
-As James, I want to set the app's theme and accent so that it reads well in my hands and to my taste.
-- AC1: Settings → Appearance offers **Dark / Light / Auto** (Void×Volt ↔ Paper×Moss) and five accent palettes — Volt, Glacier, Aurora, Flare, Violet — each a split-dot preview of its dark and light value; choice stored in `prefs` (`theme`, `palette`) and mirrored to `` for first-paint.
-- AC2: A palette re-points **only** the accent token family (`--volt*`, `--cta-*`), each carrying a hand-tuned dark AND light pair; neutrals and the warm warning colour never move, and there is never a second simultaneous accent.
-- AC3: The map basemap, charts, and every accent surface follow the live tokens, so a palette change carries through the whole app including run detail (E5.4) in both themes.
-
-**E16.2 — Responsive shell**
+## E16 · Nutrition *(beta track — designed Jul 2026, mockups 38–44)*
+
+Decisions (James, Jul 2026): all meals planned weekly; plan-first one-tap logging (no food-database diary); auto carry-over for waste; lunch assist = favorites ledger + menu paste (MealPal/Grubhub have no public APIs); dinners are household-shared; targets tuned for cholesterol — high protein, high fiber, sat-fat cap.
+
+**E16.1 — Coach-set nutrition targets**
+As James, I want daily targets (kcal, protein, fiber, sat-fat cap) set by my coach from my goals and labs so that the numbers mean something.
+- AC1: Targets live in `prefs.nutrition_targets`, proposed via a nutrition intake conversation and changed only through chat with a confirmation card; Settings → Nutrition renders them read-only with "discuss with the coach".
+- AC2: Target rationale references training load and the latest lipid panel, keeping the E7.2 GP-framing boundary.
+- AC3: The cholesterol trio (protein, fiber, sat-fat cap) is first-class wherever macros render — never buried behind calories.
+
+**E16.2 — Recipe library**
+As the Coach, I want a curated recipe library so that plans only contain meals we can actually cook.
+- AC1: Recipes carry per-serving macros (kcal, protein, fiber, sat fat, carbs, fat), minutes, difficulty (**easy | medium only**), servings, steps, and quantified ingredients; ~40 cholesterol-aligned seeds, insert-missing like the exercise seed.
+- AC2: Only complete entries are proposable (mirror of E3.1 AC3), validated server-side.
+- AC3: Ingredients normalize into an `ingredients` table (name, aisle, pack size, per-100 g macros) so shopping lists and waste math work; storage stays canonical (g / ml / kcal), conversion at the display edge.
+- AC4: Every recipe carries a palette-native SVG plate illustration — a shared `platefig` engine (plate/tray/bowl primitives + per-recipe composition, the formfig approach): thumbnails on day/week/proposal rows, full art on recipe detail and the cook-mode finish. No food photography anywhere.
+- AC5: Method steps are written to be cooked from, not skimmed: each carries a duration, restated quantities where they matter, and a **done-when** checkpoint ("74° / juices run clear") rather than bare instructions.
+
+**E16.3 — Food week proposals**
+As James, I want next week's meals proposed each Sunday alongside training so that one review sets up the whole week.
+- AC1: `meal_revisions` mirror plan_revisions (proposed/active/superseded, JSONB days→slots, rationale, `changes` delta list, per-day `why`); exactly one pending; banner + bottom-sheet approval reuse the proposal UX.
+- AC2: Every slot is planned: dinners as recipes (`prefs.cook_nights`, batch nights allowed, ≥1 zero-cook night), work-day lunches as order-assist slots carrying target macros + budget, breakfasts/snacks from reusable templates.
+- AC3: Validators: weekly averages within targets; sat-fat cap honored with planned exceptions (night out) explicitly banked in the rationale; every kept carry-over consumed (E16.5) or excused; est. grocery cost within `prefs.budget_grocery` or the overage explained.
+- AC4: No new push kinds — the food proposal rides the existing Sunday proposal notification (E12.1 unchanged).
+
+**E16.4 — One-tap logging**
+As James, I want eating on plan to be one tap so that tracking survives real life.
+- AC1: Each planned slot is a tick row; ticking writes a `meal_log` row snapshotting macros (later recipe edits never rewrite history); household dinners log one plate per participating user.
+- AC2: Off-plan food is described in chat (text or photo); the coach estimates macros and writes via confirmation card, flagged `estimated`.
+- AC3: The day view shows four meters — protein, fiber, sat-fat cap, kcal — vs targets; ticks queue offline like logged sets.
+
+**E16.5 — Carry-over & waste**
+As James, I want each week's plan to use up what the last one left so that food stops going in the bin.
+- AC1: Approving a food week computes expected leftovers (purchased packs minus recipe consumption) into `carryovers` with use-by estimates; the Sunday review opens with a keep/bin confirm list.
+- AC2: Kept items must appear in the new proposal or the rationale says why not; binned items feed a waste log the coach learns from (stop buying formats that get binned).
+- AC3: The weekly review reports waste like cool-down skips; Progress shows the bought-vs-used trend.
+
+**E16.6 — Shopping list & Amazon Fresh**
+- AC1: The approved week generates one list — recipes minus carry-overs minus pantry staples — grouped by aisle, each item annotated with the meals that want it, with est. cost vs budget.
+- AC2: Export v1: "Send to Amazon Fresh" opens per-item Fresh search links (share-sheet/copy fallback); no retailer credentials stored.
+- AC3: Automation v2 (Phase 9): a server-side browser job assembles the Fresh cart; **checkout is always manual**. HelloFresh sunset is tracked in Settings until cancelled.
+
+**E16.7 — Lunch assist**
+As James, I want help choosing a healthy work lunch within budget so that ordering out doesn't undo the week.
+- AC1: `lunch_favorites` ledger (vendor, item, price, macros, notes); favorites render as chips in chat and on the day view's lunch slot.
+- AC2: A pasted/screenshotted MealPal or Grubhub menu returns ranked picks scored against the day's remaining targets + `prefs.budget_lunch`, with modifications ("skip the white sauce") and explicit skips with reasons; choosing one logs it.
+- AC3: Repeat orders auto-promote to favorites; no vendor API is assumed anywhere.
+
+**E16.8 — Household semantics**
+- AC1: The food week, recipes, and shopping list are household-shared (like the Home equipment profile); dinner portions scale with participants.
+- AC2: Targets, meal logs, and lunch favorites are strictly per-user — segregation tests extend to every nutrition table, demo user included.
+- AC3: Shelby's dinner participation is opt-in (default on); her plate logs against her targets.
+
+**E16.10 — Cook mode**
+As James, I want cooking walked one step at a time so that the phone works propped on the counter with messy hands.
+- AC1: "Cook step-by-step" renders the method one step per screen — kitchen-scale type, step-progress dots, the step's done-when checkpoint and quantities, plus a "while you wait" hint where one exists.
+- AC2: Steps with a duration run a countdown ring (the rest-ring pattern reused); timers are local and in-app only — E12.1's two push kinds are untouched.
+- AC3: Batch checkpoints ("box half before plating") are explicit tick rows; finishing cook mode logs the dinner (E16.4) with plate count, credits each participating user, and marks the linked zero-cook day as boxed.
+- AC4: Quick-log stays one tap for already-cooked meals — cook mode is optional, never a gate.
+
+**E16.11 — Recipe import (sourcing)**
+As James, I want recipes I already like — a BBC Good Food link, a photo of a HelloFresh card — pulled into the pool so that the library grows from taste, without me browsing the internet for dinner.
+- AC1: Pasting a recipe URL in chat imports it: the server fetches **that single page** and parses its schema.org Recipe JSON-LD when present; otherwise (and for photographed HelloFresh cards or screenshots) the coach reads the content directly. No crawling, no search — one page per explicit ask.
+- AC2: Imports are **normalized, not copied**: ingredients map to the `ingredients` table (new ones added with per-100 g macros), per-serving macros recomputed from ingredients and cross-checked against the source's published nutrition (BBC GF and HelloFresh both publish sat fat + fiber; large disagreement is surfaced, not averaged), steps rewritten in Forge's done-when voice (original prose never stored — app voice, and keeps the shared library clean for the public demo seat), platefig assigned, difficulty capped at medium or the recipe is flagged.
+- AC3: A confirmation card shows the parsed recipe (name, macros, mapped ingredients, `source` + `source_url` attribution) before anything is written; incomplete parses land as drafts the coach cannot propose.
+- AC4: The coach may **adapt on import** to fit targets (e.g. yogurt for cream, sat 19 → 7) with the change stated, or park a recipe as an explicit occasional treat — never silently reject.
+- AC5: Proposals draw only from the in-app pool; the coach never fetches the internet at proposal time. New imports become proposable at the next review (or immediately via swap).
+
+**E16.9 — Nutrition × labs**
+- AC1: Dashboard + Progress gain weekly fiber and sat-fat trends with lipid-panel draw dates marked; protein adherence renders beside strength trends.
+- AC2: The weekly review references nutrition adherence with verifiable numbers ("fiber averaged 41 g; the cap slipped twice — both takeaway Fridays").
+- AC3: Panel-change interpretation references diet levers, always with GP framing (E8.1 AC3 extended).
+## E17 · Appearance *(added Jul 2026, shipped)*
+
+**E17.1 — Theme & contrast**
+As James, I want to set the app's theme and contrast so that it reads well in my hands and in any light.
+- AC1: Settings → Appearance offers **Dark / Light / Auto** (Void×Volt ↔ Paper×Moss) and a **contrast** control (Auto / More / Standard); both stored in `prefs` (`theme`, `contrast`) and mirrored to `` for first-paint. Auto contrast follows the OS `prefers-contrast` signal.
+- AC2: The accent is fixed volt — one accent everywhere (actions, trends, positive status), never a second simultaneous accent; there is no palette picker (the multi-accent palettes were removed Jul 2026).
+- AC3: More-contrast lifts only the eye-straining tiers (`--dim`, shell hairlines) past AA; `--ink`/`--mut` and the accent family never move, so Void×Volt is untouched. The map basemap and charts follow the live tokens, so theme carries through the whole app including run detail (E5.4) in both themes.
+
+**E17.2 — Responsive shell**
As James, I want one app that fits a small phone, a big phone, and a desktop so that the dashboard isn't a separate thing.
- AC1: One DOM adapts by CSS only: ≤380px compacts type/padding; ≥640px widens the bordered column; ≥900px (with real height, so landscape phones keep bottom tabs) turns the tab bar into a fixed left rail and centres content in a reading column.
- AC2: Signed-out screens carry no rail; all shell bands cap to the reading column so screen chrome never escapes the app frame.
diff --git a/ios/CLAUDE.md b/ios/CLAUDE.md
index 0205625..4b4fce4 100644
--- a/ios/CLAUDE.md
+++ b/ios/CLAUDE.md
@@ -28,6 +28,13 @@ offline queue) are in. Next: history/records/trends, then coach, then settings.
- Run from the repo root. If `xcodebuild` reports no such project, `xcode-select -p` is pointing
at the Command Line Tools instead of Xcode.app.
+**Never write the public host into a tracked file** — root `CLAUDE.md`'s rule, and
+`Kit/ServerConfig.swift` is the client-side half of it. The production origin is read at
+runtime from `ServerDefaults.plist`, which is gitignored; copy `ServerDefaults.example.plist`
+and fill in `BaseURL`. Without it the app falls back to `http://localhost:8000` and shows the
+server picker, so a fresh clone still builds. Synchronized groups bundle the plist
+automatically — there is no Xcode step.
+
## Xcode project rules
- **Never hand-edit `project.pbxproj`.** Source folders use file system synchronized groups,
diff --git a/ios/Forge/Forge/Features/Auth/SignInView.swift b/ios/Forge/Forge/Features/Auth/SignInView.swift
index b2952e3..29ff2ee 100644
--- a/ios/Forge/Forge/Features/Auth/SignInView.swift
+++ b/ios/Forge/Forge/Features/Auth/SignInView.swift
@@ -8,6 +8,14 @@ struct SignInView: View {
@State private var busy = false
@State private var showServerPicker = false
+ private var showsServerRow: Bool {
+ #if DEBUG
+ return true
+ #else
+ return !ServerConfig.isConfigured
+ #endif
+ }
+
var body: some View {
VStack(spacing: 0) {
Spacer()
@@ -55,16 +63,26 @@ struct SignInView: View {
}
}
- #if DEBUG
+ // Shown in debug, and in any build that wasn't given a production
+ // origin — an app that doesn't know where its server is should say
+ // so rather than fail opaquely against a localhost it can't reach.
+ if showsServerRow {
Button {
showServerPicker = true
} label: {
- Text(auth.client.baseURL.host() ?? "server")
- .font(.caption)
- .foregroundStyle(theme.dim)
+ VStack(spacing: 2) {
+ Text(auth.client.baseURL.host() ?? "server")
+ .font(.caption)
+ .foregroundStyle(theme.dim)
+ if !ServerConfig.isConfigured {
+ Text("No server configured — tap to choose")
+ .font(.caption2)
+ .foregroundStyle(theme.warn)
+ }
+ }
}
.padding(.top, 24)
- #endif
+ }
}
.padding(28)
.frame(maxWidth: .infinity, maxHeight: .infinity)
diff --git a/ios/Forge/Forge/Kit/ServerConfig.swift b/ios/Forge/Forge/Kit/ServerConfig.swift
index 296b425..acafcd2 100644
--- a/ios/Forge/Forge/Kit/ServerConfig.swift
+++ b/ios/Forge/Forge/Kit/ServerConfig.swift
@@ -1,30 +1,51 @@
import Foundation
-/// Where the app points. Release builds go to production; debug builds are
-/// editable at runtime (Settings → Server) so the Simulator can be aimed at a
-/// local uvicorn or the home server on the LAN without a rebuild.
+/// Where the app points.
+///
+/// **The production origin is deliberately not in tracked source** — the public
+/// host lives only in `BASE_URL` in the gitignored compose override (root
+/// `CLAUDE.md`), and this file is the client-side half of that rule. It is read
+/// at runtime from `ServerDefaults.plist`, which is gitignored; copy
+/// `ServerDefaults.example.plist` next to it and fill in `BaseURL`.
+///
+/// Without that file the app falls back to a local server and offers the
+/// picker, so a fresh clone still builds and runs — it just doesn't know where
+/// production is, which is the point.
nonisolated enum ServerConfig {
- static let production = URL(string: "https://www.get-forged.com")!
+ private static let overrideKey = "forge.baseURL"
+ private static let fallback = URL(string: "http://localhost:8000")!
- private static let key = "forge.baseURL"
+ /// The bundled production origin, if this build was given one.
+ static var production: URL? {
+ guard let url = Bundle.main.url(forResource: "ServerDefaults", withExtension: "plist"),
+ let plist = NSDictionary(contentsOf: url),
+ let raw = plist["BaseURL"] as? String,
+ let parsed = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)),
+ parsed.scheme != nil
+ else { return nil }
+ return parsed
+ }
+
+ /// True when this build knows its own server. When false the sign-in screen
+ /// surfaces the picker in any configuration, not just debug — an app that
+ /// can't reach anything should say so rather than fail opaquely.
+ static var isConfigured: Bool { production != nil || storedOverride != nil }
static var baseURL: URL {
- get {
- #if DEBUG
- if let raw = UserDefaults.standard.string(forKey: key), let url = URL(string: raw) {
- return url
- }
- #endif
- return production
- }
- set {
- UserDefaults.standard.set(newValue.absoluteString, forKey: key)
- }
+ get { storedOverride ?? production ?? fallback }
+ set { UserDefaults.standard.set(newValue.absoluteString, forKey: overrideKey) }
}
- /// Presets offered in the debug server picker.
- static let presets: [(label: String, url: URL)] = [
- ("Production", production),
- ("Simulator localhost", URL(string: "http://localhost:8000")!),
- ]
+ private static var storedOverride: URL? {
+ UserDefaults.standard.string(forKey: overrideKey).flatMap(URL.init(string:))
+ }
+
+ /// Presets offered in the server picker. Production only appears when this
+ /// build actually has one.
+ static var presets: [(label: String, url: URL)] {
+ var out: [(String, URL)] = []
+ if let production { out.append(("Production", production)) }
+ out.append(("Simulator localhost", fallback))
+ return out
+ }
}
diff --git a/ios/Forge/Forge/ServerDefaults.example.plist b/ios/Forge/Forge/ServerDefaults.example.plist
new file mode 100644
index 0000000..3c792e4
--- /dev/null
+++ b/ios/Forge/Forge/ServerDefaults.example.plist
@@ -0,0 +1,20 @@
+
+
+
+
+
+ BaseURL
+ https://forge.example.com
+
+
diff --git a/ios/XCODE-HANDOVER.md b/ios/XCODE-HANDOVER.md
index 768af68..02df9a8 100644
--- a/ios/XCODE-HANDOVER.md
+++ b/ios/XCODE-HANDOVER.md
@@ -73,7 +73,7 @@ trip otherwise:
| Push Notifications | N3 | pairs with the APNs key in B3 |
| Background Modes | N2/N3 | tick *Remote notifications* and *Workout processing* |
| App Groups | N3 | `group.com.get-forged.forge` — shared container for app ↔ widgets ↔ Watch |
-| Associated Domains | later | `applinks:www.get-forged.com` |
+| Associated Domains | later | `applinks:` + the public host from `BASE_URL` |
Adding these creates `Forge.entitlements` and sets `CODE_SIGN_ENTITLEMENTS`. Commit that file.
@@ -117,8 +117,32 @@ address over plain HTTP. `localhost` already works without this.
dictionary with `NSAllowsLocalNetworking = YES`. **Debug configuration only** — never let
this reach a Release build.
-Skip it unless the localhost loop stops being enough; testing against
-`https://www.get-forged.com` needs no exception at all.
+Skip it unless the localhost loop stops being enough; testing against the public
+HTTPS host needs no exception at all.
+
+### A6 · Decide whether the bundle ID may encode the public host `[ ]`
+
+Root `CLAUDE.md` says the public domain must never be written into a tracked file. The
+URLs are now scrubbed — the app reads its origin from a gitignored
+`ServerDefaults.plist` (see `Kit/ServerConfig.swift`) — but the **bundle identifier still
+spells the domain backwards**, in `project.pbxproj`, the App Group, and the Keychain
+service. On a public repo that leaks it just as effectively as a URL would.
+
+Left alone deliberately: renaming it is `project.pbxproj` work, and it invalidates the App
+IDs and provisioning already registered against it.
+
+**Options**:
+
+- **Accept it.** A bundle ID is visible on TestFlight and the App Store anyway, so this is
+ only a leak while the repo is public and the app isn't.
+- **Rename before signing.** Cheapest *now*, before A1 registers anything with Apple —
+ pick an identifier that doesn't encode the host, update `PRODUCT_BUNDLE_IDENTIFIER` on
+ all three targets, the App Group in A2, and `kSecAttrService` in `Kit/Keychain.swift`
+ (that last one is agent work — hand it back). After A1/B1 the same change means
+ re-registering identifiers in the portal.
+
+**Recommendation**: decide before A1. It costs a build setting today and a portal cleanup
+later.
---
diff --git a/server/app/coach.py b/server/app/coach.py
index eb22a2a..cfd3dc3 100644
--- a/server/app/coach.py
+++ b/server/app/coach.py
@@ -9,12 +9,13 @@
import json
import logging
+import re
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from .config import get_settings, local_today
-from .models import AgentRun, ChatMessage, Exercise, Niggle, Plan, PlanRevision, User
+from .models import AgentRun, ChatMessage, Exercise, MealRevision, Niggle, Plan, PlanRevision, Recipe, User
log = logging.getLogger("forge.coach")
@@ -64,7 +65,7 @@
"required": ["type", "value"]}},
{"name": "propose_revision",
"description": ("Propose next week's plan. content = {\"days\": {\"0\"..\"6\": day}, \"changes\": [...]}. "
- "Strength day: {name, kind:'strength', focus:[...], why:'one short line on this day's intent', "
+ "Strength day: {name, kind:'strength', focus:[...], why:'this session's note — see below', "
"exercises:[{slug, sets, reps, weight, rest, priority, min_sets}], "
"cooldown:[{slug, hold}]}. Exactly one exercise per strength day has priority 1 (the protected main lift); "
"min_sets 0 marks droppable accessories. Cardio day: {name, kind:'cardio', focus:[...], why:'...', "
@@ -75,8 +76,16 @@
"cooldown never empty on strength days. rationale = 2-3 plain sentences on what THIS "
"WEEK ACHIEVES, written forward: the load/volume milestones it reaches, the weekly cardio "
"minutes it banks, what it sets up next ('Bench moves to 60 kg for the first time; 90 min "
- "of Zone 2 keeps the aerobic block on track'). Real numbers, future tense. Do NOT restate "
- "the diff — changes[] already carries per-change whys."),
+ "of Zone 2 keeps the aerobic block on track'). Real numbers, future tense. The user reads "
+ "it ON that week's Plan screen once the plan is active, so present voice — 'This week', "
+ "day names — NEVER 'next week' (the validator rejects it). Do NOT restate "
+ "the diff — changes[] already carries per-change whys. "
+ "Each day's why is a SESSION note the user reads on that day's row: one line grounded in "
+ "that session's content this week — the main lift's progression state ('third week at 5×5 "
+ "60 kg; fast bar speed earns 62.5 next'), the specific stimulus ('extra pull set to balance "
+ "Monday's pressing'), or a watch point ('first squats since the knee niggle — stop short of "
+ "pain'). Never week-level copy, never the rationale rephrased, never last week's line, no "
+ "two days alike — the validator rejects all four."),
"input_schema": {"type": "object", "properties": {"content": {"type": "object"}, "rationale": {"type": "string"}},
"required": ["content", "rationale"]}},
{"name": "log_niggle", "description": "Record a new niggle after the user confirms. Args: body_part, severity (mild/moderate), note, avoid_patterns (e.g. ['deep_lunge','overhead_press']), mobility_slug (optional cool-down stretch to inject).",
@@ -85,6 +94,47 @@
"mobility_slug": {"type": "string"}}, "required": ["body_part"]}},
{"name": "clear_niggle", "description": "Mark a niggle cleared after the user confirms. Args: niggle_id.",
"input_schema": {"type": "object", "properties": {"niggle_id": {"type": "string"}}, "required": ["niggle_id"]}},
+ {"name": "get_food_week", "description": "The current calendar food week (Mon–Sun): each day's planned slots (recipes with per-serving macros), what the user has logged, day totals, and their nutrition targets. Read this before any food conversation.",
+ "input_schema": {"type": "object", "properties": {}}},
+ {"name": "get_recipes", "description": "The recipe pool you may plan food weeks from: slug, name, kind (dinner/breakfast/lunch/snack), per-serving macros, minutes, difficulty, batch servings. Proposals draw ONLY from this pool — never invent recipes. Args: kind (optional filter).",
+ "input_schema": {"type": "object", "properties": {"kind": {"type": "string"}}}},
+ {"name": "get_food_proposal", "description": "The pending food-week proposal awaiting approval (null if none). Read this before discussing or revising a food proposal.",
+ "input_schema": {"type": "object", "properties": {}}},
+ {"name": "propose_food_week",
+ "description": ("Propose next week's food for the household. content = {\"days\": {\"0\"..\"6\": {\"slots\": "
+ "{breakfast, lunch, dinner, snack}}}}. Every slot on every day must be planned. A slot is one of: "
+ "{recipe: slug, why?: '...'} · {order: true, note: 'target macros + budget'} (lunches only) · "
+ "{out: true, note: '...'} (dinner only — the planned night out) · {leftover_of: '0'} (inherits that "
+ "day's dinner; at least one dinner per week must be zero-cook via leftover or out). Every cooked "
+ "dinner needs a why one-liner about THAT plate that night — its macro job in the day, its "
+ "batch/leftover role, or the carry-over it uses ('lentils do the fiber lifting on a low-veg day', "
+ "'boxes two lunches for the office run') — never the week's goals rephrased, never the line the "
+ "current week already shows for a changed dinner, no two days alike. "
+ "Validators enforce: complete recipes only, weekly-average protein/fiber "
+ "at target, sat-fat cap honored with headroom banked for a night out, difficulty ≤ medium. "
+ "changes = diff vs the current food week, each {sign:'+'|'-'|'~', what, why}. rationale = 2-3 "
+ "sentences on what the week achieves (averages hit, carry-overs used, what the night out costs)."),
+ "input_schema": {"type": "object", "properties": {"content": {"type": "object"}, "changes": {"type": "array"},
+ "rationale": {"type": "string"}}, "required": ["content", "changes", "rationale"]}},
+ {"name": "log_meal",
+ "description": ("Log something the user ate AFTER echoing your macro estimate and getting a yes. Off-plan food "
+ "(described or photographed in chat) needs label + kcal + protein_g + fiber_g + satfat_g with "
+ "estimated: true — also estimate carbs_g, sugar_g, fat_g and sodium_mg (the app tracks the "
+ "full label set). Planned/known recipes: pass recipe (slug) + servings instead. "
+ "Args: date (YYYY-MM-DD, optional = today), slot (breakfast|lunch|dinner|snack)."),
+ "input_schema": {"type": "object", "properties": {"date": {"type": "string"}, "slot": {"type": "string"},
+ "recipe": {"type": "string"}, "servings": {"type": "number"}, "label": {"type": "string"},
+ "kcal": {"type": "number"}, "protein_g": {"type": "number"}, "carbs_g": {"type": "number"},
+ "sugar_g": {"type": "number"}, "fiber_g": {"type": "number"}, "fat_g": {"type": "number"},
+ "satfat_g": {"type": "number"}, "sodium_mg": {"type": "number"},
+ "estimated": {"type": "boolean"}},
+ "required": ["slot"]}},
+ {"name": "get_carryovers", "description": "Leftover shop items from previous food weeks (the waste loop): item, qty, use-by, status (open|kept|binned|consumed). Kept items must be consumed by the next proposal or excused in its rationale.",
+ "input_schema": {"type": "object", "properties": {}}},
+ {"name": "update_carryovers",
+ "description": ("Record the Sunday keep/bin confirm and new leftovers. updates = [{id, status: open|kept|binned|consumed}]; "
+ "add = [{item, qty, use_by?: YYYY-MM-DD}]. Binned items are waste — learn from the formats that get binned."),
+ "input_schema": {"type": "object", "properties": {"updates": {"type": "array"}, "add": {"type": "array"}}}},
{"name": "log_lab_panel",
"description": "Save a lab panel AFTER echoing the parsed values and getting the user's confirmation. Args: drawn_on (YYYY-MM-DD), results:[{marker, value, unit, ref_low, ref_high}].",
"input_schema": {"type": "object", "properties": {"drawn_on": {"type": "string"},
@@ -92,6 +142,39 @@
]
+_UESC = re.compile(r"\\u([0-9a-fA-F]{4})")
+
+
+def repair_text(s: str) -> str:
+ """The model sometimes double-escapes unicode in tool args ('\\u2014'
+ survives JSON decoding as six literal characters) and the user sees raw
+ backslash sequences in their coach note. Decode exactly that pattern —
+ nothing else, so legitimate backslashes elsewhere stay untouched."""
+ return _UESC.sub(lambda m: chr(int(m.group(1), 16)), s)
+
+
+def repair_deep(x):
+ """repair_text over every string in a nested content dict/list."""
+ if isinstance(x, str):
+ return repair_text(x)
+ if isinstance(x, dict):
+ return {k: repair_deep(v) for k, v in x.items()}
+ if isinstance(x, list):
+ return [repair_deep(v) for v in x]
+ return x
+
+
+def _validate_rationale_voice(rationale: str) -> str | None:
+ """The rationale is displayed as the coach's note ON the week it covers —
+ by the time the user reads it, that week is 'this week'. 'Next week's…'
+ framing (natural at Sunday-review time) reads as a mismatch."""
+ low = rationale.strip().lower()
+ if low.startswith("next week") or "next week's" in low:
+ return ("rationale is shown on that week's Plan screen once active — by then it IS the "
+ "current week. Write it in present voice ('This week…', day names), never 'next week'")
+ return None
+
+
def _validate_content(db: Session, content: dict) -> str | None:
days = content.get("days")
if not isinstance(days, dict) or not days:
@@ -131,8 +214,60 @@ def _validate_content(db: Session, content: dict) -> str | None:
return None
+def _norm_note(s: str) -> str:
+ return " ".join(re.sub(r"[^a-z0-9 ]", " ", (s or "").lower()).split())
+
+
+def _day_sig(day: dict) -> str:
+ """A day's content identity, ignoring its note — used to tell 'this day
+ changed' from 'this day is carried over'."""
+ return json.dumps({k: v for k, v in (day or {}).items() if k != "why"},
+ sort_keys=True, default=str)
+
+
+def _validate_day_notes(db: Session, user: User, content: dict, rationale: str) -> str | None:
+ """Day whys are per-session coaching notes, not week copy. Enforced: a day
+ whose content changed carries a note, and that note is fresh (not last
+ week's line for the day), specific (no two days share one), and not a
+ restatement of the weekly rationale — the user reads note and rationale
+ side by side on the proposal card."""
+ prev = (db.query(PlanRevision).join(Plan)
+ .filter(Plan.user_id == user.id, Plan.domain == "training",
+ PlanRevision.status == "active")
+ .order_by(PlanRevision.num.desc()).first())
+ prev_days = ((prev.content or {}).get("days", {}) or {}) if prev else {}
+ rat = _norm_note(rationale)
+ seen: dict[str, str] = {}
+ for key in sorted(content.get("days") or {}):
+ day = (content["days"].get(key)) or {}
+ prev_day = prev_days.get(key) or {}
+ changed = _day_sig(day) != _day_sig(prev_day)
+ n = _norm_note(day.get("why") or "")
+ if changed and not n:
+ return (f"day {key}: changed but has no why — write this session's note: one specific "
+ "line grounded in its content (the main lift's progression state, the stimulus "
+ "it adds, what to watch), not a week-level statement")
+ if not n:
+ continue
+ if n in seen.values():
+ dup = next(k for k, v in seen.items() if v == n)
+ return (f"days {dup} and {key} share the same why — each day's note must speak to "
+ "that session specifically")
+ if rat and (n in rat or rat in n):
+ return (f"day {key}: the why restates the weekly rationale — the rationale tells the "
+ f"week's story once; this note tells day {key}'s story")
+ if changed and prev_day and n == _norm_note(prev_day.get("why") or ""):
+ return (f"day {key}: the session changed but its why is last week's line — rewrite "
+ "the note from what actually changed this week")
+ seen[key] = n
+ return None
+
+
def create_proposal(db: Session, user: User, content: dict, rationale: str) -> dict:
- err = _validate_content(db, content)
+ content = repair_deep(content)
+ rationale = repair_text(rationale)
+ err = (_validate_content(db, content) or _validate_rationale_voice(rationale)
+ or _validate_day_notes(db, user, content, rationale))
if err:
return {"error": err}
plan = (db.query(Plan).filter(Plan.user_id == user.id, Plan.domain == "training")
@@ -255,6 +390,40 @@ def _exec_tool(db: Session, user: User, name: str, args: dict) -> object:
n.status = "cleared"
db.commit()
return {"ok": True}
+ if name == "get_food_week":
+ from .routers import food
+ return food.food_week(date=None, user=user, db=db)
+ if name == "get_recipes":
+ kind = args.get("kind")
+ rows = db.query(Recipe).all()
+ from .models import MACRO_FIELDS
+ return [{"slug": r.slug, "name": r.name, "kind": r.kind, "minutes": r.minutes,
+ "difficulty": r.difficulty, "serves": r.serves, "batch": r.batch,
+ **{k: getattr(r, k) for k in MACRO_FIELDS},
+ "tags": r.tags, "complete": bool(r.complete)}
+ for r in rows if not kind or r.kind == kind]
+ if name == "get_food_proposal":
+ from .routers.food import food_proposal
+ return food_proposal(user=user, db=db)
+ if name == "propose_food_week":
+ from .food_coach import create_food_proposal
+ return create_food_proposal(db, user, args.get("content") or {},
+ args.get("changes") or [], args.get("rationale") or "")
+ if name == "log_meal":
+ from .routers.food import LogIn, log_meal
+ from .models import MACRO_FIELDS
+ body = LogIn(date=args.get("date"), slot=args["slot"], recipe=args.get("recipe"),
+ servings=args.get("servings") or 1, label=args.get("label"),
+ estimated=bool(args.get("estimated")),
+ source="plan" if args.get("recipe") else "chat",
+ **{k: args.get(k) for k in MACRO_FIELDS})
+ return log_meal(body, user=user, db=db)
+ if name == "get_carryovers":
+ from .food_coach import list_carryovers
+ return list_carryovers(db, user)
+ if name == "update_carryovers":
+ from .food_coach import update_carryovers
+ return update_carryovers(db, user, args.get("updates"), args.get("add"))
if name == "log_lab_panel":
from .routers.misc import LabPanelIn, LabResultIn, add_panel
body = LabPanelIn(drawn_on=args["drawn_on"], source="coach-chat",
@@ -287,6 +456,25 @@ def _week_so_far(db: Session, user: User) -> str:
+ (" A proposed plan revision is awaiting their approval (get_proposal)." if pending else ""))
+def _food_context(db: Session, user: User) -> str:
+ """One grounding line for the food half: daily targets + pending proposal."""
+ from .food_coach import food_scope
+ from .routers.food import targets_for
+ t = targets_for(user)
+ prefs = user.prefs or {}
+ scope = food_scope(user)
+ q = db.query(MealRevision).filter(MealRevision.status == "proposed")
+ q = q.filter(MealRevision.user_id == scope) if scope else q.filter(MealRevision.user_id.is_(None))
+ pending = q.first() is not None
+ return (f"Nutrition targets (daily): {t['kcal']} kcal · protein {t['protein_g']} g · "
+ f"carbs {t.get('carbs_g', 250)} g · fiber {t['fiber_g']} g · fat {t.get('fat_g', 80)} g. "
+ f"Caps: sat fat {t['satfat_g']} g · sugar {t.get('sugar_g', 65)} g · "
+ f"sodium {t.get('sodium_mg', 2300)} mg. "
+ f"Cook nights: {prefs.get('cook_nights', 4)}/week; grocery budget {prefs.get('budget_grocery', '?')}; "
+ f"lunch cap {prefs.get('budget_lunch', '?')}."
+ + (" A proposed food week is awaiting approval (get_food_proposal)." if pending else ""))
+
+
# Static half of the system prompt: identical for every user and every request,
# so the prompt cache can share it (together with the TOOLS schemas that render
# before it). Anything user- or day-specific belongs in _system_prompt below.
@@ -301,6 +489,8 @@ def _week_so_far(db: Session, user: User) -> str:
- Hard boundary: you are not a doctor. Never advise on medication or dosing. Lab trends: describe the data, connect it to training/weight changes, and suggest discussing decisions with their GP.
- Plan rationales sell the week ahead, not the edit log: lead with what the athlete will achieve by Sunday (loads reached, minutes banked, what it unlocks). The changes list handles the diff.
- Mid-week changes use amend_week (only the affected days); full next-week plans use propose_revision. When the user asks to tweak a pending proposal, read get_proposal first, then propose the revised version.
+- You also coach the household's food week (the Food tab). The cholesterol trio leads: protein up, fiber up, sat fat capped — coached on WEEKLY AVERAGES, never single meals, and macro numbers are estimates (never imply lab precision). Dinners are household-shared (one plan; each person logs their own plate against their own targets). Food proposals draw only from the in-app recipe pool (get_recipes) — never invent recipes or fetch the internet at proposal time. Full food weeks use propose_food_week; to tweak a pending one, read get_food_proposal first, then propose the revised version.
+- Off-plan food described in chat: estimate macros from what they tell you, echo the estimate, and log_meal it (estimated: true) once they confirm. Same consent rule as labs.
- Messages may end with a bracketed context tag like "[re: session 2026-07-21 · Lower A]" — attached data for that item follows the message; treat it as what the user is looking at.
- Voice: short, concrete, warm, zero fluff. You're read on a phone between sets.
- Formatting (your chat replies only): the app renders markdown, so use it where it earns its keep — **bold** the key number or lift, a bullet list when you're laying out several items (a day's lifts, a few options), a numbered list for steps in order. Reach for a short "## heading" only to split a genuinely multi-part answer; a one- or two-sentence reply is just a sentence, never a heading or a lone bullet. Keep it tight — structure serves the glance, it isn't decoration. The propose_revision fields (rationale, each day's why, the change lines) render as plain text — no markdown in those."""
@@ -333,6 +523,7 @@ def _system_prompt(db: Session, user: User) -> str:
Goal: {plan.goal if plan else 'not set'}
{_week_so_far(db, user)}
+{_food_context(db, user)}
Active niggles: {nig_txt}
Active equipment profile: {prof.name if prof else 'unknown'} — prescribe only what get_equipment shows available.
Progression style: {style}. Plan changes: {approval} mode{' — your proposals apply immediately, so be conservative' if approval == 'auto' else ' — proposals wait for approval in the app'}."""
@@ -502,8 +693,17 @@ def run_chat(db: Session, user: User, text: str, extra_context: str = "") -> str
3. Call propose_revision with the full week, per-day why lines, the changes diff vs the
current week (every load change, swap, and volume move gets a row), and a rationale that
says what next week achieves — the loads it reaches, the minutes it banks, what it sets
- up — in real numbers, not a recap of the edits.
-4. Reply with a summary I can read in 30 seconds: what went well, what changes and why, anything you're watching. Mention that the proposal is waiting in the app (unless auto mode applied it)."""
+ up — in real numbers, not a recap of the edits. Day whys are session notes, not week
+ copy: each one speaks to that day's actual content this week and must differ from last
+ week's note on that day — write them fresh from this review's data.
+4. Food: read get_food_week (what got logged vs planned, day totals vs my targets) and
+ get_carryovers. If a food proposal is already pending (get_food_proposal), review it and
+ only re-propose if it needs changes. Otherwise call propose_food_week for next week:
+ recipes from get_recipes only, at least one zero-cook dinner (batch a recipe), open
+ carry-overs consumed or excused in the rationale, sat-fat headroom banked for any planned
+ night out, the changes diff and per-dinner why lines included. Report food adherence with
+ verifiable numbers ("fiber averaged 41 g; the cap slipped twice").
+5. Reply with a summary I can read in 30 seconds: what went well, what changes and why, anything you're watching. Mention that both proposals (training + food) are waiting in the app (unless auto mode applied them)."""
def run_review(db: Session, user: User) -> str:
diff --git a/server/app/config.py b/server/app/config.py
index bdb6f51..7367ac2 100644
--- a/server/app/config.py
+++ b/server/app/config.py
@@ -51,6 +51,9 @@ class Settings(BaseSettings):
coach_tz: str = "Europe/London"
review_weekday: int = 6 # Sunday
review_hour: int = 20
+ # Seed the curated recipe library + first food week on boot. Turn OFF to run
+ # an MCP-populated library (the pantry/ingredient reference still seeds).
+ seed_recipes: bool = True
@property
def allowlist(self) -> dict[str, str]:
diff --git a/server/app/demo.py b/server/app/demo.py
index ba0f106..cbae2a2 100644
--- a/server/app/demo.py
+++ b/server/app/demo.py
@@ -18,9 +18,10 @@
from sqlalchemy.orm import Session
from .fitting import epley_e1rm
-from .models import (AgentRun, ChatMessage, EquipmentProfile, IngestToken, LabPanel, LabResult,
- LoggedSet, Metric, Niggle, Plan, PlanRevision, Record, User,
- WorkoutSeries, WorkoutSession)
+from .models import (MACRO_FIELDS, AgentRun, Carryover, ChatMessage, EquipmentProfile, IngestToken,
+ LabPanel, LabResult, LoggedSet, LunchFavorite, MealLog, MealRevision, MediaBlob,
+ Metric, Niggle, NotificationLog, Plan, PlanRevision, PushSub, Recipe, Record,
+ User, WithingsLink, WorkoutSeries, WorkoutSession)
from .security import new_ingest_token
from .seed import _week, seed_user_defaults
@@ -258,9 +259,125 @@ def seed_demo(db: Session) -> User:
tool_calls=rng.randint(6, 11), ok=1, created_at=_ts(d, 20, 2)))
db.commit()
+ enrich_demo(db) # food/nutrition beta data rides the same reset
return bruce
+# ---------------------------------------------------------------- food (beta)
+
+def _resolve_demo_slot(entry: dict, days: dict) -> str | None:
+ if not entry or entry.get("out") or entry.get("order"):
+ return None
+ if entry.get("leftover_of") is not None:
+ src = days.get(str(entry["leftover_of"])) or {}
+ return ((src.get("slots") or {}).get("dinner") or {}).get("recipe")
+ return entry.get("recipe")
+
+
+def _seed_demo_food(db: Session, bruce: User, rng: random.Random) -> None:
+ """Bruce's food story: an active week (his own scope — the demo never sees
+ the household's), a pending food proposal, three weeks of plausible meal
+ logs including order-out lunches with venue/cost, carry-overs and two
+ vetted lunch favorites."""
+ from .config import local_today
+ from .food_seed import _first_week
+
+ # the food week keys off the app's calendar (Europe/London), not the OS zone —
+ # date.today() here would park "today's" half-logged day on yesterday overnight
+ today = local_today()
+ monday = today - timedelta(days=today.weekday())
+ week = _first_week()
+ days = week["days"]
+
+ db.add(MealRevision(user_id=bruce.id, num=1, status="active", content=week,
+ rationale="Protein and fiber land on target with sat-fat headroom "
+ "banked Mon–Thu for the Friday night out; the chili batch "
+ "covers Friday lunch so only three lunches are ordered."))
+ proposed = _first_week()
+ proposed["days"]["1"]["slots"]["dinner"] = {
+ "recipe": "prawn-soba-stirfry",
+ "why": "twelve minutes on your longest office day — and the week's fish without repeating salmon"}
+ db.add(MealRevision(user_id=bruce.id, num=2, status="proposed", content=proposed,
+ changes=[{"sign": "~", "what": "Tue dinner: salmon → prawn soba stir-fry",
+ "why": "salmon ran twice last week — keep the omega-3s, vary the plate"},
+ {"sign": "+", "what": "Thu chili batch +2 servings",
+ "why": "boxes Monday lunch too — one fewer order-out"}],
+ rationale="Averages hold at protein 158 g and fiber 39 g while sat fat "
+ "stays 4 g under the cap. Tuesday's stir-fry keeps cook time "
+ "at 12 minutes on the longest office day."))
+
+ recipes = {r.slug: r for r in db.query(Recipe).all()}
+ orders = [("Sweetgreen", "Harvest bowl, chicken", 13.75,
+ dict(kcal=620, protein_g=42, carbs_g=55, sugar_g=9, fiber_g=10, fat_g=25, satfat_g=4.5, sodium_mg=880)),
+ ("Chipotle", "Chicken bowl, brown rice, no cheese", 11.90,
+ dict(kcal=650, protein_g=48, carbs_g=62, sugar_g=5, fiber_g=11, fat_g=21, satfat_g=5, sodium_mg=1100)),
+ ("Pret", "Chicken & avocado salad", 9.50,
+ dict(kcal=430, protein_g=33, carbs_g=18, sugar_g=6, fiber_g=8, fat_g=25, satfat_g=4, sodium_mg=720))]
+
+ def log(d: date, slot: str, hh: int, mm: int, **kw) -> None:
+ db.add(MealLog(user_id=bruce.id, day=d, slot=slot, ts=_ts(d, hh, mm), **kw))
+
+ slot_times = {"breakfast": (8, 5), "lunch": (12, 45), "dinner": (19, 15), "snack": (15, 30)}
+ for back in range(21, -1, -1):
+ d = today - timedelta(days=back)
+ slots = (days.get(str(d.weekday())) or {}).get("slots") or {}
+ for slot, entry in slots.items():
+ if d == today and slot in ("dinner", "snack"):
+ continue # today: dinner still ahead — the tick rows have work to do
+ hh, mm = slot_times[slot]
+ if entry.get("order"):
+ if rng.random() < 0.9:
+ venue, label, cost, macros = orders[(d.toordinal() + d.weekday()) % len(orders)]
+ log(d, slot, hh, mm, label=label, venue=venue, cost=cost, currency="USD",
+ source="order", estimated=1, **macros)
+ continue
+ if entry.get("out"):
+ log(d, slot, 20, 30, label="Trattoria — pizza, side salad, one beer",
+ source="chat", estimated=1, kcal=1150, protein_g=42, carbs_g=118, sugar_g=14,
+ fiber_g=8, fat_g=48, satfat_g=16, sodium_mg=2100)
+ continue
+ slug = _resolve_demo_slot(entry, days)
+ r = recipes.get(slug or "")
+ if r and rng.random() < 0.88:
+ log(d, slot, hh, mm, recipe_slug=r.slug, label=r.name, source="plan",
+ **{k: round(getattr(r, k) or 0, 1) for k in MACRO_FIELDS})
+ if back == 6 and rng.random(): # one honest off-plan extra last week
+ log(d, "snack", 21, 40, label="Two squares dark chocolate", source="chat",
+ estimated=1, kcal=110, protein_g=1.5, carbs_g=9, sugar_g=7, fiber_g=2,
+ fat_g=8, satfat_g=4.5, sodium_mg=5)
+
+ db.add_all([
+ Carryover(user_id=bruce.id, week_start=monday - timedelta(days=7), item="harissa paste",
+ qty="⅔ jar", status="kept"),
+ Carryover(user_id=bruce.id, week_start=monday - timedelta(days=7), item="baby spinach",
+ qty="½ bag", status="binned"),
+ Carryover(user_id=bruce.id, week_start=monday, item="greek yogurt", qty="½ tub",
+ use_by=today + timedelta(days=3), status="open"),
+ LunchFavorite(user_id=bruce.id, vendor="grubhub", item="Sweetgreen harvest bowl",
+ price=13.75, kcal=620, protein_g=42, carbs_g=55, sugar_g=9, fiber_g=10,
+ fat_g=25, satfat_g=4.5, sodium_mg=880),
+ LunchFavorite(user_id=bruce.id, vendor="mealpal", item="Poke bowl, salmon + brown rice",
+ price=12.00, kcal=560, protein_g=38, carbs_g=58, sugar_g=8, fiber_g=7,
+ fat_g=18, satfat_g=3.5, sodium_mg=950),
+ ])
+
+
+def enrich_demo(db: Session) -> dict:
+ """Top up an existing demo with data newer features expect — the update
+ path for demos created before those features shipped. Idempotent per
+ block: only adds what's missing, never touches training history.
+ POST /api/admin/demo/enrich; a full reset gets everything anyway."""
+ bruce = demo_user(db)
+ if not bruce:
+ return {"exists": False, "added": []}
+ added: list[str] = []
+ if not db.query(MealRevision.id).filter_by(user_id=bruce.id).first():
+ _seed_demo_food(db, bruce, random.Random(7))
+ added.append("food")
+ db.commit()
+ return {"exists": True, "added": added}
+
+
def delete_demo(db: Session) -> bool:
"""Remove Bruce and every row he owns. Returns False if he doesn't exist."""
bruce = demo_user(db)
@@ -275,8 +392,13 @@ def delete_demo(db: Session) -> bool:
plan_ids = [i for (i,) in db.query(Plan.id).filter_by(user_id=bruce.id)]
if plan_ids:
db.query(PlanRevision).filter(PlanRevision.plan_id.in_(plan_ids)).delete(synchronize_session=False)
+ # Every user_id-carrying table must be here or the FK fails on Postgres the
+ # moment a visitor writes a row (the demo seat is public — it CAN log meals,
+ # subscribe to push, etc.). sqlite doesn't enforce FKs, so tests won't save you.
for model in (WorkoutSeries, WorkoutSession, LabPanel, Plan, Metric, Record, Niggle,
- ChatMessage, AgentRun, IngestToken, EquipmentProfile):
+ ChatMessage, AgentRun, IngestToken, EquipmentProfile,
+ MealLog, MealRevision, Carryover, LunchFavorite, MediaBlob,
+ PushSub, NotificationLog, WithingsLink):
db.query(model).filter_by(user_id=bruce.id).delete(synchronize_session=False)
db.delete(bruce)
db.commit()
diff --git a/server/app/food_coach.py b/server/app/food_coach.py
new file mode 100644
index 0000000..519f43d
--- /dev/null
+++ b/server/app/food_coach.py
@@ -0,0 +1,258 @@
+"""Phase 8 — coached food weeks (E16.3/E16.5): proposal validators + creation,
+the food twin of `coach.create_proposal`. The coach writes the household's meal
+plan only through here; nothing goes live without approval unless the acting
+user opted into auto-apply.
+
+Validator philosophy mirrors training: structural rules are hard errors the
+model can fix and retry; the error strings say exactly what to change.
+Macro checks run on WEEKLY AVERAGES of the planned week (the trio is coached
+weekly, never per meal) with declared assumptions for order-out lunches.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from datetime import date
+
+from sqlalchemy.orm import Session
+
+from .config import local_today
+from .models import MACRO_FIELDS as MACROS
+from .models import Carryover, MealRevision, Recipe, User
+
+DAY_KEYS = ("0", "1", "2", "3", "4", "5", "6")
+SLOTS = ("breakfast", "lunch", "dinner", "snack")
+
+# What an order-assist lunch is coached to hit — counted into the weekly
+# average so office days don't read as protein holes. Real numbers land when
+# the meal is logged (chat estimate, flagged `estimated`).
+ORDER_LUNCH_ASSUMED = {"kcal": 550.0, "protein_g": 40.0, "carbs_g": 45.0, "sugar_g": 8.0,
+ "fiber_g": 8.0, "fat_g": 20.0, "satfat_g": 6.0, "sodium_mg": 900.0}
+
+
+def food_scope(user: User) -> str | None:
+ from .routers.food import food_scope as fs
+ return fs(user)
+
+
+def _slot_recipe(entry: dict, days: dict) -> str | None:
+ """Slug a slot resolves to — leftover slots inherit the source day's dinner."""
+ if entry.get("leftover_of") is not None:
+ src = days.get(str(entry["leftover_of"])) or {}
+ return ((src.get("slots") or {}).get("dinner") or {}).get("recipe")
+ return entry.get("recipe")
+
+
+def validate_food_content(db: Session, user: User, content: dict, changes: list) -> str | None:
+ days = content.get("days")
+ if not isinstance(days, dict):
+ return "content.days must be an object keyed '0' (Mon) .. '6' (Sun)"
+ missing = [k for k in DAY_KEYS if k not in days]
+ if missing:
+ return f"every day must be planned — missing day(s) {missing}"
+ if set(days) - set(DAY_KEYS):
+ return f"bad day key(s) {sorted(set(days) - set(DAY_KEYS))} — use '0' (Mon) .. '6' (Sun)"
+ if not changes:
+ return "changes[] is required — one {sign:'+'|'-'|'~', what, why} row per meaningful change vs the current week"
+ for c in changes:
+ if not isinstance(c, dict) or c.get("sign") not in {"+", "-", "~"} or not c.get("what"):
+ return "each changes[] row needs sign ('+'|'-'|'~') and what"
+
+ recipes = {r.slug: r for r in db.query(Recipe).all()}
+ zero_cook = 0
+ totals = {k: 0.0 for k in MACROS}
+ has_out = False
+
+ for key in DAY_KEYS:
+ slots = (days[key] or {}).get("slots")
+ if not isinstance(slots, dict):
+ return f"day {key}: needs a slots object"
+ for slot in SLOTS:
+ entry = slots.get(slot)
+ if not isinstance(entry, dict):
+ return f"day {key}: every slot must be planned — {slot} is missing (E16.3 AC2)"
+ is_order, is_out, is_left = bool(entry.get("order")), bool(entry.get("out")), entry.get("leftover_of") is not None
+ if is_order:
+ if slot != "lunch":
+ return f"day {key}: order-assist slots are lunches only ({slot} can't be an order)"
+ if not entry.get("note"):
+ return f"day {key}: order lunch needs a note with the target macros + budget"
+ for k in MACROS:
+ totals[k] += ORDER_LUNCH_ASSUMED[k]
+ continue
+ if is_out:
+ if slot != "dinner":
+ return f"day {key}: only dinner can be a night out"
+ if not entry.get("note"):
+ return f"day {key}: the night out needs a note (it's a planned exception — say so)"
+ has_out = True
+ zero_cook += 1
+ continue
+ slug = _slot_recipe(entry, days)
+ if is_left:
+ src = str(entry["leftover_of"])
+ if src not in DAY_KEYS:
+ return f"day {key}: leftover_of must reference a day key '0'..'6'"
+ if not slug:
+ return f"day {key}: leftover_of {src} — that day has no cooked dinner recipe to inherit"
+ if slot == "dinner":
+ zero_cook += 1
+ r = recipes.get(slug or "")
+ if not r:
+ return f"day {key} {slot}: unknown recipe slug {slug!r} — use get_recipes"
+ if not r.complete:
+ return f"day {key} {slot}: {slug} is an incomplete draft — only complete recipes are proposable"
+ if r.difficulty not in {"easy", "medium"}:
+ return f"day {key} {slot}: {slug} exceeds the difficulty ceiling (easy | medium only)"
+ if slot == "dinner" and not is_left and r.kind != "dinner":
+ return f"day {key}: {slug} is a {r.kind} recipe — dinners need kind 'dinner'"
+ if slot == "dinner" and not (entry.get("why") or "").strip():
+ return f"day {key}: the dinner needs a why one-liner (the UI renders it)"
+ for k in MACROS:
+ totals[k] += float(getattr(r, k) or 0)
+ if zero_cook < 1:
+ return "at least one dinner must be zero-cook (a leftover night or the night out) — batch a recipe"
+
+ # Weekly averages vs the acting user's targets. The planned week is a
+ # floor, not the whole diet — the night out and off-plan extras top it up
+ # (the seeded baseline plans ~65% of kcal), so protein/fiber floors sit
+ # below target. The sat-fat cap is absolute: extras only ever ADD sat fat,
+ # so the planned week must leave room under it.
+ from .routers.food import targets_for
+ t = targets_for(user)
+ avg = {k: totals[k] / 7 for k in MACROS}
+ if avg["protein_g"] < 0.65 * t["protein_g"]:
+ return (f"planned protein averages {avg['protein_g']:.0f} g/day — too far below the "
+ f"{t['protein_g']} g target for extras to close (floor: {0.65 * t['protein_g']:.0f} g planned)")
+ if avg["fiber_g"] < 0.75 * t["fiber_g"]:
+ return (f"planned fiber averages {avg['fiber_g']:.0f} g/day vs the {t['fiber_g']} g target "
+ "— work in more beans, lentils, oats or veg")
+ cap = t["satfat_g"] - (1 if has_out else 0)
+ if avg["satfat_g"] > cap:
+ return (f"planned sat fat averages {avg['satfat_g']:.1f} g/day vs the {t['satfat_g']} g cap"
+ + (" — and a night out needs banked headroom below the cap" if has_out else ""))
+ if avg["kcal"] > 1.05 * t["kcal"]:
+ return f"planned calories average {avg['kcal']:.0f}/day — over the {t['kcal']} target"
+ return None
+
+
+def _norm_note(s: str) -> str:
+ return " ".join(re.sub(r"[^a-z0-9 ]", " ", (s or "").lower()).split())
+
+
+def _dinner_sig(entry: dict) -> str:
+ return json.dumps({k: v for k, v in (entry or {}).items() if k != "why"},
+ sort_keys=True, default=str)
+
+
+def _validate_dinner_notes(db: Session, user: User, content: dict, rationale: str) -> str | None:
+ """Dinner whys are plate notes, not week copy: about THAT dinner that night
+ (its macro job, batch/leftover role, a carry-over it uses). No two alike,
+ none a restatement of the rationale, and a changed dinner never keeps the
+ line the current week already shows."""
+ scope = food_scope(user)
+ q = db.query(MealRevision).filter(MealRevision.status == "active")
+ q = q.filter(MealRevision.user_id == scope) if scope else q.filter(MealRevision.user_id.is_(None))
+ prev = q.order_by(MealRevision.num.desc()).first()
+ prev_days = ((prev.content or {}).get("days", {}) or {}) if prev else {}
+ rat = _norm_note(rationale)
+ seen: dict[str, str] = {}
+ for key in DAY_KEYS:
+ entry = ((content.get("days", {}).get(key) or {}).get("slots") or {}).get("dinner") or {}
+ prev_entry = ((prev_days.get(key) or {}).get("slots") or {}).get("dinner") or {}
+ n = _norm_note(entry.get("why") or "")
+ if not n:
+ continue # presence is enforced structurally in validate_food_content
+ if n in seen.values():
+ dup = next(k for k, v in seen.items() if v == n)
+ return (f"days {dup} and {key} share the same dinner why — each note is about that "
+ "plate that night (its macro job, batch role, or the carry-over it uses)")
+ if rat and (n in rat or rat in n):
+ return (f"day {key}: the dinner why restates the weekly rationale — say what this "
+ "dinner does tonight instead")
+ if _dinner_sig(entry) != _dinner_sig(prev_entry) and prev_entry \
+ and n == _norm_note(prev_entry.get("why") or ""):
+ return (f"day {key}: the dinner changed but its why is the current week's line — "
+ "write it fresh for the new plate")
+ seen[key] = n
+ return None
+
+
+def create_food_proposal(db: Session, user: User, content: dict, changes: list,
+ rationale: str) -> dict:
+ from .coach import _validate_rationale_voice, repair_deep, repair_text
+ content = repair_deep(content)
+ changes = repair_deep(changes or [])
+ rationale = repair_text(rationale or "")
+ if not rationale.strip():
+ return {"error": "rationale is required — 2-3 sentences on what this week achieves"}
+ err = (validate_food_content(db, user, content, changes or [])
+ or _validate_rationale_voice(rationale)
+ or _validate_dinner_notes(db, user, content, rationale))
+ if err:
+ return {"error": err}
+ scope = food_scope(user)
+
+ def scoped(q):
+ return q.filter(MealRevision.user_id == scope) if scope else q.filter(MealRevision.user_id.is_(None))
+
+ max_num = max((n for (n,) in scoped(db.query(MealRevision.num))), default=0)
+ auto = (user.prefs or {}).get("coach_approval") == "auto"
+ # exactly one pending food proposal per household at a time
+ scoped(db.query(MealRevision)).filter(MealRevision.status == "proposed").update({"status": "superseded"})
+ rev = MealRevision(user_id=scope, num=max_num + 1, content=content, changes=changes,
+ rationale=rationale, status="active" if auto else "proposed")
+ db.add(rev)
+ if auto:
+ (scoped(db.query(MealRevision))
+ .filter(MealRevision.status == "active", MealRevision.num <= max_num)
+ .update({"status": "superseded"}))
+ db.commit()
+ return {"ok": True, "revision": rev.num, "status": rev.status,
+ "note": "applied immediately (auto mode)" if auto else "awaiting approval in the Food tab"}
+
+
+# ---------- carry-overs (E16.5 — the waste loop) ----------
+
+
+def list_carryovers(db: Session, user: User) -> list[dict]:
+ scope = food_scope(user)
+ q = db.query(Carryover)
+ q = q.filter(Carryover.user_id == scope) if scope else q.filter(Carryover.user_id.is_(None))
+ rows = q.order_by(Carryover.week_start.desc()).limit(40).all()
+ return [{"id": c.id, "week_start": str(c.week_start), "item": c.item, "qty": c.qty,
+ "use_by": str(c.use_by) if c.use_by else None, "status": c.status} for c in rows]
+
+
+def update_carryovers(db: Session, user: User, updates: list | None, add: list | None) -> dict:
+ """Set statuses on existing rows (keep/bin/consumed) and/or record new
+ leftovers. The coach learns from binned formats; kept items must show up
+ in the next proposal or be excused in its rationale."""
+ scope = food_scope(user)
+ changed = 0
+ for u in updates or []:
+ c = db.get(Carryover, str(u.get("id", "")))
+ if not c or c.user_id != scope:
+ return {"error": f"carryover {u.get('id')!r} not found"}
+ status = u.get("status")
+ if status not in {"open", "kept", "binned", "consumed"}:
+ return {"error": f"status must be open|kept|binned|consumed, got {status!r}"}
+ c.status = status
+ changed += 1
+ added = 0
+ for a in add or []:
+ if not a.get("item"):
+ return {"error": "each added carry-over needs an item name"}
+ use_by = None
+ if a.get("use_by"):
+ try:
+ use_by = date.fromisoformat(str(a["use_by"]))
+ except ValueError:
+ return {"error": f"bad use_by date {a['use_by']!r} — YYYY-MM-DD"}
+ db.add(Carryover(user_id=scope, week_start=local_today(), item=str(a["item"]),
+ qty=str(a.get("qty", "")), use_by=use_by,
+ status=a.get("status", "open")))
+ added += 1
+ db.commit()
+ return {"ok": True, "updated": changed, "added": added}
diff --git a/server/app/food_seed.py b/server/app/food_seed.py
new file mode 100644
index 0000000..e4eea5d
--- /dev/null
+++ b/server/app/food_seed.py
@@ -0,0 +1,759 @@
+"""Nutrition seed (beta track, Phase 7): ingredient macro table, the curated
+recipe library, per-user nutrition prefs defaults, and the hand-written first
+food week — all idempotent / insert-missing, same contract as `seed.run_seed`.
+
+Recipes follow the HelloFresh card format (structured steps, why-it's-here,
+minimal ingredients) around BBC Good Food-style classics, tuned for the
+cholesterol trio: protein up, fiber up, sat fat capped. Macros are authored
+per serving (hand-checked against USDA-style references), stored canonical.
+"""
+
+from sqlalchemy.orm import Session
+
+from .models import Ingredient, MealRevision, Recipe, User
+
+# The cholesterol trio (protein/fiber/satfat) stays the coached core; the rest
+# of the label set is tracked and displayed. sugar/satfat/sodium are caps.
+DEFAULT_TARGETS = {"kcal": 2300, "protein_g": 160, "carbs_g": 250, "sugar_g": 65,
+ "fiber_g": 38, "fat_g": 80, "satfat_g": 18, "sodium_mg": 2300}
+NUTRITION_PREF_DEFAULTS = {
+ "nutrition_targets": DEFAULT_TARGETS,
+ "cook_nights": 4,
+ "budget_grocery": 110,
+ "budget_lunch": 15,
+ "household_dinners": True,
+}
+
+# name, aisle, unit, typical pack, then per-100 (or per-item when unit is 'x'):
+# kcal, protein, carbs, sugar, fiber, fat, satfat, sodium (mg), pantry
+# Values are per-100g/ml from a trusted source (USDA FoodData Central / McCance &
+# Widdowson), hand-checked. This is the default pantry — insert-missing on every
+# boot; the MCP pantry tools maintain it further at runtime.
+INGREDIENTS = [
+ # produce
+ ("chicken thighs, skinless", "protein", "g", "650 g tray", 121, 20, 0, 0, 0, 4.7, 1.1, 95, 0),
+ ("chicken breast", "protein", "g", "500 g tray", 106, 22, 0, 0, 0, 1.9, 0.4, 63, 0),
+ ("salmon fillets", "protein", "x", "2 × 130 g", 232, 25, 0, 0, 0, 15, 2.6, 50, 0),
+ ("cod fillets", "protein", "x", "2 × 140 g", 96, 21, 0, 0, 0, 0.7, 0.1, 70, 0),
+ ("prawns, raw", "protein", "g", "300 g bag", 85, 18, 0.2, 0, 0, 0.6, 0.2, 210, 0),
+ ("turkey mince 5%", "protein", "g", "500 g pack", 120, 22, 0, 0, 0, 3.5, 0.9, 75, 0),
+ ("tuna in spring water", "cupboard", "x", "145 g tin", 116, 26, 0, 0, 0, 0.8, 0.1, 320, 0),
+ ("eggs", "protein", "x", "box of 12", 74, 6.5, 0.4, 0.2, 0, 5, 1.1, 70, 0),
+ ("greek yogurt 0%", "dairy", "g", "1 kg tub", 57, 10, 3.6, 3.2, 0, 0.2, 0.1, 36, 0),
+ ("feta", "dairy", "g", "200 g block", 264, 14, 4.1, 4.1, 0, 21, 10.9, 1100, 0),
+ ("halloumi light", "dairy", "g", "225 g block", 255, 24, 2, 1.5, 0, 17, 10.5, 1900, 0),
+ ("parmesan", "dairy", "g", "wedge", 392, 32, 3.2, 0.8, 0, 29, 13.7, 1500, 1),
+ ("peppers", "produce", "x", "3-pack", 31, 1, 6, 4.2, 2.1, 0.3, 0, 4, 0),
+ ("red onions", "produce", "x", "net of 4", 40, 1.1, 9.3, 4.2, 1.7, 0.1, 0, 4, 0),
+ ("spinach", "produce", "g", "250 g bag", 23, 2.9, 3.6, 0.4, 2.2, 0.4, 0.1, 79, 0),
+ ("long-stem broccoli", "produce", "g", "200 g pack", 35, 3, 7, 1.7, 3, 0.4, 0.1, 33, 0),
+ ("spring greens", "produce", "g", "200 g bag", 33, 3, 3.1, 2, 3.4, 0.7, 0.1, 9, 0),
+ ("courgettes", "produce", "x", "3-pack", 17, 1.2, 3.1, 2.5, 1, 0.3, 0.1, 8, 0),
+ ("cherry tomatoes", "produce", "g", "500 g pack", 18, 0.9, 3.9, 2.6, 1.2, 0.2, 0, 5, 0),
+ ("sweet potatoes", "produce", "g", "1 kg bag", 86, 1.6, 20, 4.2, 3, 0.1, 0, 55, 0),
+ ("baking potatoes", "produce", "x", "4-pack", 93, 2.5, 21, 1.2, 2.2, 0.1, 0, 6, 0),
+ ("lemons", "produce", "x", "3-pack", 29, 1.1, 9.3, 2.5, 2.8, 0.3, 0, 2, 0),
+ ("limes", "produce", "x", "3-pack", 30, 0.7, 10.5, 1.7, 2.8, 0.2, 0, 2, 0),
+ ("ginger", "produce", "g", "root", 80, 1.8, 18, 1.7, 2, 0.8, 0.1, 13, 0),
+ ("garlic", "produce", "x", "bulb", 149, 6.4, 33, 1, 2.1, 0.5, 0.1, 17, 1),
+ ("coriander", "produce", "x", "bunch", 23, 2.1, 3.7, 0.9, 2.8, 0.5, 0, 46, 0),
+ ("dill", "produce", "x", "pot", 43, 3.5, 7, 0, 2.1, 1.1, 0, 61, 0),
+ ("avocado", "produce", "x", "each", 160, 2, 8.5, 0.7, 6.7, 14.7, 2.1, 7, 0),
+ ("apples", "produce", "x", "6-pack", 52, 0.3, 14, 10.4, 2.4, 0.2, 0, 1, 0),
+ ("berries, mixed", "produce", "g", "400 g punnet", 43, 0.7, 9.6, 4.9, 3.8, 0.5, 0, 1, 0),
+ ("bananas", "produce", "x", "bunch", 89, 1.1, 23, 12, 2.6, 0.3, 0.1, 1, 0),
+ # cupboard
+ ("chickpeas", "cupboard", "x", "400 g tin", 115, 6.3, 16.7, 0.4, 5.4, 2.6, 0.2, 220, 0),
+ ("black beans", "cupboard", "x", "400 g tin", 91, 6, 15.4, 0.3, 6.5, 0.5, 0.1, 180, 0),
+ ("white beans", "cupboard", "x", "400 g tin", 90, 5.4, 15.5, 0.6, 5.6, 0.6, 0.1, 240, 0),
+ ("kidney beans", "cupboard", "x", "400 g tin", 84, 5.2, 14, 0.6, 6.4, 0.5, 0.1, 230, 0),
+ ("puy lentils", "cupboard", "g", "250 g pouch", 116, 9, 17, 0.6, 7.9, 0.6, 0.1, 180, 0),
+ ("red lentils, dry", "cupboard", "g", "500 g bag", 352, 25, 60, 2, 11, 1.1, 0.2, 7, 1),
+ ("chopped tomatoes", "cupboard", "x", "400 g tin", 32, 1.2, 5.5, 4, 1.3, 0.2, 0, 130, 0),
+ ("passata", "cupboard", "ml", "500 g carton", 32, 1.4, 6, 4.5, 1.1, 0.2, 0, 160, 0),
+ ("harissa paste", "cupboard", "g", "185 g jar", 130, 3, 14, 7, 6, 6.5, 0.6, 1300, 0),
+ ("miso paste", "cupboard", "g", "tub", 199, 12, 26, 6.2, 5.4, 6, 0.4, 3700, 1),
+ ("olives", "cupboard", "g", "160 g jar", 145, 1, 3.8, 0.5, 3.3, 15, 1.6, 1560, 0),
+ ("capers", "cupboard", "g", "jar", 23, 2.4, 4.9, 0.4, 3.2, 0.9, 0.1, 2960, 1),
+ ("wholewheat spaghetti", "cupboard", "g", "500 g bag", 348, 13, 62, 2.6, 10, 2.5, 0.3, 6, 0),
+ ("soba noodles", "cupboard", "g", "250 g pack", 336, 14, 74, 1, 3, 0.7, 0.1, 790, 0),
+ ("brown rice", "cupboard", "g", "1 kg bag", 362, 7.5, 76, 0.9, 3.4, 2.8, 0.5, 5, 1),
+ ("bulgur wheat", "cupboard", "g", "500 g bag", 342, 12, 69, 0.4, 12.5, 1.3, 0.2, 17, 1),
+ ("gnocchi", "cupboard", "g", "500 g pack", 151, 4, 32, 0.6, 2, 0.4, 0.1, 300, 0),
+ ("oats", "cupboard", "g", "1 kg bag", 379, 13, 60, 1, 10, 6.9, 1, 2, 1),
+ ("ground flaxseed", "cupboard", "g", "200 g pack", 534, 18, 29, 1.6, 27, 42, 3.2, 30, 1),
+ ("almonds", "cupboard", "g", "200 g bag", 579, 21, 22, 4.4, 12.5, 50, 3.8, 1, 1),
+ ("peanut butter", "cupboard", "g", "340 g jar", 588, 25, 20, 9, 6, 50, 9.5, 430, 1),
+ ("olive oil", "cupboard", "ml", "bottle", 884, 0, 0, 0, 0, 100, 13.8, 0, 1),
+ ("soy sauce, reduced salt", "cupboard", "ml", "bottle", 53, 8, 8, 1, 0.8, 0.1, 0, 3300, 1),
+ ("wholegrain bread", "cupboard", "x", "loaf, per slice", 90, 4, 15, 2, 2.5, 1.5, 0.2, 180, 0),
+ ("smoked paprika", "cupboard", "g", "jar", 282, 14, 54, 10, 35, 13, 0.9, 68, 1),
+ ("cumin", "cupboard", "g", "jar", 375, 18, 44, 2.3, 10.5, 22, 1.5, 168, 1),
+ ("chilli flakes", "cupboard", "g", "jar", 282, 12, 50, 10, 27, 14, 1, 30, 1),
+ # --- expanded defaults: common staples so fewer imports park incomplete ---
+ # protein
+ ("beef mince 5%", "protein", "g", "500 g pack", 137, 21, 0, 0, 0, 5, 2.3, 75, 0),
+ ("pork loin steaks", "protein", "x", "2 × 150 g", 143, 21, 0, 0, 0, 6, 2.1, 55, 0),
+ ("firm tofu", "protein", "g", "280 g block", 121, 13, 1.2, 0.6, 0.9, 7, 1, 12, 0),
+ ("tempeh", "protein", "g", "200 g block", 192, 20, 8, 0, 5, 11, 2.2, 9, 0),
+ ("bacon medallions", "protein", "g", "200 g pack", 143, 24, 0.6, 0.6, 0, 5, 1.8, 1900, 0),
+ # dairy
+ ("cottage cheese", "dairy", "g", "300 g tub", 98, 11, 3.4, 3.1, 0, 4.3, 1.7, 330, 0),
+ ("mozzarella light", "dairy", "g", "125 g ball", 178, 19, 1.5, 1, 0, 11, 7, 380, 0),
+ ("cheddar", "dairy", "g", "block", 416, 25, 0.1, 0.1, 0, 35, 21, 720, 0),
+ ("milk, semi-skimmed", "dairy", "ml", "2 L", 47, 3.4, 4.8, 4.7, 0, 1.7, 1.1, 44, 0),
+ ("creme fraiche, half-fat", "dairy", "g", "300 ml tub", 165, 2.7, 4.3, 3.6, 0, 15, 10, 40, 0),
+ ("butter", "dairy", "g", "250 g block", 717, 0.9, 0.1, 0.1, 0, 81, 51, 580, 1),
+ # produce
+ ("carrots", "produce", "g", "1 kg bag", 41, 0.9, 9.6, 4.7, 2.8, 0.2, 0, 69, 0),
+ ("brown onions", "produce", "x", "net of 5", 40, 1.1, 9.3, 4.2, 1.7, 0.1, 0, 4, 0),
+ ("celery", "produce", "x", "head", 14, 0.7, 3, 1.8, 1.6, 0.2, 0, 80, 0),
+ ("mushrooms", "produce", "g", "300 g pack", 22, 3.1, 3.3, 2, 1, 0.3, 0.1, 5, 0),
+ ("kale", "produce", "g", "200 g bag", 49, 4.3, 8.8, 2.3, 3.6, 0.9, 0.1, 38, 0),
+ ("green beans", "produce", "g", "220 g pack", 31, 1.8, 7, 3.3, 2.7, 0.2, 0, 6, 0),
+ ("cauliflower", "produce", "x", "head", 25, 1.9, 5, 1.9, 2, 0.3, 0.1, 30, 0),
+ ("aubergine", "produce", "x", "each", 25, 1, 6, 3.5, 3, 0.2, 0, 2, 0),
+ ("cucumber", "produce", "x", "each", 15, 0.7, 3.6, 1.7, 0.5, 0.1, 0, 2, 0),
+ ("oranges", "produce", "x", "5-pack", 47, 0.9, 11.8, 9.4, 2.4, 0.1, 0, 0, 0),
+ # cupboard
+ ("white rice", "cupboard", "g", "1 kg bag", 360, 7, 79, 0.1, 1.3, 0.6, 0.2, 1, 1),
+ ("quinoa", "cupboard", "g", "500 g bag", 368, 14, 64, 0, 7, 6, 0.7, 5, 1),
+ ("couscous", "cupboard", "g", "500 g bag", 376, 13, 77, 0, 5, 0.6, 0.1, 10, 1),
+ ("plain flour", "cupboard", "g", "1.5 kg bag", 341, 10, 71, 1.5, 3, 1.2, 0.2, 2, 1),
+ ("wholemeal flour", "cupboard", "g", "1.5 kg bag", 340, 12, 64, 2, 9, 2.2, 0.3, 3, 1),
+ ("tortilla wraps", "cupboard", "x", "8-pack", 297, 8, 50, 2.5, 3, 7, 2.9, 620, 0),
+ ("green lentils, dry", "cupboard", "g", "500 g bag", 336, 24, 50, 2, 11, 1.5, 0.2, 6, 1),
+ ("cannellini beans", "cupboard", "x", "400 g tin", 88, 5.5, 14.5, 0.5, 5.4, 0.5, 0.1, 220, 0),
+ ("butter beans", "cupboard", "x", "400 g tin", 82, 5, 13, 0.8, 4.7, 0.5, 0.1, 240, 0),
+ ("coconut milk, light", "cupboard", "ml", "400 ml tin", 73, 0.7, 2, 1.6, 0, 7, 6, 15, 0),
+ ("tahini", "cupboard", "g", "270 g jar", 595, 17, 21, 0.5, 9, 54, 7.6, 35, 1),
+ ("honey", "cupboard", "g", "340 g jar", 334, 0.4, 82, 82, 0, 0, 0, 4, 1),
+ ("maple syrup", "cupboard", "ml", "bottle", 260, 0, 67, 60, 0, 0.1, 0, 12, 1),
+ ("dark chocolate 70%", "cupboard", "g", "100 g bar", 598, 7.8, 46, 37, 11, 43, 25, 20, 1),
+ ("tomato purée", "cupboard", "g", "tube", 81, 4.3, 15, 9, 3, 0.5, 0.1, 400, 1),
+ ("dijon mustard", "cupboard", "g", "jar", 143, 8, 5, 2, 4, 10, 0.6, 2200, 1),
+ ("balsamic vinegar", "cupboard", "ml", "bottle", 88, 0.5, 17, 15, 0, 0, 0, 23, 1),
+ ("cashews", "cupboard", "g", "200 g bag", 553, 18, 30, 6, 3.3, 44, 8, 12, 1),
+ ("walnuts", "cupboard", "g", "200 g bag", 654, 15, 14, 2.6, 6.7, 65, 6.1, 2, 1),
+ # frozen
+ ("peas, frozen", "frozen", "g", "900 g bag", 81, 5.4, 14, 6, 5, 0.4, 0.1, 5, 1),
+ ("sweetcorn, frozen", "frozen", "g", "900 g bag", 86, 3.3, 19, 3.2, 2.7, 1.2, 0.2, 15, 1),
+ ("edamame, frozen", "frozen", "g", "500 g bag", 121, 12, 9, 2.2, 5, 5, 0.6, 6, 1),
+ ("spinach, frozen", "frozen", "g", "900 g bag", 29, 3.6, 3, 0.4, 2.4, 0.5, 0.1, 80, 1),
+]
+
+# Every dinner: HelloFresh-card structure — why-it's-here, quantified ingredients
+# joined to INGREDIENTS by name, and done-when steps (timer=True → cook-mode ring).
+RECIPES: list[dict] = [
+ # ---- dinners ----
+ dict(
+ slug="harissa-chicken-traybake", name="Harissa chicken traybake", kind="dinner",
+ minutes=25, difficulty="easy", serves=2, batch=2, platefig="tray-chicken",
+ kcal=520, protein_g=42, carbs_g=38, sugar_g=10, fiber_g=11,
+ fat_g=16, satfat_g=4.5, sodium_mg=620,
+ why="Your highest-protein traybake; chickpeas carry the fiber. Low sat fat banks headroom for a night out. Doubles into a zero-cook night — one cook, two dinners.",
+ tags=["traybake", "batch", "high-fiber"],
+ ingredients=[
+ {"name": "chicken thighs, skinless", "qty": 900, "unit": "g", "disp": "900 g"},
+ {"name": "chickpeas", "qty": 2, "unit": "x", "disp": "2 tins"},
+ {"name": "peppers", "qty": 3, "unit": "x", "disp": "3"},
+ {"name": "red onions", "qty": 2, "unit": "x", "disp": "2"},
+ {"name": "harissa paste", "qty": 30, "unit": "g", "disp": "2 tbsp"},
+ {"name": "spinach", "qty": 125, "unit": "g", "disp": "½ bag"},
+ {"name": "lemons", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "olive oil", "qty": 15, "unit": "ml", "disp": "1 tbsp", "note": "pantry"},
+ {"name": "cumin", "qty": 4, "unit": "g", "disp": "2 tsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Prep", "minutes": 5, "detail": "Oven to 220° fan. Pat the thighs dry — dry meat browns, wet meat steams. Peppers into strips, onions into wedges, chickpeas drained well."},
+ {"title": "Dress", "minutes": 2, "detail": "Everything into the tray with harissa, oil, cumin and half the lemon. Toss until slicked; thighs on top so they roast, not stew."},
+ {"title": "Roast", "minutes": 22, "timer": True, "detail": "Done when thighs read 74° / juices run clear and the chickpeas just blister. No turning — let the edges char."},
+ {"title": "Finish", "minutes": 1, "detail": "Off the heat: fold the spinach through the hot chickpeas to wilt, squeeze the rest of the lemon over."},
+ {"title": "Box & plate", "minutes": 1, "detail": "Half into the box before plating — portion now, no willpower needed on leftover night."},
+ ],
+ ),
+ dict(
+ slug="salmon-puy-lentils", name="Charred salmon, puy lentils & broccoli", kind="dinner",
+ minutes=20, difficulty="easy", serves=2, platefig="plate-salmon",
+ kcal=540, protein_g=38, carbs_g=30, sugar_g=3, fiber_g=9,
+ fat_g=24, satfat_g=3.5, sodium_mg=480,
+ why="The omega-3 day — salmon fat is the kind your lipid panel likes. Lentils carry the fiber under it.",
+ tags=["fish", "omega-3", "quick"],
+ ingredients=[
+ {"name": "salmon fillets", "qty": 2, "unit": "x", "disp": "2 fillets"},
+ {"name": "puy lentils", "qty": 250, "unit": "g", "disp": "1 pouch"},
+ {"name": "long-stem broccoli", "qty": 200, "unit": "g", "disp": "200 g"},
+ {"name": "lemons", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "garlic", "qty": 2, "unit": "x", "disp": "2 cloves", "note": "pantry"},
+ {"name": "olive oil", "qty": 10, "unit": "ml", "disp": "2 tsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Sear", "minutes": 4, "timer": True, "detail": "Hot pan, skin-side down, don't touch it. Done when the skin releases without a fight and the sides look cooked a third of the way up."},
+ {"title": "Steam", "minutes": 5, "detail": "Broccoli into a steamer (or the same pan with a splash of water and a lid). Bright green and just tender — a knife should meet slight resistance."},
+ {"title": "Warm the lentils", "minutes": 3, "detail": "Pouch into the pan with garlic and a little oil. Warm through, season, lemon juice in."},
+ {"title": "Flip & finish", "minutes": 3, "detail": "Salmon flipped for its last two minutes — done when it flakes at the thickest part but still looks juicy, not chalky."},
+ ],
+ ),
+ dict(
+ slug="turkey-black-bean-chili", name="Turkey & black-bean chili", kind="dinner",
+ minutes=35, difficulty="medium", serves=2, batch=2, platefig="bowl-chili",
+ kcal=480, protein_g=45, carbs_g=46, sugar_g=11, fiber_g=13,
+ fat_g=10, satfat_g=3, sodium_mg=680,
+ why="Fiber engine of the week — two kinds of bean, 5% turkey keeps the sat fat down. The batch feeds a lunch.",
+ tags=["batch", "high-fiber", "freezes"],
+ ingredients=[
+ {"name": "turkey mince 5%", "qty": 500, "unit": "g", "disp": "500 g"},
+ {"name": "black beans", "qty": 2, "unit": "x", "disp": "2 tins"},
+ {"name": "kidney beans", "qty": 1, "unit": "x", "disp": "1 tin"},
+ {"name": "chopped tomatoes", "qty": 2, "unit": "x", "disp": "2 tins"},
+ {"name": "red onions", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "bulgur wheat", "qty": 120, "unit": "g", "disp": "120 g", "note": "pantry"},
+ {"name": "coriander", "qty": 1, "unit": "x", "disp": "½ bunch"},
+ {"name": "smoked paprika", "qty": 4, "unit": "g", "disp": "2 tsp", "note": "pantry"},
+ {"name": "cumin", "qty": 4, "unit": "g", "disp": "2 tsp", "note": "pantry"},
+ {"name": "chilli flakes", "qty": 2, "unit": "g", "disp": "1 tsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Brown", "minutes": 6, "detail": "Turkey into a hot, barely-oiled pot. Leave it to catch before breaking it up — the browned bits are the flavour. Onion in for the last two minutes."},
+ {"title": "Spice", "minutes": 1, "detail": "Paprika, cumin, chilli flakes straight onto the meat. Thirty seconds — fragrant, not burnt."},
+ {"title": "Simmer", "minutes": 25, "timer": True, "detail": "Tomatoes, both beans, half a tin of water. Lid half-on. Done when it's thick enough that a spoon dragged through leaves a trail."},
+ {"title": "Bulgur", "minutes": 12, "detail": "Meanwhile: bulgur in double its volume of boiling water, lid on, off the heat. It cooks itself while the chili simmers."},
+ {"title": "Box & bowl", "minutes": 1, "detail": "Two portions into the lunch box before serving. Coriander over the bowls."},
+ ],
+ ),
+ dict(
+ slug="prawn-soba-stirfry", name="Prawn & soba stir-fry", kind="dinner",
+ minutes=15, difficulty="easy", serves=2, platefig="bowl-soba",
+ kcal=430, protein_g=34, carbs_g=52, sugar_g=4, fiber_g=7,
+ fat_g=8, satfat_g=1.5, sodium_mg=1150,
+ why="The fastest dinner in the pool — prawns are nearly pure protein, and the whole thing has less sat fat than a latte.",
+ tags=["quick", "low-satfat"],
+ ingredients=[
+ {"name": "prawns, raw", "qty": 300, "unit": "g", "disp": "300 g"},
+ {"name": "soba noodles", "qty": 150, "unit": "g", "disp": "150 g"},
+ {"name": "spring greens", "qty": 100, "unit": "g", "disp": "½ bag"},
+ {"name": "ginger", "qty": 20, "unit": "g", "disp": "thumb"},
+ {"name": "garlic", "qty": 2, "unit": "x", "disp": "2 cloves", "note": "pantry"},
+ {"name": "soy sauce, reduced salt", "qty": 30, "unit": "ml", "disp": "2 tbsp", "note": "pantry"},
+ {"name": "limes", "qty": 1, "unit": "x", "disp": "1"},
+ ],
+ steps=[
+ {"title": "Noodles", "minutes": 4, "timer": True, "detail": "Soba into boiling water. Done a minute early — they finish in the pan. Rinse cold so they don't clump."},
+ {"title": "Flash the aromatics", "minutes": 1, "detail": "Screaming-hot wok, ginger and garlic for thirty seconds — moving constantly, golden not brown."},
+ {"title": "Prawns", "minutes": 3, "detail": "In with the prawns and greens. Done when every prawn has curled into a loose C and gone pink through — a tight C is overdone."},
+ {"title": "Toss", "minutes": 2, "detail": "Noodles back in with soy and lime. One minute of proper tossing so every strand is coated."},
+ ],
+ ),
+ dict(
+ slug="baked-cod-white-bean-stew", name="Baked cod on white bean stew", kind="dinner",
+ minutes=30, difficulty="easy", serves=2, platefig="bowl-stew",
+ kcal=420, protein_g=40, carbs_g=36, sugar_g=8, fiber_g=12,
+ fat_g=8, satfat_g=2, sodium_mg=740,
+ why="Sunday-calm cooking. Cod is the leanest fish in the pool and the beans do the fiber work — a soft landing for the week's sat-fat average.",
+ tags=["fish", "high-fiber", "one-pan"],
+ ingredients=[
+ {"name": "cod fillets", "qty": 2, "unit": "x", "disp": "2 fillets"},
+ {"name": "white beans", "qty": 2, "unit": "x", "disp": "2 tins"},
+ {"name": "cherry tomatoes", "qty": 250, "unit": "g", "disp": "250 g"},
+ {"name": "harissa paste", "qty": 15, "unit": "g", "disp": "1 tbsp"},
+ {"name": "spring greens", "qty": 100, "unit": "g", "disp": "½ bag"},
+ {"name": "garlic", "qty": 2, "unit": "x", "disp": "2 cloves", "note": "pantry"},
+ {"name": "olive oil", "qty": 10, "unit": "ml", "disp": "2 tsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Start the stew", "minutes": 8, "detail": "Garlic in oil until fragrant, tomatoes in until they start to burst and slump. Harissa through — this finishes the jar."},
+ {"title": "Beans in", "minutes": 5, "detail": "Both tins, half drained. Simmer until it looks like a stew, not a soup. Greens folded in to wilt."},
+ {"title": "Bake the cod", "minutes": 12, "timer": True, "detail": "Fillets on top of the stew, lid or foil on, into a 200° oven. Done when the flakes separate at a nudge and the middle is opaque."},
+ ],
+ ),
+ dict(
+ slug="one-pan-chicken-puttanesca", name="One-pan chicken puttanesca", kind="dinner",
+ minutes=30, difficulty="easy", serves=2, platefig="plate-chicken",
+ kcal=495, protein_g=44, carbs_g=28, sugar_g=10, fiber_g=9,
+ fat_g=18, satfat_g=3.8, sodium_mg=1040,
+ why="Olives and capers bring the salt-and-punch a lean chicken dinner usually lacks — and a surprising fiber nudge.",
+ tags=["one-pan"],
+ ingredients=[
+ {"name": "chicken thighs, skinless", "qty": 600, "unit": "g", "disp": "600 g"},
+ {"name": "chopped tomatoes", "qty": 2, "unit": "x", "disp": "2 tins"},
+ {"name": "olives", "qty": 80, "unit": "g", "disp": "½ jar"},
+ {"name": "capers", "qty": 20, "unit": "g", "disp": "1 tbsp", "note": "pantry"},
+ {"name": "white beans", "qty": 1, "unit": "x", "disp": "1 tin"},
+ {"name": "garlic", "qty": 3, "unit": "x", "disp": "3 cloves", "note": "pantry"},
+ {"name": "chilli flakes", "qty": 1, "unit": "g", "disp": "½ tsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Brown the thighs", "minutes": 6, "detail": "Hot pan, thighs seasoned and left alone until deeply golden on one side. They finish cooking in the sauce — colour is the point here."},
+ {"title": "Build the sauce", "minutes": 3, "detail": "Garlic and chilli into the same pan, then tomatoes, olives, capers and the beans. Scrape the bottom — that's flavour, not mess."},
+ {"title": "Simmer", "minutes": 18, "timer": True, "detail": "Thighs back in, half-covered. Done when the sauce has thickened to coat a spoon and the chicken pulls apart easily."},
+ ],
+ ),
+ dict(
+ slug="turkey-meatball-spaghetti", name="Turkey meatballs & wholewheat spaghetti", kind="dinner",
+ minutes=30, difficulty="medium", serves=2, batch=2, platefig="bowl-pasta",
+ kcal=560, protein_g=42, carbs_g=62, sugar_g=12, fiber_g=10,
+ fat_g=12, satfat_g=4, sodium_mg=640,
+ why="Comfort food that behaves: 5% turkey and wholewheat pasta hold the line on sat fat and fiber where beef and white pasta wouldn't.",
+ tags=["batch", "comfort"],
+ ingredients=[
+ {"name": "turkey mince 5%", "qty": 500, "unit": "g", "disp": "500 g"},
+ {"name": "wholewheat spaghetti", "qty": 160, "unit": "g", "disp": "160 g"},
+ {"name": "passata", "qty": 500, "unit": "ml", "disp": "1 carton"},
+ {"name": "red onions", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "garlic", "qty": 2, "unit": "x", "disp": "2 cloves", "note": "pantry"},
+ {"name": "parmesan", "qty": 20, "unit": "g", "disp": "20 g", "note": "pantry"},
+ {"name": "eggs", "qty": 1, "unit": "x", "disp": "1"},
+ ],
+ steps=[
+ {"title": "Roll", "minutes": 6, "detail": "Turkey, egg, grated onion, half the parmesan, pepper. Wet hands, 12 balls — don't overwork them or they bounce."},
+ {"title": "Brown", "minutes": 5, "detail": "A film of oil, meatballs turned until golden on two sides. They finish in the sauce — don't cook them through yet."},
+ {"title": "Simmer", "minutes": 15, "timer": True, "detail": "Passata and garlic in, balls half-submerged, lid ajar. Done when a ball cut in half shows no pink."},
+ {"title": "Pasta", "minutes": 10, "detail": "Spaghetti in well-salted water, one minute short of the packet. Into the sauce with a splash of pasta water; toss until glossy."},
+ ],
+ ),
+ dict(
+ slug="miso-salmon-greens", name="Miso salmon, greens & brown rice", kind="dinner",
+ minutes=20, difficulty="easy", serves=2, platefig="plate-salmon",
+ kcal=530, protein_g=36, carbs_g=48, sugar_g=3, fiber_g=6,
+ fat_g=20, satfat_g=3, sodium_mg=1010,
+ why="Second oily-fish night, different costume. Miso does the marinade's work in ten minutes flat.",
+ tags=["fish", "omega-3", "quick"],
+ ingredients=[
+ {"name": "salmon fillets", "qty": 2, "unit": "x", "disp": "2 fillets"},
+ {"name": "miso paste", "qty": 25, "unit": "g", "disp": "1½ tbsp", "note": "pantry"},
+ {"name": "brown rice", "qty": 140, "unit": "g", "disp": "140 g", "note": "pantry"},
+ {"name": "spring greens", "qty": 100, "unit": "g", "disp": "½ bag"},
+ {"name": "ginger", "qty": 15, "unit": "g", "disp": "½ thumb"},
+ {"name": "soy sauce, reduced salt", "qty": 15, "unit": "ml", "disp": "1 tbsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Rice on", "minutes": 2, "detail": "Brown rice into plenty of boiling water — it takes 25 minutes and needs nothing from you."},
+ {"title": "Glaze", "minutes": 2, "detail": "Miso, soy and grated ginger loosened with a splash of water. Brush thickly over the fillets."},
+ {"title": "Grill", "minutes": 9, "timer": True, "detail": "Salmon under a hot grill. Done when the glaze has caught in spots and the flakes just separate — the char is the flavour, black is not."},
+ {"title": "Greens", "minutes": 3, "detail": "Steam or flash-fry with the leftover glaze. Bright and barely tender."},
+ ],
+ ),
+ dict(
+ slug="chicken-fajita-bowl", name="Chicken fajita bowl", kind="dinner",
+ minutes=25, difficulty="easy", serves=2, platefig="bowl-grain",
+ kcal=510, protein_g=43, carbs_g=48, sugar_g=8, fiber_g=11,
+ fat_g=13, satfat_g=3.5, sodium_mg=420,
+ why="Everything a fajita night promises with the tortilla-and-cheese tax refunded into beans and avocado.",
+ tags=["bowl", "high-fiber"],
+ ingredients=[
+ {"name": "chicken breast", "qty": 400, "unit": "g", "disp": "400 g"},
+ {"name": "peppers", "qty": 2, "unit": "x", "disp": "2"},
+ {"name": "red onions", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "black beans", "qty": 1, "unit": "x", "disp": "1 tin"},
+ {"name": "brown rice", "qty": 140, "unit": "g", "disp": "140 g", "note": "pantry"},
+ {"name": "avocado", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "limes", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "smoked paprika", "qty": 4, "unit": "g", "disp": "2 tsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Rice on", "minutes": 2, "detail": "Brown rice into boiling water — 25 minutes, no attention needed."},
+ {"title": "Char the veg", "minutes": 6, "detail": "Peppers and onion in a dry, very hot pan until blistered at the edges. Out and set aside."},
+ {"title": "Chicken", "minutes": 7, "timer": True, "detail": "Sliced breast tossed in paprika, into the same pan. Done when no piece shows pink at its thickest cut and the edges have caught."},
+ {"title": "Assemble", "minutes": 3, "detail": "Rice, beans warmed in the pan, veg, chicken, avocado. Lime over everything — it replaces the sour cream, honestly."},
+ ],
+ ),
+ dict(
+ slug="lentil-spinach-dal", name="Red lentil & spinach dal", kind="dinner",
+ minutes=30, difficulty="easy", serves=2, batch=2, platefig="bowl-stew",
+ kcal=440, protein_g=22, carbs_g=60, sugar_g=8, fiber_g=16,
+ fat_g=10, satfat_g=2.5, sodium_mg=380,
+ why="The meat-free night that out-fibers everything else in the pool. Freezes perfectly — the batch is insurance.",
+ tags=["veggie", "batch", "high-fiber", "freezes"],
+ ingredients=[
+ {"name": "red lentils, dry", "qty": 200, "unit": "g", "disp": "200 g", "note": "pantry"},
+ {"name": "spinach", "qty": 125, "unit": "g", "disp": "½ bag"},
+ {"name": "chopped tomatoes", "qty": 1, "unit": "x", "disp": "1 tin"},
+ {"name": "red onions", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "ginger", "qty": 20, "unit": "g", "disp": "thumb"},
+ {"name": "garlic", "qty": 3, "unit": "x", "disp": "3 cloves", "note": "pantry"},
+ {"name": "cumin", "qty": 4, "unit": "g", "disp": "2 tsp", "note": "pantry"},
+ {"name": "chilli flakes", "qty": 2, "unit": "g", "disp": "1 tsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Soften", "minutes": 5, "detail": "Onion in a little oil until translucent; garlic, ginger, cumin and chilli in for the last minute — fragrant, not coloured."},
+ {"title": "Simmer", "minutes": 22, "timer": True, "detail": "Lentils, tomatoes, 600 ml water. Done when the lentils have collapsed into a porridge that plops rather than bubbles — stir the bottom occasionally."},
+ {"title": "Finish", "minutes": 2, "detail": "Spinach folded through to wilt, salt to taste, squeeze of lemon if it needs lifting. Box the batch."},
+ ],
+ ),
+ dict(
+ slug="tuna-white-bean-salad", name="Warm tuna & white bean salad", kind="dinner",
+ minutes=10, difficulty="easy", serves=2, platefig="plate-salad",
+ kcal=400, protein_g=38, carbs_g=30, sugar_g=6, fiber_g=10,
+ fat_g=12, satfat_g=2, sodium_mg=780,
+ why="The break-glass dinner: ten minutes, one pan, mostly cupboard. Keeps a tired Thursday from becoming a takeaway.",
+ tags=["quick", "storecupboard"],
+ ingredients=[
+ {"name": "tuna in spring water", "qty": 2, "unit": "x", "disp": "2 tins"},
+ {"name": "white beans", "qty": 2, "unit": "x", "disp": "2 tins"},
+ {"name": "cherry tomatoes", "qty": 250, "unit": "g", "disp": "250 g"},
+ {"name": "red onions", "qty": 1, "unit": "x", "disp": "½, sliced thin"},
+ {"name": "lemons", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "olive oil", "qty": 15, "unit": "ml", "disp": "1 tbsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Warm the beans", "minutes": 4, "detail": "Beans with a little oil until just heated — warm beans drink the dressing, cold ones shrug it off."},
+ {"title": "Assemble", "minutes": 4, "detail": "Tomatoes halved, onion sliced paper-thin, tuna forked through in big flakes. Lemon and oil over; season harder than feels right."},
+ ],
+ ),
+ dict(
+ slug="sweet-potato-turkey-skillet", name="Sweet potato & turkey skillet", kind="dinner",
+ minutes=25, difficulty="easy", serves=2, platefig="pan-skillet",
+ kcal=500, protein_g=40, carbs_g=50, sugar_g=10, fiber_g=9,
+ fat_g=12, satfat_g=3.5, sodium_mg=340,
+ why="One pan, no drama: lean turkey and sweet potato make a heavier-feeling dinner than its sat-fat number admits.",
+ tags=["one-pan"],
+ ingredients=[
+ {"name": "turkey mince 5%", "qty": 500, "unit": "g", "disp": "500 g"},
+ {"name": "sweet potatoes", "qty": 500, "unit": "g", "disp": "500 g"},
+ {"name": "peppers", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "spinach", "qty": 125, "unit": "g", "disp": "½ bag"},
+ {"name": "smoked paprika", "qty": 4, "unit": "g", "disp": "2 tsp", "note": "pantry"},
+ {"name": "garlic", "qty": 2, "unit": "x", "disp": "2 cloves", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Potatoes first", "minutes": 10, "timer": True, "detail": "Sweet potato in small dice, into the skillet with oil and a lid. Done when a fork slides in — shake the pan halfway."},
+ {"title": "Turkey", "minutes": 7, "detail": "Push potatoes aside, brown the turkey properly, then garlic, paprika and the pepper. Mix it all together."},
+ {"title": "Wilt & serve", "minutes": 2, "detail": "Spinach folded through off the heat. Straight from the pan — it's that kind of dinner."},
+ ],
+ ),
+ dict(
+ slug="chicken-gnocchi-tray", name="Crispy gnocchi & chicken traybake", kind="dinner",
+ minutes=25, difficulty="easy", serves=2, platefig="tray-chicken",
+ kcal=540, protein_g=41, carbs_g=58, sugar_g=6, fiber_g=7,
+ fat_g=14, satfat_g=5, sodium_mg=820,
+ why="Gnocchi roast into crispy little pillows — the treat-feeling dinner that still fits the caps.",
+ tags=["traybake", "crowd-pleaser"],
+ ingredients=[
+ {"name": "chicken thighs, skinless", "qty": 600, "unit": "g", "disp": "600 g"},
+ {"name": "gnocchi", "qty": 500, "unit": "g", "disp": "1 pack"},
+ {"name": "cherry tomatoes", "qty": 250, "unit": "g", "disp": "250 g"},
+ {"name": "courgettes", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "parmesan", "qty": 15, "unit": "g", "disp": "15 g", "note": "pantry"},
+ {"name": "olive oil", "qty": 15, "unit": "ml", "disp": "1 tbsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Everything in", "minutes": 5, "detail": "220° fan. Gnocchi straight from the pack (no boiling), chicken, tomatoes, courgette half-moons — tossed in oil on the tray, chicken on top."},
+ {"title": "Roast", "minutes": 20, "timer": True, "detail": "Done when the gnocchi are golden and crisp-edged and the chicken reads 74°. Shake the tray once at half time."},
+ {"title": "Finish", "minutes": 1, "detail": "Parmesan grated thinly over the hot tray — it's seasoning, not a blanket."},
+ ],
+ ),
+ dict(
+ slug="garlic-prawn-spaghetti", name="Garlic prawn & courgette spaghetti", kind="dinner",
+ minutes=20, difficulty="easy", serves=2, platefig="bowl-pasta",
+ kcal=470, protein_g=36, carbs_g=58, sugar_g=5, fiber_g=8,
+ fat_g=10, satfat_g=2.5, sodium_mg=620,
+ why="A card-box classic rebuilt: wholewheat pasta and double courgette where the cream used to be.",
+ tags=["quick", "card-box-style"],
+ ingredients=[
+ {"name": "prawns, raw", "qty": 300, "unit": "g", "disp": "300 g"},
+ {"name": "wholewheat spaghetti", "qty": 160, "unit": "g", "disp": "160 g"},
+ {"name": "courgettes", "qty": 2, "unit": "x", "disp": "2"},
+ {"name": "garlic", "qty": 3, "unit": "x", "disp": "3 cloves", "note": "pantry"},
+ {"name": "chilli flakes", "qty": 1, "unit": "g", "disp": "½ tsp", "note": "pantry"},
+ {"name": "lemons", "qty": 1, "unit": "x", "disp": "1"},
+ ],
+ steps=[
+ {"title": "Pasta on", "minutes": 10, "timer": True, "detail": "Wholewheat spaghetti takes a couple of minutes longer than white — start it before anything else. Save a mug of pasta water."},
+ {"title": "Courgettes", "minutes": 5, "detail": "Coarsely grated, into a hot oiled pan until the water cooks off and they start to catch. This is the 'sauce'."},
+ {"title": "Prawns", "minutes": 3, "detail": "Garlic and chilli in for thirty seconds, then prawns — done at a loose pink C."},
+ {"title": "Toss", "minutes": 2, "detail": "Pasta in with a splash of its water and the lemon. Toss until it looks creamy — that's the starch, not cream."},
+ ],
+ ),
+ dict(
+ slug="peri-chicken-rice-greens", name="Peri-peri chicken, rice & greens", kind="dinner",
+ minutes=30, difficulty="easy", serves=2, platefig="plate-chicken",
+ kcal=520, protein_g=45, carbs_g=50, sugar_g=4, fiber_g=8,
+ fat_g=12, satfat_g=3.5, sodium_mg=560,
+ why="The Friday-night-out flavour, cooked in. Thighs stay juicy at a fraction of the restaurant's oil.",
+ tags=["crowd-pleaser"],
+ ingredients=[
+ {"name": "chicken thighs, skinless", "qty": 600, "unit": "g", "disp": "600 g"},
+ {"name": "brown rice", "qty": 140, "unit": "g", "disp": "140 g", "note": "pantry"},
+ {"name": "spring greens", "qty": 100, "unit": "g", "disp": "½ bag"},
+ {"name": "harissa paste", "qty": 20, "unit": "g", "disp": "4 tsp"},
+ {"name": "lemons", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "smoked paprika", "qty": 2, "unit": "g", "disp": "1 tsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Marinate fast", "minutes": 3, "detail": "Harissa, paprika, lemon juice massaged into the thighs. Even ten minutes while the rice starts is enough."},
+ {"title": "Rice on", "minutes": 2, "detail": "Brown rice into boiling water — 25 minutes."},
+ {"title": "Grill", "minutes": 16, "timer": True, "detail": "Thighs under a hot grill, turned once. Done at 74° with charred edges — the marinade should look baked on, not wet."},
+ {"title": "Greens", "minutes": 3, "detail": "Shredded, flashed in the pan with a squeeze of lemon."},
+ ],
+ ),
+ dict(
+ slug="veggie-chilli-baked-potato", name="Veggie chilli baked potatoes", kind="dinner",
+ minutes=35, difficulty="easy", serves=2, batch=2, platefig="bowl-chili",
+ kcal=450, protein_g=20, carbs_g=78, sugar_g=11, fiber_g=17,
+ fat_g=6, satfat_g=2, sodium_mg=560,
+ why="The other meat-free night — seventeen grams of fiber, most of the week's target in one bowl. Yogurt plays the sour cream.",
+ tags=["veggie", "batch", "high-fiber"],
+ ingredients=[
+ {"name": "baking potatoes", "qty": 2, "unit": "x", "disp": "2 large"},
+ {"name": "black beans", "qty": 1, "unit": "x", "disp": "1 tin"},
+ {"name": "kidney beans", "qty": 1, "unit": "x", "disp": "1 tin"},
+ {"name": "chopped tomatoes", "qty": 1, "unit": "x", "disp": "1 tin"},
+ {"name": "peppers", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "greek yogurt 0%", "qty": 80, "unit": "g", "disp": "4 tbsp"},
+ {"name": "cumin", "qty": 4, "unit": "g", "disp": "2 tsp", "note": "pantry"},
+ {"name": "smoked paprika", "qty": 4, "unit": "g", "disp": "2 tsp", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Potatoes in", "minutes": 2, "detail": "Pricked, oiled, salted, straight onto the oven shelf at 220°. They need ~45 minutes — microwave 8 minutes first to halve that."},
+ {"title": "Chilli", "minutes": 20, "timer": True, "detail": "Pepper softened, spices bloomed, beans and tomatoes in. Done when thick enough to sit on a potato without running off."},
+ {"title": "Load", "minutes": 2, "detail": "Potatoes split and crushed open, chilli over, cold yogurt on the hot chilli. Box the spare chilli."},
+ ],
+ ),
+ # ---- breakfasts (templates — planned daily, one-tap) ----
+ dict(
+ slug="oats-no1", name="Oats №1 — overnight oats, berries & flax", kind="breakfast",
+ minutes=5, difficulty="easy", serves=1, platefig="bowl-oats",
+ kcal=420, protein_g=34, carbs_g=52, sugar_g=8, fiber_g=11,
+ fat_g=9, satfat_g=2, sodium_mg=65,
+ why="The fiber head-start: oats and flax are the two best breakfast levers a lipid panel has.",
+ tags=["template", "high-fiber"],
+ ingredients=[
+ {"name": "oats", "qty": 60, "unit": "g", "disp": "60 g", "note": "pantry"},
+ {"name": "ground flaxseed", "qty": 15, "unit": "g", "disp": "1 tbsp", "note": "pantry"},
+ {"name": "greek yogurt 0%", "qty": 200, "unit": "g", "disp": "200 g"},
+ {"name": "berries, mixed", "qty": 80, "unit": "g", "disp": "80 g"},
+ ],
+ steps=[
+ {"title": "Night before", "minutes": 2, "detail": "Oats, flax and yogurt stirred with a splash of milk or water in a jar. Fridge."},
+ {"title": "Morning", "minutes": 1, "detail": "Berries on top. Done — that was the point."},
+ ],
+ ),
+ dict(
+ slug="yogurt-berry-bowl", name="Greek yogurt & berry bowl", kind="breakfast",
+ minutes=3, difficulty="easy", serves=1, platefig="bowl-oats",
+ kcal=320, protein_g=28, carbs_g=34, sugar_g=12, fiber_g=6,
+ fat_g=8, satfat_g=1, sodium_mg=55,
+ why="The lighter morning — most of the protein, none of the prep.",
+ tags=["template", "quick"],
+ ingredients=[
+ {"name": "greek yogurt 0%", "qty": 250, "unit": "g", "disp": "250 g"},
+ {"name": "berries, mixed", "qty": 100, "unit": "g", "disp": "100 g"},
+ {"name": "almonds", "qty": 15, "unit": "g", "disp": "small handful", "note": "pantry"},
+ {"name": "ground flaxseed", "qty": 10, "unit": "g", "disp": "2 tsp", "note": "pantry"},
+ ],
+ steps=[{"title": "Assemble", "minutes": 3, "detail": "Yogurt, berries, almonds, flax. A drizzle of honey if it's been that kind of week."}],
+ ),
+ dict(
+ slug="eggs-spinach-toast", name="Eggs, spinach & wholegrain toast", kind="breakfast",
+ minutes=10, difficulty="easy", serves=1, platefig="plate-eggs",
+ kcal=380, protein_g=24, carbs_g=30, sugar_g=3, fiber_g=6,
+ fat_g=17, satfat_g=3.5, sodium_mg=500,
+ why="The weekend one. Eggs' cholesterol matters far less than the sausage and butter that usually flank them — so they arrive with spinach instead.",
+ tags=["template", "weekend"],
+ ingredients=[
+ {"name": "eggs", "qty": 2, "unit": "x", "disp": "2"},
+ {"name": "spinach", "qty": 60, "unit": "g", "disp": "2 handfuls"},
+ {"name": "wholegrain bread", "qty": 2, "unit": "x", "disp": "2 slices"},
+ ],
+ steps=[
+ {"title": "Spinach", "minutes": 2, "detail": "Wilted in the dry pan first, squeezed of its water, set on the toast."},
+ {"title": "Eggs", "minutes": 4, "detail": "However you like them — poached is the low-fat play; fried in a teaspoon of oil is fine. Yolks jammy, not chalky."},
+ ],
+ ),
+ # ---- WFH lunches ----
+ dict(
+ slug="lentil-feta-salad", name="Big lentil & feta salad", kind="lunch",
+ minutes=10, difficulty="easy", serves=1, platefig="plate-salad",
+ kcal=430, protein_g=28, carbs_g=40, sugar_g=5, fiber_g=14,
+ fat_g=16, satfat_g=5, sodium_mg=620,
+ why="The WFH default: lentils for fiber, a measured amount of feta doing maximum work.",
+ tags=["wfh", "high-fiber"],
+ ingredients=[
+ {"name": "puy lentils", "qty": 125, "unit": "g", "disp": "½ pouch"},
+ {"name": "feta", "qty": 40, "unit": "g", "disp": "40 g"},
+ {"name": "cherry tomatoes", "qty": 125, "unit": "g", "disp": "handful"},
+ {"name": "spinach", "qty": 40, "unit": "g", "disp": "handful"},
+ {"name": "lemons", "qty": 1, "unit": "x", "disp": "½"},
+ {"name": "olive oil", "qty": 10, "unit": "ml", "disp": "2 tsp", "note": "pantry"},
+ ],
+ steps=[{"title": "Assemble", "minutes": 8, "detail": "Everything in a bowl, feta crumbled last so it stays in proud chunks. Lemon and oil over."}],
+ ),
+ dict(
+ slug="tuna-bean-lunchbox", name="Tuna & bean lunchbox", kind="lunch",
+ minutes=8, difficulty="easy", serves=1, platefig="plate-salad",
+ kcal=380, protein_g=34, carbs_g=32, sugar_g=4, fiber_g=10,
+ fat_g=10, satfat_g=1.5, sodium_mg=700,
+ why="Cupboard-only, travels well, and quietly one of the best protein-per-sat-fat ratios in the pool.",
+ tags=["wfh", "storecupboard"],
+ ingredients=[
+ {"name": "tuna in spring water", "qty": 1, "unit": "x", "disp": "1 tin"},
+ {"name": "white beans", "qty": 1, "unit": "x", "disp": "1 tin"},
+ {"name": "red onions", "qty": 1, "unit": "x", "disp": "¼, sliced thin"},
+ {"name": "lemons", "qty": 1, "unit": "x", "disp": "½"},
+ {"name": "olive oil", "qty": 10, "unit": "ml", "disp": "2 tsp", "note": "pantry"},
+ ],
+ steps=[{"title": "Assemble", "minutes": 8, "detail": "Beans rinsed, tuna forked through, onion and dressing over. Better after an hour in the fridge."}],
+ ),
+ dict(
+ slug="chicken-grain-soup", name="Chicken, bean & grain soup", kind="lunch",
+ minutes=25, difficulty="easy", serves=2, platefig="bowl-stew",
+ kcal=350, protein_g=30, carbs_g=36, sugar_g=3, fiber_g=8,
+ fat_g=8, satfat_g=2, sodium_mg=620,
+ why="The cold-day lunch — makes two, second one's tomorrow.",
+ tags=["wfh", "batch"],
+ ingredients=[
+ {"name": "chicken breast", "qty": 250, "unit": "g", "disp": "250 g"},
+ {"name": "white beans", "qty": 1, "unit": "x", "disp": "1 tin"},
+ {"name": "bulgur wheat", "qty": 60, "unit": "g", "disp": "60 g", "note": "pantry"},
+ {"name": "spring greens", "qty": 80, "unit": "g", "disp": "handful"},
+ {"name": "garlic", "qty": 2, "unit": "x", "disp": "2 cloves", "note": "pantry"},
+ ],
+ steps=[
+ {"title": "Simmer", "minutes": 18, "timer": True, "detail": "Chicken poached whole in 800 ml stock with garlic. Done when it shreds with two forks."},
+ {"title": "Everything in", "minutes": 5, "detail": "Chicken shredded back in with beans, bulgur and greens until the bulgur is tender."},
+ ],
+ ),
+ # ---- snacks ----
+ dict(
+ slug="apple-peanut-butter", name="Apple & peanut butter", kind="snack",
+ minutes=1, difficulty="easy", serves=1, platefig="snack-apple",
+ kcal=210, protein_g=5, carbs_g=24, sugar_g=16, fiber_g=4,
+ fat_g=11, satfat_g=1.5, sodium_mg=65,
+ why="Fiber plus fat that satisfies — the 3pm biscuit replacement that actually works.",
+ tags=["snack"],
+ ingredients=[
+ {"name": "apples", "qty": 1, "unit": "x", "disp": "1"},
+ {"name": "peanut butter", "qty": 15, "unit": "g", "disp": "1 tbsp", "note": "pantry"},
+ ],
+ steps=[],
+ ),
+ dict(
+ slug="almonds-30", name="Almonds, a proper handful", kind="snack",
+ minutes=1, difficulty="easy", serves=1, platefig="snack-nuts",
+ kcal=180, protein_g=6, carbs_g=6, sugar_g=1.3, fiber_g=4,
+ fat_g=15, satfat_g=1.2, sodium_mg=0,
+ why="Thirty grams of almonds is one of the few snacks with actual lipid-panel evidence behind it.",
+ tags=["snack"],
+ ingredients=[{"name": "almonds", "qty": 30, "unit": "g", "disp": "30 g", "note": "pantry"}],
+ steps=[],
+ ),
+ dict(
+ slug="protein-yogurt-pot", name="Protein yogurt pot", kind="snack",
+ minutes=1, difficulty="easy", serves=1, platefig="snack-yogurt",
+ kcal=150, protein_g=18, carbs_g=12, sugar_g=8, fiber_g=1,
+ fat_g=2, satfat_g=0.5, sodium_mg=60,
+ why="The evening protein closer when the day's number is short.",
+ tags=["snack"],
+ ingredients=[
+ {"name": "greek yogurt 0%", "qty": 170, "unit": "g", "disp": "170 g"},
+ {"name": "berries, mixed", "qty": 50, "unit": "g", "disp": "50 g"},
+ ],
+ steps=[],
+ ),
+]
+
+
+def _first_week() -> dict:
+ """Hand-written first food week (the Phase 2 seed-plan trick, applied to food).
+ Weekday keys 0–6 like the training plan; the rolling week maps dates onto them.
+ Office days Tue–Thu carry order-assist lunch slots; Friday is the night out."""
+ order_note = "Order out — target P 40+, sat ≤ 6 g, inside the lunch cap. Favorites + menu ranking land in Phase 9."
+ d = {
+ "0": {"slots": {
+ "breakfast": {"recipe": "oats-no1"},
+ "lunch": {"recipe": "lentil-feta-salad"},
+ "dinner": {"recipe": "harissa-chicken-traybake", "why": "cook once, eat twice — boxes Wednesday"},
+ "snack": {"recipe": "apple-peanut-butter"},
+ }},
+ "1": {"slots": {
+ "breakfast": {"recipe": "oats-no1"},
+ "lunch": {"order": True, "note": order_note},
+ "dinner": {"recipe": "salmon-puy-lentils", "why": "omega-3 day; lentils carry fiber"},
+ "snack": {"recipe": "apple-peanut-butter"},
+ }},
+ "2": {"slots": {
+ "breakfast": {"recipe": "oats-no1"},
+ "lunch": {"order": True, "note": order_note},
+ "dinner": {"leftover_of": "0", "why": "zero-cook on your run day"},
+ "snack": {"recipe": "protein-yogurt-pot"},
+ }},
+ "3": {"slots": {
+ "breakfast": {"recipe": "yogurt-berry-bowl"},
+ "lunch": {"order": True, "note": order_note},
+ "dinner": {"recipe": "turkey-black-bean-chili", "why": "fiber engine; batch feeds Friday lunch"},
+ "snack": {"recipe": "apple-peanut-butter"},
+ }},
+ "4": {"slots": {
+ "breakfast": {"recipe": "oats-no1"},
+ "lunch": {"leftover_of": "3", "why": "Thursday's chili, boxed"},
+ "dinner": {"out": True, "note": "Night out — enjoy it. Sat-fat headroom banked Mon–Thu."},
+ "snack": {"recipe": "almonds-30"},
+ }},
+ "5": {"slots": {
+ "breakfast": {"recipe": "eggs-spinach-toast"},
+ "lunch": {"recipe": "tuna-bean-lunchbox"},
+ "dinner": {"recipe": "prawn-soba-stirfry", "why": "fifteen minutes; barely any sat fat"},
+ "snack": {"recipe": "almonds-30"},
+ }},
+ "6": {"slots": {
+ "breakfast": {"recipe": "eggs-spinach-toast"},
+ "lunch": {"recipe": "chicken-grain-soup"},
+ "dinner": {"recipe": "baked-cod-white-bean-stew", "why": "finishes the harissa jar; soft landing for the week"},
+ "snack": {"recipe": "protein-yogurt-pot"},
+ }},
+ }
+ return {"days": d}
+
+
+def run_food_seed(db: Session) -> None:
+ """Insert-missing, same contract as run_seed — safe on every boot. The
+ pantry/ingredient reference always seeds (MCP recipe imports need it); the
+ curated recipes and the first food week only seed when SEED_RECIPES is on."""
+ from .config import get_settings
+ seed_recipes = get_settings().seed_recipes
+ # ingredient reference table
+ have = {i.name: i for i in db.query(Ingredient).all()}
+ for name, aisle, unit, pack, kcal, prot, carbs, sugar, fib, fat, sat, sodium, pantry in INGREDIENTS:
+ if name not in have:
+ db.add(Ingredient(name=name, aisle=aisle, unit=unit, pack=pack, kcal_100=kcal,
+ protein_100=prot, carbs_100=carbs, sugar_100=sugar, fiber_100=fib,
+ fat_100=fat, satfat_100=sat, sodium_100=sodium, pantry=pantry))
+ else: # backfill macros that predate the full-label set (startup ALTER defaults them to 0)
+ row = have[name]
+ for col, val in (("carbs_100", carbs), ("sugar_100", sugar),
+ ("fat_100", fat), ("sodium_100", sodium)):
+ if not getattr(row, col, 0):
+ setattr(row, col, val)
+ # recipe library (opt-out via SEED_RECIPES for MCP-populated libraries)
+ if seed_recipes:
+ have_r = {r.slug: r for r in db.query(Recipe).all()}
+ for r in RECIPES:
+ if r["slug"] not in have_r:
+ db.add(Recipe(**r))
+ else: # same backfill for authored recipe macros; meal_log snapshots stay untouched
+ row = have_r[r["slug"]]
+ for col in ("carbs_g", "sugar_g", "fat_g", "sodium_mg"):
+ if not getattr(row, col, 0):
+ setattr(row, col, r.get(col, 0))
+ db.commit()
+
+ # per-user nutrition prefs defaults (JSON column: reassign, never mutate in place)
+ for u in db.query(User).all():
+ prefs = dict(u.prefs or {})
+ missing = {k: v for k, v in NUTRITION_PREF_DEFAULTS.items() if k not in prefs}
+ if missing:
+ u.prefs = {**prefs, **missing}
+ db.commit()
+
+ # the member household's first food week (demo weeks are seeded by demo.py
+ # later) — references seeded recipe slugs, so it rides the same flag
+ if seed_recipes and not db.query(MealRevision).filter(MealRevision.user_id.is_(None)).first():
+ db.add(MealRevision(
+ user_id=None, num=1, status="active", content=_first_week(),
+ rationale=("First food week — hand-written baseline before the coach takes over "
+ "(Phase 8). Protein lands ~155 g/day without red meat; fiber averages "
+ "high 30s with beans, lentils and oats doing the work; sat fat holds "
+ "roughly 14 g/day, leaving honest room for Friday out."),
+ changes=[],
+ ))
+ db.commit()
diff --git a/server/app/main.py b/server/app/main.py
index bbb762f..a85353b 100644
--- a/server/app/main.py
+++ b/server/app/main.py
@@ -3,7 +3,7 @@
import os
import time
from contextlib import asynccontextmanager
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
@@ -17,7 +17,8 @@
from .config import apply_overrides, get_settings
from .db import Base, SessionLocal, engine
from .notify import push_enabled, send_push
-from .routers import admin, auth, coach_api, ingest, misc, push, training, withings
+from .routers import (admin, auth, coach_api, food, ingest, mcp_food, mcp_oauth, misc, push,
+ training, withings)
from .security import public_base_url
from .seed import run_seed
@@ -140,21 +141,31 @@ async def lifespan(_app: FastAPI):
time.sleep(1)
# create_all never alters existing tables (no Alembic yet) — tiny additive migrations here.
from sqlalchemy import inspect, text
- cols = {c["name"] for c in inspect(engine).get_columns("exercises")}
- if "benefit" not in cols:
- with engine.begin() as conn:
- conn.execute(text("ALTER TABLE exercises ADD COLUMN benefit TEXT DEFAULT ''"))
- sess_cols = {c["name"] for c in inspect(engine).get_columns("workout_sessions")}
- if "favorite" not in sess_cols:
- with engine.begin() as conn:
- conn.execute(text("ALTER TABLE workout_sessions ADD COLUMN favorite BOOLEAN DEFAULT FALSE"))
- run_cols = {c["name"] for c in inspect(engine).get_columns("agent_runs")}
- if "cache_read_tokens" not in run_cols:
- with engine.begin() as conn:
- conn.execute(text("ALTER TABLE agent_runs ADD COLUMN cache_read_tokens INTEGER DEFAULT 0"))
- if "cache_creation_tokens" not in run_cols:
- with engine.begin() as conn:
- conn.execute(text("ALTER TABLE agent_runs ADD COLUMN cache_creation_tokens INTEGER DEFAULT 0"))
+
+ def _add_missing_columns(table: str, columns: dict[str, str]) -> None:
+ have = {c["name"] for c in inspect(engine).get_columns(table)}
+ for col, ddl in columns.items():
+ if col not in have:
+ with engine.begin() as conn:
+ conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}"))
+
+ _add_missing_columns("exercises", {"benefit": "TEXT DEFAULT ''"})
+ _add_missing_columns("workout_sessions", {"favorite": "BOOLEAN DEFAULT FALSE"})
+ _add_missing_columns("agent_runs", {"cache_read_tokens": "INTEGER DEFAULT 0",
+ "cache_creation_tokens": "INTEGER DEFAULT 0"})
+ # full macro set on the food tables (carbs/sugar/fat/sodium joined the trio)
+ _new_macros = {c: "FLOAT DEFAULT 0" for c in ("carbs_g", "sugar_g", "fat_g", "sodium_mg")}
+ _add_missing_columns("recipes", {"sugar_g": "FLOAT DEFAULT 0", "sodium_mg": "FLOAT DEFAULT 0"})
+ _add_missing_columns("ingredients",
+ {c: "FLOAT DEFAULT 0" for c in ("carbs_100", "sugar_100", "fat_100", "sodium_100")})
+ _add_missing_columns("meal_log", _new_macros)
+ _add_missing_columns("lunch_favorites", _new_macros)
+ # MCP food import surface: eaten-out details on logs, imagery/ratings on recipes
+ _add_missing_columns("meal_log", {"venue": "VARCHAR(80) DEFAULT ''", "cost": "FLOAT DEFAULT 0",
+ "currency": "VARCHAR(8) DEFAULT ''", "note": "TEXT DEFAULT ''",
+ "photos": "JSON DEFAULT '[]'"})
+ _add_missing_columns("recipes", {"images": "JSON DEFAULT '[]'", "rating": "FLOAT DEFAULT 0",
+ "rating_count": "INTEGER DEFAULT 0"})
# scrub artefacts of the old agent loop that could end a run with no message
with engine.begin() as conn:
conn.execute(text("DELETE FROM chat_messages WHERE text = '(no reply)'"))
@@ -193,6 +204,9 @@ def _build_id() -> str:
app.include_router(coach_api.router)
app.include_router(withings.router)
app.include_router(push.router)
+app.include_router(food.router)
+app.include_router(mcp_food.router)
+app.include_router(mcp_oauth.router)
# Serve the frontend: the built React app when available (web/dist locally, or
# copied to ./static in the Docker image); the legacy vanilla client otherwise.
@@ -204,6 +218,18 @@ def _build_id() -> str:
_static = next((p for p in _candidates if (p / "index.html").exists()), _candidates[-1])
+def _html_page(name: str):
+ """Serve an HTML entry point with __BASE_URL__ substituted from settings.
+ The public domain deliberately lives only in the compose override — tracked
+ files carry the token so the repo never names the host."""
+ from fastapi import HTTPException
+ try:
+ html = (_static / name).read_text()
+ except OSError:
+ raise HTTPException(status_code=404, detail="not built")
+ return HTMLResponse(html.replace("__BASE_URL__", get_settings().base_url.rstrip("/")))
+
+
def _render_index(request: Request) -> HTMLResponse:
# Serve the SPA shell with the __ORIGIN__ placeholder in its link-preview
# (Open Graph / Twitter) tags resolved to the request's own origin, so the
@@ -227,20 +253,48 @@ def dashboard_page(request: Request):
return _render_index(request)
+@app.get("/.well-known/security.txt")
+@app.get("/security.txt")
+def security_txt():
+ """RFC 9116 vulnerability-disclosure pointer (Cloudflare checks for it).
+ Contact derives from the admin seat at request time, so no personal data
+ lands in the tracked repo; Expires rolls six months out."""
+ from fastapi import HTTPException
+ from fastapi.responses import PlainTextResponse
+ db = SessionLocal()
+ try:
+ admin = (db.query(models.User).filter(models.User.role == "admin")
+ .order_by(models.User.created_at).first())
+ finally:
+ db.close()
+ if not admin:
+ raise HTTPException(status_code=404, detail="no admin seat yet")
+ expires = (datetime.now(timezone.utc) + timedelta(days=182)).strftime("%Y-%m-%dT%H:%M:%SZ")
+ base = get_settings().base_url.rstrip("/")
+ return PlainTextResponse(
+ f"Contact: mailto:{admin.email}\n"
+ f"Expires: {expires}\n"
+ f"Canonical: {base}/.well-known/security.txt\n"
+ "Preferred-Languages: en\n",
+ headers={"Cache-Control": "public, max-age=86400"})
+
+
@app.get("/welcome")
def welcome_page(request: Request):
- # First-time-visitor landing page. It ships as a static welcome.html, and
- # once the service worker is installed Workbox's cleanURLs serves /welcome
- # straight from the precached welcome.html. A brand-new visitor has no
- # service worker, so the extensionless /welcome reaches the server — where
- # StaticFiles only knows welcome.html and would 404 ({"detail":"Not Found"}).
- # Mirror the client-side cleanURLs mapping here so the very first, un-cached
- # visit resolves too; fall back to the SPA shell if the page isn't shipped
- # (legacy vanilla client).
- welcome = _static / "welcome.html"
- if welcome.exists():
- return FileResponse(welcome, headers={"Cache-Control": "no-cache"})
- return _render_index(request)
+ # Pretty URL for the shareable landing page. StaticFiles only matches the
+ # exact filename, and the SW denylists /welcome from the SPA fallback — so
+ # without this route a shared "/welcome" link 404s on any fresh browser.
+ # _html_page substitutes __BASE_URL__ for the page's link-preview metadata;
+ # fall back to the SPA shell if the page isn't shipped (legacy vanilla client).
+ if not (_static / "welcome.html").exists():
+ return _render_index(request)
+ return _html_page("welcome.html")
+
+
+@app.get("/welcome.html")
+def welcome_page_html():
+ # the in-app "New here?" link uses the file name — same substituted page
+ return _html_page("welcome.html")
app.mount("/", StaticFiles(directory=str(_static), html=True), name="static")
diff --git a/server/app/media.py b/server/app/media.py
new file mode 100644
index 0000000..22d5179
--- /dev/null
+++ b/server/app/media.py
@@ -0,0 +1,88 @@
+"""Food image intake for the MCP surface. Images land as remote URLs or data:
+URIs; both are materialized into `media_blobs` so the PWA never hotlinks the
+outside world (offline-first, and recipe sources rot). A URL that can't be
+fetched is kept verbatim as a graceful fallback — the row still records where
+the picture lives."""
+
+import base64
+import binascii
+import logging
+import re
+
+import httpx
+from sqlalchemy.orm import Session
+
+from .models import MediaBlob
+
+log = logging.getLogger("forge.media")
+
+MAX_BYTES = 3 * 1024 * 1024
+FETCH_TIMEOUT = 8.0
+_DATA_RE = re.compile(r"^data:(image/[a-z0-9.+-]+);base64,(.+)$", re.IGNORECASE | re.DOTALL)
+
+
+def media_path(blob: MediaBlob) -> str:
+ return f"/api/food/media/{blob.id}"
+
+
+def _store(db: Session, user_id: str | None, mime: str, data: bytes, src_url: str = "") -> MediaBlob:
+ blob = MediaBlob(user_id=user_id, mime=mime.lower(), data=data, src_url=src_url)
+ db.add(blob)
+ db.flush() # id now assigned; caller commits with the owning row
+ return blob
+
+
+def store_image(db: Session, user_id: str | None, src: str) -> tuple[str, str | None]:
+ """Materialize one image reference. Returns (stored_ref, warning).
+ stored_ref is a /api/food/media/{id} path on success, or the original URL
+ when fetching failed (warning explains why)."""
+ src = (src or "").strip()
+ if not src:
+ return "", "empty image reference"
+
+ m = _DATA_RE.match(src)
+ if m:
+ try:
+ data = base64.b64decode(m.group(2), validate=True)
+ except (binascii.Error, ValueError):
+ return "", "invalid base64 in data: URI"
+ if len(data) > MAX_BYTES:
+ return "", f"image over the {MAX_BYTES // (1024 * 1024)} MB cap"
+ return media_path(_store(db, user_id, m.group(1), data)), None
+
+ if not src.startswith(("http://", "https://")):
+ return "", f"unsupported image reference: {src[:60]}"
+
+ # re-imports of the same page shouldn't duplicate blobs
+ existing = (db.query(MediaBlob)
+ .filter(MediaBlob.src_url == src, MediaBlob.user_id == user_id).first())
+ if existing:
+ return media_path(existing), None
+
+ try:
+ with httpx.Client(timeout=FETCH_TIMEOUT, follow_redirects=True,
+ headers={"User-Agent": "Forge/1.0 (self-hosted fitness app)"}) as client:
+ resp = client.get(src)
+ resp.raise_for_status()
+ mime = (resp.headers.get("content-type") or "").split(";")[0].strip()
+ if not mime.startswith("image/"):
+ return src, f"not an image ({mime or 'no content-type'}) — kept as remote URL"
+ if len(resp.content) > MAX_BYTES:
+ return src, "image over the size cap — kept as remote URL"
+ return media_path(_store(db, user_id, mime, resp.content, src_url=src)), None
+ except httpx.HTTPError as e:
+ log.info("image fetch failed for %s: %s", src, e)
+ return src, f"fetch failed ({type(e).__name__}) — kept as remote URL"
+
+
+def store_images(db: Session, user_id: str | None, srcs: list[str]) -> tuple[list[str], list[str]]:
+ """Materialize a list; drops empty/invalid entries, collects warnings."""
+ stored: list[str] = []
+ warnings: list[str] = []
+ for s in srcs or []:
+ ref, warn = store_image(db, user_id, s)
+ if ref:
+ stored.append(ref)
+ if warn:
+ warnings.append(warn)
+ return stored, warnings
diff --git a/server/app/models.py b/server/app/models.py
index bfebe7e..774f6f9 100644
--- a/server/app/models.py
+++ b/server/app/models.py
@@ -1,8 +1,8 @@
from datetime import date, datetime, timezone
from uuid import uuid4
-from sqlalchemy import (JSON, Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text,
- UniqueConstraint)
+from sqlalchemy import (JSON, Boolean, Date, DateTime, Float, ForeignKey, Integer, LargeBinary,
+ String, Text, UniqueConstraint)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
@@ -38,6 +38,44 @@ class IngestToken(Base):
samples: Mapped[int] = mapped_column(Integer, default=0)
+class OAuthClient(Base):
+ """Dynamically-registered MCP client (RFC 7591) — e.g. Claude's connector UI.
+ Public clients only (PKCE, no secret); registration grants nothing by itself,
+ every token still requires a signed-in user's consent."""
+ __tablename__ = "oauth_clients"
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=uid)
+ name: Mapped[str] = mapped_column(String(120), default="")
+ redirect_uris: Mapped[list] = mapped_column(JSON, default=list)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+
+
+class OAuthCode(Base):
+ """Single-use authorization code binding a consenting user to a client + PKCE
+ challenge. Short-lived; deleted on redemption."""
+ __tablename__ = "oauth_codes"
+ code: Mapped[str] = mapped_column(String(64), primary_key=True)
+ client_id: Mapped[str] = mapped_column(ForeignKey("oauth_clients.id"))
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id"))
+ redirect_uri: Mapped[str] = mapped_column(String(500))
+ code_challenge: Mapped[str] = mapped_column(String(128))
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+
+
+class OAuthToken(Base):
+ """Access + refresh token pair for one user↔client grant. Refresh rotates on
+ use; deleting the row is revocation."""
+ __tablename__ = "oauth_tokens"
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=uid)
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), index=True)
+ client_id: Mapped[str] = mapped_column(ForeignKey("oauth_clients.id"))
+ access_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+ refresh_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+ access_expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+ refresh_expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+ last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+
class Metric(Base):
__tablename__ = "metrics"
__table_args__ = (UniqueConstraint("user_id", "type", "ts", "source", name="uq_metric_sample"),)
@@ -272,3 +310,162 @@ class AppSetting(Base):
key: Mapped[str] = mapped_column(String(40), primary_key=True)
value: Mapped[str] = mapped_column(Text, default="")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
+
+
+# ---------- nutrition (beta track, Phase 7 — stories E16) ----------
+
+# The full per-meal macro set, in display order. Everything that snapshots or
+# totals meal macros (routes, coach tools, seeds) iterates this one tuple —
+# add here + a startup ALTER in main.py to extend coverage further.
+# All grams except kcal and sodium (mg).
+MACRO_FIELDS = ("kcal", "protein_g", "carbs_g", "sugar_g", "fiber_g",
+ "fat_g", "satfat_g", "sodium_mg")
+
+
+class Recipe(Base):
+ """Curated recipe library — the food twin of `exercises`. Per-serving macros
+ are authored (hand-checked) at seed/import time; the `ingredients` JSON list
+ joins the `ingredients` table by name for shopping/waste math (Phase 8)."""
+ __tablename__ = "recipes"
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=uid)
+ slug: Mapped[str] = mapped_column(String(60), unique=True, index=True)
+ name: Mapped[str] = mapped_column(String(120))
+ kind: Mapped[str] = mapped_column(String(16), default="dinner") # dinner | breakfast | lunch | snack
+ minutes: Mapped[int] = mapped_column(Integer, default=0)
+ difficulty: Mapped[str] = mapped_column(String(12), default="easy") # easy | medium — hard ceiling
+ serves: Mapped[int] = mapped_column(Integer, default=2)
+ batch: Mapped[int] = mapped_column(Integer, default=0) # extra servings boxed for a zero-cook night
+ kcal: Mapped[float] = mapped_column(Float, default=0) # per serving, canonical
+ protein_g: Mapped[float] = mapped_column(Float, default=0)
+ carbs_g: Mapped[float] = mapped_column(Float, default=0)
+ sugar_g: Mapped[float] = mapped_column(Float, default=0)
+ fiber_g: Mapped[float] = mapped_column(Float, default=0)
+ fat_g: Mapped[float] = mapped_column(Float, default=0)
+ satfat_g: Mapped[float] = mapped_column(Float, default=0)
+ sodium_mg: Mapped[float] = mapped_column(Float, default=0)
+ why: Mapped[str] = mapped_column(Text, default="") # "why it's in your week" one-liner
+ steps: Mapped[list] = mapped_column(JSON, default=list) # [{title, minutes, detail, timer, parallel}] — done-when style; parallel = background step (start timer, move on)
+ ingredients: Mapped[list] = mapped_column(JSON, default=list) # [{name, qty, unit, disp, note}]
+ tags: Mapped[list] = mapped_column(JSON, default=list)
+ platefig: Mapped[str] = mapped_column(String(32), default="plate") # plate-art composition id
+ source: Mapped[str] = mapped_column(String(24), default="seed") # seed | bbc-good-food | card-box | import
+ source_url: Mapped[str] = mapped_column(Text, default="")
+ images: Mapped[list] = mapped_column(JSON, default=list) # hero/gallery: /api/food/media/{id} paths or remote URLs
+ rating: Mapped[float] = mapped_column(Float, default=0) # source-site rating, 0–5 (0 = unrated)
+ rating_count: Mapped[int] = mapped_column(Integer, default=0)
+ complete: Mapped[int] = mapped_column(Integer, default=1) # only complete entries are proposable
+
+
+class Ingredient(Base):
+ """Macro/aisle reference per ingredient name — per 100 g/ml, or per item when
+ unit is 'x'. Pantry staples never appear on shopping lists."""
+ __tablename__ = "ingredients"
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=uid)
+ name: Mapped[str] = mapped_column(String(80), unique=True, index=True)
+ aisle: Mapped[str] = mapped_column(String(24), default="cupboard") # produce | protein | dairy | cupboard | frozen
+ unit: Mapped[str] = mapped_column(String(8), default="g") # g | ml | x
+ pack: Mapped[str] = mapped_column(String(40), default="") # typical pack, e.g. "400 g tin"
+ kcal_100: Mapped[float] = mapped_column(Float, default=0)
+ protein_100: Mapped[float] = mapped_column(Float, default=0)
+ carbs_100: Mapped[float] = mapped_column(Float, default=0)
+ sugar_100: Mapped[float] = mapped_column(Float, default=0)
+ fiber_100: Mapped[float] = mapped_column(Float, default=0)
+ fat_100: Mapped[float] = mapped_column(Float, default=0)
+ satfat_100: Mapped[float] = mapped_column(Float, default=0)
+ sodium_100: Mapped[float] = mapped_column(Float, default=0) # mg per 100 g/ml (or per item)
+ pantry: Mapped[int] = mapped_column(Integer, default=0)
+
+
+class MealRevision(Base):
+ """Versioned food week, mirror of plan_revisions. user_id NULL = the member
+ household's shared week (dinners cook for two); the demo seat gets rows under
+ its own user_id so it can never see the household's food."""
+ __tablename__ = "meal_revisions"
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=uid)
+ user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
+ num: Mapped[int] = mapped_column(Integer)
+ status: Mapped[str] = mapped_column(String(16), default="active") # proposed | active | superseded
+ content: Mapped[dict] = mapped_column(JSON, default=dict) # {"days": {"0".."6": {"slots": {...}}}}
+ rationale: Mapped[str] = mapped_column(Text, default="")
+ changes: Mapped[list] = mapped_column(JSON, default=list) # [{sign, what, why}] — proposal diff rows
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+
+
+class MealLog(Base):
+ """One row per eaten thing, macros snapshotted at log time so later recipe
+ edits never rewrite history. client_id makes offline-queue retries idempotent."""
+ __tablename__ = "meal_log"
+ __table_args__ = (UniqueConstraint("user_id", "client_id", name="uq_meal_client"),)
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=uid)
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), index=True)
+ day: Mapped[date] = mapped_column(Date, index=True)
+ slot: Mapped[str] = mapped_column(String(12)) # breakfast | lunch | dinner | snack
+ recipe_slug: Mapped[str] = mapped_column(String(60), default="") # empty for off-plan estimates
+ label: Mapped[str] = mapped_column(String(120), default="")
+ servings: Mapped[float] = mapped_column(Float, default=1)
+ kcal: Mapped[float] = mapped_column(Float, default=0) # totals for `servings`, snapshot
+ protein_g: Mapped[float] = mapped_column(Float, default=0)
+ carbs_g: Mapped[float] = mapped_column(Float, default=0)
+ sugar_g: Mapped[float] = mapped_column(Float, default=0)
+ fiber_g: Mapped[float] = mapped_column(Float, default=0)
+ fat_g: Mapped[float] = mapped_column(Float, default=0)
+ satfat_g: Mapped[float] = mapped_column(Float, default=0)
+ sodium_mg: Mapped[float] = mapped_column(Float, default=0)
+ source: Mapped[str] = mapped_column(String(12), default="plan") # plan | chat | order | mcp
+ estimated: Mapped[int] = mapped_column(Integer, default=0)
+ client_id: Mapped[str | None] = mapped_column(String(40), nullable=True)
+ venue: Mapped[str] = mapped_column(String(80), default="") # store/restaurant for eaten-out logs
+ cost: Mapped[float] = mapped_column(Float, default=0)
+ currency: Mapped[str] = mapped_column(String(8), default="") # free-text code; blank = unknown
+ note: Mapped[str] = mapped_column(Text, default="")
+ photos: Mapped[list] = mapped_column(JSON, default=list) # /api/food/media/{id} paths or remote URLs
+ ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+
+
+class MediaBlob(Base):
+ """Food images stored on-box (no external hotlinks in the offline PWA).
+ user_id NULL = household-visible (recipe imagery, shared like the recipe
+ library); a user id = that user's meal photos, never served to anyone else.
+ Small and capped at ingest (3 MB, image/* only) — fine in Postgres for two
+ seats, and it needs no new Docker volume."""
+ __tablename__ = "media_blobs"
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=uid)
+ user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
+ mime: Mapped[str] = mapped_column(String(40), default="image/jpeg")
+ data: Mapped[bytes] = mapped_column(LargeBinary)
+ src_url: Mapped[str] = mapped_column(Text, default="") # where it was fetched from, if a URL
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+
+
+class Carryover(Base):
+ """What a week's shop leaves behind (E16.5). Household-scoped like the food
+ week (user_id NULL for members, demo's id for demo). Wired up in Phase 8;
+ the table ships in Phase 7 so create_all provisions it once."""
+ __tablename__ = "carryovers"
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=uid)
+ user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
+ week_start: Mapped[date] = mapped_column(Date, index=True)
+ item: Mapped[str] = mapped_column(String(80))
+ qty: Mapped[str] = mapped_column(String(40), default="") # human amount: "½ bag", "⅔ jar"
+ use_by: Mapped[date | None] = mapped_column(Date, nullable=True)
+ status: Mapped[str] = mapped_column(String(12), default="open") # open | kept | binned | consumed
+
+
+class LunchFavorite(Base):
+ """Vetted repeat orders (E16.7, wired in Phase 9). Strictly per-user."""
+ __tablename__ = "lunch_favorites"
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=uid)
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), index=True)
+ vendor: Mapped[str] = mapped_column(String(20), default="other") # mealpal | grubhub | other
+ item: Mapped[str] = mapped_column(String(120))
+ price: Mapped[float] = mapped_column(Float, default=0)
+ kcal: Mapped[float] = mapped_column(Float, default=0)
+ protein_g: Mapped[float] = mapped_column(Float, default=0)
+ carbs_g: Mapped[float] = mapped_column(Float, default=0)
+ sugar_g: Mapped[float] = mapped_column(Float, default=0)
+ fiber_g: Mapped[float] = mapped_column(Float, default=0)
+ fat_g: Mapped[float] = mapped_column(Float, default=0)
+ satfat_g: Mapped[float] = mapped_column(Float, default=0)
+ sodium_mg: Mapped[float] = mapped_column(Float, default=0)
+ notes: Mapped[str] = mapped_column(Text, default="")
+ last_ordered: Mapped[date | None] = mapped_column(Date, nullable=True)
diff --git a/server/app/routers/admin.py b/server/app/routers/admin.py
index bfe2238..9fbafc0 100644
--- a/server/app/routers/admin.py
+++ b/server/app/routers/admin.py
@@ -171,6 +171,14 @@ def demo_create(user: User = Depends(admin_user), db: Session = Depends(get_db))
return {"exists": True}
+@router.post("/demo/enrich")
+def demo_enrich(user: User = Depends(admin_user), db: Session = Depends(get_db)):
+ """Top up an existing demo with data newer features expect (e.g. the food
+ beta) without resetting its year of training history. No-op when current."""
+ from ..demo import enrich_demo
+ return enrich_demo(db)
+
+
@router.delete("/demo")
def demo_delete(user: User = Depends(admin_user), db: Session = Depends(get_db)):
from ..demo import delete_demo
@@ -263,3 +271,17 @@ def _sorted(buckets: dict) -> list[dict]:
"by_kind": _sorted(by_kind),
"by_model": _sorted(by_model),
}
+@router.delete("/recipes")
+def wipe_recipes(user: User = Depends(admin_user), db: Session = Depends(get_db)):
+ """Empty the recipe library — for running an MCP-populated library. Also
+ retires the shared household food week that referenced the seeded recipes so
+ the Food screen is a clean slate ("No food week yet"). Meal logs (which
+ snapshot their own macros) are untouched, as is the pantry/ingredient
+ reference. Pair with SEED_RECIPES=false so a boot never re-seeds them."""
+ from ..models import MealRevision, Recipe
+ recipes = db.query(Recipe).delete(synchronize_session=False)
+ weeks = (db.query(MealRevision)
+ .filter(MealRevision.user_id.is_(None), MealRevision.status == "active")
+ .update({"status": "superseded"}, synchronize_session=False))
+ db.commit()
+ return {"recipes_deleted": recipes, "food_weeks_retired": weeks}
diff --git a/server/app/routers/auth.py b/server/app/routers/auth.py
index 76e1965..3336b83 100644
--- a/server/app/routers/auth.py
+++ b/server/app/routers/auth.py
@@ -85,6 +85,11 @@ def dev_login(body: DevLogin, request: Request, db: Session = Depends(get_db)):
return resp
+def _safe_next(nxt: str) -> str:
+ # same-origin relative paths only — anything else falls back to the app root
+ return nxt if nxt.startswith("/") and not nxt.startswith("//") else "/"
+
+
@router.get("/login")
async def login(request: Request, native: bool = False):
if not get_settings().google_enabled:
@@ -94,6 +99,9 @@ async def login(request: Request, native: bool = False):
request.session.pop("native_auth", None)
if native:
request.session["native_auth"] = True
+ nxt = _safe_next(request.query_params.get("next", "/"))
+ if nxt != "/":
+ request.session["post_login_next"] = nxt # e.g. the MCP OAuth consent page
# Host-derived so sign-in completes on whichever allowed domain it started on;
# every allowed host's /auth/callback must be registered with the Google client.
redirect_uri = public_base_url(request) + "/auth/callback"
@@ -116,9 +124,12 @@ async def callback(request: Request, db: Session = Depends(get_db)):
if native:
# The native client stores no cookies; it replays this signed value as
# an explicit Cookie header, so the session stays the same one the PWA
- # uses and the server grows no second auth surface.
+ # uses and the server grows no second auth surface. `post_login_next` is
+ # a browser concept (the MCP consent page) — drop it rather than leave
+ # it primed to fire on this session's next browser sign-in.
+ request.session.pop("post_login_next", None)
return RedirectResponse(f"{NATIVE_CALLBACK}?token={session_cookie_value(user.id)}")
- resp = RedirectResponse("/")
+ resp = RedirectResponse(_safe_next(request.session.pop("post_login_next", "/")))
set_session(resp, user.id, request)
return resp
diff --git a/server/app/routers/coach_api.py b/server/app/routers/coach_api.py
index 09387e7..e1a802e 100644
--- a/server/app/routers/coach_api.py
+++ b/server/app/routers/coach_api.py
@@ -11,9 +11,10 @@
@router.get("/proposal")
def get_proposal(user: User = Depends(current_user), db: Session = Depends(get_db)):
- rev = (db.query(PlanRevision).join(Plan)
- .filter(Plan.user_id == user.id, PlanRevision.status == "proposed")
- .order_by(PlanRevision.num.desc()).first())
+ from .training import heal_revision
+ rev = heal_revision(db, (db.query(PlanRevision).join(Plan)
+ .filter(Plan.user_id == user.id, PlanRevision.status == "proposed")
+ .order_by(PlanRevision.num.desc()).first()))
if not rev:
return {"proposal": None}
return {"proposal": {"id": rev.id, "num": rev.num, "rationale": rev.rationale,
diff --git a/server/app/routers/food.py b/server/app/routers/food.py
new file mode 100644
index 0000000..68c2b4a
--- /dev/null
+++ b/server/app/routers/food.py
@@ -0,0 +1,387 @@
+"""Nutrition routes (beta track, Phase 7 — stories E16). The food week is
+household-shared for members (meal_revisions.user_id IS NULL); the demo seat
+only ever sees rows under its own user_id. Meal logs are strictly per-user."""
+
+from datetime import timedelta
+
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import Response
+from pydantic import BaseModel
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from ..config import local_today
+from ..db import get_db
+from ..food_seed import DEFAULT_TARGETS
+from ..models import MACRO_FIELDS, Ingredient, MealLog, MealRevision, MediaBlob, Recipe, User
+from ..security import current_user
+from .training import DAY_NAMES, parse_date
+
+router = APIRouter(prefix="/api/food", tags=["food"])
+
+SLOT_ORDER = ["breakfast", "lunch", "dinner", "snack"]
+
+
+def food_scope(user: User) -> str | None:
+ """NULL scope = the member household; the demo seat is walled into its own."""
+ return user.id if user.role == "demo" else None
+
+
+def active_meal_revision(db: Session, user: User) -> MealRevision | None:
+ scope = food_scope(user)
+ q = db.query(MealRevision).filter(MealRevision.status == "active")
+ q = q.filter(MealRevision.user_id == scope) if scope else q.filter(MealRevision.user_id.is_(None))
+ return q.order_by(MealRevision.num.desc()).first()
+
+
+def recipe_card(r: Recipe) -> dict:
+ return {"slug": r.slug, "name": r.name, "kind": r.kind, "minutes": r.minutes,
+ "difficulty": r.difficulty, "serves": r.serves, "batch": r.batch,
+ "platefig": r.platefig, "why": r.why,
+ "image": (r.images or [None])[0], "rating": r.rating or 0,
+ **{k: getattr(r, k) for k in MACRO_FIELDS}}
+
+
+def targets_for(user: User) -> dict:
+ t = (user.prefs or {}).get("nutrition_targets") or {}
+ return {**DEFAULT_TARGETS, **t}
+
+
+def _resolve_slot(entry: dict, days: dict, recipes: dict[str, Recipe]) -> dict | None:
+ """Resolve one planned slot entry into an API shape. Leftover slots inherit
+ the referenced day's dinner recipe (dimmed by the UI)."""
+ if not entry:
+ return None
+ out: dict = {"why": entry.get("why", "")}
+ if entry.get("out"):
+ return {**out, "out": True, "note": entry.get("note", "")}
+ if entry.get("order"):
+ return {**out, "order": True, "note": entry.get("note", "")}
+ slug = entry.get("recipe")
+ if entry.get("leftover_of") is not None:
+ src_day = days.get(str(entry["leftover_of"])) or {}
+ slug = ((src_day.get("slots") or {}).get("dinner") or {}).get("recipe")
+ out["leftover"] = True
+ r = recipes.get(slug or "")
+ if not r:
+ return {**out, "note": entry.get("note", "unplanned")}
+ return {**out, "recipe": recipe_card(r)}
+
+
+def _logged_slot_view(lg: MealLog, recipes: dict[str, Recipe]) -> dict:
+ """What a slot shows once a meal is logged into it — replacing the planned
+ option so the slot reflects what was actually eaten. A logged recipe (even a
+ swap for the planned one) shows its card; an off-plan estimate shows its own
+ label + macros."""
+ if lg.recipe_slug and lg.recipe_slug in recipes:
+ return {"recipe": recipe_card(recipes[lg.recipe_slug])}
+ return {"label": lg.label, "estimated": bool(lg.estimated),
+ "macros": {k: getattr(lg, k) or 0 for k in MACRO_FIELDS}}
+
+
+@router.get("/week")
+def food_week(date: str | None = None, user: User = Depends(current_user),
+ db: Session = Depends(get_db)):
+ """Calendar-week food view (Mon–Sun), same contract as /api/week."""
+ base = parse_date(date)
+ base = base - timedelta(days=base.weekday()) # snap to Monday
+ actual_today = local_today()
+ rev = active_meal_revision(db, user)
+ days = ((rev.content or {}).get("days", {}) or {}) if rev else {}
+
+ logs = (db.query(MealLog)
+ .filter(MealLog.user_id == user.id, MealLog.day >= base,
+ MealLog.day <= base + timedelta(days=6))
+ .order_by(MealLog.ts).all())
+ by_day: dict[str, list[MealLog]] = {}
+ for lg in logs:
+ by_day.setdefault(str(lg.day), []).append(lg)
+
+ # planned slugs AND anything actually logged (a logged meal can replace the
+ # planned option in its slot, so its recipe card must be available too)
+ slugs = {s.get("recipe") for d in days.values() for s in (d.get("slots") or {}).values()
+ if s and s.get("recipe")}
+ slugs |= {lg.recipe_slug for lg in logs if lg.recipe_slug}
+ recipes = {r.slug: r for r in db.query(Recipe).filter(Recipe.slug.in_(slugs)).all()} if slugs else {}
+
+ out_days = []
+ for i in range(7):
+ d = base + timedelta(days=i)
+ entry = days.get(str(d.weekday())) or {}
+ slots_in = entry.get("slots") or {}
+ day_logs = by_day.get(str(d), [])
+ matched_ids: set[str] = set()
+ slots_out = []
+ for slot in SLOT_ORDER:
+ resolved = _resolve_slot(slots_in.get(slot), days, recipes)
+ if resolved is None:
+ continue # unplanned slot — any log for it stays an extra
+ match = next((lg for lg in day_logs if lg.slot == slot and lg.id not in matched_ids), None)
+ if match:
+ matched_ids.add(match.id)
+ planned_slug = (resolved.get("recipe") or {}).get("slug")
+ # what was actually eaten replaces the planned card unless it IS
+ # the planned recipe (then the card just gets ticked)
+ if not (match.recipe_slug and match.recipe_slug == planned_slug):
+ why = resolved.get("why", "")
+ resolved = {"why": why, "off_plan": True, **_logged_slot_view(match, recipes)}
+ resolved.update({"slot": slot, "logged": bool(match),
+ "log_id": match.id if match else None})
+ slots_out.append(resolved)
+ extras = [lg for lg in day_logs if lg.id not in matched_ids]
+ totals = {k: round(sum(getattr(lg, k) or 0 for lg in day_logs), 1) for k in MACRO_FIELDS}
+ out_days.append({
+ "date": str(d), "day_name": DAY_NAMES[d.weekday()], "is_today": d == actual_today,
+ "slots": slots_out,
+ "extras": [{"id": lg.id, "slot": lg.slot, "label": lg.label,
+ **{k: getattr(lg, k) or 0 for k in MACRO_FIELDS},
+ "estimated": bool(lg.estimated), "venue": lg.venue or "",
+ "cost": lg.cost or 0, "currency": lg.currency or "",
+ "note": lg.note or "", "photos": lg.photos or []} for lg in extras],
+ "totals": totals,
+ })
+
+ return {"start": str(base), "today": str(actual_today), "days": out_days,
+ "targets": targets_for(user),
+ "rationale": rev.rationale if rev else "", "has_plan": rev is not None}
+
+
+@router.get("/proposal")
+def food_proposal(user: User = Depends(current_user), db: Session = Depends(get_db)):
+ """The pending food-week proposal for this household (Phase 8, E16.3).
+ Ships a slug→card map so the UI renders names/plate art without N fetches."""
+ scope = food_scope(user)
+ q = db.query(MealRevision).filter(MealRevision.status == "proposed")
+ q = q.filter(MealRevision.user_id == scope) if scope else q.filter(MealRevision.user_id.is_(None))
+ rev = q.order_by(MealRevision.num.desc()).first()
+ if not rev:
+ return {"proposal": None}
+ days = (rev.content or {}).get("days", {}) or {}
+ slugs = {s.get("recipe") for d in days.values() for s in (d.get("slots") or {}).values()
+ if s and s.get("recipe")}
+ cards = {r.slug: recipe_card(r) for r in db.query(Recipe).filter(Recipe.slug.in_(slugs)).all()} if slugs else {}
+ return {"proposal": {"id": rev.id, "num": rev.num, "rationale": rev.rationale,
+ "changes": rev.changes or [], "content": rev.content,
+ "recipes": cards, "created_at": rev.created_at.isoformat()}}
+
+
+def _owned_food_proposal(db: Session, user: User, rid: str) -> MealRevision:
+ rev = db.get(MealRevision, rid)
+ if not rev or rev.status != "proposed" or rev.user_id != food_scope(user):
+ raise HTTPException(status_code=404, detail="proposal not found")
+ return rev
+
+
+@router.post("/proposal/{rid}/approve")
+def approve_food_proposal(rid: str, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ rev = _owned_food_proposal(db, user, rid)
+ scope = food_scope(user)
+ q = db.query(MealRevision).filter(MealRevision.status == "active")
+ q = q.filter(MealRevision.user_id == scope) if scope else q.filter(MealRevision.user_id.is_(None))
+ q.update({"status": "superseded"})
+ rev.status = "active"
+ db.commit()
+ return {"ok": True, "revision": rev.num}
+
+
+@router.post("/proposal/{rid}/reject")
+def reject_food_proposal(rid: str, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ rev = _owned_food_proposal(db, user, rid)
+ rev.status = "superseded"
+ db.commit()
+ return {"ok": True}
+
+
+class PlanSlotIn(BaseModel):
+ date: str
+ recipe: str
+ slot: str = "dinner"
+
+
+@router.patch("/week/slot")
+def plan_week_slot(body: PlanSlotIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ """Pencil a recipe into a given date's slot on the active food week. The week
+ is a weekday-keyed template, so this sets that weekday's slot (dinner by
+ default) for every week the revision covers — a direct user edit, no coach
+ proposal. Household-shared for members; the demo edits only its own scoped
+ week. Never touches the shared recipe library, so the demo may use it."""
+ if body.slot not in SLOT_ORDER:
+ raise HTTPException(status_code=400, detail=f"slot must be one of {SLOT_ORDER}")
+ r = db.query(Recipe).filter(Recipe.slug == body.recipe).first()
+ if not r:
+ raise HTTPException(status_code=404, detail="unknown recipe")
+ if body.slot == "dinner" and r.kind != "dinner":
+ raise HTTPException(status_code=400,
+ detail=f"{r.slug} is a {r.kind} recipe — a dinner needs a dinner recipe")
+ d = parse_date(body.date)
+ wd = str(d.weekday())
+ scope = food_scope(user)
+ rev = active_meal_revision(db, user)
+ if not rev: # no active week yet — start one so the swap has somewhere to land
+ base_q = db.query(MealRevision)
+ base_q = base_q.filter(MealRevision.user_id == scope) if scope \
+ else base_q.filter(MealRevision.user_id.is_(None))
+ last = base_q.order_by(MealRevision.num.desc()).first()
+ rev = MealRevision(user_id=scope, num=(last.num + 1 if last else 1),
+ status="active", content={"days": {}}, rationale="", changes=[])
+ db.add(rev)
+ content = dict(rev.content or {})
+ days = dict(content.get("days") or {})
+ day = dict(days.get(wd) or {})
+ slots = dict(day.get("slots") or {})
+ slots[body.slot] = {"recipe": r.slug, "why": (r.why or "").strip() or "Added from the recipe library"}
+ day["slots"] = slots
+ days[wd] = day
+ content["days"] = days
+ rev.content = content # reassign so SQLAlchemy tracks the JSON mutation
+ db.commit()
+ return {"ok": True, "date": str(d), "day_name": DAY_NAMES[d.weekday()],
+ "slot": body.slot, "recipe": recipe_card(r)}
+
+
+def query_recipes(db: Session, kind: str | None, term: str, include_incomplete: bool) -> list[Recipe]:
+ """One search path for the app's library screen and the MCP search tool:
+ kind filter in SQL, then a case-insensitive name/tag match in Python (the
+ library is household-sized — no index games needed)."""
+ q = db.query(Recipe)
+ if kind:
+ q = q.filter(Recipe.kind == kind)
+ if not include_incomplete:
+ q = q.filter(Recipe.complete == 1)
+ rows = q.order_by(Recipe.name).all()
+ term = (term or "").lower().strip()
+ if term:
+ rows = [r for r in rows
+ if term in r.name.lower() or any(term in t.lower() for t in (r.tags or []))]
+ return rows
+
+
+@router.get("/recipes")
+def recipe_list(q: str = "", kind: str | None = None,
+ user: User = Depends(current_user), db: Session = Depends(get_db)):
+ """The whole library, parked imports included — the UI badges incomplete
+ entries ("browsable, never proposable") instead of hiding them."""
+ rows = query_recipes(db, kind, q, include_incomplete=True)
+ return {"count": len(rows),
+ "recipes": [{**recipe_card(r), "tags": r.tags or [], "source": r.source,
+ "complete": bool(r.complete)} for r in rows]}
+
+
+@router.get("/recipes/{slug}")
+def recipe_detail(slug: str, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ r = db.query(Recipe).filter(Recipe.slug == slug).first()
+ if not r:
+ raise HTTPException(status_code=404, detail="unknown recipe")
+ names = [i.get("name") for i in (r.ingredients or [])]
+ info = {i.name: i for i in db.query(Ingredient).filter(Ingredient.name.in_(names)).all()} if names else {}
+ ingredients = []
+ for i in (r.ingredients or []):
+ meta = info.get(i.get("name"))
+ ingredients.append({**i, "aisle": meta.aisle if meta else "cupboard",
+ "pantry": bool(meta.pantry) if meta else False})
+ return {**recipe_card(r), "steps": r.steps or [], "ingredients": ingredients,
+ "tags": r.tags or [], "source": r.source, "source_url": r.source_url,
+ "images": r.images or [], "rating": r.rating or 0, "rating_count": r.rating_count or 0}
+
+
+@router.get("/media/{blob_id}")
+def food_media(blob_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ """Serve a stored food image. NULL owner = household-visible (recipe imagery,
+ shared like the recipe library itself); owned blobs (meal photos) are private."""
+ blob = db.get(MediaBlob, blob_id)
+ if not blob or (blob.user_id and blob.user_id != user.id):
+ raise HTTPException(status_code=404, detail="not found")
+ return Response(content=blob.data, media_type=blob.mime,
+ headers={"Cache-Control": "private, max-age=31536000, immutable"})
+
+
+class LogIn(BaseModel):
+ date: str | None = None
+ slot: str
+ recipe: str | None = None
+ servings: float = 1
+ # off-plan entries (chat estimates land here in Phase 8) carry their own numbers
+ label: str | None = None
+ kcal: float | None = None
+ protein_g: float | None = None
+ carbs_g: float | None = None
+ sugar_g: float | None = None
+ fiber_g: float | None = None
+ fat_g: float | None = None
+ satfat_g: float | None = None
+ sodium_mg: float | None = None
+ estimated: bool = False
+ source: str = "plan"
+ client_id: str | None = None # offline-queue idempotency
+ # eaten-out context (MCP log_food / order logs); photos are stored refs, not raw uploads
+ venue: str = ""
+ cost: float = 0
+ currency: str = ""
+ note: str = ""
+ photos: list[str] = []
+
+
+def _day_totals(db: Session, user: User, day) -> dict:
+ rows = db.query(MealLog).filter(MealLog.user_id == user.id, MealLog.day == day).all()
+ return {k: round(sum(getattr(lg, k) or 0 for lg in rows), 1) for k in MACRO_FIELDS}
+
+
+@router.post("/log")
+def log_meal(body: LogIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ if body.slot not in SLOT_ORDER:
+ raise HTTPException(status_code=400, detail=f"slot must be one of {SLOT_ORDER}")
+ day = parse_date(body.date)
+ if body.client_id:
+ existing = (db.query(MealLog)
+ .filter(MealLog.user_id == user.id, MealLog.client_id == body.client_id)
+ .first())
+ if existing: # offline retry — already written
+ return {"id": existing.id, "duplicate": True, "totals": _day_totals(db, user, day)}
+ # A slot holds one meal: logging breakfast/lunch/dinner replaces whatever was
+ # there (a planned tick or an earlier estimate) so macros reflect what was
+ # actually eaten — this is what "log it from the coach" expects. Snacks stay
+ # additive (several small things through the day).
+ if body.slot != "snack":
+ db.query(MealLog).filter(MealLog.user_id == user.id, MealLog.day == day,
+ MealLog.slot == body.slot).delete()
+ if body.recipe:
+ r = db.query(Recipe).filter(Recipe.slug == body.recipe).first()
+ if not r:
+ raise HTTPException(status_code=400, detail="unknown recipe")
+ n = body.servings
+ row = MealLog(user_id=user.id, day=day, slot=body.slot, recipe_slug=r.slug,
+ label=r.name, servings=n, source=body.source, client_id=body.client_id,
+ **{k: round((getattr(r, k) or 0) * n, 1) for k in MACRO_FIELDS})
+ else:
+ if not body.label or body.kcal is None:
+ raise HTTPException(status_code=400, detail="off-plan entries need label + kcal")
+ row = MealLog(user_id=user.id, day=day, slot=body.slot, label=body.label,
+ servings=body.servings, source=body.source or "chat",
+ estimated=1 if body.estimated else 0, client_id=body.client_id,
+ **{k: getattr(body, k) or 0 for k in MACRO_FIELDS})
+ row.venue, row.cost, row.currency = body.venue[:80], body.cost, body.currency[:8]
+ row.note, row.photos = body.note, body.photos
+ db.add(row)
+ try:
+ db.commit()
+ except IntegrityError:
+ # two retries raced past the existence check — the row is already there
+ db.rollback()
+ existing = (db.query(MealLog)
+ .filter(MealLog.user_id == user.id, MealLog.client_id == body.client_id)
+ .first())
+ if existing:
+ return {"id": existing.id, "duplicate": True, "totals": _day_totals(db, user, day)}
+ raise
+ return {"id": row.id, "duplicate": False, "totals": _day_totals(db, user, day)}
+
+
+@router.delete("/log/{log_id}")
+def unlog_meal(log_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ row = db.query(MealLog).filter(MealLog.id == log_id, MealLog.user_id == user.id).first()
+ if not row:
+ raise HTTPException(status_code=404, detail="not found")
+ day = row.day
+ db.delete(row)
+ db.commit()
+ return {"ok": True, "totals": _day_totals(db, user, day)}
diff --git a/server/app/routers/mcp_food.py b/server/app/routers/mcp_food.py
new file mode 100644
index 0000000..8e6c2a0
--- /dev/null
+++ b/server/app/routers/mcp_food.py
@@ -0,0 +1,730 @@
+"""External MCP endpoint (food surface) — lets outside automations log what was
+eaten on the spot (orders out: venue, cost, macros, photos), import recipes into
+the shared library (source URL, imagery, ratings, done-when steps), and maintain
+the shared pantry: the canonical per-100g ingredient reference that recipe
+imports draw on (bulk import, list, update, delete).
+
+Deliberately hand-rolled: a stateless Streamable-HTTP MCP server is one POST
+endpoint speaking JSON-RPC (initialize / tools list / tools call), which keeps
+the runtime image dependency-free and the whole surface testable with the
+sqlite smoke tests. No sessions, no SSE — every response is a single JSON body,
+which the spec allows and Claude clients accept.
+
+Auth mirrors /ingest: the per-user ingest token IS the identity (Settings →
+Connections). The demo seat may log its own meals but can never write the
+household-shared recipe library."""
+
+import json
+import logging
+import re
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import JSONResponse, Response
+from sqlalchemy.orm import Session
+
+from ..config import get_settings, local_today
+from ..db import get_db
+from ..media import store_images
+from ..models import MACRO_FIELDS, Ingredient, MealLog, Recipe, User
+from ..security import user_for_ingest_token
+from .mcp_oauth import user_for_mcp_token
+from .food import SLOT_ORDER, query_recipes, recipe_card, targets_for
+from .training import parse_date
+
+log = logging.getLogger("forge.mcp")
+
+router = APIRouter(tags=["mcp"])
+
+PROTOCOL_VERSIONS = {"2025-06-18", "2025-03-26", "2024-11-05"}
+SERVER_INFO = {"name": "forge-food", "version": "1.0.0"}
+INSTRUCTIONS = (
+ "Forge nutrition tools for two-seat self-hosted use. log_food records something "
+ "eaten right now (restaurant/store orders welcome: venue, cost, photos); macros are "
+ "totals for the portion eaten — estimate honestly and set estimated=true when unsure. "
+ "import_recipe adds to the shared recipe library: macros are PER SERVING and "
+ "cross-checked against the source where possible; steps must be REWRITTEN in Forge's "
+ "done-when voice (each step states the cue that tells you it's done) — never paste "
+ "source prose verbatim; always keep source_url attribution. The pantry tools "
+ "(list/bulk_import/update/delete_ingredients) maintain the shared per-100g ingredient "
+ "reference; keep it stocked so recipe imports don't park as incomplete."
+)
+
+_MACRO_PROPS = {k: {"type": "number", "description": ("kilocalories" if k == "kcal" else
+ "milligrams" if k.endswith("_mg") else "grams")}
+ for k in MACRO_FIELDS}
+
+# Ingredient-table columns mirror MACRO_FIELDS, per 100 g/ml: kcal→kcal_100,
+# protein_g→protein_100, sodium_mg→sodium_100.
+ING_MACROS = tuple(k.removesuffix("_g").removesuffix("_mg") + "_100" for k in MACRO_FIELDS)
+AISLES = ("produce", "protein", "dairy", "cupboard", "frozen")
+_ING_MACRO_PROPS = {k: {"type": "number",
+ "description": ("kcal" if k == "kcal_100" else "mg" if k == "sodium_100" else "grams")
+ + " per 100 g/ml (per item when unit is 'x')"}
+ for k in ING_MACROS}
+UNITS = ("g", "ml", "x")
+# One ingredient's reference fields — shared by import_recipe (inline creation)
+# and the dedicated pantry tools. Values are per 100 g/ml from a trusted
+# nutrition source (USDA FoodData Central / McCance & Widdowson).
+_ING_ITEM_PROPS = {
+ "name": {"type": "string", "description": "Canonical ingredient name — this is the identity (re-import updates in place)"},
+ "aisle": {"type": "string", "enum": list(AISLES)},
+ "unit": {"type": "string", "enum": list(UNITS), "description": "g | ml | per-item (x)"},
+ "pack": {"type": "string", "description": "Typical shop pack, e.g. '400 g tin'"},
+ "pantry": {"type": "boolean", "description": "Staple that never lands on a shopping list"},
+ **_ING_MACRO_PROPS,
+}
+
+
+def _ingredient_dict(i: Ingredient) -> dict:
+ return {"name": i.name, "aisle": i.aisle, "unit": i.unit, "pack": i.pack,
+ "pantry": bool(i.pantry), **{c: getattr(i, c) for c in ING_MACROS}}
+
+
+def _apply_ingredient_fields(row: Ingredient, d: dict) -> None:
+ """Set only the reference fields present in `d` — safe for partial updates
+ (an omitted field is left untouched; a new row falls to the model defaults)."""
+ if d.get("aisle") in AISLES:
+ row.aisle = d["aisle"]
+ if d.get("unit") in UNITS:
+ row.unit = d["unit"]
+ if "pack" in d:
+ row.pack = (d.get("pack") or "")[:40]
+ if "pantry" in d:
+ row.pantry = 1 if d.get("pantry") else 0
+ for c in ING_MACROS:
+ if d.get(c) is not None:
+ setattr(row, c, round(float(d[c]), 1))
+
+
+def _new_ingredient(name: str, d: dict) -> Ingredient:
+ row = Ingredient(name=name[:80])
+ _apply_ingredient_fields(row, d)
+ return row
+
+
+class ToolError(Exception):
+ pass
+
+
+# ---------------------------------------------------------------- tools
+
+TOOLS: list[dict] = [
+ {
+ "name": "log_food",
+ "description": (
+ "Log something eaten at (or near) the moment — a meal out, a store-bought "
+ "snack, an off-plan extra. Macros are totals for what was actually eaten. "
+ "If macros are estimates, say so with estimated=true (the app shows an "
+ "'estimated' tag). Photos may be https URLs or data: URIs; they are stored "
+ "on the Forge box, private to this user."),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "description": {"type": "string", "description": "What was eaten, e.g. 'Chicken burrito bowl, no rice'"},
+ "date": {"type": "string", "description": "YYYY-MM-DD; omit for today"},
+ "slot": {"type": "string", "enum": SLOT_ORDER,
+ "description": "Omit to infer from the current time of day"},
+ "venue": {"type": "string", "description": "Restaurant/store name, if bought out"},
+ "cost": {"type": "number", "description": "What it cost, in `currency`"},
+ "currency": {"type": "string", "description": "e.g. USD, GBP, EUR"},
+ "servings": {"type": "number", "default": 1},
+ **_MACRO_PROPS,
+ "estimated": {"type": "boolean", "default": True},
+ "photos": {"type": "array", "items": {"type": "string"},
+ "description": "Image URLs or data: URIs"},
+ "note": {"type": "string"},
+ "client_id": {"type": "string", "description": "Idempotency key — retries with the same id never double-log"},
+ },
+ "required": ["description", "kcal"],
+ },
+ },
+ {
+ "name": "get_food_log",
+ "description": "What's been logged (and the day's macro totals vs targets) for a date or short range.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "date": {"type": "string", "description": "YYYY-MM-DD; omit for today"},
+ "days": {"type": "integer", "minimum": 1, "maximum": 14, "default": 1,
+ "description": "How many days from `date` inclusive"},
+ },
+ },
+ },
+ {
+ "name": "delete_food_log",
+ "description": "Remove a previously logged entry by its id (from log_food or get_food_log).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {"log_id": {"type": "string"}},
+ "required": ["log_id"],
+ },
+ },
+ {
+ "name": "import_recipe",
+ "description": (
+ "Import a recipe into the shared Forge library. Macros are PER SERVING — "
+ "recompute from the ingredients and cross-check against the source's "
+ "published nutrition when available. Steps must be rewritten in Forge's "
+ "done-when voice: each step's detail states the sensory cue that tells the "
+ "cook it's done ('until the onions are translucent, ~5 min') — NEVER copy "
+ "source prose verbatim. source_url is required and kept as attribution. "
+ "Re-importing the same slug or source_url updates the existing entry. "
+ "Ingredients unknown to Forge's pantry reference park the recipe as "
+ "incomplete (never proposed by the coach, still browsable) UNLESS the "
+ "ingredient entry carries per-100g reference macros (kcal_100 at minimum "
+ "— look them up from the source or a nutrition database), which adds it "
+ "to the canonical reference. Difficulty above medium also parks."),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "slug": {"type": "string", "description": "kebab-case id; omit to derive from name"},
+ "kind": {"type": "string", "enum": ["dinner", "breakfast", "lunch", "snack"], "default": "dinner"},
+ "minutes": {"type": "integer", "description": "Active + passive time to plate"},
+ "difficulty": {"type": "string", "enum": ["easy", "medium", "hard"], "default": "easy"},
+ "serves": {"type": "integer", "default": 2},
+ "batch": {"type": "integer", "default": 0,
+ "description": "Extra servings boxed for a zero-cook night"},
+ **_MACRO_PROPS,
+ "why": {"type": "string", "description": "One-liner: why this earns a place in the week"},
+ "ingredients": {"type": "array", "items": {
+ "type": "object",
+ "properties": {"name": {"type": "string"}, "qty": {"type": "number"},
+ "unit": {"type": "string", "description": "g | ml | x"},
+ "disp": {"type": "string", "description": "Human amount, e.g. '1 tin'"},
+ "note": {"type": "string"},
+ **_ING_MACRO_PROPS,
+ "aisle": {"type": "string", "enum": list(AISLES)},
+ "pantry": {"type": "boolean",
+ "description": "Staple that never hits a shopping list"}},
+ "required": ["name"]}},
+ "steps": {"type": "array", "items": {
+ "type": "object",
+ "properties": {"title": {"type": "string"}, "detail": {"type": "string",
+ "description": "Done-when voice, your own words"},
+ "minutes": {"type": "integer"},
+ "timer": {"type": "boolean", "description": "Show a countdown for `minutes` in cook mode"},
+ "parallel": {"type": "boolean", "description": "Background step: the cook starts its "
+ "timer and moves on to later steps while it runs (e.g. a simmer or a "
+ "bake that cooks unattended). Implies timer."},
+ "image": {"type": "string", "description": "Step photo URL or data: URI"}},
+ "required": ["title", "detail"]}},
+ "tags": {"type": "array", "items": {"type": "string"}},
+ "images": {"type": "array", "items": {"type": "string"},
+ "description": "Hero/gallery image URLs or data: URIs (first = hero)"},
+ "rating": {"type": "number", "minimum": 0, "maximum": 5,
+ "description": "Source-site rating out of 5"},
+ "rating_count": {"type": "integer"},
+ "source": {"type": "string", "description": "Short origin label, e.g. 'bbc-good-food'"},
+ "source_url": {"type": "string", "description": "Original recipe URL — required attribution"},
+ },
+ "required": ["name", "source_url", "ingredients", "steps", "kcal", "protein_g"],
+ },
+ },
+ {
+ "name": "search_recipes",
+ "description": "Search the Forge recipe library by name/tag/kind. Use before importing — the recipe may already exist.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Matches name and tags, case-insensitive"},
+ "kind": {"type": "string", "enum": ["dinner", "breakfast", "lunch", "snack"]},
+ "include_incomplete": {"type": "boolean", "default": False},
+ },
+ },
+ },
+ {
+ "name": "get_recipe",
+ "description": "Full detail for one recipe: per-serving macros, ingredients, done-when steps, images, rating, source attribution.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {"slug": {"type": "string"}},
+ "required": ["slug"],
+ },
+ },
+ {
+ "name": "list_ingredients",
+ "description": (
+ "Browse the shared pantry — Forge's canonical ingredient reference (per-100g "
+ "macros, aisle, pantry-staple flag). Recipe imports draw on it; an ingredient "
+ "missing here parks a recipe as incomplete. Call before bulk_import_ingredients "
+ "to see what's already stocked."),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Case-insensitive name substring"},
+ "aisle": {"type": "string", "enum": list(AISLES)},
+ "pantry_only": {"type": "boolean", "description": "Only staples that skip the shopping list"},
+ "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 200},
+ },
+ },
+ },
+ {
+ "name": "bulk_import_ingredients",
+ "description": (
+ "Add or refresh many pantry items at once. Each item's `name` is its identity; "
+ "an existing name is updated in place (only the fields you supply change) unless "
+ "overwrite=false, which skips it. Supply per-100g macros from a trusted source "
+ "(USDA FoodData Central / McCance & Widdowson) so imports that use the ingredient "
+ "can complete. Household-shared, like the recipe library."),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "ingredients": {"type": "array", "items": {
+ "type": "object", "properties": _ING_ITEM_PROPS, "required": ["name"]}},
+ "overwrite": {"type": "boolean", "default": True,
+ "description": "Update existing names in place (false = skip them)"},
+ },
+ "required": ["ingredients"],
+ },
+ },
+ {
+ "name": "update_ingredient",
+ "description": "Patch one pantry item by name — only the fields you pass change. Errors if the name isn't stocked (use bulk_import_ingredients to add).",
+ "inputSchema": {
+ "type": "object",
+ "properties": _ING_ITEM_PROPS,
+ "required": ["name"],
+ },
+ },
+ {
+ "name": "delete_ingredient",
+ "description": "Remove a pantry item by name. Reports how many recipes still reference it (they fall back to a plain 'cupboard' aisle at read time).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {"name": {"type": "string"}},
+ "required": ["name"],
+ },
+ },
+ {
+ "name": "delete_recipe",
+ "description": (
+ "Remove recipes from the shared Forge library. Pass `slug` to delete one, "
+ "or `source` to clear every recipe from one origin at once — e.g. "
+ "source='seed' drops the curated starter set so an MCP-populated library "
+ "can stand on its own imports. Exactly one of slug/source is required. "
+ "Meal logs that referenced a deleted recipe keep their own macro snapshot, "
+ "and a planned week slot pointing at it simply reads as unplanned. "
+ "Household-shared, like import_recipe: the demo seat cannot call it."),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "slug": {"type": "string", "description": "The single recipe to delete (from search_recipes/get_recipe)"},
+ "source": {"type": "string", "description": "Delete ALL recipes carrying this origin label, e.g. 'seed' or 'bbc-good-food'"},
+ },
+ },
+ },
+]
+
+
+def _infer_slot() -> str:
+ hour = datetime.now(ZoneInfo(get_settings().coach_tz)).hour
+ if hour < 11:
+ return "breakfast"
+ if hour < 15:
+ return "lunch"
+ if hour < 21:
+ return "dinner"
+ return "snack"
+
+
+def _day_summary(db: Session, user: User, day) -> dict:
+ rows = (db.query(MealLog).filter(MealLog.user_id == user.id, MealLog.day == day)
+ .order_by(MealLog.ts).all())
+ totals = {k: round(sum(getattr(r, k) or 0 for r in rows), 1) for k in MACRO_FIELDS}
+ return {
+ "date": str(day),
+ "logged": [{"id": r.id, "slot": r.slot, "label": r.label, "servings": r.servings,
+ **{k: getattr(r, k) or 0 for k in MACRO_FIELDS},
+ "venue": r.venue or "", "cost": r.cost or 0, "currency": r.currency or "",
+ "estimated": bool(r.estimated), "recipe": r.recipe_slug or None,
+ "photos": r.photos or []} for r in rows],
+ "totals": totals,
+ }
+
+
+def _tool_log_food(db: Session, user: User, a: dict) -> dict:
+ slot = a.get("slot") or _infer_slot()
+ if slot not in SLOT_ORDER:
+ raise ToolError(f"slot must be one of {SLOT_ORDER}")
+ day = parse_date(a.get("date"))
+ if a.get("client_id"):
+ existing = (db.query(MealLog)
+ .filter(MealLog.user_id == user.id, MealLog.client_id == a["client_id"]).first())
+ if existing:
+ return {"id": existing.id, "duplicate": True, **_day_summary(db, user, day),
+ "targets": targets_for(user)}
+ photos, warnings = store_images(db, user.id, a.get("photos") or [])
+ row = MealLog(user_id=user.id, day=day, slot=slot,
+ label=(a.get("description") or "")[:120],
+ servings=a.get("servings") or 1, source="mcp",
+ estimated=1 if a.get("estimated", True) else 0,
+ client_id=a.get("client_id"),
+ venue=(a.get("venue") or "")[:80], cost=a.get("cost") or 0,
+ currency=(a.get("currency") or "")[:8], note=a.get("note") or "",
+ photos=photos,
+ **{k: a.get(k) or 0 for k in MACRO_FIELDS})
+ db.add(row)
+ db.commit()
+ out = {"id": row.id, "duplicate": False, **_day_summary(db, user, day),
+ "targets": targets_for(user)}
+ if warnings:
+ out["warnings"] = warnings
+ return out
+
+
+def _tool_get_food_log(db: Session, user: User, a: dict) -> dict:
+ from datetime import timedelta
+ start = parse_date(a.get("date"))
+ n = min(max(int(a.get("days") or 1), 1), 14)
+ return {"days": [_day_summary(db, user, start + timedelta(days=i)) for i in range(n)],
+ "targets": targets_for(user), "today": str(local_today())}
+
+
+def _tool_delete_food_log(db: Session, user: User, a: dict) -> dict:
+ row = (db.query(MealLog)
+ .filter(MealLog.id == a.get("log_id", ""), MealLog.user_id == user.id).first())
+ if not row:
+ raise ToolError("log entry not found")
+ day = row.day
+ db.delete(row)
+ db.commit()
+ return {"ok": True, **_day_summary(db, user, day)}
+
+
+def _slugify(name: str) -> str:
+ return re.sub(r"-{2,}", "-", re.sub(r"[^a-z0-9]+", "-", name.lower())).strip("-")[:60]
+
+
+def _tool_import_recipe(db: Session, user: User, a: dict) -> dict:
+ if user.role == "demo":
+ raise ToolError("the demo seat cannot write the shared recipe library")
+ name = (a.get("name") or "").strip()
+ source_url = (a.get("source_url") or "").strip()
+ if not name or not source_url:
+ raise ToolError("name and source_url are required")
+ if not source_url.startswith(("http://", "https://")):
+ raise ToolError("source_url must be an http(s) URL back to the original recipe")
+ slug = _slugify(a.get("slug") or name)
+ if not slug:
+ raise ToolError("could not derive a slug — pass one explicitly")
+ kind = a.get("kind") or "dinner"
+ if kind not in SLOT_ORDER:
+ raise ToolError(f"kind must be one of {SLOT_ORDER}")
+ difficulty = a.get("difficulty") or "easy"
+ steps_in = a.get("steps") or []
+ ingredients = a.get("ingredients") or []
+ if not steps_in or not ingredients:
+ raise ToolError("steps and ingredients must be non-empty")
+ for s in steps_in:
+ if not (s.get("title") and s.get("detail")):
+ raise ToolError("every step needs a title and a done-when detail")
+
+ warnings: list[str] = []
+ names = [i.get("name", "") for i in ingredients]
+ known = {n for (n,) in db.query(Ingredient.name).filter(Ingredient.name.in_(names)).all()}
+ # an unknown ingredient joins the canonical reference iff the import supplies
+ # per-100g macros for it (kcal_100 is the gate) — otherwise it parks the recipe
+ created: list[str] = []
+ unknown: list[str] = []
+ for i in ingredients:
+ n = i.get("name", "")
+ if not n or n in known:
+ continue
+ if i.get("kcal_100") is None:
+ unknown.append(n)
+ continue
+ db.add(_new_ingredient(n, i))
+ known.add(n)
+ created.append(n)
+ if unknown:
+ warnings.append("unknown ingredients (recipe parked as incomplete — re-import with "
+ f"per-100g macros to add them): {', '.join(unknown)}")
+ if difficulty == "hard":
+ warnings.append("difficulty 'hard' is above the library ceiling — parked as incomplete")
+ if not a.get("kcal"):
+ warnings.append("kcal is zero — parked as incomplete")
+ complete = 0 if warnings else 1
+
+ # recipe imagery is household-shared, like the library itself
+ images, img_warn = store_images(db, None, a.get("images") or [])
+ warnings += img_warn
+ steps = []
+ for s in steps_in:
+ step = {"title": s["title"], "detail": s["detail"]}
+ if s.get("minutes") is not None:
+ step["minutes"] = s["minutes"]
+ if s.get("timer") or s.get("parallel"):
+ step["timer"] = True # a background step always needs its countdown
+ if s.get("parallel"):
+ step["parallel"] = True
+ if s.get("image"):
+ ref, warn = store_images(db, None, [s["image"]])
+ if ref:
+ step["image"] = ref[0]
+ if warn:
+ warnings += warn
+ steps.append(step)
+
+ # the source page is the import's identity — same URL always updates in place,
+ # whatever slug was derived this time; only then does a slug clash matter
+ existing = db.query(Recipe).filter(Recipe.source_url == source_url).first()
+ if existing:
+ slug = existing.slug
+ else:
+ existing = db.query(Recipe).filter(Recipe.slug == slug).first()
+ if existing and existing.source == "seed":
+ raise ToolError(f"'{slug}' is a curated seed recipe — pick a different slug")
+
+ updated = existing is not None
+ r = existing or Recipe(slug=slug)
+ r.name = name[:120]
+ r.kind = kind
+ r.minutes = int(a.get("minutes") or 0)
+ r.difficulty = difficulty
+ r.serves = max(int(a.get("serves") or 2), 1)
+ r.batch = int(a.get("batch") or 0)
+ for k in MACRO_FIELDS:
+ setattr(r, k, round(float(a.get(k) or 0), 1))
+ r.why = a.get("why") or ""
+ r.steps = steps
+ r.ingredients = [{"name": i.get("name", ""), "qty": i.get("qty"), "unit": i.get("unit", ""),
+ "disp": i.get("disp", ""), "note": i.get("note", "")} for i in ingredients]
+ r.tags = a.get("tags") or []
+ r.platefig = r.platefig or "plate"
+ r.source = (a.get("source") or "import")[:24]
+ r.source_url = source_url
+ r.images = images or (r.images or [])
+ r.rating = min(max(float(a.get("rating") or 0), 0), 5)
+ r.rating_count = int(a.get("rating_count") or 0)
+ r.complete = complete
+ db.add(r)
+ db.commit()
+ out = {"slug": r.slug, "updated": updated, "complete": bool(complete),
+ "images_stored": len(images), "app_path": f"/api/food/recipes/{r.slug}"}
+ if created:
+ out["ingredients_added"] = created
+ if warnings:
+ out["warnings"] = warnings
+ return out
+
+
+def _tool_search_recipes(db: Session, user: User, a: dict) -> dict:
+ rows = query_recipes(db, a.get("kind"), a.get("query") or "",
+ bool(a.get("include_incomplete")))
+ return {"count": len(rows),
+ "recipes": [{**recipe_card(r), "tags": r.tags or [], "source": r.source,
+ "source_url": r.source_url, "complete": bool(r.complete)} for r in rows]}
+
+
+def _tool_get_recipe(db: Session, user: User, a: dict) -> dict:
+ r = db.query(Recipe).filter(Recipe.slug == a.get("slug", "")).first()
+ if not r:
+ raise ToolError("unknown recipe slug")
+ return {**recipe_card(r), "steps": r.steps or [], "ingredients": r.ingredients or [],
+ "tags": r.tags or [], "images": r.images or [], "rating": r.rating or 0,
+ "rating_count": r.rating_count or 0, "source": r.source,
+ "source_url": r.source_url, "complete": bool(r.complete)}
+
+
+def _reject_demo_pantry(user: User) -> None:
+ if user.role == "demo":
+ raise ToolError("the demo seat cannot write the shared pantry reference")
+
+
+def _tool_list_ingredients(db: Session, user: User, a: dict) -> dict:
+ q = db.query(Ingredient)
+ if a.get("aisle") in AISLES:
+ q = q.filter(Ingredient.aisle == a["aisle"])
+ if a.get("pantry_only"):
+ q = q.filter(Ingredient.pantry == 1)
+ rows = q.order_by(Ingredient.aisle, Ingredient.name).all()
+ term = (a.get("query") or "").lower().strip()
+ if term:
+ rows = [r for r in rows if term in r.name.lower()]
+ limit = min(max(int(a.get("limit") or 200), 1), 500)
+ return {"count": len(rows), "ingredients": [_ingredient_dict(r) for r in rows[:limit]]}
+
+
+def _tool_bulk_import_ingredients(db: Session, user: User, a: dict) -> dict:
+ _reject_demo_pantry(user)
+ items = a.get("ingredients") or []
+ if not items:
+ raise ToolError("ingredients must be a non-empty array")
+ overwrite = a.get("overwrite", True)
+ wanted = [(x.get("name") or "").strip()[:80] for x in items]
+ have = {i.name: i for i in
+ db.query(Ingredient).filter(Ingredient.name.in_([n for n in wanted if n])).all()}
+ created: list[str] = []
+ updated: list[str] = []
+ skipped: list[str] = []
+ for x in items:
+ n = (x.get("name") or "").strip()[:80]
+ if not n:
+ skipped.append("(unnamed)")
+ continue
+ row = have.get(n)
+ if row:
+ if not overwrite:
+ skipped.append(n)
+ continue
+ _apply_ingredient_fields(row, x)
+ updated.append(n)
+ else:
+ row = _new_ingredient(n, x)
+ db.add(row)
+ have[n] = row # dedupe repeats within one payload
+ created.append(n)
+ db.commit()
+ return {"count": len(created) + len(updated),
+ "created": created, "updated": updated, "skipped": skipped}
+
+
+def _tool_update_ingredient(db: Session, user: User, a: dict) -> dict:
+ _reject_demo_pantry(user)
+ name = (a.get("name") or "").strip()
+ row = db.query(Ingredient).filter(Ingredient.name == name).first()
+ if not row:
+ raise ToolError(f"no pantry item named '{name}' — add it with bulk_import_ingredients")
+ _apply_ingredient_fields(row, a)
+ db.commit()
+ return {"ok": True, "ingredient": _ingredient_dict(row)}
+
+
+def _tool_delete_ingredient(db: Session, user: User, a: dict) -> dict:
+ _reject_demo_pantry(user)
+ name = (a.get("name") or "").strip()
+ row = db.query(Ingredient).filter(Ingredient.name == name).first()
+ if not row:
+ raise ToolError(f"no pantry item named '{name}'")
+ refs = sum(1 for r in db.query(Recipe.ingredients).all()
+ if any((ing or {}).get("name") == name for ing in (r[0] or [])))
+ db.delete(row)
+ db.commit()
+ return {"deleted": True, "recipes_referencing": refs}
+
+
+def _tool_delete_recipe(db: Session, user: User, a: dict) -> dict:
+ if user.role == "demo":
+ raise ToolError("the demo seat cannot write the shared recipe library")
+ slug = (a.get("slug") or "").strip()
+ source = (a.get("source") or "").strip()
+ if bool(slug) == bool(source):
+ raise ToolError("pass exactly one of slug or source")
+ if slug:
+ rows = db.query(Recipe).filter(Recipe.slug == slug).all()
+ if not rows:
+ raise ToolError(f"unknown recipe slug '{slug}'")
+ else:
+ rows = db.query(Recipe).filter(Recipe.source == source).all()
+ if not rows:
+ raise ToolError(f"no recipes with source '{source}'")
+ # a deleted recipe leaves its meal-log snapshots intact (they carry their own
+ # macros) and its planned slots resolve to 'unplanned' — report the reach.
+ slugs = [r.slug for r in rows]
+ logs_referencing = (db.query(MealLog)
+ .filter(MealLog.recipe_slug.in_(slugs)).count())
+ for r in rows:
+ db.delete(r)
+ db.commit()
+ return {"deleted": slugs, "count": len(slugs), "logs_referencing": logs_referencing}
+
+
+HANDLERS = {
+ "log_food": _tool_log_food,
+ "get_food_log": _tool_get_food_log,
+ "delete_food_log": _tool_delete_food_log,
+ "import_recipe": _tool_import_recipe,
+ "search_recipes": _tool_search_recipes,
+ "get_recipe": _tool_get_recipe,
+ "list_ingredients": _tool_list_ingredients,
+ "bulk_import_ingredients": _tool_bulk_import_ingredients,
+ "update_ingredient": _tool_update_ingredient,
+ "delete_ingredient": _tool_delete_ingredient,
+ "delete_recipe": _tool_delete_recipe,
+}
+
+
+# ---------------------------------------------------------------- transport
+
+def _rpc_error(rid, code: int, message: str, status: int = 200) -> JSONResponse:
+ return JSONResponse({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}},
+ status_code=status)
+
+
+def _rpc_result(rid, result: dict) -> JSONResponse:
+ return JSONResponse({"jsonrpc": "2.0", "id": rid, "result": result})
+
+
+def _www_auth(request: Request) -> dict:
+ """401 header pointing at the resource metadata — this is what makes Claude's
+ connector UI discover the OAuth flow instead of giving up (RFC 9728 §5.1)."""
+ from ..security import public_base_url
+ base = public_base_url(request)
+ return {"WWW-Authenticate":
+ f'Bearer resource_metadata="{base}/.well-known/oauth-protected-resource/mcp"'}
+
+
+@router.post("/mcp")
+async def mcp_endpoint(request: Request, db: Session = Depends(get_db)):
+ token = request.headers.get("authorization", "").removeprefix("Bearer ").strip()
+ user = user_for_mcp_token(db, token) # OAuth access token (connector UI)
+ if not user:
+ tok = user_for_ingest_token(db, token) # ingest token (mcp-remote / config file)
+ user = db.get(User, tok.user_id) if tok else None
+ if not user:
+ return JSONResponse({"error": "missing or unknown bearer token — connect via OAuth "
+ "or use your Forge ingest token"},
+ status_code=401, headers=_www_auth(request))
+
+ try:
+ msg = json.loads(await request.body())
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ return _rpc_error(None, -32700, "parse error", status=400)
+ if isinstance(msg, list):
+ return _rpc_error(None, -32600, "batch requests are not supported", status=400)
+ method = msg.get("method", "")
+ rid = msg.get("id")
+ params = msg.get("params") or {}
+
+ if method.startswith("notifications/"):
+ return Response(status_code=202)
+ if method == "initialize":
+ asked = params.get("protocolVersion", "")
+ return _rpc_result(rid, {
+ "protocolVersion": asked if asked in PROTOCOL_VERSIONS else "2025-06-18",
+ "capabilities": {"tools": {}},
+ "serverInfo": SERVER_INFO,
+ "instructions": INSTRUCTIONS,
+ })
+ if method == "ping":
+ return _rpc_result(rid, {})
+ if method == "tools/list":
+ return _rpc_result(rid, {"tools": TOOLS})
+ if method == "tools/call":
+ name = params.get("name", "")
+ handler = HANDLERS.get(name)
+ if not handler:
+ return _rpc_error(rid, -32602, f"unknown tool: {name}")
+ try:
+ result = handler(db, user, params.get("arguments") or {})
+ except ToolError as e:
+ return _rpc_result(rid, {"content": [{"type": "text", "text": str(e)}], "isError": True})
+ except HTTPException as e: # reused route helpers raise these (e.g. bad date)
+ return _rpc_result(rid, {"content": [{"type": "text", "text": str(e.detail)}], "isError": True})
+ except Exception:
+ log.exception("mcp tool %s failed for %s", name, user.email)
+ db.rollback()
+ return _rpc_result(rid, {"content": [{"type": "text", "text": "internal error — see server logs"}],
+ "isError": True})
+ return _rpc_result(rid, {"content": [{"type": "text", "text": json.dumps(result)}],
+ "structuredContent": result, "isError": False})
+ return _rpc_error(rid, -32601, f"method not found: {method}")
+
+
+@router.get("/mcp")
+async def mcp_get():
+ # stateless server: no server-initiated SSE stream to offer
+ return Response(status_code=405, headers={"Allow": "POST"})
diff --git a/server/app/routers/mcp_oauth.py b/server/app/routers/mcp_oauth.py
new file mode 100644
index 0000000..4190eaf
--- /dev/null
+++ b/server/app/routers/mcp_oauth.py
@@ -0,0 +1,319 @@
+"""OAuth 2.1 for the external MCP endpoint — lets Claude's connector UI add
+Forge by URL (Settings → Connectors → Add custom connector), no config file.
+
+Hand-rolled like /mcp itself and for the same reasons: no SDK dependency and
+the whole flow runs in the sqlite smoke suite. The surface is the minimum the
+MCP auth spec (2025-06-18) requires a remote server to offer:
+
+ RFC 9728 /.well-known/oauth-protected-resource[/mcp] → who my auth server is
+ RFC 8414 /.well-known/oauth-authorization-server[/mcp] → my endpoints
+ RFC 7591 POST /mcp/oauth/register → dynamic client reg
+ GET/POST /mcp/oauth/authorize → consent (app session)
+ POST /mcp/oauth/token → code/refresh exchange
+
+Public clients only: registration is open (it grants nothing), every token is
+minted by a signed-in user approving the consent page, and PKCE S256 is
+mandatory — there are no client secrets anywhere. Access tokens (`fgm_`) ride
+the same Authorization: Bearer header as ingest tokens; deleting the row in
+Settings → Connections is revocation. The ingest-token path stays untouched for
+mcp-remote / Health Auto Export style callers."""
+
+import hashlib
+import html
+import secrets
+from base64 import urlsafe_b64encode
+from datetime import timedelta
+from urllib.parse import parse_qsl, urlencode, urlsplit
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+from sqlalchemy.orm import Session
+
+from ..config import get_settings
+from ..db import get_db
+from ..models import OAuthClient, OAuthCode, OAuthToken, User, utcnow
+from ..security import COOKIE, _serializer, public_base_url
+
+router = APIRouter(tags=["mcp-oauth"])
+
+CODE_TTL = timedelta(minutes=5)
+ACCESS_TTL = timedelta(days=7)
+REFRESH_TTL = timedelta(days=180)
+
+
+def _session_user(request: Request, db: Session) -> User | None:
+ """current_user without the 401 — the consent page handles signed-out itself."""
+ from itsdangerous import BadSignature
+ raw = request.cookies.get(COOKIE)
+ if not raw:
+ return None
+ try:
+ data = _serializer().loads(raw, max_age=60 * 60 * 24 * 90)
+ except BadSignature:
+ return None
+ return db.get(User, data.get("uid", ""))
+
+
+def _ok_redirect_uri(uri: str) -> bool:
+ p = urlsplit(uri)
+ if p.scheme == "https" and p.hostname:
+ return True
+ # native/dev clients: loopback only, per OAuth 2.1
+ return p.scheme == "http" and p.hostname in ("localhost", "127.0.0.1", "::1")
+
+
+def _aware(dt):
+ """sqlite round-trips DateTime(timezone=True) naive; Postgres keeps the tz."""
+ from datetime import timezone
+ return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
+
+
+async def _form(request: Request) -> dict:
+ """Hand-parse application/x-www-form-urlencoded — request.form() would pull in
+ python-multipart, and this surface stays dependency-free on purpose."""
+ return dict(parse_qsl((await request.body()).decode("utf-8", "replace")))
+
+
+def new_mcp_access_token() -> str:
+ return "fgm_" + secrets.token_urlsafe(24)
+
+
+def new_mcp_refresh_token() -> str:
+ return "fgr_" + secrets.token_urlsafe(24)
+
+
+def user_for_mcp_token(db: Session, token: str) -> User | None:
+ """Resolve an OAuth access token to its user (None if unknown/expired)."""
+ if not token.startswith("fgm_"):
+ return None
+ row = db.query(OAuthToken).filter(OAuthToken.access_token == token).first()
+ if not row or _aware(row.access_expires_at) < utcnow():
+ return None
+ row.last_used_at = utcnow()
+ db.commit()
+ return db.get(User, row.user_id)
+
+
+# ---------------------------------------------------------------- discovery
+
+def _resource_metadata(request: Request):
+ b = public_base_url(request) # host-derived so every allowed domain self-describes
+ return {"resource": b + "/mcp",
+ "authorization_servers": [b],
+ "bearer_methods_supported": ["header"]}
+
+
+def _server_metadata(request: Request):
+ b = public_base_url(request)
+ return {"issuer": b,
+ "authorization_endpoint": b + "/mcp/oauth/authorize",
+ "token_endpoint": b + "/mcp/oauth/token",
+ "registration_endpoint": b + "/mcp/oauth/register",
+ "response_types_supported": ["code"],
+ "grant_types_supported": ["authorization_code", "refresh_token"],
+ "code_challenge_methods_supported": ["S256"],
+ "token_endpoint_auth_methods_supported": ["none"]}
+
+
+# clients probe both the root form and the path-suffixed form (RFC 8414 §3)
+@router.get("/.well-known/oauth-protected-resource")
+@router.get("/.well-known/oauth-protected-resource/mcp")
+def protected_resource(request: Request):
+ return _resource_metadata(request)
+
+
+@router.get("/.well-known/oauth-authorization-server")
+@router.get("/.well-known/oauth-authorization-server/mcp")
+def authorization_server(request: Request):
+ return _server_metadata(request)
+
+
+# ---------------------------------------------------------------- registration
+
+@router.post("/mcp/oauth/register")
+async def register(request: Request, db: Session = Depends(get_db)):
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "invalid_client_metadata"}, status_code=400)
+ uris = body.get("redirect_uris") or []
+ if not isinstance(uris, list) or not uris or not all(
+ isinstance(u, str) and _ok_redirect_uri(u) for u in uris):
+ return JSONResponse({"error": "invalid_redirect_uri",
+ "error_description": "redirect_uris must be https or loopback"},
+ status_code=400)
+ client = OAuthClient(name=str(body.get("client_name", ""))[:120], redirect_uris=uris)
+ db.add(client)
+ db.commit()
+ return JSONResponse({"client_id": client.id,
+ "client_name": client.name,
+ "redirect_uris": uris,
+ "token_endpoint_auth_method": "none",
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"]}, status_code=201)
+
+
+# ---------------------------------------------------------------- authorize
+
+_HEAD = (''
+ 'Forge — connect')
+_CONSENT_CSS = """
+body{margin:0;font:15px/1.5 -apple-system,system-ui,sans-serif;background:#060708;
+ color:#e7e9ec;display:grid;place-items:center;min-height:100vh}
+.card{background:#121316;border-radius:16px;padding:28px;max-width:360px;margin:16px}
+h1{font-size:19px;margin:0 0 10px}
+p{color:#9aa0a8;margin:8px 0}
+b{color:#e7e9ec}
+.btns{display:flex;gap:10px;margin-top:20px}
+button,a.btn{flex:1;border:0;border-radius:999px;padding:12px 0;font-size:15px;font-weight:650;
+ cursor:pointer;text-align:center;text-decoration:none}
+.yes{background:#e7e9ec;color:#060708}
+.no{background:#1c1e22;color:#9aa0a8}
+"""
+
+
+def _consent_error(msg: str, status: int = 400) -> HTMLResponse:
+ return HTMLResponse(f"{_HEAD}
Can't continue
"
+ f"
{html.escape(msg)}
", status_code=status)
+
+
+def _validated_authorize_params(request: Request, db: Session):
+ """(client, params) or an error response. client_id/redirect_uri failures
+ render a page — never redirect a browser to an unverified URI."""
+ q = request.query_params
+ client = db.get(OAuthClient, q.get("client_id", ""))
+ if not client:
+ return None, _consent_error("Unknown client — re-add the connector to register it.")
+ redirect_uri = q.get("redirect_uri", "")
+ if redirect_uri not in (client.redirect_uris or []):
+ return None, _consent_error("redirect_uri does not match the registered one.")
+ err = None
+ if q.get("response_type") != "code":
+ err = "unsupported_response_type"
+ elif not q.get("code_challenge") or q.get("code_challenge_method", "S256") != "S256":
+ err = "invalid_request" # PKCE S256 is mandatory
+ if err:
+ sep = "&" if "?" in redirect_uri else "?"
+ params = {"error": err}
+ if q.get("state"):
+ params["state"] = q["state"]
+ return None, RedirectResponse(redirect_uri + sep + urlencode(params), status_code=302)
+ return client, None
+
+
+@router.get("/mcp/oauth/authorize")
+def authorize_page(request: Request, db: Session = Depends(get_db)):
+ client, error = _validated_authorize_params(request, db)
+ if error:
+ return error
+ user = _session_user(request, db)
+ if not user:
+ if get_settings().google_enabled:
+ nxt = request.url.path + "?" + str(request.url.query)
+ return RedirectResponse("/auth/login?" + urlencode({"next": nxt}), status_code=302)
+ return HTMLResponse(
+ f"{_HEAD}
Sign in first
"
+ f"
Open Forge in this browser, "
+ f"sign in, then come back and refresh this page.
", status_code=401)
+ q = request.query_params
+ hidden = "".join(
+ f''
+ for k in ("client_id", "redirect_uri", "state", "code_challenge"))
+ name = html.escape(client.name or "An MCP client")
+ return HTMLResponse(
+ f"{_HEAD}
"
+ f"
Connect {name}?
"
+ f"
{name} wants to use Forge's food tools as "
+ f"{html.escape(user.name)} — log meals, browse and import recipes.
"
+ f"
You can disconnect it any time in Settings → Connections.
"
+ f"
")
+
+
+@router.post("/mcp/oauth/authorize")
+async def authorize_submit(request: Request, db: Session = Depends(get_db)):
+ client, error = _validated_authorize_params(request, db)
+ if error:
+ return error
+ user = _session_user(request, db)
+ if not user:
+ return _consent_error("Session expired — reload the page and try again.", status=401)
+ q = request.query_params
+ form = await _form(request)
+ redirect_uri = q["redirect_uri"]
+ sep = "&" if "?" in redirect_uri else "?"
+ if form.get("decision") != "allow":
+ params = {"error": "access_denied"}
+ if q.get("state"):
+ params["state"] = q["state"]
+ return RedirectResponse(redirect_uri + sep + urlencode(params), status_code=302)
+ db.query(OAuthCode).filter(OAuthCode.expires_at < utcnow()).delete()
+ code = OAuthCode(code=secrets.token_urlsafe(32), client_id=client.id, user_id=user.id,
+ redirect_uri=redirect_uri, code_challenge=q["code_challenge"],
+ expires_at=utcnow() + CODE_TTL)
+ db.add(code)
+ db.commit()
+ params = {"code": code.code}
+ if q.get("state"):
+ params["state"] = q["state"]
+ return RedirectResponse(redirect_uri + sep + urlencode(params), status_code=302)
+
+
+# ---------------------------------------------------------------- token
+
+def _token_error(err: str, desc: str = "") -> JSONResponse:
+ body = {"error": err}
+ if desc:
+ body["error_description"] = desc
+ return JSONResponse(body, status_code=400)
+
+
+def _token_response(row: OAuthToken) -> JSONResponse:
+ return JSONResponse({"access_token": row.access_token,
+ "token_type": "Bearer",
+ "expires_in": int(ACCESS_TTL.total_seconds()),
+ "refresh_token": row.refresh_token})
+
+
+@router.post("/mcp/oauth/token")
+async def token(request: Request, db: Session = Depends(get_db)):
+ form = await _form(request)
+ grant = form.get("grant_type", "")
+
+ if grant == "authorization_code":
+ row = db.get(OAuthCode, str(form.get("code", "")))
+ if not row or _aware(row.expires_at) < utcnow():
+ return _token_error("invalid_grant", "unknown or expired code")
+ db.delete(row) # single use, spent even on failure below
+ db.commit()
+ if form.get("client_id") != row.client_id:
+ return _token_error("invalid_grant", "client mismatch")
+ if form.get("redirect_uri", row.redirect_uri) != row.redirect_uri:
+ return _token_error("invalid_grant", "redirect_uri mismatch")
+ digest = hashlib.sha256(str(form.get("code_verifier", "")).encode()).digest()
+ if urlsafe_b64encode(digest).rstrip(b"=").decode() != row.code_challenge:
+ return _token_error("invalid_grant", "PKCE verification failed")
+ tok = OAuthToken(user_id=row.user_id, client_id=row.client_id,
+ access_token=new_mcp_access_token(),
+ refresh_token=new_mcp_refresh_token(),
+ access_expires_at=utcnow() + ACCESS_TTL,
+ refresh_expires_at=utcnow() + REFRESH_TTL)
+ db.add(tok)
+ db.commit()
+ return _token_response(tok)
+
+ if grant == "refresh_token":
+ tok = (db.query(OAuthToken)
+ .filter(OAuthToken.refresh_token == str(form.get("refresh_token", ""))).first())
+ if not tok or _aware(tok.refresh_expires_at) < utcnow():
+ return _token_error("invalid_grant", "unknown or expired refresh token")
+ # rotate both tokens in place — the grant row (and Settings entry) persists
+ tok.access_token = new_mcp_access_token()
+ tok.refresh_token = new_mcp_refresh_token()
+ tok.access_expires_at = utcnow() + ACCESS_TTL
+ tok.refresh_expires_at = utcnow() + REFRESH_TTL
+ db.commit()
+ return _token_response(tok)
+
+ return _token_error("unsupported_grant_type")
diff --git a/server/app/routers/misc.py b/server/app/routers/misc.py
index 9ed5440..0c22ad2 100644
--- a/server/app/routers/misc.py
+++ b/server/app/routers/misc.py
@@ -284,9 +284,33 @@ def connections(user: User = Depends(current_user), db: Session = Depends(get_db
"coach_mcp": {"active": bool(get_settings().anthropic_api_key),
"note": ("agent live" if get_settings().anthropic_api_key
else "add an API key in Settings → Server")},
+ "mcp_clients": _mcp_clients(user, db),
}
+def _mcp_clients(user: User, db: Session) -> list[dict]:
+ from ..models import OAuthClient, OAuthToken
+ rows = (db.query(OAuthToken, OAuthClient.name)
+ .join(OAuthClient, OAuthToken.client_id == OAuthClient.id)
+ .filter(OAuthToken.user_id == user.id)
+ .order_by(OAuthToken.created_at).all())
+ return [{"id": t.id, "name": name or "MCP client",
+ "connected_at": t.created_at.isoformat(),
+ "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None}
+ for t, name in rows]
+
+
+@router.delete("/connections/mcp/{grant_id}")
+def revoke_mcp_grant(grant_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)):
+ from ..models import OAuthToken
+ tok = db.get(OAuthToken, grant_id)
+ if not tok or tok.user_id != user.id:
+ raise HTTPException(404, "grant not found")
+ db.delete(tok)
+ db.commit()
+ return {"ok": True}
+
+
@router.post("/connections/rotate-token")
def rotate_token(user: User = Depends(current_user), db: Session = Depends(get_db)):
tok = db.query(IngestToken).filter(IngestToken.user_id == user.id).first()
@@ -301,6 +325,16 @@ def rotate_token(user: User = Depends(current_user), db: Session = Depends(get_d
return {"token": tok.token}
+@router.get("/connections/token")
+def reveal_token(user: User = Depends(current_user), db: Session = Depends(get_db)):
+ """Full ingest token for the signed-in owner — the UI shows it masked and only
+ puts the real value on the clipboard (MCP client config needs it verbatim)."""
+ tok = db.query(IngestToken).filter(IngestToken.user_id == user.id).first()
+ if not tok:
+ raise HTTPException(404, "No token yet — rotate to create one")
+ return {"token": tok.token}
+
+
# Manual body measurements (height etc.) land in the same metrics stream the
# integrations write to — source 'manual', latest value wins on read.
BODY_TYPES = {"height": "cm", "weight": "kg", "body_fat_pct": "%"}
@@ -340,7 +374,7 @@ def patch_prefs(body: PrefsIn, user: User = Depends(current_user), db: Session =
@router.get("/export")
def export(user: User = Depends(current_user), db: Session = Depends(get_db)):
- from ..models import LoggedSet, Record, WorkoutSession
+ from ..models import MACRO_FIELDS, LoggedSet, MealLog, Record, WorkoutSession
def rows(model, order_col):
return db.query(model).filter(model.user_id == user.id).order_by(order_col).all()
@@ -358,4 +392,9 @@ def rows(model, order_col):
"records": [{"slug": r.exercise_slug, "kind": r.kind, "value": r.value,
"detail": r.detail, "achieved_on": str(r.achieved_on)}
for r in rows(Record, Record.achieved_on)],
+ "meals": [{"day": str(m.day), "slot": m.slot, "recipe": m.recipe_slug, "label": m.label,
+ "servings": m.servings,
+ **{k: getattr(m, k) or 0 for k in MACRO_FIELDS},
+ "source": m.source, "estimated": bool(m.estimated)}
+ for m in rows(MealLog, MealLog.ts)],
}
diff --git a/server/app/routers/training.py b/server/app/routers/training.py
index 392da8b..cb8ec79 100644
--- a/server/app/routers/training.py
+++ b/server/app/routers/training.py
@@ -7,6 +7,7 @@
from pydantic import BaseModel
from sqlalchemy.orm import Session
+from ..coach import repair_deep, repair_text
from ..db import get_db
from ..fitting import epley_e1rm, est_minutes, fit_day, plate_breakdown, warmup_ramp
from ..models import (EquipmentProfile, Exercise, LoggedSet, Metric, Niggle, Plan,
@@ -21,11 +22,24 @@
# ---------- helpers ----------
+def heal_revision(db: Session, rev: PlanRevision | None) -> PlanRevision | None:
+ """Rows written before the write-side escape repair can still carry literal
+ '\\uXXXX' sequences — fix them the first time anyone reads the revision."""
+ if not rev:
+ return rev
+ fixed_r = repair_text(rev.rationale or "")
+ fixed_c = repair_deep(rev.content or {})
+ if fixed_r != (rev.rationale or "") or fixed_c != (rev.content or {}):
+ rev.rationale, rev.content = fixed_r, fixed_c
+ db.commit()
+ return rev
+
+
def active_revision(db: Session, user_id: str) -> PlanRevision | None:
- return (db.query(PlanRevision).join(Plan)
- .filter(Plan.user_id == user_id, Plan.domain == "training",
- PlanRevision.status == "active")
- .order_by(PlanRevision.num.desc()).first())
+ return heal_revision(db, (db.query(PlanRevision).join(Plan)
+ .filter(Plan.user_id == user_id, Plan.domain == "training",
+ PlanRevision.status == "active")
+ .order_by(PlanRevision.num.desc()).first()))
def active_profile(db: Session, user: User) -> EquipmentProfile | None:
diff --git a/server/app/seed.py b/server/app/seed.py
index 9d17831..dfaf50c 100644
--- a/server/app/seed.py
+++ b/server/app/seed.py
@@ -444,3 +444,8 @@ def run_seed(db: Session) -> None:
note="Grumbles in deep lunges — avoiding that pattern",
avoid_patterns=["deep_lunge"], mobility_slug="quad-hip-flexor-stretch"))
db.commit()
+
+ # nutrition (beta track, Phase 7): ingredient table, recipe library,
+ # prefs defaults, first household food week
+ from .food_seed import run_food_seed
+ run_food_seed(db)
diff --git a/server/tests/test_link_preview.py b/server/tests/test_link_preview.py
index 8cd9a64..e9dbf21 100644
--- a/server/tests/test_link_preview.py
+++ b/server/tests/test_link_preview.py
@@ -29,7 +29,7 @@ def setup_module():
s = get_settings()
_saved.update(base_url=s.base_url, allowed_hosts=s.allowed_hosts)
s.base_url = "https://forge.example.com"
- s.allowed_hosts = "get-forged.com"
+ s.allowed_hosts = "forge.example.com"
def teardown_module():
@@ -66,11 +66,11 @@ def test_og_image_is_absolute_to_the_requesting_allowed_host(monkeypatch):
tmp = Path(tempfile.mkdtemp())
shutil.copy(_WEB / "index.html", tmp / "index.html")
monkeypatch.setattr(main, "_static", tmp)
- # A link shared from get-forged.com must preview with a get-forged.com image,
+ # A link shared from forge.example.com must preview with a forge.example.com image,
# not the canonical BASE_URL — an absolute URL to the wrong host could 404.
- html = TestClient(main.app, base_url="https://get-forged.com").get("/").text
- assert '' in html
- assert '' in html
+ html = TestClient(main.app, base_url="https://forge.example.com").get("/").text
+ assert '' in html
+ assert '' in html
def test_spoofed_host_falls_back_to_canonical_base_url(monkeypatch):
diff --git a/server/tests/test_smoke.py b/server/tests/test_smoke.py
index 6c0d404..5f9b296 100644
--- a/server/tests/test_smoke.py
+++ b/server/tests/test_smoke.py
@@ -187,6 +187,95 @@ def test_ingest_sleep_watch_not_worn_falls_back_to_in_bed():
assert abs(by_day["2026-07-23"] - 7.3) < 0.01
+def test_reveal_token_for_mcp_config():
+ login("shelby@test.dev")
+ tok = client.post("/api/connections/rotate-token").json()["token"]
+ assert client.get("/api/connections/token").json()["token"] == tok
+ login() # james sees his own token, never shelby's
+ james = client.get("/api/connections/token").json()["token"]
+ assert james != tok
+
+
+def test_mcp_oauth_flow():
+ import base64
+ import hashlib
+ from urllib.parse import parse_qs, urlsplit
+
+ # discovery: both well-known docs, and the 401 that points clients at them
+ meta = client.get("/.well-known/oauth-authorization-server").json()
+ assert meta["code_challenge_methods_supported"] == ["S256"]
+ assert meta["registration_endpoint"].endswith("/mcp/oauth/register")
+ assert client.get("/.well-known/oauth-protected-resource/mcp").json()["resource"].endswith("/mcp")
+ r = client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
+ assert r.status_code == 401 and "resource_metadata" in r.headers["www-authenticate"]
+
+ # dynamic client registration — https only
+ cb = "https://claude.ai/api/mcp/auth_callback"
+ reg = client.post("/mcp/oauth/register", json={"client_name": "Claude", "redirect_uris": [cb]})
+ assert reg.status_code == 201
+ cid = reg.json()["client_id"]
+ assert client.post("/mcp/oauth/register",
+ json={"redirect_uris": ["http://evil.example/cb"]}).status_code == 400
+
+ login()
+ verifier = "smoke-test-pkce-verifier-string"
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
+ q = {"response_type": "code", "client_id": cid, "redirect_uri": cb, "state": "xyz",
+ "code_challenge": challenge, "code_challenge_method": "S256"}
+
+ def get_code():
+ r = client.post("/mcp/oauth/authorize", params=q, data={"decision": "allow"},
+ follow_redirects=False)
+ assert r.status_code == 302 and r.headers["location"].startswith(cb + "?")
+ qs = parse_qs(urlsplit(r.headers["location"]).query)
+ assert qs["state"] == ["xyz"]
+ return qs["code"][0]
+
+ # consent page renders; deny redirects with access_denied and mints nothing
+ page = client.get("/mcp/oauth/authorize", params=q)
+ assert page.status_code == 200 and "Connect Claude?" in page.text
+ denied = client.post("/mcp/oauth/authorize", params=q, data={"decision": "deny"},
+ follow_redirects=False)
+ assert "error=access_denied" in denied.headers["location"]
+
+ # a wrong PKCE verifier is rejected and spends the code
+ code = get_code()
+ bad = client.post("/mcp/oauth/token", data={
+ "grant_type": "authorization_code", "code": code, "client_id": cid,
+ "redirect_uri": cb, "code_verifier": "wrong"})
+ assert bad.status_code == 400
+
+ # the real exchange: token works against /mcp as James, and codes are single-use
+ code = get_code()
+ form = {"grant_type": "authorization_code", "code": code, "client_id": cid,
+ "redirect_uri": cb, "code_verifier": verifier}
+ tok = client.post("/mcp/oauth/token", data=form).json()
+ assert tok["access_token"].startswith("fgm_") and tok["refresh_token"].startswith("fgr_")
+ assert client.post("/mcp/oauth/token", data=form).status_code == 400 # reuse
+ rpc = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
+ r = client.post("/mcp", headers={"Authorization": f"Bearer {tok['access_token']}"}, json=rpc)
+ assert any(t["name"] == "log_food" for t in r.json()["result"]["tools"])
+
+ # refresh rotates both tokens; the old access token dies with it
+ tok2 = client.post("/mcp/oauth/token", data={
+ "grant_type": "refresh_token", "refresh_token": tok["refresh_token"]}).json()
+ assert tok2["access_token"] != tok["access_token"]
+ assert client.post("/mcp", headers={"Authorization": f"Bearer {tok['access_token']}"},
+ json=rpc).status_code == 401
+ assert client.post("/mcp", headers={"Authorization": f"Bearer {tok2['access_token']}"},
+ json=rpc).status_code == 200
+
+ # the grant shows in Settings → Connections; only its owner can revoke it
+ grants = client.get("/api/connections").json()["mcp_clients"]
+ assert [g["name"] for g in grants] == ["Claude"]
+ login("shelby@test.dev")
+ assert client.delete(f"/api/connections/mcp/{grants[0]['id']}").status_code == 404
+ login()
+ assert client.delete(f"/api/connections/mcp/{grants[0]['id']}").json()["ok"] is True
+ assert client.post("/mcp", headers={"Authorization": f"Bearer {tok2['access_token']}"},
+ json=rpc).status_code == 401
+
+
def test_labs_and_niggles_and_export():
login()
r = client.post("/api/labs", json={"drawn_on": "2026-06-14", "results": [
@@ -228,6 +317,9 @@ def test_week_snaps_to_monday_and_pages():
# previous week pages back a full 7 days
prev = client.get("/api/week?date=2026-07-19").json()
assert prev["start"] == "2026-07-13"
+ # food week follows the same alignment
+ fw = client.get("/api/food/week?date=2026-07-23").json()
+ assert fw["start"] == MONDAY and "today" in fw
def test_planned_items_future_week():
@@ -290,9 +382,9 @@ def test_pull_forward_another_days_workout():
assert any(t["slug"] == "bench-press" for t in fitted["targets"])
detail = client.get(f"/api/sessions/{r.json()['id']}").json()
assert detail["day"] == tuesday, "session recorded on the requested date, not Friday"
- # complete it — once the real date passes 2026-07-21 an abandoned session here
- # becomes /api/week's `dangling` and shadows the one test_dangling_* creates
- client.post(f"/api/sessions/{r.json()['id']}/complete", json={})
+ # close it out — an active session left behind becomes `dangling` for later
+ # tests as soon as the London calendar moves past this test's fixed date
+ client.post(f"/api/sessions/{r.json()['id']}/complete", json={"cooldown_status": "skipped"})
# cardio days can't be pulled into a strength session
bad = client.post("/api/sessions", json={"date": "2026-07-23", "plan_day": "2"})
assert bad.status_code == 400
@@ -345,17 +437,31 @@ def test_proposal_validation_and_approve_flow():
{"slug": "back-squat", "sets": 3, "reps": 5, "priority": 1}], "cooldown": []}}}, "r")
assert "cooldown" in bad3["error"]
- good = create_proposal(db, james, {"days": {
+ good_content = {"days": {
"0": {"name": "Lower A", "kind": "strength", "focus": ["Quads"],
"why": "Main squat day — keep climbing",
"exercises": [{"slug": "back-squat", "sets": 3, "reps": 5, "weight": 62.5,
"rest": 120, "priority": 1, "min_sets": 3}],
"cooldown": [{"slug": "quad-hip-flexor-stretch", "hold": "45 s"}]},
"2": {"name": "Zone 2", "kind": "cardio", "focus": ["Base"],
+ "why": "45 easy minutes banks half the Zone-2 target early",
"cardio": {"type": "run", "minutes": 45, "hr_low": 125, "hr_high": 140}},
}, "changes": [{"sign": "+", "what": "Back Squat 60 → 62.5 kg",
- "why": "all reps clean at RPE 7"}]},
- "Squat earned +2.5; test proposal.")
+ "why": "all reps clean at RPE 7"}]}
+
+ # day-note quality gates: notes are per-session, not week copy
+ import copy
+ c = copy.deepcopy(good_content)
+ del c["days"]["2"]["why"]
+ assert "no why" in create_proposal(db, james, c, "r")["error"]
+ c = copy.deepcopy(good_content)
+ c["days"]["2"]["why"] = c["days"]["0"]["why"]
+ assert "share the same" in create_proposal(db, james, c, "r")["error"]
+ c = copy.deepcopy(good_content)
+ bad_rat = "Deload week. " + c["days"]["0"]["why"]
+ assert "restates the weekly rationale" in create_proposal(db, james, c, bad_rat)["error"]
+
+ good = create_proposal(db, james, good_content, "Squat earned +2.5; test proposal.")
assert good.get("ok"), good
assert good["status"] == "proposed"
finally:
@@ -997,6 +1103,295 @@ def test_demo_cannot_reach_member_data():
client.delete("/api/admin/demo")
+def test_food_week_recipe_and_one_tap_log():
+ """Phase 7 kitchen core (E16.2/E16.4): the seeded food week resolves recipes,
+ leftovers and order/out slots; ticking a slot snapshots macros idempotently."""
+ login()
+ me = client.get("/auth/me").json()
+ assert me["prefs"]["nutrition_targets"]["protein_g"] == 160, "seeded targets"
+
+ w = client.get(f"/api/food/week?date={MONDAY}").json()
+ assert w["has_plan"] and len(w["days"]) == 7
+ assert w["targets"]["fiber_g"] == 38
+ # extended targets always resolve, even for users whose stored prefs predate them
+ assert w["targets"]["carbs_g"] == 250 and w["targets"]["sodium_mg"] == 2300
+ mon = w["days"][0]
+ dinner = next(s for s in mon["slots"] if s["slot"] == "dinner")
+ assert dinner["recipe"]["slug"] == "harissa-chicken-traybake"
+ assert dinner["recipe"]["platefig"] == "tray-chicken"
+ assert dinner["logged"] is False
+ # Wednesday inherits Monday's dinner as leftovers; Friday is the night out;
+ # Tuesday lunch is an order-assist slot
+ wed = next(s for s in w["days"][2]["slots"] if s["slot"] == "dinner")
+ assert wed.get("leftover") and wed["recipe"]["slug"] == "harissa-chicken-traybake"
+ fri = next(s for s in w["days"][4]["slots"] if s["slot"] == "dinner")
+ assert fri.get("out") is True
+ tue_lunch = next(s for s in w["days"][1]["slots"] if s["slot"] == "lunch")
+ assert tue_lunch.get("order") is True
+
+ # recipe detail: done-when steps with a timer step, joined ingredients, the trio
+ r = client.get("/api/food/recipes/harissa-chicken-traybake").json()
+ assert len(r["steps"]) == 5 and any(s.get("timer") for s in r["steps"])
+ assert any(i["name"] == "chickpeas" and i["aisle"] == "cupboard" for i in r["ingredients"])
+ assert any(i.get("pantry") for i in r["ingredients"]), "pantry staples flagged"
+ assert r["fiber_g"] == 11 and r["satfat_g"] == 4.5 and r["difficulty"] == "easy"
+ # full label set on the card (E16 macro expansion)
+ assert r["carbs_g"] == 38 and r["fat_g"] == 16 and r["sugar_g"] == 10 and r["sodium_mg"] == 620
+ assert client.get("/api/food/recipes/nope").status_code == 404
+
+ # one-tap tick with offline-queue idempotency
+ body = {"date": MONDAY, "slot": "dinner", "recipe": "harissa-chicken-traybake",
+ "client_id": "tick-1"}
+ r1 = client.post("/api/food/log", json=body).json()
+ assert r1["duplicate"] is False and r1["totals"]["protein_g"] == 42
+ assert r1["totals"]["carbs_g"] == 38 and r1["totals"]["sodium_mg"] == 620, "full macro snapshot"
+ r2 = client.post("/api/food/log", json=body).json()
+ assert r2["duplicate"] is True and r2["id"] == r1["id"]
+ w = client.get(f"/api/food/week?date={MONDAY}").json()
+ dinner = next(s for s in w["days"][0]["slots"] if s["slot"] == "dinner")
+ assert dinner["logged"] is True and dinner["log_id"] == r1["id"]
+ assert w["days"][0]["totals"]["protein_g"] == 42
+ # off-plan entries need their own numbers
+ assert client.post("/api/food/log", json={"date": MONDAY, "slot": "snack"}).status_code == 400
+ # untick
+ assert client.delete(f"/api/food/log/{r1['id']}").json()["totals"]["kcal"] == 0
+
+
+def test_food_household_scope_and_demo_wall():
+ """E16.8: one household food week, strictly per-user logs; the demo seat
+ sees the shared recipe library but never the household's week or plates."""
+ login("shelby@test.dev")
+ w = client.get(f"/api/food/week?date={MONDAY}").json()
+ dinner = next(s for s in w["days"][0]["slots"] if s["slot"] == "dinner")
+ assert dinner["recipe"]["slug"] == "harissa-chicken-traybake", "household week is shared"
+
+ # James logs his plate; Shelby's day stays hers
+ login()
+ jid = client.post("/api/food/log", json={"date": MONDAY, "slot": "dinner",
+ "recipe": "harissa-chicken-traybake",
+ "client_id": "seg-1"}).json()["id"]
+ login("shelby@test.dev")
+ w = client.get(f"/api/food/week?date={MONDAY}").json()
+ dinner = next(s for s in w["days"][0]["slots"] if s["slot"] == "dinner")
+ assert dinner["logged"] is False and w["days"][0]["totals"]["kcal"] == 0
+ assert client.delete(f"/api/food/log/{jid}").status_code == 404, "cross-user delete"
+
+ # the demo seat: shared library yes, household week no
+ login()
+ client.post("/api/admin/demo")
+ client.post("/auth/demo")
+ wd = client.get(f"/api/food/week?date={MONDAY}").json()
+ # the demo now ships its own food week — but it must be served from the
+ # demo's OWN revision, and the household's plates must never leak into it
+ from app.db import SessionLocal
+ from app.models import MealRevision, User
+ db = SessionLocal()
+ try:
+ bruce = db.query(User).filter(User.role == "demo").first()
+ active = (db.query(MealRevision)
+ .filter(MealRevision.user_id == bruce.id, MealRevision.status == "active").first())
+ assert active is not None, "demo food week lives in the demo's own scope"
+ finally:
+ db.close()
+ assert wd["has_plan"] is True
+ # James's seg-1 dinner log (still present) must not appear anywhere in the demo's week
+ assert not any(s.get("log_id") == jid for d in wd["days"] for s in d["slots"])
+ assert not any(x["id"] == jid for d in wd["days"] for x in d["extras"])
+ assert client.get("/api/food/recipes/harissa-chicken-traybake").status_code == 200
+
+ login()
+ client.delete("/api/admin/demo")
+ assert any(m["recipe"] == "harissa-chicken-traybake"
+ for m in client.get("/api/export").json()["meals"]), "meals in export (E15.3)"
+ client.delete(f"/api/food/log/{jid}")
+
+
+def test_demo_food_rows_cleaned_up():
+ """The public demo seat can log meals; demo reset/remove must delete them —
+ on Postgres a missed table is an FK crash at delete time, on sqlite an
+ orphan row. Guards the delete_demo model list staying complete."""
+ login()
+ client.post("/api/admin/demo")
+ client.post("/auth/demo")
+ r = client.post("/api/food/log", json={"date": MONDAY, "slot": "snack",
+ "recipe": "almonds-30", "client_id": "demo-food-1"})
+ assert r.status_code == 200
+
+ login()
+ assert client.post("/api/admin/demo").status_code == 200, "reset with demo meal rows present"
+ assert client.delete("/api/admin/demo").json()["exists"] is False
+
+ from app.db import SessionLocal
+ from app.models import MealLog, User
+ db = SessionLocal()
+ try:
+ assert db.query(User).filter(User.role == "demo").count() == 0
+ assert db.query(MealLog).filter(MealLog.recipe_slug == "almonds-30").count() == 0, \
+ "demo meal rows must not survive demo removal"
+ finally:
+ db.close()
+
+
+def _food_week_content():
+ """A valid proposal: the seeded week (it satisfies every validator)."""
+ from app.food_seed import _first_week
+ return _first_week()
+
+
+FOOD_CHANGES = [{"sign": "~", "what": "Salmon moves to Thursday", "why": "test diff row"}]
+
+
+def test_food_proposal_validation_and_approve_flow():
+ """Phase 8 core (E16.3): validators catch broken weeks; a good proposal
+ round-trips proposed → approved → active, exactly one pending at a time."""
+ import copy
+
+ from app.db import SessionLocal
+ from app.food_coach import create_food_proposal
+ from app.models import MealRevision, User
+
+ login()
+ db = SessionLocal()
+ try:
+ james = db.query(User).filter(User.email == "james@test.dev").first()
+ good = _food_week_content()
+
+ # structural validators, one broken thing at a time
+ c = copy.deepcopy(good)
+ del c["days"]["6"]
+ assert "missing day" in create_food_proposal(db, james, c, FOOD_CHANGES, "r")["error"]
+
+ c = copy.deepcopy(good)
+ del c["days"]["0"]["slots"]["snack"]
+ assert "every slot" in create_food_proposal(db, james, c, FOOD_CHANGES, "r")["error"]
+
+ c = copy.deepcopy(good)
+ c["days"]["0"]["slots"]["dinner"] = {"recipe": "no-such-recipe", "why": "x"}
+ assert "unknown recipe" in create_food_proposal(db, james, c, FOOD_CHANGES, "r")["error"]
+
+ c = copy.deepcopy(good)
+ c["days"]["0"]["slots"]["dinner"] = {"recipe": "oats-no1", "why": "x"}
+ assert "kind 'dinner'" in create_food_proposal(db, james, c, FOOD_CHANGES, "r")["error"]
+
+ c = copy.deepcopy(good)
+ c["days"]["1"]["slots"]["dinner"].pop("why")
+ assert "why" in create_food_proposal(db, james, c, FOOD_CHANGES, "r")["error"]
+
+ c = copy.deepcopy(good)
+ c["days"]["2"]["slots"]["dinner"] = {"recipe": "prawn-soba-stirfry", "why": "x"}
+ c["days"]["4"]["slots"]["dinner"] = {"recipe": "salmon-puy-lentils", "why": "x"}
+ assert "zero-cook" in create_food_proposal(db, james, c, FOOD_CHANGES, "r")["error"]
+
+ assert "changes[]" in create_food_proposal(db, james, good, [], "r")["error"]
+ assert "rationale" in create_food_proposal(db, james, good, FOOD_CHANGES, " ")["error"]
+
+ # dinner-note quality gates: plate notes, not week copy
+ c = copy.deepcopy(good)
+ c["days"]["1"]["slots"]["dinner"]["why"] = c["days"]["0"]["slots"]["dinner"]["why"]
+ assert "share the same dinner why" in create_food_proposal(db, james, c, FOOD_CHANGES, "r")["error"]
+ c = copy.deepcopy(good)
+ rat = "Steady week. " + c["days"]["0"]["slots"]["dinner"]["why"]
+ assert "restates the weekly rationale" in create_food_proposal(db, james, c, FOOD_CHANGES, rat)["error"]
+
+ # the real thing
+ out = create_food_proposal(db, james, good, FOOD_CHANGES, "Test food week — same as the baseline.")
+ assert out.get("ok"), out
+ assert out["status"] == "proposed"
+
+ # a second proposal supersedes the first — never more than one pending
+ out2 = create_food_proposal(db, james, good, FOOD_CHANGES, "Re-proposal.")
+ assert out2.get("ok")
+ assert (db.query(MealRevision).filter(MealRevision.user_id.is_(None),
+ MealRevision.status == "proposed").count()) == 1
+ finally:
+ db.close()
+
+ prop = client.get("/api/food/proposal").json()["proposal"]
+ assert prop and prop["rationale"] == "Re-proposal."
+ assert prop["changes"][0]["sign"] == "~"
+ assert prop["recipes"]["harissa-chicken-traybake"]["platefig"] == "tray-chicken"
+
+ # Shelby (same household) sees and can approve the shared proposal
+ login("shelby@test.dev")
+ assert client.get("/api/food/proposal").json()["proposal"]["id"] == prop["id"]
+ assert client.post(f"/api/food/proposal/{prop['id']}/approve").status_code == 200
+ assert client.get("/api/food/proposal").json()["proposal"] is None
+ w = client.get(f"/api/food/week?date={MONDAY}").json()
+ assert w["rationale"] == "Re-proposal.", "approved food proposal is the active week"
+ login()
+
+
+def test_food_proposal_demo_wall_and_coach_tools():
+ """Phase 8: the demo seat's food proposals live in its own scope; the coach
+ tools read/write food through the same guarded paths."""
+ from app.coach import _exec_tool, _food_context
+ from app.db import SessionLocal
+ from app.food_coach import create_food_proposal
+ from app.models import MealLog, User
+
+ login()
+ client.post("/api/admin/demo")
+
+ db = SessionLocal()
+ try:
+ james = db.query(User).filter(User.email == "james@test.dev").first()
+ bruce = db.query(User).filter(User.role == "demo").first()
+
+ # a demo proposal is invisible to members, and vice versa
+ out = create_food_proposal(db, bruce, _food_week_content(), FOOD_CHANGES, "Demo week.")
+ assert out.get("ok"), out
+ member_prop = create_food_proposal(db, james, _food_week_content(), FOOD_CHANGES, "Member week.")
+ assert member_prop.get("ok")
+
+ assert client.get("/api/food/proposal").json()["proposal"]["rationale"] == "Member week."
+ client.post("/auth/demo")
+ demo_view = client.get("/api/food/proposal").json()["proposal"]
+ assert demo_view["rationale"] == "Demo week."
+ # demo cannot decide the household's proposal
+ member_id = (db.query(__import__("app.models", fromlist=["MealRevision"]).MealRevision)
+ .filter_by(rationale="Member week.").first().id)
+ assert client.post(f"/api/food/proposal/{member_id}/approve").status_code == 404
+ login()
+
+ # coach tools: read the week, the pool, the proposal; log an off-plan estimate
+ assert len(_exec_tool(db, james, "get_food_week", {})["days"]) == 7
+ pool = _exec_tool(db, james, "get_recipes", {"kind": "dinner"})
+ assert pool and all(p["kind"] == "dinner" for p in pool)
+ assert all(p["carbs_g"] > 0 and p["sodium_mg"] > 0 for p in pool), "pool carries the full label set"
+ assert _exec_tool(db, james, "get_food_proposal", {})["proposal"]["rationale"] == "Member week."
+ r = _exec_tool(db, james, "log_meal", {"slot": "lunch", "label": "burrito bowl",
+ "kcal": 700, "protein_g": 45, "carbs_g": 70,
+ "sugar_g": 6, "fiber_g": 12, "fat_g": 24,
+ "satfat_g": 8, "sodium_mg": 1400, "estimated": True})
+ assert r.get("id") and r["totals"]["sodium_mg"] == 1400 and r["totals"]["carbs_g"] == 70
+ row = db.query(MealLog).filter(MealLog.id == r["id"]).first()
+ assert row.estimated == 1 and row.source == "chat" and row.user_id == james.id
+ assert row.sugar_g == 6 and row.fat_g == 24
+ db.delete(row)
+
+ # carry-overs: add, list, keep/bin
+ assert _exec_tool(db, james, "update_carryovers",
+ {"add": [{"item": "½ jar harissa", "qty": "½ jar", "use_by": "2026-08-01"}]})["ok"]
+ rows = _exec_tool(db, james, "get_carryovers", {})
+ assert rows and rows[0]["item"] == "½ jar harissa"
+ assert _exec_tool(db, james, "update_carryovers",
+ {"updates": [{"id": rows[0]["id"], "status": "kept"}]})["ok"]
+ assert _exec_tool(db, james, "get_carryovers", {})[0]["status"] == "kept"
+ # demo can't touch the household's carry-overs
+ assert "error" in _exec_tool(db, bruce, "update_carryovers",
+ {"updates": [{"id": rows[0]["id"], "status": "binned"}]})
+
+ # dynamic prompt grounds the food half
+ ctx = _food_context(db, james)
+ assert "Nutrition targets" in ctx and "food week is awaiting" in ctx
+ finally:
+ db.close()
+
+ # tidy: reject the pending member proposal, drop the demo
+ prop_id = client.get("/api/food/proposal").json()["proposal"]["id"]
+ assert client.post(f"/api/food/proposal/{prop_id}/reject").status_code == 200
+ client.delete("/api/admin/demo")
+
def test_edit_logged_set_and_stats_recompute():
"""A mis-logged set can be corrected (active AND completed sessions);
completed-session stats are recomputed so the summary stays honest."""
@@ -1050,6 +1445,426 @@ def test_metric_history_and_vo2_aliases():
assert client.get("/api/metrics/water_pct/history").status_code == 200
+# ---------------------------------------------------------------- MCP food surface
+
+PNG_1PX = ("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
+ "AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
+
+
+def _mcp(tok, method, params=None, rid=1):
+ r = client.post("/mcp", headers={"Authorization": f"Bearer {tok}"},
+ json={"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}})
+ return r
+
+
+def _mcp_tool(tok, name, args):
+ r = _mcp(tok, "tools/call", {"name": name, "arguments": args})
+ assert r.status_code == 200, r.text
+ return r.json()["result"]
+
+
+def test_mcp_handshake_and_auth():
+ login()
+ assert client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "ping"}).status_code == 401
+ tok = client.post("/api/connections/rotate-token").json()["token"]
+
+ init = _mcp(tok, "initialize", {"protocolVersion": "2025-06-18",
+ "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}})
+ body = init.json()["result"]
+ assert body["protocolVersion"] == "2025-06-18"
+ assert body["serverInfo"]["name"] == "forge-food"
+ assert _mcp(tok, "notifications/initialized").status_code == 202
+ assert client.get("/mcp").status_code == 405
+
+ tools = {t["name"] for t in _mcp(tok, "tools/list").json()["result"]["tools"]}
+ assert tools == {"log_food", "get_food_log", "delete_food_log",
+ "import_recipe", "search_recipes", "get_recipe",
+ "list_ingredients", "bulk_import_ingredients",
+ "update_ingredient", "delete_ingredient", "delete_recipe"}
+
+
+def test_mcp_log_food_with_photo_and_venue():
+ login()
+ tok = client.post("/api/connections/rotate-token").json()["token"]
+ res = _mcp_tool(tok, "log_food", {
+ "description": "Chicken burrito bowl", "date": "2026-07-20", "slot": "lunch",
+ "venue": "Chipotle", "cost": 12.4, "currency": "USD",
+ "kcal": 640, "protein_g": 45, "fiber_g": 9, "satfat_g": 6,
+ "photos": [PNG_1PX], "client_id": "mcp-test-1"})["structuredContent"]
+ assert res["duplicate"] is False
+ entry = next(e for e in res["logged"] if e["id"] == res["id"])
+ assert entry["venue"] == "Chipotle" and entry["cost"] == 12.4
+ assert entry["photos"][0].startswith("/api/food/media/")
+
+ # the stored photo serves to the signed-in owner, and is private to them
+ assert client.get(entry["photos"][0]).headers["content-type"] == "image/png"
+ login("shelby@test.dev")
+ assert client.get(entry["photos"][0]).status_code == 404
+ login()
+
+ # idempotent retry via client_id
+ again = _mcp_tool(tok, "log_food", {"description": "Chicken burrito bowl", "kcal": 640,
+ "date": "2026-07-20", "slot": "lunch",
+ "client_id": "mcp-test-1"})["structuredContent"]
+ assert again["duplicate"] is True
+
+ # counted in the app's week view (slot-matched to the planned lunch, or an extra)
+ week = client.get("/api/food/week?date=2026-07-20").json()
+ day = next(d for d in week["days"] if d["date"] == "2026-07-20")
+ assert day["totals"]["kcal"] >= 640
+
+ # get + delete round-trip
+ got = _mcp_tool(tok, "get_food_log", {"date": "2026-07-20"})["structuredContent"]
+ assert any(e["id"] == res["id"] for e in got["days"][0]["logged"])
+ gone = _mcp_tool(tok, "delete_food_log", {"log_id": res["id"]})["structuredContent"]
+ assert gone["ok"] is True
+
+
+def test_mcp_recipe_import_and_search():
+ login()
+ tok = client.post("/api/connections/rotate-token").json()["token"]
+
+ # borrow a known ingredient name from a seed recipe so one import can be complete
+ found = _mcp_tool(tok, "search_recipes", {"kind": "dinner"})["structuredContent"]
+ assert found["count"] > 0
+ seed = next(r for r in found["recipes"] if r["source"] == "seed")
+ seed_full = _mcp_tool(tok, "get_recipe", {"slug": seed["slug"]})["structuredContent"]
+ known_ing = seed_full["ingredients"][0]["name"]
+
+ # seeds are protected from overwrite
+ clash = _mcp_tool(tok, "import_recipe", {
+ "name": seed["name"], "slug": seed["slug"], "source_url": "https://example.com/x",
+ "kcal": 500, "protein_g": 30,
+ "ingredients": [{"name": known_ing}], "steps": [{"title": "Cook", "detail": "Until done"}]})
+ assert clash["isError"] is True
+
+ res = _mcp_tool(tok, "import_recipe", {
+ "name": "Harissa Chicken Traybake", "slug": "test-import-traybake", "kind": "dinner", "minutes": 35,
+ "difficulty": "easy", "serves": 2, "kcal": 520, "protein_g": 42, "fiber_g": 8,
+ "satfat_g": 4, "why": "High protein, one tray",
+ "source": "bbc-good-food", "source_url": "https://www.bbcgoodfood.com/recipes/harissa-chicken",
+ "rating": 4.6, "rating_count": 212, "tags": ["traybake"],
+ "ingredients": [{"name": known_ing, "qty": 300, "unit": "g"}],
+ "steps": [{"title": "Roast", "detail": "Until the edges char and juices run clear, ~25 min",
+ "minutes": 25, "timer": True}]})["structuredContent"]
+ assert res["complete"] is True and res["updated"] is False
+
+ # re-import of the same source_url updates in place
+ res2 = _mcp_tool(tok, "import_recipe", {
+ "name": "Harissa Chicken Traybake", "source_url": "https://www.bbcgoodfood.com/recipes/harissa-chicken",
+ "kcal": 530, "protein_g": 43, "rating": 4.7,
+ "ingredients": [{"name": known_ing, "qty": 300, "unit": "g"}],
+ "steps": [{"title": "Roast", "detail": "Until juices run clear, ~25 min"}]})["structuredContent"]
+ assert res2["updated"] is True and res2["slug"] == res["slug"]
+
+ # unknown ingredients park the recipe as incomplete, with a warning
+ park = _mcp_tool(tok, "import_recipe", {
+ "name": "Mystery Stew", "source_url": "https://example.com/stew",
+ "kcal": 400, "protein_g": 20,
+ "ingredients": [{"name": "unicorn dust"}],
+ "steps": [{"title": "Simmer", "detail": "Until thick enough to coat a spoon"}]})["structuredContent"]
+ assert park["complete"] is False and any("unicorn dust" in w for w in park["warnings"])
+
+ # visible in the app API with attribution + rating; incomplete never proposable
+ detail = client.get(f"/api/food/recipes/{res['slug']}").json()
+ assert detail["source_url"].endswith("/harissa-chicken")
+ assert detail["rating"] == 4.7 and detail["kcal"] == 530
+ complete_only = _mcp_tool(tok, "search_recipes", {"query": "mystery"})["structuredContent"]
+ assert complete_only["count"] == 0
+ with_parked = _mcp_tool(tok, "search_recipes",
+ {"query": "mystery", "include_incomplete": True})["structuredContent"]
+ assert with_parked["count"] == 1
+
+
+def test_mcp_delete_recipe():
+ """delete_recipe removes shared-library entries by slug or by origin — the
+ path that lets an MCP-populated library shed the curated seed set. Seeds are
+ NOT protected from deletion (unlike import's overwrite guard); a deleted
+ recipe still referenced by a meal log or planned slot degrades gracefully."""
+ login()
+ tok = client.post("/api/connections/rotate-token").json()["token"]
+
+ # single delete by slug, round-tripped against the app API
+ _mcp_tool(tok, "import_recipe", {
+ "name": "Throwaway Bowl", "slug": "throwaway-bowl", "kcal": 400, "protein_g": 25,
+ "source": "test-del", "source_url": "https://example.com/throwaway",
+ "ingredients": [{"name": "spinach", "qty": 100, "unit": "g"}],
+ "steps": [{"title": "Toss", "detail": "Until combined"}]})
+ assert client.get("/api/food/recipes/throwaway-bowl").status_code == 200
+ gone = _mcp_tool(tok, "delete_recipe", {"slug": "throwaway-bowl"})["structuredContent"]
+ assert gone["deleted"] == ["throwaway-bowl"] and gone["count"] == 1
+ assert client.get("/api/food/recipes/throwaway-bowl").status_code == 404
+
+ # unknown slug and an empty/ambiguous selector are errors
+ assert _mcp_tool(tok, "delete_recipe", {"slug": "no-such"})["isError"] is True
+ assert _mcp_tool(tok, "delete_recipe", {})["isError"] is True
+ assert _mcp_tool(tok, "delete_recipe",
+ {"slug": "x", "source": "y"})["isError"] is True
+
+ # bulk delete by origin label — clears every recipe from that source at once.
+ # Uses a private label so the shared seed library other tests rely on is untouched.
+ for i in range(2):
+ _mcp_tool(tok, "import_recipe", {
+ "name": f"Batch Del {i}", "slug": f"batch-del-{i}", "kcal": 300, "protein_g": 20,
+ "source": "test-bulkdel", "source_url": f"https://example.com/bulk{i}",
+ "ingredients": [{"name": "spinach", "qty": 80, "unit": "g"}],
+ "steps": [{"title": "Mix", "detail": "Until even"}]})
+ bulk = _mcp_tool(tok, "delete_recipe", {"source": "test-bulkdel"})["structuredContent"]
+ assert set(bulk["deleted"]) == {"batch-del-0", "batch-del-1"} and bulk["count"] == 2
+ assert _mcp_tool(tok, "delete_recipe", {"source": "test-bulkdel"})["isError"] is True
+
+ # seed-sourced recipes are deletable (the whole point) — import one under the
+ # 'seed' label and confirm delete_recipe removes it rather than refusing.
+ _mcp_tool(tok, "import_recipe", {
+ "name": "Faux Seed", "slug": "faux-seed", "kcal": 350, "protein_g": 22,
+ "source": "seed", "source_url": "https://example.com/faux-seed",
+ "ingredients": [{"name": "spinach", "qty": 90, "unit": "g"}],
+ "steps": [{"title": "Wilt", "detail": "Until just collapsed"}]})
+ seed_gone = _mcp_tool(tok, "delete_recipe", {"slug": "faux-seed"})["structuredContent"]
+ assert seed_gone["count"] == 1
+
+ # the demo seat can never write the shared library
+ client.post("/api/admin/demo")
+ client.post("/auth/demo")
+ demo_tok = client.post("/api/connections/rotate-token").json()["token"]
+ denied = _mcp_tool(demo_tok, "delete_recipe", {"source": "seed"})
+ assert denied["isError"] is True and "demo" in denied["content"][0]["text"]
+ login()
+ client.delete("/api/admin/demo")
+
+
+def test_mcp_import_creates_ingredients_and_library_lists_all():
+ """An unknown ingredient carrying per-100g reference macros joins the
+ canonical table (the import completes); a macro-less unknown still parks.
+ The app's /api/food/recipes library endpoint lists both, badged by
+ `complete`, with search + kind filter."""
+ login()
+ tok = client.post("/api/connections/rotate-token").json()["token"]
+
+ res = _mcp_tool(tok, "import_recipe", {
+ "name": "Unicorn Shank Stew", "source_url": "https://example.com/unicorn-stew",
+ "kcal": 410, "protein_g": 38, "tags": ["stew"],
+ "ingredients": [{"name": "unicorn shank", "qty": 250, "unit": "g", "aisle": "protein",
+ "kcal_100": 120, "protein_100": 21.5, "fat_100": 3.2}],
+ "steps": [{"title": "Braise", "detail": "Until it shreds with a fork, ~2 h"}],
+ })["structuredContent"]
+ assert res["complete"] is True
+ assert res["ingredients_added"] == ["unicorn shank"]
+
+ # now canonical: a second recipe uses it with no macro data and still completes
+ res2 = _mcp_tool(tok, "import_recipe", {
+ "name": "Unicorn Skewers", "source_url": "https://example.com/unicorn-skewers",
+ "kcal": 300, "protein_g": 30,
+ "ingredients": [{"name": "unicorn shank", "qty": 150, "unit": "g"}],
+ "steps": [{"title": "Grill", "detail": "Until charred at the edges, ~8 min"}],
+ })["structuredContent"]
+ assert res2["complete"] is True and "ingredients_added" not in res2
+
+ # recipe detail joins the newly created reference row for its aisle
+ detail = client.get("/api/food/recipes/unicorn-shank-stew").json()
+ assert detail["ingredients"][0]["aisle"] == "protein"
+
+ # a macro-less unknown still parks, and the warning points at the fix
+ park = _mcp_tool(tok, "import_recipe", {
+ "name": "Gryphon Surprise", "source_url": "https://example.com/gryphon",
+ "kcal": 350, "protein_g": 25,
+ "ingredients": [{"name": "gryphon egg"}],
+ "steps": [{"title": "Poach", "detail": "Until the white is set through"}],
+ })["structuredContent"]
+ assert park["complete"] is False
+ assert any("per-100g" in w for w in park["warnings"])
+
+ # the library endpoint: everything, parked included, badged by `complete`
+ lib = client.get("/api/food/recipes").json()
+ by_slug = {r["slug"]: r for r in lib["recipes"]}
+ assert by_slug["unicorn-shank-stew"]["complete"] is True
+ assert by_slug["gryphon-surprise"]["complete"] is False
+ hits = client.get("/api/food/recipes?q=unicorn&kind=dinner").json()
+ assert {r["slug"] for r in hits["recipes"]} == {"unicorn-shank-stew", "unicorn-skewers"}
+ assert client.get("/api/food/recipes?q=unicorn&kind=breakfast").json()["count"] == 0
+
+
+def test_logged_meal_replaces_planned_slot():
+ """Logging a meal into a slot (the coach's log_meal path) replaces the
+ planned option shown for that slot, and the day's macros reflect what was
+ actually eaten — not the plan, and never both. Non-snack slots hold one meal
+ (re-logging replaces); snacks stay additive."""
+ login()
+ day = "2026-08-03" # a Monday untouched by other tests
+ d0 = next(d for d in client.get(f"/api/food/week?date={day}").json()["days"]
+ if d["date"] == day)
+ assert next(s for s in d0["slots"] if s["slot"] == "dinner")["logged"] is False
+
+ # coach logs an off-plan dinner
+ client.post("/api/food/log", json={
+ "date": day, "slot": "dinner", "label": "Pub burger & chips",
+ "kcal": 1100, "protein_g": 45, "carbs_g": 90, "fat_g": 60,
+ "fiber_g": 5, "satfat_g": 20, "estimated": True, "source": "chat"})
+ day0 = next(d for d in client.get(f"/api/food/week?date={day}").json()["days"]
+ if d["date"] == day)
+ din = next(s for s in day0["slots"] if s["slot"] == "dinner")
+ assert din["logged"] is True and din["off_plan"] is True
+ assert din.get("recipe") is None and din["label"] == "Pub burger & chips"
+ assert din["macros"]["kcal"] == 1100
+ assert day0["totals"]["kcal"] == 1100 # planned dinner not double-counted
+
+ # re-logging dinner replaces it (one meal per non-snack slot)
+ client.post("/api/food/log", json={
+ "date": day, "slot": "dinner", "label": "Leftover curry",
+ "kcal": 600, "protein_g": 35, "estimated": True, "source": "chat"})
+ day1 = next(d for d in client.get(f"/api/food/week?date={day}").json()["days"]
+ if d["date"] == day)
+ assert day1["totals"]["kcal"] == 600
+ assert next(s for s in day1["slots"] if s["slot"] == "dinner")["label"] == "Leftover curry"
+
+ # snacks stay additive (several small things)
+ client.post("/api/food/log", json={"date": day, "slot": "snack", "label": "Apple", "kcal": 80})
+ client.post("/api/food/log", json={"date": day, "slot": "snack", "label": "Nuts", "kcal": 180})
+ day2 = next(d for d in client.get(f"/api/food/week?date={day}").json()["days"]
+ if d["date"] == day)
+ assert day2["totals"]["kcal"] == 600 + 80 + 180
+
+
+def test_admin_wipe_recipes():
+ """DELETE /api/admin/recipes empties the library (for an MCP-populated one)
+ and retires the shared food week; admin-only. Self-restores the seed so the
+ rest of the suite sees a clean state."""
+ login("shelby@test.dev") # member, not admin
+ assert client.delete("/api/admin/recipes").status_code == 403
+
+ login() # james, admin
+ assert client.get("/api/food/recipes").json()["count"] > 0
+ res = client.delete("/api/admin/recipes").json()
+ assert res["recipes_deleted"] > 0
+ assert client.get("/api/food/recipes").json()["count"] == 0
+ # shared household week retired — no active plan for members
+ assert client.get("/api/food/week").json()["has_plan"] is False
+
+ # restore recipes + a shared active week for the rest of the suite
+ from app.db import SessionLocal
+ from app.food_seed import _first_week, run_food_seed
+ from app.models import MealRevision
+ with SessionLocal() as db:
+ run_food_seed(db) # SEED_RECIPES defaults on → re-adds the library
+ if not (db.query(MealRevision)
+ .filter(MealRevision.user_id.is_(None), MealRevision.status == "active").first()):
+ db.add(MealRevision(user_id=None, num=99, status="active",
+ content=_first_week(), rationale="restored for tests", changes=[]))
+ db.commit()
+ assert client.get("/api/food/recipes").json()["count"] > 0
+ assert client.get("/api/food/week").json()["has_plan"] is True
+
+
+def test_food_week_slot_swap_and_parallel_step():
+ """A recipe can be swapped into a given date's dinner on the active food week
+ (weekday-keyed, direct edit), and import_recipe carries a `parallel`
+ background-step flag through to the app."""
+ login()
+ tok = client.post("/api/connections/rotate-token").json()["token"]
+
+ # import a dinner with a background (parallel) step — parallel implies timer
+ imp = _mcp_tool(tok, "import_recipe", {
+ "name": "Slow Ragu", "source_url": "https://example.com/slow-ragu", "kind": "dinner",
+ "kcal": 620, "protein_g": 40, "why": "Low effort, big batch",
+ "ingredients": [{"name": "beef mince 5%", "qty": 500, "unit": "g"}],
+ "steps": [
+ {"title": "Simmer the ragu", "detail": "Until it thickens and darkens, ~90 min",
+ "minutes": 90, "parallel": True},
+ {"title": "Cook the pasta", "detail": "Until al dente, ~10 min", "minutes": 10, "timer": True},
+ ]})["structuredContent"]
+ assert imp["complete"] is True
+ got = _mcp_tool(tok, "get_recipe", {"slug": imp["slug"]})["structuredContent"]
+ assert got["steps"][0]["parallel"] is True and got["steps"][0]["timer"] is True
+
+ # swap it into a Wednesday dinner on the member household's active week
+ wed = "2026-07-29" # a Wednesday
+ res = client.patch("/api/food/week/slot", json={"date": wed, "recipe": imp["slug"]}).json()
+ assert res["ok"] is True and res["day_name"] == "Wednesday" and res["slot"] == "dinner"
+
+ # reflected in the week view for that date
+ week = client.get(f"/api/food/week?date={wed}").json()
+ day = next(d for d in week["days"] if d["date"] == wed)
+ dinner = next(s for s in day["slots"] if s["slot"] == "dinner")
+ assert dinner["recipe"]["slug"] == imp["slug"]
+
+ # a non-dinner recipe is refused for the dinner slot
+ bad = client.patch("/api/food/week/slot",
+ json={"date": wed, "recipe": "almonds-30", "slot": "dinner"})
+ assert bad.status_code == 400
+ assert client.patch("/api/food/week/slot",
+ json={"date": wed, "recipe": "nope"}).status_code == 404
+
+
+def test_mcp_pantry_tools():
+ """The shared pantry (ingredient reference) is maintainable over MCP: the
+ trusted-source defaults are queryable, bulk import upserts by name, update
+ patches one item, delete reports recipe usage — and every write is walled
+ off from the demo seat while reads stay open."""
+ login()
+ tok = client.post("/api/connections/rotate-token").json()["token"]
+
+ # trusted-source defaults are present and filterable
+ seeded = _mcp_tool(tok, "list_ingredients", {"query": "chicken breast"})["structuredContent"]
+ assert any(i["name"] == "chicken breast" for i in seeded["ingredients"])
+ proteins = _mcp_tool(tok, "list_ingredients", {"aisle": "protein"})["structuredContent"]
+ assert proteins["count"] > 0 and all(i["aisle"] == "protein" for i in proteins["ingredients"])
+
+ # bulk import: one new item, one in-place update (only the field supplied changes)
+ res = _mcp_tool(tok, "bulk_import_ingredients", {"ingredients": [
+ {"name": "seitan", "aisle": "protein", "unit": "g", "pack": "200 g", "kcal_100": 370,
+ "protein_100": 75, "carbs_100": 14, "fat_100": 1.9, "satfat_100": 0.3},
+ {"name": "chicken breast", "kcal_100": 108},
+ ]})["structuredContent"]
+ assert res["created"] == ["seitan"] and res["updated"] == ["chicken breast"]
+ row = next(i for i in _mcp_tool(tok, "list_ingredients", {"query": "seitan"})
+ ["structuredContent"]["ingredients"] if i["name"] == "seitan")
+ assert row["protein_100"] == 75 and row["pantry"] is False
+
+ # the new canonical ingredient now lets a macro-less recipe import complete
+ imp = _mcp_tool(tok, "import_recipe", {
+ "name": "Seitan Skewers", "source_url": "https://example.com/seitan-skewers",
+ "kcal": 400, "protein_g": 60,
+ "ingredients": [{"name": "seitan", "qty": 200, "unit": "g"}],
+ "steps": [{"title": "Grill", "detail": "Until charred at the edges, ~10 min"}],
+ })["structuredContent"]
+ assert imp["complete"] is True
+
+ # overwrite=false leaves an existing item untouched
+ skip = _mcp_tool(tok, "bulk_import_ingredients", {"overwrite": False, "ingredients": [
+ {"name": "seitan", "kcal_100": 999}]})["structuredContent"]
+ assert skip["skipped"] == ["seitan"] and skip["updated"] == []
+ assert next(i for i in _mcp_tool(tok, "list_ingredients", {"query": "seitan"})
+ ["structuredContent"]["ingredients"] if i["name"] == "seitan")["kcal_100"] == 370
+
+ # update patches only the passed field, leaves the rest
+ upd = _mcp_tool(tok, "update_ingredient", {"name": "seitan", "pantry": True})["structuredContent"]
+ assert upd["ingredient"]["pantry"] is True and upd["ingredient"]["protein_100"] == 75
+ assert _mcp_tool(tok, "update_ingredient", {"name": "nope"})["isError"] is True
+
+ # delete flags recipes that still reference it, then it's gone
+ gone = _mcp_tool(tok, "delete_ingredient", {"name": "seitan"})["structuredContent"]
+ assert gone["deleted"] is True and gone["recipes_referencing"] >= 1
+ assert _mcp_tool(tok, "delete_ingredient", {"name": "seitan"})["isError"] is True
+
+ # the demo seat can read the shared reference but never write it
+ client.post("/api/admin/demo")
+ from app.db import SessionLocal
+ from app.models import User as UserModel
+ from app.routers.mcp_food import HANDLERS
+ with SessionLocal() as db:
+ demo = db.query(UserModel).filter(UserModel.role == "demo").first()
+ assert demo is not None
+ assert HANDLERS["list_ingredients"](db, demo, {})["count"] > 0
+ for tool, args in (("bulk_import_ingredients", {"ingredients": [{"name": "x", "kcal_100": 1}]}),
+ ("update_ingredient", {"name": "chicken breast", "kcal_100": 1}),
+ ("delete_ingredient", {"name": "chicken breast"})):
+ try:
+ HANDLERS[tool](db, demo, args)
+ assert False, f"{tool} should reject the demo seat"
+ except Exception as e:
+ assert "demo seat cannot write" in str(e)
+
+
def test_restart_with_new_budget_refits():
"""Shortening the workout after it was already started must stick: a second
POST /api/sessions with a different budget re-fits the active session's
@@ -1086,6 +1901,135 @@ def test_restart_with_new_budget_refits():
client.post(f"/api/sessions/{full['id']}/complete", json={"cooldown_status": "skipped"})
+def test_demo_food_seed_and_enrich():
+ """The demo seat ships with a food story (E16 beta): an active week in its
+ own scope, a pending food proposal, weeks of meal logs including order-out
+ lunches with venue/cost — and /api/admin/demo/enrich tops up a demo created
+ before the food beta without touching its training history."""
+ from app.db import SessionLocal
+ from app.models import Carryover, LunchFavorite, MealLog, MealRevision, User, WorkoutSession
+
+ login()
+ client.post("/api/admin/demo")
+
+ client.post("/auth/demo")
+ assert client.get("/api/food/week").json()["has_plan"] is True
+ prop = client.get("/api/food/proposal").json()["proposal"]
+ assert prop and prop["changes"], "demo ships with a pending food proposal"
+
+ db = SessionLocal()
+ try:
+ bruce = db.query(User).filter(User.role == "demo").first()
+ orders = db.query(MealLog).filter(MealLog.user_id == bruce.id, MealLog.source == "order").all()
+ assert orders and all(o.venue and o.cost > 0 for o in orders), "order lunches carry venue+cost"
+ assert db.query(Carryover).filter_by(user_id=bruce.id).count() >= 3
+ assert db.query(LunchFavorite).filter_by(user_id=bruce.id).count() == 2
+ sessions_before = db.query(WorkoutSession).filter_by(user_id=bruce.id).count()
+ finally:
+ db.close()
+
+ # enrich is a no-op when the demo already has everything
+ login()
+ assert client.post("/api/admin/demo/enrich").json()["added"] == []
+
+ # a demo from before the food beta gets topped up in place
+ db = SessionLocal()
+ try:
+ bruce = db.query(User).filter(User.role == "demo").first()
+ db.query(MealLog).filter_by(user_id=bruce.id).delete()
+ db.query(MealRevision).filter_by(user_id=bruce.id).delete()
+ db.query(Carryover).filter_by(user_id=bruce.id).delete()
+ db.query(LunchFavorite).filter_by(user_id=bruce.id).delete()
+ db.commit()
+ finally:
+ db.close()
+ assert client.post("/api/admin/demo/enrich").json()["added"] == ["food"]
+
+ client.post("/auth/demo")
+ assert client.get("/api/food/week").json()["has_plan"] is True
+ db = SessionLocal()
+ try:
+ bruce = db.query(User).filter(User.role == "demo").first()
+ assert db.query(WorkoutSession).filter_by(user_id=bruce.id).count() == sessions_before, \
+ "enrich never touches training history"
+ finally:
+ db.close()
+ login()
+
+
+def test_security_txt():
+ """RFC 9116: served at the well-known path (and the legacy root alias),
+ contact derived from the admin seat, expiry present and in the future."""
+ r = client.get("/.well-known/security.txt")
+ assert r.status_code == 200 and r.headers["content-type"].startswith("text/plain")
+ assert "Contact: mailto:james@test.dev" in r.text
+ exp = next(line for line in r.text.splitlines() if line.startswith("Expires: "))
+ assert exp.split(" ", 1)[1] > "2026"
+ alias = client.get("/security.txt")
+ assert alias.status_code == 200 and "Contact: mailto:james@test.dev" in alias.text
+
+
+def test_html_base_url_substitution():
+ """HTML entry points must never leak the __BASE_URL__ token — the real
+ host is substituted from settings at serve time (it lives only in the
+ compose override, never in tracked files)."""
+ for path in ("/", "/welcome", "/welcome.html", "/dashboard"):
+ r = client.get(path)
+ assert r.status_code == 200, path
+ assert "__BASE_URL__" not in r.text, path
+
+
+def test_weekly_note_repair_and_voice():
+ """Two real failure modes from the live coach (Jul 2026): the model
+ double-escapes unicode in tool args (the user saw literal '\\u2014' in the
+ Plan-screen note), and at Sunday review it writes the rationale in 'next
+ week' voice — but the note is read ON the week it covers."""
+ from app.coach import create_proposal, repair_text
+ from app.db import SessionLocal
+ from app.models import Plan, PlanRevision, User
+
+ assert repair_text("light work \\u2014 not a grind") == "light work — not a grind"
+
+ login()
+ db = SessionLocal()
+ try:
+ james = db.query(User).filter(User.email == "james@test.dev").first()
+ days = {"0": {"name": "Lower A", "kind": "strength", "focus": ["Quads"],
+ "why": "squats climb \\u2014 watch bar speed",
+ "exercises": [{"slug": "back-squat", "sets": 3, "reps": 5, "weight": 65.0,
+ "rest": 120, "priority": 1, "min_sets": 3}],
+ "cooldown": [{"slug": "quad-hip-flexor-stretch", "hold": "45 s"}]}}
+
+ bad = create_proposal(db, james, {"days": days, "changes": []},
+ "Next week's squats move to 65 kg.")
+ assert "present voice" in bad["error"]
+
+ good = create_proposal(db, james, {"days": days, "changes": []},
+ "This week banks 65 kg squats \\u2014 a first at this load.")
+ assert good.get("ok"), good
+ finally:
+ db.close()
+
+ prop = client.get("/api/proposal").json()["proposal"]
+ assert "—" in prop["rationale"] and "\\u" not in prop["rationale"]
+ assert "—" in prop["content"]["days"]["0"]["why"]
+ client.post(f"/api/proposal/{prop['id']}/reject") # leave the seeded plan active
+
+ # rows written before the write-side repair heal on first read
+ db = SessionLocal()
+ try:
+ james = db.query(User).filter(User.email == "james@test.dev").first()
+ rev = (db.query(PlanRevision).join(Plan)
+ .filter(Plan.user_id == james.id, Plan.domain == "training",
+ PlanRevision.status == "active")
+ .order_by(PlanRevision.num.desc()).first())
+ rev.rationale = "Bench nudges up \\u2014 light work, not a grind."
+ db.commit()
+ finally:
+ db.close()
+ wk = client.get("/api/week").json()
+ assert "—" in wk["rationale"] and "\\u" not in wk["rationale"]
+
def test_favorite_star_and_filter():
"""Star a session, confirm it in the detail + history payloads, and that the
favorites filter narrows History to starred sessions."""
diff --git a/web/index.html b/web/index.html
index 9c701d2..4163680 100644
--- a/web/index.html
+++ b/web/index.html
@@ -10,7 +10,7 @@
-
+
-
-
-
Built for the set, not the spreadsheet
-
The details that keep you moving
-
Small things that stop a workout stalling — because logging should never interrupt training.
-
-
-
-
-
Tell it your time, get a real session
-
A 25–75 min slider re-fits live: accessories shed sets first, the main lift is never trimmed, the cool-down shortens but never drops. Short days still count.
-
-
-
-
Loses nothing offline
-
Every set writes to your phone first and syncs when you're back online. Airplane mode in a basement gym costs you zero reps.
-
-
-
-
Plate math and warm-up ramps
-
Per-side plates from your actual inventory, and a bar→85% ramp before heavy barbell work. You never do the arithmetic at the rack.
-
-
-
-
Swap in one tap
-
Bench taken? Swap lists alternatives that hit the same muscle, filtered to the equipment you have and ordered by closeness — skipping anything a niggle rules out.
-
-
-
-
Coaching that respects your body
-
Say your knee grumbled and every proposal, swap and cool-down bends around it — with targeted mobility added — until you clear it.
-
-
-
-
Rest timer that keeps pace
-
Logging a set starts a draining ring at your rest target; elapsed and remaining session time stay on screen so you keep moving.
-
-
-
-
PRs that never regress
-
e1RM, best set and rep records tracked per lift, celebrated once, and safe through any deload — a deload never erases a PB.
-
-
-
-
Labs, read in context
-
Paste a lipid panel or photograph the letter; your coach parses it, plots it against reference bands, and reads it against your training since the last draw.
+
+
Dinner is decided too BETA
+
The coach plans the week's food with the training: high protein, high fiber, a hard sat-fat cap.
+ Tick the plate you ate — done.
+
+
Protein138/160 g
+
Fiber35/38 g
+
Sat fat11/18 g
+
Calories1790/2300
-
-
-
A desktop dashboard
-
On a big screen: per-lift small multiples, weekly tonnage, VO₂max, Zone-2 minutes, lipid tracks and a consistency heatmap — from the same data the coach reads.
+
+
Cook mode
+
One step per screen, kitchen-sized type, timers built in. Every step says how you know it's done —
+ not just what to do.
+
+
+ 17:24
+
Step 3 · Simmer
+ Done when a spoon dragged through leaves a trail.
-
-
-
Chat with your coach anytime
-
It reads your live data before it answers, and every change it makes — labs, niggles, plan edits — waits behind a confirmation card you approve.
+
+
Just tell the coach
+
Move a session, log a meal out, report a niggle — it answers with your numbers and
+ fixes the plan, pending your OK.
+
+
Ate a chicken burrito bowl at Chipotle
+
Logged — est. 640 kcal · 45 g protein. Sat-fat cap still has 7 g headroom today.
+ estimated · tap to correct
+
+
+
And the parts you notice in week three
+ small things, done properly
+
+
Plate mathWarm-up ramp and per-side plates from your actual inventory.
+
Swaps that fitMachine taken? Same muscles, your equipment, one tap.
+
Niggle-awareTweaked knee? Selection avoids it; cool-downs treat it until cleared.
+
PR ledgere1RM and best-set records, next milestone always in sight.
+
Works offlineLog sets in a concrete basement; it syncs when you surface.
+
Your unitslb or kg per lift, mmol/L or mg/dL — stored canonically, shown your way.
+
Desktop dashboardEvery trend on one big screen at /dashboard.
+
Quiet notificationsExactly two kinds: plan proposed, planned day unlogged. Both optional.
+
+
+
A curated exercise library
- form photos & cues for every prescribed movement
+ );
+}
+
+function slotName(s: FoodSlot): string {
+ if (s.out) return 'Night out — enjoy it';
+ if (s.order) return 'Order out — coach-assisted';
+ if (s.recipe) return s.leftover ? `${s.recipe.name} · leftovers` : s.recipe.name;
+ if (s.label) return s.label; // logged off-plan meal that replaced the plan
+ return s.note || 'Unplanned';
+}
+
+/** Meal-row art: a fixed rounded-square tile holding the recipe/meal's own
+ * photo when there is one, else a muted tile housing the palette-native
+ * PlateFig icon. The shared tile shape keeps photo and icon rows reading as
+ * one system at a size big enough for the photo to actually read. */
+function MealMedia({ image, fig, dim, size = 64 }:
+ { image?: string | null; fig: string; dim?: boolean; size?: number }) {
+ return (
+
+ )}
+
+ );
+}
+
+/* ---------------- recipe library (browse + search) ---------------- */
+const KINDS = ['dinner', 'lunch', 'breakfast', 'snack'] as const;
+
+export function RecipeLibraryScreen() {
+ const { go, foodReplaceDate } = useApp();
+ const replace = !!foodReplaceDate;
+ const [q, setQ] = useState('');
+ const [kind, setKind] = useState(null);
+ const lib = useQuery({
+ queryKey: ['recipes'],
+ queryFn: () => api('/api/food/recipes'),
+ staleTime: 60_000,
+ });
+ if (!lib.data) return ;
+
+ const isToday = foodReplaceDate === todayISO();
+ const replDay = foodReplaceDate
+ ? (isToday ? 'tonight'
+ : new Date(foodReplaceDate + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'long' }))
+ : '';
+ // household-sized library: fetch once, filter as-you-type client-side. In
+ // "cook something else" mode we offer complete dinners to swap in for one day.
+ const term = q.trim().toLowerCase();
+ const rows = lib.data.recipes.filter((r) =>
+ (replace ? (r.kind === 'dinner' && r.complete) : (!kind || r.kind === kind)) &&
+ (!term || r.name.toLowerCase().includes(term) || r.tags.some((t) => t.toLowerCase().includes(term))));
+
+ // Replace mode is a ONE-DAY action: open the recipe scoped to that date, so
+ // cooking or logging it records it for that day only (replacing the planned
+ // dinner via the day view), never the recurring weekday template.
+ const pick = (slug: string) =>
+ replace
+ ? go('recipe', { foodSlug: slug, foodDate: foodReplaceDate, foodFrom: 'food', foodReplaceDate: null })
+ : go('recipe', { foodSlug: slug, foodFrom: 'recipes' });
+
+ return (
+
+ go('food',
+ replace ? { foodDate: foodReplaceDate, foodReplaceDate: null } : {})} />
+
+ {replace ? 'Cook something else' : 'Library'}
+
+ {replace && (
+
+ Pick a dinner to cook {isToday ? 'tonight' : `for ${replDay}`} — cooking or logging it
+ swaps it in for that day. Your weekly plan stays as it is.
+
Auto follows your device’s accessibility setting. More firms up faint labels and edges.
- {rows.map(([label, sub, onClick]) => (
-
- ))}
-
-
);
}
-/** Sex + birth year — used only to pick the right reference bands on the
- Progress metric screens. Optional; generic adult bands apply until set. */
-function AboutYouCard() {
- const { me } = useApp();
+/* ---------------- about you (sex + birth year) ----------------
+ Used only to pick the right reference bands on the Progress metric screens.
+ Optional; generic adult bands apply until set. Collected in onboarding too. */
+export function AboutYouScreen() {
+ const { me, go } = useApp();
const qc = useQueryClient();
const yearRef = useRef(null);
const save = (patch: Record) => {
@@ -102,19 +121,21 @@ function AboutYouCard() {
toast('Saved — reference ranges now use your age', true);
};
return (
-
-
About you · tunes healthy ranges
- save({ sex: v })} />
-
-
+
+ go('settings')} />
+ About you
+
+
Sex
+ save({ sex: v })} />
+
{ if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
+
Only used to pick age- and sex-appropriate
+ reference bands on the Progress charts — never shared, never sent anywhere.
-
Only used to pick age- and sex-appropriate reference bands on the
- Progress charts — never shared, never sent anywhere.
-
+
);
}
@@ -131,6 +152,91 @@ function UnitSeg({ value, options, onPick }: {
);
}
+/* ---------------- nutrition (beta track, Phase 7 — E16.1) ---------------- */
+export function NutritionScreen() {
+ const { me, go, openTab } = useApp();
+ const qc = useQueryClient();
+ const [prefs, setPrefs] = useState>(me.prefs || {});
+ const groceryRef = useRef(null);
+ const lunchRef = useRef(null);
+ // merge under stored prefs so users whose targets predate the full macro set still see the new ones
+ const t = { kcal: 2300, protein_g: 160, carbs_g: 250, sugar_g: 65, fiber_g: 38,
+ fat_g: 80, satfat_g: 18, sodium_mg: 2300, ...(prefs.nutrition_targets || {}) };
+
+ const savePrefs = (patch: Record) => {
+ const next = { ...prefs, ...patch };
+ setPrefs(next);
+ qc.setQueryData(['me'], (old) => old && { ...old, prefs: next });
+ api('/api/prefs', { method: 'PATCH', body: { prefs: patch } }).catch(() => toast('Offline — not saved'));
+ };
+ const saveBudgets = () => {
+ const g = parseFloat(groceryRef.current?.value || '');
+ const l = parseFloat(lunchRef.current?.value || '');
+ const patch: Record = {};
+ if (g > 0) patch.budget_grocery = g;
+ if (l > 0) patch.budget_lunch = l;
+ if (!Object.keys(patch).length) { toast('Enter a number first'); return; }
+ savePrefs(patch);
+ toast('Budgets saved', true);
+ };
+
+ return (
+
+ go('settings')} />
+
Nutrition
+
+
+
Daily targets · set by your coach
+
+ {t.kcal} kcal · P {t.protein_g} · C {t.carbs_g} · F {t.fat_g} · fiber {t.fiber_g}
+
+
+ caps · sat fat ≤{t.satfat_g} g · sugar ≤{t.sugar_g} g · sodium ≤{t.sodium_mg} mg
+
+
Proposed from your goals, training load and labs. Change them in chat —
+ the coach explains the trade-offs first.
+
+
+
+
+
Cook nights per week
+ savePrefs({ cook_nights: +v })} />
+
The weekly proposal plans this many dinners; the rest are leftovers or out.
+ Batch nights count once.
+
+
+
+
Budgets
+
+
+
+
+
+
+
+
+
+
+
Dinners feed the household
+ savePrefs({ household_dinners: v === 'on' })} />
+
Portions ×2 when you're both in — each plate logs to its own day,
+ each person's targets stay their own.
+
+
+ No new notification kinds — your food week rides the existing Sunday proposal push.
+ Coach-proposed food weeks and the shopping list land in Phase 8.
+
+ );
+}
+
export function UnitsScreen() {
const { me, go } = useApp();
const qc = useQueryClient();
@@ -224,6 +330,18 @@ export function UnitsScreen() {
}
/* ---------------- connections ---------------- */
+
+/** claude_desktop_config.json snippet for the /mcp food endpoint. Claude's
+ * connector UI can't send a bearer header, so mcp-remote bridges stdio → HTTP. */
+const mcpConfig = (token: string) => JSON.stringify({
+ mcpServers: {
+ 'forge-food': {
+ command: 'npx',
+ args: ['mcp-remote', `${location.origin}/mcp`, '--header', `Authorization: Bearer ${token}`],
+ },
+ },
+}, null, 2);
+
export function ConnectionsScreen() {
const { go } = useApp();
const qc = useQueryClient();
@@ -274,9 +392,9 @@ export function ConnectionsScreen() {
+ Empties the whole recipe library and retires the shared food week — for running an
+ MCP-populated library. The pantry (ingredients) and your meal logs are kept. Set
+ SEED_RECIPES=false in the compose override too, or a boot re-seeds the defaults.
+