Files
familysync/.planning/phases/02-calendar-display/02-02-PLAN.md
T

16 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
02-calendar-display 02 execute 2
02-01
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
false
CAL-02
CAL-03
CAL-07
truths artifacts key_links
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
path provides exports
apps/api/src/broker/expand.ts expandOccurrences() — ICAL.RecurExpansion + VTIMEZONE registration + allDay split → CalendarOccurrence[]
expandOccurrences
CalendarOccurrence
path provides contains
apps/api/src/routes/events.ts windowed /api/events with calendarEvents→calendars→users join, hasRrule pre-filter, zod validation zValidator
from to via pattern
apps/api/src/routes/events.ts apps/api/src/broker/expand.ts expandOccurrences() called per recurring/timed row expandOccurrences
from to via pattern
apps/api/src/routes/events.ts users.color innerJoin calendars→users, select color + isShared users.color
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 Task 1: expandOccurrences() — server-side expansion with VTIMEZONE + allDay split apps/api/src/broker/expand.ts, apps/api/tests/broker/expand.test.ts - 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" - 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) 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.
cd apps/api && pnpm test -- tests/broker/expand.test.ts - 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 expandOccurrences() produces DST-correct, all-day-safe, EXDATE-aware concrete occurrences; expand.test.ts green. Task 2: Windowed /api/events with color/owner join, hasRrule pre-filter, zod validation apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts - 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) - 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) 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).
cd apps/api && pnpm test -- tests/routes/events.test.ts 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 - 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) /api/events is windowed, validated (zod + 90-day cap), joined for color/isShared, and expands recurrences; events.test.ts green. Task 3: [CHECKPOINT] Resolve & mark the shared-family calendar (A3) 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. 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). 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 = ;` (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". Type "approved" with the chosen calendar id(s), or describe a different shared-calendar rule to encode.

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

<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>
Create `.planning/phases/02-calendar-display/02-02-SUMMARY.md` when done