generated from coulomb/repo-seed
- 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>
16 KiB
16 KiB
title
| title |
|---|
| Decisions |
const API = "http://127.0.0.1:8000";
const POLL = 15_000;
// Fetch decisions + topics (for domain context) in parallel
const decState = (async function*() {
while (true) {
let data = [], ok = false;
try {
const [rd, rt] = await Promise.all([
fetch(`${API}/decisions/?limit=500`),
fetch(`${API}/topics/`),
]);
ok = rd.ok && rt.ok;
if (ok) {
const [decisions, topics] = await Promise.all([rd.json(), rt.json()]);
const topicMap = Object.fromEntries(topics.map(t => [t.id, t]));
data = decisions
.map(d => ({
...d,
domain: topicMap[d.topic_id]?.domain ?? null,
topic_title: topicMap[d.topic_id]?.title ?? null,
}))
.sort((a, b) => {
// escalated first, then open, then resolved/superseded; within group by deadline asc
const rank = {escalated: 0, open: 1, resolved: 2, superseded: 3};
const dr = (rank[a.status] ?? 9) - (rank[b.status] ?? 9);
if (dr !== 0) return dr;
if (a.deadline && b.deadline) return a.deadline.localeCompare(b.deadline);
return a.deadline ? -1 : b.deadline ? 1 : 0;
});
}
} catch {}
yield {data, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
const data = decState.data ?? [];
const _ok = decState.ok ?? false;
const _ts = decState.ts;
import {MultiSelect} from "./components/multiselect.js";
// Create filter form without displaying — displayed below the chart via display(_filtersForm)
const _filtersForm = Inputs.form(
{
type: MultiSelect(["pending", "made"], {label: "Type", placeholder: "All types"}),
status: MultiSelect(["open", "escalated", "resolved", "superseded"], {label: "Status", placeholder: "All statuses"}),
search: Inputs.text({placeholder: "Search title…", style: "width:160px"}),
},
{
template: ({type, status, search}) => html`<div class="filter-bar">
${type}${status}
<div class="filter-search">${search}</div>
</div>`,
}
);
// Reactive value from the form without displaying it
const filters = Generators.input(_filtersForm);
const filtered = data.filter(d =>
(filters.type.length === 0 || filters.type.includes(d.decision_type)) &&
(filters.status.length === 0 || filters.status.includes(d.status)) &&
(!filters.search || d.title.toLowerCase().includes(filters.search.toLowerCase()))
);
const escalated = filtered.filter(d => d.escalation_note);
const STATUS_BORDER = {open: "steelblue", escalated: "#f59e0b", resolved: "#22c55e", superseded: "#aaa"};
const TYPE_CLASS = {pending: "badge-pending", made: "badge-made"};
function fmtDate(iso) {
return iso ? new Date(iso).toLocaleDateString(undefined, {day: "numeric", month: "short", year: "numeric"}) : null;
}
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
// ── 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>`);
Resolution History
const period = view(Inputs.radio(
["day", "week", "month", "quarter", "YTD", "year", "all"],
{value: "month", label: "Period"}
));
import * as Plot from "npm:@observablehq/plot";
// Returns the most meaningful timestamp for a decision
function _getTs(d) {
return new Date(d.decided_at ?? d.created_at);
}
// Map a timestamp to the start-of-bucket timestamp (as ms)
function _bucketKey(t, unit, start) {
switch (unit) {
case "hour": return new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours()).getTime();
case "day": return new Date(t.getFullYear(), t.getMonth(), t.getDate()).getTime();
case "week": {
const w = Math.floor((t - start) / (7 * 864e5));
return start.getTime() + w * 7 * 864e5;
}
case "month": return new Date(t.getFullYear(), t.getMonth(), 1).getTime();
}
}
// Generate all bucket start timestamps from start to end (inclusive)
function _genBuckets(start, end, unit) {
const bkts = [];
let cur = new Date(start);
while (cur <= end) {
bkts.push(cur.getTime());
if (unit === "hour") cur = new Date(cur.getTime() + 36e5);
else if (unit === "day") cur = new Date(cur.getTime() + 864e5);
else if (unit === "week") cur = new Date(cur.getTime() + 7 * 864e5);
else if (unit === "month") cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
}
return bkts;
}
// Derive window + bucket config from the selected period
const _now = new Date();
const _y = _now.getFullYear();
const _mo = _now.getMonth();
let _start, _unit, _tickFmt;
switch (period) {
case "day":
_start = new Date(_y, _mo, _now.getDate());
_unit = "hour";
_tickFmt = d => `${String(d.getHours()).padStart(2, "0")}:00`;
break;
case "week": {
const _7ago = new Date(_now - 7 * 864e5);
_start = new Date(_7ago.getFullYear(), _7ago.getMonth(), _7ago.getDate());
_unit = "day";
_tickFmt = d => d.toLocaleDateString(undefined, {weekday: "short", month: "short", day: "numeric"});
break;
}
case "month":
_start = new Date(_y, _mo, 1);
_unit = "week";
_tickFmt = d => `W/${d.toLocaleDateString(undefined, {month: "short", day: "numeric"})}`;
break;
case "quarter":
_start = new Date(_y, Math.floor(_mo / 3) * 3, 1);
_unit = "month";
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short"});
break;
case "YTD":
_start = new Date(_y, 0, 1);
_unit = "month";
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short"});
break;
case "year": {
const _52ago = new Date(_now - 365 * 864e5);
_start = new Date(_52ago.getFullYear(), _52ago.getMonth(), 1);
_unit = "month";
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "2-digit"});
break;
}
default: {
// "all" — start from earliest decision in filtered set
const _minTs = filtered.length
? Math.min(...filtered.map(d => _getTs(d)))
: _now.getTime();
const _minD = new Date(_minTs);
_start = new Date(_minD.getFullYear(), _minD.getMonth(), 1);
_unit = "month";
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "numeric"});
break;
}
}
// Restrict to window (all period uses full filtered set)
const _inWindow = period === "all"
? [...filtered]
: filtered.filter(d => { const t = _getTs(d); return t >= _start && t <= _now; });
// Count per bucket
const _bktKeys = _genBuckets(_start, _now, _unit);
const _cntMap = new Map(_bktKeys.map(k => [k, 0]));
for (const d of _inWindow) {
const key = _bucketKey(_getTs(d), _unit, _start);
if (_cntMap.has(key)) _cntMap.set(key, (_cntMap.get(key) || 0) + 1);
}
// Build cumulative series
let _cum = 0;
const _chartData = _bktKeys.map(k => {
const delta = _cntMap.get(k) || 0;
_cum += delta;
return {date: new Date(k), count: _cum, delta};
});
display(_inWindow.length === 0
? html`<p class="dim">No decisions in this period.</p>`
: Plot.plot({
x: {type: "time", tickFormat: _tickFmt, tickRotate: -30, label: null},
y: {grid: true, label: "Cumulative decisions"},
marks: [
Plot.areaY(_chartData, {x: "date", y: "count", fill: "steelblue", fillOpacity: 0.15, curve: "step-after"}),
Plot.lineY(_chartData, {x: "date", y: "count", stroke: "steelblue", strokeWidth: 2, curve: "step-after"}),
Plot.dot(_chartData.filter(d => d.delta > 0), {
x: "date", y: "count", fill: "steelblue", r: 4, tip: true,
title: d => `${_tickFmt(d.date)}\n+${d.delta} → ${d.count} total`,
}),
Plot.ruleY([0]),
],
marginBottom: 70,
width: 700,
})
);
Filter & List
display(_filtersForm);
const _nowCards = new Date();
if (filtered.length === 0) {
display(html`<p class="dim">No decisions match the current filter.</p>`);
} else {
display(html`<div class="dec-list">${filtered.map(d => {
const border = STATUS_BORDER[d.status] ?? "#ccc";
const snippet = (d.description || d.rationale || "").slice(0, 200);
const due = fmtDate(d.deadline);
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>
<span class="dec-badge dec-badge-status dec-badge-${d.status}">
${d.status === "escalated" ? "⚠ " : ""}${d.status}
</span>
${d.domain ? html`<span class="dec-context">${d.domain}</span>` : ""}
${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>
${snippet ? html`<div class="dec-snippet">${snippet}${snippet.length < (d.description || d.rationale || "").length ? "…" : ""}</div>` : ""}
${d.decided_by ? html`<div class="dec-resolved-by">✓ ${d.decided_by}${decided ? " · " + decided : ""}</div>` : ""}
${d.escalation_note ? html`<div class="dec-escalation-note">${d.escalation_note}</div>` : ""}
</div>`;
})}</div>`);
}
if (escalated.length > 0) {
display(html`<div class="escalation-box">
<strong>⚠ ${escalated.length} escalated decision${escalated.length > 1 ? "s" : ""} require human approval before any action is taken (constitution §4).</strong>
<ul>${escalated.map(d => html`<li><b>${d.title}</b>: ${d.escalation_note}</li>`)}</ul>
</div>`);
}