0011 — Unilateral set aggregation
Status: Accepted · Date: 2026-09-03
Context
A unilateral exercise is tracked one side at a time: mapScheme
(packages/core/src/training/set-scheme.ts) expands one prescribed set into
two WorkoutSet records, a left and a right, and WorkoutSet.side carries
which one it was.
Only one aggregation ever knew this — numberOfCompleted, which drives set-list
numbering and workout-item status. The session summary engine
(packages/core/src/training/exercise-overview.ts) did not: it wrote side
onto every SetRow and then read it in no reduction. The same physical session
(3 working sets, 8 reps @ 40 kg, 90 s rest) therefore summarized two different
ways depending only on whether the coach had toggled per-side tracking:
sets | totalReps | volumeLoad | avgRest | |
|---|---|---|---|---|
| bilateral | 3 | 24 | 960 | 90 s |
| unilateral | 6 | 48 | 1920 | 39 s |
Worse, the two counters contradicted each other on one screen: tracking's set list read "Set 3" while the Overview stat card beside it read "6 sets".
Decision
A unilateral set is half of a prescribed set — once it has a partner. A side still on its own counts whole. Every summary reduction weights accordingly, so a session reads the same whether or not it was tracked per side.
unmatched = |leftCount − rightCount|, taken latest-first on the majority side
weight(set) = 0.5 if unilateral AND matched
= 1 otherwise (bilateral, or an unmatched side)
volumeLoad = Σ weight · load · reps over working sets
totalReps = Σ weight · reps over working sets
sets = bilateralCount + max(leftCount, rightCount)
avgRest = session span / (sets − 1)
highestLoad = max raw load — unchanged
Worked examples, all at 8 reps @ 40 kg:
| recording | sets | reps | volume | rest |
|---|---|---|---|---|
| 3 bilateral | 3 | 24 | 960 | 90 s |
| 3 L/R pairs | 3 | 24 | 960 | 98 s |
| L, R, L | 2 | 16 | 640 | 95 s |
| L, L, L (rehab) | 3 | 24 | 960 | 90 s |
| a single L | 1 | 8 | 320 | — |
Three exports carry this, all in @enode/core/training/exercise-overview unless
noted:
countWorkingSets— how many prescribed sets a session is, counting a set from the moment it is started. What the summaries and tables show.countCompletedSets— how many are finished, so an unpartnered side does not count. WhatnumberOfCompleted, and through it workout progress, reports. The two deliberately disagree while a pair is half-done.sideWeights— each set's share of a prescribed set (see above).markSecondSides— flags, per set, whether it completes the prescribed set before it rather than starting a new one. Every view that numbers sets uses it, so a pair carries one number across the app.sideLabel(@enode/ui/set-side) — the "L" / "R" marker,nullfor a bilateral set, plussetTickLabel/setFullLabelwhich compose it with a chart set'sdisplayOrder("2 L", "Set 2 L").
Numbering is the only thing that reads set order; the totals never do.
Why weight per set rather than pair sets up
Pairing is the obvious model and it is unsound, because nothing guarantees a left and its right are adjacent:
- the athlete picks the side by hand (the Left/Right/Both picker in
CompleteSetSheet), so all-left-then-all-right is one tap away moveSetre-inserts a set by date, which can land a left between an existing pair- deleting one side leaves an orphan mid-list
Under adjacency-pairing an all-left-then-all-right session has no pairs at all,
and the double-count returns. Set order is no escape hatch either — both sides
of a pair persist the same order.
Weighting by the L/R tallies avoids all of that. It reads order in exactly
one narrow place: picking which sides are the unmatched surplus, latest-first by
created. That choice only moves the total when the loads differ between
same-side sets, and it degrades to array order when created is absent — where
adjacency-pairing gets the answer flatly wrong on an ordinary recording.
Consequences
- An unmatched side counts whole, and volume can fall when its partner lands. L at 40×8 reads 320; when R at 30×8 completes the pair, both become halves and the set reads 280. The card is a per-side-equivalent estimate, and the second side revised it down — the work is not lost, the estimate got better. The alternative (always halving) has no such step, but it reports a single-side session as half of what it was, which is worse: see the rehab row above, where halving gives 480 and a set count of 0.
setsand progress deliberately disagree while a pair is half-done. A card can read "2 sets" (both were started) while the schedule still wants the second one finished. They answer different questions —countWorkingSetsvscountCompletedSets.highestLoadstays the heaviest load actually lifted. It is a max, so it was never broken; averaging a 40/30 pair would report a 35 kg the athlete never touched on a card coaches read as a PR.avgReststill reads a few seconds high for a per-side session: the side switches sit inside the session span. That is the same kind of over-estimate the stat already carried for a set's own work time, and far better than the near-halving it produced before.SetRowkeeps one row per recorded set and gainsunitIndex, the number of the prescribed set it belongs to. Both sides share it, so a card numbers the pair once and marks the second row with its side — pair numbering without averaging away the left/right asymmetry, which is the main thing per-side tracking exists to show.- Set counts that previously read
sets.lengthnow exclude warm-ups too. This changes bilateral sessions as well: 2 warm-ups + 3 working sets now reads 3, not 5.
Open: the server's convention is unknown
The backend computes its own volume and set counts
(history-stats muscleUsage.setCount, volumeCompletion.absoluteValue,
/analysis/volume_over_time). Nothing in this repo states whether it counts a
unilateral L/R pair as one set or two — no comment, field name, doc, test or
fixture. The portal's session Summary tab renders a client-computed volumeLoad
next to the server's muscleMapVolume.
This decision covers the client only, which is what makes the client self-consistent. Still to do:
- Capture a real unilateral session against backdev and diff the server's figures against these.
- If they diverge, stop rendering a client-computed volume beside a server-computed one on the same card — do not un-fix the client.
Worth raising with the backend team regardless: if the server counts each side as a set while athletes' muscle targets were set in whole sets, weekly goal completion is already inflated for unilateral-heavy athletes.
Not covered
The live-session set counters (live/derive-*.ts) still count raw rows. The
tracking set list (set-list.tsx) numbers pairs correctly but does it with its
own Math.floor(index / 2) heuristic, which halves bilateral sets in a
session that mixes both and mis-numbers a list containing a stray side; it
should move to markSecondSides.
sessionDrop (performance-insights.ts) measures the peak → last fall-off
across a flat list of per-set means, so for a per-side session it reads the
zig-zag between a strong and a weak side as fall-off. Fixing it needs a product
call first: whether drop-off is per side or across pairs.
The retrospective fill path hardcodes side: "bilateral"
(retrospective/use-retrospective-upload.ts), so a retrospectively-entered
unilateral exercise loses its L/R split entirely.