CI / pruefen (pull_request) Canceled after 0s
Ein Projekt mit vierzig offenen Aufgaben macht das Brett unbrauchbar: die Spalte laeuft ueber die halbe Seite, und die Bloecke darunter - Notizen, Repository, Verlauf - rutschen aus dem Blick. Die Grenze steht in den Admin-Einstellungen und gilt fuer alle Projekte, wie autoSyncBatchSize und staleAfterDays. Kein Feld am Projekt: das haette eine Migration am Project-Modell gekostet und eine Einstellung, die man vierzig Mal pflegt. 0 heisst "ohne Begrenzung", dieselbe Lesart wie bei SESSION_IDLE_HOURS. Begrenzt wird ausschliesslich die Anzeige. Das ist der Kern: persist() schickt je Spalte die vollstaendige Liste an /tasks/reorder, und eine dort schon gekuerzte Liste wuerde beim ersten Ziehen die verborgenen Aufgaben aus dem Brett loeschen. Der Zustand im Brett bleibt deshalb ganz, gekuerzt wird erst beim Rendern der Spalte. Gegengeprobt am echten PATCH-Rumpf: 40 IDs bei 15 sichtbaren Karten. Die Zahl neben der Spaltenueberschrift nennt weiter den vollen Bestand. Nur die sichtbaren Karten stehen im SortableContext - was nicht im DOM ist, kann dnd-kit ohnehin nicht treffen. Die Admin-Sektion zeigt neben der Einstellung die laengste Spalte im Bestand. Ohne die steht die Zahl im Leeren: eine Grenze von 15 sagt nichts, wenn die groesste Spalte aus drei Karten besteht. Refs #16
269 lines
8.8 KiB
TypeScript
269 lines
8.8 KiB
TypeScript
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".
|
||
*/
|
||
/**
|
||
* Wie viele Karten eine Spalte des Aufgabenbretts zeigt – und wie viele
|
||
* dahinter warten.
|
||
*
|
||
* Begrenzt wird ausschliesslich die *Anzeige*. Der Zustand im Brett bleibt
|
||
* vollstaendig, weil das Umsortieren je Spalte die ganze Liste an den Server
|
||
* schickt: waere sie hier schon gekuerzt, wuerden die verborgenen Aufgaben
|
||
* beim naechsten Ziehen aus dem Brett fallen.
|
||
*
|
||
* `limit` 0 heisst "ohne Begrenzung" – dieselbe Lesart wie bei
|
||
* SESSION_IDLE_HOURS.
|
||
*/
|
||
export function spaltenAnzeige(
|
||
anzahl: number,
|
||
limit: number,
|
||
entfaltet: boolean,
|
||
): { sichtbar: number; verborgen: number } {
|
||
if (limit <= 0 || entfaltet || anzahl <= limit) {
|
||
return { sichtbar: anzahl, verborgen: 0 };
|
||
}
|
||
return { sichtbar: limit, verborgen: anzahl - limit };
|
||
}
|
||
|
||
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 },
|
||
});
|
||
}
|