From f377d7c3f869fc368af26c1a2d57db8bd9b860bb Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 09:47:07 -0400 Subject: [PATCH] feat(02-03): hydrateEvents + calendarStore + windowed fetchEvents; RED stubs green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/pwa/src/api/client.ts | 83 ++++++++++++++++-- apps/pwa/src/components/EventProof.tsx | 8 +- apps/pwa/src/lib/hydrateEvents.test.ts | 2 +- apps/pwa/src/lib/hydrateEvents.ts | 110 ++++++++++++++++++++++++ apps/pwa/src/store/calendarStore.ts | 113 +++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 13 deletions(-) create mode 100644 apps/pwa/src/lib/hydrateEvents.ts create mode 100644 apps/pwa/src/store/calendarStore.ts diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index f2f344a..b5d706f 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -9,6 +9,8 @@ * 302 redirect to Authelia on the next API call automatically (full-page nav). */ +// ── /api/me ──────────────────────────────────────────────────────────────── + export interface MeUser { id: number displayName: string | null @@ -33,9 +35,73 @@ export async function fetchMe(): Promise { return res.json() as Promise } +// ── /api/events (windowed — Phase 2) ───────────────────────────────────── + /** - * A single cached calendar event from the broker's MariaDB cache. - * Mirrors the calendarEvents table schema from apps/api/src/db/schema.ts. + * A single concrete occurrence of a calendar event. + * + * Mirrors the CalendarOccurrence shape produced by the backend's + * expandOccurrences() helper (apps/api/src/broker/expand.ts). + * + * calendarId and ownerUserId are both present: + * calendarId — DB calendar-row id (do NOT use for Schedule-X routing) + * ownerUserId — DB user id (use for Schedule-X calendarId routing) + * isShared — true when this event belongs to the shared-family calendar + * + * hydrateEvents() uses isShared/ownerUserId — never String(calendarId) — + * to build the Schedule-X calendarId that keys into buildCalendarConfig(). + */ +export interface CalendarOccurrence { + id: string // `${uid}::${dtstart_iso}` — stable identity + uid: string + calendarId: number // DB calendar-row id — do NOT use for SX calendarId routing + calendarName: string + ownerUserId: number // DB user id — the correct Schedule-X routing key + color: string // hex from users.color or shared-family constant + isShared: boolean // true → 'shared' slot; false → String(ownerUserId) slot + title: string + start: string // 'YYYY-MM-DD' for allDay:true; ISO 8601 with IANA tz for timed + end: string + allDay: boolean + location: string | null + description: string | null +} + +export interface OccurrencesResponse { + occurrences: CalendarOccurrence[] +} + +/** + * Fetch windowed calendar occurrences. + * + * The ?start=&end= window is mandatory — an unwindowed call would expand 500+ + * cached events with all their recurring occurrences (RESEARCH.md Pitfall 5). + * + * @param start ISO date string 'YYYY-MM-DD' — window start (inclusive) + * @param end ISO date string 'YYYY-MM-DD' — window end (exclusive) + */ +export async function fetchEvents( + start: string, + end: string, +): Promise { + const res = await fetch(`/api/events?start=${start}&end=${end}`, { + credentials: 'include', + }) + + if (!res.ok) { + throw new Error(`GET /api/events failed: ${res.status}`) + } + + return res.json() as Promise +} + +// ── Legacy types + function for EventProof (Phase 1 broker-proof component) ── +// EventProof.tsx uses these; the component is removed in Plan 05. +// Kept here to prevent build breakage until then. + +/** + * @deprecated Phase 1 raw cache shape — use CalendarOccurrence for Phase 2. + * EventProof uses this type; it is removed in Plan 05. */ export interface CalendarEvent { id: number @@ -49,24 +115,23 @@ export interface CalendarEvent { updatedAt: string | null } +/** + * @deprecated Phase 1 raw response shape — use OccurrencesResponse for Phase 2. + */ export interface EventsResponse { events: CalendarEvent[] } /** - * Fetches all cached calendar events from the broker's MariaDB cache. - * No live Fastmail call — the 5-min poller keeps the cache fresh. - * - * Used by EventProof to display the first cached event as broker proof (CAL-01). + * @deprecated Phase 1 broker-proof fetch — unwindowed, returns raw cache rows. + * Used only by EventProof.tsx; removed in Plan 05. */ -export async function fetchEvents(): Promise { +export async function fetchEventsLegacy(): Promise { const res = await fetch('/api/events', { credentials: 'include', }) - if (!res.ok) { throw new Error(`GET /api/events failed: ${res.status}`) } - return res.json() as Promise } diff --git a/apps/pwa/src/components/EventProof.tsx b/apps/pwa/src/components/EventProof.tsx index ba4891c..224b5b8 100644 --- a/apps/pwa/src/components/EventProof.tsx +++ b/apps/pwa/src/components/EventProof.tsx @@ -14,7 +14,9 @@ */ import { useQuery } from '@tanstack/react-query' -import { fetchEvents, type CalendarEvent } from '../api/client' +// fetchEventsLegacy: Phase 1 broker-proof; replaced by windowed fetchEvents in Plan 04. +// This component is removed in Plan 05. +import { fetchEventsLegacy, type CalendarEvent } from '../api/client' import ICAL from 'ical.js' /** @@ -57,8 +59,8 @@ function formatDate(event: CalendarEvent): string { export function EventProof() { const eventsQuery = useQuery({ - queryKey: ['events'], - queryFn: fetchEvents, + queryKey: ['events-legacy'], + queryFn: fetchEventsLegacy, retry: false, staleTime: 5 * 60 * 1000, // 5 min — matches broker poll interval }) diff --git a/apps/pwa/src/lib/hydrateEvents.test.ts b/apps/pwa/src/lib/hydrateEvents.test.ts index 794718a..8480d86 100644 --- a/apps/pwa/src/lib/hydrateEvents.test.ts +++ b/apps/pwa/src/lib/hydrateEvents.test.ts @@ -14,9 +14,9 @@ * This assertion locks the Plan 03 routing fix. */ +import 'temporal-polyfill/global' import { describe, it, expect } from 'vitest' -// Not yet built — import will fail (RED state) until Plan 03 implements hydrateEvents.ts import { hydrateEvents } from './hydrateEvents.js' // Minimal CalendarOccurrence shape for test purposes diff --git a/apps/pwa/src/lib/hydrateEvents.ts b/apps/pwa/src/lib/hydrateEvents.ts new file mode 100644 index 0000000..8ceda35 --- /dev/null +++ b/apps/pwa/src/lib/hydrateEvents.ts @@ -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 + }) +} diff --git a/apps/pwa/src/store/calendarStore.ts b/apps/pwa/src/store/calendarStore.ts new file mode 100644 index 0000000..e266433 --- /dev/null +++ b/apps/pwa/src/store/calendarStore.ts @@ -0,0 +1,113 @@ +/** + * Zustand UI-state store for the calendar shell. + * + * Owns ONLY UI-shape state — no server data ever enters this store. + * Server state (events, user profile) lives in TanStack Query. + * + * State contract (UI-SPEC §State Management Contract): + * selectedView — persisted in localStorage keyed by breakpoint group + * selectedDate — ISO string; NOT persisted + * openEventId — null when popover is closed + * calendarRange — { start, end } ISO date strings; drives TanStack Query key + * + * View default logic (D-05): + * phone (≤767px) → 'month-agenda' + * tablet-desktop (≥768px) → 'month-grid' + * + * localStorage keys: + * calendarView.phone — persisted view for phone breakpoint group + * calendarView.tablet-desktop — persisted view for tablet-desktop + * + * Note: calendarRange is initialized to today ± buffer (current month ± 1 week) + * so the first TanStack Query fetch uses a sensible window without depending on + * onRangeUpdate firing on mount (A4/Open Q2 from RESEARCH.md). + */ + +import { create } from 'zustand' + +// ── Types ────────────────────────────────────────────────────────────────── + +type BreakpointGroup = 'phone' | 'tablet-desktop' + +export interface CalendarStore { + selectedView: string + selectedDate: string + openEventId: string | null + calendarRange: { start: string; end: string } + + setSelectedView: (view: string) => void + setSelectedDate: (date: string) => void + setOpenEventId: (id: string | null) => void + setCalendarRange: (range: { start: string; end: string }) => void +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Determine breakpoint group from current viewport width. */ +function getBreakpointGroup(): BreakpointGroup { + if (typeof window === 'undefined') return 'tablet-desktop' + return window.matchMedia('(max-width: 767px)').matches ? 'phone' : 'tablet-desktop' +} + +/** localStorage key for the persisted view of a breakpoint group. */ +function viewStorageKey(group: BreakpointGroup): string { + return `calendarView.${group}` +} + +/** Read the persisted view for the current breakpoint group. */ +function readPersistedView(): string { + const group = getBreakpointGroup() + try { + const stored = localStorage.getItem(viewStorageKey(group)) + if (stored) return stored + } catch { + // localStorage may be unavailable (SSR, private mode) + } + // D-05 defaults: phone → 'month-agenda', tablet-desktop → 'month-grid' + return group === 'phone' ? 'month-agenda' : 'month-grid' +} + +/** Build the initial calendarRange: today ± ~1 week buffer (current month ± 7 days). */ +function initialCalendarRange(): { start: string; end: string } { + const today = new Date() + const start = new Date(today.getFullYear(), today.getMonth(), 1) + start.setDate(start.getDate() - 7) + const end = new Date(today.getFullYear(), today.getMonth() + 1, 0) + end.setDate(end.getDate() + 7) + return { + start: start.toISOString().slice(0, 10), + end: end.toISOString().slice(0, 10), + } +} + +/** Today as an ISO date string (YYYY-MM-DD). */ +function todayIso(): string { + return new Date().toISOString().slice(0, 10) +} + +// ── Store ────────────────────────────────────────────────────────────────── + +export const useCalendarStore = create((set) => ({ + selectedView: readPersistedView(), + selectedDate: todayIso(), + openEventId: null, + calendarRange: initialCalendarRange(), + + setSelectedView: (view: string) => { + set({ selectedView: view }) + // Persist to localStorage keyed by breakpoint group + const group = getBreakpointGroup() + try { + localStorage.setItem(viewStorageKey(group), view) + } catch { + // Ignore write failures + } + }, + + setSelectedDate: (date: string) => set({ selectedDate: date }), + + setOpenEventId: (id: string | null) => set({ openEventId: id }), + + setCalendarRange: (range: { start: string; end: string }) => + set({ calendarRange: range }), +}))