T01: Fix datetime.utcnow() → datetime.now(tz=timezone.utc) in MCP server T02: Wrap _get/_post/_patch/_delete with try/except; return error dicts T03: Log warnings when write_log skips missing project path T04: Add priority + due_date_before filters to GET /tasks/ T05: Add owner + slug filters to GET /workstreams/ T06: Add offset param to GET /progress/ for proper pagination T07: Low-severity bundle: - CORS origins from CORS_ORIGINS env var (TD-017) - seed.py upsert domains+topics on re-run (TD-011) - normalise filter bar CSS → filter-text-input everywhere (TD-016) - add 30.5 avg-days-per-month comment in decisions.md (TD-019) - TD-009, TD-018 already resolved by existing code Closes CUST-WP-0018. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
467 lines
19 KiB
Markdown
467 lines
19 KiB
Markdown
---
|
|
title: Decisions
|
|
---
|
|
|
|
```js
|
|
import {API, POLL} from "./components/config.js";
|
|
```
|
|
|
|
```js
|
|
// 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_slug ?? null,
|
|
topic_title: topicMap[d.topic_id]?.title ?? null,
|
|
}))
|
|
.sort((a, b) => {
|
|
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;
|
|
// Resolved / superseded: most recently decided first
|
|
if (a.status === "resolved" || a.status === "superseded") {
|
|
if (a.decided_at && b.decided_at) return b.decided_at.localeCompare(a.decided_at);
|
|
return a.decided_at ? -1 : 1;
|
|
}
|
|
// Open / escalated: soonest deadline first, then most recently created
|
|
if (a.deadline && b.deadline) return a.deadline.localeCompare(b.deadline);
|
|
if (a.deadline) return -1;
|
|
if (b.deadline) return 1;
|
|
return b.created_at.localeCompare(a.created_at);
|
|
});
|
|
}
|
|
} catch {}
|
|
yield {data, ok, ts: new Date()};
|
|
await new Promise(res => setTimeout(res, POLL));
|
|
}
|
|
})();
|
|
```
|
|
|
|
```js
|
|
const data = decState.data ?? [];
|
|
const _ok = decState.ok ?? false;
|
|
const _ts = decState.ts;
|
|
```
|
|
|
|
```js
|
|
import {MultiSelect} from "./components/multiselect.js";
|
|
|
|
// Create filter form without displaying — shown 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-text-input">${search}</div>
|
|
</div>`,
|
|
}
|
|
);
|
|
```
|
|
|
|
```js
|
|
const filters = Generators.input(_filtersForm);
|
|
```
|
|
|
|
```js
|
|
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 && !["resolved", "superseded"].includes(d.status));
|
|
|
|
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();
|
|
}
|
|
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`; // 30.5 = avg days per month (365/12)
|
|
}
|
|
```
|
|
|
|
# Decisions
|
|
|
|
```js
|
|
import {withDocHelp} from "./components/doc-overlay.js";
|
|
import {injectTocTop} from "./components/toc-sidebar.js";
|
|
import {openActionConfirm} from "./components/action-confirm.js";
|
|
|
|
// ── KPI computation (uses full data, not filtered) ──────────────────────────
|
|
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;
|
|
|
|
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: red = mean open > baseline; orange = any individual > baseline; black = all fine
|
|
let _openAgeColor = "inherit";
|
|
if (_meanOpenMs !== null && _meanResolveMs !== null) {
|
|
if (_meanOpenMs > _meanResolveMs) _openAgeColor = "#dc2626";
|
|
else if (_openAges.some(a => a > _meanResolveMs)) _openAgeColor = "#d97706";
|
|
}
|
|
|
|
// ── Build the KPI infobox ───────────────────────────────────────────────────
|
|
const _kpiBox = html`<div class="kpi-infobox">
|
|
<div class="kpi-infobox-title">Decision Health</div>
|
|
${_meanResolveMs !== null ? html`<div class="kpi-row">
|
|
<span class="kpi-row-label">avg resolve</span>
|
|
<div class="kpi-row-right">
|
|
<div class="kpi-row-value">${fmtDuration(_meanResolveMs)}</div>
|
|
<div class="kpi-row-sub">last ${_resolved5.length}</div>
|
|
</div>
|
|
</div>` : ""}
|
|
${_meanOpenMs !== null ? html`<div class="kpi-row" style="--oc:${_openAgeColor}">
|
|
<span class="kpi-row-label">avg open age</span>
|
|
<div class="kpi-row-right">
|
|
<div class="kpi-row-value kpi-colorable">${fmtDuration(_meanOpenMs)}</div>
|
|
<div class="kpi-row-sub kpi-colorable">${_openDecs.length} open</div>
|
|
</div>
|
|
</div>` : html`<div class="kpi-row"><span class="kpi-row-label kpi-muted">no open decisions</span></div>`}
|
|
</div>`;
|
|
|
|
withDocHelp(_kpiBox, "/docs/decisions-kpi");
|
|
|
|
// ── Build live indicator ────────────────────────────────────────────────────
|
|
const _liveEl = html`<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>`;
|
|
withDocHelp(_liveEl, "/docs/live-data");
|
|
|
|
const _h1 = document.querySelector("#observablehq-main h1");
|
|
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/decisions"); }
|
|
|
|
// ── Inject into TOC sidebar: KPI first (prepend → bottom), live last (→ top) ─
|
|
const _toc = document.querySelector("#observablehq-toc");
|
|
if (_toc) {
|
|
injectTocTop("decisions-kpi-box", _kpiBox);
|
|
injectTocTop("live-indicator", _liveEl);
|
|
} else {
|
|
display(html`<div style="float:right;width:215px;margin-left:1rem">${_liveEl}${_kpiBox}</div>`);
|
|
}
|
|
```
|
|
|
|
## Resolution History
|
|
|
|
```js
|
|
const period = view(Inputs.radio(
|
|
["day", "week", "month", "quarter", "YTD", "year", "all"],
|
|
{value: "month", label: "Period"}
|
|
));
|
|
```
|
|
|
|
```js
|
|
import * as Plot from "npm:@observablehq/plot";
|
|
|
|
function _getTs(d) { return new Date(d.decided_at ?? d.created_at); }
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const _now = new Date();
|
|
const _y = _now.getFullYear(), _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 _ago = new Date(_now - 365 * 864e5);
|
|
_start = new Date(_ago.getFullYear(), _ago.getMonth(), 1);
|
|
_unit = "month";
|
|
_tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "2-digit"});
|
|
break;
|
|
}
|
|
default: {
|
|
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;
|
|
}
|
|
}
|
|
|
|
const _inWindow = period === "all"
|
|
? [...filtered]
|
|
: filtered.filter(d => { const t = _getTs(d); return t >= _start && t <= _now; });
|
|
|
|
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);
|
|
}
|
|
|
|
let _cum = 0;
|
|
const _chartData = _bktKeys.map(k => {
|
|
const delta = _cntMap.get(k) || 0;
|
|
_cum += delta;
|
|
return {date: new Date(k), count: _cum, delta};
|
|
});
|
|
|
|
if (_inWindow.length === 0) {
|
|
display(html`<p class="dim">No decisions in this period.</p>`);
|
|
} else {
|
|
const _plotEl = 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,
|
|
});
|
|
const _plotWrap = html`<div style="position:relative">${_plotEl}</div>`;
|
|
withDocHelp(_plotWrap, "/docs/decisions-kpi");
|
|
display(_plotWrap);
|
|
}
|
|
```
|
|
|
|
## Filter & List
|
|
|
|
```js
|
|
const _filterWrap = html`<div style="position:relative;padding-right:2rem">${_filtersForm}</div>`;
|
|
withDocHelp(_filterWrap, "/docs/decisions-kpi");
|
|
display(_filterWrap);
|
|
```
|
|
|
|
```js
|
|
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);
|
|
|
|
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;
|
|
|
|
function onResolve() {
|
|
openActionConfirm({
|
|
title: "Resolve Decision",
|
|
entityTitle: d.title,
|
|
label: "Rationale",
|
|
placeholder: "Why is this resolved, and what was decided?",
|
|
confirmLabel: "Resolve",
|
|
onConfirm: async (rationale) => {
|
|
const res = await fetch(`${API}/decisions/${d.id}/resolve`, {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({rationale, decided_by: "human"}),
|
|
});
|
|
if (!res.ok) throw new Error(`API error ${res.status}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
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>
|
|
${_isOpen ? html`<button class="dec-resolve-btn" onclick=${onResolve}>Resolve</button>` : ""}
|
|
</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 && !["resolved", "superseded"].includes(d.status) ? html`<div class="dec-escalation-note">${d.escalation_note}</div>` : ""}
|
|
</div>`;
|
|
})}</div>`);
|
|
}
|
|
```
|
|
|
|
```js
|
|
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>`);
|
|
}
|
|
```
|
|
|
|
<style>
|
|
/* ── Live indicator ───────────────────────────────────────────────────────── */
|
|
.live-indicator {
|
|
font-size: 0.8rem;
|
|
color: gray;
|
|
position: relative;
|
|
padding: 0.55rem 1.8rem 0.55rem 0.7rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.kpi-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
padding: 0.3rem 0;
|
|
}
|
|
.kpi-row + .kpi-row {
|
|
border-top: 1px solid var(--theme-foreground-faint, #eee);
|
|
}
|
|
.kpi-row-label {
|
|
font-size: 0.8rem;
|
|
color: var(--theme-foreground-muted, #666);
|
|
white-space: nowrap;
|
|
}
|
|
.kpi-muted { color: var(--theme-foreground-faint, #aaa); font-style: italic; }
|
|
.kpi-row-right { text-align: right; }
|
|
.kpi-row-value {
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
font-variant-numeric: tabular-nums;
|
|
line-height: 1.1;
|
|
color: var(--theme-foreground, #111);
|
|
}
|
|
.kpi-colorable { color: var(--oc, inherit); }
|
|
.kpi-row-sub {
|
|
font-size: 0.68rem;
|
|
color: var(--theme-foreground-faint, #aaa);
|
|
line-height: 1.2;
|
|
}
|
|
|
|
/* ── Utility ──────────────────────────────────────────────────────────────── */
|
|
.dim { color: gray; font-style: italic; }
|
|
|
|
|
|
/* ── Decision list ────────────────────────────────────────────────────────── */
|
|
.dec-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
.dec-item { border-left: 3px solid #ccc; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; }
|
|
.dec-item-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.75rem; }
|
|
.dec-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
.badge-pending { background: #fef3c7; color: #92400e; }
|
|
.badge-made { background: #e0e7ff; color: #3730a3; }
|
|
.dec-badge-status { font-weight: 500; text-transform: capitalize; }
|
|
.dec-badge-open { background: #dbeafe; color: #1e40af; }
|
|
.dec-badge-escalated { background: #fef3c7; color: #92400e; }
|
|
.dec-badge-resolved { background: #dcfce7; color: #166534; }
|
|
.dec-badge-superseded { background: #f3f4f6; color: #6b7280; }
|
|
.dec-context { color: var(--theme-foreground-muted); }
|
|
.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; }
|
|
.dec-escalation-note { font-size: 0.78rem; color: #b45309; margin-top: 0.3rem; background: #fef3c7; border-radius: 4px; padding: 0.25rem 0.5rem; }
|
|
|
|
/* ── Resolve button ───────────────────────────────────────────────────────── */
|
|
.dec-resolve-btn { margin-left: auto; padding: 0.15rem 0.6rem; border-radius: 6px; border: 1px solid #22c55e; background: #f0fdf4; color: #166534; font-size: 0.7rem; font-weight: 600; cursor: pointer; }
|
|
.dec-resolve-btn:hover { background: #dcfce7; }
|
|
|
|
/* ── Escalation warning ───────────────────────────────────────────────────── */
|
|
.escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
|
|
</style>
|