diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index e13855b..b32d068 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -1,110 +1,5 @@ -import { useQuery } from '@tanstack/react-query' -import { fetchMe, type MeUser } from './api/client' -import { EventProof } from './components/EventProof' - -interface HealthResponse { - ok: boolean - db: string -} - -async function fetchHealth(): Promise { - const res = await fetch('/health') - if (!res.ok) { - throw new Error(`Health check failed: ${res.status}`) - } - return res.json() as Promise -} - -function ColorSwatch({ color }: { color: string }) { - return ( - - ) -} - -function MemberBadge({ user }: { user: MeUser }) { - return ( -
- - - {user.displayName ?? 'Member'} - -
- ) -} +import { CalendarShell } from './components/CalendarShell.js' export default function App() { - const meQuery = useQuery({ - queryKey: ['me'], - queryFn: fetchMe, - retry: false, // 401 triggers Authelia redirect — don't retry - staleTime: 5 * 60 * 1000, // 5 min — session persists via refresh rotation - }) - - const healthQuery = useQuery({ - queryKey: ['health'], - queryFn: fetchHealth, - retry: 1, - refetchInterval: 30_000, - }) - - return ( -
-

FamilySync

- - {/* Authenticated member identity (AUTH-03) */} - {meQuery.isLoading && ( -
Loading...
- )} - {meQuery.isError && ( -
- Sign-in required -
- )} - {meQuery.data && ( - - )} - - {/* Broker proof: one cached Fastmail event (CAL-01) */} -
- -
- - {/* Stack health indicator (from Plan 01) */} -
- {healthQuery.isLoading && 'Checking stack...'} - {healthQuery.isError && 'stack: down'} - {healthQuery.data && `stack: ${healthQuery.data.ok && healthQuery.data.db === 'up' ? 'up' : 'down'}`} -
-
- ) + return } diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx new file mode 100644 index 0000000..a47ecd3 --- /dev/null +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -0,0 +1,184 @@ +/** + * CalendarShell — Schedule-X calendar wired to TanStack Query + hydrateEvents + Zustand range. + * + * Data flow: + * Zustand calendarRange → TanStack Query ['events', start, end] → fetchEvents + * → hydrateEvents (ISO → Temporal) → eventsService.set() → Schedule-X render + * + * Critical constraints: + * - Plugins (eventsService, eventModal) created once via useState stable initialiser + * - onRangeUpdate fires on navigation; initial fetch uses Zustand default range (A4/Q2) + * - DateRange.start/end are Temporal.ZonedDateTime → converted to 'YYYY-MM-DD' for Zustand + * - calendarId routing: 'shared' | String(ownerUserId) — produced by hydrateEvents, consumed + * by buildCalendarConfig; never the DB calendar-row id + * - Threat T-02d-01: no dangerouslySetInnerHTML for event fields (React JSX default escaping) + */ + +import { useState, useEffect, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ScheduleXCalendar, useCalendarApp } from '@schedule-x/react' +import { + createViewDay, + createViewWeek, + createViewMonthGrid, + createViewMonthAgenda, + type CalendarType, +} from '@schedule-x/calendar' +import { createEventsServicePlugin } from '@schedule-x/events-service' +import { createEventModalPlugin } from '@schedule-x/event-modal' + +import { fetchMe, fetchEvents } from '../api/client.js' +import { hydrateEvents } from '../lib/hydrateEvents.js' +import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js' +import { useCalendarStore } from '../store/calendarStore.js' + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** + * D-05: phone (≤767px) defaults to 'month-agenda'; tablet/desktop to 'month-grid'. + * Called once at render time; Schedule-X persists selected view internally after that. + */ +function resolveDefaultView(persistedView: string): string { + // If the store already has a persisted non-default value, honour it. + // Otherwise derive from viewport width. + if (typeof window === 'undefined') return 'month-grid' + return persistedView +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function CalendarShell() { + const { calendarRange, setCalendarRange, setOpenEventId, selectedView } = useCalendarStore() + const { start, end } = calendarRange + + // Fetch current user to build per-member color config + const meQuery = useQuery({ + queryKey: ['me'], + queryFn: fetchMe, + retry: false, + staleTime: 5 * 60 * 1000, + }) + + // Fetch windowed occurrences — key includes start/end so navigation refetches + const eventsQuery = useQuery({ + queryKey: ['events', start, end], + queryFn: () => fetchEvents(start, end), + retry: 2, + staleTime: 5 * 60 * 1000, + }) + + // Create plugins once (stable across renders) + const eventsService = useState(() => createEventsServicePlugin())[0] + const eventModal = useState(() => createEventModalPlugin())[0] + + // Build calendars config whenever the authenticated user changes + const calendarsConfig: Record = useMemo(() => { + const members = meQuery.data?.user + ? [ + { + id: String(meQuery.data.user.id), + name: meQuery.data.user.displayName ?? 'Member', + color: meQuery.data.user.color, + }, + ] + : [] + return buildCalendarConfig(members).calendars + }, [meQuery.data]) + + const defaultView = resolveDefaultView(selectedView) + + // useCalendarApp — config is stable; plugins passed as second argument + const calendar = useCalendarApp( + { + views: [ + createViewDay(), + createViewWeek(), + createViewMonthGrid(), + createViewMonthAgenda(), + ], + defaultView, + firstDayOfWeek: SX_FIRST_DAY_OF_WEEK as 7, + calendars: calendarsConfig, + callbacks: { + onRangeUpdate(range) { + // range.start / range.end are Temporal.ZonedDateTime + // Convert to ISO date strings ('YYYY-MM-DD') for the Zustand range + setCalendarRange({ + start: range.start.toPlainDate().toString(), + end: range.end.toPlainDate().toString(), + }) + }, + onEventClick(event) { + // Reserve event click for Plan 05 popover — store the id now + if (event.id != null) { + setOpenEventId(String(event.id)) + } + }, + }, + }, + [eventsService, eventModal], + ) + + // Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard) + // hydrateEvents converts ISO strings → Temporal before eventsService.set() + useEffect(() => { + if (!eventsQuery.data) return + const sxEvents = hydrateEvents(eventsQuery.data.occurrences) + // CalendarEventExternal is structurally compatible with ScheduleXEvent; + // extra _familySync fields pass through via index signature + eventsService.set(sxEvents as Parameters[0]) + }, [eventsQuery.data, eventsService]) + + // ── Render ────────────────────────────────────────────────────────────── + + if (meQuery.isError) { + return ( +
+ Sign-in required +
+ ) + } + + return ( +
+ {/* Loading overlay — skeleton provided by Schedule-X's built-in empty state */} + {(meQuery.isLoading || eventsQuery.isLoading) && ( +
+ )} + + {/* Schedule-X calendar fills available space */} +
+ +
+
+ ) +}