feat(dashboard): Interventions page improvements and action-confirm modal
- Move Interventions under Workstreams in the navigator - Add action-confirm.js: shared modal component for actions requiring a mandatory comment (survives live-poll re-renders, unlike inline DOM mutation) - Wire action-confirm into Interventions (Mark done) and Decisions (Resolve) - Fix Interventions completed section: fetch all tasks and filter client-side so resolved interventions (needs_human=false) still appear under Completed - Add docs/interventions.md help page with ? button on the h1 - Replace all hardcoded "Bernd" with "human" across dashboard src and docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@ export default {
|
||||
// ── Overview ──────────────────────────────────────────────────────────────
|
||||
{ name: "Overview", path: "/" },
|
||||
{ name: "Todo", path: "/todo" },
|
||||
{ name: "Interventions", path: "/interventions" },
|
||||
// ── Organizational Entity Views ───────────────────────────────────────────
|
||||
{ name: "Domains", path: "/domains" },
|
||||
{ name: "Repos", path: "/repos" },
|
||||
@@ -15,11 +14,12 @@ export default {
|
||||
collapsible: true,
|
||||
open: false,
|
||||
pages: [
|
||||
{ name: "Decisions", path: "/decisions" },
|
||||
{ name: "Tasks", path: "/tasks" },
|
||||
{ name: "Debt", path: "/techdept" },
|
||||
{ name: "Extends", path: "/extensions" },
|
||||
{ name: "Dependencies", path: "/dependencies" },
|
||||
{ name: "Decisions", path: "/decisions" },
|
||||
{ name: "Tasks", path: "/tasks" },
|
||||
{ name: "Interventions", path: "/interventions" },
|
||||
{ name: "Debt", path: "/techdept" },
|
||||
{ name: "Extends", path: "/extensions" },
|
||||
{ name: "Dependencies", path: "/dependencies" },
|
||||
],
|
||||
},
|
||||
// ── Functional Report Views ────────────────────────────────────────────────
|
||||
@@ -49,6 +49,7 @@ export default {
|
||||
{ name: "Domains", path: "/docs/domains" },
|
||||
{ name: "Extension Points", path: "/docs/extensions" },
|
||||
{ name: "Inter-Repo Communication", path: "/docs/inter-repo-communication" },
|
||||
{ name: "Interventions", path: "/docs/interventions" },
|
||||
{ name: "Live Data", path: "/docs/live-data" },
|
||||
{ name: "Overview", path: "/docs/overview" },
|
||||
{ name: "Progress Log", path: "/docs/progress-log" },
|
||||
|
||||
270
state-hub/dashboard/src/components/action-confirm.js
Normal file
270
state-hub/dashboard/src/components/action-confirm.js
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* action-confirm — modal dialog that requires a non-empty comment before
|
||||
* confirming an action (e.g. resolving a decision, marking an intervention done).
|
||||
*
|
||||
* Lives on document.body so it survives live-poll re-renders of the page content.
|
||||
*
|
||||
* Usage:
|
||||
* import {openActionConfirm} from "./components/action-confirm.js";
|
||||
*
|
||||
* openActionConfirm({
|
||||
* title: "Mark as Done", // modal header
|
||||
* entityTitle: task.title, // shown as context below the header
|
||||
* label: "Resolution comment", // textarea label
|
||||
* placeholder: "What was done?", // textarea placeholder
|
||||
* confirmLabel: "Mark Done", // confirm button text
|
||||
* onConfirm: async (comment) => { ... }, // called with trimmed comment
|
||||
* // onConfirm should throw (or return a rejected promise) on API error
|
||||
* });
|
||||
*/
|
||||
|
||||
const _STYLE_ID = "action-confirm-styles";
|
||||
|
||||
function _ensureStyles() {
|
||||
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
|
||||
const s = document.createElement("style");
|
||||
s.id = _STYLE_ID;
|
||||
s.textContent = `
|
||||
/* ── Backdrop ────────────────────────────────────────────────────────────── */
|
||||
.ac-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
||||
z-index: 9200; display: flex; align-items: center; justify-content: center;
|
||||
animation: _ac-fade 0.15s ease;
|
||||
}
|
||||
@keyframes _ac-fade { from { opacity: 0 } to { opacity: 1 } }
|
||||
|
||||
/* ── Box ──────────────────────────────────────────────────────────────────── */
|
||||
.ac-box {
|
||||
width: min(480px, 92vw);
|
||||
background: var(--theme-background, #fff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 56px rgba(0,0,0,0.28);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
animation: _ac-rise 0.15s ease;
|
||||
}
|
||||
@keyframes _ac-rise {
|
||||
from { transform: translateY(12px); opacity: 0 }
|
||||
to { transform: translateY(0); opacity: 1 }
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────────────────────── */
|
||||
.ac-header {
|
||||
display: flex; align-items: flex-start; gap: 0.75rem;
|
||||
padding: 0.85rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
|
||||
background: var(--theme-background-alt, #f7f7f7);
|
||||
}
|
||||
.ac-title {
|
||||
flex: 1; font-size: 0.95rem; font-weight: 700; line-height: 1.3;
|
||||
color: var(--theme-foreground, #111);
|
||||
}
|
||||
.ac-close {
|
||||
background: none; border: 1px solid transparent; cursor: pointer;
|
||||
font-size: 0.9rem; color: var(--theme-foreground-muted, #888);
|
||||
padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0;
|
||||
font-family: inherit; line-height: 1.3;
|
||||
}
|
||||
.ac-close:hover {
|
||||
border-color: var(--theme-foreground-faint, #ccc);
|
||||
background: var(--theme-background, #fff); color: var(--theme-foreground, #111);
|
||||
}
|
||||
|
||||
/* ── Body ─────────────────────────────────────────────────────────────────── */
|
||||
.ac-body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
|
||||
.ac-entity-title {
|
||||
font-size: 0.85rem; color: var(--theme-foreground-muted, #555);
|
||||
background: var(--theme-background-alt, #f9f9f9);
|
||||
border: 1px solid var(--theme-foreground-faint, #eee);
|
||||
border-radius: 6px; padding: 0.45rem 0.65rem; line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ac-label {
|
||||
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--theme-foreground-muted, #666);
|
||||
margin-bottom: 0.25rem; display: block;
|
||||
}
|
||||
|
||||
.ac-textarea {
|
||||
width: 100%; box-sizing: border-box;
|
||||
min-height: 80px; resize: vertical;
|
||||
font-size: 0.87rem; line-height: 1.5; font-family: inherit;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--theme-foreground-faint, #d1d5db);
|
||||
border-radius: 6px;
|
||||
background: var(--theme-background, #fff);
|
||||
color: var(--theme-foreground, #111);
|
||||
transition: border-color 0.1s, box-shadow 0.1s;
|
||||
}
|
||||
.ac-textarea:focus { outline: none; border-color: steelblue; box-shadow: 0 0 0 2px #bfdbfe; }
|
||||
.ac-textarea.ac-invalid { border-color: #dc2626; box-shadow: 0 0 0 2px #fecaca; }
|
||||
|
||||
.ac-error {
|
||||
font-size: 0.8rem; color: #dc2626;
|
||||
background: #fef2f2; border: 1px solid #fecaca;
|
||||
border-radius: 6px; padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
/* ── Footer ───────────────────────────────────────────────────────────────── */
|
||||
.ac-footer {
|
||||
display: flex; justify-content: flex-end; gap: 0.5rem;
|
||||
padding: 0.65rem 1rem 0.9rem;
|
||||
border-top: 1px solid var(--theme-foreground-faint, #e8e8e8);
|
||||
}
|
||||
.ac-btn-cancel {
|
||||
padding: 0.3rem 0.85rem; border-radius: 6px;
|
||||
border: 1px solid var(--theme-foreground-faint, #d1d5db);
|
||||
background: var(--theme-background, #fff);
|
||||
color: var(--theme-foreground-muted, #555);
|
||||
font-size: 0.85rem; font-family: inherit; cursor: pointer;
|
||||
}
|
||||
.ac-btn-cancel:hover { border-color: #9ca3af; color: var(--theme-foreground, #111); }
|
||||
|
||||
.ac-btn-confirm {
|
||||
padding: 0.3rem 0.85rem; border-radius: 6px;
|
||||
border: 1px solid #22c55e; background: #f0fdf4; color: #166534;
|
||||
font-size: 0.85rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ac-btn-confirm:hover:not(:disabled) { background: #dcfce7; }
|
||||
.ac-btn-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
`;
|
||||
document.head.append(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.title Modal header text
|
||||
* @param {string} [opts.entityTitle] Optional context shown below the header
|
||||
* @param {string} opts.label Textarea label
|
||||
* @param {string} [opts.placeholder] Textarea placeholder
|
||||
* @param {string} [opts.confirmLabel] Confirm button label (default: "Confirm")
|
||||
* @param {Function} opts.onConfirm async (comment: string) => void — throw to show error
|
||||
*/
|
||||
export function openActionConfirm({
|
||||
title,
|
||||
entityTitle,
|
||||
label,
|
||||
placeholder = "Add a comment…",
|
||||
confirmLabel = "Confirm",
|
||||
onConfirm,
|
||||
}) {
|
||||
_ensureStyles();
|
||||
document.getElementById("_ac-root")?.remove();
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.id = "_ac-root";
|
||||
root.className = "ac-backdrop";
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-modal", "true");
|
||||
root.setAttribute("aria-label", title);
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────────
|
||||
const header = document.createElement("div");
|
||||
header.className = "ac-header";
|
||||
const titleEl = document.createElement("div");
|
||||
titleEl.className = "ac-title";
|
||||
titleEl.textContent = title;
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "ac-close";
|
||||
closeBtn.textContent = "✕";
|
||||
closeBtn.setAttribute("aria-label", "Cancel");
|
||||
header.append(titleEl, closeBtn);
|
||||
|
||||
// ── Body ──────────────────────────────────────────────────────────────────
|
||||
const body = document.createElement("div");
|
||||
body.className = "ac-body";
|
||||
|
||||
if (entityTitle) {
|
||||
const ctx = document.createElement("div");
|
||||
ctx.className = "ac-entity-title";
|
||||
ctx.textContent = entityTitle;
|
||||
body.append(ctx);
|
||||
}
|
||||
|
||||
const labelEl = document.createElement("label");
|
||||
labelEl.className = "ac-label";
|
||||
labelEl.textContent = label;
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "ac-textarea";
|
||||
textarea.placeholder = placeholder;
|
||||
textarea.rows = 3;
|
||||
labelEl.append(textarea); // make label clickable
|
||||
|
||||
const fieldWrap = document.createElement("div");
|
||||
fieldWrap.append(labelEl);
|
||||
body.append(fieldWrap);
|
||||
|
||||
const errorEl = document.createElement("div");
|
||||
errorEl.className = "ac-error";
|
||||
errorEl.style.display = "none";
|
||||
body.append(errorEl);
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────────────
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "ac-footer";
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = "ac-btn-cancel";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.className = "ac-btn-confirm";
|
||||
confirmBtn.textContent = confirmLabel;
|
||||
|
||||
footer.append(cancelBtn, confirmBtn);
|
||||
|
||||
// ── Assemble ──────────────────────────────────────────────────────────────
|
||||
const box = document.createElement("div");
|
||||
box.className = "ac-box";
|
||||
box.append(header, body, footer);
|
||||
root.append(box);
|
||||
document.body.append(root);
|
||||
|
||||
// Focus the textarea after animation
|
||||
setTimeout(() => textarea.focus(), 50);
|
||||
|
||||
// ── Behaviour ─────────────────────────────────────────────────────────────
|
||||
const close = () => root.remove();
|
||||
|
||||
cancelBtn.addEventListener("click", close);
|
||||
closeBtn.addEventListener("click", close);
|
||||
root.addEventListener("click", e => { if (e.target === root) close(); });
|
||||
|
||||
const onKey = e => {
|
||||
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
textarea.addEventListener("input", () => {
|
||||
textarea.classList.remove("ac-invalid");
|
||||
errorEl.style.display = "none";
|
||||
});
|
||||
|
||||
confirmBtn.addEventListener("click", async () => {
|
||||
const comment = textarea.value.trim();
|
||||
if (!comment) {
|
||||
textarea.classList.add("ac-invalid");
|
||||
textarea.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
confirmBtn.disabled = true;
|
||||
cancelBtn.disabled = true;
|
||||
confirmBtn.textContent = "…";
|
||||
errorEl.style.display = "none";
|
||||
|
||||
try {
|
||||
await onConfirm(comment);
|
||||
close();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err?.message ?? "Request failed — check that the API is running.";
|
||||
errorEl.style.display = "";
|
||||
confirmBtn.disabled = false;
|
||||
cancelBtn.disabled = false;
|
||||
confirmBtn.textContent = confirmLabel;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -103,8 +103,9 @@ function fmtDuration(ms) {
|
||||
# Decisions
|
||||
|
||||
```js
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
import {injectTocTop} from "./components/toc-sidebar.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
|
||||
@@ -331,6 +332,24 @@ if (filtered.length === 0) {
|
||||
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}/`, {
|
||||
method: "PATCH",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({status: "resolved", 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>
|
||||
@@ -343,6 +362,7 @@ if (filtered.length === 0) {
|
||||
</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>` : ""}
|
||||
@@ -453,6 +473,10 @@ if (escalated.length > 0) {
|
||||
.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>
|
||||
|
||||
@@ -105,7 +105,7 @@ Any `pending` decision whose title or rationale contains financial or legal keyw
|
||||
- Sort to the top of the list
|
||||
- Carry an amber escalation note explaining the reason
|
||||
- Trigger the warning box at the bottom of the page listing all escalated items
|
||||
- **Must be reviewed and approved by Bernd before any related action is taken (constitution §4)**
|
||||
- **Must be reviewed and approved by the human before any related action is taken (constitution §4)**
|
||||
|
||||
To clear an escalation, resolve the decision via `resolve_decision()` in the MCP server or the REST API.
|
||||
|
||||
|
||||
84
state-hub/dashboard/src/docs/interventions.md
Normal file
84
state-hub/dashboard/src/docs/interventions.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: Interventions — Reference
|
||||
---
|
||||
|
||||
# Interventions — Reference
|
||||
|
||||
The Interventions page lists every task that has been flagged `needs_human=true` — actions that only a human can take. It polls the API every 15 seconds and organises tasks into **Open** and **Completed / Cancelled** sections.
|
||||
|
||||
---
|
||||
|
||||
## What is a human intervention?
|
||||
|
||||
A human intervention is a task that an agent has determined cannot proceed without direct human action — for example, approving a financial decision, confirming a legal commitment, or resolving a sensitive ambiguity. Flagging a task does not change its work status; the task continues to be `todo`, `in_progress`, or `blocked` while awaiting attention.
|
||||
|
||||
---
|
||||
|
||||
## KPI sidebar card
|
||||
|
||||
| Metric | Meaning |
|
||||
|---|---|
|
||||
| **open** | Number of tasks currently flagged and not yet resolved |
|
||||
| **critical / high** | Subset with `critical` or `high` priority — shown in red when non-zero |
|
||||
| **by domain** | Top-3 domains by open intervention count |
|
||||
|
||||
---
|
||||
|
||||
## Open section
|
||||
|
||||
Tasks are sorted by priority (critical → high → medium → low), then by status (blocked → in_progress → todo). Each card shows:
|
||||
|
||||
| Element | Meaning |
|
||||
|---|---|
|
||||
| Priority badge | `critical` / `high` / `medium` / `low` |
|
||||
| Status chip | Current task status |
|
||||
| Domain | Source domain slug |
|
||||
| Workstream | Parent workstream title |
|
||||
| Action note | The `intervention_note` — what the human needs to do |
|
||||
| Task detail | Expandable `<details>` with the task title and description (shown when different from the action note) |
|
||||
|
||||
---
|
||||
|
||||
## Marking an intervention as done
|
||||
|
||||
Click **Mark done** on any open card. An inline form appears requiring a **resolution comment** — a short note describing what was done. The comment is mandatory; clicking **Confirm** without entering text highlights the field in red and does nothing.
|
||||
|
||||
Once confirmed, the API call sets `status = done`, `needs_human = false`, and replaces the action note with your resolution comment. The card moves to the **Completed / Cancelled** section on the next poll.
|
||||
|
||||
Click **Cancel** to dismiss the form without making changes.
|
||||
|
||||
---
|
||||
|
||||
## Flagging and clearing interventions via MCP
|
||||
|
||||
```
|
||||
flag_for_human(
|
||||
task_id = "<uuid>",
|
||||
note = "Approve the Q2 budget allocation before the 15th"
|
||||
)
|
||||
```
|
||||
|
||||
```
|
||||
clear_human_flag(task_id = "<uuid>")
|
||||
```
|
||||
|
||||
`clear_human_flag` sets `needs_human = false` and preserves the `intervention_note` as a historical record. It does **not** change the task's work status. Use the dashboard's **Mark done** button when you also want to advance the status to `done`.
|
||||
|
||||
---
|
||||
|
||||
## Filtering by workstream
|
||||
|
||||
`list_human_interventions(workstream_id="<uuid>")` via MCP returns only interventions for a specific workstream — useful for scoped reviews in agent sessions.
|
||||
|
||||
---
|
||||
|
||||
## REST endpoints
|
||||
|
||||
| Method | Path | Effect |
|
||||
|---|---|---|
|
||||
| `GET` | `/tasks/?needs_human=true` | List all flagged tasks |
|
||||
| `PATCH` | `/tasks/{id}/` | Update status, needs_human, intervention_note |
|
||||
|
||||
---
|
||||
|
||||
*Interventions are never created from the dashboard — they are raised by agents during task execution and resolved by the human.*
|
||||
@@ -64,7 +64,7 @@ Every Claude Code session working in this repository should:
|
||||
1. **Start** — call `get_state_summary()` for orientation
|
||||
2. **End** — call `add_progress_event()` to log what was done, decided, or discovered
|
||||
|
||||
A session that produces no progress events is invisible to future sessions and to Bernd. The log is how continuity is maintained across context windows.
|
||||
A session that produces no progress events is invisible to future sessions and to the human. The log is how continuity is maintained across context windows.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +87,7 @@ Via the REST API directly:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/progress/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"summary": "…", "event_type": "note", "author": "Bernd"}'
|
||||
-d '{"summary": "…", "event_type": "note", "author": "human"}'
|
||||
```
|
||||
|
||||
---
|
||||
@@ -96,7 +96,7 @@ curl -X POST http://127.0.0.1:8000/progress/ \
|
||||
|
||||
| Filter | Effect |
|
||||
|---|---|
|
||||
| **Author** | Restrict to events by a specific author (`custodian`, `Bernd`, etc.) |
|
||||
| **Author** | Restrict to events by a specific author (`custodian`, `human`, etc.) |
|
||||
| **Event type** | Restrict to one category of event |
|
||||
| **Since** | Show only events after a chosen date |
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ create_workstream(
|
||||
title = "Build user authentication",
|
||||
description = "JWT-based auth, refresh tokens, middleware",
|
||||
status = "active",
|
||||
owner = "Bernd",
|
||||
owner = "human",
|
||||
due_date = "2026-04-01"
|
||||
)
|
||||
```
|
||||
|
||||
@@ -479,7 +479,7 @@ if (blocking.length === 0) {
|
||||
<label>Your decision & rationale</label>
|
||||
<textarea class="r-text" rows="4" placeholder="State the chosen option and your reasoning…"></textarea>
|
||||
<label>Decided by</label>
|
||||
<input class="r-by" type="text" value="Bernd">
|
||||
<input class="r-by" type="text" value="human">
|
||||
<div class="dec-resolve-actions">
|
||||
<button class="r-submit">Record & close</button>
|
||||
<span class="r-msg"></span>
|
||||
@@ -512,7 +512,7 @@ if (blocking.length === 0) {
|
||||
|
||||
btn.addEventListener("click", async () => {
|
||||
const rationale = card.querySelector(".r-text").value.trim();
|
||||
const decidedBy = card.querySelector(".r-by").value.trim() || "Bernd";
|
||||
const decidedBy = card.querySelector(".r-by").value.trim() || "human";
|
||||
if (!rationale) { msg.textContent = "⚠ Please enter a rationale."; return; }
|
||||
btn.disabled = true; btn.textContent = "Saving…";
|
||||
try {
|
||||
|
||||
@@ -8,13 +8,13 @@ const POLL = 15_000;
|
||||
```
|
||||
|
||||
```js
|
||||
// Live poll: human-flagged tasks + workstreams + topics
|
||||
// Live poll: all tasks (filtered client-side) + workstreams + topics
|
||||
const interventionState = (async function*() {
|
||||
while (true) {
|
||||
let tasks = [], wsMap = {}, ok = false;
|
||||
try {
|
||||
const [rt, rw, rto, rr] = await Promise.all([
|
||||
fetch(`${API}/tasks/?needs_human=true`),
|
||||
fetch(`${API}/tasks/?limit=500`),
|
||||
fetch(`${API}/workstreams/`),
|
||||
fetch(`${API}/topics/`),
|
||||
fetch(`${API}/repos/`),
|
||||
@@ -51,8 +51,10 @@ const _ts = interventionState.ts;
|
||||
|
||||
```js
|
||||
const OPEN_STATUSES = new Set(["todo", "in_progress", "blocked"]);
|
||||
const open = tasks.filter(t => OPEN_STATUSES.has(t.status));
|
||||
const closed = tasks.filter(t => !OPEN_STATUSES.has(t.status));
|
||||
// open = currently flagged for human action
|
||||
// closed = previously flagged (intervention_note records the resolution comment)
|
||||
const open = tasks.filter(t => t.needs_human === true);
|
||||
const closed = tasks.filter(t => t.intervention_note && !OPEN_STATUSES.has(t.status) && !t.needs_human);
|
||||
|
||||
// Domain breakdown for top-3
|
||||
const domainCounts = {};
|
||||
@@ -107,15 +109,20 @@ withDocHelp(_liveEl, "/docs/live-data");
|
||||
|
||||
injectTocTop("intervention-kpi-box", _kpiBox);
|
||||
injectTocTop("live-indicator", _liveEl);
|
||||
|
||||
const _h1 = document.querySelector("#observablehq-main h1");
|
||||
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/interventions"); }
|
||||
```
|
||||
|
||||
Tasks flagged `needs_human=true` — actions only Bernd can take.
|
||||
Tasks flagged `needs_human=true` — actions only a human can take.
|
||||
|
||||
---
|
||||
|
||||
## Open
|
||||
|
||||
```js
|
||||
import {openActionConfirm} from "./components/action-confirm.js";
|
||||
|
||||
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
|
||||
const STATUS_ORDER = {blocked: 0, in_progress: 1, todo: 2};
|
||||
|
||||
@@ -127,26 +134,35 @@ function sortTasks(arr) {
|
||||
});
|
||||
}
|
||||
|
||||
async function markDone(taskId) {
|
||||
try {
|
||||
await fetch(`${API}/tasks/${taskId}/`, {
|
||||
method: "PATCH",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({status: "done", needs_human: false, intervention_note: null}),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function renderCard(t) {
|
||||
const isOpen = OPEN_STATUSES.has(t.status);
|
||||
const borderColor = isOpen ? "#f59e0b" : "#22c55e";
|
||||
|
||||
function onMarkDone() {
|
||||
openActionConfirm({
|
||||
title: "Mark Intervention as Done",
|
||||
entityTitle: t.title,
|
||||
label: "Resolution comment",
|
||||
placeholder: "What was done to resolve this?",
|
||||
confirmLabel: "Mark Done",
|
||||
onConfirm: async (comment) => {
|
||||
const res = await fetch(`${API}/tasks/${t.id}/`, {
|
||||
method: "PATCH",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({status: "done", needs_human: false, intervention_note: comment}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`API error ${res.status}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return html`<div class="intervention-card" style="border-left-color:${borderColor}">
|
||||
<div class="int-card-header">
|
||||
<span class="task-badge task-priority-${t.priority}">${t.priority}</span>
|
||||
<span class="task-status-chip status-chip-${t.status}">${t.status.replace("_", " ")}</span>
|
||||
<span class="task-context">${t.domain}</span>
|
||||
<span class="task-context task-ws-name">${t.workstream_title}</span>
|
||||
${isOpen ? html`<button class="done-btn" onclick=${() => markDone(t.id)}>Mark done</button>` : ""}
|
||||
${isOpen ? html`<button class="done-btn" onclick=${onMarkDone}>Mark done</button>` : ""}
|
||||
</div>
|
||||
<div class="int-action">${t.intervention_note ?? "(no note)"}</div>
|
||||
${t.title !== t.intervention_note ? html`<details class="int-desc"><summary>Task: ${t.title}</summary>${t.description ?? ""}</details>` : ""}
|
||||
|
||||
Reference in New Issue
Block a user