Files
skills/orchestrator/templates/page-tasks.js
T
Kevin Veen-BirkenbachandClaude Opus 5.5 8e63b21e21 feat(orchestrator): W2 Kanban board with swimlanes and forecast
Tasks gain a Kanban board: columns Waiting, Running and Done, swimlanes P1 to P4+ and Unpinned by WSJF, after-dependency arrows and the dashed queue line; drag and drop stages status, pin and dependency moves that apply with one passkey. The burn chart gets the trend and a forecast. Committed below the bar on the operator's word 2026-11-12 (no further per-bundle tests, one gate after the merge): the W2 e2e never ran.

🎯 Fix: 80 %
💥 Break: 10 %
💸 Cost: 100 %
⏱️ Time: 100 %
🛡️ Security: 100 %
🏆 Score: 72
🚦 Gate: ⛔ fix 80 % < 90, break 10 % > 5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 16:14:36 +00:00

228 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const TASK_STATUS = {queued: ["waiting", "info"], running: ["running", "ok"], blocked: ["blocked", "bad"], done: ["done", "neutral"], stopped: ["stopped", "neutral"], awaiting: ["waiting for a grant", "warn"]};
const TASK_COLUMNS = Kanban.COLUMNS;
const TASK_KINDS = {command: ["Command", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue job", "teal"]};
const TASK_FILTERS = [
["all", "All", "bi-list-check", () => true],
["open", "Open", "bi-play-circle", (row) => !row.ended],
["work", "Open work", "bi-pencil-square", (row) => row.open_work.length > 0],
["operator", "Waits on Operator", "bi-person-raised-hand", null],
["grant", "Waiting for a grant", "bi-hourglass-split", (row) => row.kind === "queue" && row.status === "awaiting"],
["ended", "Ended", "bi-check2-circle", (row) => row.ended],
];
const ROLE_TONES = {R: "ok", A: "violet", S: "info", C: "warn", I: "neutral"};
const ROLE_WORDS = {R: "Responsible", A: "Accountable", S: "Supportive", C: "Consulted", I: "Informed"};
const MATRIX_PARTIES = 40;
const taskView = {filter: "all", q: "", dom: null, sig: "", sort: "prio", descending: false, forecast: "always", scope: "all", picked: new Set(), plan: null, bulk: null};
const ETA_HINTS = {
true: "ETA: its start, or for a task not yet started now or the latest ETA of its predecessors, plus the forecast length of the estimation mode",
false: "ETA: the end of the ended task",
};
const branchOf = (row) => row.worktree_branch || (row.branch !== "-" ? row.branch : "");
const titleOf = (row) => (row.title !== "-" ? row.title : row.task);
const openJobs = (task) => state.jobs.filter((job) => job.kind === "task" && job.task === task && (job.status === "queued" || job.status === "running"));
function taskStatus(row) {
const [label, tone] = Object.hasOwn(TASK_STATUS, row.status) ? TASK_STATUS[row.status] : [row.status, "neutral"];
return pill(label, tone);
}
function taskBadges(row, withStatus) {
const [kindName, kindTone] = Object.hasOwn(TASK_KINDS, row.kind) ? TASK_KINDS[row.kind] : [null, null];
const score = row.wsjf;
return [
kindName ? pill(kindName, kindTone) : null,
withStatus ? taskStatus(row) : null,
row.total ? pill(`KR ${row.met}/${row.total}`, row.met === row.total ? "ok" : "info") : null,
row.prio !== null ? h("span", {class: "pill", "data-tone": "violet", title: "pinned priority"}, `Prio ${row.prio}`) : null,
score ? h("span", {class: "pill", "data-tone": "teal", title: `WSJF: business value ${score.value} / complexity ${score.complexity}`}, `WSJF ${score.score}`) : null,
openJobs(row.task).length ? pill("Change at the runner", "warn") : null,
];
}
function roleSummary(row) {
const holders = (letter) => Object.entries(row.roles).filter(([, letters]) => letters.includes(letter)).map(([party]) => party);
return ["R", "A"].map((letter) => (holders(letter).length ? h("span", {title: ROLE_WORDS[letter]}, `${letter}: ${holders(letter).join(", ")}`) : null));
}
function taskPlace(row) {
if (row.kind === "queue") {
return h("span", {}, `${row.queue.kind} for ${row.queue.parent} · queued `, when(row.queue.enqueued));
}
const place = row.worktree || (row.workdir !== "-" ? row.workdir : "");
return place ? h("code", {title: row.repo || place}, place) : null;
}
function opener(row) {
return h("button", {type: "button", class: "linkish", title: `Open ${row.task}`, on: {click: () => taskSheet(row.task)}}, row.kind === "command" ? h("code", {}, titleOf(row)) : titleOf(row));
}
const etaOf = () => Plan.etas(state.tasks, state.task_edges, {now: state.now, estimation: settings.estimation});
function etaMark(found) {
const known = Boolean(found) && found.eta !== null;
const forecast = Boolean(found) && found.forecast;
return h("span", {class: "task-eta", "data-eta": known ? String(found.eta) : "", "data-start": found && found.start !== null ? String(found.start) : "", "data-forecast": String(forecast), title: ETA_HINTS[forecast]},
icon("bi-flag"), "ETA ", known ? absolute.format(stampOf(found.eta)) : "–");
}
function taskItem(row, eta) {
const classes = ["task-row", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
const pick = row.editable ? h("input", {type: "checkbox", class: "task-pick", "aria-label": `Select ${row.task}`, title: `Select ${row.task} for a bulk business value`, checked: taskView.picked.has(row.task), on: {change: (event) => pickTask(row.task, event.target.checked)}}) : null;
return h("li", {class: classes, "data-task": row.task, "data-kind": row.kind, "data-status": row.status},
h("div", {class: "head-row"}, pick, h("code", {class: "task-id"}, row.task), taskBadges(row, true), h("span", {class: "spacer"}), etaMark(eta),
row.editable ? iconBtn("bi-sort-numeric-down", "Priority", "btn small soft", () => taskSheet(row.task), `Priority and WSJF of ${row.task}: pin or estimate, signed with the passkey`) : null),
opener(row),
row.objective !== "-" ? h("div", {class: "muted"}, `Objective: ${row.objective}`) : null,
h("div", {class: "task-meta"}, row.worker !== "-" ? h("span", {class: "who"}, face(row.worker, false), person(row.worker)) : null, roleSummary(row), taskPlace(row)),
flowLine(row.flow, branchOf(row)),
workChips(row.open_work));
}
function roleLetters(letters) {
return [...letters].map((letter) => h("span", {class: "letter", "data-tone": ROLE_TONES[letter], title: ROLE_WORDS[letter]}, letter));
}
function taskMatrix(rows) {
const parties = [...new Set(rows.flatMap((row) => Object.keys(row.roles)))].sort((a, b) => Number(a !== HUMAN) - Number(b !== HUMAN) || a.localeCompare(b, "en"));
if (!parties.length) {
return emptyState("No roles", "None of the shown tasks names R, A, S, C or I.");
}
const columns = parties.slice(0, MATRIX_PARTIES);
const legend = h("div", {class: "legend"}, Object.entries(ROLE_WORDS).map(([letter, word]) => h("span", {}, roleLetters(letter), word)),
parties.length > columns.length ? h("span", {class: "muted"}, `${parties.length - columns.length} more parties not shown`) : null);
const head = h("thead", {}, h("tr", {}, h("th", {scope: "col"}, "Task"), columns.map((party) => h("th", {scope: "col", title: party}, h("span", {class: "party"}, party)))));
const line = (row) => h("tr", {"data-task": row.task, class: row.open_work.length ? "open-work" : null},
h("th", {scope: "row"}, h("div", {class: "matrix-task"}, h("code", {class: "task-id"}, row.task), opener(row))),
columns.map((party) => {
const letters = Object.hasOwn(row.roles, party) ? row.roles[party] : "";
return h("td", {title: letters ? `${party}: ${letters}` : null}, roleLetters(letters));
}));
const table = h("table", {class: "matrix", "aria-label": "RASCI matrix: tasks by parties"}, head, h("tbody", {}, rows.map(line)));
return h("div", {class: "matrix-box"}, h("div", {class: "matrix-scroll"}, table), legend);
}
function taskModel3d() {
const rows = shownTasks();
const ids = new Set(rows.map((row) => row.task));
const starts = new Map(Plan.bars(rows, state.task_edges, {now: state.now, estimation: settings.estimation, forecast: "always"}).map((bar) => [bar.task, bar.start]));
const toneOf = (row) => TONE_ROLES[Object.hasOwn(TASK_STATUS, row.status) ? TASK_STATUS[row.status][1] : "neutral"];
return {
nodes: rows.map((row) => ({id: row.task, kind: "task", label: `${row.task} · ${titleOf(row)}`, tone: toneOf(row), depth: 0, ts: starts.get(row.task), parent: null})),
links: state.task_edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to).map((edge) => ({source: edge.from, target: edge.to, kind: edge.kind})),
};
}
function taskSelect(id, anchor, closed) {
const row = state.tasks.find((entry) => entry.task === id);
if (!row) {
return;
}
const at = (ts) => (ts === null ? "" : absolute.format(stampOf(ts)));
const open = h("button", {type: "button", class: "btn small soft", ...named("Open task", `Open ${row.task}: priority, WSJF, RASCI and signed changes`), on: {click: () => {
closePopup();
taskSheet(row.task);
}}}, icon("bi-box-arrow-up-right"), "Open");
openPopup(row.task, [popupHead(icon(Nav.ICONS.tasks), titleOf(row), h("code", {class: "task-id"}, row.task)), h("div", {class: "popup-body"},
h("div", {class: "states"}, taskBadges(row, true)), meta([["Worker", row.worker], ["Branch", branchOf(row)], ["Start", at(row.start)], ["End", at(row.end)]]),
etaMark(etaOf().get(row.task)), h("div", {class: "actions"}, open))], anchor, closed);
}
function chooseTaskFilter(id) {
if (id === "operator") {
go("inbox", null);
return;
}
taskView.filter = id;
if (ui.screen === "inbox") {
go("tasks", null);
} else {
drawTasks();
}
}
function taskChips(rows, inbox) {
return TASK_FILTERS.map(([id, label, name, test]) => {
const total = test ? rows.filter(test).length : waitingCount();
return h("button", {type: "button", class: "chip preset", "data-filter": id, "aria-pressed": String(inbox ? id === "operator" : id === taskView.filter), title: `${label}: ${total}`, on: {click: () => chooseTaskFilter(id)}},
icon(name), label, counter(total));
});
}
function taskViewSwitch(inbox) {
return h("div", {class: "segmented", role: "group", "aria-label": "View"}, Nav.TASK_VIEWS.map(([id, name]) => h("button", {
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(!inbox && ui.views.tasks === id), on: {click: () => go("tasks", id)},
}, icon(Nav.ICONS[id]))));
}
function tasksScreen() {
const inbox = ui.screen === "inbox";
const view = ui.views.tasks;
const chips = h("div", {class: "chips", role: "group", "aria-label": "Task filters"});
const count = h("span", {class: "muted"});
const search = inbox ? null : h("input", {type: "search", class: "field", placeholder: "Filter by task, title, worker, branch …", value: taskView.q, "aria-label": "Filter tasks", on: {input: (event) => {
taskView.q = event.target.value;
drawTasks();
}}});
const stage = !inbox && view === "3d" ? h("div", {class: "stage"}) : null;
const body = inbox ? inboxScreen() : stage ? h("div", {class: "card stage-card tasks-body"}, layoutTools("tasks"), stage) : h("div", {class: "tasks-body"});
taskView.dom = {chips, count, body, inbox, view, stage};
taskView.sig = "";
taskView.plan = null;
taskView.bulk = null;
const bar = h("div", {class: "card controls task-toolbar", role: "toolbar", "aria-label": "Tasks"}, taskViewSwitch(inbox), search, count, inbox ? null : planTools(view), choiceMenu());
return h("div", {class: "graph tasks"}, bar, chips, body);
}
function shownTasks() {
const q = taskView.q.trim().toLowerCase();
const [, , , test] = TASK_FILTERS.find(([id]) => id === taskView.filter);
const hit = (row) => !q || [row.task, row.title, row.worker, row.branch, row.workdir, row.objective].some((text) => String(text).toLowerCase().includes(q));
return state.tasks.filter(test).filter(hit);
}
function drawTasks() {
const dom = taskView.dom;
if (!dom) {
return;
}
if (dom.stage) {
stage3d("tasks", dom.stage);
}
const q = taskView.q.trim().toLowerCase();
const sig = JSON.stringify([state.tasks, state.jobs, state.task_edges, settings.estimation, settings.trend, taskView.filter, taskView.sort, taskView.descending, taskView.forecast, taskView.scope, q, waitingCount(), Math.floor(nowSeconds() / 60)]);
if (sig === taskView.sig) {
return;
}
taskView.sig = sig;
const rows = state.tasks;
const editable = new Set(rows.filter((row) => row.editable).map((row) => row.task));
for (const task of [...taskView.picked]) {
if (!editable.has(task)) {
taskView.picked.delete(task);
}
}
syncPlan();
dom.chips.replaceChildren(...taskChips(rows, dom.inbox));
if (dom.inbox) {
dom.count.textContent = `${rows.length} tasks in the register`;
return;
}
const shown = shownTasks();
dom.count.textContent = `${shown.length} of ${rows.length} tasks`;
if (dom.stage) {
return;
}
const views = {
list: (found) => {
const etas = etaOf();
return h("ol", {class: "task-list"}, Plan.sortRows(found, taskView.sort, taskView.descending).map((row) => taskItem(row, etas.get(row.task))));
},
board: kanbanBoard,
rasci: taskMatrix,
gantt: ganttChart,
burn: (found) => burnChart(rows, found),
};
dom.body.replaceChildren(shown.length || dom.view === "burn" ? views[dom.view](shown) : emptyState("No task", "No entry matches this filter."));
}