@monoes/monomindcli 1.10.39 → 1.10.40

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.
@@ -714,6 +714,70 @@ 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); }
717
781
  </style>
718
782
  </head>
719
783
  <body>
@@ -771,6 +835,7 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
771
835
  <span id="view-title">Now</span>
772
836
  <span class="pill"><span class="live-dot"></span> live</span>
773
837
  <span id="topbar-cost"></span>
838
+ <span id="live-cost-ticker" title="Current session accumulated cost"></span>
774
839
  <span id="topbar-activity"></span>
775
840
  <div id="tb-right">
776
841
  <button class="btn" id="btn-budget" onclick="openBudgetModal()" title="Set daily/monthly cost budget">⚑ Budget</button>
@@ -915,6 +980,14 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
915
980
  <div class="m-group-title">Token Velocity</div>
916
981
  <div class="loading-txt">—</div>
917
982
  </div>
983
+ <div id="m-daily-trend">
984
+ <div class="m-group-title">30-Day Cost Trend</div>
985
+ <div class="loading-txt">—</div>
986
+ </div>
987
+ <div id="m-hourly-heatmap">
988
+ <div class="m-group-title">Peak Work Hours</div>
989
+ <div class="loading-txt">—</div>
990
+ </div>
918
991
  </div>
919
992
  </div>
920
993
 
@@ -956,6 +1029,7 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
956
1029
  <th class="lb-rank">#</th><th>Session</th><th class="lb-cost">Cost</th><th class="lb-dur">Duration</th>
957
1030
  </tr></thead><tbody id="lb-body"></tbody></table>
958
1031
  </div>
1032
+ <div id="cost-histogram-panel"></div>
959
1033
  <div id="model-mix-panel" style="display:none;margin-bottom:16px">
960
1034
  <div id="model-mix-body"></div>
961
1035
  </div>
@@ -1186,11 +1260,12 @@ async function init() {
1186
1260
  document.getElementById('sb-proj').textContent = DIR.split('/').filter(Boolean).pop() || '—';
1187
1261
  document.getElementById('sb-path').textContent = DIR;
1188
1262
  }
1189
- viewRendered['now'] = true; // prevents switchView from re-rendering NOW on jumpToSession
1263
+ restoreURLParams();
1264
+ viewRendered['now'] = true;
1190
1265
  updateBudgetBtnStyle();
1191
1266
  await refreshNow();
1192
1267
  if (sessParam) setTimeout(() => jumpToSession(sessParam), 300);
1193
- startPolling();
1268
+ initSSE();
1194
1269
  }
1195
1270
 
1196
1271
  function startPolling() {
@@ -1198,6 +1273,20 @@ function startPolling() {
1198
1273
  pollTimer = setInterval(() => { if (currentView === 'now') refreshNowSilent(); }, 30000);
1199
1274
  }
1200
1275
 
1276
+ let _sseSource = null;
1277
+ function initSSE() {
1278
+ if (_sseSource) { try { _sseSource.close(); } catch {} _sseSource = null; }
1279
+ if (!DIR || !window.EventSource) { startPolling(); return; }
1280
+ try {
1281
+ const src = new EventSource('/api/events-stream?dir=' + enc(DIR));
1282
+ src.addEventListener('update', () => { if (currentView === 'now') refreshNowSilent(); });
1283
+ src.addEventListener('connected', () => {});
1284
+ src.onerror = () => { src.close(); _sseSource = null; startPolling(); };
1285
+ _sseSource = src;
1286
+ clearInterval(pollTimer); // SSE replaces polling
1287
+ } catch { startPolling(); }
1288
+ }
1289
+
1201
1290
  async function apiFetch(url) {
1202
1291
  const r = await fetch(url);
1203
1292
  if (!r.ok) throw new Error(r.status);
@@ -1239,7 +1328,7 @@ function toggleLiveTail() {
1239
1328
  if (sessionIdx !== 0 && allSessions.length) { sessionIdx = 0; userScrolled = false; loadFeedForSession(allSessions[0]); }
1240
1329
  liveTailTimer = setInterval(() => { if (currentView === 'now') refreshNowSilent(); }, 5000);
1241
1330
  } else {
1242
- startPolling();
1331
+ initSSE();
1243
1332
  }
1244
1333
  }
1245
1334
 
@@ -1293,6 +1382,9 @@ async function loadFeedForSession(sess) {
1293
1382
  document.getElementById('btn-prev-sess').style.opacity = sessionIdx < allSessions.length - 1 ? '1' : '0.3';
1294
1383
  document.getElementById('btn-next-sess').style.opacity = sessionIdx > 0 ? '1' : '0.3';
1295
1384
  showSessCtx(sess);
1385
+ // f48: live cost ticker
1386
+ const sessCost = typeof sess.totalCost === 'number' ? sess.totalCost : (typeof sess.cost === 'number' ? sess.cost : null);
1387
+ updateLiveTicker(sessCost);
1296
1388
  if (!sess.file) { setFeedContent('<div class="feed-empty">Session file path unavailable.</div>'); return; }
1297
1389
  try {
1298
1390
  const data = await apiFetch('/api/session?dir=' + enc(DIR) + '&file=' + enc(sess.file) + '&limit=120');
@@ -1778,21 +1870,42 @@ async function renderSessions() {
1778
1870
  }
1779
1871
  let toShow = showStarredOnly ? sessions.filter(s => bookmarks.has(s.id)) : sessions;
1780
1872
  if (activeTagFilter) toShow = toShow.filter(s => (allTags.sessionTags.get(s.id) || []).includes(activeTagFilter));
1873
+ if (heatmapDateFilter) toShow = toShow.filter(s => {
1874
+ const t = s.lastTs || s.mtime; if (!t) return false;
1875
+ return new Date(typeof t === 'number' ? t : t).toDateString() === heatmapDateFilter;
1876
+ });
1781
1877
  if (!toShow.length) {
1782
1878
  el.innerHTML = '<div class="empty"><div class="empty-ico">☆</div><div>No bookmarked sessions</div></div>';
1879
+ buildSessionHeatmap(sessions);
1783
1880
  return;
1784
1881
  }
1785
1882
  // compute median cost for anomaly detection
1786
1883
  const costsForMedian = sessions.map(s => s.totalCost || 0).filter(c => c > 0).sort((a, b) => a - b);
1787
1884
  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) => {
1885
+
1886
+ // group by date
1887
+ const now = Date.now(); const DAY = 86400000;
1888
+ function sessDateGroup(s) {
1889
+ const t = s.lastTs || s.mtime; if (!t) return 'Older';
1890
+ const age = now - (typeof t === 'number' ? t : new Date(t).getTime());
1891
+ if (age < DAY) return 'Today';
1892
+ if (age < 2 * DAY) return 'Yesterday';
1893
+ if (age < 8 * DAY) return 'This week';
1894
+ return 'Older';
1895
+ }
1896
+ const GROUP_ORDER = ['Today', 'Yesterday', 'This week', 'Older'];
1897
+ const groups = {};
1898
+ for (const s of toShow) {
1899
+ const g = sessDateGroup(s); if (!groups[g]) groups[g] = [];
1900
+ groups[g].push(s);
1901
+ }
1902
+
1903
+ function renderSessRow(s, idx) {
1790
1904
  const dur = s.totalDurationMs ? fmtDur(s.totalDurationMs) : '';
1791
1905
  const msgs = s.totalMessages ? s.totalMessages + ' msg' : '';
1792
1906
  const cost = typeof s.totalCost === 'number' ? '$' + s.totalCost.toFixed(2)
1793
1907
  : typeof s.cost === 'number' ? '$' + s.cost.toFixed(2) : '';
1794
1908
  const meta = [dur, msgs, cost].filter(Boolean).join(' · ') || s.id.slice(0, 16);
1795
- // anomaly badge
1796
1909
  const sCost = s.totalCost || 0;
1797
1910
  let anomBadge = '';
1798
1911
  if (medianCost > 0.05 && sCost > medianCost * 3 && sCost > 0.5) {
@@ -1811,16 +1924,29 @@ async function renderSessions() {
1811
1924
  const sData = JSON.stringify(s).replace(/'/g, '&#39;');
1812
1925
  const note = getSessNote(s.id);
1813
1926
  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}'>
1927
+ const files = (s.filesTouched || []).slice(0, 5);
1928
+ const filesHtml = files.length ? `<div class="sr-files">${files.map(f => `<span class="sr-file-chip">${esc(f)}</span>`).join('')}${(s.filesTouched||[]).length > 5 ? `<span class="sr-file-chip">+${(s.filesTouched||[]).length-5}</span>` : ''}</div>` : '';
1929
+ // f51: error badge — clickable if errors exist
1930
+ const errBadge = (s.errorCount > 0 && s.toolCalls > 0 && (s.errorCount / s.toolCalls) > 0.3)
1931
+ ? `<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>`
1932
+ : anomBadge;
1933
+ // f50: custom tags
1934
+ const ctags = getCustomTags(s.id);
1935
+ const ctagsHtml = renderCustomTagsInline(s.id, ctags);
1936
+ 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
1937
  <div class="sr-top">
1816
1938
  <div class="sr-prompt">${esc(s.lastPrompt || s.id)}</div>
1817
1939
  <div class="sr-time">${relTime(s.lastTs || s.mtime)}</div>
1940
+ <button class="sr-copy-btn" onclick="copyPrompt(${JSON.stringify(s.lastPrompt || s.id)},event)" title="Copy prompt to clipboard">⎘</button>
1818
1941
  <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
1942
  <span class="sr-view">→ view</span>
1820
1943
  </div>
1821
- <div class="sr-meta">${esc(meta)}${anomBadge}</div>
1944
+ <div class="sr-meta">${esc(meta)}${errBadge}</div>
1822
1945
  ${(summaries || autoTags) ? `<div class="sr-tags">${summaries}${autoTags}</div>` : ''}
1946
+ ${ctagsHtml}
1947
+ ${filesHtml}
1823
1948
  ${satBar}
1949
+ <div class="err-drawer" id="err-drawer-${esc(s.id)}"></div>
1824
1950
  <div class="sess-notes-wrap" onclick="event.stopPropagation()">
1825
1951
  <button class="sess-notes-toggle${hasNote ? ' has-note' : ''}" onclick="toggleSessNote('${esc(s.id)}',this)">✎ ${hasNote ? 'Note' : 'Add note'}</button>
1826
1952
  <div class="sess-notes-area" id="snote-${esc(s.id)}">
@@ -1829,17 +1955,36 @@ async function renderSessions() {
1829
1955
  </div>
1830
1956
  </div>
1831
1957
  </div>`;
1832
- }).join('');
1958
+ }
1959
+
1960
+ let html = '';
1961
+ let flatIdx = 0;
1962
+ for (const grp of GROUP_ORDER) {
1963
+ const items = groups[grp];
1964
+ if (!items || !items.length) continue;
1965
+ const gid = 'sg-' + grp.replace(/\s+/g, '-').toLowerCase();
1966
+ html += `<div class="sg-section"><div class="sg-header" onclick="toggleSessGroup('${gid}')">
1967
+ <span class="sg-title">${grp}</span><span class="sg-count">${items.length}</span><span class="sg-toggle">▾</span>
1968
+ </div><div class="sg-body" id="${gid}">`;
1969
+ for (const s of items) { html += renderSessRow(s, flatIdx++); }
1970
+ html += '</div></div>';
1971
+ }
1972
+ el.innerHTML = html;
1833
1973
  // prepend tag filter bar if there are common tags
1834
1974
  if (allTags.common.size > 1) {
1835
1975
  el.innerHTML = buildTagFilterBar(toShow) + el.innerHTML;
1836
1976
  }
1977
+ buildSessionHeatmap(sessions);
1837
1978
  buildDigest();
1838
1979
  buildWeeklyRecap();
1839
1980
  buildEfficiencyPanel();
1840
1981
  buildActivityHeatmap();
1841
1982
  buildTokenVelocity();
1983
+ buildDailyCostTrend();
1984
+ buildHourlyHeatmap();
1985
+ buildCostHistogram();
1842
1986
  if (leaderboardOpen) renderLeaderboard();
1987
+ syncURLParams();
1843
1988
  // check budget thresholds and fire toasts
1844
1989
  const todayCost = allSessions.filter(s => {
1845
1990
  const t = s.firstTs || s.mtime;
@@ -1888,10 +2033,10 @@ function toggleSessStarFilter() {
1888
2033
  showStarredOnly = !showStarredOnly;
1889
2034
  const btn = document.getElementById('sess-star-filter');
1890
2035
  btn.classList.toggle('on', showStarredOnly);
1891
- // re-render with filter
1892
2036
  viewRendered['sessions'] = false;
1893
2037
  renderSessions();
1894
2038
  viewRendered['sessions'] = true;
2039
+ syncURLParams();
1895
2040
  }
1896
2041
 
1897
2042
  // ── feature 1: auto-tags ───────────────────────────────────
@@ -1939,6 +2084,7 @@ function setTagFilter(tag) {
1939
2084
  viewRendered['sessions'] = false;
1940
2085
  renderSessions();
1941
2086
  viewRendered['sessions'] = true;
2087
+ syncURLParams();
1942
2088
  }
1943
2089
 
1944
2090
  // ── feature 2: session recap ───────────────────────────────
@@ -2680,6 +2826,42 @@ function toggleLoop(row) {
2680
2826
  row.classList.toggle('open');
2681
2827
  }
2682
2828
 
2829
+ function showLoopForm() {
2830
+ document.getElementById('loop-create-form').style.display = 'block';
2831
+ document.getElementById('btn-new-loop').style.display = 'none';
2832
+ document.getElementById('lcf-prompt').focus();
2833
+ }
2834
+
2835
+ function hideLoopForm() {
2836
+ document.getElementById('loop-create-form').style.display = 'none';
2837
+ document.getElementById('btn-new-loop').style.display = '';
2838
+ }
2839
+
2840
+ async function createLoop() {
2841
+ const prompt = document.getElementById('lcf-prompt').value.trim();
2842
+ if (!prompt) { showToast('Required', 'Prompt is required', 'warn'); return; }
2843
+ const name = document.getElementById('lcf-name').value.trim();
2844
+ const interval = document.getElementById('lcf-interval').value.trim() || '1h';
2845
+ const maxRepsVal = document.getElementById('lcf-maxreps').value;
2846
+ const maxReps = maxRepsVal ? parseInt(maxRepsVal) : null;
2847
+ try {
2848
+ const r = await fetch('/api/loops/create?dir=' + enc(DIR), {
2849
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2850
+ body: JSON.stringify({ name, prompt, interval, maxReps }),
2851
+ });
2852
+ const d = await r.json();
2853
+ if (!d.ok) { showToast('Error', d.error || 'Failed to create loop', 'err'); return; }
2854
+ showToast('Created', `Loop created: ${name || prompt.slice(0, 30)}`, 'ok');
2855
+ hideLoopForm();
2856
+ document.getElementById('lcf-prompt').value = '';
2857
+ document.getElementById('lcf-name').value = '';
2858
+ document.getElementById('lcf-interval').value = '1h';
2859
+ document.getElementById('lcf-maxreps').value = '';
2860
+ viewRendered['loops'] = false;
2861
+ renderLoops();
2862
+ } catch (err) { showToast('Error', err.message, 'err'); }
2863
+ }
2864
+
2683
2865
  // ── feature 19: cache efficiency panel ────────────────────
2684
2866
  function buildEfficiencyPanel() {
2685
2867
  const el = document.getElementById('m-efficiency');
@@ -2887,12 +3069,21 @@ async function renderMemory() {
2887
3069
  ).join('');
2888
3070
  }
2889
3071
 
2890
- const items = allDrawers.map((d, i) =>
2891
- `<div class="drawer-item" data-idx="${i}" data-ns="${esc(d.namespace || 'default')}">
3072
+ const tsValues = allDrawers.map(d => d.timestamp ? new Date(d.timestamp).getTime() : 0).filter(Boolean);
3073
+ const oldestTs = tsValues.length ? Math.min(...tsValues) : 0;
3074
+ const newestTs = tsValues.length ? Math.max(...tsValues) : 0;
3075
+ const tsRange = newestTs - oldestTs || 1;
3076
+ const items = allDrawers.map((d, i) => {
3077
+ const ts = d.timestamp ? new Date(d.timestamp).getTime() : 0;
3078
+ const agePct = ts && oldestTs ? Math.round(((ts - oldestTs) / tsRange) * 100) : 0;
3079
+ const ageBar = ts ? `<div class="dr-age-bar"><div class="dr-age-bar-fill" style="width:${agePct}%"></div></div>` : '';
3080
+ return `<div class="drawer-item" data-idx="${i}" data-ns="${esc(d.namespace || 'default')}">
2892
3081
  <div class="dr-key">${esc(d.key || d.namespace || '—')}</div>
2893
3082
  <div class="dr-val">${esc(String(d.value || d.text || '').slice(0, 300))}</div>
2894
3083
  ${d.timestamp ? `<div class="dr-ts">${relTime(d.timestamp)}</div>` : ''}
2895
- </div>`).join('');
3084
+ ${ageBar}
3085
+ </div>`;
3086
+ }).join('');
2896
3087
  html += `<div class="mem-section" id="drawers-section">
2897
3088
  <div class="mem-title">Drawers (${allDrawers.length})</div>
2898
3089
  <div id="drawers-list">${items}</div>
@@ -3357,7 +3548,6 @@ function updateCurrentActivity(events) {
3357
3548
  }
3358
3549
 
3359
3550
  // ── 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
3551
  let patternsOpen = false;
3362
3552
  function togglePatterns() {
3363
3553
  patternsOpen = !patternsOpen;
@@ -3454,14 +3644,169 @@ function checkBudgetToast(todayCost, monthCost) {
3454
3644
  }
3455
3645
  }
3456
3646
 
3647
+ // ── feature 39: cost period toggle ────────────────────────
3648
+ let activePeriod = 'day';
3649
+ let heatmapDateFilter = null;
3650
+
3651
+ function setPeriod(p) {
3652
+ activePeriod = p;
3653
+ document.querySelectorAll('.period-btn').forEach(b => b.classList.toggle('active', b.dataset.period === p));
3654
+ buildTokenVelocity();
3655
+ syncURLParams();
3656
+ }
3657
+
3658
+ function periodFilteredSessions() {
3659
+ const now = Date.now();
3660
+ const DAY = 86400000;
3661
+ const windows = { day: DAY, week: 7 * DAY, month: 30 * DAY, all: Infinity };
3662
+ const w = windows[activePeriod] || DAY;
3663
+ if (w === Infinity) return allSessions;
3664
+ return allSessions.filter(s => {
3665
+ const t = s.firstTs || s.mtime; if (!t) return false;
3666
+ const ts = typeof t === 'number' ? t : new Date(t).getTime();
3667
+ return (now - ts) <= w;
3668
+ });
3669
+ }
3670
+
3671
+ // ── feature 40: session heatmap ────────────────────────────
3672
+ function buildSessionHeatmap(sessions) {
3673
+ const el = document.getElementById('shm-grid');
3674
+ const wrap = document.getElementById('sess-heatmap');
3675
+ if (!el || !wrap || !sessions.length) return;
3676
+ wrap.style.display = 'block';
3677
+ const DAY = 86400000;
3678
+ const now = Date.now();
3679
+ const WEEKS = 12; const DAYS = WEEKS * 7;
3680
+ const buckets = new Array(DAYS).fill(null).map(() => ({ count:0, date:null }));
3681
+ for (let i = 0; i < DAYS; i++) {
3682
+ buckets[i].date = new Date(now - (DAYS - 1 - i) * DAY).toDateString();
3683
+ }
3684
+ for (const s of sessions) {
3685
+ const ts = s.lastTs || s.mtime; if (!ts) continue;
3686
+ const age = now - (typeof ts === 'number' ? ts : new Date(ts).getTime());
3687
+ const idx = DAYS - 1 - Math.floor(age / DAY);
3688
+ if (idx >= 0 && idx < DAYS) buckets[idx].count++;
3689
+ }
3690
+ const max = Math.max(...buckets.map(b => b.count), 1);
3691
+ el.innerHTML = buckets.map(b => {
3692
+ const level = b.count === 0 ? 0 : Math.min(4, Math.ceil(b.count / max * 4));
3693
+ const isActive = b.date === heatmapDateFilter;
3694
+ 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>`;
3695
+ }).join('');
3696
+ }
3697
+
3698
+ function setHeatmapFilter(dateStr, count) {
3699
+ if (!count) return;
3700
+ heatmapDateFilter = heatmapDateFilter === dateStr ? null : dateStr;
3701
+ const clearBtn = document.getElementById('shm-clear');
3702
+ if (clearBtn) clearBtn.classList.toggle('show', !!heatmapDateFilter);
3703
+ viewRendered['sessions'] = false;
3704
+ renderSessions();
3705
+ syncURLParams();
3706
+ }
3707
+
3708
+ function clearHeatmapFilter() {
3709
+ heatmapDateFilter = null;
3710
+ const clearBtn = document.getElementById('shm-clear');
3711
+ if (clearBtn) clearBtn.classList.remove('show');
3712
+ viewRendered['sessions'] = false;
3713
+ renderSessions();
3714
+ }
3715
+
3716
+ function toggleSessGroup(id) {
3717
+ const body = document.getElementById(id);
3718
+ if (!body) return;
3719
+ body.classList.toggle('collapsed');
3720
+ const hdr = body.previousElementSibling;
3721
+ if (hdr) { const tog = hdr.querySelector('.sg-toggle'); if (tog) tog.textContent = body.classList.contains('collapsed') ? '▸' : '▾'; }
3722
+ }
3723
+
3724
+ // ── feature 41: bulk session actions ───────────────────────
3725
+ let bulkSelected = new Set();
3726
+ let lastClickedSessIdx = null;
3727
+
3728
+ function handleSessRowClick(evt, row, sessId) {
3729
+ if (diffMode) { diffSelectSession(JSON.parse(row.dataset.sessData || '{}'), row); return; }
3730
+ if (evt.shiftKey && lastClickedSessIdx !== null) {
3731
+ // range-select
3732
+ const rows = [...document.querySelectorAll('#sess-content .sess-row')];
3733
+ const curIdx = rows.indexOf(row);
3734
+ const lo = Math.min(lastClickedSessIdx, curIdx);
3735
+ const hi = Math.max(lastClickedSessIdx, curIdx);
3736
+ for (let i = lo; i <= hi; i++) {
3737
+ const r = rows[i]; if (!r) continue;
3738
+ const id = r.dataset.sessId;
3739
+ if (id) { bulkSelected.add(id); r.classList.add('bulk-sel'); }
3740
+ }
3741
+ updateBulkToolbar();
3742
+ return;
3743
+ }
3744
+ if (bulkSelected.size > 0) {
3745
+ // toggle this row in bulk selection
3746
+ if (bulkSelected.has(sessId)) { bulkSelected.delete(sessId); row.classList.remove('bulk-sel'); }
3747
+ else { bulkSelected.add(sessId); row.classList.add('bulk-sel'); }
3748
+ const rows = [...document.querySelectorAll('#sess-content .sess-row')];
3749
+ lastClickedSessIdx = rows.indexOf(row);
3750
+ updateBulkToolbar();
3751
+ return;
3752
+ }
3753
+ lastClickedSessIdx = [...document.querySelectorAll('#sess-content .sess-row')].indexOf(row);
3754
+ jumpToSession(sessId);
3755
+ }
3756
+
3757
+ function updateBulkToolbar() {
3758
+ const tb = document.getElementById('bulk-toolbar');
3759
+ const cnt = document.getElementById('bulk-count');
3760
+ if (!tb) return;
3761
+ tb.classList.toggle('show', bulkSelected.size > 0);
3762
+ if (cnt) cnt.textContent = bulkSelected.size + ' selected';
3763
+ }
3764
+
3765
+ function clearBulkSelection() {
3766
+ bulkSelected.clear();
3767
+ document.querySelectorAll('#sess-content .sess-row.bulk-sel').forEach(r => r.classList.remove('bulk-sel'));
3768
+ updateBulkToolbar();
3769
+ }
3770
+
3771
+ function bulkBookmark() {
3772
+ for (const id of bulkSelected) bookmarks.add(id);
3773
+ localStorage.setItem('mm-bookmarks', JSON.stringify([...bookmarks]));
3774
+ clearBulkSelection();
3775
+ showToast('Bookmarked', `${bulkSelected.size || 'Selected'} sessions bookmarked`, 'ok');
3776
+ viewRendered['sessions'] = false;
3777
+ renderSessions();
3778
+ }
3779
+
3780
+ function bulkExport() {
3781
+ const toExport = allSessions.filter(s => bulkSelected.has(s.id));
3782
+ if (!toExport.length) return;
3783
+ const headers = ['Date', 'Session ID', 'Prompt', 'Cost ($)', 'Duration (s)', 'Tool Calls', 'Files Touched'];
3784
+ const rows = toExport.map(s => {
3785
+ const dt = new Date(s.firstTs || s.mtime || 0).toISOString().slice(0, 19).replace('T', ' ');
3786
+ const cost = typeof s.totalCost === 'number' ? s.totalCost.toFixed(4) : '';
3787
+ const dur = s.totalDurationMs ? Math.round(s.totalDurationMs / 1000) : '';
3788
+ const prompt = (s.lastPrompt || '').replace(/"/g, '""');
3789
+ const files = (s.filesTouched || []).join(';');
3790
+ return [dt, s.id, prompt, cost, dur, s.toolCalls || '', files];
3791
+ });
3792
+ const csv = [headers, ...rows].map(r => r.map(c => `"${c}"`).join(',')).join('\n');
3793
+ const blob = new Blob([csv], { type: 'text/csv' });
3794
+ const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
3795
+ a.download = `sessions-bulk-${new Date().toISOString().slice(0,10)}.csv`; a.click();
3796
+ URL.revokeObjectURL(a.href);
3797
+ showToast('Exported', `${toExport.length} sessions saved`, 'ok');
3798
+ clearBulkSelection();
3799
+ }
3800
+
3457
3801
  // ── feature 26: token velocity sparkline ──────────────────
3458
3802
  function buildTokenVelocity() {
3459
3803
  const el = document.getElementById('m-velocity');
3460
3804
  if (!el || !allSessions.length) return;
3461
3805
  const now = Date.now();
3462
3806
  const HOUR = 3600000;
3807
+ const filtered = periodFilteredSessions();
3463
3808
  const buckets = new Array(24).fill(0);
3464
- for (const s of allSessions) {
3809
+ for (const s of filtered) {
3465
3810
  const t = s.firstTs || s.mtime;
3466
3811
  if (!t) continue;
3467
3812
  const ts = typeof t === 'number' ? t : new Date(t).getTime();
@@ -3479,10 +3824,11 @@ function buildTokenVelocity() {
3479
3824
  const hrsAgo = 23 - i;
3480
3825
  return `<div class="vel-bar ${cls}" style="height:${h}px" title="${fmt(v)} tokens — ${hrsAgo}h ago"></div>`;
3481
3826
  }).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>
3827
+ const totalCost = filtered.reduce((a, s) => a + (s.totalCost || 0), 0);
3828
+ const periodLabel = { day:'24h', week:'7d', month:'30d', all:'all time' }[activePeriod] || '24h';
3829
+ 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
3830
  <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>`;
3831
+ <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
3832
  }
3487
3833
 
3488
3834
  // ── feature 27: export sessions CSV ───────────────────────
@@ -3584,6 +3930,254 @@ async function loadProjCosts() {
3584
3930
  }
3585
3931
  }
3586
3932
 
3933
+ // ── feature 47: 30-day daily cost trend ───────────────────
3934
+ function buildDailyCostTrend() {
3935
+ const el = document.getElementById('m-daily-trend');
3936
+ if (!el || !allSessions.length) return;
3937
+ const now = Date.now(); const DAY = 86400000;
3938
+ const buckets = new Array(30).fill(0);
3939
+ const dates = new Array(30).fill(null).map((_, i) => new Date(now - (29 - i) * DAY).toDateString());
3940
+ for (const s of allSessions) {
3941
+ const t = s.firstTs || s.mtime; if (!t) continue;
3942
+ const ts = typeof t === 'number' ? t : new Date(t).getTime();
3943
+ const daysAgo = Math.floor((now - ts) / DAY);
3944
+ const idx = 29 - daysAgo;
3945
+ if (idx >= 0 && idx < 30) buckets[idx] += s.totalCost || 0;
3946
+ }
3947
+ const maxCost = Math.max(0.001, ...buckets);
3948
+ const totalCost = buckets.reduce((a, b) => a + b, 0);
3949
+ const bars = buckets.map((v, i) => {
3950
+ const h = Math.max(2, Math.round((v / maxCost) * 38));
3951
+ const isActive = dates[i] === heatmapDateFilter;
3952
+ const label = i === 29 ? 'Today' : (i === 28 ? 'Yday' : '');
3953
+ return `<div class="dt-bar${isActive ? ' active' : ''}" style="height:${h}px" title="${dates[i]}: $${v.toFixed(3)}" onclick="setDailyTrendFilter('${dates[i]}',${v})"></div>`;
3954
+ }).join('');
3955
+ 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>
3956
+ <div id="daily-trend-chart">${bars}</div>
3957
+ <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>`;
3958
+ }
3959
+
3960
+ function setDailyTrendFilter(dateStr, cost) {
3961
+ if (!cost) return;
3962
+ heatmapDateFilter = heatmapDateFilter === dateStr ? null : dateStr;
3963
+ const clearBtn = document.getElementById('shm-clear');
3964
+ if (clearBtn) clearBtn.classList.toggle('show', !!heatmapDateFilter);
3965
+ viewRendered['sessions'] = false;
3966
+ renderSessions();
3967
+ syncURLParams();
3968
+ }
3969
+
3970
+ // ── feature 48: live cost ticker ──────────────────────────
3971
+ let _liveTickerCost = 0;
3972
+ let _liveTickerPrev = 0;
3973
+
3974
+ function updateLiveTicker(cost) {
3975
+ const el = document.getElementById('live-cost-ticker');
3976
+ if (!el) return;
3977
+ if (cost == null || cost === 0) { el.classList.remove('show'); return; }
3978
+ _liveTickerPrev = _liveTickerCost;
3979
+ _liveTickerCost = cost;
3980
+ const delta = _liveTickerCost - _liveTickerPrev;
3981
+ const deltaHtml = delta > 0.0001 ? `<span class="lct-change">+$${delta.toFixed(4)}</span>` : '';
3982
+ el.innerHTML = `$${cost.toFixed(3)}${deltaHtml}`;
3983
+ el.classList.add('show');
3984
+ }
3985
+
3986
+ // ── feature 49: hourly productivity heatmap ───────────────
3987
+ function buildHourlyHeatmap() {
3988
+ const el = document.getElementById('m-hourly-heatmap');
3989
+ if (!el || !allSessions.length) return;
3990
+ // 24 hours × 7 days-of-week matrix
3991
+ const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
3992
+ for (const s of allSessions) {
3993
+ const t = s.firstTs || s.mtime; if (!t) continue;
3994
+ const d = new Date(typeof t === 'number' ? t : t);
3995
+ const dow = d.getDay(); // 0=Sun
3996
+ const hour = d.getHours();
3997
+ grid[dow][hour]++;
3998
+ }
3999
+ const maxVal = Math.max(1, ...grid.flatMap(r => r));
4000
+ const dayNames = ['Su','Mo','Tu','We','Th','Fr','Sa'];
4001
+ let html = '<div id="hourly-heatmap-grid">';
4002
+ // Header row: empty corner + hour labels (0,6,12,18,23)
4003
+ html += '<div></div>';
4004
+ for (let h = 0; h < 24; h++) {
4005
+ const lbl = h % 6 === 0 ? String(h) : '';
4006
+ html += `<div class="hh-hour-lbl">${lbl}</div>`;
4007
+ }
4008
+ // Data rows
4009
+ for (let d = 0; d < 7; d++) {
4010
+ html += `<div class="hh-day-lbl">${dayNames[d]}</div>`;
4011
+ for (let h = 0; h < 24; h++) {
4012
+ const v = grid[d][h];
4013
+ const level = v === 0 ? 0 : Math.min(4, Math.ceil(v / maxVal * 4));
4014
+ html += `<div class="hh-cell hh-${level}" title="${dayNames[d]} ${h}:00 — ${v} session${v !== 1 ? 's' : ''}"></div>`;
4015
+ }
4016
+ }
4017
+ html += '</div>';
4018
+ const peakHour = grid.flatMap((r, d) => r.map((v, h) => ({ d, h, v }))).sort((a, b) => b.v - a.v)[0];
4019
+ const peakLabel = peakHour ? `${dayNames[peakHour.d]} ${peakHour.h}:00` : '';
4020
+ 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}`;
4021
+ }
4022
+
4023
+ // ── feature 50: custom tag editor ─────────────────────────
4024
+ const _customTagsKey = 'mm-custom-tags';
4025
+ let _customTagsMap = new Map(Object.entries(JSON.parse(localStorage.getItem(_customTagsKey) || '{}')));
4026
+
4027
+ function getCustomTags(sessId) {
4028
+ return _customTagsMap.get(sessId) || [];
4029
+ }
4030
+
4031
+ function saveCustomTags(sessId, tags) {
4032
+ if (tags.length === 0) _customTagsMap.delete(sessId);
4033
+ else _customTagsMap.set(sessId, tags);
4034
+ localStorage.setItem(_customTagsKey, JSON.stringify(Object.fromEntries(_customTagsMap)));
4035
+ }
4036
+
4037
+ function addCustomTag(sessId, tag, event) {
4038
+ if (event) event.stopPropagation();
4039
+ const t = tag.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
4040
+ if (!t) return;
4041
+ const tags = getCustomTags(sessId);
4042
+ if (!tags.includes(t)) { tags.push(t); saveCustomTags(sessId, tags); }
4043
+ const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
4044
+ if (wrap) wrap.outerHTML = renderCustomTagsInline(sessId, tags);
4045
+ // rebuild tag filter if this tag is new globally
4046
+ initTags(); buildTagFilterBar(allSessions);
4047
+ }
4048
+
4049
+ function removeCustomTag(sessId, tag, event) {
4050
+ if (event) event.stopPropagation();
4051
+ const tags = getCustomTags(sessId).filter(t => t !== tag);
4052
+ saveCustomTags(sessId, tags);
4053
+ const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
4054
+ if (wrap) wrap.outerHTML = renderCustomTagsInline(sessId, tags);
4055
+ }
4056
+
4057
+ function showCustomTagInput(sessId, event) {
4058
+ if (event) event.stopPropagation();
4059
+ const wrap = document.querySelector(`.sr-custom-tags[data-sess="${CSS.escape(sessId)}"]`);
4060
+ if (!wrap) return;
4061
+ const existing = wrap.querySelector('.ctag-input-wrap');
4062
+ if (existing) { existing.remove(); return; }
4063
+ const iw = document.createElement('div');
4064
+ iw.className = 'ctag-input-wrap';
4065
+ iw.onclick = e => e.stopPropagation();
4066
+ iw.innerHTML = `<input class="ctag-input" type="text" placeholder="tag name…" maxlength="20" autofocus>
4067
+ <button class="ctag-ok" onclick="(()=>{const inp=this.previousSibling;addCustomTag('${esc(sessId)}',inp.value,event);inp.value='';})()">Add</button>`;
4068
+ wrap.appendChild(iw);
4069
+ const inp = iw.querySelector('input');
4070
+ inp.focus();
4071
+ inp.addEventListener('keydown', e => {
4072
+ if (e.key === 'Enter') { addCustomTag(sessId, inp.value, e); inp.value = ''; }
4073
+ if (e.key === 'Escape') iw.remove();
4074
+ });
4075
+ }
4076
+
4077
+ function renderCustomTagsInline(sessId, tags) {
4078
+ const tagHtml = tags.map(t =>
4079
+ `<span class="sr-ctag">${esc(t)}<span class="ctag-del" onclick="removeCustomTag('${esc(sessId)}','${esc(t)}',event)" title="Remove tag">✕</span></span>`
4080
+ ).join('');
4081
+ return `<div class="sr-custom-tags" data-sess="${esc(sessId)}" onclick="event.stopPropagation()">
4082
+ ${tagHtml}
4083
+ <button class="ctag-add-btn" onclick="showCustomTagInput('${esc(sessId)}',event)" title="Add tag">+ tag</button>
4084
+ </div>`;
4085
+ }
4086
+
4087
+ // ── feature 51: tool error drawer ─────────────────────────
4088
+ async function toggleErrDrawer(sessId, event) {
4089
+ if (event) event.stopPropagation();
4090
+ const drawer = document.getElementById('err-drawer-' + sessId);
4091
+ if (!drawer) return;
4092
+ if (drawer.classList.contains('open')) { drawer.classList.remove('open'); drawer.innerHTML = ''; return; }
4093
+ drawer.classList.add('open');
4094
+ drawer.innerHTML = '<div class="err-drawer-head">Loading errors…</div>';
4095
+ try {
4096
+ const data = await apiFetch('/api/session-errors?dir=' + enc(DIR) + '&id=' + enc(sessId));
4097
+ if (!data.errors?.length) {
4098
+ drawer.innerHTML = `<div class="err-drawer-head"><span>No error details found</span><button class="err-close" onclick="toggleErrDrawer('${esc(sessId)}',event)">✕</button></div>`;
4099
+ return;
4100
+ }
4101
+ const items = data.errors.map(e => `<div class="err-item">${esc(e.text)}</div>`).join('');
4102
+ drawer.innerHTML = `<div class="err-drawer-head"><span>${data.errors.length} error${data.errors.length !== 1 ? 's' : ''}</span><button class="err-close" onclick="toggleErrDrawer('${esc(sessId)}',event)">✕</button></div>
4103
+ <div class="err-drawer-body">${items}</div>`;
4104
+ } catch (err) {
4105
+ drawer.innerHTML = `<div class="err-drawer-head"><span>Could not load: ${esc(err.message)}</span><button class="err-close" onclick="toggleErrDrawer('${esc(sessId)}',event)">✕</button></div>`;
4106
+ }
4107
+ }
4108
+
4109
+ // ── feature 52: prompt copy button ────────────────────────
4110
+ function copyPrompt(text, event) {
4111
+ if (event) event.stopPropagation();
4112
+ const btn = event?.currentTarget || event?.target;
4113
+ navigator.clipboard.writeText(text).then(() => {
4114
+ if (btn) { btn.textContent = '✓'; btn.classList.add('copied'); setTimeout(() => { btn.textContent = '⎘'; btn.classList.remove('copied'); }, 1500); }
4115
+ }).catch(() => {});
4116
+ }
4117
+
4118
+ // ── feature 53: session cost histogram ────────────────────
4119
+ function buildCostHistogram() {
4120
+ const el = document.getElementById('cost-histogram-panel');
4121
+ if (!el) return;
4122
+ const costs = allSessions.map(s => s.totalCost || 0).filter(c => c > 0);
4123
+ if (costs.length < 2) { el.style.display = 'none'; return; }
4124
+ el.style.display = 'block';
4125
+ const minC = Math.min(...costs); const maxC = Math.max(...costs);
4126
+ const BUCKETS = 10;
4127
+ const range = maxC - minC || 0.01;
4128
+ const bucketSize = range / BUCKETS;
4129
+ const counts = new Array(BUCKETS).fill(0);
4130
+ for (const c of costs) { const i = Math.min(BUCKETS - 1, Math.floor((c - minC) / bucketSize)); counts[i]++; }
4131
+ const maxCount = Math.max(1, ...counts);
4132
+ const fmt = v => v < 0.01 ? v.toFixed(4) : v < 1 ? '$' + v.toFixed(2) : '$' + v.toFixed(1);
4133
+ const bars = counts.map((n, i) => {
4134
+ const h = Math.max(2, Math.round((n / maxCount) * 46));
4135
+ const lo = minC + i * bucketSize; const hi = lo + bucketSize;
4136
+ return `<div class="ch-bar-wrap" title="${fmt(lo)}–${fmt(hi)}: ${n} session${n !== 1 ? 's' : ''}">
4137
+ <div class="ch-cnt">${n || ''}</div>
4138
+ <div class="ch-bar" style="height:${h}px"></div>
4139
+ <div class="ch-lbl">${i === 0 ? fmt(lo) : i === BUCKETS - 1 ? fmt(hi) : ''}</div>
4140
+ </div>`;
4141
+ }).join('');
4142
+ el.innerHTML = `<div class="ch-title">Cost Distribution — ${costs.length} sessions</div>
4143
+ <div class="ch-bars">${bars}</div>`;
4144
+ }
4145
+
4146
+ // ── feature 54: persistent filter URL params ──────────────
4147
+ function syncURLParams() {
4148
+ const p = new URLSearchParams(window.location.search);
4149
+ if (DIR) p.set('proj', DIR); else p.delete('proj');
4150
+ if (activePeriod && activePeriod !== 'day') p.set('period', activePeriod); else p.delete('period');
4151
+ if (activeTagFilter) p.set('tag', activeTagFilter); else p.delete('tag');
4152
+ if (heatmapDateFilter) p.set('date', heatmapDateFilter); else p.delete('date');
4153
+ if (showStarredOnly) p.set('starred', '1'); else p.delete('starred');
4154
+ const newUrl = window.location.pathname + (p.toString() ? '?' + p.toString() : '');
4155
+ history.replaceState(null, '', newUrl);
4156
+ }
4157
+
4158
+ function restoreURLParams() {
4159
+ const p = new URLSearchParams(window.location.search);
4160
+ const period = p.get('period');
4161
+ if (period && ['day','week','month','all'].includes(period)) {
4162
+ activePeriod = period;
4163
+ document.querySelectorAll('.period-btn').forEach(b => b.classList.toggle('active', b.dataset.period === period));
4164
+ }
4165
+ const tag = p.get('tag');
4166
+ if (tag) activeTagFilter = tag;
4167
+ const date = p.get('date');
4168
+ if (date) {
4169
+ heatmapDateFilter = date;
4170
+ const clearBtn = document.getElementById('shm-clear');
4171
+ if (clearBtn) clearBtn.classList.add('show');
4172
+ }
4173
+ const starred = p.get('starred');
4174
+ if (starred === '1') {
4175
+ showStarredOnly = true;
4176
+ const btn = document.getElementById('sess-star-filter');
4177
+ if (btn) btn.classList.add('on');
4178
+ }
4179
+ }
4180
+
3587
4181
  // ── helpers ────────────────────────────────────────────────
3588
4182
  function enc(s) { return encodeURIComponent(s); }
3589
4183
  function esc(s) {