ccusage-ui 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.
@@ -0,0 +1,23 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash",
5
+ "WebSearch",
6
+ "WebFetch",
7
+ "Read",
8
+ "Write",
9
+ "Edit",
10
+ "Skill(impersonate_cto)",
11
+ "Skill(impersonate_cto:*)",
12
+ "Bash(gh release create:*)",
13
+ "Bash(chmod:*)",
14
+ "Bash(npx:*)",
15
+ "Bash(npm cache clean:*)",
16
+ "Bash(npm view:*)"
17
+ ],
18
+ "deny": [],
19
+ "ask": [
20
+ "Bash(rm -rf:*)"
21
+ ]
22
+ }
23
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "ccusage-ui",
3
+ "version": "0.1.0",
4
+ "main": "server.js",
5
+ "bin": {
6
+ "ccusage-ui": "server.js"
7
+ },
8
+ "scripts": {
9
+ "start": "node server.js",
10
+ "test": "echo \"Error: no test specified\" && exit 1"
11
+ },
12
+ "keywords": [],
13
+ "author": "",
14
+ "license": "MIT",
15
+ "description": "Web UI for Claude Code usage statistics",
16
+ "dependencies": {
17
+ "ccusage": "^18.0.0"
18
+ }
19
+ }
@@ -0,0 +1,715 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Claude Code Usage UI</title>
7
+ <!-- Google tag (gtag.js) -->
8
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-18DJ0TJVR1"></script>
9
+ <script>
10
+ window.dataLayer = window.dataLayer || [];
11
+ function gtag(){dataLayer.push(arguments);}
12
+ gtag('js', new Date());
13
+ gtag('config', 'G-18DJ0TJVR1');
14
+ </script>
15
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
16
+ <script src="https://unpkg.com/@popperjs/core@2"></script>
17
+ <script src="https://unpkg.com/tippy.js@6"></script>
18
+ <style>
19
+ :root {
20
+ --bg-color: #f5f5f7;
21
+ --card-bg: #ffffff;
22
+ --text-primary: #1d1d1f;
23
+ --text-secondary: #86868b;
24
+ --accent-color: #0071e3;
25
+ --border-radius: 12px;
26
+ --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
27
+ }
28
+
29
+ body {
30
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
31
+ background-color: var(--bg-color);
32
+ color: var(--text-primary);
33
+ margin: 0;
34
+ padding: 40px 20px;
35
+ line-height: 1.5;
36
+ }
37
+
38
+ .container {
39
+ max-width: 1200px;
40
+ margin: 0 auto;
41
+ }
42
+
43
+ header {
44
+ margin-bottom: 40px;
45
+ display: flex;
46
+ justify-content: space-between;
47
+ align-items: center;
48
+ }
49
+
50
+ h1 {
51
+ font-size: 32px;
52
+ font-weight: 700;
53
+ margin: 0;
54
+ letter-spacing: -0.02em;
55
+ }
56
+
57
+ .refresh-btn {
58
+ background: var(--card-bg);
59
+ border: 1px solid #d2d2d7;
60
+ padding: 8px 16px;
61
+ border-radius: 8px;
62
+ cursor: pointer;
63
+ font-size: 14px;
64
+ font-weight: 500;
65
+ color: var(--text-primary);
66
+ transition: all 0.2s;
67
+ }
68
+ .refresh-btn:hover { background: #f2f2f7; }
69
+
70
+ .stats-grid {
71
+ display: grid;
72
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
73
+ gap: 20px;
74
+ margin-bottom: 30px;
75
+ }
76
+
77
+ .card {
78
+ background: var(--card-bg);
79
+ border-radius: var(--border-radius);
80
+ padding: 24px;
81
+ box-shadow: var(--shadow);
82
+ }
83
+
84
+ .stat-card h3 {
85
+ margin: 0 0 10px 0;
86
+ font-size: 14px;
87
+ font-weight: 500;
88
+ color: var(--text-secondary);
89
+ text-transform: uppercase;
90
+ letter-spacing: 0.05em;
91
+ }
92
+
93
+ .stat-value {
94
+ font-size: 36px;
95
+ font-weight: 700;
96
+ letter-spacing: -0.03em;
97
+ color: var(--text-primary);
98
+ }
99
+
100
+ .stat-sub {
101
+ font-size: 14px;
102
+ color: var(--text-secondary);
103
+ margin-top: 5px;
104
+ }
105
+
106
+ .chart-container {
107
+ position: relative;
108
+ height: 350px;
109
+ width: 100%;
110
+ }
111
+
112
+ .tabs {
113
+ display: flex;
114
+ gap: 10px;
115
+ margin-bottom: 20px;
116
+ background: #e5e5ea;
117
+ padding: 4px;
118
+ border-radius: 10px;
119
+ width: fit-content;
120
+ }
121
+
122
+ .tab {
123
+ padding: 8px 24px;
124
+ border-radius: 8px;
125
+ cursor: pointer;
126
+ font-weight: 500;
127
+ font-size: 14px;
128
+ color: #636366;
129
+ border: none;
130
+ background: transparent;
131
+ }
132
+
133
+ .tab.active {
134
+ background: white;
135
+ color: #000;
136
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
137
+ }
138
+
139
+ .tab-sm {
140
+ padding: 4px 12px;
141
+ border-radius: 6px;
142
+ cursor: pointer;
143
+ font-size: 12px;
144
+ border: 1px solid #d2d2d7;
145
+ background: transparent;
146
+ color: #636366;
147
+ }
148
+ .tab-sm.active {
149
+ background: #0071e3;
150
+ color: white;
151
+ border-color: #0071e3;
152
+ }
153
+
154
+ .tier-info {
155
+ display: inline-block;
156
+ width: 16px;
157
+ height: 16px;
158
+ background: #86868b;
159
+ color: white;
160
+ border-radius: 50%;
161
+ font-size: 11px;
162
+ text-align: center;
163
+ line-height: 16px;
164
+ cursor: help;
165
+ }
166
+
167
+ footer a:hover {
168
+ color: var(--accent-color) !important;
169
+ }
170
+
171
+ #loading {
172
+ position: fixed;
173
+ top: 0; left: 0; right: 0; bottom: 0;
174
+ background: rgba(245, 245, 247, 0.9);
175
+ backdrop-filter: blur(5px);
176
+ z-index: 1000;
177
+ display: flex;
178
+ flex-direction: column;
179
+ justify-content: center;
180
+ align-items: center;
181
+ transition: opacity 0.3s ease;
182
+ }
183
+
184
+ .spinner {
185
+ width: 40px;
186
+ height: 40px;
187
+ border: 3px solid rgba(0, 113, 227, 0.2);
188
+ border-radius: 50%;
189
+ border-top-color: #0071e3;
190
+ animation: spin 1s ease-in-out infinite;
191
+ margin-bottom: 20px;
192
+ }
193
+
194
+ @keyframes spin {
195
+ to { transform: rotate(360deg); }
196
+ }
197
+
198
+ .loading-text {
199
+ font-size: 16px;
200
+ font-weight: 500;
201
+ color: var(--text-primary);
202
+ margin-bottom: 8px;
203
+ }
204
+
205
+ .loading-sub {
206
+ font-size: 13px;
207
+ color: var(--text-secondary);
208
+ }
209
+
210
+ .grid-2 {
211
+ display: grid;
212
+ grid-template-columns: 2fr 1fr;
213
+ gap: 20px;
214
+ margin-bottom: 30px;
215
+ }
216
+ @media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
217
+
218
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
219
+ th, td { text-align: left; padding: 12px; border-bottom: 1px solid #f0f0f0; }
220
+ th { color: var(--text-secondary); font-weight: 600; }
221
+ tr:last-child td { border-bottom: none; }
222
+ .cost-positive { color: #ff3b30; font-weight: 600; }
223
+ </style>
224
+ </head>
225
+ <body>
226
+ <div id="loading">
227
+ <div class="spinner"></div>
228
+ <div class="loading-text">Analyzing Usage Data...</div>
229
+ <div class="loading-sub">Running ccusage CLI</div>
230
+ </div>
231
+
232
+ <div class="container" id="dashboard" style="display: none; opacity: 0; transition: opacity 0.5s;">
233
+ <header>
234
+ <div style="display: flex; align-items: baseline; gap: 10px;">
235
+ <h1>Claude Code Usage</h1>
236
+ <span id="appVersion" style="font-size: 12px; color: var(--text-secondary);"></span>
237
+ </div>
238
+ <button class="refresh-btn" onclick="loadData(true)">🔄 Force Refresh</button>
239
+ </header>
240
+
241
+ <div class="stats-grid">
242
+ <div class="card stat-card">
243
+ <h3>Total Cost</h3>
244
+ <div class="stat-value" id="totalCost">$0.00</div>
245
+ <div class="stat-sub">All time estimated</div>
246
+ </div>
247
+ <div class="card stat-card">
248
+ <h3>This Month</h3>
249
+ <div class="stat-value" id="monthCost">$0.00</div>
250
+ <div class="stat-sub" id="monthLabel">Current Month</div>
251
+ </div>
252
+ <div class="card stat-card">
253
+ <h3>Total Tokens</h3>
254
+ <div class="stat-value" id="totalTokens">0M</div>
255
+ <div class="stat-sub">Input + Output + Cache</div>
256
+ </div>
257
+ </div>
258
+
259
+ <div class="grid-2">
260
+ <div class="card">
261
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 20px;">
262
+ <div>
263
+ <div style="display: flex; align-items: center; gap: 8px;">
264
+ <h2 style="margin:0; font-size:18px;">Usage Trend</h2>
265
+ <span class="tier-info" data-tippy-content="🌱 Starter: ~10M<br>⚡ Pro: ~100M (Active User)<br>🔴 Power: ~500M (Tool User)<br>🟣 Mega: ~1B (Agent Operator)<br>🟣 Legendary: 1B+ (Hive Mind)">?</span>
266
+ </div>
267
+ <div id="userLevel" style="font-size:12px; color:#0071e3; margin-top:4px; font-weight:500;"></div>
268
+ </div>
269
+ <div class="tabs">
270
+ <button class="tab active" onclick="switchMetric('tokens')">Tokens</button>
271
+ <button class="tab" onclick="switchMetric('cost')">Cost ($)</button>
272
+ </div>
273
+ </div>
274
+ <div style="margin-bottom: 15px; display:flex; gap:10px; justify-content:flex-end;">
275
+ <button class="tab-sm active" id="view-daily" onclick="switchView('daily')">Daily</button>
276
+ <button class="tab-sm" id="view-monthly" onclick="switchView('monthly')">Monthly</button>
277
+ </div>
278
+ <div class="chart-container">
279
+ <canvas id="trendChart"></canvas>
280
+ </div>
281
+ </div>
282
+ <div class="card">
283
+ <h2 style="margin:0 0 20px 0; font-size:18px;">Model Breakdown (Last 30 Days)</h2>
284
+ <div class="chart-container" style="height: 300px;">
285
+ <canvas id="modelChart"></canvas>
286
+ </div>
287
+ </div>
288
+ </div>
289
+
290
+ <div class="card">
291
+ <h2 style="margin:0 0 20px 0; font-size:18px;">Recent Daily Usage</h2>
292
+ <div style="overflow-x: auto;">
293
+ <table id="dataTable">
294
+ <thead>
295
+ <tr>
296
+ <th>Date</th>
297
+ <th>Input Tokens</th>
298
+ <th>Output Tokens</th>
299
+ <th>Cache Tokens</th>
300
+ <th>Cost</th>
301
+ </tr>
302
+ </thead>
303
+ <tbody></tbody>
304
+ </table>
305
+ </div>
306
+ </div>
307
+
308
+ <footer style="text-align: center; margin-top: 40px; padding: 30px 20px; color: var(--text-secondary); font-size: 13px; border-top: 1px solid #e5e5e7;">
309
+ <div style="display: flex; justify-content: center; align-items: center; gap: 20px; margin-bottom: 16px;">
310
+ <a href="https://github.com/sowonlabs/ccusage-ui" target="_blank" title="GitHub" style="color: var(--text-secondary); transition: color 0.2s;">
311
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
312
+ </a>
313
+ <a href="https://x.com/dohapark81" target="_blank" title="X (Twitter)" style="color: var(--text-secondary); transition: color 0.2s;">
314
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
315
+ </a>
316
+ <a href="https://www.threads.com/@dohapark81" target="_blank" title="Threads" style="color: var(--text-secondary); transition: color 0.2s;">
317
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.96-.065-1.17.408-2.274 1.33-3.109.858-.775 2.071-1.263 3.51-1.413 1.044-.11 2.016-.073 2.91.088-.07-.653-.274-1.176-.617-1.58-.457-.539-1.148-.823-2.002-.823h-.082c-.68.013-1.241.209-1.669.583l-1.394-1.54c.78-.705 1.793-1.082 3.02-1.126h.11c1.515 0 2.725.503 3.6 1.494.78.885 1.207 2.084 1.27 3.56.455.155.874.337 1.255.548 1.253.695 2.17 1.678 2.647 2.843.716 1.748.682 4.494-1.433 6.564-1.905 1.865-4.287 2.727-7.492 2.747zm-1.25-9.01c-1.29.137-2.24.49-2.75.962-.41.382-.594.817-.548 1.298.063 1.04 1.09 1.696 2.61 1.613 1.076-.058 1.907-.453 2.47-1.173.496-.634.783-1.503.855-2.592-.846-.153-1.735-.194-2.637-.108z"/></svg>
318
+ </a>
319
+ <a href="https://www.linkedin.com/in/dohapark81/" target="_blank" title="LinkedIn" style="color: var(--text-secondary); transition: color 0.2s;">
320
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
321
+ </a>
322
+ </div>
323
+ <a href="https://www.sowonlabs.com" target="_blank" style="color: var(--text-secondary); text-decoration: none; display: inline-flex; align-items: center; gap: 6px;">
324
+ <img src="https://www.sowonai.com/img/LogoIcon2.png" alt="Sowon Labs" style="height: 20px; width: auto; opacity: 0.7;">
325
+ <span style="opacity: 0.8;">sowonlabs.com</span>
326
+ </a>
327
+ </footer>
328
+ </div>
329
+
330
+ <script>
331
+ let dailyData = [];
332
+ let monthlyData = [];
333
+ let trendChart = null;
334
+ let modelChart = null;
335
+
336
+ async function loadData(force = false) {
337
+ const loading = document.getElementById('loading');
338
+ const dashboard = document.getElementById('dashboard');
339
+ const statusText = document.querySelector('.loading-text');
340
+ const subText = document.querySelector('.loading-sub');
341
+
342
+ loading.style.display = 'flex';
343
+ loading.style.opacity = '1';
344
+ dashboard.style.opacity = '0'; // Fade out dashboard while reloading
345
+
346
+ try {
347
+ statusText.textContent = force ? "Refreshing Data..." : "Loading Cached Data...";
348
+ subText.textContent = force ? "Ignoring cache, fetching latest logs..." : "Checking for recent updates...";
349
+
350
+ const query = force ? '?force=true' : '';
351
+
352
+ const [dailyRes, monthlyRes] = await Promise.all([
353
+ fetch('/api/daily' + query),
354
+ fetch('/api/monthly' + query)
355
+ ]);
356
+
357
+ statusText.textContent = "Processing...";
358
+
359
+ const dData = await dailyRes.json();
360
+ const mData = await monthlyRes.json();
361
+
362
+ // Check cache status from headers for feedback (optional)
363
+ const isCacheHit = dailyRes.headers.get('X-Cache') === 'HIT';
364
+ if(isCacheHit) subText.textContent = "Loaded from 5min cache (Fast!)";
365
+ else subText.textContent = "Fresh data loaded!";
366
+
367
+ dailyData = dData.daily || [];
368
+ monthlyData = mData.monthly || [];
369
+ const totals = mData.totals || {};
370
+
371
+ renderDashboard(totals);
372
+
373
+ // Hide loading
374
+ setTimeout(() => {
375
+ loading.style.opacity = '0';
376
+ setTimeout(() => {
377
+ loading.style.display = 'none';
378
+ dashboard.style.display = 'block';
379
+ setTimeout(() => dashboard.style.opacity = '1', 50);
380
+ }, 300);
381
+ }, 500); // Slight delay to show "Fresh data" message
382
+
383
+ } catch (err) {
384
+ statusText.textContent = "Error Loading Data";
385
+ subText.textContent = "Please check server logs.";
386
+ console.error(err);
387
+ }
388
+ }
389
+
390
+ function renderDashboard(totals) {
391
+ // Update Top Stats
392
+ document.getElementById('totalCost').textContent = '$' + (totals.totalCost || 0).toFixed(2);
393
+ document.getElementById('totalTokens').textContent = ((totals.totalTokens || 0) / 1000000).toFixed(1) + 'M';
394
+
395
+ const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
396
+ const thisMonthData = monthlyData.find(m => m.month === currentMonth);
397
+ document.getElementById('monthCost').textContent = '$' + (thisMonthData ? thisMonthData.totalCost.toFixed(2) : '0.00');
398
+ document.getElementById('monthLabel').textContent = new Date().toLocaleString('default', { month: 'long', year: 'numeric' });
399
+
400
+ // Render Charts
401
+ renderTrendChart();
402
+ renderModelChart();
403
+ renderTable();
404
+ }
405
+
406
+ let currentMetric = 'tokens'; // Changed default to 'tokens'
407
+ let currentView = 'daily';
408
+
409
+ function switchMetric(metric) {
410
+ currentMetric = metric;
411
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
412
+
413
+ // Update button states manually
414
+ const btns = document.querySelectorAll('.tabs .tab');
415
+ if(metric === 'tokens') { btns[0].classList.add('active'); btns[1].classList.remove('active'); }
416
+ else { btns[1].classList.add('active'); btns[0].classList.remove('active'); }
417
+
418
+ renderTrendChart();
419
+ }
420
+
421
+ function switchView(view) {
422
+ currentView = view;
423
+ document.getElementById('view-daily').classList.toggle('active', view === 'daily');
424
+ document.getElementById('view-monthly').classList.toggle('active', view === 'monthly');
425
+ renderTrendChart();
426
+ }
427
+
428
+ function renderTrendChart() {
429
+ const ctx = document.getElementById('trendChart').getContext('2d');
430
+ if (trendChart) trendChart.destroy();
431
+
432
+ const dataSrc = currentView === 'daily' ? dailyData.slice(-30) : monthlyData;
433
+ const labels = dataSrc.map(d => currentView === 'daily' ? d.date : d.month);
434
+
435
+ let datasetData;
436
+ let label;
437
+ let color;
438
+ let annotations = {};
439
+
440
+ if (currentMetric === 'cost') {
441
+ datasetData = dataSrc.map(d => d.totalCost);
442
+ label = 'Cost ($)';
443
+ color = '#0071e3';
444
+ document.getElementById('userLevel').textContent = '';
445
+ } else {
446
+ // Tokens in Millions
447
+ datasetData = dataSrc.map(d => (d.totalTokens || (d.inputTokens + d.outputTokens + (d.cacheReadTokens||0) + (d.cacheCreationTokens||0))) / 1000000);
448
+ label = 'Total Tokens (Millions)';
449
+ color = '#34c759';
450
+
451
+ // Add Benchmarks
452
+ if (currentMetric === 'tokens') {
453
+ if (currentView === 'monthly') {
454
+ // Monthly Benchmarks & Projection
455
+ const lastDataPoint = dataSrc[dataSrc.length - 1];
456
+ const lastVal = datasetData[datasetData.length - 1]; // Millions
457
+
458
+ const now = new Date();
459
+ const currentMonthStr = now.toISOString().slice(0, 7);
460
+
461
+ let displayHTML = "";
462
+
463
+ const getLevelName = (val) => {
464
+ if (val >= 1000) return "🟣 Legendary (Hive Mind)";
465
+ if (val >= 500) return "🟣 Mega (Agent Operator)";
466
+ if (val >= 100) return "🔴 Power (Tool User)";
467
+ if (val >= 10) return "âš¡ Pro (Assisted)";
468
+ return "🌱 Starter";
469
+ };
470
+
471
+ // Only project if the last data point is the current month
472
+ if (lastDataPoint && lastDataPoint.month === currentMonthStr) {
473
+ const day = now.getDate();
474
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
475
+ const projection = (lastVal / day) * daysInMonth;
476
+
477
+ displayHTML = `
478
+ <div style="display:flex; flex-direction:column; gap:4px; border-left: 3px solid #0071e3; padding-left: 10px;">
479
+ <div style="font-size: 14px; color: #1d1d1f; display: flex; align-items: center;">
480
+ Current: <b style="color:#0071e3">${getLevelName(lastVal)}</b> <span style="color:#86868b; margin-left: 5px;">(${lastVal.toFixed(0)}M)</span>
481
+ </div>
482
+ <div style="font-size: 14px; color: #1d1d1f;">
483
+ 🚀 Pace: <b style="color:#5856d6">${getLevelName(projection)}</b> <span style="color:#86868b">(${projection.toFixed(0)}M projected)</span>
484
+ </div>
485
+ </div>
486
+ `;
487
+ } else {
488
+ displayHTML = `<div>Level: <b>${getLevelName(lastVal)}</b></div>`;
489
+ }
490
+
491
+ document.getElementById('userLevel').innerHTML = displayHTML;
492
+ } else {
493
+ // Daily Benchmarks
494
+ document.getElementById('userLevel').textContent = 'Daily Pace Benchmarks (Monthly Equivalent)';
495
+ }
496
+ } else {
497
+ document.getElementById('userLevel').textContent = '';
498
+ }
499
+ }
500
+
501
+ const plugins = [
502
+ {
503
+ id: 'custom_lines',
504
+ beforeDraw: (chart) => {
505
+ if (currentMetric === 'tokens') {
506
+ const ctx = chart.ctx;
507
+ const yAxis = chart.scales.y;
508
+ const xAxis = chart.scales.x;
509
+
510
+ const drawLine = (value, color, text) => {
511
+ const y = yAxis.getPixelForValue(value);
512
+ if (y <= yAxis.bottom && y >= yAxis.top) {
513
+ ctx.save();
514
+ ctx.beginPath();
515
+ ctx.strokeStyle = color;
516
+ ctx.lineWidth = 2;
517
+ ctx.setLineDash([5, 5]);
518
+ ctx.moveTo(xAxis.left, y);
519
+ ctx.lineTo(xAxis.right, y);
520
+ ctx.stroke();
521
+
522
+ ctx.fillStyle = color;
523
+ ctx.font = "bold 11px -apple-system";
524
+ ctx.fillText(text, xAxis.left + 5, y - 6);
525
+ ctx.restore();
526
+ }
527
+ };
528
+
529
+ if (currentView === 'monthly') {
530
+ drawLine(100, '#ff3b30', 'Power (100M)');
531
+ drawLine(500, '#af52de', 'Mega (500M)');
532
+ drawLine(1000, '#5856d6', 'Legendary (1B)');
533
+ } else {
534
+ // Daily Benchmarks
535
+
536
+ // 1. Tier Thresholds (Lighter, background context)
537
+ // Legendary (1B/mo -> ~33.3M/day)
538
+ drawLine(33.3, 'rgba(88, 86, 214, 0.4)', 'Legendary (33M)');
539
+ // Mega (500M/mo -> ~16.6M/day)
540
+ drawLine(16.6, 'rgba(175, 82, 222, 0.4)', 'Mega (16M)');
541
+ // Power (100M/mo -> ~3.3M/day)
542
+ drawLine(3.3, 'rgba(255, 59, 48, 0.4)', 'Power (3.3M)');
543
+
544
+ // 2. User Average (Stronger, personal metric)
545
+ const sum = datasetData.reduce((a, b) => a + b, 0);
546
+ const avg = sum / datasetData.length;
547
+ drawLine(avg, '#0071e3', `My Avg (${avg.toFixed(1)}M)`);
548
+ }
549
+ }
550
+ }
551
+ }
552
+ ];
553
+
554
+ let datasets = [];
555
+ const now = new Date();
556
+ const currentMonthStr = now.toISOString().slice(0, 7);
557
+
558
+ // Logic for Monthly Token Projection
559
+ if (currentMetric === 'tokens' && currentView === 'monthly') {
560
+ const lastIdx = dataSrc.length - 1;
561
+ const lastData = dataSrc[lastIdx];
562
+
563
+ // Only if the last bar is the current month
564
+ if (lastData && lastData.month === currentMonthStr) {
565
+ const day = now.getDate();
566
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
567
+
568
+ if (day > 0 && day < daysInMonth) {
569
+ const currentVal = datasetData[lastIdx];
570
+ const projectedTotal = (currentVal / day) * daysInMonth;
571
+ const remaining = projectedTotal - currentVal;
572
+
573
+ // Create Projection Dataset (Zeros for past months, value for current)
574
+ const projectionData = new Array(datasetData.length).fill(0);
575
+ projectionData[lastIdx] = remaining;
576
+
577
+ datasets = [
578
+ {
579
+ label: 'Actual',
580
+ data: datasetData,
581
+ backgroundColor: color,
582
+ borderRadius: 4,
583
+ stack: 'stack1'
584
+ },
585
+ {
586
+ label: 'Projected',
587
+ data: projectionData,
588
+ backgroundColor: 'rgba(52, 199, 89, 0.1)', // Very light green
589
+ borderColor: '#34c759',
590
+ borderWidth: 2,
591
+ borderDash: [5, 5], // Dotted line style
592
+ borderRadius: 4,
593
+ stack: 'stack1'
594
+ }
595
+ ];
596
+ }
597
+ }
598
+ }
599
+
600
+ // Fallback for standard views
601
+ if (datasets.length === 0) {
602
+ datasets = [{
603
+ label: label,
604
+ data: datasetData,
605
+ backgroundColor: color,
606
+ borderRadius: 4,
607
+ hoverBackgroundColor: color
608
+ }];
609
+ }
610
+
611
+ trendChart = new Chart(ctx, {
612
+ type: 'bar',
613
+ data: {
614
+ labels: labels,
615
+ datasets: datasets
616
+ },
617
+ options: {
618
+ responsive: true,
619
+ maintainAspectRatio: false,
620
+ plugins: {
621
+ legend: { display: datasets.length > 1 }, // Show legend if projected
622
+ tooltip: {
623
+ callbacks: {
624
+ label: function(context) {
625
+ return context.dataset.label + ': ' + context.raw.toFixed(1) + (currentMetric === 'cost' ? ' $' : ' M');
626
+ }
627
+ }
628
+ }
629
+ },
630
+ scales: {
631
+ y: {
632
+ beginAtZero: true,
633
+ stacked: true, // Enable stacking
634
+ grid: { borderDash: [2, 2] },
635
+ suggestedMax: 10
636
+ },
637
+ x: {
638
+ grid: { display: false },
639
+ stacked: true
640
+ }
641
+ }
642
+ },
643
+ plugins: plugins
644
+ });
645
+ }
646
+
647
+ function renderModelChart() {
648
+ const ctx = document.getElementById('modelChart').getContext('2d');
649
+ if (modelChart) modelChart.destroy();
650
+
651
+ // Aggregate costs by model from the last 30 days of daily data
652
+ const modelCosts = {};
653
+ dailyData.slice(-30).forEach(day => {
654
+ if (day.modelBreakdowns) {
655
+ day.modelBreakdowns.forEach(m => {
656
+ const name = m.modelName.split('-').slice(0, 2).join(' ');
657
+ modelCosts[name] = (modelCosts[name] || 0) + m.cost;
658
+ });
659
+ }
660
+ });
661
+
662
+ modelChart = new Chart(ctx, {
663
+ type: 'doughnut',
664
+ data: {
665
+ labels: Object.keys(modelCosts),
666
+ datasets: [{
667
+ data: Object.values(modelCosts),
668
+ backgroundColor: ['#5856d6', '#0071e3', '#34c759', '#ff9500', '#ff2d55'],
669
+ borderWidth: 0
670
+ }]
671
+ },
672
+ options: {
673
+ responsive: true,
674
+ maintainAspectRatio: false,
675
+ plugins: { legend: { position: 'right' } }
676
+ }
677
+ });
678
+ }
679
+
680
+ function renderTable() {
681
+ const tbody = document.querySelector('#dataTable tbody');
682
+ tbody.innerHTML = '';
683
+ // Show last 10 days reversed
684
+ const recent = [...dailyData].reverse().slice(0, 10);
685
+
686
+ recent.forEach(d => {
687
+ const tr = document.createElement('tr');
688
+ tr.innerHTML = `
689
+ <td style="font-weight:500">${d.date}</td>
690
+ <td>${(d.inputTokens/1000).toFixed(1)}k</td>
691
+ <td>${(d.outputTokens/1000).toFixed(1)}k</td>
692
+ <td>${((d.cacheReadTokens + d.cacheCreationTokens)/1000000).toFixed(2)}M</td>
693
+ <td class="cost-positive">$${d.totalCost.toFixed(2)}</td>
694
+ `;
695
+ tbody.appendChild(tr);
696
+ });
697
+ }
698
+
699
+ // Initialize tooltips
700
+ tippy('.tier-info', {
701
+ allowHTML: true,
702
+ placement: 'bottom',
703
+ theme: 'light'
704
+ });
705
+
706
+ // Load version
707
+ fetch('/api/version')
708
+ .then(r => r.json())
709
+ .then(data => document.getElementById('appVersion').textContent = 'v' + data.version);
710
+
711
+ // Start loading
712
+ loadData();
713
+ </script>
714
+ </body>
715
+ </html>
package/server.js ADDED
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ const http = require('http');
3
+ const { exec } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { loadDailyUsageData, loadMonthlyUsageData } = require('ccusage/data-loader');
7
+ const { calculateTotals, createTotalsObject, getTotalTokens } = require('ccusage/calculate-cost');
8
+
9
+ const PORT = 8150;
10
+ const pkg = require('./package.json');
11
+ const OPEN_CMD = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
12
+
13
+ // Format data to match CLI JSON output
14
+ const formatDailyData = (data) => data.map(d => ({
15
+ date: d.date,
16
+ inputTokens: d.inputTokens,
17
+ outputTokens: d.outputTokens,
18
+ cacheCreationTokens: d.cacheCreationTokens,
19
+ cacheReadTokens: d.cacheReadTokens,
20
+ totalTokens: getTotalTokens(d),
21
+ totalCost: d.totalCost,
22
+ modelsUsed: d.modelsUsed,
23
+ modelBreakdowns: d.modelBreakdowns,
24
+ }));
25
+
26
+ const formatMonthlyData = (data) => data.map(d => ({
27
+ month: d.month,
28
+ inputTokens: d.inputTokens,
29
+ outputTokens: d.outputTokens,
30
+ cacheCreationTokens: d.cacheCreationTokens,
31
+ cacheReadTokens: d.cacheReadTokens,
32
+ totalTokens: getTotalTokens(d),
33
+ totalCost: d.totalCost,
34
+ modelsUsed: d.modelsUsed,
35
+ modelBreakdowns: d.modelBreakdowns,
36
+ }));
37
+
38
+ const server = http.createServer(async (req, res) => {
39
+ res.setHeader('Access-Control-Allow-Origin', '*');
40
+ const url = new URL(req.url, `http://${req.headers.host}`);
41
+ const force = url.searchParams.get('force') === 'true';
42
+
43
+ if (url.pathname === '/') {
44
+ fs.readFile(path.join(__dirname, 'public', 'index.html'), (err, content) => {
45
+ if (err) {
46
+ res.writeHead(500);
47
+ res.end('Error loading index.html');
48
+ } else {
49
+ res.writeHead(200, { 'Content-Type': 'text/html' });
50
+ res.end(content);
51
+ }
52
+ });
53
+ return;
54
+ }
55
+
56
+ // Cache Implementation
57
+ const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
58
+ const handleCachedRequest = async (key, loader, formatter, dataKey) => {
59
+ if (!global.cache) global.cache = {};
60
+ const cached = global.cache[key];
61
+ const now = Date.now();
62
+
63
+ if (!force && cached && (now - cached.timestamp < CACHE_DURATION)) {
64
+ console.log(`Serving ${key} from cache (${Math.round((CACHE_DURATION - (now - cached.timestamp))/1000)}s left)`);
65
+ res.writeHead(200, { 'Content-Type': 'application/json', 'X-Cache': 'HIT' });
66
+ res.end(cached.data);
67
+ return;
68
+ }
69
+
70
+ try {
71
+ console.log(`Fetching ${key} data (Live)...`);
72
+ const rawData = await loader();
73
+ const totals = createTotalsObject(calculateTotals(rawData));
74
+ const result = JSON.stringify({ [dataKey]: formatter(rawData), totals });
75
+ global.cache[key] = { timestamp: now, data: result };
76
+ res.writeHead(200, { 'Content-Type': 'application/json', 'X-Cache': 'MISS' });
77
+ res.end(result);
78
+ } catch (error) {
79
+ console.error(`Error fetching ${key} data:`, error);
80
+ res.writeHead(500);
81
+ res.end(JSON.stringify({ error: `Failed to fetch ${key} data` }));
82
+ }
83
+ };
84
+
85
+ if (url.pathname === '/api/monthly') {
86
+ await handleCachedRequest('monthly', loadMonthlyUsageData, formatMonthlyData, 'monthly');
87
+ return;
88
+ }
89
+
90
+ if (url.pathname === '/api/daily') {
91
+ await handleCachedRequest('daily', loadDailyUsageData, formatDailyData, 'daily');
92
+ return;
93
+ }
94
+
95
+ if (url.pathname === '/api/version') {
96
+ res.writeHead(200, { 'Content-Type': 'application/json' });
97
+ res.end(JSON.stringify({ version: pkg.version }));
98
+ return;
99
+ }
100
+
101
+ res.writeHead(404);
102
+ res.end('Not Found');
103
+ });
104
+
105
+ server.listen(PORT, () => {
106
+ console.log(`\n🚀 ccusage-ui server running at http://localhost:${PORT}`);
107
+ console.log('Fetching data and opening browser...\n');
108
+ exec(`${OPEN_CMD} http://localhost:${PORT}`);
109
+ });