Files
CodePilot/src/components/settings/UsageStatsSection.tsx
T
7418andClaude Opus 4.6 94528edac9 fix: use local timezone for date-based features (check-in, usage stats)
Root cause: `new Date().toISOString().slice(0, 10)` returns UTC date,
causing date-based features to be off by up to ±1 day for non-UTC users.

Check-in / assistant workspace:
- Add `getLocalDateString()` utility, replace all UTC date comparisons
- Schema v2→v3 migration: normalize lastCheckInDate only when it matches
  UTC today (ambiguous window); leave past dates untouched so check-in
  triggers correctly after upgrade
- Runtime compat in `needsDailyCheckIn`: accept UTC today as fallback
  for edge cases the migration cannot catch
- `needsDailyCheckIn(state, now?)` accepts optional time for testability

Usage stats:
- Replace single-offset SQL bucketing (`DATE(datetime(..., '+N minutes'))`)
  with JS-side per-row aggregation using `getLocalDateString(new Date(utc))`
- Date methods account for historical DST offset at each message's timestamp
- `getTokenUsageStats(days, now?)` accepts optional time to pin window start
- Window boundary via `localDayStartAsUTC()` is already DST-aware

Tests (new file: timezone-boundaries.test.ts, 22 tests):
- getLocalDateString at UTC+9/UTC-5/UTC boundaries with fixed timestamps
- needsDailyCheckIn: compat suppression, clearly-past trigger, local match
- v2→v3 migration: targeted normalization, past-date preservation, null safe
- localDayStartAsUTC: UTC+8/UTC-5 midnight boundaries
- Usage stats bucketing: UTC+9 cross-day, summary window, DST spring-forward
- All usage stats tests use pinned `now` — no time-bomb from fixed test data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:55:29 +08:00

449 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { getLocalDateString } from "@/lib/utils";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { useTranslation } from "@/hooks/useTranslation";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface UsageStatsResponse {
summary: {
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
total_sessions: number;
cache_read_tokens: number;
cache_creation_tokens: number;
};
daily: Array<{
date: string;
model: string;
input_tokens: number;
output_tokens: number;
cost: number;
}>;
}
// Recharts v3 Payload — only the fields we actually read
interface RechartsPayloadItem {
name?: string | number;
dataKey?: string | number;
value?: number;
color?: string;
fill?: string;
payload?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Number formatting helpers
// ---------------------------------------------------------------------------
/**
* Format a token count for display.
* 0 → "0"
* 999 → "999"
* 1_234 → "1,234"
* 9_999 → "9,999"
* 10_000 → "10.0K"
* 1_234_567 → "1.23M"
* 1_200_000_000 → "1.20B"
*/
function formatTokens(n: number): string {
if (n === 0) return "0";
if (n < 0) return "-" + formatTokens(-n);
if (n < 10_000) return n.toLocaleString("en-US");
if (n < 1_000_000) return (n / 1_000).toFixed(1) + "K";
if (n < 1_000_000_000) return (n / 1_000_000).toFixed(2) + "M";
return (n / 1_000_000_000).toFixed(2) + "B";
}
/**
* Format a USD cost for display.
* 0 → "$0.00"
* 0.00015 → "$0.0002"
* 0.0052 → "$0.0052"
* 0.12 → "$0.12"
* 1.5 → "$1.50"
* 1234.5 → "$1,234.50"
*/
function formatCost(n: number): string {
if (n === 0) return "$0.00";
if (n < 0.01) return "$" + n.toFixed(4);
if (n >= 1000) return "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return "$" + n.toFixed(2);
}
/**
* Format a percentage.
* NaN / undefined → "N/A"
* 0 → "0%"
* 0.456 → "0.5%"
* 12.345 → "12.3%"
* 100 → "100%"
*/
function formatPercent(n: number | undefined): string {
if (n === undefined || isNaN(n)) return "N/A";
if (n === 0) return "0%";
if (n === 100) return "100%";
return n.toFixed(1) + "%";
}
/** Short date for chart x-axis: "2/24" */
function shortDate(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
return `${d.getMonth() + 1}/${d.getDate()}`;
}
// ---------------------------------------------------------------------------
// Stable model → color mapping
// ---------------------------------------------------------------------------
const COLOR_PALETTE = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
];
/**
* Assign a stable color to each unique model key (e.g. "sonnet", "Kimi/sonnet").
* Uses the index in the sorted model list so each distinct key gets its own color.
*/
function getModelColor(_model: string, idx: number): string {
return COLOR_PALETTE[idx % COLOR_PALETTE.length];
}
// ---------------------------------------------------------------------------
// Chart tooltip (recharts v3 compatible)
// ---------------------------------------------------------------------------
function ChartTooltip({ active, payload, label }: {
active?: boolean;
payload?: ReadonlyArray<RechartsPayloadItem>;
label?: string | number;
}) {
if (!active || !payload?.length) return null;
// Filter out entries with zero or missing values
const items = payload.filter((p) => typeof p.value === "number" && p.value > 0);
if (items.length === 0) return null;
return (
<div className="rounded-lg border border-border/50 bg-popover px-3 py-2 text-xs shadow-md">
<p className="mb-1 font-medium text-popover-foreground">{label}</p>
{items.map((p, i) => {
const displayName = String(p.name ?? p.dataKey ?? "unknown");
const displayColor = p.color || p.fill || "var(--color-chart-1)";
return (
<div key={displayName + i} className="flex items-center gap-2 text-popover-foreground/80">
<span
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: displayColor }}
/>
<span>{displayName}</span>
<span className="ml-auto font-mono">{formatTokens(p.value ?? 0)}</span>
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Day range selector
// ---------------------------------------------------------------------------
const RANGE_OPTIONS = [
{ label: "7D", days: 7 },
{ label: "30D", days: 30 },
{ label: "90D", days: 90 },
] as const;
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function UsageStatsSection() {
const { t } = useTranslation();
const [days, setDays] = useState(30);
const [data, setData] = useState<UsageStatsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchStats = useCallback(async (d: number) => {
// Abort any in-flight request to avoid stale data overwriting fresh data
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/usage/stats?days=${d}`, { signal: controller.signal });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
setData(await res.json());
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
setError(err instanceof Error ? err.message : String(t('usage.loadError')));
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, [t]);
useEffect(() => {
fetchStats(days);
return () => abortRef.current?.abort();
}, [days, fetchStats]);
// Derive chart data: pivot daily rows into { date, model1: N, model2: N, ... }
const { chartData, models } = deriveChartData(data?.daily ?? [], days);
const summary = data?.summary;
const totalTokens = summary
? summary.total_input_tokens + summary.total_output_tokens
: 0;
const cacheTotal = summary
? summary.cache_read_tokens + summary.total_input_tokens
: 0;
const cacheRate = summary && cacheTotal > 0
? (summary.cache_read_tokens / cacheTotal) * 100
: undefined;
return (
<div className="max-w-3xl space-y-6">
{/* Day range selector */}
<div className="flex items-center gap-2">
{RANGE_OPTIONS.map((opt) => (
<button
key={opt.days}
onClick={() => setDays(opt.days)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
days === opt.days
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent"
}`}
>
{opt.label}
</button>
))}
</div>
{/* Data cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('usage.totalTokens')}
value={loading ? "–" : formatTokens(totalTokens)}
sub={
summary
? `${t('usage.input')} ${formatTokens(summary.total_input_tokens)} · ${t('usage.output')} ${formatTokens(summary.total_output_tokens)}`
: undefined
}
/>
<StatCard
label={t('usage.totalCost')}
value={loading ? "–" : formatCost(summary?.total_cost ?? 0)}
/>
<StatCard
label={t('usage.sessions')}
value={loading ? "–" : String(summary?.total_sessions ?? 0)}
/>
<StatCard
label={t('usage.cacheHitRate')}
value={loading ? "–" : formatPercent(cacheRate)}
sub={
summary && summary.cache_read_tokens > 0
? `${formatTokens(summary.cache_read_tokens)} ${t('usage.cached')}`
: undefined
}
/>
</div>
{/* Bar chart */}
<div className="rounded-lg border border-border/50 p-4">
<h3 className="mb-4 text-sm font-medium">{t('usage.dailyChart')}</h3>
{loading && (
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
{t('usage.loading')}
</div>
)}
{error && (
<div className="flex h-64 items-center justify-center text-sm text-red-500">
{error}
</div>
)}
{!loading && !error && chartData.length === 0 && (
<div className="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-40"
>
<path d="M18 20V10" />
<path d="M12 20V4" />
<path d="M6 20v-6" />
</svg>
<p className="text-sm">{t('usage.noData')}</p>
<p className="text-xs">{t('usage.noDataHint')}</p>
</div>
)}
{!loading && !error && chartData.length > 0 && (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 4, right: 4, left: -8, bottom: 0 }}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="var(--color-border)"
opacity={0.5}
/>
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={formatTokens}
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
tickLine={false}
axisLine={false}
width={54}
/>
<Tooltip
content={(props) => <ChartTooltip {...props} />}
cursor={{ fill: "var(--color-accent)", opacity: 0.3 }}
/>
<Legend
wrapperStyle={{ fontSize: 11, paddingTop: 8 }}
iconType="circle"
iconSize={8}
/>
{models.map((model, idx) => (
<Bar
key={model}
name={model}
dataKey={model}
stackId="tokens"
fill={getModelColor(model, idx)}
radius={idx === models.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]}
maxBarSize={40}
/>
))}
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function StatCard({
label,
value,
sub,
}: {
label: string;
value: string;
sub?: string;
}) {
return (
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
{sub && <p className="mt-0.5 text-[11px] text-muted-foreground">{sub}</p>}
</div>
);
}
// ---------------------------------------------------------------------------
// Data transform: pivot daily array into chart-friendly format
// ---------------------------------------------------------------------------
function deriveChartData(daily: UsageStatsResponse["daily"], days: number): {
chartData: Array<Record<string, string | number>>;
models: string[];
} {
if (daily.length === 0) return { chartData: [], models: [] };
// Normalise model names: empty string → "unknown"
const normalised = daily.map((row) => ({
...row,
model: row.model || "unknown",
}));
// Collect all unique models
const modelSet = new Set<string>();
for (const row of normalised) {
modelSet.add(row.model);
}
const models = Array.from(modelSet).sort();
// Group by date
const byDate = new Map<string, Record<string, number>>();
for (const row of normalised) {
const total = row.input_tokens + row.output_tokens;
if (!byDate.has(row.date)) {
byDate.set(row.date, {});
}
const entry = byDate.get(row.date)!;
entry[row.model] = (entry[row.model] || 0) + total;
}
// Build a continuous date range covering the full window so x-axis has no gaps
const allDates: string[] = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
allDates.push(getLocalDateString(d));
}
// Format for recharts, filling missing dates with zeros
const chartData = allDates.map((date) => {
const modelTokens = byDate.get(date) || {};
const row: Record<string, string | number> = { date: shortDate(date) };
for (const m of models) {
row[m] = modelTokens[m] || 0;
}
return row;
});
return { chartData, models };
}