From ddc84f1ffc259dd243cb6bbf27b7fb393b4fa2af Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 20:34:15 -0400 Subject: [PATCH] docs(05): finalize plan 05-07 (D-13 boundary) + add patterns doc --- .gitignore | 1 + .../05-web-push-notifications/05-07-PLAN.md | 1 + .../05-web-push-notifications/05-PATTERNS.md | 764 ++++++++++++++++++ 3 files changed, 766 insertions(+) create mode 100644 .planning/phases/05-web-push-notifications/05-PATTERNS.md diff --git a/.gitignore b/.gitignore index 978ca9b..528ecee 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ graphify-out/ # Intel / graph diff baselines (local-only; regenerated on each refresh/build) .planning/intel/.last-refresh.json .planning/graphs/.last-build-snapshot.json +.planning/research/.cache/ diff --git a/.planning/phases/05-web-push-notifications/05-07-PLAN.md b/.planning/phases/05-web-push-notifications/05-07-PLAN.md index 7d9f66d..0262b83 100644 --- a/.planning/phases/05-web-push-notifications/05-07-PLAN.md +++ b/.planning/phases/05-web-push-notifications/05-07-PLAN.md @@ -21,6 +21,7 @@ must_haves: - "The actor (the member whose sync detected/wrote the change) is never notified of their own change (D-03)" - "calendar_events.title is populated from VEVENT SUMMARY during sync so reminder + change copy show a readable title (NOTIF-01 dependency closed)" - "syncCalendar exposes detected changes via a callback consumed by both the poller (external changes) and the outbox resync (this-member writes)" + - "D-13: event-change detection reads only from the MariaDB cache / poller / outbox — no tsdav or direct Fastmail I/O in eventChangeDispatcher (broker stays the sole Fastmail boundary, carried from Phase 3 D-12)" artifacts: - path: "apps/api/src/lib/eventChangeDispatcher.ts" provides: "dispatchEventChange(change, actorUserId) — builds copy, fans out to non-actor members" diff --git a/.planning/phases/05-web-push-notifications/05-PATTERNS.md b/.planning/phases/05-web-push-notifications/05-PATTERNS.md new file mode 100644 index 0000000..b3334d2 --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-PATTERNS.md @@ -0,0 +1,764 @@ +# Phase 5: Web Push Notifications — Pattern Map + +**Mapped:** 2026-06-09 +**Files analyzed:** 16 new/modified files +**Analogs found:** 15 / 16 + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `apps/api/src/db/schema.ts` (add `pushSubscriptions`) | model | CRUD | same file — existing `listShares` / `memberCredentials` tables | exact | +| `apps/api/src/db/migrations/0003_*.sql` | migration | — | `apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql` | exact | +| `apps/api/src/routes/push.ts` | route/controller | request-response | `apps/api/src/routes/lists.ts` | exact | +| `apps/api/src/lib/pushDispatcher.ts` | utility | request-response | `apps/api/src/lib/listEmitter.ts` (module-singleton pattern) | role-match | +| `apps/api/src/lib/pushCoalescer.ts` | utility | event-driven | `apps/api/src/lib/listEmitter.ts` (in-memory singleton) | role-match | +| `apps/api/src/lib/eventChangeDispatcher.ts` | service | event-driven | `apps/api/src/lib/listEmitter.ts` + `apps/api/src/broker/sync.ts` (hook point) | partial | +| `apps/api/src/broker/reminderScheduler.ts` | service/worker | batch | `apps/api/src/broker/poller.ts` | exact | +| `apps/api/src/index.ts` (wire push routes + scheduler) | config | — | same file — `startBrokerPoller` / `startOutboxWorker` startup pattern | exact | +| `apps/api/test/setup.ts` (add `pushSubscriptions` truncation) | test | — | same file — existing truncation pattern | exact | +| `apps/api/tests/routes/push.test.ts` | test | request-response | `apps/api/tests/routes/lists.test.ts` | exact | +| `apps/api/tests/lib/pushDispatcher.test.ts` | test | — | `apps/api/tests/lib/` unit test pattern | role-match | +| `apps/api/tests/lib/pushCoalescer.test.ts` | test | — | `apps/api/tests/lib/` unit test pattern | role-match | +| `apps/api/tests/broker/reminderScheduler.test.ts` | test | — | `apps/api/tests/broker/` broker test pattern | role-match | +| `apps/pwa/src/sw.ts` (new custom SW) | config/service-worker | event-driven | `apps/pwa/vite.config.ts` (current generateSW options to preserve) | partial | +| `apps/pwa/vite.config.ts` (migrate to injectManifest) | config | — | same file | exact | +| `apps/pwa/src/components/PushPermissionPrompt.tsx` | component | request-response | `apps/pwa/src/components/InstallPrompt.tsx` (`WalkthroughSheet`) | exact | +| `apps/pwa/src/components/SettingsSheet.tsx` | component | request-response | `apps/pwa/src/components/CreateListSheet.tsx` + `InstallPrompt.tsx` | exact | +| `apps/pwa/src/components/PermissionDeniedBanner.tsx` | component | — | `apps/pwa/src/components/InstallPrompt.tsx` (iOS banner layout) | exact | +| `apps/pwa/src/hooks/usePushSubscription.ts` | hook | request-response | `apps/pwa/src/components/InstallPrompt.tsx` (`useAndroidInstallPrompt`) | role-match | +| `apps/pwa/src/components/AppNav.tsx` (promote avatar to button) | component | — | same file | exact | +| `apps/pwa/src/App.tsx` (mount new surfaces) | component | — | same file | exact | +| `apps/pwa/src/components/InstallPrompt.tsx` (add push trigger) | component | — | same file | exact | + +--- + +## Pattern Assignments + +### `apps/api/src/db/schema.ts` — add `pushSubscriptions` table + +**Analog:** same file — `listShares` table (lines 208–224) and `memberCredentials` table (lines 55–69) + +**Imports pattern** (lines 1–14): +```typescript +import { + mysqlTable, + mysqlEnum, + varchar, + text, + int, + timestamp, + index, + unique, + // customType if collation needed — see varcharBin pattern lines 22–25 +} from 'drizzle-orm/mysql-core' +``` + +**Core table pattern** — copy `listShares` structure (lines 208–224): +```typescript +// listShares: userId FK with cascade, composite unique, index on userId +export const listShares = mysqlTable( + 'list_shares', + { + id: int().primaryKey().autoincrement(), + listId: int('list_id').notNull().references(() => lists.id, { onDelete: 'cascade' }), + userId: int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => [ + unique('uniq_list_share').on(t.listId, t.userId), + index('idx_list_shares_user_id').on(t.userId), + ], +) +``` + +`pushSubscriptions` uses the same FK + unique + index structure. `endpoint` is globally unique (one endpoint per device across all users). `text` columns for long subscription fields (endpoint, p256dh); `varchar(256)` for `auth`. No `customType` needed — no special collation required for push subscription strings. + +**Migration constraint:** Never `db:push`. Always: +```bash +pnpm --filter @familysync/api db:generate +pnpm --filter @familysync/api db:migrate +``` +Next migration file: `apps/api/src/db/migrations/0003_.sql` + +--- + +### `apps/api/src/routes/push.ts` (POST /api/push/subscription, DELETE, GET /api/push/vapid-public-key) + +**Analog:** `apps/api/src/routes/lists.ts` (lines 1–70) + +**Imports pattern** (lines 20–34): +```typescript +import { Hono } from 'hono' +import type { Context } from 'hono' +import { zValidator } from '@hono/zod-validator' +import { z } from 'zod' +import { eq } from 'drizzle-orm' +import { db } from '../db/client.js' +import { pushSubscriptions } from '../db/schema.js' +import { getAuth } from '../auth/middleware.js' +import { upsertUser, deriveDisplayName } from '../auth/user.js' +import '../auth/devBypass.js' +``` + +**Auth helper pattern** — copy verbatim from `lists.ts` lines 57–69: +```typescript +async function resolveUserId(c: Context): Promise { + const devUser = c.get('user') as { id: number } | undefined + if (devUser) return devUser.id + + const auth = await getAuth(c) + if (!auth) return null + + const iss = (auth.iss as string | undefined) ?? '' + const sub = auth.sub ?? '' + const displayName = deriveDisplayName(auth) + const user = await upsertUser(iss, sub, displayName) + return user?.id ?? null +} +``` + +**Zod validation pattern** — copy `createListSchema` style from `lists.ts` line 78: +```typescript +const subscribeSchema = z.object({ + endpoint: z.string().url().max(2048), + keys: z.object({ + p256dh: z.string().min(1).max(512), + auth: z.string().min(1).max(256), + }), +}) +``` + +**Route handler pattern** — copy the POST handler structure from `lists.ts`: +```typescript +export const pushRouter = new Hono() + +// GET /api/push/vapid-public-key — unauthenticated; serves the public VAPID key to the PWA +pushRouter.get('/vapid-public-key', (c) => { + return c.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? '' }) +}) + +// POST /api/push/subscription — subscribe (authenticated) +pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c) => { + const userId = await resolveUserId(c) + if (!userId) return c.json({ error: 'Unauthorized' }, 401) + + const body = c.req.valid('json') + // upsert: one endpoint may belong to one user; unique constraint on endpoint + await db.insert(pushSubscriptions).values({ + userId, + endpoint: body.endpoint, + p256dh: body.keys.p256dh, + auth: body.keys.auth, + }).onDuplicateKeyUpdate({ set: { userId, p256dh: body.keys.p256dh, auth: body.keys.auth } }) + + return c.json({ ok: true }, 201) +}) + +// DELETE /api/push/subscription — unsubscribe (authenticated) +pushRouter.delete('/subscription', async (c) => { + const userId = await resolveUserId(c) + if (!userId) return c.json({ error: 'Unauthorized' }, 401) + await db.delete(pushSubscriptions).where(eq(pushSubscriptions.userId, userId)) + return c.json({ ok: true }) +}) +``` + +**Mount pattern** — add to `apps/api/src/index.ts` after other route mounts (line 66): +```typescript +import { pushRouter } from './routes/push.js' +// ... +app.route('/api/push', pushRouter) +``` + +--- + +### `apps/api/src/lib/pushDispatcher.ts` + +**Analog:** `apps/api/src/lib/listEmitter.ts` (module singleton pattern, lines 1–54) + +**Module structure** — same module-level singleton with a clear export surface: +```typescript +// listEmitter.ts singleton pattern (lines 17–22): +import { EventEmitter } from 'node:events' +const emitter = new EventEmitter() +emitter.setMaxListeners(200) +export function publishListEvent(...) { emitter.emit(...) } +export function subscribeListEvents(...) { ... } +``` + +`pushDispatcher.ts` uses `webpush` (initialized once at module load / startup) as the singleton: +```typescript +import webpush from 'web-push' // default import — web-push is CommonJS (Pitfall 7) +// setVapidDetails called once from index.ts isMainModule() guard, NOT at module scope +``` + +**Error handling pattern:** 410/404 prune (no analog exists — use RESEARCH.md Pattern 1). Log errors with `console.error('[pushDispatcher] ...')` prefix matching the broker pattern used in `poller.ts` line 71 and `outboxWorker.ts` line 611. + +--- + +### `apps/api/src/lib/pushCoalescer.ts` + +**Analog:** `apps/api/src/lib/listEmitter.ts` (in-memory module-level Map singleton) + +**Module pattern** — module-level Map, no external dependencies: +```typescript +// listEmitter.ts pattern: module-level singleton never exported directly +const emitter = new EventEmitter() // ← same: Map as module-level singleton +``` + +`pushCoalescer.ts` uses a `Map }>` keyed by `${listId}:${actorId}`. The module exports a single function — same minimal API surface as `publishListEvent`. + +--- + +### `apps/api/src/lib/eventChangeDispatcher.ts` + +**Analog:** `apps/api/src/lib/listEmitter.ts` (dispatch pattern) + `apps/api/src/broker/sync.ts` (hook point) + +No existing event-change dispatcher exists. This is a new module called from inside `syncCalendar` (or a callback passed to it) after the DB upsert detects a changed event. Pattern: export a single `dispatchEventChange(event, actorUserId)` function that queries `pushSubscriptions` and calls `pushDispatcher`. Mirror the `publishListEvent` single-function export idiom. + +--- + +### `apps/api/src/broker/reminderScheduler.ts` + +**Analog:** `apps/api/src/broker/poller.ts` (lines 1–89) — exact structural match + +**Imports pattern** (lines 16–25 of poller.ts): +```typescript +import { schedule } from 'node-cron' +import { and, eq } from 'drizzle-orm' +import { db } from '../db/client.js' +import { memberCredentials, calendars } from '../db/schema.js' +// reminderScheduler adds: calendarEvents, pushSubscriptions +``` + +**Cron schedule pattern** (lines 83–89 of poller.ts): +```typescript +export function startBrokerPoller(): void { + schedule('*/5 * * * *', () => { + runPoll().catch((err: unknown) => { + console.error('[broker/poller] Unhandled runPoll error:', err) + }) + }) +} +``` + +`reminderScheduler.ts` uses the same export shape: +```typescript +export function startReminderScheduler(): void { + schedule('* * * * *', () => { // every minute (not */5) + runReminderCheck().catch((err: unknown) => { + console.error('[broker/reminderScheduler] Unhandled error:', err) + }) + }) +} +``` + +**Per-credential error isolation** (lines 68–76 of poller.ts): +```typescript +try { + // ... per-item work +} catch (err) { + console.error( + `[broker/poller] Error processing ...`, + err instanceof Error ? err.message : String(err), + ) +} +``` +Copy this catch shape for per-event and per-subscription errors in the scheduler. + +**Startup wire-in** — `apps/api/src/index.ts` lines 107–113: +```typescript +if (isMainModule()) { + startBrokerPoller() + startOutboxWorker() + // Add: + startReminderScheduler() + // Also: webpush.setVapidDetails(...) here, before the scheduler starts + serve(...) +} +``` + +**Reminder deduplication:** Use an in-memory `Set` of `${eventUid}:${minuteBucket}` (acceptable for single-process deployment per D-12). Reset on process restart — two-person household, acceptable data loss on restart. + +--- + +### `apps/api/src/index.ts` (modifications) + +**Pattern:** lines 107–116 (isMainModule guard). Add `startReminderScheduler()` and `webpush.setVapidDetails()` inside the same guard. Add `app.route('/api/push', pushRouter)` at line 66 alongside other route mounts. + +--- + +### `apps/api/test/setup.ts` (add push_subscriptions truncation) + +**Analog:** same file, lines 27–37 + +**Current pattern:** +```typescript +afterEach(async () => { + try { + await db.delete(listItems) + await db.delete(listShares) + await db.delete(lists) + } catch { /* swallow */ } +}) +``` + +**Add** `await db.delete(pushSubscriptions)` before the `lists` delete (no FK dependency on lists; delete in any order relative to lists, but after `listItems` / `listShares`). + +--- + +### `apps/api/tests/routes/push.test.ts` + +**Analog:** `apps/api/tests/routes/lists.test.ts` (lines 1–100) — exact pattern + +**Mock boilerplate** (lines 32–44 of lists.test.ts): +```typescript +let currentDevUserId = 1 + +vi.mock('../../src/auth/devBypass.js', () => ({ + devAuthBypass: () => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise) => { + c.set('user', { id: currentDevUserId }) + await next() + }, +})) + +vi.mock('@hono/oidc-auth', () => ({ + oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), + processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), + getAuth: () => null, +})) +``` + +**Lazy app import** (lines 77–80 of lists.test.ts): +```typescript +async function getApp() { + const { app } = await import('../../src/index.js') + return app +} +``` + +**Request helper** (lines 86–92): +```typescript +function jsonRequest(method: string, path: string, body?: unknown): Request { + return new Request(`http://localhost${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} +``` + +**Seed helper** (lines 50–58): +```typescript +async function seedUser(label: string): Promise { + const [result] = await db.insert(users).values({ + oidcIss: 'https://auth.test', + oidcSub: `sub-${label}-${randomUUID()}`, + displayName: `User ${label}`, + color: '#4A90D9', + }).$returningId() + return result.id +} +``` + +--- + +### `apps/pwa/vite.config.ts` (migrate generateSW → injectManifest) + +**Analog:** same file (lines 1–48) — migrate in-place + +**Current config to preserve** (lines 8–40): +```typescript +VitePWA({ + registerType: 'autoUpdate', + workbox: { + navigateFallback: '/index.html', + navigateFallbackDenylist: [ + /^\/callback/, // CRITICAL: T-03-20 — must not be lost in migration + /^\/api\//, + /^\/health/, + ], + runtimeCaching: [], + }, + manifest: { + name: 'FamilySync', short_name: 'FamilySync', + description: 'Family calendar and lists', + theme_color: '#4A90D9', background_color: '#ffffff', + display: 'standalone', scope: '/', start_url: '/', + icons: [...] + }, +}) +``` + +**Target config** — replace `workbox: {}` with `strategies: 'injectManifest'`: +```typescript +VitePWA({ + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', + registerType: 'autoUpdate', + injectManifest: { + globIgnores: ['**/node_modules/**', '**/callback**'], + }, + manifest: { /* identical to current manifest block */ }, +}) +``` + +`navigateFallback` / `navigateFallbackDenylist` / `runtimeCaching` move OUT of `workbox:{}` and are re-implemented explicitly in `sw.ts` (see below). + +--- + +### `apps/pwa/src/sw.ts` (new custom service worker) + +**No exact analog in codebase** — no existing custom SW. Use RESEARCH.md Patterns 2 and the code examples for navigateFallback preservation. + +**Critical constraints from codebase inspection (must preserve):** +1. `navigateFallbackDenylist`: `/^\/callback/`, `/^\/api\//`, `/^\/health/` (from `vite.config.ts` lines 16–19, T-03-20) +2. `runtimeCaching: []` — no API caching (line 22) +3. `autoUpdate` behavior: `self.skipWaiting()` + `clientsClaim()` (replaces generateSW auto-behavior) +4. Every push MUST call `event.waitUntil(showNotification(...))` — iOS revokes after ~3 silent pushes (D-11) + +**Required devDependencies** (not yet installed): +```bash +pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core workbox-routing +``` + +--- + +### `apps/pwa/src/components/PushPermissionPrompt.tsx` + +**Analog:** `apps/pwa/src/components/InstallPrompt.tsx` — `WalkthroughSheet` sub-component (lines 121–269) + +**Bottom sheet layout pattern** (lines 122–152 of InstallPrompt.tsx): +```tsx +
{ if (e.target === e.currentTarget) onClose() }} +> +
+``` + +**CRITICAL difference from WalkthroughSheet:** Per UI-SPEC Surface 1, the permission prompt backdrop does NOT dismiss on click (permission UX must be explicit). Remove the `onClick` backdrop-dismiss from the outer div. + +**Header with close button** (lines 153–192 of InstallPrompt.tsx): +```tsx +
+

Stay in the loop

+ +
+``` + +**Primary CTA button** — accent color pattern from Android banner install button (lines 445–462 of InstallPrompt.tsx): +```tsx + +``` + +**localStorage guard pattern** (lines 284–297 of InstallPrompt.tsx): +```typescript +function readDismissed(): boolean { + try { return localStorage.getItem('installPromptDismissed') === '1' } catch { return false } +} +function persistDismissed(): void { + try { localStorage.setItem('installPromptDismissed', '1') } catch { /* ignore */ } +} +``` +Copy for `pushPermissionDismissed` key. + +--- + +### `apps/pwa/src/components/SettingsSheet.tsx` + +**Analog:** `apps/pwa/src/components/CreateListSheet.tsx` (lines 1–60) for sheet lifecycle, plus `InstallPrompt.tsx` WalkthroughSheet for layout + +**Sheet open/close pattern** (CreateListSheet.tsx lines 28–60): +```typescript +// CreateListSheet uses zustand store for open state +const isOpen = useListsStore((s) => s.createListSheetOpen) +const setOpen = useListsStore((s) => s.setCreateListSheetOpen) + +// Escape key listener +useEffect(() => { + if (!isOpen) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleClose() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) +}, [isOpen]) +``` + +`SettingsSheet` uses a local `isOpen` prop or zustand UI store — match whichever pattern the planner selects for the avatar trigger. Escape key listener is mandatory (copy pattern above). + +**Backdrop** — same z-index layering as CreateListSheet: backdrop at `zIndex: 300`, sheet at `zIndex: 301`. Backdrop click closes (unlike PushPermissionPrompt). + +**Section label style** (matches existing "Calendars" label in DesktopNav per UI-SPEC): +```tsx +
Notifications
+``` + +**Toggle** — inline `role="switch"`, 44px touch target, `aria-checked`. No existing toggle analog in the codebase — implement inline in SettingsSheet following the button style pattern from InstallPrompt. + +--- + +### `apps/pwa/src/components/PermissionDeniedBanner.tsx` + +**Analog:** `apps/pwa/src/components/InstallPrompt.tsx` — iOS banner layout (lines 321–406) + +**Banner layout pattern** (lines 322–337 of InstallPrompt.tsx): +```tsx +
+``` + +**Inline link style** (lines 363–374 of InstallPrompt.tsx): +```tsx + +``` + +No dismiss button — banner is persistent until OS permission restored (UI-SPEC Surface 3). + +--- + +### `apps/pwa/src/hooks/usePushSubscription.ts` + +**Analog:** `apps/pwa/src/components/InstallPrompt.tsx` — `useAndroidInstallPrompt` hook (lines 76–105) + +**Hook structure** (lines 76–105 of InstallPrompt.tsx): +```typescript +export function useAndroidInstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = useState<...>(null) + + useEffect(() => { + const handler = (e: Event) => { ... } + window.addEventListener('beforeinstallprompt', handler) + window.addEventListener('appinstalled', installedHandler) + return () => { window.removeEventListener(...) } + }, []) + + const triggerInstall = async () => { ... } + return { canInstall: ..., triggerInstall } +} +``` + +`usePushSubscription` follows the same shape: `useEffect` for health-check on mount (D-10 silent re-subscribe), returns `{ subscribe, unsubscribe, permission }`. **CRITICAL:** `subscribe()` must NOT be called inside `useEffect` or any `async` boundary — it must be called directly inside the `onClick` handler of the "Enable Notifications" button (iOS user-gesture requirement, D-08/Pitfall 2). + +**localStorage guard** — copy `readDismissed` / `persistDismissed` pattern from InstallPrompt.tsx lines 284–297 for `notificationsEnabled` key. + +--- + +### `apps/pwa/src/components/AppNav.tsx` (promote avatar to button) + +**Analog:** same file lines 73–80 (current avatar `div`) + +**Current pattern** (lines 73–80 of AppNav.tsx): +```tsx +
` with `onClick` opening SettingsSheet. Copy 44px touch target pattern from InstallPrompt dismiss button (lines 382–396): +```tsx + +``` + +--- + +## Shared Patterns + +### Auth (all API routes) + +**Source:** `apps/api/src/routes/lists.ts` lines 57–69 +**Apply to:** `apps/api/src/routes/push.ts` + +```typescript +async function resolveUserId(c: Context): Promise { + const devUser = c.get('user') as { id: number } | undefined + if (devUser) return devUser.id + const auth = await getAuth(c) + if (!auth) return null + const iss = (auth.iss as string | undefined) ?? '' + const sub = auth.sub ?? '' + const displayName = deriveDisplayName(auth) + const user = await upsertUser(iss, sub, displayName) + return user?.id ?? null +} +``` + +Note: comment in `lists.ts` says "Duplicated per router (not extracted to shared module)" — maintain that convention. + +### Error logging (all broker/lib files) + +**Source:** `apps/api/src/broker/poller.ts` line 70–76, `apps/api/src/broker/outboxWorker.ts` line 611 +**Apply to:** `pushDispatcher.ts`, `reminderScheduler.ts`, `eventChangeDispatcher.ts` + +```typescript +console.error( + `[broker/reminderScheduler] Error processing ...:`, + err instanceof Error ? err.message : String(err), +) +``` +Never log the decrypted app password (T-03-13). Log `err.message` not the full `err` object. + +### Background worker startup guard + +**Source:** `apps/api/src/index.ts` lines 95–116 +**Apply to:** `startReminderScheduler()` call + `webpush.setVapidDetails()` initialization + +```typescript +if (isMainModule()) { + startBrokerPoller() + startOutboxWorker() + // Phase 5 additions: + webpush.setVapidDetails('mailto:admin@...', process.env.VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY!) + startReminderScheduler() + serve(...) +} +``` + +### Bottom sheet layout (all PWA sheet components) + +**Source:** `apps/pwa/src/components/InstallPrompt.tsx` `WalkthroughSheet` lines 121–152 +**Apply to:** `PushPermissionPrompt.tsx`, `SettingsSheet.tsx` + +Key values: `borderRadius: '12px 12px 0 0'`, `padding: 'var(--space-6, 24px)'`, `zIndex: 1000` (permission prompt) or `zIndex: 301` (settings sheet). + +### 44px touch target (all interactive elements) + +**Source:** `apps/pwa/src/components/InstallPrompt.tsx` lines 382–396 (dismiss button) +**Apply to:** All buttons in `PushPermissionPrompt.tsx`, `SettingsSheet.tsx`, `PermissionDeniedBanner.tsx`, `AppNav.tsx` + +```tsx +style={{ minWidth: '44px', minHeight: '44px', display: 'flex', alignItems: 'center', justifyContent: 'center' }} +``` + +### Token CSS variables (all PWA components) + +**Source:** `apps/pwa/src/styles/tokens.css` (read by UI-SPEC) +**Apply to:** All Phase 5 PWA components + +Never hardcode hex values — always use `var(--color-*, fallback)`. Key tokens for this phase: +- `var(--color-member-0, #4A90D9)` — accent/CTA +- `var(--color-destructive, #DC2626)` — permission-denied icon +- `var(--color-text-primary, #111318)`, `var(--color-text-secondary, #5c6472)`, `var(--color-text-muted, #9CA3AF)` +- `var(--color-border, #e2e4e9)`, `var(--color-surface, #ffffff)`, `var(--color-surface-raised, #ffffff)` +- `var(--color-overlay, rgba(0,0,0,0.32))` — backdrop +- `var(--color-focus-ring, #4A90D9)` — inline links + +--- + +## No Analog Found + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| `apps/pwa/src/sw.ts` | service-worker | event-driven | No existing custom SW — only generated SW (not editable). Use RESEARCH.md Pattern 2 + Pattern code examples for navigateFallback. Must preserve denylist from `vite.config.ts` lines 16–19. | + +--- + +## Dependency Gaps (must install before building) + +| Package | Location | Install Command | +|---------|----------|-----------------| +| `web-push` | apps/api | `pnpm --filter @familysync/api add web-push` | +| `@types/web-push` | apps/api (dev) | `pnpm --filter @familysync/api add -D @types/web-push` | +| `workbox-precaching` | apps/pwa (dev) | `pnpm --filter @familysync/pwa add -D workbox-precaching` | +| `workbox-core` | apps/pwa (dev) | `pnpm --filter @familysync/pwa add -D workbox-core` | +| `workbox-routing` | apps/pwa (dev) | `pnpm --filter @familysync/pwa add -D workbox-routing` | + +## Metadata + +**Analog search scope:** `apps/api/src/`, `apps/pwa/src/` +**Files read:** schema.ts, listEmitter.ts, poller.ts, outboxWorker.ts, index.ts, routes/lists.ts, test/setup.ts, tests/routes/lists.test.ts, components/InstallPrompt.tsx, components/CreateListSheet.tsx, components/AppNav.tsx, vite.config.ts +**Pattern extraction date:** 2026-06-09