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:
@@ -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`.
|
||||
Reference in New Issue
Block a user