Skip to main content

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 loading would 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 its target when 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). DrawerStack re-reads root every render, so a separate chart prop reaches the view live.

Where the sets come from​

Two endpoints, one per half of the drawer:

HalfCallNotes
ChartgetVelocityProfileData → GET /analysis/velocityprofile/data/{userID}/{exDefID}Line + scatter; scatter points carry setID / repID, and the response a status.
ListgetVelocityProfileSets → GET /analysis/velocityprofile/sets/{userID}/{exDefID}?from&to&offset&limitSets 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 pairKey and 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 the now anchor, 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 choosesWho sets it
Picked timeframe (PickedRange, the page's toolbar)which sets are plotted and listedthe coach
Fit window (windowAmount / windowUnit / windowAnchor)which reps the regression is built from, and therefore every residual verdict and definesProfilePointthe 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 detectOutliers collects) 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 an overlay store — the performance card's chart has none. Hiding the warning dots leaves the hover / cleanup highlight alone. The on/off state is showWarnings in 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 is profilePointSetIDs / onlyProfilePoints in the overlay store — and in the reps drawer it is also the list's profile-point filter chip, so chart and list narrow together; the profileOverlay plugin sets chart.js's skip on the other point elements in beforeDatasetsDraw (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.
  • VelocityProfileChart takes the handler as an opt-in onDotClick; the drawer hands the memoised ChartPanel a stable callback that forwards to the latest handler.

How it is wired, and why:

  • buildVelocityModel returns scatterRefs: for every scatter point with a setID, its chart.js element (datasetIndex, index) and ids. numericPointsWithRefs filters exactly like numericPoints, so the indices line up with the dataset.
  • VelocityProfileChart takes an opt-in overlay store (velocity-profile-overlay.ts). The profileOverlay plugin reads the store during each draw and paints over the real dot positions; a store change only calls chart.draw().
  • The store reaches the plugin through bindProfileOverlay(chart, binding) — a WeakMap keyed by chart instance — never through chart.options. chart.js resolves plugin options through its scriptable-option proxy, which calls any function it finds with a draw context; the store's get / set / subscribe passed as an option would be invoked by chart.js and the chart would throw on its first draw.
  • Nothing about hover goes through data, options or React state. react-chartjs-2 replays the curve's entry animation whenever those change identity, so the store is created once per drawer view and ChartPanel stays 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 setID on 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.

reasonEmpty state
okchart (the generic empty copy if there is nothing to draw)
noSessions"This lift hasn't been trained yet."
noValidRepssessions in the window, but no valid reps with load, velocity and ROM
tooFewLoadsevery usable rep at one load — a profile needs different loads
lowCorrelationvelocities vary too much across loads to draw a reliable curve (the R² values stay out of the copy)
invalidSlopevelocity doesn't drop as the load rises
notCalculatedenough data, not calculated yet — plus a Calculate profile button
noRm1title "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 when windowFrom and windowTo are both present; otherwise a variant without dates is used, so no sentence is stitched from fragments.
  • status is 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 (writeData only — the read stores the refitted model) runs refitVelocityProfile: resolve the athlete's exercise instance from getRelevantExercisesCached, GET /exercises/{id} (refits as a side effect), then invalidateAnalysis so the pair's charts refetch. A failure goes through presentError with 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 = 600 bounds 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".
  • appendPage drops a set a shifted offset served twice (a set recorded between two pages moves every later row by one), so setID stays 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 — ActionMenu with 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 Card holding one DetailRow, 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:

ItemFromDecisions 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 setthat 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 scoreThreshold and the deviation reaches minimumDeviation (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. decisionOf reads a set as invalid / deleted once every rep is, and applyDecision stages 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 definesProfilePoint come 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 distance under 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().formatMetric with the catalogue's distance metric, 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, the length dimension stands in. This assumes distance arrives 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.loadBandPercent load and ±outlierContext.loadGroupDays days (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) pushes ProfileSetRawView: 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:

KeyAction
IMark invalid
DMark to delete
VKeep ("valid")
J / →Next outlier
K / ←Previous outlier
U / ⌘ZUndo
EnterOpen raw data
CCompare 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 in body-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 — unlike workSetNumbers elsewhere.
  • 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​

flagShown asCard
normalno chipneutral
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 loadsoft 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 least minSetsForLoadGroup (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​

flagShown as
normalnothing
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
invalidthe 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:

  • validity holds 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.
  • deleted holds 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.ts keeps one undo history (20 steps) over the staged edits and the keep marks, so U walks 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​

  1. DELETE /workout_reps/batch for staged deletions. Hard delete server-side (force: true) — no undo — so a ConfirmDialog names the count first and points at marking reps invalid as the reversible alternative (the fit already ignores invalid reps).
  2. PUT /workout_reps/batch for validity changes, fetch-before-edit: the touched sets are read with GET /workout_sets/{id} and the DTOs built with toRepUpdateDto (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:

  1. refitAfterSave (velocity-profile-refit.ts) refits this pairing alone through POST /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 the GET /exercises/{id} trick, which only finds the caller's own exercises and answers 404 for an athlete's.
  2. 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.
  3. 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.