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
+ +
+ Plan + Food + History + Progress + Coach +
+
+
+ + + diff --git a/docs/mockups/39-food-week.html b/docs/mockups/39-food-week.html new file mode 100644 index 0000000..1b18196 --- /dev/null +++ b/docs/mockups/39-food-week.html @@ -0,0 +1,161 @@ + + + + + +Food week + Plan integration + + + + +
+
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.
+ +
+ Plan + Food + History + Progress + Coach +
+
+
+ + + diff --git a/docs/mockups/40-recipe-and-lunch.html b/docs/mockups/40-recipe-and-lunch.html new file mode 100644 index 0000000..9482e8e --- /dev/null +++ b/docs/mockups/40-recipe-and-lunch.html @@ -0,0 +1,170 @@ + + + + + +Recipe detail + lunch assist + + + + +
+
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.
+
+
‹ Coach
+
Tuesday 12:38 · lunch window
+ +
Ordering lunch — today's MealPal 📷 (menu screenshot)
+ +
+ Ranked for you — protein's at 34 g, fiber needs help, $15 cap: +
+
1Naya — chicken shawarma bowl
extra pickles, skip the white sauce
P 52 · sat 5 · credit
+
2Dig — charred chicken harvestP 41 · fiber 12
+
3sweetgreen — miso salmonP 33 · $14.20
+
+
Skip: Pret carbonara — 21 g sat fat, that's your whole day.
+
+ +
1 it is
+ +
Logged: shawarma bowl · 640 kcal · P 52 · fiber 8 · sat 5.
That's 3 weeks running — it's in your favorites. Dinner tonight closes fiber.
+ +
+ ★ shawarma bowl★ harvest bowlrank a menulog off-plan +
+ +
Message your coach…Send
+
+
+ + + diff --git a/docs/mockups/41-food-proposal.html b/docs/mockups/41-food-proposal.html new file mode 100644 index 0000000..147024c --- /dev/null +++ b/docs/mockups/41-food-proposal.html @@ -0,0 +1,142 @@ + + + + + +Food week proposal + carry-over check + + + + +
+
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
+
+
+ + + diff --git a/docs/mockups/42-shopping-and-settings.html b/docs/mockups/42-shopping-and-settings.html new file mode 100644 index 0000000..223231c --- /dev/null +++ b/docs/mockups/42-shopping-and-settings.html @@ -0,0 +1,153 @@ + + + + + +Shopping list + nutrition settings + + + + +
+
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

+ +
+ + + + 0 + + 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.
+
+
+ + + diff --git a/docs/mockups/43-cook-mode.html b/docs/mockups/43-cook-mode.html new file mode 100644 index 0000000..208ddba --- /dev/null +++ b/docs/mockups/43-cook-mode.html @@ -0,0 +1,157 @@ + + + + + +Cook mode — guided steps + finish + + + + +
+
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.

+ +
+ + + + 14:32 + of 22:00 + +
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
+ +
Done
+
Tell the coach how it went
+
+
+ + + diff --git a/docs/mockups/44-recipe-import.html b/docs/mockups/44-recipe-import.html new file mode 100644 index 0000000..a10f90c --- /dev/null +++ b/docs/mockups/44-recipe-import.html @@ -0,0 +1,121 @@ + + + + + +Recipe import — link paste + card rescue + + + + +
+
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
+
+
‹ Coach
+
Recipe import
+ +
bbcgoodfood.com/recipes/one-pan-chicken-puttanesca
+ +
+ 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

+ + + + + + + + +
TokenRoleOn raised
ink #f3f4f6titles, values17.3:1
mut #9ea4absubs, labels7.4:1
dim #6d737achevrons, décor — never copy3.9:1
mut @ 45% opacityrest-day rows today2.4:1 ✕
hair on a cardtoday's separators1.07:1 ✕
hair-in #2b2f37 newseparators inside raised1.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 + lrow mealrow ingrow setline + 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.

+ +
+ +
+
+
This week · Jul 20
+
Plan
+ +
+
Mo
+
Tu
+
We
+
Th
+
Fr
+
Sa
+
Su
+
+
+ + Monday 20Lower A · dinner: harissa chicken traybake + missed +
+
+ · + Tuesday 21Rest · dinner: charred salmon + +
+
+ + Wednesday 22Zone 2 run · dinner: leftovers + missed +
+
+
● Thursday 23 · Today
+
Rest day
+
◉ Tonight · Turkey & black-bean chili35 min ›
+
+
+ + Friday 24Upper A · night out + ~52 min › +
+
+

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.

+
+ + +
+
+
This week · Jul 20
+
Plan
+ +
+
Mo
+
Tu
+
We
+
Th
+
Fr
+
Sa
+
Su
+
+
+ + Monday 20Lower A · Harissa chicken traybake + Missed +
+
+ + Tuesday 21Rest · Charred salmon + +
+
+ + Wednesday 22Zone 2 run · Leftovers + Missed +
+
+
Thursday 23 · Today
+
Rest day
+
+ 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.

+
+ + +
+
+
This week · Jul 20
+
Plan
+ +
+
Mo
+
Tu
+
We
+
Th
+
Fr
+
Sa
+
Su
+
+
+
+ + Monday 20Lower A · Harissa chicken traybake + Missed +
+
+ + Tuesday 21Rest · Charred salmon + +
+
+ + Wednesday 22Zone 2 run · Leftovers + Missed +
+
+ + 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.

+
+ + +
+
+
All sessions
+
History
+
All★ Starred
+
+ + Upper A Fri 17 Jul + 5.83 t12 sets +
+
+ + Zone 2 runWed 15 Jul · avg 128 bpm + 8.0 km46 min +
+
+ + Lower AMon 13 Jul + 4.07 t12 sets +
+
+ + Upper AFri 10 Jul + 5.78 t12 sets +
+
+ + Zone 2 runWed 8 Jul · avg 129 bpm + 8.2 km46 min +
+
+ + Lower AMon 6 Jul + 4.76 t12 sets +
+
+

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.

+ +
+ +
+
+
Tuesday · 2026-07-21 · Today
+
Food
+
+
Protein0 / 160 g
+
+
Fiber0 / 38 g
+
+
Sat fat · cap0 / ≤18 g
+
+
On plan — dinner closes protein and fiber.
+
+
+ + Oats №1 — overnight oats, berries & flaxBreakfast · 420 kcal · P 34 · fib 11 · sat 2 +
+
+ + Order out — coach-assistedLunch · Order out — target P 40+, sat ≤ 6 g, inside the lunch cap. Favorites + menu ranking land in Phase 9. +
+
+ + Charred salmon, puy lentils & broccoliDinner · 540 kcal · P 38 · fib 9 · sat 3.5 + Cook › +
+
Ate something else? Tell the coach — it logs it
+
+

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.

+
+ + +
+
+
Tuesday · 2026-07-21 · Today
+
Food
+
+
+
Protein
0/160
+
Fiber
0/38
+
Sat fat
0/≤18
+
Kcal
0/2300
+
+
On plan — dinner closes protein and fiber.
+
+
+ + Oats №1 — overnight oatsBreakfast · 420 kcal · P 34 · fib 11 + +
+
+ + Order out — coach-assistedLunch · target P 40+ · sat ≤ 6 g + +
+
+ + Charred salmon & puy lentilsDinner · 540 kcal · P 38 · fib 9 + Cook › +
+
+ + 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.

+
+ + +
+
+
Tuesday · 2026-07-21 · Today
+
Food
+
+
+
Protein
0/160
+
Fiber
0/38
+
Sat fat
0/≤18
+
Kcal
0/2300
+
+
On plan — dinner closes protein and fiber.
+
+
Meals
+
+
+ + Oats №1 — overnight oatsBreakfast · 420 kcal · P 34 · fib 11 + +
+
+ + Order out — coach-assistedLunch · target P 40+ · sat ≤ 6 g + +
+
+ + Charred salmon & puy lentilsDinner · 540 kcal · P 38 · fib 9 + Cook › +
+
+ + 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.

+ +
+
+
+
This week · Jul 20
+
Plan
+ +
+
Mo
+
Tu
+
We
+
Th
+
Fr
+
Sa
+
Su
+
+
+ + Monday 20Lower A · Harissa chicken traybake + Missed +
+
+ + Tuesday 21Rest · Charred salmon + +
+
+
Thursday 23 · Today
+
Rest day
+
+ 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.

+
    +
  1. Delete the .tap .card transparent override — tappable items render + as cards again, 8px gap, pressed state stays scale(.985).
  2. +
  3. Merge the five row primitives into one .item with the anatomy + above; value columns get nowrap + tabular numerals, titles flex.
  4. +
  5. Add --hair-in (#2b2f37 dark / + #d9dcd1 light) for separators inside raised surfaces; + --hair returns to shell chrome only.
  6. +
  7. Delete .dimrow — dim glyphs, never text; rest days keep mut copy.
  8. +
  9. History rows lead with the name, friendly date in the sub, one stacked value + pair on the right.
  10. +
  11. Status becomes a tag, not a paint job — "Missed" is a small warm chip; warm + text stops doubling as row color.
  12. +
  13. Flatten the today card — the gradient becomes a flat accent wash + (color-mix(in srgb, var(--volt) 12%, var(--raised))), both themes.
  14. +
  15. Bold the week calendar — 700 mut labels, solid volt fill when done, warn wash + when missed, a 2px volt ring on today.
  16. +
  17. Deepen the light theme a step — bg #eef0ea, mut + #4d545d, dim #7f8790, accent tints 12→18%, soft card + shadows. Dark tokens don't move.
  18. +
+

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"
{hidden}" + f"" + 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
+ 65 movements · photos, cues, muscle maps & animated form guides
Back squat, start position
Back squat, bottom position
diff --git a/web/src/App.tsx b/web/src/App.tsx index e7ac109..c7b4d74 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,12 +8,14 @@ import { flushQueue } from './queue'; import { AuthScreen, DeniedScreen } from './screens/auth'; import { CoachScreen } from './screens/coach'; import { DetailScreen, HistoryScreen, MetricScreen, ProgressScreen, RecordsScreen } from './screens/data'; +import { CookScreen, FoodDayScreen, FoodWeekScreen, RecipeLibraryScreen, RecipeScreen, flushFoodQueue } from './screens/food'; import { LearnScreen } from './screens/learn'; import { OnboardingFlow } from './screens/onboarding'; import { CooldownScreen, LogScreen, SummaryScreen, SwapScreen } from './screens/session'; import { - CoachSettingsScreen, CoachUsageScreen, ConnectionsScreen, EquipmentScreen, LabsScreen, - LibraryScreen, NigglesScreen, NotifScreen, ServerScreen, SettingsScreen, UnitsScreen, + AboutYouScreen, AppearanceScreen, CoachSettingsScreen, CoachUsageScreen, ConnectionsScreen, + EquipmentScreen, LabsScreen, LibraryScreen, NigglesScreen, NotifScreen, NutritionScreen, + ServerScreen, SettingsScreen, UnitsScreen, } from './screens/settings'; import { DayScreen, PlanScreen } from './screens/today'; import { @@ -110,6 +112,10 @@ function AppInner({ me }: { me: Me }) { const [detailId, setDetailId] = useState(''); const [lift, setLift] = useState(''); const [dayDate, setDayDate] = useState(null); + const [foodSlug, setFoodSlug] = useState(''); + const [foodDate, setFoodDate] = useState(null); + const [foodFrom, setFoodFrom] = useState('food'); + const [foodReplaceDate, setFoodReplaceDate] = useState(null); const [budget, setBudget] = useState(null); const [summary, setSummary] = useState(null); const [chatContext, setChatContext] = useState(null); @@ -121,9 +127,14 @@ function AppInner({ me }: { me: Me }) { if (extra?.detailId !== undefined) setDetailId(extra.detailId); if (extra?.lift !== undefined) setLift(extra.lift); if (extra && 'dayDate' in extra) setDayDate(extra.dayDate ?? null); + if (extra?.foodSlug !== undefined) setFoodSlug(extra.foodSlug); + if (extra && 'foodDate' in extra) setFoodDate(extra.foodDate ?? null); + if (extra?.foodFrom !== undefined) setFoodFrom(extra.foodFrom); + if (extra && 'foodReplaceDate' in extra) setFoodReplaceDate(extra.foodReplaceDate ?? null); if (extra && 'chatContext' in extra) setChatContext(extra.chatContext ?? null); setScreen(s); if (s === 'coach') setTab('coach'); + if (s === 'food' || s === 'food-week' || s === 'recipes' || s === 'recipe' || s === 'cook') setTab('food'); }, []); const openTab = useCallback((t: Tab) => { setTab(t); setScreen(t); }, []); @@ -238,23 +249,30 @@ function AppInner({ me }: { me: Me }) { }, []); const ctx = useMemo(() => ({ - me, screen, tab, learnSlug, learnFrom, detailId, lift, dayDate, chatContext, setChatContext, + me, screen, tab, learnSlug, learnFrom, detailId, lift, dayDate, foodSlug, foodDate, foodFrom, foodReplaceDate, + chatContext, setChatContext, go, openTab, budget, setBudget, log, logDispatch, startSession, resumeSession, finishSession, summary, signOut, - }), [me, screen, tab, learnSlug, learnFrom, detailId, lift, dayDate, chatContext, go, openTab, - budget, log, startSession, resumeSession, finishSession, summary, signOut]); + }), [me, screen, tab, learnSlug, learnFrom, detailId, lift, dayDate, foodSlug, foodDate, foodFrom, foodReplaceDate, + chatContext, go, openTab, budget, log, startSession, resumeSession, finishSession, summary, signOut]); const SCREENS: Record JSX.Element> = { today: PlanScreen, day: DayScreen, learn: LearnScreen, log: LogScreen, swap: SwapScreen, cooldown: CooldownScreen, summary: SummaryScreen, + food: FoodDayScreen, 'food-week': FoodWeekScreen, recipes: RecipeLibraryScreen, + recipe: RecipeScreen, cook: CookScreen, history: HistoryScreen, detail: DetailScreen, progress: ProgressScreen, records: RecordsScreen, metric: MetricScreen, coach: CoachScreen, settings: SettingsScreen, 'set-conn': ConnectionsScreen, 'set-equip': EquipmentScreen, 'set-niggles': NigglesScreen, 'set-labs': LabsScreen, library: LibraryScreen, 'set-notif': NotifScreen, 'set-coach': CoachSettingsScreen, 'set-units': UnitsScreen, 'set-server': ServerScreen, 'set-coach-usage': CoachUsageScreen, + 'set-food': NutritionScreen, 'set-appearance': AppearanceScreen, 'set-about': AboutYouScreen, }; const Cur = SCREENS[screen]; - const curKey = screen === 'day' ? 'day:' + (dayDate || 'today') : screen; + const curKey = screen === 'day' ? 'day:' + (dayDate || 'today') + : screen === 'food' ? 'food:' + (foodDate || 'today') + : screen === 'recipe' || screen === 'cook' ? screen + ':' + foodSlug + : screen; return ; } @@ -292,6 +310,7 @@ const qc = new QueryClient({ window.addEventListener('online', () => { flushQueue((n) => toast('Synced ' + n + ' queued set' + (n > 1 ? 's' : ''))); + flushFoodQueue((n) => toast('Synced ' + n + ' queued meal' + (n > 1 ? 's' : ''))); }); export default function App() { diff --git a/web/src/api.ts b/web/src/api.ts index fea3fb5..948afdb 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -216,6 +216,74 @@ export interface ProposalResp { proposal: { id: string; num: number; rationale: string; created_at: string; content: { days: Record; changes?: ProposalChange[] } } | null; } +/* ---------- nutrition (beta track, Phase 7) ---------- */ +/** The full per-meal macro set (matches server models.MACRO_FIELDS). Grams, + * except kcal and sodium (mg). */ +export interface Macros { + kcal: number; protein_g: number; carbs_g: number; sugar_g: number; + fiber_g: number; fat_g: number; satfat_g: number; sodium_mg: number; +} +export const MACRO_KEYS = ['kcal', 'protein_g', 'carbs_g', 'sugar_g', + 'fiber_g', 'fat_g', 'satfat_g', 'sodium_mg'] as const satisfies readonly (keyof Macros)[]; +export type NutritionTargets = Macros; +export interface RecipeCard extends Macros { + slug: string; name: string; kind: string; minutes: number; difficulty: string; + serves: number; batch: number; platefig: string; why: string; + image?: string | null; rating?: number; // imported recipes only (MCP) +} +export interface FoodSlot { + slot: 'breakfast' | 'lunch' | 'dinner' | 'snack'; + logged: boolean; log_id: string | null; why?: string; + recipe?: RecipeCard; order?: boolean; out?: boolean; leftover?: boolean; note?: string; + // a logged off-plan meal that replaced the planned option for this slot + off_plan?: boolean; label?: string; estimated?: boolean; macros?: Macros; +} +export interface FoodExtra extends Macros { + id: string; slot: string; label: string; estimated: boolean; + // eaten-out context (logged via MCP or order logs) + venue: string; cost: number; currency: string; note: string; photos: string[]; +} +export interface FoodDay { + date: string; day_name: string; is_today: boolean; + slots: FoodSlot[]; extras: FoodExtra[]; + totals: Macros; +} +export interface FoodWeek { + start: string; today: string; days: FoodDay[]; targets: NutritionTargets; + rationale: string; has_plan: boolean; +} +/** One planned slot inside a food proposal's raw content (Phase 8). */ +export interface FoodPropSlot { + recipe?: string; why?: string; note?: string; + order?: boolean; out?: boolean; leftover_of?: string | number; +} +export interface FoodProposalResp { + proposal: { + id: string; num: number; rationale: string; created_at: string; + changes: ProposalChange[]; + content: { days: Record }> }; + recipes: Record; + } | null; +} +export interface RecipeStep { + title: string; minutes?: number; detail: string; timer?: boolean; image?: string; + /** background step: start its timer and carry on with later steps in parallel */ + parallel?: boolean; +} +export interface RecipeIngredient { + name: string; qty: number; unit: string; disp: string; note?: string; aisle: string; pantry: boolean; +} +export interface RecipeFull extends RecipeCard { + steps: RecipeStep[]; ingredients: RecipeIngredient[]; tags: string[]; + source: string; source_url: string; + images: string[]; rating: number; rating_count: number; +} +/** One row of the library browser (GET /api/food/recipes). `complete: false` + * = parked import — browsable, never proposed by the coach. */ +export interface RecipeListItem extends RecipeCard { + tags: string[]; source: string; complete: boolean; +} +export interface RecipeList { count: number; recipes: RecipeListItem[]; } /** Full-history series for one body/engine metric (Progress drill-down). */ export interface MetricHistory { type: string; unit: string; points: SeriesPoint[]; } @@ -225,6 +293,7 @@ export interface Connections { withings: { configured: boolean; linked: boolean; status: string | null; last_sync: string | null; warning: string | null; note: string }; coach_mcp: { active: boolean; note: string }; + mcp_clients: { id: string; name: string; connected_at: string; last_used_at: string | null }[]; } /* ---------- coach usage (admin) ---------- */ @@ -323,7 +392,7 @@ export function addDaysISO(iso: string, days: number): string { return new Date(new Date(iso + 'T12:00:00Z').getTime() + days * 86400000).toISOString().slice(0, 10); } /** Monday of the week containing the given ISO date — mirrors the server's - * Mon–Sun alignment of /api/week. */ + * Mon–Sun alignment of /api/week and /api/food/week. */ export function weekStartISO(iso: string): string { return addDaysISO(iso, -((new Date(iso + 'T12:00:00Z').getUTCDay() + 6) % 7)); } diff --git a/web/src/dashboard.tsx b/web/src/dashboard.tsx index 533bb4a..cdaa7b6 100644 --- a/web/src/dashboard.tsx +++ b/web/src/dashboard.tsx @@ -178,7 +178,7 @@ function DashInner() { if (!meQ.data) { return (
-
FORGE.
+
FORGE. BETA

Sign in in the app first, then reload this page.

Open Forge
@@ -194,7 +194,7 @@ function DashInner() { return (
- FORGE. + FORGE.BETA {d.name} · Dashboard ‹ App diff --git a/web/src/main.tsx b/web/src/main.tsx index c8ba570..6c95503 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -22,10 +22,17 @@ const isDashboard = location.pathname.startsWith('/dashboard'); Not on /dashboard — that page scrolls the body normally. */ if (!isDashboard && window.visualViewport) { const vv = window.visualViewport; + // The keyboard can only be up while an editable element has focus — a short + // viewport alone is NOT enough: iOS reports transiently short visual + // viewports during the standalone launch animation, and if that reading + // sticks as --vvh the app renders with dead space under the tab bar. + const editing = () => { + const el = document.activeElement; + return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' + || (el as HTMLElement).isContentEditable); + }; const sync = () => { - // Engage only when the keyboard is actually up — iOS reports transiently - // short viewports during launch, which must not stick as the app height. - const keyboardUp = document.documentElement.clientHeight - vv.height > 80; + const keyboardUp = editing() && document.documentElement.clientHeight - vv.height > 80; if (keyboardUp) { document.documentElement.style.setProperty('--vvh', vv.height + 'px'); window.scrollTo(0, 0); @@ -35,6 +42,13 @@ if (!isDashboard && window.visualViewport) { }; vv.addEventListener('resize', sync); vv.addEventListener('scroll', sync); + // keyboard dismissal doesn't always fire a viewport event; focus changes do + window.addEventListener('focusin', () => setTimeout(sync, 60)); + window.addEventListener('focusout', () => setTimeout(sync, 60)); + document.addEventListener('visibilitychange', sync); + // clear anything the launch transients left behind once the UI settles + setTimeout(sync, 400); + setTimeout(sync, 1500); } createRoot(document.getElementById('root')!).render( diff --git a/web/src/platefig.tsx b/web/src/platefig.tsx new file mode 100644 index 0000000..49bc267 --- /dev/null +++ b/web/src/platefig.tsx @@ -0,0 +1,219 @@ +/* Plate-art engine (E16.2 AC4): palette-native SVG meal illustrations, the + formfig approach — shared plate/tray/bowl primitives + per-recipe + compositions keyed by `recipes.platefig`. Tokens only, so Paper × Moss + light mode adapts for free. No food photography anywhere. */ + +import type { ReactNode } from 'react'; + +const RIM = 'var(--hair)'; +const WELL = 'var(--bg)'; +const FOOD = 'var(--mut)'; // main food mass +const FOOD_DIM = 'var(--dim)'; // shadow food / beans +const LIGHT = 'var(--ink)'; // pale food (fish, yogurt) — used with opacity +const VOLT = 'var(--volt)'; // greens & garnish only + +/* ---- primitives (viewBox 0 0 40 40) ---- */ +const Plate = ({ well = true }: { well?: boolean }) => ( + <> + + {well && } + +); +const Bowl = () => ( + <> + + + +); +const Tray = () => ( + <> + + + +); +const Leaf = ({ x, y, o = 0.85, r = 0 }: { x: number; y: number; o?: number; r?: number }) => ( + +); +const Dot = ({ x, y, r = 1.3, f = FOOD_DIM, o = 1 }: { x: number; y: number; r?: number; f?: string; o?: number }) => ( + +); + +/* ---- compositions ---- */ +const COMPS: Record = { + 'tray-chicken': ( + <> + + + + + + + ), + 'pan-skillet': ( + <> + + + + + + + + ), + 'plate-salmon': ( + <> + + + + + + + + + + + + ), + 'plate-chicken': ( + <> + + + + + + + + ), + 'plate-salad': ( + <> + + + + + + + + ), + 'plate-eggs': ( + <> + + + + + + + + ), + 'bowl-chili': ( + <> + + + + + + + + ), + 'bowl-soba': ( + <> + + + + + + + + + ), + 'bowl-stew': ( + <> + + + + + + + + + + + ), + 'bowl-pasta': ( + <> + + + + + + + + ), + 'bowl-grain': ( + <> + + + + + + + + ), + 'bowl-oats': ( + <> + + + + + + + ), + 'snack-apple': ( + <> + + + + + + + + ), + 'snack-nuts': ( + <> + + + + + + + ), + 'snack-yogurt': ( + <> + + + + + + ), + out: ( + <> + + + + + ), + plate: ( + <> + + + + + ), +}; + +export function PlateFig({ id, size = 30, dim = false }: { id: string; size?: number; dim?: boolean }) { + return ( + + ); +} diff --git a/web/src/routemap.tsx b/web/src/routemap.tsx index 8f57581..dc842cb 100644 --- a/web/src/routemap.tsx +++ b/web/src/routemap.tsx @@ -205,8 +205,8 @@ function LiveMap({ pts, apiKey }: { pts: Pt[]; apiKey: string }) { if (state === 'failed') return ; return (
-
+
{state !== 'ready' && (
diff --git a/web/src/screens/auth.tsx b/web/src/screens/auth.tsx index 09340c5..94a42ef 100644 --- a/web/src/screens/auth.tsx +++ b/web/src/screens/auth.tsx @@ -27,7 +27,7 @@ export function AuthScreen({ onSignedIn }: { onSignedIn: () => void }) { return (
-
FORGE.
+
FORGE. BETA
Coached by an agent.
Evidence from your own body.
{m?.google && ( diff --git a/web/src/screens/coach.tsx b/web/src/screens/coach.tsx index 9a47413..08bd85b 100644 --- a/web/src/screens/coach.tsx +++ b/web/src/screens/coach.tsx @@ -128,6 +128,8 @@ export function CoachScreen() { qc.invalidateQueries({ queryKey: ['today'] }); qc.invalidateQueries({ queryKey: ['week'] }); qc.invalidateQueries({ queryKey: ['niggles'] }); + qc.invalidateQueries({ queryKey: ['foodproposal'] }); + qc.invalidateQueries({ queryKey: ['foodweek'] }); } wasThinking.current = thinking; }, [thinking, qc]); @@ -161,6 +163,8 @@ export function CoachScreen() { await api('/api/coach/run-review', { method: 'POST' }); qc.invalidateQueries({ queryKey: ['chat'] }); qc.invalidateQueries({ queryKey: ['proposal'] }); + qc.invalidateQueries({ queryKey: ['foodproposal'] }); + qc.invalidateQueries({ queryKey: ['foodweek'] }); toast('Review done — proposal ready', true); } catch (e) { toast(String((e as Error).message)); diff --git a/web/src/screens/data.tsx b/web/src/screens/data.tsx index 014178b..283943e 100644 --- a/web/src/screens/data.tsx +++ b/web/src/screens/data.tsx @@ -589,6 +589,9 @@ function BandChart({ points, band, disp, units }: { {m.label} ))} + {pts.length <= 80 && pts.map((p, i) => ( + + ))} @@ -596,15 +599,20 @@ function BandChart({ points, band, disp, units }: { ); } -export function MetricScreen() { - const { openTab, go, lift: mtype, me } = useApp(); +/** One metric's full story — latest value, history chart with the reference + * band shaded, the band/zone description, and the plain-words blurb. Rendered + * inline under the Progress KPI grid (no navigation) and by the legacy + * /metric deep-link screen. `chartOverride` swaps in a custom chart (VO₂max + * keeps its raw-dots + quarterly-trend view) while the band text stays. */ +export function MetricPanel({ mtype, chartOverride }: { mtype: string; chartOverride?: ReactNode }) { + const { go, me } = useApp(); const meta = METRIC_META[mtype]; const q = useQuery({ queryKey: ['metric-history', mtype], queryFn: () => api(`/api/metrics/${mtype}/history`), enabled: !!meta, }); - if (!meta) return openTab('progress')} />Unknown metric.; + if (!meta) return Unknown metric.; const h = q.data; const unit = meta.unit(me.units); const pts = h?.points || []; @@ -624,25 +632,28 @@ export function MetricScreen() { : (lastV! > meta.disp(band![1], me.units)) === !!meta.higherBetter ? 'above the reference range' : 'below the reference range'; return ( - - openTab('progress')} /> - {meta.title} + <>
- Latest + + {meta.title} + {firstD && · since {firstD}} + {lastV != null ? lastV : '—'} {unit}
{!h && } - {h && } + {h && (chartOverride + ?? )} {band && bandFor && (
- Shaded: {meta.disp(band[0], me.units)}–{meta.disp(band[1], me.units)} {unit} · {bandFor.label} + {chartOverride ? 'Reference' : 'Shaded'}: {meta.disp(band[0], me.units)}–{meta.disp(band[1], me.units)} {unit} · {bandFor.label} {bandWord && <> — you're {bandWord}}
)} +

{meta.blurb}

{untuned && ( )} -
-
What this is
-

{meta.blurb}

-
{band && ( Reference ranges are broad healthy guidance, not targets or diagnosis — bring questions about your numbers to your GP. )} + + ); +} + +export function MetricScreen() { + const { openTab, lift: mtype } = useApp(); + const meta = METRIC_META[mtype]; + return ( + + openTab('progress')} /> + {meta ? meta.title : 'Metric'} + ); } @@ -666,6 +685,8 @@ export function MetricScreen() { export function ProgressScreen() { const { go, lift, me } = useApp(); const q = useQuery({ queryKey: ['progress'], queryFn: () => api('/api/progress') }); + const [selMetric, setSelMetric] = useState( + () => localStorage.getItem('forge-progress-metric') || 'weight'); const p = q.data; if (!p) return ; const slugs = Object.keys(p.e1rm); @@ -732,65 +753,48 @@ export function ProgressScreen() { Recordsall-time bests per lift -
Engine
-
-
- VO₂max · raw + trend - {vLast != null ? 'ml/kg/min' : ''} -
- - {p.vo2max.length >= 2 && -
Trend over single readings — the coach reads this quarterly.
} - -
-
Zone 2
-
= p.zone2.target ? { color: 'var(--volt)' } : undefined}> - {Math.round(p.zone2.done)}/{p.zone2.target || '—'} m
- - -
-
- -
Body
- - {(bc.fat_pct.length > 0 || bc.muscle.length > 0) && ( -
-
- Body composition - latest scale readings -
-
- {([['Fat %', last(bc.fat_pct), '%', 'body_fat_pct'], - ['Water %', last(bc.water_pct), '%', 'water_pct'], - ['Muscle', last(bc.muscle), null, 'muscle_mass'], - ['Bone', last(bc.bone), null, 'bone_mass']] as const).map(([k, v, pct, type]) => ( - - ))} -
- {bc.fat_pct.length >= 2 && } - {bc.fat_pct.length >= 2 &&
Chart: body-fat % over the last year
} -
Tap any number for its full history and healthy range.
-
- )} + {/* Every health KPI in one grid; tapping a chip swaps the chart + + band/zone description below it in place — no screen hopping. */} +
Health
+ {(() => { + const kpis: { type: string; k: string; v: string }[] = [ + { type: 'weight', k: 'Weight', v: wLast != null ? kgDisp(wLast, me.units) : '—' }, + { type: 'vo2max', k: 'VO₂max', v: vLast != null ? String(vLast) : '—' }, + { type: 'resting_hr', k: 'Resting HR', + v: p.resting_hr.length ? `${Math.round(p.resting_hr[p.resting_hr.length - 1].v)} bpm` : '—' }, + { type: 'sleep_h', k: 'Sleep', + v: p.sleep_h.length ? `${p.sleep_h[p.sleep_h.length - 1].v.toFixed(1)} h` : '—' }, + ]; + if (bc.fat_pct.length) kpis.push({ type: 'body_fat_pct', k: 'Fat %', v: `${last(bc.fat_pct)!.toFixed(1)}%` }); + if (bc.water_pct.length) kpis.push({ type: 'water_pct', k: 'Water %', v: `${last(bc.water_pct)!.toFixed(1)}%` }); + if (bc.muscle.length) kpis.push({ type: 'muscle_mass', k: 'Muscle', v: kgDisp(last(bc.muscle)!, me.units) }); + if (bc.bone.length) kpis.push({ type: 'bone_mass', k: 'Bone', v: kgDisp(last(bc.bone)!, me.units) }); + const selType = kpis.some((x) => x.type === selMetric) ? selMetric : 'weight'; + return ( + <> +
+ {kpis.map((kpi) => { + const on = selType === kpi.type; + return ( + + ); + })} +
+ + : undefined} /> + + ); + })()}
); } diff --git a/web/src/screens/food.tsx b/web/src/screens/food.tsx new file mode 100644 index 0000000..25b47cc --- /dev/null +++ b/web/src/screens/food.tsx @@ -0,0 +1,1103 @@ +/* Food tab (beta track, Phase 7): day view (meters + one-tap tick rows), + week menu, recipe detail, and cook mode. Plate-first layout — the + cholesterol trio leads. Ticks queue in localStorage when offline and + replay idempotently via client_id (uq_meal_client server-side). */ + +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef, useState } from 'react'; +import { + addDaysISO, api, ApiError, fmtT, MACRO_KEYS, todayISO, weekStartISO, + type FoodDay, type FoodProposalResp, type FoodPropSlot, type FoodSlot, type FoodWeek, + type Macros, type RecipeFull, type RecipeIngredient, type RecipeList, +} from '../api'; +import { PlateFig } from '../platefig'; +import { Back, Chip, Loading, Shell, Title, toast, useApp } from '../ui'; + +/* ---------------- offline tick queue ---------------- */ +const QKEY = 'forge-food-queue'; +interface QueuedTick { date: string; slot: string; recipe: string; client_id: string; } +const readQueue = (): QueuedTick[] => JSON.parse(localStorage.getItem(QKEY) || '[]'); +const writeQueue = (q: QueuedTick[]) => localStorage.setItem(QKEY, JSON.stringify(q)); + +export async function flushFoodQueue(onDone?: (n: number) => void) { + const q = readQueue(); + if (!q.length) return; + let sent = 0; + let i = 0; + for (; i < q.length; i++) { + try { + await api('/api/food/log', { method: 'POST', body: q[i] }); + sent++; + } catch (e) { + if (e instanceof ApiError && e.network) break; // still offline — keep q[i..] queued + // non-network error (e.g. recipe gone): drop it rather than loop forever + } + } + writeQueue(q.slice(i)); + if (sent) onDone?.(sent); +} + +/* ---------------- data ---------------- */ +/** The Mon–Sun food week containing `weekStart` (a Monday ISO; null/undefined + * = the current week). */ +export function useFoodWeek(weekStart?: string | null) { + return useQuery({ + queryKey: ['foodweek', weekStart || 'current'], + queryFn: () => api('/api/food/week' + (weekStart ? `?date=${weekStart}` : '')), + placeholderData: keepPreviousData, + }); +} + +export function useFoodProposal() { + return useQuery({ + queryKey: ['foodproposal'], queryFn: () => api('/api/food/proposal'), + }); +} + +const SLOT_LABEL: Record = { + breakfast: 'Breakfast', lunch: 'Lunch', dinner: 'Dinner', snack: 'Snack', +}; + +/** Compact one-line macros for the dense meal rows: "520 · P42 C48 F13". */ +const macroBrief = (m: Macros) => + `${Math.round(m.kcal)} · P${Math.round(m.protein_g)} C${Math.round(m.carbs_g)} F${Math.round(m.fat_g)}`; + +/* ---------------- food week proposal (Phase 8, E16.3) ---------------- */ +const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +export function FoodProposalBanner({ onOpen }: { onOpen: () => void }) { + const q = useFoodProposal(); + if (!q.data?.proposal) return null; + return ( + + ); +} + +export function FoodProposalCard({ onDecided }: { onDecided?: () => void }) { + const qc = useQueryClient(); + const { openTab } = useApp(); + const q = useFoodProposal(); + const [noteOpen, setNoteOpen] = useState(false); + const [daysOpen, setDaysOpen] = useState(false); + const p = q.data?.proposal; + const decide = useMutation({ + mutationFn: (arg: { id: string; verb: 'approve' | 'reject' }) => + api(`/api/food/proposal/${arg.id}/${arg.verb}`, { method: 'POST' }), + onSuccess: (_d, arg) => { + toast(arg.verb === 'approve' ? 'Food week approved — live now' : 'Proposal dismissed', + arg.verb === 'approve'); + qc.invalidateQueries({ queryKey: ['foodproposal'] }); + qc.invalidateQueries({ queryKey: ['foodweek'] }); + onDecided?.(); + }, + }); + if (!p) return null; + const proposedOn = new Date(p.created_at); + const signColor = (s: string) => s === '+' ? 'var(--volt)' : s === '-' ? 'var(--warn)' : 'var(--mut)'; + + const dinnerLine = (slots: Record): { name: string; why: string } => { + const d = slots.dinner || {}; + if (d.out) return { name: 'Night out', why: d.note || '' }; + if (d.leftover_of !== undefined) { + const src = p.content.days[String(d.leftover_of)]?.slots?.dinner?.recipe; + const r = src ? p.recipes[src] : undefined; + return { name: r ? `${r.name} · leftovers` : 'Leftovers', why: d.why || 'zero-cook night' }; + } + const r = d.recipe ? p.recipes[d.recipe] : undefined; + return { name: r?.name || d.recipe || '—', why: d.why || '' }; + }; + + return ( +
+
+ Proposed {proposedOn.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })} + {' · '}food week #{p.num} · awaiting your OK +
+ +
+ {p.changes.map((c, i) => ( +
+ {c.sign} + {c.what} + {c.why && {c.why}} +
+ ))} +
+ + {p.rationale && ( + + )} + +
+ + +
+ + + {daysOpen && Object.entries(p.content.days).sort(([a], [b]) => +a - +b).map(([k, day]) => { + const din = dinnerLine(day.slots || {}); + return ( +
+
+ {DAY_NAMES[+k]} · {din.name} +
+ {din.why &&
{din.why}
} +
+ ); + })} + +
+ ); +} + +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 ( +
+ {image ? ( + + ) : ( + + )} +
+ ); +} + +/* ---------------- macro cells (compact grid) ---------------- */ +function MacroCell({ label, val, target, cap }: + { label: string; val: number; target: number; cap?: boolean }) { + const pct = Math.min(100, Math.round((val / Math.max(target, 1)) * 100)); + const over = cap && val > target; + return ( +
+
{label}
+
{Math.round(val)} + /{cap ? '≤' : ''}{target}
+
+
+ ); +} + +/* ---------------- day view (the Food tab home) ---------------- */ +export function FoodDayScreen() { + const { go, openTab, foodDate } = useApp(); + const qc = useQueryClient(); + // fetch the week containing the viewed date, so days from a paged week open too + const rawStart = foodDate ? weekStartISO(foodDate) : null; + const wkStart = rawStart === weekStartISO(todayISO()) ? null : rawStart; + const wq = useFoodWeek(wkStart); + const [propOpen, setPropOpen] = useState(false); + const w = wq.data; + if (!w) return ; + + const day: FoodDay | undefined = + (foodDate ? w.days.find((d) => d.date === foodDate) : undefined) ?? + w.days.find((d) => d.is_today) ?? w.days[0]; + + const tick = async (s: FoodSlot, date: string) => { + if (s.logged) { + if (!s.log_id) return; // optimistic tick still in flight — don't re-log or half-untick + try { + await api('/api/food/log/' + s.log_id, { method: 'DELETE' }); + qc.invalidateQueries({ queryKey: ['foodweek'] }); + } catch (e) { + toast(e instanceof ApiError && e.network ? 'Need a connection to untick' : String((e as Error).message)); + } + return; + } + if (!s.recipe) return; + const body: QueuedTick = { date, slot: s.slot, recipe: s.recipe.slug, client_id: crypto.randomUUID() }; + // optimistic: mark logged + bump the day's totals in the cached week + qc.setQueryData(['foodweek', wkStart || 'current'], (old) => old && ({ + ...old, + days: old.days.map((d) => d.date !== date ? d : { + ...d, + slots: d.slots.map((x) => x.slot === s.slot ? { ...x, logged: true } : x), + totals: Object.fromEntries(MACRO_KEYS.map((k) => + [k, (d.totals[k] || 0) + (s.recipe![k] || 0)])) as unknown as Macros, + }), + })); + try { + await api('/api/food/log', { method: 'POST', body }); + qc.invalidateQueries({ queryKey: ['foodweek'] }); + } catch (e) { + if (e instanceof ApiError && e.network) { + writeQueue([...readQueue(), body]); + toast('Offline — meal queued, will sync'); + } else { + toast(String((e as Error).message)); + qc.invalidateQueries({ queryKey: ['foodweek'] }); + } + } + }; + + if (!day) return ; + const t = w.targets; + const remaining = day.slots.filter((s) => s.recipe && !s.logged); + const onPlanLine = !w.has_plan + ? '' + : remaining.length === 0 && day.slots.some((s) => s.logged) + ? 'Day closed — all planned meals logged.' + : remaining.some((s) => s.slot === 'dinner') + ? 'On plan — dinner closes protein and fiber.' + : ''; + + return ( + +
+ Food + + + + +
+ + setPropOpen(true)} /> + {propOpen && ( +
setPropOpen(false)}> +
e.stopPropagation()}> + setPropOpen(false)} /> +
+
+ )} + + {!w.has_plan && ( +
No food week yet
+
The recipe library is stocked — a food week appears here once one is active.
+
+ )} + + {w.has_plan && ( +
+
+ + + + + + + + +
+ {onPlanLine &&
{onPlanLine}
} +
+ )} + + {(day.slots.length > 0 || day.extras.length > 0) && <> +
Meals
+
+ {day.slots.map((s) => { + // off-plan logged meal (no recipe card) is still interactive — tap to untick + const passive = !s.recipe && !s.label; + const fig = s.out ? 'out' : (s.recipe?.platefig || 'plate'); + return ( + + ); + })} + + {day.extras.map((x) => ( +
+ + + {x.label} + {(x.venue || x.cost > 0) && ( + + {x.venue}{x.venue && x.cost > 0 ? ' · ' : ''} + {x.cost > 0 && {x.cost.toFixed(2)}{x.currency ? ` ${x.currency}` : ''}} + + )} + + {SLOT_LABEL[x.slot] || x.slot} · {macroBrief(x)}{x.estimated ? ' · est' : ''} + +
+ ))} +
+ } + + + +
+ ); +} + +/* ---------------- week menu ---------------- */ +const FOOD_WEEKS_AHEAD = 4; + +export function FoodWeekScreen() { + const { go } = useApp(); + const [weekStart, setWeekStart] = useState(null); + const curMonday = weekStartISO(todayISO()); + const start = weekStart || curMonday; + // three panes — viewed week plus both neighbours — so a swipe drags real content + const wq = useFoodWeek(weekStart); + const wqPrev = useFoodWeek(addDaysISO(start, -7)); + const wqNext = useFoodWeek(addDaysISO(start, 7)); + const [noteOpen, setNoteOpen] = useState(false); + const [propOpen, setPropOpen] = useState(false); + const trackRef = useRef(null); + const drag = useRef<{ x: number; y: number; dx: number; mode: 'h' | 'v' | null } | null>(null); + const anim = useRef(false); + const w = wq.data; + + const isCurrent = start === curMonday; + const maxStart = addDaysISO(curMonday, 7 * FOOD_WEEKS_AHEAD); + const canNext = start < maxStart; + const shiftWeek = (n: number) => { + const next = addDaysISO(start, n * 7); + if (next > maxStart) return; + setWeekStart(next === curMonday ? null : next); + }; + + /* finger-tracked prev/cur/next week track — mirrors the Plan screen: the track + sits at translateX(-100%); a horizontal drag moves it 1:1, release settles + back or slides one pane over and commits the week change in the same frame. */ + const setTrack = (px: number) => { + const el = trackRef.current; + if (!el) return; + el.style.transition = 'none'; + el.style.transform = `translateX(calc(-100% + ${px}px))`; + }; + const settle = (dir: -1 | 0 | 1) => { + const el = trackRef.current; + if (!el || (dir !== 0 && anim.current)) return; + if (dir !== 0 && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + el.style.transition = 'none'; + el.style.transform = 'translateX(-100%)'; + shiftWeek(dir); + return; + } + anim.current = dir !== 0; + el.style.transition = 'transform .28s cubic-bezier(.22,.9,.3,1)'; + el.style.transform = `translateX(${dir === 1 ? -200 : dir === -1 ? 0 : -100}%)`; + if (dir === 0) return; + window.setTimeout(() => { + el.style.transition = 'none'; + el.style.transform = 'translateX(-100%)'; + shiftWeek(dir); + anim.current = false; + }, 290); + }; + const onTouchStart = (e: React.TouchEvent) => { + if (anim.current) return; + drag.current = { x: e.touches[0].clientX, y: e.touches[0].clientY, dx: 0, mode: null }; + }; + const onTouchMove = (e: React.TouchEvent) => { + const d = drag.current; + if (!d) return; + const dx = e.touches[0].clientX - d.x; + const dy = e.touches[0].clientY - d.y; + if (!d.mode) { + if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return; + d.mode = Math.abs(dx) > Math.abs(dy) * 1.2 ? 'h' : 'v'; + } + if (d.mode !== 'h') return; + d.dx = dx < 0 && !canNext ? dx / 3 : dx; // rubber-band past the forward cap + setTrack(d.dx); + }; + const onTouchEnd = () => { + const d = drag.current; + drag.current = null; + if (!d || d.mode !== 'h') return; + const width = trackRef.current?.parentElement?.clientWidth || 390; + if (Math.abs(d.dx) < Math.min(110, width * 0.28)) return settle(0); + const dir = d.dx < 0 ? 1 : -1; + settle(dir === 1 && !canNext ? 0 : dir); + }; + const onTouchCancel = () => { + if (drag.current?.mode === 'h') settle(0); + drag.current = null; + }; + + if (!w) return ; + + const bls = (d: FoodDay): string => + d.slots.filter((s) => s.slot !== 'dinner') + .map((s) => `${s.slot[0].toUpperCase()} ${s.order ? 'order' : s.leftover ? 'leftovers' + : s.recipe ? s.recipe.name.split(' —')[0].split(',')[0].toLowerCase() : '—'}`) + .join(' · '); + + /** One week of content — stats, coach note, day rows. Rendered three times + (prev/cur/next) into the sliding track. */ + const pane = (wk: FoodWeek | undefined, pos: 'prev' | 'cur' | 'next') => { + if (!wk) return
; + const planned = wk.days.map((d) => { + const sums = { protein_g: 0, fiber_g: 0, satfat_g: 0 }; + d.slots.forEach((s) => { + if (s.recipe) { sums.protein_g += s.recipe.protein_g; sums.fiber_g += s.recipe.fiber_g; sums.satfat_g += s.recipe.satfat_g; } + }); + return sums; + }); + const avg = (k: keyof (typeof planned)[number]) => + Math.round(planned.reduce((a, p) => a + p[k], 0) / (planned.length || 1)); + const t = wk.targets; + return ( +
+
+
Planned protein
+
{avg('protein_g')}g/day
+
Planned fiber
+
{avg('fiber_g')}g/day
+
Sat fat · cap {t.satfat_g}
+
{avg('satfat_g')}g/day
+
+
+ Planned recipes only — order-out lunches and the night out add on top. +
+ {wk.rationale && ( + + )} + {wk.days.map((d) => { + const dinner = d.slots.find((s) => s.slot === 'dinner'); + const allLogged = d.slots.filter((s) => s.recipe).length > 0 && + d.slots.filter((s) => s.recipe).every((s) => s.logged); + const fig = dinner?.out ? 'out' : (dinner?.recipe?.platefig || 'plate'); + return ( + + ); + })} + The coach proposes each week on Sundays alongside training — the shopping list + lands later in Phase 8. +
+ ); + }; + + return ( + + go('food')} /> +
+
+ Food week + + {!isCurrent && ( + + )} + + + +
+ + setPropOpen(true)} /> + +
+
+ {pane(wqPrev.data, 'prev')} + {pane(w, 'cur')} + {pane(wqNext.data, 'next')} +
+
+
+ + {propOpen && ( +
setPropOpen(false)}> +
e.stopPropagation()}> + setPropOpen(false)} /> +
+
+ )} +
+ ); +} + +/* ---------------- 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. +
+ )} + setQ(e.target.value)} placeholder="Search name or tag…" + style={{ background: 'var(--raised)', border: 0, borderRadius: 12, padding: '10px 13px', width: '100%' }} /> + {!replace && ( +
+ {KINDS.map((k) => ( + + ))} +
+ )} + {rows.map((r) => ( + + ))} + {!rows.length &&
No recipes match.
} + {!replace && ( + Imported recipes land here. Parked ones are missing pantry reference data — + still cookable, never proposed by the coach. + )} +
+ ); +} + +/* ---------------- recipe detail ---------------- */ +export function useRecipe(slug: string) { + return useQuery({ + queryKey: ['recipe', slug], + queryFn: () => api('/api/food/recipes/' + slug), + enabled: !!slug, + staleTime: 5 * 60_000, + }); +} + +/** Ingredient amount scaled to `servings` of a recipe authored for `base`. + * The freeform `disp` is only trustworthy at the authored count, so once scaled + * we fall back to the numeric qty·unit. */ +function scaledAmount(i: RecipeIngredient, base: number, servings: number): string { + if (!i.qty) return i.disp || ''; // no quantity to show (e.g. "to taste") + if (servings === base || base <= 0) return i.disp || `${i.qty} ${i.unit}`; + const v = (i.qty * servings) / base; + if (i.unit === 'x') { + const n = Math.round(v * 2) / 2; + return '×' + (Number.isInteger(n) ? n : n.toFixed(1)); + } + const n = v >= 100 ? Math.round(v / 5) * 5 : Math.round(v * 10) / 10; + return `${n} ${i.unit}`; +} + +/** Pick a night to pencil this recipe into — writes that weekday's slot on the + * active food week (household-shared for members, own scope for the demo). */ +function PlanDaySheet({ recipe, onClose }: { recipe: RecipeFull; onClose: () => void }) { + const qc = useQueryClient(); + const wq = useFoodWeek(null); + const slot = recipe.kind === 'dinner' ? 'dinner' : recipe.kind; + const set = useMutation({ + mutationFn: (date: string) => api('/api/food/week/slot', { + method: 'PATCH', body: { date, recipe: recipe.slug, slot }, + }), + onSuccess: (_d, date) => { + qc.invalidateQueries({ queryKey: ['foodweek'] }); + const dn = wq.data?.days.find((x) => x.date === date)?.day_name || 'that day'; + toast(`${recipe.name} set for ${dn}`, true); + onClose(); + }, + onError: (e) => toast(e instanceof ApiError && e.network + ? 'Need a connection to plan' : String((e as Error).message)), + }); + return ( +
+
e.stopPropagation()}> +

Plan {recipe.name}

+
+ Pick a night — it becomes that weekday's {slot} on your food week (every week, until the coach reworks it). +
+ {!wq.data ? : wq.data.days.map((d) => { + const cur = d.slots.find((s) => s.slot === slot); + return ( + + ); + })} +
+
+ ); +} + +export function RecipeScreen() { + const { go, foodSlug, foodDate, foodFrom } = useApp(); + const fromLib = foodFrom === 'recipes'; + const qc = useQueryClient(); + const q = useRecipe(foodSlug); + const r = q.data; + const [servingsOverride, setServingsOverride] = useState(null); + const [planOpen, setPlanOpen] = useState(false); + + const logMut = useMutation({ + mutationFn: () => api('/api/food/log', { + method: 'POST', + body: { date: foodDate || todayISO(), slot: r!.kind === 'dinner' ? 'dinner' : r!.kind, + recipe: r!.slug, client_id: crypto.randomUUID() }, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['foodweek'] }); + toast(`Logged — P +${r!.protein_g} · fiber +${r!.fiber_g}`, true); + go('food'); + }, + onError: (e) => toast(e instanceof ApiError && e.network ? 'Offline — tick it from the day view instead' : String(e.message)), + }); + + if (!r) return ; + const servings = servingsOverride ?? r.serves; + + return ( + + go(fromLib ? 'recipes' : 'food')} /> + {r.name} +
+ {r.minutes} min + {r.difficulty} + serves {r.serves}{r.batch ? ` + ${r.batch} boxed` : ''} +
+
+ {r.kcal} kcal + P {r.protein_g} + carbs {r.carbs_g} + fiber {r.fiber_g} + fat {r.fat_g} +
+
+ sugar {r.sugar_g} g + sat fat {r.satfat_g} g + sodium {r.sodium_mg} mg +
+ + {r.images?.length ? ( + {r.name} + ) : ( +
+ +
+ )} + {r.rating > 0 && ( +
+ {r.rating.toFixed(1)} + {r.rating_count > 0 && ` · ${r.rating_count} ratings`} + {r.source && ` · ${r.source}`} +
+ )} + + {r.why && ( +
+ Why it's here +
{r.why}
+
+ )} + +
Ingredients{r.batch ? ' · 2 nights' : ''}
+ {r.serves > 1 && ( +
+
+ Scale the batch + + serves {servings} + {servings !== r.serves && · authored {r.serves}} + +
+ setServingsOverride(+e.target.value)} /> +
+ {servings === r.serves + ? 'Cooking for fewer? Drag it down — amounts scale, per-plate macros stay the same.' + : `Amounts scaled to ${servings} ${servings === 1 ? 'serving' : 'servings'}. Per-plate macros unchanged.`} +
+
+ )} +
+ {r.ingredients.map((i) => ( +
+ + {i.name} + {i.note && i.note !== 'pantry' && {i.note}} + + + {scaledAmount(i, r.serves, servings)} + {i.pantry && pantry} + +
+ ))} +
+ + {r.steps.length > 0 && ( + <> +
Method · {r.steps.length} steps
+
+ {r.steps.map((s, i) => ( +
+ {i + 1} + + {s.title} + + {s.minutes ? `· ${s.minutes} min` : ''}{s.timer ? ' · timer' : ''} + {s.parallel ? ' · background' : ''} + + {s.detail} + {s.image && ( + + )} + +
+ ))} +
+ + )} + + {r.source_url && ( + + Original recipe · {r.source || 'source'} ↗ + + )} + + + + {r.steps.length > 1 ? ( + <> + + + + ) : ( + + )} + {planOpen && setPlanOpen(false)} />} +
+ ); +} + +/* ---------------- cook mode ---------------- */ +const RING_C = 2 * Math.PI * 56; // r=56 in a 140 viewBox + +export function CookScreen() { + const { go, foodSlug, foodDate } = useApp(); + const qc = useQueryClient(); + const q = useRecipe(foodSlug); + const r = q.data; + const [idx, setIdx] = useState(0); + const [plated, setPlated] = useState(false); + // Background timers keyed by step index. They keep running — and stay visible + // in the strip — as you move to other steps, so a simmer can cook while you + // prep the next thing in parallel. Absolute end time survives step changes. + const [timers, setTimers] = useState>({}); + const [now, setNow] = useState(() => Date.now()); + const t0 = useRef(Date.now()); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 500); + return () => clearInterval(id); + }, []); + // chime each timer exactly once as it crosses zero + useEffect(() => { + const due = Object.entries(timers).filter(([, t]) => !t.done && t.end <= now); + if (!due.length) return; + setTimers((prev) => { + const next = { ...prev }; + for (const [k] of due) next[+k] = { ...next[+k], done: true }; + return next; + }); + for (const [k] of due) toast(`${r?.steps[+k]?.title || 'Timer'} — time's up`, true); + if (navigator.vibrate) navigator.vibrate([160, 90, 160]); + }, [now, timers, r]); + + if (!r) return ; + const steps = r.steps; + const step = steps[Math.min(idx, steps.length - 1)]; + const last = idx >= steps.length - 1; + const remainOf = (i: number): number | null => { + const t = timers[i]; + return t ? Math.max(0, Math.round((t.end - now) / 1000)) : null; + }; + const startTimer = (i: number) => + setTimers((t) => ({ ...t, [i]: { total: (steps[i].minutes || 1) * 60, + end: Date.now() + (steps[i].minutes || 1) * 60 * 1000, done: false } })); + const running = steps.map((s, i) => ({ s, i })).filter(({ i }) => timers[i]); + + const finish = async () => { + const cookedMin = Math.max(1, Math.round((Date.now() - t0.current) / 60000)); + try { + await api('/api/food/log', { + method: 'POST', + body: { date: foodDate || todayISO(), slot: r.kind, recipe: r.slug, + client_id: crypto.randomUUID() }, + }); + qc.invalidateQueries({ queryKey: ['foodweek'] }); + } catch { toast('Offline — tick it from the day view when back online'); } + setPlated(true); + toast(`Plated in ${cookedMin} min — ${r.kind} logged`, true); + }; + + if (plated) { + return ( + + Plated. Logged. + {r.images?.length ? ( + {r.name} + ) : ( +
+ +
+ )} +
+ {r.batch > 0 && ( +
+ + Leftover night boxed + + {r.batch} servings in the fridge — zero-cook night locked in +
+ )} +
+ + {SLOT_LABEL[r.kind] || 'Meal'} logged — your plate + + Everyone else ticks their own — their plate, their targets +
+
+
+
Per plate
+
+
kcal
{r.kcal}
+
Protein
{r.protein_g}
+
Fiber
{r.fiber_g}
+
Sat fat
{r.satfat_g}
+
+
+
Carbs
{r.carbs_g}
+
Sugar
{r.sugar_g}
+
Fat
{r.fat_g}
+
Sodium
{r.sodium_mg} mg
+
+
+ +
+ ); + } + + return ( + +
+ go('recipe')} /> + {r.name} +
+ +
+ {steps.map((_, i) => ( + + {i < idx ? '✓' : i + 1} + + ))} + Step {idx + 1} of {steps.length} +
+ + {running.length > 0 && ( +
+ {running.map(({ s, i }) => { + const t = timers[i]; + return ( + + ); + })} +
+ )} + +

{step.title}

+ {step.parallel && ( +
Background step — start it, then move on while it cooks.
+ )} +
{step.detail}
+ {step.image && ( + + )} + + {step.timer && (() => { + const rem = remainOf(idx); + const tot = timers[idx]?.total || (step.minutes || 1) * 60; + return ( +
+ + + {rem !== null && tot > 0 && ( + + )} + + {rem !== null ? fmtT(rem) : fmtT((step.minutes || 1) * 60)} + + + {rem !== null ? (rem === 0 ? 'done' : `of ${step.minutes}:00`) : `${step.minutes} min`} + + + {rem === null ? ( + + ) : step.parallel ? ( +
Running in the background — carry on to the next step; it'll chime here (and in the strip above) when it's done.
+ ) : ( +
A local timer, same as your rest ring — it chimes here, no push.
+ )} +
+ ); + })()} + +
+ {idx > 0 && ( + + )} + +
+
+ ); +} diff --git a/web/src/screens/onboarding.tsx b/web/src/screens/onboarding.tsx index b2f070c..c89932a 100644 --- a/web/src/screens/onboarding.tsx +++ b/web/src/screens/onboarding.tsx @@ -26,7 +26,7 @@ export function OnboardingFlow({ me, onDone }: { me: Me; onDone: () => void }) { return ( <>
- FORGE. + FORGE.BETA Step {step + 1} of {STEPS.length}
@@ -74,8 +74,14 @@ function SkipSheet({ title, consequences, later, onStay, onSkip }: { /* ---------------- step 1: welcome + units ---------------- */ function BasicsStep({ me, onNext }: { me: Me; onNext: () => void }) { const [units, setUnits] = useState(me.units || 'kg'); + const [sex, setSex] = useState((me.prefs as any)?.sex || ''); + const [birthYear, setBirthYear] = useState(String((me.prefs as any)?.birth_year || '')); const next = () => { - api('/api/prefs', { method: 'PATCH', body: { units } }).catch(() => {}); + const prefs: Record = {}; + if (sex) prefs.sex = sex; + const y = parseInt(birthYear, 10); + if (y >= 1930 && y <= 2015) prefs.birth_year = y; + api('/api/prefs', { method: 'PATCH', body: { units, prefs } }).catch(() => {}); onNext(); }; return ( @@ -87,7 +93,7 @@ function BasicsStep({ me, onNext }: { me: Me; onNext: () => void }) { is permanent, everything can be changed later in Settings.

-
One question: how do you talk about your body weight?
+
How do you talk about your body weight?
@@ -98,6 +104,19 @@ function BasicsStep({ me, onNext }: { me: Me; onNext: () => void }) { setting — the gym's plates don't care how you weigh yourself.)
+
+
A little about you — so Progress charts use age- and sex-appropriate healthy ranges
+
+ + +
+
+ setBirthYear(e.target.value.replace(/[^0-9]/g, '').slice(0, 4))} />
+
+ Optional — used only to pick reference bands, never shared. Change it any time in Settings → About you. +
+
diff --git a/web/src/screens/settings.tsx b/web/src/screens/settings.tsx index 3a0df1d..deee8d3 100644 --- a/web/src/screens/settings.tsx +++ b/web/src/screens/settings.tsx @@ -6,7 +6,7 @@ import { type Me, type NiggleRow, type Progress, } from '../api'; import { - applyContrast, applyTheme, Back, Chip, Loading, Shell, + applyContrast, applyTheme, Back, Chip, ConfirmSheet, Loading, Shell, storedContrast, storedTheme, Title, toast, useApp, type ThemePref, } from '../ui'; @@ -16,17 +16,9 @@ export function SettingsScreen() { const qc = useQueryClient(); const connQ = useQuery({ queryKey: ['connections'], queryFn: () => api('/api/connections') }); const theme: ThemePref = me.prefs?.theme || storedTheme(); - const pickTheme = (v: string) => { - applyTheme(v as ThemePref); - qc.setQueryData(['me'], (old) => old && { ...old, prefs: { ...old.prefs, theme: v } }); - api('/api/prefs', { method: 'PATCH', body: { prefs: { theme: v } } }).catch(() => toast('Offline — not saved')); - }; - const contrast = me.prefs?.contrast || storedContrast(); - const pickContrast = (v: string) => { - applyContrast(v); - qc.setQueryData(['me'], (old) => old && { ...old, prefs: { ...old.prefs, contrast: v } }); - api('/api/prefs', { method: 'PATCH', body: { prefs: { contrast: v } } }).catch(() => toast('Offline — not saved')); - }; + const themeName = theme === 'light' ? 'Light' : theme === 'system' ? 'Auto' : 'Dark'; + const sex = me.prefs?.sex === 'm' ? 'Male' : me.prefs?.sex === 'f' ? 'Female' : null; + const aboutSub = [sex, me.prefs?.birth_year].filter(Boolean).join(' · ') || 'sex & birth year'; const nigQ = useQuery({ queryKey: ['niggles'], queryFn: () => api('/api/niggles') }); const activeN = (nigQ.data || []).filter((n) => n.status === 'active').length; const exportData = async () => { @@ -38,6 +30,9 @@ export function SettingsScreen() { a.click(); }; const rows: [string, string, () => void][] = [ + ['Appearance', themeName, () => go('set-appearance')], + ['About you', aboutSub, () => go('set-about')], + ['Nutrition', 'targets, cook nights, budgets', () => go('set-food')], ['Units', 'loads, body, labs', () => go('set-units')], ['Connections', connQ.data ? (connQ.data.apple_health.last_push ? 'Apple Health ✓' : 'Apple Health — not seen yet') : '…', () => go('set-conn')], ['Coach', connQ.data?.coach_mcp.active ? 'agent live · Sun 20:00 review' : 'not configured', () => go('set-coach')], @@ -63,9 +58,39 @@ export function SettingsScreen() { border: '1px solid var(--hair)', color: 'var(--volt)', fontWeight: 700 }}>ADMIN )}
- + {rows.map(([label, sub, onClick]) => ( + + ))} + + + + ); +} + +/* ---------------- appearance (theme + accent) ---------------- */ +export function AppearanceScreen() { + const { me, go } = useApp(); + const qc = useQueryClient(); + const theme: ThemePref = me.prefs?.theme || storedTheme(); + const contrast = me.prefs?.contrast || storedContrast(); + const pickTheme = (v: string) => { + applyTheme(v as ThemePref); + qc.setQueryData(['me'], (old) => old && { ...old, prefs: { ...old.prefs, theme: v } }); + api('/api/prefs', { method: 'PATCH', body: { prefs: { theme: v } } }).catch(() => toast('Offline — not saved')); + }; + const pickContrast = (v: string) => { + applyContrast(v); + qc.setQueryData(['me'], (old) => old && { ...old, prefs: { ...old.prefs, contrast: v } }); + api('/api/prefs', { method: 'PATCH', body: { prefs: { contrast: v } } }).catch(() => toast('Offline — not saved')); + }; + return ( + + go('settings')} /> + Appearance
-
Appearance
+
Theme
Contrast
@@ -73,21 +98,15 @@ export function SettingsScreen() { options={[['auto', 'Auto'], ['high', 'More'], ['standard', 'Standard']]} />
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() {
-
Withings +
Withings + style={{ fontSize: 12, margin: 0, flex: 1, minWidth: 0, textAlign: 'right' }}> {c.withings.linked ? (c.withings.warning ? '● Needs re-link' : '● Linked') : c.withings.note}
{c.withings.warning &&
{c.withings.warning}
} @@ -311,8 +429,57 @@ export function ConnectionsScreen() { )}
-
Coach access · MCP - {c.coach_mcp.note}
+
Claude · MCP + + {c.mcp_clients.length ? `● ${c.mcp_clients.length} connected` : 'not connected'}
+
+ Log meals and import recipes from Claude over MCP. In Claude (desktop or web): + Settings → Connectors → Add custom connector, URL: +
+
+ {location.origin}/mcp + +
+
+ Claude opens Forge to sign in and approve — no token to paste. Each approval shows up + below and can be disconnected any time. +
+ {c.mcp_clients.map((g) => ( +
+ + {g.name} + {' · '}{g.last_used_at + ? `used ${new Date(g.last_used_at).toLocaleDateString()}` + : `connected ${new Date(g.connected_at).toLocaleDateString()}`} + + +
+ ))} + {ah.configured && ( + + )} +
+
+
Coach access · MCP + {c.coach_mcp.note}
); @@ -758,6 +925,7 @@ export function ServerScreen() {
+

Users

{uq.data.map((u) => { + setBusy(true); + try { + const r = await api<{ recipes_deleted: number }>('/api/admin/recipes', { method: 'DELETE' }); + qc.invalidateQueries({ queryKey: ['recipes'] }); + qc.invalidateQueries({ queryKey: ['foodweek'] }); + toast(`Cleared ${r.recipes_deleted} recipe${r.recipes_deleted === 1 ? '' : 's'}`, true); + setConfirming(false); + } catch (e: any) { toast(e?.message || 'Failed'); } + setBusy(false); + }; + return ( +
+ Recipe library +
+ 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. +
+ + {confirming && ( + setConfirming(false)} /> + )} +
+ ); +} + function DemoCard() { const qc = useQueryClient(); const q = useQuery<{ exists: boolean }>({ queryKey: ['admin-demo'], queryFn: () => api('/api/admin/demo') }); @@ -810,6 +1014,15 @@ function DemoCard() {
{exists ? (
+ {dayplan}
@@ -270,7 +306,17 @@ export function PlanScreen() { {d.day_name} {dayNum(d.date)} - {d.name || 'Rest'} + + {d.name || 'Rest'} + {(() => { + const din = dinnerFor(food, d.date); + return din ? + {' · '}{din.slug && !din.label.includes('leftovers') + ? `dinner: ${din.label.split(',')[0].split(' —')[0].toLowerCase()}` + : din.label.includes('leftovers') ? 'dinner: leftovers' : din.label} + : null; + })()} + {wasMissed ? Missed @@ -494,6 +540,43 @@ function budgetDefault(t?: Today): number { return Math.min(75, Math.max(25, Math.ceil(full / 5) * 5)); } +/** Swipe left/right (and ←/→ on a keyboard) pages the day view through the + calendar — the same navigation as the ‹ › buttons. Vertical scrolls and + drags that start on inputs (the budget slider) never trigger it. */ +function useDayPaging(shift: (d: number) => void) { + const fn = useRef(shift); + fn.current = shift; + useEffect(() => { + let sx = 0, sy = 0, live = false; + const start = (e: TouchEvent) => { + const el = e.target as HTMLElement; + live = !el.closest('input, textarea, .overlay'); + sx = e.touches[0].clientX; sy = e.touches[0].clientY; + }; + const end = (e: TouchEvent) => { + if (!live) return; + live = false; + const dx = e.changedTouches[0].clientX - sx; + const dy = e.changedTouches[0].clientY - sy; + if (Math.abs(dx) > 60 && Math.abs(dx) > 2 * Math.abs(dy)) fn.current(dx < 0 ? 1 : -1); + }; + const key = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null; + if (el?.closest?.('input, textarea')) return; + if (e.key === 'ArrowRight') fn.current(1); + else if (e.key === 'ArrowLeft') fn.current(-1); + }; + document.addEventListener('touchstart', start, { passive: true }); + document.addEventListener('touchend', end, { passive: true }); + document.addEventListener('keydown', key); + return () => { + document.removeEventListener('touchstart', start); + document.removeEventListener('touchend', end); + document.removeEventListener('keydown', key); + }; + }, []); +} + export function DayScreen() { const { go, budget, setBudget, startSession, me, dayDate } = useApp(); const [viewDate, setViewDate] = useState(dayDate); @@ -501,13 +584,19 @@ export function DayScreen() { const debounce = useRef>(undefined); const t = q.data; + // Anchor paging on the server's idea of today (Europe/London), not the + // device clock — around midnight they disagree and ± would skip a day. + const srvToday = useRef(todayISO()); + if (!viewDate && t) srvToday.current = t.date; + const shift = (d: number) => { + const next = addDaysISO(viewDate || srvToday.current, d); + setViewDate(next === srvToday.current ? null : next); + }; + useDayPaging(shift); + if (!t) return ; const isOther = !!viewDate; - const shift = (d: number) => { - const next = addDaysISO(viewDate || todayISO(), d); - setViewDate(next === todayISO() ? null : next); - }; const head = (
diff --git a/web/src/styles.css b/web/src/styles.css index 6c508de..1608cf7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -97,6 +97,11 @@ input{ font:inherit; font-size:16px; color:var(--ink); } /* ≥16px everywhere o .hdr .wm{ font-size:17px; letter-spacing:.05em; font-weight:800; } .hdr .wm i{ font-style:normal; color:var(--volt); } .hdr .sp{ flex:1; } +/* beta-branch marker: rides next to every wordmark so the beta stack is + unmistakable at a glance. Dropped when features merge to main. */ +.betatag{ font-size:9.5px; font-weight:800; letter-spacing:.14em; color:var(--volt); + background:var(--volt-dim); border-radius:6px; padding:3px 7px; margin-left:8px; + align-self:center; } .avatar{ width:32px; height:32px; border-radius:50%; background:var(--volt); color:var(--on-volt); display:flex; align-items:center; justify-content:center; font-weight:800; font-size:15px; } .avatar.neutral{ background:var(--neutral); color:var(--ink); } @@ -142,7 +147,7 @@ h2.title{ font-size:26px; line-height:1.08; margin:2px 0 0; font-weight:650; let .dimrow{ color:var(--mut); } .dimrow b{ font-weight:500; } .dimrow .target{ color:inherit; } -.dimrow .glyphslot, .dimrow svg, .dimrow img{ opacity:.55; } +.dimrow .glyphslot, .dimrow .mtick, .dimrow svg, .dimrow img{ opacity:.55; } /* status is a tag, not a paint job */ .mtag{ background:color-mix(in srgb, var(--warn) 16%, transparent); color:var(--warn); @@ -161,8 +166,8 @@ h2.title{ font-size:26px; line-height:1.08; margin:2px 0 0; font-weight:650; let .lt .lsub{ display:block; font-size:13px; color:var(--mut); margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .group{ background:var(--raised); border-radius:16px; padding:0 15px; overflow:hidden; } -.group .lrow{ border-top-color:var(--hair-in); } -.group > *:first-child .lrow, .group > .lrow:first-child{ border-top:0; } +.group .lrow, .group .mealrow{ border-top-color:var(--hair-in); } +.group > *:first-child .lrow, .group > .lrow:first-child, .group > .mealrow:first-child{ border-top:0; } .group .swipe-fg{ background:var(--raised); } /* paper needs the depth the void gets from luminance for free */ @@ -480,6 +485,75 @@ button.fpill{ border:none; font:inherit; cursor:pointer; } width:100%; text-align:left; } .warnbanner b{ flex:1; font-weight:600; } +/* ---- Food tab (beta track, Phase 7): meters, meal rows, cook mode ---- */ +/* compact macro grid — 8 macros in two dense rows of four */ +.macrogrid{ display:grid; grid-template-columns:repeat(4,1fr); gap:11px 12px; } +.mcell{ min-width:0; } +.mcl{ font-size:9.5px; text-transform:uppercase; letter-spacing:.05em; color:var(--mut); + white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.mcv{ font-size:14px; line-height:1.1; margin:2px 0 5px; white-space:nowrap; } +.mcv b{ font-weight:700; color:var(--volt); } +.mcv b.warn{ color:var(--warn); } +.mcv span{ color:var(--mut); font-weight:300; } +.mtrack{ height:5px; border-radius:3px; background:var(--sunken); margin-top:6px; overflow:hidden; } +.mtrack i{ display:block; height:100%; border-radius:3px; background:var(--volt); opacity:.9; + transition:width .35s ease; } + +.mealrow{ display:flex; align-items:center; gap:10px; border-top:1px solid var(--hair); + padding:11px 2px; font-size:15px; width:100%; text-align:left; } +.mealrow .mname{ flex:1; min-width:0; } +.mealrow .mname b{ font-weight:550; } +.mealrow .mname > .sub{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.mtick{ width:24px; height:24px; border-radius:50%; background:var(--sunken); color:var(--dim); + box-shadow:inset 0 0 0 1.5px var(--hair-in); + display:flex; align-items:center; justify-content:center; font-size:12px; flex:none; } +.mtick.done{ background:var(--volt); color:var(--on-volt); font-weight:800; box-shadow:none; } +.mealrow .dcol{ width:34px; flex:none; text-align:center; } +.mealrow .dcol .dn{ display:block; font-size:10px; color:var(--mut); text-transform:uppercase; + letter-spacing:.06em; } +.mealrow .dcol .dd{ display:block; font-size:16px; } + +.ingrow{ display:flex; align-items:baseline; gap:10px; border-top:1px solid var(--hair); + padding:9px 2px; font-size:14px; } +.ingrow:first-child{ border-top:0; } +.ingname{ flex:1 1 auto; min-width:0; overflow-wrap:anywhere; } +.ingnote{ display:block; margin-top:2px; font-size:11.5px; line-height:1.35; + color:var(--volt); overflow-wrap:anywhere; } +.ingamt{ flex:0 0 auto; max-width:46%; overflow-wrap:anywhere; display:flex; + flex-direction:column; align-items:flex-end; gap:1px; text-align:right; } +.ingtag{ font-size:9.5px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim); } +/* cook-mode background timers — persist across steps, run in parallel */ +.timerstrip{ display:flex; gap:8px; overflow-x:auto; padding:2px 0 4px; -webkit-overflow-scrolling:touch; } +.timerpill{ flex:0 0 auto; display:flex; flex-direction:column; gap:1px; text-align:left; + background:var(--raised); border-radius:12px; padding:7px 12px; min-width:104px; } +.timerpill.cur{ outline:2px solid var(--volt); } +.timerpill.done{ opacity:.6; } +.timerpill .tp-title{ font-size:11.5px; color:var(--mut); max-width:150px; overflow:hidden; + text-overflow:ellipsis; white-space:nowrap; } +.timerpill .tp-time{ font-size:16px; font-weight:700; color:var(--volt); } +.timerpill.done .tp-time{ color:var(--mut); } +.steprow{ display:flex; gap:10px; border-top:1px solid var(--hair); padding:10px 2px; font-size:14px; } +.steprow:first-child{ border-top:0; } +.steprow .n{ color:var(--volt); font-weight:700; width:16px; flex:none; } + +.dinline{ display:flex; justify-content:space-between; gap:8px; border-top:1px solid var(--hair); + margin-top:11px; padding-top:10px; font-size:13px; color:var(--mut); } + +.bigsub{ color:var(--mut); font-size:15px; line-height:1.55; } +.stepdots{ display:flex; gap:7px; align-items:center; margin:2px 0; } +.sdot{ width:30px; height:30px; border-radius:50%; background:var(--sunken); color:var(--dim); + display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:600; flex:none; } +.sdot.done{ background:var(--volt); color:var(--on-volt); font-weight:800; } +.sdot.cur{ background:var(--volt-dim); color:var(--volt); box-shadow:inset 0 0 0 1.6px var(--volt); } +.steplab{ flex:1; text-align:right; color:var(--dim); font-size:10.5px; letter-spacing:.1em; + text-transform:uppercase; } +.ringcook{ display:flex; flex-direction:column; align-items:center; gap:10px; padding:6px 0; } +.ringcook .ringnum{ font-weight:300; font-size:34px; letter-spacing:-.01em; fill:var(--ink); } +.ringcook .ringcap{ font-size:9.5px; fill:var(--mut); letter-spacing:.1em; text-transform:uppercase; } + +/* run map (MapLibre) — sizes the live-map container; taller on desktop below */ +.runmap{ height:260px; border-radius:12px; overflow:hidden; background:var(--sunken); } + /* ============== responsive: small phones → tablets → desktop ============== Same DOM at every size — this is all CSS. ≤380px compact type + padding so nothing crowds or wraps @@ -495,20 +569,52 @@ button.fpill{ border:none; font:inherit; cursor:pointer; } .statchip{ flex:1 1 74px; padding:6px 8px; } .fchip{ font-size:11px; } } -@media (min-width:640px){ .app{ max-width:600px; } } +@media (min-width:640px){ + .app{ max-width:600px; } + /* the space outside the column stops being a void: two soft accent glows, + tinted by the active palette (they ride --volt-dim, so they recolor live) */ + body{ background: + radial-gradient(1100px 700px at 76% -12%, var(--volt-dim), transparent 62%), + radial-gradient(900px 640px at -14% 108%, var(--volt-dim), transparent 58%), + var(--outer); } +} @media (min-width:900px) and (min-height:520px){ - .app{ max-width:none; border-left:none; border-right:none; font-size:16px; } + .app{ max-width:none; border-left:none; border-right:none; font-size:16px; + /* same glows paint the full-bleed canvas behind the reading column */ + background: + radial-gradient(1100px 700px at 76% -12%, var(--volt-dim), transparent 62%), + radial-gradient(900px 640px at -14% 108%, var(--volt-dim), transparent 58%), + var(--bg); } /* every band of the shell (header, offline strip, scroll body, footer bars) caps to a reading column; the rail is the one full-height exception */ - .app > *:not(.tabs){ width:100%; max-width:700px; margin-left:auto; margin-right:auto; } - .app:has(> .tabs){ padding-left:88px; } /* room for the rail when signed in */ - .tabs{ position:fixed; left:0; top:0; bottom:0; width:88px; - flex-direction:column; justify-content:center; gap:16px; - border-top:none; border-right:1px solid var(--hair); - padding:14px 6px calc(14px + env(safe-area-inset-bottom)) 6px; } - .tab{ flex:0 0 auto; padding:9px 0; gap:5px; font-size:11px; } + .app > *:not(.tabs){ width:100%; max-width:720px; margin-left:auto; margin-right:auto; } + .app:has(> .tabs){ padding-left:92px; } /* room for the rail when signed in */ + /* brand lives in the rail on desktop — hide the duplicate header wordmark + (only when the rail exists: signed-out screens keep theirs) */ + .app:has(> .tabs) .hdr .wm{ display:none; } + .tabs{ position:fixed; left:0; top:0; bottom:0; width:92px; + flex-direction:column; justify-content:flex-start; gap:6px; + border-top:none; border-right:1px solid var(--hair); background:var(--bg); + padding:18px 10px calc(14px + env(safe-area-inset-bottom)); } + .tabs::before{ content:"F."; display:block; text-align:center; margin:2px 0 18px; + font-size:23px; font-weight:800; letter-spacing:.05em; color:var(--volt); } + .tab{ flex:0 0 auto; padding:10px 0 8px; gap:5px; font-size:11px; border-radius:14px; } .tab svg{ width:24px; height:24px; } - .scroll{ padding-top:12px; } - .overlay{ align-items:center; padding:24px; } - .sheet{ border-radius:22px; padding-bottom:20px; } + .tab.on{ background:var(--sel); } + .scroll{ padding-top:14px; gap:12px; } + .overlay{ align-items:center; padding:24px; background:rgba(0,0,0,.45); + backdrop-filter:blur(7px); -webkit-backdrop-filter:blur(7px); } + .runmap{ height:360px; } + .sheet{ border-radius:22px; padding-bottom:20px; box-shadow:0 24px 80px rgba(0,0,0,.45); } + .statchips{ gap:8px; } +} +/* pointer machines only — keeps iPad taps from sticking in a hover state */ +@media (min-width:900px) and (min-height:520px) and (hover:hover){ + .tab:hover{ color:var(--mut); background:var(--off); } + .tab.on:hover{ color:var(--volt); background:var(--sel); } +} +@media (min-width:1240px) and (min-height:520px){ + .app > *:not(.tabs){ max-width:780px; } + .app:has(> .tabs){ padding-left:104px; } + .tabs{ width:104px; } } diff --git a/web/src/ui.tsx b/web/src/ui.tsx index c2416a7..877f132 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -12,12 +12,14 @@ export function toast(text: string, volt = false) { } /* ---------------- navigation / app context ---------------- */ -export type Tab = 'today' | 'history' | 'progress' | 'coach'; +export type Tab = 'today' | 'food' | 'history' | 'progress' | 'coach'; export type Screen = | 'today' | 'day' | 'learn' | 'log' | 'swap' | 'cooldown' | 'summary' + | 'food' | 'food-week' | 'recipes' | 'recipe' | 'cook' | 'history' | 'detail' | 'progress' | 'records' | 'metric' | 'coach' | 'settings' | 'set-conn' | 'set-equip' | 'set-niggles' | 'set-labs' | 'library' | 'set-notif' - | 'set-coach' | 'set-units' | 'set-server' | 'set-coach-usage'; + | 'set-coach' | 'set-units' | 'set-server' | 'set-coach-usage' + | 'set-food' | 'set-appearance' | 'set-about'; export interface LoggedSetLocal { weight: number; reps: number; rpe: number | null; } // weight in kg /** unit = display unit for this lift; target weights stay kg, LogState.w is display-unit. */ @@ -42,9 +44,12 @@ export interface AppCtxType { screen: Screen; tab: Tab; learnSlug: string; learnFrom: Screen; detailId: string; lift: string; dayDate: string | null; + foodSlug: string; foodDate: string | null; + foodFrom: Screen; // where the recipe detail's Back returns to ('food' | 'recipes') + foodReplaceDate: string | null; // library in "replace this date's dinner" mode chatContext: ChatContext | null; setChatContext: (c: ChatContext | null) => void; - go: (s: Screen, extra?: Partial>) => void; + go: (s: Screen, extra?: Partial>) => void; openTab: (t: Tab) => void; budget: number | null; setBudget: (n: number) => void; @@ -96,6 +101,7 @@ export function useOnline(): boolean { /* ---------------- chrome components ---------------- */ const ICONS: Record = { today: <>, + food: <>, history: <>, progress: , coach: , @@ -107,7 +113,7 @@ export function Header() { return ( <>
- FORGE. + FORGE.BETA @@ -117,7 +123,9 @@ export function Header() { ); } -const TAB_LABELS: Record = { today: 'Plan', history: 'History', progress: 'Progress', coach: 'Coach' }; +const TAB_LABELS: Record = { + today: 'Plan', food: 'Food', history: 'History', progress: 'Progress', coach: 'Coach', +}; /** Volt dot on the Coach tab when the last coach message is newer than the last visit. */ function useCoachUnread(active: boolean): boolean { @@ -134,7 +142,7 @@ export function Tabs() { const unread = useCoachUnread(tab === 'coach'); return (