diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 90f3611..e2a8672 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -83,7 +83,9 @@ Plans: 2. The lightest candidate column is highlighted and all other columns show their weight difference relative to it; the cheapest candidate is highlighted similarly for price 3. The comparison table scrolls horizontally on a narrow viewport without breaking layout; the attribute label column stays fixed on the left 4. A resolved thread shows the comparison table in read-only mode with the winning candidate visually marked -**Plans**: TBD +**Plans:** 1 plan +Plans: +- [ ] 12-01-PLAN.md — ComparisonTable component + compare toggle wiring in thread detail ### Phase 13: Setup Impact Preview **Goal**: Users can select any setup and see exactly how much weight and cost each candidate would add or subtract @@ -111,5 +113,5 @@ Plans: | 9. Weight Classification and Visualization | v1.2 | 2/2 | Complete | 2026-03-16 | | 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 | | 11. Candidate Ranking | 2/2 | Complete | 2026-03-16 | - | -| 12. Comparison View | v1.3 | 0/TBD | Not started | - | +| 12. Comparison View | v1.3 | 0/1 | Not started | - | | 13. Setup Impact Preview | v1.3 | 0/TBD | Not started | - | diff --git a/.planning/phases/12-comparison-view/12-01-PLAN.md b/.planning/phases/12-comparison-view/12-01-PLAN.md new file mode 100644 index 0000000..390e49a --- /dev/null +++ b/.planning/phases/12-comparison-view/12-01-PLAN.md @@ -0,0 +1,321 @@ +--- +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` +