Add a Tasks tab that lists the kvb-org-rasci register next to the inbox and the heavy-job queue, in list, board and RASCI matrix views, each task with its roles, WSJF, open work and the merge flow of its worktree. - Task priority pins and WSJF estimates are signed with the operator's passkey and applied by the runner through rasci.py; the hub verifies the signature itself before it queues a job or logs the change, the runner verifies it again and runs only the checked argv, from /, with a pinned PATH and a 30 s process-group limit. - The runner refuses task changes into a council that is a symlink, not owned by its uid or given by a relative path, and while the installed council.py, nofollow.py or rasci.py differ from the tree. - Only tasks the hub can sign whole are editable (task slug and length). - Page lookups keyed by register text use Object.hasOwn, so a status or party named like an Object.prototype member cannot break the views. - The inbox screen renders through the tasks screen, so its task filter shows with "Wartet auf Operator" pressed. Tests: 604 unit tests OK, e2e 15/15 OK (Chromium and Firefox). 🎯 Fix: 93 % 💥 Break: 5 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 88 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
10 KiB
JavaScript
178 lines
10 KiB
JavaScript
const TASK_STATUS = {queued: ["wartet", "info"], running: ["läuft", "ok"], blocked: ["blockiert", "bad"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"], awaiting: ["wartet auf Freigabe", "warn"]};
|
|
const TASK_COLUMNS = [["queued", "Wartet"], ["running", "Läuft"], ["blocked", "Blockiert"], ["done", "Erledigt"], ["stopped", "Gestoppt"]];
|
|
const TASK_KINDS = {command: ["Befehl", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue-Job", "teal"]};
|
|
const TASK_FILTERS = [
|
|
["alle", "Alle", "bi-list-check", () => true],
|
|
["offen", "Offen", "bi-play-circle", (row) => !row.ended],
|
|
["arbeit", "Offene Arbeit", "bi-pencil-square", (row) => row.open_work.length > 0],
|
|
["operator", "Wartet auf Operator", "bi-person-raised-hand", null],
|
|
["freigabe", "Wartet auf Freigabe", "bi-hourglass-split", (row) => row.kind === "queue" && row.status === "awaiting"],
|
|
["beendet", "Beendet", "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: "alle", q: "", dom: null, sig: ""};
|
|
|
|
const columnOf = (row) => (TASK_COLUMNS.some(([id]) => id === row.status) ? row.status : "queued");
|
|
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: "angeheftete Priorität"}, `Prio ${row.prio}`) : null,
|
|
score ? h("span", {class: "pill", "data-tone": "teal", title: `WSJF: Geschäftswert ${score.value} / Komplexität ${score.complexity}`}, `WSJF ${score.score}`) : null,
|
|
openJobs(row.task).length ? pill("Änderung beim 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} für ${row.queue.parent} · eingereiht `, 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: `${row.task} öffnen`, on: {click: () => taskSheet(row.task)}}, row.kind === "command" ? h("code", {}, titleOf(row)) : titleOf(row));
|
|
}
|
|
|
|
function taskItem(row) {
|
|
const classes = ["task-row", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
|
|
return h("li", {class: classes, "data-task": row.task, "data-kind": row.kind, "data-status": row.status},
|
|
h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task), taskBadges(row, true), h("span", {class: "spacer"}),
|
|
row.editable ? iconBtn("bi-sort-numeric-down", "Priorität", "btn small soft", () => taskSheet(row.task), `Priorität und WSJF von ${row.task}: anheften oder schätzen, mit Passkey signiert`) : null),
|
|
opener(row),
|
|
row.objective !== "-" ? h("div", {class: "muted"}, `Ziel: ${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 taskCard(row) {
|
|
const classes = ["task-card", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
|
|
return h("li", {class: classes, "data-task": row.task},
|
|
h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task)),
|
|
opener(row),
|
|
h("div", {class: "states"}, taskBadges(row, row.status === "awaiting")),
|
|
row.worker !== "-" ? h("span", {class: "muted"}, row.worker) : null,
|
|
row.flow ? flowLine(row.flow, branchOf(row)) : null,
|
|
workChips(row.open_work));
|
|
}
|
|
|
|
function taskBoard(rows) {
|
|
return h("div", {class: "task-board"}, TASK_COLUMNS.map(([id, label]) => {
|
|
const cards = rows.filter((row) => columnOf(row) === id);
|
|
return h("section", {class: "card task-column", "data-column": id, "aria-label": label},
|
|
h("h3", {class: "group-title"}, label, counter(cards.length)),
|
|
cards.length ? h("ol", {class: "task-cards"}, cards.map(taskCard)) : h("p", {class: "muted"}, "leer"));
|
|
}));
|
|
}
|
|
|
|
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, "de"));
|
|
if (!parties.length) {
|
|
return emptyState("Keine Rollen", "Keine der gezeigten Aufgaben nennt R, A, S, C oder 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} weitere Parteien nicht gezeigt`) : null);
|
|
const head = h("thead", {}, h("tr", {}, h("th", {scope: "col"}, "Aufgabe"), 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: Aufgaben mal Parteien"}, head, h("tbody", {}, rows.map(line)));
|
|
return h("div", {class: "matrix-box"}, h("div", {class: "matrix-scroll"}, table), legend);
|
|
}
|
|
|
|
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": "Ansicht"}, 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": "Aufgabenfilter"});
|
|
const count = h("span", {class: "muted"});
|
|
const search = inbox ? null : h("input", {type: "search", class: "field", placeholder: "Aufgabe, Titel, Worker, Branch filtern …", value: taskView.q, "aria-label": "Aufgaben filtern", on: {input: (event) => {
|
|
taskView.q = event.target.value;
|
|
drawTasks();
|
|
}}});
|
|
const body = inbox ? inboxScreen() : h("div", {class: "tasks-body"});
|
|
taskView.dom = {chips, count, body, inbox, view};
|
|
taskView.sig = "";
|
|
return h("div", {class: "graph tasks"}, h("div", {class: "card controls"}, taskViewSwitch(inbox), search, count), chips, body);
|
|
}
|
|
|
|
function drawTasks() {
|
|
const dom = taskView.dom;
|
|
if (!dom) {
|
|
return;
|
|
}
|
|
const q = taskView.q.trim().toLowerCase();
|
|
const sig = JSON.stringify([state.tasks, state.jobs, taskView.filter, q, waitingCount(), Math.floor(nowSeconds() / 60)]);
|
|
if (sig === taskView.sig) {
|
|
return;
|
|
}
|
|
taskView.sig = sig;
|
|
const rows = state.tasks;
|
|
dom.chips.replaceChildren(...taskChips(rows, dom.inbox));
|
|
if (dom.inbox) {
|
|
dom.count.textContent = `${rows.length} Aufgaben im Register`;
|
|
return;
|
|
}
|
|
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));
|
|
const shown = rows.filter(test).filter(hit);
|
|
dom.count.textContent = `${shown.length} von ${rows.length} Aufgaben`;
|
|
const views = {list: (found) => h("ol", {class: "task-list"}, found.map(taskItem)), board: taskBoard, rasci: taskMatrix};
|
|
dom.body.replaceChildren(shown.length ? views[dom.view](shown) : emptyState("Keine Aufgabe", "Kein Eintrag passt zu diesem Filter."));
|
|
}
|