{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"trend-chart","type":"registry:component","title":"Trend chart","description":"One metric over time, the way an analytics screen needs it: the period before as a dashed line, an unfinished day drawn dashed, a readout that glides with the pointer, and drag to zoom.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/trend-chart.tsx","type":"registry:component","content":"// Hairline UI · trend-chart · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\nimport { TimeBrush } from \"./time-brush\";\n\n/* ─────────────────────────────────────────────────────────\n * TREND CHART: one metric over time, read by pointer or keys\n *\n *   change   same buckets, new numbers (a filter): the line morphs\n *            into place and the scale rolls. New buckets (a range):\n *            the old line stretches or shrinks to where its days sit\n *            on the new axis while the new one fades in over it, so\n *            zooming in looks like zooming in\n *   compare  the period before as a dashed line, drawn in from the\n *            left when it's turned on\n *   hover    a hairline and a dot follow the pointer; one readout\n *            card glides beside them, its date morphing and its\n *            figures rolling\n *   drag     drag across the chart to zoom into those buckets (Time\n *            brush, snapped to bucket edges)\n *   today    a bucket that isn't over yet is drawn dashed, so a dip\n *            at the end isn't news\n * ───────────────────────────────────────────────────────── */\n\nexport type TrendInterval = \"hour\" | \"day\" | \"week\" | \"month\";\n/** How the figures read: plain counts (or money, with `format`), percentages (0–100) or durations in seconds. */\nexport type TrendKind = \"count\" | \"percent\" | \"duration\";\n\nexport interface TrendChartProps {\n  /** Bucket starts (epoch ms), oldest first. */\n  buckets: number[];\n  /** One value per bucket. */\n  values: number[];\n  /** The period before, bucket for bucket; null or left out hides the dashed line. */\n  previous?: number[] | null;\n  /** How long one bucket is. */\n  interval?: TrendInterval;\n  kind?: TrendKind;\n  /** Number format for counts, e.g. { style: \"currency\", currency: \"USD\" }. The scale writes the same format compactly. */\n  format?: Intl.NumberFormatOptions;\n  /** What one value is called in a sentence: \"3,412 visitors\". */\n  unit?: string;\n  /** How the readout names the dashed line. */\n  previousLabel?: string;\n  /** The time now (epoch ms): a last bucket that ends after it is unfinished and drawn dashed. The clock at mount by default. */\n  now?: number;\n  /** Called with the span dragged across. Leave it out and the chart can't be brushed. */\n  onZoom?: (range: { start: number; end: number }) => void;\n  /** A line under the figures in the readout, e.g. who sent that day's visitors. */\n  note?: (index: number) => string | null | undefined;\n  /** The bucket the pointer or keys are on, for linking other widgets to the readout. */\n  onActiveChange?: (index: number | null) => void;\n  /** Accessible name. */\n  label: string;\n  /** For dates and numbers; the viewer's own by default. */\n  locale?: string;\n  className?: string;\n}\n\ntype Snapshot = {\n  key: string;\n  buckets: number[];\n  centers: number[];\n  values: number[];\n  previous: number[] | null;\n  domain: [number, number];\n  max: number;\n  partial: boolean;\n  interval: TrendInterval;\n};\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst DURATION = 640;\nconst TOP = 10;\nconst AXIS = 26;\nconst STEPS = 4;\nconst HOUR = 3_600_000;\nconst DAY = 86_400_000;\nconst WHOLE: Intl.NumberFormatOptions = { maximumFractionDigits: 0 };\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () => React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst ease = (t: number) => 1 - Math.pow(1 - t, 4);\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpAll = (a: number[], b: number[], t: number) => b.map((v, i) => lerp(a[i] ?? v, v, t));\n\n/** `t` moved on by one bucket, in calendar terms (a day across a clock change is still one day). */\nfunction nextBucket(t: number, interval: TrendInterval) {\n  if (interval === \"hour\") return t + HOUR;\n  const d = new Date(t);\n  if (interval === \"month\") d.setMonth(d.getMonth() + 1);\n  else d.setDate(d.getDate() + (interval === \"week\" ? 7 : 1));\n  return d.getTime();\n}\n\n/** \"2m 41s\", \"48s\", \"1h 3m\". */\nexport function formatDuration(seconds: number) {\n  const s = Math.round(seconds);\n  if (s < 60) return `${s}s`;\n  if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;\n  return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;\n}\n\n/** A round top for a scale with four steps: 1,840 → 2,000. Durations step in whole minutes. */\nfunction niceTop(value: number, kind: TrendKind) {\n  if (value <= 0) return kind === \"duration\" ? 240 : kind === \"percent\" ? 100 : STEPS;\n  if (kind === \"percent\") return Math.min(100, Math.ceil(value / 20) * 20) || 100;\n  const raw = value / STEPS;\n  if (kind === \"duration\") {\n    const step = [15, 30, 60, 90, 120, 180, 300, 600, 900, 1800].find((s) => s >= raw) ?? Math.ceil(raw / 1800) * 1800;\n    return step * STEPS;\n  }\n  const mag = 10 ** Math.floor(Math.log10(raw));\n  const step = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((x) => x * mag).find((s) => s >= raw) ?? 10 * mag;\n  return step * STEPS;\n}\n\nconst r2 = (v: number) => Math.round(v * 100) / 100;\n\n/** A smooth line that never overshoots its points (monotone cubic), split into segments so the last one can be drawn dashed. */\nfunction monotoneSegments(points: [number, number][]) {\n  const n = points.length;\n  if (!n) return { head: \"\", segments: [] as string[] };\n  const head = `M${r2(points[0][0])},${r2(points[0][1])}`;\n  if (n === 1) return { head, segments: [] as string[] };\n  const m: number[] = [];\n  for (let i = 0; i < n - 1; i++) m.push((points[i + 1][1] - points[i][1]) / (points[i + 1][0] - points[i][0] || 1));\n  const t: number[] = [m[0]];\n  for (let i = 1; i < n - 1; i++) t.push(m[i - 1] * m[i] <= 0 ? 0 : (m[i - 1] + m[i]) / 2);\n  t.push(m[n - 2]);\n  for (let i = 0; i < n - 1; i++) {\n    if (m[i] === 0) {\n      t[i] = 0;\n      t[i + 1] = 0;\n      continue;\n    }\n    const a = t[i] / m[i];\n    const b = t[i + 1] / m[i];\n    const s = a * a + b * b;\n    if (s > 9) {\n      const k = 3 / Math.sqrt(s);\n      t[i] = k * a * m[i];\n      t[i + 1] = k * b * m[i];\n    }\n  }\n  const segments: string[] = [];\n  for (let i = 0; i < n - 1; i++) {\n    const [x0, y0] = points[i];\n    const [x1, y1] = points[i + 1];\n    const dx = (x1 - x0) / 3;\n    segments.push(`C${r2(x0 + dx)},${r2(y0 + t[i] * dx)} ${r2(x1 - dx)},${r2(y1 - t[i + 1] * dx)} ${r2(x1)},${r2(y1)}`);\n  }\n  return { head, segments };\n}\n\n/** Dates for one locale: the readout's title, the axis ticks and the brush's edges. */\nfunction dateWords(locale: string | undefined) {\n  const day = new Intl.DateTimeFormat(locale, { weekday: \"short\", day: \"numeric\", month: \"short\" });\n  const short = new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\" });\n  const hour = new Intl.DateTimeFormat(locale, { hour: \"numeric\" });\n  const clock = new Intl.DateTimeFormat(locale, { hour: \"2-digit\", minute: \"2-digit\" });\n  const month = new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\" });\n  const monthShort = new Intl.DateTimeFormat(locale, { month: \"short\" });\n  return {\n    /** \"Fri 18 Sep\" · \"Fri 18 Sep, 2 PM\" · \"Week of 14 Sep\" · \"September 2026\". */\n    title: (t: number, interval: TrendInterval) =>\n      interval === \"hour\" ? `${day.format(t)}, ${hour.format(t)}` : interval === \"week\" ? `Week of ${short.format(t)}` : interval === \"month\" ? month.format(t) : day.format(t),\n    /** \"18 Sep\", \"2 PM\", \"Sep\". */\n    tick: (t: number, interval: TrendInterval) => (interval === \"hour\" ? hour.format(t) : interval === \"month\" ? monthShort.format(t) : short.format(t)),\n    edge: (t: number, interval: TrendInterval) => (interval === \"hour\" ? clock.format(t) : interval === \"month\" ? monthShort.format(t) : short.format(t)),\n  };\n}\n\nfunction snapshotOf(p: { buckets: number[]; values: number[]; previous: number[] | null; interval: TrendInterval; kind: TrendKind; now: number | null }): Snapshot | null {\n  const n = p.buckets.length;\n  if (!n) return null;\n  const ends = p.buckets.map((b, i) => p.buckets[i + 1] ?? nextBucket(b, p.interval));\n  return {\n    key: `${p.kind}|${p.interval}|${p.buckets[0]}|${n}|${p.values.join(\",\")}|${p.previous?.join(\",\") ?? \"\"}`,\n    buckets: p.buckets,\n    centers: p.buckets.map((b, i) => (b + ends[i]) / 2),\n    values: p.values,\n    previous: p.previous,\n    domain: [p.buckets[0], ends[n - 1]],\n    max: niceTop(Math.max(0, ...p.values, ...(p.previous ?? [])), p.kind),\n    partial: p.now !== null && ends[n - 1] > p.now + 60_000,\n    interval: p.interval,\n  };\n}\n\nconst sameBuckets = (a: Snapshot, b: Snapshot) => a.interval === b.interval && a.buckets.length === b.buckets.length && a.buckets[0] === b.buckets[0];\n\n/**\n * A sentence that changes crossfades whole: the old one lifts away through a blur while the\n * new one rises in. (Letters of two sentences don't morph well; labels use TextMorph.)\n */\nfunction Swap({ text, reduced, className = \"\" }: { text: string; reduced: boolean; className?: string }) {\n  const [shown, setShown] = React.useState({ text, key: 0 });\n  const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]);\n  if (text !== shown.text) {\n    if (shown.text && !reduced) setLeaving((l) => [...l.slice(-2), shown]);\n    setShown({ text, key: shown.key + 1 });\n  }\n  return (\n    <span className={`relative block ${className}`}>\n      <span\n        key={shown.key}\n        className=\"block truncate\"\n        ref={(el) => {\n          if (!el || reduced || shown.key === 0 || el.dataset.in) return;\n          el.dataset.in = \"1\";\n          el.animate([{ opacity: 0, transform: \"translateY(0.3em)\", filter: \"blur(4px)\" }, { opacity: 1, transform: \"none\", filter: \"blur(0px)\" }], { duration: 320, delay: 40, easing: EASE, fill: \"backwards\" });\n        }}\n      >\n        {shown.text || \" \"}\n      </span>\n      {leaving.map((l) => (\n        <span\n          key={l.key}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 bottom-0 block truncate\"\n          ref={(el) => {\n            if (!el || el.dataset.out) return;\n            el.dataset.out = \"1\";\n            el.animate([{ opacity: 1, filter: \"blur(0px)\" }, { opacity: 0, transform: \"translateY(-0.3em)\", filter: \"blur(4px)\" }], { duration: 160, easing: EASE, fill: \"forwards\" }).onfinish = () =>\n              setLeaving((all) => all.filter((x) => x.key !== l.key));\n          }}\n        >\n          {l.text}\n        </span>\n      ))}\n    </span>\n  );\n}\n\nexport function TrendChart({\n  buckets,\n  values,\n  previous = null,\n  interval = \"day\",\n  kind = \"count\",\n  format,\n  unit,\n  previousLabel = \"the period before\",\n  now: nowProp,\n  onZoom,\n  note,\n  onActiveChange,\n  label,\n  locale,\n  className = \"\",\n}: TrendChartProps) {\n  const reduced = useReducedMotion();\n  const gradientId = `trend-wash-${React.useId().replace(/:/g, \"\")}`;\n  // Without a `now`, the clock at mount (null on the server, so the first paint matches).\n  const [mountedAt, setMountedAt] = React.useState<number | null>(null);\n  React.useEffect(() => setMountedAt(Date.now()), []);\n  const now = nowProp ?? mountedAt;\n  const target = React.useMemo(() => snapshotOf({ buckets, values, previous, interval, kind, now }), [buckets, values, previous, interval, kind, now]);\n  const [frame, setFrame] = React.useState<{ from: Snapshot | null; to: Snapshot | null; morph: boolean; p: number }>({ from: null, to: null, morph: false, p: 1 });\n  const drawnRef = React.useRef(frame);\n  drawnRef.current = frame;\n  const [arrived, setArrived] = React.useState(false);\n  const [width, setWidth] = React.useState(0);\n  const [plotH, setPlotH] = React.useState(200);\n  const [active, setActive] = React.useState<number | null>(null);\n  const [keyboard, setKeyboard] = React.useState(false);\n  const [card, setCard] = React.useState({ w: 180, h: 80 });\n  const plotRef = React.useRef<HTMLDivElement>(null);\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const cardRef = React.useRef<HTMLDivElement>(null);\n  const words = React.useMemo(() => dateWords(locale), [locale]);\n  const numbers = React.useMemo(() => new Intl.NumberFormat(locale, format ?? WHOLE), [locale, format]);\n\n  // A new target: start from wherever the drawing is now (mid-change included).\n  React.useLayoutEffect(() => {\n    if (!target) return;\n    const cur = drawnRef.current;\n    if (!cur.to || reduced) {\n      setFrame({ from: null, to: target, morph: false, p: 1 });\n      if (!cur.to) requestAnimationFrame(() => requestAnimationFrame(() => setArrived(true)));\n      return;\n    }\n    if (cur.to.key === target.key) return;\n    const e = ease(cur.p);\n    const here: Snapshot = cur.from\n      ? {\n          ...cur.to,\n          values: cur.morph ? lerpAll(cur.from.values, cur.to.values, e) : cur.to.values,\n          domain: [lerp(cur.from.domain[0], cur.to.domain[0], e), lerp(cur.from.domain[1], cur.to.domain[1], e)],\n          max: lerp(cur.from.max, cur.to.max, e),\n        }\n      : cur.to;\n    const morph = sameBuckets(here, target);\n    setFrame({ from: here, to: target, morph, p: 0 });\n    const begin = performance.now();\n    let raf = 0;\n    const step = (t: number) => {\n      const p = Math.min(1, (t - begin) / DURATION);\n      setFrame((f) => (f.to === target ? { ...f, p } : f));\n      if (p < 1) raf = requestAnimationFrame(step);\n    };\n    raf = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(raf);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [target?.key, reduced]);\n\n  // The plot's size comes from CSS (its height steps down in narrow cards).\n  React.useLayoutEffect(() => {\n    const el = boxRef.current;\n    const plot = plotRef.current;\n    if (!el || !plot) return;\n    const measure = () => {\n      setPlotH((h) => (el.offsetHeight - AXIS > 0 && el.offsetHeight - AXIS !== h ? el.offsetHeight - AXIS : h));\n      setWidth((w) => (plot.offsetWidth === w ? w : plot.offsetWidth));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    observer.observe(plot);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const el = cardRef.current;\n    if (el && active !== null) setCard((c) => (Math.abs(c.w - el.offsetWidth) < 1 && Math.abs(c.h - el.offsetHeight) < 1 ? c : { w: el.offsetWidth, h: el.offsetHeight }));\n  });\n\n  const activeRef = React.useRef<number | null>(null);\n  const setActiveIndex = (i: number | null) => {\n    if (activeRef.current === i) return;\n    activeRef.current = i;\n    setActive(i);\n    onActiveChange?.(i);\n  };\n\n  const { from, to, morph, p } = frame;\n  const e = ease(p);\n  const domain: [number, number] = to ? (from ? [lerp(from.domain[0], to.domain[0], e), lerp(from.domain[1], to.domain[1], e)] : to.domain) : [0, 1];\n  const max = to ? (from ? lerp(from.max, to.max, e) : to.max) : niceTop(0, kind);\n  const x = (t: number) => ((t - domain[0]) / (domain[1] - domain[0] || 1)) * width;\n  const y = (v: number) => TOP + (plotH - TOP) * (1 - Math.min(1.08, v / (max || 1)));\n\n  // What's drawn: one morphing series, or the old one fading under the new one.\n  const layers: { snap: Snapshot; values: number[]; alpha: number }[] = [];\n  if (to) {\n    if (from && morph) layers.push({ snap: to, values: lerpAll(from.values, to.values, e), alpha: 1 });\n    else {\n      if (from && p < 1) layers.push({ snap: from, values: from.values, alpha: 1 - e });\n      layers.push({ snap: to, values: to.values, alpha: from ? e : 1 });\n    }\n  }\n  const shownValues = layers[layers.length - 1]?.values ?? [];\n\n  const paths = (snap: Snapshot, vals: number[]) => {\n    const points = snap.centers.map((c, i) => [x(c), y(vals[i] ?? 0)] as [number, number]);\n    const { head, segments } = monotoneSegments(points);\n    const n = points.length;\n    const tail = snap.partial && n > 1;\n    const solid = head + (tail ? segments.slice(0, -1) : segments).join(\"\");\n    const dashed = tail ? `M${points[n - 2][0]},${points[n - 2][1]}${segments[n - 2]}` : \"\";\n    const area = n > 1 ? `${head}${segments.join(\"\")}L${points[n - 1][0]},${plotH}L${points[0][0]},${plotH}Z` : \"\";\n    return { solid, dashed, area };\n  };\n  const prevPath = (snap: Snapshot, prev: number[]) => {\n    const points = snap.centers.map((c, i) => [x(c), y(prev[i] ?? 0)] as [number, number]);\n    const { head, segments } = monotoneSegments(points);\n    return head + segments.join(\"\");\n  };\n\n  const comparing = !!to?.previous;\n  const [compareShown, setCompareShown] = React.useState(false);\n  React.useEffect(() => {\n    if (!comparing) return setCompareShown(false);\n    const a = requestAnimationFrame(() => requestAnimationFrame(() => setCompareShown(true)));\n    return () => cancelAnimationFrame(a);\n  }, [comparing]);\n\n  const n = to?.buckets.length ?? 0;\n  const indexAt = (clientX: number) => {\n    const r = plotRef.current?.getBoundingClientRect();\n    if (!r || !to || !n) return null;\n    const t = domain[0] + ((clientX - r.left) / r.width) * (domain[1] - domain[0]);\n    let best = 0;\n    for (let i = 1; i < n; i++) if (Math.abs(to.centers[i] - t) < Math.abs(to.centers[best] - t)) best = i;\n    return best;\n  };\n  const onKeyDown = (ev: React.KeyboardEvent) => {\n    if (!n) return;\n    const at = active ?? n - 1;\n    const moves: Record<string, number> = { ArrowLeft: at - 1, ArrowRight: at + 1, Home: 0, End: n - 1 };\n    if (ev.key === \"Escape\") return setActiveIndex(null);\n    if (!(ev.key in moves)) return;\n    ev.preventDefault();\n    setKeyboard(true);\n    setActiveIndex(Math.min(n - 1, Math.max(0, active === null ? at : moves[ev.key])));\n  };\n\n  // Ticks along the bottom: as many as fit, counted back from the newest so it always has one.\n  const ticks = (snap: Snapshot) => {\n    const gap = snap.interval === \"hour\" ? 52 : 64;\n    const every = width ? Math.max(1, Math.ceil(gap / (width / snap.buckets.length))) : snap.buckets.length;\n    return snap.buckets.map((b, i) => ({ b, i })).filter(({ i }) => (snap.buckets.length - 1 - i) % every === 0);\n  };\n\n  const fmt = (v: number) => (kind === \"percent\" ? `${Math.round(v)}%` : kind === \"duration\" ? formatDuration(v) : numbers.format(v));\n  const scale = (f: number) => {\n    const v = (to?.max ?? max) * f;\n    if (kind === \"duration\") return <TextMorph>{v ? formatDuration(v).replace(/ 0s$/, \"\") : \"0\"}</TextMorph>;\n    if (kind === \"percent\") return <NumberRoll value={v} format={WHOLE} suffix=\"%\" locales={locale} duration={700} />;\n    return <NumberRoll value={v} format={{ ...format, notation: \"compact\", minimumFractionDigits: 0, maximumFractionDigits: 1 }} locales={locale} duration={700} />;\n  };\n\n  // The brush snaps to bucket edges, so what it covers is whole buckets.\n  const brush = React.useMemo(() => (to ? { start: new Date(to.domain[0]), end: new Date(to.domain[1]) } : null), [to]);\n  const snap = (t: number) => {\n    if (!to) return t;\n    const edges = [...to.buckets, to.domain[1]];\n    let best = edges[0];\n    for (const edge of edges) if (Math.abs(edge - t) < Math.abs(best - t)) best = edge;\n    return best;\n  };\n  const minSpan = interval === \"hour\" ? HOUR : interval === \"day\" ? DAY - 2 * HOUR : interval === \"week\" ? 6 * DAY : 27 * DAY;\n  const tag = (d: Date, edge: \"start\" | \"end\") => words.edge(edge === \"end\" && interval !== \"hour\" ? d.getTime() - 1 : d.getTime(), interval);\n\n  const activeValue = active !== null ? (to?.values[active] ?? 0) : 0;\n  const activePrev = active !== null ? (to?.previous?.[active] ?? null) : null;\n  const ax = active !== null && to ? x(to.centers[active]) : 0;\n  const ay = active !== null ? y(shownValues[active] ?? 0) : 0;\n  // The card sits beside the dot, on whichever side has room. In a narrow chart with room on\n  // neither side, it moves to the half of the plot the dot isn't in, so it never covers it.\n  const flip = ax + 16 + card.w > width;\n  const cardX = Math.max(0, Math.min(width - card.w, flip ? ax - 16 - card.w : ax + 16));\n  const cramped = flip && ax - 16 - card.w < 0;\n  const cardY = cramped && ay < plotH / 2 ? Math.max(0, plotH - card.h - 4) : 0;\n  const noteText = active !== null ? (note?.(active) ?? null) : null;\n  const brushable = !!onZoom && !!to && n > 1;\n\n  return (\n    <div className={`@container relative [--gutter:34px] [--plot:170px] @[560px]:[--gutter:44px] @[560px]:[--plot:232px] ${className}`}>\n      <div ref={boxRef} className=\"relative\" style={{ height: `calc(var(--plot) + ${AXIS}px)` }}>\n        {/* The scale: hairlines at fixed heights, figures that roll to the new top. */}\n        {Array.from({ length: STEPS + 1 }, (_, k) => 1 - k / STEPS).map((f) => (\n          <div key={f} aria-hidden=\"true\" className=\"absolute inset-x-0\" style={{ top: TOP + (plotH - TOP) * (1 - f) }}>\n            <span className=\"absolute left-0 top-0 -translate-y-1/2 whitespace-nowrap text-right text-[10.5px] leading-none tabular-nums text-muted-foreground\" style={{ width: \"calc(var(--gutter) - 10px)\" }}>\n              {to ? scale(f) : null}\n            </span>\n            <div className={`border-t ${f === 0 ? \"border-border\" : \"border-dashed border-border/80\"}`} style={{ marginLeft: \"var(--gutter)\" }} />\n          </div>\n        ))}\n\n        <div className=\"absolute right-0 top-0\" style={{ left: \"var(--gutter)\", height: plotH }}>\n          <TimeBrush\n            start={brush?.start ?? new Date(0)}\n            end={brush?.end ?? new Date(1)}\n            onSelect={({ start, end }) => {\n              setActiveIndex(null);\n              onZoom?.({ start: start.getTime(), end: end.getTime() });\n            }}\n            snap={snap}\n            formatTime={tag}\n            minSpan={minSpan}\n            disabled={!brushable}\n            className=\"h-full w-full\"\n          >\n            <div\n              ref={plotRef}\n              role=\"group\"\n              tabIndex={0}\n              aria-label={`${label}. Arrow keys read each point${onZoom ? \"; drag across the chart to zoom in\" : \"\"}.`}\n              onKeyDown={onKeyDown}\n              onBlur={() => keyboard && (setActiveIndex(null), setKeyboard(false))}\n              onPointerMove={(ev) => {\n                if (ev.buttons & 1 && brushable) return setActiveIndex(null);\n                setKeyboard(false);\n                setActiveIndex(indexAt(ev.clientX));\n              }}\n              onPointerDown={(ev) => ev.pointerType !== \"mouse\" && setActiveIndex(indexAt(ev.clientX))}\n              onPointerLeave={(ev) => ev.pointerType === \"mouse\" && setActiveIndex(null)}\n              className=\"absolute inset-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/40\"\n            >\n              <div\n                className=\"absolute inset-0\"\n                style={{\n                  clipPath: arrived ? \"inset(-12px -12px -12px -12px)\" : \"inset(-12px 100% -12px -12px)\",\n                  transition: reduced ? \"none\" : `clip-path 1100ms ${EASE}`,\n                }}\n              >\n                <svg aria-hidden=\"true\" width={width} height={plotH} className=\"absolute inset-0 overflow-visible\">\n                  <defs>\n                    <linearGradient id={gradientId} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n                      <stop offset=\"0%\" stopColor=\"var(--primary)\" stopOpacity=\"0.16\" />\n                      <stop offset=\"100%\" stopColor=\"var(--primary)\" stopOpacity=\"0\" />\n                    </linearGradient>\n                  </defs>\n                  {width > 0 &&\n                    layers.map(({ snap, values: vals, alpha }, li) => {\n                      const d = paths(snap, vals);\n                      return (\n                        <g key={li === layers.length - 1 ? \"now\" : \"was\"} opacity={alpha}>\n                          <path d={d.area} fill={`url(#${gradientId})`} />\n                          <path d={d.solid} fill=\"none\" stroke=\"var(--primary)\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                          {d.dashed && <path d={d.dashed} fill=\"none\" stroke=\"var(--primary)\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeDasharray=\"3 5\" />}\n                        </g>\n                      );\n                    })}\n                </svg>\n              </div>\n\n              {/* The period before: drawn in from the left when comparing starts. */}\n              <div\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute inset-0\"\n                style={{\n                  clipPath: compareShown ? \"inset(-12px -12px -12px -12px)\" : \"inset(-12px 100% -12px -12px)\",\n                  opacity: compareShown ? 1 : 0,\n                  transition: reduced ? \"none\" : `clip-path 900ms ${EASE}, opacity 300ms ${EASE}`,\n                }}\n              >\n                <svg width={width} height={plotH} className=\"absolute inset-0 overflow-visible\">\n                  {width > 0 &&\n                    layers.map(({ snap, alpha }, li) =>\n                      snap.previous ? (\n                        <path\n                          key={li === layers.length - 1 ? \"now\" : \"was\"}\n                          d={prevPath(snap, morph && from?.previous && li === layers.length - 1 ? lerpAll(from.previous, snap.previous, e) : snap.previous)}\n                          opacity={alpha}\n                          fill=\"none\"\n                          stroke=\"var(--muted-foreground)\"\n                          strokeOpacity=\"0.7\"\n                          strokeWidth=\"1.5\"\n                          strokeDasharray=\"4 4\"\n                        />\n                      ) : null,\n                    )}\n                </svg>\n              </div>\n\n              {/* The pointer: a hairline, a dot, and the readout card beside them. */}\n              <span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute top-0 w-px bg-foreground/15\"\n                style={{ height: plotH, transform: `translateX(${ax}px)`, opacity: active !== null ? 1 : 0, transition: reduced ? \"none\" : `transform 240ms ${THROW}, opacity 180ms ${EASE}` }}\n              />\n              <span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute left-0 top-0 size-2.5 rounded-full bg-primary shadow-[0_0_0_2.5px_var(--background)]\"\n                style={{\n                  transform: `translate(${ax - 5}px, ${ay - 5}px) scale(${active !== null ? 1 : 0.4})`,\n                  opacity: active !== null ? 1 : 0,\n                  transition: reduced ? \"none\" : `transform 240ms ${THROW}, opacity 180ms ${EASE}`,\n                }}\n              />\n              <div\n                ref={cardRef}\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute left-0 top-1 z-10 w-max min-w-[148px] max-w-[240px] rounded-[12px] bg-popover px-3 py-2.5 text-popover-foreground shadow-[0_0_0_1px_var(--border)]\"\n                style={{\n                  transform: `translateX(${cardX}px) translateY(${cardY + (active !== null ? 0 : 4)}px)`,\n                  opacity: active !== null ? 1 : 0,\n                  transition: reduced ? \"none\" : `transform 260ms ${THROW}, opacity 180ms ${EASE}`,\n                }}\n              >\n                <p className=\"whitespace-nowrap text-[11.5px] text-muted-foreground\">\n                  <TextMorph>{active !== null && to ? words.title(to.buckets[active], to.interval) : \" \"}</TextMorph>\n                </p>\n                <p className=\"mt-1 flex items-baseline gap-1.5 whitespace-nowrap\">\n                  <span data-slot=\"figure\" className=\"text-[19px] font-semibold leading-none tracking-[-0.03em] tabular-nums\">\n                    {kind === \"duration\" ? (\n                      <TextMorph>{formatDuration(activeValue)}</TextMorph>\n                    ) : (\n                      <NumberRoll value={activeValue} suffix={kind === \"percent\" ? \"%\" : \"\"} format={kind === \"percent\" ? WHOLE : (format ?? WHOLE)} locales={locale} duration={500} />\n                    )}\n                  </span>\n                  {unit && <span className=\"text-[12px] text-muted-foreground\">{unit}</span>}\n                </p>\n                {activePrev !== null && (\n                  <p className=\"mt-1.5 flex items-center gap-1.5 whitespace-nowrap text-[11.5px] text-muted-foreground\">\n                    <span aria-hidden=\"true\" className=\"w-3 border-t border-dashed border-muted-foreground\" />\n                    <span className=\"tabular-nums\">{fmt(activePrev)}</span> {previousLabel}\n                  </p>\n                )}\n                {noteText !== null && <Swap text={noteText} reduced={reduced} className=\"mt-1.5 border-t border-border pt-1.5 text-[11.5px] text-foreground\" />}\n              </div>\n            </div>\n          </TimeBrush>\n        </div>\n\n        {/* Along the bottom. */}\n        <div aria-hidden=\"true\" className=\"pointer-events-none absolute right-0 [clip-path:inset(-4px_0)]\" style={{ left: \"var(--gutter)\", top: plotH + 9, height: 16 }}>\n          {width > 0 &&\n            layers.map(({ snap, alpha }, li) => (\n              <div key={li === layers.length - 1 ? \"now\" : \"was\"} style={{ opacity: alpha }}>\n                {ticks(snap).map(({ b, i }) => {\n                  // Labels near an edge hang inward instead of running off the card.\n                  const at = x(snap.centers[i]);\n                  const shift = at < 22 ? \"0%\" : at > width - 22 ? \"-100%\" : \"-50%\";\n                  return (\n                    <span\n                      key={b}\n                      className={`absolute top-0 whitespace-nowrap text-[11px] leading-none transition-colors duration-200 ${active === i && li === layers.length - 1 ? \"text-foreground\" : \"text-muted-foreground\"}`}\n                      style={{ left: at, transform: `translateX(${shift})` }}\n                    >\n                      {words.tick(b, snap.interval)}\n                    </span>\n                  );\n                })}\n              </div>\n            ))}\n        </div>\n      </div>\n\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {keyboard && active !== null && to\n          ? `${words.title(to.buckets[active], to.interval)}: ${fmt(to.values[active] ?? 0)}${unit ? ` ${unit}` : \"\"}${activePrev !== null ? `, ${fmt(activePrev)} ${previousLabel}` : \"\"}${noteText ? `. ${noteText}` : \"\"}`\n          : \"\"}\n      </p>\n      {to && (\n        <table className=\"sr-only\">\n          <caption>{label}</caption>\n          <thead>\n            <tr>\n              <th scope=\"col\">Period</th>\n              <th scope=\"col\">{unit ?? \"Value\"}</th>\n              {to.previous && <th scope=\"col\">{previousLabel.replace(/^\\w/, (c) => c.toUpperCase())}</th>}\n            </tr>\n          </thead>\n          <tbody>\n            {to.buckets.map((b, i) => (\n              <tr key={b}>\n                <th scope=\"row\">{words.title(b, to.interval)}</th>\n                <td>{fmt(to.values[i] ?? 0)}</td>\n                {to.previous && <td>{fmt(to.previous[i] ?? 0)}</td>}\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      )}\n    </div>\n  );\n}\n"},{"path":"registry/time-brush.tsx","type":"registry:component","content":"// Hairline UI · trend-chart · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { durationShort, wallAt } from \"./time-range-parse\";\n\n/* ─────────────────────────────────────────────────────────\n * TIME BRUSH: drag across any chart to pick the time under it\n *\n *   drag     a band follows the pointer from where you pressed,\n *            with its start, end and length written on it\n *   release  the band's span is passed to onSelect (zoom in),\n *            and the band fades as the chart redraws\n *   cancel   Escape, or a drag too short to mean anything\n *\n * Wrap a chart whose x axis runs from `start` to `end`. Nothing\n * re-renders while you drag: the band is moved directly.\n * ───────────────────────────────────────────────────────── */\n\nexport interface TimeBrushProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSelect\"> {\n  /** The time at the chart's left edge. */\n  start: Date;\n  /** The time at its right edge. */\n  end: Date;\n  onSelect: (range: { start: Date; end: Date }) => void;\n  /** The shortest span worth selecting, in ms. */\n  minSpan?: number;\n  /** Rounds a time to the nearest edge the chart can show (a day boundary on a daily chart). The band snaps to it while you drag. */\n  snap?: (time: number) => number;\n  /** How the band writes its edges; hours and minutes by default. `edge` says which end, so a daily chart can name the last day it covers. */\n  formatTime?: (time: Date, edge: \"start\" | \"end\") => string;\n  timeZone?: string;\n  disabled?: boolean;\n}\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n\nexport function TimeBrush({ start, end, onSelect, minSpan = 60_000, snap, formatTime, timeZone, disabled = false, className = \"\", children, ...props }: TimeBrushProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const bandRef = React.useRef<HTMLDivElement>(null);\n  const fillRef = React.useRef<HTMLSpanElement>(null);\n  const leftRef = React.useRef<HTMLSpanElement>(null);\n  const rightRef = React.useRef<HTMLSpanElement>(null);\n  const tagRef = React.useRef<HTMLSpanElement>(null);\n  const drag = React.useRef<{ x0: number; id: number; left: number; width: number; from: number; to: number } | null>(null);\n\n  const timeAt = (x: number, width: number) => start.getTime() + (Math.min(Math.max(x, 0), width) / width) * (end.getTime() - start.getTime());\n  const xAt = (t: number, width: number) => Math.min(Math.max(((t - start.getTime()) / (end.getTime() - start.getTime())) * width, 0), width);\n  const label = (t: number, edge: \"start\" | \"end\") => {\n    if (formatTime) return formatTime(new Date(t), edge);\n    const w = wallAt(t, timeZone);\n    return `${pad(w.hour)}:${pad(w.minute)}`;\n  };\n\n  const paint = (a: number, b: number, width: number) => {\n    const band = bandRef.current;\n    const tag = tagRef.current;\n    const d = drag.current;\n    if (!band || !tag || !d) return;\n    let left = Math.max(0, Math.min(a, b));\n    let right = Math.min(width, Math.max(a, b));\n    d.from = timeAt(left, width);\n    d.to = timeAt(right, width);\n    if (snap) {\n      // The band jumps from edge to edge the chart can show, so what it covers is what you get.\n      d.from = Math.max(start.getTime(), snap(d.from));\n      d.to = Math.min(end.getTime(), snap(d.to));\n      left = xAt(d.from, width);\n      right = xAt(d.to, width);\n    }\n    // Transforms only: a 1px fill scaled to the span, and two hairline edges moved into place.\n    fillRef.current!.style.transform = `translateX(${left}px) scaleX(${right - left})`;\n    leftRef.current!.style.transform = `translateX(${left}px)`;\n    rightRef.current!.style.transform = `translateX(${right - 1}px)`;\n    band.style.opacity = right - left > 2 ? \"1\" : \"0\";\n    band.style.transition = \"none\";\n    tag.textContent = `${label(d.from, \"start\")} – ${label(d.to, \"end\")} · ${durationShort(Math.max(60_000, d.to - d.from))}`;\n    // The tag stays on the chart even when the band is near an edge.\n    const tw = tag.offsetWidth;\n    tag.style.transform = `translateX(${Math.min(Math.max(left + (right - left) / 2 - tw / 2, 0), width - tw)}px)`;\n  };\n\n  const finish = (commit: boolean) => {\n    const d = drag.current;\n    const band = bandRef.current;\n    drag.current = null;\n    if (band) {\n      band.style.transition = \"opacity 360ms cubic-bezier(0.16,1,0.3,1)\";\n      band.style.opacity = \"0\";\n    }\n    if (d && commit && d.to - d.from >= minSpan) onSelect({ start: new Date(d.from), end: new Date(d.to) });\n  };\n\n  React.useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\" && drag.current) finish(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  });\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative select-none ${disabled ? \"\" : \"cursor-crosshair\"} ${className}`}\n      style={{ touchAction: \"pan-y\" }}\n      onPointerDown={(e) => {\n        if (disabled || e.button !== 0) return;\n        const r = e.currentTarget.getBoundingClientRect();\n        drag.current = { x0: e.clientX - r.left, id: e.pointerId, left: r.left, width: r.width, from: 0, to: 0 };\n      }}\n      onPointerMove={(e) => {\n        const d = drag.current;\n        if (!d || e.pointerId !== d.id) return;\n        const x = e.clientX - d.left;\n        if (Math.abs(x - d.x0) > 3 && !e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.setPointerCapture(e.pointerId);\n        paint(d.x0, x, d.width);\n      }}\n      onPointerUp={(e) => {\n        const d = drag.current;\n        if (!d || e.pointerId !== d.id) return;\n        finish(Math.abs(e.clientX - d.left - d.x0) > 6);\n      }}\n      onPointerCancel={() => finish(false)}\n      {...props}\n    >\n      {children}\n      <div ref={bandRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 opacity-0\">\n        <span ref={fillRef} className=\"absolute inset-y-0 left-0 w-px origin-left bg-primary/[0.09] will-change-transform\" />\n        <span ref={leftRef} className=\"absolute inset-y-0 left-0 w-px bg-primary/70 will-change-transform\" />\n        <span ref={rightRef} className=\"absolute inset-y-0 left-0 w-px bg-primary/70 will-change-transform\" />\n        <span ref={tagRef} className=\"absolute -top-6 left-0 whitespace-nowrap rounded-full bg-background px-2 py-0.5 font-mono text-[10.5px] tabular-nums text-foreground shadow-[inset_0_0_0_1px_var(--border)] will-change-transform\" />\n      </div>\n    </div>\n  );\n}\n"},{"path":"registry/time-range-parse.ts","type":"registry:component","content":"// Hairline UI · trend-chart · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n/* ─────────────────────────────────────────────────────────\n * TIME RANGE PARSE: what \"2h\" or \"yesterday 14:00–16:00\" means\n *\n *   ranges   last N (live), since a moment (live), or a fixed\n *            start and end\n *   typed    15m · 2 hours · last 7 days · today · yesterday ·\n *            this week · last month · since 9 · since monday ·\n *            14:00–16:00 · 9am to 5pm · yesterday 14:00-16:00 ·\n *            16 oct 14:00 – 17 oct 09:00 · ISO dates\n *   zones    wall-clock times are read and written in the zone\n *            you pass (the viewer's own by default)\n * ───────────────────────────────────────────────────────── */\n\nexport type TimeRange =\n  /** The last `duration` ms, ending now. */\n  | { type: \"relative\"; duration: number }\n  /** From `start` until now. */\n  | { type: \"since\"; start: Date }\n  | { type: \"absolute\"; start: Date; end: Date };\n\nexport const MINUTE = 60_000;\nexport const HOUR = 60 * MINUTE;\nexport const DAY = 24 * HOUR;\n\ntype Wall = { year: number; month: number; day: number; hour: number; minute: number };\n\n/* ── Zones ─────────────────────────────────────────────── */\n\nconst formatters = new Map<string, Intl.DateTimeFormat>();\nfunction fmt(timeZone?: string) {\n  const key = timeZone ?? \"\";\n  let f = formatters.get(key);\n  if (!f) {\n    f = new Intl.DateTimeFormat(\"en-US\", { timeZone, hourCycle: \"h23\", year: \"numeric\", month: \"numeric\", day: \"numeric\", hour: \"numeric\", minute: \"numeric\", second: \"numeric\", weekday: \"short\" });\n    formatters.set(key, f);\n  }\n  return f;\n}\nconst WEEKDAY = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } as Record<string, number>;\n\n/** The wall clock at an instant in a zone. */\nexport function wallAt(epoch: number, timeZone?: string) {\n  const p: Record<string, string> = {};\n  for (const part of fmt(timeZone).formatToParts(new Date(epoch))) p[part.type] = part.value;\n  return { year: +p.year, month: +p.month, day: +p.day, hour: +p.hour % 24, minute: +p.minute, second: +p.second, weekday: WEEKDAY[p.weekday] ?? 0 };\n}\n\n/** The instant a wall-clock time happens in a zone (the earlier one when clocks go back). */\nexport function fromWall(w: Wall, timeZone?: string) {\n  const guess = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);\n  const off = (t: number) => {\n    const a = wallAt(t, timeZone);\n    return Date.UTC(a.year, a.month - 1, a.day, a.hour, a.minute, a.second) - Math.floor(t / 1000) * 1000;\n  };\n  const a = guess - off(guess - 6 * HOUR);\n  const b = guess - off(guess + 6 * HOUR);\n  const fits = (t: number) => {\n    const x = wallAt(t, timeZone);\n    return x.day === w.day && x.hour === w.hour && x.minute === w.minute;\n  };\n  if (fits(a) && fits(b)) return Math.min(a, b);\n  if (fits(b)) return b;\n  // a fits, or the time falls in a gap where clocks jump: either way, the moment the clock passes it.\n  return a;\n}\n\nconst addDays = (w: Wall, n: number): Wall => {\n  const d = new Date(Date.UTC(w.year, w.month - 1, w.day + n));\n  return { ...w, year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };\n};\nconst midnight = (w: Wall): Wall => ({ ...w, hour: 0, minute: 0 });\n\n/* ── Resolving and stepping ────────────────────────────── */\n\nexport function resolveTimeRange(range: TimeRange, now = new Date()): { start: Date; end: Date } {\n  if (range.type === \"relative\") return { start: new Date(now.getTime() - range.duration), end: now };\n  if (range.type === \"since\") return { start: range.start, end: now };\n  return { start: range.start, end: range.end };\n}\n\nexport const isLive = (range: TimeRange) => range.type !== \"absolute\";\n\n/** The same length of time, one window earlier (-1) or later (1). A window that would reach now becomes live again. */\nexport function stepTimeRange(range: TimeRange, dir: -1 | 1, now = new Date()): TimeRange {\n  const { start, end } = resolveTimeRange(range, now);\n  const span = end.getTime() - start.getTime();\n  const nextEnd = end.getTime() + dir * span;\n  if (dir === 1 && nextEnd >= now.getTime() - 30_000) return { type: \"relative\", duration: span };\n  return { type: \"absolute\", start: new Date(start.getTime() + dir * span), end: new Date(nextEnd) };\n}\n\nexport const sameRange = (a: TimeRange, b: TimeRange) =>\n  a.type === b.type &&\n  (a.type === \"relative\"\n    ? a.duration === (b as typeof a).duration\n    : a.type === \"since\"\n      ? a.start.getTime() === (b as typeof a).start.getTime()\n      : a.start.getTime() === (b as typeof a).start.getTime() && a.end.getTime() === (b as typeof a).end.getTime());\n\n/* ── Words ─────────────────────────────────────────────── */\n\nconst plural = (n: number, w: string) => `${n} ${w}${n === 1 ? \"\" : \"s\"}`;\n\n/** \"15 minutes\", \"4 hours\", \"7 days\". */\nexport function durationWords(ms: number) {\n  if (ms < HOUR || ms % HOUR) return plural(Math.round(ms / MINUTE), \"minute\");\n  if (ms < 2 * DAY || ms % DAY) return plural(Math.round(ms / HOUR), \"hour\");\n  if (ms % (7 * DAY) === 0 && ms / DAY >= 14) return plural(ms / (7 * DAY), \"week\");\n  return plural(Math.round(ms / DAY), \"day\");\n}\n\n/** \"15m\", \"2h\", \"3d\", \"1h 30m\". */\nexport function durationShort(ms: number) {\n  const m = Math.round(ms / MINUTE);\n  if (m < 60) return `${m}m`;\n  if (m < 48 * 60) return m % 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m / 60}h`;\n  const d = Math.floor(m / 1440);\n  const h = Math.round((m % 1440) / 60);\n  return h ? `${d}d ${h}h` : `${d}d`;\n}\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\nconst DAYS = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\n\n/** What the picker's pill says. */\nexport function formatTimeRange(range: TimeRange, { now = new Date(), timeZone, hour12 = false, compact = false }: { now?: Date; timeZone?: string; hour12?: boolean; compact?: boolean } = {}) {\n  const time = (w: { hour: number; minute: number }) => (hour12 ? `${w.hour % 12 || 12}${w.minute ? `:${pad(w.minute)}` : \"\"}${w.hour < 12 ? \"am\" : \"pm\"}` : `${pad(w.hour)}:${pad(w.minute)}`);\n  const today = wallAt(now.getTime(), timeZone);\n  const yesterday = addDays(today, -1);\n  const dayName = (w: Wall & { weekday?: number }) => {\n    if (w.year === today.year && w.month === today.month && w.day === today.day) return \"Today\";\n    if (w.year === yesterday.year && w.month === yesterday.month && w.day === yesterday.day) return \"Yesterday\";\n    const date = `${w.day} ${MONTHS[w.month - 1]}${w.year !== today.year ? ` ${w.year}` : \"\"}`;\n    return compact ? date : `${DAYS[new Date(Date.UTC(w.year, w.month - 1, w.day)).getUTCDay()]} ${date}`;\n  };\n  // Narrow places drop the spaces round the dash and the comma after the day.\n  const dash = compact ? \"–\" : \" – \";\n  const comma = compact ? \" \" : \", \";\n  if (range.type === \"relative\") return `Last ${durationWords(range.duration)}`;\n  const s = wallAt(range.start.getTime(), timeZone);\n  if (range.type === \"since\") {\n    if (!s.hour && !s.minute) return dayName(s) === \"Today\" ? \"Today so far\" : `Since ${dayName(s)}`;\n    return dayName(s) === \"Today\" ? `Since ${time(s)}` : `Since ${dayName(s)}${comma}${time(s)}`;\n  }\n  const e = wallAt(range.end.getTime(), timeZone);\n  const sameDay = s.year === e.year && s.month === e.month && s.day === e.day;\n  const nextDay = addDays(s, 1);\n  // Whole days read as days: \"Yesterday\", \"Mon 12 Oct – Wed 14 Oct\".\n  if (!s.hour && !s.minute && !e.hour && !e.minute) {\n    if (nextDay.year === e.year && nextDay.month === e.month && nextDay.day === e.day) return dayName(s);\n    return `${dayName(s)}${dash}${dayName(addDays(e, -1))}`;\n  }\n  if (sameDay) return `${dayName(s)}${comma}${time(s)}${dash}${time(e)}`;\n  return `${dayName(s)} ${time(s)}${dash}${dayName(e)} ${time(e)}`;\n}\n\n/* ── Typed ranges ──────────────────────────────────────── */\n\nconst UNIT: Record<string, number> = {\n  s: 1000, sec: 1000, secs: 1000, second: 1000, seconds: 1000,\n  m: MINUTE, min: MINUTE, mins: MINUTE, minute: MINUTE, minutes: MINUTE,\n  h: HOUR, hr: HOUR, hrs: HOUR, hour: HOUR, hours: HOUR,\n  d: DAY, day: DAY, days: DAY,\n  w: 7 * DAY, wk: 7 * DAY, week: 7 * DAY, weeks: 7 * DAY,\n};\nconst DAY_WORDS: Record<string, number> = { sun: 0, sunday: 0, mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2, wed: 3, wednesday: 3, thu: 4, thur: 4, thurs: 4, thursday: 4, fri: 5, friday: 5, sat: 6, saturday: 6 };\nconst MONTH_WORDS: Record<string, number> = Object.fromEntries(\n  [\"january\", \"february\", \"march\", \"april\", \"may\", \"june\", \"july\", \"august\", \"september\", \"october\", \"november\", \"december\"].flatMap((m, i) => [\n    [m, i + 1],\n    [m.slice(0, 3), i + 1],\n  ]).concat([[\"sept\", 9]]),\n);\n\nfunction readTime(t: string): { hour: number; minute: number } | null {\n  if (t === \"noon\" || t === \"midday\") return { hour: 12, minute: 0 };\n  if (t === \"midnight\") return { hour: 0, minute: 0 };\n  const m = t.match(/^(\\d{1,2})(?:[:.h](\\d{2}))?\\s?(am|pm)?$/);\n  if (!m) return null;\n  let hour = +m[1];\n  const minute = m[2] ? +m[2] : 0;\n  if (m[3]) {\n    if (hour < 1 || hour > 12) return null;\n    hour = (hour % 12) + (m[3] === \"pm\" ? 12 : 0);\n  } else if (!m[2] && hour > 24) return null;\n  if (hour > 23 || minute > 59) return null;\n  return { hour, minute };\n}\n\n/** A date word or numbers, relative to today. */\nfunction readDate(t: string, today: Wall & { weekday: number }): Wall | null {\n  if (!t || t === \"today\") return midnight(today);\n  if (t === \"yesterday\") return midnight(addDays(today, -1));\n  if (t in DAY_WORDS) {\n    const back = (today.weekday - DAY_WORDS[t] + 7) % 7;\n    return midnight(addDays(today, -back));\n  }\n  let m = t.match(/^(\\d{4})-(\\d{2})-(\\d{2})$/);\n  if (m) return { year: +m[1], month: +m[2], day: +m[3], hour: 0, minute: 0 };\n  m = t.match(/^(\\d{1,2})(?:st|nd|rd|th)? (?:of )?([a-z]+)(?: (\\d{4}))?$/) ?? null;\n  if (m && MONTH_WORDS[m[2]]) return { year: m[3] ? +m[3] : today.year, month: MONTH_WORDS[m[2]], day: +m[1], hour: 0, minute: 0 };\n  m = t.match(/^([a-z]+) (\\d{1,2})(?:st|nd|rd|th)?(?:,? (\\d{4}))?$/);\n  if (m && MONTH_WORDS[m[1]]) return { year: m[3] ? +m[3] : today.year, month: MONTH_WORDS[m[1]], day: +m[2], hour: 0, minute: 0 };\n  return null;\n}\n\nconst TIME_TAIL = /\\s*(noon|midday|midnight|\\d{1,2}(?:[:.h]\\d{2})?\\s?(?:am|pm)?)$/;\nconst ISO_MOMENT = /^\\d{4}-\\d{2}-\\d{2}[t ]\\d{2}:\\d{2}/;\n\n/** \"yesterday 14:00\", \"16 oct\", \"9am\", \"2026-10-16 14:00\" as a wall time. */\nfunction readMoment(text: string, today: Wall & { weekday: number }, fallbackDate?: Wall): { wall: Wall; dated: boolean; timed: boolean } | null {\n  const t = text.trim();\n  if (ISO_MOMENT.test(t)) {\n    const [d, tm] = t.split(/[t ]/);\n    const date = readDate(d, today);\n    const time = readTime(tm.slice(0, 5));\n    return date && time ? { wall: { ...date, ...time }, dated: true, timed: true } : null;\n  }\n  const tail = t.match(TIME_TAIL);\n  let datePart = t;\n  let time: { hour: number; minute: number } | null = null;\n  if (tail) {\n    const candidate = readTime(tail[1].replace(/\\s/g, \"\"));\n    // \"16 oct\" ends in digits that aren't a time; only take the tail when what's left is a date (or nothing).\n    const rest = t.slice(0, tail.index).trim().replace(/,$/, \"\");\n    if (candidate && (rest === \"\" || readDate(rest, today))) {\n      time = candidate;\n      datePart = rest;\n    }\n  }\n  const date = datePart ? readDate(datePart, today) : (fallbackDate ?? midnight(today));\n  if (!date) return null;\n  return { wall: { ...date, ...(time ?? { hour: 0, minute: 0 }) }, dated: Boolean(datePart), timed: Boolean(time) };\n}\n\n/** Reads a typed range, or null. */\nexport function parseTimeRange(input: string, { now = new Date(), timeZone }: { now?: Date; timeZone?: string } = {}): TimeRange | null {\n  const t = input.toLowerCase().trim().replace(/\\s+/g, \" \").replace(/[–—]/g, \"-\");\n  if (!t) return null;\n  const today = wallAt(now.getTime(), timeZone);\n  const at = (w: Wall) => new Date(fromWall(w, timeZone));\n\n  // Durations: 15m, 2 hours, last 7 days.\n  const dur = t.match(/^(?:last |past )?(\\d+(?:\\.\\d+)?) ?([a-z]+)$/);\n  if (dur && UNIT[dur[2]]) return { type: \"relative\", duration: Math.round(+dur[1] * UNIT[dur[2]]) };\n  const bare = t.match(/^(?:last (minute|hour|day)|past (minute|hour|day|week))$/);\n  if (bare) return { type: \"relative\", duration: UNIT[bare[1] ?? bare[2]] };\n\n  if (t === \"today\") return { type: \"since\", start: at(midnight(today)) };\n  if (t === \"yesterday\") return { type: \"absolute\", start: at(midnight(addDays(today, -1))), end: at(midnight(today)) };\n  if (t === \"this week\") return { type: \"since\", start: at(midnight(addDays(today, -((today.weekday + 6) % 7)))) };\n  if (t === \"last week\") {\n    const monday = midnight(addDays(today, -((today.weekday + 6) % 7)));\n    return { type: \"absolute\", start: at(addDays(monday, -7)), end: at(monday) };\n  }\n  if (t === \"this month\") return { type: \"since\", start: at({ ...midnight(today), day: 1 }) };\n  if (t === \"last month\") {\n    const first = { ...midnight(today), day: 1 };\n    const prev = new Date(Date.UTC(first.year, first.month - 2, 1));\n    return { type: \"absolute\", start: at({ ...first, year: prev.getUTCFullYear(), month: prev.getUTCMonth() + 1 }), end: at(first) };\n  }\n\n  // Since a moment: since 9, since monday, since 16 oct 14:00.\n  const since = t.match(/^(?:since|from) (.+)$/);\n  if (since && !/ (?:to|until|-) /.test(since[1]) && !/\\d-\\d/.test(since[1])) {\n    const m = readMoment(since[1], today);\n    if (!m) return null;\n    const start = at(m.wall);\n    return start.getTime() < now.getTime() ? { type: \"since\", start } : null;\n  }\n\n  // A span: \"a - b\", \"a to b\", or two times joined by a bare hyphen (\"yesterday 14:00-16:00\").\n  const body = t.replace(/^(?:from|between) /, \"\");\n  let span = body.split(/ (?:-|to|until|and) /);\n  if (span.length !== 2) {\n    const tight = body.match(/^(.*?\\d{1,2}(?:[:.]\\d{2})?\\s?(?:am|pm)?)-(\\d{1,2}(?:[:.]\\d{2})?\\s?(?:am|pm)?)$/);\n    span = tight ? [tight[1], tight[2]] : [];\n  }\n  if (span.length === 2) {\n    // \"yesterday 14:00-16:00\": the date on the left carries over to a time-only right side.\n    const a = readMoment(span[0], today);\n    if (!a) return null;\n    const b = readMoment(span[1], today, midnight(a.wall));\n    if (!b) return null;\n    let start = a.wall;\n    let end = b.wall;\n    if (!b.timed && b.dated) end = addDays(end, 1); // \"16 oct - 17 oct\" includes the 17th\n    if (fromWall(end, timeZone) <= fromWall(start, timeZone)) end = addDays(end, 1); // 22:00-02:00 runs past midnight\n    // Times without a date that haven't happened yet today mean yesterday's.\n    if (!a.dated && fromWall(start, timeZone) > now.getTime()) {\n      start = addDays(start, -1);\n      end = addDays(end, -1);\n    }\n    return { type: \"absolute\", start: at(start), end: at(end) };\n  }\n\n  // A single day: monday, 16 oct.\n  const day = readMoment(t, today);\n  if (day && day.dated && !day.timed) return { type: \"absolute\", start: at(day.wall), end: at(addDays(day.wall, 1)) };\n  if (day && day.timed && !day.dated) return { type: \"since\", start: at(day.wall) };\n  return null;\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/trend-chart. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}