# 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_<generated-name>.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<number | null> {
  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<string, ...> as module-level singleton
```

`pushCoalescer.ts` uses a `Map<string, { count: number; timer: ReturnType<typeof setTimeout> }>` 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<string>` 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<void>) => {
      c.set('user', { id: currentDevUserId });
      await next();
    },
}));

vi.mock('@hono/oidc-auth', () => ({
  oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => 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<number> {
  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
<div
  role="dialog"
  aria-modal="true"
  aria-label="Add to Home Screen walkthrough"  // ← change to "Enable push notifications"
  style={{
    position: 'fixed',
    inset: 0,
    background: 'var(--color-overlay, rgba(0,0,0,0.5))',
    display: 'flex',
    flexDirection: 'column',
    justifyContent: 'flex-end',
    zIndex: 1000,
  }}
  onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
  <div style={{
    background: 'var(--color-surface, #ffffff)',
    borderRadius: '12px 12px 0 0',
    padding: 'var(--space-6, 24px)',
    maxHeight: '90dvh',
    overflowY: 'auto',
    display: 'flex',
    flexDirection: 'column',
    gap: 'var(--space-4, 16px)',
  }}>
```

**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
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
  <h2
    style={{
      margin: 0,
      fontSize: 'var(--text-heading-size, 18px)',
      fontWeight: 'var(--text-heading-weight, 600)',
      lineHeight: 'var(--text-heading-line-height, 1.25)',
      color: 'var(--color-text-primary, #111318)',
      fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
    }}
  >
    Stay in the loop
  </h2>
  <button
    onClick={onDismiss}
    aria-label="Dismiss"
    style={{
      background: 'none',
      border: 'none',
      cursor: 'pointer',
      minWidth: '44px',
      minHeight: '44px',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      color: 'var(--color-text-secondary, #5c6472)',
      borderRadius: 'var(--space-1, 4px)',
    }}
  >
    <X size={20} aria-hidden="true" />
  </button>
</div>
```

**Primary CTA button** — accent color pattern from Android banner install button (lines 445–462 of InstallPrompt.tsx):

```tsx
<button
  onClick={handleEnableClick}
  style={{
    background: 'var(--color-member-0, #4A90D9)', // ← accent, not --color-text-primary
    color: '#ffffff',
    border: 'none',
    borderRadius: 'var(--space-1, 4px)',
    minHeight: '48px', // 48px per UI-SPEC (not 44px)
    padding: '0 var(--space-4, 16px)',
    fontSize: 'var(--text-label-size, 13px)',
    fontWeight: 600,
    cursor: 'pointer',
    fontFamily: 'inherit',
    alignSelf: 'stretch',
  }}
>
  Enable Notifications
</button>
```

**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
<div
  style={{
    fontSize: 'var(--text-label-size, 13px)',
    fontWeight: 600,
    color: 'var(--color-text-muted, #9CA3AF)',
    textTransform: 'uppercase',
    letterSpacing: '0.06em',
    marginBottom: 'var(--space-2, 8px)',
  }}
>
  Notifications
</div>
```

**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
<div
  role="banner"   // ← change to role="alert" for PermissionDeniedBanner
  style={{
    display: 'flex',
    alignItems: 'center',
    gap: 'var(--space-3, 12px)',
    padding: 'var(--space-3, 12px) var(--space-4, 16px)',
    background: 'var(--color-surface-raised, #ffffff)',
    borderBottom: '1px solid var(--color-border, #e2e4e9)',
    fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
  }}
>
```

**Inline link style** (lines 363–374 of InstallPrompt.tsx):

```tsx
<button
  onClick={() => setWalkthroughOpen(true)}
  style={{
    background: 'none',
    border: 'none',
    padding: 0,
    cursor: 'pointer',
    fontSize: '13px',
    color: 'var(--color-focus-ring, #4A90D9)',
    textDecoration: 'underline',
    fontFamily: 'inherit',
  }}
>
  How to enable
</button>
```

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
<div
  style={{
    width: '32px', height: '32px', borderRadius: '50%',
    background: color,
    display: 'flex', alignItems: 'center',
```

Promote to `<button>` with `onClick` opening SettingsSheet. Copy 44px touch target pattern from InstallPrompt dismiss button (lines 382–396):

```tsx
<button
  onClick={onOpenSettings}
  aria-label={`${displayName} — open settings`}
  style={{
    background: 'none', border: 'none', cursor: 'pointer',
    minWidth: '44px', minHeight: '44px',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    padding: 0,
    borderRadius: 'var(--space-1, 4px)',
  }}
>
  <div style={{ width: '32px', height: '32px', borderRadius: '50%', background: color, ... }} />
</button>
```

---

## 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<number | null> {
  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
