mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
@@ -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)}`;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user