From 6186549b8971ee32269f2859397d1fe272c0ad71 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 28 Sep 2026 13:59:02 +0530 Subject: [PATCH 01/27] chore: [data-view, data-table, filter-chip] wip move dates onto date-adapter Work in progress. Audit findings are not applied yet. --- .../__tests__/date-adapter.test.ts | 144 +++++++++++++++ .../calendar-preview/__tests__/parse.test.ts | 15 ++ .../calendar-preview/date-adapter.ts | 93 +++++++++- .../__tests__/filter-operations.test.tsx | 32 +++- .../data-table/utils/__tests__/index.test.tsx | 4 +- .../data-table/utils/filter-operations.tsx | 116 ++++++------ .../components/data-table/utils/index.tsx | 6 +- .../__tests__/filter-operations.test.ts | 167 +++++++++++++++++- .../data-view/__tests__/timeline.test.tsx | 96 +++++----- .../data-view/components/timeline.tsx | 16 +- .../data-view/utils/filter-operations.tsx | 87 +++++---- .../components/data-view/utils/index.tsx | 8 +- .../components/data-view/utils/time-scale.tsx | 81 ++++----- .../__tests__/filter-chip.test.tsx | 21 +++ .../components/filter-chip/filter-chip.tsx | 5 +- packages/raystack/types/filters.tsx | 5 +- 16 files changed, 687 insertions(+), 209 deletions(-) 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..6d1eb9ab7 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -15,6 +15,8 @@ import { parseKey, shiftMonths, startOfMonthKey, + toDayKey, + toInstant, yearOf } from '../date-adapter'; @@ -243,3 +245,145 @@ 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(); + }); + + /* 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()); + }); +}); + +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.each([ + 'UTC', + 'Asia/Kolkata', + 'Pacific/Kiritimati', + 'Pacific/Niue' + ])('keeps a day key as the same day in %s', timeZone => { + expect(toDayKey('2026-08-15', timeZone)).toBe('2026-08-15'); + }); + + it('reads the day in the zone it is given', () => { + const instant = new Date(Date.UTC(2023, 11, 1, 2, 0)); + expect(toDayKey(instant, 'UTC')).toBe('2023-12-01'); + expect(toDayKey(instant, 'Pacific/Niue')).toBe('2023-11-30'); + }); + + 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/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 9e5320e27..5d3fa8919 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -3,11 +3,18 @@ import { TZDate } from '@date-fns/tz'; import { addDays, addMonths, + addWeeks, + addYears, endOfMonth, format, isValid, parse, - startOfMonth + parseISO, + startOfDay, + startOfMonth, + startOfQuarter, + startOfWeek, + startOfYear } from 'date-fns'; export type DayKey = string; @@ -26,6 +33,54 @@ export function dayKey(date: Date, timeZone?: string): DayKey { return key; } +export function toDayKey(value: unknown, timeZone?: string): DayKey | null { + if (typeof value === 'string' && isDayKey(value)) return value; + const date = toInstant(value); + if (!date) return null; + try { + return dayKey(date, timeZone); + } 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' || value.trim() === '') return null; + const iso = parseISO(value); + if (isValid(iso)) return iso; + const parts = LOCAL_SHAPE.exec(value); + if (parts) return fromLocalParts(parts); + const native = new Date(value); + return isValid(native) ? native : null; +} + +/* 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 +173,42 @@ 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, + 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 format(date, 'QQQ'); +} + /* 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/utils/__tests__/filter-operations.test.tsx b/packages/raystack/components/data-table/utils/__tests__/filter-operations.test.tsx index 79f75cb54..4c72d038a 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,30 @@ describe('Filter Operations', () => { expect(result).toBe(true); }); + + it.each([ + ['eq', '2023-12-15', true], + ['eq', '2023-11-30', false], + ['neq', '2023-12-31', false], + ['neq', '2024-01-01', true], + ['lt', '2023-11-30', true], + ['lt', '2023-12-15', false], + ['lte', '2023-12-31', true], + ['lte', '2024-01-01', false], + ['gt', '2024-01-01', true], + ['gt', '2023-12-15', false], + ['gte', '2023-12-01', true], + ['gte', '2023-11-30', false] + ] as const)('should filter %s %s against the whole month', (operator, row, expected) => { + const result = filterOperationsMap.date[operator]( + createMockRow(row), + 'createdAt', + { date: { date: '2023-12-01', scale: 'month' } }, + addMeta + ); + + expect(result).toBe(expected); + }); }); describe('Select Filter Operations', () => { @@ -614,14 +637,13 @@ describe('Filter Operations', () => { }); it('should handle date filter type', () => { - const date = new Date('2023-12-01'); const result = getFilterValue({ - value: date, + value: new Date(2023, 11, 1), filterType: FilterType.date }); expect(result).toEqual({ - value: date, - stringValue: date.toISOString() + value: '2023-12-01', + stringValue: '2023-12-01' }); }); 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..8c337bcf1 100644 --- a/packages/raystack/components/data-table/utils/__tests__/index.test.tsx +++ b/packages/raystack/components/data-table/utils/__tests__/index.test.tsx @@ -692,7 +692,7 @@ describe('Data Table Utils', () => { }); 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 +707,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..6dcd48a5f 100644 --- a/packages/raystack/components/data-table/utils/filter-operations.tsx +++ b/packages/raystack/components/data-table/utils/filter-operations.tsx @@ -1,8 +1,14 @@ import type { FilterFn } from '@tanstack/table-core'; -import dayjs from 'dayjs'; -import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; -import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; +import { + type DayKey, + toDayKey +} from '~/components/calendar-preview/date-adapter'; +import { + type Period, + periodOf, + type ScaleValue +} from '~/components/calendar-preview/lib/scale'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, @@ -19,10 +25,13 @@ 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 FilterPrimitive = + | string + | string[] + | number + | boolean + | Date + | ScaleValue; export type FilterFunctionsMap = { number: Record>; @@ -32,6 +41,38 @@ export type FilterFunctionsMap = { multiselect: Record>; }; +/* A day value filters on its day; a coarser one filters on the whole period it + names, so a month cannot compare as a single day. `trailingValue` never + reaches here: the period is derived from whichever edge was stored, so both + anchors resolve to the same span. */ +function periodFor(value: FilterValue['date']): Period | null { + if (value == null) return null; + if ( + typeof value === 'object' && + !(value instanceof Date) && + 'scale' in value + ) { + const anchor = toDayKey(value.date); + return anchor ? periodOf(anchor, value.scale) : null; + } + const day = toDayKey(value); + return day ? { start: day, end: day } : null; +} + +/* An unreadable row or filter matches nothing, and `neq` negates that, so + `whenUnreadable` keeps a bad value from matching every row instead. */ +function onPeriod( + test: (day: DayKey, period: Period) => boolean, + whenUnreadable = false +): FilterFn { + return (row, columnId, filterValue: FilterValue) => { + const period = periodFor(filterValue.date); + const day = toDayKey(row.getValue(columnId)); + if (!period || !day) return whenUnreadable; + return test(day, period); + }; +} + export const filterOperationsMap: FilterFunctionsMap = { number: { eq: (row, columnId, filterValue: FilterValue, _addMeta) => { @@ -83,42 +124,15 @@ 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: onPeriod((day, period) => day >= period.start && day <= period.end), + neq: onPeriod( + (day, period) => day < period.start || day > period.end, + true + ), + lt: onPeriod((day, period) => day < period.start), + lte: onPeriod((day, period) => day <= period.end), + gt: onPeriod((day, period) => day > period.end), + gte: onPeriod((day, period) => day >= period.start) }, select: { eq: (row, columnId, filterValue: FilterValue, _addMeta) => { @@ -169,18 +183,12 @@ 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 = ''; - } - } + const isPeriod = value && typeof value === 'object' && 'scale' in value; + const day = toDayKey(isPeriod ? value.date : value); + if (!day) return { value, stringValue: '' }; return { - value, - stringValue + value: isPeriod ? { ...value, date: day } : day, + stringValue: day }; } case FilterType.select: diff --git a/packages/raystack/components/data-table/utils/index.tsx b/packages/raystack/components/data-table/utils/index.tsx index 299093d42..c6dde2a4f 100644 --- a/packages/raystack/components/data-table/utils/index.tsx +++ b/packages/raystack/components/data-table/utils/index.tsx @@ -1,7 +1,7 @@ import type { Row, Table } from '@tanstack/react-table'; import { TableState } from '@tanstack/table-core'; -import dayjs from 'dayjs'; +import { toDayKey } from '~/components/calendar-preview/date-adapter'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataTableColumnDef, @@ -25,7 +25,7 @@ export function queryToTableState(query: InternalQuery): Partial { query.filters ?.filter(data => { if (data._type === FilterType.date) - return dayjs(data.value as string | Date).isValid(); + return toDayKey(data.value) !== null; if (data.value !== '') return true; return false; }) @@ -223,7 +223,7 @@ export function transformToDataTableQuery( ?.filter(data => { if (data._type === FilterType.select) return true; if (data._type === FilterType.date) - return dayjs(data.value as string | Date).isValid(); + return toDayKey(data.value) !== null; if (data.value !== '') return true; return false; }) 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..6dac81b28 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,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getDataType, getFilterFn, @@ -64,14 +64,13 @@ describe('filter-operations', () => { expect(v.stringValue).toBe('foo%'); }); - it('emits ISO string for valid dates', () => { - const d = new Date('2024-01-15T00:00:00Z'); + it('emits a day key for valid dates', () => { const v = getFilterValue({ - value: d, + value: new Date(2024, 0, 15, 23, 30), filterType: 'date', operator: 'eq' }); - expect(v.stringValue).toBe(d.toISOString()); + expect(v).toEqual({ value: '2024-01-15', stringValue: '2024-01-15' }); }); it('emits boolValue for boolean dataType', () => { @@ -108,3 +107,161 @@ describe('filter-operations', () => { }); }); }); + +/* A `ScaleValue` has a `.date`, so a coarse value used to parse happily and + then compare as a single day. Every operator is asserted at both scales so + that cannot ship green again. */ +describe('date filters by period', () => { + 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); + const MONTH = { date: '2026-08-01', scale: 'month' } as const; + + describe('at day scale the day itself is the period', () => { + 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); + }); + }); + + describe('at month scale the whole month is the period', () => { + it.each([ + /* The bug: a day inside the month must match `eq`, not just the 1st. */ + ['eq', '2026-08-15', true], + ['eq', '2026-08-01', true], + ['eq', '2026-08-31', true], + ['eq', '2026-07-31', false], + ['eq', '2026-09-01', false], + ['neq', '2026-08-15', false], + ['neq', '2026-09-01', true], + /* before the period start, not before its anchor */ + ['lt', '2026-07-31', true], + ['lt', '2026-08-01', false], + ['lt', '2026-08-15', false], + ['lte', '2026-08-31', true], + ['lte', '2026-09-01', false], + /* after the period end, not after its anchor */ + ['gt', '2026-09-01', true], + ['gt', '2026-08-31', false], + ['gt', '2026-08-15', false], + ['gte', '2026-08-01', true], + ['gte', '2026-07-31', false] + ] as const)('%s against %s', (operator, row, expected) => { + expect(run(operator, MONTH, row)).toBe(expected); + }); + }); + + it.each([ + ['quarter', { date: '2026-07-01', scale: 'quarter' }, '2026-09-30', true], + ['quarter', { date: '2026-07-01', scale: 'quarter' }, '2026-10-01', false], + ['halfYear', { date: '2026-01-01', scale: 'halfYear' }, '2026-06-30', true], + [ + 'halfYear', + { date: '2026-01-01', scale: 'halfYear' }, + '2026-07-01', + false + ], + ['year', { date: '2026-01-01', scale: 'year' }, '2026-12-31', true], + ['year', { date: '2026-01-01', scale: 'year' }, '2027-01-01', false] + ] as const)('spans a whole %s', (_scale, filterDate, row, expected) => { + expect(run('eq', filterDate, row)).toBe(expected); + }); + + /* The stored value is the anchor and the period is derived from it, so a + trailing-edge anchor resolves to the same span as a leading-edge one. */ + it('reaches both ends whichever edge was stored', () => { + const leading = { date: '2026-08-01', scale: 'month' } as const; + const trailing = { date: '2026-08-31', scale: 'month' } as const; + for (const row of ['2026-08-01', '2026-08-15', '2026-08-31']) { + expect(run('eq', leading, row)).toBe(true); + expect(run('eq', trailing, row)).toBe(true); + } + }); + + it('matches nothing when the row cannot be read, and neq matches it', () => { + expect(run('eq', DAY, 'not a date')).toBe(false); + expect(run('neq', DAY, 'not a date')).toBe(true); + expect(run('gt', DAY, undefined)).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'; + expect( + getFilterValue({ value: new Date(2026, 7, 15), filterType: 'date' }) + ).toEqual({ value: '2026-08-15', stringValue: '2026-08-15' }); + }); + + it('rewrites a stored ISO instant as a day key', () => { + process.env.TZ = 'Asia/Kolkata'; + expect( + getFilterValue({ value: '2026-08-14T18:30:00.000Z', filterType: 'date' }) + ).toEqual({ value: '2026-08-15', stringValue: '2026-08-15' }); + }); + + it('keeps the scale of a period value', () => { + const value = { date: '2026-08-01', scale: 'month' }; + expect(getFilterValue({ value, filterType: 'date' })).toEqual({ + value, + stringValue: '2026-08-01' + }); + }); +}); diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx index ee81c2ef8..055ee0f54 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 @@ -794,7 +790,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 +842,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 +1763,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..28a195d36 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,8 @@ import { useRef, useState } from 'react'; +import { defaultFormatValue } from '~/components/calendar-preview/calendar-preview-root'; +import { formatDayMonth } from '~/components/calendar-preview/date-adapter'; import { useScaling } from '~/hooks/useScaling'; import { Badge } from '../../badge'; import styles from '../data-view.module.css'; @@ -353,15 +354,14 @@ 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'); case 'quarter': - return `Q${Math.floor(date.month() / 3) + 1} ${date.format('YYYY')}`; + return defaultFormatValue(date, scale); } } @@ -925,7 +925,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 +936,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 +1009,7 @@ export function DataViewTimeline({ timeScale.t0, Math.min(timeScale.timeAt(canvasX), timeScale.t1) ); - const snapped = startOfUnit(dayjs(time), scale).valueOf(); + const snapped = startOfUnit(new Date(time), scale).getTime(); setCursorTime(prev => (prev === snapped ? prev : snapped)); }, [showCursorLine, timeScale, scale]); diff --git a/packages/raystack/components/data-view/utils/filter-operations.tsx b/packages/raystack/components/data-view/utils/filter-operations.tsx index 3268c0deb..9460614fc 100644 --- a/packages/raystack/components/data-view/utils/filter-operations.tsx +++ b/packages/raystack/components/data-view/utils/filter-operations.tsx @@ -1,8 +1,10 @@ import type { FilterFn } from '@tanstack/table-core'; -import dayjs from 'dayjs'; -import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; -import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; +import { + type DayKey, + toDayKey +} from '~/components/calendar-preview/date-adapter'; +import { type Period, periodOf } from '~/components/calendar-preview/lib/scale'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, @@ -19,9 +21,6 @@ import { } from '~/types/filters'; import { DataViewFilterValues } from '../data-view.types'; -dayjs.extend(isSameOrAfter); -dayjs.extend(isSameOrBefore); - export type FilterFunctionsMap = { number: Record>; string: Record>; @@ -30,6 +29,38 @@ export type FilterFunctionsMap = { multiselect: Record>; }; +/* A day value filters on its day; a coarser one filters on the whole period it + names, so a month cannot compare as a single day. `trailingValue` never + reaches here: the period is derived from whichever edge was stored, so both + anchors resolve to the same span. */ +function periodFor(value: FilterValue['date']): Period | null { + if (value == null) return null; + if ( + typeof value === 'object' && + !(value instanceof Date) && + 'scale' in value + ) { + const anchor = toDayKey(value.date); + return anchor ? periodOf(anchor, value.scale) : null; + } + const day = toDayKey(value); + return day ? { start: day, end: day } : null; +} + +/* An unreadable row or filter matches nothing, and `neq` negates that, so + `whenUnreadable` keeps a bad value from matching every row instead. */ +function onPeriod( + test: (day: DayKey, period: Period) => boolean, + whenUnreadable = false +): FilterFn { + return (row, columnId, filterValue: FilterValue) => { + const period = periodFor(filterValue.date); + const day = toDayKey(row.getValue(columnId)); + if (!period || !day) return whenUnreadable; + return test(day, period); + }; +} + export const filterOperationsMap: FilterFunctionsMap = { number: { eq: (row, columnId, filterValue: FilterValue) => @@ -69,24 +100,15 @@ 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: onPeriod((day, period) => day >= period.start && day <= period.end), + neq: onPeriod( + (day, period) => day < period.start || day > period.end, + true + ), + lt: onPeriod((day, period) => day < period.start), + lte: onPeriod((day, period) => day <= period.end), + gt: onPeriod((day, period) => day > period.end), + gte: onPeriod((day, period) => day >= period.start) }, select: { eq: (row, columnId, filterValue: FilterValue) => { @@ -131,16 +153,13 @@ 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 isPeriod = value && typeof value === 'object' && 'scale' in value; + const day = toDayKey(isPeriod ? value.date : value); + if (!day) return { value, stringValue: '' }; + return { + value: isPeriod ? { ...value, date: day } : day, + 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..b578bb022 100644 --- a/packages/raystack/components/data-view/utils/index.tsx +++ b/packages/raystack/components/data-view/utils/index.tsx @@ -4,8 +4,8 @@ import { type RowModel, TableState } from '@tanstack/table-core'; -import dayjs from 'dayjs'; +import { toDayKey } from '~/components/calendar-preview/date-adapter'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataViewField, @@ -29,7 +29,8 @@ export function queryToTableState(query: InternalQuery): Partial { const columnFilters = query.filters ?.filter(data => { - if (data._type === FilterType.date) return dayjs(data.value).isValid(); + if (data._type === FilterType.date) + return toDayKey(data.value) !== null; if (data.value !== '') return true; return false; }) @@ -262,7 +263,8 @@ export function transformToDataViewQuery(query: InternalQuery): DataViewQuery { filters ?.filter(data => { if (data._type === FilterType.select) return true; - if (data._type === FilterType.date) return dayjs(data.value).isValid(); + if (data._type === FilterType.date) + return toDayKey(data.value) !== null; if (data.value !== '') return true; return false; }) diff --git a/packages/raystack/components/data-view/utils/time-scale.tsx b/packages/raystack/components/data-view/utils/time-scale.tsx index 680fcdef5..37bbb9850 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,17 @@ 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 { + if (scale === 'quarter') return addUnit.month(date, 3 * n); + return addUnit[scale](date, n); } export interface TimelineTimeScale { @@ -90,26 +91,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 +138,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 +182,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 +199,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..a1c7f77b0 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,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; +import { defaultFormatValue } from '~/components/calendar-preview/calendar-preview-root'; import { FilterType } from '~/types/filters'; import { FilterChip } from '../filter-chip'; import styles from '../filter-chip.module.css'; @@ -197,6 +198,26 @@ describe('FilterChip', () => { ); expect(screen.getByDisplayValue('27/05/2026')).toBeInTheDocument(); }); + + /* A chip holds a day only, so day is the one scale to match. */ + it.each([ + ['a Date', new Date(2026, 7, 15)], + ['a day key', '2026-08-15'], + ['an epoch', new Date(2026, 7, 15).getTime()] + ])('displays %s as CalendarPreview does', (_label, value) => { + render( + + ); + expect( + screen.getByDisplayValue( + defaultFormatValue(new Date(2026, 7, 15), 'day') + ) + ).toBeInTheDocument(); + }); }); describe('Forwarded HTML attributes', () => { diff --git a/packages/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx index 7ee97b483..3894876cc 100644 --- a/packages/raystack/components/filter-chip/filter-chip.tsx +++ b/packages/raystack/components/filter-chip/filter-chip.tsx @@ -1,8 +1,8 @@ 'use client'; import { cva, VariantProps } from 'class-variance-authority'; -import dayjs from 'dayjs'; import { ComponentProps, ReactElement, useCallback, useState } from 'react'; +import { toInstant } from '~/components/calendar-preview/date-adapter'; import { XIcon } from '~/icons'; import { FilterOperation, @@ -43,8 +43,7 @@ export type FilterChipValue = string | string[] | number | Date; 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; }; diff --git a/packages/raystack/types/filters.tsx b/packages/raystack/types/filters.tsx index 29702fe86..9354326a2 100644 --- a/packages/raystack/types/filters.tsx +++ b/packages/raystack/types/filters.tsx @@ -1,3 +1,5 @@ +import type { ScaleValue } from '~/components/calendar-preview/lib/scale'; + export const FilterType = { number: 'number', string: 'string', @@ -13,7 +15,8 @@ export type FilterValueType = 'string' | 'number' | 'boolean'; export interface FilterValue { value?: FilterValueType; // values?: Array; - date?: Date; + /* A `ScaleValue` filters on the period it names, not on its anchor day. */ + date?: Date | ScaleValue; // dateRange?: DateRange; } From 5d597781e050f5fb80fe8eac24089faa9f31b72b Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 28 Sep 2026 23:07:18 +0530 Subject: [PATCH 02/27] chore: [data-view, data-table, filter-chip] compare dates by day and reuse date-fns Date filters compare whole days. ScaleValue filtering is out of scope, so periodFor, onPeriod and the type widenings are removed. An unreadable row matches no operator. The timeline badge uses date-adapter instead of CalendarPreviewRoot, and quarter stepping and labels use addQuarters and getQuarter. --- .../__tests__/date-adapter.test.ts | 21 +-- .../calendar-preview/date-adapter.ts | 12 +- .../__tests__/filter-operations.test.tsx | 23 +--- .../data-table/utils/__tests__/index.test.tsx | 33 +++++ .../data-table/utils/filter-operations.tsx | 68 ++-------- .../__tests__/filter-operations.test.ts | 125 ++++++------------ .../data-view/__tests__/timeline.test.tsx | 14 ++ .../data-view/components/timeline.tsx | 11 +- .../data-view/utils/filter-operations.tsx | 56 ++------ .../components/data-view/utils/time-scale.tsx | 1 - .../__tests__/filter-chip.test.tsx | 38 +++--- packages/raystack/types/filters.tsx | 5 +- 12 files changed, 156 insertions(+), 251 deletions(-) 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 6d1eb9ab7..d264f4b72 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -329,6 +329,12 @@ describe('toInstant', () => { expect(toInstant(input)).toBeNull(); }); + 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(); @@ -363,21 +369,6 @@ describe('toDayKey', () => { expect(toDayKey(new Date(2023, 11, 1, 23, 59))).toBe('2023-12-01'); }); - it.each([ - 'UTC', - 'Asia/Kolkata', - 'Pacific/Kiritimati', - 'Pacific/Niue' - ])('keeps a day key as the same day in %s', timeZone => { - expect(toDayKey('2026-08-15', timeZone)).toBe('2026-08-15'); - }); - - it('reads the day in the zone it is given', () => { - const instant = new Date(Date.UTC(2023, 11, 1, 2, 0)); - expect(toDayKey(instant, 'UTC')).toBe('2023-12-01'); - expect(toDayKey(instant, 'Pacific/Niue')).toBe('2023-11-30'); - }); - it('rejects what toInstant rejects', () => { expect(toDayKey('2023-02-30')).toBeNull(); expect(toDayKey(undefined)).toBeNull(); diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 5d3fa8919..78d5e496b 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -3,10 +3,12 @@ import { TZDate } from '@date-fns/tz'; import { addDays, addMonths, + addQuarters, addWeeks, addYears, endOfMonth, format, + getQuarter, isValid, parse, parseISO, @@ -33,12 +35,11 @@ export function dayKey(date: Date, timeZone?: string): DayKey { return key; } -export function toDayKey(value: unknown, timeZone?: string): DayKey | null { - if (typeof value === 'string' && isDayKey(value)) return value; +export function toDayKey(value: unknown): DayKey | null { const date = toInstant(value); if (!date) return null; try { - return dayKey(date, timeZone); + return dayKey(date); } catch { return null; } @@ -50,7 +51,7 @@ export function toInstant(value: unknown): Date | null { const fromEpoch = new Date(value); return isValid(fromEpoch) ? fromEpoch : null; } - if (typeof value !== 'string' || value.trim() === '') return null; + if (typeof value !== 'string') return null; const iso = parseISO(value); if (isValid(iso)) return iso; const parts = LOCAL_SHAPE.exec(value); @@ -186,6 +187,7 @@ export const addUnit = { day: addDays, week: addWeeks, month: addMonths, + quarter: addQuarters, year: addYears } as const; @@ -206,7 +208,7 @@ export function formatYear(date: Date): string { } export function formatQuarterShort(date: Date): string { - return format(date, 'QQQ'); + return `Q${getQuarter(date)}`; } /* Same locale as monthFromName, so the column and the parser cannot disagree. */ 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 4c72d038a..0e3869576 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 @@ -344,28 +344,15 @@ describe('Filter Operations', () => { expect(result).toBe(true); }); - it.each([ - ['eq', '2023-12-15', true], - ['eq', '2023-11-30', false], - ['neq', '2023-12-31', false], - ['neq', '2024-01-01', true], - ['lt', '2023-11-30', true], - ['lt', '2023-12-15', false], - ['lte', '2023-12-31', true], - ['lte', '2024-01-01', false], - ['gt', '2024-01-01', true], - ['gt', '2023-12-15', false], - ['gte', '2023-12-01', true], - ['gte', '2023-11-30', false] - ] as const)('should filter %s %s against the whole month', (operator, row, expected) => { - const result = filterOperationsMap.date[operator]( - createMockRow(row), + it('should not match an unreadable row with neq', () => { + const result = filterOperationsMap.date.neq( + createMockRow('not a date'), 'createdAt', - { date: { date: '2023-12-01', scale: 'month' } }, + { date: new Date(2023, 11, 1) }, addMeta ); - expect(result).toBe(expected); + expect(result).toBe(false); }); }); 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 8c337bcf1..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,6 +708,22 @@ 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, 11, 1); const query: InternalQuery = { diff --git a/packages/raystack/components/data-table/utils/filter-operations.tsx b/packages/raystack/components/data-table/utils/filter-operations.tsx index 6dcd48a5f..0d52ec87c 100644 --- a/packages/raystack/components/data-table/utils/filter-operations.tsx +++ b/packages/raystack/components/data-table/utils/filter-operations.tsx @@ -4,11 +4,6 @@ import { type DayKey, toDayKey } from '~/components/calendar-preview/date-adapter'; -import { - type Period, - periodOf, - type ScaleValue -} from '~/components/calendar-preview/lib/scale'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, @@ -25,13 +20,7 @@ import { } from '~/types/filters'; import { DataTableFilterValues } from '../data-table.types'; -export type FilterPrimitive = - | string - | string[] - | number - | boolean - | Date - | ScaleValue; +export type FilterPrimitive = string | string[] | number | boolean | Date; export type FilterFunctionsMap = { number: Record>; @@ -41,35 +30,14 @@ export type FilterFunctionsMap = { multiselect: Record>; }; -/* A day value filters on its day; a coarser one filters on the whole period it - names, so a month cannot compare as a single day. `trailingValue` never - reaches here: the period is derived from whichever edge was stored, so both - anchors resolve to the same span. */ -function periodFor(value: FilterValue['date']): Period | null { - if (value == null) return null; - if ( - typeof value === 'object' && - !(value instanceof Date) && - 'scale' in value - ) { - const anchor = toDayKey(value.date); - return anchor ? periodOf(anchor, value.scale) : null; - } - const day = toDayKey(value); - return day ? { start: day, end: day } : null; -} - -/* An unreadable row or filter matches nothing, and `neq` negates that, so - `whenUnreadable` keeps a bad value from matching every row instead. */ -function onPeriod( - test: (day: DayKey, period: Period) => boolean, - whenUnreadable = false +/* An unreadable row matches no operator, `neq` included. */ +function onDay( + test: (day: DayKey, filterDay: DayKey) => boolean ): FilterFn { return (row, columnId, filterValue: FilterValue) => { - const period = periodFor(filterValue.date); const day = toDayKey(row.getValue(columnId)); - if (!period || !day) return whenUnreadable; - return test(day, period); + const filterDay = toDayKey(filterValue.date); + return day !== null && filterDay !== null && test(day, filterDay); }; } @@ -124,15 +92,12 @@ export const filterOperationsMap: FilterFunctionsMap = { } }, date: { - eq: onPeriod((day, period) => day >= period.start && day <= period.end), - neq: onPeriod( - (day, period) => day < period.start || day > period.end, - true - ), - lt: onPeriod((day, period) => day < period.start), - lte: onPeriod((day, period) => day <= period.end), - gt: onPeriod((day, period) => day > period.end), - gte: onPeriod((day, period) => day >= period.start) + eq: onDay((day, filterDay) => day === filterDay), + neq: onDay((day, filterDay) => day !== filterDay), + 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) => { @@ -183,13 +148,8 @@ const handleStringBasedTypes = ( ): DataTableFilterValues => { switch (filterType) { case FilterType.date: { - const isPeriod = value && typeof value === 'object' && 'scale' in value; - const day = toDayKey(isPeriod ? value.date : value); - if (!day) return { value, stringValue: '' }; - return { - value: isPeriod ? { ...value, date: day } : day, - stringValue: day - }; + const day = toDayKey(value); + return { value: day ?? value, stringValue: day ?? '' }; } case FilterType.select: return { 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 6dac81b28..8e508dd84 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 { afterEach, describe, expect, it, vi } from 'vitest'; +import { queryToTableState, transformToDataViewQuery } from '../utils'; import { getDataType, getFilterFn, @@ -108,10 +109,7 @@ describe('filter-operations', () => { }); }); -/* A `ScaleValue` has a `.date`, so a coarse value used to parse happily and - then compare as a single day. Every operator is asserted at both scales so - that cannot ship green again. */ -describe('date filters by period', () => { +describe('date filters', () => { const run = ( operator: 'eq' | 'neq' | 'lt' | 'lte' | 'gt' | 'gte', filterDate: unknown, @@ -123,85 +121,34 @@ describe('date filters by period', () => { }; const DAY = new Date(2026, 7, 15); - const MONTH = { date: '2026-08-01', scale: 'month' } as const; - - describe('at day scale the day itself is the period', () => { - 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); - }); - }); - - describe('at month scale the whole month is the period', () => { - it.each([ - /* The bug: a day inside the month must match `eq`, not just the 1st. */ - ['eq', '2026-08-15', true], - ['eq', '2026-08-01', true], - ['eq', '2026-08-31', true], - ['eq', '2026-07-31', false], - ['eq', '2026-09-01', false], - ['neq', '2026-08-15', false], - ['neq', '2026-09-01', true], - /* before the period start, not before its anchor */ - ['lt', '2026-07-31', true], - ['lt', '2026-08-01', false], - ['lt', '2026-08-15', false], - ['lte', '2026-08-31', true], - ['lte', '2026-09-01', false], - /* after the period end, not after its anchor */ - ['gt', '2026-09-01', true], - ['gt', '2026-08-31', false], - ['gt', '2026-08-15', false], - ['gte', '2026-08-01', true], - ['gte', '2026-07-31', false] - ] as const)('%s against %s', (operator, row, expected) => { - expect(run(operator, MONTH, row)).toBe(expected); - }); - }); it.each([ - ['quarter', { date: '2026-07-01', scale: 'quarter' }, '2026-09-30', true], - ['quarter', { date: '2026-07-01', scale: 'quarter' }, '2026-10-01', false], - ['halfYear', { date: '2026-01-01', scale: 'halfYear' }, '2026-06-30', true], - [ - 'halfYear', - { date: '2026-01-01', scale: 'halfYear' }, - '2026-07-01', - false - ], - ['year', { date: '2026-01-01', scale: 'year' }, '2026-12-31', true], - ['year', { date: '2026-01-01', scale: 'year' }, '2027-01-01', false] - ] as const)('spans a whole %s', (_scale, filterDate, row, expected) => { - expect(run('eq', filterDate, row)).toBe(expected); - }); - - /* The stored value is the anchor and the period is derived from it, so a - trailing-edge anchor resolves to the same span as a leading-edge one. */ - it('reaches both ends whichever edge was stored', () => { - const leading = { date: '2026-08-01', scale: 'month' } as const; - const trailing = { date: '2026-08-31', scale: 'month' } as const; - for (const row of ['2026-08-01', '2026-08-15', '2026-08-31']) { - expect(run('eq', leading, row)).toBe(true); - expect(run('eq', trailing, row)).toBe(true); - } + ['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('matches nothing when the row cannot be read, and neq matches it', () => { - expect(run('eq', DAY, 'not a date')).toBe(false); - expect(run('neq', DAY, 'not a date')).toBe(true); - expect(run('gt', DAY, undefined)).toBe(false); + it.each([ + 'eq', + 'neq', + '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); }); /* dayjs read a missing filter date as "now", so an unset filter quietly @@ -256,12 +203,20 @@ describe('stored date filters', () => { getFilterValue({ value: '2026-08-14T18:30:00.000Z', filterType: 'date' }) ).toEqual({ value: '2026-08-15', stringValue: '2026-08-15' }); }); +}); - it('keeps the scale of a period value', () => { - const value = { date: '2026-08-01', scale: 'month' }; - expect(getFilterValue({ value, filterType: 'date' })).toEqual({ - value, - stringValue: '2026-08-01' - }); +/* 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 055ee0f54..1bb0a142d 100644 --- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -634,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; diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx index 28a195d36..e5b844ac5 100644 --- a/packages/raystack/components/data-view/components/timeline.tsx +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -14,8 +14,12 @@ import { useRef, useState } from 'react'; -import { defaultFormatValue } from '~/components/calendar-preview/calendar-preview-root'; -import { formatDayMonth } from '~/components/calendar-preview/date-adapter'; +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'; @@ -360,8 +364,9 @@ function cursorLabel(time: number, scale: TimelineScale): string { case 'week': return formatDayMonth(date); case 'month': + return formatMonthLabel(date); case 'quarter': - return defaultFormatValue(date, scale); + return `${formatQuarterShort(date)} ${formatYear(date)}`; } } diff --git a/packages/raystack/components/data-view/utils/filter-operations.tsx b/packages/raystack/components/data-view/utils/filter-operations.tsx index 9460614fc..22ae8418a 100644 --- a/packages/raystack/components/data-view/utils/filter-operations.tsx +++ b/packages/raystack/components/data-view/utils/filter-operations.tsx @@ -4,7 +4,6 @@ import { type DayKey, toDayKey } from '~/components/calendar-preview/date-adapter'; -import { type Period, periodOf } from '~/components/calendar-preview/lib/scale'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, @@ -29,35 +28,14 @@ export type FilterFunctionsMap = { multiselect: Record>; }; -/* A day value filters on its day; a coarser one filters on the whole period it - names, so a month cannot compare as a single day. `trailingValue` never - reaches here: the period is derived from whichever edge was stored, so both - anchors resolve to the same span. */ -function periodFor(value: FilterValue['date']): Period | null { - if (value == null) return null; - if ( - typeof value === 'object' && - !(value instanceof Date) && - 'scale' in value - ) { - const anchor = toDayKey(value.date); - return anchor ? periodOf(anchor, value.scale) : null; - } - const day = toDayKey(value); - return day ? { start: day, end: day } : null; -} - -/* An unreadable row or filter matches nothing, and `neq` negates that, so - `whenUnreadable` keeps a bad value from matching every row instead. */ -function onPeriod( - test: (day: DayKey, period: Period) => boolean, - whenUnreadable = false +/* An unreadable row matches no operator, `neq` included. */ +function onDay( + test: (day: DayKey, filterDay: DayKey) => boolean ): FilterFn { return (row, columnId, filterValue: FilterValue) => { - const period = periodFor(filterValue.date); const day = toDayKey(row.getValue(columnId)); - if (!period || !day) return whenUnreadable; - return test(day, period); + const filterDay = toDayKey(filterValue.date); + return day !== null && filterDay !== null && test(day, filterDay); }; } @@ -100,15 +78,12 @@ export const filterOperationsMap: FilterFunctionsMap = { } }, date: { - eq: onPeriod((day, period) => day >= period.start && day <= period.end), - neq: onPeriod( - (day, period) => day < period.start || day > period.end, - true - ), - lt: onPeriod((day, period) => day < period.start), - lte: onPeriod((day, period) => day <= period.end), - gt: onPeriod((day, period) => day > period.end), - gte: onPeriod((day, period) => day >= period.start) + eq: onDay((day, filterDay) => day === filterDay), + neq: onDay((day, filterDay) => day !== filterDay), + 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) => { @@ -153,13 +128,8 @@ const handleStringBasedTypes = ( ): DataViewFilterValues => { switch (filterType) { case FilterType.date: { - const isPeriod = value && typeof value === 'object' && 'scale' in value; - const day = toDayKey(isPeriod ? value.date : value); - if (!day) return { value, stringValue: '' }; - return { - value: isPeriod ? { ...value, date: day } : day, - stringValue: day - }; + const day = toDayKey(value); + return { value: day ?? value, stringValue: day ?? '' }; } case FilterType.select: return { diff --git a/packages/raystack/components/data-view/utils/time-scale.tsx b/packages/raystack/components/data-view/utils/time-scale.tsx index 37bbb9850..b2632d99a 100644 --- a/packages/raystack/components/data-view/utils/time-scale.tsx +++ b/packages/raystack/components/data-view/utils/time-scale.tsx @@ -52,7 +52,6 @@ export function startOfUnit(date: Date, scale: TimelineScale): Date { } export function addUnits(date: Date, scale: TimelineScale, n: number): Date { - if (scale === 'quarter') return addUnit.month(date, 3 * n); return addUnit[scale](date, n); } 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 a1c7f77b0..ead34de07 100644 --- a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx @@ -145,7 +145,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', () => { @@ -157,7 +161,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', () => { @@ -182,7 +190,11 @@ 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 underlying DatePicker', () => { @@ -198,26 +210,6 @@ describe('FilterChip', () => { ); expect(screen.getByDisplayValue('27/05/2026')).toBeInTheDocument(); }); - - /* A chip holds a day only, so day is the one scale to match. */ - it.each([ - ['a Date', new Date(2026, 7, 15)], - ['a day key', '2026-08-15'], - ['an epoch', new Date(2026, 7, 15).getTime()] - ])('displays %s as CalendarPreview does', (_label, value) => { - render( - - ); - expect( - screen.getByDisplayValue( - defaultFormatValue(new Date(2026, 7, 15), 'day') - ) - ).toBeInTheDocument(); - }); }); describe('Forwarded HTML attributes', () => { diff --git a/packages/raystack/types/filters.tsx b/packages/raystack/types/filters.tsx index 9354326a2..29702fe86 100644 --- a/packages/raystack/types/filters.tsx +++ b/packages/raystack/types/filters.tsx @@ -1,5 +1,3 @@ -import type { ScaleValue } from '~/components/calendar-preview/lib/scale'; - export const FilterType = { number: 'number', string: 'string', @@ -15,8 +13,7 @@ export type FilterValueType = 'string' | 'number' | 'boolean'; export interface FilterValue { value?: FilterValueType; // values?: Array; - /* A `ScaleValue` filters on the period it names, not on its anchor day. */ - date?: Date | ScaleValue; + date?: Date; // dateRange?: DateRange; } From a05872cb89fbe0e8b7642afd39b9839d6efcdc7e Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 28 Sep 2026 23:43:50 +0530 Subject: [PATCH 03/27] fix: [data-view, data-table] keep filter values and reject impossible iso days getFilterValue passes value through and writes only stringValue as a day key. neq matches a row with an unreadable date when the filter date is readable. toInstant rejects a string whose leading ISO day does not exist, so a zone suffix cannot send it to the native parser to roll over. --- .../__tests__/date-adapter.test.ts | 15 +++++++ .../calendar-preview/date-adapter.ts | 5 +++ .../__tests__/filter-operations.test.tsx | 22 ++++++++--- .../data-table/utils/filter-operations.tsx | 17 ++++++-- .../__tests__/filter-operations.test.ts | 39 ++++++++++++------- .../data-view/utils/filter-operations.tsx | 17 ++++++-- 6 files changed, 88 insertions(+), 27 deletions(-) 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 d264f4b72..6e678f3b1 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -329,6 +329,21 @@ describe('toInstant', () => { 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('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() diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 78d5e496b..0b33fc459 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -54,12 +54,17 @@ export function toInstant(value: unknown): Date | 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) ? native : null; } +/* `new Date` rolls an impossible day over, whatever suffix follows it. */ +const ISO_DAY = /^(\d{4}-\d{2}-\d{2})/; + /* 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 = 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 0e3869576..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 @@ -344,7 +344,7 @@ describe('Filter Operations', () => { expect(result).toBe(true); }); - it('should not match an unreadable row with neq', () => { + it('should match an unreadable row with neq', () => { const result = filterOperationsMap.date.neq( createMockRow('not a date'), 'createdAt', @@ -352,6 +352,17 @@ describe('Filter Operations', () => { 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); }); }); @@ -624,14 +635,13 @@ describe('Filter Operations', () => { }); it('should handle date filter type', () => { + const date = new Date(2023, 11, 1); const result = getFilterValue({ - value: new Date(2023, 11, 1), + value: date, filterType: FilterType.date }); - expect(result).toEqual({ - value: '2023-12-01', - stringValue: '2023-12-01' - }); + 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/filter-operations.tsx b/packages/raystack/components/data-table/utils/filter-operations.tsx index 0d52ec87c..ae478cf45 100644 --- a/packages/raystack/components/data-table/utils/filter-operations.tsx +++ b/packages/raystack/components/data-table/utils/filter-operations.tsx @@ -30,7 +30,8 @@ export type FilterFunctionsMap = { multiselect: Record>; }; -/* An unreadable row matches no operator, `neq` included. */ +/* An unreadable row cannot be placed before or after a day, so it matches none + of these. */ function onDay( test: (day: DayKey, filterDay: DayKey) => boolean ): FilterFn { @@ -41,6 +42,16 @@ function onDay( }; } +/* A row with no readable date is still not the filter's day. */ +const notOnDay: FilterFn = ( + row, + columnId, + filterValue: FilterValue +) => { + const filterDay = toDayKey(filterValue.date); + return filterDay !== null && toDayKey(row.getValue(columnId)) !== filterDay; +}; + export const filterOperationsMap: FilterFunctionsMap = { number: { eq: (row, columnId, filterValue: FilterValue, _addMeta) => { @@ -93,7 +104,7 @@ export const filterOperationsMap: FilterFunctionsMap = { }, date: { eq: onDay((day, filterDay) => day === filterDay), - neq: 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), @@ -149,7 +160,7 @@ const handleStringBasedTypes = ( switch (filterType) { case FilterType.date: { const day = toDayKey(value); - return { value: day ?? value, stringValue: day ?? '' }; + return { value, stringValue: day ?? '' }; } case FilterType.select: return { 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 8e508dd84..22778476a 100644 --- a/packages/raystack/components/data-view/__tests__/filter-operations.test.ts +++ b/packages/raystack/components/data-view/__tests__/filter-operations.test.ts @@ -65,13 +65,11 @@ describe('filter-operations', () => { expect(v.stringValue).toBe('foo%'); }); - it('emits a day key for valid dates', () => { - const v = getFilterValue({ - value: new Date(2024, 0, 15, 23, 30), - filterType: 'date', - operator: 'eq' - }); - expect(v).toEqual({ value: '2024-01-15', stringValue: '2024-01-15' }); + 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', () => { @@ -141,7 +139,6 @@ describe('date filters', () => { it.each([ 'eq', - 'neq', 'lt', 'lte', 'gt', @@ -151,6 +148,16 @@ describe('date filters', () => { 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', () => { @@ -192,16 +199,18 @@ describe('stored date filters', () => { it('writes the day the viewer picked, not the UTC day', () => { process.env.TZ = 'Asia/Kolkata'; - expect( - getFilterValue({ value: new Date(2026, 7, 15), filterType: 'date' }) - ).toEqual({ value: '2026-08-15', stringValue: '2026-08-15' }); + 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('rewrites a stored ISO instant as a day key', () => { + it('writes a stored ISO instant as its day and keeps the value', () => { process.env.TZ = 'Asia/Kolkata'; - expect( - getFilterValue({ value: '2026-08-14T18:30:00.000Z', filterType: 'date' }) - ).toEqual({ value: '2026-08-15', stringValue: '2026-08-15' }); + 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'); }); }); diff --git a/packages/raystack/components/data-view/utils/filter-operations.tsx b/packages/raystack/components/data-view/utils/filter-operations.tsx index 22ae8418a..62983d14b 100644 --- a/packages/raystack/components/data-view/utils/filter-operations.tsx +++ b/packages/raystack/components/data-view/utils/filter-operations.tsx @@ -28,7 +28,8 @@ export type FilterFunctionsMap = { multiselect: Record>; }; -/* An unreadable row matches no operator, `neq` included. */ +/* An unreadable row cannot be placed before or after a day, so it matches none + of these. */ function onDay( test: (day: DayKey, filterDay: DayKey) => boolean ): FilterFn { @@ -39,6 +40,16 @@ function onDay( }; } +/* A row with no readable date is still not the filter's day. */ +const notOnDay: FilterFn = ( + row, + columnId, + filterValue: FilterValue +) => { + const filterDay = toDayKey(filterValue.date); + return filterDay !== null && toDayKey(row.getValue(columnId)) !== filterDay; +}; + export const filterOperationsMap: FilterFunctionsMap = { number: { eq: (row, columnId, filterValue: FilterValue) => @@ -79,7 +90,7 @@ export const filterOperationsMap: FilterFunctionsMap = { }, date: { eq: onDay((day, filterDay) => day === filterDay), - neq: 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), @@ -129,7 +140,7 @@ const handleStringBasedTypes = ( switch (filterType) { case FilterType.date: { const day = toDayKey(value); - return { value: day ?? value, stringValue: day ?? '' }; + return { value, stringValue: day ?? '' }; } case FilterType.select: return { From da945ae833505560c2167d5292a502acd4a0331d Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 29 Sep 2026 00:42:35 +0530 Subject: [PATCH 04/27] chore: [changelog] note the date filter behaviour changes --- packages/raystack/CHANGELOG.md | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index d7acbce8f..e296369b1 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -72,6 +72,41 @@ 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 holding a day that does not exist is dropped.** dayjs + rolled `2026-02-30` over to 2 March and filtered on that. +- **`gt` and `gte` no longer match a row whose date is missing.** dayjs + read it as today, so the row matched whenever today was after the + filter day. +- **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, and there is no migration. They keep the old behaviour until they +are re-saved. + ## 0.49.0 ### Calendar / DatePicker / RangePicker improvements (PR #819) From c8dc2d9e15edd547d7cf288aee7af5d3a7c73e49 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 29 Sep 2026 01:10:41 +0530 Subject: [PATCH 05/27] fix: [calendar-preview] reject an impossible day in every date form new Date rolls an impossible day into the next month in any form it reads. toInstant rejects a fallback result whose month the string never writes, reading the month in the zone the string names. --- .../__tests__/date-adapter.test.ts | 38 +++++++++++++++ .../calendar-preview/date-adapter.ts | 46 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) 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 6e678f3b1..f81a5e842 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -344,6 +344,44 @@ describe('toInstant', () => { ); }); + 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' + ])('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() diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 0b33fc459..389251edb 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -59,12 +59,56 @@ export function toInstant(value: unknown): Date | null { const parts = LOCAL_SHAPE.exec(value); if (parts) return fromLocalParts(parts); const native = new Date(value); - return isValid(native) ? native : null; + 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. */ +const ZONE = + /(?:\b(?:GMT|UTC|UT)\s*|(?<=\d:\d{2}(?::\d{2}(?:\.\d+)?)?\s?))([+-])(\d{2}):?(\d{2})(?!\d)|\b(UTC|UT|GMT|[ECMP][SD]T)\b|(?<=\d)(Z)\b/i; +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'; + +/* `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 = ZONE.exec(value); + let month = date.getMonth(); + if (zone) { + const offset = zone[1] + ? (zone[1] === '-' ? -1 : 1) * (Number(zone[2]) * 60 + Number(zone[3])) + : ZONE_OFFSETS[(zone[4] ?? zone[5]).toUpperCase()]; + month = new Date(date.getTime() + offset * 60_000).getUTCMonth(); + } + const datePart = value.replace(ZONE, ' ').replace(TIME, ' '); + const numbers = (datePart.match(/\d+/g) ?? []).map(Number); + const words = datePart.match(/[A-Za-z]+/g) ?? []; + return ( + numbers.includes(month + 1) || + words.some( + word => + MONTH_PREFIXES.indexOf(word.slice(0, 3).toLowerCase()) === month * 3 + ) + ); +} + /* 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 = From d162f0796781f9554df6f38bf4ddb98a451bd2c4 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 29 Sep 2026 02:04:51 +0530 Subject: [PATCH 06/27] fix: [data-view, data-table] filter a restored date filter by its date A filter from the query prop has no type, so a date filter never reached the date comparisons. Loading a query now marks a filter as a date filter when its column or field has filterType date. Other filter types are left untyped, because a type changes how they are sent. --- packages/raystack/CHANGELOG.md | 7 +- .../data-table/__tests__/data-table.test.tsx | 102 +++++++++++++++++- .../components/data-table/data-table.tsx | 4 +- .../components/data-table/utils/index.tsx | 33 +++++- .../data-view/__tests__/data-view.test.tsx | 100 +++++++++++++++++ .../components/data-view/data-view.tsx | 4 +- .../components/data-view/utils/index.tsx | 27 ++++- 7 files changed, 265 insertions(+), 12 deletions(-) diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index e296369b1..8b2c657a3 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -91,6 +91,9 @@ no longer stands in for another day. `dayjs` stays a dependency, because 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 it never reached the date + comparisons, and dayjs filtered it to today instead. - **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. - **`gt` and `gte` no longer match a row whose date is missing.** dayjs @@ -104,8 +107,8 @@ no longer stands in for another day. `dayjs` stays a dependency, because - **`neq` still matches a row with a missing or unreadable date.** Saved filters and URL parameters that hold an ISO timestamp are still -read, and there is no migration. They keep the old behaviour until they -are re-saved. +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. ## 0.49.0 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 ( + + {table + .getRowModel() + .rows.map(row => (row.original as Dated).id) + .join(',')} + + ); + }; + + 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/index.tsx b/packages/raystack/components/data-table/utils/index.tsx index c6dde2a4f..e8795e17f 100644 --- a/packages/raystack/components/data-table/utils/index.tsx +++ b/packages/raystack/components/data-table/utils/index.tsx @@ -15,6 +15,7 @@ import { } from '../data-table.types'; import { type FilterPrimitive, + getDataType, getFilterFn, getFilterOperator, getFilterValue @@ -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,30 @@ export function getDefaultTableQuery( return { sort: defaultSort ? [defaultSort] : [], group_by: [defaultGroupOption.id], - ...internalQuery + ...internalQuery, + ...(internalQuery.filters && { + filters: withDateFilterTypes(internalQuery.filters, columns) + }) }; } + +/* 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. */ +function withDateFilterTypes( + filters: InternalFilter[], + columns: Pick< + DataTableColumnDef, + 'accessorKey' | 'filterType' + >[] +): InternalFilter[] { + return filters.map(filter => { + const column = columns.find(c => c.accessorKey === filter.name); + if (filter._type || column?.filterType !== FilterType.date) return filter; + return { + ...filter, + _type: FilterType.date, + _dataType: getDataType({ filterType: FilterType.date }) + }; + }); +} 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 => ( + + {ctx.table + .getRowModel() + .rows.map(row => (row.original as Dated).id) + .join(',')} + + )} + + + ); + + 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/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/index.tsx b/packages/raystack/components/data-view/utils/index.tsx index b578bb022..918b67ab4 100644 --- a/packages/raystack/components/data-view/utils/index.tsx +++ b/packages/raystack/components/data-view/utils/index.tsx @@ -19,6 +19,7 @@ import { SortOrders } from '../data-view.types'; import { + getDataType, getFilterFn, getFilterOperator, getFilterValue @@ -402,12 +403,34 @@ 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) + }) }; } + +/* 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. */ +function withDateFilterTypes( + filters: InternalFilter[], + fields: Pick[] +): InternalFilter[] { + 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: getDataType({ filterType: FilterType.date }) + }; + }); +} From 5b6db2be1c5212923e51ad8ac7b7e49077296636 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 1 Oct 2026 12:48:02 +0530 Subject: [PATCH 07/27] feat: [filter-chip] render CalendarPreview for date filters FilterChip renders CalendarPreview instead of DatePicker for columnType="date", so DataView and DataTable date filters use it too. No internal component renders Calendar, DatePicker or RangePicker. A cleared date emits ''. CalendarPreview reads its starting month and scale once, so a controlled value that starts empty no longer warns. The upgrade guide and CHANGELOG describe the date filter changes. BREAKING CHANGE: FilterChipCalendarProps, used by calendarProps and DataTable's filterProps.calendar, takes CalendarPreview props (formatValue, timeZone, minDate, maxDate, isDateUnavailable, yearRange, defaultMonth, today). The DatePicker props dateFormat, slotProps, inputProps, popoverProps, showCalendarIcon and onErrorChange are gone. --- .../src/content/docs/(overview)/upgrading.mdx | 91 +++++++++++++++++++ .../docs/components/filter-chip/demo.ts | 6 +- .../docs/components/filter-chip/index.mdx | 2 +- .../docs/components/filter-chip/props.ts | 16 ++-- packages/raystack/CHANGELOG.md | 18 ++++ .../__tests__/calendar-preview.test.tsx | 18 ++++ .../calendar-preview-root.tsx | 29 ++++-- .../__tests__/filter-chip.test.tsx | 48 ++++++++-- .../components/filter-chip/filter-chip.tsx | 60 ++++++------ 9 files changed, 229 insertions(+), 59 deletions(-) diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index 75b0e8fa7..06084cab6 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -7,6 +7,97 @@ 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. + +### 2. Move `calendarProps` to CalendarPreview props + +`FilterChip`'s `calendarProps` and `DataTable`'s `filterProps.calendar` take +CalendarPreview props now. + +| Removed | Replacement | +|---|---| +| `dateFormat` | `formatValue`, a function from the date to its label | +| `timeZone` | `timeZone`, unchanged | +| `slotProps.calendar` / `calendarProps` | `minDate`, `maxDate`, `isDateUnavailable`, `defaultMonth`, `yearRange` | +| `slotProps.input` / `inputProps` | None | +| `slotProps.popover` / `popoverProps` | None | +| `showCalendarIcon` | None. The chip shows no icon | +| `onErrorChange` | None | + +```tsx +// Before + + +// After + date.toLocaleDateString("en-CA"), + 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 `''`, and `DataView` and `DataTable` drop +the filter. If you render `FilterChip` yourself, handle `''`. + +### 4. Expect different rows from some filters + +These filters used to match by mistake, and now match fewer rows: + +- 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. +- `gt` and `gte` used to match a row with no date whenever today was after the + filter day. They no longer match it. +- 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. + ## 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..e63be58ef 100644 --- a/apps/www/src/content/docs/components/filter-chip/demo.ts +++ b/apps/www/src/content/docs/components/filter-chip/demo.ts @@ -129,10 +129,8 @@ export const calendarPropsDemo = { leadingIcon={} columnType="date" calendarProps={{ - dateFormat: "YYYY-MM-DD", - slotProps: { - calendar: { captionLayout: "dropdown" } - } + formatValue: date => date.toLocaleDateString('en-CA'), + maxDate: new Date() }} />` }; 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..c5f96fb9c 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 `formatValue`, `timeZone`, `minDate` and `maxDate` to the chip's date control. `FilterChip` owns the value and the calendar parts. 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..63fca2b95 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -46,16 +46,16 @@ 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 forwarded to the `CalendarPreview` for `columnType="date"`. Refer to CalendarPreview for each prop. */ calendarProps?: { - dateFormat?: string; - showCalendarIcon?: boolean; + formatValue?: (value: Date, scale: 'day', timeZone?: string) => string; timeZone?: string; - slotProps?: { - input?: Record; - calendar?: Record; - popover?: Record; - }; + minDate?: Date; + maxDate?: Date; + isDateUnavailable?: (date: Date) => boolean; + yearRange?: { from: number; to: number }; + defaultMonth?: Date; + today?: Date; }; /** Additional CSS class names */ diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index 8b2c657a3..2361b2369 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -110,6 +110,24 @@ 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`. `dateFormat`, `slotProps`, + `inputProps`, `popoverProps`, `showCalendarIcon` and `onErrorChange` are + gone. Replace `dateFormat` with `formatValue`, and move calendar options + such as `disabled` days to `minDate`, `maxDate` or `isDateUnavailable`. + 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/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 54a327ea7..cfa1e3f6b 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -230,14 +230,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 +254,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' }); 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 ead34de07..a79548c34 100644 --- a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx @@ -127,10 +127,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(); @@ -197,18 +194,51 @@ describe('FilterChip', () => { ).toBeInTheDocument(); }); - it('forwards calendarProps to the underlying DatePicker', () => { - // dateFormat is the easiest forwarded prop to observe, since the formatted - // string in the input changes when it lands on DatePicker. + it('forwards calendarProps to the calendar', () => { render( 'custom label' }} /> ); - expect(screen.getByDisplayValue('27/05/2026')).toBeInTheDocument(); + expect(screen.getByDisplayValue('custom label')).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(''); }); }); diff --git a/packages/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx index 3894876cc..5025df806 100644 --- a/packages/raystack/components/filter-chip/filter-chip.tsx +++ b/packages/raystack/components/filter-chip/filter-chip.tsx @@ -2,6 +2,10 @@ import { cva, VariantProps } from 'class-variance-authority'; import { ComponentProps, ReactElement, useCallback, useState } from 'react'; +import { + CalendarPreview, + type CalendarPreviewProps +} from '~/components/calendar-preview'; import { toInstant } from '~/components/calendar-preview/date-adapter'; import { XIcon } from '~/icons'; import { @@ -12,7 +16,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'; @@ -36,7 +39,7 @@ const chip = cva(styles.chip, { export type FilterChipValue = string | string[] | number | 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. */ @@ -49,14 +52,19 @@ const toDateValue = (value: unknown): Date | 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, + | 'formatValue' + | 'timeZone' + | 'minDate' + | 'maxDate' + | 'isDateUnavailable' + | 'yearRange' + | 'defaultMonth' + | 'today' >; export interface FilterChipProps @@ -72,18 +80,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. @@ -179,19 +183,21 @@ export const FilterChip = ({ className={styles.dateFieldWrapper} data-slot='filter-chip-value' > - handleFilterValueChange(date)} - slotProps={{ - ...calendarProps?.slotProps, - input: { - classNames: { container: styles.dateField }, - ...calendarProps?.slotProps?.input - } - }} - /> + value={toDateValue(filterValue) ?? null} + onValueChange={date => handleFilterValueChange(date ?? '')} + > + + + + + + + ); default: From 8b6f98d741eedd12033a0d3f70a90172292bb996 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 1 Oct 2026 12:48:08 +0530 Subject: [PATCH 08/27] chore: [filter-chip] target the calendar-preview input in chip styles --- .../raystack/components/filter-chip/filter-chip.module.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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); From 48fa1ea5eec83604dca6cdd3b81171398722a867 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 1 Oct 2026 15:41:43 +0530 Subject: [PATCH 09/27] feat: [filter-chip] restore slotProps, showCalendarIcon and onErrorChange on date filters calendarProps takes slotProps.input and slotProps.popover for the date input and its popup, showCalendarIcon for the input icon, and onErrorChange for typed-date errors. formatValue takes the date and the time zone. The chip closes the popup when a day is picked. --- .../__tests__/filter-chip.test.tsx | 117 +++++++++++++++++- .../components/filter-chip/filter-chip.tsx | 80 ++++++++++-- 2 files changed, 185 insertions(+), 12 deletions(-) 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 a79548c34..c0831ae65 100644 --- a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx @@ -1,6 +1,7 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/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'; @@ -200,10 +201,13 @@ describe('FilterChip', () => { label='Created' columnType={FilterType.date} value={new Date(2026, 4, 27)} - calendarProps={{ formatValue: () => 'custom label' }} + calendarProps={{ + formatValue: (date, timeZone) => + `${date.getDate()} ${timeZone ?? 'local'}` + }} /> ); - expect(screen.getByDisplayValue('custom label')).toBeInTheDocument(); + expect(screen.getByDisplayValue('27 local')).toBeInTheDocument(); }); it('emits the typed date', () => { @@ -240,6 +244,113 @@ describe('FilterChip', () => { expect(onValueChange).toHaveBeenCalledWith('', expect.any(String)); expect(input).toHaveValue(''); }); + + const isOpen = () => + getSlot(document.body, 'calendar-preview-content') !== null; + const clickDay = (day: string) => { + const cell = getAllSlots(document.body, 'calendar-preview-day').find( + one => + getSlot(one, 'calendar-preview-day-number')?.textContent === day && + !one.hasAttribute('data-outside') + ) as HTMLElement; + fireEvent.pointerDown(cell); + act(() => cell.focus()); + fireEvent.click(cell); + }; + + it('closes the calendar when a day is picked', async () => { + const onValueChange = vi.fn(); + render( + + ); + const input = screen.getByPlaceholderText('Select date'); + act(() => input.focus()); + expect(isOpen()).toBe(true); + clickDay('12'); + expect(onValueChange).toHaveBeenCalledWith( + new Date(2026, 4, 12), + expect.any(String) + ); + await act(() => new Promise(resolve => setTimeout(resolve, 100))); + expect(isOpen()).toBe(false); + }); + + it('keeps the calendar open when the picked day is cleared', () => { + render( + + ); + fireEvent.focus(screen.getByPlaceholderText('Select date')); + clickDay('27'); + expect(isOpen()).toBe(true); + }); + + it('forwards slotProps to the input and the popup', () => { + const { container } = render( + + ); + const input = screen.getByPlaceholderText('Pick a day'); + expect(container.querySelector('.custom-input')).toHaveClass( + styles.dateField + ); + fireEvent.focus(input); + expect( + getSlot(document.body, 'calendar-preview-content')?.querySelector( + '.custom-popup' + ) + ).toBeInTheDocument(); + }); + + it('shows the calendar icon only with showCalendarIcon', () => { + const icons = (showCalendarIcon?: boolean) => + render( + + ).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); + }); }); describe('Forwarded HTML attributes', () => { diff --git a/packages/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx index 5025df806..23546eb13 100644 --- a/packages/raystack/components/filter-chip/filter-chip.tsx +++ b/packages/raystack/components/filter-chip/filter-chip.tsx @@ -1,12 +1,17 @@ 'use client'; -import { cva, VariantProps } from 'class-variance-authority'; +import { cva, cx, VariantProps } from 'class-variance-authority'; import { ComponentProps, ReactElement, useCallback, useState } from 'react'; import { CalendarPreview, + type CalendarPreviewContentProps, + type CalendarPreviewInputProps, type CalendarPreviewProps } from '~/components/calendar-preview'; -import { toInstant } from '~/components/calendar-preview/date-adapter'; +import { + parseKey, + toInstant +} from '~/components/calendar-preview/date-adapter'; import { XIcon } from '~/icons'; import { FilterOperation, @@ -38,6 +43,9 @@ 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 calendar expects, since filter * state hydrated from a serialized query arrives as a string or epoch number. @@ -57,7 +65,6 @@ const toDateValue = (value: unknown): Date | undefined => { */ export type FilterChipCalendarProps = Pick< CalendarPreviewProps, - | 'formatValue' | 'timeZone' | 'minDate' | 'maxDate' @@ -65,7 +72,22 @@ export type FilterChipCalendarProps = Pick< | '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'>, @@ -119,6 +141,17 @@ 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 ?? {}; + const showOnRemove = typeof onRemove === 'function'; const isMultiSelectColumn = columnType === FilterType.multiselect; @@ -184,17 +217,46 @@ export const FilterChip = ({ data-slot='filter-chip-value' > + formatDate( + date instanceof Date ? date : parseKey(date.date), + timeZone + )) + } value={toDateValue(filterValue) ?? null} - onValueChange={date => handleFilterValueChange(date ?? '')} + onValueChange={date => { + handleFilterValueChange(date ?? ''); + if (date) setDateOpen(false); + }} + open={dateOpen} + onOpenChange={setDateOpen} > { + inputProps.onValidityChange?.(validity); + onErrorChange?.( + validity.valid ? undefined : validity.message + ); + }} /> - + From b6b64bd0df43cce0dc2a227d007cb891d36ea8c3 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 1 Oct 2026 15:39:39 +0530 Subject: [PATCH 10/27] chore: [filter-chip] document the calendarProps formatValue and slot types formatValue takes the date and the time zone. The chip is day-only, so it passes no scale. slotProps uses the CalendarPreview.Input and Popover.Content prop types. --- .../docs/components/filter-chip/index.mdx | 2 +- .../docs/components/filter-chip/props.ts | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) 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 c5f96fb9c..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 [CalendarPreview](/docs/components/calendar-preview) props such as `formatValue`, `timeZone`, `minDate` and `maxDate` to the chip's date control. `FilterChip` owns the value and the calendar parts. +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 63fca2b95..f783561d2 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,9 +49,10 @@ export interface FilterChipProps { defaultSearchValue?: string; }; - /** Props forwarded to the `CalendarPreview` for `columnType="date"`. Refer to CalendarPreview for each prop. */ + /** Props for the date control at `columnType="date"`. `timeZone` through `today` are CalendarPreview props. */ calendarProps?: { - formatValue?: (value: Date, scale: 'day', timeZone?: string) => string; + /** Formats the selected date for the input. The chip calls it with a `Date` and the `timeZone`. */ + formatValue?: (date: Date, timeZone?: string) => string; timeZone?: string; minDate?: Date; maxDate?: Date; @@ -56,6 +60,18 @@ export interface FilterChipProps { yearRange?: { from: number; to: number }; defaultMonth?: Date; today?: Date; + /** Props for the date input (`CalendarPreview.Input`) and its popup (`CalendarPreview.Content`). */ + 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; }; /** Additional CSS class names */ From 5d27fa3ca088834c59dfc02bab7783ceb51fc060 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 1 Oct 2026 15:54:59 +0530 Subject: [PATCH 11/27] fix: [calendar-preview] keep the popup shut when a controlled open closes it A controlled `open` can close without the root's setOpen. Base UI then returns focus to the input, and the focus guard reopened the popup. The root now runs the same close steps when `open` turns false from outside. --- .../__tests__/picker.test.tsx | 42 +++++++++++++++++++ .../calendar-preview-root.tsx | 23 +++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx index bfb4147a2..6319108f5 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,47 @@ 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('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 cfa1e3f6b..250dcbb39 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, @@ -383,9 +390,11 @@ 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; @@ -398,6 +407,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, From cfcb1c28e0ff15b72b860c44bed79c3d4da6842a Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 1 Oct 2026 15:57:24 +0530 Subject: [PATCH 12/27] chore: [changelog] update the date filter and calendarProps notes The notes list slotProps, showCalendarIcon and onErrorChange as kept, give formatValue as (date, timeZone), and describe how a row with a missing, numeric or boolean date matches. --- .../src/content/docs/(overview)/upgrading.mdx | 32 +++++++++++-------- packages/raystack/CHANGELOG.md | 29 +++++++++++------ 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index 06084cab6..8fb8b9edf 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -43,17 +43,18 @@ day that instant falls on in the viewer's time zone. ### 2. Move `calendarProps` to CalendarPreview props `FilterChip`'s `calendarProps` and `DataTable`'s `filterProps.calendar` take -CalendarPreview props now. +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`, a function from the date to its label | -| `timeZone` | `timeZone`, unchanged | +| `dateFormat` | `formatValue(date, timeZone)`, which returns the label | | `slotProps.calendar` / `calendarProps` | `minDate`, `maxDate`, `isDateUnavailable`, `defaultMonth`, `yearRange` | -| `slotProps.input` / `inputProps` | None | -| `slotProps.popover` / `popoverProps` | None | -| `showCalendarIcon` | None. The chip shows no icon | -| `onErrorChange` | None | +| `inputProps` | `slotProps.input` | +| `popoverProps` | `slotProps.popover` | ```tsx // Before @@ -69,7 +70,8 @@ CalendarPreview props now. date.toLocaleDateString("en-CA"), + formatValue: (date, timeZone) => + date.toLocaleDateString("en-CA", { timeZone }), maxDate: new Date() }} /> @@ -81,22 +83,26 @@ 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 `''`, and `DataView` and `DataTable` drop -the filter. If you render `FilterChip` yourself, handle `''`. +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 fewer rows: +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. -- `gt` and `gte` used to match a row with no date whenever today was after the - filter day. They no longer match it. +- 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 diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index 2361b2369..da7e0db8d 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -92,13 +92,17 @@ no longer stands in for another day. `dayjs` stays a dependency, because - **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 it never reached the date - comparisons, and dayjs filtered it to today instead. + 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. -- **`gt` and `gte` no longer match a row whose date is missing.** dayjs - read it as today, so the row matched whenever today was after the - filter day. +- **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` @@ -120,10 +124,17 @@ Apsara renders `Calendar`, `DatePicker` or `RangePicker` now. - **`calendarProps` takes CalendarPreview props.** `FilterChipCalendarProps` is `formatValue`, `timeZone`, `minDate`, `maxDate`, `isDateUnavailable`, - `yearRange`, `defaultMonth` and `today`. `dateFormat`, `slotProps`, - `inputProps`, `popoverProps`, `showCalendarIcon` and `onErrorChange` are - gone. Replace `dateFormat` with `formatValue`, and move calendar options - such as `disabled` days to `minDate`, `maxDate` or `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 `''`. From cb0ef109afca3d4ca81dbd28bc266fb83c702336 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 1 Oct 2026 15:58:49 +0530 Subject: [PATCH 13/27] chore: [filter-chip] split the calendarProps demo into max date and custom format tabs --- .../docs/components/filter-chip/demo.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 e63be58ef..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,16 +123,33 @@ export const autocompleteDemo = { }; export const calendarPropsDemo = { type: 'code', - code: ` + tabs: [ + { + name: 'Max date', + code: ` } columnType="date" calendarProps={{ - formatValue: date => date.toLocaleDateString('en-CA'), maxDate: new Date() }} />` + }, + { + name: 'Custom format', + code: ` +} + columnType="date" + calendarProps={{ + formatValue: (date, timeZone) => + date.toLocaleDateString('en-CA', { timeZone }) + }} +/>` + } + ] }; export const iconDemo = { type: 'code', From 23e6a41ed902fdccbef1d27a5180652add64a06c Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 1 Oct 2026 16:40:10 +0530 Subject: [PATCH 14/27] fix: [calendar-preview, data-view, data-table] load on safari 15 - date-adapter.ts finds a date's zone without regex lookbehind, which Safari before 16.4 cannot parse, so the module failed to load there. - A string with a month name is checked against that name only, and a number after the year is not read as a month. `February 30, 2026 3` is rejected. - CalendarPreview clears its close mark after the event, so a controlled parent that ignores a close and closes later still runs the cleanup. - onDay, notOnDay and withDateFilterTypes live in shared/date-filters.ts for both DataView and DataTable. - The docs say date filters use the viewer's time zone, so a different calendarProps.timeZone can shift the filter day. --- .../src/content/docs/(overview)/upgrading.mdx | 6 ++ .../docs/components/filter-chip/props.ts | 1 + .../__tests__/date-adapter.test.ts | 12 ++- .../__tests__/picker.test.tsx | 41 +++++++++ .../calendar-preview-root.tsx | 4 + .../calendar-preview/date-adapter.ts | 86 ++++++++++++++----- .../data-table/utils/filter-operations.tsx | 28 +----- .../components/data-table/utils/index.tsx | 23 +---- .../data-view/utils/filter-operations.tsx | 28 +----- .../components/data-view/utils/index.tsx | 20 +---- packages/raystack/shared/date-filters.ts | 54 ++++++++++++ 11 files changed, 188 insertions(+), 115 deletions(-) create mode 100644 packages/raystack/shared/date-filters.ts diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index 8fb8b9edf..c11d2183e 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -40,6 +40,12 @@ 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 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 f783561d2..6944867b5 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -53,6 +53,7 @@ export interface FilterChipProps { calendarProps?: { /** 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; 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 f81a5e842..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 { @@ -360,7 +362,9 @@ describe('toInstant', () => { 'Sept 31, 2026', '2023.02.30', 'Tue Feb 30 2026 10:30:00 GMT+0530', - 'Feb 30 2026 10:00 PST' + '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(); }); @@ -416,6 +420,12 @@ describe('toInstant', () => { }); }); +/* 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'); diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx index 6319108f5..3d246aa3c 100644 --- a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx @@ -131,6 +131,47 @@ describe('CalendarPreview picker composition', () => { 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 250dcbb39..47ce44988 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -400,6 +400,10 @@ export function CalendarPreviewRoot({ 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); diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 389251edb..a37c0b0ae 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -66,9 +66,13 @@ export function toInstant(value: unknown): Date | null { 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. */ -const ZONE = - /(?:\b(?:GMT|UTC|UT)\s*|(?<=\d:\d{2}(?::\d{2}(?:\.\d+)?)?\s?))([+-])(\d{2}):?(\d{2})(?!\d)|\b(UTC|UT|GMT|[ECMP][SD]T)\b|(?<=\d)(Z)\b/i; + 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, @@ -86,27 +90,67 @@ const ZONE_OFFSETS: Record = { 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 = ZONE.exec(value); - let month = date.getMonth(); - if (zone) { - const offset = zone[1] - ? (zone[1] === '-' ? -1 : 1) * (Number(zone[2]) * 60 + Number(zone[3])) - : ZONE_OFFSETS[(zone[4] ?? zone[5]).toUpperCase()]; - month = new Date(date.getTime() + offset * 60_000).getUTCMonth(); - } - const datePart = value.replace(ZONE, ' ').replace(TIME, ' '); - const numbers = (datePart.match(/\d+/g) ?? []).map(Number); - const words = datePart.match(/[A-Za-z]+/g) ?? []; - return ( - numbers.includes(month + 1) || - words.some( - word => - MONTH_PREFIXES.indexOf(word.slice(0, 3).toLowerCase()) === month * 3 - ) - ); + 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 diff --git a/packages/raystack/components/data-table/utils/filter-operations.tsx b/packages/raystack/components/data-table/utils/filter-operations.tsx index ae478cf45..9adbf165a 100644 --- a/packages/raystack/components/data-table/utils/filter-operations.tsx +++ b/packages/raystack/components/data-table/utils/filter-operations.tsx @@ -1,9 +1,7 @@ import type { FilterFn } from '@tanstack/table-core'; -import { - type DayKey, - toDayKey -} from '~/components/calendar-preview/date-adapter'; +import { toDayKey } from '~/components/calendar-preview/date-adapter'; +import { notOnDay, onDay } from '~/shared/date-filters'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, @@ -30,28 +28,6 @@ export type FilterFunctionsMap = { multiselect: Record>; }; -/* An unreadable row cannot be placed before or after a day, so it matches none - of these. */ -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. */ -const notOnDay: FilterFn = ( - row, - columnId, - filterValue: FilterValue -) => { - const filterDay = toDayKey(filterValue.date); - return filterDay !== null && toDayKey(row.getValue(columnId)) !== filterDay; -}; - export const filterOperationsMap: FilterFunctionsMap = { number: { eq: (row, columnId, filterValue: FilterValue, _addMeta) => { diff --git a/packages/raystack/components/data-table/utils/index.tsx b/packages/raystack/components/data-table/utils/index.tsx index e8795e17f..c1fff47f9 100644 --- a/packages/raystack/components/data-table/utils/index.tsx +++ b/packages/raystack/components/data-table/utils/index.tsx @@ -2,6 +2,7 @@ import type { Row, Table } from '@tanstack/react-table'; import { TableState } from '@tanstack/table-core'; import { toDayKey } from '~/components/calendar-preview/date-adapter'; +import { withDateFilterTypes } from '~/shared/date-filters'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataTableColumnDef, @@ -15,7 +16,6 @@ import { } from '../data-table.types'; import { type FilterPrimitive, - getDataType, getFilterFn, getFilterOperator, getFilterValue @@ -365,24 +365,3 @@ export function getDefaultTableQuery( }) }; } - -/* 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. */ -function withDateFilterTypes( - filters: InternalFilter[], - columns: Pick< - DataTableColumnDef, - 'accessorKey' | 'filterType' - >[] -): InternalFilter[] { - return filters.map(filter => { - const column = columns.find(c => c.accessorKey === filter.name); - if (filter._type || column?.filterType !== FilterType.date) return filter; - return { - ...filter, - _type: FilterType.date, - _dataType: getDataType({ filterType: FilterType.date }) - }; - }); -} diff --git a/packages/raystack/components/data-view/utils/filter-operations.tsx b/packages/raystack/components/data-view/utils/filter-operations.tsx index 62983d14b..9fd8e1897 100644 --- a/packages/raystack/components/data-view/utils/filter-operations.tsx +++ b/packages/raystack/components/data-view/utils/filter-operations.tsx @@ -1,9 +1,7 @@ import type { FilterFn } from '@tanstack/table-core'; -import { - type DayKey, - toDayKey -} from '~/components/calendar-preview/date-adapter'; +import { toDayKey } from '~/components/calendar-preview/date-adapter'; +import { notOnDay, onDay } from '~/shared/date-filters'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, @@ -28,28 +26,6 @@ export type FilterFunctionsMap = { multiselect: Record>; }; -/* An unreadable row cannot be placed before or after a day, so it matches none - of these. */ -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. */ -const notOnDay: FilterFn = ( - row, - columnId, - filterValue: FilterValue -) => { - const filterDay = toDayKey(filterValue.date); - return filterDay !== null && toDayKey(row.getValue(columnId)) !== filterDay; -}; - export const filterOperationsMap: FilterFunctionsMap = { number: { eq: (row, columnId, filterValue: FilterValue) => diff --git a/packages/raystack/components/data-view/utils/index.tsx b/packages/raystack/components/data-view/utils/index.tsx index 918b67ab4..3fdd4f5e4 100644 --- a/packages/raystack/components/data-view/utils/index.tsx +++ b/packages/raystack/components/data-view/utils/index.tsx @@ -6,6 +6,7 @@ import { } from '@tanstack/table-core'; import { toDayKey } from '~/components/calendar-preview/date-adapter'; +import { withDateFilterTypes } from '~/shared/date-filters'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataViewField, @@ -19,7 +20,6 @@ import { SortOrders } from '../data-view.types'; import { - getDataType, getFilterFn, getFilterOperator, getFilterValue @@ -416,21 +416,3 @@ export function getDefaultTableQuery( }) }; } - -/* 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. */ -function withDateFilterTypes( - filters: InternalFilter[], - fields: Pick[] -): InternalFilter[] { - 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: getDataType({ filterType: FilterType.date }) - }; - }); -} 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' }; + }); +} From 9002ba9283e0789cfe9ebd512d44d83aeb6da183 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 1 Oct 2026 16:54:52 +0530 Subject: [PATCH 15/27] fix: [filter-chip] keep the date value stable and pass disabled to the calendar FilterChip memoizes the date it converts from a string or epoch value. A new Date each render made the input drop typed text and its error when onErrorChange rerendered the parent. slotProps.input.disabled and readOnly also go to the CalendarPreview root, so the day grid cannot change the value of a disabled input. --- .../docs/components/filter-chip/props.ts | 2 +- .../__tests__/filter-chip.test.tsx | 44 +++++++++++++++++++ .../components/filter-chip/filter-chip.tsx | 17 ++++++- 3 files changed, 60 insertions(+), 3 deletions(-) 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 6944867b5..8e9e68772 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -61,7 +61,7 @@ export interface FilterChipProps { yearRange?: { from: number; to: number }; defaultMonth?: Date; today?: Date; - /** Props for the date input (`CalendarPreview.Input`) and its popup (`CalendarPreview.Content`). */ + /** Props for the date input (`CalendarPreview.Input`) and its popup (`CalendarPreview.Content`). `input.disabled` and `input.readOnly` also apply to the calendar. */ slotProps?: { input?: Omit; popover?: Omit; 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 c0831ae65..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,4 +1,5 @@ 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'; @@ -351,6 +352,49 @@ describe('FilterChip', () => { 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(); + }); }); describe('Forwarded HTML attributes', () => { diff --git a/packages/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx index 23546eb13..28c65bf93 100644 --- a/packages/raystack/components/filter-chip/filter-chip.tsx +++ b/packages/raystack/components/filter-chip/filter-chip.tsx @@ -1,7 +1,13 @@ 'use client'; import { cva, cx, VariantProps } from 'class-variance-authority'; -import { ComponentProps, ReactElement, useCallback, useState } from 'react'; +import { + ComponentProps, + ReactElement, + useCallback, + useMemo, + useState +} from 'react'; import { CalendarPreview, type CalendarPreviewContentProps, @@ -151,6 +157,11 @@ export const FilterChip = ({ } = 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; @@ -226,7 +237,9 @@ export const FilterChip = ({ timeZone )) } - value={toDateValue(filterValue) ?? null} + disabled={inputProps.disabled} + readOnly={inputProps.readOnly} + value={dateValue} onValueChange={date => { handleFilterValueChange(date ?? ''); if (date) setDateOpen(false); From 5381203538f17644a11d9ced5920d640ef01ac75 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 5 Oct 2026 14:25:12 +0530 Subject: [PATCH 16/27] refactor: [calendar-preview, data-view, filter-chip] keep date-adapter for the calendar only Move toInstant and toDayKey to shared/date-filters.ts and call date-fns there. Delete the unit and format wrappers from date-adapter.ts, so it matches main again. Timeline and time-scale call date-fns directly. FilterChip reads a ScaleValue with parseISO instead of parseKey. --- packages/raystack/CHANGELOG.md | 8 +- .../__tests__/date-adapter.test.ts | 198 ------------------ .../calendar-preview/date-adapter.ts | 188 +---------------- .../data-table/utils/filter-operations.tsx | 3 +- .../components/data-table/utils/index.tsx | 3 +- .../data-view/components/timeline.tsx | 17 +- .../data-view/utils/filter-operations.tsx | 3 +- .../components/data-view/utils/index.tsx | 3 +- .../components/data-view/utils/time-scale.tsx | 58 +++-- .../components/filter-chip/filter-chip.tsx | 8 +- .../shared/__tests__/date-filters.test.ts | 198 ++++++++++++++++++ packages/raystack/shared/date-filters.ts | 152 +++++++++++++- 12 files changed, 402 insertions(+), 437 deletions(-) create mode 100644 packages/raystack/shared/__tests__/date-filters.test.ts diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index da7e0db8d..9af707d04 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -75,10 +75,10 @@ and [Icons](https://apsara.raystack.io/docs/theme/icons). ### 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. +through date-fns instead of dayjs. A date filter compares whole days, +and a date that is missing or does not exist no longer stands in for +another day. `dayjs` stays a dependency, because `Calendar`, +`DatePicker` and `RangePicker` still import it. #### Breaking changes 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 f45d165d7..9648b5d96 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { @@ -17,8 +15,6 @@ import { parseKey, shiftMonths, startOfMonthKey, - toDayKey, - toInstant, yearOf } from '../date-adapter'; @@ -247,197 +243,3 @@ 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/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index a37c0b0ae..9e5320e27 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -3,20 +3,11 @@ import { TZDate } from '@date-fns/tz'; import { addDays, addMonths, - addQuarters, - addWeeks, - addYears, endOfMonth, format, - getQuarter, isValid, parse, - parseISO, - startOfDay, - startOfMonth, - startOfQuarter, - startOfWeek, - startOfYear + startOfMonth } from 'date-fns'; export type DayKey = string; @@ -35,146 +26,6 @@ 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)); } @@ -267,43 +118,6 @@ 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/utils/filter-operations.tsx b/packages/raystack/components/data-table/utils/filter-operations.tsx index 9adbf165a..9c0d460e1 100644 --- a/packages/raystack/components/data-table/utils/filter-operations.tsx +++ b/packages/raystack/components/data-table/utils/filter-operations.tsx @@ -1,7 +1,6 @@ import type { FilterFn } from '@tanstack/table-core'; -import { toDayKey } from '~/components/calendar-preview/date-adapter'; -import { notOnDay, onDay } from '~/shared/date-filters'; +import { notOnDay, onDay, toDayKey } from '~/shared/date-filters'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, diff --git a/packages/raystack/components/data-table/utils/index.tsx b/packages/raystack/components/data-table/utils/index.tsx index c1fff47f9..cf971319c 100644 --- a/packages/raystack/components/data-table/utils/index.tsx +++ b/packages/raystack/components/data-table/utils/index.tsx @@ -1,8 +1,7 @@ import type { Row, Table } from '@tanstack/react-table'; import { TableState } from '@tanstack/table-core'; -import { toDayKey } from '~/components/calendar-preview/date-adapter'; -import { withDateFilterTypes } from '~/shared/date-filters'; +import { toDayKey, withDateFilterTypes } from '~/shared/date-filters'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataTableColumnDef, diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx index e5b844ac5..f51f0042c 100644 --- a/packages/raystack/components/data-view/components/timeline.tsx +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -2,6 +2,7 @@ import type { Row } from '@tanstack/react-table'; import { cx } from 'class-variance-authority'; +import { format } from 'date-fns'; import { CSSProperties, memo, @@ -14,12 +15,6 @@ 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'; @@ -362,11 +357,11 @@ function cursorLabel(time: number, scale: TimelineScale): string { switch (scale) { case 'day': case 'week': - return formatDayMonth(date); + return format(date, 'd MMM'); case 'month': - return formatMonthLabel(date); + return format(date, 'MMM yyyy'); case 'quarter': - return `${formatQuarterShort(date)} ${formatYear(date)}`; + return format(date, 'QQQ yyyy'); } } @@ -930,7 +925,7 @@ export function DataViewTimeline({ key: '__today', time: todayTime, x: timeScale.x(todayTime), - label: formatDayMonth(new Date(todayTime)), + label: format(todayTime, 'd MMM'), variant: 'accent' }); } @@ -941,7 +936,7 @@ export function DataViewTimeline({ key: `__marker-${index}`, time, x: timeScale.x(time), - label: marker.label ?? formatDayMonth(new Date(time)), + label: marker.label ?? format(time, 'd MMM'), variant: marker.variant ?? 'default' }); }); diff --git a/packages/raystack/components/data-view/utils/filter-operations.tsx b/packages/raystack/components/data-view/utils/filter-operations.tsx index 9fd8e1897..79a6fc679 100644 --- a/packages/raystack/components/data-view/utils/filter-operations.tsx +++ b/packages/raystack/components/data-view/utils/filter-operations.tsx @@ -1,7 +1,6 @@ import type { FilterFn } from '@tanstack/table-core'; -import { toDayKey } from '~/components/calendar-preview/date-adapter'; -import { notOnDay, onDay } from '~/shared/date-filters'; +import { notOnDay, onDay, toDayKey } from '~/shared/date-filters'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, diff --git a/packages/raystack/components/data-view/utils/index.tsx b/packages/raystack/components/data-view/utils/index.tsx index 3fdd4f5e4..4cacae8c5 100644 --- a/packages/raystack/components/data-view/utils/index.tsx +++ b/packages/raystack/components/data-view/utils/index.tsx @@ -5,8 +5,7 @@ import { TableState } from '@tanstack/table-core'; -import { toDayKey } from '~/components/calendar-preview/date-adapter'; -import { withDateFilterTypes } from '~/shared/date-filters'; +import { toDayKey, withDateFilterTypes } from '~/shared/date-filters'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataViewField, diff --git a/packages/raystack/components/data-view/utils/time-scale.tsx b/packages/raystack/components/data-view/utils/time-scale.tsx index b2632d99a..b443e93f9 100644 --- a/packages/raystack/components/data-view/utils/time-scale.tsx +++ b/packages/raystack/components/data-view/utils/time-scale.tsx @@ -1,13 +1,17 @@ import { - addUnit, - formatDayOfMonth, - formatMonthLabel, - formatMonthShort, - formatQuarterShort, - formatYear, - startOfUnit as startOfCalendarUnit, - toInstant -} from '~/components/calendar-preview/date-adapter'; + addDays, + addMonths, + addQuarters, + addWeeks, + addYears, + format, + startOfDay, + startOfMonth, + startOfQuarter, + startOfWeek, + startOfYear +} from 'date-fns'; +import { toInstant } from '~/shared/date-filters'; import type { TimelineScale } from '../data-view.types'; /** @@ -47,12 +51,28 @@ export function toTimestamp(value: unknown): number | null { return null; } +const START_OF = { + day: startOfDay, + week: startOfWeek, + month: startOfMonth, + quarter: startOfQuarter, + year: startOfYear +}; + +const ADD = { + day: addDays, + week: addWeeks, + month: addMonths, + quarter: addQuarters, + year: addYears +}; + export function startOfUnit(date: Date, scale: TimelineScale): Date { - return startOfCalendarUnit[scale](date); + return START_OF[scale](date); } export function addUnits(date: Date, scale: TimelineScale, n: number): Date { - return addUnit[scale](date, n); + return ADD[scale](date, n); } export interface TimelineTimeScale { @@ -141,11 +161,11 @@ function tickLabel(date: Date, scale: TimelineScale): string { switch (scale) { case 'day': case 'week': - return formatDayOfMonth(date); + return format(date, 'd'); case 'month': - return formatMonthShort(date); + return format(date, 'MMM'); case 'quarter': - return formatQuarterShort(date); + return format(date, 'QQQ'); } } @@ -198,18 +218,18 @@ export function buildAxis( const bands: TimelineBand[] = []; const bandUnit = scale === 'day' || scale === 'week' ? 'month' : 'year'; - let band = startOfCalendarUnit[bandUnit](new Date(timeScale.t0)); + let band = START_OF[bandUnit](new Date(timeScale.t0)); let isFirst = true; while (band.getTime() < timeScale.t1) { - const next = addUnit[bandUnit](band, 1); + const next = ADD[bandUnit](band, 1); const from = Math.max(band.getTime(), timeScale.t0); const to = Math.min(next.getTime(), timeScale.t1); const label = bandUnit === 'month' ? isFirst || band.getMonth() === 0 - ? formatMonthLabel(band) - : formatMonthShort(band) - : formatYear(band); + ? format(band, 'MMM yyyy') + : format(band, 'MMM') + : format(band, 'yyyy'); bands.push({ time: band.getTime(), x: timeScale.x(from), diff --git a/packages/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx index 18e9f64f5..af3b15617 100644 --- a/packages/raystack/components/filter-chip/filter-chip.tsx +++ b/packages/raystack/components/filter-chip/filter-chip.tsx @@ -1,6 +1,7 @@ 'use client'; import { cva, cx, VariantProps } from 'class-variance-authority'; +import { parseISO } from 'date-fns'; import { ComponentProps, ReactElement, @@ -14,11 +15,8 @@ import { type CalendarPreviewInputProps, type CalendarPreviewProps } from '~/components/calendar-preview'; -import { - parseKey, - toInstant -} from '~/components/calendar-preview/date-adapter'; import { XIcon } from '~/icons'; +import { toInstant } from '~/shared/date-filters'; import { FilterOperation, FilterOperator, @@ -267,7 +265,7 @@ export const FilterChip = ({ formatDate && ((date, _scale, timeZone) => formatDate( - date instanceof Date ? date : parseKey(date.date), + date instanceof Date ? date : parseISO(date.date), timeZone )) } diff --git a/packages/raystack/shared/__tests__/date-filters.test.ts b/packages/raystack/shared/__tests__/date-filters.test.ts new file mode 100644 index 000000000..7452ca0f6 --- /dev/null +++ b/packages/raystack/shared/__tests__/date-filters.test.ts @@ -0,0 +1,198 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { toDayKey, toInstant } from '../date-filters'; + +/* 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-filters.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/shared/date-filters.ts b/packages/raystack/shared/date-filters.ts index 5885c6820..42aa228c1 100644 --- a/packages/raystack/shared/date-filters.ts +++ b/packages/raystack/shared/date-filters.ts @@ -1,8 +1,5 @@ import type { FilterFn } from '@tanstack/table-core'; -import { - type DayKey, - toDayKey -} from '~/components/calendar-preview/date-adapter'; +import { format, isExists, isValid, parseISO } from 'date-fns'; import type { FilterTypes, FilterValue, @@ -10,10 +7,155 @@ import type { } from '~/types/filters'; import { FilterType } from '~/types/filters'; +/* `uuuu`, not `yyyy`: year-of-era formats JS year 0 as `'0001'`. */ +const DAY_KEY_FORMAT = 'uuuu-MM-dd'; +const DAY_KEY_SHAPE = /^\d{4}-\d{2}-\d{2}$/; + +/** The `yyyy-MM-dd` day of a row or filter value in the viewer's zone. */ +export function toDayKey(value: unknown): string | null { + const date = toInstant(value); + if (!date) return null; + const key = format(date, DAY_KEY_FORMAT); + return DAY_KEY_SHAPE.test(key) ? key : null; +} + +export function toInstant(value: unknown): Date | null { + 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) { + const [, year, month, day] = isoDay.map(Number); + if (!isExists(year, month - 1, day)) return null; + } + const parts = LOCAL_SHAPE.exec(value); + if (parts) return fromLocalParts(parts); + const native = new Date(value); + return isValid(native) && 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; +} + /* 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 + test: (day: string, filterDay: string) => boolean ): FilterFn { return (row, columnId, filterValue: FilterValue) => { const day = toDayKey(row.getValue(columnId)); From b7e6952d5e7f318528ab9176af4cc4243f577984 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 5 Oct 2026 14:25:37 +0530 Subject: [PATCH 17/27] fix: [filter-chip] do not crash on an invalid or out-of-range date toDateValue reads every value through toInstant and returns a Date only when its year is 1 to 9999. An invalid Date or a year above 9999 leaves the field unselected instead of throwing during render. --- .../filter-chip/__tests__/filter-chip.test.tsx | 17 +++++++++++++++++ .../components/filter-chip/filter-chip.tsx | 17 +++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) 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 03c58cf4f..4078e7ecb 100644 --- a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx @@ -352,6 +352,23 @@ describe('FilterChip', () => { expect(screen.getByPlaceholderText('Select date')).toHaveValue(''); }); + it.each([ + ['an invalid Date', new Date('')], + ['a year above 9999', new Date(10000, 0, 1)], + ['a timestamp in a year above 9999', Date.UTC(10000, 6, 1)] + ])('renders %s as unselected instead of crashing', (_label, value) => { + expect(() => + render( + + ) + ).not.toThrow(); + expect(screen.getByPlaceholderText('Select date')).toHaveValue(''); + }); + it('formats a Date value with the default month-as-text format', () => { // Local-component Date so the formatted string is timezone-stable. render( diff --git a/packages/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx index af3b15617..2fbdf084e 100644 --- a/packages/raystack/components/filter-chip/filter-chip.tsx +++ b/packages/raystack/components/filter-chip/filter-chip.tsx @@ -53,14 +53,19 @@ const INVALID_DATE = 'Invalid date'; /** * 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. + * Unparseable values, and years the calendar cannot show, leave the field + * unselected. */ const toDateValue = (value: unknown): Date | undefined => { - if (value instanceof Date) return value; - if (typeof value === 'string' || typeof value === 'number') { - return toInstant(value) ?? undefined; - } - return undefined; + const date = + value instanceof Date || + typeof value === 'string' || + typeof value === 'number' + ? toInstant(value) + : null; + if (!date) return undefined; + const year = date.getFullYear(); + return year >= 1 && year <= 9999 ? date : undefined; }; /** Checked on change, not keydown, so paste and IME input are covered. */ From cacf54638028b94b184df272933437e22f6d9dfa Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 5 Oct 2026 14:26:26 +0530 Subject: [PATCH 18/27] fix: [data-view, data-table] read dayjs and moment objects in date filters toInstant reads an object whose valueOf() returns a finite number as an epoch timestamp. dayjs read these rows before the migration, and toInstant returned null for them. --- .../src/content/docs/(overview)/upgrading.mdx | 2 ++ packages/raystack/CHANGELOG.md | 2 ++ .../shared/__tests__/date-filters.test.ts | 20 +++++++++++++++++++ packages/raystack/shared/date-filters.ts | 8 ++++++++ 4 files changed, 32 insertions(+) diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index c11d2183e..981fae72b 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -39,6 +39,8 @@ full timestamp may read a date-only string differently, so test it. Saved filters and URL parameters that hold an old timestamp still load, as the day that instant falls on in the viewer's time zone. +Rows that hold a `Date`, an epoch number, or a dayjs or moment object are read +as before. DataView and DataTable compare and write the day in the viewer's time zone. If you set `calendarProps.timeZone` or `filterProps.calendar.timeZone` to another diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index 9af707d04..88e289bb2 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -109,6 +109,8 @@ another day. `dayjs` stays a dependency, because `Calendar`, matched `gt` and `gte` against a filter day before 1 January 2027. A timeline row holding one is not drawn. - **`neq` still matches a row with a missing or unreadable date.** +- **A row holding a dayjs or moment object is still read**, as its + timestamp. 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 diff --git a/packages/raystack/shared/__tests__/date-filters.test.ts b/packages/raystack/shared/__tests__/date-filters.test.ts index 7452ca0f6..f8389e1ca 100644 --- a/packages/raystack/shared/__tests__/date-filters.test.ts +++ b/packages/raystack/shared/__tests__/date-filters.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import dayjs from 'dayjs'; import { describe, expect, it } from 'vitest'; import { toDayKey, toInstant } from '../date-filters'; @@ -141,6 +142,21 @@ describe('toInstant', () => { expect(toInstant(input)?.getTime()).toBe(expected.getTime()); }); + it('reads a dayjs or moment object as its timestamp', () => { + const time = Date.UTC(2026, 7, 15, 12); + expect(toInstant(dayjs(time))?.getTime()).toBe(time); + expect(toInstant({ valueOf: () => time })?.getTime()).toBe(time); + }); + + it.each([ + ['an object without a numeric valueOf', { valueOf: () => 'x' }], + ['an object with no prototype', Object.create(null)], + ['an invalid dayjs', dayjs('')], + ['false', false] + ])('rejects %s', (_label, input) => { + expect(toInstant(input)).toBeNull(); + }); + 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() @@ -192,6 +208,10 @@ describe('toDayKey', () => { expect(toDayKey(undefined)).toBeNull(); }); + it('reads a dayjs object', () => { + expect(toDayKey(dayjs('2026-08-15'))).toBe('2026-08-15'); + }); + it('rejects a year outside four digits', () => { expect(toDayKey(new Date(10000, 0, 1))).toBeNull(); }); diff --git a/packages/raystack/shared/date-filters.ts b/packages/raystack/shared/date-filters.ts index 42aa228c1..83b653c8b 100644 --- a/packages/raystack/shared/date-filters.ts +++ b/packages/raystack/shared/date-filters.ts @@ -20,6 +20,14 @@ export function toDayKey(value: unknown): string | null { } export function toInstant(value: unknown): Date | null { + /* A dayjs or moment object reads as its epoch. */ + if (typeof value === 'object' && value !== null && !(value instanceof Date)) { + const epoch = + typeof value.valueOf === 'function' ? value.valueOf() : undefined; + return typeof epoch === 'number' && Number.isFinite(epoch) + ? new Date(epoch) + : null; + } if (value instanceof Date) return isValid(value) ? value : null; if (typeof value === 'number') { const fromEpoch = new Date(value); From 839c1d4082d21f7b7d11d20a7f38a46cb247f770 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 5 Oct 2026 14:27:25 +0530 Subject: [PATCH 19/27] fix: [data-view, data-table] simplify toInstant and keep month-boundary and bracketed-zone timestamps Remove the month-name and offset heuristic. It dropped valid timestamps near a month boundary in some viewer zones. An impossible day is now rejected only for YYYY-MM-DD strings and the local YYYY-M-D shape. Other strings go to new Date, as they did under dayjs. Strip a trailing RFC 9557 zone annotation before parseISO, so the offset before it is used instead of reading the time as UTC. Add a side-by-side test against dayjs over generated inputs near month and year boundaries. --- .../__tests__/date-filters-dayjs.test.ts | 143 ++++++++++++++++++ .../shared/__tests__/date-filters.test.ts | 76 +++++----- packages/raystack/shared/date-filters.ts | 95 +----------- 3 files changed, 190 insertions(+), 124 deletions(-) create mode 100644 packages/raystack/shared/__tests__/date-filters-dayjs.test.ts diff --git a/packages/raystack/shared/__tests__/date-filters-dayjs.test.ts b/packages/raystack/shared/__tests__/date-filters-dayjs.test.ts new file mode 100644 index 000000000..7080202fa --- /dev/null +++ b/packages/raystack/shared/__tests__/date-filters-dayjs.test.ts @@ -0,0 +1,143 @@ +import dayjs from 'dayjs'; +import { describe, expect, it } from 'vitest'; +import { toInstant } from '../date-filters'; + +const pad = (value: number, width = 2) => String(value).padStart(width, '0'); +const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' '); +const WEEKDAYS = 'Sun Mon Tue Wed Thu Fri Sat'.split(' '); + +/* Each UTC instant within 2 hours of a month or year boundary, and a mid-month one. */ +function instants(): Date[] { + const result: Date[] = []; + for (const [year, month] of [ + [2025, 11], + [2026, 0], + [2026, 1], + [2026, 2], + [2026, 8], + [2024, 1] + ]) { + const boundary = Date.UTC(year, month, 1); + for (const minutes of [-120, -90, -30, 0, 30, 90, 120]) { + result.push(new Date(boundary + minutes * 60_000)); + } + } + result.push(new Date(Date.UTC(2026, 7, 15, 12, 34, 56, 789))); + return result; +} + +const OFFSETS = [0, 330, -420, 840, -600, 120]; + +function offsetParts(minutes: number) { + const sign = minutes < 0 ? '-' : '+'; + const hours = pad(Math.floor(Math.abs(minutes) / 60)); + const rest = pad(Math.abs(minutes) % 60); + return { sign, hours, rest }; +} + +/* The wall-clock fields of `date` at `offset` minutes east of UTC. */ +function wall(date: Date, offset: number) { + const shifted = new Date(date.getTime() + offset * 60_000); + return { + year: shifted.getUTCFullYear(), + month: shifted.getUTCMonth(), + day: shifted.getUTCDate(), + weekday: shifted.getUTCDay(), + hour: shifted.getUTCHours(), + minute: shifted.getUTCMinutes(), + second: shifted.getUTCSeconds() + }; +} + +function strings(): string[] { + const result: string[] = []; + for (const date of instants()) { + result.push(date.toISOString()); + result.push(date.toUTCString()); + for (const offset of OFFSETS) { + const w = wall(date, offset); + const { sign, hours, rest } = offsetParts(offset); + const day = `${w.year}-${pad(w.month + 1)}-${pad(w.day)}`; + const time = `${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`; + result.push(`${day}T${time}${sign}${hours}:${rest}`); + result.push(`${day}T${time}${sign}${hours}${rest}`); + if (rest === '00') { + result.push(`${day}T${time}${sign}${hours}`); + result.push(`${day} ${time} ${sign}${hours}`); + } + result.push(`${day}T${time}${sign}${hours}:${rest}[Etc/Unknown]`); + const hour12 = w.hour % 12 || 12; + const meridiem = w.hour < 12 ? 'AM' : 'PM'; + result.push( + `${MONTHS[w.month]} ${w.day} ${w.year} ${hour12}:${pad(w.minute)} ${meridiem} ${sign}${hours}${rest}` + ); + result.push( + `${WEEKDAYS[w.weekday]}, ${pad(w.day)} ${MONTHS[w.month]} ${w.year} ${time} ${offset === 0 ? 'GMT' : `${sign}${hours}${rest}`}` + ); + } + const local = wall(date, -date.getTimezoneOffset()); + const day = `${local.year}-${pad(local.month + 1)}-${pad(local.day)}`; + result.push(day); + result.push(`${day} ${pad(local.hour)}:${pad(local.minute)}`); + result.push(`${day}T${pad(local.hour)}:${pad(local.minute)}:00`); + result.push(`${local.month + 1}/${local.day}/${local.year}`); + } + return result; +} + +function inputs(): unknown[] { + const result: unknown[] = [...strings()]; + for (const date of instants()) { + result.push(date.getTime(), new Date(date), dayjs(date)); + } + return result; +} + +const readByDayjs = (value: unknown) => { + const parsed = dayjs(value as dayjs.ConfigType); + return parsed.isValid() ? parsed.valueOf() : null; +}; + +const readByAdapter = (value: unknown) => toInstant(value)?.getTime() ?? null; + +/* dayjs read a bracketed zone as `new Date` does, which ignores the offset + before it. */ +const BRACKETED = /\]$/; + +/* An ISO time with a two-digit offset, which dayjs rejected. parseISO reads it. */ +const ISO_HOUR_OFFSET = /^\d{4}-\d{2}-\d{2}T[\d:]+[+-]\d{2}$/; + +describe('toInstant against dayjs', () => { + it('agrees on every generated input', () => { + const differences = inputs() + .filter( + input => + typeof input !== 'string' || + !(BRACKETED.test(input) || ISO_HOUR_OFFSET.test(input)) + ) + .map(input => ({ + input: String(input), + dayjs: readByDayjs(input), + toInstant: readByAdapter(input) + })) + .filter(row => row.dayjs !== row.toInstant); + expect(differences).toEqual([]); + }); + + it('reads an ISO time with a two-digit offset, which dayjs rejected', () => { + const hourOffsets = strings().filter(one => ISO_HOUR_OFFSET.test(one)); + expect(hourOffsets.length).toBeGreaterThan(0); + for (const input of hourOffsets) { + expect(readByDayjs(input)).toBeNull(); + expect(readByAdapter(input)).toBe(new Date(`${input}:00`).getTime()); + } + }); + + it('reads a bracketed zone by the offset before it', () => { + for (const input of strings().filter(one => BRACKETED.test(one))) { + expect(readByAdapter(input)).toBe( + new Date(input.replace(/\[.*\]$/, '')).getTime() + ); + } + }); +}); diff --git a/packages/raystack/shared/__tests__/date-filters.test.ts b/packages/raystack/shared/__tests__/date-filters.test.ts index f8389e1ca..2e51f00f0 100644 --- a/packages/raystack/shared/__tests__/date-filters.test.ts +++ b/packages/raystack/shared/__tests__/date-filters.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { format } from 'date-fns'; import dayjs from 'dayjs'; import { describe, expect, it } from 'vitest'; import { toDayKey, toInstant } from '../date-filters'; @@ -102,44 +103,28 @@ describe('toInstant', () => { ); }); + /* Outside the ISO and local shapes, `new Date` reads the string, as it did + under dayjs, and rolls an impossible day over. */ 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(); + ['02/30/2014', new Date(2014, 2, 2)], + ['February 30, 2026', new Date(2026, 2, 2)] + ])('reads %s as new Date does', (input, expected) => { + expect(toInstant(input)?.getTime()).toBe(expected.getTime()); }); it.each([ - ['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()); + ['2026-09-01 00:30:00 +02', Date.UTC(2026, 7, 31, 22, 30)], + ['Aug 31 2026 11:30 PM -0700', Date.UTC(2026, 8, 1, 6, 30)], + ['2026-01-01T00:30:00+02:00', Date.UTC(2025, 11, 31, 22, 30)], + ['Dec 31 2025 11:30 PM -0700', Date.UTC(2026, 0, 1, 6, 30)] + ])('reads %s across a month boundary in any zone', (input, expected) => { + expect(toInstant(input)?.getTime()).toBe(expected); + }); + + it('reads the offset before a bracketed zone', () => { + expect(toInstant('2026-08-15T23:00+05:30[Asia/Kolkata]')?.getTime()).toBe( + Date.UTC(2026, 7, 15, 17, 30) + ); }); it('reads a dayjs or moment object as its timestamp', () => { @@ -157,6 +142,23 @@ describe('toInstant', () => { 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() @@ -212,6 +214,12 @@ describe('toDayKey', () => { expect(toDayKey(dayjs('2026-08-15'))).toBe('2026-08-15'); }); + it('reads the day of a bracketed zone in the viewer zone', () => { + expect(toDayKey('2026-08-15T23:00+05:30[Asia/Kolkata]')).toBe( + format(Date.UTC(2026, 7, 15, 17, 30), 'yyyy-MM-dd') + ); + }); + it('rejects a year outside four digits', () => { expect(toDayKey(new Date(10000, 0, 1))).toBeNull(); }); diff --git a/packages/raystack/shared/date-filters.ts b/packages/raystack/shared/date-filters.ts index 83b653c8b..3c58fd9d0 100644 --- a/packages/raystack/shared/date-filters.ts +++ b/packages/raystack/shared/date-filters.ts @@ -34,7 +34,7 @@ export function toInstant(value: unknown): Date | null { return isValid(fromEpoch) ? fromEpoch : null; } if (typeof value !== 'string') return null; - const iso = parseISO(value); + const iso = parseISO(value.replace(ZONE_ANNOTATION, '')); if (isValid(iso)) return iso; const isoDay = ISO_DAY.exec(value); if (isoDay) { @@ -44,100 +44,15 @@ export function toInstant(value: unknown): Date | 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; + return isValid(native) ? native : null; } +/* RFC 9557, as in `2026-08-15T23:00+05:30[Asia/Kolkata]`. parseISO rejects it. */ +const ZONE_ANNOTATION = /(\[[^\]]*\])+$/; + /* `new Date` rolls an impossible day over, whatever suffix follows it. */ const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})/; -/* 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 = From c557fc498178c0861b2ed5dd400a5cadaf98f87f Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 6 Oct 2026 13:58:29 +0530 Subject: [PATCH 20/27] fix: [data-view, filter-chip] read dayjs and moment objects in the timeline and the date chip toTimestamp and the chip's toDateValue call toInstant, which reads an object whose valueOf() is a number. A timeline row holding an epoch outside the Date range is skipped instead of breaking the axis. --- packages/react/CHANGELOG.md | 3 ++- .../data-view/__tests__/timeline.test.tsx | 6 ++++++ .../components/data-view/utils/time-scale.tsx | 13 ++----------- .../filter-chip/__tests__/filter-chip.test.tsx | 16 ++++++++++++++++ .../react/components/filter-chip/filter-chip.tsx | 15 +++++---------- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 88e289bb2..8ce077a04 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -110,7 +110,8 @@ another day. `dayjs` stays a dependency, because `Calendar`, timeline row holding one is not drawn. - **`neq` still matches a row with a missing or unreadable date.** - **A row holding a dayjs or moment object is still read**, as its - timestamp. + timestamp. The timeline now draws such a row, and a date FilterChip + shows such a value. Before, both left it out. Saved filters and URL parameters that hold an ISO timestamp are still read, as the day the instant falls on in the viewer's zone. There is no diff --git a/packages/react/components/data-view/__tests__/timeline.test.tsx b/packages/react/components/data-view/__tests__/timeline.test.tsx index 1bb0a142d..5f3604e4c 100644 --- a/packages/react/components/data-view/__tests__/timeline.test.tsx +++ b/packages/react/components/data-view/__tests__/timeline.test.tsx @@ -1,5 +1,6 @@ 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 @@ -42,6 +43,11 @@ describe('toTimestamp', () => { expect(toTimestamp('2025-01-05')).toBe(new Date(2025, 0, 5).getTime()); }); + it('reads a dayjs object as its epoch', () => { + const date = new Date('2025-01-05T00:00:00'); + expect(toTimestamp(dayjs(date))).toBe(date.getTime()); + }); + it('returns null for missing or invalid values', () => { expect(toTimestamp(null)).toBeNull(); expect(toTimestamp(undefined)).toBeNull(); diff --git a/packages/react/components/data-view/utils/time-scale.tsx b/packages/react/components/data-view/utils/time-scale.tsx index b443e93f9..a0f5a4ff6 100644 --- a/packages/react/components/data-view/utils/time-scale.tsx +++ b/packages/react/components/data-view/utils/time-scale.tsx @@ -37,18 +37,9 @@ export const TIMELINE_DEFAULT_UNIT_WIDTH: Record = { /** Minimum px between rendered tick labels. Denser ticks skip labels. */ const TICK_LABEL_MIN_SPACE = 28; -/** Coerce a consumer-provided date (Date | epoch ms | parseable string) to ms. */ +/** Coerce a consumer-provided date (Date | epoch ms | string | dayjs or moment object) to ms. */ export function toTimestamp(value: unknown): number | null { - if (value == null) return null; - if (value instanceof Date) { - const time = value.getTime(); - return Number.isNaN(time) ? null : time; - } - if (typeof value === 'number') { - return Number.isFinite(value) ? value : null; - } - if (typeof value === 'string') return toInstant(value)?.getTime() ?? null; - return null; + return toInstant(value)?.getTime() ?? null; } const START_OF = { diff --git a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx index 4078e7ecb..10dc6951c 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; +import dayjs from 'dayjs'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { defaultFormatValue } from '~/components/calendar-preview/calendar-preview-root'; @@ -369,6 +370,21 @@ describe('FilterChip', () => { expect(screen.getByPlaceholderText('Select date')).toHaveValue(''); }); + it('shows a dayjs value as its day', () => { + render( + + ); + expect( + screen.getByDisplayValue( + defaultFormatValue(new Date(2026, 4, 27), 'day') + ) + ).toBeInTheDocument(); + }); + it('formats a Date value with the default month-as-text format', () => { // Local-component Date so the formatted string is timezone-stable. render( diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx index 2fbdf084e..1a8238ff3 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -51,18 +51,13 @@ export type FilterChipValue = string | string[] | number | Date; const INVALID_DATE = 'Invalid date'; /** - * 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, and years the calendar cannot show, leave the field - * unselected. + * Coerce a filter value to the `Date` the calendar expects, since filter state + * hydrated from a serialized query arrives as a string or epoch number, and a + * consumer may pass a dayjs or moment object. Unparseable values, and years the + * calendar cannot show, leave the field unselected. */ const toDateValue = (value: unknown): Date | undefined => { - const date = - value instanceof Date || - typeof value === 'string' || - typeof value === 'number' - ? toInstant(value) - : null; + const date = toInstant(value); if (!date) return undefined; const year = date.getFullYear(); return year >= 1 && year <= 9999 ? date : undefined; From d1c845e7dc957b2c645fc23f36f9e19616c21987 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 6 Oct 2026 13:58:30 +0530 Subject: [PATCH 21/27] refactor: [data-view, data-table] share the date operators and remove duplicate tests dateFilterFns in shared/date-filters.ts replaces the two identical date operator maps. Tests that repeated the generated dayjs comparison or the shared operator tests are removed. --- .../data-table/__tests__/data-table.test.tsx | 23 +------ .../__tests__/filter-operations.test.tsx | 22 ------ .../data-table/utils/filter-operations.tsx | 11 +-- .../data-view/__tests__/data-view.test.tsx | 21 +----- .../__tests__/filter-operations.test.ts | 8 --- .../data-view/utils/filter-operations.tsx | 11 +-- .../shared/__tests__/date-filters.test.ts | 67 ++----------------- packages/react/shared/date-filters.ts | 17 ++++- 8 files changed, 29 insertions(+), 151 deletions(-) diff --git a/packages/react/components/data-table/__tests__/data-table.test.tsx b/packages/react/components/data-table/__tests__/data-table.test.tsx index 0c3b25a01..05641f179 100644 --- a/packages/react/components/data-table/__tests__/data-table.test.tsx +++ b/packages/react/components/data-table/__tests__/data-table.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { DataTable } from '../data-table'; import styles from '../data-table.module.css'; import { DataTableColumnDef, DataTableQuery } from '../data-table.types'; @@ -681,10 +681,6 @@ describe('DataTable', () => { { 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(); @@ -716,21 +712,8 @@ describe('DataTable', () => { ); - 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' } - ]); + it('filters rows by a restored date filter', () => { + renderRestored([{ name: 'when', operator: 'eq', value: '2026-08-15' }]); expect(screen.getByTestId('rows')).toHaveTextContent('d15'); }); diff --git a/packages/react/components/data-table/utils/__tests__/filter-operations.test.tsx b/packages/react/components/data-table/utils/__tests__/filter-operations.test.tsx index e744f4de2..171927221 100644 --- a/packages/react/components/data-table/utils/__tests__/filter-operations.test.tsx +++ b/packages/react/components/data-table/utils/__tests__/filter-operations.test.tsx @@ -343,28 +343,6 @@ 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', () => { diff --git a/packages/react/components/data-table/utils/filter-operations.tsx b/packages/react/components/data-table/utils/filter-operations.tsx index 9c0d460e1..38ab5e604 100644 --- a/packages/react/components/data-table/utils/filter-operations.tsx +++ b/packages/react/components/data-table/utils/filter-operations.tsx @@ -1,6 +1,6 @@ import type { FilterFn } from '@tanstack/table-core'; -import { notOnDay, onDay, toDayKey } from '~/shared/date-filters'; +import { dateFilterFns, toDayKey } from '~/shared/date-filters'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, @@ -77,14 +77,7 @@ export const filterOperationsMap: FilterFunctionsMap = { return columnValue.endsWith(filterStr); } }, - date: { - 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) - }, + date: dateFilterFns, select: { eq: (row, columnId, filterValue: FilterValue, _addMeta) => { if (String(filterValue.value) === EmptyFilterValue) { diff --git a/packages/react/components/data-view/__tests__/data-view.test.tsx b/packages/react/components/data-view/__tests__/data-view.test.tsx index d5166bf77..866ea5fe2 100644 --- a/packages/react/components/data-view/__tests__/data-view.test.tsx +++ b/packages/react/components/data-view/__tests__/data-view.test.tsx @@ -1333,10 +1333,6 @@ describe('DataView', () => { filterType: 'date' } ]; - const originalTimeZone = process.env.TZ; - afterEach(() => { - process.env.TZ = originalTimeZone; - }); const renderRestored = ( filters: NonNullable, @@ -1365,21 +1361,8 @@ describe('DataView', () => { ); - 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' } - ]); + it('filters rows by a restored date filter', () => { + renderRestored([{ name: 'when', operator: 'eq', value: '2026-08-15' }]); expect(screen.getByTestId('rows')).toHaveTextContent('d15'); }); diff --git a/packages/react/components/data-view/__tests__/filter-operations.test.ts b/packages/react/components/data-view/__tests__/filter-operations.test.ts index 22778476a..01a8f1c9e 100644 --- a/packages/react/components/data-view/__tests__/filter-operations.test.ts +++ b/packages/react/components/data-view/__tests__/filter-operations.test.ts @@ -197,14 +197,6 @@ describe('stored date filters', () => { 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'; diff --git a/packages/react/components/data-view/utils/filter-operations.tsx b/packages/react/components/data-view/utils/filter-operations.tsx index 79a6fc679..8360c8115 100644 --- a/packages/react/components/data-view/utils/filter-operations.tsx +++ b/packages/react/components/data-view/utils/filter-operations.tsx @@ -1,6 +1,6 @@ import type { FilterFn } from '@tanstack/table-core'; -import { notOnDay, onDay, toDayKey } from '~/shared/date-filters'; +import { dateFilterFns, toDayKey } from '~/shared/date-filters'; import { DataTableFilterOperatorTypes, DateFilterOperatorType, @@ -63,14 +63,7 @@ export const filterOperationsMap: FilterFunctionsMap = { return columnValue.endsWith(filterStr); } }, - date: { - 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) - }, + date: dateFilterFns, select: { eq: (row, columnId, filterValue: FilterValue) => { if (String(filterValue.value) === EmptyFilterValue) diff --git a/packages/react/shared/__tests__/date-filters.test.ts b/packages/react/shared/__tests__/date-filters.test.ts index 2e51f00f0..3a9b789e2 100644 --- a/packages/react/shared/__tests__/date-filters.test.ts +++ b/packages/react/shared/__tests__/date-filters.test.ts @@ -1,6 +1,5 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { format } from 'date-fns'; import dayjs from 'dayjs'; import { describe, expect, it } from 'vitest'; import { toDayKey, toInstant } from '../date-filters'; @@ -33,29 +32,12 @@ describe('toInstant', () => { ['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', @@ -89,8 +71,6 @@ describe('toInstant', () => { }); 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 => { @@ -112,24 +92,8 @@ describe('toInstant', () => { expect(toInstant(input)?.getTime()).toBe(expected.getTime()); }); - it.each([ - ['2026-09-01 00:30:00 +02', Date.UTC(2026, 7, 31, 22, 30)], - ['Aug 31 2026 11:30 PM -0700', Date.UTC(2026, 8, 1, 6, 30)], - ['2026-01-01T00:30:00+02:00', Date.UTC(2025, 11, 31, 22, 30)], - ['Dec 31 2025 11:30 PM -0700', Date.UTC(2026, 0, 1, 6, 30)] - ])('reads %s across a month boundary in any zone', (input, expected) => { - expect(toInstant(input)?.getTime()).toBe(expected); - }); - - it('reads the offset before a bracketed zone', () => { - expect(toInstant('2026-08-15T23:00+05:30[Asia/Kolkata]')?.getTime()).toBe( - Date.UTC(2026, 7, 15, 17, 30) - ); - }); - - it('reads a dayjs or moment object as its timestamp', () => { + it('reads an object with a numeric valueOf as its timestamp', () => { const time = Date.UTC(2026, 7, 15, 12); - expect(toInstant(dayjs(time))?.getTime()).toBe(time); expect(toInstant({ valueOf: () => time })?.getTime()).toBe(time); }); @@ -137,7 +101,10 @@ describe('toInstant', () => { ['an object without a numeric valueOf', { valueOf: () => 'x' }], ['an object with no prototype', Object.create(null)], ['an invalid dayjs', dayjs('')], - ['false', false] + /* dayjs read `undefined` as now, so an unset date filter matched today. */ + ['undefined', undefined], + /* dayjs read a boolean as epoch zero. */ + ['true', true] ])('rejects %s', (_label, input) => { expect(toInstant(input)).toBeNull(); }); @@ -165,15 +132,6 @@ describe('toInstant', () => { ); }); - /* 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)], @@ -205,21 +163,6 @@ describe('toDayKey', () => { 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('reads a dayjs object', () => { - expect(toDayKey(dayjs('2026-08-15'))).toBe('2026-08-15'); - }); - - it('reads the day of a bracketed zone in the viewer zone', () => { - expect(toDayKey('2026-08-15T23:00+05:30[Asia/Kolkata]')).toBe( - format(Date.UTC(2026, 7, 15, 17, 30), 'yyyy-MM-dd') - ); - }); - it('rejects a year outside four digits', () => { expect(toDayKey(new Date(10000, 0, 1))).toBeNull(); }); diff --git a/packages/react/shared/date-filters.ts b/packages/react/shared/date-filters.ts index 3c58fd9d0..04ff99a70 100644 --- a/packages/react/shared/date-filters.ts +++ b/packages/react/shared/date-filters.ts @@ -1,6 +1,7 @@ import type { FilterFn } from '@tanstack/table-core'; import { format, isExists, isValid, parseISO } from 'date-fns'; import type { + DateFilterOperatorType, FilterTypes, FilterValue, FilterValueType @@ -77,7 +78,7 @@ function fromLocalParts(parts: RegExpExecArray): Date | null { /* An unreadable row cannot be placed before or after a day, so it matches none of these. */ -export function onDay( +function onDay( test: (day: string, filterDay: string) => boolean ): FilterFn { return (row, columnId, filterValue: FilterValue) => { @@ -88,7 +89,7 @@ export function onDay( } /* A row with no readable date is still not the filter's day. */ -export const notOnDay: FilterFn = ( +const notOnDay: FilterFn = ( row, columnId, filterValue: FilterValue @@ -97,6 +98,18 @@ export const notOnDay: FilterFn = ( return filterDay !== null && toDayKey(row.getValue(columnId)) !== filterDay; }; +export const dateFilterFns: Record< + DateFilterOperatorType, + FilterFn +> = { + eq: onDay((day, filterDay) => day === filterDay), + neq: notOnDay, + lt: onDay((day, filterDay) => day < filterDay), + lte: onDay((day, filterDay) => day <= filterDay), + gt: onDay((day, filterDay) => day > filterDay), + gte: onDay((day, filterDay) => day >= filterDay) +}; + type TypedFilter = { name: string; _type?: FilterTypes; From e0784889cedec8406718e445e3c24f21d51bb863 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 6 Oct 2026 13:58:31 +0530 Subject: [PATCH 22/27] chore: [docs] clarify calendarProps and cleared-date notes in the upgrade guide and changelog --- apps/www/src/content/docs/(overview)/upgrading.mdx | 6 +++--- packages/react/CHANGELOG.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index 981fae72b..b5fcb5409 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -60,7 +60,7 @@ 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` | +| `slotProps.calendar` / nested `calendarProps` | `minDate`, `maxDate`, `isDateUnavailable`, `defaultMonth`, `yearRange` | | `inputProps` | `slotProps.input` | | `popoverProps` | `slotProps.popover` | @@ -92,8 +92,8 @@ default formats, such as `15 Aug 2026`. 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 `''`. +chip stays, and the filter is left out of the query until a date is picked +again. If you render `FilterChip` yourself, handle `''`. ### 4. Expect different rows from some filters diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 8ce077a04..4c89119ad 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -134,7 +134,7 @@ Apsara renders `Calendar`, `DatePicker` or `RangePicker` now. `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 + the nested `calendarProps` and `popoverProps` are gone. Replace `dateFormat` with `formatValue`, move calendar options such as `disabled` days to `minDate`, `maxDate` or `isDateUnavailable`, and move `inputProps` and `popoverProps` to `slotProps.input` and `slotProps.popover`. From 40d6924cab1e92c7f576da9602fc6b2f6c91ca3d Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 7 Oct 2026 13:35:09 +0530 Subject: [PATCH 23/27] fix: [data-view, filter-chip] read times in a daylight-saving gap and guard a throwing valueOf toInstant read a local time inside a daylight-saving gap, such as '2026/03/08 02:30' in Los Angeles, as null. It now range-checks the time and reads back only the day, so the time moves forward as with dayjs. An object whose valueOf throws reads as null instead of crashing the date filters and FilterChip. FilterChip checks the year limit in calendarProps.timeZone, so a value past 9999 in that zone leaves the field empty instead of crashing. The changelog and upgrade guide say only year-first impossible days are rejected. Month-first and month-name forms roll over as before. --- .../src/content/docs/(overview)/upgrading.mdx | 9 +- packages/react/CHANGELOG.md | 10 +- .../components/data-view/utils/time-scale.tsx | 2 +- .../__tests__/filter-chip.test.tsx | 104 ++++++------- .../components/filter-chip/filter-chip.tsx | 16 +- .../shared/__tests__/date-filters.test.ts | 137 ++++++++---------- packages/react/shared/date-filters.ts | 20 ++- 7 files changed, 134 insertions(+), 164 deletions(-) diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index b5fcb5409..4bf27123e 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -102,12 +102,13 @@ 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 date filter on a year-first day that does not exist, such as `2026-02-30`, + used to roll over to 2 March. It is now dropped. A month-first or month-name + form such as `02/30/2026` still rolls over. - A row with no date used to be read as today, so it could match any operator, depending on the filter day. It now matches only `neq`. -- A row holding a 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 year-first day that does not exist used to roll over to a + real date. It now matches only `neq`, and the timeline does not draw it. - A row holding a numeric string or a boolean used to be read as a date, such as the year 1792 for `'1786752000000'`. It now matches only `neq`, and the timeline does not draw it. diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 4c89119ad..52a49bd31 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -94,8 +94,10 @@ another day. `dayjs` stays a dependency, because `Calendar`, - **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 date filter holding a year-first day that does not exist is + dropped.** dayjs rolled `2026-02-30` over to 2 March and filtered on + that. A month-first or month-name form such as `02/30/2026` still rolls + over, as with dayjs. - **A row whose date is missing matches only `neq`.** dayjs read it as today, so the row matched `eq`, `lte` and `gte` on a filter day of today, `lt` and `lte` on a later filter day, and `gt` and `gte` on an @@ -103,8 +105,8 @@ another day. `dayjs` stays a dependency, because `Calendar`, - **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 +- **A row holding a year-first day that does not exist matches only + `neq`.** dayjs rolled it to a real date and compared that, so `2026-02-30` matched `lt` and `lte` against a filter day after 2 March, and `2026-13-01` matched `gt` and `gte` against a filter day before 1 January 2027. A timeline row holding one is not drawn. diff --git a/packages/react/components/data-view/utils/time-scale.tsx b/packages/react/components/data-view/utils/time-scale.tsx index a0f5a4ff6..29759c3c2 100644 --- a/packages/react/components/data-view/utils/time-scale.tsx +++ b/packages/react/components/data-view/utils/time-scale.tsx @@ -37,7 +37,7 @@ export const TIMELINE_DEFAULT_UNIT_WIDTH: Record = { /** Minimum px between rendered tick labels. Denser ticks skip labels. */ const TICK_LABEL_MIN_SPACE = 28; -/** Coerce a consumer-provided date (Date | epoch ms | string | dayjs or moment object) to ms. */ +/** Coerce a consumer-provided date (Date | epoch ms | parseable string | object with a numeric `valueOf`) to ms. */ export function toTimestamp(value: unknown): number | null { return toInstant(value)?.getTime() ?? null; } diff --git a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx index 10dc6951c..ccb7e22c7 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -1,5 +1,4 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; -import dayjs from 'dayjs'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { defaultFormatValue } from '~/components/calendar-preview/calendar-preview-root'; @@ -309,28 +308,20 @@ describe('FilterChip', () => { expect(screen.getByPlaceholderText('Select date')).toBeInTheDocument(); }); - it('parses a serialized string date value instead of rendering blank', () => { - render( - - ); - expect( - screen.getByDisplayValue( - defaultFormatValue(new Date(2026, 4, 27), 'day') - ) - ).toBeInTheDocument(); - }); - - it('parses an epoch number value', () => { - // Local-component Date so the timestamp is timezone-stable. + it.each([ + ['a serialized string', '2026-05-27'], + ['an epoch number', new Date(2026, 4, 27).getTime()], + ['a Date', new Date(2026, 4, 27)], + [ + 'an object with a numeric valueOf', + { valueOf: () => new Date(2026, 4, 27).getTime() } + ] + ])('shows %s as its day', (_label, value) => { render( ); expect( @@ -340,65 +331,52 @@ describe('FilterChip', () => { ).toBeInTheDocument(); }); - it('coerces an unparseable value to unselected instead of crashing', () => { - expect(() => - render( - - ) - ).not.toThrow(); - expect(screen.getByPlaceholderText('Select date')).toHaveValue(''); - }); - it.each([ + ['an unparseable string', 'not-a-date'], ['an invalid Date', new Date('')], ['a year above 9999', new Date(10000, 0, 1)], - ['a timestamp in a year above 9999', Date.UTC(10000, 6, 1)] + ['a timestamp in a year above 9999', Date.UTC(10000, 6, 1)], + [ + 'an object whose valueOf throws', + { + valueOf() { + throw new TypeError('valueOf'); + } + } + ] ])('renders %s as unselected instead of crashing', (_label, value) => { expect(() => render( ) ).not.toThrow(); expect(screen.getByPlaceholderText('Select date')).toHaveValue(''); }); - it('shows a dayjs value as its day', () => { - render( - - ); - expect( - screen.getByDisplayValue( - defaultFormatValue(new Date(2026, 4, 27), 'day') - ) - ).toBeInTheDocument(); - }); - - it('formats a Date value with the default month-as-text format', () => { - // Local-component Date so the formatted string is timezone-stable. - render( - - ); - expect( - screen.getByDisplayValue( - defaultFormatValue(new Date(2026, 4, 27), 'day') - ) - ).toBeInTheDocument(); + it('renders a value that is past 9999 in calendarProps.timeZone as unselected', () => { + /* In UTC the value is still in 9999, so only the calendar zone rejects it. */ + const previous = process.env.TZ; + process.env.TZ = 'UTC'; + try { + expect(() => + render( + + ) + ).not.toThrow(); + expect(screen.getByPlaceholderText('Select date')).toHaveValue(''); + } finally { + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; + } }); it('forwards calendarProps to the calendar', () => { diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx index 1a8238ff3..21bb049e6 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -1,5 +1,6 @@ 'use client'; +import { TZDate } from '@date-fns/tz'; import { cva, cx, VariantProps } from 'class-variance-authority'; import { parseISO } from 'date-fns'; import { @@ -50,16 +51,11 @@ export type FilterChipValue = string | string[] | number | Date; /* The message `DatePicker` reports, so `onErrorChange` reads the same. */ const INVALID_DATE = 'Invalid date'; -/** - * Coerce a filter value to the `Date` the calendar expects, since filter state - * hydrated from a serialized query arrives as a string or epoch number, and a - * consumer may pass a dayjs or moment object. Unparseable values, and years the - * calendar cannot show, leave the field unselected. - */ -const toDateValue = (value: unknown): Date | undefined => { +/* A year the calendar cannot show leaves the field empty. */ +const toDateValue = (value: unknown, timeZone?: string): Date | undefined => { const date = toInstant(value); if (!date) return undefined; - const year = date.getFullYear(); + const year = (timeZone ? new TZDate(date, timeZone) : date).getFullYear(); return year >= 1 && year <= 9999 ? date : undefined; }; @@ -173,8 +169,8 @@ export const FilterChip = ({ 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] + () => toDateValue(filterValue, calendarRest.timeZone) ?? null, + [filterValue, calendarRest.timeZone] ); const showOnRemove = typeof onRemove === 'function'; diff --git a/packages/react/shared/__tests__/date-filters.test.ts b/packages/react/shared/__tests__/date-filters.test.ts index 3a9b789e2..656146191 100644 --- a/packages/react/shared/__tests__/date-filters.test.ts +++ b/packages/react/shared/__tests__/date-filters.test.ts @@ -1,11 +1,6 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import dayjs from 'dayjs'; import { describe, expect, it } from 'vitest'; import { toDayKey, toInstant } from '../date-filters'; -/* 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)], @@ -22,17 +17,33 @@ describe('toInstant', () => { ['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 local time with a longer fraction', + '2023/12/01 10:30:00.123456', + new Date(2023, 11, 1, 10, 30, 0, 123) + ], ['a month-first slashed day', '12/01/2023', new Date(2023, 11, 1)], [ 'a day-first slashed day, read month-first', '01/12/2023', new Date(2023, 0, 12) ], + ['a leap day', '02/29/2024', new Date(2024, 1, 29)], + ['a dashed month-first day', '2-28-2026', new Date(2026, 1, 28)], ['a long month name', 'December 1, 2023', new Date(2023, 11, 1)], ['a short month name', '1 Dec 2023', new Date(2023, 11, 1)], + ['a four-letter month name', 'Sept 30, 2026', new Date(2026, 8, 30)], + ['a weekday prefix', 'Monday, March 2, 2026', new Date(2026, 2, 2)], + ['a month and year', 'Dec 2023', new Date(2023, 11, 1)], ['a dotted day', '2023.12.01', new Date(2023, 11, 1)], ['surrounding whitespace', ' 2023-12-01 ', new Date(2023, 11, 1)], ['epoch zero', 0, new Date(0)], + [ + 'an object with a numeric valueOf', + { valueOf: () => Date.UTC(2026, 7, 15, 12) }, + new Date(Date.UTC(2026, 7, 15, 12)) + ], + ['a UTC suffix', '2023-02-28T00:00:00Z', new Date(Date.UTC(2023, 1, 28))], [ 'microseconds', '2023-12-01T10:30:00.123456Z', @@ -42,8 +53,23 @@ describe('toInstant', () => { 'a Date string', 'Fri Dec 01 2023 00:00:00 GMT+0000', new Date(Date.UTC(2023, 11, 1)) + ], + [ + 'an RFC 2822 string', + 'Fri, 01 Dec 2023 00:00:00 GMT', + new Date(Date.UTC(2023, 11, 1)) + ], + [ + 'a GMT offset', + 'Fri Dec 01 2023 02:00:00 GMT+0530', + new Date(Date.UTC(2023, 10, 30, 20, 30)) + ], + [ + 'a US zone name', + 'Nov 30 2026 23:00 EST', + new Date(Date.UTC(2026, 11, 1, 4)) ] - ])('reads %s as dayjs did', (_label, input, expected) => { + ])('reads %s', (_label, input, expected) => { expect(toInstant(input)?.getTime()).toBe(expected.getTime()); }); @@ -52,39 +78,35 @@ describe('toInstant', () => { ['whitespace', ' '], ['a non-date string', 'not a date'], ['null', null], + ['undefined', undefined], + ['true', true], ['an invalid Date', new Date(Number.NaN)], - ['an object', { date: '2023-12-01' }] - ])('rejects %s as dayjs did', (_label, input) => { - expect(toInstant(input)).toBeNull(); - }); - - /* dayjs rolled each of these into a neighbouring day or month. */ - it.each([ + ['an object', { date: '2023-12-01' }], + ['an object without a numeric valueOf', { valueOf: () => 'x' }], + ['an object with no prototype', Object.create(null)], + ['an object whose valueOf is NaN', { valueOf: () => Number.NaN }], + [ + 'an object whose valueOf throws', + { + valueOf() { + throw new TypeError('valueOf'); + } + } + ], + ['an epoch as a string', '1701388800000'], ['month 13', '2023-13-01'], ['month 0', '2023-00-10'], ['day 0', '2023-12-00'], ['day 32', '2023-12-32'], ['30 February', '2023-02-30'], - ['an epoch as a string', '1701388800000'] - ])('rejects %s where dayjs rolled it over', (_label, input) => { - expect(toInstant(input)).toBeNull(); - }); - - it.each([ - '2023-02-30T00:00:00Z', - '2026-02-30T12:00:00+05:30' - ])('rejects the impossible day in %s', input => { + ['30 February with a UTC suffix', '2023-02-30T00:00:00Z'], + ['30 February with an offset', '2026-02-30T12:00:00+05:30'] + ])('rejects %s', (_label, input) => { expect(toInstant(input)).toBeNull(); }); - it('reads a real day with a zone suffix', () => { - expect(toInstant('2023-02-28T00:00:00Z')?.getTime()).toBe( - Date.UTC(2023, 1, 28) - ); - }); - - /* Outside the ISO and local shapes, `new Date` reads the string, as it did - under dayjs, and rolls an impossible day over. */ + /* Outside the ISO and local shapes, `new Date` reads the string and rolls an + impossible day over. */ it.each([ ['02/30/2014', new Date(2014, 2, 2)], ['February 30, 2026', new Date(2026, 2, 2)] @@ -92,47 +114,20 @@ describe('toInstant', () => { expect(toInstant(input)?.getTime()).toBe(expected.getTime()); }); - it('reads an object with a numeric valueOf as its timestamp', () => { - const time = Date.UTC(2026, 7, 15, 12); - expect(toInstant({ valueOf: () => time })?.getTime()).toBe(time); - }); - - it.each([ - ['an object without a numeric valueOf', { valueOf: () => 'x' }], - ['an object with no prototype', Object.create(null)], - ['an invalid dayjs', dayjs('')], - /* dayjs read `undefined` as now, so an unset date filter matched today. */ - ['undefined', undefined], - /* dayjs read a boolean as epoch zero. */ - ['true', true] - ])('rejects %s', (_label, 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() - ); + ['America/Los_Angeles', '2026/03/08 02:30', '2026-03-08T10:30:00.000Z'], + ['America/Santiago', '2026/09/06', '2026-09-06T04:00:00.000Z'] + ])('reads a time in a daylight-saving gap in %s', (zone, input, expected) => { + const previous = process.env.TZ; + process.env.TZ = zone; + try { + expect(toInstant(input)?.toISOString()).toBe(expected); + } finally { + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; + } }); - /* 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)], @@ -151,12 +146,6 @@ describe('toInstant', () => { }); }); -/* Safari before 16.4 throws on a lookbehind when the module loads. */ -it('uses no regex lookbehind', () => { - const source = readFileSync(resolve(__dirname, '../date-filters.ts'), 'utf8'); - expect(source).not.toMatch(/\(\?<[=!]/); -}); - describe('toDayKey', () => { it('reads the local calendar day', () => { expect(toDayKey('2023-12-01')).toBe('2023-12-01'); diff --git a/packages/react/shared/date-filters.ts b/packages/react/shared/date-filters.ts index 04ff99a70..e772f3b9e 100644 --- a/packages/react/shared/date-filters.ts +++ b/packages/react/shared/date-filters.ts @@ -21,10 +21,14 @@ export function toDayKey(value: unknown): string | null { } export function toInstant(value: unknown): Date | null { - /* A dayjs or moment object reads as its epoch. */ + /* A date-library object reads as its numeric `valueOf`. */ if (typeof value === 'object' && value !== null && !(value instanceof Date)) { - const epoch = - typeof value.valueOf === 'function' ? value.valueOf() : undefined; + let epoch: unknown; + try { + epoch = typeof value.valueOf === 'function' ? value.valueOf() : undefined; + } catch { + return null; + } return typeof epoch === 'number' && Number.isFinite(epoch) ? new Date(epoch) : null; @@ -54,7 +58,7 @@ const ZONE_ANNOTATION = /(\[[^\]]*\])+$/; /* `new Date` rolls an impossible day over, whatever suffix follows it. */ const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})/; -/* The shape dayjs parsed as local time. `new Date` reads some of these as UTC +/* Year-first strings read as local time. `new Date` reads some of these as UTC and rolls out-of-range fields over, so they never reach it. */ const LOCAL_SHAPE = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/; @@ -65,14 +69,14 @@ function fromLocalParts(parts: RegExpExecArray): Date | null { .slice(2, 7) .map(part => (part ? Number(part) : undefined)); const ms = Number((parts[7] ?? '0').slice(0, 3)); + if (hour > 23 || minute > 59 || second > 59) return null; + /* A time in a daylight-saving gap moves forward, so only the day is read + back. */ const date = new Date(year, month - 1, day, hour, minute, second, ms); const readsBack = date.getFullYear() === year && date.getMonth() === month - 1 && - date.getDate() === day && - date.getHours() === hour && - date.getMinutes() === minute && - date.getSeconds() === second; + date.getDate() === day; return readsBack ? date : null; } From bb2beef6946ebd4b59dd8081babfc14f728ab251 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 7 Oct 2026 13:35:11 +0530 Subject: [PATCH 24/27] chore: [data-view, data-table, filter-chip] drop dayjs from tests and trim duplicate cases Remove date-filters-dayjs.test.ts and the dayjs imports in the timeline and FilterChip tests. Merge overlapping test tables, drop the DataTable copy of the restored string filter test, and remove comments that restate the code or describe dayjs history. The stored date filter tests restore TZ by deleting it when it was unset, instead of setting the string "undefined". --- .../data-table/__tests__/data-table.test.tsx | 18 --- .../data-table/utils/__tests__/index.test.tsx | 1 - .../__tests__/filter-operations.test.ts | 7 +- .../data-view/__tests__/timeline.test.tsx | 5 +- .../filter-chip/filter-chip.module.css | 1 - .../__tests__/date-filters-dayjs.test.ts | 143 ------------------ 6 files changed, 4 insertions(+), 171 deletions(-) delete mode 100644 packages/react/shared/__tests__/date-filters-dayjs.test.ts diff --git a/packages/react/components/data-table/__tests__/data-table.test.tsx b/packages/react/components/data-table/__tests__/data-table.test.tsx index 05641f179..61ca2b35e 100644 --- a/packages/react/components/data-table/__tests__/data-table.test.tsx +++ b/packages/react/components/data-table/__tests__/data-table.test.tsx @@ -729,23 +729,5 @@ describe('DataTable', () => { ]?.[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/react/components/data-table/utils/__tests__/index.test.tsx b/packages/react/components/data-table/utils/__tests__/index.test.tsx index f526ab4be..17e938a50 100644 --- a/packages/react/components/data-table/utils/__tests__/index.test.tsx +++ b/packages/react/components/data-table/utils/__tests__/index.test.tsx @@ -187,7 +187,6 @@ 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: [ diff --git a/packages/react/components/data-view/__tests__/filter-operations.test.ts b/packages/react/components/data-view/__tests__/filter-operations.test.ts index 01a8f1c9e..bcf4fd082 100644 --- a/packages/react/components/data-view/__tests__/filter-operations.test.ts +++ b/packages/react/components/data-view/__tests__/filter-operations.test.ts @@ -158,8 +158,6 @@ describe('date filters', () => { 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); }); @@ -169,7 +167,8 @@ describe('date filters', () => { describe('stored date filters', () => { const originalTimeZone = process.env.TZ; afterEach(() => { - process.env.TZ = originalTimeZone; + if (originalTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimeZone; }); const matches = (stored: unknown, row: string) => @@ -206,8 +205,6 @@ describe('stored date filters', () => { }); }); -/* 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' }] diff --git a/packages/react/components/data-view/__tests__/timeline.test.tsx b/packages/react/components/data-view/__tests__/timeline.test.tsx index 5f3604e4c..2284da4ea 100644 --- a/packages/react/components/data-view/__tests__/timeline.test.tsx +++ b/packages/react/components/data-view/__tests__/timeline.test.tsx @@ -1,6 +1,5 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import dayjs from 'dayjs'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; // biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name @@ -43,9 +42,9 @@ describe('toTimestamp', () => { expect(toTimestamp('2025-01-05')).toBe(new Date(2025, 0, 5).getTime()); }); - it('reads a dayjs object as its epoch', () => { + it('reads an object with a numeric valueOf as its epoch', () => { const date = new Date('2025-01-05T00:00:00'); - expect(toTimestamp(dayjs(date))).toBe(date.getTime()); + expect(toTimestamp({ valueOf: () => date.getTime() })).toBe(date.getTime()); }); it('returns null for missing or invalid values', () => { diff --git a/packages/react/components/filter-chip/filter-chip.module.css b/packages/react/components/filter-chip/filter-chip.module.css index 8f243721f..75eaef08a 100644 --- a/packages/react/components/filter-chip/filter-chip.module.css +++ b/packages/react/components/filter-chip/filter-chip.module.css @@ -212,7 +212,6 @@ button.selectValue:hover { box-shadow: none; } -/* 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); diff --git a/packages/react/shared/__tests__/date-filters-dayjs.test.ts b/packages/react/shared/__tests__/date-filters-dayjs.test.ts deleted file mode 100644 index 7080202fa..000000000 --- a/packages/react/shared/__tests__/date-filters-dayjs.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import dayjs from 'dayjs'; -import { describe, expect, it } from 'vitest'; -import { toInstant } from '../date-filters'; - -const pad = (value: number, width = 2) => String(value).padStart(width, '0'); -const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' '); -const WEEKDAYS = 'Sun Mon Tue Wed Thu Fri Sat'.split(' '); - -/* Each UTC instant within 2 hours of a month or year boundary, and a mid-month one. */ -function instants(): Date[] { - const result: Date[] = []; - for (const [year, month] of [ - [2025, 11], - [2026, 0], - [2026, 1], - [2026, 2], - [2026, 8], - [2024, 1] - ]) { - const boundary = Date.UTC(year, month, 1); - for (const minutes of [-120, -90, -30, 0, 30, 90, 120]) { - result.push(new Date(boundary + minutes * 60_000)); - } - } - result.push(new Date(Date.UTC(2026, 7, 15, 12, 34, 56, 789))); - return result; -} - -const OFFSETS = [0, 330, -420, 840, -600, 120]; - -function offsetParts(minutes: number) { - const sign = minutes < 0 ? '-' : '+'; - const hours = pad(Math.floor(Math.abs(minutes) / 60)); - const rest = pad(Math.abs(minutes) % 60); - return { sign, hours, rest }; -} - -/* The wall-clock fields of `date` at `offset` minutes east of UTC. */ -function wall(date: Date, offset: number) { - const shifted = new Date(date.getTime() + offset * 60_000); - return { - year: shifted.getUTCFullYear(), - month: shifted.getUTCMonth(), - day: shifted.getUTCDate(), - weekday: shifted.getUTCDay(), - hour: shifted.getUTCHours(), - minute: shifted.getUTCMinutes(), - second: shifted.getUTCSeconds() - }; -} - -function strings(): string[] { - const result: string[] = []; - for (const date of instants()) { - result.push(date.toISOString()); - result.push(date.toUTCString()); - for (const offset of OFFSETS) { - const w = wall(date, offset); - const { sign, hours, rest } = offsetParts(offset); - const day = `${w.year}-${pad(w.month + 1)}-${pad(w.day)}`; - const time = `${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`; - result.push(`${day}T${time}${sign}${hours}:${rest}`); - result.push(`${day}T${time}${sign}${hours}${rest}`); - if (rest === '00') { - result.push(`${day}T${time}${sign}${hours}`); - result.push(`${day} ${time} ${sign}${hours}`); - } - result.push(`${day}T${time}${sign}${hours}:${rest}[Etc/Unknown]`); - const hour12 = w.hour % 12 || 12; - const meridiem = w.hour < 12 ? 'AM' : 'PM'; - result.push( - `${MONTHS[w.month]} ${w.day} ${w.year} ${hour12}:${pad(w.minute)} ${meridiem} ${sign}${hours}${rest}` - ); - result.push( - `${WEEKDAYS[w.weekday]}, ${pad(w.day)} ${MONTHS[w.month]} ${w.year} ${time} ${offset === 0 ? 'GMT' : `${sign}${hours}${rest}`}` - ); - } - const local = wall(date, -date.getTimezoneOffset()); - const day = `${local.year}-${pad(local.month + 1)}-${pad(local.day)}`; - result.push(day); - result.push(`${day} ${pad(local.hour)}:${pad(local.minute)}`); - result.push(`${day}T${pad(local.hour)}:${pad(local.minute)}:00`); - result.push(`${local.month + 1}/${local.day}/${local.year}`); - } - return result; -} - -function inputs(): unknown[] { - const result: unknown[] = [...strings()]; - for (const date of instants()) { - result.push(date.getTime(), new Date(date), dayjs(date)); - } - return result; -} - -const readByDayjs = (value: unknown) => { - const parsed = dayjs(value as dayjs.ConfigType); - return parsed.isValid() ? parsed.valueOf() : null; -}; - -const readByAdapter = (value: unknown) => toInstant(value)?.getTime() ?? null; - -/* dayjs read a bracketed zone as `new Date` does, which ignores the offset - before it. */ -const BRACKETED = /\]$/; - -/* An ISO time with a two-digit offset, which dayjs rejected. parseISO reads it. */ -const ISO_HOUR_OFFSET = /^\d{4}-\d{2}-\d{2}T[\d:]+[+-]\d{2}$/; - -describe('toInstant against dayjs', () => { - it('agrees on every generated input', () => { - const differences = inputs() - .filter( - input => - typeof input !== 'string' || - !(BRACKETED.test(input) || ISO_HOUR_OFFSET.test(input)) - ) - .map(input => ({ - input: String(input), - dayjs: readByDayjs(input), - toInstant: readByAdapter(input) - })) - .filter(row => row.dayjs !== row.toInstant); - expect(differences).toEqual([]); - }); - - it('reads an ISO time with a two-digit offset, which dayjs rejected', () => { - const hourOffsets = strings().filter(one => ISO_HOUR_OFFSET.test(one)); - expect(hourOffsets.length).toBeGreaterThan(0); - for (const input of hourOffsets) { - expect(readByDayjs(input)).toBeNull(); - expect(readByAdapter(input)).toBe(new Date(`${input}:00`).getTime()); - } - }); - - it('reads a bracketed zone by the offset before it', () => { - for (const input of strings().filter(one => BRACKETED.test(one))) { - expect(readByAdapter(input)).toBe( - new Date(input.replace(/\[.*\]$/, '')).getTime() - ); - } - }); -}); From 622ed0598cc4c5e11fb8bbceb704c87de503a9a2 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 7 Oct 2026 13:49:22 +0530 Subject: [PATCH 25/27] test: [data-view] cover year-first local times that only the local-shape parser reads parseISO and new Date both return null for '2026/1/1T10:30', '2026-1-5T10' and '2026t10'. These cases fail if LOCAL_SHAPE and fromLocalParts are removed. --- packages/react/shared/__tests__/date-filters.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react/shared/__tests__/date-filters.test.ts b/packages/react/shared/__tests__/date-filters.test.ts index 656146191..5c1583bb1 100644 --- a/packages/react/shared/__tests__/date-filters.test.ts +++ b/packages/react/shared/__tests__/date-filters.test.ts @@ -17,6 +17,13 @@ describe('toInstant', () => { ['slashes', '2023/12/01', new Date(2023, 11, 1)], ['an unpadded day', '2023-1-5', new Date(2023, 0, 5)], ['an unpadded slashed day', '2023/1/5', new Date(2023, 0, 5)], + [ + 'a slashed day with a T time', + '2026/1/1T10:30', + new Date(2026, 0, 1, 10, 30) + ], + ['an unpadded day with a T hour', '2026-1-5T10', new Date(2026, 0, 5, 10)], + ['a bare year with a t hour', '2026t10', new Date(2026, 0, 1, 10)], [ 'a local time with a longer fraction', '2023/12/01 10:30:00.123456', From db75071d63869ba9c7cc26f7de8ce32227659dd6 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 7 Oct 2026 15:01:35 +0530 Subject: [PATCH 26/27] fix: [calendar-preview, filter-chip] open the calendar on a day in December 9999 The December 9999 grid ends on 1 January 10000, which has no day key, so isDateUnavailable threw a RangeError when the calendar opened. A day that has no day key is now unavailable. This crashed every date filter chip whose value was in December 9999. --- .../__tests__/calendar-preview.test.tsx | 7 +++++++ .../calendar-preview/calendar-preview-root.tsx | 8 +++++++- .../filter-chip/__tests__/filter-chip.test.tsx | 13 +++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/react/components/calendar-preview/__tests__/calendar-preview.test.tsx b/packages/react/components/calendar-preview/__tests__/calendar-preview.test.tsx index fe6cba67f..5943618d2 100644 --- a/packages/react/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/react/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -56,6 +56,13 @@ describe('CalendarPreview root', () => { error.mockRestore(); }); + it('renders December 9999, whose grid runs into 10000', () => { + const { container } = renderCalendar(undefined, { + defaultMonth: new Date(9999, 11, 1) + }); + expect(dayCell(container, '31')).toBeInTheDocument(); + }); + it('does not warn when a controlled value that started empty is set', () => { const error = vi .spyOn(console, 'error') diff --git a/packages/react/components/calendar-preview/calendar-preview-root.tsx b/packages/react/components/calendar-preview/calendar-preview-root.tsx index 47ce44988..0dba0c821 100644 --- a/packages/react/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/react/components/calendar-preview/calendar-preview-root.tsx @@ -474,7 +474,13 @@ export function CalendarPreviewRoot({ /* Day-keys, so a `minDate` carrying a time of day leaves its own day selectable. */ const isDateUnavailable = useCallback( (date: Date) => { - const key = dayKey(date, timeZone); + /* The December 9999 grid runs into 10000, which has no day key. */ + let key: string; + try { + key = dayKey(date, timeZone); + } catch { + return true; + } if (minDate && key < dayKey(minDate, timeZone)) return true; if (maxDate && key > dayKey(maxDate, timeZone)) return true; return isDateUnavailableProp?.(date) ?? false; diff --git a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx index ccb7e22c7..b0d09dfb0 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -357,6 +357,19 @@ describe('FilterChip', () => { expect(screen.getByPlaceholderText('Select date')).toHaveValue(''); }); + it('opens the calendar on 31 December 9999', () => { + render( + + ); + const input = screen.getByPlaceholderText('Select date'); + act(() => input.focus()); + expect(isOpen()).toBe(true); + }); + it('renders a value that is past 9999 in calendarProps.timeZone as unselected', () => { /* In UTC the value is still in 9999, so only the calendar zone rejects it. */ const previous = process.env.TZ; From 54ca8ba096ea4ebdccaade4107442a7d69774251 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 7 Oct 2026 15:01:36 +0530 Subject: [PATCH 27/27] chore: [changelog] say only an undefined date filter value was read as today --- packages/react/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 52a49bd31..b82985c20 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -89,8 +89,8 @@ another day. `dayjs` stays a dependency, because `Calendar`, 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 with no value is dropped.** dayjs read a missing + (`undefined`) value as today, so the filter matched today's rows. - **A date filter restored from `query` filters by its date.** It lost its type when the query was loaded, so the date comparison got no date, and dayjs read that as today.