@arraystar/tokenscope 0.1.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 (45) hide show
  1. package/README.md +87 -0
  2. package/bun.lock +1170 -0
  3. package/components.json +25 -0
  4. package/eslint.config.js +23 -0
  5. package/index.html +13 -0
  6. package/package.json +46 -0
  7. package/public/data.json +41 -0
  8. package/public/favicon.svg +1 -0
  9. package/public/icons.svg +24 -0
  10. package/src/App.css +184 -0
  11. package/src/App.tsx +98 -0
  12. package/src/PricingContext.tsx +131 -0
  13. package/src/assets/hero.png +0 -0
  14. package/src/assets/react.svg +1 -0
  15. package/src/assets/vite.svg +1 -0
  16. package/src/cli.ts +98 -0
  17. package/src/components/Overview.tsx +468 -0
  18. package/src/components/PricingSheet.tsx +209 -0
  19. package/src/components/SessionDetail.tsx +244 -0
  20. package/src/components/ui/badge.tsx +52 -0
  21. package/src/components/ui/button.tsx +58 -0
  22. package/src/components/ui/card.tsx +103 -0
  23. package/src/components/ui/input.tsx +20 -0
  24. package/src/components/ui/separator.tsx +23 -0
  25. package/src/components/ui/sheet.tsx +138 -0
  26. package/src/components/ui/sidebar.tsx +721 -0
  27. package/src/components/ui/skeleton.tsx +13 -0
  28. package/src/components/ui/table.tsx +114 -0
  29. package/src/components/ui/tooltip.tsx +64 -0
  30. package/src/data.ts +90 -0
  31. package/src/hooks/use-mobile.ts +19 -0
  32. package/src/i18n.tsx +148 -0
  33. package/src/index.css +138 -0
  34. package/src/lib/utils.ts +6 -0
  35. package/src/main.tsx +10 -0
  36. package/src/parser/claude.ts +225 -0
  37. package/src/parser/codex.ts +181 -0
  38. package/src/parser/index.ts +83 -0
  39. package/src/parser/types.ts +35 -0
  40. package/src/pricing.ts +139 -0
  41. package/src/types.ts +56 -0
  42. package/tsconfig.app.json +30 -0
  43. package/tsconfig.json +13 -0
  44. package/tsconfig.node.json +26 -0
  45. package/vite.config.ts +13 -0
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="29.961" y="-17.13" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-13.43" y="-22.082" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="34.321" y="-37.644" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="38.847" y="-10.552" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="22.45" y="-1.645" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="32.919" y="11.36" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter></defs></svg>
package/src/cli.ts ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { parseAllSessions, buildDashboardData } from "./parser/index.ts";
5
+ import type { ParseOptions } from "./parser/index.ts";
6
+
7
+ const args = process.argv.slice(2);
8
+ const options: ParseOptions = {};
9
+
10
+ for (const arg of args) {
11
+ if (arg === "--claude") options.codex = true; // only claude
12
+ else if (arg === "--codex") options.claude = true; // only codex
13
+ else if (arg === "--json-only") options.jsonOnly = true;
14
+ else if (arg === "--help" || arg === "-h") {
15
+ printHelp();
16
+ process.exit(0);
17
+ }
18
+ }
19
+
20
+ function printHelp() {
21
+ console.log(`token-dashboard — Token usage visualization for Claude Code & Codex CLI
22
+
23
+ Usage:
24
+ npx token-dashboard [options]
25
+
26
+ Options:
27
+ --claude Only parse Claude Code sessions
28
+ --codex Only parse Codex CLI sessions
29
+ --json-only Output data.json only, don't serve dashboard
30
+ -h, --help Show this help
31
+
32
+ By default, both Claude Code (~/.claude/projects) and Codex CLI (~/.codex/sessions)
33
+ data sources are auto-detected and parsed.`);
34
+ }
35
+
36
+ const sessions = parseAllSessions(options);
37
+ const data = buildDashboardData(sessions);
38
+
39
+ // Find output directory: package root / public
40
+ const thisDir = path.dirname(new URL(import.meta.url).pathname);
41
+ const distDir = path.resolve(thisDir, "..");
42
+ const publicDir = path.join(distDir, "public");
43
+
44
+ // Write data.json
45
+ const outPath = path.join(publicDir, "data.json");
46
+ if (!fs.existsSync(publicDir)) fs.mkdirSync(publicDir, { recursive: true });
47
+ fs.writeFileSync(outPath, JSON.stringify(data, null, 2));
48
+
49
+ const claudeCount = sessions.filter((s) => s.source === "claude").length;
50
+ const codexCount = sessions.filter((s) => s.source === "codex").length;
51
+ console.log(
52
+ `Parsed ${sessions.length} sessions (${claudeCount} Claude, ${codexCount} Codex) → ${outPath}`
53
+ );
54
+ console.log(
55
+ `Grand total: ${(data.grand_total.total / 1_000_000).toFixed(1)}M tokens, $${data.grand_total.cost.toFixed(2)}`
56
+ );
57
+
58
+ // @ts-ignore — runtime-only flag
59
+ if (!options.jsonOnly) {
60
+ // Try to serve the built dashboard
61
+ const serve = async () => {
62
+ const { createServer } = await import("http");
63
+ const handler = (req: any, res: any) => {
64
+ let filePath = path.join(distDir, new URL(req.url, "http://localhost").pathname.slice(1));
65
+ if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
66
+ filePath = path.join(distDir, "index.html");
67
+ }
68
+ const ext = path.extname(filePath);
69
+ const mimeTypes: Record<string, string> = {
70
+ ".html": "text/html",
71
+ ".js": "application/javascript",
72
+ ".css": "text/css",
73
+ ".json": "application/json",
74
+ ".png": "image/png",
75
+ ".svg": "image/svg+xml",
76
+ ".woff2": "font/woff2",
77
+ };
78
+ const contentType = mimeTypes[ext] || "application/octet-stream";
79
+ try {
80
+ const content = fs.readFileSync(filePath);
81
+ res.writeHead(200, { "Content-Type": contentType });
82
+ res.end(content);
83
+ } catch {
84
+ res.writeHead(404);
85
+ res.end("Not found");
86
+ }
87
+ };
88
+ const server = createServer(handler);
89
+ const port = 3141;
90
+ server.listen(port, () => {
91
+ console.log(`\nDashboard: http://localhost:${port}`);
92
+ console.log("Press Ctrl+C to stop.");
93
+ });
94
+ };
95
+ serve().catch(() => {
96
+ console.log("Run 'npm run build' first, then use --json-only to just export data.");
97
+ });
98
+ }
@@ -0,0 +1,468 @@
1
+ import { useNavigate } from "react-router-dom";
2
+ import { useState } from "react";
3
+ import {
4
+ XAxis,
5
+ YAxis,
6
+ Tooltip,
7
+ ResponsiveContainer,
8
+ PieChart,
9
+ Pie,
10
+ Cell,
11
+ AreaChart,
12
+ Area,
13
+ } from "recharts";
14
+ import {
15
+ Card,
16
+ CardAction,
17
+ CardContent,
18
+ CardDescription,
19
+ CardHeader,
20
+ CardTitle,
21
+ } from "./ui/card";
22
+ import { Badge } from "./ui/badge";
23
+ import { fmt, fmtCost, getAllSessions, getSkillStats } from "../data";
24
+ import { useRecalculatedData, usePricingConfig } from "../PricingContext";
25
+ import { useLang, useTranslations } from "../i18n";
26
+ import {
27
+ MessageSquare,
28
+ Coins,
29
+ Database,
30
+ TrendingUp,
31
+ ArrowUpRight,
32
+ ArrowDownRight,
33
+ } from "lucide-react";
34
+
35
+ const COLORS = [
36
+ "hsl(220, 70%, 55%)",
37
+ "hsl(160, 65%, 45%)",
38
+ "hsl(30, 85%, 55%)",
39
+ "hsl(280, 60%, 55%)",
40
+ "hsl(0, 70%, 55%)",
41
+ "hsl(190, 70%, 45%)",
42
+ "hsl(45, 85%, 50%)",
43
+ "hsl(330, 60%, 50%)",
44
+ ];
45
+
46
+ export default function Overview() {
47
+ const data = useRecalculatedData();
48
+ const { currencySymbol } = usePricingConfig();
49
+ const navigate = useNavigate();
50
+ const { lang } = useLang();
51
+ const tr = useTranslations(lang);
52
+ const [sourceFilter, setSourceFilter] = useState<"all" | "claude" | "codex">("all");
53
+ const allSessions = getAllSessions(data);
54
+ const sources = data.sources ?? [];
55
+
56
+ // Filter by source
57
+ const sessions = sourceFilter === "all"
58
+ ? allSessions
59
+ : allSessions.filter((s) => s.source === sourceFilter);
60
+
61
+ const gt = data.grand_total;
62
+
63
+ // Group sessions by project (filtered)
64
+ const projectMap: Record<string, { total: number; cost: number; sessions: number }> = {};
65
+ for (const s of sessions) {
66
+ const p = s.project;
67
+ if (!projectMap[p]) projectMap[p] = { total: 0, cost: 0, sessions: 0 };
68
+ projectMap[p].total += s.stats.total;
69
+ projectMap[p].cost += s.stats.cost;
70
+ projectMap[p].sessions += 1;
71
+ }
72
+ const treemapData = Object.entries(projectMap)
73
+ .sort((a, b) => b[1].total - a[1].total)
74
+ .map(([name, v], i) => ({
75
+ name: name.split("/").pop() || name,
76
+ fullName: name,
77
+ size: v.total,
78
+ cost: v.cost,
79
+ sessions: v.sessions,
80
+ color: COLORS[i % COLORS.length],
81
+ }));
82
+
83
+ // Token breakdown pie
84
+ const pieData = [
85
+ { name: tr.input, value: gt.input, color: COLORS[0] },
86
+ { name: tr.output, value: gt.output, color: COLORS[1] },
87
+ { name: tr.cacheReadLabel, value: gt.cache_read, color: COLORS[2] },
88
+ { name: tr.cacheWriteLabel, value: gt.cache_write, color: COLORS[3] },
89
+ ];
90
+
91
+ // Top sessions
92
+ const topSessions = sessions.slice(0, 12).map((s) => ({
93
+ name: `${s.project.split("/").pop()}/${s.sid}`,
94
+ fullName: `${s.project} (${s.date})`,
95
+ total: s.stats.total,
96
+ cost: s.stats.cost,
97
+ sessionKey: s.sessionKey,
98
+ msgs: s.stats.msgs,
99
+ }));
100
+
101
+ // Daily trend
102
+ const dayMap: Record<string, number> = {};
103
+ for (const s of sessions) {
104
+ const day = s.date;
105
+ dayMap[day] = (dayMap[day] || 0) + s.stats.total;
106
+ }
107
+ const trendData = Object.entries(dayMap)
108
+ .sort((a, b) => a[0].localeCompare(b[0]))
109
+ .map(([date, total]) => ({ date: date.slice(5), total }));
110
+
111
+ // Compute trend direction (last 2 days)
112
+ const lastTwo = trendData.slice(-2);
113
+ const trendPct =
114
+ lastTwo.length === 2
115
+ ? ((lastTwo[1].total - lastTwo[0].total) / lastTwo[0].total) * 100
116
+ : 0;
117
+ const trendUp = trendPct >= 0;
118
+
119
+ // Token by Skill
120
+ const skillStats = getSkillStats(data);
121
+ const skillTotal = skillStats.reduce((s, sk) => s + sk.total, 0);
122
+
123
+ return (
124
+ <div className="flex flex-1 flex-col">
125
+ <div className="@container/main flex flex-1 flex-col gap-2">
126
+ <div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
127
+ {/* Source Filter */}
128
+ {sources.length > 1 && (
129
+ <div className="flex items-center gap-2 px-4 lg:px-6">
130
+ <span className="text-xs text-muted-foreground">{tr.sourceFilter}:</span>
131
+ {(["all", "claude", "codex"] as const).map((src) => {
132
+ if (src !== "all" && !sources.includes(src)) return null;
133
+ const label = src === "all" ? tr.allSources : src === "claude" ? tr.claudeCode : tr.codexCli;
134
+ return (
135
+ <button
136
+ key={src}
137
+ onClick={() => setSourceFilter(src)}
138
+ className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
139
+ sourceFilter === src
140
+ ? "bg-primary text-primary-foreground"
141
+ : "bg-muted text-muted-foreground hover:bg-accent"
142
+ }`}
143
+ >
144
+ {label}
145
+ </button>
146
+ );
147
+ })}
148
+ </div>
149
+ )}
150
+
151
+ {/* Section Cards */}
152
+ <div className="grid grid-cols-1 gap-4 px-4 lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
153
+ <Card className="@container/card">
154
+ <CardHeader>
155
+ <CardDescription>{tr.totalTokens}</CardDescription>
156
+ <CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
157
+ {fmt(gt.total)}
158
+ </CardTitle>
159
+ <CardAction>
160
+ <Badge variant="outline">
161
+ {trendUp ? (
162
+ <ArrowUpRight className="size-3" />
163
+ ) : (
164
+ <ArrowDownRight className="size-3" />
165
+ )}
166
+ {trendUp ? "+" : ""}
167
+ {trendPct.toFixed(1)}%
168
+ </Badge>
169
+ </CardAction>
170
+ </CardHeader>
171
+ </Card>
172
+
173
+ <Card className="@container/card">
174
+ <CardHeader>
175
+ <CardDescription>{tr.cacheRead}</CardDescription>
176
+ <CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
177
+ {fmt(gt.cache_read)}
178
+ </CardTitle>
179
+ <CardAction>
180
+ <Badge variant="outline">
181
+ <Database className="size-3" />
182
+ {((gt.cache_read / gt.total) * 100).toFixed(0)}%
183
+ </Badge>
184
+ </CardAction>
185
+ </CardHeader>
186
+ </Card>
187
+
188
+ <Card className="@container/card">
189
+ <CardHeader>
190
+ <CardDescription>{tr.sessions}</CardDescription>
191
+ <CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
192
+ {sessions.length}
193
+ </CardTitle>
194
+ <CardAction>
195
+ <Badge variant="outline">
196
+ <MessageSquare className="size-3" />
197
+ {Object.keys(projectMap).length} {tr.projects}
198
+ </Badge>
199
+ </CardAction>
200
+ </CardHeader>
201
+ </Card>
202
+
203
+ <Card className="@container/card">
204
+ <CardHeader>
205
+ <CardDescription>{tr.estCost}</CardDescription>
206
+ <CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
207
+ {fmtCost(gt.cost, currencySymbol)}
208
+ </CardTitle>
209
+ <CardAction>
210
+ <Badge variant="outline">
211
+ <Coins className="size-3" />
212
+ {tr.basedOnPricing}
213
+ </Badge>
214
+ </CardAction>
215
+ </CardHeader>
216
+ </Card>
217
+ </div>
218
+
219
+ {/* Charts Row */}
220
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-4 px-4 lg:px-6">
221
+ {/* Project Treemap */}
222
+ <Card className="lg:col-span-2">
223
+ <CardHeader className="pb-2">
224
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
225
+ <TrendingUp className="w-4 h-4" />
226
+ {tr.tokenByProject}
227
+ </CardTitle>
228
+ </CardHeader>
229
+ <CardContent>
230
+ <div className="flex flex-wrap gap-1.5 h-[280px] items-stretch">
231
+ {treemapData.map((item) => {
232
+ const pct = (item.size / gt.total) * 100;
233
+ return (
234
+ <button
235
+ key={item.fullName}
236
+ onClick={() => {
237
+ const sessionsOfProject = allSessions.filter(
238
+ (s) => s.project === item.fullName
239
+ );
240
+ if (sessionsOfProject.length > 0) {
241
+ navigate(
242
+ `/session/${encodeURIComponent(sessionsOfProject[0].sessionKey)}`
243
+ );
244
+ }
245
+ }}
246
+ className="relative rounded-md flex flex-col items-center justify-center p-2 overflow-hidden transition-all hover:opacity-80 hover:scale-105 cursor-pointer"
247
+ style={{
248
+ backgroundColor: item.color,
249
+ minWidth: `${Math.max(pct * 3.5, 60)}px`,
250
+ flex: `${Math.max(pct / 20, 0.5)} 1 0`,
251
+ }}
252
+ >
253
+ <span className="text-white text-xs font-medium truncate w-full text-center">
254
+ {item.name}
255
+ </span>
256
+ <span className="text-white/80 text-[10px]">
257
+ {fmt(item.size)}
258
+ </span>
259
+ </button>
260
+ );
261
+ })}
262
+ </div>
263
+ </CardContent>
264
+ </Card>
265
+
266
+ {/* Token Breakdown Pie */}
267
+ <Card>
268
+ <CardHeader className="pb-2">
269
+ <CardTitle className="text-sm font-medium">
270
+ {tr.tokenBreakdown}
271
+ </CardTitle>
272
+ </CardHeader>
273
+ <CardContent>
274
+ <ResponsiveContainer width="100%" height={260}>
275
+ <PieChart>
276
+ <Pie
277
+ data={pieData}
278
+ cx="50%"
279
+ cy="50%"
280
+ innerRadius={55}
281
+ outerRadius={95}
282
+ paddingAngle={2}
283
+ dataKey="value"
284
+ nameKey="name"
285
+ >
286
+ {pieData.map((entry, i) => (
287
+ <Cell key={i} fill={entry.color} stroke="none" />
288
+ ))}
289
+ </Pie>
290
+ <Tooltip
291
+ content={({ active, payload }) => {
292
+ if (!active || !payload?.length) return null;
293
+ const d = payload[0].payload;
294
+ return (
295
+ <div className="bg-popover border border-border rounded-lg px-3 py-2 shadow-lg text-sm">
296
+ <p className="font-medium">{d.name}</p>
297
+ <p className="text-muted-foreground">{fmt(d.value)}</p>
298
+ </div>
299
+ );
300
+ }}
301
+ />
302
+ </PieChart>
303
+ </ResponsiveContainer>
304
+ <div className="flex flex-wrap gap-3 justify-center mt-2">
305
+ {pieData.map((d) => (
306
+ <div key={d.name} className="flex items-center gap-1.5 text-xs">
307
+ <div
308
+ className="w-2.5 h-2.5 rounded-full"
309
+ style={{ backgroundColor: d.color }}
310
+ />
311
+ <span className="text-muted-foreground">
312
+ {d.name}: {fmt(d.value)}
313
+ </span>
314
+ </div>
315
+ ))}
316
+ </div>
317
+ </CardContent>
318
+ </Card>
319
+ </div>
320
+
321
+ {/* Daily Trend */}
322
+ <div className="px-4 lg:px-6">
323
+ <Card>
324
+ <CardHeader className="pb-2">
325
+ <CardTitle className="text-sm font-medium">
326
+ {tr.dailyUsage}
327
+ </CardTitle>
328
+ </CardHeader>
329
+ <CardContent>
330
+ <ResponsiveContainer width="100%" height={220}>
331
+ <AreaChart data={trendData}>
332
+ <defs>
333
+ <linearGradient id="fillTokens" x1="0" y1="0" x2="0" y2="1">
334
+ <stop
335
+ offset="5%"
336
+ stopColor="hsl(220, 70%, 55%)"
337
+ stopOpacity={0.8}
338
+ />
339
+ <stop
340
+ offset="95%"
341
+ stopColor="hsl(220, 70%, 55%)"
342
+ stopOpacity={0.1}
343
+ />
344
+ </linearGradient>
345
+ </defs>
346
+ <XAxis dataKey="date" tick={{ fontSize: 11 }} />
347
+ <YAxis
348
+ tickFormatter={(v: number) => fmt(v)}
349
+ tick={{ fontSize: 11 }}
350
+ />
351
+ <Tooltip
352
+ content={({ active, payload }) => {
353
+ if (!active || !payload?.length) return null;
354
+ const d = payload[0].payload;
355
+ return (
356
+ <div className="bg-popover border border-border rounded-lg px-3 py-2 shadow-lg text-sm">
357
+ <p className="font-medium">{d.date}</p>
358
+ <p>{fmt(d.total)}</p>
359
+ </div>
360
+ );
361
+ }}
362
+ />
363
+ <Area
364
+ type="monotone"
365
+ dataKey="total"
366
+ stroke="hsl(220, 70%, 55%)"
367
+ fill="url(#fillTokens)"
368
+ strokeWidth={2}
369
+ />
370
+ </AreaChart>
371
+ </ResponsiveContainer>
372
+ </CardContent>
373
+ </Card>
374
+ </div>
375
+
376
+ {/* Token by Skill */}
377
+ {skillStats.length > 0 && (
378
+ <div className="px-4 lg:px-6">
379
+ <Card>
380
+ <CardHeader className="pb-2">
381
+ <CardTitle className="text-sm font-medium">
382
+ {tr.tokenBySkill}
383
+ </CardTitle>
384
+ </CardHeader>
385
+ <CardContent>
386
+ <div className="space-y-2">
387
+ {skillStats.slice(0, 15).map((sk) => {
388
+ const pct = (sk.total / skillTotal) * 100;
389
+ return (
390
+ <div key={sk.name} className="flex items-center gap-3">
391
+ <span className="text-xs font-medium w-32 truncate shrink-0" title={sk.name}>
392
+ {sk.name}
393
+ </span>
394
+ <div className="flex-1 h-5 bg-muted rounded-sm overflow-hidden">
395
+ <div
396
+ className="h-full rounded-sm transition-all"
397
+ style={{
398
+ width: `${Math.max(pct, 1)}%`,
399
+ backgroundColor: sk.color,
400
+ }}
401
+ />
402
+ </div>
403
+ <span className="text-xs text-muted-foreground w-16 text-right tabular-nums shrink-0">
404
+ {fmt(sk.total)}
405
+ </span>
406
+ <span className="text-[10px] text-muted-foreground w-12 text-right shrink-0">
407
+ {pct.toFixed(1)}%
408
+ </span>
409
+ </div>
410
+ );
411
+ })}
412
+ </div>
413
+ </CardContent>
414
+ </Card>
415
+ </div>
416
+ )}
417
+
418
+ {/* Top Sessions Table */}
419
+ <div className="px-4 lg:px-6">
420
+ <Card>
421
+ <CardHeader className="pb-2">
422
+ <CardTitle className="text-sm font-medium">
423
+ {tr.topSessions}
424
+ </CardTitle>
425
+ </CardHeader>
426
+ <CardContent>
427
+ <div className="space-y-1">
428
+ {topSessions.map((s, i) => (
429
+ <button
430
+ key={s.sessionKey}
431
+ onClick={() =>
432
+ navigate(
433
+ `/session/${encodeURIComponent(s.sessionKey)}`
434
+ )
435
+ }
436
+ className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-accent transition-colors text-left group"
437
+ >
438
+ <span className="text-xs text-muted-foreground w-6">
439
+ {i + 1}
440
+ </span>
441
+ <div className="flex-1 min-w-0">
442
+ <p className="text-sm font-medium truncate">
443
+ {s.fullName}
444
+ </p>
445
+ <p className="text-xs text-muted-foreground">
446
+ {s.msgs} {tr.msgs}
447
+ </p>
448
+ </div>
449
+ <div className="text-right shrink-0">
450
+ <p className="text-sm font-semibold">{fmt(s.total)}</p>
451
+ <p className="text-xs text-muted-foreground">
452
+ {fmtCost(s.cost, currencySymbol)}
453
+ </p>
454
+ </div>
455
+ <Badge variant="secondary" className="text-[10px] shrink-0">
456
+ #{i + 1}
457
+ </Badge>
458
+ </button>
459
+ ))}
460
+ </div>
461
+ </CardContent>
462
+ </Card>
463
+ </div>
464
+ </div>
465
+ </div>
466
+ </div>
467
+ );
468
+ }