@jeganwrites/claudash 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,1742 @@
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>Claudash</title>
7
+ <style>
8
+ @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600&display=swap');
9
+
10
+ :root {
11
+ --bg: #FAFAF8;
12
+ --surface: #FFFFFF;
13
+ --surface-warm: #F5F3EE;
14
+ --border: #E8E6E1;
15
+ --border-strong: #D4D0C8;
16
+ --text-primary: #1A1916;
17
+ --text-secondary: #6B6860;
18
+ --text-tertiary: #9C9890;
19
+ --accent: #1A1916;
20
+ --accent-green: #0D7A5F;
21
+ --accent-amber: #B45309;
22
+ --accent-red: #C0392B;
23
+ --accent-blue: #1E40AF;
24
+ --accent-purple: #6D28D9;
25
+ --accent-teal: #0F766E;
26
+ --tag-bg: #F0EEE8;
27
+
28
+ --serif: 'DM Serif Display', Georgia, serif;
29
+ --sans: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
30
+ --mono: 'DM Mono', 'SF Mono', Menlo, Consolas, monospace;
31
+ }
32
+
33
+ * { margin: 0; padding: 0; box-sizing: border-box; }
34
+
35
+ html, body {
36
+ background: var(--bg);
37
+ color: var(--text-primary);
38
+ font-family: var(--sans);
39
+ font-weight: 400;
40
+ font-size: 14px;
41
+ line-height: 1.5;
42
+ -webkit-font-smoothing: antialiased;
43
+ }
44
+
45
+ a { color: inherit; text-decoration: none; }
46
+ button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
47
+
48
+ /* ── Header ─────────────────────────────────────────────────────── */
49
+ .header {
50
+ position: sticky; top: 0; z-index: 100;
51
+ height: 56px;
52
+ background: var(--bg);
53
+ border-bottom: 1px solid var(--border);
54
+ display: flex; align-items: center;
55
+ padding: 0 32px; gap: 32px;
56
+ }
57
+ .brand {
58
+ display: flex; align-items: baseline; gap: 10px;
59
+ }
60
+ .brand .name {
61
+ font-family: var(--serif);
62
+ font-size: 22px;
63
+ letter-spacing: -0.01em;
64
+ color: var(--text-primary);
65
+ }
66
+ .brand .version {
67
+ font-family: var(--mono);
68
+ font-size: 10px;
69
+ color: var(--text-tertiary);
70
+ padding: 2px 6px;
71
+ background: var(--tag-bg);
72
+ border-radius: 3px;
73
+ letter-spacing: 0.05em;
74
+ }
75
+
76
+ .tabs {
77
+ display: flex; gap: 2px;
78
+ flex: 1;
79
+ justify-content: center;
80
+ }
81
+ .tab {
82
+ padding: 6px 14px;
83
+ font-family: var(--sans);
84
+ font-size: 13px;
85
+ font-weight: 500;
86
+ color: var(--text-secondary);
87
+ border-radius: 6px;
88
+ transition: all 0.15s;
89
+ }
90
+ .tab:hover { background: var(--surface-warm); color: var(--text-primary); }
91
+ .tab.active { background: var(--text-primary); color: var(--bg); }
92
+ .tab.add-tab { color: var(--text-tertiary); font-weight: 400; }
93
+
94
+ .header-right {
95
+ display: flex; align-items: center; gap: 18px;
96
+ font-family: var(--sans);
97
+ font-size: 12px;
98
+ color: var(--text-tertiary);
99
+ }
100
+ .header-right .scan-info { font-family: var(--mono); font-size: 11px; }
101
+ .header-right .link-btn {
102
+ color: var(--text-secondary);
103
+ font-weight: 500;
104
+ padding: 4px 0;
105
+ transition: color 0.15s;
106
+ }
107
+ .header-right .link-btn:hover { color: var(--text-primary); }
108
+
109
+ /* ── Layout ─────────────────────────────────────────────────────── */
110
+ .content {
111
+ max-width: 1240px;
112
+ margin: 0 auto;
113
+ padding: 40px 32px 80px;
114
+ }
115
+
116
+ .section { margin-bottom: 48px; }
117
+ .section-title {
118
+ font-family: var(--serif);
119
+ font-size: 22px;
120
+ color: var(--text-primary);
121
+ margin-bottom: 20px;
122
+ display: flex; align-items: baseline; gap: 10px;
123
+ }
124
+ .section-title .count {
125
+ font-family: var(--mono);
126
+ font-size: 12px;
127
+ color: var(--text-tertiary);
128
+ background: var(--tag-bg);
129
+ padding: 2px 8px;
130
+ border-radius: 10px;
131
+ }
132
+ .section-title .count.red {
133
+ background: #FCE8E6;
134
+ color: var(--accent-red);
135
+ }
136
+
137
+ /* ── Hero stat strip ────────────────────────────────────────────── */
138
+ .hero {
139
+ display: grid;
140
+ grid-template-columns: repeat(6, 1fr);
141
+ background: var(--surface);
142
+ border: 1px solid var(--border);
143
+ border-radius: 10px;
144
+ margin-bottom: 48px;
145
+ overflow: hidden;
146
+ }
147
+ .hero-cell {
148
+ padding: 24px 28px;
149
+ border-right: 1px solid var(--border);
150
+ animation: fadeUp 0.5s ease both;
151
+ }
152
+ .hero-cell:last-child { border-right: none; }
153
+ .hero-cell:nth-child(1) { animation-delay: 0ms; }
154
+ .hero-cell:nth-child(2) { animation-delay: 50ms; }
155
+ .hero-cell:nth-child(3) { animation-delay: 100ms; }
156
+ .hero-cell:nth-child(4) { animation-delay: 150ms; }
157
+ .hero-cell:nth-child(5) { animation-delay: 200ms; }
158
+ .hero-cell:nth-child(6) { animation-delay: 250ms; }
159
+ .hero-cell .mini-bar {
160
+ margin-top: 10px;
161
+ width: 100%;
162
+ height: 3px;
163
+ background: var(--border);
164
+ border-radius: 2px;
165
+ overflow: hidden;
166
+ }
167
+ .hero-cell .mini-bar .fill {
168
+ height: 100%;
169
+ background: var(--accent-green);
170
+ border-radius: 2px;
171
+ transition: width 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
172
+ }
173
+ .hero-cell .mini-bar .fill.amber { background: var(--accent-amber); }
174
+ .hero-cell .mini-bar .fill.red { background: var(--accent-red); }
175
+
176
+ .hero-cell .label {
177
+ font-family: var(--sans);
178
+ font-size: 11px;
179
+ font-weight: 500;
180
+ text-transform: uppercase;
181
+ letter-spacing: 0.08em;
182
+ color: var(--text-tertiary);
183
+ margin-bottom: 10px;
184
+ }
185
+ .hero-cell .value {
186
+ font-family: var(--mono);
187
+ font-size: 32px;
188
+ font-weight: 500;
189
+ color: var(--text-primary);
190
+ letter-spacing: -0.02em;
191
+ line-height: 1.1;
192
+ }
193
+ .hero-cell .sub {
194
+ font-family: var(--mono);
195
+ font-size: 11px;
196
+ color: var(--text-tertiary);
197
+ margin-top: 6px;
198
+ }
199
+ .hero-cell .value.green { color: var(--accent-green); }
200
+ .hero-cell .value.amber { color: var(--accent-amber); }
201
+ .hero-cell .value.red { color: var(--accent-red); }
202
+
203
+ /* ── Window panel ───────────────────────────────────────────────── */
204
+ .window-panel {
205
+ background: var(--surface-warm);
206
+ border: 1px solid var(--border);
207
+ border-radius: 10px;
208
+ padding: 28px 32px;
209
+ margin-bottom: 20px;
210
+ animation: fadeUp 0.5s ease both;
211
+ animation-delay: 250ms;
212
+ }
213
+ .window-head {
214
+ display: flex; justify-content: space-between; align-items: baseline;
215
+ margin-bottom: 18px;
216
+ }
217
+ .window-name {
218
+ font-family: var(--serif);
219
+ font-size: 18px;
220
+ color: var(--text-primary);
221
+ }
222
+ .window-sparks {
223
+ display: flex; gap: 6px;
224
+ font-family: var(--mono);
225
+ font-size: 14px;
226
+ color: var(--text-tertiary);
227
+ letter-spacing: 0.15em;
228
+ }
229
+ .bar-track {
230
+ width: 100%;
231
+ height: 4px;
232
+ background: var(--border);
233
+ border-radius: 2px;
234
+ overflow: hidden;
235
+ margin-bottom: 10px;
236
+ }
237
+ .bar-fill {
238
+ height: 100%;
239
+ background: var(--accent-green);
240
+ border-radius: 2px;
241
+ transition: width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
242
+ }
243
+ .bar-fill.amber { background: var(--accent-amber); }
244
+ .bar-fill.red { background: var(--accent-red); }
245
+
246
+ .window-stats {
247
+ display: flex; gap: 28px;
248
+ font-family: var(--mono);
249
+ font-size: 12px;
250
+ color: var(--text-secondary);
251
+ }
252
+ .window-stats .val { color: var(--text-primary); font-weight: 500; }
253
+
254
+ .bar-label {
255
+ font-family: var(--sans);
256
+ font-size: 11px;
257
+ font-weight: 500;
258
+ text-transform: uppercase;
259
+ letter-spacing: 0.06em;
260
+ color: var(--text-tertiary);
261
+ margin: 18px 0 6px;
262
+ }
263
+
264
+ /* ── Projects table ─────────────────────────────────────────────── */
265
+ .proj-table {
266
+ width: 100%;
267
+ background: var(--surface);
268
+ border: 1px solid var(--border);
269
+ border-radius: 10px;
270
+ border-collapse: collapse;
271
+ overflow: hidden;
272
+ }
273
+ .proj-table th, .proj-table td {
274
+ padding: 14px 16px;
275
+ text-align: left;
276
+ border-bottom: 1px solid var(--border);
277
+ font-size: 13px;
278
+ }
279
+ .proj-table tr:last-child td { border-bottom: none; }
280
+ .proj-table th {
281
+ font-family: var(--sans);
282
+ font-size: 11px;
283
+ font-weight: 500;
284
+ text-transform: uppercase;
285
+ letter-spacing: 0.06em;
286
+ color: var(--text-tertiary);
287
+ background: var(--surface-warm);
288
+ }
289
+ .proj-table td { font-family: var(--mono); color: var(--text-primary); }
290
+ .proj-table tbody tr {
291
+ transition: background 0.15s;
292
+ cursor: pointer;
293
+ }
294
+ .proj-table tbody tr:hover { background: var(--surface-warm); }
295
+ .proj-table .proj-name {
296
+ font-family: var(--serif);
297
+ font-size: 16px;
298
+ color: var(--text-primary);
299
+ }
300
+ .proj-table .account-tag {
301
+ display: inline-block;
302
+ font-family: var(--sans);
303
+ font-size: 10px;
304
+ font-weight: 500;
305
+ color: var(--text-secondary);
306
+ background: var(--tag-bg);
307
+ padding: 2px 8px;
308
+ border-radius: 10px;
309
+ text-transform: uppercase;
310
+ letter-spacing: 0.04em;
311
+ }
312
+ .proj-table .model-pill {
313
+ display: inline-block;
314
+ font-family: var(--sans);
315
+ font-size: 10px;
316
+ font-weight: 600;
317
+ padding: 2px 8px;
318
+ border-radius: 10px;
319
+ text-transform: lowercase;
320
+ letter-spacing: 0.02em;
321
+ }
322
+ .model-pill.opus { background: #F3EEFF; color: var(--accent-purple); }
323
+ .model-pill.sonnet { background: #E6F7F4; color: var(--accent-teal); }
324
+ .model-pill.haiku { background: #FEF3E6; color: var(--accent-amber); }
325
+
326
+ .share-bar {
327
+ display: inline-block;
328
+ width: 80px;
329
+ height: 3px;
330
+ background: var(--border);
331
+ border-radius: 2px;
332
+ position: relative;
333
+ vertical-align: middle;
334
+ margin-right: 8px;
335
+ }
336
+ .share-bar .fill {
337
+ position: absolute; top: 0; left: 0;
338
+ height: 100%;
339
+ background: var(--text-primary);
340
+ border-radius: 2px;
341
+ }
342
+ .trend-up { color: var(--accent-green); }
343
+ .trend-down { color: var(--accent-red); }
344
+ .trend-flat { color: var(--text-tertiary); }
345
+
346
+ .proj-expand {
347
+ background: var(--surface-warm);
348
+ padding: 16px 20px;
349
+ font-family: var(--mono);
350
+ font-size: 12px;
351
+ color: var(--text-secondary);
352
+ display: none;
353
+ }
354
+ .proj-expand.open { display: table-cell; }
355
+ .proj-expand .kvs { display: flex; gap: 28px; flex-wrap: wrap; }
356
+ .proj-expand .kv { display: flex; gap: 6px; }
357
+ .proj-expand .kv .k { color: var(--text-tertiary); }
358
+ .proj-expand .kv .v { color: var(--text-primary); font-weight: 500; }
359
+
360
+ /* ── Insights list ──────────────────────────────────────────────── */
361
+ .insights-list { display: flex; flex-direction: column; gap: 2px; }
362
+ .insight-row {
363
+ display: flex; align-items: center; gap: 14px;
364
+ padding: 14px 0;
365
+ border-bottom: 1px solid var(--border);
366
+ font-size: 14px;
367
+ color: var(--text-primary);
368
+ transition: opacity 0.25s, transform 0.25s;
369
+ }
370
+ .insight-row:last-child { border-bottom: none; }
371
+ .insight-row.dismissing { opacity: 0; transform: translateX(20px); }
372
+ .insight-dot {
373
+ width: 8px; height: 8px; border-radius: 50%;
374
+ flex-shrink: 0;
375
+ background: var(--text-tertiary);
376
+ }
377
+ .insight-dot.red { background: var(--accent-red); }
378
+ .insight-dot.amber { background: var(--accent-amber); }
379
+ .insight-dot.green { background: var(--accent-green); }
380
+ .insight-dot.blue { background: var(--accent-blue); }
381
+ .insight-msg { flex: 1; }
382
+ .insight-meta {
383
+ font-family: var(--mono);
384
+ font-size: 11px;
385
+ color: var(--text-tertiary);
386
+ margin-left: 8px;
387
+ }
388
+ .insight-dismiss {
389
+ font-family: var(--mono);
390
+ font-size: 13px;
391
+ color: var(--text-tertiary);
392
+ padding: 4px 8px;
393
+ border-radius: 4px;
394
+ transition: all 0.15s;
395
+ }
396
+ .insight-dismiss:hover { color: var(--accent-red); background: #FCE8E6; }
397
+
398
+ .empty {
399
+ font-family: var(--sans);
400
+ font-size: 13px;
401
+ color: var(--text-tertiary);
402
+ padding: 20px 0;
403
+ font-style: italic;
404
+ }
405
+
406
+ /* ── Story cards ────────────────────────────────────────────────── */
407
+ .story-card {
408
+ background: var(--surface);
409
+ border: 1px solid var(--border);
410
+ border-radius: 10px;
411
+ padding: 20px 24px;
412
+ animation: fadeUp 0.3s ease both;
413
+ }
414
+ .story-badge {
415
+ display: inline-block;
416
+ font-family: var(--mono);
417
+ font-size: 10px;
418
+ font-weight: 500;
419
+ text-transform: uppercase;
420
+ letter-spacing: 0.05em;
421
+ color: var(--accent-amber);
422
+ background: #FEF3C7;
423
+ padding: 3px 8px;
424
+ border-radius: 4px;
425
+ margin-bottom: 10px;
426
+ }
427
+ .story-card .story-title {
428
+ font-family: var(--serif);
429
+ font-size: 16px;
430
+ color: var(--text-primary);
431
+ margin-bottom: 8px;
432
+ }
433
+ .story-card .story-finding {
434
+ font-family: var(--sans);
435
+ font-size: 13px;
436
+ color: var(--text-secondary);
437
+ margin-bottom: 10px;
438
+ line-height: 1.5;
439
+ }
440
+ .story-card .story-action {
441
+ font-family: var(--mono);
442
+ font-size: 12px;
443
+ color: var(--accent-green);
444
+ margin-bottom: 10px;
445
+ }
446
+ .story-card .story-footer {
447
+ font-family: var(--mono);
448
+ font-size: 10px;
449
+ color: var(--text-tertiary);
450
+ }
451
+ @media (max-width: 960px) {
452
+ #story-grid { grid-template-columns: 1fr !important; }
453
+ }
454
+
455
+ /* ── Trends (bar chart) ─────────────────────────────────────────── */
456
+ .trends-wrap {
457
+ background: var(--surface);
458
+ border: 1px solid var(--border);
459
+ border-radius: 10px;
460
+ padding: 28px 32px;
461
+ }
462
+ .trends-chart {
463
+ display: flex; align-items: flex-end;
464
+ gap: 10px;
465
+ height: 160px;
466
+ margin-bottom: 20px;
467
+ }
468
+ .bar-col {
469
+ flex: 1;
470
+ display: flex; flex-direction: column; align-items: center;
471
+ gap: 6px;
472
+ height: 100%;
473
+ }
474
+ .bar-col .bar-value {
475
+ font-family: var(--mono);
476
+ font-size: 11px;
477
+ color: var(--text-primary);
478
+ }
479
+ .bar-col .bar {
480
+ width: 100%;
481
+ background: var(--text-primary);
482
+ border-radius: 3px 3px 0 0;
483
+ min-height: 2px;
484
+ transition: height 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
485
+ position: relative;
486
+ }
487
+ .bar-col .bar:hover { background: var(--accent); opacity: 0.85; }
488
+ .bar-col .bar-label {
489
+ font-family: var(--mono);
490
+ font-size: 10px;
491
+ color: var(--text-tertiary);
492
+ }
493
+ .projection {
494
+ font-family: var(--mono);
495
+ font-size: 12px;
496
+ color: var(--text-secondary);
497
+ padding-top: 16px;
498
+ border-top: 1px solid var(--border);
499
+ }
500
+ .projection.warn { color: var(--accent-amber); }
501
+ .projection .val { color: var(--text-primary); font-weight: 500; }
502
+
503
+ /* ── Two-col grid (compaction / rightsizing) ───────────────────── */
504
+ .two-col {
505
+ display: grid;
506
+ grid-template-columns: 1fr 1fr;
507
+ gap: 24px;
508
+ }
509
+ .panel {
510
+ background: var(--surface);
511
+ border: 1px solid var(--border);
512
+ border-radius: 10px;
513
+ padding: 24px 28px;
514
+ }
515
+ .panel h3 {
516
+ font-family: var(--serif);
517
+ font-size: 18px;
518
+ color: var(--text-primary);
519
+ margin-bottom: 16px;
520
+ }
521
+ .stat-row {
522
+ display: flex; gap: 28px;
523
+ font-family: var(--mono);
524
+ font-size: 12px;
525
+ color: var(--text-secondary);
526
+ }
527
+ .stat-row .val {
528
+ font-size: 20px;
529
+ color: var(--text-primary);
530
+ font-weight: 500;
531
+ display: block;
532
+ margin-bottom: 2px;
533
+ }
534
+ .eff-table { width: 100%; font-family: var(--mono); font-size: 12px; }
535
+ .eff-table th, .eff-table td {
536
+ text-align: left;
537
+ padding: 8px 0;
538
+ border-bottom: 1px dashed var(--border);
539
+ }
540
+ .eff-table th {
541
+ font-family: var(--sans);
542
+ font-size: 11px;
543
+ font-weight: 500;
544
+ text-transform: uppercase;
545
+ letter-spacing: 0.05em;
546
+ color: var(--text-tertiary);
547
+ }
548
+ .eff-table td:last-child { color: var(--accent-amber); text-align: right; }
549
+
550
+ /* ── Fix tracker ────────────────────────────────────────────────── */
551
+ .form-card {
552
+ display: none;
553
+ background: var(--surface);
554
+ border: 1px solid var(--border);
555
+ border-radius: 10px;
556
+ padding: 24px 28px;
557
+ margin-bottom: 18px;
558
+ }
559
+ .form-card.open { display: block; animation: fadeUp 0.25s ease; }
560
+ .form-card h3 { font-family: var(--serif); font-size: 18px; margin-bottom: 16px; }
561
+ .form-grid {
562
+ display: grid;
563
+ grid-template-columns: 1fr 1fr;
564
+ gap: 14px 18px;
565
+ margin-bottom: 16px;
566
+ }
567
+ .form-field.wide { grid-column: 1 / -1; }
568
+ .form-field label {
569
+ display: block;
570
+ font-family: var(--sans);
571
+ font-size: 10px;
572
+ font-weight: 500;
573
+ text-transform: uppercase;
574
+ letter-spacing: 0.06em;
575
+ color: var(--text-tertiary);
576
+ margin-bottom: 6px;
577
+ }
578
+ .form-field input, .form-field select, .form-field textarea {
579
+ width: 100%;
580
+ padding: 9px 12px;
581
+ font-family: var(--mono);
582
+ font-size: 13px;
583
+ color: var(--text-primary);
584
+ background: var(--bg);
585
+ border: 1px solid var(--border);
586
+ border-radius: 6px;
587
+ resize: vertical;
588
+ }
589
+ .form-field textarea { min-height: 80px; }
590
+ .form-field input:focus, .form-field select:focus, .form-field textarea:focus {
591
+ outline: none; border-color: var(--text-primary);
592
+ }
593
+ .form-actions { display: flex; gap: 10px; padding-top: 14px; border-top: 1px solid var(--border); }
594
+ .btn-primary {
595
+ background: var(--text-primary);
596
+ color: var(--bg);
597
+ padding: 8px 16px;
598
+ border-radius: 6px;
599
+ font-family: var(--sans);
600
+ font-size: 13px;
601
+ font-weight: 500;
602
+ cursor: pointer;
603
+ border: none;
604
+ }
605
+ .btn-primary:hover { opacity: 0.85; }
606
+ .btn-ghost {
607
+ padding: 8px 14px;
608
+ border-radius: 6px;
609
+ color: var(--text-secondary);
610
+ font-family: var(--sans);
611
+ font-size: 13px;
612
+ cursor: pointer;
613
+ background: none;
614
+ border: none;
615
+ }
616
+ .btn-ghost:hover { background: var(--surface-warm); color: var(--text-primary); }
617
+
618
+ .fix-card {
619
+ display: grid;
620
+ grid-template-columns: 1.1fr 1fr 1.3fr;
621
+ gap: 24px;
622
+ padding: 20px 24px;
623
+ background: var(--surface);
624
+ border: 1px solid var(--border);
625
+ border-radius: 10px;
626
+ margin-bottom: 12px;
627
+ animation: fadeUp 0.3s ease both;
628
+ }
629
+ .fix-card .col-left { display: flex; flex-direction: column; gap: 8px; }
630
+ .fix-card .col-title { display: flex; align-items: center; gap: 10px; }
631
+ .fix-card .status-dot {
632
+ width: 10px; height: 10px; border-radius: 50%;
633
+ background: var(--text-tertiary);
634
+ }
635
+ .fix-card .status-dot.amber { background: var(--accent-amber); }
636
+ .fix-card .status-dot.green { background: var(--accent-green); }
637
+ .fix-card .status-dot.gray { background: var(--text-tertiary); }
638
+ .fix-card .fix-project {
639
+ font-family: var(--sans);
640
+ font-size: 14px;
641
+ font-weight: 600;
642
+ color: var(--text-primary);
643
+ }
644
+ .fix-card .pattern-pill {
645
+ font-family: var(--mono);
646
+ font-size: 10px;
647
+ padding: 2px 8px;
648
+ border-radius: 10px;
649
+ background: var(--tag-bg);
650
+ color: var(--text-secondary);
651
+ text-transform: lowercase;
652
+ }
653
+ .fix-card .pattern-pill.red { background: #FCE8E6; color: var(--accent-red); }
654
+ .fix-card .pattern-pill.amber { background: #FEF3E6; color: var(--accent-amber); }
655
+ .fix-card .fix-title {
656
+ font-family: var(--sans);
657
+ font-size: 13px;
658
+ color: var(--text-secondary);
659
+ line-height: 1.4;
660
+ }
661
+ .fix-card .fix-meta {
662
+ font-family: var(--mono);
663
+ font-size: 11px;
664
+ color: var(--text-tertiary);
665
+ }
666
+ .fix-card .col-middle {
667
+ display: flex; flex-direction: column; gap: 8px;
668
+ padding-left: 20px;
669
+ border-left: 1px solid var(--border);
670
+ }
671
+ .fix-card .col-middle .k {
672
+ font-family: var(--sans);
673
+ font-size: 10px;
674
+ font-weight: 500;
675
+ text-transform: uppercase;
676
+ letter-spacing: 0.06em;
677
+ color: var(--text-tertiary);
678
+ }
679
+ .fix-card .col-middle .v {
680
+ font-family: var(--mono);
681
+ font-size: 14px;
682
+ color: var(--text-primary);
683
+ }
684
+ .fix-card .col-right {
685
+ padding-left: 20px;
686
+ border-left: 1px solid var(--border);
687
+ display: flex;
688
+ flex-direction: column;
689
+ justify-content: space-between;
690
+ gap: 10px;
691
+ }
692
+ .fix-card .delta-line {
693
+ font-family: var(--mono);
694
+ font-size: 12px;
695
+ color: var(--text-primary);
696
+ }
697
+ .fix-card .delta-line .up { color: var(--accent-green); }
698
+ .fix-card .delta-line .down { color: var(--accent-red); }
699
+ .fix-card .delta-line .k {
700
+ color: var(--text-tertiary);
701
+ margin-right: 6px;
702
+ }
703
+ .fix-card .card-actions { display: flex; gap: 8px; margin-top: 8px; }
704
+ .fix-card .btn-action {
705
+ font-family: var(--sans);
706
+ font-size: 12px;
707
+ font-weight: 500;
708
+ padding: 6px 12px;
709
+ border-radius: 6px;
710
+ background: var(--text-primary);
711
+ color: var(--bg);
712
+ border: none;
713
+ cursor: pointer;
714
+ }
715
+ .fix-card .btn-action.ghost {
716
+ background: none;
717
+ color: var(--text-secondary);
718
+ border: 1px solid var(--border);
719
+ }
720
+ .fix-card .btn-action:hover { opacity: 0.85; }
721
+ .fix-empty {
722
+ text-align: center;
723
+ padding: 30px 20px;
724
+ font-family: var(--sans);
725
+ font-size: 13px;
726
+ color: var(--text-tertiary);
727
+ font-style: italic;
728
+ }
729
+ .fix-toast {
730
+ font-family: var(--mono);
731
+ font-size: 11px;
732
+ color: var(--accent-green);
733
+ margin-left: 10px;
734
+ }
735
+
736
+ /* ── Footer ─────────────────────────────────────────────────────── */
737
+ .footer {
738
+ margin-top: 60px;
739
+ padding: 24px 0;
740
+ border-top: 1px solid var(--border);
741
+ font-family: var(--mono);
742
+ font-size: 11px;
743
+ color: var(--text-tertiary);
744
+ text-align: center;
745
+ }
746
+
747
+ /* ── Animations ─────────────────────────────────────────────────── */
748
+ @keyframes fadeUp {
749
+ from { opacity: 0; transform: translateY(8px); }
750
+ to { opacity: 1; transform: translateY(0); }
751
+ }
752
+
753
+ @media (max-width: 960px) {
754
+ .content { padding: 24px 18px 60px; }
755
+ .hero { grid-template-columns: repeat(2, 1fr); }
756
+ .hero-cell { border-bottom: 1px solid var(--border); }
757
+ .two-col { grid-template-columns: 1fr; }
758
+ .tabs { display: none; }
759
+ }
760
+ </style>
761
+ </head>
762
+ <body>
763
+
764
+ <header class="header">
765
+ <div class="brand">
766
+ <span class="name">Claudash</span>
767
+ <span class="version">v1.0</span>
768
+ </div>
769
+ <nav class="tabs" id="tabs"></nav>
770
+ <div class="header-right">
771
+ <span class="scan-info" id="scan-info">—</span>
772
+ <button class="link-btn" id="scan-btn">↻ Scan</button>
773
+ <a class="link-btn" href="/accounts">⚙ Accounts</a>
774
+ </div>
775
+ </header>
776
+
777
+ <main class="content">
778
+ <!-- Connection lost banner -->
779
+ <div id="health-banner" style="display:none; background:#FEE2E2; border:1px solid #EF4444; border-radius:10px; padding:18px 24px; margin-bottom:16px; font-family:var(--mono); font-size:13px; color:#991B1B; position:relative;">
780
+ <strong>Dashboard server not responding</strong><br>
781
+ Run: <code style="background:#FECACA; padding:2px 6px; border-radius:3px;">python3 cli.py dashboard</code><br>
782
+ On VPS: start the process on your server first
783
+ <button onclick="this.parentElement.style.display='none'" style="position:absolute; top:12px; right:16px; font-size:16px; color:#991B1B; cursor:pointer; background:none; border:none;">Dismiss</button>
784
+ </div>
785
+ <div id="reconnect-toast" style="display:none; background:#D1FAE5; border:1px solid #10B981; border-radius:8px; padding:10px 18px; margin-bottom:16px; font-family:var(--mono); font-size:13px; color:#065F46; text-align:center;">
786
+ Reconnected
787
+ </div>
788
+ <!-- First-run banner -->
789
+ <div id="first-run-banner" style="display:none; background:#FEF3C7; border:1px solid #F59E0B; border-radius:10px; padding:18px 24px; margin-bottom:24px; font-family:var(--mono); font-size:13px; color:#92400E; position:relative;">
790
+ <strong>No data yet</strong> — run <code style="background:#FDE68A; padding:2px 6px; border-radius:3px;">python3 cli.py scan</code> in your terminal, then refresh this page.
791
+ <button onclick="this.parentElement.style.display='none'" style="position:absolute; top:12px; right:16px; font-size:16px; color:#92400E; cursor:pointer; background:none; border:none;">×</button>
792
+ </div>
793
+
794
+ <!-- Hero stats -->
795
+ <section class="hero" id="hero"></section>
796
+
797
+ <!-- Windows -->
798
+ <section class="section" id="windows-section"></section>
799
+
800
+ <!-- Projects -->
801
+ <section class="section" id="projects-section">
802
+ <h2 class="section-title">Projects <span class="count" id="proj-count">0</span></h2>
803
+ <table class="proj-table">
804
+ <thead>
805
+ <tr>
806
+ <th>Project</th>
807
+ <th>Account</th>
808
+ <th>Model</th>
809
+ <th>30d cost</th>
810
+ <th>Share</th>
811
+ <th>Cache</th>
812
+ <th>Trend</th>
813
+ <th>Sessions</th>
814
+ </tr>
815
+ </thead>
816
+ <tbody id="proj-body"></tbody>
817
+ </table>
818
+ </section>
819
+
820
+ <!-- Insights -->
821
+ <section class="section">
822
+ <h2 class="section-title">Insights <span class="count" id="ins-count">0</span></h2>
823
+ <div class="insights-list" id="ins-list"></div>
824
+ </section>
825
+
826
+ <!-- What Claudash Found -->
827
+ <section class="section" id="stories-section" style="display:none;">
828
+ <h2 class="section-title">What Claudash Found <span class="count" id="story-count">0</span></h2>
829
+ <div id="story-grid" style="display:grid; grid-template-columns:1fr 1fr; gap:16px;"></div>
830
+ </section>
831
+
832
+ <!-- Trends -->
833
+ <section class="section" id="trends-section">
834
+ <h2 class="section-title">7-day spend</h2>
835
+ <div class="trends-wrap">
836
+ <div class="trends-chart" id="trends-chart"></div>
837
+ <div class="projection" id="projection">—</div>
838
+ </div>
839
+ </section>
840
+
841
+ <!-- Compaction + rightsizing -->
842
+ <section class="section" id="compaction-section">
843
+ <div class="two-col">
844
+ <div class="panel">
845
+ <h3>Compaction</h3>
846
+ <div class="stat-row" id="compaction-stats"></div>
847
+ </div>
848
+ <div class="panel">
849
+ <h3>Model efficiency</h3>
850
+ <div id="rightsizing"></div>
851
+ </div>
852
+ </div>
853
+ </section>
854
+
855
+ <!-- Fix tracker -->
856
+ <section class="section" id="fix-tracker-section">
857
+ <h2 class="section-title">Fix tracker
858
+ <span class="count" id="fix-count">0</span>
859
+ <button class="link-btn" id="add-fix-btn" style="margin-left:auto;font-family:var(--sans);font-size:12px;color:var(--text-secondary);padding:4px 10px;border:1px solid var(--border);border-radius:6px">+ Record a fix</button>
860
+ </h2>
861
+ <div style="font-family:var(--sans);font-size:12px;color:var(--text-tertiary);margin-top:-14px;margin-bottom:16px">Record a fix → measure the result. Plan-aware: max/pro report window efficiency, api reports dollars.</div>
862
+ <div class="form-card" id="fix-form"></div>
863
+ <div id="fix-list"></div>
864
+ </section>
865
+
866
+ <div class="footer" id="footer">Loading…</div>
867
+ </main>
868
+
869
+ <script>
870
+ // ── Dashboard-key auth wrapper ───────────────────────────────────────
871
+ const DASHBOARD_KEY_STORAGE = 'dashboard_key';
872
+ function authHeaders() {
873
+ return {
874
+ 'Content-Type': 'application/json',
875
+ 'X-Dashboard-Key': localStorage.getItem(DASHBOARD_KEY_STORAGE) || ''
876
+ };
877
+ }
878
+ (function installAuthFetch() {
879
+ const _origFetch = window.fetch.bind(window);
880
+ window.fetch = function(url, opts) {
881
+ opts = opts || {};
882
+ const method = (opts.method || 'GET').toUpperCase();
883
+ if (method !== 'GET' && method !== 'HEAD') {
884
+ opts.headers = Object.assign(
885
+ {'X-Dashboard-Key': localStorage.getItem(DASHBOARD_KEY_STORAGE) || ''},
886
+ opts.headers || {}
887
+ );
888
+ }
889
+ return _origFetch(url, opts).then(function(resp) {
890
+ if (resp.status === 401) {
891
+ const key = prompt(
892
+ 'Dashboard key required.\n\n' +
893
+ 'Run on the server:\n' +
894
+ ' python3 cli.py keys\n\n' +
895
+ 'Copy the dashboard_key value and paste it here:'
896
+ );
897
+ if (key) {
898
+ localStorage.setItem(DASHBOARD_KEY_STORAGE, key.trim());
899
+ location.reload();
900
+ }
901
+ throw new Error('unauthorized');
902
+ }
903
+ return resp;
904
+ });
905
+ };
906
+ })();
907
+
908
+ // ── App ─────────────────────────────────────────────────────────
909
+ (function() {
910
+ let currentAccount = 'all';
911
+ const REFRESH_MS = 60_000;
912
+ let refreshTimer = null;
913
+
914
+ const $ = (id) => document.getElementById(id);
915
+ function esc(s) {
916
+ return String(s == null ? '' : s)
917
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
918
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
919
+ }
920
+ function fmtNum(n) { return (n || 0).toLocaleString('en-US'); }
921
+ function fmtMoney(n) { return '$' + (n || 0).toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2}); }
922
+ function fmtPct(n, digits) { return (n || 0).toFixed(digits == null ? 1 : digits) + '%'; }
923
+ function timeAgo(epoch) {
924
+ if (!epoch) return 'never';
925
+ const diff = Math.floor(Date.now() / 1000) - epoch;
926
+ if (diff < 60) return diff + 's ago';
927
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
928
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
929
+ return Math.floor(diff / 86400) + 'd ago';
930
+ }
931
+ function windowClass(pct) {
932
+ if (pct >= 80) return 'red';
933
+ if (pct >= 50) return 'amber';
934
+ return 'green';
935
+ }
936
+
937
+ function buildTabs(accountsList) {
938
+ const c = $('tabs');
939
+ const withSessions = (accountsList || []).filter(a => a.sessions_count > 0 || a.has_browser_data === true || a.account_id === currentAccount);
940
+ let html = '<button class="tab' + (currentAccount === 'all' ? ' active' : '') + '" data-account="all">Combined</button>';
941
+ for (const a of withSessions) {
942
+ const cls = currentAccount === a.account_id ? ' active' : '';
943
+ html += '<button class="tab' + cls + '" data-account="' + esc(a.account_id) + '">' + esc(a.label) + '</button>';
944
+ }
945
+ html += '<a class="tab add-tab" href="/accounts">+</a>';
946
+ c.innerHTML = html;
947
+ c.querySelectorAll('button.tab').forEach(t => {
948
+ t.addEventListener('click', () => {
949
+ currentAccount = t.dataset.account;
950
+ c.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
951
+ t.classList.add('active');
952
+ refresh();
953
+ });
954
+ });
955
+ }
956
+
957
+ function renderHero(data) {
958
+ const m = data.metrics || {};
959
+ const accts = data.accounts_list || [];
960
+ const curAcct = accts.find(a => a.account_id === currentAccount);
961
+
962
+ // Browser-only account: no JSONL sessions, only claude.ai browser tracking
963
+ if (currentAccount !== 'all' && curAcct && curAcct.sessions_count === 0 && curAcct.has_browser_data) {
964
+ $('hero').innerHTML =
965
+ '<div style="background:var(--surface-warm);border:1px solid var(--border);border-radius:10px;padding:24px 32px;font-family:var(--sans)">' +
966
+ '<div style="font-family:var(--serif);font-size:18px;margin-bottom:6px">Browser-only account</div>' +
967
+ '<div style="color:var(--text-secondary);font-size:13px">No Claude Code sessions tracked. Window usage comes from claude.ai browser.</div>' +
968
+ '</div>';
969
+ return;
970
+ }
971
+
972
+ const windows = data.windows || {};
973
+ const win = windows[currentAccount] || windows.personal_max || Object.values(windows)[0] || {};
974
+ const winPct = win.window_pct || 0;
975
+ const apiEquiv = m.total_cost_30d || 0;
976
+ const roi = m.subscription_roi || 0;
977
+ const cacheHit = m.cache_hit_rate || 0;
978
+ const sessionsToday = m.sessions_today || 0;
979
+
980
+ // Daily budget — pick the active account, or aggregate when "all"
981
+ const dbm = data.daily_budget || {};
982
+ let todayCost = 0, budget = 0, hasBudget = false, budgetPct = 0;
983
+ if (currentAccount === 'all') {
984
+ for (const k of Object.keys(dbm)) {
985
+ todayCost += dbm[k].today_cost || 0;
986
+ budget += dbm[k].budget_usd || 0;
987
+ if (dbm[k].has_budget) hasBudget = true;
988
+ }
989
+ budgetPct = budget > 0 ? (todayCost / budget * 100) : 0;
990
+ } else {
991
+ const b = dbm[currentAccount] || {};
992
+ todayCost = b.today_cost || 0;
993
+ budget = b.budget_usd || 0;
994
+ hasBudget = !!b.has_budget;
995
+ budgetPct = b.budget_pct || 0;
996
+ }
997
+
998
+ let todayCell;
999
+ if (hasBudget) {
1000
+ const cls = budgetPct >= 100 ? 'red' : (budgetPct >= 80 ? 'amber' : 'green');
1001
+ todayCell = {
1002
+ label: 'Today',
1003
+ value: fmtMoney(todayCost),
1004
+ sub: '/ ' + fmtMoney(budget) + ' (' + budgetPct.toFixed(0) + '%)',
1005
+ cls: cls,
1006
+ bar: Math.min(budgetPct, 100),
1007
+ barCls: cls,
1008
+ };
1009
+ } else {
1010
+ todayCell = {label: 'Today', value: fmtMoney(todayCost), sub: 'no budget set', cls: '', bar: null};
1011
+ }
1012
+
1013
+ // Efficiency score
1014
+ const eff = data.efficiency || {};
1015
+ const effScore = eff.score || 0;
1016
+ const effGrade = eff.grade || '-';
1017
+ function effGradeClass(g) {
1018
+ if (g === 'A') return 'green';
1019
+ if (g === 'B') return 'green';
1020
+ if (g === 'C') return 'amber';
1021
+ if (g === 'D') return 'amber';
1022
+ return 'red';
1023
+ }
1024
+
1025
+ const cells = [
1026
+ {label: 'Efficiency', value: effScore + '/100', sub: 'Grade ' + effGrade + ' \u2014 click for breakdown', cls: effGradeClass(effGrade), bar: effScore, barCls: effGradeClass(effGrade), id: 'eff-cell'},
1027
+ {label: 'Window', value: fmtPct(winPct), sub: (win.minutes_to_limit ? '~' + win.minutes_to_limit + ' min to cap' : 'approximate \u2014 UTC window alignment'), cls: windowClass(winPct)},
1028
+ {label: 'API equiv 30d', value: fmtMoney(apiEquiv), sub: 'if you paid per-token at API list prices', cls: ''},
1029
+ {label: 'Cache hit', value: fmtPct(cacheHit), sub: 'cache reads / (cache reads + input)', cls: ''},
1030
+ {label: 'Sessions today', value: fmtNum(sessionsToday), sub: 'distinct conversations', cls: ''},
1031
+ todayCell,
1032
+ ];
1033
+ $('hero').innerHTML = cells.map(c => {
1034
+ const bar = (c.bar !== null && c.bar !== undefined)
1035
+ ? '<div class="mini-bar"><div class="fill ' + (c.barCls || '') + '" style="width:' + c.bar + '%"></div></div>'
1036
+ : '';
1037
+ return (
1038
+ '<div class="hero-cell"' + (c.id ? ' id="' + c.id + '"' : '') + '>' +
1039
+ '<div class="label">' + esc(c.label) + '</div>' +
1040
+ '<div class="value ' + c.cls + '">' + esc(c.value) + '</div>' +
1041
+ '<div class="sub">' + esc(c.sub) + '</div>' +
1042
+ bar +
1043
+ '</div>'
1044
+ );
1045
+ }).join('');
1046
+
1047
+ // Efficiency dimension breakdown — click to toggle
1048
+ const effCell = document.getElementById('eff-cell');
1049
+ if (effCell && eff.dimensions) {
1050
+ effCell.style.cursor = 'pointer';
1051
+ effCell.addEventListener('click', function() {
1052
+ let panel = document.getElementById('eff-breakdown');
1053
+ if (panel) { panel.remove(); return; }
1054
+ panel = document.createElement('div');
1055
+ panel.id = 'eff-breakdown';
1056
+ panel.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px 20px;margin-top:12px;font-family:var(--mono);font-size:12px;line-height:2;position:absolute;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.08);min-width:380px';
1057
+ const dims = eff.dimensions || [];
1058
+ let html = dims.map(d => {
1059
+ const filled = Math.round(d.score / 5);
1060
+ const empty = 20 - filled;
1061
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
1062
+ const wPct = Math.round(d.weight * 100);
1063
+ return '<div>' + esc(d.label.substring(0, 20).padEnd(20)) + ' ' + bar + ' ' + String(d.score).padStart(3) + '/100 ' + wPct + '%</div>';
1064
+ }).join('');
1065
+ html += '<div style="border-top:1px solid var(--border);margin-top:4px;padding-top:4px">';
1066
+ html += 'Overall: ' + effScore + '/100 \u00b7 Grade ' + effGrade;
1067
+ html += '</div>';
1068
+ if (eff.top_improvement) {
1069
+ html += '<div style="color:var(--accent-amber);margin-top:2px">Top improvement: ' + esc(eff.top_improvement) + '</div>';
1070
+ }
1071
+ panel.innerHTML = html;
1072
+ effCell.style.position = 'relative';
1073
+ effCell.appendChild(panel);
1074
+ });
1075
+ }
1076
+ }
1077
+
1078
+ function renderWindows(data) {
1079
+ const windows = data.windows || {};
1080
+ const browser = data.claude_ai_browser || {};
1081
+ const accts = data.accounts_list || [];
1082
+ const keys = currentAccount === 'all' ? accts.map(a => a.account_id) : [currentAccount];
1083
+
1084
+ const blocks = keys.map(k => {
1085
+ const w = windows[k];
1086
+ const ba = browser[k];
1087
+ const acct = accts.find(a => a.account_id === k) || {};
1088
+ const label = acct.label || k;
1089
+ const hasSessions = (acct.sessions_count || 0) > 0;
1090
+ const hasBrowser = ba && ba.status === 'active' && ba.snapshot;
1091
+
1092
+ if (!w && !hasBrowser) return '';
1093
+
1094
+ // Browser-only account (no JSONL sessions, has browser tracking)
1095
+ if (!hasSessions && hasBrowser) {
1096
+ const five = ba.snapshot.five_hour_utilization || ba.snapshot.pct_used || 0;
1097
+ const seven = ba.snapshot.seven_day_utilization || 0;
1098
+ return (
1099
+ '<div class="window-panel">' +
1100
+ '<div class="window-head">' +
1101
+ '<span class="window-name">' + esc(label) + ' — claude.ai browser only</span>' +
1102
+ '</div>' +
1103
+ '<div class="bar-label">5-hour window — ' + fmtPct(five) + '</div>' +
1104
+ '<div class="bar-track"><div class="bar-fill ' + windowClass(five) + '" style="width:' + Math.min(five, 100) + '%"></div></div>' +
1105
+ '<div class="bar-label" style="margin-top:10px">7-day window — ' + fmtPct(seven) + '</div>' +
1106
+ '<div class="bar-track"><div class="bar-fill ' + windowClass(seven) + '" style="width:' + Math.min(seven, 100) + '%"></div></div>' +
1107
+ '<div class="window-stats"><span class="val" style="color:var(--text-tertiary)">No Claude Code sessions — browser tracking only</span></div>' +
1108
+ '</div>'
1109
+ );
1110
+ }
1111
+
1112
+ if (!w) return '';
1113
+
1114
+ const pct = w.window_pct || 0;
1115
+ const cls = windowClass(pct);
1116
+ const total = w.total_tokens || 0;
1117
+ const lim = w.tokens_limit || 0;
1118
+ const burn = w.burn_per_minute || 0;
1119
+ const reset = w.window_end ? new Date(w.window_end * 1000).toISOString().slice(11,16) + ' UTC' : '—';
1120
+ const history = (w.window_history || []).slice(0, 4);
1121
+ const sparks = history.length ? history.map(h => (h.pct_used >= 50 ? '●' : '○')).join('') : '○○○○';
1122
+
1123
+ let browserBar = '';
1124
+ if (hasBrowser) {
1125
+ const bFive = ba.snapshot.five_hour_utilization || ba.snapshot.pct_used || 0;
1126
+ const bSeven = ba.snapshot.seven_day_utilization || 0;
1127
+ browserBar =
1128
+ '<div class="bar-label">claude.ai browser 5h — ' + fmtPct(bFive) + '</div>' +
1129
+ '<div class="bar-track"><div class="bar-fill ' + windowClass(bFive) + '" style="width:' + Math.min(bFive, 100) + '%"></div></div>' +
1130
+ (bSeven > 0 ? '<div class="bar-label">claude.ai browser 7d — ' + fmtPct(bSeven) + '</div>' +
1131
+ '<div class="bar-track"><div class="bar-fill ' + windowClass(bSeven) + '" style="width:' + Math.min(bSeven, 100) + '%"></div></div>' : '');
1132
+ }
1133
+
1134
+ if (lim === 0 && !browserBar && total === 0) return '';
1135
+
1136
+ return (
1137
+ '<div class="window-panel">' +
1138
+ '<div class="window-head">' +
1139
+ '<span class="window-name">' + esc(label) + '</span>' +
1140
+ '<span class="window-sparks">' + sparks + '</span>' +
1141
+ '</div>' +
1142
+ (lim > 0 ? '<div class="bar-track"><div class="bar-fill ' + cls + '" style="width:' + Math.min(pct, 100) + '%"></div></div>' : '') +
1143
+ (lim > 0 ? '<div class="window-stats">' +
1144
+ '<span><span class="val">' + fmtPct(pct) + '</span> used</span>' +
1145
+ '<span><span class="val">' + fmtNum(Math.round(burn)) + '</span> tok/min</span>' +
1146
+ '<span>resets <span class="val">' + esc(reset) + '</span></span>' +
1147
+ '<span><span class="val">' + fmtNum(total) + '</span> / ' + fmtNum(lim) + '</span>' +
1148
+ '</div>' : '') +
1149
+ browserBar +
1150
+ '</div>'
1151
+ );
1152
+ });
1153
+ $('windows-section').innerHTML = blocks.join('');
1154
+ }
1155
+
1156
+ function renderProjects(data) {
1157
+ const projs = data.projects || [];
1158
+ const subagents = data.subagent_metrics || {};
1159
+ const waste = data.waste_summary || {};
1160
+ $('proj-count').textContent = projs.length;
1161
+ if (!projs.length) {
1162
+ // Context-aware empty state
1163
+ var accts = data.accounts_list || [];
1164
+ var curAcct = accts.find(function(a) { return a.account_id === currentAccount; });
1165
+ var emptyMsg;
1166
+ if (curAcct && curAcct.sessions_count === 0 && curAcct.has_browser_data) {
1167
+ emptyMsg = 'Browser-only account \u2014 project data requires Claude Code';
1168
+ } else if (data.total_rows > 0) {
1169
+ emptyMsg = 'No sessions found for this account. Run: python3 cli.py scan';
1170
+ } else {
1171
+ emptyMsg = 'Configure your data paths in Accounts settings \u2192';
1172
+ }
1173
+ $('proj-body').innerHTML = '<tr><td colspan="8" class="empty">' + esc(emptyMsg) + '</td></tr>';
1174
+ return;
1175
+ }
1176
+ $('proj-body').innerHTML = projs.map((p, i) => {
1177
+ const model = p.dominant_model ? p.dominant_model.replace('claude-', '') : 'sonnet';
1178
+ const share = p.token_share_pct || 0;
1179
+ const cache = p.cache_hit_rate || 0;
1180
+ const wow = p.wow_change_pct || 0;
1181
+ const trendCls = wow > 10 ? 'trend-up' : (wow < -10 ? 'trend-down' : 'trend-flat');
1182
+ const arrow = wow > 0 ? '↑' : (wow < 0 ? '↓' : '·');
1183
+ const cost30 = p.cost_usd_30d || 0;
1184
+ const tokens = p.total_tokens || 0;
1185
+ const rs = p.rightsizing_savings || 0;
1186
+ const avgOut = p.avg_output_tokens || 0;
1187
+ const velocity = p.token_velocity || 0;
1188
+ const rowId = 'proj-row-' + i;
1189
+ return (
1190
+ '<tr data-row="' + rowId + '">' +
1191
+ '<td><span class="proj-name">' + esc(p.name) + '</span></td>' +
1192
+ '<td><span class="account-tag">' + esc(p.account_label || p.account) + '</span></td>' +
1193
+ '<td><span class="model-pill ' + esc(model) + '">' + esc(model) + '</span></td>' +
1194
+ '<td>' + fmtMoney(cost30) + '</td>' +
1195
+ '<td><span class="share-bar"><span class="fill" style="width:' + Math.min(share, 100) + '%"></span></span>' + fmtPct(share, 0) + '</td>' +
1196
+ '<td>' + fmtPct(cache, 0) + '</td>' +
1197
+ '<td class="' + trendCls + '">' + arrow + ' ' + Math.abs(wow).toFixed(0) + '%</td>' +
1198
+ '<td>' + fmtNum(p.session_count || 0) + '</td>' +
1199
+ '</tr>' +
1200
+ '<tr class="proj-expand-row" data-expand="' + rowId + '" style="display:none">' +
1201
+ '<td colspan="8" class="proj-expand open">' +
1202
+ '<div class="kvs">' +
1203
+ '<div class="kv"><span class="k">total tokens</span><span class="v">' + fmtNum(tokens) + '</span></div>' +
1204
+ '<div class="kv"><span class="k">avg output</span><span class="v">' + fmtNum(avgOut) + ' tok</span></div>' +
1205
+ '<div class="kv"><span class="k">velocity</span><span class="v">' + fmtNum(Math.round(velocity)) + ' tok/hr</span></div>' +
1206
+ '<div class="kv"><span class="k">model consistency</span><span class="v">' + fmtPct(p.model_consistency || 0, 0) + '</span></div>' +
1207
+ '<div class="kv"><span class="k">cache ROI</span><span class="v">' + fmtMoney(p.cache_roi_usd || 0) + '</span></div>' +
1208
+ (rs > 0 ? '<div class="kv"><span class="k">rightsizing saves</span><span class="v">' + fmtMoney(rs) + '/mo</span></div>' : '') +
1209
+ '</div>' +
1210
+ renderSubagentBlock(p.name, subagents[p.name] || {}) +
1211
+ renderWasteBlock(p.name, waste[p.name] || {}) +
1212
+ '</td>' +
1213
+ '</tr>'
1214
+ );
1215
+ }).join('');
1216
+ $('proj-body').querySelectorAll('tr[data-row]').forEach(tr => {
1217
+ tr.addEventListener('click', () => {
1218
+ const id = tr.dataset.row;
1219
+ const exp = $('proj-body').querySelector('tr[data-expand="' + id + '"]');
1220
+ if (exp) exp.style.display = exp.style.display === 'none' ? 'table-row' : 'none';
1221
+ });
1222
+ });
1223
+ }
1224
+
1225
+ function renderSubagentBlock(projName, sm) {
1226
+ if (!sm || !sm.subagent_session_count) return '';
1227
+ const top = sm.top_spawning_sessions || [];
1228
+ let html = '<div style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--border)">';
1229
+ html += '<div class="k" style="font-family:var(--sans);font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-tertiary);margin-bottom:8px">Sub-agents</div>';
1230
+ html += '<div class="kvs">';
1231
+ html += '<div class="kv"><span class="k">spawned</span><span class="v">' + fmtNum(sm.subagent_session_count) + '</span></div>';
1232
+ html += '<div class="kv"><span class="k">cost</span><span class="v">' + fmtMoney(sm.subagent_cost_usd) + '</span></div>';
1233
+ html += '<div class="kv"><span class="k">% of project</span><span class="v">' + fmtPct(sm.subagent_pct_of_total, 1) + '</span></div>';
1234
+ html += '</div>';
1235
+ if (top.length) {
1236
+ html += '<div style="margin-top:10px;font-family:var(--mono);font-size:11px;color:var(--text-secondary)">Top spawning parents:</div>';
1237
+ html += '<div style="font-family:var(--mono);font-size:11px;color:var(--text-secondary);margin-top:4px">';
1238
+ for (const t of top) {
1239
+ const pid = (t.parent_session_id || '').slice(0, 8);
1240
+ html += '<div>· ' + esc(pid) + '… ' + fmtNum(t.subagents_spawned) + ' spawned ' + fmtMoney(t.cost_usd) + '</div>';
1241
+ }
1242
+ html += '</div>';
1243
+ }
1244
+ html += '</div>';
1245
+ return html;
1246
+ }
1247
+
1248
+ function renderWasteBlock(projName, w) {
1249
+ if (!w) return '';
1250
+ const total = (w.floundering_sessions || 0) + (w.repeated_read_sessions || 0)
1251
+ + (w.cost_outliers || 0) + (w.deep_no_compact || 0);
1252
+ if (!total) return '';
1253
+ let html = '<div style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--border)">';
1254
+ html += '<div class="k" style="font-family:var(--sans);font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:0.06em;color:var(--accent-red);margin-bottom:8px">Waste detected</div>';
1255
+ html += '<div class="kvs">';
1256
+ if (w.floundering_sessions) html += '<div class="kv"><span class="k">floundering</span><span class="v">' + fmtNum(w.floundering_sessions) + '</span></div>';
1257
+ if (w.repeated_read_sessions) html += '<div class="kv"><span class="k">repeated reads</span><span class="v">' + fmtNum(w.repeated_read_sessions) + '</span></div>';
1258
+ if (w.cost_outliers) html += '<div class="kv"><span class="k">cost outliers</span><span class="v">' + fmtNum(w.cost_outliers) + '</span></div>';
1259
+ if (w.deep_no_compact) html += '<div class="kv"><span class="k">deep, no compact</span><span class="v">' + fmtNum(w.deep_no_compact) + '</span></div>';
1260
+ if (w.total_waste_cost_est > 0) html += '<div class="kv"><span class="k">est. at risk</span><span class="v">' + fmtMoney(w.total_waste_cost_est) + '</span></div>';
1261
+ html += '</div>';
1262
+ html += '</div>';
1263
+ return html;
1264
+ }
1265
+
1266
+ function renderInsights(data) {
1267
+ const ins = data._insights || [];
1268
+ $('ins-count').textContent = ins.length;
1269
+ $('ins-count').className = 'count' + (ins.length > 0 ? ' red' : '');
1270
+ if (!ins.length) {
1271
+ $('ins-list').innerHTML = '<div class="empty">No active insights. You\'re all clear.</div>';
1272
+ return;
1273
+ }
1274
+ const dotMap = {
1275
+ model_waste: 'amber', cache_spike: 'red', compaction_gap: 'amber',
1276
+ cost_target: 'green', window_risk: 'red', roi_milestone: 'green',
1277
+ heavy_day: 'blue', best_window: 'blue', window_combined_risk: 'red',
1278
+ session_expiry: 'red', pro_messages_low: 'amber',
1279
+ floundering_detected: 'red', subagent_cost_spike: 'amber',
1280
+ budget_warning: 'amber', budget_exceeded: 'red',
1281
+ };
1282
+ $('ins-list').innerHTML = ins.map(i => {
1283
+ const dot = dotMap[i.insight_type] || 'blue';
1284
+ const when = i.created_at ? new Date(i.created_at * 1000).toLocaleString('en-US', {hour:'2-digit', minute:'2-digit', month:'short', day:'numeric'}) : '';
1285
+ return (
1286
+ '<div class="insight-row" data-id="' + i.id + '">' +
1287
+ '<div class="insight-dot ' + dot + '"></div>' +
1288
+ '<div class="insight-msg">' + esc(i.message) + '</div>' +
1289
+ '<div class="insight-meta">' + esc(when) + '</div>' +
1290
+ '<button class="insight-dismiss" data-dismiss="' + i.id + '">×</button>' +
1291
+ '</div>'
1292
+ );
1293
+ }).join('');
1294
+ $('ins-list').querySelectorAll('[data-dismiss]').forEach(b => {
1295
+ b.addEventListener('click', (e) => {
1296
+ e.stopPropagation();
1297
+ const id = b.dataset.dismiss;
1298
+ const row = $('ins-list').querySelector('[data-id="' + id + '"]');
1299
+ if (row) row.classList.add('dismissing');
1300
+ fetch('/api/insights/' + id + '/dismiss', {method: 'POST', headers: authHeaders()})
1301
+ .then(() => setTimeout(() => { if (row) row.remove(); refresh(); }, 250))
1302
+ .catch(() => { if (row) row.classList.remove('dismissing'); });
1303
+ });
1304
+ });
1305
+ }
1306
+
1307
+ function renderStories() {
1308
+ fetch('/api/real-story').then(function(r) { return r.json(); }).then(function(resp) {
1309
+ var stories = resp.stories || [];
1310
+ if (currentAccount !== 'all') {
1311
+ stories = stories.filter(function(s) { return !s.account || s.account === currentAccount; });
1312
+ }
1313
+ var section = $('stories-section');
1314
+ var grid = $('story-grid');
1315
+ $('story-count').textContent = stories.length;
1316
+ if (!stories.length) {
1317
+ section.style.display = '';
1318
+ grid.innerHTML = '<div class="empty">No patterns detected yet for this account.</div>';
1319
+ return;
1320
+ }
1321
+ section.style.display = '';
1322
+ var badgeLabels = {
1323
+ model_mismatch: 'Model Mismatch',
1324
+ floundering_detected: 'Got Stuck',
1325
+ repeated_reads: 'Repeated Reads',
1326
+ subagent_spike: 'Sub-agent Spike',
1327
+ cost_spike_day: 'Cost Spike'
1328
+ };
1329
+ grid.innerHTML = stories.map(function(s) {
1330
+ var badge = s.badge || badgeLabels[s.type] || s.type;
1331
+ var footer = 'Verified';
1332
+ if (s.sessions_analyzed) footer += ' · ' + s.sessions_analyzed + ' sessions analyzed';
1333
+ return (
1334
+ '<div class="story-card">' +
1335
+ '<div class="story-badge">' + esc(badge) + '</div>' +
1336
+ '<div class="story-title">' + esc(s.title) + '</div>' +
1337
+ '<div class="story-finding">' + esc(s.finding) + '</div>' +
1338
+ '<div class="story-action">' + esc(s.what_to_do) + '</div>' +
1339
+ '<div class="story-footer">' + esc(footer) + '</div>' +
1340
+ '</div>'
1341
+ );
1342
+ }).join('');
1343
+ }).catch(function() {
1344
+ $('stories-section').style.display = 'none';
1345
+ });
1346
+ }
1347
+
1348
+ function renderTrends(data) {
1349
+ const trends = data.trends || {};
1350
+ const daily = (trends.daily || []).slice(-7);
1351
+ const chart = $('trends-chart');
1352
+ if (!daily.length) {
1353
+ chart.innerHTML = '<div class="empty" style="margin:auto">No trend data yet.</div>';
1354
+ $('projection').textContent = '—';
1355
+ return;
1356
+ }
1357
+ const max = Math.max(...daily.map(d => d.cost), 0.01);
1358
+ chart.innerHTML = daily.map(d => {
1359
+ const h = Math.max((d.cost / max) * 130, 2);
1360
+ return (
1361
+ '<div class="bar-col">' +
1362
+ '<div class="bar-value">' + fmtMoney(d.cost) + '</div>' +
1363
+ '<div class="bar" style="height:' + h + 'px" title="' + fmtMoney(d.cost) + '"></div>' +
1364
+ '<div class="bar-label">' + esc(d.date.slice(5)) + '</div>' +
1365
+ '</div>'
1366
+ );
1367
+ }).join('');
1368
+ const mp = trends.monthly_projection || 0;
1369
+ const warn = mp > 1000 ? ' warn' : '';
1370
+ $('projection').className = 'projection' + warn;
1371
+ if (mp > 1000) {
1372
+ $('projection').innerHTML = '\u26a0 High burn rate \u2014 <span class="val">' + fmtMoney(mp) + '</span> API equiv projected' +
1373
+ '<div style="font-size:11px;color:var(--text-tertiary);margin-top:2px">Based on last 7 days. Max plan cost: $100/mo</div>';
1374
+ } else {
1375
+ $('projection').innerHTML = 'Monthly projection: <span class="val">' + fmtMoney(mp) + '</span>' +
1376
+ '<div style="font-size:11px;color:var(--text-tertiary);margin-top:2px">at current 7-day burn rate</div>';
1377
+ }
1378
+ }
1379
+
1380
+ function renderCompaction(data) {
1381
+ const c = data.compaction || {};
1382
+ $('compaction-stats').innerHTML =
1383
+ '<div><span class="val">' + fmtPct(c.avg_savings_pct || 0, 0) + '</span><div>avg savings</div></div>' +
1384
+ '<div><span class="val">' + fmtNum(c.compaction_count || 0) + '</span><div>events 30d</div></div>' +
1385
+ '<div><span class="val">' + fmtNum(c.sessions_needing_compact || 0) + '</span><div>needing compact</div></div>';
1386
+ }
1387
+
1388
+ function renderRightsizing(data) {
1389
+ const rs = data.rightsizing || [];
1390
+ if (!rs.length) {
1391
+ $('rightsizing').innerHTML = '<div class="empty">Every project is on the right model.</div>';
1392
+ return;
1393
+ }
1394
+ $('rightsizing').innerHTML =
1395
+ '<table class="eff-table">' +
1396
+ '<thead><tr><th>Project</th><th>Model</th><th>Avg out</th><th>Saves/mo</th></tr></thead>' +
1397
+ '<tbody>' + rs.map(s => (
1398
+ '<tr>' +
1399
+ '<td>' + esc(s.project) + '</td>' +
1400
+ '<td>' + esc((s.current_model || '').replace('claude-','')) + '</td>' +
1401
+ '<td>' + fmtNum(s.avg_output_tokens || 0) + '</td>' +
1402
+ '<td>' + fmtMoney(s.monthly_savings || 0) + '</td>' +
1403
+ '</tr>'
1404
+ )).join('') + '</tbody>' +
1405
+ '</table>';
1406
+ }
1407
+
1408
+ function renderFooter(data) {
1409
+ const rows = data.total_rows || 0;
1410
+ const dbSize = data.db_size_mb || 0;
1411
+ const lastScan = data.last_scan ? new Date(data.last_scan * 1000).toISOString().slice(11,16) : '—';
1412
+ $('footer').textContent =
1413
+ 'Claudash v1.0 · ' + fmtNum(rows) + ' records · ' + dbSize + 'MB · last scan ' + lastScan + ' UTC';
1414
+ $('scan-info').textContent = 'last scan ' + timeAgo(data.last_scan);
1415
+ }
1416
+
1417
+ let lastData = null;
1418
+ function render() { if (lastData) { paint(lastData); } else { refresh(); } }
1419
+ function paint(data) {
1420
+ var banner = $('first-run-banner');
1421
+ if (data.first_run) { banner.style.display = 'block'; } else { banner.style.display = 'none'; }
1422
+ buildTabs(data.accounts_list || []);
1423
+
1424
+ // Detect browser-only account (no JSONL sessions, has browser data)
1425
+ var accts = data.accounts_list || [];
1426
+ var curAcct = accts.find(function(a) { return a.account_id === currentAccount; });
1427
+ var isBrowserOnly = currentAccount !== 'all' && curAcct && curAcct.sessions_count === 0 && curAcct.has_browser_data;
1428
+
1429
+ renderHero(data);
1430
+ renderWindows(data);
1431
+
1432
+ if (isBrowserOnly) {
1433
+ // Hide sections that require JSONL session data
1434
+ $('trends-section').style.display = 'none';
1435
+ $('compaction-section').style.display = 'none';
1436
+ $('fix-tracker-section').style.display = 'none';
1437
+ $('stories-section').style.display = 'none';
1438
+ $('proj-body').innerHTML =
1439
+ '<tr><td colspan="8" class="empty" style="text-align:center;padding:24px">' +
1440
+ 'These insights require Claude Code sessions.<br>' +
1441
+ '<span style="color:var(--text-tertiary)">Browser tracking is active \u2014 window data above is live.</span>' +
1442
+ '</td></tr>';
1443
+ } else {
1444
+ $('trends-section').style.display = '';
1445
+ $('compaction-section').style.display = '';
1446
+ $('fix-tracker-section').style.display = '';
1447
+ renderProjects(data);
1448
+ renderStories(data);
1449
+ renderTrends(data);
1450
+ renderCompaction(data);
1451
+ renderRightsizing(data);
1452
+ }
1453
+
1454
+ renderInsights(data);
1455
+ renderFooter(data);
1456
+ }
1457
+
1458
+ function refresh() {
1459
+ fetch('/api/data?account=' + encodeURIComponent(currentAccount))
1460
+ .then(r => r.json())
1461
+ .then(data => {
1462
+ // Insights come from /api/data as just a count; fetch the list separately.
1463
+ const iq = currentAccount === 'all' ? '' : '?account=' + encodeURIComponent(currentAccount);
1464
+ return fetch('/api/insights' + iq + (iq ? '&' : '?') + 'dismissed=0')
1465
+ .then(r => r.json())
1466
+ .then(ins => { data._insights = ins; return data; });
1467
+ })
1468
+ .then(data => { lastData = data; paint(data); })
1469
+ .catch(err => { $('footer').textContent = 'Error: ' + err.message; });
1470
+ }
1471
+
1472
+ // Scan button
1473
+ $('scan-btn').addEventListener('click', function() {
1474
+ const prev = this.textContent;
1475
+ this.textContent = '↻ Scanning…';
1476
+ this.disabled = true;
1477
+ fetch('/api/scan', {method: 'POST', headers: authHeaders()})
1478
+ .then(r => r.json())
1479
+ .then(() => { this.textContent = prev; this.disabled = false; refresh(); })
1480
+ .catch(() => { this.textContent = prev; this.disabled = false; });
1481
+ });
1482
+
1483
+ // ── Fix tracker ─────────────────────────────────────────────
1484
+ let fixProjects = [];
1485
+
1486
+ function fetchFixes() {
1487
+ fetch('/api/fixes').then(r => r.json()).then(rows => {
1488
+ renderFixList(rows || []);
1489
+ }).catch(() => { renderFixList([]); });
1490
+ }
1491
+
1492
+ function renderFixList(rows) {
1493
+ const el = $('fix-list');
1494
+ $('fix-count').textContent = rows.length;
1495
+ if (!rows.length) {
1496
+ el.innerHTML = '<div class="fix-empty">No fixes recorded yet — click <b>+ Record a fix</b> to snapshot a baseline and start measuring.</div>';
1497
+ return;
1498
+ }
1499
+ el.innerHTML = rows.map(f => renderFixCard(f)).join('');
1500
+ wireFixCardEvents();
1501
+ }
1502
+
1503
+ function renderFixCard(f) {
1504
+ const baseline = f.baseline || {};
1505
+ const plan = baseline.plan_type || 'max';
1506
+ const planCost = baseline.plan_cost_usd || 0;
1507
+ const waste = baseline.waste_events || {};
1508
+ const days = Math.max(Math.floor((Date.now()/1000 - (f.created_at || 0)) / 86400), 0);
1509
+ const status = f.status || 'applied';
1510
+ const dotCls = status === 'confirmed' ? 'green'
1511
+ : status === 'reverted' ? 'gray' : 'amber';
1512
+ const patternClass = ['floundering','cost_outlier','cache_spike'].includes(f.waste_pattern) ? 'red'
1513
+ : 'amber';
1514
+
1515
+ // Resolve account label for fix card badge
1516
+ var fixAcctLabel = '';
1517
+ if (lastData && lastData.accounts_list) {
1518
+ var pa = lastData.accounts_list.find(function(a) {
1519
+ return lastData.projects && lastData.projects.some(function(p) {
1520
+ return p.name === f.project && p.account === a.account_id;
1521
+ });
1522
+ });
1523
+ if (pa) fixAcctLabel = pa.label || pa.account_id;
1524
+ }
1525
+
1526
+ // Left col
1527
+ const left =
1528
+ '<div class="col-left">' +
1529
+ '<div class="col-title">' +
1530
+ '<div class="status-dot ' + dotCls + '"></div>' +
1531
+ '<div class="fix-project">' + esc(f.project) + '</div>' +
1532
+ '<div class="pattern-pill ' + patternClass + '">' + esc((f.waste_pattern || '').replace(/_/g,' ')) + '</div>' +
1533
+ (fixAcctLabel ? '<span style="font-size:11px;color:var(--text-tertiary);margin-left:4px">' + esc(fixAcctLabel) + '</span>' : '') +
1534
+ '</div>' +
1535
+ '<div class="fix-title">' + esc(f.title || '(no title)') + '</div>' +
1536
+ '<div class="fix-meta">applied ' + days + 'd ago · ' + esc(f.fix_type || 'other') + '</div>' +
1537
+ '</div>';
1538
+
1539
+ // Middle col (baseline)
1540
+ let middle = '<div class="col-middle">';
1541
+ if (plan === 'max' || plan === 'pro') {
1542
+ middle += '<div><span class="k">window eff.</span> <span class="v">' + fmtPct(baseline.effective_window_pct || 0, 1) + '</span></div>';
1543
+ middle += '<div><span class="k">waste events</span> <span class="v">' + fmtNum(waste.total || 0) + '</span></div>';
1544
+ middle += '<div><span class="k">files/window</span> <span class="v">' + fmtNum(baseline.files_per_window || 0) + '</span></div>';
1545
+ } else {
1546
+ middle += '<div><span class="k">cost/session</span> <span class="v">' + fmtMoney(baseline.avg_cost_per_session || 0) + '</span></div>';
1547
+ middle += '<div><span class="k">waste events</span> <span class="v">' + fmtNum(waste.total || 0) + '</span></div>';
1548
+ middle += '<div><span class="k">window cost</span> <span class="v">' + fmtMoney(baseline.cost_usd || 0) + '</span></div>';
1549
+ }
1550
+ middle += '</div>';
1551
+
1552
+ // Right col — status-dependent
1553
+ let right = '<div class="col-right">';
1554
+ if (status === 'confirmed' && f.latest) {
1555
+ const delta = f.latest.delta || {};
1556
+ const eff = delta.effective_window_pct || {};
1557
+ const fpw = delta.files_per_window || {};
1558
+ const waste2 = delta.waste_events || {};
1559
+ const cps = delta.avg_cost_per_session || {};
1560
+ const mult = delta.improvement_multiplier || 1;
1561
+ const apiEq = delta.api_equivalent_savings_monthly || 0;
1562
+ if (plan === 'max' || plan === 'pro') {
1563
+ right += '<div class="delta-line"><span class="k">waste</span> ' + waste2.before + ' → <span class="up">' + waste2.after + '</span> (' + waste2.pct_change + '%)</div>';
1564
+ right += '<div class="delta-line"><span class="k">window</span> ' + (eff.before || 0) + '% → <span class="up">' + (eff.after || 0) + '%</span></div>';
1565
+ right += '<div class="delta-line"><span class="k">output</span> ' + (fpw.before || 0) + ' → <span class="up">' + (fpw.after || 0) + '</span> files/window</div>';
1566
+ right += '<div class="fix-meta">Same $' + planCost.toFixed(0) + '/mo · <b>' + mult + '×</b> more output · API-equiv ~$' + apiEq.toFixed(0) + '/mo</div>';
1567
+ } else {
1568
+ right += '<div class="delta-line"><span class="k">cost/sess</span> ' + fmtMoney(cps.before || 0) + ' → <span class="up">' + fmtMoney(cps.after || 0) + '</span></div>';
1569
+ right += '<div class="delta-line"><span class="k">waste</span> ' + waste2.before + ' → <span class="up">' + waste2.after + '</span></div>';
1570
+ right += '<div class="fix-meta">Saved <b>~$' + apiEq.toFixed(0) + '/mo</b></div>';
1571
+ }
1572
+ right += '<div class="card-actions">' +
1573
+ '<button class="btn-action" data-share="' + f.id + '">Copy share card</button>' +
1574
+ '<button class="btn-action ghost" data-measure="' + f.id + '">Re-measure</button>' +
1575
+ '<span class="fix-toast" id="fix-toast-' + f.id + '"></span>' +
1576
+ '</div>';
1577
+ } else if (status === 'reverted') {
1578
+ right += '<div class="fix-meta">Reverted</div>';
1579
+ right += '<div class="card-actions"><button class="btn-action ghost" data-delete="' + f.id + '">Remove</button></div>';
1580
+ } else {
1581
+ right += '<div class="delta-line"><span class="k">' + days + ' days elapsed</span></div>';
1582
+ if (f.latest) {
1583
+ const d = f.latest.delta || {};
1584
+ const w = d.waste_events || {};
1585
+ right += '<div class="delta-line"><span class="k">latest</span> ' + (w.before || 0) + ' → ' + (w.after || 0) + ' (' + (w.pct_change || 0) + '%) · verdict: <b>' + esc(f.latest.verdict || '') + '</b></div>';
1586
+ }
1587
+ right += '<div class="card-actions">' +
1588
+ '<button class="btn-action" data-measure="' + f.id + '">Measure now</button>' +
1589
+ '<button class="btn-action ghost" data-delete="' + f.id + '">Revert</button>' +
1590
+ '<span class="fix-toast" id="fix-toast-' + f.id + '"></span>' +
1591
+ '</div>';
1592
+ }
1593
+ right += '</div>';
1594
+
1595
+ return '<div class="fix-card" data-fix="' + f.id + '">' + left + middle + right + '</div>';
1596
+ }
1597
+
1598
+ function wireFixCardEvents() {
1599
+ document.querySelectorAll('[data-measure]').forEach(b => {
1600
+ b.addEventListener('click', () => {
1601
+ const id = b.dataset.measure;
1602
+ b.disabled = true;
1603
+ b.textContent = 'Measuring…';
1604
+ fetch('/api/fixes/' + id + '/measure', {method: 'POST', headers: authHeaders()})
1605
+ .then(r => r.json())
1606
+ .then(() => fetchFixes())
1607
+ .catch(() => { b.disabled = false; b.textContent = 'Measure now'; });
1608
+ });
1609
+ });
1610
+ document.querySelectorAll('[data-share]').forEach(b => {
1611
+ b.addEventListener('click', () => {
1612
+ const id = b.dataset.share;
1613
+ fetch('/api/fixes/' + id + '/share-card').then(r => r.text()).then(text => {
1614
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1615
+ navigator.clipboard.writeText(text);
1616
+ } else {
1617
+ const ta = document.createElement('textarea');
1618
+ ta.value = text;
1619
+ document.body.appendChild(ta);
1620
+ ta.select();
1621
+ try { document.execCommand('copy'); } catch (e) {}
1622
+ document.body.removeChild(ta);
1623
+ }
1624
+ const toast = $('fix-toast-' + id);
1625
+ if (toast) { toast.textContent = 'Copied!'; setTimeout(() => { toast.textContent = ''; }, 2000); }
1626
+ });
1627
+ });
1628
+ });
1629
+ document.querySelectorAll('[data-delete]').forEach(b => {
1630
+ b.addEventListener('click', () => {
1631
+ const id = b.dataset.delete;
1632
+ if (!confirm('Revert this fix? The baseline and measurements are kept.')) return;
1633
+ fetch('/api/fixes/' + id, {method: 'DELETE', headers: authHeaders()})
1634
+ .then(() => fetchFixes());
1635
+ });
1636
+ });
1637
+ }
1638
+
1639
+ function showFixForm() {
1640
+ const f = $('fix-form');
1641
+ f.classList.add('open');
1642
+ const accts = (lastData && lastData.accounts_list) || [];
1643
+ const projectRows = (lastData && lastData.projects) || [];
1644
+ const projectNames = Array.from(new Set(projectRows.map(p => p.name))).sort();
1645
+ const projectOpts = projectNames.map(n => '<option value="' + esc(n) + '">' + esc(n) + '</option>').join('');
1646
+ f.innerHTML =
1647
+ '<h3>Record a fix</h3>' +
1648
+ '<div class="form-grid">' +
1649
+ '<div class="form-field"><label>Project</label><select id="ff-project">' + projectOpts + '</select></div>' +
1650
+ '<div class="form-field"><label>What was detected</label>' +
1651
+ '<select id="ff-pattern">' +
1652
+ '<option value="floundering">Floundering (tool retry loops)</option>' +
1653
+ '<option value="repeated_reads">Repeated reads (same file 3+ times)</option>' +
1654
+ '<option value="deep_no_compact">Deep session no compaction</option>' +
1655
+ '<option value="cost_outlier">Cost outlier (session 3x average)</option>' +
1656
+ '<option value="cache_spike">Cache spike</option>' +
1657
+ '<option value="model_waste">Model waste (Opus short outputs)</option>' +
1658
+ '<option value="custom">Custom</option>' +
1659
+ '</select></div>' +
1660
+ '<div class="form-field wide"><label>Fix title</label><input id="ff-title" type="text" placeholder="e.g. Add max-retry rule to CLAUDE.md"></div>' +
1661
+ '<div class="form-field"><label>Fix type</label>' +
1662
+ '<select id="ff-type">' +
1663
+ '<option value="claude_md">CLAUDE.md rule</option>' +
1664
+ '<option value="settings_json">settings.json change</option>' +
1665
+ '<option value="prompt">Prompt change</option>' +
1666
+ '<option value="architecture">Architecture change</option>' +
1667
+ '<option value="other">Other</option>' +
1668
+ '</select></div>' +
1669
+ '<div class="form-field wide"><label>What exactly changed</label><textarea id="ff-detail" placeholder="Paste your fix here — CLAUDE.md snippet, settings diff, new prompt…"></textarea></div>' +
1670
+ '</div>' +
1671
+ '<div class="form-actions">' +
1672
+ '<button class="btn-primary" id="ff-save">Capture baseline & save</button>' +
1673
+ '<button class="btn-ghost" id="ff-cancel">Cancel</button>' +
1674
+ '</div>';
1675
+
1676
+ $('ff-cancel').addEventListener('click', () => { f.classList.remove('open'); });
1677
+ $('ff-save').addEventListener('click', () => {
1678
+ const payload = {
1679
+ project: $('ff-project').value,
1680
+ waste_pattern: $('ff-pattern').value,
1681
+ title: $('ff-title').value.trim(),
1682
+ fix_type: $('ff-type').value,
1683
+ fix_detail: $('ff-detail').value,
1684
+ };
1685
+ if (!payload.project || !payload.title) return;
1686
+ $('ff-save').disabled = true;
1687
+ $('ff-save').textContent = 'Saving…';
1688
+ fetch('/api/fixes', {method: 'POST', headers: authHeaders(), body: JSON.stringify(payload)})
1689
+ .then(r => r.json())
1690
+ .then(resp => {
1691
+ if (resp.success) { f.classList.remove('open'); fetchFixes(); }
1692
+ else { $('ff-save').disabled = false; $('ff-save').textContent = 'Capture baseline & save'; alert(resp.error || 'Save failed'); }
1693
+ })
1694
+ .catch(() => { $('ff-save').disabled = false; $('ff-save').textContent = 'Capture baseline & save'; });
1695
+ });
1696
+ }
1697
+
1698
+ $('add-fix-btn').addEventListener('click', showFixForm);
1699
+ fetchFixes();
1700
+
1701
+ // Auto-refresh
1702
+ refresh();
1703
+ refreshTimer = setInterval(refresh, REFRESH_MS);
1704
+
1705
+ // Health check — connection monitoring
1706
+ var healthMisses = 0;
1707
+ var wasDisconnected = false;
1708
+ function healthCheck() {
1709
+ var controller = new AbortController();
1710
+ var timeout = setTimeout(function() { controller.abort(); }, 3000);
1711
+ fetch('/health', {signal: controller.signal})
1712
+ .then(function(r) {
1713
+ clearTimeout(timeout);
1714
+ if (r.ok) {
1715
+ if (wasDisconnected) {
1716
+ $('health-banner').style.display = 'none';
1717
+ $('reconnect-toast').style.display = 'block';
1718
+ setTimeout(function() { $('reconnect-toast').style.display = 'none'; }, 3000);
1719
+ wasDisconnected = false;
1720
+ refresh();
1721
+ }
1722
+ healthMisses = 0;
1723
+ } else {
1724
+ healthMisses++;
1725
+ }
1726
+ })
1727
+ .catch(function() {
1728
+ clearTimeout(timeout);
1729
+ healthMisses++;
1730
+ })
1731
+ .then(function() {
1732
+ if (healthMisses >= 2) {
1733
+ $('health-banner').style.display = 'block';
1734
+ wasDisconnected = true;
1735
+ }
1736
+ });
1737
+ }
1738
+ setInterval(healthCheck, 30000);
1739
+ })();
1740
+ </script>
1741
+ </body>
1742
+ </html>