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`

Task · ${shortName}

`); display(html`

← Tasks  |  ← Token Cost

`); + display(html`
+
+ Status + ${statusControl({ + entity: raw, + type: "task", + statuses: TASK_STATUSES, + onSaved: () => setTimeout(() => location.reload(), 450), + })} +
+
Priority${raw.priority ?? "—"}
+
Assignee${raw.assignee ?? "—"}
+
`); const FIELD_ORDER = [ "id","title","status","priority","assignee", "workstream_id","due_date","needs_human","intervention_note", "created_at","updated_at", ]; + const HIDDEN_FIELDS = ["description"]; + + const taskContent = (raw.description ?? "").trim(); + if (taskContent) { + display(html`
+

Task Content

+
${taskContent}
+
`); + } else { + 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` @@ -40,3 +65,52 @@ if (raw.error) {
`); } ``` + + diff --git a/dashboard/src/tpsc.md b/dashboard/src/tpsc.md index 5a7465d..360aa23 100644 --- a/dashboard/src/tpsc.md +++ b/dashboard/src/tpsc.md @@ -19,6 +19,10 @@ const gdprReport = await fetch(`${API}/tpsc/report/gdpr`) const snapshots = await fetch(`${API}/tpsc/snapshots/`) .then(r => r.json()) .catch(() => []); + +const repos = await fetch(`${API}/repos/`) + .then(r => r.ok ? r.json() : []) + .catch(() => []); ``` ```js @@ -150,12 +154,41 @@ if (gdprReport.warnings.length === 0) { ## Per-Repo Breakdown ```js +const repoById = Object.fromEntries(repos.map(r => [r.id, r])); +const repoBySlug = Object.fromEntries(repos.map(r => [r.slug, r])); + +function repoForSnapshotKey(repoKey) { + return repoById[repoKey] ?? repoBySlug[repoKey] ?? null; +} + +function repoWebUrl(repo) { + const url = repo?.remote_url ?? ""; + return /^https?:\/\//.test(url) ? url : null; +} + +function repoCell(repoKey) { + const repo = repoForSnapshotKey(repoKey); + const webUrl = repoWebUrl(repo); + const label = repo?.name || repo?.slug || repoKey || "unknown repo"; + const slug = repo?.slug ?? repoKey ?? "unknown"; + const domain = repo?.domain_slug ?? "unknown"; + const content = html`
+
+ ${webUrl + ? html`${label}` + : label} +
+
${domain} / ${slug}
+
`; + return content; +} + // Build: latest snapshot per repo → service list const repoBreakdown = new Map(); for (const snap of snapshots) { - const repoSlug = snap.repo_id || "unknown"; - if (!repoBreakdown.has(repoSlug) || snap.snapshot_at > repoBreakdown.get(repoSlug).snapshot_at) { - repoBreakdown.set(repoSlug, snap); + const repoKey = snap.repo_id || snap.repo_slug || "unknown"; + if (!repoBreakdown.has(repoKey) || snap.snapshot_at > repoBreakdown.get(repoKey).snapshot_at) { + repoBreakdown.set(repoKey, snap); } } @@ -171,8 +204,8 @@ const repoTable = html` - + ${[...repoBreakdown.entries()].map(([repoKey, snap]) => html` +
${repoSlug}
${repoCell(repoKey)} ${snap.entries.map(e => { const cat = catalogBySlug[e.service_slug]; @@ -191,3 +224,27 @@ const repoTable = html`= 0 && i < STEPS.length - 1 ? STEPS[i + 1] : null; @@ -58,7 +65,7 @@ const feedbackState = (async function*() { data = items .filter(t => t.debt_type === "dashboard-improvement") .sort((a, b) => { - const st = {submitted:0, analyse:1, plan:2, implement:3, test:4, review:5, finished:6, wont_fix:7, open:8, in_progress:9, resolved:10}; + const st = {submitted:0, analyse:1, plan:2, implement:3, test:4, review:5, finished:6, addressed:7, resolved:8, wont_fix:9, open:10, in_progress:11}; return (st[a.status] ?? 99) - (st[b.status] ?? 99); }); } @@ -82,8 +89,8 @@ const _ts = feedbackState.ts; import {injectTocTop} from "./components/toc-sidebar.js"; import {withDocHelp} from "./components/doc-overlay.js"; -const _active = data.filter(t => t.status !== "finished" && t.status !== "wont_fix"); -const _finished = data.filter(t => t.status === "finished"); +const _active = data.filter(t => !CLOSED_STATUSES.has(t.status)); +const _finished = data.filter(t => ["finished", "addressed", "resolved"].includes(t.status)); const _wontfix = data.filter(t => t.status === "wont_fix"); const _kpiBox = html`
diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index c66837e..1b866a4 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -141,6 +141,7 @@ import {injectTocTop} from "./components/toc-sidebar.js"; import {withDocHelp} from "./components/doc-overlay.js"; import "./components/help-tip.js"; import {openEntityModal, buildEntityTable} from "./components/entity-modal.js"; +import {statusControl} from "./components/status-control.js"; // ── Live indicator ──────────────────────────────────────────────────────────── const _liveEl = html`
@@ -288,7 +289,7 @@ display(_filtersForm); {label: "Title", key: "title", cls: "et-title-col et-title-cell", render: w => w.title}, {label: "Domain", key: "domain"}, - {label: "Status", key: "status"}, + {label: "Status", render: w => statusControl({entity: w, type: "workstream", statuses: WORKSTREAM_STATUSES})}, {label: "Owner", render: w => w.owner ?? "—"}, {label: "Due", render: w => w.due_date ?? "—"}, {label: "Updated", render: w => new Date(w.updated_at).toLocaleDateString()}, diff --git a/dashboard/src/workstreams/[id].md b/dashboard/src/workstreams/[id].md index b76c2e3..19f0470 100644 --- a/dashboard/src/workstreams/[id].md +++ b/dashboard/src/workstreams/[id].md @@ -5,6 +5,7 @@ title: Workstream ```js import {API} from "../components/config.js"; import {fieldRow} from "../components/field-help.js"; +import {statusControl, TASK_STATUSES, WORKSTREAM_STATUSES} from "../components/status-control.js"; ``` ```js @@ -33,7 +34,12 @@ if (raw.error) { display(html`

← Overview  |  ← Workstreams  |  ← Token Cost

`); display(html`
-
Status${raw.status ?? "—"}
+
Status${statusControl({ + entity: raw, + type: "workstream", + statuses: WORKSTREAM_STATUSES, + onSaved: () => setTimeout(() => location.reload(), 450), + })}
Workplan${workplan.filename ?? "not file-backed"}
Tasks${taskRows.length}
`); @@ -52,7 +58,12 @@ if (raw.error) { display(html`
${sortedTasks.map(t => html` - + diff --git a/workplans/STATE-WP-0043-dashboard-ui-experience.md b/workplans/STATE-WP-0043-dashboard-ui-experience.md index 9c51a1a..368cbf1 100644 --- a/workplans/STATE-WP-0043-dashboard-ui-experience.md +++ b/workplans/STATE-WP-0043-dashboard-ui-experience.md @@ -61,7 +61,7 @@ Inspection notes: ```task id: STATE-WP-0043-T01 -status: todo +status: done priority: high state_hub_task_id: "2aaeb57e-26aa-438d-bfd8-4943c8b0b136" ``` @@ -87,7 +87,7 @@ active queue and the two live suggestions are clearly visible as planned work. ```task id: STATE-WP-0043-T02 -status: todo +status: done priority: high state_hub_task_id: "b6fe5e8e-80f6-439f-8ddc-47e65218f041" ``` @@ -115,7 +115,7 @@ degrade gracefully. ```task id: STATE-WP-0043-T03 -status: todo +status: done priority: high state_hub_task_id: "11166491-2c7a-4007-9820-e0707e71556c" ``` @@ -146,7 +146,7 @@ side-effectful transitions are guarded. ```task id: STATE-WP-0043-T04 -status: todo +status: done priority: high state_hub_task_id: "34b29724-bd4b-416e-ab55-1d37132490dd" ``` @@ -171,7 +171,7 @@ Done when the active suggestion ```task id: STATE-WP-0043-T05 -status: todo +status: done priority: medium state_hub_task_id: "0562bd05-d67c-4fe2-b501-166650d7129a" ``` @@ -195,7 +195,7 @@ away and without breaking table scanning. ```task id: STATE-WP-0043-T06 -status: todo +status: done priority: medium state_hub_task_id: "7b055f11-25e2-45cb-80ac-d2d175fb5a1f" ``` @@ -222,7 +222,7 @@ consistent spacing, labels, and failure states. ```task id: STATE-WP-0043-T07 -status: todo +status: done priority: medium state_hub_task_id: "0b182fff-c100-468c-afed-91918483638f" ``` @@ -244,7 +244,7 @@ Done when the dashboard suggestions page tells the same story as the shipped UI. ```task id: STATE-WP-0043-T08 -status: todo +status: in_progress priority: high state_hub_task_id: "9d59cae4-c2a8-43e1-8a28-6344e22653b0" ```
StatusPriorityTaskHuman
${t.status}${statusControl({ + entity: t, + type: "task", + statuses: TASK_STATUSES, + onSaved: () => setTimeout(() => location.reload(), 450), + })} ${t.priority ?? "—"} ${t.title ?? t.id} ${t.needs_human ? "yes" : ""}