---
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, an ownerUserId, and the DB calendarId'
    - '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) — single local timezone for v1 (D-10)"
    - '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 + users.id'
      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 78–115; allDay = dtstart.isDate; D-13 dtstartDate/dtstartUtc split)
    - apps/api/tests/broker/expand.test.ts (the RED stub from Plan 01 — its DST wall-clock + all-day + EXDATE 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)
    - each occurrence carries ownerUserId and isShared (passed through from meta) so the client can route color
  </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[]`. Both `ownerUserId` and `isShared` are required occurrence fields (the client routes Schedule-X color by `isShared ? 'shared' : String(ownerUserId)`, NOT by calendarId) — stamp them on every emitted occurrence from `meta`.

    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`
    - CalendarOccurrence carries ownerUserId:number and isShared:boolean; every emitted occurrence has them populated from meta
    - 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 carrying ownerUserId + isShared; 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 and ownerUserId=users.id
    - 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 })`. The `ownerUserId: row.userId` field is load-bearing — the client routes calendar color by it. 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, ownerUserId, 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, ownerUserId present
    - 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 + users.id + calendars.isShared
    - events.ts calls expandOccurrences per row with ownerUserId: row.userId 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/ownerUserId, 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. NOTE: if the operator wants a DIFFERENT shared-calendar identification rule than the manual `calendars.isShared` UPDATE encoded here (e.g. automatic displayName-pattern matching, or keying off the broker user id), that is a PLAN REVISION — re-run `/gsd-plan-phase 2` with the new rule — NOT a resume-from-checkpoint. The "approved" resume path only covers the manual is_shared marking already built.</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) to confirm the manual is_shared marking. If you instead want a different identification rule encoded in code, that is a plan revision (re-run /gsd-plan-phase 2), not a resume — say so and describe the rule.</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 + ownerUserId (checkpoint)
</verification>

<success_criteria>

- /api/events returns windowed, color-tagged, DST-correct, all-day-safe, EXDATE-aware occurrences
- Each occurrence carries ownerUserId + isShared for client-side color routing
- 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>
