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