Compare commits
@@ -74,6 +74,52 @@ GRAPH_TENANT_ID=organizations
|
|||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Benachrichtigungen ----------------------------------------------------
|
||||||
|
# Beides ist freiwillig. Fehlt es, laeuft der Planer ohne Benachrichtigungen
|
||||||
|
# weiter und sagt es in den Einstellungen.
|
||||||
|
|
||||||
|
# Web-Push. Schluesselpaar einmalig erzeugen mit:
|
||||||
|
# npx web-push generate-vapid-keys
|
||||||
|
# Der oeffentliche Schluessel geht an den Browser, der private bleibt hier.
|
||||||
|
# Wird das Paar getauscht, muessen sich alle Geraete neu anmelden.
|
||||||
|
VAPID_OEFFENTLICH=
|
||||||
|
VAPID_PRIVAT=
|
||||||
|
# Kontakt fuer den Push-Dienst, als mailto: oder https:-Adresse.
|
||||||
|
VAPID_KONTAKT=
|
||||||
|
|
||||||
|
# E-Mail. Push braucht SMTP nicht und umgekehrt.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_BENUTZER=
|
||||||
|
SMTP_PASSWORT=
|
||||||
|
# Absender, z. B. "Planer <planer@example.de>".
|
||||||
|
SMTP_ABSENDER=
|
||||||
|
# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS.
|
||||||
|
SMTP_TLS=false
|
||||||
|
|
||||||
|
# --- Integrationen ---------------------------------------------------------
|
||||||
|
# Alles freiwillig. Was fehlt, erscheint unter "Integrationen" als nicht
|
||||||
|
# eingerichtet; der Rest laeuft weiter.
|
||||||
|
# Welche Integration eine Instanz ueberhaupt darf, entscheidet INSTANZ:
|
||||||
|
# Zammad und Wartungsplaner nur "firma", Telegram nur "daheim".
|
||||||
|
|
||||||
|
# Zammad (nur firma). Token in Zammad unter Profil > Token-Zugriff anlegen;
|
||||||
|
# er sieht genau das, was der zugehoerige Benutzer sehen darf.
|
||||||
|
ZAMMAD_URL=
|
||||||
|
ZAMMAD_TOKEN=
|
||||||
|
# Suchausdruck in Zammads Syntax. Was darauf passt, wird zur Aufgabe.
|
||||||
|
ZAMMAD_ABFRAGE=owner_id:me AND (state.name:new OR state.name:open)
|
||||||
|
|
||||||
|
# Telegram (nur daheim). Bot bei @BotFather anlegen, Token hier eintragen.
|
||||||
|
# Welche Chats mitreden duerfen, wird in der Anwendung gekoppelt.
|
||||||
|
TELEGRAM_TOKEN=
|
||||||
|
|
||||||
|
# Spiegel: firma sendet, daheim empfaengt. Dasselbe Geheimnis auf beiden
|
||||||
|
# Seiten, mindestens 32 Zeichen: openssl rand -base64 32
|
||||||
|
SPIEGEL_GEHEIMNIS=
|
||||||
|
# Nur auf der sendenden Seite (firma): die Adresse der anderen Instanz.
|
||||||
|
SPIEGEL_ZIEL=
|
||||||
|
|
||||||
# --- Sonstiges -------------------------------------------------------------
|
# --- Sonstiges -------------------------------------------------------------
|
||||||
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ test-results
|
|||||||
playwright-report
|
playwright-report
|
||||||
coverage
|
coverage
|
||||||
devlog/zeiten.csv
|
devlog/zeiten.csv
|
||||||
|
public/sw.js
|
||||||
|
|||||||
@@ -6,6 +6,83 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.6.0] – 2026-09-12
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Zammad** (nur `firma`): Tickets, auf die der Suchausdruck passt, werden zu
|
||||||
|
Aufgaben — mit Verweis ins Ticket. Wird ein Ticket dort geschlossen, hakt
|
||||||
|
der Planer die Aufgabe ab.
|
||||||
|
- **Telegram** (nur `daheim`): Eine Nachricht an den Bot wird zur Aufgabe.
|
||||||
|
Meldungen gehen denselben Weg zurück, mit einem Knopf zum Abhaken. Ein Chat
|
||||||
|
meldet sich über einen Einmalcode an, der zehn Minuten gilt.
|
||||||
|
- **Telegram als dritter Meldeweg** neben Push und E-Mail.
|
||||||
|
- **Spiegel** zwischen den Instanzen: `firma` schickt Belegungen ohne Titel
|
||||||
|
und die einzeln dafür markierten Aufgaben, `daheim` empfängt. Gespiegelte
|
||||||
|
Belegung erscheint in der Wochenansicht als „Dienstlich belegt" und zählt
|
||||||
|
gegen die Tageskapazität.
|
||||||
|
- Seite **Integrationen**: je Integration Zustand, letzter Erfolg, letzter
|
||||||
|
Fehler und ein Knopf, der einen Lauf sofort anstößt.
|
||||||
|
- Handbuchseite zu den Integrationen.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Was aus einem Fund wird, entscheidet für alle Integrationen dieselbe
|
||||||
|
Stelle. Geführt werden nur Titel, Notiz, Verweis und Fälligkeit — Bereich,
|
||||||
|
Projekt, Priorität und geschätzter Aufwand gehören der Planung und werden
|
||||||
|
nie von außen überschrieben.
|
||||||
|
- Eine klemmende Integration meldet sich wie ein klemmender Kalender: erst
|
||||||
|
nach dem dritten Fehlversuch in Folge.
|
||||||
|
- Die Demo-Instanz für die Bilder spricht nicht mehr nach außen.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Wer dem Telegram-Bot schreibt, ohne verbunden zu sein, bekommt **keine
|
||||||
|
Antwort** — auch keine Fehlermeldung. Sonst ließe sich herausfinden, dass
|
||||||
|
es diesen Planer gibt.
|
||||||
|
- Jede Sendung des Spiegels trägt eine Signatur über den Rumpf und ist nach
|
||||||
|
fünf Minuten wertlos. Der Empfänger antwortet auf jeden Fehlschlag gleich:
|
||||||
|
falsches Geheimnis, verstellte Uhr und Unsinn im Inhalt sind von außen
|
||||||
|
nicht zu unterscheiden.
|
||||||
|
- Der Signaturvergleich läuft in gleichbleibender Zeit.
|
||||||
|
- Aufgaben verlassen die Instanz nur mit ausdrücklicher Freigabe je Aufgabe.
|
||||||
|
Ein Schalter für einen ganzen Bereich würde zu leicht Kundennamen nach
|
||||||
|
Hause schicken.
|
||||||
|
|
||||||
|
## [0.5.0] – 2026-09-11
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Benachrichtigungen** per Web-Push und E-Mail, je Benutzer einstellbar.
|
||||||
|
Vier Anlässe: Tagesüberblick am Morgen, fällige Aufgaben, Vorlauf vor einem
|
||||||
|
Zeitblock und ein Kalender, der sich nicht abgleichen lässt.
|
||||||
|
- **Ruhezeit** von 20:00 bis 07:00 ab Werk, über Mitternacht hinweg; wahlweise
|
||||||
|
das ganze Wochenende. Der Tagesüberblick kommt trotzdem zu seiner Zeit — er
|
||||||
|
käme sonst nie, wenn jemand früh aufsteht.
|
||||||
|
- Anmeldung eines Browsers für Push, Liste der angemeldeten Geräte und ein
|
||||||
|
Knopf, der eine Probemeldung über alle eingeschalteten Wege schickt.
|
||||||
|
- Ein Lauf alle zehn Minuten prüft, ob etwas zu melden ist; die nächtliche
|
||||||
|
Aufräumrunde entfernt Nachweise, die älter als 30 Tage sind.
|
||||||
|
- Handbuchseite zu Benachrichtigungen.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Der Kalenderabgleich zählt Fehlversuche in Folge mit und meldet erst ab dem
|
||||||
|
dritten. Ein Netz hustet, ein Anbieter hat Wartung — das ist noch keine
|
||||||
|
Nachricht wert.
|
||||||
|
- Ab Werk meldet der Planer gar nichts: kein Weg ist eingeschaltet. Wer beim
|
||||||
|
ersten Start mit Meldungen überschüttet wird, schaltet alles ab — und dann
|
||||||
|
auch das Nützliche.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Die Push-Schlüssel eines Geräts liegen verschlüsselt in der Datenbank.
|
||||||
|
Weder Endpunkt noch Schlüssel noch Mailinhalte landen im Protokoll.
|
||||||
|
- Ein Gerät, das der Push-Dienst mit 404 oder 410 ablehnt, wird sofort
|
||||||
|
entfernt; andere Fehler führen nach fünf Versuchen zum selben Ergebnis.
|
||||||
|
- Der Nachweis einer Meldung wird **vor** dem Senden geschrieben: lieber eine
|
||||||
|
verpasste Nachricht als eine, die im Minutentakt erneut versucht wird.
|
||||||
|
|
||||||
## [0.4.0] – 2026-09-11
|
## [0.4.0] – 2026-09-11
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ wird.
|
|||||||
Bereich, wie voll der heutige Tag schon ist
|
Bereich, wie voll der heutige Tag schon ist
|
||||||
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
|
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
|
||||||
Kalenderterminen, Verschiebungen im Kalender kommen zurück
|
Kalenderterminen, Verschiebungen im Kalender kommen zurück
|
||||||
|
- **Benachrichtigungen** per Web-Push, E-Mail und Telegram: Tagesüberblick,
|
||||||
|
fällige Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk
|
||||||
|
still
|
||||||
|
- **Integrationen**: Zammad-Tickets werden zu Aufgaben, Telegram erfasst und
|
||||||
|
hakt ab, und der Spiegel bringt dienstliche Belegung nach Hause — ohne
|
||||||
|
Titel
|
||||||
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
||||||
Passwort — je Instanz einzeln zuschaltbar
|
Passwort — je Instanz einzeln zuschaltbar
|
||||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||||
@@ -43,24 +49,26 @@ wird.
|
|||||||
|
|
||||||
### Geplant
|
### Geplant
|
||||||
|
|
||||||
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
|
Schnellerfassung vom Handy, eine öffentliche API und ein MCP-Server.
|
||||||
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
## Bildergalerie
|
## Bildergalerie
|
||||||
|
|
||||||
| | Hell | Dunkel |
|
| | Hell | Dunkel |
|
||||||
| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
| ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||||
| Anmelden |  |  |
|
| Anmelden |  |  |
|
||||||
| Start |  |  |
|
| Start |  |  |
|
||||||
| Inbox |  |  |
|
| Inbox |  |  |
|
||||||
| Woche |  |  |
|
| Woche |  |  |
|
||||||
| Aufgaben |  |  |
|
| Aufgaben |  |  |
|
||||||
| Projekte |  |  |
|
| Projekte |  |  |
|
||||||
| Ideen |  |  |
|
| Ideen |  |  |
|
||||||
| Kalender |  |  |
|
| Kalender |  |  |
|
||||||
| Bereiche |  |  |
|
| Bereiche |  |  |
|
||||||
| Einstellungen |  |  |
|
| Einstellungen |  |  |
|
||||||
| Protokoll |  |  |
|
| Meldungen |  |  |
|
||||||
|
| Integrationen |  |  |
|
||||||
|
| Protokoll |  |  |
|
||||||
|
|
||||||
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
||||||
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
|
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
|
||||||
@@ -136,6 +144,76 @@ Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
|||||||
Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
|
Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
|
||||||
und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`).
|
und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`).
|
||||||
|
|
||||||
|
### Benachrichtigungen
|
||||||
|
|
||||||
|
Beides ist freiwillig. Fehlt ein Satz, bleibt der Weg in den Einstellungen
|
||||||
|
ausgegraut, und der Planer läuft ohne ihn weiter.
|
||||||
|
|
||||||
|
| Variable | Standard | Bedeutung |
|
||||||
|
| ------------------- | -------- | --------------------------------------------------------- |
|
||||||
|
| `VAPID_OEFFENTLICH` | _(leer)_ | Öffentlicher Push-Schlüssel, geht an den Browser |
|
||||||
|
| `VAPID_PRIVAT` | _(leer)_ | Privater Push-Schlüssel, bleibt auf dem Server |
|
||||||
|
| `VAPID_KONTAKT` | _(leer)_ | Kontakt für den Push-Dienst, `mailto:` oder `https:` |
|
||||||
|
| `SMTP_HOST` | _(leer)_ | Mailserver. Ohne ihn kein Mailversand |
|
||||||
|
| `SMTP_PORT` | `587` | Port des Mailservers |
|
||||||
|
| `SMTP_BENUTZER` | _(leer)_ | Anmeldename. Leer lassen für einen Server ohne Anmeldung |
|
||||||
|
| `SMTP_PASSWORT` | _(leer)_ | Passwort dazu |
|
||||||
|
| `SMTP_ABSENDER` | _(leer)_ | Absenderadresse, z. B. `Planer <planer@example.de>` |
|
||||||
|
| `SMTP_TLS` | `false` | `true` bei Port 465 (SMTPS), `false` bei 587 mit STARTTLS |
|
||||||
|
|
||||||
|
Push braucht **alle drei** VAPID-Werte, Mail mindestens `SMTP_HOST` und
|
||||||
|
`SMTP_ABSENDER`. Die Schlüssel entstehen einmalig mit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx web-push generate-vapid-keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Wird das Schlüsselpaar später getauscht, müssen sich alle Geräte neu
|
||||||
|
anmelden — die alten Abos sind dann wertlos. Push setzt außerdem HTTPS
|
||||||
|
voraus; `http://localhost` gilt dem Browser als sicher, jede andere
|
||||||
|
Klartextadresse nicht.
|
||||||
|
|
||||||
|
Was der Planer wann meldet, steht im
|
||||||
|
[Handbuch](docs/handbuch/benachrichtigungen.md).
|
||||||
|
|
||||||
|
### Integrationen
|
||||||
|
|
||||||
|
Welche Integration eine Instanz überhaupt darf, entscheidet `INSTANZ` — nicht
|
||||||
|
die Konfiguration. Ein Zammad-Token in der Instanz `daheim` bleibt wirkungslos.
|
||||||
|
|
||||||
|
| Variable | Standard | Bedeutung |
|
||||||
|
| ------------------- | ------------------- | ---------------------------------------------------------------- |
|
||||||
|
| `ZAMMAD_URL` | _(leer)_ | Adresse der Zammad-Instanz, nur `firma` |
|
||||||
|
| `ZAMMAD_TOKEN` | _(leer)_ | Token aus **Profil → Token-Zugriff** |
|
||||||
|
| `ZAMMAD_ABFRAGE` | `owner_id:me AND …` | Suchausdruck; was darauf passt, wird zur Aufgabe |
|
||||||
|
| `TELEGRAM_TOKEN` | _(leer)_ | Bot-Token von [@BotFather](https://t.me/BotFather), nur `daheim` |
|
||||||
|
| `SPIEGEL_GEHEIMNIS` | _(leer)_ | Gemeinsames Geheimnis beider Instanzen |
|
||||||
|
| `SPIEGEL_ZIEL` | _(leer)_ | Adresse der empfangenden Instanz; nur auf der sendenden Seite |
|
||||||
|
|
||||||
|
**Zammad** liest nur. Ein Ticket schließt man im Ticketsystem — sonst weiß die
|
||||||
|
Kollegin nicht, warum es plötzlich zu ist. Umgekehrt schließt der Planer eine
|
||||||
|
Aufgabe, sobald Zammad das Ticket als erledigt meldet.
|
||||||
|
|
||||||
|
**Telegram** braucht keinen Webhook und damit keine von außen erreichbare
|
||||||
|
Adresse. Welche Chats mitreden dürfen, wird in der Anwendung unter
|
||||||
|
**Integrationen** gekoppelt: Knopf drücken, Code an den Bot schicken, fertig.
|
||||||
|
Nachrichten aus unbekannten Chats werden verworfen, ohne zu antworten.
|
||||||
|
|
||||||
|
**Der Spiegel** geht in eine Richtung: `firma` sendet, `daheim` empfängt.
|
||||||
|
Hinüber gehen Belegungen **ohne Titel** — daheim ist zu sehen, _dass_ die Zeit
|
||||||
|
weg ist, nicht woran gearbeitet wird — und jene Aufgaben, die einzeln zum
|
||||||
|
Spiegeln markiert wurden. Jede Sendung ist mit dem gemeinsamen Geheimnis
|
||||||
|
signiert und älter als fünf Minuten wertlos.
|
||||||
|
|
||||||
|
Das Geheimnis erzeugst du wie das Sitzungsgeheimnis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Was der Alltag damit anfängt, steht im
|
||||||
|
[Handbuch](docs/handbuch/integrationen.md).
|
||||||
|
|
||||||
### Einrichtung und Sonstiges
|
### Einrichtung und Sonstiges
|
||||||
|
|
||||||
| Variable | Standard | Bedeutung |
|
| Variable | Standard | Bedeutung |
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
|||||||
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
|
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
|
||||||
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
|
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
|
||||||
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
|
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
|
||||||
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
|
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
|
||||||
| [M6](#m6--integrationen) | Integrationen | geplant |
|
| [M6](#m6--integrationen) | Integrationen | erledigt |
|
||||||
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
|
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
|
||||||
| [M8](#m8--mcp) | MCP | geplant |
|
| [M8](#m8--mcp) | MCP | geplant |
|
||||||
|
|
||||||
@@ -94,17 +94,43 @@ ein Zeitblock landet immer im ersten verbundenen Kalender. Beides steht unter
|
|||||||
|
|
||||||
## M5 – Benachrichtigungen
|
## M5 – Benachrichtigungen
|
||||||
|
|
||||||
- [ ] Web-Push mit `web-push`
|
**Erledigt am 11.09.2026 mit Version 0.5.0.**
|
||||||
- [ ] Tagesüberblick per E-Mail über nodemailer
|
|
||||||
- [ ] Erinnerungen an Fälligkeiten, Jobs über node-cron
|
**Ziel:** Sagen, was ansteht — und dabei so selten stören, dass niemand auf
|
||||||
- [ ] Je Benutzer einstellbar, mit Ruhezeiten
|
die Idee kommt, alles abzuschalten.
|
||||||
|
|
||||||
|
- [x] Web-Push mit `web-push`, Anmeldung je Browser
|
||||||
|
- [x] Tagesüberblick per E-Mail über nodemailer
|
||||||
|
- [x] Erinnerungen an Fälligkeiten und vor Zeitblöcken, Jobs über node-cron
|
||||||
|
- [x] Je Benutzer einstellbar, mit Ruhezeiten über Mitternacht
|
||||||
|
- [x] Wiederholungssperre je Anlass, Sache und Tag
|
||||||
|
- [x] Abgleichfehler erst nach mehreren Versuchen hintereinander
|
||||||
|
|
||||||
|
**Bewusst offen gelassen:** Keine Sammelmeldung mehrerer Anlässe in einer
|
||||||
|
Nachricht und kein Nachholen dessen, was in die Ruhezeit fiel. Eine
|
||||||
|
Erinnerung an einen Zeitblock, der längst begonnen hat, hilft niemandem.
|
||||||
|
|
||||||
## M6 – Integrationen
|
## M6 – Integrationen
|
||||||
|
|
||||||
- [ ] Zammad und Wartungsplaner-API (nur `firma`)
|
**Erledigt am 12.09.2026 mit Version 0.6.0.**
|
||||||
- [ ] Telegram (nur `daheim`)
|
|
||||||
- [ ] Spiegel: `firma` sendet, `daheim` empfängt
|
**Ziel:** Aufgaben dort abholen, wo sie entstehen — ohne dass die Planung
|
||||||
- [ ] Jede Integration upsertet über `[quelle, quelleRef]`
|
darunter leidet, die hier passiert.
|
||||||
|
|
||||||
|
- [x] Gemeinsame Aufnahmestelle: jede Integration liefert nur Funde
|
||||||
|
- [x] Zammad (nur `firma`), lesend
|
||||||
|
- [ ] **Wartungsplaner-API** — Schnittstelle steht noch nicht fest
|
||||||
|
- [x] Telegram (nur `daheim`): erfassen, melden, abhaken
|
||||||
|
- [x] Spiegel: `firma` sendet, `daheim` empfängt
|
||||||
|
- [x] Jede Integration upsertet über `[quelle, quelleRef]`
|
||||||
|
|
||||||
|
**Offen geblieben:** Der **Wartungsplaner** ist nicht angebunden. Die
|
||||||
|
Aufnahmestelle steht bereit und die Seite „Integrationen" führt ihn als
|
||||||
|
sichtbare Lücke; es fehlt allein das Abholen, und dafür muss erst feststehen,
|
||||||
|
was das Produkt an Schnittstelle hergibt.
|
||||||
|
|
||||||
|
**Bewusst offen gelassen:** Zammad wird nur gelesen. Ein Ticket schließt man
|
||||||
|
im Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
|
||||||
|
|
||||||
## M7 – Schnellerfassung und API
|
## M7 – Schnellerfassung und API
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,215 @@ die Übersicht aus `scripts/zeit.sh summe`.
|
|||||||
|
|
||||||
## Zeit je Meilenstein
|
## Zeit je Meilenstein
|
||||||
|
|
||||||
| Meilenstein | Art | Minuten | Stunden |
|
| Meilenstein | Art | Minuten | Stunden |
|
||||||
| ----------- | ------ | ------- | -------- |
|
| ----------- | ------ | -------- | --------- |
|
||||||
| M1 | claude | 161 | 2,68 |
|
| M1 | claude | 161 | 2,68 |
|
||||||
| M2 | claude | 44 | 0,73 |
|
| M2 | claude | 44 | 0,73 |
|
||||||
| M3 | claude | 41 | 0,68 |
|
| M3 | claude | 41 | 0,68 |
|
||||||
| M4 | claude | 167 | 2,78 |
|
| M4 | claude | 167 | 2,78 |
|
||||||
| **Gesamt** | | **413** | **6,88** |
|
| M5 | claude | 420 | 7,00 |
|
||||||
|
| M6 | claude | 733 | 12,22 |
|
||||||
|
| **Gesamt** | | **1566** | **26,10** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-12 – M6: Integrationen
|
||||||
|
|
||||||
|
**Dauer:** 733 Minuten (20:47–09:00), Art `claude`.
|
||||||
|
|
||||||
|
> Die Zahl täuscht. Ein großer Teil davon war Warten auf Docker-Bauten, die
|
||||||
|
> in der Nacht dreimal hängen blieben — einmal 39 Minuten ohne eine Zeile
|
||||||
|
> Ausgabe. Die eigentliche Arbeit war knapper.
|
||||||
|
|
||||||
|
**Ziel der Sitzung:** Aufgaben dort abholen, wo sie entstehen — ohne dass die
|
||||||
|
Planung darunter leidet, die hier passiert.
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- Gemeinsame Aufnahmestelle für alle Integrationen (21 Tests)
|
||||||
|
- Zammad, lesend, mit Suchausdruck aus der Konfiguration
|
||||||
|
- Telegram: erfassen, melden, abhaken — mit Kopplung über Einmalcode
|
||||||
|
- Spiegel zwischen den Instanzen: Belegung ohne Titel, Aufgaben nur markiert
|
||||||
|
- Seite „Integrationen" mit Zustand, Fehler und Knopf für den Sofortlauf
|
||||||
|
- Gespiegelte Belegung in der Wochenansicht
|
||||||
|
- 4 neue E2E-Tests, 9 neue Integrationsprüfungen, Handbuchseite
|
||||||
|
|
||||||
|
### Entscheidungen mit Begründung
|
||||||
|
|
||||||
|
**Das Ursprungssystem führt Titel, Notiz, Verweis und Fälligkeit — mehr
|
||||||
|
nicht.** Bereich, Projekt, Priorität und geschätzter Aufwand gehören der
|
||||||
|
Planung. Wer ein Ticket einsortiert und schätzt, muss sich darauf verlassen
|
||||||
|
können, dass der nächste Abgleich das nicht wegräumt. Ohne diese Trennung
|
||||||
|
wäre die Wochenplanung nach jedem Lauf kaputt.
|
||||||
|
|
||||||
|
**Zammad wird nur gelesen.** Ein Ticket schließt man im Ticketsystem, sonst
|
||||||
|
weiß die Kollegin nicht, warum es plötzlich zu ist. Umgekehrt darf Zammad
|
||||||
|
hier abhaken — dort ist die Wahrheit über den Zustand des Tickets.
|
||||||
|
|
||||||
|
**Telegram holt ab, statt einen Webhook zu bekommen.** Ein Webhook verlangt
|
||||||
|
eine von außen erreichbare Adresse mit gültigem Zertifikat. Der Planer soll
|
||||||
|
auch hinter einem Anschluss ohne feste Adresse laufen, und der Preis dafür
|
||||||
|
sind zwei Minuten Verzögerung.
|
||||||
|
|
||||||
|
**Unbekannte Chats bekommen keine Antwort — auch keine Fehlermeldung.** Wer
|
||||||
|
die Kennung des Bots kennt, könnte ihm sonst schreiben und daran erkennen,
|
||||||
|
dass es diesen Planer gibt. Die einzige Ausnahme ist ein gültiger
|
||||||
|
Kopplungscode.
|
||||||
|
|
||||||
|
**Der Kopplungscode entstand aus einem Widerspruch.** Erst sollte die
|
||||||
|
Chatkennung von Hand eingetragen werden. Nur: Um sie zu erfahren, müsste der
|
||||||
|
Bot Unbekannten antworten — genau das, was er nicht darf. Der Code dreht die
|
||||||
|
Richtung um: Er kommt aus der Anwendung, in die man sich anmelden muss.
|
||||||
|
|
||||||
|
**Der Spiegel schickt Belegung ohne Titel.** Daheim soll zu sehen sein,
|
||||||
|
_dass_ die Zeit weg ist, nicht woran gearbeitet wird. Aufgaben gehen nur
|
||||||
|
einzeln markiert mit — ein Schalter für einen ganzen Bereich würde zu leicht
|
||||||
|
Kundennamen nach Hause schicken.
|
||||||
|
|
||||||
|
**Was der Sender nicht mehr schickt, verschwindet beim Empfänger.** Sonst
|
||||||
|
stünde daheim für immer eine Sperre, die es längst nicht mehr gibt. Für
|
||||||
|
Aufgaben gilt das nicht: Sie sind Planung, keine Sperre.
|
||||||
|
|
||||||
|
**Jede Sendung ist fünf Minuten gültig.** Ohne Frist ließe sich ein
|
||||||
|
abgefangenes Paket beliebig oft erneut einspielen. Der Empfänger antwortet
|
||||||
|
auf jeden Fehlschlag gleich — falsches Geheimnis, verstellte Uhr und Unsinn
|
||||||
|
im Inhalt sind von außen nicht zu unterscheiden.
|
||||||
|
|
||||||
|
**Die Demo-Instanz redet mit niemandem.** Sie ist mit erfundenen Daten
|
||||||
|
gefüllt und steht nur für Bilder. Solange `DEMO_MODUS` gilt, starten die
|
||||||
|
Jobs für Zammad, Telegram und den Spiegel gar nicht erst.
|
||||||
|
|
||||||
|
### Probleme und Lösungen
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Die Kopfleiste verschluckte mit dem elften Eintrag „Einstellungen" und „Protokoll" | Sie ist jetzt breiter als die Inhaltsspalte. Eine Leiste, die Punkte stillschweigend abschneidet, ist schlimmer als eine, die nicht bündig mit dem Text steht. Gefunden im aufgenommenen Bild. |
|
||||||
|
| Der Kartentext erklärte noch den Weg von vor dem Kopplungscode | Ersetzt. Ebenfalls erst im Bild aufgefallen — im Code war beides für sich stimmig. |
|
||||||
|
| Ein Kürzungstest schlug an der Grenze fehl | Nicht der Test war falsch, sondern der Vergleich: Eine Worttrennung bei genau der halben Titellänge wurde abgelehnt. Jetzt `>=`. |
|
||||||
|
| Ein regulärer Ausdruck ging beim Schreiben über die Shell kaputt | Das Muster steht jetzt als benannte Konstante im Code, geschrieben mit dem Edit-Werkzeug statt über ein Skript. |
|
||||||
|
| Ein unsichtbares Zeichen (U+200B) landete mitten in einem Funktionsnamen | Entfernt und die Datei danach abgesucht. |
|
||||||
|
| Das Aufräumen im Integrationstest hätte fremde Telegram-Chats treffen können | Es trifft jetzt genau die Kennung, die der Lauf selbst angelegt hat. |
|
||||||
|
| Docker-Bauten blieben dreimal hängen, einmal 39 Minuten ohne Ausgabe | Kein Codeproblem: Nach Abbruch und Neustart lief derselbe Bau in Minuten durch. Die Bilder mussten deshalb viermal angestoßen werden. |
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
- **Der Wartungsplaner ist nicht angebunden.** Die Aufnahmestelle steht
|
||||||
|
bereit und die Seite führt ihn als sichtbare Lücke; es fehlt allein das
|
||||||
|
Abholen. Dafür muss erst feststehen, was das Produkt an Schnittstelle
|
||||||
|
hergibt.
|
||||||
|
- Zammad und Telegram sind gegen die Logik geprüft, nicht gegen die echten
|
||||||
|
Dienste — dafür fehlen Zugangsdaten. Der Spiegel dagegen ist end-to-end
|
||||||
|
geprüft, weil beide Enden dieselbe Anwendung sind.
|
||||||
|
- Ein Telegram-Chat lässt sich nicht umbenennen. Fällt das im Alltag auf,
|
||||||
|
ist es ein Einzeiler.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
- 7f8f505 feat: Datenmodell und Umgebung für Integrationen
|
||||||
|
- e4d3547 feat: Aufnahmeregeln für Integrationen als reine Logik
|
||||||
|
- 6dad333 feat: Zammad, Telegram und den Spiegel anbinden
|
||||||
|
- bc60e5f feat: Integrationen takten und Telegram als dritten Meldeweg
|
||||||
|
- 1281c3e feat: Integrationen sichtbar und bedienbar machen
|
||||||
|
- 0d0f850 feat: Gespiegelte Belegung in der Wochenansicht zeigen
|
||||||
|
- 4da2f3d test: Spiegel-Eingang und Integrationstabellen absichern
|
||||||
|
- 3befcc1 feat: Aufgaben einzeln zum Spiegeln freigeben
|
||||||
|
- f5eadef feat: Lauf von Hand anstoßen und Spiegelschalter in der Liste
|
||||||
|
- 3c7cdbb chore: Demo-Daten, Bildaufnahme und Platz in der Kopfleiste
|
||||||
|
- 475b1d5 docs: Handbuchseite, README und Fahrplan für M6
|
||||||
|
- 98d170b docs: Handbuchbilder für M6 neu aufnehmen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ datum;start;ende;minuten;meilenstein;art;notiz
|
|||||||
2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig
|
2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig
|
||||||
2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig
|
2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig
|
||||||
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
|
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
|
||||||
|
2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
|
||||||
|
2026-09-11;20:47;09:00;733;M6;claude;M6 Integrationen fertig
|
||||||
|
|||||||
|
@@ -57,5 +57,21 @@ services:
|
|||||||
WEBAUTHN_RP_NAME: Planer
|
WEBAUTHN_RP_NAME: Planer
|
||||||
WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
|
WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
|
||||||
DEMO_MODUS: 'true'
|
DEMO_MODUS: 'true'
|
||||||
|
# Damit die Karte "Benachrichtigungen" den Normalfall zeigt und nicht
|
||||||
|
# den Hinweis, dass nichts eingerichtet ist. Das Schluesselpaar erzeugt
|
||||||
|
# scripts/screenshots.ts fuer jeden Lauf neu – es steht bewusst nirgends
|
||||||
|
# im Verzeichnis.
|
||||||
|
VAPID_OEFFENTLICH: ${SCREENSHOT_VAPID_OEFFENTLICH:-}
|
||||||
|
VAPID_PRIVAT: ${SCREENSHOT_VAPID_PRIVAT:-}
|
||||||
|
VAPID_KONTAKT: mailto:planer@planer.invalid
|
||||||
|
# Ein Host, den es nie geben wird: die Oberflaeche zeigt E-Mail als
|
||||||
|
# moeglich, hinaus geht trotzdem nichts.
|
||||||
|
SMTP_HOST: smtp.planer.invalid
|
||||||
|
SMTP_ABSENDER: Planer <planer@planer.invalid>
|
||||||
|
# Erfundene Werte, damit die Seite "Integrationen" den eingerichteten
|
||||||
|
# Fall zeigt. Nach draussen geht davon nichts: Die Jobs schweigen,
|
||||||
|
# solange DEMO_MODUS true ist.
|
||||||
|
TELEGRAM_TOKEN: 000000:demo-token-ohne-funktion
|
||||||
|
SPIEGEL_GEHEIMNIS: demo-geheimnis-ohne-funktion
|
||||||
ports:
|
ports:
|
||||||
- '${SCREENSHOT_APP_PORT:-3200}:3000'
|
- '${SCREENSHOT_APP_PORT:-3200}:3000'
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 349 KiB |
@@ -20,10 +20,14 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
|||||||
verbinden, Belegung verstehen
|
verbinden, Belegung verstehen
|
||||||
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
||||||
Kalender legen
|
Kalender legen
|
||||||
|
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und
|
||||||
|
Telegram einrichten, Ruhezeiten setzen
|
||||||
|
9. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel
|
||||||
|
zwischen Firma und Daheim
|
||||||
|
|
||||||
## Verwaltung
|
## Verwaltung
|
||||||
|
|
||||||
8. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
10. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||||
|
|
||||||
## Was noch kommt
|
## Was noch kommt
|
||||||
|
|
||||||
@@ -31,8 +35,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
|
|||||||
|
|
||||||
| Thema | Meilenstein |
|
| Thema | Meilenstein |
|
||||||
| ------------------------------- | ----------- |
|
| ------------------------------- | ----------- |
|
||||||
| Benachrichtigungen | M5 |
|
|
||||||
| Integrationen | M6 |
|
|
||||||
| Schnellerfassung vom Handy, API | M7 |
|
| Schnellerfassung vom Handy, API | M7 |
|
||||||
| MCP | M8 |
|
| MCP | M8 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Benachrichtigungen
|
||||||
|
|
||||||
|
Ab Werk meldet der Planer **gar nichts**. Das ist Absicht: Eine Anwendung,
|
||||||
|
die zu oft meldet, wird stummgeschaltet — und dann verpasst du auch das
|
||||||
|
Wichtige. Was du hier einschaltest, kommt. Alles andere nicht.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
_Der Browser auf dem Bild hat Benachrichtigungen blockiert — so sieht die
|
||||||
|
Karte aus, wenn die Erlaubnis fehlt. Wie du sie erteilst, steht weiter unten._
|
||||||
|
|
||||||
|
Du findest die Einstellung unter **Einstellungen → Benachrichtigungen**.
|
||||||
|
|
||||||
|
## Die zwei Wege
|
||||||
|
|
||||||
|
| | Web-Push | E-Mail |
|
||||||
|
| --------- | ---------------------------------------- | ---------------------------------------- |
|
||||||
|
| Kommt an | als Systemmeldung auf dem Gerät | im Postfach |
|
||||||
|
| Braucht | Erlaubnis des Browsers, je Gerät einzeln | nichts weiter |
|
||||||
|
| Gut für | „gleich beginnt ein Zeitblock" | den Tagesüberblick am Morgen |
|
||||||
|
| Fällt aus | wenn der Browser die Erlaubnis entzieht | wenn der Mailserver nicht erreichbar ist |
|
||||||
|
|
||||||
|
Beide Wege lassen sich gleichzeitig einschalten. Dann kommt jede Meldung
|
||||||
|
über beide — der Planer verschickt sie trotzdem nur **einmal je Anlass**.
|
||||||
|
|
||||||
|
Ist ein Weg auf dem Server nicht eingerichtet, steht das direkt daneben.
|
||||||
|
Was dafür nötig ist, steht im [README](../../README.md), Abschnitt
|
||||||
|
_Benachrichtigungen_.
|
||||||
|
|
||||||
|
## Push auf einem Gerät einschalten
|
||||||
|
|
||||||
|
1. Auf **Anmelden** klicken.
|
||||||
|
2. Der Browser fragt nach Erlaubnis — **Zulassen**.
|
||||||
|
3. Das Gerät erscheint in der Liste darunter.
|
||||||
|
|
||||||
|
Das gilt **je Gerät und je Browser**. Der Rechner im Büro und das Handy
|
||||||
|
sind zwei Anmeldungen; Firefox und Chrome auf demselben Rechner ebenfalls.
|
||||||
|
|
||||||
|
> **Der Knopf fehlt oder ist grau?** Push braucht HTTPS. Unter
|
||||||
|
> `http://localhost` geht es zum Entwickeln, unter jeder anderen
|
||||||
|
> Klartextadresse nicht. Hat der Browser Benachrichtigungen einmal
|
||||||
|
> blockiert, lässt sich das nur in seinen eigenen Einstellungen
|
||||||
|
> zurücknehmen — die Anwendung darf dann nicht mehr fragen.
|
||||||
|
|
||||||
|
Ein Gerät, das der Push-Dienst dauerhaft ablehnt, entfernt der Planer von
|
||||||
|
selbst aus der Liste. Du musst nichts aufräumen.
|
||||||
|
|
||||||
|
## Was gemeldet wird
|
||||||
|
|
||||||
|
| Anlass | Wann |
|
||||||
|
| ---------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| **Tagesüberblick** | einmal morgens zur eingestellten Zeit |
|
||||||
|
| **Fällige Aufgaben** | wenn eine Aufgabe an diesem Tag fällig wird, höchstens einmal je Aufgabe |
|
||||||
|
| **Gleich beginnt ein Zeitblock** | kurz vor dem Beginn, Vorlauf einstellbar |
|
||||||
|
| **Kalender lässt sich nicht abgleichen** | erst nach dem dritten Fehlversuch hintereinander |
|
||||||
|
|
||||||
|
Eingeschaltet sind anfangs nur Tagesüberblick und fällige Aufgaben — und
|
||||||
|
selbst die kommen erst, wenn du einen Weg (Push oder E-Mail) aktiviert hast.
|
||||||
|
|
||||||
|
Der **Tagesüberblick** entfällt an Tagen, an denen es nichts zu sagen gibt.
|
||||||
|
Eine Meldung „heute steht nichts an" ist keine Information, sondern Lärm.
|
||||||
|
|
||||||
|
Der **Abgleichfehler** kommt bewusst spät: Ein Netz hustet, ein Anbieter hat
|
||||||
|
Wartung. Erst wenn es dreimal hintereinander nicht klappt, ist es eine
|
||||||
|
Nachricht wert — dann allerdings dringend, denn solange bleibt deine
|
||||||
|
Wochenansicht auf einem alten Stand.
|
||||||
|
|
||||||
|
## Ruhezeit
|
||||||
|
|
||||||
|
Voreingestellt ist **20:00 bis 07:00**. In diesem Fenster kommt nichts.
|
||||||
|
|
||||||
|
- Die Ruhezeit läuft über Mitternacht hinweg — das ist der Normalfall.
|
||||||
|
- Gleiche Start- und Endzeit heißt: **keine** Ruhezeit.
|
||||||
|
- **Am Wochenende still** legt Samstag und Sonntag komplett still.
|
||||||
|
|
||||||
|
Eine Ausnahme: Der **Tagesüberblick** kommt zu seiner festen Zeit, auch wenn
|
||||||
|
die noch in der Ruhezeit liegt. Wer ihn auf 06:00 stellt, steht um 06:00 auf
|
||||||
|
und will ihn dann auch haben.
|
||||||
|
|
||||||
|
Was in der Ruhezeit anfällt, wird **nicht nachgeholt**. Eine Erinnerung an
|
||||||
|
einen Zeitblock, der längst begonnen hat, hilft niemandem.
|
||||||
|
|
||||||
|
## Ausprobieren
|
||||||
|
|
||||||
|
Der Knopf **Probe senden** schickt eine Testmeldung über alle
|
||||||
|
eingeschalteten Wege — er umgeht Ruhezeit und Wiederholungssperre. Wer
|
||||||
|
darauf drückt, will jetzt wissen, ob es ankommt.
|
||||||
|
|
||||||
|
Die Rückmeldung sagt, was passiert ist: an wie viele Geräte der Push ging,
|
||||||
|
ob die Mail hinausging. Kommt „kein Gerät für Push angemeldet", fehlt die
|
||||||
|
Anmeldung dieses Browsers.
|
||||||
|
|
||||||
|
## Wenn nichts ankommt
|
||||||
|
|
||||||
|
| Symptom | Ursache |
|
||||||
|
| ----------------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| Probe sagt „verschickt", nichts kommt an | Betriebssystem blendet Browsermeldungen aus (Fokus, Nicht stören) |
|
||||||
|
| Gerät verschwindet immer wieder | Der Push-Dienst lehnt das Abo ab — neu anmelden |
|
||||||
|
| Gar nichts, seit der Server umgezogen ist | Neues Schlüsselpaar auf dem Server. Alle Geräte neu anmelden |
|
||||||
|
| Mail fehlt, Push kommt | SMTP prüfen; Fehler stehen im Serverprotokoll |
|
||||||
|
|
||||||
|
Der Planer prüft alle zehn Minuten, ob etwas zu melden ist. Eine Erinnerung
|
||||||
|
kann also ein paar Minuten vor der eingestellten Zeit kommen — nie danach.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Weiter zur [Wochenplanung](woche.md) oder zurück zum
|
||||||
|
[Handbuch](README.md).
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Integrationen
|
||||||
|
|
||||||
|
Was von außen hereinkommt — und was diese Instanz hinausgibt. Du findest den
|
||||||
|
Überblick im Menü unter **Integrationen**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Welche Integration es überhaupt gibt, hängt an der Instanz und ist nicht
|
||||||
|
verhandelbar:
|
||||||
|
|
||||||
|
| | Firma | Daheim |
|
||||||
|
| -------------- | ------ | -------- |
|
||||||
|
| Zammad | ja | nein |
|
||||||
|
| Wartungsplaner | ja | nein |
|
||||||
|
| Telegram | nein | ja |
|
||||||
|
| Spiegel | sendet | empfängt |
|
||||||
|
|
||||||
|
Eine Integration, die deine Instanz nicht vorsieht, lässt sich auch mit
|
||||||
|
vollständiger Konfiguration nicht anschalten. Das steckt in der Anwendung,
|
||||||
|
nicht in einer Einstellung, die sich versehentlich verstellen ließe.
|
||||||
|
|
||||||
|
## Was jede Integration mit deinen Aufgaben darf
|
||||||
|
|
||||||
|
Für alle gilt dieselbe Regel — und die ist wichtiger als jedes Detail
|
||||||
|
darunter:
|
||||||
|
|
||||||
|
| Führt das Ursprungssystem | Führst du |
|
||||||
|
| --------------------------------- | ------------------------------------------------ |
|
||||||
|
| Titel, Notiz, Verweis, Fälligkeit | Bereich, Projekt, Priorität, geschätzter Aufwand |
|
||||||
|
|
||||||
|
Wer ein Ticket in einen Bereich einsortiert und den Aufwand schätzt, muss
|
||||||
|
sich darauf verlassen können, dass der nächste Abgleich das nicht wegräumt.
|
||||||
|
Umgekehrt: Ändert sich drüben der Titel, zieht er hier mit.
|
||||||
|
|
||||||
|
Dieselbe Sache ergibt nie eine zweite Aufgabe. Der Planer merkt sich zu jeder
|
||||||
|
Aufgabe, woher sie kam und unter welcher Kennung.
|
||||||
|
|
||||||
|
## Zammad
|
||||||
|
|
||||||
|
Tickets, auf die der Suchausdruck passt, werden zu Aufgaben. Voreingestellt
|
||||||
|
sind die dir zugewiesenen offenen Tickets; der Ausdruck steht in der
|
||||||
|
Konfiguration (`ZAMMAD_ABFRAGE`) und versteht Zammads Suchsyntax.
|
||||||
|
|
||||||
|
- Der Titel wird `#Nummer Betreff`, der Verweis führt direkt ins Ticket.
|
||||||
|
- Ein Ticket, das Zammad als geschlossen meldet, hakt der Planer ab.
|
||||||
|
- **Geschrieben wird nach Zammad nichts.** Ein Ticket schließt man im
|
||||||
|
Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
|
||||||
|
|
||||||
|
Der Token gehört einem Benutzer und sieht genau das, was dieser Benutzer
|
||||||
|
sehen darf. Der Planer umgeht keine Rechte.
|
||||||
|
|
||||||
|
## Wartungsplaner
|
||||||
|
|
||||||
|
Noch nicht angebunden. Die Aufnahmestelle steht bereit — es fehlt allein das
|
||||||
|
Abholen, und dafür muss die Schnittstelle feststehen. Auf der Seite
|
||||||
|
**Integrationen** ist der Punkt als offene Lücke sichtbar, damit er nicht in
|
||||||
|
Vergessenheit gerät.
|
||||||
|
|
||||||
|
## Telegram
|
||||||
|
|
||||||
|
Drei Dinge in einem Kanal.
|
||||||
|
|
||||||
|
### Chat verbinden
|
||||||
|
|
||||||
|
1. Unter **Integrationen** auf **Chat verbinden**.
|
||||||
|
2. Der achtstellige Code erscheint. Er gilt **zehn Minuten** und genau
|
||||||
|
**einmal**.
|
||||||
|
3. Schick ihn dem Bot — als Nachricht oder als `/start ABCD2345`.
|
||||||
|
4. Der Bot bestätigt, und der Chat erscheint in der Liste.
|
||||||
|
|
||||||
|
> **Warum der Umweg?** Wer nicht in der Liste steht, bekommt vom Bot keine
|
||||||
|
> Antwort — auch keine Fehlermeldung. Sonst könnte jeder, der die Kennung des
|
||||||
|
> Bots kennt, herausfinden, dass es dich gibt. Der Code ist der Beweis, dass
|
||||||
|
> jemand mit Zugang zur Anwendung diesen Chat wirklich will.
|
||||||
|
|
||||||
|
### Erfassen
|
||||||
|
|
||||||
|
Jede Nachricht an den Bot wird eine Aufgabe. Lange Texte werden gekürzt; der
|
||||||
|
Rest landet in der Notiz und geht nicht verloren. Die Aufgabe hat keinen
|
||||||
|
Bereich — sie liegt also in der **Inbox** und will einsortiert werden.
|
||||||
|
|
||||||
|
### Melden und abhaken
|
||||||
|
|
||||||
|
Ist Telegram in den [Benachrichtigungen](benachrichtigungen.md) als Weg
|
||||||
|
eingeschaltet, kommen Meldungen auch hier an. Geht es um genau eine Aufgabe,
|
||||||
|
hängt darunter ein Knopf **✓ Erledigt** — ein Druck, und sie ist abgehakt,
|
||||||
|
ohne die Anwendung zu öffnen.
|
||||||
|
|
||||||
|
Mehr kann der Knopf nicht. Löschen oder Verschieben aus einem Chat heraus
|
||||||
|
wäre zu leicht versehentlich getroffen.
|
||||||
|
|
||||||
|
Je Chat lässt sich einzeln einstellen, ob er Meldungen bekommt: Das Glöckchen
|
||||||
|
in der Liste schaltet um.
|
||||||
|
|
||||||
|
## Der Spiegel
|
||||||
|
|
||||||
|
Die Firma schickt nach Hause, nicht umgekehrt. Zwei Dinge gehen hinüber.
|
||||||
|
|
||||||
|
**Belegungen.** Wann dienstlich Zeit verplant ist — ohne Titel, ohne Aufgabe,
|
||||||
|
ohne Kunde. Daheim erscheinen sie in der Wochenansicht als gestreifte Balken
|
||||||
|
mit der Aufschrift „Dienstlich belegt" und zählen gegen die Tageskapazität.
|
||||||
|
So kollidiert die private Planung nicht mit der dienstlichen, und trotzdem
|
||||||
|
verlässt kein Firmeninhalt die Firma.
|
||||||
|
|
||||||
|
**Markierte Aufgaben.** Nur was du an der Aufgabe ausdrücklich zum Spiegeln
|
||||||
|
freigibst, geht mit Titel hinüber. Das ist eine Einzelentscheidung je
|
||||||
|
Aufgabe, kein Schalter für alles: Wer versehentlich einen ganzen Bereich
|
||||||
|
spiegelt, schickt Kundennamen nach Hause.
|
||||||
|
|
||||||
|
Was der Sender nicht mehr schickt, verschwindet auch drüben. Wird ein
|
||||||
|
Zeitblock in der Firma ausgeplant, ist die Sperre daheim beim nächsten Lauf
|
||||||
|
weg — sonst stünde dort für immer eine Blockade, die es längst nicht mehr
|
||||||
|
gibt.
|
||||||
|
|
||||||
|
## Wann läuft was
|
||||||
|
|
||||||
|
| Integration | Takt |
|
||||||
|
| ----------- | --------------- |
|
||||||
|
| Telegram | alle 2 Minuten |
|
||||||
|
| Zammad | alle 15 Minuten |
|
||||||
|
| Spiegel | alle 15 Minuten |
|
||||||
|
|
||||||
|
Wer dem Bot etwas schickt, erwartet die Aufgabe gleich in der Liste. In
|
||||||
|
Zammad ändert sich dagegen nichts im Minutentakt.
|
||||||
|
|
||||||
|
Warten muss man trotzdem nicht: Auf der Seite **Integrationen** stößt
|
||||||
|
**Jetzt abrufen** einen Lauf von Hand an. Das lohnt vor allem direkt nach dem
|
||||||
|
Einrichten — sonst wüsstest du erst beim nächsten Takt, ob die Zugangsdaten
|
||||||
|
stimmen.
|
||||||
|
|
||||||
|
## Wenn etwas klemmt
|
||||||
|
|
||||||
|
Unter jeder Integration steht, wann sie zuletzt geglückt ist und woran sie
|
||||||
|
zuletzt gescheitert ist. Nach dem dritten Fehlschlag hintereinander gilt sie
|
||||||
|
als **gestört** — und dann meldet sich der Planer auch, sofern du den Anlass
|
||||||
|
„Ein Kalender lässt sich nicht abgleichen" eingeschaltet hast.
|
||||||
|
|
||||||
|
Erst nach dem dritten Mal: Ein Netz hustet, ein Anbieter hat Wartung. Beim
|
||||||
|
ersten Fehlschlag zu melden hieße, dich für Dinge zu wecken, die sich von
|
||||||
|
selbst erledigen.
|
||||||
|
|
||||||
|
| Symptom | Ursache |
|
||||||
|
| ---------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| „Nicht eingerichtet" trotz Konfiguration | Die Instanz sieht diese Integration nicht vor |
|
||||||
|
| Zammad findet nichts | Der Suchausdruck passt auf kein Ticket — in Zammad selbst probieren |
|
||||||
|
| Der Bot antwortet nicht | Der Chat ist nicht verbunden, oder der Code war abgelaufen |
|
||||||
|
| Der Spiegel wird abgelehnt | Verschiedene Geheimnisse, oder die Uhren laufen mehr als fünf Minuten auseinander |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Zurück zum [Handbuch](README.md).
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { PrismaPg } from '@prisma/adapter-pg'
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
import { expect, test, type Page } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import { PrismaClient } from '../src/generated/prisma/client.js'
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken.
|
* Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken.
|
||||||
@@ -9,16 +10,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js'
|
|||||||
* damit die Entwicklungsdatenbank hinterher aussieht wie vorher.
|
* damit die Entwicklungsdatenbank hinterher aussieht wie vorher.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const EMAIL = process.env.ADMIN_EMAIL ?? ''
|
|
||||||
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
|
|
||||||
const PRAEFIX = `E2E-${Date.now()}`
|
const PRAEFIX = `E2E-${Date.now()}`
|
||||||
|
|
||||||
const verbindung = process.env.DATABASE_URL ?? ''
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
|
||||||
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
|
||||||
|
|
||||||
test.describe('Aufgaben', () => {
|
test.describe('Aufgaben', () => {
|
||||||
test.skip(
|
test.skip(
|
||||||
!EMAIL || !PASSWORT || !verbindung,
|
!EMAIL || !PASSWORT || !VERBINDUNG,
|
||||||
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
|
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,14 +27,6 @@ test.describe('Aufgaben', () => {
|
|||||||
await db.$disconnect()
|
await db.$disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function anmelden(seite: Page): Promise<void> {
|
|
||||||
await seite.goto('/anmelden')
|
|
||||||
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
|
||||||
await seite.getByLabel('Passwort').fill(PASSWORT)
|
|
||||||
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
|
||||||
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => {
|
test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => {
|
||||||
const titel = `${PRAEFIX} Angebot schreiben`
|
const titel = `${PRAEFIX} Angebot schreiben`
|
||||||
await anmelden(page)
|
await anmelden(page)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was in M5 wirklich zählt: die Einstellung hält, und ohne eingeschalteten
|
||||||
|
* Weg geht nichts hinaus.
|
||||||
|
*
|
||||||
|
* Der Test rührt nur an der Einstellung des Testkontos und stellt sie am
|
||||||
|
* Ende wieder her.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
|
||||||
|
|
||||||
|
test.describe('Benachrichtigungen', () => {
|
||||||
|
test.skip(
|
||||||
|
!EMAIL || !PASSWORT || !VERBINDUNG,
|
||||||
|
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
|
||||||
|
)
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
const benutzer = await db.benutzer.findUnique({
|
||||||
|
where: { email: EMAIL },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (benutzer) {
|
||||||
|
await db.benachrichtigungseinstellung.deleteMany({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
})
|
||||||
|
await db.meldungsnachweis.deleteMany({ where: { benutzerId: benutzer.id } })
|
||||||
|
}
|
||||||
|
await db.auditEintrag.deleteMany({
|
||||||
|
where: { aktion: { startsWith: 'benachrichtigung.' } },
|
||||||
|
})
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('speichert die Einstellung und hält sie', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/einstellungen')
|
||||||
|
|
||||||
|
const karte = page.locator('[data-bild="benachrichtigungen"]')
|
||||||
|
await expect(karte).toBeVisible()
|
||||||
|
|
||||||
|
// --- Ab Werk ist nichts eingeschaltet ---------------------------------
|
||||||
|
await expect(karte.locator('input[name="perPush"]')).not.toBeChecked()
|
||||||
|
await expect(karte.locator('input[name="perMail"]')).not.toBeChecked()
|
||||||
|
|
||||||
|
// --- Ohne Weg keine Probe ---------------------------------------------
|
||||||
|
await karte.getByRole('button', { name: 'Probe schicken' }).click()
|
||||||
|
await expect(page.getByText('Erst einen Weg einschalten')).toBeVisible()
|
||||||
|
|
||||||
|
// --- Ändern und speichern ---------------------------------------------
|
||||||
|
await karte.locator('input[name="perMail"]').check()
|
||||||
|
await karte.locator('input[value="zeitblock"]').check()
|
||||||
|
await karte.getByLabel('Tagesüberblick um').fill('06:30')
|
||||||
|
await karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)').fill('20')
|
||||||
|
await karte.getByLabel('Am Wochenende gar nichts melden').check()
|
||||||
|
await karte.getByRole('button', { name: 'Speichern' }).click()
|
||||||
|
await expect(page.getByText('Einstellungen gespeichert.')).toBeVisible()
|
||||||
|
|
||||||
|
// --- Nach dem Neuladen steht alles noch da ----------------------------
|
||||||
|
await page.reload()
|
||||||
|
await expect(karte.locator('input[name="perMail"]')).toBeChecked()
|
||||||
|
await expect(karte.locator('input[value="zeitblock"]')).toBeChecked()
|
||||||
|
await expect(karte.getByLabel('Tagesüberblick um')).toHaveValue('06:30')
|
||||||
|
await expect(karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)')).toHaveValue(
|
||||||
|
'20',
|
||||||
|
)
|
||||||
|
await expect(karte.getByLabel('Am Wochenende gar nichts melden')).toBeChecked()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist eine unsinnige Vorlaufzeit zurück', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/einstellungen')
|
||||||
|
|
||||||
|
const karte = page.locator('[data-bild="benachrichtigungen"]')
|
||||||
|
const vorlauf = karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)')
|
||||||
|
// Das Zahlenfeld begrenzt auf 120; der Server darf sich darauf nicht
|
||||||
|
// verlassen, deshalb kommt der Wert an der Oberfläche vorbei.
|
||||||
|
await vorlauf.evaluate((feld: HTMLInputElement) => {
|
||||||
|
feld.max = '600'
|
||||||
|
feld.value = '300'
|
||||||
|
})
|
||||||
|
await karte.getByRole('button', { name: 'Speichern' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('Bitte die markierten Felder prüfen.')).toBeVisible()
|
||||||
|
await expect(karte.getByText('Mehr als zwei Stunden Vorlauf')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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 ?? ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geheimnis fuer den Spiegel im Test.
|
||||||
|
*
|
||||||
|
* Der Testserver bekommt denselben Wert ueber playwright.config.ts, sonst
|
||||||
|
* liesse sich die Tuer gar nicht pruefen — und eine Pruefung, die immer
|
||||||
|
* uebersprungen wird, prueft nichts.
|
||||||
|
*/
|
||||||
|
export const SPIEGEL_GEHEIMNIS =
|
||||||
|
process.env.SPIEGEL_GEHEIMNIS ?? 'e2e-spiegel-geheimnis-nur-lokal'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet das Testkonto mit Passwort an.
|
||||||
|
*
|
||||||
|
* Der Knopf gehört zu einem Client-Formular. Ein Klick, der vor der
|
||||||
|
* Hydratation ankommt, verpufft folgenlos — die Seite bleibt einfach stehen.
|
||||||
|
* Deshalb wird so lange nachgefasst, bis die Startseite da ist; hat der erste
|
||||||
|
* Klick schon gewirkt, wird nicht noch einmal geklickt.
|
||||||
|
*/
|
||||||
|
export async function anmelden(
|
||||||
|
seite: Page,
|
||||||
|
email: string = EMAIL,
|
||||||
|
passwort: string = PASSWORT,
|
||||||
|
): Promise<void> {
|
||||||
|
await seite.goto('/anmelden')
|
||||||
|
await seite.getByLabel('E-Mail-Adresse').fill(email)
|
||||||
|
await seite.getByLabel('Passwort').fill(passwort)
|
||||||
|
|
||||||
|
const knopf = seite.getByRole('button', { name: 'Mit Passwort anmelden' })
|
||||||
|
const begruessung = seite.getByRole('heading', { name: /Guten Tag/ })
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
if (new URL(seite.url()).pathname === '/anmelden') {
|
||||||
|
await knopf.click({ timeout: 5_000 })
|
||||||
|
}
|
||||||
|
await expect(begruessung).toBeVisible({ timeout: 5_000 })
|
||||||
|
}).toPass({ timeout: 45_000 })
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { PrismaPg } from '@prisma/adapter-pg'
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
import { expect, test, type Page } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { PrismaClient } from '../src/generated/prisma/client.js'
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
import { anmelden as anmeldenMit } from './hilfe'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prüft, dass die Rollentrennung serverseitig hält.
|
* Prüft, dass die Rollentrennung serverseitig hält.
|
||||||
@@ -38,23 +39,15 @@ test.describe('Rollentrennung', () => {
|
|||||||
await db.$disconnect()
|
await db.$disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function anmelden(seite: Page): Promise<void> {
|
|
||||||
await seite.goto('/anmelden')
|
|
||||||
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
|
||||||
await seite.getByLabel('Passwort').fill(PASSWORT)
|
|
||||||
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
|
||||||
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
test('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => {
|
test('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => {
|
||||||
await anmelden(page)
|
await anmeldenMit(page, EMAIL, PASSWORT)
|
||||||
await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
|
await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
|
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await anmelden(page)
|
await anmeldenMit(page, EMAIL, PASSWORT)
|
||||||
await page.goto('/verwaltung/protokoll')
|
await page.goto('/verwaltung/protokoll')
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/keine-berechtigung/)
|
await expect(page).toHaveURL(/\/keine-berechtigung/)
|
||||||
@@ -64,7 +57,7 @@ test.describe('Rollentrennung', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
|
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
|
||||||
await anmelden(page)
|
await anmeldenMit(page, EMAIL, PASSWORT)
|
||||||
await page.goto('/bereiche')
|
await page.goto('/bereiche')
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible()
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { createHmac } from 'node:crypto'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
import { SPIEGEL_GEHEIMNIS, VERBINDUNG } from './hilfe'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Eingang des Spiegels.
|
||||||
|
*
|
||||||
|
* Hier meldet sich kein Mensch an — die Gegenstelle weist sich allein über
|
||||||
|
* eine Signatur aus. Deshalb wird hier nicht die Oberfläche geprüft, sondern
|
||||||
|
* genau diese Tür: Sie muss zu sein, wenn irgendetwas nicht stimmt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GEHEIMNIS = SPIEGEL_GEHEIMNIS
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
|
||||||
|
|
||||||
|
const PRAEFIX = `E2E-S-${Date.now()}`
|
||||||
|
|
||||||
|
function signieren(koerper: string, geheimnis = GEHEIMNIS): string {
|
||||||
|
return createHmac('sha256', geheimnis).update(koerper).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendung(teile: Record<string, unknown> = {}): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
gesendetAm: new Date().toISOString(),
|
||||||
|
belegungen: [],
|
||||||
|
aufgaben: [],
|
||||||
|
...teile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Spiegel', () => {
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await db.spiegelbelegung.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
|
||||||
|
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
|
||||||
|
await db.auditEintrag.deleteMany({ where: { aktion: 'spiegel.empfangen' } })
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist eine Sendung ohne Signatur ab', async ({ request }) => {
|
||||||
|
const antwort = await request.post('/api/spiegel', {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
data: sendung(),
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist eine falsch signierte Sendung ab', async ({ request }) => {
|
||||||
|
const koerper = sendung()
|
||||||
|
const antwort = await request.post('/api/spiegel', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Planer-Signatur': signieren(koerper, 'das-ist-nicht-das-geheimnis'),
|
||||||
|
},
|
||||||
|
data: koerper,
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist eine Sendung ab, die zu lange unterwegs war', async ({ request }) => {
|
||||||
|
test.skip(!GEHEIMNIS, 'Ohne SPIEGEL_GEHEIMNIS ist der Eingang ohnehin zu.')
|
||||||
|
|
||||||
|
// Eine Stunde alt: richtig signiert, trotzdem wertlos. Sonst ließe sich
|
||||||
|
// ein abgefangenes Paket beliebig oft erneut einspielen.
|
||||||
|
const koerper = sendung({
|
||||||
|
gesendetAm: new Date(Date.now() - 3_600_000).toISOString(),
|
||||||
|
})
|
||||||
|
const antwort = await request.post('/api/spiegel', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Planer-Signatur': signieren(koerper),
|
||||||
|
},
|
||||||
|
data: koerper,
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('nimmt eine gültige Sendung an und räumt Veraltetes weg', async ({ request }) => {
|
||||||
|
test.skip(
|
||||||
|
!GEHEIMNIS,
|
||||||
|
'Ohne SPIEGEL_GEHEIMNIS auf dem Testserver lässt sich das nicht prüfen.',
|
||||||
|
)
|
||||||
|
|
||||||
|
const beginn = new Date(Date.now() + 86_400_000)
|
||||||
|
const ende = new Date(beginn.getTime() + 3_600_000)
|
||||||
|
|
||||||
|
const erste = sendung({
|
||||||
|
belegungen: [
|
||||||
|
{
|
||||||
|
ref: `${PRAEFIX}-a`,
|
||||||
|
beginn: beginn.toISOString(),
|
||||||
|
ende: ende.toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
aufgaben: [{ ref: `${PRAEFIX}-1`, titel: `${PRAEFIX} Angebot schreiben` }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const antwort = await request.post('/api/spiegel', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Planer-Signatur': signieren(erste),
|
||||||
|
},
|
||||||
|
data: erste,
|
||||||
|
})
|
||||||
|
expect(antwort.ok()).toBe(true)
|
||||||
|
|
||||||
|
expect(await db.spiegelbelegung.count({ where: { quelleRef: `${PRAEFIX}-a` } })).toBe(
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
const aufgabe = await db.aufgabe.findFirst({
|
||||||
|
where: { quelle: 'SPIEGEL', quelleRef: `${PRAEFIX}-1` },
|
||||||
|
select: { titel: true },
|
||||||
|
})
|
||||||
|
expect(aufgabe?.titel).toBe(`${PRAEFIX} Angebot schreiben`)
|
||||||
|
|
||||||
|
// Zweite Runde ohne die Belegung: Sie wurde drüben ausgeplant und muss
|
||||||
|
// auch hier verschwinden — sonst stünde für immer eine Sperre, die es
|
||||||
|
// längst nicht mehr gibt.
|
||||||
|
const zweite = sendung({ aufgaben: [] })
|
||||||
|
const nachher = await request.post('/api/spiegel', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Planer-Signatur': signieren(zweite),
|
||||||
|
},
|
||||||
|
data: zweite,
|
||||||
|
})
|
||||||
|
expect(nachher.ok()).toBe(true)
|
||||||
|
expect(await db.spiegelbelegung.count({ where: { quelleRef: `${PRAEFIX}-a` } })).toBe(
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Die Aufgabe bleibt: Sie ist Planung, keine Sperre.
|
||||||
|
expect(
|
||||||
|
await db.aufgabe.count({ where: { quelle: 'SPIEGEL', quelleRef: `${PRAEFIX}-1` } }),
|
||||||
|
).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { PrismaPg } from '@prisma/adapter-pg'
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
import { expect, test, type Page } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import { PrismaClient } from '../src/generated/prisma/client.js'
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wochenansicht und Zeitblöcke.
|
* Wochenansicht und Zeitblöcke.
|
||||||
@@ -11,16 +12,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js'
|
|||||||
* Kalender nicht erreichbar ist.
|
* Kalender nicht erreichbar ist.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const EMAIL = process.env.ADMIN_EMAIL ?? ''
|
|
||||||
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
|
|
||||||
const PRAEFIX = `E2E-W-${Date.now()}`
|
const PRAEFIX = `E2E-W-${Date.now()}`
|
||||||
|
|
||||||
const verbindung = process.env.DATABASE_URL ?? ''
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
|
||||||
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
|
||||||
|
|
||||||
test.describe('Woche', () => {
|
test.describe('Woche', () => {
|
||||||
test.skip(
|
test.skip(
|
||||||
!EMAIL || !PASSWORT || !verbindung,
|
!EMAIL || !PASSWORT || !VERBINDUNG,
|
||||||
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
|
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,14 +32,6 @@ test.describe('Woche', () => {
|
|||||||
await db.$disconnect()
|
await db.$disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function anmelden(seite: Page): Promise<void> {
|
|
||||||
await seite.goto('/anmelden')
|
|
||||||
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
|
||||||
await seite.getByLabel('Passwort').fill(PASSWORT)
|
|
||||||
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
|
||||||
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => {
|
test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => {
|
||||||
await db.kalenderkonto.deleteMany({})
|
await db.kalenderkonto.deleteMany({})
|
||||||
await anmelden(page)
|
await anmelden(page)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export default tseslint.config(
|
|||||||
// Vom Generator erzeugt, nicht von Hand gepflegt.
|
// Vom Generator erzeugt, nicht von Hand gepflegt.
|
||||||
'src/generated/**',
|
'src/generated/**',
|
||||||
'next-env.d.ts',
|
'next-env.d.ts',
|
||||||
|
// Service Worker: laeuft im Browser, nicht in unserer Werkzeugkette.
|
||||||
|
'public/sw.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...naechstesWebVitals,
|
...naechstesWebVitals,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.4.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.4.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "2.11.3",
|
"@auth/prisma-adapter": "2.11.3",
|
||||||
"@date-fns/tz": "1.5.0",
|
"@date-fns/tz": "1.5.0",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
|
"web-push": "3.6.7",
|
||||||
"zod": "4.6.1"
|
"zod": "4.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"@types/pg": "8.23.1",
|
"@types/pg": "8.23.1",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
|
"@types/web-push": "3.6.4",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"dotenv": "17.4.2",
|
"dotenv": "17.4.2",
|
||||||
@@ -4545,6 +4547,16 @@
|
|||||||
"@types/react": "^19.3.0"
|
"@types/react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.70.0",
|
"version": "8.70.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
|
||||||
@@ -5446,6 +5458,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -5678,6 +5699,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/asn1js": {
|
"node_modules/asn1js": {
|
||||||
"version": "3.0.10",
|
"version": "3.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||||
@@ -5832,6 +5865,12 @@
|
|||||||
"require-from-string": "^2.0.2"
|
"require-from-string": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||||
|
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
@@ -5892,6 +5931,12 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/c12": {
|
"node_modules/c12": {
|
||||||
"version": "3.3.4",
|
"version": "3.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
||||||
@@ -6413,7 +6458,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -6597,6 +6641,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.20.0",
|
"version": "3.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
||||||
@@ -8085,6 +8138,28 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
@@ -8139,6 +8214,12 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -8804,6 +8885,27 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -9280,6 +9382,12 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -9300,7 +9408,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -9310,7 +9417,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
@@ -10731,6 +10837,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -10793,7 +10919,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/saxes": {
|
"node_modules/saxes": {
|
||||||
@@ -12398,6 +12523,25 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.4.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
|
"web-push": "3.6.7",
|
||||||
"zod": "4.6.1"
|
"zod": "4.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
"@types/pg": "8.23.1",
|
"@types/pg": "8.23.1",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
|
"@types/web-push": "3.6.4",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"dotenv": "17.4.2",
|
"dotenv": "17.4.2",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
|
import { SPIEGEL_GEHEIMNIS } from './e2e/hilfe'
|
||||||
|
|
||||||
const PORT = Number(process.env.E2E_PORT ?? 3100)
|
const PORT = Number(process.env.E2E_PORT ?? 3100)
|
||||||
const BASIS = `http://127.0.0.1:${PORT}`
|
const BASIS = `http://127.0.0.1:${PORT}`
|
||||||
@@ -41,6 +42,8 @@ export default defineConfig({
|
|||||||
APP_URL: BASIS,
|
APP_URL: BASIS,
|
||||||
WEBAUTHN_ORIGIN: BASIS,
|
WEBAUTHN_ORIGIN: BASIS,
|
||||||
WEBAUTHN_RP_ID: '127.0.0.1',
|
WEBAUTHN_RP_ID: '127.0.0.1',
|
||||||
|
// Ohne Geheimnis waere der Spiegel-Eingang zu und sein Test sinnlos.
|
||||||
|
SPIEGEL_GEHEIMNIS,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -418,6 +418,77 @@ 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,
|
||||||
|
perTelegram: 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.
|
||||||
|
|
||||||
|
// --- Integrationen --------------------------------------------------------
|
||||||
|
await db.integrationszustand.deleteMany({})
|
||||||
|
await db.integrationszustand.create({
|
||||||
|
data: {
|
||||||
|
quelle: 'TELEGRAM',
|
||||||
|
merker: '4711',
|
||||||
|
letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 18)),
|
||||||
|
letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 18)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.integrationszustand.create({
|
||||||
|
data: {
|
||||||
|
quelle: 'SPIEGEL',
|
||||||
|
letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 15)),
|
||||||
|
letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 15)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.telegramChat.deleteMany({})
|
||||||
|
await db.telegramChat.create({
|
||||||
|
data: {
|
||||||
|
// Erfunden. Der Bot ist ohnehin abgeschaltet, solange DEMO_MODUS gilt.
|
||||||
|
chatId: BigInt(147258369),
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
bezeichnung: 'Martina',
|
||||||
|
erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 25)),
|
||||||
|
zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 6, 40)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dienstliche Belegung, wie sie aus der Firma hereinkaeme: ohne Titel.
|
||||||
|
await db.spiegelbelegung.deleteMany({})
|
||||||
|
for (const [tag, stunde, dauer] of [
|
||||||
|
[17, 9, 2],
|
||||||
|
[18, 13, 3],
|
||||||
|
[19, 8, 1],
|
||||||
|
] as const) {
|
||||||
|
await db.spiegelbelegung.create({
|
||||||
|
data: {
|
||||||
|
quelleRef: `demo-spiegel-${tag}-${stunde}`,
|
||||||
|
beginn: new Date(Date.UTC(2026, 2, tag, stunde - 1, 0)),
|
||||||
|
ende: new Date(Date.UTC(2026, 2, tag, stunde - 1 + dauer, 0)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
|
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "aufgabe" ADD COLUMN "spiegeln" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "benachrichtigungseinstellung" ADD COLUMN "perTelegram" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "integrationszustand" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"quelle" "Quelle" NOT NULL,
|
||||||
|
"merker" TEXT,
|
||||||
|
"letzterLauf" TIMESTAMP(3),
|
||||||
|
"letzterErfolg" TIMESTAMP(3),
|
||||||
|
"letzterFehler" TEXT,
|
||||||
|
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "integrationszustand_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "spiegelbelegung" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"quelleRef" TEXT NOT NULL,
|
||||||
|
"beginn" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ende" TIMESTAMP(3) NOT NULL,
|
||||||
|
"empfangenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "spiegelbelegung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "telegram_chat" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"chatId" BIGINT NOT NULL,
|
||||||
|
"bezeichnung" TEXT,
|
||||||
|
"benutzerId" TEXT,
|
||||||
|
"meldungen" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"zuletztBenutzt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "telegram_chat_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "integrationszustand_quelle_key" ON "integrationszustand"("quelle");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "spiegelbelegung_quelleRef_key" ON "spiegelbelegung"("quelleRef");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "spiegelbelegung_beginn_idx" ON "spiegelbelegung"("beginn");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "telegram_chat_chatId_key" ON "telegram_chat"("chatId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "telegram_chat_benutzerId_idx" ON "telegram_chat"("benutzerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "telegram_chat" ADD CONSTRAINT "telegram_chat_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "telegram_kopplung" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"gueltigBis" TIMESTAMP(3) NOT NULL,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "telegram_kopplung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "telegram_kopplung_code_key" ON "telegram_kopplung"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "telegram_kopplung_gueltigBis_idx" ON "telegram_kopplung"("gueltigBis");
|
||||||
@@ -31,10 +31,14 @@ model Benutzer {
|
|||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
aktualisiertAm DateTime @updatedAt
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
konten Konto[]
|
konten Konto[]
|
||||||
passkeys Passkey[]
|
passkeys Passkey[]
|
||||||
audit AuditEintrag[]
|
audit AuditEintrag[]
|
||||||
kalenderkonten Kalenderkonto[]
|
kalenderkonten Kalenderkonto[]
|
||||||
|
benachrichtigung Benachrichtigungseinstellung?
|
||||||
|
pushGeraete PushGeraet[]
|
||||||
|
meldungen Meldungsnachweis[]
|
||||||
|
telegramChats TelegramChat[]
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@map("benutzer")
|
@@map("benutzer")
|
||||||
@@ -219,6 +223,10 @@ model Aufgabe {
|
|||||||
/// Kennung beim Ursprungssystem, z. B. die Ticketnummer.
|
/// Kennung beim Ursprungssystem, z. B. die Ticketnummer.
|
||||||
quelleRef String?
|
quelleRef String?
|
||||||
|
|
||||||
|
/// Ausdruecklich zum Spiegeln freigegeben. Nur was hier true ist, verlaesst
|
||||||
|
/// die Instanz mit Titel – alles andere bleibt, wo es ist.
|
||||||
|
spiegeln Boolean @default(false)
|
||||||
|
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
aktualisiertAm DateTime @updatedAt
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
@@ -300,6 +308,9 @@ model Kalenderkonto {
|
|||||||
letzterAbgleich DateTime?
|
letzterAbgleich DateTime?
|
||||||
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
|
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
|
||||||
letzterFehler String?
|
letzterFehler String?
|
||||||
|
/// Wie oft der Abgleich hintereinander scheiterte. Ein Aussetzer ist keine
|
||||||
|
/// Nachricht wert; gemeldet wird erst ab mehreren.
|
||||||
|
fehlversuche Int @default(0)
|
||||||
|
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
|
|
||||||
@@ -378,3 +389,152 @@ model Zeitblock {
|
|||||||
@@index([aufgabeId])
|
@@index([aufgabeId])
|
||||||
@@map("zeitblock")
|
@@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)
|
||||||
|
perTelegram 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zustand einer Integration: wie weit sie gelesen hat und ob es klappt.
|
||||||
|
///
|
||||||
|
/// Eine Zeile je Quelle. Der Merker ist bewusst ein freier Text – jede
|
||||||
|
/// Integration legt darin ab, was sie zum Fortsetzen braucht: Zammad einen
|
||||||
|
/// Zeitstempel, Telegram den Zaehler der zuletzt geholten Nachricht.
|
||||||
|
model Integrationszustand {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
quelle Quelle @unique
|
||||||
|
|
||||||
|
/// Woher es weitergeht. Bedeutung haengt an der Quelle.
|
||||||
|
merker String?
|
||||||
|
|
||||||
|
letzterLauf DateTime?
|
||||||
|
letzterErfolg DateTime?
|
||||||
|
letzterFehler String?
|
||||||
|
/// Fehlschlaege in Folge; bei Erfolg wieder null.
|
||||||
|
fehlversuche Int @default(0)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("integrationszustand")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dienstliche Belegung, die aus der anderen Instanz kommt.
|
||||||
|
///
|
||||||
|
/// Bewusst ohne Titel: Daheim soll sehen, *dass* die Zeit weg ist, nicht
|
||||||
|
/// woran gearbeitet wird. Firmeninhalte verlassen die Firma nicht.
|
||||||
|
model Spiegelbelegung {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
/// Kennung des Zeitblocks in der sendenden Instanz.
|
||||||
|
quelleRef String @unique
|
||||||
|
|
||||||
|
beginn DateTime
|
||||||
|
ende DateTime
|
||||||
|
|
||||||
|
empfangenAm DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([beginn])
|
||||||
|
@@map("spiegelbelegung")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Telegram-Chat, der mit dem Planer reden darf.
|
||||||
|
///
|
||||||
|
/// Wer nicht hier steht, wird verworfen – ein Bot-Token laesst sich erraten
|
||||||
|
/// oder abgreifen, die Chatkennung ist der zweite Riegel.
|
||||||
|
model TelegramChat {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Chatkennung bei Telegram; kann negativ sein (Gruppen).
|
||||||
|
chatId BigInt @unique
|
||||||
|
|
||||||
|
bezeichnung String?
|
||||||
|
benutzerId String?
|
||||||
|
|
||||||
|
/// Empfaengt dieser Chat auch Meldungen, nicht nur Aufgaben?
|
||||||
|
meldungen Boolean @default(true)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
zuletztBenutzt DateTime?
|
||||||
|
|
||||||
|
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([benutzerId])
|
||||||
|
@@map("telegram_chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einmalcode, mit dem sich ein Telegram-Chat anmeldet.
|
||||||
|
///
|
||||||
|
/// Ohne ihn muesste die Chatkennung von Hand eingetragen werden – und um sie
|
||||||
|
/// zu erfahren, muesste der Planer auf Nachrichten Unbekannter antworten.
|
||||||
|
/// Genau das soll er nicht.
|
||||||
|
model TelegramKopplung {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
benutzerId String
|
||||||
|
gueltigBis DateTime
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([gueltigBis])
|
||||||
|
@@map("telegram_kopplung")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Service Worker des Planers.
|
||||||
|
*
|
||||||
|
* Er tut genau eine Sache: Push-Nachrichten anzeigen und beim Klick die
|
||||||
|
* richtige Seite öffnen. Kein Zwischenspeichern von Seiten — ein halb
|
||||||
|
* veralteter Planer wäre schlimmer als gar keiner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
self.addEventListener('install', () => {
|
||||||
|
// Sofort übernehmen, nicht auf das Schließen aller Tabs warten.
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (ereignis) => {
|
||||||
|
ereignis.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('push', (ereignis) => {
|
||||||
|
let daten = { titel: 'Planer', text: '', pfad: '/' }
|
||||||
|
try {
|
||||||
|
if (ereignis.data) daten = { ...daten, ...ereignis.data.json() }
|
||||||
|
} catch {
|
||||||
|
// Kaputte Nutzlast: lieber eine karge Meldung als gar keine.
|
||||||
|
}
|
||||||
|
|
||||||
|
ereignis.waitUntil(
|
||||||
|
self.registration.showNotification(daten.titel, {
|
||||||
|
body: daten.text,
|
||||||
|
lang: 'de',
|
||||||
|
// Gleiche Anlässe überschreiben einander, statt sich zu stapeln.
|
||||||
|
tag: daten.anlass || 'planer',
|
||||||
|
data: { pfad: daten.pfad },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (ereignis) => {
|
||||||
|
ereignis.notification.close()
|
||||||
|
const pfad = (ereignis.notification.data && ereignis.notification.data.pfad) || '/'
|
||||||
|
|
||||||
|
ereignis.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((fenster) => {
|
||||||
|
// Ist der Planer schon offen, dorthin wechseln statt ein zweites
|
||||||
|
// Fenster aufzumachen.
|
||||||
|
for (const f of fenster) {
|
||||||
|
if (f.url.includes(self.location.origin)) {
|
||||||
|
return f.focus().then(() => f.navigate(pfad))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(pfad)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -24,6 +24,13 @@ type Aufnahme = {
|
|||||||
pfad: string
|
pfad: string
|
||||||
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
|
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
|
||||||
vorbereiten?: (seite: Page) => Promise<void>
|
vorbereiten?: (seite: Page) => Promise<void>
|
||||||
|
/**
|
||||||
|
* Nur diesen Ausschnitt aufnehmen statt der ganzen Seite.
|
||||||
|
*
|
||||||
|
* Für Themen, die auf einer gemeinsamen Seite wohnen: ein Bild der ganzen
|
||||||
|
* Einstellungsseite zeigt im Handbuchkapitel zu wenig vom Eigentlichen.
|
||||||
|
*/
|
||||||
|
ausschnitt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUFNAHMEN: Aufnahme[] = [
|
const AUFNAHMEN: Aufnahme[] = [
|
||||||
@@ -36,7 +43,13 @@ const AUFNAHMEN: Aufnahme[] = [
|
|||||||
{ name: 'ideen', pfad: '/ideen' },
|
{ name: 'ideen', pfad: '/ideen' },
|
||||||
{ name: 'bereiche', pfad: '/bereiche' },
|
{ name: 'bereiche', pfad: '/bereiche' },
|
||||||
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
|
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
|
||||||
|
{ name: 'integrationen', pfad: '/integrationen' },
|
||||||
{ name: 'einstellungen', pfad: '/einstellungen' },
|
{ name: 'einstellungen', pfad: '/einstellungen' },
|
||||||
|
{
|
||||||
|
name: 'benachrichtigungen',
|
||||||
|
pfad: '/einstellungen',
|
||||||
|
ausschnitt: '[data-bild="benachrichtigungen"]',
|
||||||
|
},
|
||||||
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -82,10 +95,12 @@ for (const schema of ['hell', 'dunkel'] as const) {
|
|||||||
// Der Farbschema-Umschalter und Schriften sollen fertig sein.
|
// Der Farbschema-Umschalter und Schriften sollen fertig sein.
|
||||||
await seite.waitForTimeout(300)
|
await seite.waitForTimeout(300)
|
||||||
|
|
||||||
await seite.screenshot({
|
const ziel = `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`
|
||||||
path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`,
|
if (aufnahme.ausschnitt) {
|
||||||
fullPage: true,
|
await seite.locator(aufnahme.ausschnitt).screenshot({ path: ziel })
|
||||||
})
|
} else {
|
||||||
|
await seite.screenshot({ path: ziel, fullPage: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await kontext.close()
|
await kontext.close()
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ async function scheitert(name: string, tun: () => Promise<unknown>): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kennung = `zzz-test-${Date.now()}`
|
const kennung = `zzz-test-${Date.now()}`
|
||||||
|
// Eigene Chatkennung je Lauf, damit das Aufraeumen genau diese Zeile trifft
|
||||||
|
// und nicht irgendeine, die zufaellig aehnlich aussieht.
|
||||||
|
const testChatId = BigInt(Date.now())
|
||||||
const testEmail = `${kennung}@example.invalid`
|
const testEmail = `${kennung}@example.invalid`
|
||||||
|
|
||||||
async function aufraeumen(): Promise<void> {
|
async function aufraeumen(): Promise<void> {
|
||||||
@@ -56,6 +59,14 @@ async function aufraeumen(): Promise<void> {
|
|||||||
await db.benutzer.deleteMany({ where: { email: testEmail } })
|
await db.benutzer.deleteMany({ where: { email: testEmail } })
|
||||||
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
|
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||||
await db.webauthnChallenge.deleteMany({ where: { kennung } })
|
await db.webauthnChallenge.deleteMany({ where: { kennung } })
|
||||||
|
await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } })
|
||||||
|
await db.meldungsnachweis.deleteMany({ where: { schluessel: { contains: kennung } } })
|
||||||
|
await db.spiegelbelegung.deleteMany({ where: { quelleRef: { contains: kennung } } })
|
||||||
|
await db.telegramKopplung.deleteMany({
|
||||||
|
where: { code: { contains: kennung.slice(-8) } },
|
||||||
|
})
|
||||||
|
await db.telegramChat.deleteMany({ where: { chatId: testChatId } })
|
||||||
|
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -259,6 +270,131 @@ async function main(): Promise<void> {
|
|||||||
})
|
})
|
||||||
pruefe('Audit-Eintrag anlegen', audit.id.length > 0)
|
pruefe('Audit-Eintrag anlegen', audit.id.length > 0)
|
||||||
|
|
||||||
|
// --- Benachrichtigungen --------------------------------------------------
|
||||||
|
await db.benachrichtigungseinstellung.create({
|
||||||
|
data: { benutzerId: benutzer.id },
|
||||||
|
})
|
||||||
|
await scheitert('Je Benutzer nur eine Benachrichtigungseinstellung', () =>
|
||||||
|
db.benachrichtigungseinstellung.create({ data: { benutzerId: benutzer.id } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const voreinstellung = await db.benachrichtigungseinstellung.findUniqueOrThrow({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
})
|
||||||
|
pruefe(
|
||||||
|
'Ab Werk ist kein Weg eingeschaltet',
|
||||||
|
voreinstellung.perPush === false && voreinstellung.perMail === false,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.pushGeraet.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
endpunkt: `https://push.invalid/${kennung}`,
|
||||||
|
p256dh: 'x',
|
||||||
|
auth: 'y',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await scheitert('Derselbe Push-Endpunkt geht nur einmal', () =>
|
||||||
|
db.pushGeraet.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
endpunkt: `https://push.invalid/${kennung}`,
|
||||||
|
p256dh: 'x',
|
||||||
|
auth: 'y',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.meldungsnachweis.create({
|
||||||
|
data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` },
|
||||||
|
})
|
||||||
|
await scheitert('Derselbe Meldungsschluessel geht nur einmal', () =>
|
||||||
|
db.meldungsnachweis.create({
|
||||||
|
data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
pruefe(
|
||||||
|
'Derselbe Schlüssel am nächsten Tag ist erlaubt',
|
||||||
|
(
|
||||||
|
await db.meldungsnachweis.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
schluessel: `faelligkeit:${kennung}:2026-03-18`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id.length > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Integrationen -------------------------------------------------------
|
||||||
|
// Der Zustand haengt an der Quelle: eine Zeile je Integration, nicht mehr.
|
||||||
|
await db.integrationszustand.create({
|
||||||
|
data: { quelle: 'TELEGRAM', merker: '42' },
|
||||||
|
})
|
||||||
|
await scheitert('Je Quelle nur ein Integrationszustand', () =>
|
||||||
|
db.integrationszustand.create({ data: { quelle: 'TELEGRAM' } }),
|
||||||
|
)
|
||||||
|
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
|
||||||
|
|
||||||
|
// Zwei Aufgaben aus derselben Quelle mit derselben Kennung darf es nicht
|
||||||
|
// geben – sonst legte jeder Lauf dieselbe Sache erneut an.
|
||||||
|
const ausZammad = await db.aufgabe.create({
|
||||||
|
data: { titel: `${kennung}-ticket`, quelle: 'ZAMMAD', quelleRef: `${kennung}-1` },
|
||||||
|
})
|
||||||
|
pruefe('Aufgabe aus einer Integration anlegen', ausZammad.id.length > 0)
|
||||||
|
pruefe('Ohne Freigabe wird nicht gespiegelt', ausZammad.spiegeln === false)
|
||||||
|
|
||||||
|
await scheitert('Dieselbe Quelle mit derselben Kennung nur einmal (Integration)', () =>
|
||||||
|
db.aufgabe.create({
|
||||||
|
data: {
|
||||||
|
titel: `${kennung}-ticket-doppelt`,
|
||||||
|
quelle: 'ZAMMAD',
|
||||||
|
quelleRef: `${kennung}-1`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.spiegelbelegung.create({
|
||||||
|
data: {
|
||||||
|
quelleRef: `${kennung}-zb`,
|
||||||
|
beginn: new Date(Date.UTC(2026, 2, 17, 8, 0)),
|
||||||
|
ende: new Date(Date.UTC(2026, 2, 17, 9, 0)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await scheitert('Dieselbe gespiegelte Belegung nur einmal', () =>
|
||||||
|
db.spiegelbelegung.create({
|
||||||
|
data: {
|
||||||
|
quelleRef: `${kennung}-zb`,
|
||||||
|
beginn: new Date(Date.UTC(2026, 2, 18, 8, 0)),
|
||||||
|
ende: new Date(Date.UTC(2026, 2, 18, 9, 0)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const chat = await db.telegramChat.create({
|
||||||
|
data: { chatId: testChatId, benutzerId: benutzer.id },
|
||||||
|
})
|
||||||
|
pruefe('Telegram-Chat empfaengt im Zweifel Meldungen', chat.meldungen === true)
|
||||||
|
await scheitert('Dieselbe Chatkennung nur einmal', () =>
|
||||||
|
db.telegramChat.create({ data: { chatId: chat.chatId } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.telegramKopplung.create({
|
||||||
|
data: {
|
||||||
|
code: `${kennung.slice(-8)}`,
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
gueltigBis: new Date(Date.now() + 600_000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await scheitert('Denselben Kopplungscode nur einmal', () =>
|
||||||
|
db.telegramKopplung.create({
|
||||||
|
data: {
|
||||||
|
code: `${kennung.slice(-8)}`,
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
gueltigBis: new Date(Date.now() + 600_000),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
await db.benutzer.delete({ where: { id: benutzer.id } })
|
await db.benutzer.delete({ where: { id: benutzer.id } })
|
||||||
|
|
||||||
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
|
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
|
||||||
@@ -280,6 +416,20 @@ async function main(): Promise<void> {
|
|||||||
'Kalenderkonten verschwinden mit dem Benutzer',
|
'Kalenderkonten verschwinden mit dem Benutzer',
|
||||||
(await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0,
|
(await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0,
|
||||||
)
|
)
|
||||||
|
pruefe(
|
||||||
|
'Push-Geräte verschwinden mit dem Benutzer',
|
||||||
|
(await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0,
|
||||||
|
)
|
||||||
|
pruefe(
|
||||||
|
'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer',
|
||||||
|
(await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0,
|
||||||
|
)
|
||||||
|
pruefe(
|
||||||
|
'Meldungsnachweise verschwinden mit dem Benutzer',
|
||||||
|
(await db.meldungsnachweis.count({
|
||||||
|
where: { schluessel: { contains: kennung } },
|
||||||
|
})) === 0,
|
||||||
|
)
|
||||||
pruefe(
|
pruefe(
|
||||||
'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine',
|
'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine',
|
||||||
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,
|
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import webpush from 'web-push'
|
||||||
|
|
||||||
const WURZEL = fileURLToPath(new URL('..', import.meta.url))
|
const WURZEL = fileURLToPath(new URL('..', import.meta.url))
|
||||||
const PROJEKT = 'planer-screenshots'
|
const PROJEKT = 'planer-screenshots'
|
||||||
@@ -22,10 +23,16 @@ const COMPOSE_DATEI = 'docker-compose.screenshots.yml'
|
|||||||
const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
|
const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
|
||||||
const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455'
|
const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455'
|
||||||
|
|
||||||
|
// Frisches Paar je Lauf: Die Bilder sollen die eingerichtete Anwendung
|
||||||
|
// zeigen, ohne dass irgendwo ein privater Schluessel liegen bleibt.
|
||||||
|
const VAPID = webpush.generateVAPIDKeys()
|
||||||
|
|
||||||
const UMGEBUNG = {
|
const UMGEBUNG = {
|
||||||
...process.env,
|
...process.env,
|
||||||
SCREENSHOT_APP_PORT: APP_PORT,
|
SCREENSHOT_APP_PORT: APP_PORT,
|
||||||
SCREENSHOT_DB_PORT: DB_PORT,
|
SCREENSHOT_DB_PORT: DB_PORT,
|
||||||
|
SCREENSHOT_VAPID_OEFFENTLICH: VAPID.publicKey,
|
||||||
|
SCREENSHOT_VAPID_PRIVAT: VAPID.privateKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
function meldung(text: string): void {
|
function meldung(text: string): void {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type AufgabenFilter,
|
type AufgabenFilter,
|
||||||
} from '@/server/abfragen/aufgaben'
|
} from '@/server/abfragen/aufgaben'
|
||||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Aufgaben' }
|
export const metadata: Metadata = { title: 'Aufgaben' }
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ export default async function AufgabenSeite({
|
|||||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||||
}) {
|
}) {
|
||||||
await verlangeAnmeldung('/aufgaben')
|
await verlangeAnmeldung('/aufgaben')
|
||||||
|
const spiegelt = umgebung().spiegelSendenAktiv
|
||||||
const parameter = await searchParams
|
const parameter = await searchParams
|
||||||
|
|
||||||
const [aufgaben, bereiche, projekte] = await Promise.all([
|
const [aufgaben, bereiche, projekte] = await Promise.all([
|
||||||
@@ -101,11 +103,13 @@ export default async function AufgabenSeite({
|
|||||||
</CardInhalt>
|
</CardInhalt>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Nur wo auch gespiegelt wird, hat der Schalter einen Sinn. */}
|
||||||
<AufgabenListe
|
<AufgabenListe
|
||||||
aufgaben={aufgaben}
|
aufgaben={aufgaben}
|
||||||
bereiche={bereiche}
|
bereiche={bereiche}
|
||||||
projekte={projekte}
|
projekte={projekte}
|
||||||
leerText="Keine Aufgabe passt zu diesen Filtern."
|
leerText="Keine Aufgabe passt zu diesen Filtern."
|
||||||
|
mitSpiegeln={spiegelt}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
'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
|
||||||
|
perTelegram: boolean
|
||||||
|
ueberblickUm: string
|
||||||
|
ruheVon: string
|
||||||
|
ruheBis: string
|
||||||
|
ruheAmWochenende: boolean
|
||||||
|
zeitblockVorlaufMinuten: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Eigenschaften = {
|
||||||
|
einstellung: EinstellungAnzeige
|
||||||
|
geraete: GeraetAnzeige[]
|
||||||
|
pushMoeglich: boolean
|
||||||
|
mailMoeglich: boolean
|
||||||
|
telegramMoeglich: boolean
|
||||||
|
/** Wie viele Chats Meldungen empfangen. Null heisst: noch keiner. */
|
||||||
|
telegramChats: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
telegramMoeglich,
|
||||||
|
telegramChats,
|
||||||
|
}: 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>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="perTelegram"
|
||||||
|
defaultChecked={einstellung.perTelegram}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
Telegram
|
||||||
|
{!telegramMoeglich ? (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— auf diesem Server nicht eingerichtet
|
||||||
|
</span>
|
||||||
|
) : telegramChats === 0 ? (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— noch kein Chat eingetragen
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— an {telegramChats} Chat{telegramChats === 1 ? '' : 's'}, mit Knopf zum
|
||||||
|
Abhaken
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ueberblickUm">Tagesüberblick um</Label>
|
||||||
|
<Input
|
||||||
|
id="ueberblickUm"
|
||||||
|
name="ueberblickUm"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ueberblickUm}
|
||||||
|
aria-invalid={Boolean(felder.ueberblickUm)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="zeitblockVorlaufMinuten">
|
||||||
|
Vorlauf vor einem Zeitblock (Minuten)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="zeitblockVorlaufMinuten"
|
||||||
|
name="zeitblockVorlaufMinuten"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={120}
|
||||||
|
step={5}
|
||||||
|
defaultValue={einstellung.zeitblockVorlaufMinuten}
|
||||||
|
aria-invalid={Boolean(felder.zeitblockVorlaufMinuten)}
|
||||||
|
/>
|
||||||
|
{felder.zeitblockVorlaufMinuten && (
|
||||||
|
<p className="text-gefahr text-xs">{felder.zeitblockVorlaufMinuten}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ruheVon">Ruhe von</Label>
|
||||||
|
<Input
|
||||||
|
id="ruheVon"
|
||||||
|
name="ruheVon"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ruheVon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ruheBis">bis</Label>
|
||||||
|
<Input
|
||||||
|
id="ruheBis"
|
||||||
|
name="ruheBis"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ruheBis}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="ruheAmWochenende"
|
||||||
|
defaultChecked={einstellung.ruheAmWochenende}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Am Wochenende gar nichts melden</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
In der Ruhezeit bleibt alles still — bis auf den Tagesüberblick. Der käme sonst
|
||||||
|
nie, wenn du früh aufstehst.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" disabled={laeuft}>
|
||||||
|
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
art="umriss"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => mitRueckmeldung(() => probeSenden())}
|
||||||
|
>
|
||||||
|
<Send aria-hidden />
|
||||||
|
Probe schicken
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Angemeldete Geräte */}
|
||||||
|
{geraete.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">Angemeldete Geräte</h3>
|
||||||
|
<ul className="divide-rand divide-y">
|
||||||
|
{geraete.map((geraet) => (
|
||||||
|
<li key={geraet.id} className="flex items-center gap-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm">{geraet.bezeichnung ?? 'Unbenanntes Gerät'}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Angemeldet {geraet.erstelltAm}
|
||||||
|
{geraet.zuletztBenutzt
|
||||||
|
? ` · zuletzt benachrichtigt ${geraet.zuletztBenutzt}`
|
||||||
|
: ' · noch nichts empfangen'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title={`${geraet.bezeichnung ?? 'Gerät'} abmelden`}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', geraet.id)
|
||||||
|
mitRueckmeldung(() => geraetEntfernen(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
<span className="sr-only">Abmelden</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grobe Kennung des Browsers, damit sich Geräte auseinanderhalten lassen. */
|
||||||
|
function geraetename(): string {
|
||||||
|
const kennung = navigator.userAgent
|
||||||
|
if (/Android/i.test(kennung)) return 'Android-Gerät'
|
||||||
|
if (/iPhone|iPad/i.test(kennung)) return 'iPhone oder iPad'
|
||||||
|
if (/Macintosh/i.test(kennung)) return 'Mac'
|
||||||
|
if (/Windows/i.test(kennung)) return 'Windows-Rechner'
|
||||||
|
if (/Linux/i.test(kennung)) return 'Linux-Rechner'
|
||||||
|
return 'Unbekanntes Gerät'
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import { Benachrichtigungen } from '@/app/(app)/einstellungen/benachrichtigungen'
|
||||||
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -8,6 +9,9 @@ import {
|
|||||||
CardTitel,
|
CardTitel,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { ROLLEN_NAME } from '@/lib/rollen'
|
import { ROLLEN_NAME } from '@/lib/rollen'
|
||||||
|
import { alsEinstellung } from '@/server/benachrichtigung/versand'
|
||||||
|
import { alsUhrzeit } from '@/lib/zeit'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
import { alsDatumUhrzeit } from '@/lib/zeit'
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
@@ -17,6 +21,37 @@ export const metadata: Metadata = { title: 'Einstellungen' }
|
|||||||
export default async function EinstellungenSeite() {
|
export default async function EinstellungenSeite() {
|
||||||
const benutzer = await verlangeAnmeldung('/einstellungen')
|
const benutzer = await verlangeAnmeldung('/einstellungen')
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
const [benachrichtigung, telegramChats, pushGeraete] = await Promise.all([
|
||||||
|
db.benachrichtigungseinstellung.findUnique({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
select: {
|
||||||
|
anlaesse: true,
|
||||||
|
perPush: true,
|
||||||
|
perMail: true,
|
||||||
|
perTelegram: true,
|
||||||
|
ueberblickUm: true,
|
||||||
|
ruheVon: true,
|
||||||
|
ruheBis: true,
|
||||||
|
ruheAmWochenende: true,
|
||||||
|
zeitblockVorlaufMinuten: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.telegramChat.count({ where: { benutzerId: benutzer.id, meldungen: true } }),
|
||||||
|
db.pushGeraet.findMany({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bezeichnung: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
zuletztBenutzt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
const einstellung = alsEinstellung(benachrichtigung)
|
||||||
|
|
||||||
const passkeys = await db.passkey.findMany({
|
const passkeys = await db.passkey.findMany({
|
||||||
where: { benutzerId: benutzer.id },
|
where: { benutzerId: benutzer.id },
|
||||||
orderBy: { erstelltAm: 'asc' },
|
orderBy: { erstelltAm: 'asc' },
|
||||||
@@ -72,6 +107,42 @@ export default async function EinstellungenSeite() {
|
|||||||
/>
|
/>
|
||||||
</CardInhalt>
|
</CardInhalt>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card data-bild="benachrichtigungen">
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Benachrichtigungen</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
Ab Werk meldet der Planer gar nichts. Was du hier einschaltest, kommt — alles
|
||||||
|
andere nicht. Eine Anwendung, die zu oft meldet, wird stummgeschaltet und ist
|
||||||
|
dann wertlos.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<Benachrichtigungen
|
||||||
|
pushMoeglich={einst.pushAktiv}
|
||||||
|
mailMoeglich={einst.mailAktiv}
|
||||||
|
telegramMoeglich={einst.telegramAktiv}
|
||||||
|
telegramChats={telegramChats}
|
||||||
|
einstellung={{
|
||||||
|
anlaesse: [...einstellung.anlaesse],
|
||||||
|
perPush: einstellung.perPush,
|
||||||
|
perMail: einstellung.perMail,
|
||||||
|
perTelegram: einstellung.perTelegram,
|
||||||
|
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
|
||||||
|
ruheVon: alsUhrzeit(einstellung.ruheVon),
|
||||||
|
ruheBis: alsUhrzeit(einstellung.ruheBis),
|
||||||
|
ruheAmWochenende: einstellung.ruheAmWochenende,
|
||||||
|
zeitblockVorlaufMinuten: einstellung.zeitblockVorlaufMinuten,
|
||||||
|
}}
|
||||||
|
geraete={pushGeraete.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
bezeichnung: g.bezeichnung,
|
||||||
|
erstelltAm: alsDatumUhrzeit(g.erstelltAm),
|
||||||
|
zuletztBenutzt: g.zuletztBenutzt ? alsDatumUhrzeit(g.zuletztBenutzt) : null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Loader2, RefreshCw } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { laufAnstossen } from '@/server/aktionen/integrationen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stößt einen Lauf von Hand an.
|
||||||
|
*
|
||||||
|
* Vor allem direkt nach dem Einrichten: Sonst wüsste man erst beim nächsten
|
||||||
|
* Takt, ob die Zugangsdaten stimmen.
|
||||||
|
*/
|
||||||
|
export function LaufKnopf({ quelle }: { quelle: string }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
art="umriss"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
starte(async () => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('quelle', quelle)
|
||||||
|
const antwort = await laufAnstossen(daten)
|
||||||
|
if (antwort.ok) {
|
||||||
|
toast.success(antwort.meldung)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(antwort.fehler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{laeuft ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RefreshCw aria-hidden />
|
||||||
|
)}
|
||||||
|
Jetzt abrufen
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Plug } from 'lucide-react'
|
||||||
|
import { LaufKnopf } from '@/app/(app)/integrationen/lauf-knopf'
|
||||||
|
import { TelegramChats } from '@/app/(app)/integrationen/telegram-chats'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBeschreibung,
|
||||||
|
CardInhalt,
|
||||||
|
CardKopf,
|
||||||
|
CardTitel,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||||
|
import type { Quelle } from '@/generated/prisma/enums'
|
||||||
|
import { INTEGRATION_FEHLER_AB_VERSUCH } from '@/lib/integration'
|
||||||
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Integrationen' }
|
||||||
|
|
||||||
|
type Zeile = {
|
||||||
|
quelle: Quelle
|
||||||
|
name: string
|
||||||
|
beschreibung: string
|
||||||
|
/** In dieser Instanz überhaupt vorgesehen? */
|
||||||
|
vorgesehen: boolean
|
||||||
|
/** Und auch eingerichtet? */
|
||||||
|
aktiv: boolean
|
||||||
|
hinweis: string
|
||||||
|
/** Laesst sich der Lauf von Hand anstossen? Der Empfaenger wartet nur. */
|
||||||
|
abrufbar?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function IntegrationenSeite() {
|
||||||
|
await verlangeAnmeldung('/integrationen')
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
const [zustaende, chats] = await Promise.all([
|
||||||
|
db.integrationszustand.findMany(),
|
||||||
|
db.telegramChat.findMany({
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
chatId: true,
|
||||||
|
bezeichnung: true,
|
||||||
|
meldungen: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
zuletztBenutzt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const nachQuelle = new Map(zustaende.map((z) => [z.quelle, z]))
|
||||||
|
|
||||||
|
const zeilen: Zeile[] = [
|
||||||
|
{
|
||||||
|
quelle: 'ZAMMAD',
|
||||||
|
name: 'Zammad',
|
||||||
|
beschreibung:
|
||||||
|
'Tickets, auf die der Suchausdruck passt, werden zu Aufgaben. Geschrieben wird nach Zammad nichts — ein Ticket schließt man im Ticketsystem.',
|
||||||
|
vorgesehen: einst.instanz === 'firma',
|
||||||
|
aktiv: einst.zammadAktiv,
|
||||||
|
hinweis: 'ZAMMAD_URL und ZAMMAD_TOKEN fehlen.',
|
||||||
|
abrufbar: einst.zammadAktiv,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quelle: 'WARTUNGSPLANER',
|
||||||
|
name: 'Wartungsplaner',
|
||||||
|
beschreibung:
|
||||||
|
'Noch nicht angebunden: Die Schnittstelle ist offen. Die Aufnahmestelle steht bereit, es fehlt allein das Abholen.',
|
||||||
|
vorgesehen: einst.instanz === 'firma',
|
||||||
|
aktiv: false,
|
||||||
|
hinweis: 'Kommt, sobald die Schnittstelle feststeht.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quelle: 'TELEGRAM',
|
||||||
|
name: 'Telegram',
|
||||||
|
beschreibung:
|
||||||
|
'Eine Nachricht an den Bot wird zur Aufgabe. Meldungen gehen denselben Weg zurück — mit einem Knopf zum Abhaken.',
|
||||||
|
vorgesehen: einst.instanz === 'daheim',
|
||||||
|
aktiv: einst.telegramAktiv,
|
||||||
|
hinweis: 'TELEGRAM_TOKEN fehlt.',
|
||||||
|
abrufbar: einst.telegramAktiv,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quelle: 'SPIEGEL',
|
||||||
|
name: 'Spiegel',
|
||||||
|
beschreibung:
|
||||||
|
einst.instanz === 'firma'
|
||||||
|
? 'Schickt Belegungen ohne Titel nach Hause, dazu die Aufgaben, die ausdrücklich zum Spiegeln markiert sind.'
|
||||||
|
: 'Empfängt dienstliche Belegungen. Sie erscheinen in der Wochenansicht als „Dienstlich belegt", ohne Betreff.',
|
||||||
|
vorgesehen: true,
|
||||||
|
aktiv: einst.spiegelSendenAktiv || einst.spiegelEmpfangenAktiv,
|
||||||
|
hinweis: 'SPIEGEL_GEHEIMNIS fehlt.',
|
||||||
|
// Die empfangende Seite ruft nichts ab – sie wartet.
|
||||||
|
abrufbar: einst.spiegelSendenAktiv,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Plug className="text-gedaempft-vordergrund size-6" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Integrationen</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
Was von außen hereinkommt — und was diese Instanz hinausgibt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{zeilen.map((zeile) => {
|
||||||
|
const zustand = nachQuelle.get(zeile.quelle)
|
||||||
|
const gestoert = (zustand?.fehlversuche ?? 0) >= INTEGRATION_FEHLER_AB_VERSUCH
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={zeile.quelle} className={zeile.vorgesehen ? '' : 'opacity-60'}>
|
||||||
|
<CardKopf>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<CardTitel className="text-base">{zeile.name}</CardTitel>
|
||||||
|
{!zeile.vorgesehen ? (
|
||||||
|
<Kennzeichen art="neutral">
|
||||||
|
In dieser Instanz nicht vorgesehen
|
||||||
|
</Kennzeichen>
|
||||||
|
) : gestoert ? (
|
||||||
|
<Kennzeichen art="gefahr">Gestört</Kennzeichen>
|
||||||
|
) : zeile.aktiv ? (
|
||||||
|
<Kennzeichen art="erfolg">Läuft</Kennzeichen>
|
||||||
|
) : (
|
||||||
|
<Kennzeichen art="warnung">Nicht eingerichtet</Kennzeichen>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardBeschreibung>{zeile.beschreibung}</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
|
||||||
|
<CardInhalt className="text-gedaempft-vordergrund flex flex-col gap-1 text-sm">
|
||||||
|
{zeile.vorgesehen && !zeile.aktiv && <p>{zeile.hinweis}</p>}
|
||||||
|
|
||||||
|
{zustand?.letzterErfolg && (
|
||||||
|
<p>Zuletzt geglückt: {alsDatumUhrzeit(zustand.letzterErfolg)}</p>
|
||||||
|
)}
|
||||||
|
{zustand?.letzterFehler && (
|
||||||
|
<p className="text-gefahr">
|
||||||
|
Letzter Fehler: {zustand.letzterFehler}
|
||||||
|
{zustand.fehlversuche > 1 && ` (${zustand.fehlversuche}× in Folge)`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!zustand && zeile.aktiv && <p>Noch kein Lauf.</p>}
|
||||||
|
|
||||||
|
{zeile.abrufbar && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<LaufKnopf quelle={zeile.quelle} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{einst.telegramAktiv && (
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Telegram-Chats</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
Nur wer hier steht, darf mit dem Bot reden — und nur diese Chats bekommen
|
||||||
|
Meldungen. Alles andere wird verworfen, ohne zu antworten. Zum Verbinden
|
||||||
|
holst du dir hier einen Code und schickst ihn dem Bot; er gilt zehn Minuten
|
||||||
|
und genau einmal.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<TelegramChats
|
||||||
|
chats={chats.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
chatId: c.chatId.toString(),
|
||||||
|
bezeichnung: c.bezeichnung,
|
||||||
|
meldungen: c.meldungen,
|
||||||
|
erstelltAm: alsDatumUhrzeit(c.erstelltAm),
|
||||||
|
zuletztBenutzt: c.zuletztBenutzt
|
||||||
|
? alsDatumUhrzeit(c.zuletztBenutzt)
|
||||||
|
: null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Bell, BellOff, Link2, Loader2, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
chatEntfernen,
|
||||||
|
chatMeldungenUmschalten,
|
||||||
|
kopplungAnfordern,
|
||||||
|
} from '@/server/aktionen/integrationen'
|
||||||
|
|
||||||
|
export type ChatAnzeige = {
|
||||||
|
id: string
|
||||||
|
chatId: string
|
||||||
|
bezeichnung: string | null
|
||||||
|
meldungen: boolean
|
||||||
|
erstelltAm: string
|
||||||
|
zuletztBenutzt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TelegramChats({ chats }: { chats: ChatAnzeige[] }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
const [code, setCode] = useState<{ code: string; minuten: number } | null>(null)
|
||||||
|
|
||||||
|
function anfordern() {
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await kopplungAnfordern()
|
||||||
|
if (antwort.ok) {
|
||||||
|
setCode({ code: antwort.code, minuten: antwort.minuten })
|
||||||
|
} else {
|
||||||
|
toast.error(antwort.fehler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mitRueckmeldung(
|
||||||
|
tun: () => Promise<{ ok: boolean; meldung?: string; fehler?: string }>,
|
||||||
|
) {
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await tun()
|
||||||
|
if (antwort.ok) {
|
||||||
|
toast.success(antwort.meldung ?? 'Erledigt.')
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(antwort.fehler ?? 'Das hat nicht geklappt.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{chats.length === 0 ? (
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
Noch kein Chat verbunden. Ohne Verbindung nimmt der Bot nichts an.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-rand divide-y">
|
||||||
|
{chats.map((chat) => (
|
||||||
|
<li key={chat.id} className="flex items-center gap-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm">{chat.bezeichnung ?? `Chat ${chat.chatId}`}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Verbunden {chat.erstelltAm}
|
||||||
|
{chat.zuletztBenutzt
|
||||||
|
? ` · zuletzt benutzt ${chat.zuletztBenutzt}`
|
||||||
|
: ' · noch nichts geschickt'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title={
|
||||||
|
chat.meldungen ? 'Keine Meldungen mehr hierher' : 'Meldungen hierher'
|
||||||
|
}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', chat.id)
|
||||||
|
mitRueckmeldung(() => chatMeldungenUmschalten(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chat.meldungen ? (
|
||||||
|
<Bell className="text-erfolg" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<BellOff aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">Meldungen umschalten</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title="Chat trennen"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', chat.id)
|
||||||
|
mitRueckmeldung(() => chatEntfernen(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
<span className="sr-only">Trennen</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{code ? (
|
||||||
|
<div className="border-rand rounded-md border p-3">
|
||||||
|
<p className="text-sm">
|
||||||
|
Schick dem Bot jetzt diesen Code — als Nachricht oder als{' '}
|
||||||
|
<code className="text-xs">/start {code.code}</code>:
|
||||||
|
</p>
|
||||||
|
<p className="my-2 font-mono text-2xl tracking-widest">{code.code}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Gültig für {code.minuten} Minuten und nur ein einziges Mal. Danach erscheint
|
||||||
|
der Chat hier — spätestens nach dem nächsten Abholen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Button art="umriss" disabled={laeuft} onClick={anfordern}>
|
||||||
|
{laeuft ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Link2 aria-hidden />
|
||||||
|
)}
|
||||||
|
Chat verbinden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -187,6 +187,7 @@ export default async function WocheSeite({
|
|||||||
ganztaegig: t.ganztaegig,
|
ganztaegig: t.ganztaegig,
|
||||||
frei: t.zeigtAls === 'FREI',
|
frei: t.zeigtAls === 'FREI',
|
||||||
kalenderName: t.kalenderName,
|
kalenderName: t.kalenderName,
|
||||||
|
gespiegelt: t.gespiegelt,
|
||||||
}))}
|
}))}
|
||||||
aufgaben={aufgaben.map((a) => ({
|
aufgaben={aufgaben.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
@@ -205,7 +206,8 @@ export default async function WocheSeite({
|
|||||||
<p className="text-gedaempft-vordergrund text-xs">
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein
|
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
|
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.
|
Balken sind Termine aus dem Kalender, gestreifte kommen aus der anderen Instanz
|
||||||
|
— beide lassen sich hier nicht verschieben.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export type RasterTermin = {
|
|||||||
ganztaegig: boolean
|
ganztaegig: boolean
|
||||||
frei: boolean
|
frei: boolean
|
||||||
kalenderName: string
|
kalenderName: string
|
||||||
|
/** Aus der anderen Instanz gespiegelt: belegt, aber ohne Betreff. */
|
||||||
|
gespiegelt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RasterSpalte = {
|
export type RasterSpalte = {
|
||||||
@@ -321,6 +323,11 @@ export function Wochenraster({
|
|||||||
eintrag.frei
|
eintrag.frei
|
||||||
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
|
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
|
||||||
: 'bg-gedaempft text-gedaempft-vordergrund',
|
: 'bg-gedaempft text-gedaempft-vordergrund',
|
||||||
|
// Gespiegeltes gehört sichtbar in eine eigene
|
||||||
|
// Klasse: gestreift, damit niemand darin einen
|
||||||
|
// Termin mit fehlendem Betreff vermutet.
|
||||||
|
eintrag.gespiegelt &&
|
||||||
|
'border-rand border border-dashed bg-[repeating-linear-gradient(45deg,transparent,transparent_4px,color-mix(in_oklch,currentColor_12%,transparent)_4px,color-mix(in_oklch,currentColor_12%,transparent)_8px)]',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
top: lage.oben,
|
top: lage.oben,
|
||||||
@@ -328,10 +335,16 @@ export function Wochenraster({
|
|||||||
left: `${(spur / spuren) * 100}%`,
|
left: `${(spur / spuren) * 100}%`,
|
||||||
width: `${100 / spuren}%`,
|
width: `${100 / spuren}%`,
|
||||||
}}
|
}}
|
||||||
title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
|
title={
|
||||||
|
eintrag.gespiegelt
|
||||||
|
? 'Dienstlich belegt – gespiegelt aus der anderen Instanz'
|
||||||
|
: `${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="line-clamp-2">
|
<span className="line-clamp-2">
|
||||||
{eintrag.betreff ?? 'Termin'}
|
{eintrag.gespiegelt
|
||||||
|
? 'Dienstlich belegt'
|
||||||
|
: (eintrag.betreff ?? 'Termin')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { verlangeAnmeldungFuerApi } from '@/server/berechtigung'
|
||||||
|
import { geraetAbmelden, geraetAnmelden } from '@/server/benachrichtigung/push'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An- und Abmelden eines Browsers für Push-Nachrichten.
|
||||||
|
*
|
||||||
|
* Der öffentliche VAPID-Schlüssel kommt über GET; er ist nicht geheim, wird
|
||||||
|
* aber nur an Angemeldete herausgegeben — es gibt keinen Grund, die
|
||||||
|
* Einrichtung nach außen zu zeigen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const anmeldung = z.object({
|
||||||
|
endpunkt: z.url(),
|
||||||
|
p256dh: z.string().min(1).max(500),
|
||||||
|
auth: z.string().min(1).max(500),
|
||||||
|
bezeichnung: z.string().trim().max(60).nullable().default(null),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const benutzer = await verlangeAnmeldungFuerApi()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
aktiv: einst.pushAktiv,
|
||||||
|
oeffentlicherSchluessel: einst.pushAktiv ? einst.VAPID_OEFFENTLICH : null,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(anfrage: Request) {
|
||||||
|
const benutzer = await verlangeAnmeldungFuerApi()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (!umgebung().pushAktiv) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ fehler: 'Push ist auf diesem Server nicht eingerichtet.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let inhalt: unknown
|
||||||
|
try {
|
||||||
|
inhalt = await anfrage.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = anmeldung.safeParse(inhalt)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ fehler: 'Die Anmeldung war unvollständig.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const geraet = await geraetAnmelden(
|
||||||
|
benutzer.id,
|
||||||
|
{
|
||||||
|
endpunkt: gelesen.data.endpunkt,
|
||||||
|
p256dh: gelesen.data.p256dh,
|
||||||
|
auth: gelesen.data.auth,
|
||||||
|
},
|
||||||
|
gelesen.data.bezeichnung,
|
||||||
|
)
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: benutzer.id, email: benutzer.email },
|
||||||
|
aktion: 'push.angemeldet',
|
||||||
|
objektTyp: 'PushGeraet',
|
||||||
|
objektId: geraet.id,
|
||||||
|
// Weder Endpunkt noch Schlüssel – beides taugt zum Zustellen.
|
||||||
|
details: { bezeichnung: gelesen.data.bezeichnung },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const abmeldung = z.object({ endpunkt: z.url() })
|
||||||
|
|
||||||
|
export async function DELETE(anfrage: Request) {
|
||||||
|
const benutzer = await verlangeAnmeldungFuerApi()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let inhalt: unknown
|
||||||
|
try {
|
||||||
|
inhalt = await anfrage.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = abmeldung.safeParse(inhalt)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await geraetAbmelden(gelesen.data.endpunkt)
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: benutzer.id, email: benutzer.email },
|
||||||
|
aktion: 'push.abgemeldet',
|
||||||
|
objektTyp: 'PushGeraet',
|
||||||
|
objektId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { sendungAufnehmen, sendungPruefen } from '@/server/integration/spiegel'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Eingang des Spiegels — nur in der Instanz `daheim`.
|
||||||
|
*
|
||||||
|
* Hier meldet sich kein Mensch an: Die Gegenstelle weist sich über eine
|
||||||
|
* Signatur aus, die sie mit dem gemeinsamen Geheimnis über den Rumpf bildet.
|
||||||
|
*
|
||||||
|
* Jede Zurückweisung sieht gleich aus. Ob das Geheimnis falsch war, die Uhr
|
||||||
|
* verstellt oder der Inhalt Unsinn — von außen ist das nicht zu unterscheiden,
|
||||||
|
* und genau so soll es sein.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function POST(anfrage: Request) {
|
||||||
|
// Der Rumpf muss als Text gelesen werden: Die Signatur gilt für genau diese
|
||||||
|
// Zeichen, nicht für das, was ein JSON-Parser daraus und wieder zurück macht.
|
||||||
|
const koerper = await anfrage.text()
|
||||||
|
const signatur = anfrage.headers.get('X-Planer-Signatur')
|
||||||
|
|
||||||
|
const geprueft = sendungPruefen(koerper, signatur, jetzt())
|
||||||
|
if (!geprueft.ok) {
|
||||||
|
return NextResponse.json({ fehler: 'Abgelehnt.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bericht = await sendungAufnehmen(geprueft.sendung, {
|
||||||
|
id: null,
|
||||||
|
email: 'spiegel@intern',
|
||||||
|
})
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: null, email: 'spiegel@intern' },
|
||||||
|
aktion: 'spiegel.empfangen',
|
||||||
|
objektTyp: 'Spiegelbelegung',
|
||||||
|
objektId: null,
|
||||||
|
// Nur Zahlen: Was gespiegelt wurde, steht in den Datensätzen selbst.
|
||||||
|
details: bericht,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, ...bericht })
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ type Eigenschaften = {
|
|||||||
leerText: string
|
leerText: string
|
||||||
mitEinsortieren?: boolean
|
mitEinsortieren?: boolean
|
||||||
mitUebernehmen?: boolean
|
mitUebernehmen?: boolean
|
||||||
|
/** Schalter zum Spiegeln zeigen; nur auf der sendenden Instanz sinnvoll. */
|
||||||
|
mitSpiegeln?: boolean
|
||||||
/** Bearbeiten direkt in der Liste erlauben. */
|
/** Bearbeiten direkt in der Liste erlauben. */
|
||||||
mitBearbeiten?: boolean
|
mitBearbeiten?: boolean
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,7 @@ export function AufgabenListe({
|
|||||||
leerText,
|
leerText,
|
||||||
mitEinsortieren = false,
|
mitEinsortieren = false,
|
||||||
mitUebernehmen = false,
|
mitUebernehmen = false,
|
||||||
|
mitSpiegeln = false,
|
||||||
mitBearbeiten = true,
|
mitBearbeiten = true,
|
||||||
}: Eigenschaften) {
|
}: Eigenschaften) {
|
||||||
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
|
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
|
||||||
@@ -60,6 +63,7 @@ export function AufgabenListe({
|
|||||||
bereiche={bereiche}
|
bereiche={bereiche}
|
||||||
mitEinsortieren={mitEinsortieren}
|
mitEinsortieren={mitEinsortieren}
|
||||||
mitUebernehmen={mitUebernehmen}
|
mitUebernehmen={mitUebernehmen}
|
||||||
|
mitSpiegeln={mitSpiegeln}
|
||||||
{...(mitBearbeiten
|
{...(mitBearbeiten
|
||||||
? { onBearbeiten: () => setBearbeitet(aufgabe.id) }
|
? { onBearbeiten: () => setBearbeitet(aufgabe.id) }
|
||||||
: {})}
|
: {})}
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Check, ExternalLink, Loader2, Pencil, RotateCcw, Trash2 } from 'lucide-react'
|
import {
|
||||||
|
Check,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
RotateCcw,
|
||||||
|
Share2,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||||
@@ -22,6 +30,7 @@ import {
|
|||||||
aufgabeLoeschen,
|
aufgabeLoeschen,
|
||||||
einsortieren,
|
einsortieren,
|
||||||
ideeUebernehmen,
|
ideeUebernehmen,
|
||||||
|
spiegelnUmschalten,
|
||||||
statusSetzen,
|
statusSetzen,
|
||||||
type Rueckmeldung,
|
type Rueckmeldung,
|
||||||
} from '@/server/aktionen/aufgaben'
|
} from '@/server/aktionen/aufgaben'
|
||||||
@@ -36,6 +45,8 @@ type Eigenschaften = {
|
|||||||
mitEinsortieren?: boolean
|
mitEinsortieren?: boolean
|
||||||
/** Zeigt den Knopf "Zur Aufgabe machen" (Ideen). */
|
/** Zeigt den Knopf "Zur Aufgabe machen" (Ideen). */
|
||||||
mitUebernehmen?: boolean
|
mitUebernehmen?: boolean
|
||||||
|
/** Zeigt den Schalter zum Spiegeln; nur auf der sendenden Instanz. */
|
||||||
|
mitSpiegeln?: boolean
|
||||||
onBearbeiten?: (aufgabe: AufgabeAnzeige) => void
|
onBearbeiten?: (aufgabe: AufgabeAnzeige) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +55,7 @@ export function AufgabenZeile({
|
|||||||
bereiche = [],
|
bereiche = [],
|
||||||
mitEinsortieren = false,
|
mitEinsortieren = false,
|
||||||
mitUebernehmen = false,
|
mitUebernehmen = false,
|
||||||
|
mitSpiegeln = false,
|
||||||
onBearbeiten,
|
onBearbeiten,
|
||||||
}: Eigenschaften) {
|
}: Eigenschaften) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -198,6 +210,29 @@ export function AufgabenZeile({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{mitSpiegeln && (
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title={
|
||||||
|
aufgabe.spiegeln
|
||||||
|
? 'Geht mit nach Hause – hier abschalten'
|
||||||
|
: 'Mit nach Hause spiegeln'
|
||||||
|
}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', aufgabe.id)
|
||||||
|
mitRueckmeldung(() => spiegelnUmschalten(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Share2
|
||||||
|
className={aufgabe.spiegeln ? 'text-betont' : undefined}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Spiegeln</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{onBearbeiten && (
|
{onBearbeiten && (
|
||||||
<Button
|
<Button
|
||||||
art="leise"
|
art="leise"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function Kopfleiste({
|
|||||||
{ pfad: '/ideen', name: 'Ideen' },
|
{ pfad: '/ideen', name: 'Ideen' },
|
||||||
{ pfad: '/bereiche', name: 'Bereiche' },
|
{ pfad: '/bereiche', name: 'Bereiche' },
|
||||||
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
||||||
|
{ pfad: '/integrationen', name: 'Integrationen' },
|
||||||
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||||||
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
||||||
]
|
]
|
||||||
@@ -49,7 +50,13 @@ export function Kopfleiste({
|
|||||||
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
|
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
|
||||||
die zweite Zeile zu drängen.
|
die zweite Zeile zu drängen.
|
||||||
*/}
|
*/}
|
||||||
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:gap-6">
|
{/*
|
||||||
|
Breiter als die Inhaltsspalte (max-w-5xl): Mit dem elften Eintrag
|
||||||
|
passte die Leiste dort nicht mehr, und "Einstellungen" verschwand
|
||||||
|
stillschweigend am rechten Rand. Eine Leiste, die Punkte verschluckt,
|
||||||
|
ist schlimmer als eine, die nicht buendig mit dem Text steht.
|
||||||
|
*/}
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl items-center gap-3 px-4 py-3 sm:gap-5">
|
||||||
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
|
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
|
||||||
Planer
|
Planer
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function roh(teil: Partial<AufgabeRoh> = {}): AufgabeRoh {
|
|||||||
faelligAm: null,
|
faelligAm: null,
|
||||||
quelle: 'MANUELL',
|
quelle: 'MANUELL',
|
||||||
quelleUrl: null,
|
quelleUrl: null,
|
||||||
|
spiegeln: false,
|
||||||
bereich: null,
|
bereich: null,
|
||||||
projekt: null,
|
projekt: null,
|
||||||
...teil,
|
...teil,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type AufgabeRoh = {
|
|||||||
faelligAm: Date | null
|
faelligAm: Date | null
|
||||||
quelle: Quelle
|
quelle: Quelle
|
||||||
quelleUrl: string | null
|
quelleUrl: string | null
|
||||||
|
spiegeln: boolean
|
||||||
bereich: { id: string; name: string; farbe: string } | null
|
bereich: { id: string; name: string; farbe: string } | null
|
||||||
projekt: { id: string; name: string } | null
|
projekt: { id: string; name: string } | null
|
||||||
}
|
}
|
||||||
@@ -48,6 +49,8 @@ export type AufgabeAnzeige = {
|
|||||||
ueberfaellig: boolean
|
ueberfaellig: boolean
|
||||||
quelle: Quelle
|
quelle: Quelle
|
||||||
quelleUrl: string | null
|
quelleUrl: string | null
|
||||||
|
/** Ausdruecklich zum Spiegeln freigegeben. */
|
||||||
|
spiegeln: boolean
|
||||||
bereich: { id: string; name: string; farbe: string } | null
|
bereich: { id: string; name: string; farbe: string } | null
|
||||||
projekt: { id: string; name: string } | null
|
projekt: { id: string; name: string } | null
|
||||||
}
|
}
|
||||||
@@ -79,6 +82,7 @@ export function fuerAnzeige(aufgabe: AufgabeRoh, jetzt: Date): AufgabeAnzeige {
|
|||||||
ueberfaellig: istUeberfaellig(aufgabe.faelligAm, aufgabe.status, jetzt),
|
ueberfaellig: istUeberfaellig(aufgabe.faelligAm, aufgabe.status, jetzt),
|
||||||
quelle: aufgabe.quelle,
|
quelle: aufgabe.quelle,
|
||||||
quelleUrl: aufgabe.quelleUrl,
|
quelleUrl: aufgabe.quelleUrl,
|
||||||
|
spiegeln: aufgabe.spiegeln,
|
||||||
bereich: aufgabe.bereich,
|
bereich: aufgabe.bereich,
|
||||||
projekt: aufgabe.projekt,
|
projekt: aufgabe.projekt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,239 @@
|
|||||||
|
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
|
||||||
|
perTelegram: 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,
|
||||||
|
perTelegram: 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
|
||||||
|
/**
|
||||||
|
* Die Aufgabe, um die es geht — sofern es genau eine ist.
|
||||||
|
*
|
||||||
|
* Nur dann lässt sich unter die Meldung ein Knopf hängen, der sie abhakt.
|
||||||
|
* Ein Tagesüberblick hat keine.
|
||||||
|
*/
|
||||||
|
objektId?: 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 && !einstellung.perTelegram) {
|
||||||
|
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),
|
||||||
|
perTelegram: 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}.`
|
||||||
|
}
|
||||||