From 9553461130753e75db6e9315cf40c9558c6f65cb Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 20 Jul 2026 17:28:04 +0200 Subject: [PATCH] feat(board): v2 kpi cards (area chart, capped chips, done bar) --- src/server/board/kpis.ts | 90 +++++++++++++++++++++++++++++++++++ src/server/board/viewmodel.ts | 3 +- tests/boardV2-kpis.test.ts | 30 ++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/server/board/kpis.ts create mode 100644 tests/boardV2-kpis.test.ts 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 ` +
+
+
Open
+
+
+
+
+
In Progress
+
+
+
+
+
Review
+
+
+
+
+
Done
+
+
+
+
+
`; +} + +/** + * 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'); + }); +});