4 Commits
Author SHA1 Message Date
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
24 changed files with 1274 additions and 13 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "0.4.0", "version": "0.5.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": {
@@ -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 GITEA
} }
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus {
TODO
DOING
BLOCKED
DONE
}
/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat /// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat
/// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart. /// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart.
enum UserRole { enum UserRole {
@@ -237,7 +245,12 @@ model Project {
ownerId String? ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) 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[] notes Note[]
tasks Task[]
snapshot RepoSnapshot? snapshot RepoSnapshot?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -250,6 +263,30 @@ model Project {
@@index([ownerId]) @@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. /// Freie Notizen zu einem Projekt.
model Note { model Note {
id String @id @default(cuid()) id String @id @default(cuid())
+1
View File
@@ -56,6 +56,7 @@ export async function GET() {
prisma.project.findMany({ prisma.project.findMany({
include: { include: {
notes: { orderBy: { createdAt: "asc" } }, notes: { orderBy: { createdAt: "asc" } },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
// Snapshots bleiben draussen: reproduzierbar und gross. // Snapshots bleiben draussen: reproduzierbar und gross.
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -25,6 +25,7 @@ export async function GET(request: Request, { params }: Params) {
where: { id, ownerId: auth.user.id }, where: { id, ownerId: auth.user.id },
include: { include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } }, connection: { select: { name: true, provider: true, baseUrl: true } },
snapshot: true, snapshot: true,
}, },
@@ -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, repoOwner: incoming.repoOwner ?? null,
repoName: incoming.repoName ?? null, repoName: incoming.repoName ?? null,
connectionId: connection?.id ?? 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) => ({ const notes = incoming.notes.map((note) => ({
title: note.title ?? null, title: note.title ?? null,
content: note.content, content: note.content,
@@ -176,9 +193,10 @@ export async function POST(request: Request) {
// erneuter Import erzeugt keine Dubletten. // erneuter Import erzeugt keine Dubletten.
await prisma.$transaction([ await prisma.$transaction([
prisma.note.deleteMany({ where: { projectId: existing.id } }), prisma.note.deleteMany({ where: { projectId: existing.id } }),
prisma.task.deleteMany({ where: { projectId: existing.id } }),
prisma.project.update({ prisma.project.update({
where: { id: existing.id }, where: { id: existing.id },
data: { ...data, slug, notes: { create: notes } }, data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } },
}), }),
]); ]);
results.push({ ...base, action: "overwrite" }); results.push({ ...base, action: "overwrite" });
@@ -190,6 +208,7 @@ export async function POST(request: Request) {
ownerId: auth.user.id, ownerId: auth.user.id,
slug: finalSlug, slug: finalSlug,
notes: { create: notes }, notes: { create: notes },
tasks: { create: tasks },
}, },
}); });
results.push({ 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 { Markdown } from "@/components/Markdown";
import { NotesPanel } from "@/components/NotesPanel"; import { NotesPanel } from "@/components/NotesPanel";
import { ProjectHeader } from "@/components/ProjectHeader"; import { ProjectHeader } from "@/components/ProjectHeader";
import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard"; import { requireUserPage } from "@/lib/auth/guard";
import { getSessionUser } from "@/lib/auth/session"; import { getSessionUser } from "@/lib/auth/session";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -59,6 +60,12 @@ export default async function ProjectPage({ params }: PageProps) {
connections={connections as unknown as ConnectionSafe[]} 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="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-5"> <div className="space-y-6 lg:col-span-5">
<section className="glass p-5"> <section className="glass p-5">
+11
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { import {
AlertTriangle, AlertTriangle,
CheckSquare,
GitBranch, GitBranch,
GitCommitHorizontal, GitCommitHorizontal,
NotebookPen, NotebookPen,
@@ -155,6 +156,16 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</span> </span>
) : null} ) : 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 ? ( {project._count.notes > 0 ? (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<NotebookPen className="h-3.5 w-3.5 text-slate-500" /> <NotebookPen className="h-3.5 w-3.5 text-slate-500" />
+21 -1
View File
@@ -32,6 +32,7 @@ interface FormState {
repoOwner: string; repoOwner: string;
repoName: string; repoName: string;
autoSync: boolean; autoSync: boolean;
progressFromTasks: boolean;
} }
const EMPTY: FormState = { const EMPTY: FormState = {
@@ -49,6 +50,7 @@ const EMPTY: FormState = {
repoOwner: "", repoOwner: "",
repoName: "", repoName: "",
autoSync: true, autoSync: true,
progressFromTasks: false,
}; };
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) { export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
@@ -90,6 +92,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoOwner: project.repoOwner ?? "", repoOwner: project.repoOwner ?? "",
repoName: project.repoName ?? "", repoName: project.repoName ?? "",
autoSync: project.autoSync, autoSync: project.autoSync,
progressFromTasks: project.progressFromTasks,
} }
: EMPTY, : EMPTY,
); );
@@ -158,6 +161,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoOwner: form.repoOwner, repoOwner: form.repoOwner,
repoName: form.repoName, repoName: form.repoName,
autoSync: form.autoSync, autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks,
}; };
try { try {
@@ -291,9 +295,25 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
step={5} step={5}
value={form.progress} value={form.progress}
onChange={(event) => update("progress", Number(event.target.value))} 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} 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>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
+15 -2
View File
@@ -6,6 +6,7 @@ import Link from "next/link";
import { import {
ArrowLeft, ArrowLeft,
CalendarDays, CalendarDays,
CheckSquare,
Download, Download,
ExternalLink, ExternalLink,
FileJson, FileJson,
@@ -207,7 +208,15 @@ export function ProjectHeader({
{/* Fortschritt direkt regelbar */} {/* Fortschritt direkt regelbar */}
<div className="mt-6"> <div className="mt-6">
<div className="mb-2 flex items-center justify-between text-xs text-slate-500"> <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> <span className="tabular-nums text-slate-300">{progress}%</span>
</div> </div>
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem {/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
@@ -223,7 +232,11 @@ export function ProjectHeader({
onTouchEnd={() => patch({ progress })} onTouchEnd={() => patch({ progress })}
onKeyUp={() => patch({ progress })} onKeyUp={() => patch({ progress })}
aria-label="Fortschritt anpassen" 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} style={{ "--fill": `${progress}%` } as React.CSSProperties}
/> />
</div> </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; return result ? result : null;
} }
import packageJson from "../../package.json";
/** /**
* Version aus der package.json – eine einzige Quelle. Vorher stand sie * Version aus der package.json – eine einzige Quelle. Vorher stand sie
* zusaetzlich in der Fusszeile und lief prompt auseinander. * 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. */ /** Anzeigename der Instanz – Seitentitel, Kopfzeile, Passkey-Dialog. */
export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula"; 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(), 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({ export const exportedConnectionSchema = z.object({
name: z.string().max(80), name: z.string().max(80),
provider: z.enum(PROVIDERS), provider: z.enum(PROVIDERS),
@@ -51,6 +61,8 @@ export const exportedProjectSchema = z.object({
/** Nur zur Wiedererkennung – ohne Token. */ /** Nur zur Wiedererkennung – ohne Token. */
connection: exportedConnectionSchema.nullable().optional(), connection: exportedConnectionSchema.nullable().optional(),
notes: z.array(exportedNoteSchema).max(500).optional().default([]), 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(), 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 { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { PRIORITY_META, statusOf } from "@/lib/status"; 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"; import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
type ProjectForExport = Project & { type ProjectForExport = Project & {
notes: Note[]; notes: Note[];
tasks: Task[];
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null; connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
}; };
@@ -40,6 +41,16 @@ export function toExportedProject(project: ProjectForExport): ExportedProject {
pinned: note.pinned, pinned: note.pinned,
createdAt: note.createdAt.toISOString(), 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(), createdAt: project.createdAt.toISOString(),
}; };
} }
@@ -63,6 +74,7 @@ export async function loadProjectsForExport(
where: { ownerId, ...(projectId ? { id: projectId } : {}) }, where: { ownerId, ...(projectId ? { id: projectId } : {}) },
include: { include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } }, connection: { select: { name: true, provider: true, baseUrl: true } },
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -77,6 +89,13 @@ const PROVIDER_LABEL: Record<string, string> = {
GITEA: "Gitea", 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 * Ein Projekt als lesbares Markdown – zum Archivieren oder als Grundlage
* fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die * fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die
@@ -131,6 +150,21 @@ export function toMarkdown(
lines.push(""); 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) { if (project.notes.length) {
lines.push("## Notizen", ""); lines.push("## Notizen", "");
for (const note of project.notes) { 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>()({ export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
connection: { select: connectionSafeSelect }, connection: { select: connectionSafeSelect },
snapshot: true, 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>()({ export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
@@ -58,5 +61,8 @@ export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
notes: { notes: {
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }], 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()), connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
/** Am automatischen Abgleich teilnehmen. */ /** Am automatischen Abgleich teilnehmen. */
autoSync: z.boolean().default(true), autoSync: z.boolean().default(true),
/** Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen. */
progressFromTasks: z.boolean().default(false),
}); });
export const projectUpdateSchema = projectCreateSchema.partial(); 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({ export const noteCreateSchema = z.object({
title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()), title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()),
content: z.string().trim().min(1, "Notiz darf nicht leer sein").max(20000), 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 { import type {
BranchInfo, BranchInfo,
CiInfo, CiInfo,
@@ -30,14 +38,17 @@ export interface ConnectionWithCount extends ConnectionSafe {
export interface ProjectListItem extends Project { export interface ProjectListItem extends Project {
connection: ConnectionSafe | null; connection: ConnectionSafe | null;
snapshot: RepoSnapshot | 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 { export interface ProjectDetail extends Project {
connection: ConnectionSafe | null; connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null; snapshot: RepoSnapshot | null;
notes: Note[]; notes: Note[];
_count: { notes: number }; tasks: Task[];
_count: { notes: number; tasks: number };
} }
export interface RepoListEntry { export interface RepoListEntry {
@@ -60,6 +71,8 @@ export type {
ReleaseInfo, ReleaseInfo,
RepoSnapshot, RepoSnapshot,
TagInfo, TagInfo,
Task,
TaskStatus,
TicketInfo, TicketInfo,
}; };