@monoes/monomindcli 1.10.39 → 1.10.41

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 (65) hide show
  1. package/dist/src/browser/actions.d.ts.map +1 -1
  2. package/dist/src/browser/actions.js +114 -22
  3. package/dist/src/browser/actions.js.map +1 -1
  4. package/dist/src/browser/batch.d.ts +0 -2
  5. package/dist/src/browser/batch.d.ts.map +1 -1
  6. package/dist/src/browser/batch.js +1 -10
  7. package/dist/src/browser/batch.js.map +1 -1
  8. package/dist/src/browser/browser.d.ts +1 -0
  9. package/dist/src/browser/browser.d.ts.map +1 -1
  10. package/dist/src/browser/browser.js +51 -24
  11. package/dist/src/browser/browser.js.map +1 -1
  12. package/dist/src/browser/cdp.d.ts.map +1 -1
  13. package/dist/src/browser/cdp.js +21 -4
  14. package/dist/src/browser/cdp.js.map +1 -1
  15. package/dist/src/browser/console-log.d.ts +1 -0
  16. package/dist/src/browser/console-log.d.ts.map +1 -1
  17. package/dist/src/browser/console-log.js +19 -3
  18. package/dist/src/browser/console-log.js.map +1 -1
  19. package/dist/src/browser/dialog.d.ts.map +1 -1
  20. package/dist/src/browser/dialog.js +5 -2
  21. package/dist/src/browser/dialog.js.map +1 -1
  22. package/dist/src/browser/find.d.ts.map +1 -1
  23. package/dist/src/browser/find.js +28 -8
  24. package/dist/src/browser/find.js.map +1 -1
  25. package/dist/src/browser/har.d.ts +1 -0
  26. package/dist/src/browser/har.d.ts.map +1 -1
  27. package/dist/src/browser/har.js +7 -5
  28. package/dist/src/browser/har.js.map +1 -1
  29. package/dist/src/browser/network.d.ts +7 -4
  30. package/dist/src/browser/network.d.ts.map +1 -1
  31. package/dist/src/browser/network.js +60 -23
  32. package/dist/src/browser/network.js.map +1 -1
  33. package/dist/src/browser/profiler.d.ts.map +1 -1
  34. package/dist/src/browser/profiler.js +13 -2
  35. package/dist/src/browser/profiler.js.map +1 -1
  36. package/dist/src/browser/screenshot.d.ts.map +1 -1
  37. package/dist/src/browser/screenshot.js +4 -2
  38. package/dist/src/browser/screenshot.js.map +1 -1
  39. package/dist/src/browser/session.d.ts.map +1 -1
  40. package/dist/src/browser/session.js +49 -12
  41. package/dist/src/browser/session.js.map +1 -1
  42. package/dist/src/browser/snapshot.d.ts.map +1 -1
  43. package/dist/src/browser/snapshot.js +26 -14
  44. package/dist/src/browser/snapshot.js.map +1 -1
  45. package/dist/src/browser/storage.d.ts +1 -0
  46. package/dist/src/browser/storage.d.ts.map +1 -1
  47. package/dist/src/browser/storage.js +3 -0
  48. package/dist/src/browser/storage.js.map +1 -1
  49. package/dist/src/browser/tabs.d.ts +3 -3
  50. package/dist/src/browser/tabs.d.ts.map +1 -1
  51. package/dist/src/browser/tabs.js +13 -13
  52. package/dist/src/browser/tabs.js.map +1 -1
  53. package/dist/src/browser/trace.d.ts.map +1 -1
  54. package/dist/src/browser/trace.js +10 -4
  55. package/dist/src/browser/trace.js.map +1 -1
  56. package/dist/src/browser/wait.js +24 -14
  57. package/dist/src/browser/wait.js.map +1 -1
  58. package/dist/src/commands/browse.d.ts.map +1 -1
  59. package/dist/src/commands/browse.js +300 -34
  60. package/dist/src/commands/browse.js.map +1 -1
  61. package/dist/src/ui/.monomind/loops/loop-1779095996252-mdjpp.json +11 -0
  62. package/dist/src/ui/dashboard-v2.html +981 -22
  63. package/dist/src/ui/server.mjs +47 -0
  64. package/dist/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +1 -1
@@ -714,6 +714,124 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
714
714
 
715
715
  /* ── streak badge ─────────────────────────────────────────── */
716
716
  .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; }
717
+
718
+ /* ── f47: 30-day daily cost trend ────────────────────────── */
719
+ #daily-trend-chart { display:flex; align-items:flex-end; gap:2px; height:40px; margin-top:6px; }
720
+ .dt-bar { flex:1; min-width:4px; border-radius:2px 2px 0 0; background:oklch(72% 0.18 75 / 0.5); cursor:pointer; transition:background 0.15s; }
721
+ .dt-bar:hover,.dt-bar.active { background:oklch(72% 0.18 75); }
722
+ .dt-bar.has-filter { background:oklch(65% 0.15 150 / 0.7); }
723
+
724
+ /* ── f48: live cost ticker ────────────────────────────────── */
725
+ #live-cost-ticker { font-size:11px; font-family:var(--mono); font-weight:600; color:oklch(78% 0.18 75); opacity:0; transition:opacity 0.3s; white-space:nowrap; }
726
+ #live-cost-ticker.show { opacity:1; }
727
+ #live-cost-ticker .lct-change { font-size:10px; color:var(--green); margin-left:3px; }
728
+
729
+ /* ── f49: hourly productivity heatmap ────────────────────── */
730
+ #hourly-heatmap-grid { display:grid; grid-template-columns:20px repeat(24,1fr); grid-template-rows:auto; gap:2px; margin-top:6px; font-size:9px; }
731
+ .hh-hour-lbl { color:var(--text-xs); text-align:center; padding-bottom:1px; }
732
+ .hh-day-lbl { color:var(--text-xs); line-height:12px; }
733
+ .hh-cell { height:10px; border-radius:2px; background:var(--surface-hi); cursor:default; }
734
+ .hh-cell.hh-1 { background:oklch(72% 0.18 75 / 0.25); }
735
+ .hh-cell.hh-2 { background:oklch(72% 0.18 75 / 0.5); }
736
+ .hh-cell.hh-3 { background:oklch(72% 0.18 75 / 0.75); }
737
+ .hh-cell.hh-4 { background:oklch(72% 0.18 75); }
738
+
739
+ /* ── f50: custom tag editor ──────────────────────────────── */
740
+ .sr-custom-tags { display:flex; flex-wrap:wrap; gap:4px; align-items:center; margin-top:4px; }
741
+ .sr-ctag { display:inline-flex; align-items:center; gap:3px; font-size:10px; padding:1px 6px; border-radius:8px; background:oklch(65% 0.15 200 / 0.15); color:oklch(72% 0.15 200); }
742
+ .sr-ctag .ctag-del { cursor:pointer; opacity:0.6; font-size:9px; line-height:1; }
743
+ .sr-ctag .ctag-del:hover { opacity:1; }
744
+ .ctag-add-btn { font-size:10px; color:var(--text-xs); background:none; border:1px dashed var(--border); border-radius:8px; padding:1px 6px; cursor:pointer; }
745
+ .ctag-add-btn:hover { color:var(--text-lo); border-color:var(--text-xs); }
746
+ .ctag-input-wrap { display:flex; gap:4px; margin-top:4px; }
747
+ .ctag-input { font-size:11px; background:var(--surface-hi); border:1px solid var(--border); border-radius:var(--r); padding:2px 6px; color:var(--text-hi); outline:none; width:100px; }
748
+ .ctag-input:focus { border-color:oklch(72% 0.18 75 / 0.5); }
749
+ .ctag-ok { font-size:10px; background:none; border:1px solid var(--border); border-radius:var(--r); padding:2px 6px; cursor:pointer; color:var(--text-lo); }
750
+ .ctag-ok:hover { color:var(--text-hi); }
751
+ .sr-autotag.ctag { background:oklch(65% 0.15 200 / 0.12); color:oklch(72% 0.15 200); }
752
+
753
+ /* ── f51: tool error drawer ──────────────────────────────── */
754
+ .sess-anomaly.anom-err.clickable { cursor:pointer; }
755
+ .sess-anomaly.anom-err.clickable:hover { opacity:0.8; }
756
+ .err-drawer { display:none; margin-top:6px; border:1px solid oklch(60% 0.18 25 / 0.3); border-radius:var(--r); overflow:hidden; }
757
+ .err-drawer.open { display:block; }
758
+ .err-drawer-head { display:flex; align-items:center; gap:8px; padding:6px 10px; background:oklch(60% 0.18 25 / 0.08); font-size:11px; font-weight:600; color:oklch(70% 0.18 25); }
759
+ .err-drawer-head .err-close { margin-left:auto; cursor:pointer; font-size:12px; background:none; border:none; color:var(--text-xs); }
760
+ .err-drawer-body { max-height:160px; overflow-y:auto; padding:8px 10px; }
761
+ .err-item { font-size:11px; font-family:var(--mono); color:oklch(80% 0.1 25); border-bottom:1px solid var(--border); padding:4px 0; white-space:pre-wrap; word-break:break-word; }
762
+ .err-item:last-child { border:none; }
763
+
764
+ /* ── f52: prompt copy button ─────────────────────────────── */
765
+ .sr-copy-btn { font-size:10px; background:none; border:none; cursor:pointer; color:var(--text-xs); padding:0 4px; opacity:0; transition:opacity 0.15s; line-height:1; }
766
+ .sess-row:hover .sr-copy-btn { opacity:1; }
767
+ .sr-copy-btn:hover { color:var(--text-hi); }
768
+ .sr-copy-btn.copied { color:var(--green); opacity:1; }
769
+
770
+ /* ── f53: session cost histogram ─────────────────────────── */
771
+ #cost-histogram-panel { display:none; margin-bottom:16px; padding:12px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); }
772
+ .ch-title { font-size:11px; font-weight:600; color:var(--text-lo); letter-spacing:0.06em; text-transform:uppercase; margin-bottom:8px; }
773
+ .ch-bars { display:flex; align-items:flex-end; gap:4px; height:50px; }
774
+ .ch-bar-wrap { flex:1; display:flex; flex-direction:column; align-items:center; gap:2px; }
775
+ .ch-bar { width:100%; border-radius:2px 2px 0 0; background:oklch(72% 0.18 75 / 0.4); min-height:2px; }
776
+ .ch-lbl { font-size:8px; color:var(--text-xs); white-space:nowrap; }
777
+ .ch-cnt { font-size:9px; color:var(--text-lo); }
778
+
779
+ /* ── f54: URL param indicator ────────────────────────────── */
780
+ .url-param-active { background:oklch(72% 0.18 75 / 0.08); }
781
+
782
+ /* ── f59: keyboard focus in sessions ────────────────────── */
783
+ .sess-row.kb-focus { outline:1px solid oklch(72% 0.18 75 / 0.4); outline-offset:-1px; }
784
+
785
+ /* ── f55: session timeline ───────────────────────────────── */
786
+ #timeline-panel { display:none; margin-bottom:16px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); overflow:hidden; }
787
+ #timeline-panel.open { display:block; }
788
+ #timeline-head { padding:8px 12px; font-size:11px; font-weight:600; color:var(--text-lo); letter-spacing:0.06em; text-transform:uppercase; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:8px; }
789
+ #timeline-scroll { overflow-x:auto; padding:10px 12px; }
790
+ .tl-day-row { display:flex; align-items:center; gap:6px; margin-bottom:4px; min-height:18px; }
791
+ .tl-day-lbl { font-size:10px; color:var(--text-xs); width:60px; flex-shrink:0; font-family:var(--mono); }
792
+ .tl-track { flex:1; position:relative; height:14px; min-width:400px; }
793
+ .tl-bar { position:absolute; height:10px; top:2px; border-radius:3px; cursor:pointer; opacity:0.7; transition:opacity 0.15s; }
794
+ .tl-bar:hover { opacity:1; z-index:2; }
795
+
796
+ /* ── f56: daily report card ──────────────────────────────── */
797
+ #report-modal { display:none; position:fixed; inset:0; z-index:200; background:rgba(0,0,0,0.6); backdrop-filter:blur(4px); align-items:center; justify-content:center; }
798
+ #report-modal.open { display:flex; }
799
+ #report-box { background:var(--surface); border:1px solid var(--border); border-radius:10px; width:520px; max-width:90vw; max-height:80vh; display:flex; flex-direction:column; }
800
+ .rp-head { display:flex; align-items:center; gap:8px; padding:14px 16px; border-bottom:1px solid var(--border); }
801
+ .rp-title { font-size:14px; font-weight:600; color:var(--text-hi); flex:1; }
802
+ .rp-copy-btn { font-size:11px; background:none; border:1px solid var(--border); border-radius:var(--r); padding:4px 10px; cursor:pointer; color:var(--text-lo); transition:color 0.1s; }
803
+ .rp-copy-btn:hover { color:var(--text-hi); }
804
+ .rp-close-btn { background:none; border:none; cursor:pointer; color:var(--text-xs); font-size:16px; padding:0 2px; }
805
+ #report-content { flex:1; overflow-y:auto; padding:16px; }
806
+ #report-content pre { font-family:var(--mono); font-size:11px; color:var(--text-mid); white-space:pre-wrap; line-height:1.6; }
807
+
808
+ /* ── f57: file-pivot filter ──────────────────────────────── */
809
+ .sr-file-chip { cursor:pointer; }
810
+ .sr-file-chip:hover { background:oklch(72% 0.18 75 / 0.25); color:oklch(85% 0.18 75); }
811
+ .sr-file-chip.pivot-active { background:oklch(72% 0.18 75 / 0.3); color:oklch(90% 0.18 75); border-color:oklch(72% 0.18 75 / 0.5); }
812
+ #file-pivot-bar { display:none; align-items:center; gap:8px; padding:6px 12px; background:oklch(72% 0.18 75 / 0.08); border:1px solid oklch(72% 0.18 75 / 0.2); border-radius:var(--r); margin-bottom:10px; font-size:11px; }
813
+ #file-pivot-bar.show { display:flex; }
814
+ .fpb-label { color:var(--accent); font-weight:600; }
815
+ .fpb-clear { background:none; border:none; cursor:pointer; color:var(--text-lo); font-size:12px; margin-left:auto; }
816
+
817
+ /* ── f58: model cost donut ───────────────────────────────── */
818
+ #model-donut-panel { display:none; margin-bottom:16px; }
819
+ #model-donut-panel.open { display:block; }
820
+ .donut-wrap { display:flex; align-items:center; gap:16px; padding:10px 0; }
821
+ .donut-svg { flex-shrink:0; }
822
+ .donut-legend { display:flex; flex-direction:column; gap:4px; }
823
+ .donut-item { display:flex; align-items:center; gap:6px; font-size:11px; }
824
+ .donut-swatch { width:8px; height:8px; border-radius:2px; flex-shrink:0; }
825
+ .donut-name { color:var(--text-mid); min-width:80px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
826
+ .donut-cost { color:var(--accent); font-family:var(--mono); }
827
+
828
+ /* ── f62: context window pressure gauge ─────────────────── */
829
+ .ctx-pressure-wrap { margin-top:3px; display:flex; align-items:center; gap:5px; }
830
+ .ctx-pressure-bar { flex:1; height:3px; background:var(--surface-hi); border-radius:2px; overflow:hidden; }
831
+ .ctx-pressure-fill { height:100%; border-radius:2px; background:oklch(65% 0.15 150); }
832
+ .ctx-pressure-fill.warn { background:oklch(70% 0.18 80); }
833
+ .ctx-pressure-fill.crit { background:oklch(65% 0.2 25); }
834
+ .ctx-pressure-lbl { font-size:9px; color:var(--text-xs); white-space:nowrap; }
717
835
  </style>
718
836
  </head>
719
837
  <body>
@@ -771,6 +889,7 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
771
889
  <span id="view-title">Now</span>
772
890
  <span class="pill"><span class="live-dot"></span> live</span>
773
891
  <span id="topbar-cost"></span>
892
+ <span id="live-cost-ticker" title="Current session accumulated cost"></span>
774
893
  <span id="topbar-activity"></span>
775
894
  <div id="tb-right">
776
895
  <button class="btn" id="btn-budget" onclick="openBudgetModal()" title="Set daily/monthly cost budget">⚑ Budget</button>
@@ -915,6 +1034,14 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
915
1034
  <div class="m-group-title">Token Velocity</div>
916
1035
  <div class="loading-txt">—</div>
917
1036
  </div>
1037
+ <div id="m-daily-trend">
1038
+ <div class="m-group-title">30-Day Cost Trend</div>
1039
+ <div class="loading-txt">—</div>
1040
+ </div>
1041
+ <div id="m-hourly-heatmap">
1042
+ <div class="m-group-title">Peak Work Hours</div>
1043
+ <div class="loading-txt">—</div>
1044
+ </div>
918
1045
  </div>
919
1046
  </div>
920
1047
 
@@ -944,6 +1071,9 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
944
1071
  <button class="lb-toggle" id="btn-export-csv" onclick="exportSessionsCSV()" title="Export sessions as CSV">⬇ CSV</button>
945
1072
  <button class="lb-toggle" id="btn-patterns" onclick="togglePatterns()" title="Prompt word frequency across sessions">⊞ Patterns</button>
946
1073
  <button class="diff-toggle" id="btn-diff" onclick="toggleDiffMode()" title="Compare two sessions">⇄ Compare</button>
1074
+ <button class="lb-toggle" id="btn-timeline" onclick="toggleTimeline()" title="Session timeline (Gantt)">⊟ Timeline</button>
1075
+ <button class="lb-toggle" id="btn-donut" onclick="toggleModelDonut()" title="Model cost donut">◕ Donut</button>
1076
+ <button class="lb-toggle" id="btn-report" onclick="showReportCard()" title="Generate daily/weekly report card">✦ Report</button>
947
1077
  </div>
948
1078
  <div class="pg-sub" id="sess-pg-sub">Recent Claude Code sessions for this project</div>
949
1079
  <div id="diff-panel">
@@ -956,6 +1086,10 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
956
1086
  <th class="lb-rank">#</th><th>Session</th><th class="lb-cost">Cost</th><th class="lb-dur">Duration</th>
957
1087
  </tr></thead><tbody id="lb-body"></tbody></table>
958
1088
  </div>
1089
+ <div id="cost-histogram-panel"></div>
1090
+ <div id="timeline-panel"><div id="timeline-head">Session Timeline <span style="font-weight:400;font-size:10px;color:var(--text-xs)">— each bar = one session, width = duration, color = cost</span></div><div id="timeline-scroll"></div></div>
1091
+ <div id="model-donut-panel"></div>
1092
+ <div id="file-pivot-bar"><span class="fpb-label" id="fpb-label"></span><button class="fpb-clear" onclick="clearFilePivot()">✕ Clear filter</button></div>
959
1093
  <div id="model-mix-panel" style="display:none;margin-bottom:16px">
960
1094
  <div id="model-mix-body"></div>
961
1095
  </div>
@@ -1070,10 +1204,25 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
1070
1204
  </div><!-- /app -->
1071
1205
  <div id="toast-rack"></div>
1072
1206
 
1207
+ <!-- f56: report card modal -->
1208
+ <div id="report-modal" onclick="if(event.target===this)closeReportCard()">
1209
+ <div id="report-box">
1210
+ <div class="rp-head">
1211
+ <span class="rp-title">Report Card</span>
1212
+ <button class="rp-copy-btn" onclick="copyReportCard()">⎘ Copy</button>
1213
+ <button class="rp-close-btn" onclick="closeReportCard()">✕</button>
1214
+ </div>
1215
+ <div id="report-content"><pre id="report-pre"></pre></div>
1216
+ </div>
1217
+ </div>
1218
+
1073
1219
  <!-- shortcut help modal -->
1074
1220
  <div id="shortcut-modal" onclick="if(event.target===this)closeShortcutHelp()">
1075
1221
  <div id="shortcut-box">
1076
1222
  <div class="sk-title">Keyboard Shortcuts <button class="sk-close" onclick="closeShortcutHelp()">✕</button></div>
1223
+ <div class="sk-section">Sessions view</div>
1224
+ <div class="sk-row"><span class="sk-desc">Navigate rows</span><span class="sk-keys"><kbd>J</kbd><kbd>K</kbd></span></div>
1225
+ <div class="sk-row"><span class="sk-desc">Open focused session</span><span class="sk-keys"><kbd>↵</kbd></span></div>
1077
1226
  <div class="sk-section">Feed (Now view)</div>
1078
1227
  <div class="sk-row"><span class="sk-desc">Navigate entries</span><span class="sk-keys"><kbd>J</kbd><kbd>K</kbd></span></div>
1079
1228
  <div class="sk-row"><span class="sk-desc">Open detail drawer</span><span class="sk-keys"><kbd>↵</kbd></span></div>
@@ -1186,11 +1335,12 @@ async function init() {
1186
1335
  document.getElementById('sb-proj').textContent = DIR.split('/').filter(Boolean).pop() || '—';
1187
1336
  document.getElementById('sb-path').textContent = DIR;
1188
1337
  }
1189
- viewRendered['now'] = true; // prevents switchView from re-rendering NOW on jumpToSession
1338
+ restoreURLParams();
1339
+ viewRendered['now'] = true;
1190
1340
  updateBudgetBtnStyle();
1191
1341
  await refreshNow();
1192
1342
  if (sessParam) setTimeout(() => jumpToSession(sessParam), 300);
1193
- startPolling();
1343
+ initSSE();
1194
1344
  }
1195
1345
 
1196
1346
  function startPolling() {
@@ -1198,6 +1348,20 @@ function startPolling() {
1198
1348
  pollTimer = setInterval(() => { if (currentView === 'now') refreshNowSilent(); }, 30000);
1199
1349
  }
1200
1350
 
1351
+ let _sseSource = null;
1352
+ function initSSE() {
1353
+ if (_sseSource) { try { _sseSource.close(); } catch {} _sseSource = null; }
1354
+ if (!DIR || !window.EventSource) { startPolling(); return; }
1355
+ try {
1356
+ const src = new EventSource('/api/events-stream?dir=' + enc(DIR));
1357
+ src.addEventListener('update', () => { if (currentView === 'now') refreshNowSilent(); });
1358
+ src.addEventListener('connected', () => {});
1359
+ src.onerror = () => { src.close(); _sseSource = null; startPolling(); };
1360
+ _sseSource = src;
1361
+ clearInterval(pollTimer); // SSE replaces polling
1362
+ } catch { startPolling(); }
1363
+ }
1364
+
1201
1365
  async function apiFetch(url) {
1202
1366
  const r = await fetch(url);
1203
1367
  if (!r.ok) throw new Error(r.status);
@@ -1239,7 +1403,7 @@ function toggleLiveTail() {
1239
1403
  if (sessionIdx !== 0 && allSessions.length) { sessionIdx = 0; userScrolled = false; loadFeedForSession(allSessions[0]); }
1240
1404
  liveTailTimer = setInterval(() => { if (currentView === 'now') refreshNowSilent(); }, 5000);
1241
1405
  } else {
1242
- startPolling();
1406
+ initSSE();
1243
1407
  }
1244
1408
  }
1245
1409
 
@@ -1293,6 +1457,9 @@ async function loadFeedForSession(sess) {
1293
1457
  document.getElementById('btn-prev-sess').style.opacity = sessionIdx < allSessions.length - 1 ? '1' : '0.3';
1294
1458
  document.getElementById('btn-next-sess').style.opacity = sessionIdx > 0 ? '1' : '0.3';
1295
1459
  showSessCtx(sess);
1460
+ // f48: live cost ticker
1461
+ const sessCost = typeof sess.totalCost === 'number' ? sess.totalCost : (typeof sess.cost === 'number' ? sess.cost : null);
1462
+ updateLiveTicker(sessCost);
1296
1463
  if (!sess.file) { setFeedContent('<div class="feed-empty">Session file path unavailable.</div>'); return; }
1297
1464
  try {
1298
1465
  const data = await apiFetch('/api/session?dir=' + enc(DIR) + '&file=' + enc(sess.file) + '&limit=120');
@@ -1778,25 +1945,49 @@ async function renderSessions() {
1778
1945
  }
1779
1946
  let toShow = showStarredOnly ? sessions.filter(s => bookmarks.has(s.id)) : sessions;
1780
1947
  if (activeTagFilter) toShow = toShow.filter(s => (allTags.sessionTags.get(s.id) || []).includes(activeTagFilter));
1948
+ if (heatmapDateFilter) toShow = toShow.filter(s => {
1949
+ const t = s.lastTs || s.mtime; if (!t) return false;
1950
+ return new Date(typeof t === 'number' ? t : t).toDateString() === heatmapDateFilter;
1951
+ });
1952
+ // f57: file pivot filter
1953
+ if (filePivot) toShow = toShow.filter(s => (s.filesTouched || []).includes(filePivot));
1781
1954
  if (!toShow.length) {
1782
1955
  el.innerHTML = '<div class="empty"><div class="empty-ico">☆</div><div>No bookmarked sessions</div></div>';
1956
+ buildSessionHeatmap(sessions);
1783
1957
  return;
1784
1958
  }
1785
1959
  // compute median cost for anomaly detection
1786
1960
  const costsForMedian = sessions.map(s => s.totalCost || 0).filter(c => c > 0).sort((a, b) => a - b);
1787
1961
  const medianCost = costsForMedian.length ? costsForMedian[Math.floor(costsForMedian.length / 2)] : 0;
1788
- const sessData = JSON.stringify(toShow).replace(/'/g, '&#39;');
1789
- el.innerHTML = toShow.map((s, idx) => {
1962
+
1963
+ // group by date
1964
+ const now = Date.now(); const DAY = 86400000;
1965
+ function sessDateGroup(s) {
1966
+ const t = s.lastTs || s.mtime; if (!t) return 'Older';
1967
+ const age = now - (typeof t === 'number' ? t : new Date(t).getTime());
1968
+ if (age < DAY) return 'Today';
1969
+ if (age < 2 * DAY) return 'Yesterday';
1970
+ if (age < 8 * DAY) return 'This week';
1971
+ return 'Older';
1972
+ }
1973
+ const GROUP_ORDER = ['Today', 'Yesterday', 'This week', 'Older'];
1974
+ const groups = {};
1975
+ for (const s of toShow) {
1976
+ const g = sessDateGroup(s); if (!groups[g]) groups[g] = [];
1977
+ groups[g].push(s);
1978
+ }
1979
+
1980
+ function renderSessRow(s, idx) {
1790
1981
  const dur = s.totalDurationMs ? fmtDur(s.totalDurationMs) : '';
1791
1982
  const msgs = s.totalMessages ? s.totalMessages + ' msg' : '';
1792
1983
  const cost = typeof s.totalCost === 'number' ? '$' + s.totalCost.toFixed(2)
1793
1984
  : typeof s.cost === 'number' ? '$' + s.cost.toFixed(2) : '';
1794
1985
  const meta = [dur, msgs, cost].filter(Boolean).join(' · ') || s.id.slice(0, 16);
1795
- // anomaly badge
1796
1986
  const sCost = s.totalCost || 0;
1797
1987
  let anomBadge = '';
1798
1988
  if (medianCost > 0.05 && sCost > medianCost * 3 && sCost > 0.5) {
1799
- anomBadge = `<span class="sess-anomaly anom-cost" title="Cost ${((sCost/medianCost).toFixed(1))}× above median">! costly</span>`;
1989
+ // f60: anomaly badge is clickable for explainer
1990
+ anomBadge = `<span class="sess-anomaly anom-cost clickable" style="cursor:pointer" onclick="showCostExplainer('${esc(s.id)}',event)" title="Cost ${((sCost/medianCost).toFixed(1))}× above median — click for details">! costly</span>`;
1800
1991
  } else if (s.toolCalls > 0 && (s.errorCount || 0) / s.toolCalls > 0.3) {
1801
1992
  anomBadge = `<span class="sess-anomaly anom-err" title="${s.errorCount} tool errors">! errors</span>`;
1802
1993
  }
@@ -1811,16 +2002,39 @@ async function renderSessions() {
1811
2002
  const sData = JSON.stringify(s).replace(/'/g, '&#39;');
1812
2003
  const note = getSessNote(s.id);
1813
2004
  const hasNote = !!note;
1814
- return `<div class="sess-row" data-sess-idx="${idx}" onclick="diffMode ? diffSelectSession(JSON.parse(this.dataset.sessData || '{}'), this) : jumpToSession('${esc(s.id)}')" data-sess-data='${sData}'>
2005
+ const files = (s.filesTouched || []).slice(0, 5);
2006
+ // f57: file chips are clickable for pivot filter — use data-attr to avoid JSON.stringify in onclick
2007
+ const filesHtml = files.length ? `<div class="sr-files">${files.map(f => `<span class="sr-file-chip${filePivot===f?' pivot-active':''}" data-fname="${esc(f)}" onclick="setFilePivot(this.dataset.fname,event)" title="Filter by ${esc(f)}">${esc(f)}</span>`).join('')}${(s.filesTouched||[]).length > 5 ? `<span class="sr-file-chip">+${(s.filesTouched||[]).length-5}</span>` : ''}</div>` : '';
2008
+ // f62: context window pressure gauge
2009
+ const CTX_LIMIT = 200000;
2010
+ const tokPct = s.totalInputTokens ? Math.min(100, Math.round(s.totalInputTokens / CTX_LIMIT * 100)) : 0;
2011
+ const tokCls = tokPct > 80 ? 'crit' : tokPct > 50 ? 'warn' : '';
2012
+ const ctxGauge = tokPct > 5 ? `<div class="ctx-pressure-wrap" title="${(s.totalInputTokens||0).toLocaleString()} input tokens — ${tokPct}% of 200k context">
2013
+ <div class="ctx-pressure-bar"><div class="ctx-pressure-fill ${tokCls}" style="width:${tokPct}%"></div></div>
2014
+ <span class="ctx-pressure-lbl">${tokPct}% ctx</span>
2015
+ </div>` : '';
2016
+ // f51: error badge — clickable if errors exist
2017
+ const errBadge = (s.errorCount > 0 && s.toolCalls > 0 && (s.errorCount / s.toolCalls) > 0.3)
2018
+ ? `<span class="sess-anomaly anom-err clickable" onclick="toggleErrDrawer('${esc(s.id)}',event)" title="Click to see ${s.errorCount} tool errors">${s.errorCount} err</span>`
2019
+ : anomBadge;
2020
+ // f50: custom tags
2021
+ const ctags = getCustomTags(s.id);
2022
+ const ctagsHtml = renderCustomTagsInline(s.id, ctags);
2023
+ return `<div class="sess-row" data-sess-idx="${idx}" data-sess-id="${esc(s.id)}" onclick="handleSessRowClick(event,this,'${esc(s.id)}')" data-sess-data='${sData}'>
1815
2024
  <div class="sr-top">
1816
2025
  <div class="sr-prompt">${esc(s.lastPrompt || s.id)}</div>
1817
2026
  <div class="sr-time">${relTime(s.lastTs || s.mtime)}</div>
2027
+ <button class="sr-copy-btn" data-prompt="${esc(s.lastPrompt || s.id)}" onclick="copyPrompt(this.dataset.prompt,event)" title="Copy prompt to clipboard">⎘</button>
1818
2028
  <button class="sess-star${isStarred ? ' on' : ''}" data-sid="${esc(s.id)}" onclick="toggleBookmark('${esc(s.id)}',event)" title="${isStarred ? 'Remove bookmark' : 'Bookmark session'}">${isStarred ? '★' : '☆'}</button>
1819
2029
  <span class="sr-view">→ view</span>
1820
2030
  </div>
1821
- <div class="sr-meta">${esc(meta)}${anomBadge}</div>
2031
+ <div class="sr-meta">${esc(meta)}${errBadge}</div>
1822
2032
  ${(summaries || autoTags) ? `<div class="sr-tags">${summaries}${autoTags}</div>` : ''}
2033
+ ${ctagsHtml}
2034
+ ${filesHtml}
1823
2035
  ${satBar}
2036
+ ${ctxGauge}
2037
+ <div class="err-drawer" id="err-drawer-${esc(s.id)}"></div>
1824
2038
  <div class="sess-notes-wrap" onclick="event.stopPropagation()">
1825
2039
  <button class="sess-notes-toggle${hasNote ? ' has-note' : ''}" onclick="toggleSessNote('${esc(s.id)}',this)">✎ ${hasNote ? 'Note' : 'Add note'}</button>
1826
2040
  <div class="sess-notes-area" id="snote-${esc(s.id)}">
@@ -1829,17 +2043,36 @@ async function renderSessions() {
1829
2043
  </div>
1830
2044
  </div>
1831
2045
  </div>`;
1832
- }).join('');
2046
+ }
2047
+
2048
+ let html = '';
2049
+ let flatIdx = 0;
2050
+ for (const grp of GROUP_ORDER) {
2051
+ const items = groups[grp];
2052
+ if (!items || !items.length) continue;
2053
+ const gid = 'sg-' + grp.replace(/\s+/g, '-').toLowerCase();
2054
+ html += `<div class="sg-section"><div class="sg-header" onclick="toggleSessGroup('${gid}')">
2055
+ <span class="sg-title">${grp}</span><span class="sg-count">${items.length}</span><span class="sg-toggle">▾</span>
2056
+ </div><div class="sg-body" id="${gid}">`;
2057
+ for (const s of items) { html += renderSessRow(s, flatIdx++); }
2058
+ html += '</div></div>';
2059
+ }
2060
+ el.innerHTML = html;
1833
2061
  // prepend tag filter bar if there are common tags
1834
2062
  if (allTags.common.size > 1) {
1835
2063
  el.innerHTML = buildTagFilterBar(toShow) + el.innerHTML;
1836
2064
  }
2065
+ buildSessionHeatmap(sessions);
1837
2066
  buildDigest();
1838
2067
  buildWeeklyRecap();
1839
2068
  buildEfficiencyPanel();
1840
2069
  buildActivityHeatmap();
1841
2070
  buildTokenVelocity();
2071
+ buildDailyCostTrend();
2072
+ buildHourlyHeatmap();
2073
+ buildCostHistogram();
1842
2074
  if (leaderboardOpen) renderLeaderboard();
2075
+ syncURLParams();
1843
2076
  // check budget thresholds and fire toasts
1844
2077
  const todayCost = allSessions.filter(s => {
1845
2078
  const t = s.firstTs || s.mtime;
@@ -1888,10 +2121,10 @@ function toggleSessStarFilter() {
1888
2121
  showStarredOnly = !showStarredOnly;
1889
2122
  const btn = document.getElementById('sess-star-filter');
1890
2123
  btn.classList.toggle('on', showStarredOnly);
1891
- // re-render with filter
1892
2124
  viewRendered['sessions'] = false;
1893
2125
  renderSessions();
1894
2126
  viewRendered['sessions'] = true;
2127
+ syncURLParams();
1895
2128
  }
1896
2129
 
1897
2130
  // ── feature 1: auto-tags ───────────────────────────────────
@@ -1939,6 +2172,7 @@ function setTagFilter(tag) {
1939
2172
  viewRendered['sessions'] = false;
1940
2173
  renderSessions();
1941
2174
  viewRendered['sessions'] = true;
2175
+ syncURLParams();
1942
2176
  }
1943
2177
 
1944
2178
  // ── feature 2: session recap ───────────────────────────────
@@ -2680,6 +2914,42 @@ function toggleLoop(row) {
2680
2914
  row.classList.toggle('open');
2681
2915
  }
2682
2916
 
2917
+ function showLoopForm() {
2918
+ document.getElementById('loop-create-form').style.display = 'block';
2919
+ document.getElementById('btn-new-loop').style.display = 'none';
2920
+ document.getElementById('lcf-prompt').focus();
2921
+ }
2922
+
2923
+ function hideLoopForm() {
2924
+ document.getElementById('loop-create-form').style.display = 'none';
2925
+ document.getElementById('btn-new-loop').style.display = '';
2926
+ }
2927
+
2928
+ async function createLoop() {
2929
+ const prompt = document.getElementById('lcf-prompt').value.trim();
2930
+ if (!prompt) { showToast('Required', 'Prompt is required', 'warn'); return; }
2931
+ const name = document.getElementById('lcf-name').value.trim();
2932
+ const interval = document.getElementById('lcf-interval').value.trim() || '1h';
2933
+ const maxRepsVal = document.getElementById('lcf-maxreps').value;
2934
+ const maxReps = maxRepsVal ? parseInt(maxRepsVal) : null;
2935
+ try {
2936
+ const r = await fetch('/api/loops/create?dir=' + enc(DIR), {
2937
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2938
+ body: JSON.stringify({ name, prompt, interval, maxReps }),
2939
+ });
2940
+ const d = await r.json();
2941
+ if (!d.ok) { showToast('Error', d.error || 'Failed to create loop', 'err'); return; }
2942
+ showToast('Created', `Loop created: ${name || prompt.slice(0, 30)}`, 'ok');
2943
+ hideLoopForm();
2944
+ document.getElementById('lcf-prompt').value = '';
2945
+ document.getElementById('lcf-name').value = '';
2946
+ document.getElementById('lcf-interval').value = '1h';
2947
+ document.getElementById('lcf-maxreps').value = '';
2948
+ viewRendered['loops'] = false;
2949
+ renderLoops();
2950
+ } catch (err) { showToast('Error', err.message, 'err'); }
2951
+ }
2952
+
2683
2953
  // ── feature 19: cache efficiency panel ────────────────────
2684
2954
  function buildEfficiencyPanel() {
2685
2955
  const el = document.getElementById('m-efficiency');
@@ -2887,12 +3157,21 @@ async function renderMemory() {
2887
3157
  ).join('');
2888
3158
  }
2889
3159
 
2890
- const items = allDrawers.map((d, i) =>
2891
- `<div class="drawer-item" data-idx="${i}" data-ns="${esc(d.namespace || 'default')}">
3160
+ const tsValues = allDrawers.map(d => d.timestamp ? new Date(d.timestamp).getTime() : 0).filter(Boolean);
3161
+ const oldestTs = tsValues.length ? Math.min(...tsValues) : 0;
3162
+ const newestTs = tsValues.length ? Math.max(...tsValues) : 0;
3163
+ const tsRange = newestTs - oldestTs || 1;
3164
+ const items = allDrawers.map((d, i) => {
3165
+ const ts = d.timestamp ? new Date(d.timestamp).getTime() : 0;
3166
+ const agePct = ts && oldestTs ? Math.round(((ts - oldestTs) / tsRange) * 100) : 0;
3167
+ const ageBar = ts ? `<div class="dr-age-bar"><div class="dr-age-bar-fill" style="width:${agePct}%"></div></div>` : '';
3168
+ return `<div class="drawer-item" data-idx="${i}" data-ns="${esc(d.namespace || 'default')}">
2892
3169
  <div class="dr-key">${esc(d.key || d.namespace || '—')}</div>
2893
3170
  <div class="dr-val">${esc(String(d.value || d.text || '').slice(0, 300))}</div>
2894
3171
  ${d.timestamp ? `<div class="dr-ts">${relTime(d.timestamp)}</div>` : ''}
2895
- </div>`).join('');
3172
+ ${ageBar}
3173
+ </div>`;
3174
+ }).join('');
2896
3175
  html += `<div class="mem-section" id="drawers-section">
2897
3176
  <div class="mem-title">Drawers (${allDrawers.length})</div>
2898
3177
  <div id="drawers-list">${items}</div>
@@ -3309,18 +3588,48 @@ document.addEventListener('keydown', e => {
3309
3588
  if (sel) openDetail(sel.dataset.ev);
3310
3589
  }
3311
3590
  }
3591
+
3592
+ // ── f59: J/K navigation in sessions list ─────────────────
3593
+ if (currentView === 'sessions') {
3594
+ if (e.key === 'j' || e.key === 'k') {
3595
+ e.preventDefault();
3596
+ const rows = [...document.querySelectorAll('#sess-content .sess-row:not([style*="display: none"])')];
3597
+ if (!rows.length) return;
3598
+ let cur = rows.findIndex(r => r.classList.contains('kb-focus'));
3599
+ if (e.key === 'j') cur = cur < 0 ? 0 : Math.min(cur + 1, rows.length - 1);
3600
+ else cur = cur < 0 ? 0 : Math.max(cur - 1, 0);
3601
+ rows.forEach((r, i) => r.classList.toggle('kb-focus', i === cur));
3602
+ rows[cur].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
3603
+ }
3604
+ if (e.key === 'Enter') {
3605
+ const focused = document.querySelector('#sess-content .sess-row.kb-focus');
3606
+ if (focused) {
3607
+ const sid = focused.dataset.sessId;
3608
+ if (sid) jumpToSession(sid);
3609
+ }
3610
+ }
3611
+ }
3312
3612
  });
3313
3613
 
3314
3614
  // ── feature 31: inline session filter ─────────────────────
3615
+ // ── feature 61: extended session search ───────────────────
3315
3616
  function filterSessions(q) {
3316
3617
  const rows = document.querySelectorAll('#sess-content .sess-row');
3317
3618
  const lq = q.toLowerCase().trim();
3318
3619
  let visible = 0;
3319
3620
  rows.forEach(row => {
3621
+ if (!lq) { row.style.display = ''; visible++; return; }
3622
+ const sessId = row.dataset.sessId || '';
3623
+ // search prompt, meta, tags (rendered text)
3320
3624
  const prompt = (row.querySelector('.sr-prompt')?.textContent || '').toLowerCase();
3321
- const meta = (row.querySelector('.sr-meta')?.textContent || '').toLowerCase();
3322
- const tags = (row.querySelector('.sr-tags')?.textContent || '').toLowerCase();
3323
- const match = !lq || prompt.includes(lq) || meta.includes(lq) || tags.includes(lq);
3625
+ const meta = (row.querySelector('.sr-meta')?.textContent || '').toLowerCase();
3626
+ const tags = (row.querySelector('.sr-tags')?.textContent || '').toLowerCase();
3627
+ const files = (row.querySelector('.sr-files')?.textContent || '').toLowerCase();
3628
+ const ctags = (row.querySelector('.sr-custom-tags')?.textContent || '').toLowerCase();
3629
+ // search summaries from session data
3630
+ const sess = allSessions.find(s => s.id === sessId);
3631
+ const summText = sess ? (sess.summaries || []).map(sm => typeof sm === 'string' ? sm : (sm.summary || sm.text || '')).join(' ').toLowerCase() : '';
3632
+ const match = prompt.includes(lq) || meta.includes(lq) || tags.includes(lq) || files.includes(lq) || ctags.includes(lq) || summText.includes(lq);
3324
3633
  row.style.display = match ? '' : 'none';
3325
3634
  if (match) visible++;
3326
3635
  });
@@ -3357,7 +3666,6 @@ function updateCurrentActivity(events) {
3357
3666
  }
3358
3667
 
3359
3668
  // ── feature 34: prompt pattern analysis ───────────────────
3360
- const STOP_WORDS = new Set(['the','a','an','and','or','but','in','on','at','to','for','of','with','by','from','is','it','this','that','be','as','i','my','me','we','you','your','can','do','did','have','had','has','was','were','are','will','would','should','could','not','no','if','so','then','what','how','when','where','which','who','all','get','make','add','new','old','use','its','into','out','up','any','just','let','set','also','more','using','used','fix','run','file','please']);
3361
3669
  let patternsOpen = false;
3362
3670
  function togglePatterns() {
3363
3671
  patternsOpen = !patternsOpen;
@@ -3454,14 +3762,169 @@ function checkBudgetToast(todayCost, monthCost) {
3454
3762
  }
3455
3763
  }
3456
3764
 
3765
+ // ── feature 39: cost period toggle ────────────────────────
3766
+ let activePeriod = 'day';
3767
+ let heatmapDateFilter = null;
3768
+
3769
+ function setPeriod(p) {
3770
+ activePeriod = p;
3771
+ document.querySelectorAll('.period-btn').forEach(b => b.classList.toggle('active', b.dataset.period === p));
3772
+ buildTokenVelocity();
3773
+ syncURLParams();
3774
+ }
3775
+
3776
+ function periodFilteredSessions() {
3777
+ const now = Date.now();
3778
+ const DAY = 86400000;
3779
+ const windows = { day: DAY, week: 7 * DAY, month: 30 * DAY, all: Infinity };
3780
+ const w = windows[activePeriod] || DAY;
3781
+ if (w === Infinity) return allSessions;
3782
+ return allSessions.filter(s => {
3783
+ const t = s.firstTs || s.mtime; if (!t) return false;
3784
+ const ts = typeof t === 'number' ? t : new Date(t).getTime();
3785
+ return (now - ts) <= w;
3786
+ });
3787
+ }
3788
+
3789
+ // ── feature 40: session heatmap ────────────────────────────
3790
+ function buildSessionHeatmap(sessions) {
3791
+ const el = document.getElementById('shm-grid');
3792
+ const wrap = document.getElementById('sess-heatmap');
3793
+ if (!el || !wrap || !sessions.length) return;
3794
+ wrap.style.display = 'block';
3795
+ const DAY = 86400000;
3796
+ const now = Date.now();
3797
+ const WEEKS = 12; const DAYS = WEEKS * 7;
3798
+ const buckets = new Array(DAYS).fill(null).map(() => ({ count:0, date:null }));
3799
+ for (let i = 0; i < DAYS; i++) {
3800
+ buckets[i].date = new Date(now - (DAYS - 1 - i) * DAY).toDateString();
3801
+ }
3802
+ for (const s of sessions) {
3803
+ const ts = s.lastTs || s.mtime; if (!ts) continue;
3804
+ const age = now - (typeof ts === 'number' ? ts : new Date(ts).getTime());
3805
+ const idx = DAYS - 1 - Math.floor(age / DAY);
3806
+ if (idx >= 0 && idx < DAYS) buckets[idx].count++;
3807
+ }
3808
+ const max = Math.max(...buckets.map(b => b.count), 1);
3809
+ el.innerHTML = buckets.map(b => {
3810
+ const level = b.count === 0 ? 0 : Math.min(4, Math.ceil(b.count / max * 4));
3811
+ const isActive = b.date === heatmapDateFilter;
3812
+ return `<div class="shm-cell shm-${level}${isActive ? ' shm-active' : ''}" title="${b.date}: ${b.count} session${b.count !== 1 ? 's' : ''}" onclick="setHeatmapFilter('${b.date}',${b.count})"></div>`;
3813
+ }).join('');
3814
+ }
3815
+
3816
+ function setHeatmapFilter(dateStr, count) {
3817
+ if (!count) return;
3818
+ heatmapDateFilter = heatmapDateFilter === dateStr ? null : dateStr;
3819
+ const clearBtn = document.getElementById('shm-clear');
3820
+ if (clearBtn) clearBtn.classList.toggle('show', !!heatmapDateFilter);
3821
+ viewRendered['sessions'] = false;
3822
+ renderSessions();
3823
+ syncURLParams();
3824
+ }
3825
+
3826
+ function clearHeatmapFilter() {
3827
+ heatmapDateFilter = null;
3828
+ const clearBtn = document.getElementById('shm-clear');
3829
+ if (clearBtn) clearBtn.classList.remove('show');
3830
+ viewRendered['sessions'] = false;
3831
+ renderSessions();
3832
+ }
3833
+
3834
+ function toggleSessGroup(id) {
3835
+ const body = document.getElementById(id);
3836
+ if (!body) return;
3837
+ body.classList.toggle('collapsed');
3838
+ const hdr = body.previousElementSibling;
3839
+ if (hdr) { const tog = hdr.querySelector('.sg-toggle'); if (tog) tog.textContent = body.classList.contains('collapsed') ? '▸' : '▾'; }
3840
+ }
3841
+
3842
+ // ── feature 41: bulk session actions ───────────────────────
3843
+ let bulkSelected = new Set();
3844
+ let lastClickedSessIdx = null;
3845
+
3846
+ function handleSessRowClick(evt, row, sessId) {
3847
+ if (diffMode) { diffSelectSession(JSON.parse(row.dataset.sessData || '{}'), row); return; }
3848
+ if (evt.shiftKey && lastClickedSessIdx !== null) {
3849
+ // range-select
3850
+ const rows = [...document.querySelectorAll('#sess-content .sess-row')];
3851
+ const curIdx = rows.indexOf(row);
3852
+ const lo = Math.min(lastClickedSessIdx, curIdx);
3853
+ const hi = Math.max(lastClickedSessIdx, curIdx);
3854
+ for (let i = lo; i <= hi; i++) {
3855
+ const r = rows[i]; if (!r) continue;
3856
+ const id = r.dataset.sessId;
3857
+ if (id) { bulkSelected.add(id); r.classList.add('bulk-sel'); }
3858
+ }
3859
+ updateBulkToolbar();
3860
+ return;
3861
+ }
3862
+ if (bulkSelected.size > 0) {
3863
+ // toggle this row in bulk selection
3864
+ if (bulkSelected.has(sessId)) { bulkSelected.delete(sessId); row.classList.remove('bulk-sel'); }
3865
+ else { bulkSelected.add(sessId); row.classList.add('bulk-sel'); }
3866
+ const rows = [...document.querySelectorAll('#sess-content .sess-row')];
3867
+ lastClickedSessIdx = rows.indexOf(row);
3868
+ updateBulkToolbar();
3869
+ return;
3870
+ }
3871
+ lastClickedSessIdx = [...document.querySelectorAll('#sess-content .sess-row')].indexOf(row);
3872
+ jumpToSession(sessId);
3873
+ }
3874
+
3875
+ function updateBulkToolbar() {
3876
+ const tb = document.getElementById('bulk-toolbar');
3877
+ const cnt = document.getElementById('bulk-count');
3878
+ if (!tb) return;
3879
+ tb.classList.toggle('show', bulkSelected.size > 0);
3880
+ if (cnt) cnt.textContent = bulkSelected.size + ' selected';
3881
+ }
3882
+
3883
+ function clearBulkSelection() {
3884
+ bulkSelected.clear();
3885
+ document.querySelectorAll('#sess-content .sess-row.bulk-sel').forEach(r => r.classList.remove('bulk-sel'));
3886
+ updateBulkToolbar();
3887
+ }
3888
+
3889
+ function bulkBookmark() {
3890
+ for (const id of bulkSelected) bookmarks.add(id);
3891
+ localStorage.setItem('mm-bookmarks', JSON.stringify([...bookmarks]));
3892
+ clearBulkSelection();
3893
+ showToast('Bookmarked', `${bulkSelected.size || 'Selected'} sessions bookmarked`, 'ok');
3894
+ viewRendered['sessions'] = false;
3895
+ renderSessions();
3896
+ }
3897
+
3898
+ function bulkExport() {
3899
+ const toExport = allSessions.filter(s => bulkSelected.has(s.id));
3900
+ if (!toExport.length) return;
3901
+ const headers = ['Date', 'Session ID', 'Prompt', 'Cost ($)', 'Duration (s)', 'Tool Calls', 'Files Touched'];
3902
+ const rows = toExport.map(s => {
3903
+ const dt = new Date(s.firstTs || s.mtime || 0).toISOString().slice(0, 19).replace('T', ' ');
3904
+ const cost = typeof s.totalCost === 'number' ? s.totalCost.toFixed(4) : '';
3905
+ const dur = s.totalDurationMs ? Math.round(s.totalDurationMs / 1000) : '';
3906
+ const prompt = (s.lastPrompt || '').replace(/"/g, '""');
3907
+ const files = (s.filesTouched || []).join(';');
3908
+ return [dt, s.id, prompt, cost, dur, s.toolCalls || '', files];
3909
+ });
3910
+ const csv = [headers, ...rows].map(r => r.map(c => `"${c}"`).join(',')).join('\n');
3911
+ const blob = new Blob([csv], { type: 'text/csv' });
3912
+ const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
3913
+ a.download = `sessions-bulk-${new Date().toISOString().slice(0,10)}.csv`; a.click();
3914
+ URL.revokeObjectURL(a.href);
3915
+ showToast('Exported', `${toExport.length} sessions saved`, 'ok');
3916
+ clearBulkSelection();
3917
+ }
3918
+
3457
3919
  // ── feature 26: token velocity sparkline ──────────────────
3458
3920
  function buildTokenVelocity() {
3459
3921
  const el = document.getElementById('m-velocity');
3460
3922
  if (!el || !allSessions.length) return;
3461
3923
  const now = Date.now();
3462
3924
  const HOUR = 3600000;
3925
+ const filtered = periodFilteredSessions();
3463
3926
  const buckets = new Array(24).fill(0);
3464
- for (const s of allSessions) {
3927
+ for (const s of filtered) {
3465
3928
  const t = s.firstTs || s.mtime;
3466
3929
  if (!t) continue;
3467
3930
  const ts = typeof t === 'number' ? t : new Date(t).getTime();
@@ -3479,10 +3942,11 @@ function buildTokenVelocity() {
3479
3942
  const hrsAgo = 23 - i;
3480
3943
  return `<div class="vel-bar ${cls}" style="height:${h}px" title="${fmt(v)} tokens — ${hrsAgo}h ago"></div>`;
3481
3944
  }).join('');
3482
- const totalCost = allSessions.reduce((a, s) => a + (s.totalCost || 0), 0);
3483
- 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>
3945
+ const totalCost = filtered.reduce((a, s) => a + (s.totalCost || 0), 0);
3946
+ const periodLabel = { day:'24h', week:'7d', month:'30d', all:'all time' }[activePeriod] || '24h';
3947
+ el.innerHTML = `<div class="m-group-title">Token Velocity <span style="font-size:10px;color:var(--text-xs);font-weight:400">${periodLabel} · ${fmt(totalTok)}</span></div>
3484
3948
  <div id="vel-chart">${bars}</div>
3485
- <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>`;
3949
+ <div style="font-size:10px;color:var(--text-xs);margin-top:4px">Cost <span style="color:oklch(78% 0.18 75)">$${totalCost.toFixed(2)}</span> <span style="color:var(--text-xs)">· ${filtered.length} sessions</span></div>`;
3486
3950
  }
3487
3951
 
3488
3952
  // ── feature 27: export sessions CSV ───────────────────────
@@ -3584,6 +4048,501 @@ async function loadProjCosts() {
3584
4048
  }
3585
4049
  }
3586
4050
 
4051
+ // ── feature 55: session timeline (Gantt) ──────────────────
4052
+ let timelineOpen = false;
4053
+ function toggleTimeline() {
4054
+ timelineOpen = !timelineOpen;
4055
+ document.getElementById('btn-timeline').classList.toggle('on', timelineOpen);
4056
+ const panel = document.getElementById('timeline-panel');
4057
+ panel.classList.toggle('open', timelineOpen);
4058
+ if (timelineOpen) buildGanttTimeline();
4059
+ }
4060
+
4061
+ function buildGanttTimeline() {
4062
+ const el = document.getElementById('timeline-scroll');
4063
+ if (!el || !allSessions.length) return;
4064
+ // Group by date, show last 14 days
4065
+ const now = Date.now(); const DAY = 86400000;
4066
+ const days = {};
4067
+ for (let i = 13; i >= 0; i--) {
4068
+ const d = new Date(now - i * DAY); d.setHours(0,0,0,0);
4069
+ days[d.toDateString()] = [];
4070
+ }
4071
+ for (const s of allSessions) {
4072
+ const t = s.firstTs || s.mtime; if (!t) continue;
4073
+ const d = new Date(typeof t === 'number' ? t : t); d.setHours(0,0,0,0);
4074
+ const key = d.toDateString();
4075
+ if (key in days) days[key].push(s);
4076
+ }
4077
+ // Find day with most sessions to normalize bar widths by session start time within day
4078
+ const allCosts = allSessions.map(s => s.totalCost || 0);
4079
+ const maxCost = Math.max(0.01, ...allCosts);
4080
+
4081
+ const DONUT_COLORS = ['oklch(72% 0.18 75)','oklch(65% 0.15 150)','oklch(65% 0.18 220)','oklch(65% 0.2 300)','oklch(65% 0.2 25)'];
4082
+
4083
+ let html = '';
4084
+ for (const [dateStr, sessions] of Object.entries(days)) {
4085
+ const d = new Date(dateStr);
4086
+ const lbl = d.toLocaleDateString(undefined, { month:'short', day:'numeric' });
4087
+ let track = '';
4088
+ // Sort sessions by start time
4089
+ const sorted = [...sessions].sort((a, b) => {
4090
+ const ta = a.firstTs || a.mtime || 0; const tb = b.firstTs || b.mtime || 0;
4091
+ return (typeof ta === 'number' ? ta : new Date(ta).getTime()) - (typeof tb === 'number' ? tb : new Date(tb).getTime());
4092
+ });
4093
+ for (let i = 0; i < sorted.length; i++) {
4094
+ const s = sorted[i];
4095
+ const startTs = typeof (s.firstTs || s.mtime) === 'number' ? (s.firstTs || s.mtime) : new Date(s.firstTs || s.mtime).getTime();
4096
+ const dayStart = new Date(dateStr).getTime();
4097
+ const startPct = Math.min(95, ((startTs - dayStart) / DAY) * 100);
4098
+ const durPct = s.totalDurationMs ? Math.max(0.5, Math.min(15, (s.totalDurationMs / DAY) * 100)) : 1;
4099
+ const costRatio = Math.max(0.15, (s.totalCost || 0.001) / maxCost);
4100
+ const opacity = 0.3 + costRatio * 0.7;
4101
+ const color = DONUT_COLORS[i % DONUT_COLORS.length];
4102
+ const tip = `${s.lastPrompt ? s.lastPrompt.slice(0,60) : s.id} | $${(s.totalCost||0).toFixed(3)} | ${fmtDur(s.totalDurationMs||0)}`;
4103
+ track += `<div class="tl-bar" style="left:${startPct}%;width:${durPct}%;background:${color};opacity:${opacity}" title="${esc(tip)}" onclick="jumpToSession('${esc(s.id)}')"></div>`;
4104
+ }
4105
+ html += `<div class="tl-day-row"><div class="tl-day-lbl">${lbl}</div><div class="tl-track">${track}</div></div>`;
4106
+ }
4107
+ el.innerHTML = html || '<div class="loading-txt">No sessions in last 14 days</div>';
4108
+ }
4109
+
4110
+ // ── feature 56: daily report card ─────────────────────────
4111
+ function showReportCard() {
4112
+ const modal = document.getElementById('report-modal');
4113
+ const pre = document.getElementById('report-pre');
4114
+ if (!modal || !pre) return;
4115
+ const now = new Date();
4116
+ const todayStr = now.toDateString();
4117
+ const weekMs = 7 * 86400000;
4118
+ const weekAgo = Date.now() - weekMs;
4119
+
4120
+ const todaySess = allSessions.filter(s => {
4121
+ const t = s.firstTs || s.mtime; if (!t) return false;
4122
+ return new Date(typeof t === 'number' ? t : t).toDateString() === todayStr;
4123
+ });
4124
+ const weekSess = allSessions.filter(s => {
4125
+ const t = s.firstTs || s.mtime; if (!t) return false;
4126
+ return (typeof t === 'number' ? t : new Date(t).getTime()) >= weekAgo;
4127
+ });
4128
+
4129
+ function summarize(sessions, label) {
4130
+ if (!sessions.length) return `${label}: no sessions\n`;
4131
+ const totalCost = sessions.reduce((a, s) => a + (s.totalCost||0), 0);
4132
+ const totalDur = sessions.reduce((a, s) => a + (s.totalDurationMs||0), 0);
4133
+ const totalTools = sessions.reduce((a, s) => a + (s.toolCalls||0), 0);
4134
+ const totalErrs = sessions.reduce((a, s) => a + (s.errorCount||0), 0);
4135
+ // top files
4136
+ const fileFreq = {};
4137
+ for (const s of sessions) for (const f of s.filesTouched||[]) fileFreq[f] = (fileFreq[f]||0) + 1;
4138
+ const topFiles = Object.entries(fileFreq).sort((a,b)=>b[1]-a[1]).slice(0,5).map(([f,n])=>` - ${f} (×${n})`).join('\n');
4139
+ // anomalies
4140
+ const costsArr = sessions.map(s => s.totalCost||0).filter(c => c > 0).sort((a,b)=>a-b);
4141
+ const median = costsArr.length ? costsArr[Math.floor(costsArr.length/2)] : 0;
4142
+ const anomalies = sessions.filter(s => (s.totalCost||0) > median * 3 && (s.totalCost||0) > 0.5).map(s => ` - ${s.lastPrompt?.slice(0,50)||s.id}: $${(s.totalCost||0).toFixed(2)}`).join('\n');
4143
+ return `### ${label}
4144
+ - Sessions: ${sessions.length}
4145
+ - Total cost: $${totalCost.toFixed(3)}
4146
+ - Total duration: ${fmtDur(totalDur)}
4147
+ - Tool calls: ${totalTools}${totalErrs ? ` (${totalErrs} errors)` : ''}
4148
+ ${topFiles ? `\nTop files touched:\n${topFiles}` : ''}${anomalies ? `\n\nCost anomalies:\n${anomalies}` : ''}
4149
+ `;
4150
+ }
4151
+
4152
+ const report = `# Monomind Report Card
4153
+ Generated: ${now.toLocaleString()}
4154
+ Project: ${DIR.split('/').filter(Boolean).pop() || DIR}
4155
+
4156
+ ${summarize(todaySess, 'Today')}
4157
+ ${summarize(weekSess, 'This Week')}
4158
+ ---
4159
+ Total all-time: ${allSessions.length} sessions | $${allSessions.reduce((a,s)=>a+(s.totalCost||0),0).toFixed(2)}
4160
+ `;
4161
+ pre.textContent = report;
4162
+ modal.classList.add('open');
4163
+ }
4164
+
4165
+ function closeReportCard() {
4166
+ document.getElementById('report-modal').classList.remove('open');
4167
+ }
4168
+
4169
+ function copyReportCard() {
4170
+ const text = document.getElementById('report-pre')?.textContent || '';
4171
+ navigator.clipboard.writeText(text).then(() => {
4172
+ const btn = document.querySelector('.rp-copy-btn');
4173
+ if (btn) { btn.textContent = '✓ Copied'; setTimeout(() => { btn.textContent = '⎘ Copy'; }, 1500); }
4174
+ }).catch(() => {});
4175
+ }
4176
+
4177
+ // ── feature 57: file-pivot cross-filter ───────────────────
4178
+ let filePivot = null;
4179
+
4180
+ function setFilePivot(fname, event) {
4181
+ if (event) event.stopPropagation();
4182
+ filePivot = filePivot === fname ? null : fname;
4183
+ const bar = document.getElementById('file-pivot-bar');
4184
+ const lbl = document.getElementById('fpb-label');
4185
+ if (bar) bar.classList.toggle('show', !!filePivot);
4186
+ if (lbl) lbl.textContent = filePivot ? `Showing sessions that touched: ${filePivot}` : '';
4187
+ viewRendered['sessions'] = false;
4188
+ renderSessions();
4189
+ }
4190
+
4191
+ function clearFilePivot() {
4192
+ filePivot = null;
4193
+ const bar = document.getElementById('file-pivot-bar');
4194
+ if (bar) bar.classList.remove('show');
4195
+ viewRendered['sessions'] = false;
4196
+ renderSessions();
4197
+ }
4198
+
4199
+ // ── feature 58: model cost donut ──────────────────────────
4200
+ let modelDonutOpen = false;
4201
+ const DONUT_PALETTE = ['oklch(72% 0.18 75)','oklch(65% 0.15 150)','oklch(65% 0.18 220)','oklch(65% 0.2 300)','oklch(62% 0.18 25)','oklch(70% 0.12 60)'];
4202
+
4203
+ function toggleModelDonut() {
4204
+ modelDonutOpen = !modelDonutOpen;
4205
+ document.getElementById('btn-donut').classList.toggle('on', modelDonutOpen);
4206
+ const panel = document.getElementById('model-donut-panel');
4207
+ panel.classList.toggle('open', modelDonutOpen);
4208
+ if (modelDonutOpen) buildModelDonut();
4209
+ }
4210
+
4211
+ function buildModelDonut() {
4212
+ const el = document.getElementById('model-donut-panel');
4213
+ if (!el) return;
4214
+ const breakdown = {};
4215
+ for (const s of allSessions) {
4216
+ for (const [model, d] of Object.entries(s.modelBreakdown || {})) {
4217
+ if (!breakdown[model]) breakdown[model] = { calls: 0, cost: 0 };
4218
+ breakdown[model].calls += d.calls || 0;
4219
+ breakdown[model].cost += d.cost || 0;
4220
+ }
4221
+ }
4222
+ const entries = Object.entries(breakdown).sort((a, b) => b[1].cost - a[1].cost);
4223
+ if (!entries.length) { el.innerHTML = '<div class="loading-txt" style="padding:8px">No model breakdown data</div>'; return; }
4224
+ const totalCost = entries.reduce((a, [, d]) => a + d.cost, 0);
4225
+
4226
+ // Build SVG conic-gradient-style donut using stroke-dasharray
4227
+ const R = 36; const CX = 44; const CY = 44; const CIRCUMFERENCE = 2 * Math.PI * R;
4228
+ let offset = 0;
4229
+ const segments = entries.map(([model, d], i) => {
4230
+ const pct = totalCost > 0 ? d.cost / totalCost : 0;
4231
+ const dash = pct * CIRCUMFERENCE;
4232
+ const seg = `<circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="${DONUT_PALETTE[i % DONUT_PALETTE.length]}" stroke-width="14" stroke-dasharray="${dash} ${CIRCUMFERENCE - dash}" stroke-dashoffset="${-offset}" transform="rotate(-90 ${CX} ${CY})"/>`;
4233
+ offset += dash;
4234
+ return seg;
4235
+ }).join('');
4236
+ const svg = `<svg class="donut-svg" width="88" height="88" viewBox="0 0 88 88">
4237
+ <circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="var(--surface-hi)" stroke-width="14"/>
4238
+ ${segments}
4239
+ <text x="${CX}" y="${CY}" text-anchor="middle" dy="0.3em" font-size="9" fill="var(--text-xs)" font-family="var(--mono)">$${totalCost.toFixed(2)}</text>
4240
+ </svg>`;
4241
+
4242
+ const legend = entries.slice(0, 6).map(([model, d], i) => {
4243
+ const short = model.replace(/^claude-/, '').replace(/-\d{8}$/, '');
4244
+ const pct = totalCost > 0 ? Math.round(d.cost / totalCost * 100) : 0;
4245
+ return `<div class="donut-item"><div class="donut-swatch" style="background:${DONUT_PALETTE[i % DONUT_PALETTE.length]}"></div>
4246
+ <span class="donut-name" title="${esc(model)}">${esc(short)}</span>
4247
+ <span class="donut-cost">$${d.cost.toFixed(2)} <span style="color:var(--text-xs)">${pct}%</span></span>
4248
+ </div>`;
4249
+ }).join('');
4250
+
4251
+ el.innerHTML = `<div class="donut-wrap">${svg}<div class="donut-legend">${legend}</div></div>`;
4252
+ }
4253
+
4254
+ // ── feature 60: cost anomaly explainer ────────────────────
4255
+ // Enhance anom-cost badge with onclick that shows explainer panel
4256
+ function showCostExplainer(sessId, event) {
4257
+ if (event) event.stopPropagation();
4258
+ const drawer = document.getElementById('err-drawer-' + sessId);
4259
+ if (!drawer) return;
4260
+ if (drawer.classList.contains('open')) { drawer.classList.remove('open'); drawer.innerHTML = ''; return; }
4261
+ const sess = allSessions.find(s => s.id === sessId);
4262
+ if (!sess) return;
4263
+ drawer.classList.add('open');
4264
+ const costsArr = allSessions.map(s => s.totalCost||0).filter(c=>c>0).sort((a,b)=>a-b);
4265
+ const median = costsArr.length ? costsArr[Math.floor(costsArr.length/2)] : 0;
4266
+ const pct = costsArr.length ? Math.round(costsArr.filter(c=>c<=(sess.totalCost||0)).length/costsArr.length*100) : 0;
4267
+ const ratio = median > 0 ? ((sess.totalCost||0)/median).toFixed(1) : '—';
4268
+ const modelRows = Object.entries(sess.modelBreakdown||{}).sort((a,b)=>b[1].cost-a[1].cost).slice(0,4)
4269
+ .map(([m,d])=>`<div class="err-item">${esc(m.replace(/^claude-/,'').replace(/-\d{8}$/,''))}: $${(d.cost||0).toFixed(4)} · ${d.calls||0} calls</div>`).join('');
4270
+ drawer.innerHTML = `<div class="err-drawer-head" style="color:oklch(70% 0.18 80)">
4271
+ <span>Cost anomaly — $${(sess.totalCost||0).toFixed(3)} (${ratio}× median, top ${100-pct}%)</span>
4272
+ <button class="err-close" onclick="this.closest('.err-drawer').classList.remove('open');this.closest('.err-drawer').innerHTML=''">✕</button>
4273
+ </div>
4274
+ <div class="err-drawer-body">
4275
+ <div class="err-item" style="color:var(--text-lo)">Tool calls: ${sess.toolCalls||0} · Messages: ${sess.totalMessages||0} · Tokens in: ${(sess.totalInputTokens||0).toLocaleString()}</div>
4276
+ ${modelRows || '<div class="err-item" style="color:var(--text-xs)">No model breakdown available</div>'}
4277
+ </div>`;
4278
+ }
4279
+
4280
+ // ── feature 47: 30-day daily cost trend ───────────────────
4281
+ function buildDailyCostTrend() {
4282
+ const el = document.getElementById('m-daily-trend');
4283
+ if (!el || !allSessions.length) return;
4284
+ const now = Date.now(); const DAY = 86400000;
4285
+ const buckets = new Array(30).fill(0);
4286
+ const dates = new Array(30).fill(null).map((_, i) => new Date(now - (29 - i) * DAY).toDateString());
4287
+ for (const s of allSessions) {
4288
+ const t = s.firstTs || s.mtime; if (!t) continue;
4289
+ const ts = typeof t === 'number' ? t : new Date(t).getTime();
4290
+ const daysAgo = Math.floor((now - ts) / DAY);
4291
+ const idx = 29 - daysAgo;
4292
+ if (idx >= 0 && idx < 30) buckets[idx] += s.totalCost || 0;
4293
+ }
4294
+ const maxCost = Math.max(0.001, ...buckets);
4295
+ const totalCost = buckets.reduce((a, b) => a + b, 0);
4296
+ const bars = buckets.map((v, i) => {
4297
+ const h = Math.max(2, Math.round((v / maxCost) * 38));
4298
+ const isActive = dates[i] === heatmapDateFilter;
4299
+ const label = i === 29 ? 'Today' : (i === 28 ? 'Yday' : '');
4300
+ return `<div class="dt-bar${isActive ? ' active' : ''}" style="height:${h}px" title="${dates[i]}: $${v.toFixed(3)}" onclick="setDailyTrendFilter('${dates[i]}',${v})"></div>`;
4301
+ }).join('');
4302
+ el.innerHTML = `<div class="m-group-title">30-Day Cost <span style="font-size:10px;color:var(--text-xs);font-weight:400">$${totalCost.toFixed(2)} total</span></div>
4303
+ <div id="daily-trend-chart">${bars}</div>
4304
+ <div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-xs);margin-top:2px"><span>30d ago</span><span>Today</span></div>`;
4305
+ }
4306
+
4307
+ function setDailyTrendFilter(dateStr, cost) {
4308
+ if (!cost) return;
4309
+ heatmapDateFilter = heatmapDateFilter === dateStr ? null : dateStr;
4310
+ const clearBtn = document.getElementById('shm-clear');
4311
+ if (clearBtn) clearBtn.classList.toggle('show', !!heatmapDateFilter);
4312
+ viewRendered['sessions'] = false;
4313
+ renderSessions();
4314
+ syncURLParams();
4315
+ }
4316
+
4317
+ // ── feature 48: live cost ticker ──────────────────────────
4318
+ let _liveTickerCost = 0;
4319
+ let _liveTickerPrev = 0;
4320
+
4321
+ function updateLiveTicker(cost) {
4322
+ const el = document.getElementById('live-cost-ticker');
4323
+ if (!el) return;
4324
+ if (cost == null || cost === 0) { el.classList.remove('show'); return; }
4325
+ _liveTickerPrev = _liveTickerCost;
4326
+ _liveTickerCost = cost;
4327
+ const delta = _liveTickerCost - _liveTickerPrev;
4328
+ const deltaHtml = delta > 0.0001 ? `<span class="lct-change">+$${delta.toFixed(4)}</span>` : '';
4329
+ el.innerHTML = `$${cost.toFixed(3)}${deltaHtml}`;
4330
+ el.classList.add('show');
4331
+ }
4332
+
4333
+ // ── feature 49: hourly productivity heatmap ───────────────
4334
+ function buildHourlyHeatmap() {
4335
+ const el = document.getElementById('m-hourly-heatmap');
4336
+ if (!el || !allSessions.length) return;
4337
+ // 24 hours × 7 days-of-week matrix
4338
+ const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
4339
+ for (const s of allSessions) {
4340
+ const t = s.firstTs || s.mtime; if (!t) continue;
4341
+ const d = new Date(typeof t === 'number' ? t : t);
4342
+ const dow = d.getDay(); // 0=Sun
4343
+ const hour = d.getHours();
4344
+ grid[dow][hour]++;
4345
+ }
4346
+ const maxVal = Math.max(1, ...grid.flatMap(r => r));
4347
+ const dayNames = ['Su','Mo','Tu','We','Th','Fr','Sa'];
4348
+ let html = '<div id="hourly-heatmap-grid">';
4349
+ // Header row: empty corner + hour labels (0,6,12,18,23)
4350
+ html += '<div></div>';
4351
+ for (let h = 0; h < 24; h++) {
4352
+ const lbl = h % 6 === 0 ? String(h) : '';
4353
+ html += `<div class="hh-hour-lbl">${lbl}</div>`;
4354
+ }
4355
+ // Data rows
4356
+ for (let d = 0; d < 7; d++) {
4357
+ html += `<div class="hh-day-lbl">${dayNames[d]}</div>`;
4358
+ for (let h = 0; h < 24; h++) {
4359
+ const v = grid[d][h];
4360
+ const level = v === 0 ? 0 : Math.min(4, Math.ceil(v / maxVal * 4));
4361
+ html += `<div class="hh-cell hh-${level}" title="${dayNames[d]} ${h}:00 — ${v} session${v !== 1 ? 's' : ''}"></div>`;
4362
+ }
4363
+ }
4364
+ html += '</div>';
4365
+ const peakHour = grid.flatMap((r, d) => r.map((v, h) => ({ d, h, v }))).sort((a, b) => b.v - a.v)[0];
4366
+ const peakLabel = peakHour ? `${dayNames[peakHour.d]} ${peakHour.h}:00` : '';
4367
+ el.innerHTML = `<div class="m-group-title">Peak Work Hours${peakLabel ? `<span style="font-size:10px;color:var(--text-xs);font-weight:400;margin-left:4px">peak: ${peakLabel}</span>` : ''}</div>${html}`;
4368
+ }
4369
+
4370
+ // ── feature 50: custom tag editor ─────────────────────────
4371
+ const _customTagsKey = 'mm-custom-tags';
4372
+ let _customTagsMap = new Map(Object.entries(JSON.parse(localStorage.getItem(_customTagsKey) || '{}')));
4373
+
4374
+ function getCustomTags(sessId) {
4375
+ return _customTagsMap.get(sessId) || [];
4376
+ }
4377
+
4378
+ function saveCustomTags(sessId, tags) {
4379
+ if (tags.length === 0) _customTagsMap.delete(sessId);
4380
+ else _customTagsMap.set(sessId, tags);
4381
+ localStorage.setItem(_customTagsKey, JSON.stringify(Object.fromEntries(_customTagsMap)));
4382
+ }
4383
+
4384
+ function addCustomTag(sessId, tag, event) {
4385
+ if (event) event.stopPropagation();
4386
+ const t = tag.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
4387
+ if (!t) return;
4388
+ const tags = getCustomTags(sessId);
4389
+ if (!tags.includes(t)) { tags.push(t); saveCustomTags(sessId, tags); }
4390
+ const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
4391
+ if (wrap) wrap.outerHTML = renderCustomTagsInline(sessId, tags);
4392
+ // rebuild tag filter bar in the DOM if it exists
4393
+ initTags();
4394
+ const tfBar = document.querySelector('.tag-filter-bar');
4395
+ if (tfBar) tfBar.outerHTML = buildTagFilterBar(allSessions);
4396
+ }
4397
+
4398
+ function removeCustomTag(sessId, tag, event) {
4399
+ if (event) event.stopPropagation();
4400
+ const tags = getCustomTags(sessId).filter(t => t !== tag);
4401
+ saveCustomTags(sessId, tags);
4402
+ const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
4403
+ if (wrap) wrap.outerHTML = renderCustomTagsInline(sessId, tags);
4404
+ }
4405
+
4406
+ function showCustomTagInput(sessId, event) {
4407
+ if (event) event.stopPropagation();
4408
+ const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
4409
+ if (!wrap) return;
4410
+ const existing = wrap.querySelector('.ctag-input-wrap');
4411
+ if (existing) { existing.remove(); return; }
4412
+ const iw = document.createElement('div');
4413
+ iw.className = 'ctag-input-wrap';
4414
+ iw.onclick = e => e.stopPropagation();
4415
+ iw.innerHTML = `<input class="ctag-input" type="text" placeholder="tag name…" maxlength="20" autofocus>
4416
+ <button class="ctag-ok" onclick="(()=>{const inp=this.closest('.ctag-input-wrap').querySelector('input');addCustomTag('${esc(sessId)}',inp.value,event);inp.value='';})()">Add</button>`;
4417
+ wrap.appendChild(iw);
4418
+ const inp = iw.querySelector('input');
4419
+ inp.focus();
4420
+ inp.addEventListener('keydown', e => {
4421
+ if (e.key === 'Enter') { addCustomTag(sessId, inp.value, e); inp.value = ''; }
4422
+ if (e.key === 'Escape') iw.remove();
4423
+ });
4424
+ }
4425
+
4426
+ function renderCustomTagsInline(sessId, tags) {
4427
+ const tagHtml = tags.map(t =>
4428
+ `<span class="sr-ctag">${esc(t)}<span class="ctag-del" onclick="removeCustomTag('${esc(sessId)}','${esc(t)}',event)" title="Remove tag">✕</span></span>`
4429
+ ).join('');
4430
+ return `<div class="sr-custom-tags" data-sess="${esc(sessId)}" onclick="event.stopPropagation()">
4431
+ ${tagHtml}
4432
+ <button class="ctag-add-btn" onclick="showCustomTagInput('${esc(sessId)}',event)" title="Add tag">+ tag</button>
4433
+ </div>`;
4434
+ }
4435
+
4436
+ // ── feature 51: tool error drawer ─────────────────────────
4437
+ async function toggleErrDrawer(sessId, event) {
4438
+ if (event) event.stopPropagation();
4439
+ const drawer = document.getElementById('err-drawer-' + sessId);
4440
+ if (!drawer) return;
4441
+ if (drawer.classList.contains('open')) { drawer.classList.remove('open'); drawer.innerHTML = ''; return; }
4442
+ drawer.classList.add('open');
4443
+ drawer.innerHTML = '<div class="err-drawer-head">Loading errors…</div>';
4444
+ try {
4445
+ const data = await apiFetch('/api/session-errors?dir=' + enc(DIR) + '&id=' + enc(sessId));
4446
+ if (!data.errors?.length) {
4447
+ drawer.innerHTML = `<div class="err-drawer-head"><span>No error details found</span><button class="err-close" onclick="closeErrDrawer('${esc(sessId)}')">✕</button></div>`;
4448
+ return;
4449
+ }
4450
+ const items = data.errors.map(e => `<div class="err-item">${esc(e.text)}</div>`).join('');
4451
+ drawer.innerHTML = `<div class="err-drawer-head"><span>${data.errors.length} error${data.errors.length !== 1 ? 's' : ''}</span><button class="err-close" onclick="closeErrDrawer('${esc(sessId)}')">✕</button></div>
4452
+ <div class="err-drawer-body">${items}</div>`;
4453
+ } catch (err) {
4454
+ drawer.innerHTML = `<div class="err-drawer-head"><span>Could not load: ${esc(err.message)}</span><button class="err-close" onclick="closeErrDrawer('${esc(sessId)}')">✕</button></div>`;
4455
+ }
4456
+ }
4457
+
4458
+ function closeErrDrawer(sessId) {
4459
+ const drawer = document.getElementById('err-drawer-' + sessId);
4460
+ if (!drawer) return;
4461
+ drawer.classList.remove('open');
4462
+ drawer.innerHTML = '';
4463
+ }
4464
+
4465
+ // ── feature 52: prompt copy button ────────────────────────
4466
+ function copyPrompt(text, event) {
4467
+ if (event) event.stopPropagation();
4468
+ const btn = event?.currentTarget || event?.target;
4469
+ navigator.clipboard.writeText(text).then(() => {
4470
+ if (btn) { btn.textContent = '✓'; btn.classList.add('copied'); setTimeout(() => { btn.textContent = '⎘'; btn.classList.remove('copied'); }, 1500); }
4471
+ }).catch(() => {});
4472
+ }
4473
+
4474
+ // ── feature 53: session cost histogram ────────────────────
4475
+ function buildCostHistogram() {
4476
+ const el = document.getElementById('cost-histogram-panel');
4477
+ if (!el) return;
4478
+ const costs = allSessions.map(s => s.totalCost || 0).filter(c => c > 0);
4479
+ if (costs.length < 2) { el.style.display = 'none'; return; }
4480
+ el.style.display = 'block';
4481
+ const minC = Math.min(...costs); const maxC = Math.max(...costs);
4482
+ const BUCKETS = 10;
4483
+ const range = maxC - minC || 0.01;
4484
+ const bucketSize = range / BUCKETS;
4485
+ const counts = new Array(BUCKETS).fill(0);
4486
+ for (const c of costs) { const i = Math.min(BUCKETS - 1, Math.floor((c - minC) / bucketSize)); counts[i]++; }
4487
+ const maxCount = Math.max(1, ...counts);
4488
+ const fmt = v => v < 0.01 ? v.toFixed(4) : v < 1 ? '$' + v.toFixed(2) : '$' + v.toFixed(1);
4489
+ const bars = counts.map((n, i) => {
4490
+ const h = Math.max(2, Math.round((n / maxCount) * 46));
4491
+ const lo = minC + i * bucketSize; const hi = lo + bucketSize;
4492
+ return `<div class="ch-bar-wrap" title="${fmt(lo)}–${fmt(hi)}: ${n} session${n !== 1 ? 's' : ''}">
4493
+ <div class="ch-cnt">${n || ''}</div>
4494
+ <div class="ch-bar" style="height:${h}px"></div>
4495
+ <div class="ch-lbl">${i === 0 ? fmt(lo) : i === BUCKETS - 1 ? fmt(hi) : ''}</div>
4496
+ </div>`;
4497
+ }).join('');
4498
+ el.innerHTML = `<div class="ch-title">Cost Distribution — ${costs.length} sessions</div>
4499
+ <div class="ch-bars">${bars}</div>`;
4500
+ }
4501
+
4502
+ // ── feature 54: persistent filter URL params ──────────────
4503
+ function syncURLParams() {
4504
+ const p = new URLSearchParams(window.location.search);
4505
+ if (DIR) p.set('proj', DIR); else p.delete('proj');
4506
+ if (activePeriod && activePeriod !== 'day') p.set('period', activePeriod); else p.delete('period');
4507
+ if (activeTagFilter) p.set('tag', activeTagFilter); else p.delete('tag');
4508
+ if (heatmapDateFilter) p.set('date', heatmapDateFilter); else p.delete('date');
4509
+ if (showStarredOnly) p.set('starred', '1'); else p.delete('starred');
4510
+ if (filePivot) p.set('file', filePivot); else p.delete('file');
4511
+ const newUrl = window.location.pathname + (p.toString() ? '?' + p.toString() : '');
4512
+ history.replaceState(null, '', newUrl);
4513
+ }
4514
+
4515
+ function restoreURLParams() {
4516
+ const p = new URLSearchParams(window.location.search);
4517
+ const period = p.get('period');
4518
+ if (period && ['day','week','month','all'].includes(period)) {
4519
+ activePeriod = period;
4520
+ document.querySelectorAll('.period-btn').forEach(b => b.classList.toggle('active', b.dataset.period === period));
4521
+ }
4522
+ const tag = p.get('tag');
4523
+ if (tag) activeTagFilter = tag;
4524
+ const date = p.get('date');
4525
+ if (date) {
4526
+ heatmapDateFilter = date;
4527
+ const clearBtn = document.getElementById('shm-clear');
4528
+ if (clearBtn) clearBtn.classList.add('show');
4529
+ }
4530
+ const starred = p.get('starred');
4531
+ if (starred === '1') {
4532
+ showStarredOnly = true;
4533
+ const btn = document.getElementById('sess-star-filter');
4534
+ if (btn) btn.classList.add('on');
4535
+ }
4536
+ const file = p.get('file');
4537
+ if (file) {
4538
+ filePivot = file;
4539
+ const bar = document.getElementById('file-pivot-bar');
4540
+ const lbl = document.getElementById('fpb-label');
4541
+ if (bar) bar.classList.add('show');
4542
+ if (lbl) lbl.textContent = `Showing sessions that touched: ${file}`;
4543
+ }
4544
+ }
4545
+
3587
4546
  // ── helpers ────────────────────────────────────────────────
3588
4547
  function enc(s) { return encodeURIComponent(s); }
3589
4548
  function esc(s) {