From 8964c2bb583d6eb4ebfeabc07287cd394002b23b Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 4 Sep 2026 04:33:01 +0530 Subject: [PATCH] chore: upgrade calendar dependencies and add the date foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of RFC 005. Moves the calendar dependencies to the versions the rewrite targets and lands `lib/` — the scale maths and input parsing — tested, before any UI exists to use it. Dependencies, in `packages/raystack` only: - `react-day-picker` ^9.6.7 -> ~10.0.1 - `@base-ui/react` ~1.6.0 -> ~1.7.0, which pins `@base-ui/utils` at 0.3.2 - `dayjs` ^1.11.20 -> ^1.11.23, kept because the old family still imports it; it goes with `components/calendar/` at phase 6 The v10 major needs no source change. It drops the 16 `@deprecated` v8-era props and the `Button` component slot, none of which this package references, and the `mode`/`required` union is unchanged from 9.6.7 — so the four component slots the family overrides (`PreviousMonthButton`, `NextMonthButton`, `Dropdown`, `DayButton`, `MonthGrid`) and all twenty `classNames` keys still resolve. The existing 97 calendar tests pass against it untouched. `date-fns` and `@date-fns/tz` become explicit dependencies. They already reached the published tarball through react-day-picker, but pnpm's isolated layout does not resolve a transitive dependency from our own source, and `nodeExternals({ deps: true })` only externalises what the manifest declares. Importing them undeclared would break both. `date-adapter.ts` is the one module allowed to call a date library, so the plugin set is a single fact in one place and the import-order-dependent `dayjs.extend()` failure class has nowhere to live. No `extend()` is added anywhere. Values cross `lib/` as `DayKey`s — `'YYYY-MM-DD'`, timeless — whose lexicographic order is chronological, so comparing a day against a bound needs no library call and cannot drift by a timezone. `lib/scale.ts` implements the model verbatim: conversion takes the anchor date, finds the period of the target scale containing it, and emits that period's start when leading or its end when trailing; availability tests the date the period would produce, not the period start, so July 2026 and Q3 2026 are selectable in an end field bounded at 15 July while H1 2026 is not. Month ends are calendar-correct — February 2028 trailing is 2028-02-29, February 2100 is 2100-02-28. `lib/parse.ts` reads a typed string as a date and a scale together. A bare `Q4`, `H1` or `May` resolves inside the reference year and never rolls forward, so the same string does not change meaning across midnight on 31 December; the rule is documented on the function and tested at both ends of a year. 100% statement, branch, function and line coverage on all three modules. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/date-adapter.test.ts | 166 +++++++ .../calendar-preview/__tests__/parse.test.ts | 229 ++++++++++ .../calendar-preview/__tests__/scale.test.ts | 412 ++++++++++++++++++ .../calendar-preview/date-adapter.ts | 183 ++++++++ .../components/calendar-preview/lib/parse.ts | 146 +++++++ .../components/calendar-preview/lib/scale.ts | 155 +++++++ packages/raystack/package.json | 10 +- pnpm-lock.yaml | 112 +++-- 8 files changed, 1376 insertions(+), 37 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts create mode 100644 packages/raystack/components/calendar-preview/__tests__/parse.test.ts create mode 100644 packages/raystack/components/calendar-preview/__tests__/scale.test.ts create mode 100644 packages/raystack/components/calendar-preview/date-adapter.ts create mode 100644 packages/raystack/components/calendar-preview/lib/parse.ts create mode 100644 packages/raystack/components/calendar-preview/lib/scale.ts 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