Time range picker The control that decides what every panel on the page means. Relative ranges are the common case and the absolute one has to state its timezone, because a shared link crossing timezones silently changes the data. npx shadcn@latest add button popover input npm i lucide-react Tokens this needs: --foreground, --popover, --popover-foreground, --secondary, --secondary-foreground, --muted, --muted-foreground, --border, --input, --chart-1, --status-warn The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // TimeRangePicker.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { Clock, Link } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; /** * Three kinds of range, and the type says which one this is. A relative * range slides with now, an absolute one is pinned at both ends, and a * semi-relative one is pinned at the start with now as its end, which is the * shape of "since the deploy" and the one pickers leave out. */ export type Range = | { kind: "relative"; last: string } | { kind: "absolute"; from: Date; to: Date } | { kind: "since"; from: Date; label?: string }; export type Event = { label: string; at: Date }; const QUICK = ["15m", "1h", "6h", "24h", "7d"]; const UNIT: Record = { m: 60_000, h: 3_600_000, d: 86_400_000 }; const back = (last: string, now: Date) => new Date(now.getTime() - parseInt(last) * UNIT[last.slice(-1)]); const hhmm = (d: Date, timeZone: string) => new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", timeZone }).format(d); /** Both ends as instants, whichever kind it is. */ export function resolve(r: Range, now: Date): { from: Date; to: Date } { if (r.kind === "relative") return { from: back(r.last, now), to: now }; if (r.kind === "absolute") return { from: r.from, to: r.to }; return { from: r.from, to: now }; } /** The range as the trigger reads it, without opening anything. */ export function label(r: Range, tz: string) { if (r.kind === "relative") return `last ${r.last}`; if (r.kind === "absolute") return `${hhmm(r.from, tz)} to ${hhmm(r.to, tz)}`; return r.label ? `since ${r.label}` : `since ${hhmm(r.from, tz)}`; } /** What goes in a shared link. Both ends are pinned first: from=now-6h means * a different six hours to whoever opens it tomorrow. */ export function toParams(r: Range, now: Date, tz: string) { const { from, to } = resolve(r, now); return new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), tz }).toString(); } /** The range drawn on an axis that ends at now, so the reader sees which end * slides. The axis is the last `spanMs` before now. */ function Axis({ range, now, tz, spanMs }: { range: Range; now: Date; tz: string; spanMs: number }) { const { from, to } = resolve(range, now); const x = (d: Date) => Math.max(0, Math.min(100, 100 - ((now.getTime() - d.getTime()) / spanMs) * 100)); const ticks = [0.75, 0.5, 0.25].map((f) => new Date(now.getTime() - spanMs * f)); const slides = range.kind !== "absolute"; return (
{ticks.map((t) => {hhmm(t, tz)})} now
); } export function TimeRangePicker({ value, onChange, now, timezone, events, onCopyLink, defaultOpen = false }: { value: Range; onChange: (range: Range) => void; /** The clock a relative range is measured from. Passed in, so a wallboard * that never reloads can still be told what time it is. */ now: Date; timezone: string; /** Deploys, incidents: the starts people want without doing arithmetic. */ events: Event[]; onCopyLink: (params: string) => void; defaultOpen?: boolean; }) { const [open, setOpen] = useState(defaultOpen); const [typed, setTyped] = useState(""); const abs = resolve(value, now); const [from, setFrom] = useState(hhmm(abs.from, timezone)); const [to, setTo] = useState(hhmm(abs.to, timezone)); const pick = (r: Range) => { onChange(r); setOpen(false); }; const is = (r: Range) => label(r, timezone) === label(value, timezone); const row = (on: boolean) => `flex w-full items-center justify-between rounded px-2 py-1 text-left text-xs hover:bg-muted ${on ? "font-medium text-chart-1" : ""}`; // "13h" is a range. Grafana accepts it, and so should anything else. const submitTyped = () => { if (/^\d+[mhd]$/.test(typed)) pick({ kind: "relative", last: typed }); }; // Two clock times on today's date. A real picker takes a date too; the // shape is what matters here: both ends pinned, nothing slides. const submitAbsolute = () => { const day = now.toISOString().slice(0, 10); const f = new Date(`${day}T${from}:00Z`), t = new Date(`${day}T${to}:00Z`); if (!isNaN(+f) && !isNaN(+t) && f < t) pick({ kind: "absolute", from: f, to: t }); }; return ( e.preventDefault()}>

Relative · slides with now

{QUICK.map((last) => ( ))} setTyped(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submitTyped()} placeholder="13h" aria-label="last, typed" className="h-6 w-14 px-1.5 text-xs" />

Absolute · pinned at both ends

setFrom(e.target.value)} aria-label="from" className="h-6 w-16 px-1.5 text-xs tabular-nums" /> to setTo(e.target.value)} aria-label="to" className="h-6 w-16 px-1.5 text-xs tabular-nums" />

Since · pinned start, now as the end

{events.map((e) => ( ))}
{timezone}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { TimeRangePicker, type Range } from "./TimeRangePicker"; /** * Mid-investigation: the range is "since the deploy", a pinned start with * now as its end. The axis at the top of the panel shows it against now. * Pick "last 6h" or type 14:05 to 17:20 to see the other two kinds drawn. * The clock is fixed so relative ranges resolve the same on the server. */ const NOW = new Date("2026-09-15T17:40:00Z"); const at = (hhmm: string) => new Date(`2026-09-15T${hhmm}:00Z`); const DEPLOY = { label: "the deploy", at: at("12:20") }; const INCIDENT = { label: "the incident opened", at: at("14:05") }; export default function Demo() { const [range, setRange] = useState({ kind: "since", from: DEPLOY.at, label: DEPLOY.label }); const [link, setLink] = useState(null); return (
{link &&

?{link}

}
); }