feat: add impact delta computation with TDD tests
Implements computeImpactDeltas pure function with 8 TDD tests covering replace/add/none modes and null weight/price handling. Adds useImpactDeltas hook, categoryId to ThreadWithCandidates, and selectedSetupId state to uiStore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
22
src/client/hooks/useImpactDeltas.ts
Normal file
22
src/client/hooks/useImpactDeltas.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
type CandidateDelta,
|
||||||
|
type CandidateInput,
|
||||||
|
computeImpactDeltas,
|
||||||
|
type DeltaMode,
|
||||||
|
type ImpactDeltas,
|
||||||
|
type SetupItemInput,
|
||||||
|
} from "../lib/impactDeltas";
|
||||||
|
|
||||||
|
export type { CandidateDelta, DeltaMode, ImpactDeltas };
|
||||||
|
|
||||||
|
export function useImpactDeltas(
|
||||||
|
candidates: CandidateInput[],
|
||||||
|
setupItems: SetupItemInput[] | undefined,
|
||||||
|
threadCategoryId: number,
|
||||||
|
): ImpactDeltas {
|
||||||
|
return useMemo(
|
||||||
|
() => computeImpactDeltas(candidates, setupItems, threadCategoryId),
|
||||||
|
[candidates, setupItems, threadCategoryId],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,21 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import type { CreateItem } from "../../shared/types";
|
import type { CreateItem } from "../../shared/types";
|
||||||
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
quantity: number;
|
||||||
|
categoryId: number;
|
||||||
|
notes: string | null;
|
||||||
|
productUrl: string | null;
|
||||||
|
imageFilename: string | null;
|
||||||
|
imageSourceUrl: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ItemWithCategory {
|
interface ItemWithCategory {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -70,3 +85,14 @@ export function useDeleteItem() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDuplicateItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => apiPost<Item>(`/api/items/${id}/duplicate`, {}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["totals"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ interface ThreadWithCandidates {
|
|||||||
name: string;
|
name: string;
|
||||||
status: "active" | "resolved";
|
status: "active" | "resolved";
|
||||||
resolvedCandidateId: number | null;
|
resolvedCandidateId: number | null;
|
||||||
|
categoryId: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
candidates: CandidateWithCategory[];
|
candidates: CandidateWithCategory[];
|
||||||
|
|||||||
69
src/client/lib/impactDeltas.ts
Normal file
69
src/client/lib/impactDeltas.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
export interface CandidateInput {
|
||||||
|
id: number;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupItemInput {
|
||||||
|
categoryId: number;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number, CandidateDelta>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeImpactDeltas(
|
||||||
|
candidates: CandidateInput[],
|
||||||
|
setupItems: SetupItemInput[] | undefined,
|
||||||
|
threadCategoryId: number,
|
||||||
|
): ImpactDeltas {
|
||||||
|
if (!setupItems) return { mode: "none", deltas: {} };
|
||||||
|
|
||||||
|
const replacedItem =
|
||||||
|
setupItems.find((item) => item.categoryId === threadCategoryId) ?? null;
|
||||||
|
const mode: DeltaMode = replacedItem ? "replace" : "add";
|
||||||
|
const deltas: Record<number, CandidateDelta> = {};
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
let weightDelta: number | null = null;
|
||||||
|
let priceDelta: number | null = null;
|
||||||
|
|
||||||
|
if (candidate.weightGrams != null) {
|
||||||
|
weightDelta =
|
||||||
|
replacedItem?.weightGrams != null
|
||||||
|
? candidate.weightGrams - replacedItem.weightGrams
|
||||||
|
: candidate.weightGrams;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.priceCents != null) {
|
||||||
|
priceDelta =
|
||||||
|
replacedItem?.priceCents != null
|
||||||
|
? candidate.priceCents - replacedItem.priceCents
|
||||||
|
: candidate.priceCents;
|
||||||
|
}
|
||||||
|
|
||||||
|
deltas[candidate.id] = {
|
||||||
|
candidateId: candidate.id,
|
||||||
|
mode,
|
||||||
|
weightDelta,
|
||||||
|
priceDelta,
|
||||||
|
replacedItemName: replacedItem?.name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode, deltas };
|
||||||
|
}
|
||||||
@@ -52,6 +52,10 @@ interface UIState {
|
|||||||
// Candidate view mode
|
// Candidate view mode
|
||||||
candidateViewMode: "list" | "grid" | "compare";
|
candidateViewMode: "list" | "grid" | "compare";
|
||||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||||
|
|
||||||
|
// Setup impact preview
|
||||||
|
selectedSetupId: number | null;
|
||||||
|
setSelectedSetupId: (id: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>((set) => ({
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
@@ -111,4 +115,8 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
// Candidate view mode
|
// Candidate view mode
|
||||||
candidateViewMode: "list",
|
candidateViewMode: "list",
|
||||||
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
||||||
|
|
||||||
|
// Setup impact preview
|
||||||
|
selectedSetupId: null,
|
||||||
|
setSelectedSetupId: (id) => set({ selectedSetupId: id }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
87
tests/lib/impactDeltas.test.ts
Normal file
87
tests/lib/impactDeltas.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { computeImpactDeltas } from "../../src/client/lib/impactDeltas";
|
||||||
|
|
||||||
|
describe("computeImpactDeltas", () => {
|
||||||
|
const candidate = { id: 1, weightGrams: 500, priceCents: 20000 };
|
||||||
|
const candidate2 = { id: 2, weightGrams: 300, priceCents: 15000 };
|
||||||
|
|
||||||
|
it("returns mode 'none' when setupItems is undefined", () => {
|
||||||
|
const result = computeImpactDeltas([candidate], undefined, 1);
|
||||||
|
expect(result.mode).toBe("none");
|
||||||
|
expect(Object.keys(result.deltas)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns replace mode when setup item matches thread category", () => {
|
||||||
|
const setupItems = [
|
||||||
|
{ categoryId: 5, weightGrams: 800, priceCents: 30000, name: "Old Tent" },
|
||||||
|
];
|
||||||
|
const result = computeImpactDeltas([candidate], setupItems, 5);
|
||||||
|
expect(result.mode).toBe("replace");
|
||||||
|
expect(result.deltas[1].weightDelta).toBe(-300); // 500 - 800
|
||||||
|
expect(result.deltas[1].priceDelta).toBe(-10000); // 20000 - 30000
|
||||||
|
expect(result.deltas[1].replacedItemName).toBe("Old Tent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns add mode when no setup item matches thread category", () => {
|
||||||
|
const setupItems = [
|
||||||
|
{ categoryId: 99, weightGrams: 200, priceCents: 5000, name: "Unrelated" },
|
||||||
|
];
|
||||||
|
const result = computeImpactDeltas([candidate], setupItems, 5);
|
||||||
|
expect(result.mode).toBe("add");
|
||||||
|
expect(result.deltas[1].weightDelta).toBe(500);
|
||||||
|
expect(result.deltas[1].priceDelta).toBe(20000);
|
||||||
|
expect(result.deltas[1].replacedItemName).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null weightDelta when candidate weight is null", () => {
|
||||||
|
const nullWeight = { id: 3, weightGrams: null, priceCents: 10000 };
|
||||||
|
const setupItems = [
|
||||||
|
{ categoryId: 5, weightGrams: 200, priceCents: 5000, name: "Item" },
|
||||||
|
];
|
||||||
|
const result = computeImpactDeltas([nullWeight], setupItems, 5);
|
||||||
|
expect(result.deltas[3].weightDelta).toBeNull();
|
||||||
|
expect(result.deltas[3].priceDelta).toBe(5000); // 10000 - 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null priceDelta when candidate price is null", () => {
|
||||||
|
const nullPrice = { id: 4, weightGrams: 500, priceCents: null };
|
||||||
|
const setupItems = [
|
||||||
|
{ categoryId: 5, weightGrams: 200, priceCents: 5000, name: "Item" },
|
||||||
|
];
|
||||||
|
const result = computeImpactDeltas([nullPrice], setupItems, 5);
|
||||||
|
expect(result.deltas[4].weightDelta).toBe(300);
|
||||||
|
expect(result.deltas[4].priceDelta).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles replace mode with null replaced item weight", () => {
|
||||||
|
const setupItems = [
|
||||||
|
{
|
||||||
|
categoryId: 5,
|
||||||
|
weightGrams: null,
|
||||||
|
priceCents: 5000,
|
||||||
|
name: "Unknown Weight",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = computeImpactDeltas([candidate], setupItems, 5);
|
||||||
|
expect(result.deltas[1].weightDelta).toBe(500); // treat as add for weight
|
||||||
|
expect(result.deltas[1].priceDelta).toBe(15000); // 20000 - 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows negative delta when candidate is lighter", () => {
|
||||||
|
const setupItems = [
|
||||||
|
{ categoryId: 5, weightGrams: 1000, priceCents: 50000, name: "Heavy" },
|
||||||
|
];
|
||||||
|
const result = computeImpactDeltas([candidate], setupItems, 5);
|
||||||
|
expect(result.deltas[1].weightDelta).toBe(-500);
|
||||||
|
expect(result.deltas[1].priceDelta).toBe(-30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple candidates", () => {
|
||||||
|
const setupItems = [
|
||||||
|
{ categoryId: 5, weightGrams: 400, priceCents: 18000, name: "Current" },
|
||||||
|
];
|
||||||
|
const result = computeImpactDeltas([candidate, candidate2], setupItems, 5);
|
||||||
|
expect(result.deltas[1].weightDelta).toBe(100); // 500 - 400
|
||||||
|
expect(result.deltas[2].weightDelta).toBe(-100); // 300 - 400
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user