From eb402c767b97007a23bb5188fc19f839169fa7ae Mon Sep 17 00:00:00 2001 From: James Atkinson Date: Mon, 20 Jul 2026 20:26:34 -0700 Subject: [PATCH 01/43] docs(nutrition): E16 stories, Phases 7-9 beta track, mockups 38-42 Nutrition design for the beta branch: all-meals food weeks proposed by the coach, plan-first one-tap logging, carry-over waste loop, aisle-grouped shopping list with Amazon Fresh export (auto-cart Phase 9), MealPal/Grubhub lunch assist, household dinner semantics. Targets tuned for cholesterol: protein, fiber, sat-fat cap. Co-Authored-By: Claude Fable 5 --- docs/implementation-plan.md | 44 ++++++ docs/mockups/38-food-today-options.html | 149 ++++++++++++++++++++ docs/mockups/39-food-week.html | 153 +++++++++++++++++++++ docs/mockups/40-recipe-and-lunch.html | 133 ++++++++++++++++++ docs/mockups/41-food-proposal.html | 131 ++++++++++++++++++ docs/mockups/42-shopping-and-settings.html | 153 +++++++++++++++++++++ docs/user-stories.md | 56 ++++++++ 7 files changed, 819 insertions(+) create mode 100644 docs/mockups/38-food-today-options.html create mode 100644 docs/mockups/39-food-week.html create mode 100644 docs/mockups/40-recipe-and-lunch.html create mode 100644 docs/mockups/41-food-proposal.html create mode 100644 docs/mockups/42-shopping-and-settings.html diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 1ccb8c1..3194ac0 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -91,6 +91,50 @@ 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–42`. 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.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 (easy/medium, batchable, ingredient-overlapping) + ingredient macro table; 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), week view, recipe detail; 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. + +## 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 + +- 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`. +- 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; HelloFresh paused. + +## 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/mockups/38-food-today-options.html b/docs/mockups/38-food-today-options.html new file mode 100644 index 0000000..2a095e6 --- /dev/null +++ b/docs/mockups/38-food-today-options.html @@ -0,0 +1,149 @@ + + + + + +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 · fiber 11
7:40
+
MealPal — shawarma bowl
Lunch · ordered · 640 kcal · P 52 · sat 5
12:50
+
Apple + peanut butter
Snack · 210 kcal · fiber 4
15:30
+
Salmon, puy lentils & broccoli
Dinner · 20 min · P +38 · fiber +9
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 + uses: lentils · lemon +
+
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..6f14793 --- /dev/null +++ b/docs/mockups/39-food-week.html @@ -0,0 +1,153 @@ + + + + + +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..faccbec --- /dev/null +++ b/docs/mockups/40-recipe-and-lunch.html @@ -0,0 +1,133 @@ + + + + + +Recipe detail + lunch assist + + + + +
+
Recipe detail · macros + the cholesterol trio per serving, a "why it's in your week" line,
carry-over badges on ingredients, batch callout, one-tap log
+
+
‹ 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
+
+
1Toss thighs, chickpeas, peppers, onion in harissa + oil.
+
2Roast 22 min at 220°. No turning, no stress.
+
3Fold spinach through off the heat; lemon over.
+
4Box half before plating — that's Wednesday done.
+
+ +
Cooked it — log dinner ×2 plates
+
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..febce0f --- /dev/null +++ b/docs/mockups/41-food-proposal.html @@ -0,0 +1,131 @@ + + + + + +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
+
+ +
+
MonHarissa chicken traybake ×2
uses your spinach before Tuesday
+
TueSalmon, puy lentils & broccoli
omega-3 day; lentils carry fiber
+
WedTraybake leftovers
zero-cook on your run day
+
ThuTurkey & black-bean chili
batch feeds Friday lunch
+
FriOut — 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/user-stories.md b/docs/user-stories.md index 7d6baa0..be9872d 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -234,3 +234,59 @@ As James, I want to chat with my coach anytime so that questions and data entry **E15.3 — Export** - AC1: Authenticated JSON export of all of a user's own data (and only theirs). + +## E16 · Nutrition *(beta track — designed Jul 2026, mockups 38–42)* + +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. + +**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.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). From 5706a3aeac24f403dce6feb9ac3a647db8e80863 Mon Sep 17 00:00:00 2001 From: James Atkinson Date: Mon, 20 Jul 2026 20:46:14 -0700 Subject: [PATCH 02/43] docs(nutrition): plate-art visuals, done-when steps, cook mode (E16.10) Palette-native SVG plate illustrations (platefig, the formfig approach) as thumbnails on day/week/proposal rows and heroes on recipe screens; method steps rewritten with durations and done-when checkpoints; new cook mode - one step per screen, local countdown ring reusing the rest-ring pattern, batch checkpoints, finish logs the dinner. New mockup 43-cook-mode.html. Co-Authored-By: Claude Fable 5 --- docs/implementation-plan.md | 8 +- docs/mockups/38-food-today-options.html | 49 ++++++-- docs/mockups/39-food-week.html | 8 ++ docs/mockups/40-recipe-and-lunch.html | 53 ++++++-- docs/mockups/41-food-proposal.html | 21 +++- docs/mockups/43-cook-mode.html | 157 ++++++++++++++++++++++++ docs/user-stories.md | 11 +- 7 files changed, 280 insertions(+), 27 deletions(-) create mode 100644 docs/mockups/43-cook-mode.html diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 3194ac0..d237985 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -93,16 +93,16 @@ 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–42`. 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. +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–43`. 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.8 (schema + scoping) +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 (easy/medium, batchable, ingredient-overlapping) + ingredient macro table; insert-missing like the exercise seed. +- Seed ~40 cholesterol-aligned recipes (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), week view, recipe detail; dinner lines woven into Plan's hero card + day rows; offline tick queue alongside the set queue. +- **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. diff --git a/docs/mockups/38-food-today-options.html b/docs/mockups/38-food-today-options.html index 2a095e6..ca1ff56 100644 --- a/docs/mockups/38-food-today-options.html +++ b/docs/mockups/38-food-today-options.html @@ -45,6 +45,8 @@ .tick.done{ background:var(--volt); color:var(--on-volt); font-weight:800; } .meal .nm{ flex:1; min-width:0; } .meal .rt{ color:var(--mut); font-size:11px; white-space:nowrap; } + .mthumb{ width:27px; height:27px; flex:none; } + .heroplate{ width:88px; flex:none; } .slot{ color:var(--dim); font-size:9.5px; letter-spacing:.1em; text-transform:uppercase; } .fchips{ display:flex; flex-wrap:wrap; gap:5px; margin-top:7px; } @@ -85,10 +87,18 @@

Food

-
Oats №1 + yogurt
Breakfast · 420 kcal · P 34 · fiber 11
7:40
-
MealPal — shawarma bowl
Lunch · ordered · 640 kcal · P 52 · sat 5
12:50
-
Apple + peanut butter
Snack · 210 kcal · fiber 4
15:30
-
Salmon, puy lentils & broccoli
Dinner · 20 min · P +38 · fiber +9
Cook ›
+
+ + 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
@@ -110,11 +120,32 @@

Food

Food

-
Tonight · 20 min · easy
-
Salmon, puy lentils & long-stem broccoli
-
- P 38fiber 9sat 3.5 - uses: lentils · lemon +
+
+
Tonight · 20 min · easy
+
Salmon, puy lentils & long-stem broccoli
+
+ P 38fiber 9sat 3.5 +
+
+ + + + + + + + + + + + + + + + + +
Start cooking
diff --git a/docs/mockups/39-food-week.html b/docs/mockups/39-food-week.html index 6f14793..1b18196 100644 --- a/docs/mockups/39-food-week.html +++ b/docs/mockups/39-food-week.html @@ -38,6 +38,7 @@ .dcol .dd{ font-weight:300; font-size:15px; } .day .nm{ flex:1; min-width:0; } .day .rt{ color:var(--mut); font-size:11px; white-space:nowrap; } + .pthumb{ width:30px; height:30px; flex:none; } .day.today{ background:var(--volt-dim); border-radius:12px; padding:10px 8px; border-top:none; } .day.today+.day{ border-top:none; } .slotdots{ display:flex; gap:3px; margin-top:4px; } @@ -88,18 +89,25 @@

Food week

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
diff --git a/docs/mockups/40-recipe-and-lunch.html b/docs/mockups/40-recipe-and-lunch.html index faccbec..9482e8e 100644 --- a/docs/mockups/40-recipe-and-lunch.html +++ b/docs/mockups/40-recipe-and-lunch.html @@ -41,9 +41,14 @@ .ing+.ing{ border-top:1px solid var(--hair); } .ing .q{ color:var(--mut); font-size:11.5px; white-space:nowrap; } - .step{ display:flex; gap:10px; padding:7px 2px; font-size:12.5px; } + .step{ display:flex; gap:10px; padding:8px 2px; font-size:12.5px; } .step+.step{ border-top:1px solid var(--hair); } .step .n{ color:var(--volt); font-weight:700; width:14px; flex:none; } + .step .t{ font-weight:650; } + .step .tm{ color:var(--mut); font-weight:400; font-size:11px; } + .heroart{ width:100%; height:auto; display:block; margin:2px 0 8px; } + .quicklog{ text-align:center; color:var(--mut); font-size:11.5px; margin-top:9px; } + .quicklog b{ color:var(--volt); font-weight:600; } .bub{ background:var(--raised); border-radius:14px 14px 14px 4px; padding:10px 12px; font-size:12.5px; margin:6px 0; max-width:92%; } @@ -61,7 +66,7 @@
-
Recipe detail · macros + the cholesterol trio per serving, a "why it's in your week" line,
carry-over badges on ingredients, batch callout, one-tap log
+
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
@@ -73,6 +78,36 @@

Harissa chicken traybake

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.
@@ -86,15 +121,17 @@

Harissa chicken traybake

Olive oil · cumin · lemonpantry
-
Method
+
Method · 5 steps
-
1Toss thighs, chickpeas, peppers, onion in harissa + oil.
-
2Roast 22 min at 220°. No turning, no stress.
-
3Fold spinach through off the heat; lemon over.
-
4Box half before plating — that's Wednesday done.
+
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.
-
Cooked it — log dinner ×2 plates
+
Cook step-by-step · 25 min
+
Already cooked? Log it in one tap
Swap this dinner
diff --git a/docs/mockups/41-food-proposal.html b/docs/mockups/41-food-proposal.html index febce0f..147024c 100644 --- a/docs/mockups/41-food-proposal.html +++ b/docs/mockups/41-food-proposal.html @@ -43,6 +43,7 @@ .dayrow{ display:flex; gap:8px; padding:6px 0; font-size:12px; align-items:baseline; } .dayrow+.dayrow{ border-top:1px solid var(--hair); } .dayrow .d{ width:32px; color:var(--dim); text-transform:uppercase; font-size:10px; letter-spacing:.06em; flex:none; } + .dayrow .dt{ width:21px; height:21px; flex:none; align-self:center; } .dayrow .nm{ flex:1; } .dayrow .why{ color:var(--mut); font-size:10.5px; } .btnrow{ display:flex; gap:8px; margin-top:12px; } @@ -90,11 +91,21 @@

Sunday review

-
MonHarissa chicken traybake ×2
uses your spinach before Tuesday
-
TueSalmon, puy lentils & broccoli
omega-3 day; lentils carry fiber
-
WedTraybake leftovers
zero-cook on your run day
-
ThuTurkey & black-bean chili
batch feeds Friday lunch
-
FriOut — no plan, no guilt
headroom already banked
+
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 ▾
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/user-stories.md b/docs/user-stories.md index be9872d..f8917e1 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -235,7 +235,7 @@ As James, I want to chat with my coach anytime so that questions and data entry **E15.3 — Export** - AC1: Authenticated JSON export of all of a user's own data (and only theirs). -## E16 · Nutrition *(beta track — designed Jul 2026, mockups 38–42)* +## E16 · Nutrition *(beta track — designed Jul 2026, mockups 38–43)* 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. @@ -250,6 +250,8 @@ As the Coach, I want a curated recipe library so that plans only contain meals w - 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. @@ -286,6 +288,13 @@ As James, I want help choosing a healthy work lunch within budget so that orderi - 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.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"). From 146e302c71602c9646aade54e2c426e0cf7633d8 Mon Sep 17 00:00:00 2001 From: James Atkinson Date: Mon, 20 Jul 2026 20:57:35 -0700 Subject: [PATCH 03/43] =?UTF-8?q?docs(nutrition):=20recipe=20sourcing=20?= =?UTF-8?q?=E2=80=94=20import-on-ask=20from=20BBC=20Good=20Food=20+=20Hell?= =?UTF-8?q?oFresh=20cards=20(E16.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coach proposes only from the in-app pool; the pool grows via chat imports: paste a BBC Good Food link (single-page fetch, schema.org JSON-LD parse) or photograph HelloFresh cards (vision). Imports are normalized not copied - ingredients mapped, macros recomputed + cross-checked, steps rewritten in Forge voice with source attribution, adapt-don't-reject for sat-fat-heavy cards. Card-box amnesty before the HelloFresh cancel. Mockup 44. Co-Authored-By: Claude Fable 5 --- docs/implementation-plan.md | 11 +-- docs/mockups/44-recipe-import.html | 121 +++++++++++++++++++++++++++++ docs/user-stories.md | 10 ++- 3 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 docs/mockups/44-recipe-import.html diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index d237985..b8bd4b3 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -93,14 +93,14 @@ 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–43`. 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. +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 (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. +- 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). @@ -109,14 +109,15 @@ Stories: E16.1, E16.2, E16.4, E16.10, E16.8 (schema + scoping) ## 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 +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`. +- 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; HelloFresh paused. +**Exit:** one Sunday review yields two approved weeks; the list matches the fridge; the card box is digitized and HelloFresh paused. ## Phase 9 — Ordering assist & automation **Goal: the out-of-house meals meet the plan; the shop orders itself (almost).** 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/user-stories.md b/docs/user-stories.md index f8917e1..8b7f22d 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -235,7 +235,7 @@ As James, I want to chat with my coach anytime so that questions and data entry **E15.3 — Export** - AC1: Authenticated JSON export of all of a user's own data (and only theirs). -## E16 · Nutrition *(beta track — designed Jul 2026, mockups 38–43)* +## 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. @@ -295,6 +295,14 @@ As James, I want cooking walked one step at a time so that the phone works propp - 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"). From fa2cc7c26c0cd0ee8af78179b580a7256e0871cf Mon Sep 17 00:00:00 2001 From: James Atkinson Date: Mon, 20 Jul 2026 21:13:11 -0700 Subject: [PATCH 04/43] feat(food): Phase 7 server slice - schema, seed, routes (E16.2/4/8) Seven nutrition tables (create_all-provisioned, no column changes to existing ones): recipes, ingredients, meal_revisions, meal_log, carryovers, lunch_favorites. Food week is household-scoped (user_id NULL) with the demo seat walled into its own scope; meal logs strictly per-user with macro snapshots and client_id idempotency for the offline queue. Seed: 58-item ingredient macro table, 25 recipes (16 dinners, 3 breakfasts, 3 lunches, 3 snacks) in the HelloFresh-card format with done-when steps and platefig composition ids, per-user nutrition prefs defaults, and the hand-written first household food week (batch traybake Mon->Wed, chili batch->Fri lunch, Fri night out, order-assist lunch slots Tue-Thu). Routes: GET /api/food/week (rolling 7-day, resolves leftovers/order/out slots, per-day logged totals), GET /api/food/recipes/{slug} (steps + aisle-joined ingredients), POST /api/food/log + DELETE (one-tap tick, off-plan entries validated). Meals added to /api/export. Tests 33 -> 35. Co-Authored-By: Claude Fable 5 --- server/app/food_seed.py | 659 +++++++++++++++++++++++++++++++++++++ server/app/main.py | 3 +- server/app/models.py | 115 +++++++ server/app/routers/food.py | 204 ++++++++++++ server/app/routers/misc.py | 7 +- server/app/seed.py | 5 + server/tests/test_smoke.py | 84 +++++ 7 files changed, 1075 insertions(+), 2 deletions(-) create mode 100644 server/app/food_seed.py create mode 100644 server/app/routers/food.py diff --git a/server/app/food_seed.py b/server/app/food_seed.py new file mode 100644 index 0000000..ccc9d84 --- /dev/null +++ b/server/app/food_seed.py @@ -0,0 +1,659 @@ +"""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 + +DEFAULT_TARGETS = {"kcal": 2300, "protein_g": 160, "fiber_g": 38, "satfat_g": 18} +NUTRITION_PREF_DEFAULTS = { + "nutrition_targets": DEFAULT_TARGETS, + "cook_nights": 4, + "budget_grocery": 110, + "budget_lunch": 15, + "household_dinners": True, +} + +# name, aisle, unit, typical pack, kcal/100, protein/100, fiber/100, satfat/100, pantry +INGREDIENTS = [ + # produce + ("chicken thighs, skinless", "protein", "g", "650 g tray", 121, 20, 0, 1.1, 0), + ("chicken breast", "protein", "g", "500 g tray", 106, 22, 0, 0.4, 0), + ("salmon fillets", "protein", "x", "2 × 130 g", 232, 25, 0, 2.6, 0), + ("cod fillets", "protein", "x", "2 × 140 g", 96, 21, 0, 0.1, 0), + ("prawns, raw", "protein", "g", "300 g bag", 85, 18, 0, 0.2, 0), + ("turkey mince 5%", "protein", "g", "500 g pack", 120, 22, 0, 0.9, 0), + ("tuna in spring water", "cupboard", "x", "145 g tin", 116, 26, 0, 0.1, 0), + ("eggs", "protein", "x", "box of 12", 74, 6.5, 0, 1.1, 0), + ("greek yogurt 0%", "dairy", "g", "1 kg tub", 57, 10, 0, 0.1, 0), + ("feta", "dairy", "g", "200 g block", 264, 14, 0, 10.9, 0), + ("halloumi light", "dairy", "g", "225 g block", 255, 24, 0, 10.5, 0), + ("parmesan", "dairy", "g", "wedge", 392, 32, 0, 13.7, 1), + ("peppers", "produce", "x", "3-pack", 31, 1, 2.1, 0, 0), + ("red onions", "produce", "x", "net of 4", 40, 1.1, 1.7, 0, 0), + ("spinach", "produce", "g", "250 g bag", 23, 2.9, 2.2, 0.1, 0), + ("long-stem broccoli", "produce", "g", "200 g pack", 35, 3, 3, 0.1, 0), + ("spring greens", "produce", "g", "200 g bag", 33, 3, 3.4, 0.1, 0), + ("courgettes", "produce", "x", "3-pack", 17, 1.2, 1, 0.1, 0), + ("cherry tomatoes", "produce", "g", "500 g pack", 18, 0.9, 1.2, 0, 0), + ("sweet potatoes", "produce", "g", "1 kg bag", 86, 1.6, 3, 0, 0), + ("baking potatoes", "produce", "x", "4-pack", 93, 2.5, 2.2, 0, 0), + ("lemons", "produce", "x", "3-pack", 29, 1.1, 2.8, 0, 0), + ("limes", "produce", "x", "3-pack", 30, 0.7, 2.8, 0, 0), + ("ginger", "produce", "g", "root", 80, 1.8, 2, 0.1, 0), + ("garlic", "produce", "x", "bulb", 149, 6.4, 2.1, 0.1, 1), + ("coriander", "produce", "x", "bunch", 23, 2.1, 2.8, 0, 0), + ("dill", "produce", "x", "pot", 43, 3.5, 2.1, 0, 0), + ("avocado", "produce", "x", "each", 160, 2, 6.7, 2.1, 0), + ("apples", "produce", "x", "6-pack", 52, 0.3, 2.4, 0, 0), + ("berries, mixed", "produce", "g", "400 g punnet", 43, 0.7, 3.8, 0, 0), + ("bananas", "produce", "x", "bunch", 89, 1.1, 2.6, 0.1, 0), + # cupboard + ("chickpeas", "cupboard", "x", "400 g tin", 115, 6.3, 5.4, 0.2, 0), + ("black beans", "cupboard", "x", "400 g tin", 91, 6, 6.5, 0.1, 0), + ("white beans", "cupboard", "x", "400 g tin", 90, 5.4, 5.6, 0.1, 0), + ("kidney beans", "cupboard", "x", "400 g tin", 84, 5.2, 6.4, 0.1, 0), + ("puy lentils", "cupboard", "g", "250 g pouch", 116, 9, 7.9, 0.1, 0), + ("red lentils, dry", "cupboard", "g", "500 g bag", 352, 25, 11, 0.2, 1), + ("chopped tomatoes", "cupboard", "x", "400 g tin", 32, 1.2, 1.3, 0, 0), + ("passata", "cupboard", "ml", "500 g carton", 32, 1.4, 1.1, 0, 0), + ("harissa paste", "cupboard", "g", "185 g jar", 130, 3, 6, 0.6, 0), + ("miso paste", "cupboard", "g", "tub", 199, 12, 5.4, 0.4, 1), + ("olives", "cupboard", "g", "160 g jar", 145, 1, 3.3, 1.6, 0), + ("capers", "cupboard", "g", "jar", 23, 2.4, 3.2, 0.1, 1), + ("wholewheat spaghetti", "cupboard", "g", "500 g bag", 348, 13, 10, 0.3, 0), + ("soba noodles", "cupboard", "g", "250 g pack", 336, 14, 3, 0.1, 0), + ("brown rice", "cupboard", "g", "1 kg bag", 362, 7.5, 3.4, 0.5, 1), + ("bulgur wheat", "cupboard", "g", "500 g bag", 342, 12, 12.5, 0.2, 1), + ("gnocchi", "cupboard", "g", "500 g pack", 151, 4, 2, 0.1, 0), + ("oats", "cupboard", "g", "1 kg bag", 379, 13, 10, 1, 1), + ("ground flaxseed", "cupboard", "g", "200 g pack", 534, 18, 27, 3.2, 1), + ("almonds", "cupboard", "g", "200 g bag", 579, 21, 12.5, 3.8, 1), + ("peanut butter", "cupboard", "g", "340 g jar", 588, 25, 6, 9.5, 1), + ("olive oil", "cupboard", "ml", "bottle", 884, 0, 0, 13.8, 1), + ("soy sauce, reduced salt", "cupboard", "ml", "bottle", 53, 8, 0.8, 0, 1), + ("wholegrain bread", "cupboard", "x", "loaf, per slice", 90, 4, 2.5, 0.2, 0), + ("smoked paprika", "cupboard", "g", "jar", 282, 14, 35, 0.9, 1), + ("cumin", "cupboard", "g", "jar", 375, 18, 10.5, 1.5, 1), + ("chilli flakes", "cupboard", "g", "jar", 282, 12, 27, 1, 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, fiber_g=11, satfat_g=4.5, carbs_g=38, fat_g=16, + 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, fiber_g=9, satfat_g=3.5, carbs_g=30, fat_g=24, + 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, fiber_g=13, satfat_g=3, carbs_g=46, fat_g=10, + 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, fiber_g=7, satfat_g=1.5, carbs_g=52, fat_g=8, + 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, fiber_g=12, satfat_g=2, carbs_g=36, fat_g=8, + 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, fiber_g=9, satfat_g=3.8, carbs_g=28, fat_g=18, + 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, fiber_g=10, satfat_g=4, carbs_g=62, fat_g=12, + 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, fiber_g=6, satfat_g=3, carbs_g=48, fat_g=20, + 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, fiber_g=11, satfat_g=3.5, carbs_g=48, fat_g=13, + 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, fiber_g=16, satfat_g=2.5, carbs_g=60, fat_g=10, + 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, fiber_g=10, satfat_g=2, carbs_g=30, fat_g=12, + 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, fiber_g=9, satfat_g=3.5, carbs_g=50, fat_g=12, + 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, fiber_g=7, satfat_g=5, carbs_g=58, fat_g=14, + 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, fiber_g=8, satfat_g=2.5, carbs_g=58, fat_g=10, + 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, fiber_g=8, satfat_g=3.5, carbs_g=50, fat_g=12, + 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, fiber_g=17, satfat_g=2, carbs_g=78, fat_g=6, + 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, fiber_g=11, satfat_g=2, carbs_g=52, fat_g=9, + 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, fiber_g=6, satfat_g=1, carbs_g=34, fat_g=8, + 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, fiber_g=6, satfat_g=3.5, carbs_g=30, fat_g=17, + 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, fiber_g=14, satfat_g=5, carbs_g=40, fat_g=16, + 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, fiber_g=10, satfat_g=1.5, carbs_g=32, fat_g=10, + 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, fiber_g=8, satfat_g=2, carbs_g=36, fat_g=8, + 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, fiber_g=4, satfat_g=1.5, carbs_g=24, fat_g=11, + 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, fiber_g=4, satfat_g=1.2, carbs_g=6, fat_g=15, + 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, fiber_g=1, satfat_g=0.5, carbs_g=12, fat_g=2, + 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.""" + # ingredient reference table + have = {n for (n,) in db.query(Ingredient.name).all()} + for name, aisle, unit, pack, kcal, prot, fib, sat, 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, fiber_100=fib, satfat_100=sat, pantry=pantry)) + # recipe library + have_r = {s for (s,) in db.query(Recipe.slug).all()} + for r in RECIPES: + if r["slug"] not in have_r: + db.add(Recipe(**r)) + 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) + if 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 f670a9a..3f6e74c 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -16,7 +16,7 @@ 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, misc, push, training, withings from .seed import run_seed # uvicorn only configures its own loggers — without this, forge.* INFO logs @@ -180,6 +180,7 @@ 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) # 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. diff --git a/server/app/models.py b/server/app/models.py index 178309d..503a7a3 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -252,3 +252,118 @@ 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) ---------- + + +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) + fiber_g: Mapped[float] = mapped_column(Float, default=0) + satfat_g: Mapped[float] = mapped_column(Float, default=0) + carbs_g: Mapped[float] = mapped_column(Float, default=0) + fat_g: 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}] — done-when style + 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 + source_url: Mapped[str] = mapped_column(Text, default="") + 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) + fiber_100: Mapped[float] = mapped_column(Float, default=0) + satfat_100: Mapped[float] = mapped_column(Float, default=0) + 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) + fiber_g: Mapped[float] = mapped_column(Float, default=0) + satfat_g: Mapped[float] = mapped_column(Float, default=0) + source: Mapped[str] = mapped_column(String(12), default="plan") # plan | chat | order + estimated: Mapped[int] = mapped_column(Integer, default=0) + client_id: Mapped[str | None] = mapped_column(String(40), nullable=True) + ts: 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) + fiber_g: Mapped[float] = mapped_column(Float, default=0) + satfat_g: 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/food.py b/server/app/routers/food.py new file mode 100644 index 0000000..8456855 --- /dev/null +++ b/server/app/routers/food.py @@ -0,0 +1,204 @@ +"""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 pydantic import BaseModel +from sqlalchemy.orm import Session + +from ..config import local_today +from ..db import get_db +from ..food_seed import DEFAULT_TARGETS +from ..models import Ingredient, MealLog, MealRevision, 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, + "kcal": r.kcal, "protein_g": r.protein_g, "fiber_g": r.fiber_g, "satfat_g": r.satfat_g} + + +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)} + + +@router.get("/week") +def food_week(date: str | None = None, user: User = Depends(current_user), + db: Session = Depends(get_db)): + """Rolling 7-day food view, same contract as /api/week: today first.""" + base = parse_date(date) + actual_today = local_today() + rev = active_meal_revision(db, user) + days = ((rev.content or {}).get("days", {}) or {}) if rev else {} + + slugs = {s.get("recipe") for d in days.values() for s in (d.get("slots") or {}).values() + if s and s.get("recipe")} + recipes = {r.slug: r for r in db.query(Recipe).filter(Recipe.slug.in_(slugs)).all()} if slugs 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) + + 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 + 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) + 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) for lg in day_logs), 1) + for k in ("kcal", "protein_g", "fiber_g", "satfat_g")} + 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, "kcal": lg.kcal, + "protein_g": lg.protein_g, "fiber_g": lg.fiber_g, "satfat_g": lg.satfat_g, + "estimated": bool(lg.estimated)} for lg in extras], + "totals": totals, + }) + + return {"start": str(base), "days": out_days, "targets": targets_for(user), + "rationale": rev.rationale if rev else "", "has_plan": rev is not None} + + +@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 [], "carbs_g": r.carbs_g, "fat_g": r.fat_g, + "source": r.source, "source_url": r.source_url} + + +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 + fiber_g: float | None = None + satfat_g: float | None = None + estimated: bool = False + source: str = "plan" + client_id: str | None = None # offline-queue idempotency + + +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) for lg in rows), 1) + for k in ("kcal", "protein_g", "fiber_g", "satfat_g")} + + +@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)} + 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, kcal=round(r.kcal * n, 1), + protein_g=round(r.protein_g * n, 1), fiber_g=round(r.fiber_g * n, 1), + satfat_g=round(r.satfat_g * n, 1), source=body.source, + client_id=body.client_id) + 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, kcal=body.kcal or 0, + protein_g=body.protein_g or 0, fiber_g=body.fiber_g or 0, + satfat_g=body.satfat_g or 0, source=body.source or "chat", + estimated=1 if body.estimated else 0, client_id=body.client_id) + db.add(row) + db.commit() + 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/misc.py b/server/app/routers/misc.py index 9ed5440..17c147f 100644 --- a/server/app/routers/misc.py +++ b/server/app/routers/misc.py @@ -340,7 +340,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 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 +358,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, "kcal": m.kcal, "protein_g": m.protein_g, + "fiber_g": m.fiber_g, "satfat_g": m.satfat_g, "source": m.source, + "estimated": bool(m.estimated)} + for m in rows(MealLog, MealLog.ts)], } 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_smoke.py b/server/tests/test_smoke.py index da4e902..68437ad 100644 --- a/server/tests/test_smoke.py +++ b/server/tests/test_smoke.py @@ -808,3 +808,87 @@ def test_demo_cannot_reach_member_data(): login() 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 + 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" + 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 + 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() + assert all(not d["slots"] for d in wd["days"]), "household food week invisible to demo" + assert all(d["totals"]["kcal"] == 0 for d in wd["days"]) + 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}") From be9d952afc9cb33623770d8c74347bed11351fd3 Mon Sep 17 00:00:00 2001 From: James Atkinson Date: Mon, 20 Jul 2026 21:24:17 -0700 Subject: [PATCH 05/43] feat(food): Phase 7 frontend - Food tab, plate art, cook mode (E16.4/10) Fifth tab. Day view leads with the four cholesterol meters (protein, fiber, sat-fat cap, kcal) over one-tap tick rows with platefig thumbnails; offline ticks queue in localStorage and replay idempotently via client_id. Week menu reads dinner-first with honest planned-only averages. Recipe detail: plate art hero, why-it's-here, aisle-joined ingredients, done-when steps. Cook mode: one step per screen, kitchen-scale type, local countdown ring (rest- ring pattern), plated summary that logs the dinner. platefig.tsx: 16 token-native SVG compositions over shared plate/tray/bowl primitives (formfig approach) - adapts to Paper x Moss light mode free. Plan integration: tonight's dinner as one quiet hairline row on the hero, dim dinner notes on day rows. Settings -> Nutrition: coach-owned targets (read-only + chat CTA), cook nights, budgets, household toggle. Driven end-to-end headless (sign-in -> tick -> week -> recipe -> cook with running timer -> plated -> settings, dark + light): no page errors. Co-Authored-By: Claude Fable 5 --- web/src/App.tsx | 24 +- web/src/api.ts | 33 +++ web/src/platefig.tsx | 219 ++++++++++++++ web/src/screens/food.tsx | 560 +++++++++++++++++++++++++++++++++++ web/src/screens/settings.tsx | 81 +++++ web/src/screens/today.tsx | 42 ++- web/src/styles.css | 43 +++ web/src/ui.tsx | 15 +- 8 files changed, 1005 insertions(+), 12 deletions(-) create mode 100644 web/src/platefig.tsx create mode 100644 web/src/screens/food.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 48a1ea1..b2b0122 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,12 +8,13 @@ import { flushQueue } from './queue'; import { AuthScreen, DeniedScreen } from './screens/auth'; import { CoachScreen } from './screens/coach'; import { DetailScreen, HistoryScreen, ProgressScreen, RecordsScreen } from './screens/data'; +import { CookScreen, FoodDayScreen, FoodWeekScreen, 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, ConnectionsScreen, EquipmentScreen, LabsScreen, LibraryScreen, - NigglesScreen, NotifScreen, ServerScreen, SettingsScreen, UnitsScreen, + NigglesScreen, NotifScreen, NutritionScreen, ServerScreen, SettingsScreen, UnitsScreen, } from './screens/settings'; import { DayScreen, PlanScreen } from './screens/today'; import { @@ -100,6 +101,8 @@ 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 [budget, setBudget] = useState(null); const [summary, setSummary] = useState(null); const [chatContext, setChatContext] = useState(null); @@ -111,9 +114,12 @@ 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 && 'chatContext' in extra) setChatContext(extra.chatContext ?? null); setScreen(s); if (s === 'coach') setTab('coach'); + if (s === 'food') setTab('food'); }, []); const openTab = useCallback((t: Tab) => { setTab(t); setScreen(t); }, []); @@ -228,22 +234,27 @@ 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, + 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, + 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, recipe: RecipeScreen, cook: CookScreen, history: HistoryScreen, detail: DetailScreen, progress: ProgressScreen, records: RecordsScreen, 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-units': UnitsScreen, 'set-server': ServerScreen, 'set-food': NutritionScreen, }; 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 ; } @@ -277,6 +288,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 f3d035f..94809d1 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -209,6 +209,39 @@ export interface ProposalResp { proposal: { id: string; num: number; rationale: string; created_at: string; content: { days: Record; changes?: ProposalChange[] } } | null; } +/* ---------- nutrition (beta track, Phase 7) ---------- */ +export interface NutritionTargets { kcal: number; protein_g: number; fiber_g: number; satfat_g: number; } +export interface RecipeCard { + slug: string; name: string; kind: string; minutes: number; difficulty: string; + serves: number; batch: number; platefig: string; why: string; + kcal: number; protein_g: number; fiber_g: number; satfat_g: number; +} +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; +} +export interface FoodExtra { + id: string; slot: string; label: string; + kcal: number; protein_g: number; fiber_g: number; satfat_g: number; estimated: boolean; +} +export interface FoodDay { + date: string; day_name: string; is_today: boolean; + slots: FoodSlot[]; extras: FoodExtra[]; + totals: { kcal: number; protein_g: number; fiber_g: number; satfat_g: number }; +} +export interface FoodWeek { + start: string; days: FoodDay[]; targets: NutritionTargets; rationale: string; has_plan: boolean; +} +export interface RecipeStep { title: string; minutes?: number; detail: string; timer?: 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[]; + carbs_g: number; fat_g: number; source: string; source_url: string; +} + export interface Connections { apple_health: { configured: boolean; token_masked: string | null; last_push: string | null; samples: number; endpoint: string }; 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/screens/food.tsx b/web/src/screens/food.tsx new file mode 100644 index 0000000..3742352 --- /dev/null +++ b/web/src/screens/food.tsx @@ -0,0 +1,560 @@ +/* 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef, useState } from 'react'; +import { + api, ApiError, fmtT, todayISO, + type FoodDay, type FoodSlot, type FoodWeek, type RecipeFull, +} 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; + const remaining: QueuedTick[] = []; + let sent = 0; + for (const t of q) { + try { + await api('/api/food/log', { method: 'POST', body: t }); + sent++; + } catch (e) { + if (e instanceof ApiError && e.network) { remaining.push(t); break; } + // non-network error (e.g. recipe gone): drop it rather than loop forever + } + } + writeQueue(remaining.concat(q.slice(remaining.length + sent))); + if (sent) onDone?.(sent); +} + +/* ---------------- data ---------------- */ +export function useFoodWeek() { + return useQuery({ queryKey: ['foodweek'], queryFn: () => api('/api/food/week') }); +} + +const SLOT_LABEL: Record = { + breakfast: 'Breakfast', lunch: 'Lunch', dinner: 'Dinner', snack: 'Snack', +}; + +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; + return s.note || 'Unplanned'; +} + +/* ---------------- meters ---------------- */ +function Meter({ 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 * 10) / 10} + {' / '}{cap ? '≤' : ''}{target}{label === 'Calories' ? '' : ' g'} +
+
+
+ ); +} + +/* ---------------- day view (the Food tab home) ---------------- */ +export function FoodDayScreen() { + const { go, openTab, foodDate } = useApp(); + const qc = useQueryClient(); + const wq = useFoodWeek(); + 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 && s.log_id) { + 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'], (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: { + kcal: d.totals.kcal + (s.recipe!.kcal || 0), + protein_g: d.totals.protein_g + (s.recipe!.protein_g || 0), + fiber_g: d.totals.fiber_g + (s.recipe!.fiber_g || 0), + satfat_g: d.totals.satfat_g + (s.recipe!.satfat_g || 0), + }, + }), + })); + 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 + +
+ + {!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.map((s) => { + const passive = !s.recipe; + const fig = s.out ? 'out' : (s.recipe?.platefig || 'plate'); + return ( + + ); + })} + + {day.extras.map((x) => ( +
+ + + {x.label} + + {SLOT_LABEL[x.slot] || x.slot} · {x.kcal} kcal · P {x.protein_g}{x.estimated ? ' · estimated' : ''} + +
+ ))} + + +
+ ); +} + +/* ---------------- week menu ---------------- */ +export function FoodWeekScreen() { + const { go } = useApp(); + const wq = useFoodWeek(); + const w = wq.data; + const [noteOpen, setNoteOpen] = useState(false); + if (!w) return ; + + const planned = w.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 = w.targets; + + 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(' · '); + + return ( + + go('food')} /> + Food week + +
+
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. +
+ + {w.rationale && ( + + )} + + {w.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 ( + + ); + })} + Shopping list and coach-proposed food weeks land in Phase 8 — this week is the + hand-written baseline. +
+ ); +} + +/* ---------------- recipe detail ---------------- */ +export function useRecipe(slug: string) { + return useQuery({ + queryKey: ['recipe', slug], + queryFn: () => api('/api/food/recipes/' + slug), + enabled: !!slug, + staleTime: 5 * 60_000, + }); +} + +export function RecipeScreen() { + const { go, foodSlug, foodDate } = useApp(); + const qc = useQueryClient(); + const q = useRecipe(foodSlug); + const r = q.data; + + 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 ; + + return ( + + go('food')} /> + {r.name} +
+ {r.minutes} min + {r.difficulty} + serves {r.serves}{r.batch ? ` + ${r.batch} boxed` : ''} +
+
+ {r.kcal} kcal + P {r.protein_g} + fiber {r.fiber_g} + sat fat {r.satfat_g} +
+ +
+ +
+ + {r.why && ( +
+ Why it's here +
{r.why}
+
+ )} + +
Ingredients{r.batch ? ' · 2 nights' : ''}
+
+ {r.ingredients.map((i) => ( +
+ + {i.name} + {i.note && i.note !== 'pantry' && {i.note}} + + + {i.pantry ? 'pantry' : i.disp} + +
+ ))} +
+ + {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.detail} + +
+ ))} +
+ + )} + + {r.steps.length > 1 ? ( + <> + + + + ) : ( + + )} +
+ ); +} + +/* ---------------- 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); + const [remain, setRemain] = useState(null); // seconds left on this step's timer + const [total, setTotal] = useState(0); + const timerRef = useRef>(undefined); + const t0 = useRef(Date.now()); + + // reset any running timer when the step changes + useEffect(() => { + clearInterval(timerRef.current); + setRemain(null); setTotal(0); + }, [idx]); + useEffect(() => () => clearInterval(timerRef.current), []); + + if (!r) return ; + const steps = r.steps; + const step = steps[Math.min(idx, steps.length - 1)]; + const last = idx >= steps.length - 1; + + const startTimer = () => { + const secs = (step.minutes || 1) * 60; + setTotal(secs); setRemain(secs); + clearInterval(timerRef.current); + timerRef.current = setInterval(() => { + setRemain((prev) => { + if (prev === null) return prev; + if (prev <= 1) { + clearInterval(timerRef.current); + toast(`${step.title} — time's up`, true); + if (navigator.vibrate) navigator.vibrate([160, 90, 160]); + return 0; + } + return prev - 1; + }); + }, 1000); + }; + + 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: 'dinner', recipe: r.slug, + client_id: crypto.randomUUID() }, + }); + qc.invalidateQueries({ queryKey: ['foodweek'] }); + } catch { toast('Offline — tick the dinner from the day view when back online'); } + setPlated(true); + toast(`Plated in ${cookedMin} min — dinner logged`, true); + }; + + if (plated) { + return ( + + Plated. Logged. +
+ +
+
+ {r.batch > 0 && ( +
+ + Leftover night boxed + + {r.batch} servings in the fridge — zero-cook night locked in +
+ )} +
+ + Dinner 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}
+
+
+ +
+ ); + } + + return ( + +
+ go('recipe')} /> + {r.name} +
+ +
+ {steps.map((_, i) => ( + + {i < idx ? '✓' : i + 1} + + ))} + Step {idx + 1} of {steps.length} +
+ +

{step.title}

+
{step.detail}
+ + {step.timer && ( +
+ + + {remain !== null && total > 0 && ( + + )} + + {remain !== null ? fmtT(remain) : fmtT((step.minutes || 1) * 60)} + + + {remain !== null ? (remain === 0 ? 'done' : `of ${step.minutes}:00`) : `${step.minutes} min`} + + + {remain === null ? ( + + ) : ( +
A local timer, same as your rest ring — it chimes here, no push.
+ )} +
+ )} + +
+ {idx > 0 && ( + + )} + +
+
+ ); +} diff --git a/web/src/screens/settings.tsx b/web/src/screens/settings.tsx index 0caf7c6..e762264 100644 --- a/web/src/screens/settings.tsx +++ b/web/src/screens/settings.tsx @@ -28,6 +28,7 @@ export function SettingsScreen() { a.click(); }; const rows: [string, string, () => void][] = [ + ['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')], @@ -82,6 +83,86 @@ 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); + const t = prefs.nutrition_targets || { kcal: 2300, protein_g: 160, fiber_g: 38, satfat_g: 18 }; + + 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} · fiber {t.fiber_g} · sat ≤{t.satfat_g} +
+
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(); diff --git a/web/src/screens/today.tsx b/web/src/screens/today.tsx index 32c933a..7c22dc0 100644 --- a/web/src/screens/today.tsx +++ b/web/src/screens/today.tsx @@ -1,9 +1,23 @@ import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRef, useState } from 'react'; -import { api, fmtLoad, fmtT, kgDisp, loadUnitFor, todayISO, type ProposalResp, type Today, type WeekResp } from '../api'; +import { api, fmtLoad, fmtT, kgDisp, loadUnitFor, todayISO, type FoodWeek, type ProposalResp, type Today, type WeekResp } from '../api'; import { MuscleMap } from '../musclemap'; +import { useFoodWeek } from './food'; import { Back, Chip, Loading, Shell, Title, toast, useApp } from '../ui'; +/** Tonight's (or a given date's) dinner from the food week — one quiet line on + Plan; Plan stays a training screen. */ +function dinnerFor(fw: FoodWeek | undefined, date: string): + { label: string; minutes: number | null; slug: string | null } | null { + const day = fw?.days.find((d) => d.date === date); + const s = day?.slots.find((x) => x.slot === 'dinner'); + if (!s) return null; + if (s.out) return { label: 'night out', minutes: null, slug: null }; + if (!s.recipe) return null; + if (s.leftover) return { label: `${s.recipe.name} · leftovers`, minutes: null, slug: s.recipe.slug }; + return { label: s.recipe.name, minutes: s.recipe.minutes, slug: s.recipe.slug }; +} + /* ---------------- Plan: the whole week, separated by day ---------------- */ /** Tiny kind marker so rows read at a glance without reading. */ @@ -32,6 +46,7 @@ export function PlanScreen() { const qc = useQueryClient(); const q = useQuery({ queryKey: ['week'], queryFn: () => api('/api/week') }); const pq = useQuery({ queryKey: ['proposal'], queryFn: () => api('/api/proposal') }); + const fw = useFoodWeek(); const [noteOpen, setNoteOpen] = useState(false); const [dangOpen, setDangOpen] = useState(false); const [saving, setSaving] = useState(false); @@ -131,6 +146,23 @@ export function PlanScreen() { {today.focus.length > 0 && (
{today.focus.map((f) => {f})}
)} + {(() => { + const din = dinnerFor(fw.data, today.date); + if (!din) return null; + return ( + { + e.stopPropagation(); + if (din.slug) go('recipe', { foodSlug: din.slug, foodDate: today.date }); + else go('food'); + }}> + ◉ Tonight · {din.label} + + {din.minutes ? `${din.minutes} min ›` : '›'} + + + ); + })()} )} @@ -144,6 +176,14 @@ export function PlanScreen() { {d.day_name} {d.name || 'Rest'} + {(() => { + const din = dinnerFor(fw.data, 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; + })()} diff --git a/web/src/styles.css b/web/src/styles.css index 4f29a0d..c24ac4d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -308,3 +308,46 @@ input[type=range]::-moz-range-thumb{ width:18px; height:18px; border-radius:50%; font:inherit; color:var(--ink); border-radius:14px; padding:12px 13px; font-size:13.5px; width:100%; text-align:left; } .warnbanner b{ flex:1; font-weight:600; } + +/* ---- Food tab (beta track, Phase 7): meters, meal rows, cook mode ---- */ +.meter{ padding:8px 0; } +.meter + .meter{ border-top:1px solid var(--hair); } +.meter .mval{ font-weight:300; font-size:15.5px; color:var(--mut); } +.meter .mval b{ font-weight:700; color:var(--volt); font-style:normal; } +.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; } +.mtick{ width:24px; height:24px; border-radius:50%; background:var(--sunken); color:var(--dim); + 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; } +.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:8px; border-top:1px solid var(--hair); + padding:9px 2px; font-size:14px; } +.ingrow:first-child{ border-top:0; } +.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; } diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 9b94791..0665066 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -12,12 +12,13 @@ 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' | 'recipe' | 'cook' | 'history' | 'detail' | 'progress' | 'records' | 'coach' | 'settings' | 'set-conn' | 'set-equip' | 'set-niggles' | 'set-labs' | 'library' | 'set-notif' - | 'set-coach' | 'set-units' | 'set-server'; + | 'set-coach' | 'set-units' | 'set-server' | 'set-food'; 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 +43,10 @@ export interface AppCtxType { screen: Screen; tab: Tab; learnSlug: string; learnFrom: Screen; detailId: string; lift: string; dayDate: string | null; + foodSlug: string; foodDate: string | null; 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; @@ -95,6 +97,7 @@ export function useOnline(): boolean { /* ---------------- chrome components ---------------- */ const ICONS: Record = { today: <>, + food: <>, history: <>, progress: , coach: , @@ -116,7 +119,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 { @@ -133,7 +138,7 @@ export function Tabs() { const unread = useCoachUnread(tab === 'coach'); return (