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>
This commit is contained in:
1 parent
fd8fc08603
commit
8e63b21e21
18 files changed
+1375
-76
No files matched your search
@@ -412,6 +412,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "style-tasks.css" %}
|
||||
|
||||
{% include "style-kanban.css" %}
|
||||
|
||||
{% include "style-bubbles.css" %}
|
||||
</style>
|
||||
</head>
|
||||
@@ -462,6 +464,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
{% include "faces.js" %}
|
||||
|
||||
{% include "plan.js" %}
|
||||
|
||||
{% include "kanban.js" %}
|
||||
</script>
|
||||
<script nonce="{{ nonce }}">
|
||||
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
|
||||
@@ -536,6 +540,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-tasks-gantt.js" %}
|
||||
|
||||
{% include "page-kanban.js" %}
|
||||
|
||||
{% include "page-choices.js" %}
|
||||
|
||||
{% include "page-time.js" %}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
const Kanban = (() => {
|
||||
"use strict";
|
||||
const COLUMNS = [["queued", "Waiting"], ["running", "Running"], ["done", "Done"]];
|
||||
const LANES = [["p1", "P1"], ["p2", "P2"], ["p3", "P3"], ["p4", "P4+"], ["unpinned", "Unpinned"]];
|
||||
const PINS = {p1: 1, p2: 2, p3: 3};
|
||||
const FREE_FROM = 4;
|
||||
const OFF = ["command"];
|
||||
const DAY = 86400;
|
||||
const REFUSED = ["failed", "refused"];
|
||||
|
||||
const onBoard = (row) => !OFF.includes(row.kind);
|
||||
const laneOf = (prio) => (prio === null ? "unpinned" : prio >= FREE_FROM ? "p4" : `p${Math.max(prio, 1)}`);
|
||||
const labelOf = (row) => (row.status === "blocked" || row.status === "stopped" ? row.status : null);
|
||||
const old = (row, now) => columnOf(row) === "done" && row.end !== null && now - row.end > DAY;
|
||||
const score = (row) => (row.wsjf ? row.wsjf.score : null);
|
||||
|
||||
/**
|
||||
* @param row - a task row.
|
||||
* @returns "done" for done and stopped, "running" for running and for blocked once started, else "queued".
|
||||
*/
|
||||
function columnOf(row) {
|
||||
if (row.status === "done" || row.status === "stopped") {
|
||||
return "done";
|
||||
}
|
||||
return row.status === "running" || (row.status === "blocked" && row.start !== null) ? "running" : "queued";
|
||||
}
|
||||
|
||||
function ranked(a, b) {
|
||||
const pins = (a.prio === null) - (b.prio === null) || (a.prio || 0) - (b.prio || 0);
|
||||
const scores = (score(a) === null) - (score(b) === null) || (score(b) || 0) - (score(a) || 0);
|
||||
return pins || scores || a.task.localeCompare(b.task, "en");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rows - the task rows to place, staged changes applied.
|
||||
* @param options - {now, old: true shows Done cards that ended more than 24 h ago}.
|
||||
* @returns {lanes, hidden}: per lane of LANES {id, label, count, cells: {column: rows}}, each cell in pin then WSJF
|
||||
* order (WSJF rank alone in Unpinned), and the count of hidden old Done cards. Operator commands are never placed;
|
||||
* a pin below 1 counts as P1.
|
||||
*/
|
||||
function board(rows, options) {
|
||||
const placed = rows.filter(onBoard);
|
||||
const hidden = options.old ? [] : placed.filter((row) => old(row, options.now));
|
||||
const shown = placed.filter((row) => !hidden.includes(row)).sort(ranked);
|
||||
const lanes = LANES.map(([id, label]) => {
|
||||
const own = shown.filter((row) => laneOf(row.prio) === id);
|
||||
return {id, label, count: own.length, cells: Object.fromEntries(COLUMNS.map(([column]) => [column, own.filter((row) => columnOf(row) === column)]))};
|
||||
});
|
||||
return {lanes, hidden: hidden.length};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rows - every task row, staged changes applied.
|
||||
* @param task - the task that moves into P4+.
|
||||
* @returns the lowest pin from 4 no other open task holds.
|
||||
*/
|
||||
function freePin(rows, task) {
|
||||
const held = new Set(rows.filter((row) => row.task !== task && !row.ended && row.prio !== null).map((row) => row.prio));
|
||||
let pin = FREE_FROM;
|
||||
while (held.has(pin)) {
|
||||
pin += 1;
|
||||
}
|
||||
return pin;
|
||||
}
|
||||
|
||||
const slotOf = (target) => (target.after ? `after:${target.after}` : target.drop ? `after:${target.drop}` : target.lane ? "prio" : "status");
|
||||
const slotOfArgv = (argv) => (argv[2] === "--after" || argv[2] === "--drop-after" ? `after:${argv[3]}` : argv[2] === "--status" ? "status" : "prio");
|
||||
|
||||
/**
|
||||
* @param row - the hub's row of the moved card.
|
||||
* @param target - {column}, {status}, {lane}, {after: the task it follows} or {drop: the task it no longer follows}.
|
||||
* @param rows - every task row, staged changes applied, for the free pin.
|
||||
* @returns the rasci.py argv of the move against the hub's row, or null when the row already reads so.
|
||||
*/
|
||||
function move(row, target, rows) {
|
||||
if (target.column) {
|
||||
return columnOf(row) === target.column ? null : ["set", row.task, "--status", target.column];
|
||||
}
|
||||
if (target.status) {
|
||||
return row.status === target.status ? null : ["set", row.task, "--status", target.status];
|
||||
}
|
||||
if (target.lane) {
|
||||
if (laneOf(row.prio) === target.lane) {
|
||||
return null;
|
||||
}
|
||||
return target.lane === "unpinned" ? ["set", row.task, "--unpin"] : ["set", row.task, "--prio", String(PINS[target.lane] || freePin(rows, row.task))];
|
||||
}
|
||||
if (target.after) {
|
||||
return target.after === row.task || row.after.includes(target.after) ? null : ["set", row.task, "--after", target.after];
|
||||
}
|
||||
return target.drop && row.after.includes(target.drop) ? ["set", row.task, "--drop-after", target.drop] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param staged - the staged changes, each {task, argv}.
|
||||
* @param row - the hub's row of the moved card.
|
||||
* @param target - as move() takes it.
|
||||
* @param rows - every task row, staged changes applied.
|
||||
* @returns the new staged list: the move replaces an earlier one of the same task and kind (status, pin, or one
|
||||
* dependency), and a move back to the hub's state leaves only the drop of that earlier one.
|
||||
*/
|
||||
function stage(staged, row, target, rows) {
|
||||
const slot = slotOf(target);
|
||||
const kept = staged.filter((change) => !(change.task === row.task && slotOfArgv(change.argv) === slot));
|
||||
const argv = move(row, target, rows);
|
||||
return argv ? [...kept, {task: row.task, argv}] : kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rows - the hub's task rows.
|
||||
* @param staged - the staged changes.
|
||||
* @returns the rows as they read once the staged changes are applied, a changed row with pending, the argv
|
||||
* staged for it; the others unchanged.
|
||||
*/
|
||||
function pending(rows, staged) {
|
||||
return rows.map((row) => {
|
||||
const own = staged.filter((change) => change.task === row.task);
|
||||
if (!own.length) {
|
||||
return row;
|
||||
}
|
||||
const next = {...row, after: [...row.after], pending: own.map((change) => change.argv)};
|
||||
for (const {argv} of own) {
|
||||
const [, , flag, value] = argv;
|
||||
if (flag === "--status") {
|
||||
Object.assign(next, {status: value, ended: value === "done" || value === "stopped", end: null});
|
||||
} else if (flag === "--prio") {
|
||||
next.prio = Number(value);
|
||||
} else if (flag === "--unpin") {
|
||||
next.prio = null;
|
||||
} else if (flag === "--after") {
|
||||
next.after.push(value);
|
||||
} else {
|
||||
next.after = next.after.filter((task) => task !== value);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rows - the shown cards' rows, staged changes applied.
|
||||
* @param line - the hub's queue_line: tasks holding a live queue entry, in grant order.
|
||||
* @returns {arrows, queue}: one {from, to} per after dependency between two shown cards, and the shown tasks of the
|
||||
* queue line in its order.
|
||||
*/
|
||||
function lines(rows, line) {
|
||||
const shown = new Set(rows.map((row) => row.task));
|
||||
const arrows = rows.flatMap((row) => row.after.filter((task) => task !== row.task && shown.has(task)).map((from) => ({from, to: row.task})));
|
||||
return {arrows, queue: line.filter((task) => shown.has(task))};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param jobs - the hub's jobs.
|
||||
* @param task - a card's task.
|
||||
* @returns {failed, applied, total} over the task's changes in its newest signed batch (a job without a batch is
|
||||
* one of its own): failed the jobs rasci.py or the runner refused, oldest first, applied the count rasci.py ran;
|
||||
* null when none of them failed.
|
||||
*/
|
||||
function why(jobs, task) {
|
||||
const own = jobs.filter((job) => job.kind === "task" && job.task === task).sort((a, b) => a.created_ts - b.created_ts);
|
||||
const newest = own[own.length - 1];
|
||||
const batch = !newest ? [] : newest.batch ? own.filter((job) => job.batch === newest.batch) : [newest];
|
||||
const failed = batch.filter((job) => REFUSED.includes(job.status));
|
||||
return failed.length ? {failed, applied: batch.filter((job) => job.status === "done").length, total: batch.length} : null;
|
||||
}
|
||||
|
||||
return {COLUMNS, LANES, FREE_FROM, onBoard, columnOf, laneOf, labelOf, board, freePin, move, stage, pending, lines, why};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Kanban;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
const KANBAN_LABELS = {blocked: ["Blocked", "bad"], stopped: ["Stopped", "neutral"]};
|
||||
const KANBAN_COLUMN_ICONS = {queued: "bi-hourglass", running: "bi-play-circle", done: "bi-check2-circle"};
|
||||
const KANBAN_DRAG = "application/x-kvb-task";
|
||||
const kanban = {staged: [], collapsed: new Set(), old: false, dragging: null};
|
||||
|
||||
function restage(next) {
|
||||
kanban.staged = next;
|
||||
taskView.sig = "";
|
||||
drawTasks();
|
||||
}
|
||||
|
||||
function stageMove(task, targets) {
|
||||
const hub = state.tasks.find((row) => row.task === task);
|
||||
if (!hub) {
|
||||
return;
|
||||
}
|
||||
let next = kanban.staged;
|
||||
for (const target of targets.filter((entry) => (entry.column || entry.status ? hub.movable : hub.editable))) {
|
||||
next = Kanban.stage(next, hub, target, Kanban.pending(state.tasks, next));
|
||||
}
|
||||
restage(next);
|
||||
}
|
||||
|
||||
async function applyStaged() {
|
||||
const changes = kanban.staged.map(({task, argv}) => ({task, argv}));
|
||||
if (changes.length && (await changeTasks(changes, `Apply ${changes.length} changes`, null))) {
|
||||
restage([]);
|
||||
}
|
||||
}
|
||||
|
||||
function cardWhy(task) {
|
||||
const found = Kanban.why(state.jobs, task);
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
const share = `${found.applied}/${found.total}`;
|
||||
const partly = found.applied ? h("span", {class: "pill kanban-partly", "data-tone": "warn", "data-partly": share, role: "img", ...named(`Partly applied: ${share}`, `${task}: ${found.applied} of the ${found.total} changes of the last batch applied, the others refused`)}, icon("bi-exclamation-diamond"), share) : null;
|
||||
return [partly, found.failed.map((job) => h("p", {class: "task-why", "data-job": job.id, title: job.command}, icon("bi-exclamation-triangle"), job.output.trim().split("\n").pop() || job.status))];
|
||||
}
|
||||
|
||||
function menuItem(name, label, run, hint) {
|
||||
return h("button", {type: "button", class: "btn small ghost", ...named(label, hint), on: {click: (event) => {
|
||||
event.currentTarget.closest("details").open = false;
|
||||
run();
|
||||
}}}, icon(name), label);
|
||||
}
|
||||
|
||||
function kanbanMenu(row, cards) {
|
||||
const columns = TASK_COLUMNS.map(([id, label]) => menuItem(KANBAN_COLUMN_ICONS[id], label, () => stageMove(row.task, [{column: id}]), `Move ${row.task} to ${label}`));
|
||||
const lanes = row.editable ? Kanban.LANES.map(([id, label]) => menuItem("bi-pin-angle", label, () => stageMove(row.task, [{lane: id}]), `Move ${row.task} to ${label}`)) : [];
|
||||
const others = cards.filter((other) => other.task !== row.task && !row.after.includes(other.task));
|
||||
const follow = row.editable && others.length ? h("select", {class: "field", ...named("Follows", `${row.task} follows the chosen task`), on: {change: (event) => stageMove(row.task, [{after: event.target.value}])}},
|
||||
h("option", {value: "", selected: true, disabled: true}, "…"), others.map((other) => h("option", {value: other.task}, other.task))) : null;
|
||||
const drops = row.editable ? row.after.map((task) => menuItem("bi-x-lg", task, () => stageMove(row.task, [{drop: task}]), `Remove: ${row.task} no longer follows ${task}`)) : [];
|
||||
return h("details", {class: "menu kanban-menu", on: {click: (event) => event.stopPropagation(), keydown: (event) => event.stopPropagation()}},
|
||||
h("summary", named("Move", `Move ${row.task}: column, lane and dependencies by keyboard`), icon("bi-three-dots-vertical")),
|
||||
h("div", {class: "menu-box"}, h("div", {class: "actions", role: "group", "aria-label": "Column"}, columns), lanes.length ? h("div", {class: "actions", role: "group", "aria-label": "Lane"}, lanes) : null, follow, drops.length ? h("div", {class: "actions", role: "group", "aria-label": "Follows"}, drops) : null));
|
||||
}
|
||||
|
||||
function dragFrom(task, mode) {
|
||||
return (event) => {
|
||||
event.stopPropagation();
|
||||
kanban.dragging = {task, mode};
|
||||
event.dataTransfer.setData(KANBAN_DRAG, task);
|
||||
event.dataTransfer.effectAllowed = mode === "card" ? "move" : "link";
|
||||
};
|
||||
}
|
||||
|
||||
function cardLabels(row) {
|
||||
const label = Kanban.labelOf(row);
|
||||
const stale = Plan.stale(row, state.now);
|
||||
const stop = stale && row.editable ? iconBtn("bi-stop-circle", "Stop", "btn small ghost kanban-stop", (event) => {
|
||||
event.stopPropagation();
|
||||
stageMove(row.task, [{status: "stopped"}]);
|
||||
}, `Stop ${row.task}: stage the status stopped`) : null;
|
||||
return [
|
||||
label ? h("span", {class: "pill", "data-tone": KANBAN_LABELS[label][1], "data-label": label}, KANBAN_LABELS[label][0]) : null,
|
||||
stale ? h("span", {class: "pill kanban-stale", "data-tone": "warn", "data-label": "stale"}, "Stale", stop) : null,
|
||||
];
|
||||
}
|
||||
|
||||
const cardOf = (task) => () => [...document.querySelectorAll("#main .kanban-card")].find((node) => node.dataset.task === task) || null;
|
||||
|
||||
function kanbanCard(row, lane, column, cards) {
|
||||
const open = () => taskSelect(row.task, cardOf(row.task));
|
||||
const own = (event) => !event.target.closest("button, summary, details, select, .kanban-handle");
|
||||
const after = () => kanban.dragging && kanban.dragging.mode === "after" && kanban.dragging.task !== row.task;
|
||||
return h("li", {
|
||||
class: ["task-card", "kanban-card", row.pending ? "pending" : ""].filter(Boolean).join(" "), "data-task": row.task, "data-lane": lane, "data-column": column, tabindex: 0,
|
||||
"data-pending": row.pending ? row.pending.map((argv) => argv.join(" ")).join("\n") : null, draggable: row.movable ? "true" : null,
|
||||
on: {
|
||||
click: (event) => own(event) && open(),
|
||||
keydown: (event) => {
|
||||
if ((event.key === "Enter" || event.key === " ") && event.target === event.currentTarget) {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
},
|
||||
dragstart: dragFrom(row.task, "card"),
|
||||
dragend: () => {
|
||||
kanban.dragging = null;
|
||||
},
|
||||
dragover: (event) => after() && event.preventDefault(),
|
||||
drop: (event) => {
|
||||
if (after()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const from = kanban.dragging.task;
|
||||
kanban.dragging = null;
|
||||
stageMove(row.task, [{after: from}]);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task), cardLabels(row), h("span", {class: "spacer"}),
|
||||
row.pending ? h("span", {class: "kanban-pending", role: "img", ...named("Staged", row.pending.map(shownArgv).join("\n"))}, icon("bi-pencil")) : null,
|
||||
openJobs(row.task).length ? h("span", {class: "kanban-wait", role: "img", ...named("At the runner", `${row.task}: a signed change waits for the runner`)}, icon("bi-hourglass-split")) : null,
|
||||
row.editable ? h("span", {class: "kanban-handle", role: "img", draggable: "true", ...named("Follows", `Drag onto the card that waits for ${row.task}`), on: {dragstart: dragFrom(row.task, "after"), dragend: () => {
|
||||
kanban.dragging = null;
|
||||
}}}, icon("bi-arrow-right-circle")) : null,
|
||||
row.movable ? kanbanMenu(row, cards) : null),
|
||||
h("span", {class: "kanban-title"}, titleOf(row)),
|
||||
row.worker !== "-" ? h("span", {class: "muted"}, row.worker) : null,
|
||||
cardWhy(row.task));
|
||||
}
|
||||
|
||||
function kanbanCell(lane, column, label, cards) {
|
||||
const card = () => kanban.dragging && kanban.dragging.mode === "card";
|
||||
return h("ol", {class: "kanban-cell task-cards", "data-lane": lane.id, "data-column": column, "aria-label": `${lane.label} · ${label}`, on: {
|
||||
dragover: (event) => card() && event.preventDefault(),
|
||||
drop: (event) => {
|
||||
if (card()) {
|
||||
event.preventDefault();
|
||||
const task = kanban.dragging.task;
|
||||
kanban.dragging = null;
|
||||
stageMove(task, [{column}, {lane: lane.id}]);
|
||||
}
|
||||
},
|
||||
}}, lane.cells[column].map((row) => kanbanCard(row, lane.id, column, cards)));
|
||||
}
|
||||
|
||||
function kanbanLane(lane, cards) {
|
||||
const open = !kanban.collapsed.has(lane.id);
|
||||
const id = `kanban-lane-${lane.id}`;
|
||||
const toggle = h("button", {type: "button", class: "lane-toggle", "aria-expanded": String(open), "aria-controls": id, title: `${lane.label}: ${open ? "collapse" : "expand"}`, on: {click: () => {
|
||||
if (open) {
|
||||
kanban.collapsed.add(lane.id);
|
||||
} else {
|
||||
kanban.collapsed.delete(lane.id);
|
||||
}
|
||||
taskView.sig = "";
|
||||
drawTasks();
|
||||
}}}, icon(open ? "bi-chevron-down" : "bi-chevron-right"), lane.label, counter(lane.count));
|
||||
return h("section", {class: "kanban-lane", "data-lane": lane.id, "data-open": String(open)}, toggle,
|
||||
h("div", {class: "kanban-cells", id, hidden: !open}, TASK_COLUMNS.map(([column, label]) => kanbanCell(lane, column, label, cards))));
|
||||
}
|
||||
|
||||
function kanbanBar(hidden, stale) {
|
||||
const count = kanban.staged.length;
|
||||
const words = `Apply ${count} changes`;
|
||||
const apply = h("button", {type: "button", class: "btn small primary", id: "kanban-apply", disabled: count === 0, ...named(words, `${words}: one passkey signs every staged move, the runner applies them through rasci.py`), on: {click: applyStaged}}, icon("bi-check2-all"), counter(count));
|
||||
const discard = h("button", {type: "button", class: "btn small icon-only", id: "kanban-discard", disabled: count === 0, ...named("Discard", "Discard the staged moves"), on: {click: () => restage([])}}, icon("bi-x-circle"));
|
||||
const older = h("button", {type: "button", class: "btn small", id: "kanban-old", "aria-pressed": String(kanban.old), ...named("Older Done", `Done cards older than 24 h: ${hidden}, ${kanban.old ? "shown" : "hidden"}`), on: {click: () => {
|
||||
kanban.old = !kanban.old;
|
||||
taskView.sig = "";
|
||||
drawTasks();
|
||||
}}}, icon(kanban.old ? "bi-eye" : "bi-eye-slash"), counter(hidden));
|
||||
const stalled = h("span", {class: "pill", id: "kanban-stale", "data-tone": "warn", role: "img", ...named(`Stale: ${stale}`, `Stale: ${stale} tasks without a register change for ${Plan.STALE_DAYS} days, counted apart`)}, icon("bi-hourglass-bottom"), String(stale));
|
||||
const listed = count ? h("ol", {class: "kanban-staged", id: "kanban-staged", "aria-label": `${words}: the passkey signs exactly these`},
|
||||
kanban.staged.map(({task, argv}) => h("li", {"data-task": task}, h("code", {}, shownArgv(argv))))) : null;
|
||||
return [h("div", {class: "kanban-bar", role: "toolbar", "aria-label": "Board"}, apply, discard, older, stalled), listed];
|
||||
}
|
||||
|
||||
function kanbanLines(box, rows) {
|
||||
const svg = box.querySelector("svg.kanban-lines");
|
||||
const base = box.getBoundingClientRect();
|
||||
const rectOf = (task) => {
|
||||
const node = [...box.querySelectorAll(".kanban-card")].find((card) => card.dataset.task === task);
|
||||
const found = node && node.getClientRects().length ? node.getBoundingClientRect() : null;
|
||||
return found && {x: found.left - base.left, y: found.top - base.top, w: found.width, h: found.height};
|
||||
};
|
||||
const centre = (r) => ({x: r.x + r.w / 2, y: r.y + r.h / 2});
|
||||
const rim = (r, toward) => {
|
||||
const c = centre(r);
|
||||
const [dx, dy] = [toward.x - c.x, toward.y - c.y];
|
||||
const scale = Math.min(dx ? r.w / 2 / Math.abs(dx) : Infinity, dy ? r.h / 2 / Math.abs(dy) : Infinity, 1);
|
||||
return {x: c.x + dx * scale, y: c.y + dy * scale};
|
||||
};
|
||||
const hub = new Map(state.tasks.map((row) => [row.task, row]));
|
||||
const {arrows, queue} = Kanban.lines(rows, state.queue_line);
|
||||
const marks = [];
|
||||
for (const {from, to} of arrows) {
|
||||
const [a, b] = [rectOf(from), rectOf(to)];
|
||||
if (a && b) {
|
||||
const [start, end] = [rim(a, centre(b)), rim(b, centre(a))];
|
||||
const d = `M${fixed(start.x)},${fixed(start.y)} L${fixed(end.x)},${fixed(end.y)}`;
|
||||
const staged = !(hub.has(to) && hub.get(to).after.includes(from));
|
||||
marks.push(s("g", {class: `kanban-arrow${staged ? " pending" : ""}`, "data-from": from, "data-to": to, on: {click: () => stageMove(to, [{drop: from}])}},
|
||||
s("title", {}, `${to} follows ${from}: click to remove`), s("path", {class: "kanban-hit", d}), s("path", {class: "kanban-link", d, "marker-end": "url(#kanban-tip)"})));
|
||||
}
|
||||
}
|
||||
const stops = queue.map(rectOf).filter(Boolean).map(centre);
|
||||
if (stops.length > 1) {
|
||||
marks.push(s("polyline", {class: "kanban-queue", "data-tasks": queue.filter((task) => rectOf(task)).join(" "), points: stops.map((at) => `${fixed(at.x)},${fixed(at.y)}`).join(" ")}, s("title", {}, "Queue order")));
|
||||
}
|
||||
svg.setAttribute("width", box.scrollWidth);
|
||||
svg.setAttribute("height", box.scrollHeight);
|
||||
svg.replaceChildren(s("defs", {}, s("marker", {id: "kanban-tip", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, markerUnits: "userSpaceOnUse", orient: "auto"}, s("path", {class: "kanban-tip", d: "M0,0 L10,5 L0,10 z"}))), ...marks);
|
||||
}
|
||||
|
||||
function kanbanBoard(shown) {
|
||||
const known = new Set(state.tasks.filter((row) => row.movable).map((row) => row.task));
|
||||
if (kanban.staged.some((change) => !known.has(change.task))) {
|
||||
kanban.staged = kanban.staged.filter((change) => known.has(change.task));
|
||||
}
|
||||
const ids = new Set(shown.map((row) => row.task));
|
||||
const rows = Kanban.pending(state.tasks, kanban.staged).filter((row) => ids.has(row.task) && Kanban.onBoard(row));
|
||||
const {lanes} = Kanban.board(rows, {now: state.now, old: kanban.old});
|
||||
const cards = lanes.flatMap((lane) => Object.values(lane.cells).flat());
|
||||
const head = h("div", {class: "kanban-head"}, TASK_COLUMNS.map(([id, label]) => h("h3", {class: "task-column group-title", "data-column": id}, icon(KANBAN_COLUMN_ICONS[id]), label,
|
||||
counter(cards.filter((row) => Kanban.columnOf(row) === id).length))));
|
||||
const box = h("div", {class: "kanban"}, head, lanes.map((lane) => kanbanLane(lane, cards)), s("svg", {class: "kanban-lines", "aria-hidden": "true"}));
|
||||
const watch = new ResizeObserver(() => (box.isConnected ? kanbanLines(box, cards) : watch.disconnect()));
|
||||
watch.observe(box);
|
||||
return h("div", {class: "kanban-box"}, kanbanBar(Kanban.board(rows, {now: state.now, old: false}).hidden, rows.filter((row) => Plan.stale(row, state.now)).length), box);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
const EDGE_WORDS = {after: "Follows (rasci.py set --after)", base: "Branches from (base branch)", queue: "Waits behind (queue order)"};
|
||||
const SERIES_WORDS = {scope: "Scope (burnup)", done: "Done (burnup)", remaining: "Remaining (burndown)", unestimated: "Unestimated tasks"};
|
||||
const STEP_WORDS = [["started", "Started"], ["ended", "Ended"], ["joined", "Joined the scope"]];
|
||||
const FINISH_WORDS = {fixed: ["Finish at fixed scope", "bi-flag"], growing: ["Finish at growing scope", "bi-graph-up-arrow"]};
|
||||
const ESTIMATION_ICONS = {none: "bi-1-circle", points: "bi-suit-diamond"};
|
||||
const TREND_DAYS = [1, 365];
|
||||
const TREND_REACH = 3;
|
||||
@@ -108,10 +109,40 @@
|
||||
return marks;
|
||||
}
|
||||
|
||||
function trendNote(trend) {
|
||||
const forecast = trend ? trend.forecast : null;
|
||||
const words = forecast !== null ? `Forecast: ${absolute.format(stampOf(forecast))}` : trend ? "No forecast: the trend never reaches 0 remaining, so done never meets scope" : "No forecast: too few points for a trend";
|
||||
return h("span", {class: "burn-forecast", "data-forecast": forecast === null ? "" : String(forecast)}, icon("bi-graph-up-arrow"), words);
|
||||
function finishNotes(trend, finish) {
|
||||
if (!trend) {
|
||||
return h("span", {class: "burn-forecast", "data-forecast": ""}, icon("bi-graph-up-arrow"), "No forecast: too few points for a trend");
|
||||
}
|
||||
return Object.entries(FINISH_WORDS).map(([kind, [words, name]]) => {
|
||||
const at = finish[kind];
|
||||
const when = at === null ? "∞" : absolute.format(stampOf(at));
|
||||
return h("span", {class: `burn-key burn-finish k-${kind}${kind === "growing" ? " burn-forecast" : ""}`, "data-finish": kind, "data-forecast": at === null ? "" : String(at), role: "img", ...named(`${words}: ${when}`)}, icon(name), when);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param series - the burn points.
|
||||
* @param trend - Plan.trend() of the points.
|
||||
* @param finish - Plan.finishes() of the points.
|
||||
* @param reach - the chart's last time.
|
||||
* @returns one {kind, at, from: [t, level], to: [t, level]} per reached finish, from the last point to its date cut at
|
||||
* reach: the fixed-scope finish runs level at the last scope, the growing-scope one along the scope trend.
|
||||
*/
|
||||
function finishSegments(series, trend, finish, reach) {
|
||||
const last = series[series.length - 1];
|
||||
const levels = {fixed: () => last.scope, growing: (t) => Plan.lineAt(trend.lines.scope, t)};
|
||||
return Object.keys(FINISH_WORDS).filter((kind) => finish[kind] !== null).map((kind) => {
|
||||
const end = Math.min(finish[kind], reach);
|
||||
return {kind, at: finish[kind], from: [last.t, levels[kind](last.t)], to: [end, levels[kind](end)]};
|
||||
});
|
||||
}
|
||||
|
||||
function burnFinish(svg, segments, x, y) {
|
||||
for (const {kind, at, from, to} of segments) {
|
||||
const words = s("title", {}, `${FINISH_WORDS[kind][0]}: ${absolute.format(stampOf(at))}`);
|
||||
svg.append(s("path", {class: `burn-finish-line k-${kind}`, "data-finish": kind, "data-from": from[0], "data-to": to[0], "data-at": at, d: `M${fixed(x(from[0]))},${fixed(y(from[1]))} L${fixed(x(to[0]))},${fixed(y(to[1]))}`}, words),
|
||||
s("circle", {class: `burn-finish-end k-${kind}`, "data-finish": kind, cx: fixed(x(to[0])), cy: fixed(y(to[1])), r: 4}));
|
||||
}
|
||||
}
|
||||
|
||||
function burnTrend(svg, trend, end, x, y) {
|
||||
@@ -127,22 +158,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
function burnSpan(picked, trend) {
|
||||
function burnSpan(picked, trend, finish) {
|
||||
const forecast = trend ? trend.forecast : null;
|
||||
if (forecast === null) {
|
||||
return {reach: picked.to, end: picked.to};
|
||||
}
|
||||
const reach = Math.min(Math.max(forecast, picked.to), picked.to + TREND_REACH * Math.max(picked.to - picked.from, 1));
|
||||
return {reach, end: Math.min(forecast, reach)};
|
||||
const latest = Math.max(picked.to, ...Object.values(finish).filter((at) => at !== null));
|
||||
const reach = Math.min(latest, picked.to + TREND_REACH * Math.max(picked.to - picked.from, 1));
|
||||
return {reach, end: forecast === null ? picked.to : Math.min(forecast, reach)};
|
||||
}
|
||||
|
||||
function burnChart(rows, shown) {
|
||||
const picked = Plan.scoped(rows, taskView.scope, {filtered: shown, since: state.since, until: state.until, now: state.now, holders: lineHolders()});
|
||||
const series = Plan.burn(picked.rows, {estimation: estimation(), from: picked.from, to: picked.to}).points;
|
||||
const trend = Plan.trend(series, settings.trend);
|
||||
const {reach, end} = burnSpan(picked, trend);
|
||||
const finish = Plan.finishes(series, trend);
|
||||
const {reach, end} = burnSpan(picked, trend, finish);
|
||||
const names = estimation() === "points" ? Plan.SERIES : Plan.SERIES.filter((name) => name !== "unestimated");
|
||||
const ends = trend ? Plan.TRENDED.flatMap((name) => [trend.start, end].map((t) => Plan.lineAt(trend.lines[name], t))) : [];
|
||||
const segments = trend ? finishSegments(series, trend, finish, reach) : [];
|
||||
const ends = trend ? [...Plan.TRENDED.flatMap((name) => [trend.start, end].map((t) => Plan.lineAt(trend.lines[name], t))), ...segments.flatMap((segment) => [segment.from[1], segment.to[1]])] : [];
|
||||
const top = Math.max(1, ...series.flatMap((point) => names.map((name) => point[name])), ...ends);
|
||||
const unit = estimation() === "points" ? "story points; unestimated tasks as a line of their own" : "tasks";
|
||||
const svg = s("svg", {class: "chart burn", viewBox: `0 0 ${BURN.width} ${BURN.height}`, role: "img", "aria-label": `Burndown and burnup in ${unit}; hover a point for the work packages of its step`});
|
||||
@@ -157,10 +188,12 @@
|
||||
}
|
||||
if (trend) {
|
||||
burnTrend(svg, trend, end, x, y);
|
||||
burnFinish(svg, segments, x, y);
|
||||
}
|
||||
svg.append(burnPoints(series, Plan.steps(picked.rows, series, picked.from), names, x, y));
|
||||
const stale = h("span", {class: "pill burn-stale", "data-tone": "warn", "data-stale": String(picked.stale.length), role: "img", ...named(`Stale: ${picked.stale.length}`, `Stale: ${picked.stale.length} tasks without a register change for ${Plan.STALE_DAYS} days, counted apart`)}, icon("bi-hourglass-bottom"), String(picked.stale.length));
|
||||
const legend = h("div", {class: "legend"}, names.map((name) => h("span", {class: `burn-key k-${name}`, "data-series": name}, `${SERIES_WORDS[name]}: ${last[name]}`)),
|
||||
settings.trend.mode === "off" ? null : trendNote(trend));
|
||||
settings.trend.mode === "off" ? null : finishNotes(trend, finish), stale);
|
||||
return h("div", {class: "burn-box"}, svg, legend, h("p", {class: "muted"}, `${picked.rows.length} tasks in scope, counted in ${unit}`));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 = [["queued", "Waiting"], ["running", "Running"], ["blocked", "Blocked"], ["done", "Done"], ["stopped", "Stopped"]];
|
||||
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],
|
||||
@@ -18,7 +18,6 @@
|
||||
false: "ETA: the end of the ended task",
|
||||
};
|
||||
|
||||
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"));
|
||||
@@ -80,26 +79,6 @@
|
||||
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"}, "empty"));
|
||||
}));
|
||||
}
|
||||
|
||||
function roleLetters(letters) {
|
||||
return [...letters].map((letter) => h("span", {class: "letter", "data-tone": ROLE_TONES[letter], title: ROLE_WORDS[letter]}, letter));
|
||||
}
|
||||
@@ -239,7 +218,7 @@
|
||||
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: taskBoard,
|
||||
board: kanbanBoard,
|
||||
rasci: taskMatrix,
|
||||
gantt: ganttChart,
|
||||
burn: (found) => burnChart(rows, found),
|
||||
|
||||
@@ -9,6 +9,9 @@ const Plan = (() => {
|
||||
const TRENDED = ["scope", "done", "remaining"];
|
||||
const OUTSIDE = ["queue", "command"];
|
||||
const DAY = 86400;
|
||||
const STALE_DAYS = 3;
|
||||
|
||||
const stale = (row, now) => !row.ended && typeof row.updated === "number" && now - row.updated >= STALE_DAYS * DAY;
|
||||
|
||||
function median(values) {
|
||||
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
||||
@@ -134,19 +137,22 @@ const Plan = (() => {
|
||||
* window), "agent:<name>" (the tasks the agent works on or holds a RASCI role in) or "line:<office>" (those of
|
||||
* the office's holder, over the whole time).
|
||||
* @param context - {filtered, since, until, now, holders: {office: holder}}.
|
||||
* @returns {rows, from, to}: the scope's rows without queue jobs and commands and its time range.
|
||||
* @returns {rows, stale, from, to}: the scope's rows without queue jobs, operator commands and stale tasks, the
|
||||
* stale ones apart, and its time range.
|
||||
*/
|
||||
function scoped(rows, choice, context) {
|
||||
const own = (name) => (row) => row.worker === name || Object.hasOwn(row.roles, name);
|
||||
const [kind, ...rest] = String(choice).split(":");
|
||||
const key = rest.join(":");
|
||||
const chosen = kind === "filter" ? context.filtered : kind === "agent" ? rows.filter(own(key)) : kind === "line" ? rows.filter(own(context.holders[key])) : rows;
|
||||
const kept = chosen.filter((row) => !OUTSIDE.includes(row.kind));
|
||||
const inside = chosen.filter((row) => !OUTSIDE.includes(row.kind));
|
||||
const kept = inside.filter((row) => !stale(row, context.now));
|
||||
const apart = inside.filter((row) => stale(row, context.now));
|
||||
if (kind === "filter") {
|
||||
return {rows: kept, from: context.since, to: context.until};
|
||||
return {rows: kept, stale: apart, from: context.since, to: context.until};
|
||||
}
|
||||
const first = Math.min(...kept.flatMap((row) => [row.start, row.end]).filter((value) => value !== null));
|
||||
return {rows: kept, from: Number.isFinite(first) ? Math.min(first, context.now) : context.now - 86400, to: context.now};
|
||||
return {rows: kept, stale: apart, from: Number.isFinite(first) ? Math.min(first, context.now) : context.now - 86400, to: context.now};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,6 +202,7 @@ const Plan = (() => {
|
||||
}
|
||||
|
||||
const LAST_SECOND = 8.64e12;
|
||||
const reachable = (t) => (t !== null && Number.isFinite(t) && Math.abs(t) <= LAST_SECOND ? t : null);
|
||||
|
||||
function leastSquares(pairs) {
|
||||
const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
@@ -233,11 +240,28 @@ const Plan = (() => {
|
||||
}
|
||||
const rest = lines.remaining;
|
||||
const reached = rest.slope < 0 ? rest.t0 - rest.y0 / rest.slope : null;
|
||||
return {start, lines, forecast: reached !== null && Number.isFinite(reached) && Math.abs(reached) <= LAST_SECOND ? reached : null};
|
||||
return {start, lines, forecast: reachable(reached)};
|
||||
}
|
||||
|
||||
const lineAt = (line, t) => line.y0 + line.slope * (t - line.t0);
|
||||
|
||||
/**
|
||||
* @param points - burn() points, in time order.
|
||||
* @param found - trend() of the points, or null.
|
||||
* @returns {fixed, growing}: the finish at fixed scope, where the done trend reaches the last scope (the last point
|
||||
* when nothing remains, never before it), and the finish at growing scope, where the done trend meets the scope
|
||||
* trend (trend()'s forecast); each null when never reached, both null without a trend.
|
||||
*/
|
||||
function finishes(points, found) {
|
||||
if (!found) {
|
||||
return {fixed: null, growing: null};
|
||||
}
|
||||
const last = points[points.length - 1];
|
||||
const done = found.lines.done;
|
||||
const fixed = last.remaining <= 0 ? last.t : done.slope > 0 ? Math.max(last.t, done.t0 + (last.scope - done.y0) / done.slope) : null;
|
||||
return {fixed: reachable(fixed), growing: found.forecast};
|
||||
}
|
||||
|
||||
const KEYS = {
|
||||
prio: () => null,
|
||||
wsjf: (row) => (row.wsjf ? row.wsjf.score : null),
|
||||
@@ -270,7 +294,7 @@ const Plan = (() => {
|
||||
return keyed.map((entry) => entry.row);
|
||||
}
|
||||
|
||||
return {FORECASTS, ESTIMATIONS, TRENDS, SORTS, SERIES, TRENDED, STATUS_ORDER, median, medians, forecastLength, bars, etas, scoped, burn, steps, trend, lineAt, sortRows};
|
||||
return {FORECASTS, ESTIMATIONS, TRENDS, SORTS, SERIES, TRENDED, STATUS_ORDER, STALE_DAYS, median, medians, forecastLength, bars, etas, stale, scoped, burn, steps, trend, finishes, lineAt, sortRows};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Plan;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
.kanban-box { display: grid; gap: 8px; min-width: 0; }
|
||||
.kanban-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.kanban-bar .pill { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.kanban { position: relative; display: grid; gap: 6px; min-width: 0; }
|
||||
.kanban-head, .kanban-cells { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||
.kanban-head .task-column { display: flex; align-items: center; gap: 6px; margin: 0; padding: 2px 6px; }
|
||||
.kanban-lane { display: grid; gap: 4px; min-width: 0; padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--glass-card); }
|
||||
.lane-toggle { display: inline-flex; align-items: center; gap: 6px; justify-self: start; padding: 2px 6px; border: 0; border-radius: 6px; background: none; color: var(--text); font: inherit; font-weight: 650; cursor: pointer; }
|
||||
.lane-toggle:hover { background: var(--hover); }
|
||||
.kanban-cells[hidden] { display: none; }
|
||||
.kanban-cell { align-content: start; min-height: 40px; min-width: 0; padding: 4px; border-radius: 8px; background: color-mix(in srgb, var(--sunken) 55%, transparent); }
|
||||
.kanban-card { cursor: pointer; }
|
||||
.kanban-card .head-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 0; }
|
||||
.kanban-card .task-id { flex: 1 1 100%; }
|
||||
.kanban-card.pending { border-style: dashed; border-color: var(--accent); }
|
||||
.kanban-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.kanban-title { overflow-wrap: anywhere; }
|
||||
.kanban-handle { display: inline-flex; color: var(--muted); cursor: grab; }
|
||||
.kanban-pending, .kanban-wait { display: inline-flex; color: var(--accent); }
|
||||
.kanban-stale { display: inline-flex; align-items: center; gap: 2px; }
|
||||
.kanban-stale .kanban-stop { min-width: 0; padding: 0 2px; }
|
||||
.kanban-menu > summary { display: inline-flex; list-style: none; cursor: pointer; }
|
||||
.kanban-menu > summary::-webkit-details-marker { display: none; }
|
||||
.kanban-menu .menu-box { display: grid; gap: 6px; min-width: 12rem; }
|
||||
.kanban-staged { display: flex; flex-wrap: wrap; gap: 4px; max-height: 6rem; margin: 0; padding: 0; overflow: auto; list-style: none; font-size: var(--fs-2xs); }
|
||||
.kanban-staged li { padding: 1px 6px; border: 1px dashed var(--accent); border-radius: 6px; overflow-wrap: anywhere; }
|
||||
.kanban-partly { display: inline-flex; align-items: center; gap: 2px; }
|
||||
.task-why { display: flex; gap: 4px; margin: 0; color: var(--bad); font-size: var(--fs-2xs); overflow-wrap: anywhere; }
|
||||
svg.kanban-lines { position: absolute; top: 0; left: 0; overflow: visible; pointer-events: none; }
|
||||
svg .kanban-link { fill: none; stroke: var(--violet); stroke-width: 1.8; }
|
||||
svg .kanban-hit { fill: none; stroke: transparent; stroke-width: 12; pointer-events: stroke; cursor: pointer; }
|
||||
svg .kanban-arrow.pending .kanban-link { opacity: .55; }
|
||||
svg .kanban-tip { fill: var(--violet); }
|
||||
svg .kanban-queue { fill: none; stroke: var(--warn); stroke-width: 1.6; stroke-dasharray: 6 4; }
|
||||
svg .burn-finish-line { fill: none; stroke: var(--c); stroke-width: 2; stroke-dasharray: 8 3; }
|
||||
svg .burn-finish-end { fill: var(--c); }
|
||||
svg.burn .k-fixed, .burn-key.k-fixed { --c: var(--violet); }
|
||||
svg.burn .k-growing, .burn-key.k-growing { --c: var(--teal); }
|
||||
Reference in new issue
Block a user