Files
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

12 KiB
Raw Permalink Blame History

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 (~3060s). 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 onlyby 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_refs>

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). </canonical_refs>

<code_context>

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). </code_context>
## 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