"use client"; /** * RangeChart, from https://motion-brew.com/components/range-chart * * One self contained file: the component and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. */ import * as React from "react"; import { type ClassValue, clsx } from "clsx"; import { ArrowRight } from "lucide-react"; import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react"; import { twMerge } from "tailwind-merge"; // cn(): merges Tailwind classes, later ones win. /** Merge Tailwind classes, letting later classes win conflicts. */ function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // RangeChart /** * RangeChart * * Trigger: hover and press (plus keys and in view). A white card that charts a * range for each day, like heart rate: a pill from each day's low to high on * a soft column track, separate bubbles for outlying readings, and a dashed * average line. A 7D, 15D or 30D switch picks how many days to show: an ink * pill slides to it, the bars slim down or widen to fit and grow back in, and * the labels change from weekdays to dates. When the card scrolls into view * it rises, the pills grow out from the average one after another and the * average tag slides in. Hovering a day (or focusing it, or stepping with the * arrow keys) widens its pills, marks their ends with white dots and fades * the other days, while the big range in the header rolls to that day like * an odometer, the caption names it, and the Lowest, Average and Peak tiles * roll to match; leaving rolls it all back. The Full stats button slides its * arrow through on hover. Reduced motion keeps the states without movement. * * Engine: motion/react (staggered springs for the bars, odometer digit * columns for the numbers, layoutId pills for the switch and the hovered * column, AnimatePresence for the captions). */ export interface RangeDay { /** The day, used for labels and the header. */ date: Date; /** One or more [low, high] ranges; a short one reads as a single bubble. */ ranges: [number, number][]; } export interface RangeChartProps { title?: string; /** Unit after the numbers. Default `"bpm"`. */ unit?: string; /** Every day you have, oldest first. The chart shows the most recent ones. Default 30 days. */ data?: RangeDay[]; /** Spans the switch offers, in days. Default `[7, 15, 30]`. */ spans?: number[]; /** Span shown first. Default `7`. */ defaultSpan?: number; /** Value of the dashed average line. Default the mean of the shown readings. */ average?: number; /** Range colour. Default a warm red. */ color?: string; /** Called by the Full stats button. */ onFullStats?: () => void; className?: string; } // Thirty days of believable heart rate ranges, from a fixed seed so it never jumps. const DEFAULT_DATA: RangeDay[] = (() => { let seed = 7; const rand = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646; const end = new Date(2026, 8, 17); return Array.from({ length: 30 }, (_, i) => { const date = new Date(end); date.setDate(end.getDate() - (29 - i)); const low = Math.round(46 + rand() * 22); const high = Math.round(108 + rand() * 80); const ranges: [number, number][] = [[low, high]]; // Now and then a reading sits apart from the rest. if (rand() > 0.72) { const above = rand() > 0.5; const v = above ? Math.min(196, high + 14 + Math.round(rand() * 14)) : Math.max(38, low - 16 - Math.round(rand() * 6)); ranges.push([v, v + 8]); } return { date, ranges }; }); })(); const GLIDE = [0.22, 1, 0.36, 1] as const; const GROW = { type: "spring", stiffness: 190, damping: 21 } as const; const PILL = { type: "spring", stiffness: 480, damping: 38 } as const; const ROLL = { type: "spring", stiffness: 280, damping: 26, mass: 0.8, } as const; const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const; const CHART_H = 200; const LABEL_H = 24; // space under the columns for the labels function Digit({ digit, still }: { digit: number; still: boolean }) { return ( {DIGITS.map((d) => ( {d} ))} ); } /** A number whose digits roll like an odometer; columns are keyed from the right. */ function Rolling({ value, still }: { value: number; still: boolean }) { const chars = Array.from(String(Math.round(value))); return ( {chars.map((ch, i) => ( ))} ); } const weekday = new Intl.DateTimeFormat("en", { weekday: "short" }); const longDay = new Intl.DateTimeFormat("en", { weekday: "short", day: "numeric", month: "short", }); const shortDate = new Intl.DateTimeFormat("en", { day: "numeric", month: "short", }); export const RangeChart = React.forwardRef( function RangeChart( { title = "Heart rate", unit = "bpm", data = DEFAULT_DATA, spans = [7, 15, 30], defaultSpan = 7, average, color = "#e5484d", onFullStats, className, }, ref, ) { const root = React.useRef(null); React.useImperativeHandle(ref, () => root.current!); const still = !!useReducedMotion(); const inView = useInView(root, { once: true, amount: 0.3 }); const shown = inView || still; const id = React.useId(); const [span, setSpan] = React.useState( spans.includes(defaultSpan) ? defaultSpan : spans[0]!, ); const [hover, setHover] = React.useState(null); const days = data.slice(-span); const n = days.length; const all = days.flatMap((d) => d.ranges.flat()); const lo = Math.min(...all); const hi = Math.max(...all); const avg = Math.round( average ?? all.reduce((s, v) => s + v, 0) / Math.max(1, all.length), ); const floor = Math.max(0, lo - 12); const ceil = hi + 12; const y = (v: number) => ((v - floor) / (ceil - floor)) * CHART_H; // from the bottom const avgY = y(avg); const day = hover === null ? null : days[hover]!; const vals = day ? day.ranges.flat() : all; const showLo = Math.min(...vals); const showHi = Math.max(...vals); const showAvg = day ? Math.round(vals.reduce((s, v) => s + v, 0) / vals.length) : avg; const period = `${shortDate.format(days[0]!.date)} to ${shortDate.format(days[n - 1]!.date)}`; // Bars slim down as more days share the width. const barW = n <= 7 ? 22 : n <= 15 ? 12 : 6; const label = (d: Date, i: number) => n <= 7 ? weekday.format(d).slice(0, 2) : n <= 15 ? String(d.getDate()) : (n - 1 - i) % 5 === 0 ? String(d.getDate()) : ""; const move = (to: number) => { const next = (to + n) % n; setHover(next); root.current?.querySelectorAll("[data-day]")[next]?.focus(); }; return ( {/* Header: title and the span switch */}

{title}

{spans.map((s) => { const on = s === span; return ( ); })}
{/* The range rolls to the hovered day. */}

to {unit}

{day ? longDay.format(day.date) : `${period}, last ${span} days`}
{/* Chart */}
setHover(null)} onKeyDown={(e) => { if (e.key === "ArrowRight") (e.preventDefault(), move((hover ?? -1) + 1)); else if (e.key === "ArrowLeft") (e.preventDefault(), move((hover ?? n) - 1)); }} >
{days.map((d, i) => { const on = hover === i; const dim = hover !== null && !on; const flat = d.ranges.flat(); return ( ); })}
{/* The dashed average line, with an outline tag on the right. */} Avg {avg}
{/* Lowest, average and peak, for the span or the hovered day. */}
{[ { label: "Lowest", value: showLo }, { label: "Average", value: showAvg }, { label: "Peak", value: showHi }, ].map((t) => (

{t.label}

{unit}

))}
Full stats
); }, );