feat(tasks): Aufgaben je Projekt mit Spalten und Reihenfolge

Neues Modell Task: Titel, optionale Beschreibung, Spalte, Position,
Faelligkeit und Labels. Vier Spalten - offen, in Arbeit, blockiert,
erledigt.

Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte hier
bedeutsam und wird deshalb gespeichert. Der Reorder-Endpunkt nimmt je Spalte
die vollstaendige Liste in ihrer neuen Reihenfolge entgegen und leitet Status
und Position daraus ab. Das ist wiederholbar, kommt ohne Positionsrechnerei
im Browser aus, und ein verlorener Aufruf laesst sich einfach erneut senden.

Sicherheitsrelevant: der Endpunkt bekommt IDs aus dem Browser. Er
aktualisiert ausschliesslich Aufgaben, die wirklich zu diesem Projekt
gehoeren, und meldet uebergangene IDs zurueck - sonst liessen sich fremde
Aufgaben ueber untergeschobene IDs verschieben.

doneAt wird nur beim tatsaechlichen Wechsel nach "erledigt" gesetzt und beim
Zurueckziehen geleert. Ein Umsortieren innerhalb der Spalte laesst das Datum
in Ruhe.

Der Fortschritt eines Projekts laesst sich wahlweise aus dem Anteil
erledigter Aufgaben ableiten. Er wird nach jeder Aenderung neu berechnet und
auch sofort beim Einschalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 09:29:28 +02:00
co-authored by Claude Opus 5
parent 298da72b6d
commit 72bcf824ce
8 changed files with 461 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { TASK_STATUS_META, TASK_STATUS_ORDER, progressFromTasks } from "./tasks";
describe("progressFromTasks", () => {
it("rechnet den Anteil erledigter Aufgaben in Prozent um", () => {
expect(progressFromTasks(4, 1)).toBe(25);
expect(progressFromTasks(8, 3)).toBe(38);
expect(progressFromTasks(3, 3)).toBe(100);
expect(progressFromTasks(5, 0)).toBe(0);
});
it("gibt null zurueck, wenn es nichts zu rechnen gibt", () => {
// Ohne Aufgaben bleibt der von Hand gesetzte Wert stehen.
expect(progressFromTasks(0, 0)).toBeNull();
expect(progressFromTasks(-1, 0)).toBeNull();
});
it("rundet kaufmaennisch statt abzuschneiden", () => {
// 1/3 sind 33,33 % und 2/3 sind 66,67 %.
expect(progressFromTasks(3, 1)).toBe(33);
expect(progressFromTasks(3, 2)).toBe(67);
});
});
describe("Spaltenreihenfolge", () => {
it("fuehrt von offen nach erledigt", () => {
expect(TASK_STATUS_ORDER).toEqual(["TODO", "DOING", "BLOCKED", "DONE"]);
});
it("hat zu jeder Spalte eine Beschriftung und eine Farbe", () => {
for (const status of TASK_STATUS_ORDER) {
expect(TASK_STATUS_META[status].label).toBeTruthy();
expect(TASK_STATUS_META[status].color).toMatch(/^#[0-9a-f]{6}$/i);
}
});
});