feat(board): drop budget über-tabs, Verlauf chart lives in donut card as third tab

This commit is contained in:
chahinebrini 2026-07-22 17:19:55 +02:00
parent 5dd5806640
commit 964ddff2f2
4 changed files with 73 additions and 75 deletions

View File

@ -1,25 +1,18 @@
import { throughputSeries, areaPath, dayStart } from './viewmodel.js'; import { throughputSeries, areaPath, dayStart } from './viewmodel.js';
/** /**
* Sidebar skeleton: budget card (über-tabs Token Insights | Verlauf; insight * Sidebar skeleton: budget card + live feed card. The budget card has no tab
* is the default) + live feed card. The insight pane keeps the v1 mount * bar of its own the Token/Kosten/Verlauf tabs live inside the donut card
* points (#budgetRows, #budgetTotal, #budgetReset, [data-budget-mode]) that * (rendered client-side by the ported v1 budget JS in v1Budget.ts). The
* the ported v1 budget JS (v1Budget.ts) drives; donut sub-tabs (Token/Kosten) * subhead keeps the v1 mount points: [data-budget-mode] (Session/Gesamt),
* are rendered by that same ported code. * #budgetTotal, #budgetReset; #budgetRows is where the donut card mounts.
*/ */
export function sidebarHtml(): string { export function sidebarHtml(): string {
return ` return `
<aside class="b2-side"> <aside class="b2-side">
<div class="b2-panel b2-glass" id="b2Budget"> <div class="b2-panel b2-glass" id="b2Budget">
<div class="b2-tabhead">
<h6>Budget</h6>
<div class="b2-otabs">
<button type="button" data-otab="insight" class="active">Token Insights</button>
<button type="button" data-otab="verlauf">Verlauf</button>
</div>
</div>
<div data-opane="insight">
<div class="b2-subhead"> <div class="b2-subhead">
<h6>Budget</h6>
<span class="seg" aria-label="Token insight range"> <span class="seg" aria-label="Token insight range">
<button type="button" data-budget-mode="session" class="active">Session</button> <button type="button" data-budget-mode="session" class="active">Session</button>
<button type="button" data-budget-mode="total">Gesamt</button> <button type="button" data-budget-mode="total">Gesamt</button>
@ -29,11 +22,6 @@ export function sidebarHtml(): string {
</div> </div>
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div> <div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
</div> </div>
<div data-opane="verlauf" hidden>
<div id="b2Throughput"></div>
<div class="b2-chart-tip" id="b2ThroughputTip"></div>
</div>
</div>
<div class="b2-panel b2-glass"> <div class="b2-panel b2-glass">
<h6>Live</h6> <h6>Live</h6>
<div class="b2-feed" id="b2Feed"></div> <div class="b2-feed" id="b2Feed"></div>
@ -42,8 +30,8 @@ export function sidebarHtml(): string {
} }
/** /**
* Inline script: über-tab switching (persisted in localStorage), throughput * Inline script: throughput area chart renderer (used by the Verlauf tab in
* area chart, live feed followed by the ported v1 budget JS. * the donut card) + live feed, followed by the ported v1 budget JS.
*/ */
export function sidebarJs(v1BudgetJs: string): string { export function sidebarJs(v1BudgetJs: string): string {
return ` return `
@ -51,29 +39,10 @@ var DAY_MS = 24 * 3600 * 1000;
${dayStart.toString()} ${dayStart.toString()}
${throughputSeries.toString()} ${throughputSeries.toString()}
${areaPath.toString()} ${areaPath.toString()}
(function () {
var OTAB_KEY = 'agenthub-budget-otab';
function activate(name) {
document.querySelectorAll('[data-otab]').forEach(function (b) {
b.classList.toggle('active', b.getAttribute('data-otab') === name);
});
document.querySelectorAll('[data-opane]').forEach(function (p) {
p.hidden = p.getAttribute('data-opane') !== name;
});
}
document.addEventListener('click', function (e) {
var btn = e.target.closest && e.target.closest('[data-otab]');
if (!btn) return;
var name = btn.getAttribute('data-otab');
localStorage.setItem(OTAB_KEY, name);
activate(name);
});
activate(localStorage.getItem(OTAB_KEY) || 'insight');
})();
window.__b2RenderThroughput = function (tasks) { window.__b2RenderThroughput = function (tasks) {
var box = document.getElementById('b2Throughput'); var box = document.getElementById('b2Throughput');
var tip = document.getElementById('b2ThroughputTip'); var tip = document.getElementById('b2ThroughputTip');
if (!box) return; if (!box) return; // Verlauf tab not mounted right now — nothing to update
var s = throughputSeries(tasks, 14); var s = throughputSeries(tasks, 14);
var p = areaPath(s, 220, 80); var p = areaPath(s, 220, 80);
box.innerHTML = '<svg width="100%" height="80" viewBox="0 0 220 80" preserveAspectRatio="none">' box.innerHTML = '<svg width="100%" height="80" viewBox="0 0 220 80" preserveAspectRatio="none">'

View File

@ -309,22 +309,9 @@ export function boardV2Css(): string {
position: sticky; top: 84px; max-height: calc(100vh - 102px); overflow-y: auto; } position: sticky; top: 84px; max-height: calc(100vh - 102px); overflow-y: auto; }
.b2-panel { padding: 13px 15px; } .b2-panel { padding: 13px 15px; }
.b2-panel h6 { margin: 0; font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--b2-muted); } .b2-panel h6 { margin: 0; font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--b2-muted); }
.b2-tabhead { display: flex; align-items: center; gap: 8px; } .b2-subhead { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.b2-otabs, .b2-stabs { display: flex; gap: 4px; }
.b2-otabs { margin-left: auto; }
.b2-otabs button, .b2-stabs button {
min-height: 26px; padding: 0 10px; border-radius: 999px; cursor: pointer;
border: 1px solid transparent; background: transparent;
color: var(--b2-muted); font: 600 11px/1 var(--sans);
transition: color 140ms, background 140ms, border-color 140ms;
}
.b2-otabs button.active, .b2-stabs button.active {
color: var(--b2-text); background: rgba(255,255,255,.09); border-color: rgba(255,255,255,.14);
}
.b2-subhead { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.b2-subhead .seg { margin-left: auto; } .b2-subhead .seg { margin-left: auto; }
[data-opane] { margin-top: 10px; } #budgetRows { margin-top: 10px; }
[data-opane][hidden] { display: none; }
.b2-chart-tip { color: var(--b2-muted); font-size: 11px; text-align: right; margin-top: 5px; font-family: var(--mono); } .b2-chart-tip { color: var(--b2-muted); font-size: 11px; text-align: right; margin-top: 5px; font-family: var(--mono); }
/* Budget internals (ported v1: seg, reset, donuts, legend, agent bars) */ /* Budget internals (ported v1: seg, reset, donuts, legend, agent bars) */

View File

@ -114,6 +114,17 @@ export const v1BudgetJs = `
}); });
return Object.keys(byKind).map(function(k) { return byKind[k]; }).sort(function(a, b) { return b.value - a.value; }); return Object.keys(byKind).map(function(k) { return byKind[k]; }).sort(function(a, b) { return b.value - a.value; });
} }
// The donut card hosts three tabs: Token | Kosten | Verlauf (throughput chart).
function donutTabsHtml() {
return '<div class="donut-tabs">' +
'<button type="button" data-donut-tab="tokens" class="' + (donutMetric === 'tokens' ? 'active' : '') + '">Token</button>' +
'<button type="button" data-donut-tab="cost" class="' + (donutMetric === 'cost' ? 'active' : '') + '">Kosten</button>' +
'<button type="button" data-donut-tab="verlauf" class="' + (donutMetric === 'verlauf' ? 'active' : '') + '">Verlauf</button>' +
'</div>';
}
function donutCardShell(title, bodyHtml) {
return '<div class="donut-card"><h3 class="donut-title">' + esc(title) + '</h3>' + donutTabsHtml() + bodyHtml + '</div>';
}
function halfDonut(title, rows, centerValue, centerLabel, formatValue) { function halfDonut(title, rows, centerValue, centerLabel, formatValue) {
var total = rows.reduce(function(s, x) { return s + (x.value || 0); }, 0); var total = rows.reduce(function(s, x) { return s + (x.value || 0); }, 0);
var safeTotal = Math.max(1, total); var safeTotal = Math.max(1, total);
@ -136,7 +147,7 @@ export const v1BudgetJs = `
}).join('') : '<div class="empty">no provider data</div>'; }).join('') : '<div class="empty">no provider data</div>';
return '<div class="donut-card">' + return '<div class="donut-card">' +
'<h3 class="donut-title">' + esc(title) + '</h3>' + '<h3 class="donut-title">' + esc(title) + '</h3>' +
'<div class="donut-tabs"><button type="button" data-donut-tab="tokens" class="' + (donutMetric === 'tokens' ? 'active' : '') + '">Token</button><button type="button" data-donut-tab="cost" class="' + (donutMetric === 'cost' ? 'active' : '') + '">Kosten</button></div>' + donutTabsHtml() +
'<div class="half-donut">' + '<div class="half-donut">' +
'<svg viewBox="0 0 220 132" role="img" aria-label="' + esc(title) + '">' + paths + '<svg viewBox="0 0 220 132" role="img" aria-label="' + esc(title) + '">' + paths +
'<text class="donut-value" x="110" y="102" text-anchor="middle" dominant-baseline="central" data-count-to="' + esc(centerValue) + '" data-count-format="' + (formatValue === fmtEur ? 'eur' : 'tokens') + '">0</text>' + '<text class="donut-value" x="110" y="102" text-anchor="middle" dominant-baseline="central" data-count-to="' + esc(centerValue) + '" data-count-format="' + (formatValue === fmtEur ? 'eur' : 'tokens') + '">0</text>' +
@ -189,6 +200,20 @@ export const v1BudgetJs = `
function renderDonut(active, force) { function renderDonut(active, force) {
var wrap = document.getElementById('donutWrap'); var wrap = document.getElementById('donutWrap');
if (!wrap) return; if (!wrap) return;
// Verlauf tab: throughput area chart instead of the donut. Static
// signature — the chart content itself updates via __b2RenderThroughput
// on task refreshes, so the 3s budget poll must not rebuild the card.
if (donutMetric === 'verlauf') {
if (lastDonutSignature === 'verlauf' && !force) return;
lastDonutSignature = 'verlauf';
lastDonutAt = Date.now();
wrap.innerHTML = donutCardShell('Verlauf · erledigte Tasks',
'<div id="b2Throughput"></div><div class="b2-chart-tip" id="b2ThroughputTip"></div>');
if (window.__b2RenderThroughput) {
window.__b2RenderThroughput(typeof lastTasks !== 'undefined' ? lastTasks : []);
}
return;
}
var field = donutMetric === 'cost' ? 'costEur' : 'tokens'; var field = donutMetric === 'cost' ? 'costEur' : 'tokens';
var companies = aggregateByCompany(active, field); var companies = aggregateByCompany(active, field);
var sig = donutMetric + ':' + signatureFor(companies); var sig = donutMetric + ':' + signatureFor(companies);
@ -250,17 +275,28 @@ export const v1BudgetJs = `
var scoped = applyBudgetMode(rep); var scoped = applyBudgetMode(rep);
var active = scoped.agents.filter(function(a) { return a.kind && a.tokens > 0; }); var active = scoped.agents.filter(function(a) { return a.kind && a.tokens > 0; });
updateBudgetButtons(); updateBudgetButtons();
if (!active.length) { // The card skeleton (and with it the Token/Kosten/Verlauf tabs) always
rows.innerHTML = '<div class="budget-empty">no real agent token data in this ' + (budgetMode === 'session' ? 'session' : 'total range') + '</div>'; // exists — even in the empty state the Verlauf tab must stay reachable.
total.textContent = '';
lastDonutSignature = '';
return;
}
if (!document.getElementById('donutWrap') || !document.getElementById('agentBars')) { if (!document.getElementById('donutWrap') || !document.getElementById('agentBars')) {
rows.innerHTML = '<div class="donut-grid"><div id="donutWrap"></div></div><div class="agent-bars" id="agentBars"></div>'; rows.innerHTML = '<div class="donut-grid"><div id="donutWrap"></div></div><div class="agent-bars" id="agentBars"></div>';
} }
var bars = document.getElementById('agentBars');
if (!active.length) {
if (donutMetric === 'verlauf') {
renderDonut(active, opts.forceDonut); renderDonut(active, opts.forceDonut);
renderAgentBars(active); } else {
lastDonutSignature = '';
document.getElementById('donutWrap').innerHTML = donutCardShell('Token Insights',
'<div class="budget-empty">no real agent token data in this ' + (budgetMode === 'session' ? 'session' : 'total range') + '</div>');
}
if (bars) bars.innerHTML = '';
total.textContent = '';
return;
}
renderDonut(active, opts.forceDonut);
// Agent bars belong to the token insights; hidden on the Verlauf tab.
if (donutMetric === 'verlauf') { if (bars) bars.innerHTML = ''; }
else renderAgentBars(active);
total.innerHTML = (scoped.totals.estimated ? '~' : '') + fmtTokens(scoped.totals.tokens) + total.innerHTML = (scoped.totals.estimated ? '~' : '') + fmtTokens(scoped.totals.tokens) +
' tok <small>\\u00b7 \\u2248 ' + fmtEur(scoped.totals.costEur) + '</small>'; ' tok <small>\\u00b7 \\u2248 ' + fmtEur(scoped.totals.costEur) + '</small>';
} }

View File

@ -3,11 +3,10 @@ import { sidebarHtml, sidebarJs } from '../src/server/board/sidebar.js';
import { v1BudgetJs } from '../src/server/board/v1Budget.js'; import { v1BudgetJs } from '../src/server/board/v1Budget.js';
describe('sidebarHtml', () => { describe('sidebarHtml', () => {
it('renders budget card with über-tabs and insight as default', () => { it('renders budget card without über-tabs (tabs live in the donut card)', () => {
const h = sidebarHtml(); const h = sidebarHtml();
expect(h).toContain('id="b2Budget"'); expect(h).toContain('id="b2Budget"');
expect(h).toContain('data-otab="insight"'); expect(h).not.toContain('data-otab');
expect(h).toContain('data-otab="verlauf"');
expect(h).toContain('id="budgetReset"'); expect(h).toContain('id="budgetReset"');
expect(h).toContain('id="b2Feed"'); expect(h).toContain('id="b2Feed"');
}); });
@ -22,8 +21,8 @@ describe('sidebarHtml', () => {
describe('sidebarJs', () => { describe('sidebarJs', () => {
const js = sidebarJs(v1BudgetJs); const js = sidebarJs(v1BudgetJs);
it('persists the über-tab and renders throughput', () => { it('renders throughput and feed without über-tab logic', () => {
expect(js).toContain('agenthub-budget-otab'); // localStorage key expect(js).not.toContain('agenthub-budget-otab');
expect(js).toContain('function throughputSeries'); expect(js).toContain('function throughputSeries');
expect(js).toContain('function dayStart'); expect(js).toContain('function dayStart');
expect(js).toContain('__b2RenderThroughput'); expect(js).toContain('__b2RenderThroughput');
@ -36,4 +35,11 @@ describe('sidebarJs', () => {
expect(js).toContain('budgetReset'); expect(js).toContain('budgetReset');
expect(js).toContain('renderBudget'); expect(js).toContain('renderBudget');
}); });
it('hosts the three tabs Token/Kosten/Verlauf inside the donut card', () => {
expect(js).toContain('data-donut-tab="tokens"');
expect(js).toContain('data-donut-tab="cost"');
expect(js).toContain('data-donut-tab="verlauf"');
expect(js).toContain('donutTabsHtml');
expect(js).toContain('donutCardShell');
});
}); });