Files
GearBox/.planning/phases/13-setup-impact-preview/13-01-PLAN.md
Jean-Luc Makiola a826381981
Some checks failed
CI / ci (push) Failing after 19s
docs(13): create phase plan
2026-03-17 16:53:47 +01:00

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
13-setup-impact-preview 01 tdd 1
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
true
IMPC-01
IMPC-02
IMPC-03
IMPC-04
truths artifacts key_links
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
path provides exports
src/client/lib/impactDeltas.ts Pure computeImpactDeltas function with CandidateDelta, ImpactDeltas types
computeImpactDeltas
CandidateInput
CandidateDelta
DeltaMode
ImpactDeltas
path provides exports
src/client/hooks/useImpactDeltas.ts React hook wrapping computeImpactDeltas in useMemo
useImpactDeltas
path provides contains
tests/lib/impactDeltas.test.ts Unit tests for all four IMPC requirements computeImpactDeltas
from to via pattern
src/client/hooks/useImpactDeltas.ts src/client/lib/impactDeltas.ts import computeImpactDeltas import.*computeImpactDeltas.*from.*lib/impactDeltas
from to via pattern
src/client/hooks/useImpactDeltas.ts src/client/hooks/useSetups.ts SetupItemWithCategory type import 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.

<execution_context> @/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md @/home/jlmak/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/13-setup-impact-preview/13-RESEARCH.md

From src/client/hooks/useSetups.ts:

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

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

candidateViewMode: "list" | "grid" | "compare";
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
// Add selectedSetupId + setter using same pattern

From src/client/lib/formatters.ts:

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

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