|
102 | 102 | color: var(--dim); margin: 6px 0; } |
103 | 103 | .callout b, .callout code { color: var(--term-fg); } |
104 | 104 |
|
| 105 | + /* ——— day pills + session gantt ——— */ |
| 106 | + .days { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; } |
| 107 | + .dpill { flex: 1; min-width: 84px; max-width: 140px; background: none; |
| 108 | + border: 1px solid var(--subtle); border-radius: 4px; |
| 109 | + padding: 9px 6px; font: inherit; color: var(--dim); |
| 110 | + cursor: pointer; text-align: center; } |
| 111 | + .dpill:hover { border-color: var(--dim); background: var(--hover); } |
| 112 | + .dpill .dow { font-size: 10px; color: var(--subtle); display: block; } |
| 113 | + .dpill .date { font-size: 11px; color: var(--term-fg); font-weight: 500; |
| 114 | + display: block; margin: 2px 0 4px; } |
| 115 | + .dpill .pct { font-size: 16px; font-weight: 700; color: var(--term-fg); display: block; } |
| 116 | + .dpill .ns { font-size: 10px; color: var(--subtle); display: block; margin-top: 2px; } |
| 117 | + .dpill.heaviest .pct { color: var(--clay); } |
| 118 | + .dpill.sel { border-color: var(--clay); background: rgba(217,119,87,0.10); } |
| 119 | + .gantt-hd { display: flex; justify-content: space-between; align-items: baseline; |
| 120 | + margin-bottom: 6px; } |
| 121 | + .gantt-hd .day { color: var(--term-fg); font-weight: 500; } |
| 122 | + .gantt-hd .stats { font-size: 11px; color: var(--dim); } |
| 123 | + .gantt-hd .stats b { color: var(--clay); } |
| 124 | + .gantt { position: relative; border-top: 1px solid var(--outline); |
| 125 | + border-bottom: 1px solid var(--outline); min-height: 32px; } |
| 126 | + .lane { position: relative; height: 16px; |
| 127 | + border-bottom: 1px dashed rgba(255,255,255,0.04); } |
| 128 | + .seg { position: absolute; top: 2px; height: 12px; border-radius: 2px; |
| 129 | + opacity: .85; cursor: crosshair; } |
| 130 | + .seg:hover { opacity: 1; outline: 1px solid var(--term-fg); z-index: 2; } |
| 131 | + .gantt-rule { position: absolute; top: 0; bottom: 0; width: 0; |
| 132 | + border-left: 1px dashed var(--subtle); opacity: .4; |
| 133 | + pointer-events: none; } |
| 134 | + .gantt-axis { display: flex; justify-content: space-between; |
| 135 | + font-size: 10px; color: var(--subtle); padding: 4px 0; } |
| 136 | + .gantt-leg { font-size: 10px; color: var(--subtle); margin-top: 8px; |
| 137 | + display: flex; gap: 14px; flex-wrap: wrap; } |
| 138 | + .gantt-leg .sw { display: inline-block; width: 14px; height: 10px; |
| 139 | + border-radius: 2px; vertical-align: middle; margin-right: 4px; } |
| 140 | + |
105 | 141 | /* ——— block-char bars ——— */ |
106 | 142 | .bar { display: grid; grid-template-columns: 26ch 1fr 8ch; gap: 14px; |
107 | 143 | padding: 2px 0; align-items: center; } |
@@ -231,6 +267,21 @@ <h2>tokens by project<span class="hint">share of total</span></h2> |
231 | 267 | <div class="section-body" id="project-bars"></div> |
232 | 268 | </section> |
233 | 269 |
|
| 270 | + <section id="timeline-section"> |
| 271 | + <div class="hr"></div> |
| 272 | + <h2>session timeline by day<span class="hint">click a day · ←/→ to navigate</span></h2> |
| 273 | + <div class="section-body"> |
| 274 | + <div class="days" id="day-pills"></div> |
| 275 | + <div class="gantt-hd"> |
| 276 | + <span class="day" id="g-day">—</span> |
| 277 | + <span class="stats" id="g-stats"></span> |
| 278 | + </div> |
| 279 | + <div class="gantt-axis"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div> |
| 280 | + <div class="gantt" id="gantt"></div> |
| 281 | + <div class="gantt-leg" id="gantt-leg"></div> |
| 282 | + </div> |
| 283 | + </section> |
| 284 | + |
234 | 285 | <section> |
235 | 286 | <div class="hr"></div> |
236 | 287 | <h2>most expensive prompts<span class="hint">click to expand context</span></h2> |
@@ -335,6 +386,65 @@ <h2>recommendations</h2> |
335 | 386 | `<div class="val">${typeof v==='number'&&v>=1e4?fmt(v):v}</div>`+ |
336 | 387 | (d?`<div class="detail">${d}</div>`:'')+`</div>`).join(''); |
337 | 388 |
|
| 389 | + // session timeline by day |
| 390 | + (function() { |
| 391 | + const days = (DATA.by_day||[]).slice(-14); |
| 392 | + if (!days.length) { $('timeline-section').style.display='none'; return; } |
| 393 | + const PCOL = ['rgb(177,185,249)','rgb(78,186,101)','#D97757','rgb(255,193,7)', |
| 394 | + 'rgb(255,107,128)','#9b8cff','#6ec1d6','#c792ea']; |
| 395 | + const dayTotal = days.reduce((a,d)=>a+d.tokens,0) || 1; |
| 396 | + const tokMax = Math.max(...days.map(d=>d.tokens)); |
| 397 | + const projects = [...new Set(days.flatMap(d=>d.sessions.map(s=>s.project)))]; |
| 398 | + const colorOf = p => PCOL[projects.indexOf(p)%PCOL.length]; |
| 399 | + const hhmm = m => (m>=1440?`+${Math.floor(m/1440)}d `:'') + |
| 400 | + `${String(Math.floor(m/60)%24).padStart(2,'0')}:${String(m%60).padStart(2,'0')}`; |
| 401 | + const md = iso => { const [,mo,da]=iso.split('-'); return `${MON[+mo-1]} ${+da}`; }; |
| 402 | + let sel = days.findIndex(d=>d.tokens===tokMax); |
| 403 | + |
| 404 | + function pills() { |
| 405 | + $('day-pills').innerHTML = days.map((d,i)=> |
| 406 | + `<button class="dpill${d.tokens===tokMax?' heaviest':''}${i===sel?' sel':''}" data-i="${i}">`+ |
| 407 | + `<span class="dow">${esc(d.dow)}</span>`+ |
| 408 | + `<span class="date">${esc(md(d.date))}</span>`+ |
| 409 | + `<span class="pct">${(100*d.tokens/dayTotal).toFixed(1)}%</span>`+ |
| 410 | + `<span class="ns">${d.sessions.length} sess</span></button>` |
| 411 | + ).join(''); |
| 412 | + $('day-pills').querySelectorAll('.dpill').forEach(el=> |
| 413 | + el.onclick=()=>{sel=+el.dataset.i;pills();gantt();}); |
| 414 | + } |
| 415 | + function gantt() { |
| 416 | + const d = days[sel], DAY = 1440; |
| 417 | + $('g-day').textContent = `${d.dow} ${md(d.date)}`; |
| 418 | + $('g-stats').innerHTML = `${d.sessions.length} sessions · ${fmt(d.tokens)} tokens`+ |
| 419 | + ` · peak <b>${d.peak}</b> concurrent at <b>${hhmm(d.peak_at_min)}</b>`; |
| 420 | + const lanes = []; |
| 421 | + for (const s of d.sessions) { |
| 422 | + let placed = false; |
| 423 | + for (const L of lanes) if (L[L.length-1].end_min <= s.start_min) { L.push(s); placed=true; break; } |
| 424 | + if (!placed) lanes.push([s]); |
| 425 | + } |
| 426 | + let h = ''; |
| 427 | + for (let t=0;t<=24;t+=6) h += `<div class="gantt-rule" style="left:${100*t/24}%"></div>`; |
| 428 | + h += lanes.map(L=>`<div class="lane">${L.map(s=>{ |
| 429 | + const end = Math.min(s.end_min, DAY); |
| 430 | + const w = Math.max(0.15, 100*(end-s.start_min)/DAY); |
| 431 | + const tip = `folder: ${short(s.project)}\n`+ |
| 432 | + `${hhmm(s.start_min)}–${hhmm(s.end_min)} · ${fmt(s.tokens)} tokens\n`+ |
| 433 | + `session ${s.id}`; |
| 434 | + return `<span class="seg" style="left:${100*s.start_min/DAY}%;width:${w}%;`+ |
| 435 | + `background:${colorOf(s.project)}" title="${esc(tip)}"></span>`; |
| 436 | + }).join('')}</div>`).join(''); |
| 437 | + $('gantt').innerHTML = h || '<div class="callout">no sessions</div>'; |
| 438 | + } |
| 439 | + document.addEventListener('keydown',e=>{ |
| 440 | + if (e.key==='ArrowRight'&&sel<days.length-1){sel++;pills();gantt();e.preventDefault();} |
| 441 | + if (e.key==='ArrowLeft'&&sel>0){sel--;pills();gantt();e.preventDefault();} |
| 442 | + }); |
| 443 | + $('gantt-leg').innerHTML = projects.slice(0,12).map(p=> |
| 444 | + `<span><span class="sw" style="background:${colorOf(p)}"></span>${esc(short(p))}</span>`).join(''); |
| 445 | + pills(); gantt(); |
| 446 | + })(); |
| 447 | + |
338 | 448 | // block-char project bars |
339 | 449 | (function() { |
340 | 450 | const W = 48; |
@@ -366,57 +476,52 @@ <h2>recommendations</h2> |
366 | 476 | return h + '</div>'; |
367 | 477 | } |
368 | 478 |
|
369 | | - // top prompts — share of grand total |
370 | | - (function() { |
371 | | - const ps = (DATA.top_prompts||[]).slice(0,100); |
| 479 | + // expandable drill-down list with "show N more" toggle |
| 480 | + function drillList(hostId, items, rowFn, empty) { |
372 | 481 | const SHOW = 5; |
373 | | - const row = p => { |
374 | | - const inTot = p.input.uncached+p.input.cache_create+p.input.cache_read; |
375 | | - return `<details><summary>`+ |
376 | | - `<span class="amt">${share(p.total_tokens)}</span>`+ |
377 | | - `<span class="desc">${esc(p.text)}</span>`+ |
378 | | - `<span class="meta">${niceDate(p.ts)} · ${esc(short(p.project))} · ${p.api_calls} calls`+ |
379 | | - (p.subagent_calls?` · ${p.subagent_calls} subagents`:'')+ |
380 | | - ` · ${pct(p.input.cache_read,inTot)} cached</span>`+ |
381 | | - `</summary><div class="body">`+ |
382 | | - renderContext(p.context)+ |
383 | | - `<div>session <code>${esc(p.session)}</code></div>`+ |
384 | | - `<div>in: uncached ${fmt(p.input.uncached)} · cache-create ${fmt(p.input.cache_create)} · `+ |
385 | | - `cache-read ${fmt(p.input.cache_read)} · out ${fmt(p.output)}</div>`+ |
386 | | - `</div></details>`; |
387 | | - }; |
388 | | - const head = ps.slice(0,SHOW).map(row).join(''); |
389 | | - const rest = ps.slice(SHOW).map(row).join(''); |
390 | | - $('top-prompts').innerHTML = ps.length |
391 | | - ? head + (rest |
392 | | - ? `<div id="tp-rest" hidden>${rest}</div>`+ |
393 | | - `<button id="tp-more" class="more-btn">show ${ps.length-SHOW} more</button>` |
394 | | - : '') |
395 | | - : '<div class="callout">No prompts in range.</div>'; |
396 | | - const btn = $('tp-more'); |
| 482 | + const host = $(hostId); |
| 483 | + if (!items.length) { host.innerHTML = `<div class="callout">${empty}</div>`; return; } |
| 484 | + const head = items.slice(0,SHOW).map(rowFn).join(''); |
| 485 | + const rest = items.slice(SHOW).map(rowFn).join(''); |
| 486 | + host.innerHTML = head + (rest |
| 487 | + ? `<div hidden>${rest}</div><button class="more-btn">show ${items.length-SHOW} more</button>` |
| 488 | + : ''); |
| 489 | + const btn = host.querySelector('.more-btn'); |
397 | 490 | if (btn) btn.onclick = () => { |
398 | | - const r = $('tp-rest'); r.hidden = !r.hidden; |
399 | | - btn.textContent = r.hidden ? `show ${ps.length-SHOW} more` : 'show less'; |
| 491 | + const r = btn.previousElementSibling; r.hidden = !r.hidden; |
| 492 | + btn.textContent = r.hidden ? `show ${items.length-SHOW} more` : 'show less'; |
400 | 493 | }; |
401 | | - })(); |
| 494 | + } |
402 | 495 |
|
403 | | - // cache breaks |
404 | | - (function() { |
405 | | - const bs = (DATA.cache_breaks||[]).slice(0,100); |
406 | | - $('cache-breaks').innerHTML = bs.map(b => |
407 | | - `<details><summary>`+ |
408 | | - `<span class="amt">${fmt(b.uncached)}</span>`+ |
409 | | - `<span class="desc">${esc(short(b.project))} · `+ |
410 | | - `${b.kind==='subagent'?esc(b.agentType||'subagent'):'main'}</span>`+ |
411 | | - `<span class="meta">${niceDate(b.ts)} · ${pct(b.uncached,b.total)} of ${fmt(b.total)} uncached</span>`+ |
| 496 | + drillList('top-prompts', (DATA.top_prompts||[]).slice(0,100), p => { |
| 497 | + const inTot = p.input.uncached+p.input.cache_create+p.input.cache_read; |
| 498 | + return `<details><summary>`+ |
| 499 | + `<span class="amt">${share(p.total_tokens)}</span>`+ |
| 500 | + `<span class="desc">${esc(p.text)}</span>`+ |
| 501 | + `<span class="meta">${niceDate(p.ts)} · ${esc(short(p.project))} · ${p.api_calls} calls`+ |
| 502 | + (p.subagent_calls?` · ${p.subagent_calls} subagents`:'')+ |
| 503 | + ` · ${pct(p.input.cache_read,inTot)} cached</span>`+ |
412 | 504 | `</summary><div class="body">`+ |
413 | | - renderContext(b.context, |
414 | | - `<div class="ctx-break"><b>${fmt(b.uncached)}</b> uncached `+ |
415 | | - `(${pct(b.uncached,b.total)} of ${fmt(b.total)}) — cache break here</div>`)+ |
416 | | - `<div>session <code>${esc(b.session)}</code></div>`+ |
417 | | - `</div></details>` |
418 | | - ).join('') || '<div class="callout">No cache breaks over threshold.</div>'; |
419 | | - })(); |
| 505 | + renderContext(p.context)+ |
| 506 | + `<div>session <code>${esc(p.session)}</code></div>`+ |
| 507 | + `<div>in: uncached ${fmt(p.input.uncached)} · cache-create ${fmt(p.input.cache_create)} · `+ |
| 508 | + `cache-read ${fmt(p.input.cache_read)} · out ${fmt(p.output)}</div>`+ |
| 509 | + `</div></details>`; |
| 510 | + }, 'No prompts in range.'); |
| 511 | + |
| 512 | + drillList('cache-breaks', (DATA.cache_breaks||[]).slice(0,100), b => |
| 513 | + `<details><summary>`+ |
| 514 | + `<span class="amt">${fmt(b.uncached)}</span>`+ |
| 515 | + `<span class="desc">${esc(short(b.project))} · `+ |
| 516 | + `${b.kind==='subagent'?esc(b.agentType||'subagent'):'main'}</span>`+ |
| 517 | + `<span class="meta">${niceDate(b.ts)} · ${pct(b.uncached,b.total)} of ${fmt(b.total)} uncached</span>`+ |
| 518 | + `</summary><div class="body">`+ |
| 519 | + renderContext(b.context, |
| 520 | + `<div class="ctx-break"><b>${fmt(b.uncached)}</b> uncached `+ |
| 521 | + `(${pct(b.uncached,b.total)} of ${fmt(b.total)}) — cache break here</div>`)+ |
| 522 | + `<div>session <code>${esc(b.session)}</code></div>`+ |
| 523 | + `</div></details>`, |
| 524 | + 'No cache breaks over threshold.'); |
420 | 525 |
|
421 | 526 | // sortable table |
422 | 527 | function table(el, cols, rows) { |
|
0 commit comments