# 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 */}
{candidates.map((c) => (
{c.name}
))}
{ATTRIBUTE_ROWS.map((row) => (
{/* Sticky label cell — bg-white mandatory */}
{row.label}
{candidates.map((c) => (
{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 `