cchubber 0.5.8 → 0.6.1

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.
@@ -1,1280 +1,1307 @@
1
- import { readFileSync } from 'fs';
2
- import { join, dirname } from 'path';
3
- import { fileURLToPath } from 'url';
4
-
5
- const __dirname = dirname(fileURLToPath(import.meta.url));
6
- const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
7
-
8
- function esc(s) {
9
- if (!s) return '';
10
- return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
11
- }
12
-
13
- export function renderHTML(report) {
14
- const { costAnalysis, cacheHealth, anomalies, inflection, sessionIntel, modelRouting, projectBreakdown, claudeMdStack, oauthUsage, recommendations, generatedAt } = report;
15
-
16
- const dailyCosts = costAnalysis.dailyCosts || [];
17
- const grade = cacheHealth.grade || { letter: '?', color: '#666', label: 'Unknown' };
18
- const totalCost = costAnalysis.totalCost || 0;
19
- const activeDays = costAnalysis.activeDays || 0;
20
- const peakDay = costAnalysis.peakDay;
21
-
22
- const modelCosts = costAnalysis.modelCosts || {};
23
- const modelEntries = Object.entries(modelCosts).filter(([, c]) => c > 0.01).sort((a, b) => b[1] - a[1]);
24
- const anomalyDates = new Set((anomalies.anomalies || []).map(a => a.date));
25
-
26
- const dailyCostsJSON = JSON.stringify(dailyCosts.map(d => ({
27
- date: d.date, cost: d.cost, cacheOutputRatio: d.cacheOutputRatio || 0, isAnomaly: anomalyDates.has(d.date),
28
- out: d.outputTokens || 0, inp: d.inputTokens || 0, cr: d.cacheReadTokens || 0,
29
- })));
30
-
31
- const projectsJSON = JSON.stringify((projectBreakdown || []).map(p => ({
32
- name: p.name, path: p.path, messages: p.messageCount, sessions: p.sessionCount,
33
- input: p.inputTokens, output: p.outputTokens, cacheRead: p.cacheReadTokens, cacheWrite: p.cacheCreationTokens,
34
- })));
35
-
36
- const fmtCost = (n) => '$' + (n >= 100 ? Math.round(n).toLocaleString() : n.toFixed(2));
37
- const fmtDuration = (m) => m >= 120 ? Math.round(m/60) + 'h' : m >= 60 ? (m/60).toFixed(1) + 'h' : m + 'm';
38
-
39
- // Grade color mapping to Stitch palette
40
- const gradeColorMap = {
41
- 'A': '#c0c1ff', // primary indigo
42
- 'B': '#d4bbff', // tertiary purple
43
- 'C': '#ffb690', // secondary orange
44
- 'D': '#ffb4ab', // error red
45
- 'F': '#ffb4ab', // error red
46
- };
47
- const gradeColor = gradeColorMap[grade.letter] || '#908fa0';
48
-
49
- // Grade label mapping
50
- const gradeLabelMap = {
51
- 'A': 'Excellent Performance',
52
- 'B': 'Good Performance',
53
- 'C': 'Fair Performance',
54
- 'D': 'Poor Performance',
55
- 'F': 'Critical Performance',
56
- };
57
- const gradeLabel = gradeLabelMap[grade.letter] || grade.label || 'Unknown';
58
-
59
- // Diagnosis one-liner for the share card
60
- const diagnosisLine = inflection && inflection.direction === 'worsened' && inflection.multiplier >= 2
61
- ? `Efficiency dropped ${inflection.multiplier}x on ${inflection.date}`
62
- : anomalies.hasAnomalies
63
- ? `${anomalies.anomalies.length} anomal${anomalies.anomalies.length === 1 ? 'y' : 'ies'} detected`
64
- : grade.letter === 'A' ? 'System running clean'
65
- : grade.letter === 'B' ? 'Minor optimization opportunities'
66
- : `Cache efficiency needs attention`;
67
-
68
- // Model colors from Stitch palette
69
- const modelColors = ['#c0c1ff', '#d4bbff', '#ffb690', '#8083ff', '#a775ff', '#ffb4ab'];
70
-
71
- // Severity to Stitch color mapping
72
- const sevColorMap = {
73
- critical: { border: '#ffb4ab', bg: 'rgba(255, 180, 171, 0.10)', text: '#ffb4ab' },
74
- warning: { border: '#ffb690', bg: 'rgba(255, 182, 144, 0.10)', text: '#ffb690' },
75
- info: { border: '#c0c1ff', bg: 'rgba(192, 193, 255, 0.10)', text: '#c0c1ff' },
76
- positive: { border: '#c0c1ff', bg: 'rgba(192, 193, 255, 0.10)', text: '#c0c1ff' },
77
- };
78
-
79
- return `<!DOCTYPE html>
80
- <html class="dark" lang="en">
81
- <head>
82
- <meta charset="UTF-8">
83
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
84
- <title>CC Hubber</title>
85
- <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
86
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
87
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
88
- <script>
89
- tailwind.config = {
90
- darkMode: "class",
91
- theme: {
92
- extend: {
93
- colors: {
94
- "background": "#121315",
95
- "surface": "#121315",
96
- "surface-dim": "#121315",
97
- "surface-container-lowest": "#0d0e0f",
98
- "surface-container-low": "#1b1c1d",
99
- "surface-container": "#1f2021",
100
- "surface-container-high": "#292a2b",
101
- "surface-container-highest": "#343536",
102
- "surface-bright": "#38393a",
103
- "on-surface": "#e3e2e3",
104
- "on-surface-variant": "#c7c4d7",
105
- "on-background": "#e3e2e3",
106
- "outline": "#908fa0",
107
- "outline-variant": "#464554",
108
- "primary": "#c0c1ff",
109
- "primary-container": "#8083ff",
110
- "on-primary": "#1000a9",
111
- "secondary": "#ffb690",
112
- "secondary-container": "#ec6a06",
113
- "on-secondary": "#552100",
114
- "tertiary": "#d4bbff",
115
- "tertiary-container": "#a775ff",
116
- "error": "#ffb4ab",
117
- "error-container": "#93000a",
118
- "on-error": "#690005",
119
- "inverse-surface": "#e3e2e3",
120
- "inverse-on-surface": "#303032",
121
- "inverse-primary": "#494bd6",
122
- "surface-tint": "#c0c1ff",
123
- },
124
- fontFamily: {
125
- "headline": ["Inter", "system-ui", "sans-serif"],
126
- "body": ["Inter", "system-ui", "sans-serif"],
127
- "label": ["Inter", "system-ui", "sans-serif"],
128
- "mono": ["JetBrains Mono", "monospace"],
129
- },
130
- borderRadius: {
131
- "DEFAULT": "0.125rem",
132
- "lg": "0.25rem",
133
- "xl": "0.5rem",
134
- "full": "0.75rem",
135
- },
136
- },
137
- },
138
- }
139
- </script>
140
- <style>
141
- body {
142
- background-color: #121315;
143
- color: #e3e2e3;
144
- font-family: 'Inter', system-ui, sans-serif;
145
- -webkit-font-smoothing: antialiased;
146
- -moz-osx-font-smoothing: grayscale;
147
- }
148
- .font-mono { font-family: 'JetBrains Mono', monospace !important; }
149
-
150
- /* Info tooltips */
151
- .has-tip{position:relative;cursor:help;}
152
- .has-tip .tip{
153
- position:absolute;top:calc(100% + 8px);left:0;transform:none;
154
- background:#292a2b;border:1px solid rgba(70,69,84,0.3);border-radius:8px;
155
- padding:10px 14px;font-size:11px;line-height:1.5;color:#c7c4d7;
156
- width:280px;pointer-events:none;opacity:0;transition:opacity 0.15s;
157
- z-index:50;text-transform:none;letter-spacing:0;font-weight:400;
158
- box-shadow:0 8px 24px rgba(0,0,0,0.4);
159
- }
160
- .has-tip:hover .tip{opacity:1;pointer-events:auto;}
161
-
162
- /* Tooltip */
163
- .tt {
164
- position: fixed;
165
- background: rgba(31, 32, 33, 0.95);
166
- backdrop-filter: blur(12px);
167
- border: 1px solid rgba(70, 69, 84, 0.3);
168
- border-radius: 0.5rem;
169
- padding: 10px 14px;
170
- pointer-events: none;
171
- opacity: 0;
172
- transition: opacity 0.12s;
173
- z-index: 100;
174
- white-space: nowrap;
175
- }
176
- .tt.on { opacity: 1; }
177
-
178
- /* Toast */
179
- .toast {
180
- position: fixed;
181
- bottom: 20px;
182
- left: 50%;
183
- transform: translateX(-50%) translateY(60px);
184
- background: #292a2b;
185
- border: 1px solid rgba(70, 69, 84, 0.3);
186
- border-radius: 0.5rem;
187
- padding: 10px 20px;
188
- font-size: 12px;
189
- font-weight: 600;
190
- color: #e3e2e3;
191
- opacity: 0;
192
- transition: all 0.25s;
193
- z-index: 200;
194
- pointer-events: none;
195
- }
196
- .toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
197
-
198
- /* SVG chart */
199
- #cost-chart-svg { width: 100%; overflow: visible; display: block; }
200
-
201
- /* Table hover */
202
- .tbl-row:hover { background: #292a2b; }
203
- .tbl-row { transition: background 0.15s; }
204
- </style>
205
- </head>
206
- <body class="selection:bg-primary selection:text-on-primary">
207
-
208
- <div class="tt" id="tt">
209
- <div class="font-mono text-[11px] text-[#908fa0] mb-1" id="tt-d"></div>
210
- <div class="font-mono text-[15px] font-bold text-[#e3e2e3]" id="tt-c"></div>
211
- <div class="text-[10px] text-[#ffb4ab] mt-1" id="tt-a"></div>
212
- </div>
213
- <div class="toast" id="toast"></div>
214
-
215
- <!-- 1. HEADER -->
216
- <header class="w-full px-6 py-5 max-w-[1200px] mx-auto flex justify-between items-baseline">
217
- <div class="flex items-baseline gap-4">
218
- <a href="https://github.com/azkhh/cchubber" target="_blank" class="text-lg font-bold tracking-tight text-[#e3e2e3]" style="text-decoration:none;">CC Hubber</a>
219
- <span class="text-[10px] font-mono text-[#908fa0]">v${PKG_VERSION}</span>
220
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0]">shipped fast with <a href="https://moveros.dev" target="_blank" style="text-decoration:none;color:inherit;">Mover OS</a></span>
221
- </div>
222
- <span class="font-mono text-[11px] text-[#908fa0]" id="range-lbl">All time</span>
223
- </header>
224
-
225
- <main class="pb-20 px-6 max-w-[1200px] mx-auto space-y-12">
226
-
227
- <!-- 2. SHARE CARD — HTML for display, Canvas for video export -->
228
- <section class="flex flex-col items-center">
229
- <style>
230
- @keyframes cardFloat{
231
- 0%,100%{transform:perspective(800px) rotateY(-2deg) rotateX(1deg)}
232
- 50%{transform:perspective(800px) rotateY(2deg) rotateX(-1deg)}
233
- }
234
- @keyframes shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}
235
- .cc-card{
236
- position:relative;width:100%;max-width:740px;
237
- border-radius:22px;overflow:hidden;
238
- background:linear-gradient(145deg,#1a1b2e 0%,#151622 20%,#0f1018 40%,#131428 55%,#191a2d 70%,#141520 85%,#12131f 100%);
239
- box-shadow:0 2px 4px rgba(0,0,0,0.1),0 8px 16px rgba(0,0,0,0.1),0 16px 32px rgba(0,0,0,0.15);
240
- animation:cardFloat 6s ease-in-out infinite;
241
- }
242
- .cc-card::before{
243
- content:'';position:absolute;inset:0;
244
- background:linear-gradient(105deg,transparent 30%,rgba(192,193,255,0.04) 45%,rgba(212,187,255,0.06) 50%,rgba(192,193,255,0.04) 55%,transparent 70%);
245
- background-size:200% 100%;animation:shimmer 4s ease-in-out infinite;
246
- pointer-events:none;z-index:2;
247
- }
248
- .cc-card::after{
249
- content:'';position:absolute;inset:0;z-index:1;pointer-events:none;opacity:0.035;
250
- background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
251
- background-size:128px 128px;
252
- }
253
- .cc-inner{position:relative;z-index:3;display:flex;flex-direction:column;justify-content:space-between;padding:36px 40px;min-height:280px;}
254
- .cc-card.no-shimmer::before{display:none!important;}
255
- </style>
256
- <div class="cc-card" id="share-card-html">
257
- <div class="cc-inner">
258
- <div class="flex items-start justify-between">
259
- <div class="flex items-center gap-4">
260
- <div class="w-14 h-14 flex items-center justify-center rounded-[12px]" style="background:${gradeColor}">
261
- <span class="text-[30px]" style="color:#0f1018;font-weight:900;">${grade.letter}</span>
262
- </div>
263
- <div>
264
- <span class="text-[10px] font-mono uppercase tracking-[0.08em] font-bold block" style="color:${gradeColor}">${grade.label}</span>
265
- <span class="text-xl font-bold text-[#e3e2e3]">${gradeLabel}</span>
266
- </div>
267
- </div>
268
- <div class="text-right">
269
- <span class="text-[11px] font-mono uppercase tracking-[0.1em] text-[#908fa0] font-bold block">Claude Code</span>
270
- <span class="text-[11px] font-mono uppercase tracking-[0.06em] text-[#596678] block" id="card-range">All time</span>
271
- </div>
272
- </div>
273
- <div class="flex justify-between items-end">
274
- <div>
275
- <p class="text-[10px] uppercase tracking-[0.06em] text-[#908fa0] mb-1">Total Spend</p>
276
- <p class="font-mono text-[40px] font-bold text-[#e3e2e3] leading-none" id="h-cost">${fmtCost(totalCost)}</p>
277
- </div>
278
- <div class="text-center">
279
- <p class="text-[10px] uppercase tracking-[0.06em] text-[#908fa0] mb-1">Active Days</p>
280
- <p class="font-mono text-[40px] font-bold text-[#e3e2e3] leading-none" id="h-days">${activeDays}</p>
281
- </div>
282
- <div class="text-right">
283
- <p class="text-[10px] uppercase tracking-[0.06em] text-[#908fa0] mb-1">Cache Ratio</p>
284
- <p class="font-mono text-[40px] font-bold text-[#e3e2e3] leading-none">${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : 'N/A'}</p>
285
- </div>
286
- </div>
287
- <div class="flex justify-between items-end">
288
- <p class="text-[12px] text-[#908fa0]">${diagnosisLine}</p>
289
- <div class="flex items-center gap-2 text-[12px] font-mono tracking-[0.03em] shrink-0">
290
- <a href="https://github.com/azkhh/cchubber" target="_blank" class="text-[#c0c1ff] hover:text-[#e1e0ff]" style="text-decoration:none;font-weight:600;">CC Hubber</a>
291
- <span class="text-[#464554]">&middot;</span>
292
- <span class="text-[#908fa0]">shipped fast with</span>
293
- <a href="https://moveros.dev" target="_blank" class="text-[#c0c1ff] hover:text-[#e1e0ff]" style="text-decoration:none;font-weight:600;">Mover OS</a>
294
- </div>
295
- </div>
296
- </div>
297
- </div>
298
- <!-- Hidden canvas for video recording -->
299
- <canvas id="share-card" style="display:none;"></canvas>
300
- <div class="flex justify-center mt-5">
301
- <button id="btn-gif" class="px-5 py-2 border border-[rgba(70,69,84,0.3)] rounded-lg text-xs font-semibold text-[#908fa0] hover:bg-[#292a2b] hover:text-[#e3e2e3] transition-colors flex items-center gap-2 cursor-pointer">
302
- <span class="material-symbols-outlined text-sm">share</span>
303
- Share
304
- </button>
305
- </div>
306
- </section>
307
-
308
- ${inflection && inflection.multiplier >= 1.5 ? `
309
- <!-- Inflection callouts -->
310
- <section class="space-y-3">
311
- <div class="p-6 bg-[#0d0e0f] border-l-4 rounded-r-xl" style="border-left-color:${inflection.direction === 'worsened' ? '#ffb4ab' : '#c0c1ff'}">
312
- <p class="text-xs font-bold uppercase tracking-[0.05em] mb-1" style="color:${inflection.direction === 'worsened' ? '#ffb4ab' : '#c0c1ff'}">${inflection.direction === 'worsened' ? 'Degradation Detected' : 'Inflection Point'}</p>
313
- <p class="text-sm text-[#c7c4d7]">${inflection.summary}</p>
314
- </div>
315
- ${inflection.secondary ? `
316
- <div class="p-6 bg-[#0d0e0f] border-l-4 rounded-r-xl" style="border-left-color:${inflection.secondary.direction === 'worsened' ? '#ffb4ab' : '#c0c1ff'}">
317
- <p class="text-xs font-bold uppercase tracking-[0.05em] mb-1" style="color:${inflection.secondary.direction === 'worsened' ? '#ffb4ab' : '#c0c1ff'}">${inflection.secondary.direction === 'worsened' ? 'Degradation Detected' : 'Recovery Detected'}</p>
318
- <p class="text-sm text-[#c7c4d7]">${inflection.secondary.summary}</p>
319
- </div>` : ''}
320
- </section>
321
- ` : ''}
322
-
323
- <!-- 3. METRIC GRID -->
324
- <section class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 rounded-xl border border-[rgba(70,69,84,0.15)]" style="gap:1px; background:rgba(70,69,84,0.15);">
325
- <div class="p-6 bg-[#0d0e0f]">
326
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Total Cost</span>
327
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]" id="ov-total">${fmtCost(totalCost)}</span>
328
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono" id="ov-avg">${fmtCost(costAnalysis.avgDailyCost || 0)} avg/day</span>
329
- </div>
330
- <div class="p-6 bg-[#0d0e0f]">
331
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Peak Day</span>
332
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${peakDay ? fmtCost(peakDay.cost) : '$0'}</span>
333
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${peakDay ? peakDay.date : ''}</span>
334
- </div>
335
- <div class="p-6 bg-[#0d0e0f]">
336
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3 has-tip">Cache Health <span class="text-[8px] text-[#464554]">&#9432;</span>
337
- <span class="tip">Overall grade based on your cache efficiency ratio, weighted towards recent 7 days. A-B = healthy (300-800:1 ratio). C = elevated, investigate. D-F = critical, likely affected by cache bugs. The grade drops when recent efficiency is worse than historical.</span>
338
- </span>
339
- <span class="font-mono text-2xl font-bold block" style="color:${gradeColor}">${grade.letter}</span>
340
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : ''}</span>
341
- </div>
342
- <div class="p-6 bg-[#0d0e0f]">
343
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3 has-tip">Cache Breaks <span class="text-[8px] text-[#464554]">&#9432;</span>
344
- <span class="tip">When your prompt cache is invalidated, Claude Code re-reads your entire context at 12.5x the cached price. Causes: editing CLAUDE.md mid-session, connecting/disconnecting MCP tools, model switches, 5-min inactivity timeout. ${cacheHealth.totalCacheBreaks > 0 ? 'Counted from cache-break diff files.' : 'Estimated from cache write tokens since no diff files exist on your CC version.'}</span>
345
- </span>
346
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${cacheHealth.totalCacheBreaks > 0 ? cacheHealth.totalCacheBreaks : '~' + (cacheHealth.estimatedBreaks || 0)}</span>
347
- <span class="text-[10px] text-[#908fa0] mt-1 block">${cacheHealth.totalCacheBreaks > 0 ? cacheHealth.reasonsRanked?.[0]?.reason : 'estimated from writes'}</span>
348
- </div>
349
- <div class="p-6 bg-[#0d0e0f]">
350
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">CLAUDE.md</span>
351
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">~${Math.round(claudeMdStack.totalTokensEstimate / 1000)}K</span>
352
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${(claudeMdStack.totalBytes / 1024).toFixed(1)} KB</span>
353
- </div>
354
- ${sessionIntel?.available ? `
355
- <div class="p-6 bg-[#0d0e0f]">
356
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Sessions</span>
357
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${sessionIntel.totalSessions}</span>
358
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${sessionIntel.avgDuration} min avg</span>
359
- </div>` : `
360
- <div class="p-6 bg-[#0d0e0f]">
361
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Sessions</span>
362
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${costAnalysis.sessions?.total || 0}</span>
363
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${costAnalysis.sessions?.avgDurationMinutes ? Math.round(costAnalysis.sessions.avgDurationMinutes) + ' min avg' : ''}</span>
364
- </div>`}
365
-
366
- </section>
367
-
368
- <!-- 4. COST TREND CHART -->
369
- <section class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
370
- <div class="flex justify-between items-end mb-10">
371
- <div>
372
- <h3 class="text-xl font-bold text-[#e3e2e3] mb-1">Cost Trend</h3>
373
- <p class="text-sm text-[#908fa0]" id="chart-info"></p>
374
- </div>
375
- <div class="flex gap-1 p-1 bg-[#0d0e0f] rounded-xl border border-[rgba(70,69,84,0.15)]" id="filters">
376
- <button class="cfilt px-3 py-1.5 text-[10px] font-bold uppercase rounded-lg text-[#908fa0] hover:text-[#e3e2e3] transition-colors" data-r="7">7d</button>
377
- <button class="cfilt px-3 py-1.5 text-[10px] font-bold uppercase rounded-lg text-[#908fa0] hover:text-[#e3e2e3] transition-colors" data-r="30">30d</button>
378
- <button class="cfilt px-3 py-1.5 text-[10px] font-bold uppercase rounded-lg text-[#908fa0] hover:text-[#e3e2e3] transition-colors" data-r="90">90d</button>
379
- <button class="cfilt px-3 py-1.5 text-[10px] font-bold uppercase rounded-lg bg-[#c0c1ff] text-[#1000a9] transition-colors" data-r="all">All</button>
380
- </div>
381
- </div>
382
- <svg id="cost-chart-svg" viewBox="0 0 900 200" preserveAspectRatio="xMidYMid meet"></svg>
383
- </section>
384
-
385
- ${report.valueTrend?.available ? (() => {
386
- const vt = report.valueTrend;
387
- const comp = vt.comparison;
388
- const trendColor = vt.trend === 'declining' ? '#ffb4ab' : vt.trend === 'improving' ? '#10b981' : '#908fa0';
389
-
390
- // Build verdict around output per dollar — the metric people actually care about
391
- let verdict, verdictColor, verdictDetail;
392
- if (!comp || !comp.olderAvgPerDollar) {
393
- verdict = 'Not enough data to compare yet. Run again next week.';
394
- verdictColor = '#908fa0';
395
- verdictDetail = '';
396
- } else if (comp.dollarChangePct < -20) {
397
- verdict = 'Yes. ' + Math.abs(comp.dollarChangePct) + '% less output per dollar recently.';
398
- verdictColor = '#ffb4ab';
399
- var limitImpact = Math.round(100 / (100 - Math.abs(comp.dollarChangePct)) * 100 - 100);
400
- verdictDetail = 'Before: ' + comp.olderAvgPerDollar.toLocaleString() + ' tokens/$1. Now: ' + comp.recentAvgPerDollar.toLocaleString() + ' tokens/$1. In practice, your usage limits run out ~' + limitImpact + '% faster than before. This could be caused by cache changes, heavier Opus usage, or longer sessions.';
401
- } else if (comp.dollarChangePct > 20) {
402
- verdict = 'No. Actually getting ' + comp.dollarChangePct + '% more output per dollar recently.';
403
- verdictColor = '#10b981';
404
- verdictDetail = 'Before: ' + comp.olderAvgPerDollar.toLocaleString() + ' tokens/$1. Now: ' + comp.recentAvgPerDollar.toLocaleString() + ' tokens/$1.';
405
- } else {
406
- verdict = 'No. Your output per dollar is stable.';
407
- verdictColor = '#10b981';
408
- verdictDetail = comp.recentAvgPerDollar.toLocaleString() + ' output tokens per $1 (was ' + comp.olderAvgPerDollar.toLocaleString() + ').';
409
- }
410
-
411
- return `
412
- <!-- VALUE TREND -->
413
- <section class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
414
- <h3 class="text-xl font-bold text-[#e3e2e3] mb-4">Are you getting less for your money?</h3>
415
- <p class="text-[15px] font-semibold mb-1" style="color:${verdictColor}">${verdict}</p>
416
- ${verdictDetail ? '<p class="text-[12px] text-[#908fa0] mb-2">' + verdictDetail + '</p>' : ''}
417
- ${inflection ? '<p class="text-[11px] text-[#908fa0] mb-4">A cache change was detected on ' + esc(inflection.date) + '. Your recent 7 days reflect where you are now.</p>' : ''}
418
-
419
- ${(vt.anomalies?.length > 0 || vt.costAnomalies?.length > 0) ? `
420
- <div class="mt-6 space-y-2">
421
- ${vt.anomalies?.length > 0 ? `
422
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Days with unusually short responses</span>
423
- ${vt.anomalies.slice(0, 3).map(a => `
424
- <div class="p-3 bg-[#0d0e0f] rounded-lg flex items-center justify-between">
425
- <span class="text-[11px] font-mono text-[#908fa0]">${a.date}</span>
426
- <span class="text-[11px] text-[#e3e2e3]">~${Math.round(a.outputPerMsg / 1.3)} words/response</span>
427
- <span class="text-[10px] font-mono px-2 py-0.5 rounded" style="background:rgba(255,180,171,0.15);color:#ffb4ab">${a.deviation}σ below avg</span>
428
- </div>`).join('')}` : ''}
429
- ${vt.costAnomalies?.length > 0 ? `
430
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mt-3 mb-1">Days where each message cost more than usual (${vt.costAnomalies.length} days)</span>
431
- ${vt.costAnomalies.slice(0, 5).map(a => `
432
- <div class="p-3 bg-[#0d0e0f] rounded-lg grid" style="grid-template-columns:100px 1fr auto;gap:12px;align-items:center">
433
- <span class="text-[11px] font-mono text-[#908fa0]">${a.date}</span>
434
- <span class="text-[11px] text-[#e3e2e3]">$${a.costPerMsg}/msg vs $${a.medianCostPerMsg} median</span>
435
- <span class="text-[10px] font-mono px-2 py-0.5 rounded" style="background:rgba(255,180,171,0.15);color:#ffb4ab">${a.multiplier}x</span>
436
- </div>`).join('')}` : ''}
437
- </div>` : ''}
438
- </section>`;
439
- })() : ''}
440
-
441
- <!-- 5. SESSION INTELLIGENCE + MODEL DISTRIBUTION -->
442
- <section class="grid grid-cols-1 lg:grid-cols-2 gap-8">
443
-
444
- <!-- Session Intelligence -->
445
- <div class="space-y-8">
446
- ${sessionIntel?.available ? `
447
- <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
448
- <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Session Intelligence</h3>
449
- <div class="grid grid-cols-3 gap-6 mb-8">
450
- <div>
451
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Median</span>
452
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${fmtDuration(sessionIntel.medianDuration)}</span>
453
- </div>
454
- <div>
455
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">P90</span>
456
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${fmtDuration(sessionIntel.p90Duration)}</span>
457
- </div>
458
- <div>
459
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Longest</span>
460
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${fmtDuration(sessionIntel.maxDuration)}</span>
461
- </div>
462
- </div>
463
- <div class="grid grid-cols-3 gap-6 mb-8">
464
- <div>
465
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Long Sessions</span>
466
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.longSessions}</span>
467
- <span class="text-[10px] text-[#908fa0] block font-mono">${sessionIntel.longSessionPct}% over 60m</span>
468
- </div>
469
- <div>
470
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Tools/Session</span>
471
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.avgToolsPerSession}</span>
472
- </div>
473
- <div>
474
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Lines/Hour</span>
475
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.linesPerHour.toLocaleString()}</span>
476
- </div>
477
- </div>
478
-
479
- ${sessionIntel.topTools.length > 0 ? `
480
- <div>
481
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-4">Top Tools Usage</span>
482
- <div class="space-y-3">
483
- ${sessionIntel.topTools.slice(0, 10).map((t, i) => `
484
- <div class="space-y-1">
485
- <div class="flex justify-between text-[11px] font-mono">
486
- <span class="text-[#c7c4d7]">${t.name}</span>
487
- <span class="text-[#908fa0]">${t.count}</span>
488
- </div>
489
- <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden">
490
- <div class="h-full rounded-full" style="width:${sessionIntel.topTools[0].count > 0 ? (t.count / sessionIntel.topTools[0].count * 100) : 0}%;background:${i === 0 ? '#c0c1ff' : '#d4bbff'}"></div>
491
- </div>
492
- </div>`).join('')}
493
- </div>
494
- </div>` : ''}
495
- </div>` : ''}
496
-
497
- <!-- 6. ACTIVITY HEATMAP -->
498
- ${sessionIntel?.hourDistribution ? `
499
- <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
500
- <h3 class="text-lg font-bold text-[#e3e2e3] mb-4">Activity by Hour</h3>
501
- <div class="" id="hour-grid" style="display:flex;justify-content:space-between;align-items:flex-end;padding:0 4px;"></div>
502
- <!-- labels rendered by JS -->
503
- </div>` : ''}
504
- </div>
505
-
506
- <!-- Model Distribution -->
507
- <div class="space-y-8">
508
- <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
509
- <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Model Distribution</h3>
510
- <div class="w-full h-4 flex rounded-full overflow-hidden mb-6" style="gap:2px">
511
- ${modelEntries.map(([, cost], i) => {
512
- const pct = totalCost > 0 ? (cost / totalCost) * 100 : 0;
513
- return `<div class="h-full" style="width:${pct}%;background:${modelColors[i % modelColors.length]};border-radius:${i === 0 ? '9999px 0 0 9999px' : i === modelEntries.length - 1 ? '0 9999px 9999px 0' : '0'}"></div>`;
514
- }).join('')}
515
- </div>
516
- <div class="grid grid-cols-2 gap-4">
517
- ${modelEntries.map(([name, cost], i) => {
518
- const pct = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(1) : '0';
519
- return `<div class="flex items-center gap-2">
520
- <div class="w-2 h-2 rounded-full" style="background:${modelColors[i % modelColors.length]}"></div>
521
- <span class="text-xs font-mono text-[#c7c4d7]">${name}</span>
522
- <span class="text-xs font-mono text-[#908fa0]">${fmtCost(cost)}</span>
523
- <span class="text-[10px] text-[#464554] font-mono">${pct}%</span>
524
- </div>`;
525
- }).join('')}
526
- </div>
527
- ${modelRouting?.available ? `
528
- <div class="mt-6 pt-6 border-t border-[rgba(70,69,84,0.15)] text-sm text-[#c7c4d7]">
529
- <span class="font-mono">${modelRouting.opusPct}%</span> Opus &middot;
530
- <span class="font-mono">${modelRouting.sonnetPct}%</span> Sonnet &middot;
531
- <span class="font-mono">${modelRouting.haikuPct}%</span> Haiku
532
- ${modelRouting.estimatedSavings > 10 ? `<span class="text-[#c0c1ff] ml-3 font-mono">~${fmtCost(modelRouting.estimatedSavings)} potential savings</span>` : ''}
533
- </div>` : ''}
534
- </div>
535
-
536
- <!-- 9. RECOMMENDATIONS (placed alongside model distribution) -->
537
- ${recommendations.length > 0 ? `
538
- <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
539
- <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Recommendations</h3>
540
- <div class="space-y-3">
541
- ${recommendations.map((r, idx) => {
542
- const sev = sevColorMap[r.severity] || sevColorMap.info;
543
- const clipboardText = `CC Hubber flagged this about my setup:\n\n${r.title}\n\n${r.action}\n\nBefore making changes:\n1. Read the relevant files first to understand the current setup\n2. Do NOT remove or modify anything without asking me — some things are there intentionally\n3. Show me what each section/setting costs in tokens and let me decide what to keep\n4. Suggest optimizations (move to skills, use hooks, restructure) rather than deletion\n5. Do not break existing working functionality`;
544
- const b64 = Buffer.from(clipboardText).toString('base64');
545
- return `<div class="p-4 bg-[#0d0e0f] rounded-r-lg flex items-start gap-4" style="border-left:3px solid ${sev.border}">
546
- <div class="flex-1 min-w-0">
547
- <div class="flex items-start justify-between gap-4">
548
- <p class="text-[13px] font-semibold text-[#e3e2e3]">${esc(r.title)}</p>
549
- <div class="flex items-center gap-2 shrink-0">
550
- ${r.savings ? `<span class="text-[10px] font-mono px-2 py-0.5 rounded" style="background:${sev.border}18;color:${sev.text}">${r.savings}</span>` : ''}
551
- ${r.severity !== 'positive' ? `<button data-clip="${b64}" onclick="var t=atob(this.dataset.clip);navigator.clipboard.writeText(t);this.textContent='Copied!';var b=this;setTimeout(function(){b.textContent='Fix with Claude'},1500)" class="text-[10px] font-mono px-2 py-0.5 rounded border border-[rgba(70,69,84,0.3)] text-[#908fa0] cursor-pointer hover:text-[#e3e2e3] hover:border-[rgba(70,69,84,0.6)] transition-colors">Fix with Claude</button>` : ''}
552
- </div>
553
- </div>
554
- <p class="text-[11px] text-[#908fa0] mt-1 leading-relaxed">${esc(r.action)}</p>
555
- </div>
556
- </div>`;
557
- }).join('')}
558
- </div>
559
- </div>` : ''}
560
- </div>
561
- </section>
562
-
563
- <!-- 7. COMMUNITY LEADERBOARD -->
564
- <section id="community-section" class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden" style="display:none">
565
- <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)]">
566
- <div class="flex items-center justify-between">
567
- <h3 class="text-xl font-bold text-[#e3e2e3]">Community</h3>
568
- <span id="community-count" class="text-[10px] font-mono text-[#908fa0]"></span>
569
- </div>
570
- <p id="community-percentile" class="text-sm text-[#908fa0] mt-1"></p>
571
- </div>
572
-
573
- <!-- Grade distribution bar -->
574
- <div class="px-8 py-4 border-b border-[rgba(70,69,84,0.15)]">
575
- <div class="flex items-center gap-3 mb-2">
576
- <span class="text-[10px] font-mono text-[#908fa0] uppercase tracking-wider">Grade Distribution</span>
577
- </div>
578
- <div id="grade-dist-bar" class="flex h-6 rounded overflow-hidden"></div>
579
- <div id="grade-dist-labels" class="flex mt-1"></div>
580
- </div>
581
-
582
- <!-- Leaderboard table -->
583
- <div class="overflow-x-auto">
584
- <table class="w-full">
585
- <thead>
586
- <tr class="border-b border-[rgba(70,69,84,0.15)]">
587
- <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-left">#</th>
588
- <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Grade</th>
589
- <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Ratio</th>
590
- <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-left">Cost</th>
591
- <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Opus %</th>
592
- <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-left">Country</th>
593
- </tr>
594
- </thead>
595
- <tbody id="leaderboard-body"></tbody>
596
- </table>
597
- </div>
598
- </section>
599
-
600
- <!-- 8. PROJECTS TABLE -->
601
- ${projectBreakdown && projectBreakdown.length > 0 ? `
602
- <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
603
- <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
604
- <h3 class="text-xl font-bold text-[#e3e2e3]">Projects</h3>
605
- <button id="toggle-paths" onclick="document.querySelectorAll('.proj-name,.proj-path').forEach(e=>e.style.filter=e.style.filter?'':'blur(8px)');this.textContent=this.textContent==='Hide names'?'Show names':'Hide names'" class="text-[10px] font-mono text-[#908fa0] px-3 py-1 border border-[rgba(70,69,84,0.3)] rounded cursor-pointer hover:text-[#e3e2e3]">Hide names</button>
606
- </div>
607
- <div class="overflow-x-auto">
608
- <table class="w-full text-left" id="proj-tbl">
609
- <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
610
- <tr>
611
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Project</th>
612
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Messages</th>
613
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Sessions</th>
614
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Est. Cost</th>
615
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Output</th>
616
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Cache Read</th>
617
- </tr>
618
- </thead>
619
- <tbody class="divide-y divide-[rgba(70,69,84,0.15)]"></tbody>
620
- </table>
621
- </div>
622
- </section>
623
- ` : ''}
624
-
625
- <!-- 8. ANOMALIES TABLE -->
626
- ${anomalies.hasAnomalies ? `
627
- <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
628
- <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
629
- <h3 class="text-xl font-bold text-[#e3e2e3]">Detected Anomalies</h3>
630
- <span class="material-symbols-outlined text-[#ffb4ab] animate-pulse">warning</span>
631
- </div>
632
- <div class="overflow-x-auto">
633
- <table class="w-full text-left">
634
- <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
635
- <tr>
636
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Date</th>
637
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Cost</th>
638
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Deviation</th>
639
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Cache Ratio</th>
640
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Severity</th>
641
- </tr>
642
- </thead>
643
- <tbody class="divide-y divide-[rgba(70,69,84,0.15)]">
644
- ${anomalies.anomalies.map(a => {
645
- const sevBg = a.severity === 'critical' ? 'rgba(255, 180, 171, 0.10)' : 'rgba(255, 182, 144, 0.10)';
646
- const sevText = a.severity === 'critical' ? '#ffb4ab' : '#ffb690';
647
- return `<tr class="tbl-row">
648
- <td class="px-8 py-4 font-mono text-sm text-[#e3e2e3]">${a.date}</td>
649
- <td class="px-8 py-4 font-mono text-sm text-[#e3e2e3] font-bold">${fmtCost(a.cost)}</td>
650
- <td class="px-8 py-4 font-mono text-sm font-bold" style="color:${a.deviation > 0 ? '#ffb4ab' : '#c0c1ff'}">${a.deviation > 0 ? '+' : ''}$${a.deviation.toFixed(2)}</td>
651
- <td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">${a.cacheOutputRatio ? a.cacheOutputRatio.toLocaleString() + ':1' : ''}</td>
652
- <td class="px-8 py-4 text-right"><span class="px-2 py-0.5 rounded text-[10px] font-bold font-mono uppercase" style="background:${sevBg};color:${sevText}">${a.severity}</span></td>
653
- </tr>`;
654
- }).join('')}
655
- </tbody>
656
- </table>
657
- </div>
658
- </section>
659
- ` : ''}
660
-
661
- <!-- 10. CLAUDE.md ANALYSIS — Global only, section breakdown -->
662
- <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
663
- <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
664
- <h3 class="text-xl font-bold text-[#e3e2e3]">CLAUDE.md Analysis</h3>
665
- <div class="text-right">
666
- <span class="font-mono text-sm text-[#e3e2e3]">${claudeMdStack.files[0]?.lineCount || '?'} lines</span>
667
- <span class="text-[#908fa0] mx-2">&middot;</span>
668
- <span class="font-mono text-sm text-[#e3e2e3]">~${claudeMdStack.totalTokensEstimate.toLocaleString()} tokens</span>
669
- <span class="text-[#908fa0] mx-2">&middot;</span>
670
- <span class="font-mono text-sm text-[#e3e2e3]">${(claudeMdStack.totalBytes / 1024).toFixed(1)} KB</span>
671
- </div>
672
- </div>
673
- <div class="px-8 py-4 bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)] flex justify-between text-xs">
674
- <span class="text-[#908fa0]">Per-message cost impact</span>
675
- <span class="font-mono">
676
- <span class="text-[#c0c1ff]">$${claudeMdStack.costPerMessage.cached.toFixed(4)}</span> cached &middot;
677
- <span class="text-[#ffb690]">$${claudeMdStack.costPerMessage.uncached.toFixed(4)}</span> uncached &middot;
678
- <span class="text-[#ffb4ab]">$${(claudeMdStack.costPerMessage.dailyCached200 || 0).toFixed(2)}</span>/day at 200 msgs
679
- </span>
680
- </div>
681
- ${claudeMdStack.globalSections && claudeMdStack.globalSections.length > 0 ? `
682
- <div class="overflow-x-auto">
683
- <table class="w-full text-left">
684
- <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
685
- <tr>
686
- <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Section</th>
687
- <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Lines</th>
688
- <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Tokens</th>
689
- </tr>
690
- </thead>
691
- <tbody class="divide-y divide-[rgba(70,69,84,0.15)]">
692
- ${claudeMdStack.globalSections.map(s => `<tr class="tbl-row">
693
- <td class="px-8 py-3 text-sm text-[#e3e2e3]">${esc(s.name)}</td>
694
- <td class="px-8 py-3 font-mono text-sm text-[#c7c4d7] text-right">${s.lines}</td>
695
- <td class="px-8 py-3 font-mono text-sm text-[#c7c4d7] text-right">${s.tokens.toLocaleString()}</td>
696
- </tr>`).join('')}
697
- </tbody>
698
- </table>
699
- </div>
700
- ` : ''}
701
- </section>
702
-
703
- <!-- 11. CACHE SAVINGS -->
704
- ${cacheHealth.savings?.fromCaching > 0 ? `
705
- <section class="grid grid-cols-1 md:grid-cols-2 rounded-xl overflow-hidden border border-[rgba(70,69,84,0.15)]" style="gap:1px; background:rgba(70,69,84,0.15);">
706
- <div class="p-8 bg-[#0d0e0f]">
707
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Saved by Cache</span>
708
- <span class="font-mono text-3xl font-bold block text-[#c0c1ff]">~$${Number(cacheHealth.savings.fromCaching).toLocaleString()}</span>
709
- <span class="text-[10px] text-[#908fa0] mt-2 block">vs standard input pricing</span>
710
- </div>
711
- <div class="p-8 bg-[#0d0e0f]">
712
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Wasted on Breaks</span>
713
- <span class="font-mono text-3xl font-bold block text-[#ffb690]">~$${Number(cacheHealth.savings.wastedFromBreaks).toLocaleString()}</span>
714
- <span class="text-[10px] text-[#908fa0] mt-2 block">from cache invalidation</span>
715
- </div>
716
- </section>
717
- ` : ''}
718
-
719
- ${cacheHealth.totalCacheBreaks > 0 ? `
720
- <!-- Cache Break Reasons -->
721
- <section class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
722
- <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Cache Break Reasons</h3>
723
- <div class="space-y-4">
724
- ${(cacheHealth.reasonsRanked || []).map(r => `
725
- <div class="space-y-1">
726
- <div class="flex justify-between text-[11px] font-mono">
727
- <span class="text-[#c7c4d7]">${r.reason}</span>
728
- <span class="text-[#908fa0]">${r.count}</span>
729
- </div>
730
- <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden">
731
- <div class="h-full bg-[#ffb690] rounded-full" style="width:${r.percentage}%"></div>
732
- </div>
733
- </div>`).join('')}
734
- </div>
735
- </section>
736
- ` : ''}
737
-
738
- </main>
739
-
740
- <!-- 12. FOOTER -->
741
- <footer class="w-full py-12 border-t border-[rgba(70,69,84,0.05)]">
742
- <div class="max-w-[1200px] mx-auto px-6 text-center">
743
- <span class="text-[10px] tracking-widest uppercase text-[#908fa0]"><a href="https://github.com/azkhh/cchubber" target="_blank" style="text-decoration:none;color:inherit;">CC Hubber</a> &middot; shipped fast with <a href="https://moveros.dev" target="_blank" style="text-decoration:none;color:inherit;">Mover OS</a></span>
744
- </div>
745
- </footer>
746
-
747
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
748
- <script>
749
- (function(){
750
- var D=${dailyCostsJSON}, P=${projectsJSON};
751
- var CARD={
752
- grade:'${grade.letter}',gradeLabel:'${grade.label}',gradePerf:'${gradeLabel}',
753
- gradeColor:'${gradeColor}',
754
- cost:'${fmtCost(totalCost)}',days:'${activeDays}',
755
- ratio:'${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : 'N/A'}',
756
- diagnosis:'${diagnosisLine.replace(/'/g, "\\'")}',
757
- range:'All time'
758
- };
759
- var HR=${sessionIntel?.hourDistribution ? JSON.stringify(sessionIntel.hourDistribution) : 'null'};
760
- var CACHE_R=0.50,OUT=25,INP=5,CW=6.25;
761
-
762
- function fc(n){return n>=100?'$'+Math.round(n).toLocaleString():'$'+n.toFixed(2)}
763
- function ft(n){return n>=1e9?(n/1e9).toFixed(1)+'B':n>=1e6?(n/1e6).toFixed(1)+'M':n>=1e3?(n/1e3).toFixed(1)+'K':n.toString()}
764
-
765
- // Hour activity — vertical bar chart
766
- if(HR){
767
- var hg=document.getElementById('hour-grid');
768
- if(hg){
769
- var mx=Math.max.apply(null,HR);
770
- var html='';
771
- for(var i=0;i<24;i++){
772
- var pct=mx>0?Math.max(HR[i]/mx*100,2):2;
773
- var opac=pct>70?'0.85':pct>40?'0.6':pct>15?'0.35':'0.12';
774
- html+='<div style="display:flex;flex-direction:column;align-items:center;gap:4px;" title="'+i+':00 — '+HR[i]+' messages">';
775
- html+='<div style="width:3px;height:80px;background:rgba(70,69,84,0.2);border-radius:2px;position:relative;overflow:hidden;">';
776
- html+='<div style="position:absolute;bottom:0;width:100%;height:'+pct+'%;background:rgba(192,193,255,'+opac+');border-radius:2px;"></div>';
777
- html+='</div>';
778
- html+='<span style="font-size:8px;font-family:JetBrains Mono,monospace;color:'+(i%6===0?'#908fa0':'#464554')+';">'+i+'</span>';
779
- html+='</div>';
780
- }
781
- hg.innerHTML=html;
782
- }
783
- }
784
-
785
- // Project table
786
- var ptb=document.querySelector('#proj-tbl tbody');
787
- if(ptb&&P.length>0){
788
- P.sort(function(a,b){return(b.output/1e6*OUT+b.cacheRead/1e6*CACHE_R)-(a.output/1e6*OUT+a.cacheRead/1e6*CACHE_R)});
789
- var h='';
790
- for(var i=0;i<P.length;i++){
791
- var p=P[i];
792
- h+='<tr class="tbl-row">';
793
- h+='<td class="px-8 py-4 text-sm font-semibold text-[#e3e2e3]"><span class="proj-name">'+p.name+'</span>';
794
- if(p.path)h+='<br><span class="proj-path text-[10px] text-[#908fa0] font-mono">'+p.path+'</span>';
795
- h+='</td>';
796
- var projCost=p.output/1e6*OUT+p.cacheRead/1e6*CACHE_R+p.input/1e6*INP+p.cacheWrite/1e6*CW;
797
- h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.messages.toLocaleString()+'</td>';
798
- h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.sessions+'</td>';
799
- h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+fc(projCost)+'</td>';
800
- h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+ft(p.output)+'</td>';
801
- h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+ft(p.cacheRead)+'</td>';
802
- h+='</tr>';
803
- }
804
- ptb.innerHTML=h;
805
- }
806
-
807
- // Chart
808
- var W=900,H=200,PD={t:24,r:16,b:40,l:56};
809
- var cW=W-PD.l-PD.r,cH=H-PD.t-PD.b;
810
- var svg=document.getElementById('cost-chart-svg');
811
- var tt=document.getElementById('tt'),ttd=document.getElementById('tt-d'),ttc=document.getElementById('tt-c'),tta=document.getElementById('tt-a');
812
-
813
- function filt(r){return r==='all'?D:D.slice(-parseInt(r,10))}
814
-
815
- function chart(d){
816
- if(!svg)return;
817
- if(!d.length){svg.innerHTML='<text x="450" y="100" text-anchor="middle" fill="#908fa0" font-size="13" font-family="Inter,sans-serif">No data for this period</text>';return}
818
- var total=d.reduce(function(s,x){return s+x.cost},0);
819
- if(total<0.01){svg.innerHTML='<text x="450" y="100" text-anchor="middle" fill="#908fa0" font-size="13" font-family="Inter,sans-serif">No cost data — costs are only calculated from token counts, not the costUSD field</text>';return}
820
- var mx=Math.max.apply(null,d.map(function(x){return x.cost}))*1.1;if(mx<0.01)mx=1;
821
- var s='';
822
- // grid lines
823
- for(var i=0;i<=3;i++){
824
- var y=PD.t+(cH/3)*i,v=mx-(mx/3)*i;
825
- s+='<line x1="'+PD.l+'" y1="'+y+'" x2="'+(W-PD.r)+'" y2="'+y+'" stroke="rgba(70,69,84,0.15)" stroke-width="1"/>';
826
- s+='<text x="'+(PD.l-10)+'" y="'+(y+4)+'" text-anchor="end" fill="#908fa0" font-size="9" font-family="JetBrains Mono,monospace">$'+(v<1?v.toFixed(2):Math.round(v))+'</text>';
827
- }
828
- // area + line
829
- var step=d.length>1?cW/(d.length-1):0;
830
- var pts=d.map(function(x,j){return{x:PD.l+(d.length===1?cW/2:j*step),y:PD.t+cH-(x.cost/mx)*cH}});
831
- var lp='M '+pts[0].x+' '+pts[0].y;
832
- var ap='M '+pts[0].x+' '+(PD.t+cH)+' L '+pts[0].x+' '+pts[0].y;
833
- for(var j=1;j<pts.length;j++){var cx=(pts[j-1].x+pts[j].x)/2;lp+=' C '+cx+' '+pts[j-1].y+' '+cx+' '+pts[j].y+' '+pts[j].x+' '+pts[j].y;ap+=' C '+cx+' '+pts[j-1].y+' '+cx+' '+pts[j].y+' '+pts[j].x+' '+pts[j].y}
834
- ap+=' L '+pts[pts.length-1].x+' '+(PD.t+cH)+' Z';
835
- // Stitch gradient: primary at 30% to transparent
836
- s+='<defs><linearGradient id="ag" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#c0c1ff" stop-opacity="0.3"/><stop offset="100%" stop-color="#c0c1ff" stop-opacity="0"/></linearGradient></defs>';
837
- s+='<path d="'+ap+'" fill="url(#ag)"/>';
838
- s+='<path d="'+lp+'" fill="none" stroke="#c0c1ff" stroke-width="2" stroke-linecap="round"/>';
839
- // x labels
840
- var every=Math.max(1,Math.floor(d.length/8));
841
- d.forEach(function(x,j){
842
- var px=PD.l+(d.length===1?cW/2:j*step);
843
- if(j%every===0||j===d.length-1)s+='<text x="'+px+'" y="'+(H-6)+'" text-anchor="middle" fill="#908fa0" font-size="9" font-family="JetBrains Mono,monospace">'+x.date.slice(5)+'</text>';
844
- });
845
- // anomaly dots
846
- d.forEach(function(x,j){
847
- var px=PD.l+(d.length===1?cW/2:j*step),py=PD.t+cH-(x.cost/mx)*cH;
848
- if(x.isAnomaly)s+='<circle cx="'+px+'" cy="'+py+'" r="4" fill="#ffb4ab" stroke="#121315" stroke-width="2"/>';
849
- });
850
- // hover targets
851
- d.forEach(function(x,j){
852
- var px=PD.l+(d.length===1?cW/2:j*step),py=PD.t+cH-(x.cost/mx)*cH;
853
- s+='<circle cx="'+px+'" cy="'+py+'" r="14" fill="transparent" data-d="'+x.date+'" data-c="'+x.cost+'" data-a="'+(x.isAnomaly?1:0)+'" data-o="'+(x.out||0)+'" class="hov" style="cursor:crosshair"/>';
854
- });
855
- svg.innerHTML=s;
856
- svg.querySelectorAll('.hov').forEach(function(el){
857
- el.addEventListener('mouseenter',function(e){
858
- ttd.textContent=e.target.dataset.d;
859
- var ot=parseInt(e.target.dataset.o||0);
860
- ttc.textContent=fc(parseFloat(e.target.dataset.c))+(ot>0?' — '+ft(ot)+' output':'');
861
- tta.textContent=e.target.dataset.a==='1'?'ANOMALY':'';
862
- tta.style.display=e.target.dataset.a==='1'?'block':'none';
863
- tt.classList.add('on');
864
- });
865
- el.addEventListener('mousemove',function(e){tt.style.left=(e.clientX+14)+'px';tt.style.top=(e.clientY-40)+'px'});
866
- el.addEventListener('mouseleave',function(){tt.classList.remove('on')});
867
- });
868
- }
869
-
870
- var RL={7:'Last 7 days',30:'Last 30 days',90:'Last 90 days',all:'All time'};
871
-
872
- function setR(r){
873
- var f=filt(r);chart(f);
874
- var ci=document.getElementById('chart-info');
875
- if(ci&&f.length){var t=f.reduce(function(s,x){return s+x.cost},0),a=f.filter(function(x){return x.cost>0}).length,tok=f.reduce(function(s,x){return s+(x.out||0)},0);ci.textContent=a+' days \u00b7 '+fc(t)+' \u00b7 '+ft(tok)+' output'}
876
- var rl=document.getElementById('range-lbl');if(rl)rl.textContent=RL[r]||'All time';
877
- CARD.range=RL[r]||'All time';
878
- var cr=document.getElementById('card-range');if(cr)cr.textContent=CARD.range;
879
- if(f.length){
880
- var t=f.reduce(function(s,x){return s+x.cost},0),a=f.filter(function(x){return x.cost>0}).length;
881
- CARD.cost=fc(t);CARD.days=a.toString();
882
- // Update HTML card
883
- var hc=document.getElementById('h-cost');if(hc)hc.textContent=fc(t);
884
- var hd=document.getElementById('h-days');if(hd)hd.textContent=a;
885
- // Update overview
886
- var ot=document.getElementById('ov-total'),oa=document.getElementById('ov-avg');
887
- if(ot)ot.textContent=fc(t);if(oa&&a>0)oa.textContent=fc(t/a)+' avg/day';
888
- }
889
- // Update filter button states - Stitch style
890
- document.querySelectorAll('.cfilt').forEach(function(b){
891
- if(b.dataset.r===r){
892
- b.style.background='#c0c1ff';b.style.color='#1000a9';
893
- } else {
894
- b.style.background='transparent';b.style.color='#908fa0';
895
- }
896
- });
897
- }
898
-
899
- document.querySelectorAll('.cfilt').forEach(function(b){b.addEventListener('click',function(){setR(b.dataset.r)})});
900
-
901
- // Export helpers
902
- var toast=document.getElementById('toast');
903
- function showToast(m){if(!toast)return;toast.textContent=m;toast.classList.add('on');setTimeout(function(){toast.classList.remove('on')},2000)}
904
-
905
- // ─── CANVAS SHARE CARD ─────────────────────────────
906
- // Renders entirely on canvas — same output for display AND video export
907
- var cardCanvas=document.getElementById('share-card');
908
- var cardW=1480,cardH=580,cardR=44; // 2x resolution for retina
909
- cardCanvas.width=cardW;cardCanvas.height=cardH;
910
- var cardCtx=cardCanvas.getContext('2d');
911
-
912
- function roundRect(ctx,x,y,w,h,r){
913
- ctx.beginPath();ctx.moveTo(x+r,y);ctx.lineTo(x+w-r,y);ctx.quadraticCurveTo(x+w,y,x+w,y+r);
914
- ctx.lineTo(x+w,y+h-r);ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);ctx.lineTo(x+r,y+h);
915
- ctx.quadraticCurveTo(x,y+h,x,y+h-r);ctx.lineTo(x,y+r);ctx.quadraticCurveTo(x,y,x+r,y);ctx.closePath();
916
- }
917
-
918
- function drawCard(ctx,w,h,shimmerT){
919
- // Background gradient
920
- var bg=ctx.createLinearGradient(0,0,w,h);
921
- bg.addColorStop(0,'#1a1b2e');bg.addColorStop(0.4,'#0f1018');bg.addColorStop(0.7,'#191a2d');bg.addColorStop(1,'#12131f');
922
- roundRect(ctx,0,0,w,h,cardR);ctx.save();ctx.clip();
923
- ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
924
-
925
- var pad=80,padT=72;
926
-
927
- // Grade badge
928
- var bx=pad,by=padT,bs=112;
929
- roundRect(ctx,bx,by,bs,bs,24);
930
- ctx.fillStyle=CARD.gradeColor;ctx.fill();
931
- ctx.font='800 56px "JetBrains Mono",monospace';ctx.fillStyle='#0f1018';ctx.textAlign='center';ctx.textBaseline='middle';
932
- ctx.fillText(CARD.grade,bx+bs/2,by+bs/2);
933
-
934
- // Grade label + performance text
935
- ctx.textAlign='left';ctx.textBaseline='top';
936
- ctx.font='700 18px "JetBrains Mono",monospace';ctx.fillStyle=CARD.gradeColor;
937
- ctx.fillText(CARD.gradeLabel.toUpperCase(),bx+bs+32,by+12);
938
- ctx.font='700 36px "Inter",sans-serif';ctx.fillStyle='#e3e2e3';
939
- ctx.fillText(CARD.gradePerf,bx+bs+32,by+40);
940
-
941
- // Top right: Claude Code + range
942
- ctx.textAlign='right';
943
- ctx.font='700 20px "JetBrains Mono",monospace';ctx.fillStyle='#908fa0';
944
- ctx.fillText('CLAUDE CODE',w-pad,padT+16);
945
- ctx.font='400 16px "JetBrains Mono",monospace';ctx.fillStyle='#464554';
946
- ctx.fillText(CARD.range,w-pad,padT+44);
947
-
948
- // Stats row
949
- var statsY=h*0.45;
950
- var labels=['TOTAL SPEND','ACTIVE DAYS','CACHE RATIO'];
951
- var values=[CARD.cost,CARD.days,CARD.ratio];
952
- var positions=[pad,w*0.38,w*0.7];
953
- var aligns=['left','center','right'];
954
- var xEnds=[null,null,w-pad];
955
-
956
- for(var i=0;i<3;i++){
957
- ctx.textAlign=i===2?'right':i===1?'center':'left';
958
- var sx=i===2?w-pad:i===1?w/2:pad;
959
- ctx.font='400 16px "Inter",sans-serif';ctx.fillStyle='#908fa0';
960
- ctx.fillText(labels[i],sx,statsY);
961
- ctx.font='700 64px "JetBrains Mono",monospace';ctx.fillStyle='#e3e2e3';
962
- ctx.fillText(values[i],sx,statsY+28);
963
- }
964
-
965
- // Bottom: diagnosis + branding
966
- var botY=h-padT;
967
- ctx.textAlign='left';
968
- ctx.font='400 20px "Inter",sans-serif';ctx.fillStyle='#908fa0';
969
- ctx.fillText(CARD.diagnosis,pad,botY);
970
-
971
- // Branding — measure text to space properly
972
- ctx.textAlign='right';
973
- ctx.font='500 18px "JetBrains Mono",monospace';
974
- var moverW=ctx.measureText('Mover OS').width;
975
- ctx.font='400 18px "Inter",sans-serif';
976
- var shippedW=ctx.measureText(' shipped fast with ').width;
977
- ctx.font='500 18px "JetBrains Mono",monospace';
978
- var hubberW=ctx.measureText('CC Hubber').width;
979
-
980
- var bx=w-pad;
981
- ctx.font='500 18px "JetBrains Mono",monospace';ctx.fillStyle='#c0c1ff';
982
- ctx.fillText('Mover OS',bx,botY);
983
- bx-=moverW;
984
- ctx.font='400 18px "Inter",sans-serif';ctx.fillStyle='#908fa0';
985
- ctx.fillText(' shipped fast with ',bx,botY);
986
- bx-=shippedW;
987
- ctx.font='500 18px "JetBrains Mono",monospace';ctx.fillStyle='#c0c1ff';
988
- ctx.fillText('CC Hubber',bx,botY);
989
-
990
- // Subtle shimmer sweep
991
- if(shimmerT!==undefined){
992
- var sx=-w*0.4+(shimmerT%1)*w*1.8;
993
- var sg=ctx.createLinearGradient(sx,0,sx+w*0.3,h);
994
- sg.addColorStop(0,'rgba(255,255,255,0)');
995
- sg.addColorStop(0.45,'rgba(192,193,255,0.02)');
996
- sg.addColorStop(0.5,'rgba(255,255,255,0.045)');
997
- sg.addColorStop(0.55,'rgba(212,187,255,0.02)');
998
- sg.addColorStop(1,'rgba(255,255,255,0)');
999
- ctx.fillStyle=sg;ctx.fillRect(0,0,w,h);
1000
- }
1001
-
1002
- ctx.restore();
1003
- }
1004
-
1005
- // Animate the card on the page
1006
- var cardAnimStart=null;
1007
- function animateCard(ts){
1008
- if(!cardAnimStart)cardAnimStart=ts;
1009
- var t=((ts-cardAnimStart)%6000)/6000;
1010
- drawCard(cardCtx,cardW,cardH,t);
1011
- requestAnimationFrame(animateCard);
1012
- }
1013
- document.fonts.ready.then(function(){requestAnimationFrame(animateCard)});
1014
-
1015
- // ─── VIDEO EXPORT (records the same canvas) ──────
1016
- // Video export — captures HTML card with html-to-image, animates on canvas at 1440p
1017
- var gb=document.getElementById('btn-gif');
1018
- if(gb)gb.addEventListener('click',function(){
1019
- gb.textContent='Capturing...';gb.disabled=true;
1020
- var htmlCard=document.getElementById('share-card-html');
1021
- if(!htmlCard||typeof htmlToImage==='undefined'){gb.textContent='Share';gb.disabled=false;showToast('Library not loaded');return}
1022
-
1023
- // Pause animation + hide CSS shimmer/noise for clean capture
1024
- htmlCard.style.animation='none';htmlCard.style.transform='none';
1025
- htmlCard.classList.add('no-shimmer');
1026
-
1027
- document.fonts.ready.then(function(){
1028
- return htmlToImage.toPng(htmlCard,{quality:1,pixelRatio:3}).then(function(dataUrl){
1029
- htmlCard.style.animation='';htmlCard.style.transform='';
1030
- htmlCard.classList.remove('no-shimmer');
1031
- gb.textContent='Recording...';
1032
-
1033
- var img=new Image();
1034
- img.onload=function(){
1035
- // 2560x1440 canvas
1036
- var VW=2560,VH=1440;
1037
- var vidCanvas=document.createElement('canvas');vidCanvas.width=VW;vidCanvas.height=VH;
1038
- var vctx=vidCanvas.getContext('2d');
1039
- vctx.imageSmoothingEnabled=true;vctx.imageSmoothingQuality='high';
1040
-
1041
- // Scale card to fill ~90% of frame width for maximum impact
1042
- var scale=Math.min((VW*0.88)/img.width,(VH*0.82)/img.height);
1043
- var cw=Math.round(img.width*scale),ch=Math.round(img.height*scale);
1044
- var r=22*3*scale; // border radius
1045
-
1046
- var stream=vidCanvas.captureStream(30);
1047
- var chunks=[];
1048
- // Prefer MP4 (Chrome 124+, works on X/Twitter), fallback WebM
1049
- var mime=MediaRecorder.isTypeSupported('video/mp4;codecs=avc1')?'video/mp4;codecs=avc1'
1050
- :MediaRecorder.isTypeSupported('video/mp4')?'video/mp4':'video/webm';
1051
- var ext=mime.startsWith('video/mp4')?'mp4':'webm';
1052
- var recorder=new MediaRecorder(stream,{mimeType:mime,videoBitsPerSecond:30000000});
1053
- recorder.ondataavailable=function(e){if(e.data.size>0)chunks.push(e.data)};
1054
- recorder.onstop=function(){
1055
- var blob=new Blob(chunks,{type:mime.split(';')[0]});
1056
- var a=document.createElement('a');a.download='cchubber-card.'+ext;
1057
- a.href=URL.createObjectURL(blob);a.click();
1058
- gb.innerHTML='<span class="material-symbols-outlined text-sm">share</span> Share';
1059
- gb.disabled=false;showToast('Video saved ('+ext.toUpperCase()+')');
1060
- };
1061
-
1062
- // Pre-generate noise texture to fight gradient banding
1063
- var noiseC=document.createElement('canvas');noiseC.width=256;noiseC.height=256;
1064
- var nctx=noiseC.getContext('2d');
1065
- var ndata=nctx.createImageData(256,256);
1066
- for(var ni=0;ni<ndata.data.length;ni+=4){var v=Math.random()*255;ndata.data[ni]=v;ndata.data[ni+1]=v;ndata.data[ni+2]=v;ndata.data[ni+3]=8;}
1067
- nctx.putImageData(ndata,0,0);
1068
-
1069
- var duration=6000,startTime=null;
1070
- recorder.start(100);
1071
-
1072
- function frame(ts){
1073
- if(!startTime)startTime=ts;
1074
- var elapsed=ts-startTime;
1075
- if(elapsed>=duration){setTimeout(function(){recorder.stop()},300);return}
1076
-
1077
- var t=elapsed/duration;
1078
- // Gentle breathe + float — matches the natural feel of the CSS animation
1079
- // ease-in-out via cosine (same curve as CSS ease-in-out)
1080
- // Gentle lateral drift — subtle, no shake
1081
- var drift=Math.sin(t*Math.PI*2)*5;
1082
-
1083
- vctx.fillStyle='#000';vctx.fillRect(0,0,VW,VH);
1084
- vctx.save();
1085
- vctx.translate((VW-cw)/2+drift,(VH-ch)/2);
1086
-
1087
- // Draw the HTML-captured card image (browser-quality)
1088
- vctx.drawImage(img,0,0,cw,ch);
1089
-
1090
- // Clip shimmer + noise to rounded card shape (prevents corner bleed)
1091
- var cr=22*3*scale; // 22px radius * 3 pixelRatio * video scale
1092
- vctx.beginPath();
1093
- vctx.moveTo(cr,0);vctx.lineTo(cw-cr,0);vctx.quadraticCurveTo(cw,0,cw,cr);
1094
- vctx.lineTo(cw,ch-cr);vctx.quadraticCurveTo(cw,ch,cw-cr,ch);vctx.lineTo(cr,ch);
1095
- vctx.quadraticCurveTo(0,ch,0,ch-cr);vctx.lineTo(0,cr);vctx.quadraticCurveTo(0,0,cr,0);
1096
- vctx.closePath();vctx.clip();
1097
-
1098
- // Shimmer sweep
1099
- var shimProgress=(t*2)%1;
1100
- var sx=-cw*0.5+shimProgress*cw*2;
1101
- var g=vctx.createLinearGradient(sx,0,sx+cw*0.4,ch);
1102
- g.addColorStop(0,'rgba(255,255,255,0)');
1103
- g.addColorStop(0.3,'rgba(192,193,255,0.04)');
1104
- g.addColorStop(0.45,'rgba(212,187,255,0.06)');
1105
- g.addColorStop(0.55,'rgba(192,193,255,0.06)');
1106
- g.addColorStop(0.7,'rgba(212,187,255,0.04)');
1107
- g.addColorStop(1,'rgba(255,255,255,0)');
1108
- vctx.fillStyle=g;vctx.fillRect(0,0,cw,ch);
1109
-
1110
- // Noise dither
1111
- var pat=vctx.createPattern(noiseC,'repeat');
1112
- vctx.fillStyle=pat;vctx.globalAlpha=0.4;vctx.fillRect(0,0,cw,ch);vctx.globalAlpha=1;
1113
-
1114
- vctx.restore();
1115
- requestAnimationFrame(frame);
1116
- }
1117
-
1118
- requestAnimationFrame(frame);
1119
- };
1120
- img.src=dataUrl;
1121
- });
1122
- }).catch(function(e){
1123
- htmlCard.style.animation='';htmlCard.style.transform='';htmlCard.classList.remove('no-shimmer');
1124
- gb.innerHTML='<span class="material-symbols-outlined text-sm">share</span> Share';
1125
- gb.disabled=false;showToast('Failed: '+e.message);
1126
- });
1127
- });
1128
-
1129
- setR('all');
1130
-
1131
- // Community leaderboard — stats fetched at generation time, embedded as JSON
1132
- var MY_RATIO = ${cacheHealth.efficiencyRatio || 0};
1133
- var MY_GRADE = '${cacheHealth.grade?.letter || '?'}';
1134
- var MY_COST = '${(() => { const c = totalCost; return c<10?'<10':c<50?'10-50':c<200?'50-200':c<500?'200-500':c<1000?'500-1K':c<5000?'1K-5K':'5K+'; })()}';
1135
- var gradeColors = {A:'#10b981',B:'#22d3ee',C:'#f59e0b',D:'#f97316',F:'#ef4444'};
1136
- var stats = ${JSON.stringify(report.communityStats || null)};
1137
-
1138
- (function(stats){
1139
- if(!stats || !stats.totalReports) return;
1140
- var sec = document.getElementById('community-section');
1141
- if(!sec || !stats.totalReports) return;
1142
- sec.style.display = '';
1143
-
1144
- // Re-grade function — applies current grading logic to stored ratios
1145
- // so old telemetry entries get accurate grades, not the inflated v0.4 grades
1146
- function regrade(ratio){
1147
- // Simplified: ratio signal dominates (40%), assume stable trend (70), good hit rate (85), no breaks (100)
1148
- var rs;
1149
- if(ratio<=200)rs=100;else if(ratio<=350)rs=85;else if(ratio<=500)rs=70;
1150
- else if(ratio<=650)rs=55;else if(ratio<=800)rs=42;else if(ratio<=1000)rs=30;
1151
- else if(ratio<=1500)rs=18;else if(ratio<=2000)rs=10;else rs=3;
1152
- var composite=Math.round(85*0.15+rs*0.40+70*0.30+100*0.15);
1153
- if(rs<=5)composite=Math.min(composite,38);
1154
- if(composite>=75)return'A';if(composite>=60)return'B';if(composite>=45)return'C';if(composite>=30)return'D';return'F';
1155
- }
1156
-
1157
- // Count + geographic spread (don't show raw user count — looks small early on)
1158
- var countries = stats.countries ? Object.keys(stats.countries) : [];
1159
- document.getElementById('community-count').textContent = countries.length + ' countries';
1160
-
1161
- // Calculate percentile from recent entries
1162
- var recent = stats.recent || [];
1163
- var ratios = recent.map(function(r){return r.ratio||9999}).sort(function(a,b){return a-b});
1164
- var betterThan = ratios.filter(function(r){return r > MY_RATIO}).length;
1165
- var pctile = ratios.length > 0 ? Math.round(betterThan / ratios.length * 100) : 0;
1166
- document.getElementById('community-percentile').innerHTML =
1167
- 'Your cache ratio of <strong style="color:#e3e2e3">' + MY_RATIO + ':1</strong> is better than <strong style="color:#c0c1ff">' + pctile + '%</strong> of CC Hubber users';
1168
-
1169
- // Grade distribution — use stored grade for v0.5+ entries, regrade older ones
1170
- function getGrade(entry){
1171
- var v = entry.v || entry.version || '0.3.3';
1172
- var parts = v.split('.'); var isNew = (parseInt(parts[0]||0)>0) || (parseInt(parts[1]||0)>=5);
1173
- return (isNew && entry.grade) ? entry.grade : (entry.ratio ? regrade(entry.ratio) : (entry.grade || 'C'));
1174
- }
1175
- var regraded = {A:0,B:0,C:0,D:0,F:0};
1176
- recent.forEach(function(r){regraded[getGrade(r)]++});
1177
- var gTotal = Object.values(regraded).reduce(function(s,v){return s+v},0);
1178
- var bar = document.getElementById('grade-dist-bar');
1179
- var labels = document.getElementById('grade-dist-labels');
1180
- ['A','B','C','D','F'].forEach(function(g){
1181
- var count = regraded[g] || 0;
1182
- var pct = gTotal > 0 ? (count/gTotal*100) : 0;
1183
- if(pct > 0){
1184
- var seg = document.createElement('div');
1185
- seg.style.cssText = 'width:'+pct+'%;background:'+gradeColors[g]+';display:flex;align-items:center;justify-content:center;';
1186
- seg.innerHTML = '<span style="font-size:10px;font-weight:700;color:#0d0e0f">' + (pct>8?g:'') + '</span>';
1187
- bar.appendChild(seg);
1188
- var lbl = document.createElement('span');
1189
- lbl.style.cssText = 'width:'+pct+'%;text-align:center;font-size:10px;color:#908fa0;font-family:monospace;';
1190
- lbl.textContent = count;
1191
- labels.appendChild(lbl);
1192
- }
1193
- });
1194
-
1195
- // Leaderboard table — re-graded, with user's position injected
1196
- // Filter: minimum activity to qualify (exclude <$10 and $10-50 users — too little data for meaningful grade)
1197
- var validCosts = {'50-200':1,'200-500':1,'500-1K':1,'1K-5K':1,'5K+':1};
1198
- var tbody = document.getElementById('leaderboard-body');
1199
- var sorted = recent.filter(function(r){return r.ratio && (validCosts[r.cost] || r.cost===MY_COST)}).sort(function(a,b){return (a.ratio||9999)-(b.ratio||9999)});
1200
-
1201
- // Insert user's own entry into the sorted list at the right position
1202
- var myEntry = {ratio:MY_RATIO, grade:MY_GRADE, cost:MY_COST, opus:${report.modelRouting?.opusPct ?? 'null'}, country:'You', os:'${process.platform}', isMe:true};
1203
- var myRank = sorted.findIndex(function(e){return (e.ratio||0) >= MY_RATIO});
1204
- if(myRank < 0) myRank = sorted.length;
1205
- sorted.splice(myRank, 0, myEntry);
1206
-
1207
- // Show top 10 + user's position (if not in top 10)
1208
- var showIndices = [];
1209
- for(var si=0;si<Math.min(10,sorted.length);si++) showIndices.push(si);
1210
- var userInTop = myRank < 10;
1211
- if(!userInTop){
1212
- showIndices.push(-1); // separator
1213
- if(myRank > 1) showIndices.push(myRank-1);
1214
- showIndices.push(myRank);
1215
- if(myRank+1 < sorted.length) showIndices.push(myRank+1);
1216
- }
1217
-
1218
- var html = '';
1219
- for(var si2=0;si2<showIndices.length;si2++){
1220
- var idx = showIndices[si2];
1221
- if(idx === -1){
1222
- html += '<tr><td colspan="6" class="px-8 py-1 text-center text-[10px] text-[#908fa0]">...</td></tr>';
1223
- continue;
1224
- }
1225
- var entry = sorted[idx];
1226
- if(!entry) continue;
1227
- var g = entry.isMe ? MY_GRADE : getGrade(entry);
1228
- var isMe = entry.isMe;
1229
- var rowStyle = isMe ? 'background:rgba(192,193,255,0.08);border-left:3px solid #c0c1ff;' : '';
1230
- html += '<tr style="border-bottom:1px solid rgba(70,69,84,0.1);'+rowStyle+'">';
1231
- html += '<td class="px-8 py-3 text-sm font-mono '+(isMe?'text-[#c0c1ff] font-bold':'text-[#908fa0]')+'">#'+(idx+1)+(isMe?' ← you':'')+'</td>';
1232
- html += '<td class="px-4 py-3 text-sm font-bold text-center" style="color:'+gradeColors[g]+'">'+g+'</td>';
1233
- html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.ratio||'?')+':1</td>';
1234
- html += '<td class="px-4 py-3 text-sm font-mono text-[#908fa0]">'+(entry.cost?'$'+entry.cost:'?')+'</td>';
1235
- html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.opus!=null?entry.opus:'?')+'%</td>';
1236
- html += '<td class="px-4 py-3 text-sm text-[#908fa0]">'+(isMe?'You':(entry.country||'?'))+'</td>';
1237
- html += '</tr>';
1238
- }
1239
- tbody.innerHTML = html;
1240
-
1241
- // Update percentile text with rank
1242
- document.getElementById('community-percentile').innerHTML =
1243
- "You are <strong style=\\"color:#c0c1ff\\">#"+(myRank+1)+" of "+(sorted.length)+"</strong> users by cache efficiency. Better than <strong style=\\"color:#c0c1ff\\">"+pctile+"%</strong>";
1244
- })(stats);
1245
- })();
1246
- </script>
1247
- </body>
1248
- </html>`;
1249
- }
1250
-
1251
- export function renderRateLimits(usage) {
1252
- const fiveHour = usage.five_hour;
1253
- const sevenDay = usage.seven_day;
1254
- if (!fiveHour && !sevenDay) return '';
1255
-
1256
- const fivePct = fiveHour?.utilization ?? 0;
1257
- const sevenPct = sevenDay?.utilization ?? 0;
1258
- const fiveColor = fivePct > 80 ? '#ffb4ab' : fivePct > 50 ? '#ffb690' : '#c0c1ff';
1259
- const sevenColor = sevenPct > 80 ? '#ffb4ab' : sevenPct > 50 ? '#ffb690' : '#c0c1ff';
1260
-
1261
- return `
1262
- <section class="grid grid-cols-1 md:grid-cols-2 rounded-xl overflow-hidden border border-[rgba(70,69,84,0.15)]" style="gap:1px; background:rgba(70,69,84,0.15);">
1263
- <div class="p-6 bg-[#0d0e0f]">
1264
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">5-Hour Session</span>
1265
- <span class="font-mono text-2xl font-bold block" style="color:${fiveColor}">${fivePct}%</span>
1266
- <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden mt-3 mb-2">
1267
- <div class="h-full rounded-full" style="width:${fivePct}%;background:${fiveColor}"></div>
1268
- </div>
1269
- <span class="text-[10px] text-[#908fa0] block font-mono">${fiveHour?.resets_at ? 'Resets ' + new Date(fiveHour.resets_at).toLocaleTimeString() : ''}</span>
1270
- </div>
1271
- <div class="p-6 bg-[#0d0e0f]">
1272
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">7-Day Rolling</span>
1273
- <span class="font-mono text-2xl font-bold block" style="color:${sevenColor}">${sevenPct}%</span>
1274
- <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden mt-3 mb-2">
1275
- <div class="h-full rounded-full" style="width:${sevenPct}%;background:${sevenColor}"></div>
1276
- </div>
1277
- <span class="text-[10px] text-[#908fa0] block font-mono">${sevenDay?.resets_at ? 'Resets ' + new Date(sevenDay.resets_at).toLocaleDateString() : ''}</span>
1278
- </div>
1279
- </section>`;
1280
- }
1
+ import { readFileSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { renderVsBlock } from './vs-report.js';
5
+ import { renderSpentWell } from './spent-well.js';
6
+ import { renderDriftSection } from './drift-report.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
10
+
11
+ function esc(s) {
12
+ if (!s) return '';
13
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
14
+ }
15
+
16
+ export function renderHTML(report, opts = {}) {
17
+ const { costAnalysis, cacheHealth, anomalies, inflection, sessionIntel, modelRouting, projectBreakdown, claudeMdStack, oauthUsage, recommendations, generatedAt } = report;
18
+
19
+ const dailyCosts = costAnalysis.dailyCosts || [];
20
+ const grade = cacheHealth.grade || { letter: '?', color: '#666', label: 'Unknown' };
21
+ const totalCost = costAnalysis.totalCost || 0;
22
+ const activeDays = costAnalysis.activeDays || 0;
23
+ const peakDay = costAnalysis.peakDay;
24
+
25
+ const modelCosts = costAnalysis.modelCosts || {};
26
+ const modelEntries = Object.entries(modelCosts).filter(([, c]) => c > 0.01).sort((a, b) => b[1] - a[1]);
27
+ const anomalyDates = new Set((anomalies.anomalies || []).map(a => a.date));
28
+
29
+ const dailyCostsJSON = JSON.stringify(dailyCosts.map(d => ({
30
+ date: d.date, cost: d.cost, cacheOutputRatio: d.cacheOutputRatio || 0, isAnomaly: anomalyDates.has(d.date),
31
+ out: d.outputTokens || 0, inp: d.inputTokens || 0, cr: d.cacheReadTokens || 0,
32
+ })));
33
+
34
+ const projectsJSON = JSON.stringify((projectBreakdown || []).map(p => ({
35
+ name: p.name, path: p.path, messages: p.messageCount, sessions: p.sessionCount,
36
+ input: p.inputTokens, output: p.outputTokens, cacheRead: p.cacheReadTokens, cacheWrite: p.cacheCreationTokens,
37
+ })));
38
+
39
+ // "Your usage on other models": the race, the facts and the card buttons, right under the grade card.
40
+ const vsBlock = report.reprice && report.reprice.models && report.reprice.models.length ? renderVsBlock(report.reprice, opts.vs || {}) : '';
41
+
42
+ // "You burnt N tokens. Did you spend them well?": the strip between the grade card and the race.
43
+ const swBlock = renderSpentWell(report, opts.telemetry || { on: false });
44
+
45
+ // Drift (your last week against what you wrote down), directly under that strip so the two read together.
46
+ const driftBlock = opts.drift ? renderDriftSection(opts.drift, { redact: !!opts.drift.redact }) : '';
47
+
48
+ const fmtCost = (n) => '$' + (n >= 100 ? Math.round(n).toLocaleString() : n.toFixed(2));
49
+ const fmtDuration = (m) => m >= 120 ? Math.round(m/60) + 'h' : m >= 60 ? (m/60).toFixed(1) + 'h' : m + 'm';
50
+
51
+ // Grade color mapping to Stitch palette
52
+ const gradeColorMap = {
53
+ 'A': '#c0c1ff', // primary indigo
54
+ 'B': '#d4bbff', // tertiary purple
55
+ 'C': '#ffb690', // secondary orange
56
+ 'D': '#ffb4ab', // error red
57
+ 'F': '#ffb4ab', // error red
58
+ };
59
+ const gradeColor = gradeColorMap[grade.letter] || '#908fa0';
60
+
61
+ // Grade label mapping
62
+ const gradeLabelMap = {
63
+ 'A': 'Excellent Performance',
64
+ 'B': 'Good Performance',
65
+ 'C': 'Fair Performance',
66
+ 'D': 'Poor Performance',
67
+ 'F': 'Critical Performance',
68
+ };
69
+ const gradeLabel = gradeLabelMap[grade.letter] || grade.label || 'Unknown';
70
+
71
+ // Diagnosis one-liner for the share card
72
+ const diagnosisLine = inflection && inflection.direction === 'worsened' && inflection.multiplier >= 2
73
+ ? `Efficiency dropped ${inflection.multiplier}x on ${inflection.date}`
74
+ : anomalies.hasAnomalies
75
+ ? `${anomalies.anomalies.length} anomal${anomalies.anomalies.length === 1 ? 'y' : 'ies'} detected`
76
+ : grade.letter === 'A' ? 'System running clean'
77
+ : grade.letter === 'B' ? 'Minor optimization opportunities'
78
+ : `Cache efficiency needs attention`;
79
+
80
+ // Model colors from Stitch palette
81
+ const modelColors = ['#c0c1ff', '#d4bbff', '#ffb690', '#8083ff', '#a775ff', '#ffb4ab'];
82
+
83
+ // Severity to Stitch color mapping
84
+ const sevColorMap = {
85
+ critical: { border: '#ffb4ab', bg: 'rgba(255, 180, 171, 0.10)', text: '#ffb4ab' },
86
+ warning: { border: '#ffb690', bg: 'rgba(255, 182, 144, 0.10)', text: '#ffb690' },
87
+ info: { border: '#c0c1ff', bg: 'rgba(192, 193, 255, 0.10)', text: '#c0c1ff' },
88
+ positive: { border: '#c0c1ff', bg: 'rgba(192, 193, 255, 0.10)', text: '#c0c1ff' },
89
+ };
90
+
91
+ return `<!DOCTYPE html>
92
+ <html class="dark" lang="en">
93
+ <head>
94
+ <meta charset="UTF-8">
95
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
96
+ <title>CC Hubber</title>
97
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
98
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
99
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
100
+ <script>
101
+ tailwind.config = {
102
+ darkMode: "class",
103
+ theme: {
104
+ extend: {
105
+ colors: {
106
+ "background": "#121315",
107
+ "surface": "#121315",
108
+ "surface-dim": "#121315",
109
+ "surface-container-lowest": "#0d0e0f",
110
+ "surface-container-low": "#1b1c1d",
111
+ "surface-container": "#1f2021",
112
+ "surface-container-high": "#292a2b",
113
+ "surface-container-highest": "#343536",
114
+ "surface-bright": "#38393a",
115
+ "on-surface": "#e3e2e3",
116
+ "on-surface-variant": "#c7c4d7",
117
+ "on-background": "#e3e2e3",
118
+ "outline": "#908fa0",
119
+ "outline-variant": "#464554",
120
+ "primary": "#c0c1ff",
121
+ "primary-container": "#8083ff",
122
+ "on-primary": "#1000a9",
123
+ "secondary": "#ffb690",
124
+ "secondary-container": "#ec6a06",
125
+ "on-secondary": "#552100",
126
+ "tertiary": "#d4bbff",
127
+ "tertiary-container": "#a775ff",
128
+ "error": "#ffb4ab",
129
+ "error-container": "#93000a",
130
+ "on-error": "#690005",
131
+ "inverse-surface": "#e3e2e3",
132
+ "inverse-on-surface": "#303032",
133
+ "inverse-primary": "#494bd6",
134
+ "surface-tint": "#c0c1ff",
135
+ },
136
+ fontFamily: {
137
+ "headline": ["Inter", "system-ui", "sans-serif"],
138
+ "body": ["Inter", "system-ui", "sans-serif"],
139
+ "label": ["Inter", "system-ui", "sans-serif"],
140
+ "mono": ["JetBrains Mono", "monospace"],
141
+ },
142
+ borderRadius: {
143
+ "DEFAULT": "0.125rem",
144
+ "lg": "0.25rem",
145
+ "xl": "0.5rem",
146
+ "full": "0.75rem",
147
+ },
148
+ },
149
+ },
150
+ }
151
+ </script>
152
+ <style>
153
+ body {
154
+ background-color: #121315;
155
+ color: #e3e2e3;
156
+ font-family: 'Inter', system-ui, sans-serif;
157
+ -webkit-font-smoothing: antialiased;
158
+ -moz-osx-font-smoothing: grayscale;
159
+ }
160
+ .font-mono { font-family: 'JetBrains Mono', monospace !important; }
161
+
162
+ /* Info tooltips */
163
+ .has-tip{position:relative;cursor:help;}
164
+ .has-tip .tip{
165
+ position:absolute;top:calc(100% + 8px);left:0;transform:none;
166
+ background:#292a2b;border:1px solid rgba(70,69,84,0.3);border-radius:8px;
167
+ padding:10px 14px;font-size:11px;line-height:1.5;color:#c7c4d7;
168
+ width:280px;pointer-events:none;opacity:0;transition:opacity 0.15s;
169
+ z-index:50;text-transform:none;letter-spacing:0;font-weight:400;
170
+ box-shadow:0 8px 24px rgba(0,0,0,0.4);
171
+ }
172
+ .has-tip:hover .tip{opacity:1;pointer-events:auto;}
173
+
174
+ /* Tooltip */
175
+ .tt {
176
+ position: fixed;
177
+ background: rgba(31, 32, 33, 0.95);
178
+ backdrop-filter: blur(12px);
179
+ border: 1px solid rgba(70, 69, 84, 0.3);
180
+ border-radius: 0.5rem;
181
+ padding: 10px 14px;
182
+ pointer-events: none;
183
+ opacity: 0;
184
+ transition: opacity 0.12s;
185
+ z-index: 100;
186
+ white-space: nowrap;
187
+ }
188
+ .tt.on { opacity: 1; }
189
+
190
+ /* Toast */
191
+ .toast {
192
+ position: fixed;
193
+ bottom: 20px;
194
+ left: 50%;
195
+ transform: translateX(-50%) translateY(60px);
196
+ background: #292a2b;
197
+ border: 1px solid rgba(70, 69, 84, 0.3);
198
+ border-radius: 0.5rem;
199
+ padding: 10px 20px;
200
+ font-size: 12px;
201
+ font-weight: 600;
202
+ color: #e3e2e3;
203
+ opacity: 0;
204
+ transition: all 0.25s;
205
+ z-index: 200;
206
+ pointer-events: none;
207
+ }
208
+ .toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
209
+
210
+ /* SVG chart */
211
+ #cost-chart-svg { width: 100%; overflow: visible; display: block; }
212
+
213
+ /* Table hover */
214
+ .tbl-row:hover { background: #292a2b; }
215
+ .tbl-row { transition: background 0.15s; }
216
+ </style>
217
+ </head>
218
+ <body class="selection:bg-primary selection:text-on-primary">
219
+
220
+ <div class="tt" id="tt">
221
+ <div class="font-mono text-[11px] text-[#908fa0] mb-1" id="tt-d"></div>
222
+ <div class="font-mono text-[15px] font-bold text-[#e3e2e3]" id="tt-c"></div>
223
+ <div class="text-[10px] text-[#ffb4ab] mt-1" id="tt-a"></div>
224
+ </div>
225
+ <div class="toast" id="toast"></div>
226
+
227
+ <!-- 1. HEADER -->
228
+ <header class="w-full px-6 py-5 max-w-[1200px] mx-auto flex justify-between items-baseline">
229
+ <div class="flex items-baseline gap-4">
230
+ <a href="https://github.com/azkhh/cchubber" target="_blank" class="text-lg font-bold tracking-tight text-[#e3e2e3]" style="text-decoration:none;">CC Hubber</a>
231
+ <span class="text-[10px] font-mono text-[#908fa0]">v${PKG_VERSION}</span>
232
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0]">shipped fast with <a href="https://moveros.dev" target="_blank" style="text-decoration:none;color:inherit;">Mover OS</a></span>
233
+ </div>
234
+ <span class="font-mono text-[11px] text-[#908fa0]" id="range-lbl">All time</span>
235
+ </header>
236
+
237
+ <main class="pb-20 px-6 max-w-[1200px] mx-auto space-y-12">
238
+
239
+ <!-- 2. SHARE CARD — HTML for display, Canvas for video export -->
240
+ <section class="flex flex-col items-center">
241
+ <style>
242
+ @keyframes cardFloat{
243
+ 0%,100%{transform:perspective(800px) rotateY(-2deg) rotateX(1deg)}
244
+ 50%{transform:perspective(800px) rotateY(2deg) rotateX(-1deg)}
245
+ }
246
+ @keyframes shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}
247
+ .cc-card{
248
+ position:relative;width:100%;max-width:740px;
249
+ border-radius:22px;overflow:hidden;
250
+ background:linear-gradient(145deg,#1a1b2e 0%,#151622 20%,#0f1018 40%,#131428 55%,#191a2d 70%,#141520 85%,#12131f 100%);
251
+ box-shadow:0 2px 4px rgba(0,0,0,0.1),0 8px 16px rgba(0,0,0,0.1),0 16px 32px rgba(0,0,0,0.15);
252
+ animation:cardFloat 6s ease-in-out infinite;
253
+ }
254
+ .cc-card::before{
255
+ content:'';position:absolute;inset:0;
256
+ background:linear-gradient(105deg,transparent 30%,rgba(192,193,255,0.04) 45%,rgba(212,187,255,0.06) 50%,rgba(192,193,255,0.04) 55%,transparent 70%);
257
+ background-size:200% 100%;animation:shimmer 4s ease-in-out infinite;
258
+ pointer-events:none;z-index:2;
259
+ }
260
+ .cc-card::after{
261
+ content:'';position:absolute;inset:0;z-index:1;pointer-events:none;opacity:0.035;
262
+ background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
263
+ background-size:128px 128px;
264
+ }
265
+ .cc-inner{position:relative;z-index:3;display:flex;flex-direction:column;justify-content:space-between;padding:36px 40px;min-height:280px;}
266
+ .cc-card.no-shimmer::before{display:none!important;}
267
+ /* Phone: the three stats stack and wrap instead of running into each other, and the footer wraps. Desktop is untouched. */
268
+ @media (max-width:640px){
269
+ .cc-card .cc-inner{padding:24px 20px;gap:22px}
270
+ .cc-card .cc-stats{flex-wrap:wrap;justify-content:flex-start;align-items:flex-end;gap:18px 32px}
271
+ .cc-card .cc-stats > div{text-align:left;min-width:0}
272
+ .cc-card .cc-stats > div:first-child{flex:1 0 100%}
273
+ .cc-card .cc-stats p.font-mono{font-size:32px;overflow-wrap:anywhere}
274
+ .cc-card .cc-meta{white-space:nowrap;flex-shrink:0;margin-left:12px}
275
+ .cc-card .cc-foot{flex-direction:column;align-items:flex-start;gap:12px}
276
+ .cc-card .cc-foot > div{flex-wrap:wrap}
277
+ }
278
+ </style>
279
+ <div class="cc-card" id="share-card-html">
280
+ <div class="cc-inner">
281
+ <div class="flex items-start justify-between">
282
+ <div class="flex items-center gap-4">
283
+ <div class="w-14 h-14 flex items-center justify-center rounded-[12px]" style="background:${gradeColor}">
284
+ <span class="text-[30px]" style="color:#0f1018;font-weight:900;">${grade.letter}</span>
285
+ </div>
286
+ <div>
287
+ <span class="text-[10px] font-mono uppercase tracking-[0.08em] font-bold block" style="color:${gradeColor}">${grade.label}</span>
288
+ <span class="text-xl font-bold text-[#e3e2e3]">${gradeLabel}</span>
289
+ </div>
290
+ </div>
291
+ <div class="text-right cc-meta">
292
+ <span class="text-[11px] font-mono uppercase tracking-[0.1em] text-[#908fa0] font-bold block">Claude Code</span>
293
+ <span class="text-[11px] font-mono uppercase tracking-[0.06em] text-[#596678] block" id="card-range">All time</span>
294
+ </div>
295
+ </div>
296
+ <div class="flex justify-between items-end cc-stats">
297
+ <div>
298
+ <p class="text-[10px] uppercase tracking-[0.06em] text-[#908fa0] mb-1">Total Spend</p>
299
+ <p class="font-mono text-[40px] font-bold text-[#e3e2e3] leading-none" id="h-cost">${fmtCost(totalCost)}</p>
300
+ </div>
301
+ <div class="text-center">
302
+ <p class="text-[10px] uppercase tracking-[0.06em] text-[#908fa0] mb-1">Active Days</p>
303
+ <p class="font-mono text-[40px] font-bold text-[#e3e2e3] leading-none" id="h-days">${activeDays}</p>
304
+ </div>
305
+ <div class="text-right">
306
+ <p class="text-[10px] uppercase tracking-[0.06em] text-[#908fa0] mb-1">Cache Ratio</p>
307
+ <p class="font-mono text-[40px] font-bold text-[#e3e2e3] leading-none">${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : 'N/A'}</p>
308
+ </div>
309
+ </div>
310
+ <div class="flex justify-between items-end cc-foot">
311
+ <p class="text-[12px] text-[#908fa0]">${diagnosisLine}</p>
312
+ <div class="flex items-center gap-2 text-[12px] font-mono tracking-[0.03em] shrink-0">
313
+ <a href="https://github.com/azkhh/cchubber" target="_blank" class="text-[#c0c1ff] hover:text-[#e1e0ff]" style="text-decoration:none;font-weight:600;">CC Hubber</a>
314
+ <span class="text-[#464554]">&middot;</span>
315
+ <span class="text-[#908fa0]">shipped fast with</span>
316
+ <a href="https://moveros.dev" target="_blank" class="text-[#c0c1ff] hover:text-[#e1e0ff]" style="text-decoration:none;font-weight:600;">Mover OS</a>
317
+ </div>
318
+ </div>
319
+ </div>
320
+ </div>
321
+ <!-- Hidden canvas for video recording -->
322
+ <canvas id="share-card" style="display:none;"></canvas>
323
+ <div class="flex justify-center mt-5">
324
+ <button id="btn-gif" class="px-5 py-2 border border-[rgba(70,69,84,0.3)] rounded-lg text-xs font-semibold text-[#908fa0] hover:bg-[#292a2b] hover:text-[#e3e2e3] transition-colors flex items-center gap-2 cursor-pointer">
325
+ <span class="material-symbols-outlined text-sm">share</span>
326
+ Share
327
+ </button>
328
+ </div>
329
+ </section>
330
+
331
+ ${swBlock}
332
+ ${driftBlock}
333
+ ${vsBlock}
334
+
335
+ ${inflection && inflection.multiplier >= 1.5 ? `
336
+ <!-- Inflection callouts -->
337
+ <section class="space-y-3">
338
+ <div class="p-6 bg-[#0d0e0f] border-l-4 rounded-r-xl" style="border-left-color:${inflection.direction === 'worsened' ? '#ffb4ab' : '#c0c1ff'}">
339
+ <p class="text-xs font-bold uppercase tracking-[0.05em] mb-1" style="color:${inflection.direction === 'worsened' ? '#ffb4ab' : '#c0c1ff'}">${inflection.direction === 'worsened' ? 'Degradation Detected' : 'Inflection Point'}</p>
340
+ <p class="text-sm text-[#c7c4d7]">${inflection.summary}</p>
341
+ </div>
342
+ ${inflection.secondary ? `
343
+ <div class="p-6 bg-[#0d0e0f] border-l-4 rounded-r-xl" style="border-left-color:${inflection.secondary.direction === 'worsened' ? '#ffb4ab' : '#c0c1ff'}">
344
+ <p class="text-xs font-bold uppercase tracking-[0.05em] mb-1" style="color:${inflection.secondary.direction === 'worsened' ? '#ffb4ab' : '#c0c1ff'}">${inflection.secondary.direction === 'worsened' ? 'Degradation Detected' : 'Recovery Detected'}</p>
345
+ <p class="text-sm text-[#c7c4d7]">${inflection.secondary.summary}</p>
346
+ </div>` : ''}
347
+ </section>
348
+ ` : ''}
349
+
350
+ <!-- 3. METRIC GRID -->
351
+ <section class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 rounded-xl border border-[rgba(70,69,84,0.15)]" style="gap:1px; background:rgba(70,69,84,0.15);">
352
+ <div class="p-6 bg-[#0d0e0f]">
353
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Total Cost</span>
354
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]" id="ov-total">${fmtCost(totalCost)}</span>
355
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono" id="ov-avg">${fmtCost(costAnalysis.avgDailyCost || 0)} avg/day</span>
356
+ </div>
357
+ <div class="p-6 bg-[#0d0e0f]">
358
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Peak Day</span>
359
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${peakDay ? fmtCost(peakDay.cost) : '$0'}</span>
360
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${peakDay ? peakDay.date : ''}</span>
361
+ </div>
362
+ <div class="p-6 bg-[#0d0e0f]">
363
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3 has-tip">Cache Health <span class="text-[8px] text-[#464554]">&#9432;</span>
364
+ <span class="tip">Overall grade based on your cache efficiency ratio, weighted towards recent 7 days. A-B = healthy (300-800:1 ratio). C = elevated, investigate. D-F = critical, likely affected by cache bugs. The grade drops when recent efficiency is worse than historical.</span>
365
+ </span>
366
+ <span class="font-mono text-2xl font-bold block" style="color:${gradeColor}">${grade.letter}</span>
367
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : ''}</span>
368
+ </div>
369
+ <div class="p-6 bg-[#0d0e0f]">
370
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3 has-tip">Cache Breaks <span class="text-[8px] text-[#464554]">&#9432;</span>
371
+ <span class="tip">When your prompt cache is invalidated, Claude Code re-reads your entire context at 12.5x the cached price. Causes: editing CLAUDE.md mid-session, connecting/disconnecting MCP tools, model switches, 5-min inactivity timeout. ${cacheHealth.totalCacheBreaks > 0 ? 'Counted from cache-break diff files.' : 'Estimated from cache write tokens since no diff files exist on your CC version.'}</span>
372
+ </span>
373
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${cacheHealth.totalCacheBreaks > 0 ? cacheHealth.totalCacheBreaks : '~' + (cacheHealth.estimatedBreaks || 0)}</span>
374
+ <span class="text-[10px] text-[#908fa0] mt-1 block">${cacheHealth.totalCacheBreaks > 0 ? cacheHealth.reasonsRanked?.[0]?.reason : 'estimated from writes'}</span>
375
+ </div>
376
+ <div class="p-6 bg-[#0d0e0f]">
377
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">CLAUDE.md</span>
378
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">~${Math.round(claudeMdStack.totalTokensEstimate / 1000)}K</span>
379
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${(claudeMdStack.totalBytes / 1024).toFixed(1)} KB</span>
380
+ </div>
381
+ ${sessionIntel?.available ? `
382
+ <div class="p-6 bg-[#0d0e0f]">
383
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Sessions</span>
384
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${sessionIntel.totalSessions}</span>
385
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${sessionIntel.avgDuration} min avg</span>
386
+ </div>` : `
387
+ <div class="p-6 bg-[#0d0e0f]">
388
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Sessions</span>
389
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${costAnalysis.sessions?.total || 0}</span>
390
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${costAnalysis.sessions?.avgDurationMinutes ? Math.round(costAnalysis.sessions.avgDurationMinutes) + ' min avg' : ''}</span>
391
+ </div>`}
392
+
393
+ </section>
394
+
395
+ <!-- 4. COST TREND CHART -->
396
+ <section class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
397
+ <div class="flex justify-between items-end mb-10">
398
+ <div>
399
+ <h3 class="text-xl font-bold text-[#e3e2e3] mb-1">Cost Trend</h3>
400
+ <p class="text-sm text-[#908fa0]" id="chart-info"></p>
401
+ </div>
402
+ <div class="flex gap-1 p-1 bg-[#0d0e0f] rounded-xl border border-[rgba(70,69,84,0.15)]" id="filters">
403
+ <button class="cfilt px-3 py-1.5 text-[10px] font-bold uppercase rounded-lg text-[#908fa0] hover:text-[#e3e2e3] transition-colors" data-r="7">7d</button>
404
+ <button class="cfilt px-3 py-1.5 text-[10px] font-bold uppercase rounded-lg text-[#908fa0] hover:text-[#e3e2e3] transition-colors" data-r="30">30d</button>
405
+ <button class="cfilt px-3 py-1.5 text-[10px] font-bold uppercase rounded-lg text-[#908fa0] hover:text-[#e3e2e3] transition-colors" data-r="90">90d</button>
406
+ <button class="cfilt px-3 py-1.5 text-[10px] font-bold uppercase rounded-lg bg-[#c0c1ff] text-[#1000a9] transition-colors" data-r="all">All</button>
407
+ </div>
408
+ </div>
409
+ <svg id="cost-chart-svg" viewBox="0 0 900 200" preserveAspectRatio="xMidYMid meet"></svg>
410
+ </section>
411
+
412
+ ${report.valueTrend?.available ? (() => {
413
+ const vt = report.valueTrend;
414
+ const comp = vt.comparison;
415
+ const trendColor = vt.trend === 'declining' ? '#ffb4ab' : vt.trend === 'improving' ? '#10b981' : '#908fa0';
416
+
417
+ // Build verdict around output per dollar — the metric people actually care about
418
+ let verdict, verdictColor, verdictDetail;
419
+ if (!comp || !comp.olderAvgPerDollar) {
420
+ verdict = 'Not enough data to compare yet. Run again next week.';
421
+ verdictColor = '#908fa0';
422
+ verdictDetail = '';
423
+ } else if (comp.dollarChangePct < -20) {
424
+ verdict = 'Yes. ' + Math.abs(comp.dollarChangePct) + '% less output per dollar recently.';
425
+ verdictColor = '#ffb4ab';
426
+ var limitImpact = Math.round(100 / (100 - Math.abs(comp.dollarChangePct)) * 100 - 100);
427
+ verdictDetail = 'Before: ' + comp.olderAvgPerDollar.toLocaleString() + ' tokens/$1. Now: ' + comp.recentAvgPerDollar.toLocaleString() + ' tokens/$1. In practice, your usage limits run out ~' + limitImpact + '% faster than before. This could be caused by cache changes, heavier Opus usage, or longer sessions.';
428
+ } else if (comp.dollarChangePct > 20) {
429
+ verdict = 'No. Actually getting ' + comp.dollarChangePct + '% more output per dollar recently.';
430
+ verdictColor = '#10b981';
431
+ verdictDetail = 'Before: ' + comp.olderAvgPerDollar.toLocaleString() + ' tokens/$1. Now: ' + comp.recentAvgPerDollar.toLocaleString() + ' tokens/$1.';
432
+ } else {
433
+ verdict = 'No. Your output per dollar is stable.';
434
+ verdictColor = '#10b981';
435
+ verdictDetail = comp.recentAvgPerDollar.toLocaleString() + ' output tokens per $1 (was ' + comp.olderAvgPerDollar.toLocaleString() + ').';
436
+ }
437
+
438
+ return `
439
+ <!-- VALUE TREND -->
440
+ <section class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
441
+ <h3 class="text-xl font-bold text-[#e3e2e3] mb-4">Are you getting less for your money?</h3>
442
+ <p class="text-[15px] font-semibold mb-1" style="color:${verdictColor}">${verdict}</p>
443
+ ${verdictDetail ? '<p class="text-[12px] text-[#908fa0] mb-2">' + verdictDetail + '</p>' : ''}
444
+ ${inflection ? '<p class="text-[11px] text-[#908fa0] mb-4">A cache change was detected on ' + esc(inflection.date) + '. Your recent 7 days reflect where you are now.</p>' : ''}
445
+
446
+ ${(vt.anomalies?.length > 0 || vt.costAnomalies?.length > 0) ? `
447
+ <div class="mt-6 space-y-2">
448
+ ${vt.anomalies?.length > 0 ? `
449
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Days with unusually short responses</span>
450
+ ${vt.anomalies.slice(0, 3).map(a => `
451
+ <div class="p-3 bg-[#0d0e0f] rounded-lg flex items-center justify-between">
452
+ <span class="text-[11px] font-mono text-[#908fa0]">${a.date}</span>
453
+ <span class="text-[11px] text-[#e3e2e3]">~${Math.round(a.outputPerMsg / 1.3)} words/response</span>
454
+ <span class="text-[10px] font-mono px-2 py-0.5 rounded" style="background:rgba(255,180,171,0.15);color:#ffb4ab">${a.deviation}σ below avg</span>
455
+ </div>`).join('')}` : ''}
456
+ ${vt.costAnomalies?.length > 0 ? `
457
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mt-3 mb-1">Days where each message cost more than usual (${vt.costAnomalies.length} days)</span>
458
+ ${vt.costAnomalies.slice(0, 5).map(a => `
459
+ <div class="p-3 bg-[#0d0e0f] rounded-lg grid" style="grid-template-columns:100px 1fr auto;gap:12px;align-items:center">
460
+ <span class="text-[11px] font-mono text-[#908fa0]">${a.date}</span>
461
+ <span class="text-[11px] text-[#e3e2e3]">$${a.costPerMsg}/msg vs $${a.medianCostPerMsg} median</span>
462
+ <span class="text-[10px] font-mono px-2 py-0.5 rounded" style="background:rgba(255,180,171,0.15);color:#ffb4ab">${a.multiplier}x</span>
463
+ </div>`).join('')}` : ''}
464
+ </div>` : ''}
465
+ </section>`;
466
+ })() : ''}
467
+
468
+ <!-- 5. SESSION INTELLIGENCE + MODEL DISTRIBUTION -->
469
+ <section class="grid grid-cols-1 lg:grid-cols-2 gap-8">
470
+
471
+ <!-- Session Intelligence -->
472
+ <div class="space-y-8">
473
+ ${sessionIntel?.available ? `
474
+ <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
475
+ <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Session Intelligence</h3>
476
+ <div class="grid grid-cols-3 gap-6 mb-8">
477
+ <div>
478
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Median</span>
479
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${fmtDuration(sessionIntel.medianDuration)}</span>
480
+ </div>
481
+ <div>
482
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">P90</span>
483
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${fmtDuration(sessionIntel.p90Duration)}</span>
484
+ </div>
485
+ <div>
486
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Longest</span>
487
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${fmtDuration(sessionIntel.maxDuration)}</span>
488
+ </div>
489
+ </div>
490
+ <div class="grid grid-cols-3 gap-6 mb-8">
491
+ <div>
492
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Long Sessions</span>
493
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.longSessions}</span>
494
+ <span class="text-[10px] text-[#908fa0] block font-mono">${sessionIntel.longSessionPct}% over 60m</span>
495
+ </div>
496
+ <div>
497
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Tools/Session</span>
498
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.avgToolsPerSession}</span>
499
+ </div>
500
+ <div>
501
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Lines/Hour</span>
502
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.linesPerHour.toLocaleString()}</span>
503
+ </div>
504
+ </div>
505
+
506
+ ${sessionIntel.topTools.length > 0 ? `
507
+ <div>
508
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-4">Top Tools Usage</span>
509
+ <div class="space-y-3">
510
+ ${sessionIntel.topTools.slice(0, 10).map((t, i) => `
511
+ <div class="space-y-1">
512
+ <div class="flex justify-between text-[11px] font-mono">
513
+ <span class="text-[#c7c4d7]">${t.name}</span>
514
+ <span class="text-[#908fa0]">${t.count}</span>
515
+ </div>
516
+ <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden">
517
+ <div class="h-full rounded-full" style="width:${sessionIntel.topTools[0].count > 0 ? (t.count / sessionIntel.topTools[0].count * 100) : 0}%;background:${i === 0 ? '#c0c1ff' : '#d4bbff'}"></div>
518
+ </div>
519
+ </div>`).join('')}
520
+ </div>
521
+ </div>` : ''}
522
+ </div>` : ''}
523
+
524
+ <!-- 6. ACTIVITY HEATMAP -->
525
+ ${sessionIntel?.hourDistribution ? `
526
+ <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
527
+ <h3 class="text-lg font-bold text-[#e3e2e3] mb-4">Activity by Hour</h3>
528
+ <div class="" id="hour-grid" style="display:flex;justify-content:space-between;align-items:flex-end;padding:0 4px;"></div>
529
+ <!-- labels rendered by JS -->
530
+ </div>` : ''}
531
+ </div>
532
+
533
+ <!-- Model Distribution -->
534
+ <div class="space-y-8">
535
+ <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
536
+ <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Model Distribution</h3>
537
+ <div class="w-full h-4 flex rounded-full overflow-hidden mb-6" style="gap:2px">
538
+ ${modelEntries.map(([, cost], i) => {
539
+ const pct = totalCost > 0 ? (cost / totalCost) * 100 : 0;
540
+ return `<div class="h-full" style="width:${pct}%;background:${modelColors[i % modelColors.length]};border-radius:${i === 0 ? '9999px 0 0 9999px' : i === modelEntries.length - 1 ? '0 9999px 9999px 0' : '0'}"></div>`;
541
+ }).join('')}
542
+ </div>
543
+ <div class="grid grid-cols-2 gap-4">
544
+ ${modelEntries.map(([name, cost], i) => {
545
+ const pct = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(1) : '0';
546
+ return `<div class="flex items-center gap-2">
547
+ <div class="w-2 h-2 rounded-full" style="background:${modelColors[i % modelColors.length]}"></div>
548
+ <span class="text-xs font-mono text-[#c7c4d7]">${name}</span>
549
+ <span class="text-xs font-mono text-[#908fa0]">${fmtCost(cost)}</span>
550
+ <span class="text-[10px] text-[#464554] font-mono">${pct}%</span>
551
+ </div>`;
552
+ }).join('')}
553
+ </div>
554
+ ${modelRouting?.available ? `
555
+ <div class="mt-6 pt-6 border-t border-[rgba(70,69,84,0.15)] text-sm text-[#c7c4d7]">
556
+ <span class="font-mono">${modelRouting.opusPct}%</span> Opus &middot;
557
+ <span class="font-mono">${modelRouting.sonnetPct}%</span> Sonnet &middot;
558
+ <span class="font-mono">${modelRouting.haikuPct}%</span> Haiku
559
+ ${modelRouting.estimatedSavings > 10 ? `<span class="text-[#c0c1ff] ml-3 font-mono">~${fmtCost(modelRouting.estimatedSavings)} potential savings</span>` : ''}
560
+ </div>` : ''}
561
+ </div>
562
+
563
+ <!-- 9. RECOMMENDATIONS (placed alongside model distribution) -->
564
+ ${recommendations.length > 0 ? `
565
+ <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
566
+ <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Recommendations</h3>
567
+ <div class="space-y-3">
568
+ ${recommendations.map((r, idx) => {
569
+ const sev = sevColorMap[r.severity] || sevColorMap.info;
570
+ const clipboardText = `CC Hubber flagged this about my setup:\n\n${r.title}\n\n${r.action}\n\nBefore making changes:\n1. Read the relevant files first to understand the current setup\n2. Do NOT remove or modify anything without asking me — some things are there intentionally\n3. Show me what each section/setting costs in tokens and let me decide what to keep\n4. Suggest optimizations (move to skills, use hooks, restructure) rather than deletion\n5. Do not break existing working functionality`;
571
+ const b64 = Buffer.from(clipboardText).toString('base64');
572
+ return `<div class="p-4 bg-[#0d0e0f] rounded-r-lg flex items-start gap-4" style="border-left:3px solid ${sev.border}">
573
+ <div class="flex-1 min-w-0">
574
+ <div class="flex items-start justify-between gap-4">
575
+ <p class="text-[13px] font-semibold text-[#e3e2e3]">${esc(r.title)}</p>
576
+ <div class="flex items-center gap-2 shrink-0">
577
+ ${r.savings ? `<span class="text-[10px] font-mono px-2 py-0.5 rounded" style="background:${sev.border}18;color:${sev.text}">${r.savings}</span>` : ''}
578
+ ${r.severity !== 'positive' ? `<button data-clip="${b64}" onclick="var t=atob(this.dataset.clip);navigator.clipboard.writeText(t);this.textContent='Copied!';var b=this;setTimeout(function(){b.textContent='Fix with Claude'},1500)" class="text-[10px] font-mono px-2 py-0.5 rounded border border-[rgba(70,69,84,0.3)] text-[#908fa0] cursor-pointer hover:text-[#e3e2e3] hover:border-[rgba(70,69,84,0.6)] transition-colors">Fix with Claude</button>` : ''}
579
+ </div>
580
+ </div>
581
+ <p class="text-[11px] text-[#908fa0] mt-1 leading-relaxed">${esc(r.action)}</p>
582
+ </div>
583
+ </div>`;
584
+ }).join('')}
585
+ </div>
586
+ </div>` : ''}
587
+ </div>
588
+ </section>
589
+
590
+ <!-- 7. COMMUNITY LEADERBOARD -->
591
+ <section id="community-section" class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden" style="display:none">
592
+ <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)]">
593
+ <div class="flex items-center justify-between">
594
+ <h3 class="text-xl font-bold text-[#e3e2e3]">Community</h3>
595
+ <span id="community-count" class="text-[10px] font-mono text-[#908fa0]"></span>
596
+ </div>
597
+ <p id="community-percentile" class="text-sm text-[#908fa0] mt-1"></p>
598
+ </div>
599
+
600
+ <!-- Grade distribution bar -->
601
+ <div class="px-8 py-4 border-b border-[rgba(70,69,84,0.15)]">
602
+ <div class="flex items-center gap-3 mb-2">
603
+ <span class="text-[10px] font-mono text-[#908fa0] uppercase tracking-wider">Grade Distribution</span>
604
+ </div>
605
+ <div id="grade-dist-bar" class="flex h-6 rounded overflow-hidden"></div>
606
+ <div id="grade-dist-labels" class="flex mt-1"></div>
607
+ </div>
608
+
609
+ <!-- Leaderboard table -->
610
+ <div class="overflow-x-auto">
611
+ <table class="w-full">
612
+ <thead>
613
+ <tr class="border-b border-[rgba(70,69,84,0.15)]">
614
+ <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-left">#</th>
615
+ <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Grade</th>
616
+ <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Ratio</th>
617
+ <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-left">Cost</th>
618
+ <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Opus %</th>
619
+ <th class="px-4 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-left">Country</th>
620
+ </tr>
621
+ </thead>
622
+ <tbody id="leaderboard-body"></tbody>
623
+ </table>
624
+ </div>
625
+ </section>
626
+
627
+ <!-- 8. PROJECTS TABLE -->
628
+ ${projectBreakdown && projectBreakdown.length > 0 ? `
629
+ <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
630
+ <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
631
+ <h3 class="text-xl font-bold text-[#e3e2e3]">Projects</h3>
632
+ <button id="toggle-paths" onclick="document.querySelectorAll('.proj-name,.proj-path').forEach(e=>e.style.filter=e.style.filter?'':'blur(8px)');this.textContent=this.textContent==='Hide names'?'Show names':'Hide names'" class="text-[10px] font-mono text-[#908fa0] px-3 py-1 border border-[rgba(70,69,84,0.3)] rounded cursor-pointer hover:text-[#e3e2e3]">Hide names</button>
633
+ </div>
634
+ <div class="overflow-x-auto">
635
+ <table class="w-full text-left" id="proj-tbl">
636
+ <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
637
+ <tr>
638
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Project</th>
639
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Messages</th>
640
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Sessions</th>
641
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Est. Cost</th>
642
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Output</th>
643
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Cache Read</th>
644
+ </tr>
645
+ </thead>
646
+ <tbody class="divide-y divide-[rgba(70,69,84,0.15)]"></tbody>
647
+ </table>
648
+ </div>
649
+ </section>
650
+ ` : ''}
651
+
652
+ <!-- 8. ANOMALIES TABLE -->
653
+ ${anomalies.hasAnomalies ? `
654
+ <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
655
+ <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
656
+ <h3 class="text-xl font-bold text-[#e3e2e3]">Detected Anomalies</h3>
657
+ <span class="material-symbols-outlined text-[#ffb4ab] animate-pulse">warning</span>
658
+ </div>
659
+ <div class="overflow-x-auto">
660
+ <table class="w-full text-left">
661
+ <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
662
+ <tr>
663
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Date</th>
664
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Cost</th>
665
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Deviation</th>
666
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Cache Ratio</th>
667
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Severity</th>
668
+ </tr>
669
+ </thead>
670
+ <tbody class="divide-y divide-[rgba(70,69,84,0.15)]">
671
+ ${anomalies.anomalies.map(a => {
672
+ const sevBg = a.severity === 'critical' ? 'rgba(255, 180, 171, 0.10)' : 'rgba(255, 182, 144, 0.10)';
673
+ const sevText = a.severity === 'critical' ? '#ffb4ab' : '#ffb690';
674
+ return `<tr class="tbl-row">
675
+ <td class="px-8 py-4 font-mono text-sm text-[#e3e2e3]">${a.date}</td>
676
+ <td class="px-8 py-4 font-mono text-sm text-[#e3e2e3] font-bold">${fmtCost(a.cost)}</td>
677
+ <td class="px-8 py-4 font-mono text-sm font-bold" style="color:${a.deviation > 0 ? '#ffb4ab' : '#c0c1ff'}">${a.deviation > 0 ? '+' : ''}$${a.deviation.toFixed(2)}</td>
678
+ <td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">${a.cacheOutputRatio ? a.cacheOutputRatio.toLocaleString() + ':1' : ''}</td>
679
+ <td class="px-8 py-4 text-right"><span class="px-2 py-0.5 rounded text-[10px] font-bold font-mono uppercase" style="background:${sevBg};color:${sevText}">${a.severity}</span></td>
680
+ </tr>`;
681
+ }).join('')}
682
+ </tbody>
683
+ </table>
684
+ </div>
685
+ </section>
686
+ ` : ''}
687
+
688
+ <!-- 10. CLAUDE.md ANALYSIS — Global only, section breakdown -->
689
+ <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
690
+ <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
691
+ <h3 class="text-xl font-bold text-[#e3e2e3]">CLAUDE.md Analysis</h3>
692
+ <div class="text-right">
693
+ <span class="font-mono text-sm text-[#e3e2e3]">${claudeMdStack.files[0]?.lineCount || '?'} lines</span>
694
+ <span class="text-[#908fa0] mx-2">&middot;</span>
695
+ <span class="font-mono text-sm text-[#e3e2e3]">~${claudeMdStack.totalTokensEstimate.toLocaleString()} tokens</span>
696
+ <span class="text-[#908fa0] mx-2">&middot;</span>
697
+ <span class="font-mono text-sm text-[#e3e2e3]">${(claudeMdStack.totalBytes / 1024).toFixed(1)} KB</span>
698
+ </div>
699
+ </div>
700
+ <div class="px-8 py-4 bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)] flex justify-between text-xs">
701
+ <span class="text-[#908fa0]">Per-message cost impact</span>
702
+ <span class="font-mono">
703
+ <span class="text-[#c0c1ff]">$${claudeMdStack.costPerMessage.cached.toFixed(4)}</span> cached &middot;
704
+ <span class="text-[#ffb690]">$${claudeMdStack.costPerMessage.uncached.toFixed(4)}</span> uncached &middot;
705
+ <span class="text-[#ffb4ab]">$${(claudeMdStack.costPerMessage.dailyCached200 || 0).toFixed(2)}</span>/day at 200 msgs
706
+ </span>
707
+ </div>
708
+ ${claudeMdStack.globalSections && claudeMdStack.globalSections.length > 0 ? `
709
+ <div class="overflow-x-auto">
710
+ <table class="w-full text-left">
711
+ <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
712
+ <tr>
713
+ <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Section</th>
714
+ <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Lines</th>
715
+ <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Tokens</th>
716
+ </tr>
717
+ </thead>
718
+ <tbody class="divide-y divide-[rgba(70,69,84,0.15)]">
719
+ ${claudeMdStack.globalSections.map(s => `<tr class="tbl-row">
720
+ <td class="px-8 py-3 text-sm text-[#e3e2e3]">${esc(s.name)}</td>
721
+ <td class="px-8 py-3 font-mono text-sm text-[#c7c4d7] text-right">${s.lines}</td>
722
+ <td class="px-8 py-3 font-mono text-sm text-[#c7c4d7] text-right">${s.tokens.toLocaleString()}</td>
723
+ </tr>`).join('')}
724
+ </tbody>
725
+ </table>
726
+ </div>
727
+ ` : ''}
728
+ </section>
729
+
730
+ <!-- 11. CACHE SAVINGS -->
731
+ ${cacheHealth.savings?.fromCaching > 0 ? `
732
+ <section class="grid grid-cols-1 md:grid-cols-2 rounded-xl overflow-hidden border border-[rgba(70,69,84,0.15)]" style="gap:1px; background:rgba(70,69,84,0.15);">
733
+ <div class="p-8 bg-[#0d0e0f]">
734
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Saved by Cache</span>
735
+ <span class="font-mono text-3xl font-bold block text-[#c0c1ff]">~$${Number(cacheHealth.savings.fromCaching).toLocaleString()}</span>
736
+ <span class="text-[10px] text-[#908fa0] mt-2 block">vs standard input pricing</span>
737
+ </div>
738
+ <div class="p-8 bg-[#0d0e0f]">
739
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Wasted on Breaks</span>
740
+ <span class="font-mono text-3xl font-bold block text-[#ffb690]">~$${Number(cacheHealth.savings.wastedFromBreaks).toLocaleString()}</span>
741
+ <span class="text-[10px] text-[#908fa0] mt-2 block">from cache invalidation</span>
742
+ </div>
743
+ </section>
744
+ ` : ''}
745
+
746
+ ${cacheHealth.totalCacheBreaks > 0 ? `
747
+ <!-- Cache Break Reasons -->
748
+ <section class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
749
+ <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Cache Break Reasons</h3>
750
+ <div class="space-y-4">
751
+ ${(cacheHealth.reasonsRanked || []).map(r => `
752
+ <div class="space-y-1">
753
+ <div class="flex justify-between text-[11px] font-mono">
754
+ <span class="text-[#c7c4d7]">${r.reason}</span>
755
+ <span class="text-[#908fa0]">${r.count}</span>
756
+ </div>
757
+ <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden">
758
+ <div class="h-full bg-[#ffb690] rounded-full" style="width:${r.percentage}%"></div>
759
+ </div>
760
+ </div>`).join('')}
761
+ </div>
762
+ </section>
763
+ ` : ''}
764
+
765
+ </main>
766
+
767
+ <!-- 12. FOOTER -->
768
+ <footer class="w-full py-12 border-t border-[rgba(70,69,84,0.05)]">
769
+ <div class="max-w-[1200px] mx-auto px-6 text-center">
770
+ <span class="text-[10px] tracking-widest uppercase text-[#908fa0]"><a href="https://github.com/azkhh/cchubber" target="_blank" style="text-decoration:none;color:inherit;">CC Hubber</a> &middot; shipped fast with <a href="https://moveros.dev" target="_blank" style="text-decoration:none;color:inherit;">Mover OS</a></span>
771
+ </div>
772
+ </footer>
773
+
774
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
775
+ <script>
776
+ (function(){
777
+ var D=${dailyCostsJSON}, P=${projectsJSON};
778
+ var CARD={
779
+ grade:'${grade.letter}',gradeLabel:'${grade.label}',gradePerf:'${gradeLabel}',
780
+ gradeColor:'${gradeColor}',
781
+ cost:'${fmtCost(totalCost)}',days:'${activeDays}',
782
+ ratio:'${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : 'N/A'}',
783
+ diagnosis:'${diagnosisLine.replace(/'/g, "\\'")}',
784
+ range:'All time'
785
+ };
786
+ var HR=${sessionIntel?.hourDistribution ? JSON.stringify(sessionIntel.hourDistribution) : 'null'};
787
+ var CACHE_R=0.50,OUT=25,INP=5,CW=6.25;
788
+
789
+ function fc(n){return n>=100?'$'+Math.round(n).toLocaleString():'$'+n.toFixed(2)}
790
+ function ft(n){return n>=1e9?(n/1e9).toFixed(1)+'B':n>=1e6?(n/1e6).toFixed(1)+'M':n>=1e3?(n/1e3).toFixed(1)+'K':n.toString()}
791
+
792
+ // Hour activity — vertical bar chart
793
+ if(HR){
794
+ var hg=document.getElementById('hour-grid');
795
+ if(hg){
796
+ var mx=Math.max.apply(null,HR);
797
+ var html='';
798
+ for(var i=0;i<24;i++){
799
+ var pct=mx>0?Math.max(HR[i]/mx*100,2):2;
800
+ var opac=pct>70?'0.85':pct>40?'0.6':pct>15?'0.35':'0.12';
801
+ html+='<div style="display:flex;flex-direction:column;align-items:center;gap:4px;" title="'+i+':00 — '+HR[i]+' messages">';
802
+ html+='<div style="width:3px;height:80px;background:rgba(70,69,84,0.2);border-radius:2px;position:relative;overflow:hidden;">';
803
+ html+='<div style="position:absolute;bottom:0;width:100%;height:'+pct+'%;background:rgba(192,193,255,'+opac+');border-radius:2px;"></div>';
804
+ html+='</div>';
805
+ html+='<span style="font-size:8px;font-family:JetBrains Mono,monospace;color:'+(i%6===0?'#908fa0':'#464554')+';">'+i+'</span>';
806
+ html+='</div>';
807
+ }
808
+ hg.innerHTML=html;
809
+ }
810
+ }
811
+
812
+ // Project table
813
+ var ptb=document.querySelector('#proj-tbl tbody');
814
+ if(ptb&&P.length>0){
815
+ P.sort(function(a,b){return(b.output/1e6*OUT+b.cacheRead/1e6*CACHE_R)-(a.output/1e6*OUT+a.cacheRead/1e6*CACHE_R)});
816
+ var h='';
817
+ for(var i=0;i<P.length;i++){
818
+ var p=P[i];
819
+ h+='<tr class="tbl-row">';
820
+ h+='<td class="px-8 py-4 text-sm font-semibold text-[#e3e2e3]"><span class="proj-name">'+p.name+'</span>';
821
+ if(p.path)h+='<br><span class="proj-path text-[10px] text-[#908fa0] font-mono">'+p.path+'</span>';
822
+ h+='</td>';
823
+ var projCost=p.output/1e6*OUT+p.cacheRead/1e6*CACHE_R+p.input/1e6*INP+p.cacheWrite/1e6*CW;
824
+ h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.messages.toLocaleString()+'</td>';
825
+ h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.sessions+'</td>';
826
+ h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+fc(projCost)+'</td>';
827
+ h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+ft(p.output)+'</td>';
828
+ h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+ft(p.cacheRead)+'</td>';
829
+ h+='</tr>';
830
+ }
831
+ ptb.innerHTML=h;
832
+ }
833
+
834
+ // Chart
835
+ var W=900,H=200,PD={t:24,r:16,b:40,l:56};
836
+ var cW=W-PD.l-PD.r,cH=H-PD.t-PD.b;
837
+ var svg=document.getElementById('cost-chart-svg');
838
+ var tt=document.getElementById('tt'),ttd=document.getElementById('tt-d'),ttc=document.getElementById('tt-c'),tta=document.getElementById('tt-a');
839
+
840
+ function filt(r){return r==='all'?D:D.slice(-parseInt(r,10))}
841
+
842
+ function chart(d){
843
+ if(!svg)return;
844
+ if(!d.length){svg.innerHTML='<text x="450" y="100" text-anchor="middle" fill="#908fa0" font-size="13" font-family="Inter,sans-serif">No data for this period</text>';return}
845
+ var total=d.reduce(function(s,x){return s+x.cost},0);
846
+ if(total<0.01){svg.innerHTML='<text x="450" y="100" text-anchor="middle" fill="#908fa0" font-size="13" font-family="Inter,sans-serif">No cost data — costs are only calculated from token counts, not the costUSD field</text>';return}
847
+ var mx=Math.max.apply(null,d.map(function(x){return x.cost}))*1.1;if(mx<0.01)mx=1;
848
+ var s='';
849
+ // grid lines
850
+ for(var i=0;i<=3;i++){
851
+ var y=PD.t+(cH/3)*i,v=mx-(mx/3)*i;
852
+ s+='<line x1="'+PD.l+'" y1="'+y+'" x2="'+(W-PD.r)+'" y2="'+y+'" stroke="rgba(70,69,84,0.15)" stroke-width="1"/>';
853
+ s+='<text x="'+(PD.l-10)+'" y="'+(y+4)+'" text-anchor="end" fill="#908fa0" font-size="9" font-family="JetBrains Mono,monospace">$'+(v<1?v.toFixed(2):Math.round(v))+'</text>';
854
+ }
855
+ // area + line
856
+ var step=d.length>1?cW/(d.length-1):0;
857
+ var pts=d.map(function(x,j){return{x:PD.l+(d.length===1?cW/2:j*step),y:PD.t+cH-(x.cost/mx)*cH}});
858
+ var lp='M '+pts[0].x+' '+pts[0].y;
859
+ var ap='M '+pts[0].x+' '+(PD.t+cH)+' L '+pts[0].x+' '+pts[0].y;
860
+ for(var j=1;j<pts.length;j++){var cx=(pts[j-1].x+pts[j].x)/2;lp+=' C '+cx+' '+pts[j-1].y+' '+cx+' '+pts[j].y+' '+pts[j].x+' '+pts[j].y;ap+=' C '+cx+' '+pts[j-1].y+' '+cx+' '+pts[j].y+' '+pts[j].x+' '+pts[j].y}
861
+ ap+=' L '+pts[pts.length-1].x+' '+(PD.t+cH)+' Z';
862
+ // Stitch gradient: primary at 30% to transparent
863
+ s+='<defs><linearGradient id="ag" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#c0c1ff" stop-opacity="0.3"/><stop offset="100%" stop-color="#c0c1ff" stop-opacity="0"/></linearGradient></defs>';
864
+ s+='<path d="'+ap+'" fill="url(#ag)"/>';
865
+ s+='<path d="'+lp+'" fill="none" stroke="#c0c1ff" stroke-width="2" stroke-linecap="round"/>';
866
+ // x labels
867
+ var every=Math.max(1,Math.floor(d.length/8));
868
+ d.forEach(function(x,j){
869
+ var px=PD.l+(d.length===1?cW/2:j*step);
870
+ if(j%every===0||j===d.length-1)s+='<text x="'+px+'" y="'+(H-6)+'" text-anchor="middle" fill="#908fa0" font-size="9" font-family="JetBrains Mono,monospace">'+x.date.slice(5)+'</text>';
871
+ });
872
+ // anomaly dots
873
+ d.forEach(function(x,j){
874
+ var px=PD.l+(d.length===1?cW/2:j*step),py=PD.t+cH-(x.cost/mx)*cH;
875
+ if(x.isAnomaly)s+='<circle cx="'+px+'" cy="'+py+'" r="4" fill="#ffb4ab" stroke="#121315" stroke-width="2"/>';
876
+ });
877
+ // hover targets
878
+ d.forEach(function(x,j){
879
+ var px=PD.l+(d.length===1?cW/2:j*step),py=PD.t+cH-(x.cost/mx)*cH;
880
+ s+='<circle cx="'+px+'" cy="'+py+'" r="14" fill="transparent" data-d="'+x.date+'" data-c="'+x.cost+'" data-a="'+(x.isAnomaly?1:0)+'" data-o="'+(x.out||0)+'" class="hov" style="cursor:crosshair"/>';
881
+ });
882
+ svg.innerHTML=s;
883
+ svg.querySelectorAll('.hov').forEach(function(el){
884
+ el.addEventListener('mouseenter',function(e){
885
+ ttd.textContent=e.target.dataset.d;
886
+ var ot=parseInt(e.target.dataset.o||0);
887
+ ttc.textContent=fc(parseFloat(e.target.dataset.c))+(ot>0?' — '+ft(ot)+' output':'');
888
+ tta.textContent=e.target.dataset.a==='1'?'ANOMALY':'';
889
+ tta.style.display=e.target.dataset.a==='1'?'block':'none';
890
+ tt.classList.add('on');
891
+ });
892
+ el.addEventListener('mousemove',function(e){tt.style.left=(e.clientX+14)+'px';tt.style.top=(e.clientY-40)+'px'});
893
+ el.addEventListener('mouseleave',function(){tt.classList.remove('on')});
894
+ });
895
+ }
896
+
897
+ var RL={7:'Last 7 days',30:'Last 30 days',90:'Last 90 days',all:'All time'};
898
+
899
+ function setR(r){
900
+ var f=filt(r);chart(f);
901
+ var ci=document.getElementById('chart-info');
902
+ if(ci&&f.length){var t=f.reduce(function(s,x){return s+x.cost},0),a=f.filter(function(x){return x.cost>0}).length,tok=f.reduce(function(s,x){return s+(x.out||0)},0);ci.textContent=a+' days \u00b7 '+fc(t)+' \u00b7 '+ft(tok)+' output'}
903
+ var rl=document.getElementById('range-lbl');if(rl)rl.textContent=RL[r]||'All time';
904
+ CARD.range=RL[r]||'All time';
905
+ var cr=document.getElementById('card-range');if(cr)cr.textContent=CARD.range;
906
+ if(f.length){
907
+ var t=f.reduce(function(s,x){return s+x.cost},0),a=f.filter(function(x){return x.cost>0}).length;
908
+ CARD.cost=fc(t);CARD.days=a.toString();
909
+ // Update HTML card
910
+ var hc=document.getElementById('h-cost');if(hc)hc.textContent=fc(t);
911
+ var hd=document.getElementById('h-days');if(hd)hd.textContent=a;
912
+ // Update overview
913
+ var ot=document.getElementById('ov-total'),oa=document.getElementById('ov-avg');
914
+ if(ot)ot.textContent=fc(t);if(oa&&a>0)oa.textContent=fc(t/a)+' avg/day';
915
+ }
916
+ // Update filter button states - Stitch style
917
+ document.querySelectorAll('.cfilt').forEach(function(b){
918
+ if(b.dataset.r===r){
919
+ b.style.background='#c0c1ff';b.style.color='#1000a9';
920
+ } else {
921
+ b.style.background='transparent';b.style.color='#908fa0';
922
+ }
923
+ });
924
+ }
925
+
926
+ document.querySelectorAll('.cfilt').forEach(function(b){b.addEventListener('click',function(){setR(b.dataset.r)})});
927
+
928
+ // Export helpers
929
+ var toast=document.getElementById('toast');
930
+ function showToast(m){if(!toast)return;toast.textContent=m;toast.classList.add('on');setTimeout(function(){toast.classList.remove('on')},2000)}
931
+
932
+ // ─── CANVAS SHARE CARD ─────────────────────────────
933
+ // Renders entirely on canvas — same output for display AND video export
934
+ var cardCanvas=document.getElementById('share-card');
935
+ var cardW=1480,cardH=580,cardR=44; // 2x resolution for retina
936
+ cardCanvas.width=cardW;cardCanvas.height=cardH;
937
+ var cardCtx=cardCanvas.getContext('2d');
938
+
939
+ function roundRect(ctx,x,y,w,h,r){
940
+ ctx.beginPath();ctx.moveTo(x+r,y);ctx.lineTo(x+w-r,y);ctx.quadraticCurveTo(x+w,y,x+w,y+r);
941
+ ctx.lineTo(x+w,y+h-r);ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);ctx.lineTo(x+r,y+h);
942
+ ctx.quadraticCurveTo(x,y+h,x,y+h-r);ctx.lineTo(x,y+r);ctx.quadraticCurveTo(x,y,x+r,y);ctx.closePath();
943
+ }
944
+
945
+ function drawCard(ctx,w,h,shimmerT){
946
+ // Background gradient
947
+ var bg=ctx.createLinearGradient(0,0,w,h);
948
+ bg.addColorStop(0,'#1a1b2e');bg.addColorStop(0.4,'#0f1018');bg.addColorStop(0.7,'#191a2d');bg.addColorStop(1,'#12131f');
949
+ roundRect(ctx,0,0,w,h,cardR);ctx.save();ctx.clip();
950
+ ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
951
+
952
+ var pad=80,padT=72;
953
+
954
+ // Grade badge
955
+ var bx=pad,by=padT,bs=112;
956
+ roundRect(ctx,bx,by,bs,bs,24);
957
+ ctx.fillStyle=CARD.gradeColor;ctx.fill();
958
+ ctx.font='800 56px "JetBrains Mono",monospace';ctx.fillStyle='#0f1018';ctx.textAlign='center';ctx.textBaseline='middle';
959
+ ctx.fillText(CARD.grade,bx+bs/2,by+bs/2);
960
+
961
+ // Grade label + performance text
962
+ ctx.textAlign='left';ctx.textBaseline='top';
963
+ ctx.font='700 18px "JetBrains Mono",monospace';ctx.fillStyle=CARD.gradeColor;
964
+ ctx.fillText(CARD.gradeLabel.toUpperCase(),bx+bs+32,by+12);
965
+ ctx.font='700 36px "Inter",sans-serif';ctx.fillStyle='#e3e2e3';
966
+ ctx.fillText(CARD.gradePerf,bx+bs+32,by+40);
967
+
968
+ // Top right: Claude Code + range
969
+ ctx.textAlign='right';
970
+ ctx.font='700 20px "JetBrains Mono",monospace';ctx.fillStyle='#908fa0';
971
+ ctx.fillText('CLAUDE CODE',w-pad,padT+16);
972
+ ctx.font='400 16px "JetBrains Mono",monospace';ctx.fillStyle='#464554';
973
+ ctx.fillText(CARD.range,w-pad,padT+44);
974
+
975
+ // Stats row
976
+ var statsY=h*0.45;
977
+ var labels=['TOTAL SPEND','ACTIVE DAYS','CACHE RATIO'];
978
+ var values=[CARD.cost,CARD.days,CARD.ratio];
979
+ var positions=[pad,w*0.38,w*0.7];
980
+ var aligns=['left','center','right'];
981
+ var xEnds=[null,null,w-pad];
982
+
983
+ for(var i=0;i<3;i++){
984
+ ctx.textAlign=i===2?'right':i===1?'center':'left';
985
+ var sx=i===2?w-pad:i===1?w/2:pad;
986
+ ctx.font='400 16px "Inter",sans-serif';ctx.fillStyle='#908fa0';
987
+ ctx.fillText(labels[i],sx,statsY);
988
+ ctx.font='700 64px "JetBrains Mono",monospace';ctx.fillStyle='#e3e2e3';
989
+ ctx.fillText(values[i],sx,statsY+28);
990
+ }
991
+
992
+ // Bottom: diagnosis + branding
993
+ var botY=h-padT;
994
+ ctx.textAlign='left';
995
+ ctx.font='400 20px "Inter",sans-serif';ctx.fillStyle='#908fa0';
996
+ ctx.fillText(CARD.diagnosis,pad,botY);
997
+
998
+ // Branding — measure text to space properly
999
+ ctx.textAlign='right';
1000
+ ctx.font='500 18px "JetBrains Mono",monospace';
1001
+ var moverW=ctx.measureText('Mover OS').width;
1002
+ ctx.font='400 18px "Inter",sans-serif';
1003
+ var shippedW=ctx.measureText(' shipped fast with ').width;
1004
+ ctx.font='500 18px "JetBrains Mono",monospace';
1005
+ var hubberW=ctx.measureText('CC Hubber').width;
1006
+
1007
+ var bx=w-pad;
1008
+ ctx.font='500 18px "JetBrains Mono",monospace';ctx.fillStyle='#c0c1ff';
1009
+ ctx.fillText('Mover OS',bx,botY);
1010
+ bx-=moverW;
1011
+ ctx.font='400 18px "Inter",sans-serif';ctx.fillStyle='#908fa0';
1012
+ ctx.fillText(' shipped fast with ',bx,botY);
1013
+ bx-=shippedW;
1014
+ ctx.font='500 18px "JetBrains Mono",monospace';ctx.fillStyle='#c0c1ff';
1015
+ ctx.fillText('CC Hubber',bx,botY);
1016
+
1017
+ // Subtle shimmer sweep
1018
+ if(shimmerT!==undefined){
1019
+ var sx=-w*0.4+(shimmerT%1)*w*1.8;
1020
+ var sg=ctx.createLinearGradient(sx,0,sx+w*0.3,h);
1021
+ sg.addColorStop(0,'rgba(255,255,255,0)');
1022
+ sg.addColorStop(0.45,'rgba(192,193,255,0.02)');
1023
+ sg.addColorStop(0.5,'rgba(255,255,255,0.045)');
1024
+ sg.addColorStop(0.55,'rgba(212,187,255,0.02)');
1025
+ sg.addColorStop(1,'rgba(255,255,255,0)');
1026
+ ctx.fillStyle=sg;ctx.fillRect(0,0,w,h);
1027
+ }
1028
+
1029
+ ctx.restore();
1030
+ }
1031
+
1032
+ // Animate the card on the page
1033
+ var cardAnimStart=null;
1034
+ function animateCard(ts){
1035
+ if(!cardAnimStart)cardAnimStart=ts;
1036
+ var t=((ts-cardAnimStart)%6000)/6000;
1037
+ drawCard(cardCtx,cardW,cardH,t);
1038
+ requestAnimationFrame(animateCard);
1039
+ }
1040
+ document.fonts.ready.then(function(){requestAnimationFrame(animateCard)});
1041
+
1042
+ // ─── VIDEO EXPORT (records the same canvas) ──────
1043
+ // Video export — captures HTML card with html-to-image, animates on canvas at 1440p
1044
+ var gb=document.getElementById('btn-gif');
1045
+ if(gb)gb.addEventListener('click',function(){
1046
+ gb.textContent='Capturing...';gb.disabled=true;
1047
+ var htmlCard=document.getElementById('share-card-html');
1048
+ if(!htmlCard||typeof htmlToImage==='undefined'){gb.textContent='Share';gb.disabled=false;showToast('Library not loaded');return}
1049
+
1050
+ // Pause animation + hide CSS shimmer/noise for clean capture
1051
+ htmlCard.style.animation='none';htmlCard.style.transform='none';
1052
+ htmlCard.classList.add('no-shimmer');
1053
+
1054
+ document.fonts.ready.then(function(){
1055
+ return htmlToImage.toPng(htmlCard,{quality:1,pixelRatio:3}).then(function(dataUrl){
1056
+ htmlCard.style.animation='';htmlCard.style.transform='';
1057
+ htmlCard.classList.remove('no-shimmer');
1058
+ gb.textContent='Recording...';
1059
+
1060
+ var img=new Image();
1061
+ img.onload=function(){
1062
+ // 2560x1440 canvas
1063
+ var VW=2560,VH=1440;
1064
+ var vidCanvas=document.createElement('canvas');vidCanvas.width=VW;vidCanvas.height=VH;
1065
+ var vctx=vidCanvas.getContext('2d');
1066
+ vctx.imageSmoothingEnabled=true;vctx.imageSmoothingQuality='high';
1067
+
1068
+ // Scale card to fill ~90% of frame width for maximum impact
1069
+ var scale=Math.min((VW*0.88)/img.width,(VH*0.82)/img.height);
1070
+ var cw=Math.round(img.width*scale),ch=Math.round(img.height*scale);
1071
+ var r=22*3*scale; // border radius
1072
+
1073
+ var stream=vidCanvas.captureStream(30);
1074
+ var chunks=[];
1075
+ // Prefer MP4 (Chrome 124+, works on X/Twitter), fallback WebM
1076
+ var mime=MediaRecorder.isTypeSupported('video/mp4;codecs=avc1')?'video/mp4;codecs=avc1'
1077
+ :MediaRecorder.isTypeSupported('video/mp4')?'video/mp4':'video/webm';
1078
+ var ext=mime.startsWith('video/mp4')?'mp4':'webm';
1079
+ var recorder=new MediaRecorder(stream,{mimeType:mime,videoBitsPerSecond:30000000});
1080
+ recorder.ondataavailable=function(e){if(e.data.size>0)chunks.push(e.data)};
1081
+ recorder.onstop=function(){
1082
+ var blob=new Blob(chunks,{type:mime.split(';')[0]});
1083
+ var a=document.createElement('a');a.download='cchubber-card.'+ext;
1084
+ a.href=URL.createObjectURL(blob);a.click();
1085
+ gb.innerHTML='<span class="material-symbols-outlined text-sm">share</span> Share';
1086
+ gb.disabled=false;showToast('Video saved ('+ext.toUpperCase()+')');
1087
+ };
1088
+
1089
+ // Pre-generate noise texture to fight gradient banding
1090
+ var noiseC=document.createElement('canvas');noiseC.width=256;noiseC.height=256;
1091
+ var nctx=noiseC.getContext('2d');
1092
+ var ndata=nctx.createImageData(256,256);
1093
+ for(var ni=0;ni<ndata.data.length;ni+=4){var v=Math.random()*255;ndata.data[ni]=v;ndata.data[ni+1]=v;ndata.data[ni+2]=v;ndata.data[ni+3]=8;}
1094
+ nctx.putImageData(ndata,0,0);
1095
+
1096
+ var duration=6000,startTime=null;
1097
+ recorder.start(100);
1098
+
1099
+ function frame(ts){
1100
+ if(!startTime)startTime=ts;
1101
+ var elapsed=ts-startTime;
1102
+ if(elapsed>=duration){setTimeout(function(){recorder.stop()},300);return}
1103
+
1104
+ var t=elapsed/duration;
1105
+ // Gentle breathe + float — matches the natural feel of the CSS animation
1106
+ // ease-in-out via cosine (same curve as CSS ease-in-out)
1107
+ // Gentle lateral drift — subtle, no shake
1108
+ var drift=Math.sin(t*Math.PI*2)*5;
1109
+
1110
+ vctx.fillStyle='#000';vctx.fillRect(0,0,VW,VH);
1111
+ vctx.save();
1112
+ vctx.translate((VW-cw)/2+drift,(VH-ch)/2);
1113
+
1114
+ // Draw the HTML-captured card image (browser-quality)
1115
+ vctx.drawImage(img,0,0,cw,ch);
1116
+
1117
+ // Clip shimmer + noise to rounded card shape (prevents corner bleed)
1118
+ var cr=22*3*scale; // 22px radius * 3 pixelRatio * video scale
1119
+ vctx.beginPath();
1120
+ vctx.moveTo(cr,0);vctx.lineTo(cw-cr,0);vctx.quadraticCurveTo(cw,0,cw,cr);
1121
+ vctx.lineTo(cw,ch-cr);vctx.quadraticCurveTo(cw,ch,cw-cr,ch);vctx.lineTo(cr,ch);
1122
+ vctx.quadraticCurveTo(0,ch,0,ch-cr);vctx.lineTo(0,cr);vctx.quadraticCurveTo(0,0,cr,0);
1123
+ vctx.closePath();vctx.clip();
1124
+
1125
+ // Shimmer sweep
1126
+ var shimProgress=(t*2)%1;
1127
+ var sx=-cw*0.5+shimProgress*cw*2;
1128
+ var g=vctx.createLinearGradient(sx,0,sx+cw*0.4,ch);
1129
+ g.addColorStop(0,'rgba(255,255,255,0)');
1130
+ g.addColorStop(0.3,'rgba(192,193,255,0.04)');
1131
+ g.addColorStop(0.45,'rgba(212,187,255,0.06)');
1132
+ g.addColorStop(0.55,'rgba(192,193,255,0.06)');
1133
+ g.addColorStop(0.7,'rgba(212,187,255,0.04)');
1134
+ g.addColorStop(1,'rgba(255,255,255,0)');
1135
+ vctx.fillStyle=g;vctx.fillRect(0,0,cw,ch);
1136
+
1137
+ // Noise dither
1138
+ var pat=vctx.createPattern(noiseC,'repeat');
1139
+ vctx.fillStyle=pat;vctx.globalAlpha=0.4;vctx.fillRect(0,0,cw,ch);vctx.globalAlpha=1;
1140
+
1141
+ vctx.restore();
1142
+ requestAnimationFrame(frame);
1143
+ }
1144
+
1145
+ requestAnimationFrame(frame);
1146
+ };
1147
+ img.src=dataUrl;
1148
+ });
1149
+ }).catch(function(e){
1150
+ htmlCard.style.animation='';htmlCard.style.transform='';htmlCard.classList.remove('no-shimmer');
1151
+ gb.innerHTML='<span class="material-symbols-outlined text-sm">share</span> Share';
1152
+ gb.disabled=false;showToast('Failed: '+e.message);
1153
+ });
1154
+ });
1155
+
1156
+ setR('all');
1157
+
1158
+ // Community leaderboard — stats fetched at generation time, embedded as JSON
1159
+ var MY_RATIO = ${cacheHealth.efficiencyRatio || 0};
1160
+ var MY_GRADE = '${cacheHealth.grade?.letter || '?'}';
1161
+ var MY_COST = '${(() => { const c = totalCost; return c<10?'<10':c<50?'10-50':c<200?'50-200':c<500?'200-500':c<1000?'500-1K':c<5000?'1K-5K':'5K+'; })()}';
1162
+ var gradeColors = {A:'#10b981',B:'#22d3ee',C:'#f59e0b',D:'#f97316',F:'#ef4444'};
1163
+ var stats = ${JSON.stringify(report.communityStats || null)};
1164
+
1165
+ (function(stats){
1166
+ if(!stats || !stats.totalReports) return;
1167
+ var sec = document.getElementById('community-section');
1168
+ if(!sec || !stats.totalReports) return;
1169
+ sec.style.display = '';
1170
+
1171
+ // Re-grade function — applies current grading logic to stored ratios
1172
+ // so old telemetry entries get accurate grades, not the inflated v0.4 grades
1173
+ function regrade(ratio){
1174
+ // Simplified: ratio signal dominates (40%), assume stable trend (70), good hit rate (85), no breaks (100)
1175
+ var rs;
1176
+ if(ratio<=200)rs=100;else if(ratio<=350)rs=85;else if(ratio<=500)rs=70;
1177
+ else if(ratio<=650)rs=55;else if(ratio<=800)rs=42;else if(ratio<=1000)rs=30;
1178
+ else if(ratio<=1500)rs=18;else if(ratio<=2000)rs=10;else rs=3;
1179
+ var composite=Math.round(85*0.15+rs*0.40+70*0.30+100*0.15);
1180
+ if(rs<=5)composite=Math.min(composite,38);
1181
+ if(composite>=75)return'A';if(composite>=60)return'B';if(composite>=45)return'C';if(composite>=30)return'D';return'F';
1182
+ }
1183
+
1184
+ // Count + geographic spread (don't show raw user count — looks small early on)
1185
+ var countries = stats.countries ? Object.keys(stats.countries) : [];
1186
+ document.getElementById('community-count').textContent = countries.length + ' countries';
1187
+
1188
+ // Calculate percentile from recent entries
1189
+ var recent = stats.recent || [];
1190
+ var ratios = recent.map(function(r){return r.ratio||9999}).sort(function(a,b){return a-b});
1191
+ var betterThan = ratios.filter(function(r){return r > MY_RATIO}).length;
1192
+ var pctile = ratios.length > 0 ? Math.round(betterThan / ratios.length * 100) : 0;
1193
+ document.getElementById('community-percentile').innerHTML =
1194
+ 'Your cache ratio of <strong style="color:#e3e2e3">' + MY_RATIO + ':1</strong> is better than <strong style="color:#c0c1ff">' + pctile + '%</strong> of CC Hubber users';
1195
+
1196
+ // Grade distribution — use stored grade for v0.5+ entries, regrade older ones
1197
+ function getGrade(entry){
1198
+ var v = entry.v || entry.version || '0.3.3';
1199
+ var parts = v.split('.'); var isNew = (parseInt(parts[0]||0)>0) || (parseInt(parts[1]||0)>=5);
1200
+ return (isNew && entry.grade) ? entry.grade : (entry.ratio ? regrade(entry.ratio) : (entry.grade || 'C'));
1201
+ }
1202
+ var regraded = {A:0,B:0,C:0,D:0,F:0};
1203
+ recent.forEach(function(r){regraded[getGrade(r)]++});
1204
+ var gTotal = Object.values(regraded).reduce(function(s,v){return s+v},0);
1205
+ var bar = document.getElementById('grade-dist-bar');
1206
+ var labels = document.getElementById('grade-dist-labels');
1207
+ ['A','B','C','D','F'].forEach(function(g){
1208
+ var count = regraded[g] || 0;
1209
+ var pct = gTotal > 0 ? (count/gTotal*100) : 0;
1210
+ if(pct > 0){
1211
+ var seg = document.createElement('div');
1212
+ seg.style.cssText = 'width:'+pct+'%;background:'+gradeColors[g]+';display:flex;align-items:center;justify-content:center;';
1213
+ seg.innerHTML = '<span style="font-size:10px;font-weight:700;color:#0d0e0f">' + (pct>8?g:'') + '</span>';
1214
+ bar.appendChild(seg);
1215
+ var lbl = document.createElement('span');
1216
+ lbl.style.cssText = 'width:'+pct+'%;text-align:center;font-size:10px;color:#908fa0;font-family:monospace;';
1217
+ lbl.textContent = count;
1218
+ labels.appendChild(lbl);
1219
+ }
1220
+ });
1221
+
1222
+ // Leaderboard table — re-graded, with user's position injected
1223
+ // Filter: minimum activity to qualify (exclude <$10 and $10-50 users — too little data for meaningful grade)
1224
+ var validCosts = {'50-200':1,'200-500':1,'500-1K':1,'1K-5K':1,'5K+':1};
1225
+ var tbody = document.getElementById('leaderboard-body');
1226
+ var sorted = recent.filter(function(r){return r.ratio && (validCosts[r.cost] || r.cost===MY_COST)}).sort(function(a,b){return (a.ratio||9999)-(b.ratio||9999)});
1227
+
1228
+ // Insert user's own entry into the sorted list at the right position
1229
+ var myEntry = {ratio:MY_RATIO, grade:MY_GRADE, cost:MY_COST, opus:${report.modelRouting?.opusPct ?? 'null'}, country:'You', os:'${process.platform}', isMe:true};
1230
+ var myRank = sorted.findIndex(function(e){return (e.ratio||0) >= MY_RATIO});
1231
+ if(myRank < 0) myRank = sorted.length;
1232
+ sorted.splice(myRank, 0, myEntry);
1233
+
1234
+ // Show top 10 + user's position (if not in top 10)
1235
+ var showIndices = [];
1236
+ for(var si=0;si<Math.min(10,sorted.length);si++) showIndices.push(si);
1237
+ var userInTop = myRank < 10;
1238
+ if(!userInTop){
1239
+ showIndices.push(-1); // separator
1240
+ if(myRank > 1) showIndices.push(myRank-1);
1241
+ showIndices.push(myRank);
1242
+ if(myRank+1 < sorted.length) showIndices.push(myRank+1);
1243
+ }
1244
+
1245
+ var html = '';
1246
+ for(var si2=0;si2<showIndices.length;si2++){
1247
+ var idx = showIndices[si2];
1248
+ if(idx === -1){
1249
+ html += '<tr><td colspan="6" class="px-8 py-1 text-center text-[10px] text-[#908fa0]">...</td></tr>';
1250
+ continue;
1251
+ }
1252
+ var entry = sorted[idx];
1253
+ if(!entry) continue;
1254
+ var g = entry.isMe ? MY_GRADE : getGrade(entry);
1255
+ var isMe = entry.isMe;
1256
+ var rowStyle = isMe ? 'background:rgba(192,193,255,0.08);border-left:3px solid #c0c1ff;' : '';
1257
+ html += '<tr style="border-bottom:1px solid rgba(70,69,84,0.1);'+rowStyle+'">';
1258
+ html += '<td class="px-8 py-3 text-sm font-mono '+(isMe?'text-[#c0c1ff] font-bold':'text-[#908fa0]')+'">#'+(idx+1)+(isMe?' ← you':'')+'</td>';
1259
+ html += '<td class="px-4 py-3 text-sm font-bold text-center" style="color:'+gradeColors[g]+'">'+g+'</td>';
1260
+ html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.ratio||'?')+':1</td>';
1261
+ html += '<td class="px-4 py-3 text-sm font-mono text-[#908fa0]">'+(entry.cost?'$'+entry.cost:'?')+'</td>';
1262
+ html += '<td class="px-4 py-3 text-sm font-mono text-[#c7c4d7] text-right">'+(entry.opus!=null?entry.opus:'?')+'%</td>';
1263
+ html += '<td class="px-4 py-3 text-sm text-[#908fa0]">'+(isMe?'You':(entry.country||'?'))+'</td>';
1264
+ html += '</tr>';
1265
+ }
1266
+ tbody.innerHTML = html;
1267
+
1268
+ // Update percentile text with rank
1269
+ document.getElementById('community-percentile').innerHTML =
1270
+ "You are <strong style=\\"color:#c0c1ff\\">#"+(myRank+1)+" of "+(sorted.length)+"</strong> users by cache efficiency. Better than <strong style=\\"color:#c0c1ff\\">"+pctile+"%</strong>";
1271
+ })(stats);
1272
+ })();
1273
+ </script>
1274
+ </body>
1275
+ </html>`;
1276
+ }
1277
+
1278
+ export function renderRateLimits(usage) {
1279
+ const fiveHour = usage.five_hour;
1280
+ const sevenDay = usage.seven_day;
1281
+ if (!fiveHour && !sevenDay) return '';
1282
+
1283
+ const fivePct = fiveHour?.utilization ?? 0;
1284
+ const sevenPct = sevenDay?.utilization ?? 0;
1285
+ const fiveColor = fivePct > 80 ? '#ffb4ab' : fivePct > 50 ? '#ffb690' : '#c0c1ff';
1286
+ const sevenColor = sevenPct > 80 ? '#ffb4ab' : sevenPct > 50 ? '#ffb690' : '#c0c1ff';
1287
+
1288
+ return `
1289
+ <section class="grid grid-cols-1 md:grid-cols-2 rounded-xl overflow-hidden border border-[rgba(70,69,84,0.15)]" style="gap:1px; background:rgba(70,69,84,0.15);">
1290
+ <div class="p-6 bg-[#0d0e0f]">
1291
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">5-Hour Session</span>
1292
+ <span class="font-mono text-2xl font-bold block" style="color:${fiveColor}">${fivePct}%</span>
1293
+ <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden mt-3 mb-2">
1294
+ <div class="h-full rounded-full" style="width:${fivePct}%;background:${fiveColor}"></div>
1295
+ </div>
1296
+ <span class="text-[10px] text-[#908fa0] block font-mono">${fiveHour?.resets_at ? 'Resets ' + new Date(fiveHour.resets_at).toLocaleTimeString() : ''}</span>
1297
+ </div>
1298
+ <div class="p-6 bg-[#0d0e0f]">
1299
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">7-Day Rolling</span>
1300
+ <span class="font-mono text-2xl font-bold block" style="color:${sevenColor}">${sevenPct}%</span>
1301
+ <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden mt-3 mb-2">
1302
+ <div class="h-full rounded-full" style="width:${sevenPct}%;background:${sevenColor}"></div>
1303
+ </div>
1304
+ <span class="text-[10px] text-[#908fa0] block font-mono">${sevenDay?.resets_at ? 'Resets ' + new Date(sevenDay.resets_at).toLocaleDateString() : ''}</span>
1305
+ </div>
1306
+ </section>`;
1307
+ }