Files
state-hub/dashboard/src/components/field-help.js
tegwick b28298a2ec 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>
2026-03-29 22:35:35 +02:00

264 lines
9.2 KiB
JavaScript

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