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