Fuellarbeit: zeitlich flexible Arbeit fuellt ungenutztes Budget (#717, Stufe 1)

Eine einmalige Aufgabe mit fill_by (spaetestens bis) statt Termin laeuft,
wenn der Tag Budget uebrig laesst: nach regulaerer und nachgeholter Arbeit,
nur wenn nichts vom Agenten wartet, bevorzugt ab 18 Uhr, und nur wenn ihre
eigene Schaetzung in den Spielraum aus #716 passt. Kurz vor der Frist wird
einmal ein Mensch gefragt; nach der Frist laeuft sie als regulaere Arbeit.

Ehrliche Schaetzung (TaskCostEstimator): eigene Laeufe, dann Geschwister
unter derselben Elternaufgabe, dann die Angabe des Autors (als Schaetzung
gekennzeichnet), zuletzt das Mittel des Agenten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 15:34:15 +02:00
co-authored by Claude Opus 5.5
parent 25f74d9ad6
commit 183e922278
21 changed files with 1185 additions and 36 deletions
@@ -363,6 +363,12 @@
"tasks.origin": "Herkunft",
"tasks.originTask": "bei der Arbeit an {id}",
"tasks.originRun": "Lauf öffnen",
"tasks.fillWork": "Füllarbeit: läuft, wenn das Budget Platz hat, spätestens bis {time}",
"tasks.fillAlerted": "bisher kein Platz gefunden, Mensch gefragt am {time}",
"tasks.estimate": "Schätzung des Auftraggebers: {usd} je Lauf (nicht gemessen)",
"tasks.fillBy": "Füllarbeit, spätestens bis",
"tasks.fillByHint": "Leer lassen für reguläre Arbeit. Füllarbeit läuft auf Budget, das der Tag übrig lässt; ein Datum meint das Ende dieses Tages.",
"tasks.estimateUsd": "Schätzung je Lauf (USD)",
"services.title": "Dienste",
"services.newService": "Neuer Dienst",
@@ -363,6 +363,12 @@
"tasks.origin": "Arose from",
"tasks.originTask": "while working on {id}",
"tasks.originRun": "open run",
"tasks.fillWork": "Fill work: runs when the budget has room, at the latest by {time}",
"tasks.fillAlerted": "no room found so far, a human was asked on {time}",
"tasks.estimate": "Author's estimate: {usd} per run (not measured)",
"tasks.fillBy": "Fill work, at the latest by",
"tasks.fillByHint": "Leave empty for regular work. Fill work runs on budget the day leaves unused; a date means the end of that day.",
"tasks.estimateUsd": "Estimate per run (USD)",
"services.title": "Services",
"services.newService": "New Service",
@@ -174,6 +174,14 @@ function brettAuswahl(tasks) {
erstelleElement("span", { text: t("tasks.board") }), auswahl);
}
/**
* #717: The date for the input field. A date is stored as the end of that day (the next
* midnight, UTC), so one millisecond earlier is the day that was meant.
*/
function fuellDatum(iso) {
return new Date(new Date(iso).getTime() - 1).toISOString().slice(0, 10);
}
function terminText(task) {
if (!task.when) return t("tasks.scheduleNone");
const art = task.when.kind;
@@ -233,6 +241,19 @@ function erstelleAufgabenKarte(task) {
zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${task.hinweis}` }));
}
// #717: Fill work waits for room, not for a time — said on the card, or it looks forgotten.
if (task.fillBy) {
const teile = [t("tasks.fillWork", { time: formatiereDatumZeit(task.fillBy) })];
if (task.fillAlertedAt) teile.push(t("tasks.fillAlerted", { time: formatiereDatumZeit(task.fillAlertedAt) }));
zeilen.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: `◔ ${teile.join(" · ")}` }));
}
if (task.estimateUsd != null) {
zeilen.push(erstelleElement("p", {
klasse: "gedaempft text-klein",
text: t("tasks.estimate", { usd: formatiereKosten(task.estimateUsd) })
}));
}
// #716: What the budget refused, and what happens next.
const ausfall = task.budgetMiss;
if (ausfall) {
@@ -360,6 +381,17 @@ async function oeffneAufgabenDialog(task) {
// #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-…" });
// #717: Fill work — a deadline instead of an appointment — and the author's estimate.
const fuellBis = erstelleElement("input", {
type: "date",
value: task?.fillBy ? fuellDatum(task.fillBy) : "",
title: t("tasks.fillByHint")
});
const schaetzung = erstelleElement("input", {
type: "number", min: "0", step: "0.01",
value: task?.estimateUsd != null ? String(task.estimateUsd) : ""
});
const hinweis = erstelleElement("p", { klasse: "gedaempft text-klein" });
const aktualisiere = () => {
@@ -371,6 +403,9 @@ async function oeffneAufgabenDialog(task) {
const ohneTermin = terminArt.value === "";
terminWert.disabled = ohneTermin;
zeitzone.disabled = ohneTermin;
// Fill work is one-time; a recurring schedule and a deadline contradict each other.
fuellBis.disabled = wiederkehrend;
if (wiederkehrend) fuellBis.value = "";
terminWert.placeholder =
{ cron: "e.g. 0 7-22/3 * * *", every: "e.g. 30m, 2h, 1d", at: "e.g. 2026-09-15T08:00" }[terminArt.value] || "";
};
@@ -387,6 +422,8 @@ async function oeffneAufgabenDialog(task) {
erstelleElement("label", { text: "Timezone:" }), zeitzone,
erstelleElement("label", { text: "Acceptance Criteria:" }), abnahme,
erstelleElement("label", { text: t("tasks.parent") + ":" }), eltern,
erstelleElement("label", { text: t("tasks.fillBy") + ":", title: t("tasks.fillByHint") }), fuellBis,
erstelleElement("label", { text: t("tasks.estimateUsd") + ":" }), schaetzung,
erstelleElement("span"), hinweis
);
@@ -418,7 +455,9 @@ async function oeffneAufgabenDialog(task) {
timeZone: terminArt.value ? zeitzone.value.trim() : null,
acceptance: abnahme.value.trim(),
priority: task?.priority ?? 3,
parentId: eltern.value.trim()
parentId: eltern.value.trim(),
fillBy: fuellBis.value || null,
estimateUsd: schaetzung.value === "" ? null : Number(schaetzung.value)
};
try {