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

38 KiB
Raw Blame History

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):

  • /budgetBudgetReport { 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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
  <defs>
    <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0" stop-color="#38bdf8"/>
      <stop offset="1" stop-color="#8b5cf6"/>
    </linearGradient>
  </defs>
  <path fill="url(#g)" d="M12 2l8.5 5v10L12 22l-8.5-5V7z"/>
  <circle cx="12" cy="12" r="3.2" fill="#0e1226"/>
</svg>
  • Step 2: Failing Tests für die ViewModel-Helper schreiben

tests/boardV2-viewmodel.test.ts:

import { describe, it, expect } from 'vitest';
import {
  capChips, doneStats, backlogSeries, throughputSeries, areaPath,
  type KpiTask,
} from '../src/server/board/viewmodel.js';

const day = 24 * 3600 * 1000;
const iso = (msAgo: number) => new Date(Date.now() - msAgo).toISOString();

describe('capChips', () => {
  it('shows all chips when at most max', () => {
    const chips = [{ name: 'claude', minutes: 5 }, { name: 'codex', minutes: 2 }];
    expect(capChips(chips, 3)).toEqual({ visible: chips, hidden: 0 });
  });
  it('caps at max and reports the hidden count', () => {
    const chips = ['a', 'b', 'c', 'd', 'e'].map((name) => ({ name, minutes: 1 }));
    const r = capChips(chips, 3);
    expect(r.visible.map((c) => c.name)).toEqual(['a', 'b', 'c']);
    expect(r.hidden).toBe(2);
  });
});

describe('doneStats', () => {
  it('computes share and weekly count, excluding cancelled from total', () => {
    const tasks: KpiTask[] = [
      { id: '1', status: 'done', updatedAt: iso(2 * day) },
      { id: '2', status: 'done', updatedAt: iso(10 * day) },
      { id: '3', status: 'open' },
      { id: '4', status: 'cancelled' },
    ];
    const r = doneStats(tasks);
    expect(r.done).toBe(2);
    expect(r.total).toBe(3); // cancelled excluded
    expect(r.pct).toBe(67);
    expect(r.doneThisWeek).toBe(1);
  });
  it('handles empty input', () => {
    expect(doneStats([])).toEqual({ done: 0, total: 0, pct: 0, doneThisWeek: 0 });
  });
});

describe('backlogSeries', () => {
  it('returns one value per day, oldest first, ending today', () => {
    const tasks: KpiTask[] = [
      { id: '1', status: 'open', createdAt: iso(3 * day) },
      { id: '2', status: 'done', createdAt: iso(10 * day), updatedAt: iso(1 * day) },
    ];
    const s = backlogSeries(tasks, 14);
    expect(s).toHaveLength(14);
    expect(s[13]).toBe(1); // today: only the open task is backlog
    expect(s[0]).toBe(1); // 13 days ago: only task 2 existed and was not done yet
  });
});

describe('throughputSeries', () => {
  it('counts done tasks per day', () => {
    const tasks: KpiTask[] = [
      { id: '1', status: 'done', updatedAt: iso(0) },
      { id: '2', status: 'done', updatedAt: iso(0) },
      { id: '3', status: 'done', updatedAt: iso(5 * day) },
      { id: '4', status: 'open', updatedAt: iso(0) },
    ];
    const s = throughputSeries(tasks, 14);
    expect(s).toHaveLength(14);
    expect(s[13]).toBe(2); // today
    expect(s[8]).toBe(1); // 5 days ago
    expect(s.reduce((a, b) => a + b, 0)).toBe(3);
  });
});

describe('areaPath', () => {
  it('builds line and area paths scaled to width/height', () => {
    const { line, area } = areaPath([0, 5, 10], 100, 50);
    expect(line).toBe('M0,50 L50,25 L100,0');
    expect(area).toBe('M0,50 L50,25 L100,0 L100,50 L0,50 Z');
  });
  it('flattens when all values are equal (no division by zero)', () => {
    const { line } = areaPath([3, 3, 3], 90, 30);
    expect(line).toBe('M0,15 L45,15 L90,15');
  });
});
  • Step 3: Tests laufen lassen und scheitern sehen

Run: npx vitest run tests/boardV2-viewmodel.test.ts Expected: FAIL — Cannot find module '../src/server/board/viewmodel.js'

  • Step 4: src/server/board/viewmodel.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:

/**
 * Pure, dependency-free helpers for the board v2 UI.
 * These run BOTH in vitest (server-side) and in the browser — board/index.ts
 * injects them into the inline <script> via Function.prototype.toString().
 * Therefore: no imports, no closures over module state, ES2019 syntax only.
 */

export interface KpiTask {
  id: string;
  status?: string;
  assignedTo?: string;
  reviewer?: string;
  createdAt?: string;
  updatedAt?: string;
  claimedAt?: string;
}

export interface AgentChip {
  name: string;
  minutes: number | null;
}

const DAY_MS = 24 * 3600 * 1000;

/** Cap a chip list at `max` visible entries, reporting how many were hidden. */
export function capChips<T>(chips: T[], max: number): { visible: T[]; hidden: number } {
  const visible = chips.slice(0, Math.max(0, max));
  return { visible, hidden: chips.length - visible.length };
}

/** Done-card model: share of non-cancelled tasks done + done in the last 7 days. */
export function doneStats(
  tasks: KpiTask[],
  now?: number,
): { done: number; total: number; pct: number; doneThisWeek: number } {
  const t0 = now ?? Date.now();
  const relevant = tasks.filter((t) => t.status !== 'cancelled');
  const doneTasks = relevant.filter((t) => t.status === 'done');
  const weekAgo = t0 - 7 * DAY_MS;
  const doneThisWeek = doneTasks.filter(
    (t) => t.updatedAt && new Date(t.updatedAt).getTime() >= weekAgo,
  ).length;
  const pct = relevant.length === 0 ? 0 : Math.round((doneTasks.length / relevant.length) * 100);
  return { done: doneTasks.length, total: relevant.length, pct, doneThisWeek };
}

function dayStart(ts: number): number {
  const d = new Date(ts);
  d.setHours(0, 0, 0, 0);
  return d.getTime();
}

/**
 * Backlog (open) count for each of the last `days` days, oldest first.
 * Approximation: a task counts as backlog on day D when it existed by end of D
 * and was not yet moved out of 'open' (non-open tasks use updatedAt as the
 * transition timestamp; still-open tasks are backlog on every day since creation).
 */
export function backlogSeries(tasks: KpiTask[], days: number, now?: number): number[] {
  const today = dayStart(now ?? Date.now());
  const out: number[] = [];
  for (let i = days - 1; i >= 0; i--) {
    const endOfDay = today - i * DAY_MS + DAY_MS - 1;
    let count = 0;
    for (const t of tasks) {
      const created = t.createdAt ? new Date(t.createdAt).getTime() : 0;
      if (created > endOfDay) continue;
      if (t.status === 'open') {
        count++;
      } else if (t.status !== 'cancelled') {
        const left = t.updatedAt ? new Date(t.updatedAt).getTime() : created;
        if (left > endOfDay) count++; // was still open on that day
      }
    }
    out.push(count);
  }
  return out;
}

/** Done-per-day counts for the last `days` days, oldest first. */
export function throughputSeries(tasks: KpiTask[], days: number, now?: number): number[] {
  const today = dayStart(now ?? Date.now());
  const out: number[] = [];
  for (let i = days - 1; i >= 0; i--) {
    const start = today - i * DAY_MS;
    const end = start + DAY_MS - 1;
    out.push(
      tasks.filter(
        (t) =>
          t.status === 'done' &&
          t.updatedAt &&
          new Date(t.updatedAt).getTime() >= start &&
          new Date(t.updatedAt).getTime() <= end,
      ).length,
    );
  }
  return out;
}

/** Build SVG line + area path data for a value series. Values are top-anchored (max = y 0). */
export function areaPath(
  values: number[],
  w: number,
  h: number,
): { line: string; area: string } {
  if (values.length === 0) return { line: '', area: '' };
  const max = Math.max(...values);
  const min = Math.min(...values);
  const span = max - min;
  const step = values.length > 1 ? w / (values.length - 1) : 0;
  const pts = values.map((v, i) => {
    const x = Math.round(i * step * 100) / 100;
    const y = span === 0 ? h / 2 : Math.round(((max - v) / span) * h * 100) / 100;
    return `${x},${y}`;
  });
  const line = `M${pts.join(' L')}`;
  return { line, area: `${line} L${w},${h} L0,${h} Z` };
}

/** Chip model for one status lane (in_progress → assignedTo, review → reviewer). */
export function laneChips(tasks: KpiTask[], status: string, now?: number): AgentChip[] {
  const t0 = now ?? Date.now();
  return tasks
    .filter((t) => t.status === status)
    .map((t) => {
      const name = status === 'review' ? t.reviewer ?? t.assignedTo : t.assignedTo;
      const since = t.claimedAt ?? t.updatedAt;
      return {
        name: name ?? '?',
        minutes: since ? Math.max(0, Math.round((t0 - new Date(since).getTime()) / 60000)) : null,
      };
    })
    .filter((c) => c.name !== '?');
}
  • Step 5: Tests laufen lassen — grün

Run: npx vitest run tests/boardV2-viewmodel.test.ts Expected: PASS (7 Tests). Dann zusätzlich sicherstellen, dass nichts anderes bricht: npx vitest run (Gesamtlauf) — Expected: alle bestehenden Tests grün (die neue Datei wird noch nirgends importiert).

  • Step 6: Commit
git add assets/logo.svg src/server/board/viewmodel.ts tests/boardV2-viewmodel.test.ts
git commit -m "feat(board): v2 viewmodel helpers + logo asset"

Task 2: Styles-Modul (Design-Tokens, Glass, Keyframes)

Files:

  • Create: src/server/board/styles.ts

  • Reference (CSS-Quelle): .superpowers/brainstorm/22429-1784510154/content/board-v25-final.html (.v25-Block)

  • Reference (zu portierende v1-Styles): src/server/board.ts:56-845

  • Step 1: Failing Test

tests/boardV2-styles.test.ts:

import { describe, it, expect } from 'vitest';
import { boardV2Css } from '../src/server/board/styles.js';

describe('boardV2Css', () => {
  it('contains the glass surface tokens and keyframes', () => {
    const css = boardV2Css();
    expect(css).toContain('--b2-bg');
    expect(css).toContain('rgba(255,255,255,.045)'); // glass surface
    expect(css).toContain('@keyframes b2-rise');
    expect(css).toContain('@keyframes b2-fill');
    expect(css).toContain('@keyframes b2-ping');
  });
  it('respects prefers-reduced-motion', () => {
    expect(boardV2Css()).toContain('@media (prefers-reduced-motion: reduce)');
  });
});

Run: npx vitest run tests/boardV2-styles.test.ts — Expected: FAIL (Modul fehlt).

  • Step 2: src/server/board/styles.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/pingb2-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:
@media (prefers-reduced-motion: reduce) {
  .b2, .b2 * { animation: none !important; transition: none !important; }
}
  • Step 3: Tests grün

Run: npx vitest run tests/boardV2-styles.test.ts — Expected: PASS (2 Tests).

  • Step 4: Commit
git add src/server/board/styles.ts tests/boardV2-styles.test.ts
git commit -m "feat(board): v2 glass design system css"

Task 3: Chrome-Modul (Header mit Logo + Splash Screen)

Files:

  • Create: src/server/board/chrome.ts

  • Test: tests/boardV2-chrome.test.ts

  • Step 1: Failing Test

tests/boardV2-chrome.test.ts:

import { describe, it, expect } from 'vitest';
import { headerHtml, splashHtml, splashJs } from '../src/server/board/chrome.js';

describe('headerHtml', () => {
  it('renders logo, project cell with chevron, nav and new-task button', () => {
    const h = headerHtml('my-project');
    expect(h).toContain('/card-assets/../logo.svg'.replace('/card-assets/..', '')); // /logo.svg
    expect(h).toContain('my-project');
    expect(h).toContain('b2-proj'); // project cell (visual only, dropdown comes with v3)
    expect(h).toContain('▾');
    expect(h).toContain('+ New task');
    expect(h).toContain('id="connDot"');
  });
  it('escapes the project name', () => {
    expect(headerHtml('<b>x</b>')).not.toContain('<b>x</b>');
  });
});

describe('splash', () => {
  it('renders overlay with logo and wordmark', () => {
    const s = splashHtml();
    expect(s).toContain('id="b2-splash"');
    expect(s).toContain('/logo.svg');
    expect(s).toContain('agenthub');
  });
  it('fade-out script has a hard timeout and never blocks', () => {
    const js = splashJs();
    expect(js).toContain('b2-splash');
    expect(js).toContain('1500'); // hard cap in ms
  });
});

Run: npx vitest run tests/boardV2-chrome.test.ts — Expected: FAIL.

  • Step 2: src/server/board/chrome.ts implementieren

Vollständige Datei:

import { escapeHtml } from '../ui-shared.js';

const LOGO = '<img src="/logo.svg" alt="" width="22" height="22" class="b2-logo-img">';

/** Fixed app header for board v2. Project cell is visual only (dropdown = v3). */
export function headerHtml(projectName: string): string {
  const name = escapeHtml(projectName);
  return `
<header class="b2-hdr">
  <span class="b2-brand">${LOGO}<b>agenthub</b></span>
  <span class="b2-proj" title="Projektwechsel kommt mit v3">/ ${name} <span class="b2-chev">▾</span></span>
  <nav class="b2-nav">
    <b>Board</b><a href="/team">Team</a><a href="/activity">Activity</a><a href="/decisions">Decisions</a>
  </nav>
  <span class="b2-hdr-right">
    <button type="button" class="b2-btn" id="newTaskBtn">+ New task</button>
    <span class="b2-live-dot" id="connDot" title="Live-Verbindung"></span>
  </span>
</header>`;
}

/** Fullscreen splash overlay, shown until first data render (hard cap 1.5s). */
export function splashHtml(): string {
  return `
<div id="b2-splash" class="b2-splash" aria-hidden="true">
  <div class="b2-splash-inner">
    <img src="/logo.svg" alt="agenthub" width="72" height="72" class="b2-splash-logo">
    <div class="b2-splash-word">agenthub</div>
  </div>
</div>`;
}

/**
 * Inline script: hides the splash after the first successful refresh() or after
 * a hard 1500ms cap, whichever comes first. Exposes window.__b2SplashDone() so
 * the data layer can signal completion.
 */
export function splashJs(): string {
  return `
(function () {
  var done = false;
  var el = function () { return document.getElementById('b2-splash'); };
  window.__b2SplashDone = function () {
    if (done) return;
    done = true;
    var s = el();
    if (!s) return;
    s.classList.add('b2-splash-hide');
    setTimeout(function () { if (s.parentNode) s.parentNode.removeChild(s); }, 450);
  };
  setTimeout(window.__b2SplashDone, 1500); // hard cap — splash must never block
})();`;
}

Hinweis: escapeHtml existiert bereits in src/server/ui-shared.ts und wird wiederverwendet. Die Splash-CSS (b2-splash, Fade-Out per opacity-Transition + b2-splash-hide, Logo-Puls) ist Teil von Task 2; falls dort noch nicht enthalten, hier ergänzen:

.b2-splash { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center;
  background: radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%);
  transition: opacity .4s ease; }
.b2-splash-hide { opacity: 0; pointer-events: none; }
.b2-splash-logo { animation: b2-ping 1.6s ease-in-out infinite; }
.b2-splash-word { margin-top: 14px; font-size: 22px; font-weight: 700; letter-spacing: .02em; color: #eef1f8; }
  • Step 3: Tests grün

Run: npx vitest run tests/boardV2-chrome.test.ts — Expected: PASS (4 Tests).

  • Step 4: Favicon + Logo-Route sicherstellen

src/server/routes.ts: prüfen, dass GET /logo.svg ausgeliefert wird. Es existiert bereits eine Read-Only-Route GET /card-assets/* (routes.ts:64-79, Root = Repo-assets/). Kleinstmögliche Ergänzung direkt daneben:

app.get('/logo.svg', async (_req, reply) => {
  reply.type('image/svg+xml');
  return readFileSync(join(assetsRoot, 'logo.svg'));
});

(assetsRoot und readFileSync/join wie in der bestehenden /card-assets/*-Route verwendet — deren exakte lokalen Namen übernehmen.) Favicon im HTML-Head (Task 6, Assembly): <link rel="icon" href="/logo.svg" type="image/svg+xml">.

  • Step 5: Commit
git add src/server/board/chrome.ts tests/boardV2-chrome.test.ts src/server/routes.ts
git commit -m "feat(board): v2 header, splash screen, logo route"

Task 4: KPI-Modul

Files:

  • Create: src/server/board/kpis.ts

  • Test: tests/boardV2-kpis.test.ts

  • Step 1: Failing Test

tests/boardV2-kpis.test.ts:

import { describe, it, expect } from 'vitest';
import { kpiSkeletonHtml, kpiJs } from '../src/server/board/kpis.js';

describe('kpiSkeletonHtml', () => {
  it('renders the four KPI cards with stable ids', () => {
    const h = kpiSkeletonHtml();
    for (const id of ['kpiOpen', 'kpiInProgress', 'kpiReview', 'kpiDone']) {
      expect(h).toContain(`id="${id}"`);
    }
    expect(h).toContain('Open');
    expect(h).toContain('In Progress');
    expect(h).toContain('Review');
    expect(h).toContain('Done');
  });
});

describe('kpiJs', () => {
  it('injects the pure helpers and an updateKpis entry point', () => {
    const js = kpiJs();
    for (const fn of ['capChips', 'doneStats', 'backlogSeries', 'areaPath', 'laneChips']) {
      expect(js).toContain(`function ${fn}`);
    }
    expect(js).toContain('window.__b2UpdateKpis');
  });
});

Run: npx vitest run tests/boardV2-kpis.test.ts — Expected: FAIL.

  • Step 2: src/server/board/kpis.ts implementieren
import {
  capChips, doneStats, backlogSeries, areaPath, laneChips,
} from './viewmodel.js';

/** Static skeleton: four KPI cards; values are filled client-side. */
export function kpiSkeletonHtml(): string {
  return `
<section class="b2-kpis" aria-label="Kennzahlen">
  <div class="b2-kpi b2-glass" id="kpiOpen">
    <div class="b2-lbl">Open</div>
    <div class="b2-val"><span data-k="count"></span> <small data-k="total"></small></div>
    <div class="b2-miniarea" data-k="area"></div>
  </div>
  <div class="b2-kpi b2-glass" id="kpiInProgress">
    <div class="b2-lbl">In Progress</div>
    <div class="b2-val"><span data-k="count"></span></div>
    <div class="b2-chips" data-k="chips"></div>
  </div>
  <div class="b2-kpi b2-glass" id="kpiReview">
    <div class="b2-lbl">Review</div>
    <div class="b2-val"><span data-k="count"></span></div>
    <div class="b2-chips" data-k="chips"></div>
  </div>
  <div class="b2-kpi b2-glass" id="kpiDone">
    <div class="b2-lbl">Done</div>
    <div class="b2-val"><span data-k="count"></span> <small data-k="total"></small></div>
    <div class="b2-cap-r" data-k="cap"></div>
    <div class="b2-pbar"><i data-k="bar"></i></div>
  </div>
</section>`;
}

/**
 * Inline script: injects the pure viewmodel helpers (toString) and defines
 * window.__b2UpdateKpis(tasks, agentColor), called by the data layer on every
 * refresh. Agent colors reuse the existing per-agent palette via the passed
 * agentColor(name) function from the columns module.
 */
export function kpiJs(): string {
  return `
${capChips.toString()}
${doneStats.toString()}
${backlogSeries.toString()}
${areaPath.toString()}
${laneChips.toString()}
window.__b2UpdateKpis = function (tasks, agentColor) {
  var DAY = 86400000;
  function setK(card, key, fn) {
    var el = document.querySelector('#' + card + ' [data-k="' + key + '"]');
    if (el) fn(el);
  }
  // Open
  var open = tasks.filter(function (t) { return t.status === 'open'; });
  setK('kpiOpen', 'count', function (el) { el.textContent = String(open.length); });
  setK('kpiOpen', 'total', function (el) {
    el.textContent = '/ ' + tasks.filter(function (t) { return t.status !== 'cancelled'; }).length;
  });
  setK('kpiOpen', 'area', function (el) {
    var p = areaPath(backlogSeries(tasks, 14), 120, 26);
    el.innerHTML = '<svg width="100%" height="26" viewBox="0 0 120 26" preserveAspectRatio="none">'
      + '<path fill="rgba(148,163,184,.18)" d="' + p.area + '"/>'
      + '<path class="b2-spark" fill="none" stroke="#94a3b8" stroke-width="1.5" d="' + p.line + '"/></svg>';
  });
  // In Progress + Review chips (max 3, then +n)
  ['in_progress', 'review'].forEach(function (status) {
    var card = status === 'review' ? 'kpiReview' : 'kpiInProgress';
    var chips = laneChips(tasks, status);
    var capped = capChips(chips, 3);
    setK(card, 'count', function (el) { el.textContent = String(chips.length); });
    setK(card, 'chips', function (el) {
      el.innerHTML = capped.visible.map(function (c) {
        var color = agentColor ? agentColor(c.name) : '#a5b4fc';
        var initial = c.name.charAt(0).toUpperCase();
        var time = c.minutes == null ? '' : (status === 'review' ? 'prüft ' : '') + c.minutes + 'm';
        var dot = status === 'in_progress' ? '<span class="b2-cdot"></span>' : '';
        return '<span class="b2-achip"><i style="background:' + color + '">' + initial + '</i>' + dot + time + '</span>';
      }).join('') + (capped.hidden > 0 ? '<span class="b2-achip b2-more">+' + capped.hidden + '</span>' : '');
    });
  });
  // Done
  var ds = doneStats(tasks);
  setK('kpiDone', 'count', function (el) { el.textContent = String(ds.done); });
  setK('kpiDone', 'total', function (el) { el.textContent = '/ ' + ds.total; });
  setK('kpiDone', 'cap', function (el) {
    el.textContent = ds.pct + '% · +' + ds.doneThisWeek + ' diese Woche';
  });
  setK('kpiDone', 'bar', function (el) { el.style.width = ds.pct + '%'; });
};`;
}
  • Step 3: Tests grün

Run: npx vitest run tests/boardV2-kpis.test.ts — Expected: PASS (2 Tests).

  • Step 4: Commit
git add src/server/board/kpis.ts tests/boardV2-kpis.test.ts
git commit -m "feat(board): v2 kpi cards (area chart, capped chips, done bar)"

Task 5: Sidebar-Modul (Budget-Tabs, Donuts, Verlauf-Chart, Live-Feed)

Files:

  • Create: src/server/board/sidebar.ts

  • Reference (zu portierende v1-Logik): src/server/board.ts:1548-1749 (renderBudget, renderDonut, renderAgentBars, writeBaseline, Session/Total, data-donut-tab, Reset-Handler)

  • Test: tests/boardV2-sidebar.test.ts

  • Step 1: Failing Test

tests/boardV2-sidebar.test.ts:

import { describe, it, expect } from 'vitest';
import { sidebarHtml, sidebarJs } from '../src/server/board/sidebar.js';

describe('sidebarHtml', () => {
  it('renders budget card with über-tabs and insight as default', () => {
    const h = sidebarHtml();
    expect(h).toContain('id="b2Budget"');
    expect(h).toContain('data-otab="insight"');
    expect(h).toContain('data-otab="verlauf"');
    expect(h).toContain('id="budgetReset"');
    expect(h).toContain('id="b2Feed"');
  });
});

describe('sidebarJs', () => {
  it('persists the über-tab, keeps v1 reset behaviour and renders throughput', () => {
    const js = sidebarJs();
    expect(js).toContain('agenthub-budget-otab'); // localStorage key
    expect(js).toContain('writeBaseline');        // v1 reset port
    expect(js).toContain('function throughputSeries');
    expect(js).toContain('__b2UpdateSidebar');
  });
});

Run: npx vitest run tests/boardV2-sidebar.test.ts — Expected: FAIL.

  • Step 2: src/server/board/sidebar.ts implementieren
import { throughputSeries, areaPath } from './viewmodel.js';

/**
 * Sidebar skeleton: budget card (über-tabs Token Insights | Verlauf; insight is
 * the default) + live feed card. Donut/bars markup is rendered client-side by
 * the ported v1 renderBudget() — the skeleton only provides the mount points.
 */
export function sidebarHtml(): string {
  return `
<aside class="b2-side">
  <div class="b2-panel b2-glass" id="b2Budget">
    <div class="b2-tabhead">
      <h6>Budget</h6>
      <div class="b2-otabs">
        <button type="button" data-otab="insight" class="active">Token Insights</button>
        <button type="button" data-otab="verlauf">Verlauf</button>
      </div>
    </div>
    <div data-opane="insight">
      <div class="b2-subhead">
        <div class="b2-stabs" id="donutTabs"></div>
        <div class="seg" id="budgetSeg"></div>
        <button type="button" class="reset-btn" id="budgetReset">Reset</button>
      </div>
      <div id="budgetBody"></div>
    </div>
    <div data-opane="verlauf" hidden>
      <div id="b2Throughput"></div>
      <div class="b2-chart-tip" id="b2ThroughputTip"></div>
    </div>
  </div>
  <div class="b2-panel b2-glass">
    <h6>Live</h6>
    <div class="b2-feed" id="b2Feed"></div>
  </div>
</aside>`;
}

/**
 * Inline script: über-tab switching (persisted in localStorage), throughput
 * area chart, live feed, plus the PORTED v1 budget code (donut, agent bars,
 * session/total, baseline reset) — see plan Task 5 Step 3 for the port source.
 */
export function sidebarJs(v1BudgetJs: string): string {
  return `
${throughputSeries.toString()}
${areaPath.toString()}
(function () {
  var OTAB_KEY = 'agenthub-budget-otab';
  function activate(name) {
    document.querySelectorAll('[data-otab]').forEach(function (b) {
      b.classList.toggle('active', b.getAttribute('data-otab') === name);
    });
    document.querySelectorAll('[data-opane]').forEach(function (p) {
      p.hidden = p.getAttribute('data-opane') !== name;
    });
  }
  document.addEventListener('click', function (e) {
    var btn = e.target.closest && e.target.closest('[data-otab]');
    if (!btn) return;
    var name = btn.getAttribute('data-otab');
    localStorage.setItem(OTAB_KEY, name);
    activate(name);
  });
  activate(localStorage.getItem(OTAB_KEY) || 'insight');
})();
window.__b2RenderThroughput = function (tasks) {
  var box = document.getElementById('b2Throughput');
  var tip = document.getElementById('b2ThroughputTip');
  if (!box) return;
  var s = throughputSeries(tasks, 14);
  var p = areaPath(s, 220, 80);
  box.innerHTML = '<svg width="100%" height="80" viewBox="0 0 220 80" preserveAspectRatio="none">'
    + '<path fill="rgba(56,189,248,.25)" d="' + p.area + '"/>'
    + '<path class="b2-spark" fill="none" stroke="#38bdf8" stroke-width="1.6" d="' + p.line + '"/></svg>';
  if (tip) {
    var avg = s.reduce(function (a, b) { return a + b; }, 0) / s.length;
    tip.textContent = 'Erledigte Tasks · 14 Tage · Ø ' + avg.toFixed(1) + '/Tag';
  }
};
window.__b2FeedPush = function (html) {
  var feed = document.getElementById('b2Feed');
  if (!feed) return;
  var div = document.createElement('div');
  div.innerHTML = html;
  feed.insertBefore(div, feed.firstChild);
  while (feed.children.length > 5) feed.removeChild(feed.lastChild);
};
${v1BudgetJs}`;
}
  • Step 3: v1-Budget-JS portieren

Aus src/server/board.ts:1548-1749 die Funktionen renderBudget, renderDonut, renderAgentBars, writeBaseline, die Session/Total-Logik (budgetMode, localStorage agenthub-budget-mode), die Sub-Tab-Logik (data-donut-tab, localStorage agenthub-donut-metric) und den Reset-Handler (board.ts:1739-1749) unverändert in einen String-Block übernehmen und als v1BudgetJs an sidebarJs() übergeben (beim Assembly in Task 6). Einzige Anpassung: das v1-Markup mountet jetzt in #budgetBody / #donutTabs / #budgetSeg statt der alten Container-IDs; die Sub-Tabs bekommen die Labels „Token" / „Kosten". Donut-Center-Text (Fix aus v1, SVG <text> bei cx=110 cy=122) beibehalten.

  • Step 4: Tests grün

Run: npx vitest run tests/boardV2-sidebar.test.ts — Expected: PASS (2 Tests).

  • Step 5: Commit
git add src/server/board/sidebar.ts tests/boardV2-sidebar.test.ts
git commit -m "feat(board): v2 sidebar with tabbed budget card, throughput chart, live feed"

Task 6: Assembly + Spalten/Karten/Modals-Port + Verdrahtung

Files:

  • Create: src/server/board/index.ts

  • Modify: src/server/routes.ts:16 (Import auf ./board/index.js)

  • Delete: src/server/board.ts (nach erfolgreichem Port)

  • Reference (Port-Quelle): src/server/board.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:

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):

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
pnpm build 2>/dev/null || npx tsc
node bin/agenthub.js server start
# Browser: http://127.0.0.1:3377/board

Checkliste: Splash erscheint und faded aus · KPI-Karten füllen sich (Chips max 3 + „+n") · Open-Area-Chart zeichnet sich · Done-Bar animiert · Über-Tabs (Token Insights ↔ Verlauf) + Sub-Tabs (Token ↔ Kosten) + Session/Total + Reset funktionieren wie v1 · Drag&Drop Statuswechsel · Agent-Zuweisung per Drop · Live-Konsole läuft · Live-Feed zeigt Events · SSE-Dot grün · prefers-reduced-motion (DevTools-Emulation) schaltet Animationen ab.

  • Step 7: Commit
git add src/server/board/ src/server/routes.ts tests/boardV2.test.ts
git rm src/server/board.ts 2>/dev/null || true
git commit -m "feat(board): replace v1 monolith with modular v2 glass dashboard"

Self-Review (ausgeführt)

  • Spec-Abdeckung: Logo (T1/T3), Splash (T3), Header m. Projektzelle (T3), KPIs inkl. aller 4 Karten-Designs (T1+T4), Spalten/Karten/Drag&Drop/Konsole/Modals (T6), Sidebar-Tabs inkl. Reset + Session/Total + Sub-Tabs (T5), Verlauf-Chart (T5, als Tasks/Tag — /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.