38 KiB
AgentHub Board v2 Redesign — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Die Board-Seite (/board) bekommt das freigegebene v2-Redesign (Glass-Dashboard, animierte KPIs, Tab-Sidebar, Live-Feed, Logo + Splash) als modularisierte Neuimplementierung von src/server/board.ts — ohne Funktionsverlust.
Architecture: Fastify rendert weiterhin einen HTML-String beim Serverstart (kein Build-Step, kein Framework). src/server/board.ts (2098 Zeilen Monolith) wird ersetzt durch src/server/board/ mit Fokus-Modulen. Browser-Logik, die berechenbar ist (KPI-Modelle, Chart-Pfade, Chip-Kappung), liegt als reine TypeScript-Funktionen in viewmodel.ts — diese werden per fn.toString() in das Inline-<script> injiziert und sind so mit vitest direkt testbar. Bestehende Interaktions-JS (Drag&Drop, SSE, Live-Konsole, Modals, Budget-Baseline) wird aus dem alten board.ts portiert.
Tech Stack: TypeScript (nodenext, .js-Importe), Fastify, Vanilla JS + SSE im Browser, vitest.
Spec: docs/superpowers/specs/2026-07-20-board-v2-redesign-design.md
Visuelle Referenz (freigegebenes Mockup, enthält produktionsnahes CSS/HTML): .superpowers/brainstorm/22429-1784510154/content/board-v25-final.html — die .v25-Klasse ist beim Portieren durch .b2 (Board-v2-Namespace) zu ersetzen, Farbwerte bleiben identisch.
Wichtige Datenfakten (verifiziert):
/budget→BudgetReport { agents: AgentBudget[], totals, assumptions, generatedAt }— keine Tageshistorie. Das Verlauf-Chart zeigt daher erledigte Tasks/Tag (aus/tasksclientseitig ableitbar), nicht Tokens.- Budget-Reset ist rein clientseitig (
writeBaseline(BUDGET)+ localStorage), kein API-Call — aus altemboard.ts:1739-1749portieren. - Session/Total-Umschaltung existiert in v1 (
budgetMode, localStorageagenthub-budget-mode); Sub-Tabs Token/Kosten existieren alsdata-donut-tab(board.ts:1732-1737) — beides übernehmen. routes.ts:16importiertrenderBoardHtmlaus./board.js; der Import wird auf./board/index.jsgeändert (Verzeichnis statt Datei).- Tests laufen mit
npx vitest run tests/<file>.test.ts(Repo nutzt vitest, Tests importieren aus../src/...js).
Task 1: Logo-Asset + ViewModel-Helper (TDD)
Files:
-
Create:
assets/logo.svg -
Create:
src/server/board/viewmodel.ts -
Test:
tests/boardV2-viewmodel.test.ts -
Step 1: Logo-Datei anlegen
assets/logo.svg (Hexagon-Gradient wie im freigegebenen Mockup):
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#38bdf8"/>
<stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M12 2l8.5 5v10L12 22l-8.5-5V7z"/>
<circle cx="12" cy="12" r="3.2" fill="#0e1226"/>
</svg>
- Step 2: Failing Tests für die ViewModel-Helper schreiben
tests/boardV2-viewmodel.test.ts:
import { describe, it, expect } from 'vitest';
import {
capChips, doneStats, backlogSeries, throughputSeries, areaPath,
type KpiTask,
} from '../src/server/board/viewmodel.js';
const day = 24 * 3600 * 1000;
const iso = (msAgo: number) => new Date(Date.now() - msAgo).toISOString();
describe('capChips', () => {
it('shows all chips when at most max', () => {
const chips = [{ name: 'claude', minutes: 5 }, { name: 'codex', minutes: 2 }];
expect(capChips(chips, 3)).toEqual({ visible: chips, hidden: 0 });
});
it('caps at max and reports the hidden count', () => {
const chips = ['a', 'b', 'c', 'd', 'e'].map((name) => ({ name, minutes: 1 }));
const r = capChips(chips, 3);
expect(r.visible.map((c) => c.name)).toEqual(['a', 'b', 'c']);
expect(r.hidden).toBe(2);
});
});
describe('doneStats', () => {
it('computes share and weekly count, excluding cancelled from total', () => {
const tasks: KpiTask[] = [
{ id: '1', status: 'done', updatedAt: iso(2 * day) },
{ id: '2', status: 'done', updatedAt: iso(10 * day) },
{ id: '3', status: 'open' },
{ id: '4', status: 'cancelled' },
];
const r = doneStats(tasks);
expect(r.done).toBe(2);
expect(r.total).toBe(3); // cancelled excluded
expect(r.pct).toBe(67);
expect(r.doneThisWeek).toBe(1);
});
it('handles empty input', () => {
expect(doneStats([])).toEqual({ done: 0, total: 0, pct: 0, doneThisWeek: 0 });
});
});
describe('backlogSeries', () => {
it('returns one value per day, oldest first, ending today', () => {
const tasks: KpiTask[] = [
{ id: '1', status: 'open', createdAt: iso(3 * day) },
{ id: '2', status: 'done', createdAt: iso(10 * day), updatedAt: iso(1 * day) },
];
const s = backlogSeries(tasks, 14);
expect(s).toHaveLength(14);
expect(s[13]).toBe(1); // today: only the open task is backlog
expect(s[0]).toBe(1); // 13 days ago: only task 2 existed and was not done yet
});
});
describe('throughputSeries', () => {
it('counts done tasks per day', () => {
const tasks: KpiTask[] = [
{ id: '1', status: 'done', updatedAt: iso(0) },
{ id: '2', status: 'done', updatedAt: iso(0) },
{ id: '3', status: 'done', updatedAt: iso(5 * day) },
{ id: '4', status: 'open', updatedAt: iso(0) },
];
const s = throughputSeries(tasks, 14);
expect(s).toHaveLength(14);
expect(s[13]).toBe(2); // today
expect(s[8]).toBe(1); // 5 days ago
expect(s.reduce((a, b) => a + b, 0)).toBe(3);
});
});
describe('areaPath', () => {
it('builds line and area paths scaled to width/height', () => {
const { line, area } = areaPath([0, 5, 10], 100, 50);
expect(line).toBe('M0,50 L50,25 L100,0');
expect(area).toBe('M0,50 L50,25 L100,0 L100,50 L0,50 Z');
});
it('flattens when all values are equal (no division by zero)', () => {
const { line } = areaPath([3, 3, 3], 90, 30);
expect(line).toBe('M0,15 L45,15 L90,15');
});
});
- Step 3: Tests laufen lassen und scheitern sehen
Run: npx vitest run tests/boardV2-viewmodel.test.ts
Expected: FAIL — Cannot find module '../src/server/board/viewmodel.js'
- Step 4:
src/server/board/viewmodel.tsimplementieren
Komplette Datei. Alle Funktionen sind bewusst frei von Imports und Closures (sie werden per .toString() in die Browser-Seite injiziert); declare nur für Typen:
/**
* Pure, dependency-free helpers for the board v2 UI.
* These run BOTH in vitest (server-side) and in the browser — board/index.ts
* injects them into the inline <script> via Function.prototype.toString().
* Therefore: no imports, no closures over module state, ES2019 syntax only.
*/
export interface KpiTask {
id: string;
status?: string;
assignedTo?: string;
reviewer?: string;
createdAt?: string;
updatedAt?: string;
claimedAt?: string;
}
export interface AgentChip {
name: string;
minutes: number | null;
}
const DAY_MS = 24 * 3600 * 1000;
/** Cap a chip list at `max` visible entries, reporting how many were hidden. */
export function capChips<T>(chips: T[], max: number): { visible: T[]; hidden: number } {
const visible = chips.slice(0, Math.max(0, max));
return { visible, hidden: chips.length - visible.length };
}
/** Done-card model: share of non-cancelled tasks done + done in the last 7 days. */
export function doneStats(
tasks: KpiTask[],
now?: number,
): { done: number; total: number; pct: number; doneThisWeek: number } {
const t0 = now ?? Date.now();
const relevant = tasks.filter((t) => t.status !== 'cancelled');
const doneTasks = relevant.filter((t) => t.status === 'done');
const weekAgo = t0 - 7 * DAY_MS;
const doneThisWeek = doneTasks.filter(
(t) => t.updatedAt && new Date(t.updatedAt).getTime() >= weekAgo,
).length;
const pct = relevant.length === 0 ? 0 : Math.round((doneTasks.length / relevant.length) * 100);
return { done: doneTasks.length, total: relevant.length, pct, doneThisWeek };
}
function dayStart(ts: number): number {
const d = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/**
* Backlog (open) count for each of the last `days` days, oldest first.
* Approximation: a task counts as backlog on day D when it existed by end of D
* and was not yet moved out of 'open' (non-open tasks use updatedAt as the
* transition timestamp; still-open tasks are backlog on every day since creation).
*/
export function backlogSeries(tasks: KpiTask[], days: number, now?: number): number[] {
const today = dayStart(now ?? Date.now());
const out: number[] = [];
for (let i = days - 1; i >= 0; i--) {
const endOfDay = today - i * DAY_MS + DAY_MS - 1;
let count = 0;
for (const t of tasks) {
const created = t.createdAt ? new Date(t.createdAt).getTime() : 0;
if (created > endOfDay) continue;
if (t.status === 'open') {
count++;
} else if (t.status !== 'cancelled') {
const left = t.updatedAt ? new Date(t.updatedAt).getTime() : created;
if (left > endOfDay) count++; // was still open on that day
}
}
out.push(count);
}
return out;
}
/** Done-per-day counts for the last `days` days, oldest first. */
export function throughputSeries(tasks: KpiTask[], days: number, now?: number): number[] {
const today = dayStart(now ?? Date.now());
const out: number[] = [];
for (let i = days - 1; i >= 0; i--) {
const start = today - i * DAY_MS;
const end = start + DAY_MS - 1;
out.push(
tasks.filter(
(t) =>
t.status === 'done' &&
t.updatedAt &&
new Date(t.updatedAt).getTime() >= start &&
new Date(t.updatedAt).getTime() <= end,
).length,
);
}
return out;
}
/** Build SVG line + area path data for a value series. Values are top-anchored (max = y 0). */
export function areaPath(
values: number[],
w: number,
h: number,
): { line: string; area: string } {
if (values.length === 0) return { line: '', area: '' };
const max = Math.max(...values);
const min = Math.min(...values);
const span = max - min;
const step = values.length > 1 ? w / (values.length - 1) : 0;
const pts = values.map((v, i) => {
const x = Math.round(i * step * 100) / 100;
const y = span === 0 ? h / 2 : Math.round(((max - v) / span) * h * 100) / 100;
return `${x},${y}`;
});
const line = `M${pts.join(' L')}`;
return { line, area: `${line} L${w},${h} L0,${h} Z` };
}
/** Chip model for one status lane (in_progress → assignedTo, review → reviewer). */
export function laneChips(tasks: KpiTask[], status: string, now?: number): AgentChip[] {
const t0 = now ?? Date.now();
return tasks
.filter((t) => t.status === status)
.map((t) => {
const name = status === 'review' ? t.reviewer ?? t.assignedTo : t.assignedTo;
const since = t.claimedAt ?? t.updatedAt;
return {
name: name ?? '?',
minutes: since ? Math.max(0, Math.round((t0 - new Date(since).getTime()) / 60000)) : null,
};
})
.filter((c) => c.name !== '?');
}
- Step 5: Tests laufen lassen — grün
Run: npx vitest run tests/boardV2-viewmodel.test.ts
Expected: PASS (7 Tests). Dann zusätzlich sicherstellen, dass nichts anderes bricht: npx vitest run (Gesamtlauf) — Expected: alle bestehenden Tests grün (die neue Datei wird noch nirgends importiert).
- Step 6: Commit
git add assets/logo.svg src/server/board/viewmodel.ts tests/boardV2-viewmodel.test.ts
git commit -m "feat(board): v2 viewmodel helpers + logo asset"
Task 2: Styles-Modul (Design-Tokens, Glass, Keyframes)
Files:
-
Create:
src/server/board/styles.ts -
Reference (CSS-Quelle):
.superpowers/brainstorm/22429-1784510154/content/board-v25-final.html(.v25-Block) -
Reference (zu portierende v1-Styles):
src/server/board.ts:56-845 -
Step 1: Failing Test
tests/boardV2-styles.test.ts:
import { describe, it, expect } from 'vitest';
import { boardV2Css } from '../src/server/board/styles.js';
describe('boardV2Css', () => {
it('contains the glass surface tokens and keyframes', () => {
const css = boardV2Css();
expect(css).toContain('--b2-bg');
expect(css).toContain('rgba(255,255,255,.045)'); // glass surface
expect(css).toContain('@keyframes b2-rise');
expect(css).toContain('@keyframes b2-fill');
expect(css).toContain('@keyframes b2-ping');
});
it('respects prefers-reduced-motion', () => {
expect(boardV2Css()).toContain('@media (prefers-reduced-motion: reduce)');
});
});
Run: npx vitest run tests/boardV2-styles.test.ts — Expected: FAIL (Modul fehlt).
- Step 2:
src/server/board/styles.tsimplementieren
export function boardV2Css(): string gibt den kompletten CSS-Block als Template-String zurück. Aufbau:
- Tokens (auf
:rootder Board-Seite, Präfix--b2-):--b2-bg:#090c18,--b2-surface:rgba(255,255,255,.045),--b2-border:rgba(255,255,255,.09),--b2-text:#eef1f8,--b2-muted:#8fa0c6,--b2-accent:#38bdf8,--b2-violet:#8b5cf6,--b2-green:#34d399,--b2-amber:#fbbf24,--b2-open:#8b949e, Statusfarben identisch zu v1 (src/server/ui-shared.tsdesignTokensCss()). - Basis: Body-Hintergrund
radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%). - Komponenten: Header, KPI-Grid (4 Karten), Chips, Progress-Bar, Spalten/Karten, Sidebar-Panels, Tabs (Über + Sub), Donut, Feed, Splash-Overlay, Modals, Toasts. Das komplette, freigegebene Komponenten-CSS steht im Mockup
.superpowers/brainstorm/22429-1784510154/content/board-v25-final.htmlim<style>-Block — Klassenpräfix.v25→.b2umbenennen, Keyframesrise/fill/blink/draw/d25/ping→b2-riseetc., und auf volle App-Größe skalieren (Mockup ist Miniatur: Schriftgrößen/Abstände ×~1.3, Spalten-Mindesthöhe entfällt zugunsten echter Inhalte). - Aus v1 portieren (unverändert nötig für bestehende Interaktionen): Drag&Drop-States (
.card.dragging, Drop-Highlights), Live-Konsole in Karten, Modal-Styles, Toast-Styles,metric-flash— Quellesrc/server/board.ts:56-845. - Reduced Motion am Ende:
@media (prefers-reduced-motion: reduce) {
.b2, .b2 * { animation: none !important; transition: none !important; }
}
- Step 3: Tests grün
Run: npx vitest run tests/boardV2-styles.test.ts — Expected: PASS (2 Tests).
- Step 4: Commit
git add src/server/board/styles.ts tests/boardV2-styles.test.ts
git commit -m "feat(board): v2 glass design system css"
Task 3: Chrome-Modul (Header mit Logo + Splash Screen)
Files:
-
Create:
src/server/board/chrome.ts -
Test:
tests/boardV2-chrome.test.ts -
Step 1: Failing Test
tests/boardV2-chrome.test.ts:
import { describe, it, expect } from 'vitest';
import { headerHtml, splashHtml, splashJs } from '../src/server/board/chrome.js';
describe('headerHtml', () => {
it('renders logo, project cell with chevron, nav and new-task button', () => {
const h = headerHtml('my-project');
expect(h).toContain('/card-assets/../logo.svg'.replace('/card-assets/..', '')); // /logo.svg
expect(h).toContain('my-project');
expect(h).toContain('b2-proj'); // project cell (visual only, dropdown comes with v3)
expect(h).toContain('▾');
expect(h).toContain('+ New task');
expect(h).toContain('id="connDot"');
});
it('escapes the project name', () => {
expect(headerHtml('<b>x</b>')).not.toContain('<b>x</b>');
});
});
describe('splash', () => {
it('renders overlay with logo and wordmark', () => {
const s = splashHtml();
expect(s).toContain('id="b2-splash"');
expect(s).toContain('/logo.svg');
expect(s).toContain('agenthub');
});
it('fade-out script has a hard timeout and never blocks', () => {
const js = splashJs();
expect(js).toContain('b2-splash');
expect(js).toContain('1500'); // hard cap in ms
});
});
Run: npx vitest run tests/boardV2-chrome.test.ts — Expected: FAIL.
- Step 2:
src/server/board/chrome.tsimplementieren
Vollständige Datei:
import { escapeHtml } from '../ui-shared.js';
const LOGO = '<img src="/logo.svg" alt="" width="22" height="22" class="b2-logo-img">';
/** Fixed app header for board v2. Project cell is visual only (dropdown = v3). */
export function headerHtml(projectName: string): string {
const name = escapeHtml(projectName);
return `
<header class="b2-hdr">
<span class="b2-brand">${LOGO}<b>agenthub</b></span>
<span class="b2-proj" title="Projektwechsel kommt mit v3">/ ${name} <span class="b2-chev">▾</span></span>
<nav class="b2-nav">
<b>Board</b><a href="/team">Team</a><a href="/activity">Activity</a><a href="/decisions">Decisions</a>
</nav>
<span class="b2-hdr-right">
<button type="button" class="b2-btn" id="newTaskBtn">+ New task</button>
<span class="b2-live-dot" id="connDot" title="Live-Verbindung"></span>
</span>
</header>`;
}
/** Fullscreen splash overlay, shown until first data render (hard cap 1.5s). */
export function splashHtml(): string {
return `
<div id="b2-splash" class="b2-splash" aria-hidden="true">
<div class="b2-splash-inner">
<img src="/logo.svg" alt="agenthub" width="72" height="72" class="b2-splash-logo">
<div class="b2-splash-word">agenthub</div>
</div>
</div>`;
}
/**
* Inline script: hides the splash after the first successful refresh() or after
* a hard 1500ms cap, whichever comes first. Exposes window.__b2SplashDone() so
* the data layer can signal completion.
*/
export function splashJs(): string {
return `
(function () {
var done = false;
var el = function () { return document.getElementById('b2-splash'); };
window.__b2SplashDone = function () {
if (done) return;
done = true;
var s = el();
if (!s) return;
s.classList.add('b2-splash-hide');
setTimeout(function () { if (s.parentNode) s.parentNode.removeChild(s); }, 450);
};
setTimeout(window.__b2SplashDone, 1500); // hard cap — splash must never block
})();`;
}
Hinweis: escapeHtml existiert bereits in src/server/ui-shared.ts und wird wiederverwendet. Die Splash-CSS (b2-splash, Fade-Out per opacity-Transition + b2-splash-hide, Logo-Puls) ist Teil von Task 2; falls dort noch nicht enthalten, hier ergänzen:
.b2-splash { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center;
background: radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%);
transition: opacity .4s ease; }
.b2-splash-hide { opacity: 0; pointer-events: none; }
.b2-splash-logo { animation: b2-ping 1.6s ease-in-out infinite; }
.b2-splash-word { margin-top: 14px; font-size: 22px; font-weight: 700; letter-spacing: .02em; color: #eef1f8; }
- Step 3: Tests grün
Run: npx vitest run tests/boardV2-chrome.test.ts — Expected: PASS (4 Tests).
- Step 4: Favicon + Logo-Route sicherstellen
src/server/routes.ts: prüfen, dass GET /logo.svg ausgeliefert wird. Es existiert bereits eine Read-Only-Route GET /card-assets/* (routes.ts:64-79, Root = Repo-assets/). Kleinstmögliche Ergänzung direkt daneben:
app.get('/logo.svg', async (_req, reply) => {
reply.type('image/svg+xml');
return readFileSync(join(assetsRoot, 'logo.svg'));
});
(assetsRoot und readFileSync/join wie in der bestehenden /card-assets/*-Route verwendet — deren exakte lokalen Namen übernehmen.) Favicon im HTML-Head (Task 6, Assembly): <link rel="icon" href="/logo.svg" type="image/svg+xml">.
- Step 5: Commit
git add src/server/board/chrome.ts tests/boardV2-chrome.test.ts src/server/routes.ts
git commit -m "feat(board): v2 header, splash screen, logo route"
Task 4: KPI-Modul
Files:
-
Create:
src/server/board/kpis.ts -
Test:
tests/boardV2-kpis.test.ts -
Step 1: Failing Test
tests/boardV2-kpis.test.ts:
import { describe, it, expect } from 'vitest';
import { kpiSkeletonHtml, kpiJs } from '../src/server/board/kpis.js';
describe('kpiSkeletonHtml', () => {
it('renders the four KPI cards with stable ids', () => {
const h = kpiSkeletonHtml();
for (const id of ['kpiOpen', 'kpiInProgress', 'kpiReview', 'kpiDone']) {
expect(h).toContain(`id="${id}"`);
}
expect(h).toContain('Open');
expect(h).toContain('In Progress');
expect(h).toContain('Review');
expect(h).toContain('Done');
});
});
describe('kpiJs', () => {
it('injects the pure helpers and an updateKpis entry point', () => {
const js = kpiJs();
for (const fn of ['capChips', 'doneStats', 'backlogSeries', 'areaPath', 'laneChips']) {
expect(js).toContain(`function ${fn}`);
}
expect(js).toContain('window.__b2UpdateKpis');
});
});
Run: npx vitest run tests/boardV2-kpis.test.ts — Expected: FAIL.
- Step 2:
src/server/board/kpis.tsimplementieren
import {
capChips, doneStats, backlogSeries, areaPath, laneChips,
} from './viewmodel.js';
/** Static skeleton: four KPI cards; values are filled client-side. */
export function kpiSkeletonHtml(): string {
return `
<section class="b2-kpis" aria-label="Kennzahlen">
<div class="b2-kpi b2-glass" id="kpiOpen">
<div class="b2-lbl">Open</div>
<div class="b2-val"><span data-k="count">–</span> <small data-k="total"></small></div>
<div class="b2-miniarea" data-k="area"></div>
</div>
<div class="b2-kpi b2-glass" id="kpiInProgress">
<div class="b2-lbl">In Progress</div>
<div class="b2-val"><span data-k="count">–</span></div>
<div class="b2-chips" data-k="chips"></div>
</div>
<div class="b2-kpi b2-glass" id="kpiReview">
<div class="b2-lbl">Review</div>
<div class="b2-val"><span data-k="count">–</span></div>
<div class="b2-chips" data-k="chips"></div>
</div>
<div class="b2-kpi b2-glass" id="kpiDone">
<div class="b2-lbl">Done</div>
<div class="b2-val"><span data-k="count">–</span> <small data-k="total"></small></div>
<div class="b2-cap-r" data-k="cap"></div>
<div class="b2-pbar"><i data-k="bar"></i></div>
</div>
</section>`;
}
/**
* Inline script: injects the pure viewmodel helpers (toString) and defines
* window.__b2UpdateKpis(tasks, agentColor), called by the data layer on every
* refresh. Agent colors reuse the existing per-agent palette via the passed
* agentColor(name) function from the columns module.
*/
export function kpiJs(): string {
return `
${capChips.toString()}
${doneStats.toString()}
${backlogSeries.toString()}
${areaPath.toString()}
${laneChips.toString()}
window.__b2UpdateKpis = function (tasks, agentColor) {
var DAY = 86400000;
function setK(card, key, fn) {
var el = document.querySelector('#' + card + ' [data-k="' + key + '"]');
if (el) fn(el);
}
// Open
var open = tasks.filter(function (t) { return t.status === 'open'; });
setK('kpiOpen', 'count', function (el) { el.textContent = String(open.length); });
setK('kpiOpen', 'total', function (el) {
el.textContent = '/ ' + tasks.filter(function (t) { return t.status !== 'cancelled'; }).length;
});
setK('kpiOpen', 'area', function (el) {
var p = areaPath(backlogSeries(tasks, 14), 120, 26);
el.innerHTML = '<svg width="100%" height="26" viewBox="0 0 120 26" preserveAspectRatio="none">'
+ '<path fill="rgba(148,163,184,.18)" d="' + p.area + '"/>'
+ '<path class="b2-spark" fill="none" stroke="#94a3b8" stroke-width="1.5" d="' + p.line + '"/></svg>';
});
// In Progress + Review chips (max 3, then +n)
['in_progress', 'review'].forEach(function (status) {
var card = status === 'review' ? 'kpiReview' : 'kpiInProgress';
var chips = laneChips(tasks, status);
var capped = capChips(chips, 3);
setK(card, 'count', function (el) { el.textContent = String(chips.length); });
setK(card, 'chips', function (el) {
el.innerHTML = capped.visible.map(function (c) {
var color = agentColor ? agentColor(c.name) : '#a5b4fc';
var initial = c.name.charAt(0).toUpperCase();
var time = c.minutes == null ? '' : (status === 'review' ? 'prüft ' : '') + c.minutes + 'm';
var dot = status === 'in_progress' ? '<span class="b2-cdot"></span>' : '';
return '<span class="b2-achip"><i style="background:' + color + '">' + initial + '</i>' + dot + time + '</span>';
}).join('') + (capped.hidden > 0 ? '<span class="b2-achip b2-more">+' + capped.hidden + '</span>' : '');
});
});
// Done
var ds = doneStats(tasks);
setK('kpiDone', 'count', function (el) { el.textContent = String(ds.done); });
setK('kpiDone', 'total', function (el) { el.textContent = '/ ' + ds.total; });
setK('kpiDone', 'cap', function (el) {
el.textContent = ds.pct + '% · +' + ds.doneThisWeek + ' diese Woche';
});
setK('kpiDone', 'bar', function (el) { el.style.width = ds.pct + '%'; });
};`;
}
- Step 3: Tests grün
Run: npx vitest run tests/boardV2-kpis.test.ts — Expected: PASS (2 Tests).
- Step 4: Commit
git add src/server/board/kpis.ts tests/boardV2-kpis.test.ts
git commit -m "feat(board): v2 kpi cards (area chart, capped chips, done bar)"
Task 5: Sidebar-Modul (Budget-Tabs, Donuts, Verlauf-Chart, Live-Feed)
Files:
-
Create:
src/server/board/sidebar.ts -
Reference (zu portierende v1-Logik):
src/server/board.ts:1548-1749(renderBudget,renderDonut,renderAgentBars,writeBaseline, Session/Total,data-donut-tab, Reset-Handler) -
Test:
tests/boardV2-sidebar.test.ts -
Step 1: Failing Test
tests/boardV2-sidebar.test.ts:
import { describe, it, expect } from 'vitest';
import { sidebarHtml, sidebarJs } from '../src/server/board/sidebar.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"');
});
});
describe('sidebarJs', () => {
it('persists the über-tab, keeps v1 reset behaviour and renders throughput', () => {
const js = sidebarJs();
expect(js).toContain('agenthub-budget-otab'); // localStorage key
expect(js).toContain('writeBaseline'); // v1 reset port
expect(js).toContain('function throughputSeries');
expect(js).toContain('__b2UpdateSidebar');
});
});
Run: npx vitest run tests/boardV2-sidebar.test.ts — Expected: FAIL.
- Step 2:
src/server/board/sidebar.tsimplementieren
import { throughputSeries, areaPath } from './viewmodel.js';
/**
* Sidebar skeleton: budget card (über-tabs Token Insights | Verlauf; insight is
* the default) + live feed card. Donut/bars markup is rendered client-side by
* the ported v1 renderBudget() — the skeleton only provides the mount points.
*/
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">
<div class="b2-stabs" id="donutTabs"></div>
<div class="seg" id="budgetSeg"></div>
<button type="button" class="reset-btn" id="budgetReset">Reset</button>
</div>
<div id="budgetBody"></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, plus the PORTED v1 budget code (donut, agent bars,
* session/total, baseline reset) — see plan Task 5 Step 3 for the port source.
*/
export function sidebarJs(v1BudgetJs: string): string {
return `
${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}`;
}
- Step 3: v1-Budget-JS portieren
Aus src/server/board.ts:1548-1749 die Funktionen renderBudget, renderDonut, renderAgentBars, writeBaseline, die Session/Total-Logik (budgetMode, localStorage agenthub-budget-mode), die Sub-Tab-Logik (data-donut-tab, localStorage agenthub-donut-metric) und den Reset-Handler (board.ts:1739-1749) unverändert in einen String-Block übernehmen und als v1BudgetJs an sidebarJs() übergeben (beim Assembly in Task 6). Einzige Anpassung: das v1-Markup mountet jetzt in #budgetBody / #donutTabs / #budgetSeg statt der alten Container-IDs; die Sub-Tabs bekommen die Labels „Token" / „Kosten". Donut-Center-Text (Fix aus v1, SVG <text> bei cx=110 cy=122) beibehalten.
- Step 4: Tests grün
Run: npx vitest run tests/boardV2-sidebar.test.ts — Expected: PASS (2 Tests).
- Step 5: Commit
git add src/server/board/sidebar.ts tests/boardV2-sidebar.test.ts
git commit -m "feat(board): v2 sidebar with tabbed budget card, throughput chart, live feed"
Task 6: Assembly + Spalten/Karten/Modals-Port + Verdrahtung
Files:
-
Create:
src/server/board/index.ts -
Modify:
src/server/routes.ts:16(Import auf./board/index.js) -
Delete:
src/server/board.ts(nach erfolgreichem Port) -
Reference (Port-Quelle):
src/server/board.tskomplett — insb.847-973(Skeleton),975-2094(JS:taskCard()~1123,refresh()~1373, Drag&Drop, SSEtask-log, Modals, Toasts,metric-flash, Lottie-Init 2034-2094) -
Step 1: Failing Test
tests/boardV2.test.ts:
import { describe, it, expect } from 'vitest';
import { renderBoardHtml } from '../src/server/board/index.js';
describe('renderBoardHtml (v2)', () => {
const html = renderBoardHtml('demo-project');
it('is a full html document with favicon and splash', () => {
expect(html).toMatch(/^<!doctype html>/i);
expect(html).toContain('rel="icon" href="/logo.svg"');
expect(html).toContain('id="b2-splash"');
});
it('contains header, kpis, columns and sidebar mount points', () => {
for (const s of ['b2-hdr', 'demo-project', 'id="kpiOpen"', 'id="b2Budget"', 'id="b2Feed"']) {
expect(html).toContain(s);
}
});
it('keeps the v1 interaction surface (dnd, sse, modals, budget reset)', () => {
for (const s of ['draggable', "new EventSource('/events')", 'task-log', 'budgetReset', 'newTaskModal']) {
expect(html).toContain(s);
}
});
it('does not ship the old v1 metric cards or lottie kpi icons', () => {
expect(html).not.toContain('metric-card');
});
});
Run: npx vitest run tests/boardV2.test.ts — Expected: FAIL (Modul fehlt).
- Step 2:
src/server/board/index.tsimplementieren
Struktur (die Lücken columnsJs()/modalsJs() kommen aus Step 3):
import { boardV2Css } from './styles.js';
import { headerHtml, splashHtml, splashJs } from './chrome.js';
import { kpiSkeletonHtml, kpiJs } from './kpis.js';
import { sidebarHtml, sidebarJs } from './sidebar.js';
import { columnsHtml, columnsJs, modalsHtml, modalsJs, dataLayerJs } from './columns.js';
import { v1BudgetJs } from './v1Budget.js';
export const BOARD_COLUMNS = [
{ key: 'open', label: 'Open' },
{ key: 'in_progress', label: 'In Progress' },
{ key: 'review', label: 'Review' },
];
export function renderBoardHtml(projectName = 'AgentHub Project'): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>agenthub — Board</title>
<link rel="icon" href="/logo.svg" type="image/svg+xml">
<style>${boardV2Css()}</style>
</head>
<body class="b2">
${splashHtml()}
${headerHtml(projectName)}
<main class="b2-body">
<div class="b2-main">
${kpiSkeletonHtml()}
${columnsHtml(BOARD_COLUMNS)}
</div>
${sidebarHtml()}
</main>
${modalsHtml()}
<div id="toasts"></div>
<script>${splashJs()}</script>
<script>${dataLayerJs()}${kpiJs()}${sidebarJs(v1BudgetJs)}${columnsJs()}${modalsJs()}</script>
</body>
</html>`;
}
-
Step 3:
columns.ts+v1Budget.tsaus dem alten board.ts portieren -
src/server/board/v1Budget.ts:export const v1BudgetJs = \...`` mit dem Budget-Block aus Task 5 Step 3. -
src/server/board/columns.tsexportiertcolumnsHtml(columns)(3 Spalten-Skeleton, Glass-Panels, Status-Punkt + Count-Pill — Markup aus Mockup v2.5),columnsJs()(Port aus altemboard.ts:taskCard()~1123 inkl. Live-Konsole/Timer/Agent-Tag/Reviewer-Badge, Drag&Drop mit Race-Guard-Revert, Agent-Chip-Drop,metric-flash-Wertänderungspuls — nicht die Lottie-KPI-Icons, die entfallen),modalsHtml()/modalsJs()(New-Task-, Delete-, Task-Detail-Modal, unveränderte Logik),dataLayerJs()(refresh()~1373: fetch/tasks+/agents, SSE/events, SSEtask-log; nach jedem erfolgreichenrefresh()Aufruf vonwindow.__b2UpdateKpis(tasks, agentColor),window.__b2RenderThroughput(tasks)und einmaligwindow.__b2SplashDone(); SSE-Events zusätzlich anwindow.__b2FeedPush(...)als kompakte einzeilige Meldung, z.B.TSK-0145 → in_progress). -
Karten-Enter-Animation: wie v1 nur für
.card-new(Kommentar im alten Code ~663-666 beachten), damit Poll-Updates nicht flackern. -
agentColor(name): bestehende Agenten-Farbpalette aus dem alten board.ts übernehmen (claude#D97757, codex#10A37Fetc.) und dem KPI-Modul als Funktion bereitstellen. -
Step 4: Verdrahtung + altes board.ts löschen
src/server/routes.ts:16:import { renderBoardHtml } from './board.js';→import { renderBoardHtml } from './board/index.js';src/server/board.tslöschen.- Prüfen, dass keine andere Datei mehr
./board.js(Datei) importiert:grep -rn "from '.*board\.js'" src tests— erwartete Treffer nurboard/index.jsundboard/*.js-interne Importe.
- Step 5: Build + volle Testsuite
Run: npx tsc --noEmit — Expected: keine Fehler.
Run: npx vitest run — Expected: gesamte Suite grün (inkl. tests/boardV2*.test.ts; bestehende Tests, die v1-board-Strings assertieren, sind ggf. an die neuen Mount-Points anzupassen — nur die Assertions aktualisieren, keine Testlogik ändern).
- Step 6: Manueller Check
pnpm build 2>/dev/null || npx tsc
node bin/agenthub.js server start
# Browser: http://127.0.0.1:3377/board
Checkliste: Splash erscheint und faded aus · KPI-Karten füllen sich (Chips max 3 + „+n") · Open-Area-Chart zeichnet sich · Done-Bar animiert · Über-Tabs (Token Insights ↔ Verlauf) + Sub-Tabs (Token ↔ Kosten) + Session/Total + Reset funktionieren wie v1 · Drag&Drop Statuswechsel · Agent-Zuweisung per Drop · Live-Konsole läuft · Live-Feed zeigt Events · SSE-Dot grün · prefers-reduced-motion (DevTools-Emulation) schaltet Animationen ab.
- Step 7: Commit
git add src/server/board/ src/server/routes.ts tests/boardV2.test.ts
git rm src/server/board.ts 2>/dev/null || true
git commit -m "feat(board): replace v1 monolith with modular v2 glass dashboard"
Self-Review (ausgeführt)
- Spec-Abdeckung: Logo (T1/T3), Splash (T3), Header m. Projektzelle (T3), KPIs inkl. aller 4 Karten-Designs (T1+T4), Spalten/Karten/Drag&Drop/Konsole/Modals (T6), Sidebar-Tabs inkl. Reset + Session/Total + Sub-Tabs (T5), Verlauf-Chart (T5, als Tasks/Tag —
/budgethat verifiziert keine Historie; Spec-Fallback greift), Live-Feed (T5/T6), Modularisierung (T2-T6), reduced-motion (T2), Lottie-Entfall dokumentiert (T6 Step 1 Test + Step 3), Multi-Projekt nur visuell (T3, v3-Spec). Keine Lücken. - Placeholder-Scan: Task 2 Step 2 und Task 6 Step 3 sind bewusst Port-Anweisungen mit exakten Quell-Referenzen (Datei + Zeilen) statt dupliziertem Code — der Code existiert bereits in
src/server/board.tsbzw. im Mockup; keine TBDs. - Typ-Konsistenz:
capChips,doneStats,backlogSeries,throughputSeries,areaPath,laneChips,KpiTask,AgentChipin T1 definiert und in T4/T5 identisch verwendet;window.__b2UpdateKpis/__b2RenderThroughput/__b2FeedPush/__b2SplashDonekonsistent zwischen T3/T4/T5/T6.