Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartSleepStagesData,
  ChartSleepStagesItem,
  SleepStage,
} from "./chart-sleep-stages.contract"

export interface ChartSleepStagesProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartSleepStagesData {

Installation

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

Prompt

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.

On This Page