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>
278 lines
15 KiB
Markdown
278 lines
15 KiB
Markdown
# Devlog
|
||
|
||
Entwicklungstagebuch mit Zeiterfassung. Jeder Arbeitsabschnitt bekommt einen
|
||
Eintrag: wann, wie lange, was entstanden ist, und was dabei schiefging.
|
||
|
||
Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
||
„tatsächlich" der [Aufwandsschätzung](AUFWANDSSCHAETZUNG.md).
|
||
|
||
## Zeitübersicht
|
||
|
||
| Phase | Sitzungszeit | Sitzungen | abgeschlossen |
|
||
|---|---:|---:|---|
|
||
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
|
||
| Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 |
|
||
| Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 |
|
||
| **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 Ausgabe
|
||
- `npm run test` — 104 Tests grün
|
||
- `npm run test:e2e` — 56 grün, 2 begründet übersprungen
|
||
- `npm run build` — 19 Routen
|
||
- Testgeräte aus dem E2E-Lauf wurden aus der Entwicklungsdatenbank entfernt
|
||
|
||
---
|
||
|
||
## 2026-09-03 · Phase 1 — Stammdaten
|
||
|
||
**07:10 – 08:25 · 1:15 h · Version 0.2.0**
|
||
|
||
### Was entstanden ist
|
||
|
||
41 neue Quelldateien, der Umfang wächst von rund 6.100 auf 15.600 Zeilen.
|
||
|
||
| Bereich | Inhalt |
|
||
|---|---|
|
||
| Datenmodell | 5 neue Tabellen (Standort, Geraeteraum, Geraetetyp, Qualifikationstyp, Qualifikation), Fahrzeug und Geraet deutlich erweitert, 2 Migrationen |
|
||
| Fachlogik | `fristen.ts` (Prüftermine, Lebensdauer, Statusableitung), `qr.ts` und `qr-parser.ts`, `etiketten.ts`, `pdf.ts`, `import.ts` und `import-felder.ts` |
|
||
| Server | 8 tRPC-Router mit Liste, Detail, Anlegen, Ändern, Massenbearbeitung und Verlauf |
|
||
| Oberfläche | Geräteliste mit Suche und Spaltenauswahl, Gerätedetail mit QR, Fahrzeugliste und -detail mit Beladung, Mitgliederliste, Mitgliedsdetail, Qualifikationen, Import-Assistent, QR-Scanner |
|
||
| Daten | Seed erweitert: 3 Standorte, 20 Geräteräume, 14 Gerätetypen, 49 Geräte, 14 Qualifikationstypen, 107 Qualifikationen |
|
||
| Tests | 104 Vitest-Tests (vorher 32), 44 Playwright-Tests (vorher 10) |
|
||
|
||
### Entscheidungen
|
||
|
||
**Der Gerätetyp ist Vorgabe, das Gerät darf abweichen.** Vierzig Pressluftatmer
|
||
haben dasselbe Prüfintervall — das gehört einmal an den Typ und nicht
|
||
vierzigmal an das Gerät. Wo der Hersteller für eine Charge etwas anderes
|
||
vorschreibt, gewinnt der Wert am Gerät. Umgesetzt in `effektiverWert()`, das
|
||
bewusst zwischen `null` und `0` unterscheidet: ein Intervall von 0 ist eine
|
||
Angabe, kein fehlender Wert.
|
||
|
||
**Ein Gerät liegt entweder an einem Standort oder auf einem Fahrzeug.** Beides
|
||
gleichzeitig wäre eine Falschangabe, keines von beidem auch. Das steht als
|
||
Prüfbedingung in der Datenbank, nicht nur im Formular — und ein Trigger prüft
|
||
zusätzlich, dass ein angegebener Geräteraum wirklich zu dem angegebenen
|
||
Fahrzeug gehört. Das ließe sich als CHECK nicht ausdrücken.
|
||
|
||
**Der QR-Code enthält die Inventarnummer, nicht die UUID.** Eine UUID wäre
|
||
36 Zeichen lang; der Code bekäme ein feines Muster, und auf einem 38 × 21 mm
|
||
großen Etikett im staubigen Geräteraum wird das unzuverlässig. Die
|
||
Inventarnummer steht ohnehin lesbar daneben, und wer den Code mit einem
|
||
beliebigen Scanner liest, sieht sofort, um welches Gerät es geht.
|
||
|
||
**Der Import liest die Datei zweimal.** Die Vorschau zeigt, was passieren
|
||
würde; beim Übernehmen wird die Datei erneut hochgeladen und serverseitig neu
|
||
eingelesen. Es wandert kein einziger Wert aus dem Browser in die Datenbank.
|
||
Der Preis ist eine zweite Übertragung — bei den Größen, um die es geht, kein
|
||
Thema.
|
||
|
||
**Der Etikettenbogen kann an beliebiger Position beginnen.** Wer drei Etiketten
|
||
braucht, soll nicht einen ganzen Bogen opfern.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Fristen um einen Tag verschoben | `startOfDay` aus date-fns rechnet in der Zeitzone des Servers; ein DATE aus der Datenbank kommt als UTC-Mitternacht und landete dadurch auf dem Vortag | eigene Kalenderarithmetik auf UTC-Bestandteilen, „heute“ über Europe/Berlin bestimmt |
|
||
| `ctx.sitzung` blieb nullbar | `t.middleware()` erzeugt eine Middleware auf dem Wurzelkontext; die Verengung aus der vorigen Middleware ging verloren | Middleware direkt als Funktion an `.use()` übergeben |
|
||
| Import-Tests brachen beim Laden ab | `import "server-only"` wirft beim blossen Import, und Vitest ist weder Server noch Browser | im Testlauf durch ein leeres Modul ersetzt (`tests/stubs/server-only.ts`) |
|
||
| 15 E2E-Tests im Zeitlimit | `next dev` übersetzt jede Route erst beim ersten Aufruf; zu viele parallele Erstaufrufe | Worker auf 4 begrenzt, Zeitlimit angehoben |
|
||
| 5 weitere E2E-Tests rot | `CardTitle` war ein `div` und damit keine Überschrift | in der Komponente auf ein echtes `h3` umgestellt — behebt zugleich einen Zugänglichkeitsmangel |
|
||
| Erste Bilder zeigten Ladebalken | „networkidle“ greift zu früh: die Listen holen ihre Daten erst nach, wenn React im Browser gelaufen ist | Screenshot-Skript wartet, bis keine Ladeanzeige mehr im Baum ist |
|
||
| Demodaten inkonsistent | die 40 Geräte aus Phase 0 hatten keinen Gerätetyp, und der Seed ist idempotent | Geräte-Zeilen der Entwicklungsdatenbank gezielt geleert und neu eingespielt |
|
||
|
||
### Abweichung von der Vorgabe
|
||
|
||
**`prisma migrate reset` wurde nicht ausgeführt.** Der naheliegende Weg, die
|
||
Demodaten sauber neu aufzubauen, verlangt eine ausdrückliche Freigabe — Prisma
|
||
verweigert den Befehl gegenüber einem KI-Agenten ohne Zustimmung. Statt zu
|
||
fragen wurde der schmalere Weg gewählt: nur die Tabelle `geraet` der
|
||
Entwicklungsdatenbank geleert und neu eingespielt.
|
||
|
||
**Kein eigenes Formular zum Anlegen von Geräten.** Die Router können anlegen,
|
||
ändern, umlagern und aussondern; die Oberfläche dazu deckt Liste, Detail,
|
||
Massenbearbeitung und Import ab. Ein einzelnes Anlegeformular fehlt noch — in
|
||
der Praxis kommen Bestände als Tabelle herein, und einzelne Neuzugänge lassen
|
||
sich bis dahin über den Import erfassen. Nachzuholen in Phase 2, wo ohnehin
|
||
Formulare für Prüfungen entstehen.
|
||
|
||
### Nachweise
|
||
|
||
- `npm run typecheck` — ohne Ausgabe
|
||
- `npm run test` — 104 Tests grün, davon 8 gegen eine echte Datenbank
|
||
- `npm run test:e2e` — 44 Tests grün (Desktop und Tablet)
|
||
- `npm run build` — 17 Routen, 103 kB gemeinsames JavaScript
|
||
- `npm run screenshots` — 18 Bilder, hell und dunkel
|
||
- Keine Konsolenfehler und keine Hydratationswarnungen im Browser
|
||
|
||
---
|
||
|
||
## 2026-09-02 / 03 · Phase 0 — Fundament
|
||
|
||
**23:13 – 00:13 · 1:00 h · Version 0.1.0**
|
||
|
||
### Was entstanden ist
|
||
|
||
46 Quelldateien, rund 6.100 Zeilen. Im Einzelnen:
|
||
|
||
| Bereich | Inhalt |
|
||
|---|---|
|
||
| Betrieb | `docker-compose.yml` (7 Dienste), Entwicklungs-Override, mehrstufiges Dockerfile, Postgres-Initialisierung, `.env.example` mit Kommentar zu jeder Variable |
|
||
| Datenmodell | Prisma-Schema mit 11 Tabellen, 3 Migrationen |
|
||
| Kern | `env`, `db`, `authz`, `storage`, `audit`, `auth`, `passwort`, `warteschlange`, 3 Integrationsschnittstellen |
|
||
| Server | tRPC v11 mit drei Prozedurarten, Kontext, Wurzel-Router |
|
||
| Oberfläche | Layout, Seitenleiste, Anmeldeseite, Übersicht, 5 shadcn-Bausteine, Farbwelt in OKLCH |
|
||
| Daten | Seed: 1 Wehr, 9 Rollen, 38 Rechte, 16 Personen, 3 Fahrzeuge, 40 Geräte |
|
||
| Tests | 32 Vitest-Tests, 10 Playwright-Tests |
|
||
| Doku | README, Anleitung, Roadmap, Aufwandsschätzung, Versionierung, Changelog |
|
||
|
||
### Entscheidungen
|
||
|
||
**Zwei Datenbankverbindungen statt einer.** Der erste Entwurf hatte einen
|
||
einzigen Prisma-Client. Der ist beim ersten Anmeldeversuch aufgelaufen: die
|
||
Anwendung verbindet sich als `florian_app`, für den Row Level Security greift
|
||
— und die Anmeldung muss eine E-Mail-Adresse über alle Wehren hinweg suchen,
|
||
bevor überhaupt feststeht, zu welcher Wehr sie gehört. Ohne gesetzten
|
||
Mandantenkontext liefert RLS null Zeilen, also fand der Login nie jemanden.
|
||
|
||
Die Lösung ist eine saubere Trennung: `prismaApp` für alle Fachdaten (RLS
|
||
aktiv), `prismaSystem` für die wenigen Vorgänge, die vor der
|
||
Mandantenauflösung stattfinden. Der Name ist bewusst unbequem, damit auffällt,
|
||
wenn ihn jemand im falschen Zusammenhang benutzt.
|
||
|
||
**Kein FORCE ROW LEVEL SECURITY.** Der Tabelleneigentümer umgeht RLS. Das ist
|
||
gewollt: Migrationen und Seed müssen über alle Mandanten hinweg arbeiten
|
||
können. Mit `FORCE` wäre der Seed nicht mehr lauffähig gewesen.
|
||
|
||
**Audit-Log: append-only mit genau einer Ausnahme.** Der erste Trigger hat
|
||
`UPDATE` und `DELETE` ausnahmslos gesperrt. Das ist beim Aufräumen der
|
||
Testdaten aufgefallen — eine Organisation ließ sich nicht mehr löschen, weil
|
||
die Kaskade auf den Audit-Log traf.
|
||
|
||
Das ist kein Testproblem, sondern ein echtes: nach
|
||
`AUDIT_AUFBEWAHRUNG_TAGE` muss der Worker alte Einträge exportieren und dann
|
||
entfernen, sonst wächst die Tabelle unbegrenzt und das Löschkonzept nach DSGVO
|
||
lässt sich nicht umsetzen. Die zweite Migration führt deshalb einen
|
||
ausdrücklichen Schalter ein: `SET LOCAL app.audit_bereinigung = 'an'` erlaubt
|
||
`DELETE` für die laufende Transaktion. `UPDATE` bleibt unter allen Umständen
|
||
gesperrt — ein Eintrag darf verschwinden, nachdem er gesichert wurde, aber
|
||
niemals verfälscht werden.
|
||
|
||
**Rollen aus authentik nur als Vorbelegung.** Auf Nachfrage entschieden: der
|
||
Gruppen-Claim setzt die Rolle beim ersten Login, danach ist die in FLORIAN
|
||
gepflegte Rolle führend. Umgesetzt über das Feld `ausOidcGruppe` an
|
||
`PersonRolle` und eine Prüfung, die nur bei leerer Rollenliste greift.
|
||
|
||
**Terminal mit zwei Schranken.** Erst weist sich das Gerät mit
|
||
`KIOSK_DEVICE_SECRET` aus, dann die Person mit PIN oder Karte. Eine erratene
|
||
sechsstellige PIN nützt ohne das Gerätegeheimnis nichts.
|
||
|
||
### Wo es gehakt hat
|
||
|
||
| Problem | Ursache | Lösung |
|
||
|---|---|---|
|
||
| Postgres startete nicht | `de_DE.UTF-8` gibt es im Debian-Basisimage nicht | ICU-Locale: `--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8` |
|
||
| Init-Skript brach mit Syntaxfehler ab | psql ersetzt seine Variablen nicht innerhalb dollar-quotierter Blöcke | `DO $$`-Block durch `SELECT format(...) \gexec` ersetzt |
|
||
| Anmeldung schlug immer fehl | siehe oben: RLS blockierte die E-Mail-Suche | zweite Datenbankverbindung |
|
||
| `npm install` ließ Installationsskripte aus | npm 11.17 verlangt eine ausdrückliche Freigabe | `npm approve-scripts` für Prisma, esbuild und drei weitere |
|
||
| Aufräumen der Testdaten scheiterte | Audit-Trigger blockierte die Kaskade | Aufbewahrungsschalter (siehe oben) |
|
||
| Zwei E2E-Tests rot | Next.js hängt einen eigenen Routen-Ansager mit `role="alert"` in die Seite; `CardTitle` ist ein `div`, keine Überschrift | Fehlermeldung über `data-testid` angesprochen, Anmeldetitel zu einer echten `h1` gemacht |
|
||
|
||
### Abweichung von der Vorgabe
|
||
|
||
**`Berechtigung` trägt kein `organisationId`.** Die Architekturregel verlangt
|
||
es für jede Tabelle. Diese Tabelle ist aber kein Mandantendatum, sondern der
|
||
systemweite Katalog dessen, was die Software überhaupt kann — für alle Wehren
|
||
identisch. Mandantenbezogen wird es erst über `RolleBerechtigung`, und die
|
||
trägt `organisationId`. Die Tabelle ist für alle lesbar und für die
|
||
Anwendungsrolle nicht beschreibbar. Die Ausnahme steht im Schema und in der
|
||
Migration kommentiert.
|
||
|
||
**Phasenzuordnung in der Seitenleiste korrigiert.** Beim Schreiben der Roadmap
|
||
ist aufgefallen, dass ich die Module den falschen Phasen zugeordnet hatte
|
||
(Atemschutz als Phase 3, Einsätze als Phase 4). Nach `Phase-3.md` bis
|
||
`Phase-8.md` gilt: 3 Einsätze, 4 Kiosk, 5 Atemschutz, 6 Karten, 7
|
||
Benachrichtigungen, 8 KI. Seitenleiste, Kommentare in `authz.ts` und die
|
||
Übersicht sind entsprechend berichtigt.
|
||
|
||
**Kassenmodul kommt in keiner Phase vor.** Die Rolle `KASSIER` ist gefordert
|
||
und angelegt, ein Modul dazu gibt es in `Phase-0.md` bis `Phase-8.md` nicht.
|
||
Die Rechte stehen im Katalog, es gibt aber keinen Navigationseintrag. Offene
|
||
Entscheidung, siehe [ROADMAP.md](ROADMAP.md).
|
||
|
||
### Nachweise
|
||
|
||
- `npm run typecheck` — ohne Ausgabe
|
||
- `npm run test` — 32 Tests grün, davon 8 gegen eine echte Datenbank
|
||
- `npm run test:e2e` — 10 Tests grün (Desktop und Tablet)
|
||
- `npm run build` — 6 Routen, 103 kB gemeinsames JavaScript
|
||
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund
|
||
|
||
---
|
||
|
||
## Vorlage für neue Einträge
|
||
|
||
```markdown
|
||
## JJJJ-MM-TT · Phase N — Titel
|
||
|
||
**HH:MM – HH:MM · X:XX h · Version 0.N.0**
|
||
|
||
### Was entstanden ist
|
||
### Entscheidungen
|
||
### Wo es gehakt hat
|
||
### Abweichung von der Vorgabe
|
||
### Nachweise
|
||
```
|