docs(board): v2 redesign spec + implementation plan
This commit is contained in:
parent
b09e4c78d2
commit
2b742706f2
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@ dist/
|
||||
coverage/
|
||||
.agenthub/
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
928
docs/superpowers/plans/2026-07-20-board-v2-redesign.md
Normal file
928
docs/superpowers/plans/2026-07-20-board-v2-redesign.md
Normal file
@ -0,0 +1,928 @@
|
||||
# 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.
|
||||
104
docs/superpowers/specs/2026-07-20-board-v2-redesign-design.md
Normal file
104
docs/superpowers/specs/2026-07-20-board-v2-redesign-design.md
Normal file
@ -0,0 +1,104 @@
|
||||
# AgentHub Board v2 — Redesign (Spec)
|
||||
|
||||
Datum: 2026-07-20
|
||||
Status: freigegeben durch User (Mockup-Iterationen v2 → v2.5 im Visual Companion)
|
||||
|
||||
## Ziel
|
||||
|
||||
Die Board-Seite (`/board`) sieht aus wie ein echtes, modernes Dashboard: Glas-Optik (Richtung „Modern SaaS / Glass"), dezente Animationen, mehr Daten-Leben (Charts, Live-Feed), Logo + Splash Screen. **Alle vorhandenen Funktionen bleiben erhalten** (KPIs, Drag & Drop, Agent-Zuweisung, Live-Konsole in Karten, Modals, Token-Budget, SSE). Die Team-Seite bleibt unverändert.
|
||||
|
||||
## Nicht-Ziele (Out of Scope)
|
||||
|
||||
- Multi-Projekt-Architektur (Projekt anlegen/wechseln per Dropdown) → separater v3-Spec. In v2 nur visuell vorbereitet: Projektname im Header mit Chevron (`▾`), ohne Funktion.
|
||||
- Verlaufs-Charts auf der Activity-Page (später).
|
||||
- Kein Frontend-Framework (bewusste Entscheidung: Fastify + String-Templates + Vanilla JS + SSE, kein Build-Step).
|
||||
|
||||
## Architektur-Entscheidung
|
||||
|
||||
`src/server/board.ts` (aktuell ~2100 Zeilen, monolithisch, nutzt `ui-shared.ts` nicht) wird in Module zerlegt, ohne das Serving-Modell zu ändern (Fastify rendert HTML-String beim Start, Daten via `/tasks`, `/agents`, `/budget`, SSE `/events`):
|
||||
|
||||
```
|
||||
src/server/board/
|
||||
index.ts — renderBoardHtml(projectName), orchestriert Module
|
||||
styles.ts — v2 Design-Tokens + Glass-CSS + Animations-Keyframes
|
||||
header.ts — Header (Logo, Projektzelle mit Chevron, Nav, New-Task, SSE-Dot)
|
||||
kpis.ts — KPI-Reihe (Markup + Update-JS)
|
||||
columns.ts — Spalten + Task-Karten + Drag&Drop + Live-Konsole
|
||||
sidebar.ts — Budget-Card (Tabs) + Live-Feed
|
||||
splash.ts — Splash-Screen (Markup + CSS + JS)
|
||||
modals.ts — New-Task / Delete / Task-Detail (unveränderte Logik, neues Styling)
|
||||
assets/
|
||||
logo.svg — AgentHub-Logo (Hexagon-Gradient, Cyan #38bdf8 → Violett #8b5cf6)
|
||||
```
|
||||
|
||||
Design-Tokens werden an `ui-shared.ts` angeglichen (gleiche Farbwerte, Glass-Surface als `--surface` etc.), damit Board und die übrigen Seiten konsistent bleiben. Team-Page wird nicht angefasst.
|
||||
|
||||
## Layout (wie Mockup v2.5)
|
||||
|
||||
Dunkler Gradient-Hintergrund (`radial-gradient`, #1c2547 → #0e1226 → #090c18). Glass-Surfaces: `rgba(255,255,255,.045)`, Border `rgba(255,255,255,.09)`, Radius 12px.
|
||||
|
||||
### Header
|
||||
- Logo (neu, SVG, Hexagon-Gradient), `agenthub` Wortmarke
|
||||
- Projektzelle `/ <projektname> ▾` (nur Optik; Dropdown-Funktion kommt mit v3)
|
||||
- Nav: Board (aktiv) / Team / Activity / Decisions
|
||||
- Rechts: `+ New task` Button (Indigo→Violett-Gradient), SSE-Live-Dot (grün, ping-Animation)
|
||||
|
||||
### KPI-Reihe (4 Karten, nur über dem Board-Bereich, nicht über Sidebar)
|
||||
1. **Open** — Zahl `/ gesamt`, Mini-Area-Chart (Backlog-Verlauf, 14 Tage, aus `/tasks` abgeleitet), Grau
|
||||
2. **In Progress** — Zahl, Agent-Chips: max. 3 sichtbar, dann `+n`-Chip; jeder Chip: Avatar (Agent-Farbe), Live-Punkt (pulsierend), Laufzeit
|
||||
3. **Review** — Zahl, gleiche Chip-Darstellung (Reviewer), max. 3 + `+n`
|
||||
4. **Done** — Zahl `/ gesamt`, Progress-Bar (Anteil erledigt, Grün-Gradient, animierter Fill), Beschreibung („63% · +5 diese Woche") klein rechtsbündig direkt über dem Bar-Ende
|
||||
|
||||
Karten erscheinen gestaffelt (rise-Animation, 70ms Versatz).
|
||||
|
||||
### Board-Spalten (unveränderte Funktion)
|
||||
3 Spalten (Open / In Progress / Review) als Glass-Panels, Header mit Status-Punkt + Count-Pill. Task-Karten: Glass, Hover-Lift, Status-Farbe am linken Rand (dezent), Agent-Tag, Reviewer-Badge, Timer, Glow (`rgba(56,189,248,.35)` Border + Shadow) auf aktiv bearbeiteten Karten. **In-Progress-Karten zeigen einen Progress-Ring** (SVG-Radial, Füllung = verstrichene Zeit / geschätzte Dauer, Mitte = tickende verstrichene Zeit). Schätzung = Median der realen Bearbeitungsdauer der letzten erledigten Tasks (`claimedAt`→`updatedAt`, clientseitig aus `/tasks`); Fallback = Zeit-Kappe analog `MAX_LIVE_ESTIMATE_MIN_PER_TASK` aus `budgetService`. Über der Schätzung kippt der Ring von Cyan nach Amber. (Änderung gegenüber Mockup: ersetzt den dortigen Fantasie-Prozentbalken — ein echter Prozentwert existiert in den Daten nicht.) Drag&Drop (Status) + Agent-Chip-Drop (Zuweisung) + Live-Konsole bleiben funktional identisch.
|
||||
|
||||
### Sidebar rechts
|
||||
**Budget-Card** mit zwei Ebenen, immer genau ein Chart sichtbar:
|
||||
- Über-Tabs: `Token Insights` (Default) | `Verlauf`
|
||||
- Token Insights: Sub-Tabs `Token` | `Kosten`, Session/Total-Segmented-Control und **Reset-Button** wie v1
|
||||
- Token: Half-Donut (animierter Arc) + Per-Agent-Bars (Agent-Farben, animierter Fill)
|
||||
- Kosten: Half-Donut + Firmen-Legende (Anthropic/OpenAI/Moonshot mit Farbpunkten)
|
||||
- Verlauf: Area-Chart (Tokens, 14 Tage) mit Sollwert-Zeile („Ø x/Tag")
|
||||
- Tab-Persistenz in `localStorage`
|
||||
|
||||
**Live-Feed-Card** darunter: letzte ~5 Events aus SSE `/events` (kompakte Zeilen, pulsierender Punkt beim neuesten).
|
||||
|
||||
### Splash Screen
|
||||
Beim Laden von `/board`: Fullscreen-Overlay im Hintergrund-Gradient, zentriertes Logo mit Einblend-/Puls-Animation + „agenthub" Schriftzug; nach DOMContentLoaded + erstem Daten-Fetch (oder max. ~1,2s) smooth ausfaden und Dashboard einblenden. Kein Blockieren der eigentlichen Daten-Requests.
|
||||
|
||||
### Logo
|
||||
`assets/logo.svg`: Hexagon mit Cyan→Violett-Verlauf, ausgestanzter Kern (Knoten-Metapher, passt zum Agenten-Netzwerk). Verwendet in Header, Splash, Favicon.
|
||||
|
||||
## Animationen (dezent, respektiert `prefers-reduced-motion`)
|
||||
- `rise` (opacity + translateY 8px, 0,5–0,6s) für Karten beim ersten Render — nicht bei SSE-/Poll-Updates (wie bisher nur `.card-new`)
|
||||
- `fill` (width 0 → Ziel) für Bars, `draw` (stroke-dashoffset) für Charts/Arcs
|
||||
- `ping` für Live-Dot, `blink` für Status-Punkte
|
||||
- KPI-Wertänderung: kurzer Flash (bestehendes `metric-flash`-Verhalten beibehalten)
|
||||
- Keine Animation bei `prefers-reduced-motion: reduce`
|
||||
|
||||
## Datenfluss
|
||||
Unverändert: initiales `refresh()` (`GET /tasks`, `/agents`, `/budget`), SSE `/events` für Live-Updates, `task-log` SSE für Karten-Konsole, 3s-Polling für Budget. Neu abgeleitet aus vorhandenen Daten:
|
||||
- Backlog-Mini-Area (Open): Open-Count pro Tag der letzten 14 Tage aus Task-`createdAt`/Status-Historie — falls nicht aus den vorhandenen Endpunkten ableitbar, aus `/tasks` (created/status/updated) clientseitig approximiert; kein neuer Endpunkt in v2 (Fallback: flache Linie mit aktuellem Wert).
|
||||
- Verlauf-Area-Chart (Tokens/Tag): aus `/budget`-Historie, falls vorhanden; sonst gleiche Fallback-Strategie. Implementierung klärt, was `/budget` tatsächlich liefert.
|
||||
- „+5 diese Woche" bei Done: aus `/tasks` (done + `updatedAt` in den letzten 7 Tagen).
|
||||
|
||||
## Fehlerbehandlung
|
||||
- SSE-Reconnect-Verhalten wie v1 (Verbindungs-Dot im Header).
|
||||
- Wenn Budget-Endpunkt leer/fehlschlägt: Sidebar-Panels zeigen vorhandene v1-Leerzustände.
|
||||
- Splash hat harte Obergrenze (~1,5s), damit er nie blockiert.
|
||||
|
||||
## Testing
|
||||
- Bestehende Tests (`tests/`) müssen grün bleiben (Board-Rendering ist String-basiert; Snapshot-/String-Assertions ggf. anpassen).
|
||||
- Neue Unit-Tests für: `kpis.ts` (Chip-Kappung 3+`+n`, Prozent-Berechnung Done, „+diese Woche"), `sidebar.ts` (Tab-Default, Reset-Button-Markup), `splash.ts` (Overlay-Markup, reduced-motion Guard), Logo-Einbettung im Header.
|
||||
- Manueller Check: `agenthub server start` → `/board` im Browser (Splash, Tabs, Drag&Drop, Live-Updates).
|
||||
|
||||
## Entscheidungen (Log aus dem Brainstorming)
|
||||
- Design-Richtung: „Glass / Modern SaaS" (Basis B), kein Nuxt/Vue (Overkill, widerspricht Simplicity-Ziel)
|
||||
- Active/Open zu einer Karte „Open" verschmolzen (Kanban-Struktur: Open / In Progress / Review / Done)
|
||||
- Sparklines verworfen zugunsten: Mini-Area (Open), Progress-Bar (Done), Chips (In Progress/Review)
|
||||
- Chips statt Avatar-Stacks, gekappt bei 3 + `+n`
|
||||
- Verschachtelte Tabs rechts (Token Insights default), Reset-Button wie v1
|
||||
- Verlaufs-Charts später ggf. auf Activity-Page
|
||||
- Multi-Projekt = v3 (eigener Spec)
|
||||
Loading…
x
Reference in New Issue
Block a user