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) <noreply@anthropic.com>
This commit is contained in:
+55
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+98
-1
@@ -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<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Naechste freie Position am Ende einer Spalte. */
|
||||
export async function nextPosition(
|
||||
projectId: string,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user