diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx
index 75b0e8fa7..c11d2183e 100644
--- a/apps/www/src/content/docs/(overview)/upgrading.mdx
+++ b/apps/www/src/content/docs/(overview)/upgrading.mdx
@@ -7,6 +7,109 @@ 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.
+
+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` / `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 stops matching 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 day that does not exist, such as `2026-02-30`, used to roll
+ over to 2 March. It is now dropped.
+- 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 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 e80495c79..0963dfd80 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 a94709b86..8e9e68772 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;
@@ -46,16 +49,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/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md
index d7acbce8f..da7e0db8d 100644
--- a/packages/raystack/CHANGELOG.md
+++ b/packages/raystack/CHANGELOG.md
@@ -72,6 +72,73 @@ 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 CalendarPreview's date-fns adapter 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 an empty value
+ as today, so clearing a date filter filtered the rows to today.
+- **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 day that does not exist is dropped.** dayjs
+ rolled `2026-02-30` over to 2 March and filtered on that.
+- **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 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.**
+
+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`,
+ `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/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx
index dd9e2aecb..fe6cba67f 100644
--- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx
+++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx
@@ -56,6 +56,24 @@ describe('CalendarPreview root', () => {
error.mockRestore();
});
+ 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/raystack/components/calendar-preview/__tests__/date-adapter.test.ts b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts
index 9648b5d96..f45d165d7 100644
--- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts
+++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts
@@ -1,3 +1,5 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
@@ -15,6 +17,8 @@ import {
parseKey,
shiftMonths,
startOfMonthKey,
+ toDayKey,
+ toInstant,
yearOf
} from '../date-adapter';
@@ -243,3 +247,197 @@ describe('monthShortNames', () => {
});
});
});
+
+/* Expected values were captured from `dayjs(v)` in UTC, Asia/Kolkata and
+ America/Los_Angeles before the migration. */
+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 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 long month name', 'December 1, 2023', new Date(2023, 11, 1)],
+ ['a short month name', '1 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)],
+ ['a Date', new Date(2023, 11, 1), new Date(2023, 11, 1)],
+ ['an epoch', Date.UTC(2023, 11, 1), new Date(Date.UTC(2023, 11, 1))],
+ ['epoch zero', 0, new Date(0)],
+ [
+ 'a UTC instant',
+ '2023-12-01T10:30:00Z',
+ new Date(Date.UTC(2023, 11, 1, 10, 30))
+ ],
+ [
+ 'microseconds',
+ '2023-12-01T10:30:00.123456Z',
+ new Date(Date.UTC(2023, 11, 1, 10, 30, 0, 123))
+ ],
+ [
+ 'an offset',
+ '2023-12-01T10:30:00+05:30',
+ new Date(Date.UTC(2023, 11, 1, 5, 0))
+ ],
+ [
+ 'a negative offset',
+ '2023-11-30T20:00:00-05:00',
+ new Date(Date.UTC(2023, 11, 1, 1, 0))
+ ],
+ [
+ 'a Date string',
+ 'Fri Dec 01 2023 00:00:00 GMT+0000',
+ new Date(Date.UTC(2023, 11, 1))
+ ]
+ ])('reads %s as dayjs did', (_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],
+ ['an invalid Date', new Date(Number.NaN)],
+ ['an object', { date: '2023-12-01' }]
+ ])('rejects %s as dayjs did', (_label, input) => {
+ expect(toInstant(input)).toBeNull();
+ });
+
+ /* dayjs rolled each of these into a neighbouring day or month. */
+ it.each([
+ ['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'],
+ ['an epoch as a string', '1701388800000']
+ ])('rejects %s where dayjs rolled it over', (_label, input) => {
+ expect(toInstant(input)).toBeNull();
+ });
+
+ it.each([
+ '2023-02-30',
+ '2026-13-01',
+ '2023-02-30T00:00:00Z',
+ '2026-02-30T12:00:00+05:30'
+ ])('rejects the impossible day in %s', input => {
+ expect(toInstant(input)).toBeNull();
+ });
+
+ it('reads a real day with a zone suffix', () => {
+ expect(toInstant('2023-02-28T00:00:00Z')?.getTime()).toBe(
+ Date.UTC(2023, 1, 28)
+ );
+ });
+
+ it.each([
+ '02/30/2014',
+ '2/30/2026 10:30',
+ '2-30-2026',
+ '2 30 2026',
+ '2026 2 30',
+ 'Tue 2/30/2026',
+ 'February 30, 2026',
+ 'Feb. 30, 2026',
+ '30 Feb 2026',
+ '30-Feb-2026',
+ '2026 Feb 30',
+ 'Sep 31, 2026',
+ 'Sept 31, 2026',
+ '2023.02.30',
+ 'Tue Feb 30 2026 10:30:00 GMT+0530',
+ 'Feb 30 2026 10:00 PST',
+ 'February 30, 2026 3',
+ '2/30/2026 3'
+ ])('rejects the impossible day in %s', input => {
+ expect(toInstant(input)).toBeNull();
+ });
+
+ it.each([
+ ['02/29/2024', new Date(2024, 1, 29)],
+ ['2-28-2026', new Date(2026, 1, 28)],
+ ['Sept 30, 2026', new Date(2026, 8, 30)],
+ ['Monday, March 2, 2026', new Date(2026, 2, 2)],
+ ['Dec 2023', new Date(2023, 11, 1)],
+ ['Fri, 01 Dec 2023 00:00:00 GMT', new Date(Date.UTC(2023, 11, 1))],
+ [
+ 'Fri Dec 01 2023 02:00:00 GMT+0530',
+ new Date(Date.UTC(2023, 10, 30, 20, 30))
+ ],
+ ['Nov 30 2026 23:00 EST', new Date(Date.UTC(2026, 11, 1, 4))],
+ ['Dec 1 2026 01:00 EST', new Date(Date.UTC(2026, 11, 1, 6))]
+ ])('reads the real day in %s as dayjs did', (input, expected) => {
+ expect(toInstant(input)?.getTime()).toBe(expected.getTime());
+ });
+
+ it('keeps milliseconds from a longer fraction in a local time', () => {
+ expect(toInstant('2023/12/01 10:30:00.123456')?.getTime()).toBe(
+ new Date(2023, 11, 1, 10, 30, 0, 123).getTime()
+ );
+ });
+
+ /* dayjs read `undefined` as now, so an unset date filter matched today. */
+ it('rejects undefined', () => {
+ expect(toInstant(undefined)).toBeNull();
+ });
+
+ it('rejects a boolean, which dayjs read as epoch zero', () => {
+ expect(toInstant(true)).toBeNull();
+ });
+
+ /* dayjs rejected or misread these ISO 8601 forms. */
+ 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());
+ });
+});
+
+/* Safari before 16.4 throws on a lookbehind when the module loads. */
+it('uses no regex lookbehind', () => {
+ const source = readFileSync(resolve(__dirname, '../date-adapter.ts'), 'utf8');
+ expect(source).not.toMatch(/\(\?<[=!]/);
+});
+
+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 what toInstant rejects', () => {
+ expect(toDayKey('2023-02-30')).toBeNull();
+ expect(toDayKey(undefined)).toBeNull();
+ });
+
+ it('rejects a year outside four digits', () => {
+ expect(toDayKey(new Date(10000, 0, 1))).toBeNull();
+ });
+});
diff --git a/packages/raystack/components/calendar-preview/__tests__/parse.test.ts b/packages/raystack/components/calendar-preview/__tests__/parse.test.ts
index 1c2a55bfe..6dd4eb1f5 100644
--- a/packages/raystack/components/calendar-preview/__tests__/parse.test.ts
+++ b/packages/raystack/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/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx
index bfb4147a2..3d246aa3c 100644
--- a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx
+++ b/packages/raystack/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/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
index 54a327ea7..47ce44988 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
+++ b/packages/raystack/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,
diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts
index 9e5320e27..a37c0b0ae 100644
--- a/packages/raystack/components/calendar-preview/date-adapter.ts
+++ b/packages/raystack/components/calendar-preview/date-adapter.ts
@@ -3,11 +3,20 @@ import { TZDate } from '@date-fns/tz';
import {
addDays,
addMonths,
+ addQuarters,
+ addWeeks,
+ addYears,
endOfMonth,
format,
+ getQuarter,
isValid,
parse,
- startOfMonth
+ parseISO,
+ startOfDay,
+ startOfMonth,
+ startOfQuarter,
+ startOfWeek,
+ startOfYear
} from 'date-fns';
export type DayKey = string;
@@ -26,6 +35,146 @@ export function dayKey(date: Date, timeZone?: string): DayKey {
return key;
}
+export function toDayKey(value: unknown): DayKey | null {
+ const date = toInstant(value);
+ if (!date) return null;
+ try {
+ return dayKey(date);
+ } catch {
+ return null;
+ }
+}
+
+export function toInstant(value: unknown): Date | 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);
+ if (isValid(iso)) return iso;
+ const isoDay = ISO_DAY.exec(value);
+ if (isoDay && !isDayKey(isoDay[1])) return null;
+ const parts = LOCAL_SHAPE.exec(value);
+ if (parts) return fromLocalParts(parts);
+ const native = new Date(value);
+ return isValid(native) && writesMonthOf(value, native) ? native : null;
+}
+
+/* `new Date` rolls an impossible day over, whatever suffix follows it. */
+const ISO_DAY = /^(\d{4}-\d{2}-\d{2})/;
+
+/* A numeric offset counts only after GMT, UTC or a time, so `2-30-2026`
+ does not read as offset -20:26. The names are the zones V8 reads. The
+ prefixes are tested in code: Safari before 16.4 has no regex lookbehind. */
+const OFFSET = /([+-])(\d{2}):?(\d{2})(?!\d)/g;
+const AFTER_ZONE_NAME = /\b(?:GMT|UTC|UT)\s*$/i;
+const AFTER_TIME = /\d:\d{2}(?::\d{2}(?:\.\d+)?)?\s?$/;
+const ZONE_NAME = /\b(UTC|UT|GMT|[ECMP][SD]T)\b/gi;
+const ZULU = /\d(Z)\b/gi;
+const ZONE_OFFSETS: Record = {
+ UT: 0,
+ UTC: 0,
+ GMT: 0,
+ Z: 0,
+ EST: -300,
+ EDT: -240,
+ CST: -360,
+ CDT: -300,
+ MST: -420,
+ MDT: -360,
+ PST: -480,
+ PDT: -420
+};
+const TIME = /\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?/g;
+const MONTH_PREFIXES = 'janfebmaraprmayjunjulaugsepoctnovdec';
+
+type Zone = { start: number; end: number; offset: number };
+
+/* The leftmost zone, and the longest at that position. */
+function findZone(value: string): Zone | null {
+ const zones: Zone[] = [];
+ for (const match of value.matchAll(OFFSET)) {
+ const before = value.slice(0, match.index);
+ const name = AFTER_ZONE_NAME.exec(before);
+ if (!name && !AFTER_TIME.test(before)) continue;
+ const sign = match[1] === '-' ? -1 : 1;
+ zones.push({
+ start: name ? name.index : match.index,
+ end: match.index + match[0].length,
+ offset: sign * (Number(match[2]) * 60 + Number(match[3]))
+ });
+ }
+ for (const match of value.matchAll(ZONE_NAME)) {
+ zones.push({
+ start: match.index,
+ end: match.index + match[0].length,
+ offset: ZONE_OFFSETS[match[1].toUpperCase()]
+ });
+ }
+ for (const match of value.matchAll(ZULU)) {
+ zones.push({ start: match.index + 1, end: match.index + 2, offset: 0 });
+ }
+ zones.sort((a, b) => a.start - b.start || b.end - a.end);
+ return zones[0] ?? null;
+}
+
+/* The month a named month word writes, or `null` for none. */
+function monthOfWord(word: string): number | null {
+ if (word.length < 3) return null;
+ const at = MONTH_PREFIXES.indexOf(word.slice(0, 3).toLowerCase());
+ return at >= 0 && at % 3 === 0 ? at / 3 : null;
+}
+
+/* `new Date` rolls an impossible day into the next month, in any form it
+ reads. A result in a month the string never writes is that rollover. */
+function writesMonthOf(value: string, date: Date): boolean {
+ const zone = findZone(value);
+ const month = zone
+ ? new Date(date.getTime() + zone.offset * 60_000).getUTCMonth()
+ : date.getMonth();
+ const datePart = (
+ zone ? `${value.slice(0, zone.start)} ${value.slice(zone.end)}` : value
+ ).replace(TIME, ' ');
+ const named = (datePart.match(/[A-Za-z]+/g) ?? [])
+ .map(monthOfWord)
+ .filter(word => word !== null);
+ if (named.length > 0) return named.includes(month);
+ /* A number after the year is not a month, so `2/30/2026 3` is not March. */
+ const numbers = datePart.match(/\d+/g) ?? [];
+ const yearAt = numbers.findIndex(number => number.length >= 3);
+ const candidates =
+ yearAt === 0
+ ? numbers.slice(1, 3)
+ : yearAt > 0
+ ? numbers.slice(0, yearAt)
+ : numbers;
+ return candidates.map(Number).includes(month + 1);
+}
+
+/* The shape dayjs parsed 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));
+ const date = new Date(year, month - 1, day, hour, minute, second, ms);
+ const readsBack =
+ date.getFullYear() === year &&
+ date.getMonth() === month - 1 &&
+ date.getDate() === day &&
+ date.getHours() === hour &&
+ date.getMinutes() === minute &&
+ date.getSeconds() === second;
+ return readsBack ? date : null;
+}
+
export function isDayKey(value: string): boolean {
return DAY_KEY_SHAPE.test(value) && isValid(parseStrict(value));
}
@@ -118,6 +267,43 @@ export function formatWeekdayLabel(date: Date, timeZone?: string): string {
return format(zoned(date, timeZone), 'EEE');
}
+/* `TimelineScale` has `week` and stops at quarter, so it cannot share `lib/scale.ts`. */
+export const startOfUnit = {
+ day: startOfDay,
+ week: startOfWeek,
+ month: startOfMonth,
+ quarter: startOfQuarter,
+ year: startOfYear
+} as const;
+
+export const addUnit = {
+ day: addDays,
+ week: addWeeks,
+ month: addMonths,
+ quarter: addQuarters,
+ year: addYears
+} as const;
+
+export function formatDayOfMonth(date: Date): string {
+ return format(date, 'd');
+}
+
+export function formatMonthShort(date: Date): string {
+ return format(date, 'MMM');
+}
+
+export function formatDayMonth(date: Date): string {
+ return format(date, 'd MMM');
+}
+
+export function formatYear(date: Date): string {
+ return format(date, 'yyyy');
+}
+
+export function formatQuarterShort(date: Date): string {
+ return `Q${getQuarter(date)}`;
+}
+
/* Same locale as monthFromName, so the column and the parser cannot disagree. */
export function monthShortNames(): string[] {
return MONTH_INDEXES.map(index => format(new Date(2001, index, 1), 'MMM'));
diff --git a/packages/raystack/components/data-table/__tests__/data-table.test.tsx b/packages/raystack/components/data-table/__tests__/data-table.test.tsx
index f1b6d83d3..0c3b25a01 100644
--- a/packages/raystack/components/data-table/__tests__/data-table.test.tsx
+++ b/packages/raystack/components/data-table/__tests__/data-table.test.tsx
@@ -1,9 +1,10 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { afterEach, 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,101 @@ 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 originalTimeZone = process.env.TZ;
+ afterEach(() => {
+ process.env.TZ = originalTimeZone;
+ });
+
+ const Rows = () => {
+ const { table } = useDataTable();
+ return (
+
+ );
+ };
+
+ const renderRestored = (
+ filters: NonNullable,
+ mode: 'client' | 'server' = 'client',
+ onTableQueryChange?: (query: DataTableQuery) => void
+ ) =>
+ render(
+
+
+
+ );
+
+ it.each([
+ ['eq', 'd15'],
+ ['neq', 'd14,d16,dNone'],
+ ['lt', 'd14'],
+ ['gt', 'd16']
+ ] as const)('filters rows by a restored %s date filter', (operator, rows) => {
+ renderRestored([{ name: 'when', operator, value: '2026-08-15' }]);
+ expect(screen.getByTestId('rows')).toHaveTextContent(rows);
+ });
+
+ it('reads a restored ISO instant as the day the viewer picked', () => {
+ process.env.TZ = 'Asia/Kolkata';
+ renderRestored([
+ { name: 'when', operator: 'eq', value: '2026-08-14T18:30:00.000Z' }
+ ]);
+ 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');
+ });
+
+ 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 DataTableQuery;
+ expect(last.filters?.[0]).toEqual({
+ name: 'id',
+ operator: 'eq',
+ value: 'd15',
+ stringValue: 'd15'
+ });
+ });
+ });
});
diff --git a/packages/raystack/components/data-table/data-table.tsx b/packages/raystack/components/data-table/data-table.tsx
index 42369cc62..087203b27 100644
--- a/packages/raystack/components/data-table/data-table.tsx
+++ b/packages/raystack/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/raystack/components/data-table/utils/__tests__/filter-operations.test.tsx b/packages/raystack/components/data-table/utils/__tests__/filter-operations.test.tsx
index 79f75cb54..e744f4de2 100644
--- a/packages/raystack/components/data-table/utils/__tests__/filter-operations.test.tsx
+++ b/packages/raystack/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 {
@@ -344,6 +343,28 @@ describe('Filter Operations', () => {
expect(result).toBe(true);
});
+
+ it('should match an unreadable row with neq', () => {
+ const result = filterOperationsMap.date.neq(
+ createMockRow('not a date'),
+ 'createdAt',
+ { date: new Date(2023, 11, 1) },
+ addMeta
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('should not match an unreadable row with gt', () => {
+ const result = filterOperationsMap.date.gt(
+ createMockRow(undefined),
+ 'createdAt',
+ { date: new Date(2023, 11, 1) },
+ addMeta
+ );
+
+ expect(result).toBe(false);
+ });
});
describe('Select Filter Operations', () => {
@@ -614,15 +635,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/raystack/components/data-table/utils/__tests__/index.test.tsx b/packages/raystack/components/data-table/utils/__tests__/index.test.tsx
index 7fa39325b..f526ab4be 100644
--- a/packages/raystack/components/data-table/utils/__tests__/index.test.tsx
+++ b/packages/raystack/components/data-table/utils/__tests__/index.test.tsx
@@ -187,6 +187,23 @@ describe('Data Table Utils', () => {
expect(result.columnFilters).toHaveLength(0);
});
+ /* dayjs read `undefined` as now, so this filter was kept and matched today. */
+ 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 +708,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 +740,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/raystack/components/data-table/utils/filter-operations.tsx b/packages/raystack/components/data-table/utils/filter-operations.tsx
index 169e74d9c..9adbf165a 100644
--- a/packages/raystack/components/data-table/utils/filter-operations.tsx
+++ b/packages/raystack/components/data-table/utils/filter-operations.tsx
@@ -1,8 +1,7 @@
import type { FilterFn } from '@tanstack/table-core';
-import dayjs from 'dayjs';
-import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
-import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
+import { toDayKey } from '~/components/calendar-preview/date-adapter';
+import { notOnDay, onDay } from '~/shared/date-filters';
import {
DataTableFilterOperatorTypes,
DateFilterOperatorType,
@@ -19,9 +18,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 = {
@@ -83,42 +79,12 @@ export const filterOperationsMap: FilterFunctionsMap = {
}
},
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'
- );
- }
+ 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)
},
select: {
eq: (row, columnId, filterValue: FilterValue, _addMeta) => {
@@ -169,19 +135,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/raystack/components/data-table/utils/index.tsx b/packages/raystack/components/data-table/utils/index.tsx
index 299093d42..c1fff47f9 100644
--- a/packages/raystack/components/data-table/utils/index.tsx
+++ b/packages/raystack/components/data-table/utils/index.tsx
@@ -1,7 +1,8 @@
import type { Row, Table } from '@tanstack/react-table';
import { TableState } from '@tanstack/table-core';
-import dayjs from 'dayjs';
+import { toDayKey } from '~/components/calendar-preview/date-adapter';
+import { withDateFilterTypes } from '~/shared/date-filters';
import { FilterOperatorTypes, FilterType } from '~/types/filters';
import {
DataTableColumnDef,
@@ -25,7 +26,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 +224,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 +347,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 +359,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/raystack/components/data-view/__tests__/data-view.test.tsx b/packages/raystack/components/data-view/__tests__/data-view.test.tsx
index 9e98859d4..7ad5b9a25 100644
--- a/packages/raystack/components/data-view/__tests__/data-view.test.tsx
+++ b/packages/raystack/components/data-view/__tests__/data-view.test.tsx
@@ -7,6 +7,7 @@ import { DataView } from '../data-view';
import type {
DataViewField,
DataViewListColumn,
+ DataViewQuery,
ViewSpec
} from '../data-view.types';
import { useDataView as useDataViewForTest } from '../hooks/useDataView';
@@ -1190,4 +1191,103 @@ 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 originalTimeZone = process.env.TZ;
+ afterEach(() => {
+ process.env.TZ = originalTimeZone;
+ });
+
+ const renderRestored = (
+ filters: NonNullable,
+ mode: 'client' | 'server' = 'client',
+ onTableQueryChange?: (query: DataViewQuery) => void
+ ) =>
+ render(
+
+
+ {ctx => (
+
+ )}
+
+
+ );
+
+ it.each([
+ ['eq', 'd15'],
+ ['neq', 'd14,d16,dNone'],
+ ['lt', 'd14'],
+ ['gt', 'd16']
+ ] as const)('filters rows by a restored %s date filter', (operator, rows) => {
+ renderRestored([{ name: 'when', operator, value: '2026-08-15' }]);
+ expect(screen.getByTestId('rows')).toHaveTextContent(rows);
+ });
+
+ it('reads a restored ISO instant as the day the viewer picked', () => {
+ process.env.TZ = 'Asia/Kolkata';
+ renderRestored([
+ { name: 'when', operator: 'eq', value: '2026-08-14T18:30:00.000Z' }
+ ]);
+ 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/raystack/components/data-view/__tests__/filter-operations.test.ts b/packages/raystack/components/data-view/__tests__/filter-operations.test.ts
index 5a0d80647..22778476a 100644
--- a/packages/raystack/components/data-view/__tests__/filter-operations.test.ts
+++ b/packages/raystack/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,126 @@ 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);
+ });
+
+ /* dayjs read a missing filter date as "now", so an unset filter quietly
+ matched today's rows. */
+ 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(() => {
+ 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 the day the viewer picked, not the UTC day', () => {
+ process.env.TZ = 'Asia/Kolkata';
+ const value = new Date(2026, 7, 15);
+ const v = getFilterValue({ value, filterType: 'date' });
+ expect(v.value).toBe(value);
+ expect(v.stringValue).toBe('2026-08-15');
+ });
+
+ 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');
+ });
+});
+
+/* dayjs read `undefined` as now, so an unset date filter was kept and matched
+ today. */
+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/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx
index ee81c2ef8..1bb0a142d 100644
--- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx
+++ b/packages/raystack/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,7 @@ 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('returns null for missing or invalid values', () => {
@@ -56,42 +55,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 +98,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 +107,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 +130,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 +140,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 +155,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 +180,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 +194,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 +215,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 +230,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 +242,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 +253,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 +272,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 +634,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 +804,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 +856,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 +1777,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/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx
index 4671ba567..e5b844ac5 100644
--- a/packages/raystack/components/data-view/components/timeline.tsx
+++ b/packages/raystack/components/data-view/components/timeline.tsx
@@ -2,7 +2,6 @@
import type { Row } from '@tanstack/react-table';
import { cx } from 'class-variance-authority';
-import dayjs from 'dayjs';
import {
CSSProperties,
memo,
@@ -15,6 +14,12 @@ import {
useRef,
useState
} from 'react';
+import {
+ formatDayMonth,
+ formatMonthLabel,
+ formatQuarterShort,
+ formatYear
+} from '~/components/calendar-preview/date-adapter';
import { useScaling } from '~/hooks/useScaling';
import { Badge } from '../../badge';
import styles from '../data-view.module.css';
@@ -353,15 +358,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 formatDayMonth(date);
case 'month':
- return date.format('MMM YYYY');
+ return formatMonthLabel(date);
case 'quarter':
- return `Q${Math.floor(date.month() / 3) + 1} ${date.format('YYYY')}`;
+ return `${formatQuarterShort(date)} ${formatYear(date)}`;
}
}
@@ -925,7 +930,7 @@ export function DataViewTimeline({
key: '__today',
time: todayTime,
x: timeScale.x(todayTime),
- label: dayjs(todayTime).format('D MMM'),
+ label: formatDayMonth(new Date(todayTime)),
variant: 'accent'
});
}
@@ -936,7 +941,7 @@ export function DataViewTimeline({
key: `__marker-${index}`,
time,
x: timeScale.x(time),
- label: marker.label ?? dayjs(time).format('D MMM'),
+ label: marker.label ?? formatDayMonth(new Date(time)),
variant: marker.variant ?? 'default'
});
});
@@ -1009,7 +1014,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/raystack/components/data-view/data-view.tsx b/packages/raystack/components/data-view/data-view.tsx
index c2e419cbb..f784ab7b2 100644
--- a/packages/raystack/components/data-view/data-view.tsx
+++ b/packages/raystack/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/raystack/components/data-view/utils/filter-operations.tsx b/packages/raystack/components/data-view/utils/filter-operations.tsx
index 3268c0deb..9fd8e1897 100644
--- a/packages/raystack/components/data-view/utils/filter-operations.tsx
+++ b/packages/raystack/components/data-view/utils/filter-operations.tsx
@@ -1,8 +1,7 @@
import type { FilterFn } from '@tanstack/table-core';
-import dayjs from 'dayjs';
-import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
-import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
+import { toDayKey } from '~/components/calendar-preview/date-adapter';
+import { notOnDay, onDay } from '~/shared/date-filters';
import {
DataTableFilterOperatorTypes,
DateFilterOperatorType,
@@ -19,9 +18,6 @@ import {
} from '~/types/filters';
import { DataViewFilterValues } from '../data-view.types';
-dayjs.extend(isSameOrAfter);
-dayjs.extend(isSameOrBefore);
-
export type FilterFunctionsMap = {
number: Record>;
string: Record>;
@@ -69,24 +65,12 @@ export const filterOperationsMap: FilterFunctionsMap = {
}
},
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'
- )
+ 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)
},
select: {
eq: (row, columnId, filterValue: FilterValue) => {
@@ -131,16 +115,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/raystack/components/data-view/utils/index.tsx b/packages/raystack/components/data-view/utils/index.tsx
index 83dccd5e5..3fdd4f5e4 100644
--- a/packages/raystack/components/data-view/utils/index.tsx
+++ b/packages/raystack/components/data-view/utils/index.tsx
@@ -4,8 +4,9 @@ import {
type RowModel,
TableState
} from '@tanstack/table-core';
-import dayjs from 'dayjs';
+import { toDayKey } from '~/components/calendar-preview/date-adapter';
+import { withDateFilterTypes } from '~/shared/date-filters';
import { FilterOperatorTypes, FilterType } from '~/types/filters';
import {
DataViewField,
@@ -29,7 +30,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 +264,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 +403,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/raystack/components/data-view/utils/time-scale.tsx b/packages/raystack/components/data-view/utils/time-scale.tsx
index 680fcdef5..b2632d99a 100644
--- a/packages/raystack/components/data-view/utils/time-scale.tsx
+++ b/packages/raystack/components/data-view/utils/time-scale.tsx
@@ -1,5 +1,13 @@
-import dayjs, { type Dayjs } from 'dayjs';
-
+import {
+ addUnit,
+ formatDayOfMonth,
+ formatMonthLabel,
+ formatMonthShort,
+ formatQuarterShort,
+ formatYear,
+ startOfUnit as startOfCalendarUnit,
+ toInstant
+} from '~/components/calendar-preview/date-adapter';
import type { TimelineScale } from '../data-view.types';
/**
@@ -35,24 +43,16 @@ export function toTimestamp(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
- if (typeof value === 'string') {
- const parsed = dayjs(value);
- return parsed.isValid() ? parsed.valueOf() : null;
- }
+ if (typeof value === 'string') return toInstant(value)?.getTime() ?? null;
return 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);
+export function startOfUnit(date: Date, scale: TimelineScale): Date {
+ return startOfCalendarUnit[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 addUnit[scale](date, n);
}
export interface TimelineTimeScale {
@@ -90,26 +90,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 +137,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 formatDayOfMonth(date);
case 'month':
- return date.format('MMM');
+ return formatMonthShort(date);
case 'quarter':
- return `Q${Math.floor(date.month() / 3) + 1}`;
+ return formatQuarterShort(date);
}
}
@@ -181,13 +181,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 +198,20 @@ export function buildAxis(
const bands: TimelineBand[] = [];
const bandUnit = scale === 'day' || scale === 'week' ? 'month' : 'year';
- let band = dayjs(timeScale.t0).startOf(bandUnit);
+ let band = startOfCalendarUnit[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 = addUnit[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
+ ? formatMonthLabel(band)
+ : formatMonthShort(band)
+ : formatYear(band);
bands.push({
- time: band.valueOf(),
+ time: band.getTime(),
x: timeScale.x(from),
width: (to - from) * timeScale.pxPerMs,
label
diff --git a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx
index 40343f52f..aaf880264 100644
--- a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx
+++ b/packages/raystack/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';
@@ -126,10 +129,7 @@ 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();
@@ -144,7 +144,11 @@ describe('FilterChip', () => {
value='2026-05-27'
/>
);
- expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument();
+ expect(
+ screen.getByDisplayValue(
+ defaultFormatValue(new Date(2026, 4, 27), 'day')
+ )
+ ).toBeInTheDocument();
});
it('parses an epoch number value', () => {
@@ -156,7 +160,11 @@ describe('FilterChip', () => {
value={new Date(2026, 4, 27).getTime()}
/>
);
- expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument();
+ expect(
+ screen.getByDisplayValue(
+ defaultFormatValue(new Date(2026, 4, 27), 'day')
+ )
+ ).toBeInTheDocument();
});
it('coerces an unparseable value to unselected instead of crashing', () => {
@@ -181,21 +189,211 @@ describe('FilterChip', () => {
value={new Date(2026, 4, 27)}
/>
);
- expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument();
+ expect(
+ screen.getByDisplayValue(
+ defaultFormatValue(new Date(2026, 4, 27), 'day')
+ )
+ ).toBeInTheDocument();
+ });
+
+ 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('');
});
- 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.
+ 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(
);
- expect(screen.getByDisplayValue('27/05/2026')).toBeInTheDocument();
+ 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(
+
+ ).container.querySelectorAll('[data-slot="filter-chip-value"] svg')
+ .length;
+ expect(icons()).toBe(0);
+ expect(icons(true)).toBe(1);
+ });
+
+ it('reports a typed error through onErrorChange', () => {
+ const onErrorChange = vi.fn();
+ render(
+
+ );
+ 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('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(
+
+ );
+ fireEvent.click(
+ getSlot(document.body, 'calendar-preview-trigger') as HTMLElement
+ );
+ if (isOpen()) clickDay('12');
+ expect(onValueChange).not.toHaveBeenCalled();
});
});
diff --git a/packages/raystack/components/filter-chip/filter-chip.module.css b/packages/raystack/components/filter-chip/filter-chip.module.css
index ef210df74..8f243721f 100644
--- a/packages/raystack/components/filter-chip/filter-chip.module.css
+++ b/packages/raystack/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,13 @@ button.selectValue:hover {
box-shadow: none;
}
-/* match height of the DatePicker when FilterChip variant is text */
+/* match height of the date input 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/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx
index 7ee97b483..28c65bf93 100644
--- a/packages/raystack/components/filter-chip/filter-chip.tsx
+++ b/packages/raystack/components/filter-chip/filter-chip.tsx
@@ -1,8 +1,23 @@
'use client';
-import { cva, VariantProps } from 'class-variance-authority';
-import dayjs from 'dayjs';
-import { ComponentProps, ReactElement, useCallback, useState } from 'react';
+import { cva, cx, VariantProps } from 'class-variance-authority';
+import {
+ ComponentProps,
+ ReactElement,
+ useCallback,
+ useMemo,
+ useState
+} from 'react';
+import {
+ CalendarPreview,
+ type CalendarPreviewContentProps,
+ type CalendarPreviewInputProps,
+ type CalendarPreviewProps
+} from '~/components/calendar-preview';
+import {
+ parseKey,
+ toInstant
+} from '~/components/calendar-preview/date-adapter';
import { XIcon } from '~/icons';
import {
FilterOperation,
@@ -12,7 +27,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,30 +49,51 @@ const chip = cva(styles.chip, {
export type FilterChipValue = string | string[] | number | Date;
+/* The message `DatePicker` reports, so `onErrorChange` reads the same. */
+const INVALID_DATE = 'Invalid date';
+
/**
- * Coerce a `FilterChipValue` to the `Date` the DatePicker expects, since filter
+ * Coerce a `FilterChipValue` to the `Date` the calendar 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 toInstant(value) ?? undefined;
}
return undefined;
};
/**
- * 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'>,
@@ -73,18 +108,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.
@@ -116,6 +147,22 @@ export const FilterChip = ({
// `??` not `||`, since a falsy option value like `0` is a real selection.
const [filterValue, setFilterValue] = useState(value ?? '');
+ 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) ?? null,
+ [filterValue]
+ );
+
const showOnRemove = typeof onRemove === 'function';
const isMultiSelectColumn = columnType === FilterType.multiselect;
@@ -180,19 +227,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 : parseKey(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/raystack/shared/date-filters.ts b/packages/raystack/shared/date-filters.ts
new file mode 100644
index 000000000..5885c6820
--- /dev/null
+++ b/packages/raystack/shared/date-filters.ts
@@ -0,0 +1,54 @@
+import type { FilterFn } from '@tanstack/table-core';
+import {
+ type DayKey,
+ toDayKey
+} from '~/components/calendar-preview/date-adapter';
+import type {
+ FilterTypes,
+ FilterValue,
+ FilterValueType
+} from '~/types/filters';
+import { FilterType } from '~/types/filters';
+
+/* An unreadable row cannot be placed before or after a day, so it matches none
+ of these. */
+export function onDay(
+ test: (day: DayKey, filterDay: DayKey) => 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. */
+export const notOnDay: FilterFn = (
+ row,
+ columnId,
+ filterValue: FilterValue
+) => {
+ const filterDay = toDayKey(filterValue.date);
+ return filterDay !== null && toDayKey(row.getValue(columnId)) !== 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' };
+ });
+}