929 lines
38 KiB
Markdown
929 lines
38 KiB
Markdown
# 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.
|