HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1'use client';2import { useMemo, useRef } from 'react';3import { Legend, LineChart, lineLayout, type Series, stepPoints } from '@/components/charts/charts';4import { useEvidence } from '@/components/evidence';5import { Note } from '@/components/ui/section';6import { cn } from '@/lib/cn';7import { fmtDate, fmtUsdPerM, hostOf, num } from '@/lib/format';8import type { Price } from '@/lib/types';910/*11 Price history 3.0 (client): one step line per provider (output price by default, input on toggle by the parent), a legend, and a12 transitions list. Clicking the chart or a transition opens the evidence drawer for that price row (source, tier, observed time).13*/1415type Transition = { id: string; provider: string; providerSlug: string; at: string; from: number | null; to: number | null; row: Price };1617export function buildTransitions(history: Price[], field: 'input_per_mtok' | 'output_per_mtok'): Transition[] {18 const byProv = new Map<string, Price[]>();19 for (const p of history) {20 const arr = byProv.get(p.provider.slug) ?? [];21 arr.push(p);22 byProv.set(p.provider.slug, arr);23 }24 const out: Transition[] = [];25 for (const rows of byProv.values()) {26 const sorted = [...rows].sort((a, b) => a.valid_from.localeCompare(b.valid_from));27 let prev: number | null = null;28 for (const r of sorted) {29 const v = num(r[field]);30 if (v === null) continue;31 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 });32 prev = v;33 }34 }35 return out.sort((a, b) => a.at.localeCompare(b.at));36}3738export function PriceHistoryChart({ history, field, modelSlug, modelName, className }: { history: Price[]; field: 'input_per_mtok' | 'output_per_mtok'; modelSlug: string; modelName: string; className?: string }) {39 const { open } = useEvidence();40 const wrap = useRef<HTMLDivElement>(null);41 const byProv = useMemo(() => {42 const m = new Map<string, Price[]>();43 for (const p of history) {44 const arr = m.get(p.provider.name) ?? [];45 arr.push(p);46 m.set(p.provider.name, arr);47 }48 return m;49 }, [history]);50 // "Now" anchor for open-ended steps = the latest observation in the data (deterministic → identical server and client SVG).51 const nowAnchor = useMemo(() => history.reduce((m, p) => (p.observed_at > m ? p.observed_at : m), history[0]?.observed_at ?? ''), [history]);52 const series: Series[] = useMemo(53 () =>54 [...byProv.entries()].slice(0, 8).map(([name, rows]) => {55 const sorted = [...rows].sort((a, b) => a.valid_from.localeCompare(b.valid_from));56 const last = sorted[sorted.length - 1];57 const pts = stepPoints(sorted.map((r) => ({ at: r.valid_from, value: num(r[field]) })));58 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 });59 return { name, points: pts };60 }).filter((s) => s.points.length > 0),61 [byProv, field, nowAnchor],62 );63 const transitions = useMemo(() => buildTransitions(history, field), [history, field]);64 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]);65 const label = field === 'input_per_mtok' ? 'Input price' : 'Output price';6667 const openFor = (t: Transition) => {68 open({69 slug: modelSlug,70 property: `price.${field === 'input_per_mtok' ? 'input' : 'output'}.${t.providerSlug}`,71 value: t.to,72 display: `${fmtUsdPerM(t.to)} / 1M tokens`,73 label: `${label} · ${t.provider}`,74 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' },75 entity: { name: modelName, entity_type: 'model' },76 });77 };78 const onClick = (clientX: number) => {79 if (!L || !wrap.current || !transitions.length) return;80 const rect = wrap.current.getBoundingClientRect();81 const vx = ((clientX - rect.left) / rect.width) * L.w;82 const t = L.xInvert(vx);83 let best = transitions[0] as Transition;84 for (const tr of transitions) if (Math.abs(new Date(tr.at).getTime() - t) < Math.abs(new Date(best.at).getTime() - t)) best = tr;85 openFor(best);86 };8788 const points = series.reduce((n, s) => n + s.points.length, 0);89 if (!history.length) return null;90 return (91 <div className={cn('space-y-3', className)} data-price-history={field}>92 <p className="eyebrow">93 {label} · USD / 1M tokens <span className="tnum text-ink-3">{byProv.size} provider{byProv.size === 1 ? '' : 's'}</span>94 </p>95 {points < 2 || !L ? (96 <Note>Price history starts with the first observation — no change recorded yet for this side ({history.length} row{history.length === 1 ? '' : 's'}).</Note>97 ) : (98 <>99 <div ref={wrap} className="cursor-pointer" onClick={(e) => onClick(e.clientX)} title="Click to open the evidence of the nearest price change" data-price-chart>100 <LineChart series={series} height={220} step yFormat={(v) => fmtUsdPerM(v)} yDomain={[0, Math.max(...series.flatMap((s) => s.points.map((p) => p.y))) * 1.15 || 1]} yLabel={`${label} history of ${modelName}`}>101 {transitions.map((t) => (102 <circle key={t.id} cx={L.x(new Date(t.at).getTime())} cy={L.y(t.to ?? 0)} r={3.5} fill="var(--accent-2)" stroke="var(--canvas)" strokeWidth={1.2}>103 <title>{`${t.provider}: ${t.from === null ? 'first observed' : fmtUsdPerM(t.from)} → ${fmtUsdPerM(t.to)} · ${fmtDate(t.at)}`}</title>104 </circle>105 ))}106 </LineChart>107 </div>108 <Legend series={series} />109 </>110 )}111 <ul className="divide-y divide-rule border-y border-rule text-sm">112 {transitions113 .slice()114 .reverse()115 .slice(0, 12)116 .map((t) => (117 <li key={t.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-3 py-1.5 sm:grid-cols-[10rem_minmax(0,1fr)_auto]">118 <span className="truncate text-ink-2">{t.provider}</span>119 <span className="tnum col-span-2 sm:col-span-1">120 {t.from === null ? <span className="text-ink-3">first observed </span> : <span className="text-ink-3">{fmtUsdPerM(t.from)} → </span>}121 <span className={cn('font-medium', t.from !== null && t.to !== null && t.to < t.from ? 'text-positive' : t.from !== null && t.to !== null && t.to > t.from ? 'text-danger' : 'text-accent-2')}>{fmtUsdPerM(t.to)}</span>122 </span>123 <span className="tnum flex items-center gap-2 text-xs text-ink-3">124 {fmtDate(t.at)}125 <button type="button" className="evidence text-accent" onClick={() => openFor(t)} data-evidence={`${modelSlug}:price.${t.providerSlug}`}>126 Evidence127 </button>128 </span>129 </li>130 ))}131 </ul>132 </div>133 );134}135