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 (
+
+ );
+}
+
+function DeterministicCard({
+ data,
+ now,
+}: {
+ data: NonNullable;
+ now: string;
+}) {
+ const fuzzy = data.match.match_kind === "description_fuzzy";
+ return (
+
+
+
+ {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 (
+
+
+ {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`;
+}