# 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