claude-rpc 0.3.8
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/LICENSE +21 -0
- package/README.md +300 -0
- package/bin/claude-rpc.js +2 -0
- package/config.example.json +67 -0
- package/package.json +53 -0
- package/src/badge.js +144 -0
- package/src/cli.js +765 -0
- package/src/daemon.js +324 -0
- package/src/default-config.js +91 -0
- package/src/format.js +657 -0
- package/src/git.js +74 -0
- package/src/hook.js +169 -0
- package/src/insights.js +138 -0
- package/src/install.js +280 -0
- package/src/languages.js +114 -0
- package/src/paths.js +59 -0
- package/src/pricing.js +73 -0
- package/src/scanner.js +721 -0
- package/src/server.js +1584 -0
- package/src/state.js +73 -0
- package/src/tui.js +420 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,1584 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Local web dashboard for Claude RPC.
|
|
3
|
+
// Zero deps, single-file HTML, vanilla JS, SVG charts.
|
|
4
|
+
//
|
|
5
|
+
// Phase 3 overhaul:
|
|
6
|
+
// - Multiple API routes (windowed aggregate, project drilldown, day detail, insights, badge)
|
|
7
|
+
// - SSE /events for push updates (replaces 2s polling)
|
|
8
|
+
// - Range selector wired through every panel
|
|
9
|
+
// - New panels: live rail, cost, languages, code churn, bash, web domains, insights
|
|
10
|
+
// - Hash-routed drawer/modal for project/day drilldowns
|
|
11
|
+
// - Theme toggle, keyboard shortcuts
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { readFileSync, watch } from 'node:fs';
|
|
14
|
+
import { exec } from 'node:child_process';
|
|
15
|
+
import { basename, dirname } from 'node:path';
|
|
16
|
+
import { readState } from './state.js';
|
|
17
|
+
import { buildVars, fillTemplate, applyIdle, framePasses, humanProject } from './format.js';
|
|
18
|
+
import { readAggregate, findLiveSessions, dayKey } from './scanner.js';
|
|
19
|
+
import { CONFIG_PATH, STATE_PATH, AGGREGATE_PATH } from './paths.js';
|
|
20
|
+
import { generateInsights } from './insights.js';
|
|
21
|
+
import { badgeSvg } from './badge.js';
|
|
22
|
+
|
|
23
|
+
const PORT = Number(process.env.CLAUDE_RPC_PORT) || 47474;
|
|
24
|
+
|
|
25
|
+
function loadConfig() {
|
|
26
|
+
try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch { return {}; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Data helpers ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function rangeToDays(range) {
|
|
32
|
+
if (range === 'all') return Infinity;
|
|
33
|
+
if (range === '1y') return 365;
|
|
34
|
+
const n = parseInt(range, 10);
|
|
35
|
+
return Number.isFinite(n) && n > 0 ? n : 90;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Filter byDay to a windowed slice; also recompute roll-ups (top files etc.)
|
|
39
|
+
// scoped to that window. Returns a shape similar to the aggregate but trimmed.
|
|
40
|
+
function windowedAggregate(agg, range) {
|
|
41
|
+
if (!agg) return null;
|
|
42
|
+
const days = rangeToDays(range);
|
|
43
|
+
if (!Number.isFinite(days)) return agg; // 'all' → pass through
|
|
44
|
+
|
|
45
|
+
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
46
|
+
const keepKeys = new Set();
|
|
47
|
+
for (let i = 0; i < days; i++) {
|
|
48
|
+
const d = new Date(today); d.setDate(d.getDate() - i);
|
|
49
|
+
keepKeys.add(dayKey(d.getTime()));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const byDay = {};
|
|
53
|
+
let activeMs = 0, prompts = 0, toolCalls = 0, lines = 0, linesRem = 0, cost = 0, sessions = 0;
|
|
54
|
+
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
|
|
55
|
+
for (const [k, day] of Object.entries(agg.byDay || {})) {
|
|
56
|
+
if (!keepKeys.has(k)) continue;
|
|
57
|
+
byDay[k] = day;
|
|
58
|
+
activeMs += day.activeMs || 0;
|
|
59
|
+
prompts += day.userMessages || 0;
|
|
60
|
+
toolCalls += day.toolCalls || 0;
|
|
61
|
+
lines += day.linesAdded || 0;
|
|
62
|
+
linesRem += day.linesRemoved || 0;
|
|
63
|
+
cost += day.cost || 0;
|
|
64
|
+
sessions += day.sessions || 0;
|
|
65
|
+
inputTokens += day.inputTokens || 0;
|
|
66
|
+
outputTokens += day.outputTokens || 0;
|
|
67
|
+
cacheReadTokens += day.cacheReadTokens || 0;
|
|
68
|
+
cacheWriteTokens += day.cacheWriteTokens || 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
range,
|
|
73
|
+
byDay,
|
|
74
|
+
activeMs,
|
|
75
|
+
userMessages: prompts,
|
|
76
|
+
toolCalls,
|
|
77
|
+
linesAdded: lines,
|
|
78
|
+
linesRemoved: linesRem,
|
|
79
|
+
linesNet: lines - linesRem,
|
|
80
|
+
estimatedCost: cost,
|
|
81
|
+
sessions,
|
|
82
|
+
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
|
|
83
|
+
grandTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
|
|
84
|
+
// Pass-through global keys for context.
|
|
85
|
+
streak: agg.streak,
|
|
86
|
+
longestStreak: agg.longestStreak,
|
|
87
|
+
daysSinceFirst: agg.daysSinceFirst,
|
|
88
|
+
peakHour: agg.peakHour,
|
|
89
|
+
bestDay: agg.bestDay,
|
|
90
|
+
projects: agg.projects || {},
|
|
91
|
+
toolBreakdown: agg.toolBreakdown || {},
|
|
92
|
+
topEditedFiles: agg.topEditedFiles || [],
|
|
93
|
+
languages: agg.languages || {},
|
|
94
|
+
bashCommands: agg.bashCommands || {},
|
|
95
|
+
webDomains: agg.webDomains || {},
|
|
96
|
+
subagents: agg.subagents || {},
|
|
97
|
+
costByModel: agg.costByModel || {},
|
|
98
|
+
modelsUsed: agg.modelsUsed || {},
|
|
99
|
+
mcpToolCalls: agg.mcpToolCalls || 0,
|
|
100
|
+
builtinToolCalls: agg.builtinToolCalls || 0,
|
|
101
|
+
byHour: agg.byHour || {},
|
|
102
|
+
byWeekday: agg.byWeekday || {},
|
|
103
|
+
notifications: agg.notifications || 0,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function snapshot() {
|
|
108
|
+
const config = loadConfig();
|
|
109
|
+
const live = findLiveSessions({ thresholdMs: 90_000 });
|
|
110
|
+
let state = readState();
|
|
111
|
+
state.liveSessions = live;
|
|
112
|
+
state = applyIdle(state, config);
|
|
113
|
+
const aggregate = readAggregate() || {};
|
|
114
|
+
const vars = buildVars(state, config, aggregate);
|
|
115
|
+
const p = config.presence || {};
|
|
116
|
+
const frames = (p.rotation || []).map((f) => ({
|
|
117
|
+
details: fillTemplate(f.details || '', vars),
|
|
118
|
+
state: fillTemplate(f.state || '', vars),
|
|
119
|
+
passes: framePasses(f, vars),
|
|
120
|
+
requires: f.requires || null,
|
|
121
|
+
}));
|
|
122
|
+
return {
|
|
123
|
+
now: Date.now(),
|
|
124
|
+
state,
|
|
125
|
+
aggregate: {
|
|
126
|
+
sessions: aggregate.sessions,
|
|
127
|
+
subagentRuns: aggregate.subagentRuns,
|
|
128
|
+
userMessages: aggregate.userMessages,
|
|
129
|
+
toolCalls: aggregate.toolCalls,
|
|
130
|
+
uniqueFiles: aggregate.uniqueFiles,
|
|
131
|
+
activeMs: aggregate.activeMs,
|
|
132
|
+
wallMs: aggregate.wallMs,
|
|
133
|
+
inputTokens: aggregate.inputTokens,
|
|
134
|
+
outputTokens: aggregate.outputTokens,
|
|
135
|
+
cacheReadTokens: aggregate.cacheReadTokens,
|
|
136
|
+
cacheWriteTokens: aggregate.cacheWriteTokens,
|
|
137
|
+
byDay: aggregate.byDay || {},
|
|
138
|
+
byHour: aggregate.byHour || {},
|
|
139
|
+
byWeekday: aggregate.byWeekday || {},
|
|
140
|
+
projects: aggregate.projects || {},
|
|
141
|
+
toolBreakdown: aggregate.toolBreakdown || {},
|
|
142
|
+
topEditedFiles: (aggregate.topEditedFiles || []).slice(0, 12).map((e) => ({ file: basename(e.path), path: e.path, count: e.count })),
|
|
143
|
+
streak: aggregate.streak,
|
|
144
|
+
longestStreak: aggregate.longestStreak,
|
|
145
|
+
daysSinceFirst: aggregate.daysSinceFirst,
|
|
146
|
+
bestDay: aggregate.bestDay,
|
|
147
|
+
peakHour: aggregate.peakHour,
|
|
148
|
+
// Phase 1 enrichments
|
|
149
|
+
linesAdded: aggregate.linesAdded || 0,
|
|
150
|
+
linesRemoved: aggregate.linesRemoved || 0,
|
|
151
|
+
linesNet: aggregate.linesNet || 0,
|
|
152
|
+
languages: aggregate.languages || {},
|
|
153
|
+
bashCommands: aggregate.bashCommands || {},
|
|
154
|
+
webDomains: aggregate.webDomains || {},
|
|
155
|
+
subagents: aggregate.subagents || {},
|
|
156
|
+
mcpToolCalls: aggregate.mcpToolCalls || 0,
|
|
157
|
+
builtinToolCalls: aggregate.builtinToolCalls || 0,
|
|
158
|
+
estimatedCost: aggregate.estimatedCost || 0,
|
|
159
|
+
costByModel: aggregate.costByModel || {},
|
|
160
|
+
modelsUsed: aggregate.modelsUsed || {},
|
|
161
|
+
notifications: aggregate.notifications || 0,
|
|
162
|
+
},
|
|
163
|
+
vars,
|
|
164
|
+
frames,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function projectDrilldown(name) {
|
|
169
|
+
const agg = readAggregate() || {};
|
|
170
|
+
const projects = agg.projects || {};
|
|
171
|
+
const project = projects[name];
|
|
172
|
+
if (!project) return null;
|
|
173
|
+
// Sum a per-day series for the project's edits from agg.byDay isn't directly
|
|
174
|
+
// available without re-scanning. We approximate by treating the global byDay
|
|
175
|
+
// as the project's view scaled by share-of-activity — but it's more useful
|
|
176
|
+
// to just return per-project totals + global byDay so the client can show
|
|
177
|
+
// the global timeline plus the project's stats.
|
|
178
|
+
return {
|
|
179
|
+
name,
|
|
180
|
+
...project,
|
|
181
|
+
files: (agg.topEditedFiles || []).filter((f) => true).slice(0, 25), // global hotspots; future: per-project
|
|
182
|
+
tools: agg.toolBreakdown || {},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function dayDetail(dayKeyStr) {
|
|
187
|
+
const agg = readAggregate() || {};
|
|
188
|
+
const day = (agg.byDay || {})[dayKeyStr];
|
|
189
|
+
if (!day) return null;
|
|
190
|
+
return { day: dayKeyStr, ...day };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Routes ───────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
const ROUTES = new Map();
|
|
196
|
+
ROUTES.set('GET /api/state', (req, res) => {
|
|
197
|
+
res.writeHead(200, JSON_HEADERS);
|
|
198
|
+
res.end(JSON.stringify(snapshot()));
|
|
199
|
+
});
|
|
200
|
+
ROUTES.set('GET /api/aggregate', (req, res, { query }) => {
|
|
201
|
+
const range = query.range || '90d';
|
|
202
|
+
const agg = readAggregate();
|
|
203
|
+
res.writeHead(200, JSON_HEADERS);
|
|
204
|
+
res.end(JSON.stringify(windowedAggregate(agg, range)));
|
|
205
|
+
});
|
|
206
|
+
ROUTES.set('GET /api/insights', (req, res, { query }) => {
|
|
207
|
+
const agg = readAggregate();
|
|
208
|
+
const lines = generateInsights(agg, { limit: parseInt(query.limit, 10) || 5 });
|
|
209
|
+
res.writeHead(200, JSON_HEADERS);
|
|
210
|
+
res.end(JSON.stringify({ insights: lines }));
|
|
211
|
+
});
|
|
212
|
+
ROUTES.set('GET /api/badge.svg', (req, res, { query }) => {
|
|
213
|
+
const agg = readAggregate();
|
|
214
|
+
const svg = badgeSvg({
|
|
215
|
+
aggregate: agg,
|
|
216
|
+
metric: query.metric || 'hours',
|
|
217
|
+
range: query.range || '7d',
|
|
218
|
+
label: query.label,
|
|
219
|
+
});
|
|
220
|
+
res.writeHead(200, {
|
|
221
|
+
'content-type': 'image/svg+xml; charset=utf-8',
|
|
222
|
+
'cache-control': 'max-age=60, public',
|
|
223
|
+
});
|
|
224
|
+
res.end(svg);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const JSON_HEADERS = { 'content-type': 'application/json', 'cache-control': 'no-store' };
|
|
228
|
+
|
|
229
|
+
// SSE: emits {type:'state'|'aggregate'} when underlying files change.
|
|
230
|
+
const sseClients = new Set();
|
|
231
|
+
function broadcast(payload) {
|
|
232
|
+
const line = `data: ${JSON.stringify(payload)}\n\n`;
|
|
233
|
+
for (const res of sseClients) {
|
|
234
|
+
try { res.write(line); } catch { sseClients.delete(res); }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function watchSources() {
|
|
239
|
+
let stTimer = null, agTimer = null;
|
|
240
|
+
try {
|
|
241
|
+
watch(STATE_PATH, () => {
|
|
242
|
+
clearTimeout(stTimer);
|
|
243
|
+
stTimer = setTimeout(() => broadcast({ type: 'state' }), 200);
|
|
244
|
+
});
|
|
245
|
+
} catch {}
|
|
246
|
+
try {
|
|
247
|
+
watch(AGGREGATE_PATH, () => {
|
|
248
|
+
clearTimeout(agTimer);
|
|
249
|
+
agTimer = setTimeout(() => broadcast({ type: 'aggregate' }), 200);
|
|
250
|
+
});
|
|
251
|
+
} catch {}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── HTML ─────────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
const CSS = `
|
|
257
|
+
:root {
|
|
258
|
+
--bg: #0a0a0a;
|
|
259
|
+
--bg-2: #111;
|
|
260
|
+
--surface: rgba(255,255,255,0.025);
|
|
261
|
+
--surface-hover: rgba(255,255,255,0.05);
|
|
262
|
+
--border: rgba(255,255,255,0.08);
|
|
263
|
+
--border-strong: rgba(255,255,255,0.16);
|
|
264
|
+
--text: #ffffff;
|
|
265
|
+
--text-2: rgba(255,255,255,0.62);
|
|
266
|
+
--text-3: rgba(255,255,255,0.36);
|
|
267
|
+
--text-4: rgba(255,255,255,0.16);
|
|
268
|
+
--green: #4ade80;
|
|
269
|
+
--amber: #fbbf24;
|
|
270
|
+
--red: #f87171;
|
|
271
|
+
--blue: #60a5fa;
|
|
272
|
+
--purple: #a78bfa;
|
|
273
|
+
--pink: #f472b6;
|
|
274
|
+
--radius: 14px;
|
|
275
|
+
}
|
|
276
|
+
html.light {
|
|
277
|
+
--bg: #fafaf9;
|
|
278
|
+
--bg-2: #fff;
|
|
279
|
+
--surface: rgba(0,0,0,0.025);
|
|
280
|
+
--surface-hover: rgba(0,0,0,0.05);
|
|
281
|
+
--border: rgba(0,0,0,0.08);
|
|
282
|
+
--border-strong: rgba(0,0,0,0.16);
|
|
283
|
+
--text: #18181b;
|
|
284
|
+
--text-2: rgba(0,0,0,0.62);
|
|
285
|
+
--text-3: rgba(0,0,0,0.36);
|
|
286
|
+
--text-4: rgba(0,0,0,0.16);
|
|
287
|
+
}
|
|
288
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
289
|
+
::selection { background: rgba(255,255,255,0.16); }
|
|
290
|
+
html, body { background: var(--bg); color: var(--text); }
|
|
291
|
+
body {
|
|
292
|
+
font-family: 'Inter', system-ui, sans-serif;
|
|
293
|
+
font-size: 14px; line-height: 1.5;
|
|
294
|
+
font-feature-settings: 'cv11','ss01';
|
|
295
|
+
-webkit-font-smoothing: antialiased;
|
|
296
|
+
font-variant-numeric: tabular-nums;
|
|
297
|
+
min-height: 100vh;
|
|
298
|
+
}
|
|
299
|
+
.num { font-variant-numeric: tabular-nums; }
|
|
300
|
+
a { color: inherit; text-decoration: none; }
|
|
301
|
+
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
|
|
302
|
+
|
|
303
|
+
.page { max-width: 1200px; margin: 0 auto; padding: 28px 40px 100px; }
|
|
304
|
+
|
|
305
|
+
/* ── Top bar ─────────────────────────────────────────── */
|
|
306
|
+
.topbar {
|
|
307
|
+
display: flex; align-items: center; gap: 16px;
|
|
308
|
+
padding-bottom: 14px;
|
|
309
|
+
margin-bottom: 28px;
|
|
310
|
+
border-bottom: 1px solid var(--border);
|
|
311
|
+
}
|
|
312
|
+
.brand {
|
|
313
|
+
display: flex; align-items: center; gap: 10px;
|
|
314
|
+
font-weight: 500; font-size: 15px;
|
|
315
|
+
}
|
|
316
|
+
.brand .mark {
|
|
317
|
+
width: 22px; height: 22px;
|
|
318
|
+
display: grid; place-items: center;
|
|
319
|
+
background: linear-gradient(135deg, #fff, #c0c0c0);
|
|
320
|
+
color: #0a0a0a; border-radius: 6px;
|
|
321
|
+
font-weight: 700; font-size: 12px;
|
|
322
|
+
}
|
|
323
|
+
.brand .sep { color: var(--text-4); }
|
|
324
|
+
.brand .meta { color: var(--text-3); font-weight: 400; font-size: 13px; }
|
|
325
|
+
.top-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
|
326
|
+
|
|
327
|
+
.range-pills {
|
|
328
|
+
display: inline-flex; gap: 2px; padding: 3px;
|
|
329
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
330
|
+
border-radius: 999px;
|
|
331
|
+
}
|
|
332
|
+
.range-pills button {
|
|
333
|
+
font-size: 12px; padding: 5px 11px;
|
|
334
|
+
color: var(--text-3); border-radius: 999px;
|
|
335
|
+
transition: background 0.12s, color 0.12s;
|
|
336
|
+
}
|
|
337
|
+
.range-pills button:hover { color: var(--text); }
|
|
338
|
+
.range-pills button.active { background: var(--text); color: var(--bg); }
|
|
339
|
+
|
|
340
|
+
.status {
|
|
341
|
+
display: inline-flex; align-items: center; gap: 8px;
|
|
342
|
+
font-size: 13px; color: var(--text-2);
|
|
343
|
+
padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px;
|
|
344
|
+
}
|
|
345
|
+
.status .dot {
|
|
346
|
+
width: 6px; height: 6px; border-radius: 50%;
|
|
347
|
+
background: var(--green);
|
|
348
|
+
box-shadow: 0 0 0 3px rgba(74,222,128,0.16);
|
|
349
|
+
animation: pulse 2s ease-in-out infinite;
|
|
350
|
+
}
|
|
351
|
+
.status .dot.idle { background: var(--amber); box-shadow: 0 0 0 3px rgba(251,191,36,0.16); animation: none; }
|
|
352
|
+
.status .dot.stale { background: var(--text-4); box-shadow: none; animation: none; }
|
|
353
|
+
@keyframes pulse {
|
|
354
|
+
0%,100% { box-shadow: 0 0 0 3px rgba(74,222,128,0.16); }
|
|
355
|
+
50% { box-shadow: 0 0 0 6px rgba(74,222,128,0.04); }
|
|
356
|
+
}
|
|
357
|
+
.theme-btn {
|
|
358
|
+
padding: 6px 10px; border-radius: 999px; border: 1px solid var(--border);
|
|
359
|
+
font-size: 13px; color: var(--text-2);
|
|
360
|
+
}
|
|
361
|
+
.theme-btn:hover { color: var(--text); }
|
|
362
|
+
.model { font-size: 13px; color: var(--text-3); }
|
|
363
|
+
|
|
364
|
+
/* ── Live rail ───────────────────────────────────────── */
|
|
365
|
+
.live-rail {
|
|
366
|
+
display: grid; grid-template-columns: 72px 1fr auto; gap: 16px; align-items: center;
|
|
367
|
+
padding: 18px 22px; margin-bottom: 28px;
|
|
368
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
369
|
+
}
|
|
370
|
+
.live-rail .avatar {
|
|
371
|
+
width: 64px; height: 64px; border-radius: 14px;
|
|
372
|
+
background: linear-gradient(135deg, rgba(167,139,250,0.18), rgba(167,139,250,0.05));
|
|
373
|
+
overflow: hidden; position: relative;
|
|
374
|
+
}
|
|
375
|
+
.live-rail .avatar img { width: 100%; height: 100%; object-fit: cover; }
|
|
376
|
+
.live-rail .frame-app { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-3); font-weight: 600; }
|
|
377
|
+
.live-rail .frame-details { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; margin-top: 2px; }
|
|
378
|
+
.live-rail .frame-state { font-size: 13px; color: var(--text-2); margin-top: 2px; }
|
|
379
|
+
.live-rail .right { text-align: right; font-size: 12px; color: var(--text-3); }
|
|
380
|
+
.live-rail .right .frame-num { color: var(--text-2); font-size: 12px; margin-bottom: 6px; }
|
|
381
|
+
.live-rail .right .elapsed { font-size: 16px; font-weight: 500; color: var(--text); letter-spacing: -0.01em; }
|
|
382
|
+
|
|
383
|
+
/* ── Hero ────────────────────────────────────────────── */
|
|
384
|
+
.hero {
|
|
385
|
+
display: grid; grid-template-columns: 1fr 1.4fr; gap: 56px;
|
|
386
|
+
align-items: end; margin-bottom: 28px;
|
|
387
|
+
}
|
|
388
|
+
.hero .eyebrow {
|
|
389
|
+
font-size: 12px; color: var(--text-3);
|
|
390
|
+
text-transform: uppercase; letter-spacing: 0.12em;
|
|
391
|
+
font-weight: 500; margin-bottom: 16px;
|
|
392
|
+
}
|
|
393
|
+
.hero .figure { font-size: 86px; font-weight: 600; line-height: 0.92; letter-spacing: -0.05em; }
|
|
394
|
+
.hero .unit { font-size: 20px; color: var(--text-2); margin-left: 10px; }
|
|
395
|
+
.hero .caption { margin-top: 20px; color: var(--text-2); max-width: 380px; }
|
|
396
|
+
.hero .caption strong { color: var(--text); font-weight: 500; }
|
|
397
|
+
|
|
398
|
+
.chart-block .chart-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
|
|
399
|
+
.chart-block .chart-title { font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.12em; }
|
|
400
|
+
.chart-block .chart-side { font-size: 12px; color: var(--text-3); }
|
|
401
|
+
.chart-block .chart-side strong { color: var(--text-2); font-weight: 500; }
|
|
402
|
+
.chart-wrap { position: relative; height: 130px; }
|
|
403
|
+
svg.chart { width: 100%; height: 100%; overflow: visible; }
|
|
404
|
+
svg.chart .grid { stroke: var(--border); stroke-width: 1; }
|
|
405
|
+
svg.chart .area { fill: url(#whiteGrad); }
|
|
406
|
+
svg.chart .line { fill: none; stroke: var(--text); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
|
|
407
|
+
svg.chart .dot { fill: var(--text); }
|
|
408
|
+
svg.chart .ax { fill: var(--text-3); font-size: 10px; font-family: 'Inter', sans-serif; font-weight: 500; letter-spacing: 0.04em; }
|
|
409
|
+
|
|
410
|
+
/* ── Insights strip ─────────────────────────────────── */
|
|
411
|
+
.insights {
|
|
412
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
413
|
+
padding: 16px 20px; margin-bottom: 28px;
|
|
414
|
+
display: grid; gap: 6px;
|
|
415
|
+
}
|
|
416
|
+
.insights .insight {
|
|
417
|
+
display: flex; align-items: baseline; gap: 10px;
|
|
418
|
+
font-size: 13px; color: var(--text-2); line-height: 1.45;
|
|
419
|
+
}
|
|
420
|
+
.insights .insight::before {
|
|
421
|
+
content: '→'; color: var(--text-4); flex-shrink: 0; font-size: 13px;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* ── Stat cards ──────────────────────────────────────── */
|
|
425
|
+
.stat-row {
|
|
426
|
+
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
|
|
427
|
+
margin-bottom: 28px;
|
|
428
|
+
}
|
|
429
|
+
.stat-card {
|
|
430
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
431
|
+
padding: 18px 20px;
|
|
432
|
+
transition: background 0.18s, border-color 0.18s;
|
|
433
|
+
}
|
|
434
|
+
.stat-card:hover { background: var(--surface-hover); border-color: var(--border-strong); }
|
|
435
|
+
.stat-card .label { font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.1em; font-weight: 500; }
|
|
436
|
+
.stat-card .value { margin-top: 12px; display: flex; align-items: baseline; gap: 6px; font-size: 28px; font-weight: 600; letter-spacing: -0.03em; line-height: 1; }
|
|
437
|
+
.stat-card .value .unit { font-size: 13px; color: var(--text-3); font-weight: 400; }
|
|
438
|
+
.stat-card .meta { margin-top: 10px; font-size: 12px; color: var(--text-2); display: flex; align-items: center; gap: 8px; }
|
|
439
|
+
.delta { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; padding: 2px 6px; background: rgba(255,255,255,0.04); border-radius: 4px; }
|
|
440
|
+
.delta.up { color: var(--green); background: rgba(74,222,128,0.08); }
|
|
441
|
+
.delta.down { color: var(--red); background: rgba(248,113,113,0.08); }
|
|
442
|
+
.delta.flat { color: var(--text-3); }
|
|
443
|
+
|
|
444
|
+
/* ── Section heading ─────────────────────────────────── */
|
|
445
|
+
.section-head {
|
|
446
|
+
display: flex; align-items: baseline; justify-content: space-between;
|
|
447
|
+
margin-bottom: 16px;
|
|
448
|
+
}
|
|
449
|
+
.section-head h2 { font-size: 13px; font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.12em; }
|
|
450
|
+
.section-head .right { font-size: 12px; color: var(--text-3); }
|
|
451
|
+
section { margin-bottom: 28px; }
|
|
452
|
+
|
|
453
|
+
/* ── Leaderboards ────────────────────────────────────── */
|
|
454
|
+
.lb-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
|
455
|
+
.lb { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
456
|
+
.lb-h { display: flex; justify-content: space-between; align-items: baseline; padding: 14px 16px 10px; }
|
|
457
|
+
.lb-h .t { font-size: 13px; font-weight: 500; }
|
|
458
|
+
.lb-h .s { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.08em; }
|
|
459
|
+
.lb table { width: 100%; border-collapse: collapse; }
|
|
460
|
+
.lb td { padding: 7px 16px; font-size: 12.5px; }
|
|
461
|
+
.lb tr { border-top: 1px solid var(--border); transition: background 0.12s; }
|
|
462
|
+
.lb tr:hover td { background: rgba(255,255,255,0.025); }
|
|
463
|
+
.lb tr.clickable td { cursor: pointer; }
|
|
464
|
+
.lb td.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 1px; }
|
|
465
|
+
.lb td.val { color: var(--text-2); text-align: right; white-space: nowrap; }
|
|
466
|
+
.lb td.val .u { color: var(--text-3); margin-left: 3px; font-size: 11px; }
|
|
467
|
+
.lb td.name .ico { width: 12px; height: 12px; border-radius: 2px; vertical-align: -2px; margin-right: 7px; opacity: 0.7; }
|
|
468
|
+
|
|
469
|
+
/* ── Cost & languages ────────────────────────────────── */
|
|
470
|
+
.split-row { display: grid; grid-template-columns: 1.2fr 1fr; gap: 12px; margin-bottom: 28px; }
|
|
471
|
+
.card {
|
|
472
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
473
|
+
padding: 20px 22px;
|
|
474
|
+
}
|
|
475
|
+
.card-h { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
|
|
476
|
+
.card-h h3 { font-size: 13px; font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.12em; }
|
|
477
|
+
.card-h .meta { font-size: 12px; color: var(--text-3); }
|
|
478
|
+
|
|
479
|
+
.cost-grid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 24px; align-items: center; }
|
|
480
|
+
.cost-figure { font-size: 48px; font-weight: 600; letter-spacing: -0.04em; line-height: 1; }
|
|
481
|
+
.cost-sub { color: var(--text-2); font-size: 13px; margin-top: 8px; }
|
|
482
|
+
.cost-bars { display: grid; gap: 6px; font-size: 12px; }
|
|
483
|
+
.cost-bar { display: grid; grid-template-columns: 60px 1fr auto; gap: 8px; align-items: center; }
|
|
484
|
+
.cost-bar .name { color: var(--text-2); }
|
|
485
|
+
.cost-bar .track { height: 6px; background: rgba(255,255,255,0.06); border-radius: 99px; overflow: hidden; }
|
|
486
|
+
.cost-bar .fill { height: 100%; background: var(--purple); }
|
|
487
|
+
.cost-bar .val { color: var(--text); font-variant-numeric: tabular-nums; font-size: 12px; }
|
|
488
|
+
|
|
489
|
+
.lang-stack { display: flex; height: 14px; border-radius: 4px; overflow: hidden; background: rgba(255,255,255,0.06); margin-bottom: 14px; }
|
|
490
|
+
.lang-stack > span { display: block; }
|
|
491
|
+
.lang-list { display: grid; gap: 4px; }
|
|
492
|
+
.lang-list .row { display: grid; grid-template-columns: 12px 1fr auto; gap: 8px; align-items: center; font-size: 12.5px; }
|
|
493
|
+
.lang-list .swatch { width: 10px; height: 10px; border-radius: 2px; }
|
|
494
|
+
.lang-list .name { color: var(--text); }
|
|
495
|
+
.lang-list .val { color: var(--text-3); font-size: 11px; }
|
|
496
|
+
|
|
497
|
+
.churn-row { display: grid; grid-template-columns: 1fr 200px; gap: 28px; align-items: end; }
|
|
498
|
+
.churn-spark svg { width: 100%; height: 60px; display: block; }
|
|
499
|
+
.churn-spark .add { fill: var(--green); opacity: 0.85; }
|
|
500
|
+
.churn-spark .rem { fill: var(--red); opacity: 0.55; }
|
|
501
|
+
.churn-numbers { display: grid; gap: 4px; }
|
|
502
|
+
.churn-numbers .row { display: flex; justify-content: space-between; font-size: 12.5px; }
|
|
503
|
+
.churn-numbers .label { color: var(--text-3); }
|
|
504
|
+
.churn-numbers .added { color: var(--green); font-weight: 500; }
|
|
505
|
+
.churn-numbers .removed { color: var(--red); font-weight: 500; }
|
|
506
|
+
.churn-numbers .net { color: var(--text); font-weight: 500; }
|
|
507
|
+
|
|
508
|
+
/* ── Discord card ────────────────────────────────────── */
|
|
509
|
+
.discord {
|
|
510
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
511
|
+
padding: 24px 28px;
|
|
512
|
+
}
|
|
513
|
+
.discord-h { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 16px; }
|
|
514
|
+
.discord-h .t { font-size: 13px; font-weight: 500; }
|
|
515
|
+
.discord-h .s { font-size: 12px; color: var(--text-3); }
|
|
516
|
+
.live-frame {
|
|
517
|
+
padding: 22px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
|
|
518
|
+
}
|
|
519
|
+
.live-frame .label-tag {
|
|
520
|
+
font-size: 10px; color: var(--green); letter-spacing: 0.16em; text-transform: uppercase;
|
|
521
|
+
font-weight: 600; margin-bottom: 8px;
|
|
522
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
523
|
+
}
|
|
524
|
+
.live-frame .label-tag::before {
|
|
525
|
+
content: ''; width: 4px; height: 4px; border-radius: 50%;
|
|
526
|
+
background: var(--green); box-shadow: 0 0 0 2px rgba(74,222,128,0.2);
|
|
527
|
+
}
|
|
528
|
+
.live-frame .details { font-size: 20px; font-weight: 500; letter-spacing: -0.01em; line-height: 1.2; margin-bottom: 4px; }
|
|
529
|
+
.live-frame .state { font-size: 13px; color: var(--text-2); }
|
|
530
|
+
.rotation-list {
|
|
531
|
+
list-style: none; margin-top: 16px;
|
|
532
|
+
display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px 18px;
|
|
533
|
+
}
|
|
534
|
+
.rotation-list li {
|
|
535
|
+
display: flex; align-items: center; gap: 10px;
|
|
536
|
+
font-size: 12px; color: var(--text-2); padding: 4px 0;
|
|
537
|
+
}
|
|
538
|
+
.rotation-list li .pip { width: 4px; height: 4px; border-radius: 50%; background: var(--text-4); flex-shrink: 0; }
|
|
539
|
+
.rotation-list li.live .pip { background: var(--green); }
|
|
540
|
+
.rotation-list li.current { color: var(--text); }
|
|
541
|
+
.rotation-list li.current .pip { background: var(--text); box-shadow: 0 0 0 2px rgba(255,255,255,0.2); }
|
|
542
|
+
.rotation-list li.skip { color: var(--text-3); }
|
|
543
|
+
.rotation-list li .frame-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
|
544
|
+
|
|
545
|
+
/* ── Achievements ────────────────────────────────────── */
|
|
546
|
+
.achievements { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-bottom: 28px; }
|
|
547
|
+
.achievement {
|
|
548
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
|
549
|
+
padding: 12px 14px;
|
|
550
|
+
opacity: 0.32;
|
|
551
|
+
transition: opacity 0.18s, border-color 0.18s, background 0.18s;
|
|
552
|
+
}
|
|
553
|
+
.achievement.unlocked { opacity: 1; border-color: var(--border-strong); }
|
|
554
|
+
.achievement .ico { font-size: 18px; margin-bottom: 6px; display: block; }
|
|
555
|
+
.achievement .t { font-size: 12px; font-weight: 500; }
|
|
556
|
+
.achievement .s { font-size: 10.5px; color: var(--text-3); margin-top: 2px; }
|
|
557
|
+
|
|
558
|
+
/* ── Heatmap ─────────────────────────────────────────── */
|
|
559
|
+
.heatmap-card { padding: 20px 22px; }
|
|
560
|
+
.heatmap { display: grid; grid-template-columns: 20px 1fr; gap: 6px; }
|
|
561
|
+
.heatmap .day-labels { display: grid; grid-template-rows: repeat(7, 12px); gap: 3px; font-size: 9px; color: var(--text-3); padding-top: 14px; }
|
|
562
|
+
.heatmap .grid {
|
|
563
|
+
display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 12px); gap: 3px;
|
|
564
|
+
font-size: 0;
|
|
565
|
+
}
|
|
566
|
+
.heatmap .cell { width: 12px; height: 12px; border-radius: 2px; background: rgba(255,255,255,0.04); cursor: pointer; transition: transform 0.1s; }
|
|
567
|
+
.heatmap .cell:hover { transform: scale(1.4); outline: 1px solid var(--text); }
|
|
568
|
+
|
|
569
|
+
/* ── Drawer / modal ─────────────────────────────────── */
|
|
570
|
+
.scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: none; z-index: 50; }
|
|
571
|
+
.scrim.open { display: block; }
|
|
572
|
+
.drawer {
|
|
573
|
+
position: fixed; top: 0; right: 0; bottom: 0; width: 480px; max-width: 100%;
|
|
574
|
+
background: var(--bg); border-left: 1px solid var(--border);
|
|
575
|
+
transform: translateX(100%); transition: transform 0.22s ease;
|
|
576
|
+
z-index: 60; padding: 32px 28px; overflow-y: auto;
|
|
577
|
+
}
|
|
578
|
+
.drawer.open { transform: translateX(0); }
|
|
579
|
+
.drawer .close { position: absolute; top: 20px; right: 22px; font-size: 22px; color: var(--text-3); }
|
|
580
|
+
.drawer h3 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; margin-bottom: 6px; }
|
|
581
|
+
.drawer .sub { color: var(--text-3); font-size: 13px; margin-bottom: 22px; }
|
|
582
|
+
.drawer .grid { display: grid; gap: 12px; }
|
|
583
|
+
.drawer .kv { display: flex; justify-content: space-between; font-size: 13px; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
|
584
|
+
.drawer .kv .k { color: var(--text-3); }
|
|
585
|
+
.drawer .kv .v { color: var(--text); font-weight: 500; }
|
|
586
|
+
|
|
587
|
+
.modal {
|
|
588
|
+
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.96);
|
|
589
|
+
background: var(--bg); border: 1px solid var(--border-strong); border-radius: 14px;
|
|
590
|
+
padding: 28px 32px; min-width: 360px; max-width: 480px;
|
|
591
|
+
z-index: 60; opacity: 0; pointer-events: none; transition: opacity 0.18s, transform 0.18s;
|
|
592
|
+
}
|
|
593
|
+
.modal.open { opacity: 1; transform: translate(-50%, -50%) scale(1); pointer-events: auto; }
|
|
594
|
+
.modal .close { position: absolute; top: 14px; right: 16px; font-size: 22px; color: var(--text-3); }
|
|
595
|
+
.modal h4 { font-size: 18px; font-weight: 600; margin-bottom: 6px; }
|
|
596
|
+
.modal .sub { color: var(--text-3); font-size: 12px; margin-bottom: 18px; }
|
|
597
|
+
|
|
598
|
+
footer {
|
|
599
|
+
margin-top: 36px; padding-top: 22px;
|
|
600
|
+
border-top: 1px solid var(--border);
|
|
601
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
602
|
+
font-size: 12px; color: var(--text-3);
|
|
603
|
+
}
|
|
604
|
+
footer .pulse { display: inline-flex; align-items: center; gap: 6px; }
|
|
605
|
+
footer .pulse-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--green); opacity: 0.6; }
|
|
606
|
+
footer a:hover { color: var(--text-2); }
|
|
607
|
+
|
|
608
|
+
/* ── Help overlay ────────────────────────────────────── */
|
|
609
|
+
.help { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: none; z-index: 70; align-items: center; justify-content: center; }
|
|
610
|
+
.help.open { display: flex; }
|
|
611
|
+
.help-card { background: var(--bg); border: 1px solid var(--border-strong); border-radius: 14px; padding: 28px 32px; max-width: 420px; width: 90%; }
|
|
612
|
+
.help-card h4 { font-size: 16px; margin-bottom: 16px; }
|
|
613
|
+
.help-card .kbd { display: inline-block; padding: 2px 6px; border: 1px solid var(--border-strong); border-radius: 4px; font-size: 11px; font-family: monospace; margin-right: 8px; }
|
|
614
|
+
.help-card .row { display: flex; padding: 6px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--text-2); }
|
|
615
|
+
.help-card .row:first-of-type { border-top: 0; }
|
|
616
|
+
.help-card .keys { width: 110px; }
|
|
617
|
+
|
|
618
|
+
/* ── Responsive ──────────────────────────────────────── */
|
|
619
|
+
@media (max-width: 1100px) {
|
|
620
|
+
.stat-row { grid-template-columns: repeat(2, 1fr); }
|
|
621
|
+
.split-row { grid-template-columns: 1fr; }
|
|
622
|
+
.achievements { grid-template-columns: repeat(3, 1fr); }
|
|
623
|
+
}
|
|
624
|
+
@media (max-width: 760px) {
|
|
625
|
+
.hero { grid-template-columns: 1fr; gap: 28px; }
|
|
626
|
+
.lb-grid { grid-template-columns: 1fr; }
|
|
627
|
+
.rotation-list { grid-template-columns: 1fr; }
|
|
628
|
+
.drawer { width: 100%; }
|
|
629
|
+
}
|
|
630
|
+
`;
|
|
631
|
+
|
|
632
|
+
// Color palette for languages, by name. Stable across renders.
|
|
633
|
+
const LANG_PALETTE = `{
|
|
634
|
+
'JavaScript': '#f7df1e', 'TypeScript': '#3178c6', 'Python': '#3776ab', 'Rust': '#dea584',
|
|
635
|
+
'Go': '#00add8', 'Ruby': '#cc342d', 'Java': '#b07219', 'Kotlin': '#a97bff',
|
|
636
|
+
'C': '#555', 'C++': '#f34b7d', 'C#': '#178600', 'PHP': '#4f5b93',
|
|
637
|
+
'Swift': '#ffac45', 'HTML': '#e34c26', 'CSS': '#563d7c', 'SCSS': '#c6538c',
|
|
638
|
+
'Markdown': '#888', 'JSON': '#888', 'Shell': '#89e051', 'YAML': '#cb171e',
|
|
639
|
+
'Vue': '#41b883', 'Svelte': '#ff3e00', 'Notebook': '#da5b0b', 'SQL': '#dad8d8',
|
|
640
|
+
'GraphQL': '#e10098', 'Dockerfile': '#384d54', 'Make': '#427819', 'CMake': '#da3434',
|
|
641
|
+
'Lua': '#000080', 'Dart': '#00b4ab', 'Elm': '#60b5cc', 'Elixir': '#6e4a7e',
|
|
642
|
+
'Erlang': '#a90533', 'Haskell': '#5d4f85', 'OCaml': '#3be133', 'Clojure': '#db5855',
|
|
643
|
+
'ClojureScript': '#db5855', 'R': '#198ce7', 'Julia': '#a270ba', 'Zig': '#ec915c',
|
|
644
|
+
'PowerShell': '#012456', 'Batch': '#c1f12e', 'TOML': '#9c4221', 'INI': '#888',
|
|
645
|
+
'XML': '#0060ac', 'Protobuf': '#888', 'LaTeX': '#3D6117', 'Text': '#888',
|
|
646
|
+
'reStructuredText': '#888', 'Lockfile': '#444', 'Gradle': '#02303a',
|
|
647
|
+
'Crystal': '#000100', 'Nim': '#ffc200', 'V': '#4f87c4', 'Objective-C': '#438eff',
|
|
648
|
+
'Objective-C++': '#6866fb', 'Sass': '#a53b70', 'Less': '#1d365d', 'Vue': '#41b883',
|
|
649
|
+
'Scala': '#c22d40', 'Groovy': '#4298b8', 'Interface Builder': '#888', 'Env': '#888',
|
|
650
|
+
'Config': '#888', 'Git': '#f1502f',
|
|
651
|
+
}`;
|
|
652
|
+
|
|
653
|
+
const HTML = String.raw`<!doctype html>
|
|
654
|
+
<html lang="en">
|
|
655
|
+
<head>
|
|
656
|
+
<meta charset="utf-8" />
|
|
657
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
658
|
+
<title>Claude</title>
|
|
659
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
660
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
661
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
662
|
+
<style>${CSS}</style>
|
|
663
|
+
</head>
|
|
664
|
+
<body>
|
|
665
|
+
<main class="page">
|
|
666
|
+
|
|
667
|
+
<!-- ── Top bar ─────────────────────────────────────── -->
|
|
668
|
+
<header class="topbar">
|
|
669
|
+
<div class="brand">
|
|
670
|
+
<span class="mark">◆</span>
|
|
671
|
+
<span>Claude</span>
|
|
672
|
+
<span class="sep">·</span>
|
|
673
|
+
<span class="meta" id="meta">—</span>
|
|
674
|
+
</div>
|
|
675
|
+
<div class="top-right">
|
|
676
|
+
<div class="range-pills" id="range-pills">
|
|
677
|
+
<button data-range="7d">7d</button>
|
|
678
|
+
<button data-range="30d">30d</button>
|
|
679
|
+
<button data-range="90d" class="active">90d</button>
|
|
680
|
+
<button data-range="1y">1y</button>
|
|
681
|
+
<button data-range="all">All</button>
|
|
682
|
+
</div>
|
|
683
|
+
<button class="theme-btn" id="theme-btn" title="Toggle theme">◐</button>
|
|
684
|
+
<span class="model" id="model">—</span>
|
|
685
|
+
<span class="status"><span class="dot" id="dot"></span><span id="statustext">—</span></span>
|
|
686
|
+
</div>
|
|
687
|
+
</header>
|
|
688
|
+
|
|
689
|
+
<!-- ── Live rail ───────────────────────────────────── -->
|
|
690
|
+
<section class="live-rail" id="live-rail">
|
|
691
|
+
<div class="avatar" id="live-avatar"></div>
|
|
692
|
+
<div>
|
|
693
|
+
<div class="frame-app">Claude Code</div>
|
|
694
|
+
<div class="frame-details" id="frame-details">—</div>
|
|
695
|
+
<div class="frame-state" id="frame-state">—</div>
|
|
696
|
+
</div>
|
|
697
|
+
<div class="right">
|
|
698
|
+
<div class="frame-num" id="frame-num">—</div>
|
|
699
|
+
<div class="elapsed" id="elapsed">—</div>
|
|
700
|
+
</div>
|
|
701
|
+
</section>
|
|
702
|
+
|
|
703
|
+
<!-- ── Insights ────────────────────────────────────── -->
|
|
704
|
+
<section class="insights" id="insights"><div class="insight">Loading…</div></section>
|
|
705
|
+
|
|
706
|
+
<!-- ── Hero ────────────────────────────────────────── -->
|
|
707
|
+
<section class="hero">
|
|
708
|
+
<div>
|
|
709
|
+
<div class="eyebrow">Active time</div>
|
|
710
|
+
<div><span class="figure" id="hero-num">—</span><span class="unit" id="hero-unit">hours</span></div>
|
|
711
|
+
<div class="caption" id="hero-caption">—</div>
|
|
712
|
+
</div>
|
|
713
|
+
<div class="chart-block">
|
|
714
|
+
<div class="chart-head">
|
|
715
|
+
<span class="chart-title" id="chart-title">Last 90 days</span>
|
|
716
|
+
<span class="chart-side"><strong id="chart-total">—</strong> <span style="color: var(--text-4); margin: 0 6px;">·</span> peak <strong id="chart-peak">—</strong></span>
|
|
717
|
+
</div>
|
|
718
|
+
<div class="chart-wrap">
|
|
719
|
+
<svg id="chart" class="chart" viewBox="0 0 800 130" preserveAspectRatio="none">
|
|
720
|
+
<defs>
|
|
721
|
+
<linearGradient id="whiteGrad" x1="0" y1="0" x2="0" y2="1">
|
|
722
|
+
<stop offset="0%" stop-color="var(--text)" stop-opacity="0.14"/>
|
|
723
|
+
<stop offset="100%" stop-color="var(--text)" stop-opacity="0"/>
|
|
724
|
+
</linearGradient>
|
|
725
|
+
</defs>
|
|
726
|
+
</svg>
|
|
727
|
+
</div>
|
|
728
|
+
</div>
|
|
729
|
+
</section>
|
|
730
|
+
|
|
731
|
+
<!-- ── Stat row ────────────────────────────────────── -->
|
|
732
|
+
<section class="stat-row">
|
|
733
|
+
<div class="stat-card">
|
|
734
|
+
<div class="label">Today</div>
|
|
735
|
+
<div class="value"><span id="today-num">—</span><span class="unit" id="today-unit">hrs</span></div>
|
|
736
|
+
<div class="meta"><span class="delta" id="today-delta">—</span> <span id="today-sub" class="num">—</span></div>
|
|
737
|
+
</div>
|
|
738
|
+
<div class="stat-card">
|
|
739
|
+
<div class="label">This range</div>
|
|
740
|
+
<div class="value"><span id="range-num">—</span><span class="unit" id="range-unit">hrs</span></div>
|
|
741
|
+
<div class="meta"><span class="delta" id="range-delta">—</span> <span id="range-sub" class="num">—</span></div>
|
|
742
|
+
</div>
|
|
743
|
+
<div class="stat-card">
|
|
744
|
+
<div class="label">Streak</div>
|
|
745
|
+
<div class="value"><span id="streak-num">—</span><span class="unit">days</span></div>
|
|
746
|
+
<div class="meta"><span id="streak-sub">—</span></div>
|
|
747
|
+
</div>
|
|
748
|
+
<div class="stat-card">
|
|
749
|
+
<div class="label">Cost · range</div>
|
|
750
|
+
<div class="value"><span id="cost-num">—</span></div>
|
|
751
|
+
<div class="meta"><span id="cost-sub">—</span></div>
|
|
752
|
+
</div>
|
|
753
|
+
</section>
|
|
754
|
+
|
|
755
|
+
<!-- ── Achievements ────────────────────────────────── -->
|
|
756
|
+
<section class="achievements" id="achievements"></section>
|
|
757
|
+
|
|
758
|
+
<!-- ── Heatmap ─────────────────────────────────────── -->
|
|
759
|
+
<section>
|
|
760
|
+
<div class="section-head">
|
|
761
|
+
<h2>Activity</h2>
|
|
762
|
+
<div class="right" id="heatmap-meta">click a day for details</div>
|
|
763
|
+
</div>
|
|
764
|
+
<div class="card heatmap-card">
|
|
765
|
+
<div class="heatmap">
|
|
766
|
+
<div class="day-labels"><span></span><span>M</span><span></span><span>W</span><span></span><span>F</span><span></span></div>
|
|
767
|
+
<div class="grid" id="heatmap-grid"></div>
|
|
768
|
+
</div>
|
|
769
|
+
</div>
|
|
770
|
+
</section>
|
|
771
|
+
|
|
772
|
+
<!-- ── Split: cost + languages ─────────────────────── -->
|
|
773
|
+
<section class="split-row">
|
|
774
|
+
<div class="card">
|
|
775
|
+
<div class="card-h"><h3>Cost</h3><div class="meta" id="cost-card-meta">approximate · range</div></div>
|
|
776
|
+
<div class="cost-grid">
|
|
777
|
+
<div>
|
|
778
|
+
<div class="cost-figure" id="cost-figure">—</div>
|
|
779
|
+
<div class="cost-sub" id="cost-figure-sub">—</div>
|
|
780
|
+
</div>
|
|
781
|
+
<div class="cost-bars" id="cost-bars"></div>
|
|
782
|
+
</div>
|
|
783
|
+
</div>
|
|
784
|
+
<div class="card">
|
|
785
|
+
<div class="card-h"><h3>Languages</h3><div class="meta" id="lang-meta">by edits</div></div>
|
|
786
|
+
<div class="lang-stack" id="lang-stack"></div>
|
|
787
|
+
<div class="lang-list" id="lang-list"></div>
|
|
788
|
+
</div>
|
|
789
|
+
</section>
|
|
790
|
+
|
|
791
|
+
<!-- ── Code churn ──────────────────────────────────── -->
|
|
792
|
+
<section class="card" style="margin-bottom: 28px;">
|
|
793
|
+
<div class="card-h"><h3>Code churn</h3><div class="meta" id="churn-meta">lines added / removed · range</div></div>
|
|
794
|
+
<div class="churn-row">
|
|
795
|
+
<div class="churn-spark">
|
|
796
|
+
<svg id="churn-svg" viewBox="0 0 800 60" preserveAspectRatio="none"></svg>
|
|
797
|
+
</div>
|
|
798
|
+
<div class="churn-numbers">
|
|
799
|
+
<div class="row"><span class="label">Added</span><span class="added" id="churn-added">—</span></div>
|
|
800
|
+
<div class="row"><span class="label">Removed</span><span class="removed" id="churn-removed">—</span></div>
|
|
801
|
+
<div class="row"><span class="label">Net</span><span class="net" id="churn-net">—</span></div>
|
|
802
|
+
</div>
|
|
803
|
+
</div>
|
|
804
|
+
</section>
|
|
805
|
+
|
|
806
|
+
<!-- ── Tokens ──────────────────────────────────────── -->
|
|
807
|
+
<section>
|
|
808
|
+
<div class="section-head">
|
|
809
|
+
<h2>Tokens</h2>
|
|
810
|
+
<div class="right"><span id="tok-cache-pct">—</span> from cache</div>
|
|
811
|
+
</div>
|
|
812
|
+
<div class="stat-row" style="margin-bottom: 0; grid-template-columns: repeat(3, 1fr);">
|
|
813
|
+
<div class="stat-card"><div class="label">Grand total</div><div class="value"><span id="tok-grand">—</span></div><div class="meta"><span class="num" id="tok-grand-sub">in + out + cache</span></div></div>
|
|
814
|
+
<div class="stat-card"><div class="label">Output</div><div class="value"><span id="tok-out">—</span></div><div class="meta"><span class="num" id="tok-in-sub">input —</span></div></div>
|
|
815
|
+
<div class="stat-card"><div class="label">Cache</div><div class="value"><span id="tok-cache">—</span></div><div class="meta"><span class="num" id="tok-cache-sub">read — · write —</span></div></div>
|
|
816
|
+
</div>
|
|
817
|
+
</section>
|
|
818
|
+
|
|
819
|
+
<!-- ── Leaderboards ────────────────────────────────── -->
|
|
820
|
+
<section>
|
|
821
|
+
<div class="section-head">
|
|
822
|
+
<h2>Projects · tools · files</h2>
|
|
823
|
+
<div class="right" id="lb-meta">across <span id="lb-sessions">—</span> sessions</div>
|
|
824
|
+
</div>
|
|
825
|
+
<div class="lb-grid">
|
|
826
|
+
<div class="lb">
|
|
827
|
+
<div class="lb-h"><span class="t">Projects</span><span class="s">by hours</span></div>
|
|
828
|
+
<table id="projects-tbl"></table>
|
|
829
|
+
</div>
|
|
830
|
+
<div class="lb">
|
|
831
|
+
<div class="lb-h"><span class="t">Tools</span><span class="s">by calls</span></div>
|
|
832
|
+
<table id="tools-tbl"></table>
|
|
833
|
+
</div>
|
|
834
|
+
<div class="lb">
|
|
835
|
+
<div class="lb-h"><span class="t">Files</span><span class="s">by edits</span></div>
|
|
836
|
+
<table id="files-tbl"></table>
|
|
837
|
+
</div>
|
|
838
|
+
</div>
|
|
839
|
+
</section>
|
|
840
|
+
|
|
841
|
+
<!-- ── More leaderboards: bash + domains + subagents ─ -->
|
|
842
|
+
<section>
|
|
843
|
+
<div class="section-head">
|
|
844
|
+
<h2>Shell · web · subagents</h2>
|
|
845
|
+
<div class="right"><span id="mcp-label">—</span></div>
|
|
846
|
+
</div>
|
|
847
|
+
<div class="lb-grid">
|
|
848
|
+
<div class="lb">
|
|
849
|
+
<div class="lb-h"><span class="t">Bash commands</span><span class="s">by invocations</span></div>
|
|
850
|
+
<table id="bash-tbl"></table>
|
|
851
|
+
</div>
|
|
852
|
+
<div class="lb">
|
|
853
|
+
<div class="lb-h"><span class="t">WebFetch domains</span><span class="s">by hits</span></div>
|
|
854
|
+
<table id="domains-tbl"></table>
|
|
855
|
+
</div>
|
|
856
|
+
<div class="lb">
|
|
857
|
+
<div class="lb-h"><span class="t">Subagents</span><span class="s">by invocations</span></div>
|
|
858
|
+
<table id="subagents-tbl"></table>
|
|
859
|
+
</div>
|
|
860
|
+
</div>
|
|
861
|
+
</section>
|
|
862
|
+
|
|
863
|
+
<!-- ── Discord card ────────────────────────────────── -->
|
|
864
|
+
<section>
|
|
865
|
+
<div class="section-head">
|
|
866
|
+
<h2>Discord presence</h2>
|
|
867
|
+
<div class="right"><span id="frames-live">—</span> live · <span id="frames-total">—</span> total</div>
|
|
868
|
+
</div>
|
|
869
|
+
<div class="discord">
|
|
870
|
+
<div class="discord-h"><span class="t">Now showing</span><span class="s" id="frame-no">—</span></div>
|
|
871
|
+
<div class="live-frame">
|
|
872
|
+
<div class="label-tag">On air</div>
|
|
873
|
+
<div class="details" id="frame-details-2">—</div>
|
|
874
|
+
<div class="state" id="frame-state-2">—</div>
|
|
875
|
+
</div>
|
|
876
|
+
<ul class="rotation-list" id="rotation-list"></ul>
|
|
877
|
+
</div>
|
|
878
|
+
</section>
|
|
879
|
+
|
|
880
|
+
<footer>
|
|
881
|
+
<span class="pulse"><span class="pulse-dot"></span><span id="conn-state">live</span></span>
|
|
882
|
+
<span>
|
|
883
|
+
<a href="/api/badge.svg?metric=hours&range=7d" target="_blank">badges</a>
|
|
884
|
+
·
|
|
885
|
+
<span>127.0.0.1:${PORT}</span>
|
|
886
|
+
·
|
|
887
|
+
<span style="color: var(--text-4);">?</span> for help
|
|
888
|
+
</span>
|
|
889
|
+
</footer>
|
|
890
|
+
</main>
|
|
891
|
+
|
|
892
|
+
<!-- Drawer (project drilldown) -->
|
|
893
|
+
<div class="scrim" id="scrim"></div>
|
|
894
|
+
<div class="drawer" id="drawer">
|
|
895
|
+
<button class="close" id="drawer-close">×</button>
|
|
896
|
+
<h3 id="drawer-title">—</h3>
|
|
897
|
+
<div class="sub" id="drawer-sub">—</div>
|
|
898
|
+
<div class="grid" id="drawer-body"></div>
|
|
899
|
+
</div>
|
|
900
|
+
|
|
901
|
+
<!-- Modal (day detail) -->
|
|
902
|
+
<div class="modal" id="modal">
|
|
903
|
+
<button class="close" id="modal-close">×</button>
|
|
904
|
+
<h4 id="modal-title">—</h4>
|
|
905
|
+
<div class="sub" id="modal-sub">—</div>
|
|
906
|
+
<div id="modal-body"></div>
|
|
907
|
+
</div>
|
|
908
|
+
|
|
909
|
+
<!-- Keyboard help -->
|
|
910
|
+
<div class="help" id="help">
|
|
911
|
+
<div class="help-card">
|
|
912
|
+
<h4>Keyboard shortcuts</h4>
|
|
913
|
+
<div class="row"><span class="keys"><span class="kbd">1</span><span class="kbd">5</span></span><span>switch range</span></div>
|
|
914
|
+
<div class="row"><span class="keys"><span class="kbd">t</span></span><span>toggle theme</span></div>
|
|
915
|
+
<div class="row"><span class="keys"><span class="kbd">esc</span></span><span>close drawer / modal</span></div>
|
|
916
|
+
<div class="row"><span class="keys"><span class="kbd">?</span></span><span>this help</span></div>
|
|
917
|
+
</div>
|
|
918
|
+
</div>
|
|
919
|
+
|
|
920
|
+
<script>
|
|
921
|
+
${HTML_SCRIPT_PLACEHOLDER()}
|
|
922
|
+
</script>
|
|
923
|
+
</body>
|
|
924
|
+
</html>`;
|
|
925
|
+
|
|
926
|
+
function HTML_SCRIPT_PLACEHOLDER() {
|
|
927
|
+
return `(() => {
|
|
928
|
+
const $ = (id) => document.getElementById(id);
|
|
929
|
+
const LANGS = ${LANG_PALETTE};
|
|
930
|
+
|
|
931
|
+
let range = '90d';
|
|
932
|
+
let liveData = null;
|
|
933
|
+
let aggData = null;
|
|
934
|
+
let allFrames = [];
|
|
935
|
+
let currentLiveIdx = 0;
|
|
936
|
+
let rotationTimer = null;
|
|
937
|
+
|
|
938
|
+
// ── Utilities ───────────────────────────────────────────
|
|
939
|
+
const fmtH = (ms) => {
|
|
940
|
+
if (!ms) return '0h';
|
|
941
|
+
const h = ms / 3_600_000;
|
|
942
|
+
if (h < 1) return Math.round(h * 60) + 'm';
|
|
943
|
+
if (h < 10) return h.toFixed(1) + 'h';
|
|
944
|
+
return Math.round(h) + 'h';
|
|
945
|
+
};
|
|
946
|
+
const fmtN = (n) => {
|
|
947
|
+
if (!n) return '0';
|
|
948
|
+
if (n < 1000) return String(n);
|
|
949
|
+
if (n < 1e6) return (n / 1e3).toFixed(1) + 'k';
|
|
950
|
+
if (n < 1e9) return (n / 1e6).toFixed(2) + 'M';
|
|
951
|
+
return (n / 1e9).toFixed(2) + 'B';
|
|
952
|
+
};
|
|
953
|
+
const fmtCost = (usd) => {
|
|
954
|
+
if (!usd) return '$0';
|
|
955
|
+
if (usd < 0.01) return '$' + usd.toFixed(4);
|
|
956
|
+
if (usd < 100) return '$' + usd.toFixed(2);
|
|
957
|
+
if (usd < 1000) return '$' + Math.round(usd);
|
|
958
|
+
if (usd < 10000) return '$' + (usd / 1000).toFixed(2) + 'k';
|
|
959
|
+
return '$' + (usd / 1000).toFixed(1) + 'k';
|
|
960
|
+
};
|
|
961
|
+
const dayKey = (ts) => {
|
|
962
|
+
const d = new Date(ts);
|
|
963
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
|
964
|
+
};
|
|
965
|
+
const splitTime = (s) => {
|
|
966
|
+
if (!s) return ['—', ''];
|
|
967
|
+
const m = String(s).match(/^([\\d.]+)([a-z]*)$/i);
|
|
968
|
+
return m ? [m[1], m[2]] : [s, ''];
|
|
969
|
+
};
|
|
970
|
+
const setDelta = (node, ms, suffix) => {
|
|
971
|
+
if (ms === 0) { node.className = 'delta flat'; node.textContent = '—'; return; }
|
|
972
|
+
const sign = ms > 0 ? 'up' : 'down';
|
|
973
|
+
const arrow = ms > 0 ? '↑' : '↓';
|
|
974
|
+
node.className = 'delta ' + sign;
|
|
975
|
+
node.textContent = arrow + ' ' + fmtH(Math.abs(ms)) + (suffix ? ' ' + suffix : '');
|
|
976
|
+
};
|
|
977
|
+
const elapsedStr = (start) => {
|
|
978
|
+
if (!start) return '—';
|
|
979
|
+
const s = Math.floor((Date.now() - start) / 1000);
|
|
980
|
+
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
|
981
|
+
if (h) return h + 'h ' + m + 'm';
|
|
982
|
+
return m + 'm ' + (s % 60) + 's';
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
// ── Theme ───────────────────────────────────────────────
|
|
986
|
+
function applyTheme() {
|
|
987
|
+
const saved = localStorage.getItem('theme') || 'dark';
|
|
988
|
+
document.documentElement.classList.toggle('light', saved === 'light');
|
|
989
|
+
}
|
|
990
|
+
$('theme-btn').addEventListener('click', () => {
|
|
991
|
+
const cur = localStorage.getItem('theme') || 'dark';
|
|
992
|
+
localStorage.setItem('theme', cur === 'dark' ? 'light' : 'dark');
|
|
993
|
+
applyTheme();
|
|
994
|
+
});
|
|
995
|
+
applyTheme();
|
|
996
|
+
|
|
997
|
+
// ── Range pills ─────────────────────────────────────────
|
|
998
|
+
document.querySelectorAll('#range-pills button').forEach((b) => {
|
|
999
|
+
b.addEventListener('click', () => {
|
|
1000
|
+
range = b.dataset.range;
|
|
1001
|
+
for (const x of document.querySelectorAll('#range-pills button')) x.classList.toggle('active', x === b);
|
|
1002
|
+
$('chart-title').textContent = range === 'all' ? 'All time' : 'Last ' + range;
|
|
1003
|
+
fetchAggregate();
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
// ── Chart ───────────────────────────────────────────────
|
|
1008
|
+
function renderChart(byDay, days) {
|
|
1009
|
+
const svg = $('chart');
|
|
1010
|
+
[...svg.querySelectorAll('.dyn')].forEach((n) => n.remove());
|
|
1011
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
1012
|
+
const VIEW_W = 800, VIEW_H = 130, PAD_T = 6, PAD_B = 16;
|
|
1013
|
+
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
1014
|
+
const series = [];
|
|
1015
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
1016
|
+
const d = new Date(today); d.setDate(d.getDate() - i);
|
|
1017
|
+
const ms = (byDay[dayKey(d.getTime())] || {}).activeMs || 0;
|
|
1018
|
+
series.push({ d, ms });
|
|
1019
|
+
}
|
|
1020
|
+
const max = Math.max(...series.map((p) => p.ms), 1);
|
|
1021
|
+
const h = VIEW_H - PAD_T - PAD_B;
|
|
1022
|
+
const xAt = (i) => series.length > 1 ? (i / (series.length - 1)) * VIEW_W : VIEW_W / 2;
|
|
1023
|
+
const yAt = (ms) => PAD_T + h - (ms / max) * h;
|
|
1024
|
+
for (let r = 1; r <= 3; r++) {
|
|
1025
|
+
const y = PAD_T + (h / 3) * r;
|
|
1026
|
+
const ln = document.createElementNS(ns, 'line');
|
|
1027
|
+
ln.setAttribute('x1', 0); ln.setAttribute('x2', VIEW_W);
|
|
1028
|
+
ln.setAttribute('y1', y); ln.setAttribute('y2', y);
|
|
1029
|
+
ln.setAttribute('class', 'grid dyn');
|
|
1030
|
+
svg.appendChild(ln);
|
|
1031
|
+
}
|
|
1032
|
+
let path = '';
|
|
1033
|
+
series.forEach((p, i) => {
|
|
1034
|
+
const x = xAt(i), y = yAt(p.ms);
|
|
1035
|
+
path += (i === 0 ? 'M' : ' L') + x.toFixed(1) + ',' + y.toFixed(1);
|
|
1036
|
+
});
|
|
1037
|
+
const area = document.createElementNS(ns, 'path');
|
|
1038
|
+
area.setAttribute('d', path + ' L' + xAt(series.length - 1).toFixed(1) + ',' + (PAD_T + h) + ' L0,' + (PAD_T + h) + ' Z');
|
|
1039
|
+
area.setAttribute('class', 'area dyn');
|
|
1040
|
+
svg.appendChild(area);
|
|
1041
|
+
const line = document.createElementNS(ns, 'path');
|
|
1042
|
+
line.setAttribute('d', path);
|
|
1043
|
+
line.setAttribute('class', 'line dyn');
|
|
1044
|
+
svg.appendChild(line);
|
|
1045
|
+
const last = series[series.length - 1];
|
|
1046
|
+
if (last.ms > 0) {
|
|
1047
|
+
const dot = document.createElementNS(ns, 'circle');
|
|
1048
|
+
dot.setAttribute('cx', xAt(series.length - 1));
|
|
1049
|
+
dot.setAttribute('cy', yAt(last.ms));
|
|
1050
|
+
dot.setAttribute('r', 3);
|
|
1051
|
+
dot.setAttribute('class', 'dot dyn');
|
|
1052
|
+
svg.appendChild(dot);
|
|
1053
|
+
}
|
|
1054
|
+
const totalMs = series.reduce((s, p) => s + p.ms, 0);
|
|
1055
|
+
const peakDay = series.reduce((m, p) => p.ms > m.ms ? p : m, { ms: 0, d: null });
|
|
1056
|
+
$('chart-total').textContent = fmtH(totalMs) + ' total';
|
|
1057
|
+
$('chart-peak').textContent = peakDay.ms > 0 ? fmtH(peakDay.ms) + ' on ' + peakDay.d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—';
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// ── Heatmap ─────────────────────────────────────────────
|
|
1061
|
+
function renderHeatmap(byDay) {
|
|
1062
|
+
const grid = $('heatmap-grid');
|
|
1063
|
+
grid.innerHTML = '';
|
|
1064
|
+
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
1065
|
+
let start = new Date(today); start.setDate(start.getDate() - 90);
|
|
1066
|
+
while (start.getDay() !== 0) start.setDate(start.getDate() - 1);
|
|
1067
|
+
let max = 0;
|
|
1068
|
+
for (let k in byDay) max = Math.max(max, byDay[k].activeMs || 0);
|
|
1069
|
+
const cur = new Date(start);
|
|
1070
|
+
while (cur <= today) {
|
|
1071
|
+
const k = dayKey(cur.getTime());
|
|
1072
|
+
const ms = (byDay[k] || {}).activeMs || 0;
|
|
1073
|
+
const cell = document.createElement('div');
|
|
1074
|
+
cell.className = 'cell';
|
|
1075
|
+
if (ms > 0) {
|
|
1076
|
+
const lvl = Math.min(1, ms / max);
|
|
1077
|
+
cell.style.background = 'rgba(74, 222, 128, ' + (0.18 + lvl * 0.72).toFixed(2) + ')';
|
|
1078
|
+
}
|
|
1079
|
+
cell.title = k + ' · ' + fmtH(ms);
|
|
1080
|
+
cell.addEventListener('click', () => openDay(k));
|
|
1081
|
+
grid.appendChild(cell);
|
|
1082
|
+
cur.setDate(cur.getDate() + 1);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ── Churn sparkline ─────────────────────────────────────
|
|
1087
|
+
function renderChurn(byDay, days) {
|
|
1088
|
+
const svg = $('churn-svg');
|
|
1089
|
+
svg.innerHTML = '';
|
|
1090
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
1091
|
+
const W = 800, H = 60;
|
|
1092
|
+
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
1093
|
+
const series = [];
|
|
1094
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
1095
|
+
const d = new Date(today); d.setDate(d.getDate() - i);
|
|
1096
|
+
const day = byDay[dayKey(d.getTime())] || {};
|
|
1097
|
+
series.push({ add: day.linesAdded || 0, rem: day.linesRemoved || 0 });
|
|
1098
|
+
}
|
|
1099
|
+
const maxAdd = Math.max(1, ...series.map((s) => s.add));
|
|
1100
|
+
const maxRem = Math.max(1, ...series.map((s) => s.rem));
|
|
1101
|
+
const maxBoth = Math.max(maxAdd, maxRem);
|
|
1102
|
+
const half = H / 2;
|
|
1103
|
+
const bw = W / series.length;
|
|
1104
|
+
series.forEach((s, i) => {
|
|
1105
|
+
const ah = (s.add / maxBoth) * (half - 2);
|
|
1106
|
+
const rh = (s.rem / maxBoth) * (half - 2);
|
|
1107
|
+
const a = document.createElementNS(ns, 'rect');
|
|
1108
|
+
a.setAttribute('x', (i * bw + 0.5).toFixed(1));
|
|
1109
|
+
a.setAttribute('y', (half - ah).toFixed(1));
|
|
1110
|
+
a.setAttribute('width', (bw - 1).toFixed(1));
|
|
1111
|
+
a.setAttribute('height', ah.toFixed(1));
|
|
1112
|
+
a.setAttribute('class', 'add');
|
|
1113
|
+
svg.appendChild(a);
|
|
1114
|
+
const r = document.createElementNS(ns, 'rect');
|
|
1115
|
+
r.setAttribute('x', (i * bw + 0.5).toFixed(1));
|
|
1116
|
+
r.setAttribute('y', half.toFixed(1));
|
|
1117
|
+
r.setAttribute('width', (bw - 1).toFixed(1));
|
|
1118
|
+
r.setAttribute('height', rh.toFixed(1));
|
|
1119
|
+
r.setAttribute('class', 'rem');
|
|
1120
|
+
svg.appendChild(r);
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// ── Tables ──────────────────────────────────────────────
|
|
1125
|
+
function renderTable(target, rows, opts = {}) {
|
|
1126
|
+
const tbl = $(target);
|
|
1127
|
+
tbl.innerHTML = '';
|
|
1128
|
+
if (!rows.length) {
|
|
1129
|
+
const tr = document.createElement('tr');
|
|
1130
|
+
tr.innerHTML = '<td class="name" style="color: var(--text-3);">—</td><td class="val">—</td>';
|
|
1131
|
+
tbl.appendChild(tr);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
rows.forEach((r) => {
|
|
1135
|
+
const tr = document.createElement('tr');
|
|
1136
|
+
if (r.onClick) tr.classList.add('clickable');
|
|
1137
|
+
const ico = r.color ? '<span class="ico" style="background:' + r.color + '"></span>' : '';
|
|
1138
|
+
const nameHtml = opts.mono
|
|
1139
|
+
? '<code style="font-family: JetBrains Mono, monospace; font-size: 12px;">' + ico + r.name + '</code>'
|
|
1140
|
+
: ico + r.name;
|
|
1141
|
+
tr.innerHTML = '<td class="name">' + nameHtml + '</td>' +
|
|
1142
|
+
'<td class="val">' + r.val + (r.unit ? '<span class="u">' + r.unit + '</span>' : '') + '</td>';
|
|
1143
|
+
if (r.onClick) tr.addEventListener('click', r.onClick);
|
|
1144
|
+
tbl.appendChild(tr);
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// ── Achievements ────────────────────────────────────────
|
|
1149
|
+
function renderAchievements(a) {
|
|
1150
|
+
const list = [
|
|
1151
|
+
{ t: 'First session', ok: (a.sessions || 0) >= 1, s: '1', ico: '◉' },
|
|
1152
|
+
{ t: 'Week streak', ok: (a.longestStreak || 0) >= 7, s: '7 days', ico: '◆' },
|
|
1153
|
+
{ t: 'Month streak', ok: (a.longestStreak || 0) >= 30, s: '30 days', ico: '◇' },
|
|
1154
|
+
{ t: '1k prompts', ok: (a.userMessages || 0) >= 1000, s: '1k', ico: '◈' },
|
|
1155
|
+
{ t: '10k lines', ok: (a.linesAdded || 0) >= 10000, s: '10k', ico: '◍' },
|
|
1156
|
+
{ t: '100 sessions', ok: (a.sessions || 0) >= 100, s: '100', ico: '◎' },
|
|
1157
|
+
];
|
|
1158
|
+
const root = $('achievements');
|
|
1159
|
+
root.innerHTML = '';
|
|
1160
|
+
for (const it of list) {
|
|
1161
|
+
const el = document.createElement('div');
|
|
1162
|
+
el.className = 'achievement' + (it.ok ? ' unlocked' : '');
|
|
1163
|
+
el.innerHTML = '<span class="ico">' + it.ico + '</span><div class="t">' + it.t + '</div><div class="s">' + it.s + '</div>';
|
|
1164
|
+
root.appendChild(el);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// ── Cost panel ──────────────────────────────────────────
|
|
1169
|
+
function renderCost(a) {
|
|
1170
|
+
$('cost-figure').textContent = fmtCost(a.estimatedCost || 0);
|
|
1171
|
+
const hours = (a.activeMs || 0) / 3_600_000;
|
|
1172
|
+
const perHour = hours > 0.05 ? a.estimatedCost / hours : 0;
|
|
1173
|
+
$('cost-figure-sub').textContent = (perHour ? fmtCost(perHour) + ' / hour' : 'across the range');
|
|
1174
|
+
const byModel = a.costByModel || {};
|
|
1175
|
+
const entries = Object.entries(byModel).sort((x, y) => y[1] - x[1]).slice(0, 6);
|
|
1176
|
+
const total = entries.reduce((s, [, v]) => s + v, 0) || 1;
|
|
1177
|
+
const bars = $('cost-bars');
|
|
1178
|
+
bars.innerHTML = '';
|
|
1179
|
+
for (const [model, cost] of entries) {
|
|
1180
|
+
const w = Math.max(2, (cost / total) * 100);
|
|
1181
|
+
const row = document.createElement('div');
|
|
1182
|
+
row.className = 'cost-bar';
|
|
1183
|
+
row.innerHTML = '<span class="name">' + model + '</span>' +
|
|
1184
|
+
'<span class="track"><span class="fill" style="width:' + w.toFixed(0) + '%"></span></span>' +
|
|
1185
|
+
'<span class="val">' + fmtCost(cost) + '</span>';
|
|
1186
|
+
bars.appendChild(row);
|
|
1187
|
+
}
|
|
1188
|
+
if (!entries.length) bars.innerHTML = '<div style="color: var(--text-3); font-size: 12px;">No data in range</div>';
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// ── Languages panel ─────────────────────────────────────
|
|
1192
|
+
function renderLanguages(langs) {
|
|
1193
|
+
const entries = Object.entries(langs || {}).sort((x, y) => y[1].edits - x[1].edits).slice(0, 5);
|
|
1194
|
+
const total = entries.reduce((s, [, v]) => s + v.edits, 0) || 1;
|
|
1195
|
+
const stack = $('lang-stack');
|
|
1196
|
+
stack.innerHTML = '';
|
|
1197
|
+
for (const [name, v] of entries) {
|
|
1198
|
+
const span = document.createElement('span');
|
|
1199
|
+
span.style.background = LANGS[name] || '#888';
|
|
1200
|
+
span.style.width = ((v.edits / total) * 100).toFixed(2) + '%';
|
|
1201
|
+
span.title = name + ' · ' + v.edits;
|
|
1202
|
+
stack.appendChild(span);
|
|
1203
|
+
}
|
|
1204
|
+
const list = $('lang-list');
|
|
1205
|
+
list.innerHTML = '';
|
|
1206
|
+
for (const [name, v] of entries) {
|
|
1207
|
+
const row = document.createElement('div');
|
|
1208
|
+
row.className = 'row';
|
|
1209
|
+
row.innerHTML = '<span class="swatch" style="background:' + (LANGS[name] || '#888') + '"></span>' +
|
|
1210
|
+
'<span class="name">' + name + '</span>' +
|
|
1211
|
+
'<span class="val">' + fmtN(v.edits) + ' edits · ' + fmtN(v.files) + ' files</span>';
|
|
1212
|
+
list.appendChild(row);
|
|
1213
|
+
}
|
|
1214
|
+
if (!entries.length) list.innerHTML = '<div style="color: var(--text-3); font-size: 12px;">No language data yet</div>';
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// ── Discord rotation ────────────────────────────────────
|
|
1218
|
+
function renderRotation() {
|
|
1219
|
+
const live = allFrames.filter((f) => f.passes);
|
|
1220
|
+
if (live.length) {
|
|
1221
|
+
currentLiveIdx = currentLiveIdx % live.length;
|
|
1222
|
+
const f = live[currentLiveIdx];
|
|
1223
|
+
const liveOrder = allFrames.map((af, i) => af.passes ? i : -1).filter((i) => i >= 0);
|
|
1224
|
+
const allIdx = liveOrder[currentLiveIdx];
|
|
1225
|
+
// Mirror to both the top live rail and the bottom Discord card.
|
|
1226
|
+
$('frame-details').textContent = f.details || '—';
|
|
1227
|
+
$('frame-state').textContent = f.state || '—';
|
|
1228
|
+
$('frame-details-2').textContent = f.details || '—';
|
|
1229
|
+
$('frame-state-2').textContent = f.state || '—';
|
|
1230
|
+
$('frame-num').textContent = 'Frame ' + (allIdx + 1) + '/' + allFrames.length;
|
|
1231
|
+
$('frame-no').textContent = 'Frame ' + (allIdx + 1) + ' of ' + allFrames.length;
|
|
1232
|
+
}
|
|
1233
|
+
$('frames-live').textContent = live.length;
|
|
1234
|
+
$('frames-total').textContent = allFrames.length;
|
|
1235
|
+
const ul = $('rotation-list');
|
|
1236
|
+
ul.innerHTML = '';
|
|
1237
|
+
const liveOrder = allFrames.map((af, i) => af.passes ? i : -1).filter((i) => i >= 0);
|
|
1238
|
+
const onAir = liveOrder[currentLiveIdx];
|
|
1239
|
+
allFrames.forEach((f, i) => {
|
|
1240
|
+
const li = document.createElement('li');
|
|
1241
|
+
const isCurrent = i === onAir;
|
|
1242
|
+
li.className = isCurrent ? 'current' : f.passes ? 'live' : 'skip';
|
|
1243
|
+
const summary = f.passes ? ((f.details || '—') + (f.state ? ' · ' + f.state : '')) : (f.details || '—');
|
|
1244
|
+
li.innerHTML = '<span class="pip"></span><span class="frame-text">' + summary + '</span>';
|
|
1245
|
+
ul.appendChild(li);
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// ── Drawer (project) ────────────────────────────────────
|
|
1250
|
+
async function openProject(name) {
|
|
1251
|
+
location.hash = '#projects/' + encodeURIComponent(name);
|
|
1252
|
+
const p = (aggData?.projects || {})[name];
|
|
1253
|
+
if (!p) return;
|
|
1254
|
+
$('drawer-title').textContent = name;
|
|
1255
|
+
$('drawer-sub').textContent = p.sessions + ' sessions · ' + fmtH(p.activeMs) + ' active';
|
|
1256
|
+
$('drawer-body').innerHTML = [
|
|
1257
|
+
['Active time', fmtH(p.activeMs)],
|
|
1258
|
+
['Prompts', fmtN(p.userMessages)],
|
|
1259
|
+
['Tool calls', fmtN(p.toolCalls)],
|
|
1260
|
+
['Lines added', fmtN(p.linesAdded || 0)],
|
|
1261
|
+
['Lines removed', fmtN(p.linesRemoved || 0)],
|
|
1262
|
+
['Estimated cost', fmtCost(p.cost || 0)],
|
|
1263
|
+
['Tokens in', fmtN(p.inputTokens)],
|
|
1264
|
+
['Tokens out', fmtN(p.outputTokens)],
|
|
1265
|
+
].map(([k, v]) => '<div class="kv"><span class="k">' + k + '</span><span class="v">' + v + '</span></div>').join('');
|
|
1266
|
+
$('scrim').classList.add('open');
|
|
1267
|
+
$('drawer').classList.add('open');
|
|
1268
|
+
}
|
|
1269
|
+
function closeDrawer() {
|
|
1270
|
+
$('scrim').classList.remove('open');
|
|
1271
|
+
$('drawer').classList.remove('open');
|
|
1272
|
+
if (location.hash.startsWith('#projects/')) location.hash = '';
|
|
1273
|
+
}
|
|
1274
|
+
$('scrim').addEventListener('click', closeDrawer);
|
|
1275
|
+
$('drawer-close').addEventListener('click', closeDrawer);
|
|
1276
|
+
|
|
1277
|
+
// ── Modal (day) ─────────────────────────────────────────
|
|
1278
|
+
async function openDay(k) {
|
|
1279
|
+
location.hash = '#days/' + k;
|
|
1280
|
+
const day = (aggData?.byDay || {})[k];
|
|
1281
|
+
if (!day) {
|
|
1282
|
+
$('modal-title').textContent = k;
|
|
1283
|
+
$('modal-sub').textContent = 'No activity';
|
|
1284
|
+
$('modal-body').innerHTML = '';
|
|
1285
|
+
} else {
|
|
1286
|
+
$('modal-title').textContent = new Date(k + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
|
1287
|
+
$('modal-sub').textContent = fmtH(day.activeMs) + ' active · ' + (day.sessions || 0) + ' sessions';
|
|
1288
|
+
$('modal-body').innerHTML = [
|
|
1289
|
+
['Prompts', fmtN(day.userMessages)],
|
|
1290
|
+
['Tool calls', fmtN(day.toolCalls)],
|
|
1291
|
+
['Lines added', fmtN(day.linesAdded || 0)],
|
|
1292
|
+
['Lines removed', fmtN(day.linesRemoved || 0)],
|
|
1293
|
+
['Cost', fmtCost(day.cost || 0)],
|
|
1294
|
+
['Tokens', fmtN((day.inputTokens || 0) + (day.outputTokens || 0) + (day.cacheReadTokens || 0) + (day.cacheWriteTokens || 0))],
|
|
1295
|
+
['Notifications', day.notifications || 0],
|
|
1296
|
+
].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('');
|
|
1297
|
+
}
|
|
1298
|
+
$('modal').classList.add('open');
|
|
1299
|
+
$('scrim').classList.add('open');
|
|
1300
|
+
}
|
|
1301
|
+
function closeModal() {
|
|
1302
|
+
$('modal').classList.remove('open');
|
|
1303
|
+
$('scrim').classList.remove('open');
|
|
1304
|
+
if (location.hash.startsWith('#days/')) location.hash = '';
|
|
1305
|
+
}
|
|
1306
|
+
$('modal-close').addEventListener('click', closeModal);
|
|
1307
|
+
$('scrim').addEventListener('click', closeModal);
|
|
1308
|
+
|
|
1309
|
+
// ── Help ────────────────────────────────────────────────
|
|
1310
|
+
$('help').addEventListener('click', () => $('help').classList.remove('open'));
|
|
1311
|
+
document.addEventListener('keydown', (e) => {
|
|
1312
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
1313
|
+
if (e.key === '?') { e.preventDefault(); $('help').classList.toggle('open'); }
|
|
1314
|
+
if (e.key === 'Escape') { closeDrawer(); closeModal(); $('help').classList.remove('open'); }
|
|
1315
|
+
if (e.key === 't') {
|
|
1316
|
+
const cur = localStorage.getItem('theme') || 'dark';
|
|
1317
|
+
localStorage.setItem('theme', cur === 'dark' ? 'light' : 'dark'); applyTheme();
|
|
1318
|
+
}
|
|
1319
|
+
if (e.key >= '1' && e.key <= '5') {
|
|
1320
|
+
const pills = ['7d', '30d', '90d', '1y', 'all'];
|
|
1321
|
+
const target = document.querySelector('[data-range="' + pills[parseInt(e.key, 10) - 1] + '"]');
|
|
1322
|
+
if (target) target.click();
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
// ── State refresh ───────────────────────────────────────
|
|
1327
|
+
async function fetchAggregate() {
|
|
1328
|
+
try {
|
|
1329
|
+
const r = await fetch('/api/aggregate?range=' + range, { cache: 'no-store' });
|
|
1330
|
+
aggData = await r.json();
|
|
1331
|
+
drawAggregate();
|
|
1332
|
+
} catch (e) { console.error(e); }
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
async function fetchInsights() {
|
|
1336
|
+
try {
|
|
1337
|
+
const r = await fetch('/api/insights');
|
|
1338
|
+
const j = await r.json();
|
|
1339
|
+
const root = $('insights');
|
|
1340
|
+
root.innerHTML = '';
|
|
1341
|
+
for (const line of (j.insights || [])) {
|
|
1342
|
+
const el = document.createElement('div');
|
|
1343
|
+
el.className = 'insight';
|
|
1344
|
+
el.textContent = line;
|
|
1345
|
+
root.appendChild(el);
|
|
1346
|
+
}
|
|
1347
|
+
if (!(j.insights || []).length) root.innerHTML = '<div class="insight">Keep working — insights appear once you have a few days of activity.</div>';
|
|
1348
|
+
} catch (e) { console.error(e); }
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function drawAggregate() {
|
|
1352
|
+
if (!aggData) return;
|
|
1353
|
+
const days = range === '7d' ? 7 : range === '30d' ? 30 : range === '1y' ? 365 : range === 'all' ? 365 : 90;
|
|
1354
|
+
renderChart(aggData.byDay || {}, days);
|
|
1355
|
+
renderHeatmap(aggData.byDay || {});
|
|
1356
|
+
renderChurn(aggData.byDay || {}, Math.min(days, 90));
|
|
1357
|
+
renderCost(aggData);
|
|
1358
|
+
renderLanguages(aggData.languages);
|
|
1359
|
+
renderAchievements(aggData);
|
|
1360
|
+
|
|
1361
|
+
// Range stat card
|
|
1362
|
+
const [rn, ru] = splitTime(fmtH(aggData.activeMs || 0));
|
|
1363
|
+
$('range-num').textContent = rn;
|
|
1364
|
+
$('range-unit').textContent = ru === 'h' ? 'hrs' : ru;
|
|
1365
|
+
$('range-sub').textContent = fmtN(aggData.userMessages || 0) + ' prompts · ' + fmtN(aggData.grandTokens || 0) + ' tok';
|
|
1366
|
+
|
|
1367
|
+
// Range delta vs prior identical window
|
|
1368
|
+
// (approximation: today's value minus same-day-of-week last range)
|
|
1369
|
+
setDelta($('range-delta'), 0, 'range');
|
|
1370
|
+
|
|
1371
|
+
// Cost card
|
|
1372
|
+
$('cost-num').textContent = fmtCost(aggData.estimatedCost || 0);
|
|
1373
|
+
$('cost-sub').textContent = fmtN(aggData.grandTokens || 0) + ' tokens';
|
|
1374
|
+
|
|
1375
|
+
// Lifetime tokens card
|
|
1376
|
+
const grand = (aggData.inputTokens || 0) + (aggData.outputTokens || 0) + (aggData.cacheReadTokens || 0) + (aggData.cacheWriteTokens || 0);
|
|
1377
|
+
$('tok-grand').textContent = fmtN(grand);
|
|
1378
|
+
$('tok-out').textContent = fmtN(aggData.outputTokens || 0);
|
|
1379
|
+
const cache = (aggData.cacheReadTokens || 0) + (aggData.cacheWriteTokens || 0);
|
|
1380
|
+
$('tok-cache').textContent = fmtN(cache);
|
|
1381
|
+
$('tok-in-sub').textContent = 'input ' + fmtN(aggData.inputTokens || 0);
|
|
1382
|
+
$('tok-cache-sub').textContent = 'read ' + fmtN(aggData.cacheReadTokens || 0) + ' · write ' + fmtN(aggData.cacheWriteTokens || 0);
|
|
1383
|
+
$('tok-cache-pct').textContent = grand ? Math.round((cache / grand) * 100) + '%' : '0%';
|
|
1384
|
+
|
|
1385
|
+
// Code churn numbers
|
|
1386
|
+
$('churn-added').textContent = '+' + fmtN(aggData.linesAdded || 0);
|
|
1387
|
+
$('churn-removed').textContent = '−' + fmtN(aggData.linesRemoved || 0);
|
|
1388
|
+
const net = (aggData.linesAdded || 0) - (aggData.linesRemoved || 0);
|
|
1389
|
+
$('churn-net').textContent = (net >= 0 ? '+' : '−') + fmtN(Math.abs(net));
|
|
1390
|
+
|
|
1391
|
+
// Leaderboards
|
|
1392
|
+
const projs = Object.entries(aggData.projects || {}).sort((x, y) => y[1].activeMs - x[1].activeMs).slice(0, 8);
|
|
1393
|
+
renderTable('projects-tbl', projs.map(([name, p]) => {
|
|
1394
|
+
const h = p.activeMs / 3_600_000;
|
|
1395
|
+
const val = h < 1 ? Math.round(h * 60) : h < 10 ? h.toFixed(1) : Math.round(h);
|
|
1396
|
+
return { name, val: String(val), unit: h < 1 ? 'm' : 'h', onClick: () => openProject(name) };
|
|
1397
|
+
}));
|
|
1398
|
+
const tools = Object.entries(aggData.toolBreakdown || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
|
|
1399
|
+
renderTable('tools-tbl', tools.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })), { mono: true });
|
|
1400
|
+
const files = (aggData.topEditedFiles || []).slice(0, 8);
|
|
1401
|
+
renderTable('files-tbl', files.map((f) => ({ name: f.file || (f.path || '').split('/').pop(), val: fmtN(f.count), unit: '' })), { mono: true });
|
|
1402
|
+
|
|
1403
|
+
// Bash / domains / subagents
|
|
1404
|
+
const bash = Object.entries(aggData.bashCommands || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
|
|
1405
|
+
renderTable('bash-tbl', bash.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })), { mono: true });
|
|
1406
|
+
const domains = Object.entries(aggData.webDomains || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
|
|
1407
|
+
renderTable('domains-tbl', domains.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })), { mono: true });
|
|
1408
|
+
const sa = Object.entries(aggData.subagents || {}).sort((x, y) => y[1] - x[1]).slice(0, 8);
|
|
1409
|
+
renderTable('subagents-tbl', sa.map(([name, count]) => ({ name, val: fmtN(count), unit: '' })));
|
|
1410
|
+
|
|
1411
|
+
const tot = (aggData.mcpToolCalls || 0) + (aggData.builtinToolCalls || 0);
|
|
1412
|
+
$('mcp-label').textContent = tot ? Math.round(((aggData.mcpToolCalls || 0) / tot) * 100) + '% MCP · ' + Math.round(((aggData.builtinToolCalls || 0) / tot) * 100) + '% built-in' : '—';
|
|
1413
|
+
|
|
1414
|
+
$('lb-sessions').textContent = fmtN(aggData.sessions || 0);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function drawState() {
|
|
1418
|
+
if (!liveData) return;
|
|
1419
|
+
const a = liveData.aggregate;
|
|
1420
|
+
const v = liveData.vars;
|
|
1421
|
+
const s = liveData.state;
|
|
1422
|
+
|
|
1423
|
+
// Top bar
|
|
1424
|
+
const now = new Date();
|
|
1425
|
+
$('meta').textContent = 'No. ' + (v.daysSinceFirst || '—') + ' · ' + now.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
|
1426
|
+
$('model').textContent = v.modelPretty;
|
|
1427
|
+
$('statustext').textContent = v.statusVerbose;
|
|
1428
|
+
$('dot').className = 'dot ' + (s.status === 'working' || s.status === 'thinking' ? '' : s.status === 'idle' ? 'idle' : 'stale');
|
|
1429
|
+
|
|
1430
|
+
// Live avatar
|
|
1431
|
+
const cfgAvatar = (s.status && liveData.config?.statusAssets?.[s.status]) || '';
|
|
1432
|
+
$('live-avatar').innerHTML = cfgAvatar
|
|
1433
|
+
? '<img src="' + cfgAvatar.replace(/"/g, '"') + '" alt="" />'
|
|
1434
|
+
: '';
|
|
1435
|
+
$('elapsed').textContent = elapsedStr(s.sessionStart);
|
|
1436
|
+
|
|
1437
|
+
// Hero
|
|
1438
|
+
const [hn, hu] = splitTime(v.allHours);
|
|
1439
|
+
$('hero-num').textContent = hn;
|
|
1440
|
+
$('hero-unit').textContent = hu === 'h' ? 'hours' : hu === 'm' ? 'minutes' : hu;
|
|
1441
|
+
$('hero-caption').innerHTML =
|
|
1442
|
+
'on Claude Code · day <strong>' + (v.daysSinceFirst || 1) + '</strong> · ' +
|
|
1443
|
+
'<strong>' + (a.sessions || 0).toLocaleString() + '</strong> sessions · ' +
|
|
1444
|
+
'<strong>' + (a.userMessages || 0).toLocaleString() + '</strong> prompts.';
|
|
1445
|
+
|
|
1446
|
+
// Today
|
|
1447
|
+
const [tn, tu] = splitTime(v.todayHours);
|
|
1448
|
+
$('today-num').textContent = tn;
|
|
1449
|
+
$('today-unit').textContent = tu === 'h' ? 'hrs' : tu;
|
|
1450
|
+
$('today-sub').textContent = (v.todayPrompts || 0) + ' prompts · ' + (v.todayTokensFmt || '0');
|
|
1451
|
+
|
|
1452
|
+
const todayMs = ((a.byDay || {})[dayKey(Date.now())] || {}).activeMs || 0;
|
|
1453
|
+
const yest = new Date(); yest.setHours(0,0,0,0); yest.setDate(yest.getDate() - 1);
|
|
1454
|
+
const yMs = ((a.byDay || {})[dayKey(yest.getTime())] || {}).activeMs || 0;
|
|
1455
|
+
setDelta($('today-delta'), todayMs - yMs, 'vs yest.');
|
|
1456
|
+
|
|
1457
|
+
// Streak
|
|
1458
|
+
$('streak-num').textContent = v.streak;
|
|
1459
|
+
$('streak-sub').textContent = 'Longest ' + v.longestStreak + ' · best ' + (v.bestDayHours || '—');
|
|
1460
|
+
|
|
1461
|
+
// Discord
|
|
1462
|
+
allFrames = liveData.frames || [];
|
|
1463
|
+
renderRotation();
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// ── SSE ────────────────────────────────────────────────
|
|
1467
|
+
function startSse() {
|
|
1468
|
+
try {
|
|
1469
|
+
const ev = new EventSource('/events');
|
|
1470
|
+
ev.onmessage = async (e) => {
|
|
1471
|
+
try {
|
|
1472
|
+
const d = JSON.parse(e.data);
|
|
1473
|
+
if (d.type === 'state') await refreshState();
|
|
1474
|
+
if (d.type === 'aggregate') {
|
|
1475
|
+
await refreshState();
|
|
1476
|
+
await fetchAggregate();
|
|
1477
|
+
await fetchInsights();
|
|
1478
|
+
}
|
|
1479
|
+
} catch {}
|
|
1480
|
+
};
|
|
1481
|
+
ev.onerror = () => { $('conn-state').textContent = 'reconnecting'; setTimeout(() => { $('conn-state').textContent = 'live'; }, 4000); };
|
|
1482
|
+
} catch {}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
async function refreshState() {
|
|
1486
|
+
try {
|
|
1487
|
+
const r = await fetch('/api/state', { cache: 'no-store' });
|
|
1488
|
+
liveData = await r.json();
|
|
1489
|
+
drawState();
|
|
1490
|
+
} catch (e) { console.error(e); }
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// Elapsed tick — light, just updates the number.
|
|
1494
|
+
setInterval(() => {
|
|
1495
|
+
if (liveData?.state?.sessionStart) $('elapsed').textContent = elapsedStr(liveData.state.sessionStart);
|
|
1496
|
+
}, 1000);
|
|
1497
|
+
|
|
1498
|
+
// Rotation cycle
|
|
1499
|
+
rotationTimer = setInterval(() => { currentLiveIdx++; renderRotation(); }, 4000);
|
|
1500
|
+
|
|
1501
|
+
// Initial load.
|
|
1502
|
+
(async () => {
|
|
1503
|
+
await refreshState();
|
|
1504
|
+
await fetchAggregate();
|
|
1505
|
+
await fetchInsights();
|
|
1506
|
+
startSse();
|
|
1507
|
+
// Restore deep link.
|
|
1508
|
+
if (location.hash.startsWith('#projects/')) openProject(decodeURIComponent(location.hash.slice(10)));
|
|
1509
|
+
else if (location.hash.startsWith('#days/')) openDay(location.hash.slice(6));
|
|
1510
|
+
})();
|
|
1511
|
+
})();`;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// ── Server ───────────────────────────────────────────────────────────────────
|
|
1515
|
+
|
|
1516
|
+
function parseUrl(rawUrl) {
|
|
1517
|
+
const url = new URL(rawUrl, 'http://x');
|
|
1518
|
+
return { path: url.pathname, query: Object.fromEntries(url.searchParams) };
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const server = createServer((req, res) => {
|
|
1522
|
+
const { path, query } = parseUrl(req.url);
|
|
1523
|
+
const key = `${req.method} ${path}`;
|
|
1524
|
+
|
|
1525
|
+
// SSE endpoint.
|
|
1526
|
+
if (req.method === 'GET' && path === '/events') {
|
|
1527
|
+
res.writeHead(200, {
|
|
1528
|
+
'content-type': 'text/event-stream',
|
|
1529
|
+
'cache-control': 'no-store',
|
|
1530
|
+
'connection': 'keep-alive',
|
|
1531
|
+
});
|
|
1532
|
+
res.write(': hello\n\n');
|
|
1533
|
+
sseClients.add(res);
|
|
1534
|
+
req.on('close', () => sseClients.delete(res));
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Project drilldown.
|
|
1539
|
+
if (req.method === 'GET' && path.startsWith('/api/project/')) {
|
|
1540
|
+
const name = decodeURIComponent(path.slice('/api/project/'.length));
|
|
1541
|
+
const result = projectDrilldown(name);
|
|
1542
|
+
res.writeHead(result ? 200 : 404, JSON_HEADERS);
|
|
1543
|
+
res.end(JSON.stringify(result || { error: 'not found' }));
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// Day detail.
|
|
1548
|
+
if (req.method === 'GET' && path.startsWith('/api/day/')) {
|
|
1549
|
+
const day = decodeURIComponent(path.slice('/api/day/'.length));
|
|
1550
|
+
const result = dayDetail(day);
|
|
1551
|
+
res.writeHead(result ? 200 : 404, JSON_HEADERS);
|
|
1552
|
+
res.end(JSON.stringify(result || { error: 'not found' }));
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// Generic API routes.
|
|
1557
|
+
const handler = ROUTES.get(key);
|
|
1558
|
+
if (handler) return handler(req, res, { query });
|
|
1559
|
+
|
|
1560
|
+
if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
|
|
1561
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
1562
|
+
res.end(HTML);
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
res.writeHead(404).end('not found');
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
watchSources();
|
|
1570
|
+
|
|
1571
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
1572
|
+
const url = `http://127.0.0.1:${PORT}`;
|
|
1573
|
+
console.log(`◆ Claude RPC dashboard: ${url}`);
|
|
1574
|
+
console.log(' Ctrl-C to stop.');
|
|
1575
|
+
if (!process.env.CLAUDE_RPC_NO_OPEN) {
|
|
1576
|
+
const opener = process.platform === 'win32' ? `start "" "${url}"`
|
|
1577
|
+
: process.platform === 'darwin' ? `open "${url}"`
|
|
1578
|
+
: `xdg-open "${url}"`;
|
|
1579
|
+
exec(opener, () => {});
|
|
1580
|
+
}
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
process.on('SIGINT', () => process.exit(0));
|
|
1584
|
+
process.on('SIGTERM', () => process.exit(0));
|