---
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"
---
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.
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
@/home/jlmak/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/13-setup-impact-preview/13-RESEARCH.md
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(`/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;
```
Impact Delta Computation
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
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
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 }
- 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
Task 1: TDD pure computeImpactDeltas function
src/client/lib/impactDeltas.ts, tests/lib/impactDeltas.test.ts
- 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
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.
cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/lib/impactDeltas.test.ts
All 9+ test cases pass. computeImpactDeltas correctly handles replace mode, add mode, null weights, null prices, and edge cases. Types are exported.
Task 2: Add uiStore state, fix ThreadWithCandidates type, create useImpactDeltas hook
src/client/stores/uiStore.ts, src/client/hooks/useThreads.ts, src/client/hooks/useImpactDeltas.ts
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
cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/lib/impactDeltas.test.ts && bun test tests/lib/formatters.test.ts
uiStore has selectedSetupId + setter. ThreadWithCandidates includes categoryId. useImpactDeltas hook created and exports types. All existing tests still pass.
- `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
- 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