From 6736194a4a70372413c71bfad716e94a750c6a93 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 10:30:22 -0400 Subject: [PATCH] feat(02-02): add expandOccurrences() with VTIMEZONE + allDay split + EXDATE - ICAL.TimezoneService.register() runs before RecurExpansion (DST correctness) - All-day events serialized as YYYY-MM-DD strings (no time shift) - EXDATE exclusions handled internally by ICAL.RecurExpansion - Malformed rawVevent returns [] without throwing - expand.test.ts DST, all-day, EXDATE assertions green (3/3) --- apps/api/src/broker/expand.ts | 233 ++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 apps/api/src/broker/expand.ts diff --git a/apps/api/src/broker/expand.ts b/apps/api/src/broker/expand.ts new file mode 100644 index 0000000..d4556d3 --- /dev/null +++ b/apps/api/src/broker/expand.ts @@ -0,0 +1,233 @@ +/** + * expandOccurrences() — server-side recurrence expansion with VTIMEZONE + allDay split. + * + * Architecture invariant (D-09): + * Recurring events are expanded SERVER-SIDE. The client receives concrete occurrences only. + * This function must never be imported in apps/pwa. + * + * Key implementation notes: + * - VTIMEZONE components MUST be registered before constructing ICAL.RecurExpansion (Pitfall 3). + * Without this, ical.js falls back to UTC and DST transitions produce ±1h wall-clock errors. + * - All-day events (dtstart.isDate === true) are serialized as 'YYYY-MM-DD' strings (D-13). + * Never use midnight-UTC datetime for all-day events (Pitfall 2). + * - EXDATE exclusions are handled internally by ICAL.RecurExpansion — no manual filtering needed. + * - Malformed rawVevent returns [] without throwing (matches sync.ts resilience pattern). + * + * Sources: + * - https://github.com/kewisch/ical.js/wiki/Common-Use-Cases (RecurExpansion + VTIMEZONE pattern) + * - .planning/phases/02-calendar-display/02-RESEARCH.md §Pattern 1 + §Code Examples + */ + +import ICAL from 'ical.js' + +/** + * A concrete calendar event occurrence ready for UI consumption. + * Each field the Schedule-X frontend needs is present; no raw iCalendar blobs exposed. + * + * id: stable identity key = `${uid}::${startIso}` — Schedule-X uses this for dedup. + * start/end: + * - All-day: 'YYYY-MM-DD' (DATE string, no time component) — must use Temporal.PlainDate on client + * - Timed: offset-aware ISO-8601 e.g. '2026-06-01T10:00:00-04:00' — use Temporal.ZonedDateTime on client + * + * Color routing (D-06, CAL-02): + * The client routes calendarId for Schedule-X as: isShared ? 'shared' : String(ownerUserId) + * The DB calendarId is also present for reference but NOT used as the Schedule-X calendarId. + */ +export interface CalendarOccurrence { + /** `${uid}::${startIso}` — stable unique identity for this occurrence */ + id: string + uid: string + calendarId: number + calendarName: string + /** + * The Fastmail user ID who owns the calendar this occurrence belongs to. + * Client routes Schedule-X calendarId as String(ownerUserId) for personal calendars. + */ + ownerUserId: number + /** Hex color: users.color for personal calendars, '#F25C7A' for shared-family */ + color: string + /** True when this occurrence belongs to the shared-family calendar (calendars.isShared=true) */ + isShared: boolean + title: string + /** 'YYYY-MM-DD' for all-day events; offset-aware ISO string for timed events */ + start: string + /** 'YYYY-MM-DD' for all-day events; offset-aware ISO string for timed events */ + end: string + allDay: boolean + location: string | null + description: string | null +} + +/** + * Meta passed per-row from the events route join result. + * Carries color and ownership info from the calendars→users join. + */ +export interface OccurrenceMeta { + calendarId: number + calendarName: string + ownerUserId: number + /** Hex color: pre-computed by the route (users.color or shared-family constant) */ + color: string + isShared: boolean +} + +/** + * Format a UTC offset (in seconds) as ±HH:MM. + * ICAL.Time.utcOffset() returns total seconds (positive = east of UTC). + */ +function formatUtcOffset(offsetSeconds: number): string { + const sign = offsetSeconds < 0 ? '-' : '+' + const abs = Math.abs(offsetSeconds) + const hours = Math.floor(abs / 3600) + const minutes = Math.floor((abs % 3600) / 60) + return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}` +} + +/** + * Serialize an ICAL.Time to an offset-aware ISO 8601 string for timed events, + * or a plain 'YYYY-MM-DD' string for all-day events. + * + * For timed events: '2026-03-01T10:00:00-05:00' + * For all-day events: '2026-06-15' + */ +function serializeTime(t: ICAL.Time, allDay: boolean): string { + if (allDay) { + // All-day: return DATE-only string — never construct a datetime (Pitfall 2) + const y = String(t.year) + const m = String(t.month).padStart(2, '0') + const d = String(t.day).padStart(2, '0') + return `${y}-${m}-${d}` + } + + // Timed: build an offset-aware ISO string so the client can construct Temporal.ZonedDateTime + // ICAL.Time.toString() gives 'YYYY-MM-DDTHH:mm:ss' (no offset) — we append the offset. + const base = t.toString() // e.g. '2026-03-01T10:00:00' + if (t.zone === ICAL.Timezone.utcTimezone) { + // UTC zone: toString() appends 'Z' already — but toString() doesn't do this. + // We handle it explicitly. + return base + 'Z' + } + const offsetSec = t.utcOffset() + // If zone is floating (no registered timezone), utcOffset() returns 0 — treat as UTC + return base + formatUtcOffset(offsetSec) +} + +/** + * Expand a raw VCALENDAR/VEVENT string into concrete occurrences within [windowStart, windowEnd). + * + * @param rawVevent Full VCALENDAR string (as stored in calendarEvents.rawVevent) + * @param windowStart Start of the requested date window (inclusive) + * @param windowEnd End of the requested date window (exclusive) + * @param meta Calendar/ownership metadata from the JOIN result + * @returns Array of concrete CalendarOccurrence objects; [] on parse failure or no match + */ +export function expandOccurrences( + rawVevent: string, + windowStart: Date, + windowEnd: Date, + calendarId: number, + calendarName: string, + ownerUserId: number, + color: string, + isShared: boolean, +): CalendarOccurrence[] { + // --- 1. Parse VCALENDAR — return [] on malformed input (matches sync.ts pattern) --- + let parsed: ReturnType + try { + parsed = ICAL.parse(rawVevent) + } catch { + return [] + } + + const comp = new ICAL.Component(parsed) + + // --- 2. Register VTIMEZONE components BEFORE constructing RecurExpansion (MANDATORY) --- + // Skipping this causes ical.js to fall back to UTC, producing ±1h DST errors (Pitfall 3). + for (const vtz of comp.getAllSubcomponents('vtimezone')) { + const tzid = vtz.getFirstPropertyValue('tzid') as string + if (tzid && !ICAL.TimezoneService.has(tzid)) { + // TimezoneService.register(timezone, name?) — first arg is the Timezone object + ICAL.TimezoneService.register( + new ICAL.Timezone({ component: vtz, tzid }), + tzid, + ) + } + } + + // --- 3. Get the VEVENT component --- + const vevent = comp.getFirstSubcomponent('vevent') + if (!vevent) return [] + + const event = new ICAL.Event(vevent) + const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time + if (!dtstart) return [] + + const allDay: boolean = dtstart.isDate + const uid: string = event.uid ?? '' + + const rangeStart = ICAL.Time.fromJSDate(windowStart, false) + const rangeEnd = ICAL.Time.fromJSDate(windowEnd, false) + + const occurrences: CalendarOccurrence[] = [] + + // --- 4. Non-recurring event: single occurrence check --- + if (!event.isRecurring()) { + if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) { + const dtend = (vevent.getFirstPropertyValue('dtend') as ICAL.Time | null) ?? dtstart + const start = serializeTime(dtstart, allDay) + const end = serializeTime(dtend, allDay) + occurrences.push({ + id: `${uid}::${start}`, + uid, + calendarId, + calendarName, + ownerUserId, + color, + isShared, + title: event.summary ?? '', + start, + end, + allDay, + location: event.location ?? null, + description: event.description ?? null, + }) + } + return occurrences + } + + // --- 5. Recurring event: use ICAL.RecurExpansion --- + // RecurExpansion handles RRULE + RDATE + EXDATE internally — no manual EXDATE filtering (A1). + // VTIMEZONE was registered in step 2 above, so DST occurrences get correct wall-clock time. + const expand = new ICAL.RecurExpansion({ component: vevent, dtstart }) + + let next: ICAL.Time | null | undefined + while ((next = expand.next() as ICAL.Time | null | undefined) && next.compare(rangeEnd) < 0) { + if (next.compare(rangeStart) < 0) continue + + // Compute occurrence end from event duration + const duration = event.duration + const occEnd = next.clone() + occEnd.addDuration(duration) + + const start = serializeTime(next, allDay) + const end = serializeTime(occEnd, allDay) + + occurrences.push({ + id: `${uid}::${start}`, + uid, + calendarId, + calendarName, + ownerUserId, + color, + isShared, + title: event.summary ?? '', + start, + end, + allDay, + location: event.location ?? null, + description: event.description ?? null, + }) + } + + return occurrences +}