feat(site): add shared DateTimeRangeFilter component (#28255)

A text-expression datetime range picker with From/To inputs accepting
`now`, a clock time (current day), a date (midnight), or a date with a
clock time. Invalid text gets inline errors, underspecified expressions
resolve on blur, and out-of-order boundaries clamp against the other
one. The trigger derives a concise label ("Last 24 hours", "Apr 10",
"Aug 11 - Today", "Apr 17 - 19").

Placed in `site/src/components/DateTimeRangeFilter/` so other pages can
reuse it. Expression parsing and trigger-label derivation live in
`timeRange.ts` next to the component; both are generic over `Date`
pairs. Depends on #28254 for the shared `filterQuery` serialization
helpers.

Part of
[AIGOV-580](https://linear.app/codercom/issue/AIGOV-580/ai-gateway-sessions-page-takes-5-10-seconds-to-load)

---
_Generated by Coder Agents on behalf of @johnstcn._$

---

**Stack:** #28254 (filterQuery fix) \u2192 #28255 (component) \u2192
#28256 (sessions page)
This commit is contained in:
Cian Johnston
2026-08-19 13:47:06 +01:00
committed by GitHub
parent 63641b98c8
commit da99941099
5 changed files with 728 additions and 0 deletions
@@ -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<typeof DateTimeRangeFilter> = {
title: "components/DateTimeRangeFilter",
component: DateTimeRangeFilter,
args: {
now: fixedNow,
value: defaultValue,
defaultValue,
onChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof DateTimeRangeFilter>;
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();
},
};
@@ -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<DateTimeRangeFilterProps> = ({
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<FieldState>({
text: "",
touched: false,
});
const [toField, setToField] = useState<FieldState>({
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 (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-label="Filter by time range"
className="grow justify-start"
style={{ flexBasis: width }}
>
<CalendarIcon className="size-4 shrink-0 text-content-secondary" />
<span className="truncate text-left">{triggerLabel}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex flex-col gap-3 p-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="time-range-from" className="text-content-primary">
From
</Label>
<Input
id="time-range-from"
aria-label="Start of time range"
aria-invalid={fromError !== null}
placeholder="now"
className={cn(fromError !== null && "border-border-destructive")}
value={fromField.text}
onChange={(event) => {
setFromField({ text: event.target.value, touched: true });
}}
onBlur={normalizeFrom}
/>
{fromError !== null && (
<span className="text-sm text-content-destructive">
{fromError}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="time-range-to" className="text-content-primary">
To
</Label>
<Input
id="time-range-to"
aria-label="End of time range"
aria-invalid={toError !== null}
placeholder="now"
className={cn(toError !== null && "border-border-destructive")}
value={toField.text}
onChange={(event) => {
setToField({ text: event.target.value, touched: true });
}}
onBlur={normalizeTo}
/>
{toError !== null && (
<span className="text-sm text-content-destructive">
{toError}
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
{rangeError !== null && (
<span className="text-sm text-content-destructive">
{rangeError}
</span>
)}
<Button
size="sm"
disabled={applyDisabled}
onClick={() => {
if (parsedFrom && parsedTo) {
onChange({
startedAfter: parsedFrom,
startedBefore: parsedTo,
});
}
setOpen(false);
}}
>
Apply
</Button>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border-default p-4 text-sm text-content-secondary">
<span className="font-semibold text-content-primary">Examples:</span>
<span>{EXAMPLES.join(" | ")}</span>
<span>Defaults to midnight if no time is provided.</span>
<span>Defaults to current day if no date is provided.</span>
</div>
</PopoverContent>
</Popover>
);
};
@@ -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");
});
});
@@ -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)}`;
};
+2
View File
@@ -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;