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); +}