Files
familysync/.planning/phases/05-web-push-notifications/05-PATTERNS.md
T

765 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 5: Web Push Notifications — 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 208224) and `memberCredentials` table (lines 5569)
**Imports pattern** (lines 114):
```typescript
import {
mysqlTable,
mysqlEnum,
varchar,
text,
int,
timestamp,
index,
unique,
// customType if collation needed — see varcharBin pattern lines 2225
} from 'drizzle-orm/mysql-core'
```
**Core table pattern** — copy `listShares` structure (lines 208224):
```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 170)
**Imports pattern** (lines 2034):
```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 5769:
```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 154)
**Module structure** — same module-level singleton with a clear export surface:
```typescript
// listEmitter.ts singleton pattern (lines 1722):
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 189) — exact structural match
**Imports pattern** (lines 1625 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 8389 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 6876 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 107113:
```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 107116 (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 2737
**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 1100) — exact pattern
**Mock boilerplate** (lines 3244 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 7780 of lists.test.ts):
```typescript
async function getApp() {
const { app } = await import('../../src/index.js')
return app
}
```
**Request helper** (lines 8692):
```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 5058):
```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 148) — migrate in-place
**Current config to preserve** (lines 840):
```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 1619, 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 121269)
**Bottom sheet layout pattern** (lines 122152 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 153192 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 445462 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 284297 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 160) for sheet lifecycle, plus `InstallPrompt.tsx` WalkthroughSheet for layout
**Sheet open/close pattern** (CreateListSheet.tsx lines 2860):
```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 321406)
**Banner layout pattern** (lines 322337 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 363374 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 76105)
**Hook structure** (lines 76105 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 284297 for `notificationsEnabled` key.
---
### `apps/pwa/src/components/AppNav.tsx` (promote avatar to button)
**Analog:** same file lines 7380 (current avatar `div`)
**Current pattern** (lines 7380 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 382396):
```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 5769
**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 7076, `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 95116
**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 121152
**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 382396 (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 1619. |
---
## 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