Skip to main content

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:

SourceWhere
The sensor validator, when a set completesrep-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 appThe reps card's Toggle valid (complete-set-reps-card.tsx)
The coach, in the portalThe 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.tsisCounted / 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

SurfaceTreatment
Performance chart, hero chart, metric cards, set-list metric barsThe bar stays; its metric colour is muted via repMarkFillStylemutedInvalidColor (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 tabThe 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 timelineThe 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 from countedRepCount at 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 the repCount measurement 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 wireWorkoutLiveSessionRepCreateDto has no valid field, 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). Because valid is part of the broadcast-diff hash, marking a rep invalid after the fact triggers an update and removes it. Backend wishlist: add valid to 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 through SessionCreateDto / 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).