token-larper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +334 -0
  2. package/Start-TokenLarper.vbs +5 -0
  3. package/bin/cli.js +34 -0
  4. package/llms.txt +59 -0
  5. package/package.json +61 -0
  6. package/scripts/docs-demo-server.ts +141 -0
  7. package/scripts/launch-on-default-desktop.ps1 +42 -0
  8. package/scripts/launch-silent.vbs +6 -0
  9. package/scripts/run-server.ps1 +59 -0
  10. package/scripts/tray-host.ps1 +181 -0
  11. package/src/ccusage.ts +1075 -0
  12. package/src/client/App.tsx +560 -0
  13. package/src/client/charts.tsx +341 -0
  14. package/src/client/components/Calendar.tsx +207 -0
  15. package/src/client/components/DateRangePicker.tsx +131 -0
  16. package/src/client/components/KpiCards.tsx +122 -0
  17. package/src/client/components/RankChip.tsx +22 -0
  18. package/src/client/components/SelectMenu.tsx +116 -0
  19. package/src/client/components/SettingsDialog.tsx +207 -0
  20. package/src/client/components/ThemeSettings.tsx +131 -0
  21. package/src/client/context.tsx +60 -0
  22. package/src/client/index.html +54 -0
  23. package/src/client/lib/aggregate.ts +266 -0
  24. package/src/client/lib/range.ts +87 -0
  25. package/src/client/lib/rank.ts +323 -0
  26. package/src/client/lib/shareCard.ts +139 -0
  27. package/src/client/logo.svg +8 -0
  28. package/src/client/main.tsx +12 -0
  29. package/src/client/styles.css +1113 -0
  30. package/src/client/themes.ts +268 -0
  31. package/src/client/utils.ts +61 -0
  32. package/src/client/views/Models.tsx +71 -0
  33. package/src/client/views/Overview.tsx +280 -0
  34. package/src/client/views/Projects.tsx +88 -0
  35. package/src/client/views/Rank.tsx +255 -0
  36. package/src/client/views/Sessions.tsx +108 -0
  37. package/src/client/views/Tools.tsx +114 -0
  38. package/src/projects.ts +214 -0
  39. package/src/server.ts +173 -0
  40. package/src/startup.ts +110 -0
  41. package/src/tray.ts +293 -0
  42. package/src/types.ts +155 -0
@@ -0,0 +1,560 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { AlertTriangle, Moon, Power, RefreshCw, Search, Settings, Sun } from "lucide-react";
3
+ import type { DashboardPayload, HarnessId, StartupConfig } from "../types.ts";
4
+ import { OTHER_SERIES, SERIES_SLOTS, seriesColor, type SeriesInfo } from "./charts.tsx";
5
+ import { DashboardProvider, type Dashboard } from "./context.tsx";
6
+ import { DateRangePicker } from "./components/DateRangePicker.tsx";
7
+ import { RankChip } from "./components/RankChip.tsx";
8
+ import { SettingsDialog } from "./components/SettingsDialog.tsx";
9
+ import { SelectMenu, type SelectMenuOption } from "./components/SelectMenu.tsx";
10
+ import { daysInRange, sessionsInRange, type Bucket, type HarnessFilter } from "./lib/aggregate.ts";
11
+ import { RANGE_PRESETS, parseDay, presetRange, todayKey, type DateRange, type RangePreset } from "./lib/range.ts";
12
+ import { Models } from "./views/Models.tsx";
13
+ import { Overview } from "./views/Overview.tsx";
14
+ import { Projects } from "./views/Projects.tsx";
15
+ import { Rank } from "./views/Rank.tsx";
16
+ import { Sessions } from "./views/Sessions.tsx";
17
+ import { Tools } from "./views/Tools.tsx";
18
+ import {
19
+ applyTheme,
20
+ IMPORTED_THEME_STORAGE_KEY,
21
+ readImportedTheme,
22
+ readThemePalette,
23
+ THEME_MODE_STORAGE_KEY,
24
+ THEME_PALETTE_STORAGE_KEY,
25
+ type ImportedTheme,
26
+ type ThemeMode,
27
+ type ThemePalette,
28
+ } from "./themes.ts";
29
+ import { localDateKey } from "./utils.ts";
30
+
31
+ type View = "overview" | "tools" | "models" | "projects" | "sessions" | "rank";
32
+
33
+ const VIEWS: { id: View; label: string; title: string; blurb: string }[] = [
34
+ { id: "overview", label: "Overview", title: "Usage overview", blurb: "Tokens and cost across your coding tools." },
35
+ { id: "tools", label: "Tools", title: "Tools", blurb: "How each coding tool was used in this range." },
36
+ { id: "models", label: "Models", title: "Models", blurb: "Token totals by model and tool." },
37
+ { id: "projects", label: "Projects", title: "Projects", blurb: "Where your tokens went, by repository." },
38
+ { id: "sessions", label: "Sessions", title: "Sessions", blurb: "Individual coding sessions." },
39
+ { id: "rank", label: "Rank", title: "Hall of Larp", blurb: "Lifetime rank, rivals, records, and badges. Date and tool filters don't apply here." },
40
+ ];
41
+
42
+ // View and filter choices are a per-browser convenience; the dashboard works without them.
43
+ const PREFS_KEY = "token-larper-prefs";
44
+
45
+ interface Prefs {
46
+ view: View;
47
+ showRanks: boolean;
48
+ preset: RangePreset;
49
+ customStart?: string;
50
+ customEnd?: string;
51
+ bucket: Bucket;
52
+ estimated: boolean;
53
+ }
54
+
55
+ function loadPrefs(): Prefs {
56
+ const defaults: Prefs = { view: "overview", showRanks: true, preset: "30d", bucket: "daily", estimated: false };
57
+ try {
58
+ const raw = JSON.parse(localStorage.getItem(PREFS_KEY) || "{}");
59
+ const saved: Partial<Prefs> = raw && typeof raw === "object" ? raw : {};
60
+ const showRanks = saved.showRanks !== false;
61
+ const requestedView = viewFromHash() ?? (VIEWS.some((v) => v.id === saved.view) ? saved.view! : defaults.view);
62
+ const view = requestedView === "rank" && !showRanks ? "overview" : requestedView;
63
+ const bucket = saved.bucket === "daily" || saved.bucket === "weekly" || saved.bucket === "monthly"
64
+ ? saved.bucket : defaults.bucket;
65
+ const preset: RangePreset = saved.preset === "custom"
66
+ ? "custom"
67
+ : (RANGE_PRESETS.find((option) => option.id === saved.preset)?.id ?? defaults.preset);
68
+ const customDatesValid = isDayKey(saved.customStart) && isDayKey(saved.customEnd)
69
+ && saved.customStart <= saved.customEnd && saved.customEnd <= todayKey();
70
+ return {
71
+ view,
72
+ showRanks,
73
+ bucket,
74
+ preset: preset === "custom" && !customDatesValid ? defaults.preset : preset,
75
+ estimated: saved.estimated === true,
76
+ ...(customDatesValid ? { customStart: saved.customStart, customEnd: saved.customEnd } : {}),
77
+ };
78
+ } catch {
79
+ return { ...defaults, view: viewFromHash() ?? "overview" };
80
+ }
81
+ }
82
+
83
+ function isDayKey(value: unknown): value is string {
84
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
85
+ const day = parseDay(value);
86
+ return Number.isFinite(day.getTime()) && localDateKey(day) === value;
87
+ }
88
+
89
+ /** Each view has a linkable #hash, e.g. http://localhost:4269/#projects. */
90
+ function viewFromHash(): View | null {
91
+ const id = location.hash.slice(1);
92
+ return VIEWS.some((v) => v.id === id) ? (id as View) : null;
93
+ }
94
+
95
+ export function App() {
96
+ const [data, setData] = useState<DashboardPayload | null>(null);
97
+ const [startup, setStartup] = useState<StartupConfig | null>(null);
98
+ const [loading, setLoading] = useState(true);
99
+ const [refreshing, setRefreshing] = useState(false);
100
+ const [error, setError] = useState<string | null>(null);
101
+ const [stopped, setStopped] = useState(false);
102
+ const [showSettings, setShowSettings] = useState(false);
103
+ const [savingStartup, setSavingStartup] = useState(false);
104
+ const [startupError, setStartupError] = useState<string | null>(null);
105
+ const dashboardRequestId = useRef(0);
106
+ const startupRequestId = useRef(0);
107
+ const activeRefreshId = useRef<number | null>(null);
108
+ const startupMutationInFlight = useRef(false);
109
+
110
+ const [prefs, setPrefs] = useState<Prefs>(loadPrefs);
111
+ const [harness, setHarness] = useState<HarnessFilter>("all");
112
+ const [search, setSearch] = useState("");
113
+ const [theme, setTheme] = useState<ThemeMode>(() =>
114
+ document.documentElement.dataset.theme === "dark" ? "dark" : "light"
115
+ );
116
+ const [followsSystemTheme, setFollowsSystemTheme] = useState(() => {
117
+ try {
118
+ const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
119
+ return saved !== "light" && saved !== "dark";
120
+ } catch {
121
+ return true;
122
+ }
123
+ });
124
+ const [palette, setPalette] = useState<ThemePalette>(readThemePalette);
125
+ const [importedTheme, setImportedTheme] = useState<ImportedTheme | null>(readImportedTheme);
126
+
127
+ const updatePrefs = (patch: Partial<Prefs>) => setPrefs((p) => ({ ...p, ...patch }));
128
+
129
+ function setShowRanks(showRanks: boolean) {
130
+ setPrefs((p) => ({ ...p, showRanks, view: !showRanks && p.view === "rank" ? "overview" : p.view }));
131
+ }
132
+
133
+ useEffect(() => {
134
+ try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch { /* Storage may be disabled. */ }
135
+ }, [prefs]);
136
+
137
+ useEffect(() => {
138
+ const hash = prefs.view === "overview" ? "" : `#${prefs.view}`;
139
+ if (location.hash !== hash) history.replaceState(null, "", `${location.pathname}${hash}`);
140
+ }, [prefs.view]);
141
+
142
+ useEffect(() => {
143
+ const onHash = () => {
144
+ const requestedView = viewFromHash() ?? "overview";
145
+ const view = requestedView === "rank" && !prefs.showRanks ? "overview" : requestedView;
146
+ if (view !== requestedView) history.replaceState(null, "", location.pathname);
147
+ setPrefs((p) => (p.view === view ? p : { ...p, view }));
148
+ };
149
+ window.addEventListener("hashchange", onHash);
150
+ return () => window.removeEventListener("hashchange", onHash);
151
+ }, [prefs.showRanks]);
152
+
153
+ useEffect(() => {
154
+ applyTheme(theme, palette, importedTheme);
155
+ }, [theme, palette, importedTheme]);
156
+
157
+ useEffect(() => {
158
+ if (!followsSystemTheme) return;
159
+ const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
160
+ const onChange = (event: MediaQueryListEvent) => setTheme(event.matches ? "dark" : "light");
161
+ systemTheme.addEventListener("change", onChange);
162
+ return () => systemTheme.removeEventListener("change", onChange);
163
+ }, [followsSystemTheme]);
164
+
165
+ // Only a click stores a theme, so an untouched dashboard keeps following the OS.
166
+ function toggleTheme() {
167
+ const next = theme === "light" ? "dark" : "light";
168
+ setFollowsSystemTheme(false);
169
+ setTheme(next);
170
+ try { localStorage.setItem(THEME_MODE_STORAGE_KEY, next); } catch { /* Storage may be disabled. */ }
171
+ }
172
+
173
+ function changeThemeMode(mode: ThemeMode) {
174
+ setFollowsSystemTheme(false);
175
+ setTheme(mode);
176
+ try { localStorage.setItem(THEME_MODE_STORAGE_KEY, mode); } catch { /* Storage may be disabled. */ }
177
+ }
178
+
179
+ function changePalette(next: ThemePalette) {
180
+ if (next === "custom" && !importedTheme) return;
181
+ setPalette(next);
182
+ try { localStorage.setItem(THEME_PALETTE_STORAGE_KEY, next); } catch { /* Storage may be disabled. */ }
183
+ }
184
+
185
+ function saveImportedTheme(next: ImportedTheme) {
186
+ setImportedTheme(next);
187
+ setPalette("custom");
188
+ try {
189
+ localStorage.setItem(IMPORTED_THEME_STORAGE_KEY, JSON.stringify(next));
190
+ localStorage.setItem(THEME_PALETTE_STORAGE_KEY, "custom");
191
+ } catch { /* Storage may be disabled. */ }
192
+ }
193
+
194
+ async function fetchDashboard(opts?: { refresh?: boolean; forceDeepScan?: boolean }) {
195
+ if (!opts?.refresh && activeRefreshId.current !== null) return;
196
+ const requestId = ++dashboardRequestId.current;
197
+ if (opts?.refresh) activeRefreshId.current = requestId;
198
+ try {
199
+ if (opts?.refresh) setRefreshing(true);
200
+ const q = new URLSearchParams();
201
+ if (opts?.refresh) q.set("refresh", "1");
202
+ if (opts?.forceDeepScan) q.set("deep", "1");
203
+ const res = await fetch(`/api/usage?${q.toString()}`);
204
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
205
+ const next = (await res.json()) as DashboardPayload;
206
+ if (requestId === dashboardRequestId.current) {
207
+ setData(next);
208
+ setError(null);
209
+ }
210
+ } catch (err) {
211
+ if (requestId === dashboardRequestId.current) {
212
+ setError(err instanceof Error ? err.message : "Failed to load ccusage data");
213
+ }
214
+ } finally {
215
+ if (activeRefreshId.current === requestId) activeRefreshId.current = null;
216
+ if (requestId === dashboardRequestId.current) {
217
+ setLoading(false);
218
+ setRefreshing(false);
219
+ }
220
+ }
221
+ }
222
+
223
+ async function fetchStartup() {
224
+ if (startupMutationInFlight.current) return;
225
+ const requestId = ++startupRequestId.current;
226
+ try {
227
+ const res = await fetch("/api/startup");
228
+ if (!res.ok) throw new Error("Could not load startup settings");
229
+ const next = await res.json();
230
+ if (requestId === startupRequestId.current) {
231
+ setStartup(next);
232
+ setStartupError(null);
233
+ }
234
+ } catch (cause) {
235
+ if (requestId === startupRequestId.current) {
236
+ setStartupError(cause instanceof Error ? cause.message : "Could not load startup settings");
237
+ }
238
+ }
239
+ }
240
+
241
+ useEffect(() => {
242
+ void fetchDashboard();
243
+ void fetchStartup();
244
+ }, []);
245
+
246
+ // Poll gently while a background deep scan (e.g. Antigravity) is running.
247
+ useEffect(() => {
248
+ if (!data?.syncingHarnesses?.length) return;
249
+ const id = setInterval(() => {
250
+ if (!document.hidden) void fetchDashboard();
251
+ }, 3500);
252
+ return () => clearInterval(id);
253
+ }, [data?.syncingHarnesses?.length]);
254
+
255
+ // Cached data on first load triggers a background refresh on the server; pick it up.
256
+ useEffect(() => {
257
+ if (!data || data.syncingHarnesses.length || Date.now() - Date.parse(data.generatedAt) < 30_000) return;
258
+ let checks = 0;
259
+ const id = setInterval(() => {
260
+ if (document.hidden) return;
261
+ if (++checks > 12) { clearInterval(id); return; }
262
+ void fetchDashboard();
263
+ }, 5000);
264
+ return () => clearInterval(id);
265
+ }, [data?.generatedAt, data?.syncingHarnesses.length]);
266
+
267
+ async function handleToggleStartup(nextEnabled: boolean, nextOpenBrowser?: boolean) {
268
+ if (!startup || startupMutationInFlight.current) return;
269
+ startupMutationInFlight.current = true;
270
+ const requestId = ++startupRequestId.current;
271
+ setSavingStartup(true);
272
+ setStartupError(null);
273
+ try {
274
+ const res = await fetch("/api/startup", {
275
+ method: "POST",
276
+ headers: { "Content-Type": "application/json" },
277
+ body: JSON.stringify({ enabled: nextEnabled, openBrowserOnBoot: nextOpenBrowser ?? startup.openBrowserOnBoot }),
278
+ });
279
+ const result = await res.json();
280
+ if (!res.ok) throw new Error(result.error || "Could not update startup settings");
281
+ if (requestId === startupRequestId.current) setStartup(result);
282
+ } catch (cause) {
283
+ if (requestId === startupRequestId.current) {
284
+ setStartupError(cause instanceof Error ? cause.message : "Could not update startup settings");
285
+ }
286
+ } finally {
287
+ startupMutationInFlight.current = false;
288
+ setSavingStartup(false);
289
+ }
290
+ }
291
+
292
+ async function handleQuit() {
293
+ try {
294
+ await fetch("/api/shutdown", { method: "POST", headers: { "Content-Type": "application/json" } });
295
+ } catch {
296
+ // The server may drop the connection as it exits; it is stopping either way.
297
+ } finally {
298
+ setShowSettings(false);
299
+ setStopped(true);
300
+ }
301
+ }
302
+
303
+ const closeSettings = useCallback(() => setShowSettings(false), []);
304
+
305
+ const estimated = prefs.estimated;
306
+ const costOf = useCallback(
307
+ (c: { verifiedCost: number; estimatedCost: number }) => (estimated ? c.estimatedCost : c.verifiedCost),
308
+ [estimated]
309
+ );
310
+
311
+ const firstDay = data?.daily[0]?.period;
312
+ const range: DateRange = useMemo(() => {
313
+ if (prefs.preset === "custom" && prefs.customStart && prefs.customEnd) {
314
+ return { preset: "custom", start: prefs.customStart, end: prefs.customEnd };
315
+ }
316
+ return presetRange(prefs.preset === "custom" ? "30d" : prefs.preset, firstDay);
317
+ }, [prefs.preset, prefs.customStart, prefs.customEnd, firstDay]);
318
+
319
+ // Chart colors follow all-time usage rank (the server sorts harnesses by tokens), so no
320
+ // filter ever repaints a tool; past the validated slots, tools share "Other".
321
+ const { series, seriesOf, nameOf } = useMemo(() => {
322
+ const byId = new Map<HarnessId, SeriesInfo>();
323
+ const names = new Map<HarnessId, string>();
324
+ const list: SeriesInfo[] = [];
325
+ const other: SeriesInfo = { key: "other", name: "Other", color: OTHER_SERIES };
326
+ for (const h of data?.harnesses ?? []) names.set(h.meta.id, h.meta.name);
327
+ (data?.harnesses ?? []).filter((h) => h.meta.hasUsage).forEach((h, i) => {
328
+ if (i < SERIES_SLOTS) {
329
+ const info = { key: h.meta.id, name: h.meta.name, color: seriesColor(i) };
330
+ byId.set(h.meta.id, info);
331
+ list.push(info);
332
+ } else {
333
+ byId.set(h.meta.id, other);
334
+ if (!list.includes(other)) list.push(other);
335
+ }
336
+ });
337
+ return {
338
+ series: list,
339
+ seriesOf: (id: HarnessId) => byId.get(id) ?? other,
340
+ nameOf: (id: HarnessId) => names.get(id) ?? id,
341
+ };
342
+ }, [data]);
343
+
344
+ const days = useMemo(
345
+ () => (data ? daysInRange(data.daily, harness, range.start, range.end) : []),
346
+ [data, harness, range.start, range.end]
347
+ );
348
+ const sessions = useMemo(
349
+ () => (data ? sessionsInRange(data.sessions, harness, range.start, range.end) : []),
350
+ [data, harness, range.start, range.end]
351
+ );
352
+
353
+ if (stopped) {
354
+ return (
355
+ <div className="splash">
356
+ <div className="splash-card">
357
+ <Power size={30} aria-hidden="true" />
358
+ <h2>Token Larper stopped</h2>
359
+ <p>
360
+ The server and tray icon have shut down. Run <code>bun start</code> or <code>Start-TokenLarper.vbs</code> to
361
+ start it again.
362
+ </p>
363
+ </div>
364
+ </div>
365
+ );
366
+ }
367
+
368
+ if (loading && !data) {
369
+ return (
370
+ <div className="splash">
371
+ <div className="splash-card">
372
+ <img className="splash-logo" src="/logo.svg" alt="" />
373
+ <h2>Summoning Token Larper…</h2>
374
+ <p>Reading local usage from your coding tools with ccusage.</p>
375
+ </div>
376
+ </div>
377
+ );
378
+ }
379
+
380
+ if (error && !data) {
381
+ return (
382
+ <div className="splash">
383
+ <div className="splash-card">
384
+ <AlertTriangle size={28} aria-hidden="true" />
385
+ <h2>Couldn't load usage</h2>
386
+ <p>{error}</p>
387
+ <button className="btn btn-primary" onClick={() => void fetchDashboard({ refresh: true, forceDeepScan: true })}>
388
+ Try again
389
+ </button>
390
+ </div>
391
+ </div>
392
+ );
393
+ }
394
+
395
+ if (!data) return null;
396
+
397
+ const view = VIEWS.find((v) => v.id === prefs.view && (v.id !== "rank" || prefs.showRanks)) ?? VIEWS[0]!;
398
+ const failed = data.harnesses.filter((h) => h.meta.syncStatus === "error");
399
+ const toolsWithUsage = data.harnesses.filter((h) => h.meta.hasUsage);
400
+ const searchable = view.id === "models" || view.id === "projects" || view.id === "sessions";
401
+ const toolOptions: SelectMenuOption<HarnessFilter>[] = [
402
+ { value: "all", label: "All tools", visual: <span className="tool-filter-dot is-all" aria-hidden="true" /> },
403
+ ...toolsWithUsage.map((h) => ({
404
+ value: h.meta.id,
405
+ label: h.meta.name,
406
+ visual: <span className="tool-filter-dot" aria-hidden="true" style={{ background: seriesOf(h.meta.id).color }} />,
407
+ })),
408
+ ];
409
+
410
+ const ctx: Dashboard = {
411
+ data,
412
+ range,
413
+ harness,
414
+ setHarness,
415
+ estimated,
416
+ costOf,
417
+ days,
418
+ sessions,
419
+ series,
420
+ seriesOf,
421
+ nameOf,
422
+ search: searchable ? search : "",
423
+ };
424
+
425
+ return (
426
+ <DashboardProvider value={ctx}>
427
+ <div className="app">
428
+ <header className="topbar">
429
+ <div className="brand">
430
+ <img className="brand-logo" src="/logo.svg" alt="" />
431
+ <h1>Token Larper</h1>
432
+ {data.syncingHarnesses.length > 0 && (
433
+ <span className="pill">
434
+ <RefreshCw size={12} className="spin" aria-hidden="true" />
435
+ Syncing {data.syncingHarnesses.map(nameOf).join(", ")}
436
+ </span>
437
+ )}
438
+ {failed.length > 0 && (
439
+ <span className="pill pill-warning" title="ccusage failed or timed out for these tools. Showing their last good data.">
440
+ <AlertTriangle size={12} aria-hidden="true" />
441
+ Couldn't refresh {failed.map((h) => h.meta.name).join(", ")}
442
+ </span>
443
+ )}
444
+ </div>
445
+ <div className="topbar-actions">
446
+ {prefs.showRanks && <RankChip tokens={data.totals.totalTokens} active={view.id === "rank"} onOpen={() => updatePrefs({ view: "rank" })} />}
447
+ <span className="updated" title={new Date(data.generatedAt).toLocaleString()}>
448
+ Updated {new Date(data.generatedAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
449
+ </span>
450
+ <button
451
+ className="icon-btn"
452
+ onClick={toggleTheme}
453
+ title={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
454
+ aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
455
+ >
456
+ {theme === "light" ? <Moon size={16} /> : <Sun size={16} />}
457
+ </button>
458
+ <button
459
+ className="icon-btn"
460
+ onClick={() => {
461
+ setShowSettings(true);
462
+ void fetchStartup();
463
+ }}
464
+ title="Settings"
465
+ aria-label="Settings"
466
+ >
467
+ <Settings size={16} />
468
+ </button>
469
+ <button className="btn" disabled={refreshing} onClick={() => void fetchDashboard({ refresh: true, forceDeepScan: true })} title="Refresh all tools; Antigravity scans in the background">
470
+ <RefreshCw size={15} className={refreshing ? "spin" : ""} aria-hidden="true" />
471
+ <span>{refreshing ? "Scanning…" : "Sync"}</span>
472
+ </button>
473
+ </div>
474
+ </header>
475
+
476
+ <nav className="tabs" aria-label="Dashboard views">
477
+ {VIEWS.filter((v) => v.id !== "rank" || prefs.showRanks).map((v) => (
478
+ <button
479
+ key={v.id}
480
+ className="tab"
481
+ aria-current={prefs.view === v.id ? "page" : undefined}
482
+ onClick={() => updatePrefs({ view: v.id })}
483
+ >
484
+ {v.label}
485
+ </button>
486
+ ))}
487
+ </nav>
488
+
489
+ {/* The Rank tab's character sheet carries its own heading. */}
490
+ {view.id !== "rank" && (
491
+ <div className="intro">
492
+ <div>
493
+ <span className="eyebrow">Local usage</span>
494
+ <h2>{view.title}</h2>
495
+ <p>{view.blurb}</p>
496
+ </div>
497
+ </div>
498
+ )}
499
+
500
+ {error && <div className="inline-error" role="alert">Sync failed: {error}</div>}
501
+
502
+ {view.id !== "rank" && (
503
+ <div className="filters">
504
+ <DateRangePicker
505
+ range={range}
506
+ firstDay={firstDay}
507
+ onChange={(r) => updatePrefs({ preset: r.preset, customStart: r.start, customEnd: r.end })}
508
+ />
509
+ <SelectMenu label="Tool" value={harness} options={toolOptions} onChange={setHarness} className="tool-picker" />
510
+ <div className="field cost-field">
511
+ <span className="field-label">Cost</span>
512
+ <div className="cost-segmented" role="group" aria-label="Cost basis">
513
+ <button type="button" aria-pressed={!estimated} onClick={() => updatePrefs({ estimated: false })}>Verified</button>
514
+ <button type="button" aria-pressed={estimated} onClick={() => updatePrefs({ estimated: true })} title="Estimated API value">Estimate</button>
515
+ </div>
516
+ </div>
517
+ {searchable && (
518
+ <label className="field field-search">
519
+ <span className="field-label">Search</span>
520
+ <span className="search">
521
+ <Search size={15} aria-hidden="true" />
522
+ <input type="search" placeholder={`Search ${view.label.toLowerCase()}`} value={search} onChange={(e) => setSearch(e.target.value)} />
523
+ </span>
524
+ </label>
525
+ )}
526
+ </div>
527
+ )}
528
+
529
+ <main className="content">
530
+ {view.id === "overview" && <Overview bucket={prefs.bucket} setBucket={(bucket) => updatePrefs({ bucket })} />}
531
+ {view.id === "tools" && <Tools />}
532
+ {view.id === "models" && <Models />}
533
+ {view.id === "projects" && <Projects />}
534
+ {view.id === "sessions" && <Sessions />}
535
+ {view.id === "rank" && <Rank />}
536
+ </main>
537
+
538
+ {showSettings && (
539
+ <SettingsDialog
540
+ startup={startup}
541
+ startupError={startupError}
542
+ saving={savingStartup}
543
+ themeMode={theme}
544
+ palette={palette}
545
+ importedTheme={importedTheme}
546
+ showRanks={prefs.showRanks}
547
+ onClose={closeSettings}
548
+ onThemeModeChange={changeThemeMode}
549
+ onPaletteChange={changePalette}
550
+ onImportTheme={saveImportedTheme}
551
+ onShowRanksChange={setShowRanks}
552
+ onToggleStartup={(enabled, open) => void handleToggleStartup(enabled, open)}
553
+ onRetry={() => void fetchStartup()}
554
+ onQuit={() => void handleQuit()}
555
+ />
556
+ )}
557
+ </div>
558
+ </DashboardProvider>
559
+ );
560
+ }