10 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 1da2068440 chore(release): 0.5.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:39:02 +02:00
Florian WildeandClaude Opus 5 01d7b10599 docs: DEVLOG-Eintrag fuer M5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:37:04 +02:00
Florian WildeandClaude Opus 5 350f5fdbfc docs: Fahrplan und Aenderungen fuer M5 fortschreiben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:37:02 +02:00
Florian WildeandClaude Opus 5 bb7b189e53 docs: Handbuchbilder fuer M5 neu aufnehmen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:34:44 +02:00
Florian WildeandClaude Opus 5 35ecb2fa78 chore: Screenshot-Instanz mit eingerichteten Meldewegen
Die Wegwerf-Instanz bekommt je Lauf ein frisch erzeugtes VAPID-Paar und
einen Mailhost, den es nie geben wird. Damit zeigen die Bilder den
eingerichteten Normalfall statt zweier Warnhinweise — und im Verzeichnis
bleibt kein privater Schluessel liegen.

Kein Push-Geraet mehr in den Demo-Daten: Seine Schluessel liegen
verschluesselt, und der Versand entfernt jedes Geraet, dessen Schluessel
er nicht lesen kann. Der erfundene Eintrag waere beim ersten Lauf wieder
verschwunden.

Dass der Browser im Bild "blockiert" meldet, laesst sich nicht abstellen:
Chromium im Kopflosbetrieb antwortet auf Notification.permission immer so.
Das Handbuch sagt es jetzt dazu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:34:40 +02:00
Florian WildeandClaude Opus 5 7bef4351ec fix: Serverzustand geht der Browsermeldung vor
Fehlen die Push-Schluessel auf dem Server, stand unter "Dieses Geraet"
trotzdem, der Browser habe blockiert — zwei Aussagen nebeneinander, von
denen nur eine zaehlt. Jetzt sagt die Karte zuerst das, was den Ausschlag
gibt.

Aufgefallen ist es erst im aufgenommenen Bild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:34:39 +02:00
Florian WildeandClaude Opus 5 6e1f2791cd test: Benachrichtigungen end-to-end und in der Datenbank absichern
Die Einstellung haelt ueber das Neuladen, ohne eingeschalteten Weg geht
nichts hinaus, und ein unsinniger Vorlauf wird auch dann abgewiesen, wenn
er an der Oberflaeche vorbei hereinkommt.

Der Anmeldehelfer liegt jetzt einmal in e2e/hilfe.ts und fasst nach: ein
Klick, der vor der Hydratation ankommt, verpufft folgenlos — das hat die
Testlaeufe unter Last wiederholt scheitern lassen.

Der Integrationstest prueft die drei neuen Tabellen samt Kaskade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:28:32 +02:00
Florian WildeandClaude Opus 5 f03ea73dcd docs: Handbuchseite und README-Abschnitt zu Benachrichtigungen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:00:23 +02:00
Florian WildeandClaude Opus 5 5bbbdd5209 chore: Demo-Daten und Bildaufnahme fuer Benachrichtigungen
Die Aufnahme kann jetzt einen einzelnen Ausschnitt statt der ganzen Seite
festhalten. Ein Bild der kompletten Einstellungsseite zeigt im Kapitel zu
den Benachrichtigungen zu wenig vom Eigentlichen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:00:20 +02:00
Florian WildeandClaude Opus 5 047a4a1c74 feat: Meldungen alle zehn Minuten pruefen und verschicken
Der Takt passt zu den Toleranzfenstern: ein Anlass in einem
Viertelstundenfenster wird sicher getroffen, dank Sperre aber nur einmal
gemeldet. Eine Erinnerung kommt damit hoechstens ein paar Minuten frueher
als gewollt, nie spaeter.

Der Kalenderabgleich zaehlt seine Fehlversuche jetzt mit und setzt den
Zaehler bei Erfolg zurueck.

Alte Nachweise raeumt der naechtliche Lauf weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:00:00 +02:00
70 changed files with 634 additions and 85 deletions
+34
View File
@@ -6,6 +6,40 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.5.0] – 2026-09-11
### Hinzugefügt
- **Benachrichtigungen** per Web-Push und E-Mail, je Benutzer einstellbar.
Vier Anlässe: Tagesüberblick am Morgen, fällige Aufgaben, Vorlauf vor einem
Zeitblock und ein Kalender, der sich nicht abgleichen lässt.
- **Ruhezeit** von 20:00 bis 07:00 ab Werk, über Mitternacht hinweg; wahlweise
das ganze Wochenende. Der Tagesüberblick kommt trotzdem zu seiner Zeit — er
käme sonst nie, wenn jemand früh aufsteht.
- Anmeldung eines Browsers für Push, Liste der angemeldeten Geräte und ein
Knopf, der eine Probemeldung über alle eingeschalteten Wege schickt.
- Ein Lauf alle zehn Minuten prüft, ob etwas zu melden ist; die nächtliche
Aufräumrunde entfernt Nachweise, die älter als 30 Tage sind.
- Handbuchseite zu Benachrichtigungen.
### Geändert
- Der Kalenderabgleich zählt Fehlversuche in Folge mit und meldet erst ab dem
dritten. Ein Netz hustet, ein Anbieter hat Wartung — das ist noch keine
Nachricht wert.
- Ab Werk meldet der Planer gar nichts: kein Weg ist eingeschaltet. Wer beim
ersten Start mit Meldungen überschüttet wird, schaltet alles ab — und dann
auch das Nützliche.
### Sicherheit
- Die Push-Schlüssel eines Geräts liegen verschlüsselt in der Datenbank.
Weder Endpunkt noch Schlüssel noch Mailinhalte landen im Protokoll.
- Ein Gerät, das der Push-Dienst mit 404 oder 410 ablehnt, wird sofort
entfernt; andere Fehler führen nach fünf Versuchen zum selben Ergebnis.
- Der Nachweis einer Meldung wird **vor** dem Senden geschrieben: lieber eine
verpasste Nachricht als eine, die im Minutentakt erneut versucht wird.
## [0.4.0] – 2026-09-11 ## [0.4.0] – 2026-09-11
### Hinzugefügt ### Hinzugefügt
+50 -15
View File
@@ -33,6 +33,8 @@ wird.
Bereich, wie voll der heutige Tag schon ist Bereich, wie voll der heutige Tag schon ist
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten - **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
Kalenderterminen, Verschiebungen im Kalender kommen zurück Kalenderterminen, Verschiebungen im Kalender kommen zurück
- **Benachrichtigungen** per Web-Push und E-Mail: Tagesüberblick, fällige
Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk still
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und - Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
Passwort — je Instanz einzeln zuschaltbar Passwort — je Instanz einzeln zuschaltbar
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird - Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
@@ -43,24 +45,25 @@ wird.
### Geplant ### Geplant
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein Integrationen, Schnellerfassung vom Handy und ein MCP-Server.
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). Siehe [ROADMAP.md](ROADMAP.md).
## Bildergalerie ## Bildergalerie
| | Hell | Dunkel | | | Hell | Dunkel |
| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) | | Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) |
| Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) | | Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) |
| Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) | | Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) |
| Woche | ![Woche, hell](docs/bilder/woche-hell-schreibtisch.png) | ![Woche, dunkel](docs/bilder/woche-dunkel-schreibtisch.png) | | Woche | ![Woche, hell](docs/bilder/woche-hell-schreibtisch.png) | ![Woche, dunkel](docs/bilder/woche-dunkel-schreibtisch.png) |
| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) | | Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) |
| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) | | Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) |
| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) | | Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) |
| Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-dunkel-schreibtisch.png) | | Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-dunkel-schreibtisch.png) |
| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) | | Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) |
| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) | | Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) |
| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) | | Meldungen | ![Benachrichtigungen, hell](docs/bilder/benachrichtigungen-hell-schreibtisch.png) | ![Benachrichtigungen, dunkel](docs/bilder/benachrichtigungen-dunkel-schreibtisch.png) |
| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) |
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten. ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
@@ -136,6 +139,38 @@ Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind — Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`). und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`).
### Benachrichtigungen
Beides ist freiwillig. Fehlt ein Satz, bleibt der Weg in den Einstellungen
ausgegraut, und der Planer läuft ohne ihn weiter.
| Variable | Standard | Bedeutung |
| ------------------- | -------- | --------------------------------------------------------- |
| `VAPID_OEFFENTLICH` | _(leer)_ | Öffentlicher Push-Schlüssel, geht an den Browser |
| `VAPID_PRIVAT` | _(leer)_ | Privater Push-Schlüssel, bleibt auf dem Server |
| `VAPID_KONTAKT` | _(leer)_ | Kontakt für den Push-Dienst, `mailto:` oder `https:` |
| `SMTP_HOST` | _(leer)_ | Mailserver. Ohne ihn kein Mailversand |
| `SMTP_PORT` | `587` | Port des Mailservers |
| `SMTP_BENUTZER` | _(leer)_ | Anmeldename. Leer lassen für einen Server ohne Anmeldung |
| `SMTP_PASSWORT` | _(leer)_ | Passwort dazu |
| `SMTP_ABSENDER` | _(leer)_ | Absenderadresse, z. B. `Planer <planer@example.de>` |
| `SMTP_TLS` | `false` | `true` bei Port 465 (SMTPS), `false` bei 587 mit STARTTLS |
Push braucht **alle drei** VAPID-Werte, Mail mindestens `SMTP_HOST` und
`SMTP_ABSENDER`. Die Schlüssel entstehen einmalig mit:
```bash
npx web-push generate-vapid-keys
```
Wird das Schlüsselpaar später getauscht, müssen sich alle Geräte neu
anmelden — die alten Abos sind dann wertlos. Push setzt außerdem HTTPS
voraus; `http://localhost` gilt dem Browser als sicher, jede andere
Klartextadresse nicht.
Was der Planer wann meldet, steht im
[Handbuch](docs/handbuch/benachrichtigungen.md).
### Einrichtung und Sonstiges ### Einrichtung und Sonstiges
| Variable | Standard | Bedeutung | | Variable | Standard | Bedeutung |
+16 -5
View File
@@ -9,7 +9,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt | | [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt | | [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt | | [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant | | [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
| [M6](#m6--integrationen) | Integrationen | geplant | | [M6](#m6--integrationen) | Integrationen | geplant |
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant | | [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
| [M8](#m8--mcp) | MCP | geplant | | [M8](#m8--mcp) | MCP | geplant |
@@ -94,10 +94,21 @@ ein Zeitblock landet immer im ersten verbundenen Kalender. Beides steht unter
## M5 – Benachrichtigungen ## M5 – Benachrichtigungen
- [ ] Web-Push mit `web-push` **Erledigt am 11.09.2026 mit Version 0.5.0.**
- [ ] Tagesüberblick per E-Mail über nodemailer
- [ ] Erinnerungen an Fälligkeiten, Jobs über node-cron **Ziel:** Sagen, was ansteht — und dabei so selten stören, dass niemand auf
- [ ] Je Benutzer einstellbar, mit Ruhezeiten die Idee kommt, alles abzuschalten.
- [x] Web-Push mit `web-push`, Anmeldung je Browser
- [x] Tagesüberblick per E-Mail über nodemailer
- [x] Erinnerungen an Fälligkeiten und vor Zeitblöcken, Jobs über node-cron
- [x] Je Benutzer einstellbar, mit Ruhezeiten über Mitternacht
- [x] Wiederholungssperre je Anlass, Sache und Tag
- [x] Abgleichfehler erst nach mehreren Versuchen hintereinander
**Bewusst offen gelassen:** Keine Sammelmeldung mehrerer Anlässe in einer
Nachricht und kein Nachholen dessen, was in die Ruhezeit fiel. Eine
Erinnerung an einen Zeitblock, der längst begonnen hat, hilft niemandem.
## M6 – Integrationen ## M6 – Integrationen
+101 -7
View File
@@ -5,13 +5,107 @@ die Übersicht aus `scripts/zeit.sh summe`.
## Zeit je Meilenstein ## Zeit je Meilenstein
| Meilenstein | Art | Minuten | Stunden | | Meilenstein | Art | Minuten | Stunden |
| ----------- | ------ | ------- | -------- | | ----------- | ------ | ------- | --------- |
| M1 | claude | 161 | 2,68 | | M1 | claude | 161 | 2,68 |
| M2 | claude | 44 | 0,73 | | M2 | claude | 44 | 0,73 |
| M3 | claude | 41 | 0,68 | | M3 | claude | 41 | 0,68 |
| M4 | claude | 167 | 2,78 | | M4 | claude | 167 | 2,78 |
| **Gesamt** | | **413** | **6,88** | | M5 | claude | 420 | 7,00 |
| **Gesamt** | | **833** | **13,88** |
---
## 2026-09-11 – M5: Benachrichtigungen
**Dauer:** 420 Minuten (06:35–13:35), Art `claude`.
**Ziel der Sitzung:** Sagen, was ansteht — und dabei so selten stören, dass
niemand auf die Idee kommt, alles abzuschalten.
### Erledigt
- Regeln als reine Logik: Anlässe, Ruhezeit, Wiederholungssperre (30 Tests)
- Web-Push über VAPID samt Service Worker, An- und Abmeldung je Browser
- Mailversand über nodemailer, als Text und als HTML
- Vier Anlässe: Tagesüberblick, Fälligkeit, Vorlauf vor einem Zeitblock,
Abgleichfehler
- Bedienung in den Einstellungen mit Probemeldung und Geräteliste
- Ein Lauf alle zehn Minuten, Nachweise räumt die Nachtrunde weg
- 2 neue E2E-Tests, 7 neue Integrationsprüfungen, Handbuchseite
### Entscheidungen mit Begründung
**Ab Werk meldet der Planer gar nichts.** Kein Weg ist eingeschaltet. Wer
beim ersten Start mit Meldungen überschüttet wird, schaltet alles ab — und
dann auch das Nützliche. Die Einrichtung ist ein bewusster Handgriff, und
das ist der Preis dafür, dass die Meldungen später ernst genommen werden.
**Der Tagesüberblick ist von der Ruhezeit ausgenommen.** Sonst bekäme ihn
nie, wer ihn auf 06:00 stellt — die voreingestellte Ruhezeit läuft bis
07:00. Die Ruhezeit schützt vor Störungen, nicht vor der Nachricht, um die
ausdrücklich gebeten wurde.
**Der Nachweis wird vor dem Senden geschrieben, nicht danach.** Bricht der
Versand mittendrin ab, gilt die Meldung als zugestellt und kommt nicht
wieder. Andersherum würde ein hängender Mailserver dieselbe Nachricht alle
zehn Minuten erneut versuchen. Lieber eine verpasste Nachricht als eine,
die im Minutentakt klopft.
**Was in die Ruhezeit fällt, wird nicht nachgeholt.** Eine Erinnerung an
einen Zeitblock, der längst begonnen hat, ist nur der Beweis, dass die
Anwendung zu spät kommt.
**Abgleichfehler erst ab dem dritten Versuch in Folge.** Ein Netz hustet,
ein Anbieter hat Wartung. Beim ersten Fehlschlag zu melden hieße, für
Dinge zu wecken, die sich von selbst erledigen. Nach dem dritten Mal ist es
dagegen dringend — solange steht die Wochenansicht auf altem Stand.
**Der Tagesüberblick entfällt, wenn es nichts zu sagen gibt.** „Heute steht
nichts an" ist keine Information, sondern Lärm.
**Tote Abos putzen sich selbst weg.** 404 und 410 heißen endgültig, das
Gerät verschwindet sofort; andere Fehler zählen hoch und führen nach fünf
Versuchen zum selben Ergebnis. Eine Liste, die von Hand gepflegt werden
muss, wird nicht gepflegt.
**Push-Schlüssel liegen verschlüsselt, Endpunkte nie im Protokoll.** Ein
Endpunkt genügt, um jemandem Nachrichten zu schicken — er gehört damit in
dieselbe Klasse wie ein Token.
### Probleme und Lösungen
| Problem | Lösung |
| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pushManager.subscribe` lehnte den VAPID-Schlüssel als `Uint8Array<ArrayBufferLike>` ab | Der Puffer wird jetzt ausdrücklich als `ArrayBuffer` angelegt; ein geteilter Puffer ist dort nicht erlaubt. |
| Der Integrationstest brach mit einem Fremdschlüsselfehler ab | Die neuen Prüfungen standen hinter dem Löschen des Testbenutzers. Jetzt davor — und danach wird geprüft, dass Geräte und Nachweise mit ihm verschwinden. |
| E2E-Läufe scheiterten unter Last immer wieder beim Anmelden | Ein Klick, der vor der Hydratation ankommt, verpufft folgenlos. Der Anmeldehelfer liegt jetzt einmal in `e2e/hilfe.ts` und fasst nach, bis die Startseite da ist. |
| Die Karte meldete gleichzeitig „Browser blockiert" und „Server nicht eingerichtet" | Zwei Aussagen, von denen nur eine zählt. Der Serverzustand geht jetzt vor. Gefunden nur durch das Ansehen des aufgenommenen Bildes. |
| Das erfundene Push-Gerät in den Demo-Daten wäre beim ersten Versandlauf gelöscht worden | Seine Schlüssel lagen als Klartext-Platzhalter, und unlesbare Schlüssel führen zum Entfernen. Die Demo-Daten enthalten jetzt kein Gerät mehr — die Bilder zeigen den Zustand davor. |
| Im Bild meldet der Browser „blockiert", obwohl die Erlaubnis erteilt wurde | Chromium im Kopflosbetrieb antwortet auf `Notification.permission` immer mit `denied`; `grantPermissions` ändert daran nichts. Das Handbuch schreibt es jetzt unter das Bild. |
| Ein Docker-Bau brauchte 14 Minuten, ein E2E-Lauf 18 | Systemlast, kein Codeproblem: dieselben Läufe danach in 1,5 beziehungsweise 0,4 Minuten. Ein vergessener Testserver auf Port 3100 hat kräftig mitgeholfen. |
### Offene Punkte
- Der Versandweg ist gegen die Logik geprüft, nicht gegen echte Dienste:
ohne VAPID-Schlüssel und SMTP-Zugang geht lokal nichts hinaus. Das braucht
einen Lauf auf dem Server.
- Mehrere Anlässe erscheinen als mehrere Nachrichten. Ob eine Sammelmeldung
fehlt, zeigt erst der Alltag.
### Commits
- 24f613d feat: Datenmodell und Umgebung für Benachrichtigungen
- ddb13d7 feat: Regeln für Benachrichtigungen als reine Logik
- e0e11c8 feat: Versand per Web-Push und E-Mail
- b622c1b feat: Benachrichtigungen in den Einstellungen bedienen
- 047a4a1 feat: Meldungen alle zehn Minuten prüfen und verschicken
- 5bbbdd5 chore: Demo-Daten und Bildaufnahme für Benachrichtigungen
- f03ea73 docs: Handbuchseite und README-Abschnitt zu Benachrichtigungen
- 6e1f279 test: Benachrichtigungen end-to-end und in der Datenbank absichern
- 7bef435 fix: Serverzustand geht der Browsermeldung vor
- 35ecb2f chore: Screenshot-Instanz mit eingerichteten Meldewegen
- bb7b189 docs: Handbuchbilder für M5 neu aufnehmen
--- ---
+1
View File
@@ -3,3 +3,4 @@ datum;start;ende;minuten;meilenstein;art;notiz
2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig 2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig
2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig 2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig 2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
1 datum start ende minuten meilenstein art notiz
3 2026-09-10 15:57 16:41 44 M2 claude M2 Aufgaben und Projekte fertig
4 2026-09-10 17:48 18:29 41 M3 claude M3 Kalender lesen fertig
5 2026-09-10 20:42 23:29 167 M4 claude M4 Wochenplanung fertig
6 2026-09-11 06:35 13:35 420 M5 claude M5 Benachrichtigungen fertig
+11
View File
@@ -57,5 +57,16 @@ services:
WEBAUTHN_RP_NAME: Planer WEBAUTHN_RP_NAME: Planer
WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200} WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
DEMO_MODUS: 'true' DEMO_MODUS: 'true'
# Damit die Karte "Benachrichtigungen" den Normalfall zeigt und nicht
# den Hinweis, dass nichts eingerichtet ist. Das Schluesselpaar erzeugt
# scripts/screenshots.ts fuer jeden Lauf neu – es steht bewusst nirgends
# im Verzeichnis.
VAPID_OEFFENTLICH: ${SCREENSHOT_VAPID_OEFFENTLICH:-}
VAPID_PRIVAT: ${SCREENSHOT_VAPID_PRIVAT:-}
VAPID_KONTAKT: mailto:planer@planer.invalid
# Ein Host, den es nie geben wird: die Oberflaeche zeigt E-Mail als
# moeglich, hinaus geht trotzdem nichts.
SMTP_HOST: smtp.planer.invalid
SMTP_ABSENDER: Planer <planer@planer.invalid>
ports: ports:
- '${SCREENSHOT_APP_PORT:-3200}:3000' - '${SCREENSHOT_APP_PORT:-3200}:3000'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

+3 -2
View File
@@ -20,10 +20,12 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
verbinden, Belegung verstehen verbinden, Belegung verstehen
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den 7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
Kalender legen Kalender legen
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push und E-Mail
einrichten, Ruhezeiten setzen
## Verwaltung ## Verwaltung
8. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat 9. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
## Was noch kommt ## Was noch kommt
@@ -31,7 +33,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
| Thema | Meilenstein | | Thema | Meilenstein |
| ------------------------------- | ----------- | | ------------------------------- | ----------- |
| Benachrichtigungen | M5 |
| Integrationen | M6 | | Integrationen | M6 |
| Schnellerfassung vom Handy, API | M7 | | Schnellerfassung vom Handy, API | M7 |
| MCP | M8 | | MCP | M8 |
+108
View File
@@ -0,0 +1,108 @@
# Benachrichtigungen
Ab Werk meldet der Planer **gar nichts**. Das ist Absicht: Eine Anwendung,
die zu oft meldet, wird stummgeschaltet — und dann verpasst du auch das
Wichtige. Was du hier einschaltest, kommt. Alles andere nicht.
![Benachrichtigungen in den Einstellungen](../bilder/benachrichtigungen-hell-schreibtisch.png)
_Der Browser auf dem Bild hat Benachrichtigungen blockiert — so sieht die
Karte aus, wenn die Erlaubnis fehlt. Wie du sie erteilst, steht weiter unten._
Du findest die Einstellung unter **Einstellungen → Benachrichtigungen**.
## Die zwei Wege
| | Web-Push | E-Mail |
| --------- | ---------------------------------------- | ---------------------------------------- |
| Kommt an | als Systemmeldung auf dem Gerät | im Postfach |
| Braucht | Erlaubnis des Browsers, je Gerät einzeln | nichts weiter |
| Gut für | „gleich beginnt ein Zeitblock" | den Tagesüberblick am Morgen |
| Fällt aus | wenn der Browser die Erlaubnis entzieht | wenn der Mailserver nicht erreichbar ist |
Beide Wege lassen sich gleichzeitig einschalten. Dann kommt jede Meldung
über beide — der Planer verschickt sie trotzdem nur **einmal je Anlass**.
Ist ein Weg auf dem Server nicht eingerichtet, steht das direkt daneben.
Was dafür nötig ist, steht im [README](../../README.md), Abschnitt
_Benachrichtigungen_.
## Push auf einem Gerät einschalten
1. Auf **Anmelden** klicken.
2. Der Browser fragt nach Erlaubnis — **Zulassen**.
3. Das Gerät erscheint in der Liste darunter.
Das gilt **je Gerät und je Browser**. Der Rechner im Büro und das Handy
sind zwei Anmeldungen; Firefox und Chrome auf demselben Rechner ebenfalls.
> **Der Knopf fehlt oder ist grau?** Push braucht HTTPS. Unter
> `http://localhost` geht es zum Entwickeln, unter jeder anderen
> Klartextadresse nicht. Hat der Browser Benachrichtigungen einmal
> blockiert, lässt sich das nur in seinen eigenen Einstellungen
> zurücknehmen — die Anwendung darf dann nicht mehr fragen.
Ein Gerät, das der Push-Dienst dauerhaft ablehnt, entfernt der Planer von
selbst aus der Liste. Du musst nichts aufräumen.
## Was gemeldet wird
| Anlass | Wann |
| ---------------------------------------- | ------------------------------------------------------------------------ |
| **Tagesüberblick** | einmal morgens zur eingestellten Zeit |
| **Fällige Aufgaben** | wenn eine Aufgabe an diesem Tag fällig wird, höchstens einmal je Aufgabe |
| **Gleich beginnt ein Zeitblock** | kurz vor dem Beginn, Vorlauf einstellbar |
| **Kalender lässt sich nicht abgleichen** | erst nach dem dritten Fehlversuch hintereinander |
Eingeschaltet sind anfangs nur Tagesüberblick und fällige Aufgaben — und
selbst die kommen erst, wenn du einen Weg (Push oder E-Mail) aktiviert hast.
Der **Tagesüberblick** entfällt an Tagen, an denen es nichts zu sagen gibt.
Eine Meldung „heute steht nichts an" ist keine Information, sondern Lärm.
Der **Abgleichfehler** kommt bewusst spät: Ein Netz hustet, ein Anbieter hat
Wartung. Erst wenn es dreimal hintereinander nicht klappt, ist es eine
Nachricht wert — dann allerdings dringend, denn solange bleibt deine
Wochenansicht auf einem alten Stand.
## Ruhezeit
Voreingestellt ist **20:00 bis 07:00**. In diesem Fenster kommt nichts.
- Die Ruhezeit läuft über Mitternacht hinweg — das ist der Normalfall.
- Gleiche Start- und Endzeit heißt: **keine** Ruhezeit.
- **Am Wochenende still** legt Samstag und Sonntag komplett still.
Eine Ausnahme: Der **Tagesüberblick** kommt zu seiner festen Zeit, auch wenn
die noch in der Ruhezeit liegt. Wer ihn auf 06:00 stellt, steht um 06:00 auf
und will ihn dann auch haben.
Was in der Ruhezeit anfällt, wird **nicht nachgeholt**. Eine Erinnerung an
einen Zeitblock, der längst begonnen hat, hilft niemandem.
## Ausprobieren
Der Knopf **Probe senden** schickt eine Testmeldung über alle
eingeschalteten Wege — er umgeht Ruhezeit und Wiederholungssperre. Wer
darauf drückt, will jetzt wissen, ob es ankommt.
Die Rückmeldung sagt, was passiert ist: an wie viele Geräte der Push ging,
ob die Mail hinausging. Kommt „kein Gerät für Push angemeldet", fehlt die
Anmeldung dieses Browsers.
## Wenn nichts ankommt
| Symptom | Ursache |
| ----------------------------------------- | ----------------------------------------------------------------- |
| Probe sagt „verschickt", nichts kommt an | Betriebssystem blendet Browsermeldungen aus (Fokus, Nicht stören) |
| Gerät verschwindet immer wieder | Der Push-Dienst lehnt das Abo ab — neu anmelden |
| Gar nichts, seit der Server umgezogen ist | Neues Schlüsselpaar auf dem Server. Alle Geräte neu anmelden |
| Mail fehlt, Push kommt | SMTP prüfen; Fehler stehen im Serverprotokoll |
Der Planer prüft alle zehn Minuten, ob etwas zu melden ist. Eine Erinnerung
kann also ein paar Minuten vor der eingestellten Zeit kommen — nie danach.
---
Weiter zur [Wochenplanung](woche.md) oder zurück zum
[Handbuch](README.md).
+4 -14
View File
@@ -1,6 +1,7 @@
import { PrismaPg } from '@prisma/adapter-pg' import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type Page } from '@playwright/test' import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js' import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
/** /**
* Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken. * Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken.
@@ -9,16 +10,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js'
* damit die Entwicklungsdatenbank hinterher aussieht wie vorher. * damit die Entwicklungsdatenbank hinterher aussieht wie vorher.
*/ */
const EMAIL = process.env.ADMIN_EMAIL ?? ''
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
const PRAEFIX = `E2E-${Date.now()}` const PRAEFIX = `E2E-${Date.now()}`
const verbindung = process.env.DATABASE_URL ?? '' const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
test.describe('Aufgaben', () => { test.describe('Aufgaben', () => {
test.skip( test.skip(
!EMAIL || !PASSWORT || !verbindung, !EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
) )
@@ -29,14 +27,6 @@ test.describe('Aufgaben', () => {
await db.$disconnect() await db.$disconnect()
}) })
async function anmelden(seite: Page): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
await seite.getByLabel('Passwort').fill(PASSWORT)
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
}
test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => { test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => {
const titel = `${PRAEFIX} Angebot schreiben` const titel = `${PRAEFIX} Angebot schreiben`
await anmelden(page) await anmelden(page)
+91
View File
@@ -0,0 +1,91 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
/**
* Was in M5 wirklich zählt: die Einstellung hält, und ohne eingeschalteten
* Weg geht nichts hinaus.
*
* Der Test rührt nur an der Einstellung des Testkontos und stellt sie am
* Ende wieder her.
*/
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
test.describe('Benachrichtigungen', () => {
test.skip(
!EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
)
test.afterAll(async () => {
const benutzer = await db.benutzer.findUnique({
where: { email: EMAIL },
select: { id: true },
})
if (benutzer) {
await db.benachrichtigungseinstellung.deleteMany({
where: { benutzerId: benutzer.id },
})
await db.meldungsnachweis.deleteMany({ where: { benutzerId: benutzer.id } })
}
await db.auditEintrag.deleteMany({
where: { aktion: { startsWith: 'benachrichtigung.' } },
})
await db.$disconnect()
})
test('speichert die Einstellung und hält sie', async ({ page }) => {
await anmelden(page)
await page.goto('/einstellungen')
const karte = page.locator('[data-bild="benachrichtigungen"]')
await expect(karte).toBeVisible()
// --- Ab Werk ist nichts eingeschaltet ---------------------------------
await expect(karte.locator('input[name="perPush"]')).not.toBeChecked()
await expect(karte.locator('input[name="perMail"]')).not.toBeChecked()
// --- Ohne Weg keine Probe ---------------------------------------------
await karte.getByRole('button', { name: 'Probe schicken' }).click()
await expect(page.getByText('Erst einen Weg einschalten')).toBeVisible()
// --- Ändern und speichern ---------------------------------------------
await karte.locator('input[name="perMail"]').check()
await karte.locator('input[value="zeitblock"]').check()
await karte.getByLabel('Tagesüberblick um').fill('06:30')
await karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)').fill('20')
await karte.getByLabel('Am Wochenende gar nichts melden').check()
await karte.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Einstellungen gespeichert.')).toBeVisible()
// --- Nach dem Neuladen steht alles noch da ----------------------------
await page.reload()
await expect(karte.locator('input[name="perMail"]')).toBeChecked()
await expect(karte.locator('input[value="zeitblock"]')).toBeChecked()
await expect(karte.getByLabel('Tagesüberblick um')).toHaveValue('06:30')
await expect(karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)')).toHaveValue(
'20',
)
await expect(karte.getByLabel('Am Wochenende gar nichts melden')).toBeChecked()
})
test('weist eine unsinnige Vorlaufzeit zurück', async ({ page }) => {
await anmelden(page)
await page.goto('/einstellungen')
const karte = page.locator('[data-bild="benachrichtigungen"]')
const vorlauf = karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)')
// Das Zahlenfeld begrenzt auf 120; der Server darf sich darauf nicht
// verlassen, deshalb kommt der Wert an der Oberfläche vorbei.
await vorlauf.evaluate((feld: HTMLInputElement) => {
feld.max = '600'
feld.value = '300'
})
await karte.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Bitte die markierten Felder prüfen.')).toBeVisible()
await expect(karte.getByText('Mehr als zwei Stunden Vorlauf')).toBeVisible()
})
})
+35
View File
@@ -0,0 +1,35 @@
import { expect, type Page } from '@playwright/test'
/** Gemeinsames für alle E2E-Tests. */
export const EMAIL = process.env.ADMIN_EMAIL ?? ''
export const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
export const VERBINDUNG = process.env.DATABASE_URL ?? ''
/**
* Meldet das Testkonto mit Passwort an.
*
* Der Knopf gehört zu einem Client-Formular. Ein Klick, der vor der
* Hydratation ankommt, verpufft folgenlos — die Seite bleibt einfach stehen.
* Deshalb wird so lange nachgefasst, bis die Startseite da ist; hat der erste
* Klick schon gewirkt, wird nicht noch einmal geklickt.
*/
export async function anmelden(
seite: Page,
email: string = EMAIL,
passwort: string = PASSWORT,
): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(email)
await seite.getByLabel('Passwort').fill(passwort)
const knopf = seite.getByRole('button', { name: 'Mit Passwort anmelden' })
const begruessung = seite.getByRole('heading', { name: /Guten Tag/ })
await expect(async () => {
if (new URL(seite.url()).pathname === '/anmelden') {
await knopf.click({ timeout: 5_000 })
}
await expect(begruessung).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 45_000 })
}
+5 -12
View File
@@ -1,7 +1,8 @@
import { PrismaPg } from '@prisma/adapter-pg' import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type Page } from '@playwright/test' import { expect, test } from '@playwright/test'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { PrismaClient } from '../src/generated/prisma/client.js' import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden as anmeldenMit } from './hilfe'
/** /**
* Prüft, dass die Rollentrennung serverseitig hält. * Prüft, dass die Rollentrennung serverseitig hält.
@@ -38,23 +39,15 @@ test.describe('Rollentrennung', () => {
await db.$disconnect() await db.$disconnect()
}) })
async function anmelden(seite: Page): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
await seite.getByLabel('Passwort').fill(PASSWORT)
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
}
test('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => { test('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => {
await anmelden(page) await anmeldenMit(page, EMAIL, PASSWORT)
await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0) await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
}) })
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({ test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
page, page,
}) => { }) => {
await anmelden(page) await anmeldenMit(page, EMAIL, PASSWORT)
await page.goto('/verwaltung/protokoll') await page.goto('/verwaltung/protokoll')
await expect(page).toHaveURL(/\/keine-berechtigung/) await expect(page).toHaveURL(/\/keine-berechtigung/)
@@ -64,7 +57,7 @@ test.describe('Rollentrennung', () => {
}) })
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => { test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
await anmelden(page) await anmeldenMit(page, EMAIL, PASSWORT)
await page.goto('/bereiche') await page.goto('/bereiche')
await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible()
+4 -14
View File
@@ -1,6 +1,7 @@
import { PrismaPg } from '@prisma/adapter-pg' import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type Page } from '@playwright/test' import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js' import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
/** /**
* Wochenansicht und Zeitblöcke. * Wochenansicht und Zeitblöcke.
@@ -11,16 +12,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js'
* Kalender nicht erreichbar ist. * Kalender nicht erreichbar ist.
*/ */
const EMAIL = process.env.ADMIN_EMAIL ?? ''
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
const PRAEFIX = `E2E-W-${Date.now()}` const PRAEFIX = `E2E-W-${Date.now()}`
const verbindung = process.env.DATABASE_URL ?? '' const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
test.describe('Woche', () => { test.describe('Woche', () => {
test.skip( test.skip(
!EMAIL || !PASSWORT || !verbindung, !EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
) )
@@ -34,14 +32,6 @@ test.describe('Woche', () => {
await db.$disconnect() await db.$disconnect()
}) })
async function anmelden(seite: Page): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
await seite.getByLabel('Passwort').fill(PASSWORT)
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
}
test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => { test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => {
await db.kalenderkonto.deleteMany({}) await db.kalenderkonto.deleteMany({})
await anmelden(page) await anmelden(page)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "planer", "name": "planer",
"version": "0.4.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "planer", "name": "planer",
"version": "0.4.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "2.11.3", "@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0", "@date-fns/tz": "1.5.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "planer", "name": "planer",
"version": "0.4.0", "version": "0.5.0",
"private": true, "private": true,
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender", "description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
"type": "module", "type": "module",
+24
View File
@@ -418,6 +418,30 @@ async function main(): Promise<void> {
}, },
}) })
// --- Benachrichtigungen ---------------------------------------------------
// Für die Bilder eingeschaltet. Eine leere Karte zeigt nichts von dem,
// was das Kapitel erklärt.
await db.benachrichtigungseinstellung.upsert({
where: { benutzerId: benutzer.id },
update: {},
create: {
benutzerId: benutzer.id,
anlaesse: 'tagesueberblick,faelligkeit,zeitblock',
perPush: true,
perMail: true,
ueberblickUm: 7 * 60,
ruheVon: 20 * 60,
ruheBis: 7 * 60,
ruheAmWochenende: false,
zeitblockVorlaufMinuten: 10,
},
})
// Kein Push-Gerät in den Demo-Daten: Seine Schlüssel liegen
// verschlüsselt, und der Versand entfernt jedes Gerät, dessen Schlüssel er
// nicht lesen kann. Ein erfundener Eintrag verschwände beim ersten Lauf
// wieder — die Bilder zeigen deshalb den Zustand vor der ersten Anmeldung.
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`) console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
} }
+18 -4
View File
@@ -24,6 +24,13 @@ type Aufnahme = {
pfad: string pfad: string
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */ /** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
vorbereiten?: (seite: Page) => Promise<void> vorbereiten?: (seite: Page) => Promise<void>
/**
* Nur diesen Ausschnitt aufnehmen statt der ganzen Seite.
*
* Für Themen, die auf einer gemeinsamen Seite wohnen: ein Bild der ganzen
* Einstellungsseite zeigt im Handbuchkapitel zu wenig vom Eigentlichen.
*/
ausschnitt?: string
} }
const AUFNAHMEN: Aufnahme[] = [ const AUFNAHMEN: Aufnahme[] = [
@@ -37,6 +44,11 @@ const AUFNAHMEN: Aufnahme[] = [
{ name: 'bereiche', pfad: '/bereiche' }, { name: 'bereiche', pfad: '/bereiche' },
{ name: 'kalenderkonten', pfad: '/kalenderkonten' }, { name: 'kalenderkonten', pfad: '/kalenderkonten' },
{ name: 'einstellungen', pfad: '/einstellungen' }, { name: 'einstellungen', pfad: '/einstellungen' },
{
name: 'benachrichtigungen',
pfad: '/einstellungen',
ausschnitt: '[data-bild="benachrichtigungen"]',
},
{ name: 'protokoll', pfad: '/verwaltung/protokoll' }, { name: 'protokoll', pfad: '/verwaltung/protokoll' },
] ]
@@ -82,10 +94,12 @@ for (const schema of ['hell', 'dunkel'] as const) {
// Der Farbschema-Umschalter und Schriften sollen fertig sein. // Der Farbschema-Umschalter und Schriften sollen fertig sein.
await seite.waitForTimeout(300) await seite.waitForTimeout(300)
await seite.screenshot({ const ziel = `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`
path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`, if (aufnahme.ausschnitt) {
fullPage: true, await seite.locator(aufnahme.ausschnitt).screenshot({ path: ziel })
}) } else {
await seite.screenshot({ path: ziel, fullPage: true })
}
} }
await kontext.close() await kontext.close()
+67
View File
@@ -56,6 +56,8 @@ async function aufraeumen(): Promise<void> {
await db.benutzer.deleteMany({ where: { email: testEmail } }) await db.benutzer.deleteMany({ where: { email: testEmail } })
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
await db.webauthnChallenge.deleteMany({ where: { kennung } }) await db.webauthnChallenge.deleteMany({ where: { kennung } })
await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } })
await db.meldungsnachweis.deleteMany({ where: { schluessel: { contains: kennung } } })
} }
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -259,6 +261,61 @@ async function main(): Promise<void> {
}) })
pruefe('Audit-Eintrag anlegen', audit.id.length > 0) pruefe('Audit-Eintrag anlegen', audit.id.length > 0)
// --- Benachrichtigungen --------------------------------------------------
await db.benachrichtigungseinstellung.create({
data: { benutzerId: benutzer.id },
})
await scheitert('Je Benutzer nur eine Benachrichtigungseinstellung', () =>
db.benachrichtigungseinstellung.create({ data: { benutzerId: benutzer.id } }),
)
const voreinstellung = await db.benachrichtigungseinstellung.findUniqueOrThrow({
where: { benutzerId: benutzer.id },
})
pruefe(
'Ab Werk ist kein Weg eingeschaltet',
voreinstellung.perPush === false && voreinstellung.perMail === false,
)
await db.pushGeraet.create({
data: {
benutzerId: benutzer.id,
endpunkt: `https://push.invalid/${kennung}`,
p256dh: 'x',
auth: 'y',
},
})
await scheitert('Derselbe Push-Endpunkt geht nur einmal', () =>
db.pushGeraet.create({
data: {
benutzerId: benutzer.id,
endpunkt: `https://push.invalid/${kennung}`,
p256dh: 'x',
auth: 'y',
},
}),
)
await db.meldungsnachweis.create({
data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` },
})
await scheitert('Derselbe Meldungsschluessel geht nur einmal', () =>
db.meldungsnachweis.create({
data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` },
}),
)
pruefe(
'Derselbe Schlüssel am nächsten Tag ist erlaubt',
(
await db.meldungsnachweis.create({
data: {
benutzerId: benutzer.id,
schluessel: `faelligkeit:${kennung}:2026-03-18`,
},
})
).id.length > 0,
)
await db.benutzer.delete({ where: { id: benutzer.id } }) await db.benutzer.delete({ where: { id: benutzer.id } })
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } }) const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
@@ -280,6 +337,16 @@ async function main(): Promise<void> {
'Kalenderkonten verschwinden mit dem Benutzer', 'Kalenderkonten verschwinden mit dem Benutzer',
(await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0, (await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0,
) )
pruefe(
'Push-Geräte verschwinden mit dem Benutzer',
(await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0,
)
pruefe(
'Meldungsnachweise verschwinden mit dem Benutzer',
(await db.meldungsnachweis.count({
where: { schluessel: { contains: kennung } },
})) === 0,
)
pruefe( pruefe(
'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine', 'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine',
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0, (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,
+7
View File
@@ -14,6 +14,7 @@
*/ */
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import webpush from 'web-push'
const WURZEL = fileURLToPath(new URL('..', import.meta.url)) const WURZEL = fileURLToPath(new URL('..', import.meta.url))
const PROJEKT = 'planer-screenshots' const PROJEKT = 'planer-screenshots'
@@ -22,10 +23,16 @@ const COMPOSE_DATEI = 'docker-compose.screenshots.yml'
const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200' const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455' const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455'
// Frisches Paar je Lauf: Die Bilder sollen die eingerichtete Anwendung
// zeigen, ohne dass irgendwo ein privater Schluessel liegen bleibt.
const VAPID = webpush.generateVAPIDKeys()
const UMGEBUNG = { const UMGEBUNG = {
...process.env, ...process.env,
SCREENSHOT_APP_PORT: APP_PORT, SCREENSHOT_APP_PORT: APP_PORT,
SCREENSHOT_DB_PORT: DB_PORT, SCREENSHOT_DB_PORT: DB_PORT,
SCREENSHOT_VAPID_OEFFENTLICH: VAPID.publicKey,
SCREENSHOT_VAPID_PRIVAT: VAPID.privateKey,
} }
function meldung(text: string): void { function meldung(text: string): void {
@@ -206,13 +206,22 @@ export function Benachrichtigungen({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium">Dieses Gerät</p> <p className="text-sm font-medium">Dieses Gerät</p>
<p className="text-gedaempft-vordergrund text-xs"> <p className="text-gedaempft-vordergrund text-xs">
{pushZustand === 'nicht-unterstuetzt' && {/*
'Dieser Browser kann keine Push-Nachrichten empfangen.'} Fehlen die Schlüssel auf dem Server, geht hier ohnehin nichts –
{pushZustand === 'verweigert' && dann über den Browser zu reden, schickt nur auf die falsche
'Der Browser hat Benachrichtigungen blockiert. Das lässt sich nur in seinen Einstellungen ändern.'} Fährte.
{pushZustand === 'an' && 'Empfängt Push-Nachrichten.'} */}
{pushZustand === 'aus' && 'Empfängt keine Push-Nachrichten.'} {!pushMoeglich
{pushZustand === 'unbekannt' && '…'} ? 'Ohne Push-Schlüssel auf dem Server empfängt kein Gerät etwas.'
: pushZustand === 'nicht-unterstuetzt'
? 'Dieser Browser kann keine Push-Nachrichten empfangen.'
: pushZustand === 'verweigert'
? 'Der Browser hat Benachrichtigungen blockiert. Das lässt sich nur in seinen Einstellungen ändern.'
: pushZustand === 'an'
? 'Empfängt Push-Nachrichten.'
: pushZustand === 'aus'
? 'Empfängt keine Push-Nachrichten.'
: '…'}
</p> </p>
</div> </div>
+30 -1
View File
@@ -3,6 +3,10 @@ import cron from 'node-cron'
import { db } from '@/server/db' import { db } from '@/server/db'
import { abgleichFenster, kontoAbgleichen } from '@/server/kalender/abgleich' import { abgleichFenster, kontoAbgleichen } from '@/server/kalender/abgleich'
import { abgelaufeneChallengesEntfernen } from '@/server/auth/passkey' import { abgelaufeneChallengesEntfernen } from '@/server/auth/passkey'
import {
alteNachweiseEntfernen,
benachrichtigungenVerschicken,
} from '@/server/benachrichtigung/versand'
import { jetzt } from '@/server/zeitgeber' import { jetzt } from '@/server/zeitgeber'
/** /**
@@ -19,6 +23,14 @@ const globalerBereich = globalThis as unknown as { planerJobs?: boolean }
const ABGLEICH_PLAN = '*/15 * * * *' const ABGLEICH_PLAN = '*/15 * * * *'
/** Einmal nachts aufräumen. */ /** Einmal nachts aufräumen. */
const AUFRAEUM_PLAN = '17 3 * * *' const AUFRAEUM_PLAN = '17 3 * * *'
/**
* Alle zehn Minuten nachsehen, ob etwas zu melden ist.
*
* Der Takt passt zu den Toleranzfenstern in src/lib/benachrichtigung.ts: Ein
* Anlass, der in einem 15-Minuten-Fenster liegt, wird sicher getroffen, aber
* dank Wiederholungssperre nur einmal gemeldet.
*/
const MELDUNGS_PLAN = '*/10 * * * *'
async function alleKontenAbgleichen(): Promise<void> { async function alleKontenAbgleichen(): Promise<void> {
const zeitpunkt = jetzt() const zeitpunkt = jetzt()
@@ -62,8 +74,25 @@ export function jobsStarten(): void {
}) })
}) })
cron.schedule(MELDUNGS_PLAN, () => {
void benachrichtigungenVerschicken()
.then((bericht) => {
// Nur Zahlen, nie Empfänger oder Inhalte.
if (bericht.gesendet > 0) {
console.warn(`Benachrichtigungen: ${bericht.gesendet} verschickt.`)
}
})
.catch((fehler: unknown) => {
console.error('Benachrichtigungen abgebrochen:', fehler)
})
})
cron.schedule(AUFRAEUM_PLAN, () => { cron.schedule(AUFRAEUM_PLAN, () => {
void Promise.all([alteTermineEntfernen(), abgelaufeneChallengesEntfernen(jetzt())]) void Promise.all([
alteTermineEntfernen(),
abgelaufeneChallengesEntfernen(jetzt()),
alteNachweiseEntfernen(jetzt()),
])
.then(() => undefined) .then(() => undefined)
.catch((fehler: unknown) => { .catch((fehler: unknown) => {
console.error('Aufräumen abgebrochen:', fehler) console.error('Aufräumen abgebrochen:', fehler)
+6 -1
View File
@@ -339,6 +339,7 @@ export async function kontoAbgleichen(
...(neuerVerweis ? { deltaVerweis: neuerVerweis } : {}), ...(neuerVerweis ? { deltaVerweis: neuerVerweis } : {}),
letzterAbgleich: zeitpunkt, letzterAbgleich: zeitpunkt,
letzterFehler: null, letzterFehler: null,
fehlversuche: 0,
}, },
}) })
@@ -355,7 +356,11 @@ export async function kontoAbgleichen(
await db.kalenderkonto.update({ await db.kalenderkonto.update({
where: { id: konto.id }, where: { id: konto.id },
data: { letzterAbgleich: zeitpunkt, letzterFehler: meldung }, data: {
letzterAbgleich: zeitpunkt,
letzterFehler: meldung,
fehlversuche: { increment: 1 },
},
}) })
return { return {