# Phase 5: Web Push Notifications - Context **Gathered:** 2026-06-09 **Status:** Ready for planning **Mode:** mvp (vertical slice — see ROADMAP.md `**Mode:** mvp`) ## Phase Boundary Deliver Web Push so both members receive timely, reliable notifications on the installed PWA (iOS + Android) for three triggers: 1. **Event reminders** — ~15 min before a **shared Family-calendar** event starts (NOTIF-01) 2. **Event-change alerts** — when the *other* member adds/changes a relevant event (NOTIF-03) 3. **List-change alerts** — when the *other* member modifies a shared list (NOTIF-02) Plus the iOS reliability machinery (subscription health-check + visible-notification guarantee) that keeps subscriptions alive across inactivity (success criterion 4). **Not in this phase:** quiet-hours/DND, per-event custom reminder offsets, per-category opt-out, notifying on the member's *own* changes, reminders for personal calendars (see decisions + deferred). ## Implementation Decisions ### Notification copy & anti-spam - **D-01:** **Coalesce list-change pushes per list** within a short window (~30–60s). A grocery burst (many rapid edits) collapses into one push, not one-per-change. Planner must define the debounce/window mechanism. Reorder (`position`) changes do **not** push at all. - **D-02:** **Detail level differs by source.** Event notifications (reminders + changes) show **specifics** — title, time, action (e.g. `Lucas moved Dentist → Wed 3pm`, `Soccer practice starts in 15 min`). **List pings stay generic** — they name the actor, the list, and a change count, but **not item text** (e.g. `Wife made 3 changes to Groceries`). Rationale: lists are the chattier, lower-stakes source; generic keeps the lock screen cleaner. - **D-03:** **Name the actor** in every change notification (`Wife checked off…`, `Lucas added…`). Two-person household — attribution is clear and useful. - **D-04:** **Event-change trigger granularity = meaningful changes only.** New event, deletion, and changes to **time/date/title/location** push. **Description-only edits stay silent.** Avoids noise from trivial tweaks. ### Reminder scope & timing - **D-05:** **Reminders fire for SHARED Family-calendar events only** — *by design*, not as a limitation. Each member's **native device calendar app** (Apple Calendar / Android, syncing their Fastmail personal calendar) already fires reminders for personal events; FamilySync must **not duplicate** those. FamilySync owns reminders for the **shared Family calendar** — the cross-ecosystem coordination gap the native clients don't reliably cover. This narrows the literal reading of NOTIF-01 deliberately; verification must treat "shared-calendar events" as the reminder surface. - **Caveat for planner:** if a member *also* subscribes the shared calendar in their native calendar app they could get duplicate reminders — that's a household setup choice, out of our control. Do not engineer against it. - **Dependency:** the shared "Family" calendar is `is_shared=1`. Per Phase 2 D-16 the operator must first create + share the Family calendar and mark it shared. Until then there are no shared events, so the reminder path has nothing to fire on (correct, not a bug). Planner should handle the empty-shared-calendar case gracefully. - **D-06:** **Fixed ~15 min lead time** for v1. No per-event or custom offset. (Custom/ per-event lead time deferred to v1.x.) - **D-07:** **All-day events get no reminder.** They have no start time; reminders are for timed events only. (They remain visible in the app.) ### Onboarding & opt-out - **D-08:** **Contextual permission prompt right after PWA install** (or first installed launch): a one-line explainer, then trigger `Notification.requestPermission()` / `pushManager.subscribe()` on a **tap gesture**. iOS hard-requires installed-PWA + a user gesture. Highest opt-in for the non-technical member. Hook this onto the existing install flow (`InstallPrompt.tsx`, Phase 3). - **D-09:** **Single master on/off toggle** for v1 — one switch for all FamilySync notifications. Per-category toggles (reminders / event-changes / list-changes) are deferred; list-noise is already handled by coalescing (D-01), so per-category control is low value for two people. - **D-10:** **Dead-subscription recovery = silent auto re-subscribe.** On app open, if the push subscription is missing/expired **but OS permission is still granted**, silently re-subscribe in the background — no user action. Only surface UI if the **OS permission itself** was revoked. This is the user-facing half of the mandatory iOS health-check. ### Carried forward — locked, NOT re-discussed - **D-11:** **iOS reliability is mandatory from day one (STATE.md):** subscription health-check + `event.waitUntil()` in the SW + **every push must display a visible notification** (no silent pushes — iOS revokes after ~3). This is non-negotiable infrastructure, the spine of success criterion 4. - **D-12:** **In-memory `EventEmitter` fan-out, no Redis (Phase 4).** `ioredis` is **not** installed; the API is a single Node process. Push dispatch hooks the **same publish points** as SSE — do not introduce Redis for push. - **D-13:** **Broker is the only Fastmail I/O boundary (Phase 3 D-12).** Event-change detection reads from the MariaDB cache / poller / outbox — no tsdav in notification code. - **D-14:** **react-router is installed (Phase 4 D-17)** specifically to enable push deep-linking. Tap targets use real URLs. ### Claude's Discretion (researcher / planner decide) - **No quiet-hours / DND in v1** — reminders and alerts always fire immediately. (Deferred; revisit if it proves annoying in use.) - **Tap-to-open deep-link targets** (obvious mapping, not separately discussed): reminder + event-change → open that event (calendar at its day / event popover); list-change → deep-link to that list (`/lists/:id`). - **Service-worker strategy:** current setup is vite-plugin-pwa `generateSW` + `autoUpdate`; adding a `push` + `notificationclick` handler likely requires switching to `injectManifest` with a custom SW source. Planner decides and addresses Workbox-precache continuity. - **VAPID key generation + storage**, push-subscription table schema (member-count-agnostic per project D-18 / Phase 4 D-18), reminder-scheduler mechanism (cron/interval scanning shared-calendar timed events in the MariaDB cache), and the coalescing debounce implementation. - **Event-change detection source:** poller (`broker/poller.ts`, external changes) vs outbox-confirm (`broker/outboxWorker.ts`, this-member writes) — pick the trigger point(s) that fire for the *other* member without notifying the actor (D-03 implies suppress self-notifications). ## Canonical References **Downstream agents MUST read these before planning or implementing.** ### Push / iOS / VAPID constraints - `CLAUDE.md` — "React PWA Stack" iOS push requirements table (iOS 16.4 min, Home-Screen install required, user-gesture subscribe, silent push unsupported → visible notification mandatory, no BackgroundSync) AND the `web-push` (VAPID) stack entry. **Authoritative constraint list for this phase.** - `.planning/STATE.md` — Phase 5 note: iOS revokes subscriptions after ~3 silent pushes; health-check + `event.waitUntil()` mandatory from day one. ### Phase scope & requirements - `.planning/ROADMAP.md` §"Phase 5: Web Push Notifications" — goal, 4 success criteria, NOTIF-01/02/03, MVP mode, Depends on Phase 3 + 4. - `.planning/REQUIREMENTS.md` — NOTIF-01 (event reminder), NOTIF-02 (list-change alert), NOTIF-03 (event add/change alert). ### Prior locked decisions this phase builds on - `.planning/phases/04-shared-lists-live-sync/04-CONTEXT.md` — D-04 (scoped SSE fan-out), D-17 (react-router for deep-linking), D-18 (member-count-agnostic schema/auth/fan-out), fan-out mechanism justification (in-memory emitter, no Redis). - `.planning/phases/03-event-write-back-pwa-install/03-CONTEXT.md` — D-12 (broker is the only Fastmail I/O boundary), PWA install onboarding, outbox/sync architecture (D-05/D-06). - `.planning/phases/02-calendar-display/02-CONTEXT.md` (D-16, via STATE deferred items) — shared "Family" calendar must be created + shared + `is_shared=1` before shared events (and thus reminders) exist. ### Integration code (read before implementing) - `apps/api/src/lib/listEmitter.ts` — list-change publish points; push dispatch hooks here. - `apps/api/src/broker/poller.ts`, `apps/api/src/broker/outboxWorker.ts` — event-change detection sources. - `apps/pwa/src/components/InstallPrompt.tsx` — existing install flow to attach the contextual permission prompt (D-08). - `apps/pwa/vite.config.*` — current vite-plugin-pwa `generateSW`/`autoUpdate` config (SW strategy decision, D-discretion). ## Existing Code Insights ### Reusable Assets - **`apps/api/src/lib/listEmitter.ts` (`publishListEvent`)** — list-change events are already emitted at the right points for Phase 4 SSE. Push dispatch for NOTIF-02 hooks the same call sites; coalescing (D-01) wraps the dispatch. - **`broker/poller.ts` + `broker/outboxWorker.ts`** — the existing change-detection plumbing (ctag-gated poll + outbox drain) is where event add/change is observed for NOTIF-03. - **react-router (Phase 4 D-17)** — already installed; gives `/lists/:id` and event URLs for tap-to-open deep links (D-14). - **`InstallPrompt.tsx`** — Phase 3 install onboarding; natural anchor for the contextual permission prompt (D-08). ### Established Patterns - **Broker-only Fastmail I/O (D-13):** notification code reads the MariaDB cache, never tsdav. - **In-memory single-process fan-out (D-12):** no Redis/ioredis; push mirrors SSE topology. - **vite-plugin-pwa `generateSW` + `autoUpdate`:** adding `push`/`notificationclick` handlers likely means moving to `injectManifest` — planner must preserve Workbox precache + autoupdate. - **Optimistic UI + scoped access checks (Phase 4 D-04/D-18):** push fan-out must be scoped to who can see a list/event — never broadcast to all members. Suppress self-notifications. ### Integration Points - **New reminder scheduler:** a server-side interval/cron scanning *shared-calendar timed events* in the MariaDB cache, firing ~15 min pre-start (D-05/D-06/D-07). New infra — no analog exists yet. - **New push-subscription store:** member-count-agnostic table for VAPID subscriptions (per D-18); SW push handler; `web-push` server dispatch (`web-push` not yet installed). - **Permission/subscription lifecycle** on the PWA: request → subscribe → persist → health-check → silent re-subscribe (D-08/D-10/D-11). ## Specific Ideas - List-change copy shape: `"{Actor} made {N} changes to {ListName}"` (generic, coalesced). - Event copy shape: `"{Actor} {action} {EventTitle} · {when}"` (specific); reminder shape: `"{EventTitle} starts in 15 min"`. - Reminder surface is the **shared Family calendar only** to avoid double-notifying against native device calendar reminders — this is the load-bearing rationale behind D-05. ## Deferred Ideas - **Quiet hours / Do-Not-Disturb** — suppress non-urgent pushes in a quiet window. v1.x. - **Per-event / custom reminder lead time** (5/15/30/60 min, per-event field). v1.x. - **Per-category opt-out** (independent reminder / event-change / list-change toggles). v1.x. - **Reminders for personal-calendar events** — intentionally excluded (native clients cover these, D-05). Only revisit if the household stops relying on native reminders. - **Notifying on the member's own changes** — out of scope; alerts are for the *other* member. ### Reviewed Todos (not folded) - **"Adopt drizzle generate+migrate workflow (retire db:push on MariaDB)"** — keyword match on "push" was a false positive (DB migrations, not Web Push). BUT the underlying constraint still applies: Phase 5 adds a push-subscription table; new tables MUST use `drizzle-kit generate` + `migrate`, never `db:push` (unsafe on populated MariaDB). Noted as a schema constraint for the planner, not folded as discussion scope. - **"Kick off FamilySync with /gsd:new-project"** — stale kickoff todo; not relevant. --- *Phase: 05-web-push-notifications* *Context gathered: 2026-06-09*