diff --git a/src/server/board/kpis.ts b/src/server/board/kpis.ts
new file mode 100644
index 0000000..e65e7f0
--- /dev/null
+++ b/src/server/board/kpis.ts
@@ -0,0 +1,90 @@
+import {
+ capChips, doneStats, backlogSeries, areaPath, laneChips, dayStart,
+} from './viewmodel.js';
+
+/** Static skeleton: four KPI cards; values are filled client-side. */
+export function kpiSkeletonHtml(): string {
+ return `
+`;
+}
+
+/**
+ * Inline script: injects the pure viewmodel helpers (toString) plus the shared
+ * DAY_MS/dayStart they reference, and defines window.__b2UpdateKpis(tasks,
+ * agentColor), called by the data layer on every refresh. agentColor(name)
+ * comes from the columns module (existing per-agent palette).
+ */
+export function kpiJs(): string {
+ return `
+var DAY_MS = 24 * 3600 * 1000;
+${dayStart.toString()}
+${capChips.toString()}
+${doneStats.toString()}
+${backlogSeries.toString()}
+${areaPath.toString()}
+${laneChips.toString()}
+window.__b2UpdateKpis = function (tasks, agentColor) {
+ 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 = '';
+ });
+ // 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' ? '' : '';
+ return '' + initial + '' + dot + time + '';
+ }).join('') + (capped.hidden > 0 ? '+' + capped.hidden + '' : '');
+ });
+ });
+ // 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 + '%'; });
+};`;
+}
diff --git a/src/server/board/viewmodel.ts b/src/server/board/viewmodel.ts
index 7b72d0f..289d03f 100644
--- a/src/server/board/viewmodel.ts
+++ b/src/server/board/viewmodel.ts
@@ -44,7 +44,8 @@ export function doneStats(
return { done: doneTasks.length, total: relevant.length, pct, doneThisWeek };
}
-function dayStart(ts: number): number {
+/** Local midnight for a timestamp (exported: co-injected into the browser page). */
+export function dayStart(ts: number): number {
const d = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
diff --git a/tests/boardV2-kpis.test.ts b/tests/boardV2-kpis.test.ts
new file mode 100644
index 0000000..c813f66
--- /dev/null
+++ b/tests/boardV2-kpis.test.ts
@@ -0,0 +1,30 @@
+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');
+ });
+ it('co-injects the shared constants the helpers depend on', () => {
+ const js = kpiJs();
+ expect(js).toContain('DAY_MS');
+ expect(js).toContain('function dayStart');
+ });
+});