feat(site): add shared DateTimeRangeFilter component - #28255
Conversation
stringifyFilter only quoted values containing spaces, so filter values like RFC 3339 timestamps (2026-08-16T20:42:00Z) were emitted unquoted and the backend query parser rejected their colons. Quote values containing colons too; they were never valid unquoted. Extract parseFilterQuery/stringifyFilter into filterQuery.ts with unit tests.
A text-expression datetime range picker with From/To inputs accepting now, clock times, dates, or date and time pairs, with inline errors, blur normalization, and clamping. Moved to site/src/components so other pages can reuse it.
| 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)}`; | ||
| }; |
There was a problem hiding this comment.
I'm open to suggestions on the actual copy here.
The shared component's parser and trigger-label logic lived in timeRange.ts with no unit tests; coverage was only via story play functions. Add vitest coverage for the expression grammar and the label derivation cases.
There was a problem hiding this comment.
Pull request overview
Adds a reusable, text-expression based datetime range filter UI to the frontend so multiple pages (notably the AI Gateway sessions page in the PR stack) can apply consistent started-after / started-before filtering with concise trigger labels.
Changes:
- Introduces
DateTimeRangeFilterpopover component with From/To expression inputs, inline validation, and an Apply action. - Adds parsing/labeling utilities (
parseTimeExpression,formatTriggerLabel) plus unit tests for these pure functions. - Adds Storybook stories with interaction coverage for the component’s key behaviors (prefill, blur normalization, clamping, validation, apply/cancel).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| site/src/components/DateTimeRangeFilter/timeRange.ts | Implements strict parsing for supported time expressions and derives trigger labels from resolved ranges. |
| site/src/components/DateTimeRangeFilter/timeRange.test.ts | Unit tests for expression parsing and trigger label formatting. |
| site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.tsx | New shared UI component: popover trigger, inputs, validation, normalization/clamping, and apply behavior. |
| site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.stories.tsx | Storybook stories and interaction tests validating the UX and state transitions. |
Suppressed comments (2)
site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.tsx:125
- Empty input is treated as non-error (fromError/toError stay null), so the Apply button can become enabled even though parsedFrom/parsedTo are null. Clicking Apply then closes the popover without applying anything. Also, Apply is enabled based only on "touched" (not actual value changes), which contradicts the comment and allows no-op applies after reverting edits.
const fromError =
fromField.text !== "" && parsedFrom === null ? INVALID_TIME_MESSAGE : null;
const toError =
toField.text !== "" && parsedTo === null ? INVALID_TIME_MESSAGE : null;
site/src/components/DateTimeRangeFilter/DateTimeRangeFilter.tsx:109
- normalize() currently skips clamping/normalizing whenever the current text is "now", so an out-of-order "now" boundary (for example, To="now" when From is in the future, or From="now" when To is in the past) will not clamp against its sibling as described. This leaves the user stuck with a range error until they manually edit the field away from "now".
setField((current) => {
if (isNowExpression(current.text) || parsed === null) {
return current;
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
) `stringifyFilter` in the shared `Filter` component only quoted values containing spaces. Filter values like RFC 3339 timestamps (`2026-08-16T20:42:00Z`) contain colons but no spaces, so when any filter was edited and the whole query re-serialized, the timestamp went out unquoted and the backend `searchTerms` parser rejected it (`Query element ... can only contain 1 ':'`). Quote values containing colons as well; they were never valid unquoted because the backend parser already rejects them. Extract `parseFilterQuery`/`stringifyFilter` into `filterQuery.ts` with unit tests, including a round-trip of quoted timestamps. 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)
Split the shared blur normalizer into normalizeFrom and normalizeTo, each reading its own parsed boundary from render scope. Source the parse formats from the shared DATE_FORMAT constants and drop the single-digit-hour variants so input is padded-only. Add a comment pointing future maintainers at formik and yup if the control grows.
…lter The component's isNowExpression duplicated the /^now$/i regex in parseTimeExpression. Export one predicate from timeRange.ts and use it in both the parser and the blur normalizers.
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 intimeRange.tsnext to the component; both are generic overDatepairs. Depends on #28254 for the sharedfilterQueryserialization helpers.Part of AIGOV-580
Generated by Coder Agents on behalf of @johnstcn.$
Stack: #28254 (filterQuery fix) \u2192 #28255 (component) \u2192 #28256 (sessions page)