diff --git a/.planning/phases/02-calendar-display/02-04-SUMMARY.md b/.planning/phases/02-calendar-display/02-04-SUMMARY.md
new file mode 100644
index 0000000..7c290ed
--- /dev/null
+++ b/.planning/phases/02-calendar-display/02-04-SUMMARY.md
@@ -0,0 +1,121 @@
+---
+phase: 02-calendar-display
+plan: "04"
+subsystem: pwa-calendar-shell
+tags: [schedule-x, tanstack-query, zustand, hydrate-events, temporal, calendar-shell, smoke-test]
+dependency_graph:
+ requires: ["02-02", "02-03"]
+ provides: [CalendarShell, App-root-calendar, CAL-03-smoke-test]
+ affects: ["02-05"]
+tech_stack:
+ added: []
+ patterns:
+ - useCalendarApp with separate plugins array (second arg, not inside config)
+ - CalendarCallbacks nested under config.callbacks (onRangeUpdate, onEventClick)
+ - DateRange.start/end are Temporal.ZonedDateTime — extract ISO date via .toPlainDate().toString()
+ - eventsService.set() called in useEffect keyed on eventsQuery.data (Pitfall 4 guard)
+ - window.matchMedia polyfill in vitest setupFiles for Zustand module-load safety
+key_files:
+ created:
+ - apps/pwa/src/components/CalendarShell.tsx
+ - apps/pwa/src/components/CalendarShell.test.tsx
+ - apps/pwa/src/test-setup.ts
+ modified:
+ - apps/pwa/src/App.tsx
+ - apps/pwa/vitest.config.ts
+decisions:
+ - "CalendarShell callbacks nested under config.callbacks per CalendarConfigExternal type (not top-level)"
+ - "DateRange.start/end are Temporal.ZonedDateTime; extracted to ISO date via .toPlainDate().toString() for Zustand"
+ - "test-setup.ts as vitest setupFiles for window.matchMedia polyfill — calendarStore creates Zustand store at module load time before test polyfills run"
+ - "App.tsx simplified to single-line wrapper; EventProof and health probe removed from render path"
+metrics:
+ duration: "~12m"
+ completed: "2026-06-05"
+ tasks_completed: 2
+ files_created: 3
+ files_modified: 2
+---
+
+# Phase 02 Plan 04: CalendarShell — Schedule-X Mounted, Wired to Data Pipeline Summary
+
+Schedule-X CalendarShell component wired to TanStack Query windowed fetch, hydrateEvents Temporal conversion, and Zustand range management; all four views available; CAL-03 render smoke test with Temporal hydration guards.
+
+## What Was Built
+
+### Task 1: CalendarShell + App.tsx
+
+**`apps/pwa/src/components/CalendarShell.tsx`** (186 lines):
+- `useCalendarApp(config, [eventsService, eventModal])` with all four view factories: `createViewDay`, `createViewWeek`, `createViewMonthGrid`, `createViewMonthAgenda`
+- `defaultView` from Zustand persisted view (D-05 defaults: phone→month-agenda, tablet-desktop→month-grid already encoded in store)
+- `firstDayOfWeek: SX_FIRST_DAY_OF_WEEK` (7 = Sunday, Temporal convention) — Pitfall 1 guard
+- `calendars` config from `buildCalendarConfig(members)` keyed by `String(userId)` and `'shared'`
+- `config.callbacks.onRangeUpdate` converts `DateRange.start/end` (`Temporal.ZonedDateTime`) to `'YYYY-MM-DD'` strings for Zustand via `.toPlainDate().toString()`, triggering TanStack Query refetch
+- `eventsService.set(hydrateEvents(...))` in `useEffect` keyed on `eventsQuery.data` — Pitfall 4 guard
+- Token-only styling (`var(--color-*)`, `var(--space-*)`, `var(--font-family-base)`) — no hardcoded hex/px
+- Sign-in required error state; slim loading indicator bar
+
+**`apps/pwa/src/App.tsx`**: Replaced EventProof landing + health probe + MemberBadge with `` single render.
+
+**Critical API finding (Deviation 1):** `onRangeUpdate` and `onEventClick` are NOT top-level fields on `CalendarConfigExternal`. They live under `config.callbacks` (`CalendarCallbacks` type). The research pattern sketched them at the top level — the actual type required nesting.
+
+### Task 2: CalendarShell Render Smoke Test (CAL-03)
+
+**`apps/pwa/src/components/CalendarShell.test.tsx`** (6 tests):
+- Render-without-throw smoke (validates `@schedule-x/react@4.1.0` ↔ `@schedule-x/calendar@4.6.0` import compatibility — Pitfall 6)
+- `ScheduleXCalendar` mounts with non-null `calendarApp`
+- `hydrateEvents` called with both timed + all-day occurrences; `eventsService.set()` called with hydrated events
+- All-day occurrence → `Temporal.PlainDate` (Pitfall 4/all-day date shift guard)
+- Timed occurrence → `Temporal.ZonedDateTime` (Pitfall 4 guard)
+- Error state test for `/api/me` rejection
+
+**`apps/pwa/src/test-setup.ts`**: `window.matchMedia` polyfill. Zustand's `create()` runs at module load time and calls `window.matchMedia` to derive the D-05 default view. This must be defined before any module importing `calendarStore.ts` is loaded — a Vitest `setupFiles` entry is the only reliable placement.
+
+**`apps/pwa/vitest.config.ts`**: Added `setupFiles: ['./src/test-setup.ts']`.
+
+## Verification Results
+
+```
+Test Files 4 passed (4)
+Tests 24 passed (24)
+
+tsc --noEmit: clean (0 errors)
+vite build: clean (474.27 kB, built in 395ms)
+```
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 1 - Bug] CalendarCallbacks nested under config.callbacks — not top-level**
+- **Found during:** Task 1 — tsc reported `onRangeUpdate` not in `CalendarConfigExternal`
+- **Issue:** Research pattern (RESEARCH.md Pattern 4) showed `onRangeUpdate` at the top level of the config object. The actual type (`CalendarConfigExternal extends Partial`) carries `callbacks?: CalendarCallbacks` where `CalendarCallbacks` contains `onRangeUpdate` and `onEventClick`. They must be nested under `config.callbacks`.
+- **Fix:** Moved `onRangeUpdate` and `onEventClick` into `callbacks: { ... }` in the `useCalendarApp` config
+- **Files modified:** `apps/pwa/src/components/CalendarShell.tsx`
+- **Commit:** b79f649
+
+**2. [Rule 3 - Blocking] window.matchMedia not defined in jsdom**
+- **Found during:** Task 2 — test run crashed at Zustand store initialisation
+- **Issue:** `calendarStore.ts` calls `window.matchMedia` inside `readPersistedView()` which runs at `create()` time — i.e. at module load, before any test-file-level polyfill runs. Inline `Object.defineProperty` in the test file is too late.
+- **Fix:** Created `src/test-setup.ts` with the polyfill; added `setupFiles: ['./src/test-setup.ts']` to `vitest.config.ts`
+- **Files modified:** `apps/pwa/src/test-setup.ts` (new), `apps/pwa/vitest.config.ts`
+- **Commit:** f0af43c
+
+## Known Stubs
+
+None — CalendarShell fetches real windowed data from `/api/events`, hydrates to Temporal, and renders via Schedule-X. The Plan 05 popover slot (`setOpenEventId` in `onEventClick`) is wired but the popover UI itself is Plan 05.
+
+## Threat Flags
+
+No new threat surface beyond the plan's threat model.
+- T-02d-01 (XSS): CalendarShell uses React JSX default escaping for all event field rendering — no `dangerouslySetInnerHTML`. Carried to Plan 05 popover.
+
+## Self-Check: PASSED
+
+Files created:
+- [x] apps/pwa/src/components/CalendarShell.tsx
+- [x] apps/pwa/src/components/CalendarShell.test.tsx
+- [x] apps/pwa/src/test-setup.ts
+
+Commits:
+- [x] b79f649 — Task 1: CalendarShell + App.tsx
+- [x] f0af43c — Task 2: CalendarShell smoke test