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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user