Compare commits
13 Commits
50672cb662
...
Develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 32d6babf24 | |||
| 6fe029f531 | |||
| 725901623b | |||
| a826381981 | |||
| 79d84f1333 | |||
| 798bd51597 | |||
| 14a4c65b94 | |||
| 53c2bd1614 | |||
| 5b4026d36f | |||
| e442b33a59 | |||
| b090da05fa | |||
| bb8fb0a323 | |||
| 918282ff9d |
@@ -9,10 +9,10 @@ Requirements for this milestone. Each maps to roadmap phases.
|
|||||||
|
|
||||||
### Comparison View
|
### Comparison View
|
||||||
|
|
||||||
- [ ] **COMP-01**: User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status)
|
- [x] **COMP-01**: User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status)
|
||||||
- [ ] **COMP-02**: User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences
|
- [x] **COMP-02**: User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences
|
||||||
- [ ] **COMP-03**: Comparison table scrolls horizontally with a sticky label column on narrow viewports
|
- [x] **COMP-03**: Comparison table scrolls horizontally with a sticky label column on narrow viewports
|
||||||
- [ ] **COMP-04**: Comparison view displays read-only summary for resolved threads
|
- [x] **COMP-04**: Comparison view displays read-only summary for resolved threads
|
||||||
|
|
||||||
### Candidate Ranking
|
### Candidate Ranking
|
||||||
|
|
||||||
@@ -68,10 +68,10 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||||||
|
|
||||||
| Requirement | Phase | Status |
|
| Requirement | Phase | Status |
|
||||||
|-------------|-------|--------|
|
|-------------|-------|--------|
|
||||||
| COMP-01 | Phase 12 | Pending |
|
| COMP-01 | Phase 12 | Complete |
|
||||||
| COMP-02 | Phase 12 | Pending |
|
| COMP-02 | Phase 12 | Complete |
|
||||||
| COMP-03 | Phase 12 | Pending |
|
| COMP-03 | Phase 12 | Complete |
|
||||||
| COMP-04 | Phase 12 | Pending |
|
| COMP-04 | Phase 12 | Complete |
|
||||||
| RANK-01 | Phase 11 | Complete |
|
| RANK-01 | Phase 11 | Complete |
|
||||||
| RANK-02 | Phase 11 | Complete |
|
| RANK-02 | Phase 11 | Complete |
|
||||||
| RANK-03 | Phase 10 | Complete |
|
| RANK-03 | Phase 10 | Complete |
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
- [x] **Phase 10: Schema Foundation + Pros/Cons Fields** — Migrate schema and deliver pros/cons annotation UI (completed 2026-03-16)
|
- [x] **Phase 10: Schema Foundation + Pros/Cons Fields** — Migrate schema and deliver pros/cons annotation UI (completed 2026-03-16)
|
||||||
- [x] **Phase 11: Candidate Ranking** — Drag-to-reorder priority ranking with rank badges (completed 2026-03-16)
|
- [x] **Phase 11: Candidate Ranking** — Drag-to-reorder priority ranking with rank badges (completed 2026-03-16)
|
||||||
- [ ] **Phase 12: Comparison View** — Side-by-side tabular comparison with relative deltas
|
- [x] **Phase 12: Comparison View** — Side-by-side tabular comparison with relative deltas (completed 2026-03-17)
|
||||||
- [ ] **Phase 13: Setup Impact Preview** — Per-candidate weight and cost delta against a selected setup
|
- [ ] **Phase 13: Setup Impact Preview** — Per-candidate weight and cost delta against a selected setup
|
||||||
|
|
||||||
## Phase Details
|
## Phase Details
|
||||||
@@ -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
|
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
|
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
|
4. A resolved thread shows the comparison table in read-only mode with the winning candidate visually marked
|
||||||
**Plans**: TBD
|
**Plans:** 1/1 plans complete
|
||||||
|
Plans:
|
||||||
|
- [ ] 12-01-PLAN.md — ComparisonTable component + compare toggle wiring in thread detail
|
||||||
|
|
||||||
### Phase 13: Setup Impact Preview
|
### 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
|
**Goal**: Users can select any setup and see exactly how much weight and cost each candidate would add or subtract
|
||||||
@@ -94,7 +96,10 @@ Plans:
|
|||||||
2. When the selected setup contains an item in the same category as the thread, the delta reflects replacing that item (negative delta is possible) rather than pure addition
|
2. When the selected setup contains an item in the same category as the thread, the delta reflects replacing that item (negative delta is possible) rather than pure addition
|
||||||
3. When no category match exists in the selected setup, the delta shows a pure addition amount clearly labeled as "add"
|
3. When no category match exists in the selected setup, the delta shows a pure addition amount clearly labeled as "add"
|
||||||
4. A candidate with no weight recorded shows a "-- (no weight data)" indicator instead of a zero delta
|
4. A candidate with no weight recorded shows a "-- (no weight data)" indicator instead of a zero delta
|
||||||
**Plans**: TBD
|
**Plans:** 2 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 13-01-PLAN.md — TDD pure impact delta computation, uiStore state, ThreadWithCandidates type fix, useImpactDeltas hook
|
||||||
|
- [ ] 13-02-PLAN.md — SetupImpactSelector + ImpactDeltaBadge components, wire into thread detail and all candidate views
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -111,5 +116,5 @@ Plans:
|
|||||||
| 9. Weight Classification and Visualization | v1.2 | 2/2 | Complete | 2026-03-16 |
|
| 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 |
|
| 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 |
|
||||||
| 11. Candidate Ranking | 2/2 | 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 | 1/1 | Complete | 2026-03-17 | - |
|
||||||
| 13. Setup Impact Preview | v1.3 | 0/TBD | Not started | - |
|
| 13. Setup Impact Preview | v1.3 | 0/2 | Not started | - |
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
|||||||
milestone: v1.3
|
milestone: v1.3
|
||||||
milestone_name: Research & Decision Tools
|
milestone_name: Research & Decision Tools
|
||||||
status: planning
|
status: planning
|
||||||
stopped_at: Completed 11-candidate-ranking/11-02-PLAN.md
|
stopped_at: Completed 12-comparison-view/12-01-PLAN.md
|
||||||
last_updated: "2026-03-16T21:39:11.967Z"
|
last_updated: "2026-03-17T14:35:39.075Z"
|
||||||
last_activity: 2026-03-16 — Roadmap created for v1.3 milestone
|
last_activity: 2026-03-16 — Roadmap created for v1.3 milestone
|
||||||
progress:
|
progress:
|
||||||
total_phases: 4
|
total_phases: 4
|
||||||
completed_phases: 2
|
completed_phases: 3
|
||||||
total_plans: 3
|
total_plans: 4
|
||||||
completed_plans: 3
|
completed_plans: 4
|
||||||
percent: 0
|
percent: 0
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ Progress: [░░░░░░░░░░] 0%
|
|||||||
| Phase 10-schema-foundation-pros-cons-fields P01 | 6min | 2 tasks | 9 files |
|
| Phase 10-schema-foundation-pros-cons-fields P01 | 6min | 2 tasks | 9 files |
|
||||||
| Phase 11-candidate-ranking P01 | 4min | 2 tasks | 8 files |
|
| Phase 11-candidate-ranking P01 | 4min | 2 tasks | 8 files |
|
||||||
| Phase 11-candidate-ranking P02 | 4min | 3 tasks | 7 files |
|
| Phase 11-candidate-ranking P02 | 4min | 3 tasks | 7 files |
|
||||||
|
| Phase 12-comparison-view P01 | 2min | 2 tasks | 3 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
@@ -72,6 +73,9 @@ Key v1.3 research findings (see research/SUMMARY.md):
|
|||||||
- [Phase 11-candidate-ranking]: Applied sort_order migration via sqlite3 CLI directly to avoid Drizzle data-loss warning on existing rows
|
- [Phase 11-candidate-ranking]: Applied sort_order migration via sqlite3 CLI directly to avoid Drizzle data-loss warning on existing rows
|
||||||
- [Phase 11-candidate-ranking]: Resolved thread list view uses plain div (not Reorder.Group) — no drag, rank badges visible
|
- [Phase 11-candidate-ranking]: Resolved thread list view uses plain div (not Reorder.Group) — no drag, rank badges visible
|
||||||
- [Phase 11-candidate-ranking]: RankBadge exported from CandidateListItem for reuse in CandidateCard grid view
|
- [Phase 11-candidate-ranking]: RankBadge exported from CandidateListItem for reuse in CandidateCard grid view
|
||||||
|
- [Phase 12-comparison-view]: ATTRIBUTE_ROWS declarative array pattern for ComparisonTable keeps JSX clean and row reordering trivial
|
||||||
|
- [Phase 12-comparison-view]: Compare toggle only shown for 2+ candidates; Add Candidate hidden in compare view (read-only intent)
|
||||||
|
- [Phase 12-comparison-view]: Weight/price highlight color takes priority over amber winner tint when both apply (more informative)
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
@@ -83,6 +87,6 @@ None active.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-16T21:30:15.459Z
|
Last session: 2026-03-17T14:32:04.702Z
|
||||||
Stopped at: Completed 11-candidate-ranking/11-02-PLAN.md
|
Stopped at: Completed 12-comparison-view/12-01-PLAN.md
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
|||||||
321
.planning/phases/12-comparison-view/12-01-PLAN.md
Normal file
321
.planning/phases/12-comparison-view/12-01-PLAN.md
Normal file
@@ -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"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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`).
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<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
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Build ComparisonTable component</name>
|
||||||
|
<files>src/client/components/ComparisonTable.tsx</files>
|
||||||
|
<action>
|
||||||
|
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: `<div className="overflow-x-auto rounded-xl border border-gray-100">` (scroll wrapper)
|
||||||
|
- Inner: `<table>` with `style={{ minWidth: Math.max(400, candidates.length * 180) + 'px' }}` and `className="border-collapse text-sm w-full"`
|
||||||
|
- `<thead>`: One `<tr>` with sticky corner `<th>` (empty, for label column) + one `<th>` per candidate showing name. If `candidate.id === resolvedCandidateId`, apply `bg-amber-50 text-amber-800` and 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}" />` with `object-cover`. Else render `<LucideIcon name={categoryIcon} size={20} className="text-gray-400" />` in a `bg-gray-50` placeholder. Use `w-12 h-12` sizing.
|
||||||
|
- **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 in `font-medium text-gray-900`. If this is the best (`isBest`), apply `bg-blue-50` to the `<td>`. If delta string exists (not null, not best), show delta below in `text-xs text-gray-400`. If `weightGrams` is null, show `<span className="text-gray-300">—</span>`.
|
||||||
|
- **Price**: Same pattern as weight but with `formatPrice(priceCents, currency)` and `bg-green-50` for 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 `productUrl` exists, render a clickable link that calls `openExternalLink(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 `notes` exists, 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 `pros` exists, 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>` gets `border-b border-gray-50` for 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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>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).</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Wire compare toggle and ComparisonTable into thread detail</name>
|
||||||
|
<files>src/client/stores/uiStore.ts, src/client/routes/threads/$threadId.tsx</files>
|
||||||
|
<action>
|
||||||
|
**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 `<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:
|
||||||
|
```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>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
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" ? (
|
||||||
|
<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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20 && bun test 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>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.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
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
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/12-comparison-view/12-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
110
.planning/phases/12-comparison-view/12-01-SUMMARY.md
Normal file
110
.planning/phases/12-comparison-view/12-01-SUMMARY.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
phase: 12-comparison-view
|
||||||
|
plan: "01"
|
||||||
|
subsystem: ui
|
||||||
|
tags: [react, tailwind, comparison-table, zustand, framer-motion]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 11-candidate-ranking
|
||||||
|
provides: RankBadge component and sort_order-based candidate ordering
|
||||||
|
- phase: 10-schema-foundation-pros-cons-fields
|
||||||
|
provides: pros/cons fields on CandidateWithCategory type
|
||||||
|
provides:
|
||||||
|
- ComparisonTable component with sticky label column and horizontal scroll
|
||||||
|
- candidateViewMode "compare" value in uiStore
|
||||||
|
- Compare toggle in thread detail toolbar (visible when 2+ candidates)
|
||||||
|
- Weight/price delta highlighting with best-cell color coding
|
||||||
|
- Resolved thread winner column marking (amber tint + trophy)
|
||||||
|
affects: [future comparison features, thread detail enhancements]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- Declarative ATTRIBUTE_ROWS array pattern for table row rendering (key, label, render, cellClass)
|
||||||
|
- useMemo delta computation for best-cell identification in comparison views
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- src/client/components/ComparisonTable.tsx
|
||||||
|
modified:
|
||||||
|
- src/client/stores/uiStore.ts
|
||||||
|
- src/client/routes/threads/$threadId.tsx
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "ATTRIBUTE_ROWS declarative array pattern keeps JSX clean and row reordering trivial"
|
||||||
|
- "cellClass function pattern in ATTRIBUTE_ROWS allows per-row cell styling without duplicating winner-check logic in every render"
|
||||||
|
- "Compare toggle only shown when >= 2 candidates (locked decision from plan)"
|
||||||
|
- "Add Candidate button hidden in compare view — compare is for reading, not mutation"
|
||||||
|
- "Winner highlight priority: weight/price color wins over amber tint when both apply (more informative)"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Declarative table row config: ATTRIBUTE_ROWS array with { key, label, render, cellClass } objects"
|
||||||
|
- "Sticky left column pattern: sticky left-0 z-10 bg-white on every label cell for scroll bleed prevention"
|
||||||
|
|
||||||
|
requirements-completed: [COMP-01, COMP-02, COMP-03, COMP-04]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 2min
|
||||||
|
completed: 2026-03-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 12 Plan 01: Comparison View Summary
|
||||||
|
|
||||||
|
**Side-by-side candidate comparison table with sticky labels, weight/price delta highlighting, and resolved-thread winner marking via a new "compare" candidateViewMode**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 2 min
|
||||||
|
- **Started:** 2026-03-17T14:28:12Z
|
||||||
|
- **Completed:** 2026-03-17T14:30:00Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 3
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- Built ComparisonTable component with 10 attribute rows (Image, Name, Rank, Weight, Price, Status, Link, Notes, Pros, Cons) using declarative ATTRIBUTE_ROWS pattern
|
||||||
|
- Implemented useMemo delta computation — lightest weight cell highlighted blue-50, cheapest price cell green-50, non-best cells show gray +delta string
|
||||||
|
- Sticky left column with bg-white prevents content bleed-through on horizontal scroll
|
||||||
|
- Amber tint + trophy icon on winner column in resolved threads; weight/price color takes priority when winner is also best
|
||||||
|
- Extended uiStore candidateViewMode to "list" | "grid" | "compare" and wired compare toggle in thread detail toolbar
|
||||||
|
- Compare toggle only appears when thread has 2+ candidates; Add Candidate button hidden in compare view
|
||||||
|
- All 135 existing tests pass, no regressions
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Build ComparisonTable component** - `e442b33` (feat)
|
||||||
|
2. **Task 2: Wire compare toggle and ComparisonTable into thread detail** - `5b4026d` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `src/client/components/ComparisonTable.tsx` - New comparison table component with all 10 attribute rows, delta computation, sticky labels, and winner highlighting
|
||||||
|
- `src/client/stores/uiStore.ts` - Extended candidateViewMode union to include "compare"
|
||||||
|
- `src/client/routes/threads/$threadId.tsx` - Added ComparisonTable import, compare toggle button (columns-3 icon), ComparisonTable rendering branch, and "Add Candidate" hidden in compare view
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- ATTRIBUTE_ROWS declarative array pattern keeps table JSX clean and row reordering trivial — each row is just { key, label, render, cellClass }
|
||||||
|
- cellClass function in ATTRIBUTE_ROWS allows per-row cell styling without duplicating winner-check logic in every render function
|
||||||
|
- Compare toggle only shown for 2+ candidates per locked plan decision
|
||||||
|
- Add Candidate button hidden in compare view to keep toolbar uncluttered (users switch to list/grid to add)
|
||||||
|
- When winner IS also the lightest/cheapest, weight/price color (blue/green) takes priority over amber tint — more informative
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
- Minor formatting differences caught by Biome auto-formatter (indentation depth in conditional JSX) — resolved with `biome check --write`. No logic changes.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- ComparisonTable is complete and functional; compare mode wired end-to-end in thread detail
|
||||||
|
- No blockers — ready for any follow-on comparison view enhancements
|
||||||
|
- All existing tests pass; no backend changes needed
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 12-comparison-view*
|
||||||
|
*Completed: 2026-03-17*
|
||||||
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)
|
||||||
78
.planning/phases/12-comparison-view/12-VALIDATION.md
Normal file
78
.planning/phases/12-comparison-view/12-VALIDATION.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
phase: 12
|
||||||
|
slug: comparison-view
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-03-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 12 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | Bun test (built-in) |
|
||||||
|
| **Config file** | None — uses `bun test` directly |
|
||||||
|
| **Quick run command** | `bun test` |
|
||||||
|
| **Full suite command** | `bun test` |
|
||||||
|
| **Estimated runtime** | ~5 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `bun test`
|
||||||
|
- **After every plan wave:** Run `bun test`
|
||||||
|
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 5 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 12-01-01 | 01 | 1 | COMP-01 | manual-only (UI/browser) | — | N/A | ⬜ pending |
|
||||||
|
| 12-01-02 | 01 | 1 | COMP-02 | unit (if delta util extracted) | `bun test tests/lib/comparison-deltas.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 12-01-03 | 01 | 1 | COMP-03 | manual-only (CSS/browser) | — | N/A | ⬜ pending |
|
||||||
|
| 12-01-04 | 01 | 1 | COMP-04 | manual-only (UI state) | — | N/A | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] `tests/lib/comparison-deltas.test.ts` — stubs for COMP-02 delta computation (optional; skip if deltas stay inlined in React component via useMemo)
|
||||||
|
|
||||||
|
*Existing `bun test` infrastructure covers all backend services. This phase is pure frontend — no backend tests are broken.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Candidates display all fields in tabular columns | COMP-01 | UI rendering — no backend logic | Open thread with 2+ candidates, toggle compare view, verify all fields visible |
|
||||||
|
| Table scrolls horizontally with sticky label column | COMP-03 | CSS behavior — requires browser | Narrow viewport to <768px, verify horizontal scroll, verify label column stays fixed |
|
||||||
|
| Resolved thread shows read-only view with winner marked | COMP-04 | UI state — requires resolved thread | Open resolved thread, toggle compare view, verify winner column highlighted, no interactive elements |
|
||||||
|
| Missing weight/price shows dash, not zero | COMP-04 | UI rendering for null data | Add candidate with no weight, toggle compare, verify "—" not "0g" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 5s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
95
.planning/phases/12-comparison-view/12-VERIFICATION.md
Normal file
95
.planning/phases/12-comparison-view/12-VERIFICATION.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
phase: 12-comparison-view
|
||||||
|
verified: 2026-03-17T00:00:00Z
|
||||||
|
status: passed
|
||||||
|
score: 7/7 must-haves verified
|
||||||
|
re_verification: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 12: Comparison View Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Users can view all candidates for a thread side-by-side in a table with relative weight and price deltas
|
||||||
|
**Verified:** 2026-03-17
|
||||||
|
**Status:** passed
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | User can toggle to a Compare view when a thread has 2+ candidates | VERIFIED | `$threadId.tsx:172` — compare button wrapped in `{thread.candidates.length >= 2 && (...)}`; clicking calls `setCandidateViewMode("compare")` |
|
||||||
|
| 2 | Comparison table shows all candidates side-by-side with weight, price, images, notes, links, status, pros, and cons | VERIFIED | `ComparisonTable.tsx:104-269` — ATTRIBUTE_ROWS array defines all 10 rows: Image, Name, Rank, Weight, Price, Status, Link, Notes, Pros, Cons |
|
||||||
|
| 3 | The lightest candidate weight cell has a blue highlight; the cheapest candidate price cell has a green highlight | VERIFIED | `ComparisonTable.tsx:167` — `cellClass` returns `"bg-blue-50"` when `c.id === bestWeightId`; `ComparisonTable.tsx:193` — `"bg-green-50"` when `c.id === bestPriceId` |
|
||||||
|
| 4 | Non-best cells show a gray +delta string; best cells show no delta | VERIFIED | `ComparisonTable.tsx:64-66` — delta stored as `null` when `delta === 0` (best), else `+${formatWeight(delta, unit)}`; `ComparisonTable.tsx:160-162` — delta div only rendered when `!isBest && delta` |
|
||||||
|
| 5 | The table scrolls horizontally on narrow viewports while the attribute label column stays fixed on the left | VERIFIED | `ComparisonTable.tsx:274` — outer `<div className="overflow-x-auto ...">` for scroll; `ComparisonTable.tsx:282,311` — every label cell has `sticky left-0 z-10 bg-white` |
|
||||||
|
| 6 | Missing weight or price data displays a dash, never a misleading zero | VERIFIED | `ComparisonTable.tsx:152-153` — `if (c.weightGrams == null) return <span className="text-gray-300">—</span>`; `ComparisonTable.tsx:178-179` — same pattern for price |
|
||||||
|
| 7 | A resolved thread shows the comparison read-only with the winner column visually marked (amber tint + trophy) | VERIFIED | `ComparisonTable.tsx:284-301` — winner `<th>` gets `bg-amber-50 text-amber-800` + trophy icon; body cells get `bg-amber-50/50` tint via default `extraClass` branch at line 318-320; no mutation controls exist inside ComparisonTable |
|
||||||
|
|
||||||
|
**Score:** 7/7 truths verified
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `src/client/components/ComparisonTable.tsx` | Tabular side-by-side comparison component; min 120 lines | VERIFIED | 336 lines; full ATTRIBUTE_ROWS declarative pattern, useMemo deltas, sticky column, winner highlighting |
|
||||||
|
| `src/client/stores/uiStore.ts` | Extended candidateViewMode union including "compare" | VERIFIED | Line 53: `candidateViewMode: "list" \| "grid" \| "compare"` and line 54: `setCandidateViewMode: (mode: "list" \| "grid" \| "compare") => void` |
|
||||||
|
| `src/client/routes/threads/$threadId.tsx` | Compare toggle button and ComparisonTable rendering branch | VERIFIED | Line 6 imports ComparisonTable; line 172-185 conditionally renders compare button; line 207-211 renders `<ComparisonTable>` when `candidateViewMode === "compare"` |
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `$threadId.tsx` | `ComparisonTable.tsx` | import + conditional render when `candidateViewMode === "compare"` | WIRED | Line 6 imports; line 207 `candidateViewMode === "compare"` branch renders `<ComparisonTable candidates={displayItems} resolvedCandidateId={thread.resolvedCandidateId} />` |
|
||||||
|
| `ComparisonTable.tsx` | `lib/formatters.ts` | `formatWeight` and `formatPrice` for cell values and delta strings | WIRED | Line 4 imports both; used at lines 66, 92, 158, 184 for both display values and delta string construction |
|
||||||
|
| `ComparisonTable.tsx` | `CandidateListItem.tsx` | `RankBadge` import for rank row | WIRED | Line 7 imports `RankBadge`; used at line 144 in the rank ATTRIBUTE_ROW render function |
|
||||||
|
| `$threadId.tsx` | `uiStore.ts` | `candidateViewMode` state read and `setCandidateViewMode` action | WIRED | Lines 24-25 read both from `useUIStore`; `candidateViewMode` read at lines 124, 152, 164, 177, 207, 212; `setCandidateViewMode` called at lines 150, 163, 175 |
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|-------------|-------------|--------|----------|
|
||||||
|
| COMP-01 | 12-01-PLAN.md | User can view candidates side-by-side in a tabular comparison layout (weight, price, images, notes, links, status) | SATISFIED | ComparisonTable renders all fields as columns; toggle wired in $threadId.tsx |
|
||||||
|
| COMP-02 | 12-01-PLAN.md | User can see relative deltas highlighting the lightest and cheapest candidate with +/- differences | SATISFIED | useMemo delta computation at lines 47-102; blue-50/green-50 highlights; gray delta text for non-best cells |
|
||||||
|
| COMP-03 | 12-01-PLAN.md | Comparison table scrolls horizontally with a sticky label column on narrow viewports | SATISFIED | `overflow-x-auto` on wrapper; `sticky left-0 z-10 bg-white` on all label cells; `minWidth` computed from candidate count |
|
||||||
|
| COMP-04 | 12-01-PLAN.md | Comparison view displays read-only summary for resolved threads | SATISFIED | No mutation actions in ComparisonTable; winner column amber-marked with trophy; `resolvedCandidateId` prop drives the read-only winner state |
|
||||||
|
|
||||||
|
No orphaned requirements found. REQUIREMENTS.md maps COMP-01 through COMP-04 exclusively to Phase 12, all are accounted for by plan 12-01.
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
| File | Line | Pattern | Severity | Impact |
|
||||||
|
|------|------|---------|----------|--------|
|
||||||
|
| — | — | None found | — | — |
|
||||||
|
|
||||||
|
No TODO/FIXME/placeholder comments, empty implementations, or stub returns found in any of the three phase 12 files. Biome lint passes cleanly on all three files (only pre-existing unrelated issues in other src files; none in phase 12 files).
|
||||||
|
|
||||||
|
### Human Verification Required
|
||||||
|
|
||||||
|
#### 1. Horizontal scroll with sticky label column
|
||||||
|
|
||||||
|
**Test:** Open a thread with 3+ candidates on a narrow viewport (< 600px). Scroll the table right.
|
||||||
|
**Expected:** Candidate columns scroll off-screen left; the attribute label column (Image, Name, Rank, etc.) remains fixed at the left edge without content bleed-through.
|
||||||
|
**Why human:** CSS `sticky` behavior with `overflow-x-auto` interactions cannot be asserted by grep; only visual browser confirmation validates the `bg-white` bleed-through prevention.
|
||||||
|
|
||||||
|
#### 2. Winner column amber tint + trophy on resolved thread
|
||||||
|
|
||||||
|
**Test:** Navigate to a thread that has been resolved. Switch to compare view.
|
||||||
|
**Expected:** The winning candidate's column header shows a trophy icon and amber background; every row of that column has a subtle amber-50/50 tint. Weight/price highlight colors (blue/green) take priority over amber when the winner is also the lightest/cheapest.
|
||||||
|
**Why human:** Color layering and opacity compositing require visual verification.
|
||||||
|
|
||||||
|
#### 3. Delta display with mixed null/non-null data
|
||||||
|
|
||||||
|
**Test:** Add two candidates to a thread where one has weight data and the other does not. Switch to compare view.
|
||||||
|
**Expected:** The candidate with no weight shows an em dash in the weight row (not 0g). The one with weight shows its value with no delta label (it is trivially the best). No misleading zero appears.
|
||||||
|
**Why human:** Edge-case rendering for the null path requires runtime React state to confirm the `formatWeight(null)` → `"--"` path is reached and displayed as `—` (em dash span), not the `"--"` string fallback from formatters.
|
||||||
|
|
||||||
|
### Gaps Summary
|
||||||
|
|
||||||
|
No gaps found. All seven observable truths are verified, all three artifacts exist and are substantive, all four key links are wired end-to-end, all four COMP requirements are satisfied by traceable code, lint passes on all phase files, and 135 tests pass with zero regressions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-03-17_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
253
.planning/phases/13-setup-impact-preview/13-01-PLAN.md
Normal file
253
.planning/phases/13-setup-impact-preview/13-01-PLAN.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
---
|
||||||
|
phase: 13-setup-impact-preview
|
||||||
|
plan: 01
|
||||||
|
type: tdd
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/client/lib/impactDeltas.ts
|
||||||
|
- src/client/hooks/useImpactDeltas.ts
|
||||||
|
- src/client/hooks/useThreads.ts
|
||||||
|
- src/client/stores/uiStore.ts
|
||||||
|
- tests/lib/impactDeltas.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [IMPC-01, IMPC-02, IMPC-03, IMPC-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "computeImpactDeltas returns replace-mode deltas when a setup item matches the thread category"
|
||||||
|
- "computeImpactDeltas returns add-mode deltas (candidate value only) when no category match exists"
|
||||||
|
- "computeImpactDeltas returns null weightDelta/priceDelta when candidate has null weight/price"
|
||||||
|
- "computeImpactDeltas returns mode 'none' with empty deltas when setupItems is undefined"
|
||||||
|
- "selectedSetupId state persists in uiStore and can be set/cleared"
|
||||||
|
- "ThreadWithCandidates interface includes categoryId field"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/client/lib/impactDeltas.ts"
|
||||||
|
provides: "Pure computeImpactDeltas function with CandidateDelta, ImpactDeltas types"
|
||||||
|
exports: ["computeImpactDeltas", "CandidateInput", "CandidateDelta", "DeltaMode", "ImpactDeltas"]
|
||||||
|
- path: "src/client/hooks/useImpactDeltas.ts"
|
||||||
|
provides: "React hook wrapping computeImpactDeltas in useMemo"
|
||||||
|
exports: ["useImpactDeltas"]
|
||||||
|
- path: "tests/lib/impactDeltas.test.ts"
|
||||||
|
provides: "Unit tests for all four IMPC requirements"
|
||||||
|
contains: "computeImpactDeltas"
|
||||||
|
key_links:
|
||||||
|
- from: "src/client/hooks/useImpactDeltas.ts"
|
||||||
|
to: "src/client/lib/impactDeltas.ts"
|
||||||
|
via: "import computeImpactDeltas"
|
||||||
|
pattern: "import.*computeImpactDeltas.*from.*lib/impactDeltas"
|
||||||
|
- from: "src/client/hooks/useImpactDeltas.ts"
|
||||||
|
to: "src/client/hooks/useSetups.ts"
|
||||||
|
via: "SetupItemWithCategory type import"
|
||||||
|
pattern: "import.*SetupItemWithCategory.*from.*useSetups"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the pure impact delta computation logic with full TDD coverage, add selectedSetupId to uiStore, fix the ThreadWithCandidates type to include categoryId, and wrap it all in a useImpactDeltas hook.
|
||||||
|
|
||||||
|
Purpose: Establish the data layer and contracts that Plan 02 will consume for rendering delta indicators. TDD ensures the replace/add mode logic and null-weight handling are correct before any UI work.
|
||||||
|
Output: Tested pure function, React hook, updated types, uiStore state.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/13-setup-impact-preview/13-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
|
||||||
|
From src/client/hooks/useSetups.ts:
|
||||||
|
```typescript
|
||||||
|
interface SetupItemWithCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
categoryId: number;
|
||||||
|
notes: string | null;
|
||||||
|
productUrl: string | null;
|
||||||
|
imageFilename: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
categoryName: string;
|
||||||
|
categoryIcon: string;
|
||||||
|
classification: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetup(setupId: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["setups", setupId],
|
||||||
|
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
||||||
|
enabled: setupId != null, // CRITICAL: prevents null fetch
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useThreads.ts (BEFORE fix):
|
||||||
|
```typescript
|
||||||
|
interface ThreadWithCandidates {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: "active" | "resolved";
|
||||||
|
resolvedCandidateId: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
candidates: CandidateWithCategory[];
|
||||||
|
// NOTE: categoryId is MISSING — server returns it but type omits it
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/stores/uiStore.ts (existing pattern):
|
||||||
|
```typescript
|
||||||
|
candidateViewMode: "list" | "grid" | "compare";
|
||||||
|
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||||
|
// Add selectedSetupId + setter using same pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/lib/formatters.ts:
|
||||||
|
```typescript
|
||||||
|
export type WeightUnit = "g" | "oz" | "lb" | "kg";
|
||||||
|
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string;
|
||||||
|
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
|
||||||
|
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string;
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<feature>
|
||||||
|
<name>Impact Delta Computation</name>
|
||||||
|
<files>src/client/lib/impactDeltas.ts, tests/lib/impactDeltas.test.ts, src/client/hooks/useImpactDeltas.ts, src/client/hooks/useThreads.ts, src/client/stores/uiStore.ts</files>
|
||||||
|
<behavior>
|
||||||
|
IMPC-01 (setup selected, deltas computed):
|
||||||
|
- Given candidates with weight/price and a setup with items, returns per-candidate delta objects with weightDelta and priceDelta numbers
|
||||||
|
- Given no setup selected (setupItems = undefined), returns { mode: "none", deltas: {} }
|
||||||
|
|
||||||
|
IMPC-02 (replace mode auto-detection):
|
||||||
|
- Given setup items where one has categoryId === threadCategoryId, mode is "replace"
|
||||||
|
- In replace mode, weightDelta = candidate.weightGrams - replacedItem.weightGrams
|
||||||
|
- In replace mode, priceDelta = candidate.priceCents - replacedItem.priceCents
|
||||||
|
- replacedItemName is populated with the matched item's name
|
||||||
|
|
||||||
|
IMPC-03 (add mode):
|
||||||
|
- Given setup items where NONE have categoryId === threadCategoryId, mode is "add"
|
||||||
|
- In add mode, weightDelta = candidate.weightGrams (pure addition)
|
||||||
|
- In add mode, priceDelta = candidate.priceCents (pure addition)
|
||||||
|
- replacedItemName is null
|
||||||
|
|
||||||
|
IMPC-04 (null weight handling):
|
||||||
|
- Given candidate.weightGrams is null, weightDelta is null (not 0, not NaN)
|
||||||
|
- Given candidate.priceCents is null, priceDelta is null
|
||||||
|
- In replace mode with replacedItem.weightGrams null but candidate has weight, weightDelta = candidate.weightGrams (treat as add for that field)
|
||||||
|
|
||||||
|
Edge cases:
|
||||||
|
- Empty candidates array -> returns { mode based on setup, deltas: {} }
|
||||||
|
- Multiple setup items in same category as thread -> first match used for replacement
|
||||||
|
</behavior>
|
||||||
|
<implementation>
|
||||||
|
1. Create src/client/lib/impactDeltas.ts with:
|
||||||
|
- CandidateInput interface: { id: number; weightGrams: number | null; priceCents: number | null }
|
||||||
|
- DeltaMode type: "replace" | "add" | "none"
|
||||||
|
- CandidateDelta interface: { candidateId, mode, weightDelta, priceDelta, replacedItemName }
|
||||||
|
- ImpactDeltas interface: { mode: DeltaMode; deltas: Record<number, CandidateDelta> }
|
||||||
|
- SetupItemInput interface: { categoryId: number; weightGrams: number | null; priceCents: number | null; name: string } (minimal subset of SetupItemWithCategory)
|
||||||
|
- computeImpactDeltas(candidates, setupItems, threadCategoryId) pure function
|
||||||
|
|
||||||
|
2. Create src/client/hooks/useImpactDeltas.ts wrapping in useMemo
|
||||||
|
|
||||||
|
3. Add categoryId to ThreadWithCandidates in useThreads.ts
|
||||||
|
|
||||||
|
4. Add selectedSetupId + setSelectedSetupId to uiStore.ts
|
||||||
|
</implementation>
|
||||||
|
</feature>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: TDD pure computeImpactDeltas function</name>
|
||||||
|
<files>src/client/lib/impactDeltas.ts, tests/lib/impactDeltas.test.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- Test: no setup selected (undefined) returns mode "none", empty deltas
|
||||||
|
- Test: replace mode — setup item matches threadCategoryId, deltas are candidate minus replaced item
|
||||||
|
- Test: add mode — no setup item matches, deltas equal candidate values
|
||||||
|
- Test: null candidate weight returns null weightDelta, not zero
|
||||||
|
- Test: null candidate price returns null priceDelta
|
||||||
|
- Test: replace mode with null replacedItem weight but valid candidate weight returns candidate weight as delta (add-like for that field)
|
||||||
|
- Test: negative delta in replace mode (candidate lighter than replaced item)
|
||||||
|
- Test: zero delta in replace mode (identical weight)
|
||||||
|
- Test: replacedItemName populated in replace mode, null in add mode
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
RED: Create tests/lib/impactDeltas.test.ts importing computeImpactDeltas from @/client/lib/impactDeltas. Write all test cases above using Bun test (describe/test/expect). Run tests — they MUST fail (module not found).
|
||||||
|
|
||||||
|
GREEN: Create src/client/lib/impactDeltas.ts with:
|
||||||
|
- Export types: CandidateInput, SetupItemInput, DeltaMode, CandidateDelta, ImpactDeltas
|
||||||
|
- Export function computeImpactDeltas(candidates: CandidateInput[], setupItems: SetupItemInput[] | undefined, threadCategoryId: number): ImpactDeltas
|
||||||
|
- Logic: if !setupItems return { mode: "none", deltas: {} }
|
||||||
|
- Find replacedItem = setupItems.find(item => item.categoryId === threadCategoryId) ?? null
|
||||||
|
- mode = replacedItem ? "replace" : "add"
|
||||||
|
- For each candidate: null-guard weight/price BEFORE arithmetic. In replace mode with non-null replaced value, delta = candidate - replaced. In replace mode with null replaced value, delta = candidate value (like add). In add mode, delta = candidate value.
|
||||||
|
- Run tests — all MUST pass.
|
||||||
|
|
||||||
|
REFACTOR: None needed for pure function.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/lib/impactDeltas.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>All 9+ test cases pass. computeImpactDeltas correctly handles replace mode, add mode, null weights, null prices, and edge cases. Types are exported.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Add uiStore state, fix ThreadWithCandidates type, create useImpactDeltas hook</name>
|
||||||
|
<files>src/client/stores/uiStore.ts, src/client/hooks/useThreads.ts, src/client/hooks/useImpactDeltas.ts</files>
|
||||||
|
<action>
|
||||||
|
1. In src/client/stores/uiStore.ts, add to UIState interface:
|
||||||
|
- selectedSetupId: number | null;
|
||||||
|
- setSelectedSetupId: (id: number | null) => void;
|
||||||
|
Add to create() initializer:
|
||||||
|
- selectedSetupId: null,
|
||||||
|
- setSelectedSetupId: (id) => set({ selectedSetupId: id }),
|
||||||
|
Place after the "Candidate view mode" section as "// Setup impact preview" section.
|
||||||
|
|
||||||
|
2. In src/client/hooks/useThreads.ts, add `categoryId: number;` to the ThreadWithCandidates interface, after the `resolvedCandidateId` field. The server already returns this field — the type was simply missing it.
|
||||||
|
|
||||||
|
3. Create src/client/hooks/useImpactDeltas.ts:
|
||||||
|
- Import useMemo from react
|
||||||
|
- Import computeImpactDeltas and types from "../lib/impactDeltas"
|
||||||
|
- Import type { SetupItemWithCategory } from "./useSetups"
|
||||||
|
- Export function useImpactDeltas(candidates: CandidateInput[], setupItems: SetupItemWithCategory[] | undefined, threadCategoryId: number): ImpactDeltas
|
||||||
|
- Body: return useMemo(() => computeImpactDeltas(candidates, setupItems, threadCategoryId), [candidates, setupItems, threadCategoryId])
|
||||||
|
- Re-export CandidateDelta, DeltaMode, ImpactDeltas types for convenience
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/lib/impactDeltas.test.ts && bun test tests/lib/formatters.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>uiStore has selectedSetupId + setter. ThreadWithCandidates includes categoryId. useImpactDeltas hook created and exports types. All existing tests still pass.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun test tests/lib/` passes all tests (impactDeltas + formatters)
|
||||||
|
- `bun test` full suite passes (no regressions)
|
||||||
|
- Types export correctly: CandidateInput, CandidateDelta, DeltaMode, ImpactDeltas, SetupItemInput from impactDeltas.ts
|
||||||
|
- useImpactDeltas hook wraps pure function in useMemo
|
||||||
|
- uiStore.selectedSetupId defaults to null
|
||||||
|
- ThreadWithCandidates.categoryId is declared as number
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- All IMPC requirement behaviors are tested and passing via pure function unit tests
|
||||||
|
- Data layer contracts (types, hook, uiStore state) are ready for Plan 02 UI consumption
|
||||||
|
- Zero regressions in existing test suite
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/13-setup-impact-preview/13-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
330
.planning/phases/13-setup-impact-preview/13-02-PLAN.md
Normal file
330
.planning/phases/13-setup-impact-preview/13-02-PLAN.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
---
|
||||||
|
phase: 13-setup-impact-preview
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["13-01"]
|
||||||
|
files_modified:
|
||||||
|
- src/client/components/SetupImpactSelector.tsx
|
||||||
|
- src/client/components/ImpactDeltaBadge.tsx
|
||||||
|
- src/client/routes/threads/$threadId.tsx
|
||||||
|
- src/client/components/CandidateListItem.tsx
|
||||||
|
- src/client/components/CandidateCard.tsx
|
||||||
|
- src/client/components/ComparisonTable.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: [IMPC-01, IMPC-02, IMPC-03, IMPC-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "User can select a setup from a dropdown in the thread header"
|
||||||
|
- "Each candidate displays weight and cost delta badges when a setup is selected"
|
||||||
|
- "Replace mode shows signed delta with replaced item name context"
|
||||||
|
- "Add mode shows positive delta labeled as '(add)'"
|
||||||
|
- "Candidate with no weight shows '-- (no weight data)' instead of a zero"
|
||||||
|
- "Candidate with no price shows '-- (no price data)' instead of a zero"
|
||||||
|
- "Deselecting setup ('None') clears all delta indicators"
|
||||||
|
- "Deltas appear in list view, grid view, and comparison table"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/client/components/SetupImpactSelector.tsx"
|
||||||
|
provides: "Setup dropdown for thread header"
|
||||||
|
exports: ["SetupImpactSelector"]
|
||||||
|
- path: "src/client/components/ImpactDeltaBadge.tsx"
|
||||||
|
provides: "Inline delta indicator component"
|
||||||
|
exports: ["ImpactDeltaBadge"]
|
||||||
|
- path: "src/client/routes/threads/$threadId.tsx"
|
||||||
|
provides: "Thread detail page wired with impact preview"
|
||||||
|
- path: "src/client/components/CandidateListItem.tsx"
|
||||||
|
provides: "List item with delta badges"
|
||||||
|
- path: "src/client/components/CandidateCard.tsx"
|
||||||
|
provides: "Card with delta badges"
|
||||||
|
- path: "src/client/components/ComparisonTable.tsx"
|
||||||
|
provides: "Comparison table with impact delta rows"
|
||||||
|
key_links:
|
||||||
|
- from: "src/client/routes/threads/$threadId.tsx"
|
||||||
|
to: "src/client/hooks/useImpactDeltas.ts"
|
||||||
|
via: "useImpactDeltas hook call at page level"
|
||||||
|
pattern: "useImpactDeltas"
|
||||||
|
- from: "src/client/routes/threads/$threadId.tsx"
|
||||||
|
to: "src/client/hooks/useSetups.ts"
|
||||||
|
via: "useSetup(selectedSetupId) for setup item data"
|
||||||
|
pattern: "useSetup\\(selectedSetupId"
|
||||||
|
- from: "src/client/routes/threads/$threadId.tsx"
|
||||||
|
to: "src/client/stores/uiStore.ts"
|
||||||
|
via: "selectedSetupId state read"
|
||||||
|
pattern: "useUIStore.*selectedSetupId"
|
||||||
|
- from: "src/client/components/SetupImpactSelector.tsx"
|
||||||
|
to: "src/client/stores/uiStore.ts"
|
||||||
|
via: "setSelectedSetupId state write"
|
||||||
|
pattern: "setSelectedSetupId"
|
||||||
|
- from: "src/client/components/ImpactDeltaBadge.tsx"
|
||||||
|
to: "src/client/lib/impactDeltas.ts"
|
||||||
|
via: "CandidateDelta type import"
|
||||||
|
pattern: "import.*CandidateDelta"
|
||||||
|
- from: "src/client/components/ComparisonTable.tsx"
|
||||||
|
to: "src/client/lib/impactDeltas.ts"
|
||||||
|
via: "ImpactDeltas type for deltas prop"
|
||||||
|
pattern: "import.*ImpactDeltas"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the UI components (setup dropdown + delta badges) and wire them into the thread detail page across all three view modes (list, grid, compare).
|
||||||
|
|
||||||
|
Purpose: This is the user-facing delivery of the impact preview feature. Plan 01 built the logic; this plan renders it.
|
||||||
|
Output: SetupImpactSelector component, ImpactDeltaBadge component, updated CandidateListItem/CandidateCard/ComparisonTable with delta rendering, wired thread detail page.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/13-setup-impact-preview/13-RESEARCH.md
|
||||||
|
@.planning/phases/13-setup-impact-preview/13-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Types created by Plan 01 that this plan consumes -->
|
||||||
|
|
||||||
|
From src/client/lib/impactDeltas.ts (created in Plan 01):
|
||||||
|
```typescript
|
||||||
|
export interface CandidateInput {
|
||||||
|
id: number;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeltaMode = "replace" | "add" | "none";
|
||||||
|
|
||||||
|
export interface CandidateDelta {
|
||||||
|
candidateId: number;
|
||||||
|
mode: DeltaMode;
|
||||||
|
weightDelta: number | null;
|
||||||
|
priceDelta: number | null;
|
||||||
|
replacedItemName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImpactDeltas {
|
||||||
|
mode: DeltaMode;
|
||||||
|
deltas: Record<number, CandidateDelta>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useImpactDeltas.ts (created in Plan 01):
|
||||||
|
```typescript
|
||||||
|
export function useImpactDeltas(
|
||||||
|
candidates: CandidateInput[],
|
||||||
|
setupItems: SetupItemWithCategory[] | undefined,
|
||||||
|
threadCategoryId: number,
|
||||||
|
): ImpactDeltas;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useSetups.ts:
|
||||||
|
```typescript
|
||||||
|
export function useSetups(): UseQueryResult<SetupListItem[]>;
|
||||||
|
export function useSetup(setupId: number | null): UseQueryResult<SetupWithItems>;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/stores/uiStore.ts (updated in Plan 01):
|
||||||
|
```typescript
|
||||||
|
selectedSetupId: number | null;
|
||||||
|
setSelectedSetupId: (id: number | null) => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useThreads.ts (updated in Plan 01):
|
||||||
|
```typescript
|
||||||
|
interface ThreadWithCandidates {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: "active" | "resolved";
|
||||||
|
resolvedCandidateId: number | null;
|
||||||
|
categoryId: number; // <-- Added in Plan 01
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
candidates: CandidateWithCategory[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/lib/formatters.ts:
|
||||||
|
```typescript
|
||||||
|
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string;
|
||||||
|
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string;
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing component props that need delta additions:
|
||||||
|
|
||||||
|
CandidateListItem props:
|
||||||
|
```typescript
|
||||||
|
interface CandidateListItemProps {
|
||||||
|
candidate: CandidateWithCategory;
|
||||||
|
rank: number;
|
||||||
|
isActive: boolean;
|
||||||
|
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||||
|
// Will add: delta?: CandidateDelta;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CandidateCard props:
|
||||||
|
```typescript
|
||||||
|
interface CandidateCardProps {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
// ... other props
|
||||||
|
// Will add: delta?: CandidateDelta;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ComparisonTable props:
|
||||||
|
```typescript
|
||||||
|
interface ComparisonTableProps {
|
||||||
|
candidates: CandidateWithCategory[];
|
||||||
|
resolvedCandidateId: number | null;
|
||||||
|
// Will add: deltas?: Record<number, CandidateDelta>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create SetupImpactSelector and ImpactDeltaBadge components</name>
|
||||||
|
<files>src/client/components/SetupImpactSelector.tsx, src/client/components/ImpactDeltaBadge.tsx</files>
|
||||||
|
<action>
|
||||||
|
1. Create src/client/components/SetupImpactSelector.tsx:
|
||||||
|
- Import useSetups from hooks/useSetups
|
||||||
|
- Import useUIStore from stores/uiStore
|
||||||
|
- Export function SetupImpactSelector()
|
||||||
|
- Read selectedSetupId and setSelectedSetupId from uiStore
|
||||||
|
- Fetch setups via useSetups()
|
||||||
|
- If no setups or loading, return null
|
||||||
|
- Render: a flex row with label "Impact on setup:" (text-xs text-gray-500) and a native `<select>` element
|
||||||
|
- Select value = selectedSetupId ?? "", onChange parses to number or null
|
||||||
|
- Options: "None" (value="") + each setup by name
|
||||||
|
- Styling: text-sm border border-gray-200 rounded-lg px-2 py-1 text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-gray-300
|
||||||
|
|
||||||
|
2. Create src/client/components/ImpactDeltaBadge.tsx:
|
||||||
|
- Import type CandidateDelta from lib/impactDeltas
|
||||||
|
- Import formatWeight, formatPrice, WeightUnit, Currency from lib/formatters
|
||||||
|
- Import useWeightUnit from hooks/useWeightUnit
|
||||||
|
- Import useCurrency from hooks/useCurrency
|
||||||
|
- Export function ImpactDeltaBadge({ delta, type }: { delta: CandidateDelta | undefined; type: "weight" | "price" })
|
||||||
|
- If !delta or delta.mode === "none", return null
|
||||||
|
- Pick value: type === "weight" ? delta.weightDelta : delta.priceDelta
|
||||||
|
- If value === null (no data): render `<span className="text-xs text-gray-300">` with "-- (no weight data)" or "-- (no price data)" depending on type
|
||||||
|
- If value is a number:
|
||||||
|
- formatted = type === "weight" ? formatWeight(Math.abs(value), unit) : formatPrice(Math.abs(value), currency)
|
||||||
|
- sign: value > 0 -> "+" , value < 0 -> "-" (use minus sign), value === 0 -> +/-
|
||||||
|
- colorClass: value < 0 -> "text-green-600" (lighter/cheaper is good), value > 0 -> "text-red-500", value === 0 -> "text-gray-400"
|
||||||
|
- modeLabel: delta.mode === "add" ? " (add)" : ""
|
||||||
|
- vsLabel: delta.mode === "replace" && delta.replacedItemName ? ` vs ${delta.replacedItemName}` : "" (only show this as a title attribute on the span, not inline text -- too long)
|
||||||
|
- Render: `<span className="text-xs font-medium {colorClass}" title={vsLabel || undefined}>{sign}{formatted}{modeLabel}</span>`
|
||||||
|
- The component reads unit/currency internally via hooks so callers don't need to pass them.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>SetupImpactSelector renders a setup dropdown reading from uiStore. ImpactDeltaBadge renders signed, colored delta indicators with null-data handling. Both components lint-clean.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Wire impact preview into thread detail page and all candidate views</name>
|
||||||
|
<files>src/client/routes/threads/$threadId.tsx, src/client/components/CandidateListItem.tsx, src/client/components/CandidateCard.tsx, src/client/components/ComparisonTable.tsx</files>
|
||||||
|
<action>
|
||||||
|
1. In src/client/routes/threads/$threadId.tsx:
|
||||||
|
- Add imports: useSetup from hooks/useSetups, useImpactDeltas from hooks/useImpactDeltas, SetupImpactSelector from components/SetupImpactSelector, type CandidateDelta from lib/impactDeltas
|
||||||
|
- Read selectedSetupId from useUIStore: `const selectedSetupId = useUIStore((s) => s.selectedSetupId);`
|
||||||
|
- Fetch setup data: `const { data: setupData } = useSetup(selectedSetupId ?? null);`
|
||||||
|
- Compute deltas: `const impactDeltas = useImpactDeltas(thread.candidates, setupData?.items, thread.categoryId);` (place after thread is loaded, inside the render body after the isLoading/isError guards)
|
||||||
|
- Place `<SetupImpactSelector />` in the header section, after the thread name/status row and before the toolbar. Wrap it in a div for spacing if needed.
|
||||||
|
- Pass delta to CandidateListItem: add prop `delta={impactDeltas.deltas[candidate.id]}` to each CandidateListItem (both Reorder.Group and static div renderings)
|
||||||
|
- Pass delta to CandidateCard: add prop `delta={impactDeltas.deltas[candidate.id]}` (the CandidateCard receives individual props, so pass it as `delta={impactDeltas.deltas[candidate.id]}`)
|
||||||
|
- Pass deltas to ComparisonTable: add prop `deltas={impactDeltas.deltas}` alongside existing candidates and resolvedCandidateId
|
||||||
|
|
||||||
|
2. In src/client/components/CandidateListItem.tsx:
|
||||||
|
- Import type CandidateDelta from lib/impactDeltas
|
||||||
|
- Import ImpactDeltaBadge from ./ImpactDeltaBadge
|
||||||
|
- Add `delta?: CandidateDelta;` to CandidateListItemProps
|
||||||
|
- Add `delta` to destructured props
|
||||||
|
- Render two ImpactDeltaBadge components inside the badges flex-wrap div (after the existing weight and price badges):
|
||||||
|
- `{delta && <ImpactDeltaBadge delta={delta} type="weight" />}`
|
||||||
|
- `{delta && <ImpactDeltaBadge delta={delta} type="price" />}`
|
||||||
|
- Place these AFTER the existing weight/price badges so they appear as secondary indicators
|
||||||
|
|
||||||
|
3. In src/client/components/CandidateCard.tsx:
|
||||||
|
- Import type CandidateDelta from lib/impactDeltas
|
||||||
|
- Import ImpactDeltaBadge from ./ImpactDeltaBadge
|
||||||
|
- Add `delta?: CandidateDelta;` to CandidateCardProps
|
||||||
|
- Add `delta` to destructured props
|
||||||
|
- Render two ImpactDeltaBadge components inside the badges flex-wrap div (after weight/price badges):
|
||||||
|
- `{delta && <ImpactDeltaBadge delta={delta} type="weight" />}`
|
||||||
|
- `{delta && <ImpactDeltaBadge delta={delta} type="price" />}`
|
||||||
|
|
||||||
|
4. In src/client/components/ComparisonTable.tsx:
|
||||||
|
- Import type CandidateDelta from lib/impactDeltas
|
||||||
|
- Import ImpactDeltaBadge from ./ImpactDeltaBadge
|
||||||
|
- Add `deltas?: Record<number, CandidateDelta>;` to ComparisonTableProps
|
||||||
|
- Add `deltas` to destructured props
|
||||||
|
- Add two new rows to ATTRIBUTE_ROWS array, placed right after the "weight" row and "price" row respectively:
|
||||||
|
a. After "weight" row, add:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
key: "impact-weight",
|
||||||
|
label: "Impact (wt)",
|
||||||
|
render: (c) => deltas?.[c.id] ? <ImpactDeltaBadge delta={deltas[c.id]} type="weight" /> : <span className="text-gray-300">--</span>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
b. After "price" row, add:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
key: "impact-price",
|
||||||
|
label: "Impact ($)",
|
||||||
|
render: (c) => deltas?.[c.id] ? <ImpactDeltaBadge delta={deltas[c.id]} type="price" /> : <span className="text-gray-300">--</span>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- These are separate rows (per research recommendation) to avoid conflating candidate-relative deltas with setup impact deltas.
|
||||||
|
- The impact rows show "--" when no setup is selected (deltas undefined or no entry for candidate).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test && bun run lint 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- SetupImpactSelector dropdown visible in thread header
|
||||||
|
- Selecting a setup shows weight/cost delta badges on each candidate in list, grid, and compare views
|
||||||
|
- Replace mode: signed delta with green (lighter/cheaper) or red (heavier/pricier) coloring
|
||||||
|
- Add mode: positive delta with "(add)" label
|
||||||
|
- Null weight/price: shows "-- (no weight data)" / "-- (no price data)" indicator
|
||||||
|
- Deselecting setup clears all delta indicators
|
||||||
|
- ComparisonTable has dedicated "Impact (wt)" and "Impact ($)" rows
|
||||||
|
- All tests pass, lint clean
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun test` full suite passes
|
||||||
|
- `bun run lint` clean
|
||||||
|
- SetupImpactSelector renders in thread header with all setups as options
|
||||||
|
- Selecting a setup triggers useSetup fetch and delta computation
|
||||||
|
- CandidateListItem, CandidateCard, ComparisonTable all render delta badges
|
||||||
|
- Replace mode detected when setup has item in same category as thread
|
||||||
|
- Add mode used otherwise
|
||||||
|
- Null weight/price shows clear indicator
|
||||||
|
- Deselecting shows no deltas (clean state)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- All four IMPC requirements visible in the UI
|
||||||
|
- Delta rendering works across list, grid, and compare views
|
||||||
|
- No regressions in existing functionality
|
||||||
|
- Clean lint output
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/13-setup-impact-preview/13-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
518
.planning/phases/13-setup-impact-preview/13-RESEARCH.md
Normal file
518
.planning/phases/13-setup-impact-preview/13-RESEARCH.md
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
# Phase 13: Setup Impact Preview - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-03-17
|
||||||
|
**Domain:** Pure frontend — delta computation + UI (React, Zustand, React Query)
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 13 adds a setup-selector dropdown to the thread detail header. When a user picks a setup, every candidate card and list row gains two delta indicators: weight delta and cost delta. The computation has two modes determined at render time — replace mode (a setup item exists in the same category as the thread) and add mode (no category match). The entire feature is a pure frontend addition: all required data is already available through existing hooks with zero backend or schema changes needed.
|
||||||
|
|
||||||
|
The delta logic is straightforward arithmetic over nullable numbers: `candidate.weightGrams - replacedItem.weightGrams` in replace mode, or `candidate.weightGrams` in add mode. The only real complexity is the null-weight indicator (IMPC-04) and driving the selected setup ID through Zustand state so it persists across view-mode switches within the same thread session.
|
||||||
|
|
||||||
|
**Primary recommendation:** Add `selectedSetupId: number | null` to uiStore, render a setup dropdown in the thread header, compute deltas in a `useMemo` inside a new `useImpactDeltas` hook, and render inline delta indicators below the candidate weight/price badges in both list and grid views.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|-----------------|
|
||||||
|
| IMPC-01 | User can select a setup and see weight and cost delta for each candidate | `useSetups()` returns all setups for dropdown; `useSetup(id)` returns items with categoryId for matching; delta computed in useMemo |
|
||||||
|
| IMPC-02 | Impact preview auto-detects replace mode when a setup item exists in the same category as the thread | Thread has `categoryId` (from `threads.categoryId`); setup items have `categoryId` via join; match on `categoryId` equality |
|
||||||
|
| IMPC-03 | Impact preview shows add mode (pure addition) when no category match exists in the selected setup | Default when no setup item matches `thread.categoryId`; label clearly as "+add" |
|
||||||
|
| IMPC-04 | Candidates with missing weight data show a clear indicator instead of misleading zero deltas | `candidate.weightGrams == null` → render `"-- (no weight data)"` instead of computing |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| React 19 | ^19.2.4 | UI rendering | Project foundation |
|
||||||
|
| Zustand | ^5.0.11 | `selectedSetupId` UI state | Established pattern for all UI-only state (panel open/close, view mode) |
|
||||||
|
| TanStack React Query | ^5.90.21 | `useSetup(id)` for setup items | Established data fetching pattern |
|
||||||
|
| Tailwind CSS v4 | ^4.2.1 | Delta badge styling | Project styling system |
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| framer-motion | ^12.37.0 | Optional entrance animation for delta indicators | Already installed; use AnimatePresence if subtle fade needed |
|
||||||
|
| lucide-react | ^0.577.0 | Dropdown chevron icon, delta arrow icons | Project icon system |
|
||||||
|
|
||||||
|
### No New Dependencies
|
||||||
|
This phase requires zero new npm dependencies. All needed libraries are installed.
|
||||||
|
|
||||||
|
**Installation:**
|
||||||
|
```bash
|
||||||
|
# No new packages needed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
```
|
||||||
|
src/client/
|
||||||
|
├── stores/uiStore.ts # Add selectedSetupId: number | null + setter
|
||||||
|
├── hooks/
|
||||||
|
│ └── useImpactDeltas.ts # New: compute add/replace deltas per candidate
|
||||||
|
├── components/
|
||||||
|
│ ├── SetupImpactSelector.tsx # New: setup dropdown rendered in thread header
|
||||||
|
│ └── ImpactDeltaBadge.tsx # New (or inline): weight/cost delta pill
|
||||||
|
└── routes/threads/$threadId.tsx # Add selector to header, pass deltas down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: selectedSetupId in Zustand
|
||||||
|
|
||||||
|
**What:** Store selected setup ID as UI state in `uiStore.ts`, not as URL state or server state.
|
||||||
|
|
||||||
|
**When to use:** The selection is ephemeral per session (no permalink needed), needs to survive view-mode switches (list/grid/compare), and must be accessible from any component in the thread page without prop drilling.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
// In uiStore.ts — add to UIState interface
|
||||||
|
selectedSetupId: number | null;
|
||||||
|
setSelectedSetupId: (id: number | null) => void;
|
||||||
|
|
||||||
|
// In create() initializer
|
||||||
|
selectedSetupId: null,
|
||||||
|
setSelectedSetupId: (id) => set({ selectedSetupId: id }),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: useImpactDeltas Hook
|
||||||
|
|
||||||
|
**What:** A pure computation hook that accepts candidates + a setup's item list + the thread's categoryId, and returns per-candidate delta objects.
|
||||||
|
|
||||||
|
**When to use:** Delta computation must run in a single place so list, grid, and compare views all show consistent numbers.
|
||||||
|
|
||||||
|
**Interface:**
|
||||||
|
```typescript
|
||||||
|
// src/client/hooks/useImpactDeltas.ts
|
||||||
|
import type { SetupItemWithCategory } from "./useSetups";
|
||||||
|
|
||||||
|
interface CandidateInput {
|
||||||
|
id: number;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeltaMode = "replace" | "add" | "none"; // "none" = no setup selected
|
||||||
|
|
||||||
|
interface CandidateDelta {
|
||||||
|
candidateId: number;
|
||||||
|
mode: DeltaMode;
|
||||||
|
weightDelta: number | null; // null = candidate has no weight data
|
||||||
|
priceDelta: number | null; // null = candidate has no price data
|
||||||
|
replacedItemName: string | null; // populated in replace mode for tooltip
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImpactDeltas {
|
||||||
|
mode: DeltaMode;
|
||||||
|
deltas: Record<number, CandidateDelta>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImpactDeltas(
|
||||||
|
candidates: CandidateInput[],
|
||||||
|
setupItems: SetupItemWithCategory[] | undefined,
|
||||||
|
threadCategoryId: number,
|
||||||
|
): ImpactDeltas
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
```typescript
|
||||||
|
// Source: project codebase pattern — mirrors ComparisonTable useMemo
|
||||||
|
const impactDeltas = useMemo(() => {
|
||||||
|
if (!setupItems) return { mode: "none", deltas: {} };
|
||||||
|
|
||||||
|
// Find replaced item: setup item whose categoryId matches thread's categoryId
|
||||||
|
const replacedItem = setupItems.find(
|
||||||
|
(item) => item.categoryId === threadCategoryId
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
|
const mode: DeltaMode = replacedItem ? "replace" : "add";
|
||||||
|
|
||||||
|
const deltas: Record<number, CandidateDelta> = {};
|
||||||
|
for (const c of candidates) {
|
||||||
|
let weightDelta: number | null = null;
|
||||||
|
let priceDelta: number | null = null;
|
||||||
|
|
||||||
|
if (c.weightGrams != null) {
|
||||||
|
weightDelta = mode === "replace" && replacedItem?.weightGrams != null
|
||||||
|
? c.weightGrams - replacedItem.weightGrams
|
||||||
|
: c.weightGrams;
|
||||||
|
}
|
||||||
|
// priceCents is integer (cents), same arithmetic
|
||||||
|
if (c.priceCents != null) {
|
||||||
|
priceDelta = mode === "replace" && replacedItem?.priceCents != null
|
||||||
|
? c.priceCents - replacedItem.priceCents
|
||||||
|
: c.priceCents;
|
||||||
|
}
|
||||||
|
|
||||||
|
deltas[c.id] = {
|
||||||
|
candidateId: c.id,
|
||||||
|
mode,
|
||||||
|
weightDelta,
|
||||||
|
priceDelta,
|
||||||
|
replacedItemName: replacedItem?.name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode, deltas };
|
||||||
|
}, [candidates, setupItems, threadCategoryId]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: SetupImpactSelector Component
|
||||||
|
|
||||||
|
**What:** A compact `<select>` dropdown in the thread detail header, rendered between the thread title and the toolbar.
|
||||||
|
|
||||||
|
**When to use:** Always present on active and resolved thread pages (impact preview is read-only, no mutation side effects).
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
// Placed in thread header, after thread name row
|
||||||
|
function SetupImpactSelector() {
|
||||||
|
const { data: setups } = useSetups();
|
||||||
|
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
|
||||||
|
const setSelectedSetupId = useUIStore((s) => s.setSelectedSetupId);
|
||||||
|
|
||||||
|
if (!setups || setups.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-gray-500 whitespace-nowrap">
|
||||||
|
Impact on setup:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedSetupId ?? ""}
|
||||||
|
onChange={(e) => setSelectedSetupId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="text-sm border border-gray-200 rounded-lg px-2 py-1 text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-gray-300"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{setups.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: ImpactDeltaBadge Rendering
|
||||||
|
|
||||||
|
**What:** Small inline indicator rendered below weight/price badges on each candidate. Three rendering cases per field:
|
||||||
|
|
||||||
|
| Case | Render |
|
||||||
|
|------|--------|
|
||||||
|
| No setup selected | Nothing (no change to existing layout) |
|
||||||
|
| Candidate has no weight | `"-- (no weight data)"` in muted gray |
|
||||||
|
| Weight exists, replace mode | `"±Xg vs [ItemName]"` with sign-colored text |
|
||||||
|
| Weight exists, add mode | `"+Xg (add)"` in gray |
|
||||||
|
|
||||||
|
**Where it renders:** Below the existing `formatWeight` / `formatPrice` badges in `CandidateListItem` and `CandidateCard`. In `ComparisonTable`, can be added as a sub-row or a second line within the weight/price cells.
|
||||||
|
|
||||||
|
**Sign coloring convention:**
|
||||||
|
- Negative delta (lighter/cheaper when replacing) → green text
|
||||||
|
- Positive delta (heavier/more expensive) → red text
|
||||||
|
- Zero delta → gray text
|
||||||
|
- No weight data → muted gray, em-dash prefix
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Reusable inline component
|
||||||
|
function ImpactDeltaBadge({
|
||||||
|
delta,
|
||||||
|
noDataLabel = "-- (no weight data)",
|
||||||
|
unit,
|
||||||
|
currency,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
delta: CandidateDelta | undefined;
|
||||||
|
noDataLabel?: string;
|
||||||
|
unit?: WeightUnit;
|
||||||
|
currency?: Currency;
|
||||||
|
type: "weight" | "price";
|
||||||
|
}) {
|
||||||
|
if (!delta || delta.mode === "none") return null;
|
||||||
|
|
||||||
|
const value = type === "weight" ? delta.weightDelta : delta.priceDelta;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
// Candidate has no data for this field
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-gray-300">{noDataLabel}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = type === "weight"
|
||||||
|
? formatWeight(Math.abs(value), unit)
|
||||||
|
: formatPrice(Math.abs(value), currency);
|
||||||
|
|
||||||
|
const sign = value > 0 ? "+" : value < 0 ? "−" : "±";
|
||||||
|
const colorClass = value < 0 ? "text-green-600" : value > 0 ? "text-red-500" : "text-gray-400";
|
||||||
|
const modeLabel = delta.mode === "add" ? " (add)" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`text-xs font-medium ${colorClass}`}>
|
||||||
|
{sign}{formatted}{modeLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
$threadId.tsx
|
||||||
|
├── selectedSetupId ← useUIStore
|
||||||
|
├── thread ← useThread(threadId) // has thread.categoryId + candidates
|
||||||
|
├── setupData ← useSetup(selectedSetupId) // null when none selected
|
||||||
|
├── impactDeltas ← useImpactDeltas(candidates, setupData?.items, thread.categoryId)
|
||||||
|
│
|
||||||
|
├── <SetupImpactSelector /> // sets selectedSetupId in uiStore
|
||||||
|
│
|
||||||
|
├── <CandidateListItem delta={impactDeltas.deltas[c.id]} />
|
||||||
|
├── <CandidateCard delta={impactDeltas.deltas[c.id]} />
|
||||||
|
└── <ComparisonTable deltas={impactDeltas.deltas} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Computing deltas in each candidate component:** Delta mode (add vs replace) must be determined once from the full setup. Computing per-component means each card independently decides mode — a setup with multiple items in different categories could give inconsistent signals if the logic is subtle.
|
||||||
|
- **Storing selectedSetupId in URL search params:** Adds routing complexity with no benefit; the selection is ephemeral and non-shareable per project scope.
|
||||||
|
- **Calling `useSetup` inside each candidate component:** Causes N redundant React Query calls. Call once at page level, pass deltas down.
|
||||||
|
- **Treating `priceDelta = 0` as "no data":** Zero cost delta is a valid result (exact price match). The `null` check distinguishes missing data from zero.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Formatted weight delta strings | Custom formatter | Reuse `formatWeight(Math.abs(delta), unit)` + sign prefix | Already handles all 4 units (g/oz/lb/kg) correctly |
|
||||||
|
| Formatted price delta strings | Custom formatter | Reuse `formatPrice(Math.abs(delta), currency)` + sign prefix | Already handles all currencies and JPY integer case |
|
||||||
|
| Setup list fetching | Custom fetch | `useSetups()` hook | Already defined, cached by React Query |
|
||||||
|
| Setup items fetching | Custom fetch | `useSetup(id)` hook | Already defined with enabled guard |
|
||||||
|
| UI state management | Local useState | Zustand `selectedSetupId` | Persists across view mode switches within same session |
|
||||||
|
|
||||||
|
**Key insight:** All data infrastructure exists. This phase is arithmetic + UI only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Thread categoryId vs Candidate categoryId
|
||||||
|
|
||||||
|
**What goes wrong:** Using `candidate.categoryId` instead of `thread.categoryId` to find the replaced setup item. Candidates inherit the thread's category (they're in the same decision thread), but a user could theoretically pick a different category per candidate. The impact preview is about "what does buying something for this research thread do to my setup," so the match must be on the **thread** category, not individual candidate category.
|
||||||
|
|
||||||
|
**Why it happens:** `CandidateWithCategory` has a `categoryId` field that looks natural to use.
|
||||||
|
|
||||||
|
**How to avoid:** In `useImpactDeltas`, accept `threadCategoryId` as a separate parameter sourced from `thread.categoryId`, not from `candidate.categoryId`.
|
||||||
|
|
||||||
|
**Warning signs:** Replace mode never triggers even when a setup contains an item in the expected category.
|
||||||
|
|
||||||
|
### Pitfall 2: Null vs Zero for Missing Data (IMPC-04)
|
||||||
|
|
||||||
|
**What goes wrong:** When `candidate.weightGrams` is `null`, the delta would be `null - replacedItem.weightGrams = NaN` or JavaScript coerces to `0`. Rendering "0g" is actively misleading — it implies the candidate has been weighed at zero.
|
||||||
|
|
||||||
|
**Why it happens:** JavaScript's `null - 200 = -200` is NaN, not zero, but string formatting might swallow this silently.
|
||||||
|
|
||||||
|
**How to avoid:** Explicit null guard BEFORE arithmetic: `if (c.weightGrams == null) { weightDelta = null; }`. Render `null` delta as `"-- (no weight data)"` per IMPC-04.
|
||||||
|
|
||||||
|
**Warning signs:** Candidates with no weight show "0g" or "−200g" delta.
|
||||||
|
|
||||||
|
### Pitfall 3: useSetup Enabled Guard
|
||||||
|
|
||||||
|
**What goes wrong:** Calling `useSetup(null)` triggers a request to `/api/setups/null` — a 404 or server error.
|
||||||
|
|
||||||
|
**Why it happens:** `useSetup` has `enabled: setupId != null` guard, but if the `selectedSetupId` from Zustand is not passed correctly, it might be `undefined` rather than `null`.
|
||||||
|
|
||||||
|
**How to avoid:** Coerce to `null` explicitly: `useSetup(selectedSetupId ?? null)`.
|
||||||
|
|
||||||
|
**Warning signs:** Network errors in dev tools when no setup is selected.
|
||||||
|
|
||||||
|
### Pitfall 4: selectedSetupId Stale Across Thread Navigation
|
||||||
|
|
||||||
|
**What goes wrong:** User selects "Setup A" on thread 1, navigates to thread 2, sees impact deltas for "Setup A" which may not be relevant.
|
||||||
|
|
||||||
|
**Why it happens:** Zustand state persists in memory across route changes.
|
||||||
|
|
||||||
|
**How to avoid:** Two acceptable approaches:
|
||||||
|
1. **Accept it** — the user chose a setup globally; they can clear it. Simplest.
|
||||||
|
2. **Reset on thread change** — call `setSelectedSetupId(null)` in a `useEffect` that fires on `threadId` change.
|
||||||
|
|
||||||
|
Recommended: Accept cross-thread persistence (simpler, matches how `candidateViewMode` works currently).
|
||||||
|
|
||||||
|
### Pitfall 5: ComparisonTable Integration
|
||||||
|
|
||||||
|
**What goes wrong:** ComparisonTable already has its own `weightDeltas` computation (candidate-relative deltas: lightest vs others). Adding setup deltas as a third numeric display in the same weight cell risks visual clutter and ambiguity about which delta is which.
|
||||||
|
|
||||||
|
**Why it happens:** Two delta systems in one cell with no visual separation.
|
||||||
|
|
||||||
|
**How to avoid:** Render setup impact deltas in a **separate row** in ATTRIBUTE_ROWS, or as a clearly labeled sub-row below weight. Label it "Impact" with a small setup name indicator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
Verified patterns from existing codebase:
|
||||||
|
|
||||||
|
### Existing Delta Pattern (ComparisonTable.tsx)
|
||||||
|
```typescript
|
||||||
|
// Source: src/client/components/ComparisonTable.tsx
|
||||||
|
// This shows the established useMemo pattern for delta computation
|
||||||
|
const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } =
|
||||||
|
useMemo(() => {
|
||||||
|
const withWeight = candidates.filter((c) => c.weightGrams != null);
|
||||||
|
let bestWeightId: number | null = null;
|
||||||
|
const weightDeltas: Record<number, string | null> = {};
|
||||||
|
// ... arithmetic over nullable numbers
|
||||||
|
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
|
||||||
|
}, [candidates, unit, currency]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing useSetup Hook
|
||||||
|
```typescript
|
||||||
|
// Source: src/client/hooks/useSetups.ts
|
||||||
|
export function useSetup(setupId: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["setups", setupId],
|
||||||
|
queryFn: () => apiGet<SetupWithItems>(`/api/setups/${setupId}`),
|
||||||
|
enabled: setupId != null, // CRITICAL: prevents null fetch
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// SetupItemWithCategory includes: categoryId, weightGrams, priceCents, name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing formatWeight / formatPrice
|
||||||
|
```typescript
|
||||||
|
// Source: src/client/lib/formatters.ts
|
||||||
|
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string {
|
||||||
|
if (grams == null) return "--";
|
||||||
|
// handles g / oz / lb / kg
|
||||||
|
}
|
||||||
|
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string {
|
||||||
|
if (cents == null) return "--";
|
||||||
|
// handles JPY integer case, others to 2dp
|
||||||
|
}
|
||||||
|
// Pass Math.abs(delta) to these, prefix sign manually
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing Zustand UI State Pattern
|
||||||
|
```typescript
|
||||||
|
// Source: src/client/stores/uiStore.ts
|
||||||
|
// All ephemeral UI state lives here — follow same pattern
|
||||||
|
candidateViewMode: "list" | "grid" | "compare";
|
||||||
|
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||||
|
// Add analogously:
|
||||||
|
// selectedSetupId: number | null;
|
||||||
|
// setSelectedSetupId: (id: number | null) => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Thread categoryId Availability
|
||||||
|
```typescript
|
||||||
|
// Source: src/server/services/thread.service.ts — getThreadWithCandidates
|
||||||
|
// thread object from useThread() has .categoryId (integer) directly available
|
||||||
|
// Note: ThreadWithCandidates interface in useThreads.ts does NOT expose categoryId
|
||||||
|
// The raw DB thread select does, but the hook return type may need updating
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important finding:** The `ThreadWithCandidates` interface in `useThreads.ts` does NOT currently include `categoryId`. The server does return it (from `db.select().from(threads)`), but the TypeScript interface omits it. The planner must add `categoryId: number` to `ThreadWithCandidates` or source it from the candidates (each `CandidateWithCategory` has `categoryId`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Fetch data inside components | Custom hooks with React Query | Established in project | All data fetching via hooks |
|
||||||
|
| Local component state for UI | Zustand store | Established in project | All UI state centralized |
|
||||||
|
|
||||||
|
**No deprecated patterns in scope for this phase.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Thread categoryId exposure in ThreadWithCandidates**
|
||||||
|
- What we know: `getThreadWithCandidates` in thread.service.ts returns the full thread row (including `categoryId`), but `ThreadWithCandidates` TypeScript interface in `useThreads.ts` does not declare `categoryId`
|
||||||
|
- What's unclear: Does the API actually serialize `categoryId` in the response or is it filtered?
|
||||||
|
- Recommendation: Planner should add `categoryId: number` to `ThreadWithCandidates` interface and verify the server route includes it. Alternatively, use `thread.candidates[0]?.categoryId` as a fallback since all candidates share the thread's category.
|
||||||
|
|
||||||
|
2. **Selector placement on narrow viewports**
|
||||||
|
- What we know: Thread header already has breadcrumb, title/status pill, and toolbar row
|
||||||
|
- What's unclear: Three rows in mobile header may feel cramped
|
||||||
|
- Recommendation: Planner's discretion — can be inline with toolbar or as a third header row. Research finds no hard constraint.
|
||||||
|
|
||||||
|
3. **ComparisonTable delta row placement**
|
||||||
|
- What we know: ATTRIBUTE_ROWS pattern is extensible (just add an object to the array)
|
||||||
|
- What's unclear: Whether impact rows should live inside the weight/price rows or as separate "Impact Weight" / "Impact Price" rows
|
||||||
|
- Recommendation: Separate labeled rows to avoid conflating candidate-relative deltas (lightest/cheapest highlighting) with setup impact deltas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | Bun test (built-in) |
|
||||||
|
| Config file | none — bun test discovers tests automatically |
|
||||||
|
| Quick run command | `bun test tests/services/` |
|
||||||
|
| Full suite command | `bun test` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| IMPC-01 | Delta values computed and passed to candidates when setup selected | unit (hook logic) | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||||
|
| IMPC-02 | Replace mode triggered when setup contains item in same category as thread | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||||
|
| IMPC-03 | Add mode used when no category match, delta equals candidate value | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||||
|
| IMPC-04 | Null weight candidate returns null delta (not zero, not NaN) | unit | `bun test tests/lib/impactDeltas.test.ts` | Wave 0 |
|
||||||
|
|
||||||
|
**Note:** Delta computation is pure arithmetic logic and can be tested outside React via an extracted pure function. The recommended approach is to extract `computeImpactDeltas(candidates, setupItems, threadCategoryId)` as a pure function and test it directly — no React Testing Library needed.
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `bun test tests/lib/`
|
||||||
|
- **Per wave merge:** `bun test`
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] `tests/lib/impactDeltas.test.ts` — covers IMPC-01 through IMPC-04 via pure function extracted from `useImpactDeltas`
|
||||||
|
- [ ] `tests/lib/` directory — create if not exists (pure utility tests go here)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- Direct codebase read — `src/db/schema.ts` — verified `threadCandidates.categoryId`, `items.categoryId`, `setupItems` join structure
|
||||||
|
- Direct codebase read — `src/client/hooks/useSetups.ts` — verified `SetupItemWithCategory` type includes `categoryId`, `weightGrams`, `priceCents`
|
||||||
|
- Direct codebase read — `src/client/hooks/useThreads.ts` — identified missing `categoryId` in `ThreadWithCandidates` interface
|
||||||
|
- Direct codebase read — `src/client/components/ComparisonTable.tsx` — verified ATTRIBUTE_ROWS pattern and existing delta computation pattern
|
||||||
|
- Direct codebase read — `src/client/stores/uiStore.ts` — verified `selectedSetupId` does not yet exist, pattern for adding it
|
||||||
|
- Direct codebase read — `src/client/lib/formatters.ts` — verified `formatWeight` / `formatPrice` reusability with abs values
|
||||||
|
- Direct codebase read — `tests/helpers/db.ts` — verified test infrastructure, no schema changes needed
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- `.planning/STATE.md` — confirms "Impact preview must distinguish add-mode vs replace-mode by category match" as locked architectural decision
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — zero new dependencies; all libraries confirmed in package.json
|
||||||
|
- Architecture: HIGH — derived entirely from reading existing codebase; patterns are directly reusable
|
||||||
|
- Pitfalls: HIGH — identified from code inspection (ThreadWithCandidates missing categoryId is a concrete finding, not speculation)
|
||||||
|
- Delta math: HIGH — straightforward arithmetic, verified types from schema
|
||||||
|
|
||||||
|
**Research date:** 2026-03-17
|
||||||
|
**Valid until:** 2026-04-17 (stable codebase; no external library research)
|
||||||
78
.planning/phases/13-setup-impact-preview/13-VALIDATION.md
Normal file
78
.planning/phases/13-setup-impact-preview/13-VALIDATION.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
phase: 13
|
||||||
|
slug: setup-impact-preview
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-03-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 13 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | bun test (built-in) |
|
||||||
|
| **Config file** | bunfig.toml |
|
||||||
|
| **Quick run command** | `bun test` |
|
||||||
|
| **Full suite command** | `bun test` |
|
||||||
|
| **Estimated runtime** | ~5 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `bun test`
|
||||||
|
- **After every plan wave:** Run `bun test`
|
||||||
|
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 5 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 13-01-01 | 01 | 1 | IMPC-01 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||||
|
| 13-01-02 | 01 | 1 | IMPC-02 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||||
|
| 13-01-03 | 01 | 1 | IMPC-03 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||||
|
| 13-01-04 | 01 | 1 | IMPC-04 | unit | `bun test` | ❌ W0 | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] Test stubs for IMPC-01 through IMPC-04 impact delta computation
|
||||||
|
- [ ] Test fixtures for setup items and thread candidates with weight/price data
|
||||||
|
|
||||||
|
*If none: "Existing infrastructure covers all phase requirements."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Setup dropdown renders in thread header | IMPC-01 | Visual/UI placement | Open a thread, verify dropdown appears with all setups listed |
|
||||||
|
| Delta labels display correctly (add vs replace) | IMPC-03 | Visual formatting | Select setup with no category match, verify "add" label |
|
||||||
|
| Missing weight shows "-- (no weight data)" | IMPC-04 | Visual indicator | Add candidate with no weight, verify placeholder text |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 5s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
@@ -9,7 +9,8 @@ GearBox is a single-user web app for managing gear collections (bikepacking, sim
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development (run both in separate terminals)
|
# Development
|
||||||
|
bun run dev # Starts both Vite client (:5173) and Hono server (:3000) concurrently
|
||||||
bun run dev:client # Vite dev server on :5173 (proxies /api to :3000)
|
bun run dev:client # Vite dev server on :5173 (proxies /api to :3000)
|
||||||
bun run dev:server # Hono server on :3000 with hot reload
|
bun run dev:server # Hono server on :3000 with hot reload
|
||||||
|
|
||||||
@@ -67,4 +68,4 @@ bun run build # Vite build → dist/client/
|
|||||||
- **Thread resolution**: Resolving a thread copies the winning candidate's data into a new item in the collection, sets `resolvedCandidateId`, and changes status to "resolved".
|
- **Thread resolution**: Resolving a thread copies the winning candidate's data into a new item in the collection, sets `resolvedCandidateId`, and changes status to "resolved".
|
||||||
- **Setup item sync**: `PUT /api/setups/:id/items` replaces all setup_items atomically (delete all, re-insert).
|
- **Setup item sync**: `PUT /api/setups/:id/items` replaces all setup_items atomically (delete all, re-insert).
|
||||||
- **Image uploads**: `POST /api/images` saves to `./uploads/` with UUID filename, returned as `imageFilename` on item/candidate records.
|
- **Image uploads**: `POST /api/images` saves to `./uploads/` with UUID filename, returned as `imageFilename` on item/candidate records.
|
||||||
- **Aggregates** (weight/cost totals): Computed via SQL on read, not stored on records.
|
- **Aggregates** (weight/cost totals): Computed via SQL on read, not stored on records.
|
||||||
41
README.md
41
README.md
@@ -10,7 +10,7 @@ A single-user web app for managing gear collections (bikepacking, sim racing, et
|
|||||||
- Research threads for comparing candidates before buying
|
- Research threads for comparing candidates before buying
|
||||||
- Image uploads for items and candidates
|
- Image uploads for items and candidates
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start (Docker)
|
||||||
|
|
||||||
### Docker Compose (recommended)
|
### Docker Compose (recommended)
|
||||||
|
|
||||||
@@ -81,3 +81,42 @@ docker compose up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
Database migrations run automatically on startup.
|
Database migrations run automatically on startup.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime & Package Manager:** [Bun](https://bun.sh)
|
||||||
|
- **Frontend:** React 19, Vite, TanStack Router, TanStack Query, Tailwind CSS v4, Zustand
|
||||||
|
- **Backend:** Hono, Drizzle ORM, SQLite (`bun:sqlite`)
|
||||||
|
|
||||||
|
## Local Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
You must have [Bun](https://bun.sh/) installed on your machine. Docker is not required for local development.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Install all dependencies:
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Initialize the local SQLite database (`gearbox.db`):
|
||||||
|
```bash
|
||||||
|
bun run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development servers:
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
This single command will start both the Vite frontend server (port `5173`) and the Hono backend server (port `3000`) concurrently.
|
||||||
|
|
||||||
|
Open [http://localhost:5173](http://localhost:5173) in your browser to view the app.
|
||||||
|
|
||||||
|
## Additional Commands
|
||||||
|
|
||||||
|
- `bun run build` — Build the production assets into `dist/client/`
|
||||||
|
- `bun test` — Run the test suite
|
||||||
|
- `bun run lint` — Check formatting and lint rules using Biome
|
||||||
|
- `bun run db:generate` — Generate Drizzle migrations after making schema changes
|
||||||
47
bun.lock
47
bun.lock
@@ -31,6 +31,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
},
|
},
|
||||||
@@ -312,6 +313,10 @@
|
|||||||
|
|
||||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
|
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
|
||||||
|
|
||||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||||
@@ -344,12 +349,22 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||||
|
|
||||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||||
|
|
||||||
|
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
|
||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
|
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
|
||||||
@@ -396,6 +411,8 @@
|
|||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
|
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
|
||||||
@@ -426,6 +443,8 @@
|
|||||||
|
|
||||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
|
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||||
|
|
||||||
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||||
@@ -436,6 +455,8 @@
|
|||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
|
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
|
||||||
|
|
||||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
@@ -452,6 +473,8 @@
|
|||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|
||||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||||
@@ -552,12 +575,16 @@
|
|||||||
|
|
||||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||||
|
|
||||||
|
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||||
|
|
||||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
"rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
|
"rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
|
||||||
|
|
||||||
|
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||||
|
|
||||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
@@ -568,6 +595,8 @@
|
|||||||
|
|
||||||
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
|
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
|
||||||
|
|
||||||
|
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||||
|
|
||||||
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||||
|
|
||||||
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||||
@@ -578,10 +607,16 @@
|
|||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
@@ -598,6 +633,8 @@
|
|||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
|
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||||
@@ -622,10 +659,18 @@
|
|||||||
|
|
||||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||||
|
|
||||||
|
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
|
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
|
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||||
|
|
||||||
|
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||||
|
|
||||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
|
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
|
||||||
@@ -654,6 +699,8 @@
|
|||||||
|
|
||||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
|
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|||||||
94
package.json
94
package.json
@@ -1,48 +1,50 @@
|
|||||||
{
|
{
|
||||||
"name": "gearbox",
|
"name": "gearbox",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:client": "vite",
|
"dev": "concurrently -k -c \"blue,green\" -n \"server,client\" \"bun run dev:server\" \"bun run dev:client\"",
|
||||||
"dev:server": "bun --hot src/server/index.ts",
|
"dev:client": "vite",
|
||||||
"build": "vite build",
|
"dev:server": "bun --hot src/server/index.ts",
|
||||||
"db:generate": "bunx drizzle-kit generate",
|
"build": "vite build",
|
||||||
"db:push": "bunx drizzle-kit push",
|
"db:generate": "bunx drizzle-kit generate",
|
||||||
"test": "bun test",
|
"db:push": "bunx drizzle-kit push",
|
||||||
"lint": "bunx @biomejs/biome check ."
|
"test": "bun test",
|
||||||
},
|
"lint": "bunx @biomejs/biome check ."
|
||||||
"devDependencies": {
|
},
|
||||||
"@biomejs/biome": "^2.4.7",
|
"devDependencies": {
|
||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@biomejs/biome": "^2.4.7",
|
||||||
"@tanstack/react-router-devtools": "^1.166.7",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@tanstack/router-plugin": "^1.166.9",
|
"@tanstack/react-router-devtools": "^1.166.7",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@tanstack/router-plugin": "^1.166.9",
|
||||||
"@types/bun": "latest",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/react": "^19.2.14",
|
"@types/bun": "latest",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react": "^19.2.14",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@types/react-dom": "^19.2.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"drizzle-kit": "^0.31.9",
|
"better-sqlite3": "^12.8.0",
|
||||||
"vite": "^8.0.0"
|
"concurrently": "^9.1.2",
|
||||||
},
|
"drizzle-kit": "^0.31.9",
|
||||||
"peerDependencies": {
|
"vite": "^8.0.0"
|
||||||
"typescript": "^5.9.3"
|
},
|
||||||
},
|
"peerDependencies": {
|
||||||
"dependencies": {
|
"typescript": "^5.9.3"
|
||||||
"@hono/zod-validator": "^0.7.6",
|
},
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@hono/zod-validator": "^0.7.6",
|
||||||
"@tanstack/react-router": "^1.167.0",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"clsx": "^2.1.1",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"drizzle-orm": "^0.45.1",
|
"@tanstack/react-router": "^1.167.0",
|
||||||
"hono": "^4.12.8",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.577.0",
|
"drizzle-orm": "^0.45.1",
|
||||||
"react": "^19.2.4",
|
"hono": "^4.12.8",
|
||||||
"react-dom": "^19.2.4",
|
"lucide-react": "^0.577.0",
|
||||||
"recharts": "^3.8.0",
|
"react": "^19.2.4",
|
||||||
"tailwindcss": "^4.2.1",
|
"react-dom": "^19.2.4",
|
||||||
"zod": "^4.3.6",
|
"recharts": "^3.8.0",
|
||||||
"zustand": "^5.0.11"
|
"tailwindcss": "^4.2.1",
|
||||||
}
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
336
src/client/components/ComparisonTable.tsx
Normal file
336
src/client/components/ComparisonTable.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useCurrency } from "../hooks/useCurrency";
|
||||||
|
import { useWeightUnit } from "../hooks/useWeightUnit";
|
||||||
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
|
import { LucideIcon } from "../lib/iconData";
|
||||||
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { RankBadge } from "./CandidateListItem";
|
||||||
|
|
||||||
|
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 ComparisonTableProps {
|
||||||
|
candidates: CandidateWithCategory[];
|
||||||
|
resolvedCandidateId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
||||||
|
researching: "Researching",
|
||||||
|
ordered: "Ordered",
|
||||||
|
arrived: "Arrived",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ComparisonTable({
|
||||||
|
candidates,
|
||||||
|
resolvedCandidateId,
|
||||||
|
}: ComparisonTableProps) {
|
||||||
|
const unit = useWeightUnit();
|
||||||
|
const currency = useCurrency();
|
||||||
|
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
||||||
|
|
||||||
|
const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } =
|
||||||
|
useMemo(() => {
|
||||||
|
// Weight deltas
|
||||||
|
const withWeight = candidates.filter((c) => c.weightGrams != null);
|
||||||
|
let bestWeightId: number | null = null;
|
||||||
|
const weightDeltas: Record<number, string | null> = {};
|
||||||
|
|
||||||
|
if (withWeight.length > 0) {
|
||||||
|
const minWeight = Math.min(
|
||||||
|
...withWeight.map((c) => c.weightGrams as number),
|
||||||
|
);
|
||||||
|
bestWeightId =
|
||||||
|
withWeight.find((c) => c.weightGrams === minWeight)?.id ?? null;
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (c.weightGrams == null) {
|
||||||
|
weightDeltas[c.id] = null;
|
||||||
|
} else {
|
||||||
|
const delta = c.weightGrams - minWeight;
|
||||||
|
weightDeltas[c.id] =
|
||||||
|
delta === 0 ? null : `+${formatWeight(delta, unit)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const c of candidates) {
|
||||||
|
weightDeltas[c.id] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price deltas
|
||||||
|
const withPrice = candidates.filter((c) => c.priceCents != null);
|
||||||
|
let bestPriceId: number | null = null;
|
||||||
|
const priceDeltas: Record<number, string | null> = {};
|
||||||
|
|
||||||
|
if (withPrice.length > 0) {
|
||||||
|
const minPrice = Math.min(
|
||||||
|
...withPrice.map((c) => c.priceCents as number),
|
||||||
|
);
|
||||||
|
bestPriceId =
|
||||||
|
withPrice.find((c) => c.priceCents === minPrice)?.id ?? null;
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (c.priceCents == null) {
|
||||||
|
priceDeltas[c.id] = null;
|
||||||
|
} else {
|
||||||
|
const delta = c.priceCents - minPrice;
|
||||||
|
priceDeltas[c.id] =
|
||||||
|
delta === 0 ? null : `+${formatPrice(delta, currency)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const c of candidates) {
|
||||||
|
priceDeltas[c.id] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
|
||||||
|
}, [candidates, unit, currency]);
|
||||||
|
|
||||||
|
const ATTRIBUTE_ROWS: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
render: (
|
||||||
|
candidate: CandidateWithCategory,
|
||||||
|
index: number,
|
||||||
|
) => React.ReactNode;
|
||||||
|
cellClass?: (candidate: CandidateWithCategory) => string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: "image",
|
||||||
|
label: "Image",
|
||||||
|
render: (c) => (
|
||||||
|
<div className="w-12 h-12 rounded-lg overflow-hidden bg-gray-50 flex items-center justify-center">
|
||||||
|
{c.imageFilename ? (
|
||||||
|
<img
|
||||||
|
src={`/uploads/${c.imageFilename}`}
|
||||||
|
alt={c.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LucideIcon
|
||||||
|
name={c.categoryIcon}
|
||||||
|
size={20}
|
||||||
|
className="text-gray-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
label: "Name",
|
||||||
|
render: (c) => (
|
||||||
|
<span className="text-sm font-medium text-gray-900">{c.name}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rank",
|
||||||
|
label: "Rank",
|
||||||
|
render: (_c, index) => <RankBadge rank={index + 1} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "weight",
|
||||||
|
label: "Weight",
|
||||||
|
render: (c) => {
|
||||||
|
const isBest = c.id === bestWeightId;
|
||||||
|
const delta = weightDeltas[c.id];
|
||||||
|
if (c.weightGrams == null) {
|
||||||
|
return <span className="text-gray-300">—</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{formatWeight(c.weightGrams, unit)}
|
||||||
|
</span>
|
||||||
|
{!isBest && delta && (
|
||||||
|
<div className="text-xs text-gray-400">{delta}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cellClass: (c) => {
|
||||||
|
if (c.id === bestWeightId) return "bg-blue-50";
|
||||||
|
if (c.id === resolvedCandidateId) return "bg-amber-50/50";
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "price",
|
||||||
|
label: "Price",
|
||||||
|
render: (c) => {
|
||||||
|
const isBest = c.id === bestPriceId;
|
||||||
|
const delta = priceDeltas[c.id];
|
||||||
|
if (c.priceCents == null) {
|
||||||
|
return <span className="text-gray-300">—</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{formatPrice(c.priceCents, currency)}
|
||||||
|
</span>
|
||||||
|
{!isBest && delta && (
|
||||||
|
<div className="text-xs text-gray-400">{delta}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cellClass: (c) => {
|
||||||
|
if (c.id === bestPriceId) return "bg-green-50";
|
||||||
|
if (c.id === resolvedCandidateId) return "bg-amber-50/50";
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Status",
|
||||||
|
render: (c) => (
|
||||||
|
<span className="text-xs text-gray-600">{STATUS_LABELS[c.status]}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "link",
|
||||||
|
label: "Link",
|
||||||
|
render: (c) =>
|
||||||
|
c.productUrl ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openExternalLink(c.productUrl as string)}
|
||||||
|
className="text-xs text-blue-500 hover:underline"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-300">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "notes",
|
||||||
|
label: "Notes",
|
||||||
|
render: (c) =>
|
||||||
|
c.notes ? (
|
||||||
|
<p className="text-xs text-gray-700 whitespace-pre-line">{c.notes}</p>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-300">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pros",
|
||||||
|
label: "Pros",
|
||||||
|
render: (c) => {
|
||||||
|
if (!c.pros) return <span className="text-gray-300">—</span>;
|
||||||
|
const items = c.pros.split("\n").filter((s) => s.trim() !== "");
|
||||||
|
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) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
|
||||||
|
<li key={i} className="text-xs text-gray-700">
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cons",
|
||||||
|
label: "Cons",
|
||||||
|
render: (c) => {
|
||||||
|
if (!c.cons) return <span className="text-gray-300">—</span>;
|
||||||
|
const items = c.cons.split("\n").filter((s) => s.trim() !== "");
|
||||||
|
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) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
|
||||||
|
<li key={i} className="text-xs text-gray-700">
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tableMinWidth = Math.max(400, candidates.length * 180);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-gray-100">
|
||||||
|
<table
|
||||||
|
className="border-collapse text-sm w-full"
|
||||||
|
style={{ minWidth: `${tableMinWidth}px` }}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100">
|
||||||
|
{/* Sticky empty corner cell */}
|
||||||
|
<th className="sticky left-0 z-10 bg-white px-4 py-3 text-left text-xs font-medium text-gray-700 w-28" />
|
||||||
|
{candidates.map((candidate) => {
|
||||||
|
const isWinner = candidate.id === resolvedCandidateId;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={candidate.id}
|
||||||
|
className={`px-4 py-3 text-left text-xs font-medium min-w-[160px] ${
|
||||||
|
isWinner ? "bg-amber-50 text-amber-800" : "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isWinner && (
|
||||||
|
<LucideIcon
|
||||||
|
name="trophy"
|
||||||
|
size={12}
|
||||||
|
className="text-amber-600"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{candidate.name}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ATTRIBUTE_ROWS.map((row) => (
|
||||||
|
<tr key={row.key} className="border-b border-gray-50">
|
||||||
|
{/* Sticky label cell */}
|
||||||
|
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
|
||||||
|
{row.label}
|
||||||
|
</td>
|
||||||
|
{candidates.map((candidate, index) => {
|
||||||
|
const isWinner = candidate.id === resolvedCandidateId;
|
||||||
|
const extraClass = row.cellClass
|
||||||
|
? row.cellClass(candidate)
|
||||||
|
: isWinner
|
||||||
|
? "bg-amber-50/50"
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={candidate.id}
|
||||||
|
className={`px-4 py-3 min-w-[160px] ${extraClass}`}
|
||||||
|
>
|
||||||
|
{row.render(candidate, index)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Reorder } from "framer-motion";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { CandidateCard } from "../../components/CandidateCard";
|
import { CandidateCard } from "../../components/CandidateCard";
|
||||||
import { CandidateListItem } from "../../components/CandidateListItem";
|
import { CandidateListItem } from "../../components/CandidateListItem";
|
||||||
|
import { ComparisonTable } from "../../components/ComparisonTable";
|
||||||
import {
|
import {
|
||||||
useReorderCandidates,
|
useReorderCandidates,
|
||||||
useUpdateCandidate,
|
useUpdateCandidate,
|
||||||
@@ -120,7 +121,7 @@ function ThreadDetailPage() {
|
|||||||
|
|
||||||
{/* Toolbar: Add candidate + view toggle */}
|
{/* Toolbar: Add candidate + view toggle */}
|
||||||
<div className="mb-6 flex items-center gap-3">
|
<div className="mb-6 flex items-center gap-3">
|
||||||
{isActive && (
|
{isActive && candidateViewMode !== "compare" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCandidateAddPanel}
|
onClick={openCandidateAddPanel}
|
||||||
@@ -168,6 +169,20 @@ function ThreadDetailPage() {
|
|||||||
>
|
>
|
||||||
<LucideIcon name="layout-grid" size={16} />
|
<LucideIcon name="layout-grid" size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -189,6 +204,11 @@ function ThreadDetailPage() {
|
|||||||
Add your first candidate to start comparing.
|
Add your first candidate to start comparing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : candidateViewMode === "compare" ? (
|
||||||
|
<ComparisonTable
|
||||||
|
candidates={displayItems}
|
||||||
|
resolvedCandidateId={thread.resolvedCandidateId}
|
||||||
|
/>
|
||||||
) : candidateViewMode === "list" ? (
|
) : candidateViewMode === "list" ? (
|
||||||
isActive ? (
|
isActive ? (
|
||||||
<Reorder.Group
|
<Reorder.Group
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ interface UIState {
|
|||||||
closeExternalLink: () => void;
|
closeExternalLink: () => void;
|
||||||
|
|
||||||
// Candidate view mode
|
// Candidate view mode
|
||||||
candidateViewMode: "list" | "grid";
|
candidateViewMode: "list" | "grid" | "compare";
|
||||||
setCandidateViewMode: (mode: "list" | "grid") => void;
|
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>((set) => ({
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user