cchubber 0.1.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.
- package/README.md +104 -0
- package/package.json +35 -0
- package/src/analyzers/anomaly-detector.js +59 -0
- package/src/analyzers/cache-health.js +135 -0
- package/src/analyzers/cost-calculator.js +357 -0
- package/src/analyzers/inflection-detector.js +67 -0
- package/src/analyzers/recommendations.js +128 -0
- package/src/cli/index.js +174 -0
- package/src/readers/cache-breaks.js +81 -0
- package/src/readers/claude-md.js +67 -0
- package/src/readers/jsonl-reader.js +247 -0
- package/src/readers/oauth-usage.js +82 -0
- package/src/readers/session-meta.js +42 -0
- package/src/readers/stats-cache.js +54 -0
- package/src/renderers/html-report.js +1375 -0
- package/src/renderers/terminal-summary.js +30 -0
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
export function renderHTML(report) {
|
|
2
|
+
const { costAnalysis, cacheHealth, anomalies, inflection, projectBreakdown, claudeMdStack, oauthUsage, recommendations, generatedAt } = report;
|
|
3
|
+
|
|
4
|
+
const dailyCosts = costAnalysis.dailyCosts || [];
|
|
5
|
+
const grade = cacheHealth.grade || { letter: '?', color: '#666', label: 'Unknown' };
|
|
6
|
+
const totalCost = costAnalysis.totalCost || 0;
|
|
7
|
+
const activeDays = costAnalysis.activeDays || 0;
|
|
8
|
+
const peakDay = costAnalysis.peakDay;
|
|
9
|
+
|
|
10
|
+
// Model split data
|
|
11
|
+
const modelCosts = costAnalysis.modelCosts || {};
|
|
12
|
+
const modelEntries = Object.entries(modelCosts)
|
|
13
|
+
.filter(([, cost]) => cost > 0.01)
|
|
14
|
+
.sort((a, b) => b[1] - a[1]);
|
|
15
|
+
const modelColors = [
|
|
16
|
+
['#6366f1', '#818cf8'],
|
|
17
|
+
['#22d3ee', '#67e8f9'],
|
|
18
|
+
['#f59e0b', '#fcd34d'],
|
|
19
|
+
['#10b981', '#34d399'],
|
|
20
|
+
['#8b5cf6', '#a78bfa'],
|
|
21
|
+
['#ef4444', '#f87171'],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Anomaly dates for chart markers
|
|
25
|
+
const anomalyDates = new Set((anomalies.anomalies || []).map(a => a.date));
|
|
26
|
+
|
|
27
|
+
// Embed all daily cost data as JSON for client-side filtering
|
|
28
|
+
const dailyCostsJSON = JSON.stringify(dailyCosts.map(d => ({
|
|
29
|
+
date: d.date,
|
|
30
|
+
cost: d.cost,
|
|
31
|
+
cacheOutputRatio: d.cacheOutputRatio || 0,
|
|
32
|
+
isAnomaly: anomalyDates.has(d.date),
|
|
33
|
+
})));
|
|
34
|
+
|
|
35
|
+
// Per-project data for client-side rendering
|
|
36
|
+
const projectsJSON = JSON.stringify((projectBreakdown || []).slice(0, 15).map(p => ({
|
|
37
|
+
name: p.name,
|
|
38
|
+
path: p.path,
|
|
39
|
+
messages: p.messageCount,
|
|
40
|
+
sessions: p.sessionCount,
|
|
41
|
+
input: p.inputTokens,
|
|
42
|
+
output: p.outputTokens,
|
|
43
|
+
cacheRead: p.cacheReadTokens,
|
|
44
|
+
cacheWrite: p.cacheCreationTokens,
|
|
45
|
+
})));
|
|
46
|
+
|
|
47
|
+
// Format helpers
|
|
48
|
+
const fmtCost = (n) => '$' + (n >= 100 ? Math.round(n).toLocaleString() : n.toFixed(2));
|
|
49
|
+
|
|
50
|
+
return `<!DOCTYPE html>
|
|
51
|
+
<html lang="en">
|
|
52
|
+
<head>
|
|
53
|
+
<meta charset="UTF-8">
|
|
54
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
55
|
+
<title>CC Hubber — Usage Report</title>
|
|
56
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
57
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
58
|
+
<style>
|
|
59
|
+
:root {
|
|
60
|
+
--bg: #0a0e17;
|
|
61
|
+
--bg-card: rgba(15, 20, 32, 0.65);
|
|
62
|
+
--bg-card-solid: #0f1420;
|
|
63
|
+
--border: rgba(255, 255, 255, 0.06);
|
|
64
|
+
--border-strong: rgba(255, 255, 255, 0.1);
|
|
65
|
+
--text: #e8ecf2;
|
|
66
|
+
--text-secondary: #b8c4d4;
|
|
67
|
+
--text-muted: #8896aa;
|
|
68
|
+
--text-dim: #596678;
|
|
69
|
+
--accent: #7c85f5;
|
|
70
|
+
--accent-soft: rgba(124, 133, 245, 0.08);
|
|
71
|
+
--green: #34d399;
|
|
72
|
+
--yellow: #fbbf24;
|
|
73
|
+
--red: #f87171;
|
|
74
|
+
--cyan: #67e8f9;
|
|
75
|
+
--orange: #fb923c;
|
|
76
|
+
--radius: 14px;
|
|
77
|
+
--radius-sm: 8px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
81
|
+
|
|
82
|
+
body {
|
|
83
|
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
84
|
+
background: var(--bg);
|
|
85
|
+
color: var(--text);
|
|
86
|
+
line-height: 1.55;
|
|
87
|
+
min-height: 100vh;
|
|
88
|
+
-webkit-font-smoothing: antialiased;
|
|
89
|
+
-moz-osx-font-smoothing: grayscale;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.container {
|
|
93
|
+
max-width: 1080px;
|
|
94
|
+
margin: 0 auto;
|
|
95
|
+
padding: 48px 24px 80px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Header */
|
|
99
|
+
.header {
|
|
100
|
+
text-align: center;
|
|
101
|
+
margin-bottom: 48px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.header h1 {
|
|
105
|
+
font-size: 26px;
|
|
106
|
+
font-weight: 700;
|
|
107
|
+
letter-spacing: -0.5px;
|
|
108
|
+
margin-bottom: 4px;
|
|
109
|
+
color: var(--text);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.header .tagline {
|
|
113
|
+
color: var(--text-muted);
|
|
114
|
+
font-size: 14px;
|
|
115
|
+
font-weight: 400;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.header .generated {
|
|
119
|
+
color: var(--text-dim);
|
|
120
|
+
font-size: 11px;
|
|
121
|
+
margin-top: 8px;
|
|
122
|
+
font-weight: 400;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Section */
|
|
126
|
+
.section { margin-bottom: 36px; }
|
|
127
|
+
|
|
128
|
+
.section-title {
|
|
129
|
+
font-size: 11px;
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
color: var(--text-dim);
|
|
132
|
+
text-transform: uppercase;
|
|
133
|
+
letter-spacing: 1.2px;
|
|
134
|
+
margin-bottom: 14px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Cards */
|
|
138
|
+
.grid {
|
|
139
|
+
display: grid;
|
|
140
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
141
|
+
gap: 12px;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.card {
|
|
145
|
+
background: var(--bg-card);
|
|
146
|
+
border: 1px solid var(--border);
|
|
147
|
+
border-radius: var(--radius);
|
|
148
|
+
padding: 20px;
|
|
149
|
+
transition: border-color 0.2s;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.card:hover { border-color: var(--border-strong); }
|
|
153
|
+
|
|
154
|
+
.card .label {
|
|
155
|
+
font-size: 11px;
|
|
156
|
+
font-weight: 400;
|
|
157
|
+
color: var(--text-muted);
|
|
158
|
+
text-transform: uppercase;
|
|
159
|
+
letter-spacing: 0.6px;
|
|
160
|
+
margin-bottom: 4px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.card .value {
|
|
164
|
+
font-size: 26px;
|
|
165
|
+
font-weight: 700;
|
|
166
|
+
letter-spacing: -0.5px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.card .sub {
|
|
170
|
+
font-size: 12px;
|
|
171
|
+
font-weight: 400;
|
|
172
|
+
color: var(--text-dim);
|
|
173
|
+
margin-top: 3px;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* Share Card (Hero) */
|
|
177
|
+
.share-card-wrapper {
|
|
178
|
+
margin-bottom: 44px;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.share-card {
|
|
182
|
+
width: 100%;
|
|
183
|
+
background: linear-gradient(145deg, #0c1120 0%, #0f1628 50%, #0b1020 100%);
|
|
184
|
+
border: 1px solid var(--border);
|
|
185
|
+
border-radius: 18px;
|
|
186
|
+
padding: 44px 48px;
|
|
187
|
+
position: relative;
|
|
188
|
+
overflow: hidden;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.share-card::before {
|
|
192
|
+
content: '';
|
|
193
|
+
position: absolute;
|
|
194
|
+
top: -40%;
|
|
195
|
+
right: -15%;
|
|
196
|
+
width: 400px;
|
|
197
|
+
height: 400px;
|
|
198
|
+
background: radial-gradient(circle, ${grade.color}0c 0%, transparent 60%);
|
|
199
|
+
pointer-events: none;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.share-card-inner {
|
|
203
|
+
position: relative;
|
|
204
|
+
z-index: 1;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.share-card .grade-row {
|
|
208
|
+
display: flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
gap: 16px;
|
|
211
|
+
margin-bottom: 28px;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.share-card .grade-badge {
|
|
215
|
+
display: inline-flex;
|
|
216
|
+
align-items: center;
|
|
217
|
+
justify-content: center;
|
|
218
|
+
width: 72px;
|
|
219
|
+
height: 72px;
|
|
220
|
+
border-radius: 18px;
|
|
221
|
+
background: ${grade.color}14;
|
|
222
|
+
border: 1.5px solid ${grade.color}40;
|
|
223
|
+
font-size: 40px;
|
|
224
|
+
font-weight: 800;
|
|
225
|
+
color: ${grade.color};
|
|
226
|
+
letter-spacing: -1px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.share-card .grade-info { }
|
|
230
|
+
|
|
231
|
+
.share-card .grade-label {
|
|
232
|
+
color: ${grade.color};
|
|
233
|
+
font-size: 11px;
|
|
234
|
+
font-weight: 600;
|
|
235
|
+
text-transform: uppercase;
|
|
236
|
+
letter-spacing: 1.5px;
|
|
237
|
+
display: block;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.share-card .grade-desc {
|
|
241
|
+
color: var(--text-muted);
|
|
242
|
+
font-size: 13px;
|
|
243
|
+
font-weight: 400;
|
|
244
|
+
margin-top: 2px;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.share-card .stats-row {
|
|
248
|
+
display: grid;
|
|
249
|
+
grid-template-columns: repeat(3, 1fr);
|
|
250
|
+
gap: 0;
|
|
251
|
+
margin-bottom: 28px;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.share-card .stat {
|
|
255
|
+
text-align: center;
|
|
256
|
+
padding: 0 20px;
|
|
257
|
+
border-right: 1px solid var(--border);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.share-card .stat:first-child { padding-left: 0; }
|
|
261
|
+
.share-card .stat:last-child { border-right: none; }
|
|
262
|
+
|
|
263
|
+
.share-card .stat-value {
|
|
264
|
+
font-size: 38px;
|
|
265
|
+
font-weight: 800;
|
|
266
|
+
letter-spacing: -1.5px;
|
|
267
|
+
line-height: 1;
|
|
268
|
+
margin-bottom: 4px;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.share-card .stat-label {
|
|
272
|
+
font-size: 11px;
|
|
273
|
+
font-weight: 400;
|
|
274
|
+
color: var(--text-muted);
|
|
275
|
+
text-transform: uppercase;
|
|
276
|
+
letter-spacing: 0.8px;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.share-card .branding {
|
|
280
|
+
display: flex;
|
|
281
|
+
justify-content: space-between;
|
|
282
|
+
align-items: center;
|
|
283
|
+
padding-top: 20px;
|
|
284
|
+
border-top: 1px solid var(--border);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.share-card .brand-name {
|
|
288
|
+
font-weight: 600;
|
|
289
|
+
font-size: 14px;
|
|
290
|
+
color: var(--text);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.share-card .brand-sub {
|
|
294
|
+
font-size: 11px;
|
|
295
|
+
color: var(--text-dim);
|
|
296
|
+
font-weight: 400;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* Share button */
|
|
300
|
+
.share-btn-row {
|
|
301
|
+
display: flex;
|
|
302
|
+
justify-content: center;
|
|
303
|
+
margin-top: 16px;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.share-btn {
|
|
307
|
+
display: inline-flex;
|
|
308
|
+
align-items: center;
|
|
309
|
+
gap: 6px;
|
|
310
|
+
padding: 8px 20px;
|
|
311
|
+
background: rgba(124, 133, 245, 0.1);
|
|
312
|
+
border: 1px solid rgba(124, 133, 245, 0.25);
|
|
313
|
+
border-radius: 8px;
|
|
314
|
+
color: var(--accent);
|
|
315
|
+
font-size: 13px;
|
|
316
|
+
font-weight: 500;
|
|
317
|
+
cursor: pointer;
|
|
318
|
+
transition: all 0.2s;
|
|
319
|
+
font-family: inherit;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.share-btn:hover {
|
|
323
|
+
background: rgba(124, 133, 245, 0.18);
|
|
324
|
+
border-color: rgba(124, 133, 245, 0.4);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.share-btn:active { transform: scale(0.97); }
|
|
328
|
+
|
|
329
|
+
.share-btn svg { width: 14px; height: 14px; }
|
|
330
|
+
|
|
331
|
+
/* Inflection callout */
|
|
332
|
+
.inflection-card {
|
|
333
|
+
background: rgba(251, 146, 60, 0.06);
|
|
334
|
+
border: 1px solid rgba(251, 146, 60, 0.15);
|
|
335
|
+
border-left: 3px solid var(--orange);
|
|
336
|
+
border-radius: var(--radius);
|
|
337
|
+
padding: 18px 22px;
|
|
338
|
+
margin-bottom: 36px;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.inflection-card .inflection-title {
|
|
342
|
+
font-size: 13px;
|
|
343
|
+
font-weight: 600;
|
|
344
|
+
color: var(--orange);
|
|
345
|
+
margin-bottom: 4px;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.inflection-card .inflection-detail {
|
|
349
|
+
font-size: 13px;
|
|
350
|
+
font-weight: 400;
|
|
351
|
+
color: var(--text-secondary);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/* Chart */
|
|
355
|
+
.chart-container {
|
|
356
|
+
background: var(--bg-card);
|
|
357
|
+
border: 1px solid var(--border);
|
|
358
|
+
border-radius: var(--radius);
|
|
359
|
+
padding: 24px;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.chart-controls {
|
|
363
|
+
display: flex;
|
|
364
|
+
gap: 6px;
|
|
365
|
+
margin-bottom: 16px;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.chart-btn {
|
|
369
|
+
padding: 4px 12px;
|
|
370
|
+
background: transparent;
|
|
371
|
+
border: 1px solid var(--border);
|
|
372
|
+
border-radius: 6px;
|
|
373
|
+
color: var(--text-dim);
|
|
374
|
+
font-size: 12px;
|
|
375
|
+
font-weight: 500;
|
|
376
|
+
cursor: pointer;
|
|
377
|
+
transition: all 0.15s;
|
|
378
|
+
font-family: inherit;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.chart-btn:hover {
|
|
382
|
+
border-color: rgba(124, 133, 245, 0.3);
|
|
383
|
+
color: var(--text-muted);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.chart-btn.active {
|
|
387
|
+
background: rgba(124, 133, 245, 0.12);
|
|
388
|
+
border-color: rgba(124, 133, 245, 0.35);
|
|
389
|
+
color: #a5b4fc;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#cost-chart-svg {
|
|
393
|
+
width: 100%;
|
|
394
|
+
overflow: visible;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/* Tooltip */
|
|
398
|
+
.chart-tooltip {
|
|
399
|
+
position: fixed;
|
|
400
|
+
background: #0f1a2b;
|
|
401
|
+
border: 1px solid var(--border-strong);
|
|
402
|
+
border-radius: 8px;
|
|
403
|
+
padding: 10px 14px;
|
|
404
|
+
font-size: 13px;
|
|
405
|
+
pointer-events: none;
|
|
406
|
+
opacity: 0;
|
|
407
|
+
transition: opacity 0.15s;
|
|
408
|
+
z-index: 100;
|
|
409
|
+
white-space: nowrap;
|
|
410
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.chart-tooltip.visible { opacity: 1; }
|
|
414
|
+
|
|
415
|
+
.chart-tooltip .tt-date {
|
|
416
|
+
font-weight: 500;
|
|
417
|
+
color: var(--text-muted);
|
|
418
|
+
font-size: 11px;
|
|
419
|
+
text-transform: uppercase;
|
|
420
|
+
letter-spacing: 0.5px;
|
|
421
|
+
margin-bottom: 3px;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.chart-tooltip .tt-cost {
|
|
425
|
+
font-weight: 700;
|
|
426
|
+
font-size: 17px;
|
|
427
|
+
letter-spacing: -0.3px;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.chart-tooltip .tt-anomaly {
|
|
431
|
+
font-size: 11px;
|
|
432
|
+
color: var(--red);
|
|
433
|
+
margin-top: 2px;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* Model Split */
|
|
437
|
+
.model-bar {
|
|
438
|
+
display: flex;
|
|
439
|
+
height: 36px;
|
|
440
|
+
border-radius: 10px;
|
|
441
|
+
overflow: hidden;
|
|
442
|
+
margin-bottom: 16px;
|
|
443
|
+
gap: 2px;
|
|
444
|
+
box-shadow: inset 0 1px 4px rgba(0,0,0,0.2);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.model-bar-segment {
|
|
448
|
+
height: 100%;
|
|
449
|
+
transition: opacity 0.2s;
|
|
450
|
+
cursor: default;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.model-bar-segment:first-child { border-radius: 10px 0 0 10px; }
|
|
454
|
+
.model-bar-segment:last-child { border-radius: 0 10px 10px 0; }
|
|
455
|
+
.model-bar-segment:only-child { border-radius: 10px; }
|
|
456
|
+
.model-bar-segment:hover { opacity: 0.8; }
|
|
457
|
+
|
|
458
|
+
.model-legend {
|
|
459
|
+
display: flex;
|
|
460
|
+
flex-wrap: wrap;
|
|
461
|
+
gap: 12px 20px;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.model-legend-item {
|
|
465
|
+
display: flex;
|
|
466
|
+
align-items: center;
|
|
467
|
+
gap: 7px;
|
|
468
|
+
font-size: 13px;
|
|
469
|
+
font-weight: 400;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.model-legend-item .model-name { color: var(--text-muted); }
|
|
473
|
+
.model-legend-item .model-cost { font-weight: 600; }
|
|
474
|
+
.model-legend-item .model-pct { color: var(--text-dim); font-size: 12px; }
|
|
475
|
+
.model-legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
|
|
476
|
+
|
|
477
|
+
/* Project breakdown */
|
|
478
|
+
.project-table {
|
|
479
|
+
width: 100%;
|
|
480
|
+
border-collapse: collapse;
|
|
481
|
+
font-size: 13px;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.project-table th {
|
|
485
|
+
text-align: left;
|
|
486
|
+
padding: 8px 12px;
|
|
487
|
+
font-size: 11px;
|
|
488
|
+
font-weight: 600;
|
|
489
|
+
text-transform: uppercase;
|
|
490
|
+
letter-spacing: 0.8px;
|
|
491
|
+
color: var(--text-dim);
|
|
492
|
+
border-bottom: 1px solid var(--border);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.project-table td {
|
|
496
|
+
padding: 10px 12px;
|
|
497
|
+
border-bottom: 1px solid var(--border);
|
|
498
|
+
font-weight: 400;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.project-table tr:last-child td { border-bottom: none; }
|
|
502
|
+
.project-table tbody tr:hover td { background: rgba(255,255,255,0.015); }
|
|
503
|
+
|
|
504
|
+
.project-name {
|
|
505
|
+
font-weight: 500;
|
|
506
|
+
color: var(--text);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.project-path {
|
|
510
|
+
font-size: 11px;
|
|
511
|
+
color: var(--text-dim);
|
|
512
|
+
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/* Recommendations */
|
|
516
|
+
.rec-card {
|
|
517
|
+
border: 1px solid var(--border);
|
|
518
|
+
border-radius: var(--radius);
|
|
519
|
+
padding: 18px 22px;
|
|
520
|
+
margin-bottom: 10px;
|
|
521
|
+
border-left: 3px solid;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.rec-card.critical {
|
|
525
|
+
border-left-color: var(--red);
|
|
526
|
+
background: rgba(239, 68, 68, 0.03);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
.rec-card.warning {
|
|
530
|
+
border-left-color: var(--yellow);
|
|
531
|
+
background: rgba(245, 158, 11, 0.03);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.rec-card.info {
|
|
535
|
+
border-left-color: var(--cyan);
|
|
536
|
+
background: rgba(34, 211, 238, 0.02);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.rec-card.positive {
|
|
540
|
+
border-left-color: var(--green);
|
|
541
|
+
background: rgba(16, 185, 129, 0.03);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.rec-title {
|
|
545
|
+
font-weight: 600;
|
|
546
|
+
font-size: 13px;
|
|
547
|
+
margin-bottom: 4px;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.rec-detail {
|
|
551
|
+
font-size: 13px;
|
|
552
|
+
font-weight: 400;
|
|
553
|
+
color: var(--text-muted);
|
|
554
|
+
margin-bottom: 6px;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.rec-action {
|
|
558
|
+
font-size: 13px;
|
|
559
|
+
font-weight: 500;
|
|
560
|
+
color: var(--accent);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/* Rate Limits */
|
|
564
|
+
.rate-bar-bg {
|
|
565
|
+
height: 5px;
|
|
566
|
+
background: rgba(255,255,255,0.04);
|
|
567
|
+
border-radius: 3px;
|
|
568
|
+
overflow: hidden;
|
|
569
|
+
margin: 8px 0 4px;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
.rate-bar-fill {
|
|
573
|
+
height: 100%;
|
|
574
|
+
border-radius: 3px;
|
|
575
|
+
transition: width 0.4s ease;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/* Cache Break Reasons */
|
|
579
|
+
.reason-row {
|
|
580
|
+
display: flex;
|
|
581
|
+
align-items: center;
|
|
582
|
+
gap: 12px;
|
|
583
|
+
margin-bottom: 8px;
|
|
584
|
+
font-size: 13px;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.reason-row .reason-name {
|
|
588
|
+
width: 200px;
|
|
589
|
+
flex-shrink: 0;
|
|
590
|
+
font-weight: 400;
|
|
591
|
+
color: var(--text-muted);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.reason-row .reason-fill-bg {
|
|
595
|
+
flex: 1;
|
|
596
|
+
height: 4px;
|
|
597
|
+
background: rgba(255,255,255,0.04);
|
|
598
|
+
border-radius: 2px;
|
|
599
|
+
overflow: hidden;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
.reason-row .reason-fill {
|
|
603
|
+
height: 100%;
|
|
604
|
+
background: linear-gradient(90deg, var(--orange), #fbbf24);
|
|
605
|
+
border-radius: 2px;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.reason-row .reason-count {
|
|
609
|
+
width: 32px;
|
|
610
|
+
text-align: right;
|
|
611
|
+
font-weight: 400;
|
|
612
|
+
color: var(--text-dim);
|
|
613
|
+
font-size: 12px;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/* Anomaly Table */
|
|
617
|
+
.anomaly-table {
|
|
618
|
+
width: 100%;
|
|
619
|
+
border-collapse: collapse;
|
|
620
|
+
font-size: 13px;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
.anomaly-table th {
|
|
624
|
+
text-align: left;
|
|
625
|
+
padding: 8px 12px;
|
|
626
|
+
font-size: 11px;
|
|
627
|
+
font-weight: 600;
|
|
628
|
+
text-transform: uppercase;
|
|
629
|
+
letter-spacing: 0.8px;
|
|
630
|
+
color: var(--text-dim);
|
|
631
|
+
border-bottom: 1px solid var(--border);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
.anomaly-table td {
|
|
635
|
+
padding: 8px 12px;
|
|
636
|
+
border-bottom: 1px solid var(--border);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
.anomaly-table tr:last-child td { border-bottom: none; }
|
|
640
|
+
.anomaly-table tbody tr:hover td { background: rgba(255,255,255,0.015); }
|
|
641
|
+
|
|
642
|
+
.badge {
|
|
643
|
+
display: inline-flex;
|
|
644
|
+
align-items: center;
|
|
645
|
+
padding: 2px 8px;
|
|
646
|
+
border-radius: 4px;
|
|
647
|
+
font-size: 10px;
|
|
648
|
+
font-weight: 600;
|
|
649
|
+
text-transform: uppercase;
|
|
650
|
+
letter-spacing: 0.6px;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.badge.critical { background: rgba(239, 68, 68, 0.12); color: #f87171; }
|
|
654
|
+
.badge.warning { background: rgba(245, 158, 11, 0.12); color: #fbbf24; }
|
|
655
|
+
.badge.spike { background: rgba(249, 115, 22, 0.12); color: #fb923c; }
|
|
656
|
+
|
|
657
|
+
/* CLAUDE.md */
|
|
658
|
+
.claudemd-row {
|
|
659
|
+
display: flex;
|
|
660
|
+
justify-content: space-between;
|
|
661
|
+
align-items: center;
|
|
662
|
+
padding: 10px 0;
|
|
663
|
+
border-bottom: 1px solid var(--border);
|
|
664
|
+
font-size: 13px;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
.claudemd-row:last-of-type { border-bottom: none; }
|
|
668
|
+
.claudemd-row .row-label { font-weight: 400; color: var(--text-muted); }
|
|
669
|
+
.claudemd-row .row-value { font-weight: 500; color: var(--text); }
|
|
670
|
+
|
|
671
|
+
/* Footer */
|
|
672
|
+
.footer {
|
|
673
|
+
text-align: center;
|
|
674
|
+
padding-top: 48px;
|
|
675
|
+
color: var(--text-dim);
|
|
676
|
+
font-size: 12px;
|
|
677
|
+
font-weight: 400;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.footer a { color: var(--text-dim); text-decoration: none; transition: color 0.2s; }
|
|
681
|
+
.footer a:hover { color: var(--text-muted); }
|
|
682
|
+
.footer .accent-link { color: var(--accent); font-weight: 500; }
|
|
683
|
+
.footer .accent-link:hover { color: #a5b4fc; }
|
|
684
|
+
.footer-tagline { margin-top: 6px; }
|
|
685
|
+
|
|
686
|
+
.divider {
|
|
687
|
+
height: 1px;
|
|
688
|
+
background: linear-gradient(90deg, transparent, var(--border), transparent);
|
|
689
|
+
margin: 0 0 36px;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
#filter-summary {
|
|
693
|
+
font-size: 12px;
|
|
694
|
+
color: var(--text-dim);
|
|
695
|
+
font-weight: 400;
|
|
696
|
+
margin-left: auto;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
#range-label {
|
|
700
|
+
font-size: 12px;
|
|
701
|
+
color: var(--text-dim);
|
|
702
|
+
font-weight: 400;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/* Share toast */
|
|
706
|
+
.toast {
|
|
707
|
+
position: fixed;
|
|
708
|
+
bottom: 24px;
|
|
709
|
+
left: 50%;
|
|
710
|
+
transform: translateX(-50%) translateY(80px);
|
|
711
|
+
background: #1a2236;
|
|
712
|
+
border: 1px solid var(--border-strong);
|
|
713
|
+
border-radius: 8px;
|
|
714
|
+
padding: 10px 20px;
|
|
715
|
+
color: var(--text);
|
|
716
|
+
font-size: 13px;
|
|
717
|
+
font-weight: 500;
|
|
718
|
+
opacity: 0;
|
|
719
|
+
transition: all 0.3s;
|
|
720
|
+
z-index: 200;
|
|
721
|
+
pointer-events: none;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.toast.show {
|
|
725
|
+
opacity: 1;
|
|
726
|
+
transform: translateX(-50%) translateY(0);
|
|
727
|
+
}
|
|
728
|
+
</style>
|
|
729
|
+
</head>
|
|
730
|
+
<body>
|
|
731
|
+
|
|
732
|
+
<div class="chart-tooltip" id="chart-tooltip">
|
|
733
|
+
<div class="tt-date" id="tt-date"></div>
|
|
734
|
+
<div class="tt-cost" id="tt-cost"></div>
|
|
735
|
+
<div class="tt-anomaly" id="tt-anomaly"></div>
|
|
736
|
+
</div>
|
|
737
|
+
|
|
738
|
+
<div class="toast" id="toast">Image saved!</div>
|
|
739
|
+
|
|
740
|
+
<div class="container">
|
|
741
|
+
|
|
742
|
+
<!-- Header -->
|
|
743
|
+
<div class="header">
|
|
744
|
+
<h1>CC Hubber</h1>
|
|
745
|
+
<div class="tagline">What you spent. Why you spent it. Is that normal.</div>
|
|
746
|
+
<div class="generated">Generated ${new Date(generatedAt).toLocaleString()} — <span id="range-label">All time</span></div>
|
|
747
|
+
</div>
|
|
748
|
+
|
|
749
|
+
<!-- Hero / Shareable Card -->
|
|
750
|
+
<div class="share-card-wrapper">
|
|
751
|
+
<div class="share-card" id="share-card">
|
|
752
|
+
<div class="share-card-inner">
|
|
753
|
+
<div class="grade-row">
|
|
754
|
+
<div class="grade-badge">${grade.letter}</div>
|
|
755
|
+
<div class="grade-info">
|
|
756
|
+
<span class="grade-label">Cache Health: ${grade.label}</span>
|
|
757
|
+
<span class="grade-desc">${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1 cache-to-output ratio' : 'No data'}</span>
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
760
|
+
<div class="stats-row">
|
|
761
|
+
<div class="stat">
|
|
762
|
+
<div class="stat-value" id="hero-cost">${fmtCost(totalCost)}</div>
|
|
763
|
+
<div class="stat-label" id="hero-cost-label">Total Spend</div>
|
|
764
|
+
</div>
|
|
765
|
+
<div class="stat">
|
|
766
|
+
<div class="stat-value" id="hero-days">${activeDays}</div>
|
|
767
|
+
<div class="stat-label">Active Days</div>
|
|
768
|
+
</div>
|
|
769
|
+
<div class="stat">
|
|
770
|
+
<div class="stat-value">${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : 'N/A'}</div>
|
|
771
|
+
<div class="stat-label">Cache Ratio</div>
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
<div class="branding">
|
|
775
|
+
<div>
|
|
776
|
+
<div class="brand-name">CC Hubber</div>
|
|
777
|
+
<div class="brand-sub"><a href="https://github.com/azkhh/cchubber" target="_blank" style="color:var(--text-dim);text-decoration:none;">github.com/azkhh/cchubber</a></div>
|
|
778
|
+
</div>
|
|
779
|
+
<div class="brand-sub">Shipped with <a href="https://moveros.dev" target="_blank" style="color:var(--accent);font-weight:500;text-decoration:none;">Mover OS</a></div>
|
|
780
|
+
</div>
|
|
781
|
+
</div>
|
|
782
|
+
</div>
|
|
783
|
+
<div class="share-btn-row">
|
|
784
|
+
<button class="share-btn" id="share-btn">
|
|
785
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /></svg>
|
|
786
|
+
Export as image
|
|
787
|
+
</button>
|
|
788
|
+
</div>
|
|
789
|
+
</div>
|
|
790
|
+
|
|
791
|
+
${oauthUsage ? renderRateLimits(oauthUsage) : ''}
|
|
792
|
+
|
|
793
|
+
${inflection && inflection.multiplier >= 1.5 ? `
|
|
794
|
+
<!-- Inflection Point -->
|
|
795
|
+
<div class="inflection-card">
|
|
796
|
+
<div class="inflection-title">Inflection Point Detected</div>
|
|
797
|
+
<div class="inflection-detail">${inflection.summary}</div>
|
|
798
|
+
</div>
|
|
799
|
+
` : ''}
|
|
800
|
+
|
|
801
|
+
<!-- Overview Cards -->
|
|
802
|
+
<div class="section">
|
|
803
|
+
<div class="section-title">Overview</div>
|
|
804
|
+
<div class="grid">
|
|
805
|
+
<div class="card">
|
|
806
|
+
<div class="label">Total Cost</div>
|
|
807
|
+
<div class="value" id="ov-total">${fmtCost(totalCost)}</div>
|
|
808
|
+
<div class="sub" id="ov-avg">$${(costAnalysis.avgDailyCost || 0).toFixed(2)} avg/day</div>
|
|
809
|
+
</div>
|
|
810
|
+
<div class="card">
|
|
811
|
+
<div class="label">Peak Day</div>
|
|
812
|
+
<div class="value">${peakDay ? fmtCost(peakDay.cost) : '$0'}</div>
|
|
813
|
+
<div class="sub">${peakDay ? peakDay.date : 'N/A'}</div>
|
|
814
|
+
</div>
|
|
815
|
+
<div class="card">
|
|
816
|
+
<div class="label">Cache Health</div>
|
|
817
|
+
<div class="value" style="color: ${grade.color}">${grade.letter}</div>
|
|
818
|
+
<div class="sub">${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1 ratio' : 'N/A'}</div>
|
|
819
|
+
</div>
|
|
820
|
+
<div class="card">
|
|
821
|
+
<div class="label">Cache Breaks</div>
|
|
822
|
+
<div class="value">${cacheHealth.totalCacheBreaks || 0}</div>
|
|
823
|
+
<div class="sub">${cacheHealth.reasonsRanked?.[0] ? 'Top: ' + cacheHealth.reasonsRanked[0].reason : 'None detected'}</div>
|
|
824
|
+
</div>
|
|
825
|
+
<div class="card">
|
|
826
|
+
<div class="label">CLAUDE.md</div>
|
|
827
|
+
<div class="value">~${claudeMdStack.totalTokensEstimate.toLocaleString()}</div>
|
|
828
|
+
<div class="sub">tokens — ${(claudeMdStack.totalBytes / 1024).toFixed(1)} KB</div>
|
|
829
|
+
</div>
|
|
830
|
+
<div class="card">
|
|
831
|
+
<div class="label">Sessions</div>
|
|
832
|
+
<div class="value">${costAnalysis.sessions?.total || 0}</div>
|
|
833
|
+
<div class="sub">${costAnalysis.sessions?.avgDurationMinutes ? Math.round(costAnalysis.sessions.avgDurationMinutes) + ' min avg' : ''}</div>
|
|
834
|
+
</div>
|
|
835
|
+
</div>
|
|
836
|
+
</div>
|
|
837
|
+
|
|
838
|
+
<div class="divider"></div>
|
|
839
|
+
|
|
840
|
+
<!-- Daily Cost Trend -->
|
|
841
|
+
<div class="section">
|
|
842
|
+
<div class="section-title">Daily Cost Trend</div>
|
|
843
|
+
<div class="chart-container">
|
|
844
|
+
<div style="display:flex;align-items:center;gap:0;margin-bottom:16px;">
|
|
845
|
+
<div class="chart-controls" id="range-controls">
|
|
846
|
+
<button class="chart-btn" data-range="7">7d</button>
|
|
847
|
+
<button class="chart-btn" data-range="30">30d</button>
|
|
848
|
+
<button class="chart-btn" data-range="90">90d</button>
|
|
849
|
+
<button class="chart-btn active" data-range="all">All</button>
|
|
850
|
+
</div>
|
|
851
|
+
<span id="filter-summary"></span>
|
|
852
|
+
</div>
|
|
853
|
+
<div id="chart-area">
|
|
854
|
+
<svg id="cost-chart-svg" viewBox="0 0 1000 220" preserveAspectRatio="xMidYMid meet" style="width:100%;overflow:visible;display:block;"></svg>
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
</div>
|
|
858
|
+
|
|
859
|
+
<div class="divider"></div>
|
|
860
|
+
|
|
861
|
+
<!-- Model Split -->
|
|
862
|
+
<div class="section">
|
|
863
|
+
<div class="section-title">Model Cost Split</div>
|
|
864
|
+
<div class="chart-container">
|
|
865
|
+
<div class="model-bar">
|
|
866
|
+
${modelEntries.map(([name, cost], i) => {
|
|
867
|
+
const pct = totalCost > 0 ? (cost / totalCost) * 100 : 0;
|
|
868
|
+
const colors = modelColors[i % modelColors.length];
|
|
869
|
+
return `<div class="model-bar-segment" style="width:${pct}%;background:linear-gradient(135deg,${colors[0]},${colors[1]})" title="${name}: $${cost.toFixed(2)} (${pct.toFixed(1)}%)"></div>`;
|
|
870
|
+
}).join('')}
|
|
871
|
+
</div>
|
|
872
|
+
<div class="model-legend">
|
|
873
|
+
${modelEntries.map(([name, cost], i) => {
|
|
874
|
+
const pct = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(1) : '0';
|
|
875
|
+
const colors = modelColors[i % modelColors.length];
|
|
876
|
+
return `<div class="model-legend-item">
|
|
877
|
+
<div class="model-legend-dot" style="background:linear-gradient(135deg,${colors[0]},${colors[1]})"></div>
|
|
878
|
+
<span class="model-name">${name}</span>
|
|
879
|
+
<span class="model-cost">$${cost.toFixed(2)}</span>
|
|
880
|
+
<span class="model-pct">${pct}%</span>
|
|
881
|
+
</div>`;
|
|
882
|
+
}).join('')}
|
|
883
|
+
</div>
|
|
884
|
+
</div>
|
|
885
|
+
</div>
|
|
886
|
+
|
|
887
|
+
${projectBreakdown && projectBreakdown.length > 0 ? `
|
|
888
|
+
<div class="divider"></div>
|
|
889
|
+
|
|
890
|
+
<!-- Per-Project Breakdown -->
|
|
891
|
+
<div class="section">
|
|
892
|
+
<div class="section-title">Projects</div>
|
|
893
|
+
<div class="chart-container" style="overflow-x:auto;">
|
|
894
|
+
<table class="project-table" id="project-table">
|
|
895
|
+
<thead>
|
|
896
|
+
<tr>
|
|
897
|
+
<th>Project</th>
|
|
898
|
+
<th>Messages</th>
|
|
899
|
+
<th>Sessions</th>
|
|
900
|
+
<th>Output Tokens</th>
|
|
901
|
+
<th>Cache Read</th>
|
|
902
|
+
</tr>
|
|
903
|
+
</thead>
|
|
904
|
+
<tbody></tbody>
|
|
905
|
+
</table>
|
|
906
|
+
</div>
|
|
907
|
+
</div>
|
|
908
|
+
` : ''}
|
|
909
|
+
|
|
910
|
+
${cacheHealth.totalCacheBreaks > 0 ? `
|
|
911
|
+
<div class="divider"></div>
|
|
912
|
+
|
|
913
|
+
<!-- Cache Break Reasons -->
|
|
914
|
+
<div class="section">
|
|
915
|
+
<div class="section-title">Cache Break Reasons</div>
|
|
916
|
+
<div class="chart-container">
|
|
917
|
+
${(cacheHealth.reasonsRanked || []).map(r => `
|
|
918
|
+
<div class="reason-row">
|
|
919
|
+
<span class="reason-name">${r.reason}</span>
|
|
920
|
+
<div class="reason-fill-bg">
|
|
921
|
+
<div class="reason-fill" style="width:${r.percentage}%"></div>
|
|
922
|
+
</div>
|
|
923
|
+
<span class="reason-count">${r.count}</span>
|
|
924
|
+
</div>
|
|
925
|
+
`).join('')}
|
|
926
|
+
</div>
|
|
927
|
+
</div>
|
|
928
|
+
` : ''}
|
|
929
|
+
|
|
930
|
+
${anomalies.hasAnomalies ? `
|
|
931
|
+
<div class="divider"></div>
|
|
932
|
+
|
|
933
|
+
<!-- Anomalies -->
|
|
934
|
+
<div class="section">
|
|
935
|
+
<div class="section-title">Anomalies Detected</div>
|
|
936
|
+
<div class="chart-container" style="padding:0;overflow:hidden;">
|
|
937
|
+
<table class="anomaly-table">
|
|
938
|
+
<thead>
|
|
939
|
+
<tr>
|
|
940
|
+
<th>Date</th>
|
|
941
|
+
<th>Cost</th>
|
|
942
|
+
<th>Deviation</th>
|
|
943
|
+
<th>Cache Ratio</th>
|
|
944
|
+
<th>Severity</th>
|
|
945
|
+
</tr>
|
|
946
|
+
</thead>
|
|
947
|
+
<tbody>
|
|
948
|
+
${anomalies.anomalies.map(a => `
|
|
949
|
+
<tr>
|
|
950
|
+
<td style="font-weight:500;">${a.date}</td>
|
|
951
|
+
<td style="font-weight:600;">$${a.cost.toFixed(2)}</td>
|
|
952
|
+
<td style="color:${a.deviation > 0 ? 'var(--red)' : 'var(--green)'};font-weight:500;">${a.deviation > 0 ? '+' : ''}$${a.deviation.toFixed(2)}</td>
|
|
953
|
+
<td style="color:var(--text-muted);">${a.cacheOutputRatio ? a.cacheOutputRatio.toLocaleString() + ':1' : 'N/A'}</td>
|
|
954
|
+
<td><span class="badge ${a.severity}">${a.severity}</span></td>
|
|
955
|
+
</tr>
|
|
956
|
+
`).join('')}
|
|
957
|
+
</tbody>
|
|
958
|
+
</table>
|
|
959
|
+
</div>
|
|
960
|
+
</div>
|
|
961
|
+
` : ''}
|
|
962
|
+
|
|
963
|
+
${recommendations.length > 0 ? `
|
|
964
|
+
<div class="divider"></div>
|
|
965
|
+
|
|
966
|
+
<!-- Recommendations -->
|
|
967
|
+
<div class="section">
|
|
968
|
+
<div class="section-title">Recommendations</div>
|
|
969
|
+
${recommendations.map(r => `
|
|
970
|
+
<div class="rec-card ${r.severity}">
|
|
971
|
+
<div class="rec-title">${r.title}</div>
|
|
972
|
+
<div class="rec-detail">${r.detail}</div>
|
|
973
|
+
<div class="rec-action">→ ${r.action}</div>
|
|
974
|
+
</div>
|
|
975
|
+
`).join('')}
|
|
976
|
+
</div>
|
|
977
|
+
` : ''}
|
|
978
|
+
|
|
979
|
+
<div class="divider"></div>
|
|
980
|
+
|
|
981
|
+
<!-- CLAUDE.md Stack -->
|
|
982
|
+
<div class="section">
|
|
983
|
+
<div class="section-title">CLAUDE.md Analysis</div>
|
|
984
|
+
<div class="chart-container">
|
|
985
|
+
${claudeMdStack.files.map(f => `
|
|
986
|
+
<div class="claudemd-row">
|
|
987
|
+
<span class="row-label">${f.level}</span>
|
|
988
|
+
<span class="row-value">${(f.bytes / 1024).toFixed(1)} KB — ~${f.tokensEstimate.toLocaleString()} tokens</span>
|
|
989
|
+
</div>
|
|
990
|
+
`).join('')}
|
|
991
|
+
<div class="claudemd-row" style="margin-top:6px;">
|
|
992
|
+
<span class="row-label" style="color:var(--text);font-weight:500;">Per-message cost</span>
|
|
993
|
+
<span class="row-value">$${claudeMdStack.costPerMessage.cached.toFixed(4)} cached / $${claudeMdStack.costPerMessage.uncached.toFixed(4)} uncached</span>
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
</div>
|
|
997
|
+
|
|
998
|
+
${cacheHealth.savings?.fromCaching ? `
|
|
999
|
+
<div class="divider"></div>
|
|
1000
|
+
|
|
1001
|
+
<div class="section">
|
|
1002
|
+
<div class="section-title">Cache Savings</div>
|
|
1003
|
+
<div class="grid">
|
|
1004
|
+
<div class="card">
|
|
1005
|
+
<div class="label">Saved by Cache</div>
|
|
1006
|
+
<div class="value" style="color:var(--green)">~$${Number(cacheHealth.savings.fromCaching).toLocaleString()}</div>
|
|
1007
|
+
<div class="sub">vs standard input pricing</div>
|
|
1008
|
+
</div>
|
|
1009
|
+
<div class="card">
|
|
1010
|
+
<div class="label">Wasted on Breaks</div>
|
|
1011
|
+
<div class="value" style="color:var(--orange)">~$${Number(cacheHealth.savings.wastedFromBreaks).toLocaleString()}</div>
|
|
1012
|
+
<div class="sub">from cache invalidation</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
</div>
|
|
1015
|
+
</div>
|
|
1016
|
+
` : ''}
|
|
1017
|
+
|
|
1018
|
+
<!-- Footer -->
|
|
1019
|
+
<div class="footer">
|
|
1020
|
+
<div>CC Hubber — <a href="https://github.com/azkhh/cchubber" target="_blank">github.com/azkhh/cchubber</a></div>
|
|
1021
|
+
<div class="footer-tagline">Shipped with <a href="https://moveros.dev" target="_blank" class="accent-link">Mover OS</a></div>
|
|
1022
|
+
</div>
|
|
1023
|
+
|
|
1024
|
+
</div>
|
|
1025
|
+
|
|
1026
|
+
<!-- html2canvas for share card export -->
|
|
1027
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
|
1028
|
+
|
|
1029
|
+
<script>
|
|
1030
|
+
(function() {
|
|
1031
|
+
'use strict';
|
|
1032
|
+
|
|
1033
|
+
var ALL_DATA = ${dailyCostsJSON};
|
|
1034
|
+
var PROJECTS = ${projectsJSON};
|
|
1035
|
+
|
|
1036
|
+
// ─── Pricing helper (client-side cost estimation for projects) ───
|
|
1037
|
+
// Simplified: use Opus cache read rate as dominant cost component
|
|
1038
|
+
var CACHE_READ_PER_M = 0.50;
|
|
1039
|
+
var OUTPUT_PER_M = 25;
|
|
1040
|
+
var INPUT_PER_M = 5;
|
|
1041
|
+
var CACHE_WRITE_PER_M = 6.25;
|
|
1042
|
+
|
|
1043
|
+
function estimateCost(p) {
|
|
1044
|
+
return (p.input / 1e6 * INPUT_PER_M) +
|
|
1045
|
+
(p.output / 1e6 * OUTPUT_PER_M) +
|
|
1046
|
+
(p.cacheRead / 1e6 * CACHE_READ_PER_M) +
|
|
1047
|
+
(p.cacheWrite / 1e6 * CACHE_WRITE_PER_M);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// ─── Format helpers ───
|
|
1051
|
+
function fmtCostJS(n) {
|
|
1052
|
+
if (n >= 100) return '$' + Math.round(n).toLocaleString();
|
|
1053
|
+
return '$' + n.toFixed(2);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function fmtTokens(n) {
|
|
1057
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
|
|
1058
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
1059
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
1060
|
+
return n.toString();
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ─── Render project table ───
|
|
1064
|
+
function renderProjectTable() {
|
|
1065
|
+
var tbody = document.querySelector('#project-table tbody');
|
|
1066
|
+
if (!tbody || PROJECTS.length === 0) return;
|
|
1067
|
+
|
|
1068
|
+
// Sort by estimated cost desc
|
|
1069
|
+
var sorted = PROJECTS.slice().sort(function(a, b) {
|
|
1070
|
+
return estimateCost(b) - estimateCost(a);
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
var html = '';
|
|
1074
|
+
for (var i = 0; i < Math.min(sorted.length, 10); i++) {
|
|
1075
|
+
var p = sorted[i];
|
|
1076
|
+
var cost = estimateCost(p);
|
|
1077
|
+
html += '<tr>';
|
|
1078
|
+
html += '<td><div class="project-name">' + p.name + '</div>';
|
|
1079
|
+
if (p.path) html += '<div class="project-path">' + p.path + '</div>';
|
|
1080
|
+
html += '</td>';
|
|
1081
|
+
html += '<td>' + p.messages.toLocaleString() + '</td>';
|
|
1082
|
+
html += '<td>' + p.sessions + '</td>';
|
|
1083
|
+
html += '<td>' + fmtTokens(p.output) + '</td>';
|
|
1084
|
+
html += '<td>' + fmtTokens(p.cacheRead) + '</td>';
|
|
1085
|
+
html += '</tr>';
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
tbody.innerHTML = html;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
renderProjectTable();
|
|
1092
|
+
|
|
1093
|
+
// ─── Chart renderer ───
|
|
1094
|
+
var W = 1000, H = 220;
|
|
1095
|
+
var PAD = { top: 24, right: 24, bottom: 44, left: 64 };
|
|
1096
|
+
var chartW = W - PAD.left - PAD.right;
|
|
1097
|
+
var chartH = H - PAD.top - PAD.bottom;
|
|
1098
|
+
|
|
1099
|
+
var svg = document.getElementById('cost-chart-svg');
|
|
1100
|
+
var tooltip = document.getElementById('chart-tooltip');
|
|
1101
|
+
var ttDate = document.getElementById('tt-date');
|
|
1102
|
+
var ttCost = document.getElementById('tt-cost');
|
|
1103
|
+
var ttAnomaly = document.getElementById('tt-anomaly');
|
|
1104
|
+
|
|
1105
|
+
function getFilteredData(range) {
|
|
1106
|
+
if (range === 'all' || !range) return ALL_DATA;
|
|
1107
|
+
var days = parseInt(range, 10);
|
|
1108
|
+
if (!days || isNaN(days)) return ALL_DATA;
|
|
1109
|
+
return ALL_DATA.slice(-days);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function buildAreaPath(data, maxCost) {
|
|
1113
|
+
if (data.length === 0) return null;
|
|
1114
|
+
var step = chartW / Math.max(data.length - 1, 1);
|
|
1115
|
+
var points = data.map(function(d, i) {
|
|
1116
|
+
var x = PAD.left + (data.length === 1 ? chartW / 2 : i * step);
|
|
1117
|
+
var y = maxCost > 0 ? PAD.top + chartH - (d.cost / maxCost) * chartH : PAD.top + chartH;
|
|
1118
|
+
return { x: x, y: y };
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
var linePath = '';
|
|
1122
|
+
var areaPath = '';
|
|
1123
|
+
var baseY = PAD.top + chartH;
|
|
1124
|
+
|
|
1125
|
+
if (points.length === 1) {
|
|
1126
|
+
linePath = 'M ' + points[0].x + ' ' + points[0].y;
|
|
1127
|
+
areaPath = 'M ' + points[0].x + ' ' + baseY + ' L ' + points[0].x + ' ' + points[0].y + ' Z';
|
|
1128
|
+
} else {
|
|
1129
|
+
linePath = 'M ' + points[0].x + ' ' + points[0].y;
|
|
1130
|
+
for (var i = 1; i < points.length; i++) {
|
|
1131
|
+
var prev = points[i - 1];
|
|
1132
|
+
var curr = points[i];
|
|
1133
|
+
var cpx = (prev.x + curr.x) / 2;
|
|
1134
|
+
linePath += ' C ' + cpx + ' ' + prev.y + ' ' + cpx + ' ' + curr.y + ' ' + curr.x + ' ' + curr.y;
|
|
1135
|
+
}
|
|
1136
|
+
areaPath = 'M ' + points[0].x + ' ' + baseY + ' L ' + points[0].x + ' ' + points[0].y;
|
|
1137
|
+
for (var j = 1; j < points.length; j++) {
|
|
1138
|
+
var pp = points[j - 1];
|
|
1139
|
+
var cp = points[j];
|
|
1140
|
+
var cpxj = (pp.x + cp.x) / 2;
|
|
1141
|
+
areaPath += ' C ' + cpxj + ' ' + pp.y + ' ' + cpxj + ' ' + cp.y + ' ' + cp.x + ' ' + cp.y;
|
|
1142
|
+
}
|
|
1143
|
+
areaPath += ' L ' + points[points.length - 1].x + ' ' + baseY + ' Z';
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
return { linePath: linePath, areaPath: areaPath, points: points };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function renderChart(data) {
|
|
1150
|
+
if (!svg) return;
|
|
1151
|
+
|
|
1152
|
+
if (data.length === 0) {
|
|
1153
|
+
svg.innerHTML = '<text x="500" y="110" text-anchor="middle" fill="#475569" font-size="14">No data for this range</text>';
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
var maxCost = Math.max.apply(null, data.map(function(d) { return d.cost; }));
|
|
1158
|
+
maxCost = maxCost * 1.12;
|
|
1159
|
+
if (maxCost < 0.01) maxCost = 1;
|
|
1160
|
+
|
|
1161
|
+
var paths = buildAreaPath(data, maxCost);
|
|
1162
|
+
if (!paths) return;
|
|
1163
|
+
var gradId = 'areaGrad';
|
|
1164
|
+
|
|
1165
|
+
var html = '';
|
|
1166
|
+
|
|
1167
|
+
html += '<defs>';
|
|
1168
|
+
html += '<linearGradient id="' + gradId + '" x1="0" y1="0" x2="0" y2="1">';
|
|
1169
|
+
html += '<stop offset="0%" stop-color="#6366f1" stop-opacity="0.3"/>';
|
|
1170
|
+
html += '<stop offset="60%" stop-color="#6366f1" stop-opacity="0.06"/>';
|
|
1171
|
+
html += '<stop offset="100%" stop-color="#6366f1" stop-opacity="0"/>';
|
|
1172
|
+
html += '</linearGradient>';
|
|
1173
|
+
html += '</defs>';
|
|
1174
|
+
|
|
1175
|
+
// Grid lines
|
|
1176
|
+
var yTicks = 4;
|
|
1177
|
+
for (var i = 0; i <= yTicks; i++) {
|
|
1178
|
+
var y = PAD.top + (chartH / yTicks) * i;
|
|
1179
|
+
var val = maxCost - (maxCost / yTicks) * i;
|
|
1180
|
+
html += '<line x1="' + PAD.left + '" y1="' + y + '" x2="' + (W - PAD.right) + '" y2="' + y + '" stroke="rgba(255,255,255,0.03)" stroke-width="1"/>';
|
|
1181
|
+
html += '<text x="' + (PAD.left - 10) + '" y="' + (y + 4) + '" text-anchor="end" fill="#475569" font-size="10" font-family="system-ui,sans-serif">$' + (val < 1 ? val.toFixed(2) : val.toFixed(0)) + '</text>';
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
html += '<line x1="' + PAD.left + '" y1="' + (PAD.top + chartH) + '" x2="' + (W - PAD.right) + '" y2="' + (PAD.top + chartH) + '" stroke="rgba(255,255,255,0.05)" stroke-width="1"/>';
|
|
1185
|
+
|
|
1186
|
+
// Area + line
|
|
1187
|
+
html += '<path d="' + paths.areaPath + '" fill="url(#' + gradId + ')"/>';
|
|
1188
|
+
html += '<path d="' + paths.linePath + '" fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
|
|
1189
|
+
|
|
1190
|
+
// X labels
|
|
1191
|
+
var step = data.length > 1 ? chartW / (data.length - 1) : 0;
|
|
1192
|
+
var showEvery = Math.max(1, Math.floor(data.length / 10));
|
|
1193
|
+
data.forEach(function(d, i) {
|
|
1194
|
+
var x = PAD.left + (data.length === 1 ? chartW / 2 : i * step);
|
|
1195
|
+
if (i % showEvery === 0 || i === data.length - 1) {
|
|
1196
|
+
html += '<text x="' + x + '" y="' + (H - 6) + '" text-anchor="middle" fill="#475569" font-size="9" font-family="system-ui,sans-serif">' + d.date.slice(5) + '</text>';
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
// Anomaly dots
|
|
1201
|
+
data.forEach(function(d, i) {
|
|
1202
|
+
var x = PAD.left + (data.length === 1 ? chartW / 2 : i * step);
|
|
1203
|
+
var yy = maxCost > 0 ? PAD.top + chartH - (d.cost / maxCost) * chartH : PAD.top + chartH;
|
|
1204
|
+
if (d.isAnomaly) {
|
|
1205
|
+
html += '<circle cx="' + x + '" cy="' + yy + '" r="4" fill="#ef4444" stroke="#0a0e17" stroke-width="1.5"/>';
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
// Hover targets
|
|
1210
|
+
data.forEach(function(d, i) {
|
|
1211
|
+
var x = PAD.left + (data.length === 1 ? chartW / 2 : i * step);
|
|
1212
|
+
var yy = maxCost > 0 ? PAD.top + chartH - (d.cost / maxCost) * chartH : PAD.top + chartH;
|
|
1213
|
+
html += '<circle cx="' + x + '" cy="' + yy + '" r="16" fill="transparent"';
|
|
1214
|
+
html += ' data-date="' + d.date + '" data-cost="' + d.cost + '" data-anomaly="' + (d.isAnomaly ? '1' : '0') + '"';
|
|
1215
|
+
html += ' class="chart-hover-target" style="cursor:pointer;"/>';
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
svg.innerHTML = html;
|
|
1219
|
+
|
|
1220
|
+
svg.querySelectorAll('.chart-hover-target').forEach(function(el) {
|
|
1221
|
+
el.addEventListener('mouseenter', function(e) {
|
|
1222
|
+
ttDate.textContent = e.target.dataset.date;
|
|
1223
|
+
ttCost.textContent = fmtCostJS(parseFloat(e.target.dataset.cost));
|
|
1224
|
+
ttAnomaly.textContent = e.target.dataset.anomaly === '1' ? 'Anomaly' : '';
|
|
1225
|
+
ttAnomaly.style.display = e.target.dataset.anomaly === '1' ? 'block' : 'none';
|
|
1226
|
+
tooltip.classList.add('visible');
|
|
1227
|
+
});
|
|
1228
|
+
el.addEventListener('mousemove', function(e) {
|
|
1229
|
+
tooltip.style.left = (e.clientX + 16) + 'px';
|
|
1230
|
+
tooltip.style.top = (e.clientY - 40) + 'px';
|
|
1231
|
+
});
|
|
1232
|
+
el.addEventListener('mouseleave', function() {
|
|
1233
|
+
tooltip.classList.remove('visible');
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// ─── Range filter + dynamic headline ───
|
|
1239
|
+
var RANGE_LABELS = { '7': 'Last 7 days', '30': 'Last 30 days', '90': 'Last 90 days', 'all': 'All time' };
|
|
1240
|
+
|
|
1241
|
+
function updateFilterSummary(data) {
|
|
1242
|
+
var el = document.getElementById('filter-summary');
|
|
1243
|
+
if (!el) return;
|
|
1244
|
+
if (!data || data.length === 0) { el.textContent = ''; return; }
|
|
1245
|
+
var total = data.reduce(function(s, d) { return s + d.cost; }, 0);
|
|
1246
|
+
var active = data.filter(function(d) { return d.cost > 0; }).length;
|
|
1247
|
+
el.textContent = active + ' active days · ' + fmtCostJS(total);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function setRange(range) {
|
|
1251
|
+
var filtered = getFilteredData(range);
|
|
1252
|
+
renderChart(filtered);
|
|
1253
|
+
updateFilterSummary(filtered);
|
|
1254
|
+
|
|
1255
|
+
// Update range label in header
|
|
1256
|
+
var rangeLabel = document.getElementById('range-label');
|
|
1257
|
+
if (rangeLabel) rangeLabel.textContent = RANGE_LABELS[range] || 'All time';
|
|
1258
|
+
|
|
1259
|
+
// Update hero + overview with filtered data
|
|
1260
|
+
if (filtered.length > 0) {
|
|
1261
|
+
var totalFiltered = filtered.reduce(function(s, d) { return s + d.cost; }, 0);
|
|
1262
|
+
var activeDaysFiltered = filtered.filter(function(d) { return d.cost > 0; }).length;
|
|
1263
|
+
var heroCost = document.getElementById('hero-cost');
|
|
1264
|
+
var heroDays = document.getElementById('hero-days');
|
|
1265
|
+
var heroCostLabel = document.getElementById('hero-cost-label');
|
|
1266
|
+
var ovTotal = document.getElementById('ov-total');
|
|
1267
|
+
var ovAvg = document.getElementById('ov-avg');
|
|
1268
|
+
if (heroCost) heroCost.textContent = fmtCostJS(totalFiltered);
|
|
1269
|
+
if (heroDays) heroDays.textContent = activeDaysFiltered;
|
|
1270
|
+
if (heroCostLabel) heroCostLabel.textContent = RANGE_LABELS[range] ? RANGE_LABELS[range] + ' Spend' : 'Total Spend';
|
|
1271
|
+
if (ovTotal) ovTotal.textContent = fmtCostJS(totalFiltered);
|
|
1272
|
+
if (ovAvg && activeDaysFiltered > 0) ovAvg.textContent = fmtCostJS(totalFiltered / activeDaysFiltered) + ' avg/day';
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
document.querySelectorAll('.chart-btn[data-range]').forEach(function(btn) {
|
|
1276
|
+
btn.classList.toggle('active', btn.dataset.range === range);
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
document.querySelectorAll('.chart-btn[data-range]').forEach(function(btn) {
|
|
1281
|
+
btn.addEventListener('click', function() { setRange(btn.dataset.range); });
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
// ─── Share/Export PNG ───
|
|
1285
|
+
var shareBtn = document.getElementById('share-btn');
|
|
1286
|
+
var toast = document.getElementById('toast');
|
|
1287
|
+
|
|
1288
|
+
if (shareBtn) {
|
|
1289
|
+
shareBtn.addEventListener('click', function() {
|
|
1290
|
+
var card = document.getElementById('share-card');
|
|
1291
|
+
if (!card || typeof html2canvas === 'undefined') {
|
|
1292
|
+
showToast('html2canvas failed to load');
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
shareBtn.textContent = 'Exporting...';
|
|
1297
|
+
shareBtn.disabled = true;
|
|
1298
|
+
|
|
1299
|
+
html2canvas(card, {
|
|
1300
|
+
backgroundColor: '#0a0e17',
|
|
1301
|
+
scale: 2,
|
|
1302
|
+
useCORS: true,
|
|
1303
|
+
logging: false,
|
|
1304
|
+
width: card.offsetWidth,
|
|
1305
|
+
height: card.offsetHeight,
|
|
1306
|
+
}).then(function(canvas) {
|
|
1307
|
+
// Create download link
|
|
1308
|
+
var link = document.createElement('a');
|
|
1309
|
+
link.download = 'cchubber-report.png';
|
|
1310
|
+
link.href = canvas.toDataURL('image/png');
|
|
1311
|
+
link.click();
|
|
1312
|
+
|
|
1313
|
+
shareBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="width:14px;height:14px;"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /></svg> Export as image';
|
|
1314
|
+
shareBtn.disabled = false;
|
|
1315
|
+
showToast('Image saved!');
|
|
1316
|
+
}).catch(function() {
|
|
1317
|
+
shareBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="width:14px;height:14px;"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /></svg> Export as image';
|
|
1318
|
+
shareBtn.disabled = false;
|
|
1319
|
+
showToast('Export failed');
|
|
1320
|
+
});
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function showToast(msg) {
|
|
1325
|
+
if (!toast) return;
|
|
1326
|
+
toast.textContent = msg;
|
|
1327
|
+
toast.classList.add('show');
|
|
1328
|
+
setTimeout(function() { toast.classList.remove('show'); }, 2000);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Initial render
|
|
1332
|
+
setRange('all');
|
|
1333
|
+
})();
|
|
1334
|
+
</script>
|
|
1335
|
+
</body>
|
|
1336
|
+
</html>`;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function renderRateLimits(usage) {
|
|
1340
|
+
const fiveHour = usage.five_hour;
|
|
1341
|
+
const sevenDay = usage.seven_day;
|
|
1342
|
+
|
|
1343
|
+
if (!fiveHour && !sevenDay) return '';
|
|
1344
|
+
|
|
1345
|
+
const fivePct = fiveHour?.utilization ?? 0;
|
|
1346
|
+
const sevenPct = sevenDay?.utilization ?? 0;
|
|
1347
|
+
|
|
1348
|
+
const fiveColor = fivePct > 80 ? 'var(--red)' : fivePct > 50 ? 'var(--yellow)' : 'var(--green)';
|
|
1349
|
+
const sevenColor = sevenPct > 80 ? 'var(--red)' : sevenPct > 50 ? 'var(--yellow)' : 'var(--green)';
|
|
1350
|
+
|
|
1351
|
+
return `
|
|
1352
|
+
<div class="section" style="margin-bottom:36px;">
|
|
1353
|
+
<div class="section-title">Live Rate Limits</div>
|
|
1354
|
+
<div class="grid">
|
|
1355
|
+
<div class="card">
|
|
1356
|
+
<div class="label">5-Hour Session</div>
|
|
1357
|
+
<div class="value" style="color:${fiveColor}">${fivePct}%</div>
|
|
1358
|
+
<div class="rate-bar-bg">
|
|
1359
|
+
<div class="rate-bar-fill" style="width:${fivePct}%;background:${fiveColor};"></div>
|
|
1360
|
+
</div>
|
|
1361
|
+
<div class="sub">${fiveHour?.resets_at ? 'Resets ' + new Date(fiveHour.resets_at).toLocaleTimeString() : ''}</div>
|
|
1362
|
+
</div>
|
|
1363
|
+
<div class="card">
|
|
1364
|
+
<div class="label">7-Day Rolling</div>
|
|
1365
|
+
<div class="value" style="color:${sevenColor}">${sevenPct}%</div>
|
|
1366
|
+
<div class="rate-bar-bg">
|
|
1367
|
+
<div class="rate-bar-fill" style="width:${sevenPct}%;background:${sevenColor};"></div>
|
|
1368
|
+
</div>
|
|
1369
|
+
<div class="sub">${sevenDay?.resets_at ? 'Resets ' + new Date(sevenDay.resets_at).toLocaleDateString() : ''}</div>
|
|
1370
|
+
</div>
|
|
1371
|
+
</div>
|
|
1372
|
+
<div style="height:36px;"></div>
|
|
1373
|
+
<div class="divider" style="margin-bottom:0;"></div>
|
|
1374
|
+
</div>`;
|
|
1375
|
+
}
|