claude-roi 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-roi",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Correlate Claude Code token usage with git output to measure AI coding agent ROI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,15 @@
6
6
  <title>Codelens AI — Agent Productivity Dashboard</title>
7
7
  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
8
8
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
9
+ <script>
10
+ (function() {
11
+ var pref = localStorage.getItem('codelens-theme') || 'system';
12
+ var resolved = pref === 'system'
13
+ ? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
14
+ : pref;
15
+ if (resolved === 'light') document.documentElement.setAttribute('data-theme', 'light');
16
+ })();
17
+ </script>
9
18
  <style>
10
19
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
20
 
@@ -46,6 +55,86 @@
46
55
  --radius-sm: 8px;
47
56
  --font-display: 'JetBrains Mono', 'SF Mono', monospace;
48
57
  --font-body: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
58
+
59
+ /* Glassmorphism & overlay tokens */
60
+ --glass-bg-from: rgba(21, 29, 43, 0.8);
61
+ --glass-bg-to: rgba(15, 23, 35, 0.6);
62
+ --glass-border: rgba(255, 255, 255, 0.05);
63
+ --glass-border-hover: rgba(255, 255, 255, 0.1);
64
+ --overlay-subtle: rgba(255, 255, 255, 0.01);
65
+ --overlay-light: rgba(255, 255, 255, 0.02);
66
+ --overlay-soft: rgba(255, 255, 255, 0.03);
67
+ --overlay-medium: rgba(255, 255, 255, 0.04);
68
+ --overlay-strong: rgba(255, 255, 255, 0.06);
69
+ --overlay-intense: rgba(255, 255, 255, 0.08);
70
+ --shadow-card: rgba(0, 0, 0, 0.3);
71
+ --shadow-card-hover: rgba(0, 0, 0, 0.4);
72
+ --shadow-medium: rgba(0, 0, 0, 0.2);
73
+ --shadow-elevated: rgba(0, 0, 0, 0.25);
74
+ --tooltip-bg: rgba(10, 14, 23, 0.95);
75
+ --tooltip-border: rgba(255, 255, 255, 0.06);
76
+ --noise-opacity: 0.015;
77
+ --funnel-text: rgba(255, 255, 255, 0.9);
78
+
79
+ /* Grade background tokens */
80
+ --grade-a-bg: rgba(34, 211, 168, 0.13);
81
+ --grade-b-bg: rgba(59, 130, 246, 0.13);
82
+ --grade-c-bg: rgba(245, 158, 11, 0.13);
83
+ --grade-d-bg: rgba(240, 136, 62, 0.13);
84
+ --grade-f-bg: rgba(239, 68, 68, 0.13);
85
+ }
86
+
87
+ /* ── Light Theme ─────────────────────────────── */
88
+ [data-theme="light"] {
89
+ --bg-primary: #f8fafc;
90
+ --bg-secondary: #f1f5f9;
91
+ --bg-card: #ffffff;
92
+ --bg-hover: #e2e8f0;
93
+ --bg-elevated: #f1f5f9;
94
+ --border: #cbd5e1;
95
+ --border-subtle: #e2e8f0;
96
+ --text-primary: #0f172a;
97
+ --text-secondary: #475569;
98
+ --text-muted: #64748b;
99
+ --accent-green: #0d9488;
100
+ --accent-red: #dc2626;
101
+ --accent-orange: #d97706;
102
+ --accent-blue: #2563eb;
103
+ --accent-purple: #7c3aed;
104
+ --accent-cyan: #0891b2;
105
+ --grade-a: #0d9488;
106
+ --grade-b: #2563eb;
107
+ --grade-c: #d97706;
108
+ --grade-d: #ea580c;
109
+ --grade-f: #dc2626;
110
+ --glow-green: rgba(13, 148, 136, 0.1);
111
+ --glow-blue: rgba(37, 99, 235, 0.1);
112
+ --glow-purple: rgba(124, 58, 237, 0.1);
113
+ --glow-orange: rgba(217, 119, 6, 0.1);
114
+ --glow-red: rgba(220, 38, 38, 0.1);
115
+ --glass-bg-from: rgba(255, 255, 255, 0.7);
116
+ --glass-bg-to: rgba(241, 245, 249, 0.5);
117
+ --glass-border: rgba(0, 0, 0, 0.06);
118
+ --glass-border-hover: rgba(0, 0, 0, 0.12);
119
+ --overlay-subtle: rgba(0, 0, 0, 0.01);
120
+ --overlay-light: rgba(0, 0, 0, 0.02);
121
+ --overlay-soft: rgba(0, 0, 0, 0.03);
122
+ --overlay-medium: rgba(0, 0, 0, 0.04);
123
+ --overlay-strong: rgba(0, 0, 0, 0.06);
124
+ --overlay-intense: rgba(0, 0, 0, 0.08);
125
+ --shadow-card: rgba(0, 0, 0, 0.06);
126
+ --shadow-card-hover: rgba(0, 0, 0, 0.1);
127
+ --shadow-medium: rgba(0, 0, 0, 0.08);
128
+ --shadow-elevated: rgba(0, 0, 0, 0.12);
129
+ --tooltip-bg: rgba(15, 23, 42, 0.92);
130
+ --tooltip-border: rgba(255, 255, 255, 0.1);
131
+ --noise-opacity: 0.008;
132
+ --funnel-text: rgba(0, 0, 0, 0.8);
133
+ --grade-a-bg: rgba(13, 148, 136, 0.1);
134
+ --grade-b-bg: rgba(37, 99, 235, 0.1);
135
+ --grade-c-bg: rgba(217, 119, 6, 0.1);
136
+ --grade-d-bg: rgba(234, 88, 12, 0.1);
137
+ --grade-f-bg: rgba(220, 38, 38, 0.1);
49
138
  }
50
139
 
51
140
  body {
@@ -65,7 +154,7 @@
65
154
  inset: 0;
66
155
  pointer-events: none;
67
156
  z-index: 9999;
68
- opacity: 0.015;
157
+ opacity: var(--noise-opacity);
69
158
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
70
159
  background-repeat: repeat;
71
160
  background-size: 256px 256px;
@@ -139,15 +228,15 @@
139
228
  color: var(--text-muted);
140
229
  }
141
230
  .meta-info .badge {
142
- background: rgba(255, 255, 255, 0.04);
231
+ background: var(--overlay-medium);
143
232
  backdrop-filter: blur(12px);
144
- border: 1px solid rgba(255, 255, 255, 0.06);
233
+ border: 1px solid var(--overlay-strong);
145
234
  border-radius: 20px;
146
235
  padding: 5px 14px;
147
236
  transition: background 0.3s;
148
237
  }
149
238
  .meta-info .badge:hover {
150
- background: rgba(255, 255, 255, 0.08);
239
+ background: var(--overlay-intense);
151
240
  }
152
241
 
153
242
  /* Shared section heading style */
@@ -188,9 +277,9 @@
188
277
  gap: 12px;
189
278
  }
190
279
  .stat-card {
191
- background: linear-gradient(145deg, rgba(21, 29, 43, 0.8), rgba(15, 23, 35, 0.6));
280
+ background: linear-gradient(145deg, var(--glass-bg-from), var(--glass-bg-to));
192
281
  backdrop-filter: blur(16px);
193
- border: 1px solid rgba(255, 255, 255, 0.05);
282
+ border: 1px solid var(--glass-border);
194
283
  border-radius: var(--radius);
195
284
  padding: 20px 24px;
196
285
  display: flex;
@@ -205,9 +294,9 @@
205
294
  }
206
295
  .stat-card:hover {
207
296
  transform: translateY(-3px);
208
- border-color: rgba(255, 255, 255, 0.1);
209
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
210
- 0 0 0 1px rgba(255, 255, 255, 0.05);
297
+ border-color: var(--glass-border-hover);
298
+ box-shadow: 0 8px 32px var(--shadow-card),
299
+ 0 0 0 1px var(--glass-border);
211
300
  }
212
301
  .stat-card.glow::before {
213
302
  content: '';
@@ -309,8 +398,8 @@
309
398
  gap: 24px;
310
399
  margin-top: 16px;
311
400
  padding: 12px 20px;
312
- background: rgba(255, 255, 255, 0.02);
313
- border: 1px solid rgba(255, 255, 255, 0.04);
401
+ background: var(--overlay-light);
402
+ border: 1px solid var(--overlay-medium);
314
403
  border-radius: var(--radius-sm);
315
404
  }
316
405
  .hero-legend span, .cost-legend span, .token-legend span {
@@ -350,15 +439,15 @@
350
439
  align-items: flex-start;
351
440
  gap: 10px;
352
441
  padding: 14px 18px;
353
- background: rgba(255, 255, 255, 0.02);
354
- border: 1px solid rgba(255, 255, 255, 0.04);
442
+ background: var(--overlay-light);
443
+ border: 1px solid var(--overlay-medium);
355
444
  border-radius: var(--radius-sm);
356
445
  font-size: 0.88rem;
357
446
  transition: background 0.2s, border-color 0.2s, transform 0.2s;
358
447
  }
359
448
  .insight:hover {
360
- background: rgba(255, 255, 255, 0.04);
361
- border-color: rgba(255, 255, 255, 0.08);
449
+ background: var(--overlay-medium);
450
+ border-color: var(--overlay-intense);
362
451
  transform: translateX(4px);
363
452
  }
364
453
  .insight .icon { font-size: 1.1rem; flex-shrink: 0; }
@@ -375,16 +464,16 @@
375
464
  margin-bottom: 48px;
376
465
  }
377
466
  .chart-card {
378
- background: linear-gradient(145deg, rgba(21, 29, 43, 0.8), rgba(15, 23, 35, 0.6));
467
+ background: linear-gradient(145deg, var(--glass-bg-from), var(--glass-bg-to));
379
468
  backdrop-filter: blur(16px);
380
- border: 1px solid rgba(255, 255, 255, 0.05);
469
+ border: 1px solid var(--glass-border);
381
470
  border-radius: var(--radius);
382
471
  padding: 24px;
383
472
  transition: border-color 0.3s, box-shadow 0.3s;
384
473
  }
385
474
  .chart-card:hover {
386
- border-color: rgba(255, 255, 255, 0.08);
387
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
475
+ border-color: var(--overlay-intense);
476
+ box-shadow: 0 4px 20px var(--shadow-medium);
388
477
  }
389
478
  .chart-card.full-width {
390
479
  grid-column: 1 / -1;
@@ -407,8 +496,8 @@
407
496
  .chart-header h3 { margin-bottom: 0; }
408
497
  .scale-toggle {
409
498
  padding: 5px 12px;
410
- border: 1px solid rgba(255, 255, 255, 0.08);
411
- background: rgba(255, 255, 255, 0.03);
499
+ border: 1px solid var(--overlay-intense);
500
+ background: var(--overlay-soft);
412
501
  color: var(--text-secondary);
413
502
  border-radius: 6px;
414
503
  cursor: pointer;
@@ -420,7 +509,7 @@
420
509
  .scale-toggle:hover {
421
510
  color: var(--text-primary);
422
511
  border-color: var(--accent-blue);
423
- background: rgba(59, 130, 246, 0.08);
512
+ background: var(--glow-blue);
424
513
  }
425
514
  .chart-container {
426
515
  position: relative;
@@ -433,9 +522,9 @@
433
522
  margin-bottom: 48px;
434
523
  }
435
524
  .survival-card {
436
- background: linear-gradient(145deg, rgba(21, 29, 43, 0.8), rgba(15, 23, 35, 0.6));
525
+ background: linear-gradient(145deg, var(--glass-bg-from), var(--glass-bg-to));
437
526
  backdrop-filter: blur(16px);
438
- border: 1px solid rgba(255, 255, 255, 0.05);
527
+ border: 1px solid var(--glass-border);
439
528
  border-radius: var(--radius);
440
529
  padding: 24px;
441
530
  }
@@ -458,7 +547,7 @@
458
547
  width: 16px;
459
548
  height: 16px;
460
549
  border-radius: 50%;
461
- background: rgba(255, 255, 255, 0.05);
550
+ background: var(--glass-border);
462
551
  color: var(--text-muted);
463
552
  font-size: 0.6rem;
464
553
  font-style: normal;
@@ -470,7 +559,7 @@
470
559
  transition: background 0.2s, color 0.2s;
471
560
  }
472
561
  .info-tip:hover {
473
- background: rgba(59, 130, 246, 0.15);
562
+ background: var(--glow-blue);
474
563
  color: var(--accent-blue);
475
564
  }
476
565
  @keyframes tooltipFade {
@@ -483,7 +572,7 @@
483
572
  bottom: calc(100% + 10px);
484
573
  left: 50%;
485
574
  transform: translateX(-50%) translateY(0);
486
- background: rgba(10, 14, 23, 0.95);
575
+ background: var(--tooltip-bg);
487
576
  backdrop-filter: blur(16px);
488
577
  color: var(--text-secondary);
489
578
  padding: 10px 14px;
@@ -492,10 +581,10 @@
492
581
  font-weight: 400;
493
582
  width: 280px;
494
583
  line-height: 1.5;
495
- border: 1px solid rgba(255, 255, 255, 0.06);
584
+ border: 1px solid var(--tooltip-border);
496
585
  z-index: 100;
497
586
  pointer-events: none;
498
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
587
+ box-shadow: 0 8px 32px var(--shadow-card-hover);
499
588
  white-space: normal;
500
589
  text-transform: none;
501
590
  letter-spacing: normal;
@@ -508,13 +597,13 @@
508
597
  }
509
598
  /* Make table header tooltip icons more visible */
510
599
  thead .info-tip {
511
- background: rgba(255,255,255,0.1);
600
+ background: var(--glass-border-hover);
512
601
  color: var(--text-secondary);
513
602
  }
514
603
  .survival-bar {
515
604
  height: 24px;
516
- background: rgba(255, 255, 255, 0.04);
517
- border: 1px solid rgba(255, 255, 255, 0.06);
605
+ background: var(--overlay-medium);
606
+ border: 1px solid var(--overlay-strong);
518
607
  border-radius: 12px;
519
608
  overflow: hidden;
520
609
  margin-bottom: 12px;
@@ -586,9 +675,9 @@
586
675
  letter-spacing: 0.12em;
587
676
  }
588
677
  .sessions-table-wrap {
589
- background: linear-gradient(145deg, rgba(21, 29, 43, 0.8), rgba(15, 23, 35, 0.6));
678
+ background: linear-gradient(145deg, var(--glass-bg-from), var(--glass-bg-to));
590
679
  backdrop-filter: blur(16px);
591
- border: 1px solid rgba(255, 255, 255, 0.05);
680
+ border: 1px solid var(--glass-border);
592
681
  border-radius: var(--radius);
593
682
  overflow-x: auto;
594
683
  overflow-y: visible;
@@ -607,7 +696,7 @@
607
696
  font-size: 0.75rem;
608
697
  text-transform: uppercase;
609
698
  letter-spacing: 0.08em;
610
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
699
+ border-bottom: 1px solid var(--overlay-strong);
611
700
  cursor: pointer;
612
701
  user-select: none;
613
702
  white-space: nowrap;
@@ -626,10 +715,10 @@
626
715
  background: var(--accent-blue);
627
716
  }
628
717
  tbody tr {
629
- border-bottom: 1px solid rgba(255, 255, 255, 0.03);
718
+ border-bottom: 1px solid var(--overlay-soft);
630
719
  transition: background 0.2s;
631
720
  }
632
- tbody tr:hover { background: rgba(255, 255, 255, 0.03); }
721
+ tbody tr:hover { background: var(--overlay-soft); }
633
722
  tbody tr:last-child { border-bottom: none; }
634
723
  tbody td {
635
724
  padding: 12px 14px;
@@ -654,7 +743,7 @@
654
743
  tr.orphaned { background: rgba(245, 158, 11, 0.06); }
655
744
  .expand-row {
656
745
  display: none;
657
- background: rgba(255, 255, 255, 0.01);
746
+ background: var(--overlay-subtle);
658
747
  }
659
748
  .expand-row.open { display: table-row; animation: fadeSlideUp 0.3s ease forwards; }
660
749
  .expand-row td {
@@ -704,8 +793,8 @@
704
793
  }
705
794
  .pagination button {
706
795
  padding: 8px 18px;
707
- border: 1px solid rgba(255, 255, 255, 0.08);
708
- background: rgba(255, 255, 255, 0.03);
796
+ border: 1px solid var(--overlay-intense);
797
+ background: var(--overlay-soft);
709
798
  color: var(--text-primary);
710
799
  border-radius: 8px;
711
800
  cursor: pointer;
@@ -714,9 +803,9 @@
714
803
  transition: all 0.2s;
715
804
  }
716
805
  .pagination button:hover:not(:disabled) {
717
- background: rgba(255, 255, 255, 0.08);
806
+ background: var(--overlay-intense);
718
807
  border-color: var(--accent-blue);
719
- box-shadow: 0 0 12px rgba(59, 130, 246, 0.15);
808
+ box-shadow: 0 0 12px var(--glow-blue);
720
809
  }
721
810
  .pagination button:disabled { opacity: 0.4; cursor: default; }
722
811
  .pagination span { font-size: 0.8rem; color: var(--text-muted); }
@@ -768,9 +857,9 @@
768
857
  gap: 16px;
769
858
  }
770
859
  .token-stat-card {
771
- background: linear-gradient(145deg, rgba(21, 29, 43, 0.8), rgba(15, 23, 35, 0.6));
860
+ background: linear-gradient(145deg, var(--glass-bg-from), var(--glass-bg-to));
772
861
  backdrop-filter: blur(16px);
773
- border: 1px solid rgba(255, 255, 255, 0.05);
862
+ border: 1px solid var(--glass-border);
774
863
  border-radius: var(--radius);
775
864
  padding: 20px 24px;
776
865
  text-align: center;
@@ -782,7 +871,7 @@
782
871
  }
783
872
  .token-stat-card:hover {
784
873
  transform: translateY(-3px);
785
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
874
+ box-shadow: 0 8px 32px var(--shadow-elevated);
786
875
  }
787
876
  .token-stat-card.burned { border-top-color: var(--accent-orange); }
788
877
  .token-stat-card.wasted { border-top-color: var(--accent-red); }
@@ -906,9 +995,9 @@
906
995
  display: grid;
907
996
  grid-template-columns: repeat(4, 1fr);
908
997
  gap: 0;
909
- background: linear-gradient(145deg, rgba(21, 29, 43, 0.8), rgba(15, 23, 35, 0.6));
998
+ background: linear-gradient(145deg, var(--glass-bg-from), var(--glass-bg-to));
910
999
  backdrop-filter: blur(16px);
911
- border: 1px solid rgba(255, 255, 255, 0.05);
1000
+ border: 1px solid var(--glass-border);
912
1001
  border-radius: var(--radius);
913
1002
  overflow: hidden;
914
1003
  position: relative;
@@ -933,7 +1022,7 @@
933
1022
  position: relative;
934
1023
  }
935
1024
  .period-card + .period-card {
936
- border-left: 1px solid rgba(255, 255, 255, 0.05);
1025
+ border-left: 1px solid var(--glass-border);
937
1026
  }
938
1027
  .period-card:hover {
939
1028
  background: var(--bg-hover);
@@ -989,9 +1078,9 @@
989
1078
  margin-bottom: 48px;
990
1079
  }
991
1080
  .token-funnel-card {
992
- background: linear-gradient(145deg, rgba(21, 29, 43, 0.8), rgba(15, 23, 35, 0.6));
1081
+ background: linear-gradient(145deg, var(--glass-bg-from), var(--glass-bg-to));
993
1082
  backdrop-filter: blur(16px);
994
- border: 1px solid rgba(255, 255, 255, 0.05);
1083
+ border: 1px solid var(--glass-border);
995
1084
  border-radius: var(--radius);
996
1085
  padding: 24px;
997
1086
  }
@@ -1030,7 +1119,7 @@
1030
1119
  justify-content: center;
1031
1120
  font-size: 0.7rem;
1032
1121
  font-weight: 500;
1033
- color: rgba(255,255,255,0.9);
1122
+ color: var(--funnel-text);
1034
1123
  overflow: hidden;
1035
1124
  white-space: nowrap;
1036
1125
  }
@@ -1104,6 +1193,112 @@
1104
1193
  animation: gradeReveal 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
1105
1194
  }
1106
1195
 
1196
+ /* ── Header Actions (top-right buttons) ─────── */
1197
+ .header-actions {
1198
+ position: absolute;
1199
+ top: 16px;
1200
+ right: 16px;
1201
+ z-index: 10;
1202
+ display: flex;
1203
+ align-items: center;
1204
+ gap: 8px;
1205
+ }
1206
+ .header-action-btn {
1207
+ width: 42px;
1208
+ height: 42px;
1209
+ border-radius: 50%;
1210
+ border: 1px solid var(--glass-border);
1211
+ background: var(--overlay-medium);
1212
+ backdrop-filter: blur(12px);
1213
+ color: var(--text-secondary);
1214
+ cursor: pointer;
1215
+ display: flex;
1216
+ align-items: center;
1217
+ justify-content: center;
1218
+ transition: background 0.3s, border-color 0.3s, color 0.3s, transform 0.2s;
1219
+ }
1220
+ .header-action-btn:hover {
1221
+ background: var(--overlay-intense);
1222
+ border-color: var(--glass-border-hover);
1223
+ color: var(--text-primary);
1224
+ transform: scale(1.1);
1225
+ }
1226
+ .header-action-btn svg {
1227
+ width: 18px;
1228
+ height: 18px;
1229
+ }
1230
+ .refresh-btn.refreshing svg {
1231
+ animation: spin 0.8s linear infinite;
1232
+ }
1233
+ .theme-switcher {
1234
+ display: flex;
1235
+ align-items: center;
1236
+ border-radius: 22px;
1237
+ border: 1px solid var(--glass-border);
1238
+ background: var(--overlay-medium);
1239
+ backdrop-filter: blur(12px);
1240
+ padding: 3px;
1241
+ gap: 2px;
1242
+ }
1243
+ .theme-switcher button {
1244
+ width: 34px;
1245
+ height: 34px;
1246
+ border-radius: 50%;
1247
+ border: none;
1248
+ background: transparent;
1249
+ color: var(--text-muted);
1250
+ cursor: pointer;
1251
+ display: flex;
1252
+ align-items: center;
1253
+ justify-content: center;
1254
+ transition: background 0.25s, color 0.25s, transform 0.15s;
1255
+ }
1256
+ .theme-switcher button:hover {
1257
+ color: var(--text-primary);
1258
+ transform: scale(1.08);
1259
+ }
1260
+ .theme-switcher button.active {
1261
+ background: var(--overlay-intense);
1262
+ color: var(--text-primary);
1263
+ box-shadow: 0 1px 4px var(--shadow-card);
1264
+ }
1265
+ .theme-switcher button svg {
1266
+ width: 16px;
1267
+ height: 16px;
1268
+ }
1269
+
1270
+ /* ── Light Theme Overrides ───────────────────── */
1271
+ [data-theme="light"] body {
1272
+ background:
1273
+ radial-gradient(ellipse 80% 50% at 50% -20%, rgba(37, 99, 235, 0.05), transparent),
1274
+ radial-gradient(ellipse 60% 40% at 100% 50%, rgba(124, 58, 237, 0.04), transparent),
1275
+ radial-gradient(ellipse 50% 30% at 0% 80%, rgba(13, 148, 136, 0.04), transparent),
1276
+ var(--bg-primary);
1277
+ }
1278
+ [data-theme="light"] header::before {
1279
+ background:
1280
+ radial-gradient(ellipse 60% 50% at 20% 50%, rgba(37, 99, 235, 0.07), transparent),
1281
+ radial-gradient(ellipse 50% 60% at 80% 30%, rgba(124, 58, 237, 0.05), transparent),
1282
+ radial-gradient(ellipse 40% 40% at 50% 80%, rgba(13, 148, 136, 0.04), transparent);
1283
+ }
1284
+ [data-theme="light"] .info-tip:hover::after {
1285
+ color: #e2e8f0;
1286
+ }
1287
+ [data-theme="light"] tr.orphaned {
1288
+ background: rgba(217, 119, 6, 0.06);
1289
+ }
1290
+ [data-theme="light"] .main-badge {
1291
+ background: rgba(13, 148, 136, 0.12);
1292
+ }
1293
+
1294
+ /* Smooth theme transition */
1295
+ html.theme-transition,
1296
+ html.theme-transition *,
1297
+ html.theme-transition *::before,
1298
+ html.theme-transition *::after {
1299
+ transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease, background 0.3s ease !important;
1300
+ }
1301
+
1107
1302
  /* Responsive */
1108
1303
  @media (max-width: 900px) {
1109
1304
  .hero-stats { grid-template-columns: 1fr; }
@@ -1112,7 +1307,7 @@
1112
1307
  .period-stats { grid-template-columns: repeat(2, 1fr); }
1113
1308
  .period-stats::before { display: none; }
1114
1309
  .period-card:nth-child(3) { border-left: none; }
1115
- .period-card:nth-child(3), .period-card:nth-child(4) { border-top: 1px solid rgba(255, 255, 255, 0.05); }
1310
+ .period-card:nth-child(3), .period-card:nth-child(4) { border-top: 1px solid var(--glass-border); }
1116
1311
  .charts-grid { grid-template-columns: 1fr; }
1117
1312
  }
1118
1313
  @media (max-width: 600px) {
@@ -1128,6 +1323,30 @@
1128
1323
  <body>
1129
1324
  <div class="container">
1130
1325
  <header>
1326
+ <div class="header-actions">
1327
+ <button class="header-action-btn refresh-btn" id="refresh-btn" aria-label="Refresh data" title="Refresh dashboard data">
1328
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1329
+ <polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
1330
+ </svg>
1331
+ </button>
1332
+ <div class="theme-switcher" id="theme-switcher">
1333
+ <button data-theme-pref="system" title="System theme" aria-label="Use system theme">
1334
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1335
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
1336
+ </svg>
1337
+ </button>
1338
+ <button data-theme-pref="dark" title="Dark theme" aria-label="Dark theme">
1339
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1340
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
1341
+ </svg>
1342
+ </button>
1343
+ <button data-theme-pref="light" title="Light theme" aria-label="Light theme">
1344
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1345
+ <circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
1346
+ </svg>
1347
+ </button>
1348
+ </div>
1349
+ </div>
1131
1350
  <h1>Codelens AI</h1>
1132
1351
  <p class="tagline">Correlates your AI coding agent's token spend with actual git output — see what shipped, what churned, and what it cost.</p>
1133
1352
  <div class="meta-info">
@@ -1151,12 +1370,107 @@
1151
1370
  </div>
1152
1371
 
1153
1372
  <script>
1154
- const GRADE_COLORS = { A: '#22d3a8', B: '#3b82f6', C: '#f59e0b', D: '#f0883e', F: '#ef4444' };
1373
+ const GRADE_VAR = { A: 'var(--grade-a)', B: 'var(--grade-b)', C: 'var(--grade-c)', D: 'var(--grade-d)', F: 'var(--grade-f)' };
1374
+ const GRADE_BG_VAR = { A: 'var(--grade-a-bg)', B: 'var(--grade-b-bg)', C: 'var(--grade-c-bg)', D: 'var(--grade-d-bg)', F: 'var(--grade-f-bg)' };
1155
1375
  const INSIGHT_ICONS = { warning: '!', success: '+', info: 'i', tip: '*' };
1156
1376
  const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1157
1377
  let DATA = null;
1158
1378
  let initialRenderDone = false;
1159
1379
 
1380
+ function getThemePref() {
1381
+ return localStorage.getItem('codelens-theme') || 'system';
1382
+ }
1383
+
1384
+ function resolveTheme(pref) {
1385
+ if (pref === 'system') return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
1386
+ return pref;
1387
+ }
1388
+
1389
+ function getTheme() {
1390
+ return document.documentElement.getAttribute('data-theme') || 'dark';
1391
+ }
1392
+
1393
+ function applyTheme(pref) {
1394
+ const resolved = resolveTheme(pref);
1395
+ document.documentElement.classList.add('theme-transition');
1396
+ if (resolved === 'light') {
1397
+ document.documentElement.setAttribute('data-theme', 'light');
1398
+ } else {
1399
+ document.documentElement.removeAttribute('data-theme');
1400
+ }
1401
+ localStorage.setItem('codelens-theme', pref);
1402
+ updateChartTheme(resolved);
1403
+ updateThemeSwitcher(pref);
1404
+ setTimeout(() => document.documentElement.classList.remove('theme-transition'), 350);
1405
+ }
1406
+
1407
+ function updateThemeSwitcher(pref) {
1408
+ const switcher = document.getElementById('theme-switcher');
1409
+ if (!switcher) return;
1410
+ switcher.querySelectorAll('button').forEach(btn => {
1411
+ btn.classList.toggle('active', btn.dataset.themePref === pref);
1412
+ });
1413
+ }
1414
+
1415
+ function updateChartTheme(theme) {
1416
+ const isLight = theme === 'light';
1417
+ Chart.defaults.color = isLight ? '#475569' : '#94a3b8';
1418
+ Chart.defaults.borderColor = isLight ? '#cbd5e1' : '#1f2d3d';
1419
+ Chart.defaults.plugins.tooltip.backgroundColor = isLight ? 'rgba(15, 23, 42, 0.92)' : 'rgba(10, 14, 23, 0.95)';
1420
+ Chart.defaults.plugins.tooltip.borderColor = isLight ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.08)';
1421
+ Object.values(Chart.instances || {}).forEach(chart => {
1422
+ if (chart.options.scales) {
1423
+ Object.values(chart.options.scales).forEach(scale => {
1424
+ if (scale.grid) scale.grid.color = isLight ? '#e2e8f0' : '#1f2d3d';
1425
+ if (scale.ticks) scale.ticks.color = isLight ? '#475569' : '#94a3b8';
1426
+ if (scale.title) scale.title.color = isLight ? '#475569' : '#94a3b8';
1427
+ });
1428
+ }
1429
+ chart.update('none');
1430
+ });
1431
+ }
1432
+
1433
+ function initTheme() {
1434
+ const switcher = document.getElementById('theme-switcher');
1435
+ if (!switcher) return;
1436
+ const pref = getThemePref();
1437
+ updateThemeSwitcher(pref);
1438
+ switcher.addEventListener('click', (e) => {
1439
+ const btn = e.target.closest('button[data-theme-pref]');
1440
+ if (!btn) return;
1441
+ applyTheme(btn.dataset.themePref);
1442
+ });
1443
+ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
1444
+ if (getThemePref() === 'system') applyTheme('system');
1445
+ });
1446
+ }
1447
+
1448
+ async function refreshData() {
1449
+ const btn = document.getElementById('refresh-btn');
1450
+ if (!btn || btn.classList.contains('refreshing')) return;
1451
+ btn.classList.add('refreshing');
1452
+ try {
1453
+ // Trigger server-side re-parse: clears cache, re-reads sessions, re-analyzes git, recomputes metrics
1454
+ const refreshRes = await fetch('/api/refresh', { method: 'POST' });
1455
+ if (!refreshRes.ok) {
1456
+ const err = await refreshRes.json().catch(() => ({}));
1457
+ throw new Error(err.error || 'Refresh failed');
1458
+ }
1459
+ // Fetch the newly computed payload
1460
+ const res = await fetch('/api/all');
1461
+ DATA = await res.json();
1462
+ initialRenderDone = false;
1463
+ render();
1464
+ } catch (err) {
1465
+ document.getElementById('app').innerHTML = `
1466
+ <div class="loading" style="color: var(--accent-red);">
1467
+ Failed to refresh: ${err.message}
1468
+ </div>`;
1469
+ } finally {
1470
+ btn.classList.remove('refreshing');
1471
+ }
1472
+ }
1473
+
1160
1474
  function formatTokens(n) {
1161
1475
  if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
1162
1476
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
@@ -1185,6 +1499,9 @@ let timelineChart = null;
1185
1499
  let timelineLogScale = false;
1186
1500
 
1187
1501
  document.addEventListener('DOMContentLoaded', async () => {
1502
+ initTheme();
1503
+ const refreshBtn = document.getElementById('refresh-btn');
1504
+ if (refreshBtn) refreshBtn.addEventListener('click', refreshData);
1188
1505
  try {
1189
1506
  const res = await fetch('/api/all');
1190
1507
  DATA = await res.json();
@@ -1257,7 +1574,7 @@ function render() {
1257
1574
  }
1258
1575
 
1259
1576
  function renderHeroStats(s) {
1260
- const gradeColor = GRADE_COLORS[s.overallGrade] || GRADE_COLORS.F;
1577
+ const gradeColor = GRADE_VAR[s.overallGrade] || GRADE_VAR.F;
1261
1578
  const GRADE_DEG = { A: 324, B: 270, C: 216, D: 144, F: 72 };
1262
1579
  const gradeDeg = GRADE_DEG[s.overallGrade] || 72;
1263
1580
  return `<div class="stats-section">
@@ -1592,7 +1909,8 @@ function renderSessionsTable(sessions) {
1592
1909
  <tbody>
1593
1910
  ${pageData.map((s, i) => {
1594
1911
  const idx = (page - 1) * pageSize + i;
1595
- const gradeColor = GRADE_COLORS[s.grade] || GRADE_COLORS.F;
1912
+ const gradeColor = GRADE_VAR[s.grade] || GRADE_VAR.F;
1913
+ const gradeBg = GRADE_BG_VAR[s.grade] || GRADE_BG_VAR.F;
1596
1914
  const primaryName = formatModelName(s.model || 'unknown');
1597
1915
  const subModels = Object.keys(s.modelBreakdown || {})
1598
1916
  .filter(m => m !== s.model)
@@ -1610,8 +1928,8 @@ function renderSessionsTable(sessions) {
1610
1928
  <td>${s.userMessageCount + s.assistantMessageCount}</td>
1611
1929
  <td>$${s.cost.totalCost.toFixed(2)}</td>
1612
1930
  <td>${s.commitCount}</td>
1613
- <td><span style="color:#22d3a8">+${s.linesAdded.toLocaleString()}</span> / <span style="color:#ef4444">-${s.linesDeleted.toLocaleString()}</span></td>
1614
- <td><span class="grade-badge" style="background:${gradeColor}22;color:${gradeColor};">${s.grade}</span></td>
1931
+ <td><span style="color:var(--accent-green)">+${s.linesAdded.toLocaleString()}</span> / <span style="color:var(--accent-red)">-${s.linesDeleted.toLocaleString()}</span></td>
1932
+ <td><span class="grade-badge" style="background:${gradeBg};color:${gradeColor};">${s.grade}</span></td>
1615
1933
  </tr>
1616
1934
  <tr class="expand-row" id="expand-${idx}">
1617
1935
  <td colspan="8">
@@ -1664,11 +1982,12 @@ function formatDate(iso) {
1664
1982
  }
1665
1983
 
1666
1984
  function initCharts() {
1667
- Chart.defaults.color = '#94a3b8';
1668
- Chart.defaults.borderColor = '#1f2d3d';
1985
+ const isLight = getTheme() === 'light';
1986
+ Chart.defaults.color = isLight ? '#475569' : '#94a3b8';
1987
+ Chart.defaults.borderColor = isLight ? '#cbd5e1' : '#1f2d3d';
1669
1988
  Chart.defaults.font.family = "'DM Sans', sans-serif";
1670
- Chart.defaults.plugins.tooltip.backgroundColor = 'rgba(10, 14, 23, 0.95)';
1671
- Chart.defaults.plugins.tooltip.borderColor = 'rgba(255, 255, 255, 0.08)';
1989
+ Chart.defaults.plugins.tooltip.backgroundColor = isLight ? 'rgba(15, 23, 42, 0.92)' : 'rgba(10, 14, 23, 0.95)';
1990
+ Chart.defaults.plugins.tooltip.borderColor = isLight ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.08)';
1672
1991
  Chart.defaults.plugins.tooltip.borderWidth = 1;
1673
1992
  Chart.defaults.plugins.tooltip.cornerRadius = 8;
1674
1993
  Chart.defaults.plugins.tooltip.titleFont = { family: "'JetBrains Mono', monospace", size: 12, weight: '600' };
@@ -2018,14 +2337,22 @@ window.sortTable = function(col) {
2018
2337
  if (sortCol === col) { sortOrder *= -1; }
2019
2338
  else { sortCol = col; sortOrder = -1; }
2020
2339
  currentPage = 1;
2021
- render();
2340
+ rerenderSessionsTable();
2022
2341
  };
2023
2342
 
2024
2343
  window.changePage = function(delta) {
2025
2344
  currentPage += delta;
2026
- render();
2345
+ rerenderSessionsTable();
2027
2346
  };
2028
2347
 
2348
+ function rerenderSessionsTable() {
2349
+ const container = document.querySelector('.sessions-section');
2350
+ if (container) {
2351
+ const wrapper = container.parentElement;
2352
+ wrapper.innerHTML = renderSessionsTable(DATA.sessions);
2353
+ }
2354
+ }
2355
+
2029
2356
  window.toggleTimelineScale = function() {
2030
2357
  if (!timelineChart) return;
2031
2358
  timelineLogScale = !timelineLogScale;
package/src/index.js CHANGED
@@ -15,42 +15,20 @@ const { version: VERSION } = JSON.parse(
15
15
  readFileSync(new URL('../package.json', import.meta.url), 'utf8')
16
16
  );
17
17
 
18
- async function main() {
19
- const program = new Command();
20
- program
21
- .name('claude-roi')
22
- .description('Correlate Claude Code token usage with git output to measure AI coding agent ROI')
23
- .version(VERSION)
24
- .option('-p, --port <number>', 'port to serve dashboard', '3457')
25
- .option('-d, --days <number>', 'number of days to look back', '30')
26
- .option('--no-open', 'do not auto-open browser')
27
- .option('--json', 'output raw JSON to stdout instead of starting server')
28
- .option('--project <name>', 'filter to specific project')
29
- .option('--refresh', 'force full re-parse, ignore cache');
30
-
31
- program.parse();
32
- const opts = program.opts();
33
- const port = parseInt(opts.port, 10);
34
- const days = parseInt(opts.days, 10);
35
-
36
- console.log(`\x1b[36mclaude-roi\x1b[0m v${VERSION}`);
37
-
38
- const claudeDir = path.join(os.homedir(), '.claude', 'projects');
39
-
18
+ async function buildPayload(claudeDir, days, project, forceRefresh = false) {
40
19
  // Step 1: Parse sessions (with caching)
41
20
  let sessions;
42
21
  let fileIndex;
43
22
  const startParse = Date.now();
44
23
 
45
- if (opts.refresh) {
24
+ if (forceRefresh) {
46
25
  deleteCache();
47
26
  console.log('Cache cleared, performing full parse...');
48
27
  }
49
28
 
50
- const cached = opts.refresh ? null : loadCache();
29
+ const cached = forceRefresh ? null : loadCache();
51
30
 
52
31
  if (cached) {
53
- // Incremental parse: only process new/modified files
54
32
  const stale = getStaleFiles(claudeDir, cached.fileIndex);
55
33
  const newCount = stale.newFiles.length;
56
34
  const modifiedCount = stale.modifiedFiles.length;
@@ -58,33 +36,24 @@ async function main() {
58
36
  const cachedCount = Object.keys(cached.fileIndex).length - modifiedCount - deletedCount;
59
37
 
60
38
  if (newCount === 0 && modifiedCount === 0 && deletedCount === 0) {
61
- // Nothing changed, use cache as-is
62
39
  sessions = cached.sessions;
63
40
  fileIndex = cached.fileIndex;
64
41
  console.log(`Parsing sessions... ${cached.sessions.length} cached (${Date.now() - startParse}ms)`);
65
42
  } else {
66
- // Parse only new/modified files
67
- const { sessions: freshSessions, fileIndex: freshIndex } = await parseAllProjects(claudeDir, days, opts.project);
68
-
69
- // For a simpler approach: just do a full re-parse when files change
70
- // This avoids complex merging logic while still benefiting from caching
71
- // when nothing has changed
43
+ const { sessions: freshSessions, fileIndex: freshIndex } = await parseAllProjects(claudeDir, days, project);
72
44
  sessions = freshSessions;
73
45
  fileIndex = freshIndex;
74
46
  console.log(`Parsing sessions... ${newCount} new, ${modifiedCount} updated, ${Math.max(0, cachedCount)} cached (${Date.now() - startParse}ms)`);
75
47
  }
76
48
  } else {
77
- // Full parse
78
- const result = await parseAllProjects(claudeDir, days, opts.project);
49
+ const result = await parseAllProjects(claudeDir, days, project);
79
50
  sessions = result.sessions;
80
51
  fileIndex = result.fileIndex;
81
52
  console.log(`Parsing sessions... ${sessions.length} parsed (${Date.now() - startParse}ms)`);
82
53
  }
83
54
 
84
55
  if (sessions.length === 0) {
85
- console.log('\x1b[33mNo Claude Code sessions found.\x1b[0m');
86
- console.log('Make sure you have used Claude Code and session files exist in ~/.claude/projects/');
87
- process.exit(0);
56
+ return null;
88
57
  }
89
58
 
90
59
  // Step 2: Analyze git repos
@@ -107,14 +76,48 @@ async function main() {
107
76
  // Save cache for next run
108
77
  saveCache(sessions, fileIndex);
109
78
 
110
- // Step 5: Output
79
+ return payload;
80
+ }
81
+
82
+ async function main() {
83
+ const program = new Command();
84
+ program
85
+ .name('claude-roi')
86
+ .description('Correlate Claude Code token usage with git output to measure AI coding agent ROI')
87
+ .version(VERSION)
88
+ .option('-p, --port <number>', 'port to serve dashboard', '3457')
89
+ .option('-d, --days <number>', 'number of days to look back', '30')
90
+ .option('--no-open', 'do not auto-open browser')
91
+ .option('--json', 'output raw JSON to stdout instead of starting server')
92
+ .option('--project <name>', 'filter to specific project')
93
+ .option('--refresh', 'force full re-parse, ignore cache');
94
+
95
+ program.parse();
96
+ const opts = program.opts();
97
+ const port = parseInt(opts.port, 10);
98
+ const days = parseInt(opts.days, 10);
99
+
100
+ console.log(`\x1b[36mclaude-roi\x1b[0m v${VERSION}`);
101
+
102
+ const claudeDir = path.join(os.homedir(), '.claude', 'projects');
103
+
104
+ const payload = await buildPayload(claudeDir, days, opts.project, opts.refresh);
105
+
106
+ if (!payload) {
107
+ console.log('\x1b[33mNo Claude Code sessions found.\x1b[0m');
108
+ console.log('Make sure you have used Claude Code and session files exist in ~/.claude/projects/');
109
+ process.exit(0);
110
+ }
111
+
112
+ // Output
111
113
  if (opts.json) {
112
114
  process.stdout.write(JSON.stringify(payload, null, 2));
113
115
  process.exit(0);
114
116
  }
115
117
 
116
- // Start server
117
- const app = createServer(payload);
118
+ // Start server — pass a rebuild function so /api/refresh can re-run the pipeline
119
+ const rebuild = () => buildPayload(claudeDir, days, opts.project, true);
120
+ const app = createServer(payload, rebuild);
118
121
  const server = app.listen(port, () => {
119
122
  const url = `http://localhost:${port}`;
120
123
  console.log(`\x1b[32mDashboard:\x1b[0m ${url}`);
package/src/server.js CHANGED
@@ -5,8 +5,9 @@ import path from 'node:path';
5
5
 
6
6
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
7
 
8
- export function createServer(payload) {
8
+ export function createServer(initialPayload, rebuildFn) {
9
9
  const app = express();
10
+ let payload = initialPayload;
10
11
 
11
12
  // Serve dashboard HTML
12
13
  const dashboardHtml = readFileSync(path.join(__dirname, 'dashboard.html'), 'utf-8');
@@ -20,6 +21,22 @@ export function createServer(payload) {
20
21
  res.json(payload);
21
22
  });
22
23
 
24
+ // Re-run the full pipeline: clear cache, re-parse sessions, re-analyze git, recompute metrics
25
+ app.post('/api/refresh', async (req, res) => {
26
+ if (!rebuildFn) return res.status(501).json({ error: 'Refresh not available' });
27
+ try {
28
+ console.log('\x1b[36m[refresh]\x1b[0m Re-parsing sessions and recomputing metrics...');
29
+ const newPayload = await rebuildFn();
30
+ if (!newPayload) return res.status(404).json({ error: 'No sessions found after refresh' });
31
+ payload = newPayload;
32
+ console.log('\x1b[32m[refresh]\x1b[0m Done');
33
+ res.json({ ok: true });
34
+ } catch (err) {
35
+ console.error('\x1b[31m[refresh]\x1b[0m Error:', err.message);
36
+ res.status(500).json({ error: err.message });
37
+ }
38
+ });
39
+
23
40
  // Hero stats + insights
24
41
  app.get('/api/summary', (req, res) => {
25
42
  res.json({