diff --git a/.planning/phases/02-calendar-display/02-02-SUMMARY.md b/.planning/phases/02-calendar-display/02-02-SUMMARY.md new file mode 100644 index 0000000..d13d477 --- /dev/null +++ b/.planning/phases/02-calendar-display/02-02-SUMMARY.md @@ -0,0 +1,156 @@ +--- +phase: 02-calendar-display +plan: "02" +subsystem: api-expansion, api-events +tags: [recurrence-expansion, dst-correctness, windowed-query, color-join, zod-validation] +dependency_graph: + requires: [02-01] + provides: [expandOccurrences, CalendarOccurrence, windowed-events-endpoint] + affects: [02-04, 02-05] +tech_stack: + added: [] + patterns: + - ICAL.TimezoneService.register() before ICAL.RecurExpansion (DST correctness) + - D-13 allDay discrimination: isDate=true → YYYY-MM-DD, false → offset-aware ISO string + - Drizzle innerJoin calendarEvents→calendars→users for color/isShared/ownerUserId join + - zValidator('query', ...) with ISO-date regex + 90-day window cap + - vi.mock('@hono/oidc-auth') passthrough pattern for route unit tests +key_files: + created: + - apps/api/src/broker/expand.ts + modified: + - apps/api/src/routes/events.ts + - apps/api/tests/routes/events.test.ts +decisions: + - "ICAL.TimezoneService.register(timezone, name) arg order: first arg is Timezone object, second is optional name string — inverted from research pseudocode which incorrectly placed tzid first" + - "events.test.ts needed @hono/oidc-auth mock — oidcAuthMiddleware throws HTTP 500 when OIDC env vars are absent, blocking all route tests; added passthrough mock as Rule 3 fix" + - "Task 3 (shared-family calendar marking) deferred by operator: id=1 is the operator personal calendar, dedicated shared Family calendar does not exist yet — is_shared stays false for all current rows" +metrics: + duration: "22m" + completed: "2026-06-05" + tasks_completed: 2 + tasks_deferred: 1 + files_created: 1 + files_modified: 2 +--- + +# Phase 02 Plan 02: Windowed /api/events — Recurrence Expansion + Color Join Summary + +Server-side recurrence expansion with DST-correct VTIMEZONE registration, all-day-safe serialization, EXDATE exclusion, color/isShared join, Zod-validated windowed endpoint. RED stubs from Plan 01 turned GREEN; full API suite (34/34) passes. + +## What Was Built + +### Task 1: expandOccurrences() — apps/api/src/broker/expand.ts + +New file exporting `CalendarOccurrence` interface and `expandOccurrences()` function. + +**Interface `CalendarOccurrence`** — carries all fields the Schedule-X frontend needs: +- `id`: `${uid}::${startIso}` stable identity +- `ownerUserId`: load-bearing client field; Schedule-X calendarId = `isShared ? 'shared' : String(ownerUserId)` +- `isShared`: from calendar row, stamped on every occurrence from meta +- `start`/`end`: `'YYYY-MM-DD'` for all-day, offset-aware ISO string for timed (e.g. `2026-03-15T10:00:00-04:00`) +- `allDay`, `color`, `calendarId`, `calendarName`, `uid`, `title`, `location`, `description` + +**Implementation contracts met:** +1. `ICAL.parse()` in try/catch — malformed input returns `[]` without throwing +2. VTIMEZONE registration loop runs before `new ICAL.RecurExpansion(...)` — mandatory for DST correctness (Pitfall 3) +3. Non-recurring: single occurrence check against [windowStart, windowEnd) +4. Recurring: `ICAL.RecurExpansion` handles RRULE + RDATE + EXDATE internally (no manual EXDATE filtering) +5. All-day serialization: `'YYYY-MM-DD'` slice from `ICAL.Time` DATE form — never UTC midnight shift (Pitfall 2) +6. Timed serialization: base `toString()` + formatted UTC offset from `utcOffset()` in seconds +7. No `import ... 'rrule'` anywhere in expand.ts + +**Test results (expand.test.ts — 3/3 green):** +- DST: `T10:00:00` present in every March 2026 occurrence across EST→EDT boundary +- All-day: `allDay:true`, `start === '2026-06-15'`, no `T` in string +- EXDATE: 4 occurrences returned (not 5), June 15 absent + +### Task 2: Windowed /api/events — apps/api/src/routes/events.ts + +Rewrote `eventsRouter.get('/')` from raw table dump to display-ready windowed endpoint. + +**Zod validation:** +- `eventsQuerySchema`: `start` and `end` each required, validated as `/^\d{4}-\d{2}-\d{2}$/` +- `zValidator('query', eventsQuerySchema)` — 400 returned automatically on schema failure +- Post-schema: 90-day window cap returns 400 if span exceeds limit (T-02b-02 DoS guard) + +**SQL join:** +`calendarEvents` → `innerJoin(calendars)` → `innerJoin(users)` selecting `rawVevent`, `calendars.id`, `calendars.displayName`, `calendars.isShared`, `users.id`, `users.color` + +**WHERE pre-filter (RESEARCH.md Open Q3 / Pitfall 5):** +Three-branch OR covering: +1. `hasRrule=1 AND dtstartUtc < windowEnd` — recurring masters from any date +2. `hasRrule=0 AND dtstartUtc IN [windowStart, windowEnd)` — non-recurring timed events +3. `dtstartDate IN [start, end)` — all-day events (DATE comparison) + +**Color derivation:** `row.isShared ? '#F25C7A' : row.userColor` — shared calendar gets rose (D-06) + +**`ownerUserId: row.userId`** passed to `expandOccurrences()` — this is the load-bearing field for client-side Schedule-X calendar routing. + +**Error handling:** try/catch wrapping the entire DB+expansion block; returns 503 on DB error (health.ts pattern). + +**Broker-boundary invariant preserved:** No tsdav / createFastmailClient import. + +**Test results (events.test.ts — 4/4 green):** +- 400 on missing start +- 400 on missing end +- 400 on malformed date +- 200 + `{ occurrences: [] }` with correct shape on valid window + +### Task 3: Shared-Family Calendar Marking — RESOLVED BY DEFERRAL + +Per operator decision communicated before execution: + +The `calendars.is_shared` column exists (added in Plan 01, default false). The route logic is complete and correct — `isShared=true` rows will produce rose-colored (`#F25C7A`) occurrences with `isShared:true`. No UPDATE was run because: + +- `id=1` ("Calendar") is the operator's personal calendar, not a shared household calendar +- The dedicated shared "Family" calendar does not yet exist in Fastmail (operator will create it later, share it to both household members' accounts, and the broker will sync it) +- Once that row appears in `calendars`, the operator runs `UPDATE calendars SET is_shared = 1 WHERE display_name = 'Family'` (or by ID) + +**Future action required:** After the Family calendar is created and synced, run the is_shared UPDATE to enable rose coloring for shared events. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] ICAL.TimezoneService.register() argument order** +- **Found during:** Task 1 typecheck +- **Issue:** Research pseudocode showed `register(tzid, timezone)` but the actual API is `register(timezone, name?)` — tzid-first call causes TS2345 type error +- **Fix:** Swapped to `register(new ICAL.Timezone({ component: vtz, tzid }), tzid)` +- **Files modified:** apps/api/src/broker/expand.ts + +**2. [Rule 3 - Blocker] @hono/oidc-auth throws 500 in test environment** +- **Found during:** Task 2 (events test execution) +- **Issue:** `oidcAuthMiddleware()` calls `throw new HTTPException(500, ...)` when `OIDC_AUTH_SECRET` env var is absent. The RED stub's test imports `app` from `src/index.js` which mounts `oidcAuthMiddleware`, so all `/api/events` requests get 500 before reaching the route handler. +- **Fix:** Added `vi.mock('@hono/oidc-auth', ...)` passthrough mock to events.test.ts, making `oidcAuthMiddleware` a no-op in the test environment. Same pattern works for future route tests that use app.request(). +- **Files modified:** apps/api/tests/routes/events.test.ts + +## Known Stubs + +None. The files created in this plan are complete and functional. The shared-calendar marking deferral is an operational setup step, not a code stub. + +## Threat Flags + +No new threat surface beyond the plan's threat model. + +- T-02b-01 (start/end tampering) — mitigated: zValidator with ISO-date regex; Drizzle parameterized queries +- T-02b-02 (DoS via oversized window) — mitigated: 90-day cap with explicit 400 response +- T-02b-03 (cross-account leakage) — carried from Phase 1; route is behind oidcAuthMiddleware +- T-02b-04 (malformed rawVevent) — accepted: expandOccurrences try/catch returns [] on parse failure + +## Self-Check: PASSED + +Files created: +- [x] apps/api/src/broker/expand.ts — FOUND + +Files modified: +- [x] apps/api/src/routes/events.ts — FOUND +- [x] apps/api/tests/routes/events.test.ts — FOUND + +Commits: +- [x] 6736194 — feat(02-02): expandOccurrences Task 1 +- [x] 9ee26c0 — feat(02-02): windowed events route Task 2 + +Test suite: +- [x] pnpm --filter @familysync/api test — 34/34 passed +- [x] pnpm --filter @familysync/api typecheck — clean