agenthub/docs/superpowers/plans/2026-07-20-board-v2-redesign.md

929 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `/tasks` clientseitig ableitbar), nicht Tokens.
- Budget-Reset ist rein clientseitig (`writeBaseline(BUDGET)` + localStorage), kein API-Call — aus altem `board.ts:1739-1749` portieren.
- Session/Total-Umschaltung existiert in v1 (`budgetMode`, localStorage `agenthub-budget-mode`); Sub-Tabs Token/Kosten existieren als `data-donut-tab` (`board.ts:1732-1737`) — beides übernehmen.
- `routes.ts:16` importiert `renderBoardHtml` aus `./board.js`; der Import wird auf `./board/index.js` geä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
<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`:
```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.ts` implementieren**
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:
```ts
/**
* 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**
```bash
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`:
```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.ts` implementieren**
`export function boardV2Css(): string` gibt den kompletten CSS-Block als Template-String zurück. Aufbau:
1. **Tokens** (auf `:root` der 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.ts` `designTokensCss()`).
2. **Basis**: Body-Hintergrund `radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%)`.
3. **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.html` im `<style>`-Block — Klassenpräfix `.v25``.b2` umbenennen, Keyframes `rise/fill/blink/draw/d25/ping``b2-rise` etc., und auf volle App-Größe skalieren (Mockup ist Miniatur: Schriftgrößen/Abstände ×~1.3, Spalten-Mindesthöhe entfällt zugunsten echter Inhalte).
4. **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` — Quelle `src/server/board.ts:56-845`.
5. **Reduced Motion** am Ende:
```css
@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**
```bash
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`:
```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.ts` implementieren**
Vollständige Datei:
```ts
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:
```css
.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:
```ts
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**
```bash
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`:
```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.ts` implementieren**
```ts
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**
```bash
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`:
```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.ts` implementieren**
```ts
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**
```bash
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.ts` komplett — insb. `847-973` (Skeleton), `975-2094` (JS: `taskCard()` ~1123, `refresh()` ~1373, Drag&Drop, SSE `task-log`, Modals, Toasts, `metric-flash`, Lottie-Init 2034-2094)
- [ ] **Step 1: Failing Test**
`tests/boardV2.test.ts`:
```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.ts` implementieren**
Struktur (die Lücken `columnsJs()`/`modalsJs()` kommen aus Step 3):
```ts
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.ts` aus 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.ts` exportiert `columnsHtml(columns)` (3 Spalten-Skeleton, Glass-Panels, Status-Punkt + Count-Pill — Markup aus Mockup v2.5), `columnsJs()` (Port aus altem `board.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`, SSE `task-log`; nach jedem erfolgreichen `refresh()` Aufruf von `window.__b2UpdateKpis(tasks, agentColor)`, `window.__b2RenderThroughput(tasks)` und einmalig `window.__b2SplashDone()`; SSE-Events zusätzlich an `window.__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 `#10A37F` etc.) und dem KPI-Modul als Funktion bereitstellen.
- [ ] **Step 4: Verdrahtung + altes board.ts löschen**
1. `src/server/routes.ts:16`: `import { renderBoardHtml } from './board.js';``import { renderBoardHtml } from './board/index.js';`
2. `src/server/board.ts` löschen.
3. Prüfen, dass keine andere Datei mehr `./board.js` (Datei) importiert: `grep -rn "from '.*board\.js'" src tests` — erwartete Treffer nur `board/index.js` und `board/*.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**
```bash
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**
```bash
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 — `/budget` hat 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.ts` bzw. im Mockup; keine TBDs.
- **Typ-Konsistenz:** `capChips`, `doneStats`, `backlogSeries`, `throughputSeries`, `areaPath`, `laneChips`, `KpiTask`, `AgentChip` in T1 definiert und in T4/T5 identisch verwendet; `window.__b2UpdateKpis` / `__b2RenderThroughput` / `__b2FeedPush` / `__b2SplashDone` konsistent zwischen T3/T4/T5/T6.