Compare commits
@@ -59,6 +59,44 @@ WEBAUTHN_RP_NAME=Planer
|
||||
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
|
||||
WEBAUTHN_ORIGIN=http://localhost:3000
|
||||
|
||||
# --- Kalenderanbieter ------------------------------------------------------
|
||||
# Graph gibt es in beiden Instanzen, Google nur daheim. Ein Anbieter erscheint
|
||||
# erst, wenn Kennung UND Geheimnis gesetzt sind.
|
||||
# Einrichtung siehe README, Abschnitt "Kalenderanbieter einrichten".
|
||||
# Rueckleit-URIs:
|
||||
# <APP_URL>/api/kalender/rueckleitung/graph
|
||||
# <APP_URL>/api/kalender/rueckleitung/google
|
||||
GRAPH_CLIENT_ID=
|
||||
GRAPH_CLIENT_SECRET=
|
||||
# Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant.
|
||||
GRAPH_TENANT_ID=organizations
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
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 -------------------------------------------------------------
|
||||
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
||||
TZ=Europe/Berlin
|
||||
|
||||
@@ -8,3 +8,4 @@ test-results
|
||||
playwright-report
|
||||
coverage
|
||||
devlog/zeiten.csv
|
||||
public/sw.js
|
||||
|
||||
@@ -6,6 +6,100 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [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
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- Kalenderkonten für Microsoft 365 (Graph) und Google verbinden, umbenennen,
|
||||
von Hand abgleichen und trennen. Google gibt es nur in der Instanz
|
||||
`daheim`; in `firma` liefert die Route 404.
|
||||
- Termine werden örtlich gespiegelt: 7 Tage zurück bis 90 Tage voraus.
|
||||
Gelesen werden nur Beginn, Ende, Betreff, ganztägig und "zeigt an als" —
|
||||
keine Teilnehmer, Beschreibungen, Anhänge oder Orte.
|
||||
- Die Startseite zeigt je Bereich, wie voll der heutige Tag ist: Termine aus
|
||||
allen verbundenen Kalendern gegen die Tageskapazität.
|
||||
- Hintergrundjobs: alle 15 Minuten abgleichen, nachts alte Termine und
|
||||
abgelaufene WebAuthn-Herausforderungen entfernen.
|
||||
- Handbuchseite zu den Kalenderkonten, Einrichtungsanleitung für Entra ID
|
||||
und die Google Cloud Console in der README.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Zugangs- und Aktualisierungstoken liegen verschlüsselt in der Datenbank
|
||||
(AES-256-GCM, Schlüssel aus AUTH_SECRET abgeleitet). Wer eine Sicherung in
|
||||
die Hände bekommt, kann damit ohne AUTH_SECRET nichts anfangen.
|
||||
- Der Zustandswert im OAuth-Ablauf ist signiert und trägt die
|
||||
Benutzerkennung; ohne das ließe sich ein fremdes Kalenderkonto an ein
|
||||
beliebiges Konto hängen. Er verfällt nach 30 Minuten.
|
||||
- Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der
|
||||
enthält das Client-Secret oft im Echo.
|
||||
- Protokolliert werden beim Abgleich nur Zahlen, nie Kontonamen oder
|
||||
Termininhalte.
|
||||
|
||||
## [0.2.0] – 2026-09-10
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -7,10 +7,11 @@ lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
|
||||
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
||||
wird.
|
||||
|
||||
> **Stand:** Meilenstein 2 von 8. Erfassen und Wiederfinden steht: Aufgaben,
|
||||
> Inbox, Ideen und Projekte, dazu Anmeldung, Rollen, Protokoll und der Betrieb.
|
||||
> Das Lesen der Kalender kommt mit M3, die Wochenplanung per Ziehen und Ablegen
|
||||
> mit M4. Der [Fahrplan](ROADMAP.md) sagt, was wann folgt.
|
||||
> **Stand:** Meilenstein 4 von 8. Die Kernidee steht: Aufgaben lassen sich
|
||||
> per Ziehen und Ablegen als Zeitblöcke in den Kalender schreiben, und
|
||||
> 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.
|
||||
|
||||
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
||||
zur ersten Anmeldung
|
||||
@@ -28,6 +29,12 @@ wird.
|
||||
nach Dringlichkeit und filterbar über die Adresszeile
|
||||
- **Ideen** getrennt von Aufgaben — ohne Termin, per Klick übernehmbar
|
||||
- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt
|
||||
- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je
|
||||
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
|
||||
Passwort — je Instanz einzeln zuschaltbar
|
||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||
@@ -38,23 +45,25 @@ wird.
|
||||
|
||||
### Geplant
|
||||
|
||||
Kalender lesen und schreiben, Wochenplanung per Ziehen und Ablegen,
|
||||
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
|
||||
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
Integrationen, Schnellerfassung vom Handy und ein MCP-Server.
|
||||
Siehe [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
## Bildergalerie
|
||||
|
||||
| | Hell | Dunkel |
|
||||
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Anmelden |  |  |
|
||||
| Start |  |  |
|
||||
| Inbox |  |  |
|
||||
| Aufgaben |  |  |
|
||||
| Projekte |  |  |
|
||||
| Ideen |  |  |
|
||||
| Bereiche |  |  |
|
||||
| Einstellungen |  |  |
|
||||
| Protokoll |  |  |
|
||||
| | Hell | Dunkel |
|
||||
| ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| Anmelden |  |  |
|
||||
| Start |  |  |
|
||||
| Inbox |  |  |
|
||||
| Woche |  |  |
|
||||
| Aufgaben |  |  |
|
||||
| Projekte |  |  |
|
||||
| Ideen |  |  |
|
||||
| Kalender |  |  |
|
||||
| Bereiche |  |  |
|
||||
| Einstellungen |  |  |
|
||||
| Meldungen |  |  |
|
||||
| Protokoll |  |  |
|
||||
|
||||
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
||||
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
|
||||
@@ -117,6 +126,51 @@ Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example).
|
||||
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
|
||||
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
||||
|
||||
### Kalenderanbieter
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
| ---------------------- | --------------- | ------------------------------------------------------------- |
|
||||
| `GRAPH_CLIENT_ID` | _(leer)_ | Anwendungs-ID der App-Registrierung in Entra ID |
|
||||
| `GRAPH_CLIENT_SECRET` | _(leer)_ | Geheimnis dieser Registrierung |
|
||||
| `GRAPH_TENANT_ID` | `organizations` | Tenant-ID, oder `organizations` für jeden Arbeitsplatz-Tenant |
|
||||
| `GOOGLE_CLIENT_ID` | _(leer)_ | OAuth-Client-ID aus der Google Cloud Console |
|
||||
| `GOOGLE_CLIENT_SECRET` | _(leer)_ | Geheimnis dieses Clients |
|
||||
|
||||
Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
|
||||
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
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
@@ -126,6 +180,59 @@ Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
||||
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
|
||||
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
|
||||
|
||||
## Kalenderanbieter einrichten
|
||||
|
||||
Beides ist einmalige Handarbeit beim Anbieter. Der Planer fragt Lese- **und**
|
||||
Schreibrechte an: ab M4 schreibt er Zeitblöcke zurück, und eine zweite
|
||||
Zustimmungsrunde für jedes Konto wäre lästiger als eine etwas breitere
|
||||
Berechtigung von Anfang an.
|
||||
|
||||
### Microsoft 365 (Entra ID)
|
||||
|
||||
1. <https://entra.microsoft.com> → **Identität → Anwendungen →
|
||||
App-Registrierungen → Neue Registrierung**.
|
||||
2. Name frei wählen, etwa „Planer".
|
||||
3. Unterstützte Kontotypen: **Nur Konten in diesem Organisationsverzeichnis**
|
||||
(dann `GRAPH_TENANT_ID` auf die Tenant-ID setzen) oder
|
||||
**Konten in einem beliebigen Organisationsverzeichnis** (dann
|
||||
`organizations` belassen).
|
||||
4. Umleitungs-URI, Plattform **Web**:
|
||||
`<APP_URL>/api/kalender/rueckleitung/graph`
|
||||
5. Nach dem Anlegen: **Anwendungs-ID (Client)** nach `GRAPH_CLIENT_ID`,
|
||||
**Verzeichnis-ID (Mandant)** nach `GRAPH_TENANT_ID`.
|
||||
6. **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Den Wert
|
||||
sofort nach `GRAPH_CLIENT_SECRET` kopieren — er wird nur einmal angezeigt.
|
||||
7. **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph →
|
||||
Delegierte Berechtigungen**: `Calendars.ReadWrite`, `offline_access`,
|
||||
`openid`, `email`.
|
||||
|
||||
> Ist im Tenant die Zustimmung durch Endbenutzer abgeschaltet, muss ein
|
||||
> Administrator einmalig **Administratorzustimmung erteilen** klicken.
|
||||
|
||||
### Google Kalender
|
||||
|
||||
1. <https://console.cloud.google.com> → Projekt anlegen oder wählen.
|
||||
2. **APIs & Dienste → Bibliothek** → _Google Calendar API_ aktivieren.
|
||||
3. **OAuth-Zustimmungsbildschirm** einrichten. Für den Eigengebrauch genügt
|
||||
_Extern_ mit Veröffentlichungsstatus _Test_ und der eigenen Adresse als
|
||||
Testbenutzer.
|
||||
4. Bereiche (Scopes) hinzufügen:
|
||||
`.../auth/calendar.events` und `.../auth/calendar.readonly`.
|
||||
5. **Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID**,
|
||||
Anwendungstyp **Webanwendung**.
|
||||
6. Autorisierter Weiterleitungs-URI:
|
||||
`<APP_URL>/api/kalender/rueckleitung/google`
|
||||
7. Client-ID und Clientschlüssel nach `GOOGLE_CLIENT_ID` und
|
||||
`GOOGLE_CLIENT_SECRET`.
|
||||
|
||||
> Bleibt das Projekt im Status _Test_, laufen die Aktualisierungstoken nach
|
||||
> sieben Tagen ab und die Konten müssen neu verbunden werden. Für den
|
||||
> Dauerbetrieb den Zustimmungsbildschirm **veröffentlichen**.
|
||||
|
||||
Nach dem Eintragen: `docker compose up -d` und im Planer unter **Kalender**
|
||||
verbinden. Die Anleitung für den Alltag steht im
|
||||
[Handbuch](docs/handbuch/kalenderkonten.md).
|
||||
|
||||
## Betrieb
|
||||
|
||||
### Starten und stoppen
|
||||
|
||||
@@ -7,9 +7,9 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
||||
| --------------------------------------- | ---------------------------- | -------- |
|
||||
| [M1](#m1--fundament) | Fundament | erledigt |
|
||||
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
|
||||
| [M3](#m3--kalender-lesen) | Kalender lesen | geplant |
|
||||
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant |
|
||||
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
|
||||
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
|
||||
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
|
||||
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
|
||||
| [M6](#m6--integrationen) | Integrationen | geplant |
|
||||
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
|
||||
| [M8](#m8--mcp) | MCP | geplant |
|
||||
@@ -63,33 +63,52 @@ später" und wartet darauf, ob es im Alltag wirklich fehlt.
|
||||
|
||||
## M3 – Kalender lesen
|
||||
|
||||
**Erledigt am 10.09.2026 mit Version 0.3.0.**
|
||||
|
||||
**Ziel:** Termine aus Microsoft 365 und Google sehen.
|
||||
|
||||
- [ ] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
||||
- [ ] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
||||
- [ ] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
||||
- [ ] Ganztägige Termine zählen nicht zur Belegung
|
||||
- [ ] Anonymisierte Beispielantworten in `test/fixtures/`
|
||||
- [ ] Hintergrundjob mit node-cron
|
||||
- [x] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
||||
- [x] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
||||
- [x] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
||||
- [x] Ganztägige Termine zählen nicht zur Belegung
|
||||
- [x] Anonymisierte Beispielantworten in `test/fixtures/`
|
||||
- [x] Hintergrundjob mit node-cron
|
||||
|
||||
## 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
|
||||
Verschiebungen von dort zurückholen.
|
||||
|
||||
- [ ] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
|
||||
- [ ] Wochenansicht mit `@dnd-kit/core`
|
||||
- [ ] Schreiben in den Kalender, Rückweg beim Verschieben
|
||||
- [ ] Schleifenschutz über `hash = sha256(startISO|endeISO)`
|
||||
- [ ] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
|
||||
- [ ] Belegung gegen die Tageskapazität des Bereichs
|
||||
- [x] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
|
||||
- [x] Wochenansicht mit `@dnd-kit/core`
|
||||
- [x] Schreiben in den Kalender, Rückweg beim Verschieben
|
||||
- [x] Schleifenschutz über `hash = sha256(startISO|endeISO)`
|
||||
- [x] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
|
||||
- [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
|
||||
|
||||
- [ ] Web-Push mit `web-push`
|
||||
- [ ] Tagesüberblick per E-Mail über nodemailer
|
||||
- [ ] Erinnerungen an Fälligkeiten, Jobs über node-cron
|
||||
- [ ] Je Benutzer einstellbar, mit Ruhezeiten
|
||||
**Erledigt am 11.09.2026 mit Version 0.5.0.**
|
||||
|
||||
**Ziel:** Sagen, was ansteht — und dabei so selten stören, dass niemand auf
|
||||
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
|
||||
|
||||
|
||||
@@ -5,11 +5,273 @@ die Übersicht aus `scripts/zeit.sh summe`.
|
||||
|
||||
## Zeit je Meilenstein
|
||||
|
||||
| Meilenstein | Art | Minuten | Stunden |
|
||||
| ----------- | ------ | ------- | -------- |
|
||||
| M1 | claude | 161 | 2,68 |
|
||||
| M2 | claude | 44 | 0,73 |
|
||||
| **Gesamt** | | **205** | **3,42** |
|
||||
| Meilenstein | Art | Minuten | Stunden |
|
||||
| ----------- | ------ | ------- | --------- |
|
||||
| M1 | claude | 161 | 2,68 |
|
||||
| M2 | claude | 44 | 0,73 |
|
||||
| M3 | claude | 41 | 0,68 |
|
||||
| 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
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-10 – M3: Kalender lesen
|
||||
|
||||
**Dauer:** 41 Minuten (17:48–18:29), Art `claude`.
|
||||
|
||||
**Ziel der Sitzung:** Termine aus Microsoft 365 und Google lesen, damit der
|
||||
Planer weiß, wann überhaupt Platz ist.
|
||||
|
||||
### Erledigt
|
||||
|
||||
- `Kalenderkonto` und `Termin` samt Migration; Bereich bekommt sein
|
||||
Standard-Kalenderkonto für M4
|
||||
- Tokenverschlüsselung mit AES-256-GCM
|
||||
- Antworten beider Anbieter mit zod geprüft und in eine gemeinsame Form
|
||||
gebracht, geprüft gegen anonymisierte Beispielantworten
|
||||
- Belegungsrechnung: Kapazität gegen Termine, freie Fenster für M4
|
||||
- OAuth-Verbinden, Tokenerneuerung, seitenweiser Abgleich mit Rückfall auf
|
||||
einen vollständigen Durchlauf
|
||||
- Seite für die Kalenderkonten, Belegung des Tages auf der Startseite
|
||||
- Hintergrundjobs für Abgleich und nächtliches Aufräumen
|
||||
- 68 neue Unit-Tests, 10 neue Integrationsprüfungen
|
||||
- Handbuchseite und Einrichtungsanleitung für Entra ID und Google
|
||||
|
||||
### Entscheidungen mit Begründung
|
||||
|
||||
**Tokens verschlüsselt, Schlüssel aus AUTH_SECRET abgeleitet.** Wer die
|
||||
Datenbank oder eine Sicherung bekommt, hätte sonst direkten Zugriff auf
|
||||
fremde Kalender — das wiegt schwerer als bei den OIDC-Tokens aus M1, die
|
||||
nur die Anmeldung betreffen. Eine eigene Pflichtvariable wäre eine weitere
|
||||
Stelle, an der die Einrichtung scheitert; die Ableitung über HKDF kostet
|
||||
nichts und hat die richtige Nebenwirkung: wer AUTH_SECRET wechselt, muss
|
||||
die Konten neu verbinden — dann sind ohnehin alle Sitzungen ungültig.
|
||||
|
||||
**Bei fremder Zeitzone abbrechen statt raten.** Graph liefert Datum und
|
||||
Zeitzone getrennt, ohne Versatz im Text. Wir fragen ausdrücklich UTC an;
|
||||
kommt etwas anderes zurück, wäre jede Umrechnung geraten und würde Termine
|
||||
um Stunden verschieben. Ein Abbruch mit klarer Meldung ist besser als ein
|
||||
Kalender, der leise falsch liegt.
|
||||
|
||||
**Unbekanntes showAs zählt als belegt.** Lieber eine Stunde zu viel
|
||||
blockiert als eine Doppelbuchung.
|
||||
|
||||
**Schreibrechte schon jetzt anfragen.** M3 liest nur, aber M4 schreibt.
|
||||
Später erneut zuzustimmen ist genau der Schritt, an dem Einrichtung
|
||||
scheitert — einmal breiter fragen ist das kleinere Übel.
|
||||
|
||||
**Ganztägige Termine zählen nicht zur Belegung.** Ein Urlaubstag oder
|
||||
Geburtstag blockiert sonst den ganzen Tag, obwohl er keine Arbeitszeit
|
||||
kostet.
|
||||
|
||||
**Alle Kalender zählen gegen jeden Bereich.** Ein Termin blockiert die Zeit
|
||||
ohnehin, gleich zu welchem Lebensbereich er gehört.
|
||||
|
||||
**Kein SDK für Graph und Google.** Es geht um drei Aufrufe je Anbieter, und
|
||||
die Antworten werden ohnehin mit zod geprüft. Ein SDK brächte vor allem
|
||||
Abhängigkeiten und blinde Flecken.
|
||||
|
||||
### Probleme und Lösungen
|
||||
|
||||
| Problem | Lösung |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Prisma lässt in einem zusammengesetzten Unique-Schlüssel kein null zu | `kalenderId` ist nicht optional, sondern trägt den sprechenden Wert `"standard"` für den Standardkalender. |
|
||||
| Googles `syncToken` verträgt sich nicht mit `timeMin`/`timeMax` | Folgeabgleiche liefern auch Änderungen außerhalb des Fensters. Was daneben liegt, wird beim Spiegeln verworfen — sonst wüchse der Spiegel unbegrenzt. |
|
||||
| Der Verweis für die nächste Runde kann ablaufen (HTTP 410) | Verweis verwerfen, Spiegel leeren, von vorn lesen. Der Ablauf ist so gebaut, dass ein vollständiger Durchlauf jederzeit möglich ist. |
|
||||
| Die Shell zerlegte in mehreren Anläufen Template-Literale und Backticks | Auf `node - <<'SKRIPT'` umgestellt; damit geht der Text unverändert durch. |
|
||||
|
||||
### Offene Punkte
|
||||
|
||||
- **Nicht gegen die echten APIs getestet.** Ich habe keine Zugangsdaten für
|
||||
Entra ID oder Google. Provider und Abgleich sind gegen anonymisierte
|
||||
Beispielantworten geprüft; den ersten echten Verbindungsversuch muss
|
||||
Florian machen. Die Anleitung dafür steht in der README.
|
||||
- Nur der Standardkalender je Konto. Mehrere Kalender desselben Kontos sind
|
||||
im Datenmodell vorgesehen, es fehlt die Auswahl in der Oberfläche.
|
||||
- Der Abgleich läuft in einer Schleife über alle Konten. Bei vielen Konten
|
||||
wäre das nacheinander zu langsam; bei der erwarteten Zahl ist es das
|
||||
einfachere Verhalten.
|
||||
|
||||
### Commits
|
||||
|
||||
- 79e6c0d feat(kalender): Datenmodell für Kalenderkonten und Termine
|
||||
- 6fda2a5 feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen
|
||||
- 9946e7c feat(kalender): OAuth und Abgleich für Graph und Google
|
||||
- 0c67b73 feat(kalender): Seite für die Kalenderkonten
|
||||
- 42c98c8 feat(kalender): Hintergrundjobs für Abgleich und Aufräumen
|
||||
- d8d3227 feat(kalender): Belegung des Tages auf der Startseite
|
||||
- df50627 test: Verschlüsselung und Kalendermodelle absichern
|
||||
- a8b2ff0 docs: Handbuchseite und Einrichtung der Kalenderanbieter
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
datum;start;ende;minuten;meilenstein;art;notiz
|
||||
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;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_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
|
||||
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:
|
||||
- '${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: 276 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 282 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: 232 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 225 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 |
@@ -16,10 +16,16 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
||||
4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
|
||||
5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
|
||||
Fortschritt
|
||||
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
|
||||
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
|
||||
|
||||
6. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||
9. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||
|
||||
## Was noch kommt
|
||||
|
||||
@@ -27,9 +33,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
|
||||
|
||||
| Thema | Meilenstein |
|
||||
| ------------------------------- | ----------- |
|
||||
| Kalenderkonten verbinden | M3 |
|
||||
| Wochenplanung und Zeitblöcke | M4 |
|
||||
| Benachrichtigungen | M5 |
|
||||
| Integrationen | M6 |
|
||||
| Schnellerfassung vom Handy, API | M7 |
|
||||
| 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,116 @@
|
||||
# Kalenderkonten verbinden
|
||||
|
||||
Der Planer liest deine Termine, um zu wissen, **wann überhaupt Platz ist**.
|
||||
Ab M4 schreibt er Zeitblöcke in denselben Kalender zurück.
|
||||
|
||||

|
||||
|
||||
## Was der Planer liest — und was nicht
|
||||
|
||||
| Liest er | Nutzt er wofür |
|
||||
| --------------------------------- | ------------------------------------------------ |
|
||||
| Beginn und Ende | Belegung gegen die Tageskapazität |
|
||||
| Betreff | Anzeige in der Wochenansicht |
|
||||
| Ganztägig ja/nein | Ganztägige Termine zählen **nicht** zur Belegung |
|
||||
| „Zeigt an als" (frei, gebucht, …) | Als frei markierte Termine zählen nicht |
|
||||
|
||||
**Nicht gelesen** werden Teilnehmer, Beschreibungstexte, Anhänge und Orte.
|
||||
Betreffs stehen in der Datenbank, damit du sie in der Ansicht siehst — sie
|
||||
landen aber **niemals in Protokollen**.
|
||||
|
||||
Gelesen wird ein Fenster von **7 Tagen zurück bis 90 Tage voraus**. Was aus
|
||||
diesem Fenster herauswandert, wird nachts wieder entfernt.
|
||||
|
||||
## Einen Kalender verbinden
|
||||
|
||||
1. Im Menü auf **Kalender**.
|
||||
2. Auf **Microsoft 365 verbinden** beziehungsweise **Google verbinden**.
|
||||
3. Beim Anbieter anmelden und der Berechtigung zustimmen.
|
||||
4. Du landest wieder hier, und der erste Abgleich läuft sofort.
|
||||
|
||||
> **Warum fragt der Planer auch nach Schreibrechten?** Weil er ab M4
|
||||
> Zeitblöcke in den Kalender schreibt. Würde er jetzt nur Leserechte
|
||||
> anfordern, müsstest du später jedem Konto erneut zustimmen — und genau
|
||||
> daran scheitert Einrichtung erfahrungsgemäß.
|
||||
|
||||
### Welche Anbieter es gibt
|
||||
|
||||
Das hängt an der Instanz und ist nicht verhandelbar:
|
||||
|
||||
| | Firma | Daheim |
|
||||
| ------------- | -------- | ------ |
|
||||
| Microsoft 365 | ja | ja |
|
||||
| Google | **nein** | ja |
|
||||
|
||||
In der Firmeninstanz gibt es die Google-Seite nicht — der Aufruf endet dort
|
||||
mit „Diese Seite gibt es nicht". Das ist Absicht, kein Fehler.
|
||||
|
||||
## Der Abgleich
|
||||
|
||||
Alle 15 Minuten läuft der Abgleich von selbst. Mit **Jetzt abgleichen**
|
||||
stößt du ihn von Hand an.
|
||||
|
||||
Beide Anbieter liefern nur die **Änderungen** seit dem letzten Mal, nicht
|
||||
jedes Mal alle Termine. Läuft dieser Merker ab — das passiert nach längerer
|
||||
Pause —, liest der Planer den ganzen Zeitraum neu ein. Davon merkst du außer
|
||||
einem etwas längeren Abgleich nichts.
|
||||
|
||||
## Die Belegung
|
||||
|
||||
Auf der Startseite zeigt jeder Bereich, wie voll der heutige Tag schon ist:
|
||||
Termine aus **allen** verbundenen Kalendern gegen die Tageskapazität dieses
|
||||
Bereichs.
|
||||
|
||||
> **Warum zählen alle Kalender gegen jeden Bereich?** Weil ein Termin die
|
||||
> Zeit ohnehin blockiert, ganz gleich, zu welchem Lebensbereich er gehört.
|
||||
> Wer um 14 Uhr beim Zahnarzt sitzt, kann zur selben Zeit nichts für die
|
||||
> Firma tun.
|
||||
|
||||
Gezählt wird nur, was **ins Arbeitsfenster** des Bereichs fällt. Ein Termin
|
||||
um 22 Uhr verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um
|
||||
17 Uhr endet.
|
||||
|
||||
Überlappende Termine werden zusammengelegt — eine Doppelbuchung zählt nur
|
||||
einmal.
|
||||
|
||||
## Ein Konto umbenennen
|
||||
|
||||
Die Bezeichnung ist frei wählbar. „Firma Outlook" oder „Gewerbe" liest sich
|
||||
besser als die Mailadresse, gerade wenn du mehrere Konten desselben
|
||||
Anbieters hast.
|
||||
|
||||
## Ein Konto trennen
|
||||
|
||||
Der rote Knopf, mit Rückfrage. Die gespiegelten Termine verschwinden dabei —
|
||||
**im Kalender selbst bleibt alles unangetastet**. Der Planer hat bis M4
|
||||
ohnehin nur gelesen.
|
||||
|
||||
Zusätzlich solltest du die Berechtigung beim Anbieter selbst zurückziehen:
|
||||
|
||||
- Microsoft: <https://myapps.microsoft.com> → _Zugriff verwalten_
|
||||
- Google: <https://myaccount.google.com/permissions>
|
||||
|
||||
## Wenn es klemmt
|
||||
|
||||
| Was du siehst | Was dahintersteckt |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| „Der Anbieter hat kein Aktualisierungstoken herausgegeben." | Meist bei Google, wenn du schon einmal zugestimmt hattest. Berechtigung im Google-Konto entziehen und neu verbinden. |
|
||||
| „Die Anfrage war ungültig oder zu alt." | Zwischen Klick und Zustimmung lagen mehr als 30 Minuten. Einfach neu beginnen. |
|
||||
| „Kein Aktualisierungstoken hinterlegt." | Das Konto muss neu verbunden werden. Das passiert auch, wenn `AUTH_SECRET` gewechselt wurde. |
|
||||
| „Der Anbieter antwortete mit 401." | Die Berechtigung wurde beim Anbieter entzogen. Neu verbinden. |
|
||||
| „unerwartete Zeitzone …, erwartet wurde UTC" | Ein Konfigurationsfehler auf Serverseite. Bitte melden — der Planer bricht hier bewusst ab, statt Termine um Stunden zu verschieben. |
|
||||
| Die Belegung bleibt leer | Kein Kalender verbunden, oder der erste Abgleich lief noch nicht. Die Karte zeigt die Belegung erst, wenn wirklich ein Kalender dranhängt. |
|
||||
|
||||
## Sicherheit
|
||||
|
||||
Die Zugangstoken liegen **verschlüsselt** in der Datenbank (AES-256-GCM).
|
||||
Wer eine Sicherung in die Hände bekommt, kann damit nichts anfangen, solange
|
||||
er nicht auch `AUTH_SECRET` hat.
|
||||
|
||||
Wird `AUTH_SECRET` gewechselt, müssen alle Kalenderkonten neu verbunden
|
||||
werden. Das ist gewollt: mit dem alten Geheimnis sind auch alle Sitzungen
|
||||
ungültig.
|
||||
|
||||
Für die Einrichtung auf Serverseite — was in Entra ID und in der Google
|
||||
Cloud Console anzulegen ist — siehe
|
||||
[README, Abschnitt Kalenderanbieter](../../README.md#kalenderanbieter-einrichten).
|
||||
@@ -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 { expect, test, type Page } from '@playwright/test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
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.
|
||||
@@ -9,16 +10,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||
* 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 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.skip(
|
||||
!EMAIL || !PASSWORT || !verbindung,
|
||||
!EMAIL || !PASSWORT || !VERBINDUNG,
|
||||
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
|
||||
)
|
||||
|
||||
@@ -29,14 +27,6 @@ test.describe('Aufgaben', () => {
|
||||
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 }) => {
|
||||
const titel = `${PRAEFIX} Angebot schreiben`
|
||||
await anmelden(page)
|
||||
@@ -72,6 +62,11 @@ test.describe('Aufgaben', () => {
|
||||
await expect(inListe).toBeVisible()
|
||||
await inListe.getByRole('button', { name: 'Als erledigt abhaken' }).click()
|
||||
|
||||
// Auf die Bestätigung warten, bevor es weitergeht: der Klick stößt eine
|
||||
// Server Action an, und ohne diesen Haltepunkt navigiert der Test
|
||||
// gelegentlich schneller, als gespeichert wird.
|
||||
await expect(page.getByText('ist jetzt erledigt')).toBeVisible()
|
||||
|
||||
// Erledigtes fällt aus der Standardansicht heraus.
|
||||
await page.goto('/aufgaben')
|
||||
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
||||
|
||||
@@ -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 { expect, test, type Page } from '@playwright/test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||
import { anmelden as anmeldenMit } from './hilfe'
|
||||
|
||||
/**
|
||||
* Prüft, dass die Rollentrennung serverseitig hält.
|
||||
@@ -38,23 +39,15 @@ test.describe('Rollentrennung', () => {
|
||||
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 }) => {
|
||||
await anmelden(page)
|
||||
await anmeldenMit(page, EMAIL, PASSWORT)
|
||||
await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
|
||||
page,
|
||||
}) => {
|
||||
await anmelden(page)
|
||||
await anmeldenMit(page, EMAIL, PASSWORT)
|
||||
await page.goto('/verwaltung/protokoll')
|
||||
|
||||
await expect(page).toHaveURL(/\/keine-berechtigung/)
|
||||
@@ -64,7 +57,7 @@ test.describe('Rollentrennung', () => {
|
||||
})
|
||||
|
||||
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
|
||||
await anmelden(page)
|
||||
await anmeldenMit(page, EMAIL, PASSWORT)
|
||||
await page.goto('/bereiche')
|
||||
|
||||
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.
|
||||
'src/generated/**',
|
||||
'next-env.d.ts',
|
||||
// Service Worker: laeuft im Browser, nicht in unserer Werkzeugkette.
|
||||
'public/sw.js',
|
||||
],
|
||||
},
|
||||
...naechstesWebVitals,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "planer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "planer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "2.11.3",
|
||||
"@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/client": "7.10.0",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
@@ -36,6 +38,7 @@
|
||||
"server-only": "0.0.1",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"web-push": "3.6.7",
|
||||
"zod": "4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -47,6 +50,7 @@
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"dotenv": "17.4.2",
|
||||
@@ -593,6 +597,45 @@
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"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": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
|
||||
@@ -4504,6 +4547,16 @@
|
||||
"@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": {
|
||||
"version": "8.70.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -5637,6 +5699,18 @@
|
||||
"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": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
@@ -5791,6 +5865,12 @@
|
||||
"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": {
|
||||
"version": "5.0.9",
|
||||
"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_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": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
||||
@@ -6372,7 +6458,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -6556,6 +6641,15 @@
|
||||
"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": {
|
||||
"version": "3.20.0",
|
||||
"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_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": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
@@ -8098,6 +8214,12 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -8763,6 +8885,27 @@
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -9239,6 +9382,12 @@
|
||||
"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": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
@@ -9259,7 +9408,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -9269,7 +9417,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
@@ -10690,6 +10837,26 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
@@ -10752,7 +10919,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
@@ -12357,6 +12523,25 @@
|
||||
"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": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||
"type": "module",
|
||||
@@ -40,6 +40,8 @@
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "2.11.3",
|
||||
"@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/client": "7.10.0",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
@@ -66,6 +68,7 @@
|
||||
"server-only": "0.0.1",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"web-push": "3.6.7",
|
||||
"zod": "4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -77,6 +80,7 @@
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"dotenv": "17.4.2",
|
||||
|
||||
@@ -327,6 +327,83 @@ async function main(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// --- Kalenderkonto und erfundene Termine ---------------------------------
|
||||
// Die Tokens sind absichtlich Unsinn: die Screenshot-Instanz spricht mit
|
||||
// keinem Anbieter, sie soll die Seite nur nicht leer zeigen.
|
||||
await db.kalenderkonto.deleteMany({})
|
||||
const kalenderkonto = await db.kalenderkonto.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
anbieter: 'GRAPH',
|
||||
anzeigename: 'Firma Outlook',
|
||||
extKontoId: 'martina.beispiel@firma.invalid',
|
||||
letzterAbgleich: new Date(DEMO_JETZT.getTime() - 6 * 60_000),
|
||||
},
|
||||
})
|
||||
|
||||
const TERMINE = [
|
||||
{ betreff: 'Wochenbesprechung', vonStunde: 8, bisStunde: 9 },
|
||||
{ betreff: 'Kundengespräch Sonnenberg', vonStunde: 10, bisStunde: 11 },
|
||||
{ betreff: 'Mittagspause', vonStunde: 11, bisStunde: 12, frei: true },
|
||||
{ betreff: 'Abnahme Baustelle', vonStunde: 13, bisStunde: 15 },
|
||||
]
|
||||
|
||||
for (const eintrag of TERMINE) {
|
||||
await db.termin.create({
|
||||
data: {
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
extId: `demo-${eintrag.betreff.toLowerCase().replace(/[^a-z]+/g, '-')}`,
|
||||
betreff: eintrag.betreff,
|
||||
beginn: new Date(Date.UTC(2026, 2, 17, eintrag.vonStunde - 1, 0)),
|
||||
ende: new Date(Date.UTC(2026, 2, 17, eintrag.bisStunde - 1, 0)),
|
||||
zeigtAls: eintrag.frei ? 'FREI' : 'GEBUCHT',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await db.termin.create({
|
||||
data: {
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
extId: 'demo-brueckentag',
|
||||
betreff: 'Brückentag',
|
||||
beginn: new Date(Date.UTC(2026, 2, 20, 0, 0)),
|
||||
ende: new Date(Date.UTC(2026, 2, 21, 0, 0)),
|
||||
ganztaegig: true,
|
||||
zeigtAls: 'ABWESEND',
|
||||
},
|
||||
})
|
||||
|
||||
// --- 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.
|
||||
await db.aufgabe.create({
|
||||
data: {
|
||||
@@ -341,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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "KalenderAnbieter" AS ENUM ('GRAPH', 'GOOGLE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ZeigtAls" AS ENUM ('FREI', 'GEBUCHT', 'MIT_VORBEHALT', 'ABWESEND', 'WOANDERS', 'UNBEKANNT');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "bereich" ADD COLUMN "standardKalenderkontoId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "kalenderkonto" (
|
||||
"id" TEXT NOT NULL,
|
||||
"benutzerId" TEXT NOT NULL,
|
||||
"anbieter" "KalenderAnbieter" NOT NULL,
|
||||
"anzeigename" TEXT NOT NULL,
|
||||
"extKontoId" TEXT NOT NULL,
|
||||
"kalenderId" TEXT,
|
||||
"zugriffstoken" TEXT,
|
||||
"aktualisierungstoken" TEXT,
|
||||
"tokenGueltigBis" TIMESTAMP(3),
|
||||
"bereiche" TEXT,
|
||||
"deltaVerweis" TEXT,
|
||||
"letzterAbgleich" TIMESTAMP(3),
|
||||
"letzterFehler" TEXT,
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "kalenderkonto_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "termin" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kalenderkontoId" TEXT NOT NULL,
|
||||
"extId" TEXT NOT NULL,
|
||||
"etag" TEXT,
|
||||
"betreff" TEXT,
|
||||
"beginn" TIMESTAMP(3) NOT NULL,
|
||||
"ende" TIMESTAMP(3) NOT NULL,
|
||||
"ganztaegig" BOOLEAN NOT NULL DEFAULT false,
|
||||
"zeigtAls" "ZeigtAls" NOT NULL DEFAULT 'GEBUCHT',
|
||||
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "termin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "kalenderkonto_benutzerId_aktiv_idx" ON "kalenderkonto"("benutzerId", "aktiv");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "kalenderkonto_benutzerId_anbieter_extKontoId_kalenderId_key" ON "kalenderkonto"("benutzerId", "anbieter", "extKontoId", "kalenderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termin_kalenderkontoId_beginn_idx" ON "termin"("kalenderkontoId", "beginn");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "termin_beginn_ende_idx" ON "termin"("beginn", "ende");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "termin_kalenderkontoId_extId_key" ON "termin"("kalenderkontoId", "extId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "bereich" ADD CONSTRAINT "bereich_standardKalenderkontoId_fkey" FOREIGN KEY ("standardKalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "kalenderkonto" ADD CONSTRAINT "kalenderkonto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "termin" ADD CONSTRAINT "termin_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Made the column `kalenderId` on table `kalenderkonto` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "kalenderkonto" ALTER COLUMN "kalenderId" SET NOT NULL,
|
||||
ALTER COLUMN "kalenderId" SET DEFAULT 'standard';
|
||||
@@ -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;
|
||||
@@ -31,9 +31,13 @@ model Benutzer {
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
konten Konto[]
|
||||
passkeys Passkey[]
|
||||
audit AuditEintrag[]
|
||||
konten Konto[]
|
||||
passkeys Passkey[]
|
||||
audit AuditEintrag[]
|
||||
kalenderkonten Kalenderkonto[]
|
||||
benachrichtigung Benachrichtigungseinstellung?
|
||||
pushGeraete PushGeraet[]
|
||||
meldungen Meldungsnachweis[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@map("benutzer")
|
||||
@@ -121,6 +125,10 @@ model Bereich {
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
/// In welchen Kalender Zeitbloecke dieses Bereichs geschrieben werden.
|
||||
standardKalenderkontoId String?
|
||||
standardKalenderkonto Kalenderkonto? @relation("StandardKalenderkonto", fields: [standardKalenderkontoId], references: [id], onDelete: SetNull)
|
||||
|
||||
aufgaben Aufgabe[]
|
||||
projekte Projekt[]
|
||||
|
||||
@@ -217,8 +225,9 @@ model Aufgabe {
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
|
||||
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
|
||||
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
|
||||
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
|
||||
zeitbloecke Zeitblock[]
|
||||
|
||||
/// Integrationen upserten immer ueber diese beiden Felder. Von Hand
|
||||
/// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte
|
||||
@@ -252,3 +261,190 @@ model AuditEintrag {
|
||||
@@index([benutzerId])
|
||||
@@map("audit_eintrag")
|
||||
}
|
||||
|
||||
enum KalenderAnbieter {
|
||||
GRAPH
|
||||
GOOGLE
|
||||
}
|
||||
|
||||
/// Wie der Termin die Zeit belegt. Google kennt nur frei/gebucht, Graph mehr.
|
||||
enum ZeigtAls {
|
||||
FREI
|
||||
GEBUCHT
|
||||
MIT_VORBEHALT
|
||||
ABWESEND
|
||||
WOANDERS
|
||||
UNBEKANNT
|
||||
}
|
||||
|
||||
/// Ein verbundener Kalender bei Microsoft Graph oder Google.
|
||||
model Kalenderkonto {
|
||||
id String @id @default(cuid())
|
||||
benutzerId String
|
||||
anbieter KalenderAnbieter
|
||||
/// Frei waehlbare Bezeichnung, z. B. "Firma Outlook".
|
||||
anzeigename String
|
||||
/// Kennung beim Anbieter, meist die Mailadresse. Dient dem Wiedererkennen.
|
||||
extKontoId String
|
||||
/// Welcher Kalender. Bei Google "primary", bei Graph eine Kalender-ID.
|
||||
/// "standard" bedeutet: der Standardkalender des Postfachs. Nicht optional,
|
||||
/// weil Prisma in einem zusammengesetzten Unique-Schluessel kein null zulaesst.
|
||||
kalenderId String @default("standard")
|
||||
|
||||
/// Verschluesselt abgelegt, siehe src/server/krypto.ts.
|
||||
zugriffstoken String?
|
||||
aktualisierungstoken String?
|
||||
tokenGueltigBis DateTime?
|
||||
/// Erteilte Berechtigungen, kommagetrennt – zum Erkennen fehlender Rechte.
|
||||
bereiche String?
|
||||
|
||||
/// deltaLink bei Graph, syncToken bei Google. Opak, nie auswerten.
|
||||
deltaVerweis String?
|
||||
letzterAbgleich DateTime?
|
||||
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
|
||||
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)
|
||||
|
||||
erstelltAm DateTime @default(now())
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
termine Termin[]
|
||||
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
|
||||
zeitblocks Zeitblock[]
|
||||
|
||||
/// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer.
|
||||
@@unique([benutzerId, anbieter, extKontoId, kalenderId])
|
||||
@@index([benutzerId, aktiv])
|
||||
@@map("kalenderkonto")
|
||||
}
|
||||
|
||||
/// Ortlicher Spiegel eines Kalendertermins. Nur so viel, wie fuer die
|
||||
/// Belegungsrechnung und die Wochenansicht gebraucht wird.
|
||||
model Termin {
|
||||
id String @id @default(cuid())
|
||||
kalenderkontoId String
|
||||
/// Kennung des Termins beim Anbieter.
|
||||
extId String
|
||||
/// Fuer bedingte Schreibzugriffe ab M4 (If-Match).
|
||||
etag String?
|
||||
|
||||
betreff String?
|
||||
beginn DateTime
|
||||
ende DateTime
|
||||
ganztaegig Boolean @default(false)
|
||||
zeigtAls ZeigtAls @default(GEBUCHT)
|
||||
|
||||
aktualisiertAm DateTime @updatedAt
|
||||
|
||||
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([kalenderkontoId, extId])
|
||||
@@index([kalenderkontoId, beginn])
|
||||
@@index([beginn, ende])
|
||||
@@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
|
||||
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
|
||||
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[] = [
|
||||
@@ -31,10 +38,17 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
{ name: 'start', pfad: '/' },
|
||||
{ name: 'inbox', pfad: '/inbox' },
|
||||
{ name: 'aufgaben', pfad: '/aufgaben' },
|
||||
{ name: 'woche', pfad: '/woche' },
|
||||
{ name: 'projekte', pfad: '/projekte' },
|
||||
{ name: 'ideen', pfad: '/ideen' },
|
||||
{ name: 'bereiche', pfad: '/bereiche' },
|
||||
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
|
||||
{ name: 'einstellungen', pfad: '/einstellungen' },
|
||||
{
|
||||
name: 'benachrichtigungen',
|
||||
pfad: '/einstellungen',
|
||||
ausschnitt: '[data-bild="benachrichtigungen"]',
|
||||
},
|
||||
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
||||
]
|
||||
|
||||
@@ -80,10 +94,12 @@ for (const schema of ['hell', 'dunkel'] as const) {
|
||||
// Der Farbschema-Umschalter und Schriften sollen fertig sein.
|
||||
await seite.waitForTimeout(300)
|
||||
|
||||
await seite.screenshot({
|
||||
path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`,
|
||||
fullPage: true,
|
||||
})
|
||||
const ziel = `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`
|
||||
if (aufnahme.ausschnitt) {
|
||||
await seite.locator(aufnahme.ausschnitt).screenshot({ path: ziel })
|
||||
} else {
|
||||
await seite.screenshot({ path: ziel, fullPage: true })
|
||||
}
|
||||
}
|
||||
|
||||
await kontext.close()
|
||||
|
||||
@@ -50,9 +50,14 @@ async function aufraeumen(): Promise<void> {
|
||||
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: kennung } } })
|
||||
await db.projekt.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||
await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } })
|
||||
// Kalenderkonten hängen am Benutzer und gehen mit ihm; falls der Test
|
||||
// vorher abbricht, hier trotzdem noch einmal aufräumen.
|
||||
await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: kennung } } })
|
||||
await db.benutzer.deleteMany({ where: { email: testEmail } })
|
||||
await db.bereich.deleteMany({ where: { name: { startsWith: 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> {
|
||||
@@ -146,6 +151,102 @@ async function main(): Promise<void> {
|
||||
}),
|
||||
)
|
||||
|
||||
// --- Kalenderkonto und Termine ------------------------------------------
|
||||
const kalenderkonto = await db.kalenderkonto.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
anbieter: 'GRAPH',
|
||||
anzeigename: `${kennung} Firma Outlook`,
|
||||
extKontoId: `${kennung}-konto`,
|
||||
},
|
||||
})
|
||||
pruefe(
|
||||
'Kalenderkonto zeigt im Zweifel auf den Standardkalender',
|
||||
kalenderkonto.kalenderId === 'standard',
|
||||
)
|
||||
pruefe(
|
||||
'Ein frisches Konto hat noch keinen Abgleichverweis',
|
||||
kalenderkonto.deltaVerweis === null,
|
||||
)
|
||||
|
||||
await scheitert('Dasselbe Konto und derselbe Kalender nur einmal', () =>
|
||||
db.kalenderkonto.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
anbieter: 'GRAPH',
|
||||
anzeigename: `${kennung} Doppelt`,
|
||||
extKontoId: `${kennung}-konto`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Dasselbe Konto mit einem anderen Kalender ist dagegen in Ordnung.
|
||||
const zweiterKalender = await db.kalenderkonto.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
anbieter: 'GRAPH',
|
||||
anzeigename: `${kennung} Zweiter Kalender`,
|
||||
extKontoId: `${kennung}-konto`,
|
||||
kalenderId: 'AAMkAD-zweiter',
|
||||
},
|
||||
})
|
||||
pruefe(
|
||||
'Dasselbe Konto mit anderem Kalender ist erlaubt',
|
||||
zweiterKalender.id !== kalenderkonto.id,
|
||||
)
|
||||
|
||||
const termin = await db.termin.create({
|
||||
data: {
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
extId: `${kennung}-termin`,
|
||||
betreff: 'Wochenbesprechung',
|
||||
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||
ende: new Date('2026-03-17T09:00:00Z'),
|
||||
},
|
||||
})
|
||||
pruefe('Termin anlegen', termin.zeigtAls === 'GEBUCHT' && termin.ganztaegig === false)
|
||||
|
||||
await scheitert('Derselbe Termin je Konto nur einmal', () =>
|
||||
db.termin.create({
|
||||
data: {
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
extId: `${kennung}-termin`,
|
||||
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||
ende: new Date('2026-03-17T09:00:00Z'),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// So arbeitet der Abgleich: upsert statt anlegen.
|
||||
const gewandelterTermin = await db.termin.upsert({
|
||||
where: {
|
||||
kalenderkontoId_extId: {
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
extId: `${kennung}-termin`,
|
||||
},
|
||||
},
|
||||
update: { betreff: 'Wochenbesprechung, verschoben', zeigtAls: 'MIT_VORBEHALT' },
|
||||
create: {
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
extId: `${kennung}-termin`,
|
||||
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||
ende: new Date('2026-03-17T09:00:00Z'),
|
||||
},
|
||||
})
|
||||
pruefe(
|
||||
'Ein Upsert aktualisiert den Termin, statt ihn zu doppeln',
|
||||
gewandelterTermin.id === termin.id &&
|
||||
gewandelterTermin.zeigtAls === 'MIT_VORBEHALT' &&
|
||||
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 1,
|
||||
)
|
||||
|
||||
// --- Standard-Kalenderkonto eines Bereichs -------------------------------
|
||||
await db.bereich.update({
|
||||
where: { id: bereich.id },
|
||||
data: { standardKalenderkontoId: kalenderkonto.id },
|
||||
})
|
||||
await db.kalenderkonto.delete({ where: { id: zweiterKalender.id } })
|
||||
|
||||
// --- Audit überlebt den Benutzer ----------------------------------------
|
||||
const audit = await db.auditEintrag.create({
|
||||
data: {
|
||||
@@ -160,6 +261,61 @@ async function main(): Promise<void> {
|
||||
})
|
||||
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 } })
|
||||
|
||||
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
|
||||
@@ -177,6 +333,29 @@ async function main(): Promise<void> {
|
||||
'Verknüpfte Konten verschwinden mit dem Benutzer',
|
||||
(await db.konto.count({ where: { providerAccountId: kennung } })) === 0,
|
||||
)
|
||||
pruefe(
|
||||
'Kalenderkonten verschwinden mit dem Benutzer',
|
||||
(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(
|
||||
'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine',
|
||||
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,
|
||||
)
|
||||
pruefe(
|
||||
'Der Bereich überlebt das Löschen seines Standard-Kalenderkontos',
|
||||
(await db.bereich.findUnique({ where: { id: bereich.id } }))
|
||||
?.standardKalenderkontoId === null,
|
||||
)
|
||||
|
||||
// --- Aufgaben: Standardwerte und Inbox -----------------------------------
|
||||
const aufgabe = await db.aufgabe.create({
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import webpush from 'web-push'
|
||||
|
||||
const WURZEL = fileURLToPath(new URL('..', import.meta.url))
|
||||
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 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 = {
|
||||
...process.env,
|
||||
SCREENSHOT_APP_PORT: APP_PORT,
|
||||
SCREENSHOT_DB_PORT: DB_PORT,
|
||||
SCREENSHOT_VAPID_OEFFENTLICH: VAPID.publicKey,
|
||||
SCREENSHOT_VAPID_PRIVAT: VAPID.privateKey,
|
||||
}
|
||||
|
||||
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 { Benachrichtigungen } from '@/app/(app)/einstellungen/benachrichtigungen'
|
||||
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
||||
import {
|
||||
Card,
|
||||
@@ -8,6 +9,9 @@ import {
|
||||
CardTitel,
|
||||
} from '@/components/ui/card'
|
||||
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 { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
@@ -17,6 +21,35 @@ export const metadata: Metadata = { title: 'Einstellungen' }
|
||||
export default async function EinstellungenSeite() {
|
||||
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({
|
||||
where: { benutzerId: benutzer.id },
|
||||
orderBy: { erstelltAm: 'asc' },
|
||||
@@ -72,6 +105,39 @@ export default async function EinstellungenSeite() {
|
||||
/>
|
||||
</CardInhalt>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Loader2, RefreshCw, Unplug } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||
import {
|
||||
jetztAbgleichen,
|
||||
kontoTrennen,
|
||||
kontoUmbenennen,
|
||||
type Rueckmeldung,
|
||||
} from '@/server/aktionen/kalenderkonten'
|
||||
|
||||
export type KontoAnzeige = {
|
||||
id: string
|
||||
anbieter: 'GRAPH' | 'GOOGLE'
|
||||
anbieterName: string
|
||||
anzeigename: string
|
||||
extKontoId: string
|
||||
termine: number
|
||||
letzterAbgleich: string | null
|
||||
letzterFehler: string | null
|
||||
}
|
||||
|
||||
export function KontoKarte({ konto }: { konto: KontoAnzeige }) {
|
||||
const router = useRouter()
|
||||
const [laeuft, starte] = useTransition()
|
||||
const [name, setName] = useState(konto.anzeigename)
|
||||
const [trennenBestaetigt, setTrennenBestaetigt] = useState(false)
|
||||
|
||||
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
|
||||
starte(async () => {
|
||||
const antwort = await tun()
|
||||
if (antwort.ok) {
|
||||
toast.success(antwort.meldung)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(antwort.fehler)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function felder(werte: Record<string, string>): FormData {
|
||||
const daten = new FormData()
|
||||
for (const [schluessel, wert] of Object.entries(werte)) daten.set(schluessel, wert)
|
||||
return daten
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardKopf className="flex-row flex-wrap items-center gap-2">
|
||||
<CardTitel className="text-base">{konto.anzeigename}</CardTitel>
|
||||
<Kennzeichen>{konto.anbieterName}</Kennzeichen>
|
||||
{konto.letzterFehler && <Kennzeichen art="gefahr">Fehler</Kennzeichen>}
|
||||
</CardKopf>
|
||||
|
||||
<CardInhalt className="flex flex-col gap-4">
|
||||
<dl className="grid gap-1 text-sm sm:grid-cols-[10rem_1fr]">
|
||||
<dt className="text-gedaempft-vordergrund">Konto</dt>
|
||||
<dd>{konto.extKontoId}</dd>
|
||||
<dt className="text-gedaempft-vordergrund">Gespiegelte Termine</dt>
|
||||
<dd>{konto.termine}</dd>
|
||||
<dt className="text-gedaempft-vordergrund">Letzter Abgleich</dt>
|
||||
<dd>{konto.letzterAbgleich ?? 'noch nie'}</dd>
|
||||
</dl>
|
||||
|
||||
{konto.letzterFehler && (
|
||||
<p className="bg-gefahr/10 text-gefahr rounded-md px-3 py-2 text-sm">
|
||||
{konto.letzterFehler}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<label
|
||||
htmlFor={`name-${konto.id}`}
|
||||
className="text-gedaempft-vordergrund text-xs"
|
||||
>
|
||||
Bezeichnung
|
||||
</label>
|
||||
<Input
|
||||
id={`name-${konto.id}`}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={60}
|
||||
disabled={laeuft}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
art="umriss"
|
||||
disabled={laeuft || name.trim() === konto.anzeigename}
|
||||
onClick={() =>
|
||||
mitRueckmeldung(() =>
|
||||
kontoUmbenennen(felder({ id: konto.id, anzeigename: name })),
|
||||
)
|
||||
}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
art="umriss"
|
||||
disabled={laeuft}
|
||||
onClick={() =>
|
||||
mitRueckmeldung(() => jetztAbgleichen(felder({ id: konto.id })))
|
||||
}
|
||||
>
|
||||
{laeuft ? (
|
||||
<Loader2 className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RefreshCw aria-hidden />
|
||||
)}
|
||||
Jetzt abgleichen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
art="gefahr"
|
||||
className="ml-auto"
|
||||
disabled={laeuft}
|
||||
onBlur={() => setTrennenBestaetigt(false)}
|
||||
onClick={() => {
|
||||
if (!trennenBestaetigt) {
|
||||
setTrennenBestaetigt(true)
|
||||
return
|
||||
}
|
||||
mitRueckmeldung(() => kontoTrennen(felder({ id: konto.id })))
|
||||
}}
|
||||
>
|
||||
<Unplug aria-hidden />
|
||||
{trennenBestaetigt ? 'Wirklich trennen?' : 'Trennen'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{trennenBestaetigt && (
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
Die gespiegelten Termine verschwinden dabei. Im Kalender selbst bleibt alles
|
||||
unangetastet.
|
||||
</p>
|
||||
)}
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/**
|
||||
* Zeigt die Rückmeldung aus der Adresszeile, die die OAuth-Rückleitung dort
|
||||
* hinterlassen hat, und räumt sie danach weg — sonst erschiene sie bei jedem
|
||||
* Neuladen erneut.
|
||||
*/
|
||||
export function Meldung({
|
||||
ok,
|
||||
fehler,
|
||||
}: {
|
||||
ok?: string | undefined
|
||||
fehler?: string | undefined
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const gezeigt = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (gezeigt.current) return
|
||||
if (!ok && !fehler) return
|
||||
|
||||
gezeigt.current = true
|
||||
if (ok) toast.success(ok)
|
||||
if (fehler) toast.error(fehler)
|
||||
router.replace('/kalenderkonten')
|
||||
}, [ok, fehler, router])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { CalendarDays, Plus } from 'lucide-react'
|
||||
import { KontoKarte } from '@/app/(app)/kalenderkonten/konto-karte'
|
||||
import { Meldung } from '@/app/(app)/kalenderkonten/meldung'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardBeschreibung,
|
||||
CardInhalt,
|
||||
CardKopf,
|
||||
CardTitel,
|
||||
} from '@/components/ui/card'
|
||||
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { erlaubteKalenderAnbieter } from '@/server/instanz'
|
||||
import { umgebung } from '@/server/umgebung'
|
||||
|
||||
export const metadata: Metadata = { title: 'Kalenderkonten' }
|
||||
|
||||
const ANBIETER_NAME: Record<KalenderAnbieter, string> = {
|
||||
GRAPH: 'Microsoft 365',
|
||||
GOOGLE: 'Google',
|
||||
}
|
||||
|
||||
export default async function KalenderkontenSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ ok?: string; fehler?: string }>
|
||||
}) {
|
||||
const benutzer = await verlangeAnmeldung('/kalenderkonten')
|
||||
const parameter = await searchParams
|
||||
const einst = umgebung()
|
||||
|
||||
// Welche Anbieter es hier überhaupt gibt, entscheidet die Instanz; ob sie
|
||||
// benutzbar sind, hängt zusätzlich an der Konfiguration.
|
||||
const erlaubt = erlaubteKalenderAnbieter()
|
||||
const verfuegbar = erlaubt.filter((anbieter) =>
|
||||
anbieter === 'GRAPH' ? einst.graphAktiv : einst.googleAktiv,
|
||||
)
|
||||
const fehlendKonfiguriert = erlaubt.filter((a) => !verfuegbar.includes(a))
|
||||
|
||||
const konten = await db.kalenderkonto.findMany({
|
||||
where: { benutzerId: benutzer.id },
|
||||
orderBy: [{ anbieter: 'asc' }, { anzeigename: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
anbieter: true,
|
||||
anzeigename: true,
|
||||
extKontoId: true,
|
||||
letzterAbgleich: true,
|
||||
letzterFehler: true,
|
||||
_count: { select: { termine: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<CalendarDays className="size-6" aria-hidden />
|
||||
Kalenderkonten
|
||||
</h1>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||
Der Planer liest die Termine, um zu wissen, wann überhaupt Platz ist. Ab M4
|
||||
schreibt er Zeitblöcke in denselben Kalender zurück.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Meldung ok={parameter.ok} fehler={parameter.fehler} />
|
||||
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">Kalender verbinden</CardTitel>
|
||||
<CardBeschreibung>
|
||||
Du wirst zum Anbieter geschickt und kommst nach der Zustimmung hierher zurück.
|
||||
Der Planer speichert keine Zugangsdaten im Klartext.
|
||||
</CardBeschreibung>
|
||||
</CardKopf>
|
||||
<CardInhalt className="flex flex-col gap-3">
|
||||
{verfuegbar.length === 0 ? (
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
In dieser Instanz ist kein Kalenderanbieter eingerichtet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{verfuegbar.map((anbieter) => (
|
||||
<Button key={anbieter} alsKind>
|
||||
<a href={`/api/kalender/verbinden/${anbieter.toLowerCase()}`}>
|
||||
<Plus aria-hidden />
|
||||
{ANBIETER_NAME[anbieter]} verbinden
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fehlendKonfiguriert.length > 0 && (
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
{fehlendKonfiguriert.map((a) => ANBIETER_NAME[a]).join(' und ')} wäre hier
|
||||
erlaubt, ist aber noch nicht konfiguriert. Siehe README, Abschnitt
|
||||
Umgebungsvariablen.
|
||||
</p>
|
||||
)}
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-medium">Verbundene Konten</h2>
|
||||
|
||||
{konten.length === 0 ? (
|
||||
<Card>
|
||||
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
|
||||
Noch kein Kalender verbunden.
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
) : (
|
||||
konten.map((konto) => (
|
||||
<KontoKarte
|
||||
key={konto.id}
|
||||
konto={{
|
||||
id: konto.id,
|
||||
anbieter: konto.anbieter,
|
||||
anbieterName: ANBIETER_NAME[konto.anbieter],
|
||||
anzeigename: konto.anzeigename,
|
||||
extKontoId: konto.extKontoId,
|
||||
termine: konto._count.termine,
|
||||
letzterAbgleich: konto.letzterAbgleich
|
||||
? alsDatumUhrzeit(konto.letzterAbgleich)
|
||||
: null,
|
||||
letzterFehler: konto.letzterFehler,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
|
||||
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
|
||||
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
|
||||
import {
|
||||
Card,
|
||||
CardBeschreibung,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
projekteZurAuswahlLaden,
|
||||
zaehlstaende,
|
||||
} from '@/server/abfragen/aufgaben'
|
||||
import { belegungHeute, hatKalenderkonto } from '@/server/abfragen/kalender'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
@@ -24,16 +26,19 @@ export default async function StartSeite() {
|
||||
const benutzer = await verlangeAnmeldung('/')
|
||||
const heute = jetzt()
|
||||
|
||||
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([
|
||||
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
|
||||
bereicheLaden(),
|
||||
projekteZurAuswahlLaden(),
|
||||
zaehlstaende(),
|
||||
db.bereich.findMany({
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
])
|
||||
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] =
|
||||
await Promise.all([
|
||||
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
|
||||
bereicheLaden(),
|
||||
projekteZurAuswahlLaden(),
|
||||
zaehlstaende(),
|
||||
db.bereich.findMany({
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
belegungHeute(benutzer.id, heute),
|
||||
hatKalenderkonto(benutzer.id),
|
||||
])
|
||||
|
||||
// Was jetzt zählt: überfällig, heute oder morgen fällig.
|
||||
const dran = alleOffenen.filter(
|
||||
@@ -108,9 +113,10 @@ export default async function StartSeite() {
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{bereichsListe.map((bereich) => {
|
||||
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
|
||||
const heuteHier = belegung.find((b) => b.id === bereich.id)?.belegung
|
||||
return (
|
||||
<li key={bereich.id}>
|
||||
<Card>
|
||||
<Card className="h-full">
|
||||
<CardKopf className="flex-row items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -119,13 +125,33 @@ export default async function StartSeite() {
|
||||
/>
|
||||
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<CardInhalt className="flex flex-col gap-2">
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
{offeneHier.length === 0
|
||||
? 'Nichts offen'
|
||||
: `${offeneHier.length} offen`}{' '}
|
||||
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
|
||||
</p>
|
||||
|
||||
{/*
|
||||
Ohne verbundenen Kalender wäre die Belegung immer null –
|
||||
das sähe nach "alles frei" aus und wäre irreführend.
|
||||
*/}
|
||||
{mitKalender && heuteHier && (
|
||||
<>
|
||||
<Fortschrittsbalken
|
||||
prozent={heuteHier.prozent}
|
||||
beschriftung={`Belegung heute in ${bereich.name}`}
|
||||
/>
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
{heuteHier.kapazitaet === 0
|
||||
? 'Heute nicht eingeplant'
|
||||
: heuteHier.ueberbucht
|
||||
? `Heute überbucht: ${alsDauer(heuteHier.belegt)} Termine bei ${alsDauer(heuteHier.kapazitaet)} Kapazität`
|
||||
: `Heute noch ${alsDauer(heuteHier.frei)} frei von ${alsDauer(heuteHier.kapazitaet)}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</li>
|
||||
|
||||
@@ -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,136 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { auditSchreiben } from '@/server/audit'
|
||||
import { db } from '@/server/db'
|
||||
import { erzwingeFaehigkeit } from '@/server/instanz'
|
||||
import { kontoAbgleichen } from '@/server/kalender/abgleich'
|
||||
import { codeEinloesen } from '@/server/kalender/oauth'
|
||||
import {
|
||||
alsAnbieter,
|
||||
faehigkeitFuer,
|
||||
istAnbieter,
|
||||
zustandLesen,
|
||||
} from '@/server/kalender/verbinden'
|
||||
import { verschluesseln } from '@/server/krypto'
|
||||
import { umgebung } from '@/server/umgebung'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
function zurueck(nachricht: string, art: 'ok' | 'fehler'): NextResponse {
|
||||
const ziel = new URL('/kalenderkonten', umgebung().APP_URL)
|
||||
ziel.searchParams.set(art, nachricht)
|
||||
return NextResponse.redirect(ziel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt die Rückleitung des Anbieters entgegen und legt das Kalenderkonto an.
|
||||
*
|
||||
* Der Zustandswert ist signiert und trägt die Benutzerkennung. Ohne diese
|
||||
* Prüfung ließe sich ein fremdes Kalenderkonto an ein beliebiges Konto hängen.
|
||||
*/
|
||||
export async function GET(
|
||||
anfrage: Request,
|
||||
{ params }: { params: Promise<{ anbieter: string }> },
|
||||
) {
|
||||
const { anbieter: roh } = await params
|
||||
if (!istAnbieter(roh)) notFound()
|
||||
|
||||
const anbieter = alsAnbieter(roh)
|
||||
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
|
||||
|
||||
const adresse = new URL(anfrage.url)
|
||||
const fehlerAnbieter = adresse.searchParams.get('error')
|
||||
if (fehlerAnbieter) {
|
||||
// Meist "access_denied", weil die Zustimmung abgebrochen wurde.
|
||||
return zurueck('Die Verbindung wurde abgebrochen.', 'fehler')
|
||||
}
|
||||
|
||||
const zustand = zustandLesen(adresse.searchParams.get('state'))
|
||||
if (!zustand || zustand.anbieter !== anbieter) {
|
||||
return zurueck('Die Anfrage war ungültig oder zu alt. Bitte neu beginnen.', 'fehler')
|
||||
}
|
||||
|
||||
const code = adresse.searchParams.get('code')
|
||||
if (!code) {
|
||||
return zurueck('Der Anbieter hat keinen Code zurückgegeben.', 'fehler')
|
||||
}
|
||||
|
||||
const benutzer = await db.benutzer.findUnique({
|
||||
where: { id: zustand.benutzerId },
|
||||
select: { id: true, email: true, aktiv: true },
|
||||
})
|
||||
if (!benutzer?.aktiv) {
|
||||
return zurueck('Das Konto ist nicht mehr aktiv.', 'fehler')
|
||||
}
|
||||
|
||||
try {
|
||||
const satz = await codeEinloesen(anbieter, code)
|
||||
|
||||
if (!satz.aktualisierungstoken) {
|
||||
// Ohne das wäre die Verbindung nach einer Stunde tot.
|
||||
return zurueck(
|
||||
'Der Anbieter hat kein Aktualisierungstoken herausgegeben. Bitte die Zustimmung erneut erteilen.',
|
||||
'fehler',
|
||||
)
|
||||
}
|
||||
|
||||
const extKontoId = satz.kennung ?? `${anbieter.toLowerCase()}-konto`
|
||||
const kalenderId = anbieter === 'GOOGLE' ? 'primary' : 'standard'
|
||||
|
||||
const konto = await db.kalenderkonto.upsert({
|
||||
where: {
|
||||
benutzerId_anbieter_extKontoId_kalenderId: {
|
||||
benutzerId: benutzer.id,
|
||||
anbieter,
|
||||
extKontoId,
|
||||
kalenderId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
zugriffstoken: verschluesseln(satz.zugriffstoken),
|
||||
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
|
||||
tokenGueltigBis: satz.gueltigBis,
|
||||
bereiche: satz.bereiche,
|
||||
aktiv: true,
|
||||
letzterFehler: null,
|
||||
// Neu verbunden heißt: von vorn abgleichen.
|
||||
deltaVerweis: null,
|
||||
},
|
||||
create: {
|
||||
benutzerId: benutzer.id,
|
||||
anbieter,
|
||||
anzeigename:
|
||||
satz.kennung ?? (anbieter === 'GRAPH' ? 'Microsoft 365' : 'Google Kalender'),
|
||||
extKontoId,
|
||||
kalenderId,
|
||||
zugriffstoken: verschluesseln(satz.zugriffstoken),
|
||||
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
|
||||
tokenGueltigBis: satz.gueltigBis,
|
||||
bereiche: satz.bereiche,
|
||||
},
|
||||
select: { id: true, anzeigename: true },
|
||||
})
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: benutzer.id, email: benutzer.email },
|
||||
aktion: 'kalenderkonto.verbunden',
|
||||
objektTyp: 'Kalenderkonto',
|
||||
objektId: konto.id,
|
||||
// Weder Tokens noch Termininhalte – nur, welches Konto verbunden wurde.
|
||||
details: { anbieter, anzeigename: konto.anzeigename },
|
||||
})
|
||||
|
||||
const bericht = await kontoAbgleichen(konto.id)
|
||||
|
||||
return zurueck(
|
||||
bericht.ok
|
||||
? `„${konto.anzeigename}" verbunden, ${bericht.geaendert} Termin(e) gelesen.`
|
||||
: `„${konto.anzeigename}" verbunden, der erste Abgleich schlug aber fehl.`,
|
||||
bericht.ok ? 'ok' : 'fehler',
|
||||
)
|
||||
} catch (fehler) {
|
||||
const meldung =
|
||||
fehler instanceof Error ? fehler.message : 'Die Verbindung ist fehlgeschlagen.'
|
||||
return zurueck(meldung, 'fehler')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { autorisierungsAdresse } from '@/server/kalender/oauth'
|
||||
import {
|
||||
alsAnbieter,
|
||||
faehigkeitFuer,
|
||||
istAnbieter,
|
||||
zustandErzeugen,
|
||||
} from '@/server/kalender/verbinden'
|
||||
import { angemeldet } from '@/server/berechtigung'
|
||||
import { erzwingeFaehigkeit } from '@/server/instanz'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Startet das Verbinden eines Kalenderkontos und leitet zum Anbieter weiter.
|
||||
*
|
||||
* Der Riegel sitzt hier serverseitig: Google gibt es nur daheim. In der
|
||||
* Firmeninstanz liefert diese Route 404 — nicht etwa einen Hinweis, dass es
|
||||
* die Möglichkeit anderswo gäbe.
|
||||
*/
|
||||
export async function GET(
|
||||
_anfrage: Request,
|
||||
{ params }: { params: Promise<{ anbieter: string }> },
|
||||
) {
|
||||
const { anbieter: roh } = await params
|
||||
if (!istAnbieter(roh)) notFound()
|
||||
|
||||
const anbieter = alsAnbieter(roh)
|
||||
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
|
||||
|
||||
const benutzer = await angemeldet()
|
||||
if (!benutzer) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/anmelden?weiter=%2Fkalenderkonten', process.env.APP_URL ?? ''),
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const zustand = zustandErzeugen(anbieter, benutzer.id)
|
||||
return NextResponse.redirect(autorisierungsAdresse(anbieter, zustand))
|
||||
} catch (fehler) {
|
||||
const meldung =
|
||||
fehler instanceof Error ? fehler.message : 'Der Anbieter ist nicht eingerichtet.'
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/kalenderkonten?fehler=${encodeURIComponent(meldung)}`,
|
||||
process.env.APP_URL ?? '',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,9 +30,11 @@ export function Kopfleiste({
|
||||
{ pfad: '/', name: 'Start' },
|
||||
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
|
||||
{ pfad: '/aufgaben', name: 'Aufgaben' },
|
||||
{ pfad: '/woche', name: 'Woche' },
|
||||
{ pfad: '/projekte', name: 'Projekte' },
|
||||
{ pfad: '/ideen', name: 'Ideen' },
|
||||
{ pfad: '/bereiche', name: 'Bereiche' },
|
||||
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
||||
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||||
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Wird von Next einmal beim Hochfahren des Servers ausgeführt.
|
||||
*
|
||||
* Nur in der Node-Laufzeit: im Edge-Umfeld gibt es weder Prisma noch
|
||||
* node-cron. Der Demo-Modus startet bewusst keine Jobs — die
|
||||
* Screenshot-Instanz soll sich nicht selbst verändern, während sie
|
||||
* abfotografiert wird.
|
||||
*/
|
||||
export async function register(): Promise<void> {
|
||||
if (process.env.NEXT_RUNTIME !== 'nodejs') return
|
||||
if (process.env.DEMO_MODUS === 'true') return
|
||||
|
||||
const { jobsStarten } = await import('@/server/jobs')
|
||||
jobsStarten()
|
||||
}
|
||||
@@ -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,337 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
arbeitsfensterAmTag,
|
||||
belegteMinuten,
|
||||
belegtZeit,
|
||||
freieFenster,
|
||||
kapazitaetAmTag,
|
||||
tagesBelegung,
|
||||
termineAmTag,
|
||||
ueberschneidungMinuten,
|
||||
type BelegenderTermin,
|
||||
type BereichKapazitaet,
|
||||
} from './belegung'
|
||||
|
||||
// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1.
|
||||
const dienstag = new Date('2026-03-17T09:00:00Z')
|
||||
|
||||
const bereich: BereichKapazitaet = {
|
||||
kapazitaetMo: 360,
|
||||
kapazitaetDi: 240,
|
||||
kapazitaetMi: 360,
|
||||
kapazitaetDo: 360,
|
||||
kapazitaetFr: 120,
|
||||
kapazitaetSa: 0,
|
||||
kapazitaetSo: 0,
|
||||
// 08:00 bis 17:00 Berliner Zeit.
|
||||
arbeitsfensterVon: 480,
|
||||
arbeitsfensterBis: 1020,
|
||||
}
|
||||
|
||||
function termin(
|
||||
vonUtc: string,
|
||||
bisUtc: string,
|
||||
teil: Partial<BelegenderTermin> = {},
|
||||
): BelegenderTermin {
|
||||
return {
|
||||
beginn: new Date(vonUtc),
|
||||
ende: new Date(bisUtc),
|
||||
ganztaegig: false,
|
||||
zeigtAls: 'GEBUCHT',
|
||||
...teil,
|
||||
}
|
||||
}
|
||||
|
||||
describe('belegtZeit', () => {
|
||||
it('zählt einen gewöhnlichen Termin', () => {
|
||||
expect(belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'))).toBe(true)
|
||||
})
|
||||
|
||||
it('zählt ganztägige Termine nicht', () => {
|
||||
// Ein Urlaubstag oder Geburtstag kostet keine Arbeitszeit im Fenster.
|
||||
expect(
|
||||
belegtZeit(
|
||||
termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true }),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('zählt als frei markierte Termine nicht', () => {
|
||||
expect(
|
||||
belegtZeit(
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls: 'FREI' }),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('zählt Vorbehalt und Abwesenheit sehr wohl', () => {
|
||||
for (const zeigtAls of [
|
||||
'MIT_VORBEHALT',
|
||||
'ABWESEND',
|
||||
'WOANDERS',
|
||||
'UNBEKANNT',
|
||||
] as const) {
|
||||
expect(
|
||||
belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls })),
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ueberschneidungMinuten', () => {
|
||||
const fenster = {
|
||||
von: new Date('2026-03-17T08:00:00Z'),
|
||||
bis: new Date('2026-03-17T12:00:00Z'),
|
||||
}
|
||||
|
||||
it('rechnet eine vollständige Überschneidung', () => {
|
||||
expect(
|
||||
ueberschneidungMinuten(
|
||||
{ von: new Date('2026-03-17T09:00:00Z'), bis: new Date('2026-03-17T10:00:00Z') },
|
||||
fenster,
|
||||
),
|
||||
).toBe(60)
|
||||
})
|
||||
|
||||
it('schneidet an den Rändern ab', () => {
|
||||
expect(
|
||||
ueberschneidungMinuten(
|
||||
{ von: new Date('2026-03-17T07:00:00Z'), bis: new Date('2026-03-17T09:00:00Z') },
|
||||
fenster,
|
||||
),
|
||||
).toBe(60)
|
||||
})
|
||||
|
||||
it('ist ohne Überschneidung null', () => {
|
||||
expect(
|
||||
ueberschneidungMinuten(
|
||||
{ von: new Date('2026-03-17T13:00:00Z'), bis: new Date('2026-03-17T14:00:00Z') },
|
||||
fenster,
|
||||
),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('zählt reines Aneinandergrenzen nicht als Überschneidung', () => {
|
||||
expect(
|
||||
ueberschneidungMinuten(
|
||||
{ von: new Date('2026-03-17T12:00:00Z'), bis: new Date('2026-03-17T13:00:00Z') },
|
||||
fenster,
|
||||
),
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('arbeitsfensterAmTag', () => {
|
||||
it('legt das Fenster in Berliner Ortszeit', () => {
|
||||
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
|
||||
// 08:00 Berlin = 07:00 UTC in der Winterzeit.
|
||||
expect(fenster.von.toISOString()).toBe('2026-03-17T07:00:00.000Z')
|
||||
expect(fenster.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z')
|
||||
})
|
||||
|
||||
it('verschiebt sich mit der Sommerzeit', () => {
|
||||
// Ab dem 29. März 2026 gilt in Berlin UTC+2.
|
||||
const fenster = arbeitsfensterAmTag(new Date('2026-04-07T09:00:00Z'), 480, 1020)
|
||||
expect(fenster.von.toISOString()).toBe('2026-04-07T06:00:00.000Z')
|
||||
expect(fenster.bis.toISOString()).toBe('2026-04-07T15:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('kapazitaetAmTag', () => {
|
||||
it('nimmt den richtigen Wochentag', () => {
|
||||
expect(kapazitaetAmTag(bereich, dienstag)).toBe(240)
|
||||
// Montag, 16. März.
|
||||
expect(kapazitaetAmTag(bereich, new Date('2026-03-16T09:00:00Z'))).toBe(360)
|
||||
// Sonntag, 22. März.
|
||||
expect(kapazitaetAmTag(bereich, new Date('2026-03-22T09:00:00Z'))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('belegteMinuten', () => {
|
||||
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
|
||||
|
||||
it('ist ohne Termine null', () => {
|
||||
expect(belegteMinuten([], fenster)).toBe(0)
|
||||
})
|
||||
|
||||
it('summiert getrennte Termine', () => {
|
||||
expect(
|
||||
belegteMinuten(
|
||||
[
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
),
|
||||
).toBe(120)
|
||||
})
|
||||
|
||||
it('legt überlappende Termine zusammen, statt doppelt zu zählen', () => {
|
||||
expect(
|
||||
belegteMinuten(
|
||||
[
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
termin('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
),
|
||||
).toBe(180)
|
||||
})
|
||||
|
||||
it('legt lückenlos aneinandergrenzende Termine zusammen', () => {
|
||||
expect(
|
||||
belegteMinuten(
|
||||
[
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
),
|
||||
).toBe(120)
|
||||
})
|
||||
|
||||
it('zählt einen vollständig enthaltenen Termin nur einmal', () => {
|
||||
expect(
|
||||
belegteMinuten(
|
||||
[
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T12:00:00Z'),
|
||||
termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
),
|
||||
).toBe(240)
|
||||
})
|
||||
|
||||
it('schneidet an den Rändern des Arbeitsfensters ab', () => {
|
||||
// 06:00–08:00 UTC = 07:00–09:00 Berlin; nur die zweite Stunde zählt.
|
||||
expect(
|
||||
belegteMinuten([termin('2026-03-17T06:00:00Z', '2026-03-17T08:00:00Z')], fenster),
|
||||
).toBe(60)
|
||||
})
|
||||
|
||||
it('lässt Termine außerhalb des Fensters ganz weg', () => {
|
||||
// 20:00 Berlin, das Fenster endet um 17:00.
|
||||
expect(
|
||||
belegteMinuten([termin('2026-03-17T19:00:00Z', '2026-03-17T20:00:00Z')], fenster),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('lässt ganztägige und freie Termine weg', () => {
|
||||
expect(
|
||||
belegteMinuten(
|
||||
[
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z', { ganztaegig: true }),
|
||||
termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z', { zeigtAls: 'FREI' }),
|
||||
],
|
||||
fenster,
|
||||
),
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tagesBelegung', () => {
|
||||
it('rechnet Kapazität und Termine gegeneinander', () => {
|
||||
const belegung = tagesBelegung(
|
||||
bereich,
|
||||
[termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z')],
|
||||
dienstag,
|
||||
)
|
||||
expect(belegung.kapazitaet).toBe(240)
|
||||
expect(belegung.belegt).toBe(120)
|
||||
expect(belegung.frei).toBe(120)
|
||||
expect(belegung.prozent).toBe(50)
|
||||
expect(belegung.ueberbucht).toBe(false)
|
||||
})
|
||||
|
||||
it('erkennt einen überbuchten Tag', () => {
|
||||
const belegung = tagesBelegung(
|
||||
bereich,
|
||||
[termin('2026-03-17T07:00:00Z', '2026-03-17T13:00:00Z')],
|
||||
dienstag,
|
||||
)
|
||||
expect(belegung.belegt).toBe(360)
|
||||
expect(belegung.frei).toBe(0)
|
||||
expect(belegung.ueberbucht).toBe(true)
|
||||
expect(belegung.prozent).toBe(150)
|
||||
})
|
||||
|
||||
it('behandelt einen Tag ohne Kapazität als voll', () => {
|
||||
// Sonntag: keine Kapazität, also gibt es dort nichts zu planen.
|
||||
const belegung = tagesBelegung(bereich, [], new Date('2026-03-22T09:00:00Z'))
|
||||
expect(belegung.kapazitaet).toBe(0)
|
||||
expect(belegung.prozent).toBe(100)
|
||||
expect(belegung.frei).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('freieFenster', () => {
|
||||
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
|
||||
|
||||
it('gibt ohne Termine das ganze Arbeitsfenster zurück', () => {
|
||||
const luecken = freieFenster([], fenster)
|
||||
expect(luecken).toHaveLength(1)
|
||||
expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T07:00:00.000Z')
|
||||
expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z')
|
||||
})
|
||||
|
||||
it('findet die Lücke zwischen zwei Terminen', () => {
|
||||
const luecken = freieFenster(
|
||||
[
|
||||
termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
termin('2026-03-17T11:00:00Z', '2026-03-17T16:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
)
|
||||
expect(luecken).toHaveLength(1)
|
||||
expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T09:00:00.000Z')
|
||||
expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T11:00:00.000Z')
|
||||
})
|
||||
|
||||
it('lässt zu kurze Lücken weg', () => {
|
||||
const luecken = freieFenster(
|
||||
[
|
||||
termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
// Nur zehn Minuten Pause.
|
||||
termin('2026-03-17T09:10:00Z', '2026-03-17T16:00:00Z'),
|
||||
],
|
||||
fenster,
|
||||
15,
|
||||
)
|
||||
expect(luecken).toEqual([])
|
||||
})
|
||||
|
||||
it('ist bei durchgehender Belegung leer', () => {
|
||||
expect(
|
||||
freieFenster([termin('2026-03-17T07:00:00Z', '2026-03-17T16:00:00Z')], fenster),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('übergeht ganztägige Termine und findet den Tag frei', () => {
|
||||
const luecken = freieFenster(
|
||||
[termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true })],
|
||||
fenster,
|
||||
)
|
||||
expect(luecken).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('termineAmTag', () => {
|
||||
it('nimmt, was den Berliner Kalendertag berührt', () => {
|
||||
const liste = [
|
||||
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
termin('2026-03-18T08:00:00Z', '2026-03-18T09:00:00Z'),
|
||||
]
|
||||
expect(termineAmTag(liste, dienstag)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('nimmt auch einen Termin, der in den Tag hineinragt', () => {
|
||||
const uebernacht = termin('2026-03-16T22:00:00Z', '2026-03-17T02:00:00Z')
|
||||
expect(termineAmTag([uebernacht], dienstag)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lässt einen Termin weg, der genau am Tagesbeginn endet', () => {
|
||||
// 23:00 UTC am 16. = 00:00 Berlin am 17., also gerade noch der Vortag.
|
||||
const davor = termin('2026-03-16T21:00:00Z', '2026-03-16T23:00:00Z')
|
||||
expect(termineAmTag([davor], dienstag)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { ZeigtAls } from '@/generated/prisma/enums'
|
||||
import { tagesBeginn, tagesEnde, wochentagIndex } from '@/lib/zeit'
|
||||
|
||||
/**
|
||||
* Wie voll ein Tag schon ist.
|
||||
*
|
||||
* Reine Logik mit dem Zeitpunkt als Parameter. Alles rechnet in Minuten und in
|
||||
* Europe/Berlin — dieselbe Tagesgrenze wie überall sonst im Planer.
|
||||
*/
|
||||
|
||||
export type BelegenderTermin = {
|
||||
beginn: Date
|
||||
ende: Date
|
||||
ganztaegig: boolean
|
||||
zeigtAls: ZeigtAls
|
||||
}
|
||||
|
||||
export type Zeitfenster = { von: Date; bis: Date }
|
||||
|
||||
/**
|
||||
* Zählt ein Termin gegen die Kapazität?
|
||||
*
|
||||
* Ganztägige Termine nicht — ein Urlaubstag oder ein Geburtstag im Kalender
|
||||
* blockiert sonst den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
|
||||
* Ebenso wenig zählen Termine, die ausdrücklich als frei markiert sind.
|
||||
*/
|
||||
export function belegtZeit(termin: BelegenderTermin): boolean {
|
||||
if (termin.ganztaegig) return false
|
||||
if (termin.zeigtAls === 'FREI') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Überschneidung zweier Zeiträume in Minuten; ohne Überschneidung null. */
|
||||
export function ueberschneidungMinuten(a: Zeitfenster, b: Zeitfenster): number {
|
||||
const von = Math.max(a.von.getTime(), b.von.getTime())
|
||||
const bis = Math.min(a.bis.getTime(), b.bis.getTime())
|
||||
if (bis <= von) return 0
|
||||
return Math.round((bis - von) / 60_000)
|
||||
}
|
||||
|
||||
/** Das Arbeitsfenster eines Bereichs an einem bestimmten Tag. */
|
||||
export function arbeitsfensterAmTag(
|
||||
tag: Date,
|
||||
arbeitsfensterVon: number,
|
||||
arbeitsfensterBis: number,
|
||||
): Zeitfenster {
|
||||
const beginn = tagesBeginn(tag)
|
||||
return {
|
||||
von: new Date(beginn.getTime() + arbeitsfensterVon * 60_000),
|
||||
bis: new Date(beginn.getTime() + arbeitsfensterBis * 60_000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Minuten des Arbeitsfensters an diesem Tag schon durch Termine
|
||||
* belegt sind.
|
||||
*
|
||||
* Überlappende Termine werden zusammengelegt, damit eine Doppelbuchung nicht
|
||||
* doppelt zählt.
|
||||
*/
|
||||
export function belegteMinuten(
|
||||
termine: readonly BelegenderTermin[],
|
||||
fenster: Zeitfenster,
|
||||
): number {
|
||||
const abschnitte = termine
|
||||
.filter(belegtZeit)
|
||||
.map((t) => ({ von: t.beginn, bis: t.ende }))
|
||||
.filter((t) => ueberschneidungMinuten(t, fenster) > 0)
|
||||
.map((t) => ({
|
||||
von: new Date(Math.max(t.von.getTime(), fenster.von.getTime())),
|
||||
bis: new Date(Math.min(t.bis.getTime(), fenster.bis.getTime())),
|
||||
}))
|
||||
.sort((a, b) => a.von.getTime() - b.von.getTime())
|
||||
|
||||
let summe = 0
|
||||
let offenVon: Date | null = null
|
||||
let offenBis: Date | null = null
|
||||
|
||||
for (const abschnitt of abschnitte) {
|
||||
if (offenVon === null || offenBis === null) {
|
||||
offenVon = abschnitt.von
|
||||
offenBis = abschnitt.bis
|
||||
continue
|
||||
}
|
||||
if (abschnitt.von.getTime() <= offenBis.getTime()) {
|
||||
// Überlappt oder schließt an – zusammenlegen.
|
||||
if (abschnitt.bis.getTime() > offenBis.getTime()) offenBis = abschnitt.bis
|
||||
continue
|
||||
}
|
||||
summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000)
|
||||
offenVon = abschnitt.von
|
||||
offenBis = abschnitt.bis
|
||||
}
|
||||
|
||||
if (offenVon !== null && offenBis !== null) {
|
||||
summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000)
|
||||
}
|
||||
|
||||
return summe
|
||||
}
|
||||
|
||||
export type TagesBelegung = {
|
||||
/** Tageskapazität des Bereichs an diesem Wochentag, in Minuten. */
|
||||
kapazitaet: number
|
||||
/** Durch Termine belegte Minuten innerhalb des Arbeitsfensters. */
|
||||
belegt: number
|
||||
/** Was von der Kapazität übrig bleibt; nie negativ. */
|
||||
frei: number
|
||||
/** Belegung in Prozent der Kapazität, gerundet. Ohne Kapazität 100. */
|
||||
prozent: number
|
||||
/** Mehr Termine als Kapazität – der Tag ist überbucht. */
|
||||
ueberbucht: boolean
|
||||
}
|
||||
|
||||
export type BereichKapazitaet = {
|
||||
kapazitaetMo: number
|
||||
kapazitaetDi: number
|
||||
kapazitaetMi: number
|
||||
kapazitaetDo: number
|
||||
kapazitaetFr: number
|
||||
kapazitaetSa: number
|
||||
kapazitaetSo: number
|
||||
arbeitsfensterVon: number
|
||||
arbeitsfensterBis: number
|
||||
}
|
||||
|
||||
const KAPAZITAET_JE_TAG = [
|
||||
'kapazitaetMo',
|
||||
'kapazitaetDi',
|
||||
'kapazitaetMi',
|
||||
'kapazitaetDo',
|
||||
'kapazitaetFr',
|
||||
'kapazitaetSa',
|
||||
'kapazitaetSo',
|
||||
] as const
|
||||
|
||||
/** Kapazität des Bereichs an genau diesem Tag. */
|
||||
export function kapazitaetAmTag(bereich: BereichKapazitaet, tag: Date): number {
|
||||
const feld = KAPAZITAET_JE_TAG[wochentagIndex(tag)]
|
||||
return feld ? bereich[feld] : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet Kapazität und Termine eines Tages gegeneinander.
|
||||
*
|
||||
* Gezählt wird nur, was ins Arbeitsfenster fällt: ein Termin um 22 Uhr
|
||||
* verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um 17 Uhr
|
||||
* endet.
|
||||
*/
|
||||
export function tagesBelegung(
|
||||
bereich: BereichKapazitaet,
|
||||
termine: readonly BelegenderTermin[],
|
||||
tag: Date,
|
||||
): TagesBelegung {
|
||||
const kapazitaet = kapazitaetAmTag(bereich, tag)
|
||||
const fenster = arbeitsfensterAmTag(
|
||||
tag,
|
||||
bereich.arbeitsfensterVon,
|
||||
bereich.arbeitsfensterBis,
|
||||
)
|
||||
const belegt = belegteMinuten(termine, fenster)
|
||||
|
||||
return {
|
||||
kapazitaet,
|
||||
belegt,
|
||||
frei: Math.max(0, kapazitaet - belegt),
|
||||
prozent: kapazitaet === 0 ? 100 : Math.round((belegt / kapazitaet) * 100),
|
||||
ueberbucht: belegt > kapazitaet,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Freie Abschnitte innerhalb des Arbeitsfensters eines Tages — die Lücken,
|
||||
* in die ab M4 Zeitblöcke gelegt werden können.
|
||||
*/
|
||||
export function freieFenster(
|
||||
termine: readonly BelegenderTermin[],
|
||||
fenster: Zeitfenster,
|
||||
mindestensMinuten = 15,
|
||||
): Zeitfenster[] {
|
||||
const belegt = termine
|
||||
.filter(belegtZeit)
|
||||
.map((t) => ({
|
||||
von: new Date(Math.max(t.beginn.getTime(), fenster.von.getTime())),
|
||||
bis: new Date(Math.min(t.ende.getTime(), fenster.bis.getTime())),
|
||||
}))
|
||||
.filter((t) => t.bis.getTime() > t.von.getTime())
|
||||
.sort((a, b) => a.von.getTime() - b.von.getTime())
|
||||
|
||||
const luecken: Zeitfenster[] = []
|
||||
let cursor = fenster.von
|
||||
|
||||
for (const abschnitt of belegt) {
|
||||
if (abschnitt.von.getTime() > cursor.getTime()) {
|
||||
luecken.push({ von: cursor, bis: abschnitt.von })
|
||||
}
|
||||
if (abschnitt.bis.getTime() > cursor.getTime()) cursor = abschnitt.bis
|
||||
}
|
||||
|
||||
if (cursor.getTime() < fenster.bis.getTime()) {
|
||||
luecken.push({ von: cursor, bis: fenster.bis })
|
||||
}
|
||||
|
||||
return luecken.filter(
|
||||
(l) => (l.bis.getTime() - l.von.getTime()) / 60_000 >= mindestensMinuten,
|
||||
)
|
||||
}
|
||||
|
||||
/** Termine, die an diesem Kalendertag überhaupt vorkommen. */
|
||||
export function termineAmTag<T extends { beginn: Date; ende: Date }>(
|
||||
termine: readonly T[],
|
||||
tag: Date,
|
||||
): T[] {
|
||||
const von = tagesBeginn(tag).getTime()
|
||||
const bis = tagesEnde(tag).getTime()
|
||||
return termine.filter((t) => t.beginn.getTime() <= bis && t.ende.getTime() > von)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { googleSeiteLesen, googleZeitpunkt, zeigtAlsAusGoogle } from './google'
|
||||
import { AntwortUnbrauchbar } from './typen'
|
||||
|
||||
function fixture(name: string): unknown {
|
||||
return JSON.parse(
|
||||
readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'),
|
||||
) as unknown
|
||||
}
|
||||
|
||||
describe('zeigtAlsAusGoogle', () => {
|
||||
it('kennt nur frei und gebucht', () => {
|
||||
expect(zeigtAlsAusGoogle('transparent')).toBe('FREI')
|
||||
expect(zeigtAlsAusGoogle('opaque')).toBe('GEBUCHT')
|
||||
})
|
||||
|
||||
it('nimmt ein fehlendes Feld als gebucht', () => {
|
||||
// Google lässt transparency weg, wenn der Termin die Zeit belegt.
|
||||
expect(zeigtAlsAusGoogle(undefined)).toBe('GEBUCHT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('googleZeitpunkt', () => {
|
||||
it('liest eine Zeit mit Versatz', () => {
|
||||
const ergebnis = googleZeitpunkt({ dateTime: '2026-03-17T14:00:00+01:00' })
|
||||
expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-17T13:00:00.000Z')
|
||||
expect(ergebnis.ganztaegig).toBe(false)
|
||||
})
|
||||
|
||||
it('erkennt ein reines Datum als ganztägig', () => {
|
||||
const ergebnis = googleZeitpunkt({ date: '2026-03-20' })
|
||||
expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-20T00:00:00.000Z')
|
||||
expect(ergebnis.ganztaegig).toBe(true)
|
||||
})
|
||||
|
||||
it('bricht ab, wenn beides fehlt', () => {
|
||||
expect(() => googleZeitpunkt({})).toThrow(AntwortUnbrauchbar)
|
||||
})
|
||||
|
||||
it('bricht bei unlesbaren Angaben ab', () => {
|
||||
expect(() => googleZeitpunkt({ dateTime: 'irgendwann' })).toThrow(AntwortUnbrauchbar)
|
||||
expect(() => googleZeitpunkt({ date: '2026-13-45' })).toThrow(AntwortUnbrauchbar)
|
||||
})
|
||||
})
|
||||
|
||||
describe('googleSeiteLesen', () => {
|
||||
it('liest die erste Seite samt Seitenverweis', () => {
|
||||
const seite = googleSeiteLesen(fixture('google-events-seite1'))
|
||||
expect(seite.geaendert).toHaveLength(2)
|
||||
expect(seite.weiter).toBe('BEISPIEL-SEITE-2')
|
||||
expect(seite.deltaVerweis).toBeNull()
|
||||
})
|
||||
|
||||
it('rechnet den Versatz nach UTC um', () => {
|
||||
const [erster] = googleSeiteLesen(fixture('google-events-seite1')).geaendert
|
||||
expect(erster?.extId).toBe('beispiel-termin-a')
|
||||
expect(erster?.betreff).toBe('Zahnarzt')
|
||||
expect(erster?.beginn.toISOString()).toBe('2026-03-17T13:00:00.000Z')
|
||||
expect(erster?.ende.toISOString()).toBe('2026-03-17T14:00:00.000Z')
|
||||
expect(erster?.ganztaegig).toBe(false)
|
||||
})
|
||||
|
||||
it('erkennt ganztägige Termine am reinen Datum', () => {
|
||||
const ganztaegig = googleSeiteLesen(fixture('google-events-seite1')).geaendert[1]
|
||||
expect(ganztaegig?.ganztaegig).toBe(true)
|
||||
expect(ganztaegig?.betreff).toBe('Urlaubstag')
|
||||
})
|
||||
|
||||
it('liest die letzte Seite mit dem Sync-Verweis', () => {
|
||||
const seite = googleSeiteLesen(fixture('google-events-seite2'))
|
||||
expect(seite.weiter).toBeNull()
|
||||
expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-1')
|
||||
})
|
||||
|
||||
it('behandelt abgesagte Termine als entfernt', () => {
|
||||
const seite = googleSeiteLesen(fixture('google-events-seite2'))
|
||||
expect(seite.entfernt).toEqual(['beispiel-termin-d'])
|
||||
expect(seite.geaendert.map((t) => t.extId)).toEqual(['beispiel-termin-c'])
|
||||
})
|
||||
|
||||
it('erkennt einen durchlässigen Termin als frei', () => {
|
||||
const [frei] = googleSeiteLesen(fixture('google-events-seite2')).geaendert
|
||||
expect(frei?.zeigtAls).toBe('FREI')
|
||||
})
|
||||
|
||||
it('kommt mit einer leeren Antwort zurecht', () => {
|
||||
const seite = googleSeiteLesen(fixture('google-events-leer'))
|
||||
expect(seite.geaendert).toEqual([])
|
||||
expect(seite.entfernt).toEqual([])
|
||||
expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-2')
|
||||
})
|
||||
|
||||
it('weist eine Antwort mit falschem Aufbau ab', () => {
|
||||
expect(() => googleSeiteLesen({ items: 'keine Liste' })).toThrow(AntwortUnbrauchbar)
|
||||
})
|
||||
|
||||
it('übergeht Einträge ohne Zeitraum', () => {
|
||||
const seite = googleSeiteLesen({
|
||||
items: [{ id: 'ohne-zeit', status: 'confirmed', summary: 'Kein Zeitraum' }],
|
||||
})
|
||||
expect(seite.geaendert).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { z } from 'zod'
|
||||
import type { ZeigtAls } from '@/generated/prisma/enums'
|
||||
import {
|
||||
AntwortUnbrauchbar,
|
||||
type AbgleichSeite,
|
||||
type RohTermin,
|
||||
} from '@/lib/kalender/typen'
|
||||
|
||||
/**
|
||||
* Lesen der Antworten der Google-Kalender-API (`events.list`).
|
||||
*
|
||||
* Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die
|
||||
* gemeinsame Form zurück.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Google liefert entweder `date` (ganztägig, reines Datum) oder `dateTime`
|
||||
* (mit Versatz). Genau eines von beidem.
|
||||
*/
|
||||
const googleZeit = z.looseObject({
|
||||
date: z.string().optional(),
|
||||
dateTime: z.string().optional(),
|
||||
timeZone: z.string().optional(),
|
||||
})
|
||||
|
||||
const googleTermin = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
etag: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
start: googleZeit.optional(),
|
||||
end: googleZeit.optional(),
|
||||
transparency: z.string().optional(),
|
||||
})
|
||||
|
||||
const googleAntwort = z.looseObject({
|
||||
items: z.array(googleTermin).default([]),
|
||||
nextPageToken: z.string().optional(),
|
||||
nextSyncToken: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Google kennt nur zwei Zustände: `transparency: "transparent"` heißt frei,
|
||||
* alles andere (auch das Fehlen des Feldes) heißt gebucht.
|
||||
*/
|
||||
export function zeigtAlsAusGoogle(transparency: string | undefined): ZeigtAls {
|
||||
return transparency === 'transparent' ? 'FREI' : 'GEBUCHT'
|
||||
}
|
||||
|
||||
export type GoogleZeit = { date?: string | undefined; dateTime?: string | undefined }
|
||||
|
||||
/**
|
||||
* Wandelt Googles Zeitangabe in einen Zeitpunkt und sagt, ob sie ganztägig
|
||||
* war. Ein reines Datum wird als Mitternacht UTC gelesen — ganztägige Termine
|
||||
* zählen ohnehin nicht zur Belegung, es geht nur um die Einordnung im Tag.
|
||||
*/
|
||||
export function googleZeitpunkt(zeit: GoogleZeit): {
|
||||
zeitpunkt: Date
|
||||
ganztaegig: boolean
|
||||
} {
|
||||
if (zeit.date) {
|
||||
const zeitpunkt = new Date(`${zeit.date}T00:00:00Z`)
|
||||
if (Number.isNaN(zeitpunkt.getTime())) {
|
||||
throw new AntwortUnbrauchbar('Google', 'Datumsangabe nicht lesbar')
|
||||
}
|
||||
return { zeitpunkt, ganztaegig: true }
|
||||
}
|
||||
|
||||
if (zeit.dateTime) {
|
||||
const zeitpunkt = new Date(zeit.dateTime)
|
||||
if (Number.isNaN(zeitpunkt.getTime())) {
|
||||
throw new AntwortUnbrauchbar('Google', 'Zeitangabe nicht lesbar')
|
||||
}
|
||||
return { zeitpunkt, ganztaegig: false }
|
||||
}
|
||||
|
||||
throw new AntwortUnbrauchbar('Google', 'Zeitangabe fehlt')
|
||||
}
|
||||
|
||||
/** Liest eine Antwortseite von `events.list`. */
|
||||
export function googleSeiteLesen(inhalt: unknown): AbgleichSeite {
|
||||
const gelesen = googleAntwort.safeParse(inhalt)
|
||||
if (!gelesen.success) {
|
||||
throw new AntwortUnbrauchbar('Google', 'unerwarteter Aufbau')
|
||||
}
|
||||
|
||||
const geaendert: RohTermin[] = []
|
||||
const entfernt: string[] = []
|
||||
|
||||
for (const eintrag of gelesen.data.items) {
|
||||
if (eintrag.status === 'cancelled') {
|
||||
entfernt.push(eintrag.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!eintrag.start || !eintrag.end) continue
|
||||
|
||||
const beginn = googleZeitpunkt(eintrag.start)
|
||||
const ende = googleZeitpunkt(eintrag.end)
|
||||
|
||||
geaendert.push({
|
||||
extId: eintrag.id,
|
||||
etag: eintrag.etag ?? null,
|
||||
betreff: eintrag.summary ?? null,
|
||||
beginn: beginn.zeitpunkt,
|
||||
ende: ende.zeitpunkt,
|
||||
ganztaegig: beginn.ganztaegig,
|
||||
zeigtAls: zeigtAlsAusGoogle(eintrag.transparency),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
geaendert,
|
||||
entfernt,
|
||||
weiter: gelesen.data.nextPageToken ?? null,
|
||||
deltaVerweis: gelesen.data.nextSyncToken ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { graphSeiteLesen, graphZeitpunkt, zeigtAlsAusGraph } from './graph'
|
||||
import { AntwortUnbrauchbar } from './typen'
|
||||
|
||||
/** Beispielantworten aus test/fixtures, anonymisiert. */
|
||||
function fixture(name: string): unknown {
|
||||
return JSON.parse(
|
||||
readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'),
|
||||
) as unknown
|
||||
}
|
||||
|
||||
describe('zeigtAlsAusGraph', () => {
|
||||
it('übersetzt die bekannten Werte', () => {
|
||||
expect(zeigtAlsAusGraph('free')).toBe('FREI')
|
||||
expect(zeigtAlsAusGraph('busy')).toBe('GEBUCHT')
|
||||
expect(zeigtAlsAusGraph('tentative')).toBe('MIT_VORBEHALT')
|
||||
expect(zeigtAlsAusGraph('oof')).toBe('ABWESEND')
|
||||
expect(zeigtAlsAusGraph('workingElsewhere')).toBe('WOANDERS')
|
||||
})
|
||||
|
||||
it('nimmt Unbekanntes als unbekannt, statt es zu raten', () => {
|
||||
expect(zeigtAlsAusGraph('etwasNeues')).toBe('UNBEKANNT')
|
||||
expect(zeigtAlsAusGraph(undefined)).toBe('UNBEKANNT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('graphZeitpunkt', () => {
|
||||
it('liest eine UTC-Angabe', () => {
|
||||
expect(
|
||||
graphZeitpunkt({
|
||||
dateTime: '2026-03-17T08:00:00.0000000',
|
||||
timeZone: 'UTC',
|
||||
}).toISOString(),
|
||||
).toBe('2026-03-17T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('kürzt die sieben Nachkommastellen, die Date nicht versteht', () => {
|
||||
expect(
|
||||
graphZeitpunkt({
|
||||
dateTime: '2026-03-17T08:00:00.1234567',
|
||||
timeZone: 'UTC',
|
||||
}).toISOString(),
|
||||
).toBe('2026-03-17T08:00:00.123Z')
|
||||
})
|
||||
|
||||
it('kommt auch ohne Nachkommastellen zurecht', () => {
|
||||
expect(
|
||||
graphZeitpunkt({ dateTime: '2026-03-17T08:00:00', timeZone: 'UTC' }).toISOString(),
|
||||
).toBe('2026-03-17T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('bricht bei einer anderen Zeitzone ab, statt Termine zu verschieben', () => {
|
||||
expect(() =>
|
||||
graphZeitpunkt({
|
||||
dateTime: '2026-03-17T09:00:00.0000000',
|
||||
timeZone: 'W. Europe Standard Time',
|
||||
}),
|
||||
).toThrow(AntwortUnbrauchbar)
|
||||
})
|
||||
|
||||
it('bricht bei unlesbarer Zeitangabe ab', () => {
|
||||
expect(() => graphZeitpunkt({ dateTime: 'morgen früh', timeZone: 'UTC' })).toThrow(
|
||||
AntwortUnbrauchbar,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('graphSeiteLesen', () => {
|
||||
it('liest die erste Seite samt Verweis auf die nächste', () => {
|
||||
const seite = graphSeiteLesen(fixture('graph-delta-seite1'))
|
||||
|
||||
expect(seite.geaendert).toHaveLength(2)
|
||||
expect(seite.entfernt).toEqual([])
|
||||
expect(seite.weiter).toContain('$skiptoken=')
|
||||
expect(seite.deltaVerweis).toBeNull()
|
||||
})
|
||||
|
||||
it('übernimmt Betreff, Kennung und Etag', () => {
|
||||
const [erster] = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert
|
||||
expect(erster?.extId).toBe('BEISPIEL-TERMIN-1')
|
||||
expect(erster?.betreff).toBe('Wochenbesprechung')
|
||||
expect(erster?.etag).toContain('BEISPIELETAG1')
|
||||
expect(erster?.beginn.toISOString()).toBe('2026-03-17T08:00:00.000Z')
|
||||
expect(erster?.ende.toISOString()).toBe('2026-03-17T09:00:00.000Z')
|
||||
expect(erster?.ganztaegig).toBe(false)
|
||||
expect(erster?.zeigtAls).toBe('GEBUCHT')
|
||||
})
|
||||
|
||||
it('erkennt ganztägige Termine', () => {
|
||||
const ganztaegig = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert[1]
|
||||
expect(ganztaegig?.ganztaegig).toBe(true)
|
||||
expect(ganztaegig?.zeigtAls).toBe('ABWESEND')
|
||||
})
|
||||
|
||||
it('liest die letzte Seite mit dem Delta-Verweis für die nächste Runde', () => {
|
||||
const seite = graphSeiteLesen(fixture('graph-delta-seite2'))
|
||||
expect(seite.weiter).toBeNull()
|
||||
expect(seite.deltaVerweis).toContain('$deltatoken=')
|
||||
})
|
||||
|
||||
it('trennt Entferntes von Geändertem', () => {
|
||||
const seite = graphSeiteLesen(fixture('graph-delta-seite2'))
|
||||
expect(seite.entfernt).toEqual(['BEISPIEL-TERMIN-9'])
|
||||
expect(seite.geaendert.map((t) => t.extId)).toEqual(['BEISPIEL-TERMIN-3'])
|
||||
})
|
||||
|
||||
it('erkennt einen als frei markierten Termin', () => {
|
||||
const [frei] = graphSeiteLesen(fixture('graph-delta-seite2')).geaendert
|
||||
expect(frei?.zeigtAls).toBe('FREI')
|
||||
})
|
||||
|
||||
it('bricht ab, wenn Graph eine andere Zeitzone liefert', () => {
|
||||
expect(() => graphSeiteLesen(fixture('graph-delta-fremde-zeitzone'))).toThrow(
|
||||
AntwortUnbrauchbar,
|
||||
)
|
||||
})
|
||||
|
||||
it('weist eine Antwort ohne value-Liste ab', () => {
|
||||
expect(() => graphSeiteLesen({ irgendwas: true })).toThrow(AntwortUnbrauchbar)
|
||||
})
|
||||
|
||||
it('übergeht Einträge ohne Zeitraum, statt abzubrechen', () => {
|
||||
const seite = graphSeiteLesen({
|
||||
value: [{ id: 'ohne-zeit', subject: 'Kein Zeitraum' }],
|
||||
})
|
||||
expect(seite.geaendert).toEqual([])
|
||||
expect(seite.entfernt).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { z } from 'zod'
|
||||
import type { ZeigtAls } from '@/generated/prisma/enums'
|
||||
import {
|
||||
AntwortUnbrauchbar,
|
||||
type AbgleichSeite,
|
||||
type RohTermin,
|
||||
} from '@/lib/kalender/typen'
|
||||
|
||||
/**
|
||||
* Lesen der Antworten von Microsoft Graph (`/me/calendarView/delta`).
|
||||
*
|
||||
* Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die
|
||||
* gemeinsame Form zurück. Kein fetch, keine Tokens — dadurch gegen die
|
||||
* Beispielantworten in test/fixtures prüfbar.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir fragen
|
||||
* mit `Prefer: outlook.timezone="UTC"` an; kommt trotzdem etwas anderes
|
||||
* zurück, wäre jede Umrechnung geraten – dann lieber abbrechen.
|
||||
*/
|
||||
const graphZeit = z.object({
|
||||
dateTime: z.string().min(1),
|
||||
timeZone: z.string().min(1),
|
||||
})
|
||||
|
||||
const graphTermin = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
'@odata.etag': z.string().optional(),
|
||||
'@removed': z.looseObject({ reason: z.string().optional() }).optional(),
|
||||
subject: z.string().nullable().optional(),
|
||||
start: graphZeit.optional(),
|
||||
end: graphZeit.optional(),
|
||||
isAllDay: z.boolean().optional(),
|
||||
showAs: z.string().optional(),
|
||||
})
|
||||
|
||||
const graphAntwort = z.looseObject({
|
||||
value: z.array(graphTermin),
|
||||
'@odata.nextLink': z.string().optional(),
|
||||
'@odata.deltaLink': z.string().optional(),
|
||||
})
|
||||
|
||||
/** showAs von Graph auf unsere Belegungsarten. */
|
||||
export function zeigtAlsAusGraph(showAs: string | undefined): ZeigtAls {
|
||||
switch (showAs) {
|
||||
case 'free':
|
||||
return 'FREI'
|
||||
case 'busy':
|
||||
return 'GEBUCHT'
|
||||
case 'tentative':
|
||||
return 'MIT_VORBEHALT'
|
||||
case 'oof':
|
||||
return 'ABWESEND'
|
||||
case 'workingElsewhere':
|
||||
return 'WOANDERS'
|
||||
default:
|
||||
return 'UNBEKANNT'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt Graphs `{ dateTime, timeZone }` in einen Zeitpunkt.
|
||||
*
|
||||
* Der Text trägt keinen Versatz, deshalb wird er als UTC gelesen. Meldet
|
||||
* Graph eine andere Zeitzone, ist die Anfrage falsch gestellt worden — das
|
||||
* schweigend zu übergehen hieße, Termine um Stunden zu verschieben.
|
||||
*/
|
||||
export function graphZeitpunkt(zeit: { dateTime: string; timeZone: string }): Date {
|
||||
if (zeit.timeZone.toUpperCase() !== 'UTC') {
|
||||
throw new AntwortUnbrauchbar(
|
||||
'Graph',
|
||||
`unerwartete Zeitzone "${zeit.timeZone}", erwartet wurde UTC`,
|
||||
)
|
||||
}
|
||||
|
||||
// "2016-12-09T20:30:00.0000000" – die sieben Nachkommastellen versteht
|
||||
// Date nicht, drei genügen.
|
||||
const gekuerzt = zeit.dateTime.replace(/(\.\d{3})\d+$/, '$1')
|
||||
const zeitpunkt = new Date(`${gekuerzt}Z`)
|
||||
|
||||
if (Number.isNaN(zeitpunkt.getTime())) {
|
||||
throw new AntwortUnbrauchbar('Graph', 'Zeitangabe nicht lesbar')
|
||||
}
|
||||
return zeitpunkt
|
||||
}
|
||||
|
||||
/** Liest eine Antwortseite von `/me/calendarView/delta`. */
|
||||
export function graphSeiteLesen(inhalt: unknown): AbgleichSeite {
|
||||
const gelesen = graphAntwort.safeParse(inhalt)
|
||||
if (!gelesen.success) {
|
||||
throw new AntwortUnbrauchbar('Graph', 'unerwarteter Aufbau')
|
||||
}
|
||||
|
||||
const geaendert: RohTermin[] = []
|
||||
const entfernt: string[] = []
|
||||
|
||||
for (const eintrag of gelesen.data.value) {
|
||||
if (eintrag['@removed']) {
|
||||
entfernt.push(eintrag.id)
|
||||
continue
|
||||
}
|
||||
|
||||
// Ein Eintrag ohne Zeitraum ist kein Termin, den wir einplanen könnten.
|
||||
if (!eintrag.start || !eintrag.end) continue
|
||||
|
||||
geaendert.push({
|
||||
extId: eintrag.id,
|
||||
etag: eintrag['@odata.etag'] ?? null,
|
||||
betreff: eintrag.subject ?? null,
|
||||
beginn: graphZeitpunkt(eintrag.start),
|
||||
ende: graphZeitpunkt(eintrag.end),
|
||||
ganztaegig: eintrag.isAllDay === true,
|
||||
zeigtAls: zeigtAlsAusGraph(eintrag.showAs),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
geaendert,
|
||||
entfernt,
|
||||
weiter: gelesen.data['@odata.nextLink'] ?? null,
|
||||
deltaVerweis: gelesen.data['@odata.deltaLink'] ?? null,
|
||||
}
|
||||
}
|
||||