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:
@@ -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 208–224) and `memberCredentials` table (lines 55–69)
|
||||
|
||||
**Imports pattern** (lines 1–14):
|
||||
|
||||
```typescript
|
||||
import {
|
||||
mysqlTable,
|
||||
@@ -53,34 +54,41 @@ import {
|
||||
index,
|
||||
unique,
|
||||
// customType if collation needed — see varcharBin pattern lines 22–25
|
||||
} from 'drizzle-orm/mysql-core'
|
||||
} 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' }),
|
||||
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 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'
|
||||
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 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 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'
|
||||
@@ -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 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'
|
||||
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)
|
||||
})
|
||||
})
|
||||
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 68–76 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 107–113:
|
||||
|
||||
```typescript
|
||||
if (isMainModule()) {
|
||||
startBrokerPoller()
|
||||
@@ -296,14 +322,17 @@ if (isMainModule()) {
|
||||
**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 */ }
|
||||
})
|
||||
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 1–100) — exact pattern
|
||||
|
||||
**Mock boilerplate** (lines 32–44 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 77–80 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 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
|
||||
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 1–48) — migrate in-place
|
||||
|
||||
**Current config to preserve** (lines 8–40):
|
||||
|
||||
```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 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
|
||||
```
|
||||
@@ -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 121–269)
|
||||
|
||||
**Bottom sheet layout pattern** (lines 122–152 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 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)' }}>
|
||||
<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}
|
||||
<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 284–297 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 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)
|
||||
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 321–406)
|
||||
|
||||
**Banner layout pattern** (lines 322–337 of InstallPrompt.tsx):
|
||||
|
||||
```tsx
|
||||
<div
|
||||
role="banner" // ← change to role="alert" for PermissionDeniedBanner
|
||||
@@ -580,15 +659,21 @@ useEffect(() => {
|
||||
```
|
||||
|
||||
**Inline link style** (lines 363–374 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 76–105)
|
||||
|
||||
**Hook structure** (lines 76–105 of InstallPrompt.tsx):
|
||||
|
||||
```typescript
|
||||
export function useAndroidInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<...>(null)
|
||||
@@ -629,6 +715,7 @@ export function useAndroidInstallPrompt() {
|
||||
**Analog:** same file lines 73–80 (current avatar `div`)
|
||||
|
||||
**Current pattern** (lines 73–80 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 382–396):
|
||||
|
||||
```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 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` |
|
||||
| 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user