Files
state-hub/dashboard/src/decisions.md
tegwick 02b2542a2a dashboard: cumulative decisions chart with flexible period selector
- Replace static per-month bar chart with cumulative step-area chart
- Period selector: day / week / month / quarter / YTD / year / all
- Time resolution adapts to period:
    day → hours, week → days, month → weeks,
    quarter/YTD/year/all → months
- Chart respects the type/status/search filter (uses filtered, not data)
- Chart and period selector appear before the filter form and list
- Use Generators.input() to decouple filter form creation from its
  display position; display(_filtersForm) renders it below the chart
- Dots on chart mark buckets where decisions occurred; tip shows delta
- "all" period derives start from earliest decision in filtered set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 07:22:25 +01:00

12 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();
}

Decisions

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>`}
</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);
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);

    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-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>`);
}
<style> .live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; } .dim { color: gray; font-style: italic; } /* Filter bar */ .filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } .filter-search { display: flex; align-items: center; } .filter-search input { height: 30px; font-size: 0.85rem; padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--theme-foreground-faint, #ccc); background: var(--theme-background, #fff); font-family: inherit; color: inherit; } /* 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-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; } /* Escalation warning */ .escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; } </style>