Compare commits
@@ -74,6 +74,29 @@ GRAPH_TENANT_ID=organizations
|
|||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Benachrichtigungen ----------------------------------------------------
|
||||||
|
# Beides ist freiwillig. Fehlt es, laeuft der Planer ohne Benachrichtigungen
|
||||||
|
# weiter und sagt es in den Einstellungen.
|
||||||
|
|
||||||
|
# Web-Push. Schluesselpaar einmalig erzeugen mit:
|
||||||
|
# npx web-push generate-vapid-keys
|
||||||
|
# Der oeffentliche Schluessel geht an den Browser, der private bleibt hier.
|
||||||
|
# Wird das Paar getauscht, muessen sich alle Geraete neu anmelden.
|
||||||
|
VAPID_OEFFENTLICH=
|
||||||
|
VAPID_PRIVAT=
|
||||||
|
# Kontakt fuer den Push-Dienst, als mailto: oder https:-Adresse.
|
||||||
|
VAPID_KONTAKT=
|
||||||
|
|
||||||
|
# E-Mail. Push braucht SMTP nicht und umgekehrt.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_BENUTZER=
|
||||||
|
SMTP_PASSWORT=
|
||||||
|
# Absender, z. B. "Planer <planer@example.de>".
|
||||||
|
SMTP_ABSENDER=
|
||||||
|
# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS.
|
||||||
|
SMTP_TLS=false
|
||||||
|
|
||||||
# --- Sonstiges -------------------------------------------------------------
|
# --- Sonstiges -------------------------------------------------------------
|
||||||
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ test-results
|
|||||||
playwright-report
|
playwright-report
|
||||||
coverage
|
coverage
|
||||||
devlog/zeiten.csv
|
devlog/zeiten.csv
|
||||||
|
public/sw.js
|
||||||
|
|||||||
@@ -6,6 +6,70 @@ 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
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Wochenansicht** mit Ziehen und Ablegen: Aufgaben aus der linken Spalte in
|
||||||
|
ein Zeitfenster ziehen legt einen Zeitblock an und schreibt ihn in den
|
||||||
|
Kalender. Die Dauer kommt aus dem geschätzten Aufwand der Aufgabe.
|
||||||
|
- Zeitblöcke verschieben und ausplanen; der Kalendertermin zieht mit.
|
||||||
|
- **Rückweg**: Wird ein Zeitblock im Kalender verschoben, übernimmt der Planer
|
||||||
|
das beim nächsten Abgleich. Wird er dort gelöscht, verschwindet auch der
|
||||||
|
Zeitblock — die Aufgabe bleibt und fällt zurück auf offen.
|
||||||
|
- Kapazität und Belegung je Wochentag über jeder Spalte; überbuchte Tage
|
||||||
|
werden rot, sperren aber nichts.
|
||||||
|
- Eine eingeplante Aufgabe wechselt selbsttätig von _Offen_ auf _Geplant_ und
|
||||||
|
wieder zurück, wenn nichts mehr für sie geplant ist.
|
||||||
|
- Handbuchseite zur Wochenplanung.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- **Schleifenschutz** über `hash = sha256(startISO|endeISO)`: Der Planer
|
||||||
|
schreibt nur bei eigener Änderung und übernimmt aus dem Kalender nur, wenn
|
||||||
|
der Fingerabdruck abweicht. Ohne das würden App und Kalender einander
|
||||||
|
endlos hinterherschreiben.
|
||||||
|
- Jeder Schreibzugriff auf einen bestehenden Termin geht mit `If-Match`
|
||||||
|
heraus. Bei HTTP 412 **gewinnt der Kalender**: der Planer schreibt nicht,
|
||||||
|
verwirft seinen Fingerabdruck und überlässt dem nächsten Abgleich, den
|
||||||
|
fremden Stand zu holen.
|
||||||
|
- Zeitblöcke gehören dem Benutzer über sein Kalenderkonto; die Kennung geht
|
||||||
|
in jede where-Bedingung, nicht in einen nachträglichen Vergleich.
|
||||||
|
|
||||||
## [0.3.0] – 2026-09-10
|
## [0.3.0] – 2026-09-10
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
|
|||||||
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
||||||
wird.
|
wird.
|
||||||
|
|
||||||
> **Stand:** Meilenstein 3 von 8. Aufgaben, Projekte und das Lesen der
|
> **Stand:** Meilenstein 4 von 8. Die Kernidee steht: Aufgaben lassen sich
|
||||||
> Kalender stehen: der Planer weiß jetzt, wann überhaupt Platz ist. Das
|
> per Ziehen und Ablegen als Zeitblöcke in den Kalender schreiben, und
|
||||||
> Schreiben der Zeitblöcke per Ziehen und Ablegen kommt mit M4. Der
|
> Verschiebungen von dort kommen zurück. Es folgen Benachrichtigungen,
|
||||||
|
> Integrationen, Schnellerfassung vom Handy und ein MCP-Server. Der
|
||||||
> [Fahrplan](ROADMAP.md) sagt, was wann folgt.
|
> [Fahrplan](ROADMAP.md) sagt, was wann folgt.
|
||||||
|
|
||||||
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
||||||
@@ -30,6 +31,10 @@ wird.
|
|||||||
- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt
|
- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt
|
||||||
- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je
|
- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je
|
||||||
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
|
||||||
|
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
|
||||||
@@ -40,23 +45,24 @@ wird.
|
|||||||
|
|
||||||
### Geplant
|
### Geplant
|
||||||
|
|
||||||
Zeitblöcke in den Kalender schreiben, Wochenplanung per Ziehen und Ablegen,
|
Integrationen, Schnellerfassung vom Handy und ein MCP-Server.
|
||||||
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
|
Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
|
||||||
|
|
||||||
## Bildergalerie
|
## Bildergalerie
|
||||||
|
|
||||||
| | Hell | Dunkel |
|
| | Hell | Dunkel |
|
||||||
| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
| ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||||
| Anmelden |  |  |
|
| Anmelden |  |  |
|
||||||
| Start |  |  |
|
| Start |  |  |
|
||||||
| Inbox |  |  |
|
| Inbox |  |  |
|
||||||
|
| Woche |  |  |
|
||||||
| Aufgaben |  |  |
|
| Aufgaben |  |  |
|
||||||
| Projekte |  |  |
|
| Projekte |  |  |
|
||||||
| Ideen |  |  |
|
| Ideen |  |  |
|
||||||
| Kalender |  |  |
|
| Kalender |  |  |
|
||||||
| Bereiche |  |  |
|
| Bereiche |  |  |
|
||||||
| Einstellungen |  |  |
|
| Einstellungen |  |  |
|
||||||
|
| Meldungen |  |  |
|
||||||
| Protokoll |  |  |
|
| Protokoll |  |  |
|
||||||
|
|
||||||
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
||||||
@@ -133,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 |
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
|||||||
| [M1](#m1--fundament) | Fundament | erledigt |
|
| [M1](#m1--fundament) | Fundament | erledigt |
|
||||||
| [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 | geplant |
|
| [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 |
|
||||||
@@ -76,22 +76,39 @@ später" und wartet darauf, ob es im Alltag wirklich fehlt.
|
|||||||
|
|
||||||
## M4 – Wochenplanung und Zeitblöcke
|
## M4 – Wochenplanung und Zeitblöcke
|
||||||
|
|
||||||
|
**Erledigt am 11.09.2026 mit Version 0.4.0.**
|
||||||
|
|
||||||
**Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und
|
**Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und
|
||||||
Verschiebungen von dort zurückholen.
|
Verschiebungen von dort zurückholen.
|
||||||
|
|
||||||
- [ ] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
|
- [x] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
|
||||||
- [ ] Wochenansicht mit `@dnd-kit/core`
|
- [x] Wochenansicht mit `@dnd-kit/core`
|
||||||
- [ ] Schreiben in den Kalender, Rückweg beim Verschieben
|
- [x] Schreiben in den Kalender, Rückweg beim Verschieben
|
||||||
- [ ] Schleifenschutz über `hash = sha256(startISO|endeISO)`
|
- [x] Schleifenschutz über `hash = sha256(startISO|endeISO)`
|
||||||
- [ ] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
|
- [x] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
|
||||||
- [ ] Belegung gegen die Tageskapazität des Bereichs
|
- [x] Belegung gegen die Tageskapazität des Bereichs
|
||||||
|
|
||||||
|
**Bewusst offen gelassen:** Die Dauer lässt sich nicht am Balken ziehen, und
|
||||||
|
ein Zeitblock landet immer im ersten verbundenen Kalender. Beides steht unter
|
||||||
|
"Ideen für später" — erst zeigt der Alltag, ob es wirklich fehlt.
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,182 @@ 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 |
|
||||||
| **Gesamt** | | **246** | **4,10** |
|
| M4 | claude | 167 | 2,78 |
|
||||||
|
| 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-10 – M4: Wochenplanung und Zeitblöcke
|
||||||
|
|
||||||
|
**Dauer:** 167 Minuten (20:42–23:29), Art `claude`.
|
||||||
|
|
||||||
|
**Ziel der Sitzung:** Die Kernidee des Projekts — Aufgaben per Ziehen und
|
||||||
|
Ablegen in echte Kalendertermine verwandeln, mit Rückweg.
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- `Zeitblock` samt Migration und Schleifenschutz (30 Tests)
|
||||||
|
- Schreibweg zu Graph und Google mit `If-Match`, Rückweg im Abgleich
|
||||||
|
- Rasterlogik für die Wochenansicht (21 Tests)
|
||||||
|
- Wochenansicht mit `@dnd-kit/core`: einplanen, verschieben, ausplanen
|
||||||
|
- Kapazität und Belegung je Wochentag
|
||||||
|
- 3 neue E2E-Tests, Handbuchseite
|
||||||
|
|
||||||
|
### Entscheidungen mit Begründung
|
||||||
|
|
||||||
|
**Der Fingerabdruck geht nur über Beginn und Ende.** Titel und Beschreibung
|
||||||
|
ändern sich auch aus harmlosen Gründen — etwa weil der Aufgabentitel
|
||||||
|
bearbeitet wurde. Für die Planung zählt allein, wann der Block liegt; alles
|
||||||
|
andere in den Fingerabdruck zu nehmen hieße, den Anbieter grundlos zu
|
||||||
|
behelligen.
|
||||||
|
|
||||||
|
**Der Kalender gewinnt, ausnahmslos.** Wer im Kalender etwas verschiebt,
|
||||||
|
meint es so. Säße der Planer am längeren Hebel, zöge er dem Benutzer den
|
||||||
|
Termin wieder weg — und der wüsste nicht, warum. Bei HTTP 412 verwirft der
|
||||||
|
Planer sogar seinen eigenen Fingerabdruck, damit der nächste Abgleich die
|
||||||
|
fremde Änderung sicher als solche erkennt.
|
||||||
|
|
||||||
|
**Scheitert das Schreiben, überlebt die Planung.** Der Zeitblock bleibt mit
|
||||||
|
vermerktem Fehler stehen. Ihn zu verwerfen hieße, die Planung des Benutzers
|
||||||
|
wegen eines Netzproblems zu vergessen; das nächste Verschieben trägt ihn
|
||||||
|
nach. Beim Löschen ist es umgekehrt: Da verschwindet der Zeitblock auch bei
|
||||||
|
unerreichbarem Kalender, sonst wäre die Planung blockiert.
|
||||||
|
|
||||||
|
**Die Dauer kommt aus dem geschätzten Aufwand.** Wer schätzt, bekommt die
|
||||||
|
passende Blockgröße geschenkt. Das belohnt genau die Gewohnheit, die die
|
||||||
|
Planung überhaupt erst tragfähig macht.
|
||||||
|
|
||||||
|
**Ziehen beginnt erst nach acht Pixeln.** Ohne diese Schwelle löst jeder
|
||||||
|
Klick einen Zug aus und die Knöpfe im Block wären nicht mehr bedienbar.
|
||||||
|
|
||||||
|
**Überbuchte Tage werden rot, aber nicht gesperrt.** Der Planer ist ein
|
||||||
|
Werkzeug, kein Aufpasser. Manchmal muss ein Tag überbucht sein.
|
||||||
|
|
||||||
|
### Probleme und Lösungen
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Ein Zeitblock steht auch im Terminspiegel und erschien doppelt — einmal als eigene Planung, einmal als fremder Termin | Die Wochenabfrage filtert Termine heraus, deren `extId` zu einem Zeitblock gehört. |
|
||||||
|
| Ein Build lief 32 Minuten statt Sekunden | Systemlast durch einen gleichzeitigen Docker-Bau. Ein sauberer Neubau danach: 46 Sekunden. Kein Codeproblem. |
|
||||||
|
| Sich überschneidende Termine lagen unsichtbar übereinander | `spurenVerteilen` legt sie nebeneinander und gibt eine Spur wieder frei, sobald sie zu Ende ist. |
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
- **Der Schreibweg ist nicht gegen die echten APIs geprüft.** Wie in M3
|
||||||
|
fehlen die Zugangsdaten. Die Logik ist getestet, der erste echte Schreib-
|
||||||
|
und Rückweg ist Florians.
|
||||||
|
- Die Dauer lässt sich nicht am Balken ziehen; wer eine andere will, ändert
|
||||||
|
den Aufwand der Aufgabe.
|
||||||
|
- Ein Zeitblock landet immer im ersten verbundenen Kalender. Das
|
||||||
|
Standard-Kalenderkonto je Bereich ist im Datenmodell vorbereitet, aber
|
||||||
|
noch nicht in der Oberfläche.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
- 5791047 feat(zeitbloecke): Datenmodell und Schleifenschutz
|
||||||
|
- 9c1c916 feat(zeitbloecke): Schreibweg in den Kalender und Rückweg zurück
|
||||||
|
- bcb4b8e feat(woche): Rasterlogik für die Wochenansicht
|
||||||
|
- fada445 feat(woche): Wochenansicht mit Ziehen und Ablegen
|
||||||
|
- 5b1e998 test: Wochenansicht und Ausplanen absichern
|
||||||
|
- 0974695 docs: Handbuchseite zur Wochenplanung
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ datum;start;ende;minuten;meilenstein;art;notiz
|
|||||||
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
|
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
|
||||||
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-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
|
||||||
|
|||||||
|
@@ -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'
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 301 KiB |
@@ -18,10 +18,14 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
|||||||
Fortschritt
|
Fortschritt
|
||||||
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
|
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
|
||||||
verbinden, Belegung verstehen
|
verbinden, Belegung verstehen
|
||||||
|
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
||||||
|
Kalender legen
|
||||||
|
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push und E-Mail
|
||||||
|
einrichten, Ruhezeiten setzen
|
||||||
|
|
||||||
## Verwaltung
|
## Verwaltung
|
||||||
|
|
||||||
7. **[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
|
||||||
|
|
||||||
@@ -29,8 +33,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
|
|||||||
|
|
||||||
| Thema | Meilenstein |
|
| Thema | Meilenstein |
|
||||||
| ------------------------------- | ----------- |
|
| ------------------------------- | ----------- |
|
||||||
| Wochenplanung und Zeitblöcke | M4 |
|
|
||||||
| Benachrichtigungen | M5 |
|
|
||||||
| Integrationen | M6 |
|
| Integrationen | M6 |
|
||||||
| Schnellerfassung vom Handy, API | M7 |
|
| Schnellerfassung vom Handy, API | M7 |
|
||||||
| MCP | M8 |
|
| MCP | M8 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
_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).
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Wochenplanung und Zeitblöcke
|
||||||
|
|
||||||
|
Hier wird aus einer Aufgabe ein Termin. Du ziehst sie in ein Zeitfenster, und
|
||||||
|
der Planer legt sie als **Zeitblock** in deinen echten Kalender.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Der Aufbau
|
||||||
|
|
||||||
|
| Wo | Was |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Links** | Alles, was noch eingeplant werden kann — offen, geplant oder wartend. Ideen bleiben draußen |
|
||||||
|
| **Rechts** | Sieben Tage im Viertelstundenraster |
|
||||||
|
| **Über jeder Spalte** | Belegte Zeit gegen die Kapazität dieses Tages |
|
||||||
|
| **Graue Balken** | Termine aus deinem Kalender. Sie gehören dem Kalender, nicht dem Planer — hier lassen sie sich nicht anfassen |
|
||||||
|
| **Farbige Balken** | Deine Zeitblöcke, in der Farbe ihres Bereichs |
|
||||||
|
|
||||||
|
Das sichtbare Stundenfenster ergibt sich aus den Arbeitsfenstern **aller**
|
||||||
|
Bereiche, mit einer Stunde Luft nach oben und unten.
|
||||||
|
|
||||||
|
## Eine Aufgabe einplanen
|
||||||
|
|
||||||
|
Aufgabe links am Griff packen und in ein Zeitfenster ziehen. Fertig — der
|
||||||
|
Zeitblock steht sofort im Raster und wenige Augenblicke später in deinem
|
||||||
|
Kalender.
|
||||||
|
|
||||||
|
> **Die Dauer kommt aus dem geschätzten Aufwand.** Wer bei der Aufgabe
|
||||||
|
> „90 Minuten" einträgt, bekommt einen 90-Minuten-Block. Ohne Schätzung sind
|
||||||
|
> es 60 Minuten, die du danach anpassen kannst.
|
||||||
|
|
||||||
|
Eine eingeplante Aufgabe wechselt automatisch von _Offen_ auf _Geplant_.
|
||||||
|
|
||||||
|
## Verschieben
|
||||||
|
|
||||||
|
Am Griff im Block packen und woandershin ziehen. Der Planer zieht den
|
||||||
|
Kalendertermin nach.
|
||||||
|
|
||||||
|
## Ausplanen
|
||||||
|
|
||||||
|
Das Kreuz im Block. Der Termin verschwindet aus dem Kalender; die Aufgabe
|
||||||
|
bleibt und fällt zurück auf _Offen_, wenn nichts anderes mehr für sie geplant
|
||||||
|
ist.
|
||||||
|
|
||||||
|
## Wenn du im Kalender verschiebst
|
||||||
|
|
||||||
|
Das darfst du — und der Planer zieht nach. Beim nächsten Abgleich (spätestens
|
||||||
|
nach 15 Minuten) steht der Block dort, wo du ihn im Kalender hingelegt hast.
|
||||||
|
|
||||||
|
> **Der Kalender gewinnt.** Immer. Wer dort etwas verschiebt, meint es so;
|
||||||
|
> säße der Planer am längeren Hebel, würde er dir den Termin wieder wegziehen
|
||||||
|
> und du wüsstest nicht, warum.
|
||||||
|
>
|
||||||
|
> Löschst du den Termin im Kalender, verschwindet auch der Zeitblock. Die
|
||||||
|
> Aufgabe selbst bleibt.
|
||||||
|
|
||||||
|
### Warum sich das nicht aufschaukelt
|
||||||
|
|
||||||
|
Der Planer merkt sich zu jedem Zeitblock einen Fingerabdruck des Zeitraums,
|
||||||
|
den er zuletzt geschrieben hat. Findet er beim Abgleich genau diesen
|
||||||
|
Fingerabdruck wieder, war es seine eigene Änderung — er tut nichts. Weicht er
|
||||||
|
ab, hat jemand anderes verschoben.
|
||||||
|
|
||||||
|
Ohne diesen Kniff würden App und Kalender einander endlos hinterherschreiben.
|
||||||
|
|
||||||
|
## Das Warnzeichen im Block
|
||||||
|
|
||||||
|
Ein kleines Dreieck heißt: **noch nicht im Kalender**. Meist war der Anbieter
|
||||||
|
gerade nicht erreichbar.
|
||||||
|
|
||||||
|
Der Zeitblock bleibt trotzdem stehen — deine Planung soll nicht verlorengehen,
|
||||||
|
bloß weil ein Server hustet. Verschiebe den Block einmal, dann trägt der
|
||||||
|
Planer ihn nach.
|
||||||
|
|
||||||
|
## Die Zahl über der Spalte
|
||||||
|
|
||||||
|
`3 h / 6 h` heißt: sechs Stunden Kapazität an diesem Tag, drei davon schon
|
||||||
|
belegt. Gezählt werden Kalendertermine **und** deine Zeitblöcke.
|
||||||
|
|
||||||
|
Wird die Zahl rot, ist der Tag überbucht. Das ist keine Sperre — der Planer
|
||||||
|
lässt dich weiterplanen. Es ist ein Hinweis, dass der Tag nicht aufgeht.
|
||||||
|
|
||||||
|
Ganztägige Termine zählen nicht mit. Ein Urlaubstag im Kalender blockiert
|
||||||
|
sonst rechnerisch den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
|
||||||
|
|
||||||
|
## Blättern
|
||||||
|
|
||||||
|
**Zurück**, **Diese Woche** und **Weiter**. Die Woche steht in der Adresse —
|
||||||
|
eine bestimmte Woche lässt sich also als Lesezeichen ablegen.
|
||||||
|
|
||||||
|
## Wenn es klemmt
|
||||||
|
|
||||||
|
| Was du siehst | Was dahintersteckt |
|
||||||
|
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| „Ohne verbundenen Kalender lässt sich nichts einplanen." | Erst unter [Kalender](kalenderkonten.md) ein Konto verbinden |
|
||||||
|
| „Eingeplant, aber nicht in den Kalender geschrieben" | Der Anbieter war nicht erreichbar. Der Block steht, verschiebe ihn einmal zum Nachtragen |
|
||||||
|
| „Der Termin wurde inzwischen im Kalender geändert." | Jemand war schneller. Der Kalender gewinnt; der nächste Abgleich holt den dortigen Stand |
|
||||||
|
| „Zeitblöcke rasten in 15-Minuten-Schritten ein." | Das Raster ist Absicht. Genauer zu planen bringt nichts |
|
||||||
|
| Ein Block ist nicht zu sehen | Er liegt außerhalb des sichtbaren Stundenfensters. Das Fenster folgt den Arbeitsfenstern der Bereiche — siehe [Bereiche](bereiche.md) |
|
||||||
|
|
||||||
|
## Was noch nicht geht
|
||||||
|
|
||||||
|
- **Ein Zeitblock landet immer im ersten verbundenen Kalender.** Die Auswahl
|
||||||
|
je Bereich ist im Datenmodell vorbereitet, aber noch nicht in der
|
||||||
|
Oberfläche.
|
||||||
|
- **Die Dauer lässt sich nicht am Balken ziehen.** Wer eine andere Dauer will,
|
||||||
|
ändert den Aufwand der Aufgabe und plant neu ein.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wochenansicht und Zeitblöcke.
|
||||||
|
*
|
||||||
|
* Ohne echtes Kalenderkonto lässt sich der Schreibweg zum Anbieter nicht
|
||||||
|
* prüfen — er scheitert dann und der Zeitblock trägt einen Fehler. Genau das
|
||||||
|
* ist aber selbst prüfenswert: Die Planung muss auch dann überleben, wenn der
|
||||||
|
* Kalender nicht erreichbar ist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PRAEFIX = `E2E-W-${Date.now()}`
|
||||||
|
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
|
||||||
|
|
||||||
|
test.describe('Woche', () => {
|
||||||
|
test.skip(
|
||||||
|
!EMAIL || !PASSWORT || !VERBINDUNG,
|
||||||
|
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
|
||||||
|
)
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await db.zeitblock.deleteMany({
|
||||||
|
where: { aufgabe: { titel: { startsWith: PRAEFIX } } },
|
||||||
|
})
|
||||||
|
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
|
||||||
|
await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: PRAEFIX } } })
|
||||||
|
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'zeitblock.' } } })
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => {
|
||||||
|
await db.kalenderkonto.deleteMany({})
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/woche')
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Woche' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Noch kein Kalender verbunden')).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByText('Ohne verbundenen Kalender lässt sich nichts'),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeigt einen Zeitblock im Raster und plant ihn wieder aus', async ({ page }) => {
|
||||||
|
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
|
||||||
|
|
||||||
|
// Ein Kalenderkonto ohne gültige Tokens: die Ansicht funktioniert, der
|
||||||
|
// Schreibweg zum Anbieter nicht.
|
||||||
|
const konto = await db.kalenderkonto.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
anbieter: 'GRAPH',
|
||||||
|
anzeigename: `${PRAEFIX} Testkalender`,
|
||||||
|
extKontoId: `${PRAEFIX}-konto`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const aufgabe = await db.aufgabe.create({
|
||||||
|
data: { titel: `${PRAEFIX} Angebot schreiben`, aufwandMinuten: 60 },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zeitblock auf 10:00 Uhr des Montags dieser Woche legen.
|
||||||
|
const jetzt = new Date()
|
||||||
|
const montag = new Date(jetzt)
|
||||||
|
montag.setUTCHours(0, 0, 0, 0)
|
||||||
|
montag.setUTCDate(montag.getUTCDate() - ((montag.getUTCDay() + 6) % 7))
|
||||||
|
const beginn = new Date(montag.getTime() + 10 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
await db.zeitblock.create({
|
||||||
|
data: {
|
||||||
|
aufgabeId: aufgabe.id,
|
||||||
|
kalenderkontoId: konto.id,
|
||||||
|
beginn,
|
||||||
|
ende: new Date(beginn.getTime() + 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.aufgabe.update({
|
||||||
|
where: { id: aufgabe.id },
|
||||||
|
data: { status: 'GEPLANT' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/woche')
|
||||||
|
|
||||||
|
const block = page.getByTitle(new RegExp(`${PRAEFIX} Angebot schreiben`))
|
||||||
|
await expect(block).toBeVisible()
|
||||||
|
|
||||||
|
// Ausplanen: der Block verschwindet, die Aufgabe fällt zurück auf offen.
|
||||||
|
await block.getByRole('button', { name: 'Ausplanen' }).click()
|
||||||
|
await expect(page.getByText('ausgeplant')).toBeVisible()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => db.zeitblock.count({ where: { aufgabeId: aufgabe.id } }))
|
||||||
|
.toBe(0)
|
||||||
|
|
||||||
|
const danach = await db.aufgabe.findUniqueOrThrow({ where: { id: aufgabe.id } })
|
||||||
|
expect(danach.status).toBe('OFFEN')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lässt sich durch die Wochen blättern', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/woche')
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Weiter' }).click()
|
||||||
|
await expect(page).toHaveURL(/versatz=1/)
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Zurück' }).click()
|
||||||
|
await expect(page).toHaveURL(/versatz=0/)
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Diese Woche' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/woche$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,6 +16,8 @@ export default tseslint.config(
|
|||||||
// Vom Generator erzeugt, nicht von Hand gepflegt.
|
// Vom Generator erzeugt, nicht von Hand gepflegt.
|
||||||
'src/generated/**',
|
'src/generated/**',
|
||||||
'next-env.d.ts',
|
'next-env.d.ts',
|
||||||
|
// Service Worker: laeuft im Browser, nicht in unserer Werkzeugkette.
|
||||||
|
'public/sw.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...naechstesWebVitals,
|
...naechstesWebVitals,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.3.0",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.3.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",
|
||||||
|
"@dnd-kit/core": "6.3.1",
|
||||||
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@prisma/adapter-pg": "7.10.0",
|
"@prisma/adapter-pg": "7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"@radix-ui/react-avatar": "1.2.6",
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
|
"web-push": "3.6.7",
|
||||||
"zod": "4.6.1"
|
"zod": "4.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
"@types/pg": "8.23.1",
|
"@types/pg": "8.23.1",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
|
"@types/web-push": "3.6.4",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"dotenv": "17.4.2",
|
"dotenv": "17.4.2",
|
||||||
@@ -593,6 +597,45 @@
|
|||||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@electric-sql/pglite": {
|
"node_modules/@electric-sql/pglite": {
|
||||||
"version": "0.4.3",
|
"version": "0.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
|
||||||
@@ -4504,6 +4547,16 @@
|
|||||||
"@types/react": "^19.3.0"
|
"@types/react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.70.0",
|
"version": "8.70.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
|
||||||
@@ -5405,6 +5458,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -5637,6 +5699,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/asn1js": {
|
"node_modules/asn1js": {
|
||||||
"version": "3.0.10",
|
"version": "3.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||||
@@ -5791,6 +5865,12 @@
|
|||||||
"require-from-string": "^2.0.2"
|
"require-from-string": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||||
|
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
@@ -5851,6 +5931,12 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/c12": {
|
"node_modules/c12": {
|
||||||
"version": "3.3.4",
|
"version": "3.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
||||||
@@ -6372,7 +6458,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -6556,6 +6641,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.20.0",
|
"version": "3.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
||||||
@@ -8044,6 +8138,28 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
@@ -8098,6 +8214,12 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -8763,6 +8885,27 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -9239,6 +9382,12 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -9259,7 +9408,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -9269,7 +9417,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
@@ -10690,6 +10837,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -10752,7 +10919,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/saxes": {
|
"node_modules/saxes": {
|
||||||
@@ -12357,6 +12523,25 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.3.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",
|
||||||
@@ -40,6 +40,8 @@
|
|||||||
"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",
|
||||||
|
"@dnd-kit/core": "6.3.1",
|
||||||
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@prisma/adapter-pg": "7.10.0",
|
"@prisma/adapter-pg": "7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"@radix-ui/react-avatar": "1.2.6",
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
@@ -66,6 +68,7 @@
|
|||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
|
"web-push": "3.6.7",
|
||||||
"zod": "4.6.1"
|
"zod": "4.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -77,6 +80,7 @@
|
|||||||
"@types/pg": "8.23.1",
|
"@types/pg": "8.23.1",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
|
"@types/web-push": "3.6.4",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"dotenv": "17.4.2",
|
"dotenv": "17.4.2",
|
||||||
|
|||||||
@@ -373,6 +373,37 @@ async function main(): Promise<void> {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Zeitblöcke: eingeplante Aufgaben im Kalender -------------------------
|
||||||
|
await db.zeitblock.deleteMany({})
|
||||||
|
const einzuplanen = await db.aufgabe.findMany({
|
||||||
|
where: {
|
||||||
|
titel: {
|
||||||
|
in: [
|
||||||
|
'Wochenbericht an die Geschäftsführung',
|
||||||
|
'Angebot Heizungstausch nachfassen',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, titel: true, aufwandMinuten: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let blockStunde = 15
|
||||||
|
for (const aufgabe of einzuplanen) {
|
||||||
|
const beginn = new Date(Date.UTC(2026, 2, 17, blockStunde - 1, 0))
|
||||||
|
await db.zeitblock.create({
|
||||||
|
data: {
|
||||||
|
aufgabeId: aufgabe.id,
|
||||||
|
kalenderkontoId: kalenderkonto.id,
|
||||||
|
beginn,
|
||||||
|
ende: new Date(beginn.getTime() + (aufgabe.aufwandMinuten ?? 60) * 60_000),
|
||||||
|
extId: `demo-block-${aufgabe.id}`,
|
||||||
|
hash: 'demo',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
|
||||||
|
blockStunde += 2
|
||||||
|
}
|
||||||
|
|
||||||
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
|
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
|
||||||
await db.aufgabe.create({
|
await db.aufgabe.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -387,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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "zeitblock" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"aufgabeId" TEXT NOT NULL,
|
||||||
|
"kalenderkontoId" TEXT NOT NULL,
|
||||||
|
"beginn" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ende" TIMESTAMP(3) NOT NULL,
|
||||||
|
"extId" TEXT,
|
||||||
|
"etag" TEXT,
|
||||||
|
"hash" TEXT,
|
||||||
|
"letzterFehler" TEXT,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "zeitblock_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "zeitblock_beginn_ende_idx" ON "zeitblock"("beginn", "ende");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "zeitblock_aufgabeId_idx" ON "zeitblock"("aufgabeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "zeitblock_kalenderkontoId_extId_key" ON "zeitblock"("kalenderkontoId", "extId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_aufgabeId_fkey" FOREIGN KEY ("aufgabeId") REFERENCES "aufgabe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "benachrichtigungseinstellung" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"anlaesse" TEXT NOT NULL DEFAULT 'tagesueberblick,faelligkeit',
|
||||||
|
"perPush" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"perMail" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"ueberblickUm" INTEGER NOT NULL DEFAULT 420,
|
||||||
|
"ruheVon" INTEGER NOT NULL DEFAULT 1200,
|
||||||
|
"ruheBis" INTEGER NOT NULL DEFAULT 420,
|
||||||
|
"ruheAmWochenende" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"zeitblockVorlaufMinuten" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "benachrichtigungseinstellung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "push_geraet" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"endpunkt" TEXT NOT NULL,
|
||||||
|
"p256dh" TEXT NOT NULL,
|
||||||
|
"auth" TEXT NOT NULL,
|
||||||
|
"bezeichnung" TEXT,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"zuletztBenutzt" TIMESTAMP(3),
|
||||||
|
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "push_geraet_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "meldungsnachweis" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"schluessel" TEXT NOT NULL,
|
||||||
|
"gesendetAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "meldungsnachweis_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "benachrichtigungseinstellung_benutzerId_key" ON "benachrichtigungseinstellung"("benutzerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "push_geraet_endpunkt_key" ON "push_geraet"("endpunkt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "push_geraet_benutzerId_idx" ON "push_geraet"("benutzerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "meldungsnachweis_gesendetAm_idx" ON "meldungsnachweis"("gesendetAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "meldungsnachweis_benutzerId_schluessel_key" ON "meldungsnachweis"("benutzerId", "schluessel");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "benachrichtigungseinstellung" ADD CONSTRAINT "benachrichtigungseinstellung_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "push_geraet" ADD CONSTRAINT "push_geraet_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "meldungsnachweis" ADD CONSTRAINT "meldungsnachweis_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "kalenderkonto" ADD COLUMN "fehlversuche" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -35,6 +35,9 @@ model Benutzer {
|
|||||||
passkeys Passkey[]
|
passkeys Passkey[]
|
||||||
audit AuditEintrag[]
|
audit AuditEintrag[]
|
||||||
kalenderkonten Kalenderkonto[]
|
kalenderkonten Kalenderkonto[]
|
||||||
|
benachrichtigung Benachrichtigungseinstellung?
|
||||||
|
pushGeraete PushGeraet[]
|
||||||
|
meldungen Meldungsnachweis[]
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@map("benutzer")
|
@@map("benutzer")
|
||||||
@@ -224,6 +227,7 @@ model Aufgabe {
|
|||||||
|
|
||||||
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
|
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
|
||||||
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
|
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
|
||||||
|
zeitbloecke Zeitblock[]
|
||||||
|
|
||||||
/// Integrationen upserten immer ueber diese beiden Felder. Von Hand
|
/// Integrationen upserten immer ueber diese beiden Felder. Von Hand
|
||||||
/// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte
|
/// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte
|
||||||
@@ -299,6 +303,9 @@ model Kalenderkonto {
|
|||||||
letzterAbgleich DateTime?
|
letzterAbgleich DateTime?
|
||||||
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
|
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
|
||||||
letzterFehler String?
|
letzterFehler String?
|
||||||
|
/// Wie oft der Abgleich hintereinander scheiterte. Ein Aussetzer ist keine
|
||||||
|
/// Nachricht wert; gemeldet wird erst ab mehreren.
|
||||||
|
fehlversuche Int @default(0)
|
||||||
|
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
|
|
||||||
@@ -308,6 +315,7 @@ model Kalenderkonto {
|
|||||||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
termine Termin[]
|
termine Termin[]
|
||||||
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
|
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
|
||||||
|
zeitblocks Zeitblock[]
|
||||||
|
|
||||||
/// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer.
|
/// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer.
|
||||||
@@unique([benutzerId, anbieter, extKontoId, kalenderId])
|
@@unique([benutzerId, anbieter, extKontoId, kalenderId])
|
||||||
@@ -340,3 +348,103 @@ model Termin {
|
|||||||
@@index([beginn, ende])
|
@@index([beginn, ende])
|
||||||
@@map("termin")
|
@@map("termin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eine Aufgabe, die zu einer bestimmten Zeit in einem Kalender steht.
|
||||||
|
///
|
||||||
|
/// Der Zeitblock lebt an zwei Orten: hier und im Kalender des Anbieters.
|
||||||
|
/// Beide Seiten duerfen ihn verschieben; der Fingerabdruck "hash" verhindert,
|
||||||
|
/// dass sich beide gegenseitig hochschaukeln. Siehe src/lib/zeitblock.ts.
|
||||||
|
model Zeitblock {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
aufgabeId String
|
||||||
|
kalenderkontoId String
|
||||||
|
|
||||||
|
beginn DateTime
|
||||||
|
ende DateTime
|
||||||
|
|
||||||
|
/// Kennung des Termins beim Anbieter; null, solange noch nicht geschrieben.
|
||||||
|
extId String?
|
||||||
|
/// Fuer bedingte Schreibzugriffe (If-Match).
|
||||||
|
etag String?
|
||||||
|
/// sha256(startISO|endeISO) dessen, was zuletzt geschrieben wurde.
|
||||||
|
hash String?
|
||||||
|
|
||||||
|
/// Klartextmeldung des letzten Fehlschlags beim Schreiben, ohne Tokens.
|
||||||
|
letzterFehler String?
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
aufgabe Aufgabe @relation(fields: [aufgabeId], references: [id], onDelete: Cascade)
|
||||||
|
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Ein Termin im Kalender gehoert zu hoechstens einem Zeitblock.
|
||||||
|
@@unique([kalenderkontoId, extId])
|
||||||
|
@@index([beginn, ende])
|
||||||
|
@@index([aufgabeId])
|
||||||
|
@@map("zeitblock")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie und worueber ein Benutzer benachrichtigt werden will.
|
||||||
|
model Benachrichtigungseinstellung {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String @unique
|
||||||
|
|
||||||
|
/// Kommagetrennte Anlaesse, siehe src/lib/benachrichtigung.ts.
|
||||||
|
anlaesse String @default("tagesueberblick,faelligkeit")
|
||||||
|
perPush Boolean @default(false)
|
||||||
|
perMail Boolean @default(false)
|
||||||
|
|
||||||
|
/// Alles als Minuten seit Mitternacht, Europe/Berlin.
|
||||||
|
ueberblickUm Int @default(420)
|
||||||
|
ruheVon Int @default(1200)
|
||||||
|
ruheBis Int @default(420)
|
||||||
|
|
||||||
|
ruheAmWochenende Boolean @default(false)
|
||||||
|
zeitblockVorlaufMinuten Int @default(10)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("benachrichtigungseinstellung")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Browser, der Push-Nachrichten empfangen darf.
|
||||||
|
model PushGeraet {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String
|
||||||
|
|
||||||
|
/// Vom Browser vergebene Adresse; eindeutig je Geraet.
|
||||||
|
endpunkt String @unique
|
||||||
|
/// Schluessel aus der Push-Anmeldung, verschluesselt abgelegt.
|
||||||
|
p256dh String
|
||||||
|
auth String
|
||||||
|
|
||||||
|
bezeichnung String?
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
zuletztBenutzt DateTime?
|
||||||
|
/// Wie oft der Versand hintereinander scheiterte; ab einer Grenze weg.
|
||||||
|
fehlversuche Int @default(0)
|
||||||
|
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([benutzerId])
|
||||||
|
@@map("push_geraet")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nachweis, dass eine Meldung schon hinausging – verhindert Wiederholungen.
|
||||||
|
model Meldungsnachweis {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String
|
||||||
|
/// anlass:sache:tag, siehe meldungsSchluessel().
|
||||||
|
schluessel String
|
||||||
|
gesendetAm DateTime @default(now())
|
||||||
|
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([benutzerId, schluessel])
|
||||||
|
@@index([gesendetAm])
|
||||||
|
@@map("meldungsnachweis")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Service Worker des Planers.
|
||||||
|
*
|
||||||
|
* Er tut genau eine Sache: Push-Nachrichten anzeigen und beim Klick die
|
||||||
|
* richtige Seite öffnen. Kein Zwischenspeichern von Seiten — ein halb
|
||||||
|
* veralteter Planer wäre schlimmer als gar keiner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
self.addEventListener('install', () => {
|
||||||
|
// Sofort übernehmen, nicht auf das Schließen aller Tabs warten.
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (ereignis) => {
|
||||||
|
ereignis.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('push', (ereignis) => {
|
||||||
|
let daten = { titel: 'Planer', text: '', pfad: '/' }
|
||||||
|
try {
|
||||||
|
if (ereignis.data) daten = { ...daten, ...ereignis.data.json() }
|
||||||
|
} catch {
|
||||||
|
// Kaputte Nutzlast: lieber eine karge Meldung als gar keine.
|
||||||
|
}
|
||||||
|
|
||||||
|
ereignis.waitUntil(
|
||||||
|
self.registration.showNotification(daten.titel, {
|
||||||
|
body: daten.text,
|
||||||
|
lang: 'de',
|
||||||
|
// Gleiche Anlässe überschreiben einander, statt sich zu stapeln.
|
||||||
|
tag: daten.anlass || 'planer',
|
||||||
|
data: { pfad: daten.pfad },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (ereignis) => {
|
||||||
|
ereignis.notification.close()
|
||||||
|
const pfad = (ereignis.notification.data && ereignis.notification.data.pfad) || '/'
|
||||||
|
|
||||||
|
ereignis.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((fenster) => {
|
||||||
|
// Ist der Planer schon offen, dorthin wechseln statt ein zweites
|
||||||
|
// Fenster aufzumachen.
|
||||||
|
for (const f of fenster) {
|
||||||
|
if (f.url.includes(self.location.origin)) {
|
||||||
|
return f.focus().then(() => f.navigate(pfad))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(pfad)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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[] = [
|
||||||
@@ -31,11 +38,17 @@ const AUFNAHMEN: Aufnahme[] = [
|
|||||||
{ name: 'start', pfad: '/' },
|
{ name: 'start', pfad: '/' },
|
||||||
{ name: 'inbox', pfad: '/inbox' },
|
{ name: 'inbox', pfad: '/inbox' },
|
||||||
{ name: 'aufgaben', pfad: '/aufgaben' },
|
{ name: 'aufgaben', pfad: '/aufgaben' },
|
||||||
|
{ name: 'woche', pfad: '/woche' },
|
||||||
{ name: 'projekte', pfad: '/projekte' },
|
{ name: 'projekte', pfad: '/projekte' },
|
||||||
{ name: 'ideen', pfad: '/ideen' },
|
{ name: 'ideen', pfad: '/ideen' },
|
||||||
{ 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' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -81,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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Bell, BellOff, Loader2, Send, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||||
|
import {
|
||||||
|
ANLAESSE,
|
||||||
|
ANLASS_BESCHREIBUNG,
|
||||||
|
ANLASS_NAME,
|
||||||
|
type Anlass,
|
||||||
|
} from '@/lib/benachrichtigung'
|
||||||
|
import {
|
||||||
|
einstellungSpeichern,
|
||||||
|
geraetEntfernen,
|
||||||
|
probeSenden,
|
||||||
|
type Rueckmeldung,
|
||||||
|
} from '@/server/aktionen/benachrichtigungen'
|
||||||
|
|
||||||
|
export type GeraetAnzeige = {
|
||||||
|
id: string
|
||||||
|
bezeichnung: string | null
|
||||||
|
erstelltAm: string
|
||||||
|
zuletztBenutzt: string | null
|
||||||
|
/** Dieses Gerät ist der Browser, in dem die Seite gerade offen ist. */
|
||||||
|
diesesGeraet?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EinstellungAnzeige = {
|
||||||
|
anlaesse: Anlass[]
|
||||||
|
perPush: boolean
|
||||||
|
perMail: boolean
|
||||||
|
ueberblickUm: string
|
||||||
|
ruheVon: string
|
||||||
|
ruheBis: string
|
||||||
|
ruheAmWochenende: boolean
|
||||||
|
zeitblockVorlaufMinuten: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Eigenschaften = {
|
||||||
|
einstellung: EinstellungAnzeige
|
||||||
|
geraete: GeraetAnzeige[]
|
||||||
|
pushMoeglich: boolean
|
||||||
|
mailMoeglich: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt den base64url-Schlüssel in die Form, die der Browser erwartet.
|
||||||
|
*
|
||||||
|
* Der Puffer wird ausdrücklich angelegt, damit der Typ `Uint8Array<ArrayBuffer>`
|
||||||
|
* lautet — `pushManager.subscribe` nimmt keinen geteilten Puffer an.
|
||||||
|
*/
|
||||||
|
function schluesselAlsBytes(base64url: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const fuellung = '='.repeat((4 - (base64url.length % 4)) % 4)
|
||||||
|
const base64 = (base64url + fuellung).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const roh = atob(base64)
|
||||||
|
const bytes = new Uint8Array(new ArrayBuffer(roh.length))
|
||||||
|
for (let i = 0; i < roh.length; i += 1) bytes[i] = roh.charCodeAt(i)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Benachrichtigungen({
|
||||||
|
einstellung,
|
||||||
|
geraete,
|
||||||
|
pushMoeglich,
|
||||||
|
mailMoeglich,
|
||||||
|
}: Eigenschaften) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
const [felder, setFelder] = useState<Record<string, string>>({})
|
||||||
|
const [pushZustand, setPushZustand] = useState<
|
||||||
|
'unbekannt' | 'nicht-unterstuetzt' | 'aus' | 'an' | 'verweigert'
|
||||||
|
>('unbekannt')
|
||||||
|
const [pushLaeuft, setPushLaeuft] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let abgebrochen = false
|
||||||
|
|
||||||
|
async function zustandPruefen() {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
if (!abgebrochen) setPushZustand('nicht-unterstuetzt')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Notification.permission === 'denied') {
|
||||||
|
if (!abgebrochen) setPushZustand('verweigert')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const registrierung = await navigator.serviceWorker.getRegistration()
|
||||||
|
const abo = await registrierung?.pushManager.getSubscription()
|
||||||
|
if (!abgebrochen) setPushZustand(abo ? 'an' : 'aus')
|
||||||
|
}
|
||||||
|
|
||||||
|
void zustandPruefen()
|
||||||
|
return () => {
|
||||||
|
abgebrochen = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await tun()
|
||||||
|
if (antwort.ok) {
|
||||||
|
setFelder({})
|
||||||
|
toast.success(antwort.meldung)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
setFelder(antwort.felder ?? {})
|
||||||
|
toast.error(antwort.fehler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushAnmelden() {
|
||||||
|
setPushLaeuft(true)
|
||||||
|
try {
|
||||||
|
const erlaubnis = await Notification.requestPermission()
|
||||||
|
if (erlaubnis !== 'granted') {
|
||||||
|
setPushZustand(erlaubnis === 'denied' ? 'verweigert' : 'aus')
|
||||||
|
toast.error('Ohne Erlaubnis des Browsers geht es nicht.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const antwort = await fetch('/api/push')
|
||||||
|
const daten = (await antwort.json()) as {
|
||||||
|
aktiv: boolean
|
||||||
|
oeffentlicherSchluessel: string | null
|
||||||
|
}
|
||||||
|
if (!daten.aktiv || !daten.oeffentlicherSchluessel) {
|
||||||
|
toast.error('Push ist auf diesem Server nicht eingerichtet.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrierung = await navigator.serviceWorker.register('/sw.js')
|
||||||
|
await navigator.serviceWorker.ready
|
||||||
|
|
||||||
|
const abo = await registrierung.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: schluesselAlsBytes(daten.oeffentlicherSchluessel),
|
||||||
|
})
|
||||||
|
|
||||||
|
const roh = abo.toJSON() as { endpoint?: string; keys?: Record<string, string> }
|
||||||
|
const gespeichert = await fetch('/api/push', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpunkt: roh.endpoint,
|
||||||
|
p256dh: roh.keys?.p256dh,
|
||||||
|
auth: roh.keys?.auth,
|
||||||
|
bezeichnung: geraetename(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gespeichert.ok) {
|
||||||
|
const fehler = (await gespeichert.json()) as { fehler?: string }
|
||||||
|
toast.error(fehler.fehler ?? 'Die Anmeldung ließ sich nicht speichern.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPushZustand('an')
|
||||||
|
toast.success('Dieses Gerät empfängt jetzt Benachrichtigungen.')
|
||||||
|
router.refresh()
|
||||||
|
} catch {
|
||||||
|
toast.error('Die Anmeldung hat nicht geklappt.')
|
||||||
|
} finally {
|
||||||
|
setPushLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushAbmelden() {
|
||||||
|
setPushLaeuft(true)
|
||||||
|
try {
|
||||||
|
const registrierung = await navigator.serviceWorker.getRegistration()
|
||||||
|
const abo = await registrierung?.pushManager.getSubscription()
|
||||||
|
if (abo) {
|
||||||
|
await fetch('/api/push', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ endpunkt: abo.endpoint }),
|
||||||
|
})
|
||||||
|
await abo.unsubscribe()
|
||||||
|
}
|
||||||
|
setPushZustand('aus')
|
||||||
|
toast.success('Dieses Gerät empfängt keine Benachrichtigungen mehr.')
|
||||||
|
router.refresh()
|
||||||
|
} catch {
|
||||||
|
toast.error('Das Abmelden hat nicht geklappt.')
|
||||||
|
} finally {
|
||||||
|
setPushLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Dieses Gerät */}
|
||||||
|
<div className="border-rand flex flex-wrap items-center gap-3 rounded-md border p-3">
|
||||||
|
{pushZustand === 'an' ? (
|
||||||
|
<Bell className="text-erfolg size-5 shrink-0" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<BellOff className="text-gedaempft-vordergrund size-5 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">Dieses Gerät</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
{/*
|
||||||
|
Fehlen die Schlüssel auf dem Server, geht hier ohnehin nichts –
|
||||||
|
dann über den Browser zu reden, schickt nur auf die falsche
|
||||||
|
Fährte.
|
||||||
|
*/}
|
||||||
|
{!pushMoeglich
|
||||||
|
? '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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!pushMoeglich ? (
|
||||||
|
<Kennzeichen art="warnung">Auf dem Server nicht eingerichtet</Kennzeichen>
|
||||||
|
) : pushZustand === 'an' ? (
|
||||||
|
<Button art="umriss" disabled={pushLaeuft} onClick={() => void pushAbmelden()}>
|
||||||
|
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
pushZustand !== 'nicht-unterstuetzt' &&
|
||||||
|
pushZustand !== 'verweigert' && (
|
||||||
|
<Button disabled={pushLaeuft} onClick={() => void pushAnmelden()}>
|
||||||
|
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Anmelden
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Einstellungen */}
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-5"
|
||||||
|
onSubmit={(ereignis) => {
|
||||||
|
ereignis.preventDefault()
|
||||||
|
const daten = new FormData(ereignis.currentTarget)
|
||||||
|
mitRueckmeldung(() => einstellungSpeichern(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset className="flex flex-col gap-3">
|
||||||
|
<legend className="mb-1 text-sm font-medium">Worüber</legend>
|
||||||
|
{ANLAESSE.map((anlass) => (
|
||||||
|
<label key={anlass} className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="anlaesse"
|
||||||
|
value={anlass}
|
||||||
|
defaultChecked={einstellung.anlaesse.includes(anlass)}
|
||||||
|
className="accent-betont mt-0.5 size-4"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="text-sm">{ANLASS_NAME[anlass]}</span>
|
||||||
|
<span className="text-gedaempft-vordergrund block text-xs">
|
||||||
|
{ANLASS_BESCHREIBUNG[anlass]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-3">
|
||||||
|
<legend className="mb-1 text-sm font-medium">Wie</legend>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="perPush"
|
||||||
|
defaultChecked={einstellung.perPush}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
Push an angemeldete Geräte
|
||||||
|
{!pushMoeglich && (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— auf diesem Server nicht eingerichtet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="perMail"
|
||||||
|
defaultChecked={einstellung.perMail}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
E-Mail
|
||||||
|
{!mailMoeglich && (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— auf diesem Server nicht eingerichtet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ueberblickUm">Tagesüberblick um</Label>
|
||||||
|
<Input
|
||||||
|
id="ueberblickUm"
|
||||||
|
name="ueberblickUm"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ueberblickUm}
|
||||||
|
aria-invalid={Boolean(felder.ueberblickUm)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="zeitblockVorlaufMinuten">
|
||||||
|
Vorlauf vor einem Zeitblock (Minuten)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="zeitblockVorlaufMinuten"
|
||||||
|
name="zeitblockVorlaufMinuten"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={120}
|
||||||
|
step={5}
|
||||||
|
defaultValue={einstellung.zeitblockVorlaufMinuten}
|
||||||
|
aria-invalid={Boolean(felder.zeitblockVorlaufMinuten)}
|
||||||
|
/>
|
||||||
|
{felder.zeitblockVorlaufMinuten && (
|
||||||
|
<p className="text-gefahr text-xs">{felder.zeitblockVorlaufMinuten}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ruheVon">Ruhe von</Label>
|
||||||
|
<Input
|
||||||
|
id="ruheVon"
|
||||||
|
name="ruheVon"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ruheVon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ruheBis">bis</Label>
|
||||||
|
<Input
|
||||||
|
id="ruheBis"
|
||||||
|
name="ruheBis"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ruheBis}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="ruheAmWochenende"
|
||||||
|
defaultChecked={einstellung.ruheAmWochenende}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Am Wochenende gar nichts melden</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
In der Ruhezeit bleibt alles still — bis auf den Tagesüberblick. Der käme sonst
|
||||||
|
nie, wenn du früh aufstehst.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" disabled={laeuft}>
|
||||||
|
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
art="umriss"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => mitRueckmeldung(() => probeSenden())}
|
||||||
|
>
|
||||||
|
<Send aria-hidden />
|
||||||
|
Probe schicken
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Angemeldete Geräte */}
|
||||||
|
{geraete.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">Angemeldete Geräte</h3>
|
||||||
|
<ul className="divide-rand divide-y">
|
||||||
|
{geraete.map((geraet) => (
|
||||||
|
<li key={geraet.id} className="flex items-center gap-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm">{geraet.bezeichnung ?? 'Unbenanntes Gerät'}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Angemeldet {geraet.erstelltAm}
|
||||||
|
{geraet.zuletztBenutzt
|
||||||
|
? ` · zuletzt benachrichtigt ${geraet.zuletztBenutzt}`
|
||||||
|
: ' · noch nichts empfangen'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title={`${geraet.bezeichnung ?? 'Gerät'} abmelden`}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', geraet.id)
|
||||||
|
mitRueckmeldung(() => geraetEntfernen(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
<span className="sr-only">Abmelden</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grobe Kennung des Browsers, damit sich Geräte auseinanderhalten lassen. */
|
||||||
|
function geraetename(): string {
|
||||||
|
const kennung = navigator.userAgent
|
||||||
|
if (/Android/i.test(kennung)) return 'Android-Gerät'
|
||||||
|
if (/iPhone|iPad/i.test(kennung)) return 'iPhone oder iPad'
|
||||||
|
if (/Macintosh/i.test(kennung)) return 'Mac'
|
||||||
|
if (/Windows/i.test(kennung)) return 'Windows-Rechner'
|
||||||
|
if (/Linux/i.test(kennung)) return 'Linux-Rechner'
|
||||||
|
return 'Unbekanntes Gerät'
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import { Benachrichtigungen } from '@/app/(app)/einstellungen/benachrichtigungen'
|
||||||
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -8,6 +9,9 @@ import {
|
|||||||
CardTitel,
|
CardTitel,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { ROLLEN_NAME } from '@/lib/rollen'
|
import { ROLLEN_NAME } from '@/lib/rollen'
|
||||||
|
import { alsEinstellung } from '@/server/benachrichtigung/versand'
|
||||||
|
import { alsUhrzeit } from '@/lib/zeit'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
import { alsDatumUhrzeit } from '@/lib/zeit'
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
@@ -17,6 +21,35 @@ export const metadata: Metadata = { title: 'Einstellungen' }
|
|||||||
export default async function EinstellungenSeite() {
|
export default async function EinstellungenSeite() {
|
||||||
const benutzer = await verlangeAnmeldung('/einstellungen')
|
const benutzer = await verlangeAnmeldung('/einstellungen')
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
const [benachrichtigung, pushGeraete] = await Promise.all([
|
||||||
|
db.benachrichtigungseinstellung.findUnique({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
select: {
|
||||||
|
anlaesse: true,
|
||||||
|
perPush: true,
|
||||||
|
perMail: true,
|
||||||
|
ueberblickUm: true,
|
||||||
|
ruheVon: true,
|
||||||
|
ruheBis: true,
|
||||||
|
ruheAmWochenende: true,
|
||||||
|
zeitblockVorlaufMinuten: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.pushGeraet.findMany({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bezeichnung: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
zuletztBenutzt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
const einstellung = alsEinstellung(benachrichtigung)
|
||||||
|
|
||||||
const passkeys = await db.passkey.findMany({
|
const passkeys = await db.passkey.findMany({
|
||||||
where: { benutzerId: benutzer.id },
|
where: { benutzerId: benutzer.id },
|
||||||
orderBy: { erstelltAm: 'asc' },
|
orderBy: { erstelltAm: 'asc' },
|
||||||
@@ -72,6 +105,39 @@ export default async function EinstellungenSeite() {
|
|||||||
/>
|
/>
|
||||||
</CardInhalt>
|
</CardInhalt>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card data-bild="benachrichtigungen">
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Benachrichtigungen</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
Ab Werk meldet der Planer gar nichts. Was du hier einschaltest, kommt — alles
|
||||||
|
andere nicht. Eine Anwendung, die zu oft meldet, wird stummgeschaltet und ist
|
||||||
|
dann wertlos.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<Benachrichtigungen
|
||||||
|
pushMoeglich={einst.pushAktiv}
|
||||||
|
mailMoeglich={einst.mailAktiv}
|
||||||
|
einstellung={{
|
||||||
|
anlaesse: [...einstellung.anlaesse],
|
||||||
|
perPush: einstellung.perPush,
|
||||||
|
perMail: einstellung.perMail,
|
||||||
|
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
|
||||||
|
ruheVon: alsUhrzeit(einstellung.ruheVon),
|
||||||
|
ruheBis: alsUhrzeit(einstellung.ruheBis),
|
||||||
|
ruheAmWochenende: einstellung.ruheAmWochenende,
|
||||||
|
zeitblockVorlaufMinuten: einstellung.zeitblockVorlaufMinuten,
|
||||||
|
}}
|
||||||
|
geraete={pushGeraete.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
bezeichnung: g.bezeichnung,
|
||||||
|
erstelltAm: alsDatumUhrzeit(g.erstelltAm),
|
||||||
|
zuletztBenutzt: g.zuletztBenutzt ? alsDatumUhrzeit(g.zuletztBenutzt) : null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { CalendarRange } from 'lucide-react'
|
||||||
|
import { Wochenraster } from '@/app/(app)/woche/wochenraster'
|
||||||
|
import { Card, CardInhalt } from '@/components/ui/card'
|
||||||
|
import { faelligkeitEinordnen, faelligkeitText, istUeberfaellig } from '@/lib/aufgabe'
|
||||||
|
import {
|
||||||
|
belegteMinuten,
|
||||||
|
arbeitsfensterAmTag,
|
||||||
|
kapazitaetAmTag,
|
||||||
|
} from '@/lib/kalender/belegung'
|
||||||
|
import { fensterHuelle, rasterBauen } from '@/lib/woche'
|
||||||
|
import { alsDatum, gleicherTag, tagesBeginn } from '@/lib/zeit'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { planbareAufgabenLaden, wocheLaden } from '@/server/abfragen/woche'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Woche' }
|
||||||
|
|
||||||
|
const WOCHENTAGE = [
|
||||||
|
'Montag',
|
||||||
|
'Dienstag',
|
||||||
|
'Mittwoch',
|
||||||
|
'Donnerstag',
|
||||||
|
'Freitag',
|
||||||
|
'Samstag',
|
||||||
|
'Sonntag',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default async function WocheSeite({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ versatz?: string }>
|
||||||
|
}) {
|
||||||
|
const benutzer = await verlangeAnmeldung('/woche')
|
||||||
|
const parameter = await searchParams
|
||||||
|
|
||||||
|
// Über den Versatz lässt sich blättern; er steht in der Adresse, damit eine
|
||||||
|
// bestimmte Woche als Lesezeichen taugt.
|
||||||
|
const versatz = Number.parseInt(parameter.versatz ?? '0', 10)
|
||||||
|
const sichereWochen = Number.isFinite(versatz)
|
||||||
|
? Math.max(-52, Math.min(52, versatz))
|
||||||
|
: 0
|
||||||
|
const heute = jetzt()
|
||||||
|
const zeitpunkt = new Date(heute.getTime() + sichereWochen * 7 * 86_400_000)
|
||||||
|
|
||||||
|
const [daten, aufgaben, bereiche, konto] = await Promise.all([
|
||||||
|
wocheLaden(benutzer.id, zeitpunkt),
|
||||||
|
planbareAufgabenLaden(),
|
||||||
|
db.bereich.findMany({
|
||||||
|
where: { aktiv: true },
|
||||||
|
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||||
|
}),
|
||||||
|
// Für den ersten Wurf: der Zeitblock landet im ersten verbundenen
|
||||||
|
// Kalender. Die Auswahl je Bereich kommt, sobald es mehrere gibt.
|
||||||
|
db.kalenderkonto.findFirst({
|
||||||
|
where: { benutzerId: benutzer.id, aktiv: true },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
select: { id: true, anzeigename: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const huelle = fensterHuelle(bereiche)
|
||||||
|
const raster = rasterBauen(zeitpunkt, huelle.von, huelle.bis)
|
||||||
|
|
||||||
|
// Kapazität und Belegung je Tag: alle Bereiche zusammengenommen, denn ein
|
||||||
|
// Termin blockiert die Zeit für alles gleichermaßen.
|
||||||
|
const spalten = raster.spalten.map((spalte) => {
|
||||||
|
const tag = spalte.tagesBeginn
|
||||||
|
const kapazitaet = bereiche.reduce((summe, b) => summe + kapazitaetAmTag(b, tag), 0)
|
||||||
|
|
||||||
|
const belegend = [
|
||||||
|
...daten.termine.map((t) => ({
|
||||||
|
beginn: t.beginn,
|
||||||
|
ende: t.ende,
|
||||||
|
ganztaegig: t.ganztaegig,
|
||||||
|
zeigtAls: t.zeigtAls,
|
||||||
|
})),
|
||||||
|
...daten.zeitbloecke.map((z) => ({
|
||||||
|
beginn: z.beginn,
|
||||||
|
ende: z.ende,
|
||||||
|
ganztaegig: false,
|
||||||
|
zeigtAls: 'GEBUCHT' as const,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Gegen die Hülle rechnen, nicht gegen ein einzelnes Arbeitsfenster:
|
||||||
|
// die Tageskapazität ist hier die Summe über alle Bereiche.
|
||||||
|
const fenster = arbeitsfensterAmTag(tag, huelle.von, huelle.bis)
|
||||||
|
const belegt = belegteMinuten(belegend, fenster)
|
||||||
|
|
||||||
|
return {
|
||||||
|
schluessel: spalte.schluessel,
|
||||||
|
tagesBeginnMs: spalte.tagesBeginn.getTime(),
|
||||||
|
name: WOCHENTAGE[spalte.wochentag] ?? '',
|
||||||
|
datum: alsDatum(tag).split(', ')[1] ?? '',
|
||||||
|
heute: gleicherTag(tag, heute),
|
||||||
|
kapazitaet,
|
||||||
|
belegt,
|
||||||
|
ueberbucht: kapazitaet > 0 && belegt > kapazitaet,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const montag = tagesBeginn(raster.spalten[0]?.tagesBeginn ?? zeitpunkt)
|
||||||
|
const sonntag = raster.spalten[6]?.tagesBeginn ?? zeitpunkt
|
||||||
|
|
||||||
|
return (
|
||||||
|
/*
|
||||||
|
Die Wochenansicht braucht mehr Breite als der Inhaltsrahmen von 64rem
|
||||||
|
hergibt – bei sieben Spalten plus Aufgabenliste wäre sonst der Sonntag
|
||||||
|
abgeschnitten. Deshalb bricht genau diese Seite aus und nimmt bis zu
|
||||||
|
100rem, mittig und mit demselben Seitenabstand wie überall.
|
||||||
|
*/
|
||||||
|
<div className="relative left-1/2 w-screen max-w-[100rem] -translate-x-1/2 px-4 sm:px-6">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||||
|
<CalendarRange className="size-6" aria-hidden />
|
||||||
|
Woche
|
||||||
|
</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||||
|
{alsDatum(montag)} bis {alsDatum(sonntag)}
|
||||||
|
{konto ? ` · schreibt nach ${konto.anzeigename}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex items-center gap-2" aria-label="Woche wechseln">
|
||||||
|
<Link
|
||||||
|
href={`/woche?versatz=${sichereWochen - 1}`}
|
||||||
|
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/woche"
|
||||||
|
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
Diese Woche
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/woche?versatz=${sichereWochen + 1}`}
|
||||||
|
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!konto && (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="py-4 text-sm">
|
||||||
|
Noch kein Kalender verbunden. Zeitblöcke landen erst dann im Kalender —{' '}
|
||||||
|
<Link
|
||||||
|
href="/kalenderkonten"
|
||||||
|
className="text-betont underline underline-offset-4"
|
||||||
|
>
|
||||||
|
jetzt verbinden
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Wochenraster
|
||||||
|
spalten={spalten}
|
||||||
|
zeilen={raster.zeilen}
|
||||||
|
vonMinute={raster.vonMinute}
|
||||||
|
bisMinute={raster.bisMinute}
|
||||||
|
kalenderkontoId={konto?.id ?? null}
|
||||||
|
zeitbloecke={daten.zeitbloecke.map((z) => ({
|
||||||
|
id: z.id,
|
||||||
|
beginnMs: z.beginn.getTime(),
|
||||||
|
endeMs: z.ende.getTime(),
|
||||||
|
titel: z.titel,
|
||||||
|
bereichFarbe: z.bereichFarbe,
|
||||||
|
kalenderName: z.kalenderName,
|
||||||
|
letzterFehler: z.letzterFehler,
|
||||||
|
offen: z.offen,
|
||||||
|
}))}
|
||||||
|
termine={daten.termine.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
beginnMs: t.beginn.getTime(),
|
||||||
|
endeMs: t.ende.getTime(),
|
||||||
|
betreff: t.betreff,
|
||||||
|
ganztaegig: t.ganztaegig,
|
||||||
|
frei: t.zeigtAls === 'FREI',
|
||||||
|
kalenderName: t.kalenderName,
|
||||||
|
}))}
|
||||||
|
aufgaben={aufgaben.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
titel: a.titel,
|
||||||
|
aufwandMinuten: a.aufwandMinuten,
|
||||||
|
bereichName: a.bereich?.name ?? null,
|
||||||
|
bereichFarbe: a.bereich?.farbe ?? null,
|
||||||
|
faelligText: a.faelligAm
|
||||||
|
? faelligkeitText(faelligkeitEinordnen(a.faelligAm, heute))
|
||||||
|
: null,
|
||||||
|
ueberfaellig: istUeberfaellig(a.faelligAm, a.status, heute),
|
||||||
|
bereitsGeplant: a._count.zeitbloecke,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein
|
||||||
|
Zeitblock lässt sich am Griff verschieben; das Kreuz plant ihn wieder aus. Graue
|
||||||
|
Balken sind Termine aus dem Kalender — sie lassen sich hier nicht verschieben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
type DragStartEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import { AlertTriangle, CalendarClock, GripVertical, X } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||||
|
import {
|
||||||
|
ZEILE_HOEHE,
|
||||||
|
balkenlage,
|
||||||
|
spurenVerteilen,
|
||||||
|
zeilenBeschriftung,
|
||||||
|
zeitpunktAus,
|
||||||
|
type Raster,
|
||||||
|
} from '@/lib/woche'
|
||||||
|
import { alsDauer, alsUhrzeitAnzeige } from '@/lib/zeit'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
zeitblockAnlegen,
|
||||||
|
zeitblockLoesen,
|
||||||
|
zeitblockVerschieben,
|
||||||
|
type Rueckmeldung,
|
||||||
|
} from '@/server/aktionen/zeitbloecke'
|
||||||
|
|
||||||
|
export type PlanbareAufgabe = {
|
||||||
|
id: string
|
||||||
|
titel: string
|
||||||
|
aufwandMinuten: number | null
|
||||||
|
bereichName: string | null
|
||||||
|
bereichFarbe: string | null
|
||||||
|
faelligText: string | null
|
||||||
|
ueberfaellig: boolean
|
||||||
|
bereitsGeplant: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RasterZeitblock = {
|
||||||
|
id: string
|
||||||
|
beginnMs: number
|
||||||
|
endeMs: number
|
||||||
|
titel: string
|
||||||
|
bereichFarbe: string | null
|
||||||
|
kalenderName: string
|
||||||
|
letzterFehler: string | null
|
||||||
|
offen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RasterTermin = {
|
||||||
|
id: string
|
||||||
|
beginnMs: number
|
||||||
|
endeMs: number
|
||||||
|
betreff: string | null
|
||||||
|
ganztaegig: boolean
|
||||||
|
frei: boolean
|
||||||
|
kalenderName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RasterSpalte = {
|
||||||
|
schluessel: string
|
||||||
|
tagesBeginnMs: number
|
||||||
|
name: string
|
||||||
|
datum: string
|
||||||
|
heute: boolean
|
||||||
|
kapazitaet: number
|
||||||
|
belegt: number
|
||||||
|
ueberbucht: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Eigenschaften = {
|
||||||
|
spalten: RasterSpalte[]
|
||||||
|
zeilen: number[]
|
||||||
|
vonMinute: number
|
||||||
|
bisMinute: number
|
||||||
|
zeitbloecke: RasterZeitblock[]
|
||||||
|
termine: RasterTermin[]
|
||||||
|
aufgaben: PlanbareAufgabe[]
|
||||||
|
kalenderkontoId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standarddauer, wenn eine Aufgabe keinen geschätzten Aufwand trägt. */
|
||||||
|
const STANDARD_MINUTEN = 60
|
||||||
|
|
||||||
|
export function Wochenraster({
|
||||||
|
spalten,
|
||||||
|
zeilen,
|
||||||
|
vonMinute,
|
||||||
|
bisMinute,
|
||||||
|
zeitbloecke,
|
||||||
|
termine,
|
||||||
|
aufgaben,
|
||||||
|
kalenderkontoId,
|
||||||
|
}: Eigenschaften) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
const [gezogen, setGezogen] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const sensoren = useSensors(
|
||||||
|
// Erst ab acht Pixeln gilt es als Ziehen – sonst löst jeder Klick einen
|
||||||
|
// Zug aus und Knöpfe im Block wären nicht mehr bedienbar.
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const raster: Raster = {
|
||||||
|
spalten: spalten.map((s) => ({
|
||||||
|
tagesBeginn: new Date(s.tagesBeginnMs),
|
||||||
|
wochentag: 0,
|
||||||
|
schluessel: s.schluessel,
|
||||||
|
})),
|
||||||
|
zeilen,
|
||||||
|
vonMinute,
|
||||||
|
bisMinute,
|
||||||
|
}
|
||||||
|
|
||||||
|
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await tun()
|
||||||
|
if (antwort.ok) toast.success(antwort.meldung)
|
||||||
|
else toast.error(antwort.fehler)
|
||||||
|
router.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function beimStart(ereignis: DragStartEvent) {
|
||||||
|
setGezogen(String(ereignis.active.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function beimEnde(ereignis: DragEndEvent) {
|
||||||
|
setGezogen(null)
|
||||||
|
const ziel = ereignis.over?.id
|
||||||
|
if (!ziel) return
|
||||||
|
|
||||||
|
const [schluessel, minuteRoh] = String(ziel).split('@')
|
||||||
|
if (!schluessel || minuteRoh === undefined) return
|
||||||
|
const beginn = zeitpunktAus(schluessel, Number(minuteRoh))
|
||||||
|
|
||||||
|
const kennung = String(ereignis.active.id)
|
||||||
|
|
||||||
|
if (kennung.startsWith('aufgabe:')) {
|
||||||
|
if (!kalenderkontoId) {
|
||||||
|
toast.error('Erst einen Kalender verbinden, dann lässt sich einplanen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const aufgabeId = kennung.slice('aufgabe:'.length)
|
||||||
|
const aufgabe = aufgaben.find((a) => a.id === aufgabeId)
|
||||||
|
const dauer = aufgabe?.aufwandMinuten ?? STANDARD_MINUTEN
|
||||||
|
const ende = new Date(beginn.getTime() + dauer * 60_000)
|
||||||
|
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('aufgabeId', aufgabeId)
|
||||||
|
daten.set('kalenderkontoId', kalenderkontoId)
|
||||||
|
daten.set('beginn', beginn.toISOString())
|
||||||
|
daten.set('ende', ende.toISOString())
|
||||||
|
mitRueckmeldung(() => zeitblockAnlegen(daten))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kennung.startsWith('block:')) {
|
||||||
|
const id = kennung.slice('block:'.length)
|
||||||
|
const block = zeitbloecke.find((z) => z.id === id)
|
||||||
|
if (!block) return
|
||||||
|
const dauer = block.endeMs - block.beginnMs
|
||||||
|
const ende = new Date(beginn.getTime() + dauer)
|
||||||
|
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', id)
|
||||||
|
daten.set('beginn', beginn.toISOString())
|
||||||
|
daten.set('ende', ende.toISOString())
|
||||||
|
mitRueckmeldung(() => zeitblockVerschieben(daten))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ausplanen(id: string) {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', id)
|
||||||
|
mitRueckmeldung(() => zeitblockLoesen(daten))
|
||||||
|
}
|
||||||
|
|
||||||
|
const gezogeneAufgabe = gezogen?.startsWith('aufgabe:')
|
||||||
|
? aufgaben.find((a) => a.id === gezogen.slice('aufgabe:'.length))
|
||||||
|
: null
|
||||||
|
const gezogenerBlock = gezogen?.startsWith('block:')
|
||||||
|
? zeitbloecke.find((z) => z.id === gezogen.slice('block:'.length))
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensoren} onDragStart={beimStart} onDragEnd={beimEnde}>
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||||
|
{/* Vorrat: was noch eingeplant werden kann */}
|
||||||
|
<aside className="lg:w-72 lg:shrink-0">
|
||||||
|
<h2 className="mb-2 text-sm font-medium">
|
||||||
|
Einzuplanen
|
||||||
|
<span className="text-gedaempft-vordergrund ml-2 font-normal">
|
||||||
|
{aufgaben.length}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!kalenderkontoId && (
|
||||||
|
<p className="bg-warnung/15 text-warnung mb-3 rounded-md px-3 py-2 text-xs">
|
||||||
|
Ohne verbundenen Kalender lässt sich nichts einplanen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="border-rand bg-karte max-h-[32rem] divide-y divide-[var(--rand)] overflow-y-auto rounded-xl border">
|
||||||
|
{aufgaben.length === 0 ? (
|
||||||
|
<li className="text-gedaempft-vordergrund p-4 text-center text-sm">
|
||||||
|
Nichts offen. Ein guter Tag.
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
aufgaben.map((aufgabe) => (
|
||||||
|
<AufgabenKachel key={aufgabe.id} aufgabe={aufgabe} gesperrt={laeuft} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Das Wochenraster */}
|
||||||
|
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||||
|
<div className="min-w-[46rem]">
|
||||||
|
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
|
||||||
|
<div />
|
||||||
|
{spalten.map((spalte) => (
|
||||||
|
<div
|
||||||
|
key={spalte.schluessel}
|
||||||
|
className={cn(
|
||||||
|
'border-rand border-b px-1 pb-2 text-center',
|
||||||
|
spalte.heute && 'bg-betont/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{spalte.name}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">{spalte.datum}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'mt-1 text-xs',
|
||||||
|
spalte.ueberbucht ? 'text-gefahr' : 'text-gedaempft-vordergrund',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{spalte.kapazitaet === 0
|
||||||
|
? '—'
|
||||||
|
: `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
|
||||||
|
{/* Stundenleiste */}
|
||||||
|
<div className="relative">
|
||||||
|
{zeilen.map((minute) => (
|
||||||
|
<div
|
||||||
|
key={minute}
|
||||||
|
style={{ height: ZEILE_HOEHE }}
|
||||||
|
className="text-gedaempft-vordergrund pr-2 text-right text-[0.6875rem] leading-none"
|
||||||
|
>
|
||||||
|
{zeilenBeschriftung(minute)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{spalten.map((spalte) => {
|
||||||
|
const blockeHier = zeitbloecke.filter(
|
||||||
|
(z) =>
|
||||||
|
z.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
|
||||||
|
z.endeMs > spalte.tagesBeginnMs,
|
||||||
|
)
|
||||||
|
const termineHier = termine.filter(
|
||||||
|
(t) =>
|
||||||
|
!t.ganztaegig &&
|
||||||
|
t.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
|
||||||
|
t.endeMs > spalte.tagesBeginnMs,
|
||||||
|
)
|
||||||
|
|
||||||
|
const verteilteTermine = spurenVerteilen(
|
||||||
|
termineHier.map((t) => ({
|
||||||
|
...t,
|
||||||
|
beginn: new Date(t.beginnMs),
|
||||||
|
ende: new Date(t.endeMs),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={spalte.schluessel}
|
||||||
|
className={cn(
|
||||||
|
'border-rand relative border-l',
|
||||||
|
spalte.heute && 'bg-betont/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{zeilen.map((minute) => (
|
||||||
|
<Zielfeld
|
||||||
|
key={minute}
|
||||||
|
kennung={`${spalte.schluessel}@${minute}`}
|
||||||
|
volleStunde={minute % 60 === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Fremde Termine liegen hinten und lassen sich nicht ziehen */}
|
||||||
|
{verteilteTermine.map(({ eintrag, spur, spuren }) => {
|
||||||
|
const lage = balkenlage(
|
||||||
|
eintrag.beginn,
|
||||||
|
eintrag.ende,
|
||||||
|
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
|
||||||
|
raster,
|
||||||
|
)
|
||||||
|
if (lage.ausserhalb) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={eintrag.id}
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute rounded-sm px-1 text-[0.6875rem] leading-tight',
|
||||||
|
eintrag.frei
|
||||||
|
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
|
||||||
|
: 'bg-gedaempft text-gedaempft-vordergrund',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: lage.oben,
|
||||||
|
height: lage.hoehe,
|
||||||
|
left: `${(spur / spuren) * 100}%`,
|
||||||
|
width: `${100 / spuren}%`,
|
||||||
|
}}
|
||||||
|
title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
|
||||||
|
>
|
||||||
|
<span className="line-clamp-2">
|
||||||
|
{eintrag.betreff ?? 'Termin'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Eigene Zeitblöcke liegen vorn und lassen sich ziehen */}
|
||||||
|
{blockeHier.map((block) => {
|
||||||
|
const lage = balkenlage(
|
||||||
|
new Date(block.beginnMs),
|
||||||
|
new Date(block.endeMs),
|
||||||
|
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
|
||||||
|
raster,
|
||||||
|
)
|
||||||
|
if (lage.ausserhalb) return null
|
||||||
|
return (
|
||||||
|
<Zeitblockbalken
|
||||||
|
key={block.id}
|
||||||
|
block={block}
|
||||||
|
oben={lage.oben}
|
||||||
|
hoehe={lage.hoehe}
|
||||||
|
gesperrt={laeuft}
|
||||||
|
onAusplanen={() => ausplanen(block.id)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{gezogeneAufgabe && (
|
||||||
|
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
|
||||||
|
{gezogeneAufgabe.titel}
|
||||||
|
<span className="ml-1 opacity-80">
|
||||||
|
{alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gezogenerBlock && (
|
||||||
|
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
|
||||||
|
{gezogenerBlock.titel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: kennung })
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{ height: ZEILE_HOEHE }}
|
||||||
|
className={cn(
|
||||||
|
'border-rand/40',
|
||||||
|
volleStunde ? 'border-t' : 'border-t border-dashed',
|
||||||
|
isOver && 'bg-betont/20',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AufgabenKachel({
|
||||||
|
aufgabe,
|
||||||
|
gesperrt,
|
||||||
|
}: {
|
||||||
|
aufgabe: PlanbareAufgabe
|
||||||
|
gesperrt: boolean
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
|
id: `aufgabe:${aufgabe.id}`,
|
||||||
|
disabled: gesperrt,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn('flex items-start gap-2 p-2', isDragging && 'opacity-40')}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-gedaempft-vordergrund mt-0.5 cursor-grab touch-none active:cursor-grabbing"
|
||||||
|
aria-label={`${aufgabe.titel} zum Einplanen ziehen`}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<GripVertical className="size-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm leading-tight">{aufgabe.titel}</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
|
{aufgabe.bereichName && (
|
||||||
|
<Kennzeichen>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-2 rounded-full ring-1 ring-black/10"
|
||||||
|
style={{ backgroundColor: aufgabe.bereichFarbe ?? undefined }}
|
||||||
|
/>
|
||||||
|
{aufgabe.bereichName}
|
||||||
|
</Kennzeichen>
|
||||||
|
)}
|
||||||
|
<Kennzeichen>
|
||||||
|
{alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
|
||||||
|
</Kennzeichen>
|
||||||
|
{aufgabe.faelligText && (
|
||||||
|
<Kennzeichen art={aufgabe.ueberfaellig ? 'gefahr' : 'neutral'}>
|
||||||
|
{aufgabe.faelligText}
|
||||||
|
</Kennzeichen>
|
||||||
|
)}
|
||||||
|
{aufgabe.bereitsGeplant > 0 && (
|
||||||
|
<Kennzeichen art="betont">
|
||||||
|
<CalendarClock className="size-3" aria-hidden />
|
||||||
|
{aufgabe.bereitsGeplant}
|
||||||
|
</Kennzeichen>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Zeitblockbalken({
|
||||||
|
block,
|
||||||
|
oben,
|
||||||
|
hoehe,
|
||||||
|
gesperrt,
|
||||||
|
onAusplanen,
|
||||||
|
}: {
|
||||||
|
block: RasterZeitblock
|
||||||
|
oben: number
|
||||||
|
hoehe: number
|
||||||
|
gesperrt: boolean
|
||||||
|
onAusplanen: () => void
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
|
id: `block:${block.id}`,
|
||||||
|
disabled: gesperrt,
|
||||||
|
})
|
||||||
|
|
||||||
|
const farbe = block.bereichFarbe ?? 'var(--betont)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
top: oben,
|
||||||
|
height: hoehe,
|
||||||
|
borderLeftColor: farbe,
|
||||||
|
backgroundColor: `color-mix(in oklab, ${farbe} 18%, var(--karte))`,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-0.5 left-0.5 overflow-hidden rounded-sm border-l-4 px-1 text-[0.6875rem] leading-tight',
|
||||||
|
isDragging && 'opacity-40',
|
||||||
|
)}
|
||||||
|
title={`${block.titel} · ${alsUhrzeitAnzeige(new Date(block.beginnMs))}–${alsUhrzeitAnzeige(new Date(block.endeMs))} · ${block.kalenderName}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 cursor-grab touch-none active:cursor-grabbing"
|
||||||
|
aria-label={`${block.titel} verschieben`}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<GripVertical className="size-3 opacity-60" aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="line-clamp-3 flex-1 font-medium">{block.titel}</span>
|
||||||
|
|
||||||
|
{(block.offen || block.letzterFehler) && (
|
||||||
|
<AlertTriangle
|
||||||
|
className="text-warnung size-3 shrink-0"
|
||||||
|
aria-label="Noch nicht im Kalender"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
className="size-4 shrink-0 p-0"
|
||||||
|
disabled={gesperrt}
|
||||||
|
title="Ausplanen"
|
||||||
|
onClick={onAusplanen}
|
||||||
|
>
|
||||||
|
<X className="size-3" aria-hidden />
|
||||||
|
<span className="sr-only">Ausplanen</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { verlangeAnmeldungFuerApi } from '@/server/berechtigung'
|
||||||
|
import { geraetAbmelden, geraetAnmelden } from '@/server/benachrichtigung/push'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An- und Abmelden eines Browsers für Push-Nachrichten.
|
||||||
|
*
|
||||||
|
* Der öffentliche VAPID-Schlüssel kommt über GET; er ist nicht geheim, wird
|
||||||
|
* aber nur an Angemeldete herausgegeben — es gibt keinen Grund, die
|
||||||
|
* Einrichtung nach außen zu zeigen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const anmeldung = z.object({
|
||||||
|
endpunkt: z.url(),
|
||||||
|
p256dh: z.string().min(1).max(500),
|
||||||
|
auth: z.string().min(1).max(500),
|
||||||
|
bezeichnung: z.string().trim().max(60).nullable().default(null),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const benutzer = await verlangeAnmeldungFuerApi()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
aktiv: einst.pushAktiv,
|
||||||
|
oeffentlicherSchluessel: einst.pushAktiv ? einst.VAPID_OEFFENTLICH : null,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(anfrage: Request) {
|
||||||
|
const benutzer = await verlangeAnmeldungFuerApi()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (!umgebung().pushAktiv) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ fehler: 'Push ist auf diesem Server nicht eingerichtet.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let inhalt: unknown
|
||||||
|
try {
|
||||||
|
inhalt = await anfrage.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = anmeldung.safeParse(inhalt)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ fehler: 'Die Anmeldung war unvollständig.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const geraet = await geraetAnmelden(
|
||||||
|
benutzer.id,
|
||||||
|
{
|
||||||
|
endpunkt: gelesen.data.endpunkt,
|
||||||
|
p256dh: gelesen.data.p256dh,
|
||||||
|
auth: gelesen.data.auth,
|
||||||
|
},
|
||||||
|
gelesen.data.bezeichnung,
|
||||||
|
)
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: benutzer.id, email: benutzer.email },
|
||||||
|
aktion: 'push.angemeldet',
|
||||||
|
objektTyp: 'PushGeraet',
|
||||||
|
objektId: geraet.id,
|
||||||
|
// Weder Endpunkt noch Schlüssel – beides taugt zum Zustellen.
|
||||||
|
details: { bezeichnung: gelesen.data.bezeichnung },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const abmeldung = z.object({ endpunkt: z.url() })
|
||||||
|
|
||||||
|
export async function DELETE(anfrage: Request) {
|
||||||
|
const benutzer = await verlangeAnmeldungFuerApi()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let inhalt: unknown
|
||||||
|
try {
|
||||||
|
inhalt = await anfrage.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = abmeldung.safeParse(inhalt)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await geraetAbmelden(gelesen.data.endpunkt)
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: benutzer.id, email: benutzer.email },
|
||||||
|
aktion: 'push.abgemeldet',
|
||||||
|
objektTyp: 'PushGeraet',
|
||||||
|
objektId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export function Kopfleiste({
|
|||||||
{ pfad: '/', name: 'Start' },
|
{ pfad: '/', name: 'Start' },
|
||||||
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
|
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
|
||||||
{ pfad: '/aufgaben', name: 'Aufgaben' },
|
{ pfad: '/aufgaben', name: 'Aufgaben' },
|
||||||
|
{ pfad: '/woche', name: 'Woche' },
|
||||||
{ pfad: '/projekte', name: 'Projekte' },
|
{ pfad: '/projekte', name: 'Projekte' },
|
||||||
{ pfad: '/ideen', name: 'Ideen' },
|
{ pfad: '/ideen', name: 'Ideen' },
|
||||||
{ pfad: '/bereiche', name: 'Bereiche' },
|
{ pfad: '/bereiche', name: 'Bereiche' },
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
VOREINSTELLUNG,
|
||||||
|
einstellungSchema,
|
||||||
|
inRuhezeit,
|
||||||
|
meldungsSchluessel,
|
||||||
|
ruhezeitText,
|
||||||
|
sendenEntscheiden,
|
||||||
|
ueberblickFaellig,
|
||||||
|
zeitblockErinnerungFaellig,
|
||||||
|
type Einstellung,
|
||||||
|
type Meldung,
|
||||||
|
} from './benachrichtigung'
|
||||||
|
|
||||||
|
/** Dienstag, 17. März 2026. Berlin liegt dann auf UTC+1. */
|
||||||
|
function berlin(stunde: number, minute = 0): Date {
|
||||||
|
return new Date(Date.UTC(2026, 2, 17, stunde - 1, minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Samstag, 21. März 2026. */
|
||||||
|
function samstag(stunde: number): Date {
|
||||||
|
return new Date(Date.UTC(2026, 2, 21, stunde - 1, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const einstellung: Einstellung = {
|
||||||
|
...VOREINSTELLUNG,
|
||||||
|
anlaesse: ['tagesueberblick', 'faelligkeit', 'zeitblock'],
|
||||||
|
perPush: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function meldung(teil: Partial<Meldung> = {}): Meldung {
|
||||||
|
return {
|
||||||
|
anlass: 'faelligkeit',
|
||||||
|
schluessel: 'faelligkeit:a1:2026-03-17',
|
||||||
|
titel: 'Fällig heute',
|
||||||
|
text: 'Angebot schreiben',
|
||||||
|
pfad: '/aufgaben',
|
||||||
|
...teil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VOREINSTELLUNG', () => {
|
||||||
|
it('meldet ab Werk über keinen Weg', () => {
|
||||||
|
// Wer beim ersten Start überschüttet wird, schaltet alles ab.
|
||||||
|
expect(VOREINSTELLUNG.perPush).toBe(false)
|
||||||
|
expect(VOREINSTELLUNG.perMail).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hat eine Ruhezeit über Nacht', () => {
|
||||||
|
expect(VOREINSTELLUNG.ruheVon).toBe(20 * 60)
|
||||||
|
expect(VOREINSTELLUNG.ruheBis).toBe(7 * 60)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('inRuhezeit', () => {
|
||||||
|
it('erkennt die Nacht, obwohl sie über Mitternacht läuft', () => {
|
||||||
|
expect(inRuhezeit(einstellung, berlin(21))).toBe(true)
|
||||||
|
expect(inRuhezeit(einstellung, berlin(2))).toBe(true)
|
||||||
|
expect(inRuhezeit(einstellung, berlin(6, 59))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt den Tag frei', () => {
|
||||||
|
expect(inRuhezeit(einstellung, berlin(7))).toBe(false)
|
||||||
|
expect(inRuhezeit(einstellung, berlin(12))).toBe(false)
|
||||||
|
expect(inRuhezeit(einstellung, berlin(19, 59))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('beginnt pünktlich mit der Ruhe', () => {
|
||||||
|
expect(inRuhezeit(einstellung, berlin(20))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kennt auch eine Ruhezeit innerhalb eines Tages', () => {
|
||||||
|
const mittags: Einstellung = { ...einstellung, ruheVon: 12 * 60, ruheBis: 13 * 60 }
|
||||||
|
expect(inRuhezeit(mittags, berlin(12, 30))).toBe(true)
|
||||||
|
expect(inRuhezeit(mittags, berlin(11))).toBe(false)
|
||||||
|
expect(inRuhezeit(mittags, berlin(14))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deutet gleiche Zeiten als "keine Ruhezeit"', () => {
|
||||||
|
const ohne: Einstellung = { ...einstellung, ruheVon: 0, ruheBis: 0 }
|
||||||
|
expect(inRuhezeit(ohne, berlin(3))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schweigt das Wochenende, wenn gewünscht', () => {
|
||||||
|
const still: Einstellung = { ...einstellung, ruheAmWochenende: true }
|
||||||
|
expect(inRuhezeit(still, samstag(12))).toBe(true)
|
||||||
|
expect(inRuhezeit(still, berlin(12))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt das Wochenende sonst in Ruhe', () => {
|
||||||
|
expect(inRuhezeit(einstellung, samstag(12))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendenEntscheiden', () => {
|
||||||
|
const leer = new Set<string>()
|
||||||
|
|
||||||
|
it('lässt eine gewöhnliche Meldung durch', () => {
|
||||||
|
expect(sendenEntscheiden(einstellung, meldung(), leer, berlin(10))).toEqual({
|
||||||
|
senden: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schweigt bei abgeschaltetem Anlass', () => {
|
||||||
|
const ohne: Einstellung = { ...einstellung, anlaesse: ['tagesueberblick'] }
|
||||||
|
expect(sendenEntscheiden(ohne, meldung(), leer, berlin(10))).toEqual({
|
||||||
|
senden: false,
|
||||||
|
grund: 'abgeschaltet',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schweigt, wenn es keinen Weg hinaus gibt', () => {
|
||||||
|
const stumm: Einstellung = { ...einstellung, perPush: false, perMail: false }
|
||||||
|
expect(sendenEntscheiden(stumm, meldung(), leer, berlin(10))).toEqual({
|
||||||
|
senden: false,
|
||||||
|
grund: 'kein-weg',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schweigt in der Ruhezeit', () => {
|
||||||
|
expect(sendenEntscheiden(einstellung, meldung(), leer, berlin(22))).toEqual({
|
||||||
|
senden: false,
|
||||||
|
grund: 'ruhezeit',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet dieselbe Sache nicht zweimal', () => {
|
||||||
|
const gemeldet = new Set(['faelligkeit:a1:2026-03-17'])
|
||||||
|
expect(sendenEntscheiden(einstellung, meldung(), gemeldet, berlin(10))).toEqual({
|
||||||
|
senden: false,
|
||||||
|
grund: 'schon-gemeldet',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt den Tagesüberblick auch in der Ruhezeit hinaus', () => {
|
||||||
|
// Sonst käme er nie, wenn jemand früh aufsteht und die Ruhe bis 07:00 läuft.
|
||||||
|
const ueberblick = meldung({
|
||||||
|
anlass: 'tagesueberblick',
|
||||||
|
schluessel: 'tagesueberblick:tag:2026-03-17',
|
||||||
|
})
|
||||||
|
expect(sendenEntscheiden(einstellung, ueberblick, leer, berlin(6, 30))).toEqual({
|
||||||
|
senden: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prüft die Abschaltung vor der Ruhezeit', () => {
|
||||||
|
// Ein abgeschalteter Anlass ist abgeschaltet, nicht "gerade still".
|
||||||
|
const ohne: Einstellung = { ...einstellung, anlaesse: [] }
|
||||||
|
expect(sendenEntscheiden(ohne, meldung(), leer, berlin(22)).senden).toBe(false)
|
||||||
|
expect(
|
||||||
|
sendenEntscheiden(ohne, meldung(), leer, berlin(22)) as { grund: string },
|
||||||
|
).toMatchObject({ grund: 'abgeschaltet' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('meldungsSchluessel', () => {
|
||||||
|
it('trennt Anlass, Sache und Tag', () => {
|
||||||
|
expect(meldungsSchluessel('faelligkeit', 'a1', '2026-03-17')).toBe(
|
||||||
|
'faelligkeit:a1:2026-03-17',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erlaubt dieselbe Sache an einem anderen Tag erneut', () => {
|
||||||
|
// Eine Aufgabe, die heute fällig war, ist es morgen immer noch.
|
||||||
|
expect(meldungsSchluessel('faelligkeit', 'a1', '2026-03-17')).not.toBe(
|
||||||
|
meldungsSchluessel('faelligkeit', 'a1', '2026-03-18'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ueberblickFaellig', () => {
|
||||||
|
it('trifft das Zeitfenster', () => {
|
||||||
|
expect(ueberblickFaellig(einstellung, berlin(7))).toBe(true)
|
||||||
|
expect(ueberblickFaellig(einstellung, berlin(7, 14))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verpasst das Fenster davor und danach', () => {
|
||||||
|
expect(ueberblickFaellig(einstellung, berlin(6, 59))).toBe(false)
|
||||||
|
expect(ueberblickFaellig(einstellung, berlin(7, 15))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zeitblockErinnerungFaellig', () => {
|
||||||
|
const beginn = berlin(10)
|
||||||
|
|
||||||
|
it('erinnert im Vorlauffenster', () => {
|
||||||
|
// Vorlauf 10 Minuten, Toleranz 15: von 25 bis 10 Minuten vorher.
|
||||||
|
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(9, 55))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erinnert nicht zu früh', () => {
|
||||||
|
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(9, 30))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erinnert nicht mehr, wenn der Block schon läuft', () => {
|
||||||
|
expect(zeitblockErinnerungFaellig(einstellung, beginn, berlin(10, 5))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('einstellungSchema', () => {
|
||||||
|
const gueltig = {
|
||||||
|
anlaesse: ['tagesueberblick'],
|
||||||
|
perPush: 'true',
|
||||||
|
perMail: '',
|
||||||
|
ueberblickUm: '07:00',
|
||||||
|
ruheVon: '20:00',
|
||||||
|
ruheBis: '07:00',
|
||||||
|
ruheAmWochenende: '',
|
||||||
|
zeitblockVorlaufMinuten: '10',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('liest Uhrzeiten in Minuten um', () => {
|
||||||
|
const ergebnis = einstellungSchema.parse(gueltig)
|
||||||
|
expect(ergebnis.ueberblickUm).toBe(420)
|
||||||
|
expect(ergebnis.ruheVon).toBe(1200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist unsinnige Uhrzeiten ab', () => {
|
||||||
|
expect(einstellungSchema.safeParse({ ...gueltig, ruheVon: '25:00' }).success).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('begrenzt den Vorlauf', () => {
|
||||||
|
expect(
|
||||||
|
einstellungSchema.safeParse({ ...gueltig, zeitblockVorlaufMinuten: '121' }).success,
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
einstellungSchema.safeParse({ ...gueltig, zeitblockVorlaufMinuten: '120' }).success,
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist unbekannte Anlässe ab', () => {
|
||||||
|
expect(
|
||||||
|
einstellungSchema.safeParse({ ...gueltig, anlaesse: ['geburtstag'] }).success,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ruhezeitText', () => {
|
||||||
|
it('beschreibt die Nachtruhe', () => {
|
||||||
|
expect(ruhezeitText(einstellung)).toBe('Still von 20:00 bis 07:00.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nennt das Wochenende mit', () => {
|
||||||
|
expect(ruhezeitText({ ...einstellung, ruheAmWochenende: true })).toBe(
|
||||||
|
'Still von 20:00 bis 07:00 und am ganzen Wochenende.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sagt es auch, wenn es keine Ruhezeit gibt', () => {
|
||||||
|
expect(ruhezeitText({ ...einstellung, ruheVon: 0, ruheBis: 0 })).toBe(
|
||||||
|
'Keine Ruhezeit.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import {
|
||||||
|
alsUhrzeit,
|
||||||
|
ausUhrzeit,
|
||||||
|
minutenSeitMitternacht,
|
||||||
|
wochentagIndex,
|
||||||
|
} from '@/lib/zeit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regeln für Benachrichtigungen.
|
||||||
|
*
|
||||||
|
* Der heikle Teil ist nicht die Technik, sondern das Maß: Eine Anwendung, die
|
||||||
|
* zu oft meldet, wird stummgeschaltet und ist dann wertlos. Deshalb sind die
|
||||||
|
* Voreinstellungen zurückhaltend, es gibt Ruhezeiten, und dieselbe Sache wird
|
||||||
|
* nie zweimal gemeldet.
|
||||||
|
*
|
||||||
|
* Reine Logik ohne Datenbank und ohne Systemzeit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ANLAESSE = [
|
||||||
|
'tagesueberblick',
|
||||||
|
'faelligkeit',
|
||||||
|
'zeitblock',
|
||||||
|
'abgleichfehler',
|
||||||
|
] as const
|
||||||
|
export type Anlass = (typeof ANLAESSE)[number]
|
||||||
|
|
||||||
|
export const ANLASS_NAME: Record<Anlass, string> = {
|
||||||
|
tagesueberblick: 'Tagesüberblick',
|
||||||
|
faelligkeit: 'Fällige Aufgaben',
|
||||||
|
zeitblock: 'Gleich beginnt ein Zeitblock',
|
||||||
|
abgleichfehler: 'Ein Kalender lässt sich nicht abgleichen',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ANLASS_BESCHREIBUNG: Record<Anlass, string> = {
|
||||||
|
tagesueberblick:
|
||||||
|
'Einmal morgens: was heute ansteht, was überfällig ist, wie voll der Tag wird.',
|
||||||
|
faelligkeit:
|
||||||
|
'Wenn eine Aufgabe an diesem Tag fällig wird — höchstens einmal je Aufgabe.',
|
||||||
|
zeitblock: 'Kurz bevor ein eingeplanter Zeitblock beginnt.',
|
||||||
|
abgleichfehler:
|
||||||
|
'Wenn ein Kalender mehrfach hintereinander nicht erreichbar war. Nicht beim ersten Mal.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Einstellung = {
|
||||||
|
/** Welche Anlässe überhaupt melden. */
|
||||||
|
anlaesse: readonly Anlass[]
|
||||||
|
perPush: boolean
|
||||||
|
perMail: boolean
|
||||||
|
/** Uhrzeit des Tagesüberblicks, Minuten seit Mitternacht. */
|
||||||
|
ueberblickUm: number
|
||||||
|
/** Ruhezeit: von hier bis … nichts melden. */
|
||||||
|
ruheVon: number
|
||||||
|
ruheBis: number
|
||||||
|
/** Am Wochenende nichts melden. */
|
||||||
|
ruheAmWochenende: boolean
|
||||||
|
/** Vorlauf vor einem Zeitblock, in Minuten. */
|
||||||
|
zeitblockVorlaufMinuten: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voreinstellung: bewusst wenig.
|
||||||
|
*
|
||||||
|
* Wer mehr will, schaltet es zu. Wer beim ersten Start mit Meldungen
|
||||||
|
* überschüttet wird, schaltet alles ab — und dann auch das Nützliche.
|
||||||
|
*/
|
||||||
|
export const VOREINSTELLUNG: Einstellung = {
|
||||||
|
anlaesse: ['tagesueberblick', 'faelligkeit'],
|
||||||
|
perPush: false,
|
||||||
|
perMail: false,
|
||||||
|
ueberblickUm: 7 * 60,
|
||||||
|
ruheVon: 20 * 60,
|
||||||
|
ruheBis: 7 * 60,
|
||||||
|
ruheAmWochenende: false,
|
||||||
|
zeitblockVorlaufMinuten: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liegt der Zeitpunkt in der Ruhezeit?
|
||||||
|
*
|
||||||
|
* Die Ruhezeit läuft über Mitternacht hinweg — 20:00 bis 07:00 ist der
|
||||||
|
* Normalfall, nicht die Ausnahme.
|
||||||
|
*/
|
||||||
|
export function inRuhezeit(einstellung: Einstellung, zeitpunkt: Date): boolean {
|
||||||
|
if (einstellung.ruheAmWochenende && wochentagIndex(zeitpunkt) >= 5) return true
|
||||||
|
|
||||||
|
const minute = minutenSeitMitternacht(zeitpunkt)
|
||||||
|
const { ruheVon, ruheBis } = einstellung
|
||||||
|
|
||||||
|
// Gleiche Zeiten heißen: keine Ruhezeit.
|
||||||
|
if (ruheVon === ruheBis) return false
|
||||||
|
|
||||||
|
return ruheVon < ruheBis
|
||||||
|
? minute >= ruheVon && minute < ruheBis
|
||||||
|
: minute >= ruheVon || minute < ruheBis
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Meldung = {
|
||||||
|
anlass: Anlass
|
||||||
|
/** Eindeutig je Sache und Tag; verhindert doppelte Meldungen. */
|
||||||
|
schluessel: string
|
||||||
|
titel: string
|
||||||
|
text: string
|
||||||
|
/** Wohin der Klick führt. */
|
||||||
|
pfad: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soll diese Meldung jetzt hinaus?
|
||||||
|
*
|
||||||
|
* Drei Gründe, es zu lassen: der Anlass ist abgeschaltet, es ist Ruhezeit,
|
||||||
|
* oder genau diese Meldung ging schon einmal hinaus.
|
||||||
|
*/
|
||||||
|
export type Entscheidung =
|
||||||
|
| { senden: true }
|
||||||
|
| { senden: false; grund: 'abgeschaltet' | 'ruhezeit' | 'schon-gemeldet' | 'kein-weg' }
|
||||||
|
|
||||||
|
export function sendenEntscheiden(
|
||||||
|
einstellung: Einstellung,
|
||||||
|
meldung: Meldung,
|
||||||
|
bereitsGemeldet: ReadonlySet<string>,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Entscheidung {
|
||||||
|
if (!einstellung.anlaesse.includes(meldung.anlass)) {
|
||||||
|
return { senden: false, grund: 'abgeschaltet' }
|
||||||
|
}
|
||||||
|
if (!einstellung.perPush && !einstellung.perMail) {
|
||||||
|
return { senden: false, grund: 'kein-weg' }
|
||||||
|
}
|
||||||
|
if (bereitsGemeldet.has(meldung.schluessel)) {
|
||||||
|
return { senden: false, grund: 'schon-gemeldet' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Tagesüberblick kommt zu seiner festen Zeit; ihn wegen der Ruhezeit zu
|
||||||
|
// unterdrücken hieße, ihn nie zu schicken, wenn jemand früh aufsteht.
|
||||||
|
if (meldung.anlass !== 'tagesueberblick' && inRuhezeit(einstellung, zeitpunkt)) {
|
||||||
|
return { senden: false, grund: 'ruhezeit' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { senden: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schlüssel einer Meldung: Anlass, Sache und Tag.
|
||||||
|
*
|
||||||
|
* Der Tag gehört dazu, damit dieselbe Aufgabe morgen wieder gemeldet werden
|
||||||
|
* darf — sie ist dann ja immer noch fällig.
|
||||||
|
*/
|
||||||
|
export function meldungsSchluessel(
|
||||||
|
anlass: Anlass,
|
||||||
|
sache: string,
|
||||||
|
tagesschluessel: string,
|
||||||
|
): string {
|
||||||
|
return `${anlass}:${sache}:${tagesschluessel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist jetzt die Zeit für den Tagesüberblick? */
|
||||||
|
export function ueberblickFaellig(
|
||||||
|
einstellung: Einstellung,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
toleranzMinuten = 15,
|
||||||
|
): boolean {
|
||||||
|
const minute = minutenSeitMitternacht(zeitpunkt)
|
||||||
|
return (
|
||||||
|
minute >= einstellung.ueberblickUm &&
|
||||||
|
minute < einstellung.ueberblickUm + toleranzMinuten
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beginnt der Zeitblock bald genug für eine Erinnerung? */
|
||||||
|
export function zeitblockErinnerungFaellig(
|
||||||
|
einstellung: Einstellung,
|
||||||
|
beginn: Date,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
toleranzMinuten = 15,
|
||||||
|
): boolean {
|
||||||
|
const minutenBis = (beginn.getTime() - zeitpunkt.getTime()) / 60_000
|
||||||
|
return (
|
||||||
|
minutenBis <= einstellung.zeitblockVorlaufMinuten &&
|
||||||
|
minutenBis > einstellung.zeitblockVorlaufMinuten - toleranzMinuten
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ab wann ein Abgleichfehler gemeldet wird.
|
||||||
|
*
|
||||||
|
* Nicht beim ersten Mal: ein Netz hustet, ein Anbieter hat eine Wartung.
|
||||||
|
* Erst wenn es mehrfach hintereinander nicht klappt, ist es eine Nachricht
|
||||||
|
* wert.
|
||||||
|
*/
|
||||||
|
export const ABGLEICHFEHLER_AB_VERSUCH = 3
|
||||||
|
|
||||||
|
// --- Eingaben --------------------------------------------------------------
|
||||||
|
|
||||||
|
const uhrzeit = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((w) => ausUhrzeit(w) !== null, 'Bitte eine Uhrzeit wie 07:00 angeben.')
|
||||||
|
.transform((w) => ausUhrzeit(w) as number)
|
||||||
|
|
||||||
|
export const einstellungSchema = z.object({
|
||||||
|
anlaesse: z.array(z.enum(ANLAESSE)).default([]),
|
||||||
|
perPush: z.coerce.boolean().default(false),
|
||||||
|
perMail: z.coerce.boolean().default(false),
|
||||||
|
ueberblickUm: uhrzeit,
|
||||||
|
ruheVon: uhrzeit,
|
||||||
|
ruheBis: uhrzeit,
|
||||||
|
ruheAmWochenende: z.coerce.boolean().default(false),
|
||||||
|
zeitblockVorlaufMinuten: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0, 'Nicht weniger als null Minuten.')
|
||||||
|
.max(120, 'Mehr als zwei Stunden Vorlauf ist keine Erinnerung mehr.'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type EinstellungEingabe = z.infer<typeof einstellungSchema>
|
||||||
|
|
||||||
|
/** Lesbare Beschreibung der Ruhezeit für die Oberfläche. */
|
||||||
|
export function ruhezeitText(einstellung: Einstellung): string {
|
||||||
|
if (einstellung.ruheVon === einstellung.ruheBis) {
|
||||||
|
return einstellung.ruheAmWochenende
|
||||||
|
? 'Keine Ruhezeit, aber am Wochenende still.'
|
||||||
|
: 'Keine Ruhezeit.'
|
||||||
|
}
|
||||||
|
const zeiten = `${alsUhrzeit(einstellung.ruheVon)} bis ${alsUhrzeit(einstellung.ruheBis)}`
|
||||||
|
return einstellung.ruheAmWochenende
|
||||||
|
? `Still von ${zeiten} und am ganzen Wochenende.`
|
||||||
|
: `Still von ${zeiten}.`
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
ZEILE_HOEHE,
|
||||||
|
balkenlage,
|
||||||
|
fensterHuelle,
|
||||||
|
rasterBauen,
|
||||||
|
schluesselFuer,
|
||||||
|
spurenVerteilen,
|
||||||
|
zeilenBeschriftung,
|
||||||
|
zeitpunktAus,
|
||||||
|
} from './woche'
|
||||||
|
|
||||||
|
// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1.
|
||||||
|
const dienstag = new Date('2026-03-17T09:00:00Z')
|
||||||
|
|
||||||
|
describe('rasterBauen', () => {
|
||||||
|
it('beginnt am Montag und hat sieben Spalten', () => {
|
||||||
|
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||||
|
expect(raster.spalten).toHaveLength(7)
|
||||||
|
expect(raster.spalten[0]?.wochentag).toBe(0)
|
||||||
|
expect(raster.spalten[6]?.wochentag).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('legt den Montag auf den richtigen Tag', () => {
|
||||||
|
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||||
|
// Montag, 16. März, 00:00 Berlin = 23:00 UTC am 15.
|
||||||
|
expect(raster.spalten[0]?.tagesBeginn.toISOString()).toBe('2026-03-15T23:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('teilt das Fenster in Viertelstunden', () => {
|
||||||
|
const raster = rasterBauen(dienstag, 8 * 60, 10 * 60)
|
||||||
|
expect(raster.zeilen).toEqual([480, 495, 510, 525, 540, 555, 570, 585])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt die letzte Zeile vor dem Ende enden', () => {
|
||||||
|
const raster = rasterBauen(dienstag, 8 * 60, 9 * 60)
|
||||||
|
expect(raster.zeilen.at(-1)).toBe(525)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('schluesselFuer und zeitpunktAus', () => {
|
||||||
|
it('finden zurück zum Ausgangspunkt', () => {
|
||||||
|
const schluessel = schluesselFuer(dienstag)
|
||||||
|
// 09:00 Berlin am selben Tag.
|
||||||
|
expect(zeitpunktAus(schluessel, 9 * 60).toISOString()).toBe(
|
||||||
|
'2026-03-17T08:00:00.000Z',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('geben für denselben Tag denselben Schlüssel', () => {
|
||||||
|
expect(schluesselFuer(new Date('2026-03-17T06:00:00Z'))).toBe(
|
||||||
|
schluesselFuer(new Date('2026-03-17T20:00:00Z')),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fensterHuelle', () => {
|
||||||
|
it('umspannt alle Arbeitsfenster mit etwas Luft', () => {
|
||||||
|
const huelle = fensterHuelle([
|
||||||
|
{ arbeitsfensterVon: 480, arbeitsfensterBis: 1020 },
|
||||||
|
{ arbeitsfensterVon: 1020, arbeitsfensterBis: 1260 },
|
||||||
|
])
|
||||||
|
expect(huelle.von).toBe(420)
|
||||||
|
expect(huelle.bis).toBe(1320)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bleibt innerhalb eines Tages', () => {
|
||||||
|
const huelle = fensterHuelle([{ arbeitsfensterVon: 30, arbeitsfensterBis: 1410 }])
|
||||||
|
expect(huelle.von).toBe(0)
|
||||||
|
expect(huelle.bis).toBe(1440)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nimmt ohne Bereiche einen brauchbaren Standard', () => {
|
||||||
|
expect(fensterHuelle([])).toEqual({ von: 480, bis: 1080 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('balkenlage', () => {
|
||||||
|
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||||
|
const spalte = raster.spalten[1]!
|
||||||
|
|
||||||
|
it('setzt einen Block an die richtige Stelle', () => {
|
||||||
|
// 09:00 bis 10:00 Berlin.
|
||||||
|
const lage = balkenlage(
|
||||||
|
new Date('2026-03-17T08:00:00Z'),
|
||||||
|
new Date('2026-03-17T09:00:00Z'),
|
||||||
|
spalte,
|
||||||
|
raster,
|
||||||
|
)
|
||||||
|
// Eine Stunde nach Fensterbeginn = vier Zeilen.
|
||||||
|
expect(lage.oben).toBe(4 * ZEILE_HOEHE)
|
||||||
|
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||||
|
expect(lage.ausserhalb).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schneidet oben ab, was vor dem Fenster beginnt', () => {
|
||||||
|
// 07:00 bis 09:00 Berlin, Fenster beginnt um 08:00.
|
||||||
|
const lage = balkenlage(
|
||||||
|
new Date('2026-03-17T06:00:00Z'),
|
||||||
|
new Date('2026-03-17T08:00:00Z'),
|
||||||
|
spalte,
|
||||||
|
raster,
|
||||||
|
)
|
||||||
|
expect(lage.oben).toBe(0)
|
||||||
|
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schneidet unten ab, was über das Fenster hinausgeht', () => {
|
||||||
|
// 17:00 bis 20:00 Berlin, Fenster endet um 18:00.
|
||||||
|
const lage = balkenlage(
|
||||||
|
new Date('2026-03-17T16:00:00Z'),
|
||||||
|
new Date('2026-03-17T19:00:00Z'),
|
||||||
|
spalte,
|
||||||
|
raster,
|
||||||
|
)
|
||||||
|
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt einen Block ganz außerhalb des Fensters', () => {
|
||||||
|
// 22:00 bis 23:00 Berlin.
|
||||||
|
const lage = balkenlage(
|
||||||
|
new Date('2026-03-17T21:00:00Z'),
|
||||||
|
new Date('2026-03-17T22:00:00Z'),
|
||||||
|
spalte,
|
||||||
|
raster,
|
||||||
|
)
|
||||||
|
expect(lage.ausserhalb).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt auch dem kürzesten Block eine sichtbare Höhe', () => {
|
||||||
|
const lage = balkenlage(
|
||||||
|
new Date('2026-03-17T08:00:00Z'),
|
||||||
|
new Date('2026-03-17T08:15:00Z'),
|
||||||
|
spalte,
|
||||||
|
raster,
|
||||||
|
)
|
||||||
|
expect(lage.hoehe).toBeGreaterThanOrEqual(ZEILE_HOEHE)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zeilenBeschriftung', () => {
|
||||||
|
it('beschriftet nur volle Stunden', () => {
|
||||||
|
expect(zeilenBeschriftung(480)).toBe('08:00')
|
||||||
|
expect(zeilenBeschriftung(495)).toBeNull()
|
||||||
|
expect(zeilenBeschriftung(540)).toBe('09:00')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('spurenVerteilen', () => {
|
||||||
|
function eintrag(von: string, bis: string) {
|
||||||
|
return { beginn: new Date(von), ende: new Date(bis) }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('gibt einem einzelnen Eintrag die volle Breite', () => {
|
||||||
|
const verteilt = spurenVerteilen([
|
||||||
|
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||||
|
])
|
||||||
|
expect(verteilt[0]?.spur).toBe(0)
|
||||||
|
expect(verteilt[0]?.spuren).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('legt zwei sich überschneidende Einträge nebeneinander', () => {
|
||||||
|
const verteilt = spurenVerteilen([
|
||||||
|
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||||
|
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||||
|
])
|
||||||
|
expect(verteilt.map((v) => v.spur)).toEqual([0, 1])
|
||||||
|
expect(verteilt.every((v) => v.spuren === 2)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt getrennte Einträge beide die volle Breite behalten', () => {
|
||||||
|
const verteilt = spurenVerteilen([
|
||||||
|
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||||
|
eintrag('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'),
|
||||||
|
])
|
||||||
|
expect(verteilt.every((v) => v.spuren === 1)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt eine Spur wieder frei, sobald sie zu Ende ist', () => {
|
||||||
|
const verteilt = spurenVerteilen([
|
||||||
|
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||||
|
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||||
|
// Beginnt nach dem ersten, kann dessen Spur übernehmen.
|
||||||
|
eintrag('2026-03-17T10:00:00Z', '2026-03-17T12:00:00Z'),
|
||||||
|
])
|
||||||
|
expect(verteilt.map((v) => v.spur)).toEqual([0, 1, 0])
|
||||||
|
// Alle drei hängen über eine Kette zusammen und teilen sich zwei Spuren.
|
||||||
|
expect(verteilt.every((v) => v.spuren === 2)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trennt Gruppen, die sich nicht berühren', () => {
|
||||||
|
const verteilt = spurenVerteilen([
|
||||||
|
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||||
|
eintrag('2026-03-17T08:30:00Z', '2026-03-17T09:30:00Z'),
|
||||||
|
eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'),
|
||||||
|
])
|
||||||
|
expect(verteilt[0]?.spuren).toBe(2)
|
||||||
|
expect(verteilt[1]?.spuren).toBe(2)
|
||||||
|
expect(verteilt[2]?.spuren).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('behält jeden Eintrag genau einmal', () => {
|
||||||
|
const eintraege = [
|
||||||
|
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||||
|
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||||
|
eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'),
|
||||||
|
]
|
||||||
|
expect(spurenVerteilen(eintraege)).toHaveLength(eintraege.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { alsUhrzeit, tagesBeginn, wochenTage, wochentagIndex } from '@/lib/zeit'
|
||||||
|
import { RASTER_MINUTEN } from '@/lib/zeitblock'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufbau des Wochenrasters.
|
||||||
|
*
|
||||||
|
* Reine Logik: aus einem Zeitpunkt, einem Stundenfenster und einer Liste von
|
||||||
|
* Blöcken entsteht alles, was die Ansicht braucht — Spalten, Zeilen und die
|
||||||
|
* Lage jedes Blocks darin. Keine Datenbank, keine Systemzeit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Höhe einer Rasterzeile in Pixeln. 15 Minuten = 14 px. */
|
||||||
|
export const ZEILE_HOEHE = 14
|
||||||
|
|
||||||
|
export type Tagesspalte = {
|
||||||
|
/** 00:00 dieses Tages in Europe/Berlin. */
|
||||||
|
tagesBeginn: Date
|
||||||
|
/** 0 = Montag. */
|
||||||
|
wochentag: number
|
||||||
|
/** ISO-Datum, taugt als Schlüssel. */
|
||||||
|
schluessel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Raster = {
|
||||||
|
spalten: Tagesspalte[]
|
||||||
|
/** Minuten seit Mitternacht, an denen die Zeilen beginnen. */
|
||||||
|
zeilen: number[]
|
||||||
|
vonMinute: number
|
||||||
|
bisMinute: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut das Raster für die Woche, in der der Zeitpunkt liegt.
|
||||||
|
*
|
||||||
|
* Das Stundenfenster kommt von außen — sinnvollerweise als Hülle über die
|
||||||
|
* Arbeitsfenster aller Bereiche, damit nichts abgeschnitten wird, was
|
||||||
|
* geplant werden darf.
|
||||||
|
*/
|
||||||
|
export function rasterBauen(
|
||||||
|
zeitpunkt: Date,
|
||||||
|
vonMinute: number,
|
||||||
|
bisMinute: number,
|
||||||
|
): Raster {
|
||||||
|
const tage = wochenTage(zeitpunkt)
|
||||||
|
|
||||||
|
const zeilen: number[] = []
|
||||||
|
for (let minute = vonMinute; minute < bisMinute; minute += RASTER_MINUTEN) {
|
||||||
|
zeilen.push(minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
spalten: tage.map((tag) => ({
|
||||||
|
tagesBeginn: tag,
|
||||||
|
wochentag: wochentagIndex(tag),
|
||||||
|
schluessel: schluesselFuer(tag),
|
||||||
|
})),
|
||||||
|
zeilen,
|
||||||
|
vonMinute,
|
||||||
|
bisMinute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schlüssel eines Tages im Raster, unabhängig von der Zeitzone lesbar. */
|
||||||
|
export function schluesselFuer(tag: Date): string {
|
||||||
|
const beginn = tagesBeginn(tag)
|
||||||
|
return String(beginn.getTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeitpunkt aus Tagesschlüssel und Minute seit Mitternacht. */
|
||||||
|
export function zeitpunktAus(schluessel: string, minute: number): Date {
|
||||||
|
return new Date(Number(schluessel) + minute * 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hülle über alle Arbeitsfenster, mit etwas Luft nach oben und unten.
|
||||||
|
*
|
||||||
|
* Ohne die Luft läge ein Block, der genau am Rand beginnt, an der Kante des
|
||||||
|
* Rasters und ließe sich kaum greifen.
|
||||||
|
*/
|
||||||
|
export function fensterHuelle(
|
||||||
|
bereiche: readonly { arbeitsfensterVon: number; arbeitsfensterBis: number }[],
|
||||||
|
luftMinuten = 60,
|
||||||
|
): { von: number; bis: number } {
|
||||||
|
if (bereiche.length === 0) return { von: 8 * 60, bis: 18 * 60 }
|
||||||
|
|
||||||
|
const von = Math.min(...bereiche.map((b) => b.arbeitsfensterVon))
|
||||||
|
const bis = Math.max(...bereiche.map((b) => b.arbeitsfensterBis))
|
||||||
|
|
||||||
|
return {
|
||||||
|
von: Math.max(0, aufRasterMinute(von - luftMinuten)),
|
||||||
|
bis: Math.min(24 * 60, aufRasterMinute(bis + luftMinuten)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aufRasterMinute(minute: number): number {
|
||||||
|
return Math.round(minute / RASTER_MINUTEN) * RASTER_MINUTEN
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Balkenlage = {
|
||||||
|
/** Abstand von oben in Pixeln. */
|
||||||
|
oben: number
|
||||||
|
/** Höhe in Pixeln. */
|
||||||
|
hoehe: number
|
||||||
|
/** Liegt der Block ganz außerhalb des sichtbaren Fensters? */
|
||||||
|
ausserhalb: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wo ein Zeitraum im Raster eines Tages liegt.
|
||||||
|
*
|
||||||
|
* Ragt er über den sichtbaren Bereich hinaus, wird er abgeschnitten — sonst
|
||||||
|
* würde ein Termin von 6 bis 23 Uhr das ganze Raster sprengen.
|
||||||
|
*/
|
||||||
|
export function balkenlage(
|
||||||
|
beginn: Date,
|
||||||
|
ende: Date,
|
||||||
|
spalte: Tagesspalte,
|
||||||
|
raster: Raster,
|
||||||
|
): Balkenlage {
|
||||||
|
const tagBeginn = spalte.tagesBeginn.getTime()
|
||||||
|
const vonMinute = (beginn.getTime() - tagBeginn) / 60_000
|
||||||
|
const bisMinute = (ende.getTime() - tagBeginn) / 60_000
|
||||||
|
|
||||||
|
if (bisMinute <= raster.vonMinute || vonMinute >= raster.bisMinute) {
|
||||||
|
return { oben: 0, hoehe: 0, ausserhalb: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sichtbarVon = Math.max(vonMinute, raster.vonMinute)
|
||||||
|
const sichtbarBis = Math.min(bisMinute, raster.bisMinute)
|
||||||
|
|
||||||
|
return {
|
||||||
|
oben: ((sichtbarVon - raster.vonMinute) / RASTER_MINUTEN) * ZEILE_HOEHE,
|
||||||
|
hoehe: Math.max(
|
||||||
|
ZEILE_HOEHE,
|
||||||
|
((sichtbarBis - sichtbarVon) / RASTER_MINUTEN) * ZEILE_HOEHE,
|
||||||
|
),
|
||||||
|
ausserhalb: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beschriftung einer Rasterzeile; nur volle Stunden bekommen eine. */
|
||||||
|
export function zeilenBeschriftung(minute: number): string | null {
|
||||||
|
return minute % 60 === 0 ? alsUhrzeit(minute) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verteilt sich überschneidende Einträge auf Spuren nebeneinander.
|
||||||
|
*
|
||||||
|
* Ohne das läge ein Termin genau über dem anderen und einer wäre unsichtbar.
|
||||||
|
*/
|
||||||
|
export function spurenVerteilen<T extends { beginn: Date; ende: Date }>(
|
||||||
|
eintraege: readonly T[],
|
||||||
|
): { eintrag: T; spur: number; spuren: number }[] {
|
||||||
|
const sortiert = [...eintraege].sort((a, b) => a.beginn.getTime() - b.beginn.getTime())
|
||||||
|
|
||||||
|
// Gruppen bilden: alles, was sich über eine Kette von Überschneidungen
|
||||||
|
// berührt, teilt sich dieselbe Anzahl Spuren.
|
||||||
|
const gruppen: T[][] = []
|
||||||
|
let aktuelle: T[] = []
|
||||||
|
let gruppenEnde = 0
|
||||||
|
|
||||||
|
for (const eintrag of sortiert) {
|
||||||
|
if (aktuelle.length > 0 && eintrag.beginn.getTime() >= gruppenEnde) {
|
||||||
|
gruppen.push(aktuelle)
|
||||||
|
aktuelle = []
|
||||||
|
}
|
||||||
|
aktuelle.push(eintrag)
|
||||||
|
gruppenEnde = Math.max(gruppenEnde, eintrag.ende.getTime())
|
||||||
|
}
|
||||||
|
if (aktuelle.length > 0) gruppen.push(aktuelle)
|
||||||
|
|
||||||
|
const ergebnis: { eintrag: T; spur: number; spuren: number }[] = []
|
||||||
|
|
||||||
|
for (const gruppe of gruppen) {
|
||||||
|
const spurEnden: number[] = []
|
||||||
|
const zuordnung: { eintrag: T; spur: number }[] = []
|
||||||
|
|
||||||
|
for (const eintrag of gruppe) {
|
||||||
|
let spur = spurEnden.findIndex((ende) => ende <= eintrag.beginn.getTime())
|
||||||
|
if (spur === -1) {
|
||||||
|
spur = spurEnden.length
|
||||||
|
spurEnden.push(0)
|
||||||
|
}
|
||||||
|
spurEnden[spur] = eintrag.ende.getTime()
|
||||||
|
zuordnung.push({ eintrag, spur })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { eintrag, spur } of zuordnung) {
|
||||||
|
ergebnis.push({ eintrag, spur, spuren: spurEnden.length })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
RASTER_MINUTEN,
|
||||||
|
ZEITBLOCK_MAX_MINUTEN,
|
||||||
|
ZEITBLOCK_MIN_MINUTEN,
|
||||||
|
abgleichEntscheiden,
|
||||||
|
aufRaster,
|
||||||
|
dauerMinuten,
|
||||||
|
lagePruefen,
|
||||||
|
mussSchreiben,
|
||||||
|
ueberschneidetSich,
|
||||||
|
zeitblockSchema,
|
||||||
|
zeitraumHash,
|
||||||
|
} from './zeitblock'
|
||||||
|
|
||||||
|
const beginn = new Date('2026-03-17T09:00:00Z')
|
||||||
|
const ende = new Date('2026-03-17T10:00:00Z')
|
||||||
|
|
||||||
|
describe('zeitraumHash', () => {
|
||||||
|
it('ist für denselben Zeitraum gleich', () => {
|
||||||
|
expect(zeitraumHash(beginn, ende)).toBe(
|
||||||
|
zeitraumHash(new Date(beginn), new Date(ende)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ändert sich, wenn der Beginn wandert', () => {
|
||||||
|
expect(zeitraumHash(beginn, ende)).not.toBe(
|
||||||
|
zeitraumHash(new Date('2026-03-17T09:15:00Z'), ende),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ändert sich, wenn das Ende wandert', () => {
|
||||||
|
expect(zeitraumHash(beginn, ende)).not.toBe(
|
||||||
|
zeitraumHash(beginn, new Date('2026-03-17T10:30:00Z')),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ist unabhängig davon, wie der Zeitpunkt entstanden ist', () => {
|
||||||
|
// Derselbe Augenblick, anders geschrieben.
|
||||||
|
expect(zeitraumHash(new Date('2026-03-17T09:00:00.000Z'), ende)).toBe(
|
||||||
|
zeitraumHash(new Date('2026-03-17T10:00:00+01:00'), ende),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('abgleichEntscheiden — der Schleifenschutz', () => {
|
||||||
|
it('tut nichts, wenn im Kalender genau das steht, was wir geschrieben haben', () => {
|
||||||
|
// Das ist der Fall, der die Schleife bricht: der Abgleich liest die
|
||||||
|
// eigene Änderung und erkennt sie am Fingerabdruck wieder.
|
||||||
|
const entscheidung = abgleichEntscheiden(
|
||||||
|
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||||
|
{ beginn, ende },
|
||||||
|
)
|
||||||
|
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'eigene-aenderung' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tut auch ohne Fingerabdruck nichts, wenn beide Seiten übereinstimmen', () => {
|
||||||
|
const entscheidung = abgleichEntscheiden(
|
||||||
|
{ hash: null, beginn, ende },
|
||||||
|
{ beginn, ende },
|
||||||
|
)
|
||||||
|
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('übernimmt eine fremde Verschiebung — der Kalender gewinnt', () => {
|
||||||
|
const verschobenerBeginn = new Date('2026-03-17T11:00:00Z')
|
||||||
|
const verschobenesEnde = new Date('2026-03-17T12:00:00Z')
|
||||||
|
|
||||||
|
const entscheidung = abgleichEntscheiden(
|
||||||
|
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||||
|
{ beginn: verschobenerBeginn, ende: verschobenesEnde },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(entscheidung).toEqual({
|
||||||
|
tun: 'uebernehmen',
|
||||||
|
grund: 'fremd-verschoben',
|
||||||
|
beginn: verschobenerBeginn,
|
||||||
|
ende: verschobenesEnde,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt eine fremde Verschiebung auch ohne vorherigen Fingerabdruck', () => {
|
||||||
|
const entscheidung = abgleichEntscheiden(
|
||||||
|
{ hash: null, beginn, ende },
|
||||||
|
{
|
||||||
|
beginn: new Date('2026-03-17T11:00:00Z'),
|
||||||
|
ende: new Date('2026-03-17T12:00:00Z'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(entscheidung.tun).toBe('uebernehmen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('löst den Zeitblock, wenn der Termin im Kalender verschwunden ist', () => {
|
||||||
|
const entscheidung = abgleichEntscheiden(
|
||||||
|
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
expect(entscheidung).toEqual({ tun: 'loesen', grund: 'im-kalender-entfernt' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schaukelt sich über mehrere Runden nicht hoch', () => {
|
||||||
|
// Die App schreibt, der Abgleich läuft dreimal – und tut jedes Mal nichts.
|
||||||
|
let stand = { hash: zeitraumHash(beginn, ende), beginn, ende }
|
||||||
|
for (let runde = 0; runde < 3; runde++) {
|
||||||
|
const entscheidung = abgleichEntscheiden(stand, {
|
||||||
|
beginn: stand.beginn,
|
||||||
|
ende: stand.ende,
|
||||||
|
})
|
||||||
|
expect(entscheidung.tun).toBe('nichts')
|
||||||
|
stand = { ...stand }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kommt nach einer übernommenen Verschiebung zur Ruhe', () => {
|
||||||
|
const neuerBeginn = new Date('2026-03-17T11:00:00Z')
|
||||||
|
const neuesEnde = new Date('2026-03-17T12:00:00Z')
|
||||||
|
|
||||||
|
// Erste Runde: fremde Verschiebung wird übernommen.
|
||||||
|
const erste = abgleichEntscheiden(
|
||||||
|
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||||
|
{ beginn: neuerBeginn, ende: neuesEnde },
|
||||||
|
)
|
||||||
|
expect(erste.tun).toBe('uebernehmen')
|
||||||
|
|
||||||
|
// Danach steht der neue Stand samt Fingerabdruck; die zweite Runde
|
||||||
|
// erkennt ihn wieder.
|
||||||
|
const zweite = abgleichEntscheiden(
|
||||||
|
{
|
||||||
|
hash: zeitraumHash(neuerBeginn, neuesEnde),
|
||||||
|
beginn: neuerBeginn,
|
||||||
|
ende: neuesEnde,
|
||||||
|
},
|
||||||
|
{ beginn: neuerBeginn, ende: neuesEnde },
|
||||||
|
)
|
||||||
|
expect(zweite.tun).toBe('nichts')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mussSchreiben', () => {
|
||||||
|
it('schreibt bei einer echten Verschiebung', () => {
|
||||||
|
expect(
|
||||||
|
mussSchreiben(
|
||||||
|
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||||
|
new Date('2026-03-17T11:00:00Z'),
|
||||||
|
new Date('2026-03-17T12:00:00Z'),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schreibt nicht, wenn der Zeitraum gleich bleibt', () => {
|
||||||
|
// Etwa wenn nur der Aufgabentitel geändert wurde.
|
||||||
|
expect(
|
||||||
|
mussSchreiben({ hash: zeitraumHash(beginn, ende), beginn, ende }, beginn, ende),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schreibt, solange noch nichts geschrieben wurde', () => {
|
||||||
|
expect(mussSchreiben({ hash: null, beginn, ende }, beginn, ende)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dauerMinuten', () => {
|
||||||
|
it('rechnet in Minuten', () => {
|
||||||
|
expect(dauerMinuten(beginn, ende)).toBe(60)
|
||||||
|
expect(dauerMinuten(beginn, new Date('2026-03-17T09:45:00Z'))).toBe(45)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('aufRaster', () => {
|
||||||
|
it('rastet auf die nächste Viertelstunde ein', () => {
|
||||||
|
expect(aufRaster(new Date('2026-03-17T09:07:00Z')).toISOString()).toBe(
|
||||||
|
'2026-03-17T09:00:00.000Z',
|
||||||
|
)
|
||||||
|
expect(aufRaster(new Date('2026-03-17T09:08:00Z')).toISOString()).toBe(
|
||||||
|
'2026-03-17T09:15:00.000Z',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lässt einen bereits eingerasteten Zeitpunkt in Ruhe', () => {
|
||||||
|
expect(aufRaster(beginn).toISOString()).toBe(beginn.toISOString())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lagePruefen', () => {
|
||||||
|
it('nimmt eine gültige Lage an', () => {
|
||||||
|
expect(lagePruefen(beginn, ende)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist ein Ende vor dem Beginn ab', () => {
|
||||||
|
expect(lagePruefen(ende, beginn)).toBe('ende-vor-beginn')
|
||||||
|
expect(lagePruefen(beginn, beginn)).toBe('ende-vor-beginn')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist zu kurze Blöcke ab und nimmt die Untergrenze an', () => {
|
||||||
|
const knappZuKurz = new Date(beginn.getTime() + (ZEITBLOCK_MIN_MINUTEN - 15) * 60_000)
|
||||||
|
expect(lagePruefen(beginn, knappZuKurz)).toBe('ende-vor-beginn')
|
||||||
|
|
||||||
|
const genauMinimum = new Date(beginn.getTime() + ZEITBLOCK_MIN_MINUTEN * 60_000)
|
||||||
|
expect(lagePruefen(beginn, genauMinimum)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist zu lange Blöcke ab und nimmt die Obergrenze an', () => {
|
||||||
|
const genauMaximum = new Date(beginn.getTime() + ZEITBLOCK_MAX_MINUTEN * 60_000)
|
||||||
|
expect(lagePruefen(beginn, genauMaximum)).toBeNull()
|
||||||
|
|
||||||
|
const zuLang = new Date(
|
||||||
|
beginn.getTime() + (ZEITBLOCK_MAX_MINUTEN + RASTER_MINUTEN) * 60_000,
|
||||||
|
)
|
||||||
|
expect(lagePruefen(beginn, zuLang)).toBe('zu-lang')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist Zeiten außerhalb des Rasters ab', () => {
|
||||||
|
expect(lagePruefen(new Date('2026-03-17T09:07:00Z'), ende)).toBe('nicht-im-raster')
|
||||||
|
expect(lagePruefen(beginn, new Date('2026-03-17T10:07:00Z'))).toBe('nicht-im-raster')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ueberschneidetSich', () => {
|
||||||
|
const block = { beginn, ende }
|
||||||
|
|
||||||
|
it('erkennt eine Überschneidung', () => {
|
||||||
|
expect(
|
||||||
|
ueberschneidetSich(block, {
|
||||||
|
beginn: new Date('2026-03-17T09:30:00Z'),
|
||||||
|
ende: new Date('2026-03-17T10:30:00Z'),
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zählt reines Aneinandergrenzen nicht', () => {
|
||||||
|
expect(
|
||||||
|
ueberschneidetSich(block, {
|
||||||
|
beginn: ende,
|
||||||
|
ende: new Date('2026-03-17T11:00:00Z'),
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt vollständige Umschließung', () => {
|
||||||
|
expect(
|
||||||
|
ueberschneidetSich(block, {
|
||||||
|
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||||
|
ende: new Date('2026-03-17T12:00:00Z'),
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt getrennte Zeiträume', () => {
|
||||||
|
expect(
|
||||||
|
ueberschneidetSich(block, {
|
||||||
|
beginn: new Date('2026-03-17T14:00:00Z'),
|
||||||
|
ende: new Date('2026-03-17T15:00:00Z'),
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('zeitblockSchema', () => {
|
||||||
|
const gueltig = {
|
||||||
|
aufgabeId: 'a1',
|
||||||
|
kalenderkontoId: 'k1',
|
||||||
|
beginn: '2026-03-17T09:00:00.000Z',
|
||||||
|
ende: '2026-03-17T10:00:00.000Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('nimmt eine gültige Eingabe an', () => {
|
||||||
|
const ergebnis = zeitblockSchema.parse(gueltig)
|
||||||
|
expect(ergebnis.beginn).toBeInstanceOf(Date)
|
||||||
|
expect(dauerMinuten(ergebnis.beginn, ergebnis.ende)).toBe(60)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist eine unmögliche Lage ab', () => {
|
||||||
|
expect(
|
||||||
|
zeitblockSchema.safeParse({ ...gueltig, ende: '2026-03-17T08:00:00.000Z' }).success,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist Zeiten außerhalb des Rasters ab', () => {
|
||||||
|
expect(
|
||||||
|
zeitblockSchema.safeParse({ ...gueltig, beginn: '2026-03-17T09:07:00.000Z' })
|
||||||
|
.success,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verlangt Aufgabe und Kalenderkonto', () => {
|
||||||
|
expect(zeitblockSchema.safeParse({ ...gueltig, aufgabeId: '' }).success).toBe(false)
|
||||||
|
expect(zeitblockSchema.safeParse({ ...gueltig, kalenderkontoId: '' }).success).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeitblöcke und der Schleifenschutz.
|
||||||
|
*
|
||||||
|
* Ein Zeitblock ist eine Aufgabe, die zu einer bestimmten Zeit in einem
|
||||||
|
* Kalender steht. Er lebt an zwei Orten: hier und im Kalender des Anbieters.
|
||||||
|
* Beide Seiten dürfen ihn verschieben — und genau daraus entsteht die Gefahr
|
||||||
|
* einer Endlosschleife:
|
||||||
|
*
|
||||||
|
* Die App schreibt → der Abgleich liest die eigene Änderung → hält sie für
|
||||||
|
* fremd → schreibt zurück → der Abgleich liest wieder → …
|
||||||
|
*
|
||||||
|
* Der Ausweg ist ein Fingerabdruck über den Zeitraum. Beim Schreiben merkt
|
||||||
|
* sich der Zeitblock, was er in den Kalender gelegt hat. Kommt beim nächsten
|
||||||
|
* Abgleich derselbe Fingerabdruck zurück, war es die eigene Änderung und es
|
||||||
|
* passiert nichts. Weicht er ab, hat jemand anderes verschoben — dann gewinnt
|
||||||
|
* der Kalender.
|
||||||
|
*
|
||||||
|
* Reine Logik ohne Datenbank und ohne Systemzeit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerabdruck eines Zeitraums.
|
||||||
|
*
|
||||||
|
* Bewusst nur über Beginn und Ende: Titel und Beschreibung ändern sich auch
|
||||||
|
* aus harmlosen Gründen (etwa weil der Aufgabentitel bearbeitet wurde), und
|
||||||
|
* für die Planung zählt allein, wann der Block liegt.
|
||||||
|
*/
|
||||||
|
export function zeitraumHash(beginn: Date, ende: Date): string {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(`${beginn.toISOString()}|${ende.toISOString()}`)
|
||||||
|
.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was beim Abgleich mit einem Zeitblock zu tun ist. */
|
||||||
|
export type Abgleichentscheidung =
|
||||||
|
| { tun: 'nichts'; grund: 'unveraendert' | 'eigene-aenderung' }
|
||||||
|
| { tun: 'uebernehmen'; grund: 'fremd-verschoben'; beginn: Date; ende: Date }
|
||||||
|
| { tun: 'loesen'; grund: 'im-kalender-entfernt' }
|
||||||
|
|
||||||
|
export type ZeitblockStand = {
|
||||||
|
/** Was zuletzt in den Kalender geschrieben wurde. */
|
||||||
|
hash: string | null
|
||||||
|
beginn: Date
|
||||||
|
ende: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KalenderStand = {
|
||||||
|
beginn: Date
|
||||||
|
ende: Date
|
||||||
|
} | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entscheidet, was beim Abgleich mit einem Zeitblock geschehen soll.
|
||||||
|
*
|
||||||
|
* Der Kalender gewinnt: Wurde ein Block dort verschoben, übernimmt der Planer
|
||||||
|
* das, statt zurückzuschreiben. Wer im Kalender etwas verschiebt, meint es so
|
||||||
|
* — und merkt es sofort, wenn der Planer es wieder wegzieht.
|
||||||
|
*/
|
||||||
|
export function abgleichEntscheiden(
|
||||||
|
zeitblock: ZeitblockStand,
|
||||||
|
imKalender: KalenderStand,
|
||||||
|
): Abgleichentscheidung {
|
||||||
|
if (!imKalender) {
|
||||||
|
// Im Kalender gelöscht. Der Zeitblock verliert seine Verankerung; die
|
||||||
|
// Aufgabe selbst bleibt bestehen.
|
||||||
|
return { tun: 'loesen', grund: 'im-kalender-entfernt' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const imKalenderHash = zeitraumHash(imKalender.beginn, imKalender.ende)
|
||||||
|
|
||||||
|
// Steht im Kalender genau das, was wir zuletzt geschrieben haben, war es
|
||||||
|
// die eigene Änderung – nichts zu tun. Das bricht die Schleife.
|
||||||
|
if (zeitblock.hash !== null && imKalenderHash === zeitblock.hash) {
|
||||||
|
return { tun: 'nichts', grund: 'eigene-aenderung' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stimmt der Kalender ohnehin mit unserem Stand überein, ist auch nichts
|
||||||
|
// zu tun – etwa beim allerersten Abgleich nach dem Anlegen.
|
||||||
|
const eigenerHash = zeitraumHash(zeitblock.beginn, zeitblock.ende)
|
||||||
|
if (imKalenderHash === eigenerHash) {
|
||||||
|
return { tun: 'nichts', grund: 'unveraendert' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tun: 'uebernehmen',
|
||||||
|
grund: 'fremd-verschoben',
|
||||||
|
beginn: imKalender.beginn,
|
||||||
|
ende: imKalender.ende,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Muss beim Speichern in den Kalender geschrieben werden?
|
||||||
|
*
|
||||||
|
* Nur bei einer echten Verschiebung. Wird bloß der Aufgabentitel geändert,
|
||||||
|
* bleibt der Fingerabdruck gleich und es gibt keinen Grund, den Anbieter zu
|
||||||
|
* behelligen.
|
||||||
|
*/
|
||||||
|
export function mussSchreiben(
|
||||||
|
zeitblock: ZeitblockStand,
|
||||||
|
neuerBeginn: Date,
|
||||||
|
neuesEnde: Date,
|
||||||
|
): boolean {
|
||||||
|
return zeitblock.hash !== zeitraumHash(neuerBeginn, neuesEnde)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lage und Dauer --------------------------------------------------------
|
||||||
|
|
||||||
|
export const ZEITBLOCK_MIN_MINUTEN = 15
|
||||||
|
export const ZEITBLOCK_MAX_MINUTEN = 8 * 60
|
||||||
|
/** Raster, in dem Zeitblöcke einrasten. */
|
||||||
|
export const RASTER_MINUTEN = 15
|
||||||
|
|
||||||
|
export function dauerMinuten(beginn: Date, ende: Date): number {
|
||||||
|
return Math.round((ende.getTime() - beginn.getTime()) / 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rundet einen Zeitpunkt auf das Raster ab. */
|
||||||
|
export function aufRaster(zeitpunkt: Date, raster = RASTER_MINUTEN): Date {
|
||||||
|
const minuten = raster * 60_000
|
||||||
|
return new Date(Math.round(zeitpunkt.getTime() / minuten) * minuten)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LageFehler =
|
||||||
|
'zu-kurz' | 'zu-lang' | 'ende-vor-beginn' | 'nicht-im-raster' | null
|
||||||
|
|
||||||
|
/** Prüft die Lage eines Zeitblocks. Gibt null zurück, wenn sie stimmt. */
|
||||||
|
export function lagePruefen(beginn: Date, ende: Date): LageFehler {
|
||||||
|
if (ende.getTime() <= beginn.getTime()) return 'ende-vor-beginn'
|
||||||
|
|
||||||
|
const dauer = dauerMinuten(beginn, ende)
|
||||||
|
if (dauer < ZEITBLOCK_MIN_MINUTEN) return 'zu-kurz'
|
||||||
|
if (dauer > ZEITBLOCK_MAX_MINUTEN) return 'zu-lang'
|
||||||
|
|
||||||
|
if (
|
||||||
|
beginn.getTime() % (RASTER_MINUTEN * 60_000) !== 0 ||
|
||||||
|
ende.getTime() % (RASTER_MINUTEN * 60_000) !== 0
|
||||||
|
) {
|
||||||
|
return 'nicht-im-raster'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LAGE_FEHLER_TEXT: Record<Exclude<LageFehler, null>, string> = {
|
||||||
|
'zu-kurz': `Ein Zeitblock dauert mindestens ${ZEITBLOCK_MIN_MINUTEN} Minuten.`,
|
||||||
|
'zu-lang': 'Mehr als acht Stunden am Stück gehört in mehrere Zeitblöcke.',
|
||||||
|
'ende-vor-beginn': 'Das Ende muss nach dem Beginn liegen.',
|
||||||
|
'nicht-im-raster': `Zeitblöcke rasten in ${RASTER_MINUTEN}-Minuten-Schritten ein.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Überschneiden sich zwei Zeitblöcke? Aneinandergrenzen zählt nicht. */
|
||||||
|
export function ueberschneidetSich(
|
||||||
|
a: { beginn: Date; ende: Date },
|
||||||
|
b: { beginn: Date; ende: Date },
|
||||||
|
): boolean {
|
||||||
|
return a.beginn.getTime() < b.ende.getTime() && b.beginn.getTime() < a.ende.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Eingaben --------------------------------------------------------------
|
||||||
|
|
||||||
|
export const zeitblockSchema = z
|
||||||
|
.object({
|
||||||
|
aufgabeId: z.string().min(1),
|
||||||
|
kalenderkontoId: z.string().min(1),
|
||||||
|
beginn: z.coerce.date('Bitte einen gültigen Beginn angeben.'),
|
||||||
|
ende: z.coerce.date('Bitte ein gültiges Ende angeben.'),
|
||||||
|
})
|
||||||
|
.refine((w) => lagePruefen(w.beginn, w.ende) === null, {
|
||||||
|
path: ['ende'],
|
||||||
|
message: 'Die Lage des Zeitblocks stimmt nicht.',
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ZeitblockEingabe = z.infer<typeof zeitblockSchema>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import type { ZeigtAls } from '@/generated/prisma/enums'
|
||||||
|
import { tagesBeginn, wochenBeginn } from '@/lib/zeit'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alles, was die Wochenansicht braucht — in einem Zug geladen.
|
||||||
|
*
|
||||||
|
* Die Zeitblöcke des Benutzers und die Termine seiner Kalender kommen
|
||||||
|
* getrennt zurück: ein Zeitblock ist eigene Planung und lässt sich
|
||||||
|
* verschieben, ein Termin ist fremd und wird nur angezeigt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type WochenZeitblock = {
|
||||||
|
id: string
|
||||||
|
beginn: Date
|
||||||
|
ende: Date
|
||||||
|
aufgabeId: string
|
||||||
|
titel: string
|
||||||
|
bereichName: string | null
|
||||||
|
bereichFarbe: string | null
|
||||||
|
kalenderName: string
|
||||||
|
letzterFehler: string | null
|
||||||
|
/** Noch nicht in den Kalender geschrieben. */
|
||||||
|
offen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WochenTermin = {
|
||||||
|
id: string
|
||||||
|
beginn: Date
|
||||||
|
ende: Date
|
||||||
|
betreff: string | null
|
||||||
|
ganztaegig: boolean
|
||||||
|
zeigtAls: ZeigtAls
|
||||||
|
kalenderName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WocheDaten = {
|
||||||
|
zeitbloecke: WochenZeitblock[]
|
||||||
|
termine: WochenTermin[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beginn und Ende der Woche, in der der Zeitpunkt liegt. */
|
||||||
|
export function wochenFenster(zeitpunkt: Date): { von: Date; bis: Date } {
|
||||||
|
const montag = wochenBeginn(zeitpunkt)
|
||||||
|
return {
|
||||||
|
von: montag,
|
||||||
|
bis: new Date(tagesBeginn(new Date(montag.getTime() + 7 * 86_400_000)).getTime()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wocheLaden(
|
||||||
|
benutzerId: string,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Promise<WocheDaten> {
|
||||||
|
const fenster = wochenFenster(zeitpunkt)
|
||||||
|
|
||||||
|
const [zeitbloecke, termine] = await Promise.all([
|
||||||
|
db.zeitblock.findMany({
|
||||||
|
where: {
|
||||||
|
kalenderkonto: { benutzerId },
|
||||||
|
beginn: { lt: fenster.bis },
|
||||||
|
ende: { gt: fenster.von },
|
||||||
|
},
|
||||||
|
orderBy: { beginn: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
beginn: true,
|
||||||
|
ende: true,
|
||||||
|
extId: true,
|
||||||
|
letzterFehler: true,
|
||||||
|
aufgabe: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
titel: true,
|
||||||
|
bereich: { select: { name: true, farbe: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kalenderkonto: { select: { anzeigename: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.termin.findMany({
|
||||||
|
where: {
|
||||||
|
kalenderkonto: { benutzerId, aktiv: true },
|
||||||
|
beginn: { lt: fenster.bis },
|
||||||
|
ende: { gt: fenster.von },
|
||||||
|
},
|
||||||
|
orderBy: { beginn: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
extId: true,
|
||||||
|
beginn: true,
|
||||||
|
ende: true,
|
||||||
|
betreff: true,
|
||||||
|
ganztaegig: true,
|
||||||
|
zeigtAls: true,
|
||||||
|
kalenderkonto: { select: { anzeigename: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Ein Zeitblock steht auch im Kalender und damit im Spiegel. Er würde sonst
|
||||||
|
// doppelt erscheinen — einmal als eigene Planung, einmal als fremder Termin.
|
||||||
|
const eigeneExtIds = new Set(
|
||||||
|
zeitbloecke.map((z) => z.extId).filter((id): id is string => id !== null),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
zeitbloecke: zeitbloecke.map((z) => ({
|
||||||
|
id: z.id,
|
||||||
|
beginn: z.beginn,
|
||||||
|
ende: z.ende,
|
||||||
|
aufgabeId: z.aufgabe.id,
|
||||||
|
titel: z.aufgabe.titel,
|
||||||
|
bereichName: z.aufgabe.bereich?.name ?? null,
|
||||||
|
bereichFarbe: z.aufgabe.bereich?.farbe ?? null,
|
||||||
|
kalenderName: z.kalenderkonto.anzeigename,
|
||||||
|
letzterFehler: z.letzterFehler,
|
||||||
|
offen: z.extId === null,
|
||||||
|
})),
|
||||||
|
termine: termine
|
||||||
|
.filter((t) => !eigeneExtIds.has(t.extId))
|
||||||
|
.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
beginn: t.beginn,
|
||||||
|
ende: t.ende,
|
||||||
|
betreff: t.betreff,
|
||||||
|
ganztaegig: t.ganztaegig,
|
||||||
|
zeigtAls: t.zeigtAls,
|
||||||
|
kalenderName: t.kalenderkonto.anzeigename,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aufgaben, die sich einplanen lassen: offen, keine Idee, mit Aufwand. */
|
||||||
|
export async function planbareAufgabenLaden(grenze = 50) {
|
||||||
|
return db.aufgabe.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||||
|
typ: { not: 'IDEE' },
|
||||||
|
},
|
||||||
|
orderBy: [{ faelligAm: 'asc' }, { prioritaet: 'asc' }],
|
||||||
|
take: grenze,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
titel: true,
|
||||||
|
aufwandMinuten: true,
|
||||||
|
faelligAm: true,
|
||||||
|
prioritaet: true,
|
||||||
|
status: true,
|
||||||
|
bereich: { select: { id: true, name: true, farbe: true } },
|
||||||
|
_count: { select: { zeitbloecke: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { einstellungSchema } from '@/lib/benachrichtigung'
|
||||||
|
import { alsTagesschluessel } from '@/lib/zeit'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||||
|
import { mailSenden } from '@/server/benachrichtigung/mail'
|
||||||
|
import { pushSenden } from '@/server/benachrichtigung/push'
|
||||||
|
import { alsEinstellung } from '@/server/benachrichtigung/versand'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
export type Rueckmeldung =
|
||||||
|
| { ok: true; meldung: string }
|
||||||
|
| { ok: false; fehler: string; felder?: Record<string, string> }
|
||||||
|
|
||||||
|
async function akteurOderFehler() {
|
||||||
|
try {
|
||||||
|
return { akteur: await pruefeRolle('BENUTZER') }
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof NichtErlaubt) {
|
||||||
|
return { antwort: { ok: false as const, fehler: fehler.message } }
|
||||||
|
}
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speichert die Benachrichtigungseinstellung des angemeldeten Benutzers. */
|
||||||
|
export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const gelesen = einstellungSchema.safeParse({
|
||||||
|
anlaesse: daten.getAll('anlaesse'),
|
||||||
|
perPush: daten.get('perPush') !== null,
|
||||||
|
perMail: daten.get('perMail') !== null,
|
||||||
|
ueberblickUm: daten.get('ueberblickUm'),
|
||||||
|
ruheVon: daten.get('ruheVon'),
|
||||||
|
ruheBis: daten.get('ruheBis'),
|
||||||
|
ruheAmWochenende: daten.get('ruheAmWochenende') !== null,
|
||||||
|
zeitblockVorlaufMinuten: daten.get('zeitblockVorlaufMinuten'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gelesen.success) {
|
||||||
|
const felder: Record<string, string> = {}
|
||||||
|
for (const problem of gelesen.error.issues) {
|
||||||
|
const feld = problem.path.join('.')
|
||||||
|
felder[feld] ??= problem.message
|
||||||
|
}
|
||||||
|
return { ok: false, fehler: 'Bitte die markierten Felder prüfen.', felder }
|
||||||
|
}
|
||||||
|
|
||||||
|
const werte = {
|
||||||
|
anlaesse: gelesen.data.anlaesse.join(','),
|
||||||
|
perPush: gelesen.data.perPush,
|
||||||
|
perMail: gelesen.data.perMail,
|
||||||
|
ueberblickUm: gelesen.data.ueberblickUm,
|
||||||
|
ruheVon: gelesen.data.ruheVon,
|
||||||
|
ruheBis: gelesen.data.ruheBis,
|
||||||
|
ruheAmWochenende: gelesen.data.ruheAmWochenende,
|
||||||
|
zeitblockVorlaufMinuten: gelesen.data.zeitblockVorlaufMinuten,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.benachrichtigungseinstellung.upsert({
|
||||||
|
where: { benutzerId: akteur.id },
|
||||||
|
update: werte,
|
||||||
|
create: { benutzerId: akteur.id, ...werte },
|
||||||
|
})
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'benachrichtigung.geaendert',
|
||||||
|
objektTyp: 'Benachrichtigungseinstellung',
|
||||||
|
objektId: akteur.id,
|
||||||
|
details: {
|
||||||
|
anlaesse: werte.anlaesse,
|
||||||
|
perPush: werte.perPush,
|
||||||
|
perMail: werte.perMail,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/einstellungen')
|
||||||
|
return { ok: true, meldung: 'Einstellungen gespeichert.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt eine Probemeldung über alle eingeschalteten Wege.
|
||||||
|
*
|
||||||
|
* Sie umgeht Ruhezeit und Wiederholungssperre bewusst — wer auf den Knopf
|
||||||
|
* drückt, will jetzt wissen, ob es ankommt.
|
||||||
|
*/
|
||||||
|
export async function probeSenden(): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const zeile = await db.benachrichtigungseinstellung.findUnique({
|
||||||
|
where: { benutzerId: akteur.id },
|
||||||
|
select: {
|
||||||
|
anlaesse: true,
|
||||||
|
perPush: true,
|
||||||
|
perMail: true,
|
||||||
|
ueberblickUm: true,
|
||||||
|
ruheVon: true,
|
||||||
|
ruheBis: true,
|
||||||
|
ruheAmWochenende: true,
|
||||||
|
zeitblockVorlaufMinuten: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const einstellung = alsEinstellung(zeile)
|
||||||
|
|
||||||
|
if (!einstellung.perPush && !einstellung.perMail) {
|
||||||
|
return { ok: false, fehler: 'Erst einen Weg einschalten — Push oder E-Mail.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const meldung = {
|
||||||
|
anlass: 'tagesueberblick' as const,
|
||||||
|
// Die Probe umgeht Sperre und Ruhezeit; der Schlüssel dient nur der Form.
|
||||||
|
schluessel: `probe:${akteur.id}:${alsTagesschluessel(jetzt())}`,
|
||||||
|
titel: 'Probemeldung',
|
||||||
|
text: 'Wenn du das liest, kommen Benachrichtigungen an.',
|
||||||
|
pfad: '/einstellungen',
|
||||||
|
}
|
||||||
|
|
||||||
|
const wege: string[] = []
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
if (einstellung.perPush) {
|
||||||
|
if (!einst.pushAktiv) {
|
||||||
|
wege.push('Push ist auf diesem Server nicht eingerichtet')
|
||||||
|
} else {
|
||||||
|
const bericht = await pushSenden(akteur.id, meldung)
|
||||||
|
wege.push(
|
||||||
|
bericht.zugestellt > 0
|
||||||
|
? `Push an ${bericht.zugestellt} Gerät(e)`
|
||||||
|
: 'kein Gerät für Push angemeldet',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (einstellung.perMail) {
|
||||||
|
if (!einst.mailAktiv) {
|
||||||
|
wege.push('E-Mail ist auf diesem Server nicht eingerichtet')
|
||||||
|
} else {
|
||||||
|
wege.push(
|
||||||
|
(await mailSenden(akteur.email, meldung))
|
||||||
|
? 'E-Mail verschickt'
|
||||||
|
: 'E-Mail fehlgeschlagen',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'benachrichtigung.probe',
|
||||||
|
objektTyp: 'Benachrichtigungseinstellung',
|
||||||
|
objektId: akteur.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true, meldung: wege.join(' · ') }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt ein angemeldetes Push-Gerät. */
|
||||||
|
export async function geraetEntfernen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const id = daten.get('id')
|
||||||
|
if (typeof id !== 'string' || id.length === 0) {
|
||||||
|
return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const geraet = await db.pushGeraet.findFirst({
|
||||||
|
where: { id, benutzerId: akteur.id },
|
||||||
|
select: { id: true, bezeichnung: true },
|
||||||
|
})
|
||||||
|
if (!geraet) return { ok: false, fehler: 'Dieses Gerät gibt es nicht.' }
|
||||||
|
|
||||||
|
await db.pushGeraet.delete({ where: { id: geraet.id } })
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'push.abgemeldet',
|
||||||
|
objektTyp: 'PushGeraet',
|
||||||
|
objektId: geraet.id,
|
||||||
|
details: { bezeichnung: geraet.bezeichnung },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/einstellungen')
|
||||||
|
return { ok: true, meldung: 'Gerät abgemeldet.' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import {
|
||||||
|
zeitblockSchema,
|
||||||
|
zeitraumHash,
|
||||||
|
LAGE_FEHLER_TEXT,
|
||||||
|
lagePruefen,
|
||||||
|
} from '@/lib/zeitblock'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { gueltigesTokenFuer } from '@/server/kalender/abgleich'
|
||||||
|
import {
|
||||||
|
KalenderGewinnt,
|
||||||
|
terminAendern,
|
||||||
|
terminAnlegen,
|
||||||
|
terminEntfernen,
|
||||||
|
} from '@/server/kalender/schreiben'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
export type Rueckmeldung =
|
||||||
|
{ ok: true; meldung: string; id?: string } | { ok: false; fehler: string }
|
||||||
|
|
||||||
|
async function akteurOderFehler() {
|
||||||
|
try {
|
||||||
|
return { akteur: await pruefeRolle('BENUTZER') }
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof NichtErlaubt) {
|
||||||
|
return { antwort: { ok: false as const, fehler: fehler.message } }
|
||||||
|
}
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt einen Zeitblock, aber nur, wenn er dem angemeldeten Benutzer gehört —
|
||||||
|
* die Kennung geht über das Kalenderkonto in die where-Bedingung.
|
||||||
|
*/
|
||||||
|
async function eigenerZeitblock(id: string, benutzerId: string) {
|
||||||
|
return db.zeitblock.findFirst({
|
||||||
|
where: { id, kalenderkonto: { benutzerId } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
beginn: true,
|
||||||
|
ende: true,
|
||||||
|
extId: true,
|
||||||
|
etag: true,
|
||||||
|
hash: true,
|
||||||
|
aufgabe: { select: { id: true, titel: true, notiz: true, status: true } },
|
||||||
|
kalenderkonto: {
|
||||||
|
select: { id: true, anbieter: true, kalenderId: true, anzeigename: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was im Kalender als Betreff steht. */
|
||||||
|
function betreffFuer(titel: string): string {
|
||||||
|
return titel
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Anlegen ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen Zeitblock an und schreibt ihn in den Kalender.
|
||||||
|
*
|
||||||
|
* Klappt das Schreiben nicht, bleibt der Zeitblock trotzdem bestehen — mit
|
||||||
|
* vermerktem Fehler. Ihn wieder zu verwerfen hieße, die Planung des Benutzers
|
||||||
|
* wegen eines Netzproblems zu vergessen; der nächste Versuch kann ihn
|
||||||
|
* nachtragen.
|
||||||
|
*/
|
||||||
|
export async function zeitblockAnlegen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const gelesen = zeitblockSchema.safeParse({
|
||||||
|
aufgabeId: daten.get('aufgabeId'),
|
||||||
|
kalenderkontoId: daten.get('kalenderkontoId'),
|
||||||
|
beginn: daten.get('beginn'),
|
||||||
|
ende: daten.get('ende'),
|
||||||
|
})
|
||||||
|
if (!gelesen.success) {
|
||||||
|
const lage = gelesen.error.issues.find((i) => i.path.includes('ende'))
|
||||||
|
return { ok: false, fehler: lage?.message ?? 'Die Angaben stimmen nicht.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [aufgabe, konto] = await Promise.all([
|
||||||
|
db.aufgabe.findUnique({
|
||||||
|
where: { id: gelesen.data.aufgabeId },
|
||||||
|
select: { id: true, titel: true, notiz: true, status: true },
|
||||||
|
}),
|
||||||
|
db.kalenderkonto.findFirst({
|
||||||
|
where: { id: gelesen.data.kalenderkontoId, benutzerId: akteur.id, aktiv: true },
|
||||||
|
select: { id: true, anbieter: true, kalenderId: true, anzeigename: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht.' }
|
||||||
|
if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' }
|
||||||
|
|
||||||
|
const zeitblock = await db.zeitblock.create({
|
||||||
|
data: {
|
||||||
|
aufgabeId: aufgabe.id,
|
||||||
|
kalenderkontoId: konto.id,
|
||||||
|
beginn: gelesen.data.beginn,
|
||||||
|
ende: gelesen.data.ende,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Eine eingeplante Aufgabe ist geplant, nicht mehr bloß offen.
|
||||||
|
if (aufgabe.status === 'OFFEN') {
|
||||||
|
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
let schreibfehler: string | null = null
|
||||||
|
try {
|
||||||
|
const token = await gueltigesTokenFuer(konto.id, jetzt())
|
||||||
|
const ergebnis = await terminAnlegen({
|
||||||
|
anbieter: konto.anbieter,
|
||||||
|
kalenderId: konto.kalenderId,
|
||||||
|
token,
|
||||||
|
betreff: betreffFuer(aufgabe.titel),
|
||||||
|
beschreibung: aufgabe.notiz,
|
||||||
|
beginn: gelesen.data.beginn,
|
||||||
|
ende: gelesen.data.ende,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.zeitblock.update({
|
||||||
|
where: { id: zeitblock.id },
|
||||||
|
data: {
|
||||||
|
extId: ergebnis.extId,
|
||||||
|
etag: ergebnis.etag,
|
||||||
|
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
|
||||||
|
letzterFehler: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (fehler) {
|
||||||
|
schreibfehler =
|
||||||
|
fehler instanceof Error ? fehler.message : 'Der Termin ließ sich nicht anlegen.'
|
||||||
|
await db.zeitblock.update({
|
||||||
|
where: { id: zeitblock.id },
|
||||||
|
data: { letzterFehler: schreibfehler },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'zeitblock.angelegt',
|
||||||
|
objektTyp: 'Zeitblock',
|
||||||
|
objektId: zeitblock.id,
|
||||||
|
details: {
|
||||||
|
aufgabe: aufgabe.titel,
|
||||||
|
kalender: konto.anzeigename,
|
||||||
|
geschrieben: schreibfehler === null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/woche')
|
||||||
|
revalidatePath('/aufgaben')
|
||||||
|
revalidatePath('/')
|
||||||
|
|
||||||
|
return schreibfehler
|
||||||
|
? {
|
||||||
|
ok: false,
|
||||||
|
fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${schreibfehler}`,
|
||||||
|
}
|
||||||
|
: { ok: true, id: zeitblock.id, meldung: `„${aufgabe.titel}" eingeplant.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Verschieben -----------------------------------------------------------
|
||||||
|
|
||||||
|
const verschieben = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
beginn: z.coerce.date(),
|
||||||
|
ende: z.coerce.date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt einen Zeitblock und zieht den Kalender nach.
|
||||||
|
*
|
||||||
|
* Antwortet der Anbieter mit 412, hat jemand anderes den Termin inzwischen
|
||||||
|
* angefasst. Dann gewinnt der Kalender: der Planer schreibt nicht, sondern
|
||||||
|
* meldet das und überlässt dem nächsten Abgleich, den fremden Stand zu holen.
|
||||||
|
*/
|
||||||
|
export async function zeitblockVerschieben(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const gelesen = verschieben.safeParse({
|
||||||
|
id: daten.get('id'),
|
||||||
|
beginn: daten.get('beginn'),
|
||||||
|
ende: daten.get('ende'),
|
||||||
|
})
|
||||||
|
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||||
|
|
||||||
|
const lage = lagePruefen(gelesen.data.beginn, gelesen.data.ende)
|
||||||
|
if (lage) return { ok: false, fehler: LAGE_FEHLER_TEXT[lage] }
|
||||||
|
|
||||||
|
const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id)
|
||||||
|
if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' }
|
||||||
|
|
||||||
|
await db.zeitblock.update({
|
||||||
|
where: { id: zeitblock.id },
|
||||||
|
data: { beginn: gelesen.data.beginn, ende: gelesen.data.ende },
|
||||||
|
})
|
||||||
|
|
||||||
|
let meldung = `„${zeitblock.aufgabe.titel}" verschoben.`
|
||||||
|
let ok = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt())
|
||||||
|
|
||||||
|
if (zeitblock.extId) {
|
||||||
|
const ergebnis = await terminAendern({
|
||||||
|
anbieter: zeitblock.kalenderkonto.anbieter,
|
||||||
|
kalenderId: zeitblock.kalenderkonto.kalenderId,
|
||||||
|
token,
|
||||||
|
extId: zeitblock.extId,
|
||||||
|
etag: zeitblock.etag,
|
||||||
|
betreff: betreffFuer(zeitblock.aufgabe.titel),
|
||||||
|
beschreibung: zeitblock.aufgabe.notiz,
|
||||||
|
beginn: gelesen.data.beginn,
|
||||||
|
ende: gelesen.data.ende,
|
||||||
|
})
|
||||||
|
await db.zeitblock.update({
|
||||||
|
where: { id: zeitblock.id },
|
||||||
|
data: {
|
||||||
|
etag: ergebnis.etag,
|
||||||
|
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
|
||||||
|
letzterFehler: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Beim Anlegen war der Kalender nicht erreichbar – jetzt nachtragen.
|
||||||
|
const ergebnis = await terminAnlegen({
|
||||||
|
anbieter: zeitblock.kalenderkonto.anbieter,
|
||||||
|
kalenderId: zeitblock.kalenderkonto.kalenderId,
|
||||||
|
token,
|
||||||
|
betreff: betreffFuer(zeitblock.aufgabe.titel),
|
||||||
|
beschreibung: zeitblock.aufgabe.notiz,
|
||||||
|
beginn: gelesen.data.beginn,
|
||||||
|
ende: gelesen.data.ende,
|
||||||
|
})
|
||||||
|
await db.zeitblock.update({
|
||||||
|
where: { id: zeitblock.id },
|
||||||
|
data: {
|
||||||
|
extId: ergebnis.extId,
|
||||||
|
etag: ergebnis.etag,
|
||||||
|
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
|
||||||
|
letzterFehler: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
ok = false
|
||||||
|
if (fehler instanceof KalenderGewinnt) {
|
||||||
|
meldung =
|
||||||
|
'Der Termin wurde inzwischen im Kalender geändert. Der Kalender gewinnt — der nächste Abgleich holt den dortigen Stand.'
|
||||||
|
// Den eigenen Fingerabdruck verwerfen, damit der Abgleich die fremde
|
||||||
|
// Änderung als solche erkennt.
|
||||||
|
await db.zeitblock.update({
|
||||||
|
where: { id: zeitblock.id },
|
||||||
|
data: { hash: null, letzterFehler: meldung },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
meldung =
|
||||||
|
fehler instanceof Error ? fehler.message : 'Der Kalender ließ sich nicht ändern.'
|
||||||
|
await db.zeitblock.update({
|
||||||
|
where: { id: zeitblock.id },
|
||||||
|
data: { letzterFehler: meldung },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'zeitblock.verschoben',
|
||||||
|
objektTyp: 'Zeitblock',
|
||||||
|
objektId: zeitblock.id,
|
||||||
|
details: { aufgabe: zeitblock.aufgabe.titel, geschrieben: ok },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/woche')
|
||||||
|
revalidatePath('/')
|
||||||
|
return ok ? { ok: true, meldung } : { ok: false, fehler: meldung }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lösen -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const loesen = z.object({ id: z.string().min(1) })
|
||||||
|
|
||||||
|
/** Nimmt einen Zeitblock zurück und entfernt den Termin aus dem Kalender. */
|
||||||
|
export async function zeitblockLoesen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const gelesen = loesen.safeParse({ id: daten.get('id') })
|
||||||
|
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||||
|
|
||||||
|
const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id)
|
||||||
|
if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' }
|
||||||
|
|
||||||
|
let hinweis = ''
|
||||||
|
if (zeitblock.extId) {
|
||||||
|
try {
|
||||||
|
const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt())
|
||||||
|
await terminEntfernen({
|
||||||
|
anbieter: zeitblock.kalenderkonto.anbieter,
|
||||||
|
kalenderId: zeitblock.kalenderkonto.kalenderId,
|
||||||
|
token,
|
||||||
|
extId: zeitblock.extId,
|
||||||
|
etag: zeitblock.etag,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Der Zeitblock verschwindet trotzdem. Bliebe er stehen, weil der
|
||||||
|
// Kalender gerade nicht erreichbar ist, wäre die Planung blockiert.
|
||||||
|
hinweis = ' Der Termin im Kalender ließ sich nicht entfernen.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.zeitblock.delete({ where: { id: zeitblock.id } })
|
||||||
|
|
||||||
|
// Bleibt keine Planung übrig, ist die Aufgabe wieder schlicht offen.
|
||||||
|
const nochGeplant = await db.zeitblock.count({
|
||||||
|
where: { aufgabeId: zeitblock.aufgabe.id },
|
||||||
|
})
|
||||||
|
if (nochGeplant === 0 && zeitblock.aufgabe.status === 'GEPLANT') {
|
||||||
|
await db.aufgabe.update({
|
||||||
|
where: { id: zeitblock.aufgabe.id },
|
||||||
|
data: { status: 'OFFEN' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'zeitblock.geloest',
|
||||||
|
objektTyp: 'Zeitblock',
|
||||||
|
objektId: zeitblock.id,
|
||||||
|
details: { aufgabe: zeitblock.aufgabe.titel },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/woche')
|
||||||
|
revalidatePath('/aufgaben')
|
||||||
|
revalidatePath('/')
|
||||||
|
return { ok: true, meldung: `„${zeitblock.aufgabe.titel}" ausgeplant.${hinweis}` }
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import nodemailer, { type Transporter } from 'nodemailer'
|
||||||
|
import type { Meldung } from '@/lib/benachrichtigung'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E-Mail-Versand.
|
||||||
|
*
|
||||||
|
* Jede Mail geht als reiner Text **und** als HTML hinaus. Wer in einem
|
||||||
|
* Textclient liest, bekommt etwas Lesbares; wer HTML liest, etwas
|
||||||
|
* Aufgeräumtes.
|
||||||
|
*
|
||||||
|
* Der Inhalt bleibt knapp: Titel, ein paar Zeilen, ein Link. Eine Mail, die
|
||||||
|
* alles wiederholt, was ohnehin in der Anwendung steht, liest niemand.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let versand: Transporter | null = null
|
||||||
|
|
||||||
|
function transporter(): Transporter | null {
|
||||||
|
const einst = umgebung()
|
||||||
|
if (!einst.mailAktiv) return null
|
||||||
|
if (versand) return versand
|
||||||
|
|
||||||
|
versand = nodemailer.createTransport({
|
||||||
|
host: einst.SMTP_HOST,
|
||||||
|
port: einst.SMTP_PORT,
|
||||||
|
secure: einst.SMTP_TLS,
|
||||||
|
...(einst.SMTP_BENUTZER
|
||||||
|
? { auth: { user: einst.SMTP_BENUTZER, pass: einst.SMTP_PASSWORT } }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
return versand
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur für Tests und nach einer Konfigurationsänderung. */
|
||||||
|
export function versandVergessen(): void {
|
||||||
|
versand = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function alsHtml(meldung: Meldung, adresse: string): string {
|
||||||
|
// Bewusst schlichtes HTML ohne Bilder und ohne externe Stile: Mailclients
|
||||||
|
// blockieren beides ohnehin, und ein Text, der nur mit CSS lesbar ist,
|
||||||
|
// ist kaputt.
|
||||||
|
const zeilen = meldung.text
|
||||||
|
.split('\n')
|
||||||
|
.filter((z) => z.trim().length > 0)
|
||||||
|
.map((z) => `<p style="margin:0 0 8px">${maskieren(z)}</p>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;color:#1f2430">',
|
||||||
|
`<h1 style="font-size:18px;margin:0 0 12px">${maskieren(meldung.titel)}</h1>`,
|
||||||
|
zeilen,
|
||||||
|
`<p style="margin:16px 0 0"><a href="${adresse}" style="color:#2563eb">Im Planer ansehen</a></p>`,
|
||||||
|
'</div>',
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskieren(text: string): string {
|
||||||
|
return text
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schickt eine Meldung per Mail. Gibt zurück, ob es geklappt hat. */
|
||||||
|
export async function mailSenden(empfaenger: string, meldung: Meldung): Promise<boolean> {
|
||||||
|
const versender = transporter()
|
||||||
|
if (!versender) return false
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
const adresse = `${einst.APP_URL}${meldung.pfad}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await versender.sendMail({
|
||||||
|
from: einst.SMTP_ABSENDER,
|
||||||
|
to: empfaenger,
|
||||||
|
subject: `Planer: ${meldung.titel}`,
|
||||||
|
text: `${meldung.text}\n\nIm Planer ansehen: ${adresse}\n`,
|
||||||
|
html: alsHtml(meldung, adresse),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (fehler) {
|
||||||
|
// Nie den Mailinhalt ins Protokoll – nur, dass es nicht ging.
|
||||||
|
console.error(
|
||||||
|
'Mailversand fehlgeschlagen:',
|
||||||
|
fehler instanceof Error ? fehler.message : 'unbekannter Fehler',
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft die Verbindung, ohne etwas zu verschicken. */
|
||||||
|
export async function mailVerbindungPruefen(): Promise<
|
||||||
|
{ ok: true } | { ok: false; fehler: string }
|
||||||
|
> {
|
||||||
|
const versender = transporter()
|
||||||
|
if (!versender) return { ok: false, fehler: 'SMTP ist nicht eingerichtet.' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await versender.verify()
|
||||||
|
return { ok: true }
|
||||||
|
} catch (fehler) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: fehler instanceof Error ? fehler.message : 'Verbindung fehlgeschlagen.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import webpush from 'web-push'
|
||||||
|
import type { Meldung } from '@/lib/benachrichtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { entschluesseln, verschluesseln } from '@/server/krypto'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web-Push.
|
||||||
|
*
|
||||||
|
* Die Schlüssel eines Push-Abos liegen verschlüsselt in der Datenbank: Wer
|
||||||
|
* sie hat, kann Nachrichten an fremde Browser schicken. Das ist kein
|
||||||
|
* Vollzugriff, aber auch nichts, was offen in einer Sicherung liegen sollte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Ab so vielen Fehlversuchen hintereinander wird das Gerät entfernt. */
|
||||||
|
const FEHLVERSUCHE_GRENZE = 5
|
||||||
|
|
||||||
|
let eingerichtet = false
|
||||||
|
|
||||||
|
function vapidEinrichten(): boolean {
|
||||||
|
const einst = umgebung()
|
||||||
|
if (!einst.pushAktiv) return false
|
||||||
|
if (eingerichtet) return true
|
||||||
|
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
einst.VAPID_KONTAKT,
|
||||||
|
einst.VAPID_OEFFENTLICH,
|
||||||
|
einst.VAPID_PRIVAT,
|
||||||
|
)
|
||||||
|
eingerichtet = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PushAbo = {
|
||||||
|
endpunkt: string
|
||||||
|
p256dh: string
|
||||||
|
auth: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt ein Gerät an oder frischt es auf. */
|
||||||
|
export async function geraetAnmelden(
|
||||||
|
benutzerId: string,
|
||||||
|
abo: PushAbo,
|
||||||
|
bezeichnung: string | null,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
return db.pushGeraet.upsert({
|
||||||
|
where: { endpunkt: abo.endpunkt },
|
||||||
|
update: {
|
||||||
|
benutzerId,
|
||||||
|
p256dh: verschluesseln(abo.p256dh),
|
||||||
|
auth: verschluesseln(abo.auth),
|
||||||
|
fehlversuche: 0,
|
||||||
|
...(bezeichnung ? { bezeichnung } : {}),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
benutzerId,
|
||||||
|
endpunkt: abo.endpunkt,
|
||||||
|
p256dh: verschluesseln(abo.p256dh),
|
||||||
|
auth: verschluesseln(abo.auth),
|
||||||
|
bezeichnung,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function geraetAbmelden(endpunkt: string): Promise<void> {
|
||||||
|
await db.pushGeraet.deleteMany({ where: { endpunkt } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PushBericht = { zugestellt: number; entfernt: number; gescheitert: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt eine Meldung an alle Geräte eines Benutzers.
|
||||||
|
*
|
||||||
|
* Antwortet der Push-Dienst mit 404 oder 410, gibt es das Abo nicht mehr —
|
||||||
|
* das Gerät wird sofort entfernt. Andere Fehler werden gezählt; erst nach
|
||||||
|
* mehreren hintereinander fliegt es raus, denn ein Aussetzer ist kein Grund,
|
||||||
|
* jemanden abzumelden.
|
||||||
|
*/
|
||||||
|
export async function pushSenden(
|
||||||
|
benutzerId: string,
|
||||||
|
meldung: Meldung,
|
||||||
|
): Promise<PushBericht> {
|
||||||
|
if (!vapidEinrichten()) return { zugestellt: 0, entfernt: 0, gescheitert: 0 }
|
||||||
|
|
||||||
|
const geraete = await db.pushGeraet.findMany({
|
||||||
|
where: { benutzerId },
|
||||||
|
select: { id: true, endpunkt: true, p256dh: true, auth: true, fehlversuche: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const inhalt = JSON.stringify({
|
||||||
|
titel: meldung.titel,
|
||||||
|
text: meldung.text,
|
||||||
|
pfad: meldung.pfad,
|
||||||
|
anlass: meldung.anlass,
|
||||||
|
})
|
||||||
|
|
||||||
|
let zugestellt = 0
|
||||||
|
let entfernt = 0
|
||||||
|
let gescheitert = 0
|
||||||
|
|
||||||
|
for (const geraet of geraete) {
|
||||||
|
const p256dh = entschluesseln(geraet.p256dh)
|
||||||
|
const auth = entschluesseln(geraet.auth)
|
||||||
|
|
||||||
|
if (!p256dh || !auth) {
|
||||||
|
// Mit gewechseltem AUTH_SECRET sind die Schlüssel nicht mehr lesbar.
|
||||||
|
await db.pushGeraet.delete({ where: { id: geraet.id } })
|
||||||
|
entfernt++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{ endpoint: geraet.endpunkt, keys: { p256dh, auth } },
|
||||||
|
inhalt,
|
||||||
|
{ TTL: 60 * 60 },
|
||||||
|
)
|
||||||
|
await db.pushGeraet.update({
|
||||||
|
where: { id: geraet.id },
|
||||||
|
data: { zuletztBenutzt: new Date(), fehlversuche: 0 },
|
||||||
|
})
|
||||||
|
zugestellt++
|
||||||
|
} catch (fehler) {
|
||||||
|
const status =
|
||||||
|
typeof fehler === 'object' && fehler !== null && 'statusCode' in fehler
|
||||||
|
? Number(fehler.statusCode)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
if (status === 404 || status === 410) {
|
||||||
|
await db.pushGeraet.delete({ where: { id: geraet.id } })
|
||||||
|
entfernt++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const neu = geraet.fehlversuche + 1
|
||||||
|
if (neu >= FEHLVERSUCHE_GRENZE) {
|
||||||
|
await db.pushGeraet.delete({ where: { id: geraet.id } })
|
||||||
|
entfernt++
|
||||||
|
} else {
|
||||||
|
await db.pushGeraet.update({
|
||||||
|
where: { id: geraet.id },
|
||||||
|
data: { fehlversuche: neu },
|
||||||
|
})
|
||||||
|
gescheitert++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { zugestellt, entfernt, gescheitert }
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import {
|
||||||
|
ABGLEICHFEHLER_AB_VERSUCH,
|
||||||
|
VOREINSTELLUNG,
|
||||||
|
meldungsSchluessel,
|
||||||
|
sendenEntscheiden,
|
||||||
|
ueberblickFaellig,
|
||||||
|
zeitblockErinnerungFaellig,
|
||||||
|
type Anlass,
|
||||||
|
type Einstellung,
|
||||||
|
type Meldung,
|
||||||
|
} from '@/lib/benachrichtigung'
|
||||||
|
import {
|
||||||
|
alsDauer,
|
||||||
|
alsTagesschluessel,
|
||||||
|
alsUhrzeitAnzeige,
|
||||||
|
tagesBeginn,
|
||||||
|
tagesEnde,
|
||||||
|
} from '@/lib/zeit'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { mailSenden } from '@/server/benachrichtigung/mail'
|
||||||
|
import { pushSenden } from '@/server/benachrichtigung/push'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was wann an wen hinausgeht.
|
||||||
|
*
|
||||||
|
* Die Entscheidung selbst trifft die reine Logik in src/lib; hier wird nur
|
||||||
|
* beschafft, zugestellt und nachgehalten. Jede tatsächlich verschickte
|
||||||
|
* Meldung hinterlässt einen Nachweis — dieselbe Sache geht nie zweimal raus.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wie lange Nachweise aufgehoben werden. Danach räumt der Job sie weg. */
|
||||||
|
export const NACHWEIS_TAGE = 30
|
||||||
|
|
||||||
|
type BenutzerMitEinstellung = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string | null
|
||||||
|
einstellung: Einstellung
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest die Einstellung; fehlt sie, gilt die zurückhaltende Voreinstellung. */
|
||||||
|
export function alsEinstellung(
|
||||||
|
zeile: {
|
||||||
|
anlaesse: string
|
||||||
|
perPush: boolean
|
||||||
|
perMail: boolean
|
||||||
|
ueberblickUm: number
|
||||||
|
ruheVon: number
|
||||||
|
ruheBis: number
|
||||||
|
ruheAmWochenende: boolean
|
||||||
|
zeitblockVorlaufMinuten: number
|
||||||
|
} | null,
|
||||||
|
): Einstellung {
|
||||||
|
if (!zeile) return VOREINSTELLUNG
|
||||||
|
|
||||||
|
const anlaesse = zeile.anlaesse
|
||||||
|
.split(',')
|
||||||
|
.map((a) => a.trim())
|
||||||
|
.filter((a): a is Anlass =>
|
||||||
|
['tagesueberblick', 'faelligkeit', 'zeitblock', 'abgleichfehler'].includes(a),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
anlaesse,
|
||||||
|
perPush: zeile.perPush,
|
||||||
|
perMail: zeile.perMail,
|
||||||
|
ueberblickUm: zeile.ueberblickUm,
|
||||||
|
ruheVon: zeile.ruheVon,
|
||||||
|
ruheBis: zeile.ruheBis,
|
||||||
|
ruheAmWochenende: zeile.ruheAmWochenende,
|
||||||
|
zeitblockVorlaufMinuten: zeile.zeitblockVorlaufMinuten,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function empfaengerLaden(): Promise<BenutzerMitEinstellung[]> {
|
||||||
|
const zeilen = await db.benutzer.findMany({
|
||||||
|
where: { aktiv: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
benachrichtigung: {
|
||||||
|
select: {
|
||||||
|
anlaesse: true,
|
||||||
|
perPush: true,
|
||||||
|
perMail: true,
|
||||||
|
ueberblickUm: true,
|
||||||
|
ruheVon: true,
|
||||||
|
ruheBis: true,
|
||||||
|
ruheAmWochenende: true,
|
||||||
|
zeitblockVorlaufMinuten: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return zeilen.map((z) => ({
|
||||||
|
id: z.id,
|
||||||
|
email: z.email,
|
||||||
|
name: z.name,
|
||||||
|
einstellung: alsEinstellung(z.benachrichtigung),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt eine Meldung zu, wenn sie darf, und hält den Nachweis fest.
|
||||||
|
*
|
||||||
|
* Der Nachweis wird **vor** dem Versand geschrieben. Bricht der Versand ab,
|
||||||
|
* gilt die Meldung trotzdem als erledigt — lieber eine verpasste Nachricht
|
||||||
|
* als eine, die im Minutentakt erneut versucht wird.
|
||||||
|
*/
|
||||||
|
export async function meldungZustellen(
|
||||||
|
benutzer: BenutzerMitEinstellung,
|
||||||
|
meldung: Meldung,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const gemeldet = await db.meldungsnachweis.findUnique({
|
||||||
|
where: {
|
||||||
|
benutzerId_schluessel: { benutzerId: benutzer.id, schluessel: meldung.schluessel },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const entscheidung = sendenEntscheiden(
|
||||||
|
benutzer.einstellung,
|
||||||
|
meldung,
|
||||||
|
new Set(gemeldet ? [meldung.schluessel] : []),
|
||||||
|
zeitpunkt,
|
||||||
|
)
|
||||||
|
if (!entscheidung.senden) return false
|
||||||
|
|
||||||
|
await db.meldungsnachweis.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
schluessel: meldung.schluessel,
|
||||||
|
gesendetAm: zeitpunkt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (benutzer.einstellung.perPush) await pushSenden(benutzer.id, meldung)
|
||||||
|
if (benutzer.einstellung.perMail) await mailSenden(benutzer.email, meldung)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Die einzelnen Anlässe -------------------------------------------------
|
||||||
|
|
||||||
|
/** Baut den Tagesüberblick: was heute ansteht. */
|
||||||
|
async function tagesueberblickBauen(
|
||||||
|
benutzerId: string,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Promise<Meldung | null> {
|
||||||
|
const heute = tagesBeginn(zeitpunkt)
|
||||||
|
const heuteEnde = tagesEnde(zeitpunkt)
|
||||||
|
|
||||||
|
const [ueberfaellig, heuteFaellig, zeitbloecke] = await Promise.all([
|
||||||
|
db.aufgabe.count({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||||
|
typ: { not: 'IDEE' },
|
||||||
|
faelligAm: { lt: heute },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.aufgabe.count({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||||
|
typ: { not: 'IDEE' },
|
||||||
|
faelligAm: { gte: heute, lte: heuteEnde },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.zeitblock.findMany({
|
||||||
|
where: {
|
||||||
|
kalenderkonto: { benutzerId },
|
||||||
|
beginn: { gte: heute, lte: heuteEnde },
|
||||||
|
},
|
||||||
|
orderBy: { beginn: 'asc' },
|
||||||
|
select: { beginn: true, ende: true, aufgabe: { select: { titel: true } } },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Ein Überblick, der nichts zu sagen hat, ist keine Nachricht wert.
|
||||||
|
if (ueberfaellig === 0 && heuteFaellig === 0 && zeitbloecke.length === 0) return null
|
||||||
|
|
||||||
|
const teile: string[] = []
|
||||||
|
if (ueberfaellig > 0) teile.push(`${ueberfaellig} überfällig`)
|
||||||
|
if (heuteFaellig > 0) teile.push(`${heuteFaellig} heute fällig`)
|
||||||
|
|
||||||
|
const geplanteMinuten = zeitbloecke.reduce(
|
||||||
|
(summe, z) => summe + (z.ende.getTime() - z.beginn.getTime()) / 60_000,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if (zeitbloecke.length > 0) {
|
||||||
|
teile.push(`${zeitbloecke.length} Zeitblöcke (${alsDauer(geplanteMinuten)})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeilen = [teile.join(', ') + '.']
|
||||||
|
for (const block of zeitbloecke.slice(0, 5)) {
|
||||||
|
zeilen.push(`${alsUhrzeitAnzeige(block.beginn)} ${block.aufgabe.titel}`)
|
||||||
|
}
|
||||||
|
if (zeitbloecke.length > 5) zeilen.push(`… und ${zeitbloecke.length - 5} weitere.`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
anlass: 'tagesueberblick',
|
||||||
|
schluessel: meldungsSchluessel(
|
||||||
|
'tagesueberblick',
|
||||||
|
'tag',
|
||||||
|
alsTagesschluessel(zeitpunkt),
|
||||||
|
),
|
||||||
|
titel: 'Dein Tag',
|
||||||
|
text: zeilen.join('\n'),
|
||||||
|
pfad: '/',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aufgaben, die heute fällig werden. */
|
||||||
|
async function faelligkeitenBauen(zeitpunkt: Date): Promise<Meldung[]> {
|
||||||
|
const aufgaben = await db.aufgabe.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||||
|
typ: { not: 'IDEE' },
|
||||||
|
faelligAm: { gte: tagesBeginn(zeitpunkt), lte: tagesEnde(zeitpunkt) },
|
||||||
|
},
|
||||||
|
select: { id: true, titel: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const tag = alsTagesschluessel(zeitpunkt)
|
||||||
|
return aufgaben.map((aufgabe) => ({
|
||||||
|
anlass: 'faelligkeit' as const,
|
||||||
|
schluessel: meldungsSchluessel('faelligkeit', aufgabe.id, tag),
|
||||||
|
titel: 'Heute fällig',
|
||||||
|
text: aufgabe.titel,
|
||||||
|
pfad: '/aufgaben',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeitblöcke, die gleich beginnen. */
|
||||||
|
async function zeitblockErinnerungenBauen(
|
||||||
|
benutzer: BenutzerMitEinstellung,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Promise<Meldung[]> {
|
||||||
|
const bloecke = await db.zeitblock.findMany({
|
||||||
|
where: {
|
||||||
|
kalenderkonto: { benutzerId: benutzer.id },
|
||||||
|
beginn: {
|
||||||
|
gte: zeitpunkt,
|
||||||
|
lte: new Date(
|
||||||
|
zeitpunkt.getTime() +
|
||||||
|
(benutzer.einstellung.zeitblockVorlaufMinuten + 15) * 60_000,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, beginn: true, aufgabe: { select: { titel: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const tag = alsTagesschluessel(zeitpunkt)
|
||||||
|
return bloecke
|
||||||
|
.filter((b) => zeitblockErinnerungFaellig(benutzer.einstellung, b.beginn, zeitpunkt))
|
||||||
|
.map((block) => ({
|
||||||
|
anlass: 'zeitblock' as const,
|
||||||
|
schluessel: meldungsSchluessel('zeitblock', block.id, tag),
|
||||||
|
titel: `${alsUhrzeitAnzeige(block.beginn)} geht es los`,
|
||||||
|
text: block.aufgabe.titel,
|
||||||
|
pfad: '/woche',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kalender, die mehrfach hintereinander nicht abgleichbar waren. */
|
||||||
|
async function abgleichfehlerBauen(
|
||||||
|
benutzerId: string,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Promise<Meldung[]> {
|
||||||
|
const konten = await db.kalenderkonto.findMany({
|
||||||
|
where: {
|
||||||
|
benutzerId,
|
||||||
|
aktiv: true,
|
||||||
|
letzterFehler: { not: null },
|
||||||
|
// Ein Aussetzer ist keine Nachricht wert – ein Netz hustet, ein
|
||||||
|
// Anbieter hat Wartung. Erst mehrere Fehlschläge hintereinander.
|
||||||
|
fehlversuche: { gte: ABGLEICHFEHLER_AB_VERSUCH },
|
||||||
|
},
|
||||||
|
select: { id: true, anzeigename: true, letzterFehler: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const tag = alsTagesschluessel(zeitpunkt)
|
||||||
|
return konten.map((konto) => ({
|
||||||
|
anlass: 'abgleichfehler' as const,
|
||||||
|
schluessel: meldungsSchluessel('abgleichfehler', konto.id, tag),
|
||||||
|
titel: 'Ein Kalender lässt sich nicht abgleichen',
|
||||||
|
text: `„${konto.anzeigename}": ${konto.letzterFehler ?? 'unbekannter Fehler'}`,
|
||||||
|
pfad: '/kalenderkonten',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VersandBericht = { geprueft: number; gesendet: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Durchlauf: für jeden Benutzer alles zusammentragen, was jetzt fällig
|
||||||
|
* wäre, und zustellen, was darf.
|
||||||
|
*/
|
||||||
|
export async function benachrichtigungenVerschicken(
|
||||||
|
zeitpunkt: Date = jetzt(),
|
||||||
|
): Promise<VersandBericht> {
|
||||||
|
const empfaenger = await empfaengerLaden()
|
||||||
|
let gesendet = 0
|
||||||
|
|
||||||
|
for (const benutzer of empfaenger) {
|
||||||
|
// Wer keinen Weg hinaus eingerichtet hat, braucht auch nichts gebaut.
|
||||||
|
if (!benutzer.einstellung.perPush && !benutzer.einstellung.perMail) continue
|
||||||
|
|
||||||
|
const meldungen: Meldung[] = []
|
||||||
|
|
||||||
|
if (
|
||||||
|
benutzer.einstellung.anlaesse.includes('tagesueberblick') &&
|
||||||
|
ueberblickFaellig(benutzer.einstellung, zeitpunkt)
|
||||||
|
) {
|
||||||
|
const ueberblick = await tagesueberblickBauen(benutzer.id, zeitpunkt)
|
||||||
|
if (ueberblick) meldungen.push(ueberblick)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (benutzer.einstellung.anlaesse.includes('faelligkeit')) {
|
||||||
|
meldungen.push(...(await faelligkeitenBauen(zeitpunkt)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (benutzer.einstellung.anlaesse.includes('zeitblock')) {
|
||||||
|
meldungen.push(...(await zeitblockErinnerungenBauen(benutzer, zeitpunkt)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (benutzer.einstellung.anlaesse.includes('abgleichfehler')) {
|
||||||
|
meldungen.push(...(await abgleichfehlerBauen(benutzer.id, zeitpunkt)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const meldung of meldungen) {
|
||||||
|
if (await meldungZustellen(benutzer, meldung, zeitpunkt)) gesendet++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { geprueft: empfaenger.length, gesendet }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alte Nachweise wegräumen; sonst wächst die Tabelle unbegrenzt. */
|
||||||
|
export async function alteNachweiseEntfernen(zeitpunkt: Date = jetzt()): Promise<number> {
|
||||||
|
const grenze = new Date(zeitpunkt.getTime() - NACHWEIS_TAGE * 86_400_000)
|
||||||
|
const ergebnis = await db.meldungsnachweis.deleteMany({
|
||||||
|
where: { gesendetAm: { lt: grenze } },
|
||||||
|
})
|
||||||
|
return ergebnis.count
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { tagesBeginn } from '@/lib/zeit'
|
|||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
import { entschluesseln, verschluesseln } from '@/server/krypto'
|
import { entschluesseln, verschluesseln } from '@/server/krypto'
|
||||||
import { tokenErneuern } from '@/server/kalender/oauth'
|
import { tokenErneuern } from '@/server/kalender/oauth'
|
||||||
|
import { rueckwegAnwenden } from '@/server/kalender/rueckweg'
|
||||||
import { jetzt } from '@/server/zeitgeber'
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,6 +98,31 @@ async function gueltigesToken(konto: Konto, zeitpunkt: Date): Promise<string> {
|
|||||||
return satz.zugriffstoken
|
return satz.zugriffstoken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gültiges Zugriffstoken für ein Konto – für den Schreibweg aus M4, der
|
||||||
|
* nicht den ganzen Abgleich braucht.
|
||||||
|
*/
|
||||||
|
export async function gueltigesTokenFuer(
|
||||||
|
kalenderkontoId: string,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): Promise<string> {
|
||||||
|
const konto = await db.kalenderkonto.findUnique({
|
||||||
|
where: { id: kalenderkontoId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
anbieter: true,
|
||||||
|
anzeigename: true,
|
||||||
|
kalenderId: true,
|
||||||
|
zugriffstoken: true,
|
||||||
|
aktualisierungstoken: true,
|
||||||
|
tokenGueltigBis: true,
|
||||||
|
deltaVerweis: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!konto) throw new Error('Dieses Kalenderkonto gibt es nicht.')
|
||||||
|
return gueltigesToken(konto, zeitpunkt)
|
||||||
|
}
|
||||||
|
|
||||||
/** Adresse der ersten Seite einer Runde. */
|
/** Adresse der ersten Seite einer Runde. */
|
||||||
function ersteAdresse(konto: Konto, fenster: { von: Date; bis: Date }): string {
|
function ersteAdresse(konto: Konto, fenster: { von: Date; bis: Date }): string {
|
||||||
if (konto.deltaVerweis) return konto.deltaVerweis
|
if (konto.deltaVerweis) return konto.deltaVerweis
|
||||||
@@ -302,12 +328,18 @@ export async function kontoAbgleichen(
|
|||||||
adresse = naechsteAdresse(konto, adresse, seite.weiter)
|
adresse = naechsteAdresse(konto, adresse, seite.weiter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Erst den Spiegel auffrischen, dann die eigenen Zeitblöcke damit
|
||||||
|
// vergleichen: Was jemand im Kalender verschoben hat, holt der Planer
|
||||||
|
// sich hier zurück.
|
||||||
|
await rueckwegAnwenden(konto.id)
|
||||||
|
|
||||||
await db.kalenderkonto.update({
|
await db.kalenderkonto.update({
|
||||||
where: { id: konto.id },
|
where: { id: konto.id },
|
||||||
data: {
|
data: {
|
||||||
...(neuerVerweis ? { deltaVerweis: neuerVerweis } : {}),
|
...(neuerVerweis ? { deltaVerweis: neuerVerweis } : {}),
|
||||||
letzterAbgleich: zeitpunkt,
|
letzterAbgleich: zeitpunkt,
|
||||||
letzterFehler: null,
|
letzterFehler: null,
|
||||||
|
fehlversuche: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -324,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 {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { abgleichEntscheiden, zeitraumHash } from '@/lib/zeitblock'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Rückweg: Änderungen an Zeitblöcken aus dem Kalender übernehmen.
|
||||||
|
*
|
||||||
|
* Läuft nach jedem Abgleich eines Kontos. Für jeden Zeitblock wird verglichen,
|
||||||
|
* was im Kalender steht — der örtliche Spiegel (Tabelle `termin`) ist dafür
|
||||||
|
* bereits auf dem neuesten Stand.
|
||||||
|
*
|
||||||
|
* **Der Kalender gewinnt.** Wer dort etwas verschiebt, meint es so; der Planer
|
||||||
|
* zieht nach, statt zurückzuschreiben. Sonst zöge die App dem Benutzer den
|
||||||
|
* Termin unter den Händen weg.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RueckwegBericht = {
|
||||||
|
uebernommen: number
|
||||||
|
geloest: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rueckwegAnwenden(
|
||||||
|
kalenderkontoId: string,
|
||||||
|
): Promise<RueckwegBericht> {
|
||||||
|
const zeitbloecke = await db.zeitblock.findMany({
|
||||||
|
where: { kalenderkontoId, extId: { not: null } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
extId: true,
|
||||||
|
beginn: true,
|
||||||
|
ende: true,
|
||||||
|
hash: true,
|
||||||
|
aufgabeId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (zeitbloecke.length === 0) return { uebernommen: 0, geloest: 0 }
|
||||||
|
|
||||||
|
const extIds = zeitbloecke.map((z) => z.extId).filter((id): id is string => id !== null)
|
||||||
|
|
||||||
|
const termine = await db.termin.findMany({
|
||||||
|
where: { kalenderkontoId, extId: { in: extIds } },
|
||||||
|
select: { extId: true, beginn: true, ende: true, etag: true },
|
||||||
|
})
|
||||||
|
const nachExtId = new Map(termine.map((t) => [t.extId, t]))
|
||||||
|
|
||||||
|
let uebernommen = 0
|
||||||
|
let geloest = 0
|
||||||
|
|
||||||
|
for (const zeitblock of zeitbloecke) {
|
||||||
|
const imKalender = nachExtId.get(zeitblock.extId as string) ?? null
|
||||||
|
const entscheidung = abgleichEntscheiden(
|
||||||
|
{ hash: zeitblock.hash, beginn: zeitblock.beginn, ende: zeitblock.ende },
|
||||||
|
imKalender ? { beginn: imKalender.beginn, ende: imKalender.ende } : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (entscheidung.tun === 'nichts') continue
|
||||||
|
|
||||||
|
if (entscheidung.tun === 'loesen') {
|
||||||
|
// Im Kalender gelöscht: der Zeitblock verliert seine Verankerung. Die
|
||||||
|
// Aufgabe bleibt bestehen und fällt zurück auf offen, wenn nichts
|
||||||
|
// anderes mehr für sie geplant ist.
|
||||||
|
await db.zeitblock.delete({ where: { id: zeitblock.id } })
|
||||||
|
geloest++
|
||||||
|
|
||||||
|
const nochGeplant = await db.zeitblock.count({
|
||||||
|
where: { aufgabeId: zeitblock.aufgabeId },
|
||||||
|
})
|
||||||
|
if (nochGeplant === 0) {
|
||||||
|
await db.aufgabe.updateMany({
|
||||||
|
where: { id: zeitblock.aufgabeId, status: 'GEPLANT' },
|
||||||
|
data: { status: 'OFFEN' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.zeitblock.update({
|
||||||
|
where: { id: zeitblock.id },
|
||||||
|
data: {
|
||||||
|
beginn: entscheidung.beginn,
|
||||||
|
ende: entscheidung.ende,
|
||||||
|
// Den Fingerabdruck auf den übernommenen Stand setzen, damit die
|
||||||
|
// nächste Runde ihn wiedererkennt und nichts mehr tut.
|
||||||
|
hash: zeitraumHash(entscheidung.beginn, entscheidung.ende),
|
||||||
|
etag: imKalender?.etag ?? null,
|
||||||
|
letzterFehler: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
uebernommen++
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uebernommen, geloest }
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreiben in die Kalender der Anbieter.
|
||||||
|
*
|
||||||
|
* Jeder Schreibzugriff auf einen bestehenden Termin geht mit `If-Match` und
|
||||||
|
* dem zuletzt gelesenen Etag heraus. Hat inzwischen jemand anderes den Termin
|
||||||
|
* geändert, antwortet der Anbieter mit 412 — dann **gewinnt der Kalender**,
|
||||||
|
* und der Planer übernimmt dessen Stand, statt ihn zu überschreiben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Der Termin wurde inzwischen anderswo geändert. */
|
||||||
|
export class KalenderGewinnt extends Error {
|
||||||
|
constructor(anbieter: string) {
|
||||||
|
super(`Der Termin wurde in ${anbieter} zwischenzeitlich geändert.`)
|
||||||
|
this.name = 'KalenderGewinnt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Termin ist im Kalender nicht mehr vorhanden. */
|
||||||
|
export class TerminFort extends Error {
|
||||||
|
constructor(anbieter: string) {
|
||||||
|
super(`Der Termin ist in ${anbieter} nicht mehr vorhanden.`)
|
||||||
|
this.name = 'TerminFort'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SchreibEingabe = {
|
||||||
|
anbieter: KalenderAnbieter
|
||||||
|
kalenderId: string
|
||||||
|
token: string
|
||||||
|
betreff: string
|
||||||
|
beschreibung: string | null
|
||||||
|
beginn: Date
|
||||||
|
ende: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SchreibErgebnis = { extId: string; etag: string | null }
|
||||||
|
|
||||||
|
const graphAntwort = z.looseObject({
|
||||||
|
id: z.string().min(1),
|
||||||
|
'@odata.etag': z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const googleAntwort = z.looseObject({
|
||||||
|
id: z.string().min(1),
|
||||||
|
etag: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function graphBasis(kalenderId: string): string {
|
||||||
|
return kalenderId === 'standard'
|
||||||
|
? 'https://graph.microsoft.com/v1.0/me/events'
|
||||||
|
: `https://graph.microsoft.com/v1.0/me/calendars/${encodeURIComponent(kalenderId)}/events`
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleBasis(kalenderId: string): string {
|
||||||
|
const kalender = encodeURIComponent(kalenderId === 'standard' ? 'primary' : kalenderId)
|
||||||
|
return `https://www.googleapis.com/calendar/v3/calendars/${kalender}/events`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Rumpf des Termins.
|
||||||
|
*
|
||||||
|
* Graph erwartet Zeit und Zone getrennt; wir schicken UTC, damit auf beiden
|
||||||
|
* Wegen dasselbe gilt wie beim Lesen. Google nimmt einen ISO-Zeitpunkt mit
|
||||||
|
* Versatz direkt entgegen.
|
||||||
|
*/
|
||||||
|
function rumpf(eingabe: SchreibEingabe): Record<string, unknown> {
|
||||||
|
if (eingabe.anbieter === 'GRAPH') {
|
||||||
|
return {
|
||||||
|
subject: eingabe.betreff,
|
||||||
|
body: { contentType: 'text', content: eingabe.beschreibung ?? '' },
|
||||||
|
start: { dateTime: eingabe.beginn.toISOString(), timeZone: 'UTC' },
|
||||||
|
end: { dateTime: eingabe.ende.toISOString(), timeZone: 'UTC' },
|
||||||
|
showAs: 'busy',
|
||||||
|
// Damit im Kalender erkennbar ist, woher der Termin stammt.
|
||||||
|
categories: ['Planer'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: eingabe.betreff,
|
||||||
|
description: eingabe.beschreibung ?? '',
|
||||||
|
start: { dateTime: eingabe.beginn.toISOString() },
|
||||||
|
end: { dateTime: eingabe.ende.toISOString() },
|
||||||
|
transparency: 'opaque',
|
||||||
|
source: { title: 'Planer', url: process.env.APP_URL ?? '' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function senden(
|
||||||
|
anbieter: KalenderAnbieter,
|
||||||
|
adresse: string,
|
||||||
|
verfahren: 'POST' | 'PATCH' | 'DELETE',
|
||||||
|
token: string,
|
||||||
|
inhalt: Record<string, unknown> | null,
|
||||||
|
etag: string | null,
|
||||||
|
): Promise<Response> {
|
||||||
|
const kopfzeilen: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
}
|
||||||
|
if (inhalt) kopfzeilen['Content-Type'] = 'application/json'
|
||||||
|
// Ohne Etag kein If-Match: ein Schreibzugriff ohne Vergleichsstand darf
|
||||||
|
// nicht als "unverändert" durchgehen.
|
||||||
|
if (etag) kopfzeilen['If-Match'] = etag
|
||||||
|
|
||||||
|
const antwort = await fetch(adresse, {
|
||||||
|
method: verfahren,
|
||||||
|
headers: kopfzeilen,
|
||||||
|
...(inhalt ? { body: JSON.stringify(inhalt) } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (antwort.status === 412) throw new KalenderGewinnt(anbieter)
|
||||||
|
if (antwort.status === 404 || antwort.status === 410) throw new TerminFort(anbieter)
|
||||||
|
|
||||||
|
return antwort
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt einen Termin an. */
|
||||||
|
export async function terminAnlegen(eingabe: SchreibEingabe): Promise<SchreibErgebnis> {
|
||||||
|
const adresse =
|
||||||
|
eingabe.anbieter === 'GRAPH'
|
||||||
|
? graphBasis(eingabe.kalenderId)
|
||||||
|
: googleBasis(eingabe.kalenderId)
|
||||||
|
|
||||||
|
const antwort = await senden(
|
||||||
|
eingabe.anbieter,
|
||||||
|
adresse,
|
||||||
|
'POST',
|
||||||
|
eingabe.token,
|
||||||
|
rumpf(eingabe),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new Error(`Der Termin ließ sich nicht anlegen (${antwort.status}).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inhalt: unknown = await antwort.json()
|
||||||
|
if (eingabe.anbieter === 'GRAPH') {
|
||||||
|
const gelesen = graphAntwort.safeParse(inhalt)
|
||||||
|
if (!gelesen.success) throw new Error('Die Antwort von Graph war nicht verwertbar.')
|
||||||
|
return { extId: gelesen.data.id, etag: gelesen.data['@odata.etag'] ?? null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = googleAntwort.safeParse(inhalt)
|
||||||
|
if (!gelesen.success) throw new Error('Die Antwort von Google war nicht verwertbar.')
|
||||||
|
return { extId: gelesen.data.id, etag: gelesen.data.etag ?? null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ändert einen bestehenden Termin. */
|
||||||
|
export async function terminAendern(
|
||||||
|
eingabe: SchreibEingabe & { extId: string; etag: string | null },
|
||||||
|
): Promise<SchreibErgebnis> {
|
||||||
|
const basis =
|
||||||
|
eingabe.anbieter === 'GRAPH'
|
||||||
|
? graphBasis(eingabe.kalenderId)
|
||||||
|
: googleBasis(eingabe.kalenderId)
|
||||||
|
const adresse = `${basis}/${encodeURIComponent(eingabe.extId)}`
|
||||||
|
|
||||||
|
const antwort = await senden(
|
||||||
|
eingabe.anbieter,
|
||||||
|
adresse,
|
||||||
|
'PATCH',
|
||||||
|
eingabe.token,
|
||||||
|
rumpf(eingabe),
|
||||||
|
eingabe.etag,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new Error(`Der Termin ließ sich nicht ändern (${antwort.status}).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inhalt: unknown = await antwort.json()
|
||||||
|
if (eingabe.anbieter === 'GRAPH') {
|
||||||
|
const gelesen = graphAntwort.safeParse(inhalt)
|
||||||
|
return {
|
||||||
|
extId: eingabe.extId,
|
||||||
|
etag: gelesen.success ? (gelesen.data['@odata.etag'] ?? null) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = googleAntwort.safeParse(inhalt)
|
||||||
|
return {
|
||||||
|
extId: eingabe.extId,
|
||||||
|
etag: gelesen.success ? (gelesen.data.etag ?? null) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt einen Termin. Ist er ohnehin schon fort, gilt das als erledigt —
|
||||||
|
* das Ziel ist erreicht.
|
||||||
|
*/
|
||||||
|
export async function terminEntfernen(eingabe: {
|
||||||
|
anbieter: KalenderAnbieter
|
||||||
|
kalenderId: string
|
||||||
|
token: string
|
||||||
|
extId: string
|
||||||
|
etag: string | null
|
||||||
|
}): Promise<void> {
|
||||||
|
const basis =
|
||||||
|
eingabe.anbieter === 'GRAPH'
|
||||||
|
? graphBasis(eingabe.kalenderId)
|
||||||
|
: googleBasis(eingabe.kalenderId)
|
||||||
|
const adresse = `${basis}/${encodeURIComponent(eingabe.extId)}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const antwort = await senden(
|
||||||
|
eingabe.anbieter,
|
||||||
|
adresse,
|
||||||
|
'DELETE',
|
||||||
|
eingabe.token,
|
||||||
|
null,
|
||||||
|
eingabe.etag,
|
||||||
|
)
|
||||||
|
if (!antwort.ok && antwort.status !== 204) {
|
||||||
|
throw new Error(`Der Termin ließ sich nicht entfernen (${antwort.status}).`)
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof TerminFort) return
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,22 @@ const schema = z.object({
|
|||||||
GOOGLE_CLIENT_ID: z.string().trim().default(''),
|
GOOGLE_CLIENT_ID: z.string().trim().default(''),
|
||||||
GOOGLE_CLIENT_SECRET: z.string().trim().default(''),
|
GOOGLE_CLIENT_SECRET: z.string().trim().default(''),
|
||||||
|
|
||||||
|
// --- Benachrichtigungen (M5) ---
|
||||||
|
// VAPID-Schluessel fuer Web-Push. Erzeugen mit:
|
||||||
|
// npx web-push generate-vapid-keys
|
||||||
|
VAPID_OEFFENTLICH: z.string().trim().default(''),
|
||||||
|
VAPID_PRIVAT: z.string().trim().default(''),
|
||||||
|
/// Kontaktadresse fuer den Push-Dienst, als mailto:.
|
||||||
|
VAPID_KONTAKT: z.string().trim().default(''),
|
||||||
|
|
||||||
|
SMTP_HOST: z.string().trim().default(''),
|
||||||
|
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
|
||||||
|
SMTP_BENUTZER: z.string().trim().default(''),
|
||||||
|
SMTP_PASSWORT: z.string().default(''),
|
||||||
|
SMTP_ABSENDER: z.string().trim().default(''),
|
||||||
|
/// Implizites TLS (Port 465). Sonst wird STARTTLS versucht.
|
||||||
|
SMTP_TLS: jaNein.default(false),
|
||||||
|
|
||||||
DEMO_MODUS: jaNein.default(false),
|
DEMO_MODUS: jaNein.default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,6 +67,9 @@ export type Umgebung = z.infer<typeof schema> & {
|
|||||||
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
|
/** Graph laesst sich verbinden, sobald Kennung und Geheimnis stehen. */
|
||||||
graphAktiv: boolean
|
graphAktiv: boolean
|
||||||
googleAktiv: boolean
|
googleAktiv: boolean
|
||||||
|
/** Web-Push ist erst mit vollstaendigem VAPID-Satz moeglich. */
|
||||||
|
pushAktiv: boolean
|
||||||
|
mailAktiv: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let zwischengespeichert: Umgebung | null = null
|
let zwischengespeichert: Umgebung | null = null
|
||||||
@@ -72,6 +91,10 @@ function lesen(): Umgebung {
|
|||||||
),
|
),
|
||||||
graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET),
|
graphAktiv: Boolean(werte.GRAPH_CLIENT_ID && werte.GRAPH_CLIENT_SECRET),
|
||||||
googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET),
|
googleAktiv: Boolean(werte.GOOGLE_CLIENT_ID && werte.GOOGLE_CLIENT_SECRET),
|
||||||
|
pushAktiv: Boolean(
|
||||||
|
werte.VAPID_OEFFENTLICH && werte.VAPID_PRIVAT && werte.VAPID_KONTAKT,
|
||||||
|
),
|
||||||
|
mailAktiv: Boolean(werte.SMTP_HOST && werte.SMTP_ABSENDER),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||