Skip to content

feat(ios): Cook module, and every Train screen onto its decided board - #33

Open
lemmy-winks wants to merge 7 commits into
claude/merge-beta-to-mainfrom
claude/ios-n1-history-coach
Open

feat(ios): Cook module, and every Train screen onto its decided board#33
lemmy-winks wants to merge 7 commits into
claude/merge-beta-to-mainfrom
claude/ios-n1-history-coach

Conversation

@lemmy-winks

@lemmy-winks lemmy-winks commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Based on #32 (beta merge), so it targets that branch. Retarget to main once #32 lands.

Three chunks stacked on one branch: N1's remaining slices with the design correction, the Cook module, and the last two Train boards (logging, progress). Every screen in the app is now built to a decided board.


Part 2 — Cook (N4.1)

Built ahead of N2/N3 because cook-browse and cook-mode were already decided and the server needed no new API — /api/food/* already speaks everything this uses.

Cook is not a Train reskin

That's the design doc's word, and it's the thing this had to get right. Nothing is shared but the layout primitives:

Train Cook
Palette Void × Volt / Paper × Moss Paper × Ember (cookLight)
Display type DIN Condensed New York serif (ForgeFont.serif*)
Surfaces hairlines over raised cards soft warm shadows, full-bleed photos
Scheme dark and light both first-class light only

Volt and DIN never reach a Cook screen; ember and serif never reach a Train one.

Cook is light-only on purpose. Both boards were drawn on paper and the module identity is "light, airy, photography-led", so cookTheme() pins the subtree to .light rather than inventing a dark palette no design round approved. If that reads badly in an actual evening kitchen it's a design round, not a code change — flagged for you rather than guessed at.

The shell now tints from the selected tab. First build had a volt "Cook" label sitting under a paper Cook screen, which is two accents on one screen — the invariant this whole module is scoped to protect.

Screens

  • Browse — ember kicker ("MONDAY · 5 DINNERS PLANNED"), serif Cook, search, tonight's dinner as a full-bleed hero with glass chips, the shared week as thumbnails, library grid below.
  • Recipe detail — hero, why-it's-here in serif, per-serving macros (labelled per serving, because per-pan numbers would wreck the day's totals), ingredient chips, method with cues, cook mode + plan CTAs.
  • Cook mode — arm's-length serif, one step at a time, timer ring, background-step strip, up-next, segments, plate-and-log.
  • Library — full list incl. parked imports, badged not hidden; as-you-type filter and kind chips.
  • Week planner — per-slot tick, day totals against target, macro bars.
  • Plan sheet — works from either end: a night for a recipe, or a recipe for an empty slot.

Cook mode accents exactly one thing

The done-when cue. CookCue derives it from the step prose rather than needing a new field, which works because Forge's import rule already mandates done-when voice. On real seed data:

Done when thighs read 74° / juices run clear and the chickpeas just blister. No turning — let the edges char.

Prep steps genuinely have no cue and get no highlight — inventing one would make the accent meaningless. Six tests pin the extraction, including that the cue stops at the em dash so "about 4 minutes" is timing, not the cue.

Two things the code has to be careful about

Recipe media is behind the session cookie, so AsyncImage can't fetch it at all. ForgeAsyncImage goes through the client — and attaches the cookie only for our own origin. Imports keep the remote URL when the blob fetch fails, so a reference can be off-origin, and sending a recipe site's CDN a live Forge session would be a real leak.

The plan is shared; the log is yours. One household food week, per-user meal logs. Every control that plans says it changes the week for both of you; every control that ticks says it logs to your plate only. These are never merged into one control.

A gap worth naming

The seeded library ships zero images — only MCP imports materialize a hero. In a photography-led module every hero and tile would have been a blank rectangle, which is the exact failure the redesign round was called to fix on Train. PlateImage bundles fallback photography keyed by the recipe's platefig, the same split as Train's HeroImage vs ExerciseMedia. It works, but six photos across 25 recipes repeats — real per-recipe imagery is the actual fix.

Off the board, and why

The board's next button restates the cue as a confirmation — "Skin's released — next". That's a grammatical transform of the prose, and deriving it gives nonsense as often as not ("Toss until slicked — next"). The button uses the next step's title instead, which is short and always true. Doing it properly needs an authored cue_done on the step; noted in the plan.

Also still open in N4: iPad NavigationSplitView, and cook timers on the Dynamic Island — the timers are written and running, they just need a widget extension target, now written up as [C1b] in ios/XCODE-HANDOVER.md.

Train gets its meals strip

Read-only, as N4 specified: tonight's dinner and the day's macros. Volt, not ember — it's a Train screen — and every action on it lives in Cook.


Part 1 — N1.3 history + trends, N1.4 coach, Today rebuilt

N1.3 — History with offset paging, favourites filter and native swipe actions; session detail with a MapKit route and five-zone breakdown; Swift Charts trends; all-time records.

N1.4 — Coach chat (fire-and-poll, since POST /api/chat runs the turn on a server thread) plus proposal review with approve/reject.

The correction

The first pass of these screens was clean SwiftUI but not the agreed design — James called it out mid-build. I'd read native-app-design.md but never opened the actual boards. Having read them, the Train landing was wrong at the structural level, not the cosmetic one.

Rebuilt to redesign-d-type:

  • Today is the Train root, not the week list. Date kicker → big DIN TODAY → avatar; media hero with the lift list and the CTA inside it; budget caption; engine tiles; week strip demoted to the bottom.
  • History and Progress are Train screens, reached from Today. The tab bar is the module switch.
  • Media leads. That was the entire finding of the redesign round — "Cook read as far cleaner and richer than Train; media was the difference." Heroes and the Zone-2 tile use the editorial photography the design was approved with. Dark puts text on a scrim over the photo; light stacks the photo above the text.
  • The server's free-exercise-db photos stay what they're good at: form reference next to a cue, not behind a headline.

Two bugs found by looking at it, not testing it

  1. e1RM history was keyed on when a set was logged, not the day it was trained. Any session saved off the next morning plotted on the wrong date — and the completion path explicitly supports that. Fixed server-side, with a regression test that only a backdated session can catch.
  2. Zone minutes are Double (10.2), not Int — which failed the decode of the entire cardio detail payload. Also anchored the bodyweight and HR charts off zero; an AreaMark baselines at zero and flattens a 3 kg range into a hairline.

Still not matching the Train boards

  • Logging screen needs option D's big load numeral with inline plate math, the compact paired steppers, hairline set rows and session tiles.
  • Progress needs Strength/Engine/Body segmentation, the week ring, sparkline rows with plain-words status ("stalled 2 wk"), and the PR-as-photo banner.

Part 3 — the last two Train boards

Both screens I flagged in the previous round as "still the old shape" are now built.

Logging (redesign-d-logging)

"DIN Condensed doing what it was made for: race-clock numerals that read from across the rack, SF for everything you read up close."

One enormous load numeral with the plate math beneath it; the steppers shrink to compact paired controls. The set line carries last time's load, reps and RPE — you pick today's weight from what you did last time, and having to leave the screen to find it is how people repeat a week. Hairline set rows, session/volume/sets tiles, DIN-caps LOG SET 3. The tab bar hides during a session: logging is a mode, not a destination.

The plate line now recomputes as you step instead of leaving the server's answer for the prescription sitting under a number you changed.

Off the board deliberately: the form card shows a photo and says "Form", not "Form check · 0:24". The video pipeline was retired and form media is curated stills — claiming a clip that doesn't exist is worse than the honest card.

Progress (redesign-d-progress)

Strength / Engine / Body switch, week ring, one featured lift as a filled curve with its change over the window it actually covers, sparkline rows, PR-as-photo.

The words are the point. Rows read "building", "stalled 2 wk", "easing off" rather than a percentage — a number tells you what changed, a word tells you whether to act. LiftTrend derives it, with the noise floor written down: e1RM comes from reps at a load, so a rep either way moves it a couple of kilos on its own.

Four bugs, three of them mine

  1. A steadily climbing lift read "holding". The first rule compared the last session to the best of the previous ones — and running the same load Monday and Thursday is normal programming, so the delta was zero every week. Now compared against four weeks ago, with a separate check so a dip from a PR still reads as easing.
  2. I computed "today" on the client, which ios/CLAUDE.md explicitly forbids, and it did exactly what the rule predicts: the simulator's date sat in the previous week, so last week's records were reported as "6 PRs this week".
  3. RecordRow.detail is server-formatted with a kg load in it — the PR banner rendered "BACK SQUAT 73.75 × 5" on a screen otherwise reading in lb. Records had the same bug.
  4. ExerciseInfo decode: media_tier is a string, not a count, and dont is a sentence sitting next to a cues list. Because the fetch is try?, the form card just silently never appeared. This class of bug has now bitten three times in this branch, so the real payloads have tests.

One thing I did not change

With unit_load = lb (the default), one stepper tap moves 2.27 kg — so a plateable 60 kg prescription becomes an unmakeable 62.27 kg. The screen says so honestly ("per side 20 on the 20 kg bar — 1.14 kg short"), but you'll hit it on nearly every adjustment. Whether lb users want lb steps or plateable steps is a product call, and Units.stepped has a regression test behind it. Flagged rather than guessed.


Caveat

All the bundled photography — Train heroes and Cook plates — is Unsplash from the mockup set, which that README calls placeholder. Fine for two-seat TestFlight, worth re-licensing before any public listing.

Verification

69 Swift Testing declarations, 86 server tests. Driven end-to-end in the Simulator against a local server with the real seeded food week: browse → detail → all five cook-mode steps with a background timer still running → slot tick that wrote a real log (520 kcal / 42 g protein on Monday).

🤖 Generated with Claude Code

lemmy-winks and others added 2 commits July 26, 2026 18:47
…e mockups

History (offset paging, favourites, native swipe actions), session detail with
a MapKit route and zone breakdown, Swift Charts trends, records; coach chat
with proposal review/approve.

Then a correction. The first pass of these screens was clean SwiftUI but not
the agreed design — James called it out. Re-read the decided boards
(docs/mockups/native/redesign-d-*.png) and rebuilt the Train landing to match:

- Today is the Train tab root, not the week list. Date kicker, big DIN TODAY,
  avatar, media hero with the CTA inside it, engine tiles, week strip demoted
  to the bottom. History and Progress are Train screens reached from Today,
  not top-level tabs — the tab bar is the module switch (Cook and Shop join at
  N4/N5).
- Media leads, which was the whole point of the redesign round ("Cook read as
  far richer than Train; media was the difference"). Heroes and the Zone-2 tile
  use the editorial photography the design was approved with, bundled from
  docs/mockups/native/photos. Dark puts text on a scrim over the photo; light
  stacks the photo above the text, never behind it — the rule that came out of
  the round.
- The server's free-exercise-db photos stay what they are: form reference,
  next to a cue, not behind a headline.

Two bugs found by looking at it rather than testing it:

- e1RM history was keyed on when a set was logged, not the day it was trained,
  so any session saved off the next morning plotted on the wrong date. Fixed
  server-side with a regression test.
- Zone minutes decode as Double (10.2), not Int, which failed the whole cardio
  detail payload. Also anchored the bodyweight and HR charts off zero — an
  AreaMark baselined at zero flattens a 3 kg range into a hairline.

Known gaps against the boards, not yet done: the logging screen still needs
option D's big load numeral with inline plate math and compact steppers; the
Progress screen needs the Strength/Engine/Body segmentation, week ring,
sparkline rows and PR-as-photo banner; the meals strip waits on Cook.

Photo licensing caveat: the hero set is Unsplash and the mockup README calls it
placeholder — fine for two-seat TestFlight, worth re-licensing before any
public listing.

Tests: 40 Swift cases, 86 server tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
N4 slice 1, ahead of N2/N3 because cook-browse and cook-mode were already
decided and the server needed no new API.

Cook is not a Train reskin, so nothing is shared but the primitives:

- Paper × Ember (`ForgeTheme.cookLight`) and New York serif
  (`ForgeFont.serif*`) against Train's Void × Volt and DIN. Volt and DIN
  never appear on a Cook screen. Cook is light-only — both boards were
  drawn on paper, so `cookTheme()` pins the subtree to `.light` rather
  than inventing a dark palette no round approved.
- `RootView` now tints the shell from the *selected* tab. A volt "Cook"
  label under a paper Cook screen was two accents on one screen.

Screens: browse (ember kicker, serif title, tonight's hero, week strip,
library grid), recipe detail, cook mode, full library, week planner, and
a plan sheet that works from either end — pick a night for a recipe, or
pick a recipe for an empty slot.

Cook mode accents exactly one thing: the done-when cue. `CookCue` derives
it from the step prose, which is reliable because Forge's import rule
already mandates done-when voice. Timers hold an absolute end date, not a
countdown — cook mode is backgrounded constantly and a decrementing
counter drifts every time the view stops ticking. Background steps stay
visible in a strip so a roast keeps ticking while you prep.

Two things the code has to be careful about:

- Recipe media is behind the session cookie, so `AsyncImage` can't fetch
  it. `ForgeAsyncImage` goes through the client and attaches the cookie
  **only for our own origin** — imports keep remote URLs when the blob
  fetch fails, and a recipe site's CDN must never see a Forge session.
- The plan is household-shared; the log is per-user. Every control that
  plans says it changes the week for both of you; every control that
  ticks says it logs to your plate only.

The seeded library ships no imagery, which in a photography-led module
reads as breakage. `PlateImage` gives bundled fallback photography keyed
by `platefig` — the same split as Train's HeroImage vs ExerciseMedia.
Real per-recipe photos are the actual fix and are noted in the plan.

Train gets the read-only meals strip N4 called for: tonight's dinner and
the day's macros, volt not ember, with every action left to Cook.

Verified in the Simulator against a local server end to end — browse,
detail, all five cook-mode steps with a running background timer, and a
slot tick that wrote a real log (520 kcal / 42 g protein).

53 iOS cases, 86 server tests, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lemmy-winks lemmy-winks changed the title feat(ios): N1.3 history + trends, N1.4 coach, Today rebuilt to the mockups feat(ios): Cook module, plus N1 history/trends/coach and the Today rebuild Jul 27, 2026
lemmy-winks and others added 2 commits July 26, 2026 20:12
The last Train screen still on the old shape. Built to
`docs/mockups/native/redesign-d-logging.png` — "DIN Condensed doing what
it was made for: race-clock numerals that read from across the rack, SF
for everything you read up close."

- One enormous load numeral with the plate math under it; the steppers
  shrink to compact paired controls beneath. You read the load from a
  metre away and touch the steppers from thirty centimetres.
- Kicker + End, a progress rule across the session's exercises, big DIN
  lift name, and a set line that carries **last time's load, reps and
  RPE** — you pick today's weight from what you did last time, and having
  to leave the screen to find it is how people repeat a week.
- Hairline set rows instead of cards, with the next set greyed and its
  rest shown. Session / volume / sets tiles. DIN-caps "LOG SET 3".
- Form card: the exercise's own photo and cues, or the coach's note for
  today when there is one. It shows a photo and says "Form" — not the
  board's "Form check · 0:24", because the video pipeline was retired and
  claiming a clip that doesn't exist is worse than the honest card.
- The exercise strip is gone; the progress rule says where you are and a
  sheet says where else you could be.
- Tab bar hidden during a session. Logging is a mode, not a destination,
  and mid-set a stray thumb on "Cook" loses your place.

The plate line now recomputes as you step, instead of leaving the
server's answer for the prescription sitting under a number you changed.
`PlateMath` grows `perSide`/`perSideLine` over the same greedy fill
`description` already used, so there's still one implementation.

Three bugs on the way:

- `fitted.targets` carry no library-resolved `kind`, so every barbell
  looked like a machine and got no plate line. The day payload has it.
- `ExerciseInfo.mediaTier` is a string ("images"), not a count, and
  `dont` is a sentence sitting next to a `cues` *list*. Both failed the
  decode, and because the fetch is `try?` the form card just silently
  never appeared. Now covered by a test against the real payload — this
  class of bug has now bitten three times.

Exposed but not changed: with lb display, one stepper tap moves 2.27 kg,
so a plateable 60 kg prescription becomes an unmakeable 62.27 kg. The
line says so honestly ("1.14 kg short"). Whether lb users want lb steps
or plateable steps is a product call, not a guess to make here.

53 → 62 iOS cases, 86 server tests, all passing. Verified in the
Simulator against a local server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last screen off the boards. Built to
`docs/mockups/native/redesign-d-progress.png`: DIN `PROGRESS`, a
Strength / Engine / Body switch, the week as a ring, one featured lift
as a filled curve with its change, the rest as sparkline rows, and the
newest PR as a photo banner.

**The words are the point.** Each row is labelled "building", "stalled
2 wk", "easing off" rather than a percentage — a number tells you what
changed, a word tells you whether to do something about it. `LiftTrend`
derives it, with the reasoning and the noise floor written down: e1RM is
derived from reps at a load, so a rep either way moves it a couple of
kilos on its own and that must not read as progress.

Three things worth calling out:

- **A steadily climbing lift read "holding".** The first rule compared
  the last session to the best of the previous ones — and running the
  same load Monday and Thursday is normal programming, so the delta was
  zero every week. Now it compares against where you were four weeks
  ago, with a separate check for coming off a recent high so a dip from
  a PR still reads as easing rather than flat. Both cases are tests.

- **I computed "today" on the client**, which `ios/CLAUDE.md` explicitly
  forbids, and it did exactly what the rule predicts: the simulator's
  date sat in the previous week, so last week's records were counted as
  "6 PRs this week". Now taken from `/api/week`'s `today`.

- **`RecordRow.detail` is server-formatted with a kg load in it.** The PR
  banner rendered it raw — "BACK SQUAT 73.75 × 5" under a screen
  otherwise reading in lb. Converted at the display edge, and the same
  fix applied to Records, which had it too.

Smaller: the featured chart's x-axis read "May May Jun Jun Jul" on
`.automatic`, now one mark per month. The PR banner is deliberately dark
in both schemes — the board keeps it dark on paper too; it's the one
element meant to interrupt the page. Best-set records only, since an
estimated 1RM is a calculation and a banner should celebrate something
you actually did. Empty lenses say why they're empty instead of just
ending.

69 iOS test declarations, 86 server tests, all passing. Verified in the
Simulator against twelve weeks of seeded training history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lemmy-winks lemmy-winks changed the title feat(ios): Cook module, plus N1 history/trends/coach and the Today rebuild feat(ios): Cook module, and every Train screen onto its decided board Jul 27, 2026
lemmy-winks and others added 3 commits July 26, 2026 20:43
The Progress lens, Today's tile row, and the boards they were built to
all called the aerobic side "Engine". It's a coach's metaphor, not a
word either of us uses — renamed to Cardio.

Code is compiler-checked (Lens.cardio, cardioTiles, CardioTile); the
native and web boards are regenerated from their updated sources so the
design targets and the app agree.

Left alone: SQLAlchemy's `engine`, and prose using the word in its
ordinary sense ("fitting engine", VO2max's "your engine size").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found testing the Cardio rename in the simulator.

"▼ 0" on resting HR: significance was a fixed 0.05, but that card prints
whole bpm, so any drift under half a bpm drew an arrow pointing at no
change. Significance is now whatever the formatter can actually show.

Every delta was painted volt, which the design system reserves for
positive status — so a worse night's sleep read as good news. A delta
earns the accent only when it moved the way that metric should. Resting
HR down, sleep up and muscle up are the three the app already takes a
view on in its own copy; bodyweight, body fat and water have no
universal good direction and stay neutral rather than the app inventing
a goal it was never given.

Both extracted to MetricDelta so they're testable, and both pinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cook could plan a week but never see the one the coach proposed —
/api/food/proposal and its approve/reject pair had no client. The
coaching loop for food was invisible in the native app.

Adds the models, the client calls, a banner on the Cook root, and a
review screen in Cook's voice (paper, serif, ember — the training
proposal's screen is volt and DIN, and the two must not converge).

The leftovers hop is the fiddly part: a leftovers slot names no dish,
only the weekday it came from, and the seeded data writes that weekday
as a *string* while the coach can write an int. Resolving it wrong turns
Thursday into a blank row. Decoded both ways, pinned by tests, and
verified against the real seeded week.

Known: the banner sits under the hero, not above the search bar where it
belongs. Taps in a band near the top of Cook's scroll view don't reach
their target — the banner was dead there, and the search bar in that band
opens the hero's recipe instead of the library. Pre-existing and not yet
explained; ruled out the scroll edge effect and the content shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant