generated from coulomb/repo-seed
feat(dashboard): add entity detail modal and fixed-layout tables
Replace Inputs.table() with buildEntityTable() across workstreams and tasks pages. Add click-to-detail modal (openEntityModal) on all entity list views: workstreams, tasks, extension points, and technical debt. - New component: src/components/entity-modal.js - openEntityModal(entity, type) — full-detail overlay (Esc/click-outside to close) - buildEntityTable(rows, cols, onRowClick) — table-layout:fixed, overflow-safe wrapper - CSS injected lazily; no separate stylesheet required - Tables: table-layout:fixed keeps content within the content column; title col 32%, workstream col 14%, all cells ellipsis + title tooltip - Cards (EP, TD): onclick → modal; workstream name span gets title tooltip - Blocked task cards also wired to modal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
415
state-hub/dashboard/src/components/entity-modal.js
Normal file
415
state-hub/dashboard/src/components/entity-modal.js
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* entity-modal — click any entity row or card to open a full-detail overlay.
|
||||
*
|
||||
* Usage:
|
||||
* import {openEntityModal} from "./components/entity-modal.js";
|
||||
* row.addEventListener("click", () => openEntityModal(entity, "workstream"));
|
||||
*
|
||||
* Supported types: "workstream" | "task" | "ep" | "td"
|
||||
*/
|
||||
|
||||
const _STYLE_ID = "entity-modal-styles";
|
||||
|
||||
function _ensureStyles() {
|
||||
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
|
||||
const s = document.createElement("style");
|
||||
s.id = _STYLE_ID;
|
||||
s.textContent = `
|
||||
/* ── Modal backdrop ──────────────────────────────────────────────────────── */
|
||||
.entity-modal {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
||||
z-index: 9100; display: flex; align-items: center; justify-content: center;
|
||||
animation: _em-fade 0.15s ease;
|
||||
}
|
||||
@keyframes _em-fade { from { opacity:0 } to { opacity:1 } }
|
||||
|
||||
/* ── Modal box ────────────────────────────────────────────────────────────── */
|
||||
.entity-modal-box {
|
||||
width: min(700px, 92vw); max-height: 88vh;
|
||||
background: var(--theme-background, #fff); border-radius: 12px;
|
||||
box-shadow: 0 16px 56px rgba(0,0,0,0.28);
|
||||
display: flex; flex-direction: column;
|
||||
animation: _em-rise 0.15s ease; overflow: hidden;
|
||||
}
|
||||
@keyframes _em-rise {
|
||||
from { transform: translateY(14px); opacity: 0 }
|
||||
to { transform: translateY(0); opacity: 1 }
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────────────────────── */
|
||||
.entity-modal-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); flex-shrink: 0;
|
||||
}
|
||||
.entity-modal-title {
|
||||
flex: 1; font-size: 1rem; font-weight: 700; line-height: 1.35;
|
||||
color: var(--theme-foreground, #111); word-break: break-word;
|
||||
}
|
||||
.entity-modal-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;
|
||||
}
|
||||
.entity-modal-close:hover {
|
||||
border-color: var(--theme-foreground-faint, #ccc);
|
||||
background: var(--theme-background, #fff); color: var(--theme-foreground, #111);
|
||||
}
|
||||
|
||||
/* ── Body ─────────────────────────────────────────────────────────────────── */
|
||||
.entity-modal-body {
|
||||
overflow-y: auto; padding: 0.85rem 1rem;
|
||||
display: flex; flex-direction: column; gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* ── Field rows ───────────────────────────────────────────────────────────── */
|
||||
.em-field {
|
||||
display: grid; grid-template-columns: 130px 1fr;
|
||||
gap: 0.15rem 0.65rem; font-size: 0.85rem; align-items: baseline;
|
||||
}
|
||||
.em-label {
|
||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--theme-foreground-faint, #aaa);
|
||||
padding-top: 0.14rem; white-space: nowrap;
|
||||
}
|
||||
.em-value { color: var(--theme-foreground, #222); line-height: 1.5; word-break: break-word; }
|
||||
|
||||
/* ── Description block ────────────────────────────────────────────────────── */
|
||||
.em-desc {
|
||||
font-size: 0.83rem; color: var(--theme-foreground-muted, #555);
|
||||
line-height: 1.55; white-space: pre-wrap; word-break: break-word;
|
||||
background: var(--theme-background-alt, #f9f9f9);
|
||||
border-radius: 6px; padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--theme-foreground-faint, #eee);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── Divider ──────────────────────────────────────────────────────────────── */
|
||||
.em-divider { border: none; border-top: 1px solid var(--theme-foreground-faint, #eee); margin: 0.2rem 0; }
|
||||
|
||||
/* ── Badge (reused from page styles, self-contained) ─────────────────────── */
|
||||
.em-badge {
|
||||
display: inline-block; padding: 0.12rem 0.5rem; border-radius: 10px;
|
||||
font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Deps list ────────────────────────────────────────────────────────────── */
|
||||
.em-deps-list { display: flex; flex-direction: column; gap: 0.12rem; }
|
||||
.em-dep-item { font-size: 0.82rem; color: var(--theme-foreground-muted, #666); }
|
||||
|
||||
/* ── Entity table (shared across all list pages) ──────────────────────────── */
|
||||
.entity-table-wrap { overflow-x: auto; max-width: 100%; }
|
||||
.entity-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 0.87rem;
|
||||
table-layout: fixed; /* honour column widths; never spill outside container */
|
||||
}
|
||||
.entity-table thead th {
|
||||
text-align: left; padding: 0.4rem 0.65rem;
|
||||
border-bottom: 2px solid var(--theme-foreground-faint, #ddd);
|
||||
font-size: 0.73rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: var(--theme-foreground-muted, #777);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.entity-table tbody tr { border-bottom: 1px solid var(--theme-foreground-faint, #eee); }
|
||||
.entity-table tbody tr:last-child { border-bottom: none; }
|
||||
.entity-table tbody tr:hover { background: var(--theme-background-alt, #f5f5f5); }
|
||||
.entity-table td {
|
||||
padding: 0.4rem 0.65rem; vertical-align: middle;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.entity-row { cursor: pointer; }
|
||||
/* Proportional column widths — other cols share the remainder equally */
|
||||
.et-title-col { width: 32%; }
|
||||
.et-ws-col { width: 14%; }
|
||||
.et-title-cell { font-weight: 500; }
|
||||
.et-ws-cell { font-style: italic; }
|
||||
`;
|
||||
document.head.append(s);
|
||||
}
|
||||
|
||||
/* ── Style maps ──────────────────────────────────────────────────────────── */
|
||||
const _STATUS_STYLE = {
|
||||
active: "background:#d4edda;color:#155724",
|
||||
blocked: "background:#f8d7da;color:#721c24",
|
||||
completed: "background:#cce5ff;color:#004085",
|
||||
archived: "background:#e2e3e5;color:#383d41",
|
||||
open: "background:#dbeafe;color:#1e40af",
|
||||
in_progress: "background:#fef3c7;color:#92400e",
|
||||
addressed: "background:#dcfce7;color:#166534",
|
||||
deferred: "background:#f1f5f9;color:#64748b",
|
||||
wont_fix: "background:#f3f4f6;color:#9ca3af",
|
||||
todo: "background:#f1f5f9;color:#475569",
|
||||
done: "background:#dcfce7;color:#166534",
|
||||
cancelled: "background:#f3f4f6;color:#9ca3af",
|
||||
resolved: "background:#dcfce7;color:#166534",
|
||||
superseded: "background:#e2e3e5;color:#383d41",
|
||||
};
|
||||
|
||||
const _PRIORITY_STYLE = {
|
||||
critical: "background:#fee2e2;color:#991b1b",
|
||||
high: "background:#ffedd5;color:#9a3412",
|
||||
medium: "background:#dbeafe;color:#1e40af",
|
||||
low: "background:#f1f5f9;color:#475569",
|
||||
};
|
||||
|
||||
/* ── DOM helpers ─────────────────────────────────────────────────────────── */
|
||||
function _badge(text, styleMap) {
|
||||
const el = document.createElement("span");
|
||||
el.className = "em-badge";
|
||||
el.style.cssText = styleMap[text] ?? "background:#f1f5f9;color:#555";
|
||||
el.textContent = (text ?? "").replace(/_/g, " ");
|
||||
return el;
|
||||
}
|
||||
|
||||
function _field(label, valueEl) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "em-field";
|
||||
const l = document.createElement("span");
|
||||
l.className = "em-label";
|
||||
l.textContent = label;
|
||||
row.append(l, valueEl);
|
||||
return row;
|
||||
}
|
||||
|
||||
function _textVal(text) {
|
||||
const v = document.createElement("span");
|
||||
v.className = "em-value";
|
||||
v.textContent = text ?? "—";
|
||||
return v;
|
||||
}
|
||||
|
||||
function _descVal(text) {
|
||||
const v = document.createElement("div");
|
||||
v.className = "em-desc";
|
||||
v.textContent = text;
|
||||
return v;
|
||||
}
|
||||
|
||||
function _divider() {
|
||||
const hr = document.createElement("hr");
|
||||
hr.className = "em-divider";
|
||||
return hr;
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
if (!iso) return "—";
|
||||
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
||||
}
|
||||
|
||||
/* ── Body builders per entity type ─────────────────────────────────────── */
|
||||
function _buildBody(entity, type) {
|
||||
const els = [];
|
||||
const tf = (label, text) => _field(label, _textVal(text));
|
||||
const bf = (label, val, styleMap) => {
|
||||
const v = document.createElement("span");
|
||||
v.className = "em-value";
|
||||
v.append(_badge(val, styleMap));
|
||||
return _field(label, v);
|
||||
};
|
||||
|
||||
if (type === "workstream") {
|
||||
els.push(
|
||||
bf("Status", entity.status, _STATUS_STYLE),
|
||||
tf("Domain", entity.domain ?? entity.topic_title ?? "—"),
|
||||
tf("Owner", entity.owner ?? "—"),
|
||||
tf("Due", entity.due_date ?? "—"),
|
||||
);
|
||||
if (entity.description) {
|
||||
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
||||
}
|
||||
if (entity.tasks_total !== undefined) {
|
||||
els.push(_divider(),
|
||||
tf("Tasks", `${entity.tasks_done ?? 0} done / ${entity.tasks_total} total` +
|
||||
(entity.tasks_in_progress > 0 ? ` · ${entity.tasks_in_progress} in progress` : "") +
|
||||
(entity.tasks_blocked > 0 ? ` · ${entity.tasks_blocked} blocked` : ""))
|
||||
);
|
||||
}
|
||||
if (entity.depends_on?.length) {
|
||||
const list = document.createElement("div"); list.className = "em-deps-list";
|
||||
for (const d of entity.depends_on) {
|
||||
const span = document.createElement("span"); span.className = "em-dep-item";
|
||||
span.textContent = `↳ ${d.workstream_title}`;
|
||||
list.append(span);
|
||||
}
|
||||
els.push(_field("Depends on", list));
|
||||
}
|
||||
if (entity.blocks?.length) {
|
||||
const list = document.createElement("div"); list.className = "em-deps-list";
|
||||
for (const d of entity.blocks) {
|
||||
const span = document.createElement("span"); span.className = "em-dep-item";
|
||||
span.textContent = `⊳ ${d.workstream_title}`;
|
||||
list.append(span);
|
||||
}
|
||||
els.push(_field("Blocks", list));
|
||||
}
|
||||
els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at)));
|
||||
if (entity.slug) els.push(tf("Slug", entity.slug));
|
||||
els.push(tf("ID", entity.id));
|
||||
}
|
||||
|
||||
else if (type === "task") {
|
||||
els.push(
|
||||
bf("Status", entity.status, _STATUS_STYLE),
|
||||
bf("Priority", entity.priority, _PRIORITY_STYLE),
|
||||
tf("Domain", entity.domain ?? "—"),
|
||||
tf("Workstream", entity.workstream_title ?? "—"),
|
||||
tf("Assignee", entity.assignee ?? "—"),
|
||||
tf("Due", entity.due_date ?? "—"),
|
||||
);
|
||||
if (entity.description) {
|
||||
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
||||
}
|
||||
if (entity.blocking_reason) {
|
||||
const v = document.createElement("span");
|
||||
v.className = "em-value"; v.style.color = "#b45309";
|
||||
v.textContent = entity.blocking_reason;
|
||||
els.push(_divider(), _field("Blocking reason", v));
|
||||
}
|
||||
els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at)));
|
||||
els.push(tf("ID", entity.id));
|
||||
}
|
||||
|
||||
else if (type === "ep") {
|
||||
if (entity.ep_id) els.push(tf("EP ID", entity.ep_id));
|
||||
els.push(
|
||||
bf("Status", entity.status, _STATUS_STYLE),
|
||||
bf("Priority", entity.priority, _PRIORITY_STYLE),
|
||||
tf("Type", entity.ep_type ?? "—"),
|
||||
tf("Domain", entity.domain ?? "—"),
|
||||
tf("Workstream", entity.workstream_title ?? "—"),
|
||||
tf("Location", entity.location ?? "—"),
|
||||
);
|
||||
if (entity.description) {
|
||||
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
||||
}
|
||||
els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at)));
|
||||
els.push(tf("UUID", entity.id));
|
||||
}
|
||||
|
||||
else if (type === "td") {
|
||||
if (entity.td_id) els.push(tf("TD ID", entity.td_id));
|
||||
els.push(
|
||||
bf("Severity", entity.severity, _PRIORITY_STYLE),
|
||||
bf("Status", entity.status, _STATUS_STYLE),
|
||||
tf("Type", entity.debt_type ?? "—"),
|
||||
tf("Domain", entity.domain ?? "—"),
|
||||
tf("Workstream", entity.workstream_title ?? "—"),
|
||||
tf("Location", entity.location ?? "—"),
|
||||
);
|
||||
if (entity.description) {
|
||||
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
||||
}
|
||||
els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at)));
|
||||
els.push(tf("UUID", entity.id));
|
||||
}
|
||||
|
||||
return els;
|
||||
}
|
||||
|
||||
/* ── Public API ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Open a detail modal for the given entity.
|
||||
* @param {object} entity - The entity data object (workstream, task, ep, or td)
|
||||
* @param {string} type - One of: "workstream" | "task" | "ep" | "td"
|
||||
*/
|
||||
export function openEntityModal(entity, type) {
|
||||
_ensureStyles();
|
||||
document.getElementById("_entity-modal-root")?.remove();
|
||||
|
||||
const title = entity.title ?? "(no title)";
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.id = "_entity-modal-root";
|
||||
root.className = "entity-modal";
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-modal", "true");
|
||||
root.setAttribute("aria-label", title);
|
||||
|
||||
const box = document.createElement("div");
|
||||
box.className = "entity-modal-box";
|
||||
|
||||
// Header
|
||||
const header = document.createElement("div");
|
||||
header.className = "entity-modal-header";
|
||||
const titleEl = document.createElement("div");
|
||||
titleEl.className = "entity-modal-title";
|
||||
titleEl.textContent = title;
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "entity-modal-close";
|
||||
closeBtn.textContent = "✕ close";
|
||||
closeBtn.setAttribute("aria-label", "Close detail panel");
|
||||
header.append(titleEl, closeBtn);
|
||||
|
||||
// Body
|
||||
const body = document.createElement("div");
|
||||
body.className = "entity-modal-body";
|
||||
for (const el of _buildBody(entity, type)) body.append(el);
|
||||
|
||||
box.append(header, body);
|
||||
root.append(box);
|
||||
document.body.append(root);
|
||||
|
||||
const close = () => root.remove();
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an interactive entity table element.
|
||||
*
|
||||
* @param {Array} rows - Array of entity objects to display
|
||||
* @param {Array} columns - [{label, key, cls?}] — columns in order
|
||||
* @param {Function} onRowClick - Called with the full entity when a row is clicked
|
||||
* @returns {HTMLTableElement}
|
||||
*/
|
||||
export function buildEntityTable(rows, columns, onRowClick) {
|
||||
_ensureStyles();
|
||||
|
||||
const table = document.createElement("table");
|
||||
table.className = "entity-table";
|
||||
|
||||
// Header
|
||||
const thead = document.createElement("thead");
|
||||
const htr = document.createElement("tr");
|
||||
for (const col of columns) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = col.label;
|
||||
if (col.cls) th.className = col.cls;
|
||||
htr.append(th);
|
||||
}
|
||||
thead.append(htr);
|
||||
table.append(thead);
|
||||
|
||||
// Body
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "entity-row";
|
||||
tr.addEventListener("click", () => onRowClick(row));
|
||||
|
||||
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;
|
||||
if (col.cls) td.className = col.cls;
|
||||
// Native tooltip so full value shows on hover (skip placeholder "—")
|
||||
if (textStr && textStr !== "—") td.title = textStr;
|
||||
tr.append(td);
|
||||
}
|
||||
tbody.append(tr);
|
||||
}
|
||||
table.append(tbody);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "entity-table-wrap";
|
||||
wrap.append(table);
|
||||
return wrap;
|
||||
}
|
||||
@@ -86,8 +86,9 @@ const filtered = data.filter(e =>
|
||||
# Extension Points
|
||||
|
||||
```js
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
import {openEntityModal} from "./components/entity-modal.js";
|
||||
|
||||
// ── KPI sidebar ───────────────────────────────────────────────────────────────
|
||||
const _open = data.filter(e => e.status === "open" || e.status === "in_progress");
|
||||
@@ -180,17 +181,19 @@ display(_filtersForm);
|
||||
display(html`<p><strong>${filtered.length}</strong> extension points shown.</p>`);
|
||||
|
||||
display(html`<div class="ep-list">${filtered.map(ep => html`
|
||||
<div class="ep-item ep-status-${ep.status}">
|
||||
<div class="ep-item ep-status-${ep.status} entity-row"
|
||||
onclick=${() => openEntityModal(ep, "ep")}
|
||||
title="Click to view full details">
|
||||
<div class="ep-item-header">
|
||||
${ep.ep_id ? html`<span class="ep-ref">${ep.ep_id}</span>` : ""}
|
||||
<span class="ep-type-badge ep-type-${ep.ep_type}">${ep.ep_type}</span>
|
||||
<span class="ep-badge ep-badge-${ep.status}">${ep.status.replace("_", " ")}</span>
|
||||
<span class="ep-badge ep-priority-${ep.priority}">${ep.priority}</span>
|
||||
<span class="ep-domain">${ep.domain}</span>
|
||||
${ep.workstream_title ? html`<span class="ep-ws">${ep.workstream_title}</span>` : ""}
|
||||
${ep.workstream_title ? html`<span class="ep-ws" title=${ep.workstream_title}>${ep.workstream_title}</span>` : ""}
|
||||
</div>
|
||||
<div class="ep-title">${ep.title}</div>
|
||||
${ep.description ? html`<div class="ep-desc">${ep.description.slice(0, 240)}${ep.description.length > 240 ? "…" : ""}</div>` : ""}
|
||||
${ep.description ? html`<div class="ep-desc">${ep.description.slice(0, 220)}${ep.description.length > 220 ? " …" : ""}</div>` : ""}
|
||||
${ep.location ? html`<div class="ep-location"><code>${ep.location}</code></div>` : ""}
|
||||
</div>
|
||||
`)}
|
||||
@@ -222,6 +225,8 @@ display(html`<div class="ep-list">${filtered.map(ep => html`
|
||||
/* ── EP list ──────────────────────────────────────────────────────────────── */
|
||||
.ep-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.ep-item { border-left: 3px solid #94a3b8; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; }
|
||||
.ep-item.entity-row { cursor: pointer; transition: filter 0.1s; }
|
||||
.ep-item.entity-row:hover { filter: brightness(0.97); }
|
||||
.ep-status-open { border-left-color: #3b82f6; }
|
||||
.ep-status-in_progress { border-left-color: #f59e0b; }
|
||||
.ep-status-addressed { border-left-color: #22c55e; }
|
||||
|
||||
@@ -83,8 +83,9 @@ const filtered = data.filter(t =>
|
||||
# Tasks
|
||||
|
||||
```js
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
import {openEntityModal, buildEntityTable} from "./components/entity-modal.js";
|
||||
|
||||
// ── KPI sidebar card ─────────────────────────────────────────────────────────
|
||||
const _open = data.filter(t => ["todo", "in_progress", "blocked"].includes(t.status));
|
||||
@@ -179,7 +180,7 @@ if (_blockedInFilter.length === 0) {
|
||||
display(html`<p class="dim">No blocked tasks in current filter. ✓</p>`);
|
||||
} else {
|
||||
display(html`<div class="task-blocked-list">${_blockedInFilter.map(t => html`
|
||||
<div class="task-blocked-item">
|
||||
<div class="task-blocked-item entity-row" onclick=${() => openEntityModal(t, "task")}>
|
||||
<div class="task-item-header">
|
||||
<span class="task-badge task-priority-${t.priority}">${t.priority}</span>
|
||||
<span class="task-context">${t.domain}</span>
|
||||
@@ -209,15 +210,19 @@ const sorted = [...filtered].sort((a, b) => {
|
||||
return (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9);
|
||||
});
|
||||
|
||||
display(Inputs.table(sorted.map(t => ({
|
||||
Status: t.status,
|
||||
Priority: t.priority,
|
||||
Title: t.title,
|
||||
Domain: t.domain,
|
||||
Workstream: t.workstream_title,
|
||||
Assignee: t.assignee ?? "—",
|
||||
Due: t.due_date ?? "—",
|
||||
})), {rows: 25}));
|
||||
display(buildEntityTable(
|
||||
sorted,
|
||||
[
|
||||
{label: "Status", key: "status"},
|
||||
{label: "Priority", key: "priority"},
|
||||
{label: "Title", key: "title", cls: "et-title-col et-title-cell"},
|
||||
{label: "Domain", key: "domain"},
|
||||
{label: "Workstream", key: "workstream_title", cls: "et-ws-col et-ws-cell"},
|
||||
{label: "Assignee", render: t => t.assignee ?? "—"},
|
||||
{label: "Due", render: t => t.due_date ?? "—"},
|
||||
],
|
||||
t => openEntityModal(t, "task"),
|
||||
));
|
||||
```
|
||||
|
||||
<style>
|
||||
|
||||
@@ -86,8 +86,9 @@ const filtered = data.filter(t =>
|
||||
# Technical Debt
|
||||
|
||||
```js
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
import {openEntityModal} from "./components/entity-modal.js";
|
||||
|
||||
// ── KPI sidebar ───────────────────────────────────────────────────────────────
|
||||
const _open = data.filter(t => t.status === "open" || t.status === "in_progress");
|
||||
@@ -191,16 +192,18 @@ if (_urgent.length === 0) {
|
||||
display(html`<p class="dim">No critical or high severity open items in current filter. ✓</p>`);
|
||||
} else {
|
||||
display(html`<div class="td-list">${_urgent.map(t => html`
|
||||
<div class="td-item td-sev-${t.severity}">
|
||||
<div class="td-item td-sev-${t.severity} entity-row"
|
||||
onclick=${() => openEntityModal(t, "td")}
|
||||
title="Click to view full details">
|
||||
<div class="td-item-header">
|
||||
${t.td_id ? html`<span class="td-ref">${t.td_id}</span>` : ""}
|
||||
<span class="td-sev-badge td-sev-${t.severity}">${t.severity}</span>
|
||||
<span class="td-type-badge">${t.debt_type}</span>
|
||||
<span class="td-domain">${t.domain}</span>
|
||||
${t.workstream_title ? html`<span class="td-ws">${t.workstream_title}</span>` : ""}
|
||||
${t.workstream_title ? html`<span class="td-ws" title=${t.workstream_title}>${t.workstream_title}</span>` : ""}
|
||||
</div>
|
||||
<div class="td-title">${t.title}</div>
|
||||
${t.description ? html`<div class="td-desc">${t.description.slice(0, 240)}${t.description.length > 240 ? "…" : ""}</div>` : ""}
|
||||
${t.description ? html`<div class="td-desc">${t.description.slice(0, 220)}${t.description.length > 220 ? " …" : ""}</div>` : ""}
|
||||
${t.location ? html`<div class="td-location"><code>${t.location}</code></div>` : ""}
|
||||
</div>
|
||||
`)}</div>`);
|
||||
@@ -214,17 +217,19 @@ display(_filtersForm);
|
||||
display(html`<p><strong>${filtered.length}</strong> items shown.</p>`);
|
||||
|
||||
display(html`<div class="td-list">${filtered.map(t => html`
|
||||
<div class="td-item td-status-${t.status}">
|
||||
<div class="td-item td-status-${t.status} entity-row"
|
||||
onclick=${() => openEntityModal(t, "td")}
|
||||
title="Click to view full details">
|
||||
<div class="td-item-header">
|
||||
${t.td_id ? html`<span class="td-ref">${t.td_id}</span>` : ""}
|
||||
<span class="td-sev-badge td-sev-${t.severity}">${t.severity}</span>
|
||||
<span class="td-type-badge">${t.debt_type}</span>
|
||||
<span class="td-badge td-badge-${t.status}">${t.status.replace("_", " ")}</span>
|
||||
<span class="td-domain">${t.domain}</span>
|
||||
${t.workstream_title ? html`<span class="td-ws">${t.workstream_title}</span>` : ""}
|
||||
${t.workstream_title ? html`<span class="td-ws" title=${t.workstream_title}>${t.workstream_title}</span>` : ""}
|
||||
</div>
|
||||
<div class="td-title">${t.title}</div>
|
||||
${t.description ? html`<div class="td-desc">${t.description.slice(0, 240)}${t.description.length > 240 ? "…" : ""}</div>` : ""}
|
||||
${t.description ? html`<div class="td-desc">${t.description.slice(0, 220)}${t.description.length > 220 ? " …" : ""}</div>` : ""}
|
||||
${t.location ? html`<div class="td-location"><code>${t.location}</code></div>` : ""}
|
||||
</div>
|
||||
`)}
|
||||
@@ -254,6 +259,8 @@ display(html`<div class="td-list">${filtered.map(t => html`
|
||||
/* ── TD list ──────────────────────────────────────────────────────────────── */
|
||||
.td-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.td-item { border-left: 3px solid #94a3b8; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; }
|
||||
.td-item.entity-row { cursor: pointer; transition: filter 0.1s; }
|
||||
.td-item.entity-row:hover { filter: brightness(0.97); }
|
||||
.td-sev-critical { border-left-color: #dc2626; }
|
||||
.td-sev-high { border-left-color: #ea580c; }
|
||||
.td-sev-medium { border-left-color: #3b82f6; }
|
||||
|
||||
@@ -132,9 +132,10 @@ const _domainBreakdown = [...new Set(openWs.map(w => _idToDomain[w.id] ?? "unkno
|
||||
# Workstreams
|
||||
|
||||
```js
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
import "./components/help-tip.js";
|
||||
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";
|
||||
|
||||
// ── Live indicator ────────────────────────────────────────────────────────────
|
||||
const _liveEl = html`<div class="live-indicator">
|
||||
@@ -270,14 +271,24 @@ display(Plot.plot({
|
||||
```js
|
||||
display(_filtersForm);
|
||||
|
||||
display(Inputs.table(filtered.map(w => ({
|
||||
Title: w.title,
|
||||
Domain: w.domain,
|
||||
Status: w.status,
|
||||
Owner: w.owner ?? "—",
|
||||
Due: w.due_date ?? "—",
|
||||
Updated: new Date(w.updated_at).toLocaleDateString(),
|
||||
})), {rows: 20}));
|
||||
{
|
||||
// Enrich each workstream with tasks/deps data from open_workstreams summary
|
||||
const _openWsMap = Object.fromEntries(openWs.map(w => [w.id, w]));
|
||||
const _wsTable = buildEntityTable(
|
||||
filtered,
|
||||
[
|
||||
{label: "Title", key: "title", cls: "et-title-col et-title-cell",
|
||||
render: w => w.title},
|
||||
{label: "Domain", key: "domain"},
|
||||
{label: "Status", key: "status"},
|
||||
{label: "Owner", render: w => w.owner ?? "—"},
|
||||
{label: "Due", render: w => w.due_date ?? "—"},
|
||||
{label: "Updated", render: w => new Date(w.updated_at).toLocaleDateString()},
|
||||
],
|
||||
w => openEntityModal({...w, ..._openWsMap[w.id]}, "workstream"),
|
||||
);
|
||||
display(_wsTable);
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Reference in New Issue
Block a user