Wiki mit semantischer Suche ueber pgvector, Diktat ueber faster-whisper, Berichtsassistent und Belegerfassung — alles gegen selbst betriebene Modelle. Dazu zwei Protokollansichten: was die Maschine getan hat und was Menschen getan haben. Nichts, was ein Modell erzeugt, gelangt ungeprueft in einen Nachweis. Jede Ausgabe ist zuerst ein eigener Datensatz, gekennzeichnet, mit zwei gleich grossen Knoepfen davor. Die Datenbank haelt gegen: Eingabe, Ausgabe und Modell sind unveraenderlich, geloescht wird nicht, entschieden wird einmal. Kein Rueckfall auf einen Anbieter: fehlen die Container, fehlt die Funktion. Dafuer bleibt alles andere ohne Modell vollstaendig benutzbar — geprueft von vierzehn E2E-Tests, die ohne laufendes Modell durchlaufen. Postgres bringt jetzt PostGIS und pgvector mit (eigenes Image), Ollama und faster-whisper stehen im Compose-Profil "ki". 73 neue Unit-Tests, 14 neue E2E-Tests, Version 0.9.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
977 lines
54 KiB
Markdown
977 lines
54 KiB
Markdown
# Devlog
|
||
|
||
Entwicklungstagebuch mit Zeiterfassung. Jeder Arbeitsabschnitt bekommt einen
|
||
Eintrag: wann, wie lange, was entstanden ist, und was dabei schiefging.
|
||
|
||
Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
||
„tatsächlich" der [Aufwandsschätzung](AUFWANDSSCHAETZUNG.md).
|
||
|
||
## Zeitübersicht
|
||
|
||
| Phase | Sitzungszeit | Sitzungen | abgeschlossen |
|
||
|---|---:|---:|---|
|
||
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
|
||
| Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 |
|
||
| Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 |
|
||
| Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 |
|
||
| Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
|
||
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
|
||
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
|
||
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
|
||
| Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
|
||
| Phase 8 — Lokale KI | 3:40 h | 1 | 04.09.2026 |
|
||
| **Summe** | **23:00 h** | **10** | |
|
||
|
||
---
|
||
|
||
## 2026-09-04 · Phase 8 — Lokale KI
|
||
|
||
**07:30 – 11:10 · 3:40 h · Version 0.9.0**
|
||
|
||
### Was entstanden ist
|
||
|
||
Ein Wiki, das man befragen kann, ein Diktat, ein Berichtsassistent, eine
|
||
Belegerfassung — und zwei Protokolle, die belegen, was davon eine Maschine
|
||
getan hat.
|
||
|
||
| Bereich | Dateien |
|
||
|---|---|
|
||
| Rechnen | `lib/ki/zerlegen.ts`, `beleg-lesen.ts`, `anweisungen.ts`, `wiki.ts`, `vorgang.ts` |
|
||
| Kanäle | `lib/integrations/llm.ts` (um Einbettungen und Bilder erweitert), `whisper.ts` |
|
||
| Server | `server/routers/ki/wiki.ts`, `assistent.ts` |
|
||
| Oberfläche | `app/(app)/wiki/`, `belege/`, `ki-protokoll/`, `protokoll/`; `components/ki/` |
|
||
| Hintergrund | `worker/ki.ts`, zwei neue Auftragsarten |
|
||
| Betrieb | `docker/postgres/Dockerfile` (PostGIS **und** pgvector), Ollama und faster-whisper im Profil `ki` |
|
||
| Tests | 73 Unit, 14 E2E je Browser |
|
||
|
||
### Entscheidungen
|
||
|
||
**Nichts Maschinelles gelangt ungeprüft in einen Nachweis.** Das ist die Regel
|
||
aus Phase-8.md, und sie bestimmt den ganzen Aufbau: jede KI-Ausgabe ist zuerst
|
||
ein eigener Datensatz und nicht ein Feld im Zieldokument. Es gibt keine
|
||
Funktion, die eine Ausgabe erzeugt und gleich übernimmt — wer das umgehen will,
|
||
muss Code schreiben, und das fällt in einer Durchsicht auf.
|
||
|
||
Die Datenbank hält gegen: Eingabe, Ausgabe, Modell und Art sind nach dem
|
||
Anlegen unveränderlich, gelöscht wird nicht, und über einen Vorschlag wird
|
||
genau einmal entschieden. Eine Absicht im Code ist eine Absicht; ein Trigger
|
||
ist eine Zusicherung.
|
||
|
||
**Kein Rückfall auf einen Anbieter.** Fehlt der eigene Endpunkt, fehlt die
|
||
Funktion. Ein Einsatzbericht spricht über Menschen in ihrer schlechtesten
|
||
Stunde — solche Texte gehören nirgendwo anders hin als ins Gerätehaus. Und ein
|
||
Rückfall, der „nur im Notfall" greift, ist genau der Fall, in dem niemand
|
||
hinsieht.
|
||
|
||
**Ohne Modell bleibt alles andere benutzbar.** Das war der größte Einzelposten
|
||
der Phase und steht in keiner Aufwandsschätzung. Vier Hinweise an vier Stellen,
|
||
eine zweite Suche, die immer funktioniert, und vierzehn E2E-Tests, die genau
|
||
diesen Zustand absichern — der Testlauf hat keinen Ollama daneben, und das ist
|
||
Absicht.
|
||
|
||
**Drei Modelle statt einem.** Text, Einbettungen und Bilder sind einzeln
|
||
konfigurierbar, weil ein Rechner ohne Grafikkarte das Einbettungsmodell noch
|
||
flott schafft, das Textmodell zäh und das Vision-Modell praktisch nicht. Eine
|
||
Wehr kann so ihr Wiki durchsuchbar machen, ohne Berichte schreiben zu lassen.
|
||
|
||
**Zerlegt wird an den Überschriften, nicht nach Zeichenzahl.** Weil die
|
||
Fundstelle später unter der Antwort steht, und eine Fundstelle soll ein Kapitel
|
||
sein. Dazu kommt die Überschriftenkette in den Einbettungstext: ein Abschnitt
|
||
über „Rückzugsdruck" ohne das Wort „Atemschutz" darüber findet sich sonst
|
||
nicht, wenn jemand nach Atemschutz fragt.
|
||
|
||
**Markdown wird ohne HTML gerendert.** Der Text kommt von Anwendern. Jeder Weg
|
||
über `dangerouslySetInnerHTML` wäre ein Weg für ein `<script>` in einer
|
||
Dienstvorschrift; der Renderer baut React-Elemente, und ein spitzes
|
||
Klammernpaar bleibt ein spitzes Klammernpaar.
|
||
|
||
**HNSW statt IVFFlat.** IVFFlat muss auf einem gefüllten Bestand trainiert
|
||
werden und liefert auf einer frisch angelegten Tabelle schlechte Treffer. Ein
|
||
Wiki wächst Seite für Seite — es gibt nie den Moment, an dem man den Index
|
||
einmal aufbaut.
|
||
|
||
**Ein eigenes Postgres-Image.** pgvector und PostGIS gibt es nicht zusammen.
|
||
Die Alternative wäre gewesen, den Kosinusabstand in TypeScript zu rechnen: geht
|
||
bei zweihundert Wiki-Seiten, wird bei zweitausend zäh, und verlegt eine Aufgabe
|
||
in die Anwendung, für die die Datenbank einen Index hat.
|
||
|
||
**Die Audit-Log-Ansicht kam mit dazu.** Sie stand seit Phase 0 als ausgegrauter
|
||
Eintrag in der Navigation, markiert mit „ab Phase 8". Mit dem KI-Protokoll
|
||
daneben wäre es schief geworden: die Wehr könnte nachlesen, was die Maschine
|
||
getan hat, aber nicht, was Menschen getan haben. Der Router dafür existierte
|
||
seit Phase 0 — es fehlte nur die Seite.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Ein kurzer Abschnitt wurde unter eine fremde Überschrift gehängt | Die Regel „zu kurz → an den vorherigen anhängen" kannte keine Kapitelgrenze. Unter einer Antwort hätte dann eine Fundstelle gestanden, in der der zitierte Satz gar nicht vorkommt | Zusammengezogen wird nur innerhalb desselben Überschriftenpfads. Gefunden hat es der Test, nicht das Ausprobieren |
|
||
| `prisma migrate dev` wollte die Datenbank zurücksetzen | Die zurückgerollte Migration aus Phase 7 stand noch in `_prisma_migrations` und galt als „applied but missing" | Die Zeile entfernt. Ein `migrate resolve --rolled-back` räumt sie nicht weg |
|
||
| Prisma wollte wieder die Volltextindizes abräumen | Bekannt seit Phase 1, achte Migration in Folge | 27 DropIndex-Blöcke entfernt, im Kopf der Migration vermerkt |
|
||
| Die abgeleiteten Typen der Oberfläche waren `{}` | `ReturnType<typeof useQuery>["data"]` funktioniert bei tRPC nicht | `inferRouterOutputs<AppRouter>` — wie schon in Phase 7 |
|
||
|
||
### Abweichung von der Vorgabe
|
||
|
||
**Das Wiki hat keine Bilder.** Phase-8.md nennt sie nicht, aber man erwartet
|
||
sie in einem Wiki. Sie fehlen bewusst: ein Bild in einer Seite wäre entweder
|
||
eine Datei in MinIO mit eigener Rechteprüfung beim Ausliefern, oder ein Verweis
|
||
auf einen fremden Server — und Letzteres verbietet die Grundregel, dass FLORIAN
|
||
nichts von außen nachlädt. Nachrüstbar, wenn eine Wehr es vermisst.
|
||
|
||
**Kein Streaming der Modellantwort.** Der Bericht erscheint am Stück, nicht Wort
|
||
für Wort. Das sähe moderner aus, ist hier aber falsch: der Text soll gelesen und
|
||
gegengezeichnet werden, nicht beim Entstehen bewundert. Und ein halb geschriebener
|
||
Entwurf, den jemand vorzeitig übernimmt, wäre genau die Lücke, die diese Phase
|
||
schließen soll.
|
||
|
||
### Nachweise
|
||
|
||
- `npx tsc --noEmit` — ohne Ausgabe
|
||
- `npm run test` — 495 Tests grün, davon 21 neue gegen eine echte Datenbank
|
||
- `npx playwright test tests/e2e/wissen.spec.ts` — 14 grün, ohne laufendes Modell
|
||
- `npm run build` — 56 Routen
|
||
- `npm run db:seed` — vier Wiki-Seiten mit eigenem Text
|
||
- `docker compose build postgres` — PostGIS 3.5 und pgvector 0.8.6 im selben Image
|
||
- `docker compose config` — Ollama und faster-whisper im Profil `ki` prüfen durch
|
||
|
||
---
|
||
|
||
## 2026-09-04 · Phase 7 — Benachrichtigungen und Termine
|
||
|
||
**04:10 – 07:20 · 3:10 h · Version 0.8.0**
|
||
|
||
### Was entstanden ist
|
||
|
||
Der Jahresplan der Wehr und die Wege, auf denen die Mannschaft davon erfährt.
|
||
|
||
| Bereich | Dateien |
|
||
|---|---|
|
||
| Rechnen | `lib/termin/serie.ts`, `ics.ts`, `lib/benachrichtigung/vorlagen.ts`, `versand.ts`, `lib/dienstausweis.ts` |
|
||
| Kanäle | `lib/integrations/mail.ts` (echtes SMTP), `ntfy.ts`, `webpush.ts` |
|
||
| Server | `server/routers/termin/termin.ts`, `benachrichtigung.ts`, `stammdaten/dienstausweis.ts` |
|
||
| Öffentlich | `api/kalender/[token]`, `ausweis/[code]`, `api/ausweis/[code]/foto` |
|
||
| Oberfläche | `app/(app)/termine/`, `benachrichtigungen/`, `ausweis/` |
|
||
| Hintergrund | `worker/benachrichtigung.ts`, zwei neue Zeitpläne |
|
||
| Tests | 56 Unit, 17 E2E je Browser |
|
||
|
||
### Entscheidungen
|
||
|
||
**Niemand wird ungefragt benachrichtigt.** Ein neu eingerichteter Zustellweg
|
||
hat eine leere Ereignisliste. Es gibt keinen Weg, jemand anderem einen Kanal
|
||
einzutragen — auch nicht für den Kommandanten. Der Seed legt bewusst *keine*
|
||
Zustellkanäle an, obwohl das die Beispieldaten hübscher machen würde. Wer eine
|
||
Wehr ungefragt anmeldet, hat binnen einer Woche eine Wehr, die die
|
||
Benachrichtigungen abschaltet, und danach kommen auch die wichtigen nicht mehr
|
||
an.
|
||
|
||
**Serientermine werden ausgeschrieben, nicht gerechnet.** Eine Regel wäre
|
||
weniger Zeilen in der Datenbank und mehr Sonderfälle im Code: der Übungsabend
|
||
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
|
||
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Ausgeschrieben ist jede
|
||
dieser Ausnahmen einfach ein Datensatz, den man ändert. Der Preis sind hundert
|
||
Zeilen für zwei Jahre — Kleingeld, und die Zahl steht vor dem Anlegen in der
|
||
Maske.
|
||
|
||
**Gerechnet wird auf dem Kalender, nicht in Millisekunden.** „Monatlich" heißt
|
||
derselbe Tag im nächsten Monat. `setMonth(+1)` auf dem 31. Januar ergibt den
|
||
3. März, weil der Februar keinen 31. hat — ein Fehler, der in einer
|
||
Übungsserie erst im Frühjahr auffällt. Fällt der Tag im Zielmonat aus, rutscht
|
||
der Termin auf den letzten des Monats.
|
||
|
||
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird eine
|
||
Zeile geschrieben, sonst nichts. Der Worker nimmt sich, was auf WARTET steht.
|
||
Wären es eine Sache, hinge das Speichern eines Termins am Antwortverhalten von
|
||
Telegram. So ist die schlimmste Folge eines toten Mailservers eine
|
||
Benachrichtigung, die auf FEHLGESCHLAGEN steht und sichtbar ist.
|
||
|
||
**Intern bekommt den langen Text, extern die Kurzfassung.** Der Mailserver
|
||
einer Wehr ist ihr eigener oder der der Gemeinde, und ein selbst betriebener
|
||
ntfy steht im Gerätehaus — dorthin dürfen Namen, Gerätebezeichnungen und
|
||
Einsatznummern. Telegram und Web-Push laufen über fremde Server und bekommen
|
||
„3 Prüfungen fällig", nicht welche. Das steht in `KANAL_EXTERN` an einer
|
||
Stelle, wird im Test für *jeden* externen Kanal geprüft und in der Oberfläche
|
||
zweimal gesagt: neben dem Kanal und über dem Vorlagenfeld.
|
||
|
||
**Kein CalDAV, sondern ICS.** CalDAV ist ein Schreib-Lese-Protokoll mit
|
||
Rechteverwaltung und Konfliktauflösung. Gebraucht wird Lesen. Eine Adresse, die
|
||
eine ICS-Datei ausliefert, kann jeder Kalender — das Format ist eine Liste von
|
||
Zeilen, und die interessanten Teile (Zeitzone, Umbruch nach 75 Oktetten) stehen
|
||
in zwanzig Zeilen. Eine Abhängigkeit dafür wäre in einer Anwendung ohne
|
||
Netzzugang der schlechtere Tausch.
|
||
|
||
**Der Ausweis gilt fünf Minuten, die Anmeldemarke eine.** Beide sind mit
|
||
AUTH_SECRET signiert; was sie trennt, ist der Zweck im signierten Teil. Ohne
|
||
den ginge das eine als das andere durch, und der Ausweis wäre plötzlich ein
|
||
Anmeldeweg. Die fünf Minuten sind der Unterschied zwischen „wird gescannt" und
|
||
„wird vorgezeigt, abgelesen und weggesteckt" — und die Prüfseite nennt den
|
||
Zeitpunkt der Ausstellung, weil ein Ausweis, der „gültig" sagt ohne zu sagen
|
||
seit wann, wertlos ist.
|
||
|
||
**Fehlgeschlagene Benachrichtigungen werden nicht abgeräumt.** Der
|
||
Aufbewahrungslauf nimmt sich nur die zugestellten. Eine fehlgeschlagene ist der
|
||
Hinweis darauf, dass ein Kanal kaputt ist, und der verliert seinen Wert nicht
|
||
durch Zeitablauf.
|
||
|
||
**Erinnert wird um sieben, zugestellt jede Minute.** Eine Push-Meldung um drei
|
||
Uhr weckt Leute, die am nächsten Tag arbeiten müssen. Eine Absage am
|
||
Dienstagabend muss dagegen ankommen, solange sie noch etwas nützt.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Die Vorlagenmaske überschrieb den gerade getippten Text | Der Effekt, der den passenden Text lädt, hing an `vorlagen.data`. Wird die Liste im Hintergrund nachgeladen — nach dem Speichern, beim Zurückkommen ins Fenster —, war die Eingabe weg | Merker auf `ereignis:kanal`; geladen wird nur beim Wechsel der Auswahl. Gefunden hat es der E2E-Test, nicht das Ausprobieren |
|
||
| `personId` wurde zweimal gesetzt und überschrieb sich | Einschränkung und Ausschluss als zwei Objekt-Einträge im selben `where` — der zweite gewinnt still | Beide in eine Bedingung zusammengeführt |
|
||
| Der Aufräumlauf durfte nicht löschen | `REVOKE DELETE ON "benachrichtigung"` gilt der Anwendungsrolle, und `fuerOrganisation` benutzt genau die | Über `ohneMandantenfilter` mit ausdrücklichem Mandantenfilter in der Bedingung — begründet, wie es die Regel verlangt |
|
||
| Zwei E2E-Selektoren trafen zu viel | „Absagen" steckt auch in „Termin absagen"; `{titel}` steht im Textfeld *und* auf dem Platzhalterknopf | `exact: true` und Suche innerhalb des Dialogs |
|
||
| Der Warteliste-Test hing an der Reihenfolge im Seed | Ob der angemeldete Nutzer schon einen Platz hat, entscheidet die Mitgliedsnummer | Test prüft jetzt die Anzeige der Warteliste — die gilt für alle |
|
||
|
||
### Abweichung von der Vorgabe
|
||
|
||
**Kein CalDAV**, obwohl Phase-7.md „CalDAV-/ICS-Feed" nennt. Begründung oben;
|
||
sie steht auch im README und in der Roadmap.
|
||
|
||
**Termine einer Serie werden einzeln geändert, nicht „alle folgenden".** Der
|
||
Fall ist selten und mehrdeutig — meint „alle folgenden" auch die, die jemand
|
||
schon von Hand verschoben hat? Absagen geht dagegen für die ganze Serie, weil
|
||
das der Fall ist, der wirklich vorkommt (Sommerpause, Bauarbeiten am
|
||
Gerätehaus).
|
||
|
||
**Kein Push für iOS ohne Umweg.** Web-Push funktioniert auf dem iPhone erst,
|
||
wenn FLORIAN zum Startbildschirm hinzugefügt wurde. Das steht in der
|
||
Oberfläche; ein eigener Weg dafür wäre eine App im Store, und die gibt es
|
||
bewusst nicht.
|
||
|
||
### Nachweise
|
||
|
||
- `npx tsc --noEmit` — ohne Ausgabe
|
||
- `npm run test` — 422 Tests grün, davon 15 neue gegen eine echte Datenbank
|
||
- `npx playwright test tests/e2e/termine.spec.ts` — 17 grün
|
||
- `npm run build` — 50 Routen, darunter `/termine`, `/benachrichtigungen`,
|
||
`/ausweis` und die beiden öffentlichen Endpunkte
|
||
- `npm run db:seed` — 19 Termine, Warteliste und abgehakte Anwesenheit
|
||
- `docker compose config` — ntfy im Profil `push` prüft durch
|
||
|
||
---
|
||
|
||
## 2026-09-04 · Phase 6 — Kartenmodule
|
||
|
||
**22:10 – 01:00 · 2:50 h · Version 0.7.0**
|
||
|
||
### Was entstanden ist
|
||
|
||
Alles, was einen Ort hat: Löschwasser, Objekte mit Feuerwehrplan, die Lagekarte
|
||
eines Einsatzes und die geplante Förderstrecke.
|
||
|
||
| Bereich | Dateien |
|
||
|---|---|
|
||
| Rechnen | `lib/karte/wasserfoerderung.ts`, `hydrant-import.ts`, `taktische-zeichen.ts` |
|
||
| Server | `server/routers/karte/hydrant.ts`, `objekt.ts`, `lagekarte.ts` |
|
||
| Karte | `components/karte/karte.tsx` (MapLibre), `api/lagekarte/strom` |
|
||
| Oberfläche | `app/(app)/karte/` mit Karte, Hydranten, Objekten, Wasserförderung; Lagekarte im Einsatz |
|
||
| Betrieb | tileserver-gl im Compose-Profil `karte`, `docker/karten/LIESMICH.md` |
|
||
| Tests | 48 Unit, 11 E2E je Browser |
|
||
|
||
### Entscheidungen
|
||
|
||
**Kein öffentlicher Kartendienst — auch nicht als Rückfall.** Wo eine Wehr
|
||
ausrückt und wo ihre Hydranten liegen, ist niemandes Geschäft. Die Kacheln
|
||
kommen aus dem eigenen tileserver-gl. Ohne ihn zeigt die Karte einen leeren
|
||
Grund **und sagt das**; Punkte, Entfernungen und Umkreissuche funktionieren
|
||
weiter. Ein stiller leerer Kasten wäre die schlechtere Lösung gewesen: er sieht
|
||
aus wie ein Fehler, ohne einer zu sein.
|
||
|
||
**Die Umkreissuche gehört in die Datenbank.** `ST_DWithin` auf der Geografie
|
||
mit GiST-Index rechnet in Metern und nutzt den Index. In der Anwendung wäre es
|
||
ein vollständiger Tabellendurchlauf mit Grad-Arithmetik — und gefragt wird
|
||
während der Fahrt.
|
||
|
||
**Der Import rät nicht.** Er erkennt viel („DN 100", „48,5031", „24.05.2026",
|
||
vertauschte Achsen), aber wo die Position fehlt, wird die Zeile übersprungen
|
||
statt geschätzt. Und ohne Statusangabe steht „ungeprüft" — nicht
|
||
„einsatzbereit". Eine Liste, die tausend Hydranten als geprüft ausweist, weil
|
||
die Spalte fehlte, wäre schlimmer als keine Liste.
|
||
|
||
**Ein Ereignisstrom für beides.** Die Lagekarte benutzt denselben Hook wie die
|
||
Atemschutzüberwachung aus Phase 5, nur mit anderer Adresse. Zwei Ströme mit
|
||
unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler
|
||
verstecken kann.
|
||
|
||
**Das Ergebnis der Wasserförderung wird nicht gespeichert.** Nur die Eingaben.
|
||
Eine gespeicherte Zahl neben geänderten Eingaben wäre falsch, ohne falsch
|
||
auszusehen.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Der Druckverlust lag um den Faktor zehn daneben | Die Beiwerte waren aus dem Kopf gegriffen statt aus der Formel abgeleitet: gebraucht wird `verlust = beiwert × (menge/100)²`, also 0,0234 für den B-Schlauch, nicht 0,23 | Beiwerte aus den Tabellenwerten zurückgerechnet. Aufgefallen ist es nur, weil die Tests gegen die Ausbildungstabelle prüfen und nicht gegen die eigene Implementierung |
|
||
| Ein nicht erreichbarer Kartenserver ließ die Karte kaputt aussehen | Der Hinweis „kein Hintergrund" hing an `stilUrl === null`, nicht daran, ob der Stil geladen wurde | MapLibre meldet den Fehlschlag; die Karte fällt jetzt in denselben ehrlichen Zustand wie ohne Server |
|
||
| Die Klicks in die Karte gingen im E2E-Test ins Leere | Die Lagekarte liegt unten auf der Einsatzseite, außerhalb des Fensters. `page.mouse` klickt in Fensterkoordinaten | Karte vor dem Klick ins Bild holen |
|
||
| Prisma wollte wieder die Volltextindizes abräumen | Bekannt seit Phase 1 | DropIndex-Block aus der Migration entfernt, im Kopf vermerkt |
|
||
|
||
### Abweichung von der Vorgabe
|
||
|
||
**Keine Cluster-Darstellung.** Phase-6.md nennt sie. Bei 24 Löschwasserstellen
|
||
im Beispiel und einigen hundert in einer echten Wehr sind Cluster eine Lösung
|
||
für ein Problem, das erst bei Zehntausenden entsteht — und sie verstecken genau
|
||
das, was man sehen will: die Lücke im Netz. Nachrüstbar, wenn eine Wehr mit
|
||
großem Ausrückegebiet es vermisst.
|
||
|
||
**Kein Höhenprofil aus einem Geländemodell.** Die Höhe je Punkt wird von Hand
|
||
eingetragen. Ein Höhenmodell (SRTM, DGM) wäre ein weiterer Datensatz von
|
||
hunderten Megabyte, den jede Wehr selbst beschaffen müsste. Die Rechnung ist
|
||
ohne Höhe zu günstig — und das steht auch so an der Eingabe.
|
||
|
||
### Nachweise
|
||
|
||
- `npx tsc --noEmit` — ohne Ausgabe
|
||
- `npm run test` — 354 Tests grün
|
||
- `npx playwright test tests/e2e/karte.spec.ts` — 22 grün (Desktop und Tablet)
|
||
- `npm run build` — 44 Routen
|
||
- `npm run db:seed` — 24 Löschwasserstellen, 3 Objekte, Standorte verortet
|
||
|
||
---
|
||
|
||
## 2026-09-03 · Phase 5 — Atemschutz nach FwDV 7
|
||
|
||
**19:40 – 22:20 · 2:40 h · Version 0.6.0**
|
||
|
||
### Was entstanden ist
|
||
|
||
Die Atemschutzüberwachung. Sieben Tabellen, ein Ereignisstrom, ein
|
||
lückenloses Protokoll — und der Hinweis über allem, dass die analoge
|
||
Überwachung führend bleibt.
|
||
|
||
| Bereich | Dateien |
|
||
|---|---|
|
||
| Rechnen | `lib/atemschutz/berechnung.ts`, `tauglichkeit.ts`, `verfuegbarkeit.ts` |
|
||
| Server | `server/routers/atemschutz/ueberwachung.ts`, `traeger.ts` |
|
||
| Strom | `app/api/atemschutz/strom/route.ts`, `components/atemschutz/stand.ts` |
|
||
| Protokoll | `lib/atemschutz/protokoll.ts`, `protokoll-erzeugen.ts`, `api/atemschutz/protokoll` |
|
||
| Oberfläche | `app/(app)/atemschutz/`, `components/atemschutz/` |
|
||
| Tests | 51 Unit, 17 Datenbank, 8 E2E je Browser |
|
||
|
||
### Entscheidungen
|
||
|
||
**Server-Sent Events statt Socket.IO.** Phase-5.md nennt Socket.IO; das
|
||
verlangt in Next.js einen eigenen Node-Server und damit ein anderes
|
||
Deployment. Gebraucht wird aber nur eine Richtung: der Server meldet „es hat
|
||
sich etwas geändert", der Browser holt den Stand über tRPC. Ein Route Handler
|
||
genügt, `EventSource` verbindet nach einem Abbruch von selbst neu, und
|
||
`next start` bleibt wie es ist. Nach Rücksprache so entschieden.
|
||
|
||
**Es gibt keinen Timer.** Kein Zähler, kein Intervall, das Zeit führt. Jede
|
||
Dauer entsteht aus zwei Zeitstempeln — einem aus der Datenbank, einem von der
|
||
Uhr. Damit ist die Anforderung „übersteht Reload und Verbindungsabbruch" keine
|
||
Funktion, die man testen muss, sondern eine Eigenschaft, die sich nicht
|
||
verlieren lässt. Getestet wird sie trotzdem.
|
||
|
||
**Der Rückzugsdruck nach dem doppelten Anmarschverbrauch.** Nach Rücksprache:
|
||
die Wehr rechnet mit dem tatsächlichen Verbrauch auf dem Hinweg. Solange keine
|
||
Zielmeldung vorliegt, greift die Zwei-Drittel-Regel — sie liegt höher, ist also
|
||
die vorsichtigere Annahme, und das steht auch so auf der Karte.
|
||
|
||
**Die Tauglichkeit sperrt niemanden.** Neben jedem Namen steht, ob die Person
|
||
nach FwDV 7 einsetzbar wäre und was fehlt. Verweigert wird nichts. Ein
|
||
Programm, das im Einsatz eine Person blockiert, wird umgangen — und dann steht
|
||
am Ende gar nichts im Protokoll. Der Hinweis nützt, die Sperre schadet.
|
||
|
||
**Das Protokoll ist doppelt gesichert.** Entzogene Rechte **und** ein Trigger
|
||
auf `druckmessung` und `atemschutz_ereignis`. Eine Sperre hätte gereicht; bei
|
||
einer Aufzeichnung, an der Menschenleben hängen, ist die zweite ihren Preis
|
||
wert.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Ein gekapptes Netz ließ die Anzeige „Verbunden" melden | `context.setOffline()` trennt einen **offenen** SSE-Strom in Chromium nicht — er liefert weiter. Der Test prüfte damit etwas anderes als gedacht. Schlimmer: bei einem echten Abriss schweigt `EventSource`, ohne einen Fehler zu melden | Der Server schickt alle zwei Sekunden ein Lebenszeichen; bleibt es acht Sekunden aus, gilt die Verbindung als tot. Der Test schneidet den Strom jetzt über eine abgewiesene Route ab, statt das Netz zu kappen |
|
||
| Prisma meldete den Teilindex nicht beim Namen | Ein `UNIQUE INDEX … WHERE` erscheint als verletzte Eindeutigkeit auf der Spalte | Zusicherung im Test daran angepasst, mit Verweis auf den Index |
|
||
| Die Bildschirmfotos zeigten Testleichen | 34 Überwachungen aus den E2E-Läufen standen über der einen echten aus dem Seed | Rückstände in der Entwicklungsdatenbank abgeräumt, Auswahl im Skript auf Nicht-Testdaten eingegrenzt. Die Ursache bleibt: die E2E-Suite schreibt in die Demowehr |
|
||
|
||
### Abweichung von der Vorgabe
|
||
|
||
**Kein Socket.IO.** Siehe oben — nach Rücksprache durch Server-Sent Events
|
||
ersetzt. Die Roadmap ist entsprechend berichtigt.
|
||
|
||
**Kein RFID-Scan beim Truppanlegen.** Phase-5.md nennt „Truppanlage per
|
||
RFID-Scan oder Auswahl". Umgesetzt ist die Auswahl. Der Kartenleser hängt am
|
||
Terminal im Gerätehaus (Phase 4) und meldet dort Personen an; am
|
||
Überwachungsschirm steht der Überwacher mit einem Tablet und wählt zwei Namen
|
||
aus einer Liste, auf der ohnehin die Tauglichkeit steht. Der Scan wäre dort
|
||
ein zweiter Weg zum selben Ziel — nachrüstbar, wenn er im Betrieb vermisst
|
||
wird.
|
||
|
||
### Nachweise
|
||
|
||
- `npx tsc --noEmit` — ohne Ausgabe
|
||
- `npm run test` — 306 Tests grün, davon 36 gegen eine echte Datenbank
|
||
- `npx playwright test tests/e2e/atemschutz.spec.ts` — 16 grün (Desktop und Tablet)
|
||
- `npm run db:seed` — Nachweise und eine abgeschlossene Überwachung angelegt
|
||
|
||
---
|
||
|
||
## 2026-09-03 · Phase 4 — Kiosk-PWA im Gerätehaus
|
||
|
||
**14:40 – 17:40 · 3:00 h · Version 0.5.0**
|
||
|
||
### Was entstanden ist
|
||
|
||
Das Terminal an der Wand. Sieben neue Tabellen, eine eigene Oberfläche unter
|
||
`/kiosk`, ein Service Worker und eine Warteschlange, die den Betrieb ohne Netz
|
||
trägt.
|
||
|
||
| Bereich | Dateien |
|
||
|---|---|
|
||
| Datenmodell | `Fahrt`, `Anwesenheit`, `Waescheabgabe`, `Inventurgang`, `Inventurposten`, `KioskVorgang`, `SyncKonflikt` |
|
||
| Offline | `public/sw.js`, `lib/kiosk/outbox.ts`, `components/kiosk/warteschlange.tsx` |
|
||
| Anmeldung | `lib/kiosk/anmeldemarke.ts`, Anbieter `terminal-marke` in `lib/auth.ts`, `components/kiosk/anmeldung.tsx` |
|
||
| Oberfläche | `app/kiosk/` mit Kacheln, sechs Wizards, Monitor und Einrichtung |
|
||
| Verwaltung | `app/(app)/terminal/`, `app/(app)/anmeldemarke/` |
|
||
| Server | `server/routers/kiosk/kiosk.ts`, `lib/integrations/wetter.ts` |
|
||
| Tests | `tests/unit/kiosk-marke.test.ts`, `kiosk-outbox.test.ts`, `kiosk-datenbank.test.ts`, `tests/e2e/kiosk.spec.ts` |
|
||
|
||
### Entscheidungen
|
||
|
||
**Die Warteschlange ist der einzige Weg, auch online.** Es wäre naheliegend,
|
||
online direkt zu senden und nur im Fehlerfall zu puffern. Dann gäbe es zwei
|
||
Wege durch den Code — und der seltenere wäre der ungetestete, also genau der,
|
||
der im Gerätehaus gebraucht wird. Jetzt läuft alles durch dieselbe Bahn, und
|
||
der Offline-Fall ist der Normalfall mit längerer Wartezeit.
|
||
|
||
**Die Kennung erzeugt der Browser, nicht der Server.** `crypto.randomUUID()`
|
||
beim Einreihen. Damit ist sie derselbe Wert über beliebig viele Sendeversuche
|
||
hinweg, und der Server kann einen wiederholten Vorgang erkennen, statt ihn
|
||
doppelt zu buchen. Die Kennung ist der Primärschlüssel von `kiosk_vorgang`, und
|
||
die Datenbank verweigert dem Anwendungszugang `UPDATE` und `DELETE` darauf —
|
||
sonst ließe sich die Spur nachträglich glätten.
|
||
|
||
**Konflikte werden nicht erzwungen und nicht verworfen.** Ein Tablet, das drei
|
||
Tage offline war, schickt Vorgänge, deren Ziel es nicht mehr gibt oder das
|
||
inzwischen festgeschrieben ist. Beides ist kein Fehler des Anwenders. Der
|
||
Vorgang landet in `sync_konflikt`, verschwindet aus der Warteschlange (er ist
|
||
angekommen) und wartet unter `/terminal` auf eine Entscheidung.
|
||
|
||
**Die Prüfung bleibt online.** Der Wizard aus Phase 2 mit Checkliste, Messwerten,
|
||
Fotos und Unterschrift lässt sich nicht sinnvoll in eine Warteschlange legen —
|
||
und ein halb erfasstes Protokoll ist schlechter als keins. Die Kachel zeigt
|
||
deshalb „braucht Netz" und ist ohne Verbindung gesperrt, statt ins Leere zu
|
||
führen. `PRUEFUNG_ERFASSEN` steht trotzdem im Datenmodell: die Atemschutzprüfung
|
||
in Phase 5 ist kurz genug dafür.
|
||
|
||
**Die Anmeldemarke wird signiert, nicht gespeichert.** HMAC über Person, Wehr
|
||
und Zeitstempel mit `AUTH_SECRET`, gültig 60 Sekunden. Keine Tabelle, kein
|
||
Aufräumjob, kein zweiter Ort mit einem Geheimnis — und ein abfotografierter
|
||
Code aus der Umkleide ist nach einer Minute wertlos.
|
||
|
||
**Der Kartenleser hängt am Fenster, nicht an einem Eingabefeld.** Ein RFID-Leser
|
||
tippt die Kennung in Millisekunden und schließt mit Enter ab. Wer die Karte
|
||
auflegt, soll vorher nichts antippen müssen; unterschieden wird am
|
||
Anschlagstempo (60 ms), nicht am Fokus.
|
||
|
||
**Wetterwarnungen sind aus.** Es ist die einzige Verbindung, die FLORIAN von
|
||
sich aus nach außen aufbaut. Wer sie einschaltet, soll das bewusst tun; ohne
|
||
`DWD_WARNCELL` passiert nichts, und bei Ausfall bleibt die Kachel leer statt
|
||
den Monitor aufzuhalten.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Die Warteschlange schickte einen liegen gebliebenen Vorgang nie ein zweites Mal | `jetztSenden` hatte das Mutationsobjekt von React Query in den Abhängigkeiten. Das ist bei jedem Rendern ein neues, also änderte sich die Identität der Funktion dauernd, der Effekt lief neu, zog den 20-Sekunden-Zeitgeber neu auf — und der lief nie ab. Dazu wurde der `online`-Zuhörer ständig neu registriert und verpasste das Ereignis manchmal | Mutation, tRPC-Werkzeuge und das „läuft gerade"-Kennzeichen über Referenzen statt über Abhängigkeiten. Damit ist `jetztSenden` stabil |
|
||
| Der Produktionsbuild brach bei `/kiosk/einrichten` ab | `useSearchParams()` ohne Suspense-Grenze lässt sich nicht vorab erzeugen | `<Suspense>` um das Formular |
|
||
| Der Build brach bei `wetter.ts` ab | Der Hallenmonitor ist eine Client-Komponente und importierte die Warnstufentexte aus einem `server-only`-Modul | Texte nach `lib/integrations/wetter-texte.ts`, dieselbe Trennung wie bei `qr.ts` / `qr-parser.ts` |
|
||
| Der Outbox-Test lief ins Leere | `speicherVerfuegbar()` fragt `window`, und die Testsuite läuft in Node | Diese Datei unter jsdom (`@vitest-environment`) mit `fake-indexeddb` |
|
||
| Das Einrichtungsformular ließ sich im Test nicht abschicken | Playwright klickte, bevor der Effekt die Felder aus der Adresse gefüllt hatte — das Pflichtfeld war leer | Im Test auf die Feldwerte warten |
|
||
| Der Idempotenztest sah in der Verwaltung kein Anmeldeformular | Im selben Browserkontext lief noch die Terminalsitzung, und `/anmelden` leitet Angemeldete weiter | Prüfung in einen eigenen Test mit frischem Kontext |
|
||
| `getByText("B 3 Wohnhausbrand")` in den Einsatztests wurde mehrdeutig | Frühere E2E-Läufe haben Einsätze mit ähnlichem Stichwort in der Demowehr angelegt | `exact: true`; die eigentliche Ursache bleibt offen (siehe unten) |
|
||
|
||
### Abweichung von der Vorgabe
|
||
|
||
**Kein Auto-Logout nach 90 Sekunden.** Die Roadmap nennt 90 Sekunden, die
|
||
Konfiguration hatte schon `KIOSK_IDLE_TIMEOUT=120`. Der konfigurierte Wert
|
||
gewinnt — 90 Sekunden sind knapp, wenn jemand mitten in einer Mangelmeldung
|
||
noch das Foto machen muss. Die Roadmap ist entsprechend berichtigt.
|
||
|
||
**`npm run lint` läuft in diesem Projekt nicht.** Es gibt keine
|
||
ESLint-Konfiguration; `next lint` fragt interaktiv nach einer. Das ist älter als
|
||
diese Phase und in ihr nicht behoben — es würde quer über alle bisherigen
|
||
Dateien Befunde erzeugen und gehört in einen eigenen Schritt.
|
||
|
||
### Nachweise
|
||
|
||
- `npx tsc --noEmit` — ohne Ausgabe
|
||
- `npm run test` — 258 Tests grün, davon 19 gegen eine echte Datenbank
|
||
- `npx playwright test tests/e2e/kiosk.spec.ts` — 18 Tests grün (Desktop und Tablet)
|
||
- `npm run build` — 34 Routen, 103 kB gemeinsames JavaScript
|
||
- `npm run db:seed` — Fahrtenbuch, Anwesenheiten und Wäsche angelegt
|
||
|
||
---
|
||
|
||
## 2026-09-03 · Phase 3 — Einsätze und Übungen
|
||
|
||
**12:25 – 14:35 · 2:10 h · Version 0.4.0**
|
||
|
||
### Was entstanden ist
|
||
|
||
Ab hier dokumentiert die Wehr, was sie tut. Acht neue Tabellen, ein öffentlich
|
||
erreichbares Portal und die Zahlen, nach denen die Gemeinde fragt.
|
||
|
||
**Schnellerfassung.** Im Alarmfall fragt FLORIAN drei Dinge: Stichwort, Ort,
|
||
welche Fahrzeuge raus sind. Alarmzeit ist der Moment des Anlegens. Alles
|
||
Weitere wird nachgetragen, wenn wieder Ruhe ist.
|
||
|
||
**Zeitstempel und Kennzahlen.** Sechs Stempel, je ein Knopf. Daraus rechnet
|
||
sich Ausrückzeit, Anfahrtszeit, Hilfsfrist und Einsatzdauer.
|
||
|
||
**Stärke nach FwDV 3.** Aus den Funktionen der Mannschaft, in der üblichen
|
||
Schreibweise, mit Zuordnung zur taktischen Einheit.
|
||
|
||
**Einsatzbericht.** Als PDF über Gotenberg, mit der Freigabe festgeschrieben.
|
||
Dazu der Jahresexport quer gedruckt für die Gemeinde.
|
||
|
||
**Statistik.** Kategorien nach Landesstatistik, Verteilung der Hilfsfristen mit
|
||
dem 80-Prozent-Wert, Beteiligung je Person, Auslastung je Fahrzeug.
|
||
|
||
**Portal für Fremdwehren.** Ein signierter Link ohne Anmeldung: Stand sehen,
|
||
Lagemeldung mit Foto absetzen. Zeitlich begrenzt, mengenbegrenzt, widerrufbar.
|
||
|
||
**Übungen.** Thema, Lernziele, Anwesenheit zum Abhaken — die Grundlage für die
|
||
Teilnahmequote in der Jahreshauptversammlung.
|
||
|
||
### Entscheidungen
|
||
|
||
**Die Hilfsfrist wird als 80-Prozent-Wert ausgewiesen, nicht als Durchschnitt.**
|
||
Der Durchschnitt sagt bei Eintreffzeiten nichts: ein einzelner Einsatz auf der
|
||
Autobahn zieht ihn nach oben, während neunzig Prozent der Fälle im Ort liegen.
|
||
In Bayern gilt als Ziel, in 80 Prozent der Fälle binnen zehn Minuten da zu
|
||
sein — also ist genau das die Zahl.
|
||
|
||
**Hinweise statt Sperren bei Zeiten und Stärke.** Eine verdrehte Reihenfolge
|
||
oder eine fehlende Führungskraft wird angezeigt, aber nicht verhindert.
|
||
Einsätze werden Tage später aus dem Gedächtnis nachgetragen; ein Einsatz mit
|
||
unsicheren Zeiten ist immer noch besser als einer, den niemand erfasst, weil
|
||
das Formular ihn nicht angenommen hat.
|
||
|
||
**Der freigegebene Bericht ist unveränderbar — Lagemeldungen nicht.** Der
|
||
Bericht geht an die Gemeinde, die Meldungen sind das Protokoll des Verlaufs.
|
||
Eine Meldung aus dem Portal kann eintreffen, wenn der Bericht längst
|
||
geschrieben ist; sie nachträglich abzuweisen wäre falsch.
|
||
|
||
**Das Portal zeigt bewusst wenig.** Stichwort, Ort, Zeitstempel und Meldungen —
|
||
also das, was an der Einsatzstelle ohnehin jeder sieht. Keine Namen von
|
||
Einsatzkräften, kein Bericht, keine Navigation. Der Link ist ein Fenster auf
|
||
einen Einsatz, keine Tür in die Anwendung.
|
||
|
||
**Koordinaten liegen doppelt.** Als Dezimalgrad, die Prisma lesen kann, und als
|
||
PostGIS-Punkt für die Karte ab Phase 6. Ein Trigger hält beides zusammen —
|
||
sonst wäre jede Schreiboperation Roh-SQL geworden, und ein über einen anderen
|
||
Weg angelegter Einsatz fehlte später auf der Karte.
|
||
|
||
**Geocoding nur gegen einen eigenen Nominatim.** Einsatzadressen sagen, wo es
|
||
gebrannt hat. Solche Anfragen gehören nicht an einen fremden Dienst — und die
|
||
Nutzungsbedingungen des öffentlichen Nominatim erlauben es ohnehin nicht.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Prisma wollte wieder die Suchindizes löschen | wie in Phase 2: gin_trgm und gist kennt das Schema nicht | DROP INDEX-Zeilen aus der erzeugten Migration entfernt, mit Kommentar |
|
||
| Der Prisma-Client ließ sich nicht neu erzeugen | die Engine-DLL war vom laufenden Entwicklungsserver gesperrt | Server und Worker beenden, dann erzeugen — unter Windows unvermeidlich |
|
||
| Die Anwesenheitsliste blieb leer | sie forderte 300 Mitglieder an, erlaubt sind 200 je Seite | auf 200 gesetzt; aufgefallen in der Server-Ausgabe während der E2E-Tests |
|
||
| Zwei E2E-Tests schlugen im Gesamtlauf fehl, einzeln aber nicht | die erste Übersetzung einer Route dauert unter Last länger als das Erwartungslimit | Limit von 15 auf 25 Sekunden — Wartezeit des Entwicklungsservers, kein Fehler der Anwendung |
|
||
|
||
### Abweichung von der Vorgabe
|
||
|
||
**Keine.** Alle acht Tabellen tragen `organisationId` und stehen unter Row
|
||
Level Security, jede schreibende Operation protokolliert, gelöscht wird weich.
|
||
|
||
Eine Ergänzung: die Portalprozeduren sind die **ersten öffentlich erreichbaren
|
||
Endpunkte** in FLORIAN. Sie prüfen den Token selbst, laufen für die Suche des
|
||
Zugangs bewusst ohne Mandantenfilter (begründet im Code) und arbeiten danach im
|
||
Mandantenkontext der gefundenen Wehr.
|
||
|
||
### Offen und bewusst so gelassen
|
||
|
||
Die Zuordnung der Funktionen zur Stärke ist **regional unterschiedlich**. Hier
|
||
zählt der Maschinist zur Mannschaft und nur der Truppführer als Unterführer;
|
||
eine Gruppe ergibt damit 1/3/5/9. Andere Wehren rechnen anders. Die Zuordnung
|
||
steht an einer Stelle in `lib/einsatz/staerke.ts` und ist leicht zu ändern —
|
||
bestätigt ist sie noch nicht.
|
||
|
||
### Nachweise
|
||
|
||
- `npm run typecheck` — ohne Ausgabe
|
||
- `npm run test` — 207 Tests grün, davon 32 gegen eine echte Datenbank
|
||
- `npm run test:e2e` — 95 Tests grün, 2 übersprungen
|
||
- `npm run build` — 38 Routen
|
||
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund
|
||
|
||
---
|
||
|
||
## 2026-09-03 · Phase 2 — Wartungs- und Prüfmanagement
|
||
|
||
**09:55 – 12:20 · 2:25 h · Version 0.3.0**
|
||
|
||
### Was entstanden ist
|
||
|
||
Das Modul, wegen dem eine Wehr so ein Programm überhaupt einführt. Ab hier
|
||
ersetzt FLORIAN den Ordner mit den Prüfnachweisen.
|
||
|
||
**Datenmodell.** Elf neue Tabellen: Wartungsvorlage und Checklistenpunkt als
|
||
Regel, Wartungstermin als deren Anwendung auf ein einzelnes Gerät, Prüfung mit
|
||
Einzelergebnissen und Unterschriften als Nachweis, dazu Mangel, Rückruf mit
|
||
betroffener Bauart und je Gerät ein Bearbeitungsstand, und schließlich
|
||
Erinnerung als Nachweis und Sperre gegen Doppelversand in einem.
|
||
|
||
**Fristen-Dashboard.** Vier Töpfe nach Dringlichkeit, jede Zahl gleichzeitig
|
||
ein Filter, darunter die Verteilung nach Fahrzeug, Standort, Gerätetyp oder
|
||
Zuständigem. Daneben die Lebensdauerüberwachung mit Aussonderungsvorschlag.
|
||
|
||
**Prüfwizard.** Ein Punkt je Bildschirm, Foto aus der Kamera, Zeiterfassung im
|
||
Hintergrund, Unterschrift auf einem Zeichenfeld. Jede Antwort geht sofort zum
|
||
Server; ein abgebrochener Durchgang wird beim nächsten Öffnen fortgesetzt.
|
||
|
||
**Protokoll.** Der Worker rendert über Gotenberg ein PDF mit Kopf, Checkliste,
|
||
eingebetteten Fotos und Unterschriften und legt es in MinIO ab. Laufende
|
||
Nummer je Wehr und Jahr.
|
||
|
||
**Nächtlicher Lauf.** Termine planen, Gerätestatus nachziehen, erinnern bei
|
||
T-30, T-7, T-0 und einmal bei Überfälligkeit — als Sammelnachricht je
|
||
Empfänger.
|
||
|
||
### Entscheidungen
|
||
|
||
**Die Unveränderbarkeit sitzt in der Datenbank, nicht im Code.** Ein Trigger
|
||
weist jede Änderung an einer abgeschlossenen Prüfung ab, auch für den
|
||
Eigentümer-Zugang. Zulässig bleiben drei Dinge, die das Protokoll ergänzen
|
||
statt es zu verfälschen: der Wechsel auf „ersetzt", das Nachtragen des PDF,
|
||
solange keines hinterlegt ist, und `geaendertAm`. Dasselbe gilt für die
|
||
Bestandteile — sonst ließe sich ein festgeschriebenes Protokoll über den Umweg
|
||
der Einzelergebnisse verändern.
|
||
|
||
Der Grund ist derselbe wie beim Audit-Log in Phase 0: ein vergessener
|
||
Zustandscheck in einem Router reicht sonst, um den Nachweis wertlos zu machen.
|
||
|
||
**„Überfällig" wird gerechnet, nicht gespeichert.** Ein gespeicherter Zustand
|
||
läuft der Uhr hinterher — ein Termin, der heute Nacht fällig geworden ist,
|
||
stünde bis zum nächsten Lauf als „offen" da. Aus demselben Grund kennt
|
||
`WartungsterminStatus` nur offen, erledigt und entfallen.
|
||
|
||
**Pflichtpunkt heißt: davon hängt die Einsatzbereitschaft ab.** Ein
|
||
beanstandeter Pflichtpunkt bedeutet „nicht bestanden" und sperrt das Gerät, ein
|
||
beanstandeter Kann-Punkt „bestanden mit Mängeln". Damit ist das Ergebnis eine
|
||
fachliche Aussage und keine Zählübung. Übersteuern darf der Prüfer trotzdem —
|
||
er trägt die Verantwortung, nicht die Software.
|
||
|
||
**Bestanden hebt eine bestehende Sperre nicht auf.** Wer ein Gerät gesperrt
|
||
hat, hatte einen Grund, und der kann außerhalb dieser Prüfung liegen. Von
|
||
selbst löst sich nur „Prüfung fällig".
|
||
|
||
**Melden darf jedes Mitglied.** Der Mangel fällt dem auf, der das Gerät
|
||
benutzt. Eine Hürde beim Melden führt dazu, dass gar nicht gemeldet wird — und
|
||
ein nicht gemeldeter Riss im Schlauch ist gefährlicher als eine Fehlmeldung,
|
||
die jemand wieder verwirft.
|
||
|
||
**Die Rückrufsuche ist ein Vorschlag.** Seriennummern werden als Text
|
||
verglichen; das stimmt bei gleicher Stellenzahl und sonst nicht immer. Eine
|
||
automatische Sperre auf dieser Grundlage wäre gefährlicher als hilfreich.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Prisma wollte die Suchindizes aus Phase 1 löschen | gin_trgm- und gist-Indizes kennt das Schema nicht | die `DROP INDEX`-Zeilen aus der erzeugten Migration entfernt, mit Kommentar für den nächsten, der sie neu erzeugt |
|
||
| Der Seed brach beim Beispielprotokoll ab | er legte die Prüfung fertig an, und der Trigger verbot die Ergebnisse dazu | erst Entwurf, dann abschließen — genau wie im Wizard |
|
||
| Zwei gleichzeitig abgeschlossene Prüfungen zogen dieselbe Protokollnummer | die Nummer kam aus einer Zählung ohne Sperre | Transaktionssperre je Wehr und Jahr, Nummer aus dem Höchstwert statt aus der Anzahl |
|
||
| Der Worker startete außerhalb des Containers nicht | `.env` wurde nicht geladen, und `server-only` wirft unter Node | `dotenv/config` und `--conditions=react-server` in beiden Startbefehlen |
|
||
| Der Wizard-Test klickte ins Leere | die Mauskoordinaten beziehen sich auf das Fenster, das Zeichenfeld lag darunter | vor dem Zeichnen ins Bild holen |
|
||
| Zwei E2E-Projekte teilten sich denselben Prüfentwurf | Desktop und Tablet laufen parallel unter demselben Benutzer | je Projekt ein eigenes Gerät |
|
||
|
||
### Abweichung von der Vorgabe
|
||
|
||
**Keine.** Alle elf Tabellen tragen `organisationId` und stehen unter Row Level
|
||
Security, jede schreibende Operation protokolliert, gelöscht wird weich.
|
||
|
||
Eine Ergänzung zur Regel „Prüfdaten werden nie hart gelöscht": ein
|
||
**Prüfentwurf** darf verschwinden. Er ist noch kein Nachweis, sondern ein
|
||
abgebrochener Vorgang; ihn aufzuheben würde die Liste der offenen Prüfungen
|
||
zumüllen. Ab dem Abschluss greift die Sperre.
|
||
|
||
### Was aufgefallen ist
|
||
|
||
Der Navigationseintrag „Protokoll" (`/protokoll`) zeigt seit Phase 1 auf eine
|
||
Seite, die es nicht gibt. Das Audit-Log ist über tRPC abrufbar, hat aber keine
|
||
Ansicht. Gehört in ein eigenes Issue.
|
||
|
||
### Nachweise
|
||
|
||
- `npm run typecheck` — ohne Ausgabe
|
||
- `npm run test` — 161 Tests grün, davon 18 gegen eine echte Datenbank
|
||
- `npm run test:e2e` — 71 Tests grün, 2 begründet übersprungen (Desktop und Tablet)
|
||
- `npm run build` — 30 Routen
|
||
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund; Worker
|
||
richtet die Zeitpläne ein und erzeugt die Protokoll-PDFs in gut einer Sekunde
|
||
|
||
---
|
||
|
||
## 2026-09-03 · Phase 1 — Nachtrag: Geräteformular
|
||
|
||
**08:30 – 09:20 · 0:50 h**
|
||
|
||
### Was entstanden ist
|
||
|
||
Die letzte offene Lücke aus Phase 1: ein Formular zum Anlegen und Bearbeiten
|
||
eines einzelnen Geräts. Bis dahin konnte man Geräte nur über den Import
|
||
erfassen — für einen Bestand richtig, für den einen nachbeschafften Gerät
|
||
umständlich.
|
||
|
||
| Datei | Inhalt |
|
||
|---|---|
|
||
| `components/stammdaten/geraet-formular.tsx` | das Formular, geteilt von Anlegen und Bearbeiten |
|
||
| `components/stammdaten/geraet-formular-werte.ts` | Typ und Vorgabewerte, bewusst ohne `"use client"` |
|
||
| `components/ui/textarea.tsx` | fehlender Baustein |
|
||
| `app/(app)/geraete/neu/page.tsx` | Anlegen |
|
||
| `app/(app)/geraete/[id]/bearbeiten/page.tsx` | Bearbeiten |
|
||
| `tests/e2e/geraet-formular.spec.ts` | 7 Tests |
|
||
|
||
### Entscheidungen
|
||
|
||
**Die Lagerung ist ein Umschalter, keine zwei Auswahlfelder.** Standort und
|
||
Fahrzeug schließen sich aus. Zwei nebeneinanderstehende Felder laden dazu ein,
|
||
beide zu füllen; die Datenbank weist das ab, aber eine Fehlermeldung nach dem
|
||
Absenden ist schlechter als ein Formular, in dem der Fehler gar nicht möglich
|
||
ist. Beim Wechsel des Fahrzeugs wird der Geräteraum geleert — er gehört zum
|
||
Fahrzeug.
|
||
|
||
**Die Fristen werden schon beim Tippen vorgerechnet.** Wer ein Prüfintervall
|
||
einträgt, sieht sofort, wann die nächste Prüfung fällig wird. Gerechnet wird
|
||
mit denselben Funktionen wie auf dem Server; beim Speichern rechnet der Server
|
||
erneut und behält das letzte Wort. Die Vorschau ist Anzeige, keine Quelle.
|
||
|
||
**Bei nur einem Standort wird er vorbelegt.** Das ist bei den meisten Wehren
|
||
der Fall, und ein Pflichtfeld weniger ist ein Klick weniger.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Formular stürzte beim ersten Rechnen ab | `LEERES_GERAET` lag im `"use client"`-Modul; eine Server-Komponente bekommt beim Import nur eine Client-Referenz, das Spreaden ergibt `undefined` | Werte in ein eigenes Modul ohne `"use client"` |
|
||
| E2E fand die Felder nicht | `getByLabel` vergleicht den Text des `<label>`, nicht den zugänglichen Namen — am Pflichtfeld steht dort ein Sternchen | Tests suchen über die Rolle; das Sternchen bekam zusätzlich `aria-hidden`, weil es sonst vorgelesen wird |
|
||
| `fill()` blieb bei Datumsfeldern wirkungslos | Chromium nimmt bei `input[type=date]` keinen gesetzten Wert über `fill()` an | Datum wird getippt, wie es ein Mensch auch täte |
|
||
| Zwei Tests scheiterten unter Touch-Emulation | dort öffnet Chromium den Auswähler des Betriebssystems statt Zeichen anzunehmen | begründet übersprungen; die Rechnung selbst deckt der Unit-Test ab |
|
||
| Anmeldung im Screenshot-Skript lief ins Zeitlimit | bei kaltem `.next` übersetzt `next dev` die Route erst beim ersten Aufruf | Zeitlimit von 20 auf 90 Sekunden |
|
||
|
||
### Nachweise
|
||
|
||
- `npm run typecheck` — ohne Ausgabe
|
||
- `npm run test` — 104 Tests grün
|
||
- `npm run test:e2e` — 56 grün, 2 begründet übersprungen
|
||
- `npm run build` — 19 Routen
|
||
- Testgeräte aus dem E2E-Lauf wurden aus der Entwicklungsdatenbank entfernt
|
||
|
||
---
|
||
|
||
## 2026-09-03 · Phase 1 — Stammdaten
|
||
|
||
**07:10 – 08:25 · 1:15 h · Version 0.2.0**
|
||
|
||
### Was entstanden ist
|
||
|
||
41 neue Quelldateien, der Umfang wächst von rund 6.100 auf 15.600 Zeilen.
|
||
|
||
| Bereich | Inhalt |
|
||
|---|---|
|
||
| Datenmodell | 5 neue Tabellen (Standort, Geraeteraum, Geraetetyp, Qualifikationstyp, Qualifikation), Fahrzeug und Geraet deutlich erweitert, 2 Migrationen |
|
||
| Fachlogik | `fristen.ts` (Prüftermine, Lebensdauer, Statusableitung), `qr.ts` und `qr-parser.ts`, `etiketten.ts`, `pdf.ts`, `import.ts` und `import-felder.ts` |
|
||
| Server | 8 tRPC-Router mit Liste, Detail, Anlegen, Ändern, Massenbearbeitung und Verlauf |
|
||
| Oberfläche | Geräteliste mit Suche und Spaltenauswahl, Gerätedetail mit QR, Fahrzeugliste und -detail mit Beladung, Mitgliederliste, Mitgliedsdetail, Qualifikationen, Import-Assistent, QR-Scanner |
|
||
| Daten | Seed erweitert: 3 Standorte, 20 Geräteräume, 14 Gerätetypen, 49 Geräte, 14 Qualifikationstypen, 107 Qualifikationen |
|
||
| Tests | 104 Vitest-Tests (vorher 32), 44 Playwright-Tests (vorher 10) |
|
||
|
||
### Entscheidungen
|
||
|
||
**Der Gerätetyp ist Vorgabe, das Gerät darf abweichen.** Vierzig Pressluftatmer
|
||
haben dasselbe Prüfintervall — das gehört einmal an den Typ und nicht
|
||
vierzigmal an das Gerät. Wo der Hersteller für eine Charge etwas anderes
|
||
vorschreibt, gewinnt der Wert am Gerät. Umgesetzt in `effektiverWert()`, das
|
||
bewusst zwischen `null` und `0` unterscheidet: ein Intervall von 0 ist eine
|
||
Angabe, kein fehlender Wert.
|
||
|
||
**Ein Gerät liegt entweder an einem Standort oder auf einem Fahrzeug.** Beides
|
||
gleichzeitig wäre eine Falschangabe, keines von beidem auch. Das steht als
|
||
Prüfbedingung in der Datenbank, nicht nur im Formular — und ein Trigger prüft
|
||
zusätzlich, dass ein angegebener Geräteraum wirklich zu dem angegebenen
|
||
Fahrzeug gehört. Das ließe sich als CHECK nicht ausdrücken.
|
||
|
||
**Der QR-Code enthält die Inventarnummer, nicht die UUID.** Eine UUID wäre
|
||
36 Zeichen lang; der Code bekäme ein feines Muster, und auf einem 38 × 21 mm
|
||
großen Etikett im staubigen Geräteraum wird das unzuverlässig. Die
|
||
Inventarnummer steht ohnehin lesbar daneben, und wer den Code mit einem
|
||
beliebigen Scanner liest, sieht sofort, um welches Gerät es geht.
|
||
|
||
**Der Import liest die Datei zweimal.** Die Vorschau zeigt, was passieren
|
||
würde; beim Übernehmen wird die Datei erneut hochgeladen und serverseitig neu
|
||
eingelesen. Es wandert kein einziger Wert aus dem Browser in die Datenbank.
|
||
Der Preis ist eine zweite Übertragung — bei den Größen, um die es geht, kein
|
||
Thema.
|
||
|
||
**Der Etikettenbogen kann an beliebiger Position beginnen.** Wer drei Etiketten
|
||
braucht, soll nicht einen ganzen Bogen opfern.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Fristen um einen Tag verschoben | `startOfDay` aus date-fns rechnet in der Zeitzone des Servers; ein DATE aus der Datenbank kommt als UTC-Mitternacht und landete dadurch auf dem Vortag | eigene Kalenderarithmetik auf UTC-Bestandteilen, „heute“ über Europe/Berlin bestimmt |
|
||
| `ctx.sitzung` blieb nullbar | `t.middleware()` erzeugt eine Middleware auf dem Wurzelkontext; die Verengung aus der vorigen Middleware ging verloren | Middleware direkt als Funktion an `.use()` übergeben |
|
||
| Import-Tests brachen beim Laden ab | `import "server-only"` wirft beim blossen Import, und Vitest ist weder Server noch Browser | im Testlauf durch ein leeres Modul ersetzt (`tests/stubs/server-only.ts`) |
|
||
| 15 E2E-Tests im Zeitlimit | `next dev` übersetzt jede Route erst beim ersten Aufruf; zu viele parallele Erstaufrufe | Worker auf 4 begrenzt, Zeitlimit angehoben |
|
||
| 5 weitere E2E-Tests rot | `CardTitle` war ein `div` und damit keine Überschrift | in der Komponente auf ein echtes `h3` umgestellt — behebt zugleich einen Zugänglichkeitsmangel |
|
||
| Erste Bilder zeigten Ladebalken | „networkidle“ greift zu früh: die Listen holen ihre Daten erst nach, wenn React im Browser gelaufen ist | Screenshot-Skript wartet, bis keine Ladeanzeige mehr im Baum ist |
|
||
| Demodaten inkonsistent | die 40 Geräte aus Phase 0 hatten keinen Gerätetyp, und der Seed ist idempotent | Geräte-Zeilen der Entwicklungsdatenbank gezielt geleert und neu eingespielt |
|
||
|
||
### Abweichung von der Vorgabe
|
||
|
||
**`prisma migrate reset` wurde nicht ausgeführt.** Der naheliegende Weg, die
|
||
Demodaten sauber neu aufzubauen, verlangt eine ausdrückliche Freigabe — Prisma
|
||
verweigert den Befehl gegenüber einem KI-Agenten ohne Zustimmung. Statt zu
|
||
fragen wurde der schmalere Weg gewählt: nur die Tabelle `geraet` der
|
||
Entwicklungsdatenbank geleert und neu eingespielt.
|
||
|
||
**Kein eigenes Formular zum Anlegen von Geräten.** Die Router können anlegen,
|
||
ändern, umlagern und aussondern; die Oberfläche dazu deckt Liste, Detail,
|
||
Massenbearbeitung und Import ab. Ein einzelnes Anlegeformular fehlt noch — in
|
||
der Praxis kommen Bestände als Tabelle herein, und einzelne Neuzugänge lassen
|
||
sich bis dahin über den Import erfassen. Nachzuholen in Phase 2, wo ohnehin
|
||
Formulare für Prüfungen entstehen.
|
||
|
||
### Nachweise
|
||
|
||
- `npm run typecheck` — ohne Ausgabe
|
||
- `npm run test` — 104 Tests grün, davon 8 gegen eine echte Datenbank
|
||
- `npm run test:e2e` — 44 Tests grün (Desktop und Tablet)
|
||
- `npm run build` — 17 Routen, 103 kB gemeinsames JavaScript
|
||
- `npm run screenshots` — 18 Bilder, hell und dunkel
|
||
- Keine Konsolenfehler und keine Hydratationswarnungen im Browser
|
||
|
||
---
|
||
|
||
## 2026-09-02 / 03 · Phase 0 — Fundament
|
||
|
||
**23:13 – 00:13 · 1:00 h · Version 0.1.0**
|
||
|
||
### Was entstanden ist
|
||
|
||
46 Quelldateien, rund 6.100 Zeilen. Im Einzelnen:
|
||
|
||
| Bereich | Inhalt |
|
||
|---|---|
|
||
| Betrieb | `docker-compose.yml` (7 Dienste), Entwicklungs-Override, mehrstufiges Dockerfile, Postgres-Initialisierung, `.env.example` mit Kommentar zu jeder Variable |
|
||
| Datenmodell | Prisma-Schema mit 11 Tabellen, 3 Migrationen |
|
||
| Kern | `env`, `db`, `authz`, `storage`, `audit`, `auth`, `passwort`, `warteschlange`, 3 Integrationsschnittstellen |
|
||
| Server | tRPC v11 mit drei Prozedurarten, Kontext, Wurzel-Router |
|
||
| Oberfläche | Layout, Seitenleiste, Anmeldeseite, Übersicht, 5 shadcn-Bausteine, Farbwelt in OKLCH |
|
||
| Daten | Seed: 1 Wehr, 9 Rollen, 38 Rechte, 16 Personen, 3 Fahrzeuge, 40 Geräte |
|
||
| Tests | 32 Vitest-Tests, 10 Playwright-Tests |
|
||
| Doku | README, Anleitung, Roadmap, Aufwandsschätzung, Versionierung, Changelog |
|
||
|
||
### Entscheidungen
|
||
|
||
**Zwei Datenbankverbindungen statt einer.** Der erste Entwurf hatte einen
|
||
einzigen Prisma-Client. Der ist beim ersten Anmeldeversuch aufgelaufen: die
|
||
Anwendung verbindet sich als `florian_app`, für den Row Level Security greift
|
||
— und die Anmeldung muss eine E-Mail-Adresse über alle Wehren hinweg suchen,
|
||
bevor überhaupt feststeht, zu welcher Wehr sie gehört. Ohne gesetzten
|
||
Mandantenkontext liefert RLS null Zeilen, also fand der Login nie jemanden.
|
||
|
||
Die Lösung ist eine saubere Trennung: `prismaApp` für alle Fachdaten (RLS
|
||
aktiv), `prismaSystem` für die wenigen Vorgänge, die vor der
|
||
Mandantenauflösung stattfinden. Der Name ist bewusst unbequem, damit auffällt,
|
||
wenn ihn jemand im falschen Zusammenhang benutzt.
|
||
|
||
**Kein FORCE ROW LEVEL SECURITY.** Der Tabelleneigentümer umgeht RLS. Das ist
|
||
gewollt: Migrationen und Seed müssen über alle Mandanten hinweg arbeiten
|
||
können. Mit `FORCE` wäre der Seed nicht mehr lauffähig gewesen.
|
||
|
||
**Audit-Log: append-only mit genau einer Ausnahme.** Der erste Trigger hat
|
||
`UPDATE` und `DELETE` ausnahmslos gesperrt. Das ist beim Aufräumen der
|
||
Testdaten aufgefallen — eine Organisation ließ sich nicht mehr löschen, weil
|
||
die Kaskade auf den Audit-Log traf.
|
||
|
||
Das ist kein Testproblem, sondern ein echtes: nach
|
||
`AUDIT_AUFBEWAHRUNG_TAGE` muss der Worker alte Einträge exportieren und dann
|
||
entfernen, sonst wächst die Tabelle unbegrenzt und das Löschkonzept nach DSGVO
|
||
lässt sich nicht umsetzen. Die zweite Migration führt deshalb einen
|
||
ausdrücklichen Schalter ein: `SET LOCAL app.audit_bereinigung = 'an'` erlaubt
|
||
`DELETE` für die laufende Transaktion. `UPDATE` bleibt unter allen Umständen
|
||
gesperrt — ein Eintrag darf verschwinden, nachdem er gesichert wurde, aber
|
||
niemals verfälscht werden.
|
||
|
||
**Rollen aus authentik nur als Vorbelegung.** Auf Nachfrage entschieden: der
|
||
Gruppen-Claim setzt die Rolle beim ersten Login, danach ist die in FLORIAN
|
||
gepflegte Rolle führend. Umgesetzt über das Feld `ausOidcGruppe` an
|
||
`PersonRolle` und eine Prüfung, die nur bei leerer Rollenliste greift.
|
||
|
||
**Terminal mit zwei Schranken.** Erst weist sich das Gerät mit
|
||
`KIOSK_DEVICE_SECRET` aus, dann die Person mit PIN oder Karte. Eine erratene
|
||
sechsstellige PIN nützt ohne das Gerätegeheimnis nichts.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Postgres startete nicht | `de_DE.UTF-8` gibt es im Debian-Basisimage nicht | ICU-Locale: `--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8` |
|
||
| Init-Skript brach mit Syntaxfehler ab | psql ersetzt seine Variablen nicht innerhalb dollar-quotierter Blöcke | `DO $$`-Block durch `SELECT format(...) \gexec` ersetzt |
|
||
| Anmeldung schlug immer fehl | siehe oben: RLS blockierte die E-Mail-Suche | zweite Datenbankverbindung |
|
||
| `npm install` ließ Installationsskripte aus | npm 11.17 verlangt eine ausdrückliche Freigabe | `npm approve-scripts` für Prisma, esbuild und drei weitere |
|
||
| Aufräumen der Testdaten scheiterte | Audit-Trigger blockierte die Kaskade | Aufbewahrungsschalter (siehe oben) |
|
||
| Zwei E2E-Tests rot | Next.js hängt einen eigenen Routen-Ansager mit `role="alert"` in die Seite; `CardTitle` ist ein `div`, keine Überschrift | Fehlermeldung über `data-testid` angesprochen, Anmeldetitel zu einer echten `h1` gemacht |
|
||
|
||
### Abweichung von der Vorgabe
|
||
|
||
**`Berechtigung` trägt kein `organisationId`.** Die Architekturregel verlangt
|
||
es für jede Tabelle. Diese Tabelle ist aber kein Mandantendatum, sondern der
|
||
systemweite Katalog dessen, was die Software überhaupt kann — für alle Wehren
|
||
identisch. Mandantenbezogen wird es erst über `RolleBerechtigung`, und die
|
||
trägt `organisationId`. Die Tabelle ist für alle lesbar und für die
|
||
Anwendungsrolle nicht beschreibbar. Die Ausnahme steht im Schema und in der
|
||
Migration kommentiert.
|
||
|
||
**Phasenzuordnung in der Seitenleiste korrigiert.** Beim Schreiben der Roadmap
|
||
ist aufgefallen, dass ich die Module den falschen Phasen zugeordnet hatte
|
||
(Atemschutz als Phase 3, Einsätze als Phase 4). Nach `Phase-3.md` bis
|
||
`Phase-8.md` gilt: 3 Einsätze, 4 Kiosk, 5 Atemschutz, 6 Karten, 7
|
||
Benachrichtigungen, 8 KI. Seitenleiste, Kommentare in `authz.ts` und die
|
||
Übersicht sind entsprechend berichtigt.
|
||
|
||
**Kassenmodul kommt in keiner Phase vor.** Die Rolle `KASSIER` ist gefordert
|
||
und angelegt, ein Modul dazu gibt es in `Phase-0.md` bis `Phase-8.md` nicht.
|
||
Die Rechte stehen im Katalog, es gibt aber keinen Navigationseintrag. Offene
|
||
Entscheidung, siehe [ROADMAP.md](ROADMAP.md).
|
||
|
||
### Nachweise
|
||
|
||
- `npm run typecheck` — ohne Ausgabe
|
||
- `npm run test` — 32 Tests grün, davon 8 gegen eine echte Datenbank
|
||
- `npm run test:e2e` — 10 Tests grün (Desktop und Tablet)
|
||
- `npm run build` — 6 Routen, 103 kB gemeinsames JavaScript
|
||
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund
|
||
|
||
---
|
||
|
||
## Vorlage für neue Einträge
|
||
|
||
```markdown
|
||
## JJJJ-MM-TT · Phase N — Titel
|
||
|
||
**HH:MM – HH:MM · X:XX h · Version 0.N.0**
|
||
|
||
### Was entstanden ist
|
||
### Entscheidungen
|
||
### Wo es gehakt hat
|
||
### Abweichung von der Vorgabe
|
||
### Nachweise
|
||
```
|