---
phase: 05-web-push-notifications
verified: 2026-06-09T14:00:00Z
status: human_needed
score: 12/12
overrides_applied: 0
human_verification:
  - test: 'iOS PWA install → push subscription → 15-min reminder receipt'
    expected: "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."
    why_human: 'iOS-Safari standalone push delivery cannot be driven by playwright-cli per CLAUDE.md — requires a physical iOS device + Home Screen install.'
  - test: 'iOS push subscription does not receive NotAllowedError'
    expected: "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."
    why_human: '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: 'iOS subscription health-check keeps subscription alive after 1+ week of inactivity'
    expected: 'After a week without opening the app, opening it again silently re-subscribes (if permission still granted) and notifications continue to be delivered.'
    why_human: 'Requires real elapsed time and a physical iOS device. Cannot be simulated.'
  - test: 'Android FCM: event-change push arrives after the other member modifies a calendar event'
    expected: "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."
    why_human: 'End-to-end push delivery through FCM to a real Android device with a subscribed session cannot be driven by playwright-cli.'
  - test: 'List-change push coalescing is observable'
    expected: '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.'
    why_human: '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`: `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)_
