Files
AfiS-Aktensystem/docs/09-performance-und-ux.md
T
HarlekinandClaude Opus 5 4446e3f0ac Fachdokumente nach docs/, Entscheidungen festgehalten
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>
2026-09-01 00:00:58 +02:00

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

  1. Nie eine leere Seite mit Spinner. Jede Route hat eine loading.tsx mit einem Skelett, das die spätere Struktur exakt nachbildet (gleiche Höhen, gleiche Spaltenbreiten). Damit springt nichts, wenn die Daten kommen.
  2. Spinner nur für Vorgänge unbekannter Länge über 400 ms. Vorher gar nichts anzeigen, sonst blitzt es bei schnellen Antworten unangenehm auf.
  3. Server Components sind der Standard. 'use client' nur dort, wo wirklich Interaktion stattfindet: Editoren, Chat, Uploads, Filterleisten.
  4. 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ätzlich router.prefetch bei mouseenter mit 80 ms Verzögerung, damit nicht beim bloßen Überfahren geladen wird
  • Route-Cache über revalidateTag('case:<id>') statt router.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 AbortController abbrechen
  • 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

  1. Datei wird sofort im Verlauf gezeigt, Vorschau aus lokalem URL.createObjectURL
  2. Fortschrittsbalken aus XMLHttpRequest.upload.onprogress (fetch kann das nicht)
  3. Nach dem Upload Status „Wird geprüft" mit dezenter Animation
  4. attachment.scanned über SSE tauscht den Zustand auf „Geprüft" oder „Blockiert"
  5. Mehrere Dateien laufen parallel, aber höchstens drei gleichzeitig
  6. Bilder werden vor dem Upload clientseitig auf max. 2560 px verkleinert, wenn sie größer sind

Bilder

  • next/image mit 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 include mitladen
  • 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_notify verworfen
  • 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 woff2 untergesetzt, 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 analyze gehö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 (localStorage pro 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.