import { Icon } from "./Icon"; import { Fragment, useEffect, useMemo, useState } from "react"; import { useFetch } from "../hooks"; import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n"; import { navigate } from "../router"; import { useStore } from "../store"; import type { User } from "../types"; import type { Aggregate } from "../types"; import { Heatmap, Legend, SERIES, StackedBars, type Series } from "./Charts"; import { BudgetPanel } from "./Budget"; type Row = Aggregate & Record; interface StatsData { total: Aggregate; by_day: Row<"day">[]; by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[]; by_month: Row<"month">[]; /** First recorded request (any range): the years to choose from. */ first_ts: number | null; by_model: Row<"model">[]; by_provider: Row<"provider">[]; by_agent: Row<"agent">[]; by_user: Row<"user">[]; /** Last 14 days: slot = "YYYY-MM-DD HH" (local time). */ heatmap: (Aggregate & { slot: string })[]; top_sessions: (Aggregate & { session_id: string; title: string })[]; currency: string; from: number; to: number; } const RANGES: { key: string; days: number; label: Key }[] = [ { key: "7", days: 7, label: "stats.range7" }, { key: "30", days: 30, label: "stats.range30" }, { key: "90", days: 90, label: "stats.range90" }, { key: "365", days: 365, label: "stats.range365" }, { key: "all", days: 0, label: "stats.rangeAll" }, ]; type HeatMetric = "requests" | "tokens" | "cost"; /** All months (YYYY-MM, local time) from the month of `fromMs` to the month of `toMs`. */ function monthRange(fromMs: number, toMs: number): string[] { const out: string[] = []; const d = new Date(fromMs); d.setDate(1); d.setHours(0, 0, 0, 0); for (; d.getTime() <= toMs && out.length < 600; d.setMonth(d.getMonth() + 1)) out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`); return out; } function dayKey(ms: number) { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } /** All days between from and to (local time), so empty days show as gaps. */ function dayRange(fromMs: number, toMs: number, firstDataDay?: string): string[] { const start = new Date(firstDataDay && fromMs === 0 ? `${firstDataDay}T00:00:00` : fromMs); start.setHours(0, 0, 0, 0); const out: string[] = []; for (let d = new Date(start); d.getTime() <= toMs && out.length < 800; d.setDate(d.getDate() + 1)) out.push(dayKey(d.getTime())); return out; } const FOLD_KEY = "codecraft.statsFolded"; function foldedSet(): Set { try { return new Set(JSON.parse(localStorage.getItem(FOLD_KEY) ?? "[]")); } catch { return new Set(); } } /** Panel with a title that folds the content; the state is remembered per device. */ function Fold({ id, title, children }: { id: string; title: React.ReactNode; children: React.ReactNode }) { const [closed, setClosed] = useState(() => foldedSet().has(id)); const toggle = () => { const next = !closed; setClosed(next); try { const set = foldedSet(); if (next) set.add(id); else set.delete(id); localStorage.setItem(FOLD_KEY, JSON.stringify([...set])); } catch { /* storage unavailable */ } }; return (

{!closed && children}
); } function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) { return (
{label}
{value}
{sub &&
{sub}
}
); } /** Effective cost per 1M tokens (input incl. cache + output). */ function perMillion(r: Aggregate, currency: string) { const tokens = r.input_tokens + r.cache_read_tokens + r.cache_write_tokens + r.output_tokens; return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–"; } function AggTable({ rows, keyName, label, currency, link, group, }: { rows: (Aggregate & Record)[]; keyName: string; label: string; currency: string; link?: (r: any) => void; /** Group key of a row (e.g. the provider of a model): groups fold. */ group?: (r: any) => string; }) { const t = useT(); const [shut, setShut] = useState>(new Set()); const groups = group ? [...new Set(rows.map(group))] : []; const row = (r: (typeof rows)[number]) => ( {link ? (e.preventDefault(), link(r))}>{r[keyName]} : r[keyName]} {fmtNumber(r.requests)} {fmtCompact(r.input_tokens + r.cache_write_tokens)} {fmtCompact(r.cache_read_tokens)} {fmtCompact(r.output_tokens)} {fmtNumber(r.avg_ttft_ms)} ms {fmtNumber(r.output_tps, 1)} {fmtNumber(r.prefill_tps)} {fmtCost(r.cost, currency)} {perMillion(r, currency)} ); return (
{group ? groups.map((g) => { const members = rows.filter((r) => group(r) === g); const open = !shut.has(g); return ( {open && members.map(row)} ); }) : rows.map(row)}
{label} {t("stats.requests")} {t("stats.input")} {t("stats.cacheRead")} {t("stats.output")} {t("stats.ttft")} {t("stats.outputSpeed")} {t("stats.prefillSpeed")} {t("stats.cost")} {t("stats.perMillion")}
{fmtNumber(members.reduce((a, r) => a + r.requests, 0))} {fmtCost(members.reduce((a, r) => a + r.cost, 0), currency)}
); } const HEAT_DAYS = 14; export function Stats({ onMenu }: { onMenu: () => void }) { const t = useT(); const [range, setRange] = useState("30"); const [heat, setHeat] = useState("requests"); const [tableView, setTableView] = useState(false); const isAdmin = useStore((s) => s.me?.role === "admin"); const users = useFetch(isAdmin ? "/users" : null).data ?? []; const [userFilter, setUserFilter] = useState(""); const tz = -new Date().getTimezoneOffset(); // Stable per range: rounded to the minute so the URL doesn't change on every render. const now = Math.floor(Date.now() / 60000) * 60000 + 60000; // "y2025": that calendar year (local time); otherwise a rolling window. const year = /^y(\d{4})$/.test(range) ? Number(range.slice(1)) : 0; const days = RANGES.find((r) => r.key === range)?.days ?? 0; const from = year ? new Date(year, 0, 1).getTime() : days ? now - days * 86400000 : 0; const to = year ? Math.min(now, new Date(year + 1, 0, 1).getTime()) : now; const { data, error } = useFetch(`/stats?from=${from}&to=${to}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`); /** Download of what is shown: all tables as XLSX, or one table as CSV. */ const exportAs = (what: string) => { if (!what) return; const q = `from=${from}&to=${to}&tz=${tz}&lang=${getLang()}${userFilter ? `&user=${userFilter}` : ""}`; const a = document.createElement("a"); a.href = `/api/stats/export?${q}&format=${what === "xlsx" ? "xlsx" : "csv&table=" + what.slice(4)}`; a.download = ""; document.body.appendChild(a); a.click(); a.remove(); }; // Years since the first recorded request (the data survives indefinitely). const [firstYear, setFirstYear] = useState(new Date().getFullYear()); useEffect(() => { if (data?.first_ts) setFirstYear(new Date(data.first_ts).getFullYear()); }, [data?.first_ts]); const years = Array.from({ length: new Date().getFullYear() - firstYear + 1 }, (_, i) => new Date().getFullYear() - i); const spanDays = ((data?.to ?? now) - (data?.from || data?.first_ts || now)) / 86400000; // Long ranges: one bar per month instead of one per day. const monthly = spanDays > 120; const view = useMemo(() => { if (!data) return undefined; const bucket = (day: string) => (monthly ? day.slice(0, 7) : day); const cats = monthly ? monthRange(data.from || data.first_ts || data.to, data.to) : dayRange(data.from, data.to, data.by_day[0]?.day); // Up to 7 models by cost get their own color; the rest fold into "Other". const ranked = [...data.by_model].sort((a, b) => b.cost - a.cost || b.requests - a.requests); const named = ranked.slice(0, ranked.length > 8 ? 7 : 8).map((m) => m.model); const other = t("stats.other"); const modelSeries: Series[] = [...named, ...(ranked.length > 8 ? [other] : [])].map((m, i) => ({ key: m, label: m, color: SERIES[i] })); const costByDay = cats.map(() => ({}) as Record); const idx = new Map(cats.map((c, i) => [c, i])); for (const r of data.by_day_model) { const i = idx.get(bucket(r.day)); if (i === undefined) continue; const key = named.includes(r.model) ? r.model : other; costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost; } const tokenSeries: Series[] = [ { key: "input", label: t("stats.input"), color: SERIES[0] }, { key: "cache", label: t("stats.cacheRead"), color: SERIES[1] }, { key: "output", label: t("stats.output"), color: SERIES[2] }, ]; const tokensByDay = cats.map((c): Record => { const d = monthly ? data.by_month.find((x) => x.month === c) : data.by_day.find((x) => x.day === c); return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {}; }); // Rolling window: the last 14 days, today at the bottom. const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; const heatDays = Array.from({ length: HEAT_DAYS }, (_, i) => { const d = new Date(); d.setHours(12, 0, 0, 0); d.setDate(d.getDate() - (HEAT_DAYS - 1 - i)); return iso(d); }); const rowOf = new Map(heatDays.map((d, i) => [d, i])); const cells = Array(HEAT_DAYS * 24).fill(0); for (const h of data.heatmap) { const [day, hour] = h.slot.split(" "); const row = rowOf.get(day); if (row === undefined) continue; cells[row * 24 + Number(hour)] = heat === "requests" ? h.requests : heat === "cost" ? h.cost : h.input_tokens + h.cache_read_tokens + h.cache_write_tokens + h.output_tokens; } const wd = new Intl.DateTimeFormat(getLang(), { weekday: "short" }); const dm = new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "numeric" }); const full = new Intl.DateTimeFormat(getLang(), { weekday: "short", day: "numeric", month: "short" }); const heatRows = heatDays.map((d) => { const date = new Date(`${d}T12:00:00`); return { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) }; }); return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows }; }, [data, heat, t, monthly]); const dayLabel = (d: string) => monthly ? new Intl.DateTimeFormat(getLang(), { month: "short", year: "2-digit" }).format(new Date(`${d}-01T00:00:00`)) : new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`)); const cur = data?.currency ?? "USD"; const tot = data?.total; const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v)); return ( <>

{t("nav.stats")}

{data && (
{data.from ? fmtDate(data.from, false) : t("stats.rangeAll")} – {fmtDate(data.to, false)}
)}
{RANGES.map((r) => ( ))}
{years.length > 1 && ( )} {isAdmin && ( )}
{error &&
{error}
} {!data && !error &&
{t("common.loading")}
} {isAdmin && } {data && tot && view && ( <>
{tot.requests === 0 ? (
{t("stats.noData")}
) : tableView ? ( ) : ( <> fmtCost(v, cur)} />
)} {tot.requests > 0 && ( <> String(r.model).split("/")[0]} /> {isAdmin && !userFilter && data.by_user.length > 1 && ( )} !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} /> )} )}
); }