docs(05): finalize plan 05-07 (D-13 boundary) + add patterns doc
This commit is contained in:
@@ -49,3 +49,4 @@ graphify-out/
|
||||
# Intel / graph diff baselines (local-only; regenerated on each refresh/build)
|
||||
.planning/intel/.last-refresh.json
|
||||
.planning/graphs/.last-build-snapshot.json
|
||||
.planning/research/.cache/
|
||||
|
||||
@@ -21,6 +21,7 @@ must_haves:
|
||||
- "The actor (the member whose sync detected/wrote the change) is never notified of their own change (D-03)"
|
||||
- "calendar_events.title is populated from VEVENT SUMMARY during sync so reminder + change copy show a readable title (NOTIF-01 dependency closed)"
|
||||
- "syncCalendar exposes detected changes via a callback consumed by both the poller (external changes) and the outbox resync (this-member writes)"
|
||||
- "D-13: event-change detection reads only from the MariaDB cache / poller / outbox — no tsdav or direct Fastmail I/O in eventChangeDispatcher (broker stays the sole Fastmail boundary, carried from Phase 3 D-12)"
|
||||
artifacts:
|
||||
- path: "apps/api/src/lib/eventChangeDispatcher.ts"
|
||||
provides: "dispatchEventChange(change, actorUserId) — builds copy, fans out to non-actor members"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user