Terminal im Geraetehaus mit Offline-Betrieb ergaenzen

Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der
Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz
weg ist.

Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang,
Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs
erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel;
kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt
ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und
verweigert UPDATE und DELETE auf kiosk_vorgang.

Offline: von Hand geschriebener Service Worker, Warteschlange in der
IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste
ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als
Konflikt unter /terminal statt verworfen zu werden.

Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy,
Hallenmonitor mit optionalen DWD-Warnungen, Installation als App.

Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern
neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal
geschickt. Gefunden vom Offline-E2E-Test.

31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 18:35:48 +02:00
co-authored by Claude Opus 5
parent d8d692098d
commit 791523027f
126 changed files with 7787 additions and 40 deletions
+204 -11
View File
@@ -1,10 +1,10 @@
# Anleitung
Bedienung von FLORIAN, Stand Phase 3 (Version 0.4.0). Die Bilder zeigen die
Bedienung von FLORIAN, Stand Phase 4 (Version 0.5.0). Die Bilder zeigen die
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
> Atemschutz, Karten und die Kiosk-Oberfläche kommen in den folgenden Phasen
> dazu. Siehe [ROADMAP.md](ROADMAP.md).
> Atemschutz und Karten kommen in den folgenden Phasen dazu. Siehe
> [ROADMAP.md](ROADMAP.md).
---
@@ -603,7 +603,7 @@ Bericht — wird später nachgetragen. Der Einsatz bekommt sofort eine
fortlaufende Nummer im Format `JJJJ-NNNN`.
**Koordinaten** lassen sich über „Koordinaten suchen" ermitteln, wenn die Wehr
einen eigenen Nominatim betreibt (Kapitel 24). Ohne den bleibt das Feld leer —
einen eigenen Nominatim betreibt (Kapitel 30). Ohne den bleibt das Feld leer —
das ist in Ordnung, die Karte kommt in Phase 6.
---
@@ -758,7 +758,194 @@ der in der Jahreshauptversammlung gefragt wird und die bei Beförderungen zählt
---
## 24. Adressen zu Koordinaten (Nominatim)
## 24. Das Terminal im Gerätehaus
![Kachelschirm des Terminals](screenshots/25-kiosk-kacheln-light.png)
Das Terminal ist eine **eigene Oberfläche** unter `/kiosk`, gedacht für ein
Wandtablet neben der Fahrzeughalle. Große Kacheln statt Seitenleiste, alles
Bedienbare mindestens 64 Pixel hoch, jede Kachel mit Symbol **und** Text — das
ist kein Geschmack, sondern die Voraussetzung dafür, dass es mit
Feuerwehrhandschuhen bedienbar bleibt.
### Einmal je Tablet einrichten
Bevor sich jemand anmelden kann, muss das Tablet wissen, zu welcher Wehr es
gehört. Das macht der Administrator einmal unter `/kiosk/einrichten`: Kurzname
der Wehr und das Gerätegeheimnis aus der `.env` (`KIOSK_DEVICE_SECRET`).
Damit am Tablet nichts abgetippt werden muss, nimmt die Seite beides auch aus
der Adresse an:
```
http://florian.example/kiosk/einrichten?wehr=ff-musterhausen&schluessel=…
```
Diesen Link schickt sich der Administrator einmal selbst — etwa als QR-Code vom
eigenen Rechner — und ruft ihn am Tablet auf.
> **Das Gerätegeheimnis ist die erste Schranke.** Ohne eingerichtetes Tablet
> nimmt der Server keine PIN und keine Karte an. Ein fremdes Handy, das
> `/kiosk` aufruft, kommt nicht weiter, auch nicht mit richtiger PIN.
### Anmelden
![Anmeldung am Terminal](screenshots/30-kiosk-anmeldung-light.png)
Drei Wege, in dieser Reihenfolge:
1. **Ausweis auflegen** — der Regelfall. Der Leser am Tablet gibt die
Kartenkennung wie eine Tastatur aus; es muss vorher nichts angetippt werden.
2. **QR-Code vom Handy** — für den, der den Ausweis vergessen hat (Kapitel 29).
3. **Mitgliedsnummer und PIN** — wenn beides fehlt.
Nach `KIOSK_IDLE_TIMEOUT` Sekunden ohne Bedienung (Vorgabe: 120) meldet sich das
Terminal von selbst ab, mit sichtbarer Warnung vorher. Ein Terminal, an dem die
letzte Anmeldung stehen bleibt, schreibt irgendwann alles dem Falschen zu.
### Was auf dem Kachelschirm steht
Die Kacheln richten sich nach zwei Dingen: den Rechten der angemeldeten Person
und ihrem aktuellen Zustand.
| Kachel | Wann sie sich ändert |
|---|---|
| Kommen / **Gehen** | Wer eingecheckt ist, sieht „Gehen" samt Zeit, seit wann |
| Fahrt beginnen / **Fahrt beenden** | Wer mit einem Fahrzeug unterwegs ist, sieht dessen Funkrufname |
| Wäsche abgeben | Zeigt an, wie viele Stücke noch nicht zurück sind |
| Inventur | Nur für den Gerätewart |
| Prüfung durchführen | Ohne Netz gesperrt, mit dem Hinweis „braucht Netz" |
---
## 25. Mangel melden am Terminal
![Mangel melden](screenshots/26-kiosk-mangel-light.png)
Der Vorgang, an dem sich das Terminal misst — Anspruch war **unter dreißig
Sekunden**. Deshalb drei Schritte und kein Formular:
1. **QR-Code am Gerät scannen.** Oder die Inventarnummer eintippen, wenn das
Etikett unleserlich ist. Steht die Nummer im Bestand, erscheint sofort
Bezeichnung und Zustand.
2. **Foto und ein Satz dazu.** Das Foto ist kein Pflichtfeld, macht aber die
halbe Arbeit: „Riss im Schlauch, etwa 20 cm von der Kupplung" plus Bild sagt
dem Gerätewart mehr als jedes Formular.
3. **Dringlichkeit antippen.** Damit ist die Meldung abgeschickt.
| Dringlichkeit | Was passiert |
|---|---|
| Kleinigkeit | kann warten |
| Normal | beim nächsten Dienst |
| Dringend | vor dem nächsten Einsatz |
| **Kritisch** | Das Gerät wird **sofort gesperrt** |
Eine unbekannte Inventarnummer hält die Meldung nicht auf. Sie geht trotzdem
raus und landet notfalls in der Konfliktliste (Kapitel 27) — lieber ein Zettel
zu viel als ein Riss im Schlauch, den niemand gemeldet hat.
---
## 26. Ohne Netz weiterarbeiten
In einer Fahrzeughalle ist das Netz regelmäßig weg: Stahlbeton, Tore aus
Metall, ein Repeater, der irgendwann ausfällt. Das Terminal ist dafür gebaut,
nicht dagegen abgesichert.
Über jeder Terminalseite läuft eine Leiste, die den Zustand in Klartext sagt:
| Leiste | Bedeutung |
|---|---|
| **Verbunden · alles übertragen** | Alles, was erfasst wurde, ist beim Server |
| **Verbunden · 2 Änderungen warten** | Wird gerade übertragen |
| **Kein Netz · 1 Änderung wartet** | Erfasst und gespeichert, wartet auf Netz |
Und so funktioniert es: **jede** erfasste Aktion geht zuerst in eine
Warteschlange im Tablet — auch dann, wenn Netz da ist. Erst von dort geht sie
zum Server. Deshalb ist der Ablauf ohne Netz derselbe wie mit Netz, nur dauert
er länger.
Was das im Betrieb bedeutet:
- **Die Bestätigung kommt sofort.** Wer eine Meldung abschickt, kann das Tablet
weglegen. Es ist gespeichert, auch wenn es noch nicht übertragen ist.
- **Ein Neustart des Tablets schadet nicht.** Die Warteschlange liegt im
Speicher des Browsers, nicht im Arbeitsspeicher.
- **Fotos warten mit.** Das Bild liegt neben der Meldung und wird beim
Übertragen zuerst hochgeladen.
- **Nichts wird doppelt gebucht.** Jeder Vorgang bekommt beim Erfassen eine
eigene Kennung. Bricht die Übertragung mitten drin ab und wird wiederholt,
erkennt der Server dieselbe Kennung wieder und bucht nicht erneut.
- **Die Prüfung braucht Netz.** Ein Prüfprotokoll mit Checkliste, Messwerten,
Fotos und Unterschrift lässt sich nicht sinnvoll zwischenspeichern — ein halb
erfasstes Protokoll wäre schlechter als keins. Die Kachel sagt das, statt ins
Leere zu führen.
Bleibt ein Vorgang mehr als fünf Versuche liegen, lässt er sich über „anzeigen"
einsehen und im Notfall verwerfen. Vorher nicht — und das ist Absicht.
---
## 27. Konflikte nachbearbeiten
![Terminalübersicht](screenshots/28-terminal-uebersicht-light.png)
Ein Tablet, das drei Tage offline war, schickt irgendwann Vorgänge, deren Ziel
es nicht mehr gibt: das Gerät wurde ausgesondert, die Prüfung inzwischen
abgeschlossen und damit festgeschrieben.
FLORIAN erzwingt in diesem Fall nichts und verwirft nichts. Der Vorgang landet
als **Konflikt** in der Übersicht unter `/terminal` — mit Zeitpunkt, Art,
Grund und der Person, die ihn erfasst hat. Der Gerätewart entscheidet und
vermerkt, wie es gelöst wurde.
Auf derselben Seite steht das Übrige, was am Terminal zusammenkommt: wer im
Haus ist, welche Wäsche in der Reinigung ist und das Fahrtenbuch.
---
## 28. Hallenmonitor
![Hallenmonitor](screenshots/27-kiosk-monitor-light.png)
`/kiosk/monitor` ist für einen Fernseher in der Fahrzeughalle gedacht: Vollbild,
große Schrift, **keine Bedienung**. Zu sehen sind Uhrzeit und Datum, ein
laufender Einsatz, wer gerade im Haus ist, die Fahrzeuge samt laufenden Fahrten,
fällige Prüfungen und die nächsten Termine.
Optional dazu **Wetterwarnungen des DWD**:
```bash
# .env
WETTER_ENABLED=true
DWD_WARNCELL=809162000
```
> Das ist die einzige Verbindung, die FLORIAN von sich aus nach außen aufbaut,
> und sie ist deshalb standardmäßig aus. Nach draußen geht nur die
> Warnzellen-Nummer, keine Daten der Wehr. Fällt der Abruf aus, bleibt die
> Kachel leer — eine Wetterwarnung ist eine Zugabe, alarmiert wird über die
> Leitstelle.
Die Nummer der eigenen Gemeinde steht in der
[Warnzellenliste des DWD](https://www.dwd.de/DE/leistungen/opendata/help/warnungen/cap_warncellids_csv.csv).
---
## 29. Anmeldemarke fürs Handy
![Anmeldemarke](screenshots/29-anmeldemarke-light.png)
Wer den Ausweis vergessen hat, öffnet in FLORIAN auf dem Handy die Seite
**Anmeldemarke** und hält den QR-Code vor die Kamera des Terminals.
Der Code gilt **60 Sekunden** und wird von der Seite selbst erneuert. Er ist
signiert und nirgends gespeichert: ein abfotografierter Code aus der Umkleide
nützt eine Minute später nichts mehr.
---
## 30. Adressen zu Koordinaten (Nominatim)
FLORIAN löst Einsatzadressen nur auf, wenn die Wehr einen **eigenen** Nominatim
betreibt:
@@ -778,7 +965,7 @@ GEOCODING_LAENDER=de,at,ch
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
eintragen oder weglassen.
## 25. Rollen und was sie dürfen
## 31. Rollen und was sie dürfen
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
@@ -807,7 +994,7 @@ Das Terminal selbst ist nur die Tür.
---
## 26. Anmeldung über authentik einrichten
## 32. Anmeldung über authentik einrichten
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
@@ -860,9 +1047,10 @@ ganze Wehr aus.
---
## 27. Terminal-Anmeldung im Gerätehaus
## 33. PIN und Ausweiskarte verwalten
Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits.
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
was dahinter liegt.
Am Terminal wird zweifach geprüft:
@@ -882,7 +1070,7 @@ Huber), PIN `471203`.
---
## 28. Was protokolliert wird
## 34. Was protokolliert wird
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
@@ -907,7 +1095,7 @@ jeweiligen Detailseite sichtbar.
---
## 29. Wenn etwas nicht geht
## 35. Wenn etwas nicht geht
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|---|---|---|
@@ -922,6 +1110,11 @@ jeweiligen Detailseite sichtbar.
| Umlaute im Import verstümmelt | Die Datei ist weder UTF-8 noch Windows-1252 | In Excel als „CSV UTF-8" speichern |
| Gerät lässt sich nicht speichern | Standort **und** Fahrzeug gesetzt, oder keines von beidem | Genau eines von beiden angeben |
| `docker compose up` startet Traefik mit | Override-Datei wurde nicht mitgeladen | In Produktion `-f docker-compose.yml` angeben |
| Terminal zeigt „Terminal nicht eingerichtet" | Das Tablet kennt keine Wehr, oder der Browserspeicher wurde geleert | `/kiosk/einrichten` erneut aufrufen |
| Terminal weist jede PIN ab | Gerätegeheimnis stimmt nicht mit `KIOSK_DEVICE_SECRET` überein | Einrichtung mit dem aktuellen Wert wiederholen |
| Die Leiste zeigt dauerhaft wartende Änderungen | Server erreichbar, aber der Vorgang wird abgelehnt | Über „anzeigen" den Grund lesen; landet er als Konflikt, steht er unter `/terminal` |
| „Der Code wurde nicht erkannt" beim QR-Login | Marke älter als 60 Sekunden | Auf dem Handy die Seite Anmeldemarke offen halten, sie erneuert den Code selbst |
| Der Monitor zeigt keine Wetterwarnung | `WETTER_ENABLED` aus oder `DWD_WARNCELL` leer | Beides setzen; ohne Warnzelle wird nichts abgerufen |
Zustand aller Randsysteme auf einen Blick:
+37 -1
View File
@@ -192,7 +192,7 @@ Zwei Dinge fielen erst beim Tun auf:
## Phase 4 — Kiosk-PWA
**Vergleichsaufwand: 16–22 PT**
**Vergleichsaufwand: 16–22 PT** · **Tatsächlich: 3:00 h Sitzungszeit**
| Baustein | PT |
|---|---:|
@@ -211,6 +211,42 @@ unter Verbindungsabbruch, Doppelklick und parallelem Zugriff von zwei
Terminals nichts verliert und nichts doppelt schreibt, ist die eigentliche
Arbeit. Die Idempotenzschlüssel sind dafür der Schlüssel, nicht die Synchronisation.
**Rückblick.** Der Satz oben hat sich bestätigt, nur anders als gedacht. Die
Outbox selbst war in einer halben Stunde geschrieben und mit zwölf Unit-Tests
belegt. Zwei Stellen kosteten die Zeit, und keine davon steht in der
Aufstellung:
- **Der Zeitgeber, der nie ablief.** Die Warteschlange hatte einen
Wiederholungstakt von 20 Sekunden — und der wurde bei jedem Rendern neu
aufgezogen, weil das Mutationsobjekt von React Query in den Abhängigkeiten
stand. Alles sah richtig aus, alle Unit-Tests waren grün, und der liegen
gebliebene Vorgang wäre im Gerätehaus trotzdem liegen geblieben. Gefunden hat
das nur der Playwright-Test, der `context.setOffline(true)` setzt und dann
wartet. Die 1,5 PT für „Playwright-Tests für den Offline-Fall" waren der
bestangelegte Posten der Phase.
- **Die Frage, wann ein Vorgang aus der Warteschlange darf.** Nicht „wenn
erfolgreich", sondern „wenn er beim Server angekommen ist" — dazu gehört auch
„bereits verarbeitet" und „in der Konfliktliste". Diese Unterscheidung ist die
ganze Idempotenz; ohne sie kreist ein Vorgang für immer.
Billiger als angesetzt war die Anmeldung per RFID: die Aufstellung nennt WebHID,
gebraucht wurde es nicht. Handelsübliche Leser geben die Kartenkennung als
Tastatureingabe aus. Ein Zuhörer auf dem Fenster, der am Anschlagstempo
erkennt, dass da keine Hand tippt, ersetzt eine ganze Geräteschnittstelle.
Billiger war auch der Service Worker — 80 Zeilen von Hand, ohne Workbox. Die
Regeln sind einfach, solange man sie sich traut aufzuschreiben: Seiten aus dem
Netz mit Ausweichseite, lesende Abfragen aus dem Zwischenspeicher mit
Nachladen, schreibende **überhaupt nicht** anfassen. Eine Bibliothek hätte hier
mehr Fragen aufgeworfen als beantwortet.
Teurer war die Terminaloberfläche selbst. Nicht die Kacheln, sondern die
Zustände dahinter: Wer schon eingecheckt ist, sieht „Gehen" statt „Kommen". Wer
mit einem Fahrzeug unterwegs ist, sieht „Fahrt beenden". Wer keine Rechte für
die Inventur hat, sieht die Kachel nicht. Jede dieser Verzweigungen ist
trivial — sechs davon in einem Schirm, der ohne Nachdenken bedienbar bleiben
soll, sind es nicht.
## Phase 5 — Atemschutz nach FwDV 7
**Vergleichsaufwand: 14–18 PT**
+99 -1
View File
@@ -15,7 +15,105 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| 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 |
| **Summe** | **7:40 h** | **5** | |
| 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 | `<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
---
+11 -6
View File
@@ -13,7 +13,7 @@ Phase 0 ████████████████████ fertig
Phase 1 ████████████████████ fertig 0.2.0
Phase 2 ████████████████████ fertig 0.3.0
Phase 3 ████████████████████ fertig 0.4.0
Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
Phase 4 ████████████████████ fertig 0.5.0
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
@@ -97,21 +97,26 @@ die Unfallkasse standhält.
**Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen.
## Phase 4 — Kiosk-PWA · als Nächstes
## Phase 4 — Kiosk-PWA · fertig
Für die Wandtablets in der Fahrzeughalle.
- Eigenes Layout: große Kacheln, mindestens 64px, mit Handschuhen bedienbar
- Anmeldung per RFID, PIN oder QR vom Handy, Auto-Logout nach 90 Sekunden
- Wizards: Wartung, Fahrtenbuch, Mangel melden, Inventur, Wäsche, Anwesenheit
- Anmeldung per RFID, PIN oder QR vom Handy, Abmeldung nach Untätigkeit
(`KIOSK_IDLE_TIMEOUT`, Vorgabe 120 Sekunden)
- Wizards: Fahrtenbuch, Mangel melden, Inventur, Wäsche, Anwesenheit. Die
Prüfung bleibt beim Wizard aus Phase 2 — Checkliste, Fotos und Unterschrift
lassen sich nicht sinnvoll in eine Warteschlange legen; die Kachel sagt
„braucht Netz"
- Hallen-Monitor: Vollbild mit Terminen, überfälligen Prüfungen,
Fahrzeugstatus, Wetterwarnung
- Offline-Betrieb mit IndexedDB-Outbox und Synchronisation bei Reconnect
- Offline-Betrieb mit IndexedDB-Outbox, Idempotenzschlüssel und
Konfliktliste für alles, was sich beim Nachtragen nicht anwenden lässt
**Danach kann die Wehr:** einen Mangel in unter 30 Sekunden melden, ohne den
Rechner im Büro einzuschalten — auch wenn das Netz gerade weg ist.
## Phase 5 — Atemschutz nach FwDV 7
## Phase 5 — Atemschutz nach FwDV 7 · als Nächstes
Sicherheitsrelevant. Jede Ansicht zeigt dauerhaft den Hinweis, dass die
analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die
Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 KiB

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 965 KiB

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1010 KiB

After

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB