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>
This commit is contained in:
Harlekin
2026-09-01 00:00:58 +02:00
co-authored by Claude Opus 5
commit 4446e3f0ac
16 changed files with 1919 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
# 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
```ts
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`.