Files
familysync/.planning/milestones/v1.0-phases/04-shared-lists-live-sync/04-PATTERNS.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

28 KiB
Raw Blame History

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):

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):

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):

(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):

/**
 * 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):

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):

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):

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:

// After insert/update/delete succeeds:
publishListEvent(listId, { type: 'item:added', listId, payload: newItem });

Ownership verification pattern (lines 326339 of events.ts):

// 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):

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
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:

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):

app.route('/api/me', meRouter);
app.route('/api/events', eventsRouter);
app.route('/api/sse', sseRouter);

Add after sseRouter mount:

import { listsRouter } from './routes/lists.js';
// ...
app.route('/api/lists', listsRouter);

Auto-migrate pattern — add before startBrokerPoller() (line 65):

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):

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):

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:

// 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):

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):

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):

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):

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):

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):

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):

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

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

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):

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

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

// 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

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