Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, HeartPulse, RefreshCcw } from "lucide-react"

import { cn } from "@/lib/utils"
import type { ChartHrZonesData, ChartHrZonesZone } from "./chart-hr-zones.contract"

export interface ChartHrZonesProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartHrZonesData {
  /** Rendered only in the error branch; omit to hide the retry affordance. */
  onRetry?: () => void
  /** Replaces the zero-data body. */

Installation

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

Prompt

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 minBpm it 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 of 0% and keeps a 1%-minimum bar, so a ten-second sprint into Z5 is never rounded out of the story.

On This Page