Files
familysync/.planning/milestones/v1.1-phases/11-per-event-reminders/11-VERIFICATION.md
T
2026-06-18 22:21:38 -04:00

163 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 9621014; `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 467477; 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 208212 (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)_