Time Range Picker

A time range whose value is an expression, so it stays relative across a reload.

value = now-6h..now

Installation

Terminal
pnpm dlx @dowel-ui/cli add time-range-picker

Installs button, calendar, input and popover as well, because this component imports them.

npm packages installed: react-day-picker.

Accessibility

The trigger is named by the range it holds — "Last 6 hours" — and the window that resolves to is read after it, so the relative choice is not replaced by two timestamps. The popover carries role="dialog" and is named. Presets are buttons rather than radios because choosing one both selects and dismisses, with aria-pressed carrying which matches the current expression. The expression field reports why an unparseable entry is invalid through one aria-describedby element that the preview and the error share, so a reader hears the error replace the preview rather than both at once; an invalid expression is never applied, because a chart silently re-scoping itself is worse than one that refuses.

Props

TimeRange

PropTypeDefault
children (required)ReactNode
defaultValuestring"now-6h..now"
localestring
now

The instant relative expressions are measured from. Supplied by the consumer so nothing here reads the clock during render. Pass a value that changes when you refresh; a stable one holds the window still between them.

Date
onOpenChange(open: boolean) => void
onValueChange(expression: string) => void
openboolean
presetsTimeRangePreset[]DEFAULT_PRESETS
timeZone

IANA zone the snapping happens in. Defaults to the runtime zone.

string
value

The expression, e.g. now-6h..now. Controlled.

string

TimeRangeTrigger

PropTypeDefault
showResolved

Shows the resolved window under the label. Off by default; it is long.

booleanfalse

Plus every attribute of <button>.

TimeRangeContent

PropTypeDefault
label

Names the dialog.

string"Choose a time range"

TimeRangePresets

Plus every attribute of <div> except children.

TimeRangeExpression

Plus every attribute of <div> except children.

TimeRangeCalendar

PropTypeDefault
numberOfMonthsnumber1

Plus every attribute of <div> except children.

Quality

10/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Source

This is exactly what dowel add time-range-picker writes into your project, with imports rewritten to your own path alias.

ui/time-expression.ts
/**
 * Time ranges as expressions, not as two frozen timestamps.
 *
 * `now-6h` is still the last six hours tomorrow; a resolved pair is six hours
 * of last Tuesday forever. Storing the expression is what lets a dashboard URL
 * survive being bookmarked and reloaded.
 *
 * The grammar is a deliberate subset of the one Grafana, Datadog and Kibana
 * converged on, so the strings are already familiar:
 *
 *   range  := expr ".." expr
 *   expr   := "now" offset* snap? | ISO-8601
 *   offset := ("-" | "+") digits unit
 *   snap   := "/" unit
 *   unit   := s | m | h | d | w | M | y
 *
 *   now-6h..now          the last six hours
 *   now-6h/h..now        the last six hours, starting on the hour
 *   now/d..now/d         today, all of it
 *   now-1d/d..now-1d/d   yesterday
 *   2026-01-01..now      since a fixed instant
 *
 * Everything here is pure and takes `now` as an argument: reading the clock
 * during render would disagree between the server and the browser.
 */

export type TimeUnit = "s" | "m" | "h" | "d" | "w" | "M" | "y";

export interface ResolveOptions {
  /** The instant `now` refers to. Required, so nothing here reads the clock. */
  now: Date;
  /**
   * IANA zone the snapping happens in — "start of day" is a different instant
   * in Auckland than in Denver. Defaults to the runtime zone.
   */
  timeZone?: string;
}

export interface ResolvedRange {
  from: Date;
  to: Date;
}

/** Why an expression could not be resolved. Shown to the user verbatim. */
export class TimeExpressionError extends Error {
  constructor(
    message: string,
    /** The part of the input at fault, for pointing at it. */
    readonly input: string,
  ) {
    super(message);
    this.name = "TimeExpressionError";
  }
}

const UNIT_NAMES: Record<TimeUnit, string> = {
  s: "second",
  m: "minute",
  h: "hour",
  d: "day",
  w: "week",
  M: "month",
  y: "year",
};

const UNITS = new Set(Object.keys(UNIT_NAMES) as TimeUnit[]);

/* ------------------------------------------------------------------ */
/*  Zone arithmetic                                                    */
/* ------------------------------------------------------------------ */

interface Parts {
  year: number;
  month: number;
  day: number;
  hour: number;
  minute: number;
  second: number;
  ms: number;
}

const PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();

function formatterFor(timeZone: string): Intl.DateTimeFormat {
  let formatter = PART_FORMATTERS.get(timeZone);
  if (!formatter) {
    formatter = new Intl.DateTimeFormat("en-US", {
      timeZone,
      hour12: false,
      year: "numeric",
      month: "2-digit",
      day: "2-digit",
      hour: "2-digit",
      minute: "2-digit",
      second: "2-digit",
    });
    PART_FORMATTERS.set(timeZone, formatter);
  }
  return formatter;
}

/** Wall-clock fields of an instant, as read in `timeZone`. */
function toParts(instant: Date, timeZone?: string): Parts {
  if (!timeZone) {
    return {
      year: instant.getFullYear(),
      month: instant.getMonth() + 1,
      day: instant.getDate(),
      hour: instant.getHours(),
      minute: instant.getMinutes(),
      second: instant.getSeconds(),
      ms: instant.getMilliseconds(),
    };
  }

  const found: Record<string, number> = {};
  for (const part of formatterFor(timeZone).formatToParts(instant)) {
    if (part.type !== "literal") found[part.type] = Number(part.value);
  }
  return {
    year: found.year ?? 0,
    month: found.month ?? 1,
    day: found.day ?? 1,
    // Midnight formats as hour 24 rather than 0 in some engines.
    hour: (found.hour ?? 0) % 24,
    minute: found.minute ?? 0,
    second: found.second ?? 0,
    ms: instant.getMilliseconds(),
  };
}

function asUtc(parts: Parts): number {
  return Date.UTC(
    parts.year,
    parts.month - 1,
    parts.day,
    parts.hour,
    parts.minute,
    parts.second,
    parts.ms,
  );
}

/**
 * The instant at which `timeZone` reads these wall-clock fields.
 *
 * Two passes: guess the fields as UTC, measure the offset there, correct, and
 * measure again in case the correction crossed a transition. A wall-clock time
 * at a DST boundary can be ambiguous or absent; this picks one rather than
 * throwing — an hour's difference once a year is a fair trade for not shipping
 * a timezone database.
 */
function fromParts(parts: Parts, timeZone?: string): Date {
  if (!timeZone) {
    return new Date(
      parts.year,
      parts.month - 1,
      parts.day,
      parts.hour,
      parts.minute,
      parts.second,
      parts.ms,
    );
  }

  const wall = asUtc(parts);
  let instant = new Date(wall);
  for (let pass = 0; pass < 2; pass += 1) {
    const offset = asUtc(toParts(instant, timeZone)) - instant.getTime();
    const next = new Date(wall - offset);
    if (next.getTime() === instant.getTime()) break;
    instant = next;
  }
  return instant;
}

/* ------------------------------------------------------------------ */
/*  Offsets and snapping                                               */
/* ------------------------------------------------------------------ */

/** `month` is 1-based, matching `Parts`. */
function daysInMonth(year: number, month: number): number {
  return new Date(Date.UTC(year, month, 0)).getUTCDate();
}

const FIXED_MS: Partial<Record<TimeUnit, number>> = {
  s: 1000,
  m: 60_000,
  h: 3_600_000,
};

/**
 * Applies `±N unit` to an instant.
 *
 * Seconds, minutes and hours are fixed durations. Days, weeks, months and years
 * are calendar arithmetic: `now-1d` means the same wall-clock time yesterday,
 * which on the day a zone changes offset is 23 or 25 hours, not 24. And
 * `now-1M` from the 31st lands on the last day of a shorter month rather than
 * overflowing into the next one.
 */
function applyOffset(instant: Date, amount: number, unit: TimeUnit, timeZone?: string): Date {
  const fixed = FIXED_MS[unit];
  if (fixed !== undefined) return new Date(instant.getTime() + amount * fixed);

  const parts = { ...toParts(instant, timeZone) };

  if (unit === "d") {
    parts.day += amount;
  } else if (unit === "w") {
    parts.day += amount * 7;
  } else {
    if (unit === "y") {
      parts.year += amount;
    } else {
      const target = parts.month - 1 + amount;
      parts.year += Math.floor(target / 12);
      parts.month = (((target % 12) + 12) % 12) + 1;
    }
    // Clamp instead of overflowing: 31 January minus one month is the end of
    // February, not the third of March, and a year back from 29 February is
    // the 28th rather than 1 March.
    parts.day = Math.min(parts.day, daysInMonth(parts.year, parts.month));
  }

  return fromParts(parts, timeZone);
}

/**
 * Rounds an instant to a unit boundary, `edge` deciding which one.
 *
 * The detail that catches every reimplementation: `now/d..now/d` means *all of
 * today*, so the start floors to midnight and the end climbs to that day's last
 * millisecond. Flooring both yields a zero-length range and an empty chart.
 */
function applySnap(
  instant: Date,
  unit: TimeUnit,
  edge: "start" | "end",
  timeZone?: string,
): Date {
  const parts = { ...toParts(instant, timeZone) };

  parts.ms = 0;
  if (unit !== "s") parts.second = 0;
  if (unit !== "s" && unit !== "m") parts.minute = 0;
  if (unit === "d" || unit === "w" || unit === "M" || unit === "y") parts.hour = 0;
  if (unit === "M" || unit === "y") parts.day = 1;
  if (unit === "y") parts.month = 1;

  if (unit === "w") {
    // ISO weeks: Monday starts the week. Locale-varying week starts are a
    // separate decision, and one an app should make once rather than per range.
    //
    // The weekday is read off the calendar date itself, not off the instant —
    // asking a `Date` built from local fields for `getDay()` would answer for
    // the runtime's zone rather than the one being snapped in.
    const weekday = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay();
    parts.day -= (weekday + 6) % 7;
  }

  const start = fromParts(parts, timeZone);
  if (edge === "start") return start;

  // The end of the unit is one millisecond before the next one begins, which
  // keeps `to` inclusive without an off-by-one at every boundary.
  const nextStart = applyOffset(start, 1, unit, timeZone);
  return new Date(nextStart.getTime() - 1);
}

/* ------------------------------------------------------------------ */
/*  Parsing                                                            */
/* ------------------------------------------------------------------ */

const ISO =
  /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
const CALENDAR_DATE = /^(\d{4})-(\d{2})-(\d{2})/;
const OFFSET = /^([+-])(\d+)([smhdwMy])/;

/** Resolves a single side of a range. Exported for the input's live feedback. */
export function resolveExpression(
  expression: string,
  edge: "start" | "end",
  options: ResolveOptions,
): Date {
  const input = expression.trim();
  if (input === "") throw new TimeExpressionError("Enter a time.", input);

  if (ISO.test(input)) {
    // `new Date` rolls an impossible day forward rather than refusing it —
    // 30 February parses happily as 2 March — so the calendar date is checked
    // against the month before the string is trusted.
    const calendar = CALENDAR_DATE.exec(input);
    if (
      calendar &&
      (Number(calendar[2]) < 1 ||
        Number(calendar[2]) > 12 ||
        Number(calendar[3]) < 1 ||
        Number(calendar[3]) > daysInMonth(Number(calendar[1]), Number(calendar[2])))
    ) {
      throw new TimeExpressionError(`${input} is not a real date.`, input);
    }

    const dateOnly = DATE_ONLY.exec(input);
    if (dateOnly) {
      // A bare date means a day in the reader's zone, not midnight UTC, and on
      // the closing side it means the whole of that day. `2026-01-01..2026-01-31`
      // that stops at the 31st's first millisecond silently drops a day of data.
      const parts: Parts = {
        year: Number(dateOnly[1]),
        month: Number(dateOnly[2]),
        day: Number(dateOnly[3]),
        hour: 0,
        minute: 0,
        second: 0,
        ms: 0,
      };
      const start = fromParts(parts, options.timeZone);
      if (Number.isNaN(start.getTime())) {
        throw new TimeExpressionError(`${input} is not a real date.`, input);
      }
      return edge === "start" ? start : applySnap(start, "d", "end", options.timeZone);
    }

    const parsed = new Date(input);
    if (Number.isNaN(parsed.getTime())) {
      throw new TimeExpressionError(`${input} is not a real date.`, input);
    }
    return parsed;
  }

  if (!input.startsWith("now")) {
    throw new TimeExpressionError(
      `Expected "now" or a date like 2026-01-31, not "${input}".`,
      input,
    );
  }

  let rest = input.slice(3);
  let instant = options.now;

  while (rest.length > 0 && rest[0] !== "/") {
    const match = OFFSET.exec(rest);
    if (!match) {
      throw new TimeExpressionError(`Expected an offset like -6h, not "${rest}".`, rest);
    }
    const [whole, sign, digits, unit] = match;
    const amount = Number(digits) * (sign === "-" ? -1 : 1);
    instant = applyOffset(instant, amount, unit as TimeUnit, options.timeZone);
    rest = rest.slice(whole.length);
  }

  if (rest.startsWith("/")) {
    const unit = rest.slice(1);
    if (!UNITS.has(unit as TimeUnit)) {
      throw new TimeExpressionError(
        `"${unit || "/"}" is not a unit. Use s, m, h, d, w, M or y.`,
        rest,
      );
    }
    instant = applySnap(instant, unit as TimeUnit, edge, options.timeZone);
  }

  return instant;
}

/**
 * Turns a range expression into the two instants a query needs.
 *
 * Throws `TimeExpressionError` rather than returning a fallback range: a chart
 * silently showing the wrong window is worse than one that says it cannot.
 */
export function resolveTimeRange(expression: string, options: ResolveOptions): ResolvedRange {
  const separator = expression.indexOf("..");
  if (separator === -1) {
    throw new TimeExpressionError(
      `Expected two times separated by "..", as in now-6h..now.`,
      expression,
    );
  }

  const from = resolveExpression(expression.slice(0, separator), "start", options);
  const to = resolveExpression(expression.slice(separator + 2), "end", options);

  if (from.getTime() > to.getTime()) {
    throw new TimeExpressionError("The range ends before it starts.", expression);
  }

  return { from, to };
}

/** Whether an expression resolves, without the caller writing a try/catch. */
export function isValidTimeRange(expression: string, options: ResolveOptions): boolean {
  try {
    resolveTimeRange(expression, options);
    return true;
  } catch {
    return false;
  }
}

/* ------------------------------------------------------------------ */
/*  Presets and labelling                                              */
/* ------------------------------------------------------------------ */

export interface TimeRangePreset {
  expression: string;
  label: string;
  /** Groups presets under a heading in the picker. */
  group: string;
}

/** Relative windows first, for debugging; calendar periods after, for reporting. */
export const DEFAULT_PRESETS: TimeRangePreset[] = [
  { expression: "now-5m..now", label: "Last 5 minutes", group: "Relative" },
  { expression: "now-15m..now", label: "Last 15 minutes", group: "Relative" },
  { expression: "now-1h..now", label: "Last hour", group: "Relative" },
  { expression: "now-6h..now", label: "Last 6 hours", group: "Relative" },
  { expression: "now-24h..now", label: "Last 24 hours", group: "Relative" },
  { expression: "now-7d..now", label: "Last 7 days", group: "Relative" },
  { expression: "now-30d..now", label: "Last 30 days", group: "Relative" },
  { expression: "now/d..now/d", label: "Today", group: "Calendar" },
  { expression: "now-1d/d..now-1d/d", label: "Yesterday", group: "Calendar" },
  { expression: "now/w..now/w", label: "This week", group: "Calendar" },
  { expression: "now-1w/w..now-1w/w", label: "Last week", group: "Calendar" },
  { expression: "now/M..now/M", label: "This month", group: "Calendar" },
  { expression: "now-1M/M..now-1M/M", label: "Last month", group: "Calendar" },
];

const RELATIVE = /^now-(\d+)([smhdwMy])(?:\/[smhdwMy])?\.\.now$/;

/**
 * A sentence for the trigger.
 *
 * An unnamed relative range is described in its own terms — "Last 90 minutes" —
 * because showing it as two timestamps throws away what the user chose. Only an
 * absolute range, which has no relative meaning left, is shown as dates.
 */
export function describeTimeRange(
  expression: string,
  options: ResolveOptions & { locale?: string; presets?: TimeRangePreset[] },
): string {
  const presets = options.presets ?? DEFAULT_PRESETS;
  const preset = presets.find((candidate) => candidate.expression === expression);
  if (preset) return preset.label;

  const relative = RELATIVE.exec(expression.trim());
  if (relative) {
    const amount = Number(relative[1]);
    const unit = UNIT_NAMES[relative[2] as TimeUnit];
    return `Last ${String(amount)} ${unit}${amount === 1 ? "" : "s"}`;
  }

  try {
    const { from, to } = resolveTimeRange(expression, options);
    const format = new Intl.DateTimeFormat(options.locale, {
      dateStyle: "medium",
      timeStyle: "short",
      timeZone: options.timeZone,
    });
    return `${format.format(from)} – ${format.format(to)}`;
  } catch {
    return expression;
  }
}

/** The resolved window, spelled out. Pairs with the relative label, not replaces it. */
export function formatResolvedRange(
  range: ResolvedRange,
  options: { locale?: string; timeZone?: string } = {},
): string {
  const format = new Intl.DateTimeFormat(options.locale, {
    dateStyle: "medium",
    timeStyle: "medium",
    timeZone: options.timeZone,
  });
  return `${format.format(range.from)} – ${format.format(range.to)}`;
}

/** Builds the expression for an absolute range chosen on a calendar. */
export function absoluteExpression(from: Date, to: Date): string {
  return `${toLocalIso(from)}..${toLocalIso(to)}`;
}

function toLocalIso(date: Date): string {
  const pad = (value: number, width = 2) => String(value).padStart(width, "0");
  return (
    `${String(date.getFullYear())}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
    `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
  );
}
ui/time-range-picker.tsx
"use client";

import {
  createContext,
  useCallback,
  useContext,
  useId,
  useMemo,
  useState,
  useSyncExternalStore,
  type ComponentPropsWithRef,
  type ReactNode,
} from "react";
import type { DateRange } from "react-day-picker";

import { Button } from "@/components/button";
import { Calendar } from "@/components/calendar";
import { Input } from "@/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

import {
  DEFAULT_PRESETS,
  TimeExpressionError,
  absoluteExpression,
  describeTimeRange,
  formatResolvedRange,
  resolveTimeRange,
  type ResolvedRange,
  type TimeRangePreset,
} from "./time-expression";

/**
 * The control every observability product builds for itself.
 *
 * Grafana, Datadog, Sentry, PostHog, Vercel, Honeycomb, Cloudflare and
 * Amplitude each maintain a bespoke one, and no React package ships it, because
 * it looks like a date picker and is not one — see `time-expression.ts` for the
 * value model that makes the difference.
 *
 * Composed rather than reimplemented: Popover supplies the dialog semantics,
 * Calendar the absolute path, Input the expression entry.
 *
 * Two omissions are deliberate. No timezone picker — that is a 400-entry
 * combobox and a decision an app makes once, so `timeZone` is a prop. And no
 * comparison range; a second window belongs to whatever renders the chart.
 */

interface TimeRangeContextValue {
  value: string;
  setValue: (expression: string) => void;
  now: Date;
  timeZone?: string;
  locale?: string;
  presets: TimeRangePreset[];
  resolved: ResolvedRange | null;
  error: string | null;
  /** False until the clock is known, which on a server render is never. */
  clockReady: boolean;
  close: () => void;
  labelId: string;
}

/** Stands in until the clock is known. Never shown; see `clockReady`. */
const EPOCH = new Date(0);

/**
 * The clock, for callers who did not supply one.
 *
 * `useSyncExternalStore` rather than an effect, because this is the case it
 * exists for: a value the server cannot know. The server snapshot is null, so a
 * server render and the hydration render both commit to nothing clock-dependent
 * and cannot disagree; the real instant arrives in the re-render after.
 *
 * `getSnapshot` has to be referentially stable, so the instant is created once
 * for the page. That means every picker without a `now` prop shares it and none
 * of them advance — which is the whole reason the prop is there.
 */
const subscribeToNothing = () => () => undefined;
let sharedClock: Date | null = null;
const readSharedClock = () => (sharedClock ??= new Date());
const readNoClock = () => null;

const TimeRangeContext = createContext<TimeRangeContextValue | null>(null);

function useTimeRange(component: string): TimeRangeContextValue {
  const context = useContext(TimeRangeContext);
  if (!context) {
    throw new Error(`${component} must be used inside <TimeRange>.`);
  }
  return context;
}

export interface TimeRangeProps {
  /** The expression, e.g. `now-6h..now`. Controlled. */
  value?: string;
  defaultValue?: string;
  onValueChange?: (expression: string) => void;
  /**
   * The instant relative expressions are measured from. Supplied by the
   * consumer so nothing here reads the clock during render. Pass a value that
   * changes when you refresh; a stable one holds the window still between them.
   */
  now?: Date;
  /** IANA zone the snapping happens in. Defaults to the runtime zone. */
  timeZone?: string;
  locale?: string;
  presets?: TimeRangePreset[];
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  children: ReactNode;
}

export function TimeRange({
  value: valueProp,
  defaultValue = "now-6h..now",
  onValueChange,
  now,
  timeZone,
  locale,
  presets = DEFAULT_PRESETS,
  open: openProp,
  onOpenChange,
  children,
}: TimeRangeProps) {
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
  const value = valueProp ?? uncontrolledValue;

  const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
  const open = openProp ?? uncontrolledOpen;

  const labelId = useId();

  // Reading the clock during render is what the `now` prop exists to avoid: the
  // server would render one instant and hydration another, and React would
  // throw out the tree. Until the fallback arrives, nothing clock-dependent is
  // rendered — the relative label, "Last 6 hours", does not need one, which is
  // why the trigger still says something useful in the meantime.
  const fallbackClock = useSyncExternalStore(subscribeToNothing, readSharedClock, readNoClock);

  const clock = now ?? fallbackClock;
  // A placeholder only for expressions that never mention `now`; anything that
  // does is gated on `clock` below rather than resolved against a fake instant.
  const effectiveNow = clock ?? EPOCH;

  const setOpen = useCallback(
    (next: boolean) => {
      if (openProp === undefined) setUncontrolledOpen(next);
      onOpenChange?.(next);
    },
    [openProp, onOpenChange],
  );

  const setValue = useCallback(
    (expression: string) => {
      if (valueProp === undefined) setUncontrolledValue(expression);
      onValueChange?.(expression);
    },
    [valueProp, onValueChange],
  );

  const { resolved, error } = useMemo(() => {
    // Claiming neither a window nor an error before the clock is known: an
    // expression cannot be judged against an instant nobody has read yet.
    if (!clock) return { resolved: null, error: null };
    try {
      return { resolved: resolveTimeRange(value, { now: clock, timeZone }), error: null };
    } catch (thrown) {
      return {
        resolved: null,
        error: thrown instanceof TimeExpressionError ? thrown.message : "Invalid time range.",
      };
    }
  }, [value, clock, timeZone]);

  const context = useMemo<TimeRangeContextValue>(
    () => ({
      value,
      setValue,
      now: effectiveNow,
      timeZone,
      locale,
      presets,
      resolved,
      error,
      clockReady: clock !== null,
      close: () => {
        setOpen(false);
      },
      labelId,
    }),
    [
      value,
      setValue,
      effectiveNow,
      clock,
      timeZone,
      locale,
      presets,
      resolved,
      error,
      setOpen,
      labelId,
    ],
  );

  return (
    <TimeRangeContext.Provider value={context}>
      <Popover open={open} onOpenChange={setOpen}>
        {children}
      </Popover>
    </TimeRangeContext.Provider>
  );
}

function ClockIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-4 opacity-70">
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
      <path d="M12 7v5l3 2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
    </svg>
  );
}

export interface TimeRangeTriggerProps extends ComponentPropsWithRef<"button"> {
  /** Shows the resolved window under the label. Off by default; it is long. */
  showResolved?: boolean;
}

/**
 * The button, named by the range it holds: "Last 6 hours", not two timestamps.
 * The resolved window stays reachable as the title, and as `showResolved`.
 */
export function TimeRangeTrigger({
  className,
  showResolved = false,
  ...props
}: TimeRangeTriggerProps) {
  const { value, now, timeZone, locale, presets, resolved, error } =
    useTimeRange("TimeRangeTrigger");

  const label = describeTimeRange(value, { now, timeZone, locale, presets });
  const resolvedText = resolved ? formatResolvedRange(resolved, { locale, timeZone }) : null;

  return (
    <PopoverTrigger asChild>
      <Button
        data-slot="time-range-trigger"
        variant="outline"
        aria-invalid={error ? true : undefined}
        title={resolvedText ?? undefined}
        className={cn("justify-start gap-2 font-normal", className)}
        {...props}
      >
        <ClockIcon />
        <span className="flex flex-col items-start leading-tight">
          <span>{label}</span>
          {showResolved && resolvedText ? (
            <span className="text-2xs font-normal text-muted-foreground">{resolvedText}</span>
          ) : null}
        </span>
        {/* The window the label stands for, for anyone who cannot see the
            title attribute. Read after the label, not instead of it. */}
        {resolvedText && !showResolved ? (
          <span className="sr-only">{`, ${resolvedText}`}</span>
        ) : null}
      </Button>
    </PopoverTrigger>
  );
}

export interface TimeRangeContentProps extends ComponentPropsWithRef<typeof PopoverContent> {
  /** Names the dialog. */
  label?: string;
}

export function TimeRangeContent({
  className,
  label = "Choose a time range",
  children,
  ...props
}: TimeRangeContentProps) {
  const { labelId } = useTimeRange("TimeRangeContent");

  return (
    <PopoverContent
      data-slot="time-range-content"
      aria-labelledby={labelId}
      align="start"
      className={cn("w-auto max-w-[min(38rem,calc(100vw-2rem))] p-0", className)}
      {...props}
    >
      <h2 id={labelId} className="sr-only">
        {label}
      </h2>
      <div className="flex flex-col gap-0 sm:flex-row sm:items-stretch">{children}</div>
    </PopoverContent>
  );
}

/**
 * The named ranges, which is how most of these are actually used. Buttons
 * rather than a radiogroup: choosing one both selects and dismisses, which is a
 * command, and `aria-pressed` still carries which one matches.
 */
export function TimeRangePresets({
  className,
  ...props
}: Omit<ComponentPropsWithRef<"div">, "children">) {
  const { presets, value, setValue, close } = useTimeRange("TimeRangePresets");

  const groups = useMemo(() => {
    const collected = new Map<string, TimeRangePreset[]>();
    for (const preset of presets) {
      const existing = collected.get(preset.group);
      if (existing) existing.push(preset);
      else collected.set(preset.group, [preset]);
    }
    return [...collected];
  }, [presets]);

  return (
    <div
      data-slot="time-range-presets"
      className={cn(
        "max-h-80 min-w-52 overflow-y-auto border-border p-1.5 sm:border-e",
        className,
      )}
      {...props}
    >
      {groups.map(([group, items]) => (
        <div key={group} role="group" aria-label={group} className="pb-1 last:pb-0">
          <p className="px-2 py-1 text-2xs font-medium tracking-wide text-muted-foreground uppercase">
            {group}
          </p>
          {items.map((preset) => (
            <button
              key={preset.expression}
              type="button"
              aria-pressed={preset.expression === value}
              onClick={() => {
                setValue(preset.expression);
                close();
              }}
              className={cn(
                "flex w-full items-center rounded-md px-2 py-1.5 text-start text-sm transition-colors",
                preset.expression === value
                  ? "bg-accent font-medium text-accent-foreground"
                  : "hover:bg-accent hover:text-accent-foreground",
                focusRing,
              )}
            >
              {preset.label}
            </button>
          ))}
        </div>
      ))}
    </div>
  );
}

/**
 * Raw expression entry, with the resolved window shown as you type — the escape
 * hatch that keeps the presets from being a ceiling.
 *
 * An expression that does not parse says why and is not applied: a chart
 * quietly re-scoping itself to a window nobody asked for is worse than one that
 * refuses.
 */
export function TimeRangeExpression({
  className,
  ...props
}: Omit<ComponentPropsWithRef<"div">, "children">) {
  const { value, setValue, now, timeZone, locale, clockReady, close } =
    useTimeRange("TimeRangeExpression");

  const [draft, setDraft] = useState(value);
  // The expression can change under the input — a preset click, or the
  // consumer setting it. Adjusting during render rather than in an effect keeps
  // the field from showing a stale value for a frame.
  const [lastValue, setLastValue] = useState(value);
  if (value !== lastValue) {
    setLastValue(value);
    setDraft(value);
  }

  const feedbackId = useId();
  const preview = useMemo(() => {
    // Before the clock is known there is nothing to preview and nothing to
    // reject — saying "invalid" here would be a verdict reached without a fact.
    if (!clockReady) return { text: null, error: null as string | null };
    try {
      return {
        text: formatResolvedRange(resolveTimeRange(draft, { now, timeZone }), {
          locale,
          timeZone,
        }),
        error: null as string | null,
      };
    } catch (thrown) {
      return {
        text: null,
        error: thrown instanceof TimeExpressionError ? thrown.message : "Invalid time range.",
      };
    }
  }, [draft, now, timeZone, locale, clockReady]);

  const apply = () => {
    if (preview.error || !clockReady) return;
    setValue(draft.trim());
    close();
  };

  return (
    <div
      data-slot="time-range-expression"
      className={cn(
        "flex flex-col gap-1.5 border-t border-border p-3 sm:border-t-0",
        className,
      )}
      {...props}
    >
      <label htmlFor={feedbackId + "-input"} className="text-xs font-medium">
        Expression
      </label>
      <div className="flex gap-2">
        <Input
          id={feedbackId + "-input"}
          inputSize="sm"
          value={draft}
          spellCheck={false}
          autoComplete="off"
          aria-invalid={preview.error ? true : undefined}
          aria-describedby={feedbackId}
          onChange={(event) => {
            setDraft(event.target.value);
          }}
          onKeyDown={(event) => {
            if (event.key === "Enter") {
              event.preventDefault();
              apply();
            }
          }}
          className="font-mono"
        />
        <Button size="sm" onClick={apply} disabled={Boolean(preview.error) || !clockReady}>
          Apply
        </Button>
      </div>
      {/* One element for both outcomes, so a screen reader hears the error
          replace the preview rather than accumulating two descriptions. */}
      <p
        id={feedbackId}
        className={cn(
          "min-h-4 text-2xs",
          preview.error ? "text-destructive" : "text-muted-foreground",
        )}
      >
        {preview.error ?? preview.text}
      </p>
    </div>
  );
}

/**
 * The absolute path, for a window with no relative meaning. Picking dates
 * writes a concrete expression: "1 to 7 March" does not become relative just
 * because it was chosen here.
 */
export function TimeRangeCalendar({
  className,
  numberOfMonths = 1,
  ...props
}: Omit<ComponentPropsWithRef<"div">, "children"> & { numberOfMonths?: number }) {
  const { setValue, resolved, close } = useTimeRange("TimeRangeCalendar");

  const [selection, setSelection] = useState<DateRange | undefined>(() =>
    resolved ? { from: resolved.from, to: resolved.to } : undefined,
  );

  return (
    <div
      data-slot="time-range-calendar"
      className={cn("border-t border-border p-1 sm:border-s sm:border-t-0", className)}
      {...props}
    >
      <Calendar
        mode="range"
        selected={selection}
        defaultMonth={selection?.from}
        numberOfMonths={numberOfMonths}
        onSelect={(next) => {
          setSelection(next);
          // Only once both ends exist. One click is half an answer, and
          // applying it would show a zero-length range mid-gesture.
          if (next?.from && next.to) {
            setValue(absoluteExpression(startOfDay(next.from), endOfDay(next.to)));
            close();
          }
        }}
      />
    </div>
  );
}

function startOfDay(date: Date): Date {
  const start = new Date(date);
  start.setHours(0, 0, 0, 0);
  return start;
}

/**
 * A day picked as the end of a range means the whole of that day. Taking
 * midnight instead silently drops the last day's data, which is the same
 * off-by-one that `now/d..now/d` has to avoid.
 */
function endOfDay(date: Date): Date {
  const end = new Date(date);
  end.setHours(23, 59, 59, 999);
  return end;
}