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>
This commit is contained in:
2026-02-26 16:18:09 +01:00
parent af7b4a896a
commit a0373c1eec
5 changed files with 101 additions and 16 deletions

View File

@@ -151,16 +151,23 @@ const _kpiBox = html`<div class="kpi-infobox">
withDocHelp(_kpiBox, "/docs/decisions-kpi");
// ── Build sidebar widget: live indicator + KPI box ──────────────────────────
// ── 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 _tocInjected = injectTocTop("decisions-sidebar", html`<div>${_liveEl}${_kpiBox}</div>`);
if (!_tocInjected) display(html`<div style="float:right;width:215px;margin-left:1rem">${_liveEl}${_kpiBox}</div>`);
// ── 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
@@ -357,8 +364,9 @@ if (escalated.length > 0) {
.live-indicator {
font-size: 0.8rem;
color: gray;
text-align: right;
margin-bottom: 0.75rem;
position: relative;
padding-right: 1.6rem;
}
/* ── KPI infobox ──────────────────────────────────────────────────────────── */

View File

@@ -0,0 +1,62 @@
---
title: Live Data — Reference
---
# Live Data — How the Dashboard Refreshes
All dashboard pages poll the State Hub API automatically. No manual refresh is ever needed.
---
## Poll interval
Every page fetches fresh data from `http://127.0.0.1:8000` every **15 seconds** using an async generator loop. The previous data stays visible while the next request is in flight, so the UI never goes blank.
---
## The live indicator
The **●** dot in the top-right corner of each page shows the current connection state:
| Indicator | Meaning |
|---|---|
| **● Live · updated HH:MM:SS** | Last poll succeeded — data is current as of that time |
| **● Offline — run: `make api`** | API is unreachable — the dot turns red |
The timestamp updates on every successful poll. If you see a time that is more than ~30 seconds in the past, the poll is stalled (browser tab backgrounded or network issue) — reloading the page resets the loop.
---
## Offline recovery
If the API goes offline while you are viewing a page:
1. The indicator turns red immediately on the next failed poll (within 15 s)
2. The last successfully loaded data stays visible
3. Once the API restarts, the indicator turns green on the next poll — **no page reload needed**
To restart the API:
```bash
cd ~/the-custodian/state-hub
make api # starts uvicorn on 127.0.0.1:8000
# or, if postgres is not running:
make start # db + migrate + api
```
---
## Which data each page polls
| Page | Endpoints |
|---|---|
| Overview | `/state/summary` |
| Workstreams | `/workstreams/`, `/topics/`, `/state/summary` |
| Decisions | `/decisions/?limit=500`, `/topics/` |
| Progress | `/progress/?limit=500` |
All endpoints are read-only GET requests. The dashboard never writes to the API.
---
*Poll interval: 15 s. Data is refreshed in the background — the page never reloads itself.*

View File

@@ -68,12 +68,17 @@ const regsState = (async function*() {
# Custodian State Hub
```js
display(html`<div class="live-bar">
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()}`
: `<span style="color:red">Offline — run: <code>cd ~/the-custodian/state-hub && make api</code></span>`}
</div>`);
: html`<span style="color:red">Offline — run: <code>cd ~/the-custodian/state-hub && make api</code></span>`}
</div>`;
withDocHelp(_liveEl, "/docs/live-data");
injectTocTop("live-indicator", _liveEl);
```
```js
@@ -395,7 +400,7 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({
```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
.live-indicator { font-size: 0.8rem; color: gray; margin-bottom: 0.75rem; position: relative; padding-right: 1.6rem; }
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
.card.warn { border: 2px solid orange; }
.card-link { cursor: pointer; transition: box-shadow 0.15s, transform 0.1s; text-decoration: none; color: inherit; display: block; }

View File

@@ -33,12 +33,17 @@ const _ts = progState.ts;
*Append-only per constitution §5 — no deletions.*
```js
display(html`<div class="live-bar">
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`
: `<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`);
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`;
withDocHelp(_liveEl, "/docs/live-data");
injectTocTop("live-indicator", _liveEl);
```
```js
@@ -101,5 +106,5 @@ display(byDay.length === 0
```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
.live-indicator { font-size: 0.8rem; color: gray; margin-bottom: 0.75rem; position: relative; padding-right: 1.6rem; }
</style>

View File

@@ -47,12 +47,17 @@ const _ts = wsState.ts;
# Workstreams
```js
display(html`<div class="live-bar">
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()}`
: `<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`);
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`;
withDocHelp(_liveEl, "/docs/live-data");
injectTocTop("live-indicator", _liveEl);
```
```js
@@ -145,7 +150,7 @@ if (wsWithDeps.length === 0) {
```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
.live-indicator { font-size: 0.8rem; color: gray; margin-bottom: 0.75rem; position: relative; padding-right: 1.6rem; }
.dim { color: gray; font-style: italic; }
.filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.filter-owner { display: flex; align-items: center; }