docs(02): create calendar-display phase plan (5 plans, 4 waves)

This commit is contained in:
Lucas Berger
2026-06-04 14:56:57 -04:00
parent 5e14413024
commit b842955612
6 changed files with 1118 additions and 1 deletions
+9 -1
View File
@@ -58,7 +58,15 @@ Plans:
3. A recurring event (e.g., weekly meeting) displays all its occurrences correctly in the current view window, including correct behavior across DST boundaries 3. A recurring event (e.g., weekly meeting) displays all its occurrences correctly in the current view window, including correct behavior across DST boundaries
4. All-day events (birthdays, holidays) appear as full-day banners on the correct date with no timezone shift 4. All-day events (birthdays, holidays) appear as full-day banners on the correct date with no timezone shift
**Plans**: TBD **Plans**: 5 plans
Plans:
- [ ] 02-01-PLAN.md — Foundation: schema (hasRrule + calendars.isShared, pushed) + dev-auth bypass + PWA vitest/jsdom harness + ICS fixtures + RED test stubs
- [ ] 02-02-PLAN.md — Backend slice: expandOccurrences() (VTIMEZONE/DST + all-day + EXDATE) + windowed/joined/zod-validated /api/events + shared-calendar checkpoint (CAL-02/CAL-07)
- [ ] 02-03-PLAN.md — Frontend foundation: CSS token layer + colorUtils + calendarConfig (firstDayOfWeek 0→7) + hydrateEvents (Temporal/PlainDate guard) + Zustand store + windowed fetchEvents
- [ ] 02-04-PLAN.md — Vertical slice: CalendarShell mounts Schedule-X, renders real windowed Fastmail events color-coded across all four views (CAL-02/CAL-03)
- [ ] 02-05-PLAN.md — UX completion: read-only EventDetailPopover + ColorLegend + nav/toolbar + skeleton/empty/error states + human visual verification
**UI hint**: yes **UI hint**: yes
### Phase 3: Event Write-Back + PWA Install ### Phase 3: Event Write-Back + PWA Install
@@ -0,0 +1,243 @@
---
phase: 02-calendar-display
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/src/db/schema.ts
- apps/api/src/auth/devBypass.ts
- apps/api/src/index.ts
- apps/api/tests/fixtures/weekly-dst.ics
- apps/api/tests/fixtures/allday-birthday.ics
- apps/api/tests/fixtures/exdate-series.ics
- apps/api/tests/broker/expand.test.ts
- apps/api/tests/routes/events.test.ts
- apps/api/tests/auth/devBypass.test.ts
- apps/pwa/vitest.config.ts
- apps/pwa/package.json
- apps/pwa/src/lib/hydrateEvents.test.ts
- apps/pwa/src/lib/calendarConfig.test.ts
- .env.example
- docs/deployment.md
autonomous: true
requirements: [CAL-02, CAL-03, CAL-07]
user_setup: []
must_haves:
truths:
- "Drizzle schema has an indexed hasRrule boolean on calendar_events and an isShared boolean on calendars, both pushed to the live MariaDB"
- "PWA test runner (vitest + jsdom + @testing-library/react) executes and a smoke test passes"
- "Dev-auth bypass middleware injects a fixed dev user only when DEV_AUTH_BYPASS=true AND NODE_ENV!=production"
- "Failing-but-present test stubs exist for expand, events route, hydrateEvents, and calendarConfig (Wave 0 RED state)"
artifacts:
- path: "apps/api/src/db/schema.ts"
provides: "hasRrule + isShared columns + idx_calendar_events_has_rrule index"
contains: "has_rrule"
- path: "apps/api/src/auth/devBypass.ts"
provides: "devAuthBypass() middleware with hard production guard"
exports: ["devAuthBypass"]
- path: "apps/pwa/vitest.config.ts"
provides: "jsdom-environment vitest config for PWA"
contains: "jsdom"
- path: "apps/api/tests/fixtures/weekly-dst.ics"
provides: "DST-spanning weekly RRULE fixture for CAL-07 tests"
min_lines: 10
key_links:
- from: "apps/api/src/index.ts"
to: "apps/api/src/auth/devBypass.ts"
via: "app.use('/api/*', devAuthBypass()) before oidcAuthMiddleware"
pattern: "devAuthBypass"
- from: "apps/pwa/package.json"
to: "vitest"
via: "test script + devDependencies"
pattern: "\"test\".*vitest"
---
<objective>
Lay the verifiable foundation for the Phase 2 calendar slice: add the two schema columns the
display pipeline depends on (`calendar_events.hasRrule`, `calendars.isShared`), push them to the
live MariaDB, stand up the PWA test runner, write the dev-auth bypass so the UI can be built
without live Authelia (D-14), and create the failing test stubs + ICS fixtures that all later
waves turn green.
Purpose: Every later plan (expansion engine, windowed route, hydration, calendar render) needs
these columns, the test harness, and the dev-auth bypass to exist first. This is the only
horizontal-foundation plan in the phase — kept minimal so the next plan delivers a real slice.
Output: Migrated schema (pushed), PWA vitest harness, dev-auth bypass middleware, ICS fixtures,
RED test stubs.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-calendar-display/02-RESEARCH.md
@.planning/phases/02-calendar-display/02-PATTERNS.md
@.planning/phases/02-calendar-display/02-CONTEXT.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add schema columns + PWA test harness + ICS fixtures + RED test stubs</name>
<files>apps/api/src/db/schema.ts, apps/pwa/vitest.config.ts, apps/pwa/package.json, apps/api/tests/fixtures/weekly-dst.ics, apps/api/tests/fixtures/allday-birthday.ics, apps/api/tests/fixtures/exdate-series.ics, apps/api/tests/broker/expand.test.ts, apps/api/tests/routes/events.test.ts, apps/pwa/src/lib/hydrateEvents.test.ts, apps/pwa/src/lib/calendarConfig.test.ts</files>
<read_first>
- apps/api/src/db/schema.ts (current calendarEvents + calendars table definitions; column + index style to copy)
- apps/api/vitest.config.ts (analog for PWA config — change environment node→jsdom)
- apps/pwa/package.json (current scripts + devDependencies block)
- apps/api/tests/broker/poller.test.ts (test file structure: describe/it/expect, vi.mock hoisting)
- apps/api/tests/health.test.ts (Hono app.request() route-test pattern)
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Validation Architecture" (fixture corpus + Wave 0 gaps table)
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/vitest.config.ts" and §"apps/api/tests/broker/expand.test.ts"
</read_first>
<behavior>
- expand.test.ts: weekly America/New_York RRULE expanded across the March 2026 DST boundary returns occurrences whose wall-clock time stays 10:00 local on both sides of the transition (currently RED — expandOccurrences does not exist yet)
- expand.test.ts: all-day birthday fixture returns allDay:true with start as 'YYYY-MM-DD' and no time component
- expand.test.ts: exdate-series fixture omits the single EXDATE-excluded occurrence
- events.test.ts: GET /api/events?start=&end= returns occurrences each carrying a color field and isShared flag (RED — route not evolved yet)
- hydrateEvents.test.ts: all-day occurrence → Temporal.PlainDate; timed occurrence → Temporal.ZonedDateTime
- calendarConfig.test.ts: WEEK_START_DAY=0 translates to Schedule-X firstDayOfWeek 7
</behavior>
<action>
Add to `calendarEvents` in schema.ts: `hasRrule: boolean('has_rrule').default(false).notNull()`, and a new index `index('idx_calendar_events_has_rrule').on(t.hasRrule)` in the table's index array (copy the exact style of `idx_calendar_events_dtstart_utc`). Add to `calendars`: `isShared: boolean('is_shared').default(false).notNull()`. `boolean` and `index` are already imported.
Create `apps/pwa/vitest.config.ts` mirroring `apps/api/vitest.config.ts` but with `environment: 'jsdom'` and `globals: true`. In `apps/pwa/package.json` add `"test": "vitest run"` to scripts and add devDependencies `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` (use versions compatible with the workspace's existing vitest major; match the version in apps/api). Install via `pnpm install` at repo root.
Create three ICS fixtures under `apps/api/tests/fixtures/`: `weekly-dst.ics` (VEVENT with `DTSTART;TZID=America/New_York:20260301T100000`, `RRULE:FREQ=WEEKLY`, and a full `VTIMEZONE` block for America/New_York with both STANDARD and DAYLIGHT subcomponents so DST rules are present), `allday-birthday.ics` (VEVENT with `DTSTART;VALUE=DATE:20260615`, yearly RRULE, no DTEND), `exdate-series.ics` (weekly VEVENT with one `EXDATE` line removing a single occurrence). These must be valid VCALENDAR strings parseable by ICAL.parse.
Create the four RED test stubs. Each test imports the not-yet-existing module (`../../src/broker/expand.js`, etc.) so the file fails to resolve — that is the intended RED state. Per the Nyquist rule, mark each `<automated>` for the modules they cover as satisfied here. Use the describe/it patterns from poller.test.ts and health.test.ts. Load fixtures with `readFileSync` relative to the test file. Do NOT implement expand.ts, the route changes, hydrateEvents.ts, or calendarConfig.ts in this task — only the stubs that later plans turn green.
</action>
<verify>
<automated>cd apps/api && grep -q "has_rrule" src/db/schema.ts && grep -q "is_shared" src/db/schema.ts && grep -q "idx_calendar_events_has_rrule" src/db/schema.ts && echo SCHEMA_OK</automated>
<automated>cd /home/luc/Projects/familysync && grep -q jsdom apps/pwa/vitest.config.ts && grep -q '"test": "vitest run"' apps/pwa/package.json && echo PWA_HARNESS_OK</automated>
<automated>cd apps/api && node -e "const I=require('ical.js');for(const f of ['weekly-dst','allday-birthday','exdate-series']){I.parse(require('fs').readFileSync('tests/fixtures/'+f+'.ics','utf8'))};console.log('FIXTURES_PARSE_OK')"</automated>
</verify>
<acceptance_criteria>
- apps/api/src/db/schema.ts contains `hasRrule: boolean('has_rrule')` and `idx_calendar_events_has_rrule`
- apps/api/src/db/schema.ts contains `isShared: boolean('is_shared')` on the calendars table
- apps/pwa/vitest.config.ts contains `environment: 'jsdom'`
- apps/pwa/package.json scripts contains `"test": "vitest run"` and devDependencies include vitest, @testing-library/react, jsdom
- All three fixture .ics files parse via ICAL.parse without throwing
- weekly-dst.ics contains a VTIMEZONE block with both STANDARD and DAYLIGHT subcomponents
- expand.test.ts, events.test.ts, hydrateEvents.test.ts, calendarConfig.test.ts exist and reference their target modules (RED is expected — modules not yet built)
</acceptance_criteria>
<done>Schema columns + index added; PWA vitest/jsdom harness installed and runnable; three ICS fixtures parse; four RED test stubs exist referencing not-yet-built modules.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Dev-auth bypass middleware + production guard + env docs</name>
<files>apps/api/src/auth/devBypass.ts, apps/api/src/index.ts, apps/api/tests/auth/devBypass.test.ts, .env.example, docs/deployment.md</files>
<read_first>
- apps/api/src/auth/middleware.ts (re-export pattern; getAuth/oidcAuthMiddleware surface)
- apps/api/src/index.ts (current middleware mount order: callback → /health → oidcAuthMiddleware on /api/* → routes)
- apps/api/src/routes/me.ts (how getAuth(c) is consumed downstream — the injected user must satisfy it)
- apps/api/src/auth/user.ts (DEV_USER shape: id, displayName, color; COLOR_PALETTE[0])
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 5: Dev-Auth Bypass Middleware" and §"Pitfall 7"
- docs/deployment.md (existing dev-auth-bypass / Gate 2 context to extend)
</read_first>
<behavior>
- devBypass.test.ts: with NODE_ENV='production' the middleware is a pure passthrough and never sets a user, even if DEV_AUTH_BYPASS='true'
- devBypass.test.ts: with NODE_ENV='test' and DEV_AUTH_BYPASS unset, middleware is passthrough (no user injected)
- devBypass.test.ts: with NODE_ENV!='production' and DEV_AUTH_BYPASS='true', a fixed dev user is injected into the Hono context
</behavior>
<action>
Create `apps/api/src/auth/devBypass.ts` exporting `devAuthBypass(): MiddlewareHandler`. FIRST check `process.env.NODE_ENV === 'production'` and return a no-op passthrough (`async (_c, next) => next()`) before reading any other env var — this hard guard is mandatory (Pitfall 7). Then if `process.env.DEV_AUTH_BYPASS !== 'true'`, also return passthrough. Otherwise return a handler that calls `c.set('user', DEV_USER)` then `await next()`. DEV_USER = a fixed object `{ id, oidcIss: 'dev', oidcSub: 'dev-user', displayName: 'Dev User', color }` where color is `COLOR_PALETTE[0]` ('#4A90D9'). Match the context key (`'user'`) and shape that `getAuth(c)` consumers in me.ts expect — read me.ts to confirm whether downstream reads `getAuth(c)` or `c.get('user')`; if me.ts uses `getAuth(c)` from @hono/oidc-auth, set both the auth claim and `c.set('user', DEV_USER)` so the events route (which will read the resolved user) works. Document the exact mechanism in a top-of-file comment.
In `apps/api/src/index.ts`, mount `app.use('/api/*', devAuthBypass())` on the line immediately BEFORE the existing `app.use('/api/*', oidcAuthMiddleware())`. The bypass is a no-op when inactive, so production behavior is unchanged.
Add `DEV_AUTH_BYPASS` to `.env.example` with a comment: `# DEV ONLY — injects a fixed dev user, skips Authelia. Hard-disabled when NODE_ENV=production. NEVER set in prod.` Extend `docs/deployment.md` dev-auth-bypass section to note the NODE_ENV production hard guard and that the production Docker Compose must not set DEV_AUTH_BYPASS.
</action>
<verify>
<automated>cd apps/api && pnpm test -- tests/auth/devBypass.test.ts</automated>
<automated>cd apps/api && grep -q "NODE_ENV === 'production'" src/auth/devBypass.ts && grep -q "devAuthBypass()" src/index.ts && echo BYPASS_WIRED</automated>
<automated>cd /home/luc/Projects/familysync && grep -q "DEV_AUTH_BYPASS" .env.example && echo ENV_DOCUMENTED</automated>
</verify>
<acceptance_criteria>
- apps/api/src/auth/devBypass.ts exports devAuthBypass and the FIRST conditional checks NODE_ENV === 'production'
- apps/api/src/index.ts calls devAuthBypass() on /api/* immediately before oidcAuthMiddleware()
- tests/auth/devBypass.test.ts passes: production guard, unset-flag passthrough, and active-injection cases all green
- .env.example documents DEV_AUTH_BYPASS with the production warning comment
- docs/deployment.md notes the production hard guard and prod-compose prohibition
</acceptance_criteria>
<done>devAuthBypass middleware exists with production hard guard, is mounted before oidcAuthMiddleware, all three behavior tests pass, env + deployment docs updated.</done>
</task>
<task type="auto" gate="blocking">
<name>Task 3: [BLOCKING] Push Drizzle schema to live MariaDB</name>
<files>apps/api (drizzle-kit push — no source file)</files>
<read_first>
- apps/api/src/db/schema.ts (the columns added in Task 1 that must reach the live DB)
- apps/api/drizzle.config.ts (push target / credentials config)
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Environment Availability" (MariaDB Docker confirmed up)
</read_first>
<action>
Run `npx drizzle-kit push` in `apps/api` to apply the `has_rrule`, `idx_calendar_events_has_rrule`, and `calendars.is_shared` schema changes to the live MariaDB. This is MANDATORY and BLOCKING: type checks and builds pass without it (types come from the Drizzle config, not the live DB), so skipping it produces a false-positive verification state where the windowed query in Plan 02 fails at runtime with "unknown column has_rrule". The MariaDB container must be running (Phase 1 confirmed it up with 503 cached events). If drizzle-kit push emits an interactive confirmation prompt that cannot be auto-confirmed, stop and surface it — do not guess answers to destructive prompts.
</action>
<verify>
<automated>cd apps/api && node -e "const m=require('mysql2/promise');(async()=>{const c=await m.createConnection(process.env.DATABASE_URL);const [r]=await c.query('SHOW COLUMNS FROM calendar_events LIKE \'has_rrule\'');const [s]=await c.query('SHOW COLUMNS FROM calendars LIKE \'is_shared\'');if(r.length&&s.length){console.log('PUSH_OK')}else{process.exit(1)};await c.end()})()"</automated>
</verify>
<acceptance_criteria>
- `SHOW COLUMNS FROM calendar_events LIKE 'has_rrule'` returns one row against the live MariaDB
- `SHOW COLUMNS FROM calendars LIKE 'is_shared'` returns one row against the live MariaDB
- drizzle-kit push completed without destructive data loss on the existing 503-event cache
</acceptance_criteria>
<done>has_rrule (+ index) and calendars.is_shared exist in the live MariaDB schema; existing event cache intact.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → /api/* | OIDC-gated; dev-auth bypass replaces the gate in dev only |
| CI/prod env → app config | DEV_AUTH_BYPASS env var could leak into production |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-01 | Elevation of privilege | devAuthBypass() | mitigate | Hard `NODE_ENV === 'production'` guard as the FIRST conditional, before reading DEV_AUTH_BYPASS; .env.example warning; prod compose must not set the flag (Pitfall 7) |
| T-02-02 | Tampering | drizzle-kit push | accept | Local dev DB; push reviewed; no untrusted input. Operator runs push against own MariaDB |
| T-02-SC | Tampering | pnpm installs (vitest, @testing-library/*, jsdom) | mitigate | All packages are mainstream, audited in RESEARCH §Package Legitimacy (Approved); no [ASSUMED]/[SUS] packages in this plan |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test` runs (devBypass test green; expand/events stubs RED as designed)
- `pnpm --filter @familysync/pwa test` runner executes under jsdom
- Live MariaDB has has_rrule + is_shared columns
- `tsc --noEmit` clean in apps/api after schema edits
</verification>
<success_criteria>
- Schema columns added, pushed, and verified against the live DB
- PWA test runner operational
- Dev-auth bypass green with production hard guard
- ICS fixtures parse; RED stubs in place for later waves
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 01)
New symbols/files created here (exclude from drift verification):
- `calendar_events.hasRrule` Drizzle column + `idx_calendar_events_has_rrule` index
- `calendars.isShared` Drizzle column
- `devAuthBypass` (function) — apps/api/src/auth/devBypass.ts
- `DEV_USER` (const, internal to devBypass.ts)
- apps/pwa/vitest.config.ts (new)
- apps/pwa `test` npm script + vitest/@testing-library/jsdom devDependencies
- apps/api/tests/fixtures/{weekly-dst,allday-birthday,exdate-series}.ics
- apps/api/tests/broker/expand.test.ts, apps/api/tests/routes/events.test.ts, apps/api/tests/auth/devBypass.test.ts (new test files)
- apps/pwa/src/lib/hydrateEvents.test.ts, apps/pwa/src/lib/calendarConfig.test.ts (new test files)
</artifacts_produced>
<output>
Create `.planning/phases/02-calendar-display/02-01-SUMMARY.md` when done
</output>
@@ -0,0 +1,225 @@
---
phase: 02-calendar-display
plan: 02
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- apps/api/src/broker/expand.ts
- apps/api/src/routes/events.ts
- apps/api/tests/broker/expand.test.ts
- apps/api/tests/routes/events.test.ts
autonomous: false
requirements: [CAL-02, CAL-03, CAL-07]
user_setup: []
must_haves:
truths:
- "GET /api/events?start=&end= returns a flat array of concrete occurrences (no recurring masters, no raw VCALENDAR blobs) windowed to the requested date range"
- "Each occurrence carries the owner member color (from users.color) or the shared-family rose, plus an isShared flag"
- "Recurring events are expanded server-side with VTIMEZONE registered before expansion so DST occurrences keep correct wall-clock time"
- "All-day occurrences are returned with allDay:true and a 'YYYY-MM-DD' start (no timezone shift)"
- "EXDATE-excluded occurrences are omitted from the expansion"
- "Invalid or missing start/end query params are rejected (zod) before any SQL runs; window capped at 90 days"
artifacts:
- path: "apps/api/src/broker/expand.ts"
provides: "expandOccurrences() — ICAL.RecurExpansion + VTIMEZONE registration + allDay split → CalendarOccurrence[]"
exports: ["expandOccurrences", "CalendarOccurrence"]
- path: "apps/api/src/routes/events.ts"
provides: "windowed /api/events with calendarEvents→calendars→users join, hasRrule pre-filter, zod validation"
contains: "zValidator"
key_links:
- from: "apps/api/src/routes/events.ts"
to: "apps/api/src/broker/expand.ts"
via: "expandOccurrences() called per recurring/timed row"
pattern: "expandOccurrences"
- from: "apps/api/src/routes/events.ts"
to: "users.color"
via: "innerJoin calendars→users, select color + isShared"
pattern: "users\\.color"
---
<objective>
Evolve `/api/events` from the Phase 1 raw-row dump into the display-ready windowed endpoint:
filter cached events to the requested date window, join owner color + shared-family flag, expand
recurring masters server-side via `ICAL.RecurExpansion` (with VTIMEZONE registered for DST
correctness), and serialize concrete occurrences as JSON. This is the backend half of the
calendar slice — it makes real, color-tagged, DST-correct, all-day-safe occurrences available to
the UI.
Purpose: CAL-02 (color aggregation), CAL-07 (recurrence + DST + all-day + EXDATE display) live
here. The frontend slice (Plan 04) consumes this exact JSON shape.
Output: `expandOccurrences()` helper, windowed/joined/validated `/api/events`, green expand + route tests.
Note (autonomous: false): includes a blocking checkpoint to resolve which calendar is the
shared-family calendar (open question A3) — the operator marks it.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-calendar-display/02-RESEARCH.md
@.planning/phases/02-calendar-display/02-PATTERNS.md
@.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: expandOccurrences() — server-side expansion with VTIMEZONE + allDay split</name>
<files>apps/api/src/broker/expand.ts, apps/api/tests/broker/expand.test.ts</files>
<read_first>
- apps/api/src/broker/sync.ts (ICAL.parse → Component → vevent pipeline lines 78115; allDay = dtstart.isDate; D-13 dtstartDate/dtstartUtc split)
- apps/api/tests/broker/expand.test.ts (the RED stub from Plan 01 — its expected assertions are the contract)
- apps/api/tests/fixtures/weekly-dst.ics, allday-birthday.ics, exdate-series.ics (Plan 01 fixtures)
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 1" + §"Code Examples: VTIMEZONE Registration + ICAL.RecurExpansion" + §"Pitfall 2/3"
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/api/src/broker/expand.ts" + §"D-13 allDay Discrimination"
</read_first>
<behavior>
- weekly-dst.ics: occurrences across the March 2026 EST→EDT transition stay at 10:00 America/New_York local wall-clock (not shifted ±1h by a UTC fallback)
- allday-birthday.ics: returns allDay:true, start='2026-06-15' (DATE form, no time), and the yearly occurrence falls within a window containing June 15
- exdate-series.ics: the single EXDATE-excluded occurrence is absent from the returned array
- non-recurring event inside the window returns exactly one occurrence; non-recurring event outside the window returns none
- each occurrence id is `${uid}::${startIso}` (stable identity)
</behavior>
<action>
Create `apps/api/src/broker/expand.ts` exporting interface `CalendarOccurrence` (fields: id, uid, calendarId:number, calendarName:string, ownerUserId:number, color:string, isShared:boolean, title:string, start:string, end:string, allDay:boolean, location:string|null, description:string|null) and `expandOccurrences(rawVevent, windowStart: Date, windowEnd: Date, meta: { calendarId, calendarName, ownerUserId, color, isShared }): CalendarOccurrence[]`.
Implementation contract (per RESEARCH Pattern 1 and Code Examples):
1. `ICAL.parse(rawVevent)` wrapped in try/catch — on parse failure return `[]` (do not throw; match sync.ts skip-on-malformed behavior).
2. BEFORE constructing RecurExpansion, iterate `comp.getAllSubcomponents('vtimezone')`; for each, read `tzid`, and if `!ICAL.TimezoneService.has(tzid)` call `ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtz, tzid }))`. Skipping this makes DST occurrences ±1h wrong (Pitfall 3) — this registration is mandatory.
3. Get the first `vevent`; if none, return `[]`. Build `ICAL.Event(vevent)`; read `dtstart`. `allDay = dtstart.isDate`.
4. Non-recurring (`!event.isRecurring()`): emit a single occurrence if dtstart is within [windowStart, windowEnd).
5. Recurring: use `new ICAL.RecurExpansion({ component: vevent, dtstart })`. Iterate `expand.next()` while `next.compare(rangeEnd) < 0`; skip while `next.compare(rangeStart) < 0`. RecurExpansion handles RRULE+RDATE+EXDATE internally — do NOT manually filter EXDATE (A1, RESEARCH). Compute occurrence end from `event.duration`.
6. allDay serialization: for allDay occurrences, `start`/`end` are 'YYYY-MM-DD' strings (slice from the ICAL.Time DATE form) — NEVER a midnight-UTC datetime (Pitfall 2). For timed occurrences, emit a timezone-offset-aware ISO string the client can pass to `Temporal.ZonedDateTime.from()`.
7. Use rrule ONLY as a fallback if ICAL.RecurExpansion cannot parse a given RRULE — do not import it on the primary path (D-09).
Turn the Plan 01 RED expand.test.ts stub green against the three fixtures.
</action>
<verify>
<automated>cd apps/api && pnpm test -- tests/broker/expand.test.ts</automated>
</verify>
<acceptance_criteria>
- apps/api/src/broker/expand.ts exports `expandOccurrences` and `CalendarOccurrence`
- expand.test.ts passes including the DST wall-clock assertion, all-day 'YYYY-MM-DD' assertion, and EXDATE-exclusion assertion
- The VTIMEZONE registration loop runs before any RecurExpansion construction (grep: getAllSubcomponents('vtimezone') appears before new ICAL.RecurExpansion)
- No `import ... 'rrule'` on the primary expansion path (rrule only in a guarded fallback branch, if any)
- Malformed rawVevent input returns [] without throwing
</acceptance_criteria>
<done>expandOccurrences() produces DST-correct, all-day-safe, EXDATE-aware concrete occurrences; expand.test.ts green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Windowed /api/events with color/owner join, hasRrule pre-filter, zod validation</name>
<files>apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts</files>
<read_first>
- apps/api/src/routes/events.ts (current raw-dump implementation to replace; the broker-boundary invariant comment must be preserved)
- apps/api/src/routes/me.ts (Hono router structure, getAuth/c.json patterns)
- apps/api/src/db/schema.ts (calendarEvents, calendars, users columns incl. new hasRrule + calendars.isShared; foreign keys for the join)
- apps/api/src/broker/expand.ts (CalendarOccurrence shape + expandOccurrences signature from Task 1)
- apps/api/tests/routes/events.test.ts (RED stub from Plan 01 — its assertions are the contract)
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Backend: /api/events Evolution" + §"Pitfall 5" + §"Open Questions 3" (hasRrule pre-filter SQL)
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/api/src/routes/events.ts" (zod validator + Drizzle join + health.ts error handling)
</read_first>
<behavior>
- GET /api/events?start=2026-06-01&end=2026-07-01 returns { occurrences: CalendarOccurrence[] }, each with a color field
- occurrences aggregate events from multiple calendars/users (CAL-02 aggregation)
- shared-family calendar (calendars.isShared=true) occurrences carry isShared:true and color '#F25C7A'; member calendars carry the owner users.color
- missing or malformed start/end (not /^\d{4}-\d{2}-\d{2}$/) → 400 before any SQL
- a window wider than 90 days → 400 (DoS guard)
- a recurring master whose dtstartUtc predates the window still contributes in-window occurrences (hasRrule pre-filter)
</behavior>
<action>
Rewrite `eventsRouter.get('/')` in events.ts. Keep the top-of-file broker-boundary invariant comment (no tsdav, cache-only). Add zod query validation via `@hono/zod-validator`: `z.object({ start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) })` and reject (the validator returns 400 automatically). After parsing, compute the window span and return 400 if `end - start > 90 days` (V5 input validation / DoS cap).
Query: `db.select(...).from(calendarEvents).innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)).innerJoin(users, eq(calendars.userId, users.id))` selecting the columns expandOccurrences needs plus `calendars.isShared`, `calendars.displayName`, `users.color`, `users.id`. WHERE pre-filter (RESEARCH Open Q3): `(NOT hasRrule AND dtstartUtc BETWEEN start AND end) OR (hasRrule AND dtstartUtc < windowEnd) OR (dtstartDate BETWEEN start AND end)` — recurring masters predating the window must not be dropped (Pitfall 5 / Open Q3).
For each row, derive `color = row.isShared ? '#F25C7A' : row.userColor` and `isShared = row.isShared`, then call `expandOccurrences(row.rawVevent, windowStartDate, windowEndDate, { calendarId, calendarName: row.displayName, ownerUserId: row.userId, color, isShared })`. Flatten all results into one array. Wrap the DB+expansion body in try/catch returning 503 on DB error (health.ts pattern). Return `c.json({ occurrences })`.
Turn the Plan 01 RED events.test.ts stub green (mock db.select chain following the health.test.ts vi.mock pattern; assert color field, isShared, and 400 on bad params).
</action>
<verify>
<automated>cd apps/api && pnpm test -- tests/routes/events.test.ts</automated>
<automated>cd apps/api && grep -q "zValidator" src/routes/events.ts && grep -q "innerJoin" src/routes/events.ts && grep -q "expandOccurrences" src/routes/events.ts && echo ROUTE_WIRED</automated>
</verify>
<acceptance_criteria>
- events.test.ts passes: color-field assertion, multi-calendar aggregation, 400-on-bad-params, isShared flag
- events.ts uses zValidator('query', ...) with the YYYY-MM-DD regex and a ≤90-day window cap
- events.ts inner-joins calendarEvents→calendars→users and selects users.color + calendars.isShared
- events.ts calls expandOccurrences per row and returns { occurrences }
- No tsdav / createFastmailClient import in events.ts (broker-boundary invariant preserved)
</acceptance_criteria>
<done>/api/events is windowed, validated (zod + 90-day cap), joined for color/isShared, and expands recurrences; events.test.ts green.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: [CHECKPOINT] Resolve & mark the shared-family calendar (A3)</name>
<action>Operator-only manual step: inspect the calendars table, decide which row(s) are the shared-family calendar, and set is_shared=1 on them. No code is written in this task — the route logic already reads is_shared. Follow the steps in how-to-verify exactly.</action>
<what-built>
Plan 01 added `calendars.isShared` (default false). The route colors any calendar with
isShared=true rose (#F25C7A) and tags its occurrences isShared:true; all other calendars use
the owner's member color. Research open question A3 (which calendar is "shared-family") cannot
be resolved deterministically from data — the broker account exposes "Calendar" and "USA
Holidays", and each member's personal calendars arrive under their own app password. The
operator must designate the shared-family calendar(s).
</what-built>
<how-to-verify>
1. List current calendars: `cd apps/api && node -e "const m=require('mysql2/promise');(async()=>{const c=await m.createConnection(process.env.DATABASE_URL);const [r]=await c.query('SELECT id, display_name, user_id, is_shared FROM calendars');console.table(r);await c.end()})()"`
2. Decide which calendar row(s) are the shared-family calendar (the household-shared one — e.g. "Calendar" on the broker account per CAL-08-DECISION; NOT "USA Holidays", NOT a member's personal calendar).
3. Mark it: `UPDATE calendars SET is_shared = 1 WHERE id = <chosen id>;` (run via the same mysql2 connection or a DB client).
4. Re-run step 1 and confirm exactly the intended row(s) show is_shared=1.
5. Hit the endpoint in dev (DEV_AUTH_BYPASS=true): `curl 'http://localhost:3000/api/events?start=2026-06-01&end=2026-07-01'` and confirm occurrences from the marked calendar carry "isShared":true and "color":"#F25C7A".
</how-to-verify>
<resume-signal>Type "approved" with the chosen calendar id(s), or describe a different shared-calendar rule to encode.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → /api/events | start/end query params are untrusted input crossing into SQL |
| cached VEVENT → expansion | rawVevent originates from Fastmail; parsed by ical.js |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02b-01 | Tampering | start/end query params | mitigate | zod ISO-date regex validation before SQL; Drizzle parameterized queries (no string interpolation) |
| T-02b-02 | Denial of service | unwindowed/overwide fetch | mitigate | start+end required (zod); window hard-capped at 90 days; hasRrule index prevents full-table scan |
| T-02b-03 | Information disclosure | cross-account calendar leakage | mitigate | Route is behind oidcAuthMiddleware (Phase 1); each member's own credential fetched their own collections; no other-account ACL path exists (CAL-08-DECISION) |
| T-02b-04 | Tampering | malformed rawVevent | accept | expandOccurrences try/catch returns [] on parse failure; matches sync.ts resilience; no crash |
</threat_model>
<verification>
- `pnpm --filter @familysync/api test` green (expand + events tests)
- `tsc --noEmit` clean in apps/api
- Manual dev curl returns windowed occurrences with color + isShared (checkpoint)
</verification>
<success_criteria>
- /api/events returns windowed, color-tagged, DST-correct, all-day-safe, EXDATE-aware occurrences
- Bad/oversized windows rejected with 400
- Shared-family calendar marked and verified end-to-end
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 02)
- `expandOccurrences` (function) + `CalendarOccurrence` (interface) — apps/api/src/broker/expand.ts
- Evolved `eventsRouter` GET / handler with `{ occurrences }` response shape — apps/api/src/routes/events.ts
- `eventsQuerySchema` (zod) for start/end validation
- New JSON contract field set: id, uid, calendarId, calendarName, ownerUserId, color, isShared, title, start, end, allDay, location, description
</artifacts_produced>
<output>
Create `.planning/phases/02-calendar-display/02-02-SUMMARY.md` when done
</output>
@@ -0,0 +1,242 @@
---
phase: 02-calendar-display
plan: 03
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- apps/pwa/src/styles/tokens.css
- apps/pwa/src/styles/tokens.ts
- apps/pwa/src/styles/index.css
- apps/pwa/src/lib/colorUtils.ts
- apps/pwa/src/lib/colorUtils.test.ts
- apps/pwa/src/lib/calendarConfig.ts
- apps/pwa/src/lib/calendarConfig.test.ts
- apps/pwa/src/lib/hydrateEvents.ts
- apps/pwa/src/lib/hydrateEvents.test.ts
- apps/pwa/src/store/calendarStore.ts
- apps/pwa/src/api/client.ts
- apps/pwa/src/main.tsx
- apps/pwa/package.json
autonomous: true
requirements: [CAL-02, CAL-03, CAL-07]
user_setup: []
must_haves:
truths:
- "A CSS custom-property token layer (the clean theme) defines all colors, spacing, typography, breakpoints from UI-SPEC; no hard-coded hex/px will be needed by components"
- "Schedule-X --sx-color-* vars are mapped to project tokens so no Schedule-X default colors bleed through"
- "colorUtils derives Schedule-X lightColors (main/container/onContainer) from a member hex"
- "calendarConfig translates WEEK_START_DAY=0 (Sunday/JS) to Schedule-X firstDayOfWeek=7 (Temporal Sunday) and builds the per-calendar config keyed by String(userId) + 'shared'"
- "hydrateEvents converts all-day occurrences to Temporal.PlainDate and timed occurrences to Temporal.ZonedDateTime"
- "calendarStore (Zustand) holds selectedView (persisted per breakpoint group), selectedDate, openEventId, calendarRange — no server data"
- "fetchEvents(start,end) calls the windowed /api/events with credentials:include and returns OccurrencesResponse"
artifacts:
- path: "apps/pwa/src/styles/tokens.css"
provides: "clean-theme CSS custom properties + Schedule-X var overrides"
contains: "--color-shared-family"
- path: "apps/pwa/src/lib/calendarConfig.ts"
provides: "WEEK_START_DAY, firstDayOfWeek translation, buildCalendarConfig()"
exports: ["WEEK_START_DAY", "buildCalendarConfig"]
- path: "apps/pwa/src/lib/hydrateEvents.ts"
provides: "hydrateEvents() ISO→Temporal with all-day PlainDate guard"
exports: ["hydrateEvents"]
- path: "apps/pwa/src/store/calendarStore.ts"
provides: "Zustand UI-state store with localStorage view persistence"
exports: ["useCalendarStore"]
key_links:
- from: "apps/pwa/src/main.tsx"
to: "temporal-polyfill/global"
via: "import before any Schedule-X mount"
pattern: "temporal-polyfill/global"
- from: "apps/pwa/src/lib/calendarConfig.ts"
to: "apps/pwa/src/lib/colorUtils.ts"
via: "deriveScheduleXColors() for lightColors"
pattern: "deriveScheduleXColors"
---
<objective>
Build the frontend foundation the calendar render slice depends on: the CSS custom-property token
layer (D-01/D-02 — the load-bearing deliverable), the color-derivation utility, the Schedule-X
calendar config with the firstDayOfWeek translation, the ISO→Temporal hydration util with the
all-day PlainDate guard, the Zustand UI-state store, the windowed fetchEvents client, and the
Temporal-polyfill + theme-CSS imports in main.tsx.
Purpose: These are pure PWA library/style/store files with zero overlap with the backend plan, so
this runs in parallel with Plan 02. Plan 04 mounts Schedule-X and wires all of this into a
rendering calendar.
Output: tokens.css/ts/index.css, colorUtils, calendarConfig, hydrateEvents, calendarStore,
windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Install Schedule-X deps + token layer (tokens.css/ts/index.css) + main.tsx imports</name>
<files>apps/pwa/package.json, apps/pwa/src/styles/tokens.css, apps/pwa/src/styles/tokens.ts, apps/pwa/src/styles/index.css, apps/pwa/src/main.tsx</files>
<read_first>
- apps/pwa/src/main.tsx (current import order + QueryClientProvider setup)
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"Token Layer", §"Color Tokens", §"Spacing Scale", §"Typography", §"Breakpoints", §"Schedule-X CSS Override Strategy"
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Installation (PWA only)" (exact package versions) + §"Schedule-X CSS Token Override Pattern"
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/main.tsx" (Temporal-polyfill-first import order)
</read_first>
<action>
Install the Schedule-X stack in apps/pwa at the pinned versions from RESEARCH: `@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` (via pnpm add in apps/pwa). These are the Approved packages from RESEARCH §Package Legitimacy.
Create `apps/pwa/src/styles/tokens.css` declaring on `:root` every token from UI-SPEC §Color Tokens (--color-surface, --color-surface-dim, --color-surface-raised, --color-border, --color-border-subtle, --color-text-primary/secondary/muted, --color-focus-ring, --color-overlay, --color-member-0..5 with the exact UI-SPEC hexes, --color-shared-family:#F25C7A, --color-destructive:#DC2626), §Spacing Scale (--space-1..12), §Typography (--font-family-base, --text-body/label/heading/display sizes+weights+line-heights), and §Breakpoints (--bp-phone/tablet/desktop). Then add the Schedule-X override block mapping --sx-color-* vars to these tokens per UI-SPEC §Schedule-X CSS Override Strategy (--sx-color-primary→--color-member-0, surface/on-surface/outline/neutral, --sx-font-family→--font-family-base). Include the `@keyframes shimmer` from PATTERNS for the skeleton.
Create `apps/pwa/src/styles/tokens.ts` exporting a typed object mirroring the same token values (so components can use them in inline-style props). Keep names aligned with the CSS var names.
Create `apps/pwa/src/styles/index.css` importing tokens.css, plus a minimal global reset (box-sizing border-box, body font-family var, margin 0) — no third-party reset library.
Update `apps/pwa/src/main.tsx`: as the FIRST three imports (before React), add `import 'temporal-polyfill/global'`, `import '@schedule-x/theme-default/dist/index.css'`, `import './styles/index.css'` (in that order — Temporal must register before any Schedule-X usage, and token overrides must come after the Schedule-X default CSS so they win). Leave the QueryClientProvider tree intact.
</action>
<verify>
<automated>cd apps/pwa && grep -q "temporal-polyfill/global" src/main.tsx && grep -q "@schedule-x/theme-default/dist/index.css" src/main.tsx && grep -q "./styles/index.css" src/main.tsx && echo MAIN_IMPORTS_OK</automated>
<automated>cd apps/pwa && grep -q -- "--color-shared-family: #F25C7A" src/styles/tokens.css && grep -q -- "--sx-color-" src/styles/tokens.css && echo TOKENS_OK</automated>
<automated>cd apps/pwa && node -e "require('@schedule-x/calendar');require('temporal-polyfill');require('lucide-react');console.log('DEPS_OK')"</automated>
</verify>
<acceptance_criteria>
- apps/pwa/package.json dependencies include all six @schedule-x/* + temporal-polyfill + lucide-react at the RESEARCH-pinned versions
- tokens.css declares --color-shared-family:#F25C7A, all --color-member-0..5, the spacing/typography/breakpoint tokens, and a --sx-color-* override block
- main.tsx imports temporal-polyfill/global FIRST, then theme-default CSS, then styles/index.css
- tokens.ts exports a token object mirroring the CSS var values
</acceptance_criteria>
<done>Schedule-X stack installed; clean-theme token layer + Schedule-X var overrides present; main.tsx imports Temporal polyfill + theme + tokens in correct order.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: colorUtils + calendarConfig (firstDayOfWeek translation) — turn RED stubs green</name>
<files>apps/pwa/src/lib/colorUtils.ts, apps/pwa/src/lib/colorUtils.test.ts, apps/pwa/src/lib/calendarConfig.ts, apps/pwa/src/lib/calendarConfig.test.ts</files>
<read_first>
- apps/pwa/src/lib/calendarConfig.test.ts (RED stub from Plan 01 — its 0→7 assertion is the contract)
- apps/pwa/src/App.tsx (ColorSwatch inline-style pattern, lines 1834, the colorUtils analog)
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"Color derivation rule for event chips" (container=15% over white; onContainer=darken 40%) + §"Calendar config constant"
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 3: Schedule-X Calendar Configuration" + §"Pitfall 1" (firstDayOfWeek 0→7) + §"Pitfall 6" (limit to confirmed Schedule-X API surface)
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/lib/colorUtils.ts" + §"apps/pwa/src/lib/calendarConfig.ts"
</read_first>
<behavior>
- colorUtils: deriveScheduleXColors('#4A90D9') returns { main:'#4A90D9', container: <15% over white>, onContainer: <darkened 40%> }
- calendarConfig: WEEK_START_DAY === 0 translates to Schedule-X firstDayOfWeek 7
- calendarConfig: buildCalendarConfig([{id:'1',name:'Lucas',color:'#4A90D9'}]) yields calendars['1'] with lightColors and a 'shared' entry colored from #F25C7A
</behavior>
<action>
Create `apps/pwa/src/lib/colorUtils.ts` exporting `hexToContainer(hex)` (main at 15% opacity blended over #FFFFFF → returns a hex/rgb string), `hexToOnContainer(hex)` (main darkened 40%), and `deriveScheduleXColors(main)` returning `{ main, container, onContainer }`. Implement the math inline (no third-party color lib per RESEARCH Don't-Hand-Roll note — it's simple enough). Write colorUtils.test.ts asserting the derivations for a known hex.
Create `apps/pwa/src/lib/calendarConfig.ts` exporting `export const WEEK_START_DAY = 0` with the inline comment that Schedule-X uses 7=Sunday, a translation `const sxFirstDay = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY` exposed as an exported `SX_FIRST_DAY_OF_WEEK`, the view factory list (`createViewDay`, `createViewWeek`, `createViewMonthGrid`, `createViewMonthAgenda` from @schedule-x/calendar), and `buildCalendarConfig(members: MemberCalendarConfig[])` returning `{ calendars }` keyed by String(userId) plus a reserved `'shared'` entry using deriveScheduleXColors('#F25C7A'). Per-member entries use deriveScheduleXColors(member.color). Limit usage to the confirmed Schedule-X API surface (Pitfall 6). Turn the Plan 01 RED calendarConfig.test.ts green (it asserts the 0→7 translation).
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- src/lib/colorUtils.test.ts src/lib/calendarConfig.test.ts</automated>
<automated>cd apps/pwa && grep -q "WEEK_START_DAY === 0 ? 7" src/lib/calendarConfig.ts && echo WEEKSTART_TRANSLATED</automated>
</verify>
<acceptance_criteria>
- colorUtils.ts exports deriveScheduleXColors, hexToContainer, hexToOnContainer; colorUtils.test.ts green
- calendarConfig.ts exports WEEK_START_DAY (=0), the 0→7 firstDayOfWeek translation, and buildCalendarConfig
- buildCalendarConfig output keys per-member by String(userId) and includes a 'shared' entry from #F25C7A
- calendarConfig.test.ts (Plan 01 RED stub) passes the 0→7 assertion
</acceptance_criteria>
<done>colorUtils + calendarConfig built; firstDayOfWeek 0→7 translation encoded; both test files green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: hydrateEvents (Temporal, all-day guard) + Zustand store + windowed fetchEvents</name>
<files>apps/pwa/src/lib/hydrateEvents.ts, apps/pwa/src/lib/hydrateEvents.test.ts, apps/pwa/src/store/calendarStore.ts, apps/pwa/src/api/client.ts</files>
<read_first>
- apps/pwa/src/lib/hydrateEvents.test.ts (RED stub from Plan 01 — PlainDate vs ZonedDateTime contract)
- apps/pwa/src/api/client.ts (existing fetchMe pattern + the OLD unwindowed fetchEvents/EventsResponse to replace)
- apps/api/src/broker/expand.ts CalendarOccurrence shape (if Plan 02 merged first) OR .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 1" interface (the server JSON contract)
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 2" (hydrateEvents PlainDate/ZonedDateTime) + §"Pitfall 2/4"
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/store/calendarStore.ts" (Zustand state shape) + §"apps/pwa/src/api/client.ts"
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"State Management Contract" + §"View default logic (D-05)"
</read_first>
<behavior>
- hydrateEvents: occurrence with allDay:true and start '2026-06-15' → start is Temporal.PlainDate (NOT ZonedDateTime — guards Pitfall 2)
- hydrateEvents: timed occurrence → start/end Temporal.ZonedDateTime from the offset-aware ISO string
- hydrateEvents: passes uid/color/isShared through on a _familySync field; calendarId stringified
- calendarStore: setSelectedView persists to localStorage keyed by breakpoint group ('phone' | 'tablet-desktop')
</behavior>
<action>
Create `apps/pwa/src/lib/hydrateEvents.ts` exporting `ScheduleXEvent` interface and `hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[]`. Branch on `occ.allDay`: if true use `Temporal.PlainDate.from(occ.start)` for start/end (never construct a ZonedDateTime from midnight UTC — Pitfall 2); else `Temporal.ZonedDateTime.from(occ.start/end)`. Stringify calendarId. Carry uid/color/isShared on `_familySync`. Temporal is global via the main.tsx polyfill import; in tests import `'temporal-polyfill/global'` at the top of hydrateEvents.test.ts. Turn the Plan 01 RED hydrateEvents.test.ts green.
Create `apps/pwa/src/store/calendarStore.ts` exporting `useCalendarStore` (Zustand `create`) with state: `selectedView:string`, `selectedDate:string`, `openEventId:string|null`, `calendarRange:{start:string;end:string}` and setters. selectedView is initialized from localStorage keyed by breakpoint group (`window.matchMedia('(max-width:767px)').matches ? 'phone' : 'tablet-desktop'`), defaulting to 'month-agenda' on phone / 'month-grid' on tablet-desktop (D-05); setSelectedView writes back to localStorage under `calendarView.{group}`. calendarRange defaults to the current month ± 1 week (do NOT depend on Schedule-X onRangeUpdate for the first fetch — A4/Open Q2). Server events NEVER enter this store. Add `zustand` to apps/pwa deps if not already present.
In `apps/pwa/src/api/client.ts`, REPLACE the old unwindowed `fetchEvents()` and its `CalendarEvent`/`EventsResponse` types with: `CalendarOccurrence` interface (mirror the server contract), `OccurrencesResponse { occurrences: CalendarOccurrence[] }`, and `fetchEvents(start:string, end:string): Promise<OccurrencesResponse>` calling `/api/events?start=${start}&end=${end}` with `credentials:'include'` and the same `if(!res.ok) throw` pattern as fetchMe. Note: EventProof.tsx referenced the old fetchEvents — leave EventProof for Plan 05 to remove; if the type change breaks its build, update EventProof minimally to compile (it is replaced in Plan 05).
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- src/lib/hydrateEvents.test.ts</automated>
<automated>cd apps/pwa && grep -q "Temporal.PlainDate.from" src/lib/hydrateEvents.ts && grep -q "credentials: 'include'" src/api/client.ts && grep -q "calendarRange" src/store/calendarStore.ts && echo SLICE_LIB_OK</automated>
<automated>cd apps/pwa && pnpm exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- hydrateEvents.test.ts (Plan 01 RED stub) passes: all-day→PlainDate, timed→ZonedDateTime
- hydrateEvents.ts uses Temporal.PlainDate.from for all-day and never ZonedDateTime for all-day
- calendarStore exports useCalendarStore with selectedView/selectedDate/openEventId/calendarRange and persists selectedView to localStorage per breakpoint group
- client.ts fetchEvents takes (start,end), hits /api/events?start=&end= with credentials:'include', returns OccurrencesResponse
- tsc --noEmit clean in apps/pwa
</acceptance_criteria>
<done>hydrateEvents (with all-day PlainDate guard), Zustand UI store, and windowed fetchEvents all built; hydrateEvents.test.ts green; PWA typechecks.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| localStorage → store init | persisted view string read at startup |
| server JSON → hydrateEvents | occurrence strings parsed into Temporal objects |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02c-01 | Tampering | localStorage selectedView | accept | UI-only state; an invalid stored view falls back to the D-05 default; no security impact (single-device, two-person app) |
| T-02c-02 | Tampering | hydrateEvents string parsing | accept | Temporal.from throws on malformed input surfaced as a React Query error, not a security boundary; data already passed server zod validation |
| T-02c-SC | Tampering | @schedule-x/*, temporal-polyfill, lucide-react, zustand installs | mitigate | All Approved in RESEARCH §Package Legitimacy (mainstream, no postinstall); pinned versions; no [ASSUMED]/[SUS] |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa test` green (colorUtils, calendarConfig, hydrateEvents)
- `tsc --noEmit` clean in apps/pwa
- main.tsx import order correct (Temporal first)
</verification>
<success_criteria>
- Clean-theme token layer + Schedule-X overrides present (D-01/D-02)
- firstDayOfWeek 0→7 translation encoded; per-calendar config built from member colors
- All-day Temporal PlainDate guard in place
- Zustand UI store + windowed fetchEvents ready for Plan 04
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 03)
- CSS custom properties: --color-*, --space-*, --text-*, --font-family-base, --bp-*, --sx-color-* overrides (tokens.css)
- token object export (tokens.ts); styles/index.css global reset
- `hexToContainer`, `hexToOnContainer`, `deriveScheduleXColors` (colorUtils.ts)
- `WEEK_START_DAY`, `SX_FIRST_DAY_OF_WEEK`, `buildCalendarConfig`, `MemberCalendarConfig` (calendarConfig.ts)
- `hydrateEvents`, `ScheduleXEvent` (hydrateEvents.ts)
- `useCalendarStore` Zustand store + CalendarStore state shape (calendarStore.ts)
- `fetchEvents(start,end)`, `CalendarOccurrence`, `OccurrencesResponse` (client.ts — replaces old unwindowed versions)
- @schedule-x/* + temporal-polyfill + lucide-react + zustand dependencies
- main.tsx Temporal-polyfill-first import block
</artifacts_produced>
<output>
Create `.planning/phases/02-calendar-display/02-03-SUMMARY.md` when done
</output>
@@ -0,0 +1,182 @@
---
phase: 02-calendar-display
plan: 04
type: execute
wave: 3
depends_on: ["02-02", "02-03"]
files_modified:
- apps/pwa/src/components/CalendarShell.tsx
- apps/pwa/src/App.tsx
- apps/pwa/src/components/CalendarShell.test.tsx
autonomous: true
requirements: [CAL-02, CAL-03, CAL-07]
user_setup: []
must_haves:
truths:
- "Opening the app renders a Schedule-X calendar populated with REAL windowed Fastmail occurrences fetched from /api/events"
- "Events render in their owner's member color; shared-family events render in the reserved rose color"
- "The user can switch between day, week, month, and agenda views and events render in each"
- "Recurring events show all in-window occurrences; all-day events appear as full-day banners on the correct date with no shift"
- "The visible window drives the TanStack Query key; navigating to a new window refetches"
- "Default view is agenda on phone and month on tablet/desktop (D-05)"
artifacts:
- path: "apps/pwa/src/components/CalendarShell.tsx"
provides: "Schedule-X calendar wired to TanStack Query + hydrateEvents + Zustand range"
min_lines: 60
- path: "apps/pwa/src/App.tsx"
provides: "renders CalendarShell as the app root (replaces EventProof landing)"
contains: "CalendarShell"
key_links:
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "/api/events"
via: "useQuery(['events',start,end]) → fetchEvents"
pattern: "fetchEvents"
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "Schedule-X eventsService"
via: "eventsService.set(hydrateEvents(data.occurrences))"
pattern: "hydrateEvents"
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "apps/pwa/src/store/calendarStore.ts"
via: "calendarRange drives query key; onRangeUpdate updates it"
pattern: "useCalendarStore"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: CalendarShell — Schedule-X mounted + wired to TanStack Query + hydrate + Zustand range</name>
<files>apps/pwa/src/components/CalendarShell.tsx, apps/pwa/src/App.tsx</files>
<read_first>
- apps/pwa/src/App.tsx (current root: MemberBadge + EventProof + meQuery useQuery pattern lines 5892; 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 — Plan 03)
- apps/pwa/src/lib/calendarConfig.ts (buildCalendarConfig, 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"
</read_first>
<action>
Create `apps/pwa/src/components/CalendarShell.tsx`. Read the current user via `useQuery(['me'], fetchMe)` to source member colors; build the Schedule-X calendars config with `buildCalendarConfig(members)` where members come from /api/me (current user) — for Phase 2 the per-calendar color comes from users.color via the API occurrences, so the calendars config keys must match the `calendarId` (String(userId) or 'shared') that hydrateEvents stamps on each event.
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.
</action>
<verify>
<automated>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</automated>
<automated>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</automated>
<automated>cd apps/pwa && pnpm exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- 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
- 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
</acceptance_criteria>
<done>Schedule-X renders real windowed Fastmail occurrences (color-coded, all four views switchable) as the app root; PWA typechecks.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: CalendarShell render smoke test (CAL-03 — four views, real-data render path)</name>
<files>apps/pwa/src/components/CalendarShell.test.tsx</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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)
</behavior>
<action>
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: [<one timed>, <one all-day>] }`. 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.
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- src/components/CalendarShell.test.tsx</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>CAL-03 render smoke test green; Schedule-X integration and Temporal hydration path validated under test.</done>
</task>
</tasks>
<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>
<verification>
- `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
</verification>
<success_criteria>
- Real Fastmail occurrences render color-coded across day/week/month/agenda (CAL-02, CAL-03)
- Recurring + all-day occurrences render correctly in-window (CAL-07 display)
- Window navigation refetches via TanStack Query
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 04)
- `CalendarShell` (React component) — apps/pwa/src/components/CalendarShell.tsx
- App.tsx now renders CalendarShell as root (EventProof landing removed from render path)
- CalendarShell.test.tsx (CAL-03 render smoke)
- Schedule-X eventsService + eventModal plugin instances + useCalendarApp config in CalendarShell
</artifacts_produced>
<output>
Create `.planning/phases/02-calendar-display/02-04-SUMMARY.md` when done
</output>
@@ -0,0 +1,217 @@
---
phase: 02-calendar-display
plan: 05
type: execute
wave: 4
depends_on: ["02-04"]
files_modified:
- apps/pwa/src/components/EventDetailPopover.tsx
- apps/pwa/src/components/ColorLegend.tsx
- apps/pwa/src/components/AppNav.tsx
- apps/pwa/src/components/ViewToolbar.tsx
- apps/pwa/src/components/SkeletonCalendar.tsx
- apps/pwa/src/components/EmptyState.tsx
- apps/pwa/src/components/CalendarShell.tsx
- apps/pwa/src/components/EventProof.tsx
- apps/pwa/src/components/EventDetailPopover.test.tsx
autonomous: false
requirements: [CAL-02, CAL-03, CAL-07]
user_setup: []
must_haves:
truths:
- "Tapping any event opens a read-only detail popover (title, date/time, location, description, calendar name + owner color) built for Phase 3 reuse as the edit surface"
- "A color legend (member → color, plus 'Family' rose row) is always visible so ownership is decodable"
- "Initial load shows a shimmer skeleton; a successful fetch with zero events shows the empty state; a failed fetch shows the error state with a working Retry"
- "All chrome (AppNav, ViewToolbar, legend) is token-styled with 44px minimum touch targets and is keyboard/focus accessible"
- "The popover never renders event fields via dangerouslySetInnerHTML (XSS guard) and traps focus with Escape-to-close"
artifacts:
- path: "apps/pwa/src/components/EventDetailPopover.tsx"
provides: "read-only event detail popover; Phase-3-reusable edit surface; focus trap + XSS-safe"
exports: ["EventDetailPopover"]
- path: "apps/pwa/src/components/ColorLegend.tsx"
provides: "always-visible member→color legend with Family row"
exports: ["ColorLegend"]
- path: "apps/pwa/src/components/SkeletonCalendar.tsx"
provides: "shimmer loading skeleton (month + agenda variants)"
exports: ["SkeletonCalendar"]
key_links:
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "apps/pwa/src/components/EventDetailPopover.tsx"
via: "customComponents.eventModal + openEventId from Zustand"
pattern: "EventDetailPopover"
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "SkeletonCalendar | EmptyState | error state"
via: "TanStack Query isLoading/empty/isError branches"
pattern: "SkeletonCalendar"
---
<objective>
Complete the calendar UX: the read-only EventDetailPopover (built for Phase 3 reuse), the
always-visible ColorLegend, the AppNav + ViewToolbar chrome, and the loading / empty / error states
— all token-styled, accessible, and touch-friendly. Wire the popover and state branches into
CalendarShell, retire the Phase 1 EventProof, and gate the phase on a human visual verification.
Purpose: D-07 (legend), D-08 (tap-to-expand popover reusable in Phase 3), and the "slick"
constraint (skeleton/empty/error polish) land here. This closes the four phase success criteria
into a verifiable, glanceable calendar.
Output: popover + legend + nav + toolbar + skeleton + empty/error states wired into CalendarShell;
EventProof removed; human-verify checkpoint.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-calendar-display/02-UI-SPEC.md
@.planning/phases/02-calendar-display/02-PATTERNS.md
@.planning/phases/02-calendar-display/02-04-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: EventDetailPopover (read-only, accessible, XSS-safe, Phase-3-reusable) + wire into CalendarShell</name>
<files>apps/pwa/src/components/EventDetailPopover.tsx, apps/pwa/src/components/CalendarShell.tsx, apps/pwa/src/components/EventDetailPopover.test.tsx</files>
<read_first>
- apps/pwa/src/components/CalendarShell.tsx (Plan 04: openEventId via setOpenEventId, reserved customComponents.eventModal slot, the hydrated events in the TanStack Query cache)
- apps/pwa/src/App.tsx MemberBadge (component prop + inline-style analog, lines 3655)
- apps/pwa/src/store/calendarStore.ts (openEventId / setOpenEventId)
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"EventDetailPopover" + §"Interaction Contract: Keyboard / accessibility" + §"Copywriting Contract" (close = "×", aria-label="Close")
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/components/EventDetailPopover.tsx" (focus trap, Escape, never dangerouslySetInnerHTML)
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 4" (customComponents eventModal) + §Security (XSS via event fields)
</read_first>
<behavior>
- popover renders title (heading), date/time line, location line when present, description block when present, and calendar name + owner color swatch
- Escape closes the popover and clears openEventId; clicking the backdrop closes it
- event title/description rendered as plain text children (no dangerouslySetInnerHTML)
- close button has aria-label="Close" and a ≥44px touch target
</behavior>
<action>
Create `apps/pwa/src/components/EventDetailPopover.tsx` exporting `EventDetailPopover`. It resolves the open event from the TanStack Query events cache by `openEventId` (Zustand) — or accepts the Schedule-X eventModal `calendarEvent` prop when used as `customComponents.eventModal`. Render per UI-SPEC §EventDetailPopover: color chip + title (--text-heading), date/time (--text-label, secondary), location with a lucide-react MapPin icon if present, description block (max 4 lines then scroll), and a calendar-name + owner-color-swatch footer. Reserve an empty footer action area with a comment noting Phase 3 adds edit/delete here (D-08). On phone render as a bottom sheet; on tablet/desktop as an anchored popover (max-width 360px) — use the --bp-* tokens. Implement: focus trap while open, Escape closes (calls setOpenEventId(null)), focus returns to the triggering element, close "×" button aria-label="Close" with min 44px target, backdrop tap closes. NEVER use dangerouslySetInnerHTML for any event field (XSS guard). Token-styled only.
Wire into CalendarShell: pass `customComponents={{ eventModal: EventDetailPopover }}` to `<ScheduleXCalendar>`, and ensure the event-click path sets openEventId so the popover opens. Keep the eventsService/eventModal plugin wiring from Plan 04.
Write `EventDetailPopover.test.tsx` (jsdom): renders an event's fields as text, Escape triggers close, and asserts no dangerouslySetInnerHTML usage (render a title containing an HTML-looking string and assert it appears escaped as text).
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- src/components/EventDetailPopover.test.tsx</automated>
<automated>cd apps/pwa && grep -q "customComponents" src/components/CalendarShell.tsx && grep -q "EventDetailPopover" src/components/CalendarShell.tsx && ! grep -q "dangerouslySetInnerHTML" src/components/EventDetailPopover.tsx && echo POPOVER_WIRED_XSS_SAFE</automated>
</verify>
<acceptance_criteria>
- EventDetailPopover.tsx exports EventDetailPopover and renders title/time/location/description/calendar-name+color
- Escape closes and clears openEventId; close button has aria-label="Close" and ≥44px target
- No dangerouslySetInnerHTML anywhere in EventDetailPopover.tsx
- CalendarShell passes customComponents.eventModal = EventDetailPopover
- EventDetailPopover.test.tsx green incl. the escaped-HTML-as-text assertion
</acceptance_criteria>
<done>Tap-to-expand read-only popover (accessible, XSS-safe, Phase-3-reusable) wired into the calendar; test green.</done>
</task>
<task type="auto">
<name>Task 2: ColorLegend + AppNav + ViewToolbar + skeleton/empty/error states; retire EventProof</name>
<files>apps/pwa/src/components/ColorLegend.tsx, apps/pwa/src/components/AppNav.tsx, apps/pwa/src/components/ViewToolbar.tsx, apps/pwa/src/components/SkeletonCalendar.tsx, apps/pwa/src/components/EmptyState.tsx, apps/pwa/src/components/CalendarShell.tsx, apps/pwa/src/components/EventProof.tsx</files>
<read_first>
- apps/pwa/src/components/CalendarShell.tsx (Task 1 + Plan 04: eventsQuery isLoading/isError/data, calendars config for legend, selectedView/setSelectedView)
- apps/pwa/src/App.tsx (MemberBadge + ColorSwatch analogs for legend swatches)
- apps/pwa/src/components/EventProof.tsx (Phase 1 proof component to delete; confirm no remaining imports)
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"Component Inventory" (ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState) + §"Copywriting Contract" + §"Interaction Contract" + §"Post-Verification Reviewer Notes" (grid is primary focal point; phone-nav avatar needs aria-label+title)
- .planning/phases/02-calendar-display/02-PATTERNS.md §"ColorLegend", §"SkeletonCalendar" (shimmer keyframe), §"CSS Token Usage in Components"
</read_first>
<action>
Create the chrome and state components, token-styled, 44px touch targets, accessible per UI-SPEC:
- `ColorLegend.tsx`: one row per member (12px color circle + display name) plus a "Family" row using --color-shared-family. Always rendered, non-interactive (filter deferred, D-07). Swatch aria-label="{name}: {hex}". Members sourced from the calendars config / /api/me.
- `AppNav.tsx`: phone = 48px top bar (app name "FamilySync" left, user color swatch right with aria-label + title per reviewer note); tablet/desktop = 240px left sidebar (app name + ColorLegend). Accent colors NOT used on chrome (UI-SPEC 60/30/10).
- `ViewToolbar.tsx`: Today | < | > | [Day][Week][Month][Agenda]. Buttons role="button", keyboard-activatable, 44px min height, --text-label. Active view uses a subtle surface tint (NOT accent). Calls setSelectedView + drives Schedule-X view; prev/next/today drive Schedule-X navigation.
- `SkeletonCalendar.tsx`: shimmer (the @keyframes shimmer from tokens.css), month variant = 6×7 placeholder grid, agenda variant = 4 date-group blocks; root aria-busy="true", aria-label="Loading calendar". No spinner.
- `EmptyState.tsx`: centered lucide-react CalendarDays (32px, --color-text-muted) + heading "Nothing here" + body "No events in this period. Try a different date or switch views." (UI-SPEC copy).
In CalendarShell, render AppNav + ViewToolbar + ColorLegend chrome around `<ScheduleXCalendar>` (grid is the primary focal point per reviewer note). Branch on the events query: `isLoading` (initial) → SkeletonCalendar; success + `occurrences.length === 0` → EmptyState; `isError` (after retry:2) → error state replacing the grid with heading "Couldn't load events", body "Check your connection and try again.", and a "Retry" button calling `queryClient.refetchQueries({ queryKey: ['events'] })`. All token-styled.
Delete `apps/pwa/src/components/EventProof.tsx` and remove any remaining imports/references to it (Plan 04 removed it from the render path; confirm the file and its imports are gone).
</action>
<verify>
<automated>cd apps/pwa && grep -q "SkeletonCalendar" src/components/CalendarShell.tsx && grep -q "EmptyState" src/components/CalendarShell.tsx && grep -q "Couldn't load events" src/components/CalendarShell.tsx && grep -q "ColorLegend" src/components/CalendarShell.tsx && echo STATES_WIRED</automated>
<automated>cd /home/luc/Projects/familysync && ! test -f apps/pwa/src/components/EventProof.tsx && ! grep -rq "EventProof" apps/pwa/src && echo EVENTPROOF_REMOVED</automated>
<automated>cd apps/pwa && pnpm exec tsc --noEmit && pnpm exec eslint src --max-warnings=0 2>/dev/null || pnpm exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState exist and are token-styled (no hard-coded hex/px)
- CalendarShell renders the chrome and branches loading→Skeleton, empty→EmptyState, error→error state with working Retry (refetchQueries(['events']))
- ViewToolbar buttons are 44px min height, keyboard-activatable; active view uses a surface tint not accent
- EventProof.tsx is deleted and no references to it remain anywhere in apps/pwa/src
- tsc --noEmit clean in apps/pwa
</acceptance_criteria>
<done>Legend, nav, toolbar, and loading/empty/error states are wired and token-styled; EventProof retired; PWA typechecks.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: [CHECKPOINT] Visual + functional verification of the calendar (4 success criteria)</name>
<action>Operator-only manual verification: run the dev stack behind the dev-auth bypass and confirm all four phase success criteria against the live calendar UI. No code is written in this task. Follow the steps in how-to-verify exactly and report pass/fail per criterion.</action>
<what-built>
The complete read-only calendar: unified color-coded events across day/week/month/agenda, the
always-visible color legend, tap-to-expand read-only detail popover, and polished
skeleton/empty/error states — all on the clean token theme, behind the dev-auth bypass.
</what-built>
<how-to-verify>
1. Start the stack in dev with the bypass: ensure `NODE_ENV` is not production and `DEV_AUTH_BYPASS=true`, then run the API + PWA dev servers (e.g. `pnpm -r dev` or the project's documented dev command). Confirm the shared-family calendar was marked is_shared (Plan 02 checkpoint).
2. Open the PWA in a desktop browser. CONFIRM (success criterion 1): events appear color-coded — each member's events in their assigned color, shared-family events in the rose; the legend decodes which color is whom.
3. Switch Day / Week / Month / Agenda (success criterion 2): all events render correctly in each view; no missing or misplaced events.
4. Find a recurring event (e.g. a weekly meeting) and confirm (success criterion 3) all its occurrences show in the current window; navigate across a DST boundary (March 2026) and confirm the time does not jump ±1 hour.
5. Find an all-day event (birthday/holiday) and confirm (success criterion 4) it appears as a full-day banner on the correct date — not shifted a day early/late.
6. Tap an event: the read-only popover opens with title/time/location/description; Escape and backdrop-tap both close it.
7. Resize to a phone width (or open on a phone via the dev URL): confirm the default view is Agenda and the popover is a bottom sheet.
8. Force the empty state (navigate to a far-future empty window) and the error state (stop the API, hit Retry) and confirm both render as specified.
</how-to-verify>
<resume-signal>Type "approved" if all four success criteria hold, or describe the specific view/event/state that is wrong.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| event fields → popover DOM | title/location/description rendered into the popover |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02e-01 | Tampering (XSS) | EventDetailPopover event fields | mitigate | Plain-text JSX children only; no dangerouslySetInnerHTML; test asserts HTML-looking title renders escaped |
| T-02e-02 | Denial of service | Retry button hammering /api/events | accept | Manual user action, retry:2 backoff already on the query; two-person self-hosted app, negligible risk |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa test` green (popover test)
- `pnpm -r test` + `tsc --noEmit` clean in both workspaces (phase gate)
- Human-verify checkpoint passes all four success criteria
</verification>
<success_criteria>
- Color-coded events + legend decode ownership (criterion 1)
- All four views render events correctly (criterion 2)
- Recurring occurrences correct incl. DST (criterion 3)
- All-day events as full-day banners with no shift (criterion 4)
- Tap-to-expand popover + skeleton/empty/error states polished and accessible
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 05)
- `EventDetailPopover` (React component, Phase-3-reusable edit surface) — EventDetailPopover.tsx
- `ColorLegend`, `AppNav`, `ViewToolbar`, `SkeletonCalendar`, `EmptyState` (React components)
- CalendarShell: chrome + loading/empty/error branches + customComponents.eventModal wiring
- EventProof.tsx DELETED (Phase 1 proof component retired)
- EventDetailPopover.test.tsx
</artifacts_produced>
<output>
Create `.planning/phases/02-calendar-display/02-05-SUMMARY.md` when done
</output>