# 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)
