20 things got done: Sign-in no longer gives up too early on a good network (the real reason saves were failing). Tap any of these to read the whole thing.
After the reset fix, saving a workout still failed, but for a new reason. On a perfectly good wifi, the app's anonymous sign-in (the thing that gets you a login) was timing out, so the app had no login at all. With no login, it fell back to a placeholder id and the database refused every save with a "row-level security" rejection. The console was full of quic… max 5 reached and "Operation timed out".
Three small things in the sign-in code: (1) each attempt now gives up after 8 seconds of silence instead of hanging; (2) it retries up to three times, and a failed first try makes iOS drop back to the older, reliable connection type, so the retry goes through; (3) the overall wait went from 5 to 30 seconds. The longer wait is safe because sign-in runs in the background, it never freezes the app's screen; it only gives onboarding more time to get a login before it sets you up.
The whole app builds, and all 7 sign-in tests pass (the change doesn't affect the failure/timeout cases). The user spotted that this was a timing problem, not a wifi problem, they were right.
Why it was the app, not the wifi. Earlier in the same run, a different request to the database succeeded, so the network was fine. The problem: the sign-in only waited 5 seconds, tried once, and shared its connection with the rest of the app. iOS had learned the server supports a newer connection type (HTTP/3, "QUIC"), tried it for sign-in, and that handshake stalled. Five seconds wasn't enough to recover, so sign-in quit and the app carried on with no login.
After the database lock went live (the auth work), I tried to start a workout and it wouldn't save, the app kept retrying and then gave up. The reason: turning on the lock gave my install a brand-new login id, but nothing ever created a matching row for that new id in the users table. Every save points back to that table, so the database rejected the workout. Onboarding is the only place that creates the users row, and my install had onboarded long ago, so it never re-ran.
The reset button now also clears the saved login session (the access token, refresh token, expiry, and the login id). The next launch then signs in fresh, mints a new login id, and onboarding creates the matching users row for it, so saving works again. I also changed the confirmation message to tell you to quit and reopen the app before onboarding, because the fresh login only kicks in on the next launch.
Confirmed the four login keys are real, that nothing else in the app does an "identity wipe" that would need the same fix, and that the whole app still builds (BUILD SUCCEEDED). The old failed-and-parked workout in the local queue lives in the same storage the reset wipes, so it's gone after the reset too.
Why it matters. Since there's only one user right now (me), the clean fix is to wipe and start over from week one. But the in-app "Reset All App Data" button was written before the auth work, it cleared everything except the new login session. So a reset would quietly keep the same broken login, and you'd land right back in the same hole.
Follow-up filed. The deeper fix, so this can never happen again to any future user, is a database trigger that creates the users row automatically the moment a new login is made, instead of relying on onboarding. Logged as a separate issue.
What this is. Slices 1–5 of the auth/RLS workstream all shipped and the database lock is now live. This last slice is just paperwork: it writes down the decisions in a permanent record (ADR-0027) so future contributors understand why we went the way we did, and it fixes two older records that had an assumption that turned out to be wrong.
What ADR-0027 records. The audit found that row-level security was switched off on the five core tables, workouts, programs, trainee models, users, and set logs, so the per-user rules that existed for programs and set logs were doing nothing. The gym-profiles table had its lock on but a rule that said "everyone can see everything." The server functions trusted the user ID in the request body with no check on who was actually sending the request (an IDOR: anyone who found the URL could act as any user). The decision: give every fresh install a real Supabase login via anonymous sign-in (no account creation UI needed), turn on the database lock on all six tables with proper "you can only see your own rows" rules, and make the server functions verify the login token before touching the database. We also accepted that old data written before this change, tagged with a locally-generated device ID, not a real login ID, becomes invisible; that's the price of the fix at alpha scale.
The two records that needed a correction. ADR-0016 (written when we removed the service-role key from the app) said "all client database access is subject to RLS." ADR-0018 (the atomic program-save RPC) said the programs owner rule governs its writes. Both were saying what should be true; neither was true at the time because the lock was off. Both ADRs now have a short note at the top explaining that their "RLS is enforcing" assumption held only after ADR-0027 turned the lock on. Their core decisions (no service-role key on the client; atomic save via RPC) are unaffected.
CONTEXT updated. Added a new "Auth, identity, and access control" section so the terms, anonymous identity, resolvedUserId, RLS, the Edge Function ownership check, are part of the domain glossary.
Status: docs only. No code, no prod impact. PR to be reviewed and merged.
This is the keystone of the auth work. Up to now the database had no lock on the core tables, workouts, programs, your trainee model, your user row, and your set logs were all readable and writable by any logged-in client, because "row-level security" (the database's own per-user filter) was switched off on them. The two tables that did have it on were either fine (the memory embeddings) or wide open anyway (gym profiles had an "anon full access" rule that let everyone see everything). Slices 1–4 set up real per-user identities and made the server functions check them; this slice finally turns the database lock itself.
One forward migration (plus its documentation-only reverse). It (1) turns on row-level security for the five unprotected tables; (2) adds an "owner access" rule to each that says, in effect, you can only see and only write rows tagged with your own login id, both the read side (USING) and the write side (WITH CHECK), so a client can't even sneak in a row owned by someone else; for set logs (which have no owner column of their own) ownership is traced through the workout session they belong to; for the user table the row's own id is the owner; and (3) replaces the gym-profiles "everyone sees everything" rule with the same owner-only rule. No changes to who's granted access at the coarse level, once these id-based rules are on, the anonymous role matches no rows anyway, so the old grants are harmless. The server functions keep working because they connect with the privileged account that skips these rules and do their own id check (from slice 4).
SQL review against the baseline schema only, I could not run it against a database here (the local Postgres in Docker is down; supabase db lint / migration list both failed with connection-refused, as expected). I read the exact current table, column, and policy names out of the baseline and confirmed every "drop the old rule" line names the real existing rule verbatim, the owner columns are right, and the paren-balanced SQL matches the baseline's style. I also checked the reverse migration exactly undoes the forward one (turns the lock back off on those five tables, drops the new rules, and restores the original gym-profiles "anon full access" rule). Touched only the two migration files and this diary, no app code, no server functions, no other files.
Heads-up for whoever merges this. merging runs db push in CI, which turns the lock on in production, this is the live data-visibility flip. Any rows still tagged with the old placeholder id (not a real login id) become invisible to everyone; that's the accepted alpha data-wipe, and clients must already be on the slice-3 build (which tags data with the real login id) for their data to remain visible.
Slice 4 added the server-side ownership check (the function rejects a request whose login token doesn't match the body's user id). But the end-to-end "smoke" test for the trainee-model function fires a real HTTP request at the served function with no login token, so the new check correctly returned 401, the smoke test failed, and because CI's deploy step only runs when the Edge-Function tests pass, the deploy was skipped and slice 4's check never actually went live. (The slice-4 agent couldn't catch this, the smoke test needs a live local Postgres in Docker, which wasn't available, so it only ran the unit tests.)
The smoke harness's shared postWithRetry helper now attaches an Authorization: Bearer … header carrying a token whose sub equals the request's user_id (the local serve runs with --no-verify-jwt and the code only decodes the token, so the signature is a placeholder). Both smoke cases now pass the ownership gate and reach the orchestrator.
Verified the token the helper builds is accepted by slice 4's real subFromAuthorization decoder (extracts the matching sub). Can't run the full smoke test here (no Docker); CI runs it on merge, and a green Edge-Function-tests job is exactly what re-enables the deploy.
Two of our server functions, the one that updates your trainee model after a workout, and the one that saves your goal, trusted the user_id written in the request body, no questions asked. That meant a logged-in person could put someone else's id in the body and write to that person's data (a classic "insecure direct object reference" hole). And we can't lean on the database's own row-level security to stop it here, because these functions connect with a super-privileged account that bypasses those row rules. So the function itself has to be the bouncer.
Before either function does any database work, it now reads the login token the platform already verified, pulls out the user id baked into that token, and checks it matches the user_id in the request body. If they don't match, it refuses (403). If there's no token, or the token is garbled, or it has no user id, it also refuses (fail-closed, 401), it never quietly lets the write through. The token-reading logic lives in one small shared helper so both functions use exactly the same check. We only read the id from the token, we don't re-check its signature, the platform already did that.
Added unit tests for the shared helper (14) covering the good decode and every fail-closed case, plus four handler tests on each function (mismatch → 403, no token → 401, garbled token → 401, matching id → passes the gate). All pass. The existing tests still pass: shared helpers 390, model validator 21, goal validator 31. The database-integration tests can't run here (they need a local Postgres in Docker, which is down, they failed with connection-refused as expected and will run on CI when merged). Did NOT turn on row-level security, add migrations, touch the app, or deploy, those are other slices / the orchestrator's job.
Until now every install used the same hardcoded placeholder id (…0001) or a random one minted at onboarding to tag all your data. Earlier in this auth work (slice 1) the app started quietly logging itself in anonymously at launch, which gives it a real, stable identity, but nothing was using that identity yet. For the upcoming security lock (slice 5, which will only let you read rows that are tagged with your id), the data has to be keyed to that real login id, not the placeholder.
Two things. (1) The single place the app asks "who am I?" now answers with the real anonymous-login id (read instantly from where slice 1 saved it), falling back to the old placeholder only in the brief moment on a brand-new install before the background login finishes, that fallback is safe right now because the security lock is still off, and it goes away for good once slice 5 lands. (2) Onboarding now writes your user row tagged with that real login id (and skips writing the row entirely if the login somehow hasn't finished yet, rather than saving a row under the placeholder that the security lock would later orphan). Pulled the "who am I?" logic into a small, separately-testable helper so the priority order is pinned down by unit tests.
Build passed (build-exit=0). Added six unit tests for the identity helper (real id wins over the placeholder and over the older mirror; placeholder only when nothing else exists; onboarding refuses to write under the placeholder), all pass. Full suite: 561 run, 10 skipped (the live-API tests, off by default), 1 failure, and that one is the known network-timeout flake unrelated to this change; it passed on its own when re-run. Did NOT turn on the security lock or touch any server functions or migrations, that's later slices.
The redesign draws its charts and gauges by hand, pixel by pixel, a 2px "floor" line that has to read heavier than a 1px "stretch" line, solid dots for real data versus hollow dots for guesses, dashed lines for predictions. None of that shows up when you read the code; you only see if it's wrong by looking at the picture. Until now the app had no way to take a picture of a chart and notice when it changes.
Two things. First, a set of cheap, fast checks that read the exact numbers behind the drawings, that the floor line really is 2px and the stretch line really is 1px, that the prediction dash is the 4-2 pattern, that the spacing scale is 4/8/16/24/32/48, that the "work is dark ink, time is light pencil" colour split always holds. These run on every push and need no saved pictures. Second, the camera itself: a small harness (built on a well-known open-source tool, swift-snapshot-testing, our very first outside dependency, pinned to one exact version) that photographs a component and compares it to a saved reference, failing if anything drifts. The worked example photographs the whole token gallery in light and dim, at normal and largest text sizes.
The fast geometry/number suite builds and passes; the outside dependency resolves and links (the whole app + tests compile against it). The photo suite is intentionally reference-pending and stays off.
One deliberate gap. The reference photos are NOT recorded yet, on purpose. My machine runs a slightly newer Xcode than the build server, and a photo taken on the wrong toolchain would bake in the wrong fonts and subtly-off colours, poisoning every later comparison. So the camera is wired up but parked: the photo tests are switched off by default (behind a APEX_SNAPSHOT_TESTS flag) and a human/CI step on the pinned Xcode must take the first reference photos. The fast number-checks run regardless.
Also fixed a quiet contradiction. The test plan secretly forced the "run the expensive live-API tests" flag on, while a comment in the build server config claimed it was off. Removed the flag from the test plan so the comment is finally true, the live-API tests are genuinely opt-in now.
What. Wrote docs/design/coach-voice.md, a single reference document that
governs the voice and honesty rules for every coach-voiced string in the app:
AI-generated Today lines, post-workout reads, per-set coaching cues and set
framings, and static UI strings (alerts, rest-day cards, calibration notices).
Why. The flow audit (G-F13, #316) found three different registers in three prompts, the inference prompt enforces terse anti-platitude copy, the swap assistant says "Happy to help", the post-workout insights are a third voice, and no single source governed all of them. The rebuild needs one constitution before the first coach-line screen (#348) is written.
What it covers. The honesty laws (grounded-in-numbers, deterministic fallback, no fabricated precision, no echo, witness rule), the banned registers (praise- inflation, hype, mascot-cheer, accent-colored keywords), length and format contracts per surface (Today line, post-workout two-deck read, coaching cue, set framing, swap display_message), and a short "how to apply" section for both prompt authors and UI-string authors. Cites the source docs throughout.
Status: opened as a DRAFT PR (human review required, voice is the product owner's call). Not merged. Part of #330.
The onboarding "days per week" picker only offered 3, 4, 5 and 6. Someone who trains twice a week had no honest option and was forced to claim 3, even though the program engine fully supports 2-day weeks (the phase-advance math handles down to 1 day; the macro-plan prompt builds exactly as many days as you pick) and the redesign spec explicitly lists 2 / 3 / 4 / 5+.
Added 2 to the picker (now 2/3/4/5/6). One-line, no option removed, so nothing regresses. This is the one onboarding item from the audit that's both cheap and not throwaway, the rest of the onboarding cluster is either already handled (the answer-wiring shipped earlier in #339) or belongs to the full onboarding rebuild (#362), and one finding turned out stale (height/age are now used by the coach, so they must NOT be removed).
Build passed (build-exit=0). Pure picker-option change, can't affect other logic.
When you logged a past workout by hand, that session row was saved with no "status" value. But the code that finds your previous bests (for personal records and the "last time" line) filters sessions where status is not "abandoned", and in a database, comparing a missing value to "abandoned" is itself "unknown", not "true", so those hand-logged sessions were silently skipped. Your manual entries never counted toward a PR or showed up as your last-time anchor.
The manual-log save now writes status: "completed" on the session row (it always set completed: true, but the queries filter on status, not that flag). A completed session passes the "not abandoned" filter, so manual workouts now contribute to PR baselines and last-time anchors like any normal session.
Build passed (build-exit=0). The change is a single payload field on an additive struct, so it can't affect other logic; verified by the build plus the query semantics.
the cross-dimension audit found three bugs in the Supabase Edge Functions, the server-side code that turns a finished workout into an updated training model. (1) Each exercise keeps a list of its best recent sets ("top sets"); that list grew forever, one entry per workout, with nothing trimming it, a constant for the cap existed but was never used. (2) The transfer-learning math (how much progress on one lift predicts another) divided by zero when every recorded number was identical, producing "not a number", which silently becomes null when written to the database, corrupting the stored value with no error. (3) The goal-update function did four separate database writes one after another with no shared transaction, so a crash halfway through could leave the user's record half-updated.
(1) After adding new top sets, trim the list to the most recent 10 (the existing constant), newest are at the end, so keep the tail. Pulled the trim into a tiny pure helper so it could be unit-tested. (2) Detect the zero-variance case (all "from" values identical, or all "to" values identical) and return a "no transfer learnable" signal instead of the bad number; the caller then simply doesn't record a fit for that pair, while still keeping the observation so it can learn later once the numbers differ. The guard is narrow, it only triggers on genuinely flat data, not on legitimately weak correlations. (3) Wrapped all four goal-update writes in one transaction so they all succeed or all roll back together; the row-locking reads stay inside that same transaction, so the safety is real.
Deno was available, so I ran the pure-logic tests. Added new unit tests for (1) the trim keeps the correct newest 10 and (2) the math returns the skip-signal for both flat-input cases but still returns a real fit for normal input. All passed. Could not run the database-integration tests for (3)'s atomicity, they need a live local Postgres, and Docker was not running in this environment, so (3) was verified by careful code inspection; CI runs those tests on merge. Confirmed the goal function still type-checks and its only remaining check-error is a pre-existing one on main in a test file I did not touch.
two security gaps found in the cross-dimension audit. First, API keys and auth tokens in the Keychain used kSecAttrAccessibleWhenUnlocked, which lets iOS include them in unencrypted iTunes/Finder device backups, so a user's Anthropic API key could sit in a backup file on their laptop. Second, four service files wrote raw LLM responses and user training history (exercise IDs, session dates) to the device console unconditionally, even in release builds, visible to anyone with a Mac and a USB cable.
switched the Keychain accessibility to kSecAttrAccessibleWhenUnlockedThisDeviceOnly so items are excluded from backups. Wrapped seven sensitive print calls in #if DEBUG … #endif so they compile out of release builds, the four raw-LLM-response / training-history logs the audit cited, plus three more found by a follow-up grep (two non-canonical-exercise-id warnings and the macro-skeleton log that includes the user's historical day labels). InferenceSpike also logs prescription details unconditionally, but it is dead code (run() has no production caller), so its prints never execute in a real build, left in place and flagged, not gated. The Keychain change only applies to items stored after the update, existing items in a dev's Keychain keep the old attribute until they re-store the key (fresh install, or clear + re-enter via Developer Settings). That is acceptable for alpha. Two new Keychain tests confirm the round-trip still works and that the correct attribute is set.
build passed (exit 0). Full test suite ran, all Keychain round-trip tests pass, including the two new ones. Logging changes are not unit-testable (no observable side effect to assert), so they were verified by code inspection.
the same cross-dimension audit (issue #369) found four subtle threading problems in the actor that runs a live workout and in the screen that reads from it. None of them showed up every time, they only bite when two things happen close together.
Ending a session could run its wrap-up twice. The rest timer can fire "the session is over" at almost the same moment the user taps "end early". Both reached the same finish routine, and each one bumped the saved session count and queued a learning-update for the AI. So one workout could count as two. Fix: a one-way latch flipped the instant the finish routine starts, the second caller just returns. The latch is cleared whenever a brand-new session begins, so the next workout still counts.
The "Retry" button could apply a stale answer. When the AI fails and the user taps Retry, the app asks again, but if the user moved on (finished a set, skipped, or swapped the exercise) while that retry was still in flight, the late answer used to overwrite the newer state. Fix: the retry now remembers which "generation" of the session it belongs to and throws its own answer away if the session has moved on, exactly like the normal inference path already did.
Swapping an exercise (or resuming a paused session) didn't cancel the old in-flight answer. The app already had a "generation" counter that invalidates stale answers, but swap and resume never advanced it, so an answer meant for the old exercise could land on the new one. Fix: bump the counter at both points. (Judgment call on resume, documented in code + PR: bumping rather than resetting to zero catches the most common straggler, paused right as the first answer was coming back; a fully bulletproof version needs a wider change and is noted as deferred.)
The live screen read the actor one field at a time, about nine separate hops. Between hops the actor could change, so the screen could show, say, a prescription from one moment next to a state from another. Fix: one method on the actor returns all the fields at once, so the screen always paints a single consistent moment.
built clean (build-exit=0). Full suite green, 553 XCTest cases (543 passed, 0 failed, 10 skipped live-API/integration tests) plus 293 Swift Testing tests (0 failed). Four new tests: end-session runs its side effects once, the retry guard drops a stale result after a swap bumps the generation, the latch resets for the next session, and the one-hop snapshot matches the individual fields. The hard-to-reproduce timing races are pinned with deterministic latch/guard tests rather than flaky sleeps.
a cross-dimension audit (issue #369) found five efficiency problems that were silently burning CPU and memory on every session.
WorkoutView called traineeModelService.digest() three times back-to-back to read three different fields, decoding the full model each time. Fix: one let digest = await ... at the top, then read all three fields from the local copy. Same pattern applied to the two onDismiss closures.
LiveSessionWatcher polled the session actor every 500 ms for the full lifetime of the app, even when no workout was running. That is 2 actor hops per second, all day. Fix: keep polling at 500 ms while a session is active or paused; slow to 5 s when idle. The observable properties stay the same; views see no difference.
ProgressViewModel re-fetched 90 days of sessions and all set_logs every time the Progress tab appeared, with no caching. Fix: cache the result, but reuse it only when it's both within a 5-minute window AND no session has been logged since the last load, the cache compares the session counter that every finished or manually-logged workout already bumps. So a just-finished workout always appears on the next Progress visit (no stale window), while flipping tabs mid-session skips the redundant 90-day fetch.
ProgressSessionRow.date allocated up to three DateFormatter/ISO8601DateFormatter objects on every call to .date, and .date was called twice per row. Fix: hoist all three formatters to static let so they are created once per process lifetime. DateFormatter is thread-safe for read-only use after setup.
WriteAheadQueue called persistQueue() after every single item during a batch flush, encoding and writing the entire queue array to UserDefaults N times for N items, making flushing O(N²). Fix: remove all per-item persists inside the loop. The defer at the end of flush() writes the queue once after the whole batch. Dead-letter items are still persisted immediately (separate store, crash-safety). On a crash mid-flush, successfully-sent items may be re-sent on the next launch, but set_log inserts are idempotent by UUID primary key, so no data is lost or duplicated in a user-visible way.
built clean (build-exit=0). Ran the full test suite, all existing WriteAheadQueue tests passed. Two new WAQ tests verify the batch-flush end-state (empty queue, all items sent, persisted state also empty) and the permanent-failure dead-letter path. One pre-existing live-API flake unrelated to these changes.
a clean install had no Supabase anon key, so the SupabaseClient was created with an empty string and any network call to Supabase would fail. The Anthropic key already had a bundled-key mechanism (PR #368), but the Supabase anon key did not.
mirrored the existing Anthropic bundling pattern for the Supabase anon key. Added APEXSupabaseAnonKey to Info.plist expanded from a new SUPABASE_ANON_KEY build variable in the xcconfig files. Added BundledAPIKey.supabaseAnon() and SupabaseAnonKeyResolver (same Keychain-first precedence as the Anthropic resolver). AppDependencies now resolves the anon key through the resolver instead of a bare Keychain lookup, seeding it into the Keychain on first run. The launch gate in ContentView was extended from "AI key required" to "both keys required", either missing triggers NeedsSetupView.
built with no real key in place (build-exit=0). Ran the full test suite, all 23 tests in BundledKeyResolutionTests passed (9 new tests for the Supabase path). One pre-existing timing flake in AIInferenceServiceTests unrelated to this change.
Right now the app talks to our database using one shared "house key" (the anon key)
On launch the app now asks our auth service for an anonymous sign-in, like getting a wristband at the door without giving your name. It tucks the resulting token away safely (in the iPhone's secure Keychain) and uses it on its database calls. Next time you open the app it reuses the same wristband instead of grabbing a new one, so you stay the same "person" across launches. The token also quietly refreshes itself when it's about to expire.
Crucially, this is built to fail softly. If the sign-in can't happen, for example because we haven't flipped the "allow anonymous sign-ins" switch on the dashboard yet, or the network is slow, the app shrugs and carries on exactly as it does today with the shared house key. It never blocks the app waiting. So nothing about how the app behaves changes in this step: it still reads and writes the same data the same way. We did NOT switch over which user-id the app uses, and we did NOT turn on the per-person data locks, those are deliberately later steps.
Wrote tests that fake the auth server (no real network): a successful sign-in saves the token and sets it; a relaunch with a saved token reuses it without signing in again; a rejected sign-in leaves things on the old shared-key behavior without crashing or hanging; and the token-refresh / retry-after-expiry path works. Full app test suite still green (532 ran, 0 failures, 10 skipped, the usual live-API ones that need real credentials). The build is clean.
The trainee model learns from your workout data, but three bugs meant it was learning almost nothing in production.
First, every set you complete gets sent to an Edge Function that updates your model. That payload was missing the AI's original prescription, without it, the Edge Function's accuracy-learning loop hit a guard and bailed out immediately. The learning loop has never run since it was written.
Second, because the prescription was never sent, the weekly fatigue signals (deload triggers, rep-rate alarms) could never fire either. They depend on comparing what the AI prescribed to what you actually did. With no prescription in the data, every set looked like a no-prescription set and the signals stayed at zero.
Third, the muscle-volume breakdown (setsPerPrimaryMuscle) was being sent to the AI digest as a flat alternating array like ["chest", 4, "quads", 6] instead of a proper JSON object {"chest": 4, "quads": 6}. The AI can't reason about a flat array the way it can a named object.
Added ai_prescribed to the set-log payload that goes to the Edge Function, it's the same nested prescription object the EF already knows how to read (intent, reps, user_corrected_weight). Added a custom encode(to:) and init(from:) to WeekFatigueSignals so the muscle-volume map serialises as an object (using the same encodeEnumKeyedDict helper already used in TraineeModel). Also added an explicit memberwise init since the custom Codable init suppresses the synthesised one.
Added 6 new tests: two prove the ai_prescribed field is present (or correctly absent) in the encoded WAQ payload; two prove the deload signals fire when a prescription is present and stay silent when it isn't; two prove the muscle-volume field round-trips as a JSON object. All 276 tests pass (build-exit=0).
If someone installed the app fresh, they could never finish setting it up. The app needs a secret "key" to talk to the AI (for scanning your gym and building your program). But the only way to put that key in was a hidden developer screen buried inside setup, so on a clean phone there was no key, and setup would chug along for about ten minutes until the gym-scan step suddenly died with an ugly error.
Two things. First, the app can now carry a key baked into the build itself, so a fresh install just works. The real key is never stored in the project's shared code
Second, if there genuinely is no key (someone built it without setting one up), the app no longer lets you wander into a doomed setup. Instead it shows a calm, honest "This build needs setup" screen right at launch, explaining the build is missing its configuration and to contact the developer. When a key is present, nothing changes at all, the normal app opens as usual.
The redesign moves the app from four bottom tabs (Program, Workout, Progress, Settings) to three, Today, Train, Progress, with Settings tucked into a corner button instead of a tab. I built that new three-tab layout in code, drawn in the redesign's own colours and fonts (yesterday's building blocks), with a little corner gear for settings.
The important part: it's switched off by default. The app still runs the old four-tab screen exactly as before. There's a single on/off switch in the code, set to "off". That's on purpose, turning it on means moving some delicate plumbing (the first-time setup screen, crash recovery, and "resume a paused workout"), and that deserves its own careful, separately-tested step later. So this slice lays the new layout down next to the old one without disturbing any of that plumbing.
A few things worth calling out simply:
I wrote five small tests for the translator first, and they all pass. The whole app still builds cleanly with no warnings, and I confirmed I didn't touch the old main screen at all.