From 4a2c89d02328c1c4ef52a96edb94b77ca57f1abc Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Thu, 20 Aug 2026 13:22:05 +0000 Subject: [PATCH 01/17] feat(site/src/components/DateTimeRangePicker): add date-time range picker with quick picks --- .../DateTimeRangePicker.stories.tsx | 269 +++++++++++++ .../DateTimeRangePicker.tsx | 354 ++++++++++++++++++ .../DateTimeRangePicker/dateTimeRange.ts | 131 +++++++ 3 files changed, 754 insertions(+) create mode 100644 site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx create mode 100644 site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx create mode 100644 site/src/components/DateTimeRangePicker/dateTimeRange.ts diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx new file mode 100644 index 0000000000000..7d49691c7500d --- /dev/null +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx @@ -0,0 +1,269 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { DateTimeRangePicker } from "./DateTimeRangePicker"; +import type { DateTimeRange } from "./dateTimeRange"; + +// Matches the design mockup: mid-April 2026. +const fixedNow = new Date(2026, 3, 16, 10, 30, 0); + +const presetValue: DateTimeRange = { type: "preset", preset: "last_15m" }; + +const customValue: DateTimeRange = { + type: "custom", + start: new Date(2026, 3, 10, 0, 0, 0), + end: new Date(2026, 3, 16, 0, 0, 0), +}; + +const meta: Meta = { + title: "components/DateTimeRangePicker", + component: DateTimeRangePicker, + args: { + now: fixedNow, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Closed: Story = { + args: { + value: presetValue, + onChange: () => {}, + }, +}; + +export const ClosedWithCustomRange: Story = { + args: { + value: customValue, + onChange: () => {}, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /April 10-16/ }), + ).toBeInTheDocument(); + }, +}; + +export const OpenShowsOnlyQuickPicks: Story = { + args: { + value: presetValue, + onChange: () => {}, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Custom range" }), + ).toBeInTheDocument(); + }); + const popover = within(screen.getByRole("dialog")); + + expect( + popover.getByRole("button", { name: "Last 15 min" }), + ).toBeInTheDocument(); + expect( + popover.getByRole("button", { name: "Last hour" }), + ).toBeInTheDocument(); + expect(popover.getByRole("button", { name: "Today" })).toBeInTheDocument(); + expect( + popover.getByRole("button", { name: "This week" }), + ).toBeInTheDocument(); + + // The active preset is marked as selected. + expect( + popover.getByRole("button", { name: "Last 15 min" }), + ).toHaveAttribute("aria-pressed", "true"); + + // Calendar and time fields stay hidden until Custom range is chosen. + expect(screen.queryByRole("grid")).toBeNull(); + expect(screen.queryByLabelText("From")).toBeNull(); + }, +}; + +export const SelectQuickPick: Story = { + render: function SelectQuickPickStory() { + const [value, setValue] = useState(presetValue); + return ( + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + const preset = await screen.findByRole("button", { name: "Last hour" }); + await userEvent.click(preset); + + // Selecting a quick pick commits immediately and closes the dropdown. + await waitFor(() => { + expect(screen.queryByRole("button", { name: "Custom range" })).toBeNull(); + }); + expect( + canvas.getByRole("button", { name: /Last hour/ }), + ).toBeInTheDocument(); + }, +}; + +export const CustomRangeExpanded: Story = { + args: { + value: presetValue, + onChange: () => {}, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + const customButton = await screen.findByRole("button", { + name: "Custom range", + }); + await userEvent.click(customButton); + + // Calendar, time fields, and footer appear beside the quick picks. + await waitFor(() => { + expect(screen.getByRole("grid")).toBeInTheDocument(); + }); + expect(screen.getByLabelText("From")).toBeInTheDocument(); + expect(screen.getByLabelText("To")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + + // Apply stays disabled until a date range is selected. + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + }, +}; + +export const ApplyCustomRange: Story = { + render: function ApplyCustomRangeStory() { + const [value, setValue] = useState(presetValue); + return ( + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + await userEvent.click( + await screen.findByRole("button", { name: "Custom range" }), + ); + await waitFor(() => { + expect(screen.getByRole("grid")).toBeInTheDocument(); + }); + + // Pick April 10 through April 16 on the calendar. + await userEvent.click( + screen.getByRole("button", { name: /April 10th, 2026/ }), + ); + await userEvent.click( + screen.getByRole("button", { name: /April 16th, 2026/ }), + ); + + const applyButton = screen.getByRole("button", { name: "Apply" }); + await waitFor(() => { + expect(applyButton).toBeEnabled(); + }); + await userEvent.click(applyButton); + + // The dropdown closes and the trigger shows the compact range. + await waitFor(() => { + expect(screen.queryByRole("grid")).toBeNull(); + }); + expect( + canvas.getByRole("button", { name: /April 10-16/ }), + ).toBeInTheDocument(); + }, +}; + +export const InvalidTimeDisablesApply: Story = { + args: { + value: customValue, + onChange: () => {}, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + // Reopening with a committed custom value restores the expanded panel. + await waitFor(() => { + expect(screen.getByRole("grid")).toBeInTheDocument(); + }); + + const fromInput = screen.getByLabelText("From"); + await userEvent.clear(fromInput); + await userEvent.type(fromInput, "99:99"); + + await waitFor(() => { + expect( + screen.getByText("Enter a valid time, e.g. 09:30:00"), + ).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + + // Fixing the time re-enables Apply. + await userEvent.clear(fromInput); + await userEvent.type(fromInput, "09:15:00"); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Apply" })).toBeEnabled(); + }); + }, +}; + +export const EndBeforeStartShowsError: Story = { + args: { + value: { + type: "custom", + start: new Date(2026, 3, 10, 0, 0, 0), + end: new Date(2026, 3, 10, 0, 0, 0), + }, + onChange: () => {}, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + await waitFor(() => { + expect(screen.getByRole("grid")).toBeInTheDocument(); + }); + + // Same day with To earlier than From is rejected. + const fromInput = screen.getByLabelText("From"); + await userEvent.clear(fromInput); + await userEvent.type(fromInput, "11:00:00"); + + await waitFor(() => { + expect(screen.getByText("End must be after start")).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + }, +}; + +export const CancelDiscardsDraft: Story = { + render: function CancelDiscardsDraftStory() { + const [value, setValue] = useState(presetValue); + return ( + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("button"); + const originalText = trigger.textContent; + + await userEvent.click(trigger); + await userEvent.click( + await screen.findByRole("button", { name: "Custom range" }), + ); + await userEvent.click( + screen.getByRole("button", { name: /April 10th, 2026/ }), + ); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + await waitFor(() => { + expect(screen.queryByRole("grid")).toBeNull(); + }); + expect(canvas.getByRole("button").textContent).toBe(originalText); + }, +}; diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx new file mode 100644 index 0000000000000..d5158994abd61 --- /dev/null +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -0,0 +1,354 @@ +/** + * A date-and-time range picker with quick picks. The dropdown opens as + * a plain list of relative presets; the calendar and time fields stay + * hidden until "Custom range" is chosen. Composed from the project's + * Calendar, Popover, Select, and Button primitives. + * + * Frontend-only for now: the emitted value keeps preset identity (see + * DateTimeRange) so the API contract can be settled separately. + */ + +import { CalendarIcon, CheckIcon, ChevronDownIcon } from "lucide-react"; +import { type FC, useId, useState } from "react"; +import type { DateRange as DayPickerDateRange } from "react-day-picker"; +import { Button, type ButtonProps } from "#/components/Button/Button"; +import { Calendar } from "#/components/Calendar/Calendar"; +import { Input } from "#/components/Input/Input"; +import { Label } from "#/components/Label/Label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "#/components/Popover/Popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; +import { cn } from "#/utils/cn"; +import { + combineDateTime, + type DateTimeRange, + DEFAULT_QUICK_PRESETS, + formatCustomLabel, + type Meridiem, + parseClockTime, + type QuickPreset, + toClockFields, +} from "./dateTimeRange"; + +interface DateTimeRangePickerProps { + value: DateTimeRange; + onChange: (value: DateTimeRange) => void; + now?: Date; + presets?: QuickPreset[]; + size?: ButtonProps["size"]; +} + +const INVALID_TIME_MESSAGE = "Enter a valid time, e.g. 09:30:00"; +const RANGE_ORDER_MESSAGE = "End must be after start"; + +interface TimeFieldsState { + from: string; + fromMeridiem: Meridiem; + to: string; + toMeridiem: Meridiem; +} + +const midnightFields = (): TimeFieldsState => ({ + from: "12:00:00", + fromMeridiem: "AM", + to: "12:00:00", + toMeridiem: "AM", +}); + +export const DateTimeRangePicker: FC = ({ + value, + onChange, + now, + presets, + size = "sm", +}) => { + const currentTime = now ?? new Date(); + const quickPresets = presets ?? DEFAULT_QUICK_PRESETS; + const [open, setOpen] = useState(false); + const [customExpanded, setCustomExpanded] = useState(false); + const [selection, setSelection] = useState(); + const [timeFields, setTimeFields] = useState(midnightFields); + const fromTimeId = useId(); + const toTimeId = useId(); + + const handleOpenChange = (next: boolean) => { + if (next) { + // Rebuild the draft from the committed value each time the + // dropdown opens so a previously abandoned draft never leaks in. + if (value.type === "custom") { + setCustomExpanded(true); + setSelection({ from: value.start, to: value.end }); + const from = toClockFields(value.start); + const to = toClockFields(value.end); + setTimeFields({ + from: from.time, + fromMeridiem: from.meridiem, + to: to.time, + toMeridiem: to.meridiem, + }); + } else { + setCustomExpanded(false); + setSelection(undefined); + setTimeFields(midnightFields()); + } + } + setOpen(next); + }; + + const handlePreset = (preset: QuickPreset) => { + onChange({ type: "preset", preset: preset.id }); + setOpen(false); + }; + + const parsedFrom = parseClockTime(timeFields.from); + const parsedTo = parseClockTime(timeFields.to); + const fromTimeError = parsedFrom === null ? INVALID_TIME_MESSAGE : null; + const toTimeError = parsedTo === null ? INVALID_TIME_MESSAGE : null; + + const draftStart = + selection?.from && parsedFrom + ? combineDateTime(selection.from, parsedFrom, timeFields.fromMeridiem) + : null; + const draftEnd = + selection?.from && parsedTo + ? combineDateTime( + selection.to ?? selection.from, + parsedTo, + timeFields.toMeridiem, + ) + : null; + const rangeError = + draftStart && draftEnd && draftEnd.getTime() <= draftStart.getTime() + ? RANGE_ORDER_MESSAGE + : null; + const canApply = + draftStart !== null && draftEnd !== null && rangeError === null; + + const apply = () => { + if (draftStart && draftEnd) { + onChange({ type: "custom", start: draftStart, end: draftEnd }); + setOpen(false); + } + }; + + const activePreset = + value.type === "preset" + ? quickPresets.find((preset) => preset.id === value.preset) + : undefined; + const triggerLabel = + value.type === "custom" + ? formatCustomLabel(value.start, value.end) + : (activePreset?.label ?? "Select range"); + + return ( + + + + + e.preventDefault()} + > +
+ {/* Quick picks. This list is the entire dropdown until the + user expands the custom range panel. */} +
+ {quickPresets.map((preset) => ( + handlePreset(preset)} + /> + ))} + setCustomExpanded(true)} + /> +
+ + {customExpanded && ( +
+ + + {/* From/To time fields */} +
+ + setTimeFields((fields) => ({ ...fields, from: time })) + } + onMeridiemChange={(meridiem) => + setTimeFields((fields) => ({ + ...fields, + fromMeridiem: meridiem, + })) + } + /> + + setTimeFields((fields) => ({ ...fields, to: time })) + } + onMeridiemChange={(meridiem) => + setTimeFields((fields) => ({ + ...fields, + toMeridiem: meridiem, + })) + } + /> +
+ + {/* Apply footer */} +
+ {rangeError !== null && ( + + {rangeError} + + )} + + +
+
+ )} +
+
+
+ ); +}; + +interface QuickPickButtonProps { + label: string; + selected: boolean; + onClick: () => void; +} + +const QuickPickButton: FC = ({ + label, + selected, + onClick, +}) => ( + +); + +interface TimeRowProps { + id: string; + label: string; + time: string; + meridiem: Meridiem; + error: string | null; + onTimeChange: (time: string) => void; + onMeridiemChange: (meridiem: Meridiem) => void; +} + +const TimeRow: FC = ({ + id, + label, + time, + meridiem, + error, + onTimeChange, + onMeridiemChange, +}) => ( +
+
+ + onTimeChange(event.target.value)} + /> + +
+ {error !== null && ( + {error} + )} +
+); diff --git a/site/src/components/DateTimeRangePicker/dateTimeRange.ts b/site/src/components/DateTimeRangePicker/dateTimeRange.ts new file mode 100644 index 0000000000000..81a2fcb523cd5 --- /dev/null +++ b/site/src/components/DateTimeRangePicker/dateTimeRange.ts @@ -0,0 +1,131 @@ +import dayjs from "dayjs"; + +/** + * A quick-pick option shown at the top of the picker dropdown. Presets + * are relative to "now" so consumers can re-resolve them on refresh + * instead of persisting frozen timestamps. + */ +export interface QuickPreset { + id: string; + label: string; + range: (now: Date) => { start: Date; end: Date }; +} + +/** + * The committed picker value. Preset selections keep their identity so + * the trigger can keep rendering the preset label and callers can + * re-evaluate the window over time; custom selections carry absolute + * boundaries chosen from the calendar and time fields. + */ +export type DateTimeRange = + | { type: "preset"; preset: string } + | { type: "custom"; start: Date; end: Date }; + +export const DEFAULT_QUICK_PRESETS: QuickPreset[] = [ + { + id: "last_15m", + label: "Last 15 min", + range: (now) => ({ + start: dayjs(now).subtract(15, "minute").toDate(), + end: now, + }), + }, + { + id: "last_1h", + label: "Last hour", + range: (now) => ({ + start: dayjs(now).subtract(1, "hour").toDate(), + end: now, + }), + }, + { + id: "today", + label: "Today", + range: (now) => ({ start: dayjs(now).startOf("day").toDate(), end: now }), + }, + { + id: "this_week", + label: "This week", + range: (now) => ({ start: dayjs(now).startOf("week").toDate(), end: now }), + }, +]; + +export type Meridiem = "AM" | "PM"; + +export interface ClockTime { + /** 12-hour clock hours, 1-12. */ + hours: number; + minutes: number; + seconds: number; +} + +const TIME_PATTERN = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/; + +/** + * Parses a 12-hour clock string such as "12:00:00", "9:30", or + * "09:30:15". Seconds are optional and default to zero. Returns null + * for anything that is not a valid 12-hour time. + */ +export const parseClockTime = (text: string): ClockTime | null => { + const match = TIME_PATTERN.exec(text.trim()); + if (!match) { + return null; + } + const hours = Number(match[1]); + if (hours < 1 || hours > 12) { + return null; + } + return { + hours, + minutes: Number(match[2]), + seconds: match[3] !== undefined ? Number(match[3]) : 0, + }; +}; + +/** Combines a calendar day with a 12-hour clock time into a local Date. */ +export const combineDateTime = ( + date: Date, + time: ClockTime, + meridiem: Meridiem, +): Date => { + const hours24 = (time.hours % 12) + (meridiem === "PM" ? 12 : 0); + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + hours24, + time.minutes, + time.seconds, + ); +}; + +/** Splits a Date into the picker's time text and AM/PM select values. */ +export const toClockFields = ( + date: Date, +): { time: string; meridiem: Meridiem } => { + const d = dayjs(date); + return { + time: d.format("hh:mm:ss"), + meridiem: d.hour() < 12 ? "AM" : "PM", + }; +}; + +/** + * Summarizes a custom range for the trigger button, favoring the most + * compact form: "April 12", "April 10-16", "Mar 28 - Apr 2", or a + * fully qualified pair when the years differ. + */ +export const formatCustomLabel = (start: Date, end: Date): string => { + const from = dayjs(start); + const to = dayjs(end); + if (from.isSame(to, "day")) { + return from.format("MMMM D"); + } + if (from.isSame(to, "month")) { + return `${from.format("MMMM D")}-${to.format("D")}`; + } + if (from.isSame(to, "year")) { + return `${from.format("MMM D")} - ${to.format("MMM D")}`; + } + return `${from.format("MMM D, YYYY")} - ${to.format("MMM D, YYYY")}`; +}; From 9cc1018b0b7e5152097e0f6a912906c969f1c956 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Thu, 20 Aug 2026 14:48:30 +0000 Subject: [PATCH 02/17] fix(site/src/components/DateTimeRangePicker): drop unused ClockTime export --- site/src/components/DateTimeRangePicker/dateTimeRange.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/DateTimeRangePicker/dateTimeRange.ts b/site/src/components/DateTimeRangePicker/dateTimeRange.ts index 81a2fcb523cd5..f8db8f0627e0e 100644 --- a/site/src/components/DateTimeRangePicker/dateTimeRange.ts +++ b/site/src/components/DateTimeRangePicker/dateTimeRange.ts @@ -52,7 +52,7 @@ export const DEFAULT_QUICK_PRESETS: QuickPreset[] = [ export type Meridiem = "AM" | "PM"; -export interface ClockTime { +interface ClockTime { /** 12-hour clock hours, 1-12. */ hours: number; minutes: number; From 72e31f004b2a33074348f949e806c37136a8a909 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Thu, 20 Aug 2026 15:06:19 +0000 Subject: [PATCH 03/17] refactor(site/src/components/DateTimeRangePicker): emit resolved dates instead of preset tokens --- .../DateTimeRangePicker.stories.tsx | 18 +++++++------ .../DateTimeRangePicker.tsx | 25 +++++++++---------- .../DateTimeRangePicker/dateTimeRange.ts | 17 +++++++------ 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx index 7d49691c7500d..de0d994e5da06 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx @@ -2,15 +2,18 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; import { expect, screen, userEvent, waitFor, within } from "storybook/test"; import { DateTimeRangePicker } from "./DateTimeRangePicker"; -import type { DateTimeRange } from "./dateTimeRange"; +import type { DateTimeRangeValue } from "./dateTimeRange"; // Matches the design mockup: mid-April 2026. const fixedNow = new Date(2026, 3, 16, 10, 30, 0); -const presetValue: DateTimeRange = { type: "preset", preset: "last_15m" }; +const presetValue: DateTimeRangeValue = { + start: new Date(2026, 3, 16, 10, 15, 0), + end: fixedNow, + preset: "last_15m", +}; -const customValue: DateTimeRange = { - type: "custom", +const customValue: DateTimeRangeValue = { start: new Date(2026, 3, 10, 0, 0, 0), end: new Date(2026, 3, 16, 0, 0, 0), }; @@ -86,7 +89,7 @@ export const OpenShowsOnlyQuickPicks: Story = { export const SelectQuickPick: Story = { render: function SelectQuickPickStory() { - const [value, setValue] = useState(presetValue); + const [value, setValue] = useState(presetValue); return ( ); @@ -137,7 +140,7 @@ export const CustomRangeExpanded: Story = { export const ApplyCustomRange: Story = { render: function ApplyCustomRangeStory() { - const [value, setValue] = useState(presetValue); + const [value, setValue] = useState(presetValue); return ( ); @@ -214,7 +217,6 @@ export const InvalidTimeDisablesApply: Story = { export const EndBeforeStartShowsError: Story = { args: { value: { - type: "custom", start: new Date(2026, 3, 10, 0, 0, 0), end: new Date(2026, 3, 10, 0, 0, 0), }, @@ -242,7 +244,7 @@ export const EndBeforeStartShowsError: Story = { export const CancelDiscardsDraft: Story = { render: function CancelDiscardsDraftStory() { - const [value, setValue] = useState(presetValue); + const [value, setValue] = useState(presetValue); return ( ); diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx index d5158994abd61..eb45d5e8f5dfc 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -30,7 +30,7 @@ import { import { cn } from "#/utils/cn"; import { combineDateTime, - type DateTimeRange, + type DateTimeRangeValue, DEFAULT_QUICK_PRESETS, formatCustomLabel, type Meridiem, @@ -40,8 +40,8 @@ import { } from "./dateTimeRange"; interface DateTimeRangePickerProps { - value: DateTimeRange; - onChange: (value: DateTimeRange) => void; + value: DateTimeRangeValue; + onChange: (value: DateTimeRangeValue) => void; now?: Date; presets?: QuickPreset[]; size?: ButtonProps["size"]; @@ -84,7 +84,7 @@ export const DateTimeRangePicker: FC = ({ if (next) { // Rebuild the draft from the committed value each time the // dropdown opens so a previously abandoned draft never leaks in. - if (value.type === "custom") { + if (value.preset === undefined) { setCustomExpanded(true); setSelection({ from: value.start, to: value.end }); const from = toClockFields(value.start); @@ -105,7 +105,8 @@ export const DateTimeRangePicker: FC = ({ }; const handlePreset = (preset: QuickPreset) => { - onChange({ type: "preset", preset: preset.id }); + const { start, end } = preset.range(currentTime); + onChange({ start, end, preset: preset.id }); setOpen(false); }; @@ -135,19 +136,17 @@ export const DateTimeRangePicker: FC = ({ const apply = () => { if (draftStart && draftEnd) { - onChange({ type: "custom", start: draftStart, end: draftEnd }); + onChange({ start: draftStart, end: draftEnd }); setOpen(false); } }; const activePreset = - value.type === "preset" - ? quickPresets.find((preset) => preset.id === value.preset) - : undefined; + value.preset === undefined + ? undefined + : quickPresets.find((preset) => preset.id === value.preset); const triggerLabel = - value.type === "custom" - ? formatCustomLabel(value.start, value.end) - : (activePreset?.label ?? "Select range"); + activePreset?.label ?? formatCustomLabel(value.start, value.end); return ( @@ -194,7 +193,7 @@ export const DateTimeRangePicker: FC = ({ selected={selection} onSelect={setSelection} defaultMonth={ - value.type === "custom" ? value.start : currentTime + value.preset === undefined ? value.start : currentTime } today={currentTime} /> diff --git a/site/src/components/DateTimeRangePicker/dateTimeRange.ts b/site/src/components/DateTimeRangePicker/dateTimeRange.ts index f8db8f0627e0e..974040b6ee7ca 100644 --- a/site/src/components/DateTimeRangePicker/dateTimeRange.ts +++ b/site/src/components/DateTimeRangePicker/dateTimeRange.ts @@ -12,14 +12,17 @@ export interface QuickPreset { } /** - * The committed picker value. Preset selections keep their identity so - * the trigger can keep rendering the preset label and callers can - * re-evaluate the window over time; custom selections carry absolute - * boundaries chosen from the calendar and time fields. + * The committed picker value. Boundaries are always concrete local + * Dates; send them to the API in UTC (Date.toISOString). Quick picks + * resolve to dates at selection time and record their id purely so the + * trigger can keep rendering the preset label; the id is display + * metadata and must not be sent to the backend. */ -export type DateTimeRange = - | { type: "preset"; preset: string } - | { type: "custom"; start: Date; end: Date }; +export interface DateTimeRangeValue { + start: Date; + end: Date; + preset?: string; +} export const DEFAULT_QUICK_PRESETS: QuickPreset[] = [ { From 33726b9315b3d5591cab277ac115b016c71d6b3a Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Thu, 20 Aug 2026 15:22:39 +0000 Subject: [PATCH 04/17] fix(site/src/components/DateTimeRangePicker): address self-review findings Radiogroup semantics with roving focus for quick picks, blur-gated time validation with aria-describedby error linking, future dates disabled, Apply requires a complete range, and intra-day custom labels include times. --- .../DateTimeRangePicker.stories.tsx | 88 +++++++++++---- .../DateTimeRangePicker.tsx | 100 +++++++++++++++--- .../DateTimeRangePicker/dateTimeRange.ts | 7 +- 3 files changed, 154 insertions(+), 41 deletions(-) diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx index de0d994e5da06..cf22d80bcaae0 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx @@ -60,30 +60,39 @@ export const OpenShowsOnlyQuickPicks: Story = { await waitFor(() => { expect( - screen.getByRole("button", { name: "Custom range" }), + screen.getByRole("radio", { name: "Custom range" }), ).toBeInTheDocument(); }); - const popover = within(screen.getByRole("dialog")); + const quickPicks = within(screen.getByRole("radiogroup")); expect( - popover.getByRole("button", { name: "Last 15 min" }), + quickPicks.getByRole("radio", { name: "Last 15 min" }), ).toBeInTheDocument(); expect( - popover.getByRole("button", { name: "Last hour" }), + quickPicks.getByRole("radio", { name: "Last hour" }), ).toBeInTheDocument(); - expect(popover.getByRole("button", { name: "Today" })).toBeInTheDocument(); expect( - popover.getByRole("button", { name: "This week" }), + quickPicks.getByRole("radio", { name: "Today" }), + ).toBeInTheDocument(); + expect( + quickPicks.getByRole("radio", { name: "This week" }), ).toBeInTheDocument(); - - // The active preset is marked as selected. expect( - popover.getByRole("button", { name: "Last 15 min" }), - ).toHaveAttribute("aria-pressed", "true"); + quickPicks.getByRole("radio", { name: "Last 15 min" }), + ).toBeChecked(); // Calendar and time fields stay hidden until Custom range is chosen. expect(screen.queryByRole("grid")).toBeNull(); expect(screen.queryByLabelText("From")).toBeNull(); + + // Arrow keys move focus through the radiogroup. + quickPicks.getByRole("radio", { name: "Last 15 min" }).focus(); + await userEvent.keyboard("{ArrowDown}"); + expect(quickPicks.getByRole("radio", { name: "Last hour" })).toHaveFocus(); + await userEvent.keyboard("{ArrowUp}{ArrowUp}"); + expect( + quickPicks.getByRole("radio", { name: "Custom range" }), + ).toHaveFocus(); }, }; @@ -98,12 +107,12 @@ export const SelectQuickPick: Story = { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); - const preset = await screen.findByRole("button", { name: "Last hour" }); + const preset = await screen.findByRole("radio", { name: "Last hour" }); await userEvent.click(preset); // Selecting a quick pick commits immediately and closes the dropdown. await waitFor(() => { - expect(screen.queryByRole("button", { name: "Custom range" })).toBeNull(); + expect(screen.queryByRole("radio", { name: "Custom range" })).toBeNull(); }); expect( canvas.getByRole("button", { name: /Last hour/ }), @@ -120,10 +129,10 @@ export const CustomRangeExpanded: Story = { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); - const customButton = await screen.findByRole("button", { + const customRadio = await screen.findByRole("radio", { name: "Custom range", }); - await userEvent.click(customButton); + await userEvent.click(customRadio); // Calendar, time fields, and footer appear beside the quick picks. await waitFor(() => { @@ -133,8 +142,13 @@ export const CustomRangeExpanded: Story = { expect(screen.getByLabelText("To")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); - // Apply stays disabled until a date range is selected. + // Apply stays disabled until a full date range is selected. expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + + // Future dates cannot be selected. + expect( + screen.getByRole("button", { name: /April 17th, 2026/ }), + ).toBeDisabled(); }, }; @@ -150,21 +164,28 @@ export const ApplyCustomRange: Story = { await userEvent.click(canvas.getByRole("button")); await userEvent.click( - await screen.findByRole("button", { name: "Custom range" }), + await screen.findByRole("radio", { name: "Custom range" }), ); await waitFor(() => { expect(screen.getByRole("grid")).toBeInTheDocument(); }); - // Pick April 10 through April 16 on the calendar. + const applyButton = screen.getByRole("button", { name: "Apply" }); await userEvent.click( screen.getByRole("button", { name: /April 10th, 2026/ }), ); + // One boundary is not a range yet. + expect(applyButton).toBeDisabled(); await userEvent.click( screen.getByRole("button", { name: /April 16th, 2026/ }), ); - const applyButton = screen.getByRole("button", { name: "Apply" }); + // Set the To boundary to noon via the meridiem select. + await userEvent.click( + screen.getByRole("combobox", { name: "To AM or PM" }), + ); + await userEvent.click(await screen.findByRole("option", { name: "PM" })); + await waitFor(() => { expect(applyButton).toBeEnabled(); }); @@ -198,12 +219,18 @@ export const InvalidTimeDisablesApply: Story = { await userEvent.clear(fromInput); await userEvent.type(fromInput, "99:99"); + // The message waits for blur, but Apply is blocked immediately. + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + await userEvent.tab(); await waitFor(() => { expect( screen.getByText("Enter a valid time, e.g. 09:30:00"), ).toBeInTheDocument(); }); - expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + expect(fromInput).toHaveAccessibleDescription( + "Enter a valid time, e.g. 09:30:00", + ); // Fixing the time re-enables Apply. await userEvent.clear(fromInput); @@ -217,8 +244,8 @@ export const InvalidTimeDisablesApply: Story = { export const EndBeforeStartShowsError: Story = { args: { value: { - start: new Date(2026, 3, 10, 0, 0, 0), - end: new Date(2026, 3, 10, 0, 0, 0), + start: new Date(2026, 3, 10, 9, 0, 0), + end: new Date(2026, 3, 10, 10, 0, 0), }, onChange: () => {}, }, @@ -230,7 +257,6 @@ export const EndBeforeStartShowsError: Story = { expect(screen.getByRole("grid")).toBeInTheDocument(); }); - // Same day with To earlier than From is rejected. const fromInput = screen.getByLabelText("From"); await userEvent.clear(fromInput); await userEvent.type(fromInput, "11:00:00"); @@ -256,7 +282,7 @@ export const CancelDiscardsDraft: Story = { await userEvent.click(trigger); await userEvent.click( - await screen.findByRole("button", { name: "Custom range" }), + await screen.findByRole("radio", { name: "Custom range" }), ); await userEvent.click( screen.getByRole("button", { name: /April 10th, 2026/ }), @@ -269,3 +295,19 @@ export const CancelDiscardsDraft: Story = { expect(canvas.getByRole("button").textContent).toBe(originalText); }, }; + +export const IntraDayTriggerLabel: Story = { + args: { + value: { + start: new Date(2026, 3, 12, 9, 0, 0), + end: new Date(2026, 3, 12, 11, 0, 0), + }, + onChange: () => {}, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /April 12, 9:00 AM - 11:00 AM/ }), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx index eb45d5e8f5dfc..fa241f8df94c9 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -4,12 +4,12 @@ * hidden until "Custom range" is chosen. Composed from the project's * Calendar, Popover, Select, and Button primitives. * - * Frontend-only for now: the emitted value keeps preset identity (see - * DateTimeRange) so the API contract can be settled separately. + * Frontend-only: the emitted value is always resolved dates (see + * DateTimeRangeValue); consumers convert to UTC at the API boundary. */ import { CalendarIcon, CheckIcon, ChevronDownIcon } from "lucide-react"; -import { type FC, useId, useState } from "react"; +import { type FC, type KeyboardEvent, useId, useState } from "react"; import type { DateRange as DayPickerDateRange } from "react-day-picker"; import { Button, type ButtonProps } from "#/components/Button/Button"; import { Calendar } from "#/components/Calendar/Calendar"; @@ -53,15 +53,19 @@ const RANGE_ORDER_MESSAGE = "End must be after start"; interface TimeFieldsState { from: string; fromMeridiem: Meridiem; + fromTouched: boolean; to: string; toMeridiem: Meridiem; + toTouched: boolean; } const midnightFields = (): TimeFieldsState => ({ from: "12:00:00", fromMeridiem: "AM", + fromTouched: false, to: "12:00:00", toMeridiem: "AM", + toTouched: false, }); export const DateTimeRangePicker: FC = ({ @@ -92,8 +96,10 @@ export const DateTimeRangePicker: FC = ({ setTimeFields({ from: from.time, fromMeridiem: from.meridiem, + fromTouched: false, to: to.time, toMeridiem: to.meridiem, + toTouched: false, }); } else { setCustomExpanded(false); @@ -110,22 +116,44 @@ export const DateTimeRangePicker: FC = ({ setOpen(false); }; + // Roving focus for the quick-pick radiogroup: arrows move between + // options, Tab leaves the group from the selected item. + const handleQuickPickKeyDown = (event: KeyboardEvent) => { + const isNext = event.key === "ArrowDown" || event.key === "ArrowRight"; + const isPrevious = event.key === "ArrowUp" || event.key === "ArrowLeft"; + if (!isNext && !isPrevious) { + return; + } + const radios = Array.from( + event.currentTarget.querySelectorAll('[role="radio"]'), + ); + const current = radios.findIndex((radio) => radio === event.target); + if (current === -1) { + return; + } + const offset = isNext ? 1 : radios.length - 1; + radios[(current + offset) % radios.length]?.focus(); + event.preventDefault(); + }; + const parsedFrom = parseClockTime(timeFields.from); const parsedTo = parseClockTime(timeFields.to); - const fromTimeError = parsedFrom === null ? INVALID_TIME_MESSAGE : null; - const toTimeError = parsedTo === null ? INVALID_TIME_MESSAGE : null; + // Invalid text always blocks Apply, but the message waits for blur so + // it does not flash while a partially typed time is still in flight. + const fromTimeError = + timeFields.fromTouched && parsedFrom === null ? INVALID_TIME_MESSAGE : null; + const toTimeError = + timeFields.toTouched && parsedTo === null ? INVALID_TIME_MESSAGE : null; const draftStart = selection?.from && parsedFrom ? combineDateTime(selection.from, parsedFrom, timeFields.fromMeridiem) : null; + // A range needs both boundaries; a single calendar click keeps Apply + // disabled instead of silently committing a one-day range. const draftEnd = - selection?.from && parsedTo - ? combineDateTime( - selection.to ?? selection.from, - parsedTo, - timeFields.toMeridiem, - ) + selection?.to && parsedTo + ? combineDateTime(selection.to, parsedTo, timeFields.toMeridiem) : null; const rangeError = draftStart && draftEnd && draftEnd.getTime() <= draftStart.getTime() @@ -148,6 +176,13 @@ export const DateTimeRangePicker: FC = ({ const triggerLabel = activePreset?.label ?? formatCustomLabel(value.start, value.end); + const selectedQuickPickIndex = customExpanded + ? quickPresets.length + : Math.max( + 0, + quickPresets.findIndex((preset) => preset.id === activePreset?.id), + ); + return ( @@ -165,23 +200,30 @@ export const DateTimeRangePicker: FC = ({
{/* Quick picks. This list is the entire dropdown until the user expands the custom range panel. */} + {/* biome-ignore lint/a11y/useSemanticElements: native radio + inputs cannot host the check-icon rows this design needs. */}
- {quickPresets.map((preset) => ( + {quickPresets.map((preset, index) => ( handlePreset(preset)} /> ))} setCustomExpanded(true)} />
@@ -195,6 +237,7 @@ export const DateTimeRangePicker: FC = ({ defaultMonth={ value.preset === undefined ? value.start : currentTime } + disabled={{ after: currentTime }} today={currentTime} /> @@ -209,6 +252,12 @@ export const DateTimeRangePicker: FC = ({ onTimeChange={(time) => setTimeFields((fields) => ({ ...fields, from: time })) } + onBlur={() => + setTimeFields((fields) => ({ + ...fields, + fromTouched: true, + })) + } onMeridiemChange={(meridiem) => setTimeFields((fields) => ({ ...fields, @@ -225,6 +274,9 @@ export const DateTimeRangePicker: FC = ({ onTimeChange={(time) => setTimeFields((fields) => ({ ...fields, to: time })) } + onBlur={() => + setTimeFields((fields) => ({ ...fields, toTouched: true })) + } onMeridiemChange={(meridiem) => setTimeFields((fields) => ({ ...fields, @@ -237,7 +289,10 @@ export const DateTimeRangePicker: FC = ({ {/* Apply footer */}
{rangeError !== null && ( - + {rangeError} )} @@ -263,17 +318,22 @@ export const DateTimeRangePicker: FC = ({ interface QuickPickButtonProps { label: string; selected: boolean; + tabIndex: number; onClick: () => void; } const QuickPickButton: FC = ({ label, selected, + tabIndex, onClick, }) => ( + // biome-ignore lint/a11y/useSemanticElements: see radiogroup note above.
); diff --git a/site/src/components/DateTimeRangePicker/dateTimeRange.ts b/site/src/components/DateTimeRangePicker/dateTimeRange.ts index 974040b6ee7ca..456081a1877b5 100644 --- a/site/src/components/DateTimeRangePicker/dateTimeRange.ts +++ b/site/src/components/DateTimeRangePicker/dateTimeRange.ts @@ -115,14 +115,15 @@ export const toClockFields = ( /** * Summarizes a custom range for the trigger button, favoring the most - * compact form: "April 12", "April 10-16", "Mar 28 - Apr 2", or a - * fully qualified pair when the years differ. + * compact form: "April 12, 9:00 AM - 11:00 AM" for intra-day ranges, + * "April 10-16", "Mar 28 - Apr 2", or a fully qualified pair when the + * years differ. */ export const formatCustomLabel = (start: Date, end: Date): string => { const from = dayjs(start); const to = dayjs(end); if (from.isSame(to, "day")) { - return from.format("MMMM D"); + return `${from.format("MMMM D, h:mm A")} - ${to.format("h:mm A")}`; } if (from.isSame(to, "month")) { return `${from.format("MMMM D")}-${to.format("D")}`; From a83ecc607ff980fe2f854e713b23633ab727964b Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Thu, 20 Aug 2026 15:23:34 +0000 Subject: [PATCH 05/17] fix(site/src/components/DateTimeRangePicker): resolve biome warnings --- .../components/DateTimeRangePicker/DateTimeRangePicker.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx index fa241f8df94c9..011c11c6c3cfc 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -127,7 +127,8 @@ export const DateTimeRangePicker: FC = ({ const radios = Array.from( event.currentTarget.querySelectorAll('[role="radio"]'), ); - const current = radios.findIndex((radio) => radio === event.target); + const current = + event.target instanceof HTMLElement ? radios.indexOf(event.target) : -1; if (current === -1) { return; } @@ -200,8 +201,6 @@ export const DateTimeRangePicker: FC = ({
{/* Quick picks. This list is the entire dropdown until the user expands the custom range panel. */} - {/* biome-ignore lint/a11y/useSemanticElements: native radio - inputs cannot host the check-icon rows this design needs. */}
= ({ tabIndex, onClick, }) => ( - // biome-ignore lint/a11y/useSemanticElements: see radiogroup note above.
@@ -272,7 +295,11 @@ export const DateTimeRangePicker: FC = ({ time={timeFields.from} meridiem={timeFields.fromMeridiem} invalid={fromTimeError !== null} - describedBy={fromTimeError !== null ? errorId : undefined} + describedBy={ + fromTimeError !== null && visibleError === fromTimeError + ? errorId + : undefined + } onTimeChange={(time) => setTimeFields((fields) => ({ ...fields, from: time })) } @@ -296,7 +323,7 @@ export const DateTimeRangePicker: FC = ({ meridiem={timeFields.toMeridiem} invalid={toTimeError !== null} describedBy={ - toTimeError !== null && fromTimeError === null + toTimeError !== null && visibleError === toTimeError ? errorId : undefined } From e135a060fb078ebe8697c12b406ad077a2faf504 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Thu, 20 Aug 2026 21:08:08 +0000 Subject: [PATCH 12/17] chore(site/src/components/DateTimeRangePicker): trim non-essential comments --- .../DateTimeRangePicker.tsx | 18 +++------------- .../DateTimeRangePicker/dateTimeRange.ts | 21 ++----------------- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx index a2697faea4667..34e7e483185a8 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -1,11 +1,6 @@ /** - * A date-and-time range picker with quick picks. The dropdown opens as - * a plain list of relative presets; the calendar and time fields stay - * hidden until "Custom range" is chosen. Composed from the project's - * Calendar, Popover, Select, and Button primitives. - * - * Frontend-only: the emitted value is always resolved dates (see - * DateTimeRangeValue); consumers convert to UTC at the API boundary. + * A date-and-time range picker with quick picks. The calendar and time fields stay + * hidden until "Custom range" is chosen. */ import { CalendarIcon, CheckIcon, ChevronDownIcon } from "lucide-react"; @@ -124,8 +119,7 @@ export const DateTimeRangePicker: FC = ({ setOpen(false); }; - // Roving focus for the quick-pick radiogroup: arrows move between - // options, Tab leaves the group from the selected item. + // Roving focus for the quick-pick radiogroup. const handleQuickPickKeyDown = (event: KeyboardEvent) => { const isNext = event.key === "ArrowDown" || event.key === "ArrowRight"; const isPrevious = event.key === "ArrowUp" || event.key === "ArrowLeft"; @@ -147,8 +141,6 @@ export const DateTimeRangePicker: FC = ({ const parsedFrom = parseClockTime(timeFields.from); const parsedTo = parseClockTime(timeFields.to); - // Invalid text always blocks Apply, but the message waits for blur so - // it does not flash while a partially typed time is still in flight. const fromTimeError = timeFields.fromTouched && parsedFrom === null ? INVALID_TIME_MESSAGE : null; const toTimeError = @@ -158,8 +150,6 @@ export const DateTimeRangePicker: FC = ({ selection?.from && parsedFrom ? combineDateTime(selection.from, parsedFrom, timeFields.fromMeridiem) : null; - // A lone calendar click is a valid single-day range: the To boundary - // falls back to the From day until a second day is picked. const draftEnd = selection?.from && parsedTo ? combineDateTime( @@ -175,8 +165,6 @@ export const DateTimeRangePicker: FC = ({ const canApply = draftStart !== null && draftEnd !== null && rangeError === null; - // A single message overlaid on the calendar keeps the popover size - // stable; inline errors would grow the panel and shift the layout. const errorMessage = fromTimeError ?? toTimeError ?? rangeError; // Toast-style dismissal: each message hides after a timeout but is diff --git a/site/src/components/DateTimeRangePicker/dateTimeRange.ts b/site/src/components/DateTimeRangePicker/dateTimeRange.ts index 456081a1877b5..323f9c7780b73 100644 --- a/site/src/components/DateTimeRangePicker/dateTimeRange.ts +++ b/site/src/components/DateTimeRangePicker/dateTimeRange.ts @@ -1,23 +1,11 @@ import dayjs from "dayjs"; -/** - * A quick-pick option shown at the top of the picker dropdown. Presets - * are relative to "now" so consumers can re-resolve them on refresh - * instead of persisting frozen timestamps. - */ export interface QuickPreset { id: string; label: string; range: (now: Date) => { start: Date; end: Date }; } -/** - * The committed picker value. Boundaries are always concrete local - * Dates; send them to the API in UTC (Date.toISOString). Quick picks - * resolve to dates at selection time and record their id purely so the - * trigger can keep rendering the preset label; the id is display - * metadata and must not be sent to the backend. - */ export interface DateTimeRangeValue { start: Date; end: Date; @@ -65,9 +53,7 @@ interface ClockTime { const TIME_PATTERN = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/; /** - * Parses a 12-hour clock string such as "12:00:00", "9:30", or - * "09:30:15". Seconds are optional and default to zero. Returns null - * for anything that is not a valid 12-hour time. + * Parses a 12-hour clock string. Seconds are optional and default to zero. */ export const parseClockTime = (text: string): ClockTime | null => { const match = TIME_PATTERN.exec(text.trim()); @@ -114,10 +100,7 @@ export const toClockFields = ( }; /** - * Summarizes a custom range for the trigger button, favoring the most - * compact form: "April 12, 9:00 AM - 11:00 AM" for intra-day ranges, - * "April 10-16", "Mar 28 - Apr 2", or a fully qualified pair when the - * years differ. + * Summarizes a custom range to the most compact form. */ export const formatCustomLabel = (start: Date, end: Date): string => { const from = dayjs(start); From 5abf1a2ca0f92a2c261f0081f172bb4ae9290f77 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Thu, 20 Aug 2026 21:18:02 +0000 Subject: [PATCH 13/17] docs(site/src/components/DateTimeRangePicker): note preset field is display-only --- site/src/components/DateTimeRangePicker/dateTimeRange.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/components/DateTimeRangePicker/dateTimeRange.ts b/site/src/components/DateTimeRangePicker/dateTimeRange.ts index 323f9c7780b73..1d95591417e85 100644 --- a/site/src/components/DateTimeRangePicker/dateTimeRange.ts +++ b/site/src/components/DateTimeRangePicker/dateTimeRange.ts @@ -9,6 +9,7 @@ export interface QuickPreset { export interface DateTimeRangeValue { start: Date; end: Date; + /** Display metadata only; never sent to the API. */ preset?: string; } From c4e43032fc466b34041de2f6524ffa8a44ac4557 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Thu, 20 Aug 2026 21:48:10 +0000 Subject: [PATCH 14/17] fix(site/src/components/Calendar): make disabled nav chevrons unclickable react-day-picker marks nav buttons with aria-disabled rather than disabled, so they kept a pointer cursor and accepted no-op clicks. Disabled chevrons now also drop pointer events. --- site/src/components/Calendar/Calendar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/components/Calendar/Calendar.tsx b/site/src/components/Calendar/Calendar.tsx index 9f83155aada14..6e1e471496c3a 100644 --- a/site/src/components/Calendar/Calendar.tsx +++ b/site/src/components/Calendar/Calendar.tsx @@ -60,7 +60,7 @@ function Calendar({ "inline-flex items-center justify-center rounded-md", "bg-transparent border-0 cursor-pointer", "text-content-secondary hover:text-content-primary hover:bg-surface-secondary", - "aria-disabled:opacity-50", + "aria-disabled:opacity-50 aria-disabled:pointer-events-none", defaultClassNames.button_previous, ), button_next: cn( @@ -68,7 +68,7 @@ function Calendar({ "inline-flex items-center justify-center rounded-md", "bg-transparent border-0 cursor-pointer", "text-content-secondary hover:text-content-primary hover:bg-surface-secondary", - "aria-disabled:opacity-50", + "aria-disabled:opacity-50 aria-disabled:pointer-events-none", defaultClassNames.button_next, ), month_caption: cn( From 50b1a07c633b58b2421914282f8c1482876c61a4 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Thu, 20 Aug 2026 21:55:02 +0000 Subject: [PATCH 15/17] fix(site/src/components/DateTimeRangePicker): make all stories stateful Args-based stories passed a frozen value, so committing a selection in the Storybook canvas never updated the trigger while the render-function stories did. A shared stateful render now backs every story, with onChange still spied through fn() for the actions panel. --- .../DateTimeRangePicker.stories.tsx | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx index 33637279ac12e..1ea5a86f4c738 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx @@ -25,6 +25,22 @@ const meta: Meta = { now: fixedNow, onChange: fn(), }, + // Every story is stateful so committing a selection updates the + // trigger the same way it does in the app; onChange still reports + // to the actions panel through the fn() spy. + render: function StatefulPicker(args) { + const [value, setValue] = useState(args.value); + return ( + { + args.onChange(next); + setValue(next); + }} + /> + ); + }, }; export default meta; @@ -95,13 +111,10 @@ export const OpenShowsOnlyQuickPicks: Story = { }; export const SelectQuickPick: Story = { - render: function SelectQuickPickStory() { - const [value, setValue] = useState(presetValue); - return ( - - ); + args: { + value: presetValue, }, - play: async ({ canvasElement }) => { + play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button")); @@ -112,6 +125,7 @@ export const SelectQuickPick: Story = { await waitFor(() => { expect(screen.queryByRole("radio", { name: "Custom range" })).toBeNull(); }); + expect(args.onChange).toHaveBeenCalledTimes(1); expect( canvas.getByRole("button", { name: /Last hour/ }), ).toBeInTheDocument(); @@ -155,11 +169,8 @@ export const CustomRangeExpanded: Story = { }; export const ApplyCustomRange: Story = { - render: function ApplyCustomRangeStory() { - const [value, setValue] = useState(presetValue); - return ( - - ); + args: { + value: presetValue, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -202,11 +213,8 @@ export const ApplyCustomRange: Story = { }; export const SelectSingleDay: Story = { - render: function SelectSingleDayStory() { - const [value, setValue] = useState(presetValue); - return ( - - ); + args: { + value: presetValue, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -316,11 +324,8 @@ export const EndBeforeStartShowsError: Story = { }; export const CancelDiscardsDraft: Story = { - render: function CancelDiscardsDraftStory() { - const [value, setValue] = useState(presetValue); - return ( - - ); + args: { + value: presetValue, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); From bf1b3852647b9efffb4d9d3a1a3ce4b285f5e9a9 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Thu, 20 Aug 2026 22:16:39 +0000 Subject: [PATCH 16/17] fix(site/src/components/DateTimeRangePicker): use global setTimeout --- .../components/DateTimeRangePicker/DateTimeRangePicker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx index 34e7e483185a8..20f75fa24de05 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -175,11 +175,11 @@ export const DateTimeRangePicker: FC = ({ setDismissedMessage(null); return; } - const timer = window.setTimeout( + const timer = setTimeout( () => setDismissedMessage(errorMessage), ERROR_DISMISS_TIMEOUT_MS, ); - return () => window.clearTimeout(timer); + return () => clearTimeout(timer); }, [errorMessage]); const visibleError = errorMessage !== null && errorMessage !== dismissedMessage From 48e9a345c39397c375ceb6d58081577914ca34b8 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Thu, 20 Aug 2026 22:24:00 +0000 Subject: [PATCH 17/17] feat(site/src/components/DateTimeRangePicker): add Last 24 hours quick pick --- .../DateTimeRangePicker/DateTimeRangePicker.stories.tsx | 3 +++ .../components/DateTimeRangePicker/dateTimeRange.test.ts | 6 +++++- site/src/components/DateTimeRangePicker/dateTimeRange.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx index 1ea5a86f4c738..344acfb73b040 100644 --- a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx @@ -85,6 +85,9 @@ export const OpenShowsOnlyQuickPicks: Story = { expect( quickPicks.getByRole("radio", { name: "Last hour" }), ).toBeInTheDocument(); + expect( + quickPicks.getByRole("radio", { name: "Last 24 hours" }), + ).toBeInTheDocument(); expect( quickPicks.getByRole("radio", { name: "Today" }), ).toBeInTheDocument(); diff --git a/site/src/components/DateTimeRangePicker/dateTimeRange.test.ts b/site/src/components/DateTimeRangePicker/dateTimeRange.test.ts index f2cb6a13a17c1..4ec388c81b87a 100644 --- a/site/src/components/DateTimeRangePicker/dateTimeRange.test.ts +++ b/site/src/components/DateTimeRangePicker/dateTimeRange.test.ts @@ -167,7 +167,7 @@ describe("DEFAULT_QUICK_PRESETS", () => { return preset.range(now); }; - it("resolves last_15m and last_1h relative to now", () => { + it("resolves last_15m, last_1h, and last_24h relative to now", () => { expect(rangeFor("last_15m")).toEqual({ start: new Date(2026, 3, 16, 10, 15, 0), end: now, @@ -176,6 +176,10 @@ describe("DEFAULT_QUICK_PRESETS", () => { start: new Date(2026, 3, 16, 9, 30, 0), end: now, }); + expect(rangeFor("last_24h")).toEqual({ + start: new Date(2026, 3, 15, 10, 30, 0), + end: now, + }); }); it("resolves today from local midnight", () => { diff --git a/site/src/components/DateTimeRangePicker/dateTimeRange.ts b/site/src/components/DateTimeRangePicker/dateTimeRange.ts index 1d95591417e85..30589afc6a4f7 100644 --- a/site/src/components/DateTimeRangePicker/dateTimeRange.ts +++ b/site/src/components/DateTimeRangePicker/dateTimeRange.ts @@ -30,6 +30,14 @@ export const DEFAULT_QUICK_PRESETS: QuickPreset[] = [ end: now, }), }, + { + id: "last_24h", + label: "Last 24 hours", + range: (now) => ({ + start: dayjs(now).subtract(24, "hour").toDate(), + end: now, + }), + }, { id: "today", label: "Today",