feat(board): v2 sidebar with tabbed budget card, throughput chart, live feed
This commit is contained in:
parent
9553461130
commit
30b889acd9
96
src/server/board/sidebar.ts
Normal file
96
src/server/board/sidebar.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { throughputSeries, areaPath, dayStart } from './viewmodel.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar skeleton: budget card (über-tabs Token Insights | Verlauf; insight
|
||||||
|
* is the default) + live feed card. The insight pane keeps the v1 mount
|
||||||
|
* points (#budgetRows, #budgetTotal, #budgetReset, [data-budget-mode]) that
|
||||||
|
* the ported v1 budget JS (v1Budget.ts) drives; donut sub-tabs (Token/Kosten)
|
||||||
|
* are rendered by that same ported code.
|
||||||
|
*/
|
||||||
|
export function sidebarHtml(): string {
|
||||||
|
return `
|
||||||
|
<aside class="b2-side">
|
||||||
|
<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">
|
||||||
|
<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="total">Gesamt</button>
|
||||||
|
</span>
|
||||||
|
<span class="total" id="budgetTotal"></span>
|
||||||
|
<button type="button" class="reset-btn" id="budgetReset">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div id="budgetRows"><div class="budget-empty">no agent activity yet</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">
|
||||||
|
<h6>Live</h6>
|
||||||
|
<div class="b2-feed" id="b2Feed"></div>
|
||||||
|
</div>
|
||||||
|
</aside>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline script: über-tab switching (persisted in localStorage), throughput
|
||||||
|
* area chart, live feed — followed by the ported v1 budget JS.
|
||||||
|
*/
|
||||||
|
export function sidebarJs(v1BudgetJs: string): string {
|
||||||
|
return `
|
||||||
|
var DAY_MS = 24 * 3600 * 1000;
|
||||||
|
${dayStart.toString()}
|
||||||
|
${throughputSeries.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) {
|
||||||
|
var box = document.getElementById('b2Throughput');
|
||||||
|
var tip = document.getElementById('b2ThroughputTip');
|
||||||
|
if (!box) return;
|
||||||
|
var s = throughputSeries(tasks, 14);
|
||||||
|
var p = areaPath(s, 220, 80);
|
||||||
|
box.innerHTML = '<svg width="100%" height="80" viewBox="0 0 220 80" preserveAspectRatio="none">'
|
||||||
|
+ '<path fill="rgba(56,189,248,.25)" d="' + p.area + '"/>'
|
||||||
|
+ '<path class="b2-spark" fill="none" stroke="#38bdf8" stroke-width="1.6" d="' + p.line + '"/></svg>';
|
||||||
|
if (tip) {
|
||||||
|
var avg = s.reduce(function (a, b) { return a + b; }, 0) / s.length;
|
||||||
|
tip.textContent = 'Erledigte Tasks · 14 Tage · Ø ' + avg.toFixed(1) + '/Tag';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.__b2FeedPush = function (html) {
|
||||||
|
var feed = document.getElementById('b2Feed');
|
||||||
|
if (!feed) return;
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.innerHTML = html;
|
||||||
|
feed.insertBefore(div, feed.firstChild);
|
||||||
|
while (feed.children.length > 5) feed.removeChild(feed.lastChild);
|
||||||
|
};
|
||||||
|
${v1BudgetJs}`;
|
||||||
|
}
|
||||||
299
src/server/board/v1Budget.ts
Normal file
299
src/server/board/v1Budget.ts
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Verbatim port of the v1 budget-panel client JS (old src/server/board.ts,
|
||||||
|
* lines 1466–1750). Kept byte-identical on purpose: session/total baseline
|
||||||
|
* logic, donut signature throttling and the reset handler are battle-tested.
|
||||||
|
*
|
||||||
|
* Depends on page-scope helpers provided by the columns/data-layer port:
|
||||||
|
* esc, getJSON, hashColor, agentSpec, toast. Mount points expected in the DOM:
|
||||||
|
* #budgetRows, #budgetTotal, #budgetReset, [data-budget-mode].
|
||||||
|
*/
|
||||||
|
export const v1BudgetJs = `
|
||||||
|
// ── Roster ──────────────────────────────────────────────────────────────
|
||||||
|
// AGENTS backs the budget panel + resolves a title's "name:" prefix to a
|
||||||
|
// known agent when the architect drags a card into In Progress.
|
||||||
|
var AGENTS = [];
|
||||||
|
var BUDGET = null;
|
||||||
|
var budgetMode = localStorage.getItem('agenthub-budget-mode') || 'session';
|
||||||
|
var donutMetric = localStorage.getItem('agenthub-donut-metric') || 'tokens';
|
||||||
|
var lastDonutSignature = '';
|
||||||
|
var lastDonutAt = 0;
|
||||||
|
var DONUT_REFRESH_MS = 180000;
|
||||||
|
|
||||||
|
async function loadAgents() {
|
||||||
|
try {
|
||||||
|
AGENTS = await getJSON('/agents');
|
||||||
|
} catch (_) { AGENTS = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget panel ────────────────────────────────────────────────────────
|
||||||
|
function fmtTokens(n) {
|
||||||
|
n = Number(n) || 0;
|
||||||
|
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||||||
|
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
||||||
|
return String(Math.round(n));
|
||||||
|
}
|
||||||
|
function fmtEur(n) { return '\\u20ac' + (Number(n) || 0).toFixed(2); }
|
||||||
|
function maxTok(list) { return list.reduce(function(m, a) { return Math.max(m, a.tokens || 0); }, 0); }
|
||||||
|
function cloneAgent(a) {
|
||||||
|
return {
|
||||||
|
name: a.name,
|
||||||
|
role: a.role,
|
||||||
|
model: a.model,
|
||||||
|
kind: a.kind,
|
||||||
|
tokens: Number(a.tokens) || 0,
|
||||||
|
realTokens: Number(a.realTokens) || 0,
|
||||||
|
estimatedTokens: Number(a.estimatedTokens) || 0,
|
||||||
|
estimated: !!a.estimated,
|
||||||
|
costEur: Number(a.costEur) || 0,
|
||||||
|
taskCount: Number(a.taskCount) || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function currentSnapshot(rep) {
|
||||||
|
var byAgent = {};
|
||||||
|
(rep && rep.agents ? rep.agents : []).forEach(function(a) {
|
||||||
|
byAgent[a.name] = { tokens: Number(a.tokens) || 0, costEur: Number(a.costEur) || 0 };
|
||||||
|
});
|
||||||
|
return { createdAt: Date.now(), byAgent: byAgent };
|
||||||
|
}
|
||||||
|
function readBaseline() {
|
||||||
|
try { return JSON.parse(localStorage.getItem('agenthub-budget-baseline') || 'null'); } catch (_) { return null; }
|
||||||
|
}
|
||||||
|
function writeBaseline(rep) {
|
||||||
|
localStorage.setItem('agenthub-budget-baseline', JSON.stringify(currentSnapshot(rep)));
|
||||||
|
}
|
||||||
|
function applyBudgetMode(rep) {
|
||||||
|
var agents = (rep && rep.agents ? rep.agents : []).map(cloneAgent);
|
||||||
|
if (budgetMode === 'session') {
|
||||||
|
var base = readBaseline();
|
||||||
|
if (!base) { writeBaseline(rep); base = readBaseline(); }
|
||||||
|
agents.forEach(function(a) {
|
||||||
|
var b = base && base.byAgent ? base.byAgent[a.name] : null;
|
||||||
|
var baseTokens = b ? Number(b.tokens) || 0 : 0;
|
||||||
|
var baseCost = b ? Number(b.costEur) || 0 : 0;
|
||||||
|
a.tokens = Math.max(0, a.tokens - baseTokens);
|
||||||
|
a.costEur = Math.max(0, a.costEur - baseCost);
|
||||||
|
a.realTokens = Math.min(a.realTokens, a.tokens);
|
||||||
|
a.estimatedTokens = Math.max(0, a.tokens - a.realTokens);
|
||||||
|
a.estimated = a.estimated && a.estimatedTokens > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var totals = {
|
||||||
|
tokens: agents.reduce(function(s, a) { return s + (a.tokens || 0); }, 0),
|
||||||
|
costEur: agents.reduce(function(s, a) { return s + (a.costEur || 0); }, 0),
|
||||||
|
estimated: agents.some(function(a) { return a.estimated; })
|
||||||
|
};
|
||||||
|
return { agents: agents, totals: totals };
|
||||||
|
}
|
||||||
|
function companySpec(kind) {
|
||||||
|
var key = String(kind || '').toLowerCase();
|
||||||
|
var map = {
|
||||||
|
anthropic: { name: 'Anthropic', color: '#D97757' },
|
||||||
|
openai: { name: 'OpenAI', color: '#10A37F' },
|
||||||
|
moonshot: { name: 'Moonshot', color: '#7C3AED' }
|
||||||
|
};
|
||||||
|
return map[key] || { name: key ? key.charAt(0).toUpperCase() + key.slice(1) : 'Unknown', color: hashColor(key || 'unknown') };
|
||||||
|
}
|
||||||
|
function donutPolar(cx, cy, r, angleDeg) {
|
||||||
|
var rad = angleDeg * Math.PI / 180;
|
||||||
|
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||||
|
}
|
||||||
|
function donutArc(cx, cy, r, startDeg, endDeg) {
|
||||||
|
var start = donutPolar(cx, cy, r, startDeg);
|
||||||
|
var end = donutPolar(cx, cy, r, endDeg);
|
||||||
|
var largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||||
|
return 'M ' + start.x.toFixed(2) + ' ' + start.y.toFixed(2) + ' A ' + r + ' ' + r + ' 0 ' + largeArc + ' 1 ' + end.x.toFixed(2) + ' ' + end.y.toFixed(2);
|
||||||
|
}
|
||||||
|
function aggregateByCompany(agents, field) {
|
||||||
|
var byKind = {};
|
||||||
|
(agents || []).forEach(function(a) {
|
||||||
|
var kind = String(a.kind || '').toLowerCase();
|
||||||
|
if (!kind || !(a.tokens > 0)) return;
|
||||||
|
var spec = companySpec(kind);
|
||||||
|
if (!byKind[kind]) byKind[kind] = { kind: kind, name: spec.name, color: spec.color, value: 0 };
|
||||||
|
byKind[kind].value += Number(a[field]) || 0;
|
||||||
|
});
|
||||||
|
return Object.keys(byKind).map(function(k) { return byKind[k]; }).sort(function(a, b) { return b.value - a.value; });
|
||||||
|
}
|
||||||
|
function halfDonut(title, rows, centerValue, centerLabel, formatValue) {
|
||||||
|
var total = rows.reduce(function(s, x) { return s + (x.value || 0); }, 0);
|
||||||
|
var safeTotal = Math.max(1, total);
|
||||||
|
var cx = 110, cy = 122, r = 90, stroke = 18;
|
||||||
|
var angle = 180;
|
||||||
|
var paths = '<path d="' + donutArc(cx, cy, r, 180, 360) + '" stroke="rgba(255,255,255,.09)" stroke-width="' + stroke + '" fill="none" stroke-linecap="round" />';
|
||||||
|
rows.forEach(function(row, idx) {
|
||||||
|
var start = angle;
|
||||||
|
var end = angle + 180 * ((row.value || 0) / safeTotal);
|
||||||
|
angle = end;
|
||||||
|
if (end <= start + 0.4) return;
|
||||||
|
paths += '<path class="donut-segment" d="' + donutArc(cx, cy, r, start, end) + '" stroke="' + esc(row.color) + '" stroke-width="' + stroke + '" fill="none" stroke-linecap="round" pathLength="100" stroke-dasharray="100" stroke-dashoffset="100" style="animation-delay:' + (idx * 80) + 'ms" />';
|
||||||
|
});
|
||||||
|
if (!rows.length) paths += '<circle cx="' + cx + '" cy="' + (cy - r + stroke / 2) + '" r="3" fill="#94A3B8" />';
|
||||||
|
var legend = rows.length ? rows.map(function(row) {
|
||||||
|
var pct = total > 0 ? Math.round((row.value / total) * 100) : 0;
|
||||||
|
return '<div class="legend-row" style="color:' + esc(row.color) + '">' +
|
||||||
|
'<span class="legend-dot"></span><span class="legend-name">' + esc(row.name) + '</span>' +
|
||||||
|
'<span>' + esc(formatValue(row.value)) + ' · ' + pct + '%</span></div>';
|
||||||
|
}).join('') : '<div class="empty">no provider data</div>';
|
||||||
|
return '<div class="donut-card">' +
|
||||||
|
'<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>' +
|
||||||
|
'<div class="half-donut">' +
|
||||||
|
'<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-label" x="110" y="120" text-anchor="middle" dominant-baseline="central">' + esc(centerLabel) + '</text>' +
|
||||||
|
'</svg>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="legend">' + legend + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
function animateCounts(root) {
|
||||||
|
var nodes = (root || document).querySelectorAll('[data-count-to]');
|
||||||
|
nodes.forEach(function(node) {
|
||||||
|
var target = Number(node.getAttribute('data-count-to')) || 0;
|
||||||
|
var format = node.getAttribute('data-count-format');
|
||||||
|
var start = performance.now();
|
||||||
|
function step(now) {
|
||||||
|
var p = Math.min(1, (now - start) / 900);
|
||||||
|
var eased = 1 - Math.pow(1 - p, 3);
|
||||||
|
var val = target * eased;
|
||||||
|
node.textContent = format === 'eur' ? fmtEur(val) : fmtTokens(val);
|
||||||
|
if (p < 1) requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function signatureFor(rows) {
|
||||||
|
return rows.map(function(r) { return r.kind + ':' + Math.round((Number(r.value) || 0) * 100); }).join('|');
|
||||||
|
}
|
||||||
|
function shouldRenderDonut(nextSig, force) {
|
||||||
|
if (force) return true;
|
||||||
|
var now = Date.now();
|
||||||
|
if (!lastDonutSignature || now - lastDonutAt > DONUT_REFRESH_MS) return true;
|
||||||
|
if (nextSig === lastDonutSignature) return false;
|
||||||
|
var prev = {};
|
||||||
|
lastDonutSignature.split('|').forEach(function(part) {
|
||||||
|
if (!part) return;
|
||||||
|
var bits = part.split(':');
|
||||||
|
prev[bits[0]] = Number(bits[1]) || 0;
|
||||||
|
});
|
||||||
|
var changed = false;
|
||||||
|
nextSig.split('|').forEach(function(part) {
|
||||||
|
if (!part) return;
|
||||||
|
var bits = part.split(':');
|
||||||
|
var oldVal = prev[bits[0]] || 0;
|
||||||
|
var newVal = Number(bits[1]) || 0;
|
||||||
|
if (Math.abs(newVal - oldVal) / Math.max(1, oldVal) > 0.10) changed = true;
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
function renderDonut(active, force) {
|
||||||
|
var wrap = document.getElementById('donutWrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
var field = donutMetric === 'cost' ? 'costEur' : 'tokens';
|
||||||
|
var companies = aggregateByCompany(active, field);
|
||||||
|
var sig = donutMetric + ':' + signatureFor(companies);
|
||||||
|
if (!shouldRenderDonut(sig, force)) return;
|
||||||
|
lastDonutSignature = sig;
|
||||||
|
lastDonutAt = Date.now();
|
||||||
|
var center = active.reduce(function(s, a) { return s + (Number(a[field]) || 0); }, 0);
|
||||||
|
wrap.innerHTML = halfDonut(donutMetric === 'cost' ? 'Cost by Company' : 'Tokens by Company', companies, center, donutMetric === 'cost' ? 'EUR' : 'tokens', donutMetric === 'cost' ? fmtEur : fmtTokens);
|
||||||
|
animateCounts(wrap);
|
||||||
|
}
|
||||||
|
function agentBarHtml(a, mx) {
|
||||||
|
var spec = agentSpec(a.name);
|
||||||
|
var est = a.estimated ? '<span class="est">~ estimated</span>' : 'real';
|
||||||
|
var pct = mx > 0 ? Math.round(((a.tokens || 0) / mx) * 1000) / 10 : 0;
|
||||||
|
var company = companySpec(a.kind);
|
||||||
|
return '<div class="agent-bar-row" data-agent-row="' + esc(a.name) + '">' +
|
||||||
|
'<div class="budget-agent"><span class="ba-core" style="background:' + esc(spec.color) + '">' + esc(spec.initials) + '</span>' +
|
||||||
|
'<span style="min-width:0"><span class="ba-name">' + esc(a.name) + '</span>' +
|
||||||
|
'<span class="ba-model">' + esc(company.name + (a.model ? ' · ' + a.model : '')) + '</span></span></div>' +
|
||||||
|
'<div class="agent-bar-cell"><div class="agent-bar-track" style="--bar-color:' + esc(company.color) + '"><span style="width:' + Math.min(100, pct) + '%"></span></div>' +
|
||||||
|
'<div class="agent-bar-meta"><span class="tok">' + fmtTokens(a.tokens) + ' tok</span><span>' + est + ' · ' + fmtEur(a.costEur) + '</span></div></div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
function renderAgentBars(active) {
|
||||||
|
var host = document.getElementById('agentBars');
|
||||||
|
if (!host) return;
|
||||||
|
var before = {};
|
||||||
|
host.querySelectorAll('[data-agent-row]').forEach(function(el) {
|
||||||
|
before[el.getAttribute('data-agent-row')] = el.getBoundingClientRect();
|
||||||
|
});
|
||||||
|
var mx = maxTok(active) || 1;
|
||||||
|
var sorted = active.slice().sort(function(a, b) { return (b.tokens || 0) - (a.tokens || 0) || a.name.localeCompare(b.name); });
|
||||||
|
host.innerHTML = '<h3>Token-Verbrauch pro Agent</h3>' + sorted.map(function(a) { return agentBarHtml(a, mx); }).join('');
|
||||||
|
host.querySelectorAll('[data-agent-row]').forEach(function(el) {
|
||||||
|
var key = el.getAttribute('data-agent-row');
|
||||||
|
var old = before[key];
|
||||||
|
if (!old) return;
|
||||||
|
var now = el.getBoundingClientRect();
|
||||||
|
var dy = old.top - now.top;
|
||||||
|
if (!dy) return;
|
||||||
|
el.style.transform = 'translateY(' + dy + 'px)';
|
||||||
|
requestAnimationFrame(function() { el.style.transform = ''; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateBudgetButtons() {
|
||||||
|
document.querySelectorAll('[data-budget-mode]').forEach(function(btn) {
|
||||||
|
btn.classList.toggle('active', btn.getAttribute('data-budget-mode') === budgetMode);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-donut-tab]').forEach(function(btn) {
|
||||||
|
btn.classList.toggle('active', btn.getAttribute('data-donut-tab') === donutMetric);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderBudget(rep, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
BUDGET = rep;
|
||||||
|
var rows = document.getElementById('budgetRows');
|
||||||
|
var total = document.getElementById('budgetTotal');
|
||||||
|
if (!rows || !total) return;
|
||||||
|
var scoped = applyBudgetMode(rep);
|
||||||
|
var active = scoped.agents.filter(function(a) { return a.kind && a.tokens > 0; });
|
||||||
|
updateBudgetButtons();
|
||||||
|
if (!active.length) {
|
||||||
|
rows.innerHTML = '<div class="budget-empty">no real agent token data in this ' + (budgetMode === 'session' ? 'session' : 'total range') + '</div>';
|
||||||
|
total.textContent = '';
|
||||||
|
lastDonutSignature = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
renderDonut(active, opts.forceDonut);
|
||||||
|
renderAgentBars(active);
|
||||||
|
total.innerHTML = (scoped.totals.estimated ? '~' : '') + fmtTokens(scoped.totals.tokens) +
|
||||||
|
' tok <small>\\u00b7 \\u2248 ' + fmtEur(scoped.totals.costEur) + '</small>';
|
||||||
|
}
|
||||||
|
async function refreshBudget() {
|
||||||
|
try { renderBudget(await getJSON('/budget')); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var modeBtn = e.target.closest && e.target.closest('[data-budget-mode]');
|
||||||
|
if (modeBtn) {
|
||||||
|
budgetMode = modeBtn.getAttribute('data-budget-mode') || 'session';
|
||||||
|
localStorage.setItem('agenthub-budget-mode', budgetMode);
|
||||||
|
lastDonutSignature = '';
|
||||||
|
if (BUDGET) renderBudget(BUDGET, { forceDonut: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var tabBtn = e.target.closest && e.target.closest('[data-donut-tab]');
|
||||||
|
if (tabBtn) {
|
||||||
|
donutMetric = tabBtn.getAttribute('data-donut-tab') || 'tokens';
|
||||||
|
localStorage.setItem('agenthub-donut-metric', donutMetric);
|
||||||
|
if (BUDGET) renderBudget(BUDGET, { forceDonut: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var reset = e.target.closest && e.target.closest('#budgetReset');
|
||||||
|
if (reset) {
|
||||||
|
if (BUDGET) {
|
||||||
|
writeBaseline(BUDGET);
|
||||||
|
budgetMode = 'session';
|
||||||
|
localStorage.setItem('agenthub-budget-mode', budgetMode);
|
||||||
|
lastDonutSignature = '';
|
||||||
|
renderBudget(BUDGET, { forceDonut: true });
|
||||||
|
toast('Token session reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`;
|
||||||
39
tests/boardV2-sidebar.test.ts
Normal file
39
tests/boardV2-sidebar.test.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { sidebarHtml, sidebarJs } from '../src/server/board/sidebar.js';
|
||||||
|
import { v1BudgetJs } from '../src/server/board/v1Budget.js';
|
||||||
|
|
||||||
|
describe('sidebarHtml', () => {
|
||||||
|
it('renders budget card with über-tabs and insight as default', () => {
|
||||||
|
const h = sidebarHtml();
|
||||||
|
expect(h).toContain('id="b2Budget"');
|
||||||
|
expect(h).toContain('data-otab="insight"');
|
||||||
|
expect(h).toContain('data-otab="verlauf"');
|
||||||
|
expect(h).toContain('id="budgetReset"');
|
||||||
|
expect(h).toContain('id="b2Feed"');
|
||||||
|
});
|
||||||
|
it('keeps the v1 budget mount points for the ported JS', () => {
|
||||||
|
const h = sidebarHtml();
|
||||||
|
expect(h).toContain('id="budgetRows"');
|
||||||
|
expect(h).toContain('id="budgetTotal"');
|
||||||
|
expect(h).toContain('data-budget-mode="session"');
|
||||||
|
expect(h).toContain('data-budget-mode="total"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sidebarJs', () => {
|
||||||
|
const js = sidebarJs(v1BudgetJs);
|
||||||
|
it('persists the über-tab and renders throughput', () => {
|
||||||
|
expect(js).toContain('agenthub-budget-otab'); // localStorage key
|
||||||
|
expect(js).toContain('function throughputSeries');
|
||||||
|
expect(js).toContain('function dayStart');
|
||||||
|
expect(js).toContain('__b2RenderThroughput');
|
||||||
|
expect(js).toContain('__b2FeedPush');
|
||||||
|
});
|
||||||
|
it('keeps v1 reset behaviour and session/total modes', () => {
|
||||||
|
expect(js).toContain('writeBaseline');
|
||||||
|
expect(js).toContain('agenthub-budget-mode');
|
||||||
|
expect(js).toContain('agenthub-donut-metric');
|
||||||
|
expect(js).toContain('budgetReset');
|
||||||
|
expect(js).toContain('renderBudget');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user