← back to the timeline
Jun 14 2026

eight things got done: Moving the "brains" of the app into the new shell, without flipping the switch (Phase 3 UI, #376, commit 1 of 2). Tap any of these to read the whole thing.

Moving the "brains" of the app into the new shell, without flipping the switch (Phase 3 UI, #376, commit 1 of 2)

The big one, done carefully.

Shipped

The big one, done carefully. For weeks the new 3-tab look has been built but switched off, while the real app kept running on the old screen (ContentView). That old screen quietly owns the most fragile, most important plumbing in the whole app: the first-launch setup, the "you have an unfinished workout, resume or abandon?" recovery after a crash, and the exact rules for picking the right session back up. This task copied all of that plumbing into the new shell, faithfully, line for line where it mattered, so the new shell can stand on its own. But I did not flip the switch. The old screen is still the live one, completely untouched. Nothing a user sees changed.

Why split it this way. Turning the new shell on is the genuinely scary step, if the resume logic dropped a thread, someone could lose a workout they were halfway through. So this is deliberately commit 1 of 2: this commit moves the plumbing (safe, dormant, reversible by one undo), and a later separate commit flips the switch, and that flip only happens after a hands-on "force-quit mid-set on a real phone" test. This PR is explicitly not to be auto-merged; a human reviews it.

The six pieces moved. The program "view model" and its whole life-cycle (born on launch, reborn after onboarding, wiped on reset); the onboarding cover; the crash-recovery alert chain, including a subtle fix from earlier (#318) where two alerts firing at once would silently eat one of them, copied across exactly so it can't regress; the paused-session resume with its three branches; the workout loop and the settings screen (which are joined at the hip to the view model); and the "this build needs setup" gate, which I lifted up one level so it now guards both the old and new screens from a single place.

Loop look at go-live. When the switch eventually flips, the workout itself will still use the current in-session screens, so the day it goes live it behaves identically to today. The brand-new live-loop visuals turn on later, as their own separate, undoable step.

How I made the scary parts testable. The two riskiest pieces, which resume branch to take, and the two-alerts-collide rule, I pulled into small plain functions the new shell calls, so I could write tests that prove all three resume branches and the alert-collision rule behave correctly against the new shell (the codebase tests logic, not live screens). The alert ordering itself was copied across untouched.

Status: PR open from feat/376-machinery-lift, switch still off, old screen still live and untouched. 15 new tests green; the full suite (506 tests) was green before the run. Flagged in the PR: the small "pull the decision into a testable function" deviation, since the brief asked for a verbatim move.

PR open from feat/376-machinery-lift, switch still off, old screen still live and untouched. 15 new tests green; the full suite (506 tests) was green before the run. Flagged in the PR: the small "pull the decision into a testable function" deviation, since the brief asked for a verbatim move.

Coming back after a long break no longer fools the strength estimate (#418)

The app tracks how strong you are with a moving average that only looks at your last few workouts, not the calendar.

Done
the problem

The app tracks how strong you are with a moving average that only looks at your last few workouts, not the calendar. That works great while you're training regularly. But if you vanish for six weeks and come back, the math treats your first workout back as if it happened the very next day, so it quietly assumes you're as strong as you were before the break. You're not. Real strength fades during a long layoff, and the old code couldn't see that.

what I changed

When a gap of 28 days or more is still sitting inside a lift's recent-workout window, the estimate now throws out the stale pre-break workouts and re-anchors on the workouts you've done since coming back. So your first session back reads your real current strength (110.83 kg in our worked example) instead of the stale old number (116.74 kg). And it stays re-anchored across the whole comeback window, it doesn't snap back up on your second workout, then quietly turns itself off once the old pre-break workouts have aged out of the window and normal tracking resumes. The 28-day trigger matches the cue the app already uses to suggest a return-to-training phase.

how I tested it

Built test-first across the slices; the full Edge-Function suite is green (452 tests, 0 failing) and the type-checker is clean. An independent review caught one real bug along the way, a naive version actually moved the estimate the wrong way, which is why trimming the old workouts is mandatory, not optional. The iOS side compiles against the real type definitions but isn't visually QA'd yet (no simulator here); that and snapshot images are owed. Decisions recorded in ADR-0005/0015/0020/0023.

Knock-on effects handled. The strength floor (the number your training band is built on, which is only ever allowed to ratchet up) is paused from ratcheting on stale comeback data, so a break can't accidentally lock in an inflated floor, and the "only goes up" rule is preserved. Two screens (the goal-review capability list and the progress ledger) now show a small "Re-establishing after a break" note so you know why a number dipped.

MERGED to main (PR 418, squash b780549). Does not close the 369 audit umbrella. One follow-up noted for later: a fatigue-pairing calculation also reads the strength estimate and may want the same break-aware gating, reported, not yet acted on.

The Today screen: your next workout, one tap to start, one honest coach line (Phase 3 UI, #348)

The new Today tab's main screen, the "what does my coach want from me right now?" surface.

how I tested it

Unconditional tests cover which rule fires for which model state, the no-AI fallback, the empty-collapse, the character budget, the no-warmth guard, the evidence-number formatting, and that Start fires its wired action. Image snapshots (light + dim + an accessibility size) are wired but their reference images aren't recorded here, the CI record job does that.

What it is. The new Today tab's main screen, the "what does my coach want from me right now?" surface. At the top: the date and a small readiness gauge (the Lens). Below that: one short coach line. Then the hero, a single card showing your next session (its title, up to three exercises with their sets and reps drawn as a crisp number lockup, a rough time estimate) and one big full-width Start button, the only coloured thing on the screen. Any coach alerts sit as a calm list below Start, never a pop-up jumping in front of it.

The coach line is the careful part. It's one short sentence, always grounded in a real number from your trainee model, your squat floor, the gap to your next floor, your session count, and it's written by plain rules with no AI call at all. The AI version is a future upgrade, never something the screen depends on. If the rules have nothing genuinely true to say, the line collapses to an empty space, never filler, never "You got this!". The whole thing is governed by our ratified coach-voice rules: instrument-grade, factual, no warmth/praise/hype. I even added a test that scans every possible output against a banned-cheerleading word list to prove it.

The rules, ranked. (1) If a pattern is still calibrating, say so and give the model's own session count. (2) Otherwise state the floor as a held fact ("Squat floor at 105 kg, square in the band"). (3) If you're pushing the top of your band, give the deterministic distance to the next floor. (4) Last resort: a plain session tally. Each is capped at a hard 80-character budget; anything over, or anything with an ellipsis, fails and the next rule (ultimately the empty collapse) takes over.

How it gets its data (the seam). Like the sibling Train and Progress screens, the new shell doesn't own the live program "view model" yet, so the screen takes its data as a plain input tests can hand it, and the live host reads the same saved-program cache and trainee-model the old screens read. Start is wired to a clearly-marked TODO (#376) for the real session-start path, it's tappable now, live at the flip. No backend or model change.

Still dormant. Brand-new screen wired only into the off-by-default new 3-tab shell. The live app runs the old screens untouched, nothing users see changed.

PR open from feat/348-today. New suites all green (21 tests in the scoped run); whole app + tests compile with no new warnings.

The Train program root: your plan drawn as a vertical day-spine (Phase 3 UI, Slice 15 #357)

The new Train tab's main screen, drawn as a single vertical spine running down the page, the left edge is the timeline, and each training d…

how I tested it

New unconditional tests cover the dot mapping for every day status above and below the horizon, rest-and-skeleton-from-gaps, the three discrete compression steps, "Week X of N," and a guard proving the screen carries no streak/counter/adherence field. Image snapshots for light + dim + an accessibility size are wired up but their reference images aren't recorded here, the CI record job on Xcode 26.3 does that.

What it is. The new Train tab's main screen, drawn as a single vertical spine running down the page, the left edge is the timeline, and each training day hangs off it. This week shows in full: each day a row with a small status mark (a filled dot = done, a hollow dot = scheduled-but-not-done) and a short list of that day's exercises. Days you don't train are first-class "rest" nodes on the spine, not blank gaps. Below this week, the rest of the plan shows compressed and faint, pattern/focus only, no fake numbers, because the coach hasn't worked those days out yet.

The honest part. The whole point is the line between what the coach has placed and what it's still going to figure out closer to the day. Placed days are drawn in ink with real exercises; not-yet-placed days are drawn in pencil as a shape only, inside a faint diagonal hatch zone, with a drawn "PLACED ABOVE · SHAPE BELOW" line between the two. The further out a week is, the more it compresses, but in three clear steps, never a smooth fade. And there are deliberately no streaks, no rings, no "3 of 5 done" counters, no shame mark on a missed day, the calendar is a plan, not a report card. A skipped day just stays a hollow dot, like any other day the plan moved past.

Derived, not stored (no data changes). Rest days and skeleton days aren't new saved states, they're worked out from what's already there. A weekday with no training day = a rest node. A day with no generated exercises yet = a skeleton (pencil) day. I added zero new cases to the saved data and changed no model, so nothing about how programs are stored changed. The mapping from a day's status to its dot, and these rest/skeleton rules, all live in the new screen (the dot drawing itself is a dumb shared piece).

How it gets its data (the seam). The new shell doesn't yet own the program's live "view model," so this screen takes its data as a plain input that tests can hand it directly, and the live version reads the same saved-program cache the old screen reads first. When no program is cached it shows an honest empty state. The real live wiring (current-week tracking, days re-resolving on screen) is a clearly-marked TODO for a later slice (#376).

Still dormant. This is a brand-new screen wired only into the off-by-default new 3-tab shell. The live app still runs the old program screen untouched, nothing users see changed.

PR open from feat/357-train-root. New suites all green (23 tests in the scoped run); whole app + tests compile with no new warnings.

The drafting-rule system: drawing what the model knows vs. is still guessing (Phase 3 UI, #411)

A shared set of drawing pieces for one idea that shows up on three screens: the line between what the coach has placed and what it hasn't w…

how I tested it

New unconditional tests cover the new measurements, that "committed vs. provisional" is a total function over every input, that the register vanishes at zero, that the gradient is genuine discrete steps (no in-between), that the hatch uses the pencil colour and the dash uses the 4-2 projection pattern, and a guard proving the band, the spine, and the horizon all compute the same floor x (the #408 regression guard). Image snapshots for light + dim + an accessibility size are wired up but their reference images aren't recorded here, the CI record job on Xcode 26.3 does that. Added ADR-0028 recording the whole committed-vs-provisional model.

What it is. A shared set of drawing pieces for one idea that shows up on three screens: the line between what the coach has placed and what it hasn't worked out yet. The rule is simple, if the model has committed to something, draw it in ink with real numbers; if it's still provisional, draw it in pencil as a shape only; and always draw the boundary between the two as an actual line, never leave it to ink-vs-pencil alone (because the muted pencil colour already means "time/metadata," so on its own it reads as "minor detail," not "not figured out yet").

The pieces. A DraftingRule (a thin full-width line with a little 4pt tick at the left margin, Today's drawing signature, solid or dashed). A DraftingRegister (a tiny two-tone caption in the margin, the number in ink, the words in pencil, that simply isn't drawn when there's nothing to say, never a fake "0"). A GenerationHorizonBreak (the drawn line on Train's plan that says "PLACED ABOVE · SHAPE BELOW"). A ToBePlacedHatch (a sparse diagonal pencil hatch filling the not-yet-planned zone). And a CommitmentTier, the further-out-is-fuzzier effect drawn as three clear steps (this week in full, next compressed, beyond as one mark per day), deliberately NOT a smooth fade, so it can't be mistaken for a confidence-on-a-chart slope (which the app bans).

One shared floor line (fixes a real bug, #408). The Progress screen's "spine", the single vertical line all the rows' floor ticks are supposed to land on, used to guess its position from a stand-in band, so rows of different widths drifted slightly off it. I pulled the floor position into one shared helper (BandDatum.floorX) that the band, the spine, and the new horizon all call, so every floor tick lands on exactly the same line. The Progress screen's spine now uses that helper instead of its own inline guess. Same picture as before for normal widths, just exact now instead of approximate.

A doc fix. The design doc said projection dashes are always the accent ink colour, but the shipped band actually draws its dashed edges in the hairline colour. I corrected the doc to match the code: a dashed chart line is accent ink, a dashed band edge is hairline. I did not touch the band's code.

Important: still dormant. None of these new pieces are wired into any screen yet, they're built and tested, waiting for the per-screen slices. The only live-code change is the Progress spine swapping to the shared floor helper, and even that screen is behind the off-by-default new shell.

PR open from feat/411-drafting-rule. New suites + the Progress regression suite all green (42 tests in the scoped run).

Built the StatusTick instrument: filled/hollow/undrawn day-status + today marker (Phase 3 UI, Slice 5 #410)

A shared drawn instrument for day/set status on the Train spine, the drawn replacement for the green check that the design bans (train.md §…

how I tested it

8 unconditional tests cover: dayStatusTick constant (4pt), stroke constant (1.5px), the three value cases, the no-numeric-content guard (honesty law), and today-marker is a static Bool. Image-snapshot tests for filled/hollow/undrawn × light+dim, filled+today, AX5, and RestWellNode light+dim are wired up but reference images are not recorded here, the CI record job on Xcode 26.3 does that. APEX_RECORD_SNAPSHOTS was never set.

What it is. A shared drawn instrument for day/set status on the Train spine, the drawn replacement for the green check that the design bans (train.md §3). Three values: filled (done, solid ink dot), hollow (committed-not-done, ink stroke with paper interior), undrawn (skeleton/below-horizon, renders nothing, the drafting-rule zone, a sibling slice, is the only mark there). A isToday flag layers a quiet 2px ink left-margin rule, static, no animation.

Why a primitive. The mapping from model state (completed / generated / skeleton / skipped) to filled/hollow/undrawn depends on the generation horizon, that logic belongs in the consuming Train screen (#357), not here. This instrument is "dumb": it draws whatever value it is handed.

RestWellNode. A sibling view in the same file, a recessed well row for rest days on the Train spine. Not a tick. Rest is derived from day-of-week gaps (no model change); the node states what recovery buys. The caller supplies the recovery line; the view applies the well token background and inkMuted text.

Geometry constants. Added DesignGeometry.dayStatusTick = 4 (matching the live-loop §3 spec) and DesignGeometry.dayStatusTickStroke = 1.5 (mirroring CapabilityBand's hollow dot) to Layout.swift.

Cross-cutting grep. Checked LiveLoopView.swift and PostWorkoutSummaryView.swift for existing filled/hollow tick rendering. LiveLoopView renders the set-position as plain text (setPositionText = "set 2 of 5" at line 254), no drawn ticks, not yet using StatusTick. PostWorkoutSummaryView has Circle draws at lines 307–312 and 519–520, but these are the GymStreak ring (a legacy multi-hue progress arc), not set-grain ticks. Neither site was touched; unifying them onto StatusTick is a future authorized step.

Still dormant. Not wired into any live screen. The 3-tab shell is still behind useNewShell = false; ContentView is untouched.

Last piece: the goal/calibration/manual-log screens now wait for login before saving (6 of 6)

A handful of less-common save points, editing your goal, reviewing a calibration, logging a past workout by hand, still grabbed "who am I"…

Done
the problem

A handful of less-common save points, editing your goal, reviewing a calibration, logging a past workout by hand, still grabbed "who am I" the old, instant way, which during the first second after opening the app can be the temporary stand-in id. A save stamped that way gets rejected. The main workout flow was already fixed in the earlier pieces; this is the long tail.

what I changed

Those three screens now wait for the real login the same way the workout screen does. For the two "best-effort" syncs (goal and calibration), only the part that talks to the server waits for login, the bit that updates your screen still happens instantly, so nothing feels slower. For logging a past workout by hand (which actually creates records), it now waits for login and, if it genuinely can't confirm one, shows "sign-in isn't confirmed yet, please try again" instead of silently doing nothing.

how I tested it

The whole app builds; a review agent confirmed each screen still updates locally regardless, that the "wait for login" step doesn't slow anything (the login is already settled by the time you reach these screens), and that no nearby save in the same files was missed.

What I deliberately left for later (and why). A few remaining save points live inside the app's main container screen, which a separate redesign is actively rebuilding right now. Editing it today would collide with that work and likely get thrown away when the redesign swaps it out. So I wrote those up as a tracked task (#409) to do once the redesign lands. I also confirmed the workout-note and AI-memory saves are already safe, because they're tied to the workout, and the workout itself can no longer start under the wrong login (earlier pieces).

merged as PR 412 (Slice 6 of 6, the owner-mismatch campaign is complete bar the one tracked follow-up 409 and switching the database auto-create rule on in production).

The outbox now drops a save it knows will be rejected, instead of hammering it 5 times (5 of 6)

The "outbox" of pending saves retries anything that fails, five times, with growing waits, assuming the failure is temporary (bad signal, s…

Done
the problem

The "outbox" of pending saves retries anything that fails, five times, with growing waits, assuming the failure is temporary (bad signal, server hiccup). But a save stamped with the wrong owner is never going to succeed; the database will reject it every single time. So the app would waste five rounds of guaranteed-to-fail tries on each one, filling the log with red and slowing real saves.

what I changed

The outbox now does a quick check just before sending: "does this save's owner match who I'm signed in as right now?" If it clearly doesn't, it sets the save aside immediately (into the "couldn't send" pile) instead of retrying five times. If there's no owner to check, or the login isn't settled yet, it skips the check and behaves exactly as before, so it can never wrongly drop a good save.

how I tested it

Four tests cover the whole grid: wrong owner is set aside on the first try (no retries, proven precisely); right owner sends normally; no-login-yet skips the check; and saves that don't carry an owner (like individual sets, which are tied to the workout instead) are never touched. A review agent did the careful part by hand, it traced that the "who's signed in" value and the "who owns this save" value always come from the same source, so they can't drift apart and cause a good save to be dropped, and it checked every kind of save the app sends to confirm the check targets the right ones and skips the rest.

merged as PR 407 (Slice 5 of 6). One slice left: apply the same "confirm the login first" rule to the remaining writes (notes/memory, program edits, profile).