Twelve days of the gym tracker in real use
What lifting with it broke, and how each fix was proven — a redesign by parallel agents, lost weights, a clock that lied, and fifteen bugs in one morning
What the gym tracker is built from describes the app on 13 September, the day it was finished. This is what happened next: I took it to the gym.
Tests had passed. The browser flows had passed. And within a day of real use it lost my weights mid-workout, counted sixteen minutes I had spent reading the plan as training, and stepped a pounds dumbbell by 2.5. None of those were visible from a desk. All of them were obvious with a bar in your hands.
The twelve days in numbers
Commits on main (13–25 Sep, after the stack guide) |
55, of which 49 are not merges |
| Pull requests | 9 — five from the Linear agent, four from ordinary sessions |
| Files changed | 142, +11,761 / −2,822 lines |
| New database migrations | 5 (per-exercise unit, profile, password reset, first finish time, swap/skip) |
| Browser flows | 7 → 17 |
| Test cases | 87 API, 186 web |
The rule that came out of it
Every section below follows the same loop, and it is worth stating once:
- Something feels wrong at the gym. Write down the exact numbers — "ticked set 1 at 50, opened History, came back, sets 2 and 3 said 45".
- Write a browser flow that does exactly that, at phone width, and watch it fail on the current code with those numbers.
- Fix it. The flow passes.
- Deploy, then run the same flow on production as a throwaway test account.
Step 2 is the one people skip, and it is the one that matters. A flow written after the fix proves only that the fix does what I think it does. A flow that fails first proves it reproduces what actually happened.
The redesign: one agent per screen group
On 14 September every screen moved to Iron & Plate — the design from the landing page, carried into the app: iron-dark surfaces, one accent called "plate", numbers set in a mono face like stamped steel. The design came from Claude Design as HTML exports with a PNG per artboard, and each screen group got its own brief.
Rebuilding ~13 screens one after another is a day of work. I split it instead:
| Group | Merge commit |
|---|---|
| Log in, create account, Settings | acf6dbd |
| Today | 26b81a3 |
| Plans, plan editor, import, Build me a plan | 5939222 |
| Day screen (workout logging), past workout | 74ed8dc |
| Progress, Calendar, Exercise page, Cardio | 5b56f85 |
Each group was one agent, working in its own git worktree on its own branch.
The shared primitives went in first, on main, so no two agents invented their own
button: plate and danger buttons, an xl input, an eyebrow label, a segmented
control, a bottom sheet with a title and Cancel, a large stepper. Then the agents
ran in parallel, I read their screenshots, merged one group at a time, and ran every
flow after each merge.
What made parallel agents actually work
Agents in worktrees share one machine, and the defaults collide:
| Collision | Fix |
|---|---|
Every Vite dev server wants :5173 |
a port per agent |
| Every flow opened Chrome's DevTools on the same port | CDP_PORT env var in cdp.mjs (commit 04efec9), a port per agent |
| Five agents signing up flow users hit the auth limit (10 per minute per IP) | one shared dev API started with a raised authRateLimitMax — dev only |
| One Chrome profile dir per run | profile under each agent's own scratch dir, deleted after |
The rate limit is a real trap for flows. Run
tour, thenv2-features, thenlandingback to back and the third one fails at signup — not because anything is broken, but because three fresh accounts in one minute from one IP is exactly what the limiter exists to stop. Space them out, or raise the limit on your local API.
What the redesign broke
Rebuilding a screen is a chance to lose behaviour nobody wrote a test for. Three things slipped and were caught by flows the same day:
- "Remove set" on a set that was still saving (
b7e392c). Tapping it hid the row, but the queued request still went out and the set came back on the next load. It is now offered only for a set nobody has ticked.extra-set.mjscovers it. - Log out landed on the login screen instead of the landing page. The logout
sequence depends on one animation frame (below), and the rebuilt Settings added a
synchronous call in between. The drop of the user moved into the route guard
(
66c6572). - The import flow could not find library results because the redesigned search
field wrapped its input in one more element. A selector fix (
7c8ad46), but a reminder that a flow keyed on DOM structure breaks with the design.
Lost weights: the card that forgot what it was told
The first real bug, from a real workout on 14 September.
What happened. Bench press, three sets. Last week was 45 kg. I ticked set 1 at 50 — the app carries that weight down, so sets 2 and 3 now read 50. Then I opened the exercise's History from its sheet to check something. Came back. Sets 2 and 3 read 45 again. The same thing happened on opening a YouTube link from the sheet, or when the phone dropped the installed app into the background.
Why. The rows of a card that were not saved yet — the carried 50s, anything typed by hand — lived only in React state. Any remount of the card rebuilt its rows from two sources: the sets the server had, and last time. The unsaved middle layer simply did not exist anywhere that survived.
The fix (843addc) gives that layer a home: a draft per workout, per exercise, in
localStorage, written on every change.
// web/src/lib/setDraft.ts (the shape)
interface DraftRow {
setNumber: number;
weightKg: number; // always kg, whatever the screen shows
reps: number;
weightTouched: boolean; // typed by hand: a tick above must not overwrite it
}
// key "gym:setDrafts:v1" → { [sessionId]: { touchedAt, exercises: { [exerciseId]: DraftRow[] } } }
When a card builds its rows, the order of trust is now:
| Priority | Source |
|---|---|
| 1 | a set the server has saved |
| 2 | a set queued offline, not sent yet |
| 3 | the draft |
| 4 | last time |
Finishing a workout clears its draft. Logging out clears all of them. A draft nobody touched for 48 hours is dropped on the next read, so a workout abandoned last week cannot resurface its numbers.
The draft is stored in kilograms, not in whatever the screen shows, which is what let the next fix work without touching it.
set-draft.mjs does exactly what I did — tick set 1 heavier, open History, come
back, reload — and on the old code fails with the weights I saw. On 25 September a
refinement landed (fb7a847): a tick now carries its weight only into sets still
empty below it. A set already pre-filled from last time keeps its own number,
because a lifter who planned 50/50/47.5 does not want a tick on set 1 to flatten it.
Pounds on one exercise, and a stepper that did not know
My gym marks its dumbbells in pounds and its machines in kilograms. The account
already had a kg/lb setting; that was not enough. Since 8acc1ea each exercise has
its own weightUnit — kg, lb, or null to follow Settings — switched with one
tap in the exercise sheet.
Storage is kilograms everywhere, as before. The unit decides only what a screen shows and what a typed number means. The rule that made it manageable:
A weight shown or typed for a named exercise uses that exercise's unit. Totals across exercises, body weight and the CSV stay in the account's unit.
That rule touched 25 files — the day card, last time, PB, the hint, the plate calculator, the workout page, the chart, Home, Progress, the finish summary. Two bugs fell out on the way: a set saved in pounds never showed its tick (the card compared the typed pounds with the stored kilograms, so they never matched), and three labels said "kg" in plain text.
The one it missed
The next morning, a pounds dumbbell: 50 lb, press +, and it read 52.5. Save,
reopen: 52. The stepper on the day card had step={2.5} hard-coded, from the days
when everything was kilograms. 52.5 lb was stored as 23.81 kg and, since pounds were
shown whole, read back as 52.
The workout page and the plate calculator already used weightStep(unit) — 2.5 kg or
5 lb. The day card did not (f9d1f37). exercise-unit.mjs now presses + and − on a
pounds card and a kilograms card side by side, and fails on the old step with
"press + gives 52.5".
When a unit flows through a screen, check the controls, not only the labels. A search for
"kg"finds the text. It does not findstep={2.5}ormax={999.75}.
Pounds shown whole caused one more bug, found on 25 September: 17.5 lb read as 18,
so the card always saw "typed 18, saved 17.5 → changed", and ticking the set again
saved 18. Pounds are now shown to the half pound, and the card compares weights as
the user reads them (sameWeight), so the kg ↔ lb round trip can never mark a set
as edited.
The workout clock started when I opened the screen
15 September, Day 1. I opened the day, read through the plan, set up the first machine, ticked the first set — and the header clock read 16:00.
Why. Opening a day screen get-or-creates the workout (POST /sessions), and that
stamps startedAt. So startedAt was when the screen opened, and the clock, the
finish summary and the workout page all measured from it. The database showed gaps of
16.3 and 24.9 minutes between opening and the first set on two workouts.
The fix (feabfe4) measures from the first set instead. Each set now carries
loggedAt — when it was first ticked, kept on a re-tick — and:
// web/src/lib/workoutTime.ts
/** When the earliest set was logged; null until one is. */
export function firstSetAt(items: Timed[]): string | null { … }
/** Whole minutes from the first set to finishing; 0 while unfinished or empty. */
export function workoutMinutes(items: Timed[], finishedAt: string | null): number { … }
Old workouts were corrected too, with no migration — their sets already had a
creation time. The clock reads from 0:00 at the first tick, 1:18:05 rather than
78:05 past an hour, and durations read "1 h 18 min". startedAt is still in the
API, but nothing measures from it.
workout-clock.mjs waits a real 75 seconds before the first tick. That is slow,
but it needs no database access to fake an old timestamp, so it runs on production as
the test account.
And then the clocks disagreed
The first run of that flow was 15 seconds off. The Mac's clock was ~15 s slow
that day (sntp said +14.9 s); the server is NTP-synced. The clock was computing
phone's now − server's loggedAt, so any phone with a wrong clock reads wrong: a
phone 36 s fast shows 0:37 two seconds after the first tick.
The 25 September fix (serverClock.ts) keeps the server's time on the phone, from
the Date header on every API answer:
// Each answer bounds the offset: the header is whole seconds, stamped
// somewhere between sending and receiving.
const lo = stamped - receivedAt;
const hi = stamped + 1000 - sentAt;
low = Math.max(low, lo);
high = Math.min(high, hi);
// now() = phoneNow + (low + high) / 2
One answer only says "the server is between −0.2 s and +0.9 s ahead". Every answer narrows the window, and after a few requests it is under a second. If a new answer disagrees with everything before — the phone's clock was just set — it starts over. The flow now ticks the first set with the page clock pushed 36 s fast and allows for no skew.
Forgot password, briefly
On 20 September the app got a way back in: a six-digit code by email, typed into the app, not a link — because a link tapped in Mail on iOS opens Safari, and the reset lands in Safari's session while the installed app stays logged out. The server cannot send mail itself, so it goes through Brevo's HTTP API.
The whole story — the blocked SMTP ports, DNS, the timing leak, the rate limits — is
in Email from a server that cannot send it. One follow-up
belongs here: the 25 September sweep found that ten wrong guesses fired at once cost
one try, because each request read attempts, ran the slow argon2 check, then wrote
attempts + 1. Now one conditional update spends the try before the check, and
deleting the reset row is what claims it, so the right code sent twice sets one
password.
Tickets from Linear, and why merged is not live
From 15 September small changes came in as Linear tickets, picked up by an agent that opens a pull request (the setup is in Tickets into pull requests). Five of the nine PRs came that way:
| PR | Ticket |
|---|---|
| #1 | README: a "Working with agents" section |
| #2 | Log out asks before it logs you out |
| #3 | Screenshot import reads a plan written out by hand |
| #4 | Type a machine number by hand; long names stay in their column |
| #5 | A profile: who you are and what you train for |
The lesson was not about the agent. I merged #4 and #5 on GitHub and they did not
appear on the site. Of course not: merging updates main on GitHub. Nothing pulls it
to the Mac and nothing runs ./scripts/deploy.sh. The deploy builds on the Mac and
runs the tests there, and #5 added a Prisma migration — so the first deploy attempt
failed with 500s on every API test, because the local test database did not have
the new column.
The steps, now written into the repo's CLAUDE.md for any agent:
gh pr merge <n> --squash
git fetch origin main && git checkout --detach origin/main # deploy main, not the branch
# if the PR adds a migration:
(cd api && npx prisma generate && set -a && . ./.env.test && npx prisma migrate deploy)
./scripts/deploy.sh # must end "Deployed <sha> to https://gym.oyware.com"
And the rule since 18 September (8200b54): a ticket opens a PR and stops. It is
merged and deployed only when I say "deploy" on the issue. "Done when it works" is not
a deploy instruction.
Fifteen bugs in one morning
On 25 September I ran a full tour of the live app plus a code read, looking for
anything wrong rather than anything specific. It found fifteen bugs. They went into
one PR (#7, 7cf25d4, 58 files) — but they were fixed by five agents in parallel,
each in its own worktree, each given a group of related bugs.
A selection, because each one is a pattern:
| Bug | Cause | Fix |
|---|---|---|
| Reopen a one-hour workout at 22:00 to fix a number → saved as four hours | reopening cleared finishedAt; finishing again took the new time |
keep firstFinishedAt; finishing after a reopen goes back to it (or the last set, if later) |
| Tick 55 after 50 was queued offline → card flips back to 50 | the queue flushed the old copy afterwards and patched the card | a direct save supersedes the queued copy; answers for a replaced copy are ignored |
| Train past midnight → Home moves on, the workout is orphaned | a day opened after 00:00 made a new empty workout dated today | an unfinished workout from yesterday touched in the last 3 h is still the workout |
| Copy a plan at 01:00 → dated yesterday | the route fell back to the server's UTC date | the phone sends its own today |
| Day screen said PB 45 × 11, Progress said 47.5 × 8 | two PB rules: one filtered by the plan's rep range | one rule everywhere: heaviest weight for ≥ 1 rep |
| Hint told a 45 lb lift to try 51 lb | the hint always added 2.5 kg | step to the next 5 lb in a pounds unit |
| Finish with 9 of 10 sets unticked → ended at once | Finish sits beside Back | it asks: "Finish with 9 sets not done?" |
| "12" beside the Settings title | a decorative plate from the design; in the app a plate means a machine number | removed where it could read as data |
Three of the fifteen are the same bug in different clothes: "today" computed
somewhere other than the phone. Jordan is UTC+3, so from midnight to 3 a.m. the
server's date is yesterday. The flows had the same bug for a while — they dated
"today" with toISOString(), and every flow run between midnight and 3 a.m. failed on
data it had just written. They use toLocaleDateString("en-CA") now.
Parallel agents need a database each
One thing had to be set up first. Every API test runs against a real Postgres
database and clears its tables before it starts, and all five worktrees pointed at the
same gym_test. Run side by side, one agent's setup would wipe another's rows
mid-test, and passing tests would fail for no reason in their own code.
So each agent got its own — gym_test_a through gym_test_e — with migrations
applied and its own .env.test. Then the five branches merged cleanly into one PR.
Anything a test resets is shared state. Worktrees isolate files, not databases, ports or rate limits. List what each agent's tests touch before running them side by side.
Swap or skip, for today only
The machine is taken; the plan says leg curl; the hack squat is free. Until 25 September the only answer was to add hack squat as an extra and leave leg curl unticked forever, which made the workout look unfinished and the history wrong.
PR #8 adds a ⋯ menu on each planned card: Swap exercise or Skip today, until a set is ticked on it. It changes this workout only; the plan is untouched.
It is one small table:
model SessionExerciseChange {
sessionId String
planExerciseId String
kind String // "swap" | "skip"
exerciseId String? // the exercise done instead, for a swap
// cascades with the workout, the plan slot and the swapped-in exercise
}
PUT / DELETE /api/sessions/:id/changes/:planExerciseId set and undo one, and refuse
with 409 once the slot has sets, on a finished workout, or for an exercise already
in the workout. The server builds the day's view with the changes applied: a
swapped-in exercise takes the slot with the plan's sets and reps, but its own
machine, last time, PB and hint, under "Instead of Leg curling · Undo". A skipped card
folds to one line and leaves the set count, the finish question and Home's progress.
Both are saved on the server first. With no signal, a toast says so and nothing changes — unlike ticking a set, a swap is not worth an offline queue.
Discard, and the workout that came back
Same day: Discard workout on the day screen, for a workout opened by mistake. It
asks, deletes the workout and its sets, stops the rest timer and goes Home
(bc9847b). The flow passed.
Then it turned out Home still showed a workout for today — an empty one. Discard was deleting the workout, and then the day screen created a new one on its way out.
The day screen's workout comes from a query that get-or-creates: fetching it creates the workout if there is none. After deleting, the code removed that query from the cache — and a removed query that is still mounted refetches. It asked the server for today's workout, there was none, so the server made one.
// DayPage.tsx (#9, 4ac6fb0)
const [discarded, setDiscarded] = useState(false);
const start = useQuery({
queryKey: ["day-session", planDayId, date],
queryFn: /* POST /sessions: get or create */,
staleTime: Infinity,
enabled: Boolean(planDayId) && !discarded, // nothing may re-create it now
});
A query with a side effect must not be invalidated while mounted. TanStack Query's
removeQueriesandinvalidateQueriesboth refetch anything still observing. For a read, that is what you want. For a get-or-create, it undoes the delete you just made. Disable it instead.
The first version of the flow checked that the day screen went Home. It did. It now also checks that Home has no workout left — the check that would have caught it.
Browser flows: what they are, and the traps
The flows are the reason any of this is trustworthy. There are 17 now, one per
feature or per bug, plus a shared cdp.mjs:
cardio discard-workout exercise-unit extra-set import landing
machine-number password-reset profile set-draft swap-exercise
theme tour units v2-features workout-clock
Each is a plain Node script that drives headless Chrome over the DevTools Protocol at 390 px wide, in a fresh profile, and after every action compares the DOM with the API, then reloads and compares again. No test framework: a failed check prints what it saw and exits non-zero.
| Kind | Where it runs |
|---|---|
Signs up its own flow-<time>@example.com user |
local dev only |
| Changes the password or deletes the account | local dev only, always |
Takes EMAIL / PASS and cleans up after itself |
also production, as a throwaway test account — never my own, because even opening a day screen writes a workout |
Traps that each cost an hour
- A success toast repeats the row's text.
waitFor(text)passes the moment the toast appears, before the list has refetched. Wait for the row's own element — its edit button'saria-label— instead. - Headless Chrome paints only when asked. A lazily loaded image never loads until a
frame is drawn, and
requestAnimationFramenever fires. Take a screenshot to force a frame, then wait onnaturalWidth— or a working animation reads as 0×0. The logout sequence depends on one rAF, so the landing flow screenshots before tapping Log in. - A Web Worker's requests are not the page's. The page's Network domain never showed
the OCR engine download, because it happened inside a worker.
cdp.mjsattaches to worker targets too. - Radix tabs switch on
mousedown, notclick. A synthetic click does nothing. - A freshly started Vite can fail a flow once.
tour.mjsfailed 17 of 32 steps on "no library results" — the library comes from a CDN on first load — and passed 32 of 32 on the rerun. Rerun before blaming a change. - Two sessions, one local API. Another session had the dev API running from
main's source without watch mode. After a migration, my flows failed against a server that did not know the new column. Check what is serving:3002before trusting a red run.
What I would tell myself on 13 September
- Use it for real before calling it done. Four of the worst bugs here needed a phone in a gym: a remount from switching apps, a pounds dumbbell, sixteen minutes of reading, a workout past midnight.
- Write down the numbers when it goes wrong. "It lost my weight" is not reproducible. "50 ticked, History, back, 45" is a flow.
- The flow fails first, or it proves nothing.
- Parallel agents are cheap; shared state is not. Ports, DevTools ports, rate limits, test databases. Worktrees only separate the files.
- Merged is not deployed. Something has to pull, migrate the test database, build and ship — and one person decides when.