@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 +58 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +130 -0
- package/app/layout.tsx +24 -0
- package/app/page.tsx +74 -0
- package/bin/cli.js +26 -0
- package/components/DailyChart.tsx +77 -0
- package/components/ProjectTable.tsx +69 -0
- package/components/RefreshButton.tsx +20 -0
- package/components/SummaryCards.tsx +52 -0
- package/components/ui/badge.tsx +20 -0
- package/components/ui/button.tsx +58 -0
- package/components/ui/card.tsx +22 -0
- package/components.json +25 -0
- package/eslint.config.mjs +18 -0
- package/lib/collect.ts +125 -0
- package/lib/utils.ts +6 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +7 -0
- package/package.json +52 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/screenshot-1.jpg +0 -0
- package/screenshot-2.jpg +0 -0
- package/tsconfig.json +34 -0
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
|
+

|
|
6
|
+
|
|
7
|
+

|
|
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.
|
package/app/favicon.ico
ADDED
|
Binary file
|
package/app/globals.css
ADDED
|
@@ -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/ · {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
|
+
}
|
package/components.json
ADDED
|
@@ -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
package/next-env.d.ts
ADDED
package/next.config.ts
ADDED
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
|
+
}
|
package/public/file.svg
ADDED
|
@@ -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>
|
package/public/globe.svg
ADDED
|
@@ -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>
|
package/public/next.svg
ADDED
|
@@ -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>
|
package/screenshot-1.jpg
ADDED
|
Binary file
|
package/screenshot-2.jpg
ADDED
|
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
|
+
}
|