diff --git a/src/server/board/sidebar.ts b/src/server/board/sidebar.ts new file mode 100644 index 0000000..90f02ee --- /dev/null +++ b/src/server/board/sidebar.ts @@ -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 ` +`; +} + +/** + * 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 = '' + + '' + + ''; + 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}`; +} diff --git a/src/server/board/v1Budget.ts b/src/server/board/v1Budget.ts new file mode 100644 index 0000000..13d62b4 --- /dev/null +++ b/src/server/board/v1Budget.ts @@ -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 = ''; + 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 += ''; + }); + if (!rows.length) paths += ''; + var legend = rows.length ? rows.map(function(row) { + var pct = total > 0 ? Math.round((row.value / total) * 100) : 0; + return '
' + + '' + esc(row.name) + '' + + '' + esc(formatValue(row.value)) + ' · ' + pct + '%
'; + }).join('') : '
no provider data
'; + return '
' + + '

' + esc(title) + '

' + + '
' + + '
' + + '' + paths + + '0' + + '' + esc(centerLabel) + '' + + '' + + '
' + + '
' + legend + '
' + + '
'; + } + 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 ? '~ estimated' : 'real'; + var pct = mx > 0 ? Math.round(((a.tokens || 0) / mx) * 1000) / 10 : 0; + var company = companySpec(a.kind); + return '
' + + '
' + esc(spec.initials) + '' + + '' + esc(a.name) + '' + + '' + esc(company.name + (a.model ? ' · ' + a.model : '')) + '
' + + '
' + + '
' + fmtTokens(a.tokens) + ' tok' + est + ' · ' + fmtEur(a.costEur) + '
' + + '
'; + } + 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 = '

Token-Verbrauch pro Agent

' + 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 = '
no real agent token data in this ' + (budgetMode === 'session' ? 'session' : 'total range') + '
'; + total.textContent = ''; + lastDonutSignature = ''; + return; + } + if (!document.getElementById('donutWrap') || !document.getElementById('agentBars')) { + rows.innerHTML = '
'; + } + renderDonut(active, opts.forceDonut); + renderAgentBars(active); + total.innerHTML = (scoped.totals.estimated ? '~' : '') + fmtTokens(scoped.totals.tokens) + + ' tok \\u00b7 \\u2248 ' + fmtEur(scoped.totals.costEur) + ''; + } + 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'); + } + } + }); +`; diff --git a/tests/boardV2-sidebar.test.ts b/tests/boardV2-sidebar.test.ts new file mode 100644 index 0000000..d03a630 --- /dev/null +++ b/tests/boardV2-sidebar.test.ts @@ -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'); + }); +});