Training Load ACWR
A four-state training-load chart: daily load bars, 7-day acute and 28-day chronic rolling averages, and the acute:chronic ratio with a sweet-spot band and a high-risk zone — all rolling math done inside the component.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-training-load.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTrainingLoad" sports-analytics
widget on the shadcn chart primitives (ChartContainer/ChartTooltip over
recharts ComposedChart) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string; days: { date: ISO string, load >= 0 }[] }.
- days is RAW input — one load number per calendar day, rest days included as
zeros. The component derives everything else itself.
- Component props = z.infer of the schema, plus height?: number (plot height,
clamped 200–560, default 280) and onRetry?: () => void; forwardRef to the
root div, spread remaining div props, cn() merges className.
Behavior
- Derivation (useMemo over days): sort by date, build prefix sums, then per
day acute = trailing 7-day mean, chronic = trailing 28-day mean,
ACWR = acute / chronic. No ratio until at least 28 days of history (the
chronic window filled) and chronic > 0 — against a partial chronic window
the quotient sits artificially near 1 and would read as a finding.
- The four states are first-class branches inside one bg-card shell that
always carries the title:
- loading: a deterministic bar-silhouette skeleton (fixed height array,
animate-pulse + motion-reduce:animate-none), sr-only role="status".
- empty: icon + "No sessions logged yet" copy; a ready status with zero
days falls into this branch too.
- error: role="alert" message + a "Try again" button rendered only when
onRetry exists.
- ready: headline (today's ACWR to 2 decimals + zone badge:
undertraining < 0.8, sweet spot 0.8–1.3, caution 1.3–1.5, high risk
> 1.5) over the chart, then a hand-rolled legend and a footnote that
explains both windows and counts high-risk days.
- Accessibility: the recharts canvas is aria-hidden; a parallel sr-only
paragraph states the date span, latest load, acute, chronic, ratio, zone
and how many days crossed the risk line.
Rendering & styling
- ComposedChart, two Y axes: left = load (bars: var(--chart-1) at 0.45
opacity, rounded tops; acute line var(--chart-2); chronic line
var(--chart-3) dashed "6 3") and right = unit-less ratio
(ACWR line var(--chart-4), strokeWidth 2.5, connectNulls false).
- Ratio-axis domain [0, max(2, peak + headroom)] so both bands always fit;
two ReferenceAreas attach to the ratio axis only and paint first (behind
the marks): sweet spot 0.8–1.3 in var(--chart-4) at 0.1 opacity, high risk
1.5–top in var(--destructive) at 0.08.
- ChartConfig maps load/acute/chronic/acwr to the same four chart tokens so
the tooltip (ChartTooltipContent indicator="line") matches the marks.
- Dates parsed at local midnight ("T00:00:00") so labels never shift a day;
X ticks thinned with minTickGap. Semantic tokens only; the high-risk badge
uses border-destructive/50 bg-destructive/10 text-destructive.
- prefers-reduced-motion (matchMedia hook, listener cleaned up) disables
recharts series animation; skeleton pulse gets motion-reduce:animate-none.
Customization levers
- Windows: ACUTE_WINDOW / CHRONIC_WINDOW constants (7/28 is the published
standard; 3/21 suits micro-cycles) — the footnote reads from the same
constants so copy can't drift.
- Zones: SWEET_LOW / SWEET_HIGH / RISK_AT drive the bands, the badge and the
risk-day count in one place; tighten to 0.8–1.2 for conservative rehab.
- Density: height prop for the plot; drop the headline row for a thumbnail
embed, or the legend + footnote for a dense grid of athletes.
- Palette: bars/acute/chronic/acwr each map to one chart token in the
ChartConfig — re-point them (e.g. brand color for ACWR) without touching
the geometry; the sweet band deliberately shares the ACWR token.
- Unit: the unit prop only decorates labels (AU, TSS, km) — the ratio is
unit-less by construction, so no conversion is ever needed.Concepts
- Component-side rollups — the contract is one raw number per day; acute, chronic and the ratio are derived inside the component from prefix sums, so consumers can never hand in pre-aggregated values that disagree with the bands.
- Acute vs chronic — the 7-day mean is what you just did (fatigue), the 28-day mean is what you're prepared for (fitness); their quotient is the ACWR that sports science bands into zones.
- Ratio warm-up gate — no ACWR is drawn until the 28-day chronic window has filled: against a partial chronic window the quotient sits artificially near 1 and would read as a finding when it's only missing history.
- Bands on one axis — the sweet-spot and high-risk zones attach to the right (ratio) axis only, because they are statements about the unit-less ratio, not about load; loads keep the left axis.
- Zone as first-class state — the same three thresholds drive the shaded bands, the headline badge and the risk-day count in the footnote, so the chart, the summary and the screen-reader text can't disagree.
Win Probability
A four-state in-game win probability chart: one line around a 50% midline, the band shaded toward whichever side is favoured in that side's chart token, and key events pinned to the path — named on hover, focus and in the readout.
Heart Rate Zones
A four-state workout view that colors a session strip by heart-rate zone and totals time-in-zone into share-labelled bars, from bpm samples and zone floors.