Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
T
RichardandClaude Opus 5.5 b22272345e Zielkette, Stufe 1: jede Aufgabe kann sagen, wofuer sie da ist (#714)
- TaskItem.ParentId, in der Datei als parent, in der DB als ParentId. Die
  Elternaufgabe ist der Zweck ("wofuer"), blocked_by die Voraussetzung
  ("wann").
- GoalChain: Eltern bis zu acht Stufen, Zyklen brechen den Gang ab und
  werden beim Setzen abgelehnt. Oben steht das Ziel der Einheit - der
  obersten Aufgabe, die zu einer Einheit gehoert (ein Ziel, das ein Mensch
  auf der obersten Ebene setzt, gehoert keiner), sonst der naechsten Einheit
  darueber mit Ziel.
- Der Lauf einer Aufgabe bekommt den Absatz "Why this task exists" und die
  Bitte, zu melden, wenn die Aufgabe ihrem Zweck nicht mehr dient.
- Taskboard-Tool: parent bei task_create/task_update, geprueft auf Existenz,
  Sichtbarkeit (#684) und Zyklen; task_list zeigt "Serves".
- WebUI: Feld im Aufgabendialog, Anzeige auf der Karte; die Verwaltung
  prueft wie das Tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:45:15 +02:00

428 lines
15 KiB
JavaScript

// ClawdDotNet — Aufgaben (Taskboard)
//
// Central taskboard view for scheduled and manual tasks.
//
// Since #594 the only place for scheduled work. The former tab "Scheduled runs (Jobs)"
// edited the legacy config scheduler/toolJobs; after the first migration into the taskboard
// no change made there had any effect. Schedule, next and last run, pause and run-now
// now live on the task itself.
import { api } from "../modules/api.js";
import { t } from "../modules/i18n.js";
import {
erstelleElement,
erstelleIcon,
PFADE,
erstellePlakette,
erstelleKarte,
erstelleModal,
formatiereDatumZeit,
formatiereKosten,
zeigeMeldung
} from "../modules/dom.js";
function holeStatusText(status) {
switch (status) {
case "Backlog": return t("status.paused");
case "Todo": return t("status.ready");
case "InProgress": return t("status.running");
case "InReview": return t("status.inReview");
case "Done": return t("status.done");
case "Canceled": return t("status.canceled");
case "Blocked": return t("status.blocked");
case "Archived": return t("status.archived");
default: return status || t("common.unknown");
}
}
const STATUS_ART = {
Backlog: "warnung",
Todo: "neutral",
InProgress: "laufend",
Done: "erfolg",
Blocked: "warnung"
};
const LAUF_ART = {
Completed: "erfolg",
Truncated: "warnung",
Incomplete: "warnung",
BudgetExceeded: "warnung",
EmptyResponse: "gefahr",
RepeatedFailure: "gefahr",
LoopLimitExceeded: "gefahr",
Failed: "gefahr"
};
const ABGESCHLOSSEN = ["Done", "Canceled", "Archived"];
let zeigeAbgeschlossene = false;
/** #684: The board shown — "" for all, "-" for the top level, otherwise a unit id. */
let brettFilter = "";
export async function rendereAufgaben() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
container.replaceChildren();
const layout = erstelleElement("div", { klasse: "aufgaben-ansicht" });
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
const abgeschlossenSchalter = erstelleElement(
"label",
{ klasse: "gruppe-horizontal text-klein" },
erstelleElement("input", {
type: "checkbox",
checked: zeigeAbgeschlossene,
onchange: (e) => {
zeigeAbgeschlossene = e.target.checked;
ladeTaskboard();
}
}),
erstelleElement("span", { text: t("tasks.showCompleted") })
);
const neuladen = erstelleElement(
"button",
{ klasse: "schaltflaeche flach", title: t("common.refresh"), onclick: () => ladeTaskboard() },
erstelleIcon(PFADE.aktualisieren, 16)
);
const neu = erstelleElement(
"button",
{ klasse: "schaltflaeche primaer", onclick: () => oeffneAufgabenDialog(null) },
erstelleIcon(PFADE.plus, 16),
erstelleElement("span", { text: " " + t("tasks.newTask") })
);
kopf.append(
erstelleElement("h1", { text: t("tasks.title") }),
erstelleElement("div", { klasse: "gruppe-horizontal" }, abgeschlossenSchalter, neuladen, neu)
);
layout.append(kopf, erstelleElement("div", { id: "aufgaben-bereich-inhalt" }));
container.append(layout);
await ladeTaskboard();
}
async function ladeTaskboard() {
const container = document.getElementById("aufgaben-bereich-inhalt");
if (!container) return;
container.replaceChildren(
erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") })
);
try {
const alle = (await api("/tasks?limit=200")) || [];
const offen = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
const tasks = offen.filter((t) => !brettFilter || (brettFilter === "-" ? !t.board : t.board === brettFilter));
container.replaceChildren();
// #684: With units, every task is on a unit's board — pick one to see only that.
const bretter = brettAuswahl(alle);
if (bretter) container.append(bretter);
if (tasks.length === 0) {
container.append(
erstelleKarte(t("tasks.empty"), [
erstelleElement("p", {
klasse: "gedaempft",
text: "..."
})
])
);
return;
}
const raster = erstelleElement("div", { klasse: "taskboard-raster" });
for (const task of tasks) raster.append(erstelleAufgabenKarte(task));
container.append(raster);
} catch (err) {
container.replaceChildren(
erstelleKarte("Taskboard Error", [
erstelleElement("p", { klasse: "gedaempft", text: err.message })
])
);
}
}
/**
* #684: The boards the tasks are on, as a filter. Built from the tasks themselves, so it
* shows only boards that hold something; absent when no task is on a unit's board.
*/
function brettAuswahl(tasks) {
const bretter = new Map();
for (const task of tasks) if (task.board) bretter.set(task.board, task.boardPath || task.board);
if (bretter.size === 0) return null;
const auswahl = erstelleElement("select", {
"aria-label": t("tasks.board"),
onchange: (e) => { brettFilter = e.target.value; ladeTaskboard(); }
},
erstelleElement("option", { value: "", text: t("tasks.boardAll") }),
erstelleElement("option", { value: "-", text: t("tasks.boardTopLevel") }),
...[...bretter.entries()]
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([id, pfad]) => erstelleElement("option", { value: id, text: pfad })));
auswahl.value = brettFilter;
return erstelleElement("label", { klasse: "gruppe-horizontal text-klein taskboard-bretter" },
erstelleElement("span", { text: t("tasks.board") }), auswahl);
}
function terminText(task) {
if (!task.when) return t("tasks.scheduleNone");
const art = task.when.kind;
return `${art} ${task.when.value} (${task.when.timeZone || "UTC"})`;
}
function erstelleAufgabenKarte(task) {
const zeilen = [];
zeilen.push(
erstelleElement(
"div",
{ klasse: "task-status-zeile gruppe-horizontal" },
erstellePlakette(holeStatusText(task.status), STATUS_ART[task.status] || "neutral"),
erstelleElement("span", {
klasse: "gedaempft text-klein",
text: `${task.id} · ${task.assignee || "—"}${task.boardPath ? ` · ▤ ${task.boardPath}` : ""}${task.parentId ? ` · ↑ ${t("tasks.serves", { id: task.parentId })}` : ""}`
})
)
);
zeilen.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: `${t("tasks.schedule")}: ${terminText(task)}` }));
zeilen.push(
erstelleElement("p", {
klasse: "gedaempft text-klein",
text: `${t("tasks.nextRun")}: ${task.nextRunUtc ? formatiereDatumZeit(task.nextRunUtc) : "—"}`
})
);
const letzter = erstelleElement("p", { klasse: "gedaempft text-klein" }, `${t("tasks.lastRun")}: `);
if (task.lastRun) {
letzter.append(
`${formatiereDatumZeit(task.lastRun.occurredAt)} `,
erstellePlakette(task.lastRun.status, LAUF_ART[task.lastRun.status] || "neutral"),
` ${task.lastRun.costIsKnown ? formatiereKosten(task.lastRun.costUsd) : "—"}`
);
if (task.lastRun.resultRef) letzter.title = task.lastRun.resultRef;
} else {
letzter.append("—");
}
zeilen.push(letzter);
if (task.hinweis) {
zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${task.hinweis}` }));
}
// #716: What the budget refused, and what happens next.
const ausfall = task.budgetMiss;
if (ausfall) {
const teile = [t("tasks.budgetMissed", { n: ausfall.count })];
if (ausfall.lastAt) teile.push(t("tasks.budgetMissedLast", { time: formatiereDatumZeit(ausfall.lastAt) }));
if (ausfall.carriedOver && ausfall.deferredUntil) {
teile.push(t("tasks.budgetCarriedOver", { time: formatiereDatumZeit(ausfall.deferredUntil) }));
} else if (ausfall.catchUpPending) {
teile.push(t("tasks.budgetCatchUp", { time: ausfall.deferredUntil ? formatiereDatumZeit(ausfall.deferredUntil) : "—" }));
}
zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${teile.join(" · ")}` }));
}
const aktionen = erstelleElement("div", {
klasse: "gruppe-horizontal",
stil: "margin-top: 8px; justify-content: flex-end;"
});
if (task.assignee !== "@human" && task.status === "Todo") {
aktionen.append(
erstelleElement(
"button",
{ klasse: "schaltflaeche flach klein", title: t("tasks.runNow"), onclick: () => fuehreAus(task) },
erstelleIcon(PFADE.abspielen, 14),
erstelleElement("span", { text: " " + t("tasks.runNow") })
)
);
}
if (task.status === "Todo") {
aktionen.append(
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: t("tasks.pause"),
onclick: () => setzeAktiv(task, false)
})
);
} else if (["Backlog", "Done", "Canceled"].includes(task.status)) {
aktionen.append(
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: t("tasks.resume"),
onclick: () => setzeAktiv(task, true)
})
);
}
aktionen.append(
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: t("common.edit"),
onclick: () => oeffneAufgabenDialog(task)
})
);
zeilen.push(aktionen);
return erstelleKarte(task.title || `Task ${task.id}`, zeilen);
}
async function fuehreAus(task) {
try {
const res = await api(`/tasks/${encodeURIComponent(task.id)}/run`, { method: "POST" });
zeigeMeldung(res?.message || t("tasks.runTriggered"), "erfolg");
setTimeout(ladeTaskboard, 1500);
} catch (err) {
zeigeMeldung(`Error: ${err.message}`, "gefahr");
}
}
async function setzeAktiv(task, aktiv) {
try {
const res = await api(`/tasks/${encodeURIComponent(task.id)}/enabled?enabled=${aktiv}`, { method: "PUT" });
zeigeMeldung(res?.message || (aktiv ? t("tasks.resume") : t("tasks.pause")), "info");
await ladeTaskboard();
} catch (err) {
zeigeMeldung(`Error: ${err.message}`, "gefahr");
}
}
async function oeffneAufgabenDialog(task) {
let agenten = [];
try {
agenten = (await api("/agents")) || [];
} catch {
//
}
const formular = erstelleElement("form", { klasse: "formular-raster" });
const titel = erstelleElement("input", { type: "text", required: true, value: task?.title || "" });
const beschreibung = erstelleElement("textarea", { rows: "5", placeholder: "..." });
beschreibung.value = task?.body || "";
const zuordnung = erstelleElement("select", { required: true });
for (const a of agenten) {
zuordnung.append(
erstelleElement("option", { value: `@new:${a.agentId}`, text: `${a.displayName} (New Run)` }),
erstelleElement("option", { value: `@${a.agentId}`, text: `${a.displayName} (Chat History)` })
);
}
zuordnung.append(erstelleElement("option", { value: "@human", text: "Human (No Agent)" }));
if (task?.assignee && ![...zuordnung.options].some((o) => o.value === task.assignee)) {
zuordnung.append(erstelleElement("option", { value: task.assignee, text: task.assignee }));
}
zuordnung.value = task?.assignee || (agenten[0] ? `@new:${agenten[0].agentId}` : "@human");
const terminArt = erstelleElement("select");
terminArt.append(
erstelleElement("option", { value: "", text: t("tasks.scheduleNone") }),
erstelleElement("option", { value: "cron", text: "Cron" }),
erstelleElement("option", { value: "every", text: t("tasks.scheduleInterval") }),
erstelleElement("option", { value: "at", text: t("tasks.scheduleAt") })
);
terminArt.value = task?.when?.kind || "";
const terminWert = erstelleElement("input", { type: "text", value: task?.when?.value || "" });
const standardZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/Berlin";
const zeitzone = erstelleElement("input", { type: "text", value: task?.when?.timeZone || standardZone });
const abnahme = erstelleElement("textarea", { rows: "2", placeholder: "..." });
abnahme.value = task?.acceptance || "";
// #714: The task this one serves — the run is told the chain up to the unit's goal.
const eltern = erstelleElement("input", { type: "text", value: task?.parentId || "", placeholder: "t-…" });
const hinweis = erstelleElement("p", { klasse: "gedaempft text-klein" });
const aktualisiere = () => {
const wiederkehrend = terminArt.value === "cron" || terminArt.value === "every";
const imVerlauf = zuordnung.value.startsWith("@") && !zuordnung.value.startsWith("@new") && zuordnung.value !== "@human";
hinweis.hidden = !(wiederkehrend && imVerlauf);
hinweis.textContent = "⚠ Notice: Running recurring tasks in chat history increases context and costs.";
const ohneTermin = terminArt.value === "";
terminWert.disabled = ohneTermin;
zeitzone.disabled = ohneTermin;
terminWert.placeholder =
{ cron: "e.g. 0 7-22/3 * * *", every: "e.g. 30m, 2h, 1d", at: "e.g. 2026-09-15T08:00" }[terminArt.value] || "";
};
zuordnung.addEventListener("change", aktualisiere);
terminArt.addEventListener("change", aktualisiere);
aktualisiere();
formular.append(
erstelleElement("label", { text: t("common.name") + ":" }), titel,
erstelleElement("label", { text: t("tasks.taskMessage") + ":" }), beschreibung,
erstelleElement("label", { text: t("tasks.agent") + ":" }), zuordnung,
erstelleElement("label", { text: t("tasks.scheduleType") + ":" }), terminArt,
erstelleElement("label", { text: "Value:" }), terminWert,
erstelleElement("label", { text: "Timezone:" }), zeitzone,
erstelleElement("label", { text: "Acceptance Criteria:" }), abnahme,
erstelleElement("label", { text: t("tasks.parent") + ":" }), eltern,
erstelleElement("span"), hinweis
);
const abbrechen = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach",
text: t("common.cancel"),
onclick: () => modal.schliessen()
});
const speichern = erstelleElement("button", {
type: "submit",
klasse: "schaltflaeche primaer",
text: task ? t("common.save") : t("tasks.newTask")
});
const modal = erstelleModal(task ? t("common.edit") : t("tasks.createTitle"), formular, [abbrechen, speichern]);
formular.addEventListener("submit", async (e) => {
e.preventDefault();
speichern.disabled = true;
const body = {
title: titel.value.trim(),
body: beschreibung.value.trim(),
assignee: zuordnung.value,
whenKind: terminArt.value || null,
whenValue: terminArt.value ? terminWert.value.trim() : null,
timeZone: terminArt.value ? zeitzone.value.trim() : null,
acceptance: abnahme.value.trim(),
priority: task?.priority ?? 3,
parentId: eltern.value.trim()
};
try {
const res = await api(task ? `/tasks/${encodeURIComponent(task.id)}` : "/tasks", {
method: task ? "PUT" : "POST",
body
});
modal.schliessen();
const text = res?.message || t("tasks.saved");
zeigeMeldung(text, "erfolg");
await ladeTaskboard();
} catch (err) {
zeigeMeldung(`Error: ${err.message}`, "gefahr");
} finally {
speichern.disabled = false;
}
});
modal.oeffnen();
}