CLAUDE.md und README.md verweisen auf docs/, die Dateien lagen aber im Wurzelverzeichnis. Verschoben. docs/13-entscheidungen.md hält fest, wo die Dokumente sich widersprechen und wie es aufgelöst wurde. Der wichtigste Punkt: 04-datenmodell.md verlangt eine Regel, die jedes UPDATE auf CaseEntryVersion verwirft, 05-akten-und-vorlagen.md verlangt, dass eine Umklassifizierung rückwirkend auf alle Versionen wirkt. Beides zusammen geht nicht. Aufgelöst über einen Trigger, der die Tabelle anhängend hält, aber das Anheben von minClearance zulässt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.2 KiB
Performance und Bedienungsgefühl
Dieses Dokument ist verbindlich. Die Anwendung läuft auf einem Homeserver hinter einem Tunnel, also mit spürbarer Grundlatenz. Das Gefühl von Geschwindigkeit entsteht deshalb nicht durch schnelle Server, sondern durch die richtige Reaktion im Client.
Latenzbudgets
| Aktion | Sichtbare Reaktion nach | Fertig nach |
|---|---|---|
| Klick auf einen Knopf | 100 ms (Zustandswechsel) | egal |
| Navigation zu einer Akte | 200 ms (Skelett oder Inhalt) | 1 s |
| Chatnachricht senden | 0 ms (optimistisch) | Hintergrund |
| Vermerk speichern | 0 ms (optimistisch) | Hintergrund |
| Suche tippen | 150 ms (alte Treffer gedimmt) | 600 ms |
| Datei-Upload | 0 ms (Vorschau lokal) | Hintergrund mit Balken |
| PDF-Export | 200 ms (Fortschritt) | Benachrichtigung |
Wenn eine Aktion diese Budgets reißt, ist nicht der Server das Problem, sondern die fehlende Zwischenreaktion.
Grundhaltung
- Nie eine leere Seite mit Spinner. Jede Route hat eine
loading.tsxmit einem Skelett, das die spätere Struktur exakt nachbildet (gleiche Höhen, gleiche Spaltenbreiten). Damit springt nichts, wenn die Daten kommen. - Spinner nur für Vorgänge unbekannter Länge über 400 ms. Vorher gar nichts anzeigen, sonst blitzt es bei schnellen Antworten unangenehm auf.
- Server Components sind der Standard.
'use client'nur dort, wo wirklich Interaktion stattfindet: Editoren, Chat, Uploads, Filterleisten. - Streamen statt sammeln. Kopfdaten sofort, Verlauf und Chat in eigenen
<Suspense>- Grenzen. Der Nutzer liest schon, während der Rest lädt.
Optimistische Updates
React 19 useOptimistic plus Server Actions. Die Regel: Was der Nutzer selbst auslöst und
was fast immer klappt, wird sofort angezeigt.
Optimistisch behandeln:
| Aktion | Optimistische Darstellung | Bei Fehler |
|---|---|---|
| Chatnachricht senden | Nachricht erscheint, leicht gedimmt bis bestätigt | Roter Rahmen, „Erneut senden" |
| Reaktion setzen | Sofort umschalten | Zurücksetzen, dezenter Hinweis |
| Vermerk anlegen | Eintrag oben im Verlauf | Eintrag bleibt, markiert als „nicht gespeichert" |
| Eigenen Eintrag in der Karenzzeit ändern | Text sofort ersetzt | Alter Text zurück plus Toast |
| Anhang löschen | Verschwindet sofort | Kommt zurück plus Toast |
| Wiedervorlage ändern | Neues Datum sofort | Zurücksetzen |
| Beobachten an/aus | Sofort umschalten | Zurücksetzen |
| Als gelesen markieren | Sofort | Still ignorieren, unkritisch |
Nicht optimistisch behandeln, weil folgenreich oder nicht umkehrbar:
- Ändern eines fremden Eintrags oder eines Eintrags nach Ablauf der Karenzzeit. Hier gehört die Begründung erst abgeschickt und bestätigt, bevor der neue Text steht.
- Zurückziehen eines Eintrags
- Statuswechsel eines Falls, besonders
CLOSED - Änderung der Klassifizierung
- Änderung des Rollen-Mappings
- Freigabe eines Exports
- Alles im Adminbereich
Diese bekommen stattdessen einen sofortigen Pending-Zustand am Knopf (useTransition,
Beschriftung wechselt, Knopf bleibt klickbar-aussehend, aber gesperrt) und eine klare
Bestätigung danach.
Rückabwicklung sauber machen
const [optimistic, addOptimistic] = useOptimistic(entries, applyEntry)
async function submit(text: string) {
const id = uuidv7()
addOptimistic({ id, body: text, pending: true })
const res = await createEntry({ id, caseId, body: text })
if (!res.ok) toast.error('Vermerk nicht gespeichert', { action: retry(id) })
}
Die clientseitig erzeugte id ist zugleich Idempotenzschlüssel. Ein Wiederholungsversuch nach
Netzfehler erzeugt keinen zweiten Eintrag.
Löschen: rückgängig statt nachfragen
Für umkehrbare Löschungen (Anhang, Chatnachricht, Verknüpfung) kein Bestätigungsdialog. Stattdessen: Element verschwindet sofort, Toast „Gelöscht · Rückgängig" für 6 Sekunden, erst danach geht der endgültige Aufruf raus. Das ist schneller und sicherer als ein Dialog, den ohnehin niemand liest.
Bestätigungsdialoge bleiben für: Fall schließen, Fall versiegeln, Rollen-Mapping ändern, Sammelexport.
Navigation
<Link prefetch>auf allen Falllisten-Zeilen, zusätzlichrouter.prefetchbeimouseentermit 80 ms Verzögerung, damit nicht beim bloßen Überfahren geladen wird- Route-Cache über
revalidateTag('case:<id>')stattrouter.refresh(), damit nicht die ganze Seite neu berechnet wird - Oben ein dünner Fortschrittsbalken für Routenwechsel, aber erst nach 200 ms einblenden. Schnelle Wechsel sollen nicht flackern.
- Zurück-Navigation nutzt den Browser-Cache, keine Neuabfrage
Listen und Verlauf
- Cursorbasierte Paginierung, kein
OFFSET. Sortierschlüssel ist(createdAt, id). Stabil auch dann, wenn währenddessen neue Einträge dazukommen. - Beim Nachladen bleiben die alten Zeilen stehen (
keepPreviousData), keine leere Tabelle. - Verlauf und Chat ab 200 Einträgen virtualisiert (
@tanstack/react-virtual). - Chat lädt initial die letzten 50 Nachrichten und beim Hochscrollen weitere 50, mit Erhalt der Scrollposition (Höhe vor dem Einfügen merken, danach korrigieren).
Suche
- Eingabe mit 250 ms Debounce
- Laufende Anfrage bei neuer Eingabe per
AbortControllerabbrechen - Während der Suche die alten Treffer auf 50 % Deckkraft setzen statt sie zu entfernen
- Erste Filterung lokal auf bereits geladenen Fällen, damit bei kurzen Listen gar keine Anfrage nötig ist
- Ergebnisse werden pro Suchbegriff kurz gecacht, damit Zurücknavigieren sofort geht
Uploads
- Datei wird sofort im Verlauf gezeigt, Vorschau aus lokalem
URL.createObjectURL - Fortschrittsbalken aus
XMLHttpRequest.upload.onprogress(fetch kann das nicht) - Nach dem Upload Status „Wird geprüft" mit dezenter Animation
attachment.scannedüber SSE tauscht den Zustand auf „Geprüft" oder „Blockiert"- Mehrere Dateien laufen parallel, aber höchstens drei gleichzeitig
- Bilder werden vor dem Upload clientseitig auf max. 2560 px verkleinert, wenn sie größer sind
Bilder
next/imagemit festen Dimensionen, damit nichts springt- Platzhalter aus dem gespeicherten Blurhash, nicht grau
- Vorschaubilder serverseitig erzeugt, das Original wird nie in einer Liste geladen
- Anhänge in der Akte laden erst beim Scrollen (
loading="lazy")
Datenbank
- Indizes wie in
docs/04-datenmodell.md, besonders(caseId, createdAt DESC) - Kein N+1: Beteiligte, Autoren und Anhänge immer per
includemitladen - Autoren-Daten (Name, Avatar) in einer Map im Speicher halten, TTL 5 Minuten
- Verbindungspool über pgbouncer im Transaktionsmodus, Prisma mit
pgbouncer=true - Das Rollen-Mapping wird prozesslokal gecacht und nur bei
pg_notifyverworfen - Der Audit-Eintrag darf die Antwort nicht blockieren. Schreiben in eine Puffertabelle und alle 2 Sekunden im Bündel wegschreiben, aber innerhalb derselben Transaktion für schreibende Aktionen, damit nichts verloren geht.
Auslieferung
- Brotli und HTTP/2 kommen von Cloudflare, im Stack ist dafür nichts zu tun
- Schriften selbst gehostet, als
woff2untergesetzt,font-display: swap - Statische Assets mit Hash im Namen und
immutable-Cache - Keine Komponentenbibliothek im Bundle, das spart mehr als jede Optimierung danach
- Bundle-Budget: erste Route unter 120 kB JavaScript nach Kompression.
pnpm analyzegehört in die CI.
Wahrnehmung schlägt Messung
Ein paar Kleinigkeiten mit unverhältnismäßig großer Wirkung:
- Der Fokus springt nach dem Absenden eines Vermerks sofort zurück ins leere Eingabefeld
- Enter sendet, Shift+Enter macht einen Zeilenumbruch, überall gleich
- Strg+K öffnet die Kommandopalette: Aktenzeichen eintippen und direkt hinspringen
- Beim Öffnen einer Akte steht der Fokus auf dem Inhalt, nicht auf der Navigation
- Entwürfe werden alle 2 Sekunden lokal gespeichert (
localStoragepro Feld), damit ein versehentlicher Tab-Wechsel keinen Text kostet - Beim Verlassen mit ungespeicherten Änderungen eine Warnung, aber nur bei echten Änderungen
- Offline erkannt: Eingaben werden gepuffert, Kopfzeile zeigt „Keine Verbindung, wird nachgesendet"
Messen
web-vitals an einen internen Endpunkt, angezeigt im Adminbereich. Zielwerte:
LCP unter 1,5 s, INP unter 200 ms, CLS unter 0,05. Dazu die Serverzeiten der fünf
häufigsten Abfragen aus pg_stat_statements.