refactor(site): replace react-date-range with shadcn Calendar + DateRangePicker (#23495)

This commit is contained in:
Danielle Maywood
2026-03-24 17:01:35 +00:00
committed by GitHub
parent 42fdd5ed2a
commit def4f93eb4
10 changed files with 733 additions and 297 deletions
+1 -2
View File
@@ -105,7 +105,7 @@
"react": "19.2.2",
"react-color": "2.19.3",
"react-confetti": "6.4.0",
"react-date-range": "1.4.0",
"react-day-picker": "9.14.0",
"react-dom": "19.2.2",
"react-markdown": "9.1.0",
"react-query": "npm:@tanstack/react-query@5.77.0",
@@ -162,7 +162,6 @@
"@types/novnc__novnc": "1.5.0",
"@types/react": "19.2.7",
"@types/react-color": "3.0.13",
"@types/react-date-range": "1.4.4",
"@types/react-dom": "19.2.3",
"@types/react-syntax-highlighter": "15.5.13",
"@types/react-virtualized-auto-sizer": "1.0.8",
+31 -50
View File
@@ -223,9 +223,9 @@ importers:
react-confetti:
specifier: 6.4.0
version: 6.4.0(react@19.2.2)
react-date-range:
specifier: 1.4.0
version: 1.4.0(date-fns@2.30.0)(react@19.2.2)
react-day-picker:
specifier: 9.14.0
version: 9.14.0(react@19.2.2)
react-dom:
specifier: 19.2.2
version: 19.2.2(react@19.2.2)
@@ -389,9 +389,6 @@ importers:
'@types/react-color':
specifier: 3.0.13
version: 3.0.13(@types/react@19.2.7)
'@types/react-date-range':
specifier: 1.4.4
version: 1.4.4
'@types/react-dom':
specifier: 19.2.3
version: 19.2.3(@types/react@19.2.7)
@@ -878,6 +875,9 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==, tarball: https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz}
engines: {node: '>=18'}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==, tarball: https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz}
'@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==, tarball: https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz}
@@ -2579,6 +2579,10 @@ packages:
peerDependencies:
'@swc/core': '*'
'@tabby_ai/hijri-converter@1.0.5':
resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==, tarball: https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz}
engines: {node: '>=16.0.0'}
'@tailwindcss/typography@0.5.19':
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==, tarball: https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz}
peerDependencies:
@@ -2901,9 +2905,6 @@ packages:
peerDependencies:
'@types/react': '*'
'@types/react-date-range@1.4.4':
resolution: {integrity: sha512-9Y9NyNgaCsEVN/+O4HKuxzPbVjRVBGdOKRxMDcsTRWVG62lpYgnxefNckTXDWup8FvczoqPW0+ESZR6R1yymDg==, tarball: https://registry.npmjs.org/@types/react-date-range/-/react-date-range-1.4.4.tgz}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==, tarball: https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz}
peerDependencies:
@@ -3504,9 +3505,6 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==, tarball: https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz}
classnames@2.3.2:
resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==, tarball: https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz}
cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==, tarball: https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz}
engines: {node: '>=8'}
@@ -3851,9 +3849,11 @@ packages:
resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==, tarball: https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz}
engines: {node: '>=20'}
date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==, tarball: https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz}
engines: {node: '>=0.11'}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==, tarball: https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==, tarball: https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz}
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==, tarball: https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz}
@@ -5898,11 +5898,11 @@ packages:
peerDependencies:
react: ^16.3.0 || ^17.0.1 || ^18.0.0 || ^19.0.0
react-date-range@1.4.0:
resolution: {integrity: sha512-+9t0HyClbCqw1IhYbpWecjsiaftCeRN5cdhsi9v06YdimwyMR2yYHWcgVn3URwtN/txhqKpEZB6UX1fHpvK76w==, tarball: https://registry.npmjs.org/react-date-range/-/react-date-range-1.4.0.tgz}
react-day-picker@9.14.0:
resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==, tarball: https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz}
engines: {node: '>=18'}
peerDependencies:
date-fns: 2.0.0-alpha.7 || >=2.0.0
react: ^0.14 || ^15.0.0-rc || >=15.0
react: '>=16.8.0'
react-docgen-typescript@2.4.0:
resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==, tarball: https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz}
@@ -5943,11 +5943,6 @@ packages:
react-is@19.1.1:
resolution: {integrity: sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==, tarball: https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz}
react-list@0.8.17:
resolution: {integrity: sha512-pgmzGi0G5uGrdHzMhgO7KR1wx5ZXVvI3SsJUmkblSAKtewIhMwbQiMuQiTE83ozo04BQJbe0r3WIWzSO0dR1xg==, tarball: https://registry.npmjs.org/react-list/-/react-list-0.8.17.tgz}
peerDependencies:
react: 0.14 || 15 - 18
react-markdown@9.1.0:
resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==, tarball: https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz}
peerDependencies:
@@ -6264,9 +6259,6 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==, tarball: https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz}
shallow-equal@1.2.1:
resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==, tarball: https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, tarball: https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz}
engines: {node: '>=8'}
@@ -7605,6 +7597,8 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
'@date-fns/tz@1.4.1': {}
'@emnapi/core@1.7.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -9442,6 +9436,8 @@ snapshots:
'@swc/counter': 0.1.3
jsonc-parser: 3.2.0
'@tabby_ai/hijri-converter@1.0.5': {}
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.18(yaml@2.7.0))':
dependencies:
postcss-selector-parser: 6.0.10
@@ -9825,11 +9821,6 @@ snapshots:
'@types/react': 19.2.7
'@types/reactcss': 1.2.13(@types/react@19.2.7)
'@types/react-date-range@1.4.4':
dependencies:
'@types/react': 19.2.7
date-fns: 2.30.0
'@types/react-dom@18.3.7(@types/react@19.2.7)':
dependencies:
'@types/react': 19.2.7
@@ -10501,8 +10492,6 @@ snapshots:
dependencies:
clsx: 2.1.1
classnames@2.3.2: {}
cli-cursor@3.1.0:
dependencies:
restore-cursor: 3.1.0
@@ -10861,9 +10850,9 @@ snapshots:
whatwg-mimetype: 4.0.0
whatwg-url: 15.1.0
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.26.10
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
dayjs@1.11.19: {}
@@ -13527,14 +13516,13 @@ snapshots:
react: 19.2.2
tween-functions: 1.2.0
react-date-range@1.4.0(date-fns@2.30.0)(react@19.2.2):
react-day-picker@9.14.0(react@19.2.2):
dependencies:
classnames: 2.3.2
date-fns: 2.30.0
prop-types: 15.8.1
'@date-fns/tz': 1.4.1
'@tabby_ai/hijri-converter': 1.0.5
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
react: 19.2.2
react-list: 0.8.17(react@19.2.2)
shallow-equal: 1.2.1
react-docgen-typescript@2.4.0(typescript@5.6.3):
dependencies:
@@ -13578,11 +13566,6 @@ snapshots:
react-is@19.1.1: {}
react-list@0.8.17(react@19.2.2):
dependencies:
prop-types: 15.8.1
react: 19.2.2
react-markdown@9.1.0(@types/react@19.2.7)(react@19.2.2):
dependencies:
'@types/hast': 3.0.4
@@ -13991,8 +13974,6 @@ snapshots:
setprototypeof@1.2.0: {}
shallow-equal@1.2.1: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -52,12 +52,12 @@ import { useSearchParams } from "react-router";
import TextareaAutosize from "react-textarea-autosize";
import { formatTokenCount } from "utils/analytics";
import { formatCostMicros } from "utils/currency";
import {
DateRange,
type DateRangeValue,
} from "../TemplatePage/TemplateInsightsPage/DateRange";
import { ChatCostSummaryView } from "./components/ChatCostSummaryView";
import { ChatModelAdminPanel } from "./components/ChatModelAdminPanel/ChatModelAdminPanel";
import {
DateRangePicker,
type DateRangeValue,
} from "./components/DateRangePicker/DateRangePicker";
import { InsightsContent } from "./components/InsightsContent";
import { LimitsTab } from "./components/LimitsTab";
import { MCPServerAdminPanel } from "./components/MCPServerAdminPanel";
@@ -260,7 +260,10 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
}
badge={<AdminBadge />}
action={
<DateRange value={displayDateRange} onChange={onDateRangeChange} />
<DateRangePicker
value={displayDateRange}
onChange={onDateRangeChange}
/>
}
/>
);
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import type { DateRange } from "react-day-picker";
import { Calendar } from "./Calendar";
const meta: Meta<typeof Calendar> = {
title: "components/Calendar",
component: Calendar,
decorators: [
(Story) => (
<div className="rounded-lg border border-solid border-border-default w-fit">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Calendar>;
export const Single: Story = {
args: {
mode: "single",
selected: new Date("2025-03-15"),
},
};
export const Range: Story = {
render: function RangeStory() {
const [range, setRange] = useState<DateRange>({
from: new Date("2025-03-10"),
to: new Date("2025-03-18"),
});
return (
<Calendar
mode="range"
selected={range}
onSelect={(r) => r && setRange(r)}
numberOfMonths={2}
/>
);
},
};
export const TwoMonths: Story = {
args: {
mode: "single",
numberOfMonths: 2,
selected: new Date("2025-03-15"),
},
};
export const DisabledFutureDates: Story = {
args: {
mode: "single",
selected: new Date("2025-03-15"),
disabled: { after: new Date("2025-03-20") },
},
};
@@ -0,0 +1,206 @@
/**
* Adapted from shadcn/ui on 2025-03-24.
* @see {@link https://ui.shadcn.com/docs/components/calendar}
*
* Built on top of React DayPicker v9. Styled with Tailwind using the
* project's existing design tokens so it matches every other primitive
* in the component library.
*/
import { Button, type ButtonProps } from "components/Button/Button";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps } from "react";
import {
type DayButton,
DayPicker,
getDefaultClassNames,
} from "react-day-picker";
import { cn } from "utils/cn";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "subtle",
formatters,
components,
...props
}: ComponentProps<typeof DayPicker> & {
buttonVariant?: ButtonProps["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-surface-primary group/calendar p-3 [--cell-size:2rem]",
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months,
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav,
),
button_previous: cn(
"h-[--cell-size] w-[--cell-size] select-none p-0",
"inline-flex items-center justify-center rounded-md",
"bg-transparent border-0 cursor-pointer",
"text-content-secondary hover:text-content-primary hover:bg-surface-secondary",
"aria-disabled:opacity-50",
defaultClassNames.button_previous,
),
button_next: cn(
"h-[--cell-size] w-[--cell-size] select-none p-0",
"inline-flex items-center justify-center rounded-md",
"bg-transparent border-0 cursor-pointer",
"text-content-secondary hover:text-content-primary hover:bg-surface-secondary",
"aria-disabled:opacity-50",
defaultClassNames.button_next,
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption,
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"has-focus:border-content-link border-border-default relative rounded-md border",
defaultClassNames.dropdown_root,
),
dropdown: cn(
"bg-surface-primary absolute inset-0 opacity-0",
defaultClassNames.dropdown,
),
caption_label: cn(
"select-none font-medium text-sm text-content-primary",
defaultClassNames.caption_label,
),
table:
"w-full border-collapse border-0 [&_td]:border-0 [&_th]:border-0",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-content-secondary flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday,
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header,
),
week_number: cn(
"text-content-secondary select-none text-[0.8rem]",
defaultClassNames.week_number,
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center",
"[&:first-child[data-selected=true]_button]:rounded-l-md",
"[&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day,
),
range_start: cn(
"bg-surface-tertiary rounded-l-md",
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"bg-surface-tertiary rounded-r-md",
defaultClassNames.range_end,
),
today: cn(
"bg-surface-tertiary text-content-primary rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-content-disabled aria-selected:text-content-disabled",
defaultClassNames.outside,
),
disabled: cn(
"text-content-disabled opacity-50",
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Chevron: ({
className,
orientation,
size: _size,
disabled: _disabled,
...rest
}) => {
const Icon =
orientation === "left" ? ChevronLeftIcon : ChevronRightIcon;
return <Icon className={cn("size-4", className)} {...rest} />;
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...weekProps }) => (
<td {...weekProps}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
),
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
return (
<Button
variant="subtle"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none",
"data-[selected-single=true]:bg-surface-invert-primary data-[selected-single=true]:text-content-invert",
"data-[range-middle=true]:bg-surface-tertiary data-[range-middle=true]:text-content-primary",
"data-[range-start=true]:bg-surface-invert-primary data-[range-start=true]:text-content-invert",
"data-[range-end=true]:bg-surface-invert-primary data-[range-end=true]:text-content-invert",
"data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md",
"group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10",
"group-data-[focused=true]/day:ring-2 group-data-[focused=true]/day:ring-content-link",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar };
@@ -0,0 +1,180 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import dayjs from "dayjs";
import { useState } from "react";
import { expect, screen, userEvent, waitFor, within } from "storybook/test";
import { DateRangePicker, type DateRangeValue } from "./DateRangePicker";
const fixedNow = dayjs("2025-03-15T12:00:00Z");
const defaultValue: DateRangeValue = {
startDate: fixedNow.subtract(30, "day").toDate(),
endDate: fixedNow.toDate(),
};
const meta: Meta<typeof DateRangePicker> = {
title: "components/DateRangePicker",
component: DateRangePicker,
};
export default meta;
type Story = StoryObj<typeof DateRangePicker>;
export const Closed: Story = {
args: {
value: defaultValue,
onChange: () => {},
},
};
export const Open: Story = {
args: {
value: defaultValue,
onChange: () => {},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole("button");
await userEvent.click(trigger);
await waitFor(() => {
expect(screen.getByText("Last 7 days")).toBeInTheDocument();
});
// All preset labels should be visible.
expect(screen.getByText("Today")).toBeInTheDocument();
expect(screen.getByText("Yesterday")).toBeInTheDocument();
expect(screen.getByText("Last 14 days")).toBeInTheDocument();
expect(screen.getByText("Last 30 days")).toBeInTheDocument();
// The selected range should be displayed above the calendar.
// The start date appears in both the trigger and the range
// display, so expect two instances.
const startDateLabel = dayjs(defaultValue.startDate).format("MMM D, YYYY");
expect(screen.getAllByText(startDateLabel)).toHaveLength(2);
// Cancel and Apply buttons should be visible.
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Apply" })).toBeInTheDocument();
},
};
export const SelectPreset: Story = {
render: function SelectPresetStory() {
const [value, setValue] = useState<DateRangeValue>(defaultValue);
return <DateRangePicker value={value} onChange={setValue} />;
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const trigger = canvas.getByRole("button");
await userEvent.click(trigger);
const preset = await body.findByText("Last 7 days");
await userEvent.click(preset);
// Popover should close after selecting a preset.
await waitFor(() => {
expect(screen.queryByText("Last 7 days")).toBeNull();
});
// The trigger button text should have changed to reflect a
// narrower range than the original 30-day default.
const updatedTrigger = canvas.getByRole("button");
expect(updatedTrigger.textContent).not.toContain(
dayjs(defaultValue.startDate).format("MMM D, YYYY"),
);
},
};
export const SelectCalendarRange: Story = {
render: function SelectCalendarRangeStory() {
const [value, setValue] = useState<DateRangeValue>({
startDate: new Date("2025-03-01"),
endDate: new Date("2025-03-15"),
});
return <DateRangePicker value={value} onChange={setValue} />;
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole("button"));
// Wait for the calendar to render.
await waitFor(() => {
expect(screen.getByText("Today")).toBeInTheDocument();
});
// The calendar should render day cells.
const dayButtons = body.getAllByRole("gridcell");
expect(dayButtons.length).toBeGreaterThan(0);
// Apply button should be disabled until the range changes.
const applyButton = screen.getByRole("button", { name: "Apply" });
expect(applyButton).toBeDisabled();
},
};
export const CancelClosesWithoutApplying: Story = {
render: function CancelStory() {
const [value, setValue] = useState<DateRangeValue>(defaultValue);
return <DateRangePicker value={value} onChange={setValue} />;
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole("button");
const originalText = trigger.textContent;
await userEvent.click(trigger);
// Click Cancel.
const cancelButton = await screen.findByRole("button", {
name: "Cancel",
});
await userEvent.click(cancelButton);
// Popover should close.
await waitFor(() => {
expect(screen.queryByText("Cancel")).toBeNull();
});
// The trigger text should remain unchanged.
expect(canvas.getByRole("button").textContent).toBe(originalText);
},
};
export const CustomPresets: Story = {
args: {
value: defaultValue,
onChange: () => {},
presets: [
{
label: "This week",
range: () => ({
from: dayjs().startOf("week").toDate(),
to: new Date(),
}),
},
{
label: "This month",
range: () => ({
from: dayjs().startOf("month").toDate(),
to: new Date(),
}),
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await waitFor(() => {
expect(screen.getByText("This week")).toBeInTheDocument();
expect(screen.getByText("This month")).toBeInTheDocument();
});
// Default presets should not be present.
expect(screen.queryByText("Last 7 days")).toBeNull();
},
};
@@ -0,0 +1,243 @@
/**
* A date-range picker composed from the project's Calendar, Popover, and
* Button primitives. Replaces the legacy react-date-range based DateRange
* component with one that matches the native design language.
*/
import { Button } from "components/Button/Button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import dayjs from "dayjs";
import { CalendarIcon, MoveRightIcon } from "lucide-react";
import { type FC, useState } from "react";
import type { DateRange as DayPickerDateRange } from "react-day-picker";
import { cn } from "utils/cn";
import { Calendar } from "../Calendar/Calendar";
export type DateRangeValue = {
startDate: Date;
endDate: Date;
};
interface DateRangePreset {
label: string;
range: () => { from: Date; to: Date };
}
const defaultPresets: DateRangePreset[] = [
{
label: "Today",
range: () => ({ from: new Date(), to: new Date() }),
},
{
label: "Yesterday",
range: () => {
const d = dayjs().subtract(1, "day").toDate();
return { from: d, to: d };
},
},
{
label: "Last 7 days",
range: () => ({
from: dayjs().subtract(6, "day").toDate(),
to: new Date(),
}),
},
{
label: "Last 14 days",
range: () => ({
from: dayjs().subtract(13, "day").toDate(),
to: new Date(),
}),
},
{
label: "Last 30 days",
range: () => ({
from: dayjs().subtract(29, "day").toDate(),
to: new Date(),
}),
},
];
interface DateRangePickerProps {
value: DateRangeValue;
onChange: (value: DateRangeValue) => void;
presets?: DateRangePreset[];
}
/**
* Normalise a calendar selection into the API-friendly boundary format
* that the old component produced: startDate at midnight, endDate either
* rounded up to the next hour (if it falls on today) or to the start of
* the following day.
*/
function toBoundary(from: Date, to: Date): DateRangeValue {
const start = dayjs(from).startOf("day").toDate();
const end = dayjs(to).isSame(dayjs(), "day")
? dayjs().startOf("hour").add(1, "hour").toDate()
: dayjs(to).startOf("day").add(1, "day").toDate();
return { startDate: start, endDate: end };
}
/**
* Reverse the boundary normalization so the calendar highlights the
* inclusive end date the user originally selected, not the exclusive
* API boundary. Midnight boundaries get shifted back by one day;
* sub-day boundaries (today's rounded-up hour) stay on the same day.
*/
function fromBoundary(value: DateRangeValue): DayPickerDateRange {
const from = dayjs(value.startDate).startOf("day").toDate();
const endDayjs = dayjs(value.endDate);
const to = endDayjs.isSame(endDayjs.startOf("day"))
? endDayjs.subtract(1, "day").toDate()
: endDayjs.toDate();
return { from, to };
}
export const DateRangePicker: FC<DateRangePickerProps> = ({
value,
onChange,
presets = defaultPresets,
}) => {
const [open, setOpen] = useState(false);
// Internal selection state kept separate from the committed value
// so the user can freely adjust the range before applying. This
// uses raw calendar dates (inclusive), not the API boundary format.
const [selection, setSelection] = useState<DayPickerDateRange | undefined>(
() => fromBoundary(value),
);
const commit = () => {
if (selection?.from && selection?.to) {
onChange(toBoundary(selection.from, selection.to));
}
setOpen(false);
};
const handlePreset = (preset: DateRangePreset) => {
const { from, to } = preset.range();
setSelection({ from, to });
// Presets are a complete selection — commit immediately.
onChange(toBoundary(from, to));
setOpen(false);
};
const handleCalendarSelect = (range: DayPickerDateRange | undefined) => {
if (!range) return;
setSelection(range);
};
// Sync local selection when the popover opens so it reflects the
// latest committed value. Reverse the boundary normalization so
// the calendar highlights the correct inclusive dates.
const handleOpenChange = (next: boolean) => {
if (next) {
setSelection(fromBoundary(value));
}
setOpen(next);
};
// Compare in the same coordinate space (raw calendar dates) so
// re-selecting the identical range doesn't enable Apply.
const committed = fromBoundary(value);
const canApply =
selection?.from &&
selection?.to &&
(selection.from.getTime() !== committed.from?.getTime() ||
selection.to.getTime() !== committed.to?.getTime());
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<CalendarIcon className="size-4 text-content-secondary" />
<span>{dayjs(value.startDate).format("MMM D, YYYY")}</span>
<MoveRightIcon className="size-3.5 text-content-secondary" />
<span>{dayjs(value.endDate).format("MMM D, YYYY")}</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0 overflow-x-hidden overflow-y-auto"
align="end"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex">
{/* Presets sidebar */}
<div className="flex flex-col border-r border-border-default p-2 text-sm">
{presets.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => handlePreset(preset)}
className={cn(
"cursor-pointer rounded-md border-none outline-none bg-transparent px-3 py-1.5 text-left text-sm",
"text-content-secondary hover:bg-surface-secondary hover:text-content-primary",
"focus-visible:ring-2 focus-visible:ring-content-link",
"transition-colors whitespace-nowrap",
)}
>
{preset.label}
</button>
))}
</div>
{/* Calendar + footer */}
<div className="flex flex-col">
{/* Selected range display */}
<div className="flex items-center gap-2 border-b border-border-default px-4 py-2 text-sm">
<span
className={cn(
"rounded-md px-2 py-1 tabular-nums",
selection?.from
? "bg-surface-secondary text-content-primary"
: "text-content-secondary",
)}
>
{selection?.from
? dayjs(selection.from).format("MMM D, YYYY")
: "Start date"}
</span>
<MoveRightIcon className="size-3.5 text-content-secondary" />
<span
className={cn(
"rounded-md px-2 py-1 tabular-nums",
selection?.to
? "bg-surface-secondary text-content-primary"
: "text-content-secondary",
)}
>
{selection?.to
? dayjs(selection.to).format("MMM D, YYYY")
: "End date"}
</span>
</div>
{/* Two-month calendar */}
<div className="p-2">
<Calendar
mode="range"
selected={selection}
onSelect={handleCalendarSelect}
numberOfMonths={2}
disabled={{ after: new Date() }}
/>
</div>
{/* Apply footer */}
<div className="flex items-center justify-end gap-2 border-t border-border-default px-4 py-2">
<Button variant="subtle" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={commit} disabled={!canApply}>
Apply
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
};
@@ -1,238 +0,0 @@
import "react-date-range/dist/styles.css";
import "react-date-range/dist/theme/default.css";
import type { Interpolation, Theme } from "@emotion/react";
import { Button } from "components/Button/Button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import dayjs from "dayjs";
import { MoveRightIcon } from "lucide-react";
import { type ComponentProps, type FC, useRef, useState } from "react";
import { createStaticRanges, DateRangePicker } from "react-date-range";
// The type definition from @types is wrong
declare module "react-date-range" {
export function createStaticRanges(
ranges: Omit<StaticRange, "isSelected">[],
): StaticRange[];
}
export type DateRangeValue = {
startDate: Date;
endDate: Date;
};
type RangesState = NonNullable<
ComponentProps<typeof DateRangePicker>["ranges"]
>;
interface DateRangeProps {
value: DateRangeValue;
onChange: (value: DateRangeValue) => void;
}
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,
key: "selection",
},
]);
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>
<Button variant="outline">
<span>{dayjs(value.startDate).format("MMM D, YYYY")}</span>
<MoveRightIcon />
<span>{dayjs(value.endDate).format("MMM D, YYYY")}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0 overflow-x-hidden">
<div
onClickCapture={(event) => {
selectionSourceRef.current =
event.target instanceof HTMLElement &&
event.target.closest(".rdrStaticRange")
? "preset"
: null;
}}
>
<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>
);
};
const styles = {
wrapper: (theme) => ({
"& .rdrDefinedRangesWrapper": {
background: theme.palette.background.paper,
borderColor: theme.palette.divider,
},
"& .rdrStaticRange": {
background: theme.palette.background.paper,
border: 0,
fontSize: 14,
color: theme.palette.text.secondary,
"&:is(:hover, :focus) .rdrStaticRangeLabel": {
background: theme.palette.background.paper,
color: theme.palette.text.primary,
},
"&.rdrStaticRangeSelected": {
color: `${theme.palette.text.primary} !important`,
},
},
"& .rdrInputRanges": {
display: "none",
},
"& .rdrDateDisplayWrapper": {
backgroundColor: theme.palette.background.paper,
},
"& .rdrCalendarWrapper": {
backgroundColor: theme.palette.background.paper,
},
"& .rdrDateDisplayItem": {
background: "transparent",
borderColor: theme.palette.divider,
"& input": {
color: theme.palette.text.secondary,
},
"&.rdrDateDisplayItemActive": {
borderColor: theme.palette.text.primary,
backgroundColor: theme.palette.background.paper,
"& input": {
color: theme.palette.text.primary,
},
},
},
"& .rdrMonthPicker select, & .rdrYearPicker select": {
color: theme.palette.text.primary,
appearance: "auto",
background: "transparent",
},
"& .rdrMonthName, & .rdrWeekDay": {
color: theme.palette.text.secondary,
},
"& .rdrDayPassive .rdrDayNumber span": {
color: theme.palette.text.disabled,
},
"& .rdrDayNumber span": {
color: theme.palette.text.primary,
},
"& .rdrDayToday .rdrDayNumber span": {
fontWeight: 900,
"&:after": {
display: "none",
},
},
"& .rdrInRange, & .rdrEndEdge, & .rdrStartEdge": {
color: theme.palette.primary.main,
},
"& .rdrDayDisabled": {
backgroundColor: "transparent",
"& .rdrDayNumber span": {
color: theme.palette.text.disabled,
},
},
}),
} satisfies Record<string, Interpolation<Theme>>;
@@ -41,6 +41,10 @@ import {
SquareArrowOutUpRightIcon,
} from "lucide-react";
import { RequirePermission } from "modules/permissions/RequirePermission";
import {
DateRangePicker as DailyPicker,
type DateRangeValue,
} from "pages/AgentsPage/components/DateRangePicker/DateRangePicker";
import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout";
import {
type FC,
@@ -62,7 +66,6 @@ import {
subtractTime,
} from "utils/time";
import { getTemplatePageTitle } from "../utils";
import { DateRange as DailyPicker, type DateRangeValue } from "./DateRange";
import { type InsightsInterval, IntervalMenu } from "./IntervalMenu";
import { lastWeeks } from "./utils";
import { numberOfWeeksOptions, WeekPicker } from "./WeekPicker";
@@ -8,8 +8,8 @@ import {
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import dayjs from "dayjs";
import type { DateRangeValue } from "pages/AgentsPage/components/DateRangePicker/DateRangePicker";
import type { FC } from "react";
import type { DateRangeValue } from "./DateRange";
import { lastWeeks } from "./utils";
// There is no point in showing the period > 6 months. We prune stats older than