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.
|
||||
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`),
|
||||
muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein
|
||||
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
|
||||
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
|
||||
|
||||
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,
|
||||
animierte Fortschrittsbalken.
|
||||
- **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten).
|
||||
- **Barrierearm**: durchgehende ARIA-Labels, Escape schließt Dialoge, sichtbarer
|
||||
Fokusring, `prefers-reduced-motion` schaltet Animationen ab.
|
||||
- **Barrierefrei nach WCAG 2.1 AA**, und zwar geprüft statt behauptet: axe-core läuft über
|
||||
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
|
||||
Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen.
|
||||
- **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) |
|
||||
| `Project` | Projektstammdaten, Status, Fortschritt, Besitzer |
|
||||
| `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 |
|
||||
| `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis |
|
||||
| `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.1 Passwort-Zurücksetzen per E-Mail `M`
|
||||
@@ -38,11 +60,14 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
@@ -54,6 +79,11 @@ Damit klar ist, was **nicht** kommt und warum:
|
||||
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
|
||||
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
|
||||
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
|
||||
Übersicht, keine Entwicklungsumgebung.
|
||||
- **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.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
|
||||
| **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
|
||||
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);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await login(request, ADMIN);
|
||||
|
||||
|
||||
Generated
+13
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nebula",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nebula",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -34,6 +34,7 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"axe-core": "^4.12.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"prisma": "^6.8.2",
|
||||
"tailwindcss": "^4.1.7",
|
||||
@@ -2536,6 +2537,16 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nebula",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
||||
"scripts": {
|
||||
@@ -56,6 +56,7 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"axe-core": "^4.12.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"prisma": "^6.8.2",
|
||||
"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
|
||||
}
|
||||
|
||||
/// Wie oft sich eine Aufgabe wiederholt. Bewusst grob: feinere Regeln
|
||||
/// (jeden zweiten Dienstag im Monat) braucht ein Projektdashboard nicht,
|
||||
/// und sie waeren ohne Kalenderansicht kaum nachvollziehbar.
|
||||
enum Recurrence {
|
||||
DAILY
|
||||
WEEKLY
|
||||
BIWEEKLY
|
||||
MONTHLY
|
||||
}
|
||||
|
||||
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
|
||||
enum TaskStatus {
|
||||
TODO
|
||||
@@ -450,6 +460,15 @@ model Task {
|
||||
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
|
||||
doneAt DateTime?
|
||||
|
||||
/// Wiederholt sich die Aufgabe? Wandert sie nach ERLEDIGT, entsteht die
|
||||
/// naechste Fassung mit fortgeschriebenem Faelligkeitsdatum. Die erledigte
|
||||
/// bleibt stehen – sonst ginge die Historie verloren.
|
||||
recurrence Recurrence?
|
||||
/// Aus welcher Aufgabe diese hervorgegangen ist. Nur zur Nachvollziehbarkeit;
|
||||
/// bewusst ohne Fremdschluessel, damit das Loeschen der Vorgaengerin die
|
||||
/// Nachfolgerin nicht mitnimmt.
|
||||
recurredFrom String?
|
||||
|
||||
/// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link
|
||||
/// merken, damit dasselbe Issue nicht zweimal uebernommen wird.
|
||||
sourceIssue Int?
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Prisma, TaskStatus } from "@prisma/client";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { ownedProject, refreshDerivedProgress } from "@/lib/tasks";
|
||||
import { ownedProject, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks";
|
||||
import { formatZodError, taskReorderSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -52,11 +52,14 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
|
||||
const now = new Date();
|
||||
const updates: Prisma.PrismaPromise<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[]]>) {
|
||||
ids.forEach((taskId, index) => {
|
||||
if (!ownedIds.has(taskId)) return;
|
||||
const before = previousStatus.get(taskId);
|
||||
if (status === "DONE" && before !== "DONE") frischErledigt.push(taskId);
|
||||
updates.push(
|
||||
prisma.task.update({
|
||||
where: { id: taskId },
|
||||
@@ -77,6 +80,14 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
|
||||
if (updates.length > 0) {
|
||||
await prisma.$transaction(updates);
|
||||
|
||||
// Wiederkehrende Aufgaben, die eben nach ERLEDIGT gezogen wurden,
|
||||
// bekommen ihre naechste Fassung. Erst nach der Transaktion: vorher
|
||||
// stuende doneAt noch nicht, aus dem der Folgetermin gerechnet wird.
|
||||
for (const taskId of frischErledigt) {
|
||||
await spawnRecurrence(taskId);
|
||||
}
|
||||
|
||||
await refreshDerivedProgress(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function POST(request: Request, { params }: Params) {
|
||||
);
|
||||
}
|
||||
|
||||
const { title, description, status, dueDate, labels } = parsed.data;
|
||||
const { title, description, status, dueDate, labels, recurrence } = parsed.data;
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
@@ -58,6 +58,7 @@ export async function POST(request: Request, { params }: Params) {
|
||||
position: await nextPosition(id, status),
|
||||
dueDate: dueDate ? new Date(dueDate) : null,
|
||||
labels,
|
||||
recurrence: recurrence ?? null,
|
||||
doneAt: status === "DONE" ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,12 @@ import { recordActivity, shorten } from "@/lib/activity";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { TASK_STATUS_META, nextPosition, refreshDerivedProgress } from "@/lib/tasks";
|
||||
import {
|
||||
TASK_STATUS_META,
|
||||
nextPosition,
|
||||
refreshDerivedProgress,
|
||||
spawnRecurrence,
|
||||
} from "@/lib/tasks";
|
||||
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -44,6 +49,7 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
if (data.title !== undefined) update.title = data.title;
|
||||
if (data.description !== undefined) update.description = data.description;
|
||||
if (data.labels !== undefined) update.labels = data.labels;
|
||||
if (data.recurrence !== undefined) update.recurrence = data.recurrence;
|
||||
if (data.dueDate !== undefined) {
|
||||
update.dueDate = data.dueDate ? new Date(data.dueDate) : null;
|
||||
}
|
||||
@@ -57,6 +63,15 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
}
|
||||
|
||||
const task = await prisma.task.update({ where: { id }, data: update });
|
||||
|
||||
// Genau beim Uebergang nach ERLEDIGT entsteht die naechste Fassung. Ein
|
||||
// erneutes Speichern einer schon erledigten Aufgabe darf keine zweite
|
||||
// erzeugen – deshalb haengt es an update.status und nicht an data.status.
|
||||
const folge =
|
||||
update.status === "DONE" && existing.status !== "DONE"
|
||||
? await spawnRecurrence(id)
|
||||
: null;
|
||||
|
||||
await refreshDerivedProgress(existing.projectId);
|
||||
|
||||
// Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder
|
||||
@@ -68,12 +83,14 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
projectId: existing.projectId,
|
||||
userId: auth.user.id,
|
||||
kind: "TASK_MOVED",
|
||||
summary: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
|
||||
meta: { taskId: id, from: existing.status, to: nach },
|
||||
summary: folge
|
||||
? `Aufgabe „${shorten(task.title, 40)}“ erledigt, nächste angelegt`
|
||||
: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
|
||||
meta: { taskId: id, from: existing.status, to: nach, ...(folge ? { next: folge.id } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ task });
|
||||
return NextResponse.json({ task, next: folge });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: Params) {
|
||||
|
||||
@@ -16,6 +16,22 @@
|
||||
--color-void-soft: #0a0c1a;
|
||||
--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,
|
||||
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
--font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { AlignLeft, CalendarClock, GripVertical } from "lucide-react";
|
||||
import { AlignLeft, CalendarClock, GripVertical, Repeat } from "lucide-react";
|
||||
import type { Task } from "@prisma/client";
|
||||
import { RECURRENCE_META } from "@/lib/tasks";
|
||||
import { cn, formatDate } from "@/lib/utils";
|
||||
|
||||
/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */
|
||||
@@ -69,12 +70,22 @@ export function TaskCard({
|
||||
{task.title}
|
||||
</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]">
|
||||
{task.description ? (
|
||||
<AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" />
|
||||
) : 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 ? (
|
||||
<span className={cn("inline-flex items-center gap-1", due?.className)}>
|
||||
<CalendarClock className="h-2.5 w-2.5" />
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import type { Task, TaskStatus } from "@prisma/client";
|
||||
import type { Recurrence, Task, TaskStatus } from "@prisma/client";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
|
||||
import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
|
||||
import { cn, parseTags } from "@/lib/utils";
|
||||
|
||||
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
|
||||
@@ -36,6 +36,7 @@ export function TaskDialog({
|
||||
const [status, setStatus] = useState<TaskStatus>(initialStatus);
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const [labels, setLabels] = useState("");
|
||||
const [recurrence, setRecurrence] = useState<string>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -48,6 +49,7 @@ export function TaskDialog({
|
||||
setStatus(task?.status ?? initialStatus);
|
||||
setDueDate(toDateInput(task?.dueDate ?? null));
|
||||
setLabels(task?.labels.join(", ") ?? "");
|
||||
setRecurrence(task?.recurrence ?? "");
|
||||
setError(null);
|
||||
}, [open, task, initialStatus]);
|
||||
|
||||
@@ -64,6 +66,7 @@ export function TaskDialog({
|
||||
// Datum nicht auf den Vortag ziehen.
|
||||
dueDate: dueDate ? `${dueDate}T12:00:00` : null,
|
||||
labels: parseTags(labels),
|
||||
recurrence: recurrence || null,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -194,6 +197,32 @@ export function TaskDialog({
|
||||
placeholder="bug, ui"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{error ? (
|
||||
|
||||
@@ -41,6 +41,39 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
|
||||
};
|
||||
|
||||
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",
|
||||
date: "2026-07-26",
|
||||
|
||||
+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