254 lines
12 KiB
Markdown
254 lines
12 KiB
Markdown
---
|
|
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>
|