---
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
