mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add date filtering to settings usage page (#23381)
## What Replace the hardcoded 30-day date window on the Agents Settings Usage page (`/agents/settings/usage`) with an interactive date-range picker. ## Why The usage page previously showed a static 30-day lookback with no way for admins to adjust the time window. The backend API already supports `start_date`/`end_date` parameters — only the frontend was missing the controls. ## How - Reuse the existing `DateRange` picker component from Template Insights - Store selected dates in URL search params (`startDate`/`endDate`) for persistence across navigation - Default to last 30 days when no params are present - Memoize date range for stable React Query keys - Both the user list and per-user drill-in views respect the selected range - Normalize exclusive end-date boundaries for display - Preset clicks (Last 7 days, etc.) apply immediately with a single click - Semi-transparent loading overlay during data refetch ## Changes - `site/src/pages/AgentsPage/SettingsPageContent.tsx` — Replace hardcoded range with interactive picker, URL param state, memoized params, refetch overlay - `site/src/pages/AgentsPage/SettingsPageContent.stories.tsx` — Add stories for date filter interaction, preset single-click, and refetch overlay - `site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx` — Detect preset clicks and apply immediately (single-click) instead of requiring two clicks ## Validation - TypeScript ✅ - Biome lint ✅ - Storybook tests 13/13 ✅ - Visual verification via Storybook ✅
This commit is contained in:
@@ -114,6 +114,17 @@ const setupUsageSpies = (opts?: {
|
||||
spyOn(API, "getChatCostSummary").mockResolvedValue(mockCostSummary);
|
||||
};
|
||||
|
||||
const getChatCostUsersCalls = () =>
|
||||
(
|
||||
API.getChatCostUsers as typeof API.getChatCostUsers & {
|
||||
mock: {
|
||||
calls: Array<[Parameters<typeof API.getChatCostUsers>[0]]>;
|
||||
};
|
||||
}
|
||||
).mock.calls;
|
||||
|
||||
const fixedNow = dayjs("2026-03-12T00:00:00Z");
|
||||
|
||||
// ── Meta ───────────────────────────────────────────────────────
|
||||
|
||||
const meta = {
|
||||
@@ -124,7 +135,7 @@ const meta = {
|
||||
activeSection: "behavior",
|
||||
canManageChatModelConfigs: false,
|
||||
canSetSystemPrompt: true,
|
||||
now: dayjs("2026-03-12T00:00:00Z"),
|
||||
now: fixedNow,
|
||||
},
|
||||
parameters: {
|
||||
user: MockUserOwner,
|
||||
@@ -320,6 +331,120 @@ export const UsageUserList: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const UsageDateFilter: Story = {
|
||||
args: {
|
||||
activeSection: "usage",
|
||||
canManageChatModelConfigs: true,
|
||||
},
|
||||
beforeEach: () => {
|
||||
setupUsageSpies();
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const defaultStartDate = fixedNow.subtract(30, "day").toISOString();
|
||||
const defaultStartLabel = fixedNow
|
||||
.subtract(30, "day")
|
||||
.format("MMM D, YYYY");
|
||||
const defaultEndLabel = fixedNow.format("MMM D, YYYY");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getChatCostUsers).toHaveBeenCalled();
|
||||
});
|
||||
const initialCallCount = getChatCostUsersCalls().length;
|
||||
|
||||
const dateRangeTrigger = await canvas.findByRole("button", {
|
||||
name: new RegExp(`${defaultStartLabel}.*${defaultEndLabel}`),
|
||||
});
|
||||
|
||||
await userEvent.click(dateRangeTrigger);
|
||||
const last7Days = await body.findByRole("button", {
|
||||
name: "Last 7 days",
|
||||
});
|
||||
|
||||
await userEvent.click(last7Days);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(body.queryByRole("button", { name: "Last 7 days" })).toBeNull();
|
||||
const calls = getChatCostUsersCalls();
|
||||
expect(calls.length).toBeGreaterThan(initialCallCount);
|
||||
|
||||
const latestCall = calls.at(-1)?.[0];
|
||||
expect(latestCall).toBeDefined();
|
||||
if (!latestCall) {
|
||||
throw new Error("Expected getChatCostUsers to be called with params.");
|
||||
}
|
||||
|
||||
expect(latestCall.start_date).not.toBe(defaultStartDate);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const UsageDateFilterRefetchOverlay: Story = {
|
||||
args: {
|
||||
activeSection: "usage",
|
||||
canManageChatModelConfigs: true,
|
||||
},
|
||||
beforeEach: () => {
|
||||
let requestCount = 0;
|
||||
let resolveRefetch:
|
||||
| ((value: TypesGen.ChatCostUsersResponse) => void)
|
||||
| undefined;
|
||||
const refetchPromise = new Promise<TypesGen.ChatCostUsersResponse>(
|
||||
(resolve) => {
|
||||
resolveRefetch = resolve;
|
||||
},
|
||||
);
|
||||
|
||||
spyOn(API, "getChatCostUsers").mockImplementation(async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return mockUsersResponse;
|
||||
}
|
||||
|
||||
return refetchPromise;
|
||||
});
|
||||
spyOn(API, "getUser").mockResolvedValue(mockUserProfile);
|
||||
spyOn(API, "getChatCostSummary").mockResolvedValue(mockCostSummary);
|
||||
|
||||
return () => {
|
||||
resolveRefetch?.({
|
||||
...mockUsersResponse,
|
||||
start_date: "2026-03-06T00:00:00Z",
|
||||
end_date: "2026-03-12T00:00:00Z",
|
||||
});
|
||||
};
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const defaultStartLabel = fixedNow
|
||||
.subtract(30, "day")
|
||||
.format("MMM D, YYYY");
|
||||
const defaultEndLabel = fixedNow.format("MMM D, YYYY");
|
||||
|
||||
await canvas.findByText("Alice Liddell");
|
||||
|
||||
await step(
|
||||
"show a refetch overlay after changing the date range",
|
||||
async () => {
|
||||
const dateRangeTrigger = await canvas.findByRole("button", {
|
||||
name: new RegExp(`${defaultStartLabel}.*${defaultEndLabel}`),
|
||||
});
|
||||
|
||||
await userEvent.click(dateRangeTrigger);
|
||||
await userEvent.click(
|
||||
await body.findByRole("button", { name: "Last 7 days" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
await canvas.findByRole("status", { name: "Refreshing usage" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const UsageEmpty: Story = {
|
||||
args: {
|
||||
activeSection: "usage",
|
||||
|
||||
@@ -52,6 +52,10 @@ import TextareaAutosize from "react-textarea-autosize";
|
||||
import { formatTokenCount } from "utils/analytics";
|
||||
import { formatCostMicros } from "utils/currency";
|
||||
import { humanDuration } from "utils/time";
|
||||
import {
|
||||
DateRange,
|
||||
type DateRangeValue,
|
||||
} from "../TemplatePage/TemplateInsightsPage/DateRange";
|
||||
import { ChatCostSummaryView } from "./ChatCostSummaryView";
|
||||
import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel";
|
||||
import { InsightsContent } from "./InsightsContent";
|
||||
@@ -77,6 +81,37 @@ const AdminBadge: FC = () => (
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
const usageStartDateSearchParam = "startDate";
|
||||
const usageEndDateSearchParam = "endDate";
|
||||
|
||||
const getDefaultUsageDateRange = (now?: dayjs.Dayjs): DateRangeValue => {
|
||||
const end = now ?? dayjs();
|
||||
return {
|
||||
startDate: end.subtract(30, "day").toDate(),
|
||||
endDate: end.toDate(),
|
||||
};
|
||||
};
|
||||
|
||||
const formatUsageDateRange = (
|
||||
value: DateRangeValue,
|
||||
options?: {
|
||||
endDateIsExclusive?: boolean;
|
||||
},
|
||||
) => {
|
||||
// Custom ranges keep the raw API end boundary, which can be midnight on
|
||||
// the following day for full-day selections. Show the inclusive day in
|
||||
// the drill-in label without changing the query params.
|
||||
const displayEndDate =
|
||||
options?.endDateIsExclusive &&
|
||||
dayjs(value.endDate).isSame(dayjs(value.endDate).startOf("day"))
|
||||
? dayjs(value.endDate).subtract(1, "day")
|
||||
: dayjs(value.endDate);
|
||||
|
||||
return `${dayjs(value.startDate).format("MMM D")} – ${displayEndDate.format(
|
||||
"MMM D, YYYY",
|
||||
)}`;
|
||||
};
|
||||
|
||||
const UserRow: FC<{
|
||||
user: TypesGen.ChatCostUserRollup;
|
||||
onSelect: (user: TypesGen.ChatCostUserRollup) => void;
|
||||
@@ -132,21 +167,66 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(searchFilter, 300);
|
||||
const [page, setPage] = useState(1);
|
||||
const dateRange = (() => {
|
||||
const end = now ?? dayjs();
|
||||
const start = end.subtract(30, "day");
|
||||
return {
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString(),
|
||||
rangeLabel: `${start.format("MMM D")} – ${end.format("MMM D, YYYY")}`,
|
||||
};
|
||||
})();
|
||||
const startDateParam =
|
||||
searchParams.get(usageStartDateSearchParam)?.trim() ?? "";
|
||||
const endDateParam = searchParams.get(usageEndDateSearchParam)?.trim() ?? "";
|
||||
const [defaultDateRange] = useState(() => getDefaultUsageDateRange(now));
|
||||
let dateRange = defaultDateRange;
|
||||
let hasExplicitDateRange = false;
|
||||
|
||||
if (startDateParam && endDateParam) {
|
||||
const parsedStartDate = new Date(startDateParam);
|
||||
const parsedEndDate = new Date(endDateParam);
|
||||
|
||||
if (
|
||||
!Number.isNaN(parsedStartDate.getTime()) &&
|
||||
!Number.isNaN(parsedEndDate.getTime()) &&
|
||||
parsedStartDate.getTime() <= parsedEndDate.getTime()
|
||||
) {
|
||||
dateRange = {
|
||||
startDate: parsedStartDate,
|
||||
endDate: parsedEndDate,
|
||||
};
|
||||
hasExplicitDateRange = true;
|
||||
}
|
||||
}
|
||||
|
||||
const dateRangeParams = {
|
||||
start_date: dateRange.startDate.toISOString(),
|
||||
end_date: dateRange.endDate.toISOString(),
|
||||
};
|
||||
const { endDate } = dateRange;
|
||||
const isExclusiveMidnightEnd =
|
||||
hasExplicitDateRange &&
|
||||
endDate.getHours() === 0 &&
|
||||
endDate.getMinutes() === 0 &&
|
||||
endDate.getSeconds() === 0 &&
|
||||
endDate.getMilliseconds() === 0;
|
||||
const displayDateRange = isExclusiveMidnightEnd
|
||||
? {
|
||||
startDate: dateRange.startDate,
|
||||
endDate: new Date(endDate.getTime() - 1),
|
||||
}
|
||||
: dateRange;
|
||||
const dateRangeLabel = formatUsageDateRange(dateRange, {
|
||||
endDateIsExclusive: hasExplicitDateRange,
|
||||
});
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const onDateRangeChange = (value: DateRangeValue) => {
|
||||
// Reset pagination but preserve user selection and other params.
|
||||
setPage(1);
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set(usageStartDateSearchParam, value.startDate.toISOString());
|
||||
next.set(usageEndDateSearchParam, value.endDate.toISOString());
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const usersQuery = useQuery({
|
||||
...chatCostUsers({
|
||||
start_date: dateRange.startDate,
|
||||
end_date: dateRange.endDate,
|
||||
...dateRangeParams,
|
||||
username: debouncedSearch || undefined,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
@@ -162,10 +242,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
const selectedUser = selectedUserQuery.data ?? null;
|
||||
|
||||
const summaryQuery = useQuery({
|
||||
...chatCostSummary(selectedUserId ?? "me", {
|
||||
start_date: dateRange.startDate,
|
||||
end_date: dateRange.endDate,
|
||||
}),
|
||||
...chatCostSummary(selectedUserId ?? "me", dateRangeParams),
|
||||
enabled: selectedUserId !== null,
|
||||
});
|
||||
const totalCount = usersQuery.data?.count ?? 0;
|
||||
@@ -182,9 +259,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
}
|
||||
badge={<AdminBadge />}
|
||||
action={
|
||||
<span className="text-xs text-content-secondary">
|
||||
{dateRange.rangeLabel}
|
||||
</span>
|
||||
<DateRange value={displayDateRange} onChange={onDateRangeChange} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -266,7 +341,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
/>
|
||||
<div className="min-w-0 text-xs text-content-secondary">
|
||||
<div>User ID: {selectedUser.id}</div>
|
||||
<div>{dateRange.rangeLabel}</div>
|
||||
<div>{dateRangeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChatCostSummaryView
|
||||
@@ -331,64 +406,80 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usersQuery.data &&
|
||||
(usersQuery.data.users.length === 0 ? (
|
||||
<p className="py-12 text-center text-content-secondary">
|
||||
No usage data for this period.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border border-border-default">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-left text-xs uppercase tracking-wide text-content-secondary">
|
||||
<TableHead className="px-4 py-3">User</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Total Cost
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Messages
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Chats
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Input Tokens
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Output Tokens
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Cache Read
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Cache Write
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usersQuery.data.users.map((user) => (
|
||||
<UserRow
|
||||
key={user.user_id}
|
||||
user={user}
|
||||
onSelect={(u) => {
|
||||
setSearchParams({ user: u.user_id });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{usersQuery.data && (
|
||||
<div className="relative">
|
||||
{usersQuery.isFetching && !usersQuery.isLoading && (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Refreshing usage"
|
||||
className="absolute inset-0 z-10 flex items-center justify-center bg-surface-primary/50"
|
||||
>
|
||||
<Spinner size="lg" loading className="text-content-secondary" />
|
||||
</div>
|
||||
<PaginationWidgetBase
|
||||
totalRecords={usersQuery.data.count}
|
||||
currentPage={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
hasPreviousPage={hasPreviousPage}
|
||||
hasNextPage={hasNextPage}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
)}
|
||||
{usersQuery.data.users.length === 0 ? (
|
||||
<p className="py-12 text-center text-content-secondary">
|
||||
No usage data for this period.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border border-border-default">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-left text-xs uppercase tracking-wide text-content-secondary">
|
||||
<TableHead className="px-4 py-3">User</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Total Cost
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Messages
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Chats
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Input Tokens
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Output Tokens
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Cache Read
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">
|
||||
Cache Write
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usersQuery.data.users.map((user) => (
|
||||
<UserRow
|
||||
key={user.user_id}
|
||||
user={user}
|
||||
onSelect={(u) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("user", u.user_id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<PaginationWidgetBase
|
||||
totalRecords={usersQuery.data.count}
|
||||
currentPage={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
hasPreviousPage={hasPreviousPage}
|
||||
hasNextPage={hasNextPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ interface DateRangeProps {
|
||||
|
||||
export const DateRange: FC<DateRangeProps> = ({ value, onChange }) => {
|
||||
const selectionStatusRef = useRef<"idle" | "selecting">("idle");
|
||||
const selectionSourceRef = useRef<"preset" | null>(null);
|
||||
const [ranges, setRanges] = useState<RangesState>([
|
||||
{
|
||||
...value,
|
||||
@@ -43,6 +44,20 @@ export const DateRange: FC<DateRangeProps> = ({ value, onChange }) => {
|
||||
]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const applyRangeSelection = (range: RangesState[number]) => {
|
||||
selectionStatusRef.current = "idle";
|
||||
const startDate = range.startDate as Date;
|
||||
const endDate = range.endDate as Date;
|
||||
const now = new Date();
|
||||
onChange({
|
||||
startDate: dayjs(startDate).startOf("day").toDate(),
|
||||
endDate: dayjs(endDate).isSame(dayjs(), "day")
|
||||
? dayjs(now).startOf("hour").add(1, "hour").toDate()
|
||||
: dayjs(endDate).startOf("day").add(1, "day").toDate(),
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -53,74 +68,77 @@ export const DateRange: FC<DateRangeProps> = ({ value, onChange }) => {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0 overflow-x-hidden">
|
||||
<DateRangePicker
|
||||
css={styles.wrapper}
|
||||
onChange={(item) => {
|
||||
const range = item.selection;
|
||||
setRanges([range]);
|
||||
|
||||
// When it is the first selection, we don't want to close the popover
|
||||
// We have to do that ourselves because the library doesn't provide a way to do it
|
||||
if (selectionStatusRef.current === "idle") {
|
||||
selectionStatusRef.current = "selecting";
|
||||
return;
|
||||
}
|
||||
|
||||
selectionStatusRef.current = "idle";
|
||||
const startDate = range.startDate as Date;
|
||||
const endDate = range.endDate as Date;
|
||||
const now = new Date();
|
||||
onChange({
|
||||
startDate: dayjs(startDate).startOf("day").toDate(),
|
||||
endDate: dayjs(endDate).isSame(dayjs(), "day")
|
||||
? dayjs(now).startOf("hour").add(1, "hour").toDate()
|
||||
: dayjs(endDate).startOf("day").add(1, "day").toDate(),
|
||||
});
|
||||
setOpen(false);
|
||||
<div
|
||||
onClickCapture={(event) => {
|
||||
selectionSourceRef.current =
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.closest(".rdrStaticRange")
|
||||
? "preset"
|
||||
: null;
|
||||
}}
|
||||
moveRangeOnFirstSelection={false}
|
||||
months={2}
|
||||
ranges={ranges}
|
||||
maxDate={new Date()}
|
||||
direction="horizontal"
|
||||
staticRanges={createStaticRanges([
|
||||
{
|
||||
label: "Today",
|
||||
range: () => ({
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Yesterday",
|
||||
range: () => ({
|
||||
startDate: dayjs().subtract(1, "day").toDate(),
|
||||
endDate: dayjs().subtract(1, "day").toDate(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
range: () => ({
|
||||
startDate: dayjs().subtract(6, "day").toDate(),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 14 days",
|
||||
range: () => ({
|
||||
startDate: dayjs().subtract(13, "day").toDate(),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 30 days",
|
||||
range: () => ({
|
||||
startDate: dayjs().subtract(29, "day").toDate(),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
])}
|
||||
/>
|
||||
>
|
||||
<DateRangePicker
|
||||
css={styles.wrapper}
|
||||
onChange={(item) => {
|
||||
const range = item.selection;
|
||||
const isPresetSelection = selectionSourceRef.current === "preset";
|
||||
selectionSourceRef.current = null;
|
||||
setRanges([range]);
|
||||
|
||||
// When it is the first calendar selection, we don't want to
|
||||
// close the popover. Presets already provide a complete range,
|
||||
// so apply them immediately.
|
||||
if (selectionStatusRef.current === "idle" && !isPresetSelection) {
|
||||
selectionStatusRef.current = "selecting";
|
||||
return;
|
||||
}
|
||||
|
||||
applyRangeSelection(range);
|
||||
}}
|
||||
moveRangeOnFirstSelection={false}
|
||||
months={2}
|
||||
ranges={ranges}
|
||||
maxDate={new Date()}
|
||||
direction="horizontal"
|
||||
staticRanges={createStaticRanges([
|
||||
{
|
||||
label: "Today",
|
||||
range: () => ({
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Yesterday",
|
||||
range: () => ({
|
||||
startDate: dayjs().subtract(1, "day").toDate(),
|
||||
endDate: dayjs().subtract(1, "day").toDate(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
range: () => ({
|
||||
startDate: dayjs().subtract(6, "day").toDate(),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 14 days",
|
||||
range: () => ({
|
||||
startDate: dayjs().subtract(13, "day").toDate(),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 30 days",
|
||||
range: () => ({
|
||||
startDate: dayjs().subtract(29, "day").toDate(),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user