state-hub/dashboard/src/progress.md
tegwick 816f1e25f1 dashboard: move live indicator to TOC sidebar on all pages; add live-data docs
- All four pages (index, workstreams, decisions, progress) now inject the
  live indicator into #observablehq-toc via injectTocTop("live-indicator", el)
  Left-aligned (no text-align: right), position:relative + padding-right for
  the ? button affordance

- decisions.md: splits the former combined "decisions-sidebar" widget into two
  separate injectTocTop calls — KPI box first (ends lower), live indicator
  second (ends at top); both now have their own stable ids

- withDocHelp(_liveEl, "/docs/live-data") wires the ? button on every page

- src/docs/live-data.md: new documentation page explaining poll interval (15s),
  indicator colour semantics, offline recovery, and which endpoints each page hits

- Removes the .live-bar CSS class from all pages; replaces with .live-indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:18:09 +01:00

3.1 KiB

title
Progress
const API = "http://127.0.0.1:8000";
const POLL = 15_000;
const progState = (async function*() {
  while (true) {
    let data = [], ok = false;
    try {
      const r = await fetch(`${API}/progress/?limit=500`);
      ok = r.ok;
      data = ok ? await r.json() : [];
    } catch {}
    yield {data, ok, ts: new Date()};
    await new Promise(res => setTimeout(res, POLL));
  }
})();
const data = progState.data ?? [];
const _ok  = progState.ok   ?? false;
const _ts  = progState.ts;

Progress Log

Append-only per constitution §5 — no deletions.

import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp}   from "./components/doc-overlay.js";

const _liveEl = html`<div class="live-indicator">
  <span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
  ${_ok
    ? `Live · updated ${_ts?.toLocaleTimeString()} · ${data.length} events total`
    : html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`;
withDocHelp(_liveEl, "/docs/live-data");
injectTocTop("live-indicator", _liveEl);
const authorOpts = ["(all)", ...new Set(data.map(e => e.author ?? "unknown"))].sort();
const typeOpts   = ["(all)", ...new Set(data.map(e => e.event_type))].sort();

const authorFilter = view(Inputs.select(authorOpts, {label: "Author"}));
const typeFilter   = view(Inputs.select(typeOpts,   {label: "Event type"}));
const sinceFilter  = view(Inputs.date({label: "Since"}));
const filtered = data.filter(e =>
  (authorFilter === "(all)" || (e.author ?? "unknown") === authorFilter) &&
  (typeFilter   === "(all)" || e.event_type === typeFilter) &&
  (!sinceFilter || new Date(e.created_at) >= sinceFilter)
);

display(html`<p><strong>${filtered.length}</strong> events shown (append-only, no deletions).</p>`);

display(Inputs.table(filtered.map(e => ({
  Time:    new Date(e.created_at).toLocaleString(),
  Type:    e.event_type,
  Author:  e.author ?? "—",
  Summary: e.summary,
})), {rows: 50}));

Event Volume (Last 30 Days)

import * as Plot from "npm:@observablehq/plot";

const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const byDay = Object.entries(
  data
    .filter(e => new Date(e.created_at) >= cutoff)
    .reduce((acc, e) => {
      const day = e.created_at.slice(0, 10);
      acc[day] = (acc[day] ?? 0) + 1;
      return acc;
    }, {})
).sort().map(([day, count]) => ({day, count}));

display(byDay.length === 0
  ? html`<p style="color:gray">No events in the last 30 days.</p>`
  : Plot.plot({
      title: "Progress events per day (30-day window)",
      x: {label: "Date", tickRotate: -30},
      y: {label: "Events", grid: true},
      marks: [
        Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3}),
        Plot.lineY(byDay, {x: "day", y: "count", stroke: "steelblue"}),
        Plot.ruleY([0]),
      ],
      marginBottom: 60,
      width: 750,
    })
);