Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
/**
|
|
* 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;
|
|
});
|
|
}
|