/** * hydrateEvents — convert raw API occurrences to Schedule-X event format. * * Schedule-X v4 requires: * - Temporal.PlainDate for all-day events * - Temporal.ZonedDateTime for timed events * It does NOT accept ISO strings for start/end. * * CRITICAL — calendarId routing: * occ.isShared → 'shared' * !occ.isShared → String(occ.ownerUserId) ← NOT String(occ.calendarId) * * occ.calendarId is the DB calendar-row id. buildCalendarConfig() keys its * calendars config by String(userId) and 'shared'. A member owning multiple * calendars (e.g. "Calendar" + "USA Holidays") would produce a calendarId * matching no config key if we used String(calendarId), rendering those events * with no color. Routing by String(ownerUserId) keeps one color slot per member. * * Temporal is registered as a global by 'temporal-polyfill/global', imported * in main.tsx before any Schedule-X code mounts. Tests must import * 'temporal-polyfill/global' at the top of hydrateEvents.test.ts. * * Source: https://schedule-x.dev/docs/calendar/events */ export interface CalendarOccurrence { id: string; // `${uid}::${dtstart_iso}` — stable identity for Schedule-X uid: string; calendarId: number; // DB calendar-row id — NOT used for calendarId routing calendarName: string; ownerUserId: number; // DB user id — this IS the routing key for personal events color: string; // hex from users.color or shared-family constant isShared: boolean; // true when this event belongs to the shared-family calendar title: string; start: string; // 'YYYY-MM-DD' for all-day; ISO 8601 with IANA tz for timed end: string; allDay: boolean; location: string | null; description: string | null; } export interface ScheduleXEvent { id: string; title: string; start: Temporal.ZonedDateTime | Temporal.PlainDate; end: Temporal.ZonedDateTime | Temporal.PlainDate; /** * Schedule-X calendarId — keys into the calendars config built by * buildCalendarConfig(). Routing: * 'shared' when isShared === true * String(ownerUserId) when isShared === false */ calendarId: string; location?: string; description?: string; /** FamilySync custom fields — carried through for popover rendering */ _familySync: { uid: string; color: string; isShared: boolean; }; } /** * Convert an array of CalendarOccurrence (server JSON) to Schedule-X events. * * All-day guard (Pitfall 2): allDay:true → Temporal.PlainDate.from(occ.start) * where occ.start is 'YYYY-MM-DD'. Never use ZonedDateTime for all-day events * or the date will shift in negative-offset timezones (UTC-N). */ export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[] { return occurrences.map((occ) => { // calendarId routing contract — must match buildCalendarConfig() keys const calendarId: string = occ.isShared ? 'shared' : String(occ.ownerUserId); if (occ.allDay) { // All-day: use Temporal.PlainDate — do NOT construct ZonedDateTime from // midnight UTC. occ.start and occ.end are 'YYYY-MM-DD' strings. // // Exclusive→inclusive end conversion: iCalendar all-day DTEND is EXCLUSIVE // (a single-day event on the 24th is DTSTART:24 / DTEND:25), and the server // occurrence carries that exclusive end. Schedule-X treats an all-day event's // `end` as INCLUSIVE (the last day it covers), so passing the exclusive DTEND // straight through renders every all-day event one day too long (a 1-day event // showed across two days). Subtract one day to get the inclusive last day, // clamped to never precede start. const startPd = Temporal.PlainDate.from(occ.start); const endExclusive = Temporal.PlainDate.from(occ.end); const endInclusive = endExclusive.subtract({ days: 1 }); const end = Temporal.PlainDate.compare(endInclusive, startPd) < 0 ? startPd : endInclusive; return { id: occ.id, title: occ.title, start: startPd, end, calendarId, _familySync: { uid: occ.uid, color: occ.color, isShared: occ.isShared, }, } satisfies ScheduleXEvent; } // Timed: use ZonedDateTime from the offset+IANA-annotated ISO string the server returns. // e.g. '2026-06-15T10:00:00-04:00[America/New_York]' return { id: occ.id, title: occ.title, start: Temporal.ZonedDateTime.from(occ.start), end: Temporal.ZonedDateTime.from(occ.end), calendarId, location: occ.location ?? undefined, description: occ.description ?? undefined, _familySync: { uid: occ.uid, color: occ.color, isShared: occ.isShared, }, } satisfies ScheduleXEvent; }); }