{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();