CODEX // oaoisme wiki

§ Forge — hypertrophy tracker

Bulletproof in-gym UX

updated 2026-06-09

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 set op 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, refMap holds its real id and the delete is sent as a genuine DELETE.

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-inc on 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 × reps from its rendered text, refills the inputs, and runs the same logSet() 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 AudioContext created lazily on that first gesture (autoplay policy) and an opt-out flag in localStorage. 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.