oyware [email protected]

What the gym tracker is built from

A tour of the stack — the data model that outlives the plan, OCR on the phone, the offline queue, and the bugs worth learning from

2026-09-13TypeScript, React 19, Tailwind v4, shadcn/ui, Fastify 5, Prisma 6, PostgreSQL 16

Why it exists, and the one decision everything hangs off

My gym's trainer replaces the workout plan about every six weeks. The plan arrives in the gym's own app as a list per day — exercise name, sets, rep range, machine number — and that app records nothing about what I actually lifted. A week later I do not remember whether that machine was 10 kg or 15 kg.

So the job is narrow: log the weight for every set, and keep that history when the plan underneath it is thrown away.

That sentence decides the data model. If sets hung off a plan, replacing the plan would orphan or delete the history. Instead history hangs off the exercise: a plan points at exercises, a logged set points at an exercise, and deleting a plan only unhooks the workouts that were done from it. "Leg press" in the new plan is the same row as "Leg press" in the old one, so its history just continues.

It is the second full-stack app on the same droplet, after EpisodeTracker, and was built between 11 and 13 September 2026.

JSON API endpoints 44
Database tables 11
Screens in the app 13
Lines of my code ~12,700
Tests 56 API, 154 web, plus 6 browser flows
Commits 46

That line count is 2,603 lines of API, 9,897 of web and 201 of database schema. It excludes the 1,313 lines of shadcn/ui components copied into the repo, and the tests: 2,407 lines of unit and integration tests and 1,821 of browser flows.

The stack, layer by layer

Layer Technology What it does here
Language TypeScript Both sides. The API is pinned to 5.9; the web app is on 6.0.
UI React 19 Every screen. Mobile-first: it is used one-handed, between sets.
Components shadcn/ui on Radix 16 components (sheet, dialog, tabs, calendar…) copied into components/ui/, not installed as a dependency. They are my code to change.
Styling Tailwind CSS v4 Utility classes, plus a handful of CSS tokens that the whole theme hangs off.
Server state TanStack Query 5 Caches every API read, refetches after writes. Screens never hold fetched data in their own state.
Routing (web) react-router-dom 7 /day/:planDayId, /plans/import, /progress and the rest, without page reloads.
Charts Recharts 3 The 12-week chart and each exercise's weight and volume over time.
Build tool Vite 8 Dev server on :5173, proxying /api so development is same-origin too.
Installable app vite-plugin-pwa + Workbox With its own service worker (src/sw.ts) rather than the generated one — the reason is below.
OCR tesseract.js 7 Reads screenshots of the gym's app inside the browser. The server never sees an image.
Server Fastify 5 Every /api/* request. Unlike EpisodeTracker, it serves no HTML — Nginx does that.
Validation zod 4 Every body and query string is parsed. A failure is a 400 that names the field.
Database access Prisma 6.19 Schema, migrations, typed queries. Pinned deliberately; the CLI's upgrade nag is ignored.
Database PostgreSQL 16 Same server as EpisodeTracker, its own database and user, localhost only.
Auth argon2id + session rows @node-rs/argon2 for hashing, @fastify/cookie, and @fastify/rate-limit on anything that checks a password.
Tests node:test, Vitest, headless Chrome Real Postgres for the API, Vitest for pure web logic, CDP-driven browser flows for everything a user does.
Edge Cloudflare + Nginx DNS, TLS, proxy — and one very instructive caching bug.

The API has 7 runtime dependencies. The web app has 19, most of them the UI kit's own parts (Radix, the icon set, fonts). The hard constraints were written down before any code: no paid APIs, no API keys of any kind, no exercise media stored on the server, and nothing that needs Redis, a queue or Docker on a 1 GB machine.

What happens when you tick a set

This is the one interaction that has to work every time, on bad gym signal, with a sweaty thumb.

  1. Opening a day sends POST /sessions with the plan day and the phone's calendar date — not the server's. It is get-or-create: a unique key on (user, plan day, date) means a double tap on Start cannot make two workouts, and coming back to the day later continues the same one.
  2. The tick. The rest timer starts and the phone vibrates before anything touches the network — the rest begins when the set ends, not when the server answers. The weight just lifted is carried forward into the sets below it.
  3. The number is converted to kilograms (units.ts), whatever unit the screen shows, and posted to POST /sessions/:id/sets.
  4. The fetch wrapper gives every request a 10-second deadline. Gym Wi-Fi is often "connected" but dead; without a deadline the request hangs for a minute and the set never reaches the offline queue.
  5. Cloudflare, then Nginx on the droplet. Nginx serves the built web app from disk and proxies only /api/ to 127.0.0.1:3002. The API binds to loopback, so nothing reaches Node without passing through Nginx.
  6. requireUser, an onRequest hook on every protected route, reads the gym_sid cookie, computes its HMAC, finds the matching session row and attaches the user. If the login is past half its 90-day life, it is quietly renewed.
  7. The route parses the body with zod, checks the workout and the exercise both belong to this user, then upserts on (workout, exercise, set number). Ticking the same set twice overwrites it; it never duplicates.
  8. An onSend hook stamps Cache-Control: no-store on the response. That line exists because of the worst bug this app shipped — see the last section.
  9. Back on the phone, the row turns saved, and if the set beats the previous best at the plan's minimum reps, it says so.

When the signal drops

If the request gets no answer at all — no connection, or the 10-second deadline — the set goes into a queue in localStorage instead, the row is marked queued, and the phone says the set is kept and will be sent.

The queue (setQueue.ts) retries when the browser fires online, when the tab becomes visible again, and every 8 seconds while anything is waiting. Three details make it safe:

Two folders, one contract

Not a workspace monorepo this time — two plain folders side by side, each with its own package.json, and a root npm run dev that starts both.

Path Contents
api/ Fastify app split by feature under src/routes/ (auth, me, home, sessions, exercises, plans, progress, cardio), pure logic in src/domain/, and prisma/ with 7 migrations. Biggest file: routes/plans.ts, 276 lines.
web/ 13 pages, the components, and src/lib/ — where the interesting code lives: the parser, the matcher, the queue, units, theme, the plan generator. Biggest file: PlanEditorPage.tsx, 706 lines.
deploy/ The Nginx server block and systemd unit, exactly as installed on the droplet.
scripts/ The deploy script and the browser flows.

api/src/contract.ts is the only definition of every JSON shape. The web app imports it with import type through an @api/contract alias, so types cross the boundary and no runtime code does. Change a response shape and the web app stops compiling.

All 44 API routes

Everything is under /api. Apart from health and the four sign-in routes, every route requires a session.

Accounts and settings

Method Path What it does
GET /auth/config Whether signup needs an invite code, so the form knows to ask.
POST /auth/signup Create an account and start a session. Rate limited.
POST /auth/login Check the password, set the httpOnly cookie. Rate limited.
POST /auth/logout Delete the session row and clear the cookie.
GET /me Who am I, with every setting.
PATCH /settings Rest timer, weekly goal, week start, accent colour, unit, light or dark.
POST /auth/password Change password. Ends every other login, so a lost phone is logged out.
DELETE /me Delete the account and everything in it. Needs the password.

Today and workouts

Method Path What it does
GET /home The Today screen: active plan, which day is next, this week's days trained, last time's numbers and a progression hint per exercise.
POST /sessions Start or continue the workout for a plan day and date.
GET /sessions Workouts with at least one set in a date range, for the calendar.
GET /sessions/:id One workout in full.
PATCH /sessions/:id Finish, reopen, or add notes. Finishing twice keeps the first time.
DELETE /sessions/:id Delete a workout opened by mistake.
POST /sessions/:id/sets Save a set — the upsert above.
PATCH /sets/:id Correct a set's weight or reps.
DELETE /sets/:id Remove a set.

Exercises

Method Path What it does
GET /exercises Your exercises, optionally searched by name or code.
POST /exercises Create, or return the existing one with that name.
PATCH /exercises/:id Rename, set the gym's code or machine number, link a library animation.
DELETE /exercises/:id Only when nothing has been logged against it.
POST /exercises/:id/merge Fold a duplicate into another: sets move and renumber, plan slots repoint, blanks are filled.
GET /exercises/:id/history Every workout it appeared in, best set, estimated one-rep max and volume.

Plans

Method Path What it does
GET /plans All plans, active first.
POST /plans Create a plan with its days; a new plan normally becomes the active one.
GET /plans/:id One plan with days and exercises.
PATCH /plans/:id Edit. Days and slots keep their ids, so today's workout still continues.
POST /plans/:id/duplicate Copy as the next block — the trainer usually swaps a few exercises and keeps the rest.
DELETE /plans/:id Delete the plan. Workouts and sets survive.

Progress and export

Method Path What it does
GET /stats The Progress tab: goal, streak, 12 weeks, totals, recent PBs, every exercise's record.
GET /bodyweight Body-weight readings.
PUT /bodyweight One reading per day; the same day again overwrites.
DELETE /bodyweight/:date Remove a reading.
GET /export Everything you entered, as one JSON document, in kilograms.
GET /export.csv One row per set, in your unit, named in the header.

Cardio and health

Method Path What it does
GET POST /cardio/machines Your machine list, and add one.
PATCH DELETE /cardio/machines/:id Rename or remove. A machine with sessions logged refuses to be deleted.
GET POST /cardio/logs Sessions in a date range, and log one.
PATCH DELETE /cardio/logs/:id Edit or remove a session.
GET /health Runs SELECT 1 against the database, so a 200 means the whole path works.

How logins work

No auth library, again, but different choices from EpisodeTracker, each for a reason:

Eleven tables, and what each one is for

Table Scope Purpose
User one per account Email, password hash, and the settings: rest timer, weekly goal, week start, accent, unit, appearance.
AuthSession many per user A login. Its id is the HMAC of the cookie token.
Exercise per user, unique name The anchor. Name, the gym's code, machine number, and the id of the linked library animation.
Plan per user A block of training with a start date. One is active.
PlanDay per plan Day 1, Day 2… with an optional title.
PlanExercise per plan day One slot: which exercise, target sets, rep range, the note.
WorkoutSession per user + day + date One workout. Points at a plan day, but may outlive it.
SetLog per workout Weight in kg and reps. Unique on (workout, exercise, set number) — the upsert key.
BodyWeight per user + date One reading per day.
CardioMachine per user, unique name Treadmill, bike, or anything the user names.
CardioLog per machine Minutes, and optionally distance, calories and level.

The delete rules are the design

The schema says what may be destroyed by what, and this is where "history survives the plan" is actually enforced:

Relation On delete Meaning
WorkoutSession → PlanDay SetNull Deleting a plan never deletes what was lifted. The workout just loses its link to the day.
SetLog → Exercise Restrict An exercise with sets logged cannot be deleted by accident. Merge it instead.
PlanExercise → Exercise Restrict Same for a plan slot.
CardioLog → CardioMachine Restrict A machine with sessions keeps them.
everything → User Cascade Deleting the account takes all of it.

One consequence caught me out: because Exercise is a Restrict target, deleting a user in a single statement fails — Postgres checks the restriction before the cascade reaches the rows pointing at it. DELETE /me clears workouts, plans and cardio logs first, in one transaction, and then deletes the user.

Plan writes also take a row lock on the user (SELECT … FOR UPDATE) so two tabs cannot both make a plan active at once.

Screenshot import

Typing a six-exercise, four-day plan into a phone is the moment people give up. So the plan editor can read screenshots of the gym's app instead.

Reading the image, on the phone

ocr.ts does all of it in the browser:

Parsing what it read

The gym shows each exercise as two lines:

C201--flat barbell bench press
3 - 10-15 Reps - Machine No: 53

importParser.ts turns that into rows, and is tolerant by design because OCR is noisy: dash variants, Rep5, Machine N0;, placeholder machine values like a backtick or *.

The day the gym changed the format

A new block from the trainer arrived written differently — a rep count per set instead of a range:

3 - 12 10 8 Reps - 32 kg - Machine No: `

The parser only knew 10-15, so every line failed to match and the import found nothing, on every device. It now accepts a list of reps and keeps its span (12 10 8 becomes 3 sets of 8–12), ignores the prescribed weight (there is no field for it), and repairs two OCR slips the real screenshot showed: a lost space between numbers (1210) and a symbol inside the code (S$106). The actual OCR text from that screenshot is now a test fixture.

Why the OCR engine is served from our own domain

The same fix found a second, separate problem. The import worked on the laptop and not on the phone, and the parser was not the reason.

Left to its defaults, tesseract.js downloads its worker, a 3.9 MB WebAssembly core and 2.9 MB of English training data from jsDelivr, inside a Web Worker, the moment you press Read. On a throttled 1500 kbps connection that is 37 seconds of a screen saying "Reading… 0%".

Now copy-ocr-assets.mjs copies the engine out of node_modules into /ocr/<version>/ before every build. Nginx serves it as immutable, Cloudflare caches it at the edge, the service worker keeps it after the first read, and it is left out of the precache because 14 MB should not download just because someone opened the app. The script refuses to build if the version in the URL and the installed package disagree, so an upgrade cannot serve half of the old engine from a phone's cache.

Same screenshot, same throttled connection: 12 seconds, and it still works with jsDelivr blocked entirely.

Linking the gym's names to the exercise library

Every exercise can show an animation and step-by-step instructions, from the public exercises-dataset. The gym and the library do not use the same names:

The gym says The library says
Super declined chest press lever decline chest press
Peck back rear fly lever seated reverse fly
Fix bar curl ez barbell curl
LEG EXTENTION lever leg extension

Nothing matches word for word, so libraryMatch.ts scores every library entry against the whole name:

Checked on 53 rows from seven real screenshots across two plans, every row landed on the right exercise. Close calls are badged in the review list rather than linked silently.

The search box keeps a different matcher (Fuse.js) on purpose: a person typing a few words wants every one of them honoured; an imported name arrives whole and wants its best reading.

The library never touches the server

The server stores only the library's id on an Exercise. The 17 MB dataset JSON is fetched from jsDelivr by a Web Worker, trimmed to the English fields the app shows, and kept in IndexedDB for a week. Animations load from the same CDN and the service worker keeps the ones you have viewed for 90 days, so a movement looked up yesterday still plays on bad gym signal.

Colour, light mode, and pounds

One accent, one token. Buttons, the goal ring, the chart line, focus rings and the PB pulse all read a single CSS variable, --plate. A theme is a couple of variables swapped on :root; no component knows it happened. The server stores only the colour's name and never interprets it, so a new colour is a web change with no migration.

Contrast is a test, not a judgement call. theme.test.ts holds every pairing to WCAG AA — the label on the accent, the accent on the background, the accent on a card, danger on the background — at 4.5:1, in both appearances. Red brings its own danger colour (amber), because a red Delete next to a red Save is not a warning, and the test keeps the two at least 30° apart on the colour wheel. That test found a real failure that had already shipped: red on a card in dark mode measured 4.48.

Light mode needs a second shade of every accent. The bright plate colour that reads well on near-black is invisible as text on paper. So each accent has a dark shade and a light one — the same hue, darker — and both are tested against their own background and the cards.

Weights are always stored in kilograms. Pounds exist only on the way to the screen and on the way back from the stepper. 225 lb is stored as 102.06 kg and comes back as 225 lb; the tests pin that round trip. Switching units changes how a lift reads, never what was lifted. The plate calculator counts in the unit on the rack — a pound gym racks 45s on a 45 lb bar — and the CSV follows your unit while the JSON export stays in kilograms, because that one is the data rather than a reading of it.

Build me a plan

For someone with no trainer plan at all, a handful of taps — days a week, goal, experience, where you train, optionally what each day hits, exercises per day, session length, anything to protect — produce a 2 to 4-day plan from templates and a curated pool of library exercises. Every exercise note carries a cue, a warm-up reminder on the big lifts, and a progression rule.

It runs entirely in the browser (planGenerator.ts). Free LLM tiers were considered and rejected: a rate limit or a withdrawn free tier would break a feature that templates handle perfectly well. The result opens in the normal plan editor, and nothing is saved until you press Create.

Testing it

Three layers, each catching what the one below cannot.

API integration tests — real Postgres

56 tests with Node's built-in runner, driving the Fastify app through inject() against a real database. Every test starts from empty tables, which is why the suite refuses to run unless DATABASE_URL points at a database whose name contains test. One wrong .env would otherwise truncate real data.

Web unit tests — the logic in lib/

154 tests in Vitest over the parts that are pure logic and easy to get subtly wrong: the import parser (with real OCR text as fixtures), the library matcher (the gym's real names against a slice of the real library), units, theme contrast, plates, the plan generator, the queue with its storage and network injected.

Browser flows — what a person actually does

Six scripts in scripts/browser-flows/ drive headless Chrome over the DevTools Protocol at 390 px wide, with an isolated profile and a fresh throwaway account per run. After each action they compare what the screen shows against what the API says, then reload and check again. v2-features.mjs cuts the network to prove the offline queue; import.mjs pushes a real screenshot through OCR, with options to throttle the connection or block jsDelivr outright; tour.mjs walks every screen and reports horizontal scrolling and small tap targets.

This layer finds the bugs the other two cannot: "the click worked but the screen did not change", and requests made from inside a Web Worker. Its very first run caught an infinite update loop — the hook that lists queued sets returned a new array from every useSyncExternalStore snapshot, which React treats as a change every time.

Shipping it

./scripts/deploy.sh, from the Mac, in eight steps:

1. tests (API and web)
2. build both — on the Mac
3. back up the database on the server
4. rsync the API
5. npm ci --omit=dev, prisma migrate deploy
6. publish the web build
7. restart gym-api
8. verify: /api/health on the box, then the site from outside

It never builds on the droplet. tsc and Vite are exactly the memory spikes a 1 GB server shared with other apps should never take. The systemd unit caps the API at a 128 MB heap and 256 MB total for the same reason: this app must not starve EpisodeTracker.

The backup comes before the migration, and there is deliberately no automatic rollback: redeploying old code is safe, reversing a migration is not, so on failure it prints the service log, the rollback command and where the dump is.

Two smaller details that each came from something going wrong:

The service worker is network-first for pages — that is why the app has its own instead of the generated one. A cache-first shell hides every deploy until the load after next, and the service worker also deliberately does not force a reload: that would throw away numbers typed but not yet ticked, mid-workout.

Bugs worth learning from

The ones that are not about gyms, and will turn up in the next project too.

Cloudflare served one user's export to everyone

GET /api/export.csv returns the signed-in user's sets as a spreadsheet. The API sent no Cache-Control header. Cloudflare caches URLs with a static file extension at the edge by default — and .csv is on its list — so the first export would be cached and served, with that user's data, to anyone requesting the URL, signed in or not, until the cached copy expired. It was found in a full test pass and fixed the same day.

Nothing was wrong in the application logic; the session check ran on the origin, which the cached copy never reached. The fix is one hook in app.ts that sets Cache-Control: no-store on every API response, because every answer from this API belongs to one user.

The general rule: anything per-user behind a CDN must say no-store explicitly, and the dangerous URLs are the ones that look like files.

A download you cannot see

The OCR engine download above lived inside a Web Worker. A worker's requests do not appear in the page's Network panel, or in the page's DevTools Protocol network events. So the headless tests "saw" no slow download for a whole day, while the phone waited 37 seconds. The CDP helper now attaches to worker targets as well.

The general rule: when a page feels slow and its network log looks clean, check whether a worker is doing the fetching.

"Today" in UTC

Workouts are dated by the phone's calendar. The browser flows computed "today" with new Date().toISOString(), which is UTC. Amman is three hours ahead, so between midnight and 3 am every flow asked the server about yesterday and failed on data it had just written. They now use toLocaleDateString("en-CA"), which gives YYYY-MM-DD in local time.

The app itself had the same trap in reverse: the server's own clock is UTC. Routes that care about "today" — the week on the Today screen, duplicating a plan — take the phone's date from the request rather than trusting the server's.

queryClient.clear() left the previous user behind

Logging in called queryClient.clear() to throw away the last user's cached data. That also detaches the observers of components that outlive every screen — the theme and the offline queue live at the app root — so after switching accounts, both kept reading the previous user until a reload. switchUser() now removes every query except me and overwrites me with the new user, so those observers stay attached.

Found alongside it: /auth/login built its user object field by field and had silently stopped at weeklyGoal, so a fresh login had no week start and no colour until something refetched /me. Login, signup and /me now share one userDto(), with a test that login's answer equals /me's.

A test that passed before the screen changed

Saving a cardio session shows a success toast that repeats the row's text. A flow that waited for that text to appear on the page passed the instant the toast rendered — before the list had refetched. It would have gone on passing with the list broken. The flows now wait for the row's own element (its edit button) instead of text.

The general rule: wait for the specific element that proves the change, not for text that something else on the page might also say.

Terms you will hit in this codebase

Term What it means here
shadcn/ui Not a package you install and update: a CLI that copies component source into your repo. Built on Radix primitives and styled with Tailwind.
Radix Unstyled, accessible building blocks — dialogs, tabs, sheets — handling focus, keyboard and screen readers. Note that its tabs switch on mousedown, not click.
design token A CSS variable such as --plate that components read instead of a hard-coded colour. Changing the token re-themes everything that uses it.
TanStack Query Caches server data by key, refetches when told the data is stale, and lets screens share one request.
invalidate Marking a cached query stale after a write, so it refetches.
zod A schema that validates untrusted input at runtime and gives the TypeScript type of the result.
argon2id A password hash that is deliberately slow and memory-hungry, which makes guessing on a GPU expensive.
HMAC A keyed hash. Storing HMAC(secret, token) means a stolen table is useless without the server's secret.
upsert Insert, or update if a row with that unique key already exists. The reason retrying a set is safe.
Web Worker A background thread in the browser. Used here for parsing the 17 MB dataset and for OCR, so the screen never stutters.
WebAssembly Compiled code the browser runs at near-native speed. The Tesseract engine is written in C++ and shipped as wasm.
service worker A script between the page and the network. This one decides what is served from cache and what must come fresh.
injectManifest The vite-plugin-pwa mode where you write the service worker and the plugin only injects the list of files to precache.
IndexedDB The browser's on-device database. Holds the trimmed exercise library for a week.
wake lock A browser API that stops the screen from sleeping. Held while a workout is open, re-acquired after the phone locks.
e1RM Estimated one-rep max, here by Epley's formula: what a set of 8 at 60 kg suggests you could lift once.
CDP Chrome DevTools Protocol — the same interface DevTools uses, driven from a script to test the real app in a real browser.

What has drifted

Two places where the repository describes itself less accurately than the code:

Neither breaks anything, but both are the first things the next person — or the next session — will read.