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
path
provides
exports
src/client/components/SetupImpactSelector.tsx
Setup dropdown for thread header
SetupImpactSelector
path
provides
exports
src/client/components/ImpactDeltaBadge.tsx
Inline delta indicator component
ImpactDeltaBadge
path
provides
src/client/routes/threads/$threadId.tsx
Thread detail page wired with impact preview
path
provides
src/client/components/CandidateListItem.tsx
List item with delta badges
path
provides
src/client/components/CandidateCard.tsx
Card with delta badges
path
provides
src/client/components/ComparisonTable.tsx
Comparison table with impact delta rows
from
to
via
pattern
src/client/routes/threads/$threadId.tsx
src/client/hooks/useImpactDeltas.ts
useImpactDeltas hook call at page level
useImpactDeltas
from
to
via
pattern
src/client/routes/threads/$threadId.tsx
src/client/hooks/useSetups.ts
useSetup(selectedSetupId) for setup item data
useSetup(selectedSetupId
from
to
via
pattern
src/client/routes/threads/$threadId.tsx
src/client/stores/uiStore.ts
selectedSetupId state read
useUIStore.*selectedSetupId
from
to
via
pattern
src/client/components/SetupImpactSelector.tsx
src/client/stores/uiStore.ts
setSelectedSetupId state write
setSelectedSetupId
from
to
via
pattern
src/client/components/ImpactDeltaBadge.tsx
src/client/lib/impactDeltas.ts
CandidateDelta type import
import.*CandidateDelta
from
to
via
pattern
src/client/components/ComparisonTable.tsx
src/client/lib/impactDeltas.ts
ImpactDeltas type for deltas prop
import.*ImpactDeltas
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.
Existing component props that need delta additions:
CandidateListItem props:
interfaceCandidateListItemProps{candidate: CandidateWithCategory;rank: number;isActive: boolean;onStatusChange:(status:"researching"|"ordered"|"arrived")=>void;// Will add: delta?: CandidateDelta;
}
CandidateCard props:
interfaceCandidateCardProps{id: number;name: string;weightGrams: number|null;priceCents: number|null;// ... other props
// Will add: delta?: CandidateDelta;
}
ComparisonTable props:
interfaceComparisonTableProps{candidates: CandidateWithCategory[];resolvedCandidateId: number|null;// Will add: deltas?: Record<number, CandidateDelta>;
}
Task 1: Create SetupImpactSelector and ImpactDeltaBadge components
src/client/components/SetupImpactSelector.tsx, src/client/components/ImpactDeltaBadge.tsx
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 `` 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.
cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | head -20
SetupImpactSelector renders a setup dropdown reading from uiStore. ImpactDeltaBadge renders signed, colored delta indicators with null-data handling. Both components lint-clean.
Task 2: Wire impact preview into thread detail page and all candidate views
src/client/routes/threads/$threadId.tsx, src/client/components/CandidateListItem.tsx, src/client/components/CandidateCard.tsx, src/client/components/ComparisonTable.tsx
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 `` 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).
cd /home/jlmak/Projects/jlmak/GearBox && bun test && bun run lint 2>&1 | head -20
- 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
- `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)
<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>
After completion, create `.planning/phases/13-setup-impact-preview/13-02-SUMMARY.md`