"use client";
import * as React from "react";
import { ResponsiveContainer, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, PieChart, Pie, Cell, LabelList, type TooltipContentProps } from "recharts";
import { ProviderIcon } from "@/components/brand/provider-icon";
import { PROVIDERS, providerName } from "@/lib/client/providers";
import { formatUsd, formatTokens, formatNumber, formatMs, cn } from "@/lib/utils";
import type { ProviderId } from "@/lib/client/types";
/* ------------------------------------------------------------------------------------------------
* Theme-bound chart primitives. Rules (dataviz): one hue per entity (provider color follows the
* provider, never its rank), thin marks (≤ 24 px, 4 px rounded data-end), 2 px surface gaps between
* stacked fills, hairline solid grid, text in text tokens (never the series color), tooltips on
* every plot, no dual axes, no default recharts colors anywhere.
* ---------------------------------------------------------------------------------------------- */
const GRID = "var(--border)";
const TEXT = "var(--fg-muted)";
const SURFACE = "var(--bg-elevated)";
const AXIS_TICK = { fill: TEXT, fontSize: 11, fontFamily: "var(--font-mono)" } as const;
const BAR_MAX = 24;
export function providerColor(p: string): string {
return p in PROVIDERS ? PROVIDERS[p as ProviderId].colorVar : "var(--p-custom)";
}
export interface SeriesPoint {
bucket: string;
requests: number;
failures: number;
inputTokens: number;
outputTokens: number;
costUsd: number;
}
/** `YYYY-MM-DD` → "Sep 11" · `YYYY-MM-DDTHH:00` → "3 PM" (parsed as wall-clock, never shifted by the browser zone). */
export function bucketLabel(key: string, hourly: boolean, opts: { long?: boolean } = {}): string {
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/.exec(key);
if (!m) return key;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0));
if (hourly) return opts.long ? d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric" }) : d.toLocaleTimeString("en-US", { hour: "numeric" });
return d.toLocaleDateString("en-US", opts.long ? { weekday: "short", month: "short", day: "numeric", year: "numeric" } : { month: "short", day: "numeric" });
}
interface TooltipRow {
name: string;
value: React.ReactNode;
color?: string;
}
function ChartTooltipBox({ title, rows }: { title?: React.ReactNode; rows: TooltipRow[] }) {
return (
{title ?
{title}
: null}
{rows.map((r) => (
-
{r.color ? : null}
{r.name}
{r.value}
))}
);
}
type RechartsTooltipProps = TooltipContentProps;
function num(v: unknown): number {
return typeof v === "number" ? v : Number(v ?? 0);
}
/* ---------- chart card frame ---------- */
export function ChartCard({ title, hint, children, className, legend, action }: { title: string; hint?: React.ReactNode; children: React.ReactNode; className?: string; legend?: React.ReactNode; action?: React.ReactNode }) {
return (
{title}
{hint ?
{hint}
: null}
{legend ?? action}
{children}
);
}
export function Legend({ items }: { items: { label: string; color: string; icon?: React.ReactNode; opacity?: number }[] }) {
return (
{items.map((i) => (
-
{i.icon ?? }
{i.label}
))}
);
}
export function ChartEmpty({ children = "Nothing recorded for this period." }: { children?: React.ReactNode }) {
return {children}
;
}
/* ---------- time series ---------- */
const H = 200;
export function CostOverTime({ data, hourly }: { data: SeriesPoint[]; hourly: boolean }) {
if (!data.some((d) => d.costUsd > 0)) return No priced requests in this period.;
return (
bucketLabel(String(v), hourly)} tick={AXIS_TICK} axisLine={false} tickLine={false} minTickGap={28} interval="preserveStartEnd" />
(num(v) === 0 ? "$0" : formatUsd(num(v)))} width={64} />
{
if (!active || !payload?.length) return null;
const row = payload[0].payload as SeriesPoint;
return ;
}}
/>
);
}
export function RequestsOverTime({ data, hourly }: { data: SeriesPoint[]; hourly: boolean }) {
if (!data.some((d) => d.requests > 0)) return ;
const hasFailures = data.some((d) => d.failures > 0);
return (
bucketLabel(String(v), hourly)} tick={AXIS_TICK} axisLine={false} tickLine={false} minTickGap={28} interval="preserveStartEnd" />
{
if (!active || !payload?.length) return null;
const row = payload[0].payload as SeriesPoint;
return (
);
}}
/>
{/* stacked: ok (accent) + failed (status danger, only when > 0); 2 px surface gap via stroke */}
d.requests - d.failures} name="ok" stackId="r" fill="var(--accent)" stroke={SURFACE} strokeWidth={2} radius={hasFailures ? undefined : [4, 4, 0, 0]} isAnimationActive={false} maxBarSize={BAR_MAX} />
{hasFailures ? : null}
);
}
export function TokensOverTime({ data, hourly }: { data: SeriesPoint[]; hourly: boolean }) {
if (!data.some((d) => d.inputTokens + d.outputTokens > 0)) return ;
return (
bucketLabel(String(v), hourly)} tick={AXIS_TICK} axisLine={false} tickLine={false} minTickGap={28} interval="preserveStartEnd" />
formatTokens(num(v))} width={56} />
{
if (!active || !payload?.length) return null;
const row = payload[0].payload as SeriesPoint;
return (
);
}}
/>
);
}
/* ---------- by provider ---------- */
export interface ProviderSlice {
provider: string;
requests: number;
failures: number;
costUsd: number;
inputTokens: number;
outputTokens: number;
}
/** Part-to-whole donut (≤ 6 slices, tail folded into "Other") with a labelled list — identity comes from the list, not color alone. */
export function ProviderDonut({ data, metric }: { data: ProviderSlice[]; metric: "requests" | "costUsd" }) {
const total = data.reduce((a, d) => a + num(d[metric]), 0);
const sorted = [...data].filter((d) => num(d[metric]) > 0).sort((a, b) => num(b[metric]) - num(a[metric]));
const shown = sorted.length > 6 ? [...sorted.slice(0, 5), { ...sorted[5], provider: "other", [metric]: sorted.slice(5).reduce((a, d) => a + num(d[metric]), 0) }] : sorted;
if (!total) return {metric === "costUsd" ? "No priced requests in this period." : "Nothing recorded for this period."};
const color = (p: string) => (p === "other" ? "var(--fg-subtle)" : providerColor(p));
const name = (p: string) => (p === "other" ? "Other" : providerName(p));
return (
{shown.map((d) => (
|
))}
{
if (!active || !payload?.length) return null;
const d = payload[0].payload as ProviderSlice;
const v = num(d[metric]);
return ;
}}
/>
{metric === "costUsd" ? "Est. cost" : "Requests"}
{metric === "costUsd" ? formatUsd(total) : formatNumber(total)}
{shown.map((d) => {
const v = num(d[metric]);
const pct = (v / total) * 100;
return (
-
{d.provider === "other" ? : }
{name(d.provider)}
{metric === "costUsd" ? formatUsd(v) : `${pct.toFixed(0)}%`}
);
})}
);
}
/* ---------- by model ---------- */
export interface ModelRow {
modelKey: string;
provider: string;
label: string;
requests: number;
costUsd: number;
inputTokens: number;
outputTokens: number;
tokensPerSec: number | null;
avgLatencyMs: number;
avgTtftMs: number | null;
failures: number;
}
/** Horizontal bars, one row per model, colored by provider (identity), values labelled at the bar end. */
export function ModelBars({ data, metric, format, max = 8, ascending = false, emptyText }: { data: ModelRow[]; metric: keyof ModelRow; format: (v: number) => string; max?: number; ascending?: boolean; emptyText?: string }) {
const rows = [...data]
.filter((d) => num(d[metric]) > 0)
.sort((a, b) => (ascending ? num(a[metric]) - num(b[metric]) : num(b[metric]) - num(a[metric])))
.slice(0, max);
if (rows.length === 0) return {emptyText ?? "Not enough data."};
const height = Math.max(120, rows.length * 32 + 12);
return (
(v.length > 17 ? `${v.slice(0, 16)}…` : v)} />
{
if (!active || !payload?.length) return null;
const d = payload[0].payload as ModelRow;
return (
{d.label}
}
rows={[
{ name: "Value", value: format(num(d[metric])) },
{ name: "Requests", value: formatNumber(d.requests) },
{ name: "Est. cost", value: formatUsd(d.costUsd, { precise: true }) },
{ name: "Tokens in / out", value: `${formatTokens(d.inputTokens)} / ${formatTokens(d.outputTokens)}` },
]}
/>
);
}}
/>
{rows.map((d) => (
|
))}
format(num(v))} style={{ fill: TEXT, fontSize: 11, fontFamily: "var(--font-mono)" }} />
);
}
/** Two small multiples (never a dual axis): time to first token (lower is better) and output speed (higher is better). */
export function LatencyByModel({ data, max = 6 }: { data: ModelRow[]; max?: number }) {
return (
Time to first token · lower is better
formatMs(v)} max={max} ascending emptyText="No first-token timings yet." />
Output speed · higher is better
`${v.toFixed(0)} tok/s`} max={max} emptyText="No streaming speed measured yet." />
);
}
/** Tiny 12-point sparkline for KPI tiles (de-emphasised hue, no axes). */
export function Sparkline({ values, color = "var(--accent)", height = 28 }: { values: number[]; color?: string; height?: number }) {
const pts = values.slice(-12);
if (pts.length < 2 || !pts.some((v) => v > 0)) return null;
const maxV = Math.max(...pts);
const w = 72;
const step = w / (pts.length - 1);
const d = pts.map((v, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(height - 2 - (v / maxV) * (height - 4)).toFixed(1)}`).join(" ");
return (
);
}