@vimoxshah/tokenflow 1.1.2 → 1.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 (86) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/Dockerfile.team +20 -0
  3. package/README.md +30 -11
  4. package/bin/tokenflow.js +147 -12
  5. package/design/tokens.yaml +330 -0
  6. package/docs/architecture.md +5 -4
  7. package/docs/cli.md +204 -0
  8. package/docs/configuration.md +117 -2
  9. package/docs/design-system.md +187 -0
  10. package/docs/exports-and-budgets.md +85 -0
  11. package/docs/guard-codex.md +132 -0
  12. package/docs/ledger.md +144 -0
  13. package/docs/live-mode.md +40 -0
  14. package/docs/media/overview-aurora-dark.png +0 -0
  15. package/docs/media/receipts-aurora-dark.png +0 -0
  16. package/docs/providers-otel.md +179 -0
  17. package/docs/providers.md +54 -1
  18. package/docs/receipt-schema.md +74 -0
  19. package/docs/roadmap.md +182 -0
  20. package/docs/team-server.md +170 -0
  21. package/docs/ui-views.md +322 -0
  22. package/package.json +7 -2
  23. package/schemas/receipt.v0.json +160 -0
  24. package/scripts/build-menubar-app.sh +3 -1
  25. package/scripts/design-build.js +475 -0
  26. package/src/analytics/anatomy.js +467 -0
  27. package/src/analytics/branch-compare.js +159 -0
  28. package/src/analytics/cache-health.js +141 -0
  29. package/src/analytics/live-view.js +266 -0
  30. package/src/analytics/receipt-schema.js +214 -0
  31. package/src/analytics/receipt.js +709 -0
  32. package/src/analytics/rhythm.js +184 -0
  33. package/src/analytics/whatif.js +263 -0
  34. package/src/commands/budget-scopes.js +133 -0
  35. package/src/commands/doctor-checks.js +400 -0
  36. package/src/commands/guard.js +531 -0
  37. package/src/commands/hooks.js +238 -0
  38. package/src/commands/pricing-diff.js +316 -0
  39. package/src/commands/receipt.js +226 -0
  40. package/src/commands/team-serve.js +407 -0
  41. package/src/commands/week.js +86 -0
  42. package/src/core/annotations.js +97 -0
  43. package/src/core/budget.js +33 -0
  44. package/src/core/bundle.js +45 -2
  45. package/src/core/ingest.js +33 -0
  46. package/src/core/live-status.js +227 -2
  47. package/src/core/policy.js +103 -0
  48. package/src/core/receipt-note.js +123 -0
  49. package/src/core/repo.js +64 -0
  50. package/src/core/sync.js +163 -26
  51. package/src/core/team.js +0 -0
  52. package/src/export/html-snapshot.js +28 -1
  53. package/src/export/menubar.js +21 -0
  54. package/src/export/receipt-card.js +210 -0
  55. package/src/export/week-card.js +185 -0
  56. package/src/providers/mock/index.js +383 -52
  57. package/src/providers/openai/index.js +31 -1
  58. package/src/providers/otel/index.js +656 -0
  59. package/src/server/routes/annotations.js +42 -0
  60. package/src/server/routes/cache-health.js +95 -0
  61. package/src/server/routes/index.js +54 -0
  62. package/src/server/routes/session.js +157 -0
  63. package/src/server/server.js +47 -1
  64. package/src/ui/app.js +541 -308
  65. package/src/ui/charts.js +95 -0
  66. package/src/ui/first-run.js +144 -0
  67. package/src/ui/index.html +4 -1
  68. package/src/ui/palette.js +335 -0
  69. package/src/ui/styles/anatomy.css +117 -0
  70. package/src/ui/styles/annotations.css +40 -0
  71. package/src/ui/styles/branches.css +99 -0
  72. package/src/ui/styles/cache.css +6 -0
  73. package/src/ui/styles/first-run.css +31 -0
  74. package/src/ui/styles/live.css +100 -0
  75. package/src/ui/styles/palette.css +85 -0
  76. package/src/ui/styles/rhythm.css +8 -0
  77. package/src/ui/styles/whatif.css +55 -0
  78. package/src/ui/styles.css +303 -196
  79. package/src/ui/views/anatomy.js +567 -0
  80. package/src/ui/views/annotations.js +121 -0
  81. package/src/ui/views/branches.js +304 -0
  82. package/src/ui/views/cache.js +232 -0
  83. package/src/ui/views/index.js +85 -0
  84. package/src/ui/views/live.js +683 -0
  85. package/src/ui/views/rhythm.js +206 -0
  86. package/src/ui/views/whatif.js +196 -0
package/src/ui/app.js CHANGED
@@ -11,11 +11,16 @@ import { indexCube, filterCube } from '../analytics/aggregate.js';
11
11
  import { calculateDimensionSeries } from '../analytics/dimensions.js';
12
12
  import { compact, int, usd, pct, signedPct, shortDate, longDate, hourLabel, hourWindow, relativeTime, humanDuration, countdown, DOW } from '../core/units.js';
13
13
  import { INTERFACE_ORDER } from '../core/schema.js';
14
+ import { renderReceiptMarkdown } from '../analytics/receipt.js';
14
15
  import {
15
16
  el, svg, timeSeries, columns, hbars, donut, compositionBar, calendarHeatmap,
16
17
  matrix, scatter, sparkline, legend, table, miniBar, tooltip, observeWidth,
17
18
  ColorScale, SERIES_VARS, OTHER_COLOR, scaleLegend,
18
19
  } from './charts.js';
20
+ import * as charts from './charts.js';
21
+ import { VIEWS } from './views/index.js';
22
+ import { mountPalette } from './palette.js';
23
+ import { maybeShowFirstRun } from './first-run.js';
19
24
 
20
25
  const SNAPSHOT = typeof window !== 'undefined' && !!window.__TOKENFLOW_BUNDLE__;
21
26
 
@@ -55,22 +60,73 @@ const COMP_COLORS = {
55
60
  cacheWrite: 'var(--series-4)',
56
61
  };
57
62
 
63
+ /**
64
+ * The tabs app.js renders itself, as `[id, label, order]`.
65
+ *
66
+ * Orders are spaced by 10 so a view registered in ./views/index.js can slot
67
+ * anywhere without renumbering anything. 30 is deliberately absent: the Live
68
+ * tab is a registered view and claims it from the registry.
69
+ */
58
70
  const TABS = [
59
- ['overview', 'Overview'],
60
- ['live', 'Live'],
61
- ['providers', 'Providers'],
62
- ['models', 'Models'],
63
- ['interfaces', 'Interfaces'],
64
- ['time', 'Time patterns'],
65
- ['peaks', 'Peaks'],
66
- ['efficiency', 'Efficiency'],
67
- ['cost', 'Cost'],
68
- ['productivity', 'Productivity'],
69
- ['compare', 'Compare'],
70
- ['explorer', 'Data explorer'],
71
- ['health', 'Data health'],
71
+ ['overview', 'Overview', 10],
72
+ ['receipts', 'Receipts', 20],
73
+ ['providers', 'Providers', 40],
74
+ ['models', 'Models', 50],
75
+ ['interfaces', 'Interfaces', 60],
76
+ ['time', 'Time patterns', 70],
77
+ ['peaks', 'Peaks', 80],
78
+ ['efficiency', 'Efficiency', 90],
79
+ ['cost', 'Cost', 100],
80
+ ['productivity', 'Productivity', 110],
81
+ ['compare', 'Compare', 120],
82
+ ['explorer', 'Data explorer', 130],
83
+ ['health', 'Data health', 140],
72
84
  ];
73
85
 
86
+ const BUILTIN_TAB_IDS = new Set(TABS.map(([id]) => id));
87
+
88
+ /**
89
+ * Registered views that are safe to mount: a unique id that does not collide
90
+ * with a built-in tab, and the three required exports.
91
+ *
92
+ * A malformed entry is dropped with a console message rather than allowed to
93
+ * break every other tab. Several people add views to the same registry, and one
94
+ * bad module must not take the dashboard down with it.
95
+ *
96
+ * @returns {object[]}
97
+ */
98
+ function registeredViews() {
99
+ const seen = new Set();
100
+ const out = [];
101
+ for (const v of VIEWS) {
102
+ const where = v && v.id ? `view "${v.id}"` : 'a view module';
103
+ if (!v || typeof v.id !== 'string' || !v.id) { console.error(`registry: ${where} has no id — skipped`); continue; }
104
+ if (typeof v.view !== 'function' || typeof v.label !== 'string' || typeof v.order !== 'number') {
105
+ console.error(`registry: ${where} needs label, order and view() — skipped`);
106
+ continue;
107
+ }
108
+ if (BUILTIN_TAB_IDS.has(v.id) || seen.has(v.id)) { console.error(`registry: ${where} duplicates an existing tab id — skipped`); continue; }
109
+ seen.add(v.id);
110
+ out.push(v);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * Built-in tabs and registered views merged into one ordered tab list.
117
+ * @returns {{id:string,label:string,order:number,module:object|null}[]}
118
+ */
119
+ function allTabs() {
120
+ const builtin = TABS.map(([id, label, order]) => ({ id, label, order, module: null }));
121
+ const registered = registeredViews().map((v) => ({ id: v.id, label: v.label, order: v.order, module: v }));
122
+ return [...builtin, ...registered].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
123
+ }
124
+
125
+ /** The registered module owning a tab id, or null for a built-in tab. */
126
+ function viewModule(id) {
127
+ return allTabs().find((t) => t.id === id)?.module || null;
128
+ }
129
+
74
130
  /**
75
131
  * Skins restyle the room; they never restyle the data. The categorical series
76
132
  * steps live in the mode (dark/light) and were validated against every skin's
@@ -84,6 +140,177 @@ export const SKINS = [
84
140
  { id: 'editorial', name: 'Editorial', note: 'Warm charcoal, serif figures' },
85
141
  ];
86
142
 
143
+ // =========================================================== view registry ==
144
+
145
+ /**
146
+ * One `<link>` per registered view's stylesheet, injected once.
147
+ *
148
+ * Development only. A saved snapshot has no server to fetch src/ from, so the
149
+ * exporter inlines the same files instead — see src/export/html-snapshot.js.
150
+ */
151
+ let viewStylesInjected = false;
152
+ function injectViewStyles() {
153
+ if (SNAPSHOT || viewStylesInjected) return;
154
+ viewStylesInjected = true;
155
+ const already = new Set();
156
+ document.querySelectorAll('link[rel="stylesheet"]').forEach((l) => already.add(l.getAttribute('href')));
157
+ for (const v of registeredViews()) {
158
+ if (!v.css) continue;
159
+ const href = '/src/ui/' + String(v.css).replace(/^\.?\//, '');
160
+ if (already.has(href)) continue;
161
+ already.add(href);
162
+ document.head.appendChild(el('link', { rel: 'stylesheet', href }));
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The stylesheets for palette.js and first-run.js: not registered views (they
168
+ * are app.js features, not tabs), so injectViewStyles() never sees them. They
169
+ * still live under src/ui/styles/, which means html-snapshot.js's collectCss
170
+ * inlines them into an offline snapshot for free — this function only has to
171
+ * cover the dev server, where nothing else links them.
172
+ *
173
+ * A static `<link>` in index.html would do the same job in dev, but the
174
+ * snapshot's `<link>` removal is a single, non-global replace (see
175
+ * html-snapshot.js): a second `<link>` there would survive into the saved
176
+ * file and fail to load from file://. Injecting by script, the same way
177
+ * injectViewStyles() does, avoids that entirely.
178
+ */
179
+ const OWN_STYLES = ['./styles/palette.css', './styles/first-run.css'];
180
+ let ownStylesInjected = false;
181
+ function injectOwnStyles() {
182
+ if (SNAPSHOT || ownStylesInjected) return;
183
+ ownStylesInjected = true;
184
+ for (const css of OWN_STYLES) {
185
+ const href = '/src/ui/' + css.replace(/^\.?\//, '');
186
+ document.head.appendChild(el('link', { rel: 'stylesheet', href }));
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Set once boot() mounts it; the chip's click handler is the only other
192
+ * caller. Declared here — before `boot().catch(...)` is invoked below — and
193
+ * NOT after boot()'s own definition: in a snapshot, boot() never hits a real
194
+ * `await` (the bundle is already on `window`, so the SNAPSHOT branch of the
195
+ * ternary never evaluates the `await` branch), so it runs start to finish
196
+ * synchronously in one go. A `let` declared later in the file would still be
197
+ * in its temporal dead zone at that point, exactly like `viewStylesInjected`
198
+ * had to be declared before boot() for the same reason.
199
+ */
200
+ let palette = null;
201
+
202
+ /**
203
+ * Intervals a view asked for, in two lifetimes: one registered from `onEnter`
204
+ * lives until the view is left, one registered from `view()` only until the
205
+ * next render — because that render runs `view()` again and it would otherwise
206
+ * stack a duplicate every time a filter changed.
207
+ */
208
+ const viewTimers = { enter: [], render: [] };
209
+ /** @type {'enter'|'render'} */
210
+ let timerPhase = 'render';
211
+ let enteredTab = null;
212
+
213
+ function clearViewTimers(phase) {
214
+ for (const t of viewTimers[phase]) clearInterval(t);
215
+ viewTimers[phase] = [];
216
+ }
217
+
218
+ /**
219
+ * The object every registered view is given. `bundle`, `view` and `filters` are
220
+ * getters over live state, so a ctx held in a closure never reads a stale one.
221
+ *
222
+ * @returns {import('./views/index.js').ViewContext}
223
+ */
224
+ function viewContext() {
225
+ return {
226
+ S,
227
+ get bundle() { return S.bundle; },
228
+ get view() { return S.view; },
229
+ get filters() { return S.filters; },
230
+ snapshot: SNAPSHOT,
231
+ el,
232
+ card,
233
+ chartCard,
234
+ btn,
235
+ kpi,
236
+ deltaChip,
237
+ sectionTitle,
238
+ emptyCard,
239
+ openModal,
240
+ closeModal,
241
+ drillTo,
242
+ fmt: {
243
+ compact, int, usd, pct, signedPct, shortDate, longDate,
244
+ hourLabel, hourWindow, relativeTime, humanDuration, countdown, DOW,
245
+ },
246
+ charts,
247
+ // A snapshot is a file: there is no API to call, and a view must get a
248
+ // plain "no data" rather than an exception it has to catch.
249
+ fetchJson: (path, opt) => (SNAPSHOT ? Promise.resolve(null) : fetchJson(path, opt)),
250
+ schedule: (fn, ms) => {
251
+ const t = setInterval(fn, ms);
252
+ viewTimers[timerPhase].push(t);
253
+ return t;
254
+ },
255
+ rerender: (opt = {}) => {
256
+ if (opt.recompute !== false) recompute();
257
+ render();
258
+ },
259
+ };
260
+ }
261
+
262
+ /**
263
+ * The object mountPalette(ctx) is given, once, at boot.
264
+ *
265
+ * Unlike viewContext(), nothing here needs to be a getter: the palette only
266
+ * reads `getTabs()` (and nothing else state-shaped) at the moment it opens,
267
+ * never while it is closed, so a stale closure is not a risk.
268
+ *
269
+ * @returns {import('./palette.js').PaletteContext}
270
+ */
271
+ function paletteContext() {
272
+ return {
273
+ el,
274
+ getTabs: () => allTabs().map((t) => ({ id: t.id, label: t.label })),
275
+ goToTab,
276
+ ranges: QUICK_RANGES.filter((r) => r.id !== 'custom'),
277
+ applyRange: (id) => applyRange(id),
278
+ skins: SKINS,
279
+ setSkin: (id) => { applyTheme(id, document.documentElement.dataset.mode); savePrefs(); renderShell(); render(); },
280
+ modes: [{ id: 'dark', label: 'Dark' }, { id: 'light', label: 'Light' }],
281
+ setMode: (id) => { applyTheme(document.documentElement.dataset.skin, id); savePrefs(); renderShell(); render(); },
282
+ canRefresh: () => !SNAPSHOT,
283
+ refresh: () => doRefresh(),
284
+ exportCsv: () => exportMenu(),
285
+ exportHtmlInfo: () => htmlExportInfoModal(),
286
+ clearFilters,
287
+ copyDeepLink,
288
+ activeTabButton: () => document.querySelector('nav.tabs button[aria-selected="true"]'),
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Fire onLeave/onEnter when the active tab changes, and drop the timers the
294
+ * departing view owned. Called from render(), so it catches every route into a
295
+ * tab: the tab bar, a deep link, and the KPI tiles that jump between views.
296
+ */
297
+ function enterTab(ctx) {
298
+ if (enteredTab === S.tab) return;
299
+ const prev = enteredTab === null ? null : viewModule(enteredTab);
300
+ if (prev && typeof prev.onLeave === 'function') {
301
+ try { prev.onLeave(ctx); } catch (err) { console.error(err); }
302
+ }
303
+ clearViewTimers('enter');
304
+ clearViewTimers('render');
305
+ enteredTab = S.tab;
306
+ const next = viewModule(S.tab);
307
+ if (next && typeof next.onEnter === 'function') {
308
+ timerPhase = 'enter';
309
+ try { next.onEnter(ctx); } catch (err) { console.error(err); }
310
+ timerPhase = 'render';
311
+ }
312
+ }
313
+
87
314
  // ============================================================ bootstrapping ==
88
315
 
89
316
  boot().catch((err) => {
@@ -94,8 +321,16 @@ boot().catch((err) => {
94
321
  });
95
322
 
96
323
  async function boot() {
324
+ // Before the bundle fetch, so each view's stylesheet loads in parallel with
325
+ // the data and is applied by the time anything paints.
326
+ injectViewStyles();
327
+ injectOwnStyles();
97
328
  const prefs = loadPrefs();
98
329
  S.bundle = SNAPSHOT ? window.__TOKENFLOW_BUNDLE__ : await fetchJson('/api/bundle');
330
+ // Every daily chart overlays annotations from this module-level list, not
331
+ // from S.bundle directly, so a marker must not wait for someone to visit
332
+ // the Annotations tab before it appears anywhere else.
333
+ charts.setAnnotations(S.bundle.annotations || []);
99
334
  // Config supplies the default look; a choice made in the browser wins.
100
335
  applyTheme(
101
336
  prefs.skin || S.bundle.meta?.skin || SKINS[0].id,
@@ -105,9 +340,24 @@ async function boot() {
105
340
  S.rangeId = prefs.rangeId || S.bundle.meta.defaultRange || 'all';
106
341
  if (prefs.granularity) S.granularity = prefs.granularity;
107
342
  if (prefs.tab) S.tab = prefs.tab;
343
+ // Deep links: #tab=receipts&skin=terminal&mode=light. A link wins over a
344
+ // remembered preference for this load only; nothing is persisted from it.
345
+ const link = new URLSearchParams(location.hash.replace(/^#/, ''));
346
+ if (link.get('tab') && allTabs().some((t) => t.id === link.get('tab'))) S.tab = link.get('tab');
347
+ if (link.get('skin') || link.get('mode')) {
348
+ applyTheme(
349
+ SKINS.some((s) => s.id === link.get('skin')) ? link.get('skin') : document.documentElement.dataset.skin,
350
+ ['dark', 'light'].includes(link.get('mode')) ? link.get('mode') : document.documentElement.dataset.mode,
351
+ );
352
+ }
108
353
  if (S.bundle.meta?.includeOverlayDefault) S.filters.includeOverlay = true;
109
354
  applyRange(S.rangeId, { silent: true });
110
355
  recompute();
356
+ // Mounted once: the chip and the Cmd+K/Ctrl+K shortcut both open the same
357
+ // instance for the rest of this page's life.
358
+ palette = mountPalette(paletteContext());
359
+ const chip = document.getElementById('palette-chip');
360
+ if (chip) chip.addEventListener('click', () => palette.open());
111
361
  renderShell();
112
362
  render();
113
363
  // Handed over from a saved snapshot's "Refresh & open live" button. The
@@ -117,6 +367,16 @@ async function boot() {
117
367
  doRefresh();
118
368
  }
119
369
  ensureLiveLoop();
370
+ // Never in a snapshot: a saved file has no /api/providers to ask, and
371
+ // nothing new to report since it was written.
372
+ if (!SNAPSHOT) {
373
+ maybeShowFirstRun({
374
+ el,
375
+ appVersion: S.bundle.meta.appVersion,
376
+ sources: S.bundle.meta.sources,
377
+ fetchProviders: () => fetch('/api/providers').then((r) => (r.ok ? r.json() : null)).catch(() => null),
378
+ });
379
+ }
120
380
  }
121
381
 
122
382
  // ============================================================ live polling ==
@@ -128,6 +388,9 @@ let liveTimer = null;
128
388
  * what makes the header pill and the Live tab's watcher strip current without
129
389
  * any user action. In a static snapshot there is no server: the loop never
130
390
  * starts, and the Live tab renders purely from the bundle.
391
+ *
392
+ * boot() starts it whichever tab is open, so the pill works everywhere and the
393
+ * Live view (now src/ui/views/live.js) does not have to ask for it.
131
394
  */
132
395
  function ensureLiveLoop() {
133
396
  if (SNAPSHOT || liveTimer) return;
@@ -191,7 +454,6 @@ function recompute() {
191
454
  savePrefs();
192
455
  }
193
456
 
194
- // ==================================================================== theme ==
195
457
 
196
458
  function applyTheme(skin, mode) {
197
459
  const r = document.documentElement;
@@ -251,6 +513,28 @@ function themePicker() {
251
513
 
252
514
  // ==================================================================== shell ==
253
515
 
516
+ /**
517
+ * Switch the active tab. The tab bar's own buttons and the command palette's
518
+ * "Go to tab" commands both funnel through here, so a deep link built from
519
+ * one behaves exactly like a deep link built from the other.
520
+ * @param {string} id
521
+ */
522
+ function goToTab(id) {
523
+ if (S.tab !== id) S.viewEntered = false; // a view change earns the one entry stagger
524
+ S.tab = id; savePrefs(); renderShell(); render();
525
+ // Keep the URL shareable without adding history entries for every click.
526
+ try { history.replaceState(null, '', `#${currentDeepLinkHash()}`); } catch { /* file:// or a sandboxed frame may refuse; the tab still switched */ }
527
+ }
528
+
529
+ /** The `tab=…&skin=…&mode=…` hash boot() parses back. Shared by goToTab's address-bar update and copyDeepLink, so the two never disagree on shape. */
530
+ function currentDeepLinkHash() {
531
+ return new URLSearchParams({
532
+ tab: S.tab,
533
+ skin: document.documentElement.dataset.skin,
534
+ mode: document.documentElement.dataset.mode,
535
+ }).toString();
536
+ }
537
+
254
538
  function renderShell() {
255
539
  const acts = document.getElementById('header-actions');
256
540
  acts.textContent = '';
@@ -263,9 +547,9 @@ function renderShell() {
263
547
 
264
548
  const tabs = document.getElementById('tabs');
265
549
  tabs.textContent = '';
266
- for (const [id, label] of TABS) {
550
+ for (const { id, label } of allTabs()) {
267
551
  const b = el('button', { role: 'tab', text: label, 'aria-selected': String(S.tab === id) });
268
- b.addEventListener('click', () => { S.tab = id; savePrefs(); renderShell(); render(); });
552
+ b.addEventListener('click', () => goToTab(id));
269
553
  tabs.appendChild(b);
270
554
  }
271
555
 
@@ -285,11 +569,31 @@ function render() {
285
569
  renderBanners();
286
570
  renderFilters();
287
571
  renderCrumbs();
572
+ const ctx = viewContext();
573
+ enterTab(ctx);
288
574
  const host = document.getElementById('view');
289
575
  host.textContent = '';
576
+ // Cards stagger in only when the VIEW changes. A filter change re-renders the
577
+ // same view and must feel instant, so it does not re-run the entrance.
578
+ host.classList.toggle('view-enter', S.viewEntered === false);
579
+ S.viewEntered = true;
580
+ const mod = viewModule(S.tab);
581
+ if (mod) {
582
+ clearViewTimers('render');
583
+ // One broken registered view must cost its own tab, not the whole page.
584
+ try {
585
+ host.appendChild(mod.view(ctx));
586
+ } catch (err) {
587
+ console.error(err);
588
+ host.appendChild(el('div', { class: 'banner' }, [
589
+ el('span', { text: `The ${mod.label} tab could not render: ${err.message}` }),
590
+ ]));
591
+ }
592
+ return;
593
+ }
290
594
  const fn = {
291
595
  overview: viewOverview,
292
- live: viewLive,
596
+ receipts: viewReceipts,
293
597
  providers: () => viewDimension('provider', 'Provider intelligence'),
294
598
  models: viewModels,
295
599
  interfaces: viewInterfaces,
@@ -437,14 +741,25 @@ function renderFilters() {
437
741
  box.appendChild(el('div', { class: 'grp' }, [el('label', { class: 'fld' }, [el('span', { text: 'Scope' }), toggles])]));
438
742
 
439
743
  if (activeFilterCount()) {
440
- box.appendChild(btn(`Clear ${activeFilterCount()} filter(s)`, () => {
441
- S.filters = { ...EMPTY_FILTERS, includeOverlay: S.filters.includeOverlay, includeActivity: S.filters.includeActivity };
442
- S.drillDate = null;
443
- applyRange('all');
444
- }, 'ghost sm'));
744
+ box.appendChild(btn(`Clear ${activeFilterCount()} filter(s)`, clearFilters, 'ghost sm'));
445
745
  }
446
746
  }
447
747
 
748
+ /**
749
+ * Reset every filter and the date range to "all". The overlay/activity scope
750
+ * toggles are preserved — they widen or narrow what counts as data, not a
751
+ * filter on it, so "Clear filters" leaving them alone matches what the
752
+ * "Clear N filter(s)" button already only counted as filters.
753
+ *
754
+ * Shared by the filter panel's own button, the "All data" breadcrumb, and the
755
+ * command palette's "Clear filters", so the three cannot drift apart.
756
+ */
757
+ function clearFilters() {
758
+ S.filters = { ...EMPTY_FILTERS, includeOverlay: S.filters.includeOverlay, includeActivity: S.filters.includeActivity };
759
+ S.drillDate = null;
760
+ applyRange('all');
761
+ }
762
+
448
763
  function activeFilterCount() {
449
764
  let n = 0;
450
765
  for (const k of ['provider', 'model', 'model_family', 'client', 'interface', 'gateway', 'project', 'repository', 'service_tier']) {
@@ -532,7 +847,7 @@ function multi(label, options, selected, onChange) {
532
847
  function renderCrumbs() {
533
848
  const box = document.getElementById('crumbs');
534
849
  box.textContent = '';
535
- const parts = [{ label: 'All data', reset: () => { S.filters = { ...EMPTY_FILTERS, includeOverlay: S.filters.includeOverlay, includeActivity: S.filters.includeActivity }; S.drillDate = null; applyRange('all'); } }];
850
+ const parts = [{ label: 'All data', reset: clearFilters }];
536
851
  for (const [key, label] of [['provider', 'Provider'], ['model', 'Model'], ['client', 'Client'], ['interface', 'Interface'], ['project', 'Project'], ['gateway', 'Gateway'], ['service_tier', 'Tier']]) {
537
852
  const v = S.filters[key];
538
853
  if (v && v.length) {
@@ -660,6 +975,8 @@ function emptyCard(text, detail) {
660
975
  function viewOverview() {
661
976
  const v = S.view;
662
977
  const root = el('div', { class: 'grid' });
978
+ const story = storyStrip();
979
+ if (story) root.appendChild(story);
663
980
  root.appendChild(kpiRow());
664
981
 
665
982
  const gran = el('div', { class: 'chips' });
@@ -995,6 +1312,160 @@ function dayDetailBox(d) {
995
1312
  return box;
996
1313
  }
997
1314
 
1315
+ /**
1316
+ * The three sentences that matter for this slice, before any chart. Each
1317
+ * insight already carries its own condition and weight (insights.js); this
1318
+ * only picks the top three and sets the numbers in a heavier face so the eye
1319
+ * lands on them first. Nothing here is computed.
1320
+ */
1321
+ function storyStrip() {
1322
+ const ins = (S.view.insights || []).filter((i) => i.kind !== 'empty' && i.kind !== 'quality');
1323
+ if (ins.length < 2) return null;
1324
+ const top = [...ins].sort((a, b) => (b.weight || 0) - (a.weight || 0)).slice(0, 3);
1325
+ const strip = el('div', { class: 'story' });
1326
+ for (const i of top) {
1327
+ const p = el('p', { class: 'story-text' });
1328
+ // Numbers, money, ratios and percentages get the figure face; words stay words.
1329
+ // A figure is a number with its unit: "4.37M", "97.9%", "2.3×", "30 days", "$12.40", "r = 0.44".
1330
+ const re = /((?:\$|[+\-−]|r = )?\d+(?:[.,]\d+)*(?:\s?(?:[KMB](?![a-z])|×|x(?![a-z])|%|days?|hours?|min(?![a-z])))?)/g;
1331
+ let last = 0;
1332
+ for (const m of i.text.matchAll(re)) {
1333
+ if (m.index > last) p.appendChild(document.createTextNode(i.text.slice(last, m.index)));
1334
+ p.appendChild(el('strong', { text: m[0] }));
1335
+ last = m.index + m[0].length;
1336
+ }
1337
+ if (last < i.text.length) p.appendChild(document.createTextNode(i.text.slice(last)));
1338
+ strip.appendChild(el('div', { class: 'story-line ' + (i.kind || '') }, [
1339
+ el('span', { class: 'story-ico', text: i.icon || '•', 'aria-hidden': 'true' }),
1340
+ p,
1341
+ ]));
1342
+ }
1343
+ return strip;
1344
+ }
1345
+
1346
+ // ================================================================= receipts ==
1347
+
1348
+ /**
1349
+ * Spend attributed to the unit of work: per repository, per branch. The
1350
+ * receipts arrive in the bundle (computed once per refresh on the server, or
1351
+ * baked into a snapshot), so this view works offline and never scans records
1352
+ * in the browser. It covers the whole store: a receipt is bounded by its
1353
+ * branch, not by the date filter, and the view says so.
1354
+ */
1355
+ function viewReceipts() {
1356
+ const R = S.bundle.receipts;
1357
+ const root = el('div', { class: 'grid' });
1358
+ if (!R || !R.repos || !R.repos.length || !(R.totals.cost > 0)) {
1359
+ root.appendChild(card('Receipts', 'Spend attributed to a branch, per repository.', emptyCard(
1360
+ 'No branch-attributed spend yet',
1361
+ 'Receipts need sessions that recorded a git branch and a priced model. Claude Code and OpenCode sessions do; sessions on a detached HEAD are reported as unattributed.',
1362
+ )));
1363
+ return root;
1364
+ }
1365
+ const all = R.repos.flatMap((r) => r.branches.filter((b) => b.cost !== null).map((b) => ({ ...b, repo: r.repo })));
1366
+ const costs = all.map((b) => b.cost).sort((a, b) => a - b);
1367
+ const median = costs.length ? costs[Math.floor(costs.length / 2)] : null;
1368
+ const top = all.length ? all.reduce((a, b) => (b.cost > a.cost ? b : a)) : null;
1369
+ const unattributed = R.repos.reduce((a, r) => a + (r.unattributed.cost ?? 0), 0);
1370
+
1371
+ const box = el('div', { class: 'cards' });
1372
+ const heroCard = kpi('Attributed to a branch', pct(R.totals.attributedShare, 0), `${usd(R.totals.attributedCost)} of ${usd(R.totals.cost)} estimated`, { hero: true, title: 'Share of estimated spend whose turns ran on a named branch. The rest ran on a detached HEAD or with no branch recorded.' });
1373
+ heroCard.classList.add('wide');
1374
+ box.appendChild(heroCard);
1375
+ box.appendChild(kpi('Branches', int(R.totals.branches), `${int(R.repos.length)} repositor${R.repos.length === 1 ? 'y' : 'ies'}`));
1376
+ box.appendChild(kpi('Median branch', median !== null ? usd(median) : '—', 'half of all branches cost less'));
1377
+ if (top) box.appendChild(kpi('Most expensive branch', usd(top.cost), `${top.key} · ${top.repo}`, { title: `${top.sessions} sessions · ${top.turns} turns`, onClick: () => receiptDetail(R.repos.find((r) => r.repo === top.repo), top) }));
1378
+ box.appendChild(kpi('Unattributed', usd(unattributed), 'detached HEAD or no branch', { title: 'Reported, never guessed: these turns cannot be tied to a unit of work.' }));
1379
+ root.appendChild(box);
1380
+
1381
+ root.appendChild(el('div', { class: 'banner info' }, [el('span', {
1382
+ text: `Receipts cover the whole store (${int(R.totals.records)} turns, computed ${relativeTime(R.computedAt)}). The date and provider filters above do not apply here: a receipt is bounded by its branch, not by a window. Pull-request joins run from the CLI: tokenflow receipt --repo <path> --gh.`,
1383
+ })]));
1384
+
1385
+ for (const repo of R.repos.slice(0, 12)) {
1386
+ const maxCost = Math.max(...repo.branches.map((b) => b.cost ?? 0), 0);
1387
+ const rows = repo.branches.slice(0, 15);
1388
+ const columns = [
1389
+ { key: 'key', label: 'Branch', text: true, value: (b) => el('span', { class: 'branch-cell' }, [
1390
+ miniBar(maxCost ? (b.cost ?? 0) / maxCost : 0, 'var(--seq-5)'),
1391
+ el('span', { class: 'branch-name', text: b.key }),
1392
+ b.longLived ? el('span', { class: 'badge', text: 'long-lived', title: 'A branch that lives forever: this is a receipt for a period of work on it, not for one change.' }) : null,
1393
+ ]) },
1394
+ { key: 'cost', label: 'Spend', value: (b) => (b.cost === null ? null : usd(b.cost)), title: 'Estimated from the price table; unpriced turns excluded' },
1395
+ { key: 'contextShare', label: 'Context', value: (b) => (b.contextShare === null ? null : pct(b.contextShare, 0)), title: 'Share of spend that paid to re-send earlier context (cache reads + writes)' },
1396
+ { key: 'sessions', label: 'Sessions' },
1397
+ { key: 'turns', label: 'Turns' },
1398
+ { key: 'subagentShare', label: 'Subagent', value: (b) => (b.subagentTurns ? pct(b.subagentShare, 0) : '0%') },
1399
+ { key: 'vsMedian', label: '× median', value: (b) => (b.vsMedian === null ? null : `${b.vsMedian >= 10 ? Math.round(b.vsMedian) : b.vsMedian.toFixed(1)}×`), title: 'This branch against the median priced branch in the same repository' },
1400
+ ];
1401
+ const tbl = table(columns, rows, { onRowClick: (b) => receiptDetail(repo, b), emptyText: 'No attributed branches in this repository.' });
1402
+ const hint = [
1403
+ `${usd(repo.cost)} · ${int(repo.branches.length)} branch${repo.branches.length === 1 ? '' : 'es'}`,
1404
+ repo.medianBranchCost !== null ? `median ${usd(repo.medianBranchCost)}` : null,
1405
+ repo.unattributed.turns ? `unattributed ${usd(repo.unattributed.cost)} across ${int(repo.unattributed.sessions)} session${repo.unattributed.sessions === 1 ? '' : 's'}` : null,
1406
+ repo.branches.length > rows.length ? `showing the top ${rows.length}` : null,
1407
+ ].filter(Boolean).join(' · ');
1408
+ root.appendChild(card(repo.repo, hint, tbl));
1409
+ }
1410
+ if (R.repos.length > 12) root.appendChild(el('p', { class: 'muted', text: `${R.repos.length - 12} smaller repositories not shown. The CLI lists every one: tokenflow receipt.` }));
1411
+ return root;
1412
+ }
1413
+
1414
+ /** One receipt, as a card with a copy-as-PR-comment action. */
1415
+ function receiptDetail(repo, b) {
1416
+ const body = el('div', { class: 'receipt' });
1417
+ const head = el('div', { class: 'receipt-head' });
1418
+ head.appendChild(el('div', { class: 'receipt-kicker', text: repo ? repo.repo : '' }));
1419
+ head.appendChild(el('div', { class: 'receipt-branch' }, [
1420
+ document.createTextNode(b.key),
1421
+ b.longLived ? el('span', { class: 'badge', style: 'margin-left:8px;vertical-align:middle', text: 'long-lived branch · a period of work, not one change' }) : null,
1422
+ ]));
1423
+ body.appendChild(head);
1424
+
1425
+ body.appendChild(el('div', { class: 'receipt-total' }, [
1426
+ el('span', { class: 'receipt-amount', text: b.cost === null ? '—' : usd(b.cost) }),
1427
+ el('span', { class: 'receipt-amount-sub', text: b.cost === null ? 'no priced turns' : 'estimated spend on this branch' }),
1428
+ ]));
1429
+
1430
+ if (b.contextShare !== null) {
1431
+ const bar = el('div', { class: 'split-bar', role: 'img', 'aria-label': `${pct(b.contextShare, 0)} re-sent context, ${pct(1 - b.contextShare, 0)} fresh work` });
1432
+ const ctx = el('i', { class: 'seg ctx', title: 'Context: cache reads and writes — the cost of re-sending the conversation so far' });
1433
+ ctx.style.width = `${Math.round(b.contextShare * 1000) / 10}%`;
1434
+ const work = el('i', { class: 'seg work', title: 'Work: fresh input and generated output' });
1435
+ bar.appendChild(ctx);
1436
+ bar.appendChild(work);
1437
+ body.appendChild(bar);
1438
+ body.appendChild(el('div', { class: 'split-legend' }, [
1439
+ el('span', {}, [el('i', { class: 'sw ctx' }), document.createTextNode(` ${pct(b.contextShare, 0)} re-sent context`)]),
1440
+ el('span', {}, [el('i', { class: 'sw work' }), document.createTextNode(` ${pct(1 - b.contextShare, 0)} fresh work`)]),
1441
+ ]));
1442
+ }
1443
+
1444
+ const dl = el('dl', { class: 'kv receipt-kv' });
1445
+ const row = (k, v) => { dl.appendChild(el('dt', { text: k })); dl.appendChild(el('dd', { text: v })); };
1446
+ row('Sessions · turns', `${int(b.sessions)} · ${int(b.turns)}${b.subagentTurns ? ` (${pct(b.subagentShare, 0)} subagent)` : ''}`);
1447
+ if (b.models.length) row('Models', b.models.slice(0, 3).map((m) => `${m.model} ${pct(m.share, 0)}`).join(', ') + (b.models.length > 3 ? ', …' : ''));
1448
+ if (b.vsMedian !== null) row('vs repository median', `${b.vsMedian >= 10 ? Math.round(b.vsMedian) : b.vsMedian.toFixed(1)}×`);
1449
+ if (b.maxPrompt !== null) row('Largest prompt', `${compact(b.maxPrompt)} tokens`);
1450
+ if (b.first && b.last) row('Window', `${shortDate(b.first.slice(0, 10))} → ${shortDate(b.last.slice(0, 10))}`);
1451
+ if (b.unpricedTurns) row('Unpriced turns', `${int(b.unpricedTurns)} (not in the total)`);
1452
+ body.appendChild(dl);
1453
+ body.appendChild(el('p', { class: 'receipt-foot', text: `Estimated locally from the session logs on this machine with price table ${S.bundle.meta.pricingTableVersion}. No prompt or code content was read. Bounded by branch, not by a pull request — join PRs from the CLI with tokenflow receipt --gh.` }));
1454
+
1455
+ const copy = btn('Copy as PR comment', async () => {
1456
+ const md = renderReceiptMarkdown(b, { repo: repo ? repo.repo : undefined, pricingVersion: S.bundle.meta.pricingTableVersion });
1457
+ try {
1458
+ await navigator.clipboard.writeText(md);
1459
+ copy.textContent = 'Copied ✓';
1460
+ setTimeout(() => { copy.textContent = 'Copy as PR comment'; }, 1400);
1461
+ } catch {
1462
+ // The clipboard API needs a secure context or a user gesture the browser accepted; fall back to showing the text.
1463
+ openModal('Receipt (markdown)', el('pre', { class: 'mono', text: md }));
1464
+ }
1465
+ }, 'primary');
1466
+ openModal('AI cost receipt', body, [copy]);
1467
+ }
1468
+
998
1469
  function insightsCard() {
999
1470
  const body = el('div', { class: 'insights' });
1000
1471
  for (const i of S.view.insights) {
@@ -1969,6 +2440,7 @@ async function doRefresh() {
1969
2440
  const keep = { ...S.filters };
1970
2441
  const keepRange = S.rangeId;
1971
2442
  S.bundle = await fetchJson('/api/bundle');
2443
+ charts.setAnnotations(S.bundle.annotations || []);
1972
2444
  S.filters = keep;
1973
2445
  if (keepRange !== 'custom') applyRange(keepRange, { silent: true });
1974
2446
  recompute();
@@ -2069,6 +2541,52 @@ function downloadCsv(name, cols, rows) {
2069
2541
  a.remove();
2070
2542
  }
2071
2543
 
2544
+ /**
2545
+ * "Export HTML snapshot" (command palette). Writing a self-contained offline
2546
+ * file is a filesystem operation, and every route this server exposes is
2547
+ * read-only or config-writing — there is no `/api/export.html` to call from
2548
+ * here. This states the one CLI command that does it, the same way
2549
+ * freshnessBar() states `npm start` when no live dashboard answers.
2550
+ */
2551
+ function htmlExportInfoModal() {
2552
+ const body = el('div');
2553
+ body.appendChild(el('p', { class: 'hint', text: 'This writes one self-contained HTML file: the analytics, the charts and the data bundle, all inlined. It opens later from a file:// URL with no server and no network.' }));
2554
+ const row = el('div', { style: 'display:flex;gap:8px;align-items:center' });
2555
+ row.appendChild(el('code', { text: 'tokenflow export --html' }));
2556
+ const copy = btn('Copy', async () => {
2557
+ try { await navigator.clipboard.writeText('tokenflow export --html'); copy.textContent = 'Copied'; } catch { copy.textContent = 'tokenflow export --html'; }
2558
+ }, 'ghost sm');
2559
+ row.appendChild(copy);
2560
+ body.appendChild(row);
2561
+ openModal('Export HTML snapshot', body);
2562
+ }
2563
+
2564
+ /** "Copy deep link" (command palette): the current tab, skin and mode, via the same hash shape goToTab() writes to the address bar. */
2565
+ async function copyDeepLink() {
2566
+ // Built from location.href, not location.origin + location.pathname:
2567
+ // Chromium (and others) return the literal string "null" for `origin` on a
2568
+ // file:// page, which would silently produce a "null/…" link for anyone
2569
+ // copying a deep link out of a saved snapshot.
2570
+ const u = new URL(location.href);
2571
+ u.search = ''; // drop a stale ?refresh=1 from a snapshot's "Refresh & open live" handoff
2572
+ u.hash = currentDeepLinkHash();
2573
+ const url = u.href;
2574
+ let ok = true;
2575
+ try {
2576
+ await navigator.clipboard.writeText(url);
2577
+ } catch {
2578
+ // No Clipboard permission, or an insecure context (file://): the banner
2579
+ // still shows the link so it can be copied by hand.
2580
+ ok = false;
2581
+ }
2582
+ const box = document.getElementById('banners');
2583
+ const status = el('div', { class: 'banner info' }, [el('span', {
2584
+ text: ok ? `Copied: ${url}` : `Could not copy automatically. Deep link: ${url}`,
2585
+ })]);
2586
+ box.prepend(status);
2587
+ setTimeout(() => status.remove(), 9000);
2588
+ }
2589
+
2072
2590
  // ================================================================== pricing ==
2073
2591
 
2074
2592
  function pricingModal() {
@@ -2186,288 +2704,3 @@ window.addEventListener('keydown', (ev) => {
2186
2704
  if (ev.key === 'r' && (ev.metaKey || ev.ctrlKey) === false && ev.target === document.body && !SNAPSHOT) doRefresh();
2187
2705
  if (ev.key === 'Escape') tooltip.hide();
2188
2706
  });
2189
-
2190
- // ============================================================== live view ==
2191
-
2192
- const SEV = {
2193
- high: { label: 'high', cls: 'sev-high' },
2194
- warn: { label: 'watch', cls: 'sev-warn' },
2195
- info: { label: 'info', cls: 'sev-info' },
2196
- };
2197
-
2198
- function liveWatcherCard() {
2199
- const body = el('div');
2200
- const w = S.live?.watcher;
2201
- if (w) {
2202
- const age = S.live.freshness?.ageMs;
2203
- body.appendChild(el('div', { class: 'chips', style: 'padding:10px 14px' }, [
2204
- el('span', { class: 'badge ok', text: `● watcher running · pid ${w.pid}` }),
2205
- el('span', { class: 'muted', text: `every ${w.intervalSeconds ?? '?'}s · ${int(w.cycles)} cycles` + (age != null ? ` · snapshot ${relativeTime(S.live.generatedAt)}` : '') }),
2206
- ]));
2207
- } else {
2208
- const c = el('code', { text: 'tokenflow watch', style: 'font-size:12px' });
2209
- body.appendChild(el('div', { class: 'chips', style: 'padding:10px 14px;gap:8px;flex-wrap:wrap' }, [
2210
- el('span', { class: 'badge stale', text: '○ watcher not running' }),
2211
- el('span', { class: 'muted', text: 'run ' }),
2212
- c,
2213
- el('span', { class: 'muted', text: ' to keep the status file, menu bar and alerts current' }),
2214
- ]));
2215
- }
2216
- return card('Real-time engine', 'The watcher refreshes incrementally and rewrites data/status.json after every cycle.', body);
2217
- }
2218
-
2219
- function limitRow(s) {
2220
- // Past ~10× a cap, percentages stop communicating; multiples do.
2221
- const pctText = s.pctUsed == null ? '—'
2222
- : s.pctUsed >= 10 ? `${Math.round(s.pctUsed)}×`
2223
- : `${(s.pctUsed * 100).toFixed(1)}%`;
2224
- const color = s.status === 'exceeded' ? 'var(--critical)' : s.status === 'warn' ? 'var(--warning)' : 'var(--series-1)';
2225
- const row = el('div', { style: 'display:flex;align-items:center;gap:12px;padding:8px 0;border-top:1px solid var(--hairline)' });
2226
- const glyph = s.status === 'exceeded' ? '✗' : s.status === 'warn' ? '⚠' : '✓';
2227
- const left = el('div', { style: 'min-width:220px' });
2228
- left.appendChild(el('div', {}, [document.createTextNode(`${glyph} ${s.label}`), s.provider ? el('span', { class: 'muted', text: ` [${s.provider}]` }) : null]));
2229
- left.appendChild(el('div', { class: 'hint', text: `${s.scope} · ${s.metric}` }));
2230
- row.appendChild(left);
2231
- const barWrap = el('div', { style: 'flex:1;min-width:120px' });
2232
- barWrap.appendChild(miniBar(Math.max(0, Math.min(1, s.pctUsed ?? 0)), color));
2233
- row.appendChild(barWrap);
2234
- const right = el('div', { style: 'text-align:right;min-width:190px' });
2235
- right.appendChild(el('div', { text: `${pctText} of ${compact(s.cap)}` }));
2236
- const sub = [];
2237
- if (s.status !== 'exceeded' && s.etaHours != null) sub.push(`ETA ${countdown(s.etaHours * 3600000)}`);
2238
- if (s.resetsInMs > 0) sub.push(`resets in ${countdown(s.resetsInMs)}`);
2239
- if (sub.length) right.appendChild(el('div', { class: 'hint', text: sub.join(' · ') }));
2240
- row.appendChild(right);
2241
- return row;
2242
- }
2243
-
2244
- function capacityCard() {
2245
- const cap = S.view.capacity || { states: [], invalid: [], summary: {} };
2246
- const body = el('div', { style: 'padding:6px 14px 14px' });
2247
-
2248
- if (!cap.states.length) {
2249
- const yaml = [
2250
- '# ~/.tokenflow/config.yaml',
2251
- 'limits:',
2252
- ' - id: anthropic-monthly',
2253
- ' provider: anthropic # optional: provider | model | project',
2254
- ' scope: month # day | week | month',
2255
- ' metric: tokens # tokens | input | output | requests | cost',
2256
- ' cap: 120000000 # tokens (or $ for metric: cost)',
2257
- ' warnAt: 0.8 # optional warn threshold',
2258
- ].join('\n');
2259
- body.appendChild(el('p', { class: 'hint', text: 'TokenFlow never invents vendor quota numbers — a limit exists only if you declare it. Declare one here or paste this into your config:' }));
2260
- const pre = el('pre', { class: 'mono', text: yaml, style: 'background:var(--surface-2);padding:10px;border-radius:8px;overflow:auto;font-size:11.5px;line-height:1.55' });
2261
- body.appendChild(pre);
2262
- const actions = btn('⧉ Copy YAML', () => {
2263
- navigator.clipboard.writeText(yaml).then(() => { actions.textContent = '✓ Copied'; setTimeout(() => { actions.textContent = '⧉ Copy YAML'; }, 1500); }).catch(() => {});
2264
- }, 'ghost sm');
2265
- return card('Capacity & budgets', 'Burn rate, exhaustion ETA and reset countdowns for your declared limits.', body, actions);
2266
- }
2267
-
2268
- const sum = cap.summary || {};
2269
- if (sum.counts && (sum.counts.exceeded || sum.counts.warn)) {
2270
- body.appendChild(el('div', { class: 'chips', style: 'padding:2px 0 8px' }, [
2271
- sum.counts.exceeded ? el('span', { class: 'badge demo', text: `${sum.counts.exceeded} exceeded` }) : null,
2272
- sum.counts.warn ? el('span', { class: 'badge warn', text: `${sum.counts.warn} approaching` }) : null,
2273
- sum.firstToHit ? el('span', { class: 'muted', text: `first projected hit: ${sum.firstToHit.label} in ${countdown(sum.firstToHit.etaHours * 3600000)}` }) : null,
2274
- ].filter(Boolean)));
2275
- }
2276
- for (const s of cap.states) body.appendChild(limitRow(s));
2277
- if (cap.invalid?.length) {
2278
- body.appendChild(el('p', { class: 'hint', text: `${cap.invalid.length} invalid limit definition(s) in config were ignored — check \`tokenflow capacity\`.` }));
2279
- }
2280
- const manage = SNAPSHOT
2281
- ? null
2282
- : btn('⚙ Manage limits', openLimitEditor, 'ghost sm');
2283
- return card('Capacity & budgets', 'Evaluated against all primary usage regardless of dashboard filters — quota windows are facts about your accounts, not filter states.', body, manage);
2284
- }
2285
-
2286
- function openLimitEditor() {
2287
- const cur = (S.bundle.limits || []).map((l) => ({ ...l }));
2288
- const body = el('div');
2289
-
2290
- // A simple editable list is clearer than a grid here.
2291
- const rows = el('div');
2292
- const renderRows = () => {
2293
- rows.textContent = '';
2294
- for (const l of cur) {
2295
- const r = el('div', { style: 'display:flex;gap:8px;align-items:center;padding:4px 0' });
2296
- r.appendChild(el('span', { class: 'mono', text: `${l.id}`, style: 'min-width:140px' }));
2297
- r.appendChild(el('span', { class: 'muted', text: `${[l.provider, l.model, l.project].filter(Boolean).join('/') || 'all sources'} · ${l.scope} · ${l.metric} · cap ${compact(l.cap)}` }));
2298
- const spacer = el('div', { style: 'flex:1' });
2299
- r.appendChild(spacer);
2300
- r.appendChild(btn('Remove', () => { cur.splice(cur.indexOf(l), 1); renderRows(); }, 'ghost sm'));
2301
- rows.appendChild(r);
2302
- }
2303
- if (!cur.length) rows.appendChild(el('p', { class: 'hint', text: 'No limits yet — add one below.' }));
2304
- };
2305
- renderRows();
2306
- body.appendChild(rows);
2307
-
2308
- const f = {};
2309
- const field = (key, placeholder, type = 'text') => {
2310
- const input = el('input', { placeholder, type, 'aria-label': key });
2311
- input.style.cssText = 'flex:1;min-width:90px';
2312
- f[key] = input;
2313
- return input;
2314
- };
2315
- const scopeSel = el('select', { 'aria-label': 'scope' });
2316
- for (const o of ['day', 'week', 'month']) scopeSel.appendChild(el('option', { value: o, text: o }));
2317
- const metricSel = el('select', { 'aria-label': 'metric' });
2318
- for (const o of ['tokens', 'input', 'output', 'requests', 'cost']) metricSel.appendChild(el('option', { value: o, text: o }));
2319
-
2320
- const form = el('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin-top:10px' }, [
2321
- field('id', 'id (required)'),
2322
- field('provider', 'provider (optional)'),
2323
- field('model', 'model (optional)'),
2324
- scopeSel, metricSel,
2325
- field('cap', 'cap', 'number'),
2326
- field('warnAt', 'warnAt 0–1', 'number'),
2327
- ]);
2328
- for (const c of form.children) c.style.flexGrow = '0';
2329
- body.appendChild(form);
2330
-
2331
- const errBox = el('p', { class: 'hint', style: 'color:var(--critical)' });
2332
- body.appendChild(errBox);
2333
-
2334
- const foot = el('div', { style: 'display:flex;gap:8px;justify-content:flex-end;width:100%' });
2335
- foot.appendChild(btn('Cancel', () => document.getElementById('modal-close').click(), 'ghost sm'));
2336
- foot.appendChild(btn('Save limits', async () => {
2337
- errBox.textContent = '';
2338
- // The form is only part of the save when the user actually named a new
2339
- // limit. Removal-only saves must not inject an empty draft — that bug
2340
- // made every "remove" also POST a junk row and fail validation.
2341
- const wantsAdd = f.id.value.trim() !== '' || f.cap.value !== '';
2342
- if (wantsAdd && f.id.value.trim() === '') {
2343
- errBox.textContent = 'New limit needs an id (or clear the form to save removals only).';
2344
- return;
2345
- }
2346
- const def = {
2347
- id: f.id.value.trim(),
2348
- provider: f.provider.value.trim() || undefined,
2349
- model: f.model.value.trim() || undefined,
2350
- scope: scopeSel.value,
2351
- metric: metricSel.value,
2352
- cap: Number(f.cap.value),
2353
- ...(f.warnAt.value !== '' ? { warnAt: Number(f.warnAt.value) } : {}),
2354
- };
2355
- const next = wantsAdd ? [...cur, def] : [...cur];
2356
- try {
2357
- const res = await fetch('/api/config', {
2358
- method: 'POST',
2359
- headers: { 'content-type': 'application/json' },
2360
- body: JSON.stringify({ limits: next }),
2361
- });
2362
- const out = await res.json();
2363
- if (!res.ok || !out.ok) {
2364
- errBox.textContent = `Invalid: ${(out.invalid || []).map((x) => `${x.id ? x.id + ': ' : ''}${x.errors.join('; ')}`).join(' | ')}`;
2365
- return;
2366
- }
2367
- S.bundle.limits = out.limits;
2368
- recompute();
2369
- render();
2370
- document.getElementById('modal-close').click();
2371
- } catch (e) {
2372
- errBox.textContent = `Save failed: ${e.message}`;
2373
- }
2374
- }, 'sm'));
2375
- body.appendChild(foot);
2376
-
2377
- openModal('Manage capacity limits', body);
2378
- }
2379
-
2380
- function forecastCard() {
2381
- const v = S.view;
2382
- const f = v.forecast;
2383
- const body = el('div');
2384
-
2385
- if (!f || f.tomorrow === null) {
2386
- body.appendChild(el('p', { class: 'hint', text: f?.reason || 'Not enough history yet.' }));
2387
- return card('Forecast', 'A conservative linear trend over recent days — never a promise.', body);
2388
- }
2389
-
2390
- const kpis = el('div', { style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;padding:10px 14px 2px' });
2391
- const kpiTile = (label, val, sub) => {
2392
- const d = el('div', { style: 'background:var(--surface-2);border-radius:8px;padding:10px' });
2393
- d.appendChild(el('div', { class: 'hint', text: label }));
2394
- d.appendChild(el('div', { class: 'k-value str', text: val, style: 'font-size:20px' }));
2395
- if (sub) d.appendChild(el('div', { class: 'hint', text: sub }));
2396
- return d;
2397
- };
2398
- kpis.appendChild(kpiTile('Tomorrow (projected)', compact(f.tomorrow), f.tomorrowInterval ? `${compact(f.tomorrowInterval[0])} – ${compact(f.tomorrowInterval[1])}` : null));
2399
- kpis.appendChild(kpiTile('Next 7 days', compact(f.next7days), f.next7daysCost != null ? usd(f.next7daysCost) : null));
2400
- if (f.monthEnd !== null) {
2401
- kpis.appendChild(kpiTile('Month-end', compact(f.monthEnd), `measured so far ${compact(f.monthEndActualToDate)}${f.monthEndCost !== null ? ` · ≈${usd(f.monthEndCost)} est.` : ''}`));
2402
- }
2403
- kpis.appendChild(kpiTile('Confidence', f.confidence, f.n ? `${f.n}-day trend` : null));
2404
- body.appendChild(kpis);
2405
-
2406
- // History + projection side by side: measured bars, then forecast bars in a
2407
- // dashed-looking muted tone, clearly separated by an empty slot.
2408
- const daily = v.daily.slice(-14);
2409
- const data = daily.map((d) => ({
2410
- label: shortDate(d.key),
2411
- value: d.total,
2412
- fmtXLong: d.key,
2413
- color: 'var(--series-1)',
2414
- }));
2415
- if (f.tomorrow !== null) {
2416
- data.push({ label: 'tomorrow*', value: f.tomorrow, color: 'var(--hairline)', extra: [{ name: 'Projected', value: compact(f.tomorrow) }] });
2417
- }
2418
- // The month-end projection deliberately stays OUT of the chart: a whole-
2419
- // month total beside daily bars would flatten the history into unreadability.
2420
- // It lives in the KPI tiles above, labelled as a projection.
2421
- const wrapChart = el('div', { style: 'padding:6px 14px 12px' });
2422
- requestAnimationFrame(() => observeWidth(wrapChart, (w) => {
2423
- wrapChart.textContent = '';
2424
- wrapChart.appendChild(columns({
2425
- data, width: w, height: 200, fmtY: (x) => compact(x), valueLabel: 'Tokens',
2426
- ariaLabel: 'Recent daily usage with projections appended',
2427
- }));
2428
- }));
2429
- body.appendChild(wrapChart);
2430
- body.appendChild(el('p', { class: 'hint', style: 'padding:0 14px 12px', text: '* Projected, not measured. The trend assumes the recent pattern continues; confidence is stated above and drops sharply on thin or volatile history.' }));
2431
-
2432
- return card('Forecast', 'Measured history first; projections always labelled and kept apart.', body);
2433
- }
2434
-
2435
- function anomaliesCard() {
2436
- const v = S.view;
2437
- const body = el('div', { style: 'padding:6px 14px 14px' });
2438
- const anomalies = v.anomalies || [];
2439
-
2440
- if (!anomalies.length) {
2441
- body.appendChild(el('p', { class: 'hint', text: 'No anomalies detected in the current dataset. Detection covers token/cost/request spikes, weekday gaps and sudden drops — each reported with its own arithmetic.' }));
2442
- } else {
2443
- for (const a of anomalies) {
2444
- const sev = SEV[a.severity] || SEV.info;
2445
- const row = el('div', { style: 'display:flex;gap:10px;align-items:baseline;padding:7px 0;border-top:1px solid var(--hairline)' });
2446
- row.appendChild(el('span', { class: `badge ${sev.cls}`, text: sev.label }));
2447
- row.appendChild(el('span', { class: 'mono muted', text: a.date, style: 'min-width:86px;font-size:11px' }));
2448
- row.appendChild(el('span', { text: a.detail }));
2449
- body.appendChild(row);
2450
- }
2451
- }
2452
-
2453
- const fresh = [...(v.firstSeen?.models || []).map((m) => ({ kind: 'model', ...m })), ...(v.firstSeen?.providers || []).map((p) => ({ kind: 'provider', ...p }))];
2454
- if (fresh.length) {
2455
- const chips = el('div', { class: 'chips', style: 'padding-top:10px' });
2456
- chips.appendChild(el('span', { class: 'muted', text: 'New this week: ' }));
2457
- for (const x of fresh) {
2458
- chips.appendChild(el('span', { class: 'chip', text: `${x.kind} ${x.entity} (${shortDate(x.firstSeen)})` }));
2459
- }
2460
- body.appendChild(chips);
2461
- }
2462
- return card('Anomalies & changes', 'Robust median/MAD detection — every alert shows observed vs expected so you can check it.', body);
2463
- }
2464
-
2465
- function viewLive() {
2466
- ensureLiveLoop();
2467
- const root = el('div', { class: 'grid' });
2468
- if (!SNAPSHOT) root.appendChild(liveWatcherCard());
2469
- root.appendChild(capacityCard());
2470
- root.appendChild(forecastCard());
2471
- root.appendChild(anomaliesCard());
2472
- return root;
2473
- }