cchubber 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,767 +1,1045 @@
1
- export function renderHTML(report) {
2
- const { costAnalysis, cacheHealth, anomalies, inflection, sessionIntel, modelRouting, projectBreakdown, claudeMdStack, oauthUsage, recommendations, generatedAt } = report;
3
-
4
- const dailyCosts = costAnalysis.dailyCosts || [];
5
- const grade = cacheHealth.grade || { letter: '?', color: '#666', label: 'Unknown' };
6
- const totalCost = costAnalysis.totalCost || 0;
7
- const activeDays = costAnalysis.activeDays || 0;
8
- const peakDay = costAnalysis.peakDay;
9
-
10
- const modelCosts = costAnalysis.modelCosts || {};
11
- const modelEntries = Object.entries(modelCosts).filter(([, c]) => c > 0.01).sort((a, b) => b[1] - a[1]);
12
- const anomalyDates = new Set((anomalies.anomalies || []).map(a => a.date));
13
-
14
- const dailyCostsJSON = JSON.stringify(dailyCosts.map(d => ({
15
- date: d.date, cost: d.cost, cacheOutputRatio: d.cacheOutputRatio || 0, isAnomaly: anomalyDates.has(d.date),
16
- })));
17
-
18
- const projectsJSON = JSON.stringify((projectBreakdown || []).slice(0, 15).map(p => ({
19
- name: p.name, path: p.path, messages: p.messageCount, sessions: p.sessionCount,
20
- input: p.inputTokens, output: p.outputTokens, cacheRead: p.cacheReadTokens, cacheWrite: p.cacheCreationTokens,
21
- })));
22
-
23
- const fmtCost = (n) => '$' + (n >= 100 ? Math.round(n).toLocaleString() : n.toFixed(2));
24
-
25
- // Grade color mapping to Stitch palette
26
- const gradeColorMap = {
27
- 'A': '#c0c1ff', // primary indigo
28
- 'B': '#d4bbff', // tertiary purple
29
- 'C': '#ffb690', // secondary orange
30
- 'D': '#ffb4ab', // error red
31
- 'F': '#ffb4ab', // error red
32
- };
33
- const gradeColor = gradeColorMap[grade.letter] || '#908fa0';
34
-
35
- // Grade label mapping
36
- const gradeLabelMap = {
37
- 'A': 'Excellent Performance',
38
- 'B': 'Good Performance',
39
- 'C': 'Fair Performance',
40
- 'D': 'Poor Performance',
41
- 'F': 'Critical Performance',
42
- };
43
- const gradeLabel = gradeLabelMap[grade.letter] || grade.label || 'Unknown';
44
-
45
- // Diagnosis one-liner for the share card
46
- const diagnosisLine = inflection && inflection.direction === 'worsened' && inflection.multiplier >= 2
47
- ? `Efficiency dropped ${inflection.multiplier}x on ${inflection.date}`
48
- : anomalies.hasAnomalies
49
- ? `${anomalies.anomalies.length} anomal${anomalies.anomalies.length === 1 ? 'y' : 'ies'} detected`
50
- : grade.letter === 'A' ? 'System running clean'
51
- : grade.letter === 'B' ? 'Minor optimization opportunities'
52
- : `Cache efficiency needs attention`;
53
-
54
- // Model colors from Stitch palette
55
- const modelColors = ['#c0c1ff', '#d4bbff', '#ffb690', '#8083ff', '#a775ff', '#ffb4ab'];
56
-
57
- // Severity to Stitch color mapping
58
- const sevColorMap = {
59
- critical: { border: '#ffb4ab', bg: 'rgba(255, 180, 171, 0.10)', text: '#ffb4ab' },
60
- warning: { border: '#ffb690', bg: 'rgba(255, 182, 144, 0.10)', text: '#ffb690' },
61
- info: { border: '#c0c1ff', bg: 'rgba(192, 193, 255, 0.10)', text: '#c0c1ff' },
62
- positive: { border: '#c0c1ff', bg: 'rgba(192, 193, 255, 0.10)', text: '#c0c1ff' },
63
- };
64
-
65
- return `<!DOCTYPE html>
66
- <html class="dark" lang="en">
67
- <head>
68
- <meta charset="UTF-8">
69
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
70
- <title>CC Hubber</title>
71
- <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
72
- <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">
73
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
74
- <script>
75
- tailwind.config = {
76
- darkMode: "class",
77
- theme: {
78
- extend: {
79
- colors: {
80
- "background": "#121315",
81
- "surface": "#121315",
82
- "surface-dim": "#121315",
83
- "surface-container-lowest": "#0d0e0f",
84
- "surface-container-low": "#1b1c1d",
85
- "surface-container": "#1f2021",
86
- "surface-container-high": "#292a2b",
87
- "surface-container-highest": "#343536",
88
- "surface-bright": "#38393a",
89
- "on-surface": "#e3e2e3",
90
- "on-surface-variant": "#c7c4d7",
91
- "on-background": "#e3e2e3",
92
- "outline": "#908fa0",
93
- "outline-variant": "#464554",
94
- "primary": "#c0c1ff",
95
- "primary-container": "#8083ff",
96
- "on-primary": "#1000a9",
97
- "secondary": "#ffb690",
98
- "secondary-container": "#ec6a06",
99
- "on-secondary": "#552100",
100
- "tertiary": "#d4bbff",
101
- "tertiary-container": "#a775ff",
102
- "error": "#ffb4ab",
103
- "error-container": "#93000a",
104
- "on-error": "#690005",
105
- "inverse-surface": "#e3e2e3",
106
- "inverse-on-surface": "#303032",
107
- "inverse-primary": "#494bd6",
108
- "surface-tint": "#c0c1ff",
109
- },
110
- fontFamily: {
111
- "headline": ["Inter", "system-ui", "sans-serif"],
112
- "body": ["Inter", "system-ui", "sans-serif"],
113
- "label": ["Inter", "system-ui", "sans-serif"],
114
- "mono": ["JetBrains Mono", "monospace"],
115
- },
116
- borderRadius: {
117
- "DEFAULT": "0.125rem",
118
- "lg": "0.25rem",
119
- "xl": "0.5rem",
120
- "full": "0.75rem",
121
- },
122
- },
123
- },
124
- }
125
- </script>
126
- <style>
127
- body {
128
- background-color: #121315;
129
- color: #e3e2e3;
130
- font-family: 'Inter', system-ui, sans-serif;
131
- -webkit-font-smoothing: antialiased;
132
- -moz-osx-font-smoothing: grayscale;
133
- }
134
- .font-mono { font-family: 'JetBrains Mono', monospace !important; }
135
-
136
- /* Tooltip */
137
- .tt {
138
- position: fixed;
139
- background: rgba(31, 32, 33, 0.95);
140
- backdrop-filter: blur(12px);
141
- border: 1px solid rgba(70, 69, 84, 0.3);
142
- border-radius: 0.5rem;
143
- padding: 10px 14px;
144
- pointer-events: none;
145
- opacity: 0;
146
- transition: opacity 0.12s;
147
- z-index: 100;
148
- white-space: nowrap;
149
- }
150
- .tt.on { opacity: 1; }
151
-
152
- /* Toast */
153
- .toast {
154
- position: fixed;
155
- bottom: 20px;
156
- left: 50%;
157
- transform: translateX(-50%) translateY(60px);
158
- background: #292a2b;
159
- border: 1px solid rgba(70, 69, 84, 0.3);
160
- border-radius: 0.5rem;
161
- padding: 10px 20px;
162
- font-size: 12px;
163
- font-weight: 600;
164
- color: #e3e2e3;
165
- opacity: 0;
166
- transition: all 0.25s;
167
- z-index: 200;
168
- pointer-events: none;
169
- }
170
- .toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
171
-
172
- /* SVG chart */
173
- #cost-chart-svg { width: 100%; overflow: visible; display: block; }
174
-
175
- /* Table hover */
176
- .tbl-row:hover { background: #292a2b; }
177
- .tbl-row { transition: background 0.15s; }
178
- </style>
179
- </head>
180
- <body class="selection:bg-primary selection:text-on-primary">
181
-
182
- <div class="tt" id="tt">
183
- <div class="font-mono text-[11px] text-[#908fa0] mb-1" id="tt-d"></div>
184
- <div class="font-mono text-[15px] font-bold text-[#e3e2e3]" id="tt-c"></div>
185
- <div class="text-[10px] text-[#ffb4ab] mt-1" id="tt-a"></div>
186
- </div>
187
- <div class="toast" id="toast"></div>
188
-
189
- <!-- 1. HEADER -->
190
- <header class="w-full px-6 py-5 max-w-[1200px] mx-auto flex justify-between items-baseline">
191
- <div class="flex items-baseline gap-4">
192
- <span class="text-lg font-bold tracking-tight text-[#e3e2e3]">CC Hubber</span>
193
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0]">shipped fast with Mover OS</span>
194
- </div>
195
- <span class="font-mono text-[11px] text-[#908fa0]" id="range-lbl">All time</span>
196
- </header>
197
-
198
- <main class="pb-20 px-6 max-w-[1200px] mx-auto space-y-12">
199
-
200
- <!-- 2. SHARE CARD -->
201
- <section>
202
- <div id="share-card" class="p-8 md:p-10 bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] relative overflow-hidden">
203
-
204
- <!-- Top row: Grade + Label + Stats -->
205
- <div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-8">
206
- <div class="flex items-center gap-6">
207
- <div class="w-20 h-20 flex items-center justify-center rounded-xl" style="background:${gradeColor}">
208
- <span class="font-black text-4xl" style="color:#121315">${grade.letter}</span>
209
- </div>
210
- <div>
211
- <span class="text-[10px] font-mono uppercase tracking-[0.05em] block mb-1" style="color:${gradeColor}">Efficiency Rating</span>
212
- <h2 class="text-2xl md:text-3xl font-black text-[#e3e2e3]">${gradeLabel}</h2>
213
- </div>
214
- </div>
215
- <div class="flex gap-8 md:gap-12 flex-wrap">
216
- <div>
217
- <p class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] mb-1">Total Spend</p>
218
- <p class="font-mono text-2xl md:text-3xl font-bold text-[#e3e2e3]" id="h-cost">${fmtCost(totalCost)}</p>
219
- </div>
220
- <div class="border-l border-[rgba(70,69,84,0.3)] pl-8 md:pl-12">
221
- <p class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] mb-1">Active Days</p>
222
- <p class="font-mono text-2xl md:text-3xl font-bold text-[#e3e2e3]" id="h-days">${activeDays}</p>
223
- </div>
224
- <div class="border-l border-[rgba(70,69,84,0.3)] pl-8 md:pl-12">
225
- <p class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] mb-1">Cache Ratio</p>
226
- <p class="font-mono text-2xl md:text-3xl font-bold text-[#e3e2e3]">${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : 'N/A'}</p>
227
- </div>
228
- </div>
229
- </div>
230
-
231
- <!-- Diagnosis divider -->
232
- <div class="mt-8 pt-6 border-t border-[rgba(70,69,84,0.15)] flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
233
- <p class="text-sm text-[#c7c4d7]">${diagnosisLine}</p>
234
- <div class="flex items-center gap-4">
235
- <span class="text-[10px] font-mono uppercase tracking-[0.05em] text-[#908fa0]">CC Hubber</span>
236
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#464554]">shipped fast with Mover OS</span>
237
- </div>
238
- </div>
239
- </div>
240
-
241
- <!-- Export button -->
242
- <div class="flex justify-center mt-4">
243
- <button id="btn-png" class="px-5 py-2.5 border border-[rgba(70,69,84,0.3)] rounded-xl text-sm font-semibold text-[#908fa0] hover:bg-[#292a2b] hover:text-[#e3e2e3] transition-colors flex items-center gap-2 cursor-pointer">
244
- <span class="material-symbols-outlined text-sm">download</span>
245
- Save as PNG
246
- </button>
247
- </div>
248
- </section>
249
-
250
- ${oauthUsage ? renderRateLimits(oauthUsage) : ''}
251
-
252
- ${inflection && inflection.multiplier >= 1.5 ? `
253
- <!-- Inflection callout -->
254
- <section class="p-6 bg-[#0d0e0f] border-l-4 border-[#ffb690] rounded-r-xl">
255
- <p class="text-xs font-bold text-[#ffb690] uppercase tracking-[0.05em] mb-1">Inflection Point</p>
256
- <p class="text-sm text-[#c7c4d7]">${inflection.summary}</p>
257
- </section>
258
- ` : ''}
259
-
260
- <!-- 3. METRIC GRID -->
261
- <section class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 rounded-xl overflow-hidden border border-[rgba(70,69,84,0.15)]" style="gap:1px; background:rgba(70,69,84,0.15);">
262
- <div class="p-6 bg-[#0d0e0f]">
263
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Total Cost</span>
264
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]" id="ov-total">${fmtCost(totalCost)}</span>
265
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono" id="ov-avg">${fmtCost(costAnalysis.avgDailyCost || 0)} avg/day</span>
266
- </div>
267
- <div class="p-6 bg-[#0d0e0f]">
268
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Peak Day</span>
269
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${peakDay ? fmtCost(peakDay.cost) : '$0'}</span>
270
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${peakDay ? peakDay.date : ''}</span>
271
- </div>
272
- <div class="p-6 bg-[#0d0e0f]">
273
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Cache Health</span>
274
- <span class="font-mono text-2xl font-bold block" style="color:${gradeColor}">${grade.letter}</span>
275
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : ''}</span>
276
- </div>
277
- <div class="p-6 bg-[#0d0e0f]">
278
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Cache Breaks</span>
279
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${cacheHealth.totalCacheBreaks || 0}</span>
280
- <span class="text-[10px] text-[#908fa0] mt-1 block">${cacheHealth.reasonsRanked?.[0]?.reason || 'None detected'}</span>
281
- </div>
282
- <div class="p-6 bg-[#0d0e0f]">
283
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">CLAUDE.md</span>
284
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">~${Math.round(claudeMdStack.totalTokensEstimate / 1000)}K</span>
285
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${(claudeMdStack.totalBytes / 1024).toFixed(1)} KB</span>
286
- </div>
287
- ${sessionIntel?.available ? `
288
- <div class="p-6 bg-[#0d0e0f]">
289
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Sessions</span>
290
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${sessionIntel.totalSessions}</span>
291
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${sessionIntel.avgDuration} min avg</span>
292
- </div>` : `
293
- <div class="p-6 bg-[#0d0e0f]">
294
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Sessions</span>
295
- <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${costAnalysis.sessions?.total || 0}</span>
296
- <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${costAnalysis.sessions?.avgDurationMinutes ? Math.round(costAnalysis.sessions.avgDurationMinutes) + ' min avg' : ''}</span>
297
- </div>`}
298
- </section>
299
-
300
- <!-- 4. COST TREND CHART -->
301
- <section class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
302
- <div class="flex justify-between items-end mb-10">
303
- <div>
304
- <h3 class="text-xl font-bold text-[#e3e2e3] mb-1">Cost Trend</h3>
305
- <p class="text-sm text-[#908fa0]" id="chart-info"></p>
306
- </div>
307
- <div class="flex gap-1 p-1 bg-[#0d0e0f] rounded-xl border border-[rgba(70,69,84,0.15)]" id="filters">
308
- <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>
309
- <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>
310
- <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>
311
- <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>
312
- </div>
313
- </div>
314
- <svg id="cost-chart-svg" viewBox="0 0 900 200" preserveAspectRatio="xMidYMid meet"></svg>
315
- </section>
316
-
317
- <!-- 5. SESSION INTELLIGENCE + MODEL DISTRIBUTION -->
318
- <section class="grid grid-cols-1 lg:grid-cols-2 gap-8">
319
-
320
- <!-- Session Intelligence -->
321
- <div class="space-y-8">
322
- ${sessionIntel?.available ? `
323
- <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
324
- <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Session Intelligence</h3>
325
- <div class="grid grid-cols-3 gap-6 mb-8">
326
- <div>
327
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Median</span>
328
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.medianDuration}m</span>
329
- </div>
330
- <div>
331
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">P90</span>
332
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.p90Duration}m</span>
333
- </div>
334
- <div>
335
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Longest</span>
336
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.maxDuration}m</span>
337
- </div>
338
- </div>
339
- <div class="grid grid-cols-3 gap-6 mb-8">
340
- <div>
341
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Long Sessions</span>
342
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.longSessions}</span>
343
- <span class="text-[10px] text-[#908fa0] block font-mono">${sessionIntel.longSessionPct}% over 60m</span>
344
- </div>
345
- <div>
346
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Tools/Session</span>
347
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.avgToolsPerSession}</span>
348
- </div>
349
- <div>
350
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Lines/Hour</span>
351
- <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.linesPerHour.toLocaleString()}</span>
352
- </div>
353
- </div>
354
-
355
- ${sessionIntel.topTools.length > 0 ? `
356
- <div>
357
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-4">Top Tools Usage</span>
358
- <div class="space-y-3">
359
- ${sessionIntel.topTools.slice(0, 6).map((t, i) => `
360
- <div class="space-y-1">
361
- <div class="flex justify-between text-[11px] font-mono">
362
- <span class="text-[#c7c4d7]">${t.name}</span>
363
- <span class="text-[#908fa0]">${t.count}</span>
364
- </div>
365
- <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden">
366
- <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>
367
- </div>
368
- </div>`).join('')}
369
- </div>
370
- </div>` : ''}
371
- </div>` : ''}
372
-
373
- <!-- 6. ACTIVITY HEATMAP -->
374
- ${sessionIntel?.hourDistribution ? `
375
- <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
376
- <h3 class="text-lg font-bold text-[#e3e2e3] mb-4">Activity by Hour</h3>
377
- <div class="gap-[3px]" id="hour-grid" style="display:grid;grid-template-columns:repeat(24,1fr);"></div>
378
- <div class="flex justify-between mt-2 text-[9px] font-mono text-[#908fa0]">
379
- <span>00:00</span>
380
- <span>06:00</span>
381
- <span>12:00</span>
382
- <span>18:00</span>
383
- <span>23:00</span>
384
- </div>
385
- </div>` : ''}
386
- </div>
387
-
388
- <!-- Model Distribution -->
389
- <div class="space-y-8">
390
- <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
391
- <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Model Distribution</h3>
392
- <div class="w-full h-4 flex rounded-full overflow-hidden mb-6" style="gap:2px">
393
- ${modelEntries.map(([, cost], i) => {
394
- const pct = totalCost > 0 ? (cost / totalCost) * 100 : 0;
395
- 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>`;
396
- }).join('')}
397
- </div>
398
- <div class="grid grid-cols-2 gap-4">
399
- ${modelEntries.map(([name, cost], i) => {
400
- const pct = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(1) : '0';
401
- return `<div class="flex items-center gap-2">
402
- <div class="w-2 h-2 rounded-full" style="background:${modelColors[i % modelColors.length]}"></div>
403
- <span class="text-xs font-mono text-[#c7c4d7]">${name}</span>
404
- <span class="text-xs font-mono text-[#908fa0]">${fmtCost(cost)}</span>
405
- <span class="text-[10px] text-[#464554] font-mono">${pct}%</span>
406
- </div>`;
407
- }).join('')}
408
- </div>
409
- ${modelRouting?.available ? `
410
- <div class="mt-6 pt-6 border-t border-[rgba(70,69,84,0.15)] text-sm text-[#c7c4d7]">
411
- <span class="font-mono">${modelRouting.opusPct}%</span> Opus &middot;
412
- <span class="font-mono">${modelRouting.sonnetPct}%</span> Sonnet &middot;
413
- <span class="font-mono">${modelRouting.haikuPct}%</span> Haiku
414
- ${modelRouting.estimatedSavings > 10 ? `<span class="text-[#c0c1ff] ml-3 font-mono">~${fmtCost(modelRouting.estimatedSavings)} potential savings</span>` : ''}
415
- </div>` : ''}
416
- </div>
417
-
418
- <!-- 9. RECOMMENDATIONS (placed alongside model distribution) -->
419
- ${recommendations.length > 0 ? `
420
- <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
421
- <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Recommendations</h3>
422
- <div class="space-y-4">
423
- ${recommendations.map(r => {
424
- const sev = sevColorMap[r.severity] || sevColorMap.info;
425
- return `<div class="p-4 bg-[#0d0e0f] rounded-r-xl" style="border-left:4px solid ${sev.border}">
426
- <p class="text-xs font-bold text-[#e3e2e3] mb-1">${r.title}</p>
427
- <p class="text-[11px] text-[#c7c4d7] mb-2">${r.detail}</p>
428
- <p class="text-[11px] font-semibold" style="color:${sev.text}">&rarr; ${r.action}</p>
429
- </div>`;
430
- }).join('')}
431
- </div>
432
- </div>` : ''}
433
- </div>
434
- </section>
435
-
436
- <!-- 7. PROJECTS TABLE -->
437
- ${projectBreakdown && projectBreakdown.length > 0 ? `
438
- <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
439
- <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)]">
440
- <h3 class="text-xl font-bold text-[#e3e2e3]">Projects</h3>
441
- </div>
442
- <div class="overflow-x-auto">
443
- <table class="w-full text-left" id="proj-tbl">
444
- <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
445
- <tr>
446
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Project</th>
447
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Messages</th>
448
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Sessions</th>
449
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Output</th>
450
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Cache Read</th>
451
- </tr>
452
- </thead>
453
- <tbody class="divide-y divide-[rgba(70,69,84,0.15)]"></tbody>
454
- </table>
455
- </div>
456
- </section>
457
- ` : ''}
458
-
459
- <!-- 8. ANOMALIES TABLE -->
460
- ${anomalies.hasAnomalies ? `
461
- <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
462
- <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
463
- <h3 class="text-xl font-bold text-[#e3e2e3]">Detected Anomalies</h3>
464
- <span class="material-symbols-outlined text-[#ffb4ab] animate-pulse">warning</span>
465
- </div>
466
- <div class="overflow-x-auto">
467
- <table class="w-full text-left">
468
- <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
469
- <tr>
470
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Date</th>
471
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Cost</th>
472
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Deviation</th>
473
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Cache Ratio</th>
474
- <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Severity</th>
475
- </tr>
476
- </thead>
477
- <tbody class="divide-y divide-[rgba(70,69,84,0.15)]">
478
- ${anomalies.anomalies.map(a => {
479
- const sevBg = a.severity === 'critical' ? 'rgba(255, 180, 171, 0.10)' : 'rgba(255, 182, 144, 0.10)';
480
- const sevText = a.severity === 'critical' ? '#ffb4ab' : '#ffb690';
481
- return `<tr class="tbl-row">
482
- <td class="px-8 py-4 font-mono text-sm text-[#e3e2e3]">${a.date}</td>
483
- <td class="px-8 py-4 font-mono text-sm text-[#e3e2e3] font-bold">${fmtCost(a.cost)}</td>
484
- <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>
485
- <td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">${a.cacheOutputRatio ? a.cacheOutputRatio.toLocaleString() + ':1' : ''}</td>
486
- <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>
487
- </tr>`;
488
- }).join('')}
489
- </tbody>
490
- </table>
491
- </div>
492
- </section>
493
- ` : ''}
494
-
495
- <!-- 10. CLAUDE.md ANALYSIS — Global only, section breakdown -->
496
- <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
497
- <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
498
- <h3 class="text-xl font-bold text-[#e3e2e3]">CLAUDE.md Analysis</h3>
499
- <div class="text-right">
500
- <span class="font-mono text-sm text-[#e3e2e3]">${claudeMdStack.files[0]?.lineCount || '?'} lines</span>
501
- <span class="text-[#908fa0] mx-2">&middot;</span>
502
- <span class="font-mono text-sm text-[#e3e2e3]">~${claudeMdStack.totalTokensEstimate.toLocaleString()} tokens</span>
503
- <span class="text-[#908fa0] mx-2">&middot;</span>
504
- <span class="font-mono text-sm text-[#e3e2e3]">${(claudeMdStack.totalBytes / 1024).toFixed(1)} KB</span>
505
- </div>
506
- </div>
507
- <div class="px-8 py-4 bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)] flex justify-between text-xs">
508
- <span class="text-[#908fa0]">Per-message cost impact</span>
509
- <span class="font-mono">
510
- <span class="text-[#c0c1ff]">$${claudeMdStack.costPerMessage.cached.toFixed(4)}</span> cached &middot;
511
- <span class="text-[#ffb690]">$${claudeMdStack.costPerMessage.uncached.toFixed(4)}</span> uncached &middot;
512
- <span class="text-[#ffb4ab]">$${(claudeMdStack.costPerMessage.dailyCached200 || 0).toFixed(2)}</span>/day at 200 msgs
513
- </span>
514
- </div>
515
- ${claudeMdStack.globalSections && claudeMdStack.globalSections.length > 0 ? `
516
- <div class="overflow-x-auto">
517
- <table class="w-full text-left">
518
- <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
519
- <tr>
520
- <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Section</th>
521
- <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Lines</th>
522
- <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Tokens</th>
523
- </tr>
524
- </thead>
525
- <tbody class="divide-y divide-[rgba(70,69,84,0.15)]">
526
- ${claudeMdStack.globalSections.slice(0, 12).map(s => `<tr class="tbl-row">
527
- <td class="px-8 py-3 text-sm text-[#e3e2e3]">${s.name}</td>
528
- <td class="px-8 py-3 font-mono text-sm text-[#c7c4d7] text-right">${s.lines}</td>
529
- <td class="px-8 py-3 font-mono text-sm text-[#c7c4d7] text-right">${s.tokens.toLocaleString()}</td>
530
- </tr>`).join('')}
531
- </tbody>
532
- </table>
533
- </div>
534
- ` : ''}
535
- </section>
536
-
537
- <!-- 11. CACHE SAVINGS -->
538
- ${cacheHealth.savings?.fromCaching > 0 ? `
539
- <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);">
540
- <div class="p-8 bg-[#0d0e0f]">
541
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Saved by Cache</span>
542
- <span class="font-mono text-3xl font-bold block text-[#c0c1ff]">~$${Number(cacheHealth.savings.fromCaching).toLocaleString()}</span>
543
- <span class="text-[10px] text-[#908fa0] mt-2 block">vs standard input pricing</span>
544
- </div>
545
- <div class="p-8 bg-[#0d0e0f]">
546
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Wasted on Breaks</span>
547
- <span class="font-mono text-3xl font-bold block text-[#ffb690]">~$${Number(cacheHealth.savings.wastedFromBreaks).toLocaleString()}</span>
548
- <span class="text-[10px] text-[#908fa0] mt-2 block">from cache invalidation</span>
549
- </div>
550
- </section>
551
- ` : ''}
552
-
553
- ${cacheHealth.totalCacheBreaks > 0 ? `
554
- <!-- Cache Break Reasons -->
555
- <section class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
556
- <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Cache Break Reasons</h3>
557
- <div class="space-y-4">
558
- ${(cacheHealth.reasonsRanked || []).map(r => `
559
- <div class="space-y-1">
560
- <div class="flex justify-between text-[11px] font-mono">
561
- <span class="text-[#c7c4d7]">${r.reason}</span>
562
- <span class="text-[#908fa0]">${r.count}</span>
563
- </div>
564
- <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden">
565
- <div class="h-full bg-[#ffb690] rounded-full" style="width:${r.percentage}%"></div>
566
- </div>
567
- </div>`).join('')}
568
- </div>
569
- </section>
570
- ` : ''}
571
-
572
- </main>
573
-
574
- <!-- 12. FOOTER -->
575
- <footer class="w-full py-12 border-t border-[rgba(70,69,84,0.05)]">
576
- <div class="max-w-[1200px] mx-auto px-6 text-center">
577
- <span class="text-[10px] tracking-widest uppercase text-[#908fa0]">CC Hubber &middot; shipped fast with Mover OS</span>
578
- </div>
579
- </footer>
580
-
581
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
582
- <script>
583
- (function(){
584
- var D=${dailyCostsJSON}, P=${projectsJSON};
585
- var HR=${sessionIntel?.hourDistribution ? JSON.stringify(sessionIntel.hourDistribution) : 'null'};
586
- var CACHE_R=0.50,OUT=25,INP=5,CW=6.25;
587
-
588
- function fc(n){return n>=100?'$'+Math.round(n).toLocaleString():'$'+n.toFixed(2)}
589
- 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()}
590
-
591
- // Hour heatmap
592
- if(HR){
593
- var hg=document.getElementById('hour-grid');
594
- if(hg){
595
- var mx=Math.max.apply(null,HR);
596
- var html='';
597
- for(var i=0;i<24;i++){
598
- var intensity=mx>0?HR[i]/mx:0;
599
- // Stitch palette: primary #c0c1ff at varying opacity
600
- var opac=intensity>0.8?'0.9':intensity>0.6?'0.7':intensity>0.4?'0.5':intensity>0.2?'0.3':intensity>0.05?'0.15':'0.05';
601
- html+='<div style="height:28px;border-radius:3px;background:rgba(192,193,255,'+opac+')" title="'+i+':00 — '+HR[i]+' messages"></div>';
602
- }
603
- hg.innerHTML=html;
604
- }
605
- }
606
-
607
- // Project table
608
- var ptb=document.querySelector('#proj-tbl tbody');
609
- if(ptb&&P.length>0){
610
- P.sort(function(a,b){return(b.output/1e6*OUT+b.cacheRead/1e6*CACHE_R)-(a.output/1e6*OUT+a.cacheRead/1e6*CACHE_R)});
611
- var h='';
612
- for(var i=0;i<Math.min(P.length,10);i++){
613
- var p=P[i];
614
- h+='<tr class="tbl-row">';
615
- h+='<td class="px-8 py-4 text-sm font-semibold text-[#e3e2e3]">'+p.name;
616
- if(p.path)h+='<br><span class="text-[10px] text-[#908fa0] font-mono">'+p.path+'</span>';
617
- h+='</td>';
618
- h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.messages.toLocaleString()+'</td>';
619
- h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.sessions+'</td>';
620
- h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+ft(p.output)+'</td>';
621
- h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+ft(p.cacheRead)+'</td>';
622
- h+='</tr>';
623
- }
624
- ptb.innerHTML=h;
625
- }
626
-
627
- // Chart
628
- var W=900,H=200,PD={t:24,r:16,b:40,l:56};
629
- var cW=W-PD.l-PD.r,cH=H-PD.t-PD.b;
630
- var svg=document.getElementById('cost-chart-svg');
631
- var tt=document.getElementById('tt'),ttd=document.getElementById('tt-d'),ttc=document.getElementById('tt-c'),tta=document.getElementById('tt-a');
632
-
633
- function filt(r){return r==='all'?D:D.slice(-parseInt(r,10))}
634
-
635
- function chart(d){
636
- if(!svg)return;
637
- 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</text>';return}
638
- var mx=Math.max.apply(null,d.map(function(x){return x.cost}))*1.1;if(mx<0.01)mx=1;
639
- var s='';
640
- // grid lines
641
- for(var i=0;i<=3;i++){
642
- var y=PD.t+(cH/3)*i,v=mx-(mx/3)*i;
643
- s+='<line x1="'+PD.l+'" y1="'+y+'" x2="'+(W-PD.r)+'" y2="'+y+'" stroke="rgba(70,69,84,0.15)" stroke-width="1"/>';
644
- 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>';
645
- }
646
- // area + line
647
- var step=d.length>1?cW/(d.length-1):0;
648
- 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}});
649
- var lp='M '+pts[0].x+' '+pts[0].y;
650
- var ap='M '+pts[0].x+' '+(PD.t+cH)+' L '+pts[0].x+' '+pts[0].y;
651
- 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}
652
- ap+=' L '+pts[pts.length-1].x+' '+(PD.t+cH)+' Z';
653
- // Stitch gradient: primary at 30% to transparent
654
- 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>';
655
- s+='<path d="'+ap+'" fill="url(#ag)"/>';
656
- s+='<path d="'+lp+'" fill="none" stroke="#c0c1ff" stroke-width="2" stroke-linecap="round"/>';
657
- // x labels
658
- var every=Math.max(1,Math.floor(d.length/8));
659
- d.forEach(function(x,j){
660
- var px=PD.l+(d.length===1?cW/2:j*step);
661
- 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>';
662
- });
663
- // anomaly dots
664
- d.forEach(function(x,j){
665
- var px=PD.l+(d.length===1?cW/2:j*step),py=PD.t+cH-(x.cost/mx)*cH;
666
- if(x.isAnomaly)s+='<circle cx="'+px+'" cy="'+py+'" r="4" fill="#ffb4ab" stroke="#121315" stroke-width="2"/>';
667
- });
668
- // hover targets
669
- d.forEach(function(x,j){
670
- var px=PD.l+(d.length===1?cW/2:j*step),py=PD.t+cH-(x.cost/mx)*cH;
671
- s+='<circle cx="'+px+'" cy="'+py+'" r="14" fill="transparent" data-d="'+x.date+'" data-c="'+x.cost+'" data-a="'+(x.isAnomaly?1:0)+'" class="hov" style="cursor:crosshair"/>';
672
- });
673
- svg.innerHTML=s;
674
- svg.querySelectorAll('.hov').forEach(function(el){
675
- el.addEventListener('mouseenter',function(e){
676
- ttd.textContent=e.target.dataset.d;
677
- ttc.textContent=fc(parseFloat(e.target.dataset.c));
678
- tta.textContent=e.target.dataset.a==='1'?'ANOMALY':'';
679
- tta.style.display=e.target.dataset.a==='1'?'block':'none';
680
- tt.classList.add('on');
681
- });
682
- el.addEventListener('mousemove',function(e){tt.style.left=(e.clientX+14)+'px';tt.style.top=(e.clientY-40)+'px'});
683
- el.addEventListener('mouseleave',function(){tt.classList.remove('on')});
684
- });
685
- }
686
-
687
- var RL={7:'Last 7 days',30:'Last 30 days',90:'Last 90 days',all:'All time'};
688
-
689
- function setR(r){
690
- var f=filt(r);chart(f);
691
- var ci=document.getElementById('chart-info');
692
- 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;ci.textContent=a+' days \u00b7 '+fc(t)}
693
- var rl=document.getElementById('range-lbl');if(rl)rl.textContent=RL[r]||'All time';
694
- if(f.length){
695
- var t=f.reduce(function(s,x){return s+x.cost},0),a=f.filter(function(x){return x.cost>0}).length;
696
- var hc=document.getElementById('h-cost'),hd=document.getElementById('h-days');
697
- var ot=document.getElementById('ov-total'),oa=document.getElementById('ov-avg');
698
- if(hc)hc.textContent=fc(t);if(hd)hd.textContent=a;
699
- if(ot)ot.textContent=fc(t);if(oa&&a>0)oa.textContent=fc(t/a)+' avg/day';
700
- }
701
- // Update filter button states - Stitch style
702
- document.querySelectorAll('.cfilt').forEach(function(b){
703
- if(b.dataset.r===r){
704
- b.style.background='#c0c1ff';b.style.color='#1000a9';
705
- } else {
706
- b.style.background='transparent';b.style.color='#908fa0';
707
- }
708
- });
709
- }
710
-
711
- document.querySelectorAll('.cfilt').forEach(function(b){b.addEventListener('click',function(){setR(b.dataset.r)})});
712
-
713
- // PNG/GIF export
714
- var pb=document.getElementById('btn-png'),toast=document.getElementById('toast');
715
- if(pb)pb.addEventListener('click',function(){
716
- var card=document.getElementById('share-card');
717
- if(!card||typeof html2canvas==='undefined')return;
718
- pb.innerHTML='<span class="material-symbols-outlined text-sm animate-spin">progress_activity</span> Exporting...';pb.disabled=true;
719
- html2canvas(card,{backgroundColor:'#121315',scale:2,useCORS:true,logging:false}).then(function(c){
720
- var a=document.createElement('a');a.download='cchubber.png';a.href=c.toDataURL('image/png');a.click();
721
- pb.innerHTML='<span class="material-symbols-outlined text-sm">download</span> Save as PNG';
722
- pb.disabled=false;showToast('Saved to downloads');
723
- }).catch(function(){
724
- pb.innerHTML='<span class="material-symbols-outlined text-sm">download</span> Save as PNG';
725
- pb.disabled=false;showToast('Export failed');
726
- });
727
- });
728
-
729
- function showToast(m){if(!toast)return;toast.textContent=m;toast.classList.add('on');setTimeout(function(){toast.classList.remove('on')},2000)}
730
-
731
- setR('all');
732
- })();
733
- </script>
734
- </body>
735
- </html>`;
736
- }
737
-
738
- export function renderRateLimits(usage) {
739
- const fiveHour = usage.five_hour;
740
- const sevenDay = usage.seven_day;
741
- if (!fiveHour && !sevenDay) return '';
742
-
743
- const fivePct = fiveHour?.utilization ?? 0;
744
- const sevenPct = sevenDay?.utilization ?? 0;
745
- const fiveColor = fivePct > 80 ? '#ffb4ab' : fivePct > 50 ? '#ffb690' : '#c0c1ff';
746
- const sevenColor = sevenPct > 80 ? '#ffb4ab' : sevenPct > 50 ? '#ffb690' : '#c0c1ff';
747
-
748
- return `
749
- <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);">
750
- <div class="p-6 bg-[#0d0e0f]">
751
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">5-Hour Session</span>
752
- <span class="font-mono text-2xl font-bold block" style="color:${fiveColor}">${fivePct}%</span>
753
- <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden mt-3 mb-2">
754
- <div class="h-full rounded-full" style="width:${fivePct}%;background:${fiveColor}"></div>
755
- </div>
756
- <span class="text-[10px] text-[#908fa0] block font-mono">${fiveHour?.resets_at ? 'Resets ' + new Date(fiveHour.resets_at).toLocaleTimeString() : ''}</span>
757
- </div>
758
- <div class="p-6 bg-[#0d0e0f]">
759
- <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">7-Day Rolling</span>
760
- <span class="font-mono text-2xl font-bold block" style="color:${sevenColor}">${sevenPct}%</span>
761
- <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden mt-3 mb-2">
762
- <div class="h-full rounded-full" style="width:${sevenPct}%;background:${sevenColor}"></div>
763
- </div>
764
- <span class="text-[10px] text-[#908fa0] block font-mono">${sevenDay?.resets_at ? 'Resets ' + new Date(sevenDay.resets_at).toLocaleDateString() : ''}</span>
765
- </div>
766
- </section>`;
767
- }
1
+ export function renderHTML(report) {
2
+ const { costAnalysis, cacheHealth, anomalies, inflection, sessionIntel, modelRouting, projectBreakdown, claudeMdStack, oauthUsage, recommendations, generatedAt } = report;
3
+
4
+ const dailyCosts = costAnalysis.dailyCosts || [];
5
+ const grade = cacheHealth.grade || { letter: '?', color: '#666', label: 'Unknown' };
6
+ const totalCost = costAnalysis.totalCost || 0;
7
+ const activeDays = costAnalysis.activeDays || 0;
8
+ const peakDay = costAnalysis.peakDay;
9
+
10
+ const modelCosts = costAnalysis.modelCosts || {};
11
+ const modelEntries = Object.entries(modelCosts).filter(([, c]) => c > 0.01).sort((a, b) => b[1] - a[1]);
12
+ const anomalyDates = new Set((anomalies.anomalies || []).map(a => a.date));
13
+
14
+ const dailyCostsJSON = JSON.stringify(dailyCosts.map(d => ({
15
+ date: d.date, cost: d.cost, cacheOutputRatio: d.cacheOutputRatio || 0, isAnomaly: anomalyDates.has(d.date),
16
+ })));
17
+
18
+ const projectsJSON = JSON.stringify((projectBreakdown || []).slice(0, 15).map(p => ({
19
+ name: p.name, path: p.path, messages: p.messageCount, sessions: p.sessionCount,
20
+ input: p.inputTokens, output: p.outputTokens, cacheRead: p.cacheReadTokens, cacheWrite: p.cacheCreationTokens,
21
+ })));
22
+
23
+ const fmtCost = (n) => '$' + (n >= 100 ? Math.round(n).toLocaleString() : n.toFixed(2));
24
+ const fmtDuration = (m) => m >= 120 ? Math.round(m/60) + 'h' : m >= 60 ? (m/60).toFixed(1) + 'h' : m + 'm';
25
+
26
+ // Grade color mapping to Stitch palette
27
+ const gradeColorMap = {
28
+ 'A': '#c0c1ff', // primary indigo
29
+ 'B': '#d4bbff', // tertiary purple
30
+ 'C': '#ffb690', // secondary orange
31
+ 'D': '#ffb4ab', // error red
32
+ 'F': '#ffb4ab', // error red
33
+ };
34
+ const gradeColor = gradeColorMap[grade.letter] || '#908fa0';
35
+
36
+ // Grade label mapping
37
+ const gradeLabelMap = {
38
+ 'A': 'Excellent Performance',
39
+ 'B': 'Good Performance',
40
+ 'C': 'Fair Performance',
41
+ 'D': 'Poor Performance',
42
+ 'F': 'Critical Performance',
43
+ };
44
+ const gradeLabel = gradeLabelMap[grade.letter] || grade.label || 'Unknown';
45
+
46
+ // Diagnosis one-liner for the share card
47
+ const diagnosisLine = inflection && inflection.direction === 'worsened' && inflection.multiplier >= 2
48
+ ? `Efficiency dropped ${inflection.multiplier}x on ${inflection.date}`
49
+ : anomalies.hasAnomalies
50
+ ? `${anomalies.anomalies.length} anomal${anomalies.anomalies.length === 1 ? 'y' : 'ies'} detected`
51
+ : grade.letter === 'A' ? 'System running clean'
52
+ : grade.letter === 'B' ? 'Minor optimization opportunities'
53
+ : `Cache efficiency needs attention`;
54
+
55
+ // Model colors from Stitch palette
56
+ const modelColors = ['#c0c1ff', '#d4bbff', '#ffb690', '#8083ff', '#a775ff', '#ffb4ab'];
57
+
58
+ // Severity to Stitch color mapping
59
+ const sevColorMap = {
60
+ critical: { border: '#ffb4ab', bg: 'rgba(255, 180, 171, 0.10)', text: '#ffb4ab' },
61
+ warning: { border: '#ffb690', bg: 'rgba(255, 182, 144, 0.10)', text: '#ffb690' },
62
+ info: { border: '#c0c1ff', bg: 'rgba(192, 193, 255, 0.10)', text: '#c0c1ff' },
63
+ positive: { border: '#c0c1ff', bg: 'rgba(192, 193, 255, 0.10)', text: '#c0c1ff' },
64
+ };
65
+
66
+ return `<!DOCTYPE html>
67
+ <html class="dark" lang="en">
68
+ <head>
69
+ <meta charset="UTF-8">
70
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
71
+ <title>CC Hubber</title>
72
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
73
+ <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">
74
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
75
+ <script>
76
+ tailwind.config = {
77
+ darkMode: "class",
78
+ theme: {
79
+ extend: {
80
+ colors: {
81
+ "background": "#121315",
82
+ "surface": "#121315",
83
+ "surface-dim": "#121315",
84
+ "surface-container-lowest": "#0d0e0f",
85
+ "surface-container-low": "#1b1c1d",
86
+ "surface-container": "#1f2021",
87
+ "surface-container-high": "#292a2b",
88
+ "surface-container-highest": "#343536",
89
+ "surface-bright": "#38393a",
90
+ "on-surface": "#e3e2e3",
91
+ "on-surface-variant": "#c7c4d7",
92
+ "on-background": "#e3e2e3",
93
+ "outline": "#908fa0",
94
+ "outline-variant": "#464554",
95
+ "primary": "#c0c1ff",
96
+ "primary-container": "#8083ff",
97
+ "on-primary": "#1000a9",
98
+ "secondary": "#ffb690",
99
+ "secondary-container": "#ec6a06",
100
+ "on-secondary": "#552100",
101
+ "tertiary": "#d4bbff",
102
+ "tertiary-container": "#a775ff",
103
+ "error": "#ffb4ab",
104
+ "error-container": "#93000a",
105
+ "on-error": "#690005",
106
+ "inverse-surface": "#e3e2e3",
107
+ "inverse-on-surface": "#303032",
108
+ "inverse-primary": "#494bd6",
109
+ "surface-tint": "#c0c1ff",
110
+ },
111
+ fontFamily: {
112
+ "headline": ["Inter", "system-ui", "sans-serif"],
113
+ "body": ["Inter", "system-ui", "sans-serif"],
114
+ "label": ["Inter", "system-ui", "sans-serif"],
115
+ "mono": ["JetBrains Mono", "monospace"],
116
+ },
117
+ borderRadius: {
118
+ "DEFAULT": "0.125rem",
119
+ "lg": "0.25rem",
120
+ "xl": "0.5rem",
121
+ "full": "0.75rem",
122
+ },
123
+ },
124
+ },
125
+ }
126
+ </script>
127
+ <style>
128
+ body {
129
+ background-color: #121315;
130
+ color: #e3e2e3;
131
+ font-family: 'Inter', system-ui, sans-serif;
132
+ -webkit-font-smoothing: antialiased;
133
+ -moz-osx-font-smoothing: grayscale;
134
+ }
135
+ .font-mono { font-family: 'JetBrains Mono', monospace !important; }
136
+
137
+ /* Info tooltips */
138
+ .has-tip{position:relative;cursor:help;}
139
+ .has-tip .tip{
140
+ position:absolute;top:calc(100% + 8px);left:0;transform:none;
141
+ background:#292a2b;border:1px solid rgba(70,69,84,0.3);border-radius:8px;
142
+ padding:10px 14px;font-size:11px;line-height:1.5;color:#c7c4d7;
143
+ width:280px;pointer-events:none;opacity:0;transition:opacity 0.15s;
144
+ z-index:50;text-transform:none;letter-spacing:0;font-weight:400;
145
+ box-shadow:0 8px 24px rgba(0,0,0,0.4);
146
+ }
147
+ .has-tip:hover .tip{opacity:1;pointer-events:auto;}
148
+
149
+ /* Tooltip */
150
+ .tt {
151
+ position: fixed;
152
+ background: rgba(31, 32, 33, 0.95);
153
+ backdrop-filter: blur(12px);
154
+ border: 1px solid rgba(70, 69, 84, 0.3);
155
+ border-radius: 0.5rem;
156
+ padding: 10px 14px;
157
+ pointer-events: none;
158
+ opacity: 0;
159
+ transition: opacity 0.12s;
160
+ z-index: 100;
161
+ white-space: nowrap;
162
+ }
163
+ .tt.on { opacity: 1; }
164
+
165
+ /* Toast */
166
+ .toast {
167
+ position: fixed;
168
+ bottom: 20px;
169
+ left: 50%;
170
+ transform: translateX(-50%) translateY(60px);
171
+ background: #292a2b;
172
+ border: 1px solid rgba(70, 69, 84, 0.3);
173
+ border-radius: 0.5rem;
174
+ padding: 10px 20px;
175
+ font-size: 12px;
176
+ font-weight: 600;
177
+ color: #e3e2e3;
178
+ opacity: 0;
179
+ transition: all 0.25s;
180
+ z-index: 200;
181
+ pointer-events: none;
182
+ }
183
+ .toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
184
+
185
+ /* SVG chart */
186
+ #cost-chart-svg { width: 100%; overflow: visible; display: block; }
187
+
188
+ /* Table hover */
189
+ .tbl-row:hover { background: #292a2b; }
190
+ .tbl-row { transition: background 0.15s; }
191
+ </style>
192
+ </head>
193
+ <body class="selection:bg-primary selection:text-on-primary">
194
+
195
+ <div class="tt" id="tt">
196
+ <div class="font-mono text-[11px] text-[#908fa0] mb-1" id="tt-d"></div>
197
+ <div class="font-mono text-[15px] font-bold text-[#e3e2e3]" id="tt-c"></div>
198
+ <div class="text-[10px] text-[#ffb4ab] mt-1" id="tt-a"></div>
199
+ </div>
200
+ <div class="toast" id="toast"></div>
201
+
202
+ <!-- 1. HEADER -->
203
+ <header class="w-full px-6 py-5 max-w-[1200px] mx-auto flex justify-between items-baseline">
204
+ <div class="flex items-baseline gap-4">
205
+ <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>
206
+ <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>
207
+ </div>
208
+ <span class="font-mono text-[11px] text-[#908fa0]" id="range-lbl">All time</span>
209
+ </header>
210
+
211
+ <main class="pb-20 px-6 max-w-[1200px] mx-auto space-y-12">
212
+
213
+ <!-- 2. SHARE CARD — HTML for display, Canvas for video export -->
214
+ <section class="flex flex-col items-center">
215
+ <style>
216
+ @keyframes cardFloat{
217
+ 0%,100%{transform:perspective(800px) rotateY(-2deg) rotateX(1deg)}
218
+ 50%{transform:perspective(800px) rotateY(2deg) rotateX(-1deg)}
219
+ }
220
+ @keyframes shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}
221
+ .cc-card{
222
+ position:relative;width:100%;max-width:740px;
223
+ border-radius:22px;overflow:hidden;
224
+ background:linear-gradient(145deg,#1a1b2e 0%,#151622 20%,#0f1018 40%,#131428 55%,#191a2d 70%,#141520 85%,#12131f 100%);
225
+ 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);
226
+ animation:cardFloat 6s ease-in-out infinite;
227
+ }
228
+ .cc-card::before{
229
+ content:'';position:absolute;inset:0;
230
+ 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%);
231
+ background-size:200% 100%;animation:shimmer 4s ease-in-out infinite;
232
+ pointer-events:none;z-index:2;
233
+ }
234
+ .cc-card::after{
235
+ content:'';position:absolute;inset:0;z-index:1;pointer-events:none;opacity:0.035;
236
+ 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");
237
+ background-size:128px 128px;
238
+ }
239
+ .cc-inner{position:relative;z-index:3;display:flex;flex-direction:column;justify-content:space-between;padding:36px 40px;min-height:280px;}
240
+ .cc-card.no-shimmer::before{display:none!important;}
241
+ </style>
242
+ <div class="cc-card" id="share-card-html">
243
+ <div class="cc-inner">
244
+ <div class="flex items-start justify-between">
245
+ <div class="flex items-center gap-4">
246
+ <div class="w-14 h-14 flex items-center justify-center rounded-[12px]" style="background:${gradeColor}">
247
+ <span class="text-[30px]" style="color:#0f1018;font-weight:900;">${grade.letter}</span>
248
+ </div>
249
+ <div>
250
+ <span class="text-[10px] font-mono uppercase tracking-[0.08em] font-bold block" style="color:${gradeColor}">${grade.label}</span>
251
+ <span class="text-xl font-bold text-[#e3e2e3]">${gradeLabel}</span>
252
+ </div>
253
+ </div>
254
+ <div class="text-right">
255
+ <span class="text-[11px] font-mono uppercase tracking-[0.1em] text-[#908fa0] font-bold block">Claude Code</span>
256
+ <span class="text-[11px] font-mono uppercase tracking-[0.06em] text-[#596678] block" id="card-range">All time</span>
257
+ </div>
258
+ </div>
259
+ <div class="flex justify-between items-end">
260
+ <div>
261
+ <p class="text-[10px] uppercase tracking-[0.06em] text-[#908fa0] mb-1">Total Spend</p>
262
+ <p class="font-mono text-[40px] font-bold text-[#e3e2e3] leading-none" id="h-cost">${fmtCost(totalCost)}</p>
263
+ </div>
264
+ <div class="text-center">
265
+ <p class="text-[10px] uppercase tracking-[0.06em] text-[#908fa0] mb-1">Active Days</p>
266
+ <p class="font-mono text-[40px] font-bold text-[#e3e2e3] leading-none" id="h-days">${activeDays}</p>
267
+ </div>
268
+ <div class="text-right">
269
+ <p class="text-[10px] uppercase tracking-[0.06em] text-[#908fa0] mb-1">Cache Ratio</p>
270
+ <p class="font-mono text-[40px] font-bold text-[#e3e2e3] leading-none">${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : 'N/A'}</p>
271
+ </div>
272
+ </div>
273
+ <div class="flex justify-between items-end">
274
+ <p class="text-[12px] text-[#908fa0]">${diagnosisLine}</p>
275
+ <div class="flex items-center gap-2 text-[12px] font-mono tracking-[0.03em] shrink-0">
276
+ <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>
277
+ <span class="text-[#464554]">&middot;</span>
278
+ <span class="text-[#908fa0]">shipped fast with</span>
279
+ <a href="https://moveros.dev" target="_blank" class="text-[#c0c1ff] hover:text-[#e1e0ff]" style="text-decoration:none;font-weight:600;">Mover OS</a>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ </div>
284
+ <!-- Hidden canvas for video recording -->
285
+ <canvas id="share-card" style="display:none;"></canvas>
286
+ <div class="flex justify-center mt-5">
287
+ <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">
288
+ <span class="material-symbols-outlined text-sm">share</span>
289
+ Share
290
+ </button>
291
+ </div>
292
+ </section>
293
+
294
+ ${inflection && inflection.multiplier >= 1.5 ? `
295
+ <!-- Inflection callouts -->
296
+ <section class="space-y-3">
297
+ <div class="p-6 bg-[#0d0e0f] border-l-4 rounded-r-xl" style="border-left-color:${inflection.direction === 'worsened' ? '#ffb4ab' : '#c0c1ff'}">
298
+ <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>
299
+ <p class="text-sm text-[#c7c4d7]">${inflection.summary}</p>
300
+ </div>
301
+ ${inflection.secondary ? `
302
+ <div class="p-6 bg-[#0d0e0f] border-l-4 rounded-r-xl" style="border-left-color:${inflection.secondary.direction === 'worsened' ? '#ffb4ab' : '#c0c1ff'}">
303
+ <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>
304
+ <p class="text-sm text-[#c7c4d7]">${inflection.secondary.summary}</p>
305
+ </div>` : ''}
306
+ </section>
307
+ ` : ''}
308
+
309
+ <!-- 3. METRIC GRID -->
310
+ <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);">
311
+ <div class="p-6 bg-[#0d0e0f]">
312
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Total Cost</span>
313
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]" id="ov-total">${fmtCost(totalCost)}</span>
314
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono" id="ov-avg">${fmtCost(costAnalysis.avgDailyCost || 0)} avg/day</span>
315
+ </div>
316
+ <div class="p-6 bg-[#0d0e0f]">
317
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Peak Day</span>
318
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${peakDay ? fmtCost(peakDay.cost) : '$0'}</span>
319
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${peakDay ? peakDay.date : ''}</span>
320
+ </div>
321
+ <div class="p-6 bg-[#0d0e0f]">
322
+ <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>
323
+ <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>
324
+ </span>
325
+ <span class="font-mono text-2xl font-bold block" style="color:${gradeColor}">${grade.letter}</span>
326
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : ''}</span>
327
+ </div>
328
+ <div class="p-6 bg-[#0d0e0f]">
329
+ <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>
330
+ <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>
331
+ </span>
332
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${cacheHealth.totalCacheBreaks > 0 ? cacheHealth.totalCacheBreaks : '~' + (cacheHealth.estimatedBreaks || 0)}</span>
333
+ <span class="text-[10px] text-[#908fa0] mt-1 block">${cacheHealth.totalCacheBreaks > 0 ? cacheHealth.reasonsRanked?.[0]?.reason : 'estimated from writes'}</span>
334
+ </div>
335
+ <div class="p-6 bg-[#0d0e0f]">
336
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">CLAUDE.md</span>
337
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">~${Math.round(claudeMdStack.totalTokensEstimate / 1000)}K</span>
338
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${(claudeMdStack.totalBytes / 1024).toFixed(1)} KB</span>
339
+ </div>
340
+ ${sessionIntel?.available ? `
341
+ <div class="p-6 bg-[#0d0e0f]">
342
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Sessions</span>
343
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${sessionIntel.totalSessions}</span>
344
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${sessionIntel.avgDuration} min avg</span>
345
+ </div>` : `
346
+ <div class="p-6 bg-[#0d0e0f]">
347
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Sessions</span>
348
+ <span class="font-mono text-2xl font-bold block text-[#e3e2e3]">${costAnalysis.sessions?.total || 0}</span>
349
+ <span class="text-[10px] text-[#908fa0] mt-1 block font-mono">${costAnalysis.sessions?.avgDurationMinutes ? Math.round(costAnalysis.sessions.avgDurationMinutes) + ' min avg' : ''}</span>
350
+ </div>`}
351
+ </section>
352
+
353
+ <!-- 4. COST TREND CHART -->
354
+ <section class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
355
+ <div class="flex justify-between items-end mb-10">
356
+ <div>
357
+ <h3 class="text-xl font-bold text-[#e3e2e3] mb-1">Cost Trend</h3>
358
+ <p class="text-sm text-[#908fa0]" id="chart-info"></p>
359
+ </div>
360
+ <div class="flex gap-1 p-1 bg-[#0d0e0f] rounded-xl border border-[rgba(70,69,84,0.15)]" id="filters">
361
+ <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>
362
+ <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>
363
+ <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>
364
+ <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>
365
+ </div>
366
+ </div>
367
+ <svg id="cost-chart-svg" viewBox="0 0 900 200" preserveAspectRatio="xMidYMid meet"></svg>
368
+ </section>
369
+
370
+ <!-- 5. SESSION INTELLIGENCE + MODEL DISTRIBUTION -->
371
+ <section class="grid grid-cols-1 lg:grid-cols-2 gap-8">
372
+
373
+ <!-- Session Intelligence -->
374
+ <div class="space-y-8">
375
+ ${sessionIntel?.available ? `
376
+ <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
377
+ <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Session Intelligence</h3>
378
+ <div class="grid grid-cols-3 gap-6 mb-8">
379
+ <div>
380
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Median</span>
381
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${fmtDuration(sessionIntel.medianDuration)}</span>
382
+ </div>
383
+ <div>
384
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">P90</span>
385
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${fmtDuration(sessionIntel.p90Duration)}</span>
386
+ </div>
387
+ <div>
388
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Longest</span>
389
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${fmtDuration(sessionIntel.maxDuration)}</span>
390
+ </div>
391
+ </div>
392
+ <div class="grid grid-cols-3 gap-6 mb-8">
393
+ <div>
394
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Long Sessions</span>
395
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.longSessions}</span>
396
+ <span class="text-[10px] text-[#908fa0] block font-mono">${sessionIntel.longSessionPct}% over 60m</span>
397
+ </div>
398
+ <div>
399
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Tools/Session</span>
400
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.avgToolsPerSession}</span>
401
+ </div>
402
+ <div>
403
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-1">Lines/Hour</span>
404
+ <span class="font-mono text-xl font-bold text-[#e3e2e3]">${sessionIntel.linesPerHour.toLocaleString()}</span>
405
+ </div>
406
+ </div>
407
+
408
+ ${sessionIntel.topTools.length > 0 ? `
409
+ <div>
410
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-4">Top Tools Usage</span>
411
+ <div class="space-y-3">
412
+ ${sessionIntel.topTools.slice(0, 6).map((t, i) => `
413
+ <div class="space-y-1">
414
+ <div class="flex justify-between text-[11px] font-mono">
415
+ <span class="text-[#c7c4d7]">${t.name}</span>
416
+ <span class="text-[#908fa0]">${t.count}</span>
417
+ </div>
418
+ <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden">
419
+ <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>
420
+ </div>
421
+ </div>`).join('')}
422
+ </div>
423
+ </div>` : ''}
424
+ </div>` : ''}
425
+
426
+ <!-- 6. ACTIVITY HEATMAP -->
427
+ ${sessionIntel?.hourDistribution ? `
428
+ <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
429
+ <h3 class="text-lg font-bold text-[#e3e2e3] mb-4">Activity by Hour</h3>
430
+ <div class="" id="hour-grid" style="display:flex;justify-content:space-between;align-items:flex-end;padding:0 4px;"></div>
431
+ <!-- labels rendered by JS -->
432
+ </div>` : ''}
433
+ </div>
434
+
435
+ <!-- Model Distribution -->
436
+ <div class="space-y-8">
437
+ <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
438
+ <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Model Distribution</h3>
439
+ <div class="w-full h-4 flex rounded-full overflow-hidden mb-6" style="gap:2px">
440
+ ${modelEntries.map(([, cost], i) => {
441
+ const pct = totalCost > 0 ? (cost / totalCost) * 100 : 0;
442
+ 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>`;
443
+ }).join('')}
444
+ </div>
445
+ <div class="grid grid-cols-2 gap-4">
446
+ ${modelEntries.map(([name, cost], i) => {
447
+ const pct = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(1) : '0';
448
+ return `<div class="flex items-center gap-2">
449
+ <div class="w-2 h-2 rounded-full" style="background:${modelColors[i % modelColors.length]}"></div>
450
+ <span class="text-xs font-mono text-[#c7c4d7]">${name}</span>
451
+ <span class="text-xs font-mono text-[#908fa0]">${fmtCost(cost)}</span>
452
+ <span class="text-[10px] text-[#464554] font-mono">${pct}%</span>
453
+ </div>`;
454
+ }).join('')}
455
+ </div>
456
+ ${modelRouting?.available ? `
457
+ <div class="mt-6 pt-6 border-t border-[rgba(70,69,84,0.15)] text-sm text-[#c7c4d7]">
458
+ <span class="font-mono">${modelRouting.opusPct}%</span> Opus &middot;
459
+ <span class="font-mono">${modelRouting.sonnetPct}%</span> Sonnet &middot;
460
+ <span class="font-mono">${modelRouting.haikuPct}%</span> Haiku
461
+ ${modelRouting.estimatedSavings > 10 ? `<span class="text-[#c0c1ff] ml-3 font-mono">~${fmtCost(modelRouting.estimatedSavings)} potential savings</span>` : ''}
462
+ </div>` : ''}
463
+ </div>
464
+
465
+ <!-- 9. RECOMMENDATIONS (placed alongside model distribution) -->
466
+ ${recommendations.length > 0 ? `
467
+ <div class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
468
+ <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Recommendations</h3>
469
+ <div class="space-y-3">
470
+ ${recommendations.map(r => {
471
+ const sev = sevColorMap[r.severity] || sevColorMap.info;
472
+ return `<div class="p-4 bg-[#0d0e0f] rounded-r-lg flex items-start gap-4" style="border-left:3px solid ${sev.border}">
473
+ <div class="flex-1 min-w-0">
474
+ <div class="flex items-start justify-between gap-4">
475
+ <p class="text-[13px] font-semibold text-[#e3e2e3]">${r.title}</p>
476
+ ${r.savings ? `<span class="text-[10px] font-mono shrink-0 px-2 py-0.5 rounded" style="background:${sev.border}18;color:${sev.text}">${r.savings}</span>` : ''}
477
+ </div>
478
+ <p class="text-[11px] text-[#908fa0] mt-1 leading-relaxed">${r.action}</p>
479
+ </div>
480
+ </div>`;
481
+ }).join('')}
482
+ </div>
483
+ </div>` : ''}
484
+ </div>
485
+ </section>
486
+
487
+ <!-- 7. PROJECTS TABLE -->
488
+ ${projectBreakdown && projectBreakdown.length > 0 ? `
489
+ <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
490
+ <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)]">
491
+ <h3 class="text-xl font-bold text-[#e3e2e3]">Projects</h3>
492
+ </div>
493
+ <div class="overflow-x-auto">
494
+ <table class="w-full text-left" id="proj-tbl">
495
+ <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
496
+ <tr>
497
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Project</th>
498
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Messages</th>
499
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Sessions</th>
500
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Output</th>
501
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Cache Read</th>
502
+ </tr>
503
+ </thead>
504
+ <tbody class="divide-y divide-[rgba(70,69,84,0.15)]"></tbody>
505
+ </table>
506
+ </div>
507
+ </section>
508
+ ` : ''}
509
+
510
+ <!-- 8. ANOMALIES TABLE -->
511
+ ${anomalies.hasAnomalies ? `
512
+ <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
513
+ <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
514
+ <h3 class="text-xl font-bold text-[#e3e2e3]">Detected Anomalies</h3>
515
+ <span class="material-symbols-outlined text-[#ffb4ab] animate-pulse">warning</span>
516
+ </div>
517
+ <div class="overflow-x-auto">
518
+ <table class="w-full text-left">
519
+ <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
520
+ <tr>
521
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Date</th>
522
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Cost</th>
523
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Deviation</th>
524
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Cache Ratio</th>
525
+ <th class="px-8 py-4 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Severity</th>
526
+ </tr>
527
+ </thead>
528
+ <tbody class="divide-y divide-[rgba(70,69,84,0.15)]">
529
+ ${anomalies.anomalies.map(a => {
530
+ const sevBg = a.severity === 'critical' ? 'rgba(255, 180, 171, 0.10)' : 'rgba(255, 182, 144, 0.10)';
531
+ const sevText = a.severity === 'critical' ? '#ffb4ab' : '#ffb690';
532
+ return `<tr class="tbl-row">
533
+ <td class="px-8 py-4 font-mono text-sm text-[#e3e2e3]">${a.date}</td>
534
+ <td class="px-8 py-4 font-mono text-sm text-[#e3e2e3] font-bold">${fmtCost(a.cost)}</td>
535
+ <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>
536
+ <td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">${a.cacheOutputRatio ? a.cacheOutputRatio.toLocaleString() + ':1' : ''}</td>
537
+ <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>
538
+ </tr>`;
539
+ }).join('')}
540
+ </tbody>
541
+ </table>
542
+ </div>
543
+ </section>
544
+ ` : ''}
545
+
546
+ <!-- 10. CLAUDE.md ANALYSIS Global only, section breakdown -->
547
+ <section class="bg-[#1b1c1d] rounded-xl border border-[rgba(70,69,84,0.15)] overflow-hidden">
548
+ <div class="px-8 py-6 border-b border-[rgba(70,69,84,0.15)] flex justify-between items-center">
549
+ <h3 class="text-xl font-bold text-[#e3e2e3]">CLAUDE.md Analysis</h3>
550
+ <div class="text-right">
551
+ <span class="font-mono text-sm text-[#e3e2e3]">${claudeMdStack.files[0]?.lineCount || '?'} lines</span>
552
+ <span class="text-[#908fa0] mx-2">&middot;</span>
553
+ <span class="font-mono text-sm text-[#e3e2e3]">~${claudeMdStack.totalTokensEstimate.toLocaleString()} tokens</span>
554
+ <span class="text-[#908fa0] mx-2">&middot;</span>
555
+ <span class="font-mono text-sm text-[#e3e2e3]">${(claudeMdStack.totalBytes / 1024).toFixed(1)} KB</span>
556
+ </div>
557
+ </div>
558
+ <div class="px-8 py-4 bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)] flex justify-between text-xs">
559
+ <span class="text-[#908fa0]">Per-message cost impact</span>
560
+ <span class="font-mono">
561
+ <span class="text-[#c0c1ff]">$${claudeMdStack.costPerMessage.cached.toFixed(4)}</span> cached &middot;
562
+ <span class="text-[#ffb690]">$${claudeMdStack.costPerMessage.uncached.toFixed(4)}</span> uncached &middot;
563
+ <span class="text-[#ffb4ab]">$${(claudeMdStack.costPerMessage.dailyCached200 || 0).toFixed(2)}</span>/day at 200 msgs
564
+ </span>
565
+ </div>
566
+ ${claudeMdStack.globalSections && claudeMdStack.globalSections.length > 0 ? `
567
+ <div class="overflow-x-auto">
568
+ <table class="w-full text-left">
569
+ <thead class="bg-[#0d0e0f] border-b border-[rgba(70,69,84,0.15)]">
570
+ <tr>
571
+ <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0]">Section</th>
572
+ <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Lines</th>
573
+ <th class="px-8 py-3 text-[10px] uppercase font-bold tracking-[0.05em] text-[#908fa0] text-right">Tokens</th>
574
+ </tr>
575
+ </thead>
576
+ <tbody class="divide-y divide-[rgba(70,69,84,0.15)]">
577
+ ${claudeMdStack.globalSections.map(s => `<tr class="tbl-row">
578
+ <td class="px-8 py-3 text-sm text-[#e3e2e3]">${s.name}</td>
579
+ <td class="px-8 py-3 font-mono text-sm text-[#c7c4d7] text-right">${s.lines}</td>
580
+ <td class="px-8 py-3 font-mono text-sm text-[#c7c4d7] text-right">${s.tokens.toLocaleString()}</td>
581
+ </tr>`).join('')}
582
+ </tbody>
583
+ </table>
584
+ </div>
585
+ ` : ''}
586
+ </section>
587
+
588
+ <!-- 11. CACHE SAVINGS -->
589
+ ${cacheHealth.savings?.fromCaching > 0 ? `
590
+ <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);">
591
+ <div class="p-8 bg-[#0d0e0f]">
592
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Saved by Cache</span>
593
+ <span class="font-mono text-3xl font-bold block text-[#c0c1ff]">~$${Number(cacheHealth.savings.fromCaching).toLocaleString()}</span>
594
+ <span class="text-[10px] text-[#908fa0] mt-2 block">vs standard input pricing</span>
595
+ </div>
596
+ <div class="p-8 bg-[#0d0e0f]">
597
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">Wasted on Breaks</span>
598
+ <span class="font-mono text-3xl font-bold block text-[#ffb690]">~$${Number(cacheHealth.savings.wastedFromBreaks).toLocaleString()}</span>
599
+ <span class="text-[10px] text-[#908fa0] mt-2 block">from cache invalidation</span>
600
+ </div>
601
+ </section>
602
+ ` : ''}
603
+
604
+ ${cacheHealth.totalCacheBreaks > 0 ? `
605
+ <!-- Cache Break Reasons -->
606
+ <section class="bg-[#1b1c1d] p-8 rounded-xl border border-[rgba(70,69,84,0.15)]">
607
+ <h3 class="text-xl font-bold text-[#e3e2e3] mb-6">Cache Break Reasons</h3>
608
+ <div class="space-y-4">
609
+ ${(cacheHealth.reasonsRanked || []).map(r => `
610
+ <div class="space-y-1">
611
+ <div class="flex justify-between text-[11px] font-mono">
612
+ <span class="text-[#c7c4d7]">${r.reason}</span>
613
+ <span class="text-[#908fa0]">${r.count}</span>
614
+ </div>
615
+ <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden">
616
+ <div class="h-full bg-[#ffb690] rounded-full" style="width:${r.percentage}%"></div>
617
+ </div>
618
+ </div>`).join('')}
619
+ </div>
620
+ </section>
621
+ ` : ''}
622
+
623
+ </main>
624
+
625
+ <!-- 12. FOOTER -->
626
+ <footer class="w-full py-12 border-t border-[rgba(70,69,84,0.05)]">
627
+ <div class="max-w-[1200px] mx-auto px-6 text-center">
628
+ <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>
629
+ </div>
630
+ </footer>
631
+
632
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
633
+ <script>
634
+ (function(){
635
+ var D=${dailyCostsJSON}, P=${projectsJSON};
636
+ var CARD={
637
+ grade:'${grade.letter}',gradeLabel:'${grade.label}',gradePerf:'${gradeLabel}',
638
+ gradeColor:'${gradeColor}',
639
+ cost:'${fmtCost(totalCost)}',days:'${activeDays}',
640
+ ratio:'${cacheHealth.efficiencyRatio ? cacheHealth.efficiencyRatio.toLocaleString() + ':1' : 'N/A'}',
641
+ diagnosis:'${diagnosisLine.replace(/'/g, "\\'")}',
642
+ range:'All time'
643
+ };
644
+ var HR=${sessionIntel?.hourDistribution ? JSON.stringify(sessionIntel.hourDistribution) : 'null'};
645
+ var CACHE_R=0.50,OUT=25,INP=5,CW=6.25;
646
+
647
+ function fc(n){return n>=100?'$'+Math.round(n).toLocaleString():'$'+n.toFixed(2)}
648
+ 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()}
649
+
650
+ // Hour activity vertical bar chart
651
+ if(HR){
652
+ var hg=document.getElementById('hour-grid');
653
+ if(hg){
654
+ var mx=Math.max.apply(null,HR);
655
+ var html='';
656
+ for(var i=0;i<24;i++){
657
+ var pct=mx>0?Math.max(HR[i]/mx*100,2):2;
658
+ var opac=pct>70?'0.85':pct>40?'0.6':pct>15?'0.35':'0.12';
659
+ html+='<div style="display:flex;flex-direction:column;align-items:center;gap:4px;" title="'+i+':00 — '+HR[i]+' messages">';
660
+ html+='<div style="width:3px;height:80px;background:rgba(70,69,84,0.2);border-radius:2px;position:relative;overflow:hidden;">';
661
+ html+='<div style="position:absolute;bottom:0;width:100%;height:'+pct+'%;background:rgba(192,193,255,'+opac+');border-radius:2px;"></div>';
662
+ html+='</div>';
663
+ html+='<span style="font-size:8px;font-family:JetBrains Mono,monospace;color:'+(i%6===0?'#908fa0':'#464554')+';">'+i+'</span>';
664
+ html+='</div>';
665
+ }
666
+ hg.innerHTML=html;
667
+ }
668
+ }
669
+
670
+ // Project table
671
+ var ptb=document.querySelector('#proj-tbl tbody');
672
+ if(ptb&&P.length>0){
673
+ P.sort(function(a,b){return(b.output/1e6*OUT+b.cacheRead/1e6*CACHE_R)-(a.output/1e6*OUT+a.cacheRead/1e6*CACHE_R)});
674
+ var h='';
675
+ for(var i=0;i<Math.min(P.length,10);i++){
676
+ var p=P[i];
677
+ h+='<tr class="tbl-row">';
678
+ h+='<td class="px-8 py-4 text-sm font-semibold text-[#e3e2e3]">'+p.name;
679
+ if(p.path)h+='<br><span class="text-[10px] text-[#908fa0] font-mono">'+p.path+'</span>';
680
+ h+='</td>';
681
+ h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.messages.toLocaleString()+'</td>';
682
+ h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+p.sessions+'</td>';
683
+ h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7]">'+ft(p.output)+'</td>';
684
+ h+='<td class="px-8 py-4 font-mono text-sm text-[#c7c4d7] text-right">'+ft(p.cacheRead)+'</td>';
685
+ h+='</tr>';
686
+ }
687
+ ptb.innerHTML=h;
688
+ }
689
+
690
+ // Chart
691
+ var W=900,H=200,PD={t:24,r:16,b:40,l:56};
692
+ var cW=W-PD.l-PD.r,cH=H-PD.t-PD.b;
693
+ var svg=document.getElementById('cost-chart-svg');
694
+ var tt=document.getElementById('tt'),ttd=document.getElementById('tt-d'),ttc=document.getElementById('tt-c'),tta=document.getElementById('tt-a');
695
+
696
+ function filt(r){return r==='all'?D:D.slice(-parseInt(r,10))}
697
+
698
+ function chart(d){
699
+ if(!svg)return;
700
+ 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</text>';return}
701
+ var mx=Math.max.apply(null,d.map(function(x){return x.cost}))*1.1;if(mx<0.01)mx=1;
702
+ var s='';
703
+ // grid lines
704
+ for(var i=0;i<=3;i++){
705
+ var y=PD.t+(cH/3)*i,v=mx-(mx/3)*i;
706
+ s+='<line x1="'+PD.l+'" y1="'+y+'" x2="'+(W-PD.r)+'" y2="'+y+'" stroke="rgba(70,69,84,0.15)" stroke-width="1"/>';
707
+ 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>';
708
+ }
709
+ // area + line
710
+ var step=d.length>1?cW/(d.length-1):0;
711
+ 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}});
712
+ var lp='M '+pts[0].x+' '+pts[0].y;
713
+ var ap='M '+pts[0].x+' '+(PD.t+cH)+' L '+pts[0].x+' '+pts[0].y;
714
+ 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}
715
+ ap+=' L '+pts[pts.length-1].x+' '+(PD.t+cH)+' Z';
716
+ // Stitch gradient: primary at 30% to transparent
717
+ 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>';
718
+ s+='<path d="'+ap+'" fill="url(#ag)"/>';
719
+ s+='<path d="'+lp+'" fill="none" stroke="#c0c1ff" stroke-width="2" stroke-linecap="round"/>';
720
+ // x labels
721
+ var every=Math.max(1,Math.floor(d.length/8));
722
+ d.forEach(function(x,j){
723
+ var px=PD.l+(d.length===1?cW/2:j*step);
724
+ 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>';
725
+ });
726
+ // anomaly dots
727
+ d.forEach(function(x,j){
728
+ var px=PD.l+(d.length===1?cW/2:j*step),py=PD.t+cH-(x.cost/mx)*cH;
729
+ if(x.isAnomaly)s+='<circle cx="'+px+'" cy="'+py+'" r="4" fill="#ffb4ab" stroke="#121315" stroke-width="2"/>';
730
+ });
731
+ // hover targets
732
+ d.forEach(function(x,j){
733
+ var px=PD.l+(d.length===1?cW/2:j*step),py=PD.t+cH-(x.cost/mx)*cH;
734
+ s+='<circle cx="'+px+'" cy="'+py+'" r="14" fill="transparent" data-d="'+x.date+'" data-c="'+x.cost+'" data-a="'+(x.isAnomaly?1:0)+'" class="hov" style="cursor:crosshair"/>';
735
+ });
736
+ svg.innerHTML=s;
737
+ svg.querySelectorAll('.hov').forEach(function(el){
738
+ el.addEventListener('mouseenter',function(e){
739
+ ttd.textContent=e.target.dataset.d;
740
+ ttc.textContent=fc(parseFloat(e.target.dataset.c));
741
+ tta.textContent=e.target.dataset.a==='1'?'ANOMALY':'';
742
+ tta.style.display=e.target.dataset.a==='1'?'block':'none';
743
+ tt.classList.add('on');
744
+ });
745
+ el.addEventListener('mousemove',function(e){tt.style.left=(e.clientX+14)+'px';tt.style.top=(e.clientY-40)+'px'});
746
+ el.addEventListener('mouseleave',function(){tt.classList.remove('on')});
747
+ });
748
+ }
749
+
750
+ var RL={7:'Last 7 days',30:'Last 30 days',90:'Last 90 days',all:'All time'};
751
+
752
+ function setR(r){
753
+ var f=filt(r);chart(f);
754
+ var ci=document.getElementById('chart-info');
755
+ 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;ci.textContent=a+' days \u00b7 '+fc(t)}
756
+ var rl=document.getElementById('range-lbl');if(rl)rl.textContent=RL[r]||'All time';
757
+ CARD.range=RL[r]||'All time';
758
+ var cr=document.getElementById('card-range');if(cr)cr.textContent=CARD.range;
759
+ if(f.length){
760
+ var t=f.reduce(function(s,x){return s+x.cost},0),a=f.filter(function(x){return x.cost>0}).length;
761
+ CARD.cost=fc(t);CARD.days=a.toString();
762
+ // Update HTML card
763
+ var hc=document.getElementById('h-cost');if(hc)hc.textContent=fc(t);
764
+ var hd=document.getElementById('h-days');if(hd)hd.textContent=a;
765
+ // Update overview
766
+ var ot=document.getElementById('ov-total'),oa=document.getElementById('ov-avg');
767
+ if(ot)ot.textContent=fc(t);if(oa&&a>0)oa.textContent=fc(t/a)+' avg/day';
768
+ }
769
+ // Update filter button states - Stitch style
770
+ document.querySelectorAll('.cfilt').forEach(function(b){
771
+ if(b.dataset.r===r){
772
+ b.style.background='#c0c1ff';b.style.color='#1000a9';
773
+ } else {
774
+ b.style.background='transparent';b.style.color='#908fa0';
775
+ }
776
+ });
777
+ }
778
+
779
+ document.querySelectorAll('.cfilt').forEach(function(b){b.addEventListener('click',function(){setR(b.dataset.r)})});
780
+
781
+ // Export helpers
782
+ var toast=document.getElementById('toast');
783
+ function showToast(m){if(!toast)return;toast.textContent=m;toast.classList.add('on');setTimeout(function(){toast.classList.remove('on')},2000)}
784
+
785
+ // ─── CANVAS SHARE CARD ─────────────────────────────
786
+ // Renders entirely on canvas — same output for display AND video export
787
+ var cardCanvas=document.getElementById('share-card');
788
+ var cardW=1480,cardH=580,cardR=44; // 2x resolution for retina
789
+ cardCanvas.width=cardW;cardCanvas.height=cardH;
790
+ var cardCtx=cardCanvas.getContext('2d');
791
+
792
+ function roundRect(ctx,x,y,w,h,r){
793
+ ctx.beginPath();ctx.moveTo(x+r,y);ctx.lineTo(x+w-r,y);ctx.quadraticCurveTo(x+w,y,x+w,y+r);
794
+ ctx.lineTo(x+w,y+h-r);ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);ctx.lineTo(x+r,y+h);
795
+ ctx.quadraticCurveTo(x,y+h,x,y+h-r);ctx.lineTo(x,y+r);ctx.quadraticCurveTo(x,y,x+r,y);ctx.closePath();
796
+ }
797
+
798
+ function drawCard(ctx,w,h,shimmerT){
799
+ // Background gradient
800
+ var bg=ctx.createLinearGradient(0,0,w,h);
801
+ bg.addColorStop(0,'#1a1b2e');bg.addColorStop(0.4,'#0f1018');bg.addColorStop(0.7,'#191a2d');bg.addColorStop(1,'#12131f');
802
+ roundRect(ctx,0,0,w,h,cardR);ctx.save();ctx.clip();
803
+ ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
804
+
805
+ var pad=80,padT=72;
806
+
807
+ // Grade badge
808
+ var bx=pad,by=padT,bs=112;
809
+ roundRect(ctx,bx,by,bs,bs,24);
810
+ ctx.fillStyle=CARD.gradeColor;ctx.fill();
811
+ ctx.font='800 56px "JetBrains Mono",monospace';ctx.fillStyle='#0f1018';ctx.textAlign='center';ctx.textBaseline='middle';
812
+ ctx.fillText(CARD.grade,bx+bs/2,by+bs/2);
813
+
814
+ // Grade label + performance text
815
+ ctx.textAlign='left';ctx.textBaseline='top';
816
+ ctx.font='700 18px "JetBrains Mono",monospace';ctx.fillStyle=CARD.gradeColor;
817
+ ctx.fillText(CARD.gradeLabel.toUpperCase(),bx+bs+32,by+12);
818
+ ctx.font='700 36px "Inter",sans-serif';ctx.fillStyle='#e3e2e3';
819
+ ctx.fillText(CARD.gradePerf,bx+bs+32,by+40);
820
+
821
+ // Top right: Claude Code + range
822
+ ctx.textAlign='right';
823
+ ctx.font='700 20px "JetBrains Mono",monospace';ctx.fillStyle='#908fa0';
824
+ ctx.fillText('CLAUDE CODE',w-pad,padT+16);
825
+ ctx.font='400 16px "JetBrains Mono",monospace';ctx.fillStyle='#464554';
826
+ ctx.fillText(CARD.range,w-pad,padT+44);
827
+
828
+ // Stats row
829
+ var statsY=h*0.45;
830
+ var labels=['TOTAL SPEND','ACTIVE DAYS','CACHE RATIO'];
831
+ var values=[CARD.cost,CARD.days,CARD.ratio];
832
+ var positions=[pad,w*0.38,w*0.7];
833
+ var aligns=['left','center','right'];
834
+ var xEnds=[null,null,w-pad];
835
+
836
+ for(var i=0;i<3;i++){
837
+ ctx.textAlign=i===2?'right':i===1?'center':'left';
838
+ var sx=i===2?w-pad:i===1?w/2:pad;
839
+ ctx.font='400 16px "Inter",sans-serif';ctx.fillStyle='#908fa0';
840
+ ctx.fillText(labels[i],sx,statsY);
841
+ ctx.font='700 64px "JetBrains Mono",monospace';ctx.fillStyle='#e3e2e3';
842
+ ctx.fillText(values[i],sx,statsY+28);
843
+ }
844
+
845
+ // Bottom: diagnosis + branding
846
+ var botY=h-padT;
847
+ ctx.textAlign='left';
848
+ ctx.font='400 20px "Inter",sans-serif';ctx.fillStyle='#908fa0';
849
+ ctx.fillText(CARD.diagnosis,pad,botY);
850
+
851
+ // Branding — measure text to space properly
852
+ ctx.textAlign='right';
853
+ ctx.font='500 18px "JetBrains Mono",monospace';
854
+ var moverW=ctx.measureText('Mover OS').width;
855
+ ctx.font='400 18px "Inter",sans-serif';
856
+ var shippedW=ctx.measureText(' shipped fast with ').width;
857
+ ctx.font='500 18px "JetBrains Mono",monospace';
858
+ var hubberW=ctx.measureText('CC Hubber').width;
859
+
860
+ var bx=w-pad;
861
+ ctx.font='500 18px "JetBrains Mono",monospace';ctx.fillStyle='#c0c1ff';
862
+ ctx.fillText('Mover OS',bx,botY);
863
+ bx-=moverW;
864
+ ctx.font='400 18px "Inter",sans-serif';ctx.fillStyle='#908fa0';
865
+ ctx.fillText(' shipped fast with ',bx,botY);
866
+ bx-=shippedW;
867
+ ctx.font='500 18px "JetBrains Mono",monospace';ctx.fillStyle='#c0c1ff';
868
+ ctx.fillText('CC Hubber',bx,botY);
869
+
870
+ // Subtle shimmer sweep
871
+ if(shimmerT!==undefined){
872
+ var sx=-w*0.4+(shimmerT%1)*w*1.8;
873
+ var sg=ctx.createLinearGradient(sx,0,sx+w*0.3,h);
874
+ sg.addColorStop(0,'rgba(255,255,255,0)');
875
+ sg.addColorStop(0.45,'rgba(192,193,255,0.02)');
876
+ sg.addColorStop(0.5,'rgba(255,255,255,0.045)');
877
+ sg.addColorStop(0.55,'rgba(212,187,255,0.02)');
878
+ sg.addColorStop(1,'rgba(255,255,255,0)');
879
+ ctx.fillStyle=sg;ctx.fillRect(0,0,w,h);
880
+ }
881
+
882
+ ctx.restore();
883
+ }
884
+
885
+ // Animate the card on the page
886
+ var cardAnimStart=null;
887
+ function animateCard(ts){
888
+ if(!cardAnimStart)cardAnimStart=ts;
889
+ var t=((ts-cardAnimStart)%6000)/6000;
890
+ drawCard(cardCtx,cardW,cardH,t);
891
+ requestAnimationFrame(animateCard);
892
+ }
893
+ document.fonts.ready.then(function(){requestAnimationFrame(animateCard)});
894
+
895
+ // ─── VIDEO EXPORT (records the same canvas) ──────
896
+ // Video export — captures HTML card with html-to-image, animates on canvas at 1440p
897
+ var gb=document.getElementById('btn-gif');
898
+ if(gb)gb.addEventListener('click',function(){
899
+ gb.textContent='Capturing...';gb.disabled=true;
900
+ var htmlCard=document.getElementById('share-card-html');
901
+ if(!htmlCard||typeof htmlToImage==='undefined'){gb.textContent='Share';gb.disabled=false;showToast('Library not loaded');return}
902
+
903
+ // Pause animation + hide CSS shimmer/noise for clean capture
904
+ htmlCard.style.animation='none';htmlCard.style.transform='none';
905
+ htmlCard.classList.add('no-shimmer');
906
+
907
+ document.fonts.ready.then(function(){
908
+ return htmlToImage.toPng(htmlCard,{quality:1,pixelRatio:3}).then(function(dataUrl){
909
+ htmlCard.style.animation='';htmlCard.style.transform='';
910
+ htmlCard.classList.remove('no-shimmer');
911
+ gb.textContent='Recording...';
912
+
913
+ var img=new Image();
914
+ img.onload=function(){
915
+ // 2560x1440 canvas
916
+ var VW=2560,VH=1440;
917
+ var vidCanvas=document.createElement('canvas');vidCanvas.width=VW;vidCanvas.height=VH;
918
+ var vctx=vidCanvas.getContext('2d');
919
+ vctx.imageSmoothingEnabled=true;vctx.imageSmoothingQuality='high';
920
+
921
+ // Scale card to fill ~90% of frame width for maximum impact
922
+ var scale=Math.min((VW*0.88)/img.width,(VH*0.82)/img.height);
923
+ var cw=Math.round(img.width*scale),ch=Math.round(img.height*scale);
924
+ var r=22*3*scale; // border radius
925
+
926
+ var stream=vidCanvas.captureStream(30);
927
+ var chunks=[];
928
+ // Prefer MP4 (Chrome 124+, works on X/Twitter), fallback WebM
929
+ var mime=MediaRecorder.isTypeSupported('video/mp4;codecs=avc1')?'video/mp4;codecs=avc1'
930
+ :MediaRecorder.isTypeSupported('video/mp4')?'video/mp4':'video/webm';
931
+ var ext=mime.startsWith('video/mp4')?'mp4':'webm';
932
+ var recorder=new MediaRecorder(stream,{mimeType:mime,videoBitsPerSecond:30000000});
933
+ recorder.ondataavailable=function(e){if(e.data.size>0)chunks.push(e.data)};
934
+ recorder.onstop=function(){
935
+ var blob=new Blob(chunks,{type:mime.split(';')[0]});
936
+ var a=document.createElement('a');a.download='cchubber-card.'+ext;
937
+ a.href=URL.createObjectURL(blob);a.click();
938
+ gb.innerHTML='<span class="material-symbols-outlined text-sm">share</span> Share';
939
+ gb.disabled=false;showToast('Video saved ('+ext.toUpperCase()+')');
940
+ };
941
+
942
+ // Pre-generate noise texture to fight gradient banding
943
+ var noiseC=document.createElement('canvas');noiseC.width=256;noiseC.height=256;
944
+ var nctx=noiseC.getContext('2d');
945
+ var ndata=nctx.createImageData(256,256);
946
+ 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;}
947
+ nctx.putImageData(ndata,0,0);
948
+
949
+ var duration=6000,startTime=null;
950
+ recorder.start(100);
951
+
952
+ function frame(ts){
953
+ if(!startTime)startTime=ts;
954
+ var elapsed=ts-startTime;
955
+ if(elapsed>=duration){setTimeout(function(){recorder.stop()},300);return}
956
+
957
+ var t=elapsed/duration;
958
+ // Gentle breathe + float — matches the natural feel of the CSS animation
959
+ // ease-in-out via cosine (same curve as CSS ease-in-out)
960
+ // Gentle lateral drift — subtle, no shake
961
+ var drift=Math.sin(t*Math.PI*2)*5;
962
+
963
+ vctx.fillStyle='#000';vctx.fillRect(0,0,VW,VH);
964
+ vctx.save();
965
+ vctx.translate((VW-cw)/2+drift,(VH-ch)/2);
966
+
967
+ // Draw the HTML-captured card image (browser-quality)
968
+ vctx.drawImage(img,0,0,cw,ch);
969
+
970
+ // Clip shimmer + noise to rounded card shape (prevents corner bleed)
971
+ var cr=22*3*scale; // 22px radius * 3 pixelRatio * video scale
972
+ vctx.beginPath();
973
+ vctx.moveTo(cr,0);vctx.lineTo(cw-cr,0);vctx.quadraticCurveTo(cw,0,cw,cr);
974
+ vctx.lineTo(cw,ch-cr);vctx.quadraticCurveTo(cw,ch,cw-cr,ch);vctx.lineTo(cr,ch);
975
+ vctx.quadraticCurveTo(0,ch,0,ch-cr);vctx.lineTo(0,cr);vctx.quadraticCurveTo(0,0,cr,0);
976
+ vctx.closePath();vctx.clip();
977
+
978
+ // Shimmer sweep
979
+ var shimProgress=(t*2)%1;
980
+ var sx=-cw*0.5+shimProgress*cw*2;
981
+ var g=vctx.createLinearGradient(sx,0,sx+cw*0.4,ch);
982
+ g.addColorStop(0,'rgba(255,255,255,0)');
983
+ g.addColorStop(0.3,'rgba(192,193,255,0.04)');
984
+ g.addColorStop(0.45,'rgba(212,187,255,0.06)');
985
+ g.addColorStop(0.55,'rgba(192,193,255,0.06)');
986
+ g.addColorStop(0.7,'rgba(212,187,255,0.04)');
987
+ g.addColorStop(1,'rgba(255,255,255,0)');
988
+ vctx.fillStyle=g;vctx.fillRect(0,0,cw,ch);
989
+
990
+ // Noise dither
991
+ var pat=vctx.createPattern(noiseC,'repeat');
992
+ vctx.fillStyle=pat;vctx.globalAlpha=0.4;vctx.fillRect(0,0,cw,ch);vctx.globalAlpha=1;
993
+
994
+ vctx.restore();
995
+ requestAnimationFrame(frame);
996
+ }
997
+
998
+ requestAnimationFrame(frame);
999
+ };
1000
+ img.src=dataUrl;
1001
+ });
1002
+ }).catch(function(e){
1003
+ htmlCard.style.animation='';htmlCard.style.transform='';htmlCard.classList.remove('no-shimmer');
1004
+ gb.innerHTML='<span class="material-symbols-outlined text-sm">share</span> Share';
1005
+ gb.disabled=false;showToast('Failed: '+e.message);
1006
+ });
1007
+ });
1008
+
1009
+ setR('all');
1010
+ })();
1011
+ </script>
1012
+ </body>
1013
+ </html>`;
1014
+ }
1015
+
1016
+ export function renderRateLimits(usage) {
1017
+ const fiveHour = usage.five_hour;
1018
+ const sevenDay = usage.seven_day;
1019
+ if (!fiveHour && !sevenDay) return '';
1020
+
1021
+ const fivePct = fiveHour?.utilization ?? 0;
1022
+ const sevenPct = sevenDay?.utilization ?? 0;
1023
+ const fiveColor = fivePct > 80 ? '#ffb4ab' : fivePct > 50 ? '#ffb690' : '#c0c1ff';
1024
+ const sevenColor = sevenPct > 80 ? '#ffb4ab' : sevenPct > 50 ? '#ffb690' : '#c0c1ff';
1025
+
1026
+ return `
1027
+ <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);">
1028
+ <div class="p-6 bg-[#0d0e0f]">
1029
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">5-Hour Session</span>
1030
+ <span class="font-mono text-2xl font-bold block" style="color:${fiveColor}">${fivePct}%</span>
1031
+ <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden mt-3 mb-2">
1032
+ <div class="h-full rounded-full" style="width:${fivePct}%;background:${fiveColor}"></div>
1033
+ </div>
1034
+ <span class="text-[10px] text-[#908fa0] block font-mono">${fiveHour?.resets_at ? 'Resets ' + new Date(fiveHour.resets_at).toLocaleTimeString() : ''}</span>
1035
+ </div>
1036
+ <div class="p-6 bg-[#0d0e0f]">
1037
+ <span class="text-[10px] uppercase tracking-[0.05em] text-[#908fa0] block mb-3">7-Day Rolling</span>
1038
+ <span class="font-mono text-2xl font-bold block" style="color:${sevenColor}">${sevenPct}%</span>
1039
+ <div class="h-1.5 bg-[#343536] rounded-full overflow-hidden mt-3 mb-2">
1040
+ <div class="h-full rounded-full" style="width:${sevenPct}%;background:${sevenColor}"></div>
1041
+ </div>
1042
+ <span class="text-[10px] text-[#908fa0] block font-mono">${sevenDay?.resets_at ? 'Resets ' + new Date(sevenDay.resets_at).toLocaleDateString() : ''}</span>
1043
+ </div>
1044
+ </section>`;
1045
+ }