diff --git a/.planning/phases/05-web-push-notifications/05-UAT.md b/.planning/phases/05-web-push-notifications/05-UAT.md new file mode 100644 index 0000000..6d7919f --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-UAT.md @@ -0,0 +1,55 @@ +--- +status: testing +phase: 05-web-push-notifications +source: [05-VERIFICATION.md] +started: 2026-06-10T02:46:43Z +updated: 2026-06-10T02:46:43Z +--- + +## Current Test + +number: 1 +name: iOS PWA install → push subscription → 15-min reminder receipt +expected: | + After adding FamilySync to the 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. +awaiting: user response + +## Tests + +### 1. iOS PWA install → push subscription → 15-min reminder receipt +expected: After adding FamilySync to the 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. +result: [pending] + +### 2. 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. +result: [pending] + +### 3. 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. +result: [pending] + +### 4. 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. +result: [pending] + +### 5. 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. +result: [pending] + +## Summary + +total: 5 +passed: 0 +issues: 0 +pending: 5 +skipped: 0 +blocked: 0 + +## Gaps diff --git a/.planning/phases/05-web-push-notifications/05-VERIFICATION.md b/.planning/phases/05-web-push-notifications/05-VERIFICATION.md new file mode 100644 index 0000000..11a174c --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-VERIFICATION.md @@ -0,0 +1,193 @@ +--- +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 `` 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)_