226 lines
16 KiB
Markdown
226 lines
16 KiB
Markdown
---
|
||
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 78–115; 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>
|