diff --git a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts new file mode 100644 index 000000000..c16d607b9 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; + +import { + dayKey, + dayKeyFromParts, + endOfMonthKey, + endOfQuarterKey, + endOfYearKey, + epoch, + isDayKey, + monthFromName, + monthOf, + parseKey, + startOfMonthKey, + startOfQuarterKey, + startOfYearKey, + yearOf +} from '../date-adapter'; + +describe('dayKey', () => { + it('reads the calendar day from the date own fields', () => { + expect(dayKey(new Date(2026, 7, 31, 23, 30))).toBe('2026-08-31'); + expect(dayKey(new Date(2026, 7, 31, 0, 0))).toBe('2026-08-31'); + }); + + it('pads a single-digit month and day', () => { + expect(dayKey(new Date(2026, 0, 5))).toBe('2026-01-05'); + }); + + it('reads the day in an explicit zone', () => { + /* 20:00 UTC on 31 August is already 1 September in Tokyo. */ + const instant = new Date(Date.UTC(2026, 7, 31, 20, 0)); + expect(dayKey(instant, 'UTC')).toBe('2026-08-31'); + expect(dayKey(instant, 'Asia/Tokyo')).toBe('2026-09-01'); + expect(dayKey(instant, 'America/New_York')).toBe('2026-08-31'); + }); +}); + +describe('epoch', () => { + it('is the instant in milliseconds', () => { + const date = new Date(Date.UTC(2026, 7, 31, 20, 0)); + expect(epoch(date)).toBe(date.getTime()); + expect(epoch(date)).toBe(Date.UTC(2026, 7, 31, 20, 0)); + }); +}); + +describe('isDayKey', () => { + it.each([ + '2026-08-31', + '2028-02-29', + '2000-02-29', + '0001-01-01' + ])('accepts %s', key => { + expect(isDayKey(key)).toBe(true); + }); + + it.each([ + '', + '2026-8-31', + '2026/08/31', + '31-08-2026', + '2026-08-31T00:00:00Z', + '2026-13-01', + '2026-00-01', + '2026-08-32', + '2027-02-29', + '2100-02-29' + ])('rejects %j', key => { + expect(isDayKey(key)).toBe(false); + }); +}); + +describe('parseKey', () => { + it('returns local midnight on the named day', () => { + const date = parseKey('2026-08-31'); + expect(date.getFullYear()).toBe(2026); + expect(date.getMonth()).toBe(7); + expect(date.getDate()).toBe(31); + expect(date.getHours()).toBe(0); + }); + + it('round-trips with dayKey', () => { + for (const key of ['2026-08-31', '2028-02-29', '2026-01-01']) { + expect(dayKey(parseKey(key))).toBe(key); + } + }); + + it('throws on a malformed key', () => { + expect(() => parseKey('31/08/2026')).toThrow(RangeError); + }); + + it('throws on a well-shaped day that does not exist', () => { + expect(() => parseKey('2027-02-29')).toThrow(RangeError); + }); +}); + +describe('dayKeyFromParts', () => { + it('builds a key from 1-indexed months', () => { + expect(dayKeyFromParts(2026, 8, 31)).toBe('2026-08-31'); + expect(dayKeyFromParts(2026, 1, 5)).toBe('2026-01-05'); + }); + + it('validates against the real calendar rather than rolling forward', () => { + expect(dayKeyFromParts(2027, 4, 31)).toBeNull(); + expect(dayKeyFromParts(2027, 2, 29)).toBeNull(); + expect(dayKeyFromParts(2028, 2, 29)).toBe('2028-02-29'); + }); + + it.each([ + [2026.5, 8, 31], + [-1, 8, 31], + [10000, 8, 31], + [2026, 8.5, 31], + [2026, 8, 31.5], + [2026, 13, 1], + [2026, 0, 1], + [2026, 8, 0], + [2026, 100, 1] + ])('rejects (%s, %s, %s)', (year, month, day) => { + expect(dayKeyFromParts(year, month, day)).toBeNull(); + }); +}); + +describe('period key helpers', () => { + it('brackets a month, leap-correct', () => { + expect(startOfMonthKey('2028-02-14')).toBe('2028-02-01'); + expect(endOfMonthKey('2028-02-14')).toBe('2028-02-29'); + expect(endOfMonthKey('2100-02-14')).toBe('2100-02-28'); + }); + + it('brackets a quarter', () => { + expect(startOfQuarterKey('2026-08-15')).toBe('2026-07-01'); + expect(endOfQuarterKey('2026-08-15')).toBe('2026-09-30'); + }); + + it('brackets a year', () => { + expect(startOfYearKey('2026-08-15')).toBe('2026-01-01'); + expect(endOfYearKey('2026-08-15')).toBe('2026-12-31'); + }); +}); + +describe('key accessors', () => { + it('reads the year and month without parsing', () => { + expect(yearOf('2026-08-31')).toBe(2026); + expect(monthOf('2026-08-31')).toBe(8); + expect(monthOf('2026-01-31')).toBe(1); + }); +}); + +describe('monthFromName', () => { + it.each([ + ['January', 1], + ['Jan', 1], + ['jan', 1], + ['May', 5], + ['September', 9], + ['Sep', 9], + ['DECEMBER', 12] + ])('reads %s as month %i', (name, month) => { + expect(monthFromName(name)).toBe(month); + }); + + it.each(['', 'Sept', 'Mayy', 'Foo', '05'])('rejects %j', name => { + expect(monthFromName(name)).toBeNull(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/parse.test.ts b/packages/raystack/components/calendar-preview/__tests__/parse.test.ts new file mode 100644 index 000000000..ee69349c0 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/parse.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; + +import { parseScaleInput } from '../lib/parse'; + +/* Fixed so the year-inference tests do not change meaning on 1 January. */ +const REFERENCE = new Date(2026, 8, 4); // 4 September 2026 +const IN_2026 = { referenceDate: REFERENCE }; + +describe('parseScaleInput — day', () => { + it.each([ + ['20/05/2027', '2027-05-20'], + ['5/5/2027', '2027-05-05'], + ['05/05/2027', '2027-05-05'], + ['01/01/2000', '2000-01-01'], + ['31/12/2026', '2026-12-31'] + ])('reads %s as a day', (input, date) => { + expect(parseScaleInput(input, IN_2026)).toEqual({ date, scale: 'day' }); + }); + + it('reads the canonical stored form, so a value round-trips', () => { + expect(parseScaleInput('2027-05-20', IN_2026)).toEqual({ + date: '2027-05-20', + scale: 'day' + }); + }); + + it('accepts 29 February in a leap year', () => { + expect(parseScaleInput('29/02/2028', IN_2026)).toEqual({ + date: '2028-02-29', + scale: 'day' + }); + }); + + it('emits the same date at either edge — a day has only one', () => { + expect( + parseScaleInput('20/05/2027', { ...IN_2026, trailing: true }) + ).toEqual({ date: '2027-05-20', scale: 'day' }); + }); +}); + +describe('parseScaleInput — month', () => { + it.each([ + ['May 2027', '2027-05-01'], + ['September 2027', '2027-09-01'], + ['Sep 2027', '2027-09-01'], + ['sep 2027', '2027-09-01'], + ['DECEMBER 2027', '2027-12-01'], + ['January 2027', '2027-01-01'] + ])('reads %s as a month, leading', (input, date) => { + expect(parseScaleInput(input, IN_2026)).toEqual({ date, scale: 'month' }); + }); + + it.each([ + ['May 2027', '2027-05-31'], + ['February 2028', '2028-02-29'], + ['February 2100', '2100-02-28'], + ['April 2027', '2027-04-30'] + ])('emits the real month end for %s when trailing', (input, date) => { + expect(parseScaleInput(input, { ...IN_2026, trailing: true })).toEqual({ + date, + scale: 'month' + }); + }); +}); + +describe('parseScaleInput — quarter', () => { + it.each([ + ['Q1 2026', '2026-01-01', '2026-03-31'], + ['Q2 2026', '2026-04-01', '2026-06-30'], + ['Q3 2026', '2026-07-01', '2026-09-30'], + ['Q4 2026', '2026-10-01', '2026-12-31'] + ])('reads %s at both edges', (input, leading, trailing) => { + expect(parseScaleInput(input, IN_2026)).toEqual({ + date: leading, + scale: 'quarter' + }); + expect(parseScaleInput(input, { ...IN_2026, trailing: true })).toEqual({ + date: trailing, + scale: 'quarter' + }); + }); + + it('is case-insensitive', () => { + expect(parseScaleInput('q4 2026', IN_2026)).toEqual({ + date: '2026-10-01', + scale: 'quarter' + }); + }); +}); + +describe('parseScaleInput — half-year', () => { + it.each([ + ['H1 2026', '2026-01-01', '2026-06-30'], + ['H2 2026', '2026-07-01', '2026-12-31'] + ])('reads %s at both edges', (input, leading, trailing) => { + expect(parseScaleInput(input, IN_2026)).toEqual({ + date: leading, + scale: 'halfYear' + }); + expect(parseScaleInput(input, { ...IN_2026, trailing: true })).toEqual({ + date: trailing, + scale: 'halfYear' + }); + }); + + it('is case-insensitive', () => { + expect(parseScaleInput('h2 2026', IN_2026)).toEqual({ + date: '2026-07-01', + scale: 'halfYear' + }); + }); +}); + +describe('parseScaleInput — year', () => { + it('reads a bare four-digit year, leading', () => { + expect(parseScaleInput('2025', IN_2026)).toEqual({ + date: '2025-01-01', + scale: 'year' + }); + }); + + it('reads a bare four-digit year, trailing', () => { + expect(parseScaleInput('2025', { ...IN_2026, trailing: true })).toEqual({ + date: '2025-12-31', + scale: 'year' + }); + }); +}); + +/* + * The rule, stated once: a bare period resolves inside the reference year and + * never rolls forward. `Q1` typed in September 2026 is Q1 2026 — already past + * — not Q1 2027. + */ +describe('parseScaleInput — year inference for a bare period', () => { + it.each([ + ['Q4', { date: '2026-10-01', scale: 'quarter' }], + ['Q1', { date: '2026-01-01', scale: 'quarter' }], + ['H1', { date: '2026-01-01', scale: 'halfYear' }], + ['H2', { date: '2026-07-01', scale: 'halfYear' }], + ['May', { date: '2026-05-01', scale: 'month' }], + ['Dec', { date: '2026-12-01', scale: 'month' }] + ])('resolves %s into the reference year', (input, expected) => { + expect(parseScaleInput(input, IN_2026)).toEqual(expected); + }); + + it('never rolls forward — a period already past stays in the reference year', () => { + /* 4 September 2026: Q1 and H1 are both over. */ + expect(parseScaleInput('Q1', IN_2026)?.date).toBe('2026-01-01'); + expect(parseScaleInput('H1', { ...IN_2026, trailing: true })?.date).toBe( + '2026-06-30' + ); + }); + + it('does not depend on the day within the reference year', () => { + const firstDay = { referenceDate: new Date(2026, 0, 1) }; + const lastDay = { referenceDate: new Date(2026, 11, 31) }; + expect(parseScaleInput('Q4', firstDay)).toEqual( + parseScaleInput('Q4', lastDay) + ); + }); + + it('follows the reference year when it moves', () => { + expect( + parseScaleInput('Q4', { referenceDate: new Date(2030, 0, 1) }) + ).toEqual({ date: '2030-10-01', scale: 'quarter' }); + }); + + it('defaults the reference to now', () => { + const thisYear = new Date().getFullYear(); + expect(parseScaleInput('Q4')?.date).toBe(`${thisYear}-10-01`); + }); + + it('prefers an explicit year over the inferred one', () => { + expect(parseScaleInput('Q4 2030', IN_2026)?.date).toBe('2030-10-01'); + }); + + it('returns null rather than throwing when the reference year has no key', () => { + /* A `DayKey` holds four digits. A reference outside that is rejected the + * same way any other unreadable input is, so the caller keeps its value. */ + expect( + parseScaleInput('Q4', { referenceDate: new Date(12026, 0, 1) }) + ).toBeNull(); + }); +}); + +describe('parseScaleInput — whitespace', () => { + it.each([ + ' Q4 2026 ', + 'Q4 2026', + '\tH1 2026\n', + ' 20/05/2027 ' + ])('ignores surrounding and repeated whitespace in %j', input => { + expect(parseScaleInput(input, IN_2026)).not.toBeNull(); + }); +}); + +describe('parseScaleInput — rejections', () => { + it.each([ + ['', 'empty'], + [' ', 'whitespace only'], + ['tomorrow', 'a word that is not a month'], + ['Mayy 2027', 'a near-miss month name'], + ['Sept 2027', 'a four-letter abbreviation date-fns does not use'], + ['20/05/27', 'a two-digit year — the dayjs leniency this replaces'], + ['05/2027', 'a month/year pair with no day'], + ['20/05', 'a day/month pair with no year'], + ['05/20/2027', 'month-first order, which names month 20'], + ['32/01/2027', 'a day that is out of range'], + ['31/04/2027', 'a day that does not exist in that month'], + ['29/02/2027', '29 February in a common year'], + ['29/02/2100', '29 February in a non-leap century'], + ['2027-02-30', 'an ISO day that does not exist'], + ['2027-13-01', 'an ISO month that does not exist'], + ['2027-1-1', 'an unpadded ISO day'], + ['Q0', 'a quarter below the range'], + ['Q5 2026', 'a quarter above the range'], + ['H0', 'a half-year below the range'], + ['H3 2026', 'a half-year above the range'], + ['999', 'a three-digit year'], + ['20270', 'a five-digit year'], + ['May 27', 'a month with a two-digit year'], + ['Q4 26', 'a quarter with a two-digit year'], + ['2026 Q4', 'year-first quarter order, which is not accepted'], + ['Q 4 2026', 'a space inside the quarter token'] + ])('rejects %j — %s', input => { + expect(parseScaleInput(input, IN_2026)).toBeNull(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/scale.test.ts b/packages/raystack/components/calendar-preview/__tests__/scale.test.ts new file mode 100644 index 000000000..134e1d837 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/scale.test.ts @@ -0,0 +1,412 @@ +import { describe, expect, it } from 'vitest'; + +import { + anchorOf, + convertScale, + isAvailable, + isScale, + periodOf, + SCALES, + type Scale +} from '../lib/scale'; + +const LEADING = false; +const TRAILING = true; + +describe('SCALES / isScale', () => { + it('lists the five scales finest first', () => { + expect(SCALES).toEqual(['day', 'month', 'quarter', 'halfYear', 'year']); + }); + + it.each(SCALES)('accepts %s', scale => { + expect(isScale(scale)).toBe(true); + }); + + it.each(['', 'week', 'Day', 'decade'])('rejects %s', value => { + expect(isScale(value)).toBe(false); + }); +}); + +describe('periodOf', () => { + it('makes a day its own period', () => { + expect(periodOf('2026-08-15', 'day')).toEqual({ + start: '2026-08-15', + end: '2026-08-15' + }); + }); + + it.each([ + ['2026-08-15', { start: '2026-08-01', end: '2026-08-31' }], + ['2026-04-30', { start: '2026-04-01', end: '2026-04-30' }], + ['2026-02-10', { start: '2026-02-01', end: '2026-02-28' }] + ])('brackets the month containing %s', (day, expected) => { + expect(periodOf(day, 'month')).toEqual(expected); + }); + + it.each([ + ['2026-01-01', { start: '2026-01-01', end: '2026-03-31' }], + ['2026-03-31', { start: '2026-01-01', end: '2026-03-31' }], + ['2026-04-01', { start: '2026-04-01', end: '2026-06-30' }], + ['2026-07-15', { start: '2026-07-01', end: '2026-09-30' }], + ['2026-10-01', { start: '2026-10-01', end: '2026-12-31' }], + ['2026-12-31', { start: '2026-10-01', end: '2026-12-31' }] + ])('brackets the quarter containing %s', (day, expected) => { + expect(periodOf(day, 'quarter')).toEqual(expected); + }); + + it.each([ + ['2026-01-01', { start: '2026-01-01', end: '2026-06-30' }], + ['2026-06-30', { start: '2026-01-01', end: '2026-06-30' }], + ['2026-07-01', { start: '2026-07-01', end: '2026-12-31' }], + ['2026-12-31', { start: '2026-07-01', end: '2026-12-31' }] + ])('splits the half-year at 30 June for %s', (day, expected) => { + expect(periodOf(day, 'halfYear')).toEqual(expected); + }); + + it.each([ + ['2026-01-01', { start: '2026-01-01', end: '2026-12-31' }], + ['2026-12-31', { start: '2026-01-01', end: '2026-12-31' }] + ])('brackets the year containing %s', (day, expected) => { + expect(periodOf(day, 'year')).toEqual(expected); + }); + + it('accepts a Date and reads its own calendar day', () => { + expect(periodOf(new Date(2026, 7, 15), 'month')).toEqual({ + start: '2026-08-01', + end: '2026-08-31' + }); + }); + + it.each([ + '2026-8-15', + '15/08/2026', + '2026-02-30', + '' + ])('rejects %s as a day', value => { + expect(() => periodOf(value, 'day')).toThrow(RangeError); + }); +}); + +describe('periodOf — leap years and month ends', () => { + it('ends February 2028 on the 29th', () => { + expect(periodOf('2028-02-10', 'month').end).toBe('2028-02-29'); + }); + + it('ends February 2100 on the 28th — a century that is not a leap year', () => { + expect(periodOf('2100-02-10', 'month').end).toBe('2100-02-28'); + }); + + it('ends February 2000 on the 29th — a century that is', () => { + expect(periodOf('2000-02-10', 'month').end).toBe('2000-02-29'); + }); + + it.each([ + ['2027-01-15', '2027-01-31'], + ['2027-02-15', '2027-02-28'], + ['2027-04-15', '2027-04-30'], + ['2027-06-15', '2027-06-30'], + ['2027-09-15', '2027-09-30'], + ['2027-12-15', '2027-12-31'] + ])('snaps %s to the real month end %s', (day, end) => { + expect(periodOf(day, 'month').end).toBe(end); + }); + + it('keeps Q1 ending 31 March in a leap year', () => { + expect(periodOf('2028-02-29', 'quarter')).toEqual({ + start: '2028-01-01', + end: '2028-03-31' + }); + }); + + it('keeps the half-year and year edges fixed across a leap year', () => { + expect(periodOf('2028-02-29', 'halfYear')).toEqual({ + start: '2028-01-01', + end: '2028-06-30' + }); + expect(periodOf('2028-02-29', 'year')).toEqual({ + start: '2028-01-01', + end: '2028-12-31' + }); + }); +}); + +describe('anchorOf', () => { + const august = { start: '2026-08-01', end: '2026-08-31' }; + + it('emits the first day when leading', () => { + expect(anchorOf(august, LEADING)).toBe('2026-08-01'); + }); + + it('emits the last day when trailing', () => { + expect(anchorOf(august, TRAILING)).toBe('2026-08-31'); + }); +}); + +describe('convertScale — every direction', () => { + /* + * The anchor is 15 August 2026, which sits in August, Q3, H2 and 2026. Every + * cell is the period of the target scale containing that anchor, read at the + * stated edge. + */ + const leading: Record = { + day: '2026-08-15', + month: '2026-08-01', + quarter: '2026-07-01', + halfYear: '2026-07-01', + year: '2026-01-01' + }; + const trailing: Record = { + day: '2026-08-15', + month: '2026-08-31', + quarter: '2026-09-30', + halfYear: '2026-12-31', + year: '2026-12-31' + }; + + it.each(SCALES)('converts a day at 15 Aug 2026 to %s, leading', to => { + expect( + convertScale({ date: '2026-08-15', scale: 'day' }, to, LEADING) + ).toEqual({ date: leading[to], scale: to }); + }); + + it.each(SCALES)('converts a day at 15 Aug 2026 to %s, trailing', to => { + expect( + convertScale({ date: '2026-08-15', scale: 'day' }, to, TRAILING) + ).toEqual({ date: trailing[to], scale: to }); + }); + + const pairs = SCALES.flatMap(from => SCALES.map(to => [from, to] as const)); + + it.each(pairs)('converts %s -> %s from a leading anchor', (from, to) => { + const value = convertScale( + { date: '2026-08-15', scale: 'day' }, + from, + LEADING + ); + const converted = convertScale(value, to, LEADING); + expect(converted.scale).toBe(to); + expect(converted.date).toBe(anchorOf(periodOf(value.date, to), LEADING)); + }); + + it.each(pairs)('converts %s -> %s from a trailing anchor', (from, to) => { + const value = convertScale( + { date: '2026-08-15', scale: 'day' }, + from, + TRAILING + ); + const converted = convertScale(value, to, TRAILING); + expect(converted.scale).toBe(to); + expect(converted.date).toBe(anchorOf(periodOf(value.date, to), TRAILING)); + }); + + it('reads the anchor, not the original scale — a month value converts by its date', () => { + /* 2026-12-31 means "December 2026", and December is in Q4 and H2. */ + const december = { date: '2026-12-31', scale: 'month' as const }; + expect(convertScale(december, 'quarter', TRAILING)).toEqual({ + date: '2026-12-31', + scale: 'quarter' + }); + expect(convertScale(december, 'halfYear', LEADING)).toEqual({ + date: '2026-07-01', + scale: 'halfYear' + }); + }); + + it('snaps a month conversion to a real month end', () => { + expect( + convertScale({ date: '2028-02-14', scale: 'day' }, 'month', TRAILING) + ).toEqual({ date: '2028-02-29', scale: 'month' }); + expect( + convertScale({ date: '2100-02-14', scale: 'day' }, 'month', TRAILING) + ).toEqual({ date: '2100-02-28', scale: 'month' }); + }); +}); + +describe('convertScale — round trips', () => { + it('collapses day -> year -> day onto the year edge, leading', () => { + const day = { date: '2026-08-15', scale: 'day' as const }; + const year = convertScale(day, 'year', LEADING); + expect(year).toEqual({ date: '2026-01-01', scale: 'year' }); + + const back = convertScale(year, 'day', LEADING); + expect(back).toEqual({ date: '2026-01-01', scale: 'day' }); + expect(back.date).not.toBe(day.date); + }); + + it('collapses day -> year -> day onto the year edge, trailing', () => { + const day = { date: '2026-08-15', scale: 'day' as const }; + const year = convertScale(day, 'year', TRAILING); + expect(year).toEqual({ date: '2026-12-31', scale: 'year' }); + expect(convertScale(year, 'day', TRAILING)).toEqual({ + date: '2026-12-31', + scale: 'day' + }); + }); + + it.each(SCALES)('round-trips %s -> day -> %s unchanged', scale => { + for (const trailing of [LEADING, TRAILING]) { + const start = convertScale( + { date: '2026-08-15', scale: 'day' }, + scale, + trailing + ); + const viaDay = convertScale(start, 'day', trailing); + expect(convertScale(viaDay, scale, trailing)).toEqual(start); + } + }); + + it.each(SCALES)('is idempotent when converting %s to itself', scale => { + for (const trailing of [LEADING, TRAILING]) { + const once = convertScale( + { date: '2026-08-15', scale: 'day' }, + scale, + trailing + ); + expect(convertScale(once, scale, trailing)).toEqual(once); + } + }); +}); + +describe('isAvailable', () => { + it('is unbounded when neither bound is given', () => { + expect(isAvailable('1000-01-01', 'day', LEADING)).toBe(true); + expect(isAvailable('9999-12-31', 'year', TRAILING)).toBe(true); + }); + + describe('the RFC table — an end field bounded at 15 July 2026', () => { + const min = '2026-07-15'; + const trailing = TRAILING; + + it('disables H1 2026, which emits 30 June', () => { + expect(periodOf('2026-01-01', 'halfYear').end).toBe('2026-06-30'); + expect(isAvailable('2026-01-01', 'halfYear', trailing, min)).toBe(false); + }); + + it('allows July 2026, which emits 31 July', () => { + expect(periodOf('2026-07-01', 'month').end).toBe('2026-07-31'); + expect(isAvailable('2026-07-01', 'month', trailing, min)).toBe(true); + }); + + it('allows Q3 2026, which emits 30 September', () => { + expect(periodOf('2026-07-01', 'quarter').end).toBe('2026-09-30'); + expect(isAvailable('2026-07-01', 'quarter', trailing, min)).toBe(true); + }); + + it('allows August 2026', () => { + expect(isAvailable('2026-08-01', 'month', trailing, min)).toBe(true); + }); + + it('tests the produced date, not the period start', () => { + /* Every period above starts before the bound; only the produced date + * separates them. */ + for (const [day, scale] of [ + ['2026-01-01', 'halfYear'], + ['2026-07-01', 'month'], + ['2026-07-01', 'quarter'] + ] as const) { + expect(periodOf(day, scale).start < min).toBe(true); + } + }); + }); + + it('agrees with the period-start rule whenever trailing is false', () => { + const min = '2026-07-15'; + for (const [day, scale] of [ + ['2026-01-01', 'halfYear'], + ['2026-07-01', 'month'], + ['2026-07-01', 'quarter'], + ['2026-08-01', 'month'] + ] as const) { + expect(isAvailable(day, scale, LEADING, min)).toBe( + periodOf(day, scale).start >= min + ); + } + }); + + describe('bounds are inclusive at both edges', () => { + it('accepts a day exactly on min', () => { + expect(isAvailable('2026-07-15', 'day', LEADING, '2026-07-15')).toBe( + true + ); + }); + + it('rejects the day before min', () => { + expect(isAvailable('2026-07-14', 'day', LEADING, '2026-07-15')).toBe( + false + ); + }); + + it('accepts a day exactly on max', () => { + expect( + isAvailable('2026-07-15', 'day', LEADING, undefined, '2026-07-15') + ).toBe(true); + }); + + it('rejects the day after max', () => { + expect( + isAvailable('2026-07-16', 'day', LEADING, undefined, '2026-07-15') + ).toBe(false); + }); + + it('accepts a period whose produced date lands exactly on max', () => { + expect( + isAvailable('2026-08-10', 'month', TRAILING, undefined, '2026-08-31') + ).toBe(true); + expect( + isAvailable('2026-08-10', 'month', TRAILING, undefined, '2026-08-30') + ).toBe(false); + }); + + it('accepts a period whose produced date lands exactly on min', () => { + expect(isAvailable('2026-08-10', 'month', LEADING, '2026-08-01')).toBe( + true + ); + expect(isAvailable('2026-08-10', 'month', LEADING, '2026-08-02')).toBe( + false + ); + }); + }); + + it('applies both bounds together', () => { + expect( + isAvailable('2026-08-15', 'day', LEADING, '2026-01-01', '2026-12-31') + ).toBe(true); + expect( + isAvailable('2025-08-15', 'day', LEADING, '2026-01-01', '2026-12-31') + ).toBe(false); + expect( + isAvailable('2027-08-15', 'day', LEADING, '2026-01-01', '2026-12-31') + ).toBe(false); + }); + + it('can allow a period in a start field and disable it in an end field', () => { + const max = '2026-08-15'; + expect(isAvailable('2026-08-01', 'month', LEADING, undefined, max)).toBe( + true + ); + expect(isAvailable('2026-08-01', 'month', TRAILING, undefined, max)).toBe( + false + ); + }); + + it('accepts Dates for the value and for either bound', () => { + expect( + isAvailable( + new Date(2026, 7, 15), + 'day', + LEADING, + new Date(2026, 0, 1), + new Date(2026, 11, 31) + ) + ).toBe(true); + }); + + it('rejects a malformed bound rather than ignoring it', () => { + expect(() => + isAvailable('2026-08-15', 'day', LEADING, '15/08/2026') + ).toThrow(RangeError); + expect(() => + isAvailable('2026-08-15', 'day', LEADING, undefined, '2026-13-01') + ).toThrow(RangeError); + }); +}); diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts new file mode 100644 index 000000000..32a1803ad --- /dev/null +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -0,0 +1,183 @@ +/* + * The one module in `calendar-preview/` allowed to call a date library. + * + * Everything else — `lib/scale.ts`, `lib/parse.ts` and, from phase 1, the parts + * — goes through this surface. Two reasons, both from RFC 005: + * + * 1. `dayjs.extend()` is import-order dependent. A module that formats a + * quarter works or throws depending on whether some *other* module has + * already run its `extend()`. date-fns has no plugin registry, so the + * failure class disappears — but only while a single file owns the + * imports. Adding a date-library import elsewhere in `calendar-preview/` + * re-opens it. + * 2. The library stays swappable. Base UI ships `./internals/temporal` with + * date-fns and Luxon adapters; adopting it later is an edit to this file + * and nothing else. + * + * Day values are timeless. The canonical form is a `DayKey` — `'YYYY-MM-DD'`, + * no time, no zone — and it is what crosses every boundary in `lib/`. Two + * day-keys compare correctly with `<`, `>` and `===`, so ordering a day + * against a bound needs no library call and cannot drift by a timezone. + */ +import { TZDate } from '@date-fns/tz'; +import { + endOfMonth, + endOfQuarter, + endOfYear, + format, + isValid, + parse, + startOfMonth, + startOfQuarter, + startOfYear +} from 'date-fns'; + +/** + * A timeless calendar day, `'YYYY-MM-DD'`. + * + * Lexicographic order is chronological order, which is why `lib/` compares + * these as strings rather than converting back to `Date`. + */ +export type DayKey = string; + +const DAY_KEY_FORMAT = 'yyyy-MM-dd'; +const DAY_KEY_SHAPE = /^\d{4}-\d{2}-\d{2}$/; + +/* A fixed reference for `parse`; every token in DAY_KEY_FORMAT is supplied by + * the input, so no field is ever inherited from it. */ +const PARSE_REFERENCE = new Date(2000, 0, 1); + +/** + * The calendar day `date` falls on, as a `DayKey`. + * + * With no `timeZone` the day is read from the date's own calendar fields — the + * day a user in the ambient zone sees. Pass `timeZone` to read the day in that + * zone instead; this is the call that keeps a grid rendered at `timeZone` from + * keying its cells one day off, which is the shape of the current family's + * tooltip/`dateInfo` bug. + */ +export function dayKey(date: Date, timeZone?: string): DayKey { + return format(timeZone ? new TZDate(date, timeZone) : date, DAY_KEY_FORMAT); +} + +/** + * The instant `date` represents, in milliseconds. + * + * For ordering two *days*, compare their `dayKey`s instead — an epoch carries a + * time-of-day and a zone offset, and two Dates on the same calendar day can + * order either way. + */ +export function epoch(date: Date): number { + return date.getTime(); +} + +/** Whether `value` is a well-formed, real calendar day. `'2027-02-29'` is not. */ +export function isDayKey(value: string): boolean { + return DAY_KEY_SHAPE.test(value) && isValid(parseStrict(value)); +} + +/** + * A `DayKey` back to a `Date` at local midnight. + * + * Throws on anything that is not a real calendar day, including a well-shaped + * one that does not exist (`'2027-02-29'`). Callers handling typed input should + * gate on {@link isDayKey}, or build keys with {@link dayKeyFromParts}, rather + * than catching. + */ +export function parseKey(key: DayKey): Date { + if (!DAY_KEY_SHAPE.test(key)) { + throw new RangeError(`Not a YYYY-MM-DD day: ${JSON.stringify(key)}`); + } + const date = parseStrict(key); + if (!isValid(date)) { + throw new RangeError(`Not a real calendar day: ${JSON.stringify(key)}`); + } + return date; +} + +/** + * A `DayKey` from calendar parts, or `null` when they name no real day. + * + * `month` is 1-12. This is the entry point for parsed user input: it validates + * against the actual calendar, so 31 April and 29 February in a common year are + * rejected rather than rolled forward the way a `Date` constructor would. + * + * The accepted year range is exactly what a four-digit key can hold, so this + * and {@link isDayKey} always agree. Rejecting a *two-digit* year is a shape + * question and belongs to whatever matches the input — `lib/parse.ts` pins the + * year at four digits before it gets here. + */ +export function dayKeyFromParts( + year: number, + month: number, + day: number +): DayKey | null { + if (!Number.isInteger(year) || year < 0 || year > 9999) return null; + if (!Number.isInteger(month) || !Number.isInteger(day)) return null; + const key = `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`; + return isDayKey(key) ? key : null; +} + +/** The first day of the month containing `key`. */ +export function startOfMonthKey(key: DayKey): DayKey { + return dayKey(startOfMonth(parseKey(key))); +} + +/** The last day of the month containing `key` — leap-correct by construction. */ +export function endOfMonthKey(key: DayKey): DayKey { + return dayKey(endOfMonth(parseKey(key))); +} + +/** The first day of the calendar quarter containing `key`. */ +export function startOfQuarterKey(key: DayKey): DayKey { + return dayKey(startOfQuarter(parseKey(key))); +} + +/** The last day of the calendar quarter containing `key`. */ +export function endOfQuarterKey(key: DayKey): DayKey { + return dayKey(endOfQuarter(parseKey(key))); +} + +/** The first day of the year containing `key`. */ +export function startOfYearKey(key: DayKey): DayKey { + return dayKey(startOfYear(parseKey(key))); +} + +/** The last day of the year containing `key`. */ +export function endOfYearKey(key: DayKey): DayKey { + return dayKey(endOfYear(parseKey(key))); +} + +/** The calendar year of `key`. */ +export function yearOf(key: DayKey): number { + return Number(key.slice(0, 4)); +} + +/** The calendar month of `key`, 1-12. */ +export function monthOf(key: DayKey): number { + return Number(key.slice(5, 7)); +} + +/** + * The month number (1-12) a written month name denotes, or `null`. + * + * Accepts the full and three-letter forms, case-insensitively — `'September'`, + * `'Sep'`, `'sep'`. The names come from date-fns' default locale, which is + * `en-US`; a localized picker will pass a locale through here rather than + * growing a second lookup somewhere else. + */ +export function monthFromName(name: string): number | null { + for (const pattern of ['MMMM', 'MMM']) { + const date = parse(name, pattern, PARSE_REFERENCE); + if (isValid(date)) return date.getMonth() + 1; + } + return null; +} + +function parseStrict(value: string): Date { + return parse(value, DAY_KEY_FORMAT, PARSE_REFERENCE); +} + +function pad(value: number, width: number): string { + return String(value).padStart(width, '0'); +} diff --git a/packages/raystack/components/calendar-preview/lib/parse.ts b/packages/raystack/components/calendar-preview/lib/parse.ts new file mode 100644 index 000000000..cd4cfb70b --- /dev/null +++ b/packages/raystack/components/calendar-preview/lib/parse.ts @@ -0,0 +1,146 @@ +/* + * Turning a typed string into a `ScaleValue` — pure functions, no React, no UI. + * + * A scale-aware input accepts more than one shape of date, because the scales + * are what the user is choosing between: `20/05/2027` is a day, `Q4` is a + * quarter, `2025` is a year. Parsing therefore decides both the date *and* the + * scale, and the two are returned together for the same reason the committed + * value carries its scale. + * + * Recognition is deliberately narrow. Every accepted shape is pinned by a + * regular expression before any date maths runs, so a near-miss is rejected + * rather than coerced: the failure mode this replaces is dayjs' + * `customParseFormat`, which is lenient enough to read `20/05/27` as the year + * 27. Anything not listed below returns `null` and the caller keeps the field's + * previous value. + */ +import { dayKeyFromParts, isDayKey, monthFromName } from '../date-adapter'; +import { anchorOf, periodOf, type ScaleValue } from './scale'; + +export interface ParseScaleInputOptions { + /** + * The year a bare `Q4`, `H1` or `May` resolves into. Defaults to now. + * + * See {@link parseScaleInput} for the inference rule. + */ + referenceDate?: Date; + /** + * Which edge of the parsed period to emit — the root's `trailingValue`. + * + * @defaultValue false + */ + trailing?: boolean; +} + +/* Day and month accept 1-2 digits so `5/5/2027` works; the year is pinned at + * exactly 4 so a two-digit year is rejected rather than read as year 27. */ +const DAY_SLASHED = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/; +const DAY_ISO = /^\d{4}-\d{2}-\d{2}$/; +const MONTH_NAMED = /^([A-Za-z]{3,9})(?:\s+(\d{4}))?$/; +const QUARTER = /^[Qq]([1-4])(?:\s+(\d{4}))?$/; +const HALF_YEAR = /^[Hh]([12])(?:\s+(\d{4}))?$/; +const YEAR = /^(\d{4})$/; + +/** + * Read a typed string as a date at whichever scale it names, or `null`. + * + * Accepted shapes — surrounding and repeated whitespace is ignored, and month + * names are case-insensitive: + * + * | Input | Scale | Notes | + * |---|---|---| + * | `20/05/2027`, `5/5/2027` | `day` | `dd/MM/yyyy`, day first | + * | `2027-05-20` | `day` | the canonical stored form, so it round-trips | + * | `May 2027`, `September 2027`, `Sep 2027` | `month` | | + * | `May` | `month` | year inferred | + * | `Q4 2026`, `Q4` | `quarter` | | + * | `H1 2026`, `H1` | `halfYear` | H1 is Jan-Jun, H2 is Jul-Dec | + * | `2025` | `year` | exactly four digits | + * + * **Year inference.** A bare `Q4`, `H1` or `May` resolves inside the *reference + * year* — the calendar year of `referenceDate`, which defaults to now. The rule + * never rolls forward: `Q1` typed in December 2026 is Q1 **2026**, not Q1 2027. + * A "next occurrence" rule would make the same typed string mean different + * years depending on the day it was typed — `Q1` would change meaning across + * midnight on 31 December, and a stored value would not agree with the string + * that produced it after a reload. A user who means another year types it. + * + * The returned date is the period's edge under `trailing`, matching what + * clicking that period in the calendar would commit — so typing `Q4 2026` and + * clicking Q4 2026 in an end field both yield `2026-12-31`. + * + * Rejected, among anything else unrecognised: a two-digit year (`20/05/27`), a + * month/year pair with no day (`05/2027`), a day that does not exist + * (`31/04/2027`, `29/02/2027`), and an out-of-range period (`Q5`, `H3`). + */ +export function parseScaleInput( + input: string, + options: ParseScaleInputOptions = {} +): ScaleValue | null { + const { referenceDate, trailing = false } = options; + const text = input.trim().replace(/\s+/g, ' '); + if (text === '') return null; + + const slashed = DAY_SLASHED.exec(text); + if (slashed) { + const key = dayKeyFromParts( + Number(slashed[3]), + Number(slashed[2]), + Number(slashed[1]) + ); + return key === null ? null : { date: key, scale: 'day' }; + } + + if (DAY_ISO.test(text)) { + return isDayKey(text) ? { date: text, scale: 'day' } : null; + } + + const quarter = QUARTER.exec(text); + if (quarter) { + const year = yearFrom(quarter[2], referenceDate); + return at(year, Number(quarter[1]) * 3 - 2, 'quarter', trailing); + } + + const half = HALF_YEAR.exec(text); + if (half) { + const year = yearFrom(half[2], referenceDate); + return at(year, half[1] === '1' ? 1 : 7, 'halfYear', trailing); + } + + const year = YEAR.exec(text); + if (year) { + return at(Number(year[1]), 1, 'year', trailing); + } + + const named = MONTH_NAMED.exec(text); + if (named) { + const month = monthFromName(named[1]); + if (month === null) return null; + return at(yearFrom(named[2], referenceDate), month, 'month', trailing); + } + + return null; +} + +/** The explicit year when the input carried one, else the reference year. */ +function yearFrom(matched: string | undefined, reference?: Date): number { + if (matched !== undefined) return Number(matched); + return (reference ?? new Date()).getFullYear(); +} + +/** + * The value for the period of `scale` that starts in `year`-`month`. + * + * `month` is the period's first month, so the first of it always exists and + * always lands inside the period — the edge maths is then `scale.ts`'s. + */ +function at( + year: number, + month: number, + scale: ScaleValue['scale'], + trailing: boolean +): ScaleValue | null { + const inside = dayKeyFromParts(year, month, 1); + if (inside === null) return null; + return { date: anchorOf(periodOf(inside, scale), trailing), scale }; +} diff --git a/packages/raystack/components/calendar-preview/lib/scale.ts b/packages/raystack/components/calendar-preview/lib/scale.ts new file mode 100644 index 000000000..4dfb23d0a --- /dev/null +++ b/packages/raystack/components/calendar-preview/lib/scale.ts @@ -0,0 +1,155 @@ +/* + * The scale maths from RFC 005 — pure functions, no React, no UI. + * + * Everything here is expressed in `DayKey`s (`'YYYY-MM-DD'`, timeless). Every + * date-library call goes through `../date-adapter`; this file makes none of + * its own. + */ +import { + type DayKey, + dayKey, + endOfMonthKey, + endOfQuarterKey, + endOfYearKey, + isDayKey, + monthOf, + startOfMonthKey, + startOfQuarterKey, + startOfYearKey +} from '../date-adapter'; + +/** The granularities a value can be selected at. */ +export type Scale = 'day' | 'month' | 'quarter' | 'halfYear' | 'year'; + +/** + * A committed selection: a concrete day, plus what that day *means*. + * + * The scale travels with the value rather than sitting in a prop, so a stored + * `{ date: '2026-08-31', scale: 'month' }` still reads back as August 2026 with + * no calendar mounted — see RFC 005, "The value carries its scale". + */ +export interface ScaleValue { + date: DayKey; + scale: Scale; +} + +/** The inclusive day span a period covers. */ +export interface Period { + start: DayKey; + end: DayKey; +} + +/** Every scale, finest first. */ +export const SCALES: readonly Scale[] = [ + 'day', + 'month', + 'quarter', + 'halfYear', + 'year' +]; + +/** Whether `value` is one of the five scales. */ +export function isScale(value: string): value is Scale { + return (SCALES as readonly string[]).includes(value); +} + +/** + * The period of `scale` that contains `date`. + * + * `halfYear` is ours to derive — no date library has it. H1 is January to June, + * H2 is July to December. + */ +export function periodOf(date: Date | DayKey, scale: Scale): Period { + const key = toKey(date); + switch (scale) { + case 'day': + return { start: key, end: key }; + case 'month': + return { start: startOfMonthKey(key), end: endOfMonthKey(key) }; + case 'quarter': + return { start: startOfQuarterKey(key), end: endOfQuarterKey(key) }; + case 'halfYear': { + /* The four half-year edges exist in every year, leap or not, so the key + * can be composed from the year segment directly. */ + const year = yearSegment(key); + return monthOf(key) <= 6 + ? { start: `${year}-01-01`, end: `${year}-06-30` } + : { start: `${year}-07-01`, end: `${year}-12-31` }; + } + case 'year': + return { start: startOfYearKey(key), end: endOfYearKey(key) }; + } +} + +/** + * The single day a period stands for: its last day when `trailing`, its first + * otherwise. + * + * `trailing` is the root's `trailingValue`. A start field emits the leading + * edge, an end field the trailing one — so the same period yields a different + * date at each end of a start–end pair. + */ +export function anchorOf(period: Period, trailing: boolean): DayKey { + return trailing ? period.end : period.start; +} + +/** + * Re-read a value at a different scale. + * + * One rule, every direction: take the value's date as the anchor, find the + * period of the target scale that contains it, and emit that period's edge per + * `trailing`. + * + * Converting outward is lossy and does not undo. `2026-08-15` at `'day'` + * becomes `2026-01-01` at `'year'` when leading, and converting that back to + * `'day'` yields `2026-01-01`, not the original — the anchor is all that + * survives. Converting to the scale a value already carries is idempotent for + * any value sitting on its own period's edge, which is what every function + * here emits. + */ +export function convertScale( + value: ScaleValue, + to: Scale, + trailing: boolean +): ScaleValue { + return { date: anchorOf(periodOf(value.date, to), trailing), scale: to }; +} + +/** + * Whether the period of `scale` containing `value` can be selected. + * + * The test is against **the date the period would produce**, not the period's + * start — so availability depends on `trailing`, and one period can be + * selectable in a start field and disabled in an end field. With + * `min = 2026-07-15` and `trailing`, July 2026 (emits 31 Jul) and Q3 2026 + * (emits 30 Sep) are available while H1 2026 (emits 30 Jun) is not. The two + * rules coincide whenever `trailing` is false. + * + * `min` and `max` are inclusive; either may be omitted for an open bound. They + * limit selection only — navigation is never clamped. + */ +export function isAvailable( + value: Date | DayKey, + scale: Scale, + trailing: boolean, + min?: Date | DayKey, + max?: Date | DayKey +): boolean { + const produced = anchorOf(periodOf(value, scale), trailing); + if (min !== undefined && produced < toKey(min)) return false; + if (max !== undefined && produced > toKey(max)) return false; + return true; +} + +function toKey(date: Date | DayKey): DayKey { + if (typeof date !== 'string') return dayKey(date); + if (!isDayKey(date)) { + throw new RangeError(`Not a YYYY-MM-DD day: ${JSON.stringify(date)}`); + } + return date; +} + +/** The `YYYY` of a key, as written — not parsed, so it never loses a leading zero. */ +function yearSegment(key: DayKey): string { + return key.slice(0, 4); +} diff --git a/packages/raystack/package.json b/packages/raystack/package.json index 1ea09e940..d497b80c0 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -114,8 +114,9 @@ "vitest": "^3.2.4" }, "dependencies": { - "@base-ui/react": "~1.6.0", - "@base-ui/utils": "~0.3.1", + "@base-ui/react": "~1.7.0", + "@base-ui/utils": "~0.3.2", + "@date-fns/tz": "^1.5.0", "@dnd-kit/core": "^6.3.1", "@tanstack/match-sorter-utils": "^8.8.4", "@tanstack/react-table": "^8.9.2", @@ -123,7 +124,8 @@ "@tanstack/table-core": "^8.9.2", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", - "dayjs": "^1.11.20", + "date-fns": "^4.1.0", + "dayjs": "^1.11.23", "prism-react-renderer": "^2.4.1", "prosemirror-commands": "^1.7.1", "prosemirror-history": "^1.4.1", @@ -131,7 +133,7 @@ "prosemirror-model": "^1.25.1", "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.40.0", - "react-day-picker": "^9.6.7" + "react-day-picker": "~10.0.1" }, "peerDependencies": { "@types/react": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f7baba41..1748aabcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,11 +170,14 @@ importers: packages/raystack: dependencies: '@base-ui/react': - specifier: ~1.6.0 - version: 1.6.0(@date-fns/tz@1.2.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ~1.7.0 + version: 1.7.0(@date-fns/tz@1.5.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@base-ui/utils': - specifier: ~0.3.1 - version: 0.3.1(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ~0.3.2 + version: 0.3.2(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@date-fns/tz': + specifier: ^1.5.0 + version: 1.5.0 '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -196,9 +199,12 @@ importers: culori: specifier: ^4.0.2 version: 4.0.2 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 dayjs: - specifier: ^1.11.20 - version: 1.11.20 + specifier: ^1.11.23 + version: 1.11.23 prism-react-renderer: specifier: ^2.4.1 version: 2.4.1(react@19.2.1) @@ -221,8 +227,8 @@ importers: specifier: ^1.40.0 version: 1.42.2 react-day-picker: - specifier: ^9.6.7 - version: 9.6.7(react@19.2.1) + specifier: ~10.0.1 + version: 10.0.1(@types/react@19.1.9)(react@19.2.1) devDependencies: '@figma/code-connect': specifier: ^1.4.7 @@ -411,8 +417,8 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.6.0': - resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==} + '@base-ui/react@1.7.0': + resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==} engines: {node: '>=14.0.0'} peerDependencies: '@date-fns/tz': ^1.2.0 @@ -428,8 +434,8 @@ packages: date-fns: optional: true - '@base-ui/utils@0.3.1': - resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==} + '@base-ui/utils@0.3.2': + resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -526,6 +532,9 @@ packages: '@date-fns/tz@1.2.0': resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -859,20 +868,23 @@ packages: '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - '@floating-ui/dom@1.7.4': - resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} '@floating-ui/dom@1.7.6': resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - '@floating-ui/react-dom@2.1.1': - resolution: {integrity: sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react-dom@2.1.8': - resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' @@ -880,6 +892,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} @@ -3020,6 +3035,9 @@ packages: dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -5100,6 +5118,16 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + react-day-picker@9.6.7: resolution: {integrity: sha512-rCSt6X8FXQWpjykns/azRXjJk3cMSzkzGbDEXuEveFGNZgOjZULdJQ5wsu8Zfyo8ZgPBoYCBKQ5wRrgJfhJGbg==} engines: {node: '>=18'} @@ -6295,7 +6323,7 @@ snapshots: '@ariakit/react-core@0.4.16(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@ariakit/core': 0.4.15 - '@floating-ui/dom': 1.7.4 + '@floating-ui/dom': 1.7.6 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) use-sync-external-store: 1.6.0(react@19.2.1) @@ -6422,24 +6450,24 @@ snapshots: '@babel/runtime@7.29.2': {} - '@base-ui/react@1.6.0(@date-fns/tz@1.2.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@base-ui/react@1.7.0(@date-fns/tz@1.5.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@babel/runtime': 7.29.2 - '@base-ui/utils': 0.3.1(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@floating-ui/utils': 0.2.11 + '@base-ui/utils': 0.3.2(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@floating-ui/utils': 0.2.12 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) use-sync-external-store: 1.6.0(react@19.2.1) optionalDependencies: - '@date-fns/tz': 1.2.0 + '@date-fns/tz': 1.5.0 '@types/react': 19.1.9 date-fns: 4.1.0 - '@base-ui/utils@0.3.1(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@base-ui/utils@0.3.2(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@babel/runtime': 7.29.2 - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.12 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) reselect: 5.2.0 @@ -6508,6 +6536,8 @@ snapshots: '@date-fns/tz@1.2.0': {} + '@date-fns/tz@1.5.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.1)': dependencies: react: 19.2.1 @@ -6718,30 +6748,36 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.11 - '@floating-ui/dom@1.7.4': + '@floating-ui/core@1.8.0': dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.12 '@floating-ui/dom@1.7.6': dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@floating-ui/dom': 1.7.6 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - '@floating-ui/react-dom@2.1.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@floating-ui/react-dom@2.1.9(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: - '@floating-ui/dom': 1.7.6 + '@floating-ui/dom': 1.8.0 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.12': {} + '@formatjs/intl-localematcher@0.6.2': dependencies: tslib: 2.8.1 @@ -7423,7 +7459,7 @@ snapshots: '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: - '@floating-ui/react-dom': 2.1.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.1) '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.1) @@ -9019,6 +9055,8 @@ snapshots: dayjs@1.11.20: {} + dayjs@1.11.23: {} + debug@4.4.1: dependencies: ms: 2.1.3 @@ -11631,6 +11669,14 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-day-picker@10.0.1(@types/react@19.1.9)(react@19.2.1): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.1.0 + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.9 + react-day-picker@9.6.7(react@19.2.1): dependencies: '@date-fns/tz': 1.2.0