From 9cdd51fba8d222e64d1dac0aa1030e729106e17d Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 18 Aug 2026 01:54:50 +0200 Subject: [PATCH] Globale Zeitleiste ueber alle Projekte Eine Seite /verlauf, die den Aktivitaetsverlauf fortlaufend ueber alle Projekte zeigt - ohne Wochengrenze, nach Kalendertagen gegliedert, mit Nachladen aelterer Eintraege. Erste Seite serverseitig, alles Weitere ueber /api/activity. Geblaettert wird per Cursor ueber (createdAt, id), nicht per skip. Der Verlauf schreibt mehrere Eintraege im selben Augenblick - beim Uebernehmen von Issues entstehen zehn Zeilen mit identischem Zeitstempel. Ein Offset ueber eine solche Grenze hinweg ueberspringt Zeilen oder zeigt sie zweimal, und zwar lautlos. Der zweite Zweig der Bedingung (gleicher Zeitpunkt, kleinere ID) faengt genau das ab. Geprueft gegen 144 Eintraege mit absichtlichen Bloecken gleicher Zeitstempel: 15 Seiten, 144 Zeilen, keine Dublette, streng absteigend. Die Bedingung selbst steht als pruefbare Funktion in lib/timeline, samt Deckelung der Seitengroesse und Abweisen unbrauchbarer Cursor - eine ID aus der Abfragezeichenkette geht nicht ungeprueft in die Abfrage. Damit Rueckblick und Zeitleiste wirklich gleich aussehen, sind Zeile und Tagesgruppen nach components/ActivityRow gewandert. Bewusst ohne "use client" und ohne Hooks, damit beides sowohl aus der Server-Komponente (Rueckblick) als auch aus der Client-Komponente (Zeitleiste) heraus benutzbar ist. Die Buendelung im Browser ist unbedenklich, weil tagesschluessel eine feste Zeitzone benutzt - Server und Browser kommen auf denselben Kalendertag. Erreichbar ueber die Schnellsuche und vom Wochenrueckblick aus. Geprueft: 9 neue Unit-Tests zur Cursor-Logik, ein E2E-Test, der die ganze Liste in Dreierseiten durchblaettert und gegen einen einzelnen grossen Aufruf haelt, ein E2E-Test fuer die Seite, axe-core auf 375px und 1280px je vor und nach dem Nachladen ohne Beanstandung. Refs #3 --- e2e/api.spec.ts | 54 +++++++++++++ e2e/ui.spec.ts | 20 +++++ src/app/api/activity/route.ts | 58 ++++++++++++++ src/app/rueckblick/page.tsx | 6 +- src/app/verlauf/page.tsx | 77 ++++++++++++++++++ src/components/ActivityRow.tsx | 126 ++++++++++++++++++++++++++++++ src/components/CommandPalette.tsx | 8 ++ src/components/Timeline.tsx | 126 ++++++++++++++++++++++++++++++ src/components/WeeklyReview.tsx | 97 +---------------------- src/lib/timeline.test.ts | 64 +++++++++++++++ src/lib/timeline.ts | 77 ++++++++++++++++++ 11 files changed, 619 insertions(+), 94 deletions(-) create mode 100644 src/app/api/activity/route.ts create mode 100644 src/app/verlauf/page.tsx create mode 100644 src/components/ActivityRow.tsx create mode 100644 src/components/Timeline.tsx create mode 100644 src/lib/timeline.test.ts create mode 100644 src/lib/timeline.ts diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index de86047..fdd4a7c 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -763,6 +763,60 @@ test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, play 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 }) => { // /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer // das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts index 4adff30..c08c6b2 100644 --- a/e2e/ui.spec.ts +++ b/e2e/ui.spec.ts @@ -249,6 +249,26 @@ test("Wochenrueckblick zeigt, was diese Woche passiert ist", async ({ page }) => 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 }) => { await page.keyboard.press("Control+k"); await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible(); diff --git a/src/app/api/activity/route.ts b/src/app/api/activity/route.ts new file mode 100644 index 0000000..a609383 --- /dev/null +++ b/src/app/api/activity/route.ts @@ -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, + }); +} diff --git a/src/app/rueckblick/page.tsx b/src/app/rueckblick/page.tsx index d91463f..188e471 100644 --- a/src/app/rueckblick/page.tsx +++ b/src/app/rueckblick/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; 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 { requireUserPage } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; @@ -121,6 +121,10 @@ export default async function ReviewPage({ Diese Woche ) : null} + + + Ganzer Verlauf + diff --git a/src/app/verlauf/page.tsx b/src/app/verlauf/page.tsx new file mode 100644 index 0000000..cbe9d0e --- /dev/null +++ b/src/app/verlauf/page.tsx @@ -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 ( +
+
+

+ Über alle Projekte +

+
+
+

+ + Verlauf +

+

+ Alles, was passiert ist – vom Neuesten rückwärts. +

+
+ + + + Wochenrückblick + +
+
+ + +
+ ); +} diff --git a/src/components/ActivityRow.tsx b/src/components/ActivityRow.tsx new file mode 100644 index 0000000..e218b6c --- /dev/null +++ b/src/components/ActivityRow.tsx @@ -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 ( +
+ {tage.map((tag) => { + const datum = alsDatum(tag.tag); + return ( +
+
+

+ {WOCHENTAG.format(datum)} +

+ {format.format(datum)} + + {tag.eintraege.length} + + +
+ +
    + {tag.eintraege.map((eintrag) => ( + + ))} +
+
+ ); + })} +
+ ); +} + +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 ( +
  • + + + + +
    +

    {eintrag.summary}

    +
    + {/* Das Projekt gehoert in jede Zeile – ohne es ist eine + projektuebergreifende Liste nicht zu lesen. */} + +
    +
    +
  • + ); +} 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 ? ( +

    + {fehler} +

    + ) : null} + +
    + {weitere ? ( + + ) : ( +

    Das war alles.

    + )} +

    + {eintraege.length} {eintraege.length === 1 ? "Vorgang" : "Vorgänge"} geladen +

    +
    +
    + ); +} diff --git a/src/components/WeeklyReview.tsx b/src/components/WeeklyReview.tsx index 9bba634..0632913 100644 --- a/src/components/WeeklyReview.tsx +++ b/src/components/WeeklyReview.tsx @@ -1,10 +1,7 @@ -import Link from "next/link"; 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 { accentOf } from "@/lib/status"; -import { cn } from "@/lib/utils"; -import type { ReviewDay, ReviewEntry } from "@/types"; +import type { ReviewDay } from "@/types"; /** * Der Wochenrueckblick. @@ -17,26 +14,6 @@ import type { ReviewDay, ReviewEntry } from "@/types"; * 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({ tage, kennzahlen, @@ -86,32 +63,8 @@ export function WeeklyReview({

    ) : null} - {/* Verlauf nach Tagen */} -
    - {tage.map((tag) => { - const datum = alsDatum(tag.tag); - return ( -
    -
    -

    - {WOCHENTAG.format(datum)} -

    - {TAG_MONAT.format(datum)} - - {tag.eintraege.length} - - -
    - -
      - {tag.eintraege.map((eintrag) => ( - - ))} -
    -
    - ); - })} -
    + {/* 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. */} - -
    -
    -
  • - ); -} 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); +} -- 2.54.0