diff --git a/site/src/components/Calendar/Calendar.tsx b/site/src/components/Calendar/Calendar.tsx index 9f83155aada..6e1e471496c 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( diff --git a/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx new file mode 100644 index 00000000000..344acfb73b0 --- /dev/null +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.stories.tsx @@ -0,0 +1,367 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; +import { DateTimeRangePicker } from "./DateTimeRangePicker"; +import type { DateTimeRangeValue } from "./dateTimeRange"; + +// Matches the design mockup: mid-April 2026. +const fixedNow = new Date(2026, 3, 16, 10, 30, 0); + +const presetValue: DateTimeRangeValue = { + start: new Date(2026, 3, 16, 10, 15, 0), + end: fixedNow, + preset: "last_15m", +}; + +const customValue: DateTimeRangeValue = { + 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, + 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; +type Story = StoryObj; + +export const Closed: Story = { + args: { + value: presetValue, + }, +}; + +export const ClosedWithCustomRange: Story = { + args: { + value: customValue, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /April 10-16/ }), + ).toBeInTheDocument(); + }, +}; + +export const OpenShowsOnlyQuickPicks: Story = { + args: { + value: presetValue, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + await waitFor(() => { + expect( + screen.getByRole("radio", { name: "Custom range" }), + ).toBeInTheDocument(); + }); + const quickPicks = within(screen.getByRole("radiogroup")); + + expect( + quickPicks.getByRole("radio", { name: "Last 15 min" }), + ).toBeInTheDocument(); + expect( + quickPicks.getByRole("radio", { name: "Last hour" }), + ).toBeInTheDocument(); + expect( + quickPicks.getByRole("radio", { name: "Last 24 hours" }), + ).toBeInTheDocument(); + expect( + quickPicks.getByRole("radio", { name: "Today" }), + ).toBeInTheDocument(); + expect( + quickPicks.getByRole("radio", { name: "This week" }), + ).toBeInTheDocument(); + expect( + 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(); + }, +}; + +export const SelectQuickPick: Story = { + args: { + value: presetValue, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + 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("radio", { name: "Custom range" })).toBeNull(); + }); + expect(args.onChange).toHaveBeenCalledTimes(1); + expect( + canvas.getByRole("button", { name: /Last hour/ }), + ).toBeInTheDocument(); + }, +}; + +export const CustomRangeExpanded: Story = { + args: { + value: presetValue, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + const customRadio = await screen.findByRole("radio", { + name: "Custom range", + }); + await userEvent.click(customRadio); + + // 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 full date range is selected. + expect(screen.getByRole("button", { name: "Apply" })).toBeDisabled(); + + // Future dates cannot be selected, and the calendar cannot page + // past the current month. + expect( + screen.getByRole("button", { name: /April 17th, 2026/ }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: /next month/i })).toHaveAttribute( + "aria-disabled", + "true", + ); + }, +}; + +export const ApplyCustomRange: Story = { + args: { + value: presetValue, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + await userEvent.click( + await screen.findByRole("radio", { name: "Custom range" }), + ); + await waitFor(() => { + expect(screen.getByRole("grid")).toBeInTheDocument(); + }); + + const applyButton = screen.getByRole("button", { name: "Apply" }); + await userEvent.click( + screen.getByRole("button", { name: /April 10th, 2026/ }), + ); + await userEvent.click( + screen.getByRole("button", { name: /April 16th, 2026/ }), + ); + + // Flip the To meridiem to exercise the select. + await userEvent.click( + screen.getByRole("combobox", { name: "To AM or PM" }), + ); + await userEvent.click(await screen.findByRole("option", { name: "AM" })); + + 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 SelectSingleDay: Story = { + args: { + value: presetValue, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + await userEvent.click( + await screen.findByRole("radio", { name: "Custom range" }), + ); + await waitFor(() => { + expect(screen.getByRole("grid")).toBeInTheDocument(); + }); + + // A single calendar click is a complete one-day range spanning the + // default 12:00:00 AM to 11:59:59 PM. + await userEvent.click( + screen.getByRole("button", { name: /April 10th, 2026/ }), + ); + const applyButton = screen.getByRole("button", { name: "Apply" }); + await waitFor(() => { + expect(applyButton).toBeEnabled(); + }); + await userEvent.click(applyButton); + + await waitFor(() => { + expect(screen.queryByRole("grid")).toBeNull(); + }); + expect( + canvas.getByRole("button", { name: /April 10, 12:00 AM - 11:59 PM/ }), + ).toBeInTheDocument(); + }, +}; + +export const InvalidTimeDisablesApply: Story = { + args: { + value: customValue, + }, + 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"); + + // 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(fromInput).toHaveAccessibleDescription( + "Enter a valid time, e.g. 09:30:00", + ); + + // The message dismisses itself like a toast, while the invalid + // styling and disabled Apply persist until the input is fixed. + await waitFor( + () => { + expect(screen.queryByRole("alert")).toBeNull(); + }, + { timeout: 7_000 }, + ); + expect(fromInput).toHaveAttribute("aria-invalid", "true"); + 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: { + start: new Date(2026, 3, 10, 9, 0, 0), + end: new Date(2026, 3, 10, 10, 0, 0), + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + + await waitFor(() => { + expect(screen.getByRole("grid")).toBeInTheDocument(); + }); + + 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 = { + args: { + value: presetValue, + }, + 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("radio", { 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); + }, +}; + +export const IntraDayTriggerLabel: Story = { + args: { + value: { + start: new Date(2026, 3, 12, 9, 0, 0), + end: new Date(2026, 3, 12, 11, 0, 0), + }, + }, + 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 new file mode 100644 index 00000000000..20f75fa24de --- /dev/null +++ b/site/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -0,0 +1,449 @@ +/** + * 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"; +import { type FC, type KeyboardEvent, useEffect, 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 DateTimeRangeValue, + DEFAULT_QUICK_PRESETS, + formatCustomLabel, + type Meridiem, + parseClockTime, + type QuickPreset, + toClockFields, +} from "./dateTimeRange"; + +interface DateTimeRangePickerProps { + value: DateTimeRangeValue; + onChange: (value: DateTimeRangeValue) => 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"; +// How long the floating error stays visible before fading, mirroring +// toast behavior. The invalid field styling and disabled Apply remain +// until the input is corrected. +const ERROR_DISMISS_TIMEOUT_MS = 5_000; + +interface TimeFieldsState { + from: string; + fromMeridiem: Meridiem; + fromTouched: boolean; + to: string; + toMeridiem: Meridiem; + toTouched: boolean; +} + +// From defaults to the start of the day and To to the end, so any +// day-only selection spans the full final day. +const defaultTimeFields = (): TimeFieldsState => ({ + from: "12:00:00", + fromMeridiem: "AM", + fromTouched: false, + to: "11:59:59", + toMeridiem: "PM", + toTouched: false, +}); + +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(defaultTimeFields); + const fromTimeId = useId(); + const toTimeId = useId(); + const errorId = 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.preset === undefined) { + 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, + fromTouched: false, + to: to.time, + toMeridiem: to.meridiem, + toTouched: false, + }); + } else { + setCustomExpanded(false); + setSelection(undefined); + setTimeFields(defaultTimeFields()); + } + } + setOpen(next); + }; + + const handlePreset = (preset: QuickPreset) => { + const { start, end } = preset.range(currentTime); + onChange({ start, end, preset: preset.id }); + setOpen(false); + }; + + // 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"; + if (!isNext && !isPrevious) { + return; + } + const radios = Array.from( + event.currentTarget.querySelectorAll('[role="radio"]'), + ); + const current = + event.target instanceof HTMLElement ? radios.indexOf(event.target) : -1; + 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 = + 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; + 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 errorMessage = fromTimeError ?? toTimeError ?? rangeError; + + // Toast-style dismissal: each message hides after a timeout but is + // remembered so it does not reappear until the error actually changes. + const [dismissedMessage, setDismissedMessage] = useState(null); + useEffect(() => { + if (errorMessage === null) { + setDismissedMessage(null); + return; + } + const timer = setTimeout( + () => setDismissedMessage(errorMessage), + ERROR_DISMISS_TIMEOUT_MS, + ); + return () => clearTimeout(timer); + }, [errorMessage]); + const visibleError = + errorMessage !== null && errorMessage !== dismissedMessage + ? errorMessage + : null; + + const apply = () => { + if (draftStart && draftEnd) { + onChange({ start: draftStart, end: draftEnd }); + setOpen(false); + } + }; + + const activePreset = + value.preset === undefined + ? undefined + : quickPresets.find((preset) => preset.id === value.preset); + 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 ( + + + + + e.preventDefault()} + > +
+ {/* Quick picks. This list is the entire dropdown until the + user expands the custom range panel. */} +
+ {quickPresets.map((preset, index) => ( + handlePreset(preset)} + /> + ))} + setCustomExpanded(true)} + /> +
+ + {customExpanded && ( +
+
+ + {visibleError !== null && ( + + )} +
+ + {/* From/To time fields */} +
+ + setTimeFields((fields) => ({ ...fields, from: time })) + } + onBlur={() => + setTimeFields((fields) => ({ + ...fields, + fromTouched: true, + })) + } + onMeridiemChange={(meridiem) => + setTimeFields((fields) => ({ + ...fields, + fromMeridiem: meridiem, + })) + } + /> + + setTimeFields((fields) => ({ ...fields, to: time })) + } + onBlur={() => + setTimeFields((fields) => ({ ...fields, toTouched: true })) + } + onMeridiemChange={(meridiem) => + setTimeFields((fields) => ({ + ...fields, + toMeridiem: meridiem, + })) + } + /> +
+ + {/* Apply footer */} +
+ + +
+
+ )} +
+
+
+ ); +}; + +interface QuickPickButtonProps { + label: string; + selected: boolean; + tabIndex: number; + onClick: () => void; +} + +const QuickPickButton: FC = ({ + label, + selected, + tabIndex, + onClick, +}) => ( + +); + +interface TimeRowProps { + id: string; + label: string; + time: string; + meridiem: Meridiem; + invalid: boolean; + describedBy: string | undefined; + onTimeChange: (time: string) => void; + onBlur: () => void; + onMeridiemChange: (meridiem: Meridiem) => void; +} + +const TimeRow: FC = ({ + id, + label, + time, + meridiem, + invalid, + describedBy, + onTimeChange, + onBlur, + onMeridiemChange, +}) => ( +
+ + onTimeChange(event.target.value)} + onBlur={onBlur} + /> + +
+); diff --git a/site/src/components/DateTimeRangePicker/dateTimeRange.test.ts b/site/src/components/DateTimeRangePicker/dateTimeRange.test.ts new file mode 100644 index 00000000000..4ec388c81b8 --- /dev/null +++ b/site/src/components/DateTimeRangePicker/dateTimeRange.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + combineDateTime, + DEFAULT_QUICK_PRESETS, + formatCustomLabel, + parseClockTime, + toClockFields, +} from "./dateTimeRange"; + +const clock = (text: string) => { + const parsed = parseClockTime(text); + if (parsed === null) { + throw new Error(`expected "${text}" to parse`); + } + return parsed; +}; + +describe("parseClockTime", () => { + it("parses full and minute-only times", () => { + expect(parseClockTime("09:30:15")).toEqual({ + hours: 9, + minutes: 30, + seconds: 15, + }); + expect(parseClockTime("12:00:00")).toEqual({ + hours: 12, + minutes: 0, + seconds: 0, + }); + expect(parseClockTime("09:30")).toEqual({ + hours: 9, + minutes: 30, + seconds: 0, + }); + }); + + it("accepts single-digit hours and surrounding whitespace", () => { + expect(parseClockTime("9:05")).toEqual({ + hours: 9, + minutes: 5, + seconds: 0, + }); + expect(parseClockTime(" 11:15:30 ")).toEqual({ + hours: 11, + minutes: 15, + seconds: 30, + }); + }); + + it("enforces the 1-12 hour range of a 12-hour clock", () => { + expect(parseClockTime("1:00")).not.toBeNull(); + expect(parseClockTime("12:59:59")).not.toBeNull(); + expect(parseClockTime("0:30")).toBeNull(); + expect(parseClockTime("13:00")).toBeNull(); + expect(parseClockTime("99:99")).toBeNull(); + }); + + it("rejects out-of-range minutes and seconds", () => { + expect(parseClockTime("09:60")).toBeNull(); + expect(parseClockTime("09:30:60")).toBeNull(); + }); + + it("rejects unknown shapes", () => { + expect(parseClockTime("")).toBeNull(); + expect(parseClockTime("09")).toBeNull(); + expect(parseClockTime("09:5")).toBeNull(); + expect(parseClockTime("09:30:15:00")).toBeNull(); + expect(parseClockTime("9:30 AM")).toBeNull(); + expect(parseClockTime("noon")).toBeNull(); + }); +}); + +describe("combineDateTime", () => { + const day = new Date(2026, 3, 10, 17, 45, 33); + + it("maps 12 AM to midnight and 12 PM to noon", () => { + expect(combineDateTime(day, clock("12:00:00"), "AM")).toEqual( + new Date(2026, 3, 10, 0, 0, 0), + ); + expect(combineDateTime(day, clock("12:00:00"), "PM")).toEqual( + new Date(2026, 3, 10, 12, 0, 0), + ); + }); + + it("maps other hours by meridiem", () => { + expect(combineDateTime(day, clock("9:30:15"), "AM")).toEqual( + new Date(2026, 3, 10, 9, 30, 15), + ); + expect(combineDateTime(day, clock("9:30:15"), "PM")).toEqual( + new Date(2026, 3, 10, 21, 30, 15), + ); + }); + + it("keeps the calendar day and discards the source time", () => { + expect(combineDateTime(day, clock("1:02:03"), "AM")).toEqual( + new Date(2026, 3, 10, 1, 2, 3), + ); + }); +}); + +describe("toClockFields", () => { + it("splits midnight and noon into 12-hour fields", () => { + expect(toClockFields(new Date(2026, 3, 10, 0, 0, 0))).toEqual({ + time: "12:00:00", + meridiem: "AM", + }); + expect(toClockFields(new Date(2026, 3, 10, 12, 0, 0))).toEqual({ + time: "12:00:00", + meridiem: "PM", + }); + }); + + it("splits arbitrary times", () => { + expect(toClockFields(new Date(2026, 3, 10, 9, 5, 7))).toEqual({ + time: "09:05:07", + meridiem: "AM", + }); + expect(toClockFields(new Date(2026, 3, 10, 21, 30, 0))).toEqual({ + time: "09:30:00", + meridiem: "PM", + }); + }); + + it("round-trips through combineDateTime", () => { + const original = new Date(2026, 3, 10, 21, 30, 45); + const { time, meridiem } = toClockFields(original); + expect(combineDateTime(original, clock(time), meridiem)).toEqual(original); + }); +}); + +describe("formatCustomLabel", () => { + it("shows times for intra-day ranges", () => { + expect( + formatCustomLabel( + new Date(2026, 3, 12, 9, 0, 0), + new Date(2026, 3, 12, 11, 30, 0), + ), + ).toBe("April 12, 9:00 AM - 11:30 AM"); + }); + + it("collapses ranges within one month to a day span", () => { + expect( + formatCustomLabel(new Date(2026, 3, 10), new Date(2026, 3, 16)), + ).toBe("April 10-16"); + }); + + it("spells out both months within one year", () => { + expect(formatCustomLabel(new Date(2026, 2, 28), new Date(2026, 3, 2))).toBe( + "Mar 28 - Apr 2", + ); + }); + + it("includes years when they differ", () => { + expect( + formatCustomLabel(new Date(2025, 11, 30), new Date(2026, 0, 2)), + ).toBe("Dec 30, 2025 - Jan 2, 2026"); + }); +}); + +describe("DEFAULT_QUICK_PRESETS", () => { + const now = new Date(2026, 3, 16, 10, 30, 0); + const rangeFor = (id: string) => { + const preset = DEFAULT_QUICK_PRESETS.find((p) => p.id === id); + if (!preset) { + throw new Error(`missing preset ${id}`); + } + return preset.range(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, + }); + expect(rangeFor("last_1h")).toEqual({ + 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", () => { + expect(rangeFor("today")).toEqual({ + start: new Date(2026, 3, 16, 0, 0, 0), + end: now, + }); + }); + + it("resolves this_week from the start of the week", () => { + // April 16, 2026 is a Thursday; the week starts Sunday, April 12. + expect(rangeFor("this_week")).toEqual({ + start: new Date(2026, 3, 12, 0, 0, 0), + end: now, + }); + }); +}); diff --git a/site/src/components/DateTimeRangePicker/dateTimeRange.ts b/site/src/components/DateTimeRangePicker/dateTimeRange.ts new file mode 100644 index 00000000000..30589afc6a4 --- /dev/null +++ b/site/src/components/DateTimeRangePicker/dateTimeRange.ts @@ -0,0 +1,127 @@ +import dayjs from "dayjs"; + +export interface QuickPreset { + id: string; + label: string; + range: (now: Date) => { start: Date; end: Date }; +} + +export interface DateTimeRangeValue { + start: Date; + end: Date; + /** Display metadata only; never sent to the API. */ + preset?: string; +} + +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: "last_24h", + label: "Last 24 hours", + range: (now) => ({ + start: dayjs(now).subtract(24, "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"; + +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. Seconds are optional and default to zero. + */ +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 to the most compact form. + */ +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, h:mm A")} - ${to.format("h:mm A")}`; + } + 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")}`; +};