# 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 ( ); } function AppShell() { return ( <> } /> } /> } /> } /> ); } ``` --- ### `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 ( ); } ``` 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 { 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 { 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()((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 (
{/* Drag handle — listeners on handle only (not whole row) */} {/* ... checkbox, text, delete button */}
); } ``` --- ### `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 { 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/] 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: