Velocity profile — reps drawer
The Profiles page (apps/portal/src/app/dashboard/data/performance/) plots a
load–velocity profile per athlete × exercise. The chart endpoint returns a fitted
curve plus a sample scatter with no ids, so a coach can read the curve but not
act on the data behind it. The reps drawer answers "which sets is this curve
made of, and which of them look wrong?": the same chart on top, then every set
the athlete recorded for that exercise inside the picked timeframe, with the
server's outlier verdict on each set and rep. The coach decides what to rule out.
What opens it
A strip under the plot — "47 sets behind this curve · review them" — is
the way in. PairChartCard takes an optional footer render prop, handed the
card's whole fetch state; only the velocity tile passes one
(VelocityRepsAction, profile-reps-drawer.tsx). Because it lives inside
usePairAnalysisTiles, it follows the tile onto every surface that renders the
analyses: the Profiles page, the users-section profile drawer, and the live hub's
athlete drawer. Weightlifting lifts get no velocity tile at all, so it does not
exist there.
The sample count comes from sampleCount (components/charts/analysis/shared.ts),
which reads the scatter points already in the chart payload — free, and taking
the largest axis rather than the sum (only the velocity axis carries samples; the
power axis holds its curve and a single peak marker).
The footer is the card's, not ChartShell's legend. It has to render in every
state — the legend slot is skipped when a chart is empty, which would strand an
athlete who has sets but no fitted curve, and anything inside render() unmounts
during a refetch, which would close the drawer on save.
The drawer re-renders the card's chart from that same payload rather than fetching its own, so the two can't disagree. Two rules keep that true while an edit refetches the card underneath an open drawer:
- The footer renders unconditionally, including under the card's skeleton. It
owns the drawer, and hiding it while
loadingwould unmount the drawer mid-refetch. The strip is what hides; the drawer host stays mounted. - The chart state is passed around
useDrawerHost, not through it. That hook snapshots itstargetwhen the drawer opens and holds that snapshot for the drawer's life — right for the pairing, wrong for the chart (it has to follow the refetch).DrawerStackre-readsrootevery render, so a separatechartprop reaches the view live.
Where the sets come from
Two endpoints, one per half of the drawer:
| Half | Call | Notes |
|---|---|---|
| Chart | getVelocityProfileData → GET /analysis/velocityprofile/data/{userID}/{exDefID} | Line + scatter; scatter points carry setID / repID, and the response a status. |
| List | getVelocityProfileSets → GET /analysis/velocityprofile/sets/{userID}/{exDefID}?from&to&offset&limit | Sets with their concentric reps, ids, and outlier flags. Loaded in use-profile-reps.ts. |
Two further endpoints exist for this pairing and are wired in @enode/core —
GET …/velocityprofile/at/{userID}/{exDefID}?date= (the profile as of a past
date; reads and writes nothing) and
POST …/velocityprofile/recalculate/{userID}/{exDefID} (refits and stores one
pairing inline). The drawer uses neither directly; the recalculate route backs
the Calculate profile button (see below).
Both are called with the same bounds, velocityProfileBounds(range) — always
both ends. That matters: without bounds the chart endpoint draws no scatter,
while the list endpoint falls back to its own default window
(isDefaultWindow: true). Sending the same explicit bounds keeps the list and
the scatter describing the same sets.
The profile has its own section, and its own controls
On the Profiles page the velocity profile sits above the timeframe picker,
in a section of its own (velocity-profile-section.tsx). It does not answer to
that picker: the other three analyses cover a range of days, while a profile is a
single line, and the two questions a coach has about it are as of when and
fitted on how much. So the section carries:
- As of — one date, the section's only visible control. It becomes
GET …/velocityprofile/at?date=: the server finds the last session at or before it and fits the window ending there, reading and writing nothing stored, so any date can be asked freely.
The span: a caption that happens to be a control
PROFILE_FIT_WINDOW (analysis-range.ts) is the 8 months up to the athlete's
last session, and it is what every surface draws — it is the span the stored 1RM,
the matrix and the tracking app are all fitted on, so it stays the default
everywhere and a coach never has to have an opinion about it.
A coach who does have one changes it where the number is already written down:
the caption under the card's title ("Fitted on the 8 months up to 4 Sep") is
an ActionMenu trigger offering PROFILE_FIT_WINDOW_PRESETS — 6, 8 and 12
weeks, 6 months, and the 8-month default. That placement is the design: the
sentence states the span, so the one coach in twenty who wonders about it clicks
it, and the other nineteen read a caption. A page-level picker beside As of
would have asked everyone to decide.
What a picked span is, and is not:
- It is a what-if line, not the athlete's profile: the server fits it on the fly and stores nothing, so it will not match the 1RM badge elsewhere, the matrix, or the tracking app. The caption drops its muted styling while a non-default span is on screen — the line above it is then not the profile.
- It is per pairing: the section keys the picked span to
pairKeyand falls back to the default for the next athlete or lift, so a lens can't follow a coach around the page unnoticed. (The date does follow — it is a question about a day, not about a curve.) - It moves both calls: the span rides in the
query, so the line, the dots, the flags and the drawer's list are all fitted on it. Nothing on screen is left describing the old span. - Presets, never a free amount + unit: each is inside the server's 1–120 and
keeps
anchor: "lastSession", so a pick can't answer 400 and can't ask thenowanchor, which on the recalculate route can delete a paused athlete's profile. - A short span often has too few loads to fit at all. The empty state keeps its own wording (it already names the window's dates) and gains Back to the full profile as its first action, so "no curve over 6 weeks" is one tap from recovery instead of a dead end.
/at answers coefficients rather than a chart, so
velocity-profile-at-chart.ts assembles the payload /data would have sent: the
line and the power curve from the fit, and the dots from the sets of the
fit's own window, which arrive in the same response (includeSets=true). No
window is resolved on the client: the sets the server sends are the sets the
server fitted on, each judged against that fit.
Asking for the sets rather than /data's scatter buys three things at the same
cost: the dots carry ids, so a click lands on a card; they carry the server's
flags and definesProfilePoint, so the card's chart offers the same two
controls the drawer's does — Outliers (show or hide the flagged dots) and
All samples (bring back every dot the line does not lean on); and the load is
the one the drawer wants.
That last point is the whole data story: opening the drawer costs no request.
The card hands its window down as the drawer's scope, and both surfaces look
the window up under the same key (peekProfileRepsWindow), so the drawer renders
from the load the page already awaited — the same sets, judged against the same
fit, with the card's chart data passed in as props rather than refetched. Its
header names that window rather than the picked timeframe. A request is only
made when nothing has asked the question yet: a surface without the page's date
picker (the users drawer, the live hub — they scope by the picked timeframe as
before), or a window a save evicted, where a re-read is the point.
Two consequences follow from asking about a date rather than about now:
- The line runs to the fit's own
estimated1RM, not the athlete's stored 1RM measurement — the stored one is fused from several estimators and describes today, and in historical mode the server does not even read it. - A dot is always a set the line saw.
The profile drawer a session opens (users/profile-drawer-view.tsx,
reachable from a history card's "Inspect Profile") renders the page's own two
pieces — PairHeader (the athlete's photo × the exercise's animation, their
names, the variation line with this athlete's 1RM, and the PDF export) above
PairChartsColumn — so it asks the same endpoints and carries the same title,
"As of" date, fit-span caption and timeframe picker. Both live in
data/performance/, and they are the only place either is written. The drawer's
top bar therefore carries no title of its own: it would have repeated the
header's first line with room for none of the rest. It is as wide as the reps drawer it opens (max-w-[720px]),
so going one level deeper widens nothing. The live hub still renders a bare
VelocityProfileCard without a query and gets the stored profile.
The page's analyses are one stacked column, always: the velocity profile at
the top, a rule, then the timeframe picker and the other three charts, which stay
drag-reorderable among themselves (TechniqueBoard). There is no grid/list
switch — side by side, a card carrying a plot, a badge row and a footer strip was
too narrow to read, and the profile is full width either way.
Two windows, and only one of them is the picker
The drawer deals with two spans, and confusing them is the easiest mistake to make here:
| What it chooses | Who sets it | |
|---|---|---|
Picked timeframe (PickedRange, the page's toolbar) | which sets are plotted and listed | the coach |
Fit window (windowAmount / windowUnit / windowAnchor) | which reps the regression is built from, and therefore every residual verdict and definesProfilePoint | the portal, pinned |
Picking "Last 7 days" has never narrowed the curve — it narrows the dots around
it. Since the backend's own default window grew to 8 months, that gap is wide
enough to mislead, so the portal now says which window the line is fitted on:
windowLabel (velocity-profile-window.ts) renders it as the velocity card's
subtitle and as a caption under the drawer's chart.
PROFILE_FIT_WINDOW (analysis-range.ts) pins the request to 8 months up to
the athlete's last session. Worth knowing what that span means: the fit keeps
the fastest rep per load across the whole window with no recency weighting, so a
February best outranks every September set at that load — the curve describes
best form over eight months rather than current form. Both velocity calls send
the same window, or the line and the verdicts computed against it disagree.
On a deployment that predates the window parameters the request is simply ignored
and the response carries no window. resolveProfileWindow
(@enode/core/analysis/velocity-window) reports that as source: "legacy", and
the label renders nothing rather than printing a day count.
The list endpoint marks, it doesn't remove: every set and rep in the window
comes back, invalid reps included. Two things are absent because the chart has
no place for them either — sets without a load measurement, and non-concentric
reps. Every velocity is mean concentric velocity; loads arrive in the user's
display unit (loadUnit), so the drawer renders them verbatim.
Unlike the chart endpoints, the list endpoint does not answer a failure with an empty payload — an empty list would look like "didn't train" — so failures reach the drawer's error surfaces.
The chart's scatter points carry optional setID and repID (the rep whose mean
velocity became the dot's y); see Chart marks. chartX on each
set equals the scatter x for that load; it groups by load (one load can be many
sets) and is not an identity.
Chart marks
The drawer marks dots on its chart:
- Hovering a set card rings that set's dot and draws it slightly larger — in the warning colour for a set holding an outlier, otherwise in its own series shade.
- During a cleanup run the set under review keeps its dot ringed while nothing is hovered.
- Sets with something to review (a flagged set, or a set holding a flagged
rep — anything
detectOutlierscollects) are drawn as a bigger dot (3.5 px radius) filled in the design system's warning colour (--feedback-warning) — the same hue as the outlier chips. - The Outliers badge next to 1RM / Peak power shows or hides those warning
dots. Where it starts is the host's decision (
createProfileOverlayStore's defaults), as is All samples: the reps drawer opens on every set with the outliers marked, the Profiles page's card on the clean curve — its own points, no warning dots. It appears only while some set is flagged, and only on a chart given anoverlaystore — the performance card's chart has none. Hiding the warning dots leaves the hover / cleanup highlight alone. The on/off state isshowWarningsin the overlay store, so toggling only redraws; the badge reads just that flag and "anything flagged" from the store, so hover writes never re-render the chart. - The All samples badge next to Outliers chooses between the fit's own input
points (the sets with
definesProfilePoint) and every measured dot. Off is the narrowed view, on is the whole cloud, and the two hosts open opposite: the Profiles page's card off, on the curve itself, and the reps drawer on, because a coach opens it to judge sets and wants them all in front of them. It appears only while the chart knows at least one profile point, and the narrowing only bites then: while the sets are still loading, or when there is no profile point at all, every dot shows regardless. The state isprofilePointSetIDs/onlyProfilePointsin the overlay store — and in the reps drawer it is also the list's profile-point filter chip, so chart and list narrow together; theprofileOverlayplugin sets chart.js'sskipon the other point elements inbeforeDatasetsDraw(isDotHidden), so they are neither drawn nor hit-tested (no tooltip, hover or dot click), get no marks, and nothing about the data or options changes — the curve doesn't replay its animation. The fitted line and the power curve are unaffected.
A click on a dot goes the other way, chart → list:
- While browsing, the list scrolls its own scroller to that set's card (rendering
it first if it sits past the on-screen page) and the card flashes a ring for
1.6 s — in
foreground-default, the same dark ring the chart draws around a hovered set's dot. Clicking the same dot again scrolls and flashes again. - During a cleanup run the list shows one set, so there is nothing to scroll: the run jumps to that set's review — the set itself when it is flagged (a dot is the set), else the clicked dot's rep when that rep is flagged, else the set's first flagged rep. A set with nothing to review leaves the run where it is.
- A hit is the nearest dot within
DOT_HIT_RADIUS_PX(10 px) of the pointer (nearestRefAt), since the dots are too small for chart.js's exact intersection. The cursor turns into a pointer over a dot, set on the element directly so hovering never re-renders the chart. VelocityProfileCharttakes the handler as an opt-inonDotClick; the drawer hands the memoisedChartPanela stable callback that forwards to the latest handler.
How it is wired, and why:
buildVelocityModelreturnsscatterRefs: for every scatter point with asetID, its chart.js element (datasetIndex,index) and ids.numericPointsWithRefsfilters exactly likenumericPoints, so the indices line up with the dataset.VelocityProfileCharttakes an opt-inoverlaystore (velocity-profile-overlay.ts). TheprofileOverlayplugin reads the store during each draw and paints over the real dot positions; a store change only callschart.draw().- The store reaches the plugin through
bindProfileOverlay(chart, binding)— a WeakMap keyed by chart instance — never throughchart.options. chart.js resolves plugin options through its scriptable-option proxy, which calls any function it finds with a draw context; the store'sget/set/subscribepassed as an option would be invoked by chart.js and the chart would throw on its first draw. - Nothing about hover goes through
data,optionsor React state. react-chartjs-2 replays the curve's entry animation whenever those change identity, so the store is created once per drawer view andChartPanelstays memoised. Card hover writes straight to the store (SetHoverZone), and a card that unmounts under the pointer clears its own mark. - The card on the Profiles page passes no store, so nothing changes there.
- Only the velocity axis has sample dots. A payload without
setIDon its points yields no refs, and nothing is marked.
Chart status
GET /analysis/velocityprofile/data carries a status next to the chart data
(VelocityProfileDataReturnDto): a reason code plus the numbers behind it. The
server sends codes; every word is the client's. useAnalysisData returns the
whole response, PairChartCard hands it to render and to the footer, and the
velocity chart's empty state is built by useVelocityProfileEmptyState
(velocity-profile-empty-state.tsx) — on the Profiles card, the profile drawers
and the reps drawer alike.
reason | Empty state |
|---|---|
ok | chart (the generic empty copy if there is nothing to draw) |
noSessions | "This lift hasn't been trained yet." |
noValidReps | sessions in the window, but no valid reps with load, velocity and ROM |
tooFewLoads | every usable rep at one load — a profile needs different loads |
lowCorrelation | velocities vary too much across loads to draw a reliable curve (the R² values stay out of the copy) |
invalidSlope | velocity doesn't drop as the load rises |
notCalculated | enough data, not calculated yet — plus a Calculate profile button |
noRm1 | title "No 1RM for this lift yet"; a profile exists but nothing spans the chart |
statusView(velocity-profile-status.ts, pure, tested) picks generic / message / refit. Messages name the window ("Between 4 Nov and 30 Dec …") only whenwindowFromandwindowToare both present; otherwise a variant without dates is used, so no sentence is stitched from fragments.statusis null when the server's diagnosis failed, and absent on a backend that predates it: both fall back to the long-standing generic copy.- Calculate profile (
writeDataonly — the read stores the refitted model) runsrefitVelocityProfile: resolve the athlete's exercise instance fromgetRelevantExercisesCached,GET /exercises/{id}(refits as a side effect), theninvalidateAnalysisso the pair's charts refetch. A failure goes throughpresentErrorwith a retry.
When the chart itself fails to load, the drawer shows an error InlineAlert in
its place; Try again calls invalidateAnalysis for the pair, so the card
refetches and the drawer follows it. An empty window (no sets at all) renders as
a DataEmptyState.
Loading the whole window
The drawer counts outliers, and "7 outliers to review" is only honest over every set
in the timeframe — not over a first page. So use-profile-reps.ts loads the
whole window:
- The first request (
offset=0,limit=100, the endpoint's ceiling) renders as soon as it lands. windowPageOffsets(total, firstPageSize)lists the remaining pages; they load two at a time and each merges into the list as it arrives.MAX_WINDOW_SETS = 600bounds a load. Past it the drawer shows a warning ("Showing the newest 600 of N sets") and asks for a narrower timeframe.- The bounds are resolved once per load, so every page asks with the same "now".
appendPagedrops a set a shifted offset served twice (a set recorded between two pages moves every later row by one), sosetIDstays a unique key.
The outlier count waits until the window is complete. The list itself is paged on
screen (RENDER_PAGE = 20, "Show more").
Caching and generations
A module-level cache keyed userID:exDefID:rangeKey(range) holds complete
windows only; failures and partial windows are never cached, so reopening
retries. A load in flight is shared: reopening the drawer mid-load follows it.
Each load carries a generation. refresh and reload start a new one; a
superseded load stops requesting pages and may no longer write to the cache or
to state. This is what keeps a background load from an older fit from landing
after a save's reload.
A failed first page shows the error surface with a retry. A failure after the first page keeps the sets already loaded and shows "Couldn't load every set — the outlier count may be incomplete" with a retry.
Layout
The drawer follows the portal's drawer layout (space-y-6 sections, each a
SectionLabel over its content):
- Header — close on the left, "Athlete × Exercise" and the timeframe, then on
the right How it works (
HowItWorksButton, opens the Profiles page's walkthrough on the velocity profile), a View options menu (sliders icon —ActionMenuwith checked items, as in the session drawer's Reps tab: Show range of motion), and, once edits are staged, Save. There is no Discard button: the close button is the cancel, and closing with staged edits asks to discard them — as in every other editing drawer. - Velocity profile — the card's chart (blurred with a notice while the curve
is being updated, see Recalculate, then reload).
The chart sits in a
StickyProminentHeaderCard(@enode/ui), the tracking app's sticky header card: it pins under the drawer header and the rest of the body scrolls behind it, so the curve stays in view while browsing or reviewing sets. A click on a dot scrolls the set's card to just below the pinned chart. A chart that failed to load shows its error alert in place, unpinned. - Outliers — a
Cardholding oneDetailRow, the portal's title / description / action shape: the outlier count as the title, Review outliers at its right, and the description under it (review progress, what gets a set or a rep flagged, no curve yet). Under the card: the reloading caption, then any alerts. - Sets (Under review during a run) — the set count at the label's right edge; while browsing, a row with the one filter chip (outliers — it only ever filters, and the row is left out when there is nothing flagged); then the list grouped by load, with Show more under it.
- Floating at the bottom edge: the cleanup pill during a run, the selection pill otherwise.
Wording
The drawer speaks to coaches, not to the statistics behind it. Its copy stays in plain words: no scores, thresholds, load bands or R² in the UI — flags are named for what they mean ("Slower than expected", "Unusual velocity"), and the rule behind them is one plain caption. The one backend concept on screen is the Profile point pill (see What is rendered). The technical terms below are for developers; they are deliberately not surfaced.
Outlier cleanup
The Outliers section's card says how many outlier reps the server flagged
("7 outliers to review" — flagged sets and flagged reps together), with Review
outliers at the right of that line. The
count waits until the whole window has loaded ("Checking for outliers…"). Without
writeData the card shows the count but no button.
What is reviewed
detectOutliers in profile-cleanup.ts (pure, unit-tested) collects the review
items. The server flags; the module only collects, and never invents a flag. Each
flag is reviewed at the level it is about:
| Item | From | Decisions act on |
|---|---|---|
Set (kind: "set") | a set flagged belowProfile / aboveProfile — judged on its fastest valid rep against other sets at a similar load (or the curve) | the whole set: Invalid marks every rep that was valid invalid, Delete stages every rep, Keep keeps the set |
Rep (kind: "rep") | a valid rep flagged velocityOutlier / rangeOfMotionOutlier — judged against the rest of its own set | that one rep |
Why a flagged set isn't reviewed through its fastest rep. A set flag is about the set. Ruling out only its fastest rep promotes the next-fastest into the same verdict — a slow set stays slow — so the next review would offer the next rep, and the set would be worn down one rep at a time. Set and rep flags are also independent: a set that is slow throughout usually has no flagged reps, since its reps sit close to their own median.
- The flag is the verdict. The server flags only when the robust score is past
scoreThresholdand the deviation reachesminimumDeviation(0.05 m/s), so a big score without a flag is not an outlier, and a flag is collected whatever its score. - A flagged set comes before its own flagged reps, which stay separate items. A set ruled out (invalid or delete) covers them: they read as decided and the run skips them. A set kept leaves them to review on their own.
- Items are addressed by
key(set:<id>/rep:<id>); keep marks use the same keys.decisionOfreads a set as invalid / deleted once every rep is, andapplyDecisionstages a decision — replacing a set's ruling-out clears the edits on its reps; keeping an undecided set leaves rep decisions inside alone. - Never collected: invalid reps (already ruled out) and a flagged set with no valid rep. A set below the load threshold is collected for its flagged REPS: the threshold only says the set is too light to be judged against the profile (so it carries no set flag of its own and never defines a profile point), while a rep that is wrong against its own set is just as reviewable there.
outlierSetIDs(sets)is this same detection as the ids a chart marks. The Profiles page's card marks its warning dots with it, so the card cannot show a dot the drawer has no item for — one definition of "this set has an outlier".- Order: outliers on sets with
definesProfilePointcome first — they are the only ones whose decision can move the curve — then every other outlier. The order and the set card's Profile point pill are where the concept is used. Each group keeps reading order (newest session first, sets in the order they were done, reps in order). - The list comes from the loaded flags only, so it never changes under a decision; it changes when a save reloads the window.
Which outliers move the curve. The fit (EnodeCoachingCore .velocityProfileModel) drops reps more than 20 % off the reference distance,
keeps the fastest rep per exact load across all sets of the 56-day profile
window, keeps loads above 50 % of the maximum, adds an estimated-1RM point, and
fits a weighted linear regression. The server marks the set holding each of those
points with definesProfilePoint — the set supplies an input point to the
fit (the weighted regression is fitted on it). It does not mean the fitted line
passes through that point; a profile point can sit clearly above or below the
curve. The drawer orders the run by it and shows a Profile point pill on those
set cards. TODO: confirm with the backend whether the flag is set before or after
the hullCurve envelope filter, and how the points are weighted — neither is in
this repo. It is the fastest rep per exact
load, so a slow set that is alone at its load is still a point — ruling it out
does move the curve; a slow set with a faster set at the same load does not.
It is evaluated on the profile's 56-day window, not the drawer's timeframe. (EnodeMath.hullCurve is meant to filter those points to the upper
envelope but currently returns them unfiltered — backend open point — so every
best-per-load point feeds the regression.)
The run
use-profile-cleanup.ts holds the run; the pill is profile-cleanup-pill.tsx.
- Review outliers opens the run on the first undecided item. A later run resumes there ("Continue review"); once all are decided the button reads "Review again".
- While a run is open the list narrows to the one set under review (or holding
the rep under review), with its session date. The chart, the outlier count and
that set fit together, so the item and the curve stay in view without anything
being sticky. Starting a run scrolls the drawer back to its top (only the
drawer's own scroller moves). What is under review — the rep, or every rep of a
flagged set — is highlighted with the warning tint (
feedback-warning/10), distinct from the red-orange tint of a selected rep, which the shared rep lists use. A kept set shows a Kept chip in its header; a kept rep, on its row. Once every outlier is decided the list is empty and the pill carries the summary; closing the run brings the full list back. - The pill at the bottom of the drawer is one compact row, so it covers as
little of the set and its evidence as possible: ‹
3/7› · Invalid · Delete · Keep · ×. One word per button; each button's full action and keyboard shortcut are in its tooltip, and why the rep was flagged shows on the set card (its flag chips) rather than in the pill.- Invalid (primary) — reversible, and the fit already ignores invalid reps.
- Delete — staged; the hard delete is confirmed at Save.
- Keep — reviewed, and real data (a genuine best, say). Session-only: never written, survives a refresh, gone when the drawer closes.
- A decision replaces any decision the rep already had, and the run moves to the next undecided rep, wrapping round to pick up any that were skipped.
- ‹ and › step to the previous / next item whether decided or not; a revisited
item shows its staged decision as the pressed button. Rep items inside a set
that was ruled out (invalid or delete) are passed over — the set decision
settled them — so stepping lands on the set, never on one of its reps
(
coveringSetItem,stepIndex). A tap or chart click that targets such a rep lands on the set too, and an arrow is disabled when only covered reps lie that way. - During a run, every rep row also shows a range-of-motion bar — always, since
ROM is part of judging an outlier. While browsing, the bars are opt-in through
Show range of motion in the header's View options menu (off by default,
lasts while the drawer is open; during a run the item shows checked and
disabled). It is a view option, not a filter, so it doesn't sit in the chip row. Each bar shows the rep's
distanceunder its velocity bar: a step thinner, in the distance metric's category colour, against one drawer-wide scale (distanceScale), with a legend naming both bars. The value is printed under the rep's velocity reading, converted by the unit system:useUnits().formatMetricwith the catalogue'sdistancemetric, whose dimension decides the conversion (length: stored metres → cm in metric, in in imperial), re-rendering when the user flips systems. Before the catalogue has loaded, thelengthdimension stands in. This assumesdistancearrives in stored metres, like every other measurement — confirm against the payload. - During a run, tapping a rep jumps the run to that rep's own item when it is flagged, else to its set's item when the set is flagged. The pill's button tooltips name what a decision acts on ("Mark the set's reps invalid" / "Mark invalid"). Raw data for a set under review opens on the set's first valid rep.
- When every outlier is decided, the pill shows "7 reviewed" (the tally —
invalid / to delete / kept — in its tooltip) with Back and a way to finish:
- Save (with ×) when edits are staged — writes them, the same save as the header's (deletions still confirmed first), and starts the recalculation. A save that lands closes the run: the outliers are re-flagged against the new fit. × ends the run and leaves the edits staged for the header's Save.
- Done when nothing is staged — every rep was kept (keep marks are review state, not edits) — and just ends the run; nothing is written or recalculated.
- Outside a run, taps select reps as before, with the Toggle valid / Delete / Done pill; the two pills never show together. A whole set is selected from its card's "…" menu (Select all reps, checked once every rep is selected).
Comparing and raw data
The card of the set under review carries two controls at the right of its header
(SetCard's headerActions), where item actions sit on portal cards:
- Compare similar sets (key
C, a toggle chip) shows, below the card, the two sets closest in date within the load band the server's outlier detection uses — ±outlierContext.loadBandPercentload and ±outlierContext.loadGroupDaysdays (similarSets,profile-similar-sets.ts, pure and tested). The set itself, sets below the load threshold and undated sets are left out; date ties go to the closer load, then the earlier set. The compared sets render as regular set cards with their dates and range-of-motion bars, and with staged edits applied; tapping a rep in one jumps the run to that rep's or set's item when flagged. The toggle stays on while stepping through the run. The heading reads "Sets at a similar load from around the same time" — the band's numbers stay out of the copy — and with no set in the band, a line says so. - Open raw data (key
Enter) pushesProfileSetRawView: the Technique screen (SessionTechniqueTab) for the rep's session, preselected on the set and rep — velocity / force curves, bar path, video. The session is stood in from its sets (GET /workout_sets/{id}each, with sensor data) plus the catalogue exercise; the full-screen set review fills that view. Back returns to the same outlier, and the run's shortcuts pause while the view is open.
Keyboard
Only while a run is open (use-keyboard-shortcuts.ts, rules in
keyboard-shortcuts.ts); each key is printed on its button:
| Key | Action |
|---|---|
I | Mark invalid |
D | Mark to delete |
V | Keep ("valid") |
J / → | Next outlier |
K / ← | Previous outlier |
U / ⌘Z | Undo |
Enter | Open raw data |
C | Compare similar sets |
Keys are ignored while typing, during IME composition, with Alt held, when Enter
or Space would activate a focused button, and whenever a modal other than the
drawer panel is open. They are off while the delete confirmation or discard prompt
is up and while saving. Escape is not bound — DrawerStack owns it.
What is rendered
The list is grouped by load (groupByLoad in profile-reps-window.ts). There
is no sort menu.
- One block per load, lightest first — reading top to bottom the way the
chart's load axis reads left to right. Sets are grouped by
chartX, so sets that share a dot position on the chart share a block. - A heading per block (
LoadHeading): the load inbody-prominent, then the number of listed sets at that load (after the filters, across the whole list — not just the page on screen). - Within a block the load's profile point comes first, then the other sets newest first. Sessions interleave, so each card shows its own date.
- Paging is per set (
RENDER_PAGE): the flattened block order is sliced and the slice regrouped, so a block can continue after Show more. - During a cleanup run the list shows the one set under review under its
session date (
SessionHeading,body-small, muted) instead of a load heading.
Above the list, one filter chip — Only sets with outliers
(MultiSelectChip, fixed label, checkmark when on) — narrows the list to the sets
with something to review (a flagged set or a flagged rep), keeping the load
blocks. It narrows the chart with it (see below); the chart's Outliers
badge is a different control — it colours the flagged dots, it does not choose
which sets are plotted. The chip is read-only, so it is offered without
writeData too, and hidden during a cleanup run (with the rest of the row). The
filter only applies while there are outliers: after a save re-flags the window
clean, the full list returns on its own.
The second narrowing — only the sets the fit uses (definesProfilePoint),
one profile point per load — has no chip of its own. It is the chart's
All samples badge: on (how the drawer opens) lists every set, in load blocks
with each load's profile point on top; off narrows the dots and the list
together to the points the curve leans on. A chip beside the badge would have
been a second way to ask one question. With the outlier filter it combines:
profile points that hold something to review.
The list lists what the chart plots. Neither narrowing is list state: both
are onlyOutlierSets / onlyProfilePoints in the overlay store (read with
useSyncExternalStore, so the store's frequent hover writes compare equal and
never re-render the list), and the list filters its cards with the same
isDotHidden predicate the chart draws its dots with. One decision, two
surfaces — the dots above the list always stand for the cards below it.
One dot is exempt: during a cleanup run the list shows only the set under review, and that set's dot stays on the chart whatever the filters hide — the rep being decided must have somewhere to point.
Clicking a chart dot whose set the list doesn't hold turns both filters off, then scrolls to that set. With the filters shared that is now a safety net for two cases rather than the everyday path: the set a cleanup run is on, and the moment after a save while the refetched chart and the re-read list still disagree.
- Set numbers (
setNumbers) are positions among this lift's listed sets in the session, computed over the whole window. The payload carries no warm-up or side, so warm-ups are numbered and a unilateral L/R pair counts as two sets — unlikeworkSetNumberselsewhere. - Bar scale (
velocityScale): the largest mean velocity across every loaded rep, so a bar means the same thing across sessions. Built from the loaded values, so staging an edit never rescales the bars.
Each set card (profile-set-card.tsx) shows its number, load, RIR, flag chip,
a neutral Profile point pill when definesProfilePoint (no explanation on the
card), and valid-rep count; then the set's note, if any; then one row per rep (number,
mean-velocity bar, value).
The rep's marks — Kept, its flag chip, the Invalid badge — sit on a line under its bars, inside the bar column, and the value column has a fixed width. Both are deliberate: the bars are drawn as a percentage of the bar column, so anything sharing the row would narrow that column for some reps only, and a faster rep would draw a shorter bar than its neighbours.
Set flags
flag | Shown as | Card |
|---|---|---|
normal | no chip | neutral |
belowProfile | "Slower than expected" (warning chip) | soft warning border (feedback-warning/40) |
aboveProfile | "Faster than expected" (warning chip) — a new best or a mis-entered load | soft warning border (feedback-warning/40) |
belowLoadThreshold | "Too light for the curve" (neutral chip) | neutral — not an outlier and not judged, so the card shows the chip only |
"Expected" is on the basis the server names in scoreBasis:
loadGroup(the usual case) — the other sets within ±loadBandPercent(3 %) of the set's load and ±loadGroupDays(28) days, when the band holds at leastminSetsForLoadGroup(4) sets. This works without a profile model.profileResidual— the residual against the stored profile line, used when the load band is too small, and only for sets inside the profile's own window.null— neither basis could judge the set.
No score is shown: score, residualScore, the best rep's velocity and the
value it was compared with (loadGroupVelocityMedian, expectedVelocity) stay
off the card, and the flag chip carries the verdict. There is no line under the
header: a set that couldn't be judged (a null score) or has no valid reps
simply carries no flag chip.
While there are outliers, the Outliers card's description explains the rule
in plain words: a set is flagged when it's clearly faster or slower than the
athlete's sets at a similar load; a rep when it doesn't match the rest of its set,
or its range of motion is off. When the
exercise has no model (model: null), the description adds that there's no curve yet, so
each set is compared with sets at a similar load.
Rep flags
flag | Shown as |
|---|---|
normal | nothing |
velocityOutlier | "Unusual velocity" chip — past the score threshold and at least minimumDeviation from the set's median, so noise in a very even set isn't flagged |
rangeOfMotionOutlier | "Unusual range of motion" chip — checked first: a rep that is both is reported as this |
invalid | the shared Invalid badge (invalid-reps.md) |
A rep's flag chip is shown only while the rep displays as valid, so staging a rep invalid swaps its chip for the Invalid badge.
Editing
Cleanup and selection need the writeData privilege; without it the drawer is a
read-only inspection surface. What an edit MEANS lives in profile-rep-edits.ts,
pure and unit-tested.
Everything is staged until Save:
validityholds repID → the value it should end up with — absolute rather than a flip, so repeating an action is idempotent, and normalised against the loaded value, so a change back to the loaded value leaves no edit and issues no write.deletedholds repIDs staged for removal. They stay visible, struck through, and the pill's Delete becomes Restore once the whole selection is staged.- Counts show what Save would leave, not what is loaded.
use-profile-rep-edits.tskeeps one undo history (20 steps) over the staged edits and the keep marks, soUwalks back a keep as readily as an invalidation. A save that lands clears it — undoing into a state that stages deletions of reps that no longer exist would make the next save fail. There is no in-drawer Discard: closing with staged edits asks to discard them, and discarding closes the drawer (edits, history and keep marks go with it).
Save
DELETE /workout_reps/batchfor staged deletions. Hard delete server-side (force: true) — no undo — so aConfirmDialognames the count first and points at marking reps invalid as the reversible alternative (the fit already ignores invalid reps).PUT /workout_reps/batchfor validity changes, fetch-before-edit: the touched sets are read withGET /workout_sets/{id}and the DTOs built withtoRepUpdateDto(repUpdateDtosFor). The list payload carries no rep note or tags, and the update endpoint treats tags as a full replace, so a validity flip is never sent from the partial list shape.
The buckets are independent: if the delete fails nothing is cleared; if the update fails after the delete landed, the deletions are cleared and refitted while the validity edits stay staged for the retry.
Recalculate, then reload
Neither deleting nor invalidating reps refits the profile on the backend. After
any part of a save lands, useProfileReps().refresh runs:
refitAfterSave(velocity-profile-refit.ts) refits this pairing alone throughPOST /analysis/velocityprofile/recalculate/{userID}/{exDefID}, which runs inline and answers with what it did. The edits changed one exercise, so rebuilding the athlete's whole library to see them was always more than the question asked.- It can also end the profile: when the edited sets no longer hold a
curve the server deletes the stored one and says
deleted: true. The drawer then shows a warning alert saying so — putting the rep back restores it on the next save. Nothing else reports this, and it cannot be undone. - Only the velocity regression is rebuilt: the exertion profile and the 1RM are not. The athlete's whole library is still the user drawer's "Recalculate training data" button.
- On a deployment without that route the save falls back to the queued
whole-library job (
recalculateProfile,profile-recalculation.ts→GET /users/recalculate_trainingdata/{userID}), tracked in the shared job store (users/recalculation-jobs.ts). Not to theGET /exercises/{id}trick, which only finds the caller's own exercises and answers 404 for an athlete's.
- It can also end the profile: when the edited sets no longer hold a
curve the server deletes the stored one and says
- The pair is marked stale right away, so the edits show: all four analyses
refetch (
invalidateAnalysis("<userID>:<exDefID>")), every window of the pair is evicted, and an open drawer re-reads its window with the old list kept on screen. The curve and the flags are still the old fit at this point. - When the job ends — its completion notification, or the job store's timeout — the pair is marked stale again, now against the new fit. This runs at module level, so it also lands when the drawer has been closed in the meantime.
While a job runs for the athlete (useRecalculationRunning — from this drawer or
the user drawer) the drawer makes clear the curve on screen is the old one:
- The chart is blurred and not interactive (no dot hover or clicks), with a centred notice: "Updating the curve" / "This curve doesn't show your changes yet." Only a class changes, so the curve doesn't replay its animation when the blur comes or goes.
- The Outliers card is disabled (dimmed, with a spinner): its title reads "Waiting for the updated curve", the description "Outliers are checked again once it's ready.", and Review outliers is disabled — the outliers were found against the old curve.
Editing and saving stay available. A save while a job is already running (from this drawer or the user drawer) does not start a duplicate: exactly one follow-up run is queued for when the running one ends, and every pair that saved in between refetches after both.
Side effects of the backend job: other exercises' profiles and 1RMs are recomputed too, and a profile whose new fit is below the backend's r² minimum is deleted. A refused request (403/404) shows a warning ("the curve still shows the old fit") with a retry; a failed list re-read shows an error with a retry. A save that fully lands also closes an open cleanup run.
Save is disabled while the window is still loading or refreshing — a page landing mid-save would merge into a list about to be replaced.
Out of scope
- Deleting a whole set (
DELETE /workout_sets/batch). Cleanup and selection act on reps; the set itself (with its load) stays. - Editing set-level load, rep tags and notes, and undo after save.
- Cross-surface invalidation — the session drawer keeps its own caches and still needs a reopen to see these edits.