6 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 5b40d5e4ff feat(api): progressFromTasks im Projekt-Endpunkt
Der Schalter laesst sich ueber PATCH /api/projects/:id setzen. Beim
Einschalten wird der abgeleitete Wert sofort berechnet, damit er nicht bis
zur naechsten Aufgabenaenderung falsch steht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:30:04 +02:00
florian.wildeandClaude Opus 5 c50fa811c5 docs: Aufgabenbrett dokumentieren
FEATURES bekommt ein eigenes Kapitel: die vier Spalten, das Ziehen mit
Sortierung, Faelligkeiten, der abgeleitete Fortschritt und die Erklaerung,
warum die Reihenfolge als vollstaendige Liste je Spalte gespeichert wird.

ROADMAP: "Aufgaben und Checklisten" wandert zu den erledigten Punkten. Neu
unter den Kleinigkeiten stehen wiederkehrende Aufgaben und das Uebernehmen
offener Git-Issues als Aufgaben - beides ergibt sich erst jetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:51 +02:00
florian.wildeandClaude Opus 5 e40a3bd62e fix: Warnung beim Einlesen der Versionsnummer beseitigen
Der benannte Import aus der package.json loeste in Webpack die Warnung
"Should not import the named export 'version'" aus. Jetzt als Standardimport.
Version auf 0.5.0 gehoben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:50 +02:00
florian.wildeandClaude Opus 5 79c4bd1967 feat(export): Aufgaben in Export, Import und Sicherung aufnehmen
Aufgaben gehen mit - JSON-Export, Import und Instanz-Sicherung. Beim Import
wird die Position aus der Datei uebernommen, notfalls die Reihenfolge in ihr.
Wie bei den Notizen werden Aufgaben beim Ersetzen ausgetauscht statt
zusammengefuehrt, damit ein erneuter Import keine Dubletten erzeugt.

Der Markdown-Export bekommt einen Abschnitt "Aufgaben", nach Spalten
gegliedert und als Checkliste geschrieben - so bleibt der Stand auch
ausserhalb von Nebula lesbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:50 +02:00
florian.wildeandClaude Opus 5 0212dad2bf feat(ui): Aufgabenbrett auf der Projektseite
Vier Spalten, Karten per Ziehen verschiebbar - zwischen den Spalten und
innerhalb einer Spalte. Waehrend des Ziehens fuehrt der oertliche Zustand,
danach wird die neue Ordnung als Ganzes gespeichert; scheitert das, springt
das Brett auf den Serverstand zurueck.

Details:
- Gezogen wird am Griff links, damit ein Klick auf die Karte weiter den
  Dialog oeffnet.
- Der Spaltenwechsel passiert schon beim Darueberziehen, nicht erst beim
  Ablegen - sonst wirkt das Brett traege.
- Der Serverstand wird nur uebernommen, wenn gerade nicht gezogen wird,
  sonst spraenge die Karte mitten in der Bewegung zurueck.
- Faelligkeiten faerben sich: ueberfaellig rot, heute gelb, demnaechst
  gedaempft.

Ist "Fortschritt aus Aufgaben" eingeschaltet, wird der Regler gesperrt statt
einen Wert anzubieten, der beim naechsten Aufgabenwechsel ueberschrieben
wuerde - im Dialog wie im Kopfbereich. Die Projektkarte zeigt das Verhaeltnis
als "3/8".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:28 +02:00
florian.wildeandClaude Opus 5 72bcf824ce feat(tasks): Aufgaben je Projekt mit Spalten und Reihenfolge
Neues Modell Task: Titel, optionale Beschreibung, Spalte, Position,
Faelligkeit und Labels. Vier Spalten - offen, in Arbeit, blockiert,
erledigt.

Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte hier
bedeutsam und wird deshalb gespeichert. Der Reorder-Endpunkt nimmt je Spalte
die vollstaendige Liste in ihrer neuen Reihenfolge entgegen und leitet Status
und Position daraus ab. Das ist wiederholbar, kommt ohne Positionsrechnerei
im Browser aus, und ein verlorener Aufruf laesst sich einfach erneut senden.

Sicherheitsrelevant: der Endpunkt bekommt IDs aus dem Browser. Er
aktualisiert ausschliesslich Aufgaben, die wirklich zu diesem Projekt
gehoeren, und meldet uebergangene IDs zurueck - sonst liessen sich fremde
Aufgaben ueber untergeschobene IDs verschieben.

doneAt wird nur beim tatsaechlichen Wechsel nach "erledigt" gesetzt und beim
Zurueckziehen geleert. Ein Umsortieren innerhalb der Spalte laesst das Datum
in Ruhe.

Der Fortschritt eines Projekts laesst sich wahlweise aus dem Anteil
erledigter Aufgaben ableiten. Er wird nach jeder Aenderung neu berechnet und
auch sofort beim Einschalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:28 +02:00
28 changed files with 1325 additions and 21 deletions
+34
View File
@@ -137,6 +137,39 @@ Anzahl Notizen · Warnung bei Sync-Fehler · Tags · Statusauswahl · Priorität
---
## 2a. Aufgaben je Projekt
Jedes Projekt hat ein eigenes Aufgabenbrett mit vier Spalten: **Offen · In Arbeit ·
Blockiert · Erledigt**.
- **Ziehen und Ablegen** zwischen den Spalten **und innerhalb** einer Spalte. Anders als
beim Projektbrett ist die Reihenfolge hier bedeutsam, deshalb wird sie gespeichert.
- Gezogen wird am Griff links, damit ein Klick auf die Karte weiter den Dialog öffnet.
- Eine Aufgabe braucht nur einen Titel. Optional: Beschreibung (Markdown),
Fälligkeitsdatum und Labels.
- **Fälligkeiten** färben sich mit: überfällig rot, heute gelb, in den nächsten Tagen
gedämpft.
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
wieder gelöscht.
### Fortschritt aus Aufgaben
Pro Projekt umschaltbar. Ist es an, ergibt sich der Fortschritt aus dem Anteil erledigter
Aufgaben und wird bei jeder Änderung neu berechnet — der Regler ist dann gesperrt, statt
einen Wert anzubieten, der gleich wieder überschrieben würde.
Auf der Projektkarte im Dashboard steht das Verhältnis als `3/8`.
### Wie die Reihenfolge gespeichert wird
Beim Ablegen schickt die Oberfläche je Spalte die **vollständige Liste** der Aufgaben in
ihrer neuen Reihenfolge; der Server vergibt daraus Position und Status. Das ist
wiederholbar, kommt ohne Positionsrechnerei im Browser aus, und ein verlorener Aufruf
lässt sich einfach erneut senden. IDs, die nicht zum Projekt gehören, werden ignoriert
statt verschoben.
---
## 3. Notizen
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
@@ -315,6 +348,7 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
| `WebAuthnChallenge` | Kurzlebige, einmalig verwendbare Challenges |
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
| `Project` | Projektstammdaten, Status, Fortschritt, Repo-Verknüpfung, Besitzer |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit und Labels |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer |
| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Projekt) |
+3
View File
@@ -337,6 +337,9 @@ ausschließlich Daten des angemeldeten Kontos.
| `GET` | `/api/search?q=` | Volltextsuche über die eigenen Notizen |
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen |
| `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts |
| `PATCH` | `/api/projects/:id/tasks/reorder` | Neue Reihenfolge nach dem Ziehen |
| `PATCH` `DELETE` | `/api/tasks/:id` | Aufgabe ändern / löschen |
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
| `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen |
| `GET` `POST` | `/api/connections` | Git-Verbindungen |
+3 -6
View File
@@ -31,12 +31,6 @@ stillstehen. Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit
dem automatischen Abgleich und dem CI-Status ergibt das ein brauchbares
Frühwarnsystem — die Daten liegen bereits vor.
### 1.3 Aufgaben und Checklisten `M`
Eigene Entität statt Freitext, mit Häkchen und automatisch berechnetem
Fortschritt. Ersetzt das manuelle Schieben des Reglers. Die Markdown-
Checklisten in Notizen sind ein Vorgeschmack, aber nicht auswertbar.
---
## 2 · Danach
@@ -93,6 +87,8 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal |
| Aktivitätsverlauf je Projekt | `S` | Wer hat wann was geändert |
| Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" |
| Aufgaben aus Git-Issues übernehmen | `M` | Offene Issues als Aufgaben einhängen |
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
@@ -132,6 +128,7 @@ Damit klar ist, was **nicht** kommt und warum:
| Export, Import und Sicherung | ✓ |
| **v0.3.0** Markdown, Volltextsuche über Notizen, PR- und Issue-Listen, Testfundament | ✓ |
| **v0.4.0** Kanban mit Drag & Drop, CI-Status je Branch, Commit-Aktivität | ✓ |
| **v0.5.0** Aufgabenbrett je Projekt mit sortierbaren Spalten, Fortschritt aus Aufgaben | ✓ |
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nebula",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": {
@@ -0,0 +1,28 @@
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('TODO', 'DOING', 'BLOCKED', 'DONE');
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "progressFromTasks" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "Task" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "TaskStatus" NOT NULL DEFAULT 'TODO',
"position" INTEGER NOT NULL DEFAULT 0,
"dueDate" TIMESTAMP(3),
"labels" TEXT[] DEFAULT ARRAY[]::TEXT[],
"doneAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Task_projectId_status_position_idx" ON "Task"("projectId", "status", "position");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+37
View File
@@ -33,6 +33,14 @@ enum GitProvider {
GITEA
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus {
TODO
DOING
BLOCKED
DONE
}
/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat
/// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart.
enum UserRole {
@@ -237,7 +245,12 @@ model Project {
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
/// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
progressFromTasks Boolean @default(false)
notes Note[]
tasks Task[]
snapshot RepoSnapshot?
createdAt DateTime @default(now())
@@ -250,6 +263,30 @@ model Project {
@@index([ownerId])
}
/// Eine Aufgabe innerhalb eines Projekts – die Karten des Aufgabenbretts.
model Task {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
title String
/// Optionale Notiz zur Aufgabe, Markdown wird dargestellt.
description String?
status TaskStatus @default(TODO)
/// Rang innerhalb der Spalte. Anders als beim Projektbrett braucht es ihn
/// hier wirklich: Aufgaben haben eine Reihenfolge, die der Benutzer setzt.
/// Beim Umsortieren werden die Positionen einer Spalte neu durchnummeriert.
position Int @default(0)
dueDate DateTime?
labels String[] @default([])
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
doneAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId, status, position])
}
/// Freie Notizen zu einem Projekt.
model Note {
id String @id @default(cuid())
+1
View File
@@ -56,6 +56,7 @@ export async function GET() {
prisma.project.findMany({
include: {
notes: { orderBy: { createdAt: "asc" } },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
// Snapshots bleiben draussen: reproduzierbar und gross.
},
orderBy: { name: "asc" },
@@ -25,6 +25,7 @@ export async function GET(request: Request, { params }: Params) {
where: { id, ownerId: auth.user.id },
include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } },
snapshot: true,
},
+11 -2
View File
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git";
import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, projectUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
@@ -75,6 +76,7 @@ export async function PATCH(request: Request, { params }: Params) {
"tags",
"favorite",
"autoSync",
"progressFromTasks",
"connectionId",
] as const) {
if (data[key] !== undefined) update[key] = data[key];
@@ -94,9 +96,16 @@ export async function PATCH(request: Request, { params }: Params) {
if (data.repoOwner !== undefined) update.repoOwner = data.repoOwner;
if (data.repoName !== undefined) update.repoName = data.repoName;
const project = await prisma.project.update({
await prisma.project.update({ where: { id }, data: update });
// Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht
// erst beim naechsten Aufgabenwechsel.
if (data.progressFromTasks === true) {
await refreshDerivedProgress(id);
}
const project = await prisma.project.findUniqueOrThrow({
where: { id },
data: update,
include: projectListInclude,
});
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
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 { formatZodError, taskReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/**
* Neue Reihenfolge nach dem Ziehen.
*
* Uebergeben wird je betroffener Spalte die vollstaendige Liste der Aufgaben
* in ihrer neuen Reihenfolge. Daraus ergeben sich Status und Position
* eindeutig – der Aufruf ist damit wiederholbar, ohne Schaden anzurichten,
* und ein verlorener Aufruf laesst sich einfach erneut senden.
*/
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskReorderSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const columns = parsed.data.columns as Partial<Record<TaskStatus, string[]>>;
const requestedIds = Object.values(columns).flat();
// Nur Aufgaben anfassen, die wirklich zu diesem Projekt gehoeren – sonst
// liessen sich fremde Aufgaben ueber untergeschobene IDs verschieben.
const owned = await prisma.task.findMany({
where: { projectId: id, id: { in: requestedIds } },
select: { id: true, status: true },
});
const ownedIds = new Set(owned.map((task) => task.id));
const previousStatus = new Map(owned.map((task) => [task.id, task.status]));
const now = new Date();
const updates: Prisma.PrismaPromise<unknown>[] = [];
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);
updates.push(
prisma.task.update({
where: { id: taskId },
data: {
status,
position: index,
// doneAt nur beim tatsaechlichen Wechsel setzen oder loeschen,
// damit ein Umsortieren innerhalb von "Erledigt" das Datum nicht
// immer wieder erneuert.
...(before !== status
? { doneAt: status === "DONE" ? now : null }
: {}),
},
}),
);
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await refreshDerivedProgress(id);
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { position: "asc" }],
});
return NextResponse.json({
tasks,
updated: updates.length,
ignored: requestedIds.length - updates.length,
});
}
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, ownedProject, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { position: "asc" }],
});
return NextResponse.json({ tasks });
}
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { title, description, status, dueDate, labels } = parsed.data;
const task = await prisma.task.create({
data: {
projectId: id,
title,
description: description ?? null,
status,
// Neue Aufgaben landen unten in ihrer Spalte.
position: await nextPosition(id, status),
dueDate: dueDate ? new Date(dueDate) : null,
labels,
doneAt: status === "DONE" ? new Date() : null,
},
});
await refreshDerivedProgress(id);
// Damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
return NextResponse.json({ task }, { status: 201 });
}
+20 -1
View File
@@ -159,8 +159,25 @@ export async function POST(request: Request) {
repoOwner: incoming.repoOwner ?? null,
repoName: incoming.repoName ?? null,
connectionId: connection?.id ?? null,
progressFromTasks: incoming.progressFromTasks,
};
const tasks = incoming.tasks.map((task, index) => ({
title: task.title,
description: task.description ?? null,
status: task.status,
// Position aus der Datei uebernehmen, notfalls die Reihenfolge in ihr.
position: task.position ?? index,
dueDate: task.dueDate && !Number.isNaN(Date.parse(task.dueDate))
? new Date(task.dueDate)
: null,
labels: task.labels,
doneAt: task.status === "DONE" ? new Date() : null,
...(task.createdAt && !Number.isNaN(Date.parse(task.createdAt))
? { createdAt: new Date(task.createdAt) }
: {}),
}));
const notes = incoming.notes.map((note) => ({
title: note.title ?? null,
content: note.content,
@@ -176,9 +193,10 @@ export async function POST(request: Request) {
// erneuter Import erzeugt keine Dubletten.
await prisma.$transaction([
prisma.note.deleteMany({ where: { projectId: existing.id } }),
prisma.task.deleteMany({ where: { projectId: existing.id } }),
prisma.project.update({
where: { id: existing.id },
data: { ...data, slug, notes: { create: notes } },
data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } },
}),
]);
results.push({ ...base, action: "overwrite" });
@@ -190,6 +208,7 @@ export async function POST(request: Request) {
ownerId: auth.user.id,
slug: finalSlug,
notes: { create: notes },
tasks: { create: tasks },
},
});
results.push({
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Eine Aufgabe gehoert dem, dem das zugehoerige Projekt gehoert. */
async function ownedTask(taskId: string, ownerId: string) {
return prisma.task.findFirst({
where: { id: taskId, project: { ownerId } },
select: { id: true, projectId: true, status: true },
});
}
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await ownedTask(id, auth.user.id);
if (!existing) return jsonError("Aufgabe nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
const update: Record<string, unknown> = {};
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.dueDate !== undefined) {
update.dueDate = data.dueDate ? new Date(data.dueDate) : null;
}
if (data.status !== undefined && data.status !== existing.status) {
update.status = data.status;
// Beim Spaltenwechsel ans Ende der neuen Spalte.
update.position = await nextPosition(existing.projectId, data.status);
// doneAt nur setzen, wenn die Aufgabe wirklich nach DONE wandert.
update.doneAt = data.status === "DONE" ? new Date() : null;
}
const task = await prisma.task.update({ where: { id }, data: update });
await refreshDerivedProgress(existing.projectId);
return NextResponse.json({ task });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await ownedTask(id, auth.user.id);
if (!existing) return jsonError("Aufgabe nicht gefunden", 404);
await prisma.task.delete({ where: { id } });
await refreshDerivedProgress(existing.projectId);
return NextResponse.json({ ok: true });
}
+7
View File
@@ -5,6 +5,7 @@ import { GitPanel } from "@/components/GitPanel";
import { Markdown } from "@/components/Markdown";
import { NotesPanel } from "@/components/NotesPanel";
import { ProjectHeader } from "@/components/ProjectHeader";
import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard";
import { getSessionUser } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
@@ -59,6 +60,12 @@ export default async function ProjectPage({ params }: PageProps) {
connections={connections as unknown as ConnectionSafe[]}
/>
<TaskBoard
projectId={detail.id}
tasks={detail.tasks}
progressFromTasks={detail.progressFromTasks}
/>
<div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-5">
<section className="glass p-5">
+11
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useState } from "react";
import {
AlertTriangle,
CheckSquare,
GitBranch,
GitCommitHorizontal,
NotebookPen,
@@ -155,6 +156,16 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</span>
) : null}
{project._count.tasks > 0 ? (
<span
className="inline-flex items-center gap-1.5"
title={`${project.tasks.length} von ${project._count.tasks} Aufgaben erledigt`}
>
<CheckSquare className="h-3.5 w-3.5 text-slate-500" />
{project.tasks.length}/{project._count.tasks}
</span>
) : null}
{project._count.notes > 0 ? (
<span className="inline-flex items-center gap-1.5">
<NotebookPen className="h-3.5 w-3.5 text-slate-500" />
+21 -1
View File
@@ -32,6 +32,7 @@ interface FormState {
repoOwner: string;
repoName: string;
autoSync: boolean;
progressFromTasks: boolean;
}
const EMPTY: FormState = {
@@ -49,6 +50,7 @@ const EMPTY: FormState = {
repoOwner: "",
repoName: "",
autoSync: true,
progressFromTasks: false,
};
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
@@ -90,6 +92,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoOwner: project.repoOwner ?? "",
repoName: project.repoName ?? "",
autoSync: project.autoSync,
progressFromTasks: project.progressFromTasks,
}
: EMPTY,
);
@@ -158,6 +161,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoOwner: form.repoOwner,
repoName: form.repoName,
autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks,
};
try {
@@ -291,9 +295,25 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
step={5}
value={form.progress}
onChange={(event) => update("progress", Number(event.target.value))}
className="range"
disabled={form.progressFromTasks}
className={cn("range", form.progressFromTasks && "cursor-not-allowed opacity-40")}
style={{ "--fill": `${form.progress}%` } as React.CSSProperties}
/>
<label className="mt-2 flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={form.progressFromTasks}
onChange={(event) => update("progressFromTasks", event.target.checked)}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Aus Aufgaben berechnen
<span className="mt-0.5 block text-[11px] text-slate-600">
Der Fortschritt ergibt sich dann aus dem Anteil erledigter Aufgaben und
laesst sich nicht mehr von Hand schieben.
</span>
</span>
</label>
</div>
<div className="sm:col-span-2">
+15 -2
View File
@@ -6,6 +6,7 @@ import Link from "next/link";
import {
ArrowLeft,
CalendarDays,
CheckSquare,
Download,
ExternalLink,
FileJson,
@@ -207,7 +208,15 @@ export function ProjectHeader({
{/* Fortschritt direkt regelbar */}
<div className="mt-6">
<div className="mb-2 flex items-center justify-between text-xs text-slate-500">
<span>Fortschritt</span>
<span className="flex items-center gap-1.5">
Fortschritt
{project.progressFromTasks ? (
<span className="inline-flex items-center gap-1 rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400">
<CheckSquare className="h-2.5 w-2.5" />
aus Aufgaben
</span>
) : null}
</span>
<span className="tabular-nums text-slate-300">{progress}%</span>
</div>
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
@@ -223,7 +232,11 @@ export function ProjectHeader({
onTouchEnd={() => patch({ progress })}
onKeyUp={() => patch({ progress })}
aria-label="Fortschritt anpassen"
className="range"
// Wird der Wert aus Aufgaben abgeleitet, waere ein Zug hier
// wirkungslos – er wuerde beim naechsten Aufgabenwechsel wieder
// ueberschrieben.
disabled={project.progressFromTasks}
className={cn("range", project.progressFromTasks && "cursor-not-allowed opacity-50")}
style={{ "--fill": `${progress}%` } as React.CSSProperties}
/>
</div>
+329
View File
@@ -0,0 +1,329 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
pointerWithin,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CheckSquare, Loader2, Plus } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client";
import { TaskCard } from "@/components/tasks/TaskCard";
import { TaskDialog } from "@/components/tasks/TaskDialog";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn } from "@/lib/utils";
type Columns = Record<TaskStatus, Task[]>;
function group(tasks: Task[]): Columns {
const columns = { TODO: [], DOING: [], BLOCKED: [], DONE: [] } as Columns;
for (const task of [...tasks].sort((a, b) => a.position - b.position)) {
columns[task.status].push(task);
}
return columns;
}
/**
* Aufgabenbrett eines Projekts.
*
* Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte
* hier bedeutsam, deshalb @dnd-kit/sortable statt einfacher Ablageflaechen.
* Waehrend des Ziehens fuehrt der oertliche Zustand, danach wird die neue
* Ordnung als Ganzes gespeichert: je Spalte die vollstaendige Liste. Das ist
* wiederholbar und kommt ohne Nachrechnen von Positionen im Browser aus.
*/
export function TaskBoard({
projectId,
tasks,
progressFromTasks,
}: {
projectId: string;
tasks: Task[];
progressFromTasks: boolean;
}) {
const router = useRouter();
const [columns, setColumns] = useState<Columns>(() => group(tasks));
const [activeId, setActiveId] = useState<string | null>(null);
const [editing, setEditing] = useState<Task | null>(null);
const [creatingIn, setCreatingIn] = useState<TaskStatus | null>(null);
const [saving, setSaving] = useState(false);
const dragging = useRef(false);
// Serverstand uebernehmen, solange nicht gerade gezogen wird – sonst
// wuerde die Karte mitten in der Bewegung zurueckspringen.
useEffect(() => {
if (!dragging.current) setColumns(group(tasks));
}, [tasks]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
);
const allTasks = useMemo(() => Object.values(columns).flat(), [columns]);
const active = activeId ? allTasks.find((task) => task.id === activeId) ?? null : null;
const total = allTasks.length;
const done = columns.DONE.length;
function containerOf(id: string): TaskStatus | null {
if (TASK_STATUS_ORDER.includes(id as TaskStatus)) return id as TaskStatus;
for (const status of TASK_STATUS_ORDER) {
if (columns[status].some((task) => task.id === id)) return status;
}
return null;
}
function onDragStart(event: DragStartEvent) {
dragging.current = true;
setActiveId(String(event.active.id));
}
/** Wechsel der Spalte passiert schon beim Darueberziehen, nicht erst beim Ablegen. */
function onDragOver(event: DragOverEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
if (!overKey) return;
const from = containerOf(activeKey);
const to = containerOf(overKey);
if (!from || !to || from === to) return;
setColumns((current) => {
const moved = current[from].find((task) => task.id === activeKey);
if (!moved) return current;
const targetIndex = current[to].findIndex((task) => task.id === overKey);
const insertAt = targetIndex >= 0 ? targetIndex : current[to].length;
return {
...current,
[from]: current[from].filter((task) => task.id !== activeKey),
[to]: [
...current[to].slice(0, insertAt),
{ ...moved, status: to },
...current[to].slice(insertAt),
],
};
});
}
async function onDragEnd(event: DragEndEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
let next = columns;
if (overKey) {
const container = containerOf(activeKey);
const overContainer = containerOf(overKey);
if (container && container === overContainer) {
const list = columns[container];
const oldIndex = list.findIndex((task) => task.id === activeKey);
const newIndex = list.findIndex((task) => task.id === overKey);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const reordered = [...list];
const [moved] = reordered.splice(oldIndex, 1);
reordered.splice(newIndex, 0, moved);
next = { ...columns, [container]: reordered };
setColumns(next);
}
}
}
setActiveId(null);
dragging.current = false;
await persist(next);
}
async function persist(next: Columns) {
setSaving(true);
try {
const payload = {
columns: Object.fromEntries(
TASK_STATUS_ORDER.map((status) => [status, next[status].map((task) => task.id)]),
),
};
const response = await fetch(`/api/projects/${projectId}/tasks/reorder`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("failed");
router.refresh();
} catch {
// Zurueck auf den Serverstand – die Bewegung war dann nur optisch.
setColumns(group(tasks));
} finally {
setSaving(false);
}
}
return (
<section className="glass p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
<CheckSquare className="h-4 w-4" />
</span>
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-200">
Aufgaben
{total > 0 ? (
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{done}/{total}
</span>
) : null}
{saving ? <Loader2 className="h-3 w-3 animate-spin text-slate-500" /> : null}
</h2>
<p className="mt-0.5 text-[11px] text-slate-500">
{progressFromTasks
? "Der Fortschritt des Projekts folgt diesen Aufgaben."
: "Ziehen zum Verschieben und Sortieren."}
</p>
</div>
</div>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={() => setCreatingIn("TODO")}
>
<Plus className="h-3.5 w-3.5" />
Aufgabe
</button>
</div>
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
modifiers={[restrictToWindowEdges]}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDragCancel={() => {
setActiveId(null);
dragging.current = false;
setColumns(group(tasks));
}}
>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{TASK_STATUS_ORDER.map((status) => (
<TaskColumn
key={status}
status={status}
tasks={columns[status]}
onAdd={() => setCreatingIn(status)}
onEdit={setEditing}
/>
))}
</div>
<DragOverlay dropAnimation={{ duration: 180, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }}>
{active ? <TaskCard task={active} overlay /> : null}
</DragOverlay>
</DndContext>
<TaskDialog
open={creatingIn !== null || editing !== null}
projectId={projectId}
task={editing}
initialStatus={creatingIn ?? "TODO"}
onClose={() => {
setCreatingIn(null);
setEditing(null);
}}
/>
</section>
);
}
function TaskColumn({
status,
tasks,
onAdd,
onEdit,
}: {
status: TaskStatus;
tasks: Task[];
onAdd: () => void;
onEdit: (task: Task) => void;
}) {
const meta = TASK_STATUS_META[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<div
ref={setNodeRef}
data-task-column={status}
className={cn(
"flex min-h-[8rem] flex-col rounded-xl border border-white/6 bg-black/20 p-2 transition-colors",
isOver && "border-violet-400/40 bg-violet-500/6",
)}
>
<div className="mb-2 flex items-center gap-2 px-1">
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color }}
aria-hidden="true"
/>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
{meta.label}
</h3>
<span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span>
</div>
<SortableContext
items={tasks.map((task) => task.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-1 flex-col gap-1.5">
{tasks.map((task) => (
<SortableTask key={task.id} task={task} onEdit={onEdit} />
))}
{tasks.length === 0 ? (
<p className="rounded-lg border border-dashed border-white/8 px-2 py-4 text-center text-[10px] text-slate-600">
{isOver ? "Hier ablegen" : "leer"}
</p>
) : null}
</div>
</SortableContext>
<button
type="button"
onClick={onAdd}
className="mt-1.5 flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-[11px] text-slate-600 transition-colors hover:bg-white/5 hover:text-slate-300"
>
<Plus className="h-3 w-3" />
hinzufuegen
</button>
</div>
);
}
function SortableTask({ task, onEdit }: { task: Task; onEdit: (task: Task) => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id,
});
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Translate.toString(transform), transition }}
className={cn(isDragging && "opacity-30")}
>
<TaskCard task={task} onEdit={onEdit} dragHandle={{ ...attributes, ...listeners }} />
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { AlignLeft, CalendarClock, GripVertical } from "lucide-react";
import type { Task } from "@prisma/client";
import { cn, formatDate } from "@/lib/utils";
/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */
function dueTone(due: Date | string | null, done: boolean) {
if (!due || done) return null;
const date = new Date(due);
if (Number.isNaN(date.getTime())) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const days = Math.round((target.getTime() - today.getTime()) / 86_400_000);
if (days < 0) return { className: "text-rose-300", label: "ueberfaellig" };
if (days === 0) return { className: "text-amber-300", label: "heute" };
if (days <= 3) return { className: "text-amber-200/70", label: null };
return { className: "text-slate-500", label: null };
}
export function TaskCard({
task,
onEdit,
dragHandle,
overlay = false,
}: {
task: Task;
onEdit?: (task: Task) => void;
dragHandle?: Record<string, unknown>;
overlay?: boolean;
}) {
const done = task.status === "DONE";
const due = dueTone(task.dueDate, done);
return (
<article
className={cn(
"group rounded-lg border border-white/8 bg-[#0b0d1c] p-2 transition-colors",
!overlay && "hover:border-white/16",
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
)}
>
<div className="flex items-start gap-1.5">
<button
type="button"
className="mt-0.5 shrink-0 cursor-grab touch-none rounded p-0.5 text-slate-700 transition-colors hover:text-slate-400 active:cursor-grabbing"
aria-label={`${task.title} verschieben`}
{...dragHandle}
>
<GripVertical className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => onEdit?.(task)}
disabled={!onEdit}
className="min-w-0 flex-1 text-left"
>
<p
className={cn(
"break-words text-xs leading-snug text-slate-200",
done && "text-slate-500 line-through",
)}
>
{task.title}
</p>
{(task.description || task.dueDate || 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.dueDate ? (
<span className={cn("inline-flex items-center gap-1", due?.className)}>
<CalendarClock className="h-2.5 w-2.5" />
{due?.label ?? formatDate(task.dueDate)}
</span>
) : null}
{task.labels.slice(0, 3).map((label) => (
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-slate-400">
{label}
</span>
))}
</div>
)}
</button>
</div>
</article>
);
}
+233
View File
@@ -0,0 +1,233 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn, parseTags } from "@/lib/utils";
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
function toDateInput(value: Date | string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
}
export function TaskDialog({
open,
projectId,
task,
initialStatus,
onClose,
}: {
open: boolean;
projectId: string;
task: Task | null;
initialStatus: TaskStatus;
onClose: () => void;
}) {
const router = useRouter();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<TaskStatus>(initialStatus);
const [dueDate, setDueDate] = useState("");
const [labels, setLabels] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = Boolean(task);
useEffect(() => {
if (!open) return;
setTitle(task?.title ?? "");
setDescription(task?.description ?? "");
setStatus(task?.status ?? initialStatus);
setDueDate(toDateInput(task?.dueDate ?? null));
setLabels(task?.labels.join(", ") ?? "");
setError(null);
}, [open, task, initialStatus]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
const body = {
title,
description,
status,
// Ohne Uhrzeit auf Mittag legen, damit Zeitzonenverschiebungen das
// Datum nicht auf den Vortag ziehen.
dueDate: dueDate ? `${dueDate}T12:00:00` : null,
labels: parseTags(labels),
};
try {
const response = await fetch(
isEdit ? `/api/tasks/${task?.id}` : `/api/projects/${projectId}/tasks`,
{
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
async function remove() {
if (!task) return;
if (!window.confirm(`Aufgabe „${task.title}“ loeschen?`)) return;
setBusy(true);
try {
const response = await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
if (!response.ok) throw new Error("Loeschen fehlgeschlagen.");
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title={isEdit ? "Aufgabe bearbeiten" : "Neue Aufgabe"}
description={isEdit ? undefined : "Titel genuegt – alles andere ist optional."}
>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="field-label" htmlFor="task-title">
Titel *
</label>
<input
id="task-title"
className="field"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
maxLength={200}
autoFocus
/>
</div>
<div>
<span className="field-label">Spalte</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{TASK_STATUS_ORDER.map((value) => {
const meta = TASK_STATUS_META[value];
return (
<button
key={value}
type="button"
onClick={() => setStatus(value)}
className={cn(
"rounded-lg border px-2 py-2 text-xs transition-all",
status === value
? "border-violet-400/60 bg-violet-500/12 text-white"
: "border-white/9 text-slate-400 hover:border-white/20",
)}
>
<span className="flex items-center justify-center gap-1.5">
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color }}
/>
{meta.label}
</span>
</button>
);
})}
</div>
</div>
<div>
<label className="field-label" htmlFor="task-description">
Beschreibung
</label>
<textarea
id="task-description"
className="field min-h-24 resize-y font-mono text-[13px]"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Markdown wird dargestellt."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="task-due">
Faellig am
</label>
<input
id="task-due"
className="field"
type="date"
value={dueDate}
onChange={(event) => setDueDate(event.target.value)}
/>
</div>
<div>
<label className="field-label" htmlFor="task-labels">
Labels (kommagetrennt)
</label>
<input
id="task-labels"
className="field"
value={labels}
onChange={(event) => setLabels(event.target.value)}
placeholder="bug, ui"
/>
</div>
</div>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex items-center justify-between gap-2 pt-1">
{isEdit ? (
<button
type="button"
className="btn btn-danger px-3 py-1.5 text-xs"
onClick={remove}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
Loeschen
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy || !title.trim()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEdit ? "Speichern" : "Anlegen"}
</button>
</div>
</div>
</form>
</Modal>
);
}
+6 -1
View File
@@ -12,11 +12,16 @@ function trimmed(value: string | undefined): string | null {
return result ? result : null;
}
import packageJson from "../../package.json";
/**
* Version aus der package.json – eine einzige Quelle. Vorher stand sie
* zusaetzlich in der Fusszeile und lief prompt auseinander.
*
* Bewusst als Standardimport: ein benannter Import aus einer JSON-Datei
* loest in Webpack eine Warnung aus.
*/
export { version as APP_VERSION } from "../../package.json";
export const APP_VERSION: string = packageJson.version;
/** Anzeigename der Instanz – Seitentitel, Kopfzeile, Passkey-Dialog. */
export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula";
+12
View File
@@ -27,6 +27,16 @@ export const exportedNoteSchema = z.object({
createdAt: z.string().optional(),
});
export const exportedTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(5000).nullable().optional(),
status: z.enum(["TODO", "DOING", "BLOCKED", "DONE"]).optional().default("TODO"),
position: z.number().int().min(0).optional().default(0),
dueDate: z.string().nullable().optional(),
labels: z.array(z.string().max(30)).max(8).optional().default([]),
createdAt: z.string().optional(),
});
export const exportedConnectionSchema = z.object({
name: z.string().max(80),
provider: z.enum(PROVIDERS),
@@ -51,6 +61,8 @@ export const exportedProjectSchema = z.object({
/** Nur zur Wiedererkennung – ohne Token. */
connection: exportedConnectionSchema.nullable().optional(),
notes: z.array(exportedNoteSchema).max(500).optional().default([]),
tasks: z.array(exportedTaskSchema).max(1000).optional().default([]),
progressFromTasks: z.boolean().optional().default(false),
createdAt: z.string().optional(),
});
+36 -2
View File
@@ -1,12 +1,13 @@
import type { GitConnection, Note, Project, RepoSnapshot } from "@prisma/client";
import type { GitConnection, Note, Project, RepoSnapshot, Task } from "@prisma/client";
import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db";
import { PRIORITY_META, statusOf } from "@/lib/status";
import { formatDateTime } from "@/lib/utils";
import { formatDate, formatDateTime } from "@/lib/utils";
import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
type ProjectForExport = Project & {
notes: Note[];
tasks: Task[];
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
};
@@ -40,6 +41,16 @@ export function toExportedProject(project: ProjectForExport): ExportedProject {
pinned: note.pinned,
createdAt: note.createdAt.toISOString(),
})),
tasks: project.tasks.map((task) => ({
title: task.title,
description: task.description,
status: task.status,
position: task.position,
dueDate: task.dueDate?.toISOString() ?? null,
labels: task.labels,
createdAt: task.createdAt.toISOString(),
})),
progressFromTasks: project.progressFromTasks,
createdAt: project.createdAt.toISOString(),
};
}
@@ -63,6 +74,7 @@ export async function loadProjectsForExport(
where: { ownerId, ...(projectId ? { id: projectId } : {}) },
include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } },
},
orderBy: { name: "asc" },
@@ -77,6 +89,13 @@ const PROVIDER_LABEL: Record<string, string> = {
GITEA: "Gitea",
};
const TASK_LABEL: Record<string, string> = {
TODO: "Offen",
DOING: "In Arbeit",
BLOCKED: "Blockiert",
DONE: "Erledigt",
};
/**
* Ein Projekt als lesbares Markdown – zum Archivieren oder als Grundlage
* fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die
@@ -131,6 +150,21 @@ export function toMarkdown(
lines.push("");
}
if (project.tasks.length) {
lines.push("## Aufgaben", "");
for (const status of ["TODO", "DOING", "BLOCKED", "DONE"] as const) {
const inColumn = project.tasks.filter((task) => task.status === status);
if (!inColumn.length) continue;
lines.push(`### ${TASK_LABEL[status]}`, "");
for (const task of inColumn) {
const box = status === "DONE" ? "[x]" : "[ ]";
const due = task.dueDate ? ` _(faellig ${formatDate(task.dueDate)})_` : "";
lines.push(`- ${box} ${task.title}${due}`);
}
lines.push("");
}
}
if (project.notes.length) {
lines.push("## Notizen", "");
for (const note of project.notes) {
+8 -2
View File
@@ -49,7 +49,10 @@ export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
connection: { select: connectionSafeSelect },
snapshot: true,
_count: { select: { notes: true } },
_count: { select: { notes: true, tasks: true } },
// Nur die offenen Aufgaben zaehlen – fuer die Anzeige "3 von 8" auf der
// Karte, ohne die Aufgaben selbst mitzuladen.
tasks: { where: { status: "DONE" }, select: { id: true } },
});
export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
@@ -58,5 +61,8 @@ export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
notes: {
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
},
_count: { select: { notes: true } },
tasks: {
orderBy: [{ status: "asc" }, { position: "asc" }],
},
_count: { select: { notes: true, tasks: true } },
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { TASK_STATUS_META, TASK_STATUS_ORDER, progressFromTasks } from "./tasks";
describe("progressFromTasks", () => {
it("rechnet den Anteil erledigter Aufgaben in Prozent um", () => {
expect(progressFromTasks(4, 1)).toBe(25);
expect(progressFromTasks(8, 3)).toBe(38);
expect(progressFromTasks(3, 3)).toBe(100);
expect(progressFromTasks(5, 0)).toBe(0);
});
it("gibt null zurueck, wenn es nichts zu rechnen gibt", () => {
// Ohne Aufgaben bleibt der von Hand gesetzte Wert stehen.
expect(progressFromTasks(0, 0)).toBeNull();
expect(progressFromTasks(-1, 0)).toBeNull();
});
it("rundet kaufmaennisch statt abzuschneiden", () => {
// 1/3 sind 33,33 % und 2/3 sind 66,67 %.
expect(progressFromTasks(3, 1)).toBe(33);
expect(progressFromTasks(3, 2)).toBe(67);
});
});
describe("Spaltenreihenfolge", () => {
it("fuehrt von offen nach erledigt", () => {
expect(TASK_STATUS_ORDER).toEqual(["TODO", "DOING", "BLOCKED", "DONE"]);
});
it("hat zu jeder Spalte eine Beschriftung und eine Farbe", () => {
for (const status of TASK_STATUS_ORDER) {
expect(TASK_STATUS_META[status].label).toBeTruthy();
expect(TASK_STATUS_META[status].color).toMatch(/^#[0-9a-f]{6}$/i);
}
});
});
+86
View File
@@ -0,0 +1,86 @@
import type { TaskStatus } from "@prisma/client";
import { prisma } from "@/lib/db";
/** Spaltenreihenfolge des Aufgabenbretts. */
export const TASK_STATUS_ORDER: TaskStatus[] = ["TODO", "DOING", "BLOCKED", "DONE"];
export const TASK_STATUS_META: Record<
TaskStatus,
{ label: string; color: string; className: string }
> = {
TODO: {
label: "Offen",
color: "#94a3b8",
className: "bg-slate-500/12 text-slate-300 ring-slate-400/25",
},
DOING: {
label: "In Arbeit",
color: "#a78bfa",
className: "bg-violet-500/12 text-violet-200 ring-violet-400/30",
},
BLOCKED: {
label: "Blockiert",
color: "#fb7185",
className: "bg-rose-500/12 text-rose-200 ring-rose-400/30",
},
DONE: {
label: "Erledigt",
color: "#34d399",
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
},
};
/**
* Berechnet den Fortschritt aus dem Verhaeltnis erledigter Aufgaben.
* Ohne Aufgaben gibt es nichts zu berechnen – dann bleibt der Wert, wie er ist.
*/
export function progressFromTasks(total: number, done: number): number | null {
if (total <= 0) return null;
return Math.round((done / total) * 100);
}
/**
* Schreibt den abgeleiteten Fortschritt, falls das Projekt es so eingestellt
* hat. Wird nach jeder Aenderung an Aufgaben aufgerufen.
*/
export async function refreshDerivedProgress(projectId: string): Promise<void> {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { progressFromTasks: true },
});
if (!project?.progressFromTasks) return;
const [total, done] = await Promise.all([
prisma.task.count({ where: { projectId } }),
prisma.task.count({ where: { projectId, status: "DONE" } }),
]);
const value = progressFromTasks(total, done);
if (value === null) return;
await prisma.project.update({
where: { id: projectId },
data: { progress: value },
});
}
/** Naechste freie Position am Ende einer Spalte. */
export async function nextPosition(
projectId: string,
status: TaskStatus,
): Promise<number> {
const last = await prisma.task.findFirst({
where: { projectId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
/** Prueft Existenz und Besitz in einem Zug. */
export async function ownedProject(projectId: string, ownerId: string) {
return prisma.project.findFirst({
where: { id: projectId, ownerId },
select: { id: true },
});
}
+37
View File
@@ -23,10 +23,47 @@ export const projectCreateSchema = z.object({
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
/** Am automatischen Abgleich teilnehmen. */
autoSync: z.boolean().default(true),
/** Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen. */
progressFromTasks: z.boolean().default(false),
});
export const projectUpdateSchema = projectCreateSchema.partial();
// ── Aufgaben ─────────────────────────────────────────────────
const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const;
const dueDateField = z.preprocess(
emptyToNull,
z
.string()
.trim()
.refine((value) => !Number.isNaN(Date.parse(value)), "Kein gueltiges Datum")
.nullable()
.optional(),
);
export const taskCreateSchema = z.object({
title: z.string().trim().min(1, "Titel darf nicht leer sein").max(200),
description: z.preprocess(emptyToNull, z.string().trim().max(5000).nullable().optional()),
status: z.enum(TASK_STATUS).default("TODO"),
dueDate: dueDateField,
labels: z.array(z.string().trim().min(1).max(30)).max(8).default([]),
});
export const taskUpdateSchema = taskCreateSchema.partial();
/**
* Neue Reihenfolge nach dem Ziehen. Uebergeben wird je betroffener Spalte
* die vollstaendige Liste der Aufgaben in ihrer neuen Reihenfolge – daraus
* ergeben sich Status und Position eindeutig.
*/
export const taskReorderSchema = z.object({
columns: z
.record(z.enum(TASK_STATUS), z.array(z.string().min(1)).max(500))
.refine((value) => Object.keys(value).length > 0, "Keine Spalte angegeben"),
});
export const noteCreateSchema = z.object({
title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()),
content: z.string().trim().min(1, "Notiz darf nicht leer sein").max(20000),
+16 -3
View File
@@ -1,4 +1,12 @@
import type { GitProvider, Note, Project, ProjectStatus, RepoSnapshot } from "@prisma/client";
import type {
GitProvider,
Note,
Project,
ProjectStatus,
RepoSnapshot,
Task,
TaskStatus,
} from "@prisma/client";
import type {
BranchInfo,
CiInfo,
@@ -30,14 +38,17 @@ export interface ConnectionWithCount extends ConnectionSafe {
export interface ProjectListItem extends Project {
connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null;
_count: { notes: number };
_count: { notes: number; tasks: number };
/** Nur die erledigten – siehe projectListInclude. */
tasks: { id: string }[];
}
export interface ProjectDetail extends Project {
connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null;
notes: Note[];
_count: { notes: number };
tasks: Task[];
_count: { notes: number; tasks: number };
}
export interface RepoListEntry {
@@ -60,6 +71,8 @@ export type {
ReleaseInfo,
RepoSnapshot,
TagInfo,
Task,
TaskStatus,
TicketInfo,
};