Components

Getting started

Components109

More

Time range pickerNew

"Last 15 minutes" for logs, metrics and traces: type 2h or yesterday 14:00–16:00, step by the window, pause live, or drag across a chart to zoom.

Used forLog viewersMetrics dashboardsIncidents and tracesAnalytics and audit logs

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/time-range-picker.json

Where it’s used

  • Log viewers

    Show the last 15 minutes of logs and keep following new ones as they arrive.

  • Metrics dashboards

    Switch every chart between the last hour, today and last week in one place.

  • Incidents and traces

    Zoom into the exact minutes something went wrong by dragging across a chart.

  • Analytics and audit logs

    Filter events with typed ranges like “yesterday 14:00–16:00”.

What it handles for you

A calendar date-range picker answers “which days”. Monitoring screens ask “which stretch of time, ending when”, and their ranges have to keep moving. Grafana and Datadog each built their own; this is yours.

Instead of wiring upa calendar date pickerrelative-time parsinga live and paused togglechart brushingtime-zone maths
  1. Ranges that stay live

    “Last 15 minutes” keeps following the clock; Pause freezes it where it is.

  2. Steps by the window

    ‹ and › move back and forward by the range’s own length, and go live again on reaching now.

  3. Type any range

    2h, yesterday, since 9, 14:00–16:00 or 22:00–02:00; the line under the field says what it means before you press Enter.

  4. Drag to zoom

    Time brush turns a drag across any chart into the range under it.

  5. Any time zone

    Times are read and shown in the zone you pass, across clock changes.

  6. Fits a toolbar

    One compact plate that tightens itself on phones.

Overview

A date picker answers "which days", but a logs or metrics screen asks "which stretch of time, ending when": the last fifteen minutes, still moving; the hour before that; yesterday between two and four. This is one hairline plate: ‹ steps back by the window's own length (so "Last 1 hour" becomes the hour before it, fixed), › steps forward and turns live again once it reaches now, and Live breathes while the range follows the clock, Paused when it doesn't. Open it and the field is already focused: type 45m, yesterday, since 9 or 14:00–16:00 and the line under it says exactly what that means before you press Enter. Quick ranges have a highlight that glides with the pointer and a check that draws itself on the current one; ranges you typed come back as Recent. The range's words morph as it changes. Time brush comes with it: wrap any chart and a drag across it becomes the range under the band.

States

live
A relative range ("Last 1 hour") or one that runs until now ("Today so far") is live: an emerald dot breathes beside Live (2.4s). Pressing it freezes the range where it is; the dot shrinks into a ring and the label morphs to Paused. › is disabled while live.
step
‹ and › move the window by its own length. The label morphs letter by letter ("Last 1 hour" → "Today, 13:20 – 14:20"). Stepping forward into now makes the range live again.
open
The panel unfolds from the plate's edge (a clip from the top, 380ms), 6px clear of it, and lines up with its right edge by default. It lives on the page's body, so no card clips it, and it follows the plate when the page scrolls.
typing
Each keystroke is read; the line under the field crossfades to what it means ("Yesterday, 14:00 – 16:00 · 2h") and a ↵ appears. Enter applies it. Text it can't read shakes the field and the line turns red with examples.
quick
A highlight glides between quick ranges with the pointer or the arrows (220ms); the current range carries a drawn check. Ranges you typed are kept as Recent chips.
brush
Time brush: pressing on a chart and dragging draws a band (a scaled fill and two hairline edges, transforms only) with its start, end and length on a tag above it. Release selects that span; the band fades as the chart redraws. Escape cancels.

Usage

usage.tsx
import { TimeRangePicker, resolveTimeRange, type TimeRange } from "@/components/time-range-picker";const [range, setRange] = useState<TimeRange>({ type: "relative", duration: 15 * 60_000 });<TimeRangePicker value={range} onValueChange={setRange} />

Works with

Querying logs for the range

Keep the range itself in state, not its start and end, so "Last 15 minutes" stays live. Resolve it whenever you fetch, and refetch on a timer while it's live. Wrap the chart in Time brush to zoom.

example.tsx
"use client";import { useEffect, useState } from "react";import { TimeBrush, TimeRangePicker, resolveTimeRange, type TimeRange } from "@/components/time-range-picker";export function Logs({ service }: { service: string }) {  const [range, setRange] = useState<TimeRange>({ type: "relative", duration: 60 * 60_000 });  const [data, setData] = useState<LogBucket[]>([]);  const live = range.type !== "absolute";  useEffect(() => {    const load = async () => {      const { start, end } = resolveTimeRange(range);      const res = await fetch(`/api/logs?service=${service}&from=${start.toISOString()}&to=${end.toISOString()}`);      setData(await res.json());    };    load();    if (!live) return;    const timer = setInterval(load, 10_000);    return () => clearInterval(timer);  }, [range, live, service]);  const { start, end } = resolveTimeRange(range);  return (    <section>      <TimeRangePicker label="Time range for logs" value={range} onValueChange={setRange} />      <TimeBrush start={start} end={end} onSelect={(r) => setRange({ type: "absolute", ...r })}>        <VolumeChart buckets={data} />      </TimeBrush>    </section>  );}

Props

PropTypeDescription
value / defaultValueTimeRangedefault { type: "relative", duration: 3600000 }{ type: "relative", duration } (the last N ms, live), { type: "since", start } (until now, live) or { type: "absolute", start, end }.
onValueChange(range, { start, end }) => voidThe new range and the times it covers at that moment. Store the range: a relative one keeps moving.
presets{ label, range }[]default TIME_RANGE_PRESETSQuick ranges. range may be a function of now (Today so far, Yesterday).
timeZonestringRead and show wall-clock times in this IANA zone; the viewer's own by default.
hour12booleandefault falseWrite times as 2pm.
align"start" | "end"default "end"Which edge of the plate the panel lines up with.
open / defaultOpen / onOpenChangeboolean / boolean / (open) => voidControl the panel.
labelstringdefault "Time range"Accessible name of the plate.
TimeBrush{ start, end, onSelect, minSpan?, timeZone? }Wrap a chart whose x axis runs from start to end; onSelect gets the span dragged across. Spans under minSpan (60s) are ignored.

Accessibility and motion

  • The plate is a labelled group of four buttons. The range button opens a dialog whose field is described by the live reading under it; ↓ moves into the quick ranges (a listbox driven by aria-activedescendant), ←/→ move across, Enter applies, Escape closes and returns focus. New ranges are announced in a status region.
  • Typed ranges it reads: 15m, 2 hours, last 7 days, past week, today, yesterday, this week, last week, this month, last month, since 9, since monday, 14:00–16:00, 9am to 5pm, 22:00–02:00 (past midnight), yesterday 14:00-16:00, 16 oct 09:00 – 16 oct 11:30, oct 12 – oct 14, and ISO dates. Times without a date that haven't happened yet today mean yesterday's.
  • parseTimeRange, formatTimeRange, resolveTimeRange and stepTimeRange live in time-range-parse.ts with no React, so a server can read the same ranges from a URL.
  • Time brush moves only transforms while you drag and renders nothing; touch keeps vertical page scroll (touch-action: pan-y).
  • Reduced motion: no unfolding, gliding, breathing or morphing; the words change in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Text morph.

Source

components/time-range-picker.tsx
"use client";import * as React from "react";import { createPortal } from "react-dom";import { TextMorph } from "./text-morph";import { TimeBrush, type TimeBrushProps } from "./time-brush";import {  DAY,  HOUR,  MINUTE,  durationShort,  formatTimeRange,  isLive,  parseTimeRange,  resolveTimeRange,  sameRange,  stepTimeRange,  type TimeRange,} from "./time-range-parse";/* ───────────────────────────────────────────────────────── * TIME RANGE PICKER: "Last 15 minutes ▾" for logs and metrics * *   plate    one hairline plate: ‹ earlier, the range, › later, *            and Live. The range's words morph as it changes *   step     ‹ and › move the window by its own length; stepping *            back from "Last 1 hour" fixes it to the hour before, *            and stepping forward into now makes it live again *   live     the dot breathes while the range follows now; *            Paused freezes it where it is *   open     the panel unfolds from the plate's edge with the *            field focused: type 45m, yesterday, since 9 or *            14:00–16:00 and the line under it says what it *            means before you press Enter *   quick    a highlight glides over the quick ranges; the *            current one carries a drawn check; ranges you typed *            come back as Recent * * Pair it with Time brush, which turns a drag across any chart * into a range. * ───────────────────────────────────────────────────────── */export type { TimeRange };export { TimeBrush, type TimeBrushProps };export { formatTimeRange, parseTimeRange, resolveTimeRange, stepTimeRange };export interface TimeRangePreset {  label: string;  /** A range, or one worked out from now (Today, Yesterday). */  range: TimeRange | ((now: Date, timeZone?: string) => TimeRange);}export interface TimeRangePickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {  value?: TimeRange;  defaultValue?: TimeRange;  /** The range, and the start and end it covers right now. */  onValueChange?: (range: TimeRange, resolved: { start: Date; end: Date }) => void;  presets?: TimeRangePreset[];  /** Read and show times in this IANA zone; the viewer's own by default. */  timeZone?: string;  hour12?: boolean;  /** Which edge of the plate the panel lines up with. */  align?: "start" | "end";  open?: boolean;  defaultOpen?: boolean;  onOpenChange?: (open: boolean) => void;  /** Accessible name, e.g. "Time range for logs". */  label?: string;  disabled?: boolean;}const rel = (duration: number): TimeRange => ({ type: "relative", duration });export const TIME_RANGE_PRESETS: TimeRangePreset[] = [  { label: "Last 5 minutes", range: rel(5 * MINUTE) },  { label: "Last 15 minutes", range: rel(15 * MINUTE) },  { label: "Last 1 hour", range: rel(HOUR) },  { label: "Last 4 hours", range: rel(4 * HOUR) },  { label: "Last 24 hours", range: rel(DAY) },  { label: "Last 7 days", range: rel(7 * DAY) },  { label: "Today so far", range: (now, tz) => parseTimeRange("today", { now, timeZone: tz })! },  { label: "Yesterday", range: (now, tz) => parseTimeRange("yesterday", { now, timeZone: tz })! },];const EASE = "cubic-bezier(0.16,1,0.3,1)";const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50";const TOKENS = ["--background", "--foreground", "--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground", "--primary", "--primary-foreground", "--muted", "--muted-foreground", "--ring"];const HINT = "Try 45m, yesterday, since 9 or 14:00–16:00";const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);// Phones get the tighter label and a plate without the clock or the word Live.const narrowQuery = "(max-width: 480px)";const subscribeNarrow = (onChange: () => void) => {  const query = window.matchMedia(narrowQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useNarrow = () => React.useSyncExternalStore(subscribeNarrow, () => window.matchMedia(narrowQuery).matches, () => false);function zoneLabel(timeZone?: string) {  try {    const zone = timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;    const off = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "shortOffset" }).formatToParts(new Date()).find((p) => p.type === "timeZoneName")?.value.replace("GMT", "UTC");    return `${zone === "UTC" ? "UTC" : (zone.split("/").pop() ?? zone).replace(/_/g, " ")}${off && zone !== "UTC" ? ` · ${off}` : ""}`;  } catch {    return "";  }}function Arrow({ dir }: { dir: -1 | 1 }) {  return (    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5">      <path d={dir < 0 ? "M9.75 4.5 6.25 8l3.5 3.5" : "M6.25 4.5 9.75 8l-3.5 3.5"} stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />    </svg>  );}/** A sentence that changes crossfades whole. */function Line({ text, reduced, className = "" }: { text: string; reduced: boolean; className?: string }) {  const [shown, setShown] = React.useState({ text, key: 0 });  const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]);  if (text !== shown.text) {    if (shown.text && !reduced) setLeaving((l) => [...l.slice(-2), shown]);    setShown({ text, key: shown.key + 1 });  }  return (    <span className={`relative block ${className}`}>      <span        key={shown.key}        className="block truncate"        ref={(el) => {          if (!el || reduced || shown.key === 0 || el.dataset.in) return;          el.dataset.in = "1";          el.animate([{ opacity: 0, transform: "translateY(0.3em)", filter: "blur(4px)" }, { opacity: 1, transform: "none", filter: "blur(0px)" }], { duration: 340, delay: 40, easing: EASE, fill: "backwards" });        }}      >        {shown.text || " "}      </span>      {leaving.map((l) => (        <span          key={l.key}          aria-hidden="true"          className="pointer-events-none absolute inset-x-0 top-0 block truncate"          ref={(el) => {            if (!el || el.dataset.out) return;            el.dataset.out = "1";            el.animate([{ opacity: 1, filter: "blur(0px)" }, { opacity: 0, transform: "translateY(-0.3em)", filter: "blur(4px)" }], { duration: 160, easing: EASE, fill: "forwards" }).onfinish = () =>              setLeaving((all) => all.filter((x) => x.key !== l.key));          }}        >          {l.text}        </span>      ))}    </span>  );}type Phase = "closed" | "opening" | "open" | "closing";export function TimeRangePicker({  value,  defaultValue = rel(HOUR),  onValueChange,  presets = TIME_RANGE_PRESETS,  timeZone,  hour12 = false,  align = "end",  open: openProp,  defaultOpen = false,  onOpenChange,  label = "Time range",  disabled = false,  className = "",  ...props}: TimeRangePickerProps) {  const uid = React.useId();  const reduced = useReducedMotion();  const narrow = useNarrow();  const [own, setOwn] = React.useState(defaultValue);  const range = value ?? own;  const [now, setNow] = React.useState(() => new Date());  const [phase, setPhase] = React.useState<Phase>(() => ((openProp ?? defaultOpen) ? "opening" : "closed"));  const [fit, setFit] = React.useState({ x: 0, y: 0, w: 320, up: false, theme: {} as Record<string, string> });  const [typed, setTyped] = React.useState("");  const [active, setActive] = React.useState(-1);  const [mark, setMark] = React.useState({ x: 0, y: 0, w: 0, h: 0, on: false, slide: false });  const [recent, setRecent] = React.useState<TimeRange[]>([]);  const [refused, setRefused] = React.useState(false);  const [announce, setAnnounce] = React.useState("");  const anchorRef = React.useRef<HTMLDivElement>(null);  const triggerRef = React.useRef<HTMLButtonElement>(null);  const surfaceRef = React.useRef<HTMLDivElement>(null);  const inputRef = React.useRef<HTMLInputElement>(null);  const fieldRef = React.useRef<HTMLDivElement>(null);  const presetRefs = React.useRef<(HTMLButtonElement | null)[]>([]);  const timer = React.useRef(0);  const mounted = phase !== "closed";  const expanded = phase === "open";  const live = isLive(range);  // "Today" and "Last 1 hour" are about now: look at the clock once a minute.  React.useEffect(() => {    let t = 0;    const tick = () => {      setNow(new Date());      t = window.setTimeout(tick, 60_000 - (Date.now() % 60_000) + 50);    };    t = window.setTimeout(tick, 60_000 - (Date.now() % 60_000) + 50);    return () => window.clearTimeout(t);  }, []);  const words = formatTimeRange(range, { now, timeZone, hour12, compact: narrow });  const resolved = resolveTimeRange(range, now);  const span = resolved.end.getTime() - resolved.start.getTime();  const presetRanges = React.useMemo(() => presets.map((p) => (typeof p.range === "function" ? p.range(now, timeZone) : p.range)), [presets, now, timeZone]);  const currentPreset = presetRanges.findIndex((r) => sameRange(r, range));  const parsed = typed.trim() ? parseTimeRange(typed, { now, timeZone }) : null;  const commit = (next: TimeRange, how?: "typed") => {    const at = new Date();    if (value === undefined) setOwn(next);    onValueChange?.(next, resolveTimeRange(next, at));    setAnnounce(formatTimeRange(next, { now: at, timeZone, hour12 }));    if (how === "typed" && !presetRanges.some((r) => sameRange(r, next))) setRecent((list) => [next, ...list.filter((r) => !sameRange(r, next))].slice(0, 3));  };  /* ── Panel ── */  const place = React.useCallback(() => {    const anchor = anchorRef.current;    if (!anchor) return;    const r = anchor.getBoundingClientRect();    const vw = document.documentElement.clientWidth;    const vh = window.innerHeight;    const w = Math.min(320, vw - 16);    const x = Math.min(Math.max(8, align === "end" ? r.right - w : r.left), vw - 8 - w);    const up = vh - r.bottom < 380 && r.top > vh - r.bottom;    const cs = getComputedStyle(anchor);    const theme: Record<string, string> = { colorScheme: cs.colorScheme, fontFamily: cs.fontFamily };    for (const t of TOKENS) {      const v = cs.getPropertyValue(t);      if (v) theme[t] = v;    }    setFit({ x, y: up ? vh - r.top + 6 : r.bottom + 6, w, up, theme });  }, [align]);  const begin = () => {    window.clearTimeout(timer.current);    place();    setTyped("");    setRefused(false);    setActive(-1);    setMark((m) => ({ ...m, on: false, slide: false }));    setPhase(reduced ? "open" : "opening");  };  const end = React.useCallback(    (refocus = true) => {      window.clearTimeout(timer.current);      if (refocus) triggerRef.current?.focus({ preventScroll: true });      if (reduced) return setPhase("closed");      setPhase((p) => (p === "closed" ? p : "closing"));      timer.current = window.setTimeout(() => setPhase("closed"), 240);    },    [reduced],  );  const request = (next: boolean, refocus = true) => {    if (openProp === undefined) (next ? begin : () => end(refocus))();    onOpenChange?.(next);  };  React.useEffect(() => {    if (openProp === undefined) return;    if (openProp && !mounted) begin();    if (!openProp && mounted) end(false);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [openProp]);  React.useLayoutEffect(() => {    if (phase !== "opening") return;    place();    inputRef.current?.focus({ preventScroll: true });    let inner = 0;    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase("open"))));    return () => {      cancelAnimationFrame(outer);      cancelAnimationFrame(inner);    };  }, [phase, place]);  React.useEffect(() => () => window.clearTimeout(timer.current), []);  React.useEffect(() => {    if (!mounted) return;    const inside = (n: Node) => Boolean(anchorRef.current?.contains(n) || surfaceRef.current?.contains(n));    const onDown = (e: PointerEvent) => {      if (!inside(e.target as Node)) request(false, false);    };    let frame = 0;    const follow = (e: Event) => {      if (e.target instanceof Node && surfaceRef.current?.contains(e.target)) return;      cancelAnimationFrame(frame);      frame = requestAnimationFrame(place);    };    document.addEventListener("pointerdown", onDown);    window.addEventListener("scroll", follow, true);    window.addEventListener("resize", follow);    return () => {      cancelAnimationFrame(frame);      document.removeEventListener("pointerdown", onDown);      window.removeEventListener("scroll", follow, true);      window.removeEventListener("resize", follow);    };  });  // The highlight glides to the quick range under the pointer or the arrows.  React.useLayoutEffect(() => {    if (!mounted) return;    const el = presetRefs.current[active];    setMark((m) => (el ? (m.x === el.offsetLeft && m.y === el.offsetTop && m.on ? m : { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, on: true, slide: m.on }) : m.on ? { ...m, on: false, slide: false } : m));  }, [active, mounted]);  const choose = (i: number) => {    commit(presetRanges[i]);    request(false);  };  const applyTyped = () => {    if (!parsed) {      setRefused(true);      if (!reduced)        fieldRef.current?.animate([{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }], { duration: 380, easing: "ease-out" });      return;    }    commit(parsed, "typed");    request(false);  };  const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {    const n = presets.length;    const go = (i: number) => {      e.preventDefault();      setActive(i);    };    if (e.key === "ArrowDown") go(active < 0 ? 0 : Math.min(n - 1, active + 2));    else if (e.key === "ArrowUp") go(active - 2 < 0 ? -1 : active - 2);    else if (e.key === "ArrowRight" && active >= 0) go(Math.min(n - 1, active + 1));    else if (e.key === "ArrowLeft" && active >= 0) go(Math.max(0, active - 1));    else if (e.key === "Enter") {      e.preventDefault();      if (active >= 0 && !typed.trim()) choose(active);      else applyTyped();    } else if (e.key === "Escape") {      e.preventDefault();      e.stopPropagation();      request(false);    } else if (e.key === "Tab") {      e.preventDefault();      request(false);    }  };  const preview = !typed.trim()    ? HINT    : parsed      ? (() => {          const r = resolveTimeRange(parsed, now);          return `${formatTimeRange(parsed, { now, timeZone, hour12 })} · ${durationShort(r.end.getTime() - r.start.getTime())}${isLive(parsed) ? " · live" : ""}`;        })()      : refused        ? `Couldn’t read that. ${HINT}.`        : HINT;  const segment = `flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent ${FOCUS}`;  return (    <div ref={anchorRef} className={`relative inline-flex max-w-full ${className}`} {...props}>      <div role="group" aria-label={label} className={`inline-flex h-8 max-w-full items-stretch overflow-hidden rounded-full border border-border bg-background text-[12.5px] ${disabled ? "opacity-60" : ""}`}>        <button          type="button"          aria-label={`Earlier: the ${durationShort(span)} before`}          title="Earlier"          disabled={disabled}          onClick={() => commit(stepTimeRange(range, -1, new Date()))}          className={`w-8 cursor-pointer max-[480px]:w-7 ${segment}`}        >          <Arrow dir={-1} />        </button>        <span aria-hidden="true" className="my-2 w-px shrink-0 bg-border" />        <button          ref={triggerRef}          type="button"          disabled={disabled}          aria-haspopup="dialog"          aria-expanded={expanded}          aria-controls={mounted ? `${uid}-panel` : undefined}          aria-label={`${label}: ${words}`}          onClick={() => request(!(phase === "open" || phase === "opening"))}          onKeyDown={(e) => {            if (e.key === "ArrowDown") {              e.preventDefault();              request(true);            }          }}          className={`flex min-w-0 cursor-pointer items-center gap-1.5 px-2.5 font-medium transition-colors hover:bg-accent disabled:cursor-default ${expanded ? "bg-accent" : ""} ${FOCUS}`}        >          <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground max-[480px]:hidden">            <circle cx="8" cy="8" r="5.75" stroke="currentColor" strokeWidth="1.4" />            <path d="M8 5.25V8l1.75 1.25" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />          </svg>          <span className="min-w-0 overflow-hidden whitespace-nowrap">            <TextMorph>{words}</TextMorph>          </span>          <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3 shrink-0 text-muted-foreground" style={{ transform: expanded ? "rotate(180deg)" : "none", transition: reduced ? "none" : `transform 320ms ${EASE}` }}>            <path d="M4.5 6.25 8 9.75l3.5-3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />          </svg>        </button>        <span aria-hidden="true" className="my-2 w-px shrink-0 bg-border" />        <button          type="button"          aria-label={live ? "Later: already showing the latest" : `Later: the ${durationShort(span)} after`}          title={live ? "Already showing the latest" : "Later"}          disabled={disabled || live}          onClick={() => commit(stepTimeRange(range, 1, new Date()))}          className={`w-8 cursor-pointer max-[480px]:w-7 ${segment}`}        >          <Arrow dir={1} />        </button>        <span aria-hidden="true" className="my-2 w-px shrink-0 bg-border" />        <button          type="button"          aria-pressed={live}          aria-label={live ? "Live: pause" : "Paused: go live"}          disabled={disabled}          onClick={() => {            const at = new Date();            const r = resolveTimeRange(range, at);            commit(live ? { type: "absolute", start: r.start, end: r.end } : rel(r.end.getTime() - r.start.getTime()));          }}          className={`cursor-pointer gap-1.5 pl-2.5 pr-3 font-medium max-[480px]:px-2.5 ${segment} ${live ? "text-foreground" : ""}`}        >          <span aria-hidden="true" className="relative flex size-2 items-center justify-center">            <span              className={`absolute inset-0 rounded-full bg-emerald-500 ${live && !reduced ? "animate-[ui-breathe_2.4s_ease-in-out_infinite]" : ""}`}              style={{ opacity: live ? 1 : 0, transform: live ? undefined : "scale(0.4)", transition: reduced ? "none" : `opacity 260ms ${EASE}, transform 380ms ${EASE}` }}            />            <span className="absolute inset-0 rounded-full shadow-[inset_0_0_0_1.5px_currentColor]" style={{ opacity: live ? 0 : 0.8, transition: reduced ? "none" : `opacity 260ms ${EASE}` }} />          </span>          <span className="max-[480px]:hidden">            <TextMorph>{live ? "Live" : "Paused"}</TextMorph>          </span>        </button>      </div>      {mounted &&        createPortal(          <div            ref={surfaceRef}            id={`${uid}-panel`}            role="dialog"            aria-label={label}            onMouseDown={(e) => {              if (!(e.target as HTMLElement).matches("input")) e.preventDefault();            }}            className="fixed z-[60] overflow-hidden rounded-[16px] border border-border bg-popover p-1.5 text-popover-foreground"            style={{              ...fit.theme,              left: fit.x,              [fit.up ? "bottom" : "top"]: fit.y,              width: fit.w,              clipPath: expanded ? "inset(0px 0px 0px 0px round 16px)" : fit.up ? "inset(100% 0px 0px 0px round 16px)" : "inset(0px 0px 100% 0px round 16px)",              opacity: expanded ? 1 : 0,              transition: reduced ? "none" : phase === "closing" ? `clip-path 220ms ${EASE}, opacity 180ms ${EASE} 40ms` : `clip-path 380ms ${EASE}, opacity 200ms ${EASE}`,            }}          >            <div              ref={fieldRef}              className="flex h-9 items-center gap-2 rounded-[11px] px-2.5 transition-shadow duration-200"              style={{ boxShadow: `inset 0 0 0 1px ${refused && !parsed ? "color-mix(in oklab, #ef4444 45%, transparent)" : "color-mix(in oklab, var(--ring) 45%, var(--border))"}` }}            >              <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground">                <path d="M3 4.5h10M3 8h6M3 11.5h8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />              </svg>              <input                ref={inputRef}                value={typed}                onChange={(e) => {                  setTyped(e.target.value);                  setRefused(false);                  if (e.target.value) setActive(-1);                }}                onKeyDown={onInputKey}                placeholder="Type a range"                aria-label="Type a range"                aria-describedby={`${uid}-preview`}                aria-activedescendant={active >= 0 ? `${uid}-p${active}` : undefined}                autoComplete="off"                spellCheck={false}                className="h-full min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]"              />              {parsed && <span className="shrink-0 rounded-full px-1.5 font-mono text-[10.5px] text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]">↵</span>}            </div>            <p id={`${uid}-preview`} className={`px-1 pb-1 pt-1.5 text-[11.5px] transition-colors duration-200 ${refused && !parsed ? "text-red-600 dark:text-red-400" : parsed ? "text-foreground" : "text-muted-foreground"}`}>              <Line text={preview} reduced={reduced} />            </p>            <div role="listbox" aria-label="Quick ranges" className="relative mt-1 grid grid-cols-2 gap-0.5 border-t border-border pt-1.5" onMouseLeave={() => setActive(-1)}>              <span                aria-hidden="true"                className="pointer-events-none absolute left-0 top-0 rounded-[9px] bg-accent"                style={{                  width: mark.w,                  height: mark.h,                  transform: `translate(${mark.x}px, ${mark.y}px)`,                  opacity: mark.on ? 1 : 0,                  transition: reduced ? "none" : `${mark.slide ? `transform 220ms ${EASE}, width 220ms ${EASE}, ` : ""}opacity 160ms ${EASE}`,                }}              />              {presets.map((p, i) => {                const on = i === currentPreset;                return (                  <button                    key={p.label}                    ref={(el) => {                      presetRefs.current[i] = el;                    }}                    id={`${uid}-p${i}`}                    type="button"                    role="option"                    aria-selected={on}                    tabIndex={-1}                    onPointerMove={() => active !== i && setActive(i)}                    onClick={() => choose(i)}                    className={`relative flex h-8 cursor-pointer items-center justify-between gap-2 rounded-[9px] px-2 text-left text-[12.5px] ${on ? "font-medium text-foreground" : "text-muted-foreground"}`}                  >                    <span className="truncate">{p.label}</span>                    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3 shrink-0 text-foreground">                      <path                        d="M3.5 8.5 6.5 11.5 12.5 4.5"                        stroke="currentColor"                        strokeWidth="1.9"                        strokeLinecap="round"                        strokeLinejoin="round"                        pathLength={1}                        strokeDasharray={1}                        style={{ strokeDashoffset: on && expanded ? 0 : 1, transition: reduced ? "none" : `stroke-dashoffset 380ms ${EASE} 160ms` }}                      />                    </svg>                  </button>                );              })}            </div>            {recent.length > 0 && (              <div className="mt-1.5 border-t border-border px-1 pt-2">                <p className="text-[11px] text-muted-foreground">Recent</p>                <div className="mt-1.5 flex flex-wrap gap-1 pb-0.5">                  {recent.map((r) => (                    <button                      key={formatTimeRange(r, { now, timeZone, hour12 })}                      type="button"                      tabIndex={-1}                      onClick={() => {                        commit(r, "typed");                        request(false);                      }}                      className="h-6 max-w-full cursor-pointer truncate rounded-full px-2 text-[11.5px] text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent"                    >                      {formatTimeRange(r, { now, timeZone, hour12 })}                    </button>                  ))}                </div>              </div>            )}            <p className="mt-1.5 border-t border-border px-1 pb-0.5 pt-2 text-[11px] text-muted-foreground">Times in {zoneLabel(timeZone)}</p>          </div>,          document.body,        )}      <span className="sr-only" role="status" aria-live="polite">        {announce}      </span>    </div>  );}
components/time-range-parse.ts
/* ───────────────────────────────────────────────────────── * TIME RANGE PARSE: what "2h" or "yesterday 14:00–16:00" means * *   ranges   last N (live), since a moment (live), or a fixed *            start and end *   typed    15m · 2 hours · last 7 days · today · yesterday · *            this week · last month · since 9 · since monday · *            14:00–16:00 · 9am to 5pm · yesterday 14:00-16:00 · *            16 oct 14:00 – 17 oct 09:00 · ISO dates *   zones    wall-clock times are read and written in the zone *            you pass (the viewer's own by default) * ───────────────────────────────────────────────────────── */export type TimeRange =  /** The last `duration` ms, ending now. */  | { type: "relative"; duration: number }  /** From `start` until now. */  | { type: "since"; start: Date }  | { type: "absolute"; start: Date; end: Date };export const MINUTE = 60_000;export const HOUR = 60 * MINUTE;export const DAY = 24 * HOUR;type Wall = { year: number; month: number; day: number; hour: number; minute: number };/* ── Zones ─────────────────────────────────────────────── */const formatters = new Map<string, Intl.DateTimeFormat>();function fmt(timeZone?: string) {  const key = timeZone ?? "";  let f = formatters.get(key);  if (!f) {    f = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric", weekday: "short" });    formatters.set(key, f);  }  return f;}const WEEKDAY = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } as Record<string, number>;/** The wall clock at an instant in a zone. */export function wallAt(epoch: number, timeZone?: string) {  const p: Record<string, string> = {};  for (const part of fmt(timeZone).formatToParts(new Date(epoch))) p[part.type] = part.value;  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 };}/** The instant a wall-clock time happens in a zone (the earlier one when clocks go back). */export function fromWall(w: Wall, timeZone?: string) {  const guess = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);  const off = (t: number) => {    const a = wallAt(t, timeZone);    return Date.UTC(a.year, a.month - 1, a.day, a.hour, a.minute, a.second) - Math.floor(t / 1000) * 1000;  };  const a = guess - off(guess - 6 * HOUR);  const b = guess - off(guess + 6 * HOUR);  const fits = (t: number) => {    const x = wallAt(t, timeZone);    return x.day === w.day && x.hour === w.hour && x.minute === w.minute;  };  if (fits(a) && fits(b)) return Math.min(a, b);  if (fits(b)) return b;  // a fits, or the time falls in a gap where clocks jump: either way, the moment the clock passes it.  return a;}const addDays = (w: Wall, n: number): Wall => {  const d = new Date(Date.UTC(w.year, w.month - 1, w.day + n));  return { ...w, year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };};const midnight = (w: Wall): Wall => ({ ...w, hour: 0, minute: 0 });/* ── Resolving and stepping ────────────────────────────── */export function resolveTimeRange(range: TimeRange, now = new Date()): { start: Date; end: Date } {  if (range.type === "relative") return { start: new Date(now.getTime() - range.duration), end: now };  if (range.type === "since") return { start: range.start, end: now };  return { start: range.start, end: range.end };}export const isLive = (range: TimeRange) => range.type !== "absolute";/** The same length of time, one window earlier (-1) or later (1). A window that would reach now becomes live again. */export function stepTimeRange(range: TimeRange, dir: -1 | 1, now = new Date()): TimeRange {  const { start, end } = resolveTimeRange(range, now);  const span = end.getTime() - start.getTime();  const nextEnd = end.getTime() + dir * span;  if (dir === 1 && nextEnd >= now.getTime() - 30_000) return { type: "relative", duration: span };  return { type: "absolute", start: new Date(start.getTime() + dir * span), end: new Date(nextEnd) };}export const sameRange = (a: TimeRange, b: TimeRange) =>  a.type === b.type &&  (a.type === "relative"    ? a.duration === (b as typeof a).duration    : a.type === "since"      ? a.start.getTime() === (b as typeof a).start.getTime()      : a.start.getTime() === (b as typeof a).start.getTime() && a.end.getTime() === (b as typeof a).end.getTime());/* ── Words ─────────────────────────────────────────────── */const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;/** "15 minutes", "4 hours", "7 days". */export function durationWords(ms: number) {  if (ms < HOUR || ms % HOUR) return plural(Math.round(ms / MINUTE), "minute");  if (ms < 2 * DAY || ms % DAY) return plural(Math.round(ms / HOUR), "hour");  if (ms % (7 * DAY) === 0 && ms / DAY >= 14) return plural(ms / (7 * DAY), "week");  return plural(Math.round(ms / DAY), "day");}/** "15m", "2h", "3d", "1h 30m". */export function durationShort(ms: number) {  const m = Math.round(ms / MINUTE);  if (m < 60) return `${m}m`;  if (m < 48 * 60) return m % 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m / 60}h`;  const d = Math.floor(m / 1440);  const h = Math.round((m % 1440) / 60);  return h ? `${d}d ${h}h` : `${d}d`;}const pad = (n: number) => String(n).padStart(2, "0");const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];/** What the picker's pill says. */export function formatTimeRange(range: TimeRange, { now = new Date(), timeZone, hour12 = false, compact = false }: { now?: Date; timeZone?: string; hour12?: boolean; compact?: boolean } = {}) {  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)}`);  const today = wallAt(now.getTime(), timeZone);  const yesterday = addDays(today, -1);  const dayName = (w: Wall & { weekday?: number }) => {    if (w.year === today.year && w.month === today.month && w.day === today.day) return "Today";    if (w.year === yesterday.year && w.month === yesterday.month && w.day === yesterday.day) return "Yesterday";    const date = `${w.day} ${MONTHS[w.month - 1]}${w.year !== today.year ? ` ${w.year}` : ""}`;    return compact ? date : `${DAYS[new Date(Date.UTC(w.year, w.month - 1, w.day)).getUTCDay()]} ${date}`;  };  // Narrow places drop the spaces round the dash and the comma after the day.  const dash = compact ? "–" : " – ";  const comma = compact ? " " : ", ";  if (range.type === "relative") return `Last ${durationWords(range.duration)}`;  const s = wallAt(range.start.getTime(), timeZone);  if (range.type === "since") {    if (!s.hour && !s.minute) return dayName(s) === "Today" ? "Today so far" : `Since ${dayName(s)}`;    return dayName(s) === "Today" ? `Since ${time(s)}` : `Since ${dayName(s)}${comma}${time(s)}`;  }  const e = wallAt(range.end.getTime(), timeZone);  const sameDay = s.year === e.year && s.month === e.month && s.day === e.day;  const nextDay = addDays(s, 1);  // Whole days read as days: "Yesterday", "Mon 12 Oct – Wed 14 Oct".  if (!s.hour && !s.minute && !e.hour && !e.minute) {    if (nextDay.year === e.year && nextDay.month === e.month && nextDay.day === e.day) return dayName(s);    return `${dayName(s)}${dash}${dayName(addDays(e, -1))}`;  }  if (sameDay) return `${dayName(s)}${comma}${time(s)}${dash}${time(e)}`;  return `${dayName(s)} ${time(s)}${dash}${dayName(e)} ${time(e)}`;}/* ── Typed ranges ──────────────────────────────────────── */const UNIT: Record<string, number> = {  s: 1000, sec: 1000, secs: 1000, second: 1000, seconds: 1000,  m: MINUTE, min: MINUTE, mins: MINUTE, minute: MINUTE, minutes: MINUTE,  h: HOUR, hr: HOUR, hrs: HOUR, hour: HOUR, hours: HOUR,  d: DAY, day: DAY, days: DAY,  w: 7 * DAY, wk: 7 * DAY, week: 7 * DAY, weeks: 7 * DAY,};const 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 };const MONTH_WORDS: Record<string, number> = Object.fromEntries(  ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"].flatMap((m, i) => [    [m, i + 1],    [m.slice(0, 3), i + 1],  ]).concat([["sept", 9]]),);function readTime(t: string): { hour: number; minute: number } | null {  if (t === "noon" || t === "midday") return { hour: 12, minute: 0 };  if (t === "midnight") return { hour: 0, minute: 0 };  const m = t.match(/^(\d{1,2})(?:[:.h](\d{2}))?\s?(am|pm)?$/);  if (!m) return null;  let hour = +m[1];  const minute = m[2] ? +m[2] : 0;  if (m[3]) {    if (hour < 1 || hour > 12) return null;    hour = (hour % 12) + (m[3] === "pm" ? 12 : 0);  } else if (!m[2] && hour > 24) return null;  if (hour > 23 || minute > 59) return null;  return { hour, minute };}/** A date word or numbers, relative to today. */function readDate(t: string, today: Wall & { weekday: number }): Wall | null {  if (!t || t === "today") return midnight(today);  if (t === "yesterday") return midnight(addDays(today, -1));  if (t in DAY_WORDS) {    const back = (today.weekday - DAY_WORDS[t] + 7) % 7;    return midnight(addDays(today, -back));  }  let m = t.match(/^(\d{4})-(\d{2})-(\d{2})$/);  if (m) return { year: +m[1], month: +m[2], day: +m[3], hour: 0, minute: 0 };  m = t.match(/^(\d{1,2})(?:st|nd|rd|th)? (?:of )?([a-z]+)(?: (\d{4}))?$/) ?? null;  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 };  m = t.match(/^([a-z]+) (\d{1,2})(?:st|nd|rd|th)?(?:,? (\d{4}))?$/);  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 };  return null;}const TIME_TAIL = /\s*(noon|midday|midnight|\d{1,2}(?:[:.h]\d{2})?\s?(?:am|pm)?)$/;const ISO_MOMENT = /^\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}/;/** "yesterday 14:00", "16 oct", "9am", "2026-10-16 14:00" as a wall time. */function readMoment(text: string, today: Wall & { weekday: number }, fallbackDate?: Wall): { wall: Wall; dated: boolean; timed: boolean } | null {  const t = text.trim();  if (ISO_MOMENT.test(t)) {    const [d, tm] = t.split(/[t ]/);    const date = readDate(d, today);    const time = readTime(tm.slice(0, 5));    return date && time ? { wall: { ...date, ...time }, dated: true, timed: true } : null;  }  const tail = t.match(TIME_TAIL);  let datePart = t;  let time: { hour: number; minute: number } | null = null;  if (tail) {    const candidate = readTime(tail[1].replace(/\s/g, ""));    // "16 oct" ends in digits that aren't a time; only take the tail when what's left is a date (or nothing).    const rest = t.slice(0, tail.index).trim().replace(/,$/, "");    if (candidate && (rest === "" || readDate(rest, today))) {      time = candidate;      datePart = rest;    }  }  const date = datePart ? readDate(datePart, today) : (fallbackDate ?? midnight(today));  if (!date) return null;  return { wall: { ...date, ...(time ?? { hour: 0, minute: 0 }) }, dated: Boolean(datePart), timed: Boolean(time) };}/** Reads a typed range, or null. */export function parseTimeRange(input: string, { now = new Date(), timeZone }: { now?: Date; timeZone?: string } = {}): TimeRange | null {  const t = input.toLowerCase().trim().replace(/\s+/g, " ").replace(/[–—]/g, "-");  if (!t) return null;  const today = wallAt(now.getTime(), timeZone);  const at = (w: Wall) => new Date(fromWall(w, timeZone));  // Durations: 15m, 2 hours, last 7 days.  const dur = t.match(/^(?:last |past )?(\d+(?:\.\d+)?) ?([a-z]+)$/);  if (dur && UNIT[dur[2]]) return { type: "relative", duration: Math.round(+dur[1] * UNIT[dur[2]]) };  const bare = t.match(/^(?:last (minute|hour|day)|past (minute|hour|day|week))$/);  if (bare) return { type: "relative", duration: UNIT[bare[1] ?? bare[2]] };  if (t === "today") return { type: "since", start: at(midnight(today)) };  if (t === "yesterday") return { type: "absolute", start: at(midnight(addDays(today, -1))), end: at(midnight(today)) };  if (t === "this week") return { type: "since", start: at(midnight(addDays(today, -((today.weekday + 6) % 7)))) };  if (t === "last week") {    const monday = midnight(addDays(today, -((today.weekday + 6) % 7)));    return { type: "absolute", start: at(addDays(monday, -7)), end: at(monday) };  }  if (t === "this month") return { type: "since", start: at({ ...midnight(today), day: 1 }) };  if (t === "last month") {    const first = { ...midnight(today), day: 1 };    const prev = new Date(Date.UTC(first.year, first.month - 2, 1));    return { type: "absolute", start: at({ ...first, year: prev.getUTCFullYear(), month: prev.getUTCMonth() + 1 }), end: at(first) };  }  // Since a moment: since 9, since monday, since 16 oct 14:00.  const since = t.match(/^(?:since|from) (.+)$/);  if (since && !/ (?:to|until|-) /.test(since[1]) && !/\d-\d/.test(since[1])) {    const m = readMoment(since[1], today);    if (!m) return null;    const start = at(m.wall);    return start.getTime() < now.getTime() ? { type: "since", start } : null;  }  // A span: "a - b", "a to b", or two times joined by a bare hyphen ("yesterday 14:00-16:00").  const body = t.replace(/^(?:from|between) /, "");  let span = body.split(/ (?:-|to|until|and) /);  if (span.length !== 2) {    const tight = body.match(/^(.*?\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)?)-(\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)?)$/);    span = tight ? [tight[1], tight[2]] : [];  }  if (span.length === 2) {    // "yesterday 14:00-16:00": the date on the left carries over to a time-only right side.    const a = readMoment(span[0], today);    if (!a) return null;    const b = readMoment(span[1], today, midnight(a.wall));    if (!b) return null;    let start = a.wall;    let end = b.wall;    if (!b.timed && b.dated) end = addDays(end, 1); // "16 oct - 17 oct" includes the 17th    if (fromWall(end, timeZone) <= fromWall(start, timeZone)) end = addDays(end, 1); // 22:00-02:00 runs past midnight    // Times without a date that haven't happened yet today mean yesterday's.    if (!a.dated && fromWall(start, timeZone) > now.getTime()) {      start = addDays(start, -1);      end = addDays(end, -1);    }    return { type: "absolute", start: at(start), end: at(end) };  }  // A single day: monday, 16 oct.  const day = readMoment(t, today);  if (day && day.dated && !day.timed) return { type: "absolute", start: at(day.wall), end: at(addDays(day.wall, 1)) };  if (day && day.timed && !day.dated) return { type: "since", start: at(day.wall) };  return null;}
components/time-brush.tsx
"use client";import * as React from "react";import { durationShort, wallAt } from "./time-range-parse";/* ───────────────────────────────────────────────────────── * TIME BRUSH: drag across any chart to pick the time under it * *   drag     a band follows the pointer from where you pressed, *            with its start, end and length written on it *   release  the band's span is passed to onSelect (zoom in), *            and the band fades as the chart redraws *   cancel   Escape, or a drag too short to mean anything * * Wrap a chart whose x axis runs from `start` to `end`. Nothing * re-renders while you drag: the band is moved directly. * ───────────────────────────────────────────────────────── */export interface TimeBrushProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {  /** The time at the chart's left edge. */  start: Date;  /** The time at its right edge. */  end: Date;  onSelect: (range: { start: Date; end: Date }) => void;  /** The shortest span worth selecting, in ms. */  minSpan?: number;  timeZone?: string;  disabled?: boolean;}const pad = (n: number) => String(n).padStart(2, "0");export function TimeBrush({ start, end, onSelect, minSpan = 60_000, timeZone, disabled = false, className = "", children, ...props }: TimeBrushProps) {  const rootRef = React.useRef<HTMLDivElement>(null);  const bandRef = React.useRef<HTMLDivElement>(null);  const fillRef = React.useRef<HTMLSpanElement>(null);  const leftRef = React.useRef<HTMLSpanElement>(null);  const rightRef = React.useRef<HTMLSpanElement>(null);  const tagRef = React.useRef<HTMLSpanElement>(null);  const drag = React.useRef<{ x0: number; id: number; left: number; width: number; from: number; to: number } | null>(null);  const timeAt = (x: number, width: number) => start.getTime() + (Math.min(Math.max(x, 0), width) / width) * (end.getTime() - start.getTime());  const label = (t: number) => {    const w = wallAt(t, timeZone);    return `${pad(w.hour)}:${pad(w.minute)}`;  };  const paint = (a: number, b: number, width: number) => {    const band = bandRef.current;    const tag = tagRef.current;    const d = drag.current;    if (!band || !tag || !d) return;    const left = Math.max(0, Math.min(a, b));    const right = Math.min(width, Math.max(a, b));    d.from = timeAt(left, width);    d.to = timeAt(right, width);    // Transforms only: a 1px fill scaled to the span, and two hairline edges moved into place.    fillRef.current!.style.transform = `translateX(${left}px) scaleX(${right - left})`;    leftRef.current!.style.transform = `translateX(${left}px)`;    rightRef.current!.style.transform = `translateX(${right - 1}px)`;    band.style.opacity = right - left > 2 ? "1" : "0";    band.style.transition = "none";    tag.textContent = `${label(d.from)} – ${label(d.to)} · ${durationShort(Math.max(60_000, d.to - d.from))}`;    // The tag stays on the chart even when the band is near an edge.    const tw = tag.offsetWidth;    tag.style.transform = `translateX(${Math.min(Math.max(left + (right - left) / 2 - tw / 2, 0), width - tw)}px)`;  };  const finish = (commit: boolean) => {    const d = drag.current;    const band = bandRef.current;    drag.current = null;    if (band) {      band.style.transition = "opacity 360ms cubic-bezier(0.16,1,0.3,1)";      band.style.opacity = "0";    }    if (d && commit && d.to - d.from >= minSpan) onSelect({ start: new Date(d.from), end: new Date(d.to) });  };  React.useEffect(() => {    const onKey = (e: KeyboardEvent) => {      if (e.key === "Escape" && drag.current) finish(false);    };    window.addEventListener("keydown", onKey);    return () => window.removeEventListener("keydown", onKey);  });  return (    <div      ref={rootRef}      className={`relative select-none ${disabled ? "" : "cursor-crosshair"} ${className}`}      style={{ touchAction: "pan-y" }}      onPointerDown={(e) => {        if (disabled || e.button !== 0) return;        const r = e.currentTarget.getBoundingClientRect();        drag.current = { x0: e.clientX - r.left, id: e.pointerId, left: r.left, width: r.width, from: 0, to: 0 };      }}      onPointerMove={(e) => {        const d = drag.current;        if (!d || e.pointerId !== d.id) return;        const x = e.clientX - d.left;        if (Math.abs(x - d.x0) > 3 && !e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.setPointerCapture(e.pointerId);        paint(d.x0, x, d.width);      }}      onPointerUp={(e) => {        const d = drag.current;        if (!d || e.pointerId !== d.id) return;        finish(Math.abs(e.clientX - d.left - d.x0) > 6);      }}      onPointerCancel={() => finish(false)}      {...props}    >      {children}      <div ref={bandRef} aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-0">        <span ref={fillRef} className="absolute inset-y-0 left-0 w-px origin-left bg-primary/[0.09] will-change-transform" />        <span ref={leftRef} className="absolute inset-y-0 left-0 w-px bg-primary/70 will-change-transform" />        <span ref={rightRef} className="absolute inset-y-0 left-0 w-px bg-primary/70 will-change-transform" />        <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" />      </div>    </div>  );}

Registry item: /r/time-range-picker.json