Schliesst die letzte offene Luecke aus Phase 1. Bis dahin liessen sich Geraete
nur ueber den Import erfassen — fuer einen Bestand richtig, fuer ein einzelnes
nachbeschafftes Geraet umstaendlich.
Neu:
- /geraete/neu und /geraete/<id>/bearbeiten, beide ueber dieselbe Komponente
- Die Lagerung ist ein Umschalter statt zweier Auswahlfelder. Standort und
Fahrzeug schliessen sich aus; so kann der Fehler gar nicht erst entstehen,
den die Datenbank sonst abweisen wuerde. Beim Wechsel des Fahrzeugs wird die
Raumangabe zurueckgesetzt.
- Prueftermin und Lebensdauerende werden beim Tippen vorgerechnet, mit
denselben Funktionen wie auf dem Server. Beim Speichern rechnet der Server
erneut und behaelt das letzte Wort.
- Gibt es nur einen Standort, ist er beim Anlegen vorbelegt.
- Knopf "Neues Geraet" in der Liste, "Bearbeiten" auf der Detailseite
Behoben:
- Das Formular stuerzte beim ersten Rechnen ab. Die Vorgabewerte lagen in einem
"use client"-Modul und wurden von einer Server-Komponente importiert; dort
kommt nur eine Client-Referenz an, kein Objekt, und das Spreaden ergibt lauter
undefined. Die Werte liegen jetzt in geraet-formular-werte.ts ohne
"use client".
- Das Sternchen an Pflichtfeldern wurde von Sprachausgaben mitgelesen
("Bezeichnung Stern"). Es traegt jetzt aria-hidden; dass ein Feld ausgefuellt
werden muss, sagt bereits das required-Attribut.
- Fehlermeldungen in Formularen sind eindeutig ansteuerbar. Next.js haengt
einen eigenen Routen-Ansager mit der Rolle "alert" in jede Seite.
Tests:
- 7 neue Playwright-Tests zum Formular, darunter die Lagerungsregel und die
Fristenvorschau
- Zwei davon laufen nicht unter Touch-Emulation: Chromium nimmt dort keine
zeichenweise Eingabe in Datumsfelder an. Begruendet uebersprungen, die
Rechnung selbst deckt qr-und-fristen.test.ts ab.
- Insgesamt 104 Unit-Tests und 56 E2E-Tests gruen
Anleitung, Changelog, Devlog, Aufwandsschaetzung und Roadmap fortgeschrieben.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
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 |
| Summe | 3:05 h | 3 |
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 Ausgabenpm run test— 104 Tests grünnpm run test:e2e— 56 grün, 2 begründet übersprungennpm 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 Ausgabenpm run test— 104 Tests grün, davon 8 gegen eine echte Datenbanknpm run test:e2e— 44 Tests grün (Desktop und Tablet)npm run build— 17 Routen, 103 kB gemeinsames JavaScriptnpm 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 Ausgabenpm run test— 32 Tests grün, davon 8 gegen eine echte Datenbanknpm run test:e2e— 10 Tests grün (Desktop und Tablet)npm run build— 6 Routen, 103 kB gemeinsames JavaScriptdocker 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