# 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 | | **Summe** | **10:40 h** | **6** | | --- ## 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 | `` 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 `