@notenkidev/claude-token-dashboard 0.1.10 โ†’ 0.1.12

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/app/page.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { collect } from "@/lib/collect"
2
2
  import SummaryCards from "@/components/SummaryCards"
3
+ import InsightPanel from "@/components/InsightPanel"
3
4
  import ProjectTable from "@/components/ProjectTable"
4
5
  import DailyChart from "@/components/DailyChart"
5
6
  import RefreshButton from "@/components/RefreshButton"
@@ -8,7 +9,7 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
8
9
  export const dynamic = "force-dynamic"
9
10
 
10
11
  export default function Page() {
11
- const { byProject, byDay, totalFiles, totalEntries, skippedDup } = collect()
12
+ const { byProject, byDay, byProjectClaudeMd, totalFiles, totalEntries, skippedDup } = collect()
12
13
 
13
14
  const dayData = Object.entries(byDay)
14
15
  .filter(([date]) => date !== "unknown")
@@ -46,6 +47,9 @@ export default function Page() {
46
47
  skippedDup={skippedDup}
47
48
  />
48
49
 
50
+ {/* Insights */}
51
+ <InsightPanel byProject={byProject} byProjectClaudeMd={byProjectClaudeMd} />
52
+
49
53
  {/* Daily chart */}
50
54
  <Card>
51
55
  <CardHeader>
@@ -66,7 +70,7 @@ export default function Page() {
66
70
  </CardTitle>
67
71
  </CardHeader>
68
72
  <CardContent className="p-0">
69
- <ProjectTable byProject={byProject} />
73
+ <ProjectTable byProject={byProject} byProjectClaudeMd={byProjectClaudeMd} />
70
74
  </CardContent>
71
75
  </Card>
72
76
  </main>
@@ -0,0 +1,64 @@
1
+ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
2
+ import type { TokenStats } from "@/lib/collect"
3
+ import { generateInsights, type InsightLevel } from "@/lib/insights"
4
+
5
+ interface Props {
6
+ byProject: Record<string, TokenStats>
7
+ byProjectClaudeMd: Record<string, number | null>
8
+ }
9
+
10
+ const LEVEL_STYLES: Record<InsightLevel, { bar: string; icon: string; badge: string }> = {
11
+ alert: { bar: "bg-red-500", icon: "๐Ÿ”ด", badge: "text-red-400" },
12
+ warning: { bar: "bg-amber-500", icon: "โš ๏ธ", badge: "text-amber-400" },
13
+ tip: { bar: "bg-emerald-500",icon: "๐Ÿ’ก", badge: "text-emerald-400" },
14
+ }
15
+
16
+ const LEVEL_LABEL: Record<InsightLevel, string> = {
17
+ alert: "้‡่ฆ",
18
+ warning: "ๆณจๆ„",
19
+ tip: "ๆๆกˆ",
20
+ }
21
+
22
+ export default function InsightPanel({ byProject, byProjectClaudeMd }: Props) {
23
+ const insights = generateInsights(byProject, byProjectClaudeMd)
24
+
25
+ if (insights.length === 0) return null
26
+
27
+ return (
28
+ <Card>
29
+ <CardHeader className="pb-3">
30
+ <CardTitle className="text-base font-semibold text-foreground">
31
+ ๅ‰Šๆธ›ๆๆกˆ
32
+ </CardTitle>
33
+ </CardHeader>
34
+ <CardContent className="space-y-3">
35
+ {insights.map((insight, i) => {
36
+ const s = LEVEL_STYLES[insight.level]
37
+ return (
38
+ <div
39
+ key={i}
40
+ className="flex gap-3 rounded-lg border border-border bg-muted/20 overflow-hidden"
41
+ >
42
+ {/* ๅทฆใ‚ซใƒฉใƒผใƒใƒผ */}
43
+ <div className={`w-1 shrink-0 ${s.bar}`} />
44
+ <div className="flex items-start gap-2 py-3 pr-4">
45
+ <span className="text-base leading-none mt-0.5">{s.icon}</span>
46
+ <div>
47
+ <span className={`text-xs font-semibold ${s.badge} mr-2`}>
48
+ {LEVEL_LABEL[insight.level]}
49
+ </span>
50
+ <span className="text-xs font-medium text-foreground mr-2">
51
+ {insight.title}
52
+ </span>
53
+ <span className="text-xs text-muted-foreground">
54
+ {insight.message}
55
+ </span>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ )
60
+ })}
61
+ </CardContent>
62
+ </Card>
63
+ )
64
+ }
@@ -1,8 +1,11 @@
1
1
  import type { TokenStats } from "@/lib/collect"
2
2
  import { calcCost, fmtCost } from "@/lib/pricing"
3
3
 
4
+ const CLAUDE_MD_WARN_BYTES = 5 * 1024 // 5 KB
5
+
4
6
  interface Props {
5
7
  byProject: Record<string, TokenStats>
8
+ byProjectClaudeMd: Record<string, number | null>
6
9
  }
7
10
 
8
11
  function fmt(n: number): string {
@@ -11,13 +14,14 @@ function fmt(n: number): string {
11
14
  return n.toLocaleString()
12
15
  }
13
16
 
14
- export default function ProjectTable({ byProject }: Props) {
17
+ export default function ProjectTable({ byProject, byProjectClaudeMd }: Props) {
15
18
  const rows = Object.entries(byProject)
16
19
  .map(([project, stats]) => ({
17
20
  project,
18
21
  ...stats,
19
22
  total: stats.input + stats.output,
20
23
  cost: calcCost(stats),
24
+ claudeMdBytes: byProjectClaudeMd[project] ?? null,
21
25
  }))
22
26
  .sort((a, b) => b.cost - a.cost)
23
27
 
@@ -36,6 +40,7 @@ export default function ProjectTable({ byProject }: Props) {
36
40
  <th className="px-4 py-3 text-right font-medium text-muted-foreground">Input</th>
37
41
  <th className="px-4 py-3 text-right font-medium text-muted-foreground hidden md:table-cell">Cache Read</th>
38
42
  <th className="px-4 py-3 text-right font-medium text-muted-foreground hidden lg:table-cell">Cache Create</th>
43
+ <th className="px-4 py-3 text-right font-medium text-muted-foreground hidden xl:table-cell">CLAUDE.md</th>
39
44
  <th className="px-4 py-3 text-right font-medium text-muted-foreground">Tokens</th>
40
45
  <th className="px-4 py-3 w-28 hidden sm:table-cell"></th>
41
46
  </tr>
@@ -45,6 +50,11 @@ export default function ProjectTable({ byProject }: Props) {
45
50
  const pct = (row.total / maxTotal) * 100
46
51
  const costShare = totalCost > 0 ? row.cost / totalCost : 0
47
52
  const isHighCost = costShare >= WARNING_THRESHOLD
53
+ const isHeavyClaudeMd = row.claudeMdBytes !== null && row.claudeMdBytes >= CLAUDE_MD_WARN_BYTES
54
+ const claudeMdLabel =
55
+ row.claudeMdBytes === null
56
+ ? "-"
57
+ : `${(row.claudeMdBytes / 1024).toFixed(1)} KB`
48
58
 
49
59
  return (
50
60
  <tr
@@ -81,6 +91,20 @@ export default function ProjectTable({ byProject }: Props) {
81
91
  <td className="px-4 py-3 text-right font-mono text-xs text-muted-foreground hidden lg:table-cell">
82
92
  {fmt(row.cacheCreate)}
83
93
  </td>
94
+ <td className="px-4 py-3 text-right font-mono text-xs hidden xl:table-cell">
95
+ {isHeavyClaudeMd ? (
96
+ <span
97
+ className="text-orange-400 font-semibold"
98
+ title="CLAUDE.mdใŒ้‡ใ„ๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™๏ผˆ5KBไปฅไธŠ๏ผ‰"
99
+ >
100
+ โš  {claudeMdLabel}
101
+ </span>
102
+ ) : (
103
+ <span className={row.claudeMdBytes !== null ? "text-muted-foreground" : "text-muted-foreground/40"}>
104
+ {claudeMdLabel}
105
+ </span>
106
+ )}
107
+ </td>
84
108
  <td className="px-4 py-3 text-right font-mono text-xs text-muted-foreground">{fmt(row.total)}</td>
85
109
  <td className="px-4 py-3 hidden sm:table-cell">
86
110
  <div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
package/lib/collect.ts CHANGED
@@ -12,6 +12,7 @@ export interface TokenStats {
12
12
  export interface DashboardData {
13
13
  byProject: Record<string, TokenStats>
14
14
  byDay: Record<string, TokenStats>
15
+ byProjectClaudeMd: Record<string, number | null> // bytes, null = not found
15
16
  totalFiles: number
16
17
  totalEntries: number
17
18
  skippedDup: number
@@ -50,15 +51,34 @@ function addStats(target: TokenStats, input: number, output: number, cacheCreate
50
51
  target.cacheRead += cacheRead
51
52
  }
52
53
 
54
+ function claudeMdBytes(cwd: string): number | null {
55
+ // Walk up from cwd to find CLAUDE.md, stopping at home directory
56
+ const home = os.homedir()
57
+ let dir = cwd
58
+ while (dir.startsWith(home) && dir !== home) {
59
+ try {
60
+ const stat = fs.statSync(path.join(dir, "CLAUDE.md"))
61
+ if (stat.isFile()) return stat.size
62
+ } catch { /* not found at this level */ }
63
+ const parent = path.dirname(dir)
64
+ if (parent === dir) break
65
+ dir = parent
66
+ }
67
+ return null
68
+ }
69
+
53
70
  export function collect(): DashboardData {
54
71
  const byProject: Record<string, TokenStats> = {}
55
72
  const byDay: Record<string, TokenStats> = {}
73
+ const cwdByLabel: Record<string, string> = {} // label โ†’ first seen cwd
56
74
  const seenMessageIds = new Set<string>()
57
75
  let totalFiles = 0
58
76
  let totalEntries = 0
59
77
  let skippedDup = 0
60
78
 
61
- if (!fs.existsSync(BASE)) return { byProject, byDay, totalFiles, totalEntries, skippedDup }
79
+ if (!fs.existsSync(BASE)) {
80
+ return { byProject, byDay, byProjectClaudeMd: {}, totalFiles, totalEntries, skippedDup }
81
+ }
62
82
 
63
83
  const projectDirs = fs.readdirSync(BASE).sort()
64
84
 
@@ -101,6 +121,7 @@ export function collect(): DashboardData {
101
121
 
102
122
  const cwd = (d.cwd as string) || ""
103
123
  const label = cwd ? projectLabelFromCwd(cwd) : fallbackLabel
124
+ if (cwd && !cwdByLabel[label]) cwdByLabel[label] = cwd
104
125
 
105
126
  const ts = (d.timestamp as string) || ""
106
127
  let date = "unknown"
@@ -121,5 +142,12 @@ export function collect(): DashboardData {
121
142
  }
122
143
  }
123
144
 
124
- return { byProject, byDay, totalFiles, totalEntries, skippedDup }
145
+ // Resolve CLAUDE.md sizes after all entries are processed
146
+ const byProjectClaudeMd: Record<string, number | null> = {}
147
+ for (const label of Object.keys(byProject)) {
148
+ const cwd = cwdByLabel[label]
149
+ byProjectClaudeMd[label] = cwd ? claudeMdBytes(cwd) : null
150
+ }
151
+
152
+ return { byProject, byDay, byProjectClaudeMd, totalFiles, totalEntries, skippedDup }
125
153
  }
@@ -0,0 +1,86 @@
1
+ import type { TokenStats } from "./collect"
2
+ import { calcCost, PRICING } from "./pricing"
3
+
4
+ export type InsightLevel = "warning" | "alert" | "tip"
5
+
6
+ export interface Insight {
7
+ level: InsightLevel
8
+ title: string
9
+ message: string
10
+ }
11
+
12
+ const CLAUDE_MD_WARN_BYTES = 5 * 1024
13
+ const HIGH_COST_SHARE_THRESHOLD = 0.20 // 20% โ†’ alert
14
+ const HIGH_CACHE_CREATE_RATIO = 0.50 // cache_create > 50% of own cost โ†’ warning
15
+
16
+ function cacheCreateCost(stats: TokenStats): number {
17
+ return stats.cacheCreate * PRICING.cacheCreate
18
+ }
19
+
20
+ export function generateInsights(
21
+ byProject: Record<string, TokenStats>,
22
+ byProjectClaudeMd: Record<string, number | null>,
23
+ ): Insight[] {
24
+ const insights: Insight[] = []
25
+
26
+ const entries = Object.entries(byProject)
27
+ if (entries.length === 0) return insights
28
+
29
+ const totalCost = entries.reduce((s, [, v]) => s + calcCost(v), 0)
30
+ if (totalCost === 0) return insights
31
+
32
+ // โ”€โ”€ Insight 1: ๆœ€ใ‚‚ใ‚ณใ‚นใƒˆใŒ้ซ˜ใ„ใƒ—ใƒญใ‚ธใ‚งใ‚ฏใƒˆ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
33
+ const [topProject, topStats] = entries.reduce(
34
+ (best, cur) => (calcCost(cur[1]) > calcCost(best[1]) ? cur : best),
35
+ )
36
+ const topShare = calcCost(topStats) / totalCost
37
+ insights.push({
38
+ level: topShare >= HIGH_COST_SHARE_THRESHOLD ? "alert" : "tip",
39
+ title: "ๆœ€ๅคงใ‚ณใ‚นใƒˆใƒ—ใƒญใ‚ธใ‚งใ‚ฏใƒˆ",
40
+ message: `${topProject} ใŒๅ…จไฝ“ใฎ ${(topShare * 100).toFixed(1)}% ใ‚’ๅ ใ‚ใฆใ„ใพใ™ใ€‚CLAUDE.mdใฎใ‚ตใ‚คใ‚บใ‚’็ขบ่ชใ—ใฆใใ ใ•ใ„ใ€‚`,
41
+ })
42
+
43
+ // โ”€โ”€ Insight 2: cache_creation ใŒ้ซ˜ใ„ใƒ—ใƒญใ‚ธใ‚งใ‚ฏใƒˆ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
44
+ const highCacheProjects = entries
45
+ .filter(([, stats]) => {
46
+ const own = calcCost(stats)
47
+ return own > 0 && cacheCreateCost(stats) / own >= HIGH_CACHE_CREATE_RATIO
48
+ })
49
+ .sort(([, a], [, b]) => cacheCreateCost(b) - cacheCreateCost(a))
50
+ .slice(0, 3)
51
+
52
+ for (const [project] of highCacheProjects) {
53
+ insights.push({
54
+ level: "warning",
55
+ title: "cache_creation ้Žๅคš",
56
+ message: `${project} ใฎ cache_creation ใŒ้ซ˜ใ„ใงใ™ใ€‚CLAUDE.mdใŒ่‚ฅๅคงๅŒ–ใ—ใฆใ„ใ‚‹ๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™ใ€‚`,
57
+ })
58
+ }
59
+
60
+ // โ”€โ”€ Insight 3: ๅ…จไฝ“ๆœ€้ฉๅŒ–ใƒใƒ†ใƒณใ‚ทใƒฃใƒซ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
61
+ // ๅคงใใช CLAUDE.md๏ผˆโ‰ฅ5KB๏ผ‰ใ‚’ๆŒใคใƒ—ใƒญใ‚ธใ‚งใ‚ฏใƒˆใฎ cache_create ใ‚ณใ‚นใƒˆใ‚’
62
+ // 50% ๅ‰Šๆธ›ใงใใ‚‹ใจไปฎๅฎšใ—ใŸๅ ดๅˆใฎ็ฏ€็ด„้ก
63
+ const heavyProjects = entries.filter(
64
+ ([label]) => (byProjectClaudeMd[label] ?? 0) >= CLAUDE_MD_WARN_BYTES,
65
+ )
66
+
67
+ const targetProjects = heavyProjects.length > 0
68
+ ? heavyProjects
69
+ : entries.sort(([, a], [, b]) => cacheCreateCost(b) - cacheCreateCost(a)).slice(0, 3)
70
+
71
+ const optimizableCost = targetProjects.reduce(
72
+ (s, [, stats]) => s + cacheCreateCost(stats),
73
+ 0,
74
+ )
75
+ const savingPct = (optimizableCost * 0.5) / totalCost * 100
76
+
77
+ if (savingPct >= 1) {
78
+ insights.push({
79
+ level: "tip",
80
+ title: "ๆœ€้ฉๅŒ–ใƒใƒ†ใƒณใ‚ทใƒฃใƒซ",
81
+ message: `ๆœ€้ฉๅŒ–ใ™ใ‚ŒใฐๆŽจๅฎš ${savingPct.toFixed(1)}% ใฎใ‚ณใ‚นใƒˆๅ‰Šๆธ›ใŒๅฏ่ƒฝใงใ™ใ€‚`,
82
+ })
83
+ }
84
+
85
+ return insights
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notenkidev/claude-token-dashboard",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Visualize your Claude Code token usage by project and date",
5
5
  "bin": {
6
6
  "claude-token-dashboard": "bin/cli.js"