Files
nebula/src/lib/tasks.ts
T
florian.wildeandClaude Opus 5 5ae1b6ed06
CI / pruefen (push) Canceled after 0s
feat(tasks): Schnellerfassung und Aufgaben ueber alle Projekte
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>
2026-08-14 23:33:16 +02:00

246 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Recurrence, TaskStatus } from "@prisma/client";
import { prisma } from "@/lib/db";
/** Spaltenreihenfolge des Aufgabenbretts. */
export const TASK_STATUS_ORDER: TaskStatus[] = ["TODO", "DOING", "BLOCKED", "DONE"];
export const TASK_STATUS_META: Record<
TaskStatus,
{ label: string; color: string; className: string }
> = {
TODO: {
label: "Offen",
color: "#94a3b8",
className: "bg-slate-500/12 text-slate-300 ring-slate-400/25",
},
DOING: {
label: "In Arbeit",
color: "#a78bfa",
className: "bg-violet-500/12 text-violet-200 ring-violet-400/30",
},
BLOCKED: {
label: "Blockiert",
color: "#fb7185",
className: "bg-rose-500/12 text-rose-200 ring-rose-400/30",
},
DONE: {
label: "Erledigt",
color: "#34d399",
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
},
};
/**
* Berechnet den Fortschritt aus dem Verhaeltnis erledigter Aufgaben.
* Ohne Aufgaben gibt es nichts zu berechnen – dann bleibt der Wert, wie er ist.
*/
export function progressFromTasks(total: number, done: number): number | null {
if (total <= 0) return null;
return Math.round((done / total) * 100);
}
/**
* Schreibt den abgeleiteten Fortschritt, falls das Projekt es so eingestellt
* hat. Wird nach jeder Aenderung an Aufgaben aufgerufen.
*/
export async function refreshDerivedProgress(projectId: string): Promise<void> {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { progressFromTasks: true },
});
if (!project?.progressFromTasks) return;
const [total, done] = await Promise.all([
prisma.task.count({ where: { projectId } }),
prisma.task.count({ where: { projectId, status: "DONE" } }),
]);
const value = progressFromTasks(total, done);
if (value === null) return;
await prisma.project.update({
where: { id: projectId },
data: { progress: value },
});
}
// ── Wiederkehrende Aufgaben ──────────────────────────────────
export const RECURRENCE_META: Record<Recurrence, { label: string; short: string }> = {
DAILY: { label: "Täglich", short: "1 T" },
WEEKLY: { label: "Wöchentlich", short: "1 W" },
BIWEEKLY: { label: "Alle zwei Wochen", short: "2 W" },
MONTHLY: { label: "Monatlich", short: "1 M" },
};
/** Einen Termin um ein Intervall weiterschieben. */
function addInterval(from: Date, recurrence: Recurrence): Date {
const next = new Date(from);
switch (recurrence) {
case "DAILY":
next.setDate(next.getDate() + 1);
break;
case "WEEKLY":
next.setDate(next.getDate() + 7);
break;
case "BIWEEKLY":
next.setDate(next.getDate() + 14);
break;
case "MONTHLY": {
// setMonth rollt ueber: der 31. Januar wuerde zum 3. Maerz. Bei einem
// kuerzeren Folgemonat gehoert der Termin auf dessen letzten Tag.
const tag = next.getDate();
next.setDate(1);
next.setMonth(next.getMonth() + 1);
const letzterTag = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
next.setDate(Math.min(tag, letzterTag));
break;
}
}
return next;
}
/**
* Wann die naechste Fassung faellig ist.
*
* Gerechnet wird vom bisherigen Termin, nicht vom Zeitpunkt des Abhakens:
* eine woechentliche Aufgabe soll auf ihrem Montag bleiben, auch wenn sie
* einmal erst am Mittwoch erledigt wurde.
*
* Wurde sie lange liegengelassen, laege der naechste Termin allerdings
* immer noch in der Vergangenheit – dann wird so lange weitergeschoben, bis
* er in der Zukunft liegt. Sonst entstuende eine Aufgabe, die schon bei
* ihrer Entstehung ueberfaellig ist.
*
* Ohne bisherigen Termin zaehlt der Zeitpunkt des Abhakens.
*/
export function nextDueDate(
recurrence: Recurrence,
previousDue: Date | null,
completedAt: Date = new Date(),
): Date {
if (!previousDue) return addInterval(completedAt, recurrence);
let next = addInterval(previousDue, recurrence);
// Deckel gegen eine Endlosschleife bei absurden Datumsangaben.
for (let schritt = 0; schritt < 500 && next <= completedAt; schritt += 1) {
next = addInterval(next, recurrence);
}
return next;
}
/**
* Legt die naechste Fassung einer wiederkehrenden Aufgabe an.
*
* Wird genau beim Uebergang nach ERLEDIGT aufgerufen – aus dem Dialog wie
* aus dem Ziehen im Brett. Ein erneutes Speichern einer bereits erledigten
* Aufgabe darf keine zweite Fassung erzeugen.
*
* Gibt die neue Aufgabe zurueck oder null, wenn nichts zu tun war.
*/
export async function spawnRecurrence(taskId: string) {
const erledigt = await prisma.task.findUnique({ where: { id: taskId } });
if (!erledigt?.recurrence) return null;
const faellig = nextDueDate(erledigt.recurrence, erledigt.dueDate, erledigt.doneAt ?? new Date());
return prisma.task.create({
data: {
projectId: erledigt.projectId,
title: erledigt.title,
description: erledigt.description,
status: "TODO",
position: await nextPosition(erledigt.projectId, "TODO"),
dueDate: faellig,
labels: erledigt.labels,
recurrence: erledigt.recurrence,
recurredFrom: erledigt.id,
// Die Herkunft aus einem Issue wandert nicht mit: das Issue ist
// abgearbeitet, und die Eindeutigkeit je Repository verboete es ohnehin.
},
});
}
// ── Faelligkeit in der Gesamtuebersicht ──────────────────────
/**
* Faecher der Aufgabenuebersicht. Anders als im Projektbrett ordnet dort
* nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist
* ueberfaellig" die Frage, nicht "was steht in welcher Spalte".
*/
export const DUE_BUCKET_ORDER = ["overdue", "today", "week", "later", "someday"] as const;
export type DueBucket = (typeof DUE_BUCKET_ORDER)[number];
export const DUE_BUCKET_META: Record<DueBucket, { label: string; color: string }> = {
overdue: { label: "Überfällig", color: "#fb7185" },
today: { label: "Heute", color: "#fbbf24" },
week: { label: "Diese Woche", color: "#a78bfa" },
later: { label: "Später", color: "#38bdf8" },
someday: { label: "Ohne Termin", color: "#8a98b0" },
};
/**
* Kalendertag in fester Zeitzone, als fortlaufende Tagesnummer.
*
* Die Serverzeit taugt dafuer nicht: im Container laeuft die Anwendung nach
* UTC. Um 23:30 Berliner Zeit ist dort noch der Vortag, und eine heute
* faellige Aufgabe stuende unter "Ueberfaellig". Dieselbe Zeitzone benutzt
* auch die Datumsausgabe in lib/utils.
*/
const TAG_FORMAT = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
function tagesnummer(wert: Date): number {
// en-CA liefert "2026-08-14" – und damit etwas, das sich ohne weiteres
// Zerlegen sortieren und rechnen laesst.
const [jahr, monat, tag] = TAG_FORMAT.format(wert).split("-").map(Number);
return Date.UTC(jahr, monat - 1, tag) / 86_400_000;
}
/**
* In welches Fach eine Aufgabe faellt.
*
* Gerechnet wird auf Kalendertage, nicht auf Stunden: eine Aufgabe, die
* heute um 9 Uhr faellig war, ist um 17 Uhr nicht "ueberfaellig", sondern
* immer noch "heute". Sonst wanderte die halbe Liste im Lauf des Tages
* nach oben.
*
* Bewusst als reine Funktion mit uebergebenem "jetzt" – das macht sie
* pruefbar.
*/
export function dueBucket(dueDate: Date | null, now: Date = new Date()): DueBucket {
if (!dueDate) return "someday";
const abstand = tagesnummer(dueDate) - tagesnummer(now);
if (abstand < 0) return "overdue";
if (abstand === 0) return "today";
if (abstand <= 7) return "week";
return "later";
}
/** Naechste freie Position am Ende einer Spalte. */
export async function nextPosition(
projectId: string,
status: TaskStatus,
): Promise<number> {
const last = await prisma.task.findFirst({
where: { projectId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
/** Prueft Existenz und Besitz in einem Zug. */
export async function ownedProject(projectId: string, ownerId: string) {
return prisma.project.findFirst({
where: { id: projectId, ownerId },
select: { id: true },
});
}