docs(02): create phase plan

This commit is contained in:
Lucas Berger
2026-06-04 15:09:55 -04:00
parent b842955612
commit fc4cc2ccf8
7 changed files with 134 additions and 76 deletions
@@ -16,9 +16,9 @@ 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"
- "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)"
- "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:
@@ -35,7 +35,7 @@ must_haves:
pattern: "expandOccurrences"
- from: "apps/api/src/routes/events.ts"
to: "users.color"
via: "innerJoin calendars→users, select color + isShared"
via: "innerJoin calendars→users, select color + isShared + users.id"
pattern: "users\\.color"
---
@@ -76,7 +76,7 @@ shared-family calendar (open question A3) — the operator marks it.
<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/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"
@@ -87,9 +87,10 @@ shared-family calendar (open question A3) — the operator marks it.
- 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[]`.
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).
@@ -107,12 +108,13 @@ shared-family calendar (open question A3) — the operator marks it.
</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; expand.test.ts green.</done>
<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">
@@ -130,7 +132,7 @@ shared-family calendar (open question A3) — the operator marks it.
<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
- 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)
@@ -140,27 +142,27 @@ shared-family calendar (open question A3) — the operator marks it.
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 })`.
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, and 400 on bad params).
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
- 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 + calendars.isShared
- events.ts calls expandOccurrences per row and returns { occurrences }
- 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, and expands recurrences; events.test.ts green.</done>
<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.</action>
<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
@@ -176,7 +178,7 @@ shared-family calendar (open question A3) — the operator marks it.
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>
<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>
@@ -202,11 +204,12 @@ shared-family calendar (open question A3) — the operator marks it.
<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)
- 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>