feat(item-history): tight popover renderer with 4 render states

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 15:08:03 +02:00
parent f4d9601881
commit bce66b135a
2 changed files with 335 additions and 0 deletions

View file

@ -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(
<ItemHistoryPopover
data={{
deterministic: fullDeterministic,
ai: sampleAi,
ai_updated_at: baseGenerated,
generated_at: baseGenerated,
}}
loading={false}
error={null}
/>
);
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(
<ItemHistoryPopover
data={{
deterministic: fullDeterministic,
ai: null,
ai_updated_at: null,
generated_at: baseGenerated,
}}
loading={false}
error={null}
/>
);
expect(screen.getByText(/PB-derived/)).toBeInTheDocument();
expect(screen.queryByText(/AI snapshot/)).not.toBeInTheDocument();
});
it("renders only the AI card when deterministic is null", () => {
render(
<ItemHistoryPopover
data={{
deterministic: null,
ai: sampleAi,
ai_updated_at: baseGenerated,
generated_at: baseGenerated,
}}
loading={false}
error={null}
/>
);
expect(screen.getByText(/AI snapshot/)).toBeInTheDocument();
expect(screen.queryByText(/PB-derived/)).not.toBeInTheDocument();
});
it("renders the empty state when both are null", () => {
render(
<ItemHistoryPopover
data={{
deterministic: null,
ai: null,
ai_updated_at: null,
generated_at: baseGenerated,
}}
loading={false}
error={null}
/>
);
expect(screen.getByText(/No history for this item yet/)).toBeInTheDocument();
});
it("renders the loading state", () => {
render(
<ItemHistoryPopover data={null} loading={true} error={null} />
);
expect(screen.getByText(/Loading/)).toBeInTheDocument();
});
it("renders the error chip on the deterministic side when error is set", () => {
render(
<ItemHistoryPopover
data={{
deterministic: null,
ai: sampleAi,
ai_updated_at: baseGenerated,
generated_at: baseGenerated,
}}
loading={false}
error="PB timeout"
/>
);
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(
<ItemHistoryPopover
data={{
deterministic: { ...fullDeterministic, match: { sku: "", match_kind: "description_fuzzy", match_confidence: 0.7 } },
ai: null,
ai_updated_at: null,
generated_at: baseGenerated,
}}
loading={false}
error={null}
/>
);
expect(screen.getByText(/best-guess match/)).toBeInTheDocument();
});
});

View file

@ -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 (
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading history…
</div>
);
}
if (!data) {
return (
<div className="p-3 text-xs text-muted-foreground">No history available.</div>
);
}
const { deterministic, ai, generated_at } = data;
const noData = !deterministic && !ai;
if (noData && !error) {
return (
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
<div className="font-medium">No history for this item yet</div>
<div className="mt-1">First time we&rsquo;re quoting this SKU — no supplier history, no other quotes.</div>
<div className="mt-2 text-foreground">Pick a supplier, set cost, save.</div>
</div>
);
}
const showBoth = deterministic && ai;
return (
<div className="space-y-2">
{error && (
<div className="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-[10px] text-destructive">
History lookup failed: {error}
</div>
)}
<div className={cn("grid gap-2", showBoth && "grid-cols-2")}>
{deterministic && <DeterministicCard data={deterministic} now={generated_at} />}
{ai && <AiCard data={ai} updatedAt={data.ai_updated_at} />}
</div>
</div>
);
}
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 (
<span className={cn("inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium", cls)}>
{label} {ts ? relTime(ts) : ""}
</span>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wide text-muted-foreground">{title}</div>
<div className="text-xs">{children}</div>
</div>
);
}
function DeterministicCard({
data,
now,
}: {
data: NonNullable<ItemHistoryResponse["deterministic"]>;
now: string;
}) {
const fuzzy = data.match.match_kind === "description_fuzzy";
return (
<div className="rounded-lg border border-indigo-200 bg-indigo-50/40 p-2.5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<TrendingUp className="h-3.5 w-3.5 text-indigo-700" />
<span className="text-xs font-semibold text-indigo-700">PB-derived</span>
</div>
<FreshnessBadge label="fresh" ts={data.generated_at} variant="indigo" />
</div>
{fuzzy && (
<div className="rounded border border-amber-300 bg-amber-50 px-2 py-1 text-[10px] text-amber-900">
⚠ best-guess match · {Math.round(data.match.match_confidence * 100)}% confidence
</div>
)}
{data.cheapest && (
<div className="text-xs font-bold text-emerald-700">
↓ Cheapest: {data.cheapest.supplier_name} R{data.cheapest.last_cost}
</div>
)}
{data.suppliers.length > 0 && (
<Section title={`We buy this from (${data.suppliers.length})`}>
<ul className="space-y-0.5">
{data.suppliers.map((s) => (
<li key={s.supplier_id} className="flex items-center justify-between gap-2">
<span className="font-medium">{s.supplier_name}</span>
<span className="tabular-nums text-emerald-700">R{s.last_cost}</span>
<span className="text-muted-foreground text-[10px]">{relTime(s.last_bought_at)} (×{s.times_bought})</span>
</li>
))}
</ul>
</Section>
)}
{data.other_quotes.length > 0 && (
<Section title={`Other quotes (${data.other_quotes.length})`}>
<ul className="space-y-0.5">
{data.other_quotes.map((q) => (
<li key={q.quote_id} className="flex items-center justify-between gap-2">
<span className="font-mono text-[11px]">{q.quote_number}</span>
<span className="tabular-nums">R{q.selling_price}</span>
<span className="text-muted-foreground text-[10px]">{q.status} · {q.days_ago}d</span>
</li>
))}
</ul>
</Section>
)}
{data.last_sold && (
<Section title="Last sold">
R{data.last_sold.selling_price} · {data.last_sold.days_ago}d ago · {data.last_sold.company}
</Section>
)}
{data.stock && (
<Section title="Stock">
<span className="inline-flex items-center gap-1">
<Package className="h-3 w-3" />
{data.stock.in_stock} units ({data.stock.status.replace("_", " ")})
</span>
</Section>
)}
</div>
);
}
function AiCard({ data, updatedAt }: { data: Record<string, unknown>; updatedAt: string | null }) {
const summary = typeof data.summary === "string" ? data.summary : null;
return (
<div className="rounded-lg border border-amber-200 bg-amber-50/40 p-2.5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Info className="h-3.5 w-3.5 text-amber-700" />
<span className="text-xs font-semibold text-amber-700">AI snapshot</span>
</div>
<FreshnessBadge label="snapshot" ts={updatedAt} variant="amber" />
</div>
{summary && <p className="text-xs">{summary}</p>}
<details className="text-[10px] text-muted-foreground">
<summary className="cursor-pointer">Raw AI data</summary>
<pre className="mt-1 max-h-32 overflow-auto rounded bg-muted/40 p-1 text-[10px]">
{JSON.stringify(data, null, 2)}
</pre>
</details>
</div>
);
}
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`;
}