"use client"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; import { useMemo } from "react"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, } from "../../../shadcnui"; import { TokenUsageAdminTimelineInterface } from "../data/tokenusage-admin-timeline.interface"; import { Metric, StackBy } from "../data/tokenusage-admin.types"; import { useUsageFormatters } from "../lib/formatters"; import { operationLabel } from "../lib/operation-label"; import { CATEGORICAL_CEILING, ChartMode, OTHER_COLOR, seriesColor } from "../lib/palette"; /** The rollup series every series past the colour ceiling folds into. */ const OTHER_SERIES = "other"; type TokenUsageTimelineChartProps = { rows: TokenUsageAdminTimelineInterface[]; metric: Metric; stackBy: StackBy; className?: string; }; type TimelineBucket = { bucket: string } & Record; /** * The bucket key, derived with UTC getters. * * The backend field is `type: "date"` — a calendar day with no time — and the * model parses the `YYYY-MM-DD` wire value with `new Date(...)`, which lands on * UTC midnight. Reading it back with local getters would shift the bucket a day * early west of UTC, so the key (and every label built from it) stays in UTC. */ const bucketKey = (date: Date): string => { const y = date.getUTCFullYear(); const m = `${date.getUTCMonth() + 1}`.padStart(2, "0"); const d = `${date.getUTCDate()}`.padStart(2, "0"); return `${y}-${m}-${d}`; }; const metricValue = (row: TokenUsageAdminTimelineInterface, metric: Metric): number => { if (metric === "cost") return row.cost; if (metric === "credits") return row.credits; return row.tokensIn + row.tokensOut; }; /** * The granularity the data was bucketed at, inferred from the spacing between * consecutive buckets. The backend chooses the granularity and the rows carry * the consequence, so the axis reads it off the data rather than taking a prop * that could disagree with what was actually fetched. */ const inferGranularity = (buckets: string[]): "day" | "week" | "month" => { if (buckets.length < 2) return "day"; const dayMs = 24 * 60 * 60 * 1000; let smallestGap = Number.POSITIVE_INFINITY; for (let i = 1; i < buckets.length; i += 1) { const gap = (Date.parse(buckets[i]) - Date.parse(buckets[i - 1])) / dayMs; if (gap > 0 && gap < smallestGap) smallestGap = gap; } if (smallestGap >= 28) return "month"; if (smallestGap >= 7) return "week"; return "day"; }; /** * Usage over time as a stacked bar chart. * * Colour does an IDENTITY job here — each stacked segment is a series, not a * magnitude — so it draws the fixed categorical order from `../lib/palette` and * never generates or cycles a hue: series past the ceiling are summed into a * single "other" segment painted in the palette's neutral. * * The palette's light-mode validator run carries a sub-3:1 contrast WARN on * three slots, which obliges a relief channel. That is why the legend and the * value-carrying tooltip below are not optional decoration: they are what makes * the low-contrast fills readable. */ export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: TokenUsageTimelineChartProps) { const t = useTranslations(); const { compact, bucketDate, metricValue: formatValue } = useUsageFormatters(); const { resolvedTheme } = useTheme(); const mode: ChartMode = resolvedTheme === "dark" ? "dark" : "light"; const { chartData, seriesKeys } = useMemo(() => { if (rows.length === 0) return { chartData: [] as TimelineBucket[], seriesKeys: [] as string[] }; // Totals per raw series decide who keeps an identity colour: the biggest // seven do, everything else is summed into "other". Ranking by total (not by // first appearance) keeps the assignment stable while a filter changes which // series are present. const totals = new Map(); for (const row of rows) totals.set(row.series, (totals.get(row.series) ?? 0) + metricValue(row, metric)); const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([series]) => series); const named = ranked.slice(0, CATEGORICAL_CEILING); const folded = new Set(ranked.slice(CATEGORICAL_CEILING)); const keys = folded.size > 0 ? [...named, OTHER_SERIES] : named; const byBucket = new Map(); for (const row of rows) { const key = bucketKey(row.bucket); let entry = byBucket.get(key); if (!entry) { entry = { bucket: key }; for (const series of keys) entry[series] = 0; byBucket.set(key, entry); } const series = folded.has(row.series) ? OTHER_SERIES : row.series; entry[series] = (entry[series] as number) + metricValue(row, metric); } return { chartData: [...byBucket.values()].sort((a, b) => a.bucket.localeCompare(b.bucket)), seriesKeys: keys, }; }, [rows, metric]); const seriesLabel = (series: string): string => { if (series === OTHER_SERIES) { const key = "token_usage.series.other"; return t.has(key) ? t(key) : "Other"; } // Stacking by company makes the series a company NAME, which is data, not // vocabulary — it is never looked up. Scope and type keys are vocabulary the // consuming app supplies, with the raw key as the fallback. if (stackBy === "company") return series; return operationLabel( series, (key) => t(key), (key) => t.has(key), ); }; const chartConfig = useMemo( () => Object.fromEntries(seriesKeys.map((series) => [series, { label: seriesLabel(series) }])) as ChartConfig, [seriesKeys, stackBy], ); if (chartData.length === 0) { const key = "token_usage.timeline.empty"; return (

{t.has(key) ? t(key) : "No data in the selected period"}

); } const granularity = inferGranularity(chartData.map((entry) => entry.bucket)); const seriesColorFor = (series: string, index: number) => series === OTHER_SERIES ? OTHER_COLOR : seriesColor(index, mode); return (
{/* The pivot, exposed for assertions and for screen readers that would otherwise get nothing from the SVG. */} {JSON.stringify(chartData)} {JSON.stringify(seriesKeys)} bucketDate(value, granularity)} /> compact(value)} /> bucketDate(String(value), granularity)} // Without this the tooltip falls back to toLocaleString() and // prints the raw stored precision (credits carry 4 decimals), // which disagrees with every other number on the page. valueFormatter={(value) => formatValue(Number(value), metric)} // Every series appears in every bucket's payload, so a day with // one active operation would otherwise list the whole vocabulary // as zeros and bury the one number that matters. hideZeroValues /> } /> {seriesKeys.length >= 2 ? } /> : null} {seriesKeys.map((series, index) => ( ))}
); }