From bce66b135aac728471771701f32a6c8672b5034b Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 29 Jul 2026 15:08:03 +0200 Subject: [PATCH] feat(item-history): tight popover renderer with 4 render states Co-authored-by: Orca --- src/components/item-history-popover.test.tsx | 145 ++++++++++++++ src/components/item-history-popover.tsx | 190 +++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 src/components/item-history-popover.test.tsx create mode 100644 src/components/item-history-popover.tsx diff --git a/src/components/item-history-popover.test.tsx b/src/components/item-history-popover.test.tsx new file mode 100644 index 0000000..dc77679 --- /dev/null +++ b/src/components/item-history-popover.test.tsx @@ -0,0 +1,145 @@ +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(() => { + 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.getAllByText(/Fastener Co/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Phone Fastener Co/).length).toBeGreaterThan(0); + }); + + 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(); + }); +}); diff --git a/src/components/item-history-popover.tsx b/src/components/item-history-popover.tsx new file mode 100644 index 0000000..f5fbc05 --- /dev/null +++ b/src/components/item-history-popover.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { Info, Loader2, Package, TrendingUp } 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`; +}