From a3093dc68a0bff507f05587ddb92a06a63576b2e Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Sun, 26 Jul 2026 23:20:39 +0200
Subject: [PATCH] feat(tasks): wiederkehrende Aufgaben
Taeglich, woechentlich, alle zwei Wochen oder monatlich. Wandert die Aufgabe
nach ERLEDIGT, entsteht die naechste von selbst. Die erledigte bleibt
stehen - sonst ginge die Historie verloren.
Gerechnet wird vom Faelligkeitsdatum, nicht vom Abhaken: eine woechentliche
Aufgabe bleibt auf ihrem Montag, auch wenn sie einmal erst am Mittwoch
erledigt wurde. Lange Liegengebliebenes rutscht so weit vor, bis der Termin
in der Zukunft liegt; eine Aufgabe, die schon bei ihrer Entstehung
ueberfaellig ist, hilft niemandem.
Beim Monatsintervall bleibt der Tag im Monat erhalten. setMonth allein
rollt ueber: der 31. Januar wuerde zum 3. Maerz statt zum 28. Februar.
Ausgeloest wird an beiden Stellen, an denen eine Aufgabe erledigt werden
kann - im Dialog und durch Ziehen ins Brett. Nur der Uebergang zaehlt: eine
bereits erledigte Aufgabe erneut zu speichern erzeugt keine zweite Fassung.
Bewusst grob gehalten. Feinere Regeln wie "jeden zweiten Dienstag im Monat"
braucht ein Projektdashboard nicht, und sie waeren ohne Kalenderansicht
kaum nachvollziehbar.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../migration.sql | 6 ++
prisma/schema.prisma | 19 ++++
.../api/projects/[id]/tasks/reorder/route.ts | 13 ++-
src/app/api/projects/[id]/tasks/route.ts | 3 +-
src/app/api/tasks/[id]/route.ts | 25 ++++-
src/components/tasks/TaskCard.tsx | 15 ++-
src/components/tasks/TaskDialog.tsx | 33 ++++++-
src/lib/tasks.test.ts | 56 ++++++++++-
src/lib/tasks.ts | 99 ++++++++++++++++++-
src/lib/validation.ts | 3 +
10 files changed, 260 insertions(+), 12 deletions(-)
create mode 100644 prisma/migrations/20260726270000_task_recurrence/migration.sql
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 ? (
) : 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();