Feat #3: Globale Zeitleiste #6

Merged
florian.wilde merged 1 commits from feat/3-globale-zeitleiste into main 2026-08-18 01:12:55 +00:00
11 changed files with 619 additions and 94 deletions
Showing only changes of commit 9cdd51fba8 - Show all commits
+54
View File
@@ -763,6 +763,60 @@ test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, play
expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy(); expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy();
}); });
test("Verlauf blaettert per Cursor, ohne Zeilen zu verlieren", async ({ request, playwright }) => {
await login(request, ADMIN);
// Die bisherigen Tests haben reichlich Verlauf erzeugt. Erst alles auf
// einmal holen, dann in kleinen Seiten – beides muss dieselbe Menge sein.
const alles = await (await request.get("/api/activity?limit=100")).json();
expect(alles.activities.length).toBeGreaterThan(5);
const ids: string[] = [];
let cursor: { vor: string; id: string } | null = null;
for (let seite = 0; seite < 60; seite += 1) {
const query = cursor
? `?limit=3&vor=${encodeURIComponent(cursor.vor)}&id=${encodeURIComponent(cursor.id)}`
: "?limit=3";
const antwort = await request.get(`/api/activity${query}`);
expect(antwort.ok()).toBeTruthy();
const seiteBody = await antwort.json();
ids.push(...seiteBody.activities.map((eintrag: { id: string }) => eintrag.id));
if (!seiteBody.weitere) break;
cursor = seiteBody.naechster;
expect(cursor).not.toBeNull();
}
// Der eigentliche Punkt: keine Dubletten und keine Luecken, auch wenn
// mehrere Eintraege denselben Zeitstempel tragen.
expect(new Set(ids).size).toBe(ids.length);
expect(ids.length).toBe(alles.activities.length);
// Absteigend sortiert.
const zeiten: number[] = alles.activities.map((eintrag: { createdAt: string }) =>
Date.parse(eintrag.createdAt),
);
for (let i = 1; i < zeiten.length; i += 1) {
expect(zeiten[i]).toBeLessThanOrEqual(zeiten[i - 1]);
}
// Unbrauchbare Cursor fangen von vorn an, statt zu scheitern.
const kaputt = await request.get("/api/activity?vor=keindatum&id=x");
expect(kaputt.ok()).toBeTruthy();
expect((await kaputt.json()).activities.length).toBeGreaterThan(0);
// Und ein fremdes Konto sieht ausschliesslich seinen eigenen Verlauf.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
const fremderVerlauf = await (await fremd.get("/api/activity")).json();
const eigeneIds = new Set(ids);
for (const eintrag of fremderVerlauf.activities) {
expect(eigeneIds.has(eintrag.id)).toBe(false);
}
await fremd.dispose();
});
test("Der Bildoptimierer ist zu", async ({ playwright }) => { test("Der Bildoptimierer ist zu", async ({ playwright }) => {
// /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer // /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer
// das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image // das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image
+20
View File
@@ -249,6 +249,26 @@ test("Wochenrueckblick zeigt, was diese Woche passiert ist", async ({ page }) =>
await expect(page).toHaveURL(/\/rueckblick/); await expect(page).toHaveURL(/\/rueckblick/);
}); });
test("Globale Zeitleiste zeigt den Verlauf ueber alle Projekte", async ({ page }) => {
// Vom Wochenrueckblick aus verlinkt.
await page.goto("/rueckblick");
await page.getByRole("link", { name: /Ganzer Verlauf/ }).click();
await expect(page).toHaveURL(/\/verlauf$/);
await expect(page.getByRole("heading", { name: "Verlauf", level: 1 })).toBeVisible();
// Eintraege mit Projektbezug – ohne den ist eine uebergreifende Liste
// nicht zu lesen.
await expect(page.getByRole("link", { name: "Oberflaechentest" }).first()).toBeVisible();
await expect(page.getByText(/Vorgänge geladen|Vorgang geladen/)).toBeVisible();
// Auch ueber die Schnellsuche erreichbar.
await page.goto("/");
await page.keyboard.press("Control+k");
await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Verlauf");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/verlauf/);
});
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => { test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k"); await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import {
SEITEN_ORDNUNG,
cursorAus,
seitenBedingung,
seitenGroesse,
} from "@/lib/timeline";
export const dynamic = "force-dynamic";
/**
* Der Verlauf ueber alle Projekte, neueste Eintraege zuerst.
*
* Geblaettert wird per Cursor (`vor` = Zeitpunkt, `id` = Eintrag), nicht per
* Offset – siehe lib/timeline. Zurueck kommt der Cursor fuer die naechste
* Seite gleich mit, damit der Aufrufer ihn nicht selbst zusammensetzen muss.
*/
export async function GET(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const params = new URL(request.url).searchParams;
const cursor = cursorAus(params.get("vor"), params.get("id"));
const take = seitenGroesse(params.get("limit"));
/**
* Einen mehr holen als angefragt: daran laesst sich ablesen, ob es
* weitergeht, ohne dafuer zaehlen zu muessen.
*/
const gefunden = await prisma.activity.findMany({
where: seitenBedingung(auth.user.id, cursor),
orderBy: SEITEN_ORDNUNG,
take: take + 1,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
project: { select: { id: true, name: true, accent: true } },
user: { select: { username: true, displayName: true } },
},
});
const weitere = gefunden.length > take;
const activities = weitere ? gefunden.slice(0, take) : gefunden;
const letzter = activities[activities.length - 1];
return NextResponse.json({
activities: activities.map((eintrag) => ({
...eintrag,
createdAt: eintrag.createdAt.toISOString(),
})),
weitere,
naechster: weitere && letzter ? { vor: letzter.createdAt.toISOString(), id: letzter.id } : null,
});
}
+5 -1
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, ArrowRight, CalendarRange } from "lucide-react"; import { ArrowLeft, ArrowRight, CalendarRange, History } from "lucide-react";
import { WeeklyReview } from "@/components/WeeklyReview"; import { WeeklyReview } from "@/components/WeeklyReview";
import { requireUserPage } from "@/lib/auth/guard"; import { requireUserPage } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -121,6 +121,10 @@ export default async function ReviewPage({
Diese Woche Diese Woche
</Link> </Link>
) : null} ) : null}
<Link href="/verlauf" className="btn btn-ghost px-2.5 py-1.5 text-xs">
<History className="h-3.5 w-3.5" />
Ganzer Verlauf
</Link>
</nav> </nav>
</div> </div>
</header> </header>
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CalendarRange, History } from "lucide-react";
import { Timeline } from "@/components/Timeline";
import { requireUserPage } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { SEITE, SEITEN_ORDNUNG, seitenBedingung } from "@/lib/timeline";
import type { ReviewEntry } from "@/types";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Verlauf" };
/**
* Der Verlauf ueber alle Projekte, fortlaufend.
*
* Die erste Seite kommt serverseitig mit, damit sofort etwas dasteht; alles
* Weitere holt der Knopf per Cursor nach. Anders als der Wochenrueckblick
* hat diese Seite keine Grenze und kein Fazit – sie laeuft zurueck, solange
* es Eintraege gibt.
*/
export default async function TimelinePage() {
const user = await requireUserPage();
const gefunden = await prisma.activity.findMany({
where: seitenBedingung(user.id, null),
orderBy: SEITEN_ORDNUNG,
// Einen mehr, um zu wissen, ob es weitergeht.
take: SEITE + 1,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
project: { select: { id: true, name: true, accent: true } },
user: { select: { username: true, displayName: true } },
},
});
const weitere = gefunden.length > SEITE;
const erste: ReviewEntry[] = (weitere ? gefunden.slice(0, SEITE) : gefunden).map((eintrag) => ({
id: eintrag.id,
kind: eintrag.kind,
summary: eintrag.summary,
createdAt: eintrag.createdAt.toISOString(),
project: eintrag.project,
user: eintrag.user,
}));
return (
<div className="space-y-5">
<header className="fade-up pt-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Über alle Projekte
</p>
<div className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
<History className="h-6 w-6 text-violet-300" />
Verlauf
</h1>
<p className="mt-1 text-sm text-slate-400">
Alles, was passiert ist – vom Neuesten rückwärts.
</p>
</div>
<Link href="/rueckblick" className="btn btn-ghost px-3 py-1.5 text-xs">
<CalendarRange className="h-3.5 w-3.5" />
Wochenrückblick
</Link>
</div>
</header>
<Timeline erste={erste} weitere={weitere} />
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import Link from "next/link";
import { ACTIVITY_KINDS } from "@/components/activityKinds";
import { accentOf } from "@/lib/status";
import { cn } from "@/lib/utils";
import type { ReviewDay, ReviewEntry } from "@/types";
/**
* Darstellung von Verlaufseintraegen – eine Zeile und die Gruppierung nach
* Kalendertagen.
*
* Bewusst ohne "use client" und ohne Hooks: so laesst sich beides sowohl aus
* einer Server-Komponente (Wochenrueckblick) als auch aus einer
* Client-Komponente (Zeitleiste mit Nachladen) heraus benutzen. Zwei Kopien
* derselben Zeile waeren beim naechsten Feinschliff auseinandergelaufen.
*
* Alle Formate mit fester Zeitzone – dieselbe Ueberlegung wie in lib/utils:
* sonst weichen Server-Render und Hydration voneinander ab.
*/
const WOCHENTAG = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
weekday: "long",
});
const TAG_MONAT = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
day: "numeric",
month: "long",
});
const TAG_MONAT_JAHR = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
day: "numeric",
month: "long",
year: "numeric",
});
const UHRZEIT = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
hour: "2-digit",
minute: "2-digit",
});
/** "2026-08-17" → ein Zeitpunkt mittags, damit die Zeitzone nichts verschiebt. */
function alsDatum(tag: string): Date {
const [j, m, t] = tag.split("-").map(Number);
return new Date(Date.UTC(j, m - 1, t, 12));
}
export function ActivityDayGroups({
tage,
/** Bei Tagen aus anderen Jahren gehoert die Jahreszahl dazu. */
mitJahr = false,
}: {
tage: ReviewDay[];
mitJahr?: boolean;
}) {
const format = mitJahr ? TAG_MONAT_JAHR : TAG_MONAT;
return (
<div className="space-y-6">
{tage.map((tag) => {
const datum = alsDatum(tag.tag);
return (
<section key={tag.tag}>
<div className="mb-2.5 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-300">
{WOCHENTAG.format(datum)}
</h2>
<span className="text-xs text-slate-500">{format.format(datum)}</span>
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{tag.eintraege.length}
</span>
<span className="h-px flex-1 bg-gradient-to-r from-white/10 to-transparent" />
</div>
<ul className="glass divide-y divide-white/6 overflow-hidden">
{tag.eintraege.map((eintrag) => (
<ActivityRow key={eintrag.id} eintrag={eintrag} />
))}
</ul>
</section>
);
})}
</div>
);
}
export function ActivityRow({ eintrag }: { eintrag: ReviewEntry }) {
const art = ACTIVITY_KINDS[eintrag.kind] ?? ACTIVITY_KINDS.PROJECT_UPDATED;
const Icon = art.icon;
const accent = accentOf(eintrag.project.accent);
return (
<li className="flex items-start gap-2.5 px-3 py-2.5 sm:gap-3 sm:px-4">
<span className={cn("mt-0.5 shrink-0", art.color)} title={art.label}>
<Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-slate-200">{eintrag.summary}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
{/* Das Projekt gehoert in jede Zeile – ohne es ist eine
projektuebergreifende Liste nicht zu lesen. */}
<Link
href={`/projects/${eintrag.project.id}`}
className="inline-flex max-w-[12rem] items-center gap-1.5 truncate text-slate-400 underline-offset-2 hover:text-violet-300 hover:underline"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: accent.from }}
aria-hidden="true"
/>
{eintrag.project.name}
</Link>
<span className="tabular-nums">{UHRZEIT.format(new Date(eintrag.createdAt))} Uhr</span>
{eintrag.user ? (
<span className="hidden xs:inline">
{eintrag.user.displayName?.trim() || eintrag.user.username}
</span>
) : (
// Ohne Benutzer: das war der Zeitgeber oder ein Webhook.
<span className="hidden text-slate-600 xs:inline">automatisch</span>
)}
</div>
</div>
</li>
);
}
+8
View File
@@ -9,6 +9,7 @@ import {
CheckSquare, CheckSquare,
CornerDownLeft, CornerDownLeft,
GitBranch, GitBranch,
History,
LayoutDashboard, LayoutDashboard,
Loader2, Loader2,
NotebookPen, NotebookPen,
@@ -64,6 +65,13 @@ const ACTIONS: Action[] = [
icon: CalendarRange, icon: CalendarRange,
href: "/rueckblick", href: "/rueckblick",
}, },
{
id: "verlauf",
label: "Verlauf",
hint: "Alles, was passiert ist – über alle Projekte",
icon: History,
href: "/verlauf",
},
{ {
id: "tasks", id: "tasks",
label: "Aufgaben", label: "Aufgaben",
+126
View File
@@ -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<string | null>(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<string, ReviewEntry[]>();
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 (
<div className="glass fade-up flex flex-col items-center justify-center gap-4 px-6 py-16 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-violet-500/25 to-cyan-400/20 text-violet-200">
<History className="h-6 w-6" />
</span>
<div>
<h2 className="text-lg font-semibold text-white">Noch kein Verlauf</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
Sobald du ein Projekt anlegst, Aufgaben verschiebst oder Notizen schreibst, steht es
hier.
</p>
</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Die Jahreszahl gehoert dazu: eine Zeitleiste reicht weit zurueck,
und "3. Januar" allein sagt dann nicht genug. */}
<ActivityDayGroups tage={tage} mitJahr />
{fehler ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{fehler}
</p>
) : null}
<div className="flex flex-col items-center gap-2 pt-1">
{weitere ? (
<button type="button" className="btn btn-ghost" onClick={mehr} disabled={laedt}>
{laedt ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ChevronDown className="h-4 w-4" />
)}
Ältere laden
</button>
) : (
<p className="text-xs text-slate-600">Das war alles.</p>
)}
<p className="text-[11px] text-slate-600 tabular-nums">
{eintraege.length} {eintraege.length === 1 ? "Vorgang" : "Vorgänge"} geladen
</p>
</div>
</div>
);
}
+4 -93
View File
@@ -1,10 +1,7 @@
import Link from "next/link";
import { CalendarRange, CheckSquare, FolderGit2, NotebookPen, Plus } from "lucide-react"; import { CalendarRange, CheckSquare, FolderGit2, NotebookPen, Plus } from "lucide-react";
import { ACTIVITY_KINDS } from "@/components/activityKinds"; import { ActivityDayGroups } from "@/components/ActivityRow";
import type { Kennzahlen } from "@/lib/review"; import type { Kennzahlen } from "@/lib/review";
import { accentOf } from "@/lib/status"; import type { ReviewDay } from "@/types";
import { cn } from "@/lib/utils";
import type { ReviewDay, ReviewEntry } from "@/types";
/** /**
* Der Wochenrueckblick. * Der Wochenrueckblick.
@@ -17,26 +14,6 @@ import type { ReviewDay, ReviewEntry } from "@/types";
* deshalb auf den Server. * deshalb auf den Server.
*/ */
const WOCHENTAG = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
weekday: "long",
});
const TAG_MONAT = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
day: "numeric",
month: "long",
});
const UHRZEIT = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
hour: "2-digit",
minute: "2-digit",
});
function alsDatum(tag: string): Date {
const [j, m, t] = tag.split("-").map(Number);
return new Date(Date.UTC(j, m - 1, t, 12));
}
export function WeeklyReview({ export function WeeklyReview({
tage, tage,
kennzahlen, kennzahlen,
@@ -86,32 +63,8 @@ export function WeeklyReview({
</p> </p>
) : null} ) : null}
{/* Verlauf nach Tagen */} {/* Verlauf nach Tagen – dieselbe Darstellung wie in der Zeitleiste. */}
<div className="space-y-6"> <ActivityDayGroups tage={tage} />
{tage.map((tag) => {
const datum = alsDatum(tag.tag);
return (
<section key={tag.tag}>
<div className="mb-2.5 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-300">
{WOCHENTAG.format(datum)}
</h2>
<span className="text-xs text-slate-500">{TAG_MONAT.format(datum)}</span>
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{tag.eintraege.length}
</span>
<span className="h-px flex-1 bg-gradient-to-r from-white/10 to-transparent" />
</div>
<ul className="glass divide-y divide-white/6 overflow-hidden">
{tag.eintraege.map((eintrag) => (
<Zeile key={eintrag.id} eintrag={eintrag} />
))}
</ul>
</section>
);
})}
</div>
</div> </div>
); );
} }
@@ -153,45 +106,3 @@ function Kennzahl({
</div> </div>
); );
} }
function Zeile({ eintrag }: { eintrag: ReviewEntry }) {
const art = ACTIVITY_KINDS[eintrag.kind];
const Icon = art.icon;
const accent = accentOf(eintrag.project.accent);
return (
<li className="flex items-start gap-2.5 px-3 py-2.5 sm:gap-3 sm:px-4">
<span className={cn("mt-0.5 shrink-0", art.color)} title={art.label}>
<Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-slate-200">{eintrag.summary}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
{/* Das Projekt gehoert in jede Zeile – ohne es ist eine
projektuebergreifende Liste nicht zu lesen. */}
<Link
href={`/projects/${eintrag.project.id}`}
className="inline-flex max-w-[12rem] items-center gap-1.5 truncate text-slate-400 underline-offset-2 hover:text-violet-300 hover:underline"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: accent.from }}
aria-hidden="true"
/>
{eintrag.project.name}
</Link>
<span className="tabular-nums">{UHRZEIT.format(new Date(eintrag.createdAt))} Uhr</span>
{eintrag.user ? (
<span className="hidden xs:inline">
{eintrag.user.displayName?.trim() || eintrag.user.username}
</span>
) : (
// Ohne Benutzer: das war der Zeitgeber oder ein Webhook.
<span className="hidden text-slate-600 xs:inline">automatisch</span>
)}
</div>
</div>
</li>
);
}
+64
View File
@@ -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);
});
});
+77
View File
@@ -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);
}