From 6186549b8971ee32269f2859397d1fe272c0ad71 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 28 Sep 2026 13:59:02 +0530 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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);