dashboard: decision age, KPI bar, and open-age health indicator

- KPI bar top-right: avg resolution time (last ≤5 resolved decisions)
  and avg open age with count; replaces old live-bar with kpi-bar row
- Color logic for avg-open-age KPI:
    red    — mean open age > avg resolve time
    orange — any single open decision exceeds avg resolve time
    black  — all open decisions younger than avg resolve time
- Decision cards: age badge in header showing "open Xd/h/w" for
  open/escalated or "took X" for resolved/superseded; orange when an
  open decision has aged past the avg resolve time baseline
- fmtDuration() helper: compact duration formatting (m/h/d/w/mo)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 07:50:54 +01:00
parent 345068f3df
commit d056c142df

View File

@@ -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`<div class="live-bar">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: `<span style="color:red">Offline — run: <code>make api</code></span>`}
// ── 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`<div class="kpi-bar">
<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>
<div class="kpi-badges">
${_meanResolveMs !== null ? html`<span class="kpi-badge">
<span class="kpi-label">avg resolve</span>
<span class="kpi-value">${fmtDuration(_meanResolveMs)}</span>
${_resolved5.length < 5 ? html`<span class="kpi-sub">n=${_resolved5.length}</span>` : ""}
</span>` : ""}
${_meanOpenMs !== null ? html`<span class="kpi-badge" style="--kpi-color:${_openAgeColor}">
<span class="kpi-label">avg open age</span>
<span class="kpi-value">${fmtDuration(_meanOpenMs)}</span>
<span class="kpi-sub">${_openDecs.length} open</span>
</span>` : ""}
</div>
</div>`);
```
@@ -250,6 +309,8 @@ display(_filtersForm);
```
```js
const _nowCards = new Date();
if (filtered.length === 0) {
display(html`<p class="dim">No decisions match the current filter.</p>`);
} 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`<div class="dec-item" style="border-left-color:${border}">
<div class="dec-item-header">
<span class="dec-badge ${TYPE_CLASS[d.decision_type] ?? ''}">${d.decision_type}</span>
@@ -270,6 +339,7 @@ if (filtered.length === 0) {
${due ? html`<span class="dec-due ${overdue ? 'dec-overdue' : ''}">
${overdue ? "⚠ overdue" : "due"} ${due}
</span>` : ""}
<span class="dec-age ${_ageWarn ? 'dec-age-warn' : ''}">${_ageText}</span>
<span class="dec-date">${fmtDate(d.created_at)}</span>
</div>
<div class="dec-title">${d.title}</div>
@@ -291,7 +361,14 @@ if (escalated.length > 0) {
```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
/* KPI bar */
.kpi-bar { display: flex; justify-content: space-between; align-items: center; font-size: 0.8rem; color: gray; margin-bottom: 0.5rem; }
.live-indicator { color: gray; }
.kpi-badges { display: flex; gap: 0.6rem; align-items: center; }
.kpi-badge { display: inline-flex; align-items: center; gap: 0.3rem; background: var(--theme-background-alt, #f5f5f5); border-radius: 6px; padding: 0.2rem 0.55rem; font-size: 0.78rem; color: var(--kpi-color, var(--theme-foreground-muted, #666)); }
.kpi-label { font-weight: 400; }
.kpi-value { font-weight: 700; font-variant-numeric: tabular-nums; color: var(--kpi-color, var(--theme-foreground, #111)); }
.kpi-sub { font-size: 0.7rem; opacity: 0.7; }
.dim { color: gray; font-style: italic; }
/* Filter bar */
.filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
@@ -313,6 +390,8 @@ if (escalated.length > 0) {
.dec-due { color: steelblue; }
.dec-overdue { color: #dc2626; font-weight: 600; }
.dec-date { color: var(--theme-foreground-faint); margin-left: auto; }
.dec-age { font-size: 0.72rem; color: var(--theme-foreground-faint, #aaa); font-variant-numeric: tabular-nums; }
.dec-age-warn { color: #d97706; font-weight: 600; }
.dec-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.2rem; }
.dec-snippet { font-size: 0.82rem; color: var(--theme-foreground-muted); line-height: 1.45; white-space: pre-wrap; }
.dec-resolved-by { font-size: 0.78rem; color: #22c55e; margin-top: 0.3rem; }