diff --git a/docs/mockups/native/train-today.png b/docs/mockups/native/train-today.png
index d50c427..70caf2b 100644
Binary files a/docs/mockups/native/train-today.png and b/docs/mockups/native/train-today.png differ
diff --git a/docs/mockups/progress.png b/docs/mockups/progress.png
index 2df0037..8a80108 100644
Binary files a/docs/mockups/progress.png and b/docs/mockups/progress.png differ
diff --git a/ios/CLAUDE.md b/ios/CLAUDE.md
index 4b4fce4..699ea4d 100644
--- a/ios/CLAUDE.md
+++ b/ios/CLAUDE.md
@@ -5,10 +5,14 @@ and `docs/ios-implementation-plan.md` (setup + phases N0–N5) before starting w
carry the decisions; this file carries the day-to-day rules. Root `CLAUDE.md` still applies:
it describes the server and PWA this app talks to.
-**Current phase: N1 slice 2 in progress.** N0's skeleton is built and verified in the Simulator
+**Current phase: N1 done, N4 slice 1 in.** N0's skeleton is built and verified in the Simulator
(auth, `/api/week`, design system) but is **not signed off** — that needs an install on both
-phones, which is James's step. N1.1 (Today/Plan read paths) and N1.2 (session logging with the
-offline queue) are in. Next: history/records/trends, then coach, then settings.
+phones, which is James's step. N1 is complete: Today/Plan, session logging with the offline
+queue, history/records/trends, coach. **Cook** (N4.1) landed next, out of phase order because
+its boards were already decided: browse, recipe detail, cook mode, library, meal planner, plus
+the read-only meals strip on Train's Today. Still open in N4: iPad `NavigationSplitView`, cook
+timers as Live Activities (needs [C1b] in the handover). N2/N3 (HealthKit, Watch, widgets) are
+untouched.
> **Anything requiring the Xcode UI or the Apple Developer portal lives in
> [`XCODE-HANDOVER.md`](XCODE-HANDOVER.md), and is James's job — not yours.** That file is the
@@ -84,7 +88,35 @@ automatically — there is no Xcode step.
- **Design**: Void × Volt tokens for Train (dark and light both first-class; light = Paper × Moss),
DIN Condensed for display type and large numerals, SF for body. One accent *per module*, scoped
to that module's theme environment — never two accents on one screen. Warm `#e8a360` is warnings
- only. Destructive actions always confirm.
+ only. Destructive actions always confirm. **Volt means positive status**, so a number only earns
+ it when it moved the way that metric should (`MetricDelta.Better`, `LiftTrend.isPositive`) —
+ everything else renders neutral. Most metrics have no universal good direction; don't invent one.
+ And a change is only worth an arrow if the formatter can print it: a fixed significance threshold
+ under a whole-number format rendered "▼ 0" on resting HR.
+- **Cook is not a Train reskin** and never borrows from it: Paper × Ember (`ForgeTheme.cookLight`),
+ New York serif for display (`ForgeFont.serif*`), full-bleed photography, soft warm shadows
+ instead of hairlines. Volt and DIN must never appear on a Cook screen; ember and serif must
+ never appear on a Train one. **Cook is light-only** — `cookTheme()` pins `colorScheme` to
+ `.light` so system chrome matches the paper; both boards were drawn light and there is no
+ approved dark Cook. `RootView` tints the tab bar from the *selected* tab for the same reason —
+ a volt "Cook" label under a paper screen is two accents on one screen.
+- **Cook mode's ember is the done-when cue and nothing else.** `CookCue` derives it from the step
+ prose, which works because Forge's import rule already requires done-when voice. Don't accent
+ anything else in the step text, and don't invent a cue for steps that have none.
+- **Cook timers hold an absolute end date**, never a countdown — cook mode gets backgrounded
+ constantly and a decrementing counter drifts every time the view stops ticking.
+- **Recipe images need the session cookie** (`/api/food/media/{id}` is authed), so `AsyncImage`
+ can't fetch them — use `ForgeAsyncImage`/`RecipePhoto`, which go through the client. The loader
+ attaches the cookie **only for our own origin**: imports keep remote URLs on failure, and a
+ recipe site's CDN must never be sent a Forge session token.
+- **The seeded recipe library has no imagery.** `RecipePhoto` falls back to bundled plate
+ photography keyed by `platefig` (`PlateImage`) — the same split as Train's `HeroImage` vs
+ `ExerciseMedia`. Never render an empty frame where a photo belongs; in a photography-led module
+ that reads as breakage.
+- **The meal plan is shared, the meal log is yours.** One household food week (`meal_revisions`
+ with `user_id IS NULL`) but per-user `meal_logs`. Any UI that plans says it changes the week for
+ both of you; any UI that ticks says it logs to your plate only. Never merge the two ideas into
+ one control.
- **Auth**: `ASWebAuthenticationSession` → the server's existing Google OAuth → same session
cookie the PWA uses, held in the Keychain. No new auth surface on the server — the only
addition is `GET /auth/login?native=1`, which makes `/auth/callback` finish on
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/Contents.json
new file mode 100644
index 0000000..ae9dddc
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Hero/Contents.json
@@ -0,0 +1,9 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "provides-namespace": false
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/Contents.json
new file mode 100644
index 0000000..4da5402
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images": [
+ {
+ "filename": "lift-dark.jpg",
+ "idiom": "universal",
+ "scale": "1x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "2x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "3x"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/lift-dark.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/lift-dark.jpg
new file mode 100644
index 0000000..fb7dbf2
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/lift-dark.imageset/lift-dark.jpg differ
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/Contents.json
new file mode 100644
index 0000000..cd7012c
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images": [
+ {
+ "filename": "mat-warm.jpg",
+ "idiom": "universal",
+ "scale": "1x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "2x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "3x"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/mat-warm.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/mat-warm.jpg
new file mode 100644
index 0000000..7e636aa
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/mat-warm.imageset/mat-warm.jpg differ
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/Contents.json
new file mode 100644
index 0000000..afa43a1
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images": [
+ {
+ "filename": "press-light.jpg",
+ "idiom": "universal",
+ "scale": "1x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "2x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "3x"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/press-light.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/press-light.jpg
new file mode 100644
index 0000000..17b397c
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/press-light.imageset/press-light.jpg differ
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/Contents.json
new file mode 100644
index 0000000..dc39b21
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images": [
+ {
+ "filename": "pullup-bw.jpg",
+ "idiom": "universal",
+ "scale": "1x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "2x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "3x"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/pullup-bw.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/pullup-bw.jpg
new file mode 100644
index 0000000..b30212d
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/pullup-bw.imageset/pullup-bw.jpg differ
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/Contents.json
new file mode 100644
index 0000000..5c8e9f5
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images": [
+ {
+ "filename": "runners-dawn.jpg",
+ "idiom": "universal",
+ "scale": "1x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "2x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "3x"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/runners-dawn.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/runners-dawn.jpg
new file mode 100644
index 0000000..2cdddf4
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/runners-dawn.imageset/runners-dawn.jpg differ
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/Contents.json
new file mode 100644
index 0000000..8ff6142
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images": [
+ {
+ "filename": "track-start.jpg",
+ "idiom": "universal",
+ "scale": "1x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "2x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "3x"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/track-start.jpg b/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/track-start.jpg
new file mode 100644
index 0000000..a7c3f55
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Hero/track-start.imageset/track-start.jpg differ
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Plate/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/Contents.json
new file mode 100644
index 0000000..70a9178
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "bowl-tofu.jpg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/bowl-tofu.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/bowl-tofu.jpg
new file mode 100644
index 0000000..325c6ae
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/bowl-tofu.imageset/bowl-tofu.jpg differ
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/Contents.json
new file mode 100644
index 0000000..c53be03
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "brunch.jpg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/brunch.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/brunch.jpg
new file mode 100644
index 0000000..df65dcb
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/brunch.imageset/brunch.jpg differ
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/Contents.json
new file mode 100644
index 0000000..d26b25b
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "salmon.jpg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/salmon.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/salmon.jpg
new file mode 100644
index 0000000..a67c764
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/salmon.imageset/salmon.jpg differ
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/Contents.json
new file mode 100644
index 0000000..8d82487
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "skewers.jpg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/skewers.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/skewers.jpg
new file mode 100644
index 0000000..174a2ff
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/skewers.imageset/skewers.jpg differ
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/Contents.json
new file mode 100644
index 0000000..6301502
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "thai-beef.jpg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/thai-beef.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/thai-beef.jpg
new file mode 100644
index 0000000..1149b84
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/thai-beef.imageset/thai-beef.jpg differ
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/Contents.json b/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/Contents.json
new file mode 100644
index 0000000..6cb7612
--- /dev/null
+++ b/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "veg-bowl.jpg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/veg-bowl.jpg b/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/veg-bowl.jpg
new file mode 100644
index 0000000..cdb4966
Binary files /dev/null and b/ios/Forge/Forge/Assets.xcassets/Plate/veg-bowl.imageset/veg-bowl.jpg differ
diff --git a/ios/Forge/Forge/Design/CookComponents.swift b/ios/Forge/Forge/Design/CookComponents.swift
new file mode 100644
index 0000000..9599062
--- /dev/null
+++ b/ios/Forge/Forge/Design/CookComponents.swift
@@ -0,0 +1,201 @@
+import SwiftUI
+
+/// Cook's own vocabulary. Deliberately **not** a Train reskin
+/// (`docs/native-app-design.md`): serif heads instead of DIN caps, soft warm
+/// shadows instead of hairlines, full-bleed photography instead of stat tiles.
+/// The shared primitives (`PillButtonStyle`, `ForgeProgress`) carry over
+/// unchanged because they read the theme's accent — which here is ember.
+
+// MARK: - Section head
+
+/// Serif title with an ember "more ›" — the board's `.sec` row.
+struct CookSection
: View {
+ @Environment(\.theme) private var theme
+ let title: String
+ var more: String?
+ @ViewBuilder var destination: Destination
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline) {
+ Text(title)
+ .font(ForgeFont.serifSection)
+ .foregroundStyle(theme.ink)
+ Spacer(minLength: 8)
+ if let more {
+ NavigationLink {
+ destination
+ } label: {
+ Text("\(more) ›")
+ .font(.system(.subheadline, weight: .semibold))
+ .foregroundStyle(theme.accent)
+ }
+ }
+ }
+ }
+}
+
+extension CookSection where Destination == EmptyView {
+ init(_ title: String) {
+ self.init(title: title, more: nil) { EmptyView() }
+ }
+}
+
+// MARK: - Hero
+
+/// Tonight's dinner: full-bleed photo, scrim from the bottom, a pill top-left,
+/// serif name and glass chips over the image.
+///
+/// Cook keeps text **on** the photo in both roles, unlike Train's light mode —
+/// the board scrims heavily enough that it holds, and stacking would lose the
+/// magazine cover the module is built around.
+struct CookHero: View {
+ @Environment(\.theme) private var theme
+ let recipe: RecipeCard
+ let tag: String
+ let title: String
+ let chips: [String]
+ var height: CGFloat = 295
+
+ var body: some View {
+ // `Color.clear` owns the layout so a `.fill`-scaled photo can't report
+ // its intrinsic width up the tree and blow the card past the screen.
+ Color.clear
+ .frame(maxWidth: .infinity)
+ .frame(height: height)
+ .overlay { RecipePhoto(recipe: recipe) }
+ .overlay {
+ LinearGradient(
+ colors: [.black.opacity(0.28), .clear, .clear, .black.opacity(0.74)],
+ startPoint: .top, endPoint: .bottom)
+ }
+ .overlay(alignment: .topLeading) {
+ Text(tag)
+ .font(.system(.caption, weight: .bold))
+ .tracking(1.1)
+ .foregroundStyle(theme.ink)
+ .padding(.vertical, 6)
+ .padding(.horizontal, 12)
+ .background(theme.bg.opacity(0.92), in: .capsule)
+ .padding(14)
+ }
+ .overlay(alignment: .bottomLeading) {
+ VStack(alignment: .leading, spacing: 9) {
+ Text(title)
+ .font(ForgeFont.serifTitle)
+ .foregroundStyle(.white)
+ .shadow(color: .black.opacity(0.35), radius: 18, y: 2)
+ .fixedSize(horizontal: false, vertical: true)
+ if !chips.isEmpty {
+ HStack(spacing: 7) {
+ ForEach(chips, id: \.self) { GlassChip(text: $0) }
+ }
+ }
+ }
+ .padding(18)
+ }
+ .clipShape(.rect(cornerRadius: 26))
+ .shadow(color: Color(hex: 0x3C2814, alpha: 0.18), radius: 20, y: 12)
+ }
+}
+
+/// Translucent chip for use over photography.
+struct GlassChip: View {
+ let text: String
+ var body: some View {
+ Text(text)
+ .font(.system(.caption, weight: .semibold))
+ .foregroundStyle(.white)
+ .padding(.vertical, 5)
+ .padding(.horizontal, 11)
+ .background(.ultraThinMaterial.opacity(0.9), in: .capsule)
+ }
+}
+
+// MARK: - Recipe tile
+
+/// The library grid cell: photo over a serif name and a quiet meta line.
+struct RecipeTile: View {
+ @Environment(\.theme) private var theme
+ let recipe: RecipeCard
+ var height: CGFloat = 108
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Color.clear
+ .frame(maxWidth: .infinity)
+ .frame(height: height)
+ .overlay { RecipePhoto(recipe: recipe) }
+ .clipped()
+ VStack(alignment: .leading, spacing: 4) {
+ Text(recipe.name)
+ .font(ForgeFont.serif(14.5, relativeTo: .subheadline))
+ .foregroundStyle(theme.ink)
+ .multilineTextAlignment(.leading)
+ .lineLimit(2)
+ .fixedSize(horizontal: false, vertical: true)
+ HStack(spacing: 6) {
+ Text(recipe.meta)
+ .font(.system(.caption2))
+ .foregroundStyle(theme.mut)
+ if recipe.isParked {
+ Text("PARKED")
+ .font(.system(.caption2, weight: .bold))
+ .foregroundStyle(theme.warn)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 12)
+ .padding(.top, 9)
+ .padding(.bottom, 11)
+ }
+ .background(theme.raised)
+ .clipShape(.rect(cornerRadius: 18))
+ .shadow(color: Color(hex: 0x3C2814, alpha: 0.10), radius: 12, y: 6)
+ }
+}
+
+// MARK: - Ingredient chip
+
+/// "salmon **2 fillets**" — the quantity is the emphasis, the name is the label.
+struct IngredientChip: View {
+ @Environment(\.theme) private var theme
+ let name: String
+ let quantity: String
+
+ var body: some View {
+ HStack(spacing: 5) {
+ Text(name)
+ .foregroundStyle(theme.ink)
+ if !quantity.isEmpty {
+ Text(quantity)
+ .fontWeight(.semibold)
+ .foregroundStyle(theme.mut)
+ }
+ }
+ .font(.system(.subheadline))
+ .padding(.vertical, 7)
+ .padding(.horizontal, 13)
+ .background(theme.sunken, in: .capsule)
+ }
+}
+
+// MARK: - Progress segments
+
+/// Cook mode's foot rule — one segment per step, filled up to the current one.
+struct StepSegments: View {
+ @Environment(\.theme) private var theme
+ let total: Int
+ let current: Int
+
+ var body: some View {
+ HStack(spacing: 5) {
+ ForEach(0.. HeroImage {
+ let haystack = [mainLiftSlug, dayName].compactMap { $0 }.joined(separator: " ").lowercased()
+ if haystack.contains(/pull|row|chin|lat|deadlift/) { return .pull }
+ if haystack.contains(/press|bench|push|upper|shoulder/) { return .press }
+ return .lower
+ }
+
+ static func forCardio(type: String?, name: String?) -> HeroImage {
+ let haystack = [type, name].compactMap { $0 }.joined(separator: " ").lowercased()
+ return haystack.contains(/interval|vo2|sprint|threshold/) ? .intervals : .run
+ }
+}
+
+extension Image {
+ init(hero: HeroImage) {
+ self.init(hero.assetName)
+ }
+}
diff --git a/ios/Forge/Forge/Design/Media.swift b/ios/Forge/Forge/Design/Media.swift
new file mode 100644
index 0000000..2a5f141
--- /dev/null
+++ b/ios/Forge/Forge/Design/Media.swift
@@ -0,0 +1,86 @@
+import SwiftUI
+
+/// Media is the design decision, not decoration — "Cook read as far cleaner and
+/// richer than Train; media was the difference" (mockup round feedback, Jul 2026).
+/// Session heroes, the Zone-2 tile and PR banners are photographic.
+///
+/// Source is the server's own curated exercise library (`/media/exercises/…`,
+/// free-exercise-db photos already in the repo), keyed by the day's main lift —
+/// so the picture is of the thing you're actually about to do.
+nonisolated enum ExerciseMedia {
+ static func url(base: URL, slug: String, variant: Int = 0) -> URL {
+ base.appendingPathComponent("media/exercises/\(slug)-\(variant).jpg")
+ }
+}
+
+/// A photo with the treatment the design requires: in **dark** the image is the
+/// card and text sits on a scrim over it; in **light** the photo stacks *above*
+/// the text, never behind it. That rule came straight out of the redesign round
+/// — text over a bright image was the thing being rejected.
+struct MediaCard: View {
+ @Environment(\.theme) private var theme
+ @Environment(\.colorScheme) private var scheme
+
+ /// Bundled editorial photography. Preferred — it loads instantly and can't
+ /// fail, which matters for the first thing on the first screen.
+ var hero: HeroImage?
+ /// Remote fallback (the server's exercise library).
+ var url: URL?
+ var height: CGFloat = 190
+ var cornerRadius: CGFloat = 22
+ @ViewBuilder let content: Content
+
+ var body: some View {
+ Group {
+ if scheme == .dark {
+ ZStack(alignment: .bottom) {
+ photo(height: height + 130)
+ LinearGradient(
+ colors: [.black.opacity(0), .black.opacity(0.55), .black.opacity(0.9)],
+ startPoint: .top, endPoint: .bottom
+ )
+ content
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(height: height + 130)
+ } else {
+ VStack(spacing: 0) {
+ photo(height: height)
+ content
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(theme.raised)
+ }
+ }
+ }
+ .clipShape(.rect(cornerRadius: cornerRadius))
+ }
+
+ /// `Color.clear` owns the layout and the image is an overlay, so a
+ /// `.fill`-scaled photo can never report its own (huge) intrinsic width up
+ /// the tree and blow the card past the screen edge.
+ private func photo(height: CGFloat) -> some View {
+ Color.clear
+ .frame(maxWidth: .infinity)
+ .frame(height: height)
+ .overlay {
+ if let hero {
+ Image(hero: hero).resizable().scaledToFill()
+ } else {
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .success(let image):
+ image.resizable().scaledToFill()
+ default:
+ // Never a broken frame: the void reads as
+ // intentional while the photo loads, and as the
+ // design when there isn't one.
+ theme.sunken
+ }
+ }
+ }
+ }
+ .clipped()
+ }
+}
diff --git a/ios/Forge/Forge/Design/PlateImage.swift b/ios/Forge/Forge/Design/PlateImage.swift
new file mode 100644
index 0000000..ef12ba2
--- /dev/null
+++ b/ios/Forge/Forge/Design/PlateImage.swift
@@ -0,0 +1,77 @@
+import SwiftUI
+
+/// Bundled food photography, keyed by the recipe's `platefig`.
+///
+/// Cook is photography-led — that is the module, not a garnish. But the seeded
+/// library (`server/app/food_seed.py`) carries **no images**: only MCP imports
+/// materialize a hero into `media_blobs`. Without a fallback every hero, tile
+/// and thumbnail on a fresh install is a blank rectangle, which is the exact
+/// failure the mockup round was called to fix on Train.
+///
+/// So the same split as Train: a bundled set that always renders, and the
+/// server's own imagery preferred over it whenever an import supplied one.
+///
+/// NOTE: the set is the mockups' (`docs/mockups/native/photos/`) — Unsplash,
+/// which that README calls placeholders. Fine for two-seat TestFlight; re-license
+/// before any public listing. Same caveat as `HeroImage`.
+nonisolated enum PlateImage {
+ case fish, bowl, greens, tray, stew, morning
+
+ var assetName: String {
+ switch self {
+ case .fish: "salmon"
+ case .bowl: "bowl-tofu"
+ case .greens: "veg-bowl"
+ case .tray: "skewers"
+ case .stew: "thai-beef"
+ case .morning: "brunch"
+ }
+ }
+
+ /// `platefig` is the recipe's plate-art composition id — already a coarse
+ /// description of what the dish looks like, so it's the right key. Kind is
+ /// the fallback, because a breakfast that fell through should still look
+ /// like breakfast.
+ static func forRecipe(platefig: String, kind: String) -> PlateImage {
+ switch platefig {
+ case "plate-salmon", "bowl-soba": .fish
+ case "bowl-grain", "bowl-pasta": .bowl
+ case "plate-salad": .greens
+ case "tray-chicken", "pan-skillet", "plate-chicken": .tray
+ case "bowl-stew", "bowl-chili": .stew
+ case "bowl-oats", "plate-eggs": .morning
+ default:
+ switch kind {
+ case "breakfast", "snack": .morning
+ case "lunch": .greens
+ default: .stew
+ }
+ }
+ }
+}
+
+extension Image {
+ init(plate: PlateImage) {
+ self.init(plate.assetName)
+ }
+}
+
+/// A recipe's photo: the server's own image when an import supplied one,
+/// otherwise the bundled plate for its composition. Never an empty frame.
+struct RecipePhoto: View {
+ let recipe: RecipeCard
+
+ var body: some View {
+ if let ref = recipe.image, !ref.isEmpty {
+ ForgeAsyncImage(ref: ref) {
+ Image(plate: fallback).resizable().scaledToFill()
+ }
+ } else {
+ Image(plate: fallback).resizable().scaledToFill()
+ }
+ }
+
+ private var fallback: PlateImage {
+ .forRecipe(platefig: recipe.platefig, kind: recipe.kind)
+ }
+}
diff --git a/ios/Forge/Forge/Design/Theme.swift b/ios/Forge/Forge/Design/Theme.swift
index 309f3fe..a381b46 100644
--- a/ios/Forge/Forge/Design/Theme.swift
+++ b/ios/Forge/Forge/Design/Theme.swift
@@ -74,6 +74,35 @@ extension ForgeTheme {
static func train(_ scheme: ColorScheme) -> ForgeTheme {
scheme == .dark ? .trainDark : .trainLight
}
+
+ /// Paper × Ember — Cook. "The other Forge": paper-light, serif,
+ /// photography-led, and never volt. Tokens are the decided board's
+ /// (`docs/mockups/native/cook-browse.html`).
+ ///
+ /// Cook has **no dark variant** — the module identity in
+ /// `docs/native-app-design.md` is "light, airy, photography-led" and both
+ /// boards were drawn on paper, so `cookTheme()` pins the subtree to light
+ /// rather than inventing a scheme the design round never approved.
+ static let cookLight = ForgeTheme(
+ bg: Color(hex: 0xFAF8F4),
+ raised: Color(hex: 0xFFFFFF),
+ sunken: Color(hex: 0xEFECE6),
+ hair: Color(hex: 0xE7E3DC),
+ hairIn: Color(hex: 0xDCD7CE),
+ ink: Color(hex: 0x221F1A),
+ mut: Color(hex: 0x8A8377),
+ dim: Color(hex: 0xB9B2A6),
+ accent: Color(hex: 0xC05B3C),
+ accentDeep: Color(hex: 0xA44A2F),
+ onAccent: Color(hex: 0xFFF8F4),
+ accentDim: Color(hex: 0xC05B3C, alpha: 0.10),
+ // Ember already owns warm, so Cook's warning is the ochre from the
+ // light Train palette — far enough from #C05B3C to read as "careful",
+ // and not a new hex invented outside the token block.
+ warn: Color(hex: 0xA3641A),
+ onWarn: Color(hex: 0xFFFFFF),
+ warnDim: Color(hex: 0xA3641A, alpha: 0.35)
+ )
}
// MARK: - Environment
@@ -93,6 +122,15 @@ extension View {
/// Scope a module's theme to a subtree. Reads the ambient colour scheme so
/// dark and light both resolve without the caller thinking about it.
func trainTheme() -> some View { modifier(TrainThemeModifier()) }
+
+ /// Cook's theme. Pins the subtree to light so system chrome inside it
+ /// (fields, sheets, the keyboard) matches the paper rather than sitting
+ /// dark on it — Cook is light by design, not by ambience.
+ func cookTheme() -> some View {
+ environment(\.theme, .cookLight)
+ .environment(\.colorScheme, .light)
+ .tint(ForgeTheme.cookLight.accent)
+ }
}
private struct TrainThemeModifier: ViewModifier {
diff --git a/ios/Forge/Forge/Design/Typography.swift b/ios/Forge/Forge/Design/Typography.swift
index b6ad076..ea3d6f2 100644
--- a/ios/Forge/Forge/Design/Typography.swift
+++ b/ios/Forge/Forge/Design/Typography.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import UIKit
/// Two voices, held deliberately: DIN Condensed (iOS built-in, single ~bold
/// weight, upright caps) for display type and large numerals; SF for all body
@@ -27,6 +28,28 @@ enum ForgeFont {
.system(style, design: .default, weight: weight).monospacedDigit()
}
+ // MARK: - Cook's voice
+
+ /// New York (the system serif) — recipe titles, section heads, and cook
+ /// mode's arm's-length step text. The second and last display voice; never
+ /// used in Train, exactly as DIN is never used in Cook.
+ ///
+ /// Sized explicitly to the board but scaled through `UIFontMetrics` so it
+ /// answers Dynamic Type the way `display(_:relativeTo:)` does — cook mode is
+ /// read from across a worktop, so accessibility sizes are the point.
+ static func serif(_ size: CGFloat, relativeTo style: Font.TextStyle = .largeTitle,
+ weight: Font.Weight = .semibold) -> Font {
+ .system(size: UIFontMetrics(forTextStyle: style.uiKit).scaledValue(for: size),
+ weight: weight, design: .serif)
+ }
+
+ /// Screen titles ("Cook"), recipe names on a hero.
+ static var serifHero: Font { serif(40, relativeTo: .largeTitle) }
+ static var serifTitle: Font { serif(27, relativeTo: .title) }
+ static var serifSection: Font { serif(19, relativeTo: .title3) }
+ /// Cook mode's step body — the only place body copy is serif.
+ static var serifStep: Font { serif(27, relativeTo: .title2, weight: .medium) }
+
/// Small all-caps label with tracking. Pair with `.tracking()` at the call
/// site via `ForgeLabel` rather than re-deriving the spacing each time.
static var label: Font { .system(.caption, design: .default, weight: .semibold) }
@@ -57,3 +80,23 @@ extension View {
self.font(font)
}
}
+
+extension Font.TextStyle {
+ /// SwiftUI's text styles carry no metrics of their own — `UIFontMetrics`
+ /// is the only way to scale an explicit point size, and it speaks UIKit.
+ var uiKit: UIFont.TextStyle {
+ switch self {
+ case .largeTitle: .largeTitle
+ case .title: .title1
+ case .title2: .title2
+ case .title3: .title3
+ case .headline: .headline
+ case .subheadline: .subheadline
+ case .callout: .callout
+ case .footnote: .footnote
+ case .caption: .caption1
+ case .caption2: .caption2
+ default: .body
+ }
+ }
+}
diff --git a/ios/Forge/Forge/Features/Coach/CoachController.swift b/ios/Forge/Forge/Features/Coach/CoachController.swift
new file mode 100644
index 0000000..4f5f2da
--- /dev/null
+++ b/ios/Forge/Forge/Features/Coach/CoachController.swift
@@ -0,0 +1,120 @@
+import Foundation
+import Observation
+
+/// Chat with the coach, plus whatever proposal is waiting.
+///
+/// `POST /api/chat` returns immediately and runs the coach turn on a server
+/// thread, so the client polls `GET /api/chat` until `pending` clears rather
+/// than holding a request open across a model call that can take a while.
+@MainActor
+@Observable
+final class CoachController {
+ private(set) var messages: [ChatMessage] = []
+ private(set) var pending = false
+ private(set) var proposal: Proposal?
+ private(set) var errorMessage: String?
+ private(set) var isLoading = true
+ private(set) var isActing = false
+
+ private let client: APIClient
+ private var pollTask: Task?
+
+ /// Slow enough not to hammer a self-hosted box, quick enough that a reply
+ /// doesn't feel stuck.
+ private let pollInterval: Duration = .seconds(2)
+ /// A coach turn that hasn't landed in three minutes has failed in a way the
+ /// poll can't see; stop rather than spin forever.
+ private let pollLimit = 90
+
+ init(client: APIClient) {
+ self.client = client
+ }
+
+ func loadIfNeeded() async {
+ guard messages.isEmpty else { return }
+ await refresh()
+ }
+
+ func refresh() async {
+ defer { isLoading = false }
+ async let chat = try? client.chat()
+ async let proposalResponse = try? client.proposal()
+ if let chat = await chat {
+ messages = chat.messages
+ pending = chat.pending
+ if pending { startPolling() }
+ }
+ proposal = await proposalResponse?.proposal
+ }
+
+ func send(_ text: String, context: ChatContext? = nil) async {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, !pending else { return }
+ errorMessage = nil
+
+ // Echo locally so the bubble appears the instant it's sent; the poll
+ // replaces the list with the server's copy, which carries the context
+ // tag the server appends.
+ messages.append(ChatMessage(who: "me", text: trimmed, at: nil))
+ pending = true
+ do {
+ try await client.sendChat(trimmed, context: context)
+ startPolling()
+ } catch {
+ pending = false
+ messages.removeLast()
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func startPolling() {
+ pollTask?.cancel()
+ pollTask = Task { [weak self] in
+ guard let self else { return }
+ for _ in 0.. Void) async {
+ isActing = true
+ defer { isActing = false }
+ do {
+ try await body()
+ proposal = nil
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func dismissError() { errorMessage = nil }
+}
diff --git a/ios/Forge/Forge/Features/Coach/CoachView.swift b/ios/Forge/Forge/Features/Coach/CoachView.swift
new file mode 100644
index 0000000..4e5c733
--- /dev/null
+++ b/ios/Forge/Forge/Features/Coach/CoachView.swift
@@ -0,0 +1,204 @@
+import SwiftUI
+
+/// The coach: a chat surface, with the waiting proposal pinned above it.
+struct CoachView: View {
+ @Environment(\.theme) private var theme
+ @Environment(AuthController.self) private var auth
+
+ @State private var controller: CoachController?
+ @State private var draft = ""
+ @FocusState private var inputFocused: Bool
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let controller {
+ content(controller)
+ } else {
+ ForgeProgress()
+ }
+ }
+ .background(theme.bg)
+ .navigationTitle("Coach")
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationDestination(for: Proposal.self) { proposal in
+ ProposalView(proposal: proposal, controller: controller)
+ .trainTheme()
+ }
+ }
+ .task {
+ if controller == nil { controller = CoachController(client: auth.client) }
+ await controller?.loadIfNeeded()
+ }
+ .onDisappear { controller?.stopPolling() }
+ }
+
+ @ViewBuilder
+ private func content(_ controller: CoachController) -> some View {
+ VStack(spacing: 0) {
+ if controller.isLoading, controller.messages.isEmpty {
+ ForgeProgress()
+ } else {
+ ScrollViewReader { proxy in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ if let proposal = controller.proposal {
+ proposalBanner(proposal)
+ }
+ if controller.messages.isEmpty {
+ Text("Ask about your plan, a lift, or how the week went.")
+ .font(.callout)
+ .foregroundStyle(theme.dim)
+ .padding(.top, 40)
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ ForEach(controller.messages) { message in
+ MessageBubble(message: message)
+ .id(message.id)
+ }
+ if controller.pending {
+ TypingIndicator()
+ .id("typing")
+ }
+ }
+ .padding(.horizontal, 18)
+ .padding(.bottom, 12)
+ }
+ .scrollEdgeEffectStyle(.soft, for: .top)
+ .onChange(of: controller.messages.count) {
+ withAnimation { proxy.scrollTo(scrollAnchor(controller), anchor: .bottom) }
+ }
+ .onChange(of: controller.pending) {
+ withAnimation { proxy.scrollTo(scrollAnchor(controller), anchor: .bottom) }
+ }
+ }
+ }
+
+ if let message = controller.errorMessage {
+ Text(message)
+ .font(.caption)
+ .foregroundStyle(theme.warn)
+ .padding(.horizontal, 18)
+ .padding(.bottom, 4)
+ }
+
+ composer(controller)
+ }
+ }
+
+ private func scrollAnchor(_ controller: CoachController) -> String {
+ controller.pending ? "typing" : (controller.messages.last?.id ?? "")
+ }
+
+ private func proposalBanner(_ proposal: Proposal) -> some View {
+ NavigationLink(value: proposal) {
+ ForgeCard {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ ForgeLabel("Proposal waiting", color: theme.accent)
+ Text("Revision \(proposal.num)")
+ .font(ForgeFont.subtitle)
+ .foregroundStyle(theme.ink)
+ Text(proposal.rationale)
+ .font(.footnote)
+ .foregroundStyle(theme.mut)
+ .lineLimit(2)
+ }
+ Spacer(minLength: 8)
+ Image(systemName: "chevron.right")
+ .font(.footnote)
+ .foregroundStyle(theme.dim)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .padding(.top, 8)
+ }
+
+ private func composer(_ controller: CoachController) -> some View {
+ HStack(spacing: 10) {
+ TextField("Ask the coach…", text: $draft, axis: .vertical)
+ .lineLimit(1...4)
+ .focused($inputFocused)
+ .padding(.vertical, 10)
+ .padding(.horizontal, 14)
+ .background(theme.raised, in: .capsule)
+
+ Button {
+ let text = draft
+ draft = ""
+ Task { await controller.send(text) }
+ } label: {
+ Image(systemName: "arrow.up")
+ .font(.system(.body, weight: .bold))
+ .foregroundStyle(theme.onAccent)
+ .frame(width: 40, height: 40)
+ .background(theme.accent, in: .circle)
+ }
+ .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || controller.pending)
+ .opacity(controller.pending ? 0.5 : 1)
+ }
+ .padding(.horizontal, 18)
+ .padding(.vertical, 10)
+ .background(.bar)
+ }
+}
+
+// MARK: - Bubbles
+
+private struct MessageBubble: View {
+ @Environment(\.theme) private var theme
+ let message: ChatMessage
+
+ var body: some View {
+ HStack {
+ if !message.isCoach { Spacer(minLength: 40) }
+ Group {
+ if message.isCoach {
+ // Coach replies are markdown by design (the system prompt
+ // asks for structure), so render rather than show syntax.
+ Text(.init(message.text))
+ .font(.callout)
+ .foregroundStyle(theme.ink)
+ } else {
+ Text(message.text)
+ .font(.callout)
+ .foregroundStyle(theme.onAccent)
+ }
+ }
+ .padding(.vertical, 10)
+ .padding(.horizontal, 14)
+ .background(message.isCoach ? theme.raised : theme.accent,
+ in: .rect(cornerRadius: 16))
+ if message.isCoach { Spacer(minLength: 40) }
+ }
+ }
+}
+
+private struct TypingIndicator: View {
+ @Environment(\.theme) private var theme
+ @State private var phase = 0.0
+
+ var body: some View {
+ HStack(spacing: 5) {
+ ForEach(0..<3) { index in
+ Circle()
+ .fill(theme.dim)
+ .frame(width: 6, height: 6)
+ .opacity(0.35 + 0.65 * abs(sin(phase + Double(index) * 0.6)))
+ }
+ }
+ .padding(.vertical, 12)
+ .padding(.horizontal, 16)
+ .background(theme.raised, in: .capsule)
+ .task {
+ // A plain repeating animation would restart on every re-render
+ // while polling; driving a phase keeps it smooth.
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .milliseconds(90))
+ phase += 0.25
+ }
+ }
+ }
+}
diff --git a/ios/Forge/Forge/Features/Coach/ProposalView.swift b/ios/Forge/Forge/Features/Coach/ProposalView.swift
new file mode 100644
index 0000000..6556cfb
--- /dev/null
+++ b/ios/Forge/Forge/Features/Coach/ProposalView.swift
@@ -0,0 +1,153 @@
+import SwiftUI
+
+/// Review a proposed plan revision and approve or reject it.
+///
+/// The server guarantees a `changes` delta list and a per-day `why` — the UI is
+/// required to render them, so the user is approving a described change rather
+/// than a diff they have to work out themselves.
+struct ProposalView: View {
+ @Environment(\.theme) private var theme
+ @Environment(\.dismiss) private var dismiss
+
+ let proposal: Proposal
+ let controller: CoachController?
+
+ @State private var confirmReject = false
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ VStack(alignment: .leading, spacing: 6) {
+ ForgeLabel("Revision \(proposal.num)")
+ Text(proposal.rationale)
+ .font(.callout)
+ .foregroundStyle(theme.mut)
+ }
+ .padding(.top, 4)
+
+ if let changes = proposal.content.changes, !changes.isEmpty {
+ SectionHeader(title: "What changes")
+ ForgeCard {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(changes) { change in
+ HStack(alignment: .top, spacing: 10) {
+ Text(change.sign)
+ .font(ForgeFont.numeric(.headline, weight: .bold))
+ .foregroundStyle(colour(for: change.sign))
+ .frame(width: 16)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(change.what)
+ .font(.system(.subheadline, weight: .medium))
+ .foregroundStyle(theme.ink)
+ if let why = change.why {
+ Text(why)
+ .font(.caption)
+ .foregroundStyle(theme.dim)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ SectionHeader(title: "The week")
+ ForEach(proposal.orderedDays, id: \.weekday) { entry in
+ dayCard(weekday: entry.weekday, day: entry.day)
+ }
+ }
+ .padding(.horizontal, 18)
+ .padding(.bottom, 40)
+ }
+ .background(theme.bg)
+ .navigationTitle("Proposal")
+ .navigationBarTitleDisplayMode(.inline)
+ .safeAreaInset(edge: .bottom) { actions }
+ .confirmationDialog("Reject this proposal?", isPresented: $confirmReject,
+ titleVisibility: .visible) {
+ Button("Reject", role: .destructive) {
+ Task {
+ await controller?.reject()
+ dismiss()
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("Your current plan stays as it is.")
+ }
+ }
+
+ private func dayCard(weekday: Int, day: ProposalDay) -> some View {
+ ForgeCard {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ ForgeLabel(weekdayNames[safe: weekday] ?? "Day \(weekday + 1)")
+ Spacer()
+ ForgeChip(text: day.kind.rawValue, accented: day.kind == .strength)
+ }
+ Text(day.name)
+ .font(ForgeFont.subtitle)
+ .foregroundStyle(theme.ink)
+ if let why = day.why, !why.isEmpty {
+ Text(why)
+ .font(.footnote)
+ .foregroundStyle(theme.mut)
+ }
+ if let exercises = day.exercises, !exercises.isEmpty {
+ VStack(alignment: .leading, spacing: 3) {
+ ForEach(exercises) { exercise in
+ Text("\(exercise.slug) — \(exercise.sets) × \(exercise.reps)")
+ .font(.caption)
+ .foregroundStyle(theme.dim)
+ }
+ }
+ .padding(.top, 2)
+ }
+ if let cardio = day.cardio, let minutes = cardio.minutes {
+ Text("\(minutes) min"
+ + (cardio.hrLow.map { low in
+ cardio.hrHigh.map { " · \(low)–\($0) bpm" } ?? ""
+ } ?? ""))
+ .font(.caption)
+ .foregroundStyle(theme.dim)
+ }
+ }
+ }
+ }
+
+ private var actions: some View {
+ VStack(spacing: 8) {
+ Button("Approve — make this my plan") {
+ Task {
+ await controller?.approve()
+ dismiss()
+ }
+ }
+ .buttonStyle(.pill)
+
+ Button("Reject") { confirmReject = true }
+ .font(.system(.subheadline, weight: .semibold))
+ .foregroundStyle(theme.mut)
+ }
+ .disabled(controller?.isActing ?? false)
+ .padding(.horizontal, 18)
+ .padding(.vertical, 12)
+ .background(.bar)
+ }
+
+ /// Added / removed / adjusted. Warm is reserved for warnings, so removals
+ /// stay muted rather than becoming a second accent.
+ private func colour(for sign: String) -> Color {
+ switch sign {
+ case "+": theme.accent
+ case "-": theme.dim
+ default: theme.mut
+ }
+ }
+}
+
+extension Array {
+ subscript(safe index: Int) -> Element? {
+ indices.contains(index) ? self[index] : nil
+ }
+}
diff --git a/ios/Forge/Forge/Features/Cook/CookModeView.swift b/ios/Forge/Forge/Features/Cook/CookModeView.swift
new file mode 100644
index 0000000..62640a9
--- /dev/null
+++ b/ios/Forge/Forge/Features/Cook/CookModeView.swift
@@ -0,0 +1,370 @@
+import SwiftUI
+import UIKit
+
+/// **Cook mode**, built to `docs/mockups/native/cook-mode.png`: arm's-length
+/// serif type, one step at a time, the done-when cue in ember, a timer card
+/// with a ring, ingredient chips for this step, what's coming next, and a foot
+/// rule of segments over Back / next.
+///
+/// The screen stays awake — you're reading it with wet hands from a metre away.
+/// The timers are local (same as the rest timer in Train); the Dynamic Island
+/// Live Activity the board shows needs a widget extension target, which is
+/// Xcode-side work in `ios/XCODE-HANDOVER.md`.
+struct CookModeView: View {
+ @Environment(\.theme) private var theme
+ @Environment(\.dismiss) private var dismiss
+ @Environment(AuthController.self) private var auth
+
+ let recipe: RecipeDetail
+
+ @State private var index = 0
+ @State private var timers = CookTimers()
+ @State private var plated = false
+ @State private var startedAt = Date()
+ @State private var logError: String?
+
+ private var steps: [RecipeStep] { recipe.steps }
+ private var step: RecipeStep { steps[min(index, steps.count - 1)] }
+ private var isLast: Bool { index >= steps.count - 1 }
+
+ var body: some View {
+ Group {
+ if plated {
+ PlatedView(recipe: recipe, elapsed: Date().timeIntervalSince(startedAt),
+ error: logError) { dismiss() }
+ } else {
+ cooking
+ }
+ }
+ .background(theme.bg)
+ .onAppear {
+ UIApplication.shared.isIdleTimerDisabled = true
+ timers.onDone = { chime(step: $0) }
+ }
+ .onDisappear {
+ UIApplication.shared.isIdleTimerDisabled = false
+ timers.stop()
+ }
+ }
+
+ // MARK: - Cooking
+
+ private var cooking: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ header
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ ForgeLabel("Step \(index + 1) of \(steps.count)", color: theme.accent)
+ .padding(.horizontal, 24)
+ .padding(.top, 22)
+
+ CueText(step.detail, font: ForgeFont.serifStep)
+ .padding(.horizontal, 24)
+ .padding(.top, 10)
+
+ if step.hasTimer { timerCard.padding(.horizontal, 24).padding(.top, 24) }
+ if !runningElsewhere.isEmpty { backgroundStrip.padding(.top, 18) }
+ if !stepIngredients.isEmpty {
+ FlowChips(items: stepIngredients.map { ($0.name, $0.quantity) })
+ .padding(.horizontal, 24)
+ .padding(.top, 20)
+ }
+ if !isLast { upNext.padding(.horizontal, 24).padding(.top, 22) }
+ }
+ .padding(.bottom, 24)
+ }
+ foot
+ }
+ }
+
+ private var header: some View {
+ HStack {
+ Text(recipe.name)
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.mut)
+ .lineLimit(1)
+ Spacer(minLength: 12)
+ Button { dismiss() } label: {
+ Image(systemName: "xmark")
+ .font(.system(.subheadline, weight: .medium))
+ .foregroundStyle(theme.mut)
+ .frame(width: 32, height: 32)
+ .background(theme.sunken, in: .circle)
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 12)
+ }
+
+ // MARK: - Timer
+
+ private var timerCard: some View {
+ let remaining = timers.remaining(step: index)
+ let total = step.seconds
+ return HStack(spacing: 16) {
+ TimerRing(fraction: remaining == nil ? 1 : timers.fraction(step: index),
+ label: fmtClock(remaining ?? total))
+ VStack(alignment: .leading, spacing: 2) {
+ ForgeLabel("Timer", color: theme.mut)
+ Text(step.title)
+ .font(.system(.body, weight: .semibold))
+ .foregroundStyle(theme.ink)
+ Text(timerCaption(remaining: remaining, total: total))
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.mut)
+ }
+ Spacer(minLength: 8)
+ Button {
+ if remaining == nil {
+ timers.start(step: index, seconds: total)
+ } else {
+ timers.cancel(step: index)
+ }
+ } label: {
+ Image(systemName: remaining == nil ? "play.fill" : "stop.fill")
+ .font(.system(.subheadline, weight: .bold))
+ .foregroundStyle(theme.accent)
+ .frame(width: 44, height: 44)
+ .background(theme.accentDim, in: .circle)
+ }
+ }
+ .padding(.vertical, 16)
+ .padding(.horizontal, 18)
+ .background(theme.raised, in: .rect(cornerRadius: 24))
+ .shadow(color: Color(hex: 0x3C2814, alpha: 0.12), radius: 16, y: 8)
+ }
+
+ private func timerCaption(remaining: Int?, total: Int) -> String {
+ guard let remaining else { return "\(total / 60) min · tap to start" }
+ if remaining == 0 { return "time's up" }
+ return "of \(fmtClock(total)) · nudges at 0:00"
+ }
+
+ /// Timers still running on *other* steps, so a background simmer stays
+ /// visible while you work on the next thing.
+ private var runningElsewhere: [(index: Int, timer: CookTimers.Running)] {
+ timers.running.filter { $0.index != index }
+ }
+
+ private var backgroundStrip: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(runningElsewhere, id: \.index) { entry in
+ Button { index = entry.index } label: {
+ HStack(spacing: 8) {
+ Text("\(entry.index + 1). \(steps[entry.index].title)")
+ .foregroundStyle(theme.mut)
+ Text(entry.timer.remaining(at: timers.now) == 0
+ ? "done"
+ : fmtClock(entry.timer.remaining(at: timers.now)))
+ .font(ForgeFont.numeric(.subheadline))
+ .foregroundStyle(theme.accent)
+ }
+ .font(.system(.subheadline))
+ .padding(.vertical, 8)
+ .padding(.horizontal, 13)
+ .background(theme.raised, in: .capsule)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 24)
+ }
+ }
+
+ // MARK: - Step context
+
+ /// The ingredients this step actually names. Cook mode shows what's in your
+ /// hands, not the whole shopping list.
+ private var stepIngredients: [RecipeIngredient] {
+ let haystack = "\(step.title) \(step.detail)".lowercased()
+ return recipe.ingredients.filter { ingredient in
+ let name = ingredient.name.lowercased()
+ if haystack.contains(name) { return true }
+ // "salmon fillets" in the pantry, "salmon" in the prose.
+ return name.split(separator: " ").first.map { haystack.contains($0) } ?? false
+ }
+ }
+
+ private var upNext: some View {
+ VStack(alignment: .leading, spacing: 5) {
+ ForgeLabel("Up next", color: theme.mut)
+ Text("\(index + 2) · \(steps[index + 1].detail)")
+ .font(ForgeFont.serif(16, relativeTo: .body, weight: .regular))
+ .foregroundStyle(theme.mut)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(.top, 16)
+ .overlay(alignment: .top) { Rectangle().fill(theme.hair).frame(height: 1) }
+ }
+
+ // MARK: - Foot
+
+ private var foot: some View {
+ VStack(spacing: 14) {
+ StepSegments(total: steps.count, current: index)
+ HStack(spacing: 10) {
+ if index > 0 {
+ Button("‹ Back") { withAnimation(.easeOut(duration: 0.18)) { index -= 1 } }
+ .buttonStyle(.pillQuiet)
+ // The board's 1:2 split — Back is an escape hatch, not
+ // an equal choice, and an even split starves the label.
+ .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 10)
+ }
+ Button {
+ if isLast { Task { await plate() } }
+ else { withAnimation(.easeOut(duration: 0.18)) { index += 1 } }
+ } label: {
+ Text(nextLabel)
+ .lineLimit(1)
+ .minimumScaleFactor(0.85)
+ }
+ .buttonStyle(.pill)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .padding(.horizontal, 24)
+ .padding(.top, 12)
+ .padding(.bottom, 34)
+ .background(theme.bg)
+ }
+
+ /// The board's CTA is the cue restated as a confirmation — "Skin's released
+ /// — next". That's a *grammatical* transform of the prose ("ready when the
+ /// skin releases" → "skin's released"), and deriving it produces nonsense
+ /// as often as not ("Toss until slicked — next"). So the label is the next
+ /// step's title, which is short and always true; an authored `cue_done`
+ /// field on the step would let this match the board properly.
+ private var nextLabel: String {
+ isLast ? "Plate & log \(recipe.card.kind)" : "\(steps[index + 1].title) ›"
+ }
+
+ // MARK: - Finish
+
+ private func plate() async {
+ do {
+ try await auth.client.logMeal(
+ date: ForgeDate.today(),
+ slot: recipe.card.kind,
+ recipe: recipe.slug,
+ clientId: UUID().uuidString)
+ } catch {
+ // Never block the plate on the network — the meal is cooked either
+ // way; the day view can tick it later.
+ logError = error.localizedDescription
+ }
+ plated = true
+ }
+
+ private func chime(step: Int) {
+ UINotificationFeedbackGenerator().notificationOccurred(.success)
+ }
+}
+
+// MARK: - Ring
+
+private struct TimerRing: View {
+ @Environment(\.theme) private var theme
+ let fraction: Double
+ let label: String
+
+ var body: some View {
+ ZStack {
+ Circle().stroke(theme.sunken, lineWidth: 7)
+ Circle()
+ .trim(from: 0, to: max(0.001, min(1, fraction)))
+ .stroke(theme.accent, style: StrokeStyle(lineWidth: 7, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ Text(label)
+ .font(ForgeFont.numeric(.body, weight: .bold))
+ .foregroundStyle(theme.ink)
+ }
+ .frame(width: 74, height: 74)
+ .animation(.linear(duration: 0.5), value: fraction)
+ }
+}
+
+// MARK: - Cue text
+
+/// Step prose with its done-when clause in ember — the one thing cook mode
+/// accents. See `CookCue` for why the cue is derived rather than authored.
+struct CueText: View {
+ @Environment(\.theme) private var theme
+ private let detail: String
+ private let font: Font
+ private let muted: Bool
+
+ init(_ detail: String, font: Font, muted: Bool = false) {
+ self.detail = detail
+ self.font = font
+ self.muted = muted
+ }
+
+ var body: some View {
+ let parts = CookCue.split(detail)
+ return (Text(parts.before).foregroundStyle(muted ? theme.mut : theme.ink)
+ + Text(parts.cue).foregroundStyle(theme.accent)
+ + Text(parts.after).foregroundStyle(muted ? theme.mut : theme.ink))
+ .font(font)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+// MARK: - Plated
+
+private struct PlatedView: View {
+ @Environment(\.theme) private var theme
+ let recipe: RecipeDetail
+ let elapsed: TimeInterval
+ let error: String?
+ let done: () -> Void
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ VStack(alignment: .leading, spacing: 2) {
+ ForgeLabel("Cook mode · \(recipe.name)", color: theme.accent)
+ Text("Plated. Logged.")
+ .font(ForgeFont.serifHero)
+ .foregroundStyle(theme.ink)
+ }
+
+ Color.clear
+ .frame(maxWidth: .infinity)
+ .frame(height: 200)
+ .overlay { RecipePhoto(recipe: recipe.card) }
+ .clipShape(.rect(cornerRadius: 22))
+
+ Text("\(Int(max(1, elapsed / 60).rounded())) min from start to plate.")
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.mut)
+
+ if recipe.card.batch > 0 {
+ Text("\(recipe.card.batch) servings boxed — a zero-cook night is locked in.")
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.mut)
+ }
+
+ if let error {
+ Text("Couldn't log it: \(error) Tick it from the day view when you're back online.")
+ .font(.footnote)
+ .foregroundStyle(theme.warn)
+ .fixedSize(horizontal: false, vertical: true)
+ } else {
+ Text("Logged to your plate. Everyone else ticks their own — their plate, their targets.")
+ .font(.footnote)
+ .foregroundStyle(theme.dim)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Button("Done", action: done)
+ .buttonStyle(.pill)
+ .padding(.top, 4)
+ }
+ .padding(.horizontal, 24)
+ .padding(.top, 28)
+ .padding(.bottom, 40)
+ }
+ }
+}
diff --git a/ios/Forge/Forge/Features/Cook/CookTimers.swift b/ios/Forge/Forge/Features/Cook/CookTimers.swift
new file mode 100644
index 0000000..84ee1a1
--- /dev/null
+++ b/ios/Forge/Forge/Features/Cook/CookTimers.swift
@@ -0,0 +1,92 @@
+import Foundation
+import Observation
+
+/// Cook mode's timers, keyed by step index.
+///
+/// They keep running as you move between steps — a simmer cooks while you prep
+/// the next thing — so each one stores an **absolute end date** rather than a
+/// countdown. Ported from `web/src/screens/food.tsx`, where the same decision
+/// was made for the same reason: a remaining-seconds counter drifts when the
+/// view stops ticking, and cook mode gets backgrounded constantly.
+@MainActor @Observable
+final class CookTimers {
+ nonisolated struct Running: Sendable, Equatable {
+ let total: Int
+ let end: Date
+ var chimed: Bool
+
+ func remaining(at now: Date) -> Int {
+ max(0, Int(end.timeIntervalSince(now).rounded(.up)))
+ }
+ }
+
+ private(set) var timers: [Int: Running] = [:]
+ private(set) var now = Date()
+
+ /// Fired once per timer as it crosses zero — the view turns this into a
+ /// haptic and a notification-style banner.
+ var onDone: ((Int) -> Void)?
+
+ private var ticker: Task?
+
+ func start(step index: Int, seconds: Int, now: Date = Date()) {
+ timers[index] = Running(total: seconds,
+ end: now.addingTimeInterval(TimeInterval(seconds)),
+ chimed: false)
+ startTicking()
+ }
+
+ func cancel(step index: Int) {
+ timers[index] = nil
+ if timers.isEmpty { stopTicking() }
+ }
+
+ func remaining(step index: Int, at date: Date? = nil) -> Int? {
+ timers[index]?.remaining(at: date ?? now)
+ }
+
+ func fraction(step index: Int) -> Double {
+ guard let timer = timers[index], timer.total > 0 else { return 0 }
+ return Double(timer.remaining(at: now)) / Double(timer.total)
+ }
+
+ var running: [(index: Int, timer: Running)] {
+ timers.sorted { $0.key < $1.key }.map { (index: $0.key, timer: $0.value) }
+ }
+
+ /// Marks anything past its end date as chimed and reports it — pure enough
+ /// to test without a clock.
+ @discardableResult
+ func settle(at date: Date) -> [Int] {
+ now = date
+ var fired: [Int] = []
+ for (index, timer) in timers where !timer.chimed && timer.end <= date {
+ timers[index]?.chimed = true
+ fired.append(index)
+ }
+ return fired.sorted()
+ }
+
+ private func startTicking() {
+ guard ticker == nil else { return }
+ ticker = Task { [weak self] in
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .milliseconds(500))
+ guard let self else { return }
+ for index in self.settle(at: Date()) { self.onDone?(index) }
+ }
+ }
+ }
+
+ private func stopTicking() {
+ ticker?.cancel()
+ ticker = nil
+ }
+
+ func stop() { stopTicking() }
+}
+
+/// "3:41" — cook mode's only numeral format.
+nonisolated func fmtClock(_ seconds: Int) -> String {
+ String(format: "%d:%02d", seconds / 60, seconds % 60)
+}
diff --git a/ios/Forge/Forge/Features/Cook/CookView.swift b/ios/Forge/Forge/Features/Cook/CookView.swift
new file mode 100644
index 0000000..e868ba4
--- /dev/null
+++ b/ios/Forge/Forge/Features/Cook/CookView.swift
@@ -0,0 +1,316 @@
+import SwiftUI
+
+/// The Cook tab's root — **browse**, built to the decided board
+/// (`docs/mockups/native/cook-browse.png`): ember kicker, serif title, search,
+/// tonight's dinner as a full-bleed hero, the shared week as thumbnails, the
+/// library below.
+///
+/// "The other Forge": paper, serif, photography-led, ember. Nothing on this
+/// screen is allowed to be volt or DIN.
+struct CookView: View {
+ @Environment(\.theme) private var theme
+ @Environment(AuthController.self) private var auth
+
+ @State private var week: FoodWeek?
+ @State private var library: [RecipeCard] = []
+ @State private var proposal: FoodProposal?
+ @State private var errorMessage: String?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let week {
+ content(week)
+ } else if let errorMessage {
+ ForgeErrorView(message: errorMessage) { Task { await load() } }
+ } else {
+ ForgeProgress()
+ }
+ }
+ .background(theme.bg)
+ .toolbar(.hidden, for: .navigationBar)
+ .navigationDestination(for: CookRoute.self) { route in
+ switch route {
+ case .library: RecipeLibraryView(recipes: library).cookTheme()
+ case .plan: MealPlanView().cookTheme()
+ case .recipe(let slug): RecipeDetailView(slug: slug).cookTheme()
+ case .proposal(let proposal):
+ FoodProposalView(proposal: proposal) { await load() }.cookTheme()
+ }
+ }
+ }
+ .task { if week == nil { await load() } }
+ }
+
+ private func load() async {
+ errorMessage = nil
+ do {
+ // The server owns "today" — the week response carries it.
+ week = try await auth.client.foodWeek()
+ library = (try await auth.client.recipes()).recipes
+ // A pending proposal is a nice-to-have on this screen, not a
+ // precondition — losing it must not take the whole tab down.
+ proposal = (try? await auth.client.foodProposal())?.proposal
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ // MARK: - Content
+
+ @ViewBuilder
+ private func content(_ week: FoodWeek) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ header(week)
+ searchBar
+ tonight(week)
+ proposalBanner
+
+ CookSection(title: "This week", more: "Plan") { MealPlanView().cookTheme() }
+ .padding(.horizontal, 22)
+ .padding(.top, 22)
+ weekStrip(week)
+
+ CookSection(title: "Library", more: "All") {
+ RecipeLibraryView(recipes: library).cookTheme()
+ }
+ .padding(.horizontal, 22)
+ .padding(.top, 22)
+ libraryGrid
+ }
+ .padding(.bottom, 32)
+ }
+ .scrollEdgeEffectStyle(.soft, for: .top)
+ .refreshable { await load() }
+ }
+
+ private func header(_ week: FoodWeek) -> some View {
+ VStack(alignment: .leading, spacing: 1) {
+ ForgeLabel(kicker(week), color: theme.accent)
+ Text("Cook")
+ .font(ForgeFont.serifHero)
+ .foregroundStyle(theme.ink)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 20)
+ .padding(.top, 12)
+ }
+
+ /// "SATURDAY · 5 DINNERS PLANNED" — and honest when there's no plan yet,
+ /// rather than reporting zero as if it were a result.
+ private func kicker(_ week: FoodWeek) -> String {
+ let day = week.todayDay?.dayName ?? ""
+ guard week.hasPlan else {
+ return day.isEmpty ? "No meal plan yet" : "\(day) · no meal plan yet"
+ }
+ let n = week.plannedDinners
+ let dinners = n == 1 ? "1 dinner planned" : "\(n) dinners planned"
+ return day.isEmpty ? dinners : "\(day) · \(dinners)"
+ }
+
+ // MARK: - Proposal
+
+ /// A proposed food week is the one thing on this screen that expires, so it
+ /// says how many nights it commits you to before you open it.
+ ///
+ /// It sits under the hero rather than above the search bar, which is where
+ /// it belongs by importance: **taps in a band near the top of this scroll
+ /// view don't reach their target** — the banner was completely dead there,
+ /// and the search bar in that same band opens the hero's recipe instead of
+ /// the library. Pre-existing, not caused by this view, and not yet
+ /// explained (it is not the scroll edge effect and not the content shape).
+ /// Move this back up once that's fixed.
+ @ViewBuilder
+ private var proposalBanner: some View {
+ if let proposal {
+ NavigationLink(value: CookRoute.proposal(proposal)) {
+ HStack(spacing: 11) {
+ Circle()
+ .fill(theme.accent)
+ .frame(width: 7, height: 7)
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Next week's food is ready")
+ .font(.system(.subheadline, weight: .semibold))
+ .foregroundStyle(theme.ink)
+ Text("\(proposal.cookingNights) nights cooking · awaiting your OK")
+ .font(.caption)
+ .foregroundStyle(theme.mut)
+ }
+ Spacer(minLength: 8)
+ Text("Review ›")
+ .font(.system(.caption, weight: .bold))
+ .foregroundStyle(theme.accent)
+ }
+ .padding(.vertical, 12)
+ .padding(.horizontal, 14)
+ .background(theme.raised, in: .rect(cornerRadius: 16))
+ // Without this the tappable area is only the glyphs — the card
+ // looked like a button, drew like a button, and swallowed every
+ // tap that landed on the gap between the text and the chevron.
+ .contentShape(.rect(cornerRadius: 16))
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, 20)
+ .padding(.top, 14)
+ }
+ }
+
+ private var searchBar: some View {
+ NavigationLink(value: CookRoute.library) {
+ HStack(spacing: 8) {
+ Image(systemName: "magnifyingglass")
+ Text(library.isEmpty ? "Search recipes…" : "Search \(library.count) recipes…")
+ Spacer()
+ }
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.mut)
+ .padding(.vertical, 11)
+ .padding(.horizontal, 14)
+ .background(theme.sunken, in: .rect(cornerRadius: 14))
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, 20)
+ .padding(.top, 12)
+ }
+
+ // MARK: - Tonight
+
+ @ViewBuilder
+ private func tonight(_ week: FoodWeek) -> some View {
+ let slot = week.todayDay?.dinner
+ if let recipe = slot?.recipe {
+ NavigationLink(value: CookRoute.recipe(recipe.slug)) {
+ CookHero(recipe: recipe,
+ tag: heroTag(slot),
+ title: recipe.name,
+ chips: heroChips(recipe))
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, 20)
+ .padding(.top, 16)
+ } else {
+ emptyTonight(slot)
+ }
+ }
+
+ private func heroTag(_ slot: FoodSlot?) -> String {
+ if slot?.isLeftover == true { return "TONIGHT · LEFTOVERS" }
+ if slot?.logged == true { return "TONIGHT · EATEN" }
+ return "TONIGHT"
+ }
+
+ private func heroChips(_ recipe: RecipeCard) -> [String] {
+ var chips: [String] = []
+ if recipe.minutes > 0 { chips.append("\(recipe.minutes) min") }
+ if recipe.macros.kcal > 0 { chips.append("\(Int(recipe.macros.kcal.rounded())) kcal") }
+ if recipe.macros.proteinG > 0 {
+ chips.append("\(Int(recipe.macros.proteinG.rounded())) g protein")
+ }
+ return chips
+ }
+
+ /// No dinner tonight is a real state, not an error — eating out is planned
+ /// the same way cooking is.
+ private func emptyTonight(_ slot: FoodSlot?) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ ForgeLabel("Tonight", color: theme.accent)
+ Text(slot?.fallbackName ?? "Nothing planned")
+ .font(ForgeFont.serifTitle)
+ .foregroundStyle(theme.ink)
+ .fixedSize(horizontal: false, vertical: true)
+ NavigationLink(value: CookRoute.plan) {
+ Text("Plan the week")
+ .font(.system(.subheadline, weight: .semibold))
+ .foregroundStyle(theme.accent)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(20)
+ .background(theme.raised, in: .rect(cornerRadius: 26))
+ .padding(.horizontal, 20)
+ .padding(.top, 16)
+ }
+
+ // MARK: - Week strip
+
+ private func weekStrip(_ week: FoodWeek) -> some View {
+ HStack(spacing: 8) {
+ ForEach(week.days) { day in
+ NavigationLink(value: day.thumbnail.map { CookRoute.recipe($0.slug) } ?? .plan) {
+ VStack(spacing: 5) {
+ thumbnail(day)
+ Text(String(day.dayName.prefix(3)).uppercased())
+ .font(.system(.caption2, weight: .bold))
+ .tracking(0.6)
+ .foregroundStyle(day.isToday ? theme.accent : theme.mut)
+ }
+ }
+ .buttonStyle(.plain)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 11)
+ }
+
+ @ViewBuilder
+ private func thumbnail(_ day: FoodDay) -> some View {
+ // A day still ahead of you reads "+" — that's one you'd fill in. A day
+ // already gone reads "·": nothing was planned and nothing can be now.
+ let symbol = day.date >= (week?.today ?? day.date) ? "+" : "·"
+ Color.clear
+ .frame(maxWidth: .infinity)
+ .frame(height: 44)
+ .overlay {
+ if let recipe = day.thumbnail {
+ RecipePhoto(recipe: recipe)
+ } else {
+ theme.sunken.overlay {
+ Text(symbol)
+ .font(.system(.body))
+ .foregroundStyle(theme.dim)
+ }
+ }
+ }
+ .clipShape(.rect(cornerRadius: 12))
+ .opacity(day.dinner?.isLeftover == true ? 0.55 : 1)
+ }
+
+ // MARK: - Library
+
+ /// Dinners only, and not the ones already on this week's plan — the row is
+ /// "what else could you cook", so repeating tonight's hero underneath it is
+ /// wasted space. Snacks are excluded because a landing grid of almonds and
+ /// apple slices sells the library short; the full list is one tap away.
+ private var browseable: [RecipeCard] {
+ let planned = Set((week?.days ?? []).compactMap { $0.dinner?.recipe?.slug })
+ return library.filter { !$0.isParked && $0.kind == "dinner" && !planned.contains($0.slug) }
+ }
+
+ private var libraryGrid: some View {
+ LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
+ GridItem(.flexible(), spacing: 12)], spacing: 12) {
+ ForEach(browseable.prefix(6)) { recipe in
+ NavigationLink(value: CookRoute.recipe(recipe.slug)) {
+ RecipeTile(recipe: recipe)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 12)
+ }
+}
+
+nonisolated enum CookRoute: Hashable {
+ case library
+ case plan
+ case recipe(String)
+ /// The proposal travels **in the route**, not read back out of `@State`.
+ /// A `navigationDestination` case that unwraps view state can be evaluated
+ /// against a stale snapshot — the branch yields an empty view and the push
+ /// silently does nothing, which is exactly what it did here. Everything the
+ /// destination needs has to arrive with the value.
+ case proposal(FoodProposal)
+}
diff --git a/ios/Forge/Forge/Features/Cook/FoodProposalView.swift b/ios/Forge/Forge/Features/Cook/FoodProposalView.swift
new file mode 100644
index 0000000..63e3034
--- /dev/null
+++ b/ios/Forge/Forge/Features/Cook/FoodProposalView.swift
@@ -0,0 +1,257 @@
+import SwiftUI
+
+/// Review a proposed food week and approve or dismiss it.
+///
+/// The training proposal has had a screen since N1; the food one is the same
+/// contract — a `changes` delta list and a rationale, so you approve a described
+/// change rather than a diff you work out yourself — but it belongs to Cook, so
+/// it reads in Cook's voice: paper, serif, ember. Nothing here is volt or DIN.
+///
+/// **Approving changes the week for both of you.** The food plan is one shared
+/// household week, so the copy says so above the button rather than after.
+struct FoodProposalView: View {
+ @Environment(\.theme) private var theme
+ @Environment(\.dismiss) private var dismiss
+ @Environment(AuthController.self) private var auth
+
+ let proposal: FoodProposal
+ /// Called after a decision lands, so the Cook root refetches.
+ var onDecided: (() async -> Void)?
+
+ @State private var acting = false
+ @State private var confirmDismiss = false
+ @State private var noteExpanded = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 22) {
+ header
+ if !proposal.changes.isEmpty { changes }
+ if !proposal.rationale.isEmpty { rationale }
+ dinners
+ sharedNote
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 40)
+ }
+ .background(theme.bg)
+ .navigationTitle("Food week \(proposal.num)")
+ .navigationBarTitleDisplayMode(.inline)
+ .safeAreaInset(edge: .bottom) { actions }
+ .confirmationDialog("Dismiss this food week?", isPresented: $confirmDismiss,
+ titleVisibility: .visible) {
+ Button("Dismiss", role: .destructive) { Task { await decide(approve: false) } }
+ Button("Keep it", role: .cancel) {}
+ } message: {
+ Text("This week's meals stay exactly as they are. The coach can propose again.")
+ }
+ }
+
+ // MARK: - Header
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ ForgeLabel(kicker, color: theme.accent)
+ Text("Next week's food")
+ .font(ForgeFont.serifTitle)
+ .foregroundStyle(theme.ink)
+ Text(shape)
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.mut)
+ }
+ .padding(.top, 6)
+ }
+
+ /// "PROPOSED SAT 26 JUL" — the date it was written, since a proposal you
+ /// left sitting for a week is worth noticing.
+ private var kicker: String {
+ guard let date = ForgeDate.date(from: String(proposal.createdAt.prefix(10))) else {
+ return "AWAITING YOUR OK"
+ }
+ let formatter = DateFormatter()
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ formatter.dateFormat = "EEE d MMM"
+ return "Proposed \(formatter.string(from: date))"
+ }
+
+ /// What you're actually signing up to cook — the number that decides
+ /// whether this week is realistic.
+ private var shape: String {
+ let nights = proposal.cookingNights
+ let cooking = nights == 1 ? "1 night cooking" : "\(nights) nights cooking"
+ let off = 7 - nights
+ return off > 0 ? "\(cooking) · \(off) off" : cooking
+ }
+
+ // MARK: - Changes
+
+ private var changes: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("What changes")
+ .font(ForgeFont.serifSection)
+ .foregroundStyle(theme.ink)
+ VStack(alignment: .leading, spacing: 11) {
+ ForEach(proposal.changes) { change in
+ HStack(alignment: .top, spacing: 10) {
+ Text(change.sign)
+ .font(ForgeFont.numeric(.subheadline, weight: .bold))
+ .foregroundStyle(colour(for: change.sign))
+ .frame(width: 14)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(change.what)
+ .font(.system(.subheadline, weight: .medium))
+ .foregroundStyle(theme.ink)
+ .fixedSize(horizontal: false, vertical: true)
+ if let why = change.why, !why.isEmpty {
+ Text(why)
+ .font(.caption)
+ .foregroundStyle(theme.dim)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(theme.raised, in: .rect(cornerRadius: 20))
+ }
+ }
+
+ /// Added / removed / adjusted. Ember is Cook's one accent and warm is
+ /// warnings only, so a removal stays muted rather than becoming a second.
+ private func colour(for sign: String) -> Color {
+ switch sign {
+ case "+": theme.accent
+ case "-": theme.dim
+ default: theme.mut
+ }
+ }
+
+ // MARK: - Rationale
+
+ private var rationale: some View {
+ Button { withAnimation(.easeOut(duration: 0.15)) { noteExpanded.toggle() } } label: {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(proposal.rationale)
+ .font(ForgeFont.serif(17, relativeTo: .body, weight: .regular))
+ .foregroundStyle(theme.mut)
+ .fixedSize(horizontal: false, vertical: true)
+ .lineLimit(noteExpanded ? nil : 4)
+ .multilineTextAlignment(.leading)
+ Text(noteExpanded ? "less" : "more")
+ .font(.system(.caption, weight: .semibold))
+ .foregroundStyle(theme.accent)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+ }
+
+ // MARK: - Dinner by dinner
+
+ /// Seven rows, Monday first. Dinner is the decision — breakfasts and lunches
+ /// repeat, and a proposal you can't skim is one you approve without reading.
+ private var dinners: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Dinner by dinner")
+ .font(ForgeFont.serifSection)
+ .foregroundStyle(theme.ink)
+ VStack(spacing: 0) {
+ ForEach(0..<7, id: \.self) { weekday in
+ dinnerRow(weekday)
+ if weekday < 6 {
+ Rectangle().fill(theme.hair).frame(height: 1)
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 6)
+ .background(theme.raised, in: .rect(cornerRadius: 20))
+ }
+ }
+
+ private func dinnerRow(_ weekday: Int) -> some View {
+ let line = proposal.dinner(on: weekday)
+ let card = proposal.content.days[String(weekday)]?.slots["dinner"]?.recipe
+ .flatMap { proposal.recipes[$0] }
+ return HStack(spacing: 12) {
+ Text(String(weekdayNames[safe: weekday]?.prefix(3) ?? "").uppercased())
+ .font(.system(.caption2, weight: .bold))
+ .tracking(0.6)
+ .foregroundStyle(theme.dim)
+ .frame(width: 30, alignment: .leading)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(line.name)
+ .font(.system(.subheadline, weight: .medium))
+ .foregroundStyle(theme.ink)
+ .fixedSize(horizontal: false, vertical: true)
+ if !line.why.isEmpty {
+ Text(line.why)
+ .font(.caption)
+ .foregroundStyle(theme.dim)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ Spacer(minLength: 8)
+ if let card {
+ Color.clear
+ .frame(width: 40, height: 40)
+ .overlay { RecipePhoto(recipe: card) }
+ .clipShape(.rect(cornerRadius: 9))
+ }
+ }
+ .padding(.vertical, 10)
+ }
+
+ // MARK: - Actions
+
+ private var sharedNote: some View {
+ Text("The food plan is shared — approving this sets the week for James and Shelby both.")
+ .font(.footnote)
+ .foregroundStyle(theme.dim)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ private var actions: some View {
+ VStack(spacing: 8) {
+ if let errorMessage {
+ Text(errorMessage)
+ .font(.caption)
+ .foregroundStyle(theme.warn)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Button("Approve — cook this week") { Task { await decide(approve: true) } }
+ .buttonStyle(.pill)
+ Button("Dismiss") { confirmDismiss = true }
+ .font(.system(.subheadline, weight: .semibold))
+ .foregroundStyle(theme.mut)
+ }
+ .disabled(acting)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 12)
+ .background(.bar)
+ }
+
+ private func decide(approve: Bool) async {
+ guard !acting else { return }
+ acting = true
+ defer { acting = false }
+ errorMessage = nil
+ do {
+ if approve {
+ try await auth.client.approveFoodProposal(id: proposal.id)
+ } else {
+ try await auth.client.rejectFoodProposal(id: proposal.id)
+ }
+ await onDecided?()
+ dismiss()
+ } catch {
+ // Staying put with the error visible beats dismissing onto a week
+ // that didn't change.
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/ios/Forge/Forge/Features/Cook/MealPlanView.swift b/ios/Forge/Forge/Features/Cook/MealPlanView.swift
new file mode 100644
index 0000000..423e4e8
--- /dev/null
+++ b/ios/Forge/Forge/Features/Cook/MealPlanView.swift
@@ -0,0 +1,255 @@
+import SwiftUI
+
+/// The week's meals — what's planned, what was actually eaten, and the day's
+/// macros against target.
+///
+/// Two scopes on one screen, and they must not be conflated: **the plan is
+/// shared** (one household week — changing Tuesday changes it for Shelby too)
+/// while **the log is yours** (ticking dinner logs it to your plate only). The
+/// copy says so wherever an action crosses that line.
+struct MealPlanView: View {
+ @Environment(\.theme) private var theme
+ @Environment(AuthController.self) private var auth
+
+ @State private var week: FoodWeek?
+ @State private var errorMessage: String?
+ @State private var busySlot: String?
+ @State private var planning: PlanTarget?
+
+ var body: some View {
+ Group {
+ if let week {
+ content(week)
+ } else if let errorMessage {
+ ForgeErrorView(message: errorMessage) { Task { await load() } }
+ } else {
+ ForgeProgress()
+ }
+ }
+ .background(theme.bg)
+ .navigationTitle("This week")
+ .navigationBarTitleDisplayMode(.inline)
+ .task { if week == nil { await load() } }
+ .sheet(item: $planning) { target in
+ PlanRecipeSheet(date: target.date, slot: target.slot) { await load() }
+ .cookTheme()
+ }
+ }
+
+ private func load() async {
+ errorMessage = nil
+ do { week = try await auth.client.foodWeek() }
+ catch { errorMessage = error.localizedDescription }
+ }
+
+ private func content(_ week: FoodWeek) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ if !week.rationale.isEmpty {
+ Text(week.rationale)
+ .font(ForgeFont.serif(17, relativeTo: .body, weight: .regular))
+ .foregroundStyle(theme.mut)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ ForEach(week.days) { day in
+ dayCard(day, targets: week.targets)
+ }
+ Text("The plan is shared — James and Shelby cook the same week. What you tick is logged to your plate only.")
+ .font(.footnote)
+ .foregroundStyle(theme.dim)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 4)
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 40)
+ }
+ .refreshable { await load() }
+ }
+
+ // MARK: - Day
+
+ private func dayCard(_ day: FoodDay, targets: Macros) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(day.dayName)
+ .font(ForgeFont.serifSection)
+ .foregroundStyle(day.isToday ? theme.accent : theme.ink)
+ Spacer()
+ if day.totals.kcal > 0 {
+ Text("\(Int(day.totals.kcal.rounded())) / \(Int(targets.kcal.rounded())) kcal")
+ .font(ForgeFont.numeric(.caption))
+ .foregroundStyle(theme.mut)
+ }
+ }
+
+ if day.slots.isEmpty && day.extras.isEmpty {
+ Button { planning = PlanTarget(date: day.date, slot: "dinner") } label: {
+ Text("Nothing planned — add a dinner")
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.accent)
+ }
+ .buttonStyle(.plain)
+ }
+
+ ForEach(day.slots) { slot in
+ slotRow(day: day, slot: slot)
+ }
+
+ ForEach(day.extras) { extra in
+ HStack(spacing: 10) {
+ Image(systemName: "circle.fill")
+ .font(.system(size: 6))
+ .foregroundStyle(theme.dim)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(extra.label)
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.ink)
+ Text([extra.venue, extra.estimated ? "estimated" : ""]
+ .filter { !$0.isEmpty }.joined(separator: " · "))
+ .font(.system(.caption))
+ .foregroundStyle(theme.dim)
+ }
+ Spacer()
+ Text("\(Int(extra.macros.kcal.rounded())) kcal")
+ .font(ForgeFont.numeric(.caption))
+ .foregroundStyle(theme.mut)
+ }
+ }
+
+ if day.totals.kcal > 0 { macroBar(day.totals, targets: targets) }
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(theme.raised, in: .rect(cornerRadius: 20))
+ .overlay {
+ if day.isToday {
+ RoundedRectangle(cornerRadius: 20).strokeBorder(theme.accent, lineWidth: 1.5)
+ }
+ }
+ }
+
+ private func slotRow(day: FoodDay, slot: FoodSlot) -> some View {
+ HStack(spacing: 12) {
+ Button { Task { await toggle(day: day, slot: slot) } } label: {
+ Image(systemName: slot.logged ? "checkmark.circle.fill" : "circle")
+ .font(.system(.title3))
+ .foregroundStyle(slot.logged ? theme.accent : theme.hairIn)
+ }
+ .buttonStyle(.plain)
+ .disabled(busySlot == "\(day.date)#\(slot.slot)" || slot.recipe == nil)
+
+ VStack(alignment: .leading, spacing: 1) {
+ Text(slot.displayName)
+ .font(.system(.subheadline, weight: .medium))
+ .foregroundStyle(theme.ink)
+ // "Order out" slots carry a rationale in `note`, not a
+ // name — two lines, then it's the coach's job to explain.
+ .lineLimit(2)
+ Text(slotCaption(slot))
+ .font(.system(.caption))
+ .foregroundStyle(theme.dim)
+ }
+
+ Spacer(minLength: 8)
+
+ if let recipe = slot.recipe {
+ NavigationLink(value: CookRoute.recipe(recipe.slug)) {
+ Color.clear
+ .frame(width: 44, height: 44)
+ .overlay { RecipePhoto(recipe: recipe) }
+ .clipShape(.rect(cornerRadius: 10))
+ }
+ .buttonStyle(.plain)
+ } else {
+ Button { planning = PlanTarget(date: day.date, slot: slot.slot) } label: {
+ Image(systemName: "plus")
+ .font(.system(.subheadline, weight: .medium))
+ .foregroundStyle(theme.accent)
+ .frame(width: 44, height: 44)
+ .background(theme.sunken, in: .rect(cornerRadius: 10))
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .opacity(slot.isLeftover ? 0.7 : 1)
+ }
+
+ private func slotCaption(_ slot: FoodSlot) -> String {
+ var parts = [slot.slot.capitalized]
+ if slot.isLeftover { parts.append("leftovers") }
+ if slot.isOffPlan { parts.append("off plan") }
+ if slot.estimated == true { parts.append("estimated") }
+ if let recipe = slot.recipe, recipe.minutes > 0 { parts.append("\(recipe.minutes) min") }
+ return parts.joined(separator: " · ")
+ }
+
+ private func macroBar(_ totals: Macros, targets: Macros) -> some View {
+ HStack(spacing: 14) {
+ MacroBar(label: "protein", value: totals.proteinG, target: targets.proteinG)
+ MacroBar(label: "fiber", value: totals.fiberG, target: targets.fiberG)
+ MacroBar(label: "sat fat", value: totals.satfatG, target: targets.satfatG, lowerIsBetter: true)
+ }
+ .padding(.top, 2)
+ }
+
+ // MARK: - Actions
+
+ /// Ticking a slot logs the planned recipe to **your** plate; unticking
+ /// removes your log. Neither touches the shared plan.
+ private func toggle(day: FoodDay, slot: FoodSlot) async {
+ guard let recipe = slot.recipe else { return }
+ let key = "\(day.date)#\(slot.slot)"
+ busySlot = key
+ defer { busySlot = nil }
+ do {
+ if slot.logged, let logId = slot.logId {
+ try await auth.client.unlogMeal(id: logId)
+ } else {
+ try await auth.client.logMeal(date: day.date, slot: slot.slot,
+ recipe: recipe.slug,
+ clientId: UUID().uuidString)
+ }
+ await load()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
+
+nonisolated struct PlanTarget: Identifiable, Hashable {
+ let date: String
+ let slot: String
+ var id: String { "\(date)#\(slot)" }
+}
+
+// MARK: - Macro bar
+
+private struct MacroBar: View {
+ @Environment(\.theme) private var theme
+ let label: String
+ let value: Double
+ let target: Double
+ var lowerIsBetter = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ Capsule().fill(theme.sunken)
+ Capsule()
+ .fill(over ? theme.warn : theme.accent)
+ .frame(width: geo.size.width * min(1, fraction))
+ }
+ }
+ .frame(height: 5)
+ Text("\(label) \(Int(value.rounded()))/\(Int(target.rounded()))")
+ .font(.system(.caption2))
+ .foregroundStyle(theme.dim)
+ }
+ }
+
+ private var fraction: Double { target > 0 ? value / target : 0 }
+ /// Over target is only a warning where more is worse — hitting protein is
+ /// the goal, not a ceiling.
+ private var over: Bool { lowerIsBetter && value > target }
+}
diff --git a/ios/Forge/Forge/Features/Cook/PlanRecipeSheet.swift b/ios/Forge/Forge/Features/Cook/PlanRecipeSheet.swift
new file mode 100644
index 0000000..e0df9e6
--- /dev/null
+++ b/ios/Forge/Forge/Features/Cook/PlanRecipeSheet.swift
@@ -0,0 +1,183 @@
+import SwiftUI
+
+/// Pencilling a recipe onto the week, from either end: pick a **day** when you
+/// arrived from a recipe, pick a **recipe** when you arrived from an empty slot.
+///
+/// Two things the copy has to be straight about, because the server does them
+/// and the user can't see them:
+/// 1. The week is a weekday template — setting Tuesday's dinner sets it for
+/// every week the active revision covers, not just this one.
+/// 2. The plan is household-shared, so this changes what Shelby sees.
+struct PlanRecipeSheet: View {
+ @Environment(\.theme) private var theme
+ @Environment(\.dismiss) private var dismiss
+ @Environment(AuthController.self) private var auth
+
+ private enum Mode {
+ /// From a recipe: choose which day it lands on.
+ case pickDay(RecipeCard)
+ /// From an empty slot: choose what goes in it.
+ case pickRecipe(date: String, slot: String)
+ }
+
+ private let mode: Mode
+ private let onDone: (() async -> Void)?
+
+ @State private var week: FoodWeek?
+ @State private var library: [RecipeCard] = []
+ @State private var term = ""
+ @State private var busy = false
+ @State private var errorMessage: String?
+
+ init(recipe: RecipeCard, onDone: (() async -> Void)? = nil) {
+ self.mode = .pickDay(recipe)
+ self.onDone = onDone
+ }
+
+ init(date: String, slot: String, onDone: (() async -> Void)? = nil) {
+ self.mode = .pickRecipe(date: date, slot: slot)
+ self.onDone = onDone
+ }
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ switch mode {
+ case .pickDay(let recipe): dayPicker(recipe)
+ case .pickRecipe(let date, let slot): recipePicker(date: date, slot: slot)
+ }
+ }
+ .background(theme.bg)
+ .navigationTitle(title)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ }
+ }
+ .task { await load() }
+ .presentationDetents([.medium, .large])
+ }
+
+ private var title: String {
+ switch mode {
+ case .pickDay: "Which night?"
+ case .pickRecipe(_, let slot): "Pick a \(slot)"
+ }
+ }
+
+ private func load() async {
+ week = try? await auth.client.foodWeek()
+ if case .pickRecipe(_, let slot) = mode {
+ library = (try? await auth.client.recipes(kind: slot))?.recipes ?? []
+ }
+ }
+
+ // MARK: - Pick a day
+
+ private func dayPicker(_ recipe: RecipeCard) -> some View {
+ List {
+ Section {
+ ForEach(week?.days ?? []) { day in
+ Button {
+ Task { await plan(date: day.date, recipe: recipe.slug, slot: recipe.kind) }
+ } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(day.dayName)
+ .font(.system(.body, weight: .medium))
+ .foregroundStyle(day.isToday ? theme.accent : theme.ink)
+ Text(day.dinner?.recipe?.name ?? "nothing planned")
+ .font(.system(.caption))
+ .foregroundStyle(theme.dim)
+ }
+ Spacer()
+ if day.dinner?.recipe != nil {
+ Text("replaces")
+ .font(.system(.caption2, weight: .semibold))
+ .foregroundStyle(theme.warn)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .listRowBackground(theme.raised)
+ }
+ } footer: {
+ footer
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .disabled(busy)
+ }
+
+ // MARK: - Pick a recipe
+
+ private func recipePicker(date: String, slot: String) -> some View {
+ List {
+ Section {
+ ForEach(filtered) { recipe in
+ Button {
+ Task { await plan(date: date, recipe: recipe.slug, slot: slot) }
+ } label: {
+ HStack(spacing: 12) {
+ Color.clear
+ .frame(width: 44, height: 44)
+ .overlay { RecipePhoto(recipe: recipe) }
+ .clipShape(.rect(cornerRadius: 9))
+ VStack(alignment: .leading, spacing: 2) {
+ Text(recipe.name)
+ .font(.system(.subheadline, weight: .medium))
+ .foregroundStyle(theme.ink)
+ Text(recipe.meta)
+ .font(.system(.caption))
+ .foregroundStyle(theme.dim)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .listRowBackground(theme.raised)
+ }
+ } footer: {
+ footer
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .searchable(text: $term, prompt: "Search recipes")
+ .disabled(busy)
+ }
+
+ /// Parked imports are excluded here, not badged: this list is "what can go
+ /// in the week", and the server refuses to plan an incomplete recipe.
+ private var filtered: [RecipeCard] {
+ let needle = term.lowercased().trimmingCharacters(in: .whitespaces)
+ return library.filter { recipe in
+ guard !recipe.isParked else { return false }
+ guard !needle.isEmpty else { return true }
+ return recipe.name.lowercased().contains(needle)
+ }
+ }
+
+ @ViewBuilder
+ private var footer: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ if let errorMessage {
+ Text(errorMessage).foregroundStyle(theme.warn)
+ }
+ Text("The meal plan is shared — this changes the week for both of you, on that weekday, until the coach proposes a new one.")
+ }
+ .font(.footnote)
+ }
+
+ private func plan(date: String, recipe: String, slot: String) async {
+ busy = true
+ defer { busy = false }
+ do {
+ try await auth.client.planSlot(date: date, recipe: recipe, slot: slot)
+ await onDone?()
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/ios/Forge/Forge/Features/Cook/RecipeDetailView.swift b/ios/Forge/Forge/Features/Cook/RecipeDetailView.swift
new file mode 100644
index 0000000..e8ba33e
--- /dev/null
+++ b/ios/Forge/Forge/Features/Cook/RecipeDetailView.swift
@@ -0,0 +1,273 @@
+import SwiftUI
+
+/// One recipe: the photo, why it's in the week, what it costs you
+/// nutritionally, what you need, and the method — with cook mode one tap away.
+///
+/// Parked imports (unknown ingredient, `hard`, or zero kcal) are browsable here
+/// and say so; the server refuses to propose them, so the app shouldn't pretend
+/// they're plannable either.
+struct RecipeDetailView: View {
+ @Environment(\.theme) private var theme
+ @Environment(AuthController.self) private var auth
+
+ let slug: String
+
+ @State private var recipe: RecipeDetail?
+ @State private var errorMessage: String?
+ @State private var cooking = false
+ @State private var planning = false
+
+ var body: some View {
+ Group {
+ if let recipe {
+ content(recipe)
+ } else if let errorMessage {
+ ForgeErrorView(message: errorMessage) { Task { await load() } }
+ } else {
+ ForgeProgress()
+ }
+ }
+ .background(theme.bg)
+ .navigationTitle("")
+ .navigationBarTitleDisplayMode(.inline)
+ .task { if recipe == nil { await load() } }
+ .fullScreenCover(isPresented: $cooking) {
+ if let recipe {
+ CookModeView(recipe: recipe).cookTheme()
+ }
+ }
+ .sheet(isPresented: $planning) {
+ if let recipe {
+ PlanRecipeSheet(recipe: recipe.card).cookTheme()
+ }
+ }
+ }
+
+ private func load() async {
+ errorMessage = nil
+ do {
+ recipe = try await auth.client.recipe(slug: slug)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ // MARK: - Content
+
+ private func content(_ r: RecipeDetail) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ Color.clear
+ .frame(maxWidth: .infinity)
+ .frame(height: 240)
+ .overlay { RecipePhoto(recipe: r.card) }
+ .clipShape(.rect(cornerRadius: 24))
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text(r.name)
+ .font(ForgeFont.serifTitle)
+ .foregroundStyle(theme.ink)
+ .fixedSize(horizontal: false, vertical: true)
+ Text(metaLine(r.card))
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.mut)
+ if r.card.isParked {
+ Text("Parked import — browsable, but the coach can't put it in a week until its gaps are filled.")
+ .font(.footnote)
+ .foregroundStyle(theme.warn)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ if !r.card.why.isEmpty {
+ Text(r.card.why)
+ .font(ForgeFont.serif(17, relativeTo: .body, weight: .regular))
+ .foregroundStyle(theme.mut)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ macros(r.card.macros, serves: r.card.serves)
+
+ if !r.ingredients.isEmpty { ingredients(r.ingredients) }
+ if !r.steps.isEmpty { method(r.steps) }
+
+ actions(r)
+
+ if !r.sourceUrl.isEmpty {
+ Link(destination: URL(string: r.sourceUrl) ?? URL(string: "https://example.com")!) {
+ Text("Source · \(r.source.isEmpty ? "original recipe" : r.source)")
+ .font(.footnote)
+ .foregroundStyle(theme.mut)
+ }
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 40)
+ }
+ .scrollEdgeEffectStyle(.soft, for: .top)
+ }
+
+ private func metaLine(_ card: RecipeCard) -> String {
+ var parts = ["\(card.minutes) min", "serves \(card.serves)", card.difficulty]
+ if card.batch > 0 { parts.append("+\(card.batch) boxed") }
+ return parts.joined(separator: " · ")
+ }
+
+ /// Per serving, and said so — the server stores per-serving values and a
+ /// number that silently meant "per pan" would wreck the day's totals.
+ private func macros(_ m: Macros, serves: Int) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ ForgeLabel("Per serving", color: theme.mut)
+ HStack(spacing: 10) {
+ MacroCell(value: Units.trim(m.kcal), label: "kcal", accented: true)
+ MacroCell(value: Units.trim(m.proteinG), label: "protein", unit: "g")
+ MacroCell(value: Units.trim(m.fiberG), label: "fiber", unit: "g")
+ MacroCell(value: Units.trim(m.satfatG), label: "sat fat", unit: "g")
+ }
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(theme.raised, in: .rect(cornerRadius: 20))
+ }
+
+ private func ingredients(_ items: [RecipeIngredient]) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ CookSection("You need")
+ FlowChips(items: items.map { ($0.name, $0.quantity) })
+ let pantry = items.filter(\.isPantry).count
+ if pantry > 0 {
+ Text("\(pantry) already in the cupboard")
+ .font(.footnote)
+ .foregroundStyle(theme.dim)
+ }
+ }
+ }
+
+ private func method(_ steps: [RecipeStep]) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ CookSection("Method · \(steps.count) steps")
+ ForEach(Array(steps.enumerated()), id: \.offset) { i, step in
+ HStack(alignment: .top, spacing: 12) {
+ Text("\(i + 1)")
+ .font(ForgeFont.serif(17, relativeTo: .body))
+ .foregroundStyle(theme.accent)
+ .frame(width: 18, alignment: .leading)
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 8) {
+ Text(step.title)
+ .font(.system(.subheadline, weight: .semibold))
+ .foregroundStyle(theme.ink)
+ if let minutes = step.minutes, minutes > 0 {
+ Text("\(minutes) min")
+ .font(.system(.caption))
+ .foregroundStyle(theme.dim)
+ }
+ if step.isParallel {
+ Text("BACKGROUND")
+ .font(.system(.caption2, weight: .bold))
+ .foregroundStyle(theme.accent)
+ }
+ }
+ CueText(step.detail, font: .system(.subheadline), muted: true)
+ }
+ }
+ }
+ }
+ }
+
+ private func actions(_ r: RecipeDetail) -> some View {
+ VStack(spacing: 10) {
+ Button { cooking = true } label: {
+ Text(r.steps.isEmpty ? "No method to cook from" : "Start cooking")
+ }
+ .buttonStyle(.pill)
+ .disabled(r.steps.isEmpty)
+
+ Button("Put it in the week") { planning = true }
+ .buttonStyle(.pillQuiet)
+ .disabled(r.card.isParked)
+ }
+ .padding(.top, 4)
+ }
+}
+
+// MARK: - Macro cell
+
+private struct MacroCell: View {
+ @Environment(\.theme) private var theme
+ let value: String
+ let label: String
+ var unit: String?
+ var accented = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(alignment: .firstTextBaseline, spacing: 2) {
+ Text(value)
+ .font(ForgeFont.numeric(.title3, weight: .semibold))
+ .foregroundStyle(accented ? theme.accent : theme.ink)
+ if let unit {
+ Text(unit)
+ .font(.system(.caption2, weight: .medium))
+ .foregroundStyle(theme.dim)
+ }
+ }
+ Text(label)
+ .font(.system(.caption2))
+ .foregroundStyle(theme.mut)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+// MARK: - Chip flow
+
+/// Wrapping chip row. `LazyVGrid` can't do ragged widths, and an `HStack` in a
+/// `ScrollView` clips — so this lays out by hand.
+struct FlowChips: View {
+ let items: [(String, String)]
+
+ var body: some View {
+ FlowLayout(spacing: 8) {
+ ForEach(Array(items.enumerated()), id: \.offset) { _, item in
+ IngredientChip(name: item.0, quantity: item.1)
+ }
+ }
+ }
+}
+
+struct FlowLayout: Layout {
+ var spacing: CGFloat = 8
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let width = proposal.width ?? .infinity
+ var x: CGFloat = 0, y: CGFloat = 0, rowHeight: CGFloat = 0
+ for view in subviews {
+ let size = view.sizeThatFits(.unspecified)
+ if x > 0, x + size.width > width {
+ x = 0
+ y += rowHeight + spacing
+ rowHeight = 0
+ }
+ x += size.width + spacing
+ rowHeight = max(rowHeight, size.height)
+ }
+ return CGSize(width: proposal.width ?? x, height: y + rowHeight)
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
+ subviews: Subviews, cache: inout ()) {
+ var x = bounds.minX, y = bounds.minY, rowHeight: CGFloat = 0
+ for view in subviews {
+ let size = view.sizeThatFits(.unspecified)
+ if x > bounds.minX, x + size.width > bounds.maxX {
+ x = bounds.minX
+ y += rowHeight + spacing
+ rowHeight = 0
+ }
+ view.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
+ x += size.width + spacing
+ rowHeight = max(rowHeight, size.height)
+ }
+ }
+}
diff --git a/ios/Forge/Forge/Features/Cook/RecipeLibraryView.swift b/ios/Forge/Forge/Features/Cook/RecipeLibraryView.swift
new file mode 100644
index 0000000..ca12708
--- /dev/null
+++ b/ios/Forge/Forge/Features/Cook/RecipeLibraryView.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+/// The whole recipe library. Filtering is client-side as-you-type — the library
+/// is household-sized (dozens, not thousands), and the PWA made the same call
+/// in `food.tsx::RecipeLibraryScreen`.
+///
+/// Parked imports stay visible with a badge rather than being hidden: the rule
+/// is "browsable, never proposable", and a recipe that vanished after import
+/// would look like the import failed.
+struct RecipeLibraryView: View {
+ @Environment(\.theme) private var theme
+ @Environment(AuthController.self) private var auth
+
+ /// Handed down from the browse screen so the list is up immediately; it
+ /// refreshes itself in the background.
+ let recipes: [RecipeCard]
+
+ @State private var loaded: [RecipeCard] = []
+ @State private var term = ""
+ @State private var kind: String?
+
+ private var all: [RecipeCard] { loaded.isEmpty ? recipes : loaded }
+
+ private var filtered: [RecipeCard] {
+ let needle = term.lowercased().trimmingCharacters(in: .whitespaces)
+ return all.filter { recipe in
+ if let kind, recipe.kind != kind { return false }
+ guard !needle.isEmpty else { return true }
+ return recipe.name.lowercased().contains(needle)
+ || (recipe.tags ?? []).contains { $0.lowercased().contains(needle) }
+ }
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 14) {
+ kindFilter
+ if filtered.isEmpty {
+ Text(term.isEmpty ? "Nothing in the library yet." : "No recipe matches “\(term)”.")
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.mut)
+ .padding(.top, 40)
+ .frame(maxWidth: .infinity)
+ } else {
+ LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
+ GridItem(.flexible(), spacing: 12)], spacing: 12) {
+ ForEach(filtered) { recipe in
+ NavigationLink(value: CookRoute.recipe(recipe.slug)) {
+ RecipeTile(recipe: recipe)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 40)
+ }
+ .background(theme.bg)
+ .navigationTitle("Library")
+ .navigationBarTitleDisplayMode(.inline)
+ .searchable(text: $term, prompt: "Search \(all.count) recipes")
+ .task { loaded = (try? await auth.client.recipes())?.recipes ?? [] }
+ }
+
+ private var kindFilter: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ FilterChip(title: "All", selected: kind == nil) { kind = nil }
+ ForEach(MealSlot.allCases) { slot in
+ FilterChip(title: slot.title, selected: kind == slot.rawValue) {
+ kind = kind == slot.rawValue ? nil : slot.rawValue
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+ }
+}
+
+struct FilterChip: View {
+ @Environment(\.theme) private var theme
+ let title: String
+ let selected: Bool
+ let tap: () -> Void
+
+ var body: some View {
+ Button(action: tap) {
+ Text(title)
+ .font(.system(.subheadline, weight: .medium))
+ .foregroundStyle(selected ? theme.onAccent : theme.mut)
+ .padding(.vertical, 7)
+ .padding(.horizontal, 14)
+ .background(selected ? theme.accent : theme.sunken, in: .capsule)
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/ios/Forge/Forge/Features/Data/HistoryView.swift b/ios/Forge/Forge/Features/Data/HistoryView.swift
new file mode 100644
index 0000000..e006028
--- /dev/null
+++ b/ios/Forge/Forge/Features/Data/HistoryView.swift
@@ -0,0 +1,191 @@
+import SwiftUI
+
+/// Everything logged, newest first. Mirrors `web/src/screens/data.tsx` —
+/// pagination, a favourites filter, and swipe-to-delete. Native swipe actions
+/// replace the web's hand-rolled `SwipeRow`.
+struct HistoryView: View {
+ @Environment(\.theme) private var theme
+ @Environment(AuthController.self) private var auth
+
+ @State private var model: HistoryViewModel?
+ @State private var pendingDelete: HistoryItem?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let model {
+ content(model)
+ } else {
+ ForgeProgress()
+ }
+ }
+ .background(theme.bg)
+ .navigationTitle("History")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ NavigationLink(value: DataRoute.records) {
+ Image(systemName: "trophy")
+ }
+ .foregroundStyle(theme.accent)
+ }
+ }
+ .navigationDestination(for: DataRoute.self) { route in
+ switch route {
+ case .records: RecordsView().trainTheme()
+ case .session(let id): SessionDetailView(sessionId: id).trainTheme()
+ }
+ }
+ }
+ .task {
+ if model == nil { model = HistoryViewModel(client: auth.client) }
+ await model?.loadIfNeeded()
+ }
+ .confirmationDialog(
+ "Discard this workout?",
+ isPresented: Binding(get: { pendingDelete != nil },
+ set: { if !$0 { pendingDelete = nil } }),
+ titleVisibility: .visible
+ ) {
+ Button("Discard", role: .destructive) {
+ if let item = pendingDelete { Task { await model?.delete(item) } }
+ pendingDelete = nil
+ }
+ Button("Cancel", role: .cancel) { pendingDelete = nil }
+ } message: {
+ Text("Its sets are removed. All-time records are kept.")
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ model: HistoryViewModel) -> some View {
+ @Bindable var model = model
+ if model.isLoading, model.items.isEmpty {
+ ForgeProgress()
+ } else if let message = model.errorMessage, model.items.isEmpty {
+ ForgeErrorView(message: message) { Task { await model.reload() } }
+ } else {
+ List {
+ Section {
+ Toggle("Starred only", isOn: $model.favoritesOnly)
+ .font(.system(.subheadline, weight: .medium))
+ .tint(theme.accent)
+ .listRowBackground(theme.raised)
+ }
+
+ Section {
+ ForEach(model.items) { item in
+ NavigationLink(value: DataRoute.session(item.id)) {
+ HistoryRow(item: item)
+ }
+ .listRowBackground(theme.raised)
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ // Never a full-swipe delete: this is destructive and
+ // confirms, per the design system's rule.
+ Button(role: .destructive) {
+ pendingDelete = item
+ } label: {
+ Label("Discard", systemImage: "trash")
+ }
+ }
+ .swipeActions(edge: .leading) {
+ Button {
+ Task { await model.toggleFavorite(item) }
+ } label: {
+ Label(item.isFavorite ? "Unstar" : "Star",
+ systemImage: item.isFavorite ? "star.slash" : "star")
+ }
+ .tint(theme.accentDeep)
+ }
+ .task { await model.loadMoreIfNeeded(after: item) }
+ }
+
+ if model.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView().tint(theme.accent)
+ Spacer()
+ }
+ .listRowBackground(theme.bg)
+ }
+ } footer: {
+ if model.items.isEmpty {
+ Text(model.favoritesOnly
+ ? "Nothing starred yet — swipe a workout right to star it."
+ : "Nothing logged yet.")
+ .font(.footnote)
+ .foregroundStyle(theme.dim)
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .scrollContentBackground(.hidden)
+ .background(theme.bg)
+ .refreshable { await model.reload() }
+ }
+ }
+}
+
+nonisolated enum DataRoute: Hashable {
+ case records
+ case session(String)
+}
+
+// MARK: - Row
+
+private struct HistoryRow: View {
+ @Environment(\.theme) private var theme
+ let item: HistoryItem
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 12) {
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 6) {
+ Text(item.name)
+ .font(.system(.body, weight: .semibold))
+ .foregroundStyle(theme.ink)
+ if item.isFavorite {
+ Image(systemName: "star.fill")
+ .font(.caption2)
+ .foregroundStyle(theme.accent)
+ }
+ }
+ Text(dateLabel)
+ .font(.caption)
+ .foregroundStyle(theme.dim)
+ }
+ Spacer(minLength: 8)
+ VStack(alignment: .trailing, spacing: 2) {
+ Text(summary)
+ .font(ForgeFont.numeric(.subheadline))
+ .foregroundStyle(theme.accent)
+ if item.stats.partial == true {
+ Text("incomplete")
+ .font(.caption2)
+ .foregroundStyle(theme.warn)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ private var dateLabel: String {
+ guard let date = ForgeDate.date(from: item.day) else { return item.day }
+ let formatter = DateFormatter()
+ formatter.dateFormat = "EEE d MMM"
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ return formatter.string(from: date)
+ }
+
+ /// Cardio reads in distance and time; strength in tonnage.
+ private var summary: String {
+ if item.kind == "cardio" {
+ let parts = [
+ item.stats.distance.map { String(format: "%.1f km", $0) },
+ item.stats.durationS.map(Format.duration),
+ ].compactMap { $0 }
+ return parts.isEmpty ? "—" : parts.joined(separator: " · ")
+ }
+ return "\(Units.trim(item.stats.tonnage ?? 0)) t"
+ }
+}
diff --git a/ios/Forge/Forge/Features/Data/HistoryViewModel.swift b/ios/Forge/Forge/Features/Data/HistoryViewModel.swift
new file mode 100644
index 0000000..a3e80db
--- /dev/null
+++ b/ios/Forge/Forge/Features/Data/HistoryViewModel.swift
@@ -0,0 +1,94 @@
+import Foundation
+import Observation
+
+/// History paging. `/api/history` is offset-paginated and capped at 100 a page.
+@MainActor
+@Observable
+final class HistoryViewModel {
+ static let pageSize = 30
+
+ private(set) var items: [HistoryItem] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var reachedEnd = false
+ private(set) var errorMessage: String?
+
+ var favoritesOnly = false {
+ didSet {
+ guard favoritesOnly != oldValue else { return }
+ Task { await reload() }
+ }
+ }
+
+ private let client: APIClient
+
+ init(client: APIClient) {
+ self.client = client
+ }
+
+ func loadIfNeeded() async {
+ guard items.isEmpty, !isLoading else { return }
+ await reload()
+ }
+
+ func reload() async {
+ isLoading = true
+ errorMessage = nil
+ defer { isLoading = false }
+ do {
+ let page = try await client.history(limit: Self.pageSize, offset: 0,
+ favorites: favoritesOnly)
+ items = page
+ reachedEnd = page.count < Self.pageSize
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ /// Called as the last row appears. Guarded so a fast scroll can't fire
+ /// several overlapping requests for the same offset.
+ func loadMoreIfNeeded(after item: HistoryItem) async {
+ guard !isLoadingMore, !reachedEnd, !isLoading,
+ item.id == items.last?.id
+ else { return }
+ isLoadingMore = true
+ defer { isLoadingMore = false }
+ do {
+ let page = try await client.history(limit: Self.pageSize, offset: items.count,
+ favorites: favoritesOnly)
+ // Offset paging can repeat a row if something was inserted between
+ // pages; drop anything already on screen rather than showing it twice.
+ let known = Set(items.map(\.id))
+ items.append(contentsOf: page.filter { !known.contains($0.id) })
+ reachedEnd = page.count < Self.pageSize
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func toggleFavorite(_ item: HistoryItem) async {
+ guard let index = items.firstIndex(where: { $0.id == item.id }) else { return }
+ let next = !items[index].isFavorite
+ items[index].favorite = next // optimistic — a star should feel instant
+ do {
+ try await client.setFavorite(sessionId: item.id, favorite: next)
+ if favoritesOnly, !next { items.remove(at: index) }
+ } catch {
+ items[index].favorite = !next
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func delete(_ item: HistoryItem) async {
+ let snapshot = items
+ items.removeAll { $0.id == item.id }
+ do {
+ try await client.deleteSession(id: item.id)
+ } catch {
+ items = snapshot
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func dismissError() { errorMessage = nil }
+}
diff --git a/ios/Forge/Forge/Features/Data/ProgressView.swift b/ios/Forge/Forge/Features/Data/ProgressView.swift
new file mode 100644
index 0000000..378b115
--- /dev/null
+++ b/ios/Forge/Forge/Features/Data/ProgressView.swift
@@ -0,0 +1,557 @@
+import Charts
+import SwiftUI
+
+/// Progress, built to the decided board
+/// (`docs/mockups/native/redesign-d-progress.png`): DIN `PROGRESS`, a
+/// Strength/Cardio/Body switch, the week as a ring, one featured lift as a
+/// filled curve, the rest as sparkline rows labelled in plain words, and the
+/// newest PR as a photo.
+///
+/// The words are the point. "stalled 2 wk" tells you whether to do something;
+/// "+0.4%" doesn't. See `LiftTrend`.
+struct TrendsView: View {
+ @Environment(\.theme) private var theme
+ @Environment(AuthController.self) private var auth
+
+ nonisolated enum Lens: String, CaseIterable, Identifiable {
+ case strength = "Strength"
+ case cardio = "Cardio"
+ case body = "Body"
+ var id: String { rawValue }
+ }
+
+ @State private var progress: Progress?
+ @State private var records: [RecordRow] = []
+ /// The **server's** today. The client never computes it (`ios/CLAUDE.md`):
+ /// the server runs Europe/London, and a device an hour either side of it
+ /// puts "this week" in the wrong week — which is exactly how the PR count
+ /// ended up reporting last week's records as this week's.
+ @State private var today: String?
+ @State private var errorMessage: String?
+ @State private var lens: Lens = .strength
+ @State private var featured: String?
+
+ private var prefs: Prefs? { auth.me?.prefs }
+ private var bodyweightUnits: String { auth.me?.units ?? "kg" }
+
+ var body: some View {
+ Group {
+ if let progress {
+ content(progress)
+ } else if let errorMessage {
+ ForgeErrorView(message: errorMessage) { Task { await load() } }
+ } else {
+ ForgeProgress()
+ }
+ }
+ .background(theme.bg)
+ .toolbar(.hidden, for: .navigationBar)
+ .task { if progress == nil { await load() } }
+ }
+
+ private func load() async {
+ errorMessage = nil
+ do {
+ let fetched = try await auth.client.progress()
+ progress = fetched
+ featured = featured ?? fetched.plottableLifts.first?.slug
+ records = (try? await auth.client.records()) ?? []
+ today = try? await auth.client.week().today
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ progress: Progress) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("PROGRESS")
+ .font(ForgeFont.display(40, relativeTo: .largeTitle))
+ .foregroundStyle(theme.ink)
+ .padding(.top, 4)
+
+ lensPicker
+
+ switch lens {
+ case .strength: strength(progress)
+ case .cardio: cardio(progress)
+ case .body: body(progress)
+ }
+ }
+ .padding(.horizontal, 18)
+ .padding(.bottom, 40)
+ }
+ .scrollEdgeEffectStyle(.soft, for: .top)
+ .refreshable { await load() }
+ }
+
+ private var lensPicker: some View {
+ HStack(spacing: 8) {
+ ForEach(Lens.allCases) { option in
+ Button {
+ withAnimation(.easeOut(duration: 0.16)) { lens = option }
+ } label: {
+ Text(option.rawValue)
+ .font(.system(.subheadline, weight: .semibold))
+ .foregroundStyle(lens == option ? theme.onAccent : theme.mut)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ .background(lens == option ? theme.accent : theme.raised, in: .capsule)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ // MARK: - Strength
+
+ @ViewBuilder
+ private func strength(_ progress: Progress) -> some View {
+ weekRing(progress)
+ featuredLift(progress)
+ sparklineRows(progress)
+ prBanner()
+ }
+
+ /// The week as a ring: sessions done, and what else the week has in it.
+ private func weekRing(_ progress: Progress) -> some View {
+ HStack(spacing: 16) {
+ Ring(fraction: progress.week.planned > 0
+ ? Double(progress.week.done) / Double(progress.week.planned) : 0,
+ caption: "\(progress.week.done)/\(progress.week.planned)",
+ label: "sessions")
+ VStack(alignment: .leading, spacing: 4) {
+ Text((weekNumber.map { "Week \($0) · " } ?? "") + weekVerdict(progress))
+ .font(.system(.headline, weight: .semibold))
+ .foregroundStyle(theme.ink)
+ weekDetail(progress)
+ .font(.system(.subheadline))
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(theme.raised, in: .rect(cornerRadius: 18))
+ }
+
+ private var weekNumber: Int? {
+ today.flatMap(ForgeDate.date(from:))
+ .map { ForgeDate.calendar.component(.weekOfYear, from: $0) }
+ }
+
+ /// No fake encouragement: this counts what's left, and only says "on track"
+ /// when the week's sessions are actually done.
+ private func weekVerdict(_ progress: Progress) -> String {
+ let left = progress.week.planned - progress.week.done
+ if progress.week.planned == 0 { return "nothing planned" }
+ if left <= 0 { return "on track" }
+ return left == 1 ? "1 session to go" : "\(left) sessions to go"
+ }
+
+ private func weekDetail(_ progress: Progress) -> Text {
+ let prs = prsThisWeek
+ var text = Text("Zone 2 ").foregroundStyle(theme.mut)
+ + Text("\(progress.zone2.done) / \(progress.zone2.target) min")
+ .foregroundStyle(progress.zone2.done >= progress.zone2.target
+ ? theme.accent : theme.ink)
+ if progress.week.done > 0 {
+ text = text + Text(" · \(progress.week.done) logged").foregroundStyle(theme.mut)
+ }
+ if prs > 0 {
+ text = text + Text(" · ").foregroundStyle(theme.mut)
+ + Text(prs == 1 ? "1 PR" : "\(prs) PRs").foregroundStyle(theme.accent)
+ + Text(" this week").foregroundStyle(theme.mut)
+ }
+ return text
+ }
+
+ private var prsThisWeek: Int {
+ guard let today else { return 0 }
+ let weekStart = ForgeDate.weekStart(today)
+ return records.count { $0.achievedOn >= weekStart }
+ }
+
+ /// One lift, big: the estimated 1RM as a filled curve with its change over
+ /// the window it actually covers.
+ @ViewBuilder
+ private func featuredLift(_ progress: Progress) -> some View {
+ let lifts = progress.plottableLifts
+ if let slug = featured ?? lifts.first?.slug, let series = progress.e1rm[slug] {
+ let unit = Units.loadUnit(prefs: prefs, slug: slug)
+ let change = series.change(overWeeks: 12)
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .firstTextBaseline) {
+ ForgeLabel("\(series.name) · est. 1RM")
+ Spacer(minLength: 8)
+ if let change, abs(change.delta) > 0.05 {
+ Text("\(change.delta > 0 ? "▲" : "▼") "
+ + "\(Units.trim((abs(Units.kgToDisp(change.delta, unit)) * 10).rounded() / 10))"
+ + " in \(change.weeks) wk")
+ .font(.system(.subheadline, weight: .semibold))
+ .foregroundStyle(change.delta > 0 ? theme.accent : theme.mut)
+ }
+ }
+ HStack(alignment: .firstTextBaseline, spacing: 3) {
+ Text(Units.trim((Units.kgToDisp(series.latest, unit) * 10).rounded() / 10))
+ .font(ForgeFont.display(38, relativeTo: .largeTitle))
+ .foregroundStyle(theme.ink)
+ Text(unit.rawValue)
+ .font(.system(.caption, weight: .medium))
+ .foregroundStyle(theme.dim)
+ }
+
+ Chart(series.points) { point in
+ // The fill is decoration under a line whose scale excludes
+ // zero — it reads as depth, not as an area measurement.
+ AreaMark(x: .value("Date", point.date),
+ y: .value("1RM", Units.kgToDisp(point.v, unit)))
+ .foregroundStyle(theme.accent.opacity(0.18))
+ .interpolationMethod(.monotone)
+ LineMark(x: .value("Date", point.date),
+ y: .value("1RM", Units.kgToDisp(point.v, unit)))
+ .foregroundStyle(theme.accent)
+ .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round))
+ .interpolationMethod(.monotone)
+ }
+ .chartYScale(domain: .automatic(includesZero: false))
+ .chartYAxis(.hidden)
+ // One mark per month. `.automatic` puts several inside the same
+ // month and the axis reads "May May Jun Jun Jul".
+ .chartXAxis {
+ AxisMarks(values: .stride(by: .month)) {
+ AxisValueLabel(format: .dateTime.month(.abbreviated))
+ .foregroundStyle(theme.dim)
+ }
+ }
+ .frame(height: 150)
+ }
+ .padding(14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(theme.raised, in: .rect(cornerRadius: 18))
+ }
+ }
+
+ /// Every other lift as a row: name, what it's doing in words, a sparkline,
+ /// the number. Tapping one promotes it to the card above.
+ @ViewBuilder
+ private func sparklineRows(_ progress: Progress) -> some View {
+ let rest = progress.plottableLifts.filter { $0.slug != (featured ?? "") }
+ if !rest.isEmpty {
+ VStack(spacing: 0) {
+ ForEach(rest, id: \.slug) { entry in
+ Button {
+ withAnimation(.easeOut(duration: 0.16)) { featured = entry.slug }
+ } label: {
+ liftRow(slug: entry.slug, series: entry.series)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 4)
+ .background(theme.raised, in: .rect(cornerRadius: 18))
+ }
+ }
+
+ private func liftRow(slug: String, series: LiftSeries) -> some View {
+ let unit = Units.loadUnit(prefs: prefs, slug: slug)
+ let trend = series.trend
+ return HStack(spacing: 10) {
+ Text(series.name)
+ .font(.system(.subheadline, weight: .medium))
+ .foregroundStyle(theme.ink)
+ .frame(width: 108, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ Text(trend.words)
+ .font(.system(.caption))
+ .foregroundStyle(theme.mut)
+ .frame(width: 62, alignment: .leading)
+ Sparkline(points: series.points.map { Units.kgToDisp($0.v, unit) },
+ accented: trend.isPositive)
+ .frame(height: 26)
+ HStack(alignment: .firstTextBaseline, spacing: 2) {
+ Text(Units.trim((Units.kgToDisp(series.latest, unit) * 10).rounded() / 10))
+ .font(ForgeFont.display(20, relativeTo: .headline))
+ .foregroundStyle(theme.ink)
+ Text(unit.rawValue)
+ .font(.system(.caption2))
+ .foregroundStyle(theme.dim)
+ }
+ }
+ .padding(.vertical, 12)
+ .overlay(alignment: .bottom) { Rectangle().fill(theme.hair).frame(height: 1) }
+ }
+
+ /// The newest all-time record, over a photo. "PR banners are photographic"
+ /// is one of the three media surfaces the redesign round named.
+ @ViewBuilder
+ private func prBanner() -> some View {
+ // Best-set records only: an estimated 1RM is a calculation, and a
+ // banner should celebrate something you actually did.
+ if let pr = records.filter({ $0.kind == "best_set" })
+ .max(by: { $0.achievedOn < $1.achievedOn }) {
+ // Deliberately dark in *both* schemes, unlike every other card —
+ // the board keeps this banner dark on paper too. It's the one
+ // element that's meant to interrupt the page.
+ ZStack(alignment: .bottomLeading) {
+ Color.clear
+ .frame(maxWidth: .infinity)
+ .frame(height: 116)
+ .overlay {
+ Image(hero: .forStrength(mainLiftSlug: pr.slug, dayName: nil))
+ .resizable().scaledToFill()
+ }
+ .clipped()
+ LinearGradient(colors: [.black.opacity(0.35), .black.opacity(0.85)],
+ startPoint: .top, endPoint: .bottom)
+ VStack(alignment: .leading, spacing: 3) {
+ ForgeLabel("Best ever · \(prWhen(pr.achievedOn))",
+ color: ForgeTheme.trainDark.accent)
+ // Never `pr.detail` raw — the load inside it is kg.
+ Text("\(pr.name) \(pr.detail(in: Units.loadUnit(prefs: prefs, slug: pr.slug)))"
+ .uppercased())
+ .font(ForgeFont.display(24, relativeTo: .title2))
+ .foregroundStyle(.white)
+ .lineLimit(2)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(14)
+ }
+ .frame(height: 116)
+ .clipShape(.rect(cornerRadius: 18))
+ }
+ }
+
+ /// Weekday for anything in the last week, date beyond that — "Thursday"
+ /// only means something while it's still this week.
+ private func prWhen(_ iso: String) -> String {
+ guard let date = ForgeDate.date(from: iso) else { return iso }
+ let recent = today.map { iso >= ForgeDate.addDays($0, -6) } ?? false
+ let formatter = DateFormatter()
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ formatter.dateFormat = recent ? "EEEE" : "d MMM"
+ return formatter.string(from: date)
+ }
+
+ // MARK: - Cardio
+
+ @ViewBuilder
+ private func cardio(_ progress: Progress) -> some View {
+ HStack(spacing: 16) {
+ Ring(fraction: progress.zone2.target > 0
+ ? Double(progress.zone2.done) / Double(progress.zone2.target) : 0,
+ caption: "\(progress.zone2.done)",
+ label: "min")
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Zone 2 · \(progress.zone2.done) of \(progress.zone2.target) min")
+ .font(.system(.headline, weight: .semibold))
+ .foregroundStyle(theme.ink)
+ // The band is fixed server-side; the per-plan prescription only
+ // drives pct_in_zone, so saying "your band" here would mislead.
+ Text("Counted in the fixed 110–145 bpm band.")
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.mut)
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(theme.raised, in: .rect(cornerRadius: 18))
+
+ vo2Chart(progress)
+ // The two here the app is willing to call a direction on: aerobic
+ // training pushes resting HR down over months, and recovery is
+ // training. Everything else stays neutral.
+ trendCard(title: "Resting HR", points: progress.restingHr, unit: "bpm",
+ format: { String(Int($0)) }, better: .down)
+ trendCard(title: "Sleep", points: progress.sleepH, unit: "h",
+ format: { String(format: "%.1f", $0) }, better: .up)
+
+ if [progress.vo2max, progress.restingHr, progress.sleepH].allSatisfy({ $0.count < 2 }) {
+ emptyNote("VO₂max, resting HR and sleep come from the Watch. Nothing has synced yet.")
+ }
+ }
+
+ /// VO₂max is a quarterly-trend metric: raw dots plus the server's centred
+ /// rolling mean, never deltas between two noisy readings.
+ @ViewBuilder
+ private func vo2Chart(_ progress: Progress) -> some View {
+ if progress.vo2max.count > 1 {
+ card(title: "VO₂max",
+ value: progress.vo2maxSmooth.last.map { String(format: "%.1f", $0.v) }) {
+ Chart {
+ ForEach(progress.vo2max) { point in
+ PointMark(x: .value("Date", point.date), y: .value("VO₂max", point.v))
+ .foregroundStyle(theme.dim)
+ .symbolSize(14)
+ }
+ ForEach(progress.vo2maxSmooth) { point in
+ LineMark(x: .value("Date", point.date), y: .value("Trend", point.v))
+ .foregroundStyle(theme.accent)
+ .interpolationMethod(.monotone)
+ }
+ }
+ .chartYScale(domain: .automatic(includesZero: false))
+ .frame(height: 150)
+ }
+ }
+ }
+
+ // MARK: - Body
+
+ @ViewBuilder
+ private func body(_ progress: Progress) -> some View {
+ trendCard(title: "Bodyweight", points: progress.weight, unit: bodyweightUnits,
+ transform: displayWeight,
+ format: { Units.trim(($0 * 10).rounded() / 10) },
+ windowDays: 30)
+ trendCard(title: "Body fat", points: progress.bodycomp.fatPct, unit: "%",
+ format: { String(format: "%.1f", $0) })
+ // Bodyweight, body fat and water get no direction — what's "good"
+ // depends entirely on what you're training for. Muscle is the one the
+ // app already takes a view on: hold or grow it while training.
+ trendCard(title: "Muscle", points: progress.bodycomp.muscle, unit: "kg",
+ format: { String(format: "%.1f", $0) }, better: .up)
+ trendCard(title: "Water", points: progress.bodycomp.waterPct, unit: "%",
+ format: { String(format: "%.1f", $0) })
+
+ if [progress.weight, progress.bodycomp.fatPct, progress.bodycomp.muscle,
+ progress.bodycomp.waterPct].allSatisfy({ $0.count < 2 }) {
+ emptyNote("Bodyweight and composition come from the Withings scale. Nothing has synced yet.")
+ }
+ }
+
+ /// A lens with nothing in it should say why, not just end. Two readings are
+ /// the minimum for a line, so one weigh-in still lands here.
+ private func emptyNote(_ text: String) -> some View {
+ Text(text)
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.mut)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(16)
+ .background(theme.raised, in: .rect(cornerRadius: 18))
+ }
+
+ /// Bodyweight follows `user.units`, not the load preference.
+ private func displayWeight(_ kg: Double) -> Double {
+ bodyweightUnits == "lb" ? kg * 2.20462 : kg
+ }
+
+ // MARK: - Shared
+
+ /// Board shape for a metric: caps kicker, the change over the window on the
+ /// right, the line beneath. No `AreaMark` — it baselines at zero and
+ /// flattens a 3 kg change into a hairline at the top of the card.
+ @ViewBuilder
+ private func trendCard(title: String, points: [SeriesPoint], unit: String,
+ transform: ((Double) -> Double)? = nil,
+ format: @escaping (Double) -> String,
+ better: MetricDelta.Better = .either,
+ windowDays: Int = 0) -> some View {
+ if points.count > 1 {
+ let mapped = points.map { transform?($0.v) ?? $0.v }
+ let delta = (mapped.last ?? 0) - (mapped.first ?? 0)
+ card(title: windowDays > 0 ? "\(title) · \(windowDays) days" : title,
+ value: mapped.last.map { "\(format($0)) \(unit)" },
+ delta: MetricDelta.label(delta, format: format),
+ deltaIsGood: better.isGood(delta)) {
+ Chart(Array(zip(points, mapped)), id: \.0.id) { point, value in
+ LineMark(x: .value("Date", point.date), y: .value(unit, value))
+ .interpolationMethod(.monotone)
+ }
+ .foregroundStyle(theme.accent)
+ .chartYScale(domain: .automatic(includesZero: false))
+ .frame(height: 140)
+ }
+ }
+ }
+
+ private func card(title: String, value: String? = nil, delta: String? = nil,
+ deltaIsGood: Bool = false,
+ @ViewBuilder content: () -> Content) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .firstTextBaseline) {
+ ForgeLabel(title)
+ Spacer(minLength: 8)
+ if let delta {
+ Text(delta)
+ // Volt is positive status. Painting every delta with it
+ // said a worse night's sleep was good news.
+ .font(.system(.subheadline, weight: .semibold))
+ .foregroundStyle(deltaIsGood ? theme.accent : theme.mut)
+ } else if let value {
+ Text(value)
+ .font(ForgeFont.numeric(.subheadline))
+ .foregroundStyle(theme.ink)
+ }
+ }
+ content()
+ }
+ .padding(14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(theme.raised, in: .rect(cornerRadius: 18))
+ }
+}
+
+// MARK: - Ring
+
+/// Donut with the count inside — the board's week ring.
+private struct Ring: View {
+ @Environment(\.theme) private var theme
+ let fraction: Double
+ let caption: String
+ let label: String
+
+ var body: some View {
+ ZStack {
+ Circle().stroke(theme.sunken, lineWidth: 8)
+ Circle()
+ .trim(from: 0, to: max(0.001, min(1, fraction)))
+ .stroke(theme.accent, style: StrokeStyle(lineWidth: 8, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ VStack(spacing: -1) {
+ Text(caption)
+ .font(ForgeFont.display(20, relativeTo: .headline))
+ .foregroundStyle(theme.ink)
+ Text(label.uppercased())
+ .font(.system(size: 7, weight: .bold))
+ .tracking(0.5)
+ .foregroundStyle(theme.dim)
+ }
+ }
+ .frame(width: 68, height: 68)
+ .animation(.easeOut(duration: 0.3), value: fraction)
+ }
+}
+
+// MARK: - Sparkline
+
+/// A shape, not a chart. Swift Charts brings axes, scales and a lot of layout
+/// for something 26 points tall that only has to show a direction.
+private struct Sparkline: View {
+ @Environment(\.theme) private var theme
+ let points: [Double]
+ var accented = true
+
+ var body: some View {
+ GeometryReader { geo in
+ let low = points.min() ?? 0
+ let high = points.max() ?? 1
+ let span = max(high - low, 0.0001)
+ Path { path in
+ for (i, value) in points.enumerated() {
+ let x = points.count > 1
+ ? geo.size.width * Double(i) / Double(points.count - 1) : 0
+ let y = geo.size.height * (1 - (value - low) / span)
+ i == 0 ? path.move(to: CGPoint(x: x, y: y))
+ : path.addLine(to: CGPoint(x: x, y: y))
+ }
+ }
+ .stroke(accented ? theme.accent : theme.dim,
+ style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
+ }
+ }
+}
diff --git a/ios/Forge/Forge/Features/Data/RecordsView.swift b/ios/Forge/Forge/Features/Data/RecordsView.swift
new file mode 100644
index 0000000..da74eff
--- /dev/null
+++ b/ios/Forge/Forge/Features/Data/RecordsView.swift
@@ -0,0 +1,101 @@
+import SwiftUI
+
+/// All-time bests. The server keeps two kinds per lift — a calculated one-rep
+/// max (Epley) and the heaviest single set actually performed — and deleting a
+/// session never revokes them.
+struct RecordsView: View {
+ @Environment(\.theme) private var theme
+ @Environment(AuthController.self) private var auth
+
+ @State private var records: [RecordRow]?
+ @State private var errorMessage: String?
+
+ private var prefs: Prefs? { auth.me?.prefs }
+
+ var body: some View {
+ Group {
+ if let records {
+ if records.isEmpty {
+ ContentUnavailableView("No records yet",
+ systemImage: "trophy",
+ description: Text("Log a few sessions and they'll show up here."))
+ } else {
+ list(records)
+ }
+ } else if let errorMessage {
+ ForgeErrorView(message: errorMessage) { Task { await load() } }
+ } else {
+ ForgeProgress()
+ }
+ }
+ .background(theme.bg)
+ .navigationTitle("Records")
+ .navigationBarTitleDisplayMode(.inline)
+ .task { if records == nil { await load() } }
+ }
+
+ private func load() async {
+ errorMessage = nil
+ do {
+ records = try await auth.client.records()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func list(_ records: [RecordRow]) -> some View {
+ ScrollView {
+ VStack(spacing: 10) {
+ ForEach(grouped(records), id: \.slug) { group in
+ ForgeCard {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(group.name)
+ .font(ForgeFont.subtitle)
+ .foregroundStyle(theme.ink)
+ ForEach(group.rows) { row in
+ HStack(alignment: .firstTextBaseline) {
+ ForgeLabel(label(for: row.kind))
+ Spacer()
+ Text(Units.fmtLoad(row.value,
+ Units.loadUnit(prefs: prefs, slug: row.slug)))
+ .font(ForgeFont.numeric(.headline))
+ .foregroundStyle(theme.accent)
+ }
+ HStack {
+ // `detail` carries a kg load; converting it
+ // is the difference between "72.5 × 5" and
+ // a number in a unit you don't lift in.
+ Text(row.detail(in: Units.loadUnit(prefs: prefs, slug: row.slug)))
+ .font(.caption)
+ .foregroundStyle(theme.mut)
+ Spacer()
+ Text(row.achievedOn)
+ .font(.caption)
+ .foregroundStyle(theme.dim)
+ }
+ }
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 18)
+ .padding(.bottom, 40)
+ }
+ .refreshable { await load() }
+ }
+
+ private func label(for kind: String) -> String {
+ switch kind {
+ case "e1rm": "Est. 1RM"
+ case "best_set": "Best set"
+ default: kind.replacingOccurrences(of: "_", with: " ")
+ }
+ }
+
+ /// One card per lift, both record kinds inside it.
+ private func grouped(_ records: [RecordRow]) -> [(slug: String, name: String, rows: [RecordRow])] {
+ Dictionary(grouping: records, by: \.slug)
+ .map { (slug: $0.key, name: $0.value.first?.name ?? $0.key, rows: $0.value.sorted { $0.kind < $1.kind }) }
+ .sorted { $0.name < $1.name }
+ }
+}
diff --git a/ios/Forge/Forge/Features/Data/SessionDetailView.swift b/ios/Forge/Forge/Features/Data/SessionDetailView.swift
new file mode 100644
index 0000000..19941de
--- /dev/null
+++ b/ios/Forge/Forge/Features/Data/SessionDetailView.swift
@@ -0,0 +1,234 @@
+import Charts
+import MapKit
+import SwiftUI
+
+/// One logged session. Strength shows the sets as lifted; cardio shows the
+/// route, the HR trace and the five-zone breakdown.
+///
+/// The route renders on MapKit rather than the PWA's MapLibre/MapTiler stack —
+/// native maps need no key, no tile budget, and no runtime style built from CSS
+/// tokens. The server's `series` + zones payload is reused unchanged.
+struct SessionDetailView: View {
+ @Environment(\.theme) private var theme
+ @Environment(AuthController.self) private var auth
+
+ let sessionId: String
+
+ @State private var detail: SessionDetail?
+ @State private var errorMessage: String?
+
+ private var prefs: Prefs? { auth.me?.prefs }
+
+ var body: some View {
+ Group {
+ if let detail {
+ content(detail)
+ } else if let errorMessage {
+ ForgeErrorView(message: errorMessage) { Task { await load() } }
+ } else {
+ ForgeProgress()
+ }
+ }
+ .background(theme.bg)
+ .navigationTitle(detail?.name ?? "Session")
+ .navigationBarTitleDisplayMode(.inline)
+ .task { if detail == nil { await load() } }
+ }
+
+ private func load() async {
+ errorMessage = nil
+ do {
+ detail = try await auth.client.session(id: sessionId)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ session: SessionDetail) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ stats(session)
+
+ if session.isCardio {
+ if let route = routeCoordinates(session), route.count > 1 {
+ RouteMap(coordinates: route)
+ .frame(height: 240)
+ .clipShape(.rect(cornerRadius: 18))
+ }
+ if let hr = session.series?.hr, hr.count > 1 {
+ heartRateChart(hr)
+ }
+ if let zones = session.zones {
+ zoneBreakdown(zones)
+ }
+ } else {
+ ForEach(session.exercises) { exercise in
+ exerciseCard(exercise)
+ }
+ }
+
+ if let notes = session.notes, !notes.isEmpty {
+ ForgeCard {
+ VStack(alignment: .leading, spacing: 4) {
+ ForgeLabel("Notes")
+ Text(notes)
+ .font(.callout)
+ .foregroundStyle(theme.mut)
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 18)
+ .padding(.bottom, 40)
+ }
+ .scrollEdgeEffectStyle(.soft, for: .top)
+ }
+
+ private func stats(_ session: SessionDetail) -> some View {
+ HStack(spacing: 12) {
+ if session.isCardio {
+ StatTile(value: session.stats.distance.map { String(format: "%.1f", $0) } ?? "—",
+ label: "Distance", unit: "km", accented: true)
+ StatTile(value: session.stats.durationS.map { Format.duration($0) } ?? "—",
+ label: "Time")
+ } else {
+ StatTile(value: Units.trim(session.stats.tonnage ?? 0),
+ label: "Tonnage", unit: "t", accented: true)
+ StatTile(value: "\(session.stats.setsDone ?? 0)", label: "Sets")
+ StatTile(value: session.stats.avgRpe.map { String(format: "%.1f", $0) } ?? "—",
+ label: "Avg RPE")
+ }
+ }
+ }
+
+ private func exerciseCard(_ exercise: SessionDetail.Exercise) -> some View {
+ let unit = Units.loadUnit(prefs: prefs, slug: exercise.slug)
+ let timed = isTimed(exercise.slug) || isTimed(exercise.name ?? "")
+ return ForgeCard {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(exercise.name ?? exercise.slug)
+ .font(ForgeFont.subtitle)
+ .foregroundStyle(theme.ink)
+ if let original = exercise.substitutedFor {
+ ForgeChip(text: "swapped from \(original)")
+ }
+ ForEach(exercise.sets) { set in
+ HStack {
+ Text("\(set.setNo)")
+ .font(ForgeFont.numeric(.footnote))
+ .foregroundStyle(theme.dim)
+ .frame(width: 20, alignment: .leading)
+ Text("\(Units.fmtLoad(set.weight, unit)) × \(set.reps)\(timed ? "s" : "")")
+ .font(ForgeFont.numeric(.subheadline))
+ .foregroundStyle(theme.ink)
+ if let rpe = set.rpe {
+ Spacer()
+ Text("RPE \(rpe)")
+ .font(.caption)
+ .foregroundStyle(theme.dim)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private func heartRateChart(_ hr: [[Double]]) -> some View {
+ ForgeCard {
+ VStack(alignment: .leading, spacing: 10) {
+ ForgeLabel("Heart rate")
+ Chart {
+ ForEach(Array(hr.enumerated()), id: \.offset) { _, point in
+ if point.count >= 2 {
+ LineMark(
+ x: .value("Time", point[0] / 60),
+ y: .value("BPM", point[1])
+ )
+ }
+ }
+ }
+ .foregroundStyle(theme.accent)
+ .chartXAxisLabel("min")
+ // A 118–150 bpm trace against a zero baseline is a flat line in
+ // the top fifth of the card.
+ .chartYScale(domain: .automatic(includesZero: false))
+ .frame(height: 150)
+ }
+ }
+ }
+
+ private func zoneBreakdown(_ breakdown: ZoneBreakdown) -> some View {
+ ForgeCard {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ ForgeLabel("Zones")
+ Spacer()
+ Text(breakdown.estimated
+ ? "HR max \(breakdown.hrMax) (estimated)"
+ : "HR max \(breakdown.hrMax)")
+ .font(.caption)
+ .foregroundStyle(breakdown.estimated ? theme.warn : theme.dim)
+ }
+ ForEach(breakdown.zones) { zone in
+ HStack(spacing: 10) {
+ Text("Z\(zone.zone)")
+ .font(ForgeFont.numeric(.caption, weight: .bold))
+ .foregroundStyle(theme.mut)
+ .frame(width: 26, alignment: .leading)
+ GeometryReader { geo in
+ let widest = max(1, breakdown.zones.map(\.min).max() ?? 1)
+ RoundedRectangle(cornerRadius: 4)
+ .fill(theme.accent.opacity(0.35 + 0.13 * Double(zone.zone)))
+ .frame(width: geo.size.width * CGFloat(zone.min / widest))
+ }
+ .frame(height: 14)
+ Text("\(Units.trim(zone.min)) min")
+ .font(ForgeFont.numeric(.caption))
+ .foregroundStyle(theme.dim)
+ .frame(width: 56, alignment: .trailing)
+ }
+ }
+ }
+ }
+ }
+
+ private func routeCoordinates(_ session: SessionDetail) -> [CLLocationCoordinate2D]? {
+ session.series?.route?.compactMap {
+ $0.count >= 2 ? CLLocationCoordinate2D(latitude: $0[0], longitude: $0[1]) : nil
+ }
+ }
+}
+
+// MARK: - Route
+
+private struct RouteMap: View {
+ @Environment(\.theme) private var theme
+ let coordinates: [CLLocationCoordinate2D]
+
+ var body: some View {
+ Map(initialPosition: .rect(boundingRect)) {
+ MapPolyline(coordinates: coordinates)
+ .stroke(theme.accent, style: StrokeStyle(lineWidth: 4, lineCap: .round,
+ lineJoin: .round))
+ if let start = coordinates.first {
+ Annotation("Start", coordinate: start) {
+ Circle()
+ .fill(theme.accent)
+ .frame(width: 10, height: 10)
+ }
+ }
+ }
+ .mapStyle(.standard(elevation: .flat))
+ .allowsHitTesting(false) // it's a thumbnail in a scroll view, not a map to pan
+ }
+
+ /// Frame the whole trace with a little air around it.
+ private var boundingRect: MKMapRect {
+ coordinates.reduce(MKMapRect.null) { rect, coordinate in
+ let point = MKMapPoint(coordinate)
+ return rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
+ }
+ .insetBy(dx: -400, dy: -400)
+ }
+}
diff --git a/ios/Forge/Forge/Features/Root/RootView.swift b/ios/Forge/Forge/Features/Root/RootView.swift
index b004867..2f8d815 100644
--- a/ios/Forge/Forge/Features/Root/RootView.swift
+++ b/ios/Forge/Forge/Features/Root/RootView.swift
@@ -5,6 +5,8 @@ import SwiftUI
/// arrive in N4/N5 with their own accents and never mix with Train's volt.
struct RootView: View {
@Environment(AuthController.self) private var auth
+ @Environment(\.colorScheme) private var scheme
+ @State private var tab = ForgeTab.train
var body: some View {
switch auth.state {
@@ -20,15 +22,45 @@ struct RootView: View {
.trainTheme()
case .signedIn:
- TabView {
- Tab("Train", systemImage: "figure.strengthtraining.traditional") {
- WeekView().trainTheme()
+ // Per the decided boards the tab bar is the module switch —
+ // Train / Cook / Shop / Coach. History and Progress are Train
+ // screens, reached from Today, not top-level tabs. Shop joins at
+ // N5; shipping it as an empty tab now would be worse than a shorter
+ // bar. Settings hangs off the end until then.
+ //
+ // Each tab scopes its own theme: Train resolves dark or light with
+ // the system, Cook is always paper and ember. Two accents exist in
+ // the app, never on one screen — and that includes the tab bar,
+ // which is why it's tinted from the *selected* tab rather than
+ // once from Train. A volt "Cook" label sitting under a paper Cook
+ // screen is two accents on one screen.
+ TabView(selection: $tab) {
+ Tab("Train", systemImage: "figure.strengthtraining.traditional", value: .train) {
+ TodayView().trainTheme()
}
- Tab("Settings", systemImage: "gearshape") {
+ Tab("Cook", systemImage: "fork.knife", value: .cook) {
+ CookView().cookTheme()
+ }
+ Tab("Coach", systemImage: "bubble.left.and.text.bubble.right", value: .coach) {
+ CoachView().trainTheme()
+ }
+ Tab("Settings", systemImage: "gearshape", value: .settings) {
SettingsView().trainTheme()
}
}
- .trainTheme()
+ .environment(\.theme, shellTheme)
+ .tint(shellTheme.accent)
+ // Cook is paper in every ambience, so the bar under it has to be
+ // too — otherwise the glass reads dark against a light screen.
+ .environment(\.colorScheme, tab == .cook ? .light : scheme)
}
}
+
+ private var shellTheme: ForgeTheme {
+ tab == .cook ? .cookLight : .train(scheme)
+ }
+}
+
+nonisolated enum ForgeTab: Hashable {
+ case train, cook, coach, settings
}
diff --git a/ios/Forge/Forge/Features/Train/DayView.swift b/ios/Forge/Forge/Features/Train/DayView.swift
index 1721a51..637736e 100644
--- a/ios/Forge/Forge/Features/Train/DayView.swift
+++ b/ios/Forge/Forge/Features/Train/DayView.swift
@@ -217,14 +217,13 @@ struct DayView: View {
defer { starting = false }
do {
let response = try await auth.client.startSession(date: day.date, budget: day.budget)
- // The fitted targets are raw plan entries with no names; the day
- // payload already resolved them against the exercise library.
- let names = Dictionary(
- (day.exercises ?? []).map { ($0.slug, $0.name) },
- uniquingKeysWith: { first, _ in first }
- )
+ // The fitted targets are raw plan entries — no names, no last-time,
+ // no plate breakdown. The day payload has all three, already
+ // resolved against the exercise library.
live = SessionController(sessionId: response.id, fitted: response.fitted,
- client: auth.client, queue: queue, names: names)
+ client: auth.client, queue: queue,
+ plan: day.exercises ?? [],
+ profile: day.profile)
} catch {
errorMessage = error.localizedDescription
}
diff --git a/ios/Forge/Forge/Features/Train/SessionController.swift b/ios/Forge/Forge/Features/Train/SessionController.swift
index b6a2516..0126cad 100644
--- a/ios/Forge/Forge/Features/Train/SessionController.swift
+++ b/ios/Forge/Forge/Features/Train/SessionController.swift
@@ -42,12 +42,23 @@ final class SessionController: Identifiable, Hashable {
private(set) var isFinishing = false
private(set) var didFinish = false
+ /// When the workout started, for the session clock. Not the server's — the
+ /// server stamps a session at creation and this controller is created in
+ /// the same breath, but a resumed session should show time on the bar
+ /// today, not time since it was first opened yesterday.
+ let startedAt = Date()
+ /// The exercise's library form card, once fetched. Lazily filled.
+ private(set) var info: [String: ExerciseInfo] = [:]
+
private let client: APIClient
private let queue: SetQueue
- /// slug → display name. `fitted.targets` are the raw plan entries and
- /// usually carry no name, so the names come from the day payload, which
- /// resolved them against the exercise library.
- private let names: [String: String]
+ /// slug → the day's plan line. `fitted.targets` are the raw plan entries and
+ /// carry neither a name nor last-time/plate data, so both come from the day
+ /// payload, which resolved them against the exercise library.
+ private let plan: [String: TodayExercise]
+ /// The bar and plates actually in the room, so the logging screen can
+ /// re-plate live when the user steps off the prescribed load.
+ let profile: EquipmentProfile?
private var restTask: Task?
init(
@@ -55,18 +66,38 @@ final class SessionController: Identifiable, Hashable {
fitted: Fitted,
client: APIClient,
queue: SetQueue,
- names: [String: String] = [:]
+ plan: [TodayExercise] = [],
+ profile: EquipmentProfile? = nil
) {
self.sessionId = sessionId
self.fitted = fitted
self.client = client
self.queue = queue
- self.names = names
+ self.profile = profile
+ self.plan = Dictionary(plan.map { ($0.slug, $0) }, uniquingKeysWith: { first, _ in first })
// Heaviest thing first: the plan is already ordered with the priority-1
// main lift at the top, so "first target" is the right opening move.
self.currentSlug = fitted.targets.first { $0.sets > 0 }?.slug ?? ""
}
+ /// The day's plan line for an exercise — last time, plate breakdown, the
+ /// coach's note.
+ func planned(_ slug: String) -> TodayExercise? { plan[slug] }
+
+ /// Position of the current exercise in the session, 1-based — "exercise 2
+ /// of 4" on the logging screen.
+ var exerciseIndex: Int {
+ (targets.firstIndex { $0.slug == currentSlug } ?? 0) + 1
+ }
+
+ /// Pull the library entry for the form card. Cached; a miss is silent
+ /// because the card is context, not the job.
+ func loadInfo(_ slug: String) async {
+ guard info[slug] == nil, !slug.isEmpty else { return }
+ guard let detail = try? await client.exercise(slug: slug) else { return }
+ info[slug] = detail
+ }
+
/// Never shows a raw slug. Falls back to title-casing the slug, which
/// reads acceptably ("back-squat" → "Back Squat") for a coach-added
/// exercise the library hasn't caught up with.
@@ -74,7 +105,7 @@ final class SessionController: Identifiable, Hashable {
if let name = targets.first(where: { $0.slug == slug })?.name, !name.isEmpty {
return name
}
- if let name = names[slug], !name.isEmpty { return name }
+ if let name = plan[slug]?.name, !name.isEmpty { return name }
return slug.split(separator: "-").map(\.capitalized).joined(separator: " ")
}
diff --git a/ios/Forge/Forge/Features/Train/SessionView.swift b/ios/Forge/Forge/Features/Train/SessionView.swift
index 073b50b..b9cca84 100644
--- a/ios/Forge/Forge/Features/Train/SessionView.swift
+++ b/ios/Forge/Forge/Features/Train/SessionView.swift
@@ -2,10 +2,14 @@ import SwiftUI
/// The make-or-break screen: logging sets, mid-workout, one-handed, sweaty.
///
-/// Everything here optimises for that — display numerals are DIN and large, the
-/// primary action is a full-width pill in thumb reach, and nothing waits on the
-/// network. Loads are shown and edited in the user's display unit; only kg
-/// crosses the wire.
+/// Built to the decided board (`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." So the load is
+/// one enormous numeral with the plate math under it, the steppers are compact
+/// and paired beneath, and the set history is hairline rows rather than cards.
+///
+/// Loads are shown and edited in the user's display unit; only kg crosses the
+/// wire, and the entry field holds kg the whole time (see `seedEntry`).
struct SessionView: View {
@Environment(\.theme) private var theme
@Environment(AuthController.self) private var auth
@@ -15,53 +19,61 @@ struct SessionView: View {
/// Canonical kg, not a display number. Stepping converts to display,
/// applies one increment, and converts back; leaving it alone leaves the
- /// prescribed kg byte-for-byte intact. See `seedEntry`.
+ /// prescribed kg byte-for-byte intact.
@State private var weightKg: Double = 0
@State private var reps: Int = 0
@State private var rpe: Int?
@State private var showFinish = false
+ @State private var showExercises = false
@State private var seededFor: String?
+ @State private var elapsed: TimeInterval = 0
private var prefs: Prefs? { auth.me?.prefs }
private var target: FittedTarget? { controller.current }
- private var unit: LoadUnit {
- Units.loadUnit(prefs: prefs, slug: target?.slug)
- }
+ private var unit: LoadUnit { Units.loadUnit(prefs: prefs, slug: target?.slug) }
private var timedHold: Bool {
guard let target else { return false }
return isTimed(target.slug) || isTimed(controller.displayName(target.slug))
}
+ private var showsLoad: Bool { !timedHold || (target?.weight ?? 0) > 0 }
var body: some View {
VStack(spacing: 0) {
- exerciseStrip
+ header
ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- heading
- entry
- if controller.restRemaining > 0 { restBar }
- loggedSets
+ VStack(alignment: .leading, spacing: 16) {
+ formCard
+ loadCard
+ rpeRow
+ setRows
+ tiles
}
.padding(.horizontal, 18)
+ .padding(.top, 14)
.padding(.bottom, 24)
}
.scrollEdgeEffectStyle(.soft, for: .top)
logBar
}
.background(theme.bg)
- .navigationTitle(controller.fitted.name ?? "Session")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button("Finish") { showFinish = true }
- .foregroundStyle(theme.accent)
- }
- }
+ .toolbar(.hidden, for: .navigationBar)
+ // Logging is a mode, not a destination. The board has no tab bar here,
+ // and mid-set a stray thumb on "Cook" is a real way to lose your place.
+ .toolbar(.hidden, for: .tabBar)
.task {
await controller.hydrate()
seededFor = nil
seedEntry()
}
+ .task(id: controller.currentSlug) { await controller.loadInfo(controller.currentSlug) }
+ .task {
+ // One clock for the session tile. A `TimelineView` would redraw the
+ // whole screen every second, including the steppers.
+ while !Task.isCancelled {
+ elapsed = Date().timeIntervalSince(controller.startedAt)
+ try? await Task.sleep(for: .seconds(1))
+ }
+ }
.onChange(of: controller.currentSlug, initial: true) { seedEntry() }
.onChange(of: controller.didFinish) { if controller.didFinish { dismiss() } }
.sheet(isPresented: $showFinish) {
@@ -69,167 +81,296 @@ struct SessionView: View {
.trainTheme()
.presentationDetents([.medium])
}
+ .sheet(isPresented: $showExercises) {
+ ExercisePicker(controller: controller)
+ .trainTheme()
+ .presentationDetents([.medium])
+ }
.overlay(alignment: .top) { pbBanner }
}
- // MARK: - Exercise strip
+ // MARK: - Header
- private var exerciseStrip: some View {
- ScrollView(.horizontal) {
- HStack(spacing: 8) {
- ForEach(controller.targets) { item in
- let done = controller.doneCount(for: item.slug)
- let isCurrent = item.slug == controller.currentSlug
- Button {
- controller.select(item.slug)
- } label: {
- VStack(alignment: .leading, spacing: 3) {
- Text(controller.displayName(item.slug))
- .font(.system(.caption, weight: .semibold))
- .lineLimit(1)
- Text("\(done)/\(item.sets)")
- .font(ForgeFont.numeric(.caption2))
- .foregroundStyle(done >= item.sets ? theme.accent : theme.dim)
- }
- .padding(.vertical, 8)
- .padding(.horizontal, 12)
- .background(isCurrent ? theme.accentDim : theme.raised,
- in: .rect(cornerRadius: 12))
- }
- .foregroundStyle(isCurrent ? theme.ink : theme.mut)
+ /// Kicker + End, the progress rule, the exercise name, and the set line.
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .firstTextBaseline) {
+ Button { showExercises = true } label: {
+ ForgeLabel(kicker)
}
+ .buttonStyle(.plain)
+ Spacer(minLength: 8)
+ Button("End") { showFinish = true }
+ .font(.system(.subheadline))
+ .foregroundStyle(theme.mut)
}
- .padding(.horizontal, 18)
- .padding(.vertical, 10)
+
+ // One segment per exercise — where you are in the session, not in
+ // the set. The set count is in the line below it.
+ HStack(spacing: 5) {
+ ForEach(Array(controller.targets.enumerated()), id: \.element.id) { i, item in
+ Capsule()
+ .fill(controller.doneCount(for: item.slug) >= item.sets
+ ? theme.accent
+ : (i + 1 == controller.exerciseIndex ? theme.accent : theme.hairIn))
+ .frame(height: 3)
+ .opacity(i + 1 == controller.exerciseIndex ? 1 : 0.75)
+ }
+ }
+ .padding(.bottom, 2)
+
+ Text((target.map { controller.displayName($0.slug) } ?? "").uppercased())
+ .font(ForgeFont.display(40, relativeTo: .largeTitle))
+ .foregroundStyle(theme.ink)
+ .lineLimit(1)
+ .minimumScaleFactor(0.6)
+
+ setLine
}
- .scrollIndicators(.hidden)
+ .padding(.horizontal, 18)
+ .padding(.top, 8)
}
- // MARK: - Heading
+ private var kicker: String {
+ let name = controller.fitted.name ?? "Session"
+ return "\(name) · exercise \(controller.exerciseIndex) of \(controller.targets.count)"
+ }
- private var heading: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(target.map { controller.displayName($0.slug) } ?? "")
- .font(ForgeFont.title)
- .foregroundStyle(theme.ink)
- HStack(spacing: 8) {
- ForgeLabel("Set \(min(controller.nextSetNo, target?.sets ?? 1)) of \(target?.sets ?? 0)")
- if controller.pendingSync > 0 {
- ForgeChip(text: "\(controller.pendingSync) waiting to sync")
+ /// "Set **3 of 5** · last week 100 kg × 5 @ RPE 8". The second half is the
+ /// whole reason the board has this line — you decide today's load by what
+ /// you did last time, and having to leave the screen to find it is how
+ /// people end up repeating a week.
+ private var setLine: some View {
+ let setNo = min(controller.nextSetNo, target?.sets ?? 1)
+ return (Text("Set ").foregroundStyle(theme.mut)
+ + Text("\(setNo) of \(target?.sets ?? 0)").foregroundStyle(theme.ink).bold()
+ + Text(lastTimeSuffix).foregroundStyle(theme.mut))
+ .font(.system(.subheadline))
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ }
+
+ private var lastTimeSuffix: String {
+ guard let last = target.flatMap({ controller.planned($0.slug)?.last }),
+ let reps = last.reps.first
+ else { return "" }
+ var line = " · last time \(Units.fmtLoad(last.weight, unit)) × \(reps)"
+ if let rpe = last.rpe.first { line += " @ RPE \(rpe)" }
+ return line
+ }
+
+ // MARK: - Form card
+
+ /// The board's "form check" strip. It shows a *photo*, not a video, and it
+ /// says so: the video pipeline was retired and form media is curated
+ /// free-exercise-db stills. Claiming a 24-second clip that doesn't exist
+ /// would be worse than the honest card.
+ @ViewBuilder
+ private var formCard: some View {
+ if let slug = target?.slug, let cue = cueText(slug) {
+ HStack(spacing: 12) {
+ if controller.info[slug]?.hasMedia == true {
+ Color.clear
+ .frame(width: 62, height: 46)
+ .overlay {
+ ForgeAsyncImage(ref: ExerciseMedia.url(base: auth.client.baseURL,
+ slug: slug).absoluteString,
+ fill: theme.sunken)
+ }
+ .clipShape(.rect(cornerRadius: 9))
}
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Form")
+ .font(.system(.subheadline, weight: .semibold))
+ .foregroundStyle(theme.ink)
+ Text(cue)
+ .font(.system(.footnote))
+ .foregroundStyle(theme.mut)
+ .lineLimit(2)
+ }
+ Spacer(minLength: 4)
}
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(theme.raised, in: .rect(cornerRadius: 14))
}
- .padding(.top, 4)
}
- // MARK: - Entry
-
- private var entry: some View {
- VStack(spacing: 16) {
- if !timedHold || (target?.weight ?? 0) > 0 {
- Stepper2(
- label: "Load",
- value: Units.trim(Units.kgToDisp(weightKg, unit)),
- unit: unit.rawValue,
- onMinus: { weightKg = Units.stepped(weightKg, unit: unit, kind: target?.kind, up: false) },
- onPlus: { weightKg = Units.stepped(weightKg, unit: unit, kind: target?.kind, up: true) }
- )
- }
- Stepper2(
- label: timedHold ? "Hold" : "Reps",
- value: "\(reps)",
- unit: timedHold ? "sec" : nil,
- onMinus: { reps = max(0, reps - (timedHold ? 5 : 1)) },
- onPlus: { reps += timedHold ? 5 : 1 }
- )
- rpePicker
- }
+ /// The coach's note for this session beats the library's generic cue —
+ /// it's the thing written about *today*.
+ private func cueText(_ slug: String) -> String? {
+ if let note = controller.planned(slug)?.note, !note.isEmpty { return note }
+ let cues = controller.info[slug]?.cues ?? []
+ return cues.isEmpty ? nil : cues.prefix(2).joined(separator: ", ")
}
- private var rpePicker: some View {
- VStack(alignment: .leading, spacing: 8) {
- ForgeLabel("RPE — optional")
- HStack(spacing: 6) {
- ForEach(6...10, id: \.self) { value in
- Button {
- rpe = rpe == value ? nil : value
- } label: {
- Text("\(value)")
- .font(ForgeFont.numeric(.subheadline))
- .frame(maxWidth: .infinity)
- .padding(.vertical, 10)
- .background(rpe == value ? theme.accentDim : theme.raised,
- in: .rect(cornerRadius: 10))
- }
- .foregroundStyle(rpe == value ? theme.accent : theme.mut)
+ // MARK: - Load
+
+ /// The board's centrepiece: one race-clock numeral, the plate math under
+ /// it, and the two steppers small and paired beneath — you read the load
+ /// from a metre away and touch the steppers from thirty centimetres.
+ private var loadCard: some View {
+ VStack(spacing: 12) {
+ if showsLoad {
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Text(Units.trim(Units.kgToDisp(weightKg, unit)))
+ .font(ForgeFont.display(84, relativeTo: .largeTitle))
+ .foregroundStyle(theme.ink)
+ .contentTransition(.numericText())
+ Text(unit.rawValue)
+ .font(.system(.title3, weight: .medium))
+ .foregroundStyle(theme.dim)
}
+ .lineLimit(1)
+ .minimumScaleFactor(0.5)
+
+ if let plate = plateLine {
+ Text(plate)
+ .font(.system(.footnote))
+ .foregroundStyle(theme.dim)
+ .multilineTextAlignment(.center)
+ }
+ }
+
+ HStack(spacing: 10) {
+ if showsLoad {
+ CompactStepper(
+ value: Units.trim(Units.kgToDisp(weightKg, unit)),
+ label: "Load \(unit.rawValue)",
+ onMinus: { weightKg = Units.stepped(weightKg, unit: unit, kind: target?.kind, up: false) },
+ onPlus: { weightKg = Units.stepped(weightKg, unit: unit, kind: target?.kind, up: true) })
+ }
+ CompactStepper(
+ value: "\(reps)",
+ label: timedHold ? "Seconds" : "Reps",
+ onMinus: { reps = max(0, reps - (timedHold ? 5 : 1)) },
+ onPlus: { reps += timedHold ? 5 : 1 })
}
}
+ .padding(.vertical, 18)
+ .padding(.horizontal, 14)
+ .frame(maxWidth: .infinity)
+ .background(theme.raised, in: .rect(cornerRadius: 22))
}
- // MARK: - Rest
+ /// Plate math stays kg even when loads display in lb — plates are physical
+ /// objects with numbers stamped on them.
+ private var plateLine: String? {
+ guard let target, weightKg > 0 else { return nil }
+ // `fitted.targets` are raw plan entries — the *kind* is only resolved
+ // against the exercise library in the day payload, so a bar looks like
+ // a machine if you ask the session's own copy.
+ let kind = controller.planned(target.slug)?.kind ?? target.kind
+ if kind == "bb", let profile = controller.profile, !profile.platesKg.isEmpty {
+ return PlateMath.perSideLine(weightKg, profile: profile)
+ }
+ if kind == "db" {
+ return PlateMath.description(kind: "db", weight: weightKg, profile: nil,
+ unit: unit, slug: target.slug)
+ }
+ // No equipment profile: the server worked it out for the prescription,
+ // which stays right until the user steps off it — so only show it while
+ // they haven't.
+ return weightKg == target.weight ? controller.planned(target.slug)?.plate : nil
+ }
- private var restBar: some View {
- ForgeCard {
- HStack {
- VStack(alignment: .leading, spacing: 2) {
- ForgeLabel("Rest")
- Text(Format.timer(controller.restRemaining))
- .font(ForgeFont.stat)
- .foregroundStyle(theme.accent)
+ // MARK: - RPE
+
+ private var rpeRow: some View {
+ HStack(spacing: 8) {
+ ForgeLabel("RPE")
+ .frame(width: 34, alignment: .leading)
+ ForEach(6...10, id: \.self) { value in
+ Button {
+ rpe = rpe == value ? nil : value
+ } label: {
+ Text("\(value)")
+ .font(ForgeFont.numeric(.subheadline))
+ .foregroundStyle(rpe == value ? theme.accent : theme.mut)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 11)
+ .background(theme.raised, in: .rect(cornerRadius: 11))
+ .overlay {
+ if rpe == value {
+ RoundedRectangle(cornerRadius: 11)
+ .strokeBorder(theme.accent, lineWidth: 1.5)
+ }
+ }
}
- Spacer()
- Button("+30s") { controller.addRest(30) }
- .font(.system(.subheadline, weight: .semibold))
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ // MARK: - Sets
+
+ /// Hairline rows, not cards — done sets are reference, and the board keeps
+ /// them quiet so the load numeral stays the loudest thing on screen. The
+ /// row for the set *after* next is shown greyed with its rest, so you know
+ /// what's coming without it competing.
+ private var setRows: some View {
+ let done = controller.sets(for: controller.currentSlug)
+ let planned = target?.sets ?? 0
+ return VStack(spacing: 0) {
+ ForEach(done) { set in
+ row(title: "Set \(set.setNo)",
+ detail: "\(Units.fmtLoad(set.weightKg, unit)) × \(set.reps)\(timedHold ? "s" : "")"
+ + (set.rpe.map { " · RPE \($0)" } ?? ""),
+ done: true, synced: set.synced)
+ }
+ if controller.nextSetNo < planned, let rest = target?.rest {
+ row(title: "Set \(controller.nextSetNo + 1)",
+ detail: "after \(Format.timer(rest)) rest",
+ done: false, synced: true)
+ }
+ if let last = done.last, !last.synced {
+ Button("Undo last set") { controller.undoLast() }
+ .font(.footnote)
.foregroundStyle(theme.mut)
- Button("Skip") { controller.cancelRest() }
- .font(.system(.subheadline, weight: .semibold))
- .foregroundStyle(theme.accent)
- .padding(.leading, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 8)
}
}
}
- // MARK: - Logged sets
+ private func row(title: String, detail: String, done: Bool, synced: Bool) -> some View {
+ HStack {
+ Text(title)
+ .font(.system(.subheadline))
+ .foregroundStyle(done ? theme.ink : theme.dim)
+ Spacer(minLength: 8)
+ Text(detail)
+ .font(ForgeFont.numeric(.subheadline, weight: .regular))
+ .foregroundStyle(done ? theme.mut : theme.dim)
+ if done {
+ Image(systemName: synced ? "checkmark" : "arrow.trianglehead.2.clockwise")
+ .font(.system(.caption, weight: .semibold))
+ .foregroundStyle(synced ? theme.accent : theme.dim)
+ }
+ }
+ .padding(.vertical, 11)
+ .overlay(alignment: .bottom) { Rectangle().fill(theme.hair).frame(height: 1) }
+ }
- @ViewBuilder
- private var loggedSets: some View {
- let sets = controller.sets(for: controller.currentSlug)
- if !sets.isEmpty {
- VStack(alignment: .leading, spacing: 8) {
- ForgeLabel("Logged")
- ForEach(sets) { set in
- HStack {
- Text("\(set.setNo)")
- .font(ForgeFont.numeric(.footnote))
- .foregroundStyle(theme.dim)
- .frame(width: 20, alignment: .leading)
- Text("\(Units.fmtLoad(set.weightKg, unit)) × \(set.reps)\(timedHold ? "s" : "")")
- .font(ForgeFont.numeric(.subheadline))
- .foregroundStyle(theme.ink)
- if let rpe = set.rpe {
- Text("@\(rpe)")
- .font(.caption)
- .foregroundStyle(theme.dim)
- }
- Spacer()
- if !set.synced {
- Image(systemName: "arrow.trianglehead.2.clockwise")
- .font(.caption)
- .foregroundStyle(theme.dim)
- }
- }
- .padding(.vertical, 8)
- .padding(.horizontal, 12)
- .background(theme.raised, in: .rect(cornerRadius: 10))
- }
- if let last = sets.last, !last.synced {
- Button("Undo last set") { controller.undoLast() }
- .font(.footnote)
- .foregroundStyle(theme.mut)
- }
+ // MARK: - Tiles
+
+ /// Session clock, running tonnage, and the rest countdown when one's
+ /// ticking. The board's third tile is live heart rate — that needs
+ /// HealthKit (N2), so rest takes the slot rather than a fake number.
+ private var tiles: some View {
+ HStack(spacing: 10) {
+ SessionTile(label: "Session", value: Format.timer(Int(elapsed)))
+ SessionTile(label: "Volume", value: Units.trim(controller.tonnage), unit: "t")
+ if controller.restRemaining > 0 {
+ SessionTile(label: "Rest", value: Format.timer(controller.restRemaining),
+ accented: true)
+ .onTapGesture { controller.cancelRest() }
+ } else {
+ SessionTile(label: "Sets",
+ value: "\(controller.totalDone)/\(controller.totalPlanned)")
}
}
+ .padding(.top, 4)
}
// MARK: - Log bar
@@ -242,20 +383,25 @@ struct SessionView: View {
.foregroundStyle(theme.warn)
.lineLimit(2)
}
- Button(controller.isCurrentExerciseDone ? "Next exercise" : "Log set") {
+ if controller.pendingSync > 0 {
+ Text("\(controller.pendingSync) waiting to sync")
+ .font(.caption)
+ .foregroundStyle(theme.dim)
+ }
+ Button {
if controller.isCurrentExerciseDone {
controller.advanceIfDone()
} else {
controller.log(weightKg: weightKg, reps: reps, rpe: rpe)
controller.advanceIfDone()
}
+ } label: {
+ Text(logLabel)
+ .font(ForgeFont.display(22, relativeTo: .headline))
+ .tracking(0.6)
}
.buttonStyle(.pill)
.disabled(reps == 0 && !controller.isCurrentExerciseDone)
-
- Text("\(controller.totalDone) of \(controller.totalPlanned) sets · \(Units.trim(controller.tonnage)) t")
- .font(.caption)
- .foregroundStyle(theme.dim)
}
.padding(.horizontal, 18)
.padding(.top, 10)
@@ -263,6 +409,13 @@ struct SessionView: View {
.background(.bar)
}
+ private var logLabel: String {
+ if controller.isCurrentExerciseDone {
+ return controller.isComplete ? "FINISH WORKOUT" : "NEXT EXERCISE"
+ }
+ return "LOG SET \(min(controller.nextSetNo, target?.sets ?? 1))"
+ }
+
// MARK: - PB banner
@ViewBuilder
@@ -304,48 +457,128 @@ struct SessionView: View {
}
}
-// MARK: - Stepper
+// MARK: - Compact stepper
-/// Minus / big DIN numeral / plus. Named for the two controls flanking the
-/// value — SwiftUI's own `Stepper` is far too small for a working set.
-private struct Stepper2: View {
+/// − value + in one pill, with the unit as a caps label underneath. Small on
+/// purpose: the board puts the reading in the big numeral above and leaves
+/// these as controls, not displays.
+private struct CompactStepper: View {
@Environment(\.theme) private var theme
- let label: String
let value: String
- var unit: String?
+ let label: String
let onMinus: () -> Void
let onPlus: () -> Void
var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- ForgeLabel(label)
- HStack(spacing: 14) {
+ VStack(spacing: 2) {
+ HStack(spacing: 6) {
button("minus", action: onMinus)
- VStack(spacing: 0) {
- Text(value)
- .font(ForgeFont.display(52, relativeTo: .largeTitle))
- .foregroundStyle(theme.ink)
- .contentTransition(.numericText())
- if let unit {
- Text(unit)
- .font(.system(.caption, weight: .medium))
- .foregroundStyle(theme.dim)
- }
- }
- .frame(maxWidth: .infinity)
+ Text(value)
+ .font(ForgeFont.display(28, relativeTo: .title2))
+ .foregroundStyle(theme.ink)
+ .contentTransition(.numericText())
+ .frame(maxWidth: .infinity)
+ .lineLimit(1)
+ .minimumScaleFactor(0.6)
button("plus", action: onPlus)
}
+ ForgeLabel(label)
}
+ .padding(.vertical, 10)
+ .padding(.horizontal, 8)
+ .frame(maxWidth: .infinity)
+ .background(theme.sunken, in: .rect(cornerRadius: 16))
}
private func button(_ symbol: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: symbol)
- .font(.system(.title3, weight: .bold))
- .frame(width: 56, height: 56)
- .background(theme.raised, in: .circle)
+ .font(.system(.subheadline, weight: .bold))
+ .foregroundStyle(theme.mut)
+ // Small glyph, full-size hit target — this is pressed with a
+ // thumb, between sets, without looking.
+ .frame(width: 44, height: 44)
+ .contentShape(.rect)
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+// MARK: - Session tile
+
+private struct SessionTile: View {
+ @Environment(\.theme) private var theme
+ let label: String
+ let value: String
+ var unit: String?
+ var accented = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 3) {
+ ForgeLabel(label)
+ HStack(alignment: .firstTextBaseline, spacing: 2) {
+ Text(value)
+ .font(ForgeFont.display(26, relativeTo: .title2))
+ .foregroundStyle(accented ? theme.accent : theme.ink)
+ .contentTransition(.numericText())
+ if let unit {
+ Text(unit)
+ .font(.system(.caption2, weight: .medium))
+ .foregroundStyle(theme.dim)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 11)
+ .padding(.horizontal, 13)
+ .background(theme.raised, in: .rect(cornerRadius: 14))
+ }
+}
+
+// MARK: - Exercise picker
+
+/// Jumping between exercises. Off the main screen because the board doesn't
+/// have a horizontal strip any more — the progress rule says where you are and
+/// this says where else you could be.
+private struct ExercisePicker: View {
+ @Environment(\.theme) private var theme
+ @Environment(\.dismiss) private var dismiss
+ @Bindable var controller: SessionController
+
+ var body: some View {
+ NavigationStack {
+ List {
+ ForEach(controller.targets) { item in
+ Button {
+ controller.select(item.slug)
+ dismiss()
+ } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(controller.displayName(item.slug))
+ .font(.system(.body, weight: .medium))
+ .foregroundStyle(item.slug == controller.currentSlug
+ ? theme.accent : theme.ink)
+ Text("\(item.sets) × \(item.reps)")
+ .font(.system(.caption))
+ .foregroundStyle(theme.dim)
+ }
+ Spacer()
+ Text("\(controller.doneCount(for: item.slug))/\(item.sets)")
+ .font(ForgeFont.numeric(.subheadline))
+ .foregroundStyle(controller.doneCount(for: item.slug) >= item.sets
+ ? theme.accent : theme.mut)
+ }
+ }
+ .buttonStyle(.plain)
+ .listRowBackground(theme.raised)
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .background(theme.bg)
+ .navigationTitle("Exercises")
+ .navigationBarTitleDisplayMode(.inline)
}
- .foregroundStyle(theme.ink)
}
}
diff --git a/ios/Forge/Forge/Features/Train/TodayView.swift b/ios/Forge/Forge/Features/Train/TodayView.swift
new file mode 100644
index 0000000..2b74f89
--- /dev/null
+++ b/ios/Forge/Forge/Features/Train/TodayView.swift
@@ -0,0 +1,407 @@
+import SwiftUI
+
+/// The Train tab's root — **Today**, built to the decided board
+/// (`docs/mockups/native/redesign-d-type.png`): date kicker, big DIN title, a
+/// media-led session hero with the CTA inside it, cardio tiles, and the week
+/// strip at the *bottom* as secondary navigation.
+///
+/// The week list lives one level down (`WeekView`); it is not the landing.
+struct TodayView: View {
+ @Environment(\.theme) private var theme
+ @Environment(AuthController.self) private var auth
+ @Environment(SetQueue.self) private var queue
+
+ @State private var today: Today?
+ @State private var week: WeekResponse?
+ @State private var progress: Progress?
+ @State private var food: FoodWeek?
+ @State private var errorMessage: String?
+ @State private var starting = false
+ @State private var live: SessionController?
+
+ private var prefs: Prefs? { auth.me?.prefs }
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let today {
+ content(today)
+ } else if let errorMessage {
+ ForgeErrorView(message: errorMessage) { Task { await load() } }
+ } else {
+ ForgeProgress()
+ }
+ }
+ .background(theme.bg)
+ .toolbar(.hidden, for: .navigationBar)
+ .navigationDestination(for: TrainRoute.self) { route in
+ switch route {
+ case .week: WeekView().trainTheme()
+ case .day(let date): DayView(date: date).trainTheme()
+ case .progress: TrendsView().trainTheme()
+ case .history: HistoryView().trainTheme()
+ }
+ }
+ .navigationDestination(item: $live) { controller in
+ SessionView(controller: controller).trainTheme()
+ }
+ }
+ .task { if today == nil { await load() } }
+ }
+
+ private func load() async {
+ errorMessage = nil
+ do {
+ // The server owns "today" — never the phone's clock.
+ async let weekCall = auth.client.week()
+ let fetchedWeek = try await weekCall
+ week = fetchedWeek
+ today = try await auth.client.today(date: fetchedWeek.today)
+ // Recovery only rides on the day payload for rest days, but the
+ // tiles are on screen every day — take them from Progress, which
+ // always has the latest sample.
+ progress = try? await auth.client.progress()
+ // Read-only here by design (N4): Train shows what's for dinner,
+ // Cook owns every action on it. `try?` because nutrition is a beta
+ // track — an unseeded food library must not break the Train tab.
+ food = try? await auth.client.foodWeek(date: fetchedWeek.today)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ // MARK: - Content
+
+ @ViewBuilder
+ private func content(_ day: Today) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ header(day)
+ hero(day)
+ if day.kind == .strength, let est = day.est {
+ Text("fits your \(day.budget ?? est)-min budget · cooldown \(day.cd == "short" ? 2 : 5) min")
+ .font(.footnote)
+ .foregroundStyle(theme.dim)
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ cardioTiles(day)
+ mealsStrip()
+ weekStrip()
+ }
+ .padding(.horizontal, 18)
+ .padding(.bottom, 28)
+ }
+ .scrollEdgeEffectStyle(.soft, for: .top)
+ .refreshable { await load() }
+ }
+
+ private func header(_ day: Today) -> some View {
+ HStack(alignment: .center) {
+ VStack(alignment: .leading, spacing: 2) {
+ ForgeLabel(kicker(day))
+ Text("TODAY")
+ .font(ForgeFont.display(46, relativeTo: .largeTitle))
+ .foregroundStyle(theme.ink)
+ }
+ Spacer()
+ HStack(spacing: 10) {
+ NavigationLink(value: TrainRoute.history) {
+ Image(systemName: "clock.arrow.circlepath")
+ .font(.system(.body, weight: .medium))
+ .foregroundStyle(theme.mut)
+ .frame(width: 40, height: 40)
+ }
+ NavigationLink(value: TrainRoute.progress) {
+ Text(String(auth.me?.name.prefix(1) ?? "F"))
+ .font(.system(.headline, weight: .semibold))
+ .foregroundStyle(theme.accent)
+ .frame(width: 40, height: 40)
+ .overlay(Circle().strokeBorder(theme.accent, lineWidth: 1.5))
+ }
+ }
+ }
+ .padding(.top, 6)
+ }
+
+ /// "MONDAY 27 JULY · WEEK 31"
+ private func kicker(_ day: Today) -> String {
+ guard let date = ForgeDate.date(from: day.date) else { return day.dayName }
+ let formatter = DateFormatter()
+ formatter.dateFormat = "d MMMM"
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ let weekNumber = ForgeDate.calendar.component(.weekOfYear, from: date)
+ return "\(day.dayName) \(formatter.string(from: date)) · Week \(weekNumber)"
+ }
+
+ // MARK: - Hero
+
+ @ViewBuilder
+ private func hero(_ day: Today) -> some View {
+ switch day.kind {
+ case .strength: strengthHero(day)
+ case .cardio: cardioHero(day)
+ case .rest: restHero(day)
+ }
+ }
+
+ private func strengthHero(_ day: Today) -> some View {
+ let lifts = day.workingSets
+ let main = lifts.first { $0.isMainLift } ?? lifts.first
+ return MediaCard(hero: .forStrength(mainLiftSlug: main?.slug, dayName: day.name)) {
+ VStack(alignment: .leading, spacing: 10) {
+ ForgeLabel([day.name, main.map { _ in "Main lift" }].compactMap { $0 }.joined(separator: " · "),
+ color: theme.accent)
+ Text((main?.name ?? day.name ?? "Session").uppercased())
+ .font(ForgeFont.display(34, relativeTo: .title))
+ .foregroundStyle(heroInk)
+
+ VStack(spacing: 6) {
+ ForEach(lifts.prefix(4)) { lift in
+ HStack(alignment: .firstTextBaseline) {
+ Text(lift.name)
+ .font(.system(.subheadline, weight: .medium))
+ .foregroundStyle(heroInk)
+ if lift.isMainLift {
+ Text("MAIN")
+ .font(.system(.caption2, weight: .bold))
+ .foregroundStyle(theme.accent)
+ }
+ Spacer(minLength: 8)
+ Text(prescription(lift))
+ .font(ForgeFont.numeric(.subheadline, weight: .regular))
+ .foregroundStyle(heroInk.opacity(0.75))
+ }
+ }
+ }
+
+ Button {
+ Task { await start(day) }
+ } label: {
+ Text(day.session?.isActive == true ? "RESUME WORKOUT" : startLabel(day))
+ .font(ForgeFont.display(20, relativeTo: .headline))
+ .tracking(0.5)
+ }
+ .buttonStyle(.pill)
+ .disabled(starting)
+ .padding(.top, 4)
+ }
+ }
+ }
+
+ private func startLabel(_ day: Today) -> String {
+ day.est.map { "START WORKOUT · \($0) MIN" } ?? "START WORKOUT"
+ }
+
+ private func cardioHero(_ day: Today) -> some View {
+ MediaCard(hero: .forCardio(type: day.cardio?.type, name: day.name), height: 150) {
+ VStack(alignment: .leading, spacing: 8) {
+ ForgeLabel(day.name ?? "Cardio", color: theme.accent)
+ Text((day.cardio?.type ?? "Zone 2").uppercased())
+ .font(ForgeFont.display(34, relativeTo: .title))
+ .foregroundStyle(heroInk)
+ if let cardio = day.cardio {
+ Text("\(cardio.minutes ?? 0) min"
+ + (cardio.hrLow.flatMap { low in cardio.hrHigh.map { " · \(low)–\($0) bpm" } } ?? ""))
+ .font(ForgeFont.numeric(.subheadline))
+ .foregroundStyle(heroInk.opacity(0.75))
+ }
+ }
+ }
+ }
+
+ private func restHero(_ day: Today) -> some View {
+ MediaCard(hero: .rest, height: 150) {
+ VStack(alignment: .leading, spacing: 8) {
+ ForgeLabel("Recovery", color: theme.accent)
+ Text("REST DAY")
+ .font(ForgeFont.display(34, relativeTo: .title))
+ .foregroundStyle(heroInk)
+ if let tomorrow = day.tomorrow {
+ Text("Next: \(tomorrow.name ?? tomorrow.kind.capitalized) — \(tomorrow.dayName)")
+ .font(.footnote)
+ .foregroundStyle(heroInk.opacity(0.75))
+ }
+ }
+ }
+ }
+
+ /// White on the scrim in dark; ink on the card in light.
+ private var heroInk: Color {
+ colorScheme == .dark ? .white : theme.ink
+ }
+
+ @Environment(\.colorScheme) private var colorScheme
+
+ private func prescription(_ lift: TodayExercise) -> String {
+ let unit = Units.loadUnit(prefs: prefs, slug: lift.slug)
+ let reps = isTimed(lift.slug) || isTimed(lift.name) ? "\(lift.reps)s" : "\(lift.reps)"
+ return lift.weight > 0
+ ? "\(lift.sets) × \(reps) · \(Units.fmtLoad(lift.weight, unit))"
+ : "\(lift.sets) × \(reps)"
+ }
+
+ // MARK: - Cardio tiles
+
+ private func cardioTiles(_ day: Today) -> some View {
+ HStack(spacing: 10) {
+ // Photographic, per the design: the Zone-2 tile is one of the three
+ // surfaces media leads on (with the session hero and PR banners).
+ CardioTile(label: "Zone 2",
+ value: "\(progress?.zone2.done ?? 0)",
+ caption: "/\(progress?.zone2.target ?? 150) min",
+ accented: true,
+ hero: .run)
+ CardioTile(label: "Sleep",
+ value: sleepValue(day),
+ caption: "h")
+ CardioTile(label: "Rest HR",
+ value: restingHrValue(day),
+ caption: "bpm")
+ }
+ }
+
+ private func sleepValue(_ day: Today) -> String {
+ if let point = day.recovery?.sleepH { return String(format: "%.1f", point.value) }
+ return progress?.sleepH.last.map { String(format: "%.1f", $0.v) } ?? "—"
+ }
+
+ private func restingHrValue(_ day: Today) -> String {
+ if let point = day.recovery?.restingHr { return String(Int(point.value)) }
+ return progress?.restingHr.last.map { String(Int($0.v)) } ?? "—"
+ }
+
+ // MARK: - Meals
+
+ /// Tonight's dinner and the day's macros, read-only — the Cook tab owns
+ /// every action on food. Volt, not ember: this is a Train screen, and the
+ /// accent belongs to the module you're standing in.
+ @ViewBuilder
+ private func mealsStrip() -> some View {
+ if let day = food?.todayDay, day.totals.kcal > 0 || day.dinner?.recipe != nil {
+ HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 3) {
+ ForgeLabel("Tonight")
+ Text(day.dinner?.displayName ?? "Nothing planned")
+ .font(.system(.subheadline, weight: .medium))
+ .foregroundStyle(theme.ink)
+ .lineLimit(2)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 8)
+ VStack(alignment: .trailing, spacing: 3) {
+ Text("\(Int(day.totals.kcal.rounded()))")
+ .font(ForgeFont.display(26, relativeTo: .title2))
+ .foregroundStyle(theme.ink)
+ Text("\(Int(day.totals.proteinG.rounded()))g protein")
+ .font(.system(.caption2))
+ .foregroundStyle(theme.dim)
+ }
+ }
+ .padding(.vertical, 12)
+ .padding(.horizontal, 14)
+ .background(theme.raised, in: .rect(cornerRadius: 16))
+ }
+ }
+
+ // MARK: - Week strip
+
+ private func weekStrip() -> some View {
+ NavigationLink(value: TrainRoute.week) {
+ HStack(spacing: 6) {
+ ForEach(week?.days ?? []) { day in
+ VStack(spacing: 6) {
+ Text(String(day.dayName.prefix(3)).uppercased())
+ .font(.system(.caption2, weight: .bold))
+ .foregroundStyle(day.isToday ? theme.accent : theme.dim)
+ Circle()
+ .fill(dotFill(day))
+ .strokeBorder(day.session?.isDone == true ? .clear : theme.hairIn, lineWidth: 1)
+ .frame(width: 7, height: 7)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ .background(theme.raised, in: .rect(cornerRadius: 12))
+ .overlay {
+ if day.isToday {
+ RoundedRectangle(cornerRadius: 12)
+ .strokeBorder(theme.accent, lineWidth: 1.5)
+ }
+ }
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .padding(.top, 4)
+ }
+
+ private func dotFill(_ day: WeekDay) -> Color {
+ if day.session?.isDone == true { return theme.accent }
+ if day.isMissed(today: week?.today ?? day.date) { return theme.warn.opacity(0.5) }
+ return .clear
+ }
+
+ // MARK: - Start
+
+ private func start(_ day: Today) async {
+ starting = true
+ defer { starting = false }
+ do {
+ let response = try await auth.client.startSession(date: day.date, budget: day.budget)
+ live = SessionController(sessionId: response.id, fitted: response.fitted,
+ client: auth.client, queue: queue,
+ plan: day.exercises ?? [],
+ profile: day.profile)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
+
+nonisolated enum TrainRoute: Hashable {
+ case week
+ case day(String)
+ case progress
+ case history
+}
+
+// MARK: - Cardio tile
+
+private struct CardioTile: View {
+ @Environment(\.theme) private var theme
+ let label: String
+ let value: String
+ var caption: String?
+ var accented = false
+ var hero: HeroImage?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ ForgeLabel(label, color: accented ? theme.accent : theme.dim)
+ HStack(alignment: .firstTextBaseline, spacing: 3) {
+ Text(value)
+ .font(ForgeFont.display(30, relativeTo: .title2))
+ .foregroundStyle(theme.ink)
+ if let caption {
+ Text(caption)
+ .font(.system(.caption2, weight: .medium))
+ .foregroundStyle(theme.dim)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 12)
+ .padding(.horizontal, 14)
+ .background {
+ if let hero {
+ ZStack {
+ Image(hero: hero).resizable().scaledToFill()
+ LinearGradient(colors: [.black.opacity(0.45), .black.opacity(0.8)],
+ startPoint: .top, endPoint: .bottom)
+ }
+ } else {
+ theme.raised
+ }
+ }
+ .clipShape(.rect(cornerRadius: 16))
+ }
+}
diff --git a/ios/Forge/Forge/Kit/APIClient.swift b/ios/Forge/Forge/Kit/APIClient.swift
index 6f9ccd5..e8fb3ef 100644
--- a/ios/Forge/Forge/Kit/APIClient.swift
+++ b/ios/Forge/Forge/Kit/APIClient.swift
@@ -159,6 +159,10 @@ extension APIClient {
try await get("/api/sessions/\(id)")
}
+ func exercise(slug: String) async throws -> ExerciseInfo {
+ try await get("/api/exercises/\(slug)")
+ }
+
func logSet(sessionId: String, set: SetPayload) async throws -> LogSetResponse {
try await post("/api/sessions/\(sessionId)/sets", body: set)
}
@@ -167,6 +171,184 @@ extension APIClient {
try await post("/api/sessions/\(sessionId)/complete", body: body)
}
+ // MARK: History, records, trends
+
+ /// Offset-paginated. `favorites` narrows to starred sessions.
+ func history(limit: Int = 30, offset: Int = 0, favorites: Bool = false) async throws -> [HistoryItem] {
+ var query = ["limit": String(limit), "offset": String(offset)]
+ if favorites { query["favorites"] = "true" }
+ return try await get("/api/history", query: query)
+ }
+
+ func progress() async throws -> Progress {
+ try await get("/api/progress")
+ }
+
+ func records() async throws -> [RecordRow] {
+ try await get("/api/records")
+ }
+
+ @discardableResult
+ func setFavorite(sessionId: String, favorite: Bool) async throws -> EmptyResponse {
+ nonisolated struct Body: Encodable { let favorite: Bool }
+ return try await request("/api/sessions/\(sessionId)/favorite", method: "PATCH",
+ query: [:], body: Body(favorite: favorite))
+ }
+
+ /// Discards a session and its sets. All-time records survive — the same
+ /// simplification the server makes for `edit_set`.
+ @discardableResult
+ func deleteSession(id: String) async throws -> EmptyResponse {
+ try await request("/api/sessions/\(id)", method: "DELETE",
+ query: [:], body: Optional.none)
+ }
+
+ // MARK: Coach
+
+ func chat() async throws -> ChatResponse {
+ try await get("/api/chat")
+ }
+
+ /// Fire-and-poll: the server runs the coach turn on a background thread and
+ /// answers immediately with `pending`, so the client polls `chat()` rather
+ /// than holding a request open across a model call.
+ @discardableResult
+ func sendChat(_ text: String, context: ChatContext? = nil) async throws -> EmptyResponse {
+ nonisolated struct Body: Encodable {
+ let text: String
+ let context: [String: String]?
+ }
+ var payload: [String: String]?
+ if let context {
+ payload = ["kind": context.kind, "label": context.label]
+ if let id = context.id { payload?["id"] = id }
+ }
+ return try await post("/api/chat", body: Body(text: text, context: payload))
+ }
+
+ func proposal() async throws -> ProposalResponse {
+ try await get("/api/proposal")
+ }
+
+ @discardableResult
+ func approveProposal(id: String) async throws -> EmptyResponse {
+ try await post("/api/proposal/\(id)/approve", body: EmptyResponse())
+ }
+
+ @discardableResult
+ func rejectProposal(id: String) async throws -> EmptyResponse {
+ try await post("/api/proposal/\(id)/reject", body: EmptyResponse())
+ }
+
+ // MARK: - Cook
+
+ /// Calendar-week food view, same Mon–Sun contract as `/api/week`. The plan
+ /// is household-shared; the logs inside it are this user's.
+ func foodWeek(date: String? = nil) async throws -> FoodWeek {
+ try await get("/api/food/week", query: date.map { ["date": $0] } ?? [:])
+ }
+
+ /// The whole library including parked imports — the UI badges them rather
+ /// than hiding them, so filtering happens client-side.
+ func recipes(q: String = "", kind: String? = nil) async throws -> RecipeListResponse {
+ var query: [String: String] = [:]
+ if !q.isEmpty { query["q"] = q }
+ if let kind { query["kind"] = kind }
+ return try await get("/api/food/recipes", query: query)
+ }
+
+ func recipe(slug: String) async throws -> RecipeDetail {
+ try await get("/api/food/recipes/\(slug)")
+ }
+
+ /// Log a meal into a slot. Breakfast/lunch/dinner replace whatever was in
+ /// the slot; snacks stack. `clientId` makes the write idempotent.
+ @discardableResult
+ func logMeal(date: String, slot: String, recipe: String, servings: Double = 1,
+ clientId: String) async throws -> LogMealResponse {
+ nonisolated struct Body: Encodable {
+ let date: String
+ let slot: String
+ let recipe: String
+ let servings: Double
+ let client_id: String
+ }
+ return try await post("/api/food/log",
+ body: Body(date: date, slot: slot, recipe: recipe,
+ servings: servings, client_id: clientId))
+ }
+
+ @discardableResult
+ func unlogMeal(id: String) async throws -> EmptyResponse {
+ try await request("/api/food/log/\(id)", method: "DELETE",
+ query: [:], body: Optional.none)
+ }
+
+ /// Pencil a recipe into a weekday's slot on the active food week. This is a
+ /// direct user edit — no coach proposal — and it's shared, so it changes
+ /// what the other seat sees too.
+ @discardableResult
+ func planSlot(date: String, recipe: String, slot: String = "dinner") async throws -> PlanSlotResponse {
+ nonisolated struct Body: Encodable {
+ let date: String
+ let recipe: String
+ let slot: String
+ }
+ return try await request("/api/food/week/slot", method: "PATCH", query: [:],
+ body: Body(date: date, recipe: recipe, slot: slot))
+ }
+
+ /// The pending food-week proposal, or `nil` when there's nothing to decide.
+ /// Household-scoped like the plan itself — either seat can approve it.
+ func foodProposal() async throws -> FoodProposalResponse {
+ try await get("/api/food/proposal")
+ }
+
+ @discardableResult
+ func approveFoodProposal(id: String) async throws -> EmptyResponse {
+ try await post("/api/food/proposal/\(id)/approve", body: EmptyResponse())
+ }
+
+ @discardableResult
+ func rejectFoodProposal(id: String) async throws -> EmptyResponse {
+ try await post("/api/food/proposal/\(id)/reject", body: EmptyResponse())
+ }
+
+ /// Bytes for a recipe/meal image reference.
+ ///
+ /// The reference is either a path on our own server or an absolute URL an
+ /// import kept when it couldn't materialize the blob. The session cookie
+ /// goes **only** to our origin — sending it to a recipe site's CDN would
+ /// hand out a live Forge session.
+ func imageData(_ ref: String) async throws -> Data {
+ let url: URL
+ var sameOrigin = true
+ if ref.hasPrefix("http://") || ref.hasPrefix("https://") {
+ guard let remote = URL(string: ref) else { throw APIError.network }
+ url = remote
+ sameOrigin = remote.host == baseURL.host && remote.port == baseURL.port
+ } else {
+ url = baseURL.appendingPathComponent(ref.hasPrefix("/") ? String(ref.dropFirst()) : ref)
+ }
+
+ var req = URLRequest(url: url)
+ if sameOrigin, let token {
+ req.setValue("\(Self.sessionCookieName)=\(token)", forHTTPHeaderField: "Cookie")
+ }
+
+ let data: Data, response: URLResponse
+ do {
+ (data, response) = try await session.data(for: req)
+ } catch {
+ throw APIError.network
+ }
+ guard let http = response as? HTTPURLResponse else { throw APIError.network }
+ guard (200..<300).contains(http.statusCode) else {
+ throw APIError.server(status: http.statusCode, detail: "")
+ }
+ return data
+ }
+
/// Dev sign-in (only offered when Google is unconfigured, same as the web).
/// The server answers with `Set-Cookie`; we lift the session value out of
/// the header ourselves because this client deliberately stores no cookies.
diff --git a/ios/Forge/Forge/Kit/CoachModels.swift b/ios/Forge/Forge/Kit/CoachModels.swift
new file mode 100644
index 0000000..b33906e
--- /dev/null
+++ b/ios/Forge/Forge/Kit/CoachModels.swift
@@ -0,0 +1,99 @@
+import Foundation
+
+/// The coach surface — chat plus the proposal the user approves.
+///
+/// Proposals are never auto-applied unless `prefs.coach_approval == 'auto'`;
+/// the app's job is to render the delta honestly and let the user decide.
+
+nonisolated struct ChatMessage: Codable, Sendable, Equatable, Identifiable {
+ /// "me" | "coach"
+ let who: String
+ let text: String
+ let at: String?
+
+ var isCoach: Bool { who == "coach" }
+ /// Server rows have no id; the timestamp plus role is stable enough for
+ /// SwiftUI's diffing and avoids an index-based identity that reshuffles.
+ var id: String { "\(at ?? "")#\(who)#\(text.hashValue)" }
+}
+
+nonisolated struct ChatResponse: Codable, Sendable, Equatable {
+ let messages: [ChatMessage]
+ /// True while a coach turn is running server-side. The client polls until
+ /// it clears rather than holding a request open.
+ let pending: Bool
+}
+
+/// What the user was looking at when they opened the coach.
+nonisolated struct ChatContext: Codable, Sendable, Equatable {
+ /// "session" | "exercise" | "proposal"
+ let kind: String
+ let id: String?
+ let label: String
+}
+
+// MARK: - Proposals
+
+nonisolated struct ProposalExercise: Codable, Sendable, Hashable, Identifiable {
+ let slug: String
+ let sets: Int
+ let reps: Int
+ let weight: Double?
+
+ var id: String { slug }
+}
+
+nonisolated struct ProposalCardio: Codable, Sendable, Hashable {
+ let minutes: Int?
+ let hrLow: Int?
+ let hrHigh: Int?
+}
+
+nonisolated struct ProposalDay: Codable, Sendable, Hashable {
+ let name: String
+ let kind: DayKind
+ let focus: [String]?
+ /// One-liner justifying this day. The server requires it; the UI renders it.
+ let why: String?
+ let exercises: [ProposalExercise]?
+ let cardio: ProposalCardio?
+}
+
+nonisolated struct ProposalChange: Codable, Sendable, Hashable, Identifiable {
+ /// "+" added · "-" removed · "~" adjusted
+ let sign: String
+ let what: String
+ let why: String?
+
+ var id: String { "\(sign)\(what)" }
+}
+
+nonisolated struct ProposalContent: Codable, Sendable, Hashable {
+ /// Keyed by weekday number as a string, "0" (Monday) … "6".
+ let days: [String: ProposalDay]
+ let changes: [ProposalChange]?
+}
+
+nonisolated struct Proposal: Codable, Sendable, Hashable, Identifiable {
+ let id: String
+ let num: Int
+ let rationale: String
+ let createdAt: String
+ let content: ProposalContent
+
+ /// Monday-first, so the review reads in the order the week is lived.
+ var orderedDays: [(weekday: Int, day: ProposalDay)] {
+ content.days
+ .compactMap { key, value in Int(key).map { ($0, value) } }
+ .sorted { $0.0 < $1.0 }
+ .map { (weekday: $0.0, day: $0.1) }
+ }
+}
+
+nonisolated struct ProposalResponse: Codable, Sendable, Equatable {
+ let proposal: Proposal?
+}
+
+nonisolated let weekdayNames = [
+ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
+]
diff --git a/ios/Forge/Forge/Kit/CookCue.swift b/ios/Forge/Forge/Kit/CookCue.swift
new file mode 100644
index 0000000..5599833
--- /dev/null
+++ b/ios/Forge/Forge/Kit/CookCue.swift
@@ -0,0 +1,84 @@
+import Foundation
+
+/// Cook mode's step text carries one idea in ember: **the done-when cue**.
+///
+/// On the board (`docs/mockups/native/cook-mode.png`) "It's ready when the skin
+/// releases with no tug" is ember inside an otherwise black sentence — the cue
+/// is what you look up from the pan to check, so it gets the accent.
+///
+/// This is derivable rather than authored because Forge's recipe rule already
+/// requires it: every imported step is "rewritten in done-when voice (each step
+/// states the cue that tells you it's done)" — root `CLAUDE.md`. So the marker
+/// is reliably in the prose, and matching it beats adding a field the MCP
+/// importer and the seed would both have to start filling in.
+nonisolated enum CookCue {
+ /// Phrases that open a done-when clause, longest first so "it's ready when"
+ /// wins over a bare "ready when".
+ private static let openers = [
+ "done when", "ready when", "you'll know when", "you know when",
+ "it's ready when", "it's done when", "stop when", "pull it when",
+ "take it off when", "keep going until", "cook until", "until",
+ ]
+
+ /// The cue's range inside `detail`, or nil when the step has no cue —
+ /// prep steps ("Oven to 220° fan") genuinely don't have one and shouldn't
+ /// get a highlight invented for them.
+ ///
+ /// The clause runs from the opener to the next sentence break or em dash;
+ /// the board's example ends at " — about 4 minutes", which is timing, not
+ /// the cue.
+ static func range(in detail: String) -> Range? {
+ let lower = detail.lowercased()
+ var best: Range?
+ for opener in openers {
+ guard let found = lower.range(of: opener) else { continue }
+ // Earliest opener wins: a step with two cues means the first one is
+ // the one you're waiting on right now.
+ if let current = best, found.lowerBound >= current.lowerBound { continue }
+ best = found
+ }
+ guard let opener = best else { return nil }
+
+ // Walk back to the start of the sentence the opener sits in, so the
+ // highlight reads as a whole clause rather than starting mid-thought.
+ let start = sentenceStart(in: detail, before: opener.lowerBound)
+ let end = clauseEnd(in: detail, from: opener.upperBound)
+ return start < end ? start.. (before: String, cue: String, after: String) {
+ guard let range = range(in: detail) else { return (detail, "", "") }
+ return (String(detail[detail.startIndex.. = [".", "!", "?", ";"]
+ private static let clauseBreaks: Set = ["—", "–"]
+
+ private static func sentenceStart(in text: String, before index: String.Index) -> String.Index {
+ var i = index
+ while i > text.startIndex {
+ let previous = text.index(before: i)
+ let ch = text[previous]
+ if sentenceBreaks.contains(ch) || clauseBreaks.contains(ch) { break }
+ i = previous
+ }
+ // Skip the whitespace the break left behind.
+ while i < index, text[i].isWhitespace { i = text.index(after: i) }
+ return i
+ }
+
+ private static func clauseEnd(in text: String, from index: String.Index) -> String.Index {
+ var i = index
+ while i < text.endIndex {
+ let ch = text[i]
+ if sentenceBreaks.contains(ch) || clauseBreaks.contains(ch) { break }
+ i = text.index(after: i)
+ }
+ // Trim the space before the break so the ember doesn't own it.
+ while i > index, text[text.index(before: i)].isWhitespace { i = text.index(before: i) }
+ return i
+ }
+}
diff --git a/ios/Forge/Forge/Kit/DataModels.swift b/ios/Forge/Forge/Kit/DataModels.swift
new file mode 100644
index 0000000..cdffd70
--- /dev/null
+++ b/ios/Forge/Forge/Kit/DataModels.swift
@@ -0,0 +1,128 @@
+import Foundation
+
+/// History, records and trends — `/api/history`, `/api/progress`, `/api/records`,
+/// and the full `/api/sessions/{id}` detail. Mirrors `web/src/api.ts`.
+
+nonisolated struct HistoryItem: Codable, Sendable, Equatable, Identifiable {
+ let id: String
+ let day: String
+ let name: String
+ let kind: String
+ let status: String
+ let stats: SessionStats
+ var favorite: Bool?
+
+ var isFavorite: Bool { favorite ?? false }
+}
+
+/// One point on a trend line. The server sends compact keys.
+nonisolated struct SeriesPoint: Codable, Sendable, Equatable, Identifiable {
+ let d: String
+ let v: Double
+
+ var id: String { d }
+ var date: Date { ForgeDate.date(from: d) ?? .distantPast }
+}
+
+nonisolated struct LiftSeries: Codable, Sendable, Equatable {
+ let name: String
+ let points: [SeriesPoint]
+}
+
+nonisolated struct BodyComp: Codable, Sendable, Equatable {
+ let fatPct: [SeriesPoint]
+ let muscle: [SeriesPoint]
+ let bone: [SeriesPoint]
+ let waterPct: [SeriesPoint]
+ let heightCm: Double?
+}
+
+nonisolated struct Zone2Progress: Codable, Sendable, Equatable {
+ let done: Int
+ let target: Int
+}
+
+nonisolated struct WeekProgress: Codable, Sendable, Equatable {
+ let done: Int
+ let planned: Int
+}
+
+nonisolated struct Progress: Codable, Sendable, Equatable {
+ let e1rm: [String: LiftSeries]
+ let weight: [SeriesPoint]
+ let vo2max: [SeriesPoint]
+ /// Centred rolling mean. VO2max renders raw dots **plus** this trend and
+ /// never deltas — single readings are noisy enough to mislead.
+ let vo2maxSmooth: [SeriesPoint]
+ let restingHr: [SeriesPoint]
+ let sleepH: [SeriesPoint]
+ let zone2: Zone2Progress
+ let bodycomp: BodyComp
+ let week: WeekProgress
+
+ /// Lifts with enough history to plot, heaviest-first by latest e1RM.
+ var plottableLifts: [(slug: String, series: LiftSeries)] {
+ e1rm.filter { $0.value.points.count > 1 }
+ .sorted { ($0.value.points.last?.v ?? 0) > ($1.value.points.last?.v ?? 0) }
+ .map { (slug: $0.key, series: $0.value) }
+ }
+}
+
+nonisolated struct RecordRow: Codable, Sendable, Equatable, Identifiable {
+ let slug: String
+ let name: String
+ /// "e1rm" | "best_set"
+ let kind: String
+ /// Canonical kg — the number to display, converted.
+ let value: Double
+ /// Server-formatted "72.5 × 5". The load in it is **kg**, so it must never
+ /// be shown raw to a user reading in lb.
+ let detail: String
+ let achievedOn: String
+
+ var id: String { "\(slug)#\(kind)" }
+
+ /// "× 5" — the reps half of `detail`, which needs no conversion.
+ var repsPart: String {
+ detail.split(separator: "×").dropFirst().first
+ .map { "× \($0.trimmingCharacters(in: .whitespaces))" } ?? ""
+ }
+
+ /// `detail` with its load converted to the user's unit.
+ func detail(in unit: LoadUnit) -> String {
+ let load = Units.fmtLoad(value, unit)
+ let reps = repsPart
+ return reps.isEmpty ? load : "\(load) \(reps)"
+ }
+}
+
+// MARK: - Session detail
+
+nonisolated struct HeartRateZone: Codable, Sendable, Equatable, Identifiable {
+ let zone: Int
+ let low: Double
+ /// Open-ended for the top zone.
+ let high: Double?
+ /// Minutes in the zone — fractional, since it's derived from the share of
+ /// samples in band rather than counted whole minutes.
+ let min: Double
+
+ var id: Int { zone }
+}
+
+nonisolated struct ZoneBreakdown: Codable, Sendable, Equatable {
+ let hrMax: Int
+ /// True when `hrMax` was estimated rather than set in prefs — the UI says so
+ /// rather than presenting a guess as a measurement.
+ let estimated: Bool
+ let zones: [HeartRateZone]
+}
+
+/// Downsampled at ingest and stored in a separate table, so history queries
+/// never load the blobs.
+nonisolated struct WorkoutSeries: Codable, Sendable, Equatable {
+ /// [[offsetSeconds, bpm], …]
+ let hr: [[Double]]?
+ /// [[lat, lon], …]
+ let route: [[Double]]?
+}
diff --git a/ios/Forge/Forge/Kit/FoodModels.swift b/ios/Forge/Forge/Kit/FoodModels.swift
new file mode 100644
index 0000000..ded3b5d
--- /dev/null
+++ b/ios/Forge/Forge/Kit/FoodModels.swift
@@ -0,0 +1,480 @@
+import Foundation
+
+/// Nutrition — `/api/food/*`. Mirrors `server/app/routers/food.py` and the
+/// PWA's `web/src/screens/food.tsx`.
+///
+/// The food week is household-shared (one plan for James and Shelby) while
+/// **meal logs are strictly per-user**: the plan says what's for dinner, the log
+/// says who actually ate it. Slots and logs are therefore never merged here —
+/// the server has already folded a logged meal into its slot.
+
+// MARK: - Macros
+
+/// Per serving for a recipe, per portion for a log. Every field is canonical —
+/// there is no display-unit conversion in food, unlike loads.
+nonisolated struct Macros: Codable, Sendable, Equatable, Hashable {
+ var kcal: Double = 0
+ var proteinG: Double = 0
+ var carbsG: Double = 0
+ var sugarG: Double = 0
+ var fiberG: Double = 0
+ var fatG: Double = 0
+ var satfatG: Double = 0
+ var sodiumMg: Double = 0
+
+ static func + (a: Macros, b: Macros) -> Macros {
+ Macros(kcal: a.kcal + b.kcal, proteinG: a.proteinG + b.proteinG,
+ carbsG: a.carbsG + b.carbsG, sugarG: a.sugarG + b.sugarG,
+ fiberG: a.fiberG + b.fiberG, fatG: a.fatG + b.fatG,
+ satfatG: a.satfatG + b.satfatG, sodiumMg: a.sodiumMg + b.sodiumMg)
+ }
+
+ /// Every field optional. Swift's synthesised decoder ignores default values
+ /// and throws on a missing key — one absent macro would take down the whole
+ /// week rather than showing a zero.
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ kcal = try c.decodeIfPresent(Double.self, forKey: .kcal) ?? 0
+ proteinG = try c.decodeIfPresent(Double.self, forKey: .proteinG) ?? 0
+ carbsG = try c.decodeIfPresent(Double.self, forKey: .carbsG) ?? 0
+ sugarG = try c.decodeIfPresent(Double.self, forKey: .sugarG) ?? 0
+ fiberG = try c.decodeIfPresent(Double.self, forKey: .fiberG) ?? 0
+ fatG = try c.decodeIfPresent(Double.self, forKey: .fatG) ?? 0
+ satfatG = try c.decodeIfPresent(Double.self, forKey: .satfatG) ?? 0
+ sodiumMg = try c.decodeIfPresent(Double.self, forKey: .sodiumMg) ?? 0
+ }
+
+ init(kcal: Double = 0, proteinG: Double = 0, carbsG: Double = 0, sugarG: Double = 0,
+ fiberG: Double = 0, fatG: Double = 0, satfatG: Double = 0, sodiumMg: Double = 0) {
+ self.kcal = kcal; self.proteinG = proteinG; self.carbsG = carbsG; self.sugarG = sugarG
+ self.fiberG = fiberG; self.fatG = fatG; self.satfatG = satfatG; self.sodiumMg = sodiumMg
+ }
+}
+
+// MARK: - Recipes
+
+/// The list/summary shape (`food.recipe_card`). `image` is `images[0]` — either
+/// a relative `/api/food/media/{id}` path or a remote URL the import kept.
+nonisolated struct RecipeCard: Codable, Sendable, Hashable, Identifiable {
+ let slug: String
+ let name: String
+ /// dinner | breakfast | lunch | snack
+ let kind: String
+ let minutes: Int
+ let difficulty: String
+ let serves: Int
+ let batch: Int
+ let platefig: String
+ let why: String
+ let image: String?
+ let rating: Double
+ let macros: Macros
+
+ /// Only the list endpoint sends these.
+ var tags: [String]?
+ var source: String?
+ /// Parked imports (unknown ingredient, `hard`, or zero kcal) stay browsable
+ /// but are never proposable — the UI badges them rather than hiding them.
+ var complete: Bool?
+
+ var id: String { slug }
+ var isParked: Bool { complete == false }
+
+ /// "35 min · 52 g protein" — the library tile's one-line meta.
+ var meta: String {
+ var parts: [String] = []
+ if minutes > 0 { parts.append("\(minutes) min") }
+ if macros.proteinG > 0 { parts.append("\(Int(macros.proteinG.rounded())) g protein") }
+ return parts.joined(separator: " · ")
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case slug, name, kind, minutes, difficulty, serves, batch, platefig, why
+ case image, rating, tags, source, complete
+ case kcal, proteinG, carbsG, sugarG, fiberG, fatG, satfatG, sodiumMg
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ slug = try c.decode(String.self, forKey: .slug)
+ name = try c.decode(String.self, forKey: .name)
+ kind = try c.decodeIfPresent(String.self, forKey: .kind) ?? "dinner"
+ minutes = try c.decodeIfPresent(Int.self, forKey: .minutes) ?? 0
+ difficulty = try c.decodeIfPresent(String.self, forKey: .difficulty) ?? "easy"
+ serves = try c.decodeIfPresent(Int.self, forKey: .serves) ?? 2
+ batch = try c.decodeIfPresent(Int.self, forKey: .batch) ?? 0
+ platefig = try c.decodeIfPresent(String.self, forKey: .platefig) ?? "plate"
+ why = try c.decodeIfPresent(String.self, forKey: .why) ?? ""
+ image = try c.decodeIfPresent(String.self, forKey: .image)
+ rating = try c.decodeIfPresent(Double.self, forKey: .rating) ?? 0
+ tags = try c.decodeIfPresent([String].self, forKey: .tags)
+ source = try c.decodeIfPresent(String.self, forKey: .source)
+ complete = try c.decodeIfPresent(Bool.self, forKey: .complete)
+ macros = Macros(
+ kcal: try c.decodeIfPresent(Double.self, forKey: .kcal) ?? 0,
+ proteinG: try c.decodeIfPresent(Double.self, forKey: .proteinG) ?? 0,
+ carbsG: try c.decodeIfPresent(Double.self, forKey: .carbsG) ?? 0,
+ sugarG: try c.decodeIfPresent(Double.self, forKey: .sugarG) ?? 0,
+ fiberG: try c.decodeIfPresent(Double.self, forKey: .fiberG) ?? 0,
+ fatG: try c.decodeIfPresent(Double.self, forKey: .fatG) ?? 0,
+ satfatG: try c.decodeIfPresent(Double.self, forKey: .satfatG) ?? 0,
+ sodiumMg: try c.decodeIfPresent(Double.self, forKey: .sodiumMg) ?? 0)
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var c = encoder.container(keyedBy: CodingKeys.self)
+ try c.encode(slug, forKey: .slug)
+ try c.encode(name, forKey: .name)
+ try c.encode(kind, forKey: .kind)
+ try c.encode(minutes, forKey: .minutes)
+ try c.encode(macros.kcal, forKey: .kcal)
+ try c.encode(macros.proteinG, forKey: .proteinG)
+ }
+}
+
+/// One method step. `timer` means the step earns a ring; `parallel` means it
+/// runs in the background while you carry on (a simmer, an oven).
+nonisolated struct RecipeStep: Codable, Sendable, Hashable, Identifiable {
+ let title: String
+ let minutes: Int?
+ let detail: String
+ let timer: Bool?
+ let parallel: Bool?
+ let image: String?
+
+ /// Positional — steps have no ids of their own and are always rendered in
+ /// order, so the index is the identity.
+ var id: String { "\(title)#\(detail.prefix(24))" }
+
+ var hasTimer: Bool { timer == true && (minutes ?? 0) > 0 }
+ var isParallel: Bool { parallel == true }
+ var seconds: Int { max(60, (minutes ?? 1) * 60) }
+}
+
+nonisolated struct RecipeIngredient: Codable, Sendable, Hashable, Identifiable {
+ let name: String
+ let qty: Double?
+ let unit: String?
+ /// Human quantity as written on the card ("2 fillets", "½ bag").
+ let disp: String?
+ let note: String?
+ let aisle: String?
+ /// Pantry staples never reach a shopping list.
+ let pantry: Bool?
+
+ var id: String { name }
+ var quantity: String { disp ?? [qty.map { Units.trim($0) }, unit].compactMap { $0 }.joined() }
+ var isPantry: Bool { pantry == true }
+}
+
+nonisolated struct RecipeDetail: Codable, Sendable, Hashable, Identifiable {
+ let card: RecipeCard
+ let steps: [RecipeStep]
+ let ingredients: [RecipeIngredient]
+ let tags: [String]
+ let source: String
+ let sourceUrl: String
+ let images: [String]
+ let ratingCount: Int
+
+ var id: String { card.slug }
+ var slug: String { card.slug }
+ var name: String { card.name }
+
+ enum CodingKeys: String, CodingKey {
+ case steps, ingredients, tags, source, sourceUrl, images, ratingCount
+ }
+
+ /// The detail payload is the card fields *spread* alongside the extras, so
+ /// the card decodes from the same container rather than a nested key.
+ init(from decoder: Decoder) throws {
+ card = try RecipeCard(from: decoder)
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ steps = try c.decodeIfPresent([RecipeStep].self, forKey: .steps) ?? []
+ ingredients = try c.decodeIfPresent([RecipeIngredient].self, forKey: .ingredients) ?? []
+ tags = try c.decodeIfPresent([String].self, forKey: .tags) ?? []
+ source = try c.decodeIfPresent(String.self, forKey: .source) ?? ""
+ sourceUrl = try c.decodeIfPresent(String.self, forKey: .sourceUrl) ?? ""
+ images = try c.decodeIfPresent([String].self, forKey: .images) ?? []
+ ratingCount = try c.decodeIfPresent(Int.self, forKey: .ratingCount) ?? 0
+ }
+
+ func encode(to encoder: Encoder) throws {
+ try card.encode(to: encoder)
+ }
+}
+
+nonisolated struct RecipeListResponse: Codable, Sendable {
+ let count: Int
+ let recipes: [RecipeCard]
+}
+
+// MARK: - The food week
+
+/// One meal slot on a day. The server has already resolved leftovers, swaps and
+/// logged meals into this shape — a slot showing `recipe` after `logged` is
+/// what was actually eaten, not what was planned.
+nonisolated struct FoodSlot: Codable, Sendable, Hashable, Identifiable {
+ /// breakfast | lunch | dinner | snack
+ let slot: String
+ let why: String
+ let recipe: RecipeCard?
+ /// Off-plan estimate: a label + its own macros instead of a recipe card.
+ let label: String?
+ let macros: Macros?
+ let estimated: Bool?
+ let leftover: Bool?
+ /// Planned as eating out / ordering in — no recipe, a note instead.
+ let out: Bool?
+ let order: Bool?
+ let note: String?
+ let logged: Bool
+ let logId: String?
+ /// Something other than the planned recipe was eaten here.
+ let offPlan: Bool?
+
+ var id: String { slot }
+ var isLeftover: Bool { leftover == true }
+ var isOffPlan: Bool { offPlan == true }
+
+ /// What to put on the row when there's no recipe card to show.
+ var fallbackName: String {
+ if out == true { return note?.isEmpty == false ? note! : "Eating out" }
+ if order == true { return note?.isEmpty == false ? note! : "Ordering in" }
+ return label ?? note ?? "Unplanned"
+ }
+
+ var displayName: String { recipe?.name ?? fallbackName }
+}
+
+nonisolated struct FoodExtra: Codable, Sendable, Hashable, Identifiable {
+ let id: String
+ let slot: String
+ let label: String
+ let estimated: Bool
+ let venue: String
+ let note: String
+ let macros: Macros
+
+ enum CodingKeys: String, CodingKey {
+ case id, slot, label, estimated, venue, note
+ case kcal, proteinG, carbsG, sugarG, fiberG, fatG, satfatG, sodiumMg
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ id = try c.decode(String.self, forKey: .id)
+ slot = try c.decode(String.self, forKey: .slot)
+ label = try c.decodeIfPresent(String.self, forKey: .label) ?? ""
+ estimated = try c.decodeIfPresent(Bool.self, forKey: .estimated) ?? false
+ venue = try c.decodeIfPresent(String.self, forKey: .venue) ?? ""
+ note = try c.decodeIfPresent(String.self, forKey: .note) ?? ""
+ macros = Macros(
+ kcal: try c.decodeIfPresent(Double.self, forKey: .kcal) ?? 0,
+ proteinG: try c.decodeIfPresent(Double.self, forKey: .proteinG) ?? 0,
+ carbsG: try c.decodeIfPresent(Double.self, forKey: .carbsG) ?? 0,
+ sugarG: try c.decodeIfPresent(Double.self, forKey: .sugarG) ?? 0,
+ fiberG: try c.decodeIfPresent(Double.self, forKey: .fiberG) ?? 0,
+ fatG: try c.decodeIfPresent(Double.self, forKey: .fatG) ?? 0,
+ satfatG: try c.decodeIfPresent(Double.self, forKey: .satfatG) ?? 0,
+ sodiumMg: try c.decodeIfPresent(Double.self, forKey: .sodiumMg) ?? 0)
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var c = encoder.container(keyedBy: CodingKeys.self)
+ try c.encode(id, forKey: .id)
+ try c.encode(slot, forKey: .slot)
+ try c.encode(label, forKey: .label)
+ }
+}
+
+nonisolated struct FoodDay: Codable, Sendable, Hashable, Identifiable {
+ let date: String
+ let dayName: String
+ let isToday: Bool
+ let slots: [FoodSlot]
+ let extras: [FoodExtra]
+ let totals: Macros
+
+ var id: String { date }
+
+ var dinner: FoodSlot? { slots.first { $0.slot == "dinner" } }
+ /// The thumbnail the week strip shows — dinner leads, else the first
+ /// planned thing on the day.
+ var thumbnail: RecipeCard? { dinner?.recipe ?? slots.compactMap(\.recipe).first }
+
+ enum CodingKeys: String, CodingKey { case date, dayName, isToday, slots, extras, totals }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ date = try c.decode(String.self, forKey: .date)
+ dayName = try c.decode(String.self, forKey: .dayName)
+ isToday = try c.decodeIfPresent(Bool.self, forKey: .isToday) ?? false
+ slots = try c.decodeIfPresent([FoodSlot].self, forKey: .slots) ?? []
+ extras = try c.decodeIfPresent([FoodExtra].self, forKey: .extras) ?? []
+ totals = try c.decodeIfPresent(Macros.self, forKey: .totals) ?? Macros()
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var c = encoder.container(keyedBy: CodingKeys.self)
+ try c.encode(date, forKey: .date)
+ try c.encode(dayName, forKey: .dayName)
+ }
+}
+
+nonisolated struct FoodWeek: Codable, Sendable, Hashable {
+ let start: String
+ /// The server's today — the client never computes it (`ios/CLAUDE.md`).
+ let today: String
+ let days: [FoodDay]
+ let targets: Macros
+ let rationale: String
+ let hasPlan: Bool
+
+ var todayDay: FoodDay? { days.first(where: \.isToday) }
+
+ /// "5 dinners planned" — the browse kicker. Counts real cooking, so
+ /// leftovers and eating out don't inflate it.
+ var plannedDinners: Int {
+ days.filter { $0.dinner?.recipe != nil && $0.dinner?.isLeftover != true }.count
+ }
+}
+
+// MARK: - Logging
+
+nonisolated struct LogMealResponse: Codable, Sendable {
+ let id: String
+ let duplicate: Bool
+ let totals: Macros
+}
+
+nonisolated struct PlanSlotResponse: Codable, Sendable {
+ let date: String
+ let dayName: String
+ let slot: String
+ let recipe: RecipeCard
+}
+
+nonisolated enum MealSlot: String, CaseIterable, Sendable, Identifiable {
+ case breakfast, lunch, dinner, snack
+ var id: String { rawValue }
+ var title: String { rawValue.capitalized }
+}
+
+// MARK: - Food-week proposal
+
+/// One slot in a *proposed* week. Thinner than `FoodSlot`: the proposal carries
+/// recipe slugs, not resolved cards — the cards ride alongside in
+/// `FoodProposal.recipes` so the review renders without N fetches.
+nonisolated struct FoodPropSlot: Codable, Sendable, Hashable {
+ let recipe: String?
+ let why: String?
+ let note: String?
+ /// Coach-assisted takeaway rather than something you cook.
+ let order: Bool?
+ /// Eating out. A planned night off is a plan, not a gap.
+ let out: Bool?
+ /// The weekday whose dinner this is leftovers *of*.
+ let leftoverOf: Int?
+
+ private enum CodingKeys: String, CodingKey {
+ case recipe, why, note, order, out, leftoverOf
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ recipe = try c.decodeIfPresent(String.self, forKey: .recipe)
+ why = try c.decodeIfPresent(String.self, forKey: .why)
+ note = try c.decodeIfPresent(String.self, forKey: .note)
+ order = try c.decodeIfPresent(Bool.self, forKey: .order)
+ out = try c.decodeIfPresent(Bool.self, forKey: .out)
+ // The weekday keys of `days` are strings, so the coach writes this
+ // reference back as a string about as often as an int — the PWA types
+ // it `string | number` for the same reason. Getting it wrong silently
+ // turns a leftovers night into a blank row.
+ if let n = try? c.decodeIfPresent(Int.self, forKey: .leftoverOf) {
+ leftoverOf = n
+ } else {
+ leftoverOf = (try? c.decodeIfPresent(String.self, forKey: .leftoverOf)).flatMap { $0.flatMap(Int.init) }
+ }
+ }
+}
+
+nonisolated struct FoodPropDay: Codable, Sendable, Hashable {
+ let slots: [String: FoodPropSlot]
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ slots = try c.decodeIfPresent([String: FoodPropSlot].self, forKey: .slots) ?? [:]
+ }
+
+ private enum CodingKeys: String, CodingKey { case slots }
+}
+
+nonisolated struct FoodProposalContent: Codable, Sendable, Hashable {
+ /// Keyed by weekday number as a string, "0" (Monday) … "6".
+ let days: [String: FoodPropDay]
+}
+
+/// A proposed food week awaiting approval. Same contract as a training
+/// proposal: a `changes` delta list and a rationale, so what you approve is a
+/// described change rather than a diff you have to work out.
+nonisolated struct FoodProposal: Codable, Sendable, Hashable, Identifiable {
+ let id: String
+ let num: Int
+ let rationale: String
+ let createdAt: String
+ let changes: [ProposalChange]
+ let content: FoodProposalContent
+ /// slug → card, for every recipe the week references.
+ let recipes: [String: RecipeCard]
+
+ /// Monday-first, so the review reads in the order the week is lived.
+ var orderedDays: [(weekday: Int, day: FoodPropDay)] {
+ content.days
+ .compactMap { key, value in Int(key).map { ($0, value) } }
+ .sorted { $0.0 < $1.0 }
+ .map { (weekday: $0.0, day: $0.1) }
+ }
+
+ /// How a proposed dinner reads: its name and the coach's one-liner.
+ ///
+ /// Leftovers are the case worth care — the slot names no recipe, only the
+ /// weekday it came from, so this resolves that hop. Showing "Leftovers"
+ /// with no dish is the difference between a plan and a shrug.
+ func dinner(on weekday: Int) -> (name: String, why: String) {
+ guard let slot = content.days[String(weekday)]?.slots["dinner"] else {
+ return (name: "Nothing planned", why: "")
+ }
+ // Same precedence the server resolves an active week with
+ // (`food._resolve_slot`): out, then order, then leftovers, then a dish.
+ if slot.out == true {
+ return (name: "Night out", why: slot.note ?? slot.why ?? "")
+ }
+ if slot.order == true {
+ return (name: "Order out", why: slot.note ?? slot.why ?? "")
+ }
+ if let source = slot.leftoverOf {
+ let dish = content.days[String(source)]?.slots["dinner"]?.recipe
+ .flatMap { recipes[$0]?.name }
+ return (name: dish.map { "\($0) · leftovers" } ?? "Leftovers",
+ why: slot.why ?? "zero-cook night")
+ }
+ let name = slot.recipe.flatMap { recipes[$0]?.name } ?? slot.recipe
+ return (name: name ?? "Nothing planned", why: slot.why ?? "")
+ }
+
+ /// Dinners you'd actually cook — leftovers and nights out don't count, the
+ /// same rule `FoodWeek.plannedDinners` uses.
+ var cookingNights: Int {
+ (0..<7).filter { weekday in
+ guard let slot = content.days[String(weekday)]?.slots["dinner"] else { return false }
+ return slot.recipe != nil && slot.leftoverOf == nil
+ && slot.out != true && slot.order != true
+ }.count
+ }
+}
+
+nonisolated struct FoodProposalResponse: Codable, Sendable, Equatable {
+ let proposal: FoodProposal?
+}
diff --git a/ios/Forge/Forge/Kit/ImageLoader.swift b/ios/Forge/Forge/Kit/ImageLoader.swift
new file mode 100644
index 0000000..95c54d8
--- /dev/null
+++ b/ios/Forge/Forge/Kit/ImageLoader.swift
@@ -0,0 +1,81 @@
+import SwiftUI
+
+/// Recipe imagery is behind the session cookie (`/api/food/media/{id}` is
+/// `Depends(current_user)`), and this client deliberately stores no cookies —
+/// so `AsyncImage` can't fetch it. Everything photographic in Cook goes through
+/// here instead.
+///
+/// Imports keep the original remote URL when the fetch into `media_blobs`
+/// failed, so a reference can also be absolute and off-origin. Those are
+/// fetched **without** the cookie: a third-party image host must never be sent
+/// a Forge session token.
+actor ImageCache {
+ static let shared = ImageCache()
+
+ private var entries: [String: Data] = [:]
+ private var inFlight: [String: Task] = [:]
+ /// Small on purpose — a recipe library is dozens of images, not thousands,
+ /// and the bytes are already compressed.
+ private let limit = 120
+ private var order: [String] = []
+
+ func data(for ref: String, client: APIClient) async throws -> Data {
+ if let hit = entries[ref] { return hit }
+ if let running = inFlight[ref] { return try await running.value }
+
+ let task = Task { try await client.imageData(ref) }
+ inFlight[ref] = task
+ defer { inFlight[ref] = nil }
+
+ let data = try await task.value
+ entries[ref] = data
+ order.append(ref)
+ if order.count > limit, let oldest = order.first {
+ order.removeFirst()
+ entries[oldest] = nil
+ }
+ return data
+ }
+}
+
+/// `AsyncImage` with Forge's auth and cache behind it. Falls back to the
+/// module's sunken surface — never a broken frame; on the first screen of a
+/// module the void has to read as intentional.
+struct ForgeAsyncImage: View {
+ @Environment(AuthController.self) private var auth
+
+ let ref: String?
+ @ViewBuilder var placeholder: Placeholder
+
+ @State private var image: UIImage?
+ @State private var failed = false
+
+ var body: some View {
+ Group {
+ if let image {
+ Image(uiImage: image).resizable().scaledToFill()
+ } else {
+ placeholder
+ }
+ }
+ .task(id: ref) { await load() }
+ }
+
+ private func load() async {
+ guard let ref, !ref.isEmpty, image == nil, !failed else { return }
+ do {
+ let data = try await ImageCache.shared.data(for: ref, client: auth.client)
+ image = UIImage(data: data)
+ failed = image == nil
+ } catch {
+ failed = true
+ }
+ }
+}
+
+extension ForgeAsyncImage where Placeholder == AnyView {
+ /// The common case: the module's own sunken surface while it loads.
+ init(ref: String?, fill: Color) {
+ self.init(ref: ref) { AnyView(fill) }
+ }
+}
diff --git a/ios/Forge/Forge/Kit/LiftTrend.swift b/ios/Forge/Forge/Kit/LiftTrend.swift
new file mode 100644
index 0000000..1d17c50
--- /dev/null
+++ b/ios/Forge/Forge/Kit/LiftTrend.swift
@@ -0,0 +1,95 @@
+import Foundation
+
+/// What a lift's e1RM curve is actually doing, in words.
+///
+/// The Progress board (`docs/mockups/native/redesign-d-progress.png`) labels
+/// each sparkline row with plain language — "building", "stalled 2 wk" — rather
+/// than a percentage. That's the point: a number tells you what changed, a word
+/// tells you whether to do anything about it. This derives the word.
+nonisolated enum LiftTrend: Equatable {
+ case building(gain: Double)
+ /// No meaningful movement for this many weeks.
+ case stalled(weeks: Int)
+ case easing(drop: Double)
+ /// Fewer than two sessions — nothing to say yet, and saying "stalled"
+ /// about a lift you've done once would be a lie.
+ case tooEarly
+
+ /// Movement smaller than this is noise, not a trend: e1RM is derived from
+ /// reps at a load, so a rep more or less on the same weight moves it a
+ /// couple of kilos on its own.
+ static let noiseKg = 1.5
+
+ var words: String {
+ switch self {
+ case .building: "building"
+ case .stalled(let weeks): weeks >= 2 ? "stalled \(weeks) wk" : "holding"
+ case .easing: "easing off"
+ case .tooEarly: "new"
+ }
+ }
+
+ /// Only real movement earns the accent; a stall is stated, not celebrated.
+ var isPositive: Bool {
+ if case .building = self { return true }
+ return false
+ }
+}
+
+nonisolated extension LiftSeries {
+ /// Latest e1RM in kg.
+ var latest: Double { points.last?.v ?? 0 }
+
+ /// Change over roughly the requested window, and the window actually
+ /// covered — a lift with six weeks of history must not report "in 12 wk".
+ ///
+ /// The baseline is "where you were then": the most recent point at or
+ /// before the cutoff, falling back to the oldest on record when the whole
+ /// history is newer than the window.
+ func change(overWeeks weeks: Int) -> (delta: Double, weeks: Int)? {
+ guard let last = points.last, points.count > 1 else { return nil }
+ guard let end = ForgeDate.date(from: last.d) else { return nil }
+ let cutoff = end.addingTimeInterval(-Double(weeks) * 7 * 86400)
+ let baseline = points.last { ForgeDate.date(from: $0.d).map { $0 <= cutoff } ?? false }
+ ?? points[0]
+ guard let start = ForgeDate.date(from: baseline.d), start < end else { return nil }
+ let span = max(1, Int((end.timeIntervalSince(start) / (7 * 86400)).rounded()))
+ return (last.v - baseline.v, span)
+ }
+
+ /// The window the word describes. Four weeks: long enough that two sessions
+ /// at the same load don't read as a plateau, short enough that a stall
+ /// surfaces while you can still do something about it.
+ static let trendWindowWeeks = 4
+
+ var trend: LiftTrend {
+ guard points.count > 1, let last = points.last,
+ let end = ForgeDate.date(from: last.d)
+ else { return .tooEarly }
+
+ // Compared against where you were, NOT against the previous session:
+ // running the same load twice in a week is normal programming, and
+ // last-vs-previous called a steadily climbing lift "holding".
+ if let change = change(overWeeks: Self.trendWindowWeeks),
+ change.delta > LiftTrend.noiseKg {
+ return .building(gain: change.delta)
+ }
+
+ // Off a recent high is easing even when the window's net change is
+ // flat — you were at 108 a fortnight ago and you're at 101 now.
+ let cutoff = end.addingTimeInterval(-Double(Self.trendWindowWeeks) * 7 * 86400)
+ let inWindow = points.filter { ForgeDate.date(from: $0.d).map { $0 >= cutoff } ?? false }
+ let peak = inWindow.map(\.v).max() ?? last.v
+ if last.v < peak - LiftTrend.noiseKg { return .easing(drop: peak - last.v) }
+
+ // Flat: how long for? Walk back while every point sits inside the noise
+ // band around the current value.
+ var oldestFlat = end
+ for point in points.dropLast().reversed() {
+ guard abs(point.v - last.v) <= LiftTrend.noiseKg,
+ let date = ForgeDate.date(from: point.d) else { break }
+ oldestFlat = date
+ }
+ return .stalled(weeks: Int((end.timeIntervalSince(oldestFlat) / (7 * 86400)).rounded()))
+ }
+}
diff --git a/ios/Forge/Forge/Kit/MetricDelta.swift b/ios/Forge/Forge/Kit/MetricDelta.swift
new file mode 100644
index 0000000..950db07
--- /dev/null
+++ b/ios/Forge/Forge/Kit/MetricDelta.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+/// The change shown on a Progress metric card: whether it's worth showing at
+/// all, and whether it's movement in the direction you want.
+///
+/// Both halves went wrong on the first build. Resting HR prints whole bpm, so a
+/// fixed 0.05 significance threshold rendered "▼ 0" — an arrow pointing at no
+/// change. And every delta was painted volt, which by the design system means
+/// positive status, so a worse night's sleep read as good news.
+nonisolated enum MetricDelta {
+ /// Which way is good. Most metrics have no universal answer — bodyweight
+ /// and body water depend entirely on what you're training for — so
+ /// `.either` is the default and those render neutral rather than claiming
+ /// a goal the app hasn't been told.
+ enum Better {
+ case up, down, either
+
+ func isGood(_ delta: Double) -> Bool {
+ switch self {
+ case .up: delta > 0
+ case .down: delta < 0
+ case .either: false
+ }
+ }
+ }
+
+ /// "▲ 1.4", or nil when there's nothing honest to say.
+ ///
+ /// Significance is whatever the formatter can actually show: if the change
+ /// renders identically to zero, the arrow is claiming a movement the number
+ /// beside it doesn't corroborate.
+ static func label(_ delta: Double, format: (Double) -> String) -> String? {
+ let shown = format(abs(delta))
+ guard shown != format(0) else { return nil }
+ return "\(delta > 0 ? "▲" : "▼") \(shown)"
+ }
+}
diff --git a/ios/Forge/Forge/Kit/PlateMath.swift b/ios/Forge/Forge/Kit/PlateMath.swift
index c284a7c..0f0e713 100644
--- a/ios/Forge/Forge/Kit/PlateMath.swift
+++ b/ios/Forge/Forge/Kit/PlateMath.swift
@@ -26,25 +26,52 @@ enum PlateMath {
}
guard kind == "bb", let profile, !profile.platesKg.isEmpty else { return "" }
- var perSide = (weight - profile.barKg) / 2
- if perSide < 0.01 {
+ let fill = perSide(weight, profile: profile)
+ if fill.plates.isEmpty && fill.remainder < 0.01 {
return "Empty bar (\(Units.trim(profile.barKg)) kg)"
}
+ // The "kg" is deliberate and diverges from the web's `plateStr`, which
+ // omits it: with `unit_load` defaulting to lb, "Per side: 5 + 2.5"
+ // sitting under "77.2 lb" reads as pounds. Plates are always kg, so say so.
+ var text = "Per side: " + fill.plates.map(Units.trim).joined(separator: " + ") + " kg"
+ if fill.remainder > 0.01 {
+ text += String(format: " — no plate for %.2f kg", fill.remainder)
+ }
+ return text
+ }
+
+ /// Greedy per-side fill, heaviest plate first — what you actually load, and
+ /// the shared arithmetic behind both plate strings.
+ static func perSide(_ weight: Double, profile: EquipmentProfile) -> (plates: [Double], remainder: Double) {
+ var remaining = (weight - profile.barKg) / 2
+ guard remaining >= 0.01 else { return ([], max(0, remaining)) }
var used: [Double] = []
for plate in profile.platesKg.sorted(by: >) {
- while perSide >= plate - 1e-9 {
+ while remaining >= plate - 1e-9 {
used.append(plate)
- perSide -= plate
+ remaining -= plate
}
}
- // The "kg" is deliberate and diverges from the web's `plateStr`, which
- // omits it: with `unit_load` defaulting to lb, "Per side: 5 + 2.5"
- // sitting under "77.2 lb" reads as pounds. Plates are always kg, so say so.
- var text = "Per side: " + used.map(Units.trim).joined(separator: " + ") + " kg"
- if perSide > 0.01 {
- text += String(format: " — no plate for %.2f kg", perSide)
+ return (used, remaining)
+ }
+
+ /// The logging screen's line under the big numeral, in the board's voice:
+ /// "per side 25 · 10 · 2.5 · 1.25 on the 20 kg bar". Recomputed live, so
+ /// stepping the load re-plates the bar instead of leaving the server's
+ /// answer for the prescription sitting under a number you've changed.
+ static func perSideLine(_ weight: Double, profile: EquipmentProfile) -> String {
+ let fill = perSide(weight, profile: profile)
+ let bar = "on the \(Units.trim(profile.barKg)) kg bar"
+ guard !fill.plates.isEmpty else {
+ return fill.remainder > 0.01
+ ? String(format: "%@ — %.2f kg per side won't plate", bar, fill.remainder)
+ : "empty \(Units.trim(profile.barKg)) kg bar"
}
- return text
+ var line = "per side " + fill.plates.map(Units.trim).joined(separator: " · ") + " \(bar)"
+ if fill.remainder > 0.01 {
+ line += String(format: " — %.2f kg short", fill.remainder)
+ }
+ return line
}
private static func matches(_ slug: String, _ pattern: String) -> Bool {
diff --git a/ios/Forge/Forge/Kit/TrainingModels.swift b/ios/Forge/Forge/Kit/TrainingModels.swift
index 56aac5e..42e6d8f 100644
--- a/ios/Forge/Forge/Kit/TrainingModels.swift
+++ b/ios/Forge/Forge/Kit/TrainingModels.swift
@@ -47,6 +47,33 @@ nonisolated struct TodayExercise: Codable, Sendable, Equatable, Identifiable {
var isMainLift: Bool { priority == 1 }
}
+/// `/api/exercises/{slug}` — the library entry behind a plan line. Fetched
+/// lazily by the logging screen for its form card; everything else the screen
+/// needs already rides on `/api/today`.
+nonisolated struct ExerciseInfo: Codable, Sendable, Equatable, Identifiable {
+ let slug: String
+ let name: String
+ let kind: String
+ let primaryMuscles: [String]
+ let secondaryMuscles: [String]
+ let equipment: [String]
+ let cues: [String]
+ /// The one thing not to do — a single sentence, not a list, despite
+ /// sitting next to `cues` which is one.
+ let dont: String
+ let benefit: String
+ /// "images" | "none" — a *string*, not a count. Typing it `Int` failed the
+ /// whole decode silently and the form card just never appeared.
+ let mediaTier: String?
+ /// Comma-separated paths; `ExerciseMedia` derives the same paths from the
+ /// slug, so this is only here to say whether any exist.
+ let mediaUrl: String?
+
+ var hasMedia: Bool { !(mediaUrl ?? "").isEmpty }
+
+ var id: String { slug }
+}
+
nonisolated struct CooldownItem: Codable, Sendable, Equatable, Identifiable {
let slug: String
let name: String?
@@ -222,29 +249,43 @@ nonisolated struct CompletePayload: Codable, Sendable, Equatable {
}
}
-/// `GET /api/sessions/{id}` — used to rehydrate a resumed workout. Only the
-/// logged sets are read here; the run-detail fields (series, zones) arrive with
-/// History in a later slice.
+/// `GET /api/sessions/{id}` — rehydrates a resumed workout and backs the
+/// history detail screen. Cardio sessions additionally carry `series` (HR and
+/// GPS, downsampled at ingest) and a five-zone breakdown.
nonisolated struct SessionDetail: Codable, Sendable, Equatable {
- nonisolated struct Exercise: Codable, Sendable, Equatable {
+ nonisolated struct Exercise: Codable, Sendable, Equatable, Identifiable {
let slug: String
let name: String?
let substitutedFor: String?
let sets: [LoggedSetRow]
+
+ var id: String { slug }
}
- nonisolated struct LoggedSetRow: Codable, Sendable, Equatable {
+ nonisolated struct LoggedSetRow: Codable, Sendable, Equatable, Identifiable {
let setNo: Int
let weight: Double
let reps: Int
let rpe: Int?
+
+ var id: Int { setNo }
}
let id: String
let day: String
let name: String
+ let kind: String
let status: String
+ let stats: SessionStats
+ let notes: String?
+ let favorite: Bool?
+ let cooldownStatus: String?
let exercises: [Exercise]
+ let series: WorkoutSeries?
+ let zones: ZoneBreakdown?
+
+ var isFavorite: Bool { favorite ?? false }
+ var isCardio: Bool { kind == "cardio" }
}
nonisolated struct CompleteResponse: Codable, Sendable, Equatable {
diff --git a/ios/Forge/ForgeTests/CookCueTests.swift b/ios/Forge/ForgeTests/CookCueTests.swift
new file mode 100644
index 0000000..9a97c8b
--- /dev/null
+++ b/ios/Forge/ForgeTests/CookCueTests.swift
@@ -0,0 +1,57 @@
+import Testing
+@testable import Forge
+
+/// Cook mode accents the done-when cue and nothing else. These cases are real
+/// step prose from `server/app/food_seed.py` plus the board's own example —
+/// if the extraction drifts, the ember lands on the wrong half of the sentence.
+struct CookCueTests {
+ @Test func liftsTheCueOutOfASentence() {
+ let detail = "Hot pan, skin-side down, don't touch it. Done when the skin releases without a fight."
+ let parts = CookCue.split(detail)
+ #expect(parts.cue == "Done when the skin releases without a fight")
+ #expect(parts.before == "Hot pan, skin-side down, don't touch it. ")
+ #expect(parts.after == ".")
+ }
+
+ /// The board's step: the cue stops at the em dash, because "about 4
+ /// minutes" is timing, not the thing you look for.
+ @Test func stopsAtTheEmDashSoTimingIsNotAccented() {
+ let detail = "Lay the salmon skin-side down and leave it alone. It's ready when the skin releases with no tug — about 4 minutes."
+ let parts = CookCue.split(detail)
+ #expect(parts.cue == "It's ready when the skin releases with no tug")
+ #expect(parts.after == " — about 4 minutes.")
+ }
+
+ @Test func handlesUntilPhrasing() {
+ let parts = CookCue.split("Toss and cook until the edges catch and darken.")
+ #expect(parts.cue == "Toss and cook until the edges catch and darken")
+ }
+
+ /// Prep steps genuinely have no cue — inventing a highlight for them would
+ /// make the accent meaningless.
+ @Test func noCueMeansNoHighlight() {
+ let parts = CookCue.split("Oven to 220° fan. Peppers into strips, onions into wedges.")
+ #expect(parts.cue.isEmpty)
+ #expect(parts.before == "Oven to 220° fan. Peppers into strips, onions into wedges.")
+ #expect(parts.after.isEmpty)
+ }
+
+ @Test func splitAlwaysReassemblesTheOriginal() {
+ let details = [
+ "Done when thighs read 74° / juices run clear and the chickpeas just blister. No turning.",
+ "Bright green and just tender — a knife should meet slight resistance.",
+ "Warm through, season, lemon juice in.",
+ "",
+ ]
+ for detail in details {
+ let parts = CookCue.split(detail)
+ #expect(parts.before + parts.cue + parts.after == detail)
+ }
+ }
+
+ /// Two cues in one step: the first is the one you're waiting on now.
+ @Test func earliestCueWins() {
+ let parts = CookCue.split("Sear until golden. Done when it flakes.")
+ #expect(parts.cue == "Sear until golden")
+ }
+}
diff --git a/ios/Forge/ForgeTests/CookTimerTests.swift b/ios/Forge/ForgeTests/CookTimerTests.swift
new file mode 100644
index 0000000..7ad985e
--- /dev/null
+++ b/ios/Forge/ForgeTests/CookTimerTests.swift
@@ -0,0 +1,67 @@
+import Foundation
+import Testing
+@testable import Forge
+
+/// Cook timers store an absolute end date, not a countdown — cook mode is
+/// backgrounded constantly and a decrementing counter would drift every time
+/// the view stopped ticking. These pin that behaviour.
+@MainActor
+struct CookTimerTests {
+ @Test func remainingIsDerivedFromTheEndDate() {
+ let timers = CookTimers()
+ let t0 = Date(timeIntervalSince1970: 1_000_000)
+ timers.start(step: 0, seconds: 240, now: t0)
+
+ // Simulating a backgrounded app: no ticks happened, but 90 s passed.
+ timers.settle(at: t0.addingTimeInterval(90))
+ #expect(timers.remaining(step: 0) == 150)
+ }
+
+ @Test func chimesOnceAsItCrossesZero() {
+ let timers = CookTimers()
+ let t0 = Date(timeIntervalSince1970: 2_000_000)
+ timers.start(step: 2, seconds: 60, now: t0)
+
+ #expect(timers.settle(at: t0.addingTimeInterval(59)).isEmpty)
+ #expect(timers.settle(at: t0.addingTimeInterval(61)) == [2])
+ // Still zero, but already chimed — a second banner would be noise.
+ #expect(timers.settle(at: t0.addingTimeInterval(120)).isEmpty)
+ }
+
+ @Test func remainingNeverGoesNegative() {
+ let timers = CookTimers()
+ let t0 = Date(timeIntervalSince1970: 3_000_000)
+ timers.start(step: 0, seconds: 30, now: t0)
+ timers.settle(at: t0.addingTimeInterval(600))
+ #expect(timers.remaining(step: 0) == 0)
+ #expect(timers.fraction(step: 0) == 0)
+ }
+
+ /// Several timers run at once — that's the point of a background step.
+ @Test func timersOnOtherStepsKeepRunning() {
+ let timers = CookTimers()
+ let t0 = Date(timeIntervalSince1970: 4_000_000)
+ timers.start(step: 0, seconds: 600, now: t0) // a braise
+ timers.start(step: 1, seconds: 120, now: t0) // something quick
+
+ let fired = timers.settle(at: t0.addingTimeInterval(180))
+ #expect(fired == [1])
+ #expect(timers.remaining(step: 0) == 420)
+ #expect(timers.running.map(\.index) == [0, 1])
+ }
+
+ @Test func cancellingClearsOnlyThatStep() {
+ let timers = CookTimers()
+ let t0 = Date(timeIntervalSince1970: 5_000_000)
+ timers.start(step: 0, seconds: 120, now: t0)
+ timers.start(step: 3, seconds: 120, now: t0)
+ timers.cancel(step: 0)
+ #expect(timers.remaining(step: 0) == nil)
+ #expect(timers.remaining(step: 3, at: t0) == 120)
+ }
+
+ @Test(arguments: [(0, "0:00"), (9, "0:09"), (60, "1:00"), (221, "3:41"), (3600, "60:00")])
+ func clockFormat(seconds: Int, expected: String) {
+ #expect(fmtClock(seconds) == expected)
+ }
+}
diff --git a/ios/Forge/ForgeTests/ExerciseInfoTests.swift b/ios/Forge/ForgeTests/ExerciseInfoTests.swift
new file mode 100644
index 0000000..d4396e4
--- /dev/null
+++ b/ios/Forge/ForgeTests/ExerciseInfoTests.swift
@@ -0,0 +1,87 @@
+import Foundation
+import Testing
+@testable import Forge
+
+/// The logging screen fetches the library entry with `try?`, so a decode
+/// mismatch doesn't crash — the form card just silently never appears. That has
+/// now bitten twice (`media_tier` is a string, `dont` is a sentence next to a
+/// `cues` *list*), so the real payload gets a test.
+struct ExerciseInfoTests {
+ private func decoder() -> JSONDecoder {
+ let d = JSONDecoder()
+ d.keyDecodingStrategy = .convertFromSnakeCase
+ return d
+ }
+
+ /// Copied field-for-field from `GET /api/exercises/back-squat`.
+ @Test func decodesTheRealExercisePayload() throws {
+ let json = """
+ {"slug":"back-squat","name":"Back Squat","kind":"bb",
+ "primary_muscles":["Quads","Glutes"],"secondary_muscles":["Core"],
+ "equipment":["Barbell + power rack"],
+ "cues":["Bar over mid-foot, brace before you bend","Knees track the toes"],
+ "dont":"Don't let the hips shoot up first",
+ "patterns":["squat"],
+ "benefit":"The biggest bang-for-buck lower-body lift.",
+ "media_tier":"images",
+ "media_url":"/media/exercises/back-squat-0.jpg,/media/exercises/back-squat-1.jpg"}
+ """.data(using: .utf8)!
+ let info = try decoder().decode(ExerciseInfo.self, from: json)
+ #expect(info.cues.count == 2)
+ #expect(info.dont.hasPrefix("Don't let"))
+ #expect(info.mediaTier == "images")
+ #expect(info.hasMedia)
+ }
+
+ /// An exercise with no photos must report so, or the form card reserves
+ /// space for an image that never arrives.
+ @Test func noMediaIsReported() throws {
+ let json = """
+ {"slug":"plank","name":"Plank","kind":"bw","primary_muscles":["Abs"],
+ "secondary_muscles":[],"equipment":[],"cues":["Ribs down"],"dont":"Don't sag",
+ "benefit":"","media_tier":"none","media_url":""}
+ """.data(using: .utf8)!
+ let info = try decoder().decode(ExerciseInfo.self, from: json)
+ #expect(!info.hasMedia)
+ }
+}
+
+/// The logging screen re-plates the bar live as you step the load, so the line
+/// under the big numeral has to be right for loads the plan never prescribed.
+struct PlateLineTests {
+ private let gym = EquipmentProfile(name: "Gym", barKg: 20,
+ platesKg: [25, 20, 15, 10, 5, 2.5, 1.25])
+
+ @Test func namesTheBarAndSeparatesPlates() {
+ // 132.5 kg = 20 bar + 56.25 a side.
+ #expect(PlateMath.perSideLine(132.5, profile: gym)
+ == "per side 25 · 25 · 5 · 1.25 on the 20 kg bar")
+ }
+
+ @Test func emptyBarSaysSo() {
+ #expect(PlateMath.perSideLine(20, profile: gym) == "empty 20 kg bar")
+ }
+
+ /// A load the plates can't make is the normal result of stepping in lb —
+ /// saying nothing would leave you guessing at the rack.
+ @Test func reportsWhatWontPlate() {
+ // 61 kg = 20 bar + 20.5 a side, and nothing makes the last 0.5.
+ let line = PlateMath.perSideLine(61, profile: gym)
+ #expect(line.hasPrefix("per side 20 on the 20 kg bar"))
+ #expect(line.contains("0.50 kg short"))
+ }
+
+ /// `description` and `perSideLine` must never disagree about what's on the
+ /// bar — they're the same arithmetic in two voices.
+ @Test(arguments: [60.0, 82.5, 100.0, 142.5])
+ func bothVoicesAgree(weight: Double) {
+ let fill = PlateMath.perSide(weight, profile: gym)
+ let long = PlateMath.description(kind: "bb", weight: weight, profile: gym)
+ for plate in Set(fill.plates) {
+ #expect(long.contains(Units.trim(plate)))
+ #expect(PlateMath.perSideLine(weight, profile: gym).contains(Units.trim(plate)))
+ }
+ // Greedy fill: heaviest first, never ascending.
+ #expect(fill.plates == fill.plates.sorted(by: >))
+ }
+}
diff --git a/ios/Forge/ForgeTests/FoodModelTests.swift b/ios/Forge/ForgeTests/FoodModelTests.swift
new file mode 100644
index 0000000..29113b9
--- /dev/null
+++ b/ios/Forge/ForgeTests/FoodModelTests.swift
@@ -0,0 +1,133 @@
+import Foundation
+import Testing
+@testable import Forge
+
+/// The food payloads spread macros as sibling keys rather than nesting them,
+/// and the detail endpoint spreads the whole recipe card alongside its extras.
+/// Both are easy to break silently, so decode the server's actual shapes.
+struct FoodModelTests {
+ private func decoder() -> JSONDecoder {
+ let d = JSONDecoder()
+ d.keyDecodingStrategy = .convertFromSnakeCase
+ return d
+ }
+
+ /// `food.recipe_card` — macros are siblings of `slug`/`name`, not a nested
+ /// object, because the server spreads `MACRO_FIELDS` into the dict.
+ @Test func recipeCardLiftsSpreadMacros() throws {
+ let json = """
+ {"slug":"salmon-puy-lentils","name":"Charred salmon, puy lentils & broccoli",
+ "kind":"dinner","minutes":20,"difficulty":"easy","serves":2,"batch":0,
+ "platefig":"plate-salmon","why":"The omega-3 day.","image":"/api/food/media/abc",
+ "rating":4.5,"kcal":540,"protein_g":38,"carbs_g":30,"sugar_g":3,"fiber_g":9,
+ "fat_g":24,"satfat_g":3.5,"sodium_mg":480}
+ """.data(using: .utf8)!
+ let card = try decoder().decode(RecipeCard.self, from: json)
+ #expect(card.slug == "salmon-puy-lentils")
+ #expect(card.macros.kcal == 540)
+ #expect(card.macros.proteinG == 38)
+ #expect(card.macros.sodiumMg == 480)
+ #expect(card.meta == "20 min · 38 g protein")
+ // `complete` is absent on the card shape — that must not read as parked.
+ #expect(!card.isParked)
+ }
+
+ @Test func recipeDetailDecodesCardFieldsAndSteps() throws {
+ let json = """
+ {"slug":"harissa-chicken","name":"Harissa chicken tray bake","kind":"dinner",
+ "minutes":30,"difficulty":"easy","serves":2,"batch":2,"platefig":"tray",
+ "why":"","image":null,"rating":0,"kcal":520,"protein_g":44,"carbs_g":38,
+ "sugar_g":9,"fiber_g":11,"fat_g":18,"satfat_g":4,"sodium_mg":600,
+ "steps":[{"title":"Prep","minutes":5,"detail":"Oven to 220° fan."},
+ {"title":"Roast","minutes":22,"timer":true,"detail":"Done when thighs read 74°."}],
+ "ingredients":[{"name":"chicken thighs","qty":6,"unit":"x","disp":"6",
+ "aisle":"protein","pantry":false},
+ {"name":"cumin","qty":4,"unit":"g","disp":"2 tsp","note":"pantry",
+ "aisle":"cupboard","pantry":true}],
+ "tags":["batch"],"source":"seed","source_url":"","images":[],
+ "rating":0,"rating_count":0}
+ """.data(using: .utf8)!
+ let detail = try decoder().decode(RecipeDetail.self, from: json)
+ #expect(detail.name == "Harissa chicken tray bake")
+ #expect(detail.card.macros.proteinG == 44)
+ #expect(detail.steps.count == 2)
+ #expect(detail.steps[0].hasTimer == false)
+ #expect(detail.steps[1].hasTimer)
+ #expect(detail.steps[1].seconds == 22 * 60)
+ #expect(detail.ingredients[0].quantity == "6")
+ #expect(detail.ingredients[1].isPantry)
+ }
+
+ /// A step marked `timer` with no minutes must not produce a zero-length
+ /// ring that completes the instant it starts.
+ @Test func timerStepsNeedMinutes() throws {
+ let json = """
+ {"title":"Rest","timer":true,"detail":"Leave it."}
+ """.data(using: .utf8)!
+ let step = try decoder().decode(RecipeStep.self, from: json)
+ #expect(!step.hasTimer)
+ #expect(step.seconds == 60)
+ }
+
+ @Test func foodWeekCountsRealCookingOnly() throws {
+ let json = """
+ {"start":"2026-07-20","today":"2026-07-25","has_plan":true,"rationale":"",
+ "targets":{"kcal":2300,"protein_g":160,"carbs_g":250,"sugar_g":65,"fiber_g":38,
+ "fat_g":80,"satfat_g":18,"sodium_mg":2300},
+ "days":[
+ {"date":"2026-07-20","day_name":"Monday","is_today":false,
+ "slots":[{"slot":"dinner","why":"","logged":false,"log_id":null,
+ "recipe":{"slug":"a","name":"Thai beef bowls","kind":"dinner","minutes":30,
+ "difficulty":"easy","serves":2,"batch":0,"platefig":"bowl","why":"",
+ "image":null,"rating":0,"kcal":500,"protein_g":46,"carbs_g":40,
+ "sugar_g":8,"fiber_g":7,"fat_g":16,"satfat_g":4,"sodium_mg":500}}],
+ "extras":[],"totals":{"kcal":0,"protein_g":0,"carbs_g":0,"sugar_g":0,"fiber_g":0,
+ "fat_g":0,"satfat_g":0,"sodium_mg":0}},
+ {"date":"2026-07-21","day_name":"Tuesday","is_today":false,
+ "slots":[{"slot":"dinner","why":"","leftover":true,"logged":false,"log_id":null,
+ "recipe":{"slug":"a","name":"Thai beef bowls","kind":"dinner","minutes":30,
+ "difficulty":"easy","serves":2,"batch":0,"platefig":"bowl","why":"",
+ "image":null,"rating":0,"kcal":500,"protein_g":46,"carbs_g":40,
+ "sugar_g":8,"fiber_g":7,"fat_g":16,"satfat_g":4,"sodium_mg":500}}],
+ "extras":[],"totals":{"kcal":0,"protein_g":0,"carbs_g":0,"sugar_g":0,"fiber_g":0,
+ "fat_g":0,"satfat_g":0,"sodium_mg":0}},
+ {"date":"2026-07-25","day_name":"Saturday","is_today":true,
+ "slots":[{"slot":"dinner","why":"","out":true,"note":"Pizza with the Coles",
+ "logged":false,"log_id":null}],
+ "extras":[],"totals":{"kcal":0,"protein_g":0,"carbs_g":0,"sugar_g":0,"fiber_g":0,
+ "fat_g":0,"satfat_g":0,"sodium_mg":0}}]}
+ """.data(using: .utf8)!
+ let week = try decoder().decode(FoodWeek.self, from: json)
+ // Monday cooks; Tuesday is leftovers of it; Saturday is eating out.
+ #expect(week.plannedDinners == 1)
+ #expect(week.todayDay?.date == "2026-07-25")
+ #expect(week.todayDay?.dinner?.displayName == "Pizza with the Coles")
+ #expect(week.days[1].dinner?.isLeftover == true)
+ #expect(week.targets.proteinG == 160)
+ }
+
+ /// Missing macro keys must degrade to zero, not throw — one absent field
+ /// would otherwise take down the whole week rather than a single number.
+ @Test func partialMacrosDecodeToZero() throws {
+ let json = #"{"kcal":410,"protein_g":31}"#.data(using: .utf8)!
+ let macros = try decoder().decode(Macros.self, from: json)
+ #expect(macros.kcal == 410)
+ #expect(macros.proteinG == 31)
+ #expect(macros.fiberG == 0)
+ #expect(macros.sodiumMg == 0)
+ }
+
+ @Test func offPlanSlotShowsWhatWasActuallyEaten() throws {
+ let json = """
+ {"slot":"lunch","why":"","off_plan":true,"label":"Pret chicken salad",
+ "estimated":true,"logged":true,"log_id":"lg1",
+ "macros":{"kcal":430,"protein_g":34,"carbs_g":22,"sugar_g":5,"fiber_g":6,
+ "fat_g":21,"satfat_g":4,"sodium_mg":700}}
+ """.data(using: .utf8)!
+ let slot = try decoder().decode(FoodSlot.self, from: json)
+ #expect(slot.isOffPlan)
+ #expect(slot.displayName == "Pret chicken salad")
+ #expect(slot.logged)
+ #expect(slot.macros?.kcal == 430)
+ }
+}
diff --git a/ios/Forge/ForgeTests/FoodProposalTests.swift b/ios/Forge/ForgeTests/FoodProposalTests.swift
new file mode 100644
index 0000000..70a6061
--- /dev/null
+++ b/ios/Forge/ForgeTests/FoodProposalTests.swift
@@ -0,0 +1,131 @@
+import Foundation
+import Testing
+@testable import Forge
+
+/// A food-week proposal is the coaching loop for Cook — the one screen where
+/// you commit a week of cooking for two people. Reading it wrong is worse than
+/// not showing it, so the awkward bits of the payload have tests: leftovers
+/// point at another day rather than naming a dish, and the weekday they point
+/// at arrives as an int or a string depending on how the coach wrote it.
+struct FoodProposalTests {
+ private func decode(_ json: String) throws -> FoodProposal {
+ let d = JSONDecoder()
+ d.keyDecodingStrategy = .convertFromSnakeCase
+ let response = try d.decode(FoodProposalResponse.self, from: Data(json.utf8))
+ return try #require(response.proposal)
+ }
+
+ /// A trimmed copy of the real `/api/food/proposal` shape: recipes ride
+ /// alongside as a slug→card map, days are keyed by weekday string.
+ private let payload = """
+ {"proposal": {
+ "id": "rev-7", "num": 12, "rationale": "Two batch nights so Thursday cooks itself.",
+ "created_at": "2026-07-26T20:00:00",
+ "changes": [
+ {"sign": "+", "what": "Harissa chicken traybake", "why": "one tray, 35 min"},
+ {"sign": "-", "what": "Friday stir-fry", "why": "you skipped it twice"},
+ {"sign": "~", "what": "Lunch protein up 12 g"}
+ ],
+ "content": {"days": {
+ "0": {"slots": {"dinner": {"recipe": "harissa-chicken", "why": "one tray"}}},
+ "1": {"slots": {"dinner": {"leftover_of": 0, "why": "zero-cook night"}}},
+ "2": {"slots": {"dinner": {"recipe": "turkey-black-bean-chili"}}},
+ "3": {"slots": {"dinner": {"leftover_of": "2"}}},
+ "4": {"slots": {"dinner": {"out": true, "note": "Shelby's birthday"}}},
+ "5": {"slots": {"dinner": {"recipe": "soba-salmon", "why": "fast after the long run"}}},
+ "6": {"slots": {}}
+ }},
+ "recipes": {
+ "harissa-chicken": {"slug": "harissa-chicken", "name": "Harissa chicken traybake",
+ "kind": "dinner", "minutes": 35, "kcal": 520, "protein_g": 42, "carbs_g": 48,
+ "fat_g": 13, "satfat_g": 3, "fiber_g": 9},
+ "turkey-black-bean-chili": {"slug": "turkey-black-bean-chili", "name": "Turkey black bean chili",
+ "kind": "dinner", "minutes": 45, "kcal": 610, "protein_g": 48, "carbs_g": 52,
+ "fat_g": 18, "satfat_g": 4, "fiber_g": 14},
+ "soba-salmon": {"slug": "soba-salmon", "name": "Soba salmon bowl",
+ "kind": "dinner", "minutes": 25, "kcal": 580, "protein_g": 40, "carbs_g": 55,
+ "fat_g": 19, "satfat_g": 3, "fiber_g": 7}
+ }}}
+ """
+
+ @Test func decodesTheRealProposalPayload() throws {
+ let p = try decode(payload)
+ #expect(p.id == "rev-7")
+ #expect(p.num == 12)
+ #expect(p.changes.count == 3)
+ #expect(p.recipes["soba-salmon"]?.name == "Soba salmon bowl")
+ #expect(p.orderedDays.map(\.weekday) == [0, 1, 2, 3, 4, 5, 6])
+ }
+
+ /// The whole point of resolving the hop: "Leftovers" with no dish tells you
+ /// nothing about whether Tuesday is a good night.
+ @Test func leftoversNameTheDishTheyCameFrom() throws {
+ let p = try decode(payload)
+ #expect(p.dinner(on: 1).name == "Harissa chicken traybake · leftovers")
+ #expect(p.dinner(on: 1).why == "zero-cook night")
+ }
+
+ /// The weekday reference arrives as an int on day 1 and a string on day 3 —
+ /// both are real, because `days` itself is keyed by strings.
+ @Test func aLeftoverReferenceDecodesFromEitherIntOrString() throws {
+ let p = try decode(payload)
+ #expect(p.dinner(on: 3).name == "Turkey black bean chili · leftovers")
+ }
+
+ /// A leftovers slot with no `why` still gets a caption — the coach's default
+ /// reason for one is always the same.
+ @Test func leftoversFallBackToTheirOwnReason() throws {
+ let p = try decode(payload)
+ #expect(p.dinner(on: 3).why == "zero-cook night")
+ }
+
+ @Test func aNightOutIsAPlanNotAGap() throws {
+ let p = try decode(payload)
+ #expect(p.dinner(on: 4).name == "Night out")
+ #expect(p.dinner(on: 4).why == "Shelby's birthday")
+ }
+
+ @Test func anEmptyDayReadsAsNothingPlanned() throws {
+ let p = try decode(payload)
+ #expect(p.dinner(on: 6).name == "Nothing planned")
+ }
+
+ /// The number the header leads with. Leftovers and the night out are not
+ /// cooking — counting them would sell the week as busier than it is.
+ @Test func onlyRealCookingCountsAsANightCooking() throws {
+ let p = try decode(payload)
+ #expect(p.cookingNights == 3)
+ }
+
+ /// Coach-assisted takeaway resolves before the recipe slug, matching the
+ /// order `food._resolve_slot` uses server-side.
+ @Test func orderOutIsItsOwnKindOfNight() throws {
+ let p = try decode("""
+ {"proposal": {"id": "r", "num": 1, "rationale": "", "created_at": "2026-07-26T20:00:00",
+ "changes": [], "recipes": {},
+ "content": {"days": {"0": {"slots": {"dinner": {"order": true, "note": "high-protein Thai"}}}}}}}
+ """)
+ #expect(p.dinner(on: 0).name == "Order out")
+ #expect(p.dinner(on: 0).why == "high-protein Thai")
+ #expect(p.cookingNights == 0)
+ }
+
+ /// A recipe the map somehow doesn't carry falls back to its slug rather
+ /// than rendering a blank row.
+ @Test func anUnresolvedRecipeStillShowsSomething() throws {
+ let p = try decode("""
+ {"proposal": {"id": "r", "num": 1, "rationale": "", "created_at": "2026-07-26T20:00:00",
+ "changes": [], "recipes": {},
+ "content": {"days": {"0": {"slots": {"dinner": {"recipe": "mystery-stew"}}}}}}}
+ """)
+ #expect(p.dinner(on: 0).name == "mystery-stew")
+ }
+
+ @Test func noProposalIsAValidAnswer() throws {
+ let d = JSONDecoder()
+ d.keyDecodingStrategy = .convertFromSnakeCase
+ let response = try d.decode(FoodProposalResponse.self,
+ from: Data(#"{"proposal": null}"#.utf8))
+ #expect(response.proposal == nil)
+ }
+}
diff --git a/ios/Forge/ForgeTests/LiftTrendTests.swift b/ios/Forge/ForgeTests/LiftTrendTests.swift
new file mode 100644
index 0000000..14d11d1
--- /dev/null
+++ b/ios/Forge/ForgeTests/LiftTrendTests.swift
@@ -0,0 +1,112 @@
+import Testing
+@testable import Forge
+
+/// Progress labels each lift in plain words rather than a percentage, because
+/// a word tells you whether to act. That makes the wording load-bearing: saying
+/// "stalled" about a lift that's climbing, or "building" about noise, is worse
+/// than showing nothing.
+struct LiftTrendTests {
+ private func series(_ values: [(String, Double)]) -> LiftSeries {
+ LiftSeries(name: "Test", points: values.map { SeriesPoint(d: $0.0, v: $0.1) })
+ }
+
+ @Test func oneSessionIsTooEarlyToJudge() {
+ #expect(series([("2026-07-01", 100)]).trend == .tooEarly)
+ #expect(LiftTrend.tooEarly.words == "new")
+ }
+
+ @Test func aClearGainIsBuilding() {
+ let trend = series([("2026-06-01", 100), ("2026-06-15", 103),
+ ("2026-07-01", 108)]).trend
+ #expect(trend == .building(gain: 8))
+ #expect(trend.isPositive)
+ #expect(trend.words == "building")
+ }
+
+ /// The bug this rule exists for: a lift run twice a week at the same load,
+ /// climbing every week, read as "holding" when the comparison was
+ /// last-vs-previous. Two sessions at one weight is normal programming.
+ @Test func repeatingALoadWithinAWeekIsNotAStall() {
+ let trend = series([("2026-06-08", 100), ("2026-06-11", 100),
+ ("2026-06-15", 102), ("2026-06-18", 102),
+ ("2026-06-22", 104), ("2026-06-25", 104),
+ ("2026-07-01", 106), ("2026-07-04", 106)]).trend
+ #expect(trend.words == "building")
+ }
+
+ /// A rep more at the same load moves e1RM a couple of kilos on its own —
+ /// that must not read as progress.
+ @Test func movementInsideTheNoiseBandIsNotAGain() {
+ let trend = series([("2026-06-01", 100), ("2026-07-01", 101)]).trend
+ if case .building = trend { Issue.record("1 kg counted as building") }
+ #expect(!trend.isPositive)
+ }
+
+ @Test func flatForWeeksReportsTheWeeks() {
+ let trend = series([("2026-06-10", 100), ("2026-06-24", 100.5),
+ ("2026-07-08", 100)]).trend
+ #expect(trend == .stalled(weeks: 4))
+ #expect(trend.words == "stalled 4 wk")
+ }
+
+ /// One flat week is normal training, not a plateau worth flagging.
+ @Test func oneFlatWeekIsHolding() {
+ let trend = series([("2026-07-01", 100), ("2026-07-08", 100)]).trend
+ #expect(trend.words == "holding")
+ }
+
+ /// Coming off a recent high is easing even when the four-week net change is
+ /// flat — you were at 108 a fortnight ago and you're at 101 now, and
+ /// calling that "stalled" would hide a real drop.
+ @Test func comingOffARecentHighIsEasing() {
+ let trend = series([("2026-06-01", 100), ("2026-06-15", 108),
+ ("2026-07-01", 101)]).trend
+ #expect(trend == .easing(drop: 7))
+ #expect(!trend.isPositive)
+ #expect(trend.words == "easing off")
+ }
+
+ /// The headline change must name the window it actually covers — a lift
+ /// with six weeks of history cannot claim "in 12 wk".
+ @Test func changeReportsTheWindowItReallyCovers() {
+ let change = series([("2026-05-27", 100), ("2026-07-08", 110)]).change(overWeeks: 12)
+ #expect(change?.delta == 10)
+ #expect(change?.weeks == 6)
+ }
+
+ /// With more history than the window, the baseline is the window's start,
+ /// not the oldest point on record.
+ @Test func changeClipsToTheRequestedWindow() {
+ let change = series([("2026-01-01", 80), ("2026-04-15", 100),
+ ("2026-07-08", 110)]).change(overWeeks: 12)
+ #expect(change?.delta == 10)
+ #expect(change?.weeks == 12)
+ }
+
+ @Test func changeNeedsTwoPoints() {
+ #expect(series([("2026-07-08", 110)]).change(overWeeks: 12) == nil)
+ }
+}
+
+/// `RecordRow.detail` arrives pre-formatted from the server with a **kg** load
+/// in it. Rendering it raw next to lb numbers is the unit invariant's exact
+/// failure mode, so the conversion has a test.
+struct RecordDetailTests {
+ private func record(value: Double, detail: String) -> RecordRow {
+ RecordRow(slug: "back-squat", name: "Back Squat", kind: "best_set",
+ value: value, detail: detail, achievedOn: "2026-07-20")
+ }
+
+ @Test func convertsTheLoadAndKeepsTheReps() {
+ let row = record(value: 73.75, detail: "73.75 × 5")
+ #expect(row.detail(in: .kg) == "73.75 kg × 5")
+ #expect(row.detail(in: .lb) == "162.6 lb × 5")
+ }
+
+ /// An e1RM record has no reps half — it must not render a dangling "×".
+ @Test func handlesADetailWithNoReps() {
+ let row = record(value: 86, detail: "86")
+ #expect(row.detail(in: .kg) == "86 kg")
+ #expect(!row.detail(in: .lb).contains("×"))
+ }
+}
diff --git a/ios/Forge/ForgeTests/MetricDeltaTests.swift b/ios/Forge/ForgeTests/MetricDeltaTests.swift
new file mode 100644
index 0000000..985b22d
--- /dev/null
+++ b/ios/Forge/ForgeTests/MetricDeltaTests.swift
@@ -0,0 +1,45 @@
+import Foundation
+import Testing
+@testable import Forge
+
+/// Both of these were live bugs on the Progress board, caught in the simulator
+/// rather than by a test — which is why they have tests now.
+struct MetricDeltaTests {
+ private let whole: (Double) -> String = { String(Int($0)) }
+ private let oneDp: (Double) -> String = { String(format: "%.1f", $0) }
+
+ /// The bug: resting HR prints whole bpm, so a 0.4 bpm drift passed a fixed
+ /// 0.05 threshold and rendered "▼ 0" — an arrow pointing at no change.
+ @Test func aChangeTooSmallToPrintIsNotShown() {
+ #expect(MetricDelta.label(0.4, format: whole) == nil)
+ #expect(MetricDelta.label(-0.4, format: whole) == nil)
+ #expect(MetricDelta.label(0.04, format: oneDp) == nil)
+ }
+
+ /// The same 0.4 IS worth showing when the formatter can render it.
+ @Test func precisionDecidesSignificance() {
+ #expect(MetricDelta.label(0.4, format: oneDp) == "▲ 0.4")
+ #expect(MetricDelta.label(-1.5, format: whole) == "▼ 1")
+ }
+
+ @Test func exactlyNoChangeIsNeverAnArrow() {
+ #expect(MetricDelta.label(0, format: oneDp) == nil)
+ }
+
+ /// Volt means positive status. A drop in resting HR earns it; the identical
+ /// drop in sleep must not.
+ @Test func goodDependsOnWhichWayTheMetricShouldGo() {
+ #expect(MetricDelta.Better.down.isGood(-2))
+ #expect(!MetricDelta.Better.down.isGood(2))
+ #expect(MetricDelta.Better.up.isGood(2))
+ #expect(!MetricDelta.Better.up.isGood(-2))
+ }
+
+ /// Bodyweight has no universal good direction, so neither move is accented
+ /// rather than the app guessing at a goal it was never told.
+ @Test func aMetricWithNoGoodDirectionIsNeverAccented() {
+ #expect(!MetricDelta.Better.either.isGood(5))
+ #expect(!MetricDelta.Better.either.isGood(-5))
+ #expect(!MetricDelta.Better.either.isGood(0))
+ }
+}
diff --git a/ios/XCODE-HANDOVER.md b/ios/XCODE-HANDOVER.md
index 02df9a8..0686c7e 100644
--- a/ios/XCODE-HANDOVER.md
+++ b/ios/XCODE-HANDOVER.md
@@ -201,6 +201,28 @@ worse than useless — it looks like real information and isn't.
Note `ENABLE_USER_SCRIPT_SANDBOXING = YES`; the script needs an output file declared or it
will fail under sandboxing.
+### C1b · Widget Extension target — Live Activities `[ ]`
+
+**Where**: File → New → Target → **Widget Extension**, "Include Live Activity" checked,
+embedded in `Forge`. Then target `Forge` → Info → add `NSSupportsLiveActivities = YES`.
+
+**What it unblocks**: two things that are otherwise built and waiting.
+
+- **Cook mode step timers on the Dynamic Island** (N4, built). `CookModeView` runs its
+ timers locally — absolute end dates, so they survive backgrounding, and they chime with a
+ haptic. What's missing is only the Island presentation the board shows ("FORGE COOK ·
+ Skin crisping · 3:41"). Without it, a 22-minute roast is invisible the moment you leave
+ the app, which is exactly when you'd leave it.
+- **Active-workout Live Activity + the first widgets** (N3 — current exercise, set x of y,
+ rest countdown; Train's today/week/Zone-2 widgets; Cook's tonight's-dinner widget).
+
+Started locally, so **no APNs and no push entitlement are needed** for either — B3's key is
+only for server-driven updates later.
+
+Note this is the trigger for **C3** below: a widget extension is the second target, and it
+needs `Forge/Kit` and `Forge/Design`, so that's the moment the `Packages/` extraction
+stops being premature.
+
### C2 · SwiftLint build-tool plugin `[ ]`
**Where**: target `Forge` → Build Phases → Run Build Tool Plug-ins. Called for in the plan
@@ -244,5 +266,9 @@ If you want to sit down once and unblock everything through N2:
5. Say whether the deployment target stays at 26.5. **[A4]**
6. Cable-install to both phones and sign in. **[B2]**
+And when you want the Island timers Cook and Train are both already written for:
+
+7. New Widget Extension target with Live Activity, plus `NSSupportsLiveActivities`. **[C1b]**
+
Then commit `Forge.entitlements` and the `project.pbxproj` changes, and tell the next agent
session which of these are done.
diff --git a/server/app/routers/training.py b/server/app/routers/training.py
index cb8ec79..ef73578 100644
--- a/server/app/routers/training.py
+++ b/server/app/routers/training.py
@@ -712,13 +712,20 @@ def history(limit: int = Query(default=30, ge=1, le=100), offset: int = Query(de
def _e1rm_series(db: Session, user_id: str) -> dict:
- """Per-lift e1RM series: best set per completed-session day."""
- sets = (db.query(LoggedSet).join(WorkoutSession, LoggedSet.session_id == WorkoutSession.id)
+ """Per-lift e1RM series: best set per completed-session day.
+
+ Keyed on the session's `day`, NOT the set's timestamp. They coincide when
+ you log as you train, but a session saved off the next morning — which the
+ completion path explicitly supports — would otherwise plot the lift on the
+ day you pressed the button rather than the day you did the work.
+ """
+ rows = (db.query(LoggedSet, WorkoutSession.day)
+ .join(WorkoutSession, LoggedSet.session_id == WorkoutSession.id)
.filter(LoggedSet.user_id == user_id, WorkoutSession.status == "completed",
LoggedSet.weight > 0).all())
series: dict[str, dict[str, float]] = {}
- for s in sets:
- day_key = str(s.ts.date())
+ for s, day in rows:
+ day_key = str(day)
e1 = epley_e1rm(s.weight, s.reps)
cur = series.setdefault(s.exercise_slug, {})
cur[day_key] = max(cur.get(day_key, 0), e1)
diff --git a/server/tests/test_smoke.py b/server/tests/test_smoke.py
index 5f9b296..a45d445 100644
--- a/server/tests/test_smoke.py
+++ b/server/tests/test_smoke.py
@@ -2095,3 +2095,18 @@ def test_favorite_and_delete_scoped_to_owner():
# clean up James's leftover active session
login("james@test.dev")
client.delete(f"/api/sessions/{sid}")
+
+
+def test_e1rm_series_uses_the_workout_day_not_the_log_time():
+ """A session finished the morning after must plot on the day it was
+ trained. The two coincide when logging live, so only a backdated session
+ catches this."""
+ login()
+ past_monday = "2026-07-13"
+ sid = client.post("/api/sessions", json={"date": past_monday}).json()["id"]
+ client.post(f"/api/sessions/{sid}/sets",
+ json={"slug": "back-squat", "set_no": 1, "weight": 92.5, "reps": 3})
+ client.post(f"/api/sessions/{sid}/complete", json={"cooldown_status": "done"})
+
+ points = client.get("/api/progress").json()["e1rm"]["back-squat"]["points"]
+ assert any(p["d"] == past_monday for p in points), points
diff --git a/web/src/api.ts b/web/src/api.ts
index 948afdb..a9ce54d 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -284,7 +284,7 @@ export interface RecipeListItem extends RecipeCard {
tags: string[]; source: string; complete: boolean;
}
export interface RecipeList { count: number; recipes: RecipeListItem[]; }
-/** Full-history series for one body/engine metric (Progress drill-down). */
+/** Full-history series for one body/cardio metric (Progress drill-down). */
export interface MetricHistory { type: string; unit: string; points: SeriesPoint[]; }
export interface Connections {