From b967ebf1f41504841d085319c2fd86cc5cee8e5e Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Thu, 11 Jun 2026 08:56:36 -0400 Subject: [PATCH 1/2] feat(adapters): recurrence pre-pass expanding RRULEs into engine inputs (TIN-1996) Add src/adapters/recurrence.ts: RecurringHoursRule / RecurringBlock types and expandRecurrence(), which expands RFC 5545 recurrence rules into the availability engine's existing input types (HoursOverride, OccupiedBlock). Engine signatures untouched; recurrence stays a pure pre-pass. RRULE parsing is delegated to the new optional peer dependency @tummycrypt/tinyland-calendar (^0.2.3) via RecurrenceEngine.parseRRule, loaded lazily with the same dynamic-import posture as the HomegrownAdapter auth-pg schema fallback (actionable RecurrencePeerUnavailableError when absent). The occurrence walk is local: at 0.2.3 the upstream walk ignores BYDAY and mis-parses compact UNTIL, so the kit walks a validated RFC 5545 subset and throws UnsupportedRecurrenceError for anything it cannot expand faithfully instead of shipping wrong recurrence math. Adds the ./recurrence subpath export mirroring ./capabilities wiring. Refs TIN-1996 (slice 1 of 3) --- package.json | 9 + pnpm-lock.yaml | 9 + src/adapters/__tests__/recurrence.test.ts | 426 +++++++++++++ src/adapters/index.ts | 14 + src/adapters/recurrence.ts | 722 ++++++++++++++++++++++ 5 files changed, 1180 insertions(+) create mode 100644 src/adapters/__tests__/recurrence.test.ts create mode 100644 src/adapters/recurrence.ts diff --git a/package.json b/package.json index a68ca86..d25564e 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,10 @@ "types": "./dist/capabilities/index.d.ts", "default": "./dist/capabilities/index.js" }, + "./recurrence": { + "types": "./dist/adapters/recurrence.d.ts", + "default": "./dist/adapters/recurrence.js" + }, "./core": { "types": "./dist/core/index.d.ts", "default": "./dist/core/index.js" @@ -92,6 +96,7 @@ }, "peerDependencies": { "@tummycrypt/tinyland-auth-pg": "^0.2.1", + "@tummycrypt/tinyland-calendar": "^0.2.3", "@skeletonlabs/skeleton": "^4.0.0", "@skeletonlabs/skeleton-svelte": "^4.0.0", "svelte": "^5.0.0" @@ -100,6 +105,9 @@ "@tummycrypt/tinyland-auth-pg": { "optional": true }, + "@tummycrypt/tinyland-calendar": { + "optional": true + }, "@skeletonlabs/skeleton": { "optional": true }, @@ -120,6 +128,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", "@tummycrypt/tinyland-auth-pg": "^0.2.4", + "@tummycrypt/tinyland-calendar": "^0.2.3", "@types/node": "^20.0.0", "@typescript-eslint/parser": "^8.58.2", "@vitest/coverage-v8": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1567b61..1a62d08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@tummycrypt/tinyland-auth-pg': specifier: ^0.2.4 version: 0.2.4(@tummycrypt/tinyland-auth@0.2.0(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@6.4.1(@types/node@20.19.37)))(svelte@5.54.1)(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.37)))(svelte@5.54.1))(@types/pg@8.11.6) + '@tummycrypt/tinyland-calendar': + specifier: ^0.2.3 + version: 0.2.3 '@types/node': specifier: ^20.0.0 version: 20.19.37 @@ -755,6 +758,10 @@ packages: svelte: optional: true + '@tummycrypt/tinyland-calendar@0.2.3': + resolution: {integrity: sha512-afVYVzTro2p00VuksEUS5e6C5cSJk8W1WFxqFVJiJmGxC5wg8PxxFKIST1m7e0lDnIecrTiHjCkb/n84nPba5w==} + engines: {node: '>=22.0.0'} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -2909,6 +2916,8 @@ snapshots: '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@6.4.1(@types/node@20.19.37)))(svelte@5.54.1)(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.37)) svelte: 5.54.1 + '@tummycrypt/tinyland-calendar@0.2.3': {} + '@types/aria-query@5.0.4': {} '@types/chai@5.2.3': diff --git a/src/adapters/__tests__/recurrence.test.ts b/src/adapters/__tests__/recurrence.test.ts new file mode 100644 index 0000000..7305245 --- /dev/null +++ b/src/adapters/__tests__/recurrence.test.ts @@ -0,0 +1,426 @@ +/** + * Recurrence pre-pass tests (TIN-1996 slice 1) + * + * Calendar facts used below (verified): + * - 2026-06-01 is a Monday, 2026-06-03 a Wednesday + * - US DST starts Sunday 2026-03-08; 2026-03-02 is a Monday + * (09:00 EST = 14:00Z, 09:00 EDT = 13:00Z) + * - 2026 is not a leap year; 2026-01-31 exists, 2026-02-31 does not + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + expandRecurrence, + RecurrencePeerUnavailableError, + UnsupportedRecurrenceError, + type RecurringHoursRule, +} from '../recurrence.js'; + +const WINDOW = { opens: '09:00', closes: '17:00' }; +const JUNE = { start: '2026-06-01', end: '2026-06-30' }; + +const overrideDates = async (rule: RecurringHoursRule, range = JUNE) => { + const { overrides } = await expandRecurrence({ hours: [rule] }, range); + return overrides.map((o) => o.date); +}; + +describe('expandRecurrence — recurring hours rules', () => { + it('expands FREQ=WEEKLY;BYDAY=MO,WE,FR into per-date overrides', async () => { + const { overrides, occupied } = await expandRecurrence( + { + hours: [ + { + rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR', + dtstart: '2026-06-01', + window: WINDOW, + }, + ], + }, + { start: '2026-06-01', end: '2026-06-14' }, + ); + + expect(occupied).toEqual([]); + expect(overrides).toEqual([ + { date: '2026-06-01', opens: '09:00', closes: '17:00' }, + { date: '2026-06-03', opens: '09:00', closes: '17:00' }, + { date: '2026-06-05', opens: '09:00', closes: '17:00' }, + { date: '2026-06-08', opens: '09:00', closes: '17:00' }, + { date: '2026-06-10', opens: '09:00', closes: '17:00' }, + { date: '2026-06-12', opens: '09:00', closes: '17:00' }, + ]); + }); + + it('starts BYDAY expansion at dtstart, not at the top of its week', async () => { + await expect( + overrideDates( + { + rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR', + dtstart: '2026-06-03', // Wednesday — Mon 06-01 must not appear + window: WINDOW, + }, + { start: '2026-06-01', end: '2026-06-08' }, + ), + ).resolves.toEqual(['2026-06-03', '2026-06-05', '2026-06-08']); + }); + + it('defaults weekly recurrence to the dtstart weekday without BYDAY', async () => { + await expect( + overrideDates({ + rrule: 'RRULE:FREQ=WEEKLY', + dtstart: '2026-06-03', + window: WINDOW, + }), + ).resolves.toEqual([ + '2026-06-03', + '2026-06-10', + '2026-06-17', + '2026-06-24', + ]); + }); + + it('buckets INTERVAL=2 weekly BYDAY rules by week, not by occurrence', async () => { + await expect( + overrideDates( + { + rrule: 'RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR', + dtstart: '2026-06-01', + window: WINDOW, + }, + { start: '2026-06-01', end: '2026-06-28' }, + ), + ).resolves.toEqual([ + '2026-06-01', + '2026-06-05', + '2026-06-15', + '2026-06-19', + ]); + }); + + it('removes exdates after expansion', async () => { + await expect( + overrideDates({ + rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO', + dtstart: '2026-06-01', + window: WINDOW, + exdates: ['2026-06-08'], + }), + ).resolves.toEqual(['2026-06-01', '2026-06-15', '2026-06-22', '2026-06-29']); + }); + + it('consumes COUNT from dtstart even when the range starts later', async () => { + await expect( + overrideDates( + { + rrule: 'FREQ=DAILY;COUNT=5', + dtstart: '2026-06-01', + window: WINDOW, + }, + { start: '2026-06-03', end: '2026-06-30' }, + ), + ).resolves.toEqual(['2026-06-03', '2026-06-04', '2026-06-05']); + }); + + it('applies COUNT to the post-BYDAY occurrence set', async () => { + await expect( + overrideDates({ + rrule: 'FREQ=WEEKLY;BYDAY=MO,WE;COUNT=3', + dtstart: '2026-06-01', + window: WINDOW, + }), + ).resolves.toEqual(['2026-06-01', '2026-06-03', '2026-06-08']); + }); + + it('enforces RFC 5545 compact UNTIL (Invalid Date upstream at 0.2.3)', async () => { + await expect( + overrideDates({ + rrule: 'FREQ=DAILY;UNTIL=20260603T235959Z', + dtstart: '2026-06-01', + window: WINDOW, + }), + ).resolves.toEqual(['2026-06-01', '2026-06-02', '2026-06-03']); + }); + + it('treats date-only UNTIL as inclusive', async () => { + await expect( + overrideDates({ + rrule: 'FREQ=DAILY;UNTIL=20260602', + dtstart: '2026-06-01', + window: WINDOW, + }), + ).resolves.toEqual(['2026-06-01', '2026-06-02']); + }); +}); + +describe('expandRecurrence — recurring blocks', () => { + it('preserves wall-clock time across the DST boundary when timezone is set', async () => { + const { occupied } = await expandRecurrence( + { + blocks: [ + { + rrule: 'RRULE:FREQ=WEEKLY', + dtstart: '2026-03-02T09:00:00-05:00', // Mon 09:00 EST + durationMinutes: 60, + timezone: 'America/New_York', + }, + ], + }, + { start: '2026-03-01', end: '2026-03-22' }, + ); + + expect(occupied.map((b) => b.start.toISOString())).toEqual([ + '2026-03-02T14:00:00.000Z', // 09:00 EST + '2026-03-09T13:00:00.000Z', // 09:00 EDT — wall clock preserved + '2026-03-16T13:00:00.000Z', + ]); + expect(occupied.map((b) => b.end.toISOString())).toEqual([ + '2026-03-02T15:00:00.000Z', + '2026-03-09T14:00:00.000Z', + '2026-03-16T14:00:00.000Z', + ]); + }); + + it('keeps a fixed UTC cadence when no timezone is set', async () => { + const { occupied } = await expandRecurrence( + { + blocks: [ + { + rrule: 'FREQ=WEEKLY', + dtstart: '2026-03-02T14:00:00Z', + durationMinutes: 30, + }, + ], + }, + { start: '2026-03-01', end: '2026-03-22' }, + ); + + expect(occupied.map((b) => b.start.toISOString())).toEqual([ + '2026-03-02T14:00:00.000Z', + '2026-03-09T14:00:00.000Z', // drifts to 10:00 EDT locally — documented + '2026-03-16T14:00:00.000Z', + ]); + }); + + it('removes block exdates by exact instant and by date', async () => { + const { occupied } = await expandRecurrence( + { + blocks: [ + { + rrule: 'FREQ=WEEKLY', + dtstart: '2026-06-01T15:00:00Z', + durationMinutes: 45, + exdates: ['2026-06-08T15:00:00Z', '2026-06-15'], + }, + ], + }, + JUNE, + ); + + expect(occupied.map((b) => b.start.toISOString())).toEqual([ + '2026-06-01T15:00:00.000Z', + '2026-06-22T15:00:00.000Z', + '2026-06-29T15:00:00.000Z', + ]); + expect(occupied[0].end.toISOString()).toBe('2026-06-01T15:45:00.000Z'); + }); + + it('enforces UNTIL at instant precision for blocks', async () => { + const { occupied } = await expandRecurrence( + { + blocks: [ + { + rrule: 'FREQ=WEEKLY;UNTIL=20260608T150000Z', + dtstart: '2026-06-01T15:00:00Z', + durationMinutes: 30, + }, + ], + }, + JUNE, + ); + + expect(occupied.map((b) => b.start.toISOString())).toEqual([ + '2026-06-01T15:00:00.000Z', + '2026-06-08T15:00:00.000Z', // UNTIL is inclusive + ]); + }); +}); + +describe('expandRecurrence — monthly and yearly', () => { + it('expands MONTHLY;BYMONTHDAY across months', async () => { + await expect( + overrideDates( + { + rrule: 'RRULE:FREQ=MONTHLY;BYMONTHDAY=1,15', + dtstart: '2026-06-01', + window: WINDOW, + }, + { start: '2026-06-01', end: '2026-08-31' }, + ), + ).resolves.toEqual([ + '2026-06-01', + '2026-06-15', + '2026-07-01', + '2026-07-15', + '2026-08-01', + '2026-08-15', + ]); + }); + + it('skips months missing the anchor day (RFC 5545 monthly on the 31st)', async () => { + await expect( + overrideDates( + { + rrule: 'FREQ=MONTHLY', + dtstart: '2026-01-31', + window: WINDOW, + }, + { start: '2026-01-01', end: '2026-04-30' }, + ), + ).resolves.toEqual(['2026-01-31', '2026-03-31']); + }); + + it('expands YEARLY on the dtstart month/day', async () => { + await expect( + overrideDates( + { + rrule: 'FREQ=YEARLY', + dtstart: '2026-06-15', + window: WINDOW, + }, + { start: '2026-01-01', end: '2028-12-31' }, + ), + ).resolves.toEqual(['2026-06-15', '2027-06-15', '2028-06-15']); + }); +}); + +describe('expandRecurrence — unsupported constructs fail loudly', () => { + const hours = (rrule: string) => ({ + hours: [{ rrule, dtstart: '2026-06-01', window: WINDOW }], + }); + + it.each([ + ['BYSETPOS', 'FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2'], + ['BYDAY with MONTHLY', 'FREQ=MONTHLY;BYDAY=TU'], + ['ordinal BYDAY', 'FREQ=WEEKLY;BYDAY=2TU'], + ['BYMONTH', 'FREQ=YEARLY;BYMONTH=6'], + ['COUNT and UNTIL together', 'FREQ=DAILY;COUNT=3;UNTIL=20260610'], + ['missing FREQ', 'INTERVAL=2'], + ['sub-daily FREQ', 'FREQ=HOURLY'], + ['non-default WKST', 'FREQ=WEEKLY;WKST=SU'], + ['unknown token', 'FREQ=WEEKLY;BYWEEKNO=20'], + ])('rejects %s', async (_label, rrule) => { + await expect(expandRecurrence(hours(rrule), JUNE)).rejects.toThrow( + UnsupportedRecurrenceError, + ); + }); + + it('carries the offending rrule on the error', async () => { + const error = await expandRecurrence( + hours('FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2'), + JUNE, + ).catch((e: unknown) => e); + expect(error).toBeInstanceOf(UnsupportedRecurrenceError); + expect((error as UnsupportedRecurrenceError).code).toBe( + 'RECURRENCE_UNSUPPORTED', + ); + expect((error as UnsupportedRecurrenceError).rrule).toBe( + 'FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2', + ); + }); +}); + +describe('expandRecurrence — input validation', () => { + it('rejects non-YYYY-MM-DD dtstart for hours rules', async () => { + await expect( + expandRecurrence( + { + hours: [ + { rrule: 'FREQ=DAILY', dtstart: '06/01/2026', window: WINDOW }, + ], + }, + JUNE, + ), + ).rejects.toThrow(/YYYY-MM-DD/); + }); + + it('rejects non-positive durationMinutes', async () => { + await expect( + expandRecurrence( + { + blocks: [ + { + rrule: 'FREQ=DAILY', + dtstart: '2026-06-01T10:00:00Z', + durationMinutes: 0, + }, + ], + }, + JUNE, + ), + ).rejects.toThrow(/durationMinutes/); + }); + + it('rejects inverted ranges', async () => { + await expect( + expandRecurrence( + { hours: [{ rrule: 'FREQ=DAILY', dtstart: '2026-06-01', window: WINDOW }] }, + { start: '2026-06-30', end: '2026-06-01' }, + ), + ).rejects.toThrow(/precedes/); + }); +}); + +describe('expandRecurrence — optional peer @tummycrypt/tinyland-calendar', () => { + afterEach(() => { + vi.doUnmock('@tummycrypt/tinyland-calendar'); + vi.resetModules(); + }); + + it('never loads the peer for empty rule sets', async () => { + vi.resetModules(); + vi.doMock('@tummycrypt/tinyland-calendar', () => { + throw new Error("Cannot find module '@tummycrypt/tinyland-calendar'"); + }); + const mod = await import('../recurrence.js'); + + await expect(mod.expandRecurrence({}, JUNE)).resolves.toEqual({ + overrides: [], + occupied: [], + }); + }); + + it('rejects with an actionable error when the peer is absent', async () => { + vi.resetModules(); + vi.doMock('@tummycrypt/tinyland-calendar', () => { + throw new Error("Cannot find module '@tummycrypt/tinyland-calendar'"); + }); + const mod = await import('../recurrence.js'); + + const error = await mod + .expandRecurrence( + { + hours: [ + { rrule: 'FREQ=DAILY', dtstart: '2026-06-01', window: WINDOW }, + ], + }, + JUNE, + ) + .catch((e: unknown) => e); + + // Fresh module instance after resetModules — match shape, not identity. + expect(error).toMatchObject({ + name: 'RecurrencePeerUnavailableError', + code: 'RECURRENCE_PEER_UNAVAILABLE', + }); + expect((error as Error).message).toMatch( + /pnpm add @tummycrypt\/tinyland-calendar/, + ); + // Original import failure is preserved (vitest wraps the mock error). + expect((error as Error).message).toMatch(/Cause: /); + }); + + it('exposes the typed error class on the public surface', () => { + expect(new RecurrencePeerUnavailableError(new Error('x')).code).toBe( + 'RECURRENCE_PEER_UNAVAILABLE', + ); + }); +}); diff --git a/src/adapters/index.ts b/src/adapters/index.ts index e40b423..55408c8 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -22,6 +22,20 @@ export { type HomegrownContentSchemaTables, } from './homegrown.js'; +// Recurrence Pre-Pass (TIN-1996 slice 1). +// RRULE parsing uses the optional peer @tummycrypt/tinyland-calendar, +// loaded lazily inside expandRecurrence — safe to re-export statically. +export { + expandRecurrence, + RecurrencePeerUnavailableError, + UnsupportedRecurrenceError, + type RecurringHoursRule, + type RecurringBlock, + type RecurrenceExpansionInput, + type RecurrenceExpansionRange, + type RecurrenceExpansion, +} from './recurrence.js'; + // Availability Engine (pure functions) export { getAvailableSlots, diff --git a/src/adapters/recurrence.ts b/src/adapters/recurrence.ts new file mode 100644 index 0000000..777716e --- /dev/null +++ b/src/adapters/recurrence.ts @@ -0,0 +1,722 @@ +/** + * Recurrence Pre-Pass (TIN-1996, slice 1) + * + * Expands RFC 5545 recurrence rules into the availability engine's existing + * input types (`HoursOverride`, `OccupiedBlock`). The engine stays pure and + * date-local: recurrence never enters slot math. Feed the outputs of + * `expandRecurrence` straight into `getAvailableSlots` / `isSlotAvailable` / + * `getDatesWithAvailability` — their signatures are untouched. + * + * RRULE parsing is delegated to the optional peer dependency + * `@tummycrypt/tinyland-calendar` (`RecurrenceEngine.parseRRule`), loaded + * lazily via dynamic import — the same posture as the HomegrownAdapter's + * legacy auth-pg schema fallback. When the peer is not installed, + * `expandRecurrence` rejects with an actionable + * `RecurrencePeerUnavailableError` (and still resolves for empty rule sets, + * which never touch the peer). + * + * The occurrence walk is implemented locally, NOT via the peer's + * `RecurrenceEngine.generateOccurrences`. Verified upstream gaps at + * tinyland-calendar 0.2.3 (see TIN-1996 for the upstream issues): + * + * 1. `expandPattern` (the private walk behind `generateOccurrences`) ignores + * `BYDAY` entirely — `FREQ=WEEKLY;BYDAY=MO,WE,FR` steps 7 days from + * DTSTART and emits one occurrence per week on DTSTART's weekday. + * 2. `parseRRule` parses `UNTIL` with `new Date('YYYYMMDDTHHMMSSZ')`, which + * yields an Invalid Date for the RFC 5545 compact form, after which + * `UNTIL` is silently never enforced. + * 3. `parseRRule` maps a missing or unrecognized `FREQ` to `daily` instead + * of rejecting, and silently drops tokens it does not know. + * + * We therefore use `parseRRule` for tokenization, re-normalize `UNTIL` + * locally, scan the raw RRULE for tokens outside the supported subset, and + * run a local walk that throws `UnsupportedRecurrenceError` for anything it + * cannot expand faithfully (BYSETPOS, BYMONTH, ordinal BYDAY, BYDAY with + * MONTHLY/YEARLY, negative BYMONTHDAY, non-Monday WKST, sub-daily + * frequencies, …) rather than shipping wrong recurrence math. Once upstream + * exposes a correct `expandPattern(pattern, dtstart, rangeStart, rangeEnd)`, + * the walk here can be swapped for it. + * + * Supported subset: FREQ=DAILY|WEEKLY|MONTHLY|YEARLY, INTERVAL, COUNT, + * UNTIL (compact or ISO form, treated as UTC), BYDAY without ordinals for + * DAILY (filter) and WEEKLY (expansion), positive BYMONTHDAY for MONTHLY, + * WKST=MO, and EXDATE via the `exdates` fields. COUNT counts occurrences + * from DTSTART (occurrences before the requested range still consume it); + * EXDATE removes instances after COUNT is applied, per RFC 5545. + */ + +import type { + HoursOverride, + HoursWindow, + OccupiedBlock, +} from './availability-engine.js'; +import { parseTimeInTz } from './availability-engine.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** A recurring business-hours rule, expanded into `HoursOverride`s. */ +export interface RecurringHoursRule { + /** RFC 5545 RRULE, with or without the leading `RRULE:` prefix. */ + rrule: string; + /** Anchor date of the first occurrence (YYYY-MM-DD). */ + dtstart: string; + /** Hours applied on each occurrence date. */ + window: HoursWindow; + /** Occurrence dates to skip (YYYY-MM-DD). */ + exdates?: string[]; +} + +/** A recurring occupied block (recurring appointment, recurring closure). */ +export interface RecurringBlock { + /** RFC 5545 RRULE, with or without the leading `RRULE:` prefix. */ + rrule: string; + /** ISO 8601 start of the first occurrence. */ + dtstart: string; + /** Duration of each occurrence in minutes. */ + durationMinutes: number; + /** + * IANA timezone (e.g. `America/New_York`). When set, every occurrence + * keeps DTSTART's wall-clock time-of-day in this zone across DST + * transitions (RFC 5545 TZID semantics, minute precision). When unset, + * occurrences keep DTSTART's UTC time-of-day (fixed UTC cadence). + */ + timezone?: string; + /** + * Occurrences to skip: exact ISO 8601 instants, or YYYY-MM-DD dates + * (drops every occurrence on that calendar date — local date when + * `timezone` is set, UTC date otherwise). + */ + exdates?: string[]; +} + +export interface RecurrenceExpansionInput { + hours?: RecurringHoursRule[]; + blocks?: RecurringBlock[]; +} + +/** + * Inclusive expansion range. Each bound is either a YYYY-MM-DD date + * (interpreted as the full UTC day) or an ISO 8601 instant. + */ +export interface RecurrenceExpansionRange { + start: string; + end: string; +} + +export interface RecurrenceExpansion { + /** Date-specific hours, sorted by date. Later rules append; no dedup. */ + overrides: HoursOverride[]; + /** Occupied blocks, sorted by start. */ + occupied: OccupiedBlock[]; +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/** The optional peer `@tummycrypt/tinyland-calendar` could not be loaded. */ +export class RecurrencePeerUnavailableError extends Error { + readonly code = 'RECURRENCE_PEER_UNAVAILABLE'; + + constructor(cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + super( + 'expandRecurrence requires the optional peer dependency ' + + '@tummycrypt/tinyland-calendar (^0.2.3). Install it alongside ' + + '@tummycrypt/scheduling-kit to use the ./recurrence subpath ' + + `(e.g. \`pnpm add @tummycrypt/tinyland-calendar\`). Cause: ${message}`, + ); + this.name = 'RecurrencePeerUnavailableError'; + } +} + +/** + * The RRULE uses a construct the slice-1 walk cannot expand faithfully. + * Thrown instead of silently mis-expanding (see module docs for the + * supported subset and the upstream fidelity gaps). + */ +export class UnsupportedRecurrenceError extends Error { + readonly code = 'RECURRENCE_UNSUPPORTED'; + readonly rrule: string; + + constructor(detail: string, rrule: string) { + super( + `Unsupported recurrence construct in "${rrule}": ${detail}. ` + + 'See @tummycrypt/scheduling-kit/recurrence module docs for the ' + + 'supported RFC 5545 subset (TIN-1996).', + ); + this.name = 'UnsupportedRecurrenceError'; + this.rrule = rrule; + } +} + +// --------------------------------------------------------------------------- +// Optional peer loading (mirrors HomegrownAdapter's auth-pg fallback) +// --------------------------------------------------------------------------- + +interface PeerRecurrencePattern { + frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'; + interval: number; + count?: number; + until?: Date; + byDay?: string[]; + byMonth?: number[]; + byMonthDay?: number[]; + bySetPos?: number; +} + +type RRuleParser = (rrule: string) => PeerRecurrencePattern; + +let parserPromise: Promise | undefined; + +const loadRRuleParser = async (): Promise => { + try { + const mod = await import('@tummycrypt/tinyland-calendar'); + const engine = new mod.RecurrenceEngine(); + return (rrule: string) => engine.parseRRule(rrule) as PeerRecurrencePattern; + } catch (error) { + throw new RecurrencePeerUnavailableError(error); + } +}; + +// --------------------------------------------------------------------------- +// Date helpers (pure UTC calendar math — no DST in UTC) +// --------------------------------------------------------------------------- + +const DAY_MS = 86_400_000; +const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** Hard cap on candidate occurrences scanned per rule (~27 years daily). */ +const MAX_OCCURRENCE_SCAN = 10_000; + +const parseDateOnly = (value: string, label: string): Date => { + const m = DATE_ONLY.exec(value); + if (!m) { + throw new Error(`${label} must be a YYYY-MM-DD date, got "${value}"`); + } + const date = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); + if (Number.isNaN(date.getTime())) { + throw new Error(`${label} is not a valid date: "${value}"`); + } + return date; +}; + +const parseInstant = (value: string, label: string): Date => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`${label} is not a parseable ISO 8601 value: "${value}"`); + } + return date; +}; + +const formatDateOnly = (date: Date): string => date.toISOString().slice(0, 10); + +/** Wall-clock date + HH:MM of an instant in an IANA timezone. */ +const wallClockInTz = ( + instant: Date, + tz: string, +): { date: string; time: string } => { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(instant); + const get = (type: string): string => + parts.find((p) => p.type === type)?.value ?? ''; + const hour = get('hour') === '24' ? '00' : get('hour'); + return { + date: `${get('year')}-${get('month')}-${get('day')}`, + time: `${hour}:${get('minute')}`, + }; +}; + +// --------------------------------------------------------------------------- +// RRULE normalization +// --------------------------------------------------------------------------- + +/** JS UTC day numbers, indexed to match `Date#getUTCDay()`. */ +const DAY_CODE_TO_NUMBER: Record = { + SU: 0, + MO: 1, + TU: 2, + WE: 3, + TH: 4, + FR: 5, + SA: 6, +}; + +const SUPPORTED_FREQ = new Set(['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']); +const SUPPORTED_KEYS = new Set([ + 'FREQ', + 'INTERVAL', + 'COUNT', + 'UNTIL', + 'BYDAY', + 'BYMONTHDAY', + 'WKST', +]); + +interface NormalizedUntil { + /** Inclusive cutoff instant (UTC ms). */ + ms: number; + /** UTC date of the cutoff (YYYY-MM-DD), inclusive for date-typed walks. */ + dateStr: string; + /** True when UNTIL carried no time component. */ + dateOnly: boolean; +} + +interface WalkPattern { + frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'; + interval: number; + count?: number; + until?: NormalizedUntil; + /** JS UTC day numbers (validated, no ordinals). */ + byDay?: number[]; + /** Sorted, deduplicated positive month days. */ + byMonthDay?: number[]; +} + +const stripRRulePrefix = (rrule: string): string => + rrule.replace(/^RRULE:/i, '').trim(); + +/** + * Reject any raw token outside the supported subset. Mandatory because the + * peer's `parseRRule` silently drops tokens it does not recognize + * (BYSETPOS aside) and defaults unknown FREQ to daily. + */ +const scanRawTokens = (rrule: string): void => { + const body = stripRRulePrefix(rrule); + if (!body) throw new UnsupportedRecurrenceError('empty RRULE', rrule); + let freqSeen = false; + for (const part of body.split(';')) { + if (!part) continue; + const [rawKey, rawValue] = part.split('='); + const key = (rawKey ?? '').trim().toUpperCase(); + const value = (rawValue ?? '').trim().toUpperCase(); + if (!key || !rawValue) { + throw new UnsupportedRecurrenceError(`malformed token "${part}"`, rrule); + } + if (!SUPPORTED_KEYS.has(key)) { + throw new UnsupportedRecurrenceError(`token ${key} is not supported`, rrule); + } + if (key === 'FREQ') { + freqSeen = true; + if (!SUPPORTED_FREQ.has(value)) { + throw new UnsupportedRecurrenceError( + `FREQ=${value} is not supported (sub-daily frequencies excluded)`, + rrule, + ); + } + } + if (key === 'WKST' && value !== 'MO') { + throw new UnsupportedRecurrenceError( + `WKST=${value} is not supported (only the RFC default MO)`, + rrule, + ); + } + } + if (!freqSeen) { + throw new UnsupportedRecurrenceError('missing FREQ', rrule); + } +}; + +/** + * Normalize UNTIL from the raw RRULE. The peer parses UNTIL with + * `new Date()`, which cannot read the RFC 5545 compact forms + * (`YYYYMMDD`, `YYYYMMDDTHHMMSSZ`) — upstream gap #2. + */ +const normalizeUntil = ( + rrule: string, + peerUntil: Date | undefined, +): NormalizedUntil | undefined => { + const m = /(?:^|;)UNTIL=([^;]+)/i.exec(stripRRulePrefix(rrule)); + if (!m) return undefined; + const token = m[1].trim(); + const compact = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/.exec( + token, + ); + if (compact) { + const [, y, mo, d, hh, mi, ss] = compact; + const dateOnly = hh === undefined; + const ms = dateOnly + ? Date.UTC(Number(y), Number(mo) - 1, Number(d)) + : Date.UTC( + Number(y), + Number(mo) - 1, + Number(d), + Number(hh), + Number(mi), + Number(ss), + ); + if (Number.isNaN(ms)) { + throw new UnsupportedRecurrenceError(`invalid UNTIL "${token}"`, rrule); + } + return { ms, dateStr: `${y}-${mo}-${d}`, dateOnly }; + } + const fallback = + peerUntil && !Number.isNaN(peerUntil.getTime()) + ? peerUntil + : new Date(token); + if (Number.isNaN(fallback.getTime())) { + throw new UnsupportedRecurrenceError(`unparseable UNTIL "${token}"`, rrule); + } + return { + ms: fallback.getTime(), + dateStr: formatDateOnly(fallback), + dateOnly: DATE_ONLY.test(token), + }; +}; + +const buildPattern = (parser: RRuleParser, rrule: string): WalkPattern => { + scanRawTokens(rrule); + + let parsed: PeerRecurrencePattern; + try { + parsed = parser(rrule); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid RRULE "${rrule}": ${message}`); + } + + const interval = parsed.interval ?? 1; + if (!Number.isInteger(interval) || interval < 1) { + throw new UnsupportedRecurrenceError(`INTERVAL=${interval}`, rrule); + } + + if (parsed.count !== undefined && parsed.until !== undefined) { + throw new UnsupportedRecurrenceError( + 'COUNT and UNTIL are mutually exclusive per RFC 5545', + rrule, + ); + } + if ( + parsed.count !== undefined && + (!Number.isInteger(parsed.count) || parsed.count < 1) + ) { + throw new UnsupportedRecurrenceError(`COUNT=${parsed.count}`, rrule); + } + + let byDay: number[] | undefined; + if (parsed.byDay && parsed.byDay.length > 0) { + if (parsed.frequency !== 'daily' && parsed.frequency !== 'weekly') { + throw new UnsupportedRecurrenceError( + `BYDAY with FREQ=${parsed.frequency.toUpperCase()} (ordinal weekday rules)`, + rrule, + ); + } + byDay = parsed.byDay.map((code) => { + const normalized = code.trim().toUpperCase(); + const dayNumber = DAY_CODE_TO_NUMBER[normalized]; + if (dayNumber === undefined) { + throw new UnsupportedRecurrenceError( + `BYDAY value "${code}" (ordinal prefixes like 2TU are not supported)`, + rrule, + ); + } + return dayNumber; + }); + } + + let byMonthDay: number[] | undefined; + if (parsed.byMonthDay && parsed.byMonthDay.length > 0) { + if (parsed.frequency !== 'monthly') { + throw new UnsupportedRecurrenceError( + `BYMONTHDAY with FREQ=${parsed.frequency.toUpperCase()}`, + rrule, + ); + } + for (const day of parsed.byMonthDay) { + if (!Number.isInteger(day) || day < 1 || day > 31) { + throw new UnsupportedRecurrenceError( + `BYMONTHDAY value ${day} (only 1..31 supported)`, + rrule, + ); + } + } + byMonthDay = [...new Set(parsed.byMonthDay)].sort((a, b) => a - b); + } + + return { + frequency: parsed.frequency, + interval, + count: parsed.count, + until: normalizeUntil(rrule, parsed.until), + byDay, + byMonthDay, + }; +}; + +// --------------------------------------------------------------------------- +// Occurrence walk (local, RFC 5545 subset — see module docs) +// --------------------------------------------------------------------------- + +/** + * Infinite ascending generator of occurrence dates (UTC midnights) for a + * validated pattern, starting at `dtstart`. Callers bound it. + */ +function* patternOccurrences( + pattern: WalkPattern, + dtstart: Date, +): Generator { + const startMs = dtstart.getTime(); + + if (pattern.frequency === 'daily') { + for (let t = startMs; ; t += pattern.interval * DAY_MS) { + const d = new Date(t); + // RFC 5545: BYDAY acts as a filter for FREQ=DAILY. + if (!pattern.byDay || pattern.byDay.includes(d.getUTCDay())) yield d; + } + } + + if (pattern.frequency === 'weekly') { + const days = pattern.byDay ?? [dtstart.getUTCDay()]; + // Weeks start Monday (WKST=MO, the RFC default; others rejected). + const sinceMonday = (dtstart.getUTCDay() + 6) % 7; + const weekStartMs = startMs - sinceMonday * DAY_MS; + for (let week = weekStartMs; ; week += pattern.interval * 7 * DAY_MS) { + for (let i = 0; i < 7; i++) { + const t = week + i * DAY_MS; + if (t < startMs) continue; + const d = new Date(t); + if (days.includes(d.getUTCDay())) yield d; + } + } + } + + if (pattern.frequency === 'monthly') { + const anchorYear = dtstart.getUTCFullYear(); + const anchorMonth = dtstart.getUTCMonth(); + const monthDays = pattern.byMonthDay ?? [dtstart.getUTCDate()]; + for (let k = 0; ; k += pattern.interval) { + const year = anchorYear + Math.floor((anchorMonth + k) / 12); + const month = (anchorMonth + k) % 12; + for (const day of monthDays) { + const d = new Date(Date.UTC(year, month, day)); + // RFC 5545: skip months where the day does not exist (e.g. Feb 31). + if (d.getUTCMonth() !== month) continue; + if (d.getTime() < startMs) continue; + yield d; + } + } + } + + // yearly + const month = dtstart.getUTCMonth(); + const day = dtstart.getUTCDate(); + for (let year = dtstart.getUTCFullYear(); ; year += pattern.interval) { + const d = new Date(Date.UTC(year, month, day)); + // Feb 29 only recurs on leap years. + if (d.getUTCMonth() !== month) continue; + yield d; + } +} + +/** + * Bounded walk: all pattern occurrences from DTSTART through `endMs` + * (UTC-midnight comparison), honoring COUNT from DTSTART. Range filtering + * and UNTIL instant-precision happen in the callers. + */ +const walkOccurrences = ( + pattern: WalkPattern, + dtstart: Date, + endMs: number, +): Date[] => { + const occurrences: Date[] = []; + for (const d of patternOccurrences(pattern, dtstart)) { + if (d.getTime() > endMs) break; + occurrences.push(d); + if (occurrences.length > MAX_OCCURRENCE_SCAN) { + throw new Error( + `Recurrence expansion exceeded ${MAX_OCCURRENCE_SCAN} occurrences ` + + 'for a single rule; narrow the expansion range.', + ); + } + if (pattern.count !== undefined && occurrences.length >= pattern.count) { + break; + } + } + return occurrences; +}; + +// --------------------------------------------------------------------------- +// Expansion +// --------------------------------------------------------------------------- + +interface NormalizedRange { + startMs: number; + endMs: number; + startDateStr: string; + endDateStr: string; +} + +const normalizeRange = (range: RecurrenceExpansionRange): NormalizedRange => { + const startMs = DATE_ONLY.test(range.start) + ? parseDateOnly(range.start, 'range.start').getTime() + : parseInstant(range.start, 'range.start').getTime(); + const endMs = DATE_ONLY.test(range.end) + ? parseDateOnly(range.end, 'range.end').getTime() + DAY_MS - 1 + : parseInstant(range.end, 'range.end').getTime(); + if (endMs < startMs) { + throw new Error( + `range.end (${range.end}) precedes range.start (${range.start})`, + ); + } + return { + startMs, + endMs, + startDateStr: formatDateOnly(new Date(startMs)), + endDateStr: formatDateOnly(new Date(endMs)), + }; +}; + +const expandHoursRule = ( + parser: RRuleParser, + rule: RecurringHoursRule, + range: NormalizedRange, +): HoursOverride[] => { + const pattern = buildPattern(parser, rule.rrule); + const dtstart = parseDateOnly(rule.dtstart, 'RecurringHoursRule.dtstart'); + const exdates = new Set( + (rule.exdates ?? []).map((d) => + formatDateOnly(parseDateOnly(d, 'RecurringHoursRule.exdates[]')), + ), + ); + + const walkEndMs = Math.min( + parseDateOnly(range.endDateStr, 'range.end').getTime(), + pattern.until + ? parseDateOnly(pattern.until.dateStr, 'UNTIL').getTime() + : Number.POSITIVE_INFINITY, + ); + + const overrides: HoursOverride[] = []; + for (const occurrence of walkOccurrences(pattern, dtstart, walkEndMs)) { + const dateStr = formatDateOnly(occurrence); + if (dateStr < range.startDateStr) continue; + if (exdates.has(dateStr)) continue; + overrides.push({ + date: dateStr, + opens: rule.window.opens, + closes: rule.window.closes, + }); + } + return overrides; +}; + +const expandBlock = ( + parser: RRuleParser, + block: RecurringBlock, + range: NormalizedRange, +): OccupiedBlock[] => { + const pattern = buildPattern(parser, block.rrule); + if ( + !Number.isFinite(block.durationMinutes) || + block.durationMinutes <= 0 + ) { + throw new Error( + `RecurringBlock.durationMinutes must be positive, got ${block.durationMinutes}`, + ); + } + const durationMs = block.durationMinutes * 60_000; + const dtstartInstant = parseInstant(block.dtstart, 'RecurringBlock.dtstart'); + + let walkStart: Date; + let makeInstant: (dateStr: string) => Date; + if (block.timezone) { + const tz = block.timezone; + const wall = wallClockInTz(dtstartInstant, tz); + walkStart = parseDateOnly(wall.date, 'RecurringBlock.dtstart (local date)'); + makeInstant = (dateStr) => parseTimeInTz(dateStr, wall.time, tz); + } else { + walkStart = parseDateOnly( + formatDateOnly(dtstartInstant), + 'RecurringBlock.dtstart (UTC date)', + ); + const timeOfDayMs = dtstartInstant.getTime() - walkStart.getTime(); + makeInstant = (dateStr) => + new Date(parseDateOnly(dateStr, 'occurrence date').getTime() + timeOfDayMs); + } + + const exdateDays = new Set(); + const exdateInstants = new Set(); + for (const raw of block.exdates ?? []) { + if (DATE_ONLY.test(raw)) { + exdateDays.add(raw); + } else { + exdateInstants.add( + parseInstant(raw, 'RecurringBlock.exdates[]').getTime(), + ); + } + } + + const untilCapMs = pattern.until + ? pattern.until.dateOnly + ? parseDateOnly(pattern.until.dateStr, 'UNTIL').getTime() + DAY_MS - 1 + : pattern.until.ms + : Number.POSITIVE_INFINITY; + + // Walk in calendar-date space with a 2-day margin so timezone offsets + // (±14h) cannot drop edge occurrences; the instant filter is authoritative. + const walkEndMs = + parseDateOnly(range.endDateStr, 'range.end').getTime() + 2 * DAY_MS; + + const occupied: OccupiedBlock[] = []; + for (const occurrence of walkOccurrences(pattern, walkStart, walkEndMs)) { + const dateStr = formatDateOnly(occurrence); + const start = makeInstant(dateStr); + const startTimeMs = start.getTime(); + if (startTimeMs > untilCapMs) continue; + if (startTimeMs < range.startMs || startTimeMs > range.endMs) continue; + if (exdateDays.has(dateStr) || exdateInstants.has(startTimeMs)) continue; + occupied.push({ start, end: new Date(startTimeMs + durationMs) }); + } + return occupied; +}; + +/** + * Expand recurring hours rules and recurring blocks over an inclusive range + * into the availability engine's input types. + * + * Loads the optional peer `@tummycrypt/tinyland-calendar` on first use + * (only when there is at least one rule to expand); rejects with + * `RecurrencePeerUnavailableError` when it is absent and with + * `UnsupportedRecurrenceError` for RRULE constructs outside the supported + * subset (never silently mis-expands — see module docs). + */ +export const expandRecurrence = async ( + rules: RecurrenceExpansionInput, + range: RecurrenceExpansionRange, +): Promise => { + const hours = rules.hours ?? []; + const blocks = rules.blocks ?? []; + if (hours.length === 0 && blocks.length === 0) { + return { overrides: [], occupied: [] }; + } + + const normalizedRange = normalizeRange(range); + parserPromise ??= loadRRuleParser(); + let parser: RRuleParser; + try { + parser = await parserPromise; + } catch (error) { + parserPromise = undefined; // allow retry after the peer is installed + throw error; + } + + const overrides = hours.flatMap((rule) => + expandHoursRule(parser, rule, normalizedRange), + ); + const occupied = blocks.flatMap((block) => + expandBlock(parser, block, normalizedRange), + ); + + overrides.sort((a, b) => a.date.localeCompare(b.date)); + occupied.sort((a, b) => a.start.getTime() - b.start.getTime()); + return { overrides, occupied }; +}; From ebf5fb035dc699eb692370297bc8a62a0ab1d123 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Sat, 13 Jun 2026 18:23:56 -0400 Subject: [PATCH 2/2] fix(adapters): reject invalid recurrence dates --- src/adapters/__tests__/recurrence.test.ts | 40 +++++++++++++++++++++++ src/adapters/recurrence.ts | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/adapters/__tests__/recurrence.test.ts b/src/adapters/__tests__/recurrence.test.ts index 7305245..3ac0e88 100644 --- a/src/adapters/__tests__/recurrence.test.ts +++ b/src/adapters/__tests__/recurrence.test.ts @@ -342,6 +342,46 @@ describe('expandRecurrence — input validation', () => { ).rejects.toThrow(/YYYY-MM-DD/); }); + it('rejects impossible YYYY-MM-DD dates instead of normalizing them', async () => { + await expect( + expandRecurrence( + { + hours: [ + { rrule: 'FREQ=DAILY', dtstart: '2026-02-31', window: WINDOW }, + ], + }, + JUNE, + ), + ).rejects.toThrow(/not a valid date/); + }); + + it('rejects impossible exdates before filtering occurrences', async () => { + await expect( + expandRecurrence( + { + hours: [ + { + rrule: 'FREQ=WEEKLY', + dtstart: '2026-06-01', + window: WINDOW, + exdates: ['2026-06-31'], + }, + ], + }, + JUNE, + ), + ).rejects.toThrow(/not a valid date/); + }); + + it('rejects impossible date-only range bounds', async () => { + await expect( + expandRecurrence( + { hours: [{ rrule: 'FREQ=DAILY', dtstart: '2026-06-01', window: WINDOW }] }, + { start: '2026-06-01', end: '2026-06-31' }, + ), + ).rejects.toThrow(/not a valid date/); + }); + it('rejects non-positive durationMinutes', async () => { await expect( expandRecurrence( diff --git a/src/adapters/recurrence.ts b/src/adapters/recurrence.ts index 777716e..fc35146 100644 --- a/src/adapters/recurrence.ts +++ b/src/adapters/recurrence.ts @@ -197,7 +197,7 @@ const parseDateOnly = (value: string, label: string): Date => { throw new Error(`${label} must be a YYYY-MM-DD date, got "${value}"`); } const date = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); - if (Number.isNaN(date.getTime())) { + if (Number.isNaN(date.getTime()) || formatDateOnly(date) !== value) { throw new Error(`${label} is not a valid date: "${value}"`); } return date;