diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx
index 75b0e8fa7..4bf27123e 100644
--- a/apps/www/src/content/docs/(overview)/upgrading.mdx
+++ b/apps/www/src/content/docs/(overview)/upgrading.mdx
@@ -7,6 +7,112 @@ One section per release, newest first, with only the changes that need action
from you. The full record of every release, features and fixes included, is
on [GitHub releases](https://github.com/raystack/apsara/releases).
+## Unreleased: date filters compare whole days
+
+`DataView`, `DataTable` and `FilterChip` read and compare dates as calendar
+days, without dayjs. A date filter's control is
+[CalendarPreview](/docs/components/calendar-preview) instead of `DatePicker`.
+`Calendar`, `DatePicker` and `RangePicker` are still exported and do not change.
+
+### 1. Check how your backend reads `stringValue`
+
+A date filter's `stringValue` is a day key, `'2026-08-15'`. It used to be local
+midnight as a UTC instant, `'2026-08-14T18:30:00.000Z'` for a viewer in India.
+`value` does not change.
+
+```ts
+// Before
+onTableQueryChange={query => {
+ // '2026-08-14T18:30:00.000Z'
+ const day = query.filters?.[0]?.stringValue?.slice(0, 10); // '2026-08-14'
+}}
+
+// After
+onTableQueryChange={query => {
+ const day = query.filters?.[0]?.stringValue; // '2026-08-15'
+}}
+```
+
+If your backend took the date part of the old string, it read the day before
+for every viewer east of UTC. The new value fixes that. A backend that expects a
+full timestamp may read a date-only string differently, so test it.
+
+Saved filters and URL parameters that hold an old timestamp still load, as the
+day that instant falls on in the viewer's time zone.
+Rows that hold a `Date`, an epoch number, or a dayjs or moment object are read
+as before.
+
+DataView and DataTable compare and write the day in the viewer's time zone. If
+you set `calendarProps.timeZone` or `filterProps.calendar.timeZone` to another
+zone, a picked day can shift by one: 1 October picked in `Asia/Tokyo` is 30
+September for a viewer in New York. Leave `timeZone` unset for filters, or set
+it to the viewer's zone.
+
+### 2. Move `calendarProps` to CalendarPreview props
+
+`FilterChip`'s `calendarProps` and `DataTable`'s `filterProps.calendar` take
+CalendarPreview props now. `slotProps.popover` and `showCalendarIcon` work as
+before. `slotProps.input` takes `CalendarPreview.Input` props, which have no
+`value` or `defaultValue`. `onErrorChange` still reports `'Invalid date'`, but
+the error clears only when the typed text is valid or empty, or a date is
+committed. Closing the popup does not clear it.
+
+| Removed | Replacement |
+|---|---|
+| `dateFormat` | `formatValue(date, timeZone)`, which returns the label |
+| `slotProps.calendar` / nested `calendarProps` | `minDate`, `maxDate`, `isDateUnavailable`, `defaultMonth`, `yearRange` |
+| `inputProps` | `slotProps.input` |
+| `popoverProps` | `slotProps.popover` |
+
+```tsx
+// Before
+
+
+// After
+
+ date.toLocaleDateString("en-CA", { timeZone }),
+ maxDate: new Date()
+ }}
+/>
+```
+
+`formatValue` changes only the label. The input still reads typed text in the
+default formats, such as `15 Aug 2026`.
+
+### 3. Handle a cleared date
+
+A date filter can be cleared: click the selected day, or empty the input. The
+chip then calls `onValueChange` with `''`. In `DataView` and `DataTable` the
+chip stays, and the filter is left out of the query until a date is picked
+again. If you render `FilterChip` yourself, handle `''`.
+
+### 4. Expect different rows from some filters
+
+These filters used to match by mistake, and now match differently:
+
+- A date filter with no value used to filter to today. It is now dropped.
+- A date filter passed in `query` used to filter to today. It now filters by its
+ own date.
+- A date filter on a year-first day that does not exist, such as `2026-02-30`,
+ used to roll over to 2 March. It is now dropped. A month-first or month-name
+ form such as `02/30/2026` still rolls over.
+- A row with no date used to be read as today, so it could match any operator,
+ depending on the filter day. It now matches only `neq`.
+- A row holding a year-first day that does not exist used to roll over to a
+ real date. It now matches only `neq`, and the timeline does not draw it.
+- A row holding a numeric string or a boolean used to be read as a date, such
+ as the year 1792 for `'1786752000000'`. It now matches only `neq`, and the
+ timeline does not draw it.
+
## 2.0: `Theme` is rewritten
`Theme` used to put its tokens on `` from an effect, so it could not
diff --git a/apps/www/src/content/docs/components/filter-chip/demo.ts b/apps/www/src/content/docs/components/filter-chip/demo.ts
index 6852e3eb8..3bd964aba 100644
--- a/apps/www/src/content/docs/components/filter-chip/demo.ts
+++ b/apps/www/src/content/docs/components/filter-chip/demo.ts
@@ -123,18 +123,33 @@ export const autocompleteDemo = {
};
export const calendarPropsDemo = {
type: 'code',
- code: `
+ tabs: [
+ {
+ name: 'Max date',
+ code: `
}
columnType="date"
calendarProps={{
- dateFormat: "YYYY-MM-DD",
- slotProps: {
- calendar: { captionLayout: "dropdown" }
- }
+ maxDate: new Date()
}}
/>`
+ },
+ {
+ name: 'Custom format',
+ code: `
+}
+ columnType="date"
+ calendarProps={{
+ formatValue: (date, timeZone) =>
+ date.toLocaleDateString('en-CA', { timeZone })
+ }}
+/>`
+ }
+ ]
};
export const iconDemo = {
type: 'code',
diff --git a/apps/www/src/content/docs/components/filter-chip/index.mdx b/apps/www/src/content/docs/components/filter-chip/index.mdx
index 44ddbb0c8..7558ac6eb 100644
--- a/apps/www/src/content/docs/components/filter-chip/index.mdx
+++ b/apps/www/src/content/docs/components/filter-chip/index.mdx
@@ -38,7 +38,7 @@ Use `selectProps` to enable autocomplete search on select and multiselect filter
### Date with calendarProps
-Use `calendarProps` to forward DatePicker options such as `dateFormat`, `timeZone` and `slotProps.calendar` to the chip's date control. `value`, `onSelect`, and `defaultValue` are owned by `FilterChip`, and `children` is excluded so the chip's input trigger isn't replaced.
+Use `calendarProps` to forward [CalendarPreview](/docs/components/calendar-preview) props such as `timeZone`, `minDate` and `maxDate` to the chip's date control. `formatValue(date, timeZone)` returns the label for the selected date. The chip is day-only, so the callback gets no scale. `slotProps.input` and `slotProps.popover` go to the date input and its popup. `FilterChip` owns the value and the calendar parts, and closes the popup when a day is picked.
diff --git a/apps/www/src/content/docs/components/filter-chip/props.ts b/apps/www/src/content/docs/components/filter-chip/props.ts
index 6eba6b255..7e95bced2 100644
--- a/apps/www/src/content/docs/components/filter-chip/props.ts
+++ b/apps/www/src/content/docs/components/filter-chip/props.ts
@@ -1,3 +1,6 @@
+import { CalendarPreviewInputProps } from '../calendar-preview/props';
+import { PopoverContentProps } from '../popover/props';
+
export interface FilterChipProps {
/** Text label for the filter (required) */
label: string;
@@ -51,16 +54,30 @@ export interface FilterChipProps {
defaultSearchValue?: string;
};
- /** Props forwarded to the underlying DatePicker for `columnType="date"`. Refer to DatePicker for full props list. `dateFormat` defaults to `"DD MMM YYYY"`. */
+ /** Props for the date control at `columnType="date"`. `timeZone` through `today` are CalendarPreview props. */
calendarProps?: {
- dateFormat?: string;
- showCalendarIcon?: boolean;
+ /** Formats the selected date for the input. The chip calls it with a `Date` and the `timeZone`. */
+ formatValue?: (date: Date, timeZone?: string) => string;
+ /** The zone the calendar reads days in. DataView and DataTable filter in the viewer's zone, so a different zone can shift the filter day. */
timeZone?: string;
+ minDate?: Date;
+ maxDate?: Date;
+ isDateUnavailable?: (date: Date) => boolean;
+ yearRange?: { from: number; to: number };
+ defaultMonth?: Date;
+ today?: Date;
+ /** Props for the date input (`CalendarPreview.Input`) and its popup (`CalendarPreview.Content`). `input.disabled` and `input.readOnly` also apply to the calendar. */
slotProps?: {
- input?: Record;
- calendar?: Record;
- popover?: Record;
+ input?: Omit;
+ popover?: Omit;
};
+ /**
+ * Shows the calendar icon in the date input.
+ * @default false
+ */
+ showCalendarIcon?: boolean;
+ /** Called with a message when the typed date is invalid, and with `undefined` when it is valid again. */
+ onErrorChange?: (error: string | undefined) => void;
};
/** Additional CSS class names */
diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md
index d7acbce8f..b82985c20 100644
--- a/packages/react/CHANGELOG.md
+++ b/packages/react/CHANGELOG.md
@@ -72,6 +72,78 @@ and [Icons](https://apsara.raystack.io/docs/theme/icons).
`IconComponent`, `IconProps`, `IconProviderProps`, and `IconProvider`
itself.
+### Date filters: DataView and DataTable compare whole days (BREAKING)
+
+DataView, DataTable and FilterChip read, compare and format dates
+through date-fns instead of dayjs. A date filter compares whole days,
+and a date that is missing or does not exist no longer stands in for
+another day. `dayjs` stays a dependency, because `Calendar`,
+`DatePicker` and `RangePicker` still import it.
+
+#### Breaking changes
+
+- **A date filter's `stringValue` is a day key.** It is `'2026-08-15'`,
+ not `'2026-08-14T18:30:00.000Z'`. The old value was local midnight as
+ a UTC instant, so a backend that read the date part got the previous
+ day for any viewer east of UTC. `value` passes through unchanged. If
+ your backend parses `stringValue`, check it: a date-only string is
+ valid ISO 8601, but a parser that expects a timestamp may read it
+ differently.
+- **A date filter with no value is dropped.** dayjs read a missing
+ (`undefined`) value as today, so the filter matched today's rows.
+- **A date filter restored from `query` filters by its date.** It lost
+ its type when the query was loaded, so the date comparison got no
+ date, and dayjs read that as today.
+- **A date filter holding a year-first day that does not exist is
+ dropped.** dayjs rolled `2026-02-30` over to 2 March and filtered on
+ that. A month-first or month-name form such as `02/30/2026` still rolls
+ over, as with dayjs.
+- **A row whose date is missing matches only `neq`.** dayjs read it as
+ today, so the row matched `eq`, `lte` and `gte` on a filter day of
+ today, `lt` and `lte` on a later filter day, and `gt` and `gte` on an
+ earlier one.
+- **A row holding a numeric string or a boolean matches only `neq`.**
+ dayjs read `'1786752000000'` as the year 1792 and `true` as 1970, and
+ compared those. A timeline row holding one is not drawn.
+- **A row holding a year-first day that does not exist matches only
+ `neq`.** dayjs rolled it to a real date and compared that, so `2026-02-30` matched
+ `lt` and `lte` against a filter day after 2 March, and `2026-13-01`
+ matched `gt` and `gte` against a filter day before 1 January 2027. A
+ timeline row holding one is not drawn.
+- **`neq` still matches a row with a missing or unreadable date.**
+- **A row holding a dayjs or moment object is still read**, as its
+ timestamp. The timeline now draws such a row, and a date FilterChip
+ shows such a value. Before, both left it out.
+
+Saved filters and URL parameters that hold an ISO timestamp are still
+read, as the day the instant falls on in the viewer's zone. There is no
+migration: the next query a stored filter produces carries a day key.
+
+### FilterChip: the date control is CalendarPreview (BREAKING)
+
+A date FilterChip renders `CalendarPreview` instead of `DatePicker`, so
+the date filters in DataView and DataTable do too. No component inside
+Apsara renders `Calendar`, `DatePicker` or `RangePicker` now.
+
+#### Breaking changes
+
+- **`calendarProps` takes CalendarPreview props.** `FilterChipCalendarProps`
+ is `formatValue`, `timeZone`, `minDate`, `maxDate`, `isDateUnavailable`,
+ `yearRange`, `defaultMonth` and `today`, plus `slotProps.input`,
+ `slotProps.popover`, `showCalendarIcon` and `onErrorChange`.
+ `formatValue(date, timeZone)` takes the date and returns its label.
+ `slotProps.input` takes `CalendarPreview.Input` props, which have no
+ `value` or `defaultValue`. `onErrorChange` still reports
+ `'Invalid date'`, and its error no longer clears when the popup closes,
+ only when the typed text is valid or empty, or a date is committed. `dateFormat`, `slotProps.calendar`, `inputProps`,
+ the nested `calendarProps` and `popoverProps` are gone. Replace `dateFormat` with
+ `formatValue`, move calendar options such as `disabled` days to
+ `minDate`, `maxDate` or `isDateUnavailable`, and move `inputProps` and
+ `popoverProps` to `slotProps.input` and `slotProps.popover`.
+ DataTable's `filterProps.calendar` has the same type.
+- **A date filter can be cleared.** Clicking the selected day or emptying
+ the input clears it, and `onValueChange` receives `''`.
+
## 0.49.0
### Calendar / DatePicker / RangePicker improvements (PR #819)
diff --git a/packages/react/components/calendar-preview/__tests__/calendar-preview.test.tsx b/packages/react/components/calendar-preview/__tests__/calendar-preview.test.tsx
index dd9e2aecb..5943618d2 100644
--- a/packages/react/components/calendar-preview/__tests__/calendar-preview.test.tsx
+++ b/packages/react/components/calendar-preview/__tests__/calendar-preview.test.tsx
@@ -56,6 +56,31 @@ describe('CalendarPreview root', () => {
error.mockRestore();
});
+ it('renders December 9999, whose grid runs into 10000', () => {
+ const { container } = renderCalendar(undefined, {
+ defaultMonth: new Date(9999, 11, 1)
+ });
+ expect(dayCell(container, '31')).toBeInTheDocument();
+ });
+
+ it('does not warn when a controlled value that started empty is set', () => {
+ const error = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+ const { rerender } = render(
+
+
+
+ );
+ rerender(
+
+
+
+ );
+ expect(error).not.toHaveBeenCalled();
+ error.mockRestore();
+ });
+
it('commits a clicked day and reports the period and the day acted on', () => {
const onValueChange = vi.fn();
const { container } = renderCalendar(undefined, { onValueChange });
diff --git a/packages/react/components/calendar-preview/__tests__/parse.test.ts b/packages/react/components/calendar-preview/__tests__/parse.test.ts
index 1c2a55bfe..6dd4eb1f5 100644
--- a/packages/react/components/calendar-preview/__tests__/parse.test.ts
+++ b/packages/react/components/calendar-preview/__tests__/parse.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
+import { defaultFormatValue } from '../calendar-preview-root';
import { parseScaleInput } from '../lib/parse';
/* Fixed so the year-inference tests do not change meaning on 1 January. */
@@ -235,3 +236,17 @@ describe('parseScaleInput — rejections', () => {
expect(parseScaleInput(input, IN_2026)).toBeNull();
});
});
+
+describe('parseScaleInput reads what defaultFormatValue displays', () => {
+ it.each([
+ { date: '2026-08-15', scale: 'day' },
+ { date: '2026-08-01', scale: 'month' },
+ { date: '2026-07-01', scale: 'quarter' },
+ { date: '2026-07-01', scale: 'halfYear' },
+ { date: '2026-01-01', scale: 'year' }
+ ] as const)('round-trips $scale', value => {
+ expect(parseScaleInput(defaultFormatValue(value, value.scale))).toEqual(
+ value
+ );
+ });
+});
diff --git a/packages/react/components/calendar-preview/__tests__/picker.test.tsx b/packages/react/components/calendar-preview/__tests__/picker.test.tsx
index bfb4147a2..3d246aa3c 100644
--- a/packages/react/components/calendar-preview/__tests__/picker.test.tsx
+++ b/packages/react/components/calendar-preview/__tests__/picker.test.tsx
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
+import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { getAllSlots, getSlot } from '~/test-utils/data-slots';
import { Field } from '../../field';
@@ -89,6 +90,88 @@ describe('CalendarPreview picker composition', () => {
expect(isOpen()).toBe(true);
});
+ it('stays shut when a controlled open closes it from a picked day', async () => {
+ const onOpenChange = vi.fn();
+ function Controlled() {
+ const [open, setOpen] = useState(false);
+ return (
+ {
+ onOpenChange(next);
+ setOpen(next);
+ }}
+ onValueChange={() => setOpen(false)}
+ >
+
+
+
+
+
+
+
+ );
+ }
+ const { container } = render();
+ const input = getSlot(container, 'calendar-preview-input') as HTMLElement;
+ act(() => input.focus());
+ expect(isOpen()).toBe(true);
+ const day = getAllSlots(document.body, 'calendar-preview-day').find(
+ cell =>
+ getSlot(cell, 'calendar-preview-day-number')?.textContent === '12' &&
+ !cell.hasAttribute('data-outside')
+ ) as HTMLElement;
+ fireEvent.pointerDown(day);
+ act(() => day.focus());
+ fireEvent.click(day);
+ await act(() => new Promise(resolve => setTimeout(resolve, 100)));
+ expect(isOpen()).toBe(false);
+ expect(onOpenChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('stays shut when a controlled open closes it after ignoring a close', async () => {
+ let close: () => void = () => undefined;
+ function Controlled() {
+ const [open, setOpen] = useState(false);
+ close = () => setOpen(false);
+ return (
+ {
+ if (next) setOpen(true);
+ }}
+ >
+
+
+
+
+
+
+
+ );
+ }
+ const { container } = render();
+ const input = getSlot(container, 'calendar-preview-input') as HTMLElement;
+ act(() => input.focus());
+ const day = getAllSlots(document.body, 'calendar-preview-day').find(
+ cell =>
+ getSlot(cell, 'calendar-preview-day-number')?.textContent === '12' &&
+ !cell.hasAttribute('data-outside')
+ ) as HTMLElement;
+ act(() => day.focus());
+ fireEvent.keyDown(day, { key: 'Escape' });
+ await act(() => new Promise(resolve => setTimeout(resolve, 10)));
+ expect(isOpen()).toBe(true);
+ fireEvent.keyDown(day, { key: 'Shift' });
+ act(() => close());
+ await act(() => new Promise(resolve => setTimeout(resolve, 100)));
+ expect(isOpen()).toBe(false);
+ });
+
it('opens on focus again after Escape closed it', () => {
const { input } = renderPicker();
/* Real focus, so the guard can see where it is; the event drives it. */
diff --git a/packages/react/components/calendar-preview/calendar-preview-root.tsx b/packages/react/components/calendar-preview/calendar-preview-root.tsx
index 54a327ea7..0dba0c821 100644
--- a/packages/react/components/calendar-preview/calendar-preview-root.tsx
+++ b/packages/react/components/calendar-preview/calendar-preview-root.tsx
@@ -4,7 +4,14 @@ import { mergeProps, Popover, useRender } from '@base-ui/react';
import { REASONS } from '@base-ui/react/internals/reasons';
import { useControlled } from '@base-ui/utils/useControlled';
import { cx } from 'class-variance-authority';
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState
+} from 'react';
import styles from './calendar-preview.module.css';
import {
type CalendarPreviewChangeDetails,
@@ -230,14 +237,19 @@ export function CalendarPreviewRoot({
state: 'value'
});
- const [month, setMonthUnwrapped] = useControlled({
- controlled: monthProp,
- /* `valueProp` first: `defaultValue` is nulled once `value` is controlled. */
- default:
+ /* Read once: a controlled `value` that starts empty would otherwise move the default. */
+ const [initialMonth] = useState(
+ () =>
+ /* `valueProp` first: `defaultValue` is nulled once `value` is controlled. */
defaultMonth ??
monthAnchor(valueProp) ??
monthAnchor(defaultValue) ??
- today,
+ today
+ );
+
+ const [month, setMonthUnwrapped] = useControlled({
+ controlled: monthProp,
+ default: initialMonth,
name: 'CalendarPreview',
state: 'month'
});
@@ -249,16 +261,20 @@ export function CalendarPreviewRoot({
return list.length > 0 ? Array.from(new Set(list)) : ['day'];
}, [scalesProp]);
- const [scale, setScaleUnwrapped] = useControlled({
- controlled: scaleProp,
- /* The value's own scale, or a quarter opens on the day grid unmarked. */
- default:
+ const [initialScale] = useState(
+ () =>
+ /* The value's own scale, or a quarter opens on the day grid unmarked. */
defaultScale ??
(isScaleValue(valueProp)
? valueProp.scale
: isScaleValue(defaultValue)
? defaultValue.scale
- : scales[0]),
+ : scales[0])
+ );
+
+ const [scale, setScaleUnwrapped] = useControlled({
+ controlled: scaleProp,
+ default: initialScale,
name: 'CalendarPreview',
state: 'scale'
});
@@ -374,14 +390,20 @@ export function CalendarPreviewRoot({
const settleScaleRef = useRef<((scale: Scale) => void) | null>(null);
const dismissedByOutsidePress = useRef(false);
+ const closingThroughSetOpen = useRef(false);
const setOpen = useCallback(
(next: boolean, details: CalendarPreviewOpenChangeDetails) => {
+ closingThroughSetOpen.current = !next;
if (!next) {
const outside = details.reason === REASONS.outsidePress;
dismissedByOutsidePress.current = outside;
armFocusGuard(outside);
dropDraftRef.current?.();
+ /* A controlled parent can keep `open`, so the mark must not outlive this close. */
+ setTimeout(() => {
+ closingThroughSetOpen.current = false;
+ });
}
setOpenUnwrapped(next);
onOpenChange?.(next, details);
@@ -389,6 +411,18 @@ export function CalendarPreviewRoot({
[setOpenUnwrapped, onOpenChange, armFocusGuard]
);
+ /* A controlled `open` can close without `setOpen`, and the focus Base UI gives back would reopen it. */
+ const wasOpen = useRef(open);
+ useLayoutEffect(() => {
+ if (wasOpen.current && !open && !closingThroughSetOpen.current) {
+ dismissedByOutsidePress.current = false;
+ armFocusGuard(false);
+ dropDraftRef.current?.();
+ }
+ closingThroughSetOpen.current = false;
+ wasOpen.current = open;
+ }, [open, armFocusGuard]);
+
/* Base UI returns focus to the trigger's first tabbable child — the `.Input`. */
const shouldRestoreFinalFocus = useCallback(
() => !dismissedByOutsidePress.current,
@@ -440,7 +474,13 @@ export function CalendarPreviewRoot({
/* Day-keys, so a `minDate` carrying a time of day leaves its own day selectable. */
const isDateUnavailable = useCallback(
(date: Date) => {
- const key = dayKey(date, timeZone);
+ /* The December 9999 grid runs into 10000, which has no day key. */
+ let key: string;
+ try {
+ key = dayKey(date, timeZone);
+ } catch {
+ return true;
+ }
if (minDate && key < dayKey(minDate, timeZone)) return true;
if (maxDate && key > dayKey(maxDate, timeZone)) return true;
return isDateUnavailableProp?.(date) ?? false;
diff --git a/packages/react/components/data-table/__tests__/data-table.test.tsx b/packages/react/components/data-table/__tests__/data-table.test.tsx
index f1b6d83d3..61ca2b35e 100644
--- a/packages/react/components/data-table/__tests__/data-table.test.tsx
+++ b/packages/react/components/data-table/__tests__/data-table.test.tsx
@@ -3,7 +3,8 @@ import userEvent from '@testing-library/user-event';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { DataTable } from '../data-table';
import styles from '../data-table.module.css';
-import { DataTableColumnDef } from '../data-table.types';
+import { DataTableColumnDef, DataTableQuery } from '../data-table.types';
+import { useDataTable } from '../hooks/useDataTable';
beforeAll(() => {
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
@@ -667,4 +668,66 @@ describe('DataTable', () => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
+
+ describe('Restored date filters', () => {
+ type Dated = { id: string; when?: string };
+ const dated: Dated[] = [
+ { id: 'd14', when: '2026-08-14' },
+ { id: 'd15', when: '2026-08-15' },
+ { id: 'd16', when: '2026-08-16' },
+ { id: 'dNone' }
+ ];
+ const datedColumns: DataTableColumnDef[] = [
+ { accessorKey: 'id', header: 'ID' },
+ { accessorKey: 'when', header: 'When', filterType: 'date' }
+ ];
+
+ const Rows = () => {
+ const { table } = useDataTable();
+ return (
+
+ );
+ };
+
+ const renderRestored = (
+ filters: NonNullable,
+ mode: 'client' | 'server' = 'client',
+ onTableQueryChange?: (query: DataTableQuery) => void
+ ) =>
+ render(
+
+
+
+ );
+
+ it('filters rows by a restored date filter', () => {
+ renderRestored([{ name: 'when', operator: 'eq', value: '2026-08-15' }]);
+ expect(screen.getByTestId('rows')).toHaveTextContent('d15');
+ });
+
+ it('emits a restored date filter as a day key', () => {
+ const onTableQueryChange = vi.fn();
+ renderRestored(
+ [{ name: 'when', operator: 'eq', value: new Date(2026, 7, 15) }],
+ 'server',
+ onTableQueryChange
+ );
+ const last = onTableQueryChange.mock.calls[
+ onTableQueryChange.mock.calls.length - 1
+ ]?.[0] as DataTableQuery;
+ expect(last.filters?.[0]?.stringValue).toBe('2026-08-15');
+ });
+ });
});
diff --git a/packages/react/components/data-table/data-table.tsx b/packages/react/components/data-table/data-table.tsx
index 42369cc62..087203b27 100644
--- a/packages/react/components/data-table/data-table.tsx
+++ b/packages/react/components/data-table/data-table.tsx
@@ -53,8 +53,8 @@ function DataTableRoot({
getRowId
}: React.PropsWithChildren>) {
const defaultTableQuery = useMemo(
- () => getDefaultTableQuery(defaultSort, query),
- [defaultSort, query]
+ () => getDefaultTableQuery(defaultSort, query, columns),
+ [defaultSort, query, columns]
);
const initialColumnVisibility = getInitialColumnVisibility(columns);
diff --git a/packages/react/components/data-table/utils/__tests__/filter-operations.test.tsx b/packages/react/components/data-table/utils/__tests__/filter-operations.test.tsx
index 79f75cb54..171927221 100644
--- a/packages/react/components/data-table/utils/__tests__/filter-operations.test.tsx
+++ b/packages/react/components/data-table/utils/__tests__/filter-operations.test.tsx
@@ -1,5 +1,4 @@
import type { Row } from '@tanstack/table-core';
-import dayjs from 'dayjs';
import { describe, expect, it, vi } from 'vitest';
import { EmptyFilterValue, FilterType } from '~/types/filters';
import {
@@ -614,15 +613,13 @@ describe('Filter Operations', () => {
});
it('should handle date filter type', () => {
- const date = new Date('2023-12-01');
+ const date = new Date(2023, 11, 1);
const result = getFilterValue({
value: date,
filterType: FilterType.date
});
- expect(result).toEqual({
- value: date,
- stringValue: date.toISOString()
- });
+ expect(result.value).toBe(date);
+ expect(result.stringValue).toBe('2023-12-01');
});
it('should return empty stringValue for invalid date', () => {
diff --git a/packages/react/components/data-table/utils/__tests__/index.test.tsx b/packages/react/components/data-table/utils/__tests__/index.test.tsx
index 7fa39325b..17e938a50 100644
--- a/packages/react/components/data-table/utils/__tests__/index.test.tsx
+++ b/packages/react/components/data-table/utils/__tests__/index.test.tsx
@@ -187,6 +187,22 @@ describe('Data Table Utils', () => {
expect(result.columnFilters).toHaveLength(0);
});
+ it('should filter out a date filter with no value', () => {
+ const query: InternalQuery = {
+ filters: [
+ {
+ name: 'createdAt',
+ operator: 'eq',
+ value: undefined,
+ _type: FilterType.date
+ }
+ ]
+ };
+ const result = queryToTableState(query);
+
+ expect(result.columnFilters).toHaveLength(0);
+ });
+
it('should keep valid date filters', () => {
const query: InternalQuery = {
filters: [
@@ -691,8 +707,24 @@ describe('Data Table Utils', () => {
expect(result.filters).toHaveLength(0);
});
+ it('should filter out a date filter with no value', () => {
+ const query: InternalQuery = {
+ filters: [
+ {
+ name: 'createdAt',
+ operator: 'eq',
+ value: undefined,
+ _type: FilterType.date
+ }
+ ]
+ };
+
+ const result = transformToDataTableQuery(query);
+ expect(result.filters).toHaveLength(0);
+ });
+
it('should keep valid date filters', () => {
- const date = new Date('2023-12-01');
+ const date = new Date(2023, 11, 1);
const query: InternalQuery = {
filters: [
{
@@ -707,7 +739,7 @@ describe('Data Table Utils', () => {
const result = transformToDataTableQuery(query);
expect(result.filters).toHaveLength(1);
expect(result.filters![0].name).toBe('createdAt');
- expect(result.filters![0].stringValue).toBe(date.toISOString());
+ expect(result.filters![0].stringValue).toBe('2023-12-01');
});
it('should preserve other query properties', () => {
diff --git a/packages/react/components/data-table/utils/filter-operations.tsx b/packages/react/components/data-table/utils/filter-operations.tsx
index 169e74d9c..38ab5e604 100644
--- a/packages/react/components/data-table/utils/filter-operations.tsx
+++ b/packages/react/components/data-table/utils/filter-operations.tsx
@@ -1,8 +1,6 @@
import type { FilterFn } from '@tanstack/table-core';
-import dayjs from 'dayjs';
-import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
-import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
+import { dateFilterFns, toDayKey } from '~/shared/date-filters';
import {
DataTableFilterOperatorTypes,
DateFilterOperatorType,
@@ -19,9 +17,6 @@ import {
} from '~/types/filters';
import { DataTableFilterValues } from '../data-table.types';
-dayjs.extend(isSameOrAfter);
-dayjs.extend(isSameOrBefore);
-
export type FilterPrimitive = string | string[] | number | boolean | Date;
export type FilterFunctionsMap = {
@@ -82,44 +77,7 @@ export const filterOperationsMap: FilterFunctionsMap = {
return columnValue.endsWith(filterStr);
}
},
- date: {
- eq: (row, columnId, filterValue: FilterValue, _addMeta) => {
- return dayjs(row.getValue(columnId)).isSame(
- dayjs(filterValue.date),
- 'day'
- );
- },
- neq: (row, columnId, filterValue: FilterValue, _addMeta) => {
- return !dayjs(row.getValue(columnId)).isSame(
- dayjs(filterValue.date),
- 'day'
- );
- },
- lt: (row, columnId, filterValue: FilterValue, _addMeta) => {
- return dayjs(row.getValue(columnId)).isBefore(
- dayjs(filterValue.date),
- 'day'
- );
- },
- lte: (row, columnId, filterValue: FilterValue, _addMeta) => {
- return dayjs(row.getValue(columnId)).isSameOrBefore(
- dayjs(filterValue.date),
- 'day'
- );
- },
- gt: (row, columnId, filterValue: FilterValue, _addMeta) => {
- return dayjs(row.getValue(columnId)).isAfter(
- dayjs(filterValue.date),
- 'day'
- );
- },
- gte: (row, columnId, filterValue: FilterValue, _addMeta) => {
- return dayjs(row.getValue(columnId)).isSameOrAfter(
- dayjs(filterValue.date),
- 'day'
- );
- }
- },
+ date: dateFilterFns,
select: {
eq: (row, columnId, filterValue: FilterValue, _addMeta) => {
if (String(filterValue.value) === EmptyFilterValue) {
@@ -169,19 +127,8 @@ const handleStringBasedTypes = (
): DataTableFilterValues => {
switch (filterType) {
case FilterType.date: {
- const dateValue = dayjs(value as string | Date);
- let stringValue = '';
- if (dateValue.isValid()) {
- try {
- stringValue = dateValue.toISOString();
- } catch {
- stringValue = '';
- }
- }
- return {
- value,
- stringValue
- };
+ const day = toDayKey(value);
+ return { value, stringValue: day ?? '' };
}
case FilterType.select:
return {
diff --git a/packages/react/components/data-table/utils/index.tsx b/packages/react/components/data-table/utils/index.tsx
index 299093d42..cf971319c 100644
--- a/packages/react/components/data-table/utils/index.tsx
+++ b/packages/react/components/data-table/utils/index.tsx
@@ -1,7 +1,7 @@
import type { Row, Table } from '@tanstack/react-table';
import { TableState } from '@tanstack/table-core';
-import dayjs from 'dayjs';
+import { toDayKey, withDateFilterTypes } from '~/shared/date-filters';
import { FilterOperatorTypes, FilterType } from '~/types/filters';
import {
DataTableColumnDef,
@@ -25,7 +25,7 @@ export function queryToTableState(query: InternalQuery): Partial {
query.filters
?.filter(data => {
if (data._type === FilterType.date)
- return dayjs(data.value as string | Date).isValid();
+ return toDayKey(data.value) !== null;
if (data.value !== '') return true;
return false;
})
@@ -223,7 +223,7 @@ export function transformToDataTableQuery(
?.filter(data => {
if (data._type === FilterType.select) return true;
if (data._type === FilterType.date)
- return dayjs(data.value as string | Date).isValid();
+ return toDayKey(data.value) !== null;
if (data.value !== '') return true;
return false;
})
@@ -346,7 +346,11 @@ export function hasActiveTableFiltering(table: Table): boolean {
export function getDefaultTableQuery(
defaultSort?: DataTableSort,
- oldQuery: DataTableQuery = {}
+ oldQuery: DataTableQuery = {},
+ columns: Pick<
+ DataTableColumnDef,
+ 'accessorKey' | 'filterType'
+ >[] = []
): InternalQuery {
// Convert DataTableQuery to InternalQuery
const internalQuery = dataTableQueryToInternal(oldQuery);
@@ -354,6 +358,9 @@ export function getDefaultTableQuery(
return {
sort: defaultSort ? [defaultSort] : [],
group_by: [defaultGroupOption.id],
- ...internalQuery
+ ...internalQuery,
+ ...(internalQuery.filters && {
+ filters: withDateFilterTypes(internalQuery.filters, columns)
+ })
};
}
diff --git a/packages/react/components/data-view/__tests__/data-view.test.tsx b/packages/react/components/data-view/__tests__/data-view.test.tsx
index 09ab62807..866ea5fe2 100644
--- a/packages/react/components/data-view/__tests__/data-view.test.tsx
+++ b/packages/react/components/data-view/__tests__/data-view.test.tsx
@@ -15,6 +15,7 @@ import { DataView } from '../data-view';
import type {
DataViewField,
DataViewListColumn,
+ DataViewQuery,
ViewSpec
} from '../data-view.types';
import { useDataView as useDataViewForTest } from '../hooks/useDataView';
@@ -1314,4 +1315,86 @@ describe('DataView', () => {
expect(receivedRowOriginal).toMatchObject({ id: 3 });
});
});
+
+ describe('Restored date filters', () => {
+ type Dated = { id: string; when?: string };
+ const dated: Dated[] = [
+ { id: 'd14', when: '2026-08-14' },
+ { id: 'd15', when: '2026-08-15' },
+ { id: 'd16', when: '2026-08-16' },
+ { id: 'dNone' }
+ ];
+ const datedFields: DataViewField[] = [
+ { accessorKey: 'id', label: 'ID', filterable: true },
+ {
+ accessorKey: 'when',
+ label: 'When',
+ filterable: true,
+ filterType: 'date'
+ }
+ ];
+
+ const renderRestored = (
+ filters: NonNullable,
+ mode: 'client' | 'server' = 'client',
+ onTableQueryChange?: (query: DataViewQuery) => void
+ ) =>
+ render(
+
+
+ {ctx => (
+
+ )}
+
+
+ );
+
+ it('filters rows by a restored date filter', () => {
+ renderRestored([{ name: 'when', operator: 'eq', value: '2026-08-15' }]);
+ expect(screen.getByTestId('rows')).toHaveTextContent('d15');
+ });
+
+ it('emits a restored date filter as a day key', () => {
+ const onTableQueryChange = vi.fn();
+ renderRestored(
+ [{ name: 'when', operator: 'eq', value: new Date(2026, 7, 15) }],
+ 'server',
+ onTableQueryChange
+ );
+ const last = onTableQueryChange.mock.calls[
+ onTableQueryChange.mock.calls.length - 1
+ ]?.[0] as DataViewQuery;
+ expect(last.filters?.[0]?.stringValue).toBe('2026-08-15');
+ });
+
+ it('leaves a restored string filter as it was sent', () => {
+ const onTableQueryChange = vi.fn();
+ renderRestored(
+ [{ name: 'id', operator: 'eq', value: 'd15' }],
+ 'server',
+ onTableQueryChange
+ );
+ const last = onTableQueryChange.mock.calls[
+ onTableQueryChange.mock.calls.length - 1
+ ]?.[0] as DataViewQuery;
+ expect(last.filters?.[0]).toEqual({
+ name: 'id',
+ operator: 'eq',
+ value: 'd15',
+ stringValue: 'd15'
+ });
+ });
+ });
});
diff --git a/packages/react/components/data-view/__tests__/filter-operations.test.ts b/packages/react/components/data-view/__tests__/filter-operations.test.ts
index 5a0d80647..bcf4fd082 100644
--- a/packages/react/components/data-view/__tests__/filter-operations.test.ts
+++ b/packages/react/components/data-view/__tests__/filter-operations.test.ts
@@ -1,4 +1,5 @@
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { queryToTableState, transformToDataViewQuery } from '../utils';
import {
getDataType,
getFilterFn,
@@ -64,14 +65,11 @@ describe('filter-operations', () => {
expect(v.stringValue).toBe('foo%');
});
- it('emits ISO string for valid dates', () => {
- const d = new Date('2024-01-15T00:00:00Z');
- const v = getFilterValue({
- value: d,
- filterType: 'date',
- operator: 'eq'
- });
- expect(v.stringValue).toBe(d.toISOString());
+ it('emits a day key for valid dates and passes the value through', () => {
+ const value = new Date(2024, 0, 15, 23, 30);
+ const v = getFilterValue({ value, filterType: 'date', operator: 'eq' });
+ expect(v.value).toBe(value);
+ expect(v.stringValue).toBe('2024-01-15');
});
it('emits boolValue for boolean dataType', () => {
@@ -108,3 +106,115 @@ describe('filter-operations', () => {
});
});
});
+
+describe('date filters', () => {
+ const run = (
+ operator: 'eq' | 'neq' | 'lt' | 'lte' | 'gt' | 'gte',
+ filterDate: unknown,
+ rowValue: unknown
+ ) => {
+ const fn = getFilterFn('date', operator);
+ const row = { getValue: () => rowValue } as never;
+ return fn(row, 'when', { date: filterDate } as never, vi.fn());
+ };
+
+ const DAY = new Date(2026, 7, 15);
+
+ it.each([
+ ['eq', '2026-08-15', true],
+ ['eq', '2026-08-14', false],
+ ['neq', '2026-08-15', false],
+ ['neq', '2026-08-14', true],
+ ['lt', '2026-08-14', true],
+ ['lt', '2026-08-15', false],
+ ['lte', '2026-08-15', true],
+ ['lte', '2026-08-16', false],
+ ['gt', '2026-08-16', true],
+ ['gt', '2026-08-15', false],
+ ['gte', '2026-08-15', true],
+ ['gte', '2026-08-14', false]
+ ] as const)('%s against %s', (operator, row, expected) => {
+ expect(run(operator, DAY, row)).toBe(expected);
+ });
+
+ it.each([
+ 'eq',
+ 'lt',
+ 'lte',
+ 'gt',
+ 'gte'
+ ] as const)('does not match an unreadable row with %s', operator => {
+ expect(run(operator, DAY, 'not a date')).toBe(false);
+ expect(run(operator, DAY, undefined)).toBe(false);
+ });
+
+ it('matches an unreadable row with neq', () => {
+ expect(run('neq', DAY, 'not a date')).toBe(true);
+ expect(run('neq', DAY, undefined)).toBe(true);
+ expect(run('neq', DAY, '2026-02-30')).toBe(true);
+ });
+
+ it('does not match with neq when the filter date is unreadable', () => {
+ expect(run('neq', 'not a date', '2026-08-15')).toBe(false);
+ });
+
+ it('does not fall back to today when the filter has no date', () => {
+ expect(run('eq', undefined, '2026-08-15')).toBe(false);
+ });
+});
+
+/* Filters stored before day keys hold an ISO instant of local midnight. */
+describe('stored date filters', () => {
+ const originalTimeZone = process.env.TZ;
+ afterEach(() => {
+ if (originalTimeZone === undefined) delete process.env.TZ;
+ else process.env.TZ = originalTimeZone;
+ });
+
+ const matches = (stored: unknown, row: string) =>
+ getFilterFn('date', 'eq')(
+ { getValue: () => row } as never,
+ 'when',
+ { date: stored } as never,
+ vi.fn()
+ );
+
+ it('reads a stored ISO instant as the day the viewer picked', () => {
+ process.env.TZ = 'Asia/Kolkata';
+ expect(matches('2026-08-14T18:30:00.000Z', '2026-08-15')).toBe(true);
+ expect(matches('2026-08-14T18:30:00.000Z', '2026-08-14')).toBe(false);
+ });
+
+ it.each([
+ 'UTC',
+ 'Asia/Kolkata',
+ 'America/Los_Angeles',
+ 'Pacific/Kiritimati'
+ ])('reads a stored day key as the same day in %s', timeZone => {
+ process.env.TZ = timeZone;
+ expect(matches('2026-08-15', '2026-08-15')).toBe(true);
+ expect(matches('2026-08-15', '2026-08-14')).toBe(false);
+ });
+
+ it('writes a stored ISO instant as its day and keeps the value', () => {
+ process.env.TZ = 'Asia/Kolkata';
+ const value = '2026-08-14T18:30:00.000Z';
+ const v = getFilterValue({ value, filterType: 'date' });
+ expect(v.value).toBe(value);
+ expect(v.stringValue).toBe('2026-08-15');
+ });
+});
+
+describe('date filter with no value', () => {
+ const query = {
+ filters: [{ name: 'when', operator: 'eq', value: undefined, _type: 'date' }]
+ } as never;
+
+ it('is dropped from the table state', () => {
+ expect(queryToTableState(query).columnFilters).toEqual([]);
+ });
+
+ it('is dropped from the emitted query', () => {
+ expect(transformToDataViewQuery(query).filters).toEqual([]);
+ });
+});
diff --git a/packages/react/components/data-view/__tests__/timeline.test.tsx b/packages/react/components/data-view/__tests__/timeline.test.tsx
index ee81c2ef8..2284da4ea 100644
--- a/packages/react/components/data-view/__tests__/timeline.test.tsx
+++ b/packages/react/components/data-view/__tests__/timeline.test.tsx
@@ -1,6 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import dayjs from 'dayjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
// biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name
@@ -40,7 +39,12 @@ describe('toTimestamp', () => {
const date = new Date('2025-01-05T00:00:00');
expect(toTimestamp(date)).toBe(date.getTime());
expect(toTimestamp(1736035200000)).toBe(1736035200000);
- expect(toTimestamp('2025-01-05')).toBe(dayjs('2025-01-05').valueOf());
+ expect(toTimestamp('2025-01-05')).toBe(new Date(2025, 0, 5).getTime());
+ });
+
+ it('reads an object with a numeric valueOf as its epoch', () => {
+ const date = new Date('2025-01-05T00:00:00');
+ expect(toTimestamp({ valueOf: () => date.getTime() })).toBe(date.getTime());
});
it('returns null for missing or invalid values', () => {
@@ -56,42 +60,42 @@ describe('toTimestamp', () => {
describe('createTimeScale', () => {
it('snaps the domain to unit boundaries with padding', () => {
const ts = createTimeScale({
- minTime: dayjs('2025-01-05T10:30:00').valueOf(),
- maxTime: dayjs('2025-01-20T18:00:00').valueOf(),
+ minTime: new Date(2025, 0, 5, 10, 30).getTime(),
+ maxTime: new Date(2025, 0, 20, 18, 0).getTime(),
scale: 'day',
unitWidth: 20,
padUnits: 2
});
- expect(ts.t0).toBe(dayjs('2025-01-03').startOf('day').valueOf());
+ expect(ts.t0).toBe(new Date(2025, 0, 3).getTime());
// startOf(max) + (pad + 1) days.
- expect(ts.t1).toBe(dayjs('2025-01-23').startOf('day').valueOf());
+ expect(ts.t1).toBe(new Date(2025, 0, 23).getTime());
});
it('extends the domain end to reach minWidth, in whole units', () => {
// Jan 1 → Feb 1 = 31 days × 20px = 620px; filling to 1000px needs 50 days.
const ts = createTimeScale({
- minTime: dayjs('2025-01-01').valueOf(),
- maxTime: dayjs('2025-01-31').valueOf(),
+ minTime: new Date(2025, 0, 1).getTime(),
+ maxTime: new Date(2025, 0, 31).getTime(),
scale: 'day',
unitWidth: 20,
padUnits: 0,
minWidth: 1000
});
- expect(ts.t0).toBe(dayjs('2025-01-01').valueOf());
- expect(ts.t1).toBe(dayjs('2025-02-20').valueOf());
+ expect(ts.t0).toBe(new Date(2025, 0, 1).getTime());
+ expect(ts.t1).toBe(new Date(2025, 1, 20).getTime());
expect(ts.totalWidth).toBe(1000);
});
it('never shrinks a domain already wider than minWidth', () => {
const ts = createTimeScale({
- minTime: dayjs('2025-01-01').valueOf(),
- maxTime: dayjs('2025-01-31').valueOf(),
+ minTime: new Date(2025, 0, 1).getTime(),
+ maxTime: new Date(2025, 0, 31).getTime(),
scale: 'day',
unitWidth: 20,
padUnits: 0,
minWidth: 100
});
- expect(ts.t1).toBe(dayjs('2025-02-01').valueOf());
+ expect(ts.t1).toBe(new Date(2025, 1, 1).getTime());
expect(ts.totalWidth).toBe(620);
});
@@ -99,8 +103,8 @@ describe('createTimeScale', () => {
// Months render at (actual ms × pxPerMs), not exactly unitWidth, so the
// fill must land at or past minWidth despite short months.
const ts = createTimeScale({
- minTime: dayjs('2025-01-15').valueOf(),
- maxTime: dayjs('2025-02-15').valueOf(),
+ minTime: new Date(2025, 0, 15).getTime(),
+ maxTime: new Date(2025, 1, 15).getTime(),
scale: 'month',
unitWidth: 96,
padUnits: 0,
@@ -108,20 +112,20 @@ describe('createTimeScale', () => {
});
expect(ts.totalWidth).toBeGreaterThanOrEqual(500);
// Still snapped to a month boundary.
- expect(dayjs(ts.t1).date()).toBe(1);
+ expect(new Date(ts.t1).getDate()).toBe(1);
});
it('maps time to px linearly and inverts with timeAt', () => {
const ts = createTimeScale({
- minTime: dayjs('2025-01-01').valueOf(),
- maxTime: dayjs('2025-01-31').valueOf(),
+ minTime: new Date(2025, 0, 1).getTime(),
+ maxTime: new Date(2025, 0, 31).getTime(),
scale: 'day',
unitWidth: 20,
padUnits: 0
});
expect(ts.x(ts.t0)).toBe(0);
- expect(ts.x(dayjs('2025-01-05').valueOf())).toBe(80);
- const time = dayjs('2025-01-11').valueOf();
+ expect(ts.x(new Date(2025, 0, 5).getTime())).toBe(80);
+ const time = new Date(2025, 0, 11).getTime();
expect(ts.timeAt(ts.x(time))).toBe(time);
// Domain = Jan 1 → Feb 1 = 31 days.
expect(ts.totalWidth).toBe(31 * 20);
@@ -131,8 +135,8 @@ describe('createTimeScale', () => {
// 0 → pxPerMs 0 (NaN geometry); negative → inverted scale whose
// viewport-fill loop never terminates. Both clamp to a 1px unit.
const zero = createTimeScale({
- minTime: dayjs('2025-01-01').valueOf(),
- maxTime: dayjs('2025-01-31').valueOf(),
+ minTime: new Date(2025, 0, 1).getTime(),
+ maxTime: new Date(2025, 0, 31).getTime(),
scale: 'day',
unitWidth: 0,
padUnits: 0,
@@ -141,8 +145,8 @@ describe('createTimeScale', () => {
expect(Number.isFinite(zero.totalWidth)).toBe(true);
expect(zero.totalWidth).toBeGreaterThanOrEqual(500);
const negative = createTimeScale({
- minTime: dayjs('2025-01-01').valueOf(),
- maxTime: dayjs('2025-01-31').valueOf(),
+ minTime: new Date(2025, 0, 1).getTime(),
+ maxTime: new Date(2025, 0, 31).getTime(),
scale: 'day',
unitWidth: -20,
padUnits: 0,
@@ -156,8 +160,8 @@ describe('createTimeScale', () => {
describe('buildAxis', () => {
const januaryScale = createTimeScale({
- minTime: dayjs('2025-01-01').valueOf(),
- maxTime: dayjs('2025-01-31').valueOf(),
+ minTime: new Date(2025, 0, 1).getTime(),
+ maxTime: new Date(2025, 0, 31).getTime(),
scale: 'day',
unitWidth: 20,
padUnits: 0
@@ -181,8 +185,8 @@ describe('buildAxis', () => {
// 40px per tick → all labels show.
const roomy = buildAxis(
createTimeScale({
- minTime: dayjs('2025-01-01').valueOf(),
- maxTime: dayjs('2025-01-31').valueOf(),
+ minTime: new Date(2025, 0, 1).getTime(),
+ maxTime: new Date(2025, 0, 31).getTime(),
scale: 'day',
unitWidth: 40,
padUnits: 0
@@ -195,8 +199,8 @@ describe('buildAxis', () => {
it('labels every Nth unit when labelEvery is passed', () => {
const roomyScale = createTimeScale({
- minTime: dayjs('2025-01-01').valueOf(),
- maxTime: dayjs('2025-01-31').valueOf(),
+ minTime: new Date(2025, 0, 1).getTime(),
+ maxTime: new Date(2025, 0, 31).getTime(),
scale: 'day',
unitWidth: 40,
padUnits: 0
@@ -216,8 +220,8 @@ describe('buildAxis', () => {
it('collision floor wins over a too-dense labelEvery', () => {
// 10px per tick → auto floor is every 3rd; asking for every 2nd degrades.
const dense = createTimeScale({
- minTime: dayjs('2025-01-01').valueOf(),
- maxTime: dayjs('2025-01-31').valueOf(),
+ minTime: new Date(2025, 0, 1).getTime(),
+ maxTime: new Date(2025, 0, 31).getTime(),
scale: 'day',
unitWidth: 10,
padUnits: 0
@@ -231,8 +235,8 @@ describe('buildAxis', () => {
it('emits month bands over day ticks, with the year on the first band', () => {
const wide = createTimeScale({
- minTime: dayjs('2025-01-10').valueOf(),
- maxTime: dayjs('2025-02-20').valueOf(),
+ minTime: new Date(2025, 0, 10).getTime(),
+ maxTime: new Date(2025, 1, 20).getTime(),
scale: 'day',
unitWidth: 20,
padUnits: 0
@@ -243,8 +247,8 @@ describe('buildAxis', () => {
it('emits year bands over month ticks', () => {
const yearly = createTimeScale({
- minTime: dayjs('2024-11-01').valueOf(),
- maxTime: dayjs('2025-03-01').valueOf(),
+ minTime: new Date(2024, 10, 1).getTime(),
+ maxTime: new Date(2025, 2, 1).getTime(),
scale: 'month',
unitWidth: 96,
padUnits: 0
@@ -254,20 +258,17 @@ describe('buildAxis', () => {
expect(ticks[0].label).toBe('Nov');
});
- it('builds quarter ticks and year bands (hand-rolled, non-dayjs path)', () => {
- // Quarter snapping/stepping is hand-rolled (dayjs has no quarter unit
- // without a plugin): startOfUnit subtracts month % 3, addUnits steps by
- // 3 months, and tick labels derive Q1–Q4 from the month index.
+ it('builds quarter ticks and year bands', () => {
const quarterly = createTimeScale({
- minTime: dayjs('2024-11-15').valueOf(),
- maxTime: dayjs('2025-05-10').valueOf(),
+ minTime: new Date(2024, 10, 15).getTime(),
+ maxTime: new Date(2025, 4, 10).getTime(),
scale: 'quarter',
unitWidth: 140,
padUnits: 0
});
// Nov 15 snaps back to Q4's start; May 10 is in Q2, +1 unit → Jul 1.
- expect(dayjs(quarterly.t0).format('YYYY-MM-DD')).toBe('2024-10-01');
- expect(dayjs(quarterly.t1).format('YYYY-MM-DD')).toBe('2025-07-01');
+ expect(quarterly.t0).toBe(new Date(2024, 9, 1).getTime());
+ expect(quarterly.t1).toBe(new Date(2025, 6, 1).getTime());
const { ticks, bands } = buildAxis(quarterly, 'quarter', 140);
expect(ticks.map(t => t.label)).toEqual(['Q4', 'Q1', 'Q2', 'Q3']);
expect(ticks[0].x).toBe(0);
@@ -276,8 +277,8 @@ describe('buildAxis', () => {
it('builds week ticks snapped to week starts, with month bands', () => {
const weekly = createTimeScale({
- minTime: dayjs('2025-01-05').valueOf(), // a Sunday (dayjs week start)
- maxTime: dayjs('2025-01-20').valueOf(),
+ minTime: new Date(2025, 0, 5).getTime(), // a Sunday (the week start)
+ maxTime: new Date(2025, 0, 20).getTime(),
scale: 'week',
unitWidth: 56,
padUnits: 0
@@ -638,6 +639,20 @@ describe('DataView.Timeline', () => {
expect(screen.queryByText('6 Jan')).toBeNull();
});
+ it.each([
+ ['month', 96, 'Jan 2025'],
+ ['quarter', 140, 'Q1 2025']
+ ] as const)('labels the cursor badge with the %s and year', async (scale, unitWidth, label) => {
+ const { container } = renderTimeline({ scale, unitWidth });
+ const root = container.firstElementChild as HTMLElement;
+ expect(screen.queryByText(label)).toBeNull();
+ await act(async () => {
+ fireEvent.mouseMove(root, { clientX: 10 });
+ await new Promise(resolve => setTimeout(resolve, 30));
+ });
+ expect(screen.getByText(label)).toBeInTheDocument();
+ });
+
it('does not track the cursor when showCursorLine is false', async () => {
const { container } = renderTimeline({ showCursorLine: false });
const root = container.firstElementChild as HTMLElement;
@@ -794,7 +809,7 @@ describe('DataView.Timeline', () => {
expect(from).toBeInstanceOf(Date);
expect(to).toBeInstanceOf(Date);
// jsdom viewport is 0-wide at scrollLeft 0 → both edges sit at t0 (Jan 1).
- expect(from.getTime()).toBe(dayjs('2025-01-01').valueOf());
+ expect(from.getTime()).toBe(new Date(2025, 0, 1).getTime());
});
it('does not re-fire onVisibleRangeChange when the window is unchanged', async () => {
@@ -846,7 +861,7 @@ describe('DataView.Timeline', () => {
expect(onVisibleRangeChange.mock.calls.length).toBe(callsAfterMount + 1);
const calls = onVisibleRangeChange.mock.calls;
const [from] = calls[calls.length - 1][0];
- expect(from.getTime()).toBe(dayjs('2025-01-06').valueOf());
+ expect(from.getTime()).toBe(new Date(2025, 0, 6).getTime());
// Sub-pixel drift (scroll anchoring's float round-trip, device-pixel
// quantization of scrollLeft) is noise, not a scroll, so no re-fire.
@@ -1767,9 +1782,9 @@ describe('DataView.Timeline actionsRef', () => {
const range = actionsRef.current!.getVisibleRange();
expect(range).not.toBeNull();
const [from, to] = range!;
- expect(from.getTime()).toBe(dayjs('2025-01-11').valueOf());
+ expect(from.getTime()).toBe(new Date(2025, 0, 11).getTime());
// 0-wide jsdom viewport → both edges coincide.
- expect(to.getTime()).toBe(dayjs('2025-01-11').valueOf());
+ expect(to.getTime()).toBe(new Date(2025, 0, 11).getTime());
});
it('no-ops with a dev warning while hidden, and getVisibleRange is null', () => {
diff --git a/packages/react/components/data-view/components/timeline.tsx b/packages/react/components/data-view/components/timeline.tsx
index 4671ba567..f51f0042c 100644
--- a/packages/react/components/data-view/components/timeline.tsx
+++ b/packages/react/components/data-view/components/timeline.tsx
@@ -2,7 +2,7 @@
import type { Row } from '@tanstack/react-table';
import { cx } from 'class-variance-authority';
-import dayjs from 'dayjs';
+import { format } from 'date-fns';
import {
CSSProperties,
memo,
@@ -353,15 +353,15 @@ const MARKER_BADGE_VARIANT: Record<
/** Axis-badge label for the hover cursor, formatted per scale granularity. */
function cursorLabel(time: number, scale: TimelineScale): string {
- const date = dayjs(time);
+ const date = new Date(time);
switch (scale) {
case 'day':
case 'week':
- return date.format('D MMM');
+ return format(date, 'd MMM');
case 'month':
- return date.format('MMM YYYY');
+ return format(date, 'MMM yyyy');
case 'quarter':
- return `Q${Math.floor(date.month() / 3) + 1} ${date.format('YYYY')}`;
+ return format(date, 'QQQ yyyy');
}
}
@@ -925,7 +925,7 @@ export function DataViewTimeline({
key: '__today',
time: todayTime,
x: timeScale.x(todayTime),
- label: dayjs(todayTime).format('D MMM'),
+ label: format(todayTime, 'd MMM'),
variant: 'accent'
});
}
@@ -936,7 +936,7 @@ export function DataViewTimeline({
key: `__marker-${index}`,
time,
x: timeScale.x(time),
- label: marker.label ?? dayjs(time).format('D MMM'),
+ label: marker.label ?? format(time, 'd MMM'),
variant: marker.variant ?? 'default'
});
});
@@ -1009,7 +1009,7 @@ export function DataViewTimeline({
timeScale.t0,
Math.min(timeScale.timeAt(canvasX), timeScale.t1)
);
- const snapped = startOfUnit(dayjs(time), scale).valueOf();
+ const snapped = startOfUnit(new Date(time), scale).getTime();
setCursorTime(prev => (prev === snapped ? prev : snapped));
}, [showCursorLine, timeScale, scale]);
diff --git a/packages/react/components/data-view/data-view.tsx b/packages/react/components/data-view/data-view.tsx
index c2e419cbb..f784ab7b2 100644
--- a/packages/react/components/data-view/data-view.tsx
+++ b/packages/react/components/data-view/data-view.tsx
@@ -69,8 +69,8 @@ function DataViewRoot({
groupByResolvers
}: React.PropsWithChildren>) {
const defaultTableQuery = useMemo(
- () => getDefaultTableQuery(defaultSort, query),
- [defaultSort, query]
+ () => getDefaultTableQuery(defaultSort, query, fields),
+ [defaultSort, query, fields]
);
// Active view (controlled / uncontrolled).
diff --git a/packages/react/components/data-view/utils/filter-operations.tsx b/packages/react/components/data-view/utils/filter-operations.tsx
index 3268c0deb..8360c8115 100644
--- a/packages/react/components/data-view/utils/filter-operations.tsx
+++ b/packages/react/components/data-view/utils/filter-operations.tsx
@@ -1,8 +1,6 @@
import type { FilterFn } from '@tanstack/table-core';
-import dayjs from 'dayjs';
-import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
-import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
+import { dateFilterFns, toDayKey } from '~/shared/date-filters';
import {
DataTableFilterOperatorTypes,
DateFilterOperatorType,
@@ -19,9 +17,6 @@ import {
} from '~/types/filters';
import { DataViewFilterValues } from '../data-view.types';
-dayjs.extend(isSameOrAfter);
-dayjs.extend(isSameOrBefore);
-
export type FilterFunctionsMap = {
number: Record>;
string: Record>;
@@ -68,26 +63,7 @@ export const filterOperationsMap: FilterFunctionsMap = {
return columnValue.endsWith(filterStr);
}
},
- date: {
- eq: (row, columnId, filterValue: FilterValue) =>
- dayjs(row.getValue(columnId)).isSame(dayjs(filterValue.date), 'day'),
- neq: (row, columnId, filterValue: FilterValue) =>
- !dayjs(row.getValue(columnId)).isSame(dayjs(filterValue.date), 'day'),
- lt: (row, columnId, filterValue: FilterValue) =>
- dayjs(row.getValue(columnId)).isBefore(dayjs(filterValue.date), 'day'),
- lte: (row, columnId, filterValue: FilterValue) =>
- dayjs(row.getValue(columnId)).isSameOrBefore(
- dayjs(filterValue.date),
- 'day'
- ),
- gt: (row, columnId, filterValue: FilterValue) =>
- dayjs(row.getValue(columnId)).isAfter(dayjs(filterValue.date), 'day'),
- gte: (row, columnId, filterValue: FilterValue) =>
- dayjs(row.getValue(columnId)).isSameOrAfter(
- dayjs(filterValue.date),
- 'day'
- )
- },
+ date: dateFilterFns,
select: {
eq: (row, columnId, filterValue: FilterValue) => {
if (String(filterValue.value) === EmptyFilterValue)
@@ -131,16 +107,8 @@ const handleStringBasedTypes = (
): DataViewFilterValues => {
switch (filterType) {
case FilterType.date: {
- const dateValue = dayjs(value);
- let stringValue = '';
- if (dateValue.isValid()) {
- try {
- stringValue = dateValue.toISOString();
- } catch {
- stringValue = '';
- }
- }
- return { value, stringValue };
+ const day = toDayKey(value);
+ return { value, stringValue: day ?? '' };
}
case FilterType.select:
return {
diff --git a/packages/react/components/data-view/utils/index.tsx b/packages/react/components/data-view/utils/index.tsx
index 83dccd5e5..4cacae8c5 100644
--- a/packages/react/components/data-view/utils/index.tsx
+++ b/packages/react/components/data-view/utils/index.tsx
@@ -4,8 +4,8 @@ import {
type RowModel,
TableState
} from '@tanstack/table-core';
-import dayjs from 'dayjs';
+import { toDayKey, withDateFilterTypes } from '~/shared/date-filters';
import { FilterOperatorTypes, FilterType } from '~/types/filters';
import {
DataViewField,
@@ -29,7 +29,8 @@ export function queryToTableState(query: InternalQuery): Partial {
const columnFilters =
query.filters
?.filter(data => {
- if (data._type === FilterType.date) return dayjs(data.value).isValid();
+ if (data._type === FilterType.date)
+ return toDayKey(data.value) !== null;
if (data.value !== '') return true;
return false;
})
@@ -262,7 +263,8 @@ export function transformToDataViewQuery(query: InternalQuery): DataViewQuery {
filters
?.filter(data => {
if (data._type === FilterType.select) return true;
- if (data._type === FilterType.date) return dayjs(data.value).isValid();
+ if (data._type === FilterType.date)
+ return toDayKey(data.value) !== null;
if (data.value !== '') return true;
return false;
})
@@ -400,12 +402,16 @@ export function hasActiveTableFiltering(table: Table): boolean {
export function getDefaultTableQuery(
defaultSort: DataViewSort,
- oldQuery: DataViewQuery = {}
+ oldQuery: DataViewQuery = {},
+ fields: Pick[] = []
): InternalQuery {
const internalQuery = dataViewQueryToInternal(oldQuery);
return {
sort: [defaultSort],
group_by: [defaultGroupOption.id],
- ...internalQuery
+ ...internalQuery,
+ ...(internalQuery.filters && {
+ filters: withDateFilterTypes(internalQuery.filters, fields)
+ })
};
}
diff --git a/packages/react/components/data-view/utils/time-scale.tsx b/packages/react/components/data-view/utils/time-scale.tsx
index 680fcdef5..29759c3c2 100644
--- a/packages/react/components/data-view/utils/time-scale.tsx
+++ b/packages/react/components/data-view/utils/time-scale.tsx
@@ -1,5 +1,17 @@
-import dayjs, { type Dayjs } from 'dayjs';
-
+import {
+ addDays,
+ addMonths,
+ addQuarters,
+ addWeeks,
+ addYears,
+ format,
+ startOfDay,
+ startOfMonth,
+ startOfQuarter,
+ startOfWeek,
+ startOfYear
+} from 'date-fns';
+import { toInstant } from '~/shared/date-filters';
import type { TimelineScale } from '../data-view.types';
/**
@@ -25,34 +37,33 @@ export const TIMELINE_DEFAULT_UNIT_WIDTH: Record = {
/** Minimum px between rendered tick labels. Denser ticks skip labels. */
const TICK_LABEL_MIN_SPACE = 28;
-/** Coerce a consumer-provided date (Date | epoch ms | parseable string) to ms. */
+/** Coerce a consumer-provided date (Date | epoch ms | parseable string | object with a numeric `valueOf`) to ms. */
export function toTimestamp(value: unknown): number | null {
- if (value == null) return null;
- if (value instanceof Date) {
- const time = value.getTime();
- return Number.isNaN(time) ? null : time;
- }
- if (typeof value === 'number') {
- return Number.isFinite(value) ? value : null;
- }
- if (typeof value === 'string') {
- const parsed = dayjs(value);
- return parsed.isValid() ? parsed.valueOf() : null;
- }
- return null;
+ return toInstant(value)?.getTime() ?? null;
}
-/** `startOf` that also understands quarters without a dayjs plugin. */
-export function startOfUnit(date: Dayjs, scale: TimelineScale): Dayjs {
- if (scale === 'quarter') {
- return date.startOf('month').subtract(date.month() % 3, 'month');
- }
- return date.startOf(scale);
+const START_OF = {
+ day: startOfDay,
+ week: startOfWeek,
+ month: startOfMonth,
+ quarter: startOfQuarter,
+ year: startOfYear
+};
+
+const ADD = {
+ day: addDays,
+ week: addWeeks,
+ month: addMonths,
+ quarter: addQuarters,
+ year: addYears
+};
+
+export function startOfUnit(date: Date, scale: TimelineScale): Date {
+ return START_OF[scale](date);
}
-export function addUnits(date: Dayjs, scale: TimelineScale, n: number): Dayjs {
- if (scale === 'quarter') return date.add(3 * n, 'month');
- return date.add(n, scale);
+export function addUnits(date: Date, scale: TimelineScale, n: number): Date {
+ return ADD[scale](date, n);
}
export interface TimelineTimeScale {
@@ -90,26 +101,26 @@ export function createTimeScale(params: {
const unitWidth = Math.max(1, params.unitWidth);
const pxPerMs = unitWidth / TIMELINE_UNIT_MS[scale];
const t0 = addUnits(
- startOfUnit(dayjs(Math.min(minTime, maxTime)), scale),
+ startOfUnit(new Date(Math.min(minTime, maxTime)), scale),
scale,
-padUnits
- ).valueOf();
+ ).getTime();
// +1 so the max instant's unit is fully inside the domain.
let end = addUnits(
- startOfUnit(dayjs(Math.max(minTime, maxTime)), scale),
+ startOfUnit(new Date(Math.max(minTime, maxTime)), scale),
scale,
padUnits + 1
);
// Viewport fill: bulk-add the estimated deficit in one step, then correct
// for calendar drift (short months, DST days), at most a few iterations.
- const deficitPx = minWidth - (end.valueOf() - t0) * pxPerMs;
+ const deficitPx = minWidth - (end.getTime() - t0) * pxPerMs;
if (deficitPx > 0) {
end = addUnits(end, scale, Math.ceil(deficitPx / unitWidth));
- while ((end.valueOf() - t0) * pxPerMs < minWidth) {
+ while ((end.getTime() - t0) * pxPerMs < minWidth) {
end = addUnits(end, scale, 1);
}
}
- const t1 = end.valueOf();
+ const t1 = end.getTime();
return {
t0,
t1,
@@ -137,15 +148,15 @@ export interface TimelineBand {
label: string;
}
-function tickLabel(date: Dayjs, scale: TimelineScale): string {
+function tickLabel(date: Date, scale: TimelineScale): string {
switch (scale) {
case 'day':
case 'week':
- return date.format('D');
+ return format(date, 'd');
case 'month':
- return date.format('MMM');
+ return format(date, 'MMM');
case 'quarter':
- return `Q${Math.floor(date.month() / 3) + 1}`;
+ return format(date, 'QQQ');
}
}
@@ -181,13 +192,13 @@ export function buildAxis(
);
const ticks: TimelineTick[] = [];
- let cursor = startOfUnit(dayjs(timeScale.t0), scale);
- if (cursor.valueOf() < timeScale.t0) cursor = addUnits(cursor, scale, 1);
+ let cursor = startOfUnit(new Date(timeScale.t0), scale);
+ if (cursor.getTime() < timeScale.t0) cursor = addUnits(cursor, scale, 1);
let index = 0;
- while (cursor.valueOf() <= timeScale.t1) {
+ while (cursor.getTime() <= timeScale.t1) {
ticks.push({
- time: cursor.valueOf(),
- x: timeScale.x(cursor.valueOf()),
+ time: cursor.getTime(),
+ x: timeScale.x(cursor.getTime()),
label: tickLabel(cursor, scale),
showLabel: index % effectiveLabelEvery === 0,
index
@@ -198,20 +209,20 @@ export function buildAxis(
const bands: TimelineBand[] = [];
const bandUnit = scale === 'day' || scale === 'week' ? 'month' : 'year';
- let band = dayjs(timeScale.t0).startOf(bandUnit);
+ let band = START_OF[bandUnit](new Date(timeScale.t0));
let isFirst = true;
- while (band.valueOf() < timeScale.t1) {
- const next = band.add(1, bandUnit);
- const from = Math.max(band.valueOf(), timeScale.t0);
- const to = Math.min(next.valueOf(), timeScale.t1);
+ while (band.getTime() < timeScale.t1) {
+ const next = ADD[bandUnit](band, 1);
+ const from = Math.max(band.getTime(), timeScale.t0);
+ const to = Math.min(next.getTime(), timeScale.t1);
const label =
bandUnit === 'month'
- ? isFirst || band.month() === 0
- ? band.format('MMM YYYY')
- : band.format('MMM')
- : band.format('YYYY');
+ ? isFirst || band.getMonth() === 0
+ ? format(band, 'MMM yyyy')
+ : format(band, 'MMM')
+ : format(band, 'yyyy');
bands.push({
- time: band.valueOf(),
+ time: band.getTime(),
x: timeScale.x(from),
width: (to - from) * timeScale.pxPerMs,
label
diff --git a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx
index a80622421..b0d09dfb0 100644
--- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx
+++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx
@@ -1,5 +1,8 @@
-import { fireEvent, render, screen } from '@testing-library/react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
+import { defaultFormatValue } from '~/components/calendar-preview/calendar-preview-root';
+import { getAllSlots, getSlot } from '~/test-utils/data-slots';
import { FilterType } from '~/types/filters';
import { FilterChip } from '../filter-chip';
import styles from '../filter-chip.module.css';
@@ -298,76 +301,295 @@ describe('FilterChip', () => {
});
describe('Date Filter Type', () => {
- it('renders the date picker without crashing when no value is set', () => {
- // Regression: an unset date chip seeds its value with '' and forwarded
- // that string to DatePicker, whose controlled-sync effect ran
- // `valueProp?.getTime()` → "getTime is not a function".
+ it('renders the calendar without crashing when no value is set', () => {
expect(() =>
render()
).not.toThrow();
expect(screen.getByPlaceholderText('Select date')).toBeInTheDocument();
});
- it('parses a serialized string date value instead of rendering blank', () => {
+ it.each([
+ ['a serialized string', '2026-05-27'],
+ ['an epoch number', new Date(2026, 4, 27).getTime()],
+ ['a Date', new Date(2026, 4, 27)],
+ [
+ 'an object with a numeric valueOf',
+ { valueOf: () => new Date(2026, 4, 27).getTime() }
+ ]
+ ])('shows %s as its day', (_label, value) => {
render(
);
- expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument();
+ expect(
+ screen.getByDisplayValue(
+ defaultFormatValue(new Date(2026, 4, 27), 'day')
+ )
+ ).toBeInTheDocument();
+ });
+
+ it.each([
+ ['an unparseable string', 'not-a-date'],
+ ['an invalid Date', new Date('')],
+ ['a year above 9999', new Date(10000, 0, 1)],
+ ['a timestamp in a year above 9999', Date.UTC(10000, 6, 1)],
+ [
+ 'an object whose valueOf throws',
+ {
+ valueOf() {
+ throw new TypeError('valueOf');
+ }
+ }
+ ]
+ ])('renders %s as unselected instead of crashing', (_label, value) => {
+ expect(() =>
+ render(
+
+ )
+ ).not.toThrow();
+ expect(screen.getByPlaceholderText('Select date')).toHaveValue('');
});
- it('parses an epoch number value', () => {
- // Local-component Date so the timestamp is timezone-stable.
+ it('opens the calendar on 31 December 9999', () => {
render(
);
- expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument();
+ const input = screen.getByPlaceholderText('Select date');
+ act(() => input.focus());
+ expect(isOpen()).toBe(true);
});
- it('coerces an unparseable value to unselected instead of crashing', () => {
- expect(() =>
+ it('renders a value that is past 9999 in calendarProps.timeZone as unselected', () => {
+ /* In UTC the value is still in 9999, so only the calendar zone rejects it. */
+ const previous = process.env.TZ;
+ process.env.TZ = 'UTC';
+ try {
+ expect(() =>
+ render(
+
+ )
+ ).not.toThrow();
+ expect(screen.getByPlaceholderText('Select date')).toHaveValue('');
+ } finally {
+ if (previous === undefined) delete process.env.TZ;
+ else process.env.TZ = previous;
+ }
+ });
+
+ it('forwards calendarProps to the calendar', () => {
+ render(
+
+ `${date.getDate()} ${timeZone ?? 'local'}`
+ }}
+ />
+ );
+ expect(screen.getByDisplayValue('27 local')).toBeInTheDocument();
+ });
+
+ it('emits the typed date', () => {
+ const onValueChange = vi.fn();
+ render(
+
+ );
+ const input = screen.getByPlaceholderText('Select date');
+ fireEvent.change(input, { target: { value: '27 May 2026' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onValueChange).toHaveBeenCalledWith(
+ new Date(2026, 4, 27),
+ expect.any(String)
+ );
+ });
+
+ it('emits an empty value when the date is cleared', () => {
+ const onValueChange = vi.fn();
+ render(
+
+ );
+ const input = screen.getByPlaceholderText('Select date');
+ fireEvent.change(input, { target: { value: '' } });
+ fireEvent.blur(input);
+ expect(onValueChange).toHaveBeenCalledWith('', expect.any(String));
+ expect(input).toHaveValue('');
+ });
+
+ const isOpen = () =>
+ getSlot(document.body, 'calendar-preview-content') !== null;
+ const clickDay = (day: string) => {
+ const cell = getAllSlots(document.body, 'calendar-preview-day').find(
+ one =>
+ getSlot(one, 'calendar-preview-day-number')?.textContent === day &&
+ !one.hasAttribute('data-outside')
+ ) as HTMLElement;
+ fireEvent.pointerDown(cell);
+ act(() => cell.focus());
+ fireEvent.click(cell);
+ };
+
+ it('closes the calendar when a day is picked', async () => {
+ const onValueChange = vi.fn();
+ render(
+
+ );
+ const input = screen.getByPlaceholderText('Select date');
+ act(() => input.focus());
+ expect(isOpen()).toBe(true);
+ clickDay('12');
+ expect(onValueChange).toHaveBeenCalledWith(
+ new Date(2026, 4, 12),
+ expect.any(String)
+ );
+ await act(() => new Promise(resolve => setTimeout(resolve, 100)));
+ expect(isOpen()).toBe(false);
+ });
+
+ it('keeps the calendar open when the picked day is cleared', () => {
+ render(
+
+ );
+ fireEvent.focus(screen.getByPlaceholderText('Select date'));
+ clickDay('27');
+ expect(isOpen()).toBe(true);
+ });
+
+ it('forwards slotProps to the input and the popup', () => {
+ const { container } = render(
+
+ );
+ const input = screen.getByPlaceholderText('Pick a day');
+ expect(container.querySelector('.custom-input')).toHaveClass(
+ styles.dateField
+ );
+ fireEvent.focus(input);
+ expect(
+ getSlot(document.body, 'calendar-preview-content')?.querySelector(
+ '.custom-popup'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('shows the calendar icon only with showCalendarIcon', () => {
+ const icons = (showCalendarIcon?: boolean) =>
render(
- )
- ).not.toThrow();
- expect(screen.getByPlaceholderText('Select date')).toHaveValue('');
+ ).container.querySelectorAll('[data-slot="filter-chip-value"] svg')
+ .length;
+ expect(icons()).toBe(0);
+ expect(icons(true)).toBe(1);
});
- it('formats a Date value with the default month-as-text format', () => {
- // Local-component Date so the formatted string is timezone-stable.
+ it('reports a typed error through onErrorChange', () => {
+ const onErrorChange = vi.fn();
render(
);
- expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument();
+ const input = screen.getByPlaceholderText('Select date');
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'not a date' } });
+ expect(onErrorChange).toHaveBeenLastCalledWith('Invalid date');
+ fireEvent.change(input, { target: { value: '27 May 2026' } });
+ expect(onErrorChange).toHaveBeenLastCalledWith(undefined);
});
- it('forwards calendarProps to the underlying DatePicker', () => {
- // dateFormat is the easiest forwarded prop to observe, since the formatted
- // string in the input changes when it lands on DatePicker.
+ it('keeps a typed error when a string value rerenders the chip', () => {
+ function Parent() {
+ const [error, setError] = useState();
+ return (
+ <>
+ {error}
+
+ >
+ );
+ }
+ render();
+ const input = screen.getByDisplayValue(
+ defaultFormatValue(new Date(2026, 4, 27), 'day')
+ );
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'not a date' } });
+ expect(input).toHaveValue('not a date');
+ expect(screen.getByTestId('error')).toHaveTextContent('Invalid date');
+ });
+
+ it('does not select a day when slotProps.input is disabled', () => {
+ const onValueChange = vi.fn();
render(
);
- expect(screen.getByDisplayValue('27/05/2026')).toBeInTheDocument();
+ fireEvent.click(
+ getSlot(document.body, 'calendar-preview-trigger') as HTMLElement
+ );
+ if (isOpen()) clickDay('12');
+ expect(onValueChange).not.toHaveBeenCalled();
});
});
diff --git a/packages/react/components/filter-chip/filter-chip.module.css b/packages/react/components/filter-chip/filter-chip.module.css
index ef210df74..75eaef08a 100644
--- a/packages/react/components/filter-chip/filter-chip.module.css
+++ b/packages/react/components/filter-chip/filter-chip.module.css
@@ -43,7 +43,7 @@
* resize pressure so `text-overflow` ellipsizes with padding intact;
* `min-width` keeps an empty value clickable, `max-width` caps a runaway one. */
.chip [data-slot="input"],
-.chip [data-slot="date-picker-input"] {
+.chip [data-slot="calendar-preview-input"] {
border: none;
box-shadow: none;
background: transparent;
@@ -212,13 +212,12 @@ button.selectValue:hover {
box-shadow: none;
}
-/* match height of the DatePicker when FilterChip variant is text */
.chip[data-variant="text"] .dateField {
height: var(--rs-space-7);
min-height: var(--rs-space-7);
}
-.dateField [data-slot="date-picker-input"] {
+.dateField [data-slot="calendar-preview-input"] {
text-align: left;
padding-left: var(--rs-space-3);
padding-right: var(--rs-space-3);
diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx
index 01c9192fc..21bb049e6 100644
--- a/packages/react/components/filter-chip/filter-chip.tsx
+++ b/packages/react/components/filter-chip/filter-chip.tsx
@@ -1,9 +1,23 @@
'use client';
-import { cva, VariantProps } from 'class-variance-authority';
-import dayjs from 'dayjs';
-import { ComponentProps, ReactElement, useCallback, useState } from 'react';
+import { TZDate } from '@date-fns/tz';
+import { cva, cx, VariantProps } from 'class-variance-authority';
+import { parseISO } from 'date-fns';
+import {
+ ComponentProps,
+ ReactElement,
+ useCallback,
+ useMemo,
+ useState
+} from 'react';
+import {
+ CalendarPreview,
+ type CalendarPreviewContentProps,
+ type CalendarPreviewInputProps,
+ type CalendarPreviewProps
+} from '~/components/calendar-preview';
import { XIcon } from '~/icons';
+import { toInstant } from '~/shared/date-filters';
import {
FilterOperation,
FilterOperator,
@@ -12,7 +26,6 @@ import {
FilterTypes,
filterOperators
} from '~/types/filters';
-import { DatePicker, type DatePickerProps } from '../calendar';
import { Flex } from '../flex';
import { Input } from '../input';
import { Select } from '../select';
@@ -35,33 +48,49 @@ const chip = cva(styles.chip, {
export type FilterChipValue = string | string[] | number | Date;
-/**
- * Coerce a `FilterChipValue` to the `Date` the DatePicker expects, since filter
- * state hydrated from a serialized query arrives as a string or epoch number.
- * Unparseable values leave the field unselected.
- */
-const toDateValue = (value: unknown): Date | undefined => {
- if (value instanceof Date) return value;
- if (typeof value === 'string' || typeof value === 'number') {
- const parsed = dayjs(value);
- return parsed.isValid() ? parsed.toDate() : undefined;
- }
- return undefined;
+/* The message `DatePicker` reports, so `onErrorChange` reads the same. */
+const INVALID_DATE = 'Invalid date';
+
+/* A year the calendar cannot show leaves the field empty. */
+const toDateValue = (value: unknown, timeZone?: string): Date | undefined => {
+ const date = toInstant(value);
+ if (!date) return undefined;
+ const year = (timeZone ? new TZDate(date, timeZone) : date).getFullYear();
+ return year >= 1 && year <= 9999 ? date : undefined;
};
/** Checked on change, not keydown, so paste and IME input are covered. */
const PARTIAL_NUMBER = /^-?\d*\.?\d*$/;
/**
- * Subset of `DatePickerProps` that consumers may forward to the chip's
- * built-in DatePicker via `calendarProps`. `value`/`onSelect`/`defaultValue`
- * are owned by `FilterChip`; `children` would replace the input trigger and
- * break the chip layout.
+ * The `CalendarPreview` props that consumers may forward to the chip's
+ * calendar via `calendarProps`. `FilterChip` owns the value and the parts.
*/
-export type FilterChipCalendarProps = Omit<
- DatePickerProps,
- 'value' | 'onSelect' | 'defaultValue' | 'children'
->;
+export type FilterChipCalendarProps = Pick<
+ CalendarPreviewProps,
+ | 'timeZone'
+ | 'minDate'
+ | 'maxDate'
+ | 'isDateUnavailable'
+ | 'yearRange'
+ | 'defaultMonth'
+ | 'today'
+> & {
+ /** Formats the selected date for the input. */
+ formatValue?: (date: Date, timeZone?: string) => string;
+ /** Props for the chip's date input and its popup. */
+ slotProps?: {
+ input?: Omit;
+ popover?: Omit;
+ };
+ /**
+ * Shows the calendar icon in the date input.
+ * @default false
+ */
+ showCalendarIcon?: boolean;
+ /** Called with a message when the typed date is invalid, and with `undefined` when it is valid again. */
+ onErrorChange?: (error: string | undefined) => void;
+};
export interface FilterChipProps
extends ComponentProps<'div'>,
@@ -76,18 +105,14 @@ export interface FilterChipProps
leadingIcon?: ReactElement;
operations?: FilterOperator[];
selectProps?: BaseSelectProps;
- /**
- * Props forwarded to the underlying `DatePicker` for `columnType="date"`.
- * `value`/`onSelect`/`defaultValue` are owned by `FilterChip` and excluded;
- * `children` is excluded so the chip's input trigger isn't replaced.
- */
+ /** Props forwarded to the `CalendarPreview` for `columnType="date"`. */
calendarProps?: FilterChipCalendarProps;
}
/**
* A compact, removable filter pill that pairs a label and operator with a
* value control chosen by `columnType`: a `Select` (`select`/`multiselect`),
- * a `DatePicker` (`date`), or a text `Input` (`string`/`number`). The value
+ * a `CalendarPreview` (`date`), or a text `Input` (`string`/`number`). The value
* control sizes to its content so the chip hugs the active filter. Emits
* `onValueChange`/`onOperationChange` and renders a remove button when
* `onRemove` is provided.
@@ -132,6 +157,22 @@ export const FilterChip = ({
return PARTIAL_NUMBER.test(text) ? text : '';
});
+ const [dateOpen, setDateOpen] = useState(false);
+ const {
+ formatValue: formatDate,
+ slotProps: dateSlotProps,
+ showCalendarIcon = false,
+ onErrorChange,
+ ...calendarRest
+ } = calendarProps ?? {};
+ const { classNames: inputClassNames, ...inputProps } =
+ dateSlotProps?.input ?? {};
+ /* A new `Date` each render reads as a new value, and the input drops its typed text. */
+ const dateValue = useMemo(
+ () => toDateValue(filterValue, calendarRest.timeZone) ?? null,
+ [filterValue, calendarRest.timeZone]
+ );
+
const showOnRemove = typeof onRemove === 'function';
const isMultiSelectColumn = columnType === FilterType.multiselect;
@@ -214,19 +255,52 @@ export const FilterChip = ({
className={styles.dateFieldWrapper}
data-slot='filter-chip-value'
>
- handleFilterValueChange(date)}
- slotProps={{
- ...calendarProps?.slotProps,
- input: {
- classNames: { container: styles.dateField },
- ...calendarProps?.slotProps?.input
- }
+
+ formatDate(
+ date instanceof Date ? date : parseISO(date.date),
+ timeZone
+ ))
+ }
+ disabled={inputProps.disabled}
+ readOnly={inputProps.readOnly}
+ value={dateValue}
+ onValueChange={date => {
+ handleFilterValueChange(date ?? '');
+ if (date) setDateOpen(false);
}}
- />
+ open={dateOpen}
+ onOpenChange={setDateOpen}
+ >
+
+ {
+ inputProps.onValidityChange?.(validity);
+ onErrorChange?.(
+ validity.valid ? undefined : validity.message
+ );
+ }}
+ />
+
+
+
+
+
);
default:
diff --git a/packages/react/shared/__tests__/date-filters.test.ts b/packages/react/shared/__tests__/date-filters.test.ts
new file mode 100644
index 000000000..5c1583bb1
--- /dev/null
+++ b/packages/react/shared/__tests__/date-filters.test.ts
@@ -0,0 +1,165 @@
+import { describe, expect, it } from 'vitest';
+import { toDayKey, toInstant } from '../date-filters';
+
+describe('toInstant', () => {
+ it.each([
+ ['an ISO day', '2023-12-01', new Date(2023, 11, 1)],
+ ['a year and month', '2023-12', new Date(2023, 11, 1)],
+ ['a bare year', '2023', new Date(2023, 0, 1)],
+ ['basic ISO', '20231201', new Date(2023, 11, 1)],
+ ['a local time', '2023-12-01T10:30:00', new Date(2023, 11, 1, 10, 30)],
+ [
+ 'a space before the time',
+ '2023-12-01 10:30',
+ new Date(2023, 11, 1, 10, 30)
+ ],
+ ['a lowercase t', '2023-12-01t10:30:00', new Date(2023, 11, 1, 10, 30)],
+ ['slashes', '2023/12/01', new Date(2023, 11, 1)],
+ ['an unpadded day', '2023-1-5', new Date(2023, 0, 5)],
+ ['an unpadded slashed day', '2023/1/5', new Date(2023, 0, 5)],
+ [
+ 'a slashed day with a T time',
+ '2026/1/1T10:30',
+ new Date(2026, 0, 1, 10, 30)
+ ],
+ ['an unpadded day with a T hour', '2026-1-5T10', new Date(2026, 0, 5, 10)],
+ ['a bare year with a t hour', '2026t10', new Date(2026, 0, 1, 10)],
+ [
+ 'a local time with a longer fraction',
+ '2023/12/01 10:30:00.123456',
+ new Date(2023, 11, 1, 10, 30, 0, 123)
+ ],
+ ['a month-first slashed day', '12/01/2023', new Date(2023, 11, 1)],
+ [
+ 'a day-first slashed day, read month-first',
+ '01/12/2023',
+ new Date(2023, 0, 12)
+ ],
+ ['a leap day', '02/29/2024', new Date(2024, 1, 29)],
+ ['a dashed month-first day', '2-28-2026', new Date(2026, 1, 28)],
+ ['a long month name', 'December 1, 2023', new Date(2023, 11, 1)],
+ ['a short month name', '1 Dec 2023', new Date(2023, 11, 1)],
+ ['a four-letter month name', 'Sept 30, 2026', new Date(2026, 8, 30)],
+ ['a weekday prefix', 'Monday, March 2, 2026', new Date(2026, 2, 2)],
+ ['a month and year', 'Dec 2023', new Date(2023, 11, 1)],
+ ['a dotted day', '2023.12.01', new Date(2023, 11, 1)],
+ ['surrounding whitespace', ' 2023-12-01 ', new Date(2023, 11, 1)],
+ ['epoch zero', 0, new Date(0)],
+ [
+ 'an object with a numeric valueOf',
+ { valueOf: () => Date.UTC(2026, 7, 15, 12) },
+ new Date(Date.UTC(2026, 7, 15, 12))
+ ],
+ ['a UTC suffix', '2023-02-28T00:00:00Z', new Date(Date.UTC(2023, 1, 28))],
+ [
+ 'microseconds',
+ '2023-12-01T10:30:00.123456Z',
+ new Date(Date.UTC(2023, 11, 1, 10, 30, 0, 123))
+ ],
+ [
+ 'a Date string',
+ 'Fri Dec 01 2023 00:00:00 GMT+0000',
+ new Date(Date.UTC(2023, 11, 1))
+ ],
+ [
+ 'an RFC 2822 string',
+ 'Fri, 01 Dec 2023 00:00:00 GMT',
+ new Date(Date.UTC(2023, 11, 1))
+ ],
+ [
+ 'a GMT offset',
+ 'Fri Dec 01 2023 02:00:00 GMT+0530',
+ new Date(Date.UTC(2023, 10, 30, 20, 30))
+ ],
+ [
+ 'a US zone name',
+ 'Nov 30 2026 23:00 EST',
+ new Date(Date.UTC(2026, 11, 1, 4))
+ ]
+ ])('reads %s', (_label, input, expected) => {
+ expect(toInstant(input)?.getTime()).toBe(expected.getTime());
+ });
+
+ it.each([
+ ['an empty string', ''],
+ ['whitespace', ' '],
+ ['a non-date string', 'not a date'],
+ ['null', null],
+ ['undefined', undefined],
+ ['true', true],
+ ['an invalid Date', new Date(Number.NaN)],
+ ['an object', { date: '2023-12-01' }],
+ ['an object without a numeric valueOf', { valueOf: () => 'x' }],
+ ['an object with no prototype', Object.create(null)],
+ ['an object whose valueOf is NaN', { valueOf: () => Number.NaN }],
+ [
+ 'an object whose valueOf throws',
+ {
+ valueOf() {
+ throw new TypeError('valueOf');
+ }
+ }
+ ],
+ ['an epoch as a string', '1701388800000'],
+ ['month 13', '2023-13-01'],
+ ['month 0', '2023-00-10'],
+ ['day 0', '2023-12-00'],
+ ['day 32', '2023-12-32'],
+ ['30 February', '2023-02-30'],
+ ['30 February with a UTC suffix', '2023-02-30T00:00:00Z'],
+ ['30 February with an offset', '2026-02-30T12:00:00+05:30']
+ ])('rejects %s', (_label, input) => {
+ expect(toInstant(input)).toBeNull();
+ });
+
+ /* Outside the ISO and local shapes, `new Date` reads the string and rolls an
+ impossible day over. */
+ it.each([
+ ['02/30/2014', new Date(2014, 2, 2)],
+ ['February 30, 2026', new Date(2026, 2, 2)]
+ ])('reads %s as new Date does', (input, expected) => {
+ expect(toInstant(input)?.getTime()).toBe(expected.getTime());
+ });
+
+ it.each([
+ ['America/Los_Angeles', '2026/03/08 02:30', '2026-03-08T10:30:00.000Z'],
+ ['America/Santiago', '2026/09/06', '2026-09-06T04:00:00.000Z']
+ ])('reads a time in a daylight-saving gap in %s', (zone, input, expected) => {
+ const previous = process.env.TZ;
+ process.env.TZ = zone;
+ try {
+ expect(toInstant(input)?.toISOString()).toBe(expected);
+ } finally {
+ if (previous === undefined) delete process.env.TZ;
+ else process.env.TZ = previous;
+ }
+ });
+
+ it.each([
+ ['an ISO week', '2023-W48', new Date(2023, 10, 27)],
+ ['an ordinal day', '2023-335', new Date(2023, 11, 1)],
+ [
+ 'a one-digit fraction',
+ '2023-12-01T10:30:00.5',
+ new Date(2023, 11, 1, 10, 30, 0, 500)
+ ],
+ [
+ 'a year below 100',
+ '0050-01-01',
+ new Date(new Date(0, 0, 1).setFullYear(50))
+ ]
+ ])('reads %s as ISO 8601', (_label, input, expected) => {
+ expect(toInstant(input)?.getTime()).toBe(expected.getTime());
+ });
+});
+
+describe('toDayKey', () => {
+ it('reads the local calendar day', () => {
+ expect(toDayKey('2023-12-01')).toBe('2023-12-01');
+ expect(toDayKey(new Date(2023, 11, 1, 23, 59))).toBe('2023-12-01');
+ });
+
+ it('rejects a year outside four digits', () => {
+ expect(toDayKey(new Date(10000, 0, 1))).toBeNull();
+ });
+});
diff --git a/packages/react/shared/date-filters.ts b/packages/react/shared/date-filters.ts
new file mode 100644
index 000000000..e772f3b9e
--- /dev/null
+++ b/packages/react/shared/date-filters.ts
@@ -0,0 +1,136 @@
+import type { FilterFn } from '@tanstack/table-core';
+import { format, isExists, isValid, parseISO } from 'date-fns';
+import type {
+ DateFilterOperatorType,
+ FilterTypes,
+ FilterValue,
+ FilterValueType
+} from '~/types/filters';
+import { FilterType } from '~/types/filters';
+
+/* `uuuu`, not `yyyy`: year-of-era formats JS year 0 as `'0001'`. */
+const DAY_KEY_FORMAT = 'uuuu-MM-dd';
+const DAY_KEY_SHAPE = /^\d{4}-\d{2}-\d{2}$/;
+
+/** The `yyyy-MM-dd` day of a row or filter value in the viewer's zone. */
+export function toDayKey(value: unknown): string | null {
+ const date = toInstant(value);
+ if (!date) return null;
+ const key = format(date, DAY_KEY_FORMAT);
+ return DAY_KEY_SHAPE.test(key) ? key : null;
+}
+
+export function toInstant(value: unknown): Date | null {
+ /* A date-library object reads as its numeric `valueOf`. */
+ if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
+ let epoch: unknown;
+ try {
+ epoch = typeof value.valueOf === 'function' ? value.valueOf() : undefined;
+ } catch {
+ return null;
+ }
+ return typeof epoch === 'number' && Number.isFinite(epoch)
+ ? new Date(epoch)
+ : null;
+ }
+ if (value instanceof Date) return isValid(value) ? value : null;
+ if (typeof value === 'number') {
+ const fromEpoch = new Date(value);
+ return isValid(fromEpoch) ? fromEpoch : null;
+ }
+ if (typeof value !== 'string') return null;
+ const iso = parseISO(value.replace(ZONE_ANNOTATION, ''));
+ if (isValid(iso)) return iso;
+ const isoDay = ISO_DAY.exec(value);
+ if (isoDay) {
+ const [, year, month, day] = isoDay.map(Number);
+ if (!isExists(year, month - 1, day)) return null;
+ }
+ const parts = LOCAL_SHAPE.exec(value);
+ if (parts) return fromLocalParts(parts);
+ const native = new Date(value);
+ return isValid(native) ? native : null;
+}
+
+/* RFC 9557, as in `2026-08-15T23:00+05:30[Asia/Kolkata]`. parseISO rejects it. */
+const ZONE_ANNOTATION = /(\[[^\]]*\])+$/;
+
+/* `new Date` rolls an impossible day over, whatever suffix follows it. */
+const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})/;
+
+/* Year-first strings read as local time. `new Date` reads some of these as UTC
+ and rolls out-of-range fields over, so they never reach it. */
+const LOCAL_SHAPE =
+ /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/;
+
+function fromLocalParts(parts: RegExpExecArray): Date | null {
+ const year = Number(parts[1]);
+ const [month = 1, day = 1, hour = 0, minute = 0, second = 0] = parts
+ .slice(2, 7)
+ .map(part => (part ? Number(part) : undefined));
+ const ms = Number((parts[7] ?? '0').slice(0, 3));
+ if (hour > 23 || minute > 59 || second > 59) return null;
+ /* A time in a daylight-saving gap moves forward, so only the day is read
+ back. */
+ const date = new Date(year, month - 1, day, hour, minute, second, ms);
+ const readsBack =
+ date.getFullYear() === year &&
+ date.getMonth() === month - 1 &&
+ date.getDate() === day;
+ return readsBack ? date : null;
+}
+
+/* An unreadable row cannot be placed before or after a day, so it matches none
+ of these. */
+function onDay(
+ test: (day: string, filterDay: string) => boolean
+): FilterFn {
+ return (row, columnId, filterValue: FilterValue) => {
+ const day = toDayKey(row.getValue(columnId));
+ const filterDay = toDayKey(filterValue.date);
+ return day !== null && filterDay !== null && test(day, filterDay);
+ };
+}
+
+/* A row with no readable date is still not the filter's day. */
+const notOnDay: FilterFn = (
+ row,
+ columnId,
+ filterValue: FilterValue
+) => {
+ const filterDay = toDayKey(filterValue.date);
+ return filterDay !== null && toDayKey(row.getValue(columnId)) !== filterDay;
+};
+
+export const dateFilterFns: Record<
+ DateFilterOperatorType,
+ FilterFn
+> = {
+ eq: onDay((day, filterDay) => day === filterDay),
+ neq: notOnDay,
+ lt: onDay((day, filterDay) => day < filterDay),
+ lte: onDay((day, filterDay) => day <= filterDay),
+ gt: onDay((day, filterDay) => day > filterDay),
+ gte: onDay((day, filterDay) => day >= filterDay)
+};
+
+type TypedFilter = {
+ name: string;
+ _type?: FilterTypes;
+ _dataType?: FilterValueType;
+};
+
+/* A query from the consumer carries no filter types, and a date filter without
+ one never reaches the date comparisons. Only dates are typed here, because a
+ type changes how string and number filters are sent. A date filter sends
+ its day key as a string. */
+export function withDateFilterTypes(
+ filters: Filter[],
+ fields: { accessorKey?: unknown; filterType?: FilterTypes }[]
+): Filter[] {
+ return filters.map(filter => {
+ const field = fields.find(f => f.accessorKey === filter.name);
+ if (filter._type || field?.filterType !== FilterType.date) return filter;
+ return { ...filter, _type: FilterType.date, _dataType: 'string' };
+ });
+}