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>
176 lines
8.2 KiB
Markdown
176 lines
8.2 KiB
Markdown
# 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`.
|