---
phase: 12-comparison-view
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/client/components/ComparisonTable.tsx
  - src/client/stores/uiStore.ts
  - src/client/routes/threads/$threadId.tsx
autonomous: true
requirements: [COMP-01, COMP-02, COMP-03, COMP-04]

must_haves:
  truths:
    - "User can toggle to a Compare view when a thread has 2+ candidates"
    - "Comparison table shows all candidates side-by-side with weight, price, images, notes, links, status, pros, and cons"
    - "The lightest candidate weight cell has a blue highlight; the cheapest candidate price cell has a green highlight"
    - "Non-best cells show a gray +delta string; best cells show no delta"
    - "The table scrolls horizontally on narrow viewports while the attribute label column stays fixed on the left"
    - "Missing weight or price data displays a dash, never a misleading zero"
    - "A resolved thread shows the comparison read-only with the winner column visually marked (amber tint + trophy)"
  artifacts:
    - path: "src/client/components/ComparisonTable.tsx"
      provides: "Tabular side-by-side comparison component"
      min_lines: 120
    - path: "src/client/stores/uiStore.ts"
      provides: "Extended candidateViewMode union type including 'compare'"
      contains: "compare"
    - path: "src/client/routes/threads/$threadId.tsx"
      provides: "Compare toggle button and ComparisonTable rendering branch"
      contains: "ComparisonTable"
  key_links:
    - from: "src/client/routes/threads/$threadId.tsx"
      to: "src/client/components/ComparisonTable.tsx"
      via: "import and conditional render when candidateViewMode === 'compare'"
      pattern: "candidateViewMode.*compare"
    - from: "src/client/components/ComparisonTable.tsx"
      to: "src/client/lib/formatters.ts"
      via: "formatWeight and formatPrice for cell values and delta strings"
      pattern: "formatWeight|formatPrice"
    - from: "src/client/components/ComparisonTable.tsx"
      to: "src/client/components/CandidateListItem.tsx"
      via: "RankBadge import for rank row"
      pattern: "RankBadge"
    - from: "src/client/routes/threads/$threadId.tsx"
      to: "src/client/stores/uiStore.ts"
      via: "candidateViewMode state read and setCandidateViewMode action"
      pattern: "candidateViewMode"
---

<objective>
Build the side-by-side candidate comparison table for research threads. Users toggle into compare mode from the existing view-mode bar and see all candidates as columns in a horizontally-scrollable table with sticky attribute labels, weight/price delta highlighting, and resolved-thread winner marking.

Purpose: Enables users to directly compare candidates on weight, price, status, notes, pros, and cons without switching between cards -- the key decision-support view for the Research & Decision Tools milestone.

Output: One new component (`ComparisonTable.tsx`), two modified files (`uiStore.ts`, `$threadId.tsx`).
</objective>

<execution_context>
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
@/home/jlmak/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/12-comparison-view/12-CONTEXT.md
@.planning/phases/12-comparison-view/12-RESEARCH.md

@src/client/stores/uiStore.ts
@src/client/routes/threads/$threadId.tsx
@src/client/hooks/useThreads.ts
@src/client/lib/formatters.ts
@src/client/components/CandidateListItem.tsx

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From src/client/hooks/useThreads.ts:
```typescript
interface CandidateWithCategory {
  id: number;
  threadId: number;
  name: string;
  weightGrams: number | null;
  priceCents: number | null;
  categoryId: number;
  notes: string | null;
  productUrl: string | null;
  imageFilename: string | null;
  status: "researching" | "ordered" | "arrived";
  pros: string | null;
  cons: string | null;
  createdAt: string;
  updatedAt: string;
  categoryName: string;
  categoryIcon: string;
}

interface ThreadWithCandidates {
  id: number;
  name: string;
  status: "active" | "resolved";
  resolvedCandidateId: number | null;
  createdAt: string;
  updatedAt: string;
  candidates: CandidateWithCategory[];
}
```

From src/client/lib/formatters.ts:
```typescript
export type WeightUnit = "g" | "oz" | "lb" | "kg";
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
export function formatWeight(grams: number | null | undefined, unit?: WeightUnit): string; // returns "--" for null
export function formatPrice(cents: number | null | undefined, currency?: Currency): string; // returns "--" for null
```

From src/client/components/CandidateListItem.tsx:
```typescript
export function RankBadge({ rank }: { rank: number }): JSX.Element | null;
// Returns null for rank > 3, renders gold/silver/bronze medal icon for 1/2/3
```

From src/client/stores/uiStore.ts (lines 52-54, current state):
```typescript
// Current type (will be extended):
candidateViewMode: "list" | "grid";
setCandidateViewMode: (mode: "list" | "grid") => void;
```

From src/client/lib/iconData.tsx:
```typescript
export function LucideIcon({ name, size, className, style }: LucideIconProps): JSX.Element;
// Renders any Lucide icon by kebab-case name string
```

From src/client/hooks/useWeightUnit.ts:
```typescript
export function useWeightUnit(): WeightUnit; // reads from settings API
```

From src/client/hooks/useCurrency.ts:
```typescript
export function useCurrency(): Currency; // reads from settings API
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Build ComparisonTable component</name>
  <files>src/client/components/ComparisonTable.tsx</files>
  <action>
Create `src/client/components/ComparisonTable.tsx` — a self-contained comparison table component.

**Props interface:**
```typescript
interface ComparisonTableProps {
  candidates: CandidateWithCategory[];
  resolvedCandidateId: number | null;
}
```
Import `CandidateWithCategory` type inline (duplicate the interface locally or import from useThreads — match project convention for component-local interfaces as seen in CandidateListItem.tsx which declares its own `CandidateWithCategory`).

**Delta computation (useMemo):**
- Weight deltas: Filter candidates with non-null `weightGrams`. Find the minimum. For each candidate, compute `delta = weightGrams - min`. If `delta === 0` (this IS the best), store `null` as delta string. Otherwise store `+${formatWeight(delta, unit)}`. Track `bestWeightId`. If all candidates have null weight, `bestWeightId = null`.
- Price deltas: Same logic for `priceCents` with `formatPrice(delta, currency)`. Track `bestPriceId`.
- Use `useWeightUnit()` and `useCurrency()` hooks for unit/currency-aware formatting.

**Table structure:**
- Outer: `<div className="overflow-x-auto rounded-xl border border-gray-100">` (scroll wrapper)
- Inner: `<table>` with `style={{ minWidth: Math.max(400, candidates.length * 180) + 'px' }}` and `className="border-collapse text-sm w-full"`
- `<thead>`: One `<tr>` with sticky corner `<th>` (empty, for label column) + one `<th>` per candidate showing name. If `candidate.id === resolvedCandidateId`, apply `bg-amber-50 text-amber-800` and prepend a trophy icon: `<LucideIcon name="trophy" size={12} className="text-amber-600" />`.
- `<tbody>`: Render rows using a declarative ATTRIBUTE_ROWS array (see below).

**Sticky left column CSS (CRITICAL):**
Every `<td>` and `<th>` in the first (label) column MUST have: `sticky left-0 z-10 bg-white`. Without `bg-white`, scrolled content bleeds through. Use `z-10` (not higher — avoid conflicts with panels/modals).

**Attribute row order** (per locked decision): Image, Name, Rank, Weight (with delta), Price (with delta), Status, Product Link, Notes, Pros, Cons.

**Row rendering — use a declarative array pattern:**
Define `ATTRIBUTE_ROWS` as an array of `{ key, label, render(candidate) }`. This keeps the JSX clean and makes row reordering trivial. Build this array inside the component function body (after useMemo hooks) so it can close over `weightDeltas`, `priceDeltas`, `bestWeightId`, `bestPriceId`, `unit`, `currency`.

**Cell renderers:**
- **Image**: 48x48 rounded-lg container. If `imageFilename`, render `<img src="/uploads/${imageFilename}" />` with `object-cover`. Else render `<LucideIcon name={categoryIcon} size={20} className="text-gray-400" />` in a `bg-gray-50` placeholder. Use `w-12 h-12` sizing.
- **Name**: `<span className="text-sm font-medium text-gray-900">{name}</span>`
- **Rank**: Reuse `<RankBadge rank={index + 1} />` imported from CandidateListItem. Rank is derived from array position (candidates are already sorted by sort_order from the API).
- **Weight**: Show `formatWeight(weightGrams, unit)` as primary value in `font-medium text-gray-900`. If this is the best (`isBest`), apply `bg-blue-50` to the `<td>`. If delta string exists (not null, not best), show delta below in `text-xs text-gray-400`. If `weightGrams` is null, show `<span className="text-gray-300">—</span>`.
- **Price**: Same pattern as weight but with `formatPrice(priceCents, currency)` and `bg-green-50` for the best cell.
- **Status**: Render as static text `<span className="text-xs text-gray-600">{STATUS_LABELS[status]}</span>`. Define STATUS_LABELS map: `{ researching: "Researching", ordered: "Ordered", arrived: "Arrived" }`. No click-to-cycle in compare view — comparison is for reading, not mutation.
- **Product Link**: If `productUrl` exists, render a clickable link that calls `openExternalLink(productUrl)` from uiStore: `<button onClick={() => openExternalLink(productUrl)} className="text-xs text-blue-500 hover:underline">View</button>`. If null, render `<span className="text-gray-300">—</span>`. Links remain clickable even in resolved threads (read-only means no mutations, but navigation is fine).
- **Notes**: If `notes` exists, render `<p className="text-xs text-gray-700 whitespace-pre-line">{notes}</p>` (whitespace-pre-line preserves newlines). If null, render em dash placeholder.
- **Pros**: If `pros` exists, split on `"\n"`, filter empty strings, render as `<ul className="list-disc list-inside space-y-0.5">` with `<li className="text-xs text-gray-700">` items. If null, render em dash placeholder.
- **Cons**: Same as Pros rendering.

**Winner column highlight (resolved threads):**
When `resolvedCandidateId` is set, the winner's `<th>` in the header gets `bg-amber-50 text-amber-800` + trophy icon. Each body `<td>` for the winner column gets a subtle `bg-amber-50/50` tint (half-opacity amber). This must not conflict with the best-weight/best-price blue/green highlights — when both apply (winner IS also lightest), use the weight/price highlight color (it's more informative).

**Row styling:**
- Each `<tr>` gets `border-b border-gray-50` for subtle row separation.
- Label `<td>` cells: `text-xs font-medium text-gray-500 uppercase tracking-wide w-28`.
- Data `<td>` cells: `px-4 py-3 min-w-[160px]`.
- Header `<th>` cells: `px-4 py-3 text-left text-xs font-medium text-gray-700 min-w-[160px]`.

**Table border + rounding:**
The outer wrapper has `rounded-xl border border-gray-100`. Add `overflow-hidden` to the wrapper alongside `overflow-x-auto` to clip the table's corners to the rounded border: `className="overflow-x-auto overflow-hidden rounded-xl border border-gray-100"`. Actually, use `overflow-x-auto` on an outer div, and put the border/rounding there. The table itself does not need border-radius.
  </action>
  <verify>
    <automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20</automated>
  </verify>
  <done>ComparisonTable.tsx exists with all 10 attribute rows, delta computation via useMemo, sticky left column with bg-white, horizontal scroll wrapper, blue/green best-cell highlights, gray delta text for non-best, amber winner column for resolved threads, em dash for missing data (never zero).</done>
</task>

<task type="auto">
  <name>Task 2: Wire compare toggle and ComparisonTable into thread detail</name>
  <files>src/client/stores/uiStore.ts, src/client/routes/threads/$threadId.tsx</files>
  <action>
**Step 1: Extend uiStore candidateViewMode (src/client/stores/uiStore.ts)**

Change the type union on lines 53-54 from:
```typescript
candidateViewMode: "list" | "grid";
setCandidateViewMode: (mode: "list" | "grid") => void;
```
to:
```typescript
candidateViewMode: "list" | "grid" | "compare";
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
```

No other changes needed in uiStore — the implementation lines 112-113 are generic and already work with the wider type.

**Step 2: Add compare toggle button (src/client/routes/threads/$threadId.tsx)**

In the toolbar toggle bar (the `<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">` block around line 146), add a third button for compare mode. The compare button should only render when `thread.candidates.length >= 2` (per locked decision).

Add the compare button after the grid button but inside the toggle container:
```tsx
{thread.candidates.length >= 2 && (
  <button
    type="button"
    onClick={() => setCandidateViewMode("compare")}
    className={`p-1.5 rounded-md transition-colors ${
      candidateViewMode === "compare"
        ? "bg-gray-200 text-gray-900"
        : "text-gray-400 hover:text-gray-600"
    }`}
    title="Compare view"
  >
    <LucideIcon name="columns-3" size={16} />
  </button>
)}
```

Also: Hide the "Add Candidate" button when in compare view. Change the existing `{isActive && (` guard (around line 123) to `{isActive && candidateViewMode !== "compare" && (`. This keeps the toolbar uncluttered — users switch to list/grid to add candidates.

**Step 3: Add ComparisonTable rendering branch ($threadId.tsx)**

Import ComparisonTable at the top of the file:
```typescript
import { ComparisonTable } from "../../components/ComparisonTable";
```

In the candidates rendering section (starting around line 192), add a compare branch BEFORE the existing list check:
```tsx
) : candidateViewMode === "compare" ? (
  <ComparisonTable
    candidates={displayItems}
    resolvedCandidateId={thread.resolvedCandidateId}
  />
) : candidateViewMode === "list" ? (
  // ... existing list rendering (unchanged)
) : (
  // ... existing grid rendering (unchanged)
)
```

Pass `displayItems` (not `thread.candidates`) so the order reflects any pending drag reorder state, though in compare mode drag is not active — `displayItems` will equal `thread.candidates` when `tempItems` is null.
  </action>
  <verify>
    <automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20 && bun test 2>&1 | tail -5</automated>
  </verify>
  <done>uiStore accepts "compare" as a candidateViewMode value. Thread detail page shows a third toggle icon (columns-3) when 2+ candidates exist. Clicking it renders ComparisonTable. "Add Candidate" button is hidden in compare mode. Existing list/grid views still work unchanged. All existing tests pass.</done>
</task>

</tasks>

<verification>
1. `bun run lint` passes with no errors
2. `bun test` full suite passes (no backend changes, existing tests unaffected)
3. Manual browser verification:
   - Navigate to a thread with 2+ candidates
   - Verify the compare icon (columns-3) appears in the toggle bar
   - Click compare icon -> tabular comparison renders with candidates as columns
   - Verify attribute row order: Image, Name, Rank, Weight, Price, Status, Link, Notes, Pros, Cons
   - Verify lightest weight cell has blue-50 tint, cheapest price cell has green-50 tint
   - Verify non-best cells show gray +delta text
   - Verify missing weight/price shows em dash (not zero)
   - Resize viewport narrow -> table scrolls horizontally, label column stays fixed
   - Navigate to a resolved thread -> winner column has amber tint + trophy, no mutation controls
   - Toggle back to list/grid views -> they still work correctly
   - Thread with 0 or 1 candidate -> compare icon does not appear
</verification>

<success_criteria>
- ComparisonTable.tsx renders all 10 attribute rows with correct data
- Delta highlighting: blue-50 on lightest weight, green-50 on cheapest price, gray delta text on non-best
- Sticky label column with solid bg-white stays visible during horizontal scroll
- Resolved threads show winner column with amber-50 tint and trophy icon
- Missing data renders as em dash, never as zero (COMP-04)
- Compare toggle icon appears only when >= 2 candidates
- All existing tests continue to pass
</success_criteria>

<output>
After completion, create `.planning/phases/12-comparison-view/12-01-SUMMARY.md`
</output>
