| 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 |