import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { SettingsPayload } from "@/lib/types"; type TokenUsagePayload = NonNullable; type TokenUsageDay = TokenUsagePayload["days"][number]; type TokenUsageCell = { date: string; total: number; estimated: number; requests: number; sources: NonNullable; future: boolean; }; type TokenUsageMonthLabel = { label: string; column: number; }; const TOKEN_HEATMAP_CELLS = 371; const TOKEN_HEATMAP_COLUMNS = Math.ceil(TOKEN_HEATMAP_CELLS / 7); const TOKEN_USAGE_SOURCE_ORDER = ["user", "api", "cron", "dream", "system"] as const; function addUtcDays(date: Date, days: number): Date { const next = new Date(date); next.setUTCDate(next.getUTCDate() + days); return next; } function isoDay(date: Date): string { return date.toISOString().slice(0, 10); } function utcDateFromIsoDay(day: string): Date { const [year, month, date] = day.split("-").map(Number); return new Date(Date.UTC(year, month - 1, date)); } function isoDayInTimeZone(date: Date, timeZone: string | undefined): string { if (!timeZone) return isoDay(date); try { const parts = new Intl.DateTimeFormat("en", { calendar: "gregory", numberingSystem: "latn", timeZone, year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(date); const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); if (values.year && values.month && values.day) { return [ values.year.padStart(4, "0"), values.month.padStart(2, "0"), values.day.padStart(2, "0"), ].join("-"); } } catch { // Fall through to UTC when the browser cannot resolve the configured timezone. } return isoDay(date); } function buildTokenUsageCalendar( days: TokenUsageDay[] | undefined, monthFormatter: Intl.DateTimeFormat, timeZone: string | undefined, ): { cells: TokenUsageCell[]; monthLabels: TokenUsageMonthLabel[] } { const byDate = new Map((days ?? []).map((day) => [day.date, day])); const today = utcDateFromIsoDay(isoDayInTimeZone(new Date(), timeZone)); const end = addUtcDays(today, 6 - today.getUTCDay()); const start = addUtcDays(end, -(TOKEN_HEATMAP_CELLS - 1)); const monthLabels: TokenUsageMonthLabel[] = []; const cells = Array.from({ length: TOKEN_HEATMAP_CELLS }, (_, index) => { const date = addUtcDays(start, index); const key = isoDay(date); const row = byDate.get(key); if (date.getUTCDate() === 1) { monthLabels.push({ label: monthFormatter.format(date), column: Math.floor(index / 7) + 1, }); } return { date: key, total: row?.total_tokens ?? 0, estimated: row?.estimated_tokens ?? 0, requests: row?.requests ?? 0, sources: row?.sources ?? {}, future: date > today, }; }); return { cells, monthLabels }; } function tokenUsageSourceLabel( source: string, tx: (key: string, fallback: string, values?: Record) => string, ): string { if (source === "user") return tx("settings.usage.sources.user", "Chat"); if (source === "api") return tx("settings.usage.sources.api", "API"); if (source === "cron") return tx("settings.usage.sources.cron", "Automations"); if (source === "dream") return tx("settings.usage.sources.dream", "Memory"); return tx("settings.usage.sources.system", "System"); } function tokenUsageSourceBreakdown( cell: TokenUsageCell, tx: (key: string, fallback: string, values?: Record) => string, ): string { const known = TOKEN_USAGE_SOURCE_ORDER.filter((source) => cell.sources[source]?.total_tokens > 0); const extra = Object.keys(cell.sources) .filter((source) => !TOKEN_USAGE_SOURCE_ORDER.includes(source as typeof TOKEN_USAGE_SOURCE_ORDER[number])) .filter((source) => cell.sources[source]?.total_tokens > 0) .sort(); return [...known, ...extra] .map((source) => { const label = tokenUsageSourceLabel(source, tx); const tokens = formatCompactTokens(cell.sources[source]?.total_tokens ?? 0); return `${label} ${tokens}`; }) .join(" · "); } function formatCompactTokens(tokens: number): string { if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens >= 10_000_000 ? 0 : 1)}M`; if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 10_000 ? 0 : 1)}K`; return String(tokens); } function tokenUsageLevel(tokens: number, max: number): number { if (tokens <= 0 || max <= 0) return 0; const ratio = tokens / max; if (ratio >= 0.75) return 4; if (ratio >= 0.45) return 3; if (ratio >= 0.2) return 2; return 1; } function tokenUsageCellClass(level: number, future: boolean): string { if (future) return "bg-transparent ring-1 ring-neutral-200/70 dark:ring-white/[0.045]"; if (level === 4) return "bg-sky-300 dark:bg-sky-300"; if (level === 3) return "bg-sky-400/85 dark:bg-sky-500/80"; if (level === 2) return "bg-sky-500/60 dark:bg-sky-700/85"; if (level === 1) return "bg-sky-500/30 dark:bg-sky-900/80"; return "bg-neutral-200/70 ring-1 ring-black/[0.025] dark:bg-white/[0.08] dark:ring-white/[0.035]"; } export function TokenUsageHeatmap({ usage, timeZone, }: { usage?: TokenUsagePayload; timeZone?: string; }) { const { t, i18n } = useTranslation(); const tx = (key: string, fallback: string, values?: Record) => t(key, { defaultValue: fallback, ...(values ?? {}) }); const monthFormatter = useMemo( () => new Intl.DateTimeFormat(i18n.language, { month: "short", timeZone: "UTC" }), [i18n.language], ); const { cells, monthLabels } = useMemo( () => buildTokenUsageCalendar(usage?.days, monthFormatter, timeZone), [monthFormatter, timeZone, usage?.days], ); const maxTokens = Math.max(0, ...cells.map((cell) => cell.total)); return (
{tx("settings.usage.shortTitle", "Token Usage")}
{monthLabels.map((month) => ( {month.label} ))}
{cells.map((cell) => { const level = tokenUsageLevel(cell.total, maxTokens); const baseLabel = cell.future ? cell.date : tx("settings.usage.cellTitle", "{{date}}: {{tokens}} tokens, {{requests}} requests", { date: cell.date, tokens: formatCompactTokens(cell.total), requests: cell.requests, }); const label = cell.future || cell.estimated <= 0 ? baseLabel : `${baseLabel} · ${ cell.estimated >= cell.total ? tx("settings.usage.estimated", "estimated") : tx("settings.usage.includesEstimates", "includes estimates") }`; const breakdown = cell.future ? "" : tokenUsageSourceBreakdown(cell, tx); const ariaLabel = breakdown ? `${label} · ${breakdown}` : label; return ( {label} {breakdown ? ( {breakdown} ) : null} ); })}
); }