'use client'; import { useMemo, useRef } from 'react'; import { Legend, LineChart, lineLayout, type Series, stepPoints } from '@/components/charts/charts'; import { useEvidence } from '@/components/evidence'; import { Note } from '@/components/ui/section'; import { cn } from '@/lib/cn'; import { fmtDate, fmtUsdPerM, hostOf, num } from '@/lib/format'; import type { Price } from '@/lib/types'; /* Price history 3.0 (client): one step line per provider (output price by default, input on toggle by the parent), a legend, and a transitions list. Clicking the chart or a transition opens the evidence drawer for that price row (source, tier, observed time). */ type Transition = { id: string; provider: string; providerSlug: string; at: string; from: number | null; to: number | null; row: Price }; export function buildTransitions(history: Price[], field: 'input_per_mtok' | 'output_per_mtok'): Transition[] { const byProv = new Map(); for (const p of history) { const arr = byProv.get(p.provider.slug) ?? []; arr.push(p); byProv.set(p.provider.slug, arr); } const out: Transition[] = []; for (const rows of byProv.values()) { const sorted = [...rows].sort((a, b) => a.valid_from.localeCompare(b.valid_from)); let prev: number | null = null; for (const r of sorted) { const v = num(r[field]); if (v === null) continue; if (prev === null || prev !== v) out.push({ id: r.id, provider: r.provider.name, providerSlug: r.provider.slug, at: r.valid_from, from: prev, to: v, row: r }); prev = v; } } return out.sort((a, b) => a.at.localeCompare(b.at)); } export function PriceHistoryChart({ history, field, modelSlug, modelName, className }: { history: Price[]; field: 'input_per_mtok' | 'output_per_mtok'; modelSlug: string; modelName: string; className?: string }) { const { open } = useEvidence(); const wrap = useRef(null); const byProv = useMemo(() => { const m = new Map(); for (const p of history) { const arr = m.get(p.provider.name) ?? []; arr.push(p); m.set(p.provider.name, arr); } return m; }, [history]); // "Now" anchor for open-ended steps = the latest observation in the data (deterministic → identical server and client SVG). const nowAnchor = useMemo(() => history.reduce((m, p) => (p.observed_at > m ? p.observed_at : m), history[0]?.observed_at ?? ''), [history]); const series: Series[] = useMemo( () => [...byProv.entries()].slice(0, 8).map(([name, rows]) => { const sorted = [...rows].sort((a, b) => a.valid_from.localeCompare(b.valid_from)); const last = sorted[sorted.length - 1]; const pts = stepPoints(sorted.map((r) => ({ at: r.valid_from, value: num(r[field]) }))); if (last && !last.valid_to && num(last[field]) !== null && nowAnchor && nowAnchor > last.valid_from) pts.push({ x: new Date(nowAnchor), y: num(last[field]) as number }); return { name, points: pts }; }).filter((s) => s.points.length > 0), [byProv, field, nowAnchor], ); const transitions = useMemo(() => buildTransitions(history, field), [history, field]); const L = useMemo(() => lineLayout({ series, height: 220, xTime: true, yDomain: [0, Math.max(...series.flatMap((s) => s.points.map((p) => p.y)), 0) * 1.15 || 1] }), [series]); const label = field === 'input_per_mtok' ? 'Input price' : 'Output price'; const openFor = (t: Transition) => { open({ slug: modelSlug, property: `price.${field === 'input_per_mtok' ? 'input' : 'output'}.${t.providerSlug}`, value: t.to, display: `${fmtUsdPerM(t.to)} / 1M tokens`, label: `${label} · ${t.provider}`, fallback: { source_id: null, source_name: hostOf(t.row.source_url) ?? t.provider, url: t.row.source_url, observed_at: t.row.observed_at, tier: t.row.tier, confidence: 'high', extractor: 'deterministic', unit: 'USD / 1M tokens' }, entity: { name: modelName, entity_type: 'model' }, }); }; const onClick = (clientX: number) => { if (!L || !wrap.current || !transitions.length) return; const rect = wrap.current.getBoundingClientRect(); const vx = ((clientX - rect.left) / rect.width) * L.w; const t = L.xInvert(vx); let best = transitions[0] as Transition; for (const tr of transitions) if (Math.abs(new Date(tr.at).getTime() - t) < Math.abs(new Date(best.at).getTime() - t)) best = tr; openFor(best); }; const points = series.reduce((n, s) => n + s.points.length, 0); if (!history.length) return null; return (

{label} · USD / 1M tokens {byProv.size} provider{byProv.size === 1 ? '' : 's'}

{points < 2 || !L ? ( Price history starts with the first observation — no change recorded yet for this side ({history.length} row{history.length === 1 ? '' : 's'}). ) : ( <>
onClick(e.clientX)} title="Click to open the evidence of the nearest price change" data-price-chart> fmtUsdPerM(v)} yDomain={[0, Math.max(...series.flatMap((s) => s.points.map((p) => p.y))) * 1.15 || 1]} yLabel={`${label} history of ${modelName}`}> {transitions.map((t) => ( {`${t.provider}: ${t.from === null ? 'first observed' : fmtUsdPerM(t.from)} → ${fmtUsdPerM(t.to)} · ${fmtDate(t.at)}`} ))}
)}
    {transitions .slice() .reverse() .slice(0, 12) .map((t) => (
  • {t.provider} {t.from === null ? first observed : {fmtUsdPerM(t.from)} → } t.from ? 'text-danger' : 'text-accent-2')}>{fmtUsdPerM(t.to)} {fmtDate(t.at)}
  • ))}
); }