Files
familysync/.planning/phases/11-per-event-reminders/11-03-SUMMARY.md
T
Lucas Berger 72773a35eb docs(11-03): complete Plan 03 — reminderLeadMinutes end-to-end plumbing
- Schema field + VALARM wiring in outbox worker (CAL-13/CAL-14)
- sync.ts VALARM classification → reminderLeadMinutes upsert (D-07/NOTIF-05)
- CalendarOccurrence.reminderLeadMinutes + GET select (D-10)
- 13 new TDD tests; 132/132 broker tests pass; tsc clean
2026-06-13 22:24:01 -04:00

7.7 KiB

phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
phase plan subsystem tags dependency_graph tech_stack key_files decisions metrics
11-per-event-reminders 03 api/broker
valarm
reminderLeadMinutes
tdd
schema
outbox
sync
expand
cal-13
cal-14
requires provides affects
Plan 11-01 (buildTimedValarm, buildAllDayValarm, classifyValarms, extractValarms, computeAlertInstantUtc, NewEventParams extensions)
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
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)
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
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
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
duration_minutes completed_date tasks_completed files_modified
8 2026-06-14 3 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:

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

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 offlistleadMinutes (scheduler ground truth)
  • custom (absolute DATE-TIME or multiple VALARMs) → null (D-07/NOTIF-05)
  • nonenull (no VALARM)

No schema DDL change — reminder_lead_minutes column was added by Phase 10 migration.

CalendarOccurrence Propagation (D-10)

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

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