3 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 01884f4747 test: wiederkehrende Aufgaben abdecken, Doku, Version 1.3.0
CI / pruefen (push) Has been cancelled
Sieben Tests fuer die Terminlogik: Rechnen vom Faelligkeitsdatum,
Vorruecken bei langem Liegenbleiben, der Monatsuebergang ueber kurze
Monate, und dass die Schleife auch bei einem Datum von 1970 endet.

Dazu ein Ende-zu-Ende-Test ueber beide Wege nach ERLEDIGT und den Nachweis,
dass ein erneutes Speichern keine zweite Fassung erzeugt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:40 +02:00
florian.wildeandClaude Opus 5 04a07bd36c fix(ui): Kontrast gedaempfter Texte auf WCAG AA bringen
Eine Durchsicht mit axe-core ueber zehn Seitenzustaende - Dialoge einzeln,
sie stehen geschlossen nicht im DOM - fand 26 Verstoesse, alle derselben
Art: slate-500 kommt auf dem sehr dunklen Untergrund nur auf 3,8 zu 1,
slate-600 sogar auf 2,5. Verlangt sind 4,5 fuer normalen Text, und beide
Stufen werden reichlich fuer Hinweise und Nebenangaben benutzt.

Statt an hundert Stellen die Klasse zu tauschen, sind die beiden Stufen im
Theme aufgehellt. Der Abstand zwischen ihnen bleibt sichtbar, der Rest des
Entwurfs unangetastet. Geprueft gegen alle drei vorkommenden Untergruende;
der schlechteste Wert zaehlt.

Damit stimmt auch, was FEATURES.md ueber Barrierefreiheit behauptet - bisher
stand die Behauptung ungeprueft da.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:40 +02:00
florian.wildeandClaude Opus 5 a3093dc68a 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>
2026-07-26 23:20:39 +02:00
18 changed files with 461 additions and 20 deletions
+7
View File
@@ -102,6 +102,13 @@ Bei Änderungen an der Oberfläche **hinsehen**. Chrome ist installiert, ein
Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots. Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots.
Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen. Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen.
**Barrierefreiheit misst man, statt sie zu behaupten.** `axe-core` liegt als
Entwicklungsabhängigkeit bereit; ein Skript hängt es per `addScriptTag` in
die laufende Seite und ruft `axe.run` mit den WCAG-Tags auf. Jeder
Dialogzustand ist eine eigene Prüfung — geschlossene Dialoge stehen nicht im
DOM. Der Kontrast gedämpfter Texte ist der Punkt, der hier schon einmal
gerissen wurde; die aufgehellten Stufen stehen in `globals.css` im `@theme`.
Wer den Standalone-Bau von Hand startet (`node .next/standalone/server.js`), Wer den Standalone-Bau von Hand startet (`node .next/standalone/server.js`),
muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein
JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden
+27 -3
View File
@@ -207,6 +207,23 @@ Blockiert · Erledigt**.
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen - Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
wieder gelöscht. wieder gelöscht.
### Wiederkehrende Aufgaben
Täglich, wöchentlich, alle zwei Wochen oder monatlich. Wandert die Aufgabe nach
**Erledigt** — im Dialog oder durch Ziehen —, entsteht die nächste von selbst. Die
erledigte bleibt stehen, sonst ginge die Historie verloren.
- **Gerechnet wird vom Fälligkeitsdatum, nicht vom Abhaken.** Eine wöchentliche 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 überfällig ist, hilft niemandem.
- Beim Monatsintervall bleibt der Tag im Monat erhalten. Der 31. Januar wird zum
28. Februar, nicht zum 3. März.
- Nur der **Übergang** nach Erledigt löst aus. Eine bereits erledigte Aufgabe erneut zu
speichern erzeugt keine zweite Fassung.
- Bewusst grob gehalten: feinere Regeln („jeden zweiten Dienstag im Monat") braucht ein
Projektdashboard nicht und wären ohne Kalenderansicht kaum nachvollziehbar.
### Aus Git-Issues übernehmen ### Aus Git-Issues übernehmen
Im Git-Panel, Reiter *Issues*: ein Klick macht aus den offenen Issues Aufgaben — Titel, Im Git-Panel, Reiter *Issues*: ein Klick macht aus den offenen Issues Aufgaben — Titel,
@@ -502,8 +519,14 @@ letzten Zustellung stehen an der Karte.
- **Animationen**: gestaffelt einblendende Karten, Hover-Lift mit Farbverlaufsrand, - **Animationen**: gestaffelt einblendende Karten, Hover-Lift mit Farbverlaufsrand,
animierte Fortschrittsbalken. animierte Fortschrittsbalken.
- **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten). - **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten).
- **Barrierearm**: durchgehende ARIA-Labels, Escape schließt Dialoge, sichtbarer - **Barrierefrei nach WCAG 2.1 AA**, und zwar geprüft statt behauptet: axe-core läuft über
Fokusring, `prefers-reduced-motion` schaltet Animationen ab. zehn Seitenzustände einschließlich der Dialoge und meldet keine Verstöße. Durchgehende
ARIA-Labels, Escape schließt Dialoge, sichtbarer Fokusring, `prefers-reduced-motion`
schaltet Animationen ab.
Bei der ersten Durchsicht fielen 26 Kontrastverstöße auf: gedämpfter Text erreichte auf
dem sehr dunklen Untergrund nur 3,8 statt 4,5 zu 1. Statt an hundert Stellen die Klasse
zu tauschen, sind die beiden gedämpften Stufen im Theme aufgehellt — der Abstand
zwischen ihnen bleibt sichtbar.
- **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone - **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone
Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen. Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen.
- **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy - **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy
@@ -532,7 +555,8 @@ letzten Zustellung stehen an der Karte.
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) | | `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
| `Project` | Projektstammdaten, Status, Fortschritt, Besitzer | | `Project` | Projektstammdaten, Status, Fortschritt, Besitzer |
| `Repository` | Repositories eines Projekts inkl. Abgleichstand (n:1 zu Projekt) | | `Repository` | Repositories eines Projekts inkl. Abgleichstand (n:1 zu Projekt) |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit, Labels und Issue-Herkunft | | `Task` | Aufgaben mit Spalte, Position, Fälligkeit, Labels, Wiederholung und Issue-Herkunft |
| `ProjectTemplate` | Vorlagen je Konto: Stammdaten, Tags, Notizen und Aufgaben |
| `Activity` | Verlauf je Projekt, neueste zuerst, auf 200 Einträge begrenzt | | `Activity` | Verlauf je Projekt, neueste zuerst, auf 200 Einträge begrenzt |
| `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis | | `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) | | `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
+33 -2
View File
@@ -20,6 +20,28 @@ erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
--- ---
### 1.2 README-Vorschau `S`
Das README aus dem Repo holen und auf der Projektseite anzeigen. Ein
zusätzlicher API-Aufruf, Markdown kann Nebula schon. Erspart den Wechsel ins
Git, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging.
### 1.3 Läuft es noch? `M`
Eine Health-Check-URL je Projekt, die der Zeitgeber regelmäßig anpingt.
Nebula wüsste dann, ob ein Projekt nicht nur existiert, sondern auch läuft.
Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz
greift dabei wie bei den Git-Aufrufen.
### 1.4 Persönliche API-Token `M`
Derzeit geht die API nur mit Sitzungscookie. Ein Token je Konto würde
`curl`-Aufrufe und Handy-Shortcuts erlauben — Idee aus dem Terminal anlegen,
ohne den Browser zu öffnen. Nur der Hash in der Datenbank, wie bei den
Sitzungen.
---
## 2 · Größere Brocken ## 2 · Größere Brocken
### 2.1 Passwort-Zurücksetzen per E-Mail `M` ### 2.1 Passwort-Zurücksetzen per E-Mail `M`
@@ -38,11 +60,14 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
| Idee | Aufwand | Nutzen | | Idee | Aufwand | Nutzen |
| --- | --- | --- | | --- | --- | --- |
| Wochenrückblick | `S` | „Was ist diese Woche passiert" über alle Projekte, aus dem Aktivitätsverlauf |
| Globale Zeitleiste | `S` | Derselbe Verlauf, aber projektübergreifend als eigene Seite |
| Suche über Aufgaben | `S` | Die Volltextsuche deckt bisher nur Notizen ab |
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste | | Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" | | Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt |
| Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel | | Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` | | Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat | | Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
--- ---
@@ -54,6 +79,11 @@ Damit klar ist, was **nicht** kommt und warum:
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und - **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten. sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten.
- **Veraltete Abhängigkeiten prüfen.** Klingt verlockend, aber Renovate und
Dependabot machen das besser, und es wären viele Aufrufe an fremde
Registries — für eine Zahl, die im Repo selbst schon steht.
- **Anhänge an Notizen.** Bräuchte Dateispeicher und damit eine zweite Sorge
bei jeder Sicherung. Ein Link ins Git tut es auch.
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine - **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
Übersicht, keine Entwicklungsumgebung. Übersicht, keine Entwicklungsumgebung.
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle. - **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
@@ -84,6 +114,7 @@ Damit klar ist, was **nicht** kommt und warum:
| **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ | | **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ |
| **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ | | **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
| **v1.2.0** Commit-Heatmap über ein Jahr, Projektvorlagen | ✓ | | **v1.2.0** Commit-Heatmap über ein Jahr, Projektvorlagen | ✓ |
| **v1.3.0** Wiederkehrende Aufgaben, Barrierefreiheit nach WCAG 2.1 AA | ✓ |
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
+70
View File
@@ -374,6 +374,76 @@ test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) =
expect((await geloescht.json()).changed).toBe(3); expect((await geloescht.json()).changed).toBe(3);
}); });
test("Wiederkehrende Aufgaben legen die naechste Fassung an", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Wiederkehrend" } })).json()
).project;
const woechentlich = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, {
data: {
title: "Abhaengigkeiten pruefen",
dueDate: "2026-07-06T12:00:00",
recurrence: "WEEKLY",
labels: ["wartung"],
},
})
).json()
).task;
// Ein Zwischenschritt loest noch nichts aus.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { status: "DOING" } });
let alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(1);
// Erst der Uebergang nach ERLEDIGT.
const erledigt = await request.patch(`/api/tasks/${woechentlich.id}`, {
data: { status: "DONE" },
});
expect((await erledigt.json()).next).not.toBeNull();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
const folge = alle.find((task: { id: string }) => task.id !== woechentlich.id);
expect(folge.status).toBe("TODO");
expect(folge.title).toBe("Abhaengigkeiten pruefen");
expect(folge.labels).toEqual(["wartung"]);
expect(folge.recurrence).toBe("WEEKLY");
expect(folge.recurredFrom).toBe(woechentlich.id);
// Der Termin liegt in der Zukunft und auf demselben Wochentag.
expect(new Date(folge.dueDate).getTime()).toBeGreaterThan(Date.now());
expect(new Date(folge.dueDate).getUTCDay()).toBe(1);
// Erneut speichern erzeugt keine dritte.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { title: "Umbenannt" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
// Der zweite Weg: durch Ziehen in die Spalte ERLEDIGT.
const gezogen = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { DONE: [folge.id] } },
});
expect(gezogen.ok()).toBeTruthy();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(3);
expect(alle.filter((task: { status: string }) => task.status === "TODO")).toHaveLength(1);
// Eine einmalige Aufgabe bleibt einmalig.
const einmalig = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, { data: { title: "Nur einmal" } })
).json()
).task;
await request.patch(`/api/tasks/${einmalig.id}`, { data: { status: "DONE" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(4);
});
test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, playwright }) => { test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, playwright }) => {
await login(request, ADMIN); await login(request, ADMIN);
+13 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.0.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nebula", "name": "nebula",
"version": "1.0.0", "version": "1.2.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -34,6 +34,7 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"axe-core": "^4.12.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
@@ -2536,6 +2537,16 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/axe-core": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
"integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/bail": { "node_modules/bail": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.2.0", "version": "1.3.0",
"private": true, "private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": { "scripts": {
@@ -56,6 +56,7 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"axe-core": "^4.12.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
@@ -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";
+19
View File
@@ -71,6 +71,16 @@ enum ActivityKind {
SYNC_FAILED 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. /// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus { enum TaskStatus {
TODO TODO
@@ -450,6 +460,15 @@ model Task {
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist. /// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
doneAt DateTime? 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 /// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link
/// merken, damit dasselbe Issue nicht zweimal uebernommen wird. /// merken, damit dasselbe Issue nicht zweimal uebernommen wird.
sourceIssue Int? sourceIssue Int?
@@ -3,7 +3,7 @@ import type { Prisma, TaskStatus } from "@prisma/client";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; 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"; import { formatZodError, taskReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -52,11 +52,14 @@ export async function PATCH(request: Request, { params }: Params) {
const now = new Date(); const now = new Date();
const updates: Prisma.PrismaPromise<unknown>[] = []; const updates: Prisma.PrismaPromise<unknown>[] = [];
/** 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[]]>) { for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) {
ids.forEach((taskId, index) => { ids.forEach((taskId, index) => {
if (!ownedIds.has(taskId)) return; if (!ownedIds.has(taskId)) return;
const before = previousStatus.get(taskId); const before = previousStatus.get(taskId);
if (status === "DONE" && before !== "DONE") frischErledigt.push(taskId);
updates.push( updates.push(
prisma.task.update({ prisma.task.update({
where: { id: taskId }, where: { id: taskId },
@@ -77,6 +80,14 @@ export async function PATCH(request: Request, { params }: Params) {
if (updates.length > 0) { if (updates.length > 0) {
await prisma.$transaction(updates); 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); await refreshDerivedProgress(id);
} }
+2 -1
View File
@@ -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({ const task = await prisma.task.create({
data: { data: {
@@ -58,6 +58,7 @@ export async function POST(request: Request, { params }: Params) {
position: await nextPosition(id, status), position: await nextPosition(id, status),
dueDate: dueDate ? new Date(dueDate) : null, dueDate: dueDate ? new Date(dueDate) : null,
labels, labels,
recurrence: recurrence ?? null,
doneAt: status === "DONE" ? new Date() : null, doneAt: status === "DONE" ? new Date() : null,
}, },
}); });
+21 -4
View File
@@ -4,7 +4,12 @@ import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; 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"; import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; 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.title !== undefined) update.title = data.title;
if (data.description !== undefined) update.description = data.description; if (data.description !== undefined) update.description = data.description;
if (data.labels !== undefined) update.labels = data.labels; if (data.labels !== undefined) update.labels = data.labels;
if (data.recurrence !== undefined) update.recurrence = data.recurrence;
if (data.dueDate !== undefined) { if (data.dueDate !== undefined) {
update.dueDate = data.dueDate ? new Date(data.dueDate) : null; 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 }); 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); await refreshDerivedProgress(existing.projectId);
// Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder // 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, projectId: existing.projectId,
userId: auth.user.id, userId: auth.user.id,
kind: "TASK_MOVED", kind: "TASK_MOVED",
summary: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`, summary: folge
meta: { taskId: id, from: existing.status, to: nach }, ? `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) { export async function DELETE(_request: Request, { params }: Params) {
+16
View File
@@ -16,6 +16,22 @@
--color-void-soft: #0a0c1a; --color-void-soft: #0a0c1a;
--color-haze: #12142b; --color-haze: #12142b;
/* ── Gedaempfte Textfarben ──────────────────────────────────
Tailwinds slate-500 und slate-600 kommen auf diesem sehr dunklen
Untergrund nur auf 3,8 bzw. 2,5 zu 1. WCAG AA verlangt 4,5 fuer
normalen Text, und beide werden hier reichlich fuer Hinweise und
Nebenangaben benutzt – axe-core hat das an 26 Stellen beanstandet.
Statt an hundert Stellen die Klasse zu tauschen, werden die beiden
Stufen hier aufgehellt. Der Abstand zwischen ihnen bleibt sichtbar,
der Rest des Entwurfs unangetastet.
Geprueft gegen die drei vorkommenden Untergruende #0b0d1c (Seite),
#0e0f17 (.glass) und #14141d (.popover) – der schlechteste Wert
zaehlt. */
--color-slate-500: #8a98b0; /* 6,3 : 1 */
--color-slate-600: #78879f; /* 5,0 : 1 */
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif; "Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, --font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
+13 -2
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { AlignLeft, CalendarClock, GripVertical } from "lucide-react"; import { AlignLeft, CalendarClock, GripVertical, Repeat } from "lucide-react";
import type { Task } from "@prisma/client"; import type { Task } from "@prisma/client";
import { RECURRENCE_META } from "@/lib/tasks";
import { cn, formatDate } from "@/lib/utils"; import { cn, formatDate } from "@/lib/utils";
/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */ /** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */
@@ -69,12 +70,22 @@ export function TaskCard({
{task.title} {task.title}
</p> </p>
{(task.description || task.dueDate || task.labels.length > 0) && ( {(task.description || task.dueDate || task.recurrence || task.labels.length > 0) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]"> <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{task.description ? ( {task.description ? (
<AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" /> <AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" />
) : null} ) : null}
{task.recurrence ? (
<span
className="inline-flex items-center gap-1 text-slate-500"
title={`Wiederholt sich: ${RECURRENCE_META[task.recurrence].label}`}
>
<Repeat className="h-2.5 w-2.5" />
{RECURRENCE_META[task.recurrence].short}
</span>
) : null}
{task.dueDate ? ( {task.dueDate ? (
<span className={cn("inline-flex items-center gap-1", due?.className)}> <span className={cn("inline-flex items-center gap-1", due?.className)}>
<CalendarClock className="h-2.5 w-2.5" /> <CalendarClock className="h-2.5 w-2.5" />
+31 -2
View File
@@ -3,9 +3,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react"; 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 { 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"; import { cn, parseTags } from "@/lib/utils";
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */ /** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
@@ -36,6 +36,7 @@ export function TaskDialog({
const [status, setStatus] = useState<TaskStatus>(initialStatus); const [status, setStatus] = useState<TaskStatus>(initialStatus);
const [dueDate, setDueDate] = useState(""); const [dueDate, setDueDate] = useState("");
const [labels, setLabels] = useState(""); const [labels, setLabels] = useState("");
const [recurrence, setRecurrence] = useState<string>("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -48,6 +49,7 @@ export function TaskDialog({
setStatus(task?.status ?? initialStatus); setStatus(task?.status ?? initialStatus);
setDueDate(toDateInput(task?.dueDate ?? null)); setDueDate(toDateInput(task?.dueDate ?? null));
setLabels(task?.labels.join(", ") ?? ""); setLabels(task?.labels.join(", ") ?? "");
setRecurrence(task?.recurrence ?? "");
setError(null); setError(null);
}, [open, task, initialStatus]); }, [open, task, initialStatus]);
@@ -64,6 +66,7 @@ export function TaskDialog({
// Datum nicht auf den Vortag ziehen. // Datum nicht auf den Vortag ziehen.
dueDate: dueDate ? `${dueDate}T12:00:00` : null, dueDate: dueDate ? `${dueDate}T12:00:00` : null,
labels: parseTags(labels), labels: parseTags(labels),
recurrence: recurrence || null,
}; };
try { try {
@@ -194,6 +197,32 @@ export function TaskDialog({
placeholder="bug, ui" placeholder="bug, ui"
/> />
</div> </div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="task-recurrence">
Wiederholung
</label>
<select
id="task-recurrence"
className="field"
value={recurrence}
onChange={(event) => setRecurrence(event.target.value)}
>
<option value="">– einmalig –</option>
{(Object.keys(RECURRENCE_META) as Recurrence[]).map((key) => (
<option key={key} value={key}>
{RECURRENCE_META[key].label}
</option>
))}
</select>
{recurrence ? (
<p className="mt-1 text-xs text-slate-500">
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.
</p>
) : null}
</div>
</div> </div>
{error ? ( {error ? (
+33
View File
@@ -41,6 +41,39 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
}; };
export const CHANGELOG: Release[] = [ export const CHANGELOG: Release[] = [
{
version: "1.3.0",
date: "2026-07-26",
headline: "Was sich wiederholt, und was besser lesbar ist.",
changes: [
{
kind: "neu",
text:
"Wiederkehrende Aufgaben: täglich, wöchentlich, alle zwei Wochen oder monatlich. " +
"Wandert die Aufgabe nach „Erledigt“, entsteht die nächste von selbst.",
},
{
kind: "behoben",
text:
"Gedämpfter Text erreichte auf dem dunklen Untergrund nur 3,8 statt der " +
"geforderten 4,5 zu 1 — an 26 Stellen. Beide Stufen sind jetzt aufgehellt, " +
"der Abstand zwischen ihnen bleibt sichtbar.",
},
{
kind: "besser",
text:
"Die Barrierefreiheit ist mit axe-core über zehn Seitenzustände geprüft, " +
"einschließlich der Dialoge. Keine Verstöße mehr gegen WCAG 2.1 AA.",
},
{
kind: "besser",
text:
"Der Folgetermin rechnet vom Fälligkeitsdatum, nicht vom Abhaken — eine " +
"wöchentliche Aufgabe bleibt auf ihrem Wochentag. Lange Liegengebliebenes " +
"rutscht so weit vor, bis der Termin in der Zukunft liegt.",
},
],
},
{ {
version: "1.2.0", version: "1.2.0",
date: "2026-07-26", date: "2026-07-26",
+55 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"; 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", () => { describe("progressFromTasks", () => {
it("rechnet den Anteil erledigter Aufgaben in Prozent um", () => { 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
View File
@@ -1,4 +1,4 @@
import type { TaskStatus } from "@prisma/client"; import type { Recurrence, TaskStatus } from "@prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
/** Spaltenreihenfolge des Aufgabenbretts. */ /** 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. */ /** Naechste freie Position am Ende einer Spalte. */
export async function nextPosition( export async function nextPosition(
projectId: string, projectId: string,
+3
View File
@@ -4,6 +4,7 @@ const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const; const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
const ACCENTS = ["violet", "cyan", "emerald", "amber", "rose", "blue"] as const; const ACCENTS = ["violet", "cyan", "emerald", "amber", "rose", "blue"] as const;
const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const; const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const;
const RECURRENCE = ["DAILY", "WEEKLY", "BIWEEKLY", "MONTHLY"] as const;
const emptyToNull = (value: unknown) => const emptyToNull = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? null : value; typeof value === "string" && value.trim() === "" ? null : value;
@@ -157,6 +158,8 @@ export const taskCreateSchema = z.object({
status: z.enum(TASK_STATUS).default("TODO"), status: z.enum(TASK_STATUS).default("TODO"),
dueDate: dueDateField, dueDate: dueDateField,
labels: z.array(z.string().trim().min(1).max(30)).max(8).default([]), 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(); export const taskUpdateSchema = taskCreateSchema.partial();