Files
familysync/.planning/phases/04-shared-lists-live-sync/04-PATTERNS.md
T

750 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 4: Shared Lists + Live Sync — Pattern Map
**Mapped:** 2026-06-09
**Files analyzed:** 18 new/modified files
**Analogs found:** 16 / 18
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `apps/api/src/db/schema.ts` | model (modify) | CRUD | self | exact |
| `apps/api/src/db/migrations/0002_lists_schema.sql` | migration | batch | `0001_calendars_user_url_unique.sql` | role-match |
| `apps/api/src/lib/listEmitter.ts` | utility | event-driven | none in codebase | no analog |
| `apps/api/src/routes/lists.ts` | route/controller | CRUD | `apps/api/src/routes/events.ts` | exact |
| `apps/api/src/routes/sse.ts` | route (modify) | streaming | self | exact |
| `apps/api/src/index.ts` | config (modify) | request-response | self | exact |
| `apps/pwa/src/App.tsx` | component (modify) | request-response | self | exact |
| `apps/pwa/src/components/BottomTabBar.tsx` | component | request-response | `apps/pwa/src/components/AppNav.tsx` | role-match |
| `apps/pwa/src/routes/ListsIndex.tsx` | component | CRUD | `apps/pwa/src/components/CalendarShell.tsx` | role-match |
| `apps/pwa/src/routes/ListDetail.tsx` | component | CRUD + event-driven | `apps/pwa/src/components/CalendarShell.tsx` | role-match |
| `apps/pwa/src/components/ListCard.tsx` | component | request-response | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
| `apps/pwa/src/components/ItemRow.tsx` | component | event-driven | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
| `apps/pwa/src/components/AddItemInput.tsx` | component | request-response | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
| `apps/pwa/src/components/CreateListSheet.tsx` | component | request-response | `apps/pwa/src/components/DeleteConfirmationDialog.tsx` | role-match |
| `apps/pwa/src/components/LiveSyncIndicator.tsx` | component | event-driven | none — novel | no analog |
| `apps/pwa/src/components/ListsEmptyState.tsx` | component | request-response | `apps/pwa/src/components/SkeletonCalendar.tsx` | role-match |
| `apps/pwa/src/hooks/useListSSE.ts` | hook | event-driven | none in codebase | no analog |
| `apps/pwa/src/api/listsClient.ts` | utility | request-response | `apps/pwa/src/api/client.ts` | exact |
| `apps/pwa/src/store/listsStore.ts` | store | request-response | `apps/pwa/src/store/calendarStore.ts` | exact |
---
## Pattern Assignments
### `apps/api/src/db/schema.ts` (model, CRUD — append new tables)
**Analog:** self — read `apps/api/src/db/schema.ts` lines 1163 in full above.
**Imports pattern** (lines 113):
```typescript
import {
mysqlTable,
varchar,
int,
timestamp,
boolean,
index,
unique,
} from 'drizzle-orm/mysql-core'
```
Note: `mysqlEnum` is imported for `calendarOutbox` but is not needed for list tables. Import only what the new tables use.
**Table definition pattern** (lines 4054, `memberCredentials` — simplest table with FK):
```typescript
export const memberCredentials = mysqlTable(
'member_credentials',
{
id: int().primaryKey().autoincrement(),
userId: int('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
encryptedPassword: text('encrypted_password').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [index('idx_member_credentials_user_id').on(t.userId)],
)
```
**Composite unique key pattern** (lines 6186, `calendars`):
```typescript
(t) => [
index('idx_calendars_user_id').on(t.userId),
unique('uniq_calendar_user_url').on(t.userId, t.url),
]
```
**New tables to append** — follow the schema from RESEARCH.md §Database Schema Design exactly:
- `lists` — int PK, `owner_id` FK to `users`, `name varchar(255)`, `is_shared boolean DEFAULT true`, `created_at`, `updated_at`; index on `owner_id`
- `listShares` — int PK, `list_id` FK to `lists` cascade, `user_id` FK to `users` cascade, `created_at`; unique on `(list_id, user_id)`, index on `user_id`
- `listItems` — int PK, `list_id` FK to `lists` cascade, `text varchar(500)`, `checked boolean DEFAULT false`, `rank varchar(255)`, `created_at`, `updated_at`; composite index on `(list_id, rank)`, index on `(list_id, checked)`
---
### `apps/api/src/routes/lists.ts` (route, CRUD)
**Analog:** `apps/api/src/routes/events.ts` (full file above)
**File header doc-block pattern** (lines 122 of events.ts):
```typescript
/**
* Lists router — list + item CRUD with SSE fan-out trigger.
*
* Security:
* - All endpoints resolve currentUserId via resolveUserId (returns null → 401).
* - Access control: list must be owned by currentUser OR appear in list_shares.
* - Drizzle parameterized queries prevent SQL injection.
* - zod validates all write payloads (name max 255, text max 500).
*
* Mounted under /api/* in index.ts — behind oidcAuthMiddleware.
*/
```
**Imports pattern** (lines 2437 of events.ts):
```typescript
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { and, or, eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { lists, listItems, listShares, users } from '../db/schema.js'
import { getAuth } from '../auth/middleware.js'
import { upsertUser, deriveDisplayName } from '../auth/user.js'
import { publishListEvent } from '../lib/listEmitter.js'
import '../auth/devBypass.js'
export const listsRouter = new Hono()
```
**`resolveUserId` helper** — copy verbatim from `events.ts` lines 5976. This function is duplicated per router (not extracted to a shared module) — maintain that pattern.
**Zod schema pattern** (lines 82105 of events.ts):
```typescript
const createListSchema = z.object({
name: z.string().min(1).max(255),
isShared: z.boolean().default(true),
})
const createItemSchema = z.object({
text: z.string().min(1).max(500),
})
// Per-field PATCH — enforce exactly one field per D-08
const patchItemSchema = z
.object({
checked: z.boolean(),
text: z.string().min(1).max(500),
position: z.string().min(1).max(255),
})
.partial()
.refine((obj) => Object.keys(obj).length === 1, {
message: 'PATCH must update exactly one field',
})
```
**Route handler pattern** — GET with auth + access check + try/catch (lines 122221 of events.ts):
```typescript
listsRouter.get('/', async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
try {
// SELECT lists WHERE owner_id = ? OR id IN (SELECT list_id FROM list_shares WHERE user_id = ?)
const rows = await db
.select({ /* ... */ })
.from(lists)
.where(or(eq(lists.ownerId, currentUserId), /* join with listShares */ ))
return c.json({ lists: rows })
} catch (err) {
console.error('[lists] DB query failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
}
})
```
**Fan-out trigger pattern** — call after every successful write:
```typescript
// After insert/update/delete succeeds:
publishListEvent(listId, { type: 'item:added', listId, payload: newItem })
```
**Ownership verification pattern** (lines 326339 of events.ts):
```typescript
// Verify list access before any item mutation
const [listRow] = await db
.select({ ownerId: lists.ownerId })
.from(lists)
.where(eq(lists.id, listId))
if (!listRow) return c.json({ error: 'Not found' }, 404)
const isOwner = listRow.ownerId === currentUserId
const [shareRow] = isOwner ? [{}] : await db
.select({ listId: listShares.listId })
.from(listShares)
.where(and(eq(listShares.listId, listId), eq(listShares.userId, currentUserId)))
if (!isOwner && !shareRow) return c.json({ error: 'Access denied' }, 403)
```
---
### `apps/api/src/routes/sse.ts` (route, streaming — extend existing)
**Analog:** self — `apps/api/src/routes/sse.ts` lines 140 (full file above).
**Core streamSSE pattern** (lines 2840):
```typescript
sseRouter.get('/heartbeat', (c) => {
return streamSSE(c, async (stream) => {
let id = 0
while (!stream.aborted) {
await stream.writeSSE({
data: JSON.stringify({ ts: new Date().toISOString(), id }),
event: 'heartbeat',
id: String(id++),
})
await stream.sleep(10_000)
}
})
})
```
**New `/lists` endpoint** extends this with:
- `resolveUserId(c)` call first → 401 on null (same as events.ts pattern)
- `getAccessibleListIds(userId)` DB query before `streamSSE` call
- `subscribeListEvents(listId, handler)` loop inside `streamSSE`
- Heartbeat loop at 30s cadence (not 10s — Pangolin smoke test used 10s for the heartbeat, 30s is fine for production load)
- Cleanup: `unsubscribers.forEach(unsub => unsub())` after the while loop exits
```typescript
sseRouter.get('/lists', async (c) => {
const userId = await resolveUserId(c)
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
const accessibleListIds = await getAccessibleListIds(userId)
return streamSSE(c, async (stream) => {
const unsubscribers: Array<() => void> = []
for (const listId of accessibleListIds) {
const unsub = subscribeListEvents(listId, async (event) => {
if (stream.aborted) return
await stream.writeSSE({
data: JSON.stringify(event),
event: event.type,
id: `${listId}-${Date.now()}`,
})
})
unsubscribers.push(unsub)
}
let tick = 0
while (!stream.aborted) {
await stream.writeSSE({
data: JSON.stringify({ ts: new Date().toISOString() }),
event: 'heartbeat',
id: String(tick++),
})
await stream.sleep(30_000)
}
unsubscribers.forEach((unsub) => unsub())
})
})
```
---
### `apps/api/src/lib/listEmitter.ts` (utility, event-driven)
**No codebase analog** — this is new. Use the pattern from RESEARCH.md Finding 1 verbatim:
```typescript
import { EventEmitter } from 'node:events'
const emitter = new EventEmitter()
emitter.setMaxListeners(200)
export type ListEvent = {
type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted'
listId: number
payload: unknown
}
export function publishListEvent(listId: number, event: ListEvent): void {
emitter.emit(`list:${listId}`, event)
}
export function subscribeListEvents(
listId: number,
handler: (event: ListEvent) => void,
): () => void {
const channel = `list:${listId}`
emitter.on(channel, handler)
return () => emitter.off(channel, handler)
}
```
---
### `apps/api/src/index.ts` (config, modify)
**Analog:** self — lines 183 (full file above).
**Route mount pattern** (lines 5961):
```typescript
app.route('/api/me', meRouter)
app.route('/api/events', eventsRouter)
app.route('/api/sse', sseRouter)
```
**Add after `sseRouter` mount:**
```typescript
import { listsRouter } from './routes/lists.js'
// ...
app.route('/api/lists', listsRouter)
```
**Auto-migrate pattern** — add before `startBrokerPoller()` (line 65):
```typescript
import { migrate } from 'drizzle-orm/mysql2/migrator'
// ...
await migrate(db, { migrationsFolder: './src/db/migrations' })
```
---
### `apps/pwa/src/App.tsx` (component, modify)
**Analog:** self — lines 15 (full file above). Currently a one-liner.
**Transform to** (pattern from RESEARCH.md Finding 5):
```tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
import { CalendarShell } from './components/CalendarShell.js'
import { ListsIndex } from './routes/ListsIndex.js'
import { ListDetail } from './routes/ListDetail.js'
import { BottomTabBar } from './components/BottomTabBar.js'
export default function App() {
return (
<BrowserRouter>
<AppShell />
</BrowserRouter>
)
}
function AppShell() {
return (
<>
<Routes>
<Route path="/" element={<Navigate to="/calendar" replace />} />
<Route path="/calendar" element={<CalendarShell />} />
<Route path="/lists" element={<ListsIndex />} />
<Route path="/lists/:listId" element={<ListDetail />} />
</Routes>
<BottomTabBar />
</>
)
}
```
---
### `apps/pwa/src/components/BottomTabBar.tsx` (component, request-response)
**Analog:** `apps/pwa/src/components/AppNav.tsx` (nav component with active-state links — check that file if needed for CSS token conventions)
**Key pattern** — NavLink with isActive callback (from RESEARCH.md Finding 5):
```tsx
import { NavLink } from 'react-router'
import { CalendarDays, List } from 'lucide-react'
export function BottomTabBar() {
return (
<nav
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: '56px',
display: 'flex',
background: 'var(--color-surface)',
borderTop: '1px solid var(--color-border)',
zIndex: 100,
}}
>
<NavLink
to="/calendar"
className={({ isActive }) => isActive ? 'tab tab--active' : 'tab'}
>
<CalendarDays size={22} aria-hidden="true" />
<span>Calendar</span>
</NavLink>
<NavLink
to="/lists"
className={({ isActive }) => isActive ? 'tab tab--active' : 'tab'}
>
<List size={22} aria-hidden="true" />
<span>Lists</span>
</NavLink>
</nav>
)
}
```
CSS tokens: use `var(--color-surface)`, `var(--color-border)`, `var(--color-text-primary)`, `var(--color-text-secondary)` — the existing token layer from Phase 2.
---
### `apps/pwa/src/api/listsClient.ts` (utility, request-response)
**Analog:** `apps/pwa/src/api/client.ts` lines 153 (full pattern above).
**Imports + credential pattern**:
```typescript
// credentials: 'include' on every fetch — session cookie required (same as client.ts)
const BASE = '/api'
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
...init,
})
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`)
return res
}
```
**Type + function pattern** (mirrors client.ts):
```typescript
export interface List {
id: number
name: string
isShared: boolean
ownerId: number
}
export interface ListItem {
id: number
listId: number
text: string
checked: boolean
rank: string
}
export async function fetchLists(): Promise<{ lists: List[] }> {
return apiFetch('/lists').then((r) => r.json())
}
export async function createList(payload: { name: string; isShared: boolean }): Promise<{ id: number }> {
return apiFetch('/lists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then((r) => r.json())
}
export async function patchListItem(
itemId: number,
patch: { checked?: boolean } | { text: string } | { position: string },
): Promise<void> {
await apiFetch(`/list-items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
}
```
---
### `apps/pwa/src/store/listsStore.ts` (store, request-response)
**Analog:** `apps/pwa/src/store/calendarStore.ts` lines 189 (full file above).
**Imports + create pattern** (lines 2627 of calendarStore.ts):
```typescript
import { create } from 'zustand'
export interface ListsStore {
// UI-only state — no server data
activeTab: 'calendar' | 'lists'
createListSheetOpen: boolean
// ...
setActiveTab: (tab: 'calendar' | 'lists') => void
setCreateListSheetOpen: (open: boolean) => void
}
export const useListsStore = create<ListsStore>()((set) => ({
activeTab: 'calendar',
createListSheetOpen: false,
setActiveTab: (tab) => set({ activeTab: tab }),
setCreateListSheetOpen: (open) => set({ createListSheetOpen: open }),
}))
```
Convention from calendarStore.ts: no `persist` middleware used in this project — state is ephemeral (view persistence done manually with localStorage in calendarStore; lists UI state does not need persistence).
---
### `apps/pwa/src/routes/ListsIndex.tsx` (component, CRUD)
**Analog:** `apps/pwa/src/components/CalendarShell.tsx` lines 160 (header shown above).
**Data fetching pattern** — useQuery with credentials (from CalendarShell + client.ts):
```tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchLists, createList, deleteList } from '../api/listsClient.js'
const { data, isLoading, isError } = useQuery({
queryKey: ['lists'],
queryFn: fetchLists,
})
```
**State branches** — mirror CalendarShell: `isLoading` → skeleton/empty, `isError` → error state with retry, `data` → render list. CalendarShell uses `isLoading` / `isError` / success branches explicitly.
**useMutation with optimistic update** (from RESEARCH.md Finding 6):
```tsx
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: (listId: number) => deleteList(listId),
onMutate: async (listId) => {
await queryClient.cancelQueries({ queryKey: ['lists'] })
const previous = queryClient.getQueryData(['lists'])
queryClient.setQueryData(['lists'], (old: any) => ({
...old,
lists: old.lists.filter((l: any) => l.id !== listId),
}))
return { previous }
},
onError: (_err, _vars, context) => {
if (context?.previous) queryClient.setQueryData(['lists'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['lists'] })
},
})
```
**DeleteConfirmationDialog reuse** — import from `../components/DeleteConfirmationDialog.js` and render conditionally. The existing dialog is tightly coupled to `calendarStore`; create a new list-delete confirmation component (`ListDeleteDialog`) that mirrors its structure but is driven by `listsStore`. Do NOT modify the existing dialog — it is stable (D-06 says reuse, but the implementation is wired to calendarStore).
---
### `apps/pwa/src/routes/ListDetail.tsx` (component, CRUD + event-driven)
**Analog:** `apps/pwa/src/components/CalendarShell.tsx`
**SSE + polling pattern** (from RESEARCH.md Finding 4):
```tsx
import { useListSSE } from '../hooks/useListSSE.js'
const { data } = useQuery({
queryKey: ['list', listId],
queryFn: () => fetchListItems(listId),
refetchInterval: 30_000, // D-12: polling fallback always active
})
useListSSE({ listId, onStateChange: setSyncState })
```
**Active / completed section split** (D-05):
```tsx
const activeItems = items.filter((i) => !i.checked).sort(/* by rank ASC */)
const completedItems = items.filter((i) => i.checked)
```
---
### `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (reuse — no modification)
**This file is not modified.** A new `ListDeleteDialog.tsx` mirrors its structure:
- Same modal layout: fixed backdrop + centered dialog
- Same CSS tokens: `var(--color-overlay)`, `var(--color-surface-raised)`, `var(--color-destructive)`, `var(--space-*)`, `var(--text-*)`, `var(--font-family-base)`
- Same `useMutation` + `onSuccess` → close pattern (lines 6476 of DeleteConfirmationDialog.tsx)
- Same accessibility: `role="dialog"`, `aria-modal="true"`, Escape key listener, `tabIndex={-1}` + focus on open
Key lines to copy for the modal skeleton (lines 86208 of DeleteConfirmationDialog.tsx) — swap the heading text to "Delete list?" and the body text to "All items in this list will be permanently deleted."
---
### `apps/pwa/src/components/ItemRow.tsx` (component, event-driven)
**Analog:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (for mutation + CSS token patterns)
**dnd-kit drag handle pattern** (from RESEARCH.md Finding 7):
```tsx
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical } from 'lucide-react'
export function ItemRow({ item, onCheck, onDelete }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: item.id })
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition: transition ?? 'transform 150ms ease-out', // D-14: animate remote reorders
opacity: isDragging ? 0.8 : 1,
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
minHeight: '44px', // touch target
}}
{...attributes}
>
{/* Drag handle — listeners on handle only (not whole row) */}
<button
{...listeners}
aria-label="Drag to reorder"
style={{ background: 'none', border: 'none', cursor: 'grab', padding: 'var(--space-1)' }}
>
<GripVertical size={16} color="var(--color-text-secondary)" />
</button>
{/* ... checkbox, text, delete button */}
</div>
)
}
```
---
### `apps/pwa/src/hooks/useListSSE.ts` (hook, event-driven)
**No codebase analog.** Use the pattern from RESEARCH.md Finding 4 verbatim. Key conventions:
- `useCallback` for `connect` to keep the `useEffect` dependency stable
- `esRef`, `attemptsRef`, `timerRef` — all `useRef` (not state) to avoid re-render loops
- Close `es` on error before scheduling retry (prevents browser auto-reconnect stacking with manual reconnect)
- `withCredentials: true` on `new EventSource(...)` — required for session cookie (Pitfall 7)
- Return `syncState` so the caller can render `LiveSyncIndicator`
---
## Shared Patterns
### Auth / User Resolution
**Source:** `apps/api/src/routes/events.ts` lines 5976
**Apply to:** `apps/api/src/routes/lists.ts`
```typescript
async function resolveUserId(c: any): 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
}
```
Copy verbatim — do not extract to a shared module (existing convention duplicates this per router).
### Error Handling (API routes)
**Source:** `apps/api/src/routes/events.ts` (every handler's catch block)
**Apply to:** `apps/api/src/routes/lists.ts`
```typescript
try {
// ... db operations ...
return c.json({ /* result */ })
} catch (err) {
console.error('[lists/<endpoint>] DB operation failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
}
```
Pattern: 503 on caught exceptions, not 500. `console.error` with a `[module/endpoint]` prefix tag.
### 401 Guard Pattern
**Source:** `apps/api/src/routes/events.ts` (every handler, lines 125126):
```typescript
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
```
First two lines of every protected handler.
### CSS Token Usage (PWA components)
**Source:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (all inline styles)
**Apply to:** all new PWA components
Token set in use:
- `var(--color-surface)`, `var(--color-surface-raised)`, `var(--color-overlay)`
- `var(--color-text-primary)`, `var(--color-text-secondary)`
- `var(--color-destructive)`, `var(--color-border)`
- `var(--space-1)` through `var(--space-6)`
- `var(--text-body-size)`, `var(--text-heading-size)`, `var(--text-label-size)`, `var(--font-family-base)`
- Minimum touch target: `minHeight: '44px'` (buttons/rows)
### Fetch with Credentials (PWA API client)
**Source:** `apps/pwa/src/api/client.ts` lines 3639
**Apply to:** `apps/pwa/src/api/listsClient.ts`
```typescript
const res = await fetch('/api/...', {
credentials: 'include',
redirect: 'manual', // only for /api/me; not required for data endpoints
})
```
All list API calls use `credentials: 'include'`. `redirect: 'manual'` is only needed for the initial session check (`/api/me`) — not for list CRUD endpoints.
### TanStack Query Keys
**Apply to:** `apps/pwa/src/routes/ListsIndex.tsx`, `apps/pwa/src/routes/ListDetail.tsx`
```typescript
// List of lists
queryKey: ['lists']
// Items for a specific list
queryKey: ['list', listId] // listId is a number
```
Invalidate `['lists']` after create/delete list. Invalidate `['list', listId]` after any item mutation or SSE event for that list.
### Lucide Icons (PWA)
**Source:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` line 27, CalendarShell.tsx line 43
**Apply to:** all new PWA components
```tsx
import { Trash2 } from 'lucide-react'
// Usage: <Trash2 size={16} aria-hidden="true" />
```
Always pass `aria-hidden="true"` to decorative icons. Use `size={16}` for inline/dense contexts, `size={22}` for navigation tabs.
### Zustand Store Shape
**Source:** `apps/pwa/src/store/calendarStore.ts`
**Apply to:** `apps/pwa/src/store/listsStore.ts`
- Use `create<StoreInterface>()((set) => ({ ... }))` — no `persist`, no `immer`
- Actions are inline setter functions, not separate files
- UI-only state: no server data, no async in store actions (mutations live in components via `useMutation`)
### Plain-text XSS Guard (PWA)
**Source:** `apps/pwa/src/components/DeleteConfirmationDialog.tsx` (comment `/* Plain text — XSS guard */` on every text node, line 133 etc.)
**Apply to:** all new PWA components that render user-supplied strings (list names, item text)
Never use `dangerouslySetInnerHTML`. All user content is a JSX text child.
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `apps/api/src/lib/listEmitter.ts` | utility | event-driven | No EventEmitter or pub/sub pattern exists in the codebase. Use RESEARCH.md Finding 1 pattern. |
| `apps/pwa/src/components/LiveSyncIndicator.tsx` | component | event-driven | No live-sync state indicator exists. Novel UI component — use CSS token conventions and the "disconnected / updates paused" UX from D-11. |
| `apps/pwa/src/hooks/useListSSE.ts` | hook | event-driven | No custom hooks exist in the codebase. Novel — use RESEARCH.md Finding 4 pattern. |
---
## Metadata
**Analog search scope:** `apps/api/src/routes/`, `apps/api/src/db/`, `apps/api/src/lib/`, `apps/api/src/index.ts`, `apps/pwa/src/`, `apps/pwa/src/components/`, `apps/pwa/src/api/`, `apps/pwa/src/store/`
**Files scanned:** 12 source files read in full
**Pattern extraction date:** 2026-06-09