claude-rpc 0.7.2 → 0.7.3

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,647 @@
1
+ (() => {
2
+ const $ = (id) => document.getElementById(id);
3
+ const LANGS = {
4
+ 'JavaScript': '#f7df1e', 'TypeScript': '#3178c6', 'Python': '#3776ab', 'Rust': '#dea584',
5
+ 'Go': '#00add8', 'Ruby': '#cc342d', 'Java': '#b07219', 'Kotlin': '#a97bff',
6
+ 'C': '#555', 'C++': '#f34b7d', 'C#': '#178600', 'PHP': '#4f5b93',
7
+ 'Swift': '#ffac45', 'HTML': '#e34c26', 'CSS': '#563d7c', 'SCSS': '#c6538c',
8
+ 'Markdown': '#888', 'JSON': '#888', 'Shell': '#89e051', 'YAML': '#cb171e',
9
+ 'Vue': '#41b883', 'Svelte': '#ff3e00', 'Notebook': '#da5b0b', 'SQL': '#dad8d8',
10
+ 'GraphQL': '#e10098', 'Dockerfile': '#384d54', 'Make': '#427819', 'CMake': '#da3434',
11
+ 'Lua': '#000080', 'Dart': '#00b4ab', 'Elm': '#60b5cc', 'Elixir': '#6e4a7e',
12
+ 'Erlang': '#a90533', 'Haskell': '#5d4f85', 'OCaml': '#3be133', 'Clojure': '#db5855',
13
+ 'ClojureScript': '#db5855', 'R': '#198ce7', 'Julia': '#a270ba', 'Zig': '#ec915c',
14
+ 'PowerShell': '#012456', 'Batch': '#c1f12e', 'TOML': '#9c4221', 'INI': '#888',
15
+ 'XML': '#0060ac', 'Protobuf': '#888', 'LaTeX': '#3D6117', 'Text': '#888',
16
+ 'reStructuredText': '#888', 'Lockfile': '#444', 'Gradle': '#02303a',
17
+ 'Crystal': '#000100', 'Nim': '#ffc200', 'V': '#4f87c4', 'Objective-C': '#438eff',
18
+ 'Objective-C++': '#6866fb', 'Sass': '#a53b70', 'Less': '#1d365d', 'Vue': '#41b883',
19
+ 'Scala': '#c22d40', 'Groovy': '#4298b8', 'Interface Builder': '#888', 'Env': '#888',
20
+ 'Config': '#888', 'Git': '#f1502f',
21
+ };
22
+
23
+ let range = '90d';
24
+ let liveData = null;
25
+ let aggData = null;
26
+ let allFrames = [];
27
+ let currentLiveIdx = 0;
28
+ let rotationTimer = null;
29
+ let chartSeries = []; // [{ d: Date, ms }] — for the activity-chart hover tooltip
30
+ let churnSeries = []; // [{ d: Date, add, rem }] — for the churn-sparkline tooltip
31
+
32
+ // ── Utilities ───────────────────────────────────────────
33
+ const fmtH = (ms) => {
34
+ if (!ms) return '0h';
35
+ const h = ms / 3_600_000;
36
+ if (h < 1) return Math.round(h * 60) + 'm';
37
+ if (h < 10) return h.toFixed(1) + 'h';
38
+ return Math.round(h) + 'h';
39
+ };
40
+ const fmtN = (n) => {
41
+ if (!n) return '0';
42
+ if (n < 1000) return String(n);
43
+ if (n < 1e6) return (n / 1e3).toFixed(1) + 'k';
44
+ if (n < 1e9) return (n / 1e6).toFixed(2) + 'M';
45
+ return (n / 1e9).toFixed(2) + 'B';
46
+ };
47
+ const fmtCost = (usd) => {
48
+ if (!usd) return '$0';
49
+ if (usd < 0.01) return '$' + usd.toFixed(4);
50
+ if (usd < 100) return '$' + usd.toFixed(2);
51
+ if (usd < 1000) return '$' + Math.round(usd);
52
+ if (usd < 10000) return '$' + (usd / 1000).toFixed(2) + 'k';
53
+ return '$' + (usd / 1000).toFixed(1) + 'k';
54
+ };
55
+ const dayKey = (ts) => {
56
+ const d = new Date(ts);
57
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
58
+ };
59
+ const splitTime = (s) => {
60
+ if (!s) return ['—', ''];
61
+ const m = String(s).match(/^([\d.]+)([a-z]*)$/i);
62
+ return m ? [m[1], m[2]] : [s, ''];
63
+ };
64
+ const setDelta = (node, ms, suffix) => {
65
+ if (ms === 0) { node.className = 'delta flat'; node.textContent = '—'; return; }
66
+ const sign = ms > 0 ? 'up' : 'down';
67
+ const arrow = ms > 0 ? '↑' : '↓';
68
+ node.className = 'delta ' + sign;
69
+ node.textContent = arrow + ' ' + fmtH(Math.abs(ms)) + (suffix ? ' ' + suffix : '');
70
+ };
71
+ const elapsedStr = (start) => {
72
+ if (!start) return '—';
73
+ const s = Math.floor((Date.now() - start) / 1000);
74
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
75
+ if (h) return h + 'h ' + m + 'm';
76
+ return m + 'm ' + (s % 60) + 's';
77
+ };
78
+
79
+ // ── Theme ───────────────────────────────────────────────
80
+ function applyTheme() {
81
+ const saved = localStorage.getItem('theme') || 'dark';
82
+ document.documentElement.classList.toggle('light', saved === 'light');
83
+ }
84
+ $('theme-btn').addEventListener('click', () => {
85
+ const cur = localStorage.getItem('theme') || 'dark';
86
+ localStorage.setItem('theme', cur === 'dark' ? 'light' : 'dark');
87
+ applyTheme();
88
+ });
89
+ applyTheme();
90
+
91
+ // ── Range pills ─────────────────────────────────────────
92
+ document.querySelectorAll('#range-pills button').forEach((b) => {
93
+ b.addEventListener('click', () => {
94
+ range = b.dataset.range;
95
+ for (const x of document.querySelectorAll('#range-pills button')) x.classList.toggle('active', x === b);
96
+ $('chart-title').textContent = range === 'all' ? 'All time' : 'Last ' + range;
97
+ fetchAggregate();
98
+ });
99
+ });
100
+
101
+ // ── Chart ───────────────────────────────────────────────
102
+ function renderChart(byDay, days) {
103
+ const svg = $('chart');
104
+ [...svg.querySelectorAll('.dyn')].forEach((n) => n.remove());
105
+ const ns = 'http://www.w3.org/2000/svg';
106
+ const VIEW_W = 800, VIEW_H = 130, PAD_T = 6, PAD_B = 16;
107
+ const today = new Date(); today.setHours(0, 0, 0, 0);
108
+ const series = [];
109
+ for (let i = days - 1; i >= 0; i--) {
110
+ const d = new Date(today); d.setDate(d.getDate() - i);
111
+ const ms = (byDay[dayKey(d.getTime())] || {}).activeMs || 0;
112
+ series.push({ d, ms });
113
+ }
114
+ chartSeries = series;
115
+ const max = Math.max(...series.map((p) => p.ms), 1);
116
+ const h = VIEW_H - PAD_T - PAD_B;
117
+ const xAt = (i) => series.length > 1 ? (i / (series.length - 1)) * VIEW_W : VIEW_W / 2;
118
+ const yAt = (ms) => PAD_T + h - (ms / max) * h;
119
+ for (let r = 1; r <= 3; r++) {
120
+ const y = PAD_T + (h / 3) * r;
121
+ const ln = document.createElementNS(ns, 'line');
122
+ ln.setAttribute('x1', 0); ln.setAttribute('x2', VIEW_W);
123
+ ln.setAttribute('y1', y); ln.setAttribute('y2', y);
124
+ ln.setAttribute('class', 'grid dyn');
125
+ svg.appendChild(ln);
126
+ }
127
+ let path = '';
128
+ series.forEach((p, i) => {
129
+ const x = xAt(i), y = yAt(p.ms);
130
+ path += (i === 0 ? 'M' : ' L') + x.toFixed(1) + ',' + y.toFixed(1);
131
+ });
132
+ const area = document.createElementNS(ns, 'path');
133
+ area.setAttribute('d', path + ' L' + xAt(series.length - 1).toFixed(1) + ',' + (PAD_T + h) + ' L0,' + (PAD_T + h) + ' Z');
134
+ area.setAttribute('class', 'area dyn');
135
+ svg.appendChild(area);
136
+ const line = document.createElementNS(ns, 'path');
137
+ line.setAttribute('d', path);
138
+ line.setAttribute('class', 'line dyn');
139
+ svg.appendChild(line);
140
+ const last = series[series.length - 1];
141
+ if (last.ms > 0) {
142
+ const dot = document.createElementNS(ns, 'circle');
143
+ dot.setAttribute('cx', xAt(series.length - 1));
144
+ dot.setAttribute('cy', yAt(last.ms));
145
+ dot.setAttribute('r', 3);
146
+ dot.setAttribute('class', 'dot dyn');
147
+ svg.appendChild(dot);
148
+ }
149
+ const totalMs = series.reduce((s, p) => s + p.ms, 0);
150
+ const peakDay = series.reduce((m, p) => p.ms > m.ms ? p : m, { ms: 0, d: null });
151
+ $('chart-total').textContent = fmtH(totalMs) + ' total';
152
+ $('chart-peak').textContent = peakDay.ms > 0 ? fmtH(peakDay.ms) + ' on ' + peakDay.d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—';
153
+ }
154
+
155
+ // ── Heatmap ─────────────────────────────────────────────
156
+ function renderHeatmap(byDay) {
157
+ const grid = $('heatmap-grid');
158
+ grid.innerHTML = '';
159
+ const today = new Date(); today.setHours(0, 0, 0, 0);
160
+ let start = new Date(today); start.setDate(start.getDate() - 90);
161
+ while (start.getDay() !== 0) start.setDate(start.getDate() - 1);
162
+ let max = 0;
163
+ for (let k in byDay) max = Math.max(max, byDay[k].activeMs || 0);
164
+ const cur = new Date(start);
165
+ while (cur <= today) {
166
+ const k = dayKey(cur.getTime());
167
+ const ms = (byDay[k] || {}).activeMs || 0;
168
+ const cell = document.createElement('div');
169
+ cell.className = 'cell';
170
+ if (ms > 0) {
171
+ const lvl = Math.min(1, ms / max);
172
+ cell.style.background = 'rgba(74, 222, 128, ' + (0.18 + lvl * 0.72).toFixed(2) + ')';
173
+ }
174
+ cell.title = k + ' · ' + fmtH(ms);
175
+ cell.addEventListener('click', () => openDay(k));
176
+ grid.appendChild(cell);
177
+ cur.setDate(cur.getDate() + 1);
178
+ }
179
+ }
180
+
181
+ // ── Churn sparkline ─────────────────────────────────────
182
+ function renderChurn(byDay, days) {
183
+ const svg = $('churn-svg');
184
+ svg.innerHTML = '';
185
+ const ns = 'http://www.w3.org/2000/svg';
186
+ const W = 800, H = 60;
187
+ const today = new Date(); today.setHours(0, 0, 0, 0);
188
+ const series = [];
189
+ for (let i = days - 1; i >= 0; i--) {
190
+ const d = new Date(today); d.setDate(d.getDate() - i);
191
+ const day = byDay[dayKey(d.getTime())] || {};
192
+ series.push({ d, add: day.linesAdded || 0, rem: day.linesRemoved || 0 });
193
+ }
194
+ churnSeries = series;
195
+ const maxAdd = Math.max(1, ...series.map((s) => s.add));
196
+ const maxRem = Math.max(1, ...series.map((s) => s.rem));
197
+ const maxBoth = Math.max(maxAdd, maxRem);
198
+ const half = H / 2;
199
+ const bw = W / series.length;
200
+ series.forEach((s, i) => {
201
+ const ah = (s.add / maxBoth) * (half - 2);
202
+ const rh = (s.rem / maxBoth) * (half - 2);
203
+ const a = document.createElementNS(ns, 'rect');
204
+ a.setAttribute('x', (i * bw + 0.5).toFixed(1));
205
+ a.setAttribute('y', (half - ah).toFixed(1));
206
+ a.setAttribute('width', (bw - 1).toFixed(1));
207
+ a.setAttribute('height', ah.toFixed(1));
208
+ a.setAttribute('class', 'add');
209
+ svg.appendChild(a);
210
+ const r = document.createElementNS(ns, 'rect');
211
+ r.setAttribute('x', (i * bw + 0.5).toFixed(1));
212
+ r.setAttribute('y', half.toFixed(1));
213
+ r.setAttribute('width', (bw - 1).toFixed(1));
214
+ r.setAttribute('height', rh.toFixed(1));
215
+ r.setAttribute('class', 'rem');
216
+ svg.appendChild(r);
217
+ });
218
+ }
219
+
220
+ // ── Tables ──────────────────────────────────────────────
221
+ function renderTable(target, rows, opts = {}) {
222
+ const tbl = $(target);
223
+ tbl.innerHTML = '';
224
+ if (!rows.length) {
225
+ const tr = document.createElement('tr');
226
+ tr.innerHTML = '<td class="name" style="color: var(--text-3);">—</td><td class="val">—</td>';
227
+ tbl.appendChild(tr);
228
+ return;
229
+ }
230
+ rows.forEach((r) => {
231
+ const tr = document.createElement('tr');
232
+ if (r.onClick) tr.classList.add('clickable');
233
+ const ico = r.color ? '<span class="ico" style="background:' + r.color + '"></span>' : '';
234
+ const nameHtml = opts.mono
235
+ ? '<code style="font-family: JetBrains Mono, monospace; font-size: 12px;">' + ico + r.name + '</code>'
236
+ : ico + r.name;
237
+ tr.innerHTML = '<td class="name">' + nameHtml + '</td>' +
238
+ '<td class="val">' + r.val + (r.unit ? '<span class="u">' + r.unit + '</span>' : '') + '</td>';
239
+ if (r.onClick) tr.addEventListener('click', r.onClick);
240
+ tbl.appendChild(tr);
241
+ });
242
+ }
243
+
244
+ // ── Achievements ────────────────────────────────────────
245
+ function renderAchievements(a) {
246
+ const list = [
247
+ { t: 'First session', ok: (a.sessions || 0) >= 1, s: '1', ico: '◉' },
248
+ { t: 'Week streak', ok: (a.longestStreak || 0) >= 7, s: '7 days', ico: '◆' },
249
+ { t: 'Month streak', ok: (a.longestStreak || 0) >= 30, s: '30 days', ico: '◇' },
250
+ { t: '1k prompts', ok: (a.userMessages || 0) >= 1000, s: '1k', ico: '◈' },
251
+ { t: '10k lines', ok: (a.linesAdded || 0) >= 10000, s: '10k', ico: '◍' },
252
+ { t: '100 sessions', ok: (a.sessions || 0) >= 100, s: '100', ico: '◎' },
253
+ ];
254
+ const root = $('achievements');
255
+ root.innerHTML = '';
256
+ for (const it of list) {
257
+ const el = document.createElement('div');
258
+ el.className = 'achievement' + (it.ok ? ' unlocked' : '');
259
+ el.innerHTML = '<span class="ico">' + it.ico + '</span><div class="t">' + it.t + '</div><div class="s">' + it.s + '</div>';
260
+ root.appendChild(el);
261
+ }
262
+ }
263
+
264
+ // ── Cost panel ──────────────────────────────────────────
265
+ function renderCost(a) {
266
+ $('cost-figure').textContent = fmtCost(a.estimatedCost || 0);
267
+ const hours = (a.activeMs || 0) / 3_600_000;
268
+ const perHour = hours > 0.05 ? a.estimatedCost / hours : 0;
269
+ $('cost-figure-sub').textContent = (perHour ? fmtCost(perHour) + ' / hour' : 'across the range');
270
+ const byModel = a.costByModel || {};
271
+ const entries = Object.entries(byModel).sort((x, y) => y[1] - x[1]).slice(0, 6);
272
+ const total = entries.reduce((s, [, v]) => s + v, 0) || 1;
273
+ const bars = $('cost-bars');
274
+ bars.innerHTML = '';
275
+ for (const [model, cost] of entries) {
276
+ const w = Math.max(2, (cost / total) * 100);
277
+ const row = document.createElement('div');
278
+ row.className = 'cost-bar';
279
+ row.innerHTML = '<span class="name">' + model + '</span>' +
280
+ '<span class="track"><span class="fill" style="width:' + w.toFixed(0) + '%"></span></span>' +
281
+ '<span class="val">' + fmtCost(cost) + '</span>';
282
+ bars.appendChild(row);
283
+ }
284
+ if (!entries.length) bars.innerHTML = '<div style="color: var(--text-3); font-size: 12px;">No data in range</div>';
285
+ }
286
+
287
+ // ── Languages panel ─────────────────────────────────────
288
+ function renderLanguages(langs) {
289
+ const entries = Object.entries(langs || {}).sort((x, y) => y[1].edits - x[1].edits).slice(0, 5);
290
+ const total = entries.reduce((s, [, v]) => s + v.edits, 0) || 1;
291
+ const stack = $('lang-stack');
292
+ stack.innerHTML = '';
293
+ for (const [name, v] of entries) {
294
+ const span = document.createElement('span');
295
+ span.style.background = LANGS[name] || '#888';
296
+ span.style.width = ((v.edits / total) * 100).toFixed(2) + '%';
297
+ span.title = name + ' · ' + v.edits;
298
+ stack.appendChild(span);
299
+ }
300
+ const list = $('lang-list');
301
+ list.innerHTML = '';
302
+ for (const [name, v] of entries) {
303
+ const row = document.createElement('div');
304
+ row.className = 'row';
305
+ row.innerHTML = '<span class="swatch" style="background:' + (LANGS[name] || '#888') + '"></span>' +
306
+ '<span class="name">' + name + '</span>' +
307
+ '<span class="val">' + fmtN(v.edits) + ' edits · ' + fmtN(v.files) + ' files</span>';
308
+ list.appendChild(row);
309
+ }
310
+ if (!entries.length) list.innerHTML = '<div style="color: var(--text-3); font-size: 12px;">No language data yet</div>';
311
+ }
312
+
313
+ // ── Discord rotation ────────────────────────────────────
314
+ function renderRotation() {
315
+ const live = allFrames.filter((f) => f.passes);
316
+ if (live.length) {
317
+ currentLiveIdx = currentLiveIdx % live.length;
318
+ const f = live[currentLiveIdx];
319
+ const liveOrder = allFrames.map((af, i) => af.passes ? i : -1).filter((i) => i >= 0);
320
+ const allIdx = liveOrder[currentLiveIdx];
321
+ // Mirror to both the top live rail and the bottom Discord card.
322
+ $('frame-details').textContent = f.details || '—';
323
+ $('frame-state').textContent = f.state || '—';
324
+ $('frame-details-2').textContent = f.details || '—';
325
+ $('frame-state-2').textContent = f.state || '—';
326
+ $('frame-num').textContent = 'Frame ' + (allIdx + 1) + '/' + allFrames.length;
327
+ $('frame-no').textContent = 'Frame ' + (allIdx + 1) + ' of ' + allFrames.length;
328
+ }
329
+ $('frames-live').textContent = live.length;
330
+ $('frames-total').textContent = allFrames.length;
331
+ const ul = $('rotation-list');
332
+ ul.innerHTML = '';
333
+ const liveOrder = allFrames.map((af, i) => af.passes ? i : -1).filter((i) => i >= 0);
334
+ const onAir = liveOrder[currentLiveIdx];
335
+ allFrames.forEach((f, i) => {
336
+ const li = document.createElement('li');
337
+ const isCurrent = i === onAir;
338
+ li.className = isCurrent ? 'current' : f.passes ? 'live' : 'skip';
339
+ const summary = f.passes ? ((f.details || '—') + (f.state ? ' · ' + f.state : '')) : (f.details || '—');
340
+ li.innerHTML = '<span class="pip"></span><span class="frame-text">' + summary + '</span>';
341
+ ul.appendChild(li);
342
+ });
343
+ }
344
+
345
+ // ── Drawer (project) ────────────────────────────────────
346
+ async function openProject(name) {
347
+ location.hash = '#projects/' + encodeURIComponent(name);
348
+ const p = (aggData?.projects || {})[name];
349
+ if (!p) return;
350
+ $('drawer-title').textContent = name;
351
+ $('drawer-sub').textContent = p.sessions + ' sessions · ' + fmtH(p.activeMs) + ' active';
352
+ $('drawer-body').innerHTML = [
353
+ ['Active time', fmtH(p.activeMs)],
354
+ ['Prompts', fmtN(p.userMessages)],
355
+ ['Tool calls', fmtN(p.toolCalls)],
356
+ ['Lines added', fmtN(p.linesAdded || 0)],
357
+ ['Lines removed', fmtN(p.linesRemoved || 0)],
358
+ ['Estimated cost', fmtCost(p.cost || 0)],
359
+ ['Tokens in', fmtN(p.inputTokens)],
360
+ ['Tokens out', fmtN(p.outputTokens)],
361
+ ].map(([k, v]) => '<div class="kv"><span class="k">' + k + '</span><span class="v">' + v + '</span></div>').join('');
362
+ $('scrim').classList.add('open');
363
+ $('drawer').classList.add('open');
364
+ }
365
+ function closeDrawer() {
366
+ $('scrim').classList.remove('open');
367
+ $('drawer').classList.remove('open');
368
+ if (location.hash.startsWith('#projects/')) location.hash = '';
369
+ }
370
+ $('scrim').addEventListener('click', closeDrawer);
371
+ $('drawer-close').addEventListener('click', closeDrawer);
372
+
373
+ // ── Modal (day) ─────────────────────────────────────────
374
+ async function openDay(k) {
375
+ location.hash = '#days/' + k;
376
+ const day = (aggData?.byDay || {})[k];
377
+ if (!day) {
378
+ $('modal-title').textContent = k;
379
+ $('modal-sub').textContent = 'No activity';
380
+ $('modal-body').innerHTML = '';
381
+ } else {
382
+ $('modal-title').textContent = new Date(k + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
383
+ $('modal-sub').textContent = fmtH(day.activeMs) + ' active · ' + (day.sessions || 0) + ' sessions';
384
+ $('modal-body').innerHTML = [
385
+ ['Prompts', fmtN(day.userMessages)],
386
+ ['Tool calls', fmtN(day.toolCalls)],
387
+ ['Lines added', fmtN(day.linesAdded || 0)],
388
+ ['Lines removed', fmtN(day.linesRemoved || 0)],
389
+ ['Cost', fmtCost(day.cost || 0)],
390
+ ['Tokens', fmtN((day.inputTokens || 0) + (day.outputTokens || 0) + (day.cacheReadTokens || 0) + (day.cacheWriteTokens || 0))],
391
+ ['Notifications', day.notifications || 0],
392
+ ].map(([k, v]) => '<div class="kv" style="display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--border);font-size:13px;"><span style="color:var(--text-3);">' + k + '</span><span style="font-weight:500;">' + v + '</span></div>').join('');
393
+ }
394
+ $('modal').classList.add('open');
395
+ $('scrim').classList.add('open');
396
+ }
397
+ function closeModal() {
398
+ $('modal').classList.remove('open');
399
+ $('scrim').classList.remove('open');
400
+ if (location.hash.startsWith('#days/')) location.hash = '';
401
+ }
402
+ $('modal-close').addEventListener('click', closeModal);
403
+ $('scrim').addEventListener('click', closeModal);
404
+
405
+ // ── Help ────────────────────────────────────────────────
406
+ $('help').addEventListener('click', () => $('help').classList.remove('open'));
407
+ document.addEventListener('keydown', (e) => {
408
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
409
+ if (e.key === '?') { e.preventDefault(); $('help').classList.toggle('open'); }
410
+ if (e.key === 'Escape') { closeDrawer(); closeModal(); $('help').classList.remove('open'); }
411
+ if (e.key === 't') {
412
+ const cur = localStorage.getItem('theme') || 'dark';
413
+ localStorage.setItem('theme', cur === 'dark' ? 'light' : 'dark'); applyTheme();
414
+ }
415
+ if (e.key >= '1' && e.key <= '5') {
416
+ const pills = ['7d', '30d', '90d', '1y', 'all'];
417
+ const target = document.querySelector('[data-range="' + pills[parseInt(e.key, 10) - 1] + '"]');
418
+ if (target) target.click();
419
+ }
420
+ });
421
+
422
+ // ── State refresh ───────────────────────────────────────
423
+ async function fetchAggregate() {
424
+ try {
425
+ const r = await fetch('/api/aggregate?range=' + range, { cache: 'no-store' });
426
+ aggData = await r.json();
427
+ drawAggregate();
428
+ } catch (e) { console.error(e); }
429
+ }
430
+
431
+ async function fetchInsights() {
432
+ try {
433
+ const r = await fetch('/api/insights');
434
+ const j = await r.json();
435
+ const root = $('insights');
436
+ root.innerHTML = '';
437
+ for (const line of (j.insights || [])) {
438
+ const el = document.createElement('div');
439
+ el.className = 'insight';
440
+ el.textContent = line;
441
+ root.appendChild(el);
442
+ }
443
+ if (!(j.insights || []).length) root.innerHTML = '<div class="insight">Keep working — insights appear once you have a few days of activity.</div>';
444
+ } catch (e) { console.error(e); }
445
+ }
446
+
447
+ function drawAggregate() {
448
+ if (!aggData) return;
449
+ const days = range === '7d' ? 7 : range === '30d' ? 30 : range === '1y' ? 365 : range === 'all' ? 365 : 90;
450
+ renderChart(aggData.byDay || {}, days);
451
+ renderHeatmap(aggData.byDay || {});
452
+ renderChurn(aggData.byDay || {}, Math.min(days, 90));
453
+ renderCost(aggData);
454
+ renderLanguages(aggData.languages);
455
+ renderAchievements(aggData);
456
+
457
+ // Range stat card
458
+ const [rn, ru] = splitTime(fmtH(aggData.activeMs || 0));
459
+ $('range-num').textContent = rn;
460
+ $('range-unit').textContent = ru === 'h' ? 'hrs' : ru;
461
+ $('range-sub').textContent = fmtN(aggData.userMessages || 0) + ' prompts · ' + fmtN(aggData.grandTokens || 0) + ' tok';
462
+
463
+ // Range delta vs prior identical window
464
+ // (approximation: today's value minus same-day-of-week last range)
465
+ setDelta($('range-delta'), 0, 'range');
466
+
467
+ // Cost card
468
+ $('cost-num').textContent = fmtCost(aggData.estimatedCost || 0);
469
+ $('cost-sub').textContent = fmtN(aggData.grandTokens || 0) + ' tokens';
470
+
471
+ // Lifetime tokens card
472
+ const grand = (aggData.inputTokens || 0) + (aggData.outputTokens || 0) + (aggData.cacheReadTokens || 0) + (aggData.cacheWriteTokens || 0);
473
+ $('tok-grand').textContent = fmtN(grand);
474
+ $('tok-out').textContent = fmtN(aggData.outputTokens || 0);
475
+ const cache = (aggData.cacheReadTokens || 0) + (aggData.cacheWriteTokens || 0);
476
+ $('tok-cache').textContent = fmtN(cache);
477
+ $('tok-in-sub').textContent = 'input ' + fmtN(aggData.inputTokens || 0);
478
+ $('tok-cache-sub').textContent = 'read ' + fmtN(aggData.cacheReadTokens || 0) + ' · write ' + fmtN(aggData.cacheWriteTokens || 0);
479
+ $('tok-cache-pct').textContent = grand ? Math.round((cache / grand) * 100) + '%' : '0%';
480
+
481
+ // Code churn numbers
482
+ $('churn-added').textContent = '+' + fmtN(aggData.linesAdded || 0);
483
+ $('churn-removed').textContent = '−' + fmtN(aggData.linesRemoved || 0);
484
+ const net = (aggData.linesAdded || 0) - (aggData.linesRemoved || 0);
485
+ $('churn-net').textContent = (net >= 0 ? '+' : '−') + fmtN(Math.abs(net));
486
+
487
+ // Leaderboards
488
+ const projs = Object.entries(aggData.projects || {}).sort((x, y) => y[1].activeMs - x[1].activeMs).slice(0, 8);
489
+ renderTable('projects-tbl', projs.map(([name, p]) => {
490
+ const h = p.activeMs / 3_600_000;
491
+ const val = h < 1 ? Math.round(h * 60) : h < 10 ? h.toFixed(1) : Math.round(h);
492
+ return { name, val: String(val), unit: h < 1 ? 'm' : 'h', onClick: () => openProject(name) };
493
+ }));
494
+ const tools = Object.entries(aggData.toolBreakdown || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
495
+ renderTable('tools-tbl', tools.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })), { mono: true });
496
+ const files = (aggData.topEditedFiles || []).slice(0, 8);
497
+ renderTable('files-tbl', files.map((f) => ({ name: f.file || (f.path || '').split('/').pop(), val: fmtN(f.count), unit: '' })), { mono: true });
498
+
499
+ // Bash / domains / subagents
500
+ const bash = Object.entries(aggData.bashCommands || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
501
+ renderTable('bash-tbl', bash.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })), { mono: true });
502
+ const domains = Object.entries(aggData.webDomains || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
503
+ renderTable('domains-tbl', domains.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })), { mono: true });
504
+ const sa = Object.entries(aggData.subagents || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
505
+ renderTable('subagents-tbl', sa.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })));
506
+
507
+ const tot = (aggData.mcpToolCalls || 0) + (aggData.builtinToolCalls || 0);
508
+ $('mcp-label').textContent = tot ? Math.round(((aggData.mcpToolCalls || 0) / tot) * 100) + '% MCP · ' + Math.round(((aggData.builtinToolCalls || 0) / tot) * 100) + '% built-in' : '—';
509
+
510
+ $('lb-sessions').textContent = fmtN(aggData.sessions || 0);
511
+ }
512
+
513
+ function drawState() {
514
+ if (!liveData) return;
515
+ const a = liveData.aggregate;
516
+ const v = liveData.vars;
517
+ const s = liveData.state;
518
+
519
+ // Top bar
520
+ const now = new Date();
521
+ $('meta').textContent = 'No. ' + (v.daysSinceFirst || '—') + ' · ' + now.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
522
+ $('model').textContent = v.modelPretty;
523
+ $('statustext').textContent = v.statusVerbose;
524
+ $('dot').className = 'dot ' + (s.status === 'working' || s.status === 'thinking' ? '' : s.status === 'idle' ? 'idle' : 'stale');
525
+
526
+ // Live avatar
527
+ const cfgAvatar = (s.status && liveData.config?.statusAssets?.[s.status]) || '';
528
+ $('live-avatar').innerHTML = cfgAvatar
529
+ ? '<img src="' + cfgAvatar.replace(/"/g, '&quot;') + '" alt="" />'
530
+ : '';
531
+ $('elapsed').textContent = elapsedStr(s.sessionStart);
532
+
533
+ // Hero
534
+ const [hn, hu] = splitTime(v.allHours);
535
+ $('hero-num').textContent = hn;
536
+ $('hero-unit').textContent = hu === 'h' ? 'hours' : hu === 'm' ? 'minutes' : hu;
537
+ $('hero-caption').innerHTML =
538
+ 'on Claude Code · day <strong>' + (v.daysSinceFirst || 1) + '</strong> · ' +
539
+ '<strong>' + (a.sessions || 0).toLocaleString() + '</strong> sessions · ' +
540
+ '<strong>' + (a.userMessages || 0).toLocaleString() + '</strong> prompts.';
541
+
542
+ // Today
543
+ const [tn, tu] = splitTime(v.todayHours);
544
+ $('today-num').textContent = tn;
545
+ $('today-unit').textContent = tu === 'h' ? 'hrs' : tu;
546
+ $('today-sub').textContent = (v.todayPrompts || 0) + ' prompts · ' + (v.todayTokensFmt || '0');
547
+
548
+ const todayMs = ((a.byDay || {})[dayKey(Date.now())] || {}).activeMs || 0;
549
+ const yest = new Date(); yest.setHours(0,0,0,0); yest.setDate(yest.getDate() - 1);
550
+ const yMs = ((a.byDay || {})[dayKey(yest.getTime())] || {}).activeMs || 0;
551
+ setDelta($('today-delta'), todayMs - yMs, 'vs yest.');
552
+
553
+ // Streak
554
+ $('streak-num').textContent = v.streak;
555
+ $('streak-sub').textContent = 'Longest ' + v.longestStreak + ' · best ' + (v.bestDayHours || '—');
556
+
557
+ // Discord
558
+ allFrames = liveData.frames || [];
559
+ renderRotation();
560
+ }
561
+
562
+ // ── SSE ────────────────────────────────────────────────
563
+ function startSse() {
564
+ try {
565
+ const ev = new EventSource('/events');
566
+ ev.onmessage = async (e) => {
567
+ try {
568
+ const d = JSON.parse(e.data);
569
+ if (d.type === 'state') await refreshState();
570
+ if (d.type === 'aggregate') {
571
+ await refreshState();
572
+ await fetchAggregate();
573
+ await fetchInsights();
574
+ }
575
+ } catch { /* malformed SSE frame — wait for the next one */ }
576
+ };
577
+ ev.onerror = () => { $('conn-state').textContent = 'reconnecting'; setTimeout(() => { $('conn-state').textContent = 'live'; }, 4000); };
578
+ } catch { /* EventSource constructor failed (very old browser) — dashboard falls back to one-shot fetches */ }
579
+ }
580
+
581
+ async function refreshState() {
582
+ try {
583
+ const r = await fetch('/api/state', { cache: 'no-store' });
584
+ liveData = await r.json();
585
+ drawState();
586
+ } catch (e) { console.error(e); }
587
+ }
588
+
589
+ // Elapsed tick — light, just updates the number.
590
+ setInterval(() => {
591
+ if (liveData?.state?.sessionStart) $('elapsed').textContent = elapsedStr(liveData.state.sessionStart);
592
+ }, 1000);
593
+
594
+ // Rotation cycle
595
+ rotationTimer = setInterval(() => { currentLiveIdx++; renderRotation(); }, 4000);
596
+
597
+ // ── Chart hover tooltips ────────────────────────────────────────────────
598
+ // Native SVG charts, no library. preserveAspectRatio="none" means the
599
+ // x-axis scales linearly with rendered width, so the cursor's fraction
600
+ // across the SVG maps straight to a data index. A single cursor-following
601
+ // tooltip is reused for both the activity chart and the churn sparkline.
602
+ function setupChartTooltips() {
603
+ const tip = document.createElement('div');
604
+ tip.className = 'chart-tip';
605
+ document.body.appendChild(tip);
606
+ const hide = () => tip.classList.remove('show');
607
+ const show = (e, text) => {
608
+ tip.textContent = text;
609
+ tip.classList.add('show');
610
+ tip.style.left = (e.clientX + 12) + 'px';
611
+ tip.style.top = (e.clientY - 34) + 'px';
612
+ };
613
+ const idxAt = (e, svg, n) => {
614
+ if (!n) return -1;
615
+ const r = svg.getBoundingClientRect();
616
+ if (r.width <= 0) return -1;
617
+ const frac = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
618
+ return Math.round(frac * (n - 1));
619
+ };
620
+ const wire = (svg, getSeries, fmt) => {
621
+ if (!svg) return;
622
+ const host = svg.parentElement || svg;
623
+ host.addEventListener('mousemove', (e) => {
624
+ const s = getSeries();
625
+ const i = idxAt(e, svg, s.length);
626
+ if (i < 0) { hide(); return; }
627
+ show(e, fmt(s[i]));
628
+ });
629
+ host.addEventListener('mouseleave', hide);
630
+ };
631
+ const md = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
632
+ wire($('chart'), () => chartSeries, (p) => md(p.d) + ' · ' + fmtH(p.ms));
633
+ wire($('churn-svg'), () => churnSeries, (s) => md(s.d) + ' · +' + fmtN(s.add) + ' / −' + fmtN(s.rem));
634
+ }
635
+ setupChartTooltips();
636
+
637
+ // Initial load.
638
+ (async () => {
639
+ await refreshState();
640
+ await fetchAggregate();
641
+ await fetchInsights();
642
+ startSse();
643
+ // Restore deep link.
644
+ if (location.hash.startsWith('#projects/')) openProject(decodeURIComponent(location.hash.slice(10)));
645
+ else if (location.hash.startsWith('#days/')) openDay(location.hash.slice(6));
646
+ })();
647
+ })();