--- 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