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