generated from coulomb/repo-seed
feat(dashboard): entity list UX — REF column, name cells, detail pages (CUST-WP-0030)
- ref-cell.js: REF column component — click=copy deeplink, dblclick=open
- field-help.js: field registry + fieldRow helper with help-tip decoration;
FK fields (task_id, workstream_id, repo_id) render as async-linked cells
with entity-title bubble-help on hover
- GET /token-events/{id} endpoint + get-by-id tests
- GET /repos/by-id/{repo_id} UUID lookup endpoint
- Landing pages: /token-events/[id], /workstreams/[id], /repos/[slug], /tasks/[id]
- token-cost.md: REF + Name columns on all three tables; parallel fetch of
workstreams/tasks for title resolution
- reference.md: entity detail page URL scheme documented
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
263
dashboard/src/components/field-help.js
Normal file
263
dashboard/src/components/field-help.js
Normal file
@@ -0,0 +1,263 @@
|
||||
// Field-level help registry for dashboard entity detail pages.
|
||||
//
|
||||
// FIELD_HELP maps a field key to { label, description, doc? }.
|
||||
// label — human-readable field name (used as bold heading in help-tip)
|
||||
// description — one sentence explaining the field
|
||||
// doc — optional anchor into /docs/ pages for "Learn more"
|
||||
//
|
||||
// fieldRow(key, value) → <tr> element with a help-tip-decorated key cell and
|
||||
// a value cell. Falls back to a plain key cell when key is not in FIELD_HELP.
|
||||
//
|
||||
// Usage:
|
||||
// import {fieldRow} from "./components/field-help.js";
|
||||
// const tbody = html`<tbody>${fields.map(([k,v]) => fieldRow(k,v))}</tbody>`;
|
||||
|
||||
import {HelpTip} from "./help-tip.js"; // ensures custom element is registered
|
||||
import {API} from "./config.js";
|
||||
void HelpTip; // silence unused-import linters
|
||||
|
||||
// ── Entity link registry ────────────────────────────────────────────────────
|
||||
// Maps FK field names to fetch/URL/title resolution rules.
|
||||
// getUrl receives (id, data) so slug-routed entities (repos) can use data.slug.
|
||||
const FIELD_LINKS = {
|
||||
task_id: {
|
||||
apiUrl: id => `${API}/tasks/${id}`,
|
||||
getUrl: (id, _d) => `/tasks/${id}`,
|
||||
getTitle: d => d.title,
|
||||
},
|
||||
workstream_id: {
|
||||
apiUrl: id => `${API}/workstreams/${id}`,
|
||||
getUrl: (id, _d) => `/workstreams/${id}`,
|
||||
getTitle: d => d.title || d.slug,
|
||||
},
|
||||
repo_id: {
|
||||
apiUrl: id => `${API}/repos/by-id/${id}`,
|
||||
getUrl: (_id, d) => `/repos/${d.slug}`,
|
||||
getTitle: d => d.name || d.slug,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Render an entity-reference value as a link with an async help-tip showing
|
||||
* the entity title. Falls back gracefully if the fetch fails.
|
||||
*/
|
||||
function _linkCell(key, id) {
|
||||
const rule = FIELD_LINKS[key];
|
||||
const shortId = String(id).slice(0, 8) + "…";
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.textContent = shortId;
|
||||
a.href = "#";
|
||||
a.style.fontFamily = "var(--monospace, monospace)";
|
||||
a.title = String(id); // full UUID as native tooltip while async loads
|
||||
|
||||
fetch(rule.apiUrl(id))
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
const title = rule.getTitle(data);
|
||||
const url = rule.getUrl(id, data);
|
||||
if (url) a.href = url;
|
||||
if (title) {
|
||||
const tip = document.createElement("help-tip");
|
||||
tip.setAttribute("label", title);
|
||||
tip.setAttribute("description", `${key.replace(/_id$/, "")} · ${id}`);
|
||||
a.replaceWith(tip);
|
||||
tip.appendChild(a);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* leave plain link */ });
|
||||
|
||||
return a;
|
||||
}
|
||||
|
||||
export const FIELD_HELP = {
|
||||
// ── TokenEvent ─────────────────────────────────────────────────────────────
|
||||
id: {
|
||||
label: "ID",
|
||||
description: "Unique identifier for this token event (UUID v4).",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_in: {
|
||||
label: "Tokens In",
|
||||
description: "Number of input (prompt) tokens consumed in this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_out: {
|
||||
label: "Tokens Out",
|
||||
description: "Number of output (completion) tokens generated in this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_total: {
|
||||
label: "Tokens Total",
|
||||
description: "Sum of input and output tokens — total cost proxy for this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
task_id: {
|
||||
label: "Task ID",
|
||||
description: "The task this token event was recorded against (if any).",
|
||||
doc: "/docs/tasks",
|
||||
},
|
||||
workstream_id: {
|
||||
label: "Workstream ID",
|
||||
description: "The workstream this event belongs to; auto-resolved from task if not set directly.",
|
||||
doc: "/docs/workstreams",
|
||||
},
|
||||
repo_id: {
|
||||
label: "Repo ID",
|
||||
description: "The managed repo this event is attributed to; auto-resolved from workstream.",
|
||||
doc: "/docs/repos",
|
||||
},
|
||||
session_id: {
|
||||
label: "Session ID",
|
||||
description: "Opaque identifier for the Claude Code session that produced this event.",
|
||||
},
|
||||
model: {
|
||||
label: "Model",
|
||||
description: "The Claude model used (e.g. claude-sonnet-4-6).",
|
||||
doc: "/docs/reference#models",
|
||||
},
|
||||
agent: {
|
||||
label: "Agent",
|
||||
description: "The agent persona that produced this event (e.g. custodian, ralph).",
|
||||
doc: "/docs/reference#agents",
|
||||
},
|
||||
ref_type: {
|
||||
label: "Ref Type",
|
||||
description: "What kind of entity the ref_id points to: task, commit, release, or session.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
ref_id: {
|
||||
label: "Ref ID",
|
||||
description: "Free-form reference ID; interpretation depends on ref_type.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
note: {
|
||||
label: "Note",
|
||||
description: "Quality tag: 'measured' = exact from status bar, 'userbased' = human-provided, 'workplan' = prorated, 'heuristic' = server fallback.",
|
||||
doc: "/docs/reference#token-note-taxonomy",
|
||||
},
|
||||
created_at: {
|
||||
label: "Created At",
|
||||
description: "Timestamp when this token event was recorded (UTC).",
|
||||
},
|
||||
|
||||
// ── Workstream ──────────────────────────────────────────────────────────────
|
||||
slug: {
|
||||
label: "Slug",
|
||||
description: "URL-safe short identifier for this entity.",
|
||||
},
|
||||
title: {
|
||||
label: "Title",
|
||||
description: "Human-readable name for this workstream or task.",
|
||||
},
|
||||
status: {
|
||||
label: "Status",
|
||||
description: "Current lifecycle state: todo, in_progress, blocked, done, or cancelled.",
|
||||
doc: "/docs/workstream-lifecycle",
|
||||
},
|
||||
topic_id: {
|
||||
label: "Topic ID",
|
||||
description: "The topic this workstream is grouped under.",
|
||||
doc: "/docs/reference#topics",
|
||||
},
|
||||
repo_goal_id: {
|
||||
label: "Repo Goal ID",
|
||||
description: "Optional link to a repo-level strategic goal this workstream advances.",
|
||||
doc: "/docs/goals",
|
||||
},
|
||||
|
||||
// ── Task ───────────────────────────────────────────────────────────────────
|
||||
assignee: {
|
||||
label: "Assignee",
|
||||
description: "Who is responsible for completing this task (agent name or human).",
|
||||
},
|
||||
priority: {
|
||||
label: "Priority",
|
||||
description: "Relative urgency: high, medium, or low.",
|
||||
},
|
||||
due_date: {
|
||||
label: "Due Date",
|
||||
description: "Target completion date (ISO 8601).",
|
||||
},
|
||||
needs_human: {
|
||||
label: "Needs Human",
|
||||
description: "True if the task is blocked waiting for human input or approval.",
|
||||
doc: "/interventions",
|
||||
},
|
||||
intervention_note: {
|
||||
label: "Intervention Note",
|
||||
description: "Why human intervention is required for this task.",
|
||||
},
|
||||
|
||||
// ── Repo ───────────────────────────────────────────────────────────────────
|
||||
repo_slug: {
|
||||
label: "Repo Slug",
|
||||
description: "Short identifier for the repository (matches the git remote slug).",
|
||||
doc: "/docs/repos",
|
||||
},
|
||||
event_count: {
|
||||
label: "Event Count",
|
||||
description: "Total number of token events attributed to this entity.",
|
||||
},
|
||||
by_model: {
|
||||
label: "By Model",
|
||||
description: "Token totals broken down by Claude model.",
|
||||
},
|
||||
by_note: {
|
||||
label: "By Note",
|
||||
description: "Token totals broken down by quality tier (measured / workplan / heuristic).",
|
||||
doc: "/docs/reference#token-note-taxonomy",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a single key-value row for an entity detail table.
|
||||
* @param {string} key — field name
|
||||
* @param {*} value — field value (stringified automatically)
|
||||
* @returns {HTMLTableRowElement}
|
||||
*/
|
||||
export function fieldRow(key, value) {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
// Key cell
|
||||
const tdKey = document.createElement("td");
|
||||
tdKey.style.cssText = "padding:0.3rem 0.8rem 0.3rem 0; white-space:nowrap; vertical-align:top; color:var(--theme-foreground-muted,#666); font-size:0.82rem;";
|
||||
|
||||
const help = FIELD_HELP[key];
|
||||
if (help) {
|
||||
const tip = document.createElement("help-tip");
|
||||
tip.setAttribute("label", help.label);
|
||||
tip.setAttribute("description", help.description);
|
||||
if (help.doc) tip.setAttribute("doc", help.doc);
|
||||
tip.textContent = help.label;
|
||||
tdKey.appendChild(tip);
|
||||
} else {
|
||||
tdKey.textContent = key;
|
||||
}
|
||||
tr.appendChild(tdKey);
|
||||
|
||||
// Value cell
|
||||
const tdVal = document.createElement("td");
|
||||
tdVal.style.cssText = "padding:0.3rem 0; font-size:0.82rem; word-break:break-all; vertical-align:top;";
|
||||
|
||||
let display;
|
||||
if (value === null || value === undefined) {
|
||||
display = document.createElement("span");
|
||||
display.style.color = "var(--theme-foreground-faint,#aaa)";
|
||||
display.textContent = "—";
|
||||
} else if (key in FIELD_LINKS) {
|
||||
display = _linkCell(key, value);
|
||||
} else if (typeof value === "object") {
|
||||
display = document.createElement("pre");
|
||||
display.style.cssText = "margin:0; font-size:0.75rem; white-space:pre-wrap;";
|
||||
display.textContent = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
display = document.createElement("span");
|
||||
display.textContent = String(value);
|
||||
}
|
||||
tdVal.appendChild(display);
|
||||
tr.appendChild(tdVal);
|
||||
|
||||
return tr;
|
||||
}
|
||||
101
dashboard/src/components/ref-cell.js
Normal file
101
dashboard/src/components/ref-cell.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// refCell(index, recordType, id) → HTMLElement
|
||||
//
|
||||
// Renders a 1-based row number in a table cell.
|
||||
// Single click — copies deep-link to clipboard and flashes "Copied!".
|
||||
// Double click — opens deep-link in a new tab.
|
||||
//
|
||||
// Deep-link format: <origin>/data/<recordType>/<id>
|
||||
//
|
||||
// Usage:
|
||||
// import {refCell} from "./components/ref-cell.js";
|
||||
// // in an Inputs.table format callback:
|
||||
// format: { id: (_, i) => refCell(i + 1, "token-events", row.id) }
|
||||
|
||||
const _STYLE_ID = "refcell-global-style";
|
||||
if (!document.getElementById(_STYLE_ID)) {
|
||||
const s = document.createElement("style");
|
||||
s.id = _STYLE_ID;
|
||||
s.textContent = `
|
||||
.ref-cell {
|
||||
display: inline-block;
|
||||
font-family: var(--monospace, monospace);
|
||||
font-size: 0.78rem;
|
||||
color: var(--theme-foreground-focus, #3b82f6);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ref-cell:hover {
|
||||
background: var(--theme-foreground-faint, #e8f0fe);
|
||||
}
|
||||
.ref-cell-toast {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: var(--theme-background, #fff);
|
||||
border: 1px solid var(--theme-foreground-faint, #ddd);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-foreground, #333);
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ref-cell-toast.ref-cell-toast-visible { opacity: 1; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function _showToast(anchorEl, text) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "ref-cell-toast";
|
||||
toast.textContent = text;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const gap = 6;
|
||||
toast.style.left = `${rect.left}px`;
|
||||
toast.style.top = `${rect.top - toast.offsetHeight - gap}px`;
|
||||
|
||||
requestAnimationFrame(() => toast.classList.add("ref-cell-toast-visible"));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("ref-cell-toast-visible");
|
||||
toast.addEventListener("transitionend", () => toast.remove(), {once: true});
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
export function refCell(index, recordType, id) {
|
||||
const deepLink = `${location.origin}/${recordType}/${id}`;
|
||||
|
||||
const el = document.createElement("span");
|
||||
el.className = "ref-cell";
|
||||
el.title = `Click to copy link · Double-click to open\n${deepLink}`;
|
||||
el.textContent = String(index);
|
||||
|
||||
let clickTimer = null;
|
||||
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
// Use a short delay so a double-click cancels the single-click handler.
|
||||
clickTimer = setTimeout(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(deepLink);
|
||||
_showToast(el, "Copied!");
|
||||
} catch {
|
||||
// Fallback for environments where clipboard API is blocked.
|
||||
_showToast(el, deepLink);
|
||||
}
|
||||
}, 180);
|
||||
});
|
||||
|
||||
el.addEventListener("dblclick", (e) => {
|
||||
e.stopPropagation();
|
||||
clearTimeout(clickTimer);
|
||||
window.open(deepLink, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
Reference in New Issue
Block a user