Skip to content

Commit 00b59c5

Browse files
committed
Add a training log with records, and make the app installable
274 exercises, sets logged against the active profile, records per exercise. The substance is in what counts as a record, because that decides when the app interrupts someone. Four are tracked for a lifted set - heaviest weight, estimated 1RM, most reps at a weight, biggest single set - and only the first two can raise a popup. The reason is the back-off set: after 100 kg x 5, a light 60 kg x 15 finisher really is the most reps ever at 60 kg and really is the biggest single set, and celebrating that teaches people to ignore the popup inside a week. Heaviest and estimated 1RM are the two a lighter set cannot fake. Three smaller calls, each with a test: - The first set of an exercise is never a record. Otherwise every exercise begins with a meaningless personal best. - Reps compare only at the same weight. 21 reps at 20 kg does not beat 20 reps at 40 kg and no arithmetic on those numbers says it does. - Epley is capped at 12 reps, and returns the bar weight exactly for a logged single rather than the textbook's 3.3% overstatement. Exercises carry one of three metrics - load, reps, time - which picks the input fields and the records, so a plank cannot set a weight PR. Installable with manifest.webmanifest and sw.js: on iOS, Add to Home Screen gives a standalone app. There is no native iOS build and from Windows there cannot be - that needs macOS, Xcode and a paid Apple account. Caching mirrors the lazy loading: ~1 MB shell precached, the 24 MB of MediaPipe kept only once something actually uses it. Verified by stopping the server and reloading. Cache-first turned out to be the wrong default and bit during testing - a stale exercises.js quietly undid a fix - because scripts keep their filenames across deploys. Everything but the immutable MediaPipe assets is stale-while-revalidate.
1 parent c81ac42 commit 00b59c5

20 files changed

Lines changed: 2000 additions & 5 deletions

‎.github/workflows/pages.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ jobs:
5757
working-directory: app
5858
# Every suite except parity_silhouette, which needs the 8 MB mask fixture
5959
# that is deliberately not committed.
60-
run: node parity_test.mjs && node scan_score_test.mjs && node scan_steps_test.mjs && node overlay_test.mjs && node plan_test.mjs && node progress_test.mjs && node boot_test.mjs
60+
run: node parity_test.mjs && node scan_score_test.mjs && node scan_steps_test.mjs && node overlay_test.mjs && node plan_test.mjs && node progress_test.mjs && node boot_test.mjs && node workouts_test.mjs
6161

6262
- uses: actions/configure-pages@v6
6363
- uses: actions/upload-pages-artifact@v5

‎README.md‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ is how wrong numbers ship:
9494
| `plan_test.mjs` | plan maths and safety floors | 30 checks pass |
9595
| `progress_test.mjs` | history, crew board, goals, ranking | 61 checks pass |
9696
| `boot_test.mjs` | lazy model loading, retry after failure | 10 checks pass |
97+
| `workouts_test.mjs` | PR engine, sessions, training streak | 65 checks pass |
9798

9899
### A bug worth keeping
99100

@@ -141,6 +142,61 @@ measurements are still worth having — they are what the plan, the ranking and
141142
trend are built on — but the honest summary is that this fixed Stage 1's weakest
142143
result, not the headline. The shipped app uses these models.
143144

145+
## A training log, and knowing when to shout
146+
147+
274 exercises, logged as sets, with records tracked per exercise. The part worth
148+
reading is what counts as a record, because that decides when the app is allowed
149+
to interrupt someone.
150+
151+
Four records are tracked for a lifted set — heaviest weight, estimated 1RM, most
152+
reps at a given weight, and biggest single set — but **only two can raise a
153+
popup**. The reason is the back-off set. After a top set of 100 kg × 5, a light
154+
60 kg × 15 finisher is genuinely the most reps ever done at 60 kg and genuinely
155+
the biggest single-set volume. Both true, both worthless as news, and a tracker
156+
that celebrates them teaches you to ignore it within a week. Heaviest weight and
157+
estimated 1RM are exactly the records a lighter set *cannot* fake, so those two
158+
get the screen and the rest are shown quietly on the set itself.
159+
160+
Three more decisions that fell out of testing:
161+
162+
- **Nothing is a record the first time.** Otherwise every first set of every
163+
exercise is a personal best — technically true, completely useless.
164+
- **Reps are only compared at the same weight.** 21 reps at 20 kg is not better
165+
than 20 reps at 40 kg, and no arithmetic on those two numbers says otherwise.
166+
- **Epley is capped at 12 reps**, because above that it measures conditioning
167+
and reports it as strength. It also returns the bar weight exactly for a
168+
logged single, where the textbook formula claims 3.3% more than was lifted.
169+
170+
Exercises carry one of three metrics — load, reps or time — and the metric
171+
decides both the input fields and the records. It is what stops the app
172+
congratulating someone for holding a *heavier* plank.
173+
174+
## Installable, and it works with no signal
175+
176+
`manifest.webmanifest` plus `sw.js` make it a home-screen app: on iOS, Share →
177+
Add to Home Screen gives a standalone app with its own icon. There is no native
178+
iOS build, and from a Windows machine there cannot be — an App Store binary
179+
needs macOS, Xcode and a paid Apple developer account. A PWA is the whole of
180+
what is buildable here, and for this app it costs nothing: camera, models and
181+
storage all work the same.
182+
183+
The caching mirrors the loading split, for the same reason — precaching
184+
everything would mean the 25 MB download the lazy loading just removed:
185+
186+
| Cache | Contents | When |
187+
|---|---|---|
188+
| shell | ~1 MB: markup, scripts, the 11 `.bin` models | precached on install |
189+
| heavy | ~24 MB: MediaPipe wasm and tflite | kept only once actually used |
190+
191+
Verified by stopping the server and reloading: the app booted from cache, the
192+
models loaded, and the training log was intact.
193+
194+
Cache-first was the obvious strategy and the wrong one. Scripts keep their
195+
filenames across deploys, so it serves last week's code until someone remembers
196+
to bump a version constant by hand — which bit during testing, when a stale
197+
`exercises.js` silently undid a fix I had just made. Everything but the
198+
immutable MediaPipe assets is stale-while-revalidate instead.
199+
144200
## Loading in the order the user arrives
145201

146202
Measuring the boot rather than assuming it: the app fetched **24 MB and stood

‎app/README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,31 @@ localhost every one of these read as ~100 ms, which is why they survived so long
362362
blip must not brick the camera for the rest of the session. `boot_test.mjs`
363363
pins the sharing and the retry, 10 checks.
364364

365+
## The training log
366+
367+
`js/exercises.js` is a 274-row table — name, muscle, equipment, metric — parsed
368+
at load. The metric (`load`, `reps`, `time`) decides the input fields and which
369+
records exist, which is what keeps a plank from competing on weight.
370+
371+
`js/workouts.js` holds the records engine and is pure apart from an injectable
372+
store, so all 65 checks run under Node. Sets are stored flat with their own
373+
timestamps and sessions are derived by calendar day: a stored "current session"
374+
is state that can be left open, double-opened or orphaned by a refresh, and none
375+
of those bugs can exist if the session is only a view.
376+
377+
Only `weight` and `e1rm` raise a popup. A back-off set can top the rep and
378+
volume records without being news, and neither of those two can be reached by
379+
lifting something lighter. The first set of any exercise is never a record.
380+
381+
### A bug the log found in the scanner
382+
383+
`readCrew()` invents a profile when the device is new, with a fresh random id —
384+
and never wrote it down. Scan history hid this for months because `saveScan`
385+
persists the crew as a side effect of saving. The training log reads the active
386+
profile id without ever saving a scan, so every set was filed under a different
387+
person and no record ever had any history to beat. `ensureCrew()` now persists
388+
the invented profile, and `progress_test.mjs` pins the id across calls.
389+
365390
## Camera framing
366391

367392
`zoomRange` reports what the hardware actually exposes and drives a slider;

‎app/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"test": "node parity_test.mjs && node parity_silhouette.mjs && node scan_score_test.mjs && node scan_steps_test.mjs && node overlay_test.mjs && node plan_test.mjs && node progress_test.mjs && node boot_test.mjs",
7+
"test": "node parity_test.mjs && node parity_silhouette.mjs && node scan_score_test.mjs && node scan_steps_test.mjs && node overlay_test.mjs && node plan_test.mjs && node progress_test.mjs && node boot_test.mjs && node workouts_test.mjs",
88
"start": "node server.mjs"
99
},
1010
"dependencies": {

‎app/progress_test.mjs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { readFileSync } from "node:fs";
55
import {
66
loadHistory, saveScan, clearHistory, streak, deltas, badges, trendPoints, KEY,
77
readCrew, profiles, activeProfile, setActiveProfile, addProfile, removeProfile, crewBoard,
8+
CREW_KEY,
89
} from "./public/js/progress.js";
910
import { setRanks, rank, bandFor, percentileOf, rankLabel } from "./public/js/rank.js";
1011
import { bodyFatAge, nextMilestone, weeklyRate, etaTo, etaLabel } from "./public/js/goals.js";
@@ -281,5 +282,23 @@ console.log("\ngoals");
281282
check("one scan gives no rate", weeklyRate([falling[0]]) === null);
282283
}
283284

285+
/* ---------------------------------------------------- a stable identity --- */
286+
// Found by building the training log on top of this: anything keyed by profile
287+
// id needs that id to still be there on the next call. readCrew invents one on
288+
// a new device, and for a long time only saveScan happened to write it back -
289+
// so a feature that read the id without ever saving a scan silently filed
290+
// every entry under a different person.
291+
console.log("\nThe invented first profile");
292+
{
293+
const store = makeStore();
294+
const a = activeProfile(store);
295+
const b = activeProfile(store);
296+
check("a fresh device gets a profile", !!a && !!a.id);
297+
check("and the very same one next call", a.id === b.id, `${a?.id} vs ${b?.id}`);
298+
check("because reading it wrote it down", store.getItem(CREW_KEY) !== null);
299+
check("listing profiles agrees", profiles(store).length === 1);
300+
check("and agrees on the id", profiles(store)[0].id === a.id);
301+
}
302+
284303
if (failures) { console.error(`\nFAIL: ${failures} check(s) failed`); process.exit(1); }
285304
console.log("\nPASS - history, crew, goals and population ranking all behave");
803 Bytes
Loading

‎app/public/icons/icon-192.png‎

875 Bytes
Loading

‎app/public/icons/icon-512.png‎

2.48 KB
Loading
2.39 KB
Loading

‎app/public/index.html‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@
1111
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700;12..96,800&family=Figtree:wght@400;500;600;700;800&display=swap">
1212
<link rel="stylesheet" href="styles.css">
1313

14+
<!-- Installable: on Android this comes from the manifest, on iOS it does not -
15+
Safari ignores the manifest's icons and name and reads these instead. -->
16+
<link rel="manifest" href="manifest.webmanifest">
17+
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
18+
<meta name="apple-mobile-web-app-capable" content="yes">
19+
<meta name="mobile-web-app-capable" content="yes">
20+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
21+
<meta name="apple-mobile-web-app-title" content="bodycomp">
22+
<meta name="description" content="Body fat and lean mass from two photos, validated against DXA, plus a training log with personal records. Everything runs on your device.">
23+
1424
<!-- The module graph is three levels deep, so the browser discovers it one
1525
level per round trip: app.js, then its nine imports, then theirs. Naming
1626
the leaves here starts them all in the first wave. The manifest is the
@@ -101,6 +111,10 @@ <h1>Know your body fat.<br><span class="hl">Not just your weight.</span></h1>
101111
<svg viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
102112
</button>
103113
<button class="btn secondary js-needs-models" type="button" id="sample" disabled>See a demo result</button>
114+
<button class="btn ghost" type="button" data-go="train">
115+
<svg viewBox="0 0 24 24"><path d="M4 9v6M7 7v10M17 7v10M20 9v6M7 12h10"/></svg>
116+
Log a workout
117+
</button>
104118
</div>
105119
<p class="fine">A fitness estimate, not a medical device.</p>
106120
</section>
@@ -439,6 +453,17 @@ <h2>Your body, by the numbers</h2>
439453

440454
<!-- progress -->
441455
<div data-panel="progress" hidden>
456+
<div class="card traincard">
457+
<div class="card-head"><h3>Training</h3><span class="pill" id="tc-streak"></span></div>
458+
<div class="tc-stats">
459+
<div><b id="tc-sets">0</b><span>sets this week</span></div>
460+
<div><b id="tc-vol">0</b><span>kg moved</span></div>
461+
<div><b id="tc-ex">0</b><span>exercises tracked</span></div>
462+
</div>
463+
<p class="sub" id="tc-note"></p>
464+
<button class="btn ghost" type="button" data-go="train">Open my training log</button>
465+
</div>
466+
442467
<div class="card">
443468
<div class="card-head"><h3>Crew board</h3><span class="pill" id="crew-count">--</span></div>
444469
<ol class="crew" id="crew-list"></ol>
@@ -514,6 +539,112 @@ <h2>Your body, by the numbers</h2>
514539
</div>
515540
</section>
516541

542+
<!-- ================================================================== train -->
543+
<section class="screen" data-screen="train" hidden>
544+
<div class="screen-head">
545+
<p class="kicker">Training log</p>
546+
<h2>What did you lift?</h2>
547+
</div>
548+
549+
<div class="trainbar">
550+
<div class="statlet"><b id="t-streak">0</b><span>day streak</span></div>
551+
<div class="statlet"><b id="t-week-sets">0</b><span>sets this week</span></div>
552+
<div class="statlet"><b id="t-week-vol">0</b><span>kg moved</span></div>
553+
</div>
554+
555+
<button class="btn primary big" type="button" id="t-add">
556+
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
557+
Log a set
558+
</button>
559+
560+
<div id="t-empty" class="card empty" hidden>
561+
<h3>Nothing logged yet</h3>
562+
<p class="sub">Add your first set and the app starts tracking records for it. Nothing
563+
counts as a personal best until there's something to beat &mdash; so your first set of an
564+
exercise is just a baseline.</p>
565+
</div>
566+
567+
<div id="t-sessions"></div>
568+
569+
<div class="card" id="t-records-card" hidden>
570+
<div class="card-head"><h3>Your records</h3><span class="pill" id="t-records-count"></span></div>
571+
<div id="t-records"></div>
572+
</div>
573+
574+
<p class="fine">Logged on this device only, like everything else here.
575+
<button class="linkish" type="button" id="t-clear">Delete my training log</button>
576+
</p>
577+
</section>
578+
579+
</div>
580+
581+
<!-- ====================================================== exercise picker -->
582+
<div class="sheet" id="picker" hidden>
583+
<div class="sheet-back" data-close-picker></div>
584+
<div class="sheet-body" role="dialog" aria-modal="true" aria-label="Choose an exercise">
585+
<div class="sheet-grab"></div>
586+
<div class="sheet-head">
587+
<h3>Choose an exercise</h3>
588+
<button class="iconbtn" type="button" data-close-picker aria-label="Close">
589+
<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>
590+
</button>
591+
</div>
592+
<input id="pick-search" class="search" type="search" placeholder="Search 274 exercises" autocomplete="off">
593+
<div class="chiprow" id="pick-muscles"></div>
594+
<div class="picklist" id="pick-list"></div>
595+
</div>
596+
</div>
597+
598+
<!-- ============================================================ set entry -->
599+
<div class="sheet" id="setsheet" hidden>
600+
<div class="sheet-back" data-close-set></div>
601+
<div class="sheet-body" role="dialog" aria-modal="true" aria-labelledby="set-name">
602+
<div class="sheet-grab"></div>
603+
<div class="sheet-head">
604+
<div>
605+
<h3 id="set-name">Exercise</h3>
606+
<p class="sub" id="set-sub"></p>
607+
</div>
608+
<button class="iconbtn" type="button" data-close-set aria-label="Close">
609+
<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>
610+
</button>
611+
</div>
612+
613+
<p class="lastset" id="set-last" hidden></p>
614+
615+
<div class="setfields" id="set-fields">
616+
<label class="num" id="f-kg"><span>Weight</span>
617+
<input id="in-kg" type="number" inputmode="decimal" step="0.5" min="0" placeholder="0"><i>kg</i>
618+
</label>
619+
<label class="num" id="f-reps"><span>Reps</span>
620+
<input id="in-reps" type="number" inputmode="numeric" step="1" min="0" placeholder="0"><i></i>
621+
</label>
622+
<label class="num" id="f-time"><span>Time</span>
623+
<input id="in-time" type="number" inputmode="numeric" step="1" min="0" placeholder="0"><i>sec</i>
624+
</label>
625+
<label class="num" id="f-dist"><span>Distance</span>
626+
<input id="in-dist" type="number" inputmode="numeric" step="1" min="0" placeholder="0"><i>m</i>
627+
</label>
628+
</div>
629+
630+
<div class="setsteps" id="set-steps"></div>
631+
632+
<button class="btn primary big" type="button" id="set-save">Save set</button>
633+
<div class="todayset" id="set-today"></div>
634+
</div>
635+
</div>
636+
637+
<!-- ================================================================== a PR -->
638+
<div class="prpop" id="prpop" hidden aria-live="polite">
639+
<div class="prcard" role="status">
640+
<div class="prburst" aria-hidden="true"></div>
641+
<p class="prkicker" id="pr-kicker">New personal record</p>
642+
<h2 id="pr-title">Heaviest ever</h2>
643+
<p class="prvalue" id="pr-value"></p>
644+
<p class="prwas" id="pr-was"></p>
645+
<p class="prex" id="pr-ex"></p>
646+
<button class="btn primary" type="button" id="pr-ok">Let's go</button>
647+
</div>
517648
</div>
518649

519650
<script type="module" src="js/app.js"></script>

0 commit comments

Comments
 (0)