pi-cost 0.1.1 → 0.2.0

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 (72) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +10 -1
  3. package/extensions/cost.ts +53 -83
  4. package/lib/app.js +113 -0
  5. package/lib/args.js +17 -0
  6. package/lib/cli.js +593 -0
  7. package/lib/cost-dir.js +62 -0
  8. package/lib/costs.js +799 -0
  9. package/lib/net-guard.js +178 -0
  10. package/lib/pi-parsers-cost.js +234 -66
  11. package/lib/pi-pricing.js +35 -8
  12. package/lib/themes.js +90 -0
  13. package/package.json +16 -7
  14. package/public/app.js +2232 -448
  15. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  16. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  17. package/public/fonts/fonts.css +244 -0
  18. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  19. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  20. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  21. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  22. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  23. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  24. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  25. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  26. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  27. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  28. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  29. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  30. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  31. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  32. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  33. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  34. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  35. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  36. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  37. package/public/index.html +68 -46
  38. package/public/style.css +319 -8
  39. package/public/sw.js +2 -2
  40. package/server.js +58 -660
  41. package/themes/ayu-dark.json +21 -0
  42. package/themes/ayu-light.json +21 -0
  43. package/themes/catppuccin-dark.json +21 -0
  44. package/themes/catppuccin-light.json +21 -0
  45. package/themes/dracula-dark.json +21 -0
  46. package/themes/dracula-light.json +21 -0
  47. package/themes/everforest-dark.json +21 -0
  48. package/themes/everforest-light.json +21 -0
  49. package/themes/github-dark.json +21 -0
  50. package/themes/github-light.json +21 -0
  51. package/themes/gruvbox-dark.json +21 -0
  52. package/themes/gruvbox-light.json +21 -0
  53. package/themes/kanagawa-dark.json +21 -0
  54. package/themes/kanagawa-light.json +21 -0
  55. package/themes/monokai-dark.json +21 -0
  56. package/themes/monokai-light.json +21 -0
  57. package/themes/night-owl-dark.json +21 -0
  58. package/themes/night-owl-light.json +21 -0
  59. package/themes/nord-dark.json +21 -0
  60. package/themes/nord-light.json +21 -0
  61. package/themes/one-dark-dark.json +21 -0
  62. package/themes/one-dark-light.json +21 -0
  63. package/themes/rose-pine-dark.json +21 -0
  64. package/themes/rose-pine-light.json +21 -0
  65. package/themes/solarized-dark.json +21 -0
  66. package/themes/solarized-light.json +21 -0
  67. package/themes/synthwave-dark.json +21 -0
  68. package/themes/synthwave-light.json +21 -0
  69. package/themes/tokyo-night-dark.json +21 -0
  70. package/themes/tokyo-night-light.json +21 -0
  71. package/themes/vitesse-dark.json +21 -0
  72. package/themes/vitesse-light.json +21 -0
package/public/app.js CHANGED
@@ -2,25 +2,24 @@
2
2
 
3
3
  let currentView = 'overview';
4
4
  let overviewData = null;
5
- let projectsData = null;
5
+ let insightsData = null;
6
6
  let sessionsData = null;
7
- let sessionsDaily = null;
7
+ let sessionsCostSeries = null;
8
8
  let sessionsModelDistribution = null;
9
- let currentProjectInfo = null;
10
9
  let sessionDetailData = null;
11
10
  let currentProjectDir = null;
12
11
  let currentProjectName = null;
13
12
  let currentSessionId = null;
14
- let parentView = 'projects';
15
13
  let lastSelectedProject = null;
16
14
  let lastSelectedSession = null;
17
15
  const DEFAULT_SORT = {
18
16
  overview: { field: 'totalCost', order: 'desc' },
19
- projects: { field: 'lastActive', order: 'desc' },
20
17
  sessions: { field: 'lastTimestamp', order: 'desc' },
21
18
  };
22
19
  const viewSort = structuredClone(DEFAULT_SORT);
23
- let dateRange = 3;
20
+ // A tagged union: a rolling preset the server recomputes per request, or a concrete window of
21
+ // two local dates. Persisted as JSON under pi-cost:range (see loadDateRange).
22
+ let dateRange = { kind: 'preset', value: '3' };
24
23
  const charts = {};
25
24
  let lastRenderHash = {};
26
25
  let navCounter = 0;
@@ -30,10 +29,46 @@ let selectedRowIdx = -1;
30
29
 
31
30
  // #region UTILS
32
31
 
32
+ // The div.textContent -> innerHTML trick escapes only & < > because it serializes for text
33
+ // context, so a bare " breaks out of any attribute it lands in.
34
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '`': '&#96;' };
35
+
33
36
  function esc(str) {
34
- const d = document.createElement('div');
35
- d.textContent = str == null ? '' : String(str);
36
- return d.innerHTML;
37
+ if (str == null) return '';
38
+ return String(str).replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c]);
39
+ }
40
+
41
+ // For a value inside a quoted JS string inside an HTML attribute: onclick="fn('${escAttrJs(x)}')".
42
+ // The browser HTML-decodes the attribute before the JS parser sees it, so the JS escape comes
43
+ // first. Windows paths need it: C:\Users\nikiforovall would lose \U \n as JS escapes.
44
+ function escAttrJs(value) {
45
+ const js = String(value == null ? '' : value)
46
+ .replace(/\\/g, '\\\\')
47
+ .replace(/'/g, "\\'")
48
+ .replace(/\r/g, '\\r')
49
+ .replace(/\n/g, '\\n');
50
+ return esc(js);
51
+ }
52
+
53
+ // Storage access throws in a sandboxed frame or with site data blocked; the page must still work.
54
+ function storageGet(kind, key) {
55
+ try {
56
+ return window[kind].getItem(key);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function storageSet(kind, key, value) {
63
+ try {
64
+ window[kind].setItem(key, value);
65
+ } catch {}
66
+ }
67
+
68
+ function storageRemove(kind, key) {
69
+ try {
70
+ window[kind].removeItem(key);
71
+ } catch {}
37
72
  }
38
73
 
39
74
  function basename(p) {
@@ -41,18 +76,6 @@ function basename(p) {
41
76
  return s.split(/[\\/]/).filter(Boolean).pop() || s;
42
77
  }
43
78
 
44
- // Escape for use inside a single-quoted JS string embedded in an HTML attribute.
45
- // Without this, Windows paths like C:\Users\nikiforovall lose \U \n \d as JS escapes.
46
- function jsStr(s) {
47
- return String(s ?? '')
48
- .replace(/\\/g, '\\\\')
49
- .replace(/'/g, "\\'")
50
- .replace(/&/g, '&amp;')
51
- .replace(/"/g, '&quot;')
52
- .replace(/</g, '&lt;')
53
- .replace(/>/g, '&gt;');
54
- }
55
-
56
79
  function formatCost(usd) {
57
80
  if (usd == null || usd === 0) return '$0.00';
58
81
  if (usd < 0.01) return '<$0.01';
@@ -67,6 +90,38 @@ function formatTokens(count) {
67
90
  return count.toString();
68
91
  }
69
92
 
93
+ function formatPct(part, total) {
94
+ return total > 0 ? ((part / total) * 100).toFixed(1) : '0.0';
95
+ }
96
+
97
+ // Intl puts U+202F before AM/PM, which reads as a stray gap in the mono font.
98
+ function stripNarrowSpace(str) {
99
+ return str.replace(/\u202f/g, ' ');
100
+ }
101
+
102
+ function localTime(iso, opts) {
103
+ return stripNarrowSpace(new Date(iso).toLocaleTimeString(undefined, opts));
104
+ }
105
+
106
+ function meter(pct, { label = '', cls = '', ariaLabel = '' } = {}) {
107
+ const width = Math.min(100, Math.max(0, Number(pct) || 0)).toFixed(1);
108
+ const klass = esc(cls ? `meter ${cls}` : 'meter');
109
+ return `<div class="${klass}" role="img" aria-label="${esc(ariaLabel)}">
110
+ <div class="meter-track"><i style="width:${width}%"></i></div>
111
+ ${label ? `<span class="meter-value">${esc(label)}</span>` : ''}
112
+ </div>`;
113
+ }
114
+
115
+ function formatDuration(minutes) {
116
+ if (!minutes || minutes < 1) return '<1m';
117
+ minutes = Math.round(minutes);
118
+ if (minutes < 60) return `${minutes}m`;
119
+ if (minutes >= 1440) return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`;
120
+ const h = Math.floor(minutes / 60);
121
+ const m = minutes % 60;
122
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
123
+ }
124
+
70
125
  function timeAgo(ts) {
71
126
  if (!ts) return '';
72
127
  const diff = Date.now() - new Date(ts).getTime();
@@ -80,9 +135,50 @@ function timeAgo(ts) {
80
135
  return new Date(ts).toLocaleDateString();
81
136
  }
82
137
 
83
- function shortDate(dateStr) {
84
- const d = new Date(`${dateStr}T00:00:00`);
85
- return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
138
+ // Bucket keys arrive as YYYY-MM-DD (day) or YYYY-MM-DDTHH (hour), in the server's local time.
139
+ function bucketLabel(key, bucket) {
140
+ if (bucket === 'hour') return localTime(`${key}:00:00`, { hour: 'numeric' });
141
+ return new Date(`${key}T00:00:00`).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
142
+ }
143
+
144
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
145
+
146
+ function isoDate(d) {
147
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
148
+ }
149
+
150
+ function todayIso() {
151
+ return isoDate(new Date());
152
+ }
153
+
154
+ function isoParts(iso) {
155
+ return iso.split('-').map(Number);
156
+ }
157
+
158
+ function isoShift(iso, days) {
159
+ const [y, m, d] = isoParts(iso);
160
+ return isoDate(new Date(y, m - 1, d + days));
161
+ }
162
+
163
+ // Inclusive, so a single day counts as 1. Midday avoids a DST shift landing on a boundary.
164
+ function isoDayCount(from, to) {
165
+ const [ay, am, ad] = isoParts(from);
166
+ const [by, bm, bd] = isoParts(to);
167
+ return Math.round((new Date(by, bm - 1, bd, 12) - new Date(ay, am - 1, ad, 12)) / 86400000) + 1;
168
+ }
169
+
170
+ // "AUG 12", with a two-digit year only outside the current one: "DEC 28 '25".
171
+ function isoDayLabel(iso) {
172
+ const [y] = isoParts(iso);
173
+ const yearTag = y === new Date().getFullYear() ? '' : ` '${String(y).slice(2)}`;
174
+ const dayLabel = new Date(`${iso}T00:00:00`)
175
+ .toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
176
+ .toUpperCase();
177
+ return `${dayLabel}${yearTag}`;
178
+ }
179
+
180
+ function costSeriesTitle(series) {
181
+ return series?.bucket === 'hour' ? 'Hourly Cost' : 'Daily Cost';
86
182
  }
87
183
 
88
184
  function shortId(id) {
@@ -105,7 +201,8 @@ function srcBadge(src) {
105
201
  }
106
202
 
107
203
  function sortCompare(a, b, field, order) {
108
- let va = a[field], vb = b[field];
204
+ let va = a[field],
205
+ vb = b[field];
109
206
  if (va == null) va = '';
110
207
  if (vb == null) vb = '';
111
208
  if (typeof va === 'string') va = va.toLowerCase();
@@ -115,34 +212,52 @@ function sortCompare(a, b, field, order) {
115
212
  return 0;
116
213
  }
117
214
 
118
- function currentSort() { return viewSort[currentView]; }
119
-
120
- function sortArrow(field) {
121
- const s = currentSort();
122
- if (!s || s.field !== field) return '';
123
- return `<span class="sort-arrow">${s.order === 'asc' ? '▲' : '▼'}</span>`;
215
+ function currentSort() {
216
+ return viewSort[currentView];
124
217
  }
125
218
 
126
- function thClass(field) {
219
+ function sortTh(field, label) {
127
220
  const s = currentSort();
128
- return s && s.field === field ? 'sorted' : '';
221
+ const on = s && s.field === field;
222
+ const arrow = on ? `<span class="sort-arrow">${s.order === 'asc' ? '▲' : '▼'}</span>` : '';
223
+ return `<th class="${on ? 'sorted' : ''}" onclick="sortBy('${escAttrJs(field)}')">${esc(label)} ${arrow}</th>`;
129
224
  }
130
225
 
131
226
  function parentBreadcrumb() {
132
- const label = parentView === 'overview' ? 'Overview' : 'Projects';
133
- return `<a class="parent-breadcrumb">${label}</a>`;
227
+ return '<a class="parent-breadcrumb">Overview</a>';
228
+ }
229
+
230
+ function projectsTable(rows, sort) {
231
+ return `<table class="data-table">
232
+ <thead><tr>
233
+ ${sortTh('name', 'Project')}
234
+ ${sortTh('totalCost', 'Cost')}
235
+ ${sortTh('sessions', 'Sessions')}
236
+ ${sortTh('lastActive', 'Last Active')}
237
+ <th>Model</th>
238
+ </tr></thead>
239
+ <tbody>
240
+ ${[...rows]
241
+ .sort((a, b) => sortCompare(a, b, sort.field, sort.order))
242
+ .map(
243
+ (p) => `
244
+ <tr data-clickable data-key="${esc(p.projectDir)}" onclick="navigateToSessions('${escAttrJs(p.projectDir)}','${escAttrJs(p.name)}')">
245
+ <td title="${esc(p.cwd)}">${esc(p.name)}</td>
246
+ <td class="cost-cell">${formatCost(p.totalCost)}</td>
247
+ <td>${p.sessions}</td>
248
+ <td class="muted">${timeAgo(p.lastActive)}</td>
249
+ <td><span class="model-badge">${esc(shortModel(p.primaryModel))}</span></td>
250
+ </tr>`,
251
+ )
252
+ .join('')}
253
+ </tbody>
254
+ </table>`;
134
255
  }
135
256
 
136
257
  function focusPreviousRow(view) {
137
- let selector;
138
- if (view === 'overview' || view === 'projects') {
139
- if (!lastSelectedProject) return;
140
- selector = `tr[data-key="${cssAttrEscape(lastSelectedProject)}"]`;
141
- } else if (view === 'sessions') {
142
- if (!lastSelectedSession) return;
143
- selector = `tr[data-key="${cssAttrEscape(lastSelectedSession)}"]`;
144
- }
145
- if (!selector) return;
258
+ const key = view === 'sessions' ? lastSelectedSession : view === 'overview' ? lastSelectedProject : null;
259
+ if (!key) return;
260
+ const selector = `tr[data-key="${CSS.escape(key)}"]`;
146
261
  requestAnimationFrame(() => {
147
262
  const rows = getVisibleRows();
148
263
  const idx = rows.findIndex((r) => r.matches(selector));
@@ -150,18 +265,17 @@ function focusPreviousRow(view) {
150
265
  });
151
266
  }
152
267
 
153
- function cssAttrEscape(s) {
154
- return String(s).replace(/"/g, '\\"');
155
- }
156
-
157
268
  // #endregion
158
269
 
159
270
  // #region URL_STATE
160
271
 
272
+ const TOP_VIEWS = ['overview', 'insights'];
273
+ const VIEWS = [...TOP_VIEWS, 'sessions', 'detail'];
274
+
161
275
  function getUrlState() {
162
276
  let p = new URLSearchParams(window.location.search);
163
277
  if (!p.has('view') && !p.has('session') && !p.has('project')) {
164
- const saved = sessionStorage.getItem('pi-cost:nav');
278
+ const saved = storageGet('sessionStorage', 'pi-cost:nav');
165
279
  if (saved) p = new URLSearchParams(saved);
166
280
  }
167
281
  return {
@@ -169,36 +283,135 @@ function getUrlState() {
169
283
  project: p.get('project'),
170
284
  projectName: p.get('projectName'),
171
285
  session: p.get('session'),
172
- parentView: p.get('parentView') || 'projects',
173
286
  };
174
287
  }
175
288
 
289
+ const DEFAULT_RANGE_PRESET = '3';
290
+
291
+ function presetRange(value) {
292
+ return { kind: 'preset', value: String(value) };
293
+ }
294
+
295
+ function presetDef(value) {
296
+ return RANGE_PRESETS.find((p) => p.value === String(value));
297
+ }
298
+
299
+ function isPresetValue(v) {
300
+ return !!presetDef(v);
301
+ }
302
+
303
+ function isCustomRange(r) {
304
+ return r?.kind === 'custom';
305
+ }
306
+
307
+ // The old '1 day' preset meant a rolling day, which is now '24h'.
308
+ function migrateStoredPreset(value) {
309
+ const s = String(value);
310
+ return s === '1' ? '24h' : s;
311
+ }
312
+
313
+ // Reads the JSON tagged union and the legacy bare "3" / "today" scalar that predates it.
314
+ function parseStoredRange(raw) {
315
+ if (!raw) return null;
316
+ let v;
317
+ try {
318
+ v = JSON.parse(raw);
319
+ } catch {
320
+ v = raw;
321
+ }
322
+ if (v?.kind === 'custom') {
323
+ return ISO_DATE_RE.test(v.from) && ISO_DATE_RE.test(v.to) && v.from <= v.to
324
+ ? { kind: 'custom', from: v.from, to: v.to }
325
+ : null;
326
+ }
327
+ const s = migrateStoredPreset(v?.kind === 'preset' ? v.value : v);
328
+ return isPresetValue(s) ? presetRange(s) : null;
329
+ }
330
+
176
331
  function loadDateRange() {
177
- const v = localStorage.getItem('pi-cost:range');
178
- if (!v) return 3;
179
- if (v === 'today') return 'today';
180
- return parseInt(v, 10) || 3;
332
+ return parseStoredRange(storageGet('localStorage', 'pi-cost:range')) || presetRange(DEFAULT_RANGE_PRESET);
181
333
  }
182
334
 
183
- function saveDateRange(val) { localStorage.setItem('pi-cost:range', String(val)); }
335
+ function saveDateRange(val) {
336
+ storageSet('localStorage', 'pi-cost:range', JSON.stringify(val));
337
+ }
338
+
339
+ // The range reaches the labels either as the client's tagged union or as the shape the server
340
+ // echoes on the session detail payload: 'today', '24h', N days, null for all time, or { from, to }.
341
+ function normalizeRange(r) {
342
+ if (r == null) return null;
343
+ if (typeof r === 'object') {
344
+ if (r.kind === 'preset' && isPresetValue(r.value)) return presetRange(r.value);
345
+ if (r.from && r.to) return { kind: 'custom', from: r.from, to: r.to };
346
+ return null;
347
+ }
348
+ if (isPresetValue(r)) return presetRange(r);
349
+ if (String(r) === '1') return presetRange('today');
350
+ const n = Number(r);
351
+ return Number.isFinite(n) && n > 0 ? presetRange(n) : null;
352
+ }
353
+
354
+ function rangeSpanLabel(r) {
355
+ return r.from === r.to ? isoDayLabel(r.to) : `${isoDayLabel(r.from)} - ${isoDayLabel(r.to)}`;
356
+ }
357
+
358
+ // One classification (all time, custom window, today, rolling preset) with one phrase per class.
359
+ function rangeText(r, prefix = {}) {
360
+ const n = normalizeRange(r);
361
+ const kind = !n ? 'all' : n.kind === 'custom' ? 'custom' : n.value === 'today' ? 'today' : 'rolling';
362
+ const base =
363
+ kind === 'all'
364
+ ? 'all time'
365
+ : kind === 'custom'
366
+ ? rangeSpanLabel(n)
367
+ : kind === 'today'
368
+ ? 'today'
369
+ : n.value === '24h'
370
+ ? '24 hours'
371
+ : `${n.value} days`;
372
+ return (prefix[kind] || '') + base;
373
+ }
374
+
375
+ const rangeLabel = (r) => rangeText(r);
376
+ const rangeScopeLabel = (r) => rangeText(r, { rolling: 'last ' });
377
+ const rangeInLabel = (r) => rangeText(r, { all: 'in ', custom: 'in ', rolling: 'in the last ' });
378
+
379
+ const rangeTriggerLabel = (r) => {
380
+ const n = normalizeRange(r) || presetRange(DEFAULT_RANGE_PRESET);
381
+ if (n.kind === 'custom') return rangeSpanLabel(n);
382
+ const def = presetDef(n.value);
383
+ return (def ? def.label : rangeText(n)).toUpperCase();
384
+ };
184
385
 
185
- function rangeLabel(r) { return r === 'today' ? 'today' : `${r} days`; }
386
+ const rangeCardLabel = (r) => {
387
+ const n = normalizeRange(r);
388
+ if (!n) return 'All Time';
389
+ if (n.kind === 'custom') return rangeSpanLabel(n);
390
+ const def = presetDef(n.value);
391
+ return def ? def.cardLabel || def.label : `${n.value} Days`;
392
+ };
393
+
394
+ // The window card would duplicate the Today card, so it is dropped.
395
+ function isTodayOnlyRange(r) {
396
+ const n = normalizeRange(r);
397
+ if (!n) return false;
398
+ return n.kind === 'custom' ? n.from === n.to && n.to === todayIso() : n.value === 'today';
399
+ }
186
400
 
187
401
  function loadSort() {
188
402
  for (const view of Object.keys(DEFAULT_SORT)) {
189
- const raw = localStorage.getItem(`pi-cost:sort:${view}`);
190
- if (raw) {
191
- try {
192
- const { field, order } = JSON.parse(raw);
193
- if (field) viewSort[view].field = field;
194
- if (order) viewSort[view].order = order;
195
- } catch {}
196
- }
403
+ const raw = storageGet('localStorage', `pi-cost:sort:${view}`);
404
+ if (!raw) continue;
405
+ try {
406
+ const { field, order } = JSON.parse(raw);
407
+ if (field) viewSort[view].field = field;
408
+ if (order) viewSort[view].order = order;
409
+ } catch {}
197
410
  }
198
411
  }
199
412
 
200
413
  function saveSort() {
201
- localStorage.setItem(`pi-cost:sort:${currentView}`, JSON.stringify(currentSort()));
414
+ storageSet('localStorage', `pi-cost:sort:${currentView}`, JSON.stringify(currentSort()));
202
415
  }
203
416
 
204
417
  function updateUrl(mode = 'push') {
@@ -207,46 +420,101 @@ function updateUrl(mode = 'push') {
207
420
  if (currentProjectDir) p.set('project', currentProjectDir);
208
421
  if (currentProjectName) p.set('projectName', currentProjectName);
209
422
  if (currentSessionId) p.set('session', currentSessionId);
210
- if (parentView !== 'projects') p.set('parentView', parentView);
423
+ if (EMBED) p.set('embed', '1');
211
424
  const qs = p.toString();
212
425
  const url = qs ? `?${qs}` : '/';
213
426
  const sameAsCurrent = (location.search || '') === (qs ? `?${qs}` : '');
214
427
  const method = mode === 'push' && !sameAsCurrent ? 'pushState' : 'replaceState';
215
428
  history[method](null, '', url);
216
- sessionStorage.setItem('pi-cost:nav', qs);
429
+ storageSet('sessionStorage', 'pi-cost:nav', qs);
217
430
  }
218
431
 
219
432
  // #endregion
220
433
 
221
434
  // #region FETCH
222
435
 
436
+ const BROWSER_CACHE_TTL = 60 * 1000;
437
+ const MAX_CACHE_ENTRIES = 20;
223
438
  let forceRefresh = false;
439
+ // Age of the data on screen, for the auto-refresh check. A cache hit carries its own timestamp.
440
+ let dataFetchedAt = 0;
441
+
442
+ // In memory on purpose: a restarted server may read another agent dir, and a cache that outlives
443
+ // the page would keep showing the old dir's data. The server's 30 s cache absorbs reload cost.
444
+ const dataCache = new Map();
445
+ const pendingFetches = new Map();
446
+
447
+ function getCached(key) {
448
+ if (forceRefresh) return null;
449
+ const hit = dataCache.get(key);
450
+ if (!hit) return null;
451
+ if (Date.now() - hit.ts > BROWSER_CACHE_TTL) {
452
+ dataCache.delete(key);
453
+ return null;
454
+ }
455
+ dataFetchedAt = Math.max(dataFetchedAt, hit.ts);
456
+ return hit.data;
457
+ }
458
+
459
+ function cacheData(key, data) {
460
+ dataCache.delete(key);
461
+ dataCache.set(key, { data, ts: Date.now() });
462
+ if (dataCache.size > MAX_CACHE_ENTRIES) dataCache.delete(dataCache.keys().next().value);
463
+ }
464
+
465
+ // A re-render can ask for a URL whose first request is still in flight (the board's theme message
466
+ // arrives during the first load), so it joins that request.
467
+ async function fetchJSON(url, skipCache) {
468
+ if (skipCache) return requestJSON(url);
469
+ const cached = getCached(url);
470
+ if (cached) return cached;
471
+ const pending = pendingFetches.get(url);
472
+ if (pending) return pending;
473
+ const request = requestJSON(url);
474
+ pendingFetches.set(url, request);
475
+ try {
476
+ const data = await request;
477
+ cacheData(url, data);
478
+ return data;
479
+ } finally {
480
+ pendingFetches.delete(url);
481
+ }
482
+ }
224
483
 
225
- async function fetchJSON(url) {
484
+ async function requestJSON(url) {
226
485
  const res = await fetch(url, forceRefresh ? { cache: 'reload' } : undefined);
227
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
228
- return res.json();
486
+ if (!res.ok) {
487
+ const err = new Error(`HTTP ${res.status}`);
488
+ err.status = res.status;
489
+ throw err;
490
+ }
491
+ const data = await res.json();
492
+ dataFetchedAt = Date.now();
493
+ return data;
494
+ }
495
+
496
+ // A custom window sends its two local dates; presets stay on the rolling `range` param.
497
+ function rangeParams(r = dateRange) {
498
+ return isCustomRange(r) ? `from=${r.from}&to=${r.to}` : `range=${encodeURIComponent(r.value)}`;
229
499
  }
230
500
 
231
501
  async function fetchOverview() {
232
- overviewData = await fetchJSON(`/api/overview?range=${dateRange}`);
502
+ overviewData = await fetchJSON(`/api/overview?${rangeParams()}`);
233
503
  }
234
504
 
235
- async function fetchProjects() {
236
- const data = await fetchJSON(`/api/projects?range=${dateRange}`);
237
- projectsData = data.projects;
505
+ async function fetchInsights() {
506
+ insightsData = await fetchJSON(`/api/insights?${rangeParams()}`);
238
507
  }
239
508
 
240
509
  async function fetchSessions(projectDir) {
241
- const data = await fetchJSON(`/api/projects/${encodeURIComponent(projectDir)}/sessions?range=${dateRange}`);
510
+ const data = await fetchJSON(`/api/projects/${encodeURIComponent(projectDir)}/sessions?${rangeParams()}`, true);
242
511
  sessionsData = data.sessions || [];
243
- sessionsDaily = data.daily || [];
512
+ sessionsCostSeries = data.costSeries || null;
244
513
  sessionsModelDistribution = data.modelDistribution || [];
245
- currentProjectInfo = data.project || null;
246
514
  }
247
515
 
248
516
  async function fetchSessionDetail(sessionId) {
249
- sessionDetailData = await fetchJSON(`/api/sessions/${encodeURIComponent(sessionId)}`);
517
+ sessionDetailData = await fetchJSON(`/api/sessions/${encodeURIComponent(sessionId)}?${rangeParams()}`, true);
250
518
  if (sessionDetailData && !currentProjectDir) {
251
519
  currentProjectDir = sessionDetailData.projectDir;
252
520
  currentProjectName = sessionDetailData.cwd;
@@ -255,13 +523,36 @@ async function fetchSessionDetail(sessionId) {
255
523
 
256
524
  // #endregion
257
525
 
526
+ // #region LOADING
527
+
528
+ let booted = false;
529
+ const SKEL_BARS = [35, 60, 45, 80, 55, 70, 40, 90, 65, 50, 75, 30];
530
+
531
+ function skeletonHtml(view) {
532
+ const line = '<span class="skel"></span>';
533
+ const cards = `<div class="cards-row">${'<div class="stat-card"><span class="skel card-label"></span><span class="skel card-value"></span><span class="skel card-sub"></span></div>'.repeat(4)}</div>`;
534
+ const plot = SKEL_BARS.map((h) => `<span class="skel" style="--h:${h}%"></span>`).join('');
535
+ const chartsRow = `<div class="charts-row">
536
+ <div class="chart-box"><span class="skel chart-title"></span><div class="skel-plot">${plot}</div></div>
537
+ <div class="chart-box"><span class="skel chart-title"></span><div class="skel-donut"></div></div>
538
+ </div>`;
539
+ const table = `<div class="skel-table">${`<div>${line.repeat(4)}</div>`.repeat(6)}</div>`;
540
+ const head = TOP_VIEWS.includes(view) ? '' : `<div class="breadcrumb">${line}</div>`;
541
+ const body = view === 'sessions' ? chartsRow + table : cards + chartsRow + table;
542
+ // The first JS skeleton replaces the static one from index.html; fading it in again would blink.
543
+ const fade = booted ? ' skeleton-fade' : '';
544
+ return `<div class="dashboard-content skeleton${esc(fade)}" aria-busy="true" aria-label="Loading">${head}${body}</div>`;
545
+ }
546
+
547
+ // #endregion
548
+
258
549
  // #region RENDER_OVERVIEW
259
550
 
260
551
  function renderOverview() {
261
552
  const el = document.getElementById('overview-view');
262
553
  if (!el) return;
263
554
  if (!overviewData) {
264
- el.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div><span>Loading...</span></div>';
555
+ el.innerHTML = skeletonHtml('overview');
265
556
  return;
266
557
  }
267
558
 
@@ -270,26 +561,38 @@ function renderOverview() {
270
561
  if (lastRenderHash.overview === h) return;
271
562
  lastRenderHash.overview = h;
272
563
 
273
- const daily = overviewData.daily || [];
564
+ const costSeries = overviewData.costSeries;
274
565
  const models = overviewData.modelDistribution || [];
275
566
  const projects = overviewData.projects || [];
276
567
  const meta = overviewData.pricingMeta || {};
277
568
  const sc = s.sourceCounts || {};
278
569
  const totalRows = (sc.jsonl || 0) + (sc.pricing || 0) + (sc.none || 0);
279
570
  const pctEst = totalRows ? Math.round(((sc.pricing || 0) / totalRows) * 100) : 0;
571
+ // null, not 0, when nothing cacheable ran: an empty range shows no meter at all.
572
+ const cachePct = s.totalCacheRead + s.totalCacheWrite > 0 ? s.cacheEfficiency * 100 : null;
280
573
 
281
574
  el.innerHTML = `
282
575
  <div class="dashboard-content">
283
576
  <div class="cards-row">
577
+ ${
578
+ // null when the window ends before today: a zero would read as "nothing spent today".
579
+ s.todayCost == null
580
+ ? ''
581
+ : `
284
582
  <div class="stat-card">
285
583
  <div class="card-label">Today</div>
286
584
  <div class="card-value cost">${formatCost(s.todayCost)}</div>
287
- </div>
288
- ${dateRange === 'today' ? '' : `
585
+ </div>`
586
+ }
587
+ ${
588
+ isTodayOnlyRange(dateRange)
589
+ ? ''
590
+ : `
289
591
  <div class="stat-card">
290
- <div class="card-label">${dateRange} Days</div>
592
+ <div class="card-label">${esc(rangeCardLabel(dateRange))}</div>
291
593
  <div class="card-value cost">${formatCost(s.totalCost)}</div>
292
- </div>`}
594
+ </div>`
595
+ }
293
596
  <div class="stat-card">
294
597
  <div class="card-label">Sessions</div>
295
598
  <div class="card-value">${s.totalSessions}</div>
@@ -301,8 +604,9 @@ function renderOverview() {
301
604
  </div>
302
605
  <div class="stat-card">
303
606
  <div class="card-label">Cache</div>
304
- <div class="card-value">${(s.cacheEfficiency * 100).toFixed(1)}%</div>
305
- <div class="card-sub">Read: ${formatTokens(s.totalCacheRead)} / Write: ${formatTokens(s.totalCacheWrite)}</div>
607
+ <div class="card-value">${cachePct == null ? '0.0' : cachePct.toFixed(1)}%</div>
608
+ ${cachePct == null ? '' : meter(cachePct, { ariaLabel: `${cachePct.toFixed(1)}% of all input tokens were served from cache` })}
609
+ <div class="card-sub">Read: <strong>${formatTokens(s.totalCacheRead)}</strong> / Write: ${formatTokens(s.totalCacheWrite)}</div>
306
610
  </div>
307
611
  <div class="stat-card">
308
612
  <div class="card-label">Cost Source</div>
@@ -316,8 +620,8 @@ function renderOverview() {
316
620
 
317
621
  <div class="charts-row">
318
622
  <div class="chart-box">
319
- <div class="chart-title">Daily Cost</div>
320
- <canvas id="dailyChart"></canvas>
623
+ <div class="chart-title">${costSeriesTitle(costSeries)}</div>
624
+ <canvas id="costChart"></canvas>
321
625
  </div>
322
626
  <div class="chart-box">
323
627
  <div class="chart-title">Cost by Model</div>
@@ -325,132 +629,268 @@ function renderOverview() {
325
629
  </div>
326
630
  </div>
327
631
 
328
- ${projects.length ? `
632
+ ${
633
+ projects.length
634
+ ? `
329
635
  <div class="section-title">Projects</div>
330
- <table class="data-table">
331
- <thead><tr>
332
- <th class="${thClass('name')}" onclick="sortBy('name')">Project ${sortArrow('name')}</th>
333
- <th class="${thClass('totalCost')}" onclick="sortBy('totalCost')">Cost ${sortArrow('totalCost')}</th>
334
- <th class="${thClass('sessions')}" onclick="sortBy('sessions')">Sessions ${sortArrow('sessions')}</th>
335
- <th class="${thClass('lastActive')}" onclick="sortBy('lastActive')">Last Active ${sortArrow('lastActive')}</th>
336
- <th>Model</th>
337
- </tr></thead>
338
- <tbody>
339
- ${[...projects]
340
- .sort((a, b) => sortCompare(a, b, viewSort.overview.field, viewSort.overview.order))
341
- .map((p) => `
342
- <tr data-clickable data-key="${esc(p.projectDir)}" onclick="navigateToSessions('${jsStr(p.projectDir)}','${jsStr(p.name)}')">
343
- <td title="${esc(p.cwd)}">${esc(p.name)}</td>
344
- <td class="cost-cell">${formatCost(p.totalCost)}</td>
345
- <td>${p.sessions}</td>
346
- <td class="muted">${timeAgo(p.lastActive)}</td>
347
- <td><span class="model-badge">${esc(shortModel(p.primaryModel))}</span></td>
348
- </tr>`).join('')}
349
- </tbody>
350
- </table>` : ''}
636
+ ${projectsTable(projects, viewSort.overview)}`
637
+ : `<div class="empty-state"><div>No usage ${esc(rangeInLabel(dateRange))}</div></div>`
638
+ }
351
639
  </div>`;
352
640
 
353
641
  requestAnimationFrame(() => {
354
- renderDailyChart(daily);
642
+ renderCostChart(costSeries, 'costChart', 'cost', models);
355
643
  renderModelChart(models);
356
644
  });
357
645
  }
358
646
 
359
647
  // #endregion
360
648
 
361
- // #region RENDER_PROJECTS
362
-
363
- function renderProjects() {
364
- const el = document.getElementById('projects-view');
365
- if (!el) return;
366
- if (!projectsData) {
367
- el.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div><span>Loading...</span></div>';
368
- return;
369
- }
649
+ // #region RENDER_INSIGHTS
650
+
651
+ const HEATMAP_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
652
+ // Columns start at 6 AM so the workday sits left of center instead of the dead early hours.
653
+ const HEATMAP_START_HOUR = 6;
654
+
655
+ function heatmapDayLabel(dateStr) {
656
+ const [y, m, d] = isoParts(dateStr);
657
+ return `${HEATMAP_DAYS[new Date(y, m - 1, d).getDay()]} ${d}`;
658
+ }
659
+
660
+ function ampm(h) {
661
+ if (h === 0) return '12am';
662
+ if (h === 12) return '12pm';
663
+ return h < 12 ? `${h}am` : `${h - 12}pm`;
664
+ }
665
+
666
+ // Post-midnight columns sit at each row's tail and read from the next calendar day: the night
667
+ // belongs to the evening it continues.
668
+ function buildHeatmap(hm, days, today) {
669
+ const rowCosts = hm.map((row, d) => [
670
+ ...row.slice(HEATMAP_START_HOUR),
671
+ ...(hm[d + 1] ? hm[d + 1].slice(0, HEATMAP_START_HOUR) : new Array(HEATMAP_START_HOUR).fill(0)),
672
+ ]);
673
+ const max = rowCosts.reduce((m, row) => Math.max(m, ...row), 0);
674
+ if (max === 0) return '<div class="empty-state"><div>No activity in this range</div></div>';
675
+ const labels = days.map(heatmapDayLabel);
676
+ const hourAt = (col) => (col + HEATMAP_START_HOUR) % 24;
677
+ const hours = Array.from(
678
+ { length: 24 },
679
+ (_, col) => `<span class="hm-hour">${hourAt(col) % 3 === 0 ? ampm(hourAt(col)) : ''}</span>`,
680
+ ).join('');
681
+ const rows = hm
682
+ .map((_, d) => {
683
+ const label = labels[d];
684
+ return `
685
+ <div class="hm-row${days[d] === today ? ' hm-today' : ''}">
686
+ <span class="hm-day">${esc(label)}</span>
687
+ ${rowCosts[d]
688
+ .map((cost, col) => {
689
+ const h = hourAt(col);
690
+ const day = h < HEATMAP_START_HOUR ? (labels[d + 1] ?? label) : label;
691
+ return `
692
+ <span class="hm-cell" data-hm="${esc(`${day}|${h}|${cost}`)}">
693
+ <i style="opacity:${cost > 0 ? Math.max(0.12, cost / max).toFixed(2) : 0}"></i>
694
+ </span>`;
695
+ })
696
+ .join('')}
697
+ </div>`;
698
+ })
699
+ .join('');
700
+ return `<div class="heatmap">${rows}<div class="hm-row"><span class="hm-day"></span>${hours}</div></div>`;
701
+ }
702
+
703
+ let hmTipEl = null;
704
+ function initHeatmapTooltip() {
705
+ if (hmTipEl) return;
706
+ hmTipEl = document.createElement('div');
707
+ hmTipEl.className = 'hm-tip';
708
+ document.body.appendChild(hmTipEl);
709
+ document.addEventListener('mouseover', (e) => {
710
+ const cell = e.target.closest('.hm-cell[data-hm]');
711
+ if (!cell) {
712
+ hmTipEl.classList.remove('on');
713
+ return;
714
+ }
715
+ const [day, h, cost] = cell.dataset.hm.split('|');
716
+ hmTipEl.innerHTML = `<b>${formatCost(+cost)}</b><span>${esc(day)} ${ampm(+h)}&ndash;${ampm((+h + 1) % 24)}</span>`;
717
+ hmTipEl.classList.add('on');
718
+ const r = cell.getBoundingClientRect();
719
+ const tr = hmTipEl.getBoundingClientRect();
720
+ const left = Math.max(8, Math.min(r.left + r.width / 2 - tr.width / 2, window.innerWidth - tr.width - 8));
721
+ const top = r.top - tr.height - 6;
722
+ hmTipEl.style.left = `${left}px`;
723
+ hmTipEl.style.top = `${top < 4 ? r.bottom + 6 : top}px`;
724
+ });
725
+ }
370
726
 
371
- const h = JSON.stringify({ projectsData, sort: viewSort.projects });
372
- if (lastRenderHash.projects === h) return;
373
- lastRenderHash.projects = h;
727
+ function blockSpanLabel(b) {
728
+ const opts = { month: 'short', day: 'numeric', hour: 'numeric' };
729
+ return `${stripNarrowSpace(new Date(b.start).toLocaleString(undefined, opts))} – ${localTime(b.end, { hour: 'numeric' })}`;
730
+ }
374
731
 
375
- const sorted = [...projectsData].sort((a, b) => sortCompare(a, b, viewSort.projects.field, viewSort.projects.order));
732
+ // pi records no plan limits, so a block is only open (its 5 h window has not ended) or ended.
733
+ function blockStatus(b) {
734
+ const left = (new Date(b.end) - Date.now()) / 60000;
735
+ return left > 0
736
+ ? `<span class="block-badge active">open &middot; ${formatDuration(left)} left</span>`
737
+ : `<span class="block-badge">ended ${timeAgo(b.lastActivity)}</span>`;
738
+ }
376
739
 
377
- if (sorted.length === 0) {
378
- el.innerHTML = `<div class="dashboard-content"><div class="empty-state">
379
- <div class="empty-icon">$</div>
380
- <div>No project data found</div>
381
- <div>Make sure pi session files exist in ~/.pi/agent/sessions/</div>
382
- </div></div>`;
740
+ function renderInsights() {
741
+ const el = document.getElementById('insights-view');
742
+ if (!el) return;
743
+ if (!insightsData) {
744
+ el.innerHTML = skeletonHtml('insights');
383
745
  return;
384
746
  }
385
747
 
748
+ const d = insightsData;
749
+ const h = JSON.stringify(d);
750
+ if (lastRenderHash.insights === h) return;
751
+ lastRenderHash.insights = h;
752
+
753
+ const sub = d.subagents;
754
+ const agentTotal = sub.mainCost + sub.subagentCost;
755
+ const subPct = formatPct(sub.subagentCost, agentTotal);
756
+ const cacheHitPct = d.tokens.cacheRead + d.tokens.cacheWrite > 0 ? Math.round(d.cacheEfficiency * 100) : null;
757
+ const comp = d.compactions || { count: 0, totalCost: 0, tokensBefore: 0 };
758
+ const showWeekly = d.weekly.length >= 3;
759
+ const heatSpan = d.heatmapDays.length
760
+ ? `${isoDayLabel(d.heatmapDays[0])} &rarr; ${isoDayLabel(d.heatmapDays[d.heatmapDays.length - 1])}`
761
+ : '';
762
+
386
763
  el.innerHTML = `
387
764
  <div class="dashboard-content">
388
- <div class="section-title">All Projects</div>
765
+ <div class="cards-row">
766
+ <div class="stat-card">
767
+ <div class="card-label">Monthly Run-Rate</div>
768
+ <div class="card-value cost">${formatCost(d.runRate.projectedMonthly)}</div>
769
+ <div class="card-sub"><strong>${formatCost(d.runRate.dailyAvg)}/day</strong> over ${d.runRate.days} ${d.runRate.days === 1 ? 'day' : 'days'}</div>
770
+ </div>
771
+ <div class="stat-card">
772
+ <div class="card-label">Cache Savings</div>
773
+ <div class="card-value cost">${formatCost(d.cacheSavings)}</div>
774
+ ${cacheHitPct == null ? '' : meter(cacheHitPct, { label: `${cacheHitPct}% hit`, ariaLabel: `${cacheHitPct}% of input was served from cache` })}
775
+ <div class="card-sub">vs. uncached input &middot; <strong>${formatTokens(d.tokens.cacheRead)}</strong> reads</div>
776
+ </div>
777
+ <div class="stat-card">
778
+ <div class="card-label">Subagent Share</div>
779
+ <div class="card-value">${subPct}%</div>
780
+ ${agentTotal > 0 ? meter(subPct, { ariaLabel: `subagents are ${subPct}% of ${formatCost(agentTotal)}` }) : ''}
781
+ <div class="card-sub"><strong>${formatCost(sub.subagentCost)}</strong> of ${formatCost(agentTotal)} &middot; ${sub.runs} ${sub.runs === 1 ? 'run' : 'runs'}</div>
782
+ </div>
783
+ <div class="stat-card">
784
+ <div class="card-label">Compactions</div>
785
+ <div class="card-value">${comp.count}</div>
786
+ <div class="card-sub">summarizer <strong>${formatCost(comp.totalCost)}</strong> &middot; ${formatTokens(comp.tokensBefore)} tokens replaced</div>
787
+ </div>
788
+ </div>
789
+
790
+ <div class="charts-row">
791
+ <div class="chart-box">
792
+ <div class="chart-title">Token Composition <span class="chart-title-note">log scale</span></div>
793
+ <canvas id="tokenSplitChart"></canvas>
794
+ </div>
795
+ <div class="chart-box">
796
+ <div class="chart-title">Top Tools</div>
797
+ ${d.tools.length ? '<canvas id="toolsChart"></canvas>' : '<div class="card-sub">No tool calls in this range</div>'}
798
+ </div>
799
+ </div>
800
+
801
+ <div class="charts-row${showWeekly ? '' : ' single'}">
802
+ <div class="chart-box">
803
+ <div class="chart-title">Activity Heatmap (cost by hour) <span class="chart-title-note">${heatSpan}</span></div>
804
+ ${buildHeatmap(d.heatmap, d.heatmapDays, d.heatmapToday)}
805
+ </div>
806
+ ${
807
+ showWeekly
808
+ ? `
809
+ <div class="chart-box">
810
+ <div class="chart-title">Weekly Cost</div>
811
+ <canvas id="weeklyChart"></canvas>
812
+ </div>`
813
+ : ''
814
+ }
815
+ </div>
816
+
817
+ ${
818
+ d.blocks.length
819
+ ? `
820
+ <div class="section-title">5-Hour Blocks</div>
389
821
  <table class="data-table">
390
822
  <thead><tr>
391
- <th class="${thClass('name')}" onclick="sortBy('name')">Project ${sortArrow('name')}</th>
392
- <th class="${thClass('totalCost')}" onclick="sortBy('totalCost')">Cost ${sortArrow('totalCost')}</th>
393
- <th class="${thClass('sessions')}" onclick="sortBy('sessions')">Sessions ${sortArrow('sessions')}</th>
394
- <th class="${thClass('totalTokens')}" onclick="sortBy('totalTokens')">Tokens ${sortArrow('totalTokens')}</th>
395
- <th class="${thClass('lastActive')}" onclick="sortBy('lastActive')">Last Active ${sortArrow('lastActive')}</th>
396
- <th>Model</th>
823
+ <th>Block</th><th>Status</th><th>Messages</th><th>Tokens</th><th>Cost</th><th>Models</th>
397
824
  </tr></thead>
398
825
  <tbody>
399
- ${sorted.map((p) => `
400
- <tr data-clickable data-key="${esc(p.projectDir)}" onclick="navigateToSessions('${jsStr(p.projectDir)}','${jsStr(p.name)}')">
401
- <td title="${esc(p.cwd)}">${esc(p.name)}</td>
402
- <td class="cost-cell">${formatCost(p.totalCost)}</td>
403
- <td>${p.sessions}</td>
404
- <td>${formatTokens(p.totalTokens)}</td>
405
- <td class="muted">${timeAgo(p.lastActive)}</td>
406
- <td><span class="model-badge">${esc(shortModel(p.primaryModel))}</span></td>
407
- </tr>`).join('')}
826
+ ${d.blocks
827
+ .map(
828
+ (b) => `
829
+ <tr>
830
+ <td>${blockSpanLabel(b)}</td>
831
+ <td>${blockStatus(b)}</td>
832
+ <td>${b.messageCount}</td>
833
+ <td>${formatTokens(b.tokens)}</td>
834
+ <td class="cost-cell">${formatCost(b.cost)}</td>
835
+ <td>${b.models.map((m) => `<span class="model-badge">${esc(shortModel(m))}</span>`).join(' ')}</td>
836
+ </tr>`,
837
+ )
838
+ .join('')}
408
839
  </tbody>
409
- </table>
840
+ </table>`
841
+ : ''
842
+ }
410
843
  </div>`;
844
+
845
+ initHeatmapTooltip();
846
+ requestAnimationFrame(() => {
847
+ renderTokenSplitChart(d.tokens);
848
+ if (d.tools.length) renderToolsChart(d.tools);
849
+ if (showWeekly) renderWeeklyChart(d.weekly);
850
+ });
411
851
  }
412
852
 
413
853
  // #endregion
414
854
 
415
855
  // #region RENDER_SESSIONS
416
856
 
857
+ function sessionBreadcrumb() {
858
+ return `<div class="breadcrumb">
859
+ ${parentBreadcrumb()}
860
+ <span class="sep">/</span>
861
+ <span class="current" title="${esc(currentProjectName || '')}">${esc(basename(currentProjectName) || 'Project')}</span>
862
+ </div>`;
863
+ }
864
+
417
865
  function renderSessions() {
418
866
  const el = document.getElementById('sessions-view');
419
867
  if (!el) return;
420
868
  if (!sessionsData) {
421
- el.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div><span>Loading...</span></div>';
869
+ el.innerHTML = skeletonHtml('sessions');
422
870
  return;
423
871
  }
424
872
 
425
- const h = JSON.stringify({ sessionsData, sessionsDaily, sessionsModelDistribution, sort: viewSort.sessions });
873
+ const h = JSON.stringify({ sessionsData, sessionsCostSeries, sessionsModelDistribution, sort: viewSort.sessions });
426
874
  if (lastRenderHash.sessions === h) return;
427
875
  lastRenderHash.sessions = h;
428
876
 
429
- const total = sessionsData.reduce((s, x) => s + (x.totalCost || 0), 0);
430
-
431
877
  if (sessionsData.length === 0) {
432
878
  el.innerHTML = `<div class="dashboard-content">
433
- <div class="breadcrumb">
434
- ${parentBreadcrumb()}
435
- <span class="sep">/</span>
436
- <span class="current" title="${esc(currentProjectName || '')}">${esc(basename(currentProjectName) || 'Project')}</span>
437
- </div>
438
- <div class="empty-state"><div>No sessions found for this project</div></div>
879
+ ${sessionBreadcrumb()}
880
+ <div class="empty-state"><div>No sessions for this project ${esc(rangeInLabel(dateRange))}</div></div>
439
881
  </div>`;
440
882
  return;
441
883
  }
442
884
 
885
+ const total = sessionsData.reduce((s, x) => s + (x.totalCost || 0), 0);
886
+
443
887
  el.innerHTML = `
444
888
  <div class="dashboard-content">
445
- <div class="breadcrumb">
446
- ${parentBreadcrumb()}
447
- <span class="sep">/</span>
448
- <span class="current" title="${esc(currentProjectName || '')}">${esc(basename(currentProjectName) || 'Project')}</span>
449
- </div>
450
- <div class="charts-row" style="grid-template-columns:3fr 2fr auto">
889
+ ${sessionBreadcrumb()}
890
+ <div class="charts-row with-aside">
451
891
  <div class="chart-box">
452
- <div class="chart-title">Daily Cost</div>
453
- <canvas id="sessionsDailyChart"></canvas>
892
+ <div class="chart-title">${costSeriesTitle(sessionsCostSeries)}</div>
893
+ <canvas id="sessionsCostChart"></canvas>
454
894
  </div>
455
895
  <div class="chart-box">
456
896
  <div class="chart-title">Cost by Model</div>
@@ -465,41 +905,57 @@ function renderSessions() {
465
905
 
466
906
  <table class="data-table">
467
907
  <thead><tr>
468
- <th class="${thClass('customTitle')}" onclick="sortBy('customTitle')">Session ${sortArrow('customTitle')}</th>
469
- <th class="${thClass('totalCost')}" onclick="sortBy('totalCost')">Cost ${sortArrow('totalCost')}</th>
470
- <th class="${thClass('totalTokens')}" onclick="sortBy('totalTokens')">Tokens ${sortArrow('totalTokens')}</th>
471
- <th class="${thClass('messageCount')}" onclick="sortBy('messageCount')">Msgs ${sortArrow('messageCount')}</th>
908
+ ${sortTh('customTitle', 'Session')}
909
+ ${sortTh('totalCost', 'Cost')}
910
+ ${sortTh('totalTokens', 'Tokens')}
911
+ ${sortTh('messageCount', 'Msgs')}
912
+ ${sortTh('durationMinutes', 'Duration')}
472
913
  <th>Provider</th>
473
914
  <th>Model</th>
474
- <th class="${thClass('lastTimestamp')}" onclick="sortBy('lastTimestamp')">Last Active ${sortArrow('lastTimestamp')}</th>
915
+ ${sortTh('lastTimestamp', 'Last Active')}
475
916
  </tr></thead>
476
917
  <tbody>
477
918
  ${[...sessionsData]
478
919
  .sort((a, b) => sortCompare(a, b, viewSort.sessions.field, viewSort.sessions.order))
479
- .map((s) => {
480
- const label = s.customTitle || shortId(s.id);
481
- const preview = s.firstUserText ? s.firstUserText.slice(0, 60) : '';
482
- return `
483
- <tr data-clickable data-key="${esc(s.id)}" onclick="navigateToDetail('${esc(s.id)}')">
484
- <td class="truncate" title="${esc(s.id)}${preview ? ` — ${esc(s.firstUserText || '')}` : ''}"><code class="session-id">${esc(label)}</code>${preview ? ` <span class="muted">${esc(preview)}${s.firstUserText && s.firstUserText.length > 60 ? '…' : ''}</span>` : ''}${s.parentSession ? ' <span class="subagent-tag">subagent</span>' : ''}${s.subagentCount ? ` <span class="subagent-tag" title="${s.subagentCount} spawned subagent(s) totaling ${formatCost(s.subagentCost)}">+${s.subagentCount}</span>` : ''}</td>
485
- <td class="cost-cell">${formatCost(s.totalCost)}</td>
486
- <td>${formatTokens(s.totalTokens)}</td>
487
- <td>${s.messageCount}</td>
488
- <td class="muted">${esc(s.provider || '')}</td>
489
- <td><span class="model-badge">${esc(shortModel(s.model))}</span></td>
490
- <td class="muted">${timeAgo(s.lastTimestamp)}</td>
491
- </tr>`;
492
- }).join('')}
920
+ .map(sessionRow)
921
+ .join('')}
493
922
  </tbody>
494
923
  </table>
495
924
  </div>`;
496
925
 
497
926
  requestAnimationFrame(() => {
498
- renderDailyChart(sessionsDaily || [], 'sessionsDailyChart', 'sessionsDaily');
499
- renderModelChart(sessionsModelDistribution || [], 'sessionsModelChart', 'sessionsModel');
927
+ renderCostChart(sessionsCostSeries, 'sessionsCostChart', 'sessionsCost', sessionsModelDistribution);
928
+ renderModelChart(sessionsModelDistribution, 'sessionsModelChart', 'sessionsModel');
500
929
  });
501
930
  }
502
931
 
932
+ function sessionRow(s) {
933
+ const label = s.customTitle || shortId(s.id);
934
+ const preview = s.firstUserText ? s.firstUserText.slice(0, 60) : '';
935
+ const more = s.firstUserText && s.firstUserText.length > 60 ? '…' : '';
936
+ const title = preview ? `${s.id} — ${s.firstUserText}` : s.id;
937
+ const c = s.compactions;
938
+ const compactTag = c?.count
939
+ ? ` <span class="compaction-tag" title="${esc(`${c.count} compaction(s), ${formatCost(c.totalCost)}`)}">${c.count}&times; compact</span>`
940
+ : '';
941
+ // Nested subagent runs never reach the lists, so a listed row with a parent is a fork.
942
+ const subTag = s.parentSession ? ' <span class="subagent-tag" title="Forked from another session">fork</span>' : '';
943
+ const childTag = s.subagentCount
944
+ ? ` <span class="subagent-tag" title="${esc(`${s.subagentCount} spawned subagent(s) totaling ${formatCost(s.subagentCost)}`)}">+${s.subagentCount}</span>`
945
+ : '';
946
+ return `
947
+ <tr data-clickable data-key="${esc(s.id)}" onclick="navigateToDetail('${escAttrJs(s.id)}')">
948
+ <td class="truncate" title="${esc(title)}"><code class="session-id">${esc(label)}</code>${preview ? ` <span class="muted">${esc(preview)}${more}</span>` : ''}${subTag}${childTag}</td>
949
+ <td class="cost-cell">${formatCost(s.totalCost)}</td>
950
+ <td>${formatTokens(s.totalTokens)}</td>
951
+ <td>${s.messageCount}${compactTag}</td>
952
+ <td class="muted">${formatDuration(s.durationMinutes)}</td>
953
+ <td class="muted">${esc(s.provider || '')}</td>
954
+ <td><span class="model-badge">${esc(shortModel(s.primaryModel || s.model))}</span></td>
955
+ <td class="muted">${timeAgo(s.lastTimestamp)}</td>
956
+ </tr>`;
957
+ }
958
+
503
959
  // #endregion
504
960
 
505
961
  // #region RENDER_DETAIL
@@ -508,7 +964,7 @@ function renderDetail() {
508
964
  const el = document.getElementById('detail-view');
509
965
  if (!el) return;
510
966
  if (!sessionDetailData) {
511
- el.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div><span>Loading...</span></div>';
967
+ el.innerHTML = skeletonHtml('detail');
512
968
  return;
513
969
  }
514
970
 
@@ -521,48 +977,52 @@ function renderDetail() {
521
977
  const projectCwd = d.cwd || currentProjectName || projectDir;
522
978
  const projectName = basename(projectCwd);
523
979
  const sc = d.sourceCounts || {};
980
+ const hasSubagents = d.messages.some((m) => m.subagent);
981
+ const parentLink = d.parent
982
+ ? `<a class="truncate" onclick="navigateToDetail('${escAttrJs(d.parent.id)}')" title="${esc(d.parent.title || d.parent.id)}">${esc(d.parent.title || shortId(d.parent.id))}</a>
983
+ <span class="sep">/</span>`
984
+ : '';
524
985
 
525
986
  el.innerHTML = `
526
987
  <div class="dashboard-content">
527
988
  <div class="breadcrumb">
528
989
  ${parentBreadcrumb()}
529
990
  <span class="sep">/</span>
530
- <a onclick="navigateToSessions('${jsStr(projectDir)}','${jsStr(projectName)}')" title="${esc(projectCwd)}">${esc(projectName)}</a>
991
+ <a onclick="navigateToSessions('${escAttrJs(projectDir)}','${escAttrJs(projectName)}')" title="${esc(projectCwd)}">${esc(projectName)}</a>
531
992
  <span class="sep">/</span>
993
+ ${parentLink}
532
994
  <span class="current" title="${esc(d.id || '')}"><code class="session-id">${esc(d.customTitle || shortId(d.id))}</code></span>
533
995
  </div>
534
996
 
535
- ${d.firstUserText ? `<div style="color:var(--text-tertiary);font-size:12px;margin-bottom:16px;font-style:italic">"${esc(d.firstUserText.slice(0, 240))}${d.firstUserText.length > 240 ? '...' : ''}"</div>` : ''}
536
-
537
997
  <div class="detail-header">
538
998
  <div class="detail-stat">
539
999
  <div class="detail-label">Total Cost</div>
540
- <div class="detail-value cost">${formatCost(d.totalCost)}</div>
541
- ${d.subagentCount ? `<div class="detail-sub">+ ${formatCost(d.subagentCost)} subagents (${d.subagentCount}) = <strong>${formatCost(d.totalCost + d.subagentCost)}</strong></div>` : ''}
1000
+ <div class="detail-value cost">${formatCost(d.totalCost)}<span class="detail-label detail-scope">all time</span></div>
1001
+ ${d.subagentCount ? `<div class="detail-sub">incl. <strong>${formatCost(d.subagentCost)}</strong> in ${d.subagentCount} subagent ${d.subagentCount === 1 ? 'run' : 'runs'}</div>` : ''}
542
1002
  </div>
543
1003
  <div class="detail-stat">
544
1004
  <div class="detail-label">Input</div>
545
- <div class="detail-value">${formatTokens(d.totalInput)}</div>
1005
+ <div class="detail-value">${formatTokens(d.input)}</div>
546
1006
  </div>
547
1007
  <div class="detail-stat">
548
1008
  <div class="detail-label">Output</div>
549
- <div class="detail-value">${formatTokens(d.totalOutput)}</div>
1009
+ <div class="detail-value">${formatTokens(d.output)}</div>
550
1010
  </div>
551
1011
  <div class="detail-stat">
552
1012
  <div class="detail-label">Cache Write</div>
553
- <div class="detail-value">${formatTokens(d.totalCacheWrite)}</div>
1013
+ <div class="detail-value">${formatTokens(d.cacheWrite)}</div>
554
1014
  </div>
555
1015
  <div class="detail-stat">
556
1016
  <div class="detail-label">Cache Read</div>
557
- <div class="detail-value">${formatTokens(d.totalCacheRead)}</div>
1017
+ <div class="detail-value">${formatTokens(d.cacheRead)}</div>
558
1018
  </div>
559
1019
  <div class="detail-stat">
560
1020
  <div class="detail-label">Messages</div>
561
- <div class="detail-value">${d.messages.length}</div>
1021
+ <div class="detail-value">${d.messages.length}<span class="detail-label detail-scope">all time</span></div>
562
1022
  </div>
563
1023
  <div class="detail-stat">
564
1024
  <div class="detail-label">Models</div>
565
- <div class="detail-value">${(d.models || []).map(shortModel).filter(Boolean).join(', ') || '—'}</div>
1025
+ <div class="detail-value">${esc((d.models || []).map(shortModel).filter(Boolean).join(', ') || '—')}</div>
566
1026
  </div>
567
1027
  <div class="detail-stat">
568
1028
  <div class="detail-label">Source</div>
@@ -571,9 +1031,13 @@ function renderDetail() {
571
1031
  &nbsp;${srcBadge('pricing')} ${sc.pricing || 0}
572
1032
  </div>
573
1033
  </div>
1034
+ ${compactionStat(d)}
574
1035
  </div>
575
1036
 
576
- <div class="charts-row" style="margin-bottom:20px">
1037
+ ${detailRangeNote(d)}
1038
+ ${compactionNote(d)}
1039
+
1040
+ <div class="charts-row${hasSubagents ? ' with-share' : ''}" style="margin-bottom:20px">
577
1041
  <div class="chart-box">
578
1042
  <div class="chart-title">Cumulative Cost</div>
579
1043
  <canvas id="cumulativeChart"></canvas>
@@ -582,29 +1046,24 @@ function renderDetail() {
582
1046
  <div class="chart-title">Token Breakdown per Message</div>
583
1047
  <canvas id="tokenBreakdownChart"></canvas>
584
1048
  </div>
1049
+ ${
1050
+ hasSubagents
1051
+ ? `<div class="chart-box">
1052
+ <div class="chart-title">Subagent Share</div>
1053
+ <div class="share-body">
1054
+ <div class="share-pie"><canvas id="subagentShareChart"></canvas></div>
1055
+ <ul class="share-legend" id="subagentShareLegend"></ul>
1056
+ </div>
1057
+ </div>`
1058
+ : ''
1059
+ }
585
1060
  </div>
586
1061
 
587
- ${d.childSessions && d.childSessions.length ? `
588
- <div class="section-title">Spawned sessions (${d.childSessions.length})</div>
589
- <table class="data-table" style="margin-bottom:20px">
590
- <thead><tr><th>Session</th><th>Cost</th><th>Tokens</th><th>Msgs</th><th>Model</th><th>When</th></tr></thead>
591
- <tbody>
592
- ${d.childSessions.map((sa) => {
593
- const label = sa.customTitle || shortId(sa.id);
594
- const preview = sa.firstUserText ? sa.firstUserText.slice(0, 120) : '';
595
- return `<tr data-clickable onclick="navigateToDetail('${esc(sa.id)}')">
596
- <td class="truncate" title="${esc(sa.id)}${preview ? ` — ${esc(preview)}` : ''}"><code class="session-id">${esc(label)}</code>${preview ? ` <span class="muted">${esc(preview)}</span>` : ''}</td>
597
- <td class="cost-cell">${formatCost(sa.totalCost)}</td>
598
- <td>${formatTokens(sa.totalTokens)}</td>
599
- <td>${sa.messageCount}</td>
600
- <td><span class="model-badge">${esc(shortModel(sa.model))}</span></td>
601
- <td class="muted">${timeAgo(sa.lastTimestamp)}</td>
602
- </tr>`;
603
- }).join('')}
604
- </tbody>
605
- </table>` : ''}
1062
+ ${childSessionsSection(d)}
606
1063
 
607
- <div class="section-title">Messages (${d.messages.length})</div>
1064
+ ${rangeBrushHtml(d.messages.length)}
1065
+
1066
+ <div class="section-title" id="messagesTitle"></div>
608
1067
  <table class="messages-table">
609
1068
  <thead><tr>
610
1069
  <th>#</th>
@@ -618,75 +1077,229 @@ function renderDetail() {
618
1077
  <th>Src</th>
619
1078
  <th>Cumulative</th>
620
1079
  </tr></thead>
621
- <tbody>
622
- ${[...d.messages].reverse().map((m, idxRev) => {
623
- const idx = d.messages.length - idxRev;
624
- const isSub = !!m.agent;
625
- const sid = m.subagentSessionId;
626
- const clickable = isSub && sid;
627
- const rowAttrs = isSub
628
- ? ` class="subagent-row${clickable ? ' clickable' : ''}" title="${esc(m.task || '')}${clickable ? ' — click to open subagent session' : ''}"${clickable ? ` onclick="navigateToDetail('${esc(sid)}')"` : ''}`
629
- : '';
630
- return `<tr${rowAttrs}>
631
- <td class="muted">${idx}</td>
632
- <td class="muted">${m.ts ? new Date(m.ts).toLocaleTimeString() : ''}</td>
633
- <td>${isSub ? `<span class="subagent-tag">${esc(m.agent)}</span> ` : ''}<span class="model-badge">${esc(shortModel(m.model))}</span></td>
634
- <td>${formatTokens(m.input)}</td>
635
- <td>${formatTokens(m.output)}</td>
636
- <td>${formatTokens(m.cacheWrite)}</td>
637
- <td>${formatTokens(m.cacheRead)}</td>
638
- <td class="cost-cell">${formatCost(m.cost)}</td>
639
- <td>${srcBadge(m.costSource)}</td>
640
- <td class="cumulative">${formatCost(m.cumulativeCost)}</td>
641
- </tr>`;
642
- }).join('')}
643
- </tbody>
1080
+ <tbody id="messagesBody"></tbody>
644
1081
  </table>
645
1082
  </div>`;
646
1083
 
647
1084
  requestAnimationFrame(() => {
648
- renderCumulativeChart(d.messages);
649
- renderTokenBreakdownChart(d.messages);
1085
+ destroyChart('cumulative');
1086
+ destroyChart('tokenBreakdown');
1087
+ destroyChart('subagentShare');
1088
+ const range = detailRangeFor(d);
1089
+ applyDetailRange(d.messages, range);
1090
+ initRangeBrush(d.messages, range);
650
1091
  });
651
1092
  }
652
1093
 
1094
+ // The session row that links here is scoped to the active range, so a session older than the
1095
+ // range would otherwise show larger numbers here with no explanation.
1096
+ function detailRangeNote(d) {
1097
+ const all = d.messages.length;
1098
+ const slice = (label, s) =>
1099
+ `<span class="scope-label">${esc(label)}</span> <strong>${formatCost(s.totalCost)}</strong> / ${s.messageCount} msg`;
1100
+ const worthShowing = (s) => s && s.messageCount > 0 && s.messageCount < all;
1101
+ const parts = [];
1102
+ if (worthShowing(d.inRange)) parts.push(slice(rangeScopeLabel(d.range), d.inRange));
1103
+ if (worthShowing(d.today) && d.today.messageCount !== d.inRange?.messageCount) parts.push(slice('today', d.today));
1104
+ if (!parts.length) return '';
1105
+ return `<div class="detail-range-note">Totals above are all-time &middot; ${parts.join(' &middot; ')}</div>`;
1106
+ }
1107
+
1108
+ function compactionStat(d) {
1109
+ const n = d.compactionEvents?.length || 0;
1110
+ if (!n) return '';
1111
+ return `<div class="detail-stat">
1112
+ <div class="detail-label">Compactions</div>
1113
+ <div class="detail-value">${n}<span class="detail-label detail-scope">${formatCost(d.compactions?.totalCost)}</span></div>
1114
+ </div>`;
1115
+ }
1116
+
1117
+ // pi records the summarizer call's usage on the compaction entry when it has one, so that cost is
1118
+ // real and already in the total. pi records no post-compaction size and no auto/manual trigger.
1119
+ function compactionNote(d) {
1120
+ const events = d.compactionEvents || [];
1121
+ if (!events.length) return '';
1122
+ const tokensBefore = events.reduce((s, e) => s + (e.tokensBefore || 0), 0);
1123
+ const billed = d.compactions?.count || 0;
1124
+ const hook = events.filter((e) => e.fromHook).length;
1125
+ return `<div class="detail-range-note compaction-note">
1126
+ <span class="scope-label">compaction</span> ${events.length} ${events.length === 1 ? 'event' : 'events'} &middot;
1127
+ replaced <strong>${formatTokens(tokensBefore)}</strong> tokens &middot;
1128
+ summarizer <strong>${formatCost(d.compactions?.totalCost)}</strong> (in total${billed < events.length ? `, recorded for ${billed} of ${events.length}` : ''})${hook ? ` &middot; ${hook} from a hook` : ''}
1129
+ </div>`;
1130
+ }
1131
+
1132
+ function childSessionsSection(d) {
1133
+ if (!d.childSessions?.length) return '';
1134
+ return `
1135
+ <div class="section-title">Spawned sessions (${d.childSessions.length})</div>
1136
+ <table class="data-table" style="margin-bottom:20px">
1137
+ <thead><tr><th>Session</th><th>Cost</th><th>Tokens</th><th>Msgs</th><th>Model</th><th>When</th></tr></thead>
1138
+ <tbody>
1139
+ ${d.childSessions
1140
+ .map((sa) => {
1141
+ const label = sa.customTitle || shortId(sa.id);
1142
+ const preview = sa.firstUserText ? sa.firstUserText.slice(0, 120) : '';
1143
+ return `<tr data-clickable onclick="navigateToDetail('${escAttrJs(sa.id)}')">
1144
+ <td class="truncate" title="${esc(preview ? `${sa.id} — ${preview}` : sa.id)}"><code class="session-id">${esc(label)}</code>${preview ? ` <span class="muted">${esc(preview)}</span>` : ''}</td>
1145
+ <td class="cost-cell">${formatCost(sa.totalCost)}</td>
1146
+ <td>${formatTokens(sa.totalTokens)}</td>
1147
+ <td>${sa.messageCount}</td>
1148
+ <td><span class="model-badge">${esc(shortModel(sa.primaryModel || sa.model))}</span></td>
1149
+ <td class="muted">${timeAgo(sa.lastTimestamp)}</td>
1150
+ </tr>`;
1151
+ })
1152
+ .join('')}
1153
+ </tbody>
1154
+ </table>`;
1155
+ }
1156
+
1157
+ function renderMessageRows(slice, total) {
1158
+ const body = document.getElementById('messagesBody');
1159
+ if (!body) return;
1160
+ document.getElementById('messagesTitle').textContent =
1161
+ slice.length === total ? `Messages (${total})` : `Messages (${slice.length} of ${total})`;
1162
+ const rows = [];
1163
+ for (let i = slice.length - 1; i >= 0; i--) rows.push(buildMessageRow(slice[i]));
1164
+ body.innerHTML = rows.join('');
1165
+ }
1166
+
1167
+ function buildMessageRow(m) {
1168
+ const sa = m.subagent;
1169
+ const model = `<span class="model-badge">${esc(shortModel(m.model))}</span>`;
1170
+ let modelCol;
1171
+ let rowAttrs = '';
1172
+ if (sa) {
1173
+ const task = sa.task ? String(sa.task) : '';
1174
+ modelCol = `${model} <span class="subagent-tag">${esc(sa.agent || 'subagent')}</span>${task ? ` <span class="subagent-desc">${esc(task.slice(0, 80))}</span>` : ''}`;
1175
+ const tip = `${task || sa.agent || 'subagent'}${sa.turns ? ` · ${sa.turns} turns` : ''}${sa.sessionId ? ' · open subagent session' : ''}`;
1176
+ rowAttrs = sa.sessionId
1177
+ ? ` class="subagent-row clickable" data-clickable onclick="navigateToDetail('${escAttrJs(sa.sessionId)}')" title="${esc(tip)}"`
1178
+ : ` class="subagent-row" title="${esc(tip)}"`;
1179
+ } else if (m.compaction) {
1180
+ const tip = `compaction · replaced ${formatTokens(m.compaction.tokensBefore)} tokens${m.compaction.fromHook ? ' · from a hook' : ''}`;
1181
+ modelCol = `${model} <span class="compaction-tag" title="${esc(tip)}">compact</span>`;
1182
+ rowAttrs = ' class="compaction-row"';
1183
+ } else {
1184
+ const tools = m.tools?.length ? ` ${m.tools.map((t) => `<span class="tool-tag">${esc(t)}</span>`).join(' ')}` : '';
1185
+ modelCol = model + tools;
1186
+ }
1187
+ return `<tr${rowAttrs}>
1188
+ <td class="muted">${m.index}</td>
1189
+ <td class="muted">${m.ts ? localTime(m.ts) : ''}</td>
1190
+ <td>${modelCol}</td>
1191
+ <td>${formatTokens(m.input)}</td>
1192
+ <td>${formatTokens(m.output)}</td>
1193
+ <td>${formatTokens(m.cacheWrite)}</td>
1194
+ <td>${formatTokens(m.cacheRead)}</td>
1195
+ <td class="cost-cell">${formatCost(m.cost)}</td>
1196
+ <td>${srcBadge(m.costSource)}</td>
1197
+ <td class="cumulative">${formatCost(m.cumulativeCost)}</td>
1198
+ </tr>`;
1199
+ }
1200
+
653
1201
  // #endregion
654
1202
 
655
1203
  // #region CHARTS
656
1204
 
1205
+ let _chartColors = null;
1206
+
657
1207
  function getChartColors() {
1208
+ return (_chartColors ||= readChartColors());
1209
+ }
1210
+
1211
+ function readChartColors() {
658
1212
  const style = getComputedStyle(document.body);
1213
+ const v = (name, fallback) => style.getPropertyValue(name).trim() || fallback;
659
1214
  return {
660
- accent: style.getPropertyValue('--accent').trim() || '#e86f33',
661
- chartFill: style.getPropertyValue('--chart-fill').trim() || 'rgba(232,111,51,0.6)',
662
- text: style.getPropertyValue('--text-secondary').trim() || '#c2c4c9',
663
- border: style.getPropertyValue('--border').trim() || '#363840',
664
- bg: style.getPropertyValue('--bg-elevated').trim() || '#1e2025',
665
- chart1: style.getPropertyValue('--chart-1').trim() || '#e86f33',
666
- chart2: style.getPropertyValue('--chart-2').trim() || '#60a5fa',
667
- chart3: style.getPropertyValue('--chart-3').trim() || '#3ecf8e',
668
- chart4: style.getPropertyValue('--chart-4').trim() || '#f0b429',
1215
+ accent: v('--accent', '#e86f33'),
1216
+ chartFill: v('--chart-fill', 'rgba(232,111,51,0.6)'),
1217
+ text: v('--text-secondary', '#c2c4c9'),
1218
+ textPrimary: v('--text-primary', '#f0f1f3'),
1219
+ textMuted: v('--text-muted', '#7d808a'),
1220
+ mono: v('--mono', "'IBM Plex Mono', monospace"),
1221
+ border: v('--border', '#363840'),
1222
+ bg: v('--bg-elevated', '#1e2025'),
1223
+ chart2: v('--chart-2', '#60a5fa'),
1224
+ chart3: v('--chart-3', '#3ecf8e'),
1225
+ chart4: v('--chart-4', '#f0b429'),
1226
+ chart5: v('--chart-5', '#b784f5'),
1227
+ chart6: v('--chart-6', '#fb7185'),
669
1228
  };
670
1229
  }
671
1230
 
672
- function modelColor(name) {
673
- const isLight = isLightTheme();
674
- let h = 0;
675
- for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
676
- const hue = Math.abs(h) % 360;
677
- const sat = isLight ? 55 : 60;
678
- const light = isLight ? 50 : 62;
679
- return `hsl(${hue} ${sat}% ${light}%)`;
1231
+ // One palette so per-model colors match across charts. It leads with the theme accent, so a
1232
+ // single-model chart takes the theme's color.
1233
+ function chartPalette(c) {
1234
+ return [c.accent, c.chart2, c.chart3, c.chart4, c.chart5, c.chart6];
680
1235
  }
681
1236
 
682
- function chartDefaults() {
683
- const c = getChartColors();
1237
+ let colorProbe = null;
1238
+
1239
+ // Theme colors come from JSON files and from the board, so they can be hex, rgb() or any CSS
1240
+ // color. Anything else is resolved through the browser once.
1241
+ function parseColor(color) {
1242
+ const s = String(color || '').trim();
1243
+ const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(s);
1244
+ if (hex) {
1245
+ const h = hex[1].length === 3 ? [...hex[1]].map((ch) => ch + ch).join('') : hex[1];
1246
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
1247
+ }
1248
+ const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(s);
1249
+ if (rgb) return rgb.slice(1, 4).map(Number);
1250
+ if (!s || !CSS.supports('color', s)) return null;
1251
+ if (!colorProbe) {
1252
+ colorProbe = document.createElement('span');
1253
+ colorProbe.style.display = 'none';
1254
+ document.body.appendChild(colorProbe);
1255
+ }
1256
+ colorProbe.style.color = s;
1257
+ const resolved = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(getComputedStyle(colorProbe).color);
1258
+ return resolved ? resolved.slice(1, 4).map(Number) : null;
1259
+ }
1260
+
1261
+ function withAlpha(color, alpha) {
1262
+ const rgb = parseColor(color);
1263
+ return rgb ? `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})` : color;
1264
+ }
1265
+
1266
+ function messageTooltipTitle(messages, items) {
1267
+ const m = messages[items[0]?.dataIndex];
1268
+ if (!m) return '';
1269
+ return `#${m.index}${m.subagent ? ` · ${m.subagent.agent}` : m.compaction ? ' · compaction' : ''}`;
1270
+ }
1271
+
1272
+ function tokenSeries(c) {
1273
+ return [
1274
+ ['Input', 'input', c.accent],
1275
+ ['Output', 'output', c.chart2],
1276
+ ['Cache Write', 'cacheWrite', c.chart3],
1277
+ ['Cache Read', 'cacheRead', c.chart4],
1278
+ ];
1279
+ }
1280
+
1281
+ function chartDefaults(c = getChartColors()) {
684
1282
  return {
685
1283
  responsive: true,
686
1284
  maintainAspectRatio: false,
687
1285
  plugins: {
688
1286
  legend: { display: false },
689
- tooltip: { backgroundColor: c.bg, titleColor: c.text, bodyColor: c.text, borderColor: c.border, borderWidth: 1 },
1287
+ // Mirrors .hm-tip so a chart hover and a heatmap hover read as the same tooltip.
1288
+ tooltip: {
1289
+ backgroundColor: c.bg,
1290
+ titleColor: c.textMuted,
1291
+ bodyColor: c.textPrimary,
1292
+ borderColor: c.border,
1293
+ borderWidth: 1,
1294
+ cornerRadius: 4,
1295
+ padding: { top: 5, bottom: 5, left: 8, right: 8 },
1296
+ titleFont: { family: c.mono, size: 10, weight: 'normal' },
1297
+ bodyFont: { family: c.mono, size: 13, weight: '600' },
1298
+ titleMarginBottom: 2,
1299
+ boxWidth: 8,
1300
+ boxHeight: 8,
1301
+ boxPadding: 4,
1302
+ },
690
1303
  },
691
1304
  scales: {
692
1305
  x: { ticks: { color: c.text, font: { size: 10 } }, grid: { color: c.border, drawBorder: false } },
@@ -695,33 +1308,122 @@ function chartDefaults() {
695
1308
  };
696
1309
  }
697
1310
 
698
- function destroyChart(id) {
699
- if (charts[id]) { charts[id].destroy(); delete charts[id]; }
1311
+ // Log axes tick at every 2..9 within a decade, which reads as noise. Keep powers of 10 only.
1312
+ function isDecade(v) {
1313
+ const l = Math.log10(v);
1314
+ return Math.abs(l - Math.round(l)) < 1e-9;
700
1315
  }
701
1316
 
702
- function renderDailyChart(daily, canvasId = 'dailyChart', chartKey = 'daily') {
703
- const canvas = document.getElementById(canvasId);
704
- if (!canvas || !daily?.length) return;
705
- destroyChart(chartKey);
706
- const c = getChartColors();
707
- const defaults = chartDefaults();
708
- charts[chartKey] = new Chart(canvas, {
709
- type: 'bar',
710
- data: {
711
- labels: daily.map((d) => shortDate(d.date)),
712
- datasets: [{
713
- label: 'Daily Cost',
714
- data: daily.map((d) => d.cost),
715
- backgroundColor: c.accent,
716
- borderColor: c.accent,
717
- borderWidth: 1,
718
- borderRadius: 3,
719
- }],
1317
+ // maxValue caps the axis at the largest bar and labels it.
1318
+ function logAxis(xDefaults, maxValue) {
1319
+ const labeled = (v) => isDecade(v) || v === maxValue;
1320
+ return {
1321
+ ...xDefaults,
1322
+ type: 'logarithmic',
1323
+ min: 1,
1324
+ ...(maxValue > 1 ? { max: maxValue } : {}),
1325
+ afterBuildTicks: (scale) => {
1326
+ if (maxValue <= 1) return;
1327
+ scale.ticks = scale.ticks.filter(
1328
+ (t) => t.value !== maxValue && !(isDecade(t.value) && Math.abs(Math.log10(maxValue / t.value)) < 0.25),
1329
+ );
1330
+ scale.ticks.push({ value: maxValue });
1331
+ },
1332
+ grid: {
1333
+ ...xDefaults.grid,
1334
+ color: (ctx) => (ctx.tick && labeled(ctx.tick.value) ? xDefaults.grid.color : 'transparent'),
1335
+ },
1336
+ ticks: {
1337
+ ...xDefaults.ticks,
1338
+ autoSkip: false,
1339
+ callback: (v) => (labeled(v) ? formatTokens(v) : ''),
1340
+ },
1341
+ };
1342
+ }
1343
+
1344
+ function barDataset(data, color) {
1345
+ return {
1346
+ data,
1347
+ backgroundColor: Array.isArray(color) ? color.map((x) => withAlpha(x, 0.5)) : withAlpha(color, 0.5),
1348
+ borderColor: color,
1349
+ borderWidth: 1.5,
1350
+ borderRadius: 3,
1351
+ };
1352
+ }
1353
+
1354
+ function withTooltip(defaults, callbacks) {
1355
+ return { ...defaults.plugins, tooltip: { ...defaults.plugins.tooltip, callbacks } };
1356
+ }
1357
+
1358
+ // Two significant digits below a cent, so a sub-cent cost does not read as $0.00.
1359
+ function usd(v) {
1360
+ const a = Math.abs(v);
1361
+ return `$${v.toFixed(a >= 0.01 || a === 0 ? 2 : Math.min(6, Math.ceil(-Math.log10(a)) + 1))}`;
1362
+ }
1363
+
1364
+ // Ticks share the decimals their step needs, so neighbouring labels stay distinct.
1365
+ function usdTick(v, _i, ticks) {
1366
+ const step = ticks.length > 1 ? Math.abs(ticks[1].value - ticks[0].value) : 0;
1367
+ let d = 2;
1368
+ while (d < 6 && step > 0 && Math.abs(step * 10 ** d - Math.round(step * 10 ** d)) > 1e-6) d++;
1369
+ return `$${v.toFixed(d)}`;
1370
+ }
1371
+
1372
+ function destroyChart(id) {
1373
+ if (charts[id]) {
1374
+ charts[id].destroy();
1375
+ delete charts[id];
1376
+ }
1377
+ }
1378
+
1379
+ function renderCostChart(series, canvasId, chartKey, distribution = []) {
1380
+ const points = series?.points;
1381
+ const canvas = document.getElementById(canvasId);
1382
+ if (!canvas || !points?.length) return;
1383
+ destroyChart(chartKey);
1384
+
1385
+ const c = getChartColors();
1386
+ const defaults = chartDefaults(c);
1387
+
1388
+ // Stack order follows modelDistribution so segment colors match the Cost by Model chart.
1389
+ const present = new Set();
1390
+ for (const p of points) {
1391
+ for (const m in p.byModel) if (p.byModel[m] > 0) present.add(m);
1392
+ }
1393
+ const models = distribution.filter((d) => present.has(d.model)).map((d) => d.model);
1394
+ for (const m of present) if (!models.includes(m)) models.push(m);
1395
+ const palette = chartPalette(c);
1396
+
1397
+ charts[chartKey] = new Chart(canvas, {
1398
+ type: 'bar',
1399
+ data: {
1400
+ labels: points.map((p) => bucketLabel(p.key, series.bucket)),
1401
+ datasets: models.map((m, i) => ({
1402
+ label: shortModel(m),
1403
+ data: points.map((p) => p.byModel?.[m] || 0),
1404
+ backgroundColor: withAlpha(palette[i % palette.length], 0.5),
1405
+ borderColor: palette[i % palette.length],
1406
+ borderWidth: 1.5,
1407
+ borderRadius: 2,
1408
+ stack: 'cost',
1409
+ })),
720
1410
  },
721
1411
  options: {
722
1412
  ...defaults,
723
- plugins: { ...defaults.plugins, tooltip: { ...defaults.plugins.tooltip, callbacks: { label: (ctx) => `$${ctx.parsed.y.toFixed(2)}` } } },
724
- scales: { ...defaults.scales, y: { ...defaults.scales.y, ticks: { ...defaults.scales.y.ticks, callback: (v) => `$${v.toFixed(2)}` } } },
1413
+ interaction: { mode: 'nearest', intersect: true },
1414
+ plugins: {
1415
+ ...withTooltip(defaults, { label: (ctx) => `${ctx.dataset.label} ${usd(ctx.parsed.y)}` }),
1416
+ legend: {
1417
+ display: models.length > 1,
1418
+ position: 'bottom',
1419
+ labels: { color: c.text, font: { size: 9 }, boxWidth: 8, boxHeight: 8 },
1420
+ },
1421
+ },
1422
+ scales: {
1423
+ ...defaults.scales,
1424
+ x: { ...defaults.scales.x, stacked: true },
1425
+ y: { ...defaults.scales.y, stacked: true, ticks: { ...defaults.scales.y.ticks, callback: usdTick } },
1426
+ },
725
1427
  },
726
1428
  });
727
1429
  }
@@ -730,88 +1432,158 @@ function renderModelChart(models, canvasId = 'modelChart', chartKey = 'model') {
730
1432
  const canvas = document.getElementById(canvasId);
731
1433
  if (!canvas || !models?.length) return;
732
1434
  destroyChart(chartKey);
733
- const defaults = chartDefaults();
1435
+
1436
+ const c = getChartColors();
1437
+ const palette = chartPalette(c);
1438
+ const defaults = chartDefaults(c);
734
1439
  const total = models.reduce((s, m) => s + m.cost, 0);
1440
+
735
1441
  charts[chartKey] = new Chart(canvas, {
736
1442
  type: 'bar',
737
1443
  data: {
738
1444
  labels: models.map((m) => shortModel(m.model)),
739
- datasets: [{
740
- data: models.map((m) => m.cost),
741
- backgroundColor: models.map((m) => modelColor(m.model)),
742
- borderWidth: 0,
743
- borderRadius: 3,
744
- }],
1445
+ datasets: [
1446
+ barDataset(
1447
+ models.map((m) => m.cost),
1448
+ models.map((_, i) => palette[i % palette.length]),
1449
+ ),
1450
+ ],
745
1451
  },
746
1452
  options: {
747
1453
  ...defaults,
748
1454
  indexAxis: 'y',
749
- plugins: {
750
- ...defaults.plugins,
751
- tooltip: { ...defaults.plugins.tooltip, callbacks: { label: (ctx) => `$${ctx.parsed.x.toFixed(2)} (${((ctx.parsed.x / total) * 100).toFixed(1)}%)` } },
1455
+ interaction: { mode: 'nearest', intersect: true },
1456
+ plugins: withTooltip(defaults, { label: (ctx) => `${usd(ctx.parsed.x)} (${formatPct(ctx.parsed.x, total)}%)` }),
1457
+ scales: {
1458
+ ...defaults.scales,
1459
+ x: { ...defaults.scales.x, ticks: { ...defaults.scales.x.ticks, callback: usdTick } },
752
1460
  },
753
- scales: { ...defaults.scales, x: { ...defaults.scales.x, ticks: { ...defaults.scales.x.ticks, callback: (v) => `$${v}` } } },
754
1461
  },
755
1462
  });
756
1463
  }
757
1464
 
758
- function renderCumulativeChart(messages) {
759
- const canvas = document.getElementById('cumulativeChart');
760
- if (!canvas || !messages?.length) return;
761
- destroyChart('cumulative');
762
- const c = getChartColors();
763
- const defaults = chartDefaults();
764
- charts.cumulative = new Chart(canvas, {
765
- type: 'line',
766
- data: {
767
- labels: messages.map((_, i) => i + 1),
768
- datasets: [{
1465
+ function cumulativeChartData(messages, c) {
1466
+ const small = messages.length <= 50;
1467
+ return {
1468
+ labels: messages.map((m) => m.index),
1469
+ datasets: [
1470
+ {
769
1471
  label: 'Cumulative Cost',
770
1472
  data: messages.map((m) => m.cumulativeCost),
771
1473
  borderColor: c.accent,
772
1474
  backgroundColor: c.chartFill,
773
1475
  fill: true,
774
1476
  borderWidth: 2,
775
- pointRadius: messages.length > 50 ? 0 : 3,
776
- pointBackgroundColor: c.accent,
1477
+ pointRadius: small ? 3 : 0,
1478
+ // Subagent points render hollow so they read as folded-in cost, not main-loop turns.
1479
+ pointBackgroundColor: small ? messages.map((m) => (m.subagent ? c.bg : c.accent)) : c.accent,
1480
+ pointBorderColor: c.accent,
777
1481
  tension: 0.2,
778
- }],
779
- },
1482
+ },
1483
+ ],
1484
+ };
1485
+ }
1486
+
1487
+ function tokenBreakdownChartData(messages, c) {
1488
+ const barColors = (color) => {
1489
+ const faded = withAlpha(color, 0.45);
1490
+ return messages.map((m) => (m.subagent ? faded : color));
1491
+ };
1492
+ return {
1493
+ labels: messages.map((m) => m.index),
1494
+ datasets: tokenSeries(c).map(([label, key, color]) => ({
1495
+ label,
1496
+ data: messages.map((m) => m[key]),
1497
+ backgroundColor: barColors(color),
1498
+ borderRadius: 2,
1499
+ })),
1500
+ };
1501
+ }
1502
+
1503
+ // A dashed vertical line before each compaction row. Reads chart.$compactions, set by
1504
+ // markCompactions, so a draw costs no style lookup or scan.
1505
+ const compactionMarkers = {
1506
+ id: 'compactionMarkers',
1507
+ afterDatasetsDraw(chart) {
1508
+ const marks = chart.$compactions;
1509
+ if (!marks?.indices.length) return;
1510
+ const { ctx, chartArea, scales } = chart;
1511
+ ctx.save();
1512
+ ctx.strokeStyle = marks.color;
1513
+ ctx.lineWidth = 1;
1514
+ ctx.setLineDash([4, 3]);
1515
+ ctx.font = `9px ${marks.font}`;
1516
+ ctx.fillStyle = marks.color;
1517
+ ctx.textAlign = 'center';
1518
+ for (const i of marks.indices) {
1519
+ const p = scales.x.getPixelForValue(i);
1520
+ const x = i > 0 ? (p + scales.x.getPixelForValue(i - 1)) / 2 : p;
1521
+ ctx.beginPath();
1522
+ ctx.moveTo(x, chartArea.top);
1523
+ ctx.lineTo(x, chartArea.bottom);
1524
+ ctx.stroke();
1525
+ ctx.fillText('compact', x, chartArea.top - 2);
1526
+ }
1527
+ ctx.restore();
1528
+ },
1529
+ };
1530
+
1531
+ function markCompactions(chart, messages, c) {
1532
+ chart.$compactions = {
1533
+ indices: messages.flatMap((m, i) => (m.compaction ? [i] : [])),
1534
+ color: c.chart5,
1535
+ font: c.mono,
1536
+ };
1537
+ }
1538
+
1539
+ function renderCumulativeChart(messages) {
1540
+ const canvas = document.getElementById('cumulativeChart');
1541
+ if (!canvas || !messages?.length) return;
1542
+ destroyChart('cumulative');
1543
+
1544
+ const c = getChartColors();
1545
+ const defaults = chartDefaults(c);
1546
+
1547
+ charts.cumulative = new Chart(canvas, {
1548
+ type: 'line',
1549
+ data: cumulativeChartData(messages, c),
1550
+ plugins: [compactionMarkers],
780
1551
  options: {
781
1552
  ...defaults,
1553
+ plugins: withTooltip(defaults, {
1554
+ title: (items) => messageTooltipTitle(detailSlice, items),
1555
+ label: (ctx) => usd(ctx.parsed.y),
1556
+ }),
782
1557
  scales: {
783
1558
  ...defaults.scales,
784
1559
  x: { ...defaults.scales.x, title: { display: true, text: 'Message #', color: c.text, font: { size: 10 } } },
785
- y: { ...defaults.scales.y, ticks: { ...defaults.scales.y.ticks, callback: (v) => `$${v.toFixed(2)}` } },
1560
+ y: { ...defaults.scales.y, ticks: { ...defaults.scales.y.ticks, callback: usdTick } },
786
1561
  },
787
1562
  },
788
1563
  });
1564
+ markCompactions(charts.cumulative, messages, c);
789
1565
  }
790
1566
 
791
1567
  function renderTokenBreakdownChart(messages) {
792
1568
  const canvas = document.getElementById('tokenBreakdownChart');
793
1569
  if (!canvas || !messages?.length) return;
794
1570
  destroyChart('tokenBreakdown');
1571
+
795
1572
  const c = getChartColors();
796
- const defaults = chartDefaults();
1573
+ const defaults = chartDefaults(c);
1574
+
797
1575
  charts.tokenBreakdown = new Chart(canvas, {
798
1576
  type: 'bar',
799
- data: {
800
- labels: messages.map((_, i) => i + 1),
801
- datasets: [
802
- { label: 'Input', data: messages.map((m) => m.input), backgroundColor: c.chart1, borderRadius: 2 },
803
- { label: 'Output', data: messages.map((m) => m.output), backgroundColor: c.chart2, borderRadius: 2 },
804
- { label: 'Cache Write', data: messages.map((m) => m.cacheWrite), backgroundColor: c.chart3, borderRadius: 2 },
805
- { label: 'Cache Read', data: messages.map((m) => m.cacheRead), backgroundColor: c.chart4, borderRadius: 2 },
806
- ],
807
- },
1577
+ data: tokenBreakdownChartData(messages, c),
1578
+ plugins: [compactionMarkers],
808
1579
  options: {
809
1580
  ...defaults,
810
1581
  plugins: {
811
- ...defaults.plugins,
1582
+ ...withTooltip(defaults, { title: (items) => messageTooltipTitle(detailSlice, items) }),
812
1583
  legend: {
813
- display: true, position: 'bottom',
814
- labels: { color: c.text, font: { size: 10, family: "'IBM Plex Mono', monospace" }, padding: 8, boxWidth: 12, boxHeight: 12 },
1584
+ display: true,
1585
+ position: 'bottom',
1586
+ labels: { color: c.text, font: { size: 10, family: c.mono }, padding: 8, boxWidth: 12, boxHeight: 12 },
815
1587
  },
816
1588
  },
817
1589
  scales: {
@@ -821,6 +1593,481 @@ function renderTokenBreakdownChart(messages) {
821
1593
  },
822
1594
  },
823
1595
  });
1596
+ markCompactions(charts.tokenBreakdown, messages, c);
1597
+ }
1598
+
1599
+ function syncSubagentShare(messages, c) {
1600
+ const chart = charts.subagentShare;
1601
+ if (!chart) return renderSubagentShareChart(messages);
1602
+ chart.data = subagentShareChartData(messages, c);
1603
+ chart.update('none');
1604
+ renderSubagentShareLegend(chart.data.slices);
1605
+ }
1606
+
1607
+ // Grouped by agent name. A group links to its run only when it has exactly one session to open.
1608
+ function subagentShareChartData(messages, c) {
1609
+ const groups = new Map();
1610
+ let main = 0;
1611
+ for (const m of messages) {
1612
+ const sa = m.subagent;
1613
+ if (!sa) {
1614
+ main += m.cost;
1615
+ continue;
1616
+ }
1617
+ const name = sa.agent || 'subagent';
1618
+ let group = groups.get(name);
1619
+ if (!group) {
1620
+ group = { name, cost: 0, sessions: new Set() };
1621
+ groups.set(name, group);
1622
+ }
1623
+ group.cost += m.cost;
1624
+ if (sa.sessionId) group.sessions.add(sa.sessionId);
1625
+ }
1626
+ const palette = chartPalette(c);
1627
+ const agents = [...groups.values()]
1628
+ .sort((a, b) => b.cost - a.cost)
1629
+ .map(({ sessions, ...g }, i) => ({
1630
+ ...g,
1631
+ sessionId: sessions.size === 1 ? [...sessions][0] : null,
1632
+ color: palette[1 + (i % (palette.length - 1))],
1633
+ }));
1634
+ // The main loop keeps the accent wherever it ranks.
1635
+ const slices = [{ name: 'main', cost: main, color: palette[0] }, ...agents].sort((a, b) => b.cost - a.cost);
1636
+ return {
1637
+ labels: slices.map((s) => s.name),
1638
+ slices,
1639
+ datasets: [
1640
+ {
1641
+ ...barDataset(
1642
+ slices.map((s) => s.cost),
1643
+ slices.map((s) => s.color),
1644
+ ),
1645
+ borderRadius: 0,
1646
+ },
1647
+ ],
1648
+ };
1649
+ }
1650
+
1651
+ function renderSubagentShareChart(messages) {
1652
+ const canvas = document.getElementById('subagentShareChart');
1653
+ if (!canvas || !messages?.length) return;
1654
+ destroyChart('subagentShare');
1655
+
1656
+ const c = getChartColors();
1657
+ const { scales: _scales, ...defaults } = chartDefaults(c);
1658
+
1659
+ charts.subagentShare = new Chart(canvas, {
1660
+ type: 'doughnut',
1661
+ data: subagentShareChartData(messages, c),
1662
+ options: {
1663
+ ...defaults,
1664
+ cutout: '55%',
1665
+ onClick: (_e, els, chart) => {
1666
+ const id = chart.data.slices[els[0]?.index]?.sessionId;
1667
+ if (id) navigateToDetail(id);
1668
+ },
1669
+ onHover: (e, els, chart) => {
1670
+ e.native.target.style.cursor = chart.data.slices[els[0]?.index]?.sessionId ? 'pointer' : '';
1671
+ },
1672
+ plugins: withTooltip(defaults, {
1673
+ title: (items) => items[0]?.chart.data.slices[items[0].dataIndex]?.name || '',
1674
+ label: (ctx) => {
1675
+ const total = ctx.dataset.data.reduce((s, v) => s + v, 0);
1676
+ return `${usd(ctx.parsed)} (${formatPct(ctx.parsed, total)}%)`;
1677
+ },
1678
+ }),
1679
+ },
1680
+ });
1681
+ renderSubagentShareLegend(charts.subagentShare.data.slices);
1682
+ }
1683
+
1684
+ // An HTML list, not the Chart.js legend: a canvas legend that runs out of room drops entries.
1685
+ function renderSubagentShareLegend(slices) {
1686
+ const el = document.getElementById('subagentShareLegend');
1687
+ if (!el) return;
1688
+ const total = slices.reduce((s, x) => s + x.cost, 0);
1689
+ el.innerHTML = slices
1690
+ .map((s) => {
1691
+ const attrs = s.sessionId ? ` class="share-agent" onclick="navigateToDetail('${escAttrJs(s.sessionId)}')"` : '';
1692
+ return `<li${attrs} title="${esc(s.name)}">
1693
+ <span class="share-swatch" style="background:${esc(withAlpha(s.color, 0.5))};border-color:${esc(s.color)}"></span>
1694
+ <span class="share-name">${esc(s.name)}</span>
1695
+ <span class="share-cost">${formatCost(s.cost)}</span>
1696
+ <span class="share-pct">${formatPct(s.cost, total)}%</span>
1697
+ </li>`;
1698
+ })
1699
+ .join('');
1700
+ }
1701
+
1702
+ function renderTokenSplitChart(t) {
1703
+ const canvas = document.getElementById('tokenSplitChart');
1704
+ if (!canvas) return;
1705
+ destroyChart('tokenSplit');
1706
+
1707
+ const c = getChartColors();
1708
+ const defaults = chartDefaults(c);
1709
+ const rows = tokenSeries(c).map(([label, key, color]) => [label, t[key], color]);
1710
+ const total = rows.reduce((s, r) => s + r[1], 0);
1711
+
1712
+ charts.tokenSplit = new Chart(canvas, {
1713
+ type: 'bar',
1714
+ data: {
1715
+ labels: rows.map((r) => r[0]),
1716
+ datasets: [
1717
+ barDataset(
1718
+ rows.map((r) => r[1]),
1719
+ rows.map((r) => r[2]),
1720
+ ),
1721
+ ],
1722
+ },
1723
+ options: {
1724
+ ...defaults,
1725
+ indexAxis: 'y',
1726
+ interaction: { mode: 'index', axis: 'y', intersect: false },
1727
+ plugins: withTooltip(defaults, {
1728
+ label: (ctx) => `${formatTokens(ctx.parsed.x)} (${formatPct(ctx.parsed.x, total)}%)`,
1729
+ }),
1730
+ // Log scale: cache reads dwarf fresh input by orders of magnitude, so a linear axis hides
1731
+ // every other bar. The tooltip carries exact values.
1732
+ scales: { ...defaults.scales, x: logAxis(defaults.scales.x, Math.max(...rows.map((r) => r[1]))) },
1733
+ },
1734
+ });
1735
+ }
1736
+
1737
+ function renderToolsChart(tools) {
1738
+ const canvas = document.getElementById('toolsChart');
1739
+ if (!canvas || !tools?.length) return;
1740
+ destroyChart('tools');
1741
+
1742
+ const c = getChartColors();
1743
+ const defaults = chartDefaults(c);
1744
+ const top = tools.slice(0, 8);
1745
+ // MCP tool names ("mcp__server__tool") are too long for axis labels, so keep the tail.
1746
+ const shortName = (n) => (n.includes('__') ? n.split('__').pop() : n);
1747
+
1748
+ charts.tools = new Chart(canvas, {
1749
+ type: 'bar',
1750
+ data: {
1751
+ labels: top.map((t) => shortName(t.name)),
1752
+ datasets: [
1753
+ barDataset(
1754
+ top.map((t) => t.count),
1755
+ c.chart2,
1756
+ ),
1757
+ ],
1758
+ },
1759
+ options: {
1760
+ ...defaults,
1761
+ indexAxis: 'y',
1762
+ interaction: { mode: 'index', axis: 'y', intersect: false },
1763
+ plugins: withTooltip(defaults, {
1764
+ title: (items) => top[items[0].dataIndex]?.name || '',
1765
+ label: (ctx) => `${ctx.parsed.x} calls`,
1766
+ }),
1767
+ scales: {
1768
+ x: { ...defaults.scales.x, ticks: { ...defaults.scales.x.ticks, precision: 0 } },
1769
+ y: { ...defaults.scales.y, ticks: { ...defaults.scales.y.ticks, autoSkip: false } },
1770
+ },
1771
+ },
1772
+ });
1773
+ }
1774
+
1775
+ function renderWeeklyChart(weekly) {
1776
+ const canvas = document.getElementById('weeklyChart');
1777
+ if (!canvas || !weekly?.length) return;
1778
+ destroyChart('weekly');
1779
+
1780
+ const c = getChartColors();
1781
+ const defaults = chartDefaults(c);
1782
+
1783
+ charts.weekly = new Chart(canvas, {
1784
+ type: 'bar',
1785
+ data: {
1786
+ labels: weekly.map((w) => isoDayLabel(w.weekStart)),
1787
+ datasets: [
1788
+ barDataset(
1789
+ weekly.map((w) => w.cost),
1790
+ c.accent,
1791
+ ),
1792
+ ],
1793
+ },
1794
+ options: {
1795
+ ...defaults,
1796
+ plugins: withTooltip(defaults, { label: (ctx) => usd(ctx.parsed.y) }),
1797
+ scales: { ...defaults.scales, y: { ...defaults.scales.y, ticks: { ...defaults.scales.y.ticks, callback: usdTick } } },
1798
+ },
1799
+ });
1800
+ }
1801
+
1802
+ // #endregion
1803
+
1804
+ // #region RANGE_BRUSH
1805
+
1806
+ // Long sessions squash both per-message charts into noise, so the detail view shows a window of
1807
+ // the last DETAIL_WINDOW messages and lets the user drag it across an overview of the session.
1808
+ // State lives outside the DOM because renderDetail rebuilds its innerHTML on every refresh; a
1809
+ // window at the tail stays there so new messages scroll into view.
1810
+ const DETAIL_WINDOW_PRESETS = [25, 50, 100, 200];
1811
+ const DETAIL_WINDOW = DETAIL_WINDOW_PRESETS[0];
1812
+ const DETAIL_WINDOW_MIN = 10;
1813
+ const detailRanges = new Map();
1814
+ let detailSlice = [];
1815
+
1816
+ function median(sorted) {
1817
+ return sorted[Math.floor(sorted.length / 2)];
1818
+ }
1819
+
1820
+ // A cache burst is a main-loop turn that wrote far more cache than the session normally does: the
1821
+ // prompt prefix was invalidated and re-cached. Relative to the session median, with an absolute
1822
+ // floor that drops noise in tiny sessions.
1823
+ const CACHE_BURST_RATIO = 4;
1824
+ const CACHE_BURST_MIN_TOKENS = 20000;
1825
+ const SUBAGENT_COSTLY_USD = 1;
1826
+ const MARKER_KINDS = {
1827
+ burst: { cls: '', label: 'cache burst' },
1828
+ compaction: { cls: 'compact', label: 'compaction' },
1829
+ agent: { cls: 'agent', label: 'subagent' },
1830
+ costlyAgent: { cls: 'agent costly', label: 'subagent', suffix: ` ≥ ${formatCost(SUBAGENT_COSTLY_USD)}` },
1831
+ };
1832
+
1833
+ function notableEvents(messages) {
1834
+ const writes = messages
1835
+ .filter((m) => !m.subagent && m.cacheWrite > 0)
1836
+ .map((m) => m.cacheWrite)
1837
+ .sort((a, b) => a - b);
1838
+ const threshold = writes.length < 5 ? Infinity : Math.max(CACHE_BURST_MIN_TOKENS, median(writes) * CACHE_BURST_RATIO);
1839
+ return messages.flatMap((m, i) => {
1840
+ if (m.compaction) {
1841
+ const tip = `#${m.index} · compaction · replaced ${formatTokens(m.compaction.tokensBefore)} tokens · ${formatCost(m.cost)}`;
1842
+ return [{ i, kind: 'compaction', tip }];
1843
+ }
1844
+ const sa = m.subagent;
1845
+ if (sa) {
1846
+ const tip = `#${m.index} · ${sa.agent || 'subagent'}${sa.task ? ` · ${String(sa.task).slice(0, 80)}` : ''} · ${formatCost(m.cost)}`;
1847
+ return [{ i, kind: m.cost >= SUBAGENT_COSTLY_USD ? 'costlyAgent' : 'agent', tip }];
1848
+ }
1849
+ if (m.cacheWrite >= threshold) {
1850
+ return [{ i, kind: 'burst', tip: `#${m.index} · cache write ${formatTokens(m.cacheWrite)}` }];
1851
+ }
1852
+ return [];
1853
+ });
1854
+ }
1855
+
1856
+ function clampRange(start, end, total) {
1857
+ const s = Math.max(0, Math.min(start, total - DETAIL_WINDOW_MIN));
1858
+ return { start: s, end: Math.max(s + DETAIL_WINDOW_MIN, Math.min(end, total)) };
1859
+ }
1860
+
1861
+ function detailRangeFor(d) {
1862
+ const total = d.messages.length;
1863
+ const prev = detailRanges.get(d.id) || null;
1864
+ const size = prev ? prev.end - prev.start : DETAIL_WINDOW;
1865
+ const atTail = !prev || prev.end === prev.total;
1866
+ const start = atTail ? total - size : prev.start;
1867
+ const range = { total, ...clampRange(start, start + size, total) };
1868
+ detailRanges.set(d.id, range);
1869
+ return range;
1870
+ }
1871
+
1872
+ function applyDetailRange(messages, range) {
1873
+ detailSlice = messages.slice(range.start, range.end);
1874
+ renderMessageRows(detailSlice, messages.length);
1875
+ const c = getChartColors();
1876
+ if (charts.cumulative && charts.tokenBreakdown) {
1877
+ charts.cumulative.data = cumulativeChartData(detailSlice, c);
1878
+ charts.tokenBreakdown.data = tokenBreakdownChartData(detailSlice, c);
1879
+ for (const chart of [charts.cumulative, charts.tokenBreakdown]) {
1880
+ markCompactions(chart, detailSlice, c);
1881
+ chart.update('none');
1882
+ }
1883
+ } else {
1884
+ renderCumulativeChart(detailSlice);
1885
+ renderTokenBreakdownChart(detailSlice);
1886
+ }
1887
+ syncSubagentShare(detailSlice, c);
1888
+ }
1889
+
1890
+ // The overview shares the brush's percentage coordinates, so it stretches with the track.
1891
+ const RANGE_OVERVIEW_SVG =
1892
+ '<svg class="rb-overview" viewBox="0 0 1000 100" preserveAspectRatio="none" aria-hidden="true"><polygon /><polyline /></svg>';
1893
+
1894
+ function rangeBrushHtml(total) {
1895
+ if (total <= DETAIL_WINDOW) return '';
1896
+ const presets = DETAIL_WINDOW_PRESETS.filter((n) => n < total)
1897
+ .map((n) => `<button type="button" class="rb-preset" data-size="${n}">${n}</button>`)
1898
+ .join('');
1899
+ return `
1900
+ <div class="range-brush" id="rangeBrush">
1901
+ <div class="rb-head">
1902
+ <span class="rb-label"><span class="rb-scope"></span><span class="rb-burst-count" hidden><i></i></span></span>
1903
+ <div class="rb-presets" role="group" aria-label="Window size">
1904
+ ${presets}
1905
+ <button type="button" class="rb-preset" data-size="all">All</button>
1906
+ </div>
1907
+ </div>
1908
+ <div class="rb-track" tabindex="0" role="slider" aria-label="Visible message range"
1909
+ aria-valuemin="1" aria-valuemax="${esc(total)}">
1910
+ ${RANGE_OVERVIEW_SVG}
1911
+ <div class="rb-bursts"></div>
1912
+ <div class="rb-window">
1913
+ <div class="rb-handle rb-handle-left" data-edge="start"></div>
1914
+ <div class="rb-handle rb-handle-right" data-edge="end"></div>
1915
+ </div>
1916
+ </div>
1917
+ </div>`;
1918
+ }
1919
+
1920
+ function initRangeBrush(messages, range) {
1921
+ const root = document.getElementById('rangeBrush');
1922
+ if (!root) return;
1923
+ const total = messages.length;
1924
+ const q = (sel) => root.querySelector(sel);
1925
+ const track = q('.rb-track');
1926
+ const win = q('.rb-window');
1927
+ const scope = q('.rb-scope');
1928
+ const burstCount = q('.rb-burst-count');
1929
+ const presets = [...root.querySelectorAll('.rb-preset')].map((el) => ({
1930
+ el,
1931
+ size: el.dataset.size === 'all' ? total : Number(el.dataset.size),
1932
+ }));
1933
+
1934
+ drawRangeOverview(q('.rb-overview'), messages);
1935
+
1936
+ const markers = notableEvents(messages);
1937
+ const burstsEl = q('.rb-bursts');
1938
+ burstsEl.innerHTML = markers
1939
+ .map(
1940
+ (k) =>
1941
+ `<button type="button" class="rb-burst ${esc(MARKER_KINDS[k.kind].cls)}" data-i="${esc(String(k.i))}" style="left:${((k.i + 0.5) / total) * 100}%" title="${esc(k.tip)}" aria-label="${esc(k.tip)}"></button>`,
1942
+ )
1943
+ .join('');
1944
+ const burstEls = [...burstsEl.children].map((el, k) => ({ el, i: markers[k].i }));
1945
+ const tally = {};
1946
+ for (const k of markers) tally[k.kind] = (tally[k.kind] || 0) + 1;
1947
+ const counts = Object.entries(MARKER_KINDS)
1948
+ .filter(([kind]) => tally[kind])
1949
+ .map(([kind, { cls, label, suffix = '' }]) => {
1950
+ const n = tally[kind];
1951
+ return `<span class="${esc(cls)}"><i></i>${n} ${esc(label)}${n === 1 ? '' : 's'}${esc(suffix)}</span>`;
1952
+ });
1953
+ if (counts.length) {
1954
+ burstCount.hidden = false;
1955
+ burstCount.innerHTML = counts.join('<span class="rb-sep">·</span>');
1956
+ }
1957
+
1958
+ const layout = () => {
1959
+ const size = range.end - range.start;
1960
+ win.style.left = `${(range.start / total) * 100}%`;
1961
+ win.style.width = `${(size / total) * 100}%`;
1962
+ const text =
1963
+ size === total
1964
+ ? `All ${total} messages`
1965
+ : `Messages #${messages[range.start].index} – #${messages[range.end - 1].index} · ${size} of ${total}`;
1966
+ scope.textContent = text;
1967
+ track.setAttribute('aria-valuenow', String(range.start + 1));
1968
+ track.setAttribute('aria-valuetext', text);
1969
+ for (const p of presets) p.el.classList.toggle('on', p.size === size);
1970
+ for (const b of burstEls) b.el.classList.toggle('in', b.i >= range.start && b.i < range.end);
1971
+ };
1972
+
1973
+ // Pointer and wheel events outrun the frame rate, so range writes coalesce into one paint.
1974
+ let frame = 0;
1975
+ const setRange = (from, to) => {
1976
+ const next = clampRange(from, to, total);
1977
+ if (next.start === range.start && next.end === range.end) return;
1978
+ Object.assign(range, next);
1979
+ if (frame) return;
1980
+ frame = requestAnimationFrame(() => {
1981
+ frame = 0;
1982
+ layout();
1983
+ applyDetailRange(messages, range);
1984
+ });
1985
+ };
1986
+ const size = () => range.end - range.start;
1987
+ const step = () => Math.max(1, Math.round(size() / 10));
1988
+ const pan = (delta) => setRange(range.start + delta, range.end + delta);
1989
+ const centerOn = (i) => {
1990
+ const start = Math.max(0, Math.min(i - Math.floor(size() / 2), total - size()));
1991
+ setRange(start, start + size());
1992
+ };
1993
+ const resize = (n) => {
1994
+ const end = Math.min(total, Math.max(range.end, n));
1995
+ setRange(end - n, end);
1996
+ };
1997
+
1998
+ // Pointer capture keeps a fast drag alive after the cursor leaves the thin track. The rect is
1999
+ // read once per drag: reading it per move right after writing the window's style would thrash.
2000
+ track.addEventListener('pointerdown', (e) => {
2001
+ if (e.button !== 0) return;
2002
+ e.preventDefault();
2003
+ track.focus({ preventScroll: true });
2004
+ const rect = track.getBoundingClientRect();
2005
+ const perPx = total / rect.width;
2006
+ const edge = e.target.dataset?.edge;
2007
+ if (!edge && e.target !== win) centerOn(Math.round((e.clientX - rect.left) * perPx));
2008
+ const origin = { x: e.clientX, start: range.start, end: range.end };
2009
+ track.classList.add('dragging');
2010
+ track.setPointerCapture(e.pointerId);
2011
+ const move = (ev) => {
2012
+ const delta = Math.round((ev.clientX - origin.x) * perPx);
2013
+ if (edge === 'start') setRange(origin.start + delta, origin.end);
2014
+ else if (edge === 'end') setRange(origin.start, origin.end + delta);
2015
+ else {
2016
+ const n = origin.end - origin.start;
2017
+ const start = Math.max(0, Math.min(origin.start + delta, total - n));
2018
+ setRange(start, start + n);
2019
+ }
2020
+ };
2021
+ const up = () => {
2022
+ track.classList.remove('dragging');
2023
+ track.removeEventListener('pointermove', move);
2024
+ track.removeEventListener('pointerup', up);
2025
+ track.removeEventListener('pointercancel', up);
2026
+ };
2027
+ track.addEventListener('pointermove', move);
2028
+ track.addEventListener('pointerup', up);
2029
+ track.addEventListener('pointercancel', up);
2030
+ });
2031
+
2032
+ track.addEventListener(
2033
+ 'wheel',
2034
+ (e) => {
2035
+ e.preventDefault();
2036
+ pan((e.deltaY || e.deltaX) > 0 ? step() : -step());
2037
+ },
2038
+ { passive: false },
2039
+ );
2040
+
2041
+ track.addEventListener('keydown', (e) => {
2042
+ const n = e.shiftKey ? size() : step();
2043
+ if (e.key === 'ArrowLeft') pan(-n);
2044
+ else if (e.key === 'ArrowRight') pan(n);
2045
+ else if (e.key === 'Home') setRange(0, size());
2046
+ else if (e.key === 'End') setRange(total - size(), total);
2047
+ else return;
2048
+ e.preventDefault();
2049
+ e.stopPropagation();
2050
+ });
2051
+
2052
+ for (const p of presets) p.el.addEventListener('click', () => resize(p.size));
2053
+
2054
+ burstsEl.addEventListener('pointerdown', (e) => {
2055
+ const b = e.target.closest('.rb-burst');
2056
+ if (!b) return;
2057
+ e.stopPropagation();
2058
+ e.preventDefault();
2059
+ centerOn(Number(b.dataset.i));
2060
+ });
2061
+
2062
+ layout();
2063
+ }
2064
+
2065
+ function drawRangeOverview(svg, messages) {
2066
+ const n = messages.length;
2067
+ const max = messages[n - 1]?.cumulativeCost || 1;
2068
+ const pts = messages.map((m, i) => `${(i / Math.max(1, n - 1)) * 1000},${100 - (m.cumulativeCost / max) * 96 - 2}`);
2069
+ svg.querySelector('polyline').setAttribute('points', pts.join(' '));
2070
+ svg.querySelector('polygon').setAttribute('points', `0,100 ${pts.join(' ')} 1000,100`);
824
2071
  }
825
2072
 
826
2073
  // #endregion
@@ -828,25 +2075,63 @@ function renderTokenBreakdownChart(messages) {
828
2075
  // #region THEME
829
2076
 
830
2077
  const THEME_COLOR_TO_VAR = {
831
- bgDeep: '--bg-deep', bgSurface: '--bg-surface', bgElevated: '--bg-elevated', bgHover: '--bg-hover',
2078
+ bgDeep: '--bg-deep',
2079
+ bgSurface: '--bg-surface',
2080
+ bgElevated: '--bg-elevated',
2081
+ bgHover: '--bg-hover',
832
2082
  border: '--border',
833
- textPrimary: '--text-primary', textSecondary: '--text-secondary',
834
- textTertiary: '--text-tertiary', textMuted: '--text-muted',
835
- accent: '--accent', accentText: '--accent-text',
836
- success: '--success', warning: '--warning', error: '--error',
2083
+ textPrimary: '--text-primary',
2084
+ textSecondary: '--text-secondary',
2085
+ textTertiary: '--text-tertiary',
2086
+ textMuted: '--text-muted',
2087
+ accent: '--accent',
2088
+ accentText: '--accent-text',
2089
+ success: '--success',
2090
+ warning: '--warning',
2091
+ error: '--error',
837
2092
  };
838
2093
 
839
2094
  let _themeCache = { light: null, dark: null };
2095
+ let _themeDefault = { light: null, dark: null };
2096
+ let _themeFamilies = [];
2097
+ const COLOR_THEME_KEY = 'pi-cost:color-theme';
2098
+
2099
+ // Shown in a pi-kanban pane (?embed=1), pi-cost takes the board's colors over postMessage
2100
+ // (pi-companion:theme), saves no theme choice, and hides its own theme controls (body.embed).
2101
+ const EMBED = new URLSearchParams(location.search).get('embed') === '1' && window.parent !== window;
2102
+ let _embedTheme = null;
2103
+
2104
+ function onEmbedMessage(e) {
2105
+ if (e.source !== window.parent || e.data?.type !== 'pi-companion:theme') return;
2106
+ const colors = {};
2107
+ for (const k of Object.keys(THEME_COLOR_TO_VAR)) {
2108
+ const v = e.data.colors?.[k];
2109
+ if (typeof v === 'string' && CSS.supports('color', v)) colors[k] = v;
2110
+ }
2111
+ const light = e.data.mode === 'light';
2112
+ _embedTheme = { mode: light ? 'light' : 'dark', colors };
2113
+ setThemeMode(light);
2114
+ repaint();
2115
+ }
840
2116
 
841
- function _hexToRgb(hex) {
842
- const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || '');
843
- if (!m) return null;
844
- return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
2117
+ function startEmbed() {
2118
+ document.body.classList.add('embed');
2119
+ window.addEventListener('message', onEmbedMessage);
2120
+ window.parent.postMessage({ type: 'pi-companion:ready' }, '*');
845
2121
  }
846
2122
 
847
- function _rgba(hex, a) {
848
- const rgb = _hexToRgb(hex);
849
- return rgb ? `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${a})` : null;
2123
+ function setThemeMode(light) {
2124
+ document.body.classList.toggle('light', light);
2125
+ document.body.classList.toggle('dark-forced', !light);
2126
+ }
2127
+
2128
+ function repaint() {
2129
+ _chartColors = null;
2130
+ applyCurrentThemeColors();
2131
+ updateThemeIcon();
2132
+ renderThemeMenu();
2133
+ lastRenderHash = {};
2134
+ renderCurrentView();
850
2135
  }
851
2136
 
852
2137
  function isLightTheme() {
@@ -858,28 +2143,25 @@ function isLightTheme() {
858
2143
  function applyTheme(theme) {
859
2144
  if (!theme || !theme.colors) return;
860
2145
  const root = document.body.style;
2146
+ const c = {};
861
2147
  for (const [k, cssVar] of Object.entries(THEME_COLOR_TO_VAR)) {
862
- if (theme.colors[k]) root.setProperty(cssVar, theme.colors[k]);
2148
+ const v = theme.colors[k];
2149
+ if (typeof v === 'string' && CSS.supports('color', v)) c[k] = v;
2150
+ if (c[k]) root.setProperty(cssVar, c[k]);
2151
+ else root.removeProperty(cssVar);
863
2152
  }
864
- const c = theme.colors;
865
2153
  const isLight = theme.mode === 'light';
866
2154
  const dimAlpha = isLight ? 0.15 : 0.18;
867
- const accentDimAlpha = isLight ? 0.18 : 0.22;
868
- const accentGlowAlpha = isLight ? 0.5 : 0.55;
869
- const accentFaintAlpha = isLight ? 0.06 : 0.05;
870
- const accentSoftAlpha = 0.12;
871
- const accentTagAlpha = 0.15;
872
- const chartFillAlpha = isLight ? 0.5 : 0.6;
873
- const set = (name, val) => val && root.setProperty(name, val);
874
- set('--accent-dim', _rgba(c.accent, accentDimAlpha));
875
- set('--accent-glow', _rgba(c.accent, accentGlowAlpha));
876
- set('--accent-faint', _rgba(c.accent, accentFaintAlpha));
877
- set('--accent-soft', _rgba(c.accent, accentSoftAlpha));
878
- set('--accent-tag', _rgba(c.accent, accentTagAlpha));
879
- set('--chart-fill', _rgba(c.accent, chartFillAlpha));
880
- set('--success-dim', _rgba(c.success, dimAlpha));
881
- set('--warning-dim', _rgba(c.warning, dimAlpha));
882
- set('--error-dim', _rgba(c.error, dimAlpha));
2155
+ const set = (name, color, alpha) => (color ? root.setProperty(name, withAlpha(color, alpha)) : root.removeProperty(name));
2156
+ set('--accent-dim', c.accent, isLight ? 0.18 : 0.22);
2157
+ set('--accent-glow', c.accent, isLight ? 0.5 : 0.55);
2158
+ set('--accent-faint', c.accent, isLight ? 0.06 : 0.05);
2159
+ set('--accent-soft', c.accent, 0.12);
2160
+ set('--accent-tag', c.accent, 0.15);
2161
+ set('--chart-fill', c.accent, isLight ? 0.5 : 0.6);
2162
+ set('--success-dim', c.success, dimAlpha);
2163
+ set('--warning-dim', c.warning, dimAlpha);
2164
+ set('--error-dim', c.error, dimAlpha);
883
2165
  }
884
2166
 
885
2167
  async function loadActiveThemes() {
@@ -887,13 +2169,75 @@ async function loadActiveThemes() {
887
2169
  const res = await fetch('/api/themes');
888
2170
  if (res.ok) {
889
2171
  const data = await res.json();
890
- _themeCache = { light: data.light, dark: data.dark };
2172
+ _themeDefault = { light: data.light, dark: data.dark };
2173
+ _themeFamilies = data.families || [];
2174
+ const stored = EMBED ? null : storageGet('localStorage', COLOR_THEME_KEY);
2175
+ if (stored && !selectColorTheme(stored)) storageRemove('localStorage', COLOR_THEME_KEY);
2176
+ if (!stored) selectColorTheme(null);
891
2177
  }
892
2178
  } catch {}
893
2179
  }
894
2180
 
2181
+ let _colorThemeId = '';
2182
+
2183
+ function selectColorTheme(id) {
2184
+ const fam = id && _themeFamilies.find((f) => f.id === id);
2185
+ _themeCache = fam ? { light: fam.light, dark: fam.dark } : _themeDefault;
2186
+ _colorThemeId = fam ? fam.id : '';
2187
+ return _colorThemeId;
2188
+ }
2189
+
2190
+ function setColorTheme(id) {
2191
+ if (EMBED) return;
2192
+ if (selectColorTheme(id)) storageSet('localStorage', COLOR_THEME_KEY, _colorThemeId);
2193
+ else storageRemove('localStorage', COLOR_THEME_KEY);
2194
+ closeThemeMenu();
2195
+ repaint();
2196
+ }
2197
+
2198
+ function swatchStyle(t) {
2199
+ const c = t?.colors || {};
2200
+ const vars = { '--sw-bg': c.bgDeep, '--sw-accent': c.accent, '--sw-ink': c.textPrimary, '--sw-border': c.border };
2201
+ return Object.entries(vars)
2202
+ .filter(([, v]) => typeof v === 'string' && CSS.supports('color', v))
2203
+ .map(([k, v]) => `${k}:${v}`)
2204
+ .join(';');
2205
+ }
2206
+
2207
+ function renderThemeMenu() {
2208
+ const menu = document.getElementById('themeMenu');
2209
+ if (!menu || EMBED) return;
2210
+ const mode = isLightTheme() ? 'light' : 'dark';
2211
+ const items = [{ id: '', displayName: 'Default', light: _themeDefault.light, dark: _themeDefault.dark }, ..._themeFamilies];
2212
+ menu.innerHTML = items
2213
+ .map((f) => {
2214
+ const on = f.id === _colorThemeId;
2215
+ return `<button class="theme-menu-item${on ? ' on' : ''}" role="menuitemradio" aria-checked="${on ? 'true' : 'false'}" style="${esc(swatchStyle(f[mode]))}" onclick="event.stopPropagation();setColorTheme('${escAttrJs(f.id)}')" title="${esc(f[mode]?.displayName || f.displayName)}">
2216
+ <span class="theme-swatch"><i class="sw-bg"></i><i class="sw-accent"></i><i class="sw-ink"></i></span>${esc(f.displayName)}
2217
+ </button>`;
2218
+ })
2219
+ .join('');
2220
+ }
2221
+
2222
+ function isThemeMenuOpen() {
2223
+ return !!document.getElementById('themeMenu')?.classList.contains('open');
2224
+ }
2225
+
2226
+ function closeThemeMenu() {
2227
+ document.getElementById('themeMenu')?.classList.remove('open');
2228
+ document.removeEventListener('click', closeThemeMenu);
2229
+ }
2230
+
2231
+ function toggleThemeMenu(e) {
2232
+ if (e?.target.closest?.('.theme-menu')) return;
2233
+ e?.stopPropagation();
2234
+ if (isThemeMenuOpen()) return closeThemeMenu();
2235
+ document.getElementById('themeMenu')?.classList.add('open');
2236
+ document.addEventListener('click', closeThemeMenu);
2237
+ }
2238
+
895
2239
  function applyCurrentThemeColors() {
896
- const t = isLightTheme() ? _themeCache.light : _themeCache.dark;
2240
+ const t = _embedTheme || (isLightTheme() ? _themeCache.light : _themeCache.dark);
897
2241
  if (t) applyTheme(t);
898
2242
  }
899
2243
 
@@ -906,38 +2250,313 @@ function updateThemeIcon() {
906
2250
  }
907
2251
 
908
2252
  function toggleTheme() {
909
- const isCurrentlyLight = isLightTheme();
910
- if (isCurrentlyLight) {
911
- document.body.classList.remove('light');
912
- document.body.classList.add('dark-forced');
913
- localStorage.setItem('theme', 'dark');
2253
+ if (EMBED) return;
2254
+ const nextLight = !isLightTheme();
2255
+ setThemeMode(nextLight);
2256
+ storageSet('localStorage', 'theme', nextLight ? 'light' : 'dark');
2257
+ repaint();
2258
+ }
2259
+
2260
+ function loadTheme() {
2261
+ const saved = EMBED ? null : storageGet('localStorage', 'theme');
2262
+ if (saved === 'light' || saved === 'dark') setThemeMode(saved === 'light');
2263
+ updateThemeIcon();
2264
+ loadActiveThemes().then(repaint);
2265
+ window.matchMedia?.('(prefers-color-scheme: light)').addEventListener?.('change', repaint);
2266
+ }
2267
+
2268
+ // #endregion
2269
+
2270
+ // #region DATE_PICKER
2271
+
2272
+ // Presets are sticky: the preset is stored, and the server recomputes its window per request.
2273
+ const RANGE_PRESETS = [
2274
+ { value: 'today', label: 'Today' },
2275
+ { value: '24h', label: 'Last 24h' },
2276
+ { value: '3', label: '3 Days' },
2277
+ { value: '7', label: '7 Days' },
2278
+ { value: '30', label: '30 Days' },
2279
+ { value: '90', label: '90 Days' },
2280
+ { value: '365', label: '1 Year', cardLabel: '365 Days' },
2281
+ ];
2282
+ const RANGE_DOW = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
2283
+ // Kept in step with the same breakpoint in style.css. One live MediaQueryList, so the listener
2284
+ // added on open is the one removed on close.
2285
+ const oneMonthMql = window.matchMedia('(max-width: 760px)');
2286
+ const RANGE_ARROWS = {
2287
+ prev: '<path d="M6.5 1L2.5 5l4 4"/>',
2288
+ next: '<path d="M3.5 1l4 4-4 4"/>',
2289
+ };
2290
+
2291
+ // Non-null only while the popover is open: it is both the draft and the open flag.
2292
+ let rangeDraft = null;
2293
+
2294
+ function isRangePickerOpen() {
2295
+ return !!rangeDraft;
2296
+ }
2297
+
2298
+ function oneMonthMode() {
2299
+ return oneMonthMql.matches;
2300
+ }
2301
+
2302
+ // What a preset covers, for highlighting only. '24h' straddles the two calendar days it touches.
2303
+ function rangePresetSpan(value) {
2304
+ const today = todayIso();
2305
+ if (value === '24h') return { from: isoShift(today, -1), to: today };
2306
+ const days = Number(value) || 1;
2307
+ return { from: days <= 1 ? today : isoShift(today, -(days - 1)), to: today };
2308
+ }
2309
+
2310
+ function buildRangePresets() {
2311
+ const host = document.getElementById('rangePresets');
2312
+ if (!host) return;
2313
+ for (const p of RANGE_PRESETS) {
2314
+ const b = document.createElement('button');
2315
+ b.type = 'button';
2316
+ b.className = 'range-preset';
2317
+ b.dataset.value = p.value;
2318
+ b.textContent = p.label;
2319
+ b.addEventListener('click', () => {
2320
+ closeRangePicker();
2321
+ applyRange(presetRange(p.value));
2322
+ });
2323
+ host.appendChild(b);
2324
+ }
2325
+ }
2326
+
2327
+ function renderRangeTrigger() {
2328
+ const el = document.getElementById('rangeTriggerLabel');
2329
+ if (el) el.textContent = rangeTriggerLabel(dateRange);
2330
+ }
2331
+
2332
+ function toggleRangePicker(e) {
2333
+ e?.stopPropagation();
2334
+ if (isRangePickerOpen()) closeRangePicker();
2335
+ else openRangePicker();
2336
+ }
2337
+
2338
+ function openRangePicker() {
2339
+ const applied = normalizeRange(dateRange) || presetRange(DEFAULT_RANGE_PRESET);
2340
+ const span = applied.kind === 'custom' ? { from: applied.from, to: applied.to } : rangePresetSpan(applied.value);
2341
+ // The left pane shows the month before the window's end, so today sits in the right one.
2342
+ const [ay, am] = isoParts(span.to);
2343
+ const first = new Date(ay, am - 1 - (oneMonthMode() ? 0 : 1), 1);
2344
+ rangeDraft = {
2345
+ from: span.from,
2346
+ to: span.to,
2347
+ preset: applied.kind === 'custom' ? null : applied.value,
2348
+ picking: 'from',
2349
+ hover: null,
2350
+ month: { y: first.getFullYear(), m: first.getMonth() },
2351
+ };
2352
+ document.getElementById('rangePop').classList.add('open');
2353
+ document.getElementById('rangeTrigger').classList.add('on');
2354
+ // Capture phase: a day click re-renders the grid and detaches its own button, so by the bubble
2355
+ // phase the target is no longer inside #rangePicker and would read as an outside click.
2356
+ document.addEventListener('click', onRangePickerOutsideClick, true);
2357
+ const months = document.getElementById('rangeMonths');
2358
+ months.addEventListener('click', onRangeDayClick);
2359
+ months.addEventListener('mouseover', onRangeDayHover);
2360
+ oneMonthMql.addEventListener('change', renderRangePicker);
2361
+ renderRangePicker();
2362
+ }
2363
+
2364
+ function closeRangePicker() {
2365
+ if (!rangeDraft) return;
2366
+ rangeDraft = null;
2367
+ document.getElementById('rangePop').classList.remove('open');
2368
+ document.getElementById('rangeTrigger').classList.remove('on');
2369
+ document.removeEventListener('click', onRangePickerOutsideClick, true);
2370
+ const months = document.getElementById('rangeMonths');
2371
+ months.removeEventListener('click', onRangeDayClick);
2372
+ months.removeEventListener('mouseover', onRangeDayHover);
2373
+ oneMonthMql.removeEventListener('change', renderRangePicker);
2374
+ }
2375
+
2376
+ function onRangeDayClick(e) {
2377
+ const iso = e.target.dataset?.iso;
2378
+ if (iso) pickRangeDay(iso);
2379
+ }
2380
+
2381
+ function onRangeDayHover(e) {
2382
+ const iso = e.target.dataset?.iso;
2383
+ if (iso && rangeDraft?.from && !rangeDraft.to && rangeDraft.hover !== iso) {
2384
+ rangeDraft.hover = iso;
2385
+ paintRangeSpan();
2386
+ }
2387
+ }
2388
+
2389
+ function onRangePickerOutsideClick(e) {
2390
+ if (!document.getElementById('rangePicker').contains(e.target)) closeRangePicker();
2391
+ }
2392
+
2393
+ function aimRangePicker(which) {
2394
+ if (!rangeDraft) return;
2395
+ rangeDraft.picking = which;
2396
+ renderRangePicker();
2397
+ }
2398
+
2399
+ function applyRangeDraft() {
2400
+ const d = rangeDraft;
2401
+ if (!d?.from || !d.to) return;
2402
+ const next = { kind: 'custom', from: d.from, to: d.to };
2403
+ closeRangePicker();
2404
+ applyRange(next);
2405
+ }
2406
+
2407
+ function pickRangeDay(iso) {
2408
+ const d = rangeDraft;
2409
+ d.preset = null;
2410
+ if (d.picking === 'from' || !d.from) {
2411
+ d.from = iso;
2412
+ d.to = null;
2413
+ d.picking = 'to';
2414
+ } else if (iso < d.from) {
2415
+ d.to = d.from;
2416
+ d.from = iso;
2417
+ d.picking = 'from';
914
2418
  } else {
915
- document.body.classList.add('light');
916
- document.body.classList.remove('dark-forced');
917
- localStorage.setItem('theme', 'light');
2419
+ d.to = iso;
2420
+ d.picking = 'from';
2421
+ }
2422
+ d.hover = null;
2423
+ renderRangePicker();
2424
+ }
2425
+
2426
+ function rangeMonthStep(step) {
2427
+ const c = new Date(rangeDraft.month.y, rangeDraft.month.m + step, 1);
2428
+ rangeDraft.month = { y: c.getFullYear(), m: c.getMonth() };
2429
+ renderRangePicker();
2430
+ }
2431
+
2432
+ // There is no cost data ahead of today, so the calendar stops at the current month.
2433
+ function rangeNextDisabled() {
2434
+ const last = new Date(rangeDraft.month.y, rangeDraft.month.m + (oneMonthMode() ? 0 : 1), 1);
2435
+ const now = new Date();
2436
+ return last >= new Date(now.getFullYear(), now.getMonth(), 1);
2437
+ }
2438
+
2439
+ function rangeDraftSpan() {
2440
+ const d = rangeDraft;
2441
+ if (!d?.from) return [null, null];
2442
+ if (d.to) return [d.from, d.to];
2443
+ if (!d.hover) return [d.from, d.from];
2444
+ return d.hover < d.from ? [d.hover, d.from] : [d.from, d.hover];
2445
+ }
2446
+
2447
+ // Sole owner of the four span classes, so a hover repaint toggles them in place instead of
2448
+ // rebuilding both grids.
2449
+ function paintRangeSpan() {
2450
+ const [a, b] = rangeDraftSpan();
2451
+ for (const btn of document.querySelectorAll('#rangeMonths .range-day[data-iso]')) {
2452
+ const iso = btn.dataset.iso;
2453
+ const inSpan = !!(a && b && iso >= a && iso <= b);
2454
+ const edge = inSpan && (iso === a || iso === b);
2455
+ btn.classList.toggle('edge', edge);
2456
+ btn.classList.toggle('in', inSpan && !edge);
2457
+ btn.classList.toggle('start', inSpan && !edge && isoShift(iso, -1) === a);
2458
+ btn.classList.toggle('end', inSpan && !edge && isoShift(iso, 1) === b);
918
2459
  }
919
- applyCurrentThemeColors();
920
- updateThemeIcon();
921
- lastRenderHash = {};
922
- renderCurrentView();
923
2460
  }
924
2461
 
925
- function loadTheme() {
926
- const saved = localStorage.getItem('theme');
927
- if (saved === 'light') {
928
- document.body.classList.add('light');
929
- document.body.classList.remove('dark-forced');
930
- } else if (saved === 'dark') {
931
- document.body.classList.remove('light');
932
- document.body.classList.add('dark-forced');
2462
+ function renderRangePicker() {
2463
+ const d = rangeDraft;
2464
+ if (!d) return;
2465
+
2466
+ for (const b of document.querySelectorAll('#rangePresets .range-preset')) {
2467
+ b.classList.toggle('on', b.dataset.value === d.preset);
933
2468
  }
934
- // No saved pref — prefers-color-scheme CSS handles the initial paint.
935
- updateThemeIcon();
936
- loadActiveThemes().then(() => {
937
- applyCurrentThemeColors();
938
- lastRenderHash = {};
939
- renderCurrentView();
940
- });
2469
+
2470
+ const fromBox = document.getElementById('rangeFromBox');
2471
+ const toBox = document.getElementById('rangeToBox');
2472
+ fromBox.textContent = d.from ? isoDayLabel(d.from) : '—';
2473
+ toBox.textContent = d.to ? isoDayLabel(d.to) : '—';
2474
+ fromBox.classList.toggle('empty', !d.from);
2475
+ toBox.classList.toggle('empty', !d.to);
2476
+ fromBox.classList.toggle('active', d.picking === 'from');
2477
+ toBox.classList.toggle('active', d.picking === 'to');
2478
+
2479
+ const [a, b] = rangeDraftSpan();
2480
+ const days = d.from && d.to ? isoDayCount(a, b) : 0;
2481
+ document.getElementById('rangeSummary').textContent = days ? `${days} ${days === 1 ? 'day' : 'days'}` : 'pick an end date';
2482
+ document.getElementById('rangeApply').disabled = !(d.from && d.to);
2483
+
2484
+ const host = document.getElementById('rangeMonths');
2485
+ host.innerHTML = '';
2486
+ const single = oneMonthMode();
2487
+ const first = new Date(d.month.y, d.month.m, 1);
2488
+ host.appendChild(buildRangeMonth(first.getFullYear(), first.getMonth(), { prev: true, next: single }));
2489
+ if (!single) {
2490
+ const second = new Date(d.month.y, d.month.m + 1, 1);
2491
+ host.appendChild(buildRangeMonth(second.getFullYear(), second.getMonth(), { prev: false, next: true }));
2492
+ }
2493
+ paintRangeSpan();
2494
+ }
2495
+
2496
+ function rangeNavButton(dir) {
2497
+ const step = dir === 'next' ? 1 : -1;
2498
+ const btn = document.createElement('button');
2499
+ btn.type = 'button';
2500
+ btn.className = 'range-nav';
2501
+ btn.innerHTML = `<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">${RANGE_ARROWS[dir]}</svg>`;
2502
+ if (step > 0) btn.disabled = rangeNextDisabled();
2503
+ btn.addEventListener('click', () => rangeMonthStep(step));
2504
+ return btn;
2505
+ }
2506
+
2507
+ function rangeNavGap() {
2508
+ const gap = document.createElement('span');
2509
+ gap.className = 'range-nav-gap';
2510
+ return gap;
2511
+ }
2512
+
2513
+ function buildRangeMonth(y, m, nav) {
2514
+ const wrap = document.createElement('div');
2515
+ wrap.className = 'range-cal';
2516
+
2517
+ const head = document.createElement('div');
2518
+ head.className = 'range-cal-head';
2519
+ head.appendChild(nav.prev ? rangeNavButton('prev') : rangeNavGap());
2520
+ const titleEl = document.createElement('span');
2521
+ titleEl.className = 'range-cal-title';
2522
+ titleEl.textContent = `${new Date(y, m, 1).toLocaleDateString(undefined, { month: 'short' }).toUpperCase()} ${y}`;
2523
+ head.appendChild(titleEl);
2524
+ head.appendChild(nav.next ? rangeNavButton('next') : rangeNavGap());
2525
+ wrap.appendChild(head);
2526
+
2527
+ const dow = document.createElement('div');
2528
+ dow.className = 'range-dow';
2529
+ dow.innerHTML = RANGE_DOW.map((d) => `<span>${d}</span>`).join('');
2530
+ wrap.appendChild(dow);
2531
+
2532
+ const grid = document.createElement('div');
2533
+ grid.className = 'range-grid';
2534
+ const lead = (new Date(y, m, 1).getDay() + 6) % 7;
2535
+ const dayCount = new Date(y, m + 1, 0).getDate();
2536
+ const today = todayIso();
2537
+
2538
+ for (let i = 0; i < lead; i++) {
2539
+ const pad = document.createElement('span');
2540
+ pad.className = 'range-day pad';
2541
+ grid.appendChild(pad);
2542
+ }
2543
+ for (let day = 1; day <= dayCount; day++) {
2544
+ const iso = isoDate(new Date(y, m, day));
2545
+ const btn = document.createElement('button');
2546
+ btn.type = 'button';
2547
+ btn.className = 'range-day';
2548
+ btn.textContent = day;
2549
+ if (iso === today) btn.classList.add('today');
2550
+ if (iso > today) {
2551
+ btn.classList.add('off');
2552
+ btn.disabled = true;
2553
+ } else {
2554
+ btn.dataset.iso = iso;
2555
+ }
2556
+ grid.appendChild(btn);
2557
+ }
2558
+ wrap.appendChild(grid);
2559
+ return wrap;
941
2560
  }
942
2561
 
943
2562
  // #endregion
@@ -962,24 +2581,20 @@ async function navigate(view, params, urlMode = 'push') {
962
2581
  if (params?.projectName) currentProjectName = params.projectName;
963
2582
  if (params?.session) currentSessionId = params.session;
964
2583
 
965
- if (view === 'overview' || view === 'projects') {
2584
+ if (TOP_VIEWS.includes(view)) {
966
2585
  currentProjectDir = null;
967
2586
  currentProjectName = null;
968
2587
  currentSessionId = null;
969
2588
  }
970
- if (view === 'sessions') {
971
- currentSessionId = null;
972
- }
2589
+ if (view === 'sessions') currentSessionId = null;
973
2590
 
974
- const navView = view === 'sessions' || view === 'detail' ? parentView : view;
975
- setActiveNav(navView);
2591
+ setActiveNav(TOP_VIEWS.includes(view) ? view : 'overview');
976
2592
  updateUrl(urlMode);
977
2593
  await loadAndRender(view);
978
2594
  if (view !== 'detail') selectRow(0);
979
2595
  }
980
2596
 
981
2597
  async function navigateToSessions(projectDir, name) {
982
- if (currentView === 'overview' || currentView === 'projects') parentView = currentView;
983
2598
  lastSelectedProject = projectDir;
984
2599
  currentProjectDir = projectDir;
985
2600
  currentProjectName = name;
@@ -996,20 +2611,26 @@ async function navigateToDetail(sessionId) {
996
2611
 
997
2612
  function sortBy(field) {
998
2613
  const s = currentSort();
2614
+ if (!s) return;
999
2615
  if (s.field === field) s.order = s.order === 'desc' ? 'asc' : 'desc';
1000
- else { s.field = field; s.order = 'desc'; }
2616
+ else {
2617
+ s.field = field;
2618
+ s.order = 'desc';
2619
+ }
1001
2620
  lastRenderHash[currentView] = null;
1002
2621
  saveSort();
1003
2622
  updateUrl('replace');
1004
2623
  if (currentView === 'overview') renderOverview();
1005
2624
  else if (currentView === 'sessions') renderSessions();
1006
- else renderProjects();
1007
2625
  }
1008
2626
 
1009
- async function onRangeChange(val) {
1010
- dateRange = val === 'today' ? 'today' : parseInt(val, 10) || 7;
2627
+ // The one entry point for a range change. The renderers are hash-gated, so the hash reset is what
2628
+ // makes them repaint the new window.
2629
+ async function applyRange(next) {
2630
+ dateRange = next;
1011
2631
  saveDateRange(dateRange);
1012
2632
  lastRenderHash = {};
2633
+ renderRangeTrigger();
1013
2634
  updateUrl('replace');
1014
2635
  const t = showToast(`Recalculating for ${rangeLabel(dateRange)}...`, true);
1015
2636
  await loadAndRender(currentView);
@@ -1024,14 +2645,14 @@ async function refreshData() {
1024
2645
  const minWait = new Promise((r) => setTimeout(r, 250));
1025
2646
  try {
1026
2647
  await fetch('/api/refresh', { method: 'POST' });
2648
+ dataCache.clear();
1027
2649
  forceRefresh = true;
1028
2650
  lastRenderHash = {};
1029
- overviewData = projectsData = sessionsData = sessionDetailData = null;
1030
2651
  await loadAndRender(currentView);
1031
- forceRefresh = false;
1032
2652
  await minWait;
1033
2653
  showToast('Data refreshed', false, 'success');
1034
2654
  } finally {
2655
+ forceRefresh = false;
1035
2656
  dismissToast(t);
1036
2657
  btn.classList.remove('loading');
1037
2658
  btn.disabled = false;
@@ -1045,9 +2666,7 @@ async function loadAndRender(view) {
1045
2666
  showView(`${view}-view`);
1046
2667
 
1047
2668
  const viewEl = document.getElementById(`${view}-view`);
1048
- if (viewEl && !viewEl.querySelector('.dashboard-content')) {
1049
- viewEl.innerHTML = '<div class="loading-state"><div class="loading-spinner"></div><span>Loading...</span></div>';
1050
- }
2669
+ if (viewEl && !viewEl.querySelector('.dashboard-content')) viewEl.innerHTML = skeletonHtml(view);
1051
2670
 
1052
2671
  try {
1053
2672
  switch (view) {
@@ -1056,13 +2675,17 @@ async function loadAndRender(view) {
1056
2675
  if (myNav !== navCounter) return;
1057
2676
  renderOverview();
1058
2677
  break;
1059
- case 'projects':
1060
- await fetchProjects();
2678
+ case 'insights':
2679
+ await fetchInsights();
1061
2680
  if (myNav !== navCounter) return;
1062
- renderProjects();
2681
+ renderInsights();
1063
2682
  break;
1064
2683
  case 'sessions':
1065
2684
  if (currentProjectDir) {
2685
+ if (!sessionsData) {
2686
+ lastRenderHash.sessions = null;
2687
+ renderSessions();
2688
+ }
1066
2689
  await fetchSessions(currentProjectDir);
1067
2690
  if (myNav !== navCounter) return;
1068
2691
  renderSessions();
@@ -1070,6 +2693,10 @@ async function loadAndRender(view) {
1070
2693
  break;
1071
2694
  case 'detail':
1072
2695
  if (currentSessionId) {
2696
+ if (!sessionDetailData) {
2697
+ lastRenderHash.detail = null;
2698
+ renderDetail();
2699
+ }
1073
2700
  await fetchSessionDetail(currentSessionId);
1074
2701
  if (myNav !== navCounter) return;
1075
2702
  renderDetail();
@@ -1080,17 +2707,29 @@ async function loadAndRender(view) {
1080
2707
  } catch (err) {
1081
2708
  if (myNav !== navCounter) return;
1082
2709
  console.error(`Failed to load ${view}:`, err);
2710
+ // The cursor comes back from sessionStorage, so it can point at a session another agent dir
2711
+ // never had. A 404 there is a stale cursor: drop it and land on the overview.
2712
+ if (err.status === 404 && view === 'detail') {
2713
+ sessionDetailData = null;
2714
+ showToast('Session not found — showing overview');
2715
+ await navigate('overview', undefined, 'replace');
2716
+ return;
2717
+ }
1083
2718
  showToast(`Error: ${err.message}`);
1084
2719
  if (viewEl) viewEl.innerHTML = `<div class="loading-state"><span>Failed to load: ${esc(err.message)}</span></div>`;
2720
+ } finally {
2721
+ if (myNav === navCounter) booted = true;
1085
2722
  }
1086
2723
  }
1087
2724
 
1088
- function renderCurrentView() { loadAndRender(currentView); }
2725
+ function renderCurrentView() {
2726
+ loadAndRender(currentView);
2727
+ }
1089
2728
 
1090
2729
  function ensureViewElements() {
1091
2730
  const app = document.getElementById('app');
1092
2731
  if (!app) return;
1093
- for (const v of ['overview', 'projects', 'sessions', 'detail']) {
2732
+ for (const v of VIEWS) {
1094
2733
  if (!document.getElementById(`${v}-view`)) {
1095
2734
  const div = document.createElement('div');
1096
2735
  div.id = `${v}-view`;
@@ -1106,7 +2745,57 @@ function ensureViewElements() {
1106
2745
 
1107
2746
  // #region MODAL
1108
2747
 
2748
+ const SHORTCUT_GROUPS = [
2749
+ {
2750
+ title: 'Navigation',
2751
+ rows: [
2752
+ { keys: ['1'], label: 'Overview' },
2753
+ { keys: ['2'], label: 'Insights' },
2754
+ { keys: ['Backspace'], label: 'Go back' },
2755
+ { keys: ['Esc'], label: 'Close / back' },
2756
+ ],
2757
+ },
2758
+ {
2759
+ title: 'Table',
2760
+ rows: [
2761
+ { keys: ['J', '↓'], label: 'Next row' },
2762
+ { keys: ['K', '↑'], label: 'Previous row' },
2763
+ { keys: ['Enter', 'Space'], label: 'Open' },
2764
+ ],
2765
+ },
2766
+ {
2767
+ title: 'Timeline range',
2768
+ rows: [
2769
+ { keys: ['←', '→'], label: 'Pan one step' },
2770
+ { keys: ['Shift', '←/→'], combo: true, label: 'Pan a full window' },
2771
+ { keys: ['Home', 'End'], label: 'Jump to start / end' },
2772
+ ],
2773
+ },
2774
+ {
2775
+ title: 'Actions',
2776
+ rows: [
2777
+ { keys: ['R'], label: 'Refresh' },
2778
+ { keys: ['T'], label: 'Toggle theme' },
2779
+ { keys: ['?'], label: 'This help' },
2780
+ ],
2781
+ },
2782
+ ];
2783
+
2784
+ function buildHelpShortcuts() {
2785
+ return SHORTCUT_GROUPS.map((g) => {
2786
+ const rows = g.rows
2787
+ .map((r) => {
2788
+ const sep = r.combo ? ' + ' : ' / ';
2789
+ return `<tr><td>${r.keys.map((k) => `<kbd>${esc(k)}</kbd>`).join(sep)}</td><td>${esc(r.label)}</td></tr>`;
2790
+ })
2791
+ .join('');
2792
+ return `<div><h4 class="shortcut-section">${esc(g.title)}</h4><table class="shortcut-table">${rows}</table></div>`;
2793
+ }).join('');
2794
+ }
2795
+
1109
2796
  function toggleHelpModal() {
2797
+ const list = document.getElementById('helpShortcuts');
2798
+ if (list && !list.childElementCount) list.innerHTML = buildHelpShortcuts();
1110
2799
  document.getElementById('helpModal').classList.toggle('visible');
1111
2800
  }
1112
2801
 
@@ -1144,36 +2833,75 @@ async function goBack() {
1144
2833
  target = 'sessions';
1145
2834
  await navigate('sessions', { project: currentProjectDir, projectName: currentProjectName });
1146
2835
  } else {
1147
- target = parentView;
1148
- await navigate(parentView);
2836
+ target = 'overview';
2837
+ await navigate('overview');
1149
2838
  }
1150
- } else if (currentView === 'sessions') {
1151
- target = parentView;
1152
- await navigate(parentView);
1153
- } else if (currentView === 'projects') {
2839
+ } else if (currentView === 'sessions' || currentView === 'insights') {
1154
2840
  target = 'overview';
1155
2841
  await navigate('overview');
1156
2842
  }
1157
2843
  if (target) focusPreviousRow(target);
1158
2844
  }
1159
2845
 
2846
+ const KEY_VIEWS = { 1: 'overview', 2: 'insights' };
2847
+
1160
2848
  document.addEventListener('keydown', (e) => {
1161
2849
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
1162
2850
 
1163
2851
  const anyModal = document.querySelector('.modal-overlay.visible');
1164
2852
  if (anyModal) {
1165
- if (e.key === 'Escape') { anyModal.classList.remove('visible'); e.preventDefault(); }
2853
+ if (e.key === 'Escape' || (e.key === '?' && anyModal.id === 'helpModal')) {
2854
+ anyModal.classList.remove('visible');
2855
+ e.preventDefault();
2856
+ }
2857
+ return;
2858
+ }
2859
+
2860
+ // The popover is a div, so the INPUT guard does not cover it, and r/t/j/k would fire through it.
2861
+ if (isRangePickerOpen()) {
2862
+ if (e.key === 'Escape') {
2863
+ closeRangePicker();
2864
+ e.preventDefault();
2865
+ }
1166
2866
  return;
1167
2867
  }
1168
- if (e.ctrlKey || e.altKey || e.metaKey) return;
1169
2868
 
1170
- if (e.key === '?' || (e.key === '/' && e.shiftKey)) { e.preventDefault(); toggleHelpModal(); return; }
1171
- if (e.key === '1') { e.preventDefault(); navigate('overview'); return; }
1172
- if (e.key === '2') { e.preventDefault(); navigate('projects'); return; }
1173
- if (e.key === 'r') { e.preventDefault(); refreshData(); return; }
1174
- if (e.key === 't') { e.preventDefault(); toggleTheme(); return; }
1175
- if (e.key === 'Escape' || e.key === 'Backspace') { e.preventDefault(); goBack(); return; }
2869
+ if (isThemeMenuOpen()) {
2870
+ if (e.key === 'Escape') {
2871
+ closeThemeMenu();
2872
+ document.getElementById('themePickerBtn')?.focus();
2873
+ e.preventDefault();
2874
+ }
2875
+ return;
2876
+ }
1176
2877
 
2878
+ if (e.ctrlKey || e.altKey || e.metaKey) return;
2879
+
2880
+ if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
2881
+ e.preventDefault();
2882
+ toggleHelpModal();
2883
+ return;
2884
+ }
2885
+ if (KEY_VIEWS[e.key]) {
2886
+ e.preventDefault();
2887
+ navigate(KEY_VIEWS[e.key]);
2888
+ return;
2889
+ }
2890
+ if (e.key === 'r') {
2891
+ e.preventDefault();
2892
+ refreshData();
2893
+ return;
2894
+ }
2895
+ if (e.key === 't') {
2896
+ e.preventDefault();
2897
+ toggleTheme();
2898
+ return;
2899
+ }
2900
+ if (e.key === 'Escape' || e.key === 'Backspace') {
2901
+ e.preventDefault();
2902
+ goBack();
2903
+ return;
2904
+ }
1177
2905
  if (e.key === 'j' || e.key === 'ArrowDown') {
1178
2906
  e.preventDefault();
1179
2907
  const rows = getVisibleRows();
@@ -1186,7 +2914,10 @@ document.addEventListener('keydown', (e) => {
1186
2914
  if (rows.length) selectRow(Math.max(selectedRowIdx - 1, 0));
1187
2915
  return;
1188
2916
  }
1189
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activateSelectedRow(); return; }
2917
+ if (e.key === 'Enter' || e.key === ' ') {
2918
+ e.preventDefault();
2919
+ activateSelectedRow();
2920
+ }
1190
2921
  });
1191
2922
 
1192
2923
  // #endregion
@@ -1204,7 +2935,35 @@ function showToast(msg, persistent, type) {
1204
2935
  return el;
1205
2936
  }
1206
2937
 
1207
- function dismissToast(el) { if (el) el.remove(); }
2938
+ function dismissToast(el) {
2939
+ if (el) el.remove();
2940
+ }
2941
+
2942
+ // #endregion
2943
+
2944
+ // #region AUTO_REFRESH
2945
+
2946
+ const STALE_CHECK_MS = 30_000;
2947
+ // The interval and visibilitychange share refreshIfStale, so a second trigger must not clear
2948
+ // forceRefresh under the first.
2949
+ let refreshing = false;
2950
+
2951
+ async function refreshIfStale() {
2952
+ if (refreshing || document.hidden) return;
2953
+ if (Date.now() - dataFetchedAt < BROWSER_CACHE_TTL) return;
2954
+ refreshing = true;
2955
+ forceRefresh = true;
2956
+ try {
2957
+ await loadAndRender(currentView);
2958
+ } catch {
2959
+ } finally {
2960
+ forceRefresh = false;
2961
+ refreshing = false;
2962
+ }
2963
+ }
2964
+
2965
+ setInterval(refreshIfStale, STALE_CHECK_MS);
2966
+ document.addEventListener('visibilitychange', refreshIfStale);
1208
2967
 
1209
2968
  // #endregion
1210
2969
 
@@ -1212,8 +2971,7 @@ function dismissToast(el) { if (el) el.remove(); }
1212
2971
 
1213
2972
  (async function initPi() {
1214
2973
  try {
1215
- const cfg = await fetch('/pi-config').then((r) => r.json());
1216
- window.__PI__ = cfg;
2974
+ window.__PI__ = await fetch('/pi-config').then((r) => r.json());
1217
2975
  } catch {}
1218
2976
  })();
1219
2977
 
@@ -1222,7 +2980,29 @@ function dismissToast(el) { if (el) el.remove(); }
1222
2980
  // #region PWA
1223
2981
 
1224
2982
  if ('serviceWorker' in navigator) {
1225
- navigator.serviceWorker.register('/sw.js');
2983
+ navigator.serviceWorker.register('/sw.js').catch(() => {});
2984
+ }
2985
+
2986
+ let deferredInstallPrompt = null;
2987
+
2988
+ function setInstallPrompt(e) {
2989
+ deferredInstallPrompt = e;
2990
+ const btn = document.getElementById('installBtn');
2991
+ if (btn) btn.hidden = !e;
2992
+ }
2993
+
2994
+ window.addEventListener('beforeinstallprompt', (e) => {
2995
+ e.preventDefault();
2996
+ setInstallPrompt(e);
2997
+ });
2998
+
2999
+ window.addEventListener('appinstalled', () => setInstallPrompt(null));
3000
+
3001
+ async function promptInstall() {
3002
+ const e = deferredInstallPrompt;
3003
+ if (!e) return;
3004
+ setInstallPrompt(null);
3005
+ await e.prompt();
1226
3006
  }
1227
3007
 
1228
3008
  // #endregion
@@ -1230,61 +3010,65 @@ if ('serviceWorker' in navigator) {
1230
3010
  // #region INIT
1231
3011
 
1232
3012
  loadTheme();
3013
+ if (EMBED) startEmbed();
1233
3014
  loadSort();
1234
3015
 
1235
3016
  document.addEventListener('click', async (e) => {
1236
3017
  if (e.target.matches('.parent-breadcrumb')) {
1237
- const target = parentView;
1238
- await navigate(target);
1239
- focusPreviousRow(target);
3018
+ await navigate('overview');
3019
+ focusPreviousRow('overview');
1240
3020
  }
1241
3021
  });
1242
3022
 
1243
3023
  document.addEventListener('DOMContentLoaded', async () => {
1244
3024
  const state = getUrlState();
1245
3025
  dateRange = loadDateRange();
1246
- const rs = document.getElementById('rangeSelect');
1247
- if (rs) rs.value = dateRange;
3026
+ buildRangePresets();
3027
+ renderRangeTrigger();
1248
3028
 
1249
3029
  if (state.session) {
1250
3030
  currentSessionId = state.session;
1251
- parentView = state.parentView;
1252
3031
  currentProjectDir = state.project;
1253
3032
  currentProjectName = state.projectName;
1254
3033
  await navigate('detail', { session: state.session }, 'replace');
1255
3034
  } else if (state.project) {
1256
- parentView = state.parentView;
1257
3035
  currentProjectDir = state.project;
1258
3036
  currentProjectName = state.projectName;
1259
3037
  await navigate('sessions', { project: state.project, projectName: state.projectName }, 'replace');
1260
- } else if (state.view === 'projects') {
1261
- await navigate('projects', undefined, 'replace');
1262
3038
  } else {
1263
- await navigate('overview', undefined, 'replace');
3039
+ await navigate(TOP_VIEWS.includes(state.view) ? state.view : 'overview', undefined, 'replace');
1264
3040
  }
1265
3041
  });
1266
3042
 
1267
3043
  window.addEventListener('popstate', () => {
1268
3044
  const p = new URLSearchParams(window.location.search);
1269
- currentView = p.get('view') || 'overview';
1270
- parentView = p.get('parentView') || 'projects';
3045
+ currentView = VIEWS.includes(p.get('view')) ? p.get('view') : 'overview';
1271
3046
  currentProjectDir = p.get('project');
1272
3047
  currentProjectName = p.get('projectName');
1273
3048
  currentSessionId = p.get('session');
3049
+ if (p.has('session') && currentView === 'overview') currentView = 'detail';
1274
3050
  lastRenderHash = {};
1275
- sessionStorage.setItem('pi-cost:nav', p.toString());
1276
- setActiveNav(currentView === 'sessions' || currentView === 'detail' ? parentView : currentView);
3051
+ storageSet('sessionStorage', 'pi-cost:nav', p.toString());
3052
+ setActiveNav(TOP_VIEWS.includes(currentView) ? currentView : 'overview');
1277
3053
  loadAndRender(currentView);
1278
3054
  });
1279
3055
 
1280
- // Expose for inline handlers
1281
- window.navigate = navigate;
1282
- window.navigateToSessions = navigateToSessions;
1283
- window.navigateToDetail = navigateToDetail;
1284
- window.sortBy = sortBy;
1285
- window.onRangeChange = onRangeChange;
1286
- window.refreshData = refreshData;
1287
- window.toggleTheme = toggleTheme;
1288
- window.toggleHelpModal = toggleHelpModal;
3056
+ // Exposed for the inline handlers in markup.
3057
+ Object.assign(window, {
3058
+ navigate,
3059
+ navigateToSessions,
3060
+ navigateToDetail,
3061
+ sortBy,
3062
+ refreshData,
3063
+ toggleTheme,
3064
+ toggleThemeMenu,
3065
+ setColorTheme,
3066
+ toggleHelpModal,
3067
+ toggleRangePicker,
3068
+ closeRangePicker,
3069
+ aimRangePicker,
3070
+ applyRangeDraft,
3071
+ promptInstall,
3072
+ });
1289
3073
 
1290
3074
  // #endregion