diff --git a/dashboard/src/components/entity-modal.js b/dashboard/src/components/entity-modal.js index 46610b5..362b8ab 100644 --- a/dashboard/src/components/entity-modal.js +++ b/dashboard/src/components/entity-modal.js @@ -401,12 +401,18 @@ export function buildEntityTable(rows, columns, onRowClick) { for (const col of columns) { const td = document.createElement("td"); const raw = col.key ? row[col.key] : null; - const text = col.render ? col.render(row) : (raw ?? "—"); - const textStr = String(text ?? "—"); - td.textContent = textStr; + const rendered = col.render ? col.render(row) : (raw ?? "—"); + let textStr = ""; + if (rendered instanceof Node) { + td.append(rendered); + textStr = td.textContent ?? ""; + } else { + textStr = String(rendered ?? "—"); + td.textContent = textStr; + } if (col.cls) td.className = col.cls; // Native tooltip so full value shows on hover (skip placeholder "—") - if (textStr && textStr !== "—") td.title = textStr; + if (!(rendered instanceof Node) && textStr && textStr !== "—") td.title = textStr; tr.append(td); } tbody.append(tr); diff --git a/dashboard/src/components/status-control.js b/dashboard/src/components/status-control.js new file mode 100644 index 0000000..c73deae --- /dev/null +++ b/dashboard/src/components/status-control.js @@ -0,0 +1,160 @@ +import {apiFetch} from "./config.js"; +import {WORKSTREAM_STATUSES} from "./workplan-status.js"; + +const STYLE_ID = "status-control-styles"; + +export const TASK_STATUSES = ["todo", "in_progress", "blocked", "done", "cancelled"]; +export {WORKSTREAM_STATUSES}; + +function ensureStyles() { + if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.status-control { + display: inline-flex; + align-items: center; + gap: 0.4rem; + max-width: 100%; +} +.status-control-select { + min-width: 8.5rem; + max-width: 100%; + height: 1.85rem; + border: 1px solid var(--theme-foreground-faint, #d1d5db); + border-radius: 6px; + background: var(--theme-background, #fff); + color: var(--theme-foreground, #111); + font: inherit; + font-size: 0.82rem; + padding: 0.18rem 0.45rem; +} +.status-control-select:disabled { + opacity: 0.65; +} +.status-control-message { + min-width: 3.5rem; + font-size: 0.72rem; + color: var(--theme-foreground-muted, #6b7280); + white-space: nowrap; +} +.status-control-message.status-control-error { + color: #dc2626; +} +.status-control-message.status-control-ok { + color: #16a34a; +} +`; + document.head.append(style); +} + +function labelForStatus(status) { + return String(status ?? "").replace(/_/g, " "); +} + +function endpointFor(type, id) { + if (type === "task") return `/tasks/${id}`; + if (type === "workstream") return `/workstreams/${id}`; + throw new Error(`Unsupported status-control type: ${type}`); +} + +async function readError(response) { + try { + const body = await response.json(); + if (Array.isArray(body?.detail)) return body.detail.map(d => d.msg ?? String(d)).join("; "); + return body?.detail ?? `HTTP ${response.status}`; + } catch { + return `HTTP ${response.status}`; + } +} + +export function statusControl({ + entity, + type, + statuses = type === "task" ? TASK_STATUSES : WORKSTREAM_STATUSES, + onSaved = null, +} = {}) { + ensureStyles(); + + let currentStatus = entity?.status ?? ""; + const root = document.createElement("span"); + root.className = "status-control"; + root.addEventListener("click", event => event.stopPropagation()); + root.addEventListener("mousedown", event => event.stopPropagation()); + + const select = document.createElement("select"); + select.className = "status-control-select"; + select.setAttribute("aria-label", "Status"); + for (const status of statuses) { + const option = document.createElement("option"); + option.value = status; + option.textContent = labelForStatus(status); + select.append(option); + } + select.value = currentStatus; + + const message = document.createElement("span"); + message.className = "status-control-message"; + + function setMessage(text, kind = "") { + message.textContent = text; + message.classList.toggle("status-control-error", kind === "error"); + message.classList.toggle("status-control-ok", kind === "ok"); + } + + select.addEventListener("change", async () => { + const nextStatus = select.value; + if (nextStatus === currentStatus) return; + + const payload = {status: nextStatus}; + if (type === "task" && nextStatus === "blocked") { + const existingReason = entity?.blocking_reason ?? ""; + const reason = existingReason || window.prompt("Blocking reason required for blocked tasks:"); + if (!reason) { + select.value = currentStatus; + setMessage("unchanged"); + return; + } + payload.blocking_reason = reason; + } + + if (type === "task" && nextStatus === "done" && currentStatus !== "done") { + const ok = window.confirm( + "Mark this task done? Without token fields, State Hub records a heuristic token event." + ); + if (!ok) { + select.value = currentStatus; + setMessage("unchanged"); + return; + } + } + + select.disabled = true; + setMessage("saving"); + try { + const response = await apiFetch(endpointFor(type, entity.id), { + method: "PATCH", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(await readError(response)); + const updated = await response.json(); + Object.assign(entity, updated); + currentStatus = updated.status; + select.value = currentStatus; + setMessage("saved", "ok"); + if (typeof onSaved === "function") onSaved(updated); + setTimeout(() => { + if (message.textContent === "saved") setMessage(""); + }, 2200); + } catch (error) { + select.value = currentStatus; + setMessage(error?.message ?? "save failed", "error"); + } finally { + select.disabled = false; + } + }); + + root.append(select, message); + return root; +} diff --git a/dashboard/src/tasks.md b/dashboard/src/tasks.md index 6a83d00..4bdd1b6 100644 --- a/dashboard/src/tasks.md +++ b/dashboard/src/tasks.md @@ -92,6 +92,7 @@ const filtered = data.filter(t => import {injectTocTop} from "./components/toc-sidebar.js"; import {withDocHelp} from "./components/doc-overlay.js"; import {openEntityModal, buildEntityTable} from "./components/entity-modal.js"; +import {statusControl, TASK_STATUSES} from "./components/status-control.js"; // ── KPI sidebar card ───────────────────────────────────────────────────────── const _open = data.filter(t => ["todo", "in_progress", "blocked"].includes(t.status)); @@ -219,7 +220,7 @@ const sorted = [...filtered].sort((a, b) => { display(buildEntityTable( sorted, [ - {label: "Status", key: "status"}, + {label: "Status", render: t => statusControl({entity: t, type: "task", statuses: TASK_STATUSES})}, {label: "Priority", key: "priority"}, {label: "Title", key: "title", cls: "et-title-col et-title-cell"}, {label: "Domain", key: "domain"}, diff --git a/dashboard/src/tasks/[id].md b/dashboard/src/tasks/[id].md index 8e7308d..92299e1 100644 --- a/dashboard/src/tasks/[id].md +++ b/dashboard/src/tasks/[id].md @@ -5,6 +5,7 @@ title: Task ```js import {API} from "../components/config.js"; import {fieldRow} from "../components/field-help.js"; +import {statusControl, TASK_STATUSES} from "../components/status-control.js"; ``` ```js @@ -22,16 +23,40 @@ if (raw.error) { const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name; display(html`
No task content recorded.
`); + } const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null)); for (const k of Object.keys(raw)) { - if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k])); + if (!FIELD_ORDER.includes(k) && !HIDDEN_FIELDS.includes(k)) rows.push(fieldRow(k, raw[k])); } display(html`| ${repoSlug} | + ${[...repoBreakdown.entries()].map(([repoKey, snap]) => html`||||||||||
| ${repoCell(repoKey)} |
${snap.entries.map(e => {
const cat = catalogBySlug[e.service_slug];
@@ -191,3 +224,27 @@ const repoTable = html`
|