Files
FireAdministration/docs/DEVLOG.md
T
florian.wilde 3b20abcb23
Pruefung / pruefung (push) Canceled after 0s
Pruefung / oberflaeche (push) Canceled after 0s
Version 1.0.0 — Freigabe für den Echtbetrieb (#17)
2026-09-06 20:08:16 +00:00

80 KiB
Raw Blame History

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.

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
Aufräumarbeiten (Issues #10–#12) 1:20 h 1 04.09.2026
Phase 9 — Kassenverwaltung 4:10 h 1 04.09.2026
Phase 10 — Mandantenfähigkeit 4:20 h 1 05.09.2026
Phase 11 — Freigabereife 2:40 h 1 05.09.2026
1.0.0 — Freigabe 0:50 h 1 06.09.2026
Summe 36:20 h 15

2026-09-06 · 1.0.0 — Freigabe

09:20 – 10:10 · 0:50 h · Version 1.0.0

Was entstanden ist

Kein Modul. Zwei Dinge, die zum Betrieb gehören, und eine Entscheidung.

Was Wofür
update.sh Aktualisieren auf einem Linux-Server, in einem Befehl
docs/KURZANLEITUNG.md Der Einstieg für die Wehr, nach Rollen sortiert
Version 1.0.0 Ab hier semantische Versionierung

Entscheidungen

Das Update-Skript sichert, bevor es irgendetwas anfasst. Ein Update ist der häufigste Anlass, eine Sicherung zu brauchen — und der Moment, in dem niemand daran denkt. --ohne-sicherung gibt es, aber mit einer Warnung und nicht als Vorgabe.

Die Reihenfolge ist so gewählt, dass ein Abbruch harmlos bleibt. Holen und Bauen passieren, bevor umgeschaltet wird: schlägt der Bau fehl, läuft die alte Fassung unverändert weiter. Erst danach wird umgeschaltet, und ab da nennt die Fehlerbehandlung die Befehle für den Rückweg.

Die Kurzanleitung ist aufgabenorientiert, nicht modulorientiert. Die große Anleitung hat 63 Kapitel und ist nach Funktionsbereichen sortiert — richtig zum Nachschlagen, unbrauchbar zum Anfangen. Die Kurzanleitung fragt stattdessen „was willst du tun": ein Gerät prüfen, einen Kontoauszug einlesen, einen Termin absagen.

Sie erklärt bei jedem heiklen Punkt das Warum. Warum ein abgeschlossenes Prüfprotokoll nicht änderbar ist, warum gegengebucht statt korrigiert wird, warum der Atemschutzhinweis stehen bleibt. Wer den Grund kennt, sucht nicht nach dem Weg drumherum.

Die Roadmap hakt nicht alles ab. Zwei Freigabevoraussetzungen sind organisatorisch und stehen aus — die Abnahme des Löschkonzepts und die Schulung. Sie stehen mit ihrem Stand dort, statt stillschweigend als erledigt zu gelten. Eine Freigabe, die abhakt, was nicht geprüft wurde, ist keine.

Wo es gehakt hat

Die Ursache der Testflackerei gefunden — nach elf Phasen.

Beim abschließenden Durchlauf fielen wieder einmal fünf, dann sechs, dann ein Test aus, in wechselnder Besetzung. Dieses Mal habe ich nicht weitergemacht, sondern fünf Läufe hintereinander mitgeschrieben: betroffen waren immer Zusicherungen über Trigger — „Wiki-Fassungen bleiben", „Mandantentrennung", „abgeschlossene Prüfprotokolle".

ki-datenbank.test.ts und atemschutz-datenbank.test.ts schalteten beim Aufräumen ihre Trigger mit ALTER TABLE ... DISABLE TRIGGER USER ab. Das ist eine Schemaänderung: sie gilt für die ganze Datenbank und für alle. Vitest fährt die Testdateien parallel — während also die eine Datei abräumte, prüfte die andere gerade, dass genau dieser Trigger greift, und sah keinen.

SET LOCAL session_replication_role = 'replica' bewirkt dasselbe, aber nur in der eigenen Transaktion. Danach: fünf Läufe hintereinander 601 grün.

Rückwirkend erklärt das eine Reihe von Ausfällen, die ich als Flackern abgetan habe — sie waren keines. Die Lehre ist unbequem: „läuft einzeln durch, also Umgebung" ist eine bequeme Erklärung und oft die falsche.

Weiteres Ursache Lösung
Ein Datenbanktest ließ sich nur einmal ausführen mandanten-datenbank.test.ts räumte nur am Ende auf; ein abgebrochener Lauf legte den nächsten lahm Räumt jetzt auch am Anfang auf — dasselbe Muster wie in Phase 10 bei ki-datenbank

Abweichung von der Vorgabe

update.sh ist nicht auf einem Linux-Server erprobt. Entwickelt wurde unter Windows; geprüft sind die Syntax (bash -n), die Hilfe, die Argumentbehandlung und der Wächter gegen unverbuchte Änderungen — alles unter Git Bash. Der eigentliche Ablauf (fetch, build, up, Gesundheitsprüfung) läuft erst dort, wo FLORIAN wirklich steht.

Das ist keine Nachlässigkeit, sondern die Grenze dieser Umgebung. Beim ersten echten Update gehört ./update.sh --pruefen vorangestellt: es holt nichts und ändert nichts, zeigt aber, ob Docker, git und die .env gefunden werden.

Nachweise

  • npm run typecheck — ohne Ausgabe
  • npm run lint — ohne Befund
  • npm run test — 601 Tests grün
  • bash -n update.sh — Syntax in Ordnung
  • ./update.sh --help und --quatsch — Ausgabe und Ablehnung wie vorgesehen
  • Der Wächter gegen unverbuchte Änderungen greift (an einem schmutzigen Arbeitsverzeichnis geprüft)
  • Alle acht Bilder der Kurzanleitung existieren
  • npm run test fünfmal hintereinander — jedes Mal 601 grün. Das war nach elf Phasen der erste Lauf, bei dem ich das behaupten kann
  • npm run test:e2e — 268 grün, kein Fehlschlag, kein Flackern. Ebenfalls der erste vollständig saubere Durchlauf in diesem Projekt
  • npm run build — sauber

2026-09-05 · Phase 11 — Freigabereife

14:30 – 17:10 · 2:40 h · Version 0.12.0

Was entstanden ist

Kein Modul, sondern die drei Software-Voraussetzungen für 1.0.0.

Bereich Umfang
Auskunft lib/dsgvo/auskunft.ts und bericht.ts, person.auskunft, Dialog auf der Mitgliederseite
Sicherung scripts/sicherung.ts, wiederherstellen.ts, sicherung-zugang.ts, sicherung-sperren.ts
Pipeline .gitea/workflows/pruefung.yml mit zwei Aufträgen
Tests 18 neue: 9 gegen die Auskunft, 11 gegen die Sicherung

Entscheidungen

Die Auskunft trennt „über mich" von „von mir". Was über die Person gespeichert ist, steht vollständig drin — auch der gemahnte Beitrag und die nicht bestandene G26. Was die Person getan hat, steht als Zahl drin. Die zwölf Prüfprotokolle, die sie unterschrieben hat, gehören zum Nachweisbestand der Wehr; vollständig ausgeliefert käme jeder über eine Selbstauskunft an die Geräteakten, und die Auskunftspflicht wäre eine Hintertür.

Über jedem Abschnitt steht der Zweck. Art. 15 Abs. 1 verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger und den Hinweis auf die weiteren Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und ist keine — das ist der Unterschied zwischen einem Datenbankexport und einem Schreiben an einen Menschen.

Eine Mutation, keine Abfrage. Das Erstellen der Auskunft ist die Offenlegung, und die gehört ins Protokoll. Als Abfrage würde sie zwischengespeichert und mehrfach ausgeführt — sie wäre entweder nicht oder zwanzigmal vermerkt.

Sicherung und Wiederherstellung gehören zusammen. Deshalb sind es zwei Skripte in einem Zug und nicht eines. Der Probelauf ist die Vorgabe; ohne --wirklich geschieht nichts.

Eine Liste, zwei Verwendungen. sicherung-sperren.ts treibt sowohl das Wiederherstellen der Sperren als auch deren Prüfung. Zwei Listen wären zwei Wahrheiten, und die falsche fiele erst im Notfall auf.

Die Pipeline baut das eigene Postgres-Image. Ein Dienst-Container mit postgres:17 ließe die Migrationen durchlaufen und scheiterte erst bei der ersten Umkreissuche — die Pipeline prüfte dann etwas anderes als den Betrieb.

Wo es gehakt hat

Problem Ursache Lösung
Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien Zuerst --no-acl im Dump. Und selbst ohne das Flag greift beim Zurückspielen die Vorgabeprivilegierung aus dem Init-Skript: jede von pg_restore neu angelegte Tabelle bekommt alle Rechte, und GRANT kann nur hinzufügen Das Skript setzt die Sperren nach dem Zurückspielen neu und prüft danach nach. Gefunden haben es vier Datenbanktests direkt nach der ersten Rückspielprobe
pg_dump lehnte die Verbindungszeichenkette ab Prisma hängt ?schema=public an — „invalid URI query parameter" Die URL wird zerlegt, Benutzer und Datenbank gehen als Flags, das Passwort über PGPASSWORD
Feldnamen im Auskunftssammler stimmten reihenweise nicht Aus dem Gedächtnis geschrieben statt aus dem Schema gelesen: alarmierung statt alarmiertUm, beginn statt beginnUm, gemeldetVonId statt melderId Der Typechecker hat sie alle einzeln benannt — zehn Durchgänge, jeder eine Zeile
Ein eigener Test war falsch, nicht der Code new URL("localhost:5432") gelingt in Node: „localhost:" gilt als Protokoll Die Zusicherung prüft jetzt, was wirklich greift — die Protokollprüfung eine Zeile später
Ein Vorlagentest fiel im vollen Lauf aus Er klickte auf „Speichern“, bevor React lief — dann verschickt das Formular sich selbst, und die Fehlerfläche entsteht nie. Unter Last dauert das Hydrieren länger. Dasselbe Muster wie beim Anmeldeskript in Phase 8 Er wartet jetzt auf einen client-seitig erzeugten Knopf, bevor er klickt

Abweichung von der Vorgabe

Die Pipeline ist nicht erprobt. Sie ist geschrieben und syntaktisch geprüft, aber in Gitea war zum Zeitpunkt dieser Phase kein act_runner registriert. Ohne Runner erscheinen die Läufe als „wartend" und laufen nie. Ob der Ablauf durchläuft, zeigt sich erst, wenn einer eingerichtet ist — das steht so auch im README, damit niemand sich auf eine Prüfung verlässt, die nicht stattfindet.

Nachweise

  • npm run typecheck — ohne Ausgabe
  • npm run lint — ohne Befund
  • npm run test — 599 Tests grün, davon 18 neu
  • Auskunft von Hand erzeugt: 13 Abschnitte, 28 Angaben, 58 kB PDF
  • Sicherung und Wiederherstellung vollständig durchgeführt: ein nach der Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten wieder, und die 599 Tests liefen nach dem Zurückspielen grün — einschließlich derer, die die Sperren prüfen

2026-09-05 · Phase 10 — Mandantenfähigkeit

08:10 – 12:30 · 4:20 h · Version 0.11.0

Was entstanden ist

Die einzige Phase bisher, die die Anmeldung selbst anfasst.

Bereich Umfang
Datenmodell Benutzer, Mitgliedschaft, MitgliedschaftArt; Person verliert die Anmeldefelder
Absicherung RLS für mitgliedschaft, zwei Trigger, vier CHECKs, ein REVOKE
Anmeldung lib/auth.ts neu: Konto statt Akte, Mitgliedschaftswahl, Wehrwechsel im Token
Rechte Rolle AUFSICHT, gefiltert statt aufgezählt; istVerwalter als Kontomerkmal
Oberfläche Wehrwähler, Aufsichtshinweis, /wehren zur Verwaltung
Tests 19 neue Unit-Tests (11 gegen die Datenbank), 13 E2E-Tests

Entscheidungen

Die Sitzung behält ihre Form. sitzung.organisationId und sitzung.personId werden an 329 bzw. 85 Stellen in 61 Dateien gelesen. Hätte sich ihre Bedeutung geändert, wäre das ein Umbau durch das halbe Projekt geworden. Stattdessen entsteht die Sitzung nur anders — aus der aktiven Mitgliedschaft statt aus der Person. Keine dieser 61 Dateien musste angefasst werden.

personId ist nullbar geworden. Das war die eine Ausnahme davon, und zwar absichtlich: eine Aufsichtssitzung hat keine Akte. Der Typechecker hat daraus eine Liste gemacht — genau fünf Stellen verlangen eine Person, und alle fünf sind Dinge, an denen die Kreisaufsicht nichts verloren hat (Anmeldemarke, Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz). Ein ?? "" hätte dieselben fünf Stellen still passieren lassen.

Benutzer ohne organisationId. Die zweite Ausnahme von der Architekturregel nach Berechtigung — und dieselbe Begründung: die Tabelle steht über allen Mandanten, weil sie der Angelpunkt ist, an dem ein Mensch zu mehreren Wehren gehört. Geschützt über Rechte statt RLS.

AUFSICHT steht nicht im Prisma-Enum. Alle anderen Rollen werden einem Menschen in einer Wehr zugewiesen. Diese ergibt sich aus der Mitgliedschaftsart. Sie vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr Rechte an einer anderen verteilen könnte.

Ihre Rechte werden gefiltert, nicht aufgezählt. Alles, was .lesen heißt, minus vier ausdrückliche Ausnahmen. Eine Liste von Hand wäre bei Phase 11 veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte mit, die niemand nachträgt. So bekommt die Aufsicht neue Leserechte automatisch und neue Schreibrechte nie. Der Test dazu ist entsprechend formuliert: nicht „diese dreizehn Rechte", sondern „kein einziges, das nicht lesen heißt".

Die Kasse ist ausgenommen, obwohl kasse.lesen auf .lesen endet. Sie gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. Sie wäre nur deshalb dabei gewesen, weil das Recht zufällig richtig heißt — und genau dagegen ist die Ausnahmeliste da.

Die Migration ist datenerhaltend, mit Abbruchprüfung. Konten und Zuordnungen entstehen, bevor die alten Spalten fallen; am Ende prüft ein DO-Block, dass keine Person mit Anmeldedaten ohne Konto dasteht, und bricht sonst ab. Eine Migration, die die Anmeldung anfasst und auf halbem Weg liegenbleibt, sperrt alle aus.

Wo es gehakt hat

Problem Ursache Lösung
Nach dem Wehrwechsel landete alles auf der Anmeldeseite aktuelleSitzung() verlangte eine Personenkennung — die Aufsicht hat keine und galt damit als nicht angemeldet Eine Sitzung ist gültig, wenn Wehr und Konto feststehen; die Akte darf fehlen
Der Wechsel schien nicht zu greifen Er griff: das Token trug die neue Wehr. Es war der dritte hängengebliebene Entwicklungsserver dieser Woche — npm run build neben laufendem next dev macht .next unbrauchbar, und der Server antwortet danach nur noch mit 500 Server beenden, .next löschen. Diesmal notiert, damit es nicht ein viertes Mal passiert
Der Seed brach ab Er legt die Rollen aus ROLLEN an, und dort steht jetzt AUFSICHT — die es im Prisma-Enum bewusst nicht gibt Im Seed gefiltert, mit Begründung
unstable_update nahm den Wechselwunsch nicht an Es akzeptiert nur Partial<Session> Der Wunsch reist als user.mitgliedschaftId und wird serverseitig gegen die Mitgliedschaften geprüft
Die Nachbarwehr war fest verdrahtet ff-nachbarshausen für jeden Mandanten — genau die Vermischung von Test- und Beispieldaten, die Phase 8 beseitigt hat Aus dem Mandanten abgeleitet: <slug>-nachbar
Ein alter Datenbanktest wurde unzuverlässig ki-datenbank.test.ts legte Wehr und Person idempotent an, die Wiki-Seite aber nicht. Die neue Testdatei verschob die Reihenfolge, und ein Rest aus einem abgebrochenen Lauf ließ den ganzen Datei-Lauf scheitern Auch dort ein upsert
Nach db:test-reset zeigte der Wehrwähler nur noch die betreute Wehr Der Reset löschte die Testwehr, aber nicht ihre Nachbarwehr. Der Seed sah die Nachbarwehr, hielt alles für erledigt und legte die Mitgliedschaft in der neuen Testwehr nie wieder an Der Reset nimmt Nachbarwehr und Kreiskonto mit; der Seed prüft beide Teile getrennt statt mit einem gemeinsamen Ausstieg. Gefunden hat es ein E2E-Test
Im vollen Lauf fielen fremde Testdateien mit „Anmeldung fehlgeschlagen“ aus Der Fehlversuch-Test meldete sich mit der Adresse des Administrators an. Fünf Fehlversuche sperren das Konto fünf Minuten — bei zwei Projekten und Wiederholungen kam das zusammen, und alle anderen Dateien brauchen dasselbe Konto Der Test nimmt jetzt eine Adresse, die es nicht gibt. Die Meldung muss ohnehin dieselbe sein, damit sie nicht verrät, ob es die Adresse gibt

Abweichung von der Vorgabe

Zwei Architekturregeln bekommen eine dokumentierte Ausnahme.

„Jede Tabelle hat organisationId" gilt für Benutzer nicht — sie kann es nicht, ohne den Zweck der Tabelle aufzuheben. Die Ausnahme steht im Schema, in der Migration und in der Roadmap, und die Tabelle ist dafür über Rechte geschützt statt über RLS.

„Berechtigungen zentral in lib/authz.ts" gilt für den Verwalterzugang nicht: er hängt an Benutzer.istVerwalter und nicht an einem Recht. Rechte gelten je Wehr — ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre die Hintertür, die diese Phase gerade schließen soll.

Nachweise

  • npm run typecheck — ohne Ausgabe
  • npm run lint — ohne Befund
  • npm run test — 581 Tests grün, davon 11 neu gegen eine echte Datenbank
  • npm run test:e2e — grün über Desktop und Tablet, davon 26 neu
  • npm run db:seed und npm run db:test-reset — beide Mandanten samt Nachbarwehr und Kreisebene
  • Anmeldung nach der Umstellung von Hand geprüft: Konto gefunden, Passwort richtig, Sitzung trägt Konto, Mitgliedschaft und Verwaltermerkmal

2026-09-04 · Phase 9 — Kassenverwaltung

12:40 – 16:50 · 4:10 h · Version 0.10.0

Was entstanden ist

Neun Modelle, drei Trigger, zwölf CHECKs, sechs Router, fünf Ansichten und ein Kassenjahr im Seed.

Der Reihe nach, wie es CLAUDE.md vorgibt: Schema, Migration, Serverlogik, UI, Tests.

Bereich Umfang
Datenmodell Kassenkonto, Buchungskategorie, Buchung, Beitragssatz, Beitrag, Rechnung, Rechnungsposition, Kassenabschluss, Abschlussposten
Absicherung RLS für alle neun Tabellen, drei Trigger, zwölf CHECKs, ein REVOKE
Bibliothek lib/kasse/betrag.ts, bankimport.ts, vorschlag.ts, texte.ts, dokumente.ts
Router Konten, Kategorien, Buchungen, Beiträge, Rechnungen, Abschluss
Oberfläche Übersicht, Kassenbuch, Beiträge, Rechnungen, Abschluss mit Auswertung
Hintergrund Rechnungs-PDF, Kassenprüfbericht, Meldung überfälliger Posten
Tests 67 Unit-Tests (13 gegen eine echte Datenbank), 13 E2E-Tests

Entscheidungen

Die Sperren sitzen in der Datenbank, nicht im Router. Die ganze Phase hängt an einem Satz: was geprüft ist, ändert sich nicht mehr. Ein Zustandscheck in buchung.aendern erfüllt das nicht — er gilt für genau diesen einen Weg. Ein zweiter Router, ein Skript, ein Import, und der geprüfte Bestand ist ein anderer, ohne dass irgendwo etwas auffällt. Also drei Trigger: festgeschriebene Buchung unveränderbar, geprüfter Zeitraum zu, festgeschriebener Abschluss fest.

Jeder Trigger hat genau eine Ausnahme, und jede hat einen Grund. Die Buchung lässt den Verweis auf ihre Gegenbuchung eintragen — eine Stornierung entsteht später, sie einzutragen ist keine Änderung des Vorgangs, sondern seine Fortschreibung. Der Abschluss lässt den Prüfbericht nachtragen, denn das PDF entsteht im Hintergrund und damit nach dem Festschreiben. Ohne diese Ausnahme gäbe es nie einen Bericht.

Der Bestand wird gerechnet, nie gespeichert. Ein gespeicherter Bestand neben einer nachgetragenen Buchung wäre falsch, ohne falsch auszusehen — und genau danach sucht ein Kassenprüfer. Bei den Größenordnungen einer Feuerwehrkasse kostet das Rechnen nichts.

Der Kategorievorschlag geht dem eigenen Bestand vor der Stichwortliste nach. Wenn eine Wehr Diesel seit Jahren unter „Fahrzeuge" bucht statt unter „Kraftstoff", ist das ihre Entscheidung, und sie soll dabei bleiben dürfen. Die Stichwortliste greift erst, wenn es noch keinen Bestand gibt. Unter der Hälfte gemeinsamer Wörter schweigt der Vorschlag: ein falscher wird übernommen, ohne gelesen zu werden.

Der Bankimport erzeugt Entwürfe, keine Buchungen. Und jeder Umsatz trägt eine Kennung, damit ein zweimal eingelesener Auszug als solcher erkannt wird. Ohne das wäre es ein doppelt gebuchter Monat, und der fiele erst beim Kassenabschluss auf — also drei Monate später.

Kein XML-Parser für CAMT. Gebraucht werden sechs Felder aus einer flachen Struktur, die Datei kommt von der eigenen Bank. Der Parser ist kürzer als die Einbindung, und in einer Anwendung ohne Netzzugang ist jede Abhängigkeit weniger eine Sorge weniger. Namensräume werden ignoriert — genau das, was ein Parser hier auch täte.

In der Prüfmaske stehen die gezählten Felder leer. Der Buchbestand ist zu sehen, weil der Prüfer ihn für die Differenz braucht. Vorbelegt wird nichts: wer erst das Buch sieht und dann zählt, zählt, bis es passt.

Wo es gehakt hat

Problem Ursache Lösung
Der Abschluss ließ sich nicht mit seinem Bericht versehen Der Trigger sperrte auch berichtDateiId — das PDF entsteht aber erst nach dem Festschreiben Ausnahme für genau dieses Feld, wie beim Stornierungsverweis der Buchung
Die Rechnungsnummer war ein Wettlauf Höchste lesen, dann schreiben: zwei gleichzeitige Rechnungen lesen dieselbe Nummer Zusammenstoß erkennen und die nächste nehmen; gefunden hat es der parallele E2E-Lauf über zwei Projekte
Eine Organisation ließ sich nicht mehr löschen Der DELETE-Trigger der Buchung kannte den Aufbewahrungsschalter nicht, den Audit-Log und Prüfprotokoll haben app.audit_bereinigung auch dort — ohne ihn wäre die DSGVO-Löschung nicht durchführbar
Der Datenbanktest ließ sich nur einmal ausführen Dieselbe Ursache: das Aufräumen scheiterte an den festgeschriebenen Buchungen, und der Rest lief im nächsten Lauf gegen einen geprüften Zeitraum siehe oben
E2E: „Barkasse" fand sieben Elemente Das Wort steht auch in Verwendungszwecken über die Kartenüberschrift angesprochen
E2E: die erste Maifest-Zeile war die falsche Absteigend sortiert steht das laufende Jahr oben, und das ist nicht festgeschrieben eine Buchung genommen, die es nur im Vorjahr gibt — und die Gegenprobe dazu
Prisma wollte zum zehnten Mal die Trigramm-Indizes aus Phase 1 löschen Sie stehen in einer Roh-SQL-Migration, nicht im Schema 30 DropIndex-Blöcke entfernt, Kommentar in die Migration
Der volle E2E-Lauf meldete quer durch alle Module Fehler npm run build während eines laufenden next dev: der Produktionslauf überschreibt .next, und der Entwicklungsserver antwortet danach nur noch mit 500 Entwicklungsserver beenden, .next löschen, neu laufen lassen — 239 grün
Ein E2E-Test aus Phase 0 wurde rot Er sicherte zu, dass die Kasse kein Verweis ist — die Zusicherung ist mit dieser Phase abgelaufen umgeschrieben: die Kasse ist jetzt ein Verweis. Ein künftiges Modul zum Ausgrauen gibt es nicht mehr

Abweichung von der Vorgabe

Keine. Phase-9.md ist vollständig umgesetzt, einschließlich CAMT.053, MT940, Nummernkreis, Kassenprüfbericht und Export.

Der Beleg-Anhang an einer Buchung nutzt den Belegentwurf aus Phase 8 mit — er hat dafür einen buchungId bekommen. Fotografieren und Erfassen war bereits da; neu ist nur, dass daraus eine Buchung wird.

Nachweise

  • npm run typecheck — ohne Ausgabe
  • npm run lint — ohne Befund
  • npm run test — 562 Tests grün, davon 13 neu gegen eine echte Datenbank
  • npm run test:e2e — 239 Tests grün über Desktop und Tablet, davon 26 neue
  • npm run build — fünf neue Routen unter /kasse
  • npm run db:seed — Kassenjahr angelegt, Abschluss festgeschrieben
  • Rechnungs-PDF (41 kB) und Kassenprüfbericht (45 kB) einmal von Hand über Gotenberg erzeugt und in MinIO abgelegt — die beiden Vorlagen haben sonst keinen automatischen Nachweis

2026-09-04 · Aufräumarbeiten — Issues #10 bis #12

11:15 – 12:35 · 1:20 h

Was entstanden ist

Drei Punkte, die seit mehreren Phasen im Abschlussbericht standen und in jeder Phase Zeit gekostet haben. Diesmal als Issues angelegt und abgearbeitet.

Issue Kern
#11 ESLint hatte keine Konfiguration — npm run lint prüfte nichts
#12 <div> in einem <p> auf der Einsatzseite, seit Phase 3
#10 Die E2E-Tests schrieben in die Beispielwehr

Entscheidungen

ESLint zuerst. Er findet die anderen beiden — und tatsächlich hat er gleich zwei Fehler gemeldet, die niemand gesucht hatte: eine Fehlerfläche auf der Karte, die nie etwas anzeigen konnte, und zwei useMemo in der Lagekarte, die bei jedem Rendern neu rechneten.

Keine Formatierungsregeln. Die macht Prettier. Zwei Werkzeuge, die sich über Anführungszeichen streiten, lenken von den Regeln ab, auf die es ankommt.

Auch keine typgestützten Regeln. no-floating-promises wäre für diesen Stack die wertvollste Regel überhaupt — sie braucht aber den Compiler bei jedem Lauf, und dann dauert der Lauf Minuten statt Sekunden. Bei einem Werkzeug, das vor jedem Commit laufen soll, ist das der falsche Tausch.

Das Badge wurde ein <span>, nicht der Absatz ein <div>. Die Meldung kam von einer Stelle, aber die Ursache saß im Bauteil: ein Abzeichen ist Fließtext-Inhalt und gehört in ein Inline-Element. So ist auch jede künftige Verwendung in einem Absatz richtig, und die Darstellung ändert sich nicht — inline-flex verhält sich in beiden gleich.

Eine zweite Wehr statt Aufräumen nach dem Lauf. Ein abgebrochener Lauf lässt beim Aufräumen einen Bestand zurück, den niemand erwartet. Ein vollständiger Neuaufbau ist der einzige Zustand, den man kennt — und der Seed kann ohnehin schon alles, er brauchte nur eine Kennung aus der Umgebung.

Ein Riegel gegen den eigenen Fehler. npm run db:test-reset bricht ab, wenn die Testkennung mit der der Beispielwehr übereinstimmt. Ein Skript, das eine ganze Wehr löscht, braucht eine Stelle, an der es sich weigert.

Wo es gehakt hat

Problem Ursache Lösung
Zwei Tests fielen nach der Umstellung durch — und das war richtig so Sie hatten die Verschmutzung gebraucht. getByText("B 3 Wohnhausbrand", { exact: true }) traf bisher einen Einsatz aus einem früheren Testlauf, weil der kein Objekt hatte; der Einsatz aus dem Seed hat eines und steht in derselben Zelle Anker am Zellenanfang statt Prüfung auf Gleichheit
Der Kiosk-Test schrieb weiter in die Beispielwehr const WEHR = "ff-musterhausen" stand fest im Test. Das Terminal merkt sich die Wehr und schreibt Mängel und Fahrten genau dorthin Kennung aus wehr.ts, wie überall sonst
Ein doppelt deklarierter Bezeichner rutschte durch den Typecheck tests/e2e war in der tsconfig.json ausgeschlossen Ausnahme entfernt. Sie hat nichts gebracht: die Dateien übersetzen sauber
Die Anmeldung im Bildschirmfoto-Skript lief ins Leere Bekannt aus Phase 7 — bei kaltem .next ist die Seite noch nicht hydriert War schon behoben; hier nur wieder aufgetreten und bestätigt

Abweichung von der Vorgabe

Issues angelegt, obwohl CLAUDE.md das ausdrücklich verbietet („Niemals ein Issue anlegen"). Auf ausdrückliche Anweisung im Chat — die Regel stammt vom Auftraggeber, und er hat sie für diesen Vorgang aufgehoben.

Nachweise

  • npx tsc --noEmit — ohne Ausgabe, jetzt einschließlich tests/e2e
  • npm run lint — ohne Beanstandung
  • npm run test — 495 Tests grün
  • npx playwright test — grün gegen die Testwehr
  • Die Beispielwehr steht danach unverändert auf dem Seed-Stand: 49 Geräte, 8 Einsätze, 1 Atemschutzüberwachung, 19 Termine, 4 Wiki-Seiten

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.

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

## 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