Insights chart creation
The portal's Insights page (apps/portal/src/app/dashboard/data/insights/)
creates charts in two ways that write the same chart definition. Requirements:
POR-INS-03..05. Inside the Insights demo the guided
path builds and saves on sample athletes, without the server
(insights-demo.md).
Two paths, one chart definition
- Guided path. "New chart" opens a wizard on the shared
@enode/ui/wizard(useWizard+WizardDrawerView, like the roster-upload and migration drawers) inguided-flow.tsx. Its first step is the coaching question (templates inchart-templates.ts), which fixes the chart type, metric(s), timeframe, title and note; the next steps ask only what is left: whose sessions → which lifts → check and save. A Profile also asks against whom (default: the whole squad) and on which metrics (the question's bundle, or a custom set), and uses the same lifts for both groups. - Full builder.
chart-builder.tsxoffers every option (frosted drawer header with Save as its trailing pill): all types and metrics, fixed athletes plus tags, per-group exercises in a Profile, and fixed date ranges. It is reached through "Build it yourself" (pinned in the drawer's footer beside Next while the questions show, via the wizard step'sfooterslot), "Adjust everything" (from any guided step, with every choice kept), editing, duplicating, and the live hub. Its layout is an accordion stepper, described below.
Both paths edit the same drafts (FlatDraft / ProfileDraft in
chart-draft.ts),
validate with the same rules
(chart-gaps.ts),
and save through the same functions
(chart-save.ts).
A change to what a chart needs belongs in those modules, not in either path.
Question shapes: what is many, what is fixed
Each question declares its shape (TemplateShape in
chart-templates.ts):
whether the athletes are one or many, and whether the lifts are one, or many
(optionally capped and pre-picked). The QUESTION decides it, not the chart
type. "Is anyone slower than usual?" is many athletes × one lift; "Is one
athlete progressing on every lift?" is one athlete × many lifts; "Where is
something off this week?" is many × up to 5 lifts (3 pre-picked), shown as the
team matrix. The guided steps follow the shape: a one-athlete question asks
"Which athlete?" (saved as a fixed athlete, never a tag); a one-lift question
takes a single lift; a capped question stops ticking at its cap. The full
builder keeps every combination.
The full builder's stepper
The full builder shows one step at a time
(builder-stepper.tsx).
Every other step is folded into a one-line summary: ✓ when filled, – when not
needed, its number while still missing.
- Steps. Trend, Ranking, Distribution and Correlation: chart type · metric (Correlation: two metrics) · athletes · exercises · timeframe · name & note. Profile: chart type · metrics · profile group · reference group · timeframe · name & note. The labels stay the same across types; the prompt under an open step carries the type's question ("Who should be ranked?").
- States come from
builder-steps.ts, which derives them from the same gaps Save checks (eachFormGapnames its step). Exercises are "not needed" for an athlete-level metric; the name is always optional. The progress line counts the steps the coach has to fill. - Moving on. A new chart opens on its first missing step. Single choices (type, metric, a timeframe preset; Correlation once both metrics are picked and differ) continue on their own to the next missing step; multi-picks wait for Continue. Editing a saved chart opens with every step folded, the type locked, and a pick folds its step instead of moving on.
- Save never greys out. With gaps it opens the first missing step and marks every missing step with its reason.
- One-part edits (a link in the full chart's subject) keep the compact drawer with
only that picker (
sectionmode). - Result card ("What you'll get"). The last thing in the drawer, and the
same card on the guided check step
(
chart-preview-card.tsx). Once nothing blocks Save it shows the real chart, built by the server from the unsaved picks (see "Live preview" below) and drawn as a board tile. Until then — and while the first preview builds, or when it fails — it shows the chart's layout (chart-shape.ts,chart-shape-preview.tsx) with everything the picks already give: the title, the metric(s) and where their numbers come from ("per rep"), who, which lifts and the period in its header, and on the plot the names of its panels, rows, columns, ranking tabs, radar axes and Profile groups (tag members resolved with today's roster,useCoveredNames), with neutral marks. Below it: what it opens as, and size warnings.
Live preview
use-chart-preview.ts
asks POST /charts/{type}/preview (previewChart in @enode/core/api/charts)
for the chart with exactly the body Save would send (flatChartRequest /
profileChartRequest in chart-draft.ts). The response is a saved chart's
shape without an id; nothing is stored. Requests are kept few:
- Only once nothing blocks Save; the guided flow and the builder pass
nulluntil then. - After the picks have held still for 400 ms (trailing debounce).
- Never for a title or note change: the request key leaves them out
(
chart-preview-cache.ts), and the card shows them from the draft. An empty title shows the server's own name, as on the saved chart. - One at a time: a newer request aborts the previous one (
AbortController). - Not again for picks previewed in the last minute: a module-level cache (20
entries) outlives the guided check step, which unmounts when the coach steps
back. A preview the server couldn't build (
failed) is not cached.
While a new preview loads, the last one stays up, dimmed. The server builds one
preview per user at a time and answers 409 to one a newer preview overtook.
The portal's own superseded requests are already aborted, so a 409 on the
current request means another tab overtook it: it is asked again after a pause
(twice at most) and is never reported. Any other failure is reported and shown
under the layout as an inline alert with Try again and Report; a failed
preview shows the tile's "couldn't be built" state with Try again.
A Profile needs athletes in both groups (profileGaps); a group also
needs exercises when any axis is measured per exercise.
Size and opening view
chart-size.ts
says what a selection turns into: athletes × lifts → series (counted with
today's tag members, use-selection-size.ts), the view the chart opens with,
and a warning only where even that view gets hard to read. The guided check
step and the full builder both show it in their "What you'll get" card (the
builder also live in its athletes and exercises steps); it never blocks
saving.
Trend views
A Trend has three views (trend-model.ts
trendViewsFor): the overlay, panels (one per athlete, per lift, or an
athlete × lift table) and the team matrix (one cell per athlete × lift holding
the trend's change, trend-matrix.tsx).
It opens in the view that fits the KIND of selection in its definition — a tag
or several picks count as many, one pick as one — never today's counts, so a
growing tag doesn't flip the chart: 1 × 1 → overlay (the single trend), one
side many → panels, many × many → matrix. The header switches between the
views on offer for the session (one segmented control). A matrix cell opens its
series in the overlay, with "← Back to matrix" returning to the matrix and its
column sort. The export doesn't follow the view on screen; see
Chart report export.
Working with a saved chart
On the Insights board each chart card is wrapped in ChartCardActionsProvider
(chart-card-actions.tsx),
which gives it two in-place edits and the board's spotlight:
- Change one part. In the focus view the subject is the chart's settings
(
SubjectEditinginchart-tile-context.tsx): each saved part of the standfirst (who, lifts, period; for a Profile the metrics and period) and the kicker's topic (the metric; a Profile's groups) is a dotted link that opens the builder showing only that part (ChartBuilderTarget.section), saved through the normal update. Hovering a link lists every name and says it saves the chart. The facet bar that said the same is only shown where no answer leads (then there is no standfirst). - The view and Reset view. The pickers and toggles in the controls row,
under a "View" label, change only how the chart is shown and reset when
stepping to another chart. The standfirst's reading part ("weekly avg · usual
load") is shaded as the current view and follows them. While anything differs
from how the chart opens (pickers, the Trend's view, zoom and period, the
Distribution's grouping, layers and order, the Ranking's lift and order, the
Profile's view and swapped sides), "Reset view" appears at the end of the
standfirst. Each chart passes
ChartHeaderaview: { changed, reset }. - Selecting. A click on a mark (line, point, bar, an athlete's box) or a
legend row focuses it; the rest steps back. ⌘ / Ctrl-click adds it to a
comparison of up to three (
MAX_COMPARED,nextFocusedinlegend-model.ts). A click off the marks releases the focus. One rule for every type:plotClickinchart-legend.tsx. - Athlete actions. While an athlete is focused, a selection card on the
plot names them and shows the actions as buttons: Spotlight on every chart,
Open athlete, Velocity profile, Show sessions; Remove from chart sits behind
its "⋯". While comparing, the card says who is compared. Its ✕ releases the
selection (everyone compared too), as does a click off the marks.
A right-click on a mark (desktop) and the legend row's "⋯" are shortcuts to
the same list. On touch, press-and-hold reads the tooltip; there is no
long-press menu. Removing saves the chart without a hand-picked athlete; for
an athlete who is in through a tag it explains that the tag decides
(
remove-athlete.ts). - Legend. Hovering a row previews it on the plot without changing the
focus, and Find… lights its matches on the plot the same way (the
interaction's
setPreview). "N hidden · Show all" appears while any row is hidden; ⌥ / Alt-click on an eye shows only that row. - Spotlight. One athlete highlighted on every chart of the page (matched by id; the Trend matches by name, since its series carry only labels). A bar above the board names them and clears it.
- Zoom (Trend, focus view only). Drag across the plot to zoom into a time range (mouse and pen); the change and everything else follow it; "Zoomed … · Reset", Reset view or a double-click resets.
- Per type, in the focus view. Ranking: "Highest first · Lowest first",
and up to four lifts as segments instead of a menu. Distribution: group by as
segments and an order (widest spread, highest median, name;
sortGroupsindistribution-model.ts). Profile: Difference · Radar as segments, "⇄ Swap sides" (measure the reference against the profile group; a view setting, the heading keeps the saved sides), and above the radar two group chips that focus (click) or hide (eye) a group. - Reading values in place (focus view).
- Trend: a crosshair follows the hovered date (
drawCrosshair), and the side panel becomes a readout of every row's value at that date (ChartLegendTable'sreadout). - Trend: the time navigator under the overlay
(
trend-navigator.tsx, rules innavigator-model.ts) shows the zoom as a window on the whole period. Drag an edge or the window (mouse, pen, touch), use ← / → on an edge (Shift: four weeks), or pick 4 · 8 · 12 weeks or All. Drag-to-zoom on the plot stays as a shortcut. - Trend matrix: hovering or focusing a cell shows its trend, sessions, change and load in a popover.
- Ranking: the tooltip names the athlete, the value and the gap to the team
median (a tile's reads in one line). A drawn gap bracket was tried and
dropped: the tooltip covered it and it didn't read well. A long
ranking scrolls its pane to the focused athlete or the legend's Find…
matches (
preview.source === "find", not a mere hover;scrollPlotTo). - Correlation: hovering a point lights that athlete in every lift and in the legend; hovering a fit line says the link in words ("Strong · 14 sessions"); a lift's name shows that lift alone ("← All lifts" returns).
- Distribution: hovering a group lights it in the legend.
- Profile: one metric is lit in the bars, the radar's axis and the metric table at once (hover, or click to keep it). The group chips also show in the Difference view, as names only.
- Trend: a crosshair follows the hovered date (
- Names in the answer (tile). A bold name in a tile's answer that names one
of its rows is a link: hovering it lights those rows, a click opens the
focus view with them selected (up to three;
FocusOpening.focusName). Each chart registers its rows withuseNameLinks; matching is by whole name (mentionsinanswer-links.ts). - Tiles in one line. A tile's tooltip reads "who · value"; a Distribution tile shows one summary per group (median, typical range, sets). A Ranking tile's "⋯ N in between" row opens the focus view in the middle of the ranking; a Profile tile's bar opens it with that metric lit.
- Spotlight search. The board always shows "Spotlight an athlete…", a
combobox over the trainable roster (
spotlightSuggestionsinspotlight-model.ts). - Export. The PDF report and the slide image (download or copy), see
Chart report export, and Download values (CSV) of
the legend's visible rows (
legendCsvinexport-csv.ts). - Keys and touch. "?" (or the ? button) lists the focus view's keys and pointer shortcuts; Escape closes the list first. "+ Compare" in the selection card makes the next tap add an athlete, for touch screens without ⌘ / Ctrl-click.
- Opened from a card (lab only, review P8).
InsightsCharttakes an optionalheading(the card's question; the header shows it, with the chart's own title under it) andnamed(the labels the card named). Trend, Distribution and Correlation then draw the named rows as usual and step the rest back while nothing is focused (withNamedEmphasis; a focus or the spotlight wins), and Trend's panels put the named athletes first. Ranking needs nothing (it already marks its top three and bottom two) and Profile names metrics, not rows. Without these props a chart is unchanged.
Charts shown elsewhere (the live hub) get no card actions and no spotlight, so they only offer the links.
Chart report export
A chart's share menu (focus view header,
chart-export-menu.tsx)
offers Export as PDF, Image for slides (PNG), Copy image and
Download values (CSV). Requirement: POR-INS-13.
The design and its decisions come from the local "Exports" study
(insights-layouts/index.html#exports, not in git).
Report model
Each chart builds a ChartReport on click (buildReport in each
*-chart.tsx) from what it shows at that moment; nothing is copied from the
on-screen canvas. The model and its pure rules live in
report-model.ts;
report-render.ts
draws every figure for print and lays out the pages;
report-frame.ts
words the text around the figure.
Page anatomy
Brand line (wordmark, "Insights report · date") → kicker (coach's title · metric · unit, or the two groups) → verdict (the chart's answer, named athletes in bold) → standfirst (who · lifts · how read · period) → figure and table → notes (how to read, loads, hidden rows) → footer (exporter's name, enode.ai, date, page x of y). The faint enode mark sits behind the figure.
- Paper. A4, Letter where the browser's locale is a Letter country
(
paperFor). Landscape, portrait for long rankings (> 15) and distributions (> 12). Long figures continue on further pages. - Light only. There is no dark PDF or image; series use their light colours whatever the app's theme.
- Real dates. The period ends on the export date instead of "today"; tag counts get a "Tag members as of …" note.
- Load. Every value states the load it was lifted at, per row: the usual load in kg, the picked load, or the range of loads pooled. A note explains the reading. Profile states each group's load range.
- Hidden rows are left out and listed in a note; a focus or comparison becomes "Highlighted: …" in the standfirst.
- Raster. Pages are canvases wrapped by jsPDF, so the text isn't selectable (a later step).
Per chart type
| Type | Page 1 | Further pages |
|---|---|---|
| Trend | See below | Panels, sorted by change |
| Ranking | Bars with the value on each, then the load(s) and "vs team median" beside them; no repeated table | Continuation of a long ranking |
| Distribution | Always the rows layout: box, whiskers, sets per row, with spread, typical range, median, load, unusual sets and sets on the same line | Continuation of a long list |
| Correlation | Every lift side by side (one lift: plot left, table right); athlete table with the link in words and the load per lift | More than 6 lifts continue |
| Profile | The view on screen (radar or difference bars) beside a metric · group · reference · difference table | — |
Trend prints the views that work on paper (trendReportPlan): every printed
line must be identifiable without hovering. Up to LABEL_LINES_UP_TO (4) lines
print as an overlay with every line named. More than that: the lines the
verdict names (or the highlighted ones) print over a grey team band (the range
of the others plus a dashed team median, teamBand), with every series as a
panel on the next pages. Many athletes × many lifts: the matrix (change and
load per cell), then one page of panels per lift. The view open on screen
doesn't change this.
The slide image is 1920 × 1080: kicker, verdict, the page-1 figure and a key line (the named rows with their load).
Loading the board
The board's tabs are All (the default) and one per chart type; custom
groups come later. A tab loads the whole list of every type it shows:
useInsightsBoard(tab)
(insights-board.ts)
calls useCharts(type, { pageSize: 10, all: true, enabled }) for each type,
and only the shown types load. The charts store
(charts/store.ts) fetches
GET /charts/{type}/data in pages of 10, back to back, until hasMore is
false.
-
On All, each type's charts show as soon as its first page arrives, in tab order, each type in its saved order. Two placeholders stand in for what is still loading. The board is an error only when every shown type failed; otherwise a staleness hint offers the retry.
-
Order. Drag-to-reorder works on a type's tab, where the order is saved per type. On All, dragging is off.
-
Caching. Types stay cached after loading, so switching tabs back is instant.
-
Why pages of 10. The server builds every chart on read and doesn't cache results (backend decision, 2026-10-19). Builds are fast: a page of 10 takes about 1–2 s, two charts at a time. Small pages show the first charts quickly, and no single request risks the proxy's 60 s timeout.
-
A failed page stops the loading and marks the list stale. "Load more" resumes it with
loadMoreCharts(type, 10, { all: true }); there is no retry loop. -
Saves and deletes while it loads. A created chart moves the next page's offset forward by one and a deleted chart moves it back, so no chart is loaded twice or skipped. A page that a save makes outdated is fetched again.
-
A chart the server couldn't build comes back with
failed: true(HTTP 200,isEmptytrue, title and settings intact; read it withisChartFailed). Its card says it couldn't be built and offers Try again, which re-reads only that chart (GET /charts/{type}/data/:id), and Report. A save that returnsfailedis still a saved chart. Deployments from before the field existed omit it, and a failed chart there still reads as "No data".
Board tiles (layout L)
The Insights board (layout L of the Insights layout study) shows every chart as a tile: the chart's own picture, led by its answer in words. Each tile has Expand (the focus view), the ⋯ menu (Edit, Duplicate, Delete), and Try again / Report on a chart that couldn't be built.
A tile reads; it doesn't change state (rule R1 of the study's "Chart
interaction" tab). Hover shows the tooltips. A click anywhere on the card opens
the focus view (InsightsChartTile's onOpen); a click on a mark opens it
with that series already focused, and a Trend matrix cell opens it on that
series' trend (FocusOpening, read by useSeriesInteraction through
useFocusOpening). The answer headline is a button, the keyboard way in, next
to ⤢. A tile has no focus, zoom, selection card or right-click menu. The
builder's preview renders the same tile without onOpen and inside
ChartSpotlightIsolation, so it only reads: no clicks, and neither lit by nor
able to set the board's spotlight.
The board wiring lives in
insights-board-wiring.tsx:
FocusHost (which chart the focus view shows and what it opens with) and
SpotlightBar (the spotlight search). The Insights page and the debug page
use the same pieces, so their interactions can't drift apart.
The debug page's Insights tiles (/dashboard/debug/insights-tiles) renders
every chart type in every case on mock data through the real tiles, focus view
and board wiring, so it is a lookup for every interaction without real data:
- What would save (Edit, Duplicate, Delete, a standfirst link, Remove from chart) only shows a note saying what it would do.
- "My roster" (the default) puts the mock athletes and lifts on the signed-in
coach's trainable athletes and catalogue exercises, ids and names
(
roster-mock.ts), so Open athlete, Velocity profile, Show sessions and the spotlight work; the chart values stay mock, while those drawers show the athlete's real data. "Mock names" keeps the made-up names.
Columns. The board is always a grid (there is no list view): two columns
from xl (1280 px) up, one below that. A tile spans both columns only
where half the width would cramp its picture (tileIsWide in
chart-tile-model.ts):
- a Trend shown as the team matrix with more than 3 lift columns;
- a Correlation with 2 or more lift panels side by side;
- a Distribution in columns with 7 or 8 groups (from 9 it turns into rows, which fit).
Line overlays, rankings, distribution rows and profiles stay at half width.
The focus view's open chart lives in FocusHost, not in the page component.
Opening or stepping through charts re-renders only the focus view, never the
board's tiles behind it (re-rendering every tile on each step was the lag on
the debug page).
InsightsChartTile(insights-chart-tile.tsx) wrapsInsightsChartinChartTileProvider. Each chart type reads the context and swaps three parts:- the header for
ChartTileHeader: the subject's kicker, the answer as the headline, and the subject's standfirst (see "The subject" below); - the statistics table for
ChartTileKeys: the key values, a caveat, and what was folded away; - its plot for one fixed height (
TILE_PLOT_HEIGHT, also kept by the empty and failed states).
- the header for
- The answer
(
chart-answer.ts):chartFactsreads numbers and names off a chart result with the default view's rules (average, usual load), andchartAnswerwords them with literalt()calls. It names who the answer is about, and those rows are what the chart puts first and in colour. Where the data can't support a claim (too few sessions or sets, no clear difference) the sentence stays neutral. The Insights lab's summaries use the same facts for Trend, Ranking and Profile. - The subject
(
chart-subject.ts, proposal R of the layout study's "Chart hierarchy" tab): what the answer is about, framed around it so the answer stays the headline.- The kicker, above the answer, in brand green: the coach's own title (only when one was set, and dropped when it just repeats the topic), then what is measured — the metric and its unit, "Peak power vs Peak force" for a Correlation, "U19 vs Squad" for a Profile. On a narrow tile the title truncates first; the topic stays whole.
- The standfirst, under the answer: who · lifts · how the values are read · period. Up to two names of each, then "+ N more"; a tag by name with what it covers today ("U19 (8 athletes)"); how it's read in short form ("weekly avg · usual load", "every set · 60 kg"), left out where the data has no loads. It wraps to two lines instead of cutting off. A Ranking names the one lift it ranks.
- Built by
useChartSubject/useScopeWordinginchart-header.tsx(the Profile words its own), and drawn byChartSubjectKickerandChartSubjectStandfirstinchart-tile-context.tsx. - The key line gives a % a scale: a Trend's leading key adds where its line went ("Lena −9 % · 0.60 → 0.54 m/s"), a Distribution's spread adds the metric's unit ("±6 % (±0.04 m/s)", not for a metric already in %).
- Folds per type
(
chart-tile-model.ts):- Trend: an overlay (crowded charts draw only the named series), or for many × many the team matrix, cut to 6 rows × 5 lifts.
- Ranking: from 13 athletes, top 3 · "⋯ N in between" · bottom 2, with named athletes pulled out of the fold.
- Distribution: rows cut to the 6 widest, named ones always kept.
- Correlation: at most 3 lift panels.
- Profile: the view the chart opens with (
profileDefaultViewinprofile-model.ts): from 3 metrics the radar, every metric on it; with fewer the difference bars, the 6 biggest differences. Either way, metrics clearly behind are in the warning colour (axis labels on the radar). A click on a radar axis or a bar opens the focus view with that metric lit.
- Focus view.
InsightsChartFocus(insights-chart-focus.tsx) opens the same chart full screen, with nothing folded and every control back:- Keys: Escape closes, ← / → or J / K step through the charts. A key typed
into a field, pressed inside a menu, or coming from a drawer or dialog on
top doesn't count (
isFocusViewKey); menus and pickers stop the Escape that closes them (SelectMenu,ActionMenu, the plot's menu). - The tile's answer stays on top as the heading, framed by the same kicker
and standfirst as on the tile; the standfirst follows the controls (period,
value, load). Inside
ChartLiveAnswerProvidereach chart answers again at the settings it shows (useChartAnswerHeading, passing anAnswerView: the Ranking's lift, the value and load pickers, the Trend's period and zoom, the Distribution's grouping), so the heading follows the controls.chartFactsreads the same rules at a view; without one it reads the defaults, which is what the tile shows. A Distribution grouped by load has no answer: the chart's title leads instead. - The answer's caveat shows under the heading (a Distribution pooling several lifts per athlete offers "Show by exercise").
- The picture matches the tile: the named rows keep their colour (a crowded Trend draws the named series, a Ranking marks the named athletes), a Trend opens in the view its tile showed, a Profile too (the radar from 3 metrics; Difference · Radar switches). "Highlighting … ✕" under the heading says why the other rows are faded; ✕ draws every row alike.
- What the answer rests on is drawn: the Ranking's "Well below the team"
line (
FAR_BELOW_MEDIANof the team median) and the Profile's "About the same" band (±PROFILE_SIDE_PCT), with metrics clearly behind in the warning colour. - Inside
ChartFocusProvider,ChartShelllets the plot fill the screen's height, with the legend in a side panel 22rem wide on wide screens (below the plot on narrow ones). - The side-panel legend. The panel is too narrow for the legend table
(its columns grow with each chart's stats), so
ChartLegendTablerenders as a compact list there (useLegendPlacement, fromChartShell):- Open from the start; the header is a plain count ("6 athletes", "3 lifts") and "Full table", which moves the table to full width under the plot ("Back beside the chart" returns).
- One value per row, the stat the chart marks
primary: Trend's change, Ranking's distance to the median, Distribution's spread, Correlation's link. The other plain stats share a detail line under the name (panelDetailinlegend-model.ts); detail columns, sparklines and stats markedpanel: "omit"stay in the full table. - The rows the answer names lead under "In the heading" (
leadIds). - Per type: Trend names a row by what varies (the athlete with one lift,
the lift for one athlete) and in matrix view becomes a narrow name-only
filter, since the matrix holds the numbers; Ranking draws the team
median as a divider; Distribution flags unusual sets as a badge;
Correlation with several lifts shows one mark per lift (
linkMark,liftCodes) with a key; Profile puts its values table in the panel instead of the two-row group legend. - Search sits on its own line above 10 rows; show/hide is at the row's end, faint until hover.
- A strip of the board's charts as small cards (title, the tile's picture as
a thumbnail, the answer), and ← / →, step through them; Escape returns to
the board. A thumbnail (
InsightsChartThumbnail, the tile context'sthumbnailflag) is the tile's plot alone, without axes, labels or interaction (asThumbnailOptions). Thumbnails mount when scrolled into view and are memoised, so stepping rebuilds only the chart shown.
- Keys: Escape closes, ← / → or J / K step through the charts. A key typed
into a field, pressed inside a menu, or coming from a drawer or dialog on
top doesn't count (
- Not decided yet. The Ranking's "well below the team" threshold (85 % of the median) is carried over from the lab and still needs coaches' agreement.
Which questions are offered
A question is hidden when its metric isn't in the account's metric list
(isTemplateAvailable): flat charts need every metric, a Profile at least one.
This keeps a question whose metric key the server doesn't offer (e.g. velocity
loss within a set) from opening as a broken start.
Data checks in the guided path
The lifts step and the check step read GET /history/scope for the chart's
timeframe (use-lift-activity.ts),
once per window. The endpoint returns one exercise instance per athlete ×
exercise trained in the window, so
lift-activity.ts
can say how many athletes trained a lift. It can't say how many sessions:
no endpoint returns per-athlete session counts. A check that needs them (e.g.
"enough sessions for a correlation verdict") needs a backend source first.
A failed read never blocks saving. The steps fall back to the regular exercise picker and show a retry hint.
Tagging athletes on the spot
A hand-picked list of athletes never grows. Where athletes are picked one by
one (the guided "whose sessions" step, the full builder's specific-athletes
row) and on the guided check step, a coach with writeEntityTag can tag them
on the spot (useTagTheseAthletes in
scope-selection.tsx).
It pushes the Tags page's own form (TagCreateView from
tag-drawer.tsx) onto
the chart drawer's stack, with "Use for: Users" preset. Once the tag is
created it's assigned to the athletes, and the chart switches from the fixed
ids to the tag. Because it's created before Save, discarding the chart
leaves the tag in place; it can be deleted on the Users page.
Start preference
"Always open the full builder" (in the questions view) is stored on the device
(use-builder-start.ts).
With it on, "New chart" opens the full builder, which offers "Start from a
question" to get back.
Not part of the chart definition
The chart card's header settings (Value, Load, Group by, Per athlete) are view state and reset on every visit. Questions therefore can't preset them yet.
Insights lab: board prototype (dev builds only)
/dashboard/data/insights-lab (linked from the debug page) prototypes a board
layout that hasn't been decided. It covers review proposals B1 (boards with
sections by purpose, with no chart-type tabs or filter), B2 (compact cards
that lead with the answer), B3 (the full chart opens in place, as a row under
its card's row; a second opens beside it, a third closes the oldest; full
screen on demand; edit and one-part edits; the opened chart is headed with
the card's question and leads with what the card named, behind a "Show in
chart order" chip; with one chart open a pointer aims at its card, with two
open, hovering a card or chart outlines its partner) and B4 (a 1/2/3-column grid, wide cards,
drag to reorder within a section) and B5 (one filter over the board: who,
when, one lift). It runs on endpoints the backend doesn't have yet,
so everything about it is a proposal:
-
proposed-api.tsholds the proposed contract:GET/POST /insights/boardsandPUT/DELETE /insights/boards/{id}. A board stores chart references only (chartID,chartType,size), grouped into sections.POST /insights/summaries: the card faces of a whole board in one call. A summary carries the numbers of the answer line, a coarse mini chart, the context and when its result was computed. It is only cheap if the server keeps computed results; computing a chart to summarize it costs as much as reading the chart.POST /charts/{type}/preview: a chart computed for other athletes or a longer period without saving it. The board filter needs it when it asks for more than a chart holds.
-
board-filter.tsdecides from a card's summary what the board filter means for it. It either narrows the loaded full result in the browser, needs a preview, or leaves the chart as it is. A Ranking keeps its period (its values are totals) and a Profile keeps its whole scope (group averages). Narrowing a real chart needs its full result, which loads on a tap; fixture charts and previews load by themselves, one at a time. It also says which parts of the filter a chart follows (follows) and, in a coach's words, what it keeps of its own ("Ranks over its own period"). The filter is reported by exception: a card that follows it shows the filter's who and period in its own scope line, marked with a filter icon, and only what a card keeps of its own is written under that line. The bar says how many charts show the filter. A card that follows none of it (a Profile, or a chart without the filtered lift) fades its question, answer, chart and flag, but keeps its scope and reason lines at full contrast. It stays in place, so the arranged board doesn't reflow. "Keep for this board" saves the filter as the board'scontext, so the board always opens with it; "Stop opening this way" removes it. -
chart-summary.tscomputes a summary from a full chart result, with the same rules the charts use (average, usual load). The "needs a look" rules in it are placeholders. Its small chart carries only what the answer names, labelled, against a reference: change against "their usual" (Trend) or the other group (Profile), the top three and those far below the team (Ranking), the verdict split (Correlation), and the widest spreads (Distribution).mini-chart.tsxdraws it. Every mark is light grey (surface-stroke-muted) except those that need a look, which are amber (feedback-warning), and amber appears only on a card that needs a look. That keeps the flagged mark the heaviest ink on the card, with or without colour. -
A card leads with its question, from
summary-text.ts: the guided question the chart came from, else the coach's own title, else a question generated from the chart type. "Needs a look" is a pill beside the question; the chart type isn't shown. Under it, on one line: who the chart is for (tags as chips with their size, hand-picked athletes by name; the summary'sscope), what is measured and when. Then the answer, right under the question so answers line up across a row, and last the small chart that backs it. Each fact is said once: the answer gives the count or the key name, and the small chart names the rest with values. The section header counts the cards that need a look; drag handles show only in its "Arrange" mode. A Trend's small chart shows the athletes trending down as change bars against their usual (small trend lines were tried and read worse). An opened Profile in the lab starts on the Profile chart's "Difference" view (bars,defaultView), so it matches its card. The Insights page keeps the radar as its default.summary-text.tswords a summary for the card. -
mock-api.tsis a fetch stub that only the lab installs. It answers the board routes fromlocalStorageper user. It answers summaries formock-chart-…fixture ids (fixture-charts.ts) on the spot. A real chart has a summary only once the lab has seen its result: a read, create or update that passed through the stub, or a chart already loaded on the Insights page this session. That device cache stands in for results kept on the server. Without it, the card says "Not calculated yet". -
The preview mock has no compute: it answers with a generated chart of the same type for the requested athletes and period. For a real chart that is prototype data, and the card says so.
-
Every other request reaches the real backend. Fixture reads and summaries follow the lab's latency and failure knobs. The full chart is a server compute, so it loads only when a card is opened, one at a time, with at most two open per board. Card size and order need no endpoint of their own: they are part of the board (
PUT /insights/boards/{id}). A chart created in the lab is saved for real. -
A "Board / Needs-a-look feed" switch shows the same board as a single column ordered by status instead of by hand (layout study, approach D). Cards that need a look come first, then the rest, then the ones the coach marked "Seen" this week. Within a group the board's order is kept (
feed-order.ts). A card's group follows what it shows now, so under the board filter it is the filtered answer. Cards take the full width, and their small chart is drawn at the width it gets, so its text keeps its size. A tap opens the chart right under its card, one at a time. "Seen" closes it and moves the card down, and a "Seen" mark lasts until Monday. Sections become chips that narrow the feed, with each chip counting its cards that need a look. The layout choice and the marks are stored on the device (feed-state.ts). A real feed would keep the marks per coach on the server.
In production builds the page renders nothing, using the same render-time guard as the debug pages, and the stub never installs.