From the build log of an AI coach that learns what you can lift and tells you exactly what to do next.
seven things got done: The workout no longer stops when the coach can't be reached. Tap any of these to read the whole thing.
Every set waited on the AI coach to tell you the weight and reps. If the connection hiccuped (which happens, see the earlier "Coach offline" fixes), you got a pop-up asking you to tap "Try again," sometimes more than once, before you could do your next set. Mid-workout, that's exactly the wrong moment to be poking at a retry button. The set was effectively held hostage by the network.
When the coach can't be reached because of a connection problem (a timeout, a dropped request, a server hiccup), the app now just keeps you moving: it instantly fills in a sensible set, your last logged weight for that exercise (or last session's, if it's your first set) with the program's rep and rest targets, and shows a small "Coach offline" note. No pop-up, no waiting. The AI becomes a bonus when it's reachable, not a gate when it isn't. The pop-up is kept ONLY for the rare cases where retrying actually can't help (the app sent a broken request, the coach replied with gibberish, or a setup file is missing), there you still get a choice, as before.
The app builds clean and the workout-engine tests pass, including new ones: a connection failure now auto-fills the set (no pop-up), the failure-type classification is pinned (connection issues vs. broken-request), and a genuinely broken response still shows the pop-up. The existing "Continue with last weights" button and the tricky concurrency safeguards were kept intact.
The app had two pieces of code for building your training program. Only one is actually used (the newer "skeleton" builder). The other, older one was still sitting in the codebase, wired up enough to compile but never actually run, dead weight that made the next round of program-generation work harder to reason about. Time to delete it before building on top.
Deleted the old unused builder, its prompt file, and its tests, plus every leftover wire connecting it (a handful of setup spots and seven test files that constructed it but didn't need it). One small useful helper that lived inside the old file, the bit that sets up the AI connection for building programs, was still needed by the new builder, so I moved it over rather than losing it. No behavior change: nothing that runs today changed, because the deleted code never ran.
Searched the whole codebase to confirm nothing real still called the old builder (only stale code comments mention it by name now, flagged those for a later tidy-up). The app and all tests build clean.
The coach tracked a floor for each muscle, "you're below the amount of work that grows this muscle, do more." But it had no ceiling. So it could never notice the opposite problem: hammering one muscle past the point where more sets actually help (and just dig a recovery hole). There was simply no signal for "this is too much volume."
Added a sensible upper limit per muscle (a research-style "you probably shouldn't exceed this" number, scaled to your training frequency the same way the floor is) and an "over-volume" reading: how far past that limit your recent sets are, zero if you're under it. It's advisory only, it does NOT cap or cut your sets; it just gives the coach an honest signal so its advice can reflect reality. The new numbers also show up in the summary the coach reasons over. Older saved profiles that don't have these fields yet just read as zero, nothing breaks.
23 server-side tests pass (4 new for the ceiling + over-volume math), an end-to-end database test proves the signal shows up after a heavy session, and the iOS app builds clean with two new tests proving old saved data still loads and the new fields survive a save/load round-trip.
Heads-up for deploy. server-side coach change, live only after
supabase functions deploy update-trainee-model. (#570, part of #558 / ADR-0030.)
The coach has a target for how many hard sets each muscle should get. That target was baked in assuming everyone trains about 4 times a week. But the way it's measured is "sets over your last 7 workouts for that muscle", and 7 workouts is a different amount of time depending on how often you train. If you train a muscle 6×/week, 7 workouts is barely over a week, so the fixed target was actually ~145% of what a week should be, too high. If you train it 2×/week, 7 workouts spans 3+ weeks, so the same target was only ~57% of a week, too low. So high-frequency people looked permanently "under target" and low-frequency people looked "done" when they weren't.
The target now scales to how often you actually train each muscle (read straight from the timestamps of your own sessions for that muscle). Train it more often → the per-window target comes down to match a sensible weekly amount; train it less often → it goes up. At 4×/week nothing changes. I capped the scaling to a sane range so someone training once a week (or twice a day) doesn't get an absurd number. Important detail I got right: the target is always recalculated from the fixed baseline, never from last time's already-scaled number, otherwise it would quietly drift lower and lower every workout.
19 automated tests on the math (8 new, including one that proves it does NOT drift when you train at a steady pace), plus a full end-to-end test that runs in CI against a real database. The existing targets tests still pass (a brand-new user with one session sees the same baseline as before).
Heads-up for deploy. server-side coach change, live only after
supabase functions deploy update-trainee-model. (#164, part of #558 / ADR-0030.)
The app runs a little engine that decides, for each lift you do, what "phase" of training you're in, building volume, pushing intensity, peaking (going very heavy for a few reps), or deloading (an easy week). The catch: it cycled everyone through that same loop, including the "peaking" phase, no matter what their actual goal was. Peaking is a powerlifting idea, very heavy, very few reps. If your goal is just to build muscle, getting shoved into a heavy low-rep peak is the wrong training. So a muscle-building user could quietly end up doing a strength-peaking block they never wanted.
The engine now looks at your goal first. If your goal actually says strength (or "max weight", "powerlifting", "1RM"), nothing changes, you still get the full loop with peaking. For everyone else, muscle size, endurance, general fitness, or if no goal is set, the loop now skips peaking entirely and goes building → harder → easy week → repeat. I deliberately made "skip the peak" the safe default: if the app is ever unsure, it will NOT peak you, because wrongly peaking someone is the bug we're fixing.
Twenty automated tests on the engine pass (the seven new ones cover both the strength loop staying the same and the new "skip peaking" loop), plus the 38 tests on the code that feeds the engine. This is the first piece of a larger program-generation overhaul (decision write-up ADR-0030, now accepted).
Heads-up for deploy. this is a server-side coach change, it goes live only
after supabase functions deploy update-trainee-model is run. Until then nothing
changes for anyone. (#559, part of #558; ADR-0030 accepted via #557.)
Too many times in the middle of a workout, the app would say "Coach is offline" and you'd have to tap "Try again" two or three times before your next set finally came through. Really annoying. I dug in and found two reasons. First, the coach was talking to the internet using the phone's default settings, which try a newer fast connection method (called QUIC) that some Wi-Fi and phone networks choke on, the connection just hangs. We'd already hit this exact thing on the login screen months ago and fixed it there, but the coach never got the same fix. Second, the app was supposed to quietly retry on its own, but it was wired so all the retries had to finish inside an 8-second window, and the waiting between tries alone ate up those 8 seconds, so the automatic retry basically never got a real shot. That's why you ended up doing the retrying by hand: every tap opened a brand-new connection, and eventually one wasn't stuck.
Gave the coach the same reliable connection the login screen already uses, one that avoids the flaky QUIC method and rides out brief network blips. And I rewired the timing so the app now gets up to about 30 seconds to quietly retry on its own, opening a fresh connection each time, before it ever bothers you with the "offline" message. I picked the patient setting on purpose so it tries hard before giving up.
Built the whole app clean (no errors) on the iPhone 17 Pro simulator. I left the safety net in place: if the network is genuinely dead, it still falls back to your program's planned weights rather than guessing. I only touched the coach's connection and its retry timing, nothing about how your sets or weights are decided changed.
The bar at the bottom of the app, the one that switches between Program, Workout, Progress and Settings, was still Apple's standard bar with a coat of black paint over it. It couldn't do the look we wanted: a clean white block sitting over whichever tab you're on. So I built our own bar from scratch to match the rest of the app's blunt black-and-white style. The white block marks the active tab; the lime-green colour stays reserved for buttons you press, not for navigation.
Replaced the built-in bar with a custom one. The hard part was a surprise from the latest iPhone software: the new floating "glass" bar Apple added simply won't go away when you ask the normal way, it just hides behind your own bar and pops back out the moment the layout shifts. I figured out the trick that actually banishes it, and a second one so your screen's content always sits neatly above the bar instead of sliding underneath it. I tested both in a throwaway sandbox app with the real moving parts before touching the actual app, so I wasn't guessing.
Built the whole app clean (no errors) on the iPhone 17 Pro simulator, and proved the bar behaves, no ghost bar, content sits above it, white block on the right tab, with real screenshots in the sandbox. Nothing about how the app works changed: tabs switch the same, sessions and the paused banner are untouched. It's purely how it looks.