burner-cc 0.1.2 → 0.3.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/src/report.js ADDED
@@ -0,0 +1,873 @@
1
+ // Self-contained HTML report — no external deps, pure canvas charts.
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { themeCss, categoryOverrideCss, categoryColorsForTheme } from './themes.js';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const ASSETS_DIR = path.join(__dirname, '..', 'assets');
10
+
11
+ // Embedded in a <script>; keeps "</script>" in session titles from closing it.
12
+ const safeJson = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
13
+
14
+ function fmtTok(n) {
15
+ if (!n) return '—';
16
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M tok';
17
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K tok';
18
+ return n + ' tok';
19
+ }
20
+ function fmtCost(n) { return (!n) ? '—' : '$' + n.toFixed(4); }
21
+ function shortDate(iso) {
22
+ if (!iso) return 'unknown';
23
+ return new Date(iso).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' }).toUpperCase();
24
+ }
25
+ function pricingLabel(src) {
26
+ return { 'bundled-fallback': 'OFFLINE SNAPSHOT', 'cache': 'CACHED', 'live': 'LIVE', 'stale-cache': 'CACHED (STALE)' }[src] ?? src.toUpperCase();
27
+ }
28
+
29
+ function readLogoBase64(name) {
30
+ try { return readFileSync(path.join(ASSETS_DIR, name)).toString('base64'); } catch {}
31
+ return null;
32
+ }
33
+
34
+ export function renderReport(data, { served = false, theme = null, range = null } = {}) {
35
+ const { sessions, pricingSource, generatedAt, categories } = data;
36
+ const uiTheme = theme ?? 'paper';
37
+ const reportSessions = sessions.map(({ activity, attribution, subagentGroups, subagentRuns, agents, ...s }) => s);
38
+
39
+ const logoUri = (name) => {
40
+ if (served) return `/assets/${name}`;
41
+ const b64 = readLogoBase64(name);
42
+ return b64 ? `data:image/png;base64,${b64}` : '';
43
+ };
44
+ const logoMarkUri = logoUri('logo-mark.png');
45
+ const logoTextUri = logoUri('logo-with-text.png');
46
+
47
+ const totalSessions = sessions.length;
48
+ const totalTokens = sessions.reduce((s, r) => s + r.totalTokens.total, 0);
49
+ const totalCost = sessions.reduce((s, r) => s + r.totalCost.total, 0);
50
+ const uniqueModels = [...new Set(sessions.flatMap(s => s.models.map(m => m.rawModel)))];
51
+ const uniqueProjects = [...new Set(sessions.map(s => s.project))];
52
+ const promptSide = sessions.reduce((a, r) => a + r.totalTokens.input + r.totalTokens.cacheWrite + r.totalTokens.cacheRead, 0);
53
+ const avgCacheHit = promptSide ? Math.round(sessions.reduce((a, r) => a + r.totalTokens.cacheRead, 0) / promptSide * 100) : 0;
54
+ const outputTokens = sessions.reduce((a, r) => a + r.totalTokens.output, 0);
55
+ const untaggedCount = sessions.filter(s => s.category === 'untagged').length;
56
+ const unpricedModels = data.unpricedModels ?? [];
57
+ const pricingLbl = pricingLabel(pricingSource);
58
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
59
+
60
+ const CAT_COLORS = categoryColorsForTheme(uiTheme, categories);
61
+ const catOverrideCss = categoryOverrideCss(categories);
62
+ const CAT_LABELS = Object.fromEntries(categories.map(c => [c.id, c.label]));
63
+
64
+ // ── Inline micro chart library ────────────────────────────────────────────
65
+ const MICRO_CHARTS = String.raw`
66
+ (function(G){
67
+ function px(n){return Math.round(n);}
68
+ function cssVar(name){return getComputedStyle(document.documentElement).getPropertyValue(name).trim();}
69
+ function ink(){return cssVar('--ink')||'#2b2b26';}
70
+ function dim(){return cssVar('--dim')||'#8a8478';}
71
+ function bg(){return cssVar('--bg')||'#f4f1e8';}
72
+ function rule(){return cssVar('--rule')||'#b8b2a2';}
73
+ function hexAlpha(hex,a){
74
+ const h=hex.replace('#','');
75
+ const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);
76
+ return 'rgba('+r+','+g+','+b+','+a+')';
77
+ }
78
+ function inkAlpha(a){
79
+ // parse ink color (could be rgb or hex)
80
+ const c=ink();
81
+ if(c.startsWith('#')) return hexAlpha(c,a);
82
+ return c.replace('rgb(','rgba(').replace(')',','+a+')');
83
+ }
84
+
85
+ // kind: 'usd' -> $1.23, otherwise compact counts / tokens
86
+ function fmtV(v,kind,dp){
87
+ if(kind==='usd') return '$'+(v>=1000?Math.round(v).toLocaleString():v.toFixed(2));
88
+ if(v>=1e6) return (v/1e6).toFixed(dp||1)+'M';
89
+ if(v>=1e3) return (v/1e3).toFixed(dp||1)+'K';
90
+ return String(Math.round(v*10)/10);
91
+ }
92
+
93
+ // Returns hit areas for tooltips
94
+ function doughnut(canvas,labels,values,colors,title){
95
+ const ctx=canvas.getContext('2d');
96
+ const W=canvas.width=canvas.offsetWidth*devicePixelRatio||400;
97
+ const H=canvas.height=canvas.offsetHeight*devicePixelRatio||220;
98
+ ctx.clearRect(0,0,W,H);
99
+ const total=values.reduce((a,b)=>a+b,0);
100
+ const hits=[];
101
+ if(!total) return hits;
102
+ const cx=W*0.38,cy=H/2,r=Math.min(cx,cy)*0.82,ir=r*0.55;
103
+ const gap=values.length>1?Math.min(0.045,0.012*values.length):0;
104
+ let angle=-Math.PI/2;
105
+ values.forEach((v,i)=>{
106
+ const slice=2*Math.PI*v/total;
107
+ const a0=angle+gap/2,a1=angle+slice-gap/2;
108
+ if(a1>a0){
109
+ ctx.beginPath();
110
+ ctx.arc(cx,cy,r,a0,a1);
111
+ ctx.arc(cx,cy,ir,a1,a0,true);
112
+ ctx.closePath();
113
+ ctx.fillStyle=colors[i]||'#888';ctx.fill();
114
+ }
115
+ hits.push({label:labels[i],value:v,pct:Math.round(v/total*100),startAngle:a0,endAngle:a1,cx,cy,r,ir,color:colors[i]});
116
+ angle+=slice;
117
+ });
118
+ ctx.beginPath();ctx.arc(cx,cy,ir,0,2*Math.PI);ctx.fillStyle=bg();ctx.fill();
119
+ ctx.fillStyle=ink();ctx.font='bold '+px(H*0.09)+'px "IBM Plex Mono",monospace';
120
+ ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(total,cx,cy);
121
+ const lx=W*0.7,lw=W*0.28,lh=Math.max(H*0.11,H/(labels.length+0.35));
122
+ labels.forEach((lbl,i)=>{
123
+ const ly=lh*(i+0.85);
124
+ ctx.fillStyle=colors[i]||'#888';ctx.fillRect(px(lx),px(ly-H*0.025),px(H*0.04),px(H*0.04));
125
+ ctx.fillStyle=dim();ctx.font=px(H*0.065)+'px "IBM Plex Mono",monospace';
126
+ ctx.textAlign='left';ctx.textBaseline='middle';
127
+ ctx.fillText(lbl.length>12?lbl.slice(0,11)+'\u2026':lbl,px(lx+H*0.055),ly);
128
+ ctx.fillStyle=ink();ctx.textAlign='right';
129
+ ctx.fillText(Math.round(values[i]/total*100)+'%',px(lx+lw),ly);
130
+ });
131
+ return hits;
132
+ }
133
+
134
+ function hbar(canvas,labels,values,color,title,kind){
135
+ const ctx=canvas.getContext('2d');
136
+ const W=canvas.width=canvas.offsetWidth*devicePixelRatio||400;
137
+ const H=canvas.height=canvas.offsetHeight*devicePixelRatio||220;
138
+ ctx.clearRect(0,0,W,H);
139
+ const hits=[];
140
+ if(!values.length) return hits;
141
+ const max=Math.max(...values)||1;
142
+ // Measure longest label to set labelW dynamically
143
+ const fontSize=Math.max(10,px(H*0.065));
144
+ ctx.font=fontSize+'px "IBM Plex Mono",monospace';
145
+ const maxLabelPx=Math.max(...labels.map(l=>ctx.measureText(l).width));
146
+ const labelW=Math.min(maxLabelPx+W*0.02, W*0.55); // cap at 55% of width
147
+ const maxValPx=Math.max(...values.map(v=>ctx.measureText(fmtV(v,kind)).width));
148
+ const barArea=Math.max(W*0.1,W-labelW-W*0.05-maxValPx),barH=H/(values.length+1)*0.55,gap=H/(values.length+1);
149
+ values.forEach((v,i)=>{
150
+ const y=gap*(i+0.8);
151
+ ctx.fillStyle=dim();ctx.font=fontSize+'px "IBM Plex Mono",monospace';
152
+ ctx.textAlign='right';ctx.textBaseline='middle';
153
+ const lbl=labels[i]||'';
154
+ ctx.fillText(lbl,px(labelW),px(y));
155
+ const bw=barArea*(v/max);
156
+ ctx.fillStyle=color||ink();
157
+ ctx.fillRect(px(labelW+W*0.02),px(y-barH/2),px(bw),px(barH));
158
+ ctx.fillStyle=ink();ctx.textAlign='left';
159
+ const vStr=fmtV(v,kind);
160
+ ctx.fillText(vStr,px(labelW+W*0.02+bw+W*0.01),px(y));
161
+ hits.push({label:labels[i],value:v,x:px(labelW+W*0.02),y:px(y-barH/2),w:px(bw),h:px(barH)});
162
+ });
163
+ return hits;
164
+ }
165
+
166
+ function line(canvas,labels,values,title,kind){
167
+ const ctx=canvas.getContext('2d');
168
+ const W=canvas.width=canvas.offsetWidth*devicePixelRatio||400;
169
+ const H=canvas.height=canvas.offsetHeight*devicePixelRatio||220;
170
+ ctx.clearRect(0,0,W,H);
171
+ const hits=[];
172
+ if(values.length<1) return hits;
173
+ const max=Math.max(...values)||1;
174
+ const padL=W*(kind==='usd'?0.14:0.1),padR=W*0.04,padT=H*0.1,padB=H*0.2;
175
+ const w=W-padL-padR,h=H-padT-padB;
176
+ [0,0.25,0.5,0.75,1].forEach(f=>{
177
+ const y=padT+h*(1-f);
178
+ ctx.strokeStyle=rule();ctx.lineWidth=1;ctx.setLineDash([4,4]);
179
+ ctx.beginPath();ctx.moveTo(padL,y);ctx.lineTo(padL+w,y);ctx.stroke();
180
+ ctx.fillStyle=dim();ctx.font=px(H*0.06)+'px "IBM Plex Mono",monospace';
181
+ ctx.textAlign='right';ctx.textBaseline='middle';
182
+ ctx.fillText(kind==='usd'?fmtV(max*f,'usd'):Math.round(max*f),px(padL-W*0.01),px(y));
183
+ });
184
+ ctx.setLineDash([]);
185
+ const pts=values.map((v,i)=>({x:padL+(values.length>1?w*i/(values.length-1):w/2),y:padT+h*(1-v/max),label:labels[i],value:v}));
186
+ ctx.beginPath();ctx.moveTo(pts[0].x,H-padB);
187
+ pts.forEach(p=>ctx.lineTo(p.x,p.y));
188
+ ctx.lineTo(pts[pts.length-1].x,H-padB);ctx.closePath();
189
+ ctx.fillStyle=inkAlpha(0.1);ctx.fill();
190
+ ctx.beginPath();ctx.moveTo(pts[0].x,pts[0].y);
191
+ pts.forEach(p=>ctx.lineTo(p.x,p.y));
192
+ ctx.strokeStyle=ink();ctx.lineWidth=2;ctx.stroke();
193
+ pts.forEach(p=>{
194
+ ctx.beginPath();ctx.arc(p.x,p.y,3,0,2*Math.PI);ctx.fillStyle=ink();ctx.fill();
195
+ hits.push({label:p.label,value:p.value,x:p.x,y:p.y,r:8});
196
+ });
197
+ const step=Math.max(1,Math.ceil(labels.length/6));
198
+ ctx.fillStyle=dim();ctx.font=px(H*0.06)+'px "IBM Plex Mono",monospace';ctx.textAlign='center';
199
+ labels.forEach((l,i)=>{if(i%step===0)ctx.fillText(l.slice(5),pts[i].x,H-padB+H*0.09);});
200
+ return hits;
201
+ }
202
+
203
+ // Tooltip engine
204
+ const tip=document.createElement('div');
205
+ tip.style.cssText='position:fixed;pointer-events:none;background:var(--ink);color:var(--page-bg);font:11px "IBM Plex Mono",monospace;padding:5px 10px;border-radius:3px;display:none;z-index:200;white-space:nowrap;letter-spacing:.04em;';
206
+ document.body.appendChild(tip);
207
+
208
+ function attachTooltip(canvas,getHits,type,kind){
209
+ canvas.addEventListener('mousemove',e=>{
210
+ const rect=canvas.getBoundingClientRect();
211
+ const mx=(e.clientX-rect.left)*devicePixelRatio;
212
+ const my=(e.clientY-rect.top)*devicePixelRatio;
213
+ const hits=getHits();
214
+ let found=null;
215
+ if(type==='doughnut'){
216
+ for(const h of hits){
217
+ const dx=mx-h.cx,dy=my-h.cy;
218
+ const dist=Math.sqrt(dx*dx+dy*dy);
219
+ if(dist<h.r&&dist>h.ir){
220
+ let a=Math.atan2(dy,dx);
221
+ if(a<-Math.PI/2) a+=2*Math.PI;
222
+ // normalize to start from -PI/2
223
+ let sa=h.startAngle<-Math.PI/2?h.startAngle+2*Math.PI:h.startAngle;
224
+ let ea=h.endAngle<-Math.PI/2?h.endAngle+2*Math.PI:h.endAngle;
225
+ let na=a<-Math.PI/2?a+2*Math.PI:a;
226
+ if(na>=sa&&na<=ea) found=h;
227
+ }
228
+ }
229
+ } else if(type==='hbar'){
230
+ for(const h of hits){
231
+ if(mx>=h.x&&mx<=h.x+h.w+W*0.12&&my>=h.y-4&&my<=h.y+h.h+4) found=h;
232
+ }
233
+ } else if(type==='line'){
234
+ let minD=Infinity;
235
+ for(const h of hits){
236
+ const d=Math.sqrt((mx-h.x)**2+(my-h.y)**2);
237
+ if(d<h.r*3&&d<minD){minD=d;found=h;}
238
+ }
239
+ }
240
+ if(found){
241
+ const vStr=type==='hbar'?fmtV(found.value,kind,2):
242
+ type==='doughnut'?found.value+' ('+found.pct+'%)':
243
+ fmtV(found.value,kind);
244
+ tip.textContent=found.label+': '+vStr+(found.note?' | '+found.note:'');
245
+ tip.style.display='block';
246
+ tip.style.left=(e.clientX+12)+'px';
247
+ tip.style.top=(e.clientY-28)+'px';
248
+ canvas.style.cursor='crosshair';
249
+ } else {
250
+ tip.style.display='none';
251
+ canvas.style.cursor='default';
252
+ }
253
+ });
254
+ canvas.addEventListener('mouseleave',()=>{tip.style.display='none';});
255
+ }
256
+
257
+ // Store chart data for expand
258
+ const CHART_DATA={};
259
+ // opts: { name: column heading, kind: 'usd' | undefined, extra: [{name, values, kind}] }
260
+ function store(id,type,labels,values,colors,color,opts){
261
+ CHART_DATA[id]={type,labels,values,colors,color,opts:opts||{}};
262
+ }
263
+ function notesFor(d){
264
+ const ex=(d.opts&&d.opts.extra)||[];
265
+ return d.labels.map((l,i)=>ex.map(e=>fmtV(e.values[i]||0,e.kind)+' '+e.name.toLowerCase()).join(', '));
266
+ }
267
+ function withNotes(hits,d){const n=notesFor(d);hits.forEach((h,i)=>{h.note=n[i];});return hits;}
268
+
269
+ // Line pagination state
270
+ let _linePage=0, _linePageSize=92, _lineId=null;
271
+
272
+ function renderExpandLine(canvas,d,page){
273
+ const labels=d.labels,values=d.values;
274
+ const total=labels.length;
275
+ const pages=Math.ceil(total/_linePageSize);
276
+ const start=page*_linePageSize, end=Math.min(start+_linePageSize,total);
277
+ const sliceL=labels.slice(start,end), sliceV=values.slice(start,end);
278
+ const hits=line(canvas,sliceL,sliceV,null,d.opts.kind);
279
+ const notes=notesFor(d).slice(start,end);hits.forEach((h,i)=>{h.note=notes[i];});
280
+ attachTooltip(canvas,()=>hits,'line',d.opts.kind);
281
+
282
+ // Pagination controls
283
+ const pg=document.getElementById('expandPager');
284
+ if(pages<=1){pg.innerHTML='';return;}
285
+ let h='<span style="color:var(--dim);font-size:10px">'+
286
+ sliceL[0].slice(0,10)+' → '+sliceL[sliceL.length-1].slice(0,10)+
287
+ ' &nbsp;('+start+'–'+(end-1)+' of '+total+' days)</span> ';
288
+ if(page>0) h+='<button onclick="MicroCharts.linePageGo('+(_linePage-1)+')">‹ PREV</button> ';
289
+ h+='<span style="font-size:10px;color:var(--dim)">'+( page+1)+'/'+pages+'</span> ';
290
+ if(page<pages-1) h+='<button onclick="MicroCharts.linePageGo('+(_linePage+1)+')">NEXT ›</button>';
291
+ pg.innerHTML=h;
292
+ }
293
+
294
+ function renderExpandTable(d,page,pageSize){
295
+ const tbody=document.getElementById('expandTable');
296
+ const o=d.opts||{}, ex=o.extra||[];
297
+ const total=d.values.reduce((a,b)=>a+b,0)||1;
298
+ const isLine=d.type==='line';
299
+ const from=isLine?page*pageSize:0, to=isLine?Math.min((page+1)*pageSize,d.labels.length):d.labels.length;
300
+ const r='style="text-align:right"';
301
+ document.getElementById('expandHead').innerHTML='<tr><th>'+(o.labelName||'LABEL')+'</th><th '+r+'>'+(o.name||'COUNT')+'</th>'+ex.map(e=>'<th '+r+'>'+e.name+'</th>').join('')+'<th '+r+'>%</th></tr>';
302
+ let rows='';
303
+ for(let i=from;i<to;i++){
304
+ const v=d.values[i];
305
+ const dot=d.colors?'<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:'+(d.colors[i]||'#888')+';margin-right:6px"></span>':'';
306
+ rows+='<tr><td>'+dot+d.labels[i]+'</td><td '+r+'>'+fmtV(v,o.kind,2)+'</td>'+ex.map(e=>'<td '+r+'>'+fmtV(e.values[i]||0,e.kind,2)+'</td>').join('')+'<td style="text-align:right;color:var(--dim)">'+Math.round(v/total*100)+'%</td></tr>';
307
+ }
308
+ tbody.innerHTML=rows;
309
+ }
310
+
311
+ function emptyChart(canvas,msg){
312
+ const ctx=canvas.getContext('2d');
313
+ const W=canvas.width=canvas.offsetWidth*devicePixelRatio||400;
314
+ const H=canvas.height=canvas.offsetHeight*devicePixelRatio||220;
315
+ ctx.clearRect(0,0,W,H);
316
+ ctx.fillStyle=dim();ctx.font=px(Math.max(11,H*0.06))+'px "IBM Plex Mono",monospace';
317
+ ctx.textAlign='center';ctx.textBaseline='middle';
318
+ ctx.fillText(msg,W/2,H/2);
319
+ }
320
+
321
+ G.MicroCharts={
322
+ empty(canvas,msg){
323
+ store(canvas.id,'hbar',[],[],null,null,{});
324
+ emptyChart(canvas,msg);
325
+ },
326
+ doughnut(canvas,labels,values,colors,opts){
327
+ store(canvas.id,'doughnut',labels,values,colors,null,opts);
328
+ const hits=withNotes(doughnut(canvas,labels,values,colors),CHART_DATA[canvas.id]);
329
+ attachTooltip(canvas,()=>hits,'doughnut');
330
+ return hits;
331
+ },
332
+ hbar(canvas,labels,values,color,opts){
333
+ store(canvas.id,'hbar',labels,values,null,color,opts);
334
+ const d=CHART_DATA[canvas.id];
335
+ const hits=withNotes(hbar(canvas,labels,values,color,null,d.opts.kind),d);
336
+ attachTooltip(canvas,()=>hits,'hbar',d.opts.kind);
337
+ return hits;
338
+ },
339
+ line(canvas,labels,values,opts){
340
+ store(canvas.id,'line',labels,values,null,null,opts);
341
+ const hits=withNotes(line(canvas,labels,values,null,CHART_DATA[canvas.id].opts.kind),CHART_DATA[canvas.id]);
342
+ attachTooltip(canvas,()=>hits,'line',CHART_DATA[canvas.id].opts.kind);
343
+ return hits;
344
+ },
345
+ linePageGo(page){
346
+ _linePage=page;
347
+ const canvas=document.getElementById('expandCanvas');
348
+ const d=CHART_DATA[_lineId];
349
+ renderExpandLine(canvas,d,page);
350
+ renderExpandTable(d,page,_linePageSize);
351
+ },
352
+ expandModal(id,title){
353
+ const d=CHART_DATA[id];if(!d)return;
354
+ const canvas=document.getElementById('expandCanvas');
355
+ document.getElementById('expandTitle').textContent=title.toUpperCase();
356
+ document.getElementById('expandPager').innerHTML='';
357
+
358
+ if(d.type==='line'){
359
+ _lineId=id; _linePage=Math.max(0,Math.ceil(d.labels.length/_linePageSize)-1); // start at most recent
360
+ requestAnimationFrame(()=>{
361
+ renderExpandLine(canvas,d,_linePage);
362
+ renderExpandTable(d,_linePage,_linePageSize);
363
+ });
364
+ } else {
365
+ _lineId=null;
366
+ requestAnimationFrame(()=>{
367
+ let hits=[];
368
+ if(d.type==='doughnut'){hits=withNotes(doughnut(canvas,d.labels,d.values,d.colors),d);attachTooltip(canvas,()=>hits,'doughnut');}
369
+ else if(d.type==='hbar'){hits=withNotes(hbar(canvas,d.labels,d.values,d.color||ink(),null,d.opts.kind),d);attachTooltip(canvas,()=>hits,'hbar',d.opts.kind);}
370
+ renderExpandTable(d,0,d.labels.length);
371
+ });
372
+ }
373
+ document.getElementById('expandOverlay').classList.add('show');
374
+ }
375
+ };
376
+ })(window);
377
+ `;
378
+
379
+ const html = `<!doctype html>
380
+ <html lang="en" data-theme="paper">
381
+ <head>
382
+ <meta charset="utf-8" />
383
+ <title>Burner</title>
384
+ <script>(function(){var t=${served ? safeJson(theme) : 'null'};try{t=t||localStorage.getItem('burner-theme');}catch(e){}if(['paper','thermal','matrix','dark'].indexOf(t)<0)t='paper';document.documentElement.setAttribute('data-theme',t);})();</script>
385
+ ${logoMarkUri ? `<link rel="icon" type="image/png" href="${logoMarkUri}" />` : ''}
386
+ <style>
387
+ ${themeCss()}
388
+ ${catOverrideCss}
389
+ *{box-sizing:border-box;}
390
+ body{margin:0;font-family:'IBM Plex Mono','Courier New',monospace;background:var(--page-bg);color:var(--ink);padding:24px 16px 60px;transition:background .15s,color .15s;}
391
+ .page-brand{position:fixed;top:20px;left:24px;height:56px;width:auto;z-index:6;}
392
+ .toolbar{max-width:860px;margin:0 auto 20px;display:flex;justify-content:flex-end;align-items:center;flex-wrap:wrap;padding-left:120px;gap:0;}
393
+ .toolbar-group{display:flex;flex-wrap:wrap;align-items:center;gap:12px;}
394
+ .toolbar-group+.toolbar-group{margin-left:22px;padding-left:22px;border-left:1px dashed var(--rule);}
395
+ .toolbar a.toolbar-link{display:inline-flex;text-decoration:none;}
396
+ button{font-family:inherit;font-size:11px;letter-spacing:.05em;border:1px solid var(--rule);background:transparent;color:var(--ink);padding:5px 11px;cursor:pointer;border-radius:3px;text-transform:uppercase;}
397
+ .toolbar button{padding:6px 14px;}
398
+ button.active,button:hover{background:var(--ink);color:var(--page-bg);}
399
+ input,select{font-family:inherit;font-size:11px;background:var(--bg);border:1px solid var(--rule);color:var(--ink);padding:5px 10px;border-radius:3px;}
400
+ .page{max-width:860px;margin:0 auto;}
401
+ .rule{border:none;border-top:1px dashed var(--rule);margin:12px 0;}
402
+ .solid{border:none;border-top:1px solid var(--rule);margin:12px 0;}
403
+ .card{background:var(--bg);padding:22px 26px 24px;box-shadow:0 6px 18px var(--shadow-lg);margin-bottom:18px;}
404
+ .card h2{text-align:center;font-size:17px;letter-spacing:.18em;margin-bottom:2px;}
405
+ .subtitle{text-align:center;font-size:11px;letter-spacing:.1em;font-weight:bold;}
406
+ .tagline{text-align:center;font-size:10px;color:var(--dim);font-style:italic;margin-bottom:10px;}
407
+ .meta-row{display:flex;justify-content:space-between;font-size:11px;line-height:1.7;}
408
+ .line{display:flex;justify-content:space-between;align-items:baseline;font-size:12px;line-height:1.8;gap:8px;}
409
+ .line .lbl{flex:1;}.line .lbl.i{padding-left:14px;color:var(--dim);font-size:11px;}
410
+ .line .val{font-weight:bold;text-align:right;}.line .dv{color:var(--dim);font-size:11px;text-align:right;}
411
+ .line.total .val{font-size:14px;}
412
+ .time-bar{background:var(--bg);padding:12px 18px;box-shadow:0 3px 10px var(--shadow);margin-bottom:18px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
413
+ .time-bar span{font-size:10px;color:var(--dim);letter-spacing:.06em;}
414
+ .custom-range{display:none;gap:8px;align-items:center;font-size:11px;}
415
+ .custom-range.show{display:flex;}
416
+ .charts-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:18px;}
417
+ @media(max-width:700px){.charts-grid{grid-template-columns:1fr;}}
418
+ .chart-card{background:var(--bg);padding:16px 18px 18px;box-shadow:0 3px 10px var(--shadow);position:relative;}
419
+ .chart-card-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;}
420
+ .chart-card-hdr h3{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);font-weight:bold;margin:0;}
421
+ .expand-btn{background:none;border:1px solid var(--rule);color:var(--dim);padding:2px 7px;font-size:10px;cursor:pointer;border-radius:2px;letter-spacing:.04em;}
422
+ .expand-btn:hover{background:var(--ink);color:var(--page-bg);border-color:var(--ink);}
423
+ .chart-wrap{position:relative;height:180px;}
424
+ .chart-wrap canvas{width:100%!important;height:100%!important;}
425
+ .sessions-card{background:var(--bg);padding:18px 22px 22px;box-shadow:0 3px 10px var(--shadow);margin-bottom:18px;}
426
+ .sessions-card h3{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);font-weight:bold;margin-bottom:4px;}
427
+ .filter-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:10px 0 12px;}
428
+ .filter-row label{font-size:10px;color:var(--dim);}
429
+ .srow{display:flex;justify-content:space-between;align-items:center;font-size:11px;line-height:1;padding:9px 0;border-bottom:1px dashed var(--rule);cursor:pointer;gap:6px;text-decoration:none;color:inherit;}
430
+ .srow:hover{opacity:.7;background:rgba(0,0,0,.03);}
431
+ .srow .sd{color:var(--dim);min-width:76px;font-size:10px;flex-shrink:0;}
432
+ .srow .st{flex:2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
433
+ .srow .sp{color:var(--dim);font-size:10px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
434
+ .srow .sc{min-width:88px;text-align:right;flex-shrink:0;}
435
+ .srow .stok{min-width:64px;text-align:right;color:var(--dim);font-size:10px;flex-shrink:0;}
436
+ .srow .scost{min-width:58px;text-align:right;font-weight:bold;flex-shrink:0;}
437
+ .dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;}
438
+ .pager{display:flex;justify-content:center;gap:8px;margin-top:12px;font-size:11px;align-items:center;}
439
+ .pg-info{color:var(--dim);}
440
+ /* (session modal removed — row click opens receipt directly) */
441
+ /* Expand chart modal */
442
+ .expand-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:150;justify-content:center;align-items:center;padding:24px;}
443
+ .expand-overlay.show{display:flex;}
444
+ .expand-modal{background:var(--bg);width:min(860px,95vw);padding:22px 24px 24px;box-shadow:0 10px 40px rgba(0,0,0,.5);position:relative;max-height:90vh;overflow-y:auto;}
445
+ .expand-modal-x{position:absolute;top:12px;right:14px;background:none;border:none;font-size:18px;cursor:pointer;color:var(--dim);padding:0;}
446
+ #expandCanvas{width:100%!important;height:320px;}
447
+ .exp-table{width:100%;border-collapse:collapse;font-size:11px;margin-top:14px;}
448
+ .exp-table th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--rule);color:var(--dim);font-weight:bold;letter-spacing:.05em;}
449
+ .exp-table td{padding:6px 8px;border-bottom:1px dashed var(--rule);}
450
+ /* Docs modal */
451
+ .docs-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:160;justify-content:center;align-items:center;padding:24px;}
452
+ .docs-overlay.show{display:flex;}
453
+ .docs-modal{background:var(--bg);width:min(700px,95vw);padding:26px 28px;box-shadow:0 10px 40px rgba(0,0,0,.5);position:relative;max-height:90vh;overflow-y:auto;}
454
+ .docs-modal-x{position:absolute;top:12px;right:14px;background:none;border:none;font-size:18px;cursor:pointer;color:var(--dim);padding:0;}
455
+ .docs-modal h2{font-size:15px;letter-spacing:.15em;margin-bottom:4px;text-align:center;}
456
+ .docs-modal h3{font-size:11px;letter-spacing:.1em;margin:14px 0 6px;text-transform:uppercase;color:var(--dim);}
457
+ .docs-modal p,.docs-modal li{font-size:11px;line-height:1.7;color:var(--ink);}
458
+ .docs-modal code{background:var(--page-bg);padding:1px 5px;border-radius:2px;font-size:10px;}
459
+ .docs-modal ul{padding-left:18px;}
460
+ /* Footer */
461
+ .footer-bar{max-width:860px;margin:18px auto 0;display:flex;justify-content:center;align-items:center;gap:14px;font-size:10px;color:var(--dim);flex-wrap:wrap;}
462
+ .footer-bar a{color:var(--dim);text-decoration:none;border-bottom:1px dashed var(--rule);}
463
+ .footer-bar a:hover{color:var(--ink);}
464
+ .footer-sep{color:var(--rule);}
465
+ /* Misc */
466
+ .toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--page-bg);padding:7px 14px;border-radius:3px;font-size:11px;letter-spacing:.05em;opacity:0;transition:opacity .2s;pointer-events:none;z-index:300;}
467
+ .toast.show{opacity:1;}
468
+ </style>
469
+ </head>
470
+ <body>
471
+ ${logoTextUri ? `<img class="page-brand" src="${logoTextUri}" alt="Burner" />` : ''}
472
+ <div class="toolbar">
473
+ <div class="toolbar-group">
474
+ <button data-theme="paper">Paper</button>
475
+ <button data-theme="thermal">Thermal</button>
476
+ <button data-theme="matrix">Matrix</button>
477
+ <button data-theme="dark">Dark</button>
478
+ </div>
479
+ <div class="toolbar-group">
480
+ <button id="exportBtn">Export .txt</button>
481
+ ${served ? '<a class="toolbar-link" href="/settings"><button type="button">Settings</button></a>' : ''}
482
+ </div>
483
+ </div>
484
+ <div class="page">
485
+
486
+ <!-- Summary receipt -->
487
+ <div class="card">
488
+ <h2>CLAUDE CODE</h2>
489
+ <div class="subtitle">USAGE ANALYTICS REPORT</div>
490
+ <div class="tagline">-- every token, every session, itemized --</div>
491
+ <hr class="rule" />
492
+ <div class="meta-row">
493
+ <div>GENERATED: ${shortDate(generatedAt)}<br/>PRICING: ${pricingLbl}<br/>UNTAGGED: ${untaggedCount}</div>
494
+ <div style="text-align:right">SESSIONS: ${totalSessions}<br/>PROJECTS: ${uniqueProjects.length}<br/>MODELS: ${uniqueModels.length}</div>
495
+ </div>
496
+ <hr class="rule" />
497
+ <div id="sumBreakdown"></div>
498
+ <div class="line"><span class="lbl i">PRICING${unpricedModels.length ? ' (' + unpricedModels.length + ' MODEL(S) AT $0)' : ''}</span><span class="val">${pricingLbl}</span></div>
499
+ <hr class="rule" />
500
+ <div class="line total"><span class="lbl">TOTAL SESSIONS</span><span class="val">${totalSessions}</span></div>
501
+ <hr class="rule" />
502
+ ${logoMarkUri ? `<img style="display:block;height:52px;width:auto;margin:14px auto 4px" src="${logoMarkUri}" alt="Burner" />` : ''}
503
+ <div class="fn" style="letter-spacing:.15em;text-align:center;width:100%">~ B U R N E R ~</div>
504
+ </div>
505
+
506
+ <!-- Time filter -->
507
+ <div class="time-bar">
508
+ <span>RANGE:</span>
509
+ <button class="tbtn active" data-r="all">ALL TIME</button>
510
+ <button class="tbtn" data-r="7">7 DAYS</button>
511
+ <button class="tbtn" data-r="30">30 DAYS</button>
512
+ <button class="tbtn" data-r="90">3 MONTHS</button>
513
+ <button class="tbtn" data-r="custom">CUSTOM</button>
514
+ <div class="custom-range" id="customRange">
515
+ FROM <input type="date" id="fromDate" /> TO <input type="date" id="toDate" />
516
+ <button id="applyCustom">APPLY</button>
517
+ </div>
518
+ </div>
519
+
520
+ <!-- Charts -->
521
+ <div class="charts-grid">
522
+ <div class="chart-card">
523
+ <div class="chart-card-hdr"><h3>Sessions by Category</h3><button class="expand-btn" data-id="catChart" data-title="Sessions by Category">⤢ EXPAND</button></div>
524
+ <div class="chart-wrap"><canvas id="catChart"></canvas></div>
525
+ </div>
526
+ <div class="chart-card">
527
+ <div class="chart-card-hdr"><h3>Tokens by Model</h3><button class="expand-btn" data-id="modelChart" data-title="Tokens by Model">⤢ EXPAND</button></div>
528
+ <div class="chart-wrap"><canvas id="modelChart"></canvas></div>
529
+ </div>
530
+ <div class="chart-card">
531
+ <div class="chart-card-hdr"><h3>Cost by Project</h3><button class="expand-btn" data-id="projChart" data-title="Cost by Project">⤢ EXPAND</button></div>
532
+ <div class="chart-wrap"><canvas id="projChart"></canvas></div>
533
+ </div>
534
+ <div class="chart-card">
535
+ <div class="chart-card-hdr"><h3>Cost Over Time</h3><button class="expand-btn" data-id="timeChart" data-title="Cost Over Time">⤢ EXPAND</button></div>
536
+ <div class="chart-wrap"><canvas id="timeChart"></canvas></div>
537
+ </div>
538
+ </div>
539
+
540
+ <!-- Sessions -->
541
+ <div class="sessions-card">
542
+ <h3>ALL SESSIONS</h3>
543
+ <div class="filter-row">
544
+ <label>SEARCH <input type="text" id="search" placeholder="title / project…" style="width:170px" /></label>
545
+ <label>CATEGORY <select id="catFilter"><option value="">ALL</option>${categories.map(c => `<option value="${esc(c.id)}">${esc(c.label)}</option>`).join('')}</select></label>
546
+ <label>SORT <select id="sortBy"><option value="date">DATE</option><option value="tokens">TOKENS</option><option value="cost">COST</option><option value="duration">DURATION</option></select></label>
547
+ </div>
548
+ <hr class="solid" />
549
+ <div id="sessionList"></div>
550
+ <div class="pager" id="pager"></div>
551
+ </div>
552
+
553
+ </div><!-- .page -->
554
+
555
+ <!-- Footer -->
556
+ <div class="footer-bar">
557
+ <span>not official billing</span><span class="footer-sep">·</span>
558
+ <span>unpriced models count as $0</span><span class="footer-sep">·</span>
559
+ ${served ? '<a href="/settings#pricing">set prices</a><span class="footer-sep">·</span><a href="/settings#tagging">tagging</a><span class="footer-sep">·</span>' : ''}
560
+ <a href="#" id="docsLink">how to use ↗</a>
561
+ </div>
562
+
563
+ <div class="toast" id="toast"></div>
564
+
565
+ <!-- Chart expand modal -->
566
+ <div class="expand-overlay" id="expandOverlay">
567
+ <div class="expand-modal">
568
+ <button class="expand-modal-x" id="expandClose">✕</button>
569
+ <div style="font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);font-weight:bold;margin-bottom:12px" id="expandTitle"></div>
570
+ <canvas id="expandCanvas" style="height:300px"></canvas>
571
+ <div id="expandPager" style="display:flex;justify-content:center;gap:8px;align-items:center;margin:10px 0;font-size:11px;flex-wrap:wrap;"></div>
572
+ <table class="exp-table">
573
+ <thead id="expandHead"></thead>
574
+ <tbody id="expandTable"></tbody>
575
+ </table>
576
+ </div>
577
+ </div>
578
+
579
+ <!-- Docs modal -->
580
+ <div class="docs-overlay" id="docsOverlay">
581
+ <div class="docs-modal">
582
+ <button class="docs-modal-x" id="docsClose">✕</button>
583
+ <h2>HOW TO USE</h2>
584
+ <hr class="rule" />
585
+ <h3>Commands</h3>
586
+ <ul>
587
+ <li><code>burner</code> — open the web app (report + Settings) on 127.0.0.1:4777</li>
588
+ <li><code>burner report</code> — write this report as a static file</li>
589
+ <li><code>burner list</code> — terminal table</li>
590
+ <li><code>burner tag &lt;category&gt;</code> — tag the current session (what <code>/burner-tag</code> runs)</li>
591
+ <li><code>burner install</code> — install <code>/burner-tag</code> globally and apply the tagging level</li>
592
+ <li><code>burner pricing refresh</code> — fetch fresh public prices</li>
593
+ <li><code>--project</code>, <code>--from</code>, <code>--to</code>, <code>--json</code> filters</li>
594
+ </ul>
595
+ <hr class="rule" />
596
+ <h3>Tokens</h3>
597
+ <p>Claude Code logs one API response as several transcript lines (one per streamed content block) with growing <code>output_tokens</code>. Burner keeps only the final line per request ID, and drops requests a continued session re-logs from its parent. Totals include all four billed buckets: fresh input, cache write, cache read, output. Subagent transcripts are billed to their parent session.</p>
598
+ <h3>Categories</h3>
599
+ <p>A session's category comes from, in order: a tag (<code>/burner-tag</code>, the report UI, session-end model hook, or CLI), its project's default category, then the <strong>learned model</strong> if trained and confident (≥60%). Otherwise it stays <em>untagged</em>.</p>
600
+ <p><strong>Learned model</strong> — optional on-device classifier (multinomial naive Bayes) trained on your tagged sessions; guesses only when nothing else applied. <strong>/burner-tag skill</strong> — global skill under <code>~/.claude/skills/burner-tag</code>; run <code>burner install</code> (Settings → Tagging has copy blocks). <strong>Session end</strong> — with a model id on Settings, a SessionEnd hook asks that Anthropic model what the session was for; without a model id, the main agent tags via the skill before wrapping up.</p>
601
+ <p>Token totals follow the same idea as <a href="https://ccusage.com/guide/cost-modes" target="_blank" rel="noopener">ccusage</a>: one row per <code>requestId</code> (last streamed chunk), 5m/1h cache write split when present, global dedup for continued sessions. Burner always calculates from tokens (like ccusage <code>--mode calculate</code>); Claude Code's <code>cost-state</code> line is shown on the receipt for comparison when available.</p>
602
+ <h3>Projects</h3>
603
+ <p>A project groups one or more directories. Every session is matched by the directory Claude Code ran in (longest match wins). Directories not in any project show up under their folder name.</p>
604
+ <h3>Pricing</h3>
605
+ <p>Claude models are priced from LiteLLM's public price list (refreshed daily, bundled snapshot when offline). Any other model — gateways, on-prem, local — is $0 until you set a price on Settings → Pricing.</p>
606
+ <hr class="rule" />
607
+ <div class="fn">files live in ~/.burner · not official billing</div>
608
+ </div>
609
+ </div>
610
+
611
+
612
+ <script>${MICRO_CHARTS}</script>
613
+ <script>
614
+ let ALL = ${safeJson(reportSessions)};
615
+ const CC = ${safeJson(CAT_COLORS)};
616
+ const CL = ${safeJson(CAT_LABELS)};
617
+ const SERVED = ${served};
618
+ const RANGE0 = ${served ? safeJson(range) : 'null'};
619
+ const PS = 20;
620
+
621
+ let filtered=[], curPage=1, activeRange='all', customFrom=null, customTo=null;
622
+
623
+ function fT(n){if(!n)return'0';if(n>=1e6)return(n/1e6).toFixed(2)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'K';return String(n);}
624
+ function fC(n){return(!n)?'$0':'$'+n.toFixed(n<0.01?4:2);}
625
+ function toast(m){const t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1800);}
626
+ function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
627
+ function catOf(s){return s.category;}
628
+ function catLabel(id){return CL[id]||id;}
629
+ function receiptUrl(id){return SERVED?'/receipt/'+id:'receipt-'+id.slice(0,8)+'.html';}
630
+
631
+ function inRange(s){
632
+ if(activeRange==='all') return true;
633
+ if(activeRange==='custom') return (!customFrom||s.mtime>=customFrom)&&(!customTo||s.mtime<=customTo+86400000);
634
+ return s.mtime>=Date.now()-Number(activeRange)*86400000;
635
+ }
636
+
637
+ document.querySelectorAll('.tbtn').forEach(b=>b.addEventListener('click',()=>{
638
+ document.querySelectorAll('.tbtn').forEach(x=>x.classList.remove('active'));
639
+ b.classList.add('active'); activeRange=b.dataset.r; saveRange();
640
+ document.getElementById('customRange').classList.toggle('show',activeRange==='custom');
641
+ rebuild();
642
+ }));
643
+ document.getElementById('applyCustom').addEventListener('click',()=>{
644
+ const f=document.getElementById('fromDate').value,t=document.getElementById('toDate').value;
645
+ customFrom=f?new Date(f).getTime():null; customTo=t?new Date(t).getTime():null; saveRange(); rebuild();
646
+ });
647
+
648
+ // The chosen range is remembered (server config when served, else this browser).
649
+ function saveRange(){
650
+ const body={range:activeRange,from:document.getElementById('fromDate').value||'',to:document.getElementById('toDate').value||''};
651
+ try{localStorage.setItem('burner-range',JSON.stringify(body));}catch(e){}
652
+ if(SERVED)fetch('/api/range',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).catch(()=>{});
653
+ }
654
+ function restoreRange(){
655
+ let r=RANGE0;
656
+ if(!r){try{r=JSON.parse(localStorage.getItem('burner-range')||'null');}catch(e){}}
657
+ if(!r||!['all','7','30','90','custom'].includes(String(r.range)))return;
658
+ activeRange=String(r.range);
659
+ document.querySelectorAll('.tbtn').forEach(x=>x.classList.toggle('active',x.dataset.r===activeRange));
660
+ if(r.from)document.getElementById('fromDate').value=r.from;
661
+ if(r.to)document.getElementById('toDate').value=r.to;
662
+ if(activeRange==='custom'){
663
+ customFrom=r.from?new Date(r.from).getTime():null; customTo=r.to?new Date(r.to).getTime():null;
664
+ document.getElementById('customRange').classList.add('show');
665
+ }
666
+ }
667
+
668
+ function getInk(){return getComputedStyle(document.documentElement).getPropertyValue('--ink').trim()||'#2b2b26';}
669
+ function cssVar(n){return getComputedStyle(document.documentElement).getPropertyValue(n).trim();}
670
+ function catCol(id){return cssVar('--cat-'+id)||CC[id]||'#888';}
671
+
672
+ function drawCharts(sessions){
673
+ cancelAnimationFrame(window._rafId);
674
+ window._rafId=requestAnimationFrame(()=>{
675
+ const inkColor=getInk();
676
+ if(!sessions.length){
677
+ ['catChart','modelChart','projChart','timeChart'].forEach(id=>MicroCharts.empty(document.getElementById(id),'no sessions in this range'));
678
+ return;
679
+ }
680
+
681
+ const cost=s=>(s.totalCost&&s.totalCost.total)||0;
682
+
683
+ // Split sessions contribute to each category by their share of cost.
684
+ const byCat={}, byCatCost={};
685
+ sessions.forEach(s=>{
686
+ if(s.categorySplit&&s.categorySplit.length>1){
687
+ const tot=s.categorySplit.reduce((a,x)=>a+(x.cost||x.tokens),0)||1;
688
+ s.categorySplit.forEach(x=>{byCat[x.category]=(byCat[x.category]||0)+(x.cost||x.tokens)/tot;byCatCost[x.category]=(byCatCost[x.category]||0)+(x.cost||0);});
689
+ } else {const c=catOf(s);byCat[c]=(byCat[c]||0)+1;byCatCost[c]=(byCatCost[c]||0)+cost(s);}
690
+ });
691
+ const catE=Object.entries(byCat).map(([k,v])=>[k,Math.round(v*10)/10]).sort((a,b)=>b[1]-a[1]);
692
+ MicroCharts.doughnut(document.getElementById('catChart'),catE.map(([k])=>catLabel(k)),catE.map(([,v])=>v),catE.map(([k])=>catCol(k)),
693
+ {name:'SESSIONS',extra:[{name:'COST',kind:'usd',values:catE.map(([k])=>byCatCost[k]||0)}]});
694
+
695
+ const byM={}, byMCost={};
696
+ sessions.forEach(s=>s.models.forEach(m=>{const t=m.tokens;byM[m.rawModel]=(byM[m.rawModel]||0)+t.input+t.output+t.cacheWrite5m+t.cacheWrite1h+t.cacheRead;byMCost[m.rawModel]=(byMCost[m.rawModel]||0)+((m.cost&&m.cost.total)||0);}));
697
+ const mE=Object.entries(byM).sort((a,b)=>b[1]-a[1]).slice(0,7);
698
+ MicroCharts.hbar(document.getElementById('modelChart'),mE.map(([k])=>k),mE.map(([,v])=>v),cssVar('--chart-bar-alt')||inkColor,
699
+ {name:'TOKENS',extra:[{name:'COST',kind:'usd',values:mE.map(([k])=>byMCost[k]||0)}]});
700
+
701
+ // Projects are ranked by what they cost, not by how many sessions they had.
702
+ const byP={}, byPN={};
703
+ sessions.forEach(s=>{byP[s.project]=(byP[s.project]||0)+cost(s);byPN[s.project]=(byPN[s.project]||0)+1;});
704
+ const pE=Object.entries(byP).sort((a,b)=>b[1]-a[1]);
705
+ MicroCharts.hbar(document.getElementById('projChart'),pE.map(([k])=>k),pE.map(([,v])=>v),cssVar('--chart-bar')||inkColor,
706
+ {name:'COST',kind:'usd',extra:[{name:'SESSIONS',values:pE.map(([k])=>byPN[k])}]});
707
+
708
+ // Spend per day from each session's own turns, so a long session lands
709
+ // on the days it actually ran, and only days inside the chosen range count.
710
+ const DAY=86400000;
711
+ const dayStr=t=>new Date(t).toISOString().slice(0,10);
712
+ let lo=null, hi=Date.now();
713
+ if(activeRange==='custom'){ lo=customFrom; if(customTo) hi=customTo; }
714
+ else if(activeRange!=='all'){ lo=Date.now()-Number(activeRange)*DAY; }
715
+ const loS=lo==null?null:dayStr(lo), hiS=dayStr(hi);
716
+ const byD={}, byDCost={}, byDS={};
717
+ sessions.forEach(s=>(s.daily||[]).forEach(d=>{
718
+ if((loS&&d.date<loS)||d.date>hiS) return;
719
+ byDCost[d.date]=(byDCost[d.date]||0)+d.cost;
720
+ byDS[d.date]=(byDS[d.date]||0)+1;
721
+ }));
722
+ let dates=Object.keys(byDCost).sort();
723
+ // Bounded ranges get every day (zeros included) so the shape is honest.
724
+ if(loS&&(hi-lo)/DAY<=120){
725
+ dates=[];
726
+ for(let t=Date.parse(loS+'T00:00:00Z');dayStr(t)<=hiS;t+=DAY) dates.push(dayStr(t));
727
+ }
728
+ MicroCharts.line(document.getElementById('timeChart'),dates,dates.map(d=>byDCost[d]||0),
729
+ {name:'COST',kind:'usd',labelName:'DATE',extra:[{name:'SESSIONS',values:dates.map(d=>byDS[d]||0)}]});
730
+ });
731
+ }
732
+
733
+ document.querySelectorAll('.expand-btn').forEach(b=>{
734
+ b.addEventListener('click',()=>MicroCharts.expandModal(b.dataset.id, b.dataset.title));
735
+ });
736
+ document.getElementById('expandClose').addEventListener('click',()=>document.getElementById('expandOverlay').classList.remove('show'));
737
+ document.getElementById('expandOverlay').addEventListener('click',e=>{if(e.target.id==='expandOverlay')document.getElementById('expandOverlay').classList.remove('show');});
738
+
739
+ function applyFilters(sessions){
740
+ const q=document.getElementById('search').value.toLowerCase();
741
+ const cat=document.getElementById('catFilter').value;
742
+ const sort=document.getElementById('sortBy').value;
743
+ let rows=sessions.map(s=>({...s,_cat:catOf(s)}));
744
+ if(q) rows=rows.filter(s=>(s.title+' '+s.project+' '+catLabel(s._cat)).toLowerCase().includes(q));
745
+ if(cat) rows=rows.filter(s=>s._cat===cat||(s.categorySplit||[]).some(x=>x.category===cat));
746
+ rows.sort((a,b)=>{
747
+ if(sort==='tokens') return b.totalTokens.total-a.totalTokens.total;
748
+ if(sort==='cost') return b.totalCost.total-a.totalCost.total;
749
+ if(sort==='duration') return (b.durationMin||0)-(a.durationMin||0);
750
+ return b.mtime-a.mtime;
751
+ });
752
+ return rows;
753
+ }
754
+
755
+ function renderList(sessions){filtered=applyFilters(sessions);curPage=1;renderPage();}
756
+
757
+ function renderPage(){
758
+ const total=filtered.length,pages=Math.ceil(total/PS);
759
+ const sl=filtered.slice((curPage-1)*PS,curPage*PS);
760
+ document.getElementById('sessionList').innerHTML=sl.map(s=>{
761
+ const cat=catOf(s),col=catCol(cat);
762
+ const hint=s.categorySource==='learned'?'?':(s.categorySplit&&s.categorySplit.length>1?'+':'');
763
+ return '<div class="srow" data-id="'+esc(s.sessionId)+'">'
764
+ +'<span class="sd">'+esc(s.date)+'</span>'
765
+ +'<span class="st" title="'+esc(s.title)+'">'+esc(s.title)+'</span>'
766
+ +'<span class="sp" title="'+esc(s.cwd||s.project)+'">'+esc(s.project)+'</span>'
767
+ +'<span class="sc" title="'+esc(s.categorySource)+'"><span class="dot" style="background:'+col+'"></span>'+esc(catLabel(cat))+hint+'</span>'
768
+ +'<span class="stok">'+fT(s.totalTokens.total)+'</span>'
769
+ +'<span class="scost">'+fC(s.totalCost.total)+(s.hasUnpriced?'*':'')+'</span>'
770
+ +'</div>';
771
+ }).join('')||'<div style="font-size:11px;color:var(--dim);padding:10px 0">no sessions match</div>';
772
+
773
+ const pg=document.getElementById('pager');
774
+ document.querySelectorAll('.srow').forEach(r=>r.addEventListener('click',()=>{const u=receiptUrl(r.dataset.id);if(SERVED)location.href=u;else window.open(u,'_blank');}));
775
+ if(pages<=1){pg.innerHTML='';return;}
776
+ let h='<span class="pg-info">'+curPage+' / '+pages+' ('+total+')</span> ';
777
+ if(curPage>1) h+='<button onclick="gp('+(curPage-1)+')">&lt;</button> ';
778
+ const s2=Math.max(1,curPage-2),e=Math.min(pages,curPage+2);
779
+ for(let i=s2;i<=e;i++) h+='<button onclick="gp('+i+')" class="'+(i===curPage?'active':'')+'">'+i+'</button> ';
780
+ if(curPage<pages) h+='<button onclick="gp('+(curPage+1)+')">&gt;</button>';
781
+ pg.innerHTML=h;
782
+ }
783
+ window.gp=function(n){curPage=n;renderPage();document.querySelector('.sessions-card').scrollIntoView({behavior:'smooth',block:'start'});};
784
+
785
+ document.getElementById('docsLink').addEventListener('click',e=>{e.preventDefault();document.getElementById('docsOverlay').classList.add('show');});
786
+ document.getElementById('docsClose').addEventListener('click',()=>document.getElementById('docsOverlay').classList.remove('show'));
787
+ document.getElementById('docsOverlay').addEventListener('click',e=>{if(e.target.id==='docsOverlay')document.getElementById('docsOverlay').classList.remove('show');});
788
+
789
+ document.getElementById('exportBtn').addEventListener('click',()=>{
790
+ const sess=ALL.filter(inRange);
791
+ const lines=['CLAUDE CODE USAGE REPORT','Generated: '+new Date('${generatedAt}').toLocaleString(),'='.repeat(80),''];
792
+ lines.push('SUMMARY');
793
+ lines.push(' Sessions : '+sess.length);
794
+ lines.push(' Tokens : '+sess.reduce((s,r)=>s+r.totalTokens.total,0).toLocaleString());
795
+ lines.push(' Cost : $'+sess.reduce((s,r)=>s+r.totalCost.total,0).toFixed(4)+' (unpriced models at $0)');
796
+ lines.push('');
797
+ lines.push('SESSIONS');
798
+ lines.push(['DATE','TITLE','PROJECT','CATEGORY','TOKENS','COST','DUR'].map(h=>h.padEnd(16)).join(' '));
799
+ lines.push('-'.repeat(120));
800
+ sess.forEach(s=>{
801
+ lines.push([
802
+ s.date.padEnd(16),
803
+ s.title.slice(0,16).padEnd(16),
804
+ s.project.slice(0,16).padEnd(16),
805
+ catLabel(catOf(s)).slice(0,16).padEnd(16),
806
+ fT(s.totalTokens.total).padEnd(16),
807
+ fC(s.totalCost.total).padEnd(16),
808
+ (s.durationMin!=null?s.durationMin+'m':'-').padEnd(16),
809
+ ].join(' '));
810
+ });
811
+ lines.push('');lines.push('~ burner ~');
812
+ const blob=new Blob([lines.join('\\n')],{type:'text/plain'});
813
+ const a=document.createElement('a');
814
+ a.href=URL.createObjectURL(blob);
815
+ a.download='burner-'+new Date().toISOString().slice(0,10)+'.txt';
816
+ a.click();toast('exported .txt');
817
+ });
818
+
819
+ function setTheme(t,persist=true){
820
+ document.documentElement.setAttribute('data-theme',t);
821
+ try{localStorage.setItem('burner-theme',t);}catch(e){}
822
+ if(persist&&SERVED)fetch('/api/theme',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({theme:t})}).catch(()=>{});
823
+ document.querySelectorAll('button[data-theme]').forEach(b=>b.classList.toggle('active',b.dataset.theme===t));
824
+ rebuild();
825
+ }
826
+ document.querySelectorAll('button[data-theme]').forEach(b=>b.addEventListener('click',()=>setTheme(b.dataset.theme)));
827
+
828
+ document.getElementById('search').addEventListener('input',()=>rebuild());
829
+ document.getElementById('catFilter').addEventListener('change',()=>rebuild());
830
+ document.getElementById('sortBy').addEventListener('change',()=>rebuild());
831
+
832
+ function renderSummary(sess){
833
+ const z=()=>({input:0,cacheWrite:0,cacheRead:0,output:0,total:0});
834
+ const t=z(),c=z();
835
+ sess.forEach(s=>{for(const k of ['input','cacheWrite','cacheRead','output','total']){t[k]+=s.totalTokens[k]||0;c[k]+=s.totalCost[k]||0;}});
836
+ const pct=k=>c.total>0?Math.round(c[k]/c.total*100)+'%':'';
837
+ const row=(lbl,k,cls)=>'<div class="line'+(cls||'')+'"><span class="lbl'+(cls?'':' i')+'">'+lbl+'</span><span class="dv">'+(pct(k)?pct(k)+' of cost':'')+'</span><span class="val" style="min-width:84px">'+fT(t[k])+' tok</span><span class="val" style="min-width:72px">'+fC(c[k])+'</span></div>';
838
+ const prompt=t.input+t.cacheWrite+t.cacheRead;
839
+ document.getElementById('sumBreakdown').innerHTML=
840
+ row('INPUT (fresh, uncached)','input')+row('CACHE WRITE','cacheWrite')+row('CACHE READ (reused)','cacheRead')+row('OUTPUT','output')
841
+ +'<hr class="rule" />'+row('TOTAL','total',' total')
842
+ +'<div class="line"><span class="lbl i">CACHE HIT RATE</span><span class="val">'+(prompt?Math.round(t.cacheRead/prompt*100):0)+'%</span></div>';
843
+ }
844
+ function rebuild(){const sess=ALL.filter(inRange);renderSummary(sess);drawCharts(sess);renderList(sess);}
845
+
846
+ window.addEventListener('resize',()=>drawCharts(ALL.filter(inRange)));
847
+
848
+ // Coming back to this tab (browser Back, switching windows) must show tags and
849
+ // project moves made elsewhere, without a manual refresh.
850
+ let _lastSync=Date.now();
851
+ async function syncData(force){
852
+ if(!SERVED||(!force&&Date.now()-_lastSync<1500))return;
853
+ _lastSync=Date.now();
854
+ try{
855
+ const res=await fetch('/api/sessions',{cache:'no-store'});
856
+ if(!res.ok)return;
857
+ ALL=(await res.json()).map(({activity,attribution,subagentGroups,subagentRuns,agents,...s})=>s);
858
+ rebuild();
859
+ }catch(e){}
860
+ }
861
+ window.addEventListener('pageshow',e=>{if(e.persisted)syncData(true);});
862
+ document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible')syncData(false);});
863
+ window.addEventListener('focus',()=>syncData(false));
864
+
865
+ restoreRange();
866
+ setTheme(document.documentElement.getAttribute('data-theme'),false);
867
+ rebuild();
868
+ </script>
869
+ </body>
870
+ </html>`;
871
+
872
+ return html;
873
+ }