Files
GearBox/.planning/phases/12-comparison-view/12-01-PLAN.md

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
12-comparison-view 01 execute 1
src/client/components/ComparisonTable.tsx
src/client/stores/uiStore.ts
src/client/routes/threads/$threadId.tsx
true
COMP-01
COMP-02
COMP-03
COMP-04
truths artifacts key_links
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)
path provides min_lines
src/client/components/ComparisonTable.tsx Tabular side-by-side comparison component 120
path provides contains
src/client/stores/uiStore.ts Extended candidateViewMode union type including 'compare' compare
path provides contains
src/client/routes/threads/$threadId.tsx Compare toggle button and ComparisonTable rendering branch ComparisonTable
from to via pattern
src/client/routes/threads/$threadId.tsx src/client/components/ComparisonTable.tsx import and conditional render when candidateViewMode === 'compare' candidateViewMode.*compare
from to via pattern
src/client/components/ComparisonTable.tsx src/client/lib/formatters.ts formatWeight and formatPrice for cell values and delta strings formatWeight|formatPrice
from to via pattern
src/client/components/ComparisonTable.tsx src/client/components/CandidateListItem.tsx RankBadge import for rank row RankBadge
from to via pattern
src/client/routes/threads/$threadId.tsx src/client/stores/uiStore.ts candidateViewMode state read and setCandidateViewMode action 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).

<execution_context> @/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md @/home/jlmak/.claude/get-shit-done/templates/summary.md </execution_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

From src/client/hooks/useThreads.ts:

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:

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:

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

// Current type (will be extended):
candidateViewMode: "list" | "grid";
setCandidateViewMode: (mode: "list" | "grid") => void;

From src/client/lib/iconData.tsx:

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:

export function useWeightUnit(): WeightUnit; // reads from settings API

From src/client/hooks/useCurrency.ts:

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:

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

candidateViewMode: "list" | "grid";
setCandidateViewMode: (mode: "list" | "grid") => void;

to:

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:

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

import { ComparisonTable } from "../../components/ComparisonTable";

In the candidates rendering section (starting around line 192), add a compare branch BEFORE the existing list check:

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

<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>
After completion, create `.planning/phases/12-comparison-view/12-01-SUMMARY.md`