import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Line } from 'react-chartjs-2'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import type { TokenCategory } from '@/utils/usage'; import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig'; import type { UsageOverviewPayload } from './hooks/useUsageData'; import styles from '@/pages/UsagePage.module.scss'; const TOKEN_COLORS: Record = { input: { border: '#8b8680', bg: 'rgba(139, 134, 128, 0.25)' }, output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' }, cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' }, reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' } }; const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning']; const HOUR_MS = 60 * 60 * 1000; type TokenBreakdownChartPeriod = 'hour' | 'day'; type TokenSeriesSource = NonNullable; export type TokenBreakdownChartSeries = { labels: string[]; dataByCategory: Record; }; export type BuildTokenBreakdownChartSeriesOptions = { usage: UsageOverviewPayload | null; period: TokenBreakdownChartPeriod; hourWindowHours?: number; endMs?: number; }; const normalizeHourWindow = (hourWindowHours?: number): number => { if (!Number.isFinite(hourWindowHours) || !hourWindowHours || hourWindowHours <= 0) { return 24; } return Math.min(Math.max(Math.floor(hourWindowHours), 1), 24); }; const formatHourBucketKey = (timestampMs: number): string => `${new Date(timestampMs).toISOString().slice(0, 13)}:00:00Z`; const formatChartLabel = (label: string, period: TokenBreakdownChartPeriod) => { if (period !== 'hour') return label; const date = new Date(label); if (Number.isNaN(date.getTime())) return label; return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; }; const getTokenSource = (usage: UsageOverviewPayload | null, period: TokenBreakdownChartPeriod): TokenSeriesSource | undefined => ( period === 'hour' ? (usage?.hourly_series ?? usage?.series) : (usage?.daily_series ?? usage?.series) ); const buildHourlyLabels = (source: TokenSeriesSource | undefined, hourWindowHours?: number, endMs?: number) => { const labels = Object.keys(source?.input_tokens ?? {}).sort((a, b) => a.localeCompare(b)); if (labels.length === 0) return []; const bucketCount = normalizeHourWindow(hourWindowHours); const latestLabelMs = Date.parse(labels[labels.length - 1]); const requestedEndMs = Number.isFinite(endMs) && endMs && endMs > 0 ? endMs : latestLabelMs; const currentHour = new Date(requestedEndMs); currentHour.setUTCMinutes(0, 0, 0); const earliestTime = currentHour.getTime() - ((bucketCount - 1) * HOUR_MS); return Array.from({ length: bucketCount }, (_, index) => formatHourBucketKey(earliestTime + index * HOUR_MS)); }; export const buildTokenBreakdownChartSeries = ({ usage, period, hourWindowHours, endMs, }: BuildTokenBreakdownChartSeriesOptions): TokenBreakdownChartSeries => { const source = getTokenSource(usage, period); const labels = period === 'hour' ? buildHourlyLabels(source, hourWindowHours, endMs) : Object.keys(source?.input_tokens ?? {}).sort((a, b) => a.localeCompare(b)); return { labels: labels.map((label) => formatChartLabel(label, period)), dataByCategory: { input: labels.map((label) => Number(source?.input_tokens?.[label] ?? 0)), output: labels.map((label) => Number(source?.output_tokens?.[label] ?? 0)), cached: labels.map((label) => Number(source?.cached_tokens?.[label] ?? 0)), reasoning: labels.map((label) => Number(source?.reasoning_tokens?.[label] ?? 0)), }, }; }; export interface TokenBreakdownChartProps { usage: UsageOverviewPayload | null; loading: boolean; isDark: boolean; isMobile: boolean; hourWindowHours?: number; endMs?: number; } export function TokenBreakdownChart({ usage, loading, isDark, isMobile, hourWindowHours, endMs }: TokenBreakdownChartProps) { const { t } = useTranslation(); const [period, setPeriod] = useState<'hour' | 'day'>('hour'); const { chartData, chartOptions } = useMemo(() => { const series = buildTokenBreakdownChartSeries({ usage, period, hourWindowHours, endMs }); const categoryLabels: Record = { input: t('usage_stats.input_tokens'), output: t('usage_stats.output_tokens'), cached: t('usage_stats.cached_tokens'), reasoning: t('usage_stats.reasoning_tokens') }; const data = { labels: series.labels, datasets: CATEGORIES.map((cat) => ({ label: categoryLabels[cat], data: series.dataByCategory[cat], borderColor: TOKEN_COLORS[cat].border, backgroundColor: TOKEN_COLORS[cat].bg, pointBackgroundColor: TOKEN_COLORS[cat].border, pointBorderColor: TOKEN_COLORS[cat].border, fill: true, tension: 0.35 })) }; const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile }); const options = { ...baseOptions, scales: { ...baseOptions.scales, y: { ...baseOptions.scales?.y, stacked: true }, x: { ...baseOptions.scales?.x, stacked: true } } }; return { chartData: data, chartOptions: options }; }, [usage, period, isDark, isMobile, hourWindowHours, endMs, t]); return ( } > {loading ? (
{t('common.loading')}
) : chartData.labels.length > 0 ? (
{chartData.datasets.map((dataset, index) => (
{dataset.label}
))}
) : (
{t('usage_stats.no_data')}
)}
); }