Skip to main content

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) in guided-flow.tsx. Its first step is the coaching question (templates in chart-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.tsx offers 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's footer slot), "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 (each FormGap names 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 (section mode).
  • 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 null until 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 (SubjectEditing in chart-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 ChartHeader a view: { 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, nextFocused in legend-model.ts). A click off the marks releases the focus. One rule for every type: plotClick in chart-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; sortGroups in distribution-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's readout).
    • Trend: the time navigator under the overlay (trend-navigator.tsx, rules in navigator-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.
  • 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 with useNameLinks; matching is by whole name (mentions in answer-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 (spotlightSuggestions in spotlight-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 (legendCsv in export-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). InsightsChart takes an optional heading (the card's question; the header shows it, with the chart's own title under it) and named (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​

TypePage 1Further pages
TrendSee belowPanels, sorted by change
RankingBars with the value on each, then the load(s) and "vs team median" beside them; no repeated tableContinuation of a long ranking
DistributionAlways the rows layout: box, whiskers, sets per row, with spread, typical range, median, load, unusual sets and sets on the same lineContinuation of a long list
CorrelationEvery lift side by side (one lift: plot left, table right); athlete table with the link in words and the load per liftMore than 6 lifts continue
ProfileThe 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, isEmpty true, title and settings intact; read it with isChartFailed). 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 returns failed is 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) wraps InsightsChart in ChartTileProvider. 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 answer (chart-answer.ts): chartFacts reads numbers and names off a chart result with the default view's rules (average, usual load), and chartAnswer words them with literal t() 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 / useScopeWording in chart-header.tsx (the Profile words its own), and drawn by ChartSubjectKicker and ChartSubjectStandfirst in chart-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 (profileDefaultView in profile-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 ChartLiveAnswerProvider each chart answers again at the settings it shows (useChartAnswerHeading, passing an AnswerView: 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. chartFacts reads 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_MEDIAN of the team median) and the Profile's "About the same" band (±PROFILE_SIDE_PCT), with metrics clearly behind in the warning colour.
    • Inside ChartFocusProvider, ChartShell lets 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 ChartLegendTable renders as a compact list there (useLegendPlacement, from ChartShell):
      • 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 (panelDetail in legend-model.ts); detail columns, sparklines and stats marked panel: "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's thumbnail flag) 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.
  • 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.ts holds the proposed contract:

    • GET/POST /insights/boards and PUT/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.ts decides 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's context, so the board always opens with it; "Stop opening this way" removes it.

  • chart-summary.ts computes 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.tsx draws 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's scope), 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.ts words a summary for the card.

  • mock-api.ts is a fetch stub that only the lab installs. It answers the board routes from localStorage per user. It answers summaries for mock-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.