Invalid reps
The rule: an invalid rep is always visible, and never counted.
It keeps its place, its rep number and its mark in every list, chart and timeline — muted, so it reads as excluded at a glance. It contributes to no count, mean, extreme, estimate or export.
The rule exists because the two failure modes are equally bad. Hiding a rep makes the app look like it lost data the athlete performed ("I did 8, it says 6"). Counting it lets one bad rep move a 1RM, an RIR or a session best.
Two different things are called "invalid"
1. Firmware rejection — no rep is created
The sensor rejects a movement outright: repConcentricEndInvalid (0x12),
repEccentricEndInvalid (0x17), repEccentricWLEndInvalid (0x14) — see
enode-sensor-protocol.ts.
rep-recorder.ts drops the sample
buffer and use-sensor-reps.ts
discards the cached eccentric, so no WorkoutRep is ever built. Nothing
downstream can show what was never created.
This is currently silent in the UI — an open follow-up tracked in
ble.md ("Visible rep-rejection cue").
2. The valid flag on a recorded rep
WorkoutRep.valid (models.ts).
Every rep is built valid: true and is only ever flipped to false by:
| Source | Where |
|---|---|
| The sensor validator, when a set completes | rep-validation.ts — border-rep metric outliers (distance > 30 %, velocity > 65 % off the central mean) OR a raw-data shape score > 2.0 |
| The coach, in the tracking app | The reps card's Toggle valid (complete-set-reps-card.tsx) |
| The coach, in the portal | The per-rep Mark invalid menu (session-reps-tab.tsx) |
The validator only flips true → false, never back, so a coach override
survives a re-run.
The one seam for "does this rep count?"
training/rep-scope.ts —
isCounted / countedReps / countedRepCount. A rep counts when it is
concentric (the eccentric phase is the lowering half of the same physical
rep, not its own series) and valid.
Every aggregate goes through it. Adding a new number to the app means calling it, not re-deriving the predicate.
The coaching layer has a second, narrower seam:
toCcSet applies countedReps once
when projecting into the CC… types, so no coaching estimator re-checks
validity — 1RM, exertion/RIR, capacity, muscle stimulus and the regression
inputs all inherit the scoping from that single call.
Where invalid reps are shown
| Surface | Treatment |
|---|---|
| Performance chart, hero chart, metric cards, set-list metric bars | The bar stays; its metric colour is muted via repMarkFillStyle → mutedInvalidColor (30 % of the hue). The phase texture is kept, so an invalid eccentric still reads as eccentric. |
| Reps card (tracking) | Muted row + InvalidRepBadge, plus an "N invalid" count on the collapsed header. |
| Reps table (portal) | Muted row + InvalidRepBadge in the rep-number cell — muting alone would be ambiguous, a deleted rep mutes the same way. |
| Technique tab | The rep pill stays selectable with a muted dot; a banner above the charts names it. Inspecting the trajectory is often exactly why the rep was ruled out. |
| Set-review timeline | The block stays and stays scrubbable — the video runs through it — at a fraction of the normal fill weight. The timeline's resting state is already grey, so weight is the only signal available there. |
| Only marks that ARE reps carry the invalid styling. A bar that stands for a | |
| whole set (the compact metric cards' set mode, the big chart's "Sets" mode) or | |
| for a load (the load-response curve) is not a rep, and a set is not invalid | |
| because one of its reps was ruled out. Those marks stay in the plain metric | |
| colour; the exclusion shows the only way it can at that altitude — the rep is | |
| already out of the mean / peak. Rep validity is drawn where reps are drawn. |
A set never disappears, though. Both aggregate views derive the bar height
from a value that is null when nothing counts (set.mean,
concentricStats().max), so a set listed for a metric whose concentrics all got
ruled out rendered a zero-height bar — a bare set number with nothing above it,
indistinguishable from a rendering bug. It now draws a short stub instead. That
is a "no value here" marker, not a validity cue.
The shared chip is InvalidRepBadge.
It is feedback-error on feedback-error-surface — the same negative chip the
portal uses for a failed attempt — so a ruled-out rep is unmistakable at a
glance in a dense rep list. The surface is opaque, which also lets the chip
float over the reps card's scrolling columns without a backing layer. The same
red is used for the {count} invalid count in the reps-card header, so the
summary and the rows agree.
Rep numbers never shift. Lists number over all reps of the phase
(phaseOrderOf), so "Rep 4" means the same rep in the set list, the reps card,
the timeline and the technique pills. The two "best rep" labels are labels, not
aggregates, so they point at the same position: bestRepIndex
(set-review.ts) and
bestRep().repNumber
(performance-insights.ts)
both number over ALL concentrics. Only counted reps can win the comparison.
Where invalid reps are excluded
repCount— stamped fromcountedRepCountat set completion and re-derived on every rep edit, so marking a rep invalid drops the count immediately, exactly as deleting it would.- Volume load / session summary (
exercise-overview.ts) — counts the reps when the set carries them, falling back to therepCountmeasurement only for sets that don't (manual sets, list reads without the rep tree). Counting first is deliberate: sessions recorded before the stamp was scoped carry a measurement that still includes invalid reps. - All coaching — via
toCcSet(see above). - Session focus metric — the session headline can't be an invalid rep.
- Set means, extremes, spread, drop %, best rep — set-chart-service, set-review, performance-insights, performance-format, and the portal's heatmap + min/max markers.
- The live wire —
WorkoutLiveSessionRepCreateDtohas novalidfield, so an invalid rep can't be labelled on the live board. It is withheld from the broadcast rather than sent unmarked (live-sessions.ts). Becausevalidis part of the broadcast-diff hash, marking a rep invalid after the fact triggers an update and removes it. Backend wishlist: addvalidto the live rep DTO, then send everything and mute it like everywhere else.
Deliberate exceptions
- Chart axis bounds scale over all reps, invalid included. This is scaling, not a reported number — excluding them would let a muted bar overflow its plot.
- Session duration spans the first to the last rep regardless of validity — the athlete was training for that whole time.
- The validator's own input (
toRepValidationInputs) takes all reps. It is the producer of the flag, and its border-outlier logic keys on positions in the full list. Known limitation: when a set is committed in two batches, reps already ruled out in the first batch still feed the second run's central reference. - Reps are uploaded and stored with
valid: false— never dropped. The flag round-trips throughSessionCreateDto/WorkoutRepUpdateDto.
Tests
rep-scope.test.ts, mappers.test.ts (the coaching seam),
exercise-overview.test.ts (counting vs. a stale measurement),
set-review.test.ts (timeline flag), set-list.test.ts (bar kept, numbers
excluded), live-sessions.test.ts (withheld from the broadcast).