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)
This commit is contained in:
@@ -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<typeof ICAL.parse>
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user