@@ -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_<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