§ Forge — hypertrophy tracker
Growth tracking and analytics
Forge logs sets and coaches the next one — but "am I actually getting bigger and stronger over months?" is a different question, answered by different data on a longer time axis. This page explains the Stats tab (bodyweight + measurements), the expanded Progress analytics (all-lift 1RM trends, strength-balance ratios, volume-over-time), and the CSV export that lets you take everything with you.
The pure module
Mirroring engine.js, coach.js, and the other Forge helpers, all the math lives in a pure module — analytics.js — with no DB and no clock reads: the caller hands it pre-fetched rows and any "now" it needs, and it returns plain shapes the server renders. That keeps it unit-testable in isolation (analytics.test.js, 14 node --test cases). It exports four functions and a few constants:
trendStats(series)— over a time-ordered series, the latest value and its delta from the first/previous point, tolerant of empty and single-point inputs (no NaN, no divide-by-zero).strengthRatios(bests, defs)— given best est-1RMs per lift and a set ofRATIO_DEFS, computes each a:b ratio and labels itbalanced/a_weak/b_weak, picking the strongest variant when a slot has several candidate lifts and skipping a ratio entirely when a side has no data.volumeOverTime(rows)— buckets sets into ISO weeks and sums tonnage/sets per week for the chart.toCsv(rows)— serializes rows to CSV with proper quote-escaping.- Constants:
MEAS_SITES/MEAS_LABEL(the fixed measurement sites and their display names) andRATIO_DEFS(which lifts make up each balance ratio).
The Stats tab
A new, fifth bottom-nav tab — "Stats" — added within Forge's self-imposed ≤5-tab law (see the program and design pages for why the nav stays small). It is the home for body data, as opposed to lift data:
- Bodyweight log — a single-field form (
POST /api/bodyweight) writing tobodyweight_log(at, kg). The page shows a trend chart of the series plus the latest delta chip. - Measurements — a site + value form (
POST /api/measurement) writing tomeasurements(at, site, cm). There are four fixed panels (theMEAS_SITES), each with its own trend line and delta chip, so you track e.g. arms / chest / waist / thigh over time.
Both forms post through the same guarded api() / flash() client helpers the rest of the app uses (.log-bw / .log-meas handlers in public/app.js), so submitting reuses the existing optimistic-feedback path rather than a full page reload.
A deliberate colour choice: deltas are rendered in neutral muted, not green-good / red-bad. A bodyweight that went up isn't inherently good or bad — it depends on your goal — so the UI refuses to editorialize. Contrast that with the strength-balance pills below, which do use teal-for-balanced and --warn-for-imbalance, because there an imbalance genuinely is a thing to address. No new colours or shadows were introduced; everything reuses existing design tokens.
Empty state
Before you've logged anything, Stats shows "no data yet" placeholders across the charts and no delta chips — a first-run experience the e2e explicitly asserts, so the page never renders a broken or NaN-filled chart on a fresh DB.
The expanded Progress page
The existing Progress view already showed est-1RM trends for the four key lifts (squat/bench/deadlift/OHP). Growth tracking widened it into a full analytics surface. The route now also computes, via analytics.js:
- All-lift est-1RM trends — a
.trendgrid.allof every lift that has history, each a small drawn line with its latest est-1RM and delta. The four key lifts keep their own prominent.trendgrid.keygrid; this adds the long tail of accessories so nothing you train is invisible. (One intentional e2e adjustment came from this split: the key-lift card count assertion was rescoped to.trendgrid.key.) - Strength-balance card — the
strengthRatiosoutput rendered as rows with monoa:bvalues and a balanced/imbalanced pill. This is the "are my pulls keeping up with my pushes" check, computed from your actual best est-1RMs. - Total-volume chart —
volumeOverTime, a weekly tonnage chart so you can see whether your working volume is trending up, flat, or being quietly eroded. - Backup / Export-CSV link — points at the export endpoint below.
CSV export — taking your data with you
GET /api/export.csv streams everything as text/csv: set logs, bodyweight entries, and measurements, drawn from a single db.exportRows() read and serialized by analytics.toCsv. It's the escape hatch — your data is never trapped in the app. The e2e asserts the raw response (not just that a link exists): correct text/csv content-type, a header row, and at least one of each kind of row (a seeded exercise key, a bodyweight row, a measurement row), with toCsv's quote-escaping verified by unit test.
Schema added
Two small, append-only tables (no table is ever dropped — same discipline as the rest of Forge's data model):
bodyweight_log(id, at timestamptz default now(), kg real)
measurements (id, at timestamptz default now(), site text, cm real)
plus idx_bw_at and idx_meas_site_at. The new db.js reads are logBodyweight, bodyweightSeries, logMeasurement, measurementSeries, exerciseKeysWithHistory, currentBestE1RMRows, and exportRows — all pure data access; the analytics shaping happens in the module, not in SQL.
Why this split (Stats vs Progress)
The boundary is body vs barbell. Stats is where you enter longitudinal body data (weight, measurements) and read its trend. Progress is where you read what the lifts have done — derived entirely from sets you already logged, with no new input forms. Keeping them apart means the daily-logging surface (Today) stays uncluttered, while the slow, monthly "am I progressing" review has its own deliberate home.
Gotchas & notes
- Trends tolerate sparse data.
trendStatsis written for empty and single-point series on purpose, because real logs have gaps — the chart degrades to a placeholder rather than throwing. muscles/JSON shapes vary. Like the alternatives recommender, the analytics code is careful about input shapes (object vs JSON string frompg) so it never silently double-parses into empty data.- Deltas are not value-judgements. The neutral-colour choice for bodyweight/measurement deltas is intentional; only strength imbalance is flagged as actionable.
- Progress photos were scoped out. The original plan had an optional photos table; it was deliberately not built — uploads need multipart parsing (not in the deps) and add path-traversal risk, so
progress_photosdoes not exist. The four numeric measurement sites cover the tracking need without that surface.
Files
| Concern | File |
|---|---|
| Pure analytics math (+ unit tests) | analytics.js, analytics.test.js |
Routes (/stats, expanded /progress, /api/bodyweight, /api/measurement, /api/export.csv) |
server.js |
| Stats tab view | views/stats.ejs |
| Expanded Progress view | views/progress.ejs |
| 5th nav tab | views/partials/nav.ejs |
| Bodyweight/measurement table reads | db.js |
| Additive styles (statcard, delta, ratio rows, balance pills, export button) | public/forge.css |
| Log-form client handlers | public/app.js |
| e2e coverage | e2e.mjs |
Testing
node --test covers the analytics module — trend deltas (empty / single / rising / falling), ratio classification (balanced / a_weak / b_weak / missing-side / strongest-variant), weekly bucketing, and CSV escaping (14 cases; 31 across the whole Forge unit suite). The Playwright e2e.mjs adds: the 5-tab nav; the Stats empty state; bodyweight + measurement logging through the live forms with trend paths and delta chips; all four measurement-site panels; the all-lift est-1RM trend cards (beyond the four key lifts) with drawn lines; the strength-balance ratios; the weekly total-volume chart; and a raw-fetch CSV assertion (text/csv, header + rows containing a seeded exercise key, a bodyweight row, and a measurement row). It runs on desktop + 390px mobile + reduced-motion, holding the zero console/page/network-error bar — final suite 132 checks green.
Where it sits in the roadmap
This is the second of the three post-coaching builds named at the end of Smart coaching: bulletproof in-gym UX, this growth/analytics layer, and the Connected Telegram + alternatives integration.