daili-usage-keeper / web /src /components /usage /TokenBreakdownChart.tsx
pjpjq's picture
fix: build usage keeper from source
b034029 verified
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<TokenCategory, { border: string; bg: string }> = {
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<UsageOverviewPayload['series']>;
export type TokenBreakdownChartSeries = {
labels: string[];
dataByCategory: Record<TokenCategory, number[]>;
};
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<TokenCategory, string> = {
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 (
<Card
title={t('usage_stats.token_breakdown_title')}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : chartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{chartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}