---
phase: 11-per-event-reminders
verified: 2026-06-14T07:38:00Z
status: human_needed
score: 5/5
overrides_applied: 1
overrides:
  - must_have: "The reminder selector is disabled/hidden for all-day events in the UI"
    reason: "Deliberately superseded by decisions D-02/D-03 during discuss/UI-SPEC: the picker SWAPS to day-granularity presets (None / Same day (9 AM) / 1d / 2d / 1wk) instead of being disabled. The scheduler fires at 9 AM local on the computed alert day. Documented in 11-CONTEXT.md (roadmap_amendments section) and authorized by the user. The 9 AM local fire time (NOTIF-06) is retained and now governs day-lead choices."
    accepted_by: "luc"
    accepted_at: "2026-06-13T00:00:00Z"
human_verification:
  - test: "Create a timed event with a 30-minute reminder on the live dev stack using a member that has a connected Fastmail provider, then verify the Fastmail calendar shows a VALARM on the event, and the push fires at T-30."
    expected: "Event appears in Fastmail with BEGIN:VALARM / TRIGGER:-PT30M; a push notification arrives 30 minutes before the event."
    why_human: "Dev-bypass user 1 has no Fastmail provider configured (needsProviderSetup=true, no member_credentials). End-to-end CalDAV write + VAPID push requires a live Fastmail account. Server schema acceptance and ICS generation are verified by 327 automated tests; only the live Fastmail round-trip cannot be exercised in dev. Tracked in backlog 999.19."
---

# Phase 11: Per-Event Reminders — Verification Report

**Phase Goal:** A user can choose a reminder lead time per event (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, default None), serialized as a VALARM on the event, and the push scheduler fires at that exact lead — firing nothing when there is no alarm and never stripping reminders set in other clients.
**Verified:** 2026-06-14T07:38:00Z
**Status:** human_needed
**Re-verification:** No — initial verification

---

## Goal Achievement

### Observable Truths

The ROADMAP defines 5 success criteria. SC-5 has an authorized override (all-day swap behavior vs the literal "disabled" wording).

| # | Truth (Roadmap SC) | Status | Evidence |
|---|---|---|---|
| 1 | User can pick a reminder lead when creating/editing a timed event; choice round-trips to Fastmail as a VALARM | VERIFIED | `eventFieldsSchema` accepts `reminderLeadMinutes`; outboxWorker wires it to `buildVeventString`; 327/327 API tests pass including outboxWorker CAL-13 tests asserting `TRIGGER:-PT15M` in emitted ICS |
| 2 | Editing an event with a reminder set in another client preserves that VALARM — never silently dropped | VERIFIED | `hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes')` + `extractValarms(rawVevent)` preserve path in outboxWorker UPDATE branch; test `CAL-14 preserve: UPDATE with no reminderLeadMinutes field preserves existing VALARM from rawVevent` passes |
| 3 | A reminder push fires at the event's chosen lead time (e.g. T-30), not a hardcoded 15-min lead | VERIFIED | reminderScheduler reads `reminder_lead_minutes` from DB; per-event fire time: `fireTime = dtstartUtc - reminderLeadMinutes * 60s`; test `NOTIF-04: dispatches a timed event when now is inside the lead-driven fire window (30-min lead)` passes; `humanizeLeadMinutes` drives body from configured lead |
| 4 | An event with no reminder set produces no reminder push | VERIFIED | SQL `WHERE reminderLeadMinutes IS NOT NULL`; timed-0 guard `if (lead === 0) continue`; tests `NOTIF-05: NULL lead → zero dispatches` and `NOTIF-05: timed-0-lead → zero dispatches` pass |
| 5 | All-day event's reminder fires at 9 AM local on alert day; exactly-once across catch-up scans and rescheduled events | PASSED (override) | Override: SC-5 "disabled" wording superseded by D-02/D-03 — picker swaps to day-granularity presets, not disabled. 9 AM local fire and exactly-once are verified: `computeAlertInstantUtc` (DST-correct, 5 DST boundary tests pass); `uid:dtstartMs` dedup; all-day `pruneMs = start-of-next-day` fix; tests `NOTIF-06: all-day 9 AM-local fire` and reschedule re-fire pass. Accepted by luc on 2026-06-13. |

**Score:** 5/5 (including 1 override)

---

### Deferred Items

None. All must-haves are verified or covered by an authorized override.

---

### Required Artifacts

| Artifact | Expected | Status | Details |
|---|---|---|---|
| `apps/api/src/broker/vevent.ts` | VALARM builders, classifier, extractor, computeAlertInstantUtc, extended NewEventParams | VERIFIED | All 5 functions exported: `buildTimedValarm`, `buildAllDayValarm`, `classifyValarms`, `extractValarms`, `computeAlertInstantUtc`; `PRESET_MINUTES`, `AlarmClassification` type; `NewEventParams` extended with `reminderLeadMinutes`, `valarms`, `allDayAlertInstantUtc` |
| `apps/api/tests/broker/vevent.test.ts` | TDD coverage for all 5 units | VERIFIED | 37 tests (per SUMMARY-01 metrics); asserts `TRIGGER:-PT30M`, no `VALUE=TEXT`, absolute `VALUE=DATE-TIME`, DST boundaries (5 cases), `classifyValarms` all 4 kinds, `extractValarms` round-trip |
| `apps/api/src/broker/reminderScheduler.ts` | Variable-lead query, uid:dtstartMs dedup, all-day 9 AM, humanizeLeadMinutes, dropped isShared | VERIFIED | Two-query split (timed + all-day); `humanizeLeadMinutes` exported; `uid:dtstartMs` compound key; `pruneMs` separate field; `setInterval` only (no node-cron); wired in `index.ts` at line 149 |
| `apps/api/tests/broker/reminderScheduler.test.ts` | TDD coverage for variable-lead, dedup, all-day, humanized body | VERIFIED | 28 tests covering NOTIF-04/05/06/D-09; `uid:dtstartMs` dedup across 3 ticks; reschedule re-fire; personal calendar dispatch |
| `apps/api/src/broker/outboxWorker.ts` | reminderLeadMinutes in outboxPayloadSchema, hasExplicitReminder preserve path, buildVeventString wiring | VERIFIED | `reminderLeadMinutes: z.number().int().min(0).nullable().optional()` in `outboxPayloadSchema`; `hasExplicitReminder` guard at line 443; `extractValarms` + `computeAlertInstantUtc` imported and called; both UPDATE and CREATE branches pass correct params to `buildVeventString` |
| `apps/api/tests/broker/outboxWorker.test.ts` | Tests for preserve path, timed VALARM, null clear, all-day DATE-TIME | VERIFIED | 4 new tests: CAL-14 preserve, CAL-13 timed (TRIGGER:-PT15M), CAL-13 clear (no VALARM), CAL-13 all-day (VALUE=DATE-TIME) |
| `apps/api/src/broker/sync.ts` | VALARM → reminderLeadMinutes upsert via classifyValarms | VERIFIED | `classifyValarms` imported; `reminderLeadMinutesValue` derived (preset/offlist → minutes, custom/none → null); written to both `.values()` and `.onDuplicateKeyUpdate()` |
| `apps/api/tests/broker/sync.test.ts` | Tests for VALARM → DB column derivation | VERIFIED | 5 tests: preset TRIGGER:-PT30M → 30, no VALARM → null, absolute DATE-TIME → null, two VALARMs → null, onDuplicateKeyUpdate column present |
| `apps/api/src/broker/expand.ts` | reminderLeadMinutes on CalendarOccurrence, series-level propagation | VERIFIED | `reminderLeadMinutes: number \| null` on `CalendarOccurrence` interface; derived via `classifyValarms(rawVevent)` once per event; set in both non-recurring and recurring occurrence branches |
| `apps/api/tests/broker/expand.test.ts` | Tests for reminderLeadMinutes propagation (D-10) | VERIFIED | 4 tests: non-recurring with 30-min, all-day 0-lead, no VALARM null, recurring series-level inheritance |
| `apps/api/src/routes/events.ts` | reminderLeadMinutes in eventFieldsSchema + GET select | VERIFIED | `reminderLeadMinutes: z.number().int().min(0).nullable().optional()` at line 125; `calendarEvents.reminderLeadMinutes` in GET select at line 181 |
| `apps/pwa/src/api/client.ts` | reminderLeadMinutes on CreateEventPayload + CalendarOccurrence | VERIFIED | `reminderLeadMinutes: number \| null` on `CalendarOccurrence` (required, line 141); `reminderLeadMinutes?: number \| null` on `CreateEventPayload` (optional, line 212) |
| `apps/pwa/src/components/EventForm.tsx` | Reminder `<select id="event-reminder">`, allDay swap, edit pre-population, payload mapping | VERIFIED | `id="event-reminder"` at line 953; allDay conditional option set swap at lines 962–1014; `handleAllDayToggle` resets `setReminderValue('__none__')` at line 359; `reminderPayload` assembled and spread into `payload` at line 494; `deriveReminderValue` drives edit pre-population from `occurrence.reminderLeadMinutes` |
| `apps/pwa/src/components/EventForm.test.tsx` | Component tests: default None, allDay swap + reset, edit pre-population, payload mapping | VERIFIED | 54 PWA tests pass including Phase 11 describe block: D-01 default None, D-02/D-03 allDay swap + reset, edit pre-population (30 → "30 minutes before", 1440 all-day → "1 day before (9 AM)", off-list 45 → synthetic), payload mapping (None→null, preset→integer, Custom-kept→field absent) |

---

### Key Link Verification

| From | To | Via | Status | Details |
|---|---|---|---|---|
| EventForm reminder select | `CreateEventPayload.reminderLeadMinutes` | submit handler maps `reminderValue` → `reminderPayload` → spread into `payload` | WIRED | `reminderPayload = { reminderLeadMinutes: null \| parsed }` assembled at lines 467–477; spread at line 494 |
| `edit-mode load` | `occurrence.reminderLeadMinutes` | `deriveReminderValue` called on mount at line 321 | WIRED | `setReminderValue(deriveReminderValue(occurrence?.reminderLeadMinutes ?? null, occAllDay))` |
| `outboxWorker` UPDATE branch | `extractValarms(rawVevent)` | `hasExplicitReminder` gate at line 493 | WIRED | `if (!hasExplicitReminder && freshEtagRows.length > 0 && freshEtagRows[0].rawVevent) { valarmsToPreserve = extractValarms(...) }` |
| `sync.ts` upsert | `classifyValarms(rawVevent)` | `reminderLeadMinutesValue` derivation at line 137 | WIRED | `const alarmClass = classifyValarms(obj.data as string)` → written to both values() and onDuplicateKeyUpdate() |
| `GET /api/events` select | `expandOccurrences → CalendarOccurrence.reminderLeadMinutes` | `calendarEvents.reminderLeadMinutes` in select + `classifyValarms(rawVevent)` in expandOccurrences | WIRED | Select at events.ts line 181; derivation in expand.ts line 245 |
| `runReminderCheck` timed query | `calendarEvents.reminderLeadMinutes` | SQL `WHERE reminder_lead_minutes IS NOT NULL` | WIRED | `sql\`${calendarEvents.reminderLeadMinutes} IS NOT NULL\`` at reminderScheduler.ts line 143 |
| `notification.body` | `humanizeLeadMinutes` | Function call replacing hardcoded string | WIRED | `body: humanizeLeadMinutes(event.reminderLeadMinutes)` at reminderScheduler.ts line 292 |
| `startReminderScheduler` | `index.ts` server startup | Import + call inside `isMainModule` guard | WIRED | `import { startReminderScheduler }` at index.ts:18; `startReminderScheduler()` at index.ts:149 |

---

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|---|---|---|---|---|
| `EventForm.tsx` reminder select | `reminderValue` (state) | `deriveReminderValue(occurrence.reminderLeadMinutes)` on mount; user interaction | Yes — from DB-backed occurrence or user selection | FLOWING |
| `reminderScheduler.ts` `runReminderCheck` | `calendarEvents.reminderLeadMinutes` | DB column (populated by sync.ts upsert from native VALARMs, or set by outboxWorker on create/edit) | Yes — real DB query with `IS NOT NULL` filter | FLOWING |
| `outboxWorker.ts` preserve path | `valarmsToPreserve` | `extractValarms(freshEtagRows[0].rawVevent)` — reads live rawVevent from CalDAV GET | Yes — live VALARM components re-attached verbatim | FLOWING |

---

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|---|---|---|---|
| `buildTimedValarm(30)` produces `TRIGGER:-PT30M`, no `VALUE=TEXT` | Asserted in vevent.test.ts line 208–212 (vitest run 37/37) | PASS | PASS |
| `classifyValarms` returns `{kind:'offlist', leadMinutes:45}` for `TRIGGER:-PT45M` | Asserted in vevent.test.ts line 348 (vitest run) | PASS | PASS |
| `computeAlertInstantUtc('2026-06-15', 0, 'America/New_York')` → `2026-06-15T13:00:00.000Z` | Asserted in vevent.test.ts DST tests (vitest run) | PASS | PASS |
| Full API suite (327 tests) | `DB_HOST=127.0.0.1 pnpm --filter @familysync/api exec vitest run` | 327/327 PASS | PASS |
| Full PWA suite (201 tests) | `pnpm --filter @familysync/pwa exec vitest run` | 201/201 PASS | PASS |
| API typecheck | `pnpm --filter @familysync/api exec tsc --noEmit` | 0 errors | PASS |
| PWA typecheck | `pnpm --filter @familysync/pwa exec tsc --noEmit` | 0 errors | PASS |
| No `node-cron` in reminderScheduler | `grep node-cron reminderScheduler.ts` | no match | PASS |
| `startReminderScheduler` wired in index.ts | `grep startReminderScheduler apps/api/src/index.ts` | lines 18, 149 | PASS |

---

### Probe Execution

No probe scripts declared or applicable for this phase.

---

### Requirements Coverage

| REQ-ID | Source Plan | Description | Status | Evidence |
|---|---|---|---|---|
| CAL-13 | 11-01, 11-03, 11-04 | User picks reminder lead; choice serialized as VALARM on event written to Fastmail | SATISFIED | `eventFieldsSchema` field; outboxWorker CREATE/UPDATE wiring; `buildTimedValarm`/`buildAllDayValarm`; EventForm picker; 327 tests pass |
| CAL-14 | 11-01, 11-03, 11-04 | Editing an event preserves existing VALARM — never silently stripped | SATISFIED | `hasExplicitReminder` absent-vs-null sentinel; `extractValarms` preserve path; CAL-14 test passes; `__custom__` → field omitted from payload → server preserves |
| NOTIF-04 | 11-02 | Reminder fires at event's chosen lead time, not hardcoded 15-min | SATISFIED | Per-event `fireTime = dtstartUtc - lead * 60s`; `NOTIF-04` test passes; `humanizeLeadMinutes` body from DB lead |
| NOTIF-05 | 11-02 | No reminder set → no push | SATISFIED | `IS NOT NULL` SQL filter; timed-0 skip `if (lead === 0) continue`; `NOTIF-05` NULL and timed-0 tests pass; personal calendar restriction dropped |
| NOTIF-06 | 11-01, 11-02 | All-day fires at 9 AM local; exactly-once across catch-up and reschedule | SATISFIED | `computeAlertInstantUtc` (DST-correct); `uid:dtstartMs` dedup; `pruneMs = start-of-next-day` for all-day; all-day 9 AM and reschedule tests pass |

No orphaned requirements for Phase 11. REQUIREMENTS.md traceability table shows CAL-13, CAL-14, NOTIF-04, NOTIF-05, NOTIF-06 all mapped to Phase 11; all plans' `requirements` fields cover these IDs completely with no gaps or extras.

---

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|---|---|---|---|---|
| `apps/pwa/src/components/EventForm.tsx` | 701, 1086, 1121, 1135 | `placeholder=` | Info | HTML input placeholder attributes (not stub indicators). Normal UI copy. No impact. |

No `TBD`, `FIXME`, `XXX`, `return null`, empty handlers, or stub patterns found in any Phase 11 modified file.

---

### Human Verification Required

#### 1. Live end-to-end reminder round-trip (Fastmail + push)

**Test:** Using a household member account with a connected Fastmail provider (not the dev-bypass user 1), create a timed event with a 30-minute reminder. Open the event in the PWA to confirm the reminder shows "30 minutes before". Wait for the push notification to fire at T-30. Then open the same event in Fastmail Web or Apple Calendar and confirm the VALARM is present.

**Expected:** (a) PWA edit form shows "30 minutes before" pre-populated. (b) A push notification with body "Starts in 30 min" arrives ~30 minutes before event start. (c) Fastmail / Apple Calendar shows a reminder on the event.

**Why human:** Dev-bypass user (id 1) has no Fastmail provider configured (`needsProviderSetup=true`, empty `member_credentials`/`calendars`). A live CalDAV PUT (outbox → Fastmail) and a real VAPID push to a subscribed device cannot be exercised without a provisioned provider. All server-side paths are validated by 327 automated tests and a route-mocked Playwright smoke. Only the live Fastmail round-trip and real device push require a human + live device. Tracked in backlog 999.19.

---

### Gaps Summary

No gaps. All 5 ROADMAP success criteria are verified (1 with an authorized override for the deliberate all-day behavior evolution from "disabled" to "preset swap"). All 327 API tests and 201 PWA tests pass. All typechecks clean. No debt markers. The one item in the `human_verification` section is a dev-environment caveat (no live Fastmail provider in dev), not an implementation gap.

---

_Verified: 2026-06-14T07:38:00Z_
_Verifier: Claude (gsd-verifier)_
