claude-spend 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1020 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Claude Spend</title>
7
+ <style>
8
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
9
+
10
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
+
12
+ :root {
13
+ --bg: #F8F9FC;
14
+ --bg-mesh-1: #EEF0FF;
15
+ --bg-mesh-2: #F0FDFA;
16
+ --white: #FFFFFF;
17
+ --text: #0F172A;
18
+ --text-secondary: #475569;
19
+ --text-tertiary: #94A3B8;
20
+ --border: rgba(0,0,0,0.06);
21
+ --border-strong: rgba(0,0,0,0.1);
22
+
23
+ --indigo: #6366F1;
24
+ --violet: #8B5CF6;
25
+ --purple: #A855F7;
26
+ --teal: #14B8A6;
27
+ --cyan: #06B6D4;
28
+ --emerald: #10B981;
29
+ --amber: #F59E0B;
30
+ --orange: #F97316;
31
+ --rose: #F43F5E;
32
+ --blue: #3B82F6;
33
+
34
+ --gradient-main: linear-gradient(135deg, #6366F1, #8B5CF6, #A855F7);
35
+ --gradient-teal: linear-gradient(135deg, #14B8A6, #06B6D4);
36
+ --gradient-warm: linear-gradient(135deg, #F59E0B, #F97316);
37
+ --gradient-rose: linear-gradient(135deg, #F43F5E, #EC4899);
38
+
39
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
40
+ --shadow-md: 0 4px 16px rgba(0,0,0,0.06);
41
+ --shadow-lg: 0 8px 32px rgba(0,0,0,0.08);
42
+ --shadow-glow: 0 0 40px rgba(99,102,241,0.15);
43
+
44
+ --radius: 16px;
45
+ --radius-sm: 10px;
46
+ --radius-xs: 6px;
47
+ --font: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
48
+ --mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
49
+ }
50
+
51
+ body {
52
+ font-family: var(--font);
53
+ background: var(--bg);
54
+ color: var(--text);
55
+ line-height: 1.6;
56
+ min-height: 100vh;
57
+ overflow-x: hidden;
58
+ }
59
+
60
+ /* Mesh gradient background */
61
+ body::before {
62
+ content: '';
63
+ position: fixed; top: 0; left: 0; right: 0; height: 600px;
64
+ background:
65
+ radial-gradient(ellipse 80% 60% at 10% 0%, rgba(99,102,241,0.12) 0%, transparent 60%),
66
+ radial-gradient(ellipse 60% 50% at 90% 10%, rgba(139,92,246,0.08) 0%, transparent 50%),
67
+ radial-gradient(ellipse 50% 40% at 50% 20%, rgba(20,184,166,0.06) 0%, transparent 50%);
68
+ pointer-events: none; z-index: 0;
69
+ }
70
+
71
+ /* Loading */
72
+ .loading {
73
+ display: flex; align-items: center; justify-content: center;
74
+ min-height: 100vh; flex-direction: column; gap: 20px;
75
+ }
76
+ .spinner {
77
+ width: 36px; height: 36px;
78
+ border: 3px solid var(--border); border-top-color: var(--indigo);
79
+ border-radius: 50%; animation: spin 0.7s linear infinite;
80
+ }
81
+ @keyframes spin { to { transform: rotate(360deg); } }
82
+ .loading-text { color: var(--text-secondary); font-size: 15px; font-weight: 500; }
83
+
84
+ /* Layout */
85
+ .container { max-width: 1200px; margin: 0 auto; padding: 40px 28px 60px; position: relative; z-index: 1; }
86
+
87
+ /* Fade-in animation */
88
+ @keyframes fadeUp {
89
+ from { opacity: 0; transform: translateY(16px); }
90
+ to { opacity: 1; transform: translateY(0); }
91
+ }
92
+ .animate { animation: fadeUp 0.5s ease-out both; }
93
+ .delay-1 { animation-delay: 0.05s; }
94
+ .delay-2 { animation-delay: 0.1s; }
95
+ .delay-3 { animation-delay: 0.15s; }
96
+ .delay-4 { animation-delay: 0.2s; }
97
+ .delay-5 { animation-delay: 0.25s; }
98
+
99
+ /* ---- HEADER ---- */
100
+ .header {
101
+ display: flex; align-items: center; justify-content: space-between;
102
+ margin-bottom: 40px; flex-wrap: wrap; gap: 16px;
103
+ }
104
+ .header-left { display: flex; align-items: center; gap: 14px; }
105
+ .logo-mark {
106
+ width: 40px; height: 40px; border-radius: 12px;
107
+ background: var(--gradient-main);
108
+ display: flex; align-items: center; justify-content: center;
109
+ box-shadow: 0 4px 12px rgba(99,102,241,0.3);
110
+ }
111
+ .logo-mark svg { width: 22px; height: 22px; }
112
+ .header h1 { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
113
+ .header-right { display: flex; align-items: center; gap: 14px; }
114
+ .date-range {
115
+ color: var(--text-tertiary); font-size: 13px; font-weight: 500;
116
+ background: var(--white); padding: 6px 14px; border-radius: 20px;
117
+ border: 1px solid var(--border);
118
+ }
119
+ .refresh-btn {
120
+ background: var(--white); border: 1px solid var(--border);
121
+ color: var(--text-secondary); padding: 7px 16px; border-radius: 20px;
122
+ cursor: pointer; font-size: 13px; font-weight: 500; font-family: var(--font);
123
+ transition: all 0.2s; display: flex; align-items: center; gap: 6px;
124
+ }
125
+ .refresh-btn:hover {
126
+ border-color: var(--indigo); color: var(--indigo);
127
+ box-shadow: 0 2px 8px rgba(99,102,241,0.12);
128
+ }
129
+ .refresh-btn svg { width: 14px; height: 14px; }
130
+
131
+ /* ---- STAT CARDS ---- */
132
+ .stats-row {
133
+ display: grid; grid-template-columns: repeat(4, 1fr);
134
+ gap: 16px; margin-bottom: 32px;
135
+ }
136
+ @media (max-width: 780px) { .stats-row { grid-template-columns: repeat(2, 1fr); } }
137
+
138
+ .stat-card {
139
+ background: var(--white); border-radius: var(--radius);
140
+ padding: 24px; position: relative;
141
+ border: 1px solid var(--border);
142
+ box-shadow: var(--shadow-sm);
143
+ transition: box-shadow 0.2s, transform 0.2s;
144
+ }
145
+ .stat-card:hover { box-shadow: var(--shadow-md); }
146
+ .stat-card .accent-bar {
147
+ position: absolute; top: 0; left: 0; right: 0; height: 3px;
148
+ }
149
+ .stat-label {
150
+ font-size: 12px; font-weight: 600; color: var(--text-tertiary);
151
+ text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 10px;
152
+ }
153
+ .stat-value {
154
+ font-size: 34px; font-weight: 800; letter-spacing: -2px;
155
+ line-height: 1; margin-bottom: 6px;
156
+ color: var(--text);
157
+ }
158
+ .stat-sub { font-size: 13px; color: var(--text-tertiary); font-weight: 500; }
159
+
160
+ /* Tooltips */
161
+ .has-tooltip {
162
+ position: relative;
163
+ cursor: help;
164
+ }
165
+ .has-tooltip .tooltip {
166
+ position: absolute;
167
+ bottom: calc(100% + 10px);
168
+ left: 50%;
169
+ transform: translateX(-50%);
170
+ background: var(--text);
171
+ color: #F8FAFC;
172
+ font-size: 12px;
173
+ font-weight: 500;
174
+ line-height: 1.5;
175
+ padding: 10px 14px;
176
+ border-radius: 10px;
177
+ white-space: normal;
178
+ width: max-content;
179
+ max-width: 280px;
180
+ pointer-events: none;
181
+ opacity: 0;
182
+ transition: opacity 0.15s, transform 0.15s;
183
+ transform: translateX(-50%) translateY(4px);
184
+ z-index: 100;
185
+ box-shadow: 0 8px 24px rgba(0,0,0,0.15);
186
+ text-transform: none;
187
+ letter-spacing: 0;
188
+ text-align: left;
189
+ }
190
+ .has-tooltip .tooltip::after {
191
+ content: '';
192
+ position: absolute;
193
+ top: 100%;
194
+ left: 50%;
195
+ transform: translateX(-50%);
196
+ border: 6px solid transparent;
197
+ border-top-color: var(--text);
198
+ }
199
+ .has-tooltip:hover .tooltip {
200
+ opacity: 1;
201
+ transform: translateX(-50%) translateY(0);
202
+ }
203
+ /* For table headers, tooltip goes below */
204
+ .has-tooltip-below .tooltip {
205
+ bottom: auto;
206
+ top: calc(100% + 10px);
207
+ }
208
+ .has-tooltip-below .tooltip::after {
209
+ top: auto;
210
+ bottom: 100%;
211
+ border-top-color: transparent;
212
+ border-bottom-color: var(--text);
213
+ }
214
+ /* Help icon next to labels */
215
+ .help-icon {
216
+ display: inline-flex;
217
+ align-items: center;
218
+ justify-content: center;
219
+ width: 15px;
220
+ height: 15px;
221
+ border-radius: 50%;
222
+ background: rgba(0,0,0,0.06);
223
+ color: var(--text-tertiary);
224
+ font-size: 10px;
225
+ font-weight: 700;
226
+ margin-left: 5px;
227
+ vertical-align: middle;
228
+ flex-shrink: 0;
229
+ }
230
+
231
+ /* Token explainer */
232
+ .token-explainer {
233
+ font-size: 13px; color: var(--text-tertiary); font-weight: 500;
234
+ text-align: center; padding: 10px 20px; margin: -16px 0 28px;
235
+ line-height: 1.5;
236
+ }
237
+
238
+ /* ---- SECTION HEADINGS ---- */
239
+ .section-header {
240
+ display: flex; align-items: center; gap: 10px;
241
+ margin-bottom: 16px;
242
+ }
243
+ .section-icon {
244
+ width: 28px; height: 28px; border-radius: 8px;
245
+ display: flex; align-items: center; justify-content: center;
246
+ flex-shrink: 0;
247
+ }
248
+ .section-icon svg { width: 16px; height: 16px; }
249
+ .section-title {
250
+ font-size: 16px; font-weight: 700; letter-spacing: -0.3px;
251
+ }
252
+
253
+ /* ---- INSIGHTS ---- */
254
+ .insights-section { margin-bottom: 32px; }
255
+ .insight-card {
256
+ background: var(--white); border-radius: var(--radius-sm);
257
+ padding: 14px 20px; margin-bottom: 8px;
258
+ border: 1px solid var(--border);
259
+ box-shadow: var(--shadow-sm);
260
+ cursor: pointer; transition: box-shadow 0.2s;
261
+ }
262
+ .insight-card:hover { box-shadow: var(--shadow-md); }
263
+ .insight-top {
264
+ display: flex; align-items: center; gap: 12px;
265
+ }
266
+ .insight-arrow {
267
+ margin-left: auto; flex-shrink: 0;
268
+ width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
269
+ color: var(--text-tertiary); transition: transform 0.25s ease;
270
+ }
271
+ .insight-card.expanded .insight-arrow { transform: rotate(180deg); }
272
+ .insight-indicator {
273
+ width: 28px; height: 28px; border-radius: 8px;
274
+ display: flex; align-items: center; justify-content: center;
275
+ flex-shrink: 0; font-size: 14px;
276
+ }
277
+ .insight-card.warning .insight-indicator { background: linear-gradient(135deg, #FEF3C7, #FDE68A); }
278
+ .insight-card.info .insight-indicator { background: linear-gradient(135deg, #E0E7FF, #C7D2FE); }
279
+ .insight-card.neutral .insight-indicator { background: linear-gradient(135deg, #F1F5F9, #E2E8F0); }
280
+ .insight-title { font-size: 14px; font-weight: 700; line-height: 1.4; }
281
+ .insight-oneliner { font-size: 13px; color: var(--text-secondary); margin-left: auto; white-space: nowrap; font-weight: 500; }
282
+ .insight-expand {
283
+ max-height: 0; overflow: hidden; transition: max-height 0.3s ease;
284
+ }
285
+ .insight-card.expanded .insight-expand { max-height: 300px; }
286
+ .insight-detail {
287
+ font-size: 13px; color: var(--text-secondary); line-height: 1.65;
288
+ padding: 12px 0 4px 40px;
289
+ border-top: 1px solid var(--border); margin-top: 12px;
290
+ }
291
+ .insight-action-tip {
292
+ font-size: 13px; line-height: 1.6;
293
+ padding: 8px 0 0 40px; color: var(--indigo); font-weight: 600;
294
+ }
295
+
296
+ /* ---- CHARTS ---- */
297
+ .charts-grid {
298
+ display: grid; grid-template-columns: 5fr 2fr;
299
+ gap: 16px; margin-bottom: 32px;
300
+ }
301
+ @media (max-width: 800px) { .charts-grid { grid-template-columns: 1fr; } }
302
+
303
+ .chart-card {
304
+ background: var(--white); border-radius: var(--radius);
305
+ padding: 24px; border: 1px solid var(--border);
306
+ box-shadow: var(--shadow-sm);
307
+ }
308
+ .chart-card h3 {
309
+ font-size: 13px; font-weight: 700; color: var(--text-tertiary);
310
+ text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 20px;
311
+ }
312
+ canvas { display: block; }
313
+ .legend {
314
+ display: flex; gap: 20px; margin-top: 16px;
315
+ font-size: 12px; font-weight: 500; color: var(--text-secondary);
316
+ }
317
+ .legend-item { display: flex; align-items: center; gap: 7px; }
318
+ .legend-dot { width: 10px; height: 10px; border-radius: 4px; flex-shrink: 0; }
319
+
320
+ /* ---- TOP PROMPTS ---- */
321
+ .top-prompts { margin-bottom: 32px; }
322
+ .prompts-card {
323
+ background: var(--white); border-radius: var(--radius);
324
+ border: 1px solid var(--border); box-shadow: var(--shadow-sm);
325
+ overflow: hidden;
326
+ }
327
+ .prompt-row {
328
+ display: grid; grid-template-columns: 36px 1fr auto;
329
+ gap: 14px; padding: 16px 24px; align-items: start;
330
+ border-bottom: 1px solid var(--border);
331
+ cursor: pointer; transition: background 0.15s;
332
+ }
333
+ .prompt-row:last-child { border-bottom: none; }
334
+ .prompt-row:hover { background: #FAFBFF; }
335
+ .prompt-rank {
336
+ width: 28px; height: 28px; border-radius: 8px;
337
+ background: var(--bg); display: flex; align-items: center; justify-content: center;
338
+ font-size: 12px; font-weight: 700; color: var(--text-secondary);
339
+ }
340
+ .prompt-row:nth-child(1) .prompt-rank { background: var(--gradient-main); color: white; }
341
+ .prompt-row:nth-child(2) .prompt-rank { background: var(--gradient-teal); color: white; }
342
+ .prompt-row:nth-child(3) .prompt-rank { background: var(--gradient-warm); color: white; }
343
+ .prompt-text {
344
+ font-size: 14px; font-weight: 500; line-height: 1.5;
345
+ display: -webkit-box; -webkit-line-clamp: 2;
346
+ -webkit-box-orient: vertical; overflow: hidden;
347
+ }
348
+ .prompt-meta { font-size: 12px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500; }
349
+ .token-bar-wrap {
350
+ display: flex; gap: 1px; height: 4px; margin-top: 8px;
351
+ border-radius: 4px; overflow: hidden;
352
+ }
353
+ .token-bar-in { background: var(--indigo); height: 100%; border-radius: 4px 0 0 4px; }
354
+ .token-bar-out { background: var(--teal); height: 100%; border-radius: 0 4px 4px 0; }
355
+ .prompt-tokens { text-align: right; white-space: nowrap; padding-top: 2px; }
356
+ .prompt-tokens .value {
357
+ font-size: 16px; font-weight: 700; font-family: var(--mono);
358
+ letter-spacing: -0.5px;
359
+ }
360
+ .prompt-tokens .sub { font-size: 11px; color: var(--text-tertiary); font-weight: 500; margin-top: 2px; }
361
+
362
+ /* ---- SESSIONS ---- */
363
+ .sessions-section { margin-bottom: 40px; }
364
+ .sessions-toolbar {
365
+ display: flex; align-items: center; justify-content: space-between;
366
+ margin-bottom: 14px; gap: 12px; flex-wrap: wrap;
367
+ }
368
+ .session-search {
369
+ background: var(--white); border: 1px solid var(--border);
370
+ color: var(--text); padding: 9px 16px; border-radius: 10px;
371
+ font-size: 14px; font-weight: 500; width: 300px; outline: none;
372
+ font-family: var(--font); transition: all 0.2s;
373
+ }
374
+ .session-search::placeholder { color: var(--text-tertiary); }
375
+ .session-search:focus { border-color: var(--indigo); box-shadow: 0 0 0 3px rgba(99,102,241,0.1); }
376
+ .session-count { color: var(--text-tertiary); font-size: 13px; font-weight: 600; }
377
+
378
+ .sessions-card {
379
+ background: var(--white); border-radius: var(--radius);
380
+ border: 1px solid var(--border); box-shadow: var(--shadow-sm);
381
+ overflow: visible;
382
+ }
383
+ .sessions-table { width: 100%; border-collapse: collapse; font-size: 14px; }
384
+ .sessions-table th {
385
+ text-align: left; padding: 12px 16px;
386
+ font-size: 11px; font-weight: 700; text-transform: uppercase;
387
+ letter-spacing: 0.6px; color: var(--text-tertiary);
388
+ border-bottom: 1px solid var(--border);
389
+ cursor: pointer; user-select: none; white-space: nowrap;
390
+ background: #FAFBFC;
391
+ }
392
+ .sessions-table th:hover { color: var(--text-secondary); }
393
+ .sessions-table th.sorted { color: var(--indigo); }
394
+ .sessions-table td {
395
+ padding: 12px 16px; border-bottom: 1px solid var(--border); vertical-align: middle;
396
+ }
397
+ .sessions-table tbody tr { cursor: pointer; transition: background 0.1s; }
398
+ .sessions-table tbody tr:hover td { background: #FAFBFF; }
399
+ .sessions-table tbody tr:last-child td { border-bottom: none; }
400
+ .prompt-preview {
401
+ max-width: 340px; overflow: hidden; text-overflow: ellipsis;
402
+ white-space: nowrap; font-weight: 500;
403
+ }
404
+ .token-num {
405
+ font-family: var(--mono); font-size: 13px; font-weight: 600;
406
+ text-align: right; white-space: nowrap; letter-spacing: -0.3px;
407
+ }
408
+ .model-badge {
409
+ display: inline-flex; align-items: center; gap: 5px;
410
+ padding: 3px 10px; border-radius: 20px;
411
+ font-size: 12px; font-weight: 600;
412
+ }
413
+ .model-opus { background: #EEF2FF; color: #4F46E5; }
414
+ .model-sonnet { background: #ECFDF5; color: #059669; }
415
+ .model-haiku { background: #FFF7ED; color: #EA580C; }
416
+ .model-unknown { background: #F1F5F9; color: #64748B; }
417
+ .model-dot { width: 6px; height: 6px; border-radius: 50%; }
418
+ .model-opus .model-dot { background: #6366F1; }
419
+ .model-sonnet .model-dot { background: #10B981; }
420
+ .model-haiku .model-dot { background: #F97316; }
421
+ .date-cell { white-space: nowrap; color: var(--text-secondary); font-weight: 500; }
422
+ .project-tag {
423
+ font-size: 11px; color: var(--text-tertiary); display: block;
424
+ margin-top: 1px; font-weight: 500;
425
+ max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
426
+ }
427
+
428
+ /* ---- DRILLDOWN ---- */
429
+ .drilldown {
430
+ background: var(--white); border-radius: var(--radius);
431
+ padding: 28px; margin-bottom: 32px; display: none;
432
+ border: 1px solid var(--border); box-shadow: var(--shadow-lg);
433
+ }
434
+ .drilldown.open { display: block; animation: fadeUp 0.3s ease-out; }
435
+ .drilldown-header {
436
+ display: flex; align-items: flex-start; justify-content: space-between;
437
+ margin-bottom: 24px; gap: 16px;
438
+ }
439
+ .drilldown-title { font-size: 16px; font-weight: 700; line-height: 1.4; }
440
+ .drilldown-meta { font-size: 13px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500; }
441
+ .drilldown-close {
442
+ background: var(--bg); border: none; color: var(--text-secondary);
443
+ width: 32px; height: 32px; border-radius: 8px; cursor: pointer;
444
+ font-size: 18px; display: flex; align-items: center; justify-content: center;
445
+ flex-shrink: 0; transition: all 0.15s;
446
+ }
447
+ .drilldown-close:hover { background: #EEF2FF; color: var(--indigo); }
448
+
449
+ .query-list { display: flex; flex-direction: column; gap: 8px; }
450
+ .query-item {
451
+ display: grid; grid-template-columns: 36px 1fr auto;
452
+ gap: 14px; padding: 14px 16px; border-radius: var(--radius-sm);
453
+ background: var(--bg); align-items: start;
454
+ transition: background 0.1s;
455
+ }
456
+ .query-item:hover { background: #EEF2FF; }
457
+ .query-num {
458
+ width: 28px; height: 28px; border-radius: 8px; background: var(--white);
459
+ border: 1px solid var(--border);
460
+ font-size: 12px; font-weight: 700; color: var(--text-tertiary);
461
+ display: flex; align-items: center; justify-content: center;
462
+ }
463
+ .query-prompt { font-size: 14px; font-weight: 500; line-height: 1.5; word-break: break-word; }
464
+ .query-prompt.no-prompt { color: var(--text-tertiary); font-style: italic; font-weight: 400; }
465
+ .query-tokens-col { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; white-space: nowrap; }
466
+ .query-tokens-col .total { font-family: var(--mono); font-size: 14px; font-weight: 700; }
467
+ .query-tokens-col .detail { font-size: 12px; color: var(--text-tertiary); font-weight: 500; }
468
+
469
+ /* ---- FOOTER ---- */
470
+ .footer {
471
+ text-align: center; padding: 24px;
472
+ color: var(--text-tertiary); font-size: 13px; font-weight: 500;
473
+ }
474
+ .footer a { color: var(--indigo); text-decoration: none; font-weight: 600; }
475
+ .footer a:hover { text-decoration: underline; }
476
+
477
+ /* Privacy notice */
478
+ .privacy-notice {
479
+ display: flex; align-items: center; justify-content: center; gap: 6px;
480
+ font-size: 13px; font-weight: 500; color: var(--text-secondary);
481
+ background: rgba(99,102,241,0.06); border: 1px solid rgba(99,102,241,0.1);
482
+ padding: 8px 16px; border-radius: 10px; margin-bottom: 20px;
483
+ }
484
+
485
+ /* Hero / value prop */
486
+ .hero-section {
487
+ text-align: center; margin-bottom: 36px; padding: 0 20px;
488
+ }
489
+ .hero-title {
490
+ font-size: 28px; font-weight: 800; letter-spacing: -1px;
491
+ background: var(--gradient-main); -webkit-background-clip: text;
492
+ -webkit-text-fill-color: transparent; background-clip: text;
493
+ margin-bottom: 10px; line-height: 1.3;
494
+ }
495
+ .hero-subtitle {
496
+ font-size: 15px; color: var(--text-secondary); font-weight: 500;
497
+ max-width: 600px; margin: 0 auto; line-height: 1.7;
498
+ }
499
+ </style>
500
+ </head>
501
+ <body>
502
+
503
+ <div id="loading" class="loading">
504
+ <div class="spinner"></div>
505
+ <div class="loading-text">Reading your Claude Code sessions...</div>
506
+ </div>
507
+
508
+ <div id="app" class="container" style="display:none">
509
+
510
+ <!-- Privacy notice -->
511
+ <div class="privacy-notice animate">
512
+ &#128274; All data stays on your machine. Nothing is sent anywhere.
513
+ </div>
514
+
515
+ <!-- Header -->
516
+ <div class="header animate">
517
+ <div class="header-left">
518
+ <div class="logo-mark">
519
+ <svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
520
+ </div>
521
+ <h1>Claude Spend</h1>
522
+ </div>
523
+ <div class="header-right">
524
+ <span id="dateRange" class="date-range"></span>
525
+ <button class="refresh-btn" onclick="refreshData()">
526
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
527
+ Refresh
528
+ </button>
529
+ </div>
530
+ </div>
531
+
532
+ <!-- Value proposition -->
533
+ <div class="hero-section animate delay-1">
534
+ <h2 class="hero-title">See where your tokens actually go</h2>
535
+ <p class="hero-subtitle">Your Claude Code usage, visualized.</p>
536
+ </div>
537
+
538
+ <!-- Stats -->
539
+ <div class="stats-row" id="statsRow"></div>
540
+ <div id="tokenExplainer" class="token-explainer animate delay-2">
541
+ Tokens are how AI measures text -- roughly 1 token = 1 word. Hover over any label with a <span style="display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border-radius:50%;background:rgba(0,0,0,0.08);font-size:10px;font-weight:700;vertical-align:middle;">?</span> for an explanation.
542
+ </div>
543
+
544
+ <!-- Insights -->
545
+ <div id="insightsSection" class="insights-section" style="display:none">
546
+ <div class="section-header animate">
547
+ <div class="section-icon" style="background:linear-gradient(135deg,#FEF3C7,#FDE68A)">
548
+ <svg viewBox="0 0 24 24" fill="none" stroke="#D97706" stroke-width="2.5" stroke-linecap="round"><path d="M12 2a7 7 0 0 1 4 12.75V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.25A7 7 0 0 1 12 2z"/><path d="M9 21h6"/></svg>
549
+ </div>
550
+ <div class="section-title">Insights</div>
551
+ </div>
552
+ <div id="insightsList"></div>
553
+ </div>
554
+
555
+ <!-- Charts -->
556
+ <div class="charts-grid animate delay-3">
557
+ <div class="chart-card">
558
+ <h3 class="has-tooltip has-tooltip-below" style="display:inline-block">Tokens per Day<div class="tooltip">How many tokens you used each day. Taller bars mean heavier usage days. The indigo portion is what Claude read, the teal is what Claude wrote.</div></h3>
559
+ <canvas id="dailyChart"></canvas>
560
+ <div class="legend">
561
+ <div class="legend-item"><div class="legend-dot" style="background:var(--indigo)"></div> Read by Claude (your messages + context)</div>
562
+ <div class="legend-item"><div class="legend-dot" style="background:var(--teal)"></div> Written by Claude (responses)</div>
563
+ </div>
564
+ </div>
565
+ <div class="chart-card">
566
+ <h3>By Model</h3>
567
+ <canvas id="modelChart"></canvas>
568
+ <div id="modelLegend" class="legend" style="flex-direction:column; gap:12px; margin-top:20px;"></div>
569
+ </div>
570
+ </div>
571
+
572
+ <!-- Most Expensive Prompts -->
573
+ <div class="top-prompts animate delay-4">
574
+ <div class="section-header">
575
+ <div class="section-icon" style="background:linear-gradient(135deg,#E0E7FF,#C7D2FE)">
576
+ <svg viewBox="0 0 24 24" fill="none" stroke="#6366F1" stroke-width="2.5" stroke-linecap="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
577
+ </div>
578
+ <div class="section-title has-tooltip has-tooltip-below" style="display:inline-flex">Most Expensive Prompts<div class="tooltip">Your top 20 individual messages ranked by how many tokens they used. Short vague messages like "Yes" often rank highest because they trigger long chains of tool calls where Claude tries to figure out what you meant.</div></div>
579
+ </div>
580
+ <div class="prompts-card" id="topPromptsList"></div>
581
+ </div>
582
+
583
+ <!-- Drill-down -->
584
+ <div id="drilldown" class="drilldown">
585
+ <div class="drilldown-header">
586
+ <div>
587
+ <div id="drilldownTitle" class="drilldown-title"></div>
588
+ <div id="drilldownMeta" class="drilldown-meta"></div>
589
+ </div>
590
+ <button class="drilldown-close" onclick="closeDrilldown()">&times;</button>
591
+ </div>
592
+ <div id="queryList" class="query-list"></div>
593
+ </div>
594
+
595
+ <!-- All Sessions -->
596
+ <div class="sessions-section animate delay-5">
597
+ <div class="section-header">
598
+ <div class="section-icon" style="background:linear-gradient(135deg,#ECFDF5,#D1FAE5)">
599
+ <svg viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/></svg>
600
+ </div>
601
+ <div class="section-title">All Sessions</div>
602
+ </div>
603
+ <div class="sessions-toolbar">
604
+ <input type="text" class="session-search" id="searchInput" placeholder="Search prompts, models, projects...">
605
+ <span id="sessionCount" class="session-count"></span>
606
+ </div>
607
+ <div class="sessions-card">
608
+ <table class="sessions-table">
609
+ <thead>
610
+ <tr>
611
+ <th data-sort="date">Date</th>
612
+ <th data-sort="prompt">What you asked</th>
613
+ <th data-sort="model" class="has-tooltip has-tooltip-below">Model<div class="tooltip">Which AI model was used. Opus is the most capable (and uses more tokens), Sonnet is faster and lighter.</div></th>
614
+ <th data-sort="queries" style="text-align:right" class="has-tooltip has-tooltip-below">Messages<div class="tooltip">How many back-and-forth messages happened in this conversation, including automatic tool calls Claude made.</div></th>
615
+ <th data-sort="total" style="text-align:right" class="sorted has-tooltip has-tooltip-below">Total tokens<div class="tooltip">The total number of tokens used in this conversation. Higher means more expensive. Long conversations cost more because Claude re-reads everything each turn.</div></th>
616
+ <th data-sort="input" style="text-align:right" class="has-tooltip has-tooltip-below">Read<div class="tooltip">Tokens Claude read: your messages, the conversation history, files, and system context. This grows with each message because the full history gets re-sent.</div></th>
617
+ <th data-sort="output" style="text-align:right" class="has-tooltip has-tooltip-below">Written<div class="tooltip">Tokens Claude wrote back: responses, code, explanations. Usually a small fraction of the total.</div></th>
618
+ </tr>
619
+ </thead>
620
+ <tbody id="sessionsBody"></tbody>
621
+ </table>
622
+ </div>
623
+ </div>
624
+
625
+ <div class="footer">
626
+ Made with <a href="https://claude.ai/code" target="_blank">Claude Code</a> and &#10084; by <a href="https://www.linkedin.com/in/aniketparihar/" target="_blank">Aniket</a>
627
+ </div>
628
+ </div>
629
+
630
+ <script>
631
+ let DATA = null;
632
+ let currentSort = { key: 'total', dir: 'desc' };
633
+ let searchQuery = '';
634
+
635
+ function fmt(n) {
636
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
637
+ if (n >= 10_000) return (n / 1_000).toFixed(0) + 'K';
638
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
639
+ return n.toLocaleString();
640
+ }
641
+ function fmtFull(n) { return n.toLocaleString(); }
642
+
643
+ function modelClass(m) {
644
+ if (m.includes('opus')) return 'model-opus';
645
+ if (m.includes('sonnet')) return 'model-sonnet';
646
+ if (m.includes('haiku')) return 'model-haiku';
647
+ return 'model-unknown';
648
+ }
649
+ function modelShort(m) {
650
+ if (m.includes('opus')) return 'Opus';
651
+ if (m.includes('sonnet')) return 'Sonnet';
652
+ if (m.includes('haiku')) return 'Haiku';
653
+ return m;
654
+ }
655
+ function projectShort(p) {
656
+ return p.replace(/^C--Users-[^-]+-?/, '').replace(/^Projects-?/, '').replace(/-/g, '/') || '~';
657
+ }
658
+ function escapeHtml(s) {
659
+ const d = document.createElement('div'); d.textContent = s; return d.innerHTML;
660
+ }
661
+ function formatDate(d) {
662
+ if (!d) return '';
663
+ const parts = d.split('-');
664
+ const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
665
+ return `${months[parseInt(parts[1])-1]} ${parseInt(parts[2])}`;
666
+ }
667
+
668
+ async function fetchData() {
669
+ const res = await fetch('/api/data');
670
+ DATA = await res.json();
671
+ render();
672
+ }
673
+ async function refreshData() {
674
+ document.getElementById('loading').style.display = 'flex';
675
+ document.getElementById('app').style.display = 'none';
676
+ await fetch('/api/refresh');
677
+ await fetchData();
678
+ }
679
+
680
+ function render() {
681
+ document.getElementById('loading').style.display = 'none';
682
+ document.getElementById('app').style.display = 'block';
683
+ renderStats();
684
+ renderInsights();
685
+ renderDailyChart();
686
+ renderModelChart();
687
+ renderTopPrompts();
688
+ renderSessions();
689
+ }
690
+
691
+ // Stats
692
+ function renderStats() {
693
+ const t = DATA.totals;
694
+ const range = t.dateRange ? `${formatDate(t.dateRange.from)} - ${formatDate(t.dateRange.to)}` : '';
695
+ document.getElementById('dateRange').textContent = range;
696
+
697
+ const cards = [
698
+ { label: 'Total Usage', value: fmt(t.totalTokens), sub: `${fmt(t.totalInputTokens)} read by Claude + ${fmt(t.totalOutputTokens)} written back`,
699
+ tip: 'The total number of tokens used across all your conversations. This includes everything Claude reads (your messages, conversation history, files) plus everything Claude writes back.' },
700
+ { label: 'Conversations', value: fmtFull(t.totalSessions), sub: `Each one used ~${fmt(t.avgTokensPerSession)} tokens on average`,
701
+ tip: 'Each time you start Claude Code and begin chatting, that counts as one conversation. A new conversation starts fresh with no prior context.' },
702
+ { label: 'Messages Sent', value: fmtFull(t.totalQueries), sub: `Each message cost ~${fmt(t.avgTokensPerQuery)} tokens on average`,
703
+ tip: 'Every time you hit Enter and send something to Claude, that is one message. This includes follow-up tool calls Claude makes automatically behind the scenes.' },
704
+ { label: 'Claude Wrote', value: fmt(t.totalOutputTokens), sub: `Only ${((t.totalOutputTokens / Math.max(t.totalTokens, 1)) * 100).toFixed(1)}% of total -- most usage is from re-reading context`,
705
+ tip: 'The tokens Claude spent writing responses, code, and explanations. This is usually a tiny fraction of total usage because most tokens go toward re-reading your conversation history.' },
706
+ ];
707
+
708
+ document.getElementById('statsRow').innerHTML = cards.map((c, i) => `
709
+ <div class="stat-card animate delay-${i + 1}">
710
+ <div class="stat-label has-tooltip">${c.label}<span class="help-icon">?</span><div class="tooltip">${c.tip}</div></div>
711
+ <div class="stat-value">${c.value}</div>
712
+ <div class="stat-sub">${c.sub}</div>
713
+ </div>`).join('');
714
+ }
715
+
716
+ // Insights
717
+ function renderInsights() {
718
+ const insights = DATA.insights || [];
719
+ const section = document.getElementById('insightsSection');
720
+ if (!insights.length) { section.style.display = 'none'; return; }
721
+
722
+ section.style.display = 'block';
723
+ const icons = { warning: '!', info: 'i', neutral: '~' };
724
+ const emojis = { warning: '\u26A0\uFE0F', info: '\uD83D\uDCA1', neutral: '\uD83D\uDCC5' };
725
+
726
+ document.getElementById('insightsList').innerHTML = insights.map((ins, i) => {
727
+ const detailHtml = ins.description ? `<div class="insight-detail">${escapeHtml(ins.description)}</div>` : '';
728
+ const actionHtml = ins.action ? `<div class="insight-action-tip">${escapeHtml(ins.action)}</div>` : '';
729
+ return `<div class="insight-card ${ins.type} animate delay-${Math.min(i + 2, 5)}" onclick="this.classList.toggle('expanded')">
730
+ <div class="insight-top">
731
+ <div class="insight-indicator">${emojis[ins.type] || ''}</div>
732
+ <div class="insight-title">${escapeHtml(ins.title)}</div>
733
+ <div class="insight-arrow"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 4.5L6 7.5L9 4.5"/></svg></div>
734
+ </div>
735
+ <div class="insight-expand">
736
+ ${detailHtml}
737
+ ${actionHtml}
738
+ </div>
739
+ </div>`;
740
+ }).join('');
741
+ }
742
+
743
+ // Daily chart
744
+ function renderDailyChart() {
745
+ const canvas = document.getElementById('dailyChart');
746
+ const ctx = canvas.getContext('2d');
747
+ const data = DATA.dailyUsage;
748
+ if (!data.length) return;
749
+
750
+ const dpr = window.devicePixelRatio || 1;
751
+ const w = canvas.parentElement.clientWidth - 48;
752
+ const h = 200;
753
+ canvas.width = w * dpr; canvas.height = h * dpr;
754
+ canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
755
+ ctx.scale(dpr, dpr);
756
+ ctx.clearRect(0, 0, w, h);
757
+
758
+ const maxTotal = Math.max(...data.map(d => d.totalTokens));
759
+ const barW = Math.max(8, Math.min(32, (w - 52) / data.length - 3));
760
+ const gap = 3;
761
+ const chartH = h - 36;
762
+ const startX = 48;
763
+
764
+ // Grid
765
+ ctx.font = '500 10px Inter, system-ui';
766
+ ctx.fillStyle = '#94A3B8';
767
+ ctx.textAlign = 'right';
768
+ for (let i = 0; i <= 4; i++) {
769
+ const val = (maxTotal / 4) * i;
770
+ const y = chartH - (chartH * i / 4) + 8;
771
+ ctx.fillText(fmt(val), startX - 10, y + 3);
772
+ ctx.strokeStyle = 'rgba(0,0,0,0.04)'; ctx.lineWidth = 1;
773
+ ctx.beginPath(); ctx.moveTo(startX, y); ctx.lineTo(w, y); ctx.stroke();
774
+ }
775
+
776
+ // Bars with gradient
777
+ const inGrad = ctx.createLinearGradient(0, chartH + 8, 0, 0);
778
+ inGrad.addColorStop(0, '#818CF8'); inGrad.addColorStop(1, '#6366F1');
779
+ const outGrad = ctx.createLinearGradient(0, chartH + 8, 0, 0);
780
+ outGrad.addColorStop(0, '#2DD4BF'); outGrad.addColorStop(1, '#14B8A6');
781
+
782
+ data.forEach((d, i) => {
783
+ const x = startX + i * (barW + gap);
784
+ const baseY = chartH + 8;
785
+ const r = Math.min(4, barW / 2);
786
+
787
+ const outH = (d.outputTokens / maxTotal) * chartH;
788
+ if (outH > 0) {
789
+ ctx.fillStyle = outGrad;
790
+ ctx.beginPath(); roundedRect(ctx, x, baseY - outH, barW, outH, r); ctx.fill();
791
+ }
792
+
793
+ const inH = (d.inputTokens / maxTotal) * chartH;
794
+ if (inH > 0) {
795
+ ctx.fillStyle = inGrad;
796
+ ctx.beginPath(); roundedRect(ctx, x, baseY - outH - inH, barW, inH, r); ctx.fill();
797
+ }
798
+ });
799
+
800
+ // X labels
801
+ ctx.fillStyle = '#94A3B8'; ctx.textAlign = 'center';
802
+ ctx.font = '500 10px Inter, system-ui';
803
+ const step = Math.max(1, Math.floor(data.length / 7));
804
+ data.forEach((d, i) => {
805
+ if (i % step === 0 || i === data.length - 1) {
806
+ const x = startX + i * (barW + gap) + barW / 2;
807
+ ctx.fillText(formatDate(d.date), x, chartH + 24);
808
+ }
809
+ });
810
+ }
811
+
812
+ function roundedRect(ctx, x, y, w, h, r) {
813
+ if (h <= 0) return;
814
+ r = Math.min(r, h / 2, w / 2);
815
+ ctx.moveTo(x + r, y);
816
+ ctx.lineTo(x + w - r, y);
817
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
818
+ ctx.lineTo(x + w, y + h - r);
819
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
820
+ ctx.lineTo(x + r, y + h);
821
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
822
+ ctx.lineTo(x, y + r);
823
+ ctx.quadraticCurveTo(x, y, x + r, y);
824
+ ctx.closePath();
825
+ }
826
+
827
+ // Model donut
828
+ function renderModelChart() {
829
+ const canvas = document.getElementById('modelChart');
830
+ const ctx = canvas.getContext('2d');
831
+ const data = DATA.modelBreakdown;
832
+ if (!data.length) return;
833
+
834
+ const dpr = window.devicePixelRatio || 1;
835
+ const size = Math.min(180, canvas.parentElement.clientWidth - 48);
836
+ canvas.width = size * dpr; canvas.height = size * dpr;
837
+ canvas.style.width = size + 'px'; canvas.style.height = size + 'px';
838
+ ctx.scale(dpr, dpr);
839
+
840
+ const cx = size/2, cy = size/2, r = size/2 - 6, innerR = r * 0.6;
841
+ const total = data.reduce((s, d) => s + d.totalTokens, 0);
842
+
843
+ const modelColors = { opus: ['#6366F1','#818CF8'], sonnet: ['#10B981','#34D399'], haiku: ['#F97316','#FB923C'] };
844
+ function getColors(m) {
845
+ for (const [k, c] of Object.entries(modelColors)) { if (m.includes(k)) return c; }
846
+ return ['#94A3B8','#CBD5E1'];
847
+ }
848
+
849
+ let angle = -Math.PI / 2;
850
+ const slices = [...data].sort((a, b) => b.totalTokens - a.totalTokens);
851
+
852
+ slices.forEach(d => {
853
+ const sa = (d.totalTokens / total) * Math.PI * 2;
854
+ const [c1, c2] = getColors(d.model);
855
+ const grad = ctx.createConicGradient(angle, cx, cy);
856
+ grad.addColorStop(0, c1); grad.addColorStop(1, c2);
857
+
858
+ ctx.beginPath();
859
+ ctx.arc(cx, cy, r, angle, angle + sa);
860
+ ctx.arc(cx, cy, innerR, angle + sa, angle, true);
861
+ ctx.closePath();
862
+ ctx.fillStyle = c1;
863
+ ctx.fill();
864
+
865
+ // Gap between slices
866
+ ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 2; ctx.stroke();
867
+ angle += sa;
868
+ });
869
+
870
+ ctx.fillStyle = '#0F172A'; ctx.textAlign = 'center';
871
+ ctx.font = '800 17px Inter, system-ui';
872
+ ctx.fillText(fmt(total), cx, cy + 2);
873
+ ctx.font = '500 10px Inter, system-ui';
874
+ ctx.fillStyle = '#94A3B8';
875
+ ctx.fillText('total', cx, cy + 16);
876
+
877
+ document.getElementById('modelLegend').innerHTML = slices.map(d => {
878
+ const pct = ((d.totalTokens / total) * 100).toFixed(1);
879
+ return `<div class="legend-item">
880
+ <div class="legend-dot" style="background:${getColors(d.model)[0]}"></div>
881
+ <span><strong>${modelShort(d.model)}</strong> &ndash; ${fmt(d.totalTokens)} (${pct}%)</span>
882
+ </div>`;
883
+ }).join('');
884
+ }
885
+
886
+ // Top prompts
887
+ function renderTopPrompts() {
888
+ const prompts = DATA.topPrompts;
889
+ if (!prompts.length) return;
890
+ const maxTokens = prompts[0].totalTokens;
891
+
892
+ document.getElementById('topPromptsList').innerHTML = prompts.map((p, i) => {
893
+ const inPct = (p.inputTokens / p.totalTokens) * 100;
894
+ return `<div class="prompt-row" onclick="openDrilldown('${p.sessionId}')">
895
+ <div class="prompt-rank">${i + 1}</div>
896
+ <div>
897
+ <div class="prompt-text">${escapeHtml(p.prompt)}</div>
898
+ <div class="prompt-meta">${formatDate(p.date)} &middot; ${modelShort(p.model)}</div>
899
+ <div class="token-bar-wrap" style="width:${Math.max(10, (p.totalTokens / maxTokens) * 100)}%">
900
+ <div class="token-bar-in" style="width:${inPct}%"></div>
901
+ <div class="token-bar-out" style="width:${100 - inPct}%"></div>
902
+ </div>
903
+ </div>
904
+ <div class="prompt-tokens">
905
+ <div class="value">${fmt(p.totalTokens)}</div>
906
+ <div class="sub">${fmt(p.inputTokens)} read &middot; ${fmt(p.outputTokens)} written</div>
907
+ </div>
908
+ </div>`;
909
+ }).join('');
910
+ }
911
+
912
+ // Sessions
913
+ function renderSessions() {
914
+ let sessions = [...DATA.sessions];
915
+ if (searchQuery) {
916
+ const q = searchQuery.toLowerCase();
917
+ sessions = sessions.filter(s =>
918
+ s.firstPrompt.toLowerCase().includes(q) ||
919
+ s.model.toLowerCase().includes(q) ||
920
+ projectShort(s.project).toLowerCase().includes(q)
921
+ );
922
+ }
923
+
924
+ const sortFns = {
925
+ date: (a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''),
926
+ prompt: (a, b) => a.firstPrompt.localeCompare(b.firstPrompt),
927
+ model: (a, b) => a.model.localeCompare(b.model),
928
+ queries: (a, b) => a.queryCount - b.queryCount,
929
+ total: (a, b) => a.totalTokens - b.totalTokens,
930
+ input: (a, b) => a.inputTokens - b.inputTokens,
931
+ output: (a, b) => a.outputTokens - b.outputTokens,
932
+ };
933
+ const fn = sortFns[currentSort.key] || sortFns.total;
934
+ sessions.sort((a, b) => currentSort.dir === 'desc' ? fn(b, a) : fn(a, b));
935
+
936
+ document.getElementById('sessionCount').textContent = `${sessions.length} sessions`;
937
+
938
+ document.getElementById('sessionsBody').innerHTML = sessions.map(s => `
939
+ <tr onclick="openDrilldown('${s.sessionId}')">
940
+ <td class="date-cell">
941
+ ${formatDate(s.date)}
942
+ <span class="project-tag" title="${escapeHtml(projectShort(s.project))}">${escapeHtml(projectShort(s.project))}</span>
943
+ </td>
944
+ <td><div class="prompt-preview" title="${escapeHtml(s.firstPrompt)}">${escapeHtml(s.firstPrompt)}</div></td>
945
+ <td><span class="model-badge ${modelClass(s.model)}"><span class="model-dot"></span>${modelShort(s.model)}</span></td>
946
+ <td class="token-num">${s.queryCount}</td>
947
+ <td class="token-num" style="font-weight:700">${fmt(s.totalTokens)}</td>
948
+ <td class="token-num">${fmt(s.inputTokens)}</td>
949
+ <td class="token-num">${fmt(s.outputTokens)}</td>
950
+ </tr>
951
+ `).join('');
952
+ }
953
+
954
+ // Sort
955
+ document.querySelectorAll('.sessions-table th[data-sort]').forEach(th => {
956
+ th.addEventListener('click', () => {
957
+ const key = th.dataset.sort;
958
+ if (currentSort.key === key) { currentSort.dir = currentSort.dir === 'desc' ? 'asc' : 'desc'; }
959
+ else { currentSort = { key, dir: 'desc' }; }
960
+ document.querySelectorAll('.sessions-table th').forEach(t => t.classList.remove('sorted'));
961
+ th.classList.add('sorted');
962
+ renderSessions();
963
+ });
964
+ });
965
+
966
+ document.getElementById('searchInput').addEventListener('input', e => {
967
+ searchQuery = e.target.value;
968
+ renderSessions();
969
+ });
970
+
971
+ // Drill-down
972
+ function openDrilldown(sessionId) {
973
+ const session = DATA.sessions.find(s => s.sessionId === sessionId);
974
+ if (!session) return;
975
+
976
+ document.getElementById('drilldownTitle').textContent = session.firstPrompt.substring(0, 140);
977
+ document.getElementById('drilldownMeta').textContent =
978
+ `${formatDate(session.date)} \u00B7 ${modelShort(session.model)} \u00B7 ${session.queryCount} messages \u00B7 ${fmt(session.totalTokens)} tokens used`;
979
+
980
+ const grouped = [];
981
+ let current = null;
982
+ for (const q of session.queries) {
983
+ if (q.userPrompt) {
984
+ if (current) grouped.push(current);
985
+ current = { prompt: q.userPrompt, inputTokens: q.inputTokens, outputTokens: q.outputTokens, totalTokens: q.totalTokens, continuations: 0 };
986
+ } else if (current) {
987
+ current.inputTokens += q.inputTokens;
988
+ current.outputTokens += q.outputTokens;
989
+ current.totalTokens += q.totalTokens;
990
+ current.continuations++;
991
+ }
992
+ }
993
+ if (current) grouped.push(current);
994
+
995
+ document.getElementById('queryList').innerHTML = grouped.map((q, i) => {
996
+ const cont = q.continuations > 0 ? ` + ${q.continuations} tool uses` : '';
997
+ return `<div class="query-item">
998
+ <div class="query-num">${i + 1}</div>
999
+ <div class="query-prompt">${escapeHtml(q.prompt.substring(0, 500))}</div>
1000
+ <div class="query-tokens-col">
1001
+ <div class="total">${fmt(q.totalTokens)}</div>
1002
+ <div class="detail">${fmt(q.inputTokens)} read / ${fmt(q.outputTokens)} written${cont}</div>
1003
+ </div>
1004
+ </div>`;
1005
+ }).join('');
1006
+
1007
+ const panel = document.getElementById('drilldown');
1008
+ panel.classList.add('open');
1009
+ panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
1010
+ }
1011
+
1012
+ function closeDrilldown() {
1013
+ document.getElementById('drilldown').classList.remove('open');
1014
+ }
1015
+
1016
+ fetchData();
1017
+ window.addEventListener('resize', () => { if (DATA) { renderDailyChart(); renderModelChart(); } });
1018
+ </script>
1019
+ </body>
1020
+ </html>