返回 DeepSeek-Reasonix
UsageStatsPanel.tsx
根目录 / desktop / frontend / src / components / UsageStatsPanel.tsx
1 import { SettingsOptions } from "./SettingsOptions";
2 // UsageStatsPanel renders the "usage statistics" subtab inside the Models
3 // settings page. It reads aggregated stats from the Go backend (App.UsageStats)
4 // and draws three charts by hand in SVG — a GitHub-style activity heatmap, a
5 // stacked per-day token trend, and a per-model donut — so no chart library is
6 // needed and theme variables (--accent, --fg, --bg-elev-*) drive
7 // the palette for both stock themes and theme packs. Model colours come from
8 // a fixed two-set categorical palette (--chart-1..5 plus the gray
9 // --chart-other, light/dark variants defined in styles.css from GitHub
10 // Primer's data-viz tokens): a model's colour is its rank among the top five
11 // by token volume, and everything beyond the top five collapses into one gray
12 // "Other" step.
13 // The component's styles live in UsageStatsPanel.css (loaded on demand with
14 // this chunk), so the ~7 KB rule block never inflates the settings bundle.
15 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
16 import { Activity, CalendarDays, ChevronDown, ChevronRight, Coins, Cpu, MessageSquare, MessagesSquare } from "lucide-react";
17 import { useI18n } from "../lib/i18n";
18 import { app } from "../lib/bridge";
19 import type { DailyTokenUsage, ModelTokenUsage, UsageStatsRange, UsageStatsRequest } from "../lib/types";
20 import { formatUsageTokens as formatTokens } from "../lib/usageStatsFormat";
21 import "./UsageStatsPanel.css";
22
23 const RANGE_PRESETS = ["7", "14", "30", "90"] as const;
24 // Every entry point that records usage (see StatsSource tags in the Go
25 // kernel). "all" is the unfiltered aggregate; the rest match one source label.
26 const SOURCES = ["all", "desktop", "cli", "serve", "bot", "remote"] as const;
27
28 // The heatmap always shows a fixed 40-week window regardless of the range
29 // preset (it only follows the source filter).
30 const HEAT_WEEKS = 40;
31 // Custom ranges may span up to ten years. Keep the detailed trend bounded so
32 // one unusual range cannot create thousands of interactive SVG nodes.
33 const MAX_TREND_DAYS = 180;
34
35 // Keep this lazy panel's translations in its own chunk. Putting them in the
36 // eager global English dictionary makes an unopened settings subtab part of
37 // every desktop startup bundle.
38 const USAGE_STATS_TRANSLATIONS = {
39 en: {
40 "common.loading": "Loading…",
41 "common.none": "none",
42 "settings.stats.range": "Time range",
43 "settings.stats.rangePreset.7": "Last 7 days",
44 "settings.stats.rangePreset.14": "Last 14 days",
45 "settings.stats.rangePreset.30": "Last 30 days",
46 "settings.stats.rangePreset.90": "Last 90 days",
47 "settings.stats.rangeCustom": "Custom",
48 "settings.stats.from": "From",
49 "settings.stats.to": "To",
50 "settings.stats.source": "Source",
51 "settings.stats.source.all": "All",
52 "settings.stats.source.desktop": "Desktop",
53 "settings.stats.source.cli": "CLI",
54 "settings.stats.source.serve": "Web",
55 "settings.stats.source.bot": "Bot",
56 "settings.stats.source.remote": "Remote",
57 "settings.stats.refresh": "Refresh",
58 "settings.stats.tokens": "Token usage",
59 "settings.stats.sessions": "Completed turns",
60 "settings.stats.requests": "Requests",
61 "settings.stats.activeDays": "Active days",
62 "settings.stats.cacheRate": "Avg cache hit rate",
63 "settings.stats.cacheRateHint": "Cached input tokens as a share of all input tokens in the range",
64 "settings.stats.cacheHitRate": "Cache hit rate",
65 "settings.stats.hitRateLegend": "Cache hit rate",
66 "settings.stats.topModel": "Most used model",
67 "settings.stats.topModelHint": "Ranked by token volume, not call count",
68 "settings.stats.heatmap": "Activity heatmap",
69 "settings.stats.heatLess": "Less",
70 "settings.stats.heatMore": "More",
71 "settings.stats.dailyTrend": "Daily token trend",
72 "settings.stats.trendLimited": "Showing the latest 180 days",
73 "settings.stats.modelUsage": "Model usage",
74 "settings.stats.other": "Other",
75 "settings.stats.moreModels": "more models",
76 "settings.stats.total": "Total",
77 "settings.stats.percent": "Share",
78 "settings.stats.asOf": "As of",
79 "settings.stats.empty": "No usage data in this range yet. Token usage is recorded from the day this feature ships.",
80 },
81 zh: {
82 "common.loading": "加载中…",
83 "common.none": "无",
84 "settings.stats.range": "时间范围",
85 "settings.stats.rangePreset.7": "最近 7 天",
86 "settings.stats.rangePreset.14": "最近 14 天",
87 "settings.stats.rangePreset.30": "最近 30 天",
88 "settings.stats.rangePreset.90": "最近 90 天",
89 "settings.stats.rangeCustom": "自定义",
90 "settings.stats.from": "开始日期",
91 "settings.stats.to": "结束日期",
92 "settings.stats.source": "统计来源",
93 "settings.stats.source.all": "全部",
94 "settings.stats.source.desktop": "桌面端",
95 "settings.stats.source.cli": "命令行",
96 "settings.stats.source.serve": "网页端",
97 "settings.stats.source.bot": "机器人",
98 "settings.stats.source.remote": "远程工作台",
99 "settings.stats.refresh": "刷新",
100 "settings.stats.tokens": "Tokens 用量",
101 "settings.stats.sessions": "完成轮次",
102 "settings.stats.requests": "请求数量",
103 "settings.stats.activeDays": "活跃天数",
104 "settings.stats.cacheRate": "平均缓存命中率",
105 "settings.stats.cacheRateHint": "时间段内缓存命中 token 占输入 token 的比例",
106 "settings.stats.cacheHitRate": "缓存命中率",
107 "settings.stats.hitRateLegend": "缓存命中率",
108 "settings.stats.topModel": "最常用模型",
109 "settings.stats.topModelHint": "按 token 用量排序,非调用次数",
110 "settings.stats.heatmap": "活跃热力图",
111 "settings.stats.heatLess": "较少",
112 "settings.stats.heatMore": "较多",
113 "settings.stats.dailyTrend": "按天 Token 趋势",
114 "settings.stats.trendLimited": "仅显示最近 180 天",
115 "settings.stats.modelUsage": "模型用量",
116 "settings.stats.other": "其他",
117 "settings.stats.moreModels": "个其他模型",
118 "settings.stats.total": "总用量",
119 "settings.stats.percent": "占比",
120 "settings.stats.asOf": "统计截至",
121 "settings.stats.empty": "当前时间范围内暂无用量数据。Token 用量从本功能启用后开始累计。",
122 },
123 "zh-TW": {
124 "common.loading": "載入中…",
125 "common.none": "無",
126 "settings.stats.range": "時間範圍",
127 "settings.stats.rangePreset.7": "最近 7 天",
128 "settings.stats.rangePreset.14": "最近 14 天",
129 "settings.stats.rangePreset.30": "最近 30 天",
130 "settings.stats.rangePreset.90": "最近 90 天",
131 "settings.stats.rangeCustom": "自訂",
132 "settings.stats.from": "開始日期",
133 "settings.stats.to": "結束日期",
134 "settings.stats.source": "統計來源",
135 "settings.stats.source.all": "全部",
136 "settings.stats.source.desktop": "桌面端",
137 "settings.stats.source.cli": "命令列",
138 "settings.stats.source.serve": "網頁端",
139 "settings.stats.source.bot": "機器人",
140 "settings.stats.source.remote": "遠端工作台",
141 "settings.stats.refresh": "重新整理",
142 "settings.stats.tokens": "Tokens 用量",
143 "settings.stats.sessions": "完成輪次",
144 "settings.stats.requests": "請求數量",
145 "settings.stats.activeDays": "活躍天數",
146 "settings.stats.cacheRate": "平均快取命中率",
147 "settings.stats.cacheRateHint": "時間範圍內快取命中 token 佔輸入 token 的比例",
148 "settings.stats.cacheHitRate": "快取命中率",
149 "settings.stats.hitRateLegend": "快取命中率",
150 "settings.stats.topModel": "最常用模型",
151 "settings.stats.topModelHint": "依 token 用量排序,非呼叫次數",
152 "settings.stats.heatmap": "活躍熱力圖",
153 "settings.stats.heatLess": "較少",
154 "settings.stats.heatMore": "較多",
155 "settings.stats.dailyTrend": "按天 Token 趨勢",
156 "settings.stats.trendLimited": "僅顯示最近 180 天",
157 "settings.stats.modelUsage": "模型用量",
158 "settings.stats.other": "其他",
159 "settings.stats.moreModels": "個其他模型",
160 "settings.stats.total": "總用量",
161 "settings.stats.percent": "佔比",
162 "settings.stats.asOf": "統計截至",
163 "settings.stats.empty": "目前時間範圍內暫無用量資料。Token 用量從此功能啟用後開始累計。",
164 },
165 } as const;
166
167 type UsageStatsKey = keyof typeof USAGE_STATS_TRANSLATIONS.en;
168 type UsageStatsTranslator = (key: UsageStatsKey) => string;
169
170 // Model colour palette: a fixed two-set categorical series (--chart-1..5 with
171 // light/dark variants defined in styles.css, from GitHub Primer's data-viz
172 // tokens). A model's colour is its rank among the top five; models beyond the
173 // top five share one gray "Other" step (--chart-other). The palette is
174 // deliberately independent of --accent so charts stay readable in every theme
175 // style and theme pack, whose tokens only cover the app chrome.
176 // Each series colour is mixed toward --bg-elev like the heatmap levels
177 // (MODEL_COLOR_MIX% colour + the rest background), so the pure hexes sit
178 // softly on the card instead of glaring; the two themes still get the same
179 // hues, only the background differs.
180 const TOP_MODELS = 5;
181 const OTHER_MODEL = "\u0000other"; // sentinel; cannot collide with a real model ref
182 const OTHER_COLOR = "var(--chart-other)";
183 const MODEL_COLOR_MIX = 72; // percent of the series colour in the --bg-elev mix
184 const MAX_TOOLTIP_OTHER_DETAILS = 5;
185 // A grouped day carries the raw tail split so hover tooltips can expand the
186 // "Other" step into its per-model detail without extra queries.
187 type GroupedDaily = DailyTokenUsage & { otherByModel: Record<string, number> };
188 // A grouped model may carry the tail list that "Other" aggregates.
189 type GroupedModel = ModelTokenUsage & { items?: ModelTokenUsage[] };
190
191 // localDay returns today's date (plus/minus offsetDays) in the local calendar,
192 // matching the backend's "2006-01-02" day keys.
193 function localDay(offsetDays: number): string {
194 const d = new Date();
195 d.setDate(d.getDate() + offsetDays);
196 const y = d.getFullYear();
197 const m = String(d.getMonth() + 1).padStart(2, "0");
198 const day = String(d.getDate()).padStart(2, "0");
199 return `${y}-${m}-${day}`;
200 }
201
202 export function UsageStatsPanel() {
203 const { locale } = useI18n();
204 const t = useCallback<UsageStatsTranslator>((key) => USAGE_STATS_TRANSLATIONS[locale][key], [locale]);
205 const [range, setRange] = useState<string>("30");
206 const [customFrom, setCustomFrom] = useState("");
207 const [customTo, setCustomTo] = useState("");
208 const [source, setSource] = useState<string>("all");
209 const [stats, setStats] = useState<UsageStatsRange | null>(null);
210 const [loading, setLoading] = useState(true);
211 const [error, setError] = useState("");
212 const generationRef = useRef(0);
213
214 // Heatmap window: the last HEAT_WEEKS*7 days, fixed regardless of `range`.
215 const heatWindow = useMemo(() => {
216 const to = localDay(0);
217 const from = localDay(-(HEAT_WEEKS * 7 - 1));
218 return { from, to };
219 }, []);
220 const [heatDaily, setHeatDaily] = useState<DailyTokenUsage[]>([]);
221 const heatGenRef = useRef(0);
222
223 const loadHeat = useCallback(async () => {
224 const generation = ++heatGenRef.current;
225 setHeatDaily([]);
226 try {
227 const res = await app.UsageStats({ range: "custom", from: heatWindow.from, to: heatWindow.to, source });
228 if (heatGenRef.current !== generation) return;
229 setHeatDaily(res.daily);
230 } catch {
231 if (heatGenRef.current !== generation) return;
232 // The heatmap is auxiliary — a failed fetch leaves the current source
233 // empty instead of retaining cells from the previous source.
234 setHeatDaily([]);
235 }
236 }, [heatWindow.from, heatWindow.to, source]);
237
238 useEffect(() => {
239 void loadHeat();
240 }, [loadHeat]);
241
242 const load = useCallback(async () => {
243 const generation = ++generationRef.current;
244 // A custom range with empty date pickers must not hit the backend — the
245 // request would carry "" from/to and fail validation. Wait for both dates.
246 if (range === "custom" && (!customFrom || !customTo)) {
247 setStats(null);
248 setLoading(false);
249 setError("");
250 return;
251 }
252 const req: UsageStatsRequest =
253 range === "custom"
254 ? { range, from: customFrom, to: customTo, source }
255 : { range, source };
256 setStats(null);
257 setLoading(true);
258 setError("");
259 try {
260 const res = await app.UsageStats(req);
261 if (generationRef.current !== generation) return; // stale response
262 setStats(res);
263 } catch (e) {
264 if (generationRef.current !== generation) return;
265 setStats(null);
266 setError(e instanceof Error ? e.message : String(e));
267 } finally {
268 if (generationRef.current === generation) setLoading(false);
269 }
270 }, [range, customFrom, customTo, source]);
271
272 useEffect(() => {
273 void load();
274 }, [load]);
275
276 // Models rank by token volume (stats.models is already descending): the top
277 // five keep their own series colour and everything else aggregates into a
278 // single gray "Other" entry (carrying its tail list for expandable detail),
279 // so the palette stays distinguishable.
280 const groupedModels = useMemo<GroupedModel[]>(() => {
281 const list = stats?.models ?? [];
282 if (list.length <= TOP_MODELS) return list;
283 const top = list.slice(0, TOP_MODELS);
284 const rest = list.slice(TOP_MODELS);
285 return [
286 ...top,
287 {
288 model: OTHER_MODEL,
289 provider: "",
290 tokens: rest.reduce((sum, m) => sum + m.tokens, 0),
291 percent: rest.reduce((sum, m) => sum + m.percent, 0),
292 items: rest,
293 },
294 ];
295 }, [stats]);
296 const dailyGrouped = useMemo<GroupedDaily[]>(() => {
297 const topSet = new Set((stats?.models ?? []).slice(0, TOP_MODELS).map((m) => m.model));
298 return (stats?.daily ?? []).map((d) => {
299 const byModel: Record<string, number> = {};
300 const otherByModel: Record<string, number> = {};
301 for (const [model, tokens] of Object.entries(d.byModel)) {
302 if (topSet.has(model)) byModel[model] = (byModel[model] ?? 0) + tokens;
303 else otherByModel[model] = (otherByModel[model] ?? 0) + tokens;
304 }
305 const other = Object.values(otherByModel).reduce((sum, v) => sum + v, 0);
306 if (other > 0) byModel[OTHER_MODEL] = other;
307 return { ...d, byModel, otherByModel };
308 });
309 }, [stats]);
310 const colorForModel = useCallback(
311 (model: string) => {
312 if (model === OTHER_MODEL) return OTHER_COLOR;
313 const slot = (stats?.models ?? []).findIndex((m) => m.model === model);
314 const rank = Math.min(slot < 0 ? 0 : slot, TOP_MODELS - 1) + 1;
315 return `color-mix(in srgb, var(--chart-${rank}) ${MODEL_COLOR_MIX}%, var(--bg-elev))`;
316 },
317 [stats],
318 );
319
320 return (
321 <div className="usage-stats">
322 {/* Section 1: range + source pickers, each in its own framed group */}
323 <div className="usage-stats__toolbar settings-toolbar">
324 <SettingsOptions className="usage-stats__group" role="group" aria-label={t("settings.stats.range")}>
325 {RANGE_PRESETS.map((r) => (
326 <button
327 key={r}
328 type="button"
329 className={`provider-add-segmented__item${range === r ? " provider-add-segmented__item--active" : ""}`}
330 aria-pressed={range === r}
331 onClick={() => setRange(r)}
332 >
333 {t(`settings.stats.rangePreset.${r}`)}
334 </button>
335 ))}
336 <button
337 type="button"
338 className={`provider-add-segmented__item${range === "custom" ? " provider-add-segmented__item--active" : ""}`}
339 aria-pressed={range === "custom"}
340 onClick={() => setRange("custom")}
341 >
342 {t("settings.stats.rangeCustom")}
343 </button>
344 </SettingsOptions>
345 {range === "custom" && (
346 <div className="usage-stats__custom">
347 <input
348 type="date"
349 className="mem-input"
350 value={customFrom}
351 max={customTo || undefined}
352 onChange={(e) => setCustomFrom(e.target.value)}
353 aria-label={t("settings.stats.from")}
354 />
355 <span className="usage-stats__custom-sep">–</span>
356 <input
357 type="date"
358 className="mem-input"
359 value={customTo}
360 min={customFrom || undefined}
361 max={localDay(0)}
362 onChange={(e) => setCustomTo(e.target.value)}
363 aria-label={t("settings.stats.to")}
364 />
365 </div>
366 )}
367 <SettingsOptions className="usage-stats__group" role="group" aria-label={t("settings.stats.source")}>
368 {SOURCES.map((s) => (
369 <button
370 key={s}
371 type="button"
372 className={`provider-add-segmented__item${source === s ? " provider-add-segmented__item--active" : ""}`}
373 aria-pressed={source === s}
374 onClick={() => setSource(s)}
375 >
376 {t(`settings.stats.source.${s}`)}
377 </button>
378 ))}
379 </SettingsOptions>
380 <button type="button" className="usage-stats__refresh" onClick={() => { void load(); void loadHeat(); }} disabled={loading} aria-label={t("settings.stats.refresh")}>
381 {t("settings.stats.refresh")}
382 </button>
383 </div>
384
385 {error && <div className="provider-fetch-banner provider-fetch-banner--warn">{error}</div>}
386 {loading && !stats && <div className="usage-stats__loading">{t("common.loading")}</div>}
387 {!loading && stats && (
388 <>
389 <StatCards stats={stats} t={t} />
390 <Heatmap daily={heatDaily} from={heatWindow.from} to={heatWindow.to} t={t} />
391 <DailyTrend daily={dailyGrouped} modelOrder={groupedModels.map((m) => m.model)} t={t} colorForModel={colorForModel} />
392 <ModelUsage models={groupedModels} t={t} colorForModel={colorForModel} />
393 {stats.to && (
394 <div className="usage-stats__foot">
395 {t("settings.stats.asOf")} {stats.to}
396 </div>
397 )}
398 </>
399 )}
400 {!loading && !error && stats && stats.tokens === 0 && (
401 <div className="usage-stats__empty">{t("settings.stats.empty")}</div>
402 )}
403 </div>
404 );
405 }
406
407 // ── Section 2+3: numeric cards ────────────────────────────────────────────
408
409 function StatCards({ stats, t }: { stats: UsageStatsRange; t: UsageStatsTranslator }) {
410 const topModel = stats.topModel || t("common.none");
411 // The model name is the longest value: it may wrap to a second line on
412 // narrow windows instead of being shrunk or truncated; tokens shows the
413 // exact number and stays on one line (FitText shrinks it if needed).
414 const cards: Array<{ icon: typeof Coins; label: string; value: string; sm?: boolean; wrap?: boolean; hint?: string }> = [
415 { icon: Coins, label: t("settings.stats.tokens"), value: stats.tokens.toLocaleString("en-US") },
416 // Turn markers count completed top-level turns; a conversation session may
417 // contain many of them, so the user-facing label names the exact metric.
418 { icon: MessageSquare, label: t("settings.stats.sessions"), value: String(stats.turns) },
419 { icon: MessagesSquare, label: t("settings.stats.requests"), value: String(stats.requests) },
420 { icon: CalendarDays, label: t("settings.stats.activeDays"), value: String(stats.activeDays) },
421 // Average prompt-cache hit ratio over the range: cached input tokens
422 // divided by all input tokens. "—" when no usage is in range yet.
423 { icon: Activity, label: t("settings.stats.cacheRate"), value: cacheRateText(stats.cacheHit, stats.cacheMiss), hint: t("settings.stats.cacheRateHint") },
424 // "top model" ranks by token volume (not call count) — the hint keeps the
425 // metric's meaning visible next to the value.
426 { icon: Cpu, label: t("settings.stats.topModel"), value: topModel, sm: true, wrap: true, hint: t("settings.stats.topModelHint") },
427 ];
428 return (
429 <div className="usage-stats__cards">
430 {cards.map((c) => (
431 <div className="usage-stats__card" key={c.label} title={c.hint}>
432 <div className="usage-stats__card-head">
433 <c.icon className="usage-stats__card-icon" size={14} strokeWidth={2} aria-hidden="true" />
434 <span className="usage-stats__card-label">{c.label}</span>
435 </div>
436 {c.wrap ? (
437 <div className="usage-stats__card-value usage-stats__card-value--sm usage-stats__card-value--wrap">{c.value}</div>
438 ) : (
439 <FitText
440 text={c.value}
441 className={`usage-stats__card-value${c.sm ? " usage-stats__card-value--sm" : ""}`}
442 maxSize={c.sm ? 14 : 22}
443 />
444 )}
445 </div>
446 ))}
447 </div>
448 );
449 }
450
451 // FitText renders `text` on a single line, shrinking the font until it fits
452 // the card width (long token numbers never overflow or wrap).
453 function FitText({ text, className, maxSize }: { text: string; className?: string; maxSize: number }) {
454 const ref = useRef<HTMLDivElement>(null);
455 const [size, setSize] = useState(maxSize);
456
457 useLayoutEffect(() => {
458 const el = ref.current;
459 if (!el) return;
460 const fit = () => {
461 let s = maxSize;
462 el.style.fontSize = `${s}px`;
463 while (el.scrollWidth > el.clientWidth + 1 && s > 11) {
464 s -= 0.5;
465 el.style.fontSize = `${s}px`;
466 }
467 setSize(s);
468 };
469 fit();
470 const ro = new ResizeObserver(fit);
471 ro.observe(el);
472 return () => ro.disconnect();
473 }, [text, maxSize]);
474
475 return (
476 <div ref={ref} className={className} style={{ fontSize: size }}>
477 {text}
478 </div>
479 );
480 }
481
482 // ── Section 4: GitHub-style activity heatmap ──────────────────────────────
483
484 const HEAT_BASE = 13; // cell size at which column trimming starts
485 const HEAT_GAP = 3;
486
487 // Heatmap always renders the fixed 40-week window passed in `daily`. A wide
488 // container grows the cells to fill it; once the container can no longer fit
489 // the window at HEAT_BASE the earliest columns are trimmed first, so the most
490 // recent weeks stay visible (never the reverse).
491 function Heatmap({ daily, from, to, t }: { daily: DailyTokenUsage[]; from: string; to: string; t: UsageStatsTranslator }) {
492 const [tip, setTip] = useState<{ day: string; tokens: number; requests: number; cacheHit: number; cacheMiss: number; x: number; top: number; bottom: number } | null>(null);
493 const wrapRef = useRef<HTMLDivElement>(null);
494 const [geom, setGeom] = useState<{ size: number; cols: number }>({ size: HEAT_BASE, cols: HEAT_WEEKS });
495
496 useEffect(() => {
497 const el = wrapRef.current;
498 if (!el) return;
499 const update = () => {
500 const avail = Math.max(1, el.clientWidth - 2);
501 const baseCols = Math.max(1, Math.floor((avail + HEAT_GAP) / (HEAT_BASE + HEAT_GAP)));
502 if (baseCols >= HEAT_WEEKS) {
503 // The weekday offset pushes the last day into an extra column, so size
504 // the cells against the real column count — the heatmap then fills the
505 // whole container edge to edge (no right-hand gap, no scrollbar).
506 const so = (indexOfDay(from) + 1) % 7;
507 const totalWeeks = Math.ceil((HEAT_WEEKS * 7 + so) / 7);
508 const size = Math.max(HEAT_BASE, avail / totalWeeks - HEAT_GAP);
509 setGeom({ size, cols: HEAT_WEEKS });
510 } else {
511 // Too narrow for the full window at the base size: keep the cells at
512 // the base size and trim the earliest columns.
513 setGeom({ size: HEAT_BASE, cols: baseCols });
514 }
515 };
516 update();
517 const ro = new ResizeObserver(update);
518 ro.observe(el);
519 return () => ro.disconnect();
520 }, [from]);
521
522 const byDay = new Map<string, DailyTokenUsage>();
523 for (const d of daily) byDay.set(d.day, d);
524 const allDays = daysBetween(from, to);
525 if (allDays.length === 0) return null;
526 // Keep the newest `geom.cols` weeks (7 days each) — trimming the earliest.
527 const days = allDays.slice(-Math.min(geom.cols * 7, allDays.length));
528 const max = Math.max(1, ...days.map((d) => byDay.get(d)?.total ?? 0));
529
530 const rows = 7; // one per weekday, GitHub-style
531 const startOffset = days[0] ? (indexOfDay(days[0]) + 1) % 7 : 0; // 0 = Monday
532 // The offset pushes the last day into a further column, so the svg must be
533 // wide enough for it — otherwise the newest days get clipped off the edge.
534 const weeks = Math.max(1, Math.ceil((days.length + startOffset) / 7));
535 // Keep the tooltip inside the heatmap container: flip below when the cell
536 // sits too close to the top edge.
537 const wrapW = wrapRef.current?.clientWidth ?? 400;
538 const tipX = tip ? Math.max(120, Math.min(tip.x, wrapW - 120)) : 0;
539 const tipAbove = tip ? tip.top >= 72 : true;
540 const tipY = tip ? (tipAbove ? tip.top - 10 : tip.bottom + 10) : 0;
541
542 return (
543 <section className="usage-stats__section">
544 <div className="usage-stats__section-head">
545 <h3 className="usage-stats__section-title">{t("settings.stats.heatmap")}</h3>
546 <div className="usage-stats__heatmap-legend">
547 <span>{t("settings.stats.heatLess")}</span>
548 <i className="usage-stats__heat-cell usage-stats__heat-cell--1" style={{ width: geom.size, height: geom.size }} />
549 <i className="usage-stats__heat-cell usage-stats__heat-cell--2" style={{ width: geom.size, height: geom.size }} />
550 <i className="usage-stats__heat-cell usage-stats__heat-cell--3" style={{ width: geom.size, height: geom.size }} />
551 <i className="usage-stats__heat-cell usage-stats__heat-cell--4" style={{ width: geom.size, height: geom.size }} />
552 <i className="usage-stats__heat-cell usage-stats__heat-cell--5" style={{ width: geom.size, height: geom.size }} />
553 <span>{t("settings.stats.heatMore")}</span>
554 </div>
555 </div>
556 <div className="usage-stats__heatmap-wrap" ref={wrapRef}>
557 <svg className="usage-stats__heatmap" width={weeks * (geom.size + HEAT_GAP) + HEAT_GAP} height={rows * (geom.size + HEAT_GAP) + HEAT_GAP} role="img" aria-label={t("settings.stats.heatmap")}>
558 {days.map((day, i) => {
559 const col = Math.floor((i + startOffset) / 7);
560 const row = (i + startOffset) % 7;
561 const rec = byDay.get(day);
562 const tokens = rec?.total ?? 0;
563 const level = tokens === 0 ? 0 : 1 + Math.floor((tokens / max) * 4); // 1..5
564 const x = HEAT_GAP + col * (geom.size + HEAT_GAP);
565 const y = HEAT_GAP + row * (geom.size + HEAT_GAP);
566 return (
567 <rect
568 key={day}
569 className={`usage-stats__heat-cell usage-stats__heat-cell--${level}`}
570 x={x}
571 y={y}
572 width={geom.size}
573 height={geom.size}
574 rx={Math.max(1.5, geom.size * 0.2)}
575 onMouseEnter={(e) => {
576 const wrap = wrapRef.current;
577 if (!wrap) return;
578 const wr = wrap.getBoundingClientRect();
579 const r = e.currentTarget.getBoundingClientRect();
580 setTip({ day, tokens, requests: rec?.requests ?? 0, cacheHit: rec?.cacheHit ?? 0, cacheMiss: rec?.cacheMiss ?? 0, x: r.left + r.width / 2 - wr.left, top: r.top - wr.top, bottom: r.bottom - wr.top });
581 }}
582 onMouseLeave={() => setTip(null)}
583 />
584 );
585 })}
586 </svg>
587 {tip && (
588 <div className="usage-stats__tip usage-stats__tip--chart" style={{ transform: `translate(${tipX}px, ${tipY}px) translate(-50%, ${tipAbove ? "-100%" : "0"})` }}>
589 <div className="usage-stats__tip-title">{tip.day}</div>
590 <div>{t("settings.stats.tokens")}: {formatTokens(tip.tokens)}</div>
591 <div>{t("settings.stats.requests")}: {tip.requests}</div>
592 <div>{t("settings.stats.cacheHitRate")}: {cacheRateText(tip.cacheHit, tip.cacheMiss)}</div>
593 </div>
594 )}
595 </div>
596 </section>
597 );
598 }
599
600 // ── Section 5: stacked daily token trend ──────────────────────────────────
601
602 function DailyTrend({ daily, modelOrder, t, colorForModel }: { daily: GroupedDaily[]; modelOrder: string[]; t: UsageStatsTranslator; colorForModel: (m: string) => string }) {
603 const trendDaily = daily.length > MAX_TREND_DAYS ? daily.slice(-MAX_TREND_DAYS) : daily;
604 const trendLimited = trendDaily.length !== daily.length;
605 const [tip, setTip] = useState<{ day: string; total: number; byModel: Record<string, number>; otherByModel?: Record<string, number>; cacheHit: number; cacheMiss: number; cx: number; top: number; bottom: number } | null>(null);
606 const [hover, setHover] = useState<string | null>(null);
607 const wrapRef = useRef<HTMLDivElement>(null);
608
609 const W = 720;
610 const H = 220;
611 const padL = 46;
612 const padR = 65; // 50 for the hit-rate axis labels + 15 for the rightmost column's half-bar
613 const padB = 26;
614 const padT = 10;
615 const plotH = H - padT - padB;
616 const MIN_COL = 14; // viewBox units per day once trimming kicks in
617
618 // The svg fills its container (width 100%). When the full series fits, the
619 // viewBox is set to the actual container width and the columns spread
620 // across it — so the chart always spans edge to edge, never letterboxed by a
621 // fixed viewBox. When the container is too narrow for a readable column
622 // pitch we trim the earliest days so the newest are always visible.
623 const [view, setView] = useState<{ avail: number; trimN: number | null }>({ avail: W, trimN: null });
624
625 useEffect(() => {
626 const el = wrapRef.current;
627 if (!el || trendDaily.length === 0) return;
628 const update = () => {
629 const avail = Math.max(1, el.clientWidth);
630 if (avail >= W) {
631 setView({ avail, trimN: null }); // full series, stretched to fill
632 return;
633 }
634 // Trim: keep as many of the newest days as fit at MIN_COL pitch.
635 const maxN = Math.max(1, Math.floor((avail - padL - padR) / MIN_COL) + 1);
636 setView({ avail, trimN: Math.min(trendDaily.length, maxN) });
637 };
638 update();
639 const ro = new ResizeObserver(update);
640 ro.observe(el);
641 return () => ro.disconnect();
642 }, [trendDaily.length]);
643
644 if (trendDaily.length === 0) return null;
645
646 const visible = view.trimN !== null ? trendDaily.slice(-view.trimN) : trendDaily;
647 const n = visible.length;
648 const step =
649 n > 1
650 ? view.trimN !== null
651 ? MIN_COL
652 : (view.avail - padL - padR) / (n - 1)
653 : Math.max(1, view.avail - padL - padR);
654 const barW = Math.max(3, Math.min(30, step * 0.62));
655 // Columns are drawn from their left edge — the leftmost column starts exactly
656 // at padL — and centred on padL + barHalf + i*step, so the edge columns never
657 // cover the token labels (left) or the hit-rate axis labels (right).
658 const barHalf = barW / 2;
659 // In stretched mode the viewBox width equals the container width (1:1), so
660 // text stays 10px while bars spread to fill; trimmed mode keeps MIN_COL.
661 const plotWUsed = view.trimN !== null ? padL + (n - 1) * step + barW + padR : view.avail;
662 const maxTotal = Math.max(1, ...visible.map((d) => d.total));
663 const ticks = niceTicks(maxTotal, 4);
664
665 // Prompt-cache hit ratio curve: one point per day that has usage (0/0 days
666 // have no ratio). The points feed a Catmull-Rom -> Bezier path so the line
667 // reads as a smooth curve and simply carries across data-less days instead
668 // of breaking into segments.
669 const trendPoints: Array<{ x: number; y: number }> = [];
670 const trendPointByDay = new Map<string, { x: number; y: number }>();
671 visible.forEach((d, i) => {
672 const rate = cacheRate(d.cacheHit, d.cacheMiss);
673 if (rate === null) return;
674 const pt = { x: padL + barHalf + i * step, y: padT + plotH - (rate / 100) * plotH };
675 trendPoints.push(pt);
676 trendPointByDay.set(d.day, pt);
677 });
678 const trendPath = smoothPath(trendPoints);
679 const trendTipPt = tip ? trendPointByDay.get(tip.day) : undefined;
680 const rateTicks = [0, 25, 50, 75, 100];
681 // Legend and bar stacks follow the overall usage ranking (not the per-day
682 // leader), so a model's colour stays in the same position every day and the
683 // aggregated "Other" step always sits on top.
684 const legendAgg = aggregateByModel(trendDaily);
685 const legendModels = modelOrder.filter((m) => legendAgg[m] !== undefined);
686
687 // Tooltip is viewport-fixed and only clamped to the settings content pane
688 // (left/right/top/bottom), so it may float over the chart freely but never
689 // escapes the settings page. It sits above the column, flipping below when
690 // the column is too close to the top of the pane.
691 const contentRect = wrapRef.current?.closest(".settings-center__content")?.getBoundingClientRect();
692 const cLeft = contentRect?.left ?? 0;
693 const cRight = contentRect?.right ?? window.innerWidth;
694 const cTop = contentRect?.top ?? 0;
695 const cBottom = contentRect?.bottom ?? window.innerHeight;
696 const TIP_W = 260;
697 const tipX = tip ? Math.max(cLeft + TIP_W / 2 + 8, Math.min(tip.cx, cRight - TIP_W / 2 - 8)) : 0;
698 const tipOtherEntries = tip?.otherByModel
699 ? Object.entries(tip.otherByModel).sort((a, b) => b[1] - a[1])
700 : [];
701 const tipOtherVisible = tipOtherEntries.slice(0, MAX_TOOLTIP_OTHER_DETAILS);
702 const tipOtherRemaining = Math.max(0, tipOtherEntries.length - tipOtherVisible.length);
703 const tipRows = tip ? Object.keys(tip.byModel).length + 1 + tipOtherVisible.length + (tipOtherRemaining > 0 ? 1 : 0) : 0; // +1 cache ratio row; + the bounded Other breakdown
704 const tipH = 46 + 18 * tipRows;
705 const tipAbove = tip ? tip.top - cTop >= tipH + 10 : true;
706 let tipY = tip ? (tipAbove ? tip.top - 10 : tip.bottom + 10) : 0;
707 // "Below" is only used when it actually fits; otherwise fall back above.
708 if (tip && !tipAbove && tipY + tipH > cBottom - 8) tipY = tip.top - 10;
709
710 return (
711 <section className="usage-stats__section">
712 <div className="usage-stats__section-head">
713 <h3 className="usage-stats__section-title">{t("settings.stats.dailyTrend")}</h3>
714 {trendLimited && <span className="usage-stats__section-note">{t("settings.stats.trendLimited")}</span>}
715 </div>
716 <div className="usage-stats__chart-wrap" ref={wrapRef}>
717 <svg className="usage-stats__chart" width="100%" height={H} viewBox={`0 0 ${plotWUsed} ${H}`} onMouseLeave={() => { setTip(null); setHover(null); }}>
718 {ticks.map((tk) => {
719 const y = padT + plotH - (tk / maxTotal) * plotH;
720 return (
721 <g key={tk}>
722 <line className="usage-stats__grid" x1={padL} y1={y} x2={padL + (n - 1) * step + barW} y2={y} />
723 <text className="usage-stats__axis" x={padL - 6} y={y + 3} textAnchor="end">{formatCompact(tk)}</text>
724 </g>
725 );
726 })}
727 {visible.map((d, i) => {
728 const x = padL + barHalf + i * step - barW / 2;
729 const dayOrder = modelOrder.filter((m) => d.byModel[m] !== undefined);
730 let yBottom = padT + plotH;
731 const bars = dayOrder.map((model) => {
732 const tokens = d.byModel[model];
733 const h = (tokens / maxTotal) * plotH;
734 const y = yBottom - h;
735 yBottom = y;
736 return { model, tokens, x, y, h, color: colorForModel(model), dimmed: hover !== null && hover !== model };
737 });
738 // Bars build bottom-up; the whole column is one hover target.
739 const hovered = tip?.day === d.day;
740 return (
741 <g key={d.day}>
742 {bars.map((b) => {
743 // Hovered columns widen horizontally only, centred on the bar:
744 // scaleX around the bar's own centre (transform-origin: center)
745 // so both edges grow at the same rate — the earlier width/x
746 // animation visibly bulged left first, then right.
747 return (
748 <rect
749 key={`${d.day}-${b.model}`}
750 className={`usage-stats__bar${b.dimmed ? " usage-stats__bar--dim" : ""}`}
751 x={b.x}
752 y={b.y}
753 width={barW}
754 height={b.h}
755 fill={b.color}
756 style={hovered ? { transform: `scaleX(${(barW + 3) / barW})` } : undefined}
757 />
758 );
759 })}
760 {/* Invisible whole-column hit target: hovering anywhere in the
761 column's vertical span (even a zero-value day) shows the
762 day's per-model breakdown, the cache hit ratio, and (when the
763 column has data) the trend point that sits on top. */}
764 <rect
765 className="usage-stats__bar-hit"
766 x={x}
767 y={padT}
768 width={barW}
769 height={plotH}
770 onMouseEnter={(e) => {
771 const r = e.currentTarget.getBoundingClientRect();
772 setTip({ day: d.day, total: d.total, byModel: d.byModel, otherByModel: d.otherByModel, cacheHit: d.cacheHit, cacheMiss: d.cacheMiss, cx: r.left + r.width / 2, top: r.top, bottom: r.bottom });
773 }}
774 onMouseLeave={() => setTip(null)}
775 />
776 {(i % Math.max(1, Math.floor(n / 8)) === 0 || i === n - 1) && (
777 <text className="usage-stats__axis" x={padL + barHalf + i * step} y={H - 8} textAnchor="middle">{shortDay(d.day)}</text>
778 )}
779 </g>
780 );
781 })}
782 {/* Cache hit-rate curve draws after the bars so it sits on top. */}
783 <path className="usage-stats__trend" d={trendPath} fill="none" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
784 {/* The hovered day's rate point lights up on the curve when that day
785 has data; no marker is shown otherwise. */}
786 {trendTipPt && (
787 <circle className="usage-stats__trend-dot" cx={trendTipPt.x} cy={trendTipPt.y} r={4} />
788 )}
789 {/* Right-hand axis for the hit-rate scale (0/25/50/75/100%). */}
790 {rateTicks.map((p) => {
791 const y = padT + plotH - (p / 100) * plotH;
792 return (
793 <g key={`rate-${p}`}>
794 <text className="usage-stats__axis usage-stats__axis--rate" x={padL + (n - 1) * step + barW + 8} y={y + 3}>{p}%</text>
795 </g>
796 );
797 })}
798 </svg>
799 {tip && (
800 <div className="usage-stats__tip usage-stats__tip--chart usage-stats__tip--screen" style={{ transform: `translate(${tipX}px, ${tipY}px) translate(-50%, ${tipAbove ? "-100%" : "0"})` }}>
801 <div className="usage-stats__tip-title">{tip.day}</div>
802 <div>{t("settings.stats.total")}: {formatTokens(tip.total)}</div>
803 {modelOrder.filter((m) => tip.byModel[m] !== undefined).map((m) => (
804 <div key={m} className="usage-stats__tip-row"><i className="usage-stats__legend-swatch" style={{ background: colorForModel(m) }} />{m === OTHER_MODEL ? t("settings.stats.other") : m}: {formatTokens(tip.byModel[m])}</div>
805 ))}
806 {tipOtherVisible.map(([m, v]) => (
807 <div key={m} className="usage-stats__tip-row usage-stats__tip-row--other"><i className="usage-stats__legend-swatch" style={{ background: OTHER_COLOR }} />{m}: {formatTokens(v)}</div>
808 ))}
809 {tipOtherRemaining > 0 && <div className="usage-stats__tip-more">+{tipOtherRemaining} {t("settings.stats.moreModels")}</div>}
810 <div>{t("settings.stats.cacheHitRate")}: {cacheRateText(tip.cacheHit, tip.cacheMiss)}</div>
811 </div>
812 )}
813 <div className="usage-stats__legend">
814 {legendModels.map((model) => (
815 <span key={model} className="usage-stats__legend-item" onMouseEnter={() => setHover(model)} onMouseLeave={() => setHover(null)}>
816 <i className="usage-stats__legend-swatch" style={{ background: colorForModel(model) }} />
817 {model === OTHER_MODEL ? t("settings.stats.other") : model}
818 </span>
819 ))}
820 <span className="usage-stats__legend-item" aria-hidden="true">
821 <i className="usage-stats__legend-swatch usage-stats__legend-swatch--trend" />
822 {t("settings.stats.hitRateLegend")}
823 </span>
824 </div>
825 </div>
826 </section>
827 );
828 }
829
830 // ── Section 6: per-model donut + list ─────────────────────────────────────
831
832 function ModelUsage({ models, t, colorForModel }: { models: GroupedModel[]; t: UsageStatsTranslator; colorForModel: (m: string) => string }) {
833 const [tip, setTip] = useState<{ model: string; tokens: number; percent: number; x: number; y: number; items?: ModelTokenUsage[] } | null>(null);
834 const [hover, setHover] = useState<string | null>(null);
835 const [expandedOther, setExpandedOther] = useState(false);
836 const donutRef = useRef<HTMLDivElement>(null);
837
838 if (models.length === 0) return null;
839 const other = models.find((m) => m.model === OTHER_MODEL);
840 const tipItems = tip?.items ?? [];
841 const tipItemsVisible = tipItems.slice(0, MAX_TOOLTIP_OTHER_DETAILS);
842 const tipItemsRemaining = Math.max(0, tipItems.length - tipItemsVisible.length);
843
844 // The ring leaves a 6px margin inside the 240px viewBox at rest, so the
845 // hover-grow of the stroke (+5px, half of it outward) keeps a 3.5px margin
846 // and never overflows into a clipped square.
847 const OUTER = 114; // ring outer radius
848 const SW = 36; // ring thickness
849 const R = OUTER - SW / 2; // circle path radius
850 const CX = 120; // svg centre; the 240x240 viewBox stays fixed while the ring shrinks
851 const CIRC = 2 * Math.PI * R;
852 const total = Math.max(1, models.reduce((sum, m) => sum + m.tokens, 0));
853 let offset = 0;
854
855 const tipX = tip ? Math.max(110, Math.min(tip.x, (donutRef.current?.clientWidth ?? 240) - 110)) : 0;
856 const tipAbove = tip ? tip.y >= 100 : true;
857 const tipY = tip ? (tipAbove ? tip.y - 14 : tip.y + 14) : 0;
858
859 return (
860 <section className="usage-stats__section">
861 <h3 className="usage-stats__section-title">{t("settings.stats.modelUsage")}</h3>
862 <div className="usage-stats__models">
863 <div className="usage-stats__donut-wrap" ref={donutRef}>
864 <svg className="usage-stats__donut" width={CX * 2} height={CX * 2} viewBox={`0 0 ${CX * 2} ${CX * 2}`} role="img" aria-label={t("settings.stats.modelUsage")}>
865 <circle className="usage-stats__donut-track" cx={CX} cy={CX} r={R} fill="none" strokeWidth={SW} />
866 {models.map((m) => {
867 const frac = m.tokens / total;
868 const dash = frac * CIRC;
869 const active = hover === m.model || tip?.model === m.model;
870 const el = (
871 <circle
872 key={m.model}
873 className={`usage-stats__donut-seg${hover !== null && !active ? " usage-stats__donut-seg--dim" : ""}`}
874 cx={CX}
875 cy={CX}
876 r={R}
877 fill="none"
878 stroke={colorForModel(m.model)}
879 strokeDasharray={`${dash} ${CIRC - dash}`}
880 strokeDashoffset={-offset}
881 transform={`rotate(-90 ${CX} ${CX})`}
882 style={{ strokeWidth: active ? SW + 5 : SW, transition: "stroke-width 0.12s ease" }}
883 onMouseEnter={(e) => {
884 const dw = donutRef.current;
885 setHover(m.model);
886 if (!dw) return;
887 const dr = dw.getBoundingClientRect();
888 setTip({ model: m.model, tokens: m.tokens, percent: m.percent, x: e.clientX - dr.left, y: e.clientY - dr.top, items: m.items });
889 }}
890 onMouseLeave={() => { setHover(null); setTip(null); }}
891 />
892 );
893 offset += dash;
894 return el;
895 })}
896 <text className="usage-stats__donut-center" x={CX} y={CX + 8} textAnchor="middle">{formatCompact(total)}</text>
897 <text className="usage-stats__donut-label" x={CX} y={CX + 26} textAnchor="middle">{t("settings.stats.tokens")}</text>
898 </svg>
899 {tip && (
900 <div className="usage-stats__tip usage-stats__tip--chart" style={{ transform: `translate(${tipX}px, ${tipY}px) translate(-50%, ${tipAbove ? "-100%" : "0"})` }}>
901 <div className="usage-stats__tip-title">{tip.model === OTHER_MODEL ? t("settings.stats.other") : tip.model}</div>
902 <div>{t("settings.stats.total")}: {formatTokens(tip.tokens)}</div>
903 <div>{t("settings.stats.percent")}: {formatPercent(tip.percent)}</div>
904 {tipItems.length > 0 && (
905 <div className="usage-stats__tip-breakdown">
906 {tipItemsVisible.map((it) => (
907 <div key={it.model} className="usage-stats__tip-row usage-stats__tip-row--other"><i className="usage-stats__legend-swatch" style={{ background: OTHER_COLOR }} />{it.model}: {formatTokens(it.tokens)}</div>
908 ))}
909 {tipItemsRemaining > 0 && <div className="usage-stats__tip-more">+{tipItemsRemaining} {t("settings.stats.moreModels")}</div>}
910 </div>
911 )}
912 </div>
913 )}
914 </div>
915 <ul className="usage-stats__model-list">
916 {models.map((m) => {
917 const isOther = m.model === OTHER_MODEL;
918 const rowContent = (
919 <>
920 <i className="usage-stats__legend-swatch" style={{ background: colorForModel(m.model) }} />
921 <span className="usage-stats__model-name">
922 {isOther && (
923 <span className="usage-stats__model-toggle" aria-hidden="true">
924 {expandedOther ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
925 </span>
926 )}
927 {isOther ? t("settings.stats.other") : m.model}
928 </span>
929 <span className="usage-stats__model-provider">{isOther ? "" : providerOf(m.model)}</span>
930 <span className="usage-stats__model-tokens">{formatTokens(m.tokens)}</span>
931 <span className="usage-stats__model-pct">{formatPercent(m.percent)}</span>
932 </>
933 );
934 if (isOther) {
935 return (
936 <li key={m.model} className="usage-stats__model-item">
937 <button
938 type="button"
939 className="usage-stats__model-row usage-stats__model-row--expandable"
940 onMouseEnter={() => setHover(m.model)}
941 onMouseLeave={() => setHover(null)}
942 onClick={() => setExpandedOther((open) => !open)}
943 aria-expanded={expandedOther}
944 >
945 {rowContent}
946 </button>
947 </li>
948 );
949 }
950 return (
951 <li
952 key={m.model}
953 className="usage-stats__model-row"
954 onMouseEnter={() => setHover(m.model)}
955 onMouseLeave={() => setHover(null)}
956 >
957 {rowContent}
958 </li>
959 );
960 })}
961 {other?.items && other.items.length > 0 && (
962 <li className={`usage-stats__model-other-wrap${expandedOther ? " usage-stats__model-other-wrap--open" : ""}`}>
963 <ul className="usage-stats__model-other-list">
964 {other.items.map((it) => (
965 <li key={it.model} className="usage-stats__model-row usage-stats__model-row--sub">
966 <i className="usage-stats__legend-swatch" style={{ background: OTHER_COLOR }} />
967 <span className="usage-stats__model-name">{it.model}</span>
968 <span className="usage-stats__model-provider">{providerOf(it.model)}</span>
969 <span className="usage-stats__model-tokens">{formatTokens(it.tokens)}</span>
970 <span className="usage-stats__model-pct">{formatPercent(it.percent)}</span>
971 </li>
972 ))}
973 </ul>
974 </li>
975 )}
976 </ul>
977 </div>
978 </section>
979 );
980 }
981
982 // ── formatting helpers ────────────────────────────────────────────────────
983
984 function providerOf(model: string): string {
985 return model.includes("/") ? model.split("/")[0] : "default";
986 }
987
988 function formatCompact(n: number): string {
989 if (n >= 1e9) return (n / 1e9).toFixed(1) + "B";
990 if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
991 if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
992 return String(n);
993 }
994
995 function formatPercent(p: number): string {
996 return (Math.round(p * 10) / 10).toFixed(1) + "%";
997 }
998
999 // cacheRate returns the prompt-cache hit ratio as 0..100 from cached/missed
1000 // input tokens, or null when there is no usage to judge (0/0).
1001 function cacheRate(hit: number, miss: number): number | null {
1002 const total = hit + miss;
1003 if (total <= 0) return null;
1004 return (hit / total) * 100;
1005 }
1006
1007 // smoothPath builds a Catmull-Rom spline through the points, converted to
1008 // cubic Beziers, so the hit-rate line reads as a smooth curve. Points skip
1009 // data-less days, so the curve simply carries across them (no misleading dip).
1010 function smoothPath(pts: Array<{ x: number; y: number }>): string {
1011 if (pts.length === 0) return "";
1012 if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
1013 let d = `M ${pts[0].x} ${pts[0].y}`;
1014 for (let i = 0; i < pts.length - 1; i++) {
1015 const p0 = pts[i - 1] ?? pts[i];
1016 const p1 = pts[i];
1017 const p2 = pts[i + 1];
1018 const p3 = pts[i + 2] ?? p2;
1019 // Catmull-Rom tangents (tension 0.5), offset for the cubic Bezier control points.
1020 const c1x = p1.x + (p2.x - p0.x) / 6;
1021 const c1y = p1.y + (p2.y - p0.y) / 6;
1022 const c2x = p2.x - (p3.x - p1.x) / 6;
1023 const c2y = p2.y - (p3.y - p1.y) / 6;
1024 d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
1025 }
1026 return d;
1027 }
1028
1029 // cacheRateText formats a hit ratio for display; no data renders as "—".
1030 function cacheRateText(hit: number, miss: number): string {
1031 const r = cacheRate(hit, miss);
1032 return r === null ? "—" : formatPercent(r);
1033 }
1034
1035 // daysBetween produces local-calendar date strings (no UTC shift) so the keys
1036 // match the backend's "2006-01-02" day keys.
1037 function daysBetween(from: string, to: string): string[] {
1038 const out: string[] = [];
1039 const start = new Date(from + "T00:00:00");
1040 const end = new Date(to + "T00:00:00");
1041 for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
1042 const y = d.getFullYear();
1043 const m = String(d.getMonth() + 1).padStart(2, "0");
1044 const day = String(d.getDate()).padStart(2, "0");
1045 out.push(`${y}-${m}-${day}`);
1046 }
1047 return out;
1048 }
1049
1050 function indexOfDay(day: string): number {
1051 // 0 = Monday … 6 = Sunday
1052 const d = new Date(day + "T00:00:00");
1053 return (d.getDay() + 6) % 7;
1054 }
1055
1056 function shortDay(day: string): string {
1057 const [, m, d] = day.split("-");
1058 return `${Number(m)}/${Number(d)}`;
1059 }
1060
1061 function aggregateByModel(daily: DailyTokenUsage[]): Record<string, number> {
1062 const out: Record<string, number> = {};
1063 for (const d of daily) {
1064 for (const [m, v] of Object.entries(d.byModel)) out[m] = (out[m] ?? 0) + v;
1065 }
1066 return out;
1067 }
1068
1069 function niceTicks(max: number, count: number): number[] {
1070 const raw = max / count;
1071 const mag = Math.pow(10, Math.floor(Math.log10(raw)));
1072 const norm = raw / mag;
1073 const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * mag;
1074 const out: number[] = [];
1075 for (let v = step; v <= max; v += step) out.push(v);
1076 return out;
1077 }
1078
1078 lines Plain Text