CI / pruefen (pull_request) Canceled after 0s
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
78 lines
2.7 KiB
TypeScript
78 lines
2.7 KiB
TypeScript
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);
|
||
}
|