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.title}
@@ -291,7 +361,14 @@ if (escalated.length > 0) {
```