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

226 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 02-calendar-display
plan: 02
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- 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
autonomous: false
requirements: [CAL-02, CAL-03, CAL-07]
user_setup: []
must_haves:
truths:
- "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"
artifacts:
- path: "apps/api/src/broker/expand.ts"
provides: "expandOccurrences() — ICAL.RecurExpansion + VTIMEZONE registration + allDay split → CalendarOccurrence[]"
exports: ["expandOccurrences", "CalendarOccurrence"]
- path: "apps/api/src/routes/events.ts"
provides: "windowed /api/events with calendarEvents→calendars→users join, hasRrule pre-filter, zod validation"
contains: "zValidator"
key_links:
- from: "apps/api/src/routes/events.ts"
to: "apps/api/src/broker/expand.ts"
via: "expandOccurrences() called per recurring/timed row"
pattern: "expandOccurrences"
- from: "apps/api/src/routes/events.ts"
to: "users.color"
via: "innerJoin calendars→users, select color + isShared"
pattern: "users\\.color"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: expandOccurrences() — server-side expansion with VTIMEZONE + allDay split</name>
<files>apps/api/src/broker/expand.ts, apps/api/tests/broker/expand.test.ts</files>
<read_first>
- 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"
</read_first>
<behavior>
- 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)
</behavior>
<action>
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.
</action>
<verify>
<automated>cd apps/api && pnpm test -- tests/broker/expand.test.ts</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>expandOccurrences() produces DST-correct, all-day-safe, EXDATE-aware concrete occurrences; expand.test.ts green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Windowed /api/events with color/owner join, hasRrule pre-filter, zod validation</name>
<files>apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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)
</behavior>
<action>
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).
</action>
<verify>
<automated>cd apps/api && pnpm test -- tests/routes/events.test.ts</automated>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>/api/events is windowed, validated (zod + 90-day cap), joined for color/isShared, and expands recurrences; events.test.ts green.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: [CHECKPOINT] Resolve & mark the shared-family calendar (A3)</name>
<action>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.</action>
<what-built>
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).
</what-built>
<how-to-verify>
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 = <chosen 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".
</how-to-verify>
<resume-signal>Type "approved" with the chosen calendar id(s), or describe a different shared-calendar rule to encode.</resume-signal>
</task>
</tasks>
<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>
<verification>
- `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)
</verification>
<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>
<output>
Create `.planning/phases/02-calendar-display/02-02-SUMMARY.md` when done
</output>