@notenkidev/claude-token-dashboard 0.1.2

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.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # Claude Token Dashboard
2
+
3
+ > Visualize your [Claude Code](https://claude.ai/code) token usage by project and date — built with Next.js + Tailwind.
4
+
5
+ ![Summary cards and daily chart](screenshot-1.jpg)
6
+
7
+ ![Project breakdown table](screenshot-2.jpg)
8
+
9
+ ## What it shows
10
+
11
+ | Section | Details |
12
+ |---|---|
13
+ | **Summary cards** | Output tokens · Input tokens · Effective input · Cache read ratio · Project count · Session count |
14
+ | **Daily chart** | Input / output tokens per day (bar chart) |
15
+ | **Project table** | Per-project breakdown sorted by total usage, with inline bar |
16
+
17
+ Cache read tokens from prompt caching are tracked separately — you can see at a glance how much of your effective input Claude is serving from cache (typically 90%+).
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ npx @notenkidev/claude-token-dashboard
23
+ ```
24
+
25
+ Open [http://localhost:3000](http://localhost:3000).
26
+
27
+ On first run, dependencies are installed automatically (~30s). After that it starts instantly.
28
+
29
+ **Custom port:**
30
+
31
+ ```bash
32
+ npx @notenkidev/claude-token-dashboard -p 4000
33
+ ```
34
+
35
+ Reads `~/.claude/projects/**/*.jsonl` directly — no config, no API key needed.
36
+
37
+ ## How it works
38
+
39
+ Claude Code writes every conversation turn to JSONL files under `~/.claude/projects/`. Each assistant message includes a `usage` object with `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, and `cache_read_input_tokens`.
40
+
41
+ This app:
42
+ 1. Scans all JSONL files at request time (server-side, no API key needed)
43
+ 2. Deduplicates by `message.id` (the same message can appear in multiple entries)
44
+ 3. Groups by project (`cwd` field) and by date (`timestamp` field)
45
+ 4. Renders everything as a dark-mode dashboard
46
+
47
+ Hit **Refresh** to re-read the files without restarting the server.
48
+
49
+ ## Tech stack
50
+
51
+ - [Next.js 16](https://nextjs.org) — App Router, Server Components
52
+ - [Tailwind CSS](https://tailwindcss.com) — dark mode, utility-first
53
+ - [Recharts](https://recharts.org) — daily bar chart
54
+ - [shadcn/ui](https://ui.shadcn.com) — Card, Badge components
55
+
56
+ ## Why I built this
57
+
58
+ I had no idea how many tokens I was burning per project in Claude Code. Turns out all the data is sitting in `~/.claude/projects/` as JSONL files — one file per session, one JSON line per turn. Parsing it took about 100 lines of Python to confirm, and a weekend to turn into something worth looking at.
Binary file
@@ -0,0 +1,130 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @import "shadcn/tailwind.css";
4
+
5
+ @custom-variant dark (&:is(.dark *));
6
+
7
+ @theme inline {
8
+ --color-background: var(--background);
9
+ --color-foreground: var(--foreground);
10
+ --font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
11
+ --font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
12
+ --font-heading: var(--font-sans);
13
+ --color-sidebar-ring: var(--sidebar-ring);
14
+ --color-sidebar-border: var(--sidebar-border);
15
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
16
+ --color-sidebar-accent: var(--sidebar-accent);
17
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
18
+ --color-sidebar-primary: var(--sidebar-primary);
19
+ --color-sidebar-foreground: var(--sidebar-foreground);
20
+ --color-sidebar: var(--sidebar);
21
+ --color-chart-5: var(--chart-5);
22
+ --color-chart-4: var(--chart-4);
23
+ --color-chart-3: var(--chart-3);
24
+ --color-chart-2: var(--chart-2);
25
+ --color-chart-1: var(--chart-1);
26
+ --color-ring: var(--ring);
27
+ --color-input: var(--input);
28
+ --color-border: var(--border);
29
+ --color-destructive: var(--destructive);
30
+ --color-accent-foreground: var(--accent-foreground);
31
+ --color-accent: var(--accent);
32
+ --color-muted-foreground: var(--muted-foreground);
33
+ --color-muted: var(--muted);
34
+ --color-secondary-foreground: var(--secondary-foreground);
35
+ --color-secondary: var(--secondary);
36
+ --color-primary-foreground: var(--primary-foreground);
37
+ --color-primary: var(--primary);
38
+ --color-popover-foreground: var(--popover-foreground);
39
+ --color-popover: var(--popover);
40
+ --color-card-foreground: var(--card-foreground);
41
+ --color-card: var(--card);
42
+ --radius-sm: calc(var(--radius) * 0.6);
43
+ --radius-md: calc(var(--radius) * 0.8);
44
+ --radius-lg: var(--radius);
45
+ --radius-xl: calc(var(--radius) * 1.4);
46
+ --radius-2xl: calc(var(--radius) * 1.8);
47
+ --radius-3xl: calc(var(--radius) * 2.2);
48
+ --radius-4xl: calc(var(--radius) * 2.6);
49
+ }
50
+
51
+ :root {
52
+ --background: oklch(1 0 0);
53
+ --foreground: oklch(0.145 0 0);
54
+ --card: oklch(1 0 0);
55
+ --card-foreground: oklch(0.145 0 0);
56
+ --popover: oklch(1 0 0);
57
+ --popover-foreground: oklch(0.145 0 0);
58
+ --primary: oklch(0.205 0 0);
59
+ --primary-foreground: oklch(0.985 0 0);
60
+ --secondary: oklch(0.97 0 0);
61
+ --secondary-foreground: oklch(0.205 0 0);
62
+ --muted: oklch(0.97 0 0);
63
+ --muted-foreground: oklch(0.556 0 0);
64
+ --accent: oklch(0.97 0 0);
65
+ --accent-foreground: oklch(0.205 0 0);
66
+ --destructive: oklch(0.577 0.245 27.325);
67
+ --border: oklch(0.922 0 0);
68
+ --input: oklch(0.922 0 0);
69
+ --ring: oklch(0.708 0 0);
70
+ --chart-1: oklch(0.87 0 0);
71
+ --chart-2: oklch(0.556 0 0);
72
+ --chart-3: oklch(0.439 0 0);
73
+ --chart-4: oklch(0.371 0 0);
74
+ --chart-5: oklch(0.269 0 0);
75
+ --radius: 0.625rem;
76
+ --sidebar: oklch(0.985 0 0);
77
+ --sidebar-foreground: oklch(0.145 0 0);
78
+ --sidebar-primary: oklch(0.205 0 0);
79
+ --sidebar-primary-foreground: oklch(0.985 0 0);
80
+ --sidebar-accent: oklch(0.97 0 0);
81
+ --sidebar-accent-foreground: oklch(0.205 0 0);
82
+ --sidebar-border: oklch(0.922 0 0);
83
+ --sidebar-ring: oklch(0.708 0 0);
84
+ }
85
+
86
+ .dark {
87
+ --background: oklch(0.145 0 0);
88
+ --foreground: oklch(0.985 0 0);
89
+ --card: oklch(0.205 0 0);
90
+ --card-foreground: oklch(0.985 0 0);
91
+ --popover: oklch(0.205 0 0);
92
+ --popover-foreground: oklch(0.985 0 0);
93
+ --primary: oklch(0.922 0 0);
94
+ --primary-foreground: oklch(0.205 0 0);
95
+ --secondary: oklch(0.269 0 0);
96
+ --secondary-foreground: oklch(0.985 0 0);
97
+ --muted: oklch(0.269 0 0);
98
+ --muted-foreground: oklch(0.708 0 0);
99
+ --accent: oklch(0.269 0 0);
100
+ --accent-foreground: oklch(0.985 0 0);
101
+ --destructive: oklch(0.704 0.191 22.216);
102
+ --border: oklch(1 0 0 / 10%);
103
+ --input: oklch(1 0 0 / 15%);
104
+ --ring: oklch(0.556 0 0);
105
+ --chart-1: oklch(0.87 0 0);
106
+ --chart-2: oklch(0.556 0 0);
107
+ --chart-3: oklch(0.439 0 0);
108
+ --chart-4: oklch(0.371 0 0);
109
+ --chart-5: oklch(0.269 0 0);
110
+ --sidebar: oklch(0.205 0 0);
111
+ --sidebar-foreground: oklch(0.985 0 0);
112
+ --sidebar-primary: oklch(0.488 0.243 264.376);
113
+ --sidebar-primary-foreground: oklch(0.985 0 0);
114
+ --sidebar-accent: oklch(0.269 0 0);
115
+ --sidebar-accent-foreground: oklch(0.985 0 0);
116
+ --sidebar-border: oklch(1 0 0 / 10%);
117
+ --sidebar-ring: oklch(0.556 0 0);
118
+ }
119
+
120
+ @layer base {
121
+ * {
122
+ @apply border-border outline-ring/50;
123
+ }
124
+ body {
125
+ @apply bg-background text-foreground;
126
+ }
127
+ html {
128
+ @apply font-sans;
129
+ }
130
+ }
package/app/layout.tsx ADDED
@@ -0,0 +1,24 @@
1
+ import type { Metadata } from "next"
2
+ import { Geist, Geist_Mono } from "next/font/google"
3
+ import "./globals.css"
4
+
5
+ const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] })
6
+ const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] })
7
+
8
+ export const metadata: Metadata = {
9
+ title: "Claude Token Dashboard",
10
+ description: "Token usage report for Claude Code sessions",
11
+ }
12
+
13
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
14
+ return (
15
+ <html
16
+ lang="ja"
17
+ className={`dark ${geistSans.variable} ${geistMono.variable}`}
18
+ >
19
+ <body className="min-h-screen bg-background text-foreground antialiased">
20
+ {children}
21
+ </body>
22
+ </html>
23
+ )
24
+ }
package/app/page.tsx ADDED
@@ -0,0 +1,74 @@
1
+ import { collect } from "@/lib/collect"
2
+ import SummaryCards from "@/components/SummaryCards"
3
+ import ProjectTable from "@/components/ProjectTable"
4
+ import DailyChart from "@/components/DailyChart"
5
+ import RefreshButton from "@/components/RefreshButton"
6
+ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
7
+
8
+ export const dynamic = "force-dynamic"
9
+
10
+ export default function Page() {
11
+ const { byProject, byDay, totalFiles, totalEntries, skippedDup } = collect()
12
+
13
+ const dayData = Object.entries(byDay)
14
+ .filter(([date]) => date !== "unknown")
15
+ .sort(([a], [b]) => a.localeCompare(b))
16
+ .map(([date, stats]) => ({ date, ...stats }))
17
+
18
+ const generatedAt = new Date().toLocaleString("ja-JP", {
19
+ timeZone: "Asia/Tokyo",
20
+ year: "numeric",
21
+ month: "2-digit",
22
+ day: "2-digit",
23
+ hour: "2-digit",
24
+ minute: "2-digit",
25
+ })
26
+
27
+ return (
28
+ <main className="mx-auto max-w-screen-xl px-4 py-8 space-y-8">
29
+ {/* Header */}
30
+ <div className="flex items-center justify-between">
31
+ <div>
32
+ <h1 className="text-2xl font-bold tracking-tight">Claude Token Dashboard</h1>
33
+ <p className="mt-1 text-sm text-muted-foreground">
34
+ ~/.claude/projects/ &nbsp;·&nbsp; {generatedAt} JST
35
+ </p>
36
+ </div>
37
+ <RefreshButton />
38
+ </div>
39
+
40
+ {/* Summary cards */}
41
+ <SummaryCards
42
+ byProject={byProject}
43
+ byDay={byDay}
44
+ totalFiles={totalFiles}
45
+ totalEntries={totalEntries}
46
+ skippedDup={skippedDup}
47
+ />
48
+
49
+ {/* Daily chart */}
50
+ <Card>
51
+ <CardHeader>
52
+ <CardTitle className="text-base font-semibold text-foreground">
53
+ 日別トークン消費
54
+ </CardTitle>
55
+ </CardHeader>
56
+ <CardContent>
57
+ <DailyChart data={dayData} />
58
+ </CardContent>
59
+ </Card>
60
+
61
+ {/* Project table */}
62
+ <Card>
63
+ <CardHeader>
64
+ <CardTitle className="text-base font-semibold text-foreground">
65
+ プロジェクト別トークン消費
66
+ </CardTitle>
67
+ </CardHeader>
68
+ <CardContent className="p-0">
69
+ <ProjectTable byProject={byProject} />
70
+ </CardContent>
71
+ </Card>
72
+ </main>
73
+ )
74
+ }
package/bin/cli.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync, spawn } = require('child_process')
4
+ const { existsSync } = require('fs')
5
+ const { join } = require('path')
6
+
7
+ const root = join(__dirname, '..')
8
+ const args = process.argv.slice(2)
9
+
10
+ // Install dependencies on first run (node_modules not included in npm package)
11
+ if (!existsSync(join(root, 'node_modules', 'next'))) {
12
+ console.log('📦 Installing dependencies (first run, this takes ~30s)...')
13
+ execSync('npm install', { cwd: root, stdio: 'inherit' })
14
+ }
15
+
16
+ console.log('🚀 Starting Claude Token Dashboard → http://localhost:3000\n')
17
+
18
+ const nextBin = join(root, 'node_modules', '.bin', 'next')
19
+ const child = spawn(process.execPath, [nextBin, 'dev', ...args], {
20
+ cwd: root,
21
+ stdio: 'inherit',
22
+ })
23
+
24
+ child.on('exit', (code) => process.exit(code ?? 0))
25
+ process.on('SIGINT', () => child.kill('SIGINT'))
26
+ process.on('SIGTERM', () => child.kill('SIGTERM'))
@@ -0,0 +1,77 @@
1
+ "use client"
2
+
3
+ import {
4
+ BarChart,
5
+ Bar,
6
+ XAxis,
7
+ YAxis,
8
+ CartesianGrid,
9
+ Tooltip,
10
+ Legend,
11
+ ResponsiveContainer,
12
+ } from "recharts"
13
+
14
+ interface DayEntry {
15
+ date: string
16
+ input: number
17
+ output: number
18
+ cacheRead: number
19
+ cacheCreate: number
20
+ }
21
+
22
+ interface Props {
23
+ data: DayEntry[]
24
+ }
25
+
26
+ function fmt(n: number): string {
27
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
28
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
29
+ return String(n)
30
+ }
31
+
32
+ export default function DailyChart({ data }: Props) {
33
+ const chartData = data.map((d) => ({
34
+ ...d,
35
+ label: d.date.slice(5), // "MM-DD"
36
+ }))
37
+
38
+ return (
39
+ <ResponsiveContainer width="100%" height={300}>
40
+ <BarChart data={chartData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
41
+ <CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
42
+ <XAxis
43
+ dataKey="label"
44
+ tick={{ fontSize: 11, fill: "oklch(0.708 0 0)" }}
45
+ tickLine={false}
46
+ axisLine={false}
47
+ />
48
+ <YAxis
49
+ tickFormatter={fmt}
50
+ tick={{ fontSize: 11, fill: "oklch(0.708 0 0)" }}
51
+ tickLine={false}
52
+ axisLine={false}
53
+ width={48}
54
+ />
55
+ <Tooltip
56
+ contentStyle={{
57
+ background: "oklch(0.205 0 0)",
58
+ border: "1px solid oklch(1 0 0 / 10%)",
59
+ borderRadius: "8px",
60
+ fontSize: 12,
61
+ }}
62
+ labelStyle={{ color: "oklch(0.985 0 0)", marginBottom: 4 }}
63
+ formatter={(value, name) => [
64
+ typeof value === "number" ? value.toLocaleString() : value,
65
+ name,
66
+ ]}
67
+ />
68
+ <Legend
69
+ wrapperStyle={{ fontSize: 12, color: "oklch(0.708 0 0)" }}
70
+ iconType="square"
71
+ />
72
+ <Bar dataKey="input" name="input" fill="oklch(0.6 0.15 250)" radius={[2, 2, 0, 0]} />
73
+ <Bar dataKey="output" name="output" fill="oklch(0.6 0.18 290)" radius={[2, 2, 0, 0]} />
74
+ </BarChart>
75
+ </ResponsiveContainer>
76
+ )
77
+ }
@@ -0,0 +1,69 @@
1
+ import type { TokenStats } from "@/lib/collect"
2
+
3
+ interface Props {
4
+ byProject: Record<string, TokenStats>
5
+ }
6
+
7
+ function fmt(n: number): string {
8
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
9
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
10
+ return n.toLocaleString()
11
+ }
12
+
13
+ export default function ProjectTable({ byProject }: Props) {
14
+ const rows = Object.entries(byProject)
15
+ .map(([project, stats]) => ({ project, ...stats, total: stats.input + stats.output }))
16
+ .sort((a, b) => b.total - a.total)
17
+
18
+ const maxTotal = rows[0]?.total ?? 1
19
+
20
+ return (
21
+ <div className="overflow-x-auto rounded-xl border border-border">
22
+ <table className="w-full text-sm">
23
+ <thead>
24
+ <tr className="border-b border-border bg-muted/40">
25
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground">Project</th>
26
+ <th className="px-4 py-3 text-right font-medium text-muted-foreground">Output</th>
27
+ <th className="px-4 py-3 text-right font-medium text-muted-foreground">Input</th>
28
+ <th className="px-4 py-3 text-right font-medium text-muted-foreground hidden md:table-cell">Cache Read</th>
29
+ <th className="px-4 py-3 text-right font-medium text-muted-foreground hidden lg:table-cell">Cache Create</th>
30
+ <th className="px-4 py-3 text-right font-medium text-muted-foreground">Total</th>
31
+ <th className="px-4 py-3 w-32 hidden sm:table-cell"></th>
32
+ </tr>
33
+ </thead>
34
+ <tbody>
35
+ {rows.map((row, i) => {
36
+ const pct = (row.total / maxTotal) * 100
37
+ return (
38
+ <tr
39
+ key={row.project}
40
+ className={`border-b border-border last:border-0 hover:bg-muted/20 transition-colors ${i === 0 ? "bg-muted/10" : ""}`}
41
+ >
42
+ <td className="px-4 py-3 font-mono text-xs max-w-[200px] truncate" title={row.project}>
43
+ {row.project}
44
+ </td>
45
+ <td className="px-4 py-3 text-right font-mono text-xs text-violet-400">{fmt(row.output)}</td>
46
+ <td className="px-4 py-3 text-right font-mono text-xs text-blue-400">{fmt(row.input)}</td>
47
+ <td className="px-4 py-3 text-right font-mono text-xs text-muted-foreground hidden md:table-cell">
48
+ {fmt(row.cacheRead)}
49
+ </td>
50
+ <td className="px-4 py-3 text-right font-mono text-xs text-muted-foreground hidden lg:table-cell">
51
+ {fmt(row.cacheCreate)}
52
+ </td>
53
+ <td className="px-4 py-3 text-right font-mono text-xs font-semibold">{fmt(row.total)}</td>
54
+ <td className="px-4 py-3 hidden sm:table-cell">
55
+ <div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
56
+ <div
57
+ className="h-full rounded-full bg-violet-500/70"
58
+ style={{ width: `${pct}%` }}
59
+ />
60
+ </div>
61
+ </td>
62
+ </tr>
63
+ )
64
+ })}
65
+ </tbody>
66
+ </table>
67
+ </div>
68
+ )
69
+ }
@@ -0,0 +1,20 @@
1
+ "use client"
2
+
3
+ import { useRouter } from "next/navigation"
4
+ import { useTransition } from "react"
5
+
6
+ export default function RefreshButton() {
7
+ const router = useRouter()
8
+ const [isPending, startTransition] = useTransition()
9
+
10
+ return (
11
+ <button
12
+ onClick={() => startTransition(() => router.refresh())}
13
+ disabled={isPending}
14
+ className="flex items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-secondary-foreground hover:bg-secondary/80 transition-colors disabled:opacity-50"
15
+ >
16
+ <span className={isPending ? "animate-spin" : ""}>↻</span>
17
+ {isPending ? "Refreshing…" : "Refresh"}
18
+ </button>
19
+ )
20
+ }
@@ -0,0 +1,52 @@
1
+ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
2
+ import type { TokenStats } from "@/lib/collect"
3
+
4
+ interface Props {
5
+ byProject: Record<string, TokenStats>
6
+ byDay: Record<string, TokenStats>
7
+ totalFiles: number
8
+ totalEntries: number
9
+ skippedDup: number
10
+ }
11
+
12
+ function fmtBig(n: number): string {
13
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`
14
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
15
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
16
+ return n.toLocaleString()
17
+ }
18
+
19
+ export default function SummaryCards({ byProject, totalFiles, totalEntries, skippedDup }: Props) {
20
+ const allStats = Object.values(byProject)
21
+ const totalInput = allStats.reduce((s, v) => s + v.input, 0)
22
+ const totalOutput = allStats.reduce((s, v) => s + v.output, 0)
23
+ const totalCacheRead = allStats.reduce((s, v) => s + v.cacheRead, 0)
24
+ const totalCacheCreate = allStats.reduce((s, v) => s + v.cacheCreate, 0)
25
+ const effectiveInput = totalInput + totalCacheRead + totalCacheCreate
26
+ const cacheRatio = effectiveInput > 0 ? (totalCacheRead / effectiveInput) * 100 : 0
27
+
28
+ const cards = [
29
+ { title: "Output Tokens", value: fmtBig(totalOutput), sub: "generated" },
30
+ { title: "Input Tokens", value: fmtBig(totalInput), sub: "sent" },
31
+ { title: "Effective Input", value: fmtBig(effectiveInput), sub: "input + cache" },
32
+ { title: "Cache Read Ratio", value: `${cacheRatio.toFixed(1)}%`, sub: "of effective input" },
33
+ { title: "Projects", value: String(Object.keys(byProject).length), sub: "unique" },
34
+ { title: "Sessions", value: fmtBig(totalFiles), sub: `${fmtBig(totalEntries)} entries · ${fmtBig(skippedDup)} deduped` },
35
+ ]
36
+
37
+ return (
38
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
39
+ {cards.map((c) => (
40
+ <Card key={c.title}>
41
+ <CardHeader className="pb-2">
42
+ <CardTitle>{c.title}</CardTitle>
43
+ </CardHeader>
44
+ <CardContent>
45
+ <p className="text-2xl font-bold font-mono tracking-tight">{c.value}</p>
46
+ <p className="mt-1 text-xs text-muted-foreground">{c.sub}</p>
47
+ </CardContent>
48
+ </Card>
49
+ ))}
50
+ </div>
51
+ )
52
+ }
@@ -0,0 +1,20 @@
1
+ import { cn } from "@/lib/utils"
2
+
3
+ interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
4
+ variant?: "default" | "secondary" | "outline"
5
+ }
6
+
7
+ export function Badge({ className, variant = "default", ...props }: BadgeProps) {
8
+ return (
9
+ <div
10
+ className={cn(
11
+ "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",
12
+ variant === "default" && "bg-primary text-primary-foreground",
13
+ variant === "secondary" && "bg-secondary text-secondary-foreground",
14
+ variant === "outline" && "border border-border text-foreground",
15
+ className
16
+ )}
17
+ {...props}
18
+ />
19
+ )
20
+ }
@@ -0,0 +1,58 @@
1
+ import { Button as ButtonPrimitive } from "@base-ui/react/button"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const buttonVariants = cva(
7
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
12
+ outline:
13
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
14
+ secondary:
15
+ "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
16
+ ghost:
17
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
18
+ destructive:
19
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default:
24
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
25
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
26
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
27
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
28
+ icon: "size-8",
29
+ "icon-xs":
30
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
31
+ "icon-sm":
32
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
33
+ "icon-lg": "size-9",
34
+ },
35
+ },
36
+ defaultVariants: {
37
+ variant: "default",
38
+ size: "default",
39
+ },
40
+ }
41
+ )
42
+
43
+ function Button({
44
+ className,
45
+ variant = "default",
46
+ size = "default",
47
+ ...props
48
+ }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
49
+ return (
50
+ <ButtonPrimitive
51
+ data-slot="button"
52
+ className={cn(buttonVariants({ variant, size, className }))}
53
+ {...props}
54
+ />
55
+ )
56
+ }
57
+
58
+ export { Button, buttonVariants }
@@ -0,0 +1,22 @@
1
+ import { cn } from "@/lib/utils"
2
+
3
+ export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
4
+ return (
5
+ <div
6
+ className={cn("rounded-xl border border-border bg-card text-card-foreground shadow-sm", className)}
7
+ {...props}
8
+ />
9
+ )
10
+ }
11
+
12
+ export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
13
+ return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
14
+ }
15
+
16
+ export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
17
+ return <h3 className={cn("text-sm font-medium text-muted-foreground", className)} {...props} />
18
+ }
19
+
20
+ export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
21
+ return <div className={cn("p-6 pt-0", className)} {...props} />
22
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "base-nova",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "rtl": false,
15
+ "aliases": {
16
+ "components": "@/components",
17
+ "utils": "@/lib/utils",
18
+ "ui": "@/components/ui",
19
+ "lib": "@/lib",
20
+ "hooks": "@/hooks"
21
+ },
22
+ "menuColor": "default",
23
+ "menuAccent": "subtle",
24
+ "registries": {}
25
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
package/lib/collect.ts ADDED
@@ -0,0 +1,125 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+ import os from "os"
4
+
5
+ export interface TokenStats {
6
+ input: number
7
+ output: number
8
+ cacheCreate: number
9
+ cacheRead: number
10
+ }
11
+
12
+ export interface DashboardData {
13
+ byProject: Record<string, TokenStats>
14
+ byDay: Record<string, TokenStats>
15
+ totalFiles: number
16
+ totalEntries: number
17
+ skippedDup: number
18
+ }
19
+
20
+ const BASE = path.join(os.homedir(), ".claude", "projects")
21
+
22
+ function projectLabelFromCwd(cwd: string): string {
23
+ const home = os.homedir()
24
+ if (cwd.startsWith(home)) {
25
+ cwd = cwd.slice(home.length).replace(/^\//, "")
26
+ }
27
+ if (cwd.startsWith("works/")) cwd = cwd.slice(6)
28
+ return cwd || "(home)"
29
+ }
30
+
31
+ function projectLabelFromDir(dirName: string): string {
32
+ const parts = dirName.replace(/^-/, "").split("-")
33
+ const idx = parts.indexOf("watanabehiroya")
34
+ const remainder = idx >= 0 ? parts.slice(idx + 1) : parts
35
+ const trimmed =
36
+ remainder.length > 0 && (remainder[0] === "works" || remainder[0] === "Library")
37
+ ? remainder.slice(1)
38
+ : remainder
39
+ return trimmed.length > 0 ? trimmed.join("/") : dirName
40
+ }
41
+
42
+ function emptyStats(): TokenStats {
43
+ return { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 }
44
+ }
45
+
46
+ function addStats(target: TokenStats, input: number, output: number, cacheCreate: number, cacheRead: number) {
47
+ target.input += input
48
+ target.output += output
49
+ target.cacheCreate += cacheCreate
50
+ target.cacheRead += cacheRead
51
+ }
52
+
53
+ export function collect(): DashboardData {
54
+ const byProject: Record<string, TokenStats> = {}
55
+ const byDay: Record<string, TokenStats> = {}
56
+ const seenMessageIds = new Set<string>()
57
+ let totalFiles = 0
58
+ let totalEntries = 0
59
+ let skippedDup = 0
60
+
61
+ if (!fs.existsSync(BASE)) return { byProject, byDay, totalFiles, totalEntries, skippedDup }
62
+
63
+ const projectDirs = fs.readdirSync(BASE).sort()
64
+
65
+ for (const dirName of projectDirs) {
66
+ const projectDir = path.join(BASE, dirName)
67
+ if (!fs.statSync(projectDir).isDirectory()) continue
68
+
69
+ const fallbackLabel = projectLabelFromDir(dirName)
70
+
71
+ const jsonlFiles = fs
72
+ .readdirSync(projectDir)
73
+ .filter((f) => f.endsWith(".jsonl"))
74
+
75
+ for (const file of jsonlFiles) {
76
+ totalFiles++
77
+ const content = fs.readFileSync(path.join(projectDir, file), "utf-8")
78
+ const lines = content.split("\n")
79
+
80
+ for (const line of lines) {
81
+ const trimmed = line.trim()
82
+ if (!trimmed) continue
83
+
84
+ let d: Record<string, unknown>
85
+ try {
86
+ d = JSON.parse(trimmed)
87
+ } catch {
88
+ continue
89
+ }
90
+
91
+ const message = d.message as Record<string, unknown> | undefined
92
+ const usage = message?.usage as Record<string, number> | undefined
93
+ if (!usage) continue
94
+
95
+ const msgId = (message?.id as string) || ""
96
+ if (msgId && seenMessageIds.has(msgId)) {
97
+ skippedDup++
98
+ continue
99
+ }
100
+ if (msgId) seenMessageIds.add(msgId)
101
+
102
+ const cwd = (d.cwd as string) || ""
103
+ const label = cwd ? projectLabelFromCwd(cwd) : fallbackLabel
104
+
105
+ const ts = (d.timestamp as string) || ""
106
+ let date = "unknown"
107
+ const datePart = ts.slice(0, 10)
108
+ if (/^\d{4}-\d{2}-\d{2}$/.test(datePart)) date = datePart
109
+
110
+ const input = (usage.input_tokens || 0)
111
+ const output = (usage.output_tokens || 0)
112
+ const cacheCreate = (usage.cache_creation_input_tokens || 0)
113
+ const cacheRead = (usage.cache_read_input_tokens || 0)
114
+
115
+ if (!byProject[label]) byProject[label] = emptyStats()
116
+ if (!byDay[date]) byDay[date] = emptyStats()
117
+ addStats(byProject[label], input, output, cacheCreate, cacheRead)
118
+ addStats(byDay[date], input, output, cacheCreate, cacheRead)
119
+ totalEntries++
120
+ }
121
+ }
122
+ }
123
+
124
+ return { byProject, byDay, totalFiles, totalEntries, skippedDup }
125
+ }
package/lib/utils.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
package/next-env.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/dev/types/routes.d.ts";
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
package/next.config.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ };
6
+
7
+ export default nextConfig;
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@notenkidev/claude-token-dashboard",
3
+ "version": "0.1.2",
4
+ "description": "Visualize your Claude Code token usage by project and date",
5
+ "bin": {
6
+ "claude-token-dashboard": "./bin/cli.js"
7
+ },
8
+ "files": [
9
+ "app",
10
+ "components",
11
+ "lib",
12
+ "public",
13
+ "bin",
14
+ "next.config.ts",
15
+ "postcss.config.mjs",
16
+ "tsconfig.json",
17
+ "components.json",
18
+ "eslint.config.mjs",
19
+ "next-env.d.ts",
20
+ "screenshot-1.jpg",
21
+ "screenshot-2.jpg"
22
+ ],
23
+ "scripts": {
24
+ "dev": "next dev",
25
+ "build": "next build",
26
+ "start": "next start",
27
+ "lint": "eslint"
28
+ },
29
+ "dependencies": {
30
+ "@base-ui/react": "^1.5.0",
31
+ "class-variance-authority": "^0.7.1",
32
+ "clsx": "^2.1.1",
33
+ "lucide-react": "^1.17.0",
34
+ "next": "16.2.7",
35
+ "react": "19.2.4",
36
+ "react-dom": "19.2.4",
37
+ "recharts": "^3.8.1",
38
+ "shadcn": "^4.10.0",
39
+ "tailwind-merge": "^3.6.0",
40
+ "tw-animate-css": "^1.4.0"
41
+ },
42
+ "devDependencies": {
43
+ "@tailwindcss/postcss": "^4",
44
+ "@types/node": "^20",
45
+ "@types/react": "^19",
46
+ "@types/react-dom": "^19",
47
+ "eslint": "^9",
48
+ "eslint-config-next": "16.2.7",
49
+ "tailwindcss": "^4",
50
+ "typescript": "^5"
51
+ }
52
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1 @@
1
+ <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
Binary file
Binary file
package/tsconfig.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }