Files
familysync/.planning/phases/11-per-event-reminders/11-VERIFICATION.md
T

16 KiB
Raw Blame History

phase, verified, status, score, overrides_applied, overrides, human_verification
phase verified status score overrides_applied overrides human_verification
11-per-event-reminders 2026-06-14T07:38:00Z human_needed 5/5 1
must_have reason accepted_by accepted_at
The reminder selector is disabled/hidden for all-day events in the UI 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. luc 2026-06-13T00:00:00Z
test expected why_human
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. Event appears in Fastmail with BEGIN:VALARM / TRIGGER:-PT30M; a push notification arrives 30 minutes before the event. 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)

From To Via Status Details
EventForm reminder select CreateEventPayload.reminderLeadMinutes submit handler maps reminderValuereminderPayload → 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)