opencode-tokenwatch 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,739 @@
1
+ function fmtTokens(n) {
2
+ if (n >= 1_000_000_000)
3
+ return (n / 1_000_000_000).toFixed(1) + "B";
4
+ if (n >= 1_000_000)
5
+ return (n / 1_000_000).toFixed(1) + "M";
6
+ if (n >= 1_000)
7
+ return (n / 1_000).toFixed(1) + "K";
8
+ return String(n);
9
+ }
10
+ function fmtCost(n) {
11
+ if (n === 0)
12
+ return "$0.00";
13
+ if (n < 0.01)
14
+ return "$" + n.toFixed(6);
15
+ return "$" + n.toFixed(2);
16
+ }
17
+ function fmtPercent(n) {
18
+ return (n * 100).toFixed(1) + "%";
19
+ }
20
+ function cacheHitRate(input, cacheRead) {
21
+ if (input + cacheRead === 0)
22
+ return 0;
23
+ return cacheRead / (input + cacheRead);
24
+ }
25
+ function sortModelsByUsage(models) {
26
+ return [...models].sort((a, b) => b.totalTokens - a.totalTokens);
27
+ }
28
+ function renderMeta(data) {
29
+ const m = data.meta;
30
+ return `TokenWatch Usage Report · ${m.dateRange.start} → ${m.dateRange.end} · generated ${m.generatedAt}`;
31
+ }
32
+ function renderKpiCards(data) {
33
+ const s = data.summary;
34
+ const hitRate = cacheHitRate(s.inputTokens, s.cacheRead);
35
+ const hitRatePct = fmtPercent(hitRate);
36
+ const avgTpsRaw = s.requestCount > 0 && data.perfSummary.length > 0
37
+ ? data.perfSummary.reduce((sum, p) => sum + (p.avgTPS ?? 0) * p.requestCount, 0) /
38
+ data.perfSummary.reduce((sum, p) => sum + p.requestCount, 0)
39
+ : 0;
40
+ const avgTps = avgTpsRaw ? avgTpsRaw.toFixed(1) : "—";
41
+ const isHighCache = hitRate >= 0.5;
42
+ return `
43
+ <div class="kpi-row">
44
+ <div class="kpi-card">
45
+ <div class="kpi-label">Total Tokens</div>
46
+ <div class="kpi-value">${fmtTokens(s.totalTokens)}</div>
47
+ </div>
48
+ <div class="kpi-card${isHighCache ? ' kpi-glow' : ''}">
49
+ <div class="kpi-label">Cache Hit Rate</div>
50
+ <div class="kpi-value" style="color:var(--cache)">${hitRatePct}</div>
51
+ </div>
52
+ <div class="kpi-card">
53
+ <div class="kpi-label">Avg TPS</div>
54
+ <div class="kpi-value" style="color:var(--tps)">${avgTps}</div>
55
+ </div>
56
+ <div class="kpi-card">
57
+ <div class="kpi-label">Requests</div>
58
+ <div class="kpi-value">${s.requestCount}</div>
59
+ </div>
60
+ <div class="kpi-card">
61
+ <div class="kpi-label">Total Cost</div>
62
+ <div class="kpi-value" style="color:var(--tps)">${fmtCost(s.totalCost)}</div>
63
+ </div>
64
+ </div>`;
65
+ }
66
+ function renderModelChartInit(data) {
67
+ const models = sortModelsByUsage(data.models).filter(m => m.totalTokens >= 1_000_000);
68
+ const names = models.map(m => m.model);
69
+ const inputData = models.map(m => m.inputTokens);
70
+ const outputData = models.map(m => m.outputTokens);
71
+ const cacheData = models.map(m => m.cacheRead);
72
+ const tpsData = models.map(m => {
73
+ const perf = data.perfSummary.find(p => p.model === `${m.provider}/${m.model}`);
74
+ return perf?.avgTPS ?? null;
75
+ });
76
+ return `var modelNames = ${JSON.stringify(names)};
77
+ var modelInput = ${JSON.stringify(inputData)};
78
+ var modelOutput = ${JSON.stringify(outputData)};
79
+ var modelCache = ${JSON.stringify(cacheData)};
80
+ var modelTps = ${JSON.stringify(tpsData)};
81
+
82
+ function initModelChart() {
83
+ var el = document.getElementById('model-chart');
84
+ if (!el) return;
85
+ var chart = echarts.init(el);
86
+ window.modelChart = chart;
87
+ renderModelChart(chart);
88
+ return chart;
89
+ }
90
+
91
+ function renderModelChart(chart) {
92
+ var totals = modelInput.map(function(v, i) { return v + modelOutput[i] + modelCache[i]; });
93
+ var option = {
94
+ tooltip: {
95
+ trigger: 'axis',
96
+ axisPointer: { type: 'shadow' },
97
+ formatter: function(params) {
98
+ var html = '<b>' + params[0].axisValue + '</b><br/>';
99
+ var total = 0;
100
+ params.forEach(function(p) {
101
+ if (p.seriesName !== 'TPS') {
102
+ html += p.marker + ' ' + p.seriesName + ': ' + fmt(p.value) + '<br/>';
103
+ total += p.value;
104
+ }
105
+ });
106
+ html += 'Total: ' + fmt(total) + '<br/>';
107
+ var tpsParam = params.find(function(p) { return p.seriesName === 'TPS'; });
108
+ if (tpsParam && tpsParam.value != null) {
109
+ html += tpsParam.marker + ' TPS: ' + tpsParam.value.toFixed(1) + '<br/>';
110
+ }
111
+ return html;
112
+ }
113
+ },
114
+ legend: {
115
+ data: ['Input', 'Output', 'Cache', 'TPS'],
116
+ textStyle: { color: '#B0B0C0' },
117
+ top: 5
118
+ },
119
+ grid: { left: 60, right: 60, bottom: 100, top: 50 },
120
+ xAxis: {
121
+ type: 'category',
122
+ data: modelNames,
123
+ axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
124
+ axisLine: { lineStyle: { color: '#2A2A35' } }
125
+ },
126
+ yAxis: [
127
+ {
128
+ type: 'value',
129
+ name: 'Tokens',
130
+ nameTextStyle: { color: '#B0B0C0' },
131
+ axisLabel: {
132
+ color: '#B0B0C0',
133
+ formatter: fmt
134
+ },
135
+ splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
136
+ },
137
+ {
138
+ type: 'value',
139
+ name: 'TPS',
140
+ nameTextStyle: { color: '#FFB800' },
141
+ axisLabel: { color: '#FFB800', formatter: function(v) { return v.toFixed(1); } },
142
+ splitLine: { show: false }
143
+ }
144
+ ],
145
+ series: [
146
+ {
147
+ name: 'Input',
148
+ type: 'bar',
149
+ stack: 'tokens',
150
+ data: modelInput,
151
+ itemStyle: { color: '#00D1FF' },
152
+ barMaxWidth: 40
153
+ },
154
+ {
155
+ name: 'Cache',
156
+ type: 'bar',
157
+ stack: 'tokens',
158
+ data: modelCache,
159
+ itemStyle: { color: '#00F593' },
160
+ barMaxWidth: 40,
161
+ label: {
162
+ show: true,
163
+ position: 'inside',
164
+ formatter: function(p) {
165
+ var cache = modelCache[p.dataIndex];
166
+ var input = modelInput[p.dataIndex];
167
+ if (cache === 0) return '';
168
+ return (cache / (input + cache) * 100).toFixed(0) + '%';
169
+ },
170
+ color: '#fff', fontSize: 10, fontWeight: 'bold'
171
+ }
172
+ },
173
+ {
174
+ name: 'Output',
175
+ type: 'bar',
176
+ stack: 'tokens',
177
+ data: modelOutput,
178
+ itemStyle: { color: '#B545FF' },
179
+ barMaxWidth: 40,
180
+ label: {
181
+ show: true,
182
+ position: 'top',
183
+ formatter: function(p) {
184
+ var total = modelInput[p.dataIndex] + modelOutput[p.dataIndex] + modelCache[p.dataIndex];
185
+ return total > 0 ? fmt(total) : '';
186
+ },
187
+ color: '#fff', fontSize: 10, fontWeight: 'bold'
188
+ }
189
+ },
190
+ {
191
+ name: 'TPS',
192
+ type: 'line',
193
+ yAxisIndex: 1,
194
+ data: modelTps,
195
+ smooth: true,
196
+ symbol: 'circle',
197
+ symbolSize: 6,
198
+ lineStyle: { color: '#FFB800', width: 2 },
199
+ itemStyle: { color: '#FFB800' },
200
+ label: {
201
+ show: true,
202
+ position: 'right',
203
+ formatter: function(p) { return p.value != null ? p.value.toFixed(1) : ''; },
204
+ color: '#FFB800', fontSize: 10
205
+ }
206
+ }
207
+ ]
208
+ };
209
+ chart.setOption(option);
210
+ chart.resize();
211
+ }`;
212
+ }
213
+ function renderScatterChartInit(data) {
214
+ if (data.perfSummary.length === 0)
215
+ return "";
216
+ const providerColors = {
217
+ opencode: "#00F593", deepseek: "#00D1FF", nvidia: "#B545FF", modelscope: "#FFB800",
218
+ };
219
+ const scatterData = data.perfSummary.map(p => {
220
+ const costPer1K = p.totalInput + p.totalOutput > 0
221
+ ? (p.totalCost / (p.totalInput + p.totalOutput)) * 1000
222
+ : 0;
223
+ return {
224
+ name: p.model,
225
+ provider: p.providerID,
226
+ tps: p.avgTPS,
227
+ value: [p.avgTTFT != null && p.avgTTFT > 0 ? p.avgTTFT : 0.01, costPer1K, p.requestCount]
228
+ };
229
+ });
230
+ return `
231
+ var scatterData = ${JSON.stringify(scatterData)};
232
+ var providerColors = ${JSON.stringify(providerColors)};
233
+
234
+ function initScatterChart() {
235
+ var el = document.getElementById('scatter-chart');
236
+ if (!el) return;
237
+ var chart = echarts.init(el);
238
+ window.scatterChart = chart;
239
+ var option = {
240
+ tooltip: {
241
+ formatter: function(params) {
242
+ var d = params.data;
243
+ return '<b>' + params.name + '</b><br/>' +
244
+ 'Provider: ' + (d.provider || '\u2014') + '<br/>' +
245
+ 'TTFT: ' + (d.value[0] != null ? d.value[0].toFixed(1) + 'ms' : '\u2014') + '<br/>' +
246
+ 'TPS: ' + (d.tps != null ? d.tps.toFixed(1) : '\u2014') + '<br/>' +
247
+ 'Cost/1K: $' + d.value[1].toFixed(4) + '<br/>' +
248
+ 'Requests: ' + d.value[2];
249
+ }
250
+ },
251
+ grid: { left: 80, right: 40, bottom: 60, top: 30 },
252
+ xAxis: {
253
+ type: 'log',
254
+ name: 'TTFT (ms)',
255
+ min: 0.01,
256
+ nameTextStyle: { color: '#B0B0C0' },
257
+ axisLabel: { color: '#B0B0C0' },
258
+ splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
259
+ },
260
+ yAxis: {
261
+ type: 'value',
262
+ name: 'Cost per 1K tokens',
263
+ nameTextStyle: { color: '#B0B0C0' },
264
+ axisLabel: { color: '#B0B0C0', formatter: function(v) { return '$' + v.toFixed(4); } },
265
+ splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
266
+ },
267
+ series: [{
268
+ type: 'scatter',
269
+ data: scatterData.map(function(d) {
270
+ return {
271
+ value: d.value,
272
+ name: d.name,
273
+ provider: d.provider,
274
+ tps: d.tps,
275
+ itemStyle: { color: providerColors[d.provider] || '#B545FF' }
276
+ };
277
+ }),
278
+ symbolSize: function(val) {
279
+ return Math.max(8, Math.min(40, Math.sqrt(val[2]) * 3));
280
+ },
281
+ itemStyle: { opacity: 0.8 },
282
+ label: {
283
+ show: true,
284
+ formatter: function(p) { return p.name; },
285
+ position: 'right',
286
+ color: '#B0B0C0',
287
+ fontSize: 10
288
+ }
289
+ }]
290
+ };
291
+ chart.setOption(option);
292
+ chart.resize();
293
+ }`;
294
+ }
295
+ function providerBorderColor(provider) {
296
+ const colors = { opencode: "#00F593", deepseek: "#00D1FF", nvidia: "#B545FF", modelscope: "#FFB800", };
297
+ return colors[provider] || "#2A2A35";
298
+ }
299
+ function renderProviderCards(data) {
300
+ return data.providers.map(p => {
301
+ const modelCount = data.models.filter(m => m.provider === p.provider).length;
302
+ const perfItems = data.perfSummary.filter(ps => ps.providerID === p.provider);
303
+ const avgTtft = perfItems.length > 0
304
+ ? perfItems.reduce((s, x) => s + (x.avgTTFT ?? 0) * x.requestCount, 0) /
305
+ perfItems.reduce((s, x) => s + x.requestCount, 0)
306
+ : null;
307
+ const avgTps = perfItems.length > 0
308
+ ? perfItems.reduce((s, x) => s + (x.avgTPS ?? 0) * x.requestCount, 0) /
309
+ perfItems.reduce((s, x) => s + x.requestCount, 0)
310
+ : null;
311
+ return `
312
+ <div class="provider-card" style="border-color:${providerBorderColor(p.provider)}">
313
+ <div class="provider-name">${p.provider}</div>
314
+ <div class="provider-stat"><span class="stat-label">Tokens</span><span>${fmtTokens(p.totalTokens)}</span></div>
315
+ <div class="provider-stat"><span class="stat-label">Cost</span><span>${fmtCost(p.totalCost)}</span></div>
316
+ <div class="provider-stat"><span class="stat-label">Avg TTFT</span><span>${avgTtft != null ? avgTtft.toFixed(0) + 'ms' : '—'}</span></div>
317
+ <div class="provider-stat"><span class="stat-label">Avg TPS</span><span>${avgTps != null ? avgTps.toFixed(1) : '—'}</span></div>
318
+ <div class="provider-stat"><span class="stat-label">Models</span><span>${modelCount}</span></div>
319
+ </div>`;
320
+ }).join("\n");
321
+ }
322
+ function renderDataTable(data) {
323
+ const rows = sortModelsByUsage(data.models).map(m => {
324
+ const hitRate = cacheHitRate(m.inputTokens, m.cacheRead);
325
+ const hitRatePct = fmtPercent(hitRate);
326
+ const hitColor = hitRate >= 0.85 ? 'var(--cache)' : hitRate >= 0.70 ? 'var(--tps)' : 'var(--output)';
327
+ const perf = data.perfSummary.find(p => p.model === `${m.provider}/${m.model}`);
328
+ const ttft = perf?.avgTTFT != null ? perf.avgTTFT.toFixed(0) + 'ms' : '—';
329
+ const tps = perf?.avgTPS != null ? perf.avgTPS.toFixed(1) : '—';
330
+ return `<tr>
331
+ <td>${m.model}</td>
332
+ <td>${m.provider}</td>
333
+ <td>${m.requests}</td>
334
+ <td>${fmtTokens(m.totalTokens)}</td>
335
+ <td>${fmtTokens(m.inputTokens)}</td>
336
+ <td>${fmtTokens(m.outputTokens)}</td>
337
+ <td>${fmtTokens(m.cacheRead)}</td>
338
+ <td style="color:${hitColor};font-weight:600">${hitRatePct}</td>
339
+ <td>${ttft}</td>
340
+ <td>${tps}</td>
341
+ <td>${fmtCost(m.totalCost)}</td>
342
+ </tr>`;
343
+ }).join("\n");
344
+ return `<table class="data-table">
345
+ <thead>
346
+ <tr>
347
+ <th>Model</th>
348
+ <th>Provider</th>
349
+ <th>Req</th>
350
+ <th>Total</th>
351
+ <th>Input</th>
352
+ <th>Output</th>
353
+ <th>Cache</th>
354
+ <th>Hit Rate</th>
355
+ <th>TTFT</th>
356
+ <th>TPS</th>
357
+ <th>Cost</th>
358
+ </tr>
359
+ </thead>
360
+ <tbody>${rows}</tbody>
361
+ </table>`;
362
+ }
363
+ function renderDailyTrendInit(data) {
364
+ const days = data.daily.slice().reverse().map(d => d.day);
365
+ const tokens = data.daily.slice().reverse().map(d => d.totalTokens);
366
+ const costs = data.daily.slice().reverse().map(d => d.totalCost);
367
+ return `
368
+ var dailyDays = ${JSON.stringify(days)};
369
+ var dailyTokens = ${JSON.stringify(tokens)};
370
+ var dailyCosts = ${JSON.stringify(costs)};
371
+
372
+ function initDailyChart() {
373
+ var el = document.getElementById('daily-chart');
374
+ if (!el) return;
375
+ var chart = echarts.init(el);
376
+ window.dailyChart = chart;
377
+ var option = {
378
+ tooltip: {
379
+ trigger: 'axis',
380
+ formatter: function(params) {
381
+ var html = '<b>' + params[0].axisValue + '</b><br/>';
382
+ params.forEach(function(p) {
383
+ html += p.marker + ' ' + p.seriesName + ': ' + (p.seriesName === 'Cost' ? '$' + p.value.toFixed(4) : fmt(p.value)) + '<br/>';
384
+ });
385
+ return html;
386
+ }
387
+ },
388
+ legend: {
389
+ data: ['Tokens', 'Cost'],
390
+ textStyle: { color: '#B0B0C0' },
391
+ top: 5
392
+ },
393
+ grid: { left: 60, right: 60, bottom: 80, top: 40 },
394
+ xAxis: {
395
+ type: 'category',
396
+ data: dailyDays,
397
+ axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
398
+ axisLine: { lineStyle: { color: '#2A2A35' } }
399
+ },
400
+ yAxis: [
401
+ {
402
+ type: 'value',
403
+ name: 'Tokens',
404
+ nameTextStyle: { color: '#B0B0C0' },
405
+ axisLabel: { color: '#B0B0C0', formatter: fmt },
406
+ splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
407
+ },
408
+ {
409
+ type: 'value',
410
+ name: 'Cost',
411
+ nameTextStyle: { color: '#FFB800' },
412
+ axisLabel: { color: '#FFB800', formatter: function(v) { return '$' + v.toFixed(4); } },
413
+ splitLine: { show: false }
414
+ }
415
+ ],
416
+ dataZoom: [{
417
+ type: 'slider',
418
+ bottom: 5,
419
+ height: 20,
420
+ borderColor: '#2A2A35',
421
+ fillerColor: 'rgba(0,213,255,0.1)',
422
+ handleStyle: { color: '#00D1FF' },
423
+ textStyle: { color: '#B0B0C0' }
424
+ }],
425
+ series: [
426
+ {
427
+ name: 'Tokens',
428
+ type: 'line',
429
+ data: dailyTokens,
430
+ smooth: true,
431
+ symbol: 'none',
432
+ lineStyle: { color: '#00D1FF', width: 2 },
433
+ areaStyle: { color: 'rgba(0,209,255,0.15)' }
434
+ },
435
+ {
436
+ name: 'Cost',
437
+ type: 'line',
438
+ yAxisIndex: 1,
439
+ data: dailyCosts,
440
+ smooth: true,
441
+ symbol: 'none',
442
+ lineStyle: { color: '#FFB800', width: 2 },
443
+ areaStyle: { color: 'rgba(255,184,0,0.1)' }
444
+ }
445
+ ]
446
+ };
447
+ chart.setOption(option);
448
+ chart.resize();
449
+ }`;
450
+ }
451
+ function renderHeatmapInit(data) {
452
+ const days = data.daily.slice().reverse();
453
+ const heatData = days.map(d => [d.day, Math.log10(d.totalTokens + 1)]);
454
+ const minDate = days.length > 0 ? days[0].day : '';
455
+ const maxDate = days.length > 0 ? days[days.length - 1].day : '';
456
+ return `
457
+ var heatData = ${JSON.stringify(heatData)};
458
+
459
+ function initHeatmapChart() {
460
+ var el = document.getElementById('heatmap-chart');
461
+ if (!el) return;
462
+ var chart = echarts.init(el);
463
+ window.heatmapChart = chart;
464
+ var option = {
465
+ tooltip: {
466
+ formatter: function(params) {
467
+ var val = params.value;
468
+ var rawTokens = Math.pow(10, val[1]) - 1;
469
+ return '<b>' + val[0] + '</b><br/>Tokens: ' + fmt(Math.round(rawTokens));
470
+ }
471
+ },
472
+ visualMap: {
473
+ min: 0,
474
+ max: Math.max.apply(null, heatData.map(function(d) { return d[1]; })) || 5,
475
+ calculable: true,
476
+ orient: 'horizontal',
477
+ left: 'center',
478
+ bottom: 10,
479
+ textStyle: { color: '#B0B0C0' },
480
+ inRange: {
481
+ color: ['#0C0C0E', '#1a3a2a', '#00F593', '#00D1FF', '#B545FF']
482
+ }
483
+ },
484
+ calendar: {
485
+ left: 30,
486
+ right: 30,
487
+ top: 20,
488
+ bottom: 60,
489
+ range: ['${minDate}', '${maxDate}'],
490
+ splitLine: { lineStyle: { color: '#2A2A35' } },
491
+ dayLabel: { color: '#B0B0C0' },
492
+ monthLabel: { color: '#B0B0C0' },
493
+ yearLabel: { color: '#B0B0C0' },
494
+ itemStyle: { color: '#16161A', borderColor: '#0C0C0E', borderWidth: 2 }
495
+ },
496
+ series: [{
497
+ type: 'heatmap',
498
+ coordinateSystem: 'calendar',
499
+ data: heatData
500
+ }]
501
+ };
502
+ chart.setOption(option);
503
+ chart.resize();
504
+ }`;
505
+ }
506
+ export function generateUsageHtml(data) {
507
+ const metaStr = renderMeta(data);
508
+ const kpiStr = renderKpiCards(data);
509
+ const modelChartVisible = data.models.filter(m => m.totalTokens >= 1_000_000).length > 0;
510
+ const modelChartJs = modelChartVisible ? renderModelChartInit(data) : "";
511
+ const scatterChartJs = renderScatterChartInit(data);
512
+ const providerStr = renderProviderCards(data);
513
+ const tableStr = renderDataTable(data);
514
+ const dailyChartJs = renderDailyTrendInit(data);
515
+ const heatmapJs = renderHeatmapInit(data);
516
+ const hasPerf = data.perfSummary.length > 0;
517
+ const jsonData = JSON.stringify(data);
518
+ return `<!DOCTYPE html>
519
+ <html lang="en">
520
+ <head>
521
+ <meta charset="UTF-8">
522
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
523
+ <title>TokenWatch Usage Report</title>
524
+ <link rel="preconnect" href="https://fonts.googleapis.com">
525
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
526
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
527
+ <style>
528
+ :root {
529
+ --bg: #0C0C0E;
530
+ --card: #16161A;
531
+ --border: #2A2A35;
532
+ --text: #E0E0F0;
533
+ --text-dim: #B0B0C0;
534
+ --cache: #00F593;
535
+ --input: #00D1FF;
536
+ --output: #B545FF;
537
+ --tps: #FFB800;
538
+ --radius: 8px;
539
+ }
540
+ * { margin: 0; padding: 0; box-sizing: border-box; }
541
+ body {
542
+ background: var(--bg);
543
+ color: var(--text);
544
+ font-family: 'Inter', -apple-system, sans-serif;
545
+ font-size: 14px;
546
+ line-height: 1.5;
547
+ min-height: 100vh;
548
+ }
549
+ .container { max-width: 1400px; margin: 0 auto; padding: 24px 20px; }
550
+ .header {
551
+ display: flex; justify-content: space-between; align-items: center;
552
+ padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 24px;
553
+ }
554
+ .header h1 { font-size: 22px; font-weight: 600; color: var(--text); }
555
+ .header h1 span { color: var(--input); }
556
+ .header .meta { font-size: 12px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; }
557
+
558
+ .kpi-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 24px; }
559
+ .kpi-card {
560
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
561
+ padding: 16px; text-align: center;
562
+ }
563
+ .kpi-card.kpi-glow { box-shadow: 0 0 20px rgba(0,245,147,0.15); border-color: var(--cache); }
564
+ .kpi-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
565
+ .kpi-value { font-size: 26px; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--text); }
566
+
567
+ .section { margin-bottom: 28px; }
568
+ .section-title {
569
+ font-size: 16px; font-weight: 600; margin-bottom: 12px;
570
+ padding-bottom: 6px; border-bottom: 1px solid var(--border);
571
+ }
572
+ .chart-box {
573
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
574
+ padding: 12px; height: 400px;
575
+ }
576
+
577
+ .tab-bar { display: flex; gap: 4px; margin-bottom: 12px; }
578
+ .tab-btn {
579
+ background: var(--card); border: 1px solid var(--border); color: var(--text-dim);
580
+ padding: 6px 18px; border-radius: 4px 4px 0 0; cursor: pointer; font-size: 13px; font-family: 'Inter', sans-serif;
581
+ }
582
+ .tab-btn:hover { border-color: var(--input); color: var(--text); }
583
+ .tab-btn.active {
584
+ background: var(--border); color: var(--text); border-bottom-color: var(--border);
585
+ }
586
+ .tab-content { display: none; }
587
+ .tab-content.active { display: block; }
588
+
589
+ .provider-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
590
+ .provider-card {
591
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
592
+ padding: 14px;
593
+ }
594
+ .provider-name { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--input); }
595
+ .provider-stat { display: flex; justify-content: space-between; font-size: 12px; padding: 2px 0; }
596
+ .provider-stat .stat-label { color: var(--text-dim); }
597
+
598
+ .data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
599
+ .data-table th {
600
+ background: var(--card); color: var(--text-dim); padding: 8px 10px;
601
+ text-align: right; border-bottom: 1px solid var(--border); font-weight: 500;
602
+ white-space: nowrap;
603
+ }
604
+ .data-table th:first-child { text-align: left; }
605
+ .data-table td {
606
+ padding: 6px 10px; text-align: right; border-bottom: 1px solid var(--border);
607
+ font-family: 'JetBrains Mono', monospace;
608
+ }
609
+ .data-table td:first-child {
610
+ text-align: left; color: var(--text); font-family: 'Inter', sans-serif;
611
+ }
612
+ .data-table tbody tr:hover { background: rgba(42,42,53,0.4); }
613
+
614
+ .empty-state {
615
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
616
+ padding: 40px; text-align: center; color: var(--text-dim);
617
+ }
618
+
619
+ .footer {
620
+ margin-top: 40px; padding: 16px 0; border-top: 1px solid var(--border);
621
+ text-align: center; font-size: 11px; color: var(--text-dim);
622
+ }
623
+
624
+ @media (max-width: 768px) {
625
+ .kpi-row { grid-template-columns: repeat(2, 1fr); }
626
+ .provider-row { grid-template-columns: 1fr; }
627
+ .container { padding: 12px 10px; }
628
+ .header { flex-direction: column; gap: 6px; align-items: flex-start; }
629
+ .chart-box { height: 300px; }
630
+ .data-table { font-size: 11px; }
631
+ .data-table th, .data-table td { padding: 4px 6px; }
632
+ }
633
+ </style>
634
+ </head>
635
+ <body>
636
+ <div class="container">
637
+ <div class="header">
638
+ <h1><span>TokenWatch</span> Usage Report</h1>
639
+ <div class="meta">${metaStr}</div>
640
+ </div>
641
+
642
+ ${kpiStr}
643
+
644
+ <div class="section">
645
+ <div class="section-title">Model Comparison Matrix</div>
646
+ ${modelChartVisible ? '<div class="chart-box" id="model-chart"></div>' : '<div class="empty-state">No models with >=1M tokens in this period.</div>'}
647
+ </div>
648
+
649
+ <div class="section">
650
+ <div class="section-title">Provider Summary</div>
651
+ <div class="provider-row">${providerStr}</div>
652
+ </div>
653
+
654
+ <div class="section">
655
+ <div class="section-title">Detailed Data Grid</div>
656
+ ${tableStr}
657
+ </div>
658
+
659
+ <div class="section">
660
+ <div class="section-title">Efficiency vs Cost</div>
661
+ ${hasPerf ? '<div class="chart-box" id="scatter-chart"></div>' : '<div class="empty-state">No performance data available for this period.</div>'}
662
+ </div>
663
+
664
+ <div class="section">
665
+ <div class="section-title">Usage Timeline</div>
666
+ <div class="tab-bar">
667
+ <button class="tab-btn active" data-tab="daily" onclick="switchTab('daily')">Daily Trend</button>
668
+ <button class="tab-btn" data-tab="heatmap" onclick="switchTab('heatmap')">Heatmap</button>
669
+ </div>
670
+ <div id="tab-daily" class="tab-content active">
671
+ <div class="chart-box" id="daily-chart"></div>
672
+ </div>
673
+ <div id="tab-heatmap" class="tab-content">
674
+ <div class="chart-box" id="heatmap-chart"></div>
675
+ </div>
676
+ </div>
677
+
678
+ <div class="footer">
679
+ Generated by TokenWatch &middot; Data: SQLite + JSONL &middot; Export: <a href="javascript:void(0)" onclick="downloadJSON()" style="color:var(--input);text-decoration:none">JSON</a> <span style="color:var(--text-dim)">(saves to browser download folder)</span>
680
+ </div>
681
+ </div>
682
+
683
+ <script id="report-data" type="application/json">${jsonData}</script>
684
+
685
+ <script>
686
+ var fmt = function(v) {
687
+ if (v == null) return '\u2014';
688
+ if (v >= 1000000000) return (v/1000000000).toFixed(1)+'B';
689
+ if (v >= 1000000) return (v/1000000).toFixed(1)+'M';
690
+ if (v >= 1000) return (v/1000).toFixed(1)+'K';
691
+ return String(v);
692
+ };
693
+
694
+ window.switchTab = function(name) {
695
+ document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
696
+ document.querySelectorAll('.tab-btn').forEach(function(el) { el.classList.remove('active'); });
697
+ document.getElementById('tab-' + name).classList.add('active');
698
+ document.querySelector('[data-tab="' + name + '"]').classList.add('active');
699
+ setTimeout(function() {
700
+ if (name === 'daily' && window.dailyChart) window.dailyChart.resize();
701
+ if (name === 'heatmap' && window.heatmapChart) window.heatmapChart.resize();
702
+ }, 50);
703
+ };
704
+
705
+ ${modelChartJs}
706
+ ${scatterChartJs}
707
+ ${dailyChartJs}
708
+ ${heatmapJs}
709
+
710
+ window.downloadJSON = function() {
711
+ var d = document.getElementById('report-data');
712
+ if (!d) return;
713
+ var b = new Blob([d.textContent], { type: 'application/json' });
714
+ var a = document.createElement('a');
715
+ a.href = URL.createObjectURL(b);
716
+ a.download = 'tokenwatch-data.json';
717
+ document.body.appendChild(a);
718
+ a.click();
719
+ document.body.removeChild(a);
720
+ setTimeout(function() { URL.revokeObjectURL(a.href); }, 100);
721
+ };
722
+
723
+ document.addEventListener('DOMContentLoaded', function() {
724
+ ${modelChartVisible ? 'initModelChart();' : ''}
725
+ ${hasPerf ? 'initScatterChart();' : ''}
726
+ initDailyChart();
727
+ initHeatmapChart();
728
+ });
729
+
730
+ window.addEventListener('resize', function() {
731
+ ${modelChartVisible ? 'if (window.modelChart) window.modelChart.resize();' : ''}
732
+ if (window.scatterChart) window.scatterChart.resize();
733
+ if (window.dailyChart) window.dailyChart.resize();
734
+ if (window.heatmapChart) window.heatmapChart.resize();
735
+ });
736
+ </script>
737
+ </body>
738
+ </html>`;
739
+ }