Files
familysync/apps/api/src/broker/expand.ts
T
Lucas Berger 44d336c01b feat(06-03): expose hasRrule on expanded occurrences
- Add hasRrule: boolean to CalendarOccurrence interface
- Capture isRecurring = event.isRecurring() once before the branch
- Set hasRrule: isRecurring in non-recurring push (always false)
- Set hasRrule: isRecurring in recurring push (always true)
- All 10 expand.test.ts tests pass (RED→GREEN)
2026-06-10 11:17:13 -04:00

314 lines
12 KiB
TypeScript

/**
* 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-<sanitized-uid>-<epochMs>` — 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-<sanitized-uid>-<epochMs>`
* - '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<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 ?? ''
// 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) as ICAL.Time
// 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
}