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 |
|
true |
|
|
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
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, computedelta = weightGrams - min. Ifdelta === 0(this IS the best), storenullas delta string. Otherwise store+${formatWeight(delta, unit)}. TrackbestWeightId. If all candidates have null weight,bestWeightId = null. - Price deltas: Same logic for
priceCentswithformatPrice(delta, currency). TrackbestPriceId. - Use
useWeightUnit()anduseCurrency()hooks for unit/currency-aware formatting.
Table structure:
- Outer:
<div className="overflow-x-auto rounded-xl border border-gray-100">(scroll wrapper) - Inner:
<table>withstyle={{ minWidth: Math.max(400, candidates.length * 180) + 'px' }}andclassName="border-collapse text-sm w-full" <thead>: One<tr>with sticky corner<th>(empty, for label column) + one<th>per candidate showing name. Ifcandidate.id === resolvedCandidateId, applybg-amber-50 text-amber-800and 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}" />withobject-cover. Else render<LucideIcon name={categoryIcon} size={20} className="text-gray-400" />in abg-gray-50placeholder. Usew-12 h-12sizing. - 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 infont-medium text-gray-900. If this is the best (isBest), applybg-blue-50to the<td>. If delta string exists (not null, not best), show delta below intext-xs text-gray-400. IfweightGramsis null, show<span className="text-gray-300">—</span>. - Price: Same pattern as weight but with
formatPrice(priceCents, currency)andbg-green-50for 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
productUrlexists, render a clickable link that callsopenExternalLink(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
notesexists, 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
prosexists, 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>getsborder-b border-gray-50for 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).
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.
<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>