--- 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 After completion, create `.planning/phases/13-setup-impact-preview/13-01-SUMMARY.md`