8.2 KiB
phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
| phase | plan | subsystem | tags | dependency_graph | tech_stack | key_files | decisions | metrics | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-calendar-display | 02 | api-expansion, api-events |
|
|
|
|
|
|
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 identityownerUserId: load-bearing client field; Schedule-X calendarId =isShared ? 'shared' : String(ownerUserId)isShared: from calendar row, stamped on every occurrence from metastart/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:
ICAL.parse()in try/catch — malformed input returns[]without throwing- VTIMEZONE registration loop runs before
new ICAL.RecurExpansion(...)— mandatory for DST correctness (Pitfall 3) - Non-recurring: single occurrence check against [windowStart, windowEnd)
- Recurring:
ICAL.RecurExpansionhandles RRULE + RDATE + EXDATE internally (no manual EXDATE filtering) - All-day serialization:
'YYYY-MM-DD'slice fromICAL.TimeDATE form — never UTC midnight shift (Pitfall 2) - Timed serialization: base
toString()+ formatted UTC offset fromutcOffset()in seconds - No
import ... 'rrule'anywhere in expand.ts
Test results (expand.test.ts — 3/3 green):
- DST:
T10:00:00present in every March 2026 occurrence across EST→EDT boundary - All-day:
allDay:true,start === '2026-06-15', noTin 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:startandendeach 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:
hasRrule=1 AND dtstartUtc < windowEnd— recurring masters from any datehasRrule=0 AND dtstartUtc IN [windowStart, windowEnd)— non-recurring timed eventsdtstartDate 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 runsUPDATE 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 isregister(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()callsthrow new HTTPException(500, ...)whenOIDC_AUTH_SECRETenv var is absent. The RED stub's test importsappfromsrc/index.jswhich mountsoidcAuthMiddleware, so all/api/eventsrequests get 500 before reaching the route handler. - Fix: Added
vi.mock('@hono/oidc-auth', ...)passthrough mock to events.test.ts, makingoidcAuthMiddlewarea 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:
- apps/api/src/broker/expand.ts — FOUND
Files modified:
- apps/api/src/routes/events.ts — FOUND
- apps/api/tests/routes/events.test.ts — FOUND
Commits:
Test suite:
- pnpm --filter @familysync/api test — 34/34 passed
- pnpm --filter @familysync/api typecheck — clean