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 { 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 {
|
||||
id: number;
|
||||
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;
|
||||
status: "active" | "resolved";
|
||||
resolvedCandidateId: number | null;
|
||||
categoryId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
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
|
||||
candidateViewMode: "list" | "grid" | "compare";
|
||||
setCandidateViewMode: (mode: "list" | "grid" | "compare") => void;
|
||||
|
||||
// Setup impact preview
|
||||
selectedSetupId: number | null;
|
||||
setSelectedSetupId: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
@@ -111,4 +115,8 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
// Candidate view mode
|
||||
candidateViewMode: "list",
|
||||
setCandidateViewMode: (mode) => set({ candidateViewMode: mode }),
|
||||
|
||||
// Setup impact preview
|
||||
selectedSetupId: null,
|
||||
setSelectedSetupId: (id) => set({ selectedSetupId: id }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user