Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
16 KiB
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 |
|
|
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)