oyware [email protected]

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

2026-09-25React 19, Fastify 5, Prisma 6, PostgreSQL 16, headless Chrome over CDP

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:

  1. 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".
  2. Write a browser flow that does exactly that, at phone width, and watch it fail on the current code with those numbers.
  3. Fix it. The flow passes.
  4. 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, then v2-features, then landing back 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:


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 find step={2.5} or max={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 removeQueries and invalidateQueries both 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


What I would tell myself on 13 September