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

231 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 5: Web Push Notifications - Context
**Gathered:** 2026-06-09
**Status:** Ready for planning
**Mode:** mvp (vertical slice — see ROADMAP.md `**Mode:** mvp`)
<domain>
## 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).
</domain>
<decisions>
## 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 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).
</decisions>
<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>
<specifics>
## 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.
</specifics>
<deferred>
## 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.
</deferred>
---
_Phase: 05-web-push-notifications_
_Context gathered: 2026-06-09_