/** * 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: IANA-annotated ISO-8601 e.g. '2026-06-01T10:00:00-04:00[America/New_York]' — * Temporal.ZonedDateTime.from() requires the IANA bracket; offset-only strings throw. * * 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 { /** `ev--` — Schedule-X-safe stable id (see makeOccurrenceId) */ 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; /** * Display name of the calendar owner (users.displayName). * Null when the user has not set a display name. * The popover uses this to show the owner name for personal events; * falls back to calendarName when null. */ ownerName: string | null; /** 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; IANA-annotated ISO string for timed events e.g. '2026-06-01T10:00:00-04:00[America/New_York]' */ start: string; /** 'YYYY-MM-DD' for all-day events; IANA-annotated ISO string for timed events e.g. '2026-06-01T11:00:00-04:00[America/New_York]' */ end: string; allDay: boolean; location: string | null; description: string | null; /** True when this occurrence belongs to a recurring series (has RRULE). False for single events. */ hasRrule: boolean; } /** * 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; /** Display name of the calendar owner; null when users.displayName is not set */ ownerName: string | null; /** Hex color: pre-computed by the route (users.color or shared-family constant) */ color: string; isShared: boolean; } /** * Build a Schedule-X-safe occurrence id. * * Schedule-X validates event ids against `document.querySelector` — the id must be a valid * CSS identifier (letters, digits, '-', '_'), must NOT contain ':', '[', ']', '+', and must * NOT start with a digit. The old `${uid}::${iso}` format violated this (the ISO timestamp * carries ':' and the '[IANA/Zone]' bracket), so eventsService.set() threw and blanked the * calendar. * * Format: `ev--` * - 'ev-' prefix guarantees a non-digit first character. * - uid is sanitized (any non [A-Za-z0-9_-] char → '_') since iCal UIDs may contain '@', '.'. * - epochMs (occurrence start instant) disambiguates recurring occurrences and is stable * across refetches/windows, so Schedule-X dedup and the popover id lookup keep matching. */ function makeOccurrenceId(uid: string, start: ICAL.Time): string { const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, '_'); const epochMs = start.toJSDate().getTime(); return `ev-${safeUid}-${epochMs}`; } /** * 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 IANA-annotated 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[America/New_York]' * The IANA bracket is REQUIRED — Temporal.ZonedDateTime.from() throws on offset-only * strings such as '2026-03-01T10:00:00-05:00'. See verified diagnosis in PLAN.md. * 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 IANA-annotated ISO string so the client can construct Temporal.ZonedDateTime. // ICAL.Time.toString() gives 'YYYY-MM-DDTHH:mm:ssZ' for UTC or 'YYYY-MM-DDTHH:mm:ss' for local. // We always emit '...±HH:MM[IANA/Zone]' — the bracket is mandatory for ZonedDateTime.from(). if (t.zone === ICAL.Timezone.utcTimezone) { // UTC zone: toString() produces '...Z'; strip the Z and emit +00:00[UTC]. const base = t.toString().replace(/Z$/, ''); // e.g. '2026-03-01T10:00:00' return `${base}+00:00[UTC]`; } const tzid = t.zone?.tzid; // Floating zone (tzid === 'floating') has no real timezone — fall back to UTC. // This is a safe degradation: the event had no VTIMEZONE and no offset is knowable. if (!tzid || tzid === 'floating') { const base = t.toString(); // no trailing Z for floating return `${base}+00:00[UTC]`; } // Named IANA zone: combine base datetime + offset + IANA bracket. const base = t.toString(); // e.g. '2026-03-01T10:00:00' const offsetSec = t.utcOffset(); return `${base}${formatUtcOffset(offsetSec)}[${tzid}]`; } /** * 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, ownerName: string | null, color: string, isShared: boolean, ): CalendarOccurrence[] { // --- 1. Parse VCALENDAR — return [] on malformed input (matches sync.ts pattern) --- let parsed: ReturnType; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it parsed = ICAL.parse(rawVevent); } catch { return []; } // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value 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 ?? ''; // useUTC=true: windowStart/windowEnd are absolute instants (midnight-UTC of the // requested dates). Interpreting them in UTC keeps the occurrence-window comparison // absolute. With useUTC=false ical.js used the SERVER's local timezone, shifting the // window by the server's offset and dropping evening occurrences near the window end // (e.g. a 17:45-04:00 event = 21:45Z fell past a day window whose end was shifted to 20:00). const rangeStart = ICAL.Time.fromJSDate(windowStart, true); const rangeEnd = ICAL.Time.fromJSDate(windowEnd, true); const occurrences: CalendarOccurrence[] = []; // Capture once — used in both the non-recurring and recurring branches to populate hasRrule. const isRecurring = event.isRecurring(); // --- 4. Non-recurring event: single occurrence check --- if (!isRecurring) { if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) { // Use ICAL.Event.endDate which derives end from DTEND, or DTSTART+DURATION, or sensible default. // Do NOT use getFirstPropertyValue('dtend') directly — events with only DURATION set return null, // producing zero-duration occurrences (BUG 1). let occEnd: ICAL.Time = event.endDate ?? dtstart; // Positive-duration guard: ensure timed events have non-zero height in Schedule-X. if (!allDay && occEnd.compare(dtstart) <= 0) { occEnd = dtstart.clone(); occEnd.addDuration(ICAL.Duration.fromString('PT30M')); } else if (allDay && occEnd.compare(dtstart) <= 0) { occEnd = dtstart.clone(); occEnd.addDuration(ICAL.Duration.fromString('P1D')); } const start = serializeTime(dtstart, allDay); const end = serializeTime(occEnd, allDay); occurrences.push({ id: makeOccurrenceId(uid, dtstart), uid, calendarId, calendarName, ownerUserId, ownerName, color, isShared, title: event.summary ?? '', start, end, allDay, location: event.location ?? null, description: event.description ?? null, hasRrule: isRecurring, // always false in the non-recurring branch }); } 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; let occEnd = next.clone(); occEnd.addDuration(duration); // Positive-duration guard: same logic as non-recurring branch above. if (!allDay && occEnd.compare(next) <= 0) { occEnd = next.clone(); occEnd.addDuration(ICAL.Duration.fromString('PT30M')); } else if (allDay && occEnd.compare(next) <= 0) { occEnd = next.clone(); occEnd.addDuration(ICAL.Duration.fromString('P1D')); } const start = serializeTime(next, allDay); const end = serializeTime(occEnd, allDay); occurrences.push({ id: makeOccurrenceId(uid, next), uid, calendarId, calendarName, ownerUserId, ownerName, color, isShared, title: event.summary ?? '', start, end, allDay, location: event.location ?? null, description: event.description ?? null, hasRrule: isRecurring, // always true in the recurring branch }); } return occurrences; }