diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts index ec25a7a..8f9423d 100644 --- a/src/app/api/admin/backup/route.ts +++ b/src/app/api/admin/backup/route.ts @@ -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" }, diff --git a/src/app/api/projects/[id]/export/route.ts b/src/app/api/projects/[id]/export/route.ts index d4b939e..67b71a9 100644 --- a/src/app/api/projects/[id]/export/route.ts +++ b/src/app/api/projects/[id]/export/route.ts @@ -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, }, diff --git a/src/app/api/projects/import/route.ts b/src/app/api/projects/import/route.ts index a78eb35..104a6bd 100644 --- a/src/app/api/projects/import/route.ts +++ b/src/app/api/projects/import/route.ts @@ -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({ diff --git a/src/lib/export/format.ts b/src/lib/export/format.ts index cb8c7d9..674f9f4 100644 --- a/src/lib/export/format.ts +++ b/src/lib/export/format.ts @@ -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(), }); diff --git a/src/lib/export/project.ts b/src/lib/export/project.ts index 9c44344..1816a70 100644 --- a/src/lib/export/project.ts +++ b/src/lib/export/project.ts @@ -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 | 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 = { GITEA: "Gitea", }; +const TASK_LABEL: Record = { + 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) {