Files
familysync/.planning/milestones/v1.0-phases/02-calendar-display/02-02-SUMMARY.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

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
recurrence-expansion
dst-correctness
windowed-query
color-join
zod-validation
requires provides affects
02-01
expandOccurrences
CalendarOccurrence
windowed-events-endpoint
02-04
02-05
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
created modified
apps/api/src/broker/expand.ts
apps/api/src/routes/events.ts
apps/api/tests/routes/events.test.ts
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
duration completed tasks_completed tasks_deferred files_created files_modified
22m 2026-06-05 2 1 1 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: calendarEventsinnerJoin(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:

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

  • 6736194 — feat(02-02): expandOccurrences Task 1
  • 9ee26c0 — feat(02-02): windowed events route Task 2

Test suite:

  • pnpm --filter @familysync/api test — 34/34 passed
  • pnpm --filter @familysync/api typecheck — clean