diff --git a/state-hub/dashboard/src/decisions.md b/state-hub/dashboard/src/decisions.md index d58d329..01549fe 100644 --- a/state-hub/dashboard/src/decisions.md +++ b/state-hub/dashboard/src/decisions.md @@ -91,16 +91,75 @@ function fmtDate(iso) { function isOverdue(iso) { return iso && new Date(iso) < new Date(); } + +// Format a duration in milliseconds as a compact human string +function fmtDuration(ms) { + if (ms < 0) ms = 0; + const h = 3_600_000, d = 86_400_000, w = 7 * d; + if (ms < 2 * h) return `${Math.floor(ms / 60_000)}m`; + if (ms < 2 * d) return `${Math.floor(ms / h)}h`; + if (ms < 2 * w) return `${Math.floor(ms / d)}d`; + if (ms < 8 * w) return `${Math.floor(ms / w)}w`; + return `${Math.round(ms / (30.5 * d))}mo`; +} ``` # Decisions ```js -display(html`
- - ${_ok - ? `Live · updated ${_ts?.toLocaleTimeString()}` - : `Offline — run: make api`} +// ── KPI bar ──────────────────────────────────────────────────────────────── +// Uses full `data` (not filtered) — these are health metrics for the whole system + +// Mean resolution time from the last ≤5 resolved decisions +const _resolved5 = data + .filter(d => d.decided_at && d.created_at) + .sort((a, b) => b.decided_at.localeCompare(a.decided_at)) + .slice(0, 5); + +const _meanResolveMs = _resolved5.length + ? _resolved5.reduce((s, d) => s + (new Date(d.decided_at) - new Date(d.created_at)), 0) / _resolved5.length + : null; + +// Mean age of currently open decisions +const _nowKpi = new Date(); +const _openDecs = data.filter(d => d.status === "open" || d.status === "escalated"); +const _openAges = _openDecs.map(d => _nowKpi - new Date(d.created_at)); +const _meanOpenMs = _openAges.length + ? _openAges.reduce((s, a) => s + a, 0) / _openAges.length + : null; + +// Color logic for the mean-open-age KPI: +// red — mean open age exceeds avg resolve time +// orange — at least one open decision exceeds avg resolve time (but mean is still OK) +// black — all open decisions are younger than avg resolve time +let _openAgeColor = "inherit"; +if (_meanOpenMs !== null && _meanResolveMs !== null) { + if (_meanOpenMs > _meanResolveMs) { + _openAgeColor = "#dc2626"; + } else if (_openAges.some(a => a > _meanResolveMs)) { + _openAgeColor = "#d97706"; + } +} + +display(html`
+
+ + ${_ok + ? `Live · updated ${_ts?.toLocaleTimeString()}` + : html`Offline — run: make api`} +
+
+ ${_meanResolveMs !== null ? html` + avg resolve + ${fmtDuration(_meanResolveMs)} + ${_resolved5.length < 5 ? html`n=${_resolved5.length}` : ""} + ` : ""} + ${_meanOpenMs !== null ? html` + avg open age + ${fmtDuration(_meanOpenMs)} + ${_openDecs.length} open + ` : ""} +
`); ``` @@ -250,6 +309,8 @@ display(_filtersForm); ``` ```js +const _nowCards = new Date(); + if (filtered.length === 0) { display(html`

No decisions match the current filter.

`); } else { @@ -260,6 +321,14 @@ if (filtered.length === 0) { const decided = fmtDate(d.decided_at); const overdue = isOverdue(d.deadline); + // Age: time-to-resolve for closed decisions, time-open for live ones + const _isOpen = d.status === "open" || d.status === "escalated"; + const _ageMs = d.decided_at + ? new Date(d.decided_at) - new Date(d.created_at) + : _nowCards - new Date(d.created_at); + const _ageText = d.decided_at ? `took ${fmtDuration(_ageMs)}` : `open ${fmtDuration(_ageMs)}`; + const _ageWarn = _isOpen && _meanResolveMs !== null && _ageMs > _meanResolveMs; + return html`
${d.decision_type} @@ -270,6 +339,7 @@ if (filtered.length === 0) { ${due ? html` ${overdue ? "⚠ overdue" : "due"} ${due} ` : ""} + ${_ageText} ${fmtDate(d.created_at)}
${d.title}
@@ -291,7 +361,14 @@ if (escalated.length > 0) { ```