diff --git a/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.stories.tsx b/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.stories.tsx new file mode 100644 index 00000000000..52b8a38f1d2 --- /dev/null +++ b/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.stories.tsx @@ -0,0 +1,268 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + expect, + fireEvent, + fn, + userEvent, + waitFor, + within, +} from "storybook/test"; +import { formatDateTime } from "#/utils/time"; +import { DateTimeRangeFilter } from "./DateTimeRangeFilter"; +import type { TimeRange } from "./timeRange"; + +const fixedNow = new Date(2026, 7, 13, 15, 0, 0); + +const defaultValue: TimeRange = { + startedAfter: new Date(2026, 7, 12, 15, 0, 0), + startedBefore: fixedNow, +}; + +const singleDayValue: TimeRange = { + startedAfter: new Date(2026, 3, 10, 7, 23, 0), + startedBefore: new Date(2026, 3, 10, 9, 30, 0), +}; + +const meta: Meta = { + title: "components/DateTimeRangeFilter", + component: DateTimeRangeFilter, + args: { + now: fixedNow, + value: defaultValue, + defaultValue, + onChange: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const DefaultLabel: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: "Filter by time range" }), + ).toHaveTextContent("Last 24 hours"); + expect(args.onChange).not.toHaveBeenCalled(); + }, +}; + +export const SingleDayLabel: Story = { + args: { + value: singleDayValue, + }, +}; + +export const RangeEndingTodayLabel: Story = { + args: { + value: { + startedAfter: new Date(2026, 7, 11, 23, 59, 59), + startedBefore: new Date(2026, 7, 13, 10, 0, 0), + }, + }, +}; + +export const SameMonthLabel: Story = { + args: { + value: { + startedAfter: new Date(2026, 3, 17), + startedBefore: new Date(2026, 3, 19), + }, + }, +}; + +export const OpenPrefillsExpressions: Story = { + args: { + value: singleDayValue, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + canvas.getByRole("button", { name: "Filter by time range" }), + ); + + // Committed bounds are shown as absolute local expressions. + const fromInput = await body.findByLabelText("Start of time range"); + const toInput = body.getByLabelText("End of time range"); + expect(fromInput).toHaveValue("2026-04-10 07:23:00"); + expect(toInput).toHaveValue("2026-04-10 09:30:00"); + + // The examples footer explains the accepted grammar. + expect(body.getByText("Examples:")).toBeInTheDocument(); + expect( + body.getByText("Defaults to midnight if no time is provided."), + ).toBeInTheDocument(); + + // Apply stays disabled until the selection changes. + expect(body.getByRole("button", { name: "Apply" })).toBeDisabled(); + }, +}; + +export const OpenPrefillsNowForCurrentBoundary: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + canvas.getByRole("button", { name: "Filter by time range" }), + ); + + // The default end boundary is the current moment, so it reads as + // "now"; the start boundary is a frozen timestamp. + const fromInput = await body.findByLabelText("Start of time range"); + const toInput = body.getByLabelText("End of time range"); + expect(fromInput).toHaveValue(formatDateTime(defaultValue.startedAfter)); + expect(toInput).toHaveValue("now"); + }, +}; + +export const BlurNormalizesUnderspecifiedInput: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + canvas.getByRole("button", { name: "Filter by time range" }), + ); + + // A clock-only expression resolves to the current day once the + // input loses focus, so the user sees what will be applied. + const fromInput = await body.findByLabelText("Start of time range"); + await userEvent.click(fromInput); + await fireEvent.change(fromInput, { target: { value: "12:34" } }); + await userEvent.tab(); + await waitFor(() => { + expect(fromInput).toHaveValue("2026-08-13 12:34:00"); + }); + + // "now" is already unambiguous and stays untouched. + const toInput = body.getByLabelText("End of time range"); + await userEvent.tab(); + expect(toInput).toHaveValue("now"); + }, +}; + +export const BlurClampsReversedRange: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + canvas.getByRole("button", { name: "Filter by time range" }), + ); + + // Blurring an out-of-order boundary snaps it back inside the range. + const fromInput = await body.findByLabelText("Start of time range"); + const toInput = body.getByLabelText("End of time range"); + await userEvent.click(fromInput); + await fireEvent.change(fromInput, { + target: { value: "2026-08-13 16:00" }, + }); + await fireEvent.change(toInput, { target: { value: "2026-08-13 08:00" } }); + // Tabbing out of From clamps it below To; tabbing out of To then + // reformats it without changing the now-valid range. + await userEvent.tab(); + await userEvent.tab(); + await waitFor(() => { + expect(fromInput).toHaveValue("2026-08-13 07:59:59"); + }); + expect(toInput).toHaveValue("2026-08-13 08:00:00"); + expect(body.queryByText("From must be before To")).toBeNull(); + }, +}; + +export const ApplyCommitsSelection: Story = { + args: { + onChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + canvas.getByRole("button", { name: "Filter by time range" }), + ); + + const fromInput = await body.findByLabelText("Start of time range"); + await fireEvent.change(fromInput, { + target: { value: "2026-08-13 08:00" }, + }); + + const applyButton = body.getByRole("button", { name: "Apply" }); + expect(applyButton).toBeEnabled(); + await userEvent.click(applyButton); + + await waitFor(() => { + expect(body.queryByRole("button", { name: "Apply" })).toBeNull(); + }); + expect(args.onChange).toHaveBeenCalledWith({ + startedAfter: new Date(2026, 7, 13, 8, 0, 0), + startedBefore: fixedNow, + }); + }, +}; + +export const InvalidExpressionShowsError: Story = { + args: { + onChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + canvas.getByRole("button", { name: "Filter by time range" }), + ); + + const fromInput = await body.findByLabelText("Start of time range"); + await fireEvent.change(fromInput, { target: { value: "30d" } }); + expect(fromInput).toHaveAttribute("aria-invalid", "true"); + expect( + body.getByText("Enter a valid time, e.g. 2026-08-13 11:43"), + ).toBeInTheDocument(); + expect(body.getByRole("button", { name: "Apply" })).toBeDisabled(); + }, +}; + +export const ReversedRangeShowsError: Story = { + args: { + onChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + canvas.getByRole("button", { name: "Filter by time range" }), + ); + + // From after To is not a valid range and gets its own message. + const fromInput = await body.findByLabelText("Start of time range"); + const toInput = body.getByLabelText("End of time range"); + await fireEvent.change(fromInput, { + target: { value: "2026-08-13 16:00" }, + }); + await fireEvent.change(toInput, { target: { value: "2026-08-13 08:00" } }); + expect(body.getByText("From must be before To")).toBeInTheDocument(); + expect(body.getByRole("button", { name: "Apply" })).toBeDisabled(); + }, +}; + +export const EscapeClosesWithoutApplying: Story = { + args: { + onChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + canvas.getByRole("button", { name: "Filter by time range" }), + ); + + const fromInput = await body.findByLabelText("Start of time range"); + await fireEvent.change(fromInput, { + target: { value: "2026-08-13 08:00" }, + }); + await userEvent.keyboard("{Escape}"); + + await waitFor(() => { + expect(body.queryByRole("button", { name: "Apply" })).toBeNull(); + }); + expect(args.onChange).not.toHaveBeenCalled(); + }, +}; diff --git a/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.tsx b/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.tsx new file mode 100644 index 00000000000..d37159bf95e --- /dev/null +++ b/site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.tsx @@ -0,0 +1,240 @@ +import { CalendarIcon } from "lucide-react"; +import { type FC, useEffectEvent, useState } from "react"; +import { Button } from "#/components/Button/Button"; +import { Input } from "#/components/Input/Input"; +import { Label } from "#/components/Label/Label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "#/components/Popover/Popover"; +import { cn } from "#/utils/cn"; +import { formatDateTime } from "#/utils/time"; +import { + formatTriggerLabel, + isNowExpression, + parseTimeExpression, + type TimeRange, +} from "./timeRange"; + +interface DateTimeRangeFilterProps { + value: TimeRange; + /** + * The range the page falls back to when no explicit filter is set. + * The trigger labels it as "Last 24 hours" while the value equals it. + */ + defaultValue: TimeRange; + onChange: (value: TimeRange) => void; + now?: Date; + /** Matches the SelectFilter trigger metrics in the filter row. */ + width?: number; +} + +interface FieldState { + text: string; + touched: boolean; +} + +const EXAMPLES = ["Now", "15:43", "2026-08-13 11:43"]; + +const INVALID_TIME_MESSAGE = "Enter a valid time, e.g. 2026-08-13 11:43"; + +const NOW_TOLERANCE_MS = 60 * 1000; + +export const DateTimeRangeFilter: FC = ({ + value, + defaultValue, + onChange, + now, + width = 200, +}) => { + const [open, setOpen] = useState(false); + const currentTime = now ?? new Date(); + const isDefault = + value.startedAfter.getTime() === defaultValue.startedAfter.getTime() && + value.startedBefore.getTime() === defaultValue.startedBefore.getTime(); + + // Text state is kept separate from the committed value so the user can + // adjust the expressions freely before applying; invalid text never + // leaks out. If this control grows more fields or validation rules, + // consider moving to formik and yup instead of hand-rolling state. + const [fromField, setFromField] = useState({ + text: "", + touched: false, + }); + const [toField, setToField] = useState({ + text: "", + touched: false, + }); + + const handleOpenChange = useEffectEvent((next: boolean) => { + if (next) { + // Boundaries at (or very near) the current moment read better + // as "now" than as a frozen timestamp when the popover reopens. + const toFieldText = (date: Date): string => + Math.abs(date.getTime() - currentTime.getTime()) < NOW_TOLERANCE_MS + ? "now" + : formatDateTime(date); + setFromField({ + text: toFieldText(value.startedAfter), + touched: false, + }); + setToField({ + text: toFieldText(value.startedBefore), + touched: false, + }); + } + setOpen(next); + }); + + const parsedFrom = parseTimeExpression(fromField.text, currentTime); + const parsedTo = parseTimeExpression(toField.text, currentTime); + + // Underspecified expressions resolve to absolute local timestamps when + // the input loses focus, so the user sees exactly what will be applied. + // "now" is left as-is because it is already unambiguous. Out-of-range + // values clamp against the other boundary so the committed range is + // always valid. + const normalizeFrom = useEffectEvent(() => { + setFromField((current) => { + if (isNowExpression(current.text) || parsedFrom === null) { + return current; + } + const clamped = + parsedTo !== null && parsedFrom.getTime() >= parsedTo.getTime() + ? new Date(parsedTo.getTime() - 1000) + : parsedFrom; + return { ...current, text: formatDateTime(clamped) }; + }); + }); + + const normalizeTo = useEffectEvent(() => { + setToField((current) => { + if (isNowExpression(current.text) || parsedTo === null) { + return current; + } + const clamped = + parsedFrom !== null && parsedTo.getTime() <= parsedFrom.getTime() + ? new Date(parsedFrom.getTime() + 1000) + : parsedTo; + return { ...current, text: formatDateTime(clamped) }; + }); + }); + + const fromError = + fromField.text !== "" && parsedFrom === null ? INVALID_TIME_MESSAGE : null; + const toError = + toField.text !== "" && parsedTo === null ? INVALID_TIME_MESSAGE : null; + const rangeError = + fromError === null && + toError === null && + parsedFrom !== null && + parsedTo !== null && + parsedFrom.getTime() >= parsedTo.getTime() + ? "From must be before To" + : null; + + // Apply is only useful when something actually changed; untouched + // fields resolve back to the committed range. + const applyDisabled = + !(fromField.touched || toField.touched) || + fromError !== null || + toError !== null || + rangeError !== null; + + const triggerLabel = isDefault + ? "Last 24 hours" + : formatTriggerLabel(value, currentTime); + + return ( + + + + + +
+
+ + { + setFromField({ text: event.target.value, touched: true }); + }} + onBlur={normalizeFrom} + /> + {fromError !== null && ( + + {fromError} + + )} +
+
+ + { + setToField({ text: event.target.value, touched: true }); + }} + onBlur={normalizeTo} + /> + {toError !== null && ( + + {toError} + + )} +
+
+ {rangeError !== null && ( + + {rangeError} + + )} + +
+
+
+ Examples: + {EXAMPLES.join(" | ")} + Defaults to midnight if no time is provided. + Defaults to current day if no date is provided. +
+
+
+ ); +}; diff --git a/site/src/components/DateTimeRangeFilter/timeRange.test.ts b/site/src/components/DateTimeRangeFilter/timeRange.test.ts new file mode 100644 index 00000000000..61637359844 --- /dev/null +++ b/site/src/components/DateTimeRangeFilter/timeRange.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + formatTriggerLabel, + isNowExpression, + parseTimeExpression, +} from "./timeRange"; + +const now = new Date(2026, 7, 13, 15, 0, 0); + +describe("isNowExpression", () => { + it("matches now case-insensitively with surrounding whitespace", () => { + expect(isNowExpression("now")).toBe(true); + expect(isNowExpression("Now")).toBe(true); + expect(isNowExpression(" NOW ")).toBe(true); + }); + + it("rejects other input", () => { + expect(isNowExpression("now ")).toBe(true); + expect(isNowExpression("not-now")).toBe(false); + expect(isNowExpression("")).toBe(false); + expect(isNowExpression("2026-08-13")).toBe(false); + }); +}); + +describe("parseTimeExpression", () => { + it("parses now case-insensitively", () => { + expect(parseTimeExpression("now", now)).toEqual(now); + expect(parseTimeExpression("Now", now)).toEqual(now); + expect(parseTimeExpression(" NOW ", now)).toEqual(now); + }); + + it("parses clock times against the current day", () => { + expect(parseTimeExpression("15:43", now)).toEqual( + new Date(2026, 7, 13, 15, 43, 0), + ); + expect(parseTimeExpression("09:05:09", now)).toEqual( + new Date(2026, 7, 13, 9, 5, 9), + ); + }); + + it("defaults a bare date to midnight", () => { + expect(parseTimeExpression("2026-08-13", now)).toEqual( + new Date(2026, 7, 13), + ); + }); + + it("parses date and time together", () => { + expect(parseTimeExpression("2026-08-13 11:43", now)).toEqual( + new Date(2026, 7, 13, 11, 43, 0), + ); + expect(parseTimeExpression("2026-08-13 11:43:21", now)).toEqual( + new Date(2026, 7, 13, 11, 43, 21), + ); + }); + + it("rejects single-digit hours", () => { + expect(parseTimeExpression("9:05", now)).toBeNull(); + expect(parseTimeExpression("2026-08-13 7:23:00", now)).toBeNull(); + }); + + it("rejects out-of-range clocks and dates", () => { + expect(parseTimeExpression("23:59:99", now)).toBeNull(); + expect(parseTimeExpression("24:00", now)).toBeNull(); + expect(parseTimeExpression("2026-02-30", now)).toBeNull(); + expect(parseTimeExpression("2026-13-01", now)).toBeNull(); + expect(parseTimeExpression("2026-08-13 23:59:99", now)).toBeNull(); + }); + + it("rejects unknown shapes", () => { + expect(parseTimeExpression("", now)).toBeNull(); + expect(parseTimeExpression("30d", now)).toBeNull(); + expect(parseTimeExpression("13/08/2026", now)).toBeNull(); + expect(parseTimeExpression("2026-08-13T11:43", now)).toBeNull(); + }); +}); + +describe("formatTriggerLabel", () => { + it("collapses a single day", () => { + expect( + formatTriggerLabel( + { + startedAfter: new Date(2026, 3, 10, 7, 23, 0), + startedBefore: new Date(2026, 3, 10, 9, 30, 0), + }, + now, + ), + ).toBe("Apr 10"); + }); + + it("labels a range ending today against now", () => { + expect( + formatTriggerLabel( + { + startedAfter: new Date(2026, 7, 11, 23, 59, 59), + startedBefore: new Date(2026, 7, 13, 10, 0, 0), + }, + now, + ), + ).toBe("Aug 11 - Today"); + }); + + it("shortens ranges within one month", () => { + expect( + formatTriggerLabel( + { + startedAfter: new Date(2026, 3, 17), + startedBefore: new Date(2026, 3, 19), + }, + now, + ), + ).toBe("Apr 17 - 19"); + }); + + it("falls back to a full range across months", () => { + expect( + formatTriggerLabel( + { + startedAfter: new Date(2026, 2, 30), + startedBefore: new Date(2026, 3, 2), + }, + now, + ), + ).toBe("Mar 30 - Apr 2"); + }); +}); diff --git a/site/src/components/DateTimeRangeFilter/timeRange.ts b/site/src/components/DateTimeRangeFilter/timeRange.ts new file mode 100644 index 00000000000..7185e18360d --- /dev/null +++ b/site/src/components/DateTimeRangeFilter/timeRange.ts @@ -0,0 +1,93 @@ +import dayjs from "dayjs"; +import customParseFormat from "dayjs/plugin/customParseFormat"; +import { DATE_FORMAT } from "#/utils/time"; + +dayjs.extend(customParseFormat); + +export type TimeRange = { + startedAfter: Date; + startedBefore: Date; +}; + +// dayjs strict parsing is width-exact, so the format tokens are +// zero-padded only (e.g. "09:45" parses, "9:45" does not). +const DATE_FORMATS = [ + DATE_FORMAT.ISO_DATE, + DATE_FORMAT.ISO_DATETIME, + DATE_FORMAT.ISO_DATETIME_MINUTE, +]; +const TIME_FORMATS = [DATE_FORMAT.TIME_24H, DATE_FORMAT.TIME_24H_MINUTE]; + +const NOW_PATTERN = /^now$/i; + +/** Whether an expression is the literal "now" (case-insensitive). */ +export const isNowExpression = (expression: string): boolean => + NOW_PATTERN.test(expression.trim()); + +/** + * Parses a human-friendly time expression in browser-local time: + * "now", a clock time (current day), a date (midnight), or a date + * with a clock time. Returns null for anything else. + */ +export const parseTimeExpression = ( + expression: string, + now: Date, +): Date | null => { + const trimmed = expression.trim(); + if (trimmed === "") { + return null; + } + if (isNowExpression(trimmed)) { + return new Date(now.getTime()); + } + + const dated = dayjs(trimmed, DATE_FORMATS, true); + if (dated.isValid()) { + return dated.toDate(); + } + + // Clock-only expressions resolve against the current day. + const clock = dayjs(trimmed, TIME_FORMATS, true); + if (clock.isValid()) { + return new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + clock.hour(), + clock.minute(), + clock.second(), + ); + } + + return null; +}; + +const sameDay = (a: Date, b: Date): boolean => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + +const MONTH_DAY = "MMM D"; + +/** + * Summarizes a resolved range the way the filter trigger displays it: + * a single day, a range ending today, a range within one month, or a + * full from-to range. Callers render "Last 24 hours" for the default + * range before falling back to this. + */ +export const formatTriggerLabel = (range: TimeRange, now: Date): string => { + const from = dayjs(range.startedAfter); + if (sameDay(range.startedAfter, range.startedBefore)) { + return from.format(MONTH_DAY); + } + if (sameDay(range.startedBefore, now)) { + return `${from.format(MONTH_DAY)} - Today`; + } + if ( + range.startedAfter.getFullYear() === range.startedBefore.getFullYear() && + range.startedAfter.getMonth() === range.startedBefore.getMonth() + ) { + return `${from.format(MONTH_DAY)} - ${range.startedBefore.getDate()}`; + } + return `${from.format(MONTH_DAY)} - ${dayjs(range.startedBefore).format(MONTH_DAY)}`; +}; diff --git a/site/src/utils/time.ts b/site/src/utils/time.ts index faa13a20e64..df79b133259 100644 --- a/site/src/utils/time.ts +++ b/site/src/utils/time.ts @@ -31,11 +31,13 @@ type DateTimeInput = Date | string | number | Dayjs | null | undefined; export const DATE_FORMAT = { ISO_DATE: "YYYY-MM-DD", ISO_DATETIME: "YYYY-MM-DD HH:mm:ss", + ISO_DATETIME_MINUTE: "YYYY-MM-DD HH:mm", FULL_DATE: "MMMM D, YYYY", MEDIUM_DATE: "MMM D, YYYY", FULL_DATETIME: "MMMM D, YYYY h:mm A", SHORT_DATE: "MM/DD/YYYY", TIME_24H: "HH:mm:ss", + TIME_24H_MINUTE: "HH:mm", TIME_12H: "h:mm A", UTC_OFFSET: "Z", } as const;