+ {/* Das Projekt gehoert in jede Zeile – ohne es ist eine
+ projektuebergreifende Liste nicht zu lesen. */}
+
+
+ {eintrag.project.name}
+
+ {UHRZEIT.format(new Date(eintrag.createdAt))} Uhr
+ {eintrag.user ? (
+
+ {eintrag.user.displayName?.trim() || eintrag.user.username}
+
+ ) : (
+ // Ohne Benutzer: das war der Zeitgeber oder ein Webhook.
+ automatisch
+ )}
+
+
+
+ );
+}
diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx
index a128026..f7af258 100644
--- a/src/components/CommandPalette.tsx
+++ b/src/components/CommandPalette.tsx
@@ -9,6 +9,7 @@ import {
CheckSquare,
CornerDownLeft,
GitBranch,
+ History,
LayoutDashboard,
Loader2,
NotebookPen,
@@ -64,6 +65,13 @@ const ACTIONS: Action[] = [
icon: CalendarRange,
href: "/rueckblick",
},
+ {
+ id: "verlauf",
+ label: "Verlauf",
+ hint: "Alles, was passiert ist – über alle Projekte",
+ icon: History,
+ href: "/verlauf",
+ },
{
id: "tasks",
label: "Aufgaben",
diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx
new file mode 100644
index 0000000..1815d6c
--- /dev/null
+++ b/src/components/Timeline.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { ChevronDown, History, Loader2 } from "lucide-react";
+import { ActivityDayGroups } from "@/components/ActivityRow";
+import { tagesschluessel } from "@/lib/review";
+import type { ReviewDay, ReviewEntry } from "@/types";
+
+/**
+ * Die fortlaufende Zeitleiste.
+ *
+ * Client-Komponente, weil hier nachgeladen wird – das ist der Unterschied
+ * zum Wochenrueckblick, der komplett auf dem Server entsteht.
+ *
+ * Die Buendelung nach Tagen passiert hier im Browser. Das ist unbedenklich,
+ * weil tagesschluessel eine feste Zeitzone benutzt: Server und Browser kommen
+ * damit auf denselben Kalendertag, und die erste Seite hydriert ohne
+ * Abweichung.
+ */
+export function Timeline({
+ erste,
+ weitere: gabWeitere,
+}: {
+ erste: ReviewEntry[];
+ weitere: boolean;
+}) {
+ const [eintraege, setEintraege] = useState(erste);
+ const [weitere, setWeitere] = useState(gabWeitere);
+ const [laedt, setLaedt] = useState(false);
+ const [fehler, setFehler] = useState(null);
+
+ const tage: ReviewDay[] = useMemo(() => {
+ // Map behaelt die Einfuegereihenfolge – die Eintraege kommen schon
+ // absteigend, also stimmt die Reihenfolge der Tage von selbst.
+ const nachTag = new Map();
+ for (const eintrag of eintraege) {
+ const schluessel = tagesschluessel(new Date(eintrag.createdAt));
+ const liste = nachTag.get(schluessel) ?? [];
+ liste.push(eintrag);
+ nachTag.set(schluessel, liste);
+ }
+ return [...nachTag.entries()].map(([tag, liste]) => ({ tag, eintraege: liste }));
+ }, [eintraege]);
+
+ async function mehr() {
+ const letzter = eintraege[eintraege.length - 1];
+ if (!letzter) return;
+
+ setLaedt(true);
+ setFehler(null);
+ try {
+ const antwort = await fetch(
+ `/api/activity?vor=${encodeURIComponent(letzter.createdAt)}&id=${encodeURIComponent(letzter.id)}`,
+ );
+ if (!antwort.ok) throw new Error("Verlauf konnte nicht geladen werden.");
+ const nutzlast = (await antwort.json()) as {
+ activities: ReviewEntry[];
+ weitere: boolean;
+ };
+
+ /**
+ * Gegen Dubletten absichern, auch wenn der Cursor sie ausschliesst:
+ * ein zweimal gedrueckter Knopf oder ein wiederholter Aufruf soll
+ * keine Zeile verdoppeln, und React braucht eindeutige Schluessel.
+ */
+ setEintraege((bisher) => {
+ const bekannt = new Set(bisher.map((eintrag) => eintrag.id));
+ return [...bisher, ...nutzlast.activities.filter((eintrag) => !bekannt.has(eintrag.id))];
+ });
+ setWeitere(nutzlast.weitere);
+ } catch (gefangen) {
+ setFehler(gefangen instanceof Error ? gefangen.message : String(gefangen));
+ } finally {
+ setLaedt(false);
+ }
+ }
+
+ if (eintraege.length === 0) {
+ return (
+
+
+
+
+
+
Noch kein Verlauf
+
+ Sobald du ein Projekt anlegst, Aufgaben verschiebst oder Notizen schreibst, steht es
+ hier.
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Die Jahreszahl gehoert dazu: eine Zeitleiste reicht weit zurueck,
+ und "3. Januar" allein sagt dann nicht genug. */}
+
+
+ {fehler ? (
+
+ {/* Verlauf nach Tagen – dieselbe Darstellung wie in der Zeitleiste. */}
+
);
}
@@ -153,45 +106,3 @@ function Kennzahl({
);
}
-
-function Zeile({ eintrag }: { eintrag: ReviewEntry }) {
- const art = ACTIVITY_KINDS[eintrag.kind];
- const Icon = art.icon;
- const accent = accentOf(eintrag.project.accent);
-
- return (
-
-
-
-
-
-
-
{eintrag.summary}
-
- {/* Das Projekt gehoert in jede Zeile – ohne es ist eine
- projektuebergreifende Liste nicht zu lesen. */}
-
-
- {eintrag.project.name}
-
- {UHRZEIT.format(new Date(eintrag.createdAt))} Uhr
- {eintrag.user ? (
-
- {eintrag.user.displayName?.trim() || eintrag.user.username}
-
- ) : (
- // Ohne Benutzer: das war der Zeitgeber oder ein Webhook.
- automatisch
- )}
-
-
-
- );
-}
diff --git a/src/lib/timeline.test.ts b/src/lib/timeline.test.ts
new file mode 100644
index 0000000..2827c5b
--- /dev/null
+++ b/src/lib/timeline.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import { SEITE, SEITE_MAX, cursorAus, seitenBedingung, seitenGroesse } from "./timeline";
+
+const ID = "cmstxnrzs00011cx4nd5tiqbt";
+
+describe("cursorAus", () => {
+ it("liest Zeitpunkt und ID", () => {
+ const cursor = cursorAus("2026-08-17T22:30:00.000Z", ID);
+ expect(cursor?.id).toBe(ID);
+ expect(cursor?.createdAt.toISOString()).toBe("2026-08-17T22:30:00.000Z");
+ });
+
+ it("faengt ohne Cursor von vorn an", () => {
+ expect(cursorAus(null, null)).toBeNull();
+ expect(cursorAus("2026-08-17T22:30:00.000Z", null)).toBeNull();
+ expect(cursorAus(null, ID)).toBeNull();
+ });
+
+ it("weist Unbrauchbares ab, statt es in die Abfrage zu geben", () => {
+ expect(cursorAus("kein datum", ID)).toBeNull();
+ expect(cursorAus("2026-08-17T22:30:00.000Z", "kurz")).toBeNull();
+ expect(cursorAus("2026-08-17T22:30:00.000Z", "'; DROP TABLE x --")).toBeNull();
+ });
+});
+
+describe("seitenBedingung", () => {
+ it("filtert immer auf den Besitzer", () => {
+ expect(seitenBedingung("u1", null)).toEqual({ project: { ownerId: "u1" } });
+ });
+
+ it("behaelt den Besitzer auch mit Cursor", () => {
+ const wo = seitenBedingung("u1", { createdAt: new Date("2026-08-17T22:30:00Z"), id: ID });
+ expect(wo.project).toEqual({ ownerId: "u1" });
+ });
+
+ it("fasst gleichzeitige Eintraege ueber die ID auseinander", () => {
+ // Der eigentliche Zweck: beim Uebernehmen von Issues entstehen mehrere
+ // Zeilen mit identischem createdAt. Ohne den zweiten Zweig wuerde die
+ // naechste Seite sie ueberspringen oder wiederholen.
+ const zeitpunkt = new Date("2026-08-17T22:30:00Z");
+ const wo = seitenBedingung("u1", { createdAt: zeitpunkt, id: ID });
+ expect(wo.OR).toEqual([
+ { createdAt: { lt: zeitpunkt } },
+ { createdAt: zeitpunkt, id: { lt: ID } },
+ ]);
+ });
+});
+
+describe("seitenGroesse", () => {
+ it("nimmt die Vorgabe, wenn nichts angefragt wurde", () => {
+ expect(seitenGroesse(null)).toBe(SEITE);
+ expect(seitenGroesse("unsinn")).toBe(SEITE);
+ });
+
+ it("deckelt nach oben und unten", () => {
+ expect(seitenGroesse("1000")).toBe(SEITE_MAX);
+ expect(seitenGroesse("0")).toBe(1);
+ expect(seitenGroesse("-5")).toBe(1);
+ });
+
+ it("nimmt einen gueltigen Wert an", () => {
+ expect(seitenGroesse("25")).toBe(25);
+ });
+});
diff --git a/src/lib/timeline.ts b/src/lib/timeline.ts
new file mode 100644
index 0000000..07e8401
--- /dev/null
+++ b/src/lib/timeline.ts
@@ -0,0 +1,77 @@
+import type { Prisma } from "@prisma/client";
+
+/**
+ * Fortlaufender Verlauf ueber alle Projekte.
+ *
+ * Geblaettert wird per Cursor, nicht per `skip`. Der Verlauf schreibt
+ * mehrere Eintraege im selben Augenblick – beim Uebernehmen von Issues etwa
+ * entstehen zehn Zeilen mit identischem `createdAt`. Ein Offset ueber eine
+ * solche Grenze hinweg ueberspringt Zeilen oder zeigt sie zweimal, und zwar
+ * lautlos. Der Cursor aus (createdAt, id) ist dagegen eindeutig.
+ */
+
+/** Wie viele Eintraege eine Seite hat. */
+export const SEITE = 40;
+/** Deckel gegen einen Aufrufer, der sich alles auf einmal holen will. */
+export const SEITE_MAX = 100;
+
+export interface Cursor {
+ /** Zeitpunkt des letzten gezeigten Eintrags. */
+ createdAt: Date;
+ /** Seine ID – entscheidet bei gleichem Zeitpunkt. */
+ id: string;
+}
+
+/**
+ * Liest einen Cursor aus Abfrageparametern.
+ *
+ * Gibt null zurueck, wenn nichts oder Unbrauchbares ankommt – dann faengt
+ * die Seite eben von vorn an. Ein Fehler waere hier die schlechtere Antwort:
+ * ein abgeschnittener Verweis soll die Seite nicht sperren.
+ */
+export function cursorAus(vor: string | null, id: string | null): Cursor | null {
+ if (!vor || !id) return null;
+ const zeitpunkt = new Date(vor);
+ if (Number.isNaN(zeitpunkt.getTime())) return null;
+ // Die ID ist eine cuid; alles andere weisen wir ab, statt es in die
+ // Abfrage zu geben.
+ if (!/^[a-z0-9]{20,32}$/i.test(id)) return null;
+ return { createdAt: zeitpunkt, id };
+}
+
+/**
+ * Die where-Bedingung fuer eine Seite.
+ *
+ * Besitzer zuerst – wie ueberall im Projekt, damit fremde Datensaetze gar
+ * nicht erst getroffen werden. Der Verlauf selbst kennt keinen Besitzer, die
+ * Bedingung laeuft deshalb ueber das Projekt.
+ *
+ * Der Cursor bedeutet "aelter als der letzte gezeigte Eintrag": entweder
+ * frueher, oder gleichzeitig und mit kleinerer ID. Genau so sortiert auch
+ * die Abfrage, weshalb beides zusammenpasst.
+ */
+export function seitenBedingung(ownerId: string, cursor: Cursor | null): Prisma.ActivityWhereInput {
+ const basis: Prisma.ActivityWhereInput = { project: { ownerId } };
+ if (!cursor) return basis;
+
+ return {
+ ...basis,
+ OR: [
+ { createdAt: { lt: cursor.createdAt } },
+ { createdAt: cursor.createdAt, id: { lt: cursor.id } },
+ ],
+ };
+}
+
+/** Sortierung, die zur Cursor-Bedingung passt. Beide muessen gleich sein. */
+export const SEITEN_ORDNUNG: Prisma.ActivityOrderByWithRelationInput[] = [
+ { createdAt: "desc" },
+ { id: "desc" },
+];
+
+/** Begrenzt eine gewuenschte Seitengroesse auf das Erlaubte. */
+export function seitenGroesse(roh: string | null): number {
+ const angefragt = roh === null ? Number.NaN : Number(roh);
+ if (!Number.isFinite(angefragt)) return SEITE;
+ return Math.min(Math.max(Math.trunc(angefragt), 1), SEITE_MAX);
+}