calendarRange drives query key; onRangeUpdate updates it
useCalendarStore
Deliver the phase's first true end-to-end user-facing slice: mount Schedule-X in a CalendarShell,
fetch the visible window from /api/events via TanStack Query, hydrate the occurrences to Temporal
events, feed them to Schedule-X's events service, and render the unified color-coded calendar with
all four views switchable. After this plan a household member can open the app and SEE their real
Fastmail calendar — color-coded, recurring + all-day correct — across day/week/month/agenda.
Purpose: This is where CAL-02, CAL-03, and the CAL-07 display path become observable to the user.
It consumes the Plan 02 endpoint and the Plan 03 token layer / config / hydration / store.
Output: CalendarShell.tsx wired to the full data pipeline; App.tsx renders it; a render smoke test.
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-calendar-display/02-UI-SPEC.md
@.planning/phases/02-calendar-display/02-RESEARCH.md
@.planning/phases/02-calendar-display/02-PATTERNS.md
@.planning/phases/02-calendar-display/02-03-SUMMARY.md
@.planning/phases/02-calendar-display/02-02-SUMMARY.md
Task 1: CalendarShell — Schedule-X mounted + wired to TanStack Query + hydrate + Zustand range
apps/pwa/src/components/CalendarShell.tsx, apps/pwa/src/App.tsx
- apps/pwa/src/App.tsx (current root: MemberBadge + EventProof + meQuery useQuery pattern lines 58–92; this becomes the CalendarShell host)
- apps/pwa/src/api/client.ts (fetchEvents(start,end), fetchMe — from Plan 03)
- apps/pwa/src/lib/hydrateEvents.ts (hydrateEvents signature + its calendarId routing: 'shared' | String(ownerUserId) — Plan 03)
- apps/pwa/src/lib/calendarConfig.ts (buildCalendarConfig keyed by String(userId) + 'shared', SX_FIRST_DAY_OF_WEEK, view factories — Plan 03)
- apps/pwa/src/store/calendarStore.ts (useCalendarStore: calendarRange, selectedView, setCalendarRange, setOpenEventId — Plan 03)
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 4: TanStack Query + onRangeUpdate Wiring" + §"Pitfall 4" + §"A4 note" (do not depend on onRangeUpdate for first fetch)
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"Component Inventory: CalendarShell" + §"View default logic (D-05)" + §"View Layout Specification"
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/components/CalendarShell.tsx"
Create `apps/pwa/src/components/CalendarShell.tsx`. Read the current user via `useQuery(['me'], fetchMe)` to source member colors, then build the Schedule-X calendars config with `buildCalendarConfig(members)` where members come from /api/me (current user). CRITICAL — the calendars config keys are `String(userId)` for each member plus the reserved `'shared'` entry; these MUST match the `calendarId` that hydrateEvents stamps on each event, which is `occ.isShared ? 'shared' : String(occ.ownerUserId)` (Plan 03 routing fix). Do NOT key the config by the DB calendar-row id (`occ.calendarId`) — events from a member who owns multiple calendars would then render with no color.
Create the events service and event-modal plugins ONCE via `useState(() => createEventsServicePlugin())[0]` / `useState(() => createEventModalPlugin())[0]` (stable across renders). Build the app with `useCalendarApp({ views: [createViewDay(), createViewWeek(), createViewMonthGrid(), createViewMonthAgenda()], defaultView: <month-agenda on phone, month-grid on tablet/desktop per D-05>, firstDayOfWeek: SX_FIRST_DAY_OF_WEEK, calendars, plugins: [eventsService, eventModal], onRangeUpdate(range){ setCalendarRange({start, end}) } })`.
Fetch events with `useQuery({ queryKey: ['events', calendarRange.start, calendarRange.end], queryFn: () => fetchEvents(calendarRange.start, calendarRange.end), retry: 2, staleTime: 5*60*1000 })`. The initial calendarRange comes from the Zustand default (current month ± 1 week) — do NOT rely on onRangeUpdate firing on mount (A4 / Open Q2). In a `useEffect` keyed on `eventsQuery.data`, call `eventsService.set(hydrateEvents(eventsQuery.data.occurrences))` (Pitfall 4: must hydrate to Temporal before set). Wire event click to `setOpenEventId` (popover is built in Plan 05; here just store the id — keep the customComponents.eventModal slot reserved for Plan 05).
Render `<ScheduleXCalendar calendarApp={calendar} />` filling the available space. Use token-based styling only (className/var(--token)) — no hard-coded hex/px (Phase 2 rule). The AppNav/ViewToolbar/ColorLegend/popover chrome is Plan 05; CalendarShell here may render a minimal toolbar placeholder or rely on Schedule-X's built-in controls so the four views are switchable and verifiable now.
Update `apps/pwa/src/App.tsx`: replace the EventProof landing content with `<CalendarShell />` as the app root. Migrate any remaining hard-coded hex/px in App.tsx to tokens (Phase 2 rule). Leave the meQuery sign-in-required error branch intact for unauthenticated state.
cd apps/pwa && grep -q "ScheduleXCalendar" src/components/CalendarShell.tsx && grep -q "hydrateEvents" src/components/CalendarShell.tsx && grep -q "queryKey: \['events'" src/components/CalendarShell.tsx && grep -q "CalendarShell" src/App.tsx && echo SHELL_WIRED
cd apps/pwa && grep -q "createViewDay" src/components/CalendarShell.tsx && grep -q "createViewWeek" src/components/CalendarShell.tsx && grep -q "createViewMonthGrid" src/components/CalendarShell.tsx && grep -q "createViewMonthAgenda" src/components/CalendarShell.tsx && echo ALL_FOUR_VIEWS
cd apps/pwa && pnpm exec tsc --noEmit
- CalendarShell.tsx mounts ScheduleXCalendar with all four view factories (day/week/month-grid/month-agenda)
- CalendarShell uses useQuery(['events', start, end]) → fetchEvents and calls eventsService.set(hydrateEvents(...)) in a data-keyed effect
- The calendars config is keyed by String(userId) + 'shared' (matching hydrateEvents' calendarId routing), NOT by the DB calendar-row id
- defaultView resolves to month-agenda on phone and month-grid on tablet/desktop (D-05)
- firstDayOfWeek passed as SX_FIRST_DAY_OF_WEEK (=7), not 0
- App.tsx renders CalendarShell as root; EventProof landing content removed from the render path
- No hard-coded hex/px in CalendarShell.tsx (token vars only); tsc --noEmit clean
Schedule-X renders real windowed Fastmail occurrences (color-coded via userId/shared-keyed config, all four views switchable) as the app root; PWA typechecks.
Task 2: CalendarShell render smoke test (CAL-03 — four views, real-data render path)
apps/pwa/src/components/CalendarShell.test.tsx
- apps/pwa/src/components/CalendarShell.tsx (Task 1 output — the component under test)
- apps/pwa/vitest.config.ts (jsdom env — Plan 01)
- apps/api → .planning/phases/02-calendar-display/02-RESEARCH.md §"Phase Requirements → Test Map" (CAL-03 smoke row) + §"Pitfall 6" (test the @schedule-x/react + @schedule-x/calendar integration in Wave 0)
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/vitest.config.ts" (jsdom test pattern)
- CalendarShell renders without throwing when all four views are configured (CAL-03 smoke; validates @schedule-x/react@4.1.0 + @schedule-x/calendar@4.6.0 compatibility — Pitfall 6/A2)
- Given a mocked fetchEvents returning one timed + one all-day occurrence, eventsService receives hydrated Temporal events (no ISO-string rejection — Pitfall 4)
Create `apps/pwa/src/components/CalendarShell.test.tsx` using @testing-library/react under jsdom. Import `'temporal-polyfill/global'` at top. Mock `../api/client` so `fetchMe` returns a member and `fetchEvents` returns `{ occurrences: [, ] }`. Wrap render in a QueryClientProvider with retry:false. Assert the component renders without throwing (the CAL-03 smoke from the test map) and that the Schedule-X root mounts. If asserting on eventsService internals is impractical, assert that hydrateEvents is invoked with the mocked occurrences (spy) and that no error is thrown for the all-day PlainDate event — this guards Pitfall 4 (Temporal hydration) and Pitfall 6 (adapter/core version compatibility) per the Wave 0 mandate.
cd apps/pwa && pnpm test -- src/components/CalendarShell.test.tsx
- CalendarShell.test.tsx renders the component under jsdom without throwing with all four views configured
- The test exercises both a timed and an all-day occurrence through the hydrate→eventsService path
- Test passes, confirming @schedule-x/react@4.1.0 ↔ @schedule-x/calendar@4.6.0 compatibility (A2/Pitfall 6 resolved)
CAL-03 render smoke test green; Schedule-X integration and Temporal hydration path validated under test.
<threat_model>
Trust Boundaries
Boundary
Description
/api/events JSON → calendar render
server occurrences rendered into the DOM via React
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-02d-01
Tampering (XSS)
event title/location/description in render
mitigate
React JSX default escaping; never dangerouslySetInnerHTML for event fields (carried into Plan 05 popover)
T-02d-02
Information disclosure
events from another member's account
accept
API already enforces auth + per-credential scoping (Plan 02 / CAL-08-DECISION); client renders only what the authed endpoint returns
</threat_model>
- `pnpm --filter @familysync/pwa test` green (CalendarShell smoke)
- `tsc --noEmit` clean in apps/pwa
- Manual (dev-auth bypass): app shows real color-coded events; all four views switch and render
<success_criteria>
Real Fastmail occurrences render color-coded across day/week/month/agenda (CAL-02, CAL-03)