docs(phase-12): research comparison view phase
This commit is contained in:
541
.planning/phases/12-comparison-view/12-RESEARCH.md
Normal file
541
.planning/phases/12-comparison-view/12-RESEARCH.md
Normal file
@@ -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 `<div>`, not the `<table>` element itself, and the sticky `<td>` 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 `<div className="overflow-x-auto">` wrapper around a plain `<table>`, and uses `sticky left-0 bg-white z-10` on the label `<td>` cells.
|
||||
|
||||
---
|
||||
|
||||
<user_constraints>
|
||||
## 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
|
||||
</user_constraints>
|
||||
|
||||
---
|
||||
|
||||
<phase_requirements>
|
||||
## 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 |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## 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 `<table>` in `<div className="overflow-x-auto">`. Apply `sticky left-0 bg-white z-10` to every `<td>` and `<th>` 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
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-100">
|
||||
<table
|
||||
className="border-collapse text-sm"
|
||||
style={{ minWidth: `${Math.max(400, candidates.length * 180)}px` }}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
{/* Sticky corner cell — bg-white mandatory */}
|
||||
<th className="sticky left-0 z-10 bg-white px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wide w-28" />
|
||||
{candidates.map((c) => (
|
||||
<th key={c.id} className="px-4 py-3 text-left text-xs font-medium text-gray-700 min-w-[160px]">
|
||||
{c.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ATTRIBUTE_ROWS.map((row) => (
|
||||
<tr key={row.key} className="border-b border-gray-50">
|
||||
{/* Sticky label cell — bg-white mandatory */}
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500">
|
||||
{row.label}
|
||||
</td>
|
||||
{candidates.map((c) => (
|
||||
<td key={c.id} className="px-4 py-3">
|
||||
{row.render(c)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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<number, string | null>(), 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 && (
|
||||
<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>
|
||||
)}
|
||||
```
|
||||
|
||||
**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) => <ImageCell candidate={c} /> },
|
||||
{ key: "name", label: "Name", render: (c: C) => <span className="text-sm font-medium text-gray-900">{c.name}</span> },
|
||||
{ key: "rank", label: "Rank", render: (c: C) => <RankBadge rank={rankOf(c)} /> },
|
||||
{ key: "weight", label: "Weight", render: (c: C) => <WeightCell candidate={c} delta={weightDeltas.get(c.id)} isBest={c.id === bestWeightId} unit={unit} /> },
|
||||
{ key: "price", label: "Price", render: (c: C) => <PriceCell candidate={c} delta={priceDeltas.get(c.id)} isBest={c.id === bestPriceId} currency={currency} /> },
|
||||
{ key: "status", label: "Status", render: (c: C) => <span className="text-xs text-gray-600">{STATUS_LABELS[c.status]}</span> },
|
||||
{ key: "link", label: "Link", render: (c: C) => c.productUrl ? <a href="#" onClick={() => openExternalLink(c.productUrl!)} className="text-xs text-blue-500 hover:underline">View</a> : <span className="text-gray-300">—</span> },
|
||||
{ key: "notes", label: "Notes", render: (c: C) => <TextCell text={c.notes} /> },
|
||||
{ key: "pros", label: "Pros", render: (c: C) => <BulletCell text={c.pros} /> },
|
||||
{ key: "cons", label: "Cons", render: (c: C) => <BulletCell text={c.cons} /> },
|
||||
];
|
||||
```
|
||||
|
||||
### Pattern 6: Pros/Cons Rendering (confirmed newline-separated)
|
||||
|
||||
**What:** `CandidateForm.tsx` uses a `<textarea>` with placeholder "One pro per line..." — users enter newline-separated text. The form submits `form.pros.trim() || undefined`, so empty = `undefined` → stored as `null` in DB. Non-empty content is raw text with `\n` separators.
|
||||
|
||||
**How to render in compare table:**
|
||||
```tsx
|
||||
function BulletCell({ text }: { text: string | null }) {
|
||||
if (!text) return <span className="text-gray-300">—</span>;
|
||||
const items = text.split("\n").filter(Boolean);
|
||||
if (items.length === 0) return <span className="text-gray-300">—</span>;
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="text-xs text-gray-700">{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Setting `overflow-x-auto` on `<table>` directly:** Has no effect in CSS. Must be on a wrapper `<div>`.
|
||||
- **Transparent sticky cells:** Sticky `<td>` cells without `bg-white` let scrolled content bleed through visually.
|
||||
- **Computing deltas inside render:** Use `useMemo` — compute once, not per render cycle.
|
||||
- **Using `overflow: hidden` on any ancestor of the sticky column:** Breaks the sticky positioning context.
|
||||
- **Missing data shown as "0":** `formatWeight(null)` already returns `"--"`. Guard delta computation with null checks before arithmetic.
|
||||
- **Rendering pros/cons as raw string:** Split on `\n` and render as `<ul>` — the form stores `\n`-separated text.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Weight/price formatting | Custom format functions | `formatWeight()` / `formatPrice()` in `formatters.ts` | Handles all units, currencies, null — returns `"--"` for null |
|
||||
| Rank medal icons | Custom SVG or color dots | `RankBadge` from `CandidateListItem.tsx` | Already exported, handles ranks 1-3 with correct colors |
|
||||
| Zustand state | Local useState for view mode | Existing `candidateViewMode` in `uiStore` | Persists across navigation, consistent with list/grid |
|
||||
| Icon rendering | Direct lucide component imports | `LucideIcon` helper from `iconData.tsx` | Handles fallback, consistent API across project |
|
||||
| Unit/currency awareness | Hardcode "g" or "$" | `useWeightUnit()` / `useCurrency()` | Reads from user settings |
|
||||
|
||||
**Key insight:** This phase is almost entirely composition of already-built primitives. The delta computation logic and sticky column CSS are the only genuinely new work.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Sticky Cell Background Bleed-Through
|
||||
**What goes wrong:** The label column appears sticky but scrolling content renders on top of it, making text illegible.
|
||||
**Why it happens:** `position: sticky` keeps the element in its visual position but does not create an opaque layer. Without a background color the cell is transparent.
|
||||
**How to avoid:** Add `bg-white` to every sticky `<td>` and `<th>` in the label column. If alternating row backgrounds are used, the sticky cells must also match those background colors.
|
||||
**Warning signs:** Label text becomes unreadable when scrolling horizontally.
|
||||
|
||||
### Pitfall 2: overflow-x-auto on Wrong Element
|
||||
**What goes wrong:** The table never scrolls horizontally regardless of viewport width.
|
||||
**Why it happens:** CSS `overflow` properties only apply to block/flex/grid containers. `<table>` is a table container — `overflow-x: auto` on `<table>` has no effect per CSS spec.
|
||||
**How to avoid:** Wrap `<table>` in `<div className="overflow-x-auto">`. Set `minWidth` on the `<table>` itself (not the wrapper) to force scrollability.
|
||||
**Warning signs:** Table content wraps aggressively instead of scrolling; columns collapse on narrow screens.
|
||||
|
||||
### Pitfall 3: Delta Shows for Best Candidate
|
||||
**What goes wrong:** The lightest candidate shows "+0g" instead of just the value cleanly.
|
||||
**Why it happens:** Naive `delta = candidate - min` yields 0 for the best candidate.
|
||||
**How to avoid:** When `delta === 0`, return `null` for the delta string. The best-cell highlight color already communicates "this is best." Only non-best cells show a delta string.
|
||||
**Warning signs:** Best cell shows "+0g" or "+$0.00" alongside the colored highlight.
|
||||
|
||||
### Pitfall 4: Missing Data Rendered as Zero (COMP-04 violation)
|
||||
**What goes wrong:** A candidate with `weightGrams: null` shows "0g" in the weight row, misleading the user.
|
||||
**Why it happens:** Passing `null` through subtraction arithmetic silently produces 0 in JavaScript.
|
||||
**How to avoid:** Guard before computing: `if (c.weightGrams == null) return [c.id, null]`. In the cell renderer, when value is null, render `—` (em dash).
|
||||
**Warning signs:** COMP-04 violated; user appears to see "0g" for an item with no weight entered.
|
||||
|
||||
### Pitfall 5: z-index Conflicts with Panels/Dropdowns
|
||||
**What goes wrong:** Sticky label column renders above the SlideOutPanel or modal overlays.
|
||||
**Why it happens:** Using `z-index: 50` or higher on sticky cells competes with panel z-index values.
|
||||
**How to avoid:** Use `z-index: 10` (Tailwind `z-10`) for sticky cells. They only need to be above the regular table body cells (z-index: auto). The compare view has no interactive StatusBadge dropdowns (read-only in resolved mode; in active mode the compare view is navigational, not mutation-focused).
|
||||
**Warning signs:** Sticky column clips or obscures slide-out panels.
|
||||
|
||||
### Pitfall 6: Pros/Cons Rendered as Raw String
|
||||
**What goes wrong:** A candidate's pros appear as a single run-on text block with no formatting.
|
||||
**Why it happens:** `CandidateForm` stores pros/cons as newline-separated plain text. Plain JSX `{candidate.pros}` ignores newlines in HTML.
|
||||
**How to avoid:** Split on `"\n"`, filter empty strings, render as `<ul>/<li>`. Confirmed from `CandidateForm.tsx` textarea with "One pro per line..." placeholder.
|
||||
**Warning signs:** All pro/con items concatenated without separation.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from project source:
|
||||
|
||||
### Extending uiStore candidateViewMode
|
||||
```typescript
|
||||
// src/client/stores/uiStore.ts — lines 53-54 today read:
|
||||
// candidateViewMode: "list" | "grid";
|
||||
// setCandidateViewMode: (mode: "list" | "grid") => void;
|
||||
|
||||
// After change (only these two lines change in the interface):
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
|
||||
// Implementation at lines 112-113 — no change needed:
|
||||
candidateViewMode: "list",
|
||||
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
||||
```
|
||||
|
||||
### threadId.tsx integration point (current line 192)
|
||||
```tsx
|
||||
// Current conditional rendering at line 192:
|
||||
// ) : candidateViewMode === "list" ? (
|
||||
// <Reorder.Group ... /> or <div ... />
|
||||
// ) : (
|
||||
// <div className="grid ..."> (grid view)
|
||||
// )
|
||||
|
||||
// After change — add compare branch before list check:
|
||||
} : candidateViewMode === "compare" ? (
|
||||
<ComparisonTable
|
||||
candidates={displayItems}
|
||||
resolvedCandidateId={thread.resolvedCandidateId}
|
||||
/>
|
||||
) : candidateViewMode === "list" ? (
|
||||
// list rendering (unchanged)
|
||||
) : (
|
||||
// grid rendering (unchanged)
|
||||
)
|
||||
```
|
||||
|
||||
### Minimum viable ComparisonTable props interface
|
||||
```typescript
|
||||
// Reuse the CandidateWithCategory type from hooks/useThreads.ts
|
||||
interface ComparisonTableProps {
|
||||
candidates: CandidateWithCategory[]; // already typed in useThreads.ts
|
||||
resolvedCandidateId: number | null; // for winner column highlight
|
||||
}
|
||||
```
|
||||
|
||||
### Full delta computation with useMemo (null-safe)
|
||||
```typescript
|
||||
const { priceDeltas, bestPriceId } = useMemo(() => {
|
||||
const withPrice = candidates.filter((c) => c.priceCents != null);
|
||||
if (withPrice.length === 0) {
|
||||
return { priceDeltas: new Map<number, string | null>(), bestPriceId: null };
|
||||
}
|
||||
const minCents = Math.min(...withPrice.map((c) => c.priceCents as number));
|
||||
const bestPriceId = withPrice.find((c) => c.priceCents === minCents)!.id;
|
||||
const priceDeltas = new Map(
|
||||
candidates.map((c) => {
|
||||
if (c.priceCents == null) return [c.id, null]; // missing data
|
||||
const delta = c.priceCents - minCents;
|
||||
return [c.id, delta === 0 ? null : `+${formatPrice(delta, currency)}`];
|
||||
})
|
||||
);
|
||||
return { priceDeltas, bestPriceId };
|
||||
}, [candidates, currency]);
|
||||
```
|
||||
|
||||
### Best-cell highlight pattern (weight example)
|
||||
```tsx
|
||||
// Weight cell — bg-blue-50 for "lightest" (matches existing blue weight pills in CandidateListItem)
|
||||
function WeightCell({ candidate, delta, isBest, unit }: WeightCellProps) {
|
||||
return (
|
||||
<td className={`px-4 py-3 text-sm ${isBest ? "bg-blue-50" : ""}`}>
|
||||
{candidate.weightGrams != null ? (
|
||||
<>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatWeight(candidate.weightGrams, unit)}
|
||||
</span>
|
||||
{delta && (
|
||||
<span className="block text-xs text-gray-400 mt-0.5">{delta}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Note: CONTEXT.md uses "bg-green-50" as the example for lightest weight. Recommend aligning with existing project badge colors: lightest weight → `bg-blue-50` (consistent with blue weight pills), cheapest price → `bg-green-50` (consistent with green price pills). This is within Claude's discretion.
|
||||
|
||||
### Winner column pattern (resolved threads)
|
||||
```tsx
|
||||
// Column header for the winning candidate gets amber tint (matches resolution banner)
|
||||
<th
|
||||
key={c.id}
|
||||
className={`px-4 py-3 text-left text-xs font-medium min-w-[160px] ${
|
||||
c.id === resolvedCandidateId
|
||||
? "bg-amber-50 text-amber-800"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{c.id === resolvedCandidateId && (
|
||||
<LucideIcon name="trophy" size={12} className="text-amber-600" />
|
||||
)}
|
||||
{c.name}
|
||||
</div>
|
||||
</th>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | Notes |
|
||||
|--------------|------------------|-------|
|
||||
| Hand-rolled format functions | Reuse `formatWeight` / `formatPrice` with delta arithmetic | Project formatters already handle all units, currencies, and null |
|
||||
| `overflow-x: auto` on `<table>` | `overflow-x-auto` on wrapper `<div>` | CSS spec: overflow only applies to block containers |
|
||||
| JS-based sticky columns | CSS `position: sticky` with `left: 0` | 92%+ browser support, zero JS overhead |
|
||||
| Inline column rendering | Declarative row-definition array | Matches the locked attribute order, easy to maintain |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- Direct lucide icon component imports (e.g., `import { LayoutList } from "lucide-react"`): project uses `LucideIcon` helper uniformly — follow the same pattern.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
All questions resolved during research:
|
||||
|
||||
1. **Pros/cons storage format — RESOLVED**
|
||||
- `CandidateForm.tsx` uses a `<textarea>` with "One pro per line..." placeholder
|
||||
- Submit handler: `pros: form.pros.trim() || undefined` — empty string → not sent → stored as `null`
|
||||
- Non-empty content: raw multiline text stored as-is, newline-separated
|
||||
- **Action for planner:** Use `BulletCell` pattern (split on `\n`, render `<ul>/<li>`)
|
||||
|
||||
2. **`columns-3` icon availability — RESOLVED**
|
||||
- Verified: `Columns3` is present in lucide-react ^0.577.0 installed package
|
||||
- Use `<LucideIcon name="columns-3" size={16} />` — the LucideIcon helper converts to PascalCase
|
||||
- `table-2` is also present as a backup if needed
|
||||
|
||||
3. **"Add Candidate" button in compare mode — RECOMMENDATION**
|
||||
- Currently guarded by `{isActive && ...}` in `$threadId.tsx`
|
||||
- Recommendation: hide "Add Candidate" when `candidateViewMode === "compare"` (keep toolbar uncluttered; users switch to list/grid to add)
|
||||
- Implementation: add `&& candidateViewMode !== "compare"` to the existing `isActive` guard
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Bun test (built-in) |
|
||||
| Config file | None — uses `bun test` directly |
|
||||
| Quick run command | `bun test tests/lib/` |
|
||||
| Full suite command | `bun test` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| COMP-01 | Candidates display all required fields in table | manual-only (UI/browser) | — | N/A |
|
||||
| COMP-02 | Delta computation: null-safe, best candidate identified, zero-delta suppressed | unit (if extracted to util) | `bun test tests/lib/comparison-deltas.test.ts` | ❌ Wave 0 gap (optional) |
|
||||
| COMP-03 | Table scrolls horizontally / sticky label column stays fixed | manual-only (CSS/browser) | — | N/A |
|
||||
| COMP-04 | Resolved thread shows read-only view with winner marked; no zero for missing data | manual-only (UI state) | — | N/A |
|
||||
|
||||
**Note on testing scope:** COMP-01, COMP-03, COMP-04 are UI/browser behaviors. COMP-02 delta logic is pure arithmetic — testable if extracted to a standalone utility function. This is a pure frontend phase; the existing `bun test` suite covers backend services only and will not be broken by this phase.
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `bun test` (full suite, fast — no UI tests in suite)
|
||||
- **Per wave merge:** `bun test`
|
||||
- **Phase gate:** Full suite green + manual browser verification of scroll/sticky behavior on a narrow viewport
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `tests/lib/comparison-deltas.test.ts` — covers COMP-02 delta logic if extracted to a pure utility (optional; skip if deltas stay inlined in the React component)
|
||||
|
||||
*(If delta computation stays in the React component via `useMemo`, no new test files are needed — COMP-02 is verified manually in the browser.)*
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Project codebase direct inspection:
|
||||
- `src/client/stores/uiStore.ts` — confirmed `candidateViewMode` type and setter
|
||||
- `src/client/routes/threads/$threadId.tsx` — confirmed integration points, toggle bar pattern, lines to modify
|
||||
- `src/client/components/CandidateListItem.tsx` — confirmed `RankBadge` export, `CandidateWithCategory` interface
|
||||
- `src/client/components/CandidateCard.tsx` — confirmed field usage patterns
|
||||
- `src/client/components/CandidateForm.tsx` — confirmed pros/cons are newline-separated textarea input; empty = null
|
||||
- `src/client/lib/formatters.ts` — confirmed null handling, `"--"` return for null
|
||||
- `src/client/hooks/useThreads.ts` — confirmed `CandidateWithCategory` shape with all fields needed
|
||||
- `package.json` — confirmed no new dependencies needed
|
||||
- Runtime verification: `Columns3` in lucide-react ^0.577.0 confirmed present via node script
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Tailwind CSS overflow docs](https://tailwindcss.com/docs/overflow) — `overflow-x-auto` on wrapper div pattern
|
||||
- [Tailwind CSS position docs](https://tailwindcss.com/docs/position) — sticky utility, z-index behavior
|
||||
- [Lexington Themes — scrollable sticky header table](https://lexingtonthemes.com/blog/how-to-build-a-scrollable-table-with-sticky-header-using-tailwind-css) — confirmed sticky thead pattern with Tailwind
|
||||
|
||||
### Tertiary (LOW confidence — WebSearch, not verified against official spec)
|
||||
- [Multi-Directional Sticky CSS (Medium, Jan 2026)](https://medium.com/@ashutoshgautam10b11/multi-directional-sticky-css-and-horizontal-scroll-in-tables-41fc25c3ce8b) — z-index layering reference; this phase only needs one sticky axis (left column, z-10 suffices)
|
||||
- [DEV Community — sticky frozen column](https://dev.to/nicolaserny/table-with-a-fixed-first-column-2c5b) — background color requirement for sticky cells confirmed
|
||||
- [freeCodeCamp Forum — overflow + sticky](https://forum.freecodecamp.org/t/fixing-sticky-table-header-with-horizontal-scroll-in-a-scrollable-container/735559) — overflow-x-auto must be on wrapper div, not table element
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — all dependencies confirmed present in package.json; no new packages
|
||||
- Architecture: HIGH — directly derived from reading all relevant project source files
|
||||
- Pitfalls: HIGH — sticky bg issue confirmed by multiple sources; overflow-on-table confirmed by CSS spec; pros/cons newline format confirmed from CandidateForm source
|
||||
- Delta computation: HIGH — pure arithmetic, formatters already handle null, confirmed return values
|
||||
|
||||
**Research date:** 2026-03-17
|
||||
**Valid until:** 2026-04-17 (stable CSS, stable React, stable project codebase)
|
||||
Reference in New Issue
Block a user