@monoes/monomindcli 1.10.36 → 1.10.38

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.
Files changed (62) hide show
  1. package/.claude/helpers/utils/telemetry.cjs +1 -0
  2. package/dist/src/browser/actions.d.ts +1 -0
  3. package/dist/src/browser/actions.d.ts.map +1 -1
  4. package/dist/src/browser/actions.js +12 -5
  5. package/dist/src/browser/actions.js.map +1 -1
  6. package/dist/src/browser/batch.d.ts +0 -6
  7. package/dist/src/browser/batch.d.ts.map +1 -1
  8. package/dist/src/browser/batch.js.map +1 -1
  9. package/dist/src/browser/browser.d.ts.map +1 -1
  10. package/dist/src/browser/browser.js +37 -20
  11. package/dist/src/browser/browser.js.map +1 -1
  12. package/dist/src/browser/cdp.d.ts +1 -0
  13. package/dist/src/browser/cdp.d.ts.map +1 -1
  14. package/dist/src/browser/cdp.js +14 -4
  15. package/dist/src/browser/cdp.js.map +1 -1
  16. package/dist/src/browser/console-log.d.ts +4 -4
  17. package/dist/src/browser/console-log.d.ts.map +1 -1
  18. package/dist/src/browser/console-log.js +49 -16
  19. package/dist/src/browser/console-log.js.map +1 -1
  20. package/dist/src/browser/dialog.d.ts +2 -1
  21. package/dist/src/browser/dialog.d.ts.map +1 -1
  22. package/dist/src/browser/dialog.js +24 -10
  23. package/dist/src/browser/dialog.js.map +1 -1
  24. package/dist/src/browser/find.d.ts +1 -1
  25. package/dist/src/browser/find.d.ts.map +1 -1
  26. package/dist/src/browser/find.js +31 -12
  27. package/dist/src/browser/find.js.map +1 -1
  28. package/dist/src/browser/har.d.ts.map +1 -1
  29. package/dist/src/browser/har.js +13 -6
  30. package/dist/src/browser/har.js.map +1 -1
  31. package/dist/src/browser/network.d.ts +3 -0
  32. package/dist/src/browser/network.d.ts.map +1 -1
  33. package/dist/src/browser/network.js +69 -27
  34. package/dist/src/browser/network.js.map +1 -1
  35. package/dist/src/browser/profiler.d.ts.map +1 -1
  36. package/dist/src/browser/profiler.js +25 -14
  37. package/dist/src/browser/profiler.js.map +1 -1
  38. package/dist/src/browser/record.d.ts.map +1 -1
  39. package/dist/src/browser/record.js +21 -10
  40. package/dist/src/browser/record.js.map +1 -1
  41. package/dist/src/browser/session.d.ts.map +1 -1
  42. package/dist/src/browser/session.js +11 -7
  43. package/dist/src/browser/session.js.map +1 -1
  44. package/dist/src/browser/snapshot.d.ts.map +1 -1
  45. package/dist/src/browser/snapshot.js +1 -2
  46. package/dist/src/browser/snapshot.js.map +1 -1
  47. package/dist/src/browser/trace.d.ts.map +1 -1
  48. package/dist/src/browser/trace.js +34 -25
  49. package/dist/src/browser/trace.js.map +1 -1
  50. package/dist/src/browser/types.d.ts +1 -0
  51. package/dist/src/browser/types.d.ts.map +1 -1
  52. package/dist/src/browser/types.js.map +1 -1
  53. package/dist/src/browser/vitals.js +4 -4
  54. package/dist/src/browser/wait.js +28 -14
  55. package/dist/src/browser/wait.js.map +1 -1
  56. package/dist/src/commands/browse.d.ts.map +1 -1
  57. package/dist/src/commands/browse.js +8 -14
  58. package/dist/src/commands/browse.js.map +1 -1
  59. package/dist/src/ui/dashboard-v2.html +534 -1
  60. package/dist/src/ui/server.mjs +194 -3
  61. package/dist/tsconfig.tsbuildinfo +1 -1
  62. package/package.json +1 -1
@@ -436,6 +436,7 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
436
436
  #app.ambient #feed-recap,
437
437
  #app.ambient #feed-timeline,
438
438
  #app.ambient #digest-card { display:none !important; }
439
+ #app.ambient #weekly-card { display:none !important; }
439
440
  #app.ambient #main { background:var(--bg); }
440
441
  #app.ambient #view-now { height:100vh; }
441
442
  #app.ambient #feed-pane { border:none; }
@@ -552,6 +553,37 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
552
553
  .lsp-bar.err { background:var(--red); opacity:0.8; }
553
554
  .le-spark { display:flex; align-items:center; gap:8px; }
554
555
 
556
+ /* ── feature 19: cache efficiency ───────────────────────── */
557
+ .eff-row { display:flex; align-items:center; justify-content:space-between; font-size:11px; margin-bottom:2px; gap:6px; }
558
+ .eff-lbl { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text-lo); flex:1; min-width:0; font-size:10px; }
559
+ .eff-pct { font-size:10px; font-variant-numeric:tabular-nums; flex-shrink:0; }
560
+ .eff-good { color:oklch(65% 0.15 150); }
561
+ .eff-warn { color:oklch(70% 0.18 80); }
562
+ .eff-bad { color:oklch(65% 0.2 25); }
563
+ .eff-bar-wrap { height:2px; background:var(--border); border-radius:1px; margin-bottom:5px; }
564
+ .eff-bar-fill { height:2px; border-radius:1px; }
565
+
566
+ /* ── feature 21: weekly recap ────────────────────────────── */
567
+ #weekly-card { display:none; border:1px solid var(--border); border-radius:8px; margin:0 18px 10px; padding:10px 12px; background:oklch(14% 0.008 55 / 0.6); }
568
+ #weekly-card.show { display:block; }
569
+ .weekly-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:6px; }
570
+ .weekly-title { font-size:10px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-lo); }
571
+ .weekly-dismiss { background:none; border:none; color:var(--text-xs); cursor:pointer; font-size:11px; padding:0; line-height:1; }
572
+ .weekly-dismiss:hover { color:var(--text-lo); }
573
+
574
+ /* ── feature 22: context saturation ─────────────────────── */
575
+ .ctx-sat-wrap { margin-top:4px; }
576
+ .ctx-sat-bar { height:2px; background:var(--border); border-radius:1px; overflow:hidden; }
577
+ .ctx-sat-fill { height:2px; border-radius:1px; }
578
+ .ctx-sat-lbl { font-size:9px; color:var(--text-xs); margin-top:1px; }
579
+
580
+ /* ── feature 24: activity heatmap ───────────────────────── */
581
+ .heatmap-grid { display:flex; flex-direction:column; gap:2px; }
582
+ .heatmap-row { display:flex; align-items:center; gap:1px; }
583
+ .heatmap-lbl { width:14px; font-size:8px; color:var(--text-xs); flex-shrink:0; text-align:right; padding-right:2px; }
584
+ .heatmap-hdr-cell { width:9px; font-size:7px; color:var(--text-xs); text-align:center; flex-shrink:0; }
585
+ .heatmap-cell { width:9px; height:9px; border-radius:1px; flex-shrink:0; cursor:default; }
586
+
555
587
  /* ── budget cap ──────────────────────────────────────────── */
556
588
  #budget-modal { display:none; position:fixed; inset:0; z-index:200; background:oklch(5% 0 0 / 0.6); align-items:center; justify-content:center; }
557
589
  #budget-modal.open { display:flex; }
@@ -566,6 +598,59 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
566
598
  .bm-save:hover { background:oklch(68% 0.18 75); }
567
599
  .bm-cancel { background:transparent; border:1px solid var(--border); border-radius:var(--r); padding:7px 12px; font-size:13px; color:var(--text-lo); cursor:pointer; }
568
600
  .bm-cancel:hover { color:var(--text-hi); }
601
+
602
+ /* ── toast notifications ──────────────────────────────────── */
603
+ #toast-rack { position:fixed; bottom:20px; right:20px; z-index:9999; display:flex; flex-direction:column; gap:8px; pointer-events:none; }
604
+ .toast { display:flex; align-items:flex-start; gap:10px; padding:10px 14px; border-radius:8px; font-size:12px; max-width:300px; pointer-events:all; background:oklch(18% 0.012 55); border:1px solid var(--border); color:var(--text-mid); box-shadow:0 4px 24px rgba(0,0,0,0.4); animation:toast-in 0.25s var(--ease-out); }
605
+ .toast.t-warn { border-color:oklch(72% 0.18 75 / 0.4); background:oklch(18% 0.015 75); }
606
+ .toast.t-err { border-color:oklch(60% 0.18 25 / 0.4); background:oklch(17% 0.015 25); }
607
+ .toast.t-ok { border-color:oklch(65% 0.15 150 / 0.4); background:oklch(17% 0.012 150); }
608
+ .toast-ico { flex-shrink:0; font-size:14px; }
609
+ .toast-body { flex:1; min-width:0; }
610
+ .toast-title { font-weight:600; color:var(--text-hi); margin-bottom:2px; }
611
+ .toast-msg { color:var(--text-lo); line-height:1.4; }
612
+ .toast-close { background:none; border:none; color:var(--text-xs); cursor:pointer; font-size:12px; line-height:1; padding:0; flex-shrink:0; }
613
+ .toast-close:hover { color:var(--text-lo); }
614
+ @keyframes toast-in { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
615
+
616
+ /* ── token velocity sparkline ─────────────────────────────── */
617
+ #vel-chart { display:flex; align-items:flex-end; gap:2px; height:28px; margin-top:6px; }
618
+ .vel-bar { flex:1; border-radius:2px 2px 0 0; min-height:2px; background:oklch(65% 0.18 200 / 0.55); }
619
+ .vel-bar.vel-hi { background:oklch(65% 0.18 150 / 0.8); }
620
+ .vel-bar.vel-lo { background:oklch(65% 0.08 200 / 0.3); }
621
+
622
+ /* ── shortcut help modal ──────────────────────────────────── */
623
+ #shortcut-modal { display:none; position:fixed; inset:0; z-index:500; background:oklch(0% 0 0 / 0.6); align-items:center; justify-content:center; }
624
+ #shortcut-modal.open { display:flex; }
625
+ #shortcut-box { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:24px 28px; max-width:460px; width:90%; max-height:80vh; overflow-y:auto; }
626
+ .sk-title { font-size:14px; font-weight:600; color:var(--text-hi); margin-bottom:16px; display:flex; justify-content:space-between; align-items:center; }
627
+ .sk-close { background:none; border:none; color:var(--text-xs); cursor:pointer; font-size:16px; padding:0; }
628
+ .sk-close:hover { color:var(--text-lo); }
629
+ .sk-section { font-size:10px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-xs); margin:14px 0 6px; }
630
+ .sk-row { display:flex; justify-content:space-between; align-items:center; padding:5px 0; border-bottom:1px solid oklch(25% 0.008 55 / 0.4); }
631
+ .sk-row:last-child { border-bottom:none; }
632
+ .sk-keys { display:flex; gap:3px; }
633
+ .sk-keys kbd { font-size:10px; background:var(--surface-hi); border:1px solid var(--border); border-radius:3px; padding:1px 6px; font-family:var(--mono); color:var(--text-lo); }
634
+ .sk-desc { font-size:11px; color:var(--text-mid); }
635
+
636
+ /* ── session filter ───────────────────────────────────────── */
637
+ #sess-filter-wrap { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
638
+ #sess-filter-input { flex:1; background:var(--surface-hi); border:1px solid var(--border); border-radius:var(--r); padding:5px 10px; font-size:12px; color:var(--text-hi); outline:none; font-family:var(--sans); }
639
+ #sess-filter-input:focus { border-color:oklch(72% 0.18 75 / 0.5); }
640
+ #sess-filter-input::placeholder { color:var(--text-xs); }
641
+ #sess-filter-count { font-size:11px; color:var(--text-xs); white-space:nowrap; min-width:60px; text-align:right; }
642
+
643
+ /* ── topbar activity chip ─────────────────────────────────── */
644
+ #topbar-activity { font-size:10px; color:var(--text-xs); font-family:var(--mono); max-width:180px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; opacity:0; transition:opacity 0.3s; }
645
+ #topbar-activity.loaded { opacity:1; }
646
+
647
+ /* ── anomaly badge on session rows ────────────────────────── */
648
+ .sess-anomaly { display:inline-flex; align-items:center; font-size:10px; padding:1px 5px; border-radius:5px; font-weight:600; margin-left:4px; vertical-align:middle; }
649
+ .sess-anomaly.anom-cost { background:oklch(60% 0.18 25 / 0.12); color:oklch(70% 0.18 25); }
650
+ .sess-anomaly.anom-err { background:oklch(65% 0.15 300 / 0.12); color:oklch(72% 0.15 300); }
651
+
652
+ /* ── streak badge ─────────────────────────────────────────── */
653
+ .streak-chip { display:inline-flex; align-items:center; gap:3px; font-size:11px; padding:2px 7px; border-radius:8px; background:oklch(65% 0.18 75 / 0.12); color:oklch(78% 0.18 75); white-space:nowrap; }
569
654
  </style>
570
655
  </head>
571
656
  <body>
@@ -623,9 +708,11 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
623
708
  <span id="view-title">Now</span>
624
709
  <span class="pill"><span class="live-dot"></span> live</span>
625
710
  <span id="topbar-cost"></span>
711
+ <span id="topbar-activity"></span>
626
712
  <div id="tb-right">
627
713
  <button class="btn" id="btn-budget" onclick="openBudgetModal()" title="Set daily/monthly cost budget">⚑ Budget</button>
628
714
  <button class="btn" onclick="openCmdPalette()">⌕ Search <kbd style="font-size:10px;opacity:0.6;margin-left:3px">⌘K</kbd></button>
715
+ <button class="btn" onclick="openShortcutHelp()" title="Keyboard shortcuts (?)">? Help</button>
629
716
  <button class="btn" onclick="refreshCurrent()">↺ Refresh</button>
630
717
  </div>
631
718
  </div>
@@ -697,6 +784,13 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
697
784
  <button class="tf-btn" data-tf="24h" onclick="setFeedTimeFilter('24h')">24h</button>
698
785
  <span class="kb-hint"><kbd>J</kbd><kbd>K</kbd> navigate &nbsp;<kbd>↵</kbd> detail &nbsp;<kbd>/</kbd> find &nbsp;<kbd>G</kbd> live &nbsp;<kbd>A</kbd> ambient &nbsp;<kbd>⌘K</kbd> search</span>
699
786
  </div>
787
+ <div id="weekly-card">
788
+ <div class="weekly-header">
789
+ <span class="weekly-title">This week</span>
790
+ <button class="weekly-dismiss" onclick="dismissWeekly()" title="Dismiss">✕</button>
791
+ </div>
792
+ <div class="digest-row" id="weekly-stats"></div>
793
+ </div>
700
794
  <div id="digest-card">
701
795
  <div class="digest-title">Today's Digest <button class="digest-close" onclick="dismissDigest()" title="Dismiss">✕</button></div>
702
796
  <div class="digest-row" id="digest-stats"></div>
@@ -746,6 +840,18 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
746
840
  <div class="m-group-title">Session Lanes</div>
747
841
  <div class="loading-txt">—</div>
748
842
  </div>
843
+ <div id="m-efficiency">
844
+ <div class="m-group-title">Cache Efficiency</div>
845
+ <div class="loading-txt">—</div>
846
+ </div>
847
+ <div id="m-heatmap">
848
+ <div class="m-group-title">Activity Heatmap</div>
849
+ <div class="loading-txt">—</div>
850
+ </div>
851
+ <div id="m-velocity">
852
+ <div class="m-group-title">Token Velocity</div>
853
+ <div class="loading-txt">—</div>
854
+ </div>
749
855
  </div>
750
856
  </div>
751
857
 
@@ -768,6 +874,12 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
768
874
  <div class="pg-title" style="margin-bottom:0">Sessions</div>
769
875
  <button id="sess-star-filter" onclick="toggleSessStarFilter()" title="Show only bookmarked sessions">☆ Starred</button>
770
876
  <button class="lb-toggle" id="btn-leaderboard" onclick="toggleLeaderboard()" title="Cost leaderboard">⬆ Leaderboard</button>
877
+ <button class="lb-toggle" id="btn-model-mix" onclick="toggleModelMix()" title="Model usage breakdown">⬡ Models</button>
878
+ <button class="lb-toggle" id="btn-tool-errors" onclick="toggleToolErrors()" title="Tool error rate">⚠ Errors</button>
879
+ <button class="lb-toggle" id="btn-tool-rank" onclick="toggleToolRank()" title="Most-used tools across sessions">⟳ Tools</button>
880
+ <button class="lb-toggle" id="btn-proj-costs" onclick="toggleProjCosts()" title="Cost breakdown by project">$ Projects</button>
881
+ <button class="lb-toggle" id="btn-export-csv" onclick="exportSessionsCSV()" title="Export sessions as CSV">⬇ CSV</button>
882
+ <button class="lb-toggle" id="btn-patterns" onclick="togglePatterns()" title="Prompt word frequency across sessions">⊞ Patterns</button>
771
883
  <button class="diff-toggle" id="btn-diff" onclick="toggleDiffMode()" title="Compare two sessions">⇄ Compare</button>
772
884
  </div>
773
885
  <div class="pg-sub" id="sess-pg-sub">Recent Claude Code sessions for this project</div>
@@ -781,6 +893,25 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
781
893
  <th class="lb-rank">#</th><th>Session</th><th class="lb-cost">Cost</th><th class="lb-dur">Duration</th>
782
894
  </tr></thead><tbody id="lb-body"></tbody></table>
783
895
  </div>
896
+ <div id="model-mix-panel" style="display:none;margin-bottom:16px">
897
+ <div id="model-mix-body"></div>
898
+ </div>
899
+ <div id="tool-errors-panel" style="display:none;margin-bottom:16px">
900
+ <div id="tool-errors-body"></div>
901
+ </div>
902
+ <div id="tool-rank-panel" style="display:none;margin-bottom:16px">
903
+ <div id="tool-rank-body"></div>
904
+ </div>
905
+ <div id="proj-costs-panel" style="display:none;margin-bottom:16px">
906
+ <div id="proj-costs-body"></div>
907
+ </div>
908
+ <div id="patterns-panel" style="display:none;margin-bottom:16px">
909
+ <div id="patterns-body"></div>
910
+ </div>
911
+ <div id="sess-filter-wrap">
912
+ <input id="sess-filter-input" type="text" placeholder="Filter sessions by prompt…" oninput="filterSessions(this.value)" autocomplete="off">
913
+ <span id="sess-filter-count"></span>
914
+ </div>
784
915
  <div id="sess-content" class="sess-list"><div class="loading-txt">Loading…</div></div>
785
916
  </div>
786
917
  </div>
@@ -831,6 +962,7 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
831
962
  </div><!-- /main -->
832
963
  <div id="app-ambient-hint">Press A to exit ambient mode</div>
833
964
  </div><!-- /app -->
965
+ <div id="toast-rack"></div>
834
966
 
835
967
  <!-- budget modal (fixed overlay, outside app) -->
836
968
  <div id="budget-modal" onclick="if(event.target===this)closeBudgetModal()">
@@ -1521,6 +1653,11 @@ async function renderSessions() {
1521
1653
  const cost = typeof s.totalCost === 'number' ? '$' + s.totalCost.toFixed(2)
1522
1654
  : typeof s.cost === 'number' ? '$' + s.cost.toFixed(2) : '';
1523
1655
  const meta = [dur, msgs, cost].filter(Boolean).join(' · ') || s.id.slice(0, 16);
1656
+ const satPct = Math.min(100, Math.round((s.totalMessages || 0) / 200 * 100));
1657
+ const satColor = satPct > 80 ? 'oklch(65% 0.2 25)' : satPct > 50 ? 'oklch(70% 0.18 80)' : 'var(--accent)';
1658
+ const satBar = satPct > 0 ? `<div class="ctx-sat-wrap" title="Context saturation ~${satPct}% (${s.totalMessages||0} turns / 200 est. max)">
1659
+ <div class="ctx-sat-bar"><div class="ctx-sat-fill" style="width:${satPct}%;background:${satColor}"></div></div>
1660
+ </div>` : '';
1524
1661
  const summaries = (s.summaries || []).slice(0, 2).map(sm => { const t = typeof sm === 'string' ? sm : (sm.summary || sm.text || String(sm)); return `<span class="sr-tag">${esc(t.slice(0, 40))}</span>`; }).join('');
1525
1662
  const autoTags = (allTags.sessionTags.get(s.id) || []).map(t => `<span class="sr-autotag">${esc(t)}</span>`).join('');
1526
1663
  const isStarred = bookmarks.has(s.id);
@@ -1536,6 +1673,7 @@ async function renderSessions() {
1536
1673
  </div>
1537
1674
  <div class="sr-meta">${esc(meta)}</div>
1538
1675
  ${(summaries || autoTags) ? `<div class="sr-tags">${summaries}${autoTags}</div>` : ''}
1676
+ ${satBar}
1539
1677
  <div class="sess-notes-wrap" onclick="event.stopPropagation()">
1540
1678
  <button class="sess-notes-toggle${hasNote ? ' has-note' : ''}" onclick="toggleSessNote('${esc(s.id)}',this)">✎ ${hasNote ? 'Note' : 'Add note'}</button>
1541
1679
  <div class="sess-notes-area" id="snote-${esc(s.id)}">
@@ -1550,7 +1688,27 @@ async function renderSessions() {
1550
1688
  el.innerHTML = buildTagFilterBar(toShow) + el.innerHTML;
1551
1689
  }
1552
1690
  buildDigest();
1691
+ buildWeeklyRecap();
1692
+ buildEfficiencyPanel();
1693
+ buildActivityHeatmap();
1694
+ buildTokenVelocity();
1553
1695
  if (leaderboardOpen) renderLeaderboard();
1696
+ // check budget thresholds and fire toasts
1697
+ const todayCost = allSessions.filter(s => {
1698
+ const t = s.firstTs || s.mtime;
1699
+ if (!t) return false;
1700
+ const d = new Date(typeof t === 'number' ? t : t);
1701
+ const now = new Date();
1702
+ return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
1703
+ }).reduce((a, s) => a + (s.totalCost || 0), 0);
1704
+ const monthCost = allSessions.filter(s => {
1705
+ const t = s.firstTs || s.mtime;
1706
+ if (!t) return false;
1707
+ const d = new Date(typeof t === 'number' ? t : t);
1708
+ const now = new Date();
1709
+ return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
1710
+ }).reduce((a, s) => a + (s.totalCost || 0), 0);
1711
+ checkBudgetToast(todayCost, monthCost);
1554
1712
  } catch (err) {
1555
1713
  el.innerHTML = '<div class="empty">Could not load sessions: ' + esc(err.message) + '</div>';
1556
1714
  }
@@ -2268,7 +2426,33 @@ function buildSwimlane() {
2268
2426
  </div>
2269
2427
  </div>`;
2270
2428
  }).join('');
2271
- el.innerHTML = `<div class="m-group-title">Session Lanes <span style="font-size:10px;color:var(--text-xs);font-weight:400">24h</span></div><div id="swimlane-wrap">${rows}</div>`;
2429
+ // dead time: find largest gap between consecutive sessions
2430
+ const sorted = recent.slice().sort((a, b) => {
2431
+ const aTs = typeof (a.firstTs || a.mtime) === 'number' ? (a.firstTs || a.mtime) : new Date(a.firstTs || a.mtime).getTime();
2432
+ const bTs = typeof (b.firstTs || b.mtime) === 'number' ? (b.firstTs || b.mtime) : new Date(b.firstTs || b.mtime).getTime();
2433
+ return aTs - bTs;
2434
+ });
2435
+ let maxGapMs = 0; let gapStart = 0;
2436
+ for (let i = 1; i < sorted.length; i++) {
2437
+ const prev = sorted[i - 1];
2438
+ const curr = sorted[i];
2439
+ const prevTs = typeof (prev.firstTs || prev.mtime) === 'number' ? (prev.firstTs || prev.mtime) : new Date(prev.firstTs || prev.mtime).getTime();
2440
+ const prevEnd = prevTs + (prev.totalDurationMs || 60000);
2441
+ const currTs = typeof (curr.firstTs || curr.mtime) === 'number' ? (curr.firstTs || curr.mtime) : new Date(curr.firstTs || curr.mtime).getTime();
2442
+ const gap = currTs - prevEnd;
2443
+ if (gap > maxGapMs) { maxGapMs = gap; gapStart = prevEnd; }
2444
+ }
2445
+ const MIN15 = 15 * 60000;
2446
+ let gapNote = '';
2447
+ if (maxGapMs > MIN15) {
2448
+ const gapMins = Math.round(maxGapMs / 60000);
2449
+ const when = new Date(gapStart);
2450
+ const hr = when.getHours().toString().padStart(2, '0');
2451
+ const mn = when.getMinutes().toString().padStart(2, '0');
2452
+ const label = maxGapMs > 3600000 ? `${Math.floor(maxGapMs/3600000)}h idle` : `${gapMins}m idle`;
2453
+ gapNote = `<div style="font-size:10px;color:var(--text-xs);margin-top:5px;padding:3px 6px;border-radius:4px;background:oklch(40% 0.04 55 / 0.15)">Longest gap: ${label} starting ${hr}:${mn}</div>`;
2454
+ }
2455
+ el.innerHTML = `<div class="m-group-title">Session Lanes <span style="font-size:10px;color:var(--text-xs);font-weight:400">24h</span></div><div id="swimlane-wrap">${rows}</div>${gapNote}`;
2272
2456
  }
2273
2457
 
2274
2458
  // ── feature 18: loop run history ──────────────────────────
@@ -2335,6 +2519,185 @@ function toggleLoop(row) {
2335
2519
  row.classList.toggle('open');
2336
2520
  }
2337
2521
 
2522
+ // ── feature 19: cache efficiency panel ────────────────────
2523
+ function buildEfficiencyPanel() {
2524
+ const el = document.getElementById('m-efficiency');
2525
+ if (!el) return;
2526
+ const sessions = allSessions.filter(s => s.totalInputTokens > 0);
2527
+ if (!sessions.length) {
2528
+ el.innerHTML = `<div class="m-group-title">Cache Efficiency</div><div class="loading-txt" style="padding:4px 0">No token data yet</div>`;
2529
+ return;
2530
+ }
2531
+ const totalCR = sessions.reduce((a,s) => a + (s.cacheReadTokens||0), 0);
2532
+ const totalIn = sessions.reduce((a,s) => a + (s.totalInputTokens||0), 0);
2533
+ const avgPct = totalIn > 0 ? Math.round(totalCR/totalIn*100) : 0;
2534
+ const avgCls = avgPct >= 60 ? 'eff-good' : avgPct >= 20 ? 'eff-warn' : 'eff-bad';
2535
+ const rows = sessions.slice(0, 5).map(s => {
2536
+ const pct = s.totalInputTokens > 0 ? Math.round((s.cacheReadTokens||0)/s.totalInputTokens*100) : 0;
2537
+ const cls = pct >= 60 ? 'eff-good' : pct >= 20 ? 'eff-warn' : 'eff-bad';
2538
+ const fillColor = pct >= 60 ? 'oklch(65% 0.15 150)' : pct >= 20 ? 'oklch(70% 0.18 80)' : 'oklch(65% 0.2 25)';
2539
+ const lbl = (s.lastPrompt || s.id).slice(0, 22);
2540
+ return `<div class="eff-row">
2541
+ <span class="eff-lbl" title="${esc(s.lastPrompt||s.id)}">${esc(lbl)}</span>
2542
+ <span class="eff-pct ${cls}">${pct}%</span>
2543
+ </div>
2544
+ <div class="eff-bar-wrap"><div class="eff-bar-fill" style="width:${pct}%;background:${fillColor}"></div></div>`;
2545
+ }).join('');
2546
+ el.innerHTML = `<div class="m-group-title">Cache Efficiency <span class="${avgCls}" style="font-size:10px;font-weight:400">${avgPct}% avg</span></div>${rows}`;
2547
+ }
2548
+
2549
+ // ── feature 20: model mix ──────────────────────────────────
2550
+ let modelMixOpen = false;
2551
+ function toggleModelMix() {
2552
+ modelMixOpen = !modelMixOpen;
2553
+ const p = document.getElementById('model-mix-panel');
2554
+ p.style.display = modelMixOpen ? 'block' : 'none';
2555
+ document.getElementById('btn-model-mix').classList.toggle('active', modelMixOpen);
2556
+ if (modelMixOpen) renderModelMix();
2557
+ }
2558
+ function renderModelMix() {
2559
+ const body = document.getElementById('model-mix-body');
2560
+ const breakdown = {};
2561
+ for (const s of allSessions) {
2562
+ for (const [model, d] of Object.entries(s.modelBreakdown || {})) {
2563
+ if (!breakdown[model]) breakdown[model] = { calls: 0, cost: 0 };
2564
+ breakdown[model].calls += d.calls || 0;
2565
+ breakdown[model].cost += d.cost || 0;
2566
+ }
2567
+ }
2568
+ const entries = Object.entries(breakdown).sort((a,b) => b[1].cost - a[1].cost);
2569
+ if (!entries.length) {
2570
+ body.innerHTML = '<div style="color:var(--text-lo);font-size:11px;padding:8px 0">No model data</div>';
2571
+ return;
2572
+ }
2573
+ const totalCost = entries.reduce((a,[,d]) => a + d.cost, 0);
2574
+ body.innerHTML = `<table class="lb-table"><thead><tr>
2575
+ <th>Model</th><th class="lb-cost">Cost</th><th class="lb-dur">%</th><th class="lb-dur">Calls</th>
2576
+ </tr></thead><tbody>
2577
+ ${entries.map(([model, d]) => {
2578
+ const short = model.replace(/^claude-/,'').replace(/-\d{8}$/,'');
2579
+ const pct = totalCost > 0 ? Math.round(d.cost/totalCost*100) : 0;
2580
+ return `<tr>
2581
+ <td style="font-size:11px">${esc(short)}</td>
2582
+ <td class="lb-cost">$${d.cost.toFixed(2)}</td>
2583
+ <td class="lb-dur">${pct}%</td>
2584
+ <td class="lb-dur">${d.calls}</td>
2585
+ </tr>`;
2586
+ }).join('')}
2587
+ </tbody></table>`;
2588
+ }
2589
+
2590
+ // ── feature 21: weekly recap ───────────────────────────────
2591
+ const WEEKLY_DISMISSED_KEY = 'mm-weekly-dismissed';
2592
+ function getWeekKey() {
2593
+ const d = new Date();
2594
+ const sun = new Date(d); sun.setDate(d.getDate() - d.getDay()); sun.setHours(0,0,0,0);
2595
+ return sun.toISOString().slice(0,10);
2596
+ }
2597
+ function buildWeeklyRecap() {
2598
+ if (localStorage.getItem(WEEKLY_DISMISSED_KEY) === getWeekKey()) return;
2599
+ if (!allSessions.length) return;
2600
+ const weekStart = new Date(); weekStart.setDate(weekStart.getDate() - weekStart.getDay()); weekStart.setHours(0,0,0,0);
2601
+ const weekSess = allSessions.filter(s => {
2602
+ const t = s.lastTs || s.mtime;
2603
+ return t && new Date(typeof t === 'number' ? t : t).getTime() >= weekStart.getTime();
2604
+ });
2605
+ if (!weekSess.length) return;
2606
+ const totalCost = weekSess.reduce((a,s) => a + (s.totalCost||0), 0);
2607
+ const totalTools = weekSess.reduce((a,s) => a + (s.toolCalls||0), 0);
2608
+ const days = new Set(weekSess.map(s => {
2609
+ const t = s.lastTs || s.mtime;
2610
+ return new Date(typeof t === 'number' ? t : t).toDateString();
2611
+ })).size;
2612
+ const longestMs = Math.max(...weekSess.map(s => s.totalDurationMs||0));
2613
+ const stats = [
2614
+ `${weekSess.length} session${weekSess.length!==1?'s':''}`,
2615
+ `${days} day${days!==1?'s':''}`,
2616
+ totalCost > 0 ? `$${totalCost.toFixed(2)} spent` : null,
2617
+ totalTools > 0 ? `${totalTools.toLocaleString()} tool calls` : null,
2618
+ longestMs > 0 ? `${fmtDur(longestMs)} longest` : null,
2619
+ ].filter(Boolean);
2620
+ document.getElementById('weekly-stats').innerHTML = stats.map(s => `<span class="digest-stat">${esc(s)}</span>`).join('');
2621
+ document.getElementById('weekly-card').classList.add('show');
2622
+ }
2623
+ function dismissWeekly() {
2624
+ localStorage.setItem(WEEKLY_DISMISSED_KEY, getWeekKey());
2625
+ document.getElementById('weekly-card').classList.remove('show');
2626
+ }
2627
+
2628
+ // ── feature 23: tool error rate ────────────────────────────
2629
+ let toolErrorsOpen = false;
2630
+ function toggleToolErrors() {
2631
+ toolErrorsOpen = !toolErrorsOpen;
2632
+ const p = document.getElementById('tool-errors-panel');
2633
+ p.style.display = toolErrorsOpen ? 'block' : 'none';
2634
+ document.getElementById('btn-tool-errors').classList.toggle('active', toolErrorsOpen);
2635
+ if (toolErrorsOpen) loadToolErrors();
2636
+ }
2637
+ async function loadToolErrors() {
2638
+ const body = document.getElementById('tool-errors-body');
2639
+ body.innerHTML = '<div class="loading-txt">Loading…</div>';
2640
+ try {
2641
+ const data = await apiFetch('/api/tool-errors?dir=' + enc(DIR));
2642
+ const errors = data.errors || [];
2643
+ if (!errors.length) {
2644
+ body.innerHTML = '<div style="color:var(--text-lo);font-size:11px;padding:8px 0">No tool errors found in recent sessions</div>';
2645
+ return;
2646
+ }
2647
+ body.innerHTML = `<table class="lb-table"><thead><tr>
2648
+ <th>Tool</th><th class="lb-cost">Errors</th><th class="lb-dur">Rate</th>
2649
+ </tr></thead><tbody>
2650
+ ${errors.map(e => {
2651
+ const rate = e.total > 0 ? Math.round(e.count/e.total*100)+'%' : '—';
2652
+ return `<tr>
2653
+ <td style="font-size:11px">${esc(e.tool)}</td>
2654
+ <td class="lb-cost" style="color:oklch(65% 0.2 25)">${e.count}</td>
2655
+ <td class="lb-dur">${rate}</td>
2656
+ </tr>`;
2657
+ }).join('')}
2658
+ </tbody></table>`;
2659
+ } catch (err) {
2660
+ body.innerHTML = `<div style="color:var(--text-lo);font-size:11px">Error: ${esc(err.message)}</div>`;
2661
+ }
2662
+ }
2663
+
2664
+ // ── feature 24: activity heatmap ──────────────────────────
2665
+ function buildActivityHeatmap() {
2666
+ const el = document.getElementById('m-heatmap');
2667
+ if (!el) return;
2668
+ if (!allSessions.length) {
2669
+ el.innerHTML = `<div class="m-group-title">Activity Heatmap</div><div class="loading-txt" style="padding:4px 0">No sessions</div>`;
2670
+ return;
2671
+ }
2672
+ const grid = Array.from({length:7}, () => new Array(24).fill(0));
2673
+ for (const s of allSessions) {
2674
+ const t = s.firstTs || s.mtime;
2675
+ if (!t) continue;
2676
+ const d = new Date(typeof t === 'number' ? t : t);
2677
+ grid[d.getDay()][d.getHours()]++;
2678
+ }
2679
+ const maxVal = Math.max(1, ...grid.flat());
2680
+ const DAYS = ['Su','Mo','Tu','We','Th','Fr','Sa'];
2681
+ let html = '<div class="heatmap-grid">';
2682
+ html += '<div class="heatmap-row"><div class="heatmap-lbl"></div>';
2683
+ for (let h = 0; h < 24; h++) {
2684
+ html += `<div class="heatmap-hdr-cell">${h % 6 === 0 ? h : ''}</div>`;
2685
+ }
2686
+ html += '</div>';
2687
+ for (let d = 0; d < 7; d++) {
2688
+ html += `<div class="heatmap-row"><div class="heatmap-lbl">${DAYS[d]}</div>`;
2689
+ for (let h = 0; h < 24; h++) {
2690
+ const v = grid[d][h];
2691
+ const alpha = v > 0 ? Math.max(0.18, v/maxVal).toFixed(2) : 0;
2692
+ const bg = v > 0 ? `oklch(65% 0.18 200 / ${alpha})` : 'transparent';
2693
+ html += `<div class="heatmap-cell" style="background:${bg};border:1px solid ${v>0?'transparent':'var(--border)'}" title="${DAYS[d]} ${h}:00 — ${v} session${v!==1?'s':''}"></div>`;
2694
+ }
2695
+ html += '</div>';
2696
+ }
2697
+ html += '</div>';
2698
+ el.innerHTML = `<div class="m-group-title">Activity Heatmap <span style="font-size:10px;color:var(--text-xs);font-weight:400">by day/hr</span></div>${html}`;
2699
+ }
2700
+
2338
2701
  // ── memory ─────────────────────────────────────────────────
2339
2702
  async function renderMemory() {
2340
2703
  activeMemNs = 'All'; // reset on every render so stale filter doesn't persist
@@ -2784,6 +3147,176 @@ document.addEventListener('keydown', e => {
2784
3147
  }
2785
3148
  });
2786
3149
 
3150
+ // ── feature 25: notification toasts ──────────────────────
3151
+ let _toastLastBudgetKey = '';
3152
+ function showToast(title, msg, type = 'info', duration = 5000) {
3153
+ const rack = document.getElementById('toast-rack');
3154
+ if (!rack) return;
3155
+ const icoMap = { warn: '⚑', err: '⚠', ok: '✓', info: '◉' };
3156
+ const div = document.createElement('div');
3157
+ div.className = 'toast t-' + type;
3158
+ div.innerHTML = `<span class="toast-ico">${icoMap[type] || '◉'}</span>
3159
+ <div class="toast-body">
3160
+ <div class="toast-title">${esc(title)}</div>
3161
+ <div class="toast-msg">${esc(msg)}</div>
3162
+ </div>
3163
+ <button class="toast-close" onclick="this.closest('.toast').remove()">✕</button>`;
3164
+ rack.appendChild(div);
3165
+ if (duration > 0) setTimeout(() => { try { div.remove(); } catch {} }, duration);
3166
+ }
3167
+
3168
+ function checkBudgetToast(todayCost, monthCost) {
3169
+ const budget = JSON.parse(localStorage.getItem('mm-budget') || '{}');
3170
+ const daily = parseFloat(budget.daily) || 0;
3171
+ const monthly = parseFloat(budget.monthly) || 0;
3172
+ if (daily > 0 && todayCost >= daily * 0.9) {
3173
+ const pct = Math.round((todayCost / daily) * 100);
3174
+ const key = 'daily-' + pct;
3175
+ if (key !== _toastLastBudgetKey) {
3176
+ _toastLastBudgetKey = key;
3177
+ showToast('Budget alert', `Daily spend at ${pct}% ($${todayCost.toFixed(2)} of $${daily})`, todayCost >= daily ? 'err' : 'warn');
3178
+ }
3179
+ }
3180
+ if (monthly > 0 && monthCost >= monthly * 0.9) {
3181
+ const pct = Math.round((monthCost / monthly) * 100);
3182
+ const key = 'monthly-' + pct;
3183
+ if (key !== _toastLastBudgetKey) {
3184
+ _toastLastBudgetKey = key;
3185
+ showToast('Monthly budget', `Monthly spend at ${pct}% ($${monthCost.toFixed(2)} of $${monthly})`, monthCost >= monthly ? 'err' : 'warn');
3186
+ }
3187
+ }
3188
+ }
3189
+
3190
+ // ── feature 26: token velocity sparkline ──────────────────
3191
+ function buildTokenVelocity() {
3192
+ const el = document.getElementById('m-velocity');
3193
+ if (!el || !allSessions.length) return;
3194
+ const now = Date.now();
3195
+ const HOUR = 3600000;
3196
+ const buckets = new Array(24).fill(0);
3197
+ for (const s of allSessions) {
3198
+ const t = s.firstTs || s.mtime;
3199
+ if (!t) continue;
3200
+ const ts = typeof t === 'number' ? t : new Date(t).getTime();
3201
+ const hoursAgo = (now - ts) / HOUR;
3202
+ if (hoursAgo < 0 || hoursAgo >= 24) continue;
3203
+ const bucket = Math.min(23, Math.floor(23 - hoursAgo));
3204
+ buckets[bucket] += s.totalInputTokens || 0;
3205
+ }
3206
+ const maxTok = Math.max(1, ...buckets);
3207
+ const totalTok = buckets.reduce((a, b) => a + b, 0);
3208
+ const fmt = n => n > 1e6 ? (n/1e6).toFixed(1)+'M' : n > 1e3 ? (n/1e3).toFixed(0)+'k' : String(n);
3209
+ const bars = buckets.map((v, i) => {
3210
+ const h = Math.max(2, Math.round((v / maxTok) * 28));
3211
+ const cls = v > maxTok * 0.66 ? 'vel-hi' : v < maxTok * 0.15 ? 'vel-lo' : '';
3212
+ const hrsAgo = 23 - i;
3213
+ return `<div class="vel-bar ${cls}" style="height:${h}px" title="${fmt(v)} tokens — ${hrsAgo}h ago"></div>`;
3214
+ }).join('');
3215
+ const totalCost = allSessions.reduce((a, s) => a + (s.totalCost || 0), 0);
3216
+ el.innerHTML = `<div class="m-group-title">Token Velocity <span style="font-size:10px;color:var(--text-xs);font-weight:400">24h · ${fmt(totalTok)}</span></div>
3217
+ <div id="vel-chart">${bars}</div>
3218
+ <div style="font-size:10px;color:var(--text-xs);margin-top:4px">Total cost <span style="color:oklch(78% 0.18 75)">$${totalCost.toFixed(2)}</span></div>`;
3219
+ }
3220
+
3221
+ // ── feature 27: export sessions CSV ───────────────────────
3222
+ function exportSessionsCSV() {
3223
+ if (!allSessions.length) { showToast('No data', 'No sessions loaded yet', 'warn'); return; }
3224
+ const headers = ['Date', 'Session ID', 'Prompt', 'Cost ($)', 'Duration (s)', 'Tool Calls', 'User Messages', 'Cache Hit %', 'Input Tokens'];
3225
+ const rows = allSessions.map(s => {
3226
+ const dt = new Date(s.firstTs || s.mtime || 0).toISOString().slice(0, 19).replace('T', ' ');
3227
+ const cost = typeof s.totalCost === 'number' ? s.totalCost.toFixed(4) : '';
3228
+ const dur = s.totalDurationMs ? Math.round(s.totalDurationMs / 1000) : '';
3229
+ const cachePct = s.totalInputTokens > 0 ? Math.round((s.cacheReadTokens || 0) / s.totalInputTokens * 100) : '';
3230
+ const prompt = (s.lastPrompt || '').replace(/"/g, '""');
3231
+ return [dt, s.id, prompt, cost, dur, s.toolCalls || '', s.userMessages || '', cachePct, s.totalInputTokens || ''];
3232
+ });
3233
+ const csv = [headers, ...rows].map(r => r.map(c => `"${c}"`).join(',')).join('\n');
3234
+ const blob = new Blob([csv], { type: 'text/csv' });
3235
+ const a = document.createElement('a');
3236
+ a.href = URL.createObjectURL(blob);
3237
+ a.download = `sessions-${new Date().toISOString().slice(0, 10)}.csv`;
3238
+ a.click();
3239
+ URL.revokeObjectURL(a.href);
3240
+ showToast('Exported', `${allSessions.length} sessions saved as CSV`, 'ok');
3241
+ }
3242
+
3243
+ // ── feature 28: tool usage ranking ────────────────────────
3244
+ let toolRankOpen = false;
3245
+ function toggleToolRank() {
3246
+ toolRankOpen = !toolRankOpen;
3247
+ document.getElementById('btn-tool-rank').classList.toggle('on', toolRankOpen);
3248
+ const p = document.getElementById('tool-rank-panel');
3249
+ p.style.display = toolRankOpen ? '' : 'none';
3250
+ if (toolRankOpen) loadToolRank();
3251
+ }
3252
+ async function loadToolRank() {
3253
+ const el = document.getElementById('tool-rank-body');
3254
+ el.innerHTML = '<div class="loading-txt">Loading…</div>';
3255
+ try {
3256
+ const data = await apiFetch('/api/tool-ranking?dir=' + enc(DIR));
3257
+ if (!data.tools?.length) { el.innerHTML = '<div class="loading-txt">No tool usage data</div>'; return; }
3258
+ const maxCount = data.tools[0].count;
3259
+ const rows = data.tools.slice(0, 15).map((t, i) => {
3260
+ const barW = Math.round((t.count / maxCount) * 100);
3261
+ const errRate = t.errors > 0 ? ((t.errors / t.count) * 100).toFixed(0) + '%' : '—';
3262
+ return `<tr><td class="lb-rank">${i + 1}</td>
3263
+ <td style="font-size:12px;color:var(--text-mid);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.tool)}</td>
3264
+ <td style="width:80px;padding:4px 6px">
3265
+ <div style="height:4px;background:var(--surface-hi);border-radius:2px;overflow:hidden">
3266
+ <div style="height:100%;width:${barW}%;background:oklch(65% 0.15 200);border-radius:2px"></div>
3267
+ </div>
3268
+ </td>
3269
+ <td class="lb-cost" style="color:var(--text-mid)">${t.count.toLocaleString()}</td>
3270
+ <td class="lb-dur" style="color:${t.errors > 0 ? 'oklch(70% 0.18 25)' : 'var(--text-xs)'}">${errRate}</td>
3271
+ </tr>`;
3272
+ }).join('');
3273
+ el.innerHTML = `<table class="lb-table"><thead><tr>
3274
+ <th class="lb-rank">#</th><th>Tool</th><th></th><th class="lb-cost">Calls</th><th class="lb-dur">Error%</th>
3275
+ </tr></thead><tbody>${rows}</tbody></table>`;
3276
+ } catch (err) {
3277
+ el.innerHTML = '<div class="loading-txt">Could not load: ' + esc(err.message) + '</div>';
3278
+ }
3279
+ }
3280
+
3281
+ // ── feature 29: cost breakdown by project ─────────────────
3282
+ let projCostsOpen = false;
3283
+ function toggleProjCosts() {
3284
+ projCostsOpen = !projCostsOpen;
3285
+ document.getElementById('btn-proj-costs').classList.toggle('on', projCostsOpen);
3286
+ const p = document.getElementById('proj-costs-panel');
3287
+ p.style.display = projCostsOpen ? '' : 'none';
3288
+ if (projCostsOpen) loadProjCosts();
3289
+ }
3290
+ async function loadProjCosts() {
3291
+ const el = document.getElementById('proj-costs-body');
3292
+ el.innerHTML = '<div class="loading-txt">Loading…</div>';
3293
+ try {
3294
+ const data = await apiFetch('/api/project-costs');
3295
+ if (!data.projects?.length) { el.innerHTML = '<div class="loading-txt">No cost data across projects</div>'; return; }
3296
+ const maxCost = data.projects[0].cost;
3297
+ const rows = data.projects.slice(0, 10).map((p, i) => {
3298
+ const barW = maxCost > 0 ? Math.round((p.cost / maxCost) * 100) : 0;
3299
+ const name = p.path.split('/').filter(Boolean).pop() || p.path;
3300
+ return `<tr onclick="switchProject('${esc(p.path)}')" style="cursor:pointer" title="${esc(p.path)}">
3301
+ <td class="lb-rank">${i + 1}</td>
3302
+ <td style="font-size:12px;color:var(--text-mid);max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(name)}</td>
3303
+ <td class="lb-cost">$${p.cost.toFixed(2)}</td>
3304
+ <td style="width:80px;padding:4px 6px">
3305
+ <div style="height:4px;background:var(--surface-hi);border-radius:2px;overflow:hidden">
3306
+ <div style="height:100%;width:${barW}%;background:oklch(72% 0.18 75 / 0.7);border-radius:2px"></div>
3307
+ </div>
3308
+ </td>
3309
+ <td class="lb-dur">${p.sessions}</td>
3310
+ </tr>`;
3311
+ }).join('');
3312
+ el.innerHTML = `<table class="lb-table"><thead><tr>
3313
+ <th class="lb-rank">#</th><th>Project</th><th class="lb-cost">Cost</th><th></th><th class="lb-dur">Sessions</th>
3314
+ </tr></thead><tbody>${rows}</tbody></table>`;
3315
+ } catch (err) {
3316
+ el.innerHTML = '<div class="loading-txt">Could not load: ' + esc(err.message) + '</div>';
3317
+ }
3318
+ }
3319
+
2787
3320
  // ── helpers ────────────────────────────────────────────────
2788
3321
  function enc(s) { return encodeURIComponent(s); }
2789
3322
  function esc(s) {