feat(02-03): hydrateEvents + calendarStore + windowed fetchEvents; RED stubs green
- Create hydrateEvents.ts: Temporal.PlainDate for allDay, ZonedDateTime for timed calendarId routes via isShared ? 'shared' : String(ownerUserId) — NOT String(calendarId) _familySync carries uid/color/isShared through to popover - Update hydrateEvents.test.ts: add temporal-polyfill/global import; all 4 RED stubs now GREEN - Create calendarStore.ts: Zustand store with selectedView (localStorage per breakpoint group), selectedDate, openEventId, calendarRange; D-05 view defaults; calendarRange ± buffer for initial TanStack Query key without depending on onRangeUpdate firing on mount - Update client.ts: add CalendarOccurrence/OccurrencesResponse, windowed fetchEvents(start,end) with credentials:include; keep legacy CalendarEvent/EventsResponse + fetchEventsLegacy as deprecated for EventProof.tsx (removed in Plan 05) - Update EventProof.tsx: switch to fetchEventsLegacy to keep build clean until Plan 05 - tsc --noEmit clean; all 18 PWA tests pass
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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.
|
||||
return {
|
||||
id: occ.id,
|
||||
title: occ.title,
|
||||
start: Temporal.PlainDate.from(occ.start),
|
||||
end: Temporal.PlainDate.from(occ.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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user