diff --git a/docs/superpowers/plans/2026-07-29-order-sprint-item-history.md b/docs/superpowers/plans/2026-07-29-order-sprint-item-history.md new file mode 100644 index 0000000..e816d39 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-order-sprint-item-history.md @@ -0,0 +1,1528 @@ +# Order Sprint Item History Popover Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild the per-item ⓘ popover on `/sprint` so it shows actionable history (cheapest supplier, other quotes, last sold, recommendation). Build both a deterministic PB-join service and keep the existing n8n AI blob; render side by side for comparison; delete whichever loses after a 1-week bake-in. + +**Architecture:** Three layers — (1) `item-history-service.ts` does deterministic PB joins, (2) existing n8n `ai_item_insights` blob keeps flowing unchanged, (3) new `item-history-popover.tsx` renders both. Single endpoint `GET /api/v1/quote_items/[id]/item-history` composes both and honours `ITEM_HISTORY_COMPARE_MODE`. + +**Tech Stack:** Next.js App Router, TypeScript, Vitest, React Testing Library, PocketBase admin SDK. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/lib/types.ts` | modify | Add `ItemHistory`, `ItemHistorySupplier`, `ItemHistoryOtherQuote`, `ItemHistoryLastSold`, `ItemHistoryStock`, `ItemHistoryMatch`, `ItemHistoryCheapest` | +| `src/lib/server/item-history-service.ts` | create | Pure matching helpers (`findMatchingItems`, `findMatchingItemByDescription`) + `getItemHistory(itemId)` | +| `src/lib/server/item-history-service.test.ts` | create | Unit tests for matching helpers + service test with mocked PB | +| `src/app/api/v1/quote_items/[id]/item-history/route.ts` | create | Composes deterministic service + AI blob read, honours env var | +| `src/components/item-history-popover.tsx` | create | Side-by-side renderer with four render states | +| `src/components/item-history-popover.test.tsx` | create | Component tests for the four render states | +| `src/components/ai-item-history-trigger.tsx` | modify | Fetch the new endpoint instead of reading `ai_item_insights` inline | + +--- + +## Task 1: Add `ItemHistory` types to `src/lib/types.ts` + +**Files:** +- Modify: `src/lib/types.ts` +- Test: type-check only + +- [ ] **Step 1: Append the new types after the existing `QuoteItem` block** + +Open `src/lib/types.ts` and append at the end of the file: + +```ts +// ─── Item history popover ──────────────────────────────────────────── + +/** Per-supplier purchase history for a single item. */ +export interface ItemHistorySupplier { + supplier_id: string; + supplier_name: string; + last_cost: number; + last_bought_at: string; + times_bought: number; + last_order_id: string; +} + +/** Other quotes to the same customer / other companies for the same SKU. */ +export interface ItemHistoryOtherQuote { + quote_id: string; + quote_number: string; + company: string; + customer_name: string; + status: string; + selling_price: number; + quoted_at: string; + days_ago: number; +} + +/** Last time this SKU was on a fulfilled order. */ +export interface ItemHistoryLastSold { + selling_price: number; + sold_at: string; + order_id: string; + order_number: string; + company: string; + days_ago: number; +} + +/** Stock snapshot at the matched supplier. */ +export interface ItemHistoryStock { + in_stock: number; + status: "in_stock" | "low" | "out"; +} + +/** Derived cheapest supplier + cost. */ +export interface ItemHistoryCheapest { + supplier_id: string; + supplier_name: string; + last_cost: number; +} + +/** How the service matched "same item". */ +export interface ItemHistoryMatch { + sku: string; + match_kind: "sku_exact" | "sku_normalised" | "description_fuzzy" | "none"; + match_confidence: number; // 0..1 +} + +/** Aggregate returned by `getItemHistory`. */ +export interface ItemHistory { + generated_at: string; + suppliers: ItemHistorySupplier[]; + other_quotes: ItemHistoryOtherQuote[]; + last_sold: ItemHistoryLastSold | null; + stock: ItemHistoryStock | null; + cheapest: ItemHistoryCheapest | null; + match: ItemHistoryMatch; +} + +/** Response shape for `/api/v1/quote_items/[id]/item-history`. */ +export interface ItemHistoryResponse { + deterministic: ItemHistory | null; + ai: Record | null; + ai_updated_at: string | null; + generated_at: string; + error?: string; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/types.ts +git commit -m "feat(types): add ItemHistory types for new popover" +``` + +--- + +## Task 2: Pure SKU matching helpers in `item-history-service.ts` + +**Files:** +- Create: `src/lib/server/item-history-service.ts` +- Create: `src/lib/server/item-history-service.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/item-history-service.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { + normaliseSku, + findMatchingItemsBySku, +} from "@/lib/server/item-history-service"; + +describe("SKU matching", () => { + it("normaliseSku trims, uppercases, and strips leading zeros", () => { + expect(normaliseSku(" drl-06mm ")).toBe("DRL-06MM"); + expect(normaliseSku("DRL-006MM")).toBe("DRL-6MM"); + expect(normaliseSku("DRL 6MM")).toBe("DRL-6MM"); + expect(normaliseSku("")).toBe(""); + }); + + it("findMatchingItemsBySku returns exact + normalised matches", async () => { + const target = { sku: "DRL-6MM" }; + const candidates = [ + { id: "a", sku: "DRL-6MM" }, // exact + { id: "b", sku: "DRL-006MM" }, // normalised + { id: "c", sku: "SCREW-6MM" }, // no match + ]; + const matched = await findMatchingItemsBySku(target, candidates, "DRL-6MM"); + const ids = matched.map((m) => m.id).sort(); + expect(ids).toEqual(["a", "b"]); + }); + + it("findMatchingItemsBySku returns empty when target sku is empty", async () => { + const matched = await findMatchingItemsBySku({ sku: "" }, [{ id: "a", sku: "X" }], ""); + expect(matched).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run the test — verify it fails** + +Run: `npm run test:run -- src/lib/server/item-history-service.test.ts` +Expected: FAIL with "Cannot find module '@/lib/server/item-history-service'" or similar. + +- [ ] **Step 3: Write the minimal implementation** + +Create `src/lib/server/item-history-service.ts`: + +```ts +/** + * item-history-service — deterministic item history for the ⓘ popover. + * + * Two layers: + * 1. Pure matching helpers (testable in isolation, no PB). + * 2. `getItemHistory(itemId)` — runs the four PB queries, applies the + * matchers, returns a typed `ItemHistory`. + * + * The matching helpers are exported so other features (process orders, + * price-list backfill) can reuse them. Keep them PB-free. + */ + +import { getAdminPocketBase } from "./pocketbase-admin"; +import type { + ItemHistory, + ItemHistoryMatch, + ItemHistorySupplier, + ItemHistoryOtherQuote, + ItemHistoryLastSold, + ItemHistoryStock, + ItemHistoryCheapest, +} from "@/lib/types"; + +// ─── Pure helpers ──────────────────────────────────────────────────── + +/** Lowercase, trim, collapse separators, strip leading zeros. */ +export function normaliseSku(sku: string): string { + if (!sku) return ""; + return sku + .trim() + .toUpperCase() + .replace(/[\s_]+/g, "-") + .replace(/^0+(?=\d)/g, ""); +} + +/** Match by SKU exact or normalised. Returns matches with `match_kind`. */ +export async function findMatchingItemsBySku< + T extends { id: string; sku?: string | null } +>( + target: { sku?: string | null }, + candidates: T[], + normalisedTargetSku?: string +): Promise> { + const targetNorm = + normalisedTargetSku !== undefined + ? normalisedTargetSku + : normaliseSku(target.sku || ""); + const targetRaw = (target.sku || "").trim(); + + if (!targetNorm) return []; + + const out: Array<{ item: T; kind: "sku_exact" | "sku_normalised" }> = []; + for (const c of candidates) { + const raw = (c.sku || "").trim(); + if (raw && raw === targetRaw) { + out.push({ item: c, kind: "sku_exact" }); + continue; + } + if (normaliseSku(raw) === targetNorm) { + out.push({ item: c, kind: "sku_normalised" }); + } + } + return out; +} +``` + +- [ ] **Step 4: Run the test — verify it passes** + +Run: `npm run test:run -- src/lib/server/item-history-service.test.ts` +Expected: PASS, 3 tests passing. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/item-history-service.ts src/lib/server/item-history-service.test.ts +git commit -m "feat(item-history): add SKU exact + normalised matchers" +``` + +--- + +## Task 3: Pure description-fuzzy matcher (Jaccard) + +**Files:** +- Modify: `src/lib/server/item-history-service.ts` (add the function) +- Modify: `src/lib/server/item-history-service.test.ts` (add tests) + +- [ ] **Step 1: Append failing test** + +Append to `src/lib/server/item-history-service.test.ts`: + +```ts +import { findBestDescriptionMatch } from "@/lib/server/item-history-service"; + +describe("description-fuzzy matching", () => { + it("returns null when no candidate clears the 0.6 threshold", () => { + const target = { id: "t", description: "Cable gland M20 brass" }; + const candidates = [ + { id: "a", description: "Hammer 16oz claw steel" }, + { id: "b", description: "Paint brush 50mm" }, + ]; + expect(findBestDescriptionMatch(target, candidates, 0.6)).toBeNull(); + }); + + it("returns the best match with the right confidence", () => { + const target = { id: "t", description: "Cable gland M20 brass nickel plated" }; + const candidates = [ + // Same item, slightly different wording → high Jaccard + { id: "a", description: "M20 brass cable gland nickel plated" }, + // Tangentially related → low + { id: "b", description: "Cable ties M20 black nylon" }, + ]; + const match = findBestDescriptionMatch(target, candidates, 0.4); + expect(match).not.toBeNull(); + expect(match!.item.id).toBe("a"); + expect(match!.confidence).toBeGreaterThan(0.6); + }); + + it("confidence is below 1.0 for any fuzzy match", () => { + const target = { id: "t", description: "Drill bit 6mm HSS" }; + const candidates = [{ id: "a", description: "Drill bit 6mm HSS" }]; + const match = findBestDescriptionMatch(target, candidates, 0.0); + expect(match).not.toBeNull(); + expect(match!.confidence).toBeLessThan(1.0); + }); +}); +``` + +- [ ] **Step 2: Run test — verify the new ones fail** + +Run: `npm run test:run -- src/lib/server/item-history-service.test.ts` +Expected: FAIL — `findBestDescriptionMatch` is not exported. + +- [ ] **Step 3: Add the implementation** + +Append to `src/lib/server/item-history-service.ts`: + +```ts +import { tokenize, jaccard } from "@/lib/server/order-quote-match"; + +export interface DescriptionMatch { + item: T; + confidence: number; // < 1.0 always (callers gate on threshold) +} + +/** Return the best fuzzy description match above `threshold`, or null. */ +export function findBestDescriptionMatch< + T extends { id: string; description?: string | null } +>( + target: T, + candidates: T[], + threshold: number +): DescriptionMatch | null { + const targetTokens = tokenize(target.description || ""); + if (targetTokens.size === 0) return null; + + let best: { item: T; score: number } | null = null; + for (const c of candidates) { + const cTokens = tokenize(c.description || ""); + if (cTokens.size === 0) continue; + const score = jaccard(targetTokens, cTokens); + if (!best || score > best.score) { + best = { item: c, score }; + } + } + if (!best || best.score < threshold) return null; + return { item: best.item, confidence: best.score }; +} +``` + +- [ ] **Step 4: Run test — verify all pass** + +Run: `npm run test:run -- src/lib/server/item-history-service.test.ts` +Expected: PASS, 6 tests passing. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/item-history-service.ts src/lib/server/item-history-service.test.ts +git commit -m "feat(item-history): add Jaccard description matcher" +``` + +--- + +## Task 4: Pure aggregation helpers (cheapest, days_ago, zero-cost filter) + +**Files:** +- Modify: `src/lib/server/item-history-service.ts` (add helpers) +- Modify: `src/lib/server/item-history-service.test.ts` (add tests) + +- [ ] **Step 1: Append failing tests** + +Append to `src/lib/server/item-history-service.test.ts`: + +```ts +import { computeCheapest, daysAgo, isValidPrice } from "@/lib/server/item-history-service"; + +describe("aggregation helpers", () => { + it("computeCheapest picks the lowest last_cost", () => { + const suppliers = [ + { supplier_id: "a", supplier_name: "A", last_cost: 50, last_bought_at: "", times_bought: 1, last_order_id: "" }, + { supplier_id: "b", supplier_name: "B", last_cost: 42, last_bought_at: "", times_bought: 2, last_order_id: "" }, + { supplier_id: "c", supplier_name: "C", last_cost: 99, last_bought_at: "", times_bought: 5, last_order_id: "" }, + ]; + expect(computeCheapest(suppliers)).toEqual({ + supplier_id: "b", + supplier_name: "B", + last_cost: 42, + }); + }); + + it("computeCheapest returns null for empty list", () => { + expect(computeCheapest([])).toBeNull(); + }); + + it("computeCheapest ignores suppliers with zero cost", () => { + const suppliers = [ + { supplier_id: "a", supplier_name: "A", last_cost: 0, last_bought_at: "", times_bought: 1, last_order_id: "" }, + { supplier_id: "b", supplier_name: "B", last_cost: 30, last_bought_at: "", times_bought: 2, last_order_id: "" }, + ]; + expect(computeCheapest(suppliers)?.supplier_id).toBe("b"); + }); + + it("daysAgo returns 0 for now, larger for older", () => { + const now = new Date("2026-07-29T12:00:00Z"); + expect(daysAgo("2026-07-29T12:00:00Z", now)).toBe(0); + expect(daysAgo("2026-07-22T12:00:00Z", now)).toBe(7); + expect(daysAgo("2026-06-29T12:00:00Z", now)).toBe(30); + }); + + it("isValidPrice rejects zero and negative", () => { + expect(isValidPrice(50)).toBe(true); + expect(isValidPrice(0)).toBe(false); + expect(isValidPrice(-1)).toBe(false); + expect(isValidPrice(NaN)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test — verify the new ones fail** + +Run: `npm run test:run -- src/lib/server/item-history-service.test.ts` +Expected: FAIL — `computeCheapest`, `daysAgo`, `isValidPrice` not exported. + +- [ ] **Step 3: Add the implementation** + +Append to `src/lib/server/item-history-service.ts`: + +```ts +/** Pick the supplier with the lowest valid (non-zero) last_cost. */ +export function computeCheapest( + suppliers: ItemHistorySupplier[] +): ItemHistoryCheapest | null { + const valid = suppliers.filter((s) => s.last_cost > 0); + if (valid.length === 0) return null; + const cheapest = valid.reduce((min, s) => + s.last_cost < min.last_cost ? s : min + ); + return { + supplier_id: cheapest.supplier_id, + supplier_name: cheapest.supplier_name, + last_cost: cheapest.last_cost, + }; +} + +/** Days between `iso` and `now`. Negative clamped to 0. */ +export function daysAgo(iso: string, now: Date = new Date()): number { + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return 0; + const diffMs = now.getTime() - t; + if (diffMs <= 0) return 0; + return Math.floor(diffMs / (24 * 60 * 60 * 1000)); +} + +/** True for finite, positive prices. Filters the "operator forgot to fill it" rows. */ +export function isValidPrice(v: number | null | undefined): boolean { + return typeof v === "number" && Number.isFinite(v) && v > 0; +} +``` + +- [ ] **Step 4: Run test — verify all pass** + +Run: `npm run test:run -- src/lib/server/item-history-service.test.ts` +Expected: PASS, 11 tests passing. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/item-history-service.ts src/lib/server/item-history-service.test.ts +git commit -m "feat(item-history): add aggregation helpers (cheapest, days_ago, valid price)" +``` + +--- + +## Task 5: `getItemHistory` service function with mocked PB + +**Files:** +- Modify: `src/lib/server/item-history-service.ts` (add service) +- Modify: `src/lib/server/item-history-service.test.ts` (add service test) + +- [ ] **Step 1: Append the failing service test** + +Append to `src/lib/server/item-history-service.test.ts`: + +```ts +import { getItemHistory } from "@/lib/server/item-history-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn(), +})); + +describe("getItemHistory service", () => { + it("returns a populated history when the SKU matches across collections", async () => { + const ordersBySupplier = { + items: [ + { id: "po1", supplier: "sup-a", cost_price: 42, updated: "2026-07-11T10:00:00Z" }, + { id: "po2", supplier: "sup-b", cost_price: 48, updated: "2026-07-24T10:00:00Z" }, + { id: "po3", supplier: "sup-a", cost_price: 0, updated: "2026-07-01T10:00:00Z" }, // zero-cost filtered + ], + }; + const otherQuotes = { + items: [ + { + id: "qi1", + quote: "q1", + selling_price: 85, + created: "2026-07-11T10:00:00Z", + expand: { + quote: { + quote_number: "Q-26070112", + status: "approved", + customer_name: "MegaCorp", + company: "MegaCorp (Pty) Ltd", + }, + }, + }, + ], + }; + const lastSold = { items: [] }; + const stock = { items: [{ sku: "DRL-6MM", in_stock: 240, status: "in_stock" }] }; + const targetItem = { + id: "qi-target", + sku: "DRL-6MM", + description: "Drill bit 6mm HSS", + }; + const suppliersById = { + items: [ + { id: "sup-a", suppliername: "Fastener Co" }, + { id: "sup-b", suppliername: "Tool Depot" }, + ], + }; + + const mockPb = { + collection: vi.fn((name: string) => { + if (name === "quote_items") { + return { + getOne: vi.fn().mockResolvedValue(targetItem), + getList: vi.fn().mockResolvedValue(otherQuotes), + }; + } + if (name === "order_items") { + return { + getList: vi.fn().mockResolvedValue(ordersBySupplier), + }; + } + if (name === "stock_purchase_items") { + return { + getList: vi.fn().mockResolvedValue(stock), + }; + } + if (name === "suppliers") { + return { + getList: vi.fn().mockResolvedValue(suppliersById), + }; + } + return { getList: vi.fn().mockResolvedValue({ items: [] }), getOne: vi.fn() }; + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(mockPb as never); + + const history = await getItemHistory("qi-target"); + + expect(history.match.match_kind).toBe("sku_exact"); + expect(history.match.match_confidence).toBe(1); + expect(history.suppliers).toHaveLength(2); + expect(history.suppliers.map((s) => s.supplier_name).sort()).toEqual(["Fastener Co", "Tool Depot"]); + expect(history.cheapest).toEqual({ supplier_id: "sup-a", supplier_name: "Fastener Co", last_cost: 42 }); + expect(history.other_quotes).toHaveLength(1); + expect(history.other_quotes[0].quote_number).toBe("Q-26070112"); + expect(history.stock).toEqual({ in_stock: 240, status: "in_stock" }); + expect(history.last_sold).toBeNull(); + expect(history.generated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("returns empty match when SKU is empty and no fuzzy hit", async () => { + const targetItem = { id: "qi-x", sku: "", description: "" }; + const mockPb = { + collection: vi.fn(() => ({ + getOne: vi.fn().mockResolvedValue(targetItem), + getList: vi.fn().mockResolvedValue({ items: [] }), + })), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(mockPb as never); + + const history = await getItemHistory("qi-x"); + expect(history.match.match_kind).toBe("none"); + expect(history.suppliers).toEqual([]); + expect(history.other_quotes).toEqual([]); + expect(history.cheapest).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test — verify the new ones fail** + +Run: `npm run test:run -- src/lib/server/item-history-service.test.ts` +Expected: FAIL — `getItemHistory` is not exported. + +- [ ] **Step 3: Add the service implementation** + +Append to `src/lib/server/item-history-service.ts`: + +```ts +type PB = Awaited>; + +/** Fetch the deterministic item history for a `quote_items` row. */ +export async function getItemHistory(itemId: string): Promise { + const pb = await getAdminPocketBase(); + const generated_at = new Date().toISOString(); + + // 1. Load the target item. + const target = await pb.collection("quote_items").getOne<{ + id: string; + sku?: string; + description?: string; + }>(itemId); + + const match: ItemHistoryMatch = { + sku: target.sku || "", + match_kind: "none", + match_confidence: 0, + }; + + // Bail early when there's nothing to match on. + if (!target.sku && !target.description) { + return emptyHistory(generated_at, match); + } + + // 2. Fan out 5 PB reads in parallel: order_items by SKU, quote_items by SKU + // (other quotes), order_items delivered by SKU (last sold), + // stock_purchase_items by SKU (stock), suppliers (for name enrichment). + const [orderItems, quoteItems, lastSoldRows, stockRows, suppliersRows] = await Promise.all([ + target.sku + ? pb.collection("order_items").getList<{ + id: string; + supplier: string; + cost_price: number; + updated: string; + }>(1, 100, { filter: `sku = "${target.sku.replace(/"/g, '\\"')}"` }) + : Promise.resolve({ items: [] as Array<{ id: string; supplier: string; cost_price: number; updated: string }> }), + target.sku + ? pb.collection("quote_items").getList<{ + id: string; + quote: string; + selling_price: number; + created: string; + expand?: { quote?: { quote_number: string; status: string; customer_name?: string; company?: string } }; + }>(1, 100, { + filter: `sku = "${target.sku.replace(/"/g, '\\"')}" && id != "${itemId}"`, + expand: "quote", + }) + : Promise.resolve({ items: [] as Array<{ id: string; quote: string; selling_price: number; created: string; expand?: { quote?: { quote_number: string; status: string; customer_name?: string; company?: string } } }> }), + target.sku + ? pb.collection("order_items").getList<{ + id: string; + selling_price: number; + updated: string; + order: string; + expand?: { order?: { order_number: string; company?: string } }; + }>(1, 1, { + filter: `sku = "${target.sku.replace(/"/g, '\\"')}" && item_status = "delivered"`, + sort: "-updated", + expand: "order", + }) + : Promise.resolve({ items: [] as Array<{ id: string; selling_price: number; updated: string; order: string; expand?: { order?: { order_number: string; company?: string } } }> }), + target.sku + ? pb.collection("stock_purchase_items").getList<{ + sku: string; + in_stock: number; + status: string; + }>(1, 50, { filter: `sku = "${target.sku.replace(/"/g, '\\"')}"` }) + : Promise.resolve({ items: [] as Array<{ sku: string; in_stock: number; status: string }> }), + // Suppliers list — capped at 200, full names for any ID we encounter. + pb.collection("suppliers").getList<{ id: string; suppliername: string }>(1, 200), + ]); + + const supplierNameById = new Map(suppliersRows.items.map((s) => [s.id, s.suppliername])); + + // 3. Build suppliers list (group by supplier, last cost, last bought). + const suppliers = aggregateSuppliers(orderItems.items, supplierNameById); + + // 4. Map other quotes. + const other_quotes: ItemHistoryOtherQuote[] = quoteItems.items + .filter((q) => isValidPrice(q.selling_price)) + .map((q) => ({ + quote_id: q.quote, + quote_number: q.expand?.quote?.quote_number || "—", + company: q.expand?.quote?.company || "—", + customer_name: q.expand?.quote?.customer_name || "—", + status: q.expand?.quote?.status || "", + selling_price: q.selling_price, + quoted_at: q.created, + days_ago: daysAgo(q.created), + })); + + // 5. Last sold. + const lastSold = lastSoldRows.items[0]; + const last_sold: ItemHistoryLastSold | null = lastSold && isValidPrice(lastSold.selling_price) + ? { + selling_price: lastSold.selling_price, + sold_at: lastSold.updated, + order_id: lastSold.order, + order_number: lastSold.expand?.order?.order_number || "—", + company: lastSold.expand?.order?.company || "—", + days_ago: daysAgo(lastSold.updated), + } + : null; + + // 6. Stock. + const stockRow = stockRows.items[0]; + const stock: ItemHistoryStock | null = stockRow + ? { + in_stock: stockRow.in_stock, + status: stockRow.status as ItemHistoryStock["status"], + } + : null; + + match.match_kind = target.sku ? "sku_exact" : "description_fuzzy"; + match.match_confidence = target.sku ? 1 : 0.7; + + return { + generated_at, + suppliers, + other_quotes, + last_sold, + stock, + cheapest: computeCheapest(suppliers), + match, + }; +} + +// ─── Internal helpers ──────────────────────────────────────────────── + +function emptyHistory(generated_at: string, match: ItemHistoryMatch): ItemHistory { + return { + generated_at, + suppliers: [], + other_quotes: [], + last_sold: null, + stock: null, + cheapest: null, + match, + }; +} + +function aggregateSuppliers( + rows: Array<{ id: string; supplier: string; cost_price: number; updated: string }>, + nameById: Map +): ItemHistorySupplier[] { + const bySupplier = new Map(); + for (const r of rows) { + if (!r.supplier) continue; + const prev = bySupplier.get(r.supplier); + if (!prev) { + bySupplier.set(r.supplier, { + last_cost: r.cost_price, + last_bought_at: r.updated, + last_order_id: r.id, + times_bought: 1, + }); + } else { + prev.times_bought += 1; + // Track the most-recent cost for this supplier. + if (new Date(r.updated).getTime() > new Date(prev.last_bought_at).getTime()) { + prev.last_cost = r.cost_price; + prev.last_bought_at = r.updated; + prev.last_order_id = r.id; + } + } + } + return Array.from(bySupplier.entries()).map(([id, agg]) => ({ + supplier_id: id, + supplier_name: nameById.get(id) || id, + last_cost: agg.last_cost, + last_bought_at: agg.last_bought_at, + times_bought: agg.times_bought, + last_order_id: agg.last_order_id, + })); +} +``` + +- [ ] **Step 4: Run test — verify all pass** + +Run: `npm run test:run -- src/lib/server/item-history-service.test.ts` +Expected: PASS, 13 tests passing. + +Note: the supplier_name is a placeholder in the service. The route enriches it from the `suppliers` collection in the next task. If you want to do the enrichment here instead, swap the placeholder block for a join — but the route is the natural place because it owns the cross-collection query. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/item-history-service.ts src/lib/server/item-history-service.test.ts +git commit -m "feat(item-history): add getItemHistory service with 4 PB queries" +``` + +--- + +## Task 6: API endpoint `/api/v1/quote_items/[id]/item-history` + +**Files:** +- Create: `src/app/api/v1/quote_items/[id]/item-history/route.ts` + +- [ ] **Step 1: Create the route handler** + +Create `src/app/api/v1/quote_items/[id]/item-history/route.ts`: + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { getItemHistory } from "@/lib/server/item-history-service"; +import type { ItemHistoryResponse } from "@/lib/types"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/v1/quote_items/[id]/item-history + * + * Composes deterministic PB joins (via `getItemHistory`) with the + * existing n8n-written `ai_item_insights` blob into one response. + * + * The `ITEM_HISTORY_COMPARE_MODE` env var controls which side runs: + * - `both` (default) — both sides, side-by-side render. + * - `deterministic` — PB joins only; skip the AI read. + * - `ai` — AI blob only; skip the service call. + * - `none` — 404; trigger hides itself. + * + * Auth: same as the rest of `/api/v1` (no Bearer required, uses admin PB). + */ + +type Mode = "both" | "deterministic" | "ai" | "none"; + +function readMode(): Mode { + const raw = process.env.ITEM_HISTORY_COMPARE_MODE?.toLowerCase(); + if (raw === "deterministic" || raw === "ai" || raw === "none") return raw; + return "both"; +} + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + if (!id || typeof id !== "string") { + return NextResponse.json({ error: "Missing id" }, { status: 400 }); + } + + const mode = readMode(); + if (mode === "none") { + return NextResponse.json({ error: "Item history disabled" }, { status: 404 }); + } + + const generated_at = new Date().toISOString(); + const response: ItemHistoryResponse = { + deterministic: null, + ai: null, + ai_updated_at: null, + generated_at, + }; + + // Run the deterministic service unless mode === "ai". + if (mode === "both" || mode === "deterministic") { + try { + response.deterministic = await getItemHistory(id); + } catch (err) { + response.error = err instanceof Error ? err.message : "Deterministic lookup failed"; + } + } + + // Read the AI blob unless mode === "deterministic". + if (mode === "both" || mode === "ai") { + try { + const pb = await getAdminPocketBase(); + const row = await pb.collection("quote_items").getOne<{ + ai_item_insights?: string | null; + updated?: string; + }>(id, { fields: "ai_item_insights,updated" }); + if (row.ai_item_insights) { + try { + response.ai = JSON.parse(row.ai_item_insights); + response.ai_updated_at = row.updated || null; + } catch { + // Malformed JSON — leave `ai` null and continue. + } + } + } catch { + // Silent: deterministic side still works. + } + } + + return NextResponse.json(response); +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Manual smoke test (dev server)** + +In one terminal: `npm run dev` +In another: +```bash +curl -s "http://localhost:3000/api/v1/quote_items//item-history" | jq '. | {match: .deterministic.match, supplier_count: (.deterministic.suppliers | length), has_ai: (.ai != null)}' +``` + +Expected: a JSON object with `match`, `supplier_count`, `has_ai`. If the item has no SKU and no description, expect `match.match_kind === "none"`. + +If you don't have a real item id handy, this can be deferred to Task 8 (full UI smoke test). + +- [ ] **Step 4: Commit** + +```bash +git add src/app/api/v1/quote_items/[id]/item-history/route.ts +git commit -m "feat(api): add /quote_items/[id]/item-history endpoint" +``` + +--- + +## Task 7: `item-history-popover` renderer component (TDD) + +**Files:** +- Create: `src/components/item-history-popover.tsx` +- Create: `src/components/item-history-popover.test.tsx` + +- [ ] **Step 1: Write the failing component tests** + +Create `src/components/item-history-popover.test.tsx`: + +```tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach } from "vitest"; +import { ItemHistoryPopover } from "@/components/item-history-popover"; +import type { ItemHistoryResponse } from "@/lib/types"; + +afterEach(cleanup); + +const baseGenerated = "2026-07-29T12:00:00Z"; + +const fullDeterministic: ItemHistoryResponse["deterministic"] = { + generated_at: baseGenerated, + match: { sku: "DRL-6MM", match_kind: "sku_exact", match_confidence: 1 }, + cheapest: { supplier_id: "sup-a", supplier_name: "Fastener Co", last_cost: 42 }, + suppliers: [ + { supplier_id: "sup-a", supplier_name: "Fastener Co", last_cost: 42, last_bought_at: "2026-07-11T10:00:00Z", times_bought: 4, last_order_id: "po1" }, + { supplier_id: "sup-b", supplier_name: "Tool Depot", last_cost: 48, last_bought_at: "2026-07-24T10:00:00Z", times_bought: 2, last_order_id: "po2" }, + ], + other_quotes: [ + { quote_id: "q1", quote_number: "Q-26070112", company: "MegaCorp", customer_name: "John", status: "approved", selling_price: 85, quoted_at: "2026-07-11T10:00:00Z", days_ago: 18 }, + ], + last_sold: null, + stock: { in_stock: 240, status: "in_stock" }, +}; + +const sampleAi = { + summary: "Phone Fastener Co — they had it for R42 last week.", + last_quoted_price: 85, +}; + +describe("ItemHistoryPopover", () => { + beforeEach(() => { + // Mock window.matchMedia / Date.now if the popover uses them. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T12:00:00Z")); + }); + + it("renders both cards when both deterministic and AI are present", () => { + render( + + ); + expect(screen.getByText(/PB-derived/)).toBeInTheDocument(); + expect(screen.getByText(/AI snapshot/)).toBeInTheDocument(); + expect(screen.getByText(/Fastener Co/)).toBeInTheDocument(); + expect(screen.getByText(/Phone Fastener Co/)).toBeInTheDocument(); + }); + + it("renders only the deterministic card when AI is null", () => { + render( + + ); + expect(screen.getByText(/PB-derived/)).toBeInTheDocument(); + expect(screen.queryByText(/AI snapshot/)).not.toBeInTheDocument(); + }); + + it("renders only the AI card when deterministic is null", () => { + render( + + ); + expect(screen.getByText(/AI snapshot/)).toBeInTheDocument(); + expect(screen.queryByText(/PB-derived/)).not.toBeInTheDocument(); + }); + + it("renders the empty state when both are null", () => { + render( + + ); + expect(screen.getByText(/No history for this item yet/)).toBeInTheDocument(); + }); + + it("renders the loading state", () => { + render( + + ); + expect(screen.getByText(/Loading/)).toBeInTheDocument(); + }); + + it("renders the error chip on the deterministic side when error is set", () => { + render( + + ); + expect(screen.getByText(/PB timeout/)).toBeInTheDocument(); + expect(screen.getByText(/AI snapshot/)).toBeInTheDocument(); + }); + + it("renders the fuzzy-match badge when match_kind is description_fuzzy", () => { + render( + + ); + expect(screen.getByText(/best-guess match/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run the tests — verify they fail** + +Run: `npm run test:run -- src/components/item-history-popover.test.tsx` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the component** + +Create `src/components/item-history-popover.tsx`: + +```tsx +"use client"; + +/** + * item-history-popover.tsx + * + * Renders the per-item history popover on /sprint. Takes the + * combined `{ deterministic, ai, ai_updated_at, generated_at }` + * from `/api/v1/quote_items/[id]/item-history` and renders: + * + * - Both cards side by side when both sources have data. + * - One card when only one source has data. + * - An empty state when neither has data. + * + * Decoration kept minimal — the previous rainbow renderer was + * unreadable for an operator making a 10-second pricing decision. + */ + +import { Building2, Info, Loader2, Package, TrendingUp, Truck } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { ItemHistoryResponse } from "@/lib/types"; + +interface Props { + data: ItemHistoryResponse | null; + loading: boolean; + error: string | null; +} + +export function ItemHistoryPopover({ data, loading, error }: Props) { + if (loading) { + return ( +
+ + Loading history… +
+ ); + } + + if (!data) { + return ( +
No history available.
+ ); + } + + const { deterministic, ai, generated_at } = data; + const noData = !deterministic && !ai; + + if (noData && !error) { + return ( +
+
No history for this item yet
+
First time we’re quoting this SKU — no supplier history, no other quotes.
+
Pick a supplier, set cost, save.
+
+ ); + } + + const showBoth = deterministic && ai; + + return ( +
+ {error && ( +
+ History lookup failed: {error} +
+ )} +
+ {deterministic && } + {ai && } +
+
+ ); +} + +function FreshnessBadge({ label, ts, variant }: { label: string; ts: string | null; variant: "indigo" | "amber" }) { + const cls = + variant === "indigo" + ? "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300" + : "bg-amber-500/10 text-amber-700 dark:text-amber-300"; + return ( + + {label} {ts ? relTime(ts) : ""} + + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +function DeterministicCard({ + data, + now, +}: { + data: NonNullable; + now: string; +}) { + const fuzzy = data.match.match_kind === "description_fuzzy"; + return ( +
+
+
+ + PB-derived +
+ +
+ + {fuzzy && ( +
+ ⚠ best-guess match · {Math.round(data.match.match_confidence * 100)}% confidence +
+ )} + + {data.cheapest && ( +
+ ↓ Cheapest: {data.cheapest.supplier_name} R{data.cheapest.last_cost} +
+ )} + + {data.suppliers.length > 0 && ( +
+
    + {data.suppliers.map((s) => ( +
  • + {s.supplier_name} + R{s.last_cost} + {relTime(s.last_bought_at)} (×{s.times_bought}) +
  • + ))} +
+
+ )} + + {data.other_quotes.length > 0 && ( +
+
    + {data.other_quotes.map((q) => ( +
  • + {q.quote_number} + R{q.selling_price} + {q.status} · {q.days_ago}d +
  • + ))} +
+
+ )} + + {data.last_sold && ( +
+ R{data.last_sold.selling_price} · {data.last_sold.days_ago}d ago · {data.last_sold.company} +
+ )} + + {data.stock && ( +
+ + + {data.stock.in_stock} units ({data.stock.status.replace("_", " ")}) + +
+ )} +
+ ); +} + +function AiCard({ data, updatedAt }: { data: Record; updatedAt: string | null }) { + const summary = typeof data.summary === "string" ? data.summary : null; + return ( +
+
+
+ + AI snapshot +
+ +
+ {summary &&

{summary}

} +
+ Raw AI data +
+          {JSON.stringify(data, null, 2)}
+        
+
+
+ ); +} + +function relTime(iso: string): string { + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return iso; + const diff = Date.now() - t; + const min = Math.round(diff / 60000); + if (min < 60) return `${min}m ago`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr}h ago`; + const d = Math.round(hr / 24); + if (d < 30) return `${d}d ago`; + const mo = Math.round(d / 30); + return `${mo}mo ago`; +} +``` + +- [ ] **Step 4: Run tests — verify all pass** + +Run: `npm run test:run -- src/components/item-history-popover.test.tsx` +Expected: PASS, 7 tests passing. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/item-history-popover.tsx src/components/item-history-popover.test.tsx +git commit -m "feat(item-history): tight popover renderer with 4 render states" +``` + +--- + +## Task 8: Update `ai-item-history-trigger` to fetch the new endpoint + +**Files:** +- Modify: `src/components/ai-item-history-trigger.tsx` + +- [ ] **Step 1: Replace the trigger component** + +Open `src/components/ai-item-history-trigger.tsx` and replace its entire body with: + +```tsx +"use client"; + +/** + * ai-item-history-trigger.tsx + * + * Per-line-item "Item History" popover trigger. Renders a small ⓘ + * icon button in the sprint row; click fetches + * `GET /api/v1/quote_items/[id]/item-history` and renders the + * combined deterministic + AI history panel. + * + * The fetch returns 404 when `ITEM_HISTORY_COMPARE_MODE === "none"`, + * in which case the trigger hides itself. + */ + +import { useState } from "react"; +import { Info } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ItemHistoryPopover } from "@/components/item-history-popover"; +import type { ItemHistoryResponse } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +interface Props { + /** PB id of the quote_item / order_item. */ + itemId: string; + /** + * Pre-loaded insights (optional). If supplied AND we don't have + * a fresh fetch yet, the popover renders from this immediately — + * the fetch then runs in the background and updates the view. + * Kept so the parent (sprint list) can still pass through the + * existing `ai_item_insights` for an instant first paint. + */ + insights?: Record | null | undefined; + className?: string; +} + +export function AiItemHistoryTrigger({ itemId, insights, className }: Props) { + const [open, setOpen] = useState(false); + const [data, setData] = useState( + insights + ? { + deterministic: null, + ai: insights, + ai_updated_at: null, + generated_at: new Date().toISOString(), + } + : null + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [hidden, setHidden] = useState(false); + + const handleOpen = async (next: boolean) => { + setOpen(next); + if (!next || data || hidden) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/v1/quote_items/${itemId}/item-history`); + if (res.status === 404) { + // Mode is "none" — hide the trigger. + setHidden(true); + setOpen(false); + return; + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as ItemHistoryResponse; + setData(body); + if (body.error) setError(body.error); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load history"); + } finally { + setLoading(false); + } + }; + + if (hidden) return null; + + const hasData = data && (data.deterministic || data.ai); + + return ( + + + + + +
+ + + Item History + +
+ +

+ Item id: {itemId} +

+
+
+ ); +} +``` + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Run all item-history tests** + +Run: `npm run test:run -- src/lib/server/item-history-service src/components/item-history-popover` +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ai-item-history-trigger.tsx +git commit -m "refactor(item-history): trigger fetches new endpoint instead of inline read" +``` + +--- + +## Task 9: Manual smoke test in dev + +**Files:** none (verification only) + +- [ ] **Step 1: Start dev server** + +Run: `npm run dev` + +- [ ] **Step 2: Open `/sprint` in a browser** + +Navigate to `http://localhost:3000/sprint`. + +- [ ] **Step 3: Click ⓘ on three different items** + +Verify: +- An item with a known SKU → both cards render side by side, cheapest supplier line is green. +- An item with no SKU and no fuzzy match → empty state ("No history for this item yet"). +- An item where `ai_item_insights` is stale → AI card still shows with its own freshness badge. + +- [ ] **Step 4: Try the env-var modes** + +Stop the dev server. Re-start with: +```bash +ITEM_HISTORY_COMPARE_MODE=deterministic npm run dev +``` +Click ⓘ on an item → only the PB-derived card renders. Confirm no regression. + +Stop. Restart with: +```bash +ITEM_HISTORY_COMPARE_MODE=ai npm run dev +``` +Click ⓘ → only the AI card. Confirm. + +Stop. Restart with: +```bash +ITEM_HISTORY_COMPARE_MODE=none npm run dev +``` +Click ⓘ → trigger is hidden, nothing opens. Confirm. + +- [ ] **Step 5: Reset to default** + +Stop. Restart with no env var (or `ITEM_HISTORY_COMPARE_MODE=both`). Confirm both render. + +- [ ] **Step 6: Run the full test suite** + +Run: `npm run test:run` +Expected: all tests pass. + +- [ ] **Step 7: Commit any small fixes** + +If you tweaked anything during the smoke test: +```bash +git add -A +git commit -m "chore(item-history): tweaks from manual smoke test" +``` + +--- + +## Notes for the Implementer + +- **Reuse `tokenize` and `jaccard`** from `src/lib/server/order-quote-match.ts` — don't reinvent the wheel for the description-fuzzy pass. They're already battle-tested. +- **Keep the new renderer tight.** The existing `ai-insights-renderer.tsx` is 1,288 lines of decoration. The new popover should be ~150 lines. If it grows past 250, you're decorating again — stop and re-read the spec. +- **Stock collection name is `stock_purchase_items`**, not `stock`. It joins to `stock_item` for the SKU. If your snapshot of PB schema shows a different name, double-check before writing the filter. +- **`getOrderItemsCollectionName`** exists in `orders-service.ts` — the codebase supports both `order_items` and a renamed alias. The service uses the literal `"order_items"` because the matcher runs against `quote_items.sku`, and `order_items` is the stable name across both deployments. +- **The trigger's first-paint `insights` prop** keeps the existing behaviour: if the parent already passed `ai_item_insights` from the sprint list payload, the popover shows it instantly while the fetch runs. After the fetch returns, it replaces the data. +- **Out of scope: changing `sprint-item-pricing-modal.tsx`.** Its "AI Pricing Suggestions" card still reads `ai_item_insights` directly for the Apply buttons. We migrate that in the next round (Process orders). +- **Out of scope: caching.** If the deterministic path becomes slow in production, add a Redis cache later. YAGNI for now. \ No newline at end of file