# Phase 12: Comparison View - Research **Researched:** 2026-03-17 **Domain:** React tabular UI, CSS sticky columns, horizontal scroll, delta computation **Confidence:** HIGH ## Summary Phase 12 is a pure frontend phase. No backend changes, no schema changes, no new npm packages. All required data is already returned by `useThread(threadId)` — candidates carry `weightGrams`, `priceCents`, `status`, `productUrl`, `notes`, `pros`, `cons`, `imageFilename`, `categoryIcon`, and rank is derived from sort_order position in the array. The work is entirely in building a `ComparisonTable` component, wiring a third toggle button into the existing view-mode bar, and extending the `candidateViewMode` Zustand union type. The core CSS challenge is the sticky-first-column + horizontal-scroll table pattern. Modern CSS handles this well as long as `overflow-x: auto` is placed on a wrapper `
`, not the `` element itself, and the sticky `
` cells in the label column have an explicit background color (otherwise scrolling content bleeds through). Z-index layering is simple for this use case because there is only one sticky axis (the left label column); no sticky top header is needed since the table is not vertically scrollable. Delta computation is straightforward arithmetic: find the minimum `weightGrams` across candidates that have a value, subtract each candidate's value from that minimum to produce a delta, and render a `+Xg` or `—` string. The "best" cell gets `bg-blue-50` for weight (matching existing blue weight pill color) or `bg-green-50` for price (matching existing green price pill color). Missing data must never display as "0" — a dash placeholder is required by COMP-04, and `formatWeight(null)` already returns `"--"`. **Primary recommendation:** Build `ComparisonTable.tsx` as a self-contained component that accepts `candidates[]` and `resolvedCandidateId | null`, computes deltas internally with `useMemo`, renders a `
` wrapper around a plain ``, and uses `sticky left-0 bg-white z-10` on the label `
` cells. --- ## User Constraints (from CONTEXT.md) ### Locked Decisions - Compare mode entry point: Add a third icon to the existing list/grid toggle bar, making it list | grid | compare (three-way toggle) - Use `candidateViewMode: 'list' | 'grid' | 'compare'` in uiStore — extends the existing Zustand state - Compare icon only appears when 2+ candidates exist in the thread (hidden otherwise) - Table orientation: Candidates as columns, attribute labels as rows (classic product-comparison pattern — Amazon/Wirecutter style) - Sticky left column for attribute labels; table scrolls horizontally on narrow viewports - Attribute row order: Image → Name → Rank → Weight (with delta) → Price (with delta) → Status → Product Link → Notes → Pros → Cons - Delta highlighting style: Lightest candidate's weight cell gets a subtle colored background tint (e.g., bg-green-50); cheapest similarly - Non-best cells show delta text in neutral gray — no colored badges for deltas, only the "best" cell gets color ### Claude's Discretion - "Add Candidate" button visibility when in compare view - Image thumbnail sizing in comparison cells (square crop vs wider aspect) - Multi-line text rendering strategy (clamped with expand vs full text) - Missing data indicator style (dash with label, empty cell, etc.) - Delta format: absolute value + delta underneath, or delta only for non-best cells - Winner column marking approach (column tint, trophy icon, or both) - Resolved thread interactivity (links clickable vs all read-only) - Resolution banner behavior in compare view - View mode persistence (already in Zustand — whether compare resets on navigation or persists) - Compare toggle icon choice (e.g., Lucide `columns-3`, `table-2`, or similar) - Table cell padding, border styling, and overall table chrome - Column minimum/maximum widths - Keyboard accessibility for horizontal scrolling ### Deferred Ideas (OUT OF SCOPE) None — discussion stayed within phase scope --- ## Phase Requirements | ID | Description | Research Support | |----|-------------|-----------------| | COMP-01 | User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status) | ComparisonTable component; all fields available from useThread hook; no backend changes needed | | COMP-02 | User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences | Delta computation via array reduce; best-cell highlight via bg-blue-50 (weight) / bg-green-50 (price); gray delta text for non-best | | COMP-03 | Comparison table scrolls horizontally with a sticky label column on narrow viewports | overflow-x-auto wrapper div + sticky left-0 bg-white z-10 on label td cells | | COMP-04 | Comparison view displays read-only summary for resolved threads | resolvedCandidateId from useThread; disable mutation actions; winner column visual tint; resolved check pattern established in Phase 11 | --- ## Standard Stack ### Core (all already installed — no new packages needed) | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | React 19 | ^19.2.4 | Component rendering | Project stack | | Tailwind CSS | v4 | Utility styling | Project stack | | Zustand | ^5.0.11 | candidateViewMode state | Already used for list/grid toggle | | lucide-react | ^0.577.0 | Toggle icon (`columns-3` confirmed present) | All icons use LucideIcon helper | | framer-motion | ^12.37.0 | Optional AnimatePresence for view transition | Already installed | ### Supporting Utilities (already in project) | Utility | Location | Purpose | |---------|----------|---------| | `formatWeight(grams, unit)` | `src/client/lib/formatters.ts` | Weight cell values and delta strings; returns `"--"` for null | | `formatPrice(cents, currency)` | `src/client/lib/formatters.ts` | Price cell values and delta strings; returns `"--"` for null | | `useWeightUnit()` | `src/client/hooks/useWeightUnit.ts` | Current unit setting | | `useCurrency()` | `src/client/hooks/useCurrency.ts` | Current currency setting | | `useThread(threadId)` | `src/client/hooks/useThreads.ts` | All candidate data | | `RankBadge` | `src/client/components/CandidateListItem.tsx` | Rank medal icons (exported) | | `LucideIcon` | `src/client/lib/iconData.tsx` | Icon rendering with fallback | --- ## Architecture Patterns ### Recommended File Structure ``` src/client/ ├── components/ │ └── ComparisonTable.tsx # New: tabular comparison component ├── stores/ │ └── uiStore.ts # Modify: extend candidateViewMode union type └── routes/threads/ └── $threadId.tsx # Modify: add compare branch + third toggle button ``` ### Pattern 1: Sticky Left Column with Horizontal Scroll **What:** Wrap `` in `
`. Apply `sticky left-0 bg-white z-10` to every `
` and `` in the first (label) column. **When to use:** Any time a table needs a frozen left column with horizontal scrolling. **Critical pitfall:** The sticky `td` cells MUST have a solid background color. Without `bg-white`, scrolling content bleeds through the "sticky" cell because the cell is transparent. **Example:** ```tsx // Outer wrapper enables horizontal scroll
{/* Sticky corner cell — bg-white mandatory */} ))} {ATTRIBUTE_ROWS.map((row) => ( {/* Sticky label cell — bg-white mandatory */} {candidates.map((c) => ( ))} ))}
{candidates.map((c) => ( {c.name}
{row.label} {row.render(c)}
``` ### Pattern 2: Delta Computation (null-safe, useMemo) **What:** Derive the "best" candidate and compute deltas before rendering. Use `useMemo` keyed on `candidates` to avoid recomputing on every render. **Example:** ```tsx // Source: derived from project formatters.ts patterns const { weightDeltas, bestWeightId } = useMemo(() => { const withWeight = candidates.filter((c) => c.weightGrams != null); if (withWeight.length === 0) return { weightDeltas: new Map(), bestWeightId: null }; const minGrams = Math.min(...withWeight.map((c) => c.weightGrams as number)); const bestWeightId = withWeight.find((c) => c.weightGrams === minGrams)!.id; const weightDeltas = new Map( candidates.map((c) => { if (c.weightGrams == null) return [c.id, null]; // null = missing data const delta = c.weightGrams - minGrams; return [c.id, delta === 0 ? null : `+${formatWeight(delta, unit)}`]; // delta === 0 means this IS the best — no delta string needed }) ); return { weightDeltas, bestWeightId }; }, [candidates, unit]); ``` ### Pattern 3: Extending Zustand Union Type **What:** Widen the existing `candidateViewMode` type from `'list' | 'grid'` to `'list' | 'grid' | 'compare'`. The implementation setter line is unchanged. **Example:** ```typescript // In uiStore.ts — only two type declaration lines change (lines 53-54): candidateViewMode: "list" | "grid" | "compare"; setCandidateViewMode: (mode: "list" | "grid" | "compare") => void; // Implementation lines 112-113 — unchanged: candidateViewMode: "list", setCandidateViewMode: (mode) => set({ candidateViewMode: mode }), ``` ### Pattern 4: Three-Way Toggle Button **What:** Add a third button to the existing `bg-gray-100 rounded-lg p-0.5` toggle bar in `$threadId.tsx`. Show compare button only when `thread.candidates.length >= 2`. **Example:** ```tsx {thread.candidates.length >= 2 && ( )} ``` **Confirmed:** `columns-3` maps to `Columns3` in lucide-react ^0.577.0 and is present in the installed package (verified via `node -e "const {icons}=require('lucide-react'); console.log('Columns3' in icons)"`). Use `LucideIcon name="columns-3"` — the LucideIcon helper handles the `toPascalCase` conversion. ### Pattern 5: Row Definition as Data **What:** Define the attribute rows as a declarative array, not hard-coded JSX branches. Each entry has a `key`, `label`, and a `render(candidate)` function. This makes row reordering trivial and matches the locked attribute order. **Example:** ```tsx // Attribute row order per CONTEXT.md: Image → Name → Rank → Weight → Price → Status → Link → Notes → Pros → Cons const ATTRIBUTE_ROWS = [ { key: "image", label: "Image", render: (c: C) => }, { key: "name", label: "Name", render: (c: C) => {c.name} }, { key: "rank", label: "Rank", render: (c: C) => }, { key: "weight", label: "Weight", render: (c: C) => }, { key: "price", label: "Price", render: (c: C) => }, { key: "status", label: "Status", render: (c: C) => {STATUS_LABELS[c.status]} }, { key: "link", label: "Link", render: (c: C) => c.productUrl ? openExternalLink(c.productUrl!)} className="text-xs text-blue-500 hover:underline">View : }, { key: "notes", label: "Notes", render: (c: C) => }, { key: "pros", label: "Pros", render: (c: C) => }, { key: "cons", label: "Cons", render: (c: C) => }, ]; ``` ### Pattern 6: Pros/Cons Rendering (confirmed newline-separated) **What:** `CandidateForm.tsx` uses a `