CI / pruefen (push) Canceled after 0s
Zwei Wege, die zusammengehoeren: etwas festhalten, ohne den Rechner
aufzuklappen, und danach einen Ort haben, an dem alles zusammenlaeuft.
**Schnellerfassung.** Ein Blitzsymbol in der Kopfleiste, auf jeder Seite
erreichbar. Ein Feld, Enter, fertig - als Idee entsteht ein Projekt im
Status IDEA, als Aufgabe eine Aufgabe im gewaehlten Projekt. Beide Schemas
kommen mit einem einzigen Feld aus, an der API war deshalb nichts zu
aendern.
Der Dialog bleibt nach dem Anlegen offen und leert nur das Feld. Wer eine
Idee notiert, hat meistens gleich die naechste; ihn jedes Mal neu zu oeffnen
waere genau die Huerde, die hier wegsoll. Das zuletzt gewaehlte Projekt
wird gemerkt.
Der Einstieg sitzt in der Kopfleiste und nicht als schwebender Knopf unten
rechts - dort waere er auf schmalen Anzeigen mit der Auswahlleiste
kollidiert, die beim Markieren von Projekten einfaehrt.
**Aufgabenuebersicht.** /tasks sammelt jede Aufgabe aus jedem Projekt.
Geordnet wird nach Faelligkeit, nicht nach Spalte: projektuebergreifend
lautet die Frage "was ist ueberfaellig", nicht "was steht wo im Brett".
Abhaken geht direkt aus der Liste; den Rest - Zeitpunkt, abgeleiteter
Fortschritt, naechste Fassung wiederkehrender Aufgaben - erledigt der
bestehende Endpunkt.
Zwei Entscheidungen, die im Code begruendet sind:
- Gerechnet wird auf Kalendertage in fester Zeitzone, nicht auf Stunden und
nicht nach Serverzeit. Eine um 9 Uhr faellige Aufgabe ist um 17 Uhr nicht
ueberfaellig, sondern immer noch "heute". Und im Container laeuft die
Anwendung nach UTC - ohne feste Zeitzone kippte die Einteilung abends um
einen Tag. Das Fach bestimmt der Server, sonst liefen Server-Render und
Hydration auseinander.
- Bewusst nicht per Ziehen sortierbar. Die Position gilt innerhalb ihrer
Spalte in ihrem Projekt und waere hier ohne Bedeutung; eine Reihenfolge
anzubieten, die nirgends ankommt, waere ein leeres Versprechen.
Die Aufgabe kennt keinen Besitzer - die Trennung laeuft ueber das Projekt,
"project: { ownerId }" steht als einzige und erste Bedingung.
Nebenbei: der Untertitel in der Kopfleiste brach mit dem dritten
Navigationspunkt auf 375px mitten im Wort ab und bleibt unterhalb von sm
jetzt weg.
Geprueft: 200 Unit-Tests (davon 7 neue zu dueBucket, inklusive des
UTC-Falls), 27 E2E-Tests mit zwei neuen Wegen, axe-core auf 375px ueber
Uebersicht und beide Zustaende der Schnellerfassung ohne Beanstandung.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
139 lines
5.5 KiB
TypeScript
139 lines
5.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
DUE_BUCKET_META,
|
||
DUE_BUCKET_ORDER,
|
||
TASK_STATUS_META,
|
||
TASK_STATUS_ORDER,
|
||
dueBucket,
|
||
nextDueDate,
|
||
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);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("nextDueDate", () => {
|
||
const abhaken = (iso: string) => new Date(iso);
|
||
|
||
it("rechnet vom bisherigen Termin, nicht vom Abhaken", () => {
|
||
// Woechentlich, faellig Montag, erledigt erst Mittwoch: der naechste
|
||
// Termin bleibt der Montag darauf, nicht der uebernaechste Mittwoch.
|
||
const next = nextDueDate("WEEKLY", new Date("2026-07-06T12:00:00Z"), abhaken("2026-07-08T09:00:00Z"));
|
||
expect(next.toISOString().slice(0, 10)).toBe("2026-07-13");
|
||
});
|
||
|
||
it("nimmt ohne bisherigen Termin den Zeitpunkt des Abhakens", () => {
|
||
const next = nextDueDate("DAILY", null, abhaken("2026-07-26T09:00:00Z"));
|
||
expect(next.toISOString().slice(0, 10)).toBe("2026-07-27");
|
||
});
|
||
|
||
it("schiebt so lange weiter, bis der Termin in der Zukunft liegt", () => {
|
||
// Ein halbes Jahr liegengeblieben: die naechste Fassung darf nicht
|
||
// schon bei ihrer Entstehung ueberfaellig sein.
|
||
const next = nextDueDate("WEEKLY", new Date("2026-01-05T12:00:00Z"), abhaken("2026-07-26T09:00:00Z"));
|
||
expect(next.getTime()).toBeGreaterThan(abhaken("2026-07-26T09:00:00Z").getTime());
|
||
// Und es bleibt ein Montag.
|
||
expect(next.getUTCDay()).toBe(1);
|
||
});
|
||
|
||
it("haelt beim Monatsintervall den Tag im Monat", () => {
|
||
const next = nextDueDate("MONTHLY", new Date("2026-03-15T12:00:00Z"), abhaken("2026-03-16T09:00:00Z"));
|
||
expect(next.toISOString().slice(0, 10)).toBe("2026-04-15");
|
||
});
|
||
|
||
it("rollt beim Monatsintervall nicht ueber kurze Monate hinweg", () => {
|
||
// Der 31. Januar plus ein Monat ist der 28. Februar, nicht der 3. Maerz.
|
||
const next = nextDueDate("MONTHLY", new Date("2026-01-31T12:00:00Z"), abhaken("2026-02-01T09:00:00Z"));
|
||
expect(next.toISOString().slice(0, 10)).toBe("2026-02-28");
|
||
});
|
||
|
||
it("kennt zwei Wochen", () => {
|
||
const next = nextDueDate("BIWEEKLY", new Date("2026-07-01T12:00:00Z"), abhaken("2026-07-02T09:00:00Z"));
|
||
expect(next.toISOString().slice(0, 10)).toBe("2026-07-15");
|
||
});
|
||
|
||
it("endet auch bei einem absurd alten Termin", () => {
|
||
// Ohne Deckel liefe die Schleife bei einem taeglichen Intervall und
|
||
// einem Datum von 1970 sehr lange.
|
||
const next = nextDueDate("DAILY", new Date("1970-01-01T00:00:00Z"), abhaken("2026-07-26T09:00:00Z"));
|
||
expect(next instanceof Date).toBe(true);
|
||
expect(Number.isNaN(next.getTime())).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("dueBucket", () => {
|
||
/** 14.08.2026, 17:30 Berliner Zeit – bewusst spaeter Nachmittag. */
|
||
const JETZT = new Date("2026-08-14T15:30:00Z");
|
||
|
||
it("ordnet eine Aufgabe ohne Termin nach hinten", () => {
|
||
expect(dueBucket(null, JETZT)).toBe("someday");
|
||
});
|
||
|
||
it("zaehlt den heutigen Tag als heute, auch wenn die Uhrzeit vorbei ist", () => {
|
||
// Der Punkt der Funktion: gerechnet wird auf Kalendertage. Um 9 Uhr
|
||
// faellig und jetzt ist es halb sechs – die Aufgabe steht trotzdem
|
||
// unter "Heute" und nicht unter "Ueberfaellig".
|
||
expect(dueBucket(new Date("2026-08-14T07:00:00Z"), JETZT)).toBe("today");
|
||
});
|
||
|
||
it("erkennt den Vortag als ueberfaellig", () => {
|
||
expect(dueBucket(new Date("2026-08-13T21:00:00Z"), JETZT)).toBe("overdue");
|
||
});
|
||
|
||
it("fasst die kommenden sieben Tage als diese Woche", () => {
|
||
expect(dueBucket(new Date("2026-08-15T06:00:00Z"), JETZT)).toBe("week");
|
||
expect(dueBucket(new Date("2026-08-21T06:00:00Z"), JETZT)).toBe("week");
|
||
});
|
||
|
||
it("schiebt alles danach nach spaeter", () => {
|
||
expect(dueBucket(new Date("2026-08-22T06:00:00Z"), JETZT)).toBe("later");
|
||
});
|
||
|
||
it("richtet sich nach Berliner Kalendertagen, nicht nach UTC", () => {
|
||
// 21:30 UTC ist in Berlin schon 23:30 desselben Tages – im Container
|
||
// laeuft die Anwendung aber nach UTC. Ohne feste Zeitzone kippte die
|
||
// Einteilung hier um einen Tag.
|
||
const spaetAbends = new Date("2026-08-15T21:30:00Z");
|
||
expect(dueBucket(new Date("2026-08-15T10:00:00Z"), spaetAbends)).toBe("today");
|
||
expect(dueBucket(new Date("2026-08-16T10:00:00Z"), spaetAbends)).toBe("week");
|
||
});
|
||
|
||
it("hat zu jedem Fach eine Beschriftung und eine Farbe", () => {
|
||
for (const bucket of DUE_BUCKET_ORDER) {
|
||
expect(DUE_BUCKET_META[bucket].label).toBeTruthy();
|
||
expect(DUE_BUCKET_META[bucket].color).toMatch(/^#[0-9a-f]{6}$/i);
|
||
}
|
||
});
|
||
});
|