@zhangferry-dev/tokendash 1.3.0 → 1.4.2

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,1132 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="380, initial-scale=1">
6
+ <title>TokenDash</title>
7
+ <style>
8
+ :root {
9
+ --bg: oklch(96.8% 0.01 155);
10
+ --surface: rgba(255, 255, 255, 0.90);
11
+ --surface-strong: rgba(255, 255, 255, 0.97);
12
+ --fg: oklch(22% 0.02 240);
13
+ --muted: oklch(54% 0.012 240);
14
+ --border: oklch(88% 0.008 240);
15
+ --accent: oklch(69% 0.11 162);
16
+ --accent-strong: oklch(61% 0.13 162);
17
+ --error-bg: oklch(95% 0.02 80);
18
+ --error-fg: oklch(45% 0.08 25);
19
+ --shadow: 0 26px 70px rgba(20, 28, 38, 0.14), 0 8px 18px rgba(20, 28, 38, 0.08);
20
+ --radius-shell: 30px;
21
+ --radius-panel: 22px;
22
+ --radius-card: 16px;
23
+ --font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", system-ui, sans-serif;
24
+ --font-body: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
25
+ --font-mono: "SF Mono", "JetBrains Mono", ui-monospace, Menlo, monospace;
26
+ }
27
+
28
+ * { box-sizing: border-box; }
29
+
30
+ html, body {
31
+ width: 380px;
32
+ height: 540px;
33
+ overflow: hidden;
34
+ border-radius: var(--radius-shell);
35
+ }
36
+
37
+ body {
38
+ margin: 0;
39
+ font-family: var(--font-body);
40
+ color: var(--fg);
41
+ background: transparent;
42
+ user-select: none;
43
+ }
44
+
45
+ .shell {
46
+ position: relative;
47
+ width: 100%;
48
+ height: 100%;
49
+ background: rgba(255, 255, 255, 0.76);
50
+ border: 1px solid rgba(255, 255, 255, 0.72);
51
+ border-radius: var(--radius-shell);
52
+ box-shadow: var(--shadow);
53
+ backdrop-filter: blur(28px);
54
+ overflow: hidden;
55
+ }
56
+
57
+ .toolbar {
58
+ display: flex;
59
+ align-items: center;
60
+ justify-content: flex-start;
61
+ padding: 14px 18px 12px;
62
+ border-bottom: 1px solid rgba(225, 230, 228, 0.94);
63
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(248, 250, 249, 0.78));
64
+ }
65
+
66
+ .toolbar-label {
67
+ font-size: 12px;
68
+ color: var(--muted);
69
+ margin: 0;
70
+ }
71
+
72
+ /* Error banner — shown when API is unreachable */
73
+ .error-banner {
74
+ display: none;
75
+ padding: 7px 18px;
76
+ font-size: 11px;
77
+ color: var(--error-fg);
78
+ background: var(--error-bg);
79
+ border-bottom: 1px solid rgba(200, 180, 170, 0.3);
80
+ }
81
+
82
+ .error-banner.visible { display: block; }
83
+
84
+ .content {
85
+ height: calc(100% - 48px);
86
+ padding: 18px;
87
+ display: grid;
88
+ grid-template-rows: auto auto auto;
89
+ gap: 14px;
90
+ background: linear-gradient(180deg, rgba(250, 252, 251, 0.85), rgba(244, 247, 245, 0.7));
91
+ }
92
+
93
+ .content.has-error {
94
+ height: calc(100% - 48px - 28px);
95
+ }
96
+
97
+ .summary {
98
+ display: grid;
99
+ grid-template-columns: repeat(2, minmax(0, 1fr));
100
+ gap: 8px;
101
+ }
102
+
103
+ .card {
104
+ padding: 12px;
105
+ border-radius: var(--radius-card);
106
+ border: 1px solid var(--border);
107
+ background: var(--surface-strong);
108
+ }
109
+
110
+ .card-label {
111
+ margin: 0 0 6px;
112
+ font-size: 11px;
113
+ color: var(--muted);
114
+ text-transform: uppercase;
115
+ letter-spacing: 0.08em;
116
+ }
117
+
118
+ .card-value {
119
+ margin: 0;
120
+ font-family: var(--font-display);
121
+ font-size: 22px;
122
+ line-height: 1;
123
+ letter-spacing: -0.05em;
124
+ font-weight: 700;
125
+ }
126
+
127
+ .card-value.empty { color: var(--muted); }
128
+
129
+ .card-meta {
130
+ margin: 6px 0 0;
131
+ font-size: 11px;
132
+ color: var(--muted);
133
+ }
134
+
135
+ /* Shimmer skeleton for loading state */
136
+ .skeleton {
137
+ display: inline-block;
138
+ width: 56px;
139
+ height: 24px;
140
+ border-radius: 6px;
141
+ background: linear-gradient(90deg, var(--border) 25%, rgba(255,255,255,0.6) 50%, var(--border) 75%);
142
+ background-size: 200% 100%;
143
+ animation: shimmer 1.5s infinite;
144
+ }
145
+
146
+ @keyframes shimmer {
147
+ 0% { background-position: 200% 0; }
148
+ 100% { background-position: -200% 0; }
149
+ }
150
+
151
+ .chart-panel {
152
+ padding: 16px;
153
+ border-radius: var(--radius-panel);
154
+ border: 1px solid var(--border);
155
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(247, 250, 248, 0.9));
156
+ }
157
+
158
+ .chart-header {
159
+ margin-bottom: 14px;
160
+ }
161
+
162
+ .eyebrow {
163
+ margin: 0;
164
+ font-family: var(--font-mono);
165
+ font-size: 11px;
166
+ color: var(--muted);
167
+ text-transform: uppercase;
168
+ letter-spacing: 0.12em;
169
+ }
170
+
171
+ .chart-frame {
172
+ display: grid;
173
+ grid-template-columns: max-content 1fr;
174
+ gap: 6px;
175
+ align-items: stretch;
176
+ min-height: 190px;
177
+ }
178
+
179
+ .y-axis {
180
+ display: flex;
181
+ flex-direction: column;
182
+ justify-content: space-between;
183
+ align-items: flex-end;
184
+ width: max-content;
185
+ padding-block: 6px 24px;
186
+ color: var(--muted);
187
+ font-family: var(--font-mono);
188
+ font-size: 10px;
189
+ }
190
+
191
+ .plot {
192
+ position: relative;
193
+ border-inline-start: 1px solid var(--border);
194
+ border-block-end: 1px solid var(--border);
195
+ padding: 6px 12px 24px 14px;
196
+ display: grid;
197
+ align-items: end;
198
+ gap: 8px;
199
+ background:
200
+ linear-gradient(to top, rgba(212, 218, 215, 0.6) 1px, transparent 1px) 0 0 / 100% calc((100% - 24px) / 3),
201
+ transparent;
202
+ }
203
+
204
+ /* Empty state for chart */
205
+ .chart-empty {
206
+ display: flex;
207
+ flex-direction: column;
208
+ align-items: center;
209
+ justify-content: center;
210
+ height: 100%;
211
+ gap: 6px;
212
+ color: var(--muted);
213
+ font-size: 12px;
214
+ text-align: center;
215
+ }
216
+
217
+ .chart-empty-icon {
218
+ font-size: 24px;
219
+ opacity: 0.4;
220
+ margin-bottom: 4px;
221
+ }
222
+
223
+ .chart-empty-title {
224
+ font-weight: 600;
225
+ color: var(--fg);
226
+ opacity: 0.6;
227
+ }
228
+
229
+ .bar-wrap {
230
+ position: relative;
231
+ height: 100%;
232
+ display: flex;
233
+ align-items: end;
234
+ justify-content: center;
235
+ }
236
+
237
+ .bar {
238
+ width: 100%;
239
+ max-width: 22px;
240
+ border-radius: 8px 8px 3px 3px;
241
+ background: linear-gradient(180deg, color-mix(in oklch, var(--accent) 60%, white), var(--accent));
242
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45);
243
+ }
244
+
245
+ .bar.zero { opacity: 0.35; }
246
+
247
+ .bar.peak {
248
+ background: linear-gradient(180deg, color-mix(in oklch, var(--accent) 85%, white), var(--accent-strong));
249
+ }
250
+
251
+ .bar-label {
252
+ position: absolute;
253
+ inset-block-end: -20px;
254
+ font-family: var(--font-mono);
255
+ font-size: 10px;
256
+ color: var(--muted);
257
+ }
258
+
259
+ .bar-tip {
260
+ position: absolute;
261
+ inset-block-end: calc(var(--height) + 4px);
262
+ font-size: 10px;
263
+ color: var(--fg);
264
+ font-family: var(--font-mono);
265
+ background: transparent;
266
+ border: none;
267
+ border-radius: 0;
268
+ padding: 0;
269
+ white-space: nowrap;
270
+ }
271
+
272
+ .actions {
273
+ display: grid;
274
+ grid-template-columns: repeat(2, minmax(0, 1fr));
275
+ gap: 8px;
276
+ align-self: end;
277
+ }
278
+
279
+ .action {
280
+ appearance: none;
281
+ border: 1px solid var(--border);
282
+ border-radius: 14px;
283
+ min-height: 42px;
284
+ padding: 0 14px;
285
+ font-family: var(--font-body);
286
+ font-size: 13px;
287
+ font-weight: 600;
288
+ cursor: pointer;
289
+ transition: transform 160ms ease, background-color 160ms ease, border-color 160ms ease, opacity 160ms ease;
290
+ }
291
+
292
+ .action:hover { transform: translateY(-1px); }
293
+ .action:disabled { opacity: 0.6; cursor: default; transform: none; }
294
+
295
+ .action.primary {
296
+ background: #1f2423;
297
+ border-color: #1f2423;
298
+ color: white;
299
+ }
300
+
301
+ .action.secondary {
302
+ background: rgba(255, 255, 255, 0.94);
303
+ color: var(--fg);
304
+ }
305
+
306
+ .drawer-overlay {
307
+ position: absolute;
308
+ inset: 0;
309
+ background: transparent;
310
+ opacity: 0;
311
+ pointer-events: none;
312
+ transition: opacity 200ms ease;
313
+ overflow: hidden;
314
+ border-radius: var(--radius-shell);
315
+ }
316
+
317
+ .drawer {
318
+ height: 100%;
319
+ display: grid;
320
+ grid-template-rows: auto 1fr auto;
321
+ gap: 14px;
322
+ padding: 14px;
323
+ border-radius: var(--radius-shell);
324
+ background: rgba(255, 255, 255, 0.96);
325
+ backdrop-filter: blur(20px);
326
+ border: 1px solid rgba(255, 255, 255, 0.84);
327
+ box-shadow: 0 18px 48px rgba(20, 28, 38, 0.14);
328
+ transform: translateY(10px);
329
+ opacity: 0;
330
+ transition: transform 220ms ease, opacity 220ms ease;
331
+ }
332
+
333
+ .shell[data-view="settings"] .drawer-overlay {
334
+ opacity: 1;
335
+ pointer-events: auto;
336
+ }
337
+
338
+ .shell[data-view="settings"] .drawer {
339
+ transform: translateY(0);
340
+ opacity: 1;
341
+ }
342
+
343
+ .drawer-header {
344
+ display: flex;
345
+ align-items: center;
346
+ justify-content: flex-start;
347
+ }
348
+
349
+ .drawer-back {
350
+ appearance: none;
351
+ border: 1px solid var(--border);
352
+ background: rgba(255, 255, 255, 0.94);
353
+ color: var(--fg);
354
+ display: inline-flex;
355
+ align-items: center;
356
+ gap: 4px;
357
+ min-width: auto;
358
+ height: 32px;
359
+ padding: 0 12px;
360
+ border-radius: 999px;
361
+ font-size: 13px;
362
+ font-weight: 600;
363
+ font-family: var(--font-body);
364
+ cursor: pointer;
365
+ }
366
+
367
+ .drawer-back-arrow {
368
+ font-size: 17px;
369
+ line-height: 1;
370
+ }
371
+
372
+ .settings-list {
373
+ display: grid;
374
+ align-content: start;
375
+ gap: 8px;
376
+ }
377
+
378
+ .settings-row {
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: space-between;
382
+ gap: 12px;
383
+ padding: 12px 14px;
384
+ border-radius: 14px;
385
+ border: 1px solid var(--border);
386
+ background: rgba(250, 252, 251, 0.94);
387
+ }
388
+
389
+ .settings-copy {
390
+ min-width: 0;
391
+ }
392
+
393
+ .settings-label {
394
+ margin: 0;
395
+ font-size: 13px;
396
+ font-weight: 600;
397
+ }
398
+
399
+ .settings-meta {
400
+ margin: 3px 0 0;
401
+ font-size: 11px;
402
+ color: var(--muted);
403
+ }
404
+
405
+ .settings-value {
406
+ display: inline-flex;
407
+ align-items: center;
408
+ justify-content: center;
409
+ min-height: 28px;
410
+ min-width: 58px;
411
+ padding-inline: 10px;
412
+ border-radius: 999px;
413
+ border: 1px solid var(--border);
414
+ background: rgba(255, 255, 255, 0.96);
415
+ color: var(--muted);
416
+ font-family: var(--font-mono);
417
+ font-size: 11px;
418
+ white-space: nowrap;
419
+ }
420
+
421
+ .settings-value.button {
422
+ cursor: pointer;
423
+ color: var(--fg);
424
+ }
425
+
426
+ /* Agent filter dropdown */
427
+ .agent-dropdown-wrap {
428
+ position: relative;
429
+ }
430
+
431
+ .agent-dropdown-trigger {
432
+ cursor: pointer;
433
+ color: var(--fg);
434
+ padding-right: 24px;
435
+ position: relative;
436
+ }
437
+
438
+ .agent-dropdown-trigger::after {
439
+ content: "▾";
440
+ position: absolute;
441
+ right: 10px;
442
+ font-size: 10px;
443
+ top: 50%;
444
+ transform: translateY(-50%);
445
+ }
446
+
447
+ .agent-dropdown-menu {
448
+ display: none;
449
+ position: absolute;
450
+ right: 0;
451
+ top: calc(100% + 6px);
452
+ min-width: 160px;
453
+ padding: 6px;
454
+ border-radius: 14px;
455
+ border: 1px solid var(--border);
456
+ background: white;
457
+ box-shadow: 0 8px 24px rgba(20, 28, 38, 0.12);
458
+ z-index: 10;
459
+ }
460
+
461
+ .agent-dropdown-menu.open { display: block; }
462
+
463
+ .agent-option {
464
+ display: flex;
465
+ align-items: center;
466
+ gap: 8px;
467
+ padding: 8px 10px;
468
+ border-radius: 10px;
469
+ cursor: pointer;
470
+ font-size: 13px;
471
+ font-weight: 500;
472
+ transition: background 120ms ease;
473
+ }
474
+
475
+ .agent-option:hover { background: rgba(240, 244, 242, 0.9); }
476
+
477
+ .agent-checkbox {
478
+ width: 16px;
479
+ height: 16px;
480
+ border-radius: 4px;
481
+ border: 1.5px solid var(--border);
482
+ display: flex;
483
+ align-items: center;
484
+ justify-content: center;
485
+ font-size: 10px;
486
+ flex-shrink: 0;
487
+ transition: background 120ms ease, border-color 120ms ease;
488
+ }
489
+
490
+ .agent-checkbox.checked {
491
+ background: color-mix(in oklch, var(--accent) 72%, white);
492
+ border-color: var(--accent);
493
+ }
494
+
495
+ .agent-checkbox.checked::after {
496
+ content: "✓";
497
+ color: white;
498
+ font-weight: 700;
499
+ font-size: 10px;
500
+ }
501
+
502
+ .settings-divider {
503
+ height: 1px;
504
+ background: var(--border);
505
+ margin: 4px 0;
506
+ }
507
+
508
+ .settings-quit-row {
509
+ border-color: rgba(192, 57, 43, 0.15);
510
+ }
511
+
512
+ .settings-quit-btn {
513
+ color: #c0392b !important;
514
+ }
515
+
516
+ .settings-version {
517
+ text-align: center;
518
+ font-family: var(--font-mono);
519
+ font-size: 11px;
520
+ color: var(--muted);
521
+ letter-spacing: 0.06em;
522
+ text-transform: uppercase;
523
+ padding-block: 2px 6px;
524
+ }
525
+
526
+ .switch {
527
+ position: relative;
528
+ width: 44px;
529
+ height: 26px;
530
+ border-radius: 999px;
531
+ border: 1px solid color-mix(in srgb, #000 7%, transparent);
532
+ background: rgba(228, 231, 229, 0.9);
533
+ cursor: pointer;
534
+ transition: background-color 160ms ease;
535
+ }
536
+
537
+ .switch::after {
538
+ content: "";
539
+ position: absolute;
540
+ inset-block-start: 2px;
541
+ inset-inline-start: 2px;
542
+ width: 20px;
543
+ height: 20px;
544
+ border-radius: 50%;
545
+ background: white;
546
+ box-shadow: 0 1px 3px rgba(20, 28, 38, 0.18);
547
+ transition: transform 160ms ease;
548
+ }
549
+
550
+ .switch[aria-checked="true"] {
551
+ background: color-mix(in oklch, var(--accent) 72%, white);
552
+ }
553
+
554
+ .switch[aria-checked="true"]::after {
555
+ transform: translateX(18px);
556
+ }
557
+ </style>
558
+ </head>
559
+ <body>
560
+ <main class="shell" aria-label="Token dashboard menu" data-view="main">
561
+ <header class="toolbar">
562
+ <p class="toolbar-label">Today <span id="date"></span></p>
563
+ </header>
564
+
565
+ <div class="error-banner" id="error-banner">
566
+ Unable to fetch data. Retrying...
567
+ </div>
568
+
569
+ <section class="content" id="content">
570
+ <section class="summary" aria-label="Summary metrics">
571
+ <article class="card">
572
+ <p class="card-label">Total</p>
573
+ <p class="card-value" id="total-tokens"><span class="skeleton"></span></p>
574
+ </article>
575
+ <article class="card">
576
+ <p class="card-label">Input</p>
577
+ <p class="card-value" id="input"><span class="skeleton"></span></p>
578
+ </article>
579
+ <article class="card">
580
+ <p class="card-label">Output</p>
581
+ <p class="card-value" id="output"><span class="skeleton"></span></p>
582
+ </article>
583
+ <article class="card">
584
+ <p class="card-label">Hit rate</p>
585
+ <p class="card-value" id="cache-rate"><span class="skeleton"></span></p>
586
+ </article>
587
+ </section>
588
+
589
+ <section class="chart-panel" aria-label="Hourly usage">
590
+ <div class="chart-header">
591
+ <p class="eyebrow">Hourly usage</p>
592
+ </div>
593
+ <div class="chart-frame">
594
+ <div class="y-axis" id="y-axis" aria-hidden="true"></div>
595
+ <div class="plot" id="plot">
596
+ <div class="chart-empty">
597
+ <div class="chart-empty-icon">&#x1F4CA;</div>
598
+ <div class="chart-empty-title">No usage yet</div>
599
+ <div>Start a session to see your hourly breakdown.</div>
600
+ </div>
601
+ </div>
602
+ </div>
603
+ </section>
604
+
605
+ <section class="actions" aria-label="Primary actions">
606
+ <button class="action primary" type="button" id="open-dashboard">Open Dashboard</button>
607
+ <button class="action secondary" type="button" id="open-settings">Settings</button>
608
+ </section>
609
+ </section>
610
+
611
+ <div class="drawer-overlay" aria-hidden="true">
612
+ <section class="drawer" aria-label="Settings drawer">
613
+ <div class="drawer-header">
614
+ <button class="drawer-back" type="button" aria-label="Back" id="close-settings">
615
+ <span class="drawer-back-arrow">&lsaquo;</span>
616
+ </button>
617
+ </div>
618
+
619
+ <div class="settings-list">
620
+ <article class="settings-row">
621
+ <div class="settings-copy">
622
+ <p class="settings-label">Check for updates</p>
623
+ <p class="settings-meta" id="update-status">Check whether a newer build is available.</p>
624
+ </div>
625
+ <button class="settings-value button" type="button" id="check-updates">Check</button>
626
+ </article>
627
+
628
+ <article class="settings-row">
629
+ <div class="settings-copy">
630
+ <p class="settings-label">Launch at login</p>
631
+ <p class="settings-meta">Keep TokenDash in the menu bar after sign in.</p>
632
+ </div>
633
+ <button class="switch" type="button" id="launch-at-login" role="switch" aria-checked="false" aria-label="Launch at login"></button>
634
+ </article>
635
+
636
+ <article class="settings-row" id="agent-filter-row">
637
+ <div class="settings-copy">
638
+ <p class="settings-label">Data sources</p>
639
+ <p class="settings-meta">Filter which agents contribute to metrics.</p>
640
+ </div>
641
+ <div class="agent-dropdown-wrap">
642
+ <button class="settings-value button agent-dropdown-trigger" type="button" id="agent-dropdown-btn">All</button>
643
+ <div class="agent-dropdown-menu" id="agent-dropdown-menu"></div>
644
+ </div>
645
+ </article>
646
+
647
+ <div class="settings-divider"></div>
648
+
649
+ <article class="settings-row settings-quit-row">
650
+ <div class="settings-copy">
651
+ <p class="settings-label">Quit TokenDash</p>
652
+ <p class="settings-meta">Close the app and stop background services.</p>
653
+ </div>
654
+ <button class="settings-value button settings-quit-btn" type="button" id="quit-app">Quit</button>
655
+ </article>
656
+ </div>
657
+
658
+ <div class="settings-version" id="app-version">Version --</div>
659
+ </section>
660
+ </div>
661
+ </main>
662
+
663
+ <script>
664
+ var shell = document.querySelector('.shell');
665
+ var port = window.location.port || '3456';
666
+ var apiBase = 'http://localhost:' + port;
667
+ var appInfo = { version: '--', launchAtLogin: false };
668
+ var availableAgents = [];
669
+ var selectedAgents = [];
670
+ var isFirstLoad = true;
671
+
672
+ // --- Helpers ---
673
+
674
+ function formatNumber(n) {
675
+ if (!Number.isFinite(n) || n <= 0) return '0';
676
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
677
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
678
+ return String(Math.round(n));
679
+ }
680
+
681
+ function formatPercent(n) {
682
+ return (Number.isFinite(n) ? n : 0).toFixed(1) + '%';
683
+ }
684
+
685
+ function getTodayString() {
686
+ var d = new Date();
687
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
688
+ }
689
+
690
+ function setDateLabel() {
691
+ var today = new Date();
692
+ document.getElementById('date').textContent =
693
+ today.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
694
+ }
695
+
696
+ function fetchJson(url) {
697
+ return fetch(url).then(function(res) { return res.json(); });
698
+ }
699
+
700
+ // --- Dynamic chart hours based on current time ---
701
+
702
+ function getChartHours() {
703
+ var now = new Date();
704
+ var currentHour = now.getHours();
705
+ var hours = [];
706
+ for (var h = 0; h <= currentHour; h += 2) {
707
+ hours.push(h);
708
+ }
709
+ // At least 4 bars
710
+ while (hours.length < 4) {
711
+ var next = hours[hours.length - 1] + 2;
712
+ if (next > 23) break;
713
+ hours.push(next);
714
+ }
715
+ return hours;
716
+ }
717
+
718
+ // --- Loading & Error states ---
719
+
720
+ function showSkeletons() {
721
+ ['total-tokens', 'input', 'output', 'cache-rate'].forEach(function(id) {
722
+ var el = document.getElementById(id);
723
+ if (el && !el.querySelector('.skeleton')) return; // already has data
724
+ if (el) el.innerHTML = '<span class="skeleton"></span>';
725
+ });
726
+ }
727
+
728
+ function showError() {
729
+ document.getElementById('error-banner').classList.add('visible');
730
+ document.getElementById('content').classList.add('has-error');
731
+ }
732
+
733
+ function hideError() {
734
+ document.getElementById('error-banner').classList.remove('visible');
735
+ document.getElementById('content').classList.remove('has-error');
736
+ }
737
+
738
+ // --- Metrics rendering ---
739
+
740
+ function renderMetrics(allDaily) {
741
+ var todayStr = getTodayString();
742
+ var totalInput = 0;
743
+ var totalOutput = 0;
744
+ var totalCacheRead = 0;
745
+ var totalTokens = 0;
746
+ var totalCost = 0;
747
+
748
+ (allDaily || []).forEach(function(daily) {
749
+ if (!daily || !daily.daily) return;
750
+ var entry = daily.daily.find(function(d) { return d.date === todayStr; });
751
+ if (!entry) return;
752
+ totalInput += entry.inputTokens || 0;
753
+ totalOutput += entry.outputTokens || 0;
754
+ totalCacheRead += entry.cacheReadTokens || 0;
755
+ totalTokens += entry.totalTokens || 0;
756
+ totalCost += entry.totalCost || 0;
757
+ });
758
+
759
+ var denominator = totalInput + totalCacheRead;
760
+ var cacheRate = denominator > 0 ? (totalCacheRead / denominator) * 100 : 0;
761
+
762
+ setCardValue('total-tokens', formatNumber(totalTokens), totalTokens === 0);
763
+ setCardValue('input', formatNumber(totalInput), totalInput === 0);
764
+ setCardValue('output', formatNumber(totalOutput), totalOutput === 0);
765
+ setCardValue('cache-rate', formatPercent(cacheRate), cacheRate === 0);
766
+
767
+ return {
768
+ today: todayStr,
769
+ agentKey: selectedAgents.slice().sort().join(','),
770
+ totalTokens: totalTokens,
771
+ totalCost: totalCost,
772
+ totalCacheRead: totalCacheRead
773
+ };
774
+ }
775
+
776
+ function setCardValue(id, text, isEmpty) {
777
+ var el = document.getElementById(id);
778
+ el.textContent = text;
779
+ el.classList.toggle('empty', isEmpty);
780
+ }
781
+
782
+ // --- Chart rendering ---
783
+
784
+ function renderChart(allBlocks) {
785
+ var hourly = new Array(24).fill(0);
786
+ var todayStr = getTodayString();
787
+
788
+ (allBlocks || []).forEach(function(data) {
789
+ if (!data || !data.blocks) return;
790
+ data.blocks.forEach(function(block) {
791
+ if (!block.startTime || block.startTime.substring(0, 10) !== todayStr) return;
792
+ var hour = parseInt(block.startTime.substring(11, 13), 10);
793
+ if (hour >= 0 && hour < 24) {
794
+ hourly[hour] += block.totalTokens || 0;
795
+ }
796
+ });
797
+ });
798
+
799
+ var chartHours = getChartHours();
800
+ var selected = chartHours.map(function(hour) {
801
+ return { hour: hour, value: hourly[hour] || 0 };
802
+ });
803
+
804
+ var maxValue = selected.reduce(function(max, entry) {
805
+ return Math.max(max, entry.value);
806
+ }, 0);
807
+
808
+ var plot = document.getElementById('plot');
809
+ var axis = document.getElementById('y-axis');
810
+
811
+ // Empty state
812
+ if (maxValue === 0) {
813
+ axis.innerHTML = '';
814
+ plot.innerHTML = '';
815
+ plot.style.gridTemplateColumns = '';
816
+ plot.innerHTML = '<div class="chart-empty">' +
817
+ '<div class="chart-empty-title">No usage yet</div>' +
818
+ '<div>Start a session to see your hourly breakdown.</div>' +
819
+ '</div>';
820
+ return;
821
+ }
822
+
823
+ var axisInfo = niceStep(maxValue);
824
+ renderAxis(maxValue);
825
+ var maxEntry = selected.reduce(function(max, entry) {
826
+ return !max || entry.value > max.value ? entry : max;
827
+ }, null);
828
+ renderBars(selected, axisInfo.top, maxEntry);
829
+ }
830
+
831
+ function niceStep(value) {
832
+ // Always divide into 3 intervals, pick a step that gives round numbers
833
+ if (value <= 0) return { top: 3, step: 1 };
834
+ var rawStep = value / 3;
835
+ var exp = Math.floor(Math.log10(rawStep));
836
+ var base = Math.pow(10, exp);
837
+ var frac = rawStep / base;
838
+ // Round step up to nearest 1, 2, or 5 × 10^n
839
+ var nice = frac <= 1 ? base : frac <= 2 ? 2 * base : frac <= 5 ? 5 * base : 10 * base;
840
+ var top = nice * 3;
841
+ return { top: top, step: nice };
842
+ }
843
+
844
+ function renderAxis(axisMax) {
845
+ var axis = document.getElementById('y-axis');
846
+ var info = niceStep(axisMax);
847
+ axis.innerHTML = [
848
+ '<span>' + formatNumber(info.top) + '</span>',
849
+ '<span>' + formatNumber(info.top - info.step) + '</span>',
850
+ '<span>' + formatNumber(info.top - 2 * info.step) + '</span>',
851
+ '<span>0</span>'
852
+ ].join('');
853
+ }
854
+
855
+ function renderBars(selected, maxValue, maxEntry) {
856
+ var plot = document.getElementById('plot');
857
+ plot.innerHTML = '';
858
+ plot.style.gridTemplateColumns = 'repeat(' + selected.length + ', minmax(0, 1fr))';
859
+
860
+ // Adjust gap based on number of bars
861
+ var gap = selected.length > 10 ? '4px' : selected.length > 6 ? '6px' : '8px';
862
+ plot.style.gap = gap;
863
+
864
+ selected.forEach(function(entry) {
865
+ var height = entry.value > 0 ? Math.max(8, (entry.value / maxValue) * 100) : 3;
866
+ var wrap = document.createElement('div');
867
+ wrap.className = 'bar-wrap';
868
+ wrap.style.setProperty('--height', height + '%');
869
+
870
+ var bar = document.createElement('div');
871
+ bar.className = 'bar';
872
+ if (entry.value === 0) {
873
+ bar.classList.add('zero');
874
+ } else if (maxEntry && entry.hour === maxEntry.hour && entry.value === maxEntry.value) {
875
+ bar.classList.add('peak');
876
+ }
877
+ bar.style.height = height + '%';
878
+ wrap.appendChild(bar);
879
+
880
+ if (maxEntry && entry.hour === maxEntry.hour && entry.value === maxEntry.value) {
881
+ var tip = document.createElement('span');
882
+ tip.className = 'bar-tip';
883
+ tip.textContent = formatNumber(entry.value);
884
+ wrap.appendChild(tip);
885
+ }
886
+
887
+ var label = document.createElement('span');
888
+ label.className = 'bar-label';
889
+ label.textContent = String(entry.hour).padStart(2, '0');
890
+ wrap.appendChild(label);
891
+
892
+ plot.appendChild(wrap);
893
+ });
894
+ }
895
+
896
+ // --- App info ---
897
+
898
+ function applyAppInfo(info) {
899
+ appInfo = info || appInfo;
900
+ document.getElementById('app-version').textContent = 'Version ' + (appInfo.version || '--');
901
+ setLaunchAtLogin(Boolean(appInfo.launchAtLogin));
902
+ }
903
+
904
+ function setLaunchAtLogin(enabled) {
905
+ var button = document.getElementById('launch-at-login');
906
+ button.setAttribute('aria-checked', enabled ? 'true' : 'false');
907
+ }
908
+
909
+ function fetchAppInfo() {
910
+ if (window.electronAPI && window.electronAPI.getAppInfo) {
911
+ return window.electronAPI.getAppInfo().then(applyAppInfo).catch(function() {});
912
+ }
913
+ applyAppInfo({ version: '--', launchAtLogin: false });
914
+ return Promise.resolve();
915
+ }
916
+
917
+ // --- Agent filter ---
918
+
919
+ function loadSelectedAgents() {
920
+ try {
921
+ var saved = localStorage.getItem('tokendash:selected-agents');
922
+ if (saved) return JSON.parse(saved);
923
+ } catch(e) {}
924
+ return null;
925
+ }
926
+
927
+ function saveSelectedAgents() {
928
+ try {
929
+ localStorage.setItem('tokendash:selected-agents', JSON.stringify(selectedAgents));
930
+ } catch(e) {}
931
+ // Sync to main process for tray badge updates
932
+ if (window.electronAPI && window.electronAPI.setSelectedAgents) {
933
+ window.electronAPI.setSelectedAgents(selectedAgents);
934
+ }
935
+ }
936
+
937
+ function updateAgentDropdownLabel() {
938
+ var btn = document.getElementById('agent-dropdown-btn');
939
+ if (selectedAgents.length === availableAgents.length) {
940
+ btn.textContent = 'All';
941
+ } else {
942
+ btn.textContent = selectedAgents.length + ' of ' + availableAgents.length;
943
+ }
944
+ }
945
+
946
+ function renderAgentDropdown() {
947
+ var menu = document.getElementById('agent-dropdown-menu');
948
+ menu.innerHTML = '';
949
+
950
+ availableAgents.forEach(function(agent) {
951
+ var isChecked = selectedAgents.indexOf(agent) !== -1;
952
+ var option = document.createElement('div');
953
+ option.className = 'agent-option';
954
+ option.innerHTML =
955
+ '<div class="agent-checkbox' + (isChecked ? ' checked' : '') + '"></div>' +
956
+ '<span>' + agent.charAt(0).toUpperCase() + agent.slice(1) + '</span>';
957
+
958
+ option.addEventListener('click', function(e) {
959
+ e.stopPropagation();
960
+ var idx = selectedAgents.indexOf(agent);
961
+ if (idx !== -1) {
962
+ // Don't allow deselecting the last agent
963
+ if (selectedAgents.length <= 1) return;
964
+ selectedAgents.splice(idx, 1);
965
+ } else {
966
+ selectedAgents.push(agent);
967
+ }
968
+ saveSelectedAgents();
969
+ renderAgentDropdown();
970
+ updateAgentDropdownLabel();
971
+ fetchData();
972
+ });
973
+
974
+ menu.appendChild(option);
975
+ });
976
+
977
+ updateAgentDropdownLabel();
978
+
979
+ // Show/hide the row if only 1 agent available
980
+ var row = document.getElementById('agent-filter-row');
981
+ row.style.display = availableAgents.length <= 1 ? 'none' : '';
982
+ }
983
+
984
+ function initAgentFilter(agents) {
985
+ availableAgents = agents;
986
+ var saved = loadSelectedAgents();
987
+ if (saved) {
988
+ // Filter saved to only include agents that are actually available
989
+ selectedAgents = saved.filter(function(a) { return agents.indexOf(a) !== -1; });
990
+ if (selectedAgents.length === 0) selectedAgents = agents.slice(); // fallback
991
+ } else {
992
+ selectedAgents = agents.slice(); // default: all selected
993
+ }
994
+ renderAgentDropdown();
995
+ saveSelectedAgents();
996
+ }
997
+
998
+ // --- Data fetching ---
999
+
1000
+ function fetchData() {
1001
+ setDateLabel();
1002
+ hideError();
1003
+
1004
+ return fetchJson(apiBase + '/api/agents')
1005
+ .then(function(agentData) {
1006
+ var agents = (agentData && agentData.available) ? agentData.available : ['claude'];
1007
+
1008
+ // Init agent filter on first load or if agents changed
1009
+ if (isFirstLoad || agents.length !== availableAgents.length) {
1010
+ initAgentFilter(agents);
1011
+ isFirstLoad = false;
1012
+ }
1013
+
1014
+ // Use filtered agents
1015
+ var fetchAgents = selectedAgents.length > 0 ? selectedAgents : agents;
1016
+
1017
+ return Promise.all(fetchAgents.map(function(agent) {
1018
+ return fetchJson(apiBase + '/api/daily?agent=' + agent).catch(function() { return null; });
1019
+ })).then(function(dailyResults) {
1020
+ // Also fetch blocks for all selected agents
1021
+ var blockPromises = fetchAgents.map(function(agent) {
1022
+ return fetchJson(apiBase + '/api/blocks?agent=' + agent).catch(function() { return null; });
1023
+ });
1024
+ return Promise.all(blockPromises).then(function(blockResults) {
1025
+ return { dailyResults: dailyResults, blockResults: blockResults };
1026
+ });
1027
+ });
1028
+ })
1029
+ .then(function(data) {
1030
+ var traySnapshot = renderMetrics(data.dailyResults);
1031
+ renderChart(data.blockResults);
1032
+ if (window.electronAPI && window.electronAPI.updateTraySnapshot && traySnapshot.totalTokens > 0) {
1033
+ window.electronAPI.updateTraySnapshot(traySnapshot).catch(function() {});
1034
+ }
1035
+ hideError();
1036
+ })
1037
+ .catch(function(error) {
1038
+ console.error('Popover fetch error:', error);
1039
+ showError();
1040
+ });
1041
+ }
1042
+
1043
+ // --- Event listeners ---
1044
+
1045
+ document.getElementById('open-dashboard').addEventListener('click', function() {
1046
+ if (window.electronAPI && window.electronAPI.openDashboard) {
1047
+ window.electronAPI.openDashboard(apiBase);
1048
+ } else {
1049
+ window.open(apiBase, '_blank');
1050
+ }
1051
+ });
1052
+
1053
+ document.getElementById('open-settings').addEventListener('click', function() {
1054
+ shell.dataset.view = 'settings';
1055
+ });
1056
+
1057
+ document.getElementById('close-settings').addEventListener('click', function() {
1058
+ shell.dataset.view = 'main';
1059
+ });
1060
+
1061
+ document.getElementById('launch-at-login').addEventListener('click', function() {
1062
+ var nextValue = this.getAttribute('aria-checked') !== 'true';
1063
+ if (window.electronAPI && window.electronAPI.setLaunchAtLogin) {
1064
+ window.electronAPI.setLaunchAtLogin(nextValue)
1065
+ .then(function(result) {
1066
+ setLaunchAtLogin(Boolean(result && result.launchAtLogin));
1067
+ })
1068
+ .catch(function() {
1069
+ setLaunchAtLogin(nextValue);
1070
+ });
1071
+ } else {
1072
+ setLaunchAtLogin(nextValue);
1073
+ }
1074
+ });
1075
+
1076
+ document.getElementById('check-updates').addEventListener('click', function() {
1077
+ var status = document.getElementById('update-status');
1078
+ var button = this;
1079
+ status.textContent = 'Checking...';
1080
+ button.disabled = true;
1081
+
1082
+ var promise = window.electronAPI && window.electronAPI.checkForUpdates
1083
+ ? window.electronAPI.checkForUpdates()
1084
+ : Promise.resolve({ currentVersion: appInfo.version, latestVersion: appInfo.version, upToDate: true });
1085
+
1086
+ promise.then(function(result) {
1087
+ if (result.error) {
1088
+ status.textContent = 'Unable to reach update service right now.';
1089
+ return;
1090
+ }
1091
+ status.textContent = result.upToDate
1092
+ ? 'You are up to date.'
1093
+ : 'Version ' + result.latestVersion + ' is available.';
1094
+ }).catch(function() {
1095
+ status.textContent = 'Unable to reach update service right now.';
1096
+ }).finally(function() {
1097
+ button.disabled = false;
1098
+ });
1099
+ });
1100
+
1101
+ document.getElementById('quit-app').addEventListener('click', function() {
1102
+ if (window.electronAPI && window.electronAPI.quitApp) {
1103
+ window.electronAPI.quitApp();
1104
+ }
1105
+ });
1106
+
1107
+ // Agent dropdown toggle
1108
+ document.getElementById('agent-dropdown-btn').addEventListener('click', function(e) {
1109
+ e.stopPropagation();
1110
+ var menu = document.getElementById('agent-dropdown-menu');
1111
+ menu.classList.toggle('open');
1112
+ });
1113
+
1114
+ // Close dropdown on outside click
1115
+ document.addEventListener('click', function() {
1116
+ var menu = document.getElementById('agent-dropdown-menu');
1117
+ if (menu) menu.classList.remove('open');
1118
+ });
1119
+
1120
+ // Prevent dropdown from closing when clicking inside it
1121
+ document.getElementById('agent-dropdown-menu').addEventListener('click', function(e) {
1122
+ e.stopPropagation();
1123
+ });
1124
+
1125
+ // --- Init ---
1126
+
1127
+ fetchAppInfo();
1128
+ fetchData();
1129
+ setInterval(fetchData, 5000);
1130
+ </script>
1131
+ </body>
1132
+ </html>