diff --git a/prisma/migrations/20260726270000_task_recurrence/migration.sql b/prisma/migrations/20260726270000_task_recurrence/migration.sql new file mode 100644 index 0000000..86b07c3 --- /dev/null +++ b/prisma/migrations/20260726270000_task_recurrence/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "Recurrence" AS ENUM ('DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY'); + +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "recurredFrom" TEXT, +ADD COLUMN "recurrence" "Recurrence"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf575a7..958f0fd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,6 +71,16 @@ enum ActivityKind { SYNC_FAILED } +/// Wie oft sich eine Aufgabe wiederholt. Bewusst grob: feinere Regeln +/// (jeden zweiten Dienstag im Monat) braucht ein Projektdashboard nicht, +/// und sie waeren ohne Kalenderansicht kaum nachvollziehbar. +enum Recurrence { + DAILY + WEEKLY + BIWEEKLY + MONTHLY +} + /// Spalten des Aufgabenbretts innerhalb eines Projekts. enum TaskStatus { TODO @@ -450,6 +460,15 @@ model Task { /// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist. doneAt DateTime? + /// Wiederholt sich die Aufgabe? Wandert sie nach ERLEDIGT, entsteht die + /// naechste Fassung mit fortgeschriebenem Faelligkeitsdatum. Die erledigte + /// bleibt stehen – sonst ginge die Historie verloren. + recurrence Recurrence? + /// Aus welcher Aufgabe diese hervorgegangen ist. Nur zur Nachvollziehbarkeit; + /// bewusst ohne Fremdschluessel, damit das Loeschen der Vorgaengerin die + /// Nachfolgerin nicht mitnimmt. + recurredFrom String? + /// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link /// merken, damit dasselbe Issue nicht zweimal uebernommen wird. sourceIssue Int? diff --git a/src/app/api/projects/[id]/tasks/reorder/route.ts b/src/app/api/projects/[id]/tasks/reorder/route.ts index 2695efe..bb5739d 100644 --- a/src/app/api/projects/[id]/tasks/reorder/route.ts +++ b/src/app/api/projects/[id]/tasks/reorder/route.ts @@ -3,7 +3,7 @@ import type { Prisma, TaskStatus } from "@prisma/client"; import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; import { jsonError, readJsonBody } from "@/lib/security/request"; -import { ownedProject, refreshDerivedProgress } from "@/lib/tasks"; +import { ownedProject, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks"; import { formatZodError, taskReorderSchema } from "@/lib/validation"; export const dynamic = "force-dynamic"; @@ -52,11 +52,14 @@ export async function PATCH(request: Request, { params }: Params) { const now = new Date(); const updates: Prisma.PrismaPromise[] = []; + /** Wer eben erst nach ERLEDIGT gewandert ist – nicht, wer dort schon lag. */ + const frischErledigt: string[] = []; for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) { ids.forEach((taskId, index) => { if (!ownedIds.has(taskId)) return; const before = previousStatus.get(taskId); + if (status === "DONE" && before !== "DONE") frischErledigt.push(taskId); updates.push( prisma.task.update({ where: { id: taskId }, @@ -77,6 +80,14 @@ export async function PATCH(request: Request, { params }: Params) { if (updates.length > 0) { await prisma.$transaction(updates); + + // Wiederkehrende Aufgaben, die eben nach ERLEDIGT gezogen wurden, + // bekommen ihre naechste Fassung. Erst nach der Transaktion: vorher + // stuende doneAt noch nicht, aus dem der Folgetermin gerechnet wird. + for (const taskId of frischErledigt) { + await spawnRecurrence(taskId); + } + await refreshDerivedProgress(id); } diff --git a/src/app/api/projects/[id]/tasks/route.ts b/src/app/api/projects/[id]/tasks/route.ts index cca2fc2..c07c8f2 100644 --- a/src/app/api/projects/[id]/tasks/route.ts +++ b/src/app/api/projects/[id]/tasks/route.ts @@ -46,7 +46,7 @@ export async function POST(request: Request, { params }: Params) { ); } - const { title, description, status, dueDate, labels } = parsed.data; + const { title, description, status, dueDate, labels, recurrence } = parsed.data; const task = await prisma.task.create({ data: { @@ -58,6 +58,7 @@ export async function POST(request: Request, { params }: Params) { position: await nextPosition(id, status), dueDate: dueDate ? new Date(dueDate) : null, labels, + recurrence: recurrence ?? null, doneAt: status === "DONE" ? new Date() : null, }, }); diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 3519d45..b4fb21f 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -4,7 +4,12 @@ import { recordActivity, shorten } from "@/lib/activity"; import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; import { jsonError, readJsonBody } from "@/lib/security/request"; -import { TASK_STATUS_META, nextPosition, refreshDerivedProgress } from "@/lib/tasks"; +import { + TASK_STATUS_META, + nextPosition, + refreshDerivedProgress, + spawnRecurrence, +} from "@/lib/tasks"; import { formatZodError, taskUpdateSchema } from "@/lib/validation"; export const dynamic = "force-dynamic"; @@ -44,6 +49,7 @@ export async function PATCH(request: Request, { params }: Params) { if (data.title !== undefined) update.title = data.title; if (data.description !== undefined) update.description = data.description; if (data.labels !== undefined) update.labels = data.labels; + if (data.recurrence !== undefined) update.recurrence = data.recurrence; if (data.dueDate !== undefined) { update.dueDate = data.dueDate ? new Date(data.dueDate) : null; } @@ -57,6 +63,15 @@ export async function PATCH(request: Request, { params }: Params) { } const task = await prisma.task.update({ where: { id }, data: update }); + + // Genau beim Uebergang nach ERLEDIGT entsteht die naechste Fassung. Ein + // erneutes Speichern einer schon erledigten Aufgabe darf keine zweite + // erzeugen – deshalb haengt es an update.status und nicht an data.status. + const folge = + update.status === "DONE" && existing.status !== "DONE" + ? await spawnRecurrence(id) + : null; + await refreshDerivedProgress(existing.projectId); // Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder @@ -68,12 +83,14 @@ export async function PATCH(request: Request, { params }: Params) { projectId: existing.projectId, userId: auth.user.id, kind: "TASK_MOVED", - summary: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`, - meta: { taskId: id, from: existing.status, to: nach }, + summary: folge + ? `Aufgabe „${shorten(task.title, 40)}“ erledigt, nächste angelegt` + : `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`, + meta: { taskId: id, from: existing.status, to: nach, ...(folge ? { next: folge.id } : {}) }, }); } - return NextResponse.json({ task }); + return NextResponse.json({ task, next: folge }); } export async function DELETE(_request: Request, { params }: Params) { diff --git a/src/components/tasks/TaskCard.tsx b/src/components/tasks/TaskCard.tsx index e1a9b98..4c074bd 100644 --- a/src/components/tasks/TaskCard.tsx +++ b/src/components/tasks/TaskCard.tsx @@ -1,7 +1,8 @@ "use client"; -import { AlignLeft, CalendarClock, GripVertical } from "lucide-react"; +import { AlignLeft, CalendarClock, GripVertical, Repeat } from "lucide-react"; import type { Task } from "@prisma/client"; +import { RECURRENCE_META } from "@/lib/tasks"; import { cn, formatDate } from "@/lib/utils"; /** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */ @@ -69,12 +70,22 @@ export function TaskCard({ {task.title}

- {(task.description || task.dueDate || task.labels.length > 0) && ( + {(task.description || task.dueDate || task.recurrence || task.labels.length > 0) && (
{task.description ? ( ) : null} + {task.recurrence ? ( + + + {RECURRENCE_META[task.recurrence].short} + + ) : null} + {task.dueDate ? ( diff --git a/src/components/tasks/TaskDialog.tsx b/src/components/tasks/TaskDialog.tsx index f0542d2..00ebbd9 100644 --- a/src/components/tasks/TaskDialog.tsx +++ b/src/components/tasks/TaskDialog.tsx @@ -3,9 +3,9 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Loader2, Trash2 } from "lucide-react"; -import type { Task, TaskStatus } from "@prisma/client"; +import type { Recurrence, Task, TaskStatus } from "@prisma/client"; import { Modal } from "@/components/ui/Modal"; -import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; +import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; import { cn, parseTags } from "@/lib/utils"; /** Datum fuer – ortszeitbezogen, nicht UTC. */ @@ -36,6 +36,7 @@ export function TaskDialog({ const [status, setStatus] = useState(initialStatus); const [dueDate, setDueDate] = useState(""); const [labels, setLabels] = useState(""); + const [recurrence, setRecurrence] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -48,6 +49,7 @@ export function TaskDialog({ setStatus(task?.status ?? initialStatus); setDueDate(toDateInput(task?.dueDate ?? null)); setLabels(task?.labels.join(", ") ?? ""); + setRecurrence(task?.recurrence ?? ""); setError(null); }, [open, task, initialStatus]); @@ -64,6 +66,7 @@ export function TaskDialog({ // Datum nicht auf den Vortag ziehen. dueDate: dueDate ? `${dueDate}T12:00:00` : null, labels: parseTags(labels), + recurrence: recurrence || null, }; try { @@ -194,6 +197,32 @@ export function TaskDialog({ placeholder="bug, ui" />
+ +
+ + + {recurrence ? ( +

+ Sobald die Aufgabe nach „Erledigt“ wandert, entsteht die nächste. Gerechnet + wird vom Fälligkeitsdatum, nicht vom Abhaken — eine wöchentliche Aufgabe + bleibt also auf ihrem Wochentag. +

+ ) : null} +
{error ? ( diff --git a/src/lib/tasks.test.ts b/src/lib/tasks.test.ts index 8272f18..3de6efc 100644 --- a/src/lib/tasks.test.ts +++ b/src/lib/tasks.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { TASK_STATUS_META, TASK_STATUS_ORDER, progressFromTasks } from "./tasks"; +import { + TASK_STATUS_META, + TASK_STATUS_ORDER, + nextDueDate, + progressFromTasks, +} from "./tasks"; describe("progressFromTasks", () => { it("rechnet den Anteil erledigter Aufgaben in Prozent um", () => { @@ -34,3 +39,52 @@ describe("Spaltenreihenfolge", () => { } }); }); + +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); + }); +}); diff --git a/src/lib/tasks.ts b/src/lib/tasks.ts index b3c8a55..46535b2 100644 --- a/src/lib/tasks.ts +++ b/src/lib/tasks.ts @@ -1,4 +1,4 @@ -import type { TaskStatus } from "@prisma/client"; +import type { Recurrence, TaskStatus } from "@prisma/client"; import { prisma } from "@/lib/db"; /** Spaltenreihenfolge des Aufgabenbretts. */ @@ -64,6 +64,103 @@ export async function refreshDerivedProgress(projectId: string): Promise { }); } +// ── Wiederkehrende Aufgaben ────────────────────────────────── + +export const RECURRENCE_META: Record = { + 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. + }, + }); +} + /** Naechste freie Position am Ende einer Spalte. */ export async function nextPosition( projectId: string, diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 4129d7d..2d21e50 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -4,6 +4,7 @@ const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED" const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const; const ACCENTS = ["violet", "cyan", "emerald", "amber", "rose", "blue"] as const; const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const; +const RECURRENCE = ["DAILY", "WEEKLY", "BIWEEKLY", "MONTHLY"] as const; const emptyToNull = (value: unknown) => typeof value === "string" && value.trim() === "" ? null : value; @@ -157,6 +158,8 @@ export const taskCreateSchema = z.object({ status: z.enum(TASK_STATUS).default("TODO"), dueDate: dueDateField, labels: z.array(z.string().trim().min(1).max(30)).max(8).default([]), + /** Wiederholt sich die Aufgabe? null = einmalig. */ + recurrence: z.preprocess(emptyToNull, z.enum(RECURRENCE).nullable().optional()), }); export const taskUpdateSchema = taskCreateSchema.partial();