diff --git a/.planning/phases/12-comparison-view/12-RESEARCH.md b/.planning/phases/12-comparison-view/12-RESEARCH.md new file mode 100644 index 0000000..1d20c6a --- /dev/null +++ b/.planning/phases/12-comparison-view/12-RESEARCH.md @@ -0,0 +1,541 @@ +# Phase 12: Comparison View - Research + +**Researched:** 2026-03-17 +**Domain:** React tabular UI, CSS sticky columns, horizontal scroll, delta computation +**Confidence:** HIGH + +## Summary + +Phase 12 is a pure frontend phase. No backend changes, no schema changes, no new npm packages. All required data is already returned by `useThread(threadId)` — candidates carry `weightGrams`, `priceCents`, `status`, `productUrl`, `notes`, `pros`, `cons`, `imageFilename`, `categoryIcon`, and rank is derived from sort_order position in the array. The work is entirely in building a `ComparisonTable` component, wiring a third toggle button into the existing view-mode bar, and extending the `candidateViewMode` Zustand union type. + +The core CSS challenge is the sticky-first-column + horizontal-scroll table pattern. Modern CSS handles this well as long as `overflow-x: auto` is placed on a wrapper `
`, not the `` element itself, and the sticky `
` cells in the label column have an explicit background color (otherwise scrolling content bleeds through). Z-index layering is simple for this use case because there is only one sticky axis (the left label column); no sticky top header is needed since the table is not vertically scrollable. + +Delta computation is straightforward arithmetic: find the minimum `weightGrams` across candidates that have a value, subtract each candidate's value from that minimum to produce a delta, and render a `+Xg` or `—` string. The "best" cell gets `bg-blue-50` for weight (matching existing blue weight pill color) or `bg-green-50` for price (matching existing green price pill color). Missing data must never display as "0" — a dash placeholder is required by COMP-04, and `formatWeight(null)` already returns `"--"`. + +**Primary recommendation:** Build `ComparisonTable.tsx` as a self-contained component that accepts `candidates[]` and `resolvedCandidateId | null`, computes deltas internally with `useMemo`, renders a `
` wrapper around a plain ``, and uses `sticky left-0 bg-white z-10` on the label `
` cells. + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +- Compare mode entry point: Add a third icon to the existing list/grid toggle bar, making it list | grid | compare (three-way toggle) +- Use `candidateViewMode: 'list' | 'grid' | 'compare'` in uiStore — extends the existing Zustand state +- Compare icon only appears when 2+ candidates exist in the thread (hidden otherwise) +- Table orientation: Candidates as columns, attribute labels as rows (classic product-comparison pattern — Amazon/Wirecutter style) +- Sticky left column for attribute labels; table scrolls horizontally on narrow viewports +- Attribute row order: Image → Name → Rank → Weight (with delta) → Price (with delta) → Status → Product Link → Notes → Pros → Cons +- Delta highlighting style: Lightest candidate's weight cell gets a subtle colored background tint (e.g., bg-green-50); cheapest similarly +- Non-best cells show delta text in neutral gray — no colored badges for deltas, only the "best" cell gets color + +### Claude's Discretion +- "Add Candidate" button visibility when in compare view +- Image thumbnail sizing in comparison cells (square crop vs wider aspect) +- Multi-line text rendering strategy (clamped with expand vs full text) +- Missing data indicator style (dash with label, empty cell, etc.) +- Delta format: absolute value + delta underneath, or delta only for non-best cells +- Winner column marking approach (column tint, trophy icon, or both) +- Resolved thread interactivity (links clickable vs all read-only) +- Resolution banner behavior in compare view +- View mode persistence (already in Zustand — whether compare resets on navigation or persists) +- Compare toggle icon choice (e.g., Lucide `columns-3`, `table-2`, or similar) +- Table cell padding, border styling, and overall table chrome +- Column minimum/maximum widths +- Keyboard accessibility for horizontal scrolling + +### Deferred Ideas (OUT OF SCOPE) +None — discussion stayed within phase scope + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| COMP-01 | User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status) | ComparisonTable component; all fields available from useThread hook; no backend changes needed | +| COMP-02 | User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences | Delta computation via array reduce; best-cell highlight via bg-blue-50 (weight) / bg-green-50 (price); gray delta text for non-best | +| COMP-03 | Comparison table scrolls horizontally with a sticky label column on narrow viewports | overflow-x-auto wrapper div + sticky left-0 bg-white z-10 on label td cells | +| COMP-04 | Comparison view displays read-only summary for resolved threads | resolvedCandidateId from useThread; disable mutation actions; winner column visual tint; resolved check pattern established in Phase 11 | + + +--- + +## Standard Stack + +### Core (all already installed — no new packages needed) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| React 19 | ^19.2.4 | Component rendering | Project stack | +| Tailwind CSS | v4 | Utility styling | Project stack | +| Zustand | ^5.0.11 | candidateViewMode state | Already used for list/grid toggle | +| lucide-react | ^0.577.0 | Toggle icon (`columns-3` confirmed present) | All icons use LucideIcon helper | +| framer-motion | ^12.37.0 | Optional AnimatePresence for view transition | Already installed | + +### Supporting Utilities (already in project) +| Utility | Location | Purpose | +|---------|----------|---------| +| `formatWeight(grams, unit)` | `src/client/lib/formatters.ts` | Weight cell values and delta strings; returns `"--"` for null | +| `formatPrice(cents, currency)` | `src/client/lib/formatters.ts` | Price cell values and delta strings; returns `"--"` for null | +| `useWeightUnit()` | `src/client/hooks/useWeightUnit.ts` | Current unit setting | +| `useCurrency()` | `src/client/hooks/useCurrency.ts` | Current currency setting | +| `useThread(threadId)` | `src/client/hooks/useThreads.ts` | All candidate data | +| `RankBadge` | `src/client/components/CandidateListItem.tsx` | Rank medal icons (exported) | +| `LucideIcon` | `src/client/lib/iconData.tsx` | Icon rendering with fallback | + +--- + +## Architecture Patterns + +### Recommended File Structure +``` +src/client/ +├── components/ +│ └── ComparisonTable.tsx # New: tabular comparison component +├── stores/ +│ └── uiStore.ts # Modify: extend candidateViewMode union type +└── routes/threads/ + └── $threadId.tsx # Modify: add compare branch + third toggle button +``` + +### Pattern 1: Sticky Left Column with Horizontal Scroll + +**What:** Wrap `` in `
`. Apply `sticky left-0 bg-white z-10` to every `
` and `` in the first (label) column. + +**When to use:** Any time a table needs a frozen left column with horizontal scrolling. + +**Critical pitfall:** The sticky `td` cells MUST have a solid background color. Without `bg-white`, scrolling content bleeds through the "sticky" cell because the cell is transparent. + +**Example:** +```tsx +// Outer wrapper enables horizontal scroll +
+ + + + {/* Sticky corner cell — bg-white mandatory */} + + ))} + + + + {ATTRIBUTE_ROWS.map((row) => ( + + {/* Sticky label cell — bg-white mandatory */} + + {candidates.map((c) => ( + + ))} + + ))} + +
+ {candidates.map((c) => ( + + {c.name} +
+ {row.label} + + {row.render(c)} +
+
+``` + +### Pattern 2: Delta Computation (null-safe, useMemo) + +**What:** Derive the "best" candidate and compute deltas before rendering. Use `useMemo` keyed on `candidates` to avoid recomputing on every render. + +**Example:** +```tsx +// Source: derived from project formatters.ts patterns +const { weightDeltas, bestWeightId } = useMemo(() => { + const withWeight = candidates.filter((c) => c.weightGrams != null); + if (withWeight.length === 0) return { weightDeltas: new Map(), bestWeightId: null }; + + const minGrams = Math.min(...withWeight.map((c) => c.weightGrams as number)); + const bestWeightId = withWeight.find((c) => c.weightGrams === minGrams)!.id; + + const weightDeltas = new Map( + candidates.map((c) => { + if (c.weightGrams == null) return [c.id, null]; // null = missing data + const delta = c.weightGrams - minGrams; + return [c.id, delta === 0 ? null : `+${formatWeight(delta, unit)}`]; + // delta === 0 means this IS the best — no delta string needed + }) + ); + return { weightDeltas, bestWeightId }; +}, [candidates, unit]); +``` + +### Pattern 3: Extending Zustand Union Type + +**What:** Widen the existing `candidateViewMode` type from `'list' | 'grid'` to `'list' | 'grid' | 'compare'`. The implementation setter line is unchanged. + +**Example:** +```typescript +// In uiStore.ts — only two type declaration lines change (lines 53-54): +candidateViewMode: "list" | "grid" | "compare"; +setCandidateViewMode: (mode: "list" | "grid" | "compare") => void; + +// Implementation lines 112-113 — unchanged: +candidateViewMode: "list", +setCandidateViewMode: (mode) => set({ candidateViewMode: mode }), +``` + +### Pattern 4: Three-Way Toggle Button + +**What:** Add a third button to the existing `bg-gray-100 rounded-lg p-0.5` toggle bar in `$threadId.tsx`. Show compare button only when `thread.candidates.length >= 2`. + +**Example:** +```tsx +{thread.candidates.length >= 2 && ( + +)} +``` + +**Confirmed:** `columns-3` maps to `Columns3` in lucide-react ^0.577.0 and is present in the installed package (verified via `node -e "const {icons}=require('lucide-react'); console.log('Columns3' in icons)"`). Use `LucideIcon name="columns-3"` — the LucideIcon helper handles the `toPascalCase` conversion. + +### Pattern 5: Row Definition as Data + +**What:** Define the attribute rows as a declarative array, not hard-coded JSX branches. Each entry has a `key`, `label`, and a `render(candidate)` function. This makes row reordering trivial and matches the locked attribute order. + +**Example:** +```tsx +// Attribute row order per CONTEXT.md: Image → Name → Rank → Weight → Price → Status → Link → Notes → Pros → Cons +const ATTRIBUTE_ROWS = [ + { key: "image", label: "Image", render: (c: C) => }, + { key: "name", label: "Name", render: (c: C) => {c.name} }, + { key: "rank", label: "Rank", render: (c: C) => }, + { key: "weight", label: "Weight", render: (c: C) => }, + { key: "price", label: "Price", render: (c: C) => }, + { key: "status", label: "Status", render: (c: C) => {STATUS_LABELS[c.status]} }, + { key: "link", label: "Link", render: (c: C) => c.productUrl ? openExternalLink(c.productUrl!)} className="text-xs text-blue-500 hover:underline">View : }, + { key: "notes", label: "Notes", render: (c: C) => }, + { key: "pros", label: "Pros", render: (c: C) => }, + { key: "cons", label: "Cons", render: (c: C) => }, +]; +``` + +### Pattern 6: Pros/Cons Rendering (confirmed newline-separated) + +**What:** `CandidateForm.tsx` uses a `