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>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user