ai-usage-analyzer 0.4.0 → 0.5.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.
package/web/app.js ADDED
@@ -0,0 +1,952 @@
1
+ import { overall, perProject, perTool, perMonth, topSessions } from '/aggregate.js';
2
+
3
+ const $ = id => document.getElementById(id);
4
+ const root = document.documentElement;
5
+ const compact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });
6
+ const number = new Intl.NumberFormat('en');
7
+ const monthDate = new Intl.DateTimeFormat('en', { month: 'short', year: 'numeric', timeZone: 'UTC' });
8
+ const shortMonth = new Intl.DateTimeFormat('en', { month: 'short', timeZone: 'UTC' });
9
+ const dayDate = new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', year: 'numeric' });
10
+ const utcDayLong = new Intl.DateTimeFormat('en', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
11
+ const utcDayShort = new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', timeZone: 'UTC' });
12
+ const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
13
+ const weekdayLong = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
14
+ const pageSize = 20;
15
+ const projectLimit = 8;
16
+ const views = ['overview', 'sessions', 'sources'];
17
+ const tokenFields = [
18
+ ['Input', 'tokensInput'],
19
+ ['Output', 'tokensOutput'],
20
+ ['Cache read', 'tokensCacheRead'],
21
+ ['Cache write', 'tokensCacheWrite'],
22
+ ['Reasoning', 'tokensReasoning'],
23
+ ];
24
+ const periodLabels = { all: 'All time', today: 'Today', week: 'This week', 30: 'Last 30 days', 90: 'Last 90 days', year: 'This year', custom: 'Custom range' };
25
+ const brandIcons = new Set(['claude', 'codex', 'opencode']);
26
+ const state = { data: null, page: 0, project: null, period: { key: 'all' }, tool: 'all', sort: 'tokens', allProjects: false, totalTarget: 0, previousTotal: 0, tapeKey: '', tapeFresh: false, tape: null };
27
+ const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
28
+ let animation;
29
+
30
+ function readTheme() {
31
+ try {
32
+ const value = localStorage.getItem('theme');
33
+ return value === 'light' || value === 'dark' ? value : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function effectiveTheme() {
40
+ return root.dataset.theme || 'light';
41
+ }
42
+
43
+ function syncThemeButton() {
44
+ $('theme').setAttribute('aria-pressed', String(effectiveTheme() === 'dark'));
45
+ }
46
+
47
+ function setTheme(theme) {
48
+ root.dataset.theme = theme;
49
+ syncThemeButton();
50
+ try {
51
+ localStorage.setItem('theme', theme);
52
+ } catch {}
53
+ }
54
+
55
+ function finishAnimation() {
56
+ animation?.progress(1).kill();
57
+ animation = null;
58
+ setTotal(state.totalTarget);
59
+ }
60
+
61
+ function currentView() {
62
+ const name = location.hash.slice(1);
63
+ return views.includes(name) ? name : 'overview';
64
+ }
65
+
66
+ function animateContent(kind) {
67
+ finishAnimation();
68
+ const gsap = window.gsap;
69
+ if (!gsap || reducedMotion.matches) return;
70
+ animation = gsap.timeline({ defaults: { ease: 'power3.out', duration: .6, clearProps: 'transform,opacity,visibility' } });
71
+ const add = (selector, vars, position) => {
72
+ if (gsap.utils.toArray(selector).length) animation.from(selector, vars, position);
73
+ };
74
+ if (kind === 'dialog') {
75
+ animation.from($('session-dialog'), { x: 56, opacity: 0, duration: .4 });
76
+ return;
77
+ }
78
+ const intro = kind === 'intro';
79
+ const view = currentView();
80
+ if (intro) {
81
+ add('.masthead', { y: -12, autoAlpha: 0, duration: .5 });
82
+ add('.filters', { autoAlpha: 0, duration: .5 }, .1);
83
+ }
84
+ const start = intro ? .15 : 0;
85
+ if (view !== 'overview') {
86
+ add(`#${view}-view`, { y: 14, autoAlpha: 0, duration: .45 }, start);
87
+ return;
88
+ }
89
+ const from = intro || kind === 'view' ? 0 : state.previousTotal;
90
+ setTotal(from);
91
+ const counter = { value: from };
92
+ animation.add(gsap.to(counter, { value: state.totalTarget, duration: 1.1, ease: 'power2.out', onUpdate: () => setTotal(counter.value) }), start);
93
+ if (kind !== 'data') {
94
+ add('#view-title, .hero-detail, .insight', { y: 16, autoAlpha: 0, stagger: .06 }, start);
95
+ add('.slip > div', { y: 12, autoAlpha: 0, stagger: .06 }, start + .15);
96
+ }
97
+ add('#breakdown-bar .seg', { scaleX: 0, stagger: .06, duration: .7 }, start + .2);
98
+ if (state.tapeFresh) {
99
+ add('.tick', { scaleY: 0, stagger: { amount: .7 }, duration: .6 }, start + .3);
100
+ state.tapeFresh = false;
101
+ }
102
+ add('.tool-row .fill, .proj-row .part', { scaleX: 0, stagger: .02, duration: .6 }, start + .4);
103
+ add('.wd-bar', { scaleY: 0, stagger: .04 }, start + .4);
104
+ }
105
+
106
+ reducedMotion.addEventListener('change', () => {
107
+ if (reducedMotion.matches) finishAnimation();
108
+ });
109
+
110
+ function el(tag, className, text) {
111
+ const node = document.createElement(tag);
112
+ if (className) node.className = className;
113
+ if (text != null) node.textContent = text;
114
+ return node;
115
+ }
116
+
117
+ function clear(id) {
118
+ const node = $(id);
119
+ node.replaceChildren();
120
+ return node;
121
+ }
122
+
123
+ function clamp(value, low, high) {
124
+ return Math.min(high, Math.max(low, value));
125
+ }
126
+
127
+ function setTotal(value) {
128
+ const text = compact.format(Math.max(0, value));
129
+ const match = /^([\d.,]+)(.*)$/.exec(text);
130
+ $('total-value').textContent = match ? match[1] : text;
131
+ $('total-unit').textContent = match ? match[2] : '';
132
+ }
133
+
134
+ function toolName(key) {
135
+ return state.data.detections.find(tool => tool.key === key)?.name || key;
136
+ }
137
+
138
+ function textureSwatch(key) {
139
+ const node = el('span', `tx tx-${Math.max(0, state.data.tokenTools.indexOf(key)) % 6}`);
140
+ node.setAttribute('aria-hidden', 'true');
141
+ return node;
142
+ }
143
+
144
+ function toolSwatch(key) {
145
+ if (!brandIcons.has(key)) return textureSwatch(key);
146
+ const node = el('span', `ic ic-${key}`);
147
+ node.setAttribute('aria-hidden', 'true');
148
+ return node;
149
+ }
150
+
151
+ function textureClass(key) {
152
+ return `tx-${Math.max(0, state.data.tokenTools.indexOf(key)) % 6}`;
153
+ }
154
+
155
+ function costLabel(summary) {
156
+ const prefixes = { partial: '≥ ', estimated: '≈ ', recorded: '' };
157
+ return summary.costStatus === 'unavailable' ? 'N/A' : `${prefixes[summary.costStatus] || ''}$${summary.cost.toFixed(2)}`;
158
+ }
159
+
160
+ function costDescription(status) {
161
+ return ({ recorded: 'Recorded in local data', estimated: 'Estimated at public API rates', partial: 'Partial total, some costs missing', unavailable: 'Cost data unavailable' })[status];
162
+ }
163
+
164
+ function validTs(record) {
165
+ return Number.isFinite(record.ts) && !Number.isNaN(new Date(record.ts).getTime());
166
+ }
167
+
168
+ function dated(ts, formatter = dayDate) {
169
+ return Number.isFinite(ts) && !Number.isNaN(new Date(ts).getTime()) ? formatter.format(ts) : 'Unknown date';
170
+ }
171
+
172
+ function dayNumber(ts) {
173
+ const date = new Date(ts);
174
+ return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);
175
+ }
176
+
177
+ function dayLabel(day, formatter = utcDayLong) {
178
+ return formatter.format(day * 86400000);
179
+ }
180
+
181
+ function periodBounds() {
182
+ const { key, from, to } = state.period;
183
+ const today = dayNumber(Date.now());
184
+ if (key === 'all') return null;
185
+ if (key === 'custom') return [from, to];
186
+ if (key === 'today') return [today, today];
187
+ if (key === 'week') return [today - (new Date().getDay() + 6) % 7, today];
188
+ if (key === 'year') return [dayNumber(new Date(new Date().getFullYear(), 0, 1).getTime()), today];
189
+ return [today - Number(key) + 1, today];
190
+ }
191
+
192
+ function periodRecords() {
193
+ const bounds = periodBounds();
194
+ if (!bounds) return state.data.records;
195
+ return state.data.records.filter(record => validTs(record) && dayNumber(record.ts) >= bounds[0] && dayNumber(record.ts) <= bounds[1]);
196
+ }
197
+
198
+ function toolRecords() {
199
+ return state.tool === 'all' ? state.data.records : state.data.records.filter(record => record.tool === state.tool);
200
+ }
201
+
202
+ function selectedRecords() {
203
+ return state.tool === 'all' ? periodRecords() : periodRecords().filter(record => record.tool === state.tool);
204
+ }
205
+
206
+ function makeTable(headers, numeric = []) {
207
+ const table = el('table');
208
+ const head = el('thead');
209
+ const row = el('tr');
210
+ headers.forEach((name, index) => {
211
+ const th = el('th', numeric.includes(index) ? 'numeric' : '', name);
212
+ th.scope = 'col';
213
+ row.append(th);
214
+ });
215
+ head.append(row);
216
+ const body = el('tbody');
217
+ table.append(head, body);
218
+ return { table, body, numeric };
219
+ }
220
+
221
+ function addRow(target, cells) {
222
+ const row = el('tr');
223
+ cells.forEach((content, index) => {
224
+ const td = el('td', target.numeric.includes(index) ? 'numeric' : '');
225
+ td.append(content instanceof Node ? content : document.createTextNode(String(content)));
226
+ row.append(td);
227
+ });
228
+ target.body.append(row);
229
+ }
230
+
231
+ function empty(container, message, label, handler) {
232
+ const block = el('div', 'empty');
233
+ block.append(el('p', '', message));
234
+ if (label) block.append(action(label, handler));
235
+ container.append(block);
236
+ }
237
+
238
+ function action(text, handler) {
239
+ const button = el('button', 'text-button', text);
240
+ button.type = 'button';
241
+ button.addEventListener('click', handler);
242
+ return button;
243
+ }
244
+
245
+ function resetFilters() {
246
+ state.period = { key: 'all' };
247
+ state.tool = 'all';
248
+ state.page = 0;
249
+ render();
250
+ }
251
+
252
+ function filtersActive() {
253
+ return state.period.key !== 'all' || state.tool !== 'all';
254
+ }
255
+
256
+ function dailyTotals(records) {
257
+ const days = new Map();
258
+ for (const record of records) {
259
+ if (!validTs(record)) continue;
260
+ const day = dayNumber(record.ts);
261
+ days.set(day, (days.get(day) || 0) + (record.tokensTotal || 0));
262
+ }
263
+ return days;
264
+ }
265
+
266
+ function insightText(totals) {
267
+ if (!totals.tokensTotal) return '';
268
+ const share = value => `${(value / totals.tokensTotal * 100).toFixed(1)}%`;
269
+ const [name, key] = tokenFields.reduce((best, field) => totals[field[1]] > totals[best[1]] ? field : best);
270
+ if (key === 'tokensOutput') return `Output is ${share(totals.tokensOutput)} of the tokens.`;
271
+ return `${name} is ${share(totals[key])} of the tokens. Output, the text you read back, is ${share(totals.tokensOutput)}.`;
272
+ }
273
+
274
+ function renderSummary(records) {
275
+ const totals = overall(records);
276
+ state.previousTotal = state.totalTarget;
277
+ state.totalTarget = totals.tokensTotal;
278
+ setTotal(totals.tokensTotal);
279
+ $('total-detail').textContent = totals.n ? `${number.format(totals.tokensTotal)} tokens across ${number.format(totals.n)} ${totals.n === 1 ? 'session' : 'sessions'}` : 'No sessions in this selection';
280
+ $('insight').textContent = insightText(totals);
281
+ $('session-count').textContent = number.format(totals.n);
282
+ $('project-count').textContent = number.format(new Set(records.map(record => record.project)).size);
283
+ $('average').textContent = totals.n ? compact.format(totals.avg) : 'None';
284
+ $('cost').textContent = costLabel(totals);
285
+ $('cost-detail').textContent = costDescription(totals.costStatus);
286
+ const days = dailyTotals(records);
287
+ const busiest = [...days].reduce((best, entry) => !best || entry[1] > best[1] ? entry : best, null);
288
+ $('busiest-day').textContent = busiest ? `${dayLabel(busiest[0], utcDayShort)} · ${compact.format(busiest[1])}` : 'None';
289
+ const times = records.filter(validTs).map(record => record.ts);
290
+ const range = times.reduce((result, ts) => [Math.min(result[0], ts), Math.max(result[1], ts)], [Infinity, -Infinity]);
291
+ $('range-label').textContent = times.length ? `${dated(range[0])} to ${dated(range[1])}` : '';
292
+ renderComposition(totals);
293
+ }
294
+
295
+ function renderComposition(totals) {
296
+ const bar = clear('breakdown-bar');
297
+ const list = clear('breakdown');
298
+ const percent = value => totals.tokensTotal ? `${(Math.min(100, value / totals.tokensTotal * 100)).toFixed(1)}%` : '0%';
299
+ bar.setAttribute('aria-label', `Token categories: ${tokenFields.map(([name, key]) => `${name} ${percent(totals[key])}`).join(', ')}`);
300
+ tokenFields.forEach(([name, key], index) => {
301
+ const value = totals[key] || 0;
302
+ if (value) {
303
+ const segment = el('div', `seg t${index}`);
304
+ segment.style.flex = `${value} 1 0%`;
305
+ bar.append(segment);
306
+ }
307
+ const item = el('li');
308
+ item.title = `${name}: ${number.format(value)} tokens`;
309
+ item.append(el('span', `chip seg t${index}`), el('span', '', name), el('strong', '', compact.format(value)), el('em', '', percent(value)));
310
+ list.append(item);
311
+ });
312
+ }
313
+
314
+ function tapeModel() {
315
+ const records = toolRecords().filter(validTs);
316
+ if (!records.length) return null;
317
+ let first = Infinity;
318
+ let last = dayNumber(Date.now());
319
+ for (const record of records) {
320
+ const day = dayNumber(record.ts);
321
+ if (day < first) first = day;
322
+ if (day > last) last = day;
323
+ }
324
+ const span = last - first + 1;
325
+ const size = span > 400 ? 7 : 1;
326
+ const count = Math.ceil(span / size);
327
+ const buckets = Array.from({ length: count }, (_, index) => ({ start: first + index * size, end: Math.min(first + (index + 1) * size - 1, last), tokens: 0, n: 0 }));
328
+ for (const record of records) {
329
+ const bucket = buckets[Math.floor((dayNumber(record.ts) - first) / size)];
330
+ bucket.tokens += record.tokensTotal || 0;
331
+ bucket.n++;
332
+ }
333
+ return { first, last, size, count, buckets };
334
+ }
335
+
336
+ function bucketLabel(model, bucket) {
337
+ return model.size === 1 ? dayLabel(bucket.start) : `Week of ${dayLabel(bucket.start, utcDayShort)}`;
338
+ }
339
+
340
+ function rangeReadout() {
341
+ const bounds = periodBounds();
342
+ const totals = overall(selectedRecords());
343
+ const readout = clear('tape-readout');
344
+ const detail = bounds
345
+ ? `${bounds[0] === bounds[1] ? dayLabel(bounds[0], utcDayShort) : `${dayLabel(bounds[0], utcDayShort)} to ${dayLabel(bounds[1], utcDayShort)}`} · ${compact.format(totals.tokensTotal)} tokens`
346
+ : state.tape ? `${dayLabel(state.tape.first, utcDayShort)} to ${dayLabel(state.tape.last, utcDayShort)}` : '';
347
+ readout.append(document.createTextNode(`${periodLabels[state.period.key]} `), el('span', '', detail));
348
+ $('tape-reset').hidden = !bounds;
349
+ }
350
+
351
+ function placeSelection(range) {
352
+ const model = state.tape;
353
+ const plot = $('tape').querySelector('.tape-plot');
354
+ if (!model || !plot) return;
355
+ const [left, right, pick] = plot.querySelectorAll('.veil-left, .veil-right, .pick');
356
+ if (!range) {
357
+ for (const node of [left, right, pick]) node.hidden = true;
358
+ return;
359
+ }
360
+ const outside = range[1] < model.first || range[0] > model.last;
361
+ const from = clamp(Math.floor((range[0] - model.first) / model.size), 0, model.count - 1);
362
+ const to = clamp(Math.floor((range[1] - model.first) / model.size), 0, model.count - 1);
363
+ const a = from / model.count * 100;
364
+ const b = (to + 1) / model.count * 100;
365
+ left.hidden = right.hidden = false;
366
+ pick.hidden = outside;
367
+ left.style.left = '0';
368
+ left.style.width = outside ? '100%' : `${a}%`;
369
+ right.style.left = `${b}%`;
370
+ right.style.width = outside ? '0' : `${100 - b}%`;
371
+ pick.style.left = `${a}%`;
372
+ pick.style.width = `${b - a}%`;
373
+ }
374
+
375
+ function commitRange(fromIndex, toIndex) {
376
+ const model = state.tape;
377
+ const a = Math.min(fromIndex, toIndex);
378
+ const b = Math.max(fromIndex, toIndex);
379
+ state.period = { key: 'custom', from: model.first + a * model.size, to: Math.min(model.first + (b + 1) * model.size - 1, model.last) };
380
+ state.page = 0;
381
+ render();
382
+ }
383
+
384
+ function buildAxis(model, plotWidth) {
385
+ const axis = el('div', 'tape-axis');
386
+ axis.setAttribute('aria-hidden', 'true');
387
+ let lastKey = '';
388
+ let lastLeft = -Infinity;
389
+ model.buckets.forEach((bucket, index) => {
390
+ const date = new Date(bucket.start * 86400000);
391
+ const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
392
+ if (key === lastKey) return;
393
+ lastKey = key;
394
+ const left = index / model.count * plotWidth;
395
+ if (left - lastLeft < 64) return;
396
+ lastLeft = left;
397
+ const label = el('span', '', date.getUTCMonth() === 0 || index === 0 ? monthDate.format(date) : shortMonth.format(date));
398
+ label.style.left = `${index / model.count * 100}%`;
399
+ axis.append(label);
400
+ });
401
+ return axis;
402
+ }
403
+
404
+ function renderTape() {
405
+ const container = $('tape');
406
+ const model = tapeModel();
407
+ const key = `${state.tool}|${state.data.generatedAt}|${innerWidth}`;
408
+ if (model && key === state.tapeKey && container.querySelector('.tape-plot')) {
409
+ state.tape = model;
410
+ placeSelection(periodBounds());
411
+ rangeReadout();
412
+ return;
413
+ }
414
+ state.tapeKey = key;
415
+ state.tape = model;
416
+ container.replaceChildren();
417
+ if (!model) {
418
+ $('tape-readout').replaceChildren();
419
+ $('tape-reset').hidden = true;
420
+ empty(container, 'No dated sessions for this tool. Sessions without a date cannot be placed on the tape.', state.tool !== 'all' ? 'Show all tools' : '', () => { state.tool = 'all'; render(); });
421
+ return;
422
+ }
423
+ state.tapeFresh = true;
424
+ const peak = model.buckets.reduce((best, bucket) => bucket.tokens > best.tokens ? bucket : best);
425
+ const peakIndex = model.buckets.indexOf(peak);
426
+ const plot = el('div', 'tape-plot');
427
+ plot.tabIndex = 0;
428
+ plot.setAttribute('role', 'group');
429
+ plot.setAttribute('aria-label', 'Daily token activity. Use left and right arrow keys to read each day, hold Shift to select a range, press Escape to clear it. Exact monthly values are below.');
430
+ model.buckets.forEach((bucket, index) => {
431
+ const tick = el('div', 'tick');
432
+ if (!bucket.tokens) tick.classList.add('zero');
433
+ else tick.style.setProperty('--h', `${Math.max(2, bucket.tokens / peak.tokens * 100)}%`);
434
+ if (index === peakIndex && bucket.tokens) tick.classList.add('peak');
435
+ plot.append(tick);
436
+ });
437
+ const cursor = el('div', 'cursor');
438
+ cursor.hidden = true;
439
+ plot.append(el('div', 'veil veil-left'), el('div', 'veil veil-right'), el('div', 'pick'), cursor);
440
+ if (peak.tokens) {
441
+ const flag = el('div', 'peak-flag', `Peak ${compact.format(peak.tokens)} · ${model.size === 1 ? dayLabel(peak.start, utcDayShort) : `week of ${dayLabel(peak.start, utcDayShort)}`}`);
442
+ const center = (peakIndex + .5) / model.count * 100;
443
+ if (center > 65) flag.style.right = `${100 - center}%`;
444
+ else flag.style.left = `${center}%`;
445
+ plot.append(flag);
446
+ }
447
+ container.append(plot);
448
+ container.append(buildAxis(model, plot.getBoundingClientRect().width || 800));
449
+ placeSelection(periodBounds());
450
+ rangeReadout();
451
+ bindTape(plot, model, cursor);
452
+ renderMonthly();
453
+ }
454
+
455
+ function bindTape(plot, model, cursor) {
456
+ const indexAt = event => {
457
+ const rect = plot.getBoundingClientRect();
458
+ return clamp(Math.floor((event.clientX - rect.left) / rect.width * model.count), 0, model.count - 1);
459
+ };
460
+ const preview = (a, b) => {
461
+ const low = Math.min(a, b);
462
+ const high = Math.max(a, b);
463
+ placeSelection([model.first + low * model.size, model.first + (high + 1) * model.size - 1]);
464
+ };
465
+ const show = index => {
466
+ const bucket = model.buckets[index];
467
+ cursor.hidden = false;
468
+ cursor.style.left = `${(index + .5) / model.count * 100}%`;
469
+ const readout = clear('tape-readout');
470
+ readout.append(document.createTextNode(`${bucketLabel(model, bucket)} `), el('span', '', `${compact.format(bucket.tokens)} tokens · ${number.format(bucket.n)} ${bucket.n === 1 ? 'session' : 'sessions'}`));
471
+ return readout.textContent;
472
+ };
473
+ const rest = () => {
474
+ cursor.hidden = true;
475
+ rangeReadout();
476
+ };
477
+ let drag = null;
478
+ let position = null;
479
+ let anchor = null;
480
+ plot.addEventListener('pointerdown', event => {
481
+ if (event.button !== 0) return;
482
+ plot.setPointerCapture(event.pointerId);
483
+ const index = indexAt(event);
484
+ drag = { from: index, to: index };
485
+ preview(index, index);
486
+ });
487
+ plot.addEventListener('pointermove', event => {
488
+ const index = indexAt(event);
489
+ show(index);
490
+ if (!drag) return;
491
+ drag.to = index;
492
+ preview(drag.from, drag.to);
493
+ });
494
+ plot.addEventListener('pointerup', () => {
495
+ if (!drag) return;
496
+ const { from, to } = drag;
497
+ drag = null;
498
+ commitRange(from, to);
499
+ });
500
+ plot.addEventListener('pointercancel', () => {
501
+ drag = null;
502
+ placeSelection(periodBounds());
503
+ rest();
504
+ });
505
+ plot.addEventListener('pointerleave', () => {
506
+ if (!drag) rest();
507
+ });
508
+ plot.addEventListener('blur', () => {
509
+ position = null;
510
+ anchor = null;
511
+ rest();
512
+ });
513
+ plot.addEventListener('keydown', event => {
514
+ if (event.key === 'Escape') {
515
+ if (state.period.key === 'all') return;
516
+ event.preventDefault();
517
+ anchor = null;
518
+ state.period = { key: 'all' };
519
+ render();
520
+ $('tape-live').textContent = 'Range cleared. Showing all time.';
521
+ return;
522
+ }
523
+ const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
524
+ if (step == null && event.key !== 'Home' && event.key !== 'End' && event.key !== 'Enter' && event.key !== ' ') return;
525
+ event.preventDefault();
526
+ const previous = position;
527
+ if (event.key === 'Home') position = 0;
528
+ else if (event.key === 'End') position = model.count - 1;
529
+ else if (step != null) position = clamp((position ?? (step < 0 ? model.count : -1)) + step, 0, model.count - 1);
530
+ else position ??= model.count - 1;
531
+ if (event.shiftKey || event.key === 'Enter' || event.key === ' ') {
532
+ anchor ??= previous ?? position;
533
+ commitRange(anchor, position);
534
+ } else {
535
+ anchor = null;
536
+ }
537
+ $('tape-live').textContent = show(position);
538
+ });
539
+ }
540
+
541
+ function renderMonthly() {
542
+ const container = clear('monthly-values');
543
+ const months = perMonth(toolRecords().filter(record => /^\d{4}-(0[1-9]|1[0-2])$/.test(record.month)));
544
+ if (!months.length) {
545
+ empty(container, 'No monthly records for this tool.');
546
+ return;
547
+ }
548
+ const table = makeTable(['Month (UTC)', 'Tokens', 'Sessions'], [1, 2]);
549
+ for (const month of months) addRow(table, [monthDate.format(new Date(`${month.month}-01T00:00:00Z`)), number.format(month.tokensTotal), number.format(month.n)]);
550
+ container.append(table.table);
551
+ }
552
+
553
+ function renderTools() {
554
+ const container = clear('tools');
555
+ const records = periodRecords();
556
+ const groups = perTool(records);
557
+ if (!groups.length) {
558
+ empty(container, 'No token records in this period. Sources lists which tools were detected.', filtersActive() ? 'Show all time and all tools' : '', resetFilters);
559
+ return;
560
+ }
561
+ const total = overall(records).tokensTotal;
562
+ for (const group of groups) {
563
+ const row = el('button', 'tool-row');
564
+ row.type = 'button';
565
+ row.setAttribute('aria-pressed', String(state.tool === group.tool));
566
+ const head = el('span', 'tool-head');
567
+ const name = el('span', 'name');
568
+ name.append(toolSwatch(group.tool), document.createTextNode(toolName(group.tool)));
569
+ head.append(name);
570
+ if (state.tool === group.tool) head.append(el('span', 'state', 'Filter on'));
571
+ head.append(el('strong', '', compact.format(group.tokensTotal)));
572
+ const bar = el('span', 'bar');
573
+ const fill = el('span', `fill ${textureClass(group.tool)}`);
574
+ fill.style.width = `${total ? group.tokensTotal / total * 100 : 0}%`;
575
+ bar.append(fill);
576
+ const share = total ? (group.tokensTotal / total * 100).toFixed(1) : '0';
577
+ row.append(head, bar, el('span', 'tool-meta', `${share}% of tokens · ${number.format(group.n)} ${group.n === 1 ? 'session' : 'sessions'} · ${compact.format(group.avg)} average`));
578
+ row.setAttribute('aria-label', `${state.tool === group.tool ? 'Clear filter for' : 'Filter by'} ${toolName(group.tool)}: ${compact.format(group.tokensTotal)} tokens`);
579
+ row.addEventListener('click', () => {
580
+ state.tool = state.tool === group.tool ? 'all' : group.tool;
581
+ state.page = 0;
582
+ render();
583
+ });
584
+ container.append(row);
585
+ }
586
+ }
587
+
588
+ function renderWeekday(records) {
589
+ const container = clear('weekday');
590
+ const totals = Array(7).fill(0);
591
+ for (const record of records) if (validTs(record)) totals[(new Date(record.ts).getDay() + 6) % 7] += record.tokensTotal || 0;
592
+ const sum = totals.reduce((a, b) => a + b, 0);
593
+ const peak = Math.max(...totals);
594
+ const top = peak ? totals.indexOf(peak) : -1;
595
+ totals.forEach((value, index) => {
596
+ const column = el('div', index === top ? 'wd top' : 'wd');
597
+ column.title = `${weekdayLong[index]}: ${number.format(value)} tokens`;
598
+ const track = el('div', 'wd-track');
599
+ const bar = el('div', 'wd-bar');
600
+ bar.style.height = `${peak ? value / peak * 100 : 0}%`;
601
+ track.append(bar);
602
+ column.append(track, el('span', 'wd-label', weekdays[index]));
603
+ container.append(column);
604
+ });
605
+ $('weekday-note').textContent = sum ? `${weekdayLong[top]} carries ${(peak / sum * 100).toFixed(0)}% of the tokens, in your local time.` : 'No dated sessions in this selection.';
606
+ }
607
+
608
+ function renderProjects(records) {
609
+ const container = clear('projects');
610
+ const more = $('projects-more');
611
+ const groups = perProject(records);
612
+ more.hidden = groups.length <= projectLimit;
613
+ more.textContent = state.allProjects ? `Show top ${projectLimit} only` : `Show all ${groups.length} projects`;
614
+ if (!groups.length) {
615
+ clear('projects-legend');
616
+ empty(container, 'No projects in this selection.', filtersActive() ? 'Show all time and all tools' : '', resetFilters);
617
+ return;
618
+ }
619
+ const total = overall(records).tokensTotal;
620
+ const peak = groups[0].tokensTotal || 1;
621
+ const legend = clear('projects-legend');
622
+ for (const tool of new Set(groups.flatMap(group => Object.keys(group.byTool)))) {
623
+ const item = el('li');
624
+ item.append(textureSwatch(tool), document.createTextNode(toolName(tool)));
625
+ legend.append(item);
626
+ }
627
+ const list = el('ol');
628
+ groups.slice(0, state.allProjects ? groups.length : projectLimit).forEach((project, index) => {
629
+ const row = el('li', 'proj-row');
630
+ row.append(el('span', 'proj-rank', String(index + 1).padStart(2, '0')));
631
+ const name = el('div', 'proj-name');
632
+ const label = project.project || '(unknown project)';
633
+ name.append(action(label.split('/').filter(Boolean).at(-1) || label, () => {
634
+ state.project = project.project;
635
+ state.page = 0;
636
+ $('search').value = '';
637
+ location.hash = 'sessions';
638
+ }), el('span', 'path', label));
639
+ const stack = el('div', 'stack');
640
+ stack.setAttribute('role', 'img');
641
+ const parts = Object.entries(project.byTool).sort((a, b) => b[1] - a[1]);
642
+ stack.setAttribute('aria-label', parts.map(([tool, value]) => `${toolName(tool)} ${compact.format(value)}`).join(', '));
643
+ for (const [tool, value] of parts) {
644
+ const part = el('div', `part ${textureClass(tool)}`);
645
+ part.style.width = `${value / peak * 100}%`;
646
+ part.title = `${toolName(tool)}: ${number.format(value)} tokens`;
647
+ stack.append(part);
648
+ }
649
+ row.append(name, stack, el('span', 'proj-tokens', compact.format(project.tokensTotal)), el('span', 'proj-share', `${total ? (project.tokensTotal / total * 100).toFixed(1) : 0}%`));
650
+ list.append(row);
651
+ });
652
+ container.append(list);
653
+ }
654
+
655
+ function renderHeaviest(records) {
656
+ const container = clear('heaviest');
657
+ const items = topSessions(records.filter(record => record.tokensTotal > 0), 5);
658
+ if (!items.length) {
659
+ const item = el('li');
660
+ empty(item, 'No sessions with token records in this selection.', filtersActive() ? 'Show all time and all tools' : '', resetFilters);
661
+ container.append(item);
662
+ return;
663
+ }
664
+ items.forEach((record, index) => {
665
+ const item = el('li');
666
+ const button = el('button', 'heavy-row');
667
+ button.type = 'button';
668
+ button.setAttribute('aria-label', `Open session ${record.title || 'Untitled session'}, ${compact.format(record.tokensTotal)} tokens`);
669
+ const meta = el('span', 'heavy-meta');
670
+ const tag = el('span', 'tag');
671
+ tag.append(toolSwatch(record.tool), document.createTextNode(toolName(record.tool)));
672
+ meta.append(tag, el('span', '', record.project || '(unknown project)'), el('span', '', dated(record.ts)));
673
+ button.append(el('span', 'heavy-rank', String(index + 1).padStart(2, '0')), el('span', 'heavy-tokens', compact.format(record.tokensTotal)), el('span', 'heavy-title', record.title || 'Untitled session'), meta);
674
+ button.addEventListener('click', () => openSession(record));
675
+ item.append(button);
676
+ container.append(item);
677
+ });
678
+ }
679
+
680
+ function formatDuration(ms) {
681
+ const minutes = Math.round(ms / 60000);
682
+ if (minutes < 1) return 'Under a minute';
683
+ return minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
684
+ }
685
+
686
+ function openSession(record) {
687
+ $('session-title').textContent = record.title || 'Untitled session';
688
+ const tool = clear('session-tool');
689
+ tool.append(toolSwatch(record.tool), document.createTextNode(toolName(record.tool)));
690
+ const total = clear('session-total');
691
+ total.append(document.createTextNode(number.format(record.tokensTotal)), el('small', '', 'tokens'));
692
+ const split = clear('session-split');
693
+ tokenFields.forEach(([name, key], index) => {
694
+ if (!record[key]) return;
695
+ const segment = el('div', `seg t${index}`);
696
+ segment.style.flex = `${record[key]} 1 0%`;
697
+ segment.title = `${name}: ${number.format(record[key])} tokens`;
698
+ split.append(segment);
699
+ });
700
+ const summary = overall([record]);
701
+ const details = clear('session-details');
702
+ const fields = [
703
+ ['Project', record.project || '(unknown)'],
704
+ ['Date', dated(record.ts)],
705
+ ...(record.durationMs > 0 ? [['Duration', formatDuration(record.durationMs)]] : []),
706
+ ['Model', record.model || 'Not recorded'],
707
+ ['Session ID', record.sessionId || 'Not recorded'],
708
+ ...tokenFields.map(([name, key], index) => [name, number.format(record[key] || 0), index]),
709
+ ['API cost', `${costLabel(summary)} · ${costDescription(summary.costStatus)}`],
710
+ ];
711
+ for (const [name, value, tone] of fields) {
712
+ const row = el('div');
713
+ const term = el('dt');
714
+ if (tone != null) term.append(el('span', `swatch seg t${tone}`));
715
+ term.append(document.createTextNode(name));
716
+ row.append(term, el('dd', '', value));
717
+ details.append(row);
718
+ }
719
+ $('session-dialog').showModal();
720
+ animateContent('dialog');
721
+ }
722
+
723
+ function renderSessions(records) {
724
+ const query = $('search').value.trim().toLocaleLowerCase();
725
+ const items = records.filter(record => (!state.project || record.project === state.project) && (!query || [record.title, record.project, record.model, toolName(record.tool)].some(value => String(value || '').toLocaleLowerCase().includes(query))));
726
+ items.sort(state.sort === 'newest' ? (a, b) => (b.ts || 0) - (a.ts || 0) : (a, b) => b.tokensTotal - a.tokensTotal);
727
+ state.page = Math.min(state.page, Math.max(0, Math.ceil(items.length / pageSize) - 1));
728
+ const start = state.page * pageSize;
729
+ const container = clear('session-list');
730
+ $('project-filter').hidden = !state.project;
731
+ $('selected-project').textContent = state.project || '';
732
+ for (const button of document.querySelectorAll('[data-sort]')) button.setAttribute('aria-pressed', String(button.dataset.sort === state.sort));
733
+ if (!items.length) {
734
+ const anyFilter = query || state.project || filtersActive();
735
+ empty(container, anyFilter ? 'No sessions match. Clear the search or widen the filters.' : 'No sessions were found. Sources shows which tools were detected.', anyFilter ? 'Clear search and filters' : '', () => {
736
+ $('search').value = '';
737
+ state.project = null;
738
+ resetFilters();
739
+ });
740
+ } else {
741
+ const peak = Math.max(...items.map(record => record.tokensTotal), 1);
742
+ const target = makeTable(['Session', 'Tool', 'Date', 'Tokens', 'API cost'], [3, 4]);
743
+ target.table.className = 'session-table';
744
+ for (const record of items.slice(start, start + pageSize)) {
745
+ const summary = overall([record]);
746
+ const name = el('div', 'session-title-cell');
747
+ name.append(
748
+ action(record.title || 'Untitled session', () => openSession(record)),
749
+ el('span', 'cell-detail', record.project || '(unknown project)'),
750
+ el('span', 'mobile-session-meta', `${toolName(record.tool)} · ${dated(record.ts)} · ${costLabel(summary)}`),
751
+ );
752
+ const tag = el('span', 'tool-tag');
753
+ tag.append(toolSwatch(record.tool), document.createTextNode(toolName(record.tool)));
754
+ const tokens = el('div', 'token-cell');
755
+ const mini = el('div', 'mini');
756
+ const fill = el('span');
757
+ fill.style.width = `${record.tokensTotal / peak * 100}%`;
758
+ mini.append(fill);
759
+ tokens.append(el('strong', '', compact.format(record.tokensTotal)), mini);
760
+ const cost = el('span', '', costLabel(summary));
761
+ cost.title = costDescription(summary.costStatus);
762
+ addRow(target, [name, tag, dated(record.ts), tokens, cost]);
763
+ }
764
+ container.append(target.table);
765
+ }
766
+ $('session-range').textContent = items.length ? `${start + 1}–${Math.min(start + pageSize, items.length)} of ${number.format(items.length)} sessions` : '0 sessions';
767
+ $('previous').disabled = state.page === 0;
768
+ $('next').disabled = start + pageSize >= items.length;
769
+ }
770
+
771
+ function renderSources() {
772
+ const container = clear('sources');
773
+ const target = makeTable(['Tool', 'Availability', 'Data path', 'Token records']);
774
+ target.table.className = 'sources-table';
775
+ let detected = 0;
776
+ for (const tool of state.data.detections) {
777
+ if (tool.status === 'present') detected++;
778
+ const path = el('span', 'source-path', tool.path || 'No local data found');
779
+ const status = el('span', tool.status === 'present' ? 'status detected' : 'status', tool.status === 'present' ? 'Detected' : 'Not detected');
780
+ const name = el('strong', '', tool.name);
781
+ addRow(target, [name, status, path, tool.hasTokens ? 'Supported' : 'Presence only']);
782
+ }
783
+ container.append(target.table);
784
+ $('source-count').textContent = `${detected}/${state.data.detections.length}`;
785
+ const errors = clear('error-list');
786
+ $('read-errors').hidden = !state.data.errors?.length;
787
+ for (const error of state.data.errors || []) errors.append(el('li', '', String(error)));
788
+ }
789
+
790
+ function buildToolFilter() {
791
+ const group = clear('tool-filter');
792
+ const all = el('button', '', 'All tools');
793
+ all.type = 'button';
794
+ all.dataset.tool = 'all';
795
+ group.append(all);
796
+ for (const detection of state.data.detections.filter(item => item.hasTokens)) {
797
+ const button = el('button');
798
+ button.type = 'button';
799
+ button.dataset.tool = detection.key;
800
+ button.append(toolSwatch(detection.key), document.createTextNode(detection.name));
801
+ group.append(button);
802
+ }
803
+ }
804
+
805
+ function renderFilters(records) {
806
+ for (const button of document.querySelectorAll('[data-period]')) {
807
+ const key = button.dataset.period;
808
+ button.setAttribute('aria-pressed', String(state.period.key === key));
809
+ if (key === 'custom') button.hidden = state.period.key !== 'custom';
810
+ }
811
+ for (const button of $('tool-filter').children) button.setAttribute('aria-pressed', String(button.dataset.tool === state.tool));
812
+ $('scope').textContent = `${number.format(records.length)} ${records.length === 1 ? 'session' : 'sessions'} in view`;
813
+ }
814
+
815
+ function render(animate = true) {
816
+ if (!state.data) return;
817
+ finishAnimation();
818
+ const records = selectedRecords();
819
+ renderFilters(records);
820
+ renderSummary(records);
821
+ renderTape();
822
+ renderTools();
823
+ renderWeekday(records);
824
+ renderProjects(records);
825
+ renderHeaviest(records);
826
+ renderSessions(records);
827
+ if (animate) animateContent('data');
828
+ }
829
+
830
+ function showView(animate = true) {
831
+ finishAnimation();
832
+ const view = currentView();
833
+ for (const link of document.querySelectorAll('.views a')) {
834
+ if (link.hash === `#${view}`) link.setAttribute('aria-current', 'page');
835
+ else link.removeAttribute('aria-current');
836
+ }
837
+ for (const name of views) $(`${name}-view`).hidden = view !== name;
838
+ $('filters').hidden = view === 'sources';
839
+ document.title = `${({ overview: 'Usage overview', sessions: 'Sessions', sources: 'Local sources' })[view]} · AI Usage Analyzer`;
840
+ if (state.data && view === 'sessions') renderSessions(selectedRecords());
841
+ if (state.data && animate) animateContent('view');
842
+ }
843
+
844
+ function setMessage(text, { error = false, link, retry } = {}) {
845
+ const node = clear('message');
846
+ node.classList.toggle('error', error);
847
+ if (!text) return;
848
+ node.append(document.createTextNode(text));
849
+ if (link) {
850
+ const anchor = el('a', '', link[0]);
851
+ anchor.href = link[1];
852
+ node.append(anchor);
853
+ }
854
+ if (retry) node.append(action(retry, load));
855
+ }
856
+
857
+ async function load() {
858
+ const initial = !state.data;
859
+ $('refresh').disabled = true;
860
+ $('refresh-label').textContent = 'Reading…';
861
+ $('dashboard').setAttribute('aria-busy', 'true');
862
+ if (initial) $('loading').hidden = false;
863
+ else setMessage('Refreshing local usage records…');
864
+ try {
865
+ const response = await fetch('/api/usage');
866
+ if (!response.ok) throw new Error('The analyzer could not read your local usage records.');
867
+ const data = await response.json();
868
+ data.tokenTools = data.detections.filter(item => item.hasTokens).map(item => item.key);
869
+ state.data = data;
870
+ if (state.tool !== 'all' && !data.tokenTools.includes(state.tool)) state.tool = 'all';
871
+ state.tapeKey = '';
872
+ buildToolFilter();
873
+ $('loading').hidden = true;
874
+ $('dashboard').hidden = false;
875
+ setMessage('');
876
+ if (data.errors?.length) setMessage(`${data.errors.length} read ${data.errors.length === 1 ? 'issue needs' : 'issues need'} attention. Totals may be incomplete.`, { link: ['View read issues', '#sources'] });
877
+ $('updated').textContent = `Read at ${new Date(data.generatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
878
+ renderSources();
879
+ render(false);
880
+ showView(false);
881
+ animateContent(initial ? 'intro' : 'data');
882
+ } catch (error) {
883
+ $('loading').hidden = true;
884
+ const reason = error instanceof TypeError ? 'The analyzer server did not respond.' : error.message;
885
+ setMessage(`${reason} ${state.data ? 'Showing the previous snapshot. ' : ''}Check the terminal where it is running.`, { error: true, retry: 'Read again' });
886
+ } finally {
887
+ $('refresh').disabled = false;
888
+ $('refresh-label').textContent = 'Refresh records';
889
+ $('dashboard').setAttribute('aria-busy', 'false');
890
+ }
891
+ }
892
+
893
+ const storedTheme = readTheme();
894
+ if (storedTheme) root.dataset.theme = storedTheme;
895
+ syncThemeButton();
896
+ $('theme').addEventListener('click', () => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark'));
897
+ $('refresh').addEventListener('click', load);
898
+ document.querySelector('.skip-link').addEventListener('click', event => {
899
+ event.preventDefault();
900
+ $('main').focus();
901
+ });
902
+ for (const button of document.querySelectorAll('[data-period]')) {
903
+ button.addEventListener('click', () => {
904
+ if (button.dataset.period === 'custom') return;
905
+ state.period = { key: button.dataset.period };
906
+ state.page = 0;
907
+ render();
908
+ });
909
+ }
910
+ $('tool-filter').addEventListener('click', event => {
911
+ const button = event.target.closest('button');
912
+ if (!button) return;
913
+ state.tool = button.dataset.tool;
914
+ state.page = 0;
915
+ render();
916
+ });
917
+ $('tape-reset').addEventListener('click', () => {
918
+ state.period = { key: 'all' };
919
+ render();
920
+ });
921
+ $('projects-more').addEventListener('click', () => {
922
+ state.allProjects = !state.allProjects;
923
+ renderProjects(selectedRecords());
924
+ });
925
+ for (const button of document.querySelectorAll('[data-sort]')) {
926
+ button.addEventListener('click', () => {
927
+ state.sort = button.dataset.sort;
928
+ state.page = 0;
929
+ renderSessions(selectedRecords());
930
+ });
931
+ }
932
+ $('search').addEventListener('input', () => { state.page = 0; renderSessions(selectedRecords()); });
933
+ $('clear-project').addEventListener('click', () => { state.project = null; state.page = 0; renderSessions(selectedRecords()); });
934
+ $('previous').addEventListener('click', () => { state.page--; renderSessions(selectedRecords()); });
935
+ $('next').addEventListener('click', () => { state.page++; renderSessions(selectedRecords()); });
936
+ $('close-dialog').addEventListener('click', () => { finishAnimation(); $('session-dialog').close(); });
937
+ $('session-dialog').addEventListener('cancel', finishAnimation);
938
+ $('session-dialog').addEventListener('click', event => {
939
+ if (event.target === event.currentTarget) { finishAnimation(); event.currentTarget.close(); }
940
+ });
941
+ let resizeTimer;
942
+ addEventListener('resize', () => {
943
+ clearTimeout(resizeTimer);
944
+ resizeTimer = setTimeout(() => {
945
+ if (!state.data || $('dashboard').hidden || currentView() !== 'overview') return;
946
+ renderTape();
947
+ state.tapeFresh = false;
948
+ }, 150);
949
+ });
950
+ addEventListener('hashchange', () => { showView(true); scrollTo(0, 0); });
951
+ showView(false);
952
+ load();