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
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.
- Opening a day sends
POST /sessionswith 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. - 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.
- The number is converted to kilograms (
units.ts), whatever unit the screen shows, and posted toPOST /sessions/:id/sets. - 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.
- Cloudflare, then Nginx on the droplet. Nginx serves the built web app from
disk and proxies only
/api/to127.0.0.1:3002. The API binds to loopback, so nothing reaches Node without passing through Nginx. requireUser, anonRequesthook on every protected route, reads thegym_sidcookie, 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.- 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.
- An
onSendhook stampsCache-Control: no-storeon the response. That line exists because of the worst bug this app shipped — see the last section. - 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:
- Sending twice is harmless, because the server upserts. That is what allows a simple "retry until it works" loop at all.
- A retry that meets 401, 408, 429 or a 5xx waits rather than dropping the set. A login that expired or a deploy mid-workout is not the set's fault.
- The queue belongs to a user. Logging out empties it; another account logging in on the same phone drops the previous account's sets, so they can never be sent under the wrong session.
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:
- argon2id at OWASP's minimum profile (19 MiB, 2 iterations). Minimum on purpose — every login allocates that memory on a 1 GB machine shared with other apps.
- The cookie holds a random token; the database holds only its HMAC. A copy of the sessions table does not hand anyone a working login.
- An unknown email still runs a full password check against a dummy hash, so response time does not reveal which emails have accounts.
- Rate limits key on
X-Real-IP, which Nginx sets from Cloudflare's view of the visitor — notX-Forwarded-For, whose left-most entry is whatever the client chose to send. - Sessions last 90 days and renew once past halfway. Logging in again between sets is friction for nothing on a personal tracker.
- A wrong password on account deletion is 403, not 401. The web app treats any
401 outside
/auth/as "your login ended" and logs you out, which is not what mistyping a password should do.
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:
- The gym's app is light text on near-black, which Tesseract reads badly, so the image is turned grayscale and inverted when it is mostly dark.
- A modern phone screenshot is about 1290 × 2800. Phone browsers cap canvas size far lower than laptops, and an over-large canvas comes back blank, with no error. So the pixel count is clamped to about 2.6 million, a blank result is detected, and the original file is used as a fallback.
- If the prepared image reads as nothing, the untouched screenshot is tried once more before giving up.
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:
- Words are stemmed and the gym's spellings repaired, so curling and curl, extention and extension, meet.
- Every word is weighted by how rare it is in the library. "Super", "multi" and "circular" appear nowhere in the library, so they weigh zero — no stopword list to maintain. "Shrug" or "hammer" can decide a match on their own.
- Equipment is read out of the name to separate the dumbbell version from the barbell one, and a machine number counts as evidence for the machine version, because this gym leaves the number blank for cables and free weights.
- It has to fit both ways: the name must explain the library entry as well as the reverse, so "leg curling" does not settle on "cable assisted inverse leg curl".
- Confidence compares the winner with the runner-up. Two entries within a whisker of each other is exactly when to ask rather than link.
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:
- Old JavaScript chunks are kept for 30 days. A tab opened before a deploy still lazy-loads its screens from the previous build's file names.
- Icons keep fixed file names, so Cloudflare can serve stale ones for hours. The script compares each icon's size at the edge with the build and names any that need purging.
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:
README.mdis behind the app. Its feature list stops before the plan generator, the accent colours, light mode, pounds, the week-start setting and account deletion, all of which have shipped.- A schema comment says sessions store "only its SHA-256". The code stores an
HMAC-SHA-256 keyed with the session secret (
sessions.ts), which is the stronger choice. The comment should say so.
Neither breaks anything, but both are the first things the next person — or the next session — will read.