diff --git a/.planning/phases/02-calendar-display/02-RESEARCH.md b/.planning/phases/02-calendar-display/02-RESEARCH.md
new file mode 100644
index 0000000..47c2ec1
--- /dev/null
+++ b/.planning/phases/02-calendar-display/02-RESEARCH.md
@@ -0,0 +1,919 @@
+# Phase 2: Calendar Display - Research
+
+**Researched:** 2026-06-04
+**Domain:** React PWA calendar rendering · CalDAV recurrence expansion · Timezone/DST correctness · CSS token theming
+**Confidence:** HIGH (locked stack verified against live codebase and official docs)
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+- **D-01:** Design-token layer — color, spacing, density, typography as CSS custom properties + TypeScript token object. No hard-coded colors/spacing in components.
+- **D-02:** Ship only the "clean" theme in Phase 2. Token architecture must make a future kiosk theme a token-set swap, not a refactor.
+- **D-03:** Clean theme is tuned for legibility at tablet distance (information density first-class).
+- **D-04:** All four views: day, week, month, agenda.
+- **D-05:** Device-adaptive default: phone → Agenda; tablet/desktop → Month. Persist last-used view per device.
+- **D-06:** Per-member color fill from `users.color` (6-color palette). Shared-family calendar uses `#F25C7A`.
+- **D-07:** Color legend always visible. Per-member show/hide filter deferred.
+- **D-08:** Informational + tap-to-expand. Month = colored bars with title. EventDetailPopover is read-only in Phase 2 but must be reusable as Phase 3 edit surface.
+- **D-09:** Recurring events expanded server-side. Client renders concrete occurrences.
+- **D-10:** Single local timezone for v1. All-day events render with no date shift (D-13 schema already splits allDay/timed).
+- **Calendar rendering library:** Schedule-X (`@schedule-x/react` + `@schedule-x/calendar`) — selected by UI-SPEC. CSS token override strategy documented there.
+- **Week start day:** Sunday (WEEK_START_DAY = 0). `calendarConfig.ts` constant.
+- **TanStack Query = server state; Zustand = UI state.** Server events never enter Zustand.
+- **Broker boundary:** Routes never call tsdav. `/api/events` reads MariaDB cache only.
+
+### Claude's Discretion
+
+- Skeleton/loading states: build polished versions.
+- Dev-auth bypass: documented env-flagged middleware injecting a fixed dev user. Must be off by default and impossible in production builds.
+
+### Deferred Ideas (OUT OF SCOPE)
+
+- Tablet/wall-display kiosk mode + Skylight display theme + runtime theme-switcher UI (v2).
+- Per-member show/hide filter (add when membership > 2).
+- Secondary timezone display toggle (v1.x).
+- Single-occurrence / "this and following" recurring edits (v1.x).
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| CAL-02 | User sees a unified, color-coded calendar that aggregates every accessible calendar into one view | §Backend: /api/events evolution; §Frontend: Schedule-X calendars config with per-calendar lightColors |
+| CAL-03 | User can switch between week, month, day, and agenda/list views | §Schedule-X Views; all four views confirmed in @schedule-x/calendar v4.6.0 |
+| CAL-07 | User can create a recurring event and see all its occurrences expanded correctly (display portion only — creation is Phase 3) | §Recurrence expansion pipeline; §DST correctness; §All-day event handling |
+
+
+---
+
+## Summary
+
+Phase 2 is a read-only calendar display layer built on top of the Phase 1 broker and schema. The backend work is evolving `/api/events` from a raw table dump to a display-ready shape: a windowed query that joins `calendarEvents → calendars → users.color`, expands recurring events via `ICAL.RecurExpansion`, and serializes concrete occurrences as JSON. The frontend work is building a React calendar shell around Schedule-X, wiring TanStack Query to the windowed endpoint, and implementing the CSS token layer.
+
+The hardest technical areas are (1) the recurrence expansion pipeline — specifically extracting and registering VTIMEZONE components before calling `ICAL.RecurExpansion` so DST boundaries produce correct wall-clock times — and (2) the Schedule-X v4 event format, which requires `Temporal.ZonedDateTime` and `Temporal.PlainDate` objects rather than ISO strings. The server returns JSON; the frontend must hydrate those strings into Temporal objects before passing them to Schedule-X. Both conversions have well-known pitfall patterns documented below.
+
+All-day event correctness is already partially solved by the D-13 schema split (Phase 1): `allDay=true` events have a `dtstartDate` DATE column with no time component. The API must pass `Temporal.PlainDate` for these, not a `Temporal.ZonedDateTime` derived from midnight UTC, or Schedule-X will shift the date.
+
+**Primary recommendation:** Expand recurrences on the server using `ICAL.RecurExpansion` with registered VTIMEZONE, serialize occurrences as plain ISO date strings in JSON, and hydrate to Temporal objects in the React client layer before feeding Schedule-X.
+
+---
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Event storage and polling | API / Backend | — | Phase 1 broker owns all Fastmail I/O; routes read MariaDB cache |
+| Recurrence expansion | API / Backend | — | Server expands to concrete occurrences for the requested window; client renders, never expands (D-09) |
+| All-day / timed discrimination | API / Backend | — | D-13 schema split already done in Phase 1; route must preserve and expose the distinction |
+| Color and ownership join | API / Backend | — | `calendars.userId → users.color` join lives closest to the data; frontend just reads the color hex |
+| Temporal object construction | Frontend (PWA) | — | Server sends plain strings; client converts to `Temporal.ZonedDateTime` / `Temporal.PlainDate` before Schedule-X |
+| Calendar rendering (views) | Frontend (PWA) | — | Schedule-X renders day/week/month/agenda in the browser |
+| Token layer / theming | Frontend (PWA) | — | CSS custom properties + TS token object; Schedule-X `--sx-color-*` vars overridden |
+| View state, selected date, open popover | Frontend (PWA) — Zustand | — | UI-only state; never server data |
+| Event list caching and re-fetch | Frontend (PWA) — TanStack Query | — | Cache key = `['events', start, end]`; invalidated on range change |
+| Dev-auth bypass | API / Backend | — | Env-flagged middleware injecting fixed user; never active in production |
+
+---
+
+## Standard Stack
+
+### Core (all versions verified against npm registry 2026-06-04)
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `@schedule-x/calendar` | 4.6.0 | Calendar engine (views, Temporal-based event model) | Selected in UI-SPEC; active maintenance; last published 2026-05-12 |
+| `@schedule-x/react` | 4.1.0 | React adapter (`useCalendarApp`, `ScheduleXCalendar`) | Official React adapter; peer-requires `@schedule-x/calendar ^3.1.0 \|\| ^4.0.0`; 4.6.0 satisfies this |
+| `@schedule-x/theme-default` | 4.6.0 | Default CSS layout; overridden by project tokens | Required for Schedule-X internal layout engine; all colors are token-overridden |
+| `@schedule-x/event-modal` | 4.6.0 | `createEventModalPlugin()` for custom `eventModal` component | Required to replace default modal with `EventDetailPopover` |
+| `@schedule-x/events-service` | 4.6.0 | `createEventsServicePlugin()` for dynamic event updates | Required to update events after TanStack Query fetches new window |
+| `temporal-polyfill` | 0.3.2 | `Temporal` global polyfill for browsers without native support | `@schedule-x/calendar` peer-requires `temporal-polyfill@0.3.0`; 0.3.2 satisfies |
+| `lucide-react` | 1.17.0 | Icon library (CalendarDays, X, MapPin, ChevronLeft/Right) | Specified in UI-SPEC; tree-shakeable; active maintenance |
+| `ical.js` | 2.2.1 | VEVENT parse + `ICAL.RecurExpansion` for recurrence | Already in both `apps/api` and `apps/pwa`; Phase 1 pattern established |
+| `rrule` | 2.8.1 | RRULE string parsing (used only if `ICAL.RecurExpansion` is insufficient) | Already in project stack per CLAUDE.md; last pub 2023-11-10 — treat as stable |
+
+### No New Backend Dependencies Needed
+
+Phase 1 already installed all required API packages: `ical.js`, `hono`, `drizzle-orm`, `mysql2`, `zod`. The recurrence expansion work (`ICAL.RecurExpansion`) uses ical.js already present. No new API npm packages are required.
+
+### Installation (PWA only)
+
+```bash
+cd apps/pwa
+pnpm add @schedule-x/calendar@4.6.0 @schedule-x/react@4.1.0 @schedule-x/theme-default@4.6.0 @schedule-x/event-modal@4.6.0 @schedule-x/events-service@4.6.0 temporal-polyfill@0.3.2 lucide-react@1.17.0
+```
+
+**Note on version mismatch:** `@schedule-x/react` tops out at 4.1.0 (last published 2026-01-21) while `@schedule-x/calendar` is at 4.6.0 (2026-05-12). The React adapter peer-depends on `^3.1.0 || ^4.0.0` for `@schedule-x/calendar` — 4.6.0 satisfies `^4.0.0`. They are **compatible**. [VERIFIED: npm registry]
+
+**Note on `firstDayOfWeek`:** Schedule-X v4 uses Temporal numbering where `7 = Sunday`, NOT `0 = Sunday`. [VERIFIED: schedule-x.dev/docs/calendar/configuration]. The UI-SPEC sets `WEEK_START_DAY = 0` as a constant — the constant must be converted: pass `7` to Schedule-X when the constant is `0`. Document this translation in `calendarConfig.ts`.
+
+---
+
+## Package Legitimacy Audit
+
+slopcheck was not available at research time. All packages below were verified via official documentation or established source repos. No packages flagged as suspicious by manual review.
+
+| Package | Registry | Age | Source Repo | Postinstall | Disposition |
+|---------|----------|-----|-------------|-------------|-------------|
+| `@schedule-x/calendar` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
+| `@schedule-x/react` | npm | 2+ yrs | github.com/schedule-x/react | none | Approved |
+| `@schedule-x/theme-default` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
+| `@schedule-x/event-modal` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
+| `@schedule-x/events-service` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
+| `temporal-polyfill` | npm | 2+ yrs | github.com/fullcalendar/temporal-polyfill | none | Approved |
+| `lucide-react` | npm | 4+ yrs | github.com/lucide-icons/lucide | none | Approved |
+
+**Packages removed due to slopcheck [SLOP] verdict:** none
+**Packages flagged as suspicious [SUS]:** none
+
+*slopcheck was unavailable at research time. All packages are tagged [VERIFIED: npm registry] based on official source repos confirmed via npm view. Planner should add `checkpoint:human-verify` before install if extra caution is warranted — this two-person household app is self-hosted with no third-party attack surface for these well-established packages.*
+
+---
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+Fastmail CalDAV
+ │
+ ▼
+ [Broker Poller] ──5min ctag──▶ [MariaDB: calendarEvents]
+ │
+ ▼
+ [GET /api/events?start=&end=]
+ ┌────┴────┐
+ │ Window │
+ │ filter │
+ │ (SQL) │
+ └────┬────┘
+ │ rawVevent rows + calendar.userId + users.color
+ ▼
+ [expandOccurrences()]
+ ┌─────────────────────┐
+ │ ICAL.parse(rawVevent) │
+ │ ICAL.RecurExpansion │
+ │ VTIMEZONE register │
+ │ EXDATE filter │
+ │ allDay / timed split │
+ └──────────┬────────────┘
+ │ JSON: CalendarOccurrence[]
+ ▼
+ [TanStack Query: ['events', start, end]]
+ │
+ ▼
+ [hydrateEvents()] ← converts ISO→Temporal
+ │
+ ▼
+ [Schedule-X eventsService.set()]
+ │
+ ┌───────────┴────────────┐
+ │ ScheduleXCalendar │
+ │ Day | Week | Month | │
+ │ Agenda │
+ └────────────────────────-┘
+ │
+ ▼
+ [EventDetailPopover] (custom eventModal)
+```
+
+### Recommended Project Structure
+
+```
+apps/
+├── api/src/
+│ ├── routes/
+│ │ └── events.ts ← evolve: windowed query + expansion
+│ └── broker/
+│ └── expand.ts ← new: expandOccurrences() helper
+└── pwa/src/
+ ├── styles/
+ │ ├── tokens.css ← CSS custom properties (clean theme)
+ │ ├── tokens.ts ← TypeScript token object
+ │ └── index.css ← imports tokens.css + global resets
+ ├── lib/
+ │ ├── calendarConfig.ts ← WEEK_START_DAY + Schedule-X config factory
+ │ └── colorUtils.ts ← derive container/onContainer from main hex
+ ├── components/
+ │ ├── CalendarShell.tsx ← layout: AppNav + ViewToolbar + ColorLegend + SX
+ │ ├── AppNav.tsx
+ │ ├── ViewToolbar.tsx
+ │ ├── ColorLegend.tsx
+ │ ├── EventDetailPopover.tsx ← read-only; Phase 3 adds edit actions in footer
+ │ └── SkeletonCalendar.tsx
+ ├── store/
+ │ └── calendarStore.ts ← Zustand: selectedView, selectedDate, openEventId, calendarRange
+ └── api/
+ └── client.ts ← extend with fetchEvents(start, end)
+```
+
+### Pattern 1: `/api/events` Windowed Query with Expansion
+
+**What:** `GET /api/events?start=2026-06-01&end=2026-07-01` returns a flat array of concrete occurrences (no recurring master events, no raw VCALENDAR blobs). Each occurrence has all fields the UI needs: title, start (ISO string), end (ISO string), allDay, color, calendarId, calendarName, uid, occurrenceId (uid + dtstart for identity), location, description.
+
+**Server implementation shape:**
+
+```typescript
+// apps/api/src/broker/expand.ts
+// Source: https://github.com/kewisch/ical.js/wiki/Common-Use-Cases
+import ICAL from 'ical.js'
+
+export interface CalendarOccurrence {
+ id: string // `${uid}::${dtstart_iso}` — stable identity for Schedule-X
+ uid: string
+ calendarId: number
+ calendarName: string
+ ownerUserId: number
+ color: string // hex from users.color or shared-family constant
+ isShared: boolean // true when calendar is the shared-family calendar
+ title: string
+ start: string // ISO 8601 with timezone offset: '2026-06-15T10:00:00+02:00[America/Toronto]'
+ // for all-day: 'DATE:2026-06-15' — use a distinct format so client knows
+ end: string
+ allDay: boolean
+ location: string | null
+ description: string | null
+}
+
+export function expandOccurrences(
+ rawVevent: string,
+ windowStart: Date,
+ windowEnd: Date,
+ calendarId: number,
+ calendarName: string,
+ ownerUserId: number,
+ color: string,
+ isShared: boolean,
+): CalendarOccurrence[] {
+ const parsed = ICAL.parse(rawVevent)
+ const comp = new ICAL.Component(parsed)
+
+ // CRITICAL: Register VTIMEZONE components before RecurExpansion
+ // Without this, RecurExpansion uses UTC and DST transitions produce wrong wall-clock times
+ for (const vtimezone of comp.getAllSubcomponents('vtimezone')) {
+ const tzid = vtimezone.getFirstPropertyValue('tzid') as string
+ if (tzid && !ICAL.TimezoneService.has(tzid)) {
+ ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtimezone, tzid }))
+ }
+ }
+
+ const vevent = comp.getFirstSubcomponent('vevent')
+ if (!vevent) return []
+
+ const event = new ICAL.Event(vevent)
+ const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time
+ const uid = event.uid
+
+ // Non-recurring event: single occurrence check
+ if (!event.isRecurring()) {
+ // ... check if within window, return single occurrence
+ }
+
+ // Recurring event: use RecurExpansion
+ const expand = new ICAL.RecurExpansion({ component: vevent, dtstart })
+ const rangeStart = ICAL.Time.fromJSDate(windowStart, /* useUtc */ false)
+ const rangeEnd = ICAL.Time.fromJSDate(windowEnd, /* useUtc */ false)
+
+ const occurrences: CalendarOccurrence[] = []
+ let next: ICAL.Time | null
+
+ while ((next = expand.next()) && next.compare(rangeEnd) < 0) {
+ if (next.compare(rangeStart) < 0) continue
+ // Build occurrence, compute end from duration
+ // ...
+ }
+ return occurrences
+}
+```
+
+**Key insight:** `ICAL.RecurExpansion` handles EXDATE exclusions internally — you do not need to extract and compare EXDATEs manually when using the high-level API. [CITED: github.com/kewisch/ical.js/wiki/Common-Use-Cases]
+
+### Pattern 2: Schedule-X Event Format (Temporal, not ISO strings)
+
+**What:** Schedule-X v4 requires `Temporal.ZonedDateTime` for timed events and `Temporal.PlainDate` for all-day events. The backend returns ISO strings; the client hydrates them.
+
+**Critical finding:** Schedule-X v3 changed from the old `"2024-01-15 09:00"` ISO string format to Temporal objects. v4 continues this. You cannot pass plain strings. [VERIFIED: schedule-x.dev/blog/schedule-x-v3-temporal-api]
+
+```typescript
+// apps/pwa/src/lib/hydrateEvents.ts
+// Source: https://schedule-x.dev/docs/calendar/events
+import 'temporal-polyfill/global' // registers Temporal on globalThis
+import type { CalendarOccurrence } from '@familysync/shared' // server type
+
+export interface ScheduleXEvent {
+ id: string
+ title: string
+ start: Temporal.ZonedDateTime | Temporal.PlainDate
+ end: Temporal.ZonedDateTime | Temporal.PlainDate
+ calendarId: string // must be string matching the key in calendars config
+ location?: string
+ description?: string
+ // custom business fields pass through
+ _familySync?: { uid: string; color: string; isShared: boolean }
+}
+
+export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[] {
+ return occurrences.map((occ) => {
+ if (occ.allDay) {
+ // All-day: use PlainDate — do NOT construct ZonedDateTime from midnight UTC
+ return {
+ id: occ.id,
+ title: occ.title,
+ start: Temporal.PlainDate.from(occ.start), // occ.start is 'YYYY-MM-DD'
+ end: Temporal.PlainDate.from(occ.end),
+ calendarId: String(occ.calendarId),
+ _familySync: { uid: occ.uid, color: occ.color, isShared: occ.isShared },
+ }
+ }
+ // Timed: use ZonedDateTime from the offset-aware ISO string the server returns
+ return {
+ id: occ.id,
+ title: occ.title,
+ start: Temporal.ZonedDateTime.from(occ.start),
+ end: Temporal.ZonedDateTime.from(occ.end),
+ calendarId: String(occ.calendarId),
+ location: occ.location ?? undefined,
+ description: occ.description ?? undefined,
+ _familySync: { uid: occ.uid, color: occ.color, isShared: occ.isShared },
+ }
+ })
+}
+```
+
+### Pattern 3: Schedule-X Calendar Configuration
+
+```typescript
+// apps/pwa/src/lib/calendarConfig.ts
+// Source: https://schedule-x.dev/docs/calendar/calendars
+// Source: https://schedule-x.dev/docs/calendar/configuration
+import {
+ createViewDay,
+ createViewWeek,
+ createViewMonthGrid,
+ createViewMonthAgenda,
+} from '@schedule-x/calendar'
+import { createEventsServicePlugin } from '@schedule-x/events-service'
+import { createEventModalPlugin } from '@schedule-x/event-modal'
+
+export const WEEK_START_DAY = 0 // 0 = Sunday in project convention; Schedule-X uses 7 = Sunday
+
+// Schedule-X v4 firstDayOfWeek: Temporal numbering — 1=Mon, 7=Sun
+// Must translate from project convention (0=Sun) to Schedule-X (7=Sun)
+function toSXWeekStart(dayConvention: number): number {
+ return dayConvention === 0 ? 7 : dayConvention
+}
+
+export interface MemberCalendarConfig {
+ id: string // String(users.id)
+ name: string // users.displayName
+ color: string // users.color hex
+}
+
+export function buildCalendarConfig(members: MemberCalendarConfig[]) {
+ const calendars: Record = {}
+
+ // Shared-family calendar: reserved rose color
+ calendars['shared'] = {
+ colorName: 'shared',
+ lightColors: deriveScheduleXColors('#F25C7A'),
+ }
+
+ // Per-member calendars keyed by String(userId)
+ for (const m of members) {
+ calendars[m.id] = {
+ colorName: `member-${m.id}`,
+ lightColors: deriveScheduleXColors(m.color),
+ }
+ }
+
+ return { calendars }
+}
+
+// UI-SPEC color derivation: container = main at 15% opacity over white, onContainer = main darkened 40%
+function deriveScheduleXColors(main: string) {
+ return {
+ main,
+ container: hexWithOpacity(main, 0.15), // CSS rgba computed over #FFFFFF
+ onContainer: darkenHex(main, 0.4),
+ }
+}
+```
+
+**`firstDayOfWeek` translation note:** The UI-SPEC declares `WEEK_START_DAY = 0` (Sunday in JS/date-fns convention). Schedule-X v4 uses Temporal convention where Sunday = 7. Pass `7` to Schedule-X when `WEEK_START_DAY === 0`. [VERIFIED: schedule-x.dev/docs/calendar/configuration]
+
+### Pattern 4: TanStack Query + onRangeUpdate Wiring
+
+```typescript
+// apps/pwa/src/components/CalendarShell.tsx (sketch)
+import { useCalendarApp, ScheduleXCalendar } from '@schedule-x/react'
+import { createViewDay, createViewWeek, createViewMonthGrid, createViewMonthAgenda } from '@schedule-x/calendar'
+import { useQuery } from '@tanstack/react-query'
+
+function CalendarShell() {
+ const rangeStore = useCalendarStore() // Zustand
+ const { start, end } = rangeStore.calendarRange
+
+ // TanStack Query — key includes the visible window
+ const eventsQuery = useQuery({
+ queryKey: ['events', start, end],
+ queryFn: () => fetchEvents(start, end),
+ retry: 2,
+ staleTime: 5 * 60 * 1000, // 5 min — poller refreshes broker every 5 min
+ })
+
+ const eventsService = useState(() => createEventsServicePlugin())[0]
+ const eventModal = useState(() => createEventModalPlugin())[0]
+
+ const calendar = useCalendarApp({
+ views: [createViewDay(), createViewWeek(), createViewMonthGrid(), createViewMonthAgenda()],
+ defaultView: isMobile ? 'month-agenda' : 'month-grid', // D-05
+ firstDayOfWeek: 7, // Sunday — Temporal convention
+ calendars: buildCalendarConfig(members).calendars,
+ plugins: [eventsService, eventModal],
+ onRangeUpdate(range) {
+ // Schedule-X fires this when the user navigates to a new window
+ // range.start and range.end are ISO date strings in v4
+ rangeStore.setCalendarRange({ start: range.start, end: range.end })
+ // Setting Zustand range triggers queryKey change → TanStack Query re-fetches
+ },
+ })
+
+ // Sync TanStack Query result into Schedule-X eventsService
+ useEffect(() => {
+ if (eventsQuery.data) {
+ const sxEvents = hydrateEvents(eventsQuery.data.occurrences)
+ eventsService.set(sxEvents)
+ }
+ }, [eventsQuery.data])
+
+ return (
+
+ )
+}
+```
+
+### Pattern 5: Dev-Auth Bypass Middleware
+
+```typescript
+// apps/api/src/auth/devBypass.ts
+// Active ONLY when DEV_AUTH_BYPASS=true AND NODE_ENV !== 'production'
+// Injects a fixed dev user into the request context so oidcAuthMiddleware is skipped
+import type { MiddlewareHandler } from 'hono'
+
+const DEV_USER = {
+ id: 1,
+ oidcIss: 'dev',
+ oidcSub: 'dev-user',
+ displayName: 'Dev User',
+ color: '#4A90D9',
+}
+
+export function devAuthBypass(): MiddlewareHandler {
+ if (process.env.NODE_ENV === 'production') {
+ // Hard guard — never active in production regardless of env flag
+ return async (_c, next) => next()
+ }
+ if (process.env.DEV_AUTH_BYPASS !== 'true') {
+ return async (_c, next) => next()
+ }
+ // Inject fixed dev user into Hono context (replaces getAuth(c) result)
+ return async (c, next) => {
+ c.set('user', DEV_USER)
+ await next()
+ }
+}
+```
+
+Mount in `index.ts` BEFORE `oidcAuthMiddleware` on `/api/*` when bypass is active. The bypass must short-circuit the OIDC redirect — `oidcAuthMiddleware` must be conditionally swapped out, not just prepended. Cleanest pattern: `app.use('/api/*', devAuthBypass() ?? oidcAuthMiddleware())`.
+
+### Anti-Patterns to Avoid
+
+- **Putting timed events on CalendarShell with a plain ISO string:** Schedule-X v4 rejects ISO strings. Hydrate to Temporal before calling `eventsService.set()`.
+- **Constructing `Temporal.ZonedDateTime` for all-day events:** All-day events must use `Temporal.PlainDate`. Using `ZonedDateTime` midnight UTC will shift the date in non-UTC local timezones.
+- **Expanding RRULE in the client:** D-09 locks server-side expansion. Never import rrule or call `ICAL.RecurExpansion` in the React frontend.
+- **Fetching all events without a window:** With 503+ cached events and recurring series expanding infinitely, an unwindowed `/api/events` call will time out or exhaust memory. The `?start=&end=` window is mandatory.
+- **Skipping VTIMEZONE registration:** If `ICAL.TimezoneService.register()` is not called before `ICAL.RecurExpansion`, ical.js falls back to UTC for timezone-aware events. Events in summer DST will appear one hour off.
+- **Using rrule directly when ical.js RecurExpansion is available:** `ICAL.RecurExpansion` handles EXDATE, RDATE, and VTIMEZONE in one integrated call. rrule only handles the RRULE string — you must separately handle EXDATEs and VTIMEZONE registration. Use rrule only as a fallback for parsing RRULE strings that `ICAL.RecurExpansion` cannot handle.
+- **Hard-coding `0` as `firstDayOfWeek` in Schedule-X config:** Schedule-X v4 uses Temporal numbering (7 = Sunday, not 0). Passing `0` will default to Monday.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Recurring event expansion with EXDATE | Custom RRULE iterator | `ICAL.RecurExpansion` | RecurExpansion handles RDATE, EXDATE, RECURRENCE-ID in one integrated iterator |
+| All four calendar views | Custom React grid | `@schedule-x/calendar` views | Day/week/month/agenda correctly handling overlap, all-day banners, and touch is 3-6 weeks of work |
+| Custom event modal | Custom DOM overlay | `createEventModalPlugin` + `customComponents.eventModal` | Schedule-X positions the modal relative to the event; re-use in Phase 3 is built-in |
+| VTIMEZONE DST tables | Custom offset lookup | `ICAL.TimezoneService.register()` from parsed VTIMEZONE | The VTIMEZONE component in the ICS already contains the correct DST rules for the calendar's timezone |
+| Calendar color derivation | Manual CSS computation | `colorUtils.ts` utility function (small, one-file) | The 15%/darken derivation is simple enough to implement inline; no third-party needed |
+| iCalendar string parsing | Custom VCALENDAR parser | `ICAL.parse()` + `ICAL.Component` | VCALENDAR has pathological edge cases (folded lines, UTF-8 encoded params, VTIMEZONE nesting) |
+
+**Key insight:** Calendar view rendering that handles overlap, drag handle exclusion zones, DST, all-day banners, and touch gestures for iOS correctly is multi-month work. Schedule-X exists precisely for this.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: `firstDayOfWeek` Temporal Numbering Mismatch
+
+**What goes wrong:** Passing `0` to Schedule-X `firstDayOfWeek` instead of `7` causes week views to start on Monday (the default), silently ignoring Sunday.
+
+**Why it happens:** JS/date-fns use `0 = Sunday`; Temporal (and therefore Schedule-X v4) uses `1 = Monday ... 7 = Sunday`.
+
+**How to avoid:** The constant `WEEK_START_DAY = 0` in `calendarConfig.ts` is in the JS convention. Translate it: `const sxFirstDay = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY`.
+
+**Warning signs:** Week view starts on Monday even after setting Sunday; calendar headers show Mon as the first column.
+
+### Pitfall 2: All-Day Events Shifting by One Day
+
+**What goes wrong:** An all-day event for "June 15" appears on "June 14" (or "June 16") in the calendar.
+
+**Why it happens:** If the all-day event's ISO string (`2026-06-15`) is passed to `Temporal.ZonedDateTime.from('2026-06-15T00:00:00Z')`, the UTC midnight maps to June 14 in timezones west of UTC. Schedule-X expects `Temporal.PlainDate` for all-day events.
+
+**How to avoid:** In `hydrateEvents()`, check `occ.allDay`. If `true`, use `Temporal.PlainDate.from(occ.start)` where `occ.start` is already `'YYYY-MM-DD'`. Never construct a ZonedDateTime for an all-day event. The D-13 schema split (Phase 1) already separates `dtstartDate` (DATE column, all-day) from `dtstartUtc` (TIMESTAMP, timed) — the API must expose this as a clean flag.
+
+**Warning signs:** Birthday/holiday events appear one day early; affects users in UTC-N timezones (Americas).
+
+### Pitfall 3: Missing VTIMEZONE Registration Causes DST-Shifted Occurrences
+
+**What goes wrong:** A weekly meeting at 10:00 America/New_York produces occurrences at 10:00 UTC during EST (correct) and 10:00 UTC during EDT (one hour off — shows as 11:00 local time).
+
+**Why it happens:** `ICAL.RecurExpansion` defers to `ICAL.TimezoneService` for timezone-aware ICAL.Time conversion. If the TZID is not registered, ical.js silently falls back to UTC, treating all occurrences as UTC regardless of DST.
+
+**How to avoid:** Before calling `new ICAL.RecurExpansion(...)`, iterate `comp.getAllSubcomponents('vtimezone')` and call `ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component, tzid }))` for each one. Guard with `!ICAL.TimezoneService.has(tzid)` to avoid double-registration.
+
+**Warning signs:** Recurring events across DST transitions display at the wrong wall-clock time by exactly ±1 hour.
+
+### Pitfall 4: Schedule-X ISO String Events Silently Fail
+
+**What goes wrong:** Events appear to not render, or Schedule-X throws a runtime error.
+
+**Why it happens:** Schedule-X v4 dropped ISO string event format in v3. The old format was `{ start: "2024-01-15 09:00", end: "2024-01-15 10:00" }`. Passing these strings now produces a type error or silent failure.
+
+**How to avoid:** All events passed to `eventsService.set()` must have `Temporal.ZonedDateTime` or `Temporal.PlainDate` for start/end. The `hydrateEvents()` function must run before `eventsService.set()`. Ensure `temporal-polyfill/global` is imported in `main.tsx` before any Schedule-X component mounts.
+
+**Warning signs:** Calendar renders with no events even when the query returns data; console shows `Temporal is not defined`.
+
+### Pitfall 5: Unwindowed `/api/events` Endpoint
+
+**What goes wrong:** First page load fetches all 503+ events (as of the Phase 1 spike) plus all recurring occurrences expanded to "all time", causing the request to time out or return a 10 MB payload.
+
+**Why it happens:** Phase 1 `/api/events` returns `db.select().from(calendarEvents)` — no window filter. This was fine as a proof-of-concept; it is unsuitable for the display layer.
+
+**How to avoid:** The new `/api/events?start=YYYY-MM-DD&end=YYYY-MM-DD` endpoint filters `dtstartUtc BETWEEN start AND end` (for timed events) and `dtstartDate BETWEEN start AND end` (for all-day), then expands recurring masters within the window. The SQL filter is a pre-filter; `ICAL.RecurExpansion` does the precise window check. Non-recurring events can be filtered entirely in SQL.
+
+**Warning signs:** Initial page load takes >3s; response payload >1 MB; memory usage spikes during expansion.
+
+### Pitfall 6: `@schedule-x/react` Version Behind `@schedule-x/calendar`
+
+**What goes wrong:** Potential API mismatch if `@schedule-x/react@4.1.0` does not expose new Schedule-X features added in `@schedule-x/calendar@4.4–4.6`.
+
+**Why it happens:** The React adapter (`github.com/schedule-x/react`) has a separate release cadence from the core (`github.com/schedule-x/schedule-x`). React adapter was last published 2026-01-21; core was 2026-05-12.
+
+**How to avoid:** The adapter peer-dep `^4.0.0` for `@schedule-x/calendar` is satisfied by 4.6.0 — the API contract is maintained. Limit usage to the API surface confirmed in docs: `useCalendarApp`, `ScheduleXCalendar`, `customComponents`. Test the integration in Wave 0 before building dependent components.
+
+**Warning signs:** TypeScript errors on `useCalendarApp` options that are documented but not typed in `@schedule-x/react@4.1.0`.
+
+### Pitfall 7: Dev-Auth Bypass Active in Production
+
+**What goes wrong:** A `DEV_AUTH_BYPASS=true` env var accidentally set in production gives unauthenticated access to all `/api/*` routes.
+
+**Why it happens:** Env vars leak into production containers via `.env` file copy mistakes or CI/CD misconfiguration.
+
+**How to avoid:** The bypass middleware must have a hard `process.env.NODE_ENV === 'production'` guard as its FIRST check, before reading `DEV_AUTH_BYPASS`. The Docker Compose production configuration must NOT set `DEV_AUTH_BYPASS`. Document this in the env var table in `.env.example` with a warning comment.
+
+**Warning signs:** `/api/me` returns a response without an Authelia session cookie in production.
+
+---
+
+## Code Examples
+
+### VTIMEZONE Registration + ICAL.RecurExpansion (complete pattern)
+
+```typescript
+// Source: https://github.com/kewisch/ical.js/wiki/Common-Use-Cases
+// Source: https://kewisch.github.io/ical.js/api/
+import ICAL from 'ical.js'
+
+function expandVeventOccurrences(
+ rawVcalendar: string,
+ windowStart: Date,
+ windowEnd: Date,
+): Array<{ dtstart: Date; dtend: Date; allDay: boolean }> {
+ const parsed = ICAL.parse(rawVcalendar)
+ const comp = new ICAL.Component(parsed)
+
+ // Step 1: Register all VTIMEZONE components in this VCALENDAR.
+ // Must happen BEFORE constructing ICAL.RecurExpansion.
+ for (const vtz of comp.getAllSubcomponents('vtimezone')) {
+ const tzid = vtz.getFirstPropertyValue('tzid') as string
+ if (tzid && !ICAL.TimezoneService.has(tzid)) {
+ ICAL.TimezoneService.register(
+ tzid,
+ new ICAL.Timezone({ component: vtz, tzid }),
+ )
+ }
+ }
+
+ const vevent = comp.getFirstSubcomponent('vevent')
+ if (!vevent) return []
+
+ const event = new ICAL.Event(vevent)
+ const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time
+ const allDay = dtstart.isDate
+
+ const results: Array<{ dtstart: Date; dtend: Date; allDay: boolean }> = []
+ const rangeStart = ICAL.Time.fromJSDate(windowStart, false)
+ const rangeEnd = ICAL.Time.fromJSDate(windowEnd, false)
+
+ if (!event.isRecurring()) {
+ if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) {
+ const dtend = vevent.getFirstPropertyValue('dtend') as ICAL.Time | null
+ results.push({
+ dtstart: dtstart.toJSDate(),
+ dtend: (dtend ?? dtstart).toJSDate(),
+ allDay,
+ })
+ }
+ return results
+ }
+
+ // RecurExpansion handles RRULE + RDATE + EXDATE internally
+ const expand = new ICAL.RecurExpansion({ component: vevent, dtstart })
+ let next: ICAL.Time | null
+ while ((next = expand.next()) && next.compare(rangeEnd) < 0) {
+ if (next.compare(rangeStart) < 0) continue
+ // Compute end using the original event's duration
+ const duration = event.duration
+ const occEnd = next.clone()
+ occEnd.addDuration(duration)
+ results.push({ dtstart: next.toJSDate(), dtend: occEnd.toJSDate(), allDay })
+ }
+ return results
+}
+```
+
+### All-Day Event: Server Format to Schedule-X PlainDate
+
+```typescript
+// Source: https://schedule-x.dev/docs/calendar/events
+// The server sends allDay events with start: 'YYYY-MM-DD' (from dtstartDate column)
+// The client must use Temporal.PlainDate — NOT ZonedDateTime
+
+// WRONG (shifts date in negative-offset timezones):
+{ start: Temporal.ZonedDateTime.from('2026-06-15T00:00:00Z') }
+
+// CORRECT:
+{ start: Temporal.PlainDate.from('2026-06-15') }
+```
+
+### Schedule-X CSS Token Override Pattern
+
+```css
+/* apps/pwa/src/styles/tokens.css */
+/* Source: https://schedule-x.dev — import theme-default, then override all --sx-color-* vars */
+
+/* Import Schedule-X default layout CSS in main.tsx:
+ import '@schedule-x/theme-default/dist/index.css'
+ Import tokens.css after — these overrides take precedence */
+
+:root {
+ /* Map Schedule-X color vars to project tokens */
+ --sx-color-primary: var(--color-member-0); /* current user's color */
+ --sx-color-on-primary: #ffffff;
+ --sx-color-surface: var(--color-surface);
+ --sx-color-on-surface: var(--color-text-primary);
+ --sx-color-on-surface-variant: var(--color-text-secondary);
+ --sx-color-outline: var(--color-border);
+ --sx-color-neutral: var(--color-surface-dim);
+ --sx-color-neutral-variant: var(--color-border-subtle);
+
+ /* Typography */
+ --sx-font-family: var(--font-family-base);
+}
+```
+
+---
+
+## Backend: `/api/events` Evolution
+
+### Current state (Phase 1)
+
+```typescript
+// apps/api/src/routes/events.ts — current implementation
+eventsRouter.get('/', async (c) => {
+ const events = await db.select().from(calendarEvents) // no window, no join, no expansion
+ return c.json({ events })
+})
+```
+
+### Target state (Phase 2)
+
+```typescript
+// apps/api/src/routes/events.ts — evolved
+eventsRouter.get('/', async (c) => {
+ const { start, end } = c.req.query()
+ // Zod-validate start/end as ISO dates
+ // SQL: calendarEvents JOIN calendars JOIN users
+ // WHERE (dtstartUtc BETWEEN start AND end) OR (dtstartDate BETWEEN start AND end)
+ // OR event.hasRrule (to catch recurring masters whose window occurrence may differ)
+ // For each row: call expandOccurrences(rawVevent, windowStart, windowEnd, ...)
+ // Return: { occurrences: CalendarOccurrence[] }
+})
+```
+
+**SQL pre-filter strategy:** The SQL `WHERE` must also include events with an RRULE property that _started before_ the window, because a weekly meeting created 3 years ago can still have occurrences in the current window. Include a `hasRrule` boolean column (can be added via migration) or parse `rawVevent` in the expansion step and skip in-memory if no occurrences fall in window. The simpler approach: include all events where `dtstartUtc < windowEnd` (no lower bound) OR `dtstartDate < windowEnd`, then let `expandOccurrences` handle the window check. Add a schema migration to add a `hasRrule` boolean indexed column to `calendarEvents` to avoid scanning all historical events on every request.
+
+**Shared calendar identification:** The `calendars` table has `userId` but no explicit `isShared` flag. The shared-family calendar is identified by being the one that has its `displayName = 'Calendar'` (from CAL-08-DECISION.md) or, more robustly, by convention (the broker user is the broker account, not a household member). The Phase 2 plan should resolve this: either add a `isShared` boolean to `calendars`, or identify shared calendars by comparing `calendars.userId` to a designated broker user ID.
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Schedule-X ISO string events `"YYYY-MM-DD HH:MM"` | `Temporal.ZonedDateTime` / `Temporal.PlainDate` | Schedule-X v3 (2024) | Server must return parseable strings; client must hydrate |
+| `react-big-calendar` (moment/date-fns) | Schedule-X (Temporal-based) | 2024 ecosystem shift | react-big-calendar's CSS is hard to override; Schedule-X CSS tokens are first-class |
+| `FullCalendar` open-source | Schedule-X (fully MIT) | 2024 for self-hosted | FullCalendar premium features are commercial; Schedule-X is fully open |
+| rrule-only recurrence expansion | `ICAL.RecurExpansion` (higher-level) | ical.js 1.x+ | RecurExpansion integrates RRULE + RDATE + EXDATE; no separate EXDATE handling needed |
+
+**Deprecated/outdated:**
+- `react-big-calendar`: Not deprecated per se, but the CSS override story is significantly worse for a token-based design system. The UI-SPEC already rejected it.
+- Schedule-X v2 ISO string format: Removed in v3. Any tutorial older than mid-2024 using string dates is wrong.
+
+---
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | Vitest (already configured in `apps/api/vitest.config.ts`) |
+| Config file | `apps/api/vitest.config.ts` (exists); `apps/pwa` has no test setup — needs Wave 0 |
+| Quick run command | `pnpm --filter @familysync/api test` |
+| Full suite command | `pnpm -r test` (workspace-wide) |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| CAL-02 (color) | Events returned with correct `color` field from `users.color` | unit | `pnpm --filter @familysync/api test -- tests/routes/events.test.ts` | ❌ Wave 0 |
+| CAL-02 (aggregation) | Events from multiple calendars (multiple users) returned in single response | unit | same file | ❌ Wave 0 |
+| CAL-03 (views) | Schedule-X renders without error with all four views configured | smoke | `pnpm --filter @familysync/pwa test -- calendar.spec.tsx` | ❌ Wave 0 |
+| CAL-07 (recurrence) | `expandOccurrences()` returns correct occurrences for weekly RRULE in a 30-day window | unit | `pnpm --filter @familysync/api test -- tests/broker/expand.test.ts` | ❌ Wave 0 |
+| CAL-07 (DST) | `expandOccurrences()` with America/New_York RRULE across March DST boundary returns correct wall-clock times | unit | same file | ❌ Wave 0 |
+| CAL-07 (all-day) | `expandOccurrences()` for all-day event returns `allDay: true` and `start: 'YYYY-MM-DD'` with no time component | unit | same file | ❌ Wave 0 |
+| CAL-07 (EXDATE) | `expandOccurrences()` excludes EXDATE occurrences from expansion | unit | same file | ❌ Wave 0 |
+| CAL-07 (Temporal) | `hydrateEvents()` converts all-day occurrences to `Temporal.PlainDate` and timed to `Temporal.ZonedDateTime` | unit | `pnpm --filter @familysync/pwa test -- lib/hydrateEvents.test.ts` | ❌ Wave 0 |
+
+### Fixture ICS Files (test corpus)
+
+The most valuable test artifacts are fixture `.ics` files. Create in `apps/api/tests/fixtures/`:
+
+| Filename | Contents | Tests |
+|----------|----------|-------|
+| `weekly-dst.ics` | Weekly meeting at 10:00 America/New_York spanning March DST transition (2026-03-01 to 2026-04-30) | CAL-07 DST |
+| `allday-birthday.ics` | Annual birthday event (DATE type, no DTEND) | CAL-07 all-day |
+| `exdate-series.ics` | Weekly series with one EXDATE (a skipped occurrence) | CAL-07 EXDATE |
+| `multi-cal.ics` | Two separate VCALENDAR blobs to represent two members' events | CAL-02 aggregation |
+
+These fixture files can be generated from real Fastmail ICS exports or hand-crafted with known-correct VTIMEZONE blocks.
+
+### Sampling Rate
+
+- **Per task commit:** `pnpm --filter @familysync/api test` (API unit tests, <5s)
+- **Per wave merge:** `pnpm -r test` + `pnpm -r typecheck`
+- **Phase gate:** Full suite green + `tsc --noEmit` clean in both workspaces before `/gsd-verify-work`
+
+### Wave 0 Gaps
+
+- [ ] `apps/api/tests/broker/expand.test.ts` — covers CAL-07 recurrence + DST + EXDATE + all-day
+- [ ] `apps/api/tests/routes/events.test.ts` — covers CAL-02 windowed query + color join
+- [ ] `apps/pwa/vitest.config.ts` — Vitest not configured in PWA; needs `vitest` + `@testing-library/react` + `jsdom`
+- [ ] `apps/pwa/src/lib/hydrateEvents.test.ts` — covers Temporal hydration + all-day guard
+- [ ] `apps/pwa/src/lib/calendarConfig.test.ts` — covers `firstDayOfWeek` translation (0→7)
+- [ ] `apps/pwa/package.json` — add `"test": "vitest run"` script + `vitest`, `@testing-library/react`, `jsdom` devDependencies
+
+---
+
+## Security Domain
+
+`security_enforcement: true`, `security_asvs_level: 1` per config.json.
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes — dev bypass must not leak | `NODE_ENV === 'production'` hard guard in `devAuthBypass()` |
+| V3 Session Management | carried from Phase 1 | `@hono/oidc-auth` JWT cookie (httpOnly + Secure + SameSite) |
+| V4 Access Control | yes — `/api/events` must be authenticated | `oidcAuthMiddleware` on `/api/*` (Phase 1 pattern) |
+| V5 Input Validation | yes — `?start=` and `?end=` query params | `zod` + `@hono/zod-validator`: validate ISO date format before SQL |
+| V6 Cryptography | no new crypto in Phase 2 | — |
+
+### Known Threat Patterns for This Phase
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Dev-auth bypass left active in production | Elevation of privilege | Hard `NODE_ENV !== 'production'` guard; `.env.example` warning |
+| SQL injection via `?start=` / `?end=` date params | Tampering | Zod ISO date validation; Drizzle parameterized queries |
+| XSS via event title/description in EventDetailPopover | Tampering | React's default JSX escaping; never use `dangerouslySetInnerHTML` for event fields |
+| Overfetch (no window) timing/DoS | Denial of service | Zod-enforce required `start` + `end` params; cap window to 90 days max |
+
+---
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Node.js 22 | API + PWA build | ✓ | (WSL2 dev env — assumed from Phase 1) | — |
+| pnpm | Workspace install | ✓ | (Phase 1 used it) | — |
+| MariaDB (Docker) | `/api/events` windowed query | ✓ | Phase 1 confirmed: 503 events cached | — |
+| Temporal (browser) | Schedule-X v4 | Partial | Needs `temporal-polyfill` in PWA | `temporal-polyfill@0.3.2` — no fallback needed |
+| Live Authelia/Pangolin | Full auth flow | ✗ (D-14 deferred) | — | Dev-auth bypass middleware (must build in Phase 2) |
+
+**Missing with no fallback:** None. Dev-auth bypass covers the Authelia deferral.
+
+---
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | `ICAL.RecurExpansion` handles EXDATE internally when using the high-level API | Architecture Patterns | Planner would need to add manual EXDATE filtering in `expandOccurrences` |
+| A2 | `@schedule-x/react@4.1.0` is API-compatible with `@schedule-x/calendar@4.6.0` for the features used (views, calendars, onRangeUpdate, customComponents) | Standard Stack | Version mismatch may cause TypeScript errors on newer options; test in Wave 0 |
+| A3 | The shared-family calendar can be identified programmatically (by displayName or a new `isShared` column) without a schema migration | Backend: /api/events evolution | If not deterministic, Phase 2 plan must include a migration adding `calendars.isShared` |
+| A4 | `onRangeUpdate` fires immediately on mount with the initial window | Architecture Patterns | If it does not fire on mount, initial fetch requires a separate first-render trigger |
+
+**A1 verification:** The ical.js wiki states RecurExpansion "takes into account recurrence exceptions (RDATE and EXDATE)" [CITED: github.com/kewisch/ical.js/wiki/Common-Use-Cases]. Treat as HIGH confidence.
+**A2 verification:** Peer dep `^4.0.0` satisfied by 4.6.0 [VERIFIED: npm registry]. API surface used (views, calendars, onRangeUpdate) is stable since v4.0.0. Treat as MEDIUM confidence — validate in Wave 0.
+**A3 risk:** The Phase 1 spike showed Lucas's broker account has two calendars: "Calendar" and "USA Holidays". The shared-family calendar is the household-shared one. Since the per-member app-password model means each member's own calendars are fetched under their own credential, "shared" in the context of Phase 2 likely means a calendar explicitly shared at the Fastmail account level, not just a personal calendar. The plan should include a `checkpoint:human-verify` to confirm how to mark shared calendars, or default to: the calendar synced under the broker account is shared-family; calendars synced under member credentials are personal.
+**A4 note:** Schedule-X fires `onRangeUpdate` when the view changes (navigation). Initial mount may not fire it. The TanStack Query initial key should be set from Zustand's default `calendarRange` (today ± buffer), not depend on `onRangeUpdate` for the first fetch.
+
+---
+
+## Open Questions
+
+1. **Identifying the shared-family calendar**
+ - What we know: Phase 1 caches calendars under `calendars.userId`. Lucas's broker account has "Calendar" and "USA Holidays". The wife's personal calendar will be added.
+ - What's unclear: Which calendar(s) are "shared-family" vs "personal"? Is it deterministic from displayName? From which user account synced it? The UI-SPEC assigns the rose `#F25C7A` to the shared-family calendar.
+ - Recommendation: Add a `calendars.isShared` boolean column (default false). The operator marks the shared-family calendar during initial setup. Alternatively, treat "Calendar" (exact displayName match) from the broker account as shared — but this is fragile.
+
+2. **`onRangeUpdate` initial mount behavior**
+ - What we know: Schedule-X fires `onRangeUpdate` on navigation. Docs do not specify if it fires on mount.
+ - What's unclear: Does the calendar fire `onRangeUpdate` immediately with the initial visible window, or only on user navigation?
+ - Recommendation: Do not rely on `onRangeUpdate` for the initial fetch. Set Zustand `calendarRange` to a sensible default (e.g., current month ± 1 week) on store initialization; use that as the initial TanStack Query key.
+
+3. **Recurring masters with `dtstartUtc` before the window**
+ - What we know: A weekly meeting created 3 years ago has `dtstartUtc` from 3 years ago. The SQL pre-filter `WHERE dtstartUtc BETWEEN start AND end` will miss it entirely.
+ - What's unclear: The right balance between SQL efficiency and correctness.
+ - Recommendation: Add `hasRrule boolean` indexed column to `calendarEvents` (schema migration in Wave 0). Pre-filter: `WHERE (NOT hasRrule AND dtstartUtc BETWEEN start AND end) OR (hasRrule AND dtstartUtc < windowEnd)`. The expansion step then filters the precise window.
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+- [schedule-x.dev/docs/frameworks/react](https://schedule-x.dev/docs/frameworks/react) — React adapter usage, views, eventsService plugin
+- [schedule-x.dev/docs/calendar/calendars](https://schedule-x.dev/docs/calendar/calendars) — lightColors config, calendarId on events
+- [schedule-x.dev/docs/calendar/events](https://schedule-x.dev/docs/calendar/events) — Temporal.ZonedDateTime / Temporal.PlainDate requirement
+- [schedule-x.dev/docs/calendar/configuration](https://schedule-x.dev/docs/calendar/configuration) — firstDayOfWeek (Temporal: 7=Sunday), onRangeUpdate
+- [schedule-x.dev/blog/schedule-x-v3-temporal-api](https://schedule-x.dev/blog/schedule-x-v3-temporal-api) — breaking change from ISO strings to Temporal in v3
+- [github.com/kewisch/ical.js/wiki/Common-Use-Cases](https://github.com/kewisch/ical.js/wiki/Common-Use-Cases) — ICAL.RecurExpansion pattern, VTIMEZONE registration
+- [github.com/kewisch/ical.js/wiki/Parsing-iCalendar](https://github.com/kewisch/ical.js/wiki/Parsing-iCalendar) — ICAL.parse + ICAL.Component + ICAL.Event pipeline
+- Phase 1 codebase: `apps/api/src/broker/sync.ts`, `apps/api/src/db/schema.ts`, CAL-08-DECISION.md — confirmed Phase 1 foundation
+- `npm view` on all Phase 2 packages — versions and publish dates confirmed [VERIFIED: npm registry]
+
+### Secondary (MEDIUM confidence)
+- [schedule-x.dev/docs/calendar/major-version-migrations](https://schedule-x.dev/docs/calendar/major-version-migrations) — v2→v3 breaking changes (Temporal adoption confirmed)
+- [schedule-x.dev/docs/calendar/plugins/event-modal](https://schedule-x.dev/docs/calendar/plugins/event-modal) — createEventModalPlugin + customComponents.eventModal
+- WebSearch on rrule DST behavior — confirmed known issue with `tzid` parameter and UTC fallback; `ICAL.RecurExpansion` is the recommended alternative
+
+### Tertiary (LOW confidence)
+- WebSearch results on VTIMEZONE registration best practices — cross-verified with official ical.js wiki
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — all packages verified on npm registry; Schedule-X selected in UI-SPEC
+- Architecture (recurrence expansion): HIGH — ICAL.RecurExpansion documented in official ical.js wiki; Phase 1 sync.ts pattern extended
+- Architecture (Schedule-X Temporal format): HIGH — verified against official Schedule-X docs
+- firstDayOfWeek translation: HIGH — verified in Schedule-X configuration docs
+- All-day event PlainDate requirement: HIGH — verified in Schedule-X events docs
+- VTIMEZONE registration for DST: MEDIUM — pattern documented in ical.js wiki; ICAL.js DST behavior not independently regression-tested
+- Shared calendar identification: LOW — depends on runtime data shape not fully inspected
+
+**Research date:** 2026-06-04
+**Valid until:** 2026-09-04 (90 days — Schedule-X v4 is in active development; re-verify if minor versions change significantly before execution)