Phase 7: Benachrichtigungen und Termine

Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und
Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP,
ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als
ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite.

Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere
Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr
verlaesst, bekommt nur die Kurzfassung ohne Namen und Details.

Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp
(Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) --
beides im README begruendet.

56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 08:39:38 +02:00
co-authored by Claude Opus 5
parent c779266450
commit 0b8874da26
134 changed files with 9340 additions and 123 deletions
+250 -11
View File
@@ -1,10 +1,10 @@
# Anleitung
Bedienung von FLORIAN, Stand Phase 6 (Version 0.7.0). Die Bilder zeigen die
Bedienung von FLORIAN, Stand Phase 7 (Version 0.8.0). Die Bilder zeigen die
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
> Benachrichtigungen, lokale KI und die Kassenverwaltung kommen in den
> folgenden Phasen dazu. Siehe [ROADMAP.md](ROADMAP.md).
> Die lokale KI und die Kassenverwaltung kommen in den folgenden Phasen dazu.
> Siehe [ROADMAP.md](ROADMAP.md).
---
@@ -1279,7 +1279,230 @@ GEOCODING_LAENDER=de,at,ch
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
eintragen oder weglassen.
## 39. Rollen und was sie dürfen
## 39. Der Terminkalender
![Termine](screenshots/37-termine-light.png)
Unter **Termine** steht, was ansteht: Dienste, Übungen, Lehrgänge,
Arbeitsdienste, Veranstaltungen, Sitzungen. Die Liste blickt nach vorn — das
ist, was fast immer gemeint ist.
An jeder Zeile stehen zwei Knöpfe: **Zusagen** und **Absagen**. Mehr braucht
das normale Mitglied nicht.
### Rückmeldung ist die Ausnahme
Beim Übungsabend meldet sich niemand an, man kommt oder man kommt nicht. Erst
beim Lehrgang mit zwölf Plätzen und beim Ausflug mit Busbestellung wird beim
Anlegen **Rückmeldung verlangen** angekreuzt.
Wer dann Plätze begrenzt, bekommt automatisch eine **Warteliste**: Zusagen über
die Platzzahl hinaus landen dort, nummeriert in der Reihenfolge der Anmeldung.
Sagt jemand ab, rückt der Erste von selbst nach und erfährt es. Niemand muss
eine Liste führen.
### Serientermine
Beim Anlegen unter **Wiederholung** wählen: wöchentlich, alle zwei Wochen,
monatlich, jährlich. Dazu ein Datum, bis zu dem die Serie läuft.
Darunter steht dann in einem Kasten, **wie viele Termine daraus werden** —
lesen Sie das, bevor Sie auf „Anlegen" drücken. Wöchentlich über ein Jahr sind
52 Einträge.
FLORIAN legt jeden Abend als eigenen Termin an, nicht als Regel. Das ist
Absicht: so lässt sich der Abend im August absagen, der im Dezember eine Woche
vorziehen, und für jeden gibt es eine eigene Teilnehmer- und Anwesenheitsliste.
Bei „monatlich" bleibt der Tag im Monat gleich — der 31. rutscht im Februar auf
den 28., nicht in den März.
### Absagen statt löschen
Ein Termin, zu dem schon jemand zugesagt hat, lässt sich nicht löschen. Er wird
**abgesagt**, mit Grund. Er bleibt dann durchgestrichen im Kalender stehen, der
Grund steht daneben, und wer zugesagt hatte, bekommt eine Nachricht — nicht die
ganze Wehr.
Gehört der Termin zu einer Serie, können Sie im selben Schritt **alle folgenden
Termine** absagen. Vergangene bleiben unberührt; man sagt nicht rückwirkend ab,
was schon stattgefunden hat.
### Anwesenheitsliste
Auf jedem Termin oben rechts. Sie zeigt **alle aktiven Mitglieder**, nicht nur
die Angemeldeten — auf einem Blatt zum Abhaken muss jeder stehen. Daneben, was
jemand gemeldet hatte.
Wer das Recht dazu hat, hakt gleich hier ab; sonst führt **Drucken** zu einem
Blatt mit leeren Kästchen.
> Zusage und Anwesenheit sind zwei verschiedene Dinge. Wer zugesagt hat und
> nicht kam, steht genau so in der Liste — das ist der Zweck der Trennung.
---
## 40. Benachrichtigungen einrichten
![Benachrichtigungen](screenshots/38-benachrichtigungen-light.png)
> **Niemand wird ungefragt benachrichtigt.** Wer hier nichts einträgt, bekommt
> nichts. Es gibt keine Voreinstellung, die alle anmeldet.
Unter **Benachrichtigungen** richten Sie Ihre eigenen Wege ein — Ihre, nicht
die von jemand anderem. Auch der Kommandant kann niemanden für Sie anmelden.
### Die vier Wege
| Weg | Was Sie brauchen | Bleibt im Haus? |
|---|---|---|
| **E-Mail** | nichts, es gilt Ihre hinterlegte Adresse | ja |
| **ntfy** | die ntfy-App und ein Thema | ja, wenn der Server der Wehr gehört |
| **Telegram** | Ihre Chat-Kennung vom Bot | **nein** |
| **Browser-Push** | nur die Erlaubnis des Browsers | **nein** |
Neben den Wegen, die das Netz der Wehr verlassen, steht ein Warnhinweis. Dorthin
gehen nur **Kurzfassungen**: „3 Prüfungen fällig", nicht welche. Wer es genau
wissen will, bekommt es per E-Mail oder schaut in FLORIAN nach.
### ntfy einrichten
Der bequemste Weg, wenn die Wehr einen eigenen ntfy betreibt:
1. Die App „ntfy" aus dem Store laden.
2. In der App den Server eintragen, der auf dieser Seite steht.
3. In FLORIAN **ntfy** wählen; das vorgeschlagene Thema übernehmen.
4. In der App dasselbe Thema abonnieren.
> **Das Thema ist Ihr Schlüssel.** Wer den Namen kennt, liest mit. Deshalb
> schlägt FLORIAN einen zufälligen vor — nehmen Sie nicht „florian-mueller".
### Browser-Push
**Einrichten** drücken; der Browser fragt nach Erlaubnis. Das Abo gilt für
**diesen einen Browser auf diesem einen Gerät** — auf dem Diensthandy und am
Rechner im Büro also zweimal einrichten.
Auf dem iPhone funktioniert es erst, wenn FLORIAN über „Zum Home-Bildschirm"
abgelegt wurde. Vorher bietet der Browser es gar nicht an.
### Wofür?
Unter jedem Weg stehen die sechs Anlässe zum Ankreuzen:
| Anlass | Wann er kommt |
|---|---|
| Prüfung fällig | im morgendlichen Lauf |
| Mangel gemeldet | sofort |
| Qualifikation läuft ab | 90, 30 und 7 Tage vorher |
| Neuer Termin | beim Anlegen |
| Erinnerung an einen Termin | 7 Tage und 1 Tag vorher |
| Einsatzbericht freigegeben | bei der Freigabe |
### Der Probeknopf
**Drücken Sie ihn.** Er steht direkt an jedem Weg und schickt eine
Probenachricht durch dieselbe Maschinerie wie alles andere. Ohne ihn merken Sie
einen Tippfehler im Thema erst in drei Wochen — dann, wenn die Nachricht
wichtig gewesen wäre.
Sie geht mit dem nächsten Lauf raus, üblicherweise binnen einer Minute.
### Was ankam
Ganz unten steht, was FLORIAN Ihnen zuletzt geschickt hat und ob es angekommen
ist. Steht dort mehrfach **Fehlgeschlagen**, ist der Weg kaputt — der Grund
steht daneben.
---
## 41. Den Kalender aufs Handy holen
Auf derselben Seite unter **Kalender abonnieren**. Namen für das Gerät eingeben,
**Adresse erzeugen** — und die Adresse gleich übernehmen.
> **Sie wird genau einmal angezeigt.** FLORIAN speichert nur ihre Prüfsumme.
> Wer sie verliert, erzeugt eine neue und widerruft die alte.
Die Adresse dann eintragen:
| Gerät | Weg |
|---|---|
| **iPhone/iPad** | Einstellungen → Kalender → Accounts → Account hinzufügen → Andere → Kalenderabo hinzufügen |
| **Android** | in Google Kalender am Rechner: Andere Kalender → Per URL |
| **Thunderbird** | Neuer Kalender → Im Netzwerk → iCalendar (ICS) |
| **Outlook** | Kalender hinzufügen → Aus dem Internet abonnieren |
Danach stehen die Termine der Wehr neben allem anderen im Kalender. Neue
Termine und Absagen kommen von selbst nach; die meisten Programme fragen
stündlich nach.
**Nur eigene Termine** angekreuzt heißt: nur die, zu denen Sie sich
zurückgemeldet haben.
> **Die Adresse ist ein Schlüssel.** Wer sie hat, sieht die Termine der Wehr —
> ohne Anmeldung. Geben Sie sie nicht weiter; bei einem verlorenen Handy
> genügt ein Klick auf **Widerrufen**.
Ausgeliefert werden ausschließlich Termine — keine Teilnehmerlisten, keine
Mängel, keine Einsätze. Wer aus der Wehr austritt, verliert das Abo.
---
## 42. Textvorlagen und der Dienstausweis
### Textvorlagen
![Textvorlagen](screenshots/39-vorlagen-light.png)
Für Kommandant, Schriftführer und Administrator: unter **Benachrichtigungen →
Vorlagen bearbeiten** steht, was in den Nachrichten steht. Was hier geändert
wird, gilt für **alle**.
Links der Text, rechts die Vorschau mit erfundenen Beispielwerten. Die
**Platzhalter** darunter sind anklickbar und werden beim Versand ersetzt:
`{titel}` wird zu „Übungsabend", `{datum}` zum Datum.
Ein Platzhalter, den es für diesen Anlass nicht gibt, wird beim Speichern
abgewiesen — mit der Liste der erlaubten daneben. Und einer, der stehen bleibt,
bleibt auch in der fertigen Nachricht sichtbar: eine Lücke, die niemand
bemerkt, wäre schlimmer.
Ohne eigene Vorlage greift der eingebaute Text. **Auf Standard zurücksetzen**
stellt ihn wieder her.
> Bei Telegram und Browser-Push steht ein Warnhinweis über dem Feld. Was Sie
> dort hineinschreiben, läuft über einen fremden Server — also keine Namen,
> keine Adressen, keine Einsatzdetails.
### Der digitale Dienstausweis
![Dienstausweis](screenshots/40-dienstausweis-light.png)
Unter **Dienstausweis**: ein QR-Code mit Ihrem Namen, Ihrer Wehr, Ihrer
Funktion und Ihren gültigen Qualifikationen. Zum Vorzeigen an der Absperrung,
beim Lehrgang oder bei einer Kontrolle.
Wer ihn scannt, landet auf einer Prüfseite und sieht genau das — **ohne sich
anzumelden**. Denn wer prüft, hat keinen Zugang zu FLORIAN.
Drei Dinge sollten Sie darüber wissen:
- **Der Code gilt fünf Minuten.** Danach zeigt die Seite ihn selbst neu an. Ein
abfotografierter Code nützt nichts.
- **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein.
- **Es steht nicht mehr darauf als auf einem Ausweis aus Papier.** Keine
Anschrift, kein Geburtsdatum, keine Telefonnummer.
Die Prüfseite nennt außerdem, wann der Code erzeugt wurde. Das ist der Punkt,
auf den ein Prüfender schauen sollte: „vor zwei Minuten" ist ein Ausweis,
„gültig" allein wäre wertlos.
Wer aus der Wehr ausgetreten ist, bekommt dort **Kein aktives Mitglied** — auch
wenn sein Code technisch noch stimmt.
---
## 43. 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
@@ -1292,9 +1515,9 @@ kann sie also nachträglich zuschneiden, ohne dass jemand Code ändert.
| **Gerätewart** | Fahrzeuge und Geräte anlegen, ändern, aussondern; Prüfungen planen und durchführen |
| **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 |
| **Kassier** | Buchungen und Kassenabschluss (Modul noch nicht geplant, siehe Roadmap) |
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen |
| **Ausbilder** | Übungen planen, Teilnahme und Qualifikationen erfassen |
| **Mitglied** | eigene Daten, Listen lesen |
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen |
| **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen |
| **Mitglied** | eigene Daten, Listen lesen, Termine sehen und sich zurückmelden, eigene Benachrichtigungswege einrichten |
| **Terminal** | nur anmelden und erfassen — nie verwalten |
Zwei Dinge sind bewusst so gebaut:
@@ -1306,9 +1529,14 @@ Trennung nach Verantwortung, nicht nach Bequemlichkeit.
zusätzlich mit PIN oder Karte an und handelt dann unter seiner eigenen Person.
Das Terminal selbst ist nur die Tür.
**Seine Benachrichtigungswege richtet jeder selbst ein.** Das ist ein
Grundrecht jedes Mitglieds und keine Sache des Kommandanten — es gibt bewusst
keinen Weg, jemand anderem einen Zustellweg einzutragen. Was für alle gilt,
sind nur die Textvorlagen, und die hängen an einem eigenen Recht.
---
## 40. Anmeldung über authentik einrichten
## 44. Anmeldung über authentik einrichten
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
@@ -1361,7 +1589,7 @@ ganze Wehr aus.
---
## 41. PIN und Ausweiskarte verwalten
## 45. PIN und Ausweiskarte verwalten
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
was dahinter liegt.
@@ -1384,7 +1612,7 @@ Huber), PIN `471203`.
---
## 42. Was protokolliert wird
## 46. Was protokolliert wird
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
@@ -1409,7 +1637,7 @@ jeweiligen Detailseite sichtbar.
---
## 43. Wenn etwas nicht geht
## 47. Wenn etwas nicht geht
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|---|---|---|
@@ -1438,6 +1666,17 @@ jeweiligen Detailseite sichtbar.
| Nach dem Import steht überall „ungeprüft" | Die Quelle hat keine Statusspalte | Absicht: ohne Angabe wird nicht „einsatzbereit" behauptet. Zustand nachpflegen |
| Die Lagekarte gleicht nicht ab | Der Ereignisstrom ist unterbrochen | Steht oben in der Lagekarte. Bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen |
| Die Förderstrecke braucht unerwartet viele Pumpen | Fördermenge zu hoch für den Durchmesser | Der Verlust steigt im Quadrat: 800 statt 400 l/min kostet die vierfache Reibung. Größeren Schlauch wählen oder weniger fördern |
| Es kommt keine Benachrichtigung an | Kein Zustellweg eingerichtet, oder kein Anlass angekreuzt | Unter **Benachrichtigungen** einen Weg einrichten und ankreuzen, wofür. Ohne beides kommt absichtlich nichts |
| Die Probenachricht bleibt aus | Der Kanal ist auf dem Server nicht eingerichtet | In der Auswahl steht „(nicht eingerichtet)". `MAIL_ENABLED`, `NTFY_ENABLED`, `TELEGRAM_ENABLED` oder `WEBPUSH_ENABLED` setzen |
| Im Verlauf steht mehrfach „Fehlgeschlagen" | Der Weg ist kaputt — der Grund steht daneben | Bei ntfy meist ein Tippfehler im Thema, bei Telegram eine falsche Chat-Kennung |
| Browser-Push lässt sich nicht einrichten | Keine VAPID-Schlüssel auf dem Server | `npm run vapid` und die Ausgabe in die `.env` übernehmen |
| Push kam bisher an, jetzt nicht mehr | Der öffentliche VAPID-Schlüssel wurde gewechselt | Alle Abos sind damit wertlos. Jeder muss Push neu erlauben — deshalb gehören die Schlüssel ins Backup |
| Auf dem iPhone gibt es keinen Push-Knopf | Web-Push geht dort nur als abgelegte App | Über „Teilen → Zum Home-Bildschirm" ablegen, dann erneut versuchen |
| Der Kalender im Handy aktualisiert sich nicht | Das Programm fragt nur stündlich nach | Abwarten oder im Kalenderprogramm „Aktualisieren" auslösen |
| Der Kalender ist plötzlich leer | Der Zugang wurde widerrufen, oder die Person ist ausgetreten | Unter **Benachrichtigungen** eine neue Adresse erzeugen |
| Eine Vorlage lässt sich nicht speichern | Ein Platzhalter gehört nicht zu diesem Anlass | Die Meldung nennt ihn und listet die erlaubten auf |
| Ein Termin lässt sich nicht löschen | Es liegen schon Rückmeldungen vor | Absagen statt löschen — dann sehen die Angemeldeten den Grund |
| Die Zusage wurde zur Warteliste | Die Plätze sind belegt | Sagt jemand ab, rücken Sie von selbst nach und werden benachrichtigt |
Zustand aller Randsysteme auf einen Blick:
+22 -9
View File
@@ -1,7 +1,7 @@
# Aufwandsschätzung
Schätzung je Phase, daneben der tatsächliche Aufwand aus dem
[Devlog](DEVLOG.md). Stand: 3. September 2026.
[Devlog](DEVLOG.md). Stand: 4. September 2026.
## Wie hier gerechnet wird
@@ -34,10 +34,10 @@ Faktoren, die in die Schätzung eingehen:
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** |
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** |
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen |
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | offen |
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | **3:00 h** |
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | **2:40 h** |
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** |
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** |
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen |
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
| | **Summe** | **123–164 PT** | **28–37 h** | |
@@ -339,7 +339,7 @@ das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
## Phase 7 — Benachrichtigungen und Termine
**Vergleichsaufwand: 10–13 PT**
**Vergleichsaufwand: 10–13 PT** · **Tatsächlich: 3:10 h Sitzungszeit**
| Baustein | PT |
|---|---:|
@@ -347,11 +347,24 @@ das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
| Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 |
| Abonnements je Person | 1,0 |
| Kalender mit Serienterminen und Selbstanmeldung | 3,0 |
| CalDAV-/ICS-Feed je Person mit Token | 1,5 |
| ICS-Feed je Person mit Token | 1,5 |
| Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 |
Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0
(`lib/integrations/`), das spart hier etwa einen Personentag.
Das Grundgerüst der Kanalabstraktion stand bereits aus Phase 0
(`lib/integrations/`), das hat etwa einen Personentag gespart.
Die einzige Phase bisher, die über der geschätzten Sitzungszeit liegt — und
zwar an einer Stelle, die in der Aufstellung nicht vorkommt. „Kalender mit
Serienterminen" klingt nach einer Datumsrechnung; teuer war die Entscheidung
**gegen** eine Regel und **für** ausgeschriebene Termine, und dann die
Kalenderarithmetik dahinter: `setMonth(+1)` auf dem 31. Januar ergibt den
3. März, der 29. Februar existiert in vier von fünf Jahren nicht, und ein
Übungsabend muss über die ganze Serie auf demselben Wochentag bleiben. Das sind
drei Tests, die man erst schreibt, wenn man den Fall kennt.
Die 1,5 PT für den Feed waren dagegen zu hoch angesetzt, weil sie CalDAV
unterstellen. ICS ist ein Textformat; der ganze Erzeuger sind hundertsechzig
Zeilen samt Maskierung und Zeilenumbruch nach 75 Oktetten.
## Phase 8 — Lokale KI
+120 -1
View File
@@ -18,7 +18,126 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| 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 |
| **Summe** | **16:10 h** | **8** | |
| Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
| **Summe** | **19:20 h** | **9** | |
---
## 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
---
+42 -14
View File
@@ -16,8 +16,8 @@ Phase 3 ████████████████████ fertig
Phase 4 ████████████████████ fertig 0.5.0
Phase 5 ████████████████████ fertig 0.6.0
Phase 6 ████████████████████ fertig 0.7.0
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
Phase 7 ████████████████████ fertig 0.8.0
Phase 8 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.9.0
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
```
@@ -148,25 +148,53 @@ analogen, bis die Wehr ausdrücklich anders entscheidet.
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
und was ihn hergibt.
## Phase 7 — Benachrichtigungen und Termine · als Nächstes
## Phase 7 — Benachrichtigungen und Termine · fertig
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
- Kalender: Dienste, Übungen, Lehrgänge, Arbeitsdienste; Serientermine,
Selbstanmeldung mit Teilnehmerlimit, Anwesenheitsliste zum Ausdrucken
- CalDAV-/ICS-Feed je Person
- Digitaler Dienstausweis mit signiertem QR und Prüfseite
Die Wehr weiß, wann der nächste Dienst ist — ohne dass jemand eine Rundmail
tippt.
- Terminkalender mit Dienst, Übung, Lehrgang, Arbeitsdienst, Veranstaltung und
Sitzung. Serientermine werden ausgeschrieben, nicht als Regel gespeichert.
- Selbstanmeldung mit Platzbegrenzung und einer Warteliste, die von selbst
nachrückt. Absage mit Grund, wahlweise für alle folgenden Termine einer Serie.
- Anwesenheitsliste über alle aktiven Mitglieder — zum Abhaken am Tablet oder
zum Ausdrucken. Zusage und Anwesenheit sind getrennt.
- Vier Kanäle hinter Schnittstellen: E-Mail (echtes SMTP statt Platzhalter),
ntfy (selbst gehostet), Telegram, Web-Push. Was das Netz der Wehr verlässt,
bekommt nur die Kurzfassung.
- Vorlagen je Ereignis und Kanal mit Platzhaltern, Vorschau und eingebautem
Standardtext für alles.
- ICS-Abonnement je Person, Token nur als Hash gespeichert, einzeln widerrufbar.
- Digitaler Dienstausweis: signierter QR, fünf Minuten gültig, Prüfseite ohne
Anmeldung.
**Danach kann die Wehr:** ihren Jahresplan führen, Rückmeldungen einsammeln,
Anwesenheit nachweisen und jedes Mitglied auf dem Weg erreichen, den es selbst
gewählt hat.
### Kein CalDAV
CalDAV ist ein Schreib-Lese-Protokoll mit Rechteverwaltung und
Konfliktauflösung. Gebraucht wird hier nur Lesen — die Termine entstehen in
FLORIAN, nicht im Kalender des Anwenders. Dafür genügt eine Adresse, die eine
ICS-Datei ausliefert; jeder Kalender kann das. CalDAV wäre ein Vielfaches an
Aufwand für dieselbe Wirkung.
### Warum kein WhatsApp
Die WhatsApp Business API kostet je Konversation und verlangt einen
zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nummern aller
Mitglieder gingen an Meta, was sich mit einer Anwendung, die bewusst im
Gerätehaus steht, nicht verträgt. Telegram, ntfy und Web-Push decken denselben
Bedarf ab, ntfy sogar vollständig selbst gehostet.
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nachrichten liefen
über Server von Meta, die Wehr müsste einen Vertrag zur Auftragsverarbeitung
schließen, und die gewöhnliche App gleicht zusätzlich das Telefonbuch ab. Das
verträgt sich nicht mit einer Anwendung, die bewusst im Gerätehaus steht.
Telegram, ntfy und Web-Push decken denselben Bedarf ab, ntfy sogar vollständig
selbst gehostet.
## Phase 8 — Lokale KI
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts.
## Phase 8 — Lokale KI · als Nächstes
Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 227 KiB

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: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 792 KiB

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 KiB

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 KiB

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 KiB

After

Width:  |  Height:  |  Size: 904 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB