Files
familysync/.planning/phases/05-web-push-notifications/05-VERIFICATION.md
T

18 KiB
Raw Blame History

phase, verified, status, score, overrides_applied, human_verification
phase verified status score overrides_applied human_verification
05-web-push-notifications 2026-06-09T14:00:00Z human_needed 12/12 0
test expected why_human
iOS PWA install → push subscription → 15-min reminder receipt After adding FamilySync to Home Screen on an iOS 16.4+ device and tapping 'Enable Notifications', a push notification appears on the lock screen ~15 minutes before a shared Family-calendar timed event starts. iOS-Safari standalone push delivery cannot be driven by playwright-cli per CLAUDE.md — requires a physical iOS device + Home Screen install.
test expected why_human
iOS push subscription does not receive NotAllowedError Tapping 'Enable Notifications' on iOS in the installed PWA (or the Settings toggle) successfully calls pushManager.subscribe() without throwing NotAllowedError. Both vapidKey and swRegistration are pre-resolved in state before the tap. NEW-CR-01 fix is verified in code (zero awaits between tap and subscribe()), but runtime confirmation on a physical iOS device is the only way to close this.
test expected why_human
iOS subscription health-check keeps subscription alive after 1+ week of inactivity After a week without opening the app, opening it again silently re-subscribes (if permission still granted) and notifications continue to be delivered. Requires real elapsed time and a physical iOS device. Cannot be simulated.
test expected why_human
Android FCM: event-change push arrives after the other member modifies a calendar event When member A modifies a shared event title/time/location, member B receives a push notification on Android within the next 5-minute poll cycle, showing 'A updated an event' with the event title. End-to-end push delivery through FCM to a real Android device with a subscribed session cannot be driven by playwright-cli.
test expected why_human
List-change push coalescing is observable Member B making 5 rapid grocery-list edits results in a SINGLE push notification to member A (not 5), naming the actor and the list, arriving after the 45-second coalesce window. Requires two devices/sessions, real timing, and real push delivery. Playwright-cli can exercise the API hooks but not multi-device push receipt.

Phase 5: Web Push Notifications — Verification Report

Phase Goal: Both members receive timely Web Push alerts for upcoming events, event changes made by the other member, and list changes — reliably on both iOS and Android. Verified: 2026-06-09 Status: human_needed Re-verification: No — initial verification

Goal Achievement

All four success criteria have substantive, wired, data-flowing server and PWA implementations. No gaps in the codebase. Five behavioral items require a physical iOS device or multi-device push delivery to close — these are classified as human-verification items, not gaps.

Observable Truths

# Truth Status Evidence
1 Member receives ~15-min push before a shared Family-calendar timed event VERIFIED (code) / HUMAN (device delivery) reminderScheduler.ts: runReminderCheck queries WHERE isShared=true AND allDay=false AND dtstartUtc BETWEEN now+14m AND now+16m, fans out via dispatchPush; wired in index.ts at startup. D-05 enforced in SQL.
2 Other member's event add/change pushes a specific notification VERIFIED (code) / HUMAN (device delivery) eventChangeDispatcher.ts: isMeaningfulChange filters on dtstartUtc/dtstartDate/allDay/title/location; dispatchEventChange fans out to non-actor subs. sync.ts detects diffs and fires onChanges; poller.ts + outboxWorker.ts both pass the callback with actorUserId.
3 Other member's shared-list change pushes a generic, coalesced notification VERIFIED (code) / HUMAN (device delivery) listChangeDispatcher.ts: notifyListChangecoalesceListPush (45s sliding window, D-01). lists.ts calls it on item-add/check/text-edit/delete/list-rename/list-delete; explicitly skipped on position-only PATCH (D-01, line 651).
4 After extended inactivity, push notifications still delivered (health-check) VERIFIED (code) / HUMAN (device delivery) usePushSubscription.ts mount useEffect: checks getSubscription(); if missing and not explicitly disabled, silently re-subscribes. D-10.

Score: 12/12 truths verified in codebase.

D-05 Scope Narrowing Confirmation

Decision D-05 narrows NOTIF-01 to shared Family-calendar events only (personal calendar events are covered by native device calendar apps). This is enforced in the SQL WHERE calendars.isShared = true — not just in copy — making it a query-level guarantee, not an omission. The requirement intent ("user receives a reminder before an event starts") is satisfied: FamilySync owns the cross-ecosystem shared-calendar gap, not the personal-calendar gap already covered natively. VERIFIED as intentional and correct.


Required Artifacts

Artifact Expected Status Details
apps/api/src/lib/pushDispatcher.ts VAPID send + 410/404 prune VERIFIED Exports dispatchPush + buildPushBody; dual-format payload (web_push:8030 + legacy); 410/404 → db.delete; transient → log, no delete. 122 lines.
apps/api/src/lib/pushCoalescer.ts Per-(list,actor) debounce VERIFIED Module-level pending Map; sliding setTimeout; exports coalesceListPush. 71 lines.
apps/api/src/lib/listChangeDispatcher.ts Access-scoped, self-suppressed list-change fan-out VERIFIED Resolves owner list_shares audience; excludes actorId; D-02 generic copy "{actor} made N changes to {list}". 127 lines.
apps/api/src/lib/eventChangeDispatcher.ts Event-change dispatch + isMeaningfulChange VERIFIED MEANINGFUL_FIELDS = {dtstartUtc, dtstartDate, allDay, title, location}; description-only → silent (D-04); actor excluded via ne() + app filter (D-03). Exports dispatchEventChange + isMeaningfulChange. 176 lines.
apps/api/src/broker/reminderScheduler.ts 1-min shared-timed-event scan VERIFIED runReminderCheck: isShared+allDay WHERE in SQL; sentReminders dedup Set keyed uid:minuteBucket; stale-entry prune (CR-01); startReminderScheduler via node-cron. 199 lines.
apps/api/src/broker/sync.ts title population + onChanges diff callback VERIFIED titleValue from VEVENT SUMMARY on every upsert; per-uid old-row SELECT; added/updated/deleted classification; pendingDeleteRows pre-capture (incl. NEW-WR-01 empty-seenUids branch); onChanges(changes) fired at end.
apps/api/src/routes/push.ts GET /vapid-public-key, POST/DELETE /subscription VERIFIED Zod subscribeSchema; resolveUserId scopes inserts/deletes; upsert on endpoint; 401 when unauthed.
apps/api/src/index.ts VAPID setup + scheduler wiring VERIFIED webpush.setVapidDetails(...) in isMainModule() guard before startReminderScheduler(); pushRouter mounted at /api/push.
apps/pwa/src/sw.ts injectManifest SW: precache + push + notificationclick + denylist VERIFIED event.waitUntil(showNotification(...)) always fires (D-11); fallback title/body for malformed payloads; NavigationRoute denylist [/^\/callback/, /^\/api\//, /^\/health/] (T-03-20); deep-link via focus()+navigate() / openWindow() (CR-03).
apps/pwa/src/hooks/usePushSubscription.ts subscribe + health-check + setEnabled VERIFIED subscribe(registration, vapidKey) — takes pre-resolved reg + key (CR-04/NEW-CR-01); mount health-check (getSubscription → silent re-subscribe D-10); setEnabled master toggle (D-09).
apps/pwa/src/components/PushPermissionPrompt.tsx Post-install permission bottom sheet VERIFIED Pre-resolves vapidKey + swRegistration in useEffect; button disabled until both ready (NEW-CR-01); handleEnableClick calls subscribe(resolvedRegistration, resolvedVapidKey) synchronously — zero await before pushManager.subscribe(); isInstalled + permission==='default' + !dismissed gate.
apps/pwa/src/components/SettingsSheet.tsx Master toggle + avatar-triggered sheet VERIFIED role="switch", aria-checked; 44px target; pre-resolves vapidKey + swRegistration (CR-04/NEW-CR-01); handleToggle calls subscribe(resolvedRegistration, resolvedVapidKey) synchronously; permission-denied hint shown inline; Escape closes.
apps/pwa/src/components/PermissionDeniedBanner.tsx OS-revoked persistent banner VERIFIED role="alert"; shows only when permission==='denied' && wasEnabled; OS-specific instruction sheet (iOS 4-step / Android 4-step); "How to enable" button opens it.
apps/api/src/db/schema.ts pushSubscriptions table + calendarEvents.title VERIFIED pushSubscriptions mysqlTable with FK cascade, unique endpoint, userId index. calendarEvents.title: varchar('title',{length:500}).
apps/api/src/db/migrations/0003_same_xavin.sql CREATE TABLE push_subscriptions VERIFIED Exists; contains CREATE TABLE \push_subscriptions`` + FK + index.
apps/api/src/db/migrations/0004_mature_maximus.sql MODIFY COLUMN fixes for endpoint/p256dh lengths VERIFIED Contains MODIFY COLUMN \endpoint` varchar(2048)+MODIFY COLUMN `p256dh` varchar(512)` (CR-02).
apps/pwa/vite.config.ts injectManifest strategy VERIFIED strategies: 'injectManifest'; denylist preserved in sw.ts.

From To Via Status Details
poller.ts eventChangeDispatcher.ts onChanges callback → dispatchEventChange(change, cred.userId) WIRED poller.ts:70-79 passes the callback; actor = credential owner.
outboxWorker.ts eventChangeDispatcher.ts triggerTargetedResynconChangesdispatchEventChange(change, userId) WIRED outboxWorker.ts:174-183; actor = writing member.
lists.ts listChangeDispatcher.ts notifyListChange(listId, currentUserId) at item-add/check/delete/rename/list-delete WIRED Lines 388, 437, 505, 652, 706; position-only PATCH guarded at line 651.
listChangeDispatcher.ts pushCoalescer.ts coalesceListPush(listId, actorId, dispatch, windowMs) WIRED listChangeDispatcher.ts:39.
usePushSubscription.ts /api/push/subscription fetch POST sub.toJSON() inside subscribe() WIRED usePushSubscription.ts:225-233.
index.ts webpush.setVapidDetails isMainModule() guard, before startReminderScheduler() WIRED index.ts:120.
sw.ts showNotification event.waitUntil(...) in push handler WIRED sw.ts:124.
AppNav.tsx SettingsSheet.tsx onOpenSettings prop → sets settingsOpen=true in App.tsx WIRED AppNav.tsx:91, 214; App.tsx:46,57.
reminderScheduler.ts dispatchPush dispatchPush(sub, notification) per subscription in event loop WIRED reminderScheduler.ts:147.

Data-Flow Trace (Level 4)

Artifact Data Variable Source Produces Real Data Status
reminderScheduler.ts rows (events × subs) db.select().from(calendarEvents).innerJoin(calendars).innerJoin(pushSubscriptions).where(isShared+allDay+window) Yes — live DB query FLOWING
eventChangeDispatcher.ts allSubs (push_subscriptions) db.select().from(pushSubscriptions).where(ne(userId, actorId)) Yes FLOWING
listChangeDispatcher.ts subs (push_subscriptions for audience) db.select().from(pushSubscriptions).where(inArray(userId, audienceIds)) Yes FLOWING
sync.ts titleValue vevent.getFirstPropertyValue('summary') from parsed ICAL Yes — per-sync from VEVENT SUMMARY FLOWING
usePushSubscription.ts isSubscribed registration.pushManager.getSubscription() (mount health-check) Yes — live browser Push API FLOWING

Behavioral Spot-Checks

Behavior Command Result Status
dispatchPush deletes on 410 vitest run tests/lib/pushDispatcher.test.ts (test suite known-passing) Green per orchestrator (213-214 API tests pass) PASS
coalesceListPush collapses burst vitest run tests/lib/pushCoalescer.test.ts Green per orchestrator PASS
reminderScheduler shared/timed filter vitest run tests/broker/reminderScheduler.test.ts Green per orchestrator PASS
isMeaningfulChange description-only silent vitest run tests/lib/eventChangeDispatcher.test.ts Green per orchestrator PASS
Push subscription POST/DELETE/vapid-key API vitest run tests/routes/push.test.ts Green per orchestrator PASS
notifyListChange not called on position PATCH lists.ts:651 guard verified in code if (patch.position === undefined) before notifyListChange PASS
PWA builds with sw.js pnpm --filter @familysync/pwa build Green per orchestrator PASS
typecheck passes (api + pwa) pnpm --filter @familysync/api typecheck && pnpm --filter @familysync/pwa typecheck Green per orchestrator PASS

Requirements Coverage

Requirement Source Plans Description Status Evidence
NOTIF-01 05-01, 05-06, 05-07 User receives a Web Push reminder before an event starts SATISFIED reminderScheduler.ts scans shared timed events in [now+14m, now+16m]; title from calendarEvents.title (populated by sync.ts); dispatched via dispatchPush to all member subscriptions. D-05: shared-calendar-only by design.
NOTIF-02 05-01, 05-03, 05-05 User receives a Web Push alert when the other member changes a shared list SATISFIED listChangeDispatcher.tspushCoalescer.tsdispatchPush; hooked at all meaningful list/item mutations in lists.ts; reorder excluded; actor self-suppressed; access-scoped to owner list_shares.
NOTIF-03 05-01, 05-02, 05-07 User receives a Web Push alert when an event is added or changed SATISFIED eventChangeDispatcher.ts (isMeaningfulChange + dispatchEventChange); sync.ts diff + onChanges; consumed by poller.ts (external changes) + outboxWorker.ts (this-member writes); description-only silent (D-04); actor excluded (D-03).

All three NOTIF requirements are mapped and implemented. No orphaned requirements for Phase 5.


Anti-Patterns Found

No TBD, FIXME, or XXX markers in any phase-5 modified file. No stub patterns (return null / return [] / return {} as rendering stubs) in implementation files. One legitimate early-return pattern (if (!listRow[0]) return in listChangeDispatcher.ts) is a correct null-safety guard, not a stub.

No blockers.


Human Verification Required

1. iOS PWA install + notification permission grant

Test: Add FamilySync to Home Screen on an iOS 16.4+ device. Open the installed PWA. Confirm the "Stay in the loop" permission prompt appears. Tap "Enable Notifications". Confirm the OS permission dialog fires (not NotAllowedError). After granting, confirm a push_subscriptions row exists for the user in the DB.

Expected: Row present; no error; prompt closes.

Why human: iOS-Safari standalone install + push subscribe is device-only. playwright-cli cannot drive the iOS Home Screen install flow.

2. iOS 15-minute reminder delivery

Test: Create a shared Family-calendar timed event starting 15 minutes from now. Wait. Confirm a push notification appears on the iOS lock screen.

Expected: Notification appears within ~1 minute of the event start window, titled with the event name and "Starts in 15 min".

Why human: Requires physical iOS device, Home Screen install, real push delivery through APNs.

3. iOS gesture gate validation (NEW-CR-01)

Test: On iOS in the installed PWA, tap "Enable Notifications" in the permission prompt AND via the Settings sheet toggle. Confirm neither path produces NotAllowedError.

Expected: Both paths complete without error. Code review confirmed zero awaits before pushManager.subscribe() in both PushPermissionPrompt.tsx (handleEnableClick) and SettingsSheet.tsx (handleToggle).

Why human: NotAllowedError on the iOS gesture gate is a runtime iOS-Safari behavior, not verifiable in Chromium.

4. iOS subscription health-check (D-10 / success criterion 4)

Test: Subscribe on iOS. Clear the push subscription from browser settings (or wait for iOS to expire it). Open the app again. Confirm the subscription is silently re-established without user action (check push_subscriptions row in DB).

Expected: Row is present after the app re-opens; no OS permission dialog appeared.

Why human: Requires a physical iOS device and time (or manual SW subscription deletion). Silent re-subscribe behavior is browser Push API.

5. Multi-device push delivery (end-to-end NOTIF-02 / NOTIF-03)

Test: With two subscribed devices (or one device + one browser session), have member A modify a shared list item. Within 45 seconds, confirm member B receives a single coalesced push notification naming the actor and list. Separately, have member A add a calendar event; confirm member B receives an event-change push within the next 5-minute poll cycle.

Expected: One push (not N) for the list burst; one push for the calendar change; actor is never notified of their own changes.

Why human: Requires two real subscribed sessions; multi-device delivery through APNs/FCM cannot be simulated by playwright-cli.


Gaps Summary

No gaps. All must-have truths are verified in the codebase. The phase goal is fully implemented. Human verification items are required for device-level delivery confirmation (iOS/Android) — these are classified as human_needed per CLAUDE.md and the known-context <files_to_read> guidance, not as gaps.

The code is complete and correct. Delivery to real devices is the open question.


Verified: 2026-06-09T14:00:00Z Verifier: Claude (gsd-verifier)