§ Forge — hypertrophy tracker
Bulletproof in-gym UX
A gym is the worst networking environment a web app will ever meet: concrete, steel racks, a phone in a sweaty pocket, signal that drops between sets. This page explains the four things Forge does so that logging is fast and nothing is ever lost mid-workout.
1. Offline-first logging — the op queue
The core idea is enqueue-then-flush. Every gym mutation — log a set, delete a set, finish or discard a session — is first written to an IndexedDB queue, then sent to the server. The UI never waits on the network to show you a result.
The store
A single IndexedDB database forge-q with one object store ops, keyed by an auto-incrementing integer. The key is the send order: FIFO, oldest first. Each op looks like:
{ id, type: 'set'|'delSet'|'finish'|'discard', sessionId, body, localRef, status }
Everything is feature-guarded (typeof indexedDB !== 'undefined') and wrapped in try/catch, so a private-mode or exception path degrades gracefully instead of throwing a console error.
Optimistic rows and id mapping
When you log a set offline, the DOM row is created immediately with a temporary id like local:3 and a dashed "queued" border. The op carries that localRef. When the queue later flushes and the server returns the real row, reconcileRow() swaps data-set="local:3" for the real numeric id, removes the queued styling, and — if the server flagged a PR — stamps the white-hot PR mark then. An in-memory refMap remembers local:3 → 91 so any later delete that references the local id can be rewritten to the real one.
Ordering, dependencies, and the cancel rule
flush() walks ops oldest-first and only advances on success — a failed send leaves the op pending and reschedules, so order is never broken. The subtle case is a delete of a set that hasn't synced yet:
- If the matching
setop is still in the queue, the delete and the set cancel each other — neither ever reaches the server. - If the set already synced earlier in the same flush pass,
refMapholds its real id and the delete is sent as a genuineDELETE.
The cancel check re-reads the live queue (not a stale snapshot) precisely so a set that synced moments ago isn't mistaken for "still pending" — that mistake would skip a real delete and leave an orphan row on the server.
Flush triggers
On page load, on the online event, after every successful enqueue (when online), and a 5-second retry timer while the queue is non-empty. Because the ops live in IndexedDB, a reload re-opens the store and flushes — durability survives a refresh.
drain() before navigation
Finish and discard are queued like any other op, but the handler then calls drain() — it loops until the queue is empty and no flush is mid-flight before navigating. This stops the page from aborting an in-flight finish request (which would otherwise show up as a requestfailed error) and guarantees finished_at is written before the summary page reads it for the duration.
The indicator
A small mono pill in the session bar: hidden when online and the queue is empty, QUEUED · n (amber, like the coach .warnchip) when ops are pending, and OFFLINE when navigator.onLine is false. No new colour — it reuses --warn and the steel tokens.
The service worker's job
sw.js keeps the shell working offline (navigations are network-first with a cached / fallback; static assets are cache-first) but it never touches /api. Durability of writes belongs to the IndexedDB queue, not the SW cache — keeping that boundary clean is what lets the e2e suite assert zero failed requests.
2. Faster set entry
- Steppers:
−/+buttons flank the weight and reps inputs. Weight steps by the exercise's own increment (data-incon the lift card — 2.5kg for a barbell, less for a cable), reps step by 1, both clamped at 0 and snapped to the step grid. Each button is ≥44px. - Repeat last set: re-reads the previous row's
weight × repsfrom its rendered text, refills the inputs, and runs the samelogSet()path — so a repeated set flows through the queue and kicks the rest timer identically to a manual one. Disabled until at least one set exists.
3. Rest-timer finish cue
When the auto-started rest timer crosses zero it fires three cues, all behind feature checks so headless Chromium never throws:
navigator.vibrate(200)if vibration exists.- A local Notification ("rest done") — permission is requested politely on the first rest-start gesture, never on page load, and only when
Notification.permission === 'default'. - An optional short WebAudio beep, with the
AudioContextcreated lazily on that first gesture (autoplay policy) and an opt-out flag inlocalStorage. Per the box's iOS silent-switch note, sound is best-effort and never asserted.
A restFired flag guarantees the cue fires exactly once per timer, reset on each startRest.
4. Session summary
Finishing a session now navigates to GET /session/:id/summary (rendered by views/summary.ejs) instead of straight to Progress. The stats are computed by a new pure module summary.js (no DB, no clock reads — the caller passes now), mirroring engine.js/coach.js:
- total working sets, total tonnage (Σ weight·reps, warm-ups excluded), exercise count, warm-up count;
- per-muscle effective set counts (Σ muscle contribution, the same convention as
engine.weeklyVolume), rendered with the shared heat-tier bars; - PRs hit, shown with the white-hot PR stamp;
- duration (
finished_at − started_at).
Because a heavier set later in the same session can mask an earlier PR, PRs are persisted at log time in a new append-only prs table (recordPR() is called from the /set route when isPR), rather than recomputed post-hoc. The summary ends with a "View progress ▸" link onward. No new bottom-nav tab — summary is reached by navigation, keeping the nav at four tabs.
Where it lives
| Concern | File |
|---|---|
| Queue, steppers, repeat, rest cue | public/app.js |
| Offline shell + nav fallback | public/sw.js |
| Steppers, repeat button, q-pill in session bar | views/today.ejs |
| Summary view | views/summary.ejs |
| Summary route + PR recording | server.js |
prs table + summary queries |
db.js |
| Pure summary math (+ unit tests) | summary.js, summary.test.js |
| e2e coverage | e2e.mjs |
Testing
node --test covers the summary math (tonnage, warm-up exclusion, per-muscle counts, PR pass-through, duration, empty session). The Playwright suite adds: offline-log-then-sync (log offline, come back online, flush, reload, assert server persistence and id reconciliation), an offline log+delete that cancels cleanly, the steppers and repeat-last with ≥44px assertions, the guarded rest cue (notifications pre-granted, zero errors), and the full summary stat readout with the onward link — all on desktop + 390px mobile + reduced-motion with a zero-error bar.