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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import { z } from "zod";
|
||||
* ein Abgleich nach dem Import holt sie zurueck.
|
||||
* - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert
|
||||
* 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";
|
||||
|
||||
@@ -64,5 +64,17 @@ export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||||
tasks: {
|
||||
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 } },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Project } from "@prisma/client";
|
||||
import { recordActivity, shorten } from "@/lib/activity";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
|
||||
import { notifyAfterSync } from "@/lib/notify";
|
||||
@@ -73,6 +74,15 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
||||
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);
|
||||
return { ok: false, message };
|
||||
}
|
||||
@@ -90,6 +100,40 @@ export function resolveRepoRef(project: Project) {
|
||||
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) {
|
||||
const latestCommit = data.commits[0] ?? null;
|
||||
const latestRelease =
|
||||
@@ -133,6 +177,12 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
||||
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([
|
||||
prisma.repoSnapshot.upsert({
|
||||
where: { projectId },
|
||||
@@ -150,4 +200,6 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await noteNewCommits(projectId, vorher?.lastCommitSha ?? null, data);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { pruneActivities } from "@/lib/activity";
|
||||
import { getAppSettings } from "@/lib/auth/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { runStaleCheck } from "@/lib/notify/stale";
|
||||
@@ -76,19 +77,19 @@ async function tick() {
|
||||
}
|
||||
}
|
||||
|
||||
// Unabhaengig vom Abgleich: der Stillstand ergibt sich aus dem
|
||||
// gespeicherten Stand, dafuer muss nichts geholt werden.
|
||||
await maybeCheckStale();
|
||||
// Unabhaengig vom Abgleich: beides ergibt sich aus dem gespeicherten
|
||||
// Stand, dafuer muss nichts geholt werden.
|
||||
await dailyChores();
|
||||
} catch (error) {
|
||||
console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stillstandspruefung, hoechstens einmal am Tag. Laeuft nur, wenn ueberhaupt
|
||||
* jemand darauf hoert – sonst waere es reine Datenbanklast.
|
||||
* Was hoechstens einmal am Tag zu tun ist: den Verlauf stutzen und nach
|
||||
* stillstehenden Projekten sehen.
|
||||
*/
|
||||
async function maybeCheckStale() {
|
||||
async function dailyChores() {
|
||||
const settings = await getAppSettings();
|
||||
const last = settings.lastStaleCheckAt?.getTime() ?? 0;
|
||||
if (Date.now() - last < STALE_CHECK_INTERVAL_MS) return;
|
||||
@@ -104,6 +105,17 @@ async function maybeCheckStale() {
|
||||
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;
|
||||
|
||||
const report = await runStaleCheck(Math.max(1, settings.staleAfterDays));
|
||||
|
||||
Reference in New Issue
Block a user