18 KiB
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 |
|
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: notifyListChange → coalesceListPush (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. |
Key Link Verification
| 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 |
triggerTargetedResync → onChanges → dispatchEventChange(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.ts → pushCoalescer.ts → dispatchPush; 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)