feat(projects): Aktivitaetsverlauf je Projekt

Was ist hier eigentlich passiert - eine Zeitleiste auf der Projektseite
beantwortet das jetzt: Statuswechsel, Notizen, Aufgaben, neue Commits.

Der Verlauf soll erzaehlen, nicht protokollieren. Deshalb bekommen Status
und Repo-Verknuepfung einen eigenen Eintrag, waehrend alle uebrigen Felder
zu einem zusammengefasst werden - sonst ertraenkt eine Feinjustage der Farbe
alles Wichtige. Ein Titel, der sich aendert, kommt gar nicht vor.

Beim Abgleich ist Zurueckhaltung noch wichtiger: er laeuft im Zweifel
stuendlich. Vermerkt wird nur, wenn der letzte Commit ein anderer ist als
beim letzten Mal, und beim ersten Abgleich gar nicht - dass ein Repo Commits
hat, ist keine Neuigkeit. Fehlschlaege stehen nur beim Uebergang in den
Fehlerzustand drin, sonst schlaegt ein abgelaufenes Token stuendlich neu auf.

Der Text wird beim Schreiben formuliert, nicht beim Anzeigen. Ein Eintrag
soll auch dann lesbar bleiben, wenn es die Notiz, um die es ging, laengst
nicht mehr gibt.

Zweihundert Eintraege je Projekt bleiben erhalten, der taegliche Durchgang
stutzt den Rest. Der Export nimmt den Verlauf nicht mit - er beschriebe, was
in dieser Instanz passiert ist, und verwiese auf Konten, die es anderswo
nicht gibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 12:36:19 +02:00
co-authored by Claude Opus 5
parent fa5fd78318
commit af562584ad
20 changed files with 772 additions and 13 deletions
@@ -0,0 +1,24 @@
-- CreateEnum
CREATE TYPE "ActivityKind" AS ENUM ('PROJECT_CREATED', 'PROJECT_UPDATED', 'STATUS_CHANGED', 'NOTE_ADDED', 'NOTE_UPDATED', 'NOTE_DELETED', 'TASK_ADDED', 'TASK_MOVED', 'TASK_DELETED', 'TASKS_IMPORTED', 'REPO_LINKED', 'REPO_COMMITS', 'SYNC_FAILED');
-- CreateTable
CREATE TABLE "Activity" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"userId" TEXT,
"kind" "ActivityKind" NOT NULL,
"summary" TEXT NOT NULL,
"meta" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Activity_projectId_createdAt_idx" ON "Activity"("projectId", "createdAt");
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+98 -3
View File
@@ -52,6 +52,25 @@ enum NotificationEvent {
PROJECT_STALE PROJECT_STALE
} }
/// Was im Verlauf eines Projekts festgehalten wird. Bewusst grob: der
/// Verlauf soll erzaehlen, was passiert ist, und nicht jede Feldaenderung
/// mitschreiben.
enum ActivityKind {
PROJECT_CREATED
PROJECT_UPDATED
STATUS_CHANGED
NOTE_ADDED
NOTE_UPDATED
NOTE_DELETED
TASK_ADDED
TASK_MOVED
TASK_DELETED
TASKS_IMPORTED
REPO_LINKED
REPO_COMMITS
SYNC_FAILED
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts. /// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus { enum TaskStatus {
TODO TODO
@@ -93,11 +112,21 @@ model User {
failedLoginCount Int @default(0) failedLoginCount Int @default(0)
lockedUntil DateTime? lockedUntil DateTime?
/// TOTP-Schluessel, AES-256-GCM-verschluesselt (siehe src/lib/crypto.ts).
/// Hinterlegt heisst noch nicht scharf: erst ein bestaetigter Code setzt
/// totpEnabledAt. Bis dahin darf die Anmeldung nicht danach fragen, sonst
/// sperrt sich aus, wer die Einrichtung abbricht.
totpSecretCipher String?
totpEnabledAt DateTime?
sessions Session[] sessions Session[]
credentials Credential[] credentials Credential[]
projects Project[] projects Project[]
connections GitConnection[] connections GitConnection[]
notificationTargets NotificationTarget[] notificationTargets NotificationTarget[]
activities Activity[]
recoveryCodes RecoveryCode[]
pendingLogins PendingLogin[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -142,6 +171,45 @@ model Credential {
@@index([userId]) @@index([userId])
} }
/// Ein Wiederherstellungscode fuer den Fall, dass der Authenticator weg ist.
/// Jeder Code gilt genau einmal.
model RecoveryCode {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// SHA-256 des Codes. Kein langsames Verfahren wie bei Passwoertern: die
/// Codes sind zufaellig erzeugt und lang genug, dass Raten aussichtslos
/// ist – und bei einer Anmeldung waeren sonst zehn teure Vergleiche noetig.
codeHash String @unique
usedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
/// Zwischenzustand einer Anmeldung: Passwort stimmt, der zweite Faktor fehlt
/// noch. Bewusst eine eigene Zeile und keine halbfertige Sitzung – so kann
/// aus diesem Zustand nichts entstehen, was einer Anmeldung aehnelt.
model PendingLogin {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Wie bei der Sitzung liegt nur der SHA-256-Hash des Cookie-Tokens hier.
tokenHash String @unique
/// Fehlversuche. Nach zu vielen wird der Vorgang verworfen, damit die
/// sechs Stellen nicht in Ruhe durchprobiert werden koennen.
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
/// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie /// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie
/// kein zweites Mal verwendet werden kann. /// kein zweites Mal verwendet werden kann.
model WebAuthnChallenge { model WebAuthnChallenge {
@@ -285,9 +353,10 @@ model Project {
/// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde. /// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde.
notifiedStaleAt DateTime? notifiedStaleAt DateTime?
notes Note[] notes Note[]
tasks Task[] tasks Task[]
snapshot RepoSnapshot? snapshot RepoSnapshot?
activities Activity[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -361,6 +430,32 @@ model Task {
@@index([projectId, status, position]) @@index([projectId, status, position])
} }
/// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert.
model Activity {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
/// Wer. null bedeutet: die Anwendung selbst, also der Zeitgeber beim
/// automatischen Abgleich.
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
kind ActivityKind
/// Fertig formulierter Einzeiler. Die Formulierung beim Schreiben
/// festzuhalten spart der Anzeige das Nachschlagen von Namen, die es
/// spaeter womoeglich nicht mehr gibt – etwa den einer geloeschten Notiz.
summary String
/// Zusatzangaben wie { from: "IDEA", to: "DONE" }. Fuer die Anzeige nicht
/// noetig, aber nuetzlich, falls spaeter ausgewertet werden soll.
meta Json?
createdAt DateTime @default(now())
/// Absteigend gelesen, immer je Projekt.
@@index([projectId, createdAt])
}
/// Freie Notizen zu einem Projekt. /// Freie Notizen zu einem Projekt.
model Note { model Note {
id String @id @default(cuid()) id String @id @default(cuid())
+28
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -44,6 +45,18 @@ export async function PATCH(request: Request, { params }: Params) {
}, },
}); });
// Nur Anheften ist keine inhaltliche Aenderung und kommt nicht in den
// Verlauf – sonst steht dort dreimal dieselbe Notiz, weil jemand unschluessig war.
if (parsed.data.title !== undefined || parsed.data.content !== undefined) {
await recordActivity({
projectId: note.projectId,
userId: auth.user.id,
kind: "NOTE_UPDATED",
summary: `Notiz bearbeitet: ${shorten(updated.title || updated.content)}`,
meta: { noteId: id },
});
}
return NextResponse.json({ note: updated }); return NextResponse.json({ note: updated });
} }
@@ -53,10 +66,25 @@ export async function DELETE(_request: Request, { params }: Params) {
const { id } = await params; const { id } = await params;
// Vor dem Loeschen lesen: danach ist nicht mehr feststellbar, worum es ging.
const vorher = await prisma.note.findFirst({
where: { id, project: { ownerId: auth.user.id } },
select: { projectId: true, title: true, content: true },
});
const result = await prisma.note.deleteMany({ const result = await prisma.note.deleteMany({
where: { id, project: { ownerId: auth.user.id } }, where: { id, project: { ownerId: auth.user.id } },
}); });
if (result.count === 0) return jsonError("Notiz nicht gefunden", 404); if (result.count === 0) return jsonError("Notiz nicht gefunden", 404);
if (vorher) {
await recordActivity({
projectId: vorher.projectId,
userId: auth.user.id,
kind: "NOTE_DELETED",
summary: `Notiz gelöscht: ${shorten(vorher.title || vorher.content)}`,
});
}
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { ACTIVITY_LIMIT } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError } from "@/lib/security/request";
import { ownedProject } from "@/lib/tasks";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Der Verlauf eines Projekts, neueste Eintraege zuerst. */
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);
}
// Number(null) waere 0 und damit endlich – ohne die Pruefung auf den
// fehlenden Parameter kaeme genau ein Eintrag zurueck.
const roh = new URL(request.url).searchParams.get("limit");
const angefragt = roh === null ? Number.NaN : Number(roh);
const take = Number.isFinite(angefragt)
? Math.min(Math.max(angefragt, 1), ACTIVITY_LIMIT)
: 30;
const activities = await prisma.activity.findMany({
where: { projectId: id },
orderBy: { createdAt: "desc" },
take,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
user: { select: { username: true, displayName: true } },
},
});
return NextResponse.json({ activities });
}
+9
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -64,5 +65,13 @@ export async function POST(request: Request, { params }: Params) {
// Projekt "anfassen", damit die Sortierung nach Aktivitaet stimmt. // Projekt "anfassen", damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } }); await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "NOTE_ADDED",
summary: `Notiz angelegt: ${shorten(note.title || note.content)}`,
meta: { noteId: note.id },
});
return NextResponse.json({ note }, { status: 201 }); return NextResponse.json({ note }, { status: 201 });
} }
+57
View File
@@ -1,4 +1,6 @@
import type { ProjectStatus } from "@prisma/client";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git"; import { parseRepoUrl } from "@/lib/git";
@@ -98,6 +100,8 @@ export async function PATCH(request: Request, { params }: Params) {
await prisma.project.update({ where: { id }, data: update }); await prisma.project.update({ where: { id }, data: update });
await noteChanges(id, auth.user.id, existing, update);
// Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht // Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht
// erst beim naechsten Aufgabenwechsel. // erst beim naechsten Aufgabenwechsel.
if (data.progressFromTasks === true) { if (data.progressFromTasks === true) {
@@ -112,6 +116,59 @@ export async function PATCH(request: Request, { params }: Params) {
return NextResponse.json({ project }); return NextResponse.json({ project });
} }
/**
* Haelt fest, was sich geaendert hat. Status und Repo-Verknuepfung bekommen
* einen eigenen Eintrag – sie sind die Vorgaenge, die man spaeter sucht. Alles
* uebrige fasst ein Eintrag zusammen, sonst ertraenkt eine Feinjustage der
* Farbe den Verlauf.
*/
async function noteChanges(
projectId: string,
userId: string,
before: { status: ProjectStatus; repoUrl: string | null },
update: Record<string, unknown>,
) {
const rest = Object.keys(update).filter(
(key) => key !== "status" && key !== "slug" && !key.startsWith("repo"),
);
const entries: Parameters<typeof recordActivities>[0] = [];
if (update.status !== undefined && update.status !== before.status) {
const to = update.status as ProjectStatus;
entries.push({
projectId,
userId,
kind: "STATUS_CHANGED",
summary: describeStatus(before.status, to),
meta: { from: before.status, to },
});
}
if (update.repoUrl !== undefined && update.repoUrl !== before.repoUrl) {
const url = update.repoUrl as string | null;
entries.push({
projectId,
userId,
kind: "REPO_LINKED",
summary: url ? `Repository verknüpft: ${shorten(url)}` : "Repository-Verknüpfung entfernt",
meta: { repoUrl: url },
});
}
const felder = describeFields(rest);
if (felder) {
entries.push({
projectId,
userId,
kind: "PROJECT_UPDATED",
summary: felder,
meta: { fields: rest },
});
}
await recordActivities(entries);
}
export async function DELETE(_request: Request, { params }: Params) { export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi(); const auth = await requireUserApi();
if (!auth.ok) return auth.response; if (!auth.ok) return auth.response;
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -97,6 +98,18 @@ export async function POST(request: Request, { params }: Params) {
if (angelegt.length > 0) { if (angelegt.length > 0) {
await refreshDerivedProgress(id); await refreshDerivedProgress(id);
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } }); await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
// Ein Eintrag fuer den Vorgang, nicht einer je Aufgabe: sonst besteht der
// Verlauf nach einer Uebernahme aus nichts anderem mehr.
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "TASKS_IMPORTED",
summary:
angelegt.length === 1
? "1 Issue als Aufgabe übernommen"
: `${angelegt.length} Issues als Aufgaben übernommen`,
meta: { numbers: angelegt.map((task) => task.sourceIssue) },
});
} }
return NextResponse.json({ return NextResponse.json({
+9
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -65,5 +66,13 @@ export async function POST(request: Request, { params }: Params) {
// Damit die Sortierung nach Aktivitaet stimmt. // Damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } }); await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "TASK_ADDED",
summary: `Aufgabe angelegt: ${shorten(task.title, 50)}`,
meta: { taskId: task.id },
});
return NextResponse.json({ task }, { status: 201 }); return NextResponse.json({ task }, { status: 201 });
} }
+11 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { exportFileSchema, type ExportedProject } from "@/lib/export/format"; import { exportFileSchema, type ExportedProject } from "@/lib/export/format";
@@ -202,7 +203,7 @@ export async function POST(request: Request) {
results.push({ ...base, action: "overwrite" }); results.push({ ...base, action: "overwrite" });
} else { } else {
const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug; const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug;
await prisma.project.create({ const angelegt = await prisma.project.create({
data: { data: {
...data, ...data,
ownerId: auth.user.id, ownerId: auth.user.id,
@@ -211,6 +212,15 @@ export async function POST(request: Request) {
tasks: { create: tasks }, tasks: { create: tasks },
}, },
}); });
// Der Verlauf wird nicht mitexportiert – der erste Eintrag hier haelt
// wenigstens fest, woher das Projekt stammt.
await recordActivity({
projectId: angelegt.id,
userId: auth.user.id,
kind: "PROJECT_CREATED",
summary: "Aus einer Exportdatei importiert",
meta: { notes: notes.length, tasks: tasks.length },
});
results.push({ results.push({
...base, ...base,
action: existing ? "rename" : "create", action: existing ? "rename" : "create",
+13
View File
@@ -1,10 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git"; import { parseRepoUrl } from "@/lib/git";
import { projectListInclude, uniqueSlug } from "@/lib/projects"; import { projectListInclude, uniqueSlug } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { statusOf } from "@/lib/status";
import { formatZodError, projectCreateSchema } from "@/lib/validation"; import { formatZodError, projectCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -105,5 +107,16 @@ export async function POST(request: Request) {
include: projectListInclude, include: projectListInclude,
}); });
await recordActivity({
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_CREATED",
summary:
data.status === "IDEA"
? "Als Idee angelegt"
: `Angelegt mit Status „${statusOf(data.status).label}“`,
meta: { status: data.status },
});
return NextResponse.json({ project }, { status: 201 }); return NextResponse.json({ project }, { status: 201 });
} }
+25 -2
View File
@@ -1,8 +1,10 @@
import type { TaskStatus } from "@prisma/client";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, refreshDerivedProgress } from "@/lib/tasks"; import { TASK_STATUS_META, nextPosition, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskUpdateSchema } from "@/lib/validation"; import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -13,7 +15,7 @@ type Params = { params: Promise<{ id: string }> };
async function ownedTask(taskId: string, ownerId: string) { async function ownedTask(taskId: string, ownerId: string) {
return prisma.task.findFirst({ return prisma.task.findFirst({
where: { id: taskId, project: { ownerId } }, where: { id: taskId, project: { ownerId } },
select: { id: true, projectId: true, status: true }, select: { id: true, projectId: true, status: true, title: true },
}); });
} }
@@ -57,6 +59,20 @@ export async function PATCH(request: Request, { params }: Params) {
const task = await prisma.task.update({ where: { id }, data: update }); const task = await prisma.task.update({ where: { id }, data: update });
await refreshDerivedProgress(existing.projectId); await refreshDerivedProgress(existing.projectId);
// Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder
// ein neues Label sind zu kleinteilig, um die Geschichte des Projekts zu
// erzaehlen – und beim Ziehen im Brett ist der Wechsel die eigentliche Tat.
if (update.status !== undefined) {
const nach = update.status as TaskStatus;
await recordActivity({
projectId: existing.projectId,
userId: auth.user.id,
kind: "TASK_MOVED",
summary: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
meta: { taskId: id, from: existing.status, to: nach },
});
}
return NextResponse.json({ task }); return NextResponse.json({ task });
} }
@@ -71,5 +87,12 @@ export async function DELETE(_request: Request, { params }: Params) {
await prisma.task.delete({ where: { id } }); await prisma.task.delete({ where: { id } });
await refreshDerivedProgress(existing.projectId); await refreshDerivedProgress(existing.projectId);
await recordActivity({
projectId: existing.projectId,
userId: auth.user.id,
kind: "TASK_DELETED",
summary: `Aufgabe gelöscht: ${shorten(existing.title, 50)}`,
});
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
+3 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
import { ActivityFeed } from "@/components/ActivityFeed";
import { GitPanel } from "@/components/GitPanel"; 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";
@@ -85,8 +86,9 @@ export default async function ProjectPage({ params }: PageProps) {
<NotesPanel projectId={detail.id} notes={detail.notes} /> <NotesPanel projectId={detail.id} notes={detail.notes} />
</div> </div>
<div className="lg:col-span-7"> <div className="space-y-6 lg:col-span-7">
<GitPanel project={detail} /> <GitPanel project={detail} />
<ActivityFeed projectId={detail.id} initial={detail.activities} />
</div> </div>
</div> </div>
</div> </div>
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import {
AlertTriangle,
ArrowRight,
Bot,
Download,
GitCommitHorizontal,
History,
Link2,
Loader2,
NotebookPen,
Pencil,
Plus,
Signpost,
Sparkles,
Trash2,
} from "lucide-react";
import type { ActivityEntry, ActivityKind } from "@/types";
import { formatDateTime, formatRelative } from "@/lib/utils";
/**
* Der Verlauf eines Projekts: was ist wann passiert.
*
* Die erste Seite kommt mit der Seite mit, weitere holt der Knopf nach. Die
* Alternative – alles gleich mitzuladen – wuerde jeden Seitenaufruf mit
* zweihundert Eintraegen belasten, von denen man selten mehr als die
* obersten fuenf liest.
*/
const KINDS: Record<ActivityKind, { icon: typeof Sparkles; color: string }> = {
PROJECT_CREATED: { icon: Sparkles, color: "text-violet-300" },
PROJECT_UPDATED: { icon: Pencil, color: "text-slate-400" },
STATUS_CHANGED: { icon: Signpost, color: "text-cyan-300" },
NOTE_ADDED: { icon: NotebookPen, color: "text-blue-300" },
NOTE_UPDATED: { icon: Pencil, color: "text-blue-300" },
NOTE_DELETED: { icon: Trash2, color: "text-rose-300" },
TASK_ADDED: { icon: Plus, color: "text-emerald-300" },
TASK_MOVED: { icon: ArrowRight, color: "text-violet-300" },
TASK_DELETED: { icon: Trash2, color: "text-rose-300" },
TASKS_IMPORTED: { icon: Download, color: "text-amber-300" },
REPO_LINKED: { icon: Link2, color: "text-cyan-300" },
REPO_COMMITS: { icon: GitCommitHorizontal, color: "text-emerald-300" },
SYNC_FAILED: { icon: AlertTriangle, color: "text-rose-300" },
};
export function ActivityFeed({
projectId,
initial,
}: {
projectId: string;
initial: ActivityEntry[];
}) {
const [entries, setEntries] = useState(initial);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Kam eine volle Seite, gibt es womoeglich noch mehr.
const [complete, setComplete] = useState(initial.length < 20);
async function loadAll() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/projects/${projectId}/activity?limit=200`);
if (!response.ok) throw new Error("Verlauf konnte nicht geladen werden.");
const payload = await response.json();
setEntries(payload.activities as ActivityEntry[]);
setComplete(true);
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setLoading(false);
}
}
return (
<section className="glass p-5">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<History className="h-4 w-4 text-violet-300" />
Verlauf
</h2>
{entries.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">
Noch nichts passiert. Sobald du etwas änderst, steht es hier.
</p>
) : (
<ol className="mt-4 space-y-0">
{entries.map((entry, index) => {
const meta = KINDS[entry.kind] ?? KINDS.PROJECT_UPDATED;
const Icon = meta.icon;
const letzter = index === entries.length - 1;
return (
<li key={entry.id} className="relative flex gap-3 pb-4">
{/* Die Linie endet am letzten Eintrag, statt ins Leere zu laufen. */}
{!letzter ? (
<span
aria-hidden
className="absolute left-3 top-7 h-[calc(100%-1.25rem)] w-px bg-white/15"
/>
) : null}
<span className="relative z-10 mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#0d1020] ring-1 ring-white/10">
<Icon className={`h-3.5 w-3.5 ${meta.color}`} />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-slate-200">{entry.summary}</p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<time dateTime={new Date(entry.createdAt).toISOString()}>
{formatRelative(entry.createdAt)}
</time>
<span aria-hidden>·</span>
<span>{formatDateTime(entry.createdAt)}</span>
{/* Ohne Benutzer war es der automatische Abgleich. */}
{entry.user ? null : (
<>
<span aria-hidden>·</span>
<Bot className="h-3 w-3" aria-hidden />
<span>automatisch</span>
</>
)}
</p>
</div>
</li>
);
})}
</ol>
)}
{error ? <p className="mt-2 text-xs text-rose-300">{error}</p> : null}
{!complete ? (
<button
type="button"
onClick={loadAll}
disabled={loading}
className="btn btn-ghost mt-1 w-full text-xs"
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Älteres anzeigen
</button>
) : null}
</section>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { describeFields, describeStatus, shorten } from "./activity";
describe("describeFields", () => {
it("nennt ein einzelnes Feld beim Namen", () => {
expect(describeFields(["name"])).toBe("Name geändert");
});
it("zaehlt zwei und drei Felder auf", () => {
expect(describeFields(["name", "tags"])).toBe("Name und Tags geändert");
expect(describeFields(["name", "tags", "priority"])).toBe(
"Name, Tags und Priorität geändert",
);
});
it("fasst ab vier Feldern zusammen, statt eine Litanei zu bilden", () => {
expect(describeFields(["name", "tags", "priority", "accent"])).toBe("4 Felder geändert");
});
it("zaehlt zusammengehoerende Felder nur einmal", () => {
// repoOwner und repoName sind fuer den Leser eine einzige Aenderung.
expect(describeFields(["repoOwner", "repoName"])).toBe("Repository geändert");
});
it("liefert null, wenn nichts Nennenswertes dabei war", () => {
// slug wird nur mitgeschrieben, wenn sich der Name aendert – der Name
// steht dann ohnehin schon in der Liste.
expect(describeFields([])).toBeNull();
expect(describeFields(["slug", "updatedAt"])).toBeNull();
});
});
describe("describeStatus", () => {
it("uebersetzt beide Seiten in die Anzeigenamen", () => {
expect(describeStatus("IDEA", "IN_DEVELOPMENT")).toBe("Status: Idee → In Entwicklung");
});
it("laesst unbekannte Werte stehen, statt sie zu verschlucken", () => {
expect(describeStatus("IDEA", "PHANTASIE")).toBe("Status: Idee → PHANTASIE");
});
});
describe("shorten", () => {
it("laesst Kurzes unangetastet", () => {
expect(shorten("Kurzer Titel")).toBe("Kurzer Titel");
});
it("kuerzt auf die vorgegebene Laenge einschliesslich Auslassungszeichen", () => {
const gekuerzt = shorten("x".repeat(100), 20);
expect(gekuerzt).toHaveLength(20);
expect(gekuerzt.endsWith("…")).toBe(true);
});
it("macht aus Zeilenumbruechen eine Zeile", () => {
expect(shorten("Erste Zeile\n\n zweite Zeile ")).toBe("Erste Zeile zweite Zeile");
});
});
+135
View File
@@ -0,0 +1,135 @@
import type { ActivityKind, Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { STATUS_META } from "@/lib/status";
/**
* Verlauf je Projekt: was ist wann passiert.
*
* Der Einzeiler wird beim Schreiben formuliert und nicht erst beim Anzeigen.
* Das ist Absicht: ein Eintrag soll auch dann noch lesbar sein, wenn es die
* Notiz oder die Aufgabe, um die es ging, laengst nicht mehr gibt.
*/
/** Wie viele Eintraege je Projekt aufgehoben werden. */
export const ACTIVITY_LIMIT = 200;
type Entry = {
projectId: string;
/** null = die Anwendung selbst, etwa der Zeitgeber beim Abgleich. */
userId?: string | null;
kind: ActivityKind;
summary: string;
meta?: Prisma.InputJsonValue;
};
/**
* Haelt einen Vorgang fest. Wirft nie: der Verlauf ist Beiwerk, und daran
* darf die eigentliche Aenderung nicht scheitern.
*/
export async function recordActivity(entry: Entry): Promise<void> {
try {
await prisma.activity.create({
data: {
projectId: entry.projectId,
userId: entry.userId ?? null,
kind: entry.kind,
summary: entry.summary,
meta: entry.meta,
},
});
} catch (error) {
console.error("[activity] konnte nicht geschrieben werden:", error);
}
}
/** Mehrere Vorgaenge auf einmal – ein Rundgang statt vieler. */
export async function recordActivities(entries: Entry[]): Promise<void> {
if (entries.length === 0) return;
try {
await prisma.activity.createMany({
data: entries.map((entry) => ({
projectId: entry.projectId,
userId: entry.userId ?? null,
kind: entry.kind,
summary: entry.summary,
meta: entry.meta,
})),
});
} catch (error) {
console.error("[activity] konnte nicht geschrieben werden:", error);
}
}
// ── Formulierungshilfen ──────────────────────────────────────
const FIELD_LABELS: Record<string, string> = {
name: "Name",
summary: "Kurzbeschreibung",
description: "Beschreibung",
priority: "Priorität",
progress: "Fortschritt",
accent: "Farbe",
tags: "Tags",
favorite: "Favorit",
autoSync: "Automatischer Abgleich",
progressFromTasks: "Fortschritt aus Aufgaben",
connectionId: "Git-Verbindung",
repoUrl: "Repository",
repoOwner: "Repository",
repoName: "Repository",
defaultBranch: "Standard-Branch",
};
/**
* Aus geaenderten Feldnamen einen lesbaren Satz machen: "Name und Tags
* geändert". Doppelte Bezeichnungen fallen weg – wer repoOwner und repoName
* zusammen setzt, hat einmal das Repository geaendert, nicht zweimal.
*/
export function describeFields(fields: string[]): string | null {
const labels: string[] = [];
for (const field of fields) {
const label = FIELD_LABELS[field];
if (label && !labels.includes(label)) labels.push(label);
}
if (labels.length === 0) return null;
if (labels.length === 1) return `${labels[0]} geändert`;
// Ab vier Feldern wird die Aufzaehlung laenger als sie nuetzt.
if (labels.length > 3) return `${labels.length} Felder geändert`;
return `${labels.slice(0, -1).join(", ")} und ${labels.at(-1)} geändert`;
}
/** "Idee → In Entwicklung" */
export function describeStatus(from: string, to: string): string {
const label = (value: string) =>
STATUS_META[value as keyof typeof STATUS_META]?.label ?? value;
return `Status: ${label(from)} → ${label(to)}`;
}
/** Kuerzt lange Titel, damit ein Eintrag eine Zeile bleibt. */
export function shorten(text: string, max = 70): string {
const clean = text.replace(/\s+/g, " ").trim();
return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`;
}
/**
* Stutzt den Verlauf auf die neuesten Eintraege je Projekt zurueck. Laeuft
* im taeglichen Durchgang des Zeitgebers mit – bei jedem Schreiben zu
* pruefen waere eine zusaetzliche Abfrage fuer nichts.
*/
export async function pruneActivities(): Promise<number> {
const rows = await prisma.$queryRaw<{ count: bigint }[]>`
WITH nummeriert AS (
SELECT "id", ROW_NUMBER() OVER (
PARTITION BY "projectId" ORDER BY "createdAt" DESC, "id" DESC
) AS rang
FROM "Activity"
), geloescht AS (
DELETE FROM "Activity"
WHERE "id" IN (SELECT "id" FROM nummeriert WHERE rang > ${ACTIVITY_LIMIT})
RETURNING 1
)
SELECT COUNT(*)::bigint AS count FROM geloescht
`;
return Number(rows[0]?.count ?? 0);
}
+3
View File
@@ -12,6 +12,9 @@ import { z } from "zod";
* ein Abgleich nach dem Import holt sie zurueck. * ein Abgleich nach dem Import holt sie zurueck.
* - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert * - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert
* wurde. * wurde.
* - kein Verlauf. Er beschreibt, was in *dieser* Instanz passiert ist, und
* verwiese nach dem Import auf Konten, die es dort nicht gibt. Der Import
* setzt stattdessen einen eigenen ersten Eintrag.
*/ */
export const EXPORT_KIND = "nebula.projects"; export const EXPORT_KIND = "nebula.projects";
+12
View File
@@ -64,5 +64,17 @@ export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
tasks: { tasks: {
orderBy: [{ status: "asc" }, { position: "asc" }], orderBy: [{ status: "asc" }, { position: "asc" }],
}, },
// Erste Seite des Verlaufs; weitere holt die Anzeige bei Bedarf nach.
activities: {
orderBy: { createdAt: "desc" },
take: 20,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
user: { select: { username: true, displayName: true } },
},
},
_count: { select: { notes: true, tasks: true } }, _count: { select: { notes: true, tasks: true } },
}); });
+52
View File
@@ -1,4 +1,5 @@
import type { Project } from "@prisma/client"; import type { Project } from "@prisma/client";
import { recordActivity, shorten } from "@/lib/activity";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git"; import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import { notifyAfterSync } from "@/lib/notify"; import { notifyAfterSync } from "@/lib/notify";
@@ -73,6 +74,15 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
syncFailureCount: { increment: 1 }, syncFailureCount: { increment: 1 },
}, },
}); });
// Nur der Uebergang in den Fehlerzustand kommt in den Verlauf. Ein Repo
// mit abgelaufenem Token schlaegt sonst stuendlich neu auf.
if (!project.syncError) {
await recordActivity({
projectId: project.id,
kind: "SYNC_FAILED",
summary: `Abgleich fehlgeschlagen: ${shorten(message, 120)}`,
});
}
await notifyAfterSync(project.id); await notifyAfterSync(project.id);
return { ok: false, message }; return { ok: false, message };
} }
@@ -90,6 +100,40 @@ export function resolveRepoRef(project: Project) {
return null; return null;
} }
/**
* Haelt neue Commits im Verlauf fest.
*
* Der Abgleich laeuft im Zweifel stuendlich, ein Eintrag je Durchlauf waere
* also unbrauchbar. Vermerkt wird nur, wenn sich der letzte Commit tatsaechlich
* geaendert hat – und beim allerersten Abgleich gar nicht: dass ein Repo
* Commits hat, ist keine Neuigkeit.
*/
async function noteNewCommits(
projectId: string,
vorher: string | null,
data: RepoSnapshotData,
) {
const neuester = data.commits[0]?.sha ?? null;
if (!neuester || !vorher || neuester === vorher) return;
// Wie viele Commits liegen zwischen altem und neuem Stand? Steht der alte
// nicht mehr in der Liste, waren es mehr als eine Seite – dann lieber ohne
// Zahl als mit einer falschen.
const index = data.commits.findIndex((commit) => commit.sha === vorher);
const anzahl = index > 0 ? index : null;
const wo = data.defaultBranch ? ` auf ${data.defaultBranch}` : "";
await recordActivity({
projectId,
kind: "REPO_COMMITS",
summary:
anzahl === null
? `Neue Commits${wo}: ${shorten(data.commits[0]?.message ?? "", 60)}`
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"}${wo}: ${shorten(data.commits[0]?.message ?? "", 60)}`,
meta: { from: vorher, to: neuester, count: anzahl },
});
}
async function persistSnapshot(projectId: string, data: RepoSnapshotData) { async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
const latestCommit = data.commits[0] ?? null; const latestCommit = data.commits[0] ?? null;
const latestRelease = const latestRelease =
@@ -133,6 +177,12 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
fetchedAt: new Date(), fetchedAt: new Date(),
}; };
// Vor dem Ueberschreiben lesen – danach ist der alte Stand weg.
const vorher = await prisma.repoSnapshot.findUnique({
where: { projectId },
select: { lastCommitSha: true },
});
await prisma.$transaction([ await prisma.$transaction([
prisma.repoSnapshot.upsert({ prisma.repoSnapshot.upsert({
where: { projectId }, where: { projectId },
@@ -150,4 +200,6 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
}, },
}), }),
]); ]);
await noteNewCommits(projectId, vorher?.lastCommitSha ?? null, data);
} }
+18 -6
View File
@@ -1,4 +1,5 @@
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { pruneActivities } from "@/lib/activity";
import { getAppSettings } from "@/lib/auth/settings"; import { getAppSettings } from "@/lib/auth/settings";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { runStaleCheck } from "@/lib/notify/stale"; import { runStaleCheck } from "@/lib/notify/stale";
@@ -76,19 +77,19 @@ async function tick() {
} }
} }
// Unabhaengig vom Abgleich: der Stillstand ergibt sich aus dem // Unabhaengig vom Abgleich: beides ergibt sich aus dem gespeicherten
// gespeicherten Stand, dafuer muss nichts geholt werden. // Stand, dafuer muss nichts geholt werden.
await maybeCheckStale(); await dailyChores();
} catch (error) { } catch (error) {
console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error); console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error);
} }
} }
/** /**
* Stillstandspruefung, hoechstens einmal am Tag. Laeuft nur, wenn ueberhaupt * Was hoechstens einmal am Tag zu tun ist: den Verlauf stutzen und nach
* jemand darauf hoert – sonst waere es reine Datenbanklast. * stillstehenden Projekten sehen.
*/ */
async function maybeCheckStale() { async function dailyChores() {
const settings = await getAppSettings(); const settings = await getAppSettings();
const last = settings.lastStaleCheckAt?.getTime() ?? 0; const last = settings.lastStaleCheckAt?.getTime() ?? 0;
if (Date.now() - last < STALE_CHECK_INTERVAL_MS) return; if (Date.now() - last < STALE_CHECK_INTERVAL_MS) return;
@@ -104,6 +105,17 @@ async function maybeCheckStale() {
data: { lastStaleCheckAt: new Date() }, data: { lastStaleCheckAt: new Date() },
}); });
// Der Verlauf waechst mit jeder Aenderung; einmal am Tag zurueckschneiden
// reicht, um ihn beschraenkt zu halten.
try {
const entfernt = await pruneActivities();
if (entfernt > 0) console.log(`[nebula] Verlauf gestutzt: ${entfernt} Eintraege entfernt.`);
} catch (error) {
console.error("[nebula] Verlauf konnte nicht gestutzt werden:", error);
}
// Die Stillstandspruefung laeuft nur, wenn jemand zuhoert – sonst waere sie
// reine Datenbanklast.
if (zuhoerer === 0) return; if (zuhoerer === 0) return;
const report = await runStaleCheck(Math.max(1, settings.staleAfterDays)); const report = await runStaleCheck(Math.max(1, settings.staleAfterDays));
+13
View File
@@ -1,4 +1,5 @@
import type { import type {
ActivityKind,
GitProvider, GitProvider,
Note, Note,
Project, Project,
@@ -43,11 +44,22 @@ export interface ProjectListItem extends Project {
tasks: { id: string }[]; tasks: { id: string }[];
} }
/** Ein Eintrag im Projektverlauf, so wie ihn die Anzeige braucht. */
export interface ActivityEntry {
id: string;
kind: ActivityKind;
summary: string;
createdAt: Date | string;
/** null bedeutet: die Anwendung selbst, etwa der automatische Abgleich. */
user: { username: string; displayName: string | null } | null;
}
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[];
tasks: Task[]; tasks: Task[];
activities: ActivityEntry[];
_count: { notes: number; tasks: number }; _count: { notes: number; tasks: number };
} }
@@ -63,6 +75,7 @@ export interface RepoListEntry {
} }
export type { export type {
ActivityKind,
BranchInfo, BranchInfo,
CiInfo, CiInfo,
CommitInfo, CommitInfo,