Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01884f4747 | ||
|
|
04a07bd36c | ||
|
|
a3093dc68a |
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Generated
+13
-2
@@ -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
@@ -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";
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user