Implement dashboard UI improvements

This commit is contained in:
2026-05-19 02:16:24 +02:00
parent bf09782f68
commit cc21c5869e
9 changed files with 342 additions and 25 deletions

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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"},

View File

@@ -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`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Task · <em>${shortName}</em></h1>`);
display(html`<p style="margin-top:0"><a href="/tasks">← Tasks</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`);
display(html`<div class="task-summary">
<div>
<span>Status</span>
${statusControl({
entity: raw,
type: "task",
statuses: TASK_STATUSES,
onSaved: () => setTimeout(() => location.reload(), 450),
})}
</div>
<div><span>Priority</span><strong>${raw.priority ?? "—"}</strong></div>
<div><span>Assignee</span><strong>${raw.assignee ?? "—"}</strong></div>
</div>`);
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`<section class="task-content">
<h2>Task Content</h2>
<div class="task-content-body">${taskContent}</div>
</section>`);
} else {
display(html`<p class="task-content-empty">No task content recorded.</p>`);
}
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`<table style="border-collapse:collapse;width:100%;max-width:640px">
@@ -40,3 +65,52 @@ if (raw.error) {
</table>`);
}
```
<style>
.task-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 1rem 0 1.25rem;
max-width: 860px;
}
.task-summary div {
background: var(--theme-background-alt);
border-radius: 6px;
padding: 0.75rem 0.9rem;
}
.task-summary span {
display: block;
color: gray;
font-size: 0.72rem;
text-transform: uppercase;
margin-bottom: 0.2rem;
}
.task-summary strong {
overflow-wrap: anywhere;
}
.task-content {
margin: 1rem 0 1.25rem;
max-width: 860px;
border: 1px solid var(--theme-foreground-faint, #e5e7eb);
border-radius: 6px;
background: var(--theme-background-alt, #f8fafc);
}
.task-content h2 {
margin: 0;
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--theme-foreground-faint, #e5e7eb);
font-size: 0.9rem;
}
.task-content-body {
padding: 0.85rem 0.9rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.55;
font-size: 0.88rem;
}
.task-content-empty {
margin: 1rem 0;
color: var(--theme-foreground-muted, #666);
}
</style>

View File

@@ -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`<div class="repo-ref">
<div class="repo-ref-name">
${webUrl
? html`<a href="${webUrl}" target="_blank" rel="noopener noreferrer">${label}</a>`
: label}
</div>
<div class="repo-ref-meta">${domain} / ${slug}</div>
</div>`;
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`<table style="width:100%; border-collapse:collapse; font-
</tr>
</thead>
<tbody>
${[...repoBreakdown.entries()].map(([repoSlug, snap]) => html`<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:8px 12px; font-weight:500;">${repoSlug}</td>
${[...repoBreakdown.entries()].map(([repoKey, snap]) => html`<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:8px 12px; font-weight:500;">${repoCell(repoKey)}</td>
<td style="padding:8px 12px;">
${snap.entries.map(e => {
const cat = catalogBySlug[e.service_slug];
@@ -191,3 +224,27 @@ const repoTable = html`<table style="width:100%; border-collapse:collapse; font-
</table>`;
display(repoTable);
```
<style>
.repo-ref {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.repo-ref-name {
font-weight: 600;
color: var(--theme-foreground, #111);
}
.repo-ref-name a {
color: #1d4ed8;
text-decoration: none;
}
.repo-ref-name a:hover {
text-decoration: underline;
}
.repo-ref-meta {
font-size: 0.76rem;
font-weight: 400;
color: var(--theme-foreground-muted, #6b7280);
}
</style>

View File

@@ -17,6 +17,8 @@ const STEP_LABEL = {
test: "Test",
review: "Review",
finished: "Finished",
addressed: "Addressed",
resolved: "Resolved",
wont_fix: "Won't Fix",
};
const STEP_COLOR = {
@@ -27,6 +29,8 @@ const STEP_COLOR = {
test: "#06b6d4",
review: "#6366f1",
finished: "#16a34a",
addressed: "#16a34a",
resolved: "#16a34a",
wont_fix: "#9ca3af",
};
const STEP_HINT = {
@@ -37,8 +41,11 @@ const STEP_HINT = {
test: "Verifying the implementation works correctly.",
review: "Awaiting review by the original suggester.",
finished: "Shipped and confirmed.",
addressed: "Implemented before the current workflow labels existed.",
resolved: "Resolved before the current workflow labels existed.",
wont_fix: "Decided not to implement.",
};
const CLOSED_STATUSES = new Set(["finished", "addressed", "resolved", "wont_fix"]);
function nextStep(current) {
const i = STEPS.indexOf(current);
return i >= 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`<div class="kpi-infobox">

View File

@@ -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`<div class="live-indicator">
@@ -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()},

View File

@@ -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`<p style="margin-top:0"><a href="/">← Overview</a> &nbsp;|&nbsp; <a href="/workstreams">← Workstreams</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`);
display(html`<div class="ws-summary">
<div><span>Status</span><strong>${raw.status ?? "—"}</strong></div>
<div><span>Status</span>${statusControl({
entity: raw,
type: "workstream",
statuses: WORKSTREAM_STATUSES,
onSaved: () => setTimeout(() => location.reload(), 450),
})}</div>
<div><span>Workplan</span><strong>${workplan.filename ?? "not file-backed"}</strong></div>
<div><span>Tasks</span><strong>${taskRows.length}</strong></div>
</div>`);
@@ -52,7 +58,12 @@ if (raw.error) {
display(html`<table class="task-table">
<thead><tr><th>Status</th><th>Priority</th><th>Task</th><th>Human</th></tr></thead>
<tbody>${sortedTasks.map(t => html`<tr>
<td><span class="task-status task-status-${t.status}">${t.status}</span></td>
<td>${statusControl({
entity: t,
type: "task",
statuses: TASK_STATUSES,
onSaved: () => setTimeout(() => location.reload(), 450),
})}</td>
<td>${t.priority ?? "—"}</td>
<td><a href="/tasks/${t.id}">${t.title ?? t.id}</a></td>
<td>${t.needs_human ? "yes" : ""}</td>

View File

@@ -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"
```