diff --git a/.planning/phases/11-per-event-reminders/11-03-SUMMARY.md b/.planning/phases/11-per-event-reminders/11-03-SUMMARY.md new file mode 100644 index 0000000..1f839a0 --- /dev/null +++ b/.planning/phases/11-per-event-reminders/11-03-SUMMARY.md @@ -0,0 +1,161 @@ +--- +phase: 11-per-event-reminders +plan: "03" +subsystem: api/broker +tags: [valarm, reminderLeadMinutes, tdd, schema, outbox, sync, expand, cal-13, cal-14] +dependency_graph: + requires: + - "Plan 11-01 (buildTimedValarm, buildAllDayValarm, classifyValarms, extractValarms, computeAlertInstantUtc, NewEventParams extensions)" + provides: + - "reminderLeadMinutes in eventFieldsSchema (ingress validation)" + - "reminderLeadMinutes in outboxPayloadSchema (drain re-validation)" + - "hasExplicitReminder preserve-on-edit path in outboxWorker UPDATE branch" + - "VALARM wiring in buildVeventString calls (both UPDATE and CREATE branches)" + - "reminderLeadMinutesValue derivation + upsert in sync.ts" + - "reminderLeadMinutes on CalendarOccurrence (expand.ts)" + - "reminderLeadMinutes in GET /api/events select" + affects: + - "apps/api/src/routes/events.ts" + - "apps/api/src/broker/outboxWorker.ts" + - "apps/api/src/broker/sync.ts" + - "apps/api/src/broker/expand.ts" + - "Plan 11-02 (reminderScheduler — scheduler reads reminderLeadMinutes from DB)" +tech_stack: + added: [] + patterns: + - "hasExplicitReminder sentinel mirrors hasExplicitRecurrence WR-01 pattern" + - "Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes') for absent-vs-null distinction (D-08)" + - "classifyValarms(rawVevent) for series-level reminderLeadMinutes derivation in expand.ts" + - "computeAlertInstantUtc(start, leadDays, tz) for all-day absolute DATE-TIME trigger" + - "extractValarms(rawVevent) preserve-on-edit re-attachment via addSubcomponent" +key_files: + created: [] + modified: + - apps/api/src/routes/events.ts + - apps/api/src/broker/outboxWorker.ts + - apps/api/src/broker/sync.ts + - apps/api/src/broker/expand.ts + - apps/api/tests/broker/outboxWorker.test.ts + - apps/api/tests/broker/sync.test.ts + - apps/api/tests/broker/expand.test.ts +decisions: + - "D-REMIND-ABSENT: absent field (not in payload) = no-change path (D-08); Object.prototype.hasOwnProperty.call distinguishes absent from null — mirrors WR-01 for VALARM preservation" + - "D-REMIND-EXPAND: reminderLeadMinutes derived inside expandOccurrences via classifyValarms(rawVevent) — self-contained; consistent with sync.ts derivation (both consume the same VEVENT source)" + - "D-REMIND-ALLDAY-LEADDAYS: all-day leadDays = reminderLeadMinutes / 1440 (consistent with D-05 mapping); computeAlertInstantUtc called at drain time (not enqueue) for correct DST" +metrics: + duration_minutes: 8 + completed_date: "2026-06-14" + tasks_completed: 3 + files_modified: 7 +--- + +# Phase 11 Plan 03: reminderLeadMinutes End-to-End Plumbing Summary + +`reminderLeadMinutes` round-trips end-to-end: `eventFieldsSchema` ingress validation → outbox payload drain → `buildVeventString` VALARM emission → Fastmail PUT; sync.ts parses native VALARMs into the DB column (scheduler ground truth); `CalendarOccurrence` surfaces the value for edit-mode picker pre-population. + +## Tasks Completed + +| Task | Description | RED Commit | GREEN Commit | +|------|-------------|------------|--------------| +| 1 | Schema field + outbox worker preserve-on-edit + buildVeventString wiring | 79f6871 | 4f42b75 | +| 2 | sync.ts VALARM → reminderLeadMinutes upsert | cdca930 | 1cc0d72 | +| 3 | Surface reminderLeadMinutes on CalendarOccurrence + GET select | 7df11d2 | e171431 | + +## Schema Field (eventFieldsSchema + outboxPayloadSchema) + +Both schemas now have: +```typescript +reminderLeadMinutes: z.number().int().min(0).nullable().optional() +``` + +Four-state semantics (D-08): +- `absent` — field not present in payload; UPDATE branch preserves existing VALARM verbatim (D-08) +- `null` — explicit "None" → VALARM cleared on write-back +- `0` — same-day all-day (9 AM on event date); timed 0 = None (D-06) +- `positive` — N minutes before event start (timed) or N/1440 days before (all-day) + +## hasExplicitReminder Preserve-on-Edit Path (CAL-14) + +Pattern mirrors the WR-01 `hasExplicitRecurrence` + `_preservedRrule` preserve path: + +UPDATE branch computes: +- `const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes')` +- `!hasExplicitReminder` + rawVevent has VALARMs → `valarmsToPreserve = extractValarms(rawVevent)` (preserve verbatim via addSubcomponent) +- `hasExplicitReminder` + allDay + value → `allDayAlertInstantUtcUpdate = computeAlertInstantUtc(start, lead/1440, tz)` +- `hasExplicitReminder` + null → clear (no valarms, reminderLeadMinutes=null passed to buildVeventString) + +buildVeventString call extended with: `reminderLeadMinutes: hasExplicitReminder ? fields.reminderLeadMinutes : undefined`, `valarms: valarmsToPreserve`, `allDayAlertInstantUtc: allDayAlertInstantUtcUpdate`. + +CREATE branch: no preserve path (new event always carries explicit picker value). Computes `allDayAlertInstantUtcCreate` from `reminderLeadMinutes / 1440` when allDay. + +## sync.ts Derivation Rule + +```typescript +import { classifyValarms } from './vevent.js'; +const alarmClass = classifyValarms(obj.data as string); +const reminderLeadMinutesValue: number | null = + alarmClass.kind === 'preset' || alarmClass.kind === 'offlist' + ? alarmClass.leadMinutes + : null; +``` + +Written to both `.values({...})` and `.onDuplicateKeyUpdate({ set: {...} })`. Classification rules: +- `preset` or `offlist` → `leadMinutes` (scheduler ground truth) +- `custom` (absolute DATE-TIME or multiple VALARMs) → `null` (D-07/NOTIF-05) +- `none` → `null` (no VALARM) + +No schema DDL change — `reminder_lead_minutes` column was added by Phase 10 migration. + +## CalendarOccurrence Propagation (D-10) + +```typescript +// CalendarOccurrence interface: +reminderLeadMinutes: number | null; // after hasRrule +``` + +Derived once per VEVENT in `expandOccurrences` via `classifyValarms(rawVevent)` (series-level, D-10). All occurrences inherit the master's value. Added to both non-recurring and recurring occurrence construction branches. GET `/api/events` select also includes `calendarEvents.reminderLeadMinutes` for edit-mode pre-population. + +## Verification Results + +- `pnpm --filter @familysync/api exec vitest run tests/broker/ tests/broker/vevent.test.ts`: **132/132 PASS** +- `pnpm --filter @familysync/api exec tsc --noEmit`: **CLEAN (0 errors)** +- No `drizzle-kit push` introduced; no schema.ts DDL change +- 13 new tests added: 4 (Task 1 outboxWorker), 5 (Task 2 sync), 4 (Task 3 expand) + +## Deviations from Plan + +None — plan executed exactly as written. + +## Known Stubs + +None. All wiring is complete end-to-end. No placeholder values or TODO markers. + +## Threat Flags + +No new threat surface. All new fields are bounded integers validated by Zod at both ingress (eventFieldsSchema) and drain (outboxPayloadSchema) — T-11-06 mitigated. `classifyValarms` is try/catch safe — T-11-07 mitigated. The VALARM preserve path (`extractValarms`) rides the existing CR-02 scoped query on the writing member's calendar — T-11-08 unchanged. + +## Self-Check: PASSED + +Files exist: +- FOUND: apps/api/src/routes/events.ts +- FOUND: apps/api/src/broker/outboxWorker.ts +- FOUND: apps/api/src/broker/sync.ts +- FOUND: apps/api/src/broker/expand.ts +- FOUND: apps/api/tests/broker/outboxWorker.test.ts +- FOUND: apps/api/tests/broker/sync.test.ts +- FOUND: apps/api/tests/broker/expand.test.ts + +Commits exist: +- 79f6871: test(11-03) RED Task 1 +- 4f42b75: feat(11-03) GREEN Task 1 +- cdca930: test(11-03) RED Task 2 +- 1cc0d72: feat(11-03) GREEN Task 2 +- 7df11d2: test(11-03) RED Task 3 +- e171431: feat(11-03) GREEN Task 3 + +Key exports verified: +- reminderLeadMinutes in eventFieldsSchema: CONFIRMED (grep: `reminderLeadMinutes: z.number()`) +- hasExplicitReminder in outboxWorker.ts: CONFIRMED +- reminderLeadMinutes on CalendarOccurrence: CONFIRMED +- classifyValarms import in sync.ts: CONFIRMED +- classifyValarms import in expand.ts: CONFIRMED