clawculator 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1219 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { resolveModel, MODEL_PRICING, parseTranscript } = require('./analyzer');
8
+
9
+ // ── Helpers ──────────────────────────────────────────────
10
+ function modelLabel(m) {
11
+ const k = resolveModel(m);
12
+ return k ? (MODEL_PRICING[k]?.label || k) : (m || 'unknown');
13
+ }
14
+
15
+ // ── SQLite Setup ─────────────────────────────────────────
16
+ function initDB(dbPath) {
17
+ let Database;
18
+ try {
19
+ Database = require('better-sqlite3');
20
+ } catch {
21
+ console.error('\x1b[31mError:\x1b[0m better-sqlite3 not installed. Run: npm install better-sqlite3');
22
+ process.exit(1);
23
+ }
24
+
25
+ const db = new Database(dbPath);
26
+ db.pragma('journal_mode = WAL');
27
+
28
+ db.exec(`
29
+ CREATE TABLE IF NOT EXISTS daily_snapshots (
30
+ date TEXT PRIMARY KEY,
31
+ total_cost REAL DEFAULT 0,
32
+ total_messages INTEGER DEFAULT 0,
33
+ total_tokens INTEGER DEFAULT 0,
34
+ cache_read INTEGER DEFAULT 0,
35
+ cache_write INTEGER DEFAULT 0,
36
+ model_breakdown TEXT DEFAULT '{}',
37
+ session_breakdown TEXT DEFAULT '{}',
38
+ updated_at TEXT
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS events (
42
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43
+ timestamp TEXT,
44
+ session_name TEXT,
45
+ session_id TEXT,
46
+ model TEXT,
47
+ cost REAL,
48
+ input_tokens INTEGER,
49
+ output_tokens INTEGER,
50
+ cache_read INTEGER,
51
+ cache_write INTEGER,
52
+ total_tokens INTEGER
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS session_daily (
56
+ date TEXT,
57
+ session_id TEXT,
58
+ session_name TEXT,
59
+ model TEXT,
60
+ cost REAL DEFAULT 0,
61
+ messages INTEGER DEFAULT 0,
62
+ tokens INTEGER DEFAULT 0,
63
+ cache_read INTEGER DEFAULT 0,
64
+ cache_write INTEGER DEFAULT 0,
65
+ PRIMARY KEY (date, session_id)
66
+ );
67
+
68
+ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(timestamp);
69
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
70
+ `);
71
+
72
+ // Auto-prune events older than 14 days
73
+ db.exec(`DELETE FROM events WHERE timestamp < datetime('now', '-14 days')`);
74
+
75
+ return db;
76
+ }
77
+
78
+ // ── Parse single JSONL line ──────────────────────────────
79
+ function parseUsageLine(line) {
80
+ try {
81
+ const entry = JSON.parse(line);
82
+ if (entry.type !== 'message') return null;
83
+ const u = entry.usage || entry.message?.usage;
84
+ if (!u) return null;
85
+ const model = entry.model || entry.message?.model;
86
+ const ts = entry.timestamp || entry.message?.timestamp;
87
+ const cost = u.cost ? (typeof u.cost === 'object' ? u.cost.total || 0 : u.cost) : 0;
88
+ return {
89
+ model, cost,
90
+ input: u.input || 0, output: u.output || 0,
91
+ cacheRead: u.cacheRead || 0, cacheWrite: u.cacheWrite || 0,
92
+ totalTokens: u.totalTokens || 0,
93
+ timestamp: ts || new Date().toISOString(),
94
+ };
95
+ } catch { return null; }
96
+ }
97
+
98
+ // ── Web Dashboard Server ─────────────────────────────────
99
+ function startWebDashboard(opts = {}) {
100
+ const openclawHome = opts.openclawHome || process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
101
+ const port = opts.port || 3457;
102
+ const dbPath = path.join(openclawHome, 'clawculator.db');
103
+
104
+ const db = initDB(dbPath);
105
+
106
+ // Prepared statements
107
+ const upsertDaily = db.prepare(`
108
+ INSERT INTO daily_snapshots (date, total_cost, total_messages, total_tokens, cache_read, cache_write, model_breakdown, session_breakdown, updated_at)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
110
+ ON CONFLICT(date) DO UPDATE SET
111
+ total_cost=excluded.total_cost, total_messages=excluded.total_messages,
112
+ total_tokens=excluded.total_tokens, cache_read=excluded.cache_read,
113
+ cache_write=excluded.cache_write, model_breakdown=excluded.model_breakdown,
114
+ session_breakdown=excluded.session_breakdown, updated_at=excluded.updated_at
115
+ `);
116
+
117
+ const insertEvent = db.prepare(`
118
+ INSERT INTO events (timestamp, session_name, session_id, model, cost, input_tokens, output_tokens, cache_read, cache_write, total_tokens)
119
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
120
+ `);
121
+
122
+ const upsertSessionDaily = db.prepare(`
123
+ INSERT INTO session_daily (date, session_id, session_name, model, cost, messages, tokens, cache_read, cache_write)
124
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
125
+ ON CONFLICT(date, session_id) DO UPDATE SET
126
+ cost=cost+excluded.cost, messages=messages+excluded.messages,
127
+ tokens=tokens+excluded.tokens, cache_read=cache_read+excluded.cache_read,
128
+ cache_write=cache_write+excluded.cache_write,
129
+ model=excluded.model, session_name=excluded.session_name
130
+ `);
131
+
132
+ // ── State ──────────────────────────────────────────────
133
+ const sseClients = new Set();
134
+ const watchers = new Map();
135
+ const offsets = new Map();
136
+ const fileToSession = new Map();
137
+
138
+ // Today's live state
139
+ let today = { cost: 0, messages: 0, tokens: 0, cacheRead: 0, cacheWrite: 0, models: {}, sessions: {} };
140
+ let peakCostPerMsg = 0;
141
+ let recentEvents = [];
142
+ const MAX_RECENT = 50;
143
+
144
+ function todayStr() { return new Date().toISOString().slice(0, 10); }
145
+
146
+ // ── Session name resolution ────────────────────────────
147
+ function getSessionNames() {
148
+ const names = new Map();
149
+ const agentsDir = path.join(openclawHome, 'agents');
150
+ try {
151
+ for (const agent of fs.readdirSync(agentsDir)) {
152
+ const sjPath = path.join(agentsDir, agent, 'sessions', 'sessions.json');
153
+ if (!fs.existsSync(sjPath)) continue;
154
+ const sj = JSON.parse(fs.readFileSync(sjPath, 'utf8'));
155
+ for (const [key, val] of Object.entries(sj)) {
156
+ if (val.sessionId) {
157
+ const short = key.replace('agent:main:', '').replace(/:[a-f0-9-]{36}/g, '').replace(/:run$/, '');
158
+ if (!names.has(val.sessionId) || short.length < names.get(val.sessionId).length) {
159
+ names.set(val.sessionId, short);
160
+ }
161
+ }
162
+ }
163
+ }
164
+ } catch {}
165
+ return names;
166
+ }
167
+
168
+ // ── File watching ──────────────────────────────────────
169
+ function discoverFiles() {
170
+ const sessionNames = getSessionNames();
171
+ const agentsDir = path.join(openclawHome, 'agents');
172
+ try {
173
+ for (const agent of fs.readdirSync(agentsDir)) {
174
+ const sessDir = path.join(agentsDir, agent, 'sessions');
175
+ if (!fs.existsSync(sessDir)) continue;
176
+ for (const file of fs.readdirSync(sessDir)) {
177
+ if (!file.endsWith('.jsonl')) continue;
178
+ const filePath = path.join(sessDir, file);
179
+ if (watchers.has(filePath)) continue;
180
+
181
+ const sessionId = file.replace('.jsonl', '');
182
+ const friendlyName = sessionNames.get(sessionId) || sessionId.slice(0, 8);
183
+ fileToSession.set(filePath, { id: sessionId, name: friendlyName });
184
+
185
+ initialParse(filePath, sessionId, friendlyName);
186
+
187
+ try {
188
+ const watcher = fs.watch(filePath, () => tailFile(filePath));
189
+ watchers.set(filePath, watcher);
190
+ } catch {}
191
+ }
192
+ }
193
+ } catch {}
194
+ }
195
+
196
+ function initialParse(filePath, sessionId, friendlyName) {
197
+ try {
198
+ const content = fs.readFileSync(filePath, 'utf8');
199
+ const stat = fs.statSync(filePath);
200
+ offsets.set(filePath, stat.size);
201
+
202
+ const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
203
+ const todayMs = todayStart.getTime();
204
+
205
+ for (const line of content.split('\n')) {
206
+ if (!line.trim()) continue;
207
+ const usage = parseUsageLine(line);
208
+ if (!usage) continue;
209
+ const ts = new Date(usage.timestamp).getTime();
210
+ if (ts >= todayMs) {
211
+ recordEvent(usage, sessionId, friendlyName, false); // don't broadcast on init
212
+ }
213
+ }
214
+ } catch {}
215
+ }
216
+
217
+ function tailFile(filePath) {
218
+ try {
219
+ const stat = fs.statSync(filePath);
220
+ const prev = offsets.get(filePath) || 0;
221
+ if (stat.size <= prev) return;
222
+
223
+ const fd = fs.openSync(filePath, 'r');
224
+ const buf = Buffer.alloc(stat.size - prev);
225
+ fs.readSync(fd, buf, 0, buf.length, prev);
226
+ fs.closeSync(fd);
227
+ offsets.set(filePath, stat.size);
228
+
229
+ const session = fileToSession.get(filePath);
230
+ if (!session) return;
231
+
232
+ for (const line of buf.toString('utf8').split('\n')) {
233
+ if (!line.trim()) continue;
234
+ const usage = parseUsageLine(line);
235
+ if (!usage) continue;
236
+ recordEvent(usage, session.id, session.name, true);
237
+ }
238
+ } catch {}
239
+ }
240
+
241
+ // ── Record & broadcast ─────────────────────────────────
242
+ function recordEvent(usage, sessionId, sessionName, broadcast) {
243
+ const ml = modelLabel(usage.model);
244
+
245
+ // Update live state
246
+ today.cost += usage.cost;
247
+ today.messages++;
248
+ today.tokens += usage.totalTokens;
249
+ today.cacheRead += usage.cacheRead;
250
+ today.cacheWrite += usage.cacheWrite;
251
+ today.models[ml] = (today.models[ml] || 0) + usage.cost;
252
+
253
+ if (!today.sessions[sessionId]) {
254
+ today.sessions[sessionId] = { name: sessionName, model: ml, cost: 0, messages: 0, tokens: 0, lastSeen: null };
255
+ }
256
+ const sess = today.sessions[sessionId];
257
+ sess.cost += usage.cost;
258
+ sess.messages++;
259
+ sess.tokens += usage.totalTokens;
260
+ sess.model = ml;
261
+ sess.lastSeen = usage.timestamp;
262
+
263
+ if (usage.cost > peakCostPerMsg) peakCostPerMsg = usage.cost;
264
+
265
+ const event = {
266
+ time: usage.timestamp,
267
+ session: sessionName,
268
+ sessionId,
269
+ model: ml,
270
+ cost: usage.cost,
271
+ tokens: usage.totalTokens,
272
+ cacheWrite: usage.cacheWrite,
273
+ cacheRead: usage.cacheRead,
274
+ };
275
+ recentEvents.unshift(event);
276
+ if (recentEvents.length > MAX_RECENT) recentEvents.length = MAX_RECENT;
277
+
278
+ // Persist to SQLite
279
+ try {
280
+ insertEvent.run(usage.timestamp, sessionName, sessionId, usage.model, usage.cost,
281
+ usage.input, usage.output, usage.cacheRead, usage.cacheWrite, usage.totalTokens);
282
+
283
+ const d = todayStr();
284
+ upsertSessionDaily.run(d, sessionId, sessionName, usage.model, usage.cost, 1,
285
+ usage.totalTokens, usage.cacheRead, usage.cacheWrite);
286
+
287
+ upsertDaily.run(d, today.cost, today.messages, today.tokens, today.cacheRead,
288
+ today.cacheWrite, JSON.stringify(today.models), JSON.stringify(
289
+ Object.fromEntries(Object.entries(today.sessions).map(([k, v]) => [k, { name: v.name, cost: v.cost, messages: v.messages }]))
290
+ ), new Date().toISOString());
291
+ } catch {}
292
+
293
+ // Broadcast to SSE clients
294
+ if (broadcast) {
295
+ const payload = JSON.stringify({ type: 'event', event, today: getTodaySummary() });
296
+ for (const res of sseClients) {
297
+ try { res.write(`data: ${payload}\n\n`); } catch { sseClients.delete(res); }
298
+ }
299
+ }
300
+ }
301
+
302
+ function getTodaySummary() {
303
+ return {
304
+ cost: today.cost,
305
+ messages: today.messages,
306
+ tokens: today.tokens,
307
+ cacheRead: today.cacheRead,
308
+ cacheWrite: today.cacheWrite,
309
+ models: today.models,
310
+ sessions: Object.entries(today.sessions).map(([id, s]) => ({
311
+ id, name: s.name, model: s.model, cost: s.cost, messages: s.messages, tokens: s.tokens, lastSeen: s.lastSeen,
312
+ })).sort((a, b) => b.cost - a.cost),
313
+ peakCostPerMsg,
314
+ avgCostPerMsg: today.messages > 0 ? today.cost / today.messages : 0,
315
+ };
316
+ }
317
+
318
+ // ── API endpoints ──────────────────────────────────────
319
+ function getHistory(days = 30) {
320
+ try {
321
+ return db.prepare(`SELECT * FROM daily_snapshots ORDER BY date DESC LIMIT ?`).all(days);
322
+ } catch { return []; }
323
+ }
324
+
325
+ function getSessionHistory(days = 7) {
326
+ try {
327
+ return db.prepare(`SELECT * FROM session_daily WHERE date >= date('now', ?) ORDER BY date, cost DESC`).all(`-${days} days`);
328
+ } catch { return []; }
329
+ }
330
+
331
+ function getHourlyToday() {
332
+ try {
333
+ return db.prepare(`
334
+ SELECT strftime('%H', timestamp) as hour, SUM(cost) as cost, COUNT(*) as messages, SUM(total_tokens) as tokens
335
+ FROM events WHERE date(timestamp) = date('now') GROUP BY hour ORDER BY hour
336
+ `).all();
337
+ } catch { return []; }
338
+ }
339
+
340
+ function getTopMessages(limit = 10) {
341
+ try {
342
+ return db.prepare(`
343
+ SELECT timestamp, session_name, model, cost, total_tokens, cache_write
344
+ FROM events WHERE date(timestamp) = date('now') ORDER BY cost DESC LIMIT ?
345
+ `).all(limit);
346
+ } catch { return []; }
347
+ }
348
+
349
+ // ── HTTP Server ────────────────────────────────────────
350
+ const server = http.createServer((req, res) => {
351
+ const url = new URL(req.url, `http://localhost:${port}`);
352
+
353
+ // SSE endpoint
354
+ if (url.pathname === '/api/stream') {
355
+ res.writeHead(200, {
356
+ 'Content-Type': 'text/event-stream',
357
+ 'Cache-Control': 'no-cache',
358
+ 'Connection': 'keep-alive',
359
+ 'Access-Control-Allow-Origin': '*',
360
+ });
361
+ res.write(`data: ${JSON.stringify({ type: 'init', today: getTodaySummary(), recent: recentEvents.slice(0, 20) })}\n\n`);
362
+ sseClients.add(res);
363
+ req.on('close', () => sseClients.delete(res));
364
+ return;
365
+ }
366
+
367
+ // JSON API endpoints
368
+ if (url.pathname === '/api/today') {
369
+ res.writeHead(200, { 'Content-Type': 'application/json' });
370
+ res.end(JSON.stringify(getTodaySummary()));
371
+ return;
372
+ }
373
+
374
+ if (url.pathname === '/api/history') {
375
+ const days = parseInt(url.searchParams.get('days') || '30');
376
+ res.writeHead(200, { 'Content-Type': 'application/json' });
377
+ res.end(JSON.stringify(getHistory(days)));
378
+ return;
379
+ }
380
+
381
+ if (url.pathname === '/api/hourly') {
382
+ res.writeHead(200, { 'Content-Type': 'application/json' });
383
+ res.end(JSON.stringify(getHourlyToday()));
384
+ return;
385
+ }
386
+
387
+ if (url.pathname === '/api/top-messages') {
388
+ res.writeHead(200, { 'Content-Type': 'application/json' });
389
+ res.end(JSON.stringify(getTopMessages()));
390
+ return;
391
+ }
392
+
393
+ if (url.pathname === '/api/sessions') {
394
+ const days = parseInt(url.searchParams.get('days') || '7');
395
+ res.writeHead(200, { 'Content-Type': 'application/json' });
396
+ res.end(JSON.stringify(getSessionHistory(days)));
397
+ return;
398
+ }
399
+
400
+ // Serve dashboard HTML
401
+ if (url.pathname === '/' || url.pathname === '/index.html') {
402
+ res.writeHead(200, { 'Content-Type': 'text/html' });
403
+ res.end(getDashboardHTML());
404
+ return;
405
+ }
406
+
407
+ res.writeHead(404);
408
+ res.end('Not found');
409
+ });
410
+
411
+ // ── Start ──────────────────────────────────────────────
412
+ discoverFiles();
413
+
414
+ // Re-discover new files every 30s
415
+ setInterval(discoverFiles, 30000);
416
+
417
+ // Periodic snapshot save every 60s
418
+ setInterval(() => {
419
+ try {
420
+ const d = todayStr();
421
+ upsertDaily.run(d, today.cost, today.messages, today.tokens, today.cacheRead,
422
+ today.cacheWrite, JSON.stringify(today.models), JSON.stringify(
423
+ Object.fromEntries(Object.entries(today.sessions).map(([k, v]) => [k, { name: v.name, cost: v.cost, messages: v.messages }]))
424
+ ), new Date().toISOString());
425
+ } catch {}
426
+ }, 60000);
427
+
428
+ server.listen(port, '127.0.0.1', () => {
429
+ console.log(`\n\x1b[36m CLAWCULATOR WEB DASHBOARD\x1b[0m`);
430
+ console.log(`\x1b[90m ─────────────────────────────────\x1b[0m`);
431
+ console.log(` 🦞 Dashboard: \x1b[1m\x1b[36mhttp://localhost:${port}\x1b[0m`);
432
+ console.log(` 📊 Watching: ${watchers.size} transcript(s)`);
433
+ console.log(` 💾 Database: ${dbPath}`);
434
+ console.log(` 📅 Today: $${today.cost.toFixed(2)} across ${today.messages} messages`);
435
+ console.log(`\x1b[90m ─────────────────────────────────\x1b[0m`);
436
+ console.log(`\x1b[90m 💡 Pin this tab for always-on cost monitoring\x1b[0m`);
437
+ console.log(`\x1b[90m Press Ctrl+C to stop\x1b[0m\n`);
438
+
439
+ // Auto-open browser
440
+ const { exec } = require('child_process');
441
+ exec(`open "http://localhost:${port}" 2>/dev/null || xdg-open "http://localhost:${port}" 2>/dev/null`);
442
+ });
443
+
444
+ // Cleanup
445
+ process.on('SIGINT', () => {
446
+ console.log(`\n\x1b[36mClawculator Web\x1b[0m stopped. Today: $${today.cost.toFixed(2)} across ${today.messages} messages.\n`);
447
+ for (const [, w] of watchers) { try { w.close(); } catch {} }
448
+ try { db.close(); } catch {}
449
+ process.exit(0);
450
+ });
451
+
452
+ return { server, db };
453
+ }
454
+
455
+ // ── Dashboard HTML ───────────────────────────────────────
456
+ function getDashboardHTML() {
457
+ return `<!DOCTYPE html>
458
+ <html lang="en">
459
+ <head>
460
+ <meta charset="UTF-8">
461
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
462
+ <title>Clawculator — Live Cost Dashboard</title>
463
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🦞</text></svg>">
464
+ <style>
465
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700;800;900&display=swap');
466
+
467
+ :root {
468
+ --bg-deep: #05080f;
469
+ --bg-card: #0b1120;
470
+ --bg-card-hover: #0f1729;
471
+ --border: #1a2744;
472
+ --border-glow: #1e3a5f;
473
+ --cyan: #22d3ee;
474
+ --cyan-dim: #0891b2;
475
+ --amber: #f59e0b;
476
+ --amber-dim: #92400e;
477
+ --red: #ef4444;
478
+ --red-dim: #991b1b;
479
+ --green: #22c55e;
480
+ --green-dim: #166534;
481
+ --purple: #a78bfa;
482
+ --text: #e2e8f0;
483
+ --text-dim: #64748b;
484
+ --text-muted: #334155;
485
+ --glass: rgba(11, 17, 32, 0.8);
486
+ }
487
+
488
+ * { box-sizing: border-box; margin: 0; padding: 0; }
489
+
490
+ body {
491
+ font-family: 'Outfit', sans-serif;
492
+ background: var(--bg-deep);
493
+ color: var(--text);
494
+ min-height: 100vh;
495
+ overflow-x: hidden;
496
+ }
497
+
498
+ /* Animated background grid */
499
+ body::before {
500
+ content: '';
501
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
502
+ background:
503
+ linear-gradient(rgba(34,211,238,0.03) 1px, transparent 1px),
504
+ linear-gradient(90deg, rgba(34,211,238,0.03) 1px, transparent 1px);
505
+ background-size: 60px 60px;
506
+ z-index: 0;
507
+ animation: gridScroll 20s linear infinite;
508
+ }
509
+ @keyframes gridScroll { to { background-position: 60px 60px; } }
510
+
511
+ /* Glow orb in background */
512
+ body::after {
513
+ content: '';
514
+ position: fixed; top: -200px; right: -200px;
515
+ width: 600px; height: 600px;
516
+ background: radial-gradient(circle, rgba(34,211,238,0.08) 0%, transparent 70%);
517
+ z-index: 0;
518
+ animation: orbFloat 15s ease-in-out infinite alternate;
519
+ }
520
+ @keyframes orbFloat {
521
+ 0% { transform: translate(0, 0); }
522
+ 100% { transform: translate(-100px, 100px); }
523
+ }
524
+
525
+ .app { position: relative; z-index: 1; max-width: 1400px; margin: 0 auto; padding: 24px; }
526
+
527
+ /* Header */
528
+ .header {
529
+ display: flex; align-items: center; justify-content: space-between;
530
+ padding: 20px 0; margin-bottom: 24px;
531
+ border-bottom: 1px solid var(--border);
532
+ }
533
+ .logo-area { display: flex; align-items: center; gap: 16px; }
534
+ .logo-claw {
535
+ font-size: 48px;
536
+ animation: chomp 0.6s steps(2) infinite;
537
+ filter: drop-shadow(0 0 12px rgba(34,211,238,0.3));
538
+ }
539
+ @keyframes chomp {
540
+ 0%, 100% { transform: scaleX(1); }
541
+ 50% { transform: scaleX(0.9); }
542
+ }
543
+ .logo-text {
544
+ font-family: 'JetBrains Mono', monospace;
545
+ font-weight: 800; font-size: 28px; letter-spacing: -1px;
546
+ background: linear-gradient(135deg, var(--cyan), #818cf8);
547
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
548
+ }
549
+ .logo-sub { font-size: 13px; color: var(--text-dim); margin-top: 2px; }
550
+ .header-right { text-align: right; }
551
+ .live-dot {
552
+ display: inline-block; width: 8px; height: 8px; border-radius: 50%;
553
+ background: var(--green); margin-right: 6px;
554
+ animation: pulse 2s ease-in-out infinite;
555
+ }
556
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
557
+ .header-time { font-family: 'JetBrains Mono', monospace; font-size: 13px; color: var(--text-dim); }
558
+
559
+ /* Pac-Claw Chase */
560
+ .pacclaw-track {
561
+ position: absolute; top: 14px; left: 140px; right: 200px; height: 50px;
562
+ overflow: hidden; pointer-events: none;
563
+ }
564
+ .pacclaw-claw {
565
+ position: absolute; left: -40px; top: 8px; font-size: 32px; z-index: 2;
566
+ animation: clawRun 12s linear infinite;
567
+ filter: drop-shadow(0 0 8px rgba(34,211,238,0.4));
568
+ }
569
+ .pacclaw-claw .claw-top {
570
+ display: block; transform-origin: bottom center;
571
+ animation: clawChomp 0.3s steps(2) infinite;
572
+ line-height: 0.5;
573
+ }
574
+ .pacclaw-claw .claw-bot {
575
+ display: block; transform-origin: top center;
576
+ animation: clawChompBot 0.3s steps(2) infinite;
577
+ line-height: 0.5;
578
+ }
579
+ @keyframes clawChomp {
580
+ 0%, 100% { transform: rotate(0deg); }
581
+ 50% { transform: rotate(-15deg); }
582
+ }
583
+ @keyframes clawChompBot {
584
+ 0%, 100% { transform: rotate(0deg); }
585
+ 50% { transform: rotate(15deg); }
586
+ }
587
+ @keyframes clawRun {
588
+ 0% { left: -40px; }
589
+ 100% { left: calc(100% + 40px); }
590
+ }
591
+ .pacclaw-penny {
592
+ position: absolute; top: 12px; font-size: 22px; z-index: 1;
593
+ transition: opacity 0.15s, transform 0.15s;
594
+ }
595
+ .pacclaw-penny.eaten {
596
+ opacity: 0 !important;
597
+ transform: scale(0.3) !important;
598
+ }
599
+ /* Speed up claw on cost spike */
600
+ .pacclaw-track.turbo .pacclaw-claw {
601
+ animation-duration: 4s;
602
+ filter: drop-shadow(0 0 16px rgba(239,68,68,0.6));
603
+ }
604
+
605
+ /* Cards */
606
+ .card {
607
+ background: var(--bg-card);
608
+ border: 1px solid var(--border);
609
+ border-radius: 16px;
610
+ padding: 24px;
611
+ transition: all 0.3s ease;
612
+ position: relative; overflow: hidden;
613
+ }
614
+ .card::before {
615
+ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
616
+ background: linear-gradient(90deg, transparent, var(--cyan), transparent);
617
+ opacity: 0; transition: opacity 0.3s;
618
+ }
619
+ .card:hover::before { opacity: 1; }
620
+ .card:hover { border-color: var(--border-glow); }
621
+ .card-label { font-size: 12px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 8px; }
622
+ .card-value {
623
+ font-family: 'JetBrains Mono', monospace;
624
+ font-weight: 800; font-size: 36px; line-height: 1;
625
+ transition: all 0.4s ease;
626
+ }
627
+ .card-sub { font-size: 13px; color: var(--text-dim); margin-top: 8px; }
628
+
629
+ /* Big numbers grid */
630
+ .stats-grid {
631
+ display: grid;
632
+ grid-template-columns: 2fr 1fr 1fr 1fr;
633
+ gap: 16px; margin-bottom: 24px;
634
+ }
635
+ .stat-primary .card-value { font-size: 52px; }
636
+
637
+ /* Charts area */
638
+ .charts-grid {
639
+ display: grid;
640
+ grid-template-columns: 2fr 1fr;
641
+ gap: 16px; margin-bottom: 24px;
642
+ }
643
+
644
+ /* Session table */
645
+ .sessions-table { width: 100%; border-collapse: collapse; }
646
+ .sessions-table th {
647
+ font-size: 11px; font-weight: 600; color: var(--text-dim);
648
+ text-transform: uppercase; letter-spacing: 1px;
649
+ padding: 12px 16px; text-align: left;
650
+ border-bottom: 1px solid var(--border);
651
+ }
652
+ .sessions-table td {
653
+ padding: 12px 16px; font-size: 14px;
654
+ border-bottom: 1px solid var(--text-muted);
655
+ font-family: 'JetBrains Mono', monospace;
656
+ }
657
+ .sessions-table tr:hover { background: var(--bg-card-hover); }
658
+
659
+ /* Live feed */
660
+ .feed-item {
661
+ display: grid;
662
+ grid-template-columns: 80px 120px 1fr 80px 80px;
663
+ gap: 8px; padding: 8px 12px;
664
+ font-family: 'JetBrains Mono', monospace;
665
+ font-size: 12px; border-bottom: 1px solid var(--text-muted);
666
+ animation: feedSlide 0.3s ease-out;
667
+ }
668
+ @keyframes feedSlide { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
669
+
670
+ /* Donut chart */
671
+ .donut-container { display: flex; align-items: center; gap: 24px; }
672
+ .donut-svg { width: 160px; height: 160px; }
673
+ .donut-legend { flex: 1; }
674
+ .legend-item { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; }
675
+ .legend-dot { width: 10px; height: 10px; border-radius: 50%; }
676
+
677
+ /* Bar chart */
678
+ .bar-chart { display: flex; align-items: flex-end; gap: 4px; height: 200px; padding-top: 20px; }
679
+ .bar-group { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; }
680
+ .bar {
681
+ width: 100%; border-radius: 4px 4px 0 0;
682
+ background: linear-gradient(180deg, var(--cyan), var(--cyan-dim));
683
+ transition: height 0.6s ease;
684
+ min-height: 2px; position: relative;
685
+ }
686
+ .bar:hover { filter: brightness(1.3); }
687
+ .bar-label { font-size: 10px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; }
688
+ .bar-value {
689
+ font-size: 10px; color: var(--cyan); font-family: 'JetBrains Mono', monospace;
690
+ position: absolute; top: -16px; left: 50%; transform: translateX(-50%); white-space: nowrap;
691
+ }
692
+
693
+ /* Heat map */
694
+ .heatmap { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; }
695
+ .heat-cell {
696
+ aspect-ratio: 1; border-radius: 3px;
697
+ border: 1px solid var(--text-muted);
698
+ transition: all 0.2s; cursor: pointer;
699
+ position: relative;
700
+ }
701
+ .heat-cell:hover { border-color: var(--cyan); transform: scale(1.2); z-index: 2; }
702
+ .heat-cell[title]:hover::after {
703
+ content: attr(title); position: absolute; bottom: 110%; left: 50%; transform: translateX(-50%);
704
+ background: var(--bg-card); color: var(--text); padding: 4px 8px; border-radius: 4px;
705
+ font-size: 11px; white-space: nowrap; border: 1px solid var(--border);
706
+ font-family: 'JetBrains Mono', monospace; z-index: 10;
707
+ }
708
+
709
+ /* Meter / gauge */
710
+ .gauge-container { display: flex; flex-direction: column; align-items: center; }
711
+ .gauge-svg { width: 200px; height: 120px; }
712
+ .gauge-label { font-size: 13px; color: var(--text-dim); margin-top: 8px; }
713
+
714
+ /* Velocity indicator */
715
+ .velocity { display: flex; align-items: center; gap: 6px; font-size: 13px; }
716
+ .velocity-arrow { font-size: 16px; }
717
+ .velocity-up { color: var(--red); }
718
+ .velocity-down { color: var(--green); }
719
+ .velocity-flat { color: var(--text-dim); }
720
+
721
+ /* Leaderboard */
722
+ .leaderboard-item {
723
+ display: flex; align-items: center; gap: 12px;
724
+ padding: 10px 0; border-bottom: 1px solid var(--text-muted);
725
+ }
726
+ .leaderboard-rank {
727
+ width: 28px; height: 28px; border-radius: 50%;
728
+ display: flex; align-items: center; justify-content: center;
729
+ font-weight: 800; font-size: 13px;
730
+ font-family: 'JetBrains Mono', monospace;
731
+ }
732
+ .rank-1 { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #000; }
733
+ .rank-2 { background: linear-gradient(135deg, #94a3b8, #64748b); color: #000; }
734
+ .rank-3 { background: linear-gradient(135deg, #c2855a, #a0714a); color: #000; }
735
+ .rank-n { background: var(--text-muted); color: var(--text-dim); }
736
+
737
+ /* Responsive */
738
+ @media (max-width: 900px) {
739
+ .stats-grid { grid-template-columns: 1fr 1fr; }
740
+ .charts-grid { grid-template-columns: 1fr; }
741
+ .stat-primary .card-value { font-size: 36px; }
742
+ }
743
+
744
+ /* Counter animation */
745
+ .counter-animate {
746
+ display: inline-block;
747
+ transition: transform 0.2s ease;
748
+ }
749
+ .counter-animate.bump { transform: scale(1.1); }
750
+
751
+ /* Sections */
752
+ .section-title {
753
+ font-size: 14px; font-weight: 700; color: var(--text-dim);
754
+ text-transform: uppercase; letter-spacing: 2px;
755
+ margin-bottom: 16px;
756
+ display: flex; align-items: center; gap: 8px;
757
+ }
758
+ .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
759
+
760
+ /* No data state */
761
+ .no-data { text-align: center; padding: 40px; color: var(--text-dim); font-size: 14px; }
762
+ </style>
763
+ </head>
764
+ <body>
765
+ <div class="app">
766
+
767
+ <!-- Header -->
768
+ <div class="header">
769
+ <div class="logo-area">
770
+ <div class="logo-claw">🦞</div>
771
+ <div>
772
+ <div class="logo-text">CLAWCULATOR</div>
773
+ <div class="logo-sub">Your friendly penny pincher</div>
774
+ </div>
775
+ </div>
776
+ <div class="pacclaw-track" id="pacclawTrack">
777
+ <div class="pacclaw-claw" id="pacclawClaw"><span class="claw-top">🦞</span></div>
778
+ </div>
779
+ <div class="header-right">
780
+ <div><span class="live-dot"></span><span style="color:var(--green); font-weight:600; font-size:13px;">LIVE</span></div>
781
+ <div class="header-time" id="clock"></div>
782
+ </div>
783
+ </div>
784
+
785
+ <!-- Big Numbers -->
786
+ <div class="stats-grid">
787
+ <div class="card stat-primary">
788
+ <div class="card-label">Today's Spend</div>
789
+ <div class="card-value counter-animate" id="todayCost" style="color:var(--amber)">$0.00</div>
790
+ <div class="card-sub" id="todayVsYesterday"></div>
791
+ </div>
792
+ <div class="card">
793
+ <div class="card-label">Messages</div>
794
+ <div class="card-value counter-animate" id="todayMessages" style="color:var(--text)">0</div>
795
+ <div class="card-sub" id="avgCost"></div>
796
+ </div>
797
+ <div class="card">
798
+ <div class="card-label">Peak $/msg</div>
799
+ <div class="card-value counter-animate" id="peakCost" style="color:var(--red)">$0.00</div>
800
+ <div class="card-sub">Most expensive single call</div>
801
+ </div>
802
+ <div class="card">
803
+ <div class="card-label">Burn Rate</div>
804
+ <div class="card-value counter-animate" id="burnRate" style="color:var(--purple)">—</div>
805
+ <div class="card-sub" id="projectedMonth"></div>
806
+ </div>
807
+ </div>
808
+
809
+ <!-- Charts Row -->
810
+ <div class="charts-grid">
811
+ <div class="card">
812
+ <div class="section-title">📊 Hourly Cost Today</div>
813
+ <div class="bar-chart" id="hourlyChart"></div>
814
+ </div>
815
+ <div class="card">
816
+ <div class="section-title">🧠 Model Mix</div>
817
+ <div class="donut-container" id="modelDonut">
818
+ <div class="no-data">Waiting for data...</div>
819
+ </div>
820
+ </div>
821
+ </div>
822
+
823
+ <!-- Sessions + Feed -->
824
+ <div class="two-col">
825
+ <div class="card">
826
+ <div class="section-title">⚡ Active Sessions</div>
827
+ <table class="sessions-table">
828
+ <thead><tr><th>Session</th><th>Model</th><th>Msgs</th><th>Cost</th></tr></thead>
829
+ <tbody id="sessionsBody"></tbody>
830
+ </table>
831
+ </div>
832
+ <div class="card">
833
+ <div class="section-title">🏆 Costliest Calls Today</div>
834
+ <div id="leaderboard"></div>
835
+ </div>
836
+ </div>
837
+
838
+ <!-- History + Heat Map -->
839
+ <div class="two-col">
840
+ <div class="card">
841
+ <div class="section-title">📅 Daily History</div>
842
+ <div class="bar-chart" id="historyChart" style="height:160px"></div>
843
+ </div>
844
+ <div class="card">
845
+ <div class="section-title">🔥 Spend Heat Map (30 days)</div>
846
+ <div class="heatmap" id="heatmap"></div>
847
+ </div>
848
+ </div>
849
+
850
+ <!-- Live Feed -->
851
+ <div class="card" style="margin-bottom:24px">
852
+ <div class="section-title">🌊 Live Feed</div>
853
+ <div id="liveFeed"></div>
854
+ </div>
855
+
856
+ </div>
857
+
858
+ <script>
859
+ // ── State ─────────────────────────────────────────────
860
+ let state = { cost: 0, messages: 0, tokens: 0, cacheRead: 0, cacheWrite: 0, models: {}, sessions: [], peakCostPerMsg: 0, avgCostPerMsg: 0 };
861
+ let recentEvents = [];
862
+ let historyData = [];
863
+
864
+ // ── Helpers ───────────────────────────────────────────
865
+ function fmtCost(n) {
866
+ if (n >= 1) return '$' + n.toFixed(2);
867
+ if (n >= 0.01) return '$' + n.toFixed(4);
868
+ return '$' + n.toFixed(6);
869
+ }
870
+ function fmtTokens(n) {
871
+ if (n >= 1e6) return (n/1e6).toFixed(1) + 'M';
872
+ if (n >= 1e3) return (n/1e3).toFixed(1) + 'K';
873
+ return String(n);
874
+ }
875
+
876
+ // ── Pac-Claw Chase Engine ─────────────────────────────
877
+ const pacTrack = document.getElementById('pacclawTrack');
878
+ const pacClaw = document.getElementById('pacclawClaw');
879
+ let pacPennies = [];
880
+ let pacTurboTimeout = null;
881
+
882
+ function spawnPacPennies() {
883
+ // Clear old pennies
884
+ pacPennies.forEach(p => p.el.remove());
885
+ pacPennies = [];
886
+ const trackWidth = pacTrack.offsetWidth || 800;
887
+ const count = 8 + Math.floor(Math.random() * 5);
888
+ for (let i = 0; i < count; i++) {
889
+ const el = document.createElement('span');
890
+ el.className = 'pacclaw-penny';
891
+ el.textContent = '🪙';
892
+ const x = 40 + (i / count) * (trackWidth - 80);
893
+ el.style.left = x + 'px';
894
+ el.style.opacity = '0.6';
895
+ pacTrack.appendChild(el);
896
+ pacPennies.push({ el, x, eaten: false });
897
+ }
898
+ }
899
+ spawnPacPennies();
900
+
901
+ // Check claw position and eat pennies
902
+ function pacClawLoop() {
903
+ if (!pacClaw) return;
904
+ const clawRect = pacClaw.getBoundingClientRect();
905
+ const trackRect = pacTrack.getBoundingClientRect();
906
+ const clawX = clawRect.left - trackRect.left + clawRect.width / 2;
907
+
908
+ for (const penny of pacPennies) {
909
+ if (!penny.eaten && Math.abs(penny.x - clawX) < 22) {
910
+ penny.eaten = true;
911
+ penny.el.classList.add('eaten');
912
+ }
913
+ }
914
+
915
+ // Respawn when claw completes a lap (all eaten or claw past right edge)
916
+ const allEaten = pacPennies.every(p => p.eaten);
917
+ if (allEaten || clawX > (pacTrack.offsetWidth || 800) + 30) {
918
+ setTimeout(spawnPacPennies, 500);
919
+ }
920
+
921
+ requestAnimationFrame(pacClawLoop);
922
+ }
923
+ requestAnimationFrame(pacClawLoop);
924
+
925
+ // Turbo mode on cost events
926
+ function triggerPacTurbo() {
927
+ pacTrack.classList.add('turbo');
928
+ // Spawn extra pennies during turbo
929
+ const trackWidth = pacTrack.offsetWidth || 800;
930
+ for (let i = 0; i < 3; i++) {
931
+ const el = document.createElement('span');
932
+ el.className = 'pacclaw-penny';
933
+ el.textContent = '🪙';
934
+ const x = Math.random() * trackWidth;
935
+ el.style.left = x + 'px';
936
+ el.style.opacity = '0.8';
937
+ pacTrack.appendChild(el);
938
+ pacPennies.push({ el, x, eaten: false });
939
+ }
940
+ clearTimeout(pacTurboTimeout);
941
+ pacTurboTimeout = setTimeout(() => pacTrack.classList.remove('turbo'), 5000);
942
+ }
943
+
944
+ // ── Clock ─────────────────────────────────────────────
945
+ setInterval(() => {
946
+ document.getElementById('clock').textContent = new Date().toLocaleTimeString();
947
+ }, 1000);
948
+ document.getElementById('clock').textContent = new Date().toLocaleTimeString();
949
+
950
+ // ── Render Functions ──────────────────────────────────
951
+ function updateBigNumbers() {
952
+ const costEl = document.getElementById('todayCost');
953
+ costEl.textContent = fmtCost(state.cost);
954
+ costEl.style.color = state.cost > 10 ? 'var(--red)' : state.cost > 1 ? 'var(--amber)' : 'var(--green)';
955
+ bump(costEl);
956
+
957
+ const msgEl = document.getElementById('todayMessages');
958
+ msgEl.textContent = state.messages;
959
+ bump(msgEl);
960
+
961
+ const peakEl = document.getElementById('peakCost');
962
+ peakEl.textContent = fmtCost(state.peakCostPerMsg);
963
+ bump(peakEl);
964
+
965
+ document.getElementById('avgCost').textContent = 'Avg: ' + fmtCost(state.avgCostPerMsg) + '/msg';
966
+
967
+ // Burn rate
968
+ const now = new Date();
969
+ const hoursElapsed = now.getHours() + now.getMinutes() / 60;
970
+ if (hoursElapsed > 0.5 && state.cost > 0) {
971
+ const hourlyRate = state.cost / hoursElapsed;
972
+ const dailyProjection = hourlyRate * 24;
973
+ document.getElementById('burnRate').textContent = fmtCost(hourlyRate) + '/hr';
974
+ document.getElementById('projectedMonth').textContent = '~' + fmtCost(dailyProjection * 30) + '/month projected';
975
+ }
976
+ }
977
+
978
+ function bump(el) {
979
+ el.classList.remove('bump');
980
+ void el.offsetWidth;
981
+ el.classList.add('bump');
982
+ setTimeout(() => el.classList.remove('bump'), 300);
983
+ }
984
+
985
+ function renderSessions() {
986
+ const tbody = document.getElementById('sessionsBody');
987
+ if (!state.sessions.length) {
988
+ tbody.innerHTML = '<tr><td colspan="4" style="color:var(--text-dim);text-align:center;padding:20px;">Waiting for API calls...</td></tr>';
989
+ return;
990
+ }
991
+ tbody.innerHTML = state.sessions.slice(0, 8).map(s => {
992
+ const costColor = s.cost > 5 ? 'var(--red)' : s.cost > 0.5 ? 'var(--amber)' : 'var(--green)';
993
+ return '<tr>' +
994
+ '<td style="color:var(--cyan)">' + (s.name.length > 18 ? s.name.slice(0,16)+'…' : s.name) + '</td>' +
995
+ '<td style="color:var(--text-dim)">' + (s.model || '—') + '</td>' +
996
+ '<td>' + s.messages + '</td>' +
997
+ '<td style="color:' + costColor + '">' + fmtCost(s.cost) + '</td>' +
998
+ '</tr>';
999
+ }).join('');
1000
+ }
1001
+
1002
+ function renderModelDonut() {
1003
+ const container = document.getElementById('modelDonut');
1004
+ const models = Object.entries(state.models).sort((a,b) => b[1] - a[1]);
1005
+ if (!models.length) { container.innerHTML = '<div class="no-data">Waiting for data...</div>'; return; }
1006
+
1007
+ const total = models.reduce((s, [,v]) => s + v, 0);
1008
+ const colors = ['#22d3ee', '#a78bfa', '#f59e0b', '#ef4444', '#22c55e', '#f97316'];
1009
+ let cumAngle = 0;
1010
+ const paths = models.map(([name, cost], i) => {
1011
+ const pct = cost / total;
1012
+ const startAngle = cumAngle;
1013
+ cumAngle += pct * 360;
1014
+ const endAngle = cumAngle;
1015
+ const r = 60, cx = 80, cy = 80;
1016
+ const x1 = cx + r * Math.cos((startAngle - 90) * Math.PI / 180);
1017
+ const y1 = cy + r * Math.sin((startAngle - 90) * Math.PI / 180);
1018
+ const x2 = cx + r * Math.cos((endAngle - 90) * Math.PI / 180);
1019
+ const y2 = cy + r * Math.sin((endAngle - 90) * Math.PI / 180);
1020
+ const large = pct > 0.5 ? 1 : 0;
1021
+ const ri = 35;
1022
+ const x3 = cx + ri * Math.cos((endAngle - 90) * Math.PI / 180);
1023
+ const y3 = cy + ri * Math.sin((endAngle - 90) * Math.PI / 180);
1024
+ const x4 = cx + ri * Math.cos((startAngle - 90) * Math.PI / 180);
1025
+ const y4 = cy + ri * Math.sin((startAngle - 90) * Math.PI / 180);
1026
+ return { path: 'M'+x1+','+y1+' A'+r+','+r+' 0 '+large+' 1 '+x2+','+y2+' L'+x3+','+y3+' A'+ri+','+ri+' 0 '+large+' 0 '+x4+','+y4+' Z', color: colors[i % colors.length], name, cost, pct };
1027
+ });
1028
+
1029
+ container.innerHTML =
1030
+ '<svg class="donut-svg" viewBox="0 0 160 160">' +
1031
+ paths.map(p => '<path d="'+p.path+'" fill="'+p.color+'" opacity="0.85"><title>'+p.name+': '+fmtCost(p.cost)+' ('+(p.pct*100).toFixed(1)+'%)</title></path>').join('') +
1032
+ '<text x="80" y="78" text-anchor="middle" fill="white" font-family="JetBrains Mono" font-weight="800" font-size="16">'+fmtCost(total)+'</text>' +
1033
+ '<text x="80" y="94" text-anchor="middle" fill="#64748b" font-family="JetBrains Mono" font-size="10">total</text>' +
1034
+ '</svg>' +
1035
+ '<div class="donut-legend">' +
1036
+ paths.map(p => '<div class="legend-item"><div class="legend-dot" style="background:'+p.color+'"></div><span style="color:var(--text-dim)">'+p.name+'</span><span style="margin-left:auto;font-family:JetBrains Mono;font-size:12px;color:'+p.color+'">'+fmtCost(p.cost)+'</span></div>').join('') +
1037
+ '</div>';
1038
+ }
1039
+
1040
+ function renderHourlyChart() {
1041
+ fetch('/api/hourly').then(r => r.json()).then(data => {
1042
+ const chart = document.getElementById('hourlyChart');
1043
+ if (!data.length) { chart.innerHTML = '<div class="no-data">No hourly data yet</div>'; return; }
1044
+ const max = Math.max(...data.map(d => d.cost), 0.01);
1045
+ const hours = Array.from({length: 24}, (_, i) => String(i).padStart(2, '0'));
1046
+ const dataMap = Object.fromEntries(data.map(d => [d.hour, d]));
1047
+
1048
+ chart.innerHTML = hours.map(h => {
1049
+ const d = dataMap[h];
1050
+ const cost = d ? d.cost : 0;
1051
+ const height = Math.max(2, (cost / max) * 180);
1052
+ const now = new Date().getHours();
1053
+ const isCurrent = parseInt(h) === now;
1054
+ const color = isCurrent ? 'var(--amber)' : 'var(--cyan)';
1055
+ return '<div class="bar-group">' +
1056
+ '<div class="bar" style="height:'+height+'px;background:linear-gradient(180deg,'+color+','+color+'44)">' +
1057
+ (cost > 0 ? '<div class="bar-value">'+fmtCost(cost)+'</div>' : '') +
1058
+ '</div>' +
1059
+ '<div class="bar-label"'+(isCurrent?' style="color:var(--amber);font-weight:700"':'')+'>'+h+'</div>' +
1060
+ '</div>';
1061
+ }).join('');
1062
+ }).catch(() => {});
1063
+ }
1064
+
1065
+ function renderHistory() {
1066
+ fetch('/api/history?days=14').then(r => r.json()).then(data => {
1067
+ historyData = data;
1068
+ const chart = document.getElementById('historyChart');
1069
+ if (!data.length) { chart.innerHTML = '<div class="no-data">No history yet — data builds over time</div>'; return; }
1070
+ const sorted = [...data].sort((a,b) => a.date.localeCompare(b.date));
1071
+ const max = Math.max(...sorted.map(d => d.total_cost), 0.01);
1072
+
1073
+ chart.innerHTML = sorted.map(d => {
1074
+ const height = Math.max(2, (d.total_cost / max) * 140);
1075
+ const isToday = d.date === new Date().toISOString().slice(0,10);
1076
+ const color = isToday ? 'var(--amber)' : 'var(--cyan)';
1077
+ const label = d.date.slice(5);
1078
+ return '<div class="bar-group">' +
1079
+ '<div class="bar" style="height:'+height+'px;background:linear-gradient(180deg,'+color+','+color+'44)" title="'+d.date+': '+fmtCost(d.total_cost)+' ('+d.total_messages+' msgs)">' +
1080
+ '<div class="bar-value">'+fmtCost(d.total_cost)+'</div>' +
1081
+ '</div>' +
1082
+ '<div class="bar-label"'+(isToday?' style="color:var(--amber);font-weight:700"':'')+'>'+label+'</div>' +
1083
+ '</div>';
1084
+ }).join('');
1085
+
1086
+ // Yesterday comparison
1087
+ if (sorted.length >= 2) {
1088
+ const todayData = sorted[sorted.length - 1];
1089
+ const yesterData = sorted[sorted.length - 2];
1090
+ if (todayData && yesterData && yesterData.total_cost > 0) {
1091
+ const diff = todayData.total_cost - yesterData.total_cost;
1092
+ const pct = (diff / yesterData.total_cost * 100);
1093
+ const el = document.getElementById('todayVsYesterday');
1094
+ if (diff > 0) {
1095
+ el.innerHTML = '<span style="color:var(--red)">▲ +'+fmtCost(diff)+' vs yesterday (+'+pct.toFixed(0)+'%)</span>';
1096
+ } else {
1097
+ el.innerHTML = '<span style="color:var(--green)">▼ '+fmtCost(diff)+' vs yesterday ('+pct.toFixed(0)+'%)</span>';
1098
+ }
1099
+ }
1100
+ }
1101
+ }).catch(() => {});
1102
+ }
1103
+
1104
+ function renderHeatmap() {
1105
+ fetch('/api/history?days=35').then(r => r.json()).then(data => {
1106
+ const heatmap = document.getElementById('heatmap');
1107
+ const costMap = Object.fromEntries(data.map(d => [d.date, d.total_cost]));
1108
+ const maxCost = Math.max(...data.map(d => d.total_cost), 1);
1109
+
1110
+ let cells = '';
1111
+ for (let i = 34; i >= 0; i--) {
1112
+ const d = new Date(); d.setDate(d.getDate() - i);
1113
+ const ds = d.toISOString().slice(0, 10);
1114
+ const cost = costMap[ds] || 0;
1115
+ const intensity = cost > 0 ? Math.max(0.15, cost / maxCost) : 0;
1116
+ const color = cost === 0 ? 'var(--text-muted)' :
1117
+ intensity > 0.7 ? 'rgba(239,68,68,' + intensity + ')' :
1118
+ intensity > 0.3 ? 'rgba(245,158,11,' + intensity + ')' :
1119
+ 'rgba(34,211,238,' + intensity + ')';
1120
+ cells += '<div class="heat-cell" style="background:' + color + '" title="' + ds + ': ' + fmtCost(cost) + '"></div>';
1121
+ }
1122
+ heatmap.innerHTML = cells;
1123
+ }).catch(() => {});
1124
+ }
1125
+
1126
+ function renderLeaderboard() {
1127
+ fetch('/api/top-messages').then(r => r.json()).then(data => {
1128
+ const el = document.getElementById('leaderboard');
1129
+ if (!data.length) { el.innerHTML = '<div class="no-data">No calls yet today</div>'; return; }
1130
+ el.innerHTML = data.slice(0, 5).map((msg, i) => {
1131
+ const rankClass = i === 0 ? 'rank-1' : i === 1 ? 'rank-2' : i === 2 ? 'rank-3' : 'rank-n';
1132
+ const time = new Date(msg.timestamp).toLocaleTimeString();
1133
+ return '<div class="leaderboard-item">' +
1134
+ '<div class="leaderboard-rank ' + rankClass + '">' + (i+1) + '</div>' +
1135
+ '<div style="flex:1"><div style="font-weight:600;font-size:14px;color:var(--amber)">'+fmtCost(msg.cost)+'</div>' +
1136
+ '<div style="font-size:11px;color:var(--text-dim)">' + msg.session_name + ' · ' + (msg.model||'').split('/').pop() + ' · ' + time + '</div></div>' +
1137
+ '<div style="font-size:11px;color:var(--text-dim);text-align:right">' + fmtTokens(msg.total_tokens) + ' tok' +
1138
+ (msg.cache_write > 10000 ? '<br/>' + fmtTokens(msg.cache_write) + ' cache' : '') + '</div>' +
1139
+ '</div>';
1140
+ }).join('');
1141
+ }).catch(() => {});
1142
+ }
1143
+
1144
+ function renderFeed() {
1145
+ const el = document.getElementById('liveFeed');
1146
+ if (!recentEvents.length) { el.innerHTML = '<div class="no-data">Waiting for API calls...</div>'; return; }
1147
+ el.innerHTML = recentEvents.slice(0, 12).map(ev => {
1148
+ const time = new Date(ev.time).toLocaleTimeString();
1149
+ const costColor = ev.cost > 0.5 ? 'var(--red)' : ev.cost > 0.05 ? 'var(--amber)' : 'var(--green)';
1150
+ return '<div class="feed-item">' +
1151
+ '<span style="color:var(--text-dim)">' + time + '</span>' +
1152
+ '<span style="color:var(--cyan)">' + ev.session + '</span>' +
1153
+ '<span style="color:var(--text-dim)">' + (ev.model||'').slice(0,20) + '</span>' +
1154
+ '<span style="color:' + costColor + ';text-align:right">' + fmtCost(ev.cost) + '</span>' +
1155
+ '<span style="color:var(--text-dim);text-align:right">' + fmtTokens(ev.tokens) + '</span>' +
1156
+ '</div>';
1157
+ }).join('');
1158
+ }
1159
+
1160
+ // ── SSE Connection ────────────────────────────────────
1161
+ function connect() {
1162
+ const es = new EventSource('/api/stream');
1163
+
1164
+ es.onmessage = (e) => {
1165
+ const data = JSON.parse(e.data);
1166
+
1167
+ if (data.type === 'init') {
1168
+ state = data.today;
1169
+ recentEvents = data.recent || [];
1170
+ renderAll();
1171
+ return;
1172
+ }
1173
+
1174
+ if (data.type === 'event') {
1175
+ state = data.today;
1176
+ recentEvents.unshift(data.event);
1177
+ if (recentEvents.length > 50) recentEvents.length = 50;
1178
+
1179
+ updateBigNumbers();
1180
+ renderSessions();
1181
+ renderModelDonut();
1182
+ renderFeed();
1183
+ triggerPacTurbo(); // 🦞 CHOMP CHOMP
1184
+ }
1185
+ };
1186
+
1187
+ es.onerror = () => {
1188
+ es.close();
1189
+ setTimeout(connect, 3000);
1190
+ };
1191
+ }
1192
+
1193
+ function renderAll() {
1194
+ updateBigNumbers();
1195
+ renderSessions();
1196
+ renderModelDonut();
1197
+ renderHourlyChart();
1198
+ renderHistory();
1199
+ renderHeatmap();
1200
+ renderLeaderboard();
1201
+ renderFeed();
1202
+ }
1203
+
1204
+ // ── Init ──────────────────────────────────────────────
1205
+ connect();
1206
+
1207
+ // Refresh charts every 30s
1208
+ setInterval(() => {
1209
+ renderHourlyChart();
1210
+ renderHistory();
1211
+ renderHeatmap();
1212
+ renderLeaderboard();
1213
+ }, 30000);
1214
+ </script>
1215
+ </body>
1216
+ </html>`;
1217
+ }
1218
+
1219
+ module.exports = { startWebDashboard };