# 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
| 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 |
---
## 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;
}
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 = {};
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 `