diff --git a/.gitignore b/.gitignore index 55a6654..978ca9b 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ apps/api/scripts/seed-credential.mjs # Graphify build cache (regenerable; committed artifacts live in .planning/graphs/) graphify-out/ + +# Intel / graph diff baselines (local-only; regenerated on each refresh/build) +.planning/intel/.last-refresh.json +.planning/graphs/.last-build-snapshot.json diff --git a/.planning/config.json b/.planning/config.json index ea34c68..090bdbf 100644 --- a/.planning/config.json +++ b/.planning/config.json @@ -18,7 +18,7 @@ "plan_check": true, "verifier": true, "nyquist_validation": true, - "auto_advance": true, + "auto_advance": false, "node_repair": true, "node_repair_budget": 2, "ui_phase": true, @@ -27,7 +27,7 @@ "tdd_mode": true, "human_verify_mode": "end-of-phase", "text_mode": false, - "research_before_questions": false, + "research_before_questions": true, "discuss_mode": "discuss", "skip_discuss": false, "code_review": true, @@ -42,7 +42,9 @@ "security_enforcement": true, "security_asvs_level": 1, "security_block_on": "high", - "_auto_chain_active": false + "_auto_chain_active": false, + "ui_review": true, + "use_worktrees": true }, "ship": { "pr_body_sections": [ @@ -83,5 +85,11 @@ "source_grounding_authority": "grep" }, "mode": "interactive", - "granularity": "standard" + "granularity": "standard", + "intel": { + "enabled": true + }, + "graphify": { + "enabled": true + } } diff --git a/.planning/phases/04-shared-lists-live-sync/04-PATTERNS.md b/.planning/phases/04-shared-lists-live-sync/04-PATTERNS.md new file mode 100644 index 0000000..c157f14 --- /dev/null +++ b/.planning/phases/04-shared-lists-live-sync/04-PATTERNS.md @@ -0,0 +1,749 @@ +# 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: