claude-code-watch 0.1.4 → 0.2.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,480 @@
1
+ // ══════════════════════════════════════════════════════════════════════════════
2
+ // token.js — Token Statistics page
3
+ // ══════════════════════════════════════════════════════════════════════════════
4
+
5
+ // ── Token State ──
6
+ let tokenStatsData = { totals: { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, days: 0 }, modelTotals: {}, daily: {}, hourly: [] };
7
+ let tokenStatsRendered = false;
8
+ let tsDetailTab = 'daily';
9
+
10
+ // ══════════════════════════════════════════════════════════════════════════════
11
+ // WebSocket handler
12
+ // ══════════════════════════════════════════════════════════════════════════════
13
+
14
+ function handleTokenStats(payload) {
15
+ tokenStatsData = payload;
16
+ // Auto-render if currently on tokens tab (handles both late data arrival
17
+ // after initial tab switch and live updates while viewing the tab)
18
+ if (currentTab === 'tokens' && payload.totals.messages > 0) {
19
+ tokenStatsRendered = true;
20
+ renderTokenPage();
21
+ }
22
+ }
23
+
24
+ // ══════════════════════════════════════════════════════════════════════════════
25
+ // Tab switching & refresh
26
+ // ══════════════════════════════════════════════════════════════════════════════
27
+
28
+ function tsSwitchDetail(n) {
29
+ tsDetailTab = n;
30
+ document.querySelectorAll('.tp-tab').forEach(t => t.classList.remove('active'));
31
+ document.querySelectorAll('.tp-tc').forEach(t => t.classList.remove('active'));
32
+ document.querySelector(`.tp-tab[data-tab="${n}"]`)?.classList.add('active');
33
+ document.getElementById('tp-tc-' + n)?.classList.add('active');
34
+ }
35
+
36
+ function refreshTokenStats() {
37
+ const btn = document.getElementById('btn-refresh-tokens');
38
+ const info = document.getElementById('tp-refresh-info');
39
+ if (btn) btn.disabled = true;
40
+ if (info) info.textContent = '正在刷新...';
41
+ fetch('/api/token-stats')
42
+ .then(r => r.json())
43
+ .then(data => {
44
+ tokenStatsData = data;
45
+ tokenStatsRendered = true;
46
+ renderTokenPage();
47
+ if (info) {
48
+ const now = new Date();
49
+ info.textContent = '上次刷新: ' + now.toLocaleTimeString();
50
+ }
51
+ })
52
+ .catch(err => {
53
+ if (info) info.textContent = '刷新失败: ' + err.message;
54
+ })
55
+ .finally(() => {
56
+ if (btn) btn.disabled = false;
57
+ });
58
+ }
59
+
60
+ // ══════════════════════════════════════════════════════════════════════════════
61
+ // Chart builders
62
+ // ══════════════════════════════════════════════════════════════════════════════
63
+
64
+ // ── Heatmap: 52-week × 7-day GitHub-style grid ──
65
+ function buildHeatmap(daily) {
66
+ const today = new Date();
67
+ const dailyTotalsMap = {};
68
+ for (const [k, d] of Object.entries(daily)) {
69
+ dailyTotalsMap[k] = d.input + d.output + d.cacheCreation + d.cacheRead;
70
+ }
71
+
72
+ const startSunday = new Date(today);
73
+ startSunday.setDate(startSunday.getDate() - startSunday.getDay() - 52 * 7);
74
+ const startStr = fmtDateISO(startSunday);
75
+
76
+ let maxVal = 0;
77
+ for (const [k, v] of Object.entries(dailyTotalsMap)) {
78
+ if (k >= startStr && v > maxVal) maxVal = v;
79
+ }
80
+
81
+ const weeks = [];
82
+ const monthLabels = [];
83
+ let lastMonth = -1;
84
+ let currentSunday = new Date(startSunday);
85
+
86
+ for (let w = 0; w < 53; w++) {
87
+ const weekData = [];
88
+ for (let dow = 0; dow < 7; dow++) {
89
+ const d = new Date(currentSunday);
90
+ d.setDate(d.getDate() + dow);
91
+ const ds = fmtDateISO(d);
92
+ const val = dailyTotalsMap[ds] || 0;
93
+ weekData.push({ date: ds, val, future: d > today });
94
+ if (dow === 0) {
95
+ const m = d.getMonth();
96
+ if (m !== lastMonth) { monthLabels.push({ month: m, week: w }); lastMonth = m; }
97
+ }
98
+ }
99
+ weeks.push(weekData);
100
+ currentSunday.setDate(currentSunday.getDate() + 7);
101
+ }
102
+
103
+ function cellColor(val, future) {
104
+ if (future) return 'var(--bg3)';
105
+ if (val === 0) return '#0d423d';
106
+ const pct = maxVal > 0 ? val / maxVal : 0;
107
+ if (pct < 0.25) return '#0e6b5a';
108
+ if (pct < 0.5) return '#12b886';
109
+ if (pct < 0.75) return '#34d399';
110
+ return '#6ee7b7';
111
+ }
112
+
113
+ const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
114
+ const monthsParts = ['<div class="tp-hm-months">'];
115
+ let prevWeek = 0;
116
+ for (const ml of monthLabels) {
117
+ const offset = ml.week - prevWeek;
118
+ if (offset > 0) monthsParts.push(`<span style="width:${offset * 14}px"></span>`);
119
+ monthsParts.push(`<span style="width:14px">${monthNames[ml.month]}</span>`);
120
+ prevWeek = ml.week + 1;
121
+ }
122
+ monthsParts.push('</div>');
123
+ const monthsHTML = monthsParts.join('');
124
+
125
+ const dayLabels = ['','Mon','','Wed','','Fri',''];
126
+ const gridParts = [];
127
+ for (let dow = 0; dow < 7; dow++) {
128
+ gridParts.push(`<div class="tp-hm-row"><span class="tp-hm-day-label">${dayLabels[dow]}</span>`);
129
+ for (let w = 0; w < weeks.length; w++) {
130
+ const cell = weeks[w][dow];
131
+ const bg = cellColor(cell.val, cell.future);
132
+ const tip = `${cell.date} · ${fmtTS(cell.val)} tokens`;
133
+ gridParts.push(`<span class="tp-hm-cell" style="background:${bg}" title="${tip}"></span>`);
134
+ }
135
+ gridParts.push('</div>');
136
+ }
137
+ const gridHTML = gridParts.join('');
138
+
139
+ const legendHTML = '<div class="tp-hm-legend"><span>少 Less</span>'
140
+ + '<span class="tp-hm-legend-cell" style="background:#0d423d"></span>'
141
+ + '<span class="tp-hm-legend-cell" style="background:#0e6b5a"></span>'
142
+ + '<span class="tp-hm-legend-cell" style="background:#12b886"></span>'
143
+ + '<span class="tp-hm-legend-cell" style="background:#34d399"></span>'
144
+ + '<span class="tp-hm-legend-cell" style="background:#6ee7b7"></span>'
145
+ + '<span>多 More</span></div>';
146
+
147
+ return `<div class="tp-heatmap"><div class="tp-heatmap-inner">${monthsHTML}${gridHTML}</div>${legendHTML}</div>`;
148
+ }
149
+
150
+ // ── Trend: bar chart for last 30 days ──
151
+ function buildTrend(daily) {
152
+ const keys = Object.keys(daily).sort();
153
+ const recentKeys = keys.slice(-30);
154
+ if (recentKeys.length === 0) return '<div style="color:var(--dim);padding:8px">暂无趋势数据</div>';
155
+
156
+ const values = recentKeys.map(k => {
157
+ const d = daily[k];
158
+ return d.input + d.output + d.cacheCreation + d.cacheRead;
159
+ });
160
+ const maxVal = Math.max(...values);
161
+
162
+ let barsHTML = '';
163
+ for (let i = 0; i < recentKeys.length; i++) {
164
+ const k = recentKeys[i];
165
+ const v = values[i];
166
+ const pct = maxVal > 0 ? (v / maxVal * 100) : 0;
167
+ const label = k.slice(5);
168
+ const tip = `${k}: ${fmtTS(v)}`;
169
+ const color = pct < 30 ? '#0e6b5a' : pct < 60 ? '#12b886' : pct < 80 ? '#34d399' : '#6ee7b7';
170
+ barsHTML += `<div class="tp-trend-bar-wrap"><div class="tp-trend-bar" style="height:${Math.max(pct, 3)}%;background:${color}" data-tip="${esc(tip)}"></div><span class="tp-trend-label">${esc(label)}</span></div>`;
171
+ }
172
+
173
+ const gridLines = `<div class="tp-trend-grid-lines">
174
+ <span style="font-size:9px;color:var(--dim);align-self:flex-start">${fmtTS(maxVal)}</span>
175
+ <div class="tp-trend-grid-line"></div>
176
+ <div class="tp-trend-grid-line"></div>
177
+ <span style="font-size:9px;color:var(--dim);align-self:center">${fmtTS(maxVal * 0.5)}</span>
178
+ <div class="tp-trend-grid-line"></div>
179
+ <span style="font-size:9px;color:var(--dim);align-self:flex-end">0</span>
180
+ </div>`;
181
+
182
+ return `<div class="tp-trend-bars">${gridLines}${barsHTML}</div>`;
183
+ }
184
+
185
+ // ── Model ranking sidebar ──
186
+ function buildModelRank(mt, totalAll) {
187
+ const sorted = Object.entries(mt).sort((a, b) => {
188
+ const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
189
+ const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
190
+ return sB - sA;
191
+ });
192
+
193
+ let html = '<div class="tp-rank-title">🏆 模型排名 Model Ranking</div>';
194
+ for (let i = 0; i < Math.min(sorted.length, 5); i++) {
195
+ const [name, m] = sorted[i];
196
+ const mTotal = m.input + m.output + m.cacheCreation + m.cacheRead;
197
+ const pct = totalAll > 0 ? (mTotal / totalAll * 100).toFixed(1) : '0';
198
+ const c = modelColor(name);
199
+ html += `<div class="tp-rank-item">
200
+ <span class="tp-rank-num">${i + 1}</span>
201
+ <span class="tp-rank-dot" style="background:${c}"></span>
202
+ <span class="tp-rank-name">${esc(name)}</span>
203
+ <span class="tp-rank-pct">${pct}%</span>
204
+ </div>`;
205
+ }
206
+ return html;
207
+ }
208
+
209
+ // ── Stacked bar chart for weekly/monthly token consumption ──
210
+ const STACK_COLORS = { input: '#58a6ff', output: '#f0883e', cacheRead: '#3fb950', cacheCreation: '#a371f7' };
211
+ const STACK_LABELS = { input: '输入 Input', output: '输出 Output', cacheRead: '缓存读取 Cache Read', cacheCreation: '缓存创建 Cache Create' };
212
+
213
+ function buildStackedChart(dailyKeys, daily, type) {
214
+ const title = type === 'weekly' ? '📊 周 Token 消耗 Weekly Token Consumption' : '📊 月 Token 消耗 Monthly Token Consumption';
215
+ let periods;
216
+ if (type === 'weekly') {
217
+ periods = aggregateWeekly(dailyKeys, daily);
218
+ } else {
219
+ periods = aggregateMonthly(dailyKeys, daily);
220
+ }
221
+
222
+ const periodKeys = Object.keys(periods).sort();
223
+ if (periodKeys.length === 0) return `<div class="tp-chart-title">${title}</div><div style="color:var(--dim);padding:8px">暂无数据</div>`;
224
+
225
+ const totals = periodKeys.map(k => {
226
+ const p = periods[k];
227
+ return p.input + p.output + p.cacheCreation + p.cacheRead;
228
+ });
229
+ const maxTotal = Math.max(...totals);
230
+
231
+ let barsHTML = '';
232
+ for (let i = 0; i < periodKeys.length; i++) {
233
+ const k = periodKeys[i];
234
+ const p = periods[k];
235
+ const total = totals[i];
236
+
237
+ const segs = [
238
+ { key: 'input', val: p.input },
239
+ { key: 'output', val: p.output },
240
+ { key: 'cacheRead', val: p.cacheRead },
241
+ { key: 'cacheCreation', val: p.cacheCreation },
242
+ ];
243
+ let segsHTML = '';
244
+ for (const s of segs) {
245
+ const segPct = maxTotal > 0 ? (s.val / maxTotal * 100) : 0;
246
+ const segH = maxTotal > 0 ? Math.max(segPct, 0.5) : 0;
247
+ segsHTML += `<div class="tp-stack-seg" style="height:${segH}%;background:${STACK_COLORS[s.key]}"></div>`;
248
+ }
249
+
250
+ const label = type === 'weekly' ? k.slice(5) : k.slice(2);
251
+ const tip = `${k}: 总计 ${fmtTS(total)} | 输入 ${fmtTS(p.input)} 输出 ${fmtTS(p.output)} 缓存读 ${fmtTS(p.cacheRead)} 缓存写 ${fmtTS(p.cacheCreation)}`;
252
+ barsHTML += `<div class="tp-stack-wrap"><div class="tp-stack-bar-group" data-tip="${esc(tip)}">${segsHTML}</div><span class="tp-stack-label">${esc(label)}</span></div>`;
253
+ }
254
+
255
+ let gridHTML = '<div class="tp-stack-grid">';
256
+ gridHTML += `<span style="font-size:9px;color:var(--dim);align-self:flex-start">${fmtTS(maxTotal)}</span>`;
257
+ gridHTML += '<div class="tp-stack-grid-line"></div><div class="tp-stack-grid-line"></div>';
258
+ gridHTML += `<span style="font-size:9px;color:var(--dim);align-self:center">${fmtTS(Math.round(maxTotal * 0.5))}</span>`;
259
+ gridHTML += '<div class="tp-stack-grid-line"></div>';
260
+ gridHTML += `<span style="font-size:9px;color:var(--dim);align-self:flex-end">0</span>`;
261
+ gridHTML += '</div>';
262
+
263
+ let legendHTML = '<div style="display:flex;gap:12px;margin-bottom:6px;font-size:10px;color:var(--dim)">';
264
+ for (const [key, color] of Object.entries(STACK_COLORS)) {
265
+ legendHTML += `<span style="display:inline-flex;align-items:center;gap:4px"><span style="width:8px;height:8px;border-radius:2px;background:${color};display:inline-block"></span>${STACK_LABELS[key]}</span>`;
266
+ }
267
+ legendHTML += '</div>';
268
+
269
+ return `<div class="tp-chart-title">${title}</div>${legendHTML}<div class="tp-stack-bars">${gridHTML}${barsHTML}</div>`;
270
+ }
271
+
272
+ // ── Model proportion doughnut chart ──
273
+ function buildModelPie(mt, totalAll) {
274
+ const title = '🤖 模型 Token 占比 Model Token Proportion';
275
+ if (totalAll === 0) return `<div class="tp-chart-title">${title}</div><div style="color:var(--dim);padding:8px">暂无数据</div>`;
276
+
277
+ const sorted = Object.entries(mt).sort((a, b) => {
278
+ const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
279
+ const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
280
+ return sB - sA;
281
+ });
282
+
283
+ let gradParts = '';
284
+ let legendHTML = '';
285
+ let currentDeg = 0;
286
+
287
+ for (let i = 0; i < sorted.length; i++) {
288
+ const [name, m] = sorted[i];
289
+ const mTotal = m.input + m.output + m.cacheCreation + m.cacheRead;
290
+ const pct = totalAll > 0 ? (mTotal / totalAll * 100) : 0;
291
+ const nextDeg = currentDeg + pct * 3.6;
292
+ const c = modelColor(name);
293
+ gradParts += `${c} ${currentDeg.toFixed(2)}deg ${nextDeg.toFixed(2)}deg`;
294
+ if (i < sorted.length - 1) gradParts += ', ';
295
+ currentDeg = nextDeg;
296
+ }
297
+
298
+ if (currentDeg < 360) {
299
+ gradParts += `, var(--bg3) ${currentDeg.toFixed(2)}deg 360deg`;
300
+ }
301
+
302
+ for (let i = 0; i < sorted.length; i++) {
303
+ const [name, m] = sorted[i];
304
+ const mTotal = m.input + m.output + m.cacheCreation + m.cacheRead;
305
+ const pct = totalAll > 0 ? (mTotal / totalAll * 100).toFixed(1) : '0';
306
+ const c = modelColor(name);
307
+ legendHTML += `<div class="tp-pie-legend-item"><span class="tp-pie-dot" style="background:${c}"></span><span class="tp-pie-name">${esc(name)}</span><span class="tp-pie-pct">${pct}%</span></div>`;
308
+ }
309
+
310
+ const modelsCount = sorted.length;
311
+
312
+ return `<div class="tp-chart-title">${title}</div>
313
+ <div class="tp-pie-wrap">
314
+ <div class="tp-pie-ring" style="background:conic-gradient(${gradParts})">
315
+ <div class="tp-pie-hole">
316
+ <div class="tp-pie-hole-v">${modelsCount}</div>
317
+ <div class="tp-pie-hole-l">模型 models</div>
318
+ </div>
319
+ </div>
320
+ <div class="tp-pie-legend">${legendHTML}</div>
321
+ </div>`;
322
+ }
323
+
324
+ // ── Hourly distribution chart ──
325
+ function buildHourlyChart(hourly) {
326
+ const title = '⏰ 活跃时段分布 Active Time Distribution';
327
+ if (!hourly || hourly.length === 0 || hourly.every(v => v === 0)) return `<div class="tp-chart-title">${title}</div><div style="color:var(--dim);padding:8px">暂无数据</div>`;
328
+
329
+ const maxCalls = Math.max(...hourly);
330
+
331
+ let barsHTML = '';
332
+ for (let h = 0; h < 24; h++) {
333
+ const calls = hourly[h] || 0;
334
+ const pct = maxCalls > 0 ? (calls / maxCalls * 100) : 0;
335
+ const color = pct < 30 ? 'rgba(247,120,186,0.25)' : pct < 60 ? 'rgba(247,120,186,0.5)' : pct < 80 ? 'rgba(247,120,186,0.75)' : 'rgba(247,120,186,1)';
336
+ const borderColor = '#f778ba';
337
+ const tip = `${h}:00 · ${calls.toLocaleString()} 次调用 calls`;
338
+ barsHTML += `<div class="tp-hourly-bar-wrap"><div class="tp-hourly-bar" style="height:${Math.max(pct, 2)}%;background:${color};border:1px solid ${borderColor}" data-tip="${esc(tip)}"></div><span class="tp-hourly-label">${h}</span></div>`;
339
+ }
340
+
341
+ let gridHTML = '<div class="tp-hourly-grid">';
342
+ gridHTML += `<span style="font-size:9px;color:var(--dim);align-self:flex-start">${maxCalls.toLocaleString()}</span>`;
343
+ gridHTML += '<div style="border-top:1px dashed var(--border);opacity:0.3"></div>';
344
+ gridHTML += `<span style="font-size:9px;color:var(--dim);align-self:flex-end">0</span>`;
345
+ gridHTML += '</div>';
346
+
347
+ const totalCalls = hourly.reduce((a, b) => a + b, 0);
348
+ const peakHour = hourly.indexOf(maxCalls);
349
+
350
+ return `<div class="tp-chart-title">${title}</div>
351
+ <div style="display:flex;gap:12px;margin-bottom:6px;font-size:10px;color:var(--dim)">
352
+ <span>总调用 Total calls: <b style="color:var(--white)">${totalCalls.toLocaleString()}</b></span>
353
+ <span>峰值 Peak: <b style="color:var(--white)">${peakHour}:00</b> (${maxCalls.toLocaleString()} calls)</span>
354
+ </div>
355
+ <div class="tp-hourly-bars">${gridHTML}${barsHTML}</div>`;
356
+ }
357
+
358
+ // ══════════════════════════════════════════════════════════════════════════════
359
+ // Detail table
360
+ // ══════════════════════════════════════════════════════════════════════════════
361
+
362
+ function renderPeriodTable(keys, data, type) {
363
+ const sorted = keys.sort((a, b) => b.localeCompare(a));
364
+ const rows = new Array(sorted.length);
365
+ for (let i = 0; i < sorted.length; i++) {
366
+ const k = sorted[i];
367
+ const d = data[k];
368
+ const total = d.input + d.output + d.cacheCreation + d.cacheRead;
369
+ const label = type === 'daily' ? k : k + '<br><small style="color:var(--dim)">' + esc(d.dateRange || '') + '</small>';
370
+ const modelsHtml = Object.entries(d.models).sort((a, b) => {
371
+ const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
372
+ const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
373
+ return sB - sA;
374
+ }).slice(0, 4).map(([mn, m]) => {
375
+ const mT = m.input + m.output + m.cacheCreation + m.cacheRead;
376
+ const c = modelColor(mn);
377
+ return `<span class="tp-mtag" style="background:${c}20;border-color:${c};color:${c}">${esc(mn)}: ${fmtTS(mT)}</span>`;
378
+ }).join(' ');
379
+ rows[i] = `<tr><td>${label}</td><td><b>${fmtTS(total)}</b></td><td>${fmtTS(d.input)}</td><td>${fmtTS(d.output)}</td><td>${fmtTS(d.cacheRead)}</td><td>${fmtTS(d.cacheCreation)}</td><td>${d.messages.toLocaleString()}</td><td class="tp-mbreak">${modelsHtml}</td></tr>`;
380
+ }
381
+ return '<table class="tp-table"><thead><tr><th>日期 Date</th><th>总计 Total</th><th>输入 Input</th><th>输出 Output</th><th>缓存读取 Cache Read</th><th>缓存创建 Cache Create</th><th>消息数 Messages</th><th>模型 Models</th></tr></thead><tbody>' + rows.join('') + '</tbody></table>';
382
+ }
383
+
384
+ // ══════════════════════════════════════════════════════════════════════════════
385
+ // Render entire token page
386
+ // ══════════════════════════════════════════════════════════════════════════════
387
+
388
+ function renderTokenPage() {
389
+ _modelColorIdx = 0;
390
+ const t = tokenStatsData.totals;
391
+ const mt = tokenStatsData.modelTotals;
392
+ const daily = tokenStatsData.daily;
393
+ const totalAll = t.input + t.output + t.cacheCreation + t.cacheRead;
394
+
395
+ if (totalAll === 0) {
396
+ document.getElementById('tp-total-card').innerHTML = '<div style="color:var(--dim);padding:8px">暂无历史 Token 数据</div>';
397
+ document.getElementById('tp-stats-grid').innerHTML = '';
398
+ document.getElementById('tp-model-rank').innerHTML = '';
399
+ document.getElementById('tp-trend-card').innerHTML = '';
400
+ document.getElementById('tp-heatmap-card').innerHTML = '';
401
+ document.getElementById('tp-weekly-chart').innerHTML = '';
402
+ document.getElementById('tp-monthly-chart').innerHTML = '';
403
+ document.getElementById('tp-model-pie').innerHTML = '';
404
+ document.getElementById('tp-hourly-chart').innerHTML = '';
405
+ document.getElementById('tp-detail-tabs').innerHTML = '';
406
+ document.getElementById('tp-daily-table').innerHTML = '';
407
+ document.getElementById('tp-weekly-table').innerHTML = '';
408
+ document.getElementById('tp-monthly-table').innerHTML = '';
409
+ return;
410
+ }
411
+
412
+ const inputPct = totalAll > 0 ? (t.input / totalAll * 100).toFixed(1) : '0';
413
+ const outputPct = totalAll > 0 ? (t.output / totalAll * 100).toFixed(1) : '0';
414
+ const crPct = totalAll > 0 ? (t.cacheRead / totalAll * 100).toFixed(1) : '0';
415
+ const ccPct = totalAll > 0 ? (t.cacheCreation / totalAll * 100).toFixed(1) : '0';
416
+ const dailyAvg = t.days > 0 ? Math.round(totalAll / t.days).toLocaleString() : '—';
417
+
418
+ // 1. Total tokens card
419
+ document.getElementById('tp-total-card').innerHTML = `
420
+ <div class="tp-total-label">总用量 TOTAL TOKENS</div>
421
+ <div class="tp-total-value">${fmtTS(totalAll)}</div>
422
+ <div class="tp-footer-stats">
423
+ <span>开始 Started <span class="tp-fv">${Object.keys(daily).sort()[0] || '—'}</span></span>
424
+ <span>活跃 Active <span class="tp-fv">${t.days} 天 DAY</span></span>
425
+ <span>模型 Models <span class="tp-fv">${Object.keys(mt).length}</span></span>
426
+ </div>`;
427
+
428
+ // 2. Stats grid
429
+ const stats = [
430
+ { l: '输入 Input', v: fmtTS(t.input), s: inputPct + '%' },
431
+ { l: '输出 Output', v: fmtTS(t.output), s: outputPct + '%' },
432
+ { l: '缓存读取 Cache Read', v: fmtTS(t.cacheRead), s: crPct + '%' },
433
+ { l: '缓存创建 Cache Create', v: fmtTS(t.cacheCreation), s: ccPct + '%' },
434
+ { l: '消息 Messages', v: fmtTS(t.messages), s: t.messages.toLocaleString() },
435
+ { l: '日平均 Daily Avg', v: dailyAvg, s: 'tokens/天' },
436
+ ];
437
+ document.getElementById('tp-stats-grid').innerHTML = `<div class="tp-stat-grid">${stats.map(s => `<div class="tp-stat"><div class="tp-s-l">${s.l}</div><div class="tp-s-v">${s.v}</div><div style="font-size:9px;color:var(--dim)">${s.s}</div></div>`).join('')}</div>`;
438
+
439
+ // 3. Model ranking
440
+ document.getElementById('tp-model-rank').innerHTML = buildModelRank(mt, totalAll);
441
+
442
+ // 4. Usage Trend
443
+ const dailyKeys = Object.keys(daily);
444
+ document.getElementById('tp-trend-card').innerHTML = `<div class="tp-h3">📊 使用趋势 Usage Trend</div>${buildTrend(daily)}`;
445
+
446
+ // 5. Activity Heatmap
447
+ const tzOffset = -(new Date().getTimezoneOffset() / 60);
448
+ document.getElementById('tp-heatmap-card').innerHTML = `<div class="tp-h3">🗓 活跃热力图 Activity Heatmap</div><span style="font-size:10px;color:var(--dim);float:right">UTC+${tzOffset.toFixed(0)}</span>${buildHeatmap(daily)}`;
449
+
450
+ // 6. Charts: Weekly, Monthly, Model Pie, Hourly
451
+ document.getElementById('tp-weekly-chart').innerHTML = buildStackedChart(dailyKeys, daily, 'weekly');
452
+ document.getElementById('tp-monthly-chart').innerHTML = buildStackedChart(dailyKeys, daily, 'monthly');
453
+ document.getElementById('tp-model-pie').innerHTML = buildModelPie(mt, totalAll);
454
+ document.getElementById('tp-hourly-chart').innerHTML = buildHourlyChart(tokenStatsData.hourly || []);
455
+
456
+ // 7. Detail tabs
457
+ const weeklyCount = weeklyKeysFromDaily(dailyKeys).length;
458
+ const monthlyCount = monthlyKeysFromDaily(dailyKeys).length;
459
+ document.getElementById('tp-detail-tabs').innerHTML = `<div class="tp-tab ${tsDetailTab === 'daily' ? 'active' : ''}" data-tab="daily" onclick="tsSwitchDetail('daily')">每日明细 Daily Breakdown (${dailyKeys.length})</div><div class="tp-tab ${tsDetailTab === 'weekly' ? 'active' : ''}" data-tab="weekly" onclick="tsSwitchDetail('weekly')">每周 Weekly (${weeklyCount})</div><div class="tp-tab ${tsDetailTab === 'monthly' ? 'active' : ''}" data-tab="monthly" onclick="tsSwitchDetail('monthly')">每月 Monthly (${monthlyCount})</div>`;
460
+ document.querySelectorAll('.tp-tc').forEach(tc => tc.classList.remove('active'));
461
+ document.getElementById('tp-tc-' + tsDetailTab)?.classList.add('active');
462
+
463
+ document.getElementById('tp-daily-table').innerHTML = renderPeriodTable(dailyKeys, daily, 'daily');
464
+ const weekly = aggregateWeekly(dailyKeys, daily);
465
+ document.getElementById('tp-weekly-table').innerHTML = renderPeriodTable(Object.keys(weekly), weekly, 'weekly');
466
+ const monthly = aggregateMonthly(dailyKeys, daily);
467
+ document.getElementById('tp-monthly-table').innerHTML = renderPeriodTable(Object.keys(monthly), monthly, 'monthly');
468
+ }
469
+
470
+ function weeklyKeysFromDaily(keys) {
471
+ const weeks = new Set();
472
+ for (const k of keys) { const d = new Date(k); const wk = getWeekKey(d); weeks.add(wk); }
473
+ return [...weeks];
474
+ }
475
+
476
+ function monthlyKeysFromDaily(keys) {
477
+ const months = new Set();
478
+ for (const k of keys) { months.add(k.slice(0, 7)); }
479
+ return [...months];
480
+ }
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ var fs = require('fs');
4
+ var path = require('path');
5
+ var os = require('os');
6
+ var readline = require('readline');
7
+
8
+ // ── Walk directory recursively ──
9
+ async function walkDir(dir, callback) {
10
+ try {
11
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
12
+ for (const entry of entries) {
13
+ const fullPath = path.join(dir, entry.name);
14
+ if (entry.isDirectory()) {
15
+ await walkDir(fullPath, callback);
16
+ } else {
17
+ try {
18
+ const stats = await fs.promises.stat(fullPath);
19
+ callback(fullPath, stats);
20
+ } catch { /* skip */ }
21
+ }
22
+ }
23
+ } catch (err) {
24
+ if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
25
+ console.error('[scanner] walkDir error on ' + dir + ': ' + err.message);
26
+ }
27
+ }
28
+ }
29
+
30
+ function isJsonlFile(filePath, stats) {
31
+ if (!stats.isFile()) return false;
32
+ return path.extname(filePath) === '.jsonl';
33
+ }
34
+
35
+ // ── Get Claude projects directory ──
36
+ function getClaudeDir() {
37
+ return path.join(os.homedir(), '.claude', 'projects');
38
+ }
39
+
40
+ /**
41
+ * Full-scan all JSONL files under ~/.claude/projects,
42
+ * extract token usage data, aggregate by date.
43
+ * Returns a Map: "YYYY-MM-DD" → { messages, input, output, cacheCreation, cacheRead, models: { modelName: { ... } } }
44
+ */
45
+ async function fullScanTokenUsage(progressCallback) {
46
+ const claudeDir = getClaudeDir();
47
+ const dailyStats = new Map();
48
+ // Hourly aggregation: 24-hour distribution of API calls (local timezone)
49
+ const hourlyStats = new Array(24).fill(0);
50
+
51
+ // Collect all JSONL files (main + subagent)
52
+ const jsonlFiles = [];
53
+ await walkDir(claudeDir, (filePath, stats) => {
54
+ if (isJsonlFile(filePath, stats)) {
55
+ jsonlFiles.push(filePath);
56
+ }
57
+ });
58
+
59
+ if (progressCallback) progressCallback(0, jsonlFiles.length);
60
+
61
+ // Process files in concurrent batches for faster startup
62
+ const SCAN_BATCH_SIZE = 8;
63
+ for (let i = 0; i < jsonlFiles.length; i += SCAN_BATCH_SIZE) {
64
+ const batch = jsonlFiles.slice(i, i + SCAN_BATCH_SIZE);
65
+ await Promise.all(batch.map(f => scanOneFile(f, dailyStats, hourlyStats)));
66
+ const done = Math.min(i + SCAN_BATCH_SIZE, jsonlFiles.length);
67
+ if (progressCallback && (done % 50 < SCAN_BATCH_SIZE || done === jsonlFiles.length)) {
68
+ progressCallback(done, jsonlFiles.length);
69
+ }
70
+ }
71
+
72
+ return { dailyStats, hourlyStats };
73
+ }
74
+
75
+ async function scanOneFile(filePath, dailyStats, hourlyStats) {
76
+ let input, rl;
77
+ try {
78
+ input = fs.createReadStream(filePath, { encoding: 'utf-8' });
79
+ rl = readline.createInterface({ input, crlfDelay: Infinity });
80
+ } catch {
81
+ return;
82
+ }
83
+
84
+ for await (const line of rl) {
85
+ // Fast pre-filter: only parse lines containing "usage"
86
+ if (!line.includes('"usage"')) continue;
87
+ // Also need model for per-model breakdown
88
+ const hasModel = line.includes('"model"');
89
+
90
+ let raw;
91
+ try {
92
+ raw = JSON.parse(line);
93
+ } catch {
94
+ continue;
95
+ }
96
+
97
+ const msg = raw.message;
98
+ if (!msg) continue;
99
+
100
+ // Extract timestamp — required for date-based and hour-based aggregation
101
+ let ts;
102
+ if (raw.timestamp) {
103
+ ts = new Date(raw.timestamp);
104
+ }
105
+ if (!raw.timestamp || isNaN(ts.getTime())) {
106
+ // Skip lines without valid timestamps — can't determine which day/hour they belong to
107
+ continue;
108
+ }
109
+ const dateStr = ts.getFullYear() + '-' + String(ts.getMonth() + 1).padStart(2, '0') + '-' + String(ts.getDate()).padStart(2, '0');
110
+ const hour = ts.getHours();
111
+
112
+ // Extract usage
113
+ const usage = msg.usage;
114
+ if (!usage) continue;
115
+
116
+ const inputTokens = usage.input_tokens || 0;
117
+ const outputTokens = usage.output_tokens || 0;
118
+ const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
119
+ const cacheReadTokens = usage.cache_read_input_tokens || 0;
120
+
121
+ if (!inputTokens && !outputTokens && !cacheCreationTokens && !cacheReadTokens) continue;
122
+
123
+ // Get or create day entry
124
+ let day = dailyStats.get(dateStr);
125
+ if (!day) {
126
+ day = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {} };
127
+ dailyStats.set(dateStr, day);
128
+ }
129
+
130
+ day.messages++;
131
+ day.input += inputTokens;
132
+ day.output += outputTokens;
133
+ day.cacheCreation += cacheCreationTokens;
134
+ day.cacheRead += cacheReadTokens;
135
+
136
+ // Hourly distribution (API calls count)
137
+ hourlyStats[hour]++;
138
+
139
+ // Per-model breakdown
140
+ const model = (hasModel && msg.model && msg.model !== '<synthetic>') ? msg.model : '';
141
+ if (model) {
142
+ let m = day.models[model];
143
+ if (!m) {
144
+ m = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
145
+ day.models[model] = m;
146
+ }
147
+ m.input += inputTokens;
148
+ m.output += outputTokens;
149
+ m.cacheCreation += cacheCreationTokens;
150
+ m.cacheRead += cacheReadTokens;
151
+ }
152
+ }
153
+ }
154
+
155
+ module.exports = { fullScanTokenUsage, getClaudeDir };