Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Activity, AlertCircle, RefreshCcw } from "lucide-react"
import {
  Bar,
  CartesianGrid,
  ComposedChart,
  Line,
  ReferenceArea,
  XAxis,
  YAxis,
} from "recharts"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-training-load.json

Prompt

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.

On This Page