542 lines
29 KiB
Markdown
542 lines
29 KiB
Markdown
# 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)
|