docs(05): capture phase context
This commit is contained in:
@@ -0,0 +1,215 @@
|
|||||||
|
# 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 (~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).
|
||||||
|
</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*
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Phase 5: Web Push Notifications - Discussion Log
|
||||||
|
|
||||||
|
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||||
|
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Phase:** 5-web-push-notifications
|
||||||
|
**Areas discussed:** Copy & anti-spam, Reminder scope & timing, Onboarding & opt-out
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Area selection
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Copy & anti-spam | Wording per type + coalescing | ✓ |
|
||||||
|
| Reminder scope & timing | Whose events, fixed/custom lead, all-day | ✓ |
|
||||||
|
| Quiet hours / DND | Suppress non-urgent in a quiet window | |
|
||||||
|
| Onboarding & opt-out | When to prompt, toggle granularity | ✓ |
|
||||||
|
|
||||||
|
**Notes:** Quiet hours/DND left to Claude's discretion (v1 = no quiet hours).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copy & anti-spam
|
||||||
|
|
||||||
|
### List-change batching
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Coalesce per list | Batch same-list changes in ~30–60s into one push | ✓ |
|
||||||
|
| Coalesce + skip check-offs | Same, but check-offs never push | |
|
||||||
|
| One push per change | Immediate, no batching | |
|
||||||
|
|
||||||
|
### Detail level
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Specific details | Full specifics for everything | |
|
||||||
|
| Specific events, generic lists | Events show detail; list pings generic | ✓ |
|
||||||
|
| Generic only | Everything generic | |
|
||||||
|
|
||||||
|
### Attribution
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Name the actor | "Wife checked off…" | ✓ |
|
||||||
|
| No name | "Milk checked off…" | |
|
||||||
|
|
||||||
|
### Event-change granularity
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Meaningful changes only | new/delete/time/date/title/location; description silent | ✓ |
|
||||||
|
| Time/date only | only reschedules + add/delete | |
|
||||||
|
| Any change | including description edits | |
|
||||||
|
|
||||||
|
**User's choice:** Coalesce per list; specific-for-events/generic-for-lists; name the actor; meaningful changes only.
|
||||||
|
**Notes:** Generic-list + name-actor reconciled as "Wife made 3 changes to Groceries" (actor + list + count, no item text).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reminder scope & timing
|
||||||
|
|
||||||
|
### Whose events remind
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Own + shared | Own personal + shared Family | |
|
||||||
|
| Everything visible | Incl. partner's personal events | |
|
||||||
|
| Shared only | Shared Family calendar only | ✓ |
|
||||||
|
|
||||||
|
### Lead time
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Fixed 15 min (v1) | Always ~15 min | ✓ |
|
||||||
|
| User default, changeable | One global offset | |
|
||||||
|
| Per-event lead time | Per-event offset field | |
|
||||||
|
|
||||||
|
### All-day events
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Morning-of | Fixed AM time | |
|
||||||
|
| No reminder | Never push | ✓ |
|
||||||
|
| Evening before | ~6pm prior day | |
|
||||||
|
|
||||||
|
**User's choice:** Shared-only; fixed 15 min; no all-day reminder.
|
||||||
|
**Notes (load-bearing rationale, free-text):** "the native mail client on the device will still send notifications. we dont want to duplicate that." Personal-calendar reminders are already covered by each member's native device calendar app; FamilySync owns reminders for the shared Family calendar only. Confirmed deliberately after a challenge that this narrows NOTIF-01.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Onboarding & opt-out
|
||||||
|
|
||||||
|
### Permission prompt timing
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Contextual, after install | Explainer + tap right after install | ✓ |
|
||||||
|
| On first relevant action | After first event create | |
|
||||||
|
| Settings toggle only | No auto-prompt | |
|
||||||
|
|
||||||
|
### Opt-out granularity
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Single master toggle | One on/off | ✓ |
|
||||||
|
| Per-category toggles | reminders/event/list switches | |
|
||||||
|
| Master + categories | Both | |
|
||||||
|
|
||||||
|
### Dead-subscription recovery
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Silent auto re-subscribe | Background re-subscribe if permission granted | ✓ |
|
||||||
|
| Silent, then banner fallback | Banner if silent fails | |
|
||||||
|
| Always prompt | Banner on every death | |
|
||||||
|
|
||||||
|
**User's choice:** Contextual after-install prompt; single master toggle; silent auto re-subscribe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude's Discretion
|
||||||
|
|
||||||
|
- No quiet-hours / DND in v1 (reminders/alerts always fire).
|
||||||
|
- Tap-to-open deep-link targets (reminder/event-change → event; list-change → `/lists/:id`).
|
||||||
|
- Service-worker strategy (generateSW vs injectManifest for push handler).
|
||||||
|
- VAPID key generation/storage, push-subscription table schema, reminder-scheduler mechanism, coalescing debounce, event-change detection source (poller vs outbox).
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- Quiet hours / DND — v1.x
|
||||||
|
- Per-event / custom reminder lead time — v1.x
|
||||||
|
- Per-category opt-out — v1.x
|
||||||
|
- Reminders for personal-calendar events — intentionally excluded (native clients cover these)
|
||||||
|
- Notifying on own changes — out of scope
|
||||||
|
|
||||||
|
**Reviewed todos (not folded):** drizzle generate+migrate (false-positive "push" match, but schema constraint noted); new-project kickoff (stale).
|
||||||
Reference in New Issue
Block a user