--- 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" --- 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`). @/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md @/home/jlmak/.claude/get-shit-done/templates/summary.md @.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 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 ``` Task 1: Build ComparisonTable component src/client/components/ComparisonTable.tsx 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: `
` (scroll wrapper) - Inner: `` with `style={{ minWidth: Math.max(400, candidates.length * 180) + 'px' }}` and `className="border-collapse text-sm w-full"` - ``: One `` with sticky corner ``: Render rows using a declarative ATTRIBUTE_ROWS array (see below). **Sticky left column CSS (CRITICAL):** Every `` gets `border-b border-gray-50` for subtle row separation. - Label `
` (empty, for label column) + one `` per candidate showing name. If `candidate.id === resolvedCandidateId`, apply `bg-amber-50 text-amber-800` and prepend a trophy icon: ``. - `
` and `` 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 `` with `object-cover`. Else render `` in a `bg-gray-50` placeholder. Use `w-12 h-12` sizing. - **Name**: `{name}` - **Rank**: Reuse `` 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 ``. If delta string exists (not null, not best), show delta below in `text-xs text-gray-400`. If `weightGrams` is null, show ``. - **Price**: Same pattern as weight but with `formatPrice(priceCents, currency)` and `bg-green-50` for the best cell. - **Status**: Render as static text `{STATUS_LABELS[status]}`. 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: ``. If null, render ``. Links remain clickable even in resolved threads (read-only means no mutations, but navigation is fine). - **Notes**: If `notes` exists, render `

{notes}

` (whitespace-pre-line preserves newlines). If null, render em dash placeholder. - **Pros**: If `pros` exists, split on `"\n"`, filter empty strings, render as `
    ` with `
  • ` items. If null, render em dash placeholder. - **Cons**: Same as Pros rendering. **Winner column highlight (resolved threads):** When `resolvedCandidateId` is set, the winner's `
` in the header gets `bg-amber-50 text-amber-800` + trophy icon. Each body `` 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 `
` cells: `text-xs font-medium text-gray-500 uppercase tracking-wide w-28`. - Data `` cells: `px-4 py-3 min-w-[160px]`. - Header `` 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. cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20 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). Task 2: Wire compare toggle and ComparisonTable into thread detail src/client/stores/uiStore.ts, src/client/routes/threads/$threadId.tsx **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 `
` 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 && ( )} ``` 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" ? ( ) : 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. cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20 && bun test 2>&1 | tail -5 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. 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 - 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 After completion, create `.planning/phases/12-comparison-view/12-01-SUMMARY.md`