# 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 1–163 in full above.

**Imports pattern** (lines 1–13):
```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 40–54, `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 61–86, `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 1–22 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 24–37 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 59–76. This function is duplicated per router (not extracted to a shared module) — maintain that pattern.

**Zod schema pattern** (lines 82–105 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 122–221 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 326–339 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 1–40 (full file above).

**Core streamSSE pattern** (lines 28–40):
```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 1–83 (full file above).

**Route mount pattern** (lines 59–61):
```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 1–5 (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 1–53 (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 1–89 (full file above).

**Imports + create pattern** (lines 26–27 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 1–60 (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 64–76 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 86–208 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 59–76
**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 125–126):
```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 36–39
**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
