feat(ios): Cook module, and every Train screen onto its decided board - #33
Open
lemmy-winks wants to merge 7 commits into
Open
feat(ios): Cook module, and every Train screen onto its decided board#33lemmy-winks wants to merge 7 commits into
lemmy-winks wants to merge 7 commits into
Conversation
…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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-browseandcook-modewere 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:
cookLight)ForgeFont.serif*)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.lightrather 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
Cook, search, tonight's dinner as a full-bleed hero with glass chips, the shared week as thumbnails, library grid below.Cook mode accents exactly one thing
The done-when cue.
CookCuederives 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: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
AsyncImagecan't fetch it at all.ForgeAsyncImagegoes 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.
PlateImagebundles fallback photography keyed by the recipe'splatefig, the same split as Train'sHeroImagevsExerciseMedia. 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_doneon 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] inios/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/chatruns 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.mdbut 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→ avatar; media hero with the lift list and the CTA inside it; budget caption; engine tiles; week strip demoted to the bottom.Two bugs found by looking at it, not testing it
Double(10.2), notInt— which failed the decode of the entire cardio detail payload. Also anchored the bodyweight and HR charts off zero; anAreaMarkbaselines at zero and flattens a 3 kg range into a hairline.Still not matching the Train boards
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.
LiftTrendderives 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
ios/CLAUDE.mdexplicitly 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".RecordRow.detailis 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.ExerciseInfodecode:media_tieris a string, not a count, anddontis a sentence sitting next to acueslist. Because the fetch istry?, 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, andUnits.steppedhas 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