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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-hr-zones.jsonPrompt
Build a React + TypeScript + Tailwind "ChartHrZones" workout widget with zod —
positioned divs only, no charting library.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
zones: { id, label, minBpm >= 0 }[]; samples: { t >= 0, bpm >= 0 }[] }.
- minBpm is an inclusive floor: a zone ends where the next floor starts and
the top zone is open-ended. t is seconds from workout start.
- Component props = z.infer of the schema, plus onRetry?: () => void,
emptyState?: ReactNode, maxGapSeconds?: number (longest stretch one sample
may speak for; defaults to five median sampling intervals) and the
remaining div props (forwardRef root, className merged with cn(), rest
spread).
Behavior
- Sort zones by minBpm and samples by t; a sample lands in the highest zone
whose floor it reaches, and anything below the lowest floor still counts as
the lowest zone (the way watch vendors bucket a warm-up).
- Each sample owns the time to the next one, capped at maxGapSeconds; the
last sample repeats the previous owned duration (one second when it is
alone) so the strip does not end one sampling interval short.
- Consecutive samples in the same zone merge into one strip run; a
longer-than-cap recording gap starts a fresh run instead, accrues no zone
time or avg-bpm weight, and the muted track shows through where nothing
was measured.
- Per-zone totals: seconds and share of recorded time. Shares round, but a
visited zone prints "<1%" instead of a lying "0%", and its bar never
renders below 1% width.
- Header meta line: recorded time, time-weighted average bpm, peak bpm.
- ready with no zones or no samples falls through to the empty branch; all
four states are first-class branches, not stacked && conditions.
- Hovering a strip run or a zone row highlights that zone in both places
(linked highlight, one hovered-zone-id state). Pointer-only sugar — every
number it points at is already printed in the rows and the sr-only tables.
Rendering & styling
- Zone i (ascending by floor) colors with var(--chart-{(i % 5) + 1}) in the
strip run, the row dot and the row bar — one formula, three consumers;
a sixth zone wraps the palette.
- Strip: relative h-9 rounded-lg bg-muted container, runs absolutely
positioned (left/width as % of elapsed time), native title tooltips per
run, 0:00-to-total tick labels underneath.
- Rows: hardest zone first — color dot, truncating label column, track bar
(h-2.5 rounded-full bg-muted), mm:ss time and share in tabular-nums.
- Panel: rounded-xl border bg-card p-6; only semantic tokens, no hex.
- prefers-reduced-motion: skeleton pulse and hover-dim transitions off via
motion-reduce:*. Strip and dots are aria-hidden; screen readers get a
summary sentence, a per-zone table (label, bpm range, time, share) and a
timeline table of consecutive stretches. The retry button carries a
focus-visible ring.
Customization levers
- Strip height and radius: h-9 rounded-lg reads as a timeline; h-4
rounded-full reads as a slim session ribbon for compact cards.
- Blocks: drop the strip for a pure time-in-zone card, or drop the rows and
keep the strip as a sparkline-grade ribbon in a workout list.
- Zone order: rows render hardest-first; remove the reverse() to read
easiest-first like a pace chart.
- Palette: the formula maps zone i to var(--chart-i+1); pin fixed tokens per
zone id when the host brand has established zone colors.
- Zone count: the contract takes any number of floors (3-zone polarized,
7-zone power); past five the palette wraps, so re-map tokens before adding
a sixth zone.
- Gap threshold: maxGapSeconds defaults to five median sampling intervals;
raise it for smart-recording watches that sample sparsely on purpose,
lower it to call out shorter pauses as gaps.
- Meta line: swap avg/max bpm for calories or training load — the header is
plain text, not chart geometry.Concepts
- Threshold bucketing — zones are floors, not bins: a sample belongs to the highest zone whose
minBpmit reaches, the top zone is open-ended, and below-floor warm-up samples still count as the lowest zone instead of vanishing. - Gap-honest strip — a sample only speaks for up to
maxGapSeconds(five median sampling intervals by default): a paused recording accrues no time-in-zone and shows through as muted track rather than being stretched over, so neither the strip nor the totals claim measurements that were never taken. - One color formula, three consumers — the strip run, the row dot and the row bar all read
var(--chart-N)from the same zone index, so the two representations of a zone can never drift apart and re-theme with the host's chart tokens. - Linked highlight — hovering either representation of a zone lights both, answering "where in the session was all that Z4?" without adding any control surface; it stays pointer-only sugar because every number is also printed as text.
- Rounding honesty — a visited zone prints
<1%instead of0%and keeps a 1%-minimum bar, so a ten-second sprint into Z5 is never rounded out of the story.
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.
Race Splits
A four-state race-splits spaghetti chart: every athlete's cumulative gap to the split leader against a zero baseline, lines named at their ends, lead changes and DNFs disclosed, every split keyboard-reachable.