Sleep Stages
A four-state sleep hypnogram — stage-colored segments stepping across fixed Awake/REM/Light/Deep lanes over the night, with time-in-stage summary bars and percentages.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-sleep-stages.jsonPrompt
Build a React + TypeScript + Tailwind "ChartSleepStages" hypnogram card with
zod. No chart library — the plot is absolutely-positioned divs on a percent
time scale, so it needs no measuring, no ResizeObserver and no SVG text.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
stages: { start: ISO string; end: ISO string;
stage: "awake" | "rem" | "light" | "deep" }[] }.
- Component props = z.infer of the schema, plus locale ("en-US"),
showSummary (true), onRetry?: () => void, className, and the remaining
div props spread on the root. No hand-written parallel interface.
Behavior
- Derive the night once (useMemo): parse both timestamps with Date.parse,
drop rows that don't parse or end <= start and COUNT them — the count is
printed under the header ("N segments had unreadable timestamps —
ignored"), never swallowed. Sort by start; night span = first start to
last end.
- Four fixed lanes top-to-bottom in clinical order: Awake, REM, Light,
Deep — "the line falling" is the night deepening. Each segment is a bar
in its lane at left/width percentages of the span, with a width floor
(~0.35%) so a 1-minute wake-up never rounds to zero pixels.
- Step connectors: a thin vertical line joins two consecutive segments in
different lanes only when the boundary gap is under ~90s; a tracker
dropout stays a visible hole, not a fake clean transition.
- Time axis: hour ticks aligned to LOCAL clock hours via Date rounding
(epoch rounding lands on UTC hours and drifts in :30 timezones), thinned
so at most ~8 fit; faint full-height grid lines behind the lanes.
- Header meta line: "10:56 PM – 6:32 AM · 6 h 40 m asleep of 7 h 36 m in
bed · 2 wake-ups". Asleep = tracked minus awake; wake-ups = awake
segments after the first sleep segment.
- Summary bars (showSummary): one row per stage — label, token-colored bar
on a bg-muted track, duration and percentage of tracked time
(guard division by zero).
- Cursor: one delegated pointermove on the plot (data-seg attribute per
bar), plus tabIndex=0 with ArrowLeft/Right stepping chronologically
(clamped, never wrapping), Home/End, Escape to clear; blur clears. The
active bar gets a ring, the rest dim; an aria-live readout line prints
"11:42 PM – 12:18 AM · Deep · 36 m", defaulting to the longest sleep
stretch. Clamp the cursor when data reloads with fewer segments.
- Four first-class status branches: loading = a deterministic skeleton
that mirrors the lane silhouette; empty (also ready-with-zero-drawable
rows) = dashed step glyph + copy; error = message + a "Try again" button
rendered only when onRetry exists.
Rendering & styling
- Semantic tokens only: lanes map to chart tokens in one constant —
awake var(--chart-4), rem var(--chart-3), light var(--chart-2),
deep var(--chart-1); grid/borders use border, track bg-muted, copy
text-muted-foreground, panel rounded-xl border bg-card p-6.
- cn() merges className; skeleton pulse gets motion-reduce:animate-none
and the dim transition motion-reduce:transition-none; focus-visible ring
on the plot; numbers tabular-nums.
- The bars are decorative to a screen reader: the plot is role="img" with
a full-sentence summary label, and an sr-only <table> lists every
segment with times, stage, duration and share. Never put sr-only on the
table itself — width:1px is only a lower bound for a table box.
Customization levers
- Lane order & colors: both live in the single LANES constant — reorder
lanes or re-map stage → token there (e.g. REM as the headline hue for a
dream-tracking app) without touching geometry.
- Density: LANE_H (40) and BAR_H (12) set the plot's height; drop LANE_H
to ~28 for a row inside a list, raise BAR_H for a chunkier poster look.
- Sub-blocks: showSummary=false for a sparkline-style embed; the meta line
and readout are independent <p>s you can delete or restyle.
- Granularity: the stage enum extends (e.g. add "n3" vs "n2") by adding a
lane entry and an enum member — everything else derives from LANES.
- Contiguity: CONTIGUOUS_MS (90s) decides what counts as one continuous
night versus a visible recording gap; loosen it for coarse trackers.
- Time labels: swap the Intl options for 24-hour clocks or pass locale;
all formatting goes through the two shared formatters.Concepts
- Fixed-lane hypnogram — every stage owns a permanent horizontal lane in clinical order (awake up, deep down), so the night reads as one line falling into deep sleep and rising toward morning; nothing is stacked or re-sorted between nights, which is what makes two nights comparable at a glance.
- Step connectors, honestly gated — vertical joins are drawn only where two segments actually touch (under ~90s apart); a tracker dropout stays a visible hole instead of being smoothed into a transition that never happened.
- Structure and composition in one card — the lanes answer "how did the night flow?" while the time-in-stage bars answer "what was it made of?"; both derive from the same parsed segments, so they can never disagree.
- Dropped rows are counted out loud — a timestamp that doesn't parse removes a segment from the drawing, and the chart says exactly how many it removed; a hypnogram that silently loses a wake-up is lying about the night.
- Percent time scale — every bar and tick is positioned as a percentage of the night's span, so the plot stretches to any container without measurement, observers or SVG text scaling.
- Cursor with a spoken twin — hover and arrow keys share one cursor whose readout line is aria-live, and the full segment list lives in an sr-only table, so the pointer affordance never becomes the only way in.
Elevation Profile
A four-state route elevation profile in plain SVG — the distance-vs-elevation area split into flat, moderate and steep runs by grade, labelled climb bands measured from the samples underneath them, and a hover or keyboard readout of km, elevation and grade.
Fatigue and Freshness
A four-state performance-management chart: fitness (CTL) and fatigue (ATL) as exponential averages of daily training stress, form (TSB) shaded around zero, race days flagged.