style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
@@ -8,30 +8,30 @@
## 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 |
| 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 |
---
@@ -42,6 +42,7 @@
**Analog:** same file — `listShares` table (lines 208224) and `memberCredentials` table (lines 5569)
**Imports pattern** (lines 114):
```typescript
import {
mysqlTable,
@@ -53,34 +54,41 @@ import {
index,
unique,
// customType if collation needed — see varcharBin pattern lines 2225
} from 'drizzle-orm/mysql-core'
} 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' }),
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`
---
@@ -90,37 +98,40 @@ Next migration file: `apps/api/src/db/migrations/0003_<generated-name>.sql`
**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'
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 devUser = c.get('user') as { id: number } | undefined;
if (devUser) return devUser.id;
const auth = await getAuth(c)
if (!auth) return null
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
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),
@@ -128,49 +139,54 @@ const subscribeSchema = 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()
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 ?? '' })
})
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 userId = await resolveUserId(c);
if (!userId) return c.json({ error: 'Unauthorized' }, 401);
const body = c.req.valid('json')
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 } })
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)
})
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 })
})
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'
import { pushRouter } from './routes/push.js';
// ...
app.route('/api/push', pushRouter)
app.route('/api/push', pushRouter);
```
---
@@ -180,6 +196,7 @@ app.route('/api/push', pushRouter)
**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'
@@ -190,8 +207,9 @@ 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)
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
```
@@ -204,9 +222,10 @@ import webpush from 'web-push' // default import — web-push is CommonJS (Pitf
**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
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`.
@@ -226,37 +245,42 @@ No existing event-change dispatcher exists. This is a new module called from ins
**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'
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)
})
})
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)
schedule('* * * * *', () => {
// every minute (not */5)
runReminderCheck().catch((err: unknown) => {
console.error('[broker/reminderScheduler] Unhandled error:', err)
})
})
console.error('[broker/reminderScheduler] Unhandled error:', err);
});
});
}
```
**Per-credential error isolation** (lines 6876 of poller.ts):
```typescript
try {
// ... per-item work
@@ -264,12 +288,14 @@ try {
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()
@@ -296,14 +322,17 @@ if (isMainModule()) {
**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 */ }
})
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`).
@@ -315,52 +344,60 @@ afterEach(async () => {
**Analog:** `apps/api/tests/routes/lists.test.ts` (lines 1100) — exact pattern
**Mock boilerplate** (lines 3244 of lists.test.ts):
```typescript
let currentDevUserId = 1
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()
},
}))
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
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
const [result] = await db
.insert(users)
.values({
oidcIss: 'https://auth.test',
oidcSub: `sub-${label}-${randomUUID()}`,
displayName: `User ${label}`,
color: '#4A90D9',
})
.$returningId();
return result.id;
}
```
@@ -371,6 +408,7 @@ async function seedUser(label: string): Promise<number> {
**Analog:** same file (lines 148) — migrate in-place
**Current config to preserve** (lines 840):
```typescript
VitePWA({
registerType: 'autoUpdate',
@@ -394,6 +432,7 @@ VitePWA({
```
**Target config** — replace `workbox: {}` with `strategies: 'injectManifest'`:
```typescript
VitePWA({
strategies: 'injectManifest',
@@ -403,8 +442,10 @@ VitePWA({
injectManifest: {
globIgnores: ['**/node_modules/**', '**/callback**'],
},
manifest: { /* identical to current manifest block */ },
})
manifest: {
/* identical to current manifest block */
},
});
```
`navigateFallback` / `navigateFallbackDenylist` / `runtimeCaching` move OUT of `workbox:{}` and are re-implemented explicitly in `sw.ts` (see below).
@@ -416,12 +457,14 @@ VitePWA({
**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
```
@@ -433,6 +476,7 @@ pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core workbox-rou
**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"
@@ -464,56 +508,84 @@ pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core workbox-rou
**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)' }}>
<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}
<button
onClick={handleEnableClick}
style={{
background: 'var(--color-member-0, #4A90D9)', // ← accent, not --color-text-primary
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)
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 }
try {
return localStorage.getItem('installPromptDismissed') === '1';
} catch {
return false;
}
}
function persistDismissed(): void {
try { localStorage.setItem('installPromptDismissed', '1') } catch { /* ignore */ }
try {
localStorage.setItem('installPromptDismissed', '1');
} catch {
/* ignore */
}
}
```
Copy for `pushPermissionDismissed` key.
---
@@ -523,20 +595,21 @@ Copy for `pushPermissionDismissed` key.
**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)
const isOpen = useListsStore((s) => s.createListSheetOpen);
const setOpen = useListsStore((s) => s.setCreateListSheetOpen);
// Escape key listener
useEffect(() => {
if (!isOpen) return
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [isOpen])
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).
@@ -544,15 +617,20 @@ useEffect(() => {
**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>
<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.
@@ -564,6 +642,7 @@ useEffect(() => {
**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
@@ -580,15 +659,21 @@ useEffect(() => {
```
**Inline link style** (lines 363374 of InstallPrompt.tsx):
```tsx
<button onClick={() => setWalkthroughOpen(true)}
<button
onClick={() => setWalkthroughOpen(true)}
style={{
background: 'none', border: 'none', padding: 0, cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
fontSize: '13px',
color: 'var(--color-focus-ring, #4A90D9)',
textDecoration: 'underline',
fontFamily: 'inherit',
}}>
}}
>
How to enable
</button>
```
@@ -602,6 +687,7 @@ No dismiss button — banner is persistent until OS permission restored (UI-SPEC
**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)
@@ -629,6 +715,7 @@ export function useAndroidInstallPrompt() {
**Analog:** same file lines 7380 (current avatar `div`)
**Current pattern** (lines 7380 of AppNav.tsx):
```tsx
<div
style={{
@@ -638,6 +725,7 @@ export function useAndroidInstallPrompt() {
```
Promote to `<button>` with `onClick` opening SettingsSheet. Copy 44px touch target pattern from InstallPrompt dismiss button (lines 382396):
```tsx
<button
onClick={onOpenSettings}
@@ -665,15 +753,15 @@ Promote to `<button>` with `onClick` opening SettingsSheet. Copy 44px touch targ
```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
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;
}
```
@@ -688,8 +776,9 @@ Note: comment in `lists.ts` says "Duplicated per router (not extracted to shared
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
@@ -730,6 +819,7 @@ style={{ minWidth: '44px', minHeight: '44px', display: 'flex', alignItems: 'cent
**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)`
@@ -741,21 +831,21 @@ Never hardcode hex values — always use `var(--color-*, fallback)`. Key tokens
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| 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` |
| 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` |
| `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