diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index a029ffe..bcc4bd6 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -96,7 +96,10 @@ Plans: 2. When the selected setup contains an item in the same category as the thread, the delta reflects replacing that item (negative delta is possible) rather than pure addition 3. When no category match exists in the selected setup, the delta shows a pure addition amount clearly labeled as "add" 4. A candidate with no weight recorded shows a "-- (no weight data)" indicator instead of a zero delta -**Plans**: TBD +**Plans:** 2 plans +Plans: +- [ ] 13-01-PLAN.md — TDD pure impact delta computation, uiStore state, ThreadWithCandidates type fix, useImpactDeltas hook +- [ ] 13-02-PLAN.md — SetupImpactSelector + ImpactDeltaBadge components, wire into thread detail and all candidate views ## Progress @@ -114,4 +117,4 @@ Plans: | 10. Schema Foundation + Pros/Cons Fields | v1.3 | 1/1 | Complete | 2026-03-16 | | 11. Candidate Ranking | 2/2 | Complete | 2026-03-16 | - | | 12. Comparison View | 1/1 | Complete | 2026-03-17 | - | -| 13. Setup Impact Preview | v1.3 | 0/TBD | Not started | - | +| 13. Setup Impact Preview | v1.3 | 0/2 | Not started | - | diff --git a/.planning/phases/13-setup-impact-preview/13-01-PLAN.md b/.planning/phases/13-setup-impact-preview/13-01-PLAN.md new file mode 100644 index 0000000..67cc332 --- /dev/null +++ b/.planning/phases/13-setup-impact-preview/13-01-PLAN.md @@ -0,0 +1,253 @@ +--- +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` + diff --git a/.planning/phases/13-setup-impact-preview/13-02-PLAN.md b/.planning/phases/13-setup-impact-preview/13-02-PLAN.md new file mode 100644 index 0000000..dfc9ef4 --- /dev/null +++ b/.planning/phases/13-setup-impact-preview/13-02-PLAN.md @@ -0,0 +1,330 @@ +--- +phase: 13-setup-impact-preview +plan: 02 +type: execute +wave: 2 +depends_on: ["13-01"] +files_modified: + - src/client/components/SetupImpactSelector.tsx + - src/client/components/ImpactDeltaBadge.tsx + - src/client/routes/threads/$threadId.tsx + - src/client/components/CandidateListItem.tsx + - src/client/components/CandidateCard.tsx + - src/client/components/ComparisonTable.tsx +autonomous: true +requirements: [IMPC-01, IMPC-02, IMPC-03, IMPC-04] + +must_haves: + truths: + - "User can select a setup from a dropdown in the thread header" + - "Each candidate displays weight and cost delta badges when a setup is selected" + - "Replace mode shows signed delta with replaced item name context" + - "Add mode shows positive delta labeled as '(add)'" + - "Candidate with no weight shows '-- (no weight data)' instead of a zero" + - "Candidate with no price shows '-- (no price data)' instead of a zero" + - "Deselecting setup ('None') clears all delta indicators" + - "Deltas appear in list view, grid view, and comparison table" + artifacts: + - path: "src/client/components/SetupImpactSelector.tsx" + provides: "Setup dropdown for thread header" + exports: ["SetupImpactSelector"] + - path: "src/client/components/ImpactDeltaBadge.tsx" + provides: "Inline delta indicator component" + exports: ["ImpactDeltaBadge"] + - path: "src/client/routes/threads/$threadId.tsx" + provides: "Thread detail page wired with impact preview" + - path: "src/client/components/CandidateListItem.tsx" + provides: "List item with delta badges" + - path: "src/client/components/CandidateCard.tsx" + provides: "Card with delta badges" + - path: "src/client/components/ComparisonTable.tsx" + provides: "Comparison table with impact delta rows" + key_links: + - from: "src/client/routes/threads/$threadId.tsx" + to: "src/client/hooks/useImpactDeltas.ts" + via: "useImpactDeltas hook call at page level" + pattern: "useImpactDeltas" + - from: "src/client/routes/threads/$threadId.tsx" + to: "src/client/hooks/useSetups.ts" + via: "useSetup(selectedSetupId) for setup item data" + pattern: "useSetup\\(selectedSetupId" + - from: "src/client/routes/threads/$threadId.tsx" + to: "src/client/stores/uiStore.ts" + via: "selectedSetupId state read" + pattern: "useUIStore.*selectedSetupId" + - from: "src/client/components/SetupImpactSelector.tsx" + to: "src/client/stores/uiStore.ts" + via: "setSelectedSetupId state write" + pattern: "setSelectedSetupId" + - from: "src/client/components/ImpactDeltaBadge.tsx" + to: "src/client/lib/impactDeltas.ts" + via: "CandidateDelta type import" + pattern: "import.*CandidateDelta" + - from: "src/client/components/ComparisonTable.tsx" + to: "src/client/lib/impactDeltas.ts" + via: "ImpactDeltas type for deltas prop" + pattern: "import.*ImpactDeltas" +--- + + +Build the UI components (setup dropdown + delta badges) and wire them into the thread detail page across all three view modes (list, grid, compare). + +Purpose: This is the user-facing delivery of the impact preview feature. Plan 01 built the logic; this plan renders it. +Output: SetupImpactSelector component, ImpactDeltaBadge component, updated CandidateListItem/CandidateCard/ComparisonTable with delta rendering, wired thread detail page. + + + +@/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 +@.planning/phases/13-setup-impact-preview/13-01-SUMMARY.md + + + + +From src/client/lib/impactDeltas.ts (created in Plan 01): +```typescript +export interface CandidateInput { + id: number; + weightGrams: number | null; + priceCents: number | null; +} + +export type DeltaMode = "replace" | "add" | "none"; + +export interface CandidateDelta { + candidateId: number; + mode: DeltaMode; + weightDelta: number | null; + priceDelta: number | null; + replacedItemName: string | null; +} + +export interface ImpactDeltas { + mode: DeltaMode; + deltas: Record; +} +``` + +From src/client/hooks/useImpactDeltas.ts (created in Plan 01): +```typescript +export function useImpactDeltas( + candidates: CandidateInput[], + setupItems: SetupItemWithCategory[] | undefined, + threadCategoryId: number, +): ImpactDeltas; +``` + +From src/client/hooks/useSetups.ts: +```typescript +export function useSetups(): UseQueryResult; +export function useSetup(setupId: number | null): UseQueryResult; +``` + +From src/client/stores/uiStore.ts (updated in Plan 01): +```typescript +selectedSetupId: number | null; +setSelectedSetupId: (id: number | null) => void; +``` + +From src/client/hooks/useThreads.ts (updated in Plan 01): +```typescript +interface ThreadWithCandidates { + id: number; + name: string; + status: "active" | "resolved"; + resolvedCandidateId: number | null; + categoryId: number; // <-- Added in Plan 01 + createdAt: string; + updatedAt: string; + candidates: CandidateWithCategory[]; +} +``` + +From src/client/lib/formatters.ts: +```typescript +export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string; +export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string; +``` + +Existing component props that need delta additions: + +CandidateListItem props: +```typescript +interface CandidateListItemProps { + candidate: CandidateWithCategory; + rank: number; + isActive: boolean; + onStatusChange: (status: "researching" | "ordered" | "arrived") => void; + // Will add: delta?: CandidateDelta; +} +``` + +CandidateCard props: +```typescript +interface CandidateCardProps { + id: number; + name: string; + weightGrams: number | null; + priceCents: number | null; + // ... other props + // Will add: delta?: CandidateDelta; +} +``` + +ComparisonTable props: +```typescript +interface ComparisonTableProps { + candidates: CandidateWithCategory[]; + resolvedCandidateId: number | null; + // Will add: deltas?: Record; +} +``` + + + + + + + Task 1: Create SetupImpactSelector and ImpactDeltaBadge components + src/client/components/SetupImpactSelector.tsx, src/client/components/ImpactDeltaBadge.tsx + + 1. Create src/client/components/SetupImpactSelector.tsx: + - Import useSetups from hooks/useSetups + - Import useUIStore from stores/uiStore + - Export function SetupImpactSelector() + - Read selectedSetupId and setSelectedSetupId from uiStore + - Fetch setups via useSetups() + - If no setups or loading, return null + - Render: a flex row with label "Impact on setup:" (text-xs text-gray-500) and a native `