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

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

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

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

Aufgefallen ist es erst im aufgenommenen Bild.

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

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

Der Integrationstest prueft die drei neuen Tabellen samt Kaskade.

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

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

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

Alte Nachweise raeumt der naechtliche Lauf weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:00:00 +02:00
Florian WildeandClaude Opus 5 b622c1bb28 feat: Benachrichtigungen in den Einstellungen bedienen
Anmelden dieses Browsers, Liste der angemeldeten Geraete, Auswahl der
Anlaesse, Ruhezeit und Vorlauf. Ist ein Weg auf dem Server nicht
eingerichtet, steht das direkt daneben statt in einem Fehler beim
Speichern.

Der Knopf "Probe senden" umgeht Ruhezeit und Sperre absichtlich: wer
darauf drueckt, will jetzt wissen, ob es ankommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:59:57 +02:00
Florian WildeandClaude Opus 5 e0e11c85b2 feat: Versand per Web-Push und E-Mail
Push ueber VAPID, Mail ueber SMTP, beide unabhaengig zuschaltbar. Die
Schluessel eines Push-Geraets liegen verschluesselt in der Datenbank.

Ein Geraet, das der Push-Dienst mit 404 oder 410 ablehnt, wird sofort
entfernt; andere Fehler zaehlen hoch und fuehren nach fuenf Versuchen
zum selben Ergebnis. Eine Liste toter Abos putzt sich so von allein.

Der Nachweis wird vor dem Senden geschrieben, nicht danach: lieber eine
verpasste Nachricht als eine, die im Minutentakt erneut versucht wird.

Weder Endpunkt noch Schluessel noch Mailinhalt landen im Protokoll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:59:31 +02:00
Florian WildeandClaude Opus 5 ddb13d7dd0 feat: Regeln fuer Benachrichtigungen als reine Logik
Anlaesse, Ruhezeit ueber Mitternacht, Wiederholungssperre und die
Entscheidung, ob eine Meldung jetzt hinausgeht. Ohne Datenbank und ohne
Systemzeit — der Zeitpunkt kommt immer als Parameter herein, damit sich
alles ohne Warten pruefen laesst.

Der Tagesueberblick ist von der Ruhezeit ausgenommen: wer ihn auf 06:00
stellt, bekaeme ihn sonst nie.

Die Voreinstellung ist bewusst zurueckhaltend. Wer beim ersten Start mit
Meldungen ueberschuettet wird, schaltet alles ab — und dann auch das
Nuetzliche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:59:27 +02:00
Florian WildeandClaude Opus 5 24f613d372 feat: Datenmodell und Umgebung fuer Benachrichtigungen
Drei Tabellen: die Einstellung je Benutzer, angemeldete Push-Geraete und
ein Nachweis je verschickter Meldung. Der Nachweis ist das Gedaechtnis
gegen Wiederholungen — ohne ihn kaeme dieselbe Sache bei jedem Durchlauf
erneut.

Kalenderkonten bekommen einen Zaehler fuer Fehlversuche in Folge, damit
ein Abgleichfehler erst nach mehreren Versuchen gemeldet wird.

VAPID- und SMTP-Werte sind freiwillig: fehlen sie, laeuft der Planer
ohne den jeweiligen Weg weiter und sagt es in den Einstellungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:58:59 +02:00
Florian WildeandClaude Opus 5 ef79b24a96 Merge: Meilenstein 4 – Wochenplanung und Zeitblöcke (0.4.0)
Damit steht die Kernidee des Projekts: Aufgaben werden per Ziehen und
Ablegen zu echten Kalenderterminen, und Verschiebungen aus dem Kalender
kommen zurück.

Der Schleifenschutz über sha256(startISO|endeISO) verhindert, dass App
und Kalender einander endlos hinterherschreiben. Bei HTTP 412 gewinnt der
Kalender.

Geprüft vor dem Merge: npm run pruefen mit 250 Unit-Tests, 40
Integrationsprüfungen, Migrationstest und 17 E2E-Tests. Der Schreibweg ist
gegen die Logik geprüft, nicht gegen die echten APIs — dafür fehlen die
Zugangsdaten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 06:33:28 +02:00
Florian WildeandClaude Opus 5 57bf7748be chore(release): 0.4.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 06:33:19 +02:00
Florian WildeandClaude Opus 5 30387ea5af docs: M4-Datum auf den Freigabetag setzen
Der Eintrag trägt jetzt das Datum des Tags, damit ROADMAP und CHANGELOG
dieselbe Zahl nennen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 06:29:45 +02:00
Florian WildeandClaude Opus 5 e9e2ae51ec docs: DEVLOG-Eintrag für M4
Ziel, Erledigtes, die Entscheidungen mit Begründung, die Probleme samt
Lösung und die offenen Punkte — allen voran, dass auch der Schreibweg
nicht gegen die echten APIs geprüft werden konnte.

Dauer laut zeiten.csv: 167 Minuten. Gesamt bislang 413 Minuten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:32:21 +02:00
Florian WildeandClaude Opus 5 5429b960fd docs: Handbuchbilder für M4 neu aufnehmen
44 Bilder aus der Wegwerf-Instanz: elf Seiten, je hell und dunkel,
Schreibtisch und Handy. Neu dabei die Wochenansicht mit eingeplanten
Zeitblöcken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:32:20 +02:00
Florian WildeandClaude Opus 5 0b967d4a33 fix(woche): Wochenansicht aus dem schmalen Inhaltsrahmen befreien
Der Fehler fiel erst im aufgenommenen Bild auf: Bei sieben Spalten plus
Aufgabenliste war der Sonntag abgeschnitten — der Inhaltsrahmen von 64rem
ist für eine Wochenansicht zu eng.

Genau diese eine Seite bricht jetzt aus und nimmt bis zu 100rem, mittig
und mit demselben Seitenabstand wie überall. Die anderen Seiten bleiben
bei ihrer Lesebreite; eine Aufgabenliste über 100rem zu ziehen wäre keine
Verbesserung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:32:18 +02:00
Florian WildeandClaude Opus 5 0974695d93 docs: Handbuchseite zur Wochenplanung
Erklärt nicht nur, wo man zieht, sondern warum der Kalender immer gewinnt,
warum sich das nicht aufschaukelt und was das Warnzeichen im Block
bedeutet.

Dazu der ehrliche Abschnitt "Was noch nicht geht": die Dauer lässt sich
nicht am Balken ziehen, und ein Zeitblock landet immer im ersten
verbundenen Kalender.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:17:23 +02:00
Florian WildeandClaude Opus 5 5b1e9981e4 test: Wochenansicht und Ausplanen absichern
Drei E2E-Tests: der Hinweis ohne verbundenen Kalender, ein Zeitblock im
Raster samt Ausplanen, und das Blättern durch die Wochen.

Der mittlere Test läuft bewusst mit einem Kalenderkonto ohne gültige
Tokens. Der Schreibweg zum Anbieter scheitert dann — und genau das ist
prüfenswert: Die Planung muss auch überleben, wenn der Kalender nicht
erreichbar ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:17:22 +02:00
Florian WildeandClaude Opus 5 fada4450bd feat(woche): Wochenansicht mit Ziehen und Ablegen
Links die einzuplanenden Aufgaben, rechts sieben Tage im
Viertelstundenraster. Eine Aufgabe ins Raster ziehen legt einen Zeitblock
an und schreibt ihn in den Kalender; ein Block lässt sich am Griff
verschieben, das Kreuz plant ihn wieder aus.

Die Dauer eines neuen Blocks kommt aus dem geschätzten Aufwand der
Aufgabe — wer schätzt, bekommt die passende Größe geschenkt; ohne
Schätzung sind es 60 Minuten.

Fremde Termine liegen grau im Hintergrund und lassen sich hier nicht
anfassen. Sie gehören dem Kalender, nicht dem Planer.

Zwei Details:
- Ziehen beginnt erst nach acht Pixeln Bewegung. Sonst löst jeder Klick
  einen Zug aus und die Knöpfe im Block wären nicht mehr bedienbar.
- Ein Zeitblock steht auch im Terminspiegel; er würde sonst doppelt
  erscheinen, einmal als eigene Planung und einmal als fremder Termin.
  Die Abfrage filtert ihn heraus.

Der Wochenversatz steht in der Adresse, damit eine bestimmte Woche als
Lesezeichen taugt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:37:53 +02:00
Florian WildeandClaude Opus 5 bcb4b8eb02 feat(woche): Rasterlogik für die Wochenansicht
Aus Zeitpunkt, Stundenfenster und Blöcken entsteht alles, was die Ansicht
braucht: Spalten ab Montag, Zeilen im Viertelstundenraster und die Lage
jedes Balkens darin. Reine Logik, 21 Tests.

Drei Feinheiten, die ohne Test leicht falsch geraten:
- Das Stundenfenster ist die Hülle über alle Arbeitsfenster, mit einer
  Stunde Luft. Ohne die Luft läge ein Block, der genau am Rand beginnt, an
  der Kante und ließe sich kaum greifen.
- Balken werden am Fensterrand abgeschnitten. Ein Termin von 6 bis 23 Uhr
  würde sonst das Raster sprengen.
- Sich überschneidende Termine bekommen Spuren nebeneinander, sonst läge
  einer unsichtbar hinter dem anderen. Eine Spur wird wieder frei, sobald
  sie zu Ende ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:37:52 +02:00
Florian WildeandClaude Opus 5 9c1c916b58 feat(zeitbloecke): Schreibweg in den Kalender und Rückweg zurück
Anlegen, verschieben und lösen schreiben in den Kalender des Anbieters.
Jeder Zugriff auf einen bestehenden Termin geht mit If-Match und dem
zuletzt gelesenen Etag heraus; antwortet der Anbieter mit 412, gewinnt der
Kalender: der Planer schreibt nicht, verwirft seinen Fingerabdruck und
überlässt dem nächsten Abgleich, den fremden Stand zu holen.

Der Rückweg läuft nach jedem Abgleich und vergleicht die eigenen
Zeitblöcke mit dem aufgefrischten Spiegel. Wurde im Kalender verschoben,
zieht der Planer nach. Wurde dort gelöscht, verliert der Zeitblock seine
Verankerung und die Aufgabe fällt zurück auf offen, wenn nichts anderes
mehr für sie geplant ist.

Zwei Entscheidungen zum Fehlerfall:
- Scheitert das Schreiben beim Anlegen, bleibt der Zeitblock trotzdem
  bestehen, mit vermerktem Fehler. Ihn zu verwerfen hieße, die Planung
  wegen eines Netzproblems zu vergessen; das Verschieben trägt ihn nach.
- Scheitert das Entfernen, verschwindet der Zeitblock dennoch. Bliebe er
  stehen, weil der Kalender gerade nicht erreichbar ist, wäre die Planung
  blockiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:49:52 +02:00
Florian WildeandClaude Opus 5 5791047982 feat(zeitbloecke): Datenmodell und Schleifenschutz
Ein Zeitblock ist eine Aufgabe, die zu einer bestimmten Zeit in einem
Kalender steht. Er lebt an zwei Orten — hier und beim Anbieter — und beide
Seiten dürfen ihn verschieben. Daraus entsteht die Gefahr einer
Endlosschleife: Die App schreibt, der Abgleich liest die eigene Änderung,
hält sie für fremd, schreibt zurück, der Abgleich liest wieder.

Der Ausweg ist ein Fingerabdruck über den Zeitraum, sha256(startISO|endeISO).
Beim Schreiben merkt sich der Zeitblock, was er in den Kalender gelegt hat;
kommt derselbe Fingerabdruck zurück, war es die eigene Änderung. Weicht er
ab, hat jemand anderes verschoben — und dann gewinnt der Kalender.

Bewusst nur über Beginn und Ende: Titel und Beschreibung ändern sich auch
aus harmlosen Gründen, und für die Planung zählt allein, wann der Block
liegt.

30 Tests, darunter zwei, die den eigentlichen Zweck prüfen: dass es sich
über mehrere Runden nicht hochschaukelt und nach einer übernommenen
fremden Verschiebung wieder zur Ruhe kommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:49:52 +02:00
Florian WildeandClaude Opus 5 bd9df22a18 Merge: Meilenstein 3 – Kalender lesen (0.3.0)
Kalenderkonten für Microsoft 365 und Google verbinden, Termine örtlich
spiegeln und die Belegung des Tages gegen die Tageskapazität rechnen.
Tokens liegen verschlüsselt in der Datenbank.

Geprüft vor dem Merge: npm run pruefen mit 199 Unit-Tests, 40
Integrationsprüfungen, Migrationstest und 14 E2E-Tests. Der Abgleich ist
gegen anonymisierte Beispielantworten geprüft, nicht gegen die echten
APIs — dafür fehlen die Zugangsdaten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:42:00 +02:00
Florian WildeandClaude Opus 5 dad2db929b chore(release): 0.3.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:41:59 +02:00
Florian WildeandClaude Opus 5 e4b08b4c30 docs: DEVLOG-Eintrag für M3
Ziel, Erledigtes, die Entscheidungen mit Begründung, die Probleme samt
Lösung und die offenen Punkte — allen voran, dass der Abgleich nicht gegen
die echten APIs getestet werden konnte.

Dauer laut zeiten.csv: 41 Minuten. Gesamt bislang 246 Minuten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:34:51 +02:00
Florian WildeandClaude Opus 5 7e11ef736c test: Wartepunkt nach dem Abhaken ergänzen
Der Test navigierte direkt nach dem Klick weiter, ohne das Ende der Server
Action abzuwarten. In M2 war die Seite schnell genug, mit der zusätzlichen
Belegungsabfrage aus M3 nicht mehr — ein Zeitproblem im Test, kein
Produktfehler. Er wartet jetzt auf die Bestätigung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:34:51 +02:00
Florian WildeandClaude Opus 5 80f86b4cff docs: Handbuchbilder für M3 neu aufnehmen
40 Bilder aus der Wegwerf-Instanz: zehn Seiten, je hell und dunkel,
Schreibtisch und Handy. Neu dabei die Kalenderkonten und die Belegung des
Tages auf der Startseite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:34:51 +02:00
Florian WildeandClaude Opus 5 a8b2ff0235 docs: Handbuchseite und Einrichtung der Kalenderanbieter
Die Handbuchseite erklärt, was der Planer liest und was ausdrücklich nicht,
warum alle Kalender gegen jeden Bereich zählen und was beim Trennen mit den
Terminen passiert.

Die README bekommt eine Schritt-für-Schritt-Anleitung für Entra ID und die
Google Cloud Console — mit den genauen Rückleit-URIs und Berechtigungen.
Dazu der Hinweis, dass ein Google-Projekt im Status "Test" die
Aktualisierungstoken nach sieben Tagen verfallen lässt; das kostet sonst
einen halben Abend Fehlersuche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:12:05 +02:00
Florian WildeandClaude Opus 5 df50627a20 test: Verschlüsselung und Kalendermodelle absichern
Sieben Tests für die Tokenverschlüsselung: Rückweg, Umlaute, verschiedene
Chiffren bei gleichem Klartext, und dass eine Veränderung am Chiffrat null
ergibt statt Unsinn.

Der Integrationstest deckt jetzt auch ab, dass dasselbe Konto mit
verschiedenen Kalendern erlaubt ist, ein Upsert den Termin aktualisiert
statt zu doppeln, und ein Bereich das Löschen seines Standard-Kalenderkontos
überlebt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:12:05 +02:00
Florian WildeandClaude Opus 5 d8d3227c82 feat(kalender): Belegung des Tages auf der Startseite
Jeder Bereich zeigt, wie voll der heutige Tag schon ist: Termine aus allen
verbundenen Kalendern gegen die Tageskapazität dieses Bereichs.

Alle Kalender zählen gegen jeden Bereich, weil ein Termin die Zeit ohnehin
blockiert, gleich zu welchem Lebensbereich er gehört. Wer um 14 Uhr beim
Zahnarzt sitzt, kann zur selben Zeit nichts für die Firma tun.

Ohne verbundenen Kalender bleibt der Balken weg. Er wäre sonst immer bei
null und sähe nach "alles frei" aus — eine Zahl, die lügt, ist schlechter
als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:12:04 +02:00
Florian WildeandClaude Opus 5 42c98c8b29 feat(kalender): Hintergrundjobs für Abgleich und Aufräumen
Alle 15 Minuten abgleichen, nachts alte Termine und abgelaufene
WebAuthn-Herausforderungen entfernen. Ohne das Aufräumen bliebe der
Terminspiegel für immer stehen.

Der Riegel über globalThis ist nötig, weil Next im Entwicklungsbetrieb
Module mehrfach lädt — sonst liefen nach ein paar Änderungen ein Dutzend
Zeitpläne parallel. Im Demo-Modus starten gar keine Jobs: die
Screenshot-Instanz soll sich nicht selbst verändern, während sie
abfotografiert wird.

Protokolliert werden nur Zahlen, nie Kontonamen oder Termininhalte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:05:07 +02:00
Florian WildeandClaude Opus 5 0c67b73a62 feat(kalender): Seite für die Kalenderkonten
Verbinden, umbenennen, von Hand abgleichen und trennen. Alle Aktionen
arbeiten nur auf Konten des angemeldeten Benutzers — die Kennung geht in
die where-Bedingung, nicht in einen nachträglichen Vergleich.

Ein Anbieter erscheint nur, wenn die Instanz ihn erlaubt *und* er
konfiguriert ist. Ist er erlaubt, aber nicht konfiguriert, sagt die Seite
das ausdrücklich, statt den Knopf wortlos wegzulassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:05:07 +02:00
Florian WildeandClaude Opus 5 9946e7c308 feat(kalender): OAuth und Abgleich für Graph und Google
Verbinden, Token erneuern und seitenweise abrufen — mit blankem fetch,
ohne SDK. Es geht um drei Aufrufe je Anbieter, und die Antworten werden
ohnehin mit zod geprüft; ein SDK brächte vor allem Abhängigkeiten und
blinde Flecken.

Entscheidungen:
- Der Zustandswert im OAuth-Ablauf ist mit AUTH_SECRET signiert und trägt
  die Benutzerkennung. Ohne das ließe sich ein fremdes Kalenderkonto an
  ein beliebiges Konto hängen.
- Die Berechtigungen schließen das Schreiben ein, obwohl M3 nur liest.
  Sonst müsste ab M4 jedes Konto neu zustimmen — und die Zustimmung ist
  genau der Schritt, an dem Einrichtung scheitert.
- Läuft der Abgleichverweis ab (beide Anbieter antworten mit 410), wird er
  verworfen und von vorn gelesen. Der Ablauf ist so gebaut, dass ein
  vollständiger Durchlauf jederzeit möglich ist.
- Googles syncToken trägt die Zeitgrenzen nicht mit; Folgeabgleiche
  liefern also auch Änderungen außerhalb unseres Fensters. Was daneben
  liegt, wird verworfen — sonst wüchse der Spiegel unbegrenzt.

Die Instanzregel greift serverseitig: Google gibt es nur daheim, in der
Firmeninstanz liefert die Route 404 statt eines Hinweises.

Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der
enthält das Client-Secret oft im Echo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:05:06 +02:00
Florian WildeandClaude Opus 5 6fda2a579e feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen
Reine Logik ohne fetch: die Antworten beider Anbieter werden mit zod
geprüft und in eine gemeinsame Form gebracht. Geprüft wird gegen
anonymisierte Beispielantworten in test/fixtures — 61 Tests.

Zwei Stellen, an denen bewusst abgebrochen statt geraten wird:
- Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir
  fragen mit Prefer: outlook.timezone="UTC" an; kommt etwas anderes
  zurück, wäre jede Umrechnung geraten und würde Termine um Stunden
  verschieben.
- Ein unbekanntes showAs wird UNBEKANNT und zählt damit als belegt. Lieber
  eine Stunde zu viel als eine Doppelbuchung.

Zur Belegung: Ganztägige Termine zählen nicht — ein Urlaubstag oder
Geburtstag im Kalender blockiert sonst den ganzen Tag, obwohl er keine
Arbeitszeit kostet. Ebenso wenig zählen ausdrücklich als frei markierte
Termine. Überlappende Termine werden zusammengelegt, damit eine
Doppelbuchung nicht doppelt zählt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:04:41 +02:00
Florian WildeandClaude Opus 5 79e6c0d556 feat(kalender): Datenmodell für Kalenderkonten und Termine
Kalenderkonto mit Tokens und Abgleichverweis, Termin als örtlicher Spiegel
mit genau so viel, wie die Belegungsrechnung und die Wochenansicht
brauchen. Bereich bekommt sein Standard-Kalenderkonto für M4.

Die Tokens liegen verschlüsselt (AES-256-GCM). Wer die Datenbank oder eine
Sicherung in die Hände bekommt, hätte sonst direkten Zugriff auf fremde
Kalender — das wiegt schwerer als bei den OIDC-Tokens aus M1, die nur die
Anmeldung betreffen. Der Schlüssel wird aus AUTH_SECRET abgeleitet statt
separat konfiguriert: eine weitere Pflichtvariable wäre eine weitere
Stelle, an der die Einrichtung scheitert.

kalenderId ist nicht optional, sondern trägt den Wert "standard" für den
Standardkalender — Prisma lässt in einem zusammengesetzten Unique-Schlüssel
kein null zu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:04:41 +02:00
Florian WildeandClaude Opus 5 a6bcaf8982 Merge: Meilenstein 2 – Aufgaben und Projekte (0.2.0)
Schnellerfassung, Inbox mit Einsortieren, Aufgabenliste mit Filtern in der
Adresszeile, Ideen als eigene Ansicht und Projekte mit Fortschritt.

Geprüft vor dem Merge: npm run pruefen mit 131 Unit-Tests, 30
Integrationsprüfungen, Migrationstest und 14 E2E-Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:47:43 +02:00
128 changed files with 9437 additions and 100 deletions
+38
View File
@@ -59,6 +59,44 @@ WEBAUTHN_RP_NAME=Planer
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
WEBAUTHN_ORIGIN=http://localhost:3000
# --- Kalenderanbieter ------------------------------------------------------
# Graph gibt es in beiden Instanzen, Google nur daheim. Ein Anbieter erscheint
# erst, wenn Kennung UND Geheimnis gesetzt sind.
# Einrichtung siehe README, Abschnitt "Kalenderanbieter einrichten".
# Rueckleit-URIs:
# <APP_URL>/api/kalender/rueckleitung/graph
# <APP_URL>/api/kalender/rueckleitung/google
GRAPH_CLIENT_ID=
GRAPH_CLIENT_SECRET=
# Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant.
GRAPH_TENANT_ID=organizations
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# --- Benachrichtigungen ----------------------------------------------------
# Beides ist freiwillig. Fehlt es, laeuft der Planer ohne Benachrichtigungen
# weiter und sagt es in den Einstellungen.
# Web-Push. Schluesselpaar einmalig erzeugen mit:
# npx web-push generate-vapid-keys
# Der oeffentliche Schluessel geht an den Browser, der private bleibt hier.
# Wird das Paar getauscht, muessen sich alle Geraete neu anmelden.
VAPID_OEFFENTLICH=
VAPID_PRIVAT=
# Kontakt fuer den Push-Dienst, als mailto: oder https:-Adresse.
VAPID_KONTAKT=
# E-Mail. Push braucht SMTP nicht und umgekehrt.
SMTP_HOST=
SMTP_PORT=587
SMTP_BENUTZER=
SMTP_PASSWORT=
# Absender, z. B. "Planer <planer@example.de>".
SMTP_ABSENDER=
# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS.
SMTP_TLS=false
# --- Sonstiges -------------------------------------------------------------
# Zeitzone fuer Anzeigen und Tagesgrenzen.
TZ=Europe/Berlin
+1
View File
@@ -8,3 +8,4 @@ test-results
playwright-report
coverage
devlog/zeiten.csv
public/sw.js
+94
View File
@@ -6,6 +6,100 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
## [0.5.0] – 2026-09-11
### Hinzugefügt
- **Benachrichtigungen** per Web-Push und E-Mail, je Benutzer einstellbar.
Vier Anlässe: Tagesüberblick am Morgen, fällige Aufgaben, Vorlauf vor einem
Zeitblock und ein Kalender, der sich nicht abgleichen lässt.
- **Ruhezeit** von 20:00 bis 07:00 ab Werk, über Mitternacht hinweg; wahlweise
das ganze Wochenende. Der Tagesüberblick kommt trotzdem zu seiner Zeit — er
käme sonst nie, wenn jemand früh aufsteht.
- Anmeldung eines Browsers für Push, Liste der angemeldeten Geräte und ein
Knopf, der eine Probemeldung über alle eingeschalteten Wege schickt.
- Ein Lauf alle zehn Minuten prüft, ob etwas zu melden ist; die nächtliche
Aufräumrunde entfernt Nachweise, die älter als 30 Tage sind.
- Handbuchseite zu Benachrichtigungen.
### Geändert
- Der Kalenderabgleich zählt Fehlversuche in Folge mit und meldet erst ab dem
dritten. Ein Netz hustet, ein Anbieter hat Wartung — das ist noch keine
Nachricht wert.
- Ab Werk meldet der Planer gar nichts: kein Weg ist eingeschaltet. Wer beim
ersten Start mit Meldungen überschüttet wird, schaltet alles ab — und dann
auch das Nützliche.
### Sicherheit
- Die Push-Schlüssel eines Geräts liegen verschlüsselt in der Datenbank.
Weder Endpunkt noch Schlüssel noch Mailinhalte landen im Protokoll.
- Ein Gerät, das der Push-Dienst mit 404 oder 410 ablehnt, wird sofort
entfernt; andere Fehler führen nach fünf Versuchen zum selben Ergebnis.
- Der Nachweis einer Meldung wird **vor** dem Senden geschrieben: lieber eine
verpasste Nachricht als eine, die im Minutentakt erneut versucht wird.
## [0.4.0] – 2026-09-11
### Hinzugefügt
- **Wochenansicht** mit Ziehen und Ablegen: Aufgaben aus der linken Spalte in
ein Zeitfenster ziehen legt einen Zeitblock an und schreibt ihn in den
Kalender. Die Dauer kommt aus dem geschätzten Aufwand der Aufgabe.
- Zeitblöcke verschieben und ausplanen; der Kalendertermin zieht mit.
- **Rückweg**: Wird ein Zeitblock im Kalender verschoben, übernimmt der Planer
das beim nächsten Abgleich. Wird er dort gelöscht, verschwindet auch der
Zeitblock — die Aufgabe bleibt und fällt zurück auf offen.
- Kapazität und Belegung je Wochentag über jeder Spalte; überbuchte Tage
werden rot, sperren aber nichts.
- Eine eingeplante Aufgabe wechselt selbsttätig von _Offen_ auf _Geplant_ und
wieder zurück, wenn nichts mehr für sie geplant ist.
- Handbuchseite zur Wochenplanung.
### Sicherheit
- **Schleifenschutz** über `hash = sha256(startISO|endeISO)`: Der Planer
schreibt nur bei eigener Änderung und übernimmt aus dem Kalender nur, wenn
der Fingerabdruck abweicht. Ohne das würden App und Kalender einander
endlos hinterherschreiben.
- Jeder Schreibzugriff auf einen bestehenden Termin geht mit `If-Match`
heraus. Bei HTTP 412 **gewinnt der Kalender**: der Planer schreibt nicht,
verwirft seinen Fingerabdruck und überlässt dem nächsten Abgleich, den
fremden Stand zu holen.
- Zeitblöcke gehören dem Benutzer über sein Kalenderkonto; die Kennung geht
in jede where-Bedingung, nicht in einen nachträglichen Vergleich.
## [0.3.0] – 2026-09-10
### Hinzugefügt
- Kalenderkonten für Microsoft 365 (Graph) und Google verbinden, umbenennen,
von Hand abgleichen und trennen. Google gibt es nur in der Instanz
`daheim`; in `firma` liefert die Route 404.
- Termine werden örtlich gespiegelt: 7 Tage zurück bis 90 Tage voraus.
Gelesen werden nur Beginn, Ende, Betreff, ganztägig und "zeigt an als" —
keine Teilnehmer, Beschreibungen, Anhänge oder Orte.
- Die Startseite zeigt je Bereich, wie voll der heutige Tag ist: Termine aus
allen verbundenen Kalendern gegen die Tageskapazität.
- Hintergrundjobs: alle 15 Minuten abgleichen, nachts alte Termine und
abgelaufene WebAuthn-Herausforderungen entfernen.
- Handbuchseite zu den Kalenderkonten, Einrichtungsanleitung für Entra ID
und die Google Cloud Console in der README.
### Sicherheit
- Zugangs- und Aktualisierungstoken liegen verschlüsselt in der Datenbank
(AES-256-GCM, Schlüssel aus AUTH_SECRET abgeleitet). Wer eine Sicherung in
die Hände bekommt, kann damit ohne AUTH_SECRET nichts anfangen.
- Der Zustandswert im OAuth-Ablauf ist signiert und trägt die
Benutzerkennung; ohne das ließe sich ein fremdes Kalenderkonto an ein
beliebiges Konto hängen. Er verfällt nach 30 Minuten.
- Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der
enthält das Client-Secret oft im Echo.
- Protokolliert werden beim Abgleich nur Zahlen, nie Kontonamen oder
Termininhalte.
## [0.2.0] – 2026-09-10
### Hinzugefügt
+125 -18
View File
@@ -7,10 +7,11 @@ lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
wird.
> **Stand:** Meilenstein 2 von 8. Erfassen und Wiederfinden steht: Aufgaben,
> Inbox, Ideen und Projekte, dazu Anmeldung, Rollen, Protokoll und der Betrieb.
> Das Lesen der Kalender kommt mit M3, die Wochenplanung per Ziehen und Ablegen
> mit M4. Der [Fahrplan](ROADMAP.md) sagt, was wann folgt.
> **Stand:** Meilenstein 4 von 8. Die Kernidee steht: Aufgaben lassen sich
> per Ziehen und Ablegen als Zeitblöcke in den Kalender schreiben, und
> Verschiebungen von dort kommen zurück. Es folgen Benachrichtigungen,
> Integrationen, Schnellerfassung vom Handy und ein MCP-Server. Der
> [Fahrplan](ROADMAP.md) sagt, was wann folgt.
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
zur ersten Anmeldung
@@ -28,6 +29,12 @@ wird.
nach Dringlichkeit und filterbar über die Adresszeile
- **Ideen** getrennt von Aufgaben — ohne Termin, per Klick übernehmbar
- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt
- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je
Bereich, wie voll der heutige Tag schon ist
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
Kalenderterminen, Verschiebungen im Kalender kommen zurück
- **Benachrichtigungen** per Web-Push und E-Mail: Tagesüberblick, fällige
Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk still
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
Passwort — je Instanz einzeln zuschaltbar
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
@@ -38,23 +45,25 @@ wird.
### Geplant
Kalender lesen und schreiben, Wochenplanung per Ziehen und Ablegen,
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
Integrationen, Schnellerfassung vom Handy und ein MCP-Server.
Siehe [ROADMAP.md](ROADMAP.md).
## Bildergalerie
| | Hell | Dunkel |
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) |
| Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) |
| Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) |
| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) |
| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) |
| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) |
| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) |
| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) |
| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) |
| | Hell | Dunkel |
| ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) |
| Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) |
| Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) |
| Woche | ![Woche, hell](docs/bilder/woche-hell-schreibtisch.png) | ![Woche, dunkel](docs/bilder/woche-dunkel-schreibtisch.png) |
| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) |
| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) |
| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) |
| Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-dunkel-schreibtisch.png) |
| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) |
| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) |
| Meldungen | ![Benachrichtigungen, hell](docs/bilder/benachrichtigungen-hell-schreibtisch.png) | ![Benachrichtigungen, dunkel](docs/bilder/benachrichtigungen-dunkel-schreibtisch.png) |
| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) |
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
@@ -117,6 +126,51 @@ Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example).
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
### Kalenderanbieter
| Variable | Standard | Bedeutung |
| ---------------------- | --------------- | ------------------------------------------------------------- |
| `GRAPH_CLIENT_ID` | _(leer)_ | Anwendungs-ID der App-Registrierung in Entra ID |
| `GRAPH_CLIENT_SECRET` | _(leer)_ | Geheimnis dieser Registrierung |
| `GRAPH_TENANT_ID` | `organizations` | Tenant-ID, oder `organizations` für jeden Arbeitsplatz-Tenant |
| `GOOGLE_CLIENT_ID` | _(leer)_ | OAuth-Client-ID aus der Google Cloud Console |
| `GOOGLE_CLIENT_SECRET` | _(leer)_ | Geheimnis dieses Clients |
Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`).
### Benachrichtigungen
Beides ist freiwillig. Fehlt ein Satz, bleibt der Weg in den Einstellungen
ausgegraut, und der Planer läuft ohne ihn weiter.
| Variable | Standard | Bedeutung |
| ------------------- | -------- | --------------------------------------------------------- |
| `VAPID_OEFFENTLICH` | _(leer)_ | Öffentlicher Push-Schlüssel, geht an den Browser |
| `VAPID_PRIVAT` | _(leer)_ | Privater Push-Schlüssel, bleibt auf dem Server |
| `VAPID_KONTAKT` | _(leer)_ | Kontakt für den Push-Dienst, `mailto:` oder `https:` |
| `SMTP_HOST` | _(leer)_ | Mailserver. Ohne ihn kein Mailversand |
| `SMTP_PORT` | `587` | Port des Mailservers |
| `SMTP_BENUTZER` | _(leer)_ | Anmeldename. Leer lassen für einen Server ohne Anmeldung |
| `SMTP_PASSWORT` | _(leer)_ | Passwort dazu |
| `SMTP_ABSENDER` | _(leer)_ | Absenderadresse, z. B. `Planer <planer@example.de>` |
| `SMTP_TLS` | `false` | `true` bei Port 465 (SMTPS), `false` bei 587 mit STARTTLS |
Push braucht **alle drei** VAPID-Werte, Mail mindestens `SMTP_HOST` und
`SMTP_ABSENDER`. Die Schlüssel entstehen einmalig mit:
```bash
npx web-push generate-vapid-keys
```
Wird das Schlüsselpaar später getauscht, müssen sich alle Geräte neu
anmelden — die alten Abos sind dann wertlos. Push setzt außerdem HTTPS
voraus; `http://localhost` gilt dem Browser als sicher, jede andere
Klartextadresse nicht.
Was der Planer wann meldet, steht im
[Handbuch](docs/handbuch/benachrichtigungen.md).
### Einrichtung und Sonstiges
| Variable | Standard | Bedeutung |
@@ -126,6 +180,59 @@ Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
## Kalenderanbieter einrichten
Beides ist einmalige Handarbeit beim Anbieter. Der Planer fragt Lese- **und**
Schreibrechte an: ab M4 schreibt er Zeitblöcke zurück, und eine zweite
Zustimmungsrunde für jedes Konto wäre lästiger als eine etwas breitere
Berechtigung von Anfang an.
### Microsoft 365 (Entra ID)
1. <https://entra.microsoft.com> → **Identität → Anwendungen →
App-Registrierungen → Neue Registrierung**.
2. Name frei wählen, etwa „Planer".
3. Unterstützte Kontotypen: **Nur Konten in diesem Organisationsverzeichnis**
(dann `GRAPH_TENANT_ID` auf die Tenant-ID setzen) oder
**Konten in einem beliebigen Organisationsverzeichnis** (dann
`organizations` belassen).
4. Umleitungs-URI, Plattform **Web**:
`<APP_URL>/api/kalender/rueckleitung/graph`
5. Nach dem Anlegen: **Anwendungs-ID (Client)** nach `GRAPH_CLIENT_ID`,
**Verzeichnis-ID (Mandant)** nach `GRAPH_TENANT_ID`.
6. **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Den Wert
sofort nach `GRAPH_CLIENT_SECRET` kopieren — er wird nur einmal angezeigt.
7. **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph →
Delegierte Berechtigungen**: `Calendars.ReadWrite`, `offline_access`,
`openid`, `email`.
> Ist im Tenant die Zustimmung durch Endbenutzer abgeschaltet, muss ein
> Administrator einmalig **Administratorzustimmung erteilen** klicken.
### Google Kalender
1. <https://console.cloud.google.com> → Projekt anlegen oder wählen.
2. **APIs & Dienste → Bibliothek** → _Google Calendar API_ aktivieren.
3. **OAuth-Zustimmungsbildschirm** einrichten. Für den Eigengebrauch genügt
_Extern_ mit Veröffentlichungsstatus _Test_ und der eigenen Adresse als
Testbenutzer.
4. Bereiche (Scopes) hinzufügen:
`.../auth/calendar.events` und `.../auth/calendar.readonly`.
5. **Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID**,
Anwendungstyp **Webanwendung**.
6. Autorisierter Weiterleitungs-URI:
`<APP_URL>/api/kalender/rueckleitung/google`
7. Client-ID und Clientschlüssel nach `GOOGLE_CLIENT_ID` und
`GOOGLE_CLIENT_SECRET`.
> Bleibt das Projekt im Status _Test_, laufen die Aktualisierungstoken nach
> sieben Tagen ab und die Konten müssen neu verbunden werden. Für den
> Dauerbetrieb den Zustimmungsbildschirm **veröffentlichen**.
Nach dem Eintragen: `docker compose up -d` und im Planer unter **Kalender**
verbinden. Die Anleitung für den Alltag steht im
[Handbuch](docs/handbuch/kalenderkonten.md).
## Betrieb
### Starten und stoppen
+38 -19
View File
@@ -7,9 +7,9 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| --------------------------------------- | ---------------------------- | -------- |
| [M1](#m1--fundament) | Fundament | erledigt |
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
| [M3](#m3--kalender-lesen) | Kalender lesen | geplant |
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant |
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
| [M6](#m6--integrationen) | Integrationen | geplant |
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
| [M8](#m8--mcp) | MCP | geplant |
@@ -63,33 +63,52 @@ später" und wartet darauf, ob es im Alltag wirklich fehlt.
## M3 – Kalender lesen
**Erledigt am 10.09.2026 mit Version 0.3.0.**
**Ziel:** Termine aus Microsoft 365 und Google sehen.
- [ ] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
- [ ] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
- [ ] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
- [ ] Ganztägige Termine zählen nicht zur Belegung
- [ ] Anonymisierte Beispielantworten in `test/fixtures/`
- [ ] Hintergrundjob mit node-cron
- [x] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
- [x] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
- [x] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
- [x] Ganztägige Termine zählen nicht zur Belegung
- [x] Anonymisierte Beispielantworten in `test/fixtures/`
- [x] Hintergrundjob mit node-cron
## M4 – Wochenplanung und Zeitblöcke
**Erledigt am 11.09.2026 mit Version 0.4.0.**
**Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und
Verschiebungen von dort zurückholen.
- [ ] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
- [ ] Wochenansicht mit `@dnd-kit/core`
- [ ] Schreiben in den Kalender, Rückweg beim Verschieben
- [ ] Schleifenschutz über `hash = sha256(startISO|endeISO)`
- [ ] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
- [ ] Belegung gegen die Tageskapazität des Bereichs
- [x] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
- [x] Wochenansicht mit `@dnd-kit/core`
- [x] Schreiben in den Kalender, Rückweg beim Verschieben
- [x] Schleifenschutz über `hash = sha256(startISO|endeISO)`
- [x] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
- [x] Belegung gegen die Tageskapazität des Bereichs
**Bewusst offen gelassen:** Die Dauer lässt sich nicht am Balken ziehen, und
ein Zeitblock landet immer im ersten verbundenen Kalender. Beides steht unter
"Ideen für später" — erst zeigt der Alltag, ob es wirklich fehlt.
## M5 – Benachrichtigungen
- [ ] Web-Push mit `web-push`
- [ ] Tagesüberblick per E-Mail über nodemailer
- [ ] Erinnerungen an Fälligkeiten, Jobs über node-cron
- [ ] Je Benutzer einstellbar, mit Ruhezeiten
**Erledigt am 11.09.2026 mit Version 0.5.0.**
**Ziel:** Sagen, was ansteht — und dabei so selten stören, dass niemand auf
die Idee kommt, alles abzuschalten.
- [x] Web-Push mit `web-push`, Anmeldung je Browser
- [x] Tagesüberblick per E-Mail über nodemailer
- [x] Erinnerungen an Fälligkeiten und vor Zeitblöcken, Jobs über node-cron
- [x] Je Benutzer einstellbar, mit Ruhezeiten über Mitternacht
- [x] Wiederholungssperre je Anlass, Sache und Tag
- [x] Abgleichfehler erst nach mehreren Versuchen hintereinander
**Bewusst offen gelassen:** Keine Sammelmeldung mehrerer Anlässe in einer
Nachricht und kein Nachholen dessen, was in die Ruhezeit fiel. Eine
Erinnerung an einen Zeitblock, der längst begonnen hat, hilft niemandem.
## M6 – Integrationen
+267 -5
View File
@@ -5,11 +5,273 @@ die Übersicht aus `scripts/zeit.sh summe`.
## Zeit je Meilenstein
| Meilenstein | Art | Minuten | Stunden |
| ----------- | ------ | ------- | -------- |
| M1 | claude | 161 | 2,68 |
| M2 | claude | 44 | 0,73 |
| **Gesamt** | | **205** | **3,42** |
| Meilenstein | Art | Minuten | Stunden |
| ----------- | ------ | ------- | --------- |
| M1 | claude | 161 | 2,68 |
| M2 | claude | 44 | 0,73 |
| M3 | claude | 41 | 0,68 |
| M4 | claude | 167 | 2,78 |
| M5 | claude | 420 | 7,00 |
| **Gesamt** | | **833** | **13,88** |
---
## 2026-09-11 – M5: Benachrichtigungen
**Dauer:** 420 Minuten (06:35–13:35), Art `claude`.
**Ziel der Sitzung:** Sagen, was ansteht — und dabei so selten stören, dass
niemand auf die Idee kommt, alles abzuschalten.
### Erledigt
- Regeln als reine Logik: Anlässe, Ruhezeit, Wiederholungssperre (30 Tests)
- Web-Push über VAPID samt Service Worker, An- und Abmeldung je Browser
- Mailversand über nodemailer, als Text und als HTML
- Vier Anlässe: Tagesüberblick, Fälligkeit, Vorlauf vor einem Zeitblock,
Abgleichfehler
- Bedienung in den Einstellungen mit Probemeldung und Geräteliste
- Ein Lauf alle zehn Minuten, Nachweise räumt die Nachtrunde weg
- 2 neue E2E-Tests, 7 neue Integrationsprüfungen, Handbuchseite
### Entscheidungen mit Begründung
**Ab Werk meldet der Planer gar nichts.** Kein Weg ist eingeschaltet. Wer
beim ersten Start mit Meldungen überschüttet wird, schaltet alles ab — und
dann auch das Nützliche. Die Einrichtung ist ein bewusster Handgriff, und
das ist der Preis dafür, dass die Meldungen später ernst genommen werden.
**Der Tagesüberblick ist von der Ruhezeit ausgenommen.** Sonst bekäme ihn
nie, wer ihn auf 06:00 stellt — die voreingestellte Ruhezeit läuft bis
07:00. Die Ruhezeit schützt vor Störungen, nicht vor der Nachricht, um die
ausdrücklich gebeten wurde.
**Der Nachweis wird vor dem Senden geschrieben, nicht danach.** Bricht der
Versand mittendrin ab, gilt die Meldung als zugestellt und kommt nicht
wieder. Andersherum würde ein hängender Mailserver dieselbe Nachricht alle
zehn Minuten erneut versuchen. Lieber eine verpasste Nachricht als eine,
die im Minutentakt klopft.
**Was in die Ruhezeit fällt, wird nicht nachgeholt.** Eine Erinnerung an
einen Zeitblock, der längst begonnen hat, ist nur der Beweis, dass die
Anwendung zu spät kommt.
**Abgleichfehler erst ab dem dritten Versuch in Folge.** Ein Netz hustet,
ein Anbieter hat Wartung. Beim ersten Fehlschlag zu melden hieße, für
Dinge zu wecken, die sich von selbst erledigen. Nach dem dritten Mal ist es
dagegen dringend — solange steht die Wochenansicht auf altem Stand.
**Der Tagesüberblick entfällt, wenn es nichts zu sagen gibt.** „Heute steht
nichts an" ist keine Information, sondern Lärm.
**Tote Abos putzen sich selbst weg.** 404 und 410 heißen endgültig, das
Gerät verschwindet sofort; andere Fehler zählen hoch und führen nach fünf
Versuchen zum selben Ergebnis. Eine Liste, die von Hand gepflegt werden
muss, wird nicht gepflegt.
**Push-Schlüssel liegen verschlüsselt, Endpunkte nie im Protokoll.** Ein
Endpunkt genügt, um jemandem Nachrichten zu schicken — er gehört damit in
dieselbe Klasse wie ein Token.
### Probleme und Lösungen
| Problem | Lösung |
| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pushManager.subscribe` lehnte den VAPID-Schlüssel als `Uint8Array<ArrayBufferLike>` ab | Der Puffer wird jetzt ausdrücklich als `ArrayBuffer` angelegt; ein geteilter Puffer ist dort nicht erlaubt. |
| Der Integrationstest brach mit einem Fremdschlüsselfehler ab | Die neuen Prüfungen standen hinter dem Löschen des Testbenutzers. Jetzt davor — und danach wird geprüft, dass Geräte und Nachweise mit ihm verschwinden. |
| E2E-Läufe scheiterten unter Last immer wieder beim Anmelden | Ein Klick, der vor der Hydratation ankommt, verpufft folgenlos. Der Anmeldehelfer liegt jetzt einmal in `e2e/hilfe.ts` und fasst nach, bis die Startseite da ist. |
| Die Karte meldete gleichzeitig „Browser blockiert" und „Server nicht eingerichtet" | Zwei Aussagen, von denen nur eine zählt. Der Serverzustand geht jetzt vor. Gefunden nur durch das Ansehen des aufgenommenen Bildes. |
| Das erfundene Push-Gerät in den Demo-Daten wäre beim ersten Versandlauf gelöscht worden | Seine Schlüssel lagen als Klartext-Platzhalter, und unlesbare Schlüssel führen zum Entfernen. Die Demo-Daten enthalten jetzt kein Gerät mehr — die Bilder zeigen den Zustand davor. |
| Im Bild meldet der Browser „blockiert", obwohl die Erlaubnis erteilt wurde | Chromium im Kopflosbetrieb antwortet auf `Notification.permission` immer mit `denied`; `grantPermissions` ändert daran nichts. Das Handbuch schreibt es jetzt unter das Bild. |
| Ein Docker-Bau brauchte 14 Minuten, ein E2E-Lauf 18 | Systemlast, kein Codeproblem: dieselben Läufe danach in 1,5 beziehungsweise 0,4 Minuten. Ein vergessener Testserver auf Port 3100 hat kräftig mitgeholfen. |
### Offene Punkte
- Der Versandweg ist gegen die Logik geprüft, nicht gegen echte Dienste:
ohne VAPID-Schlüssel und SMTP-Zugang geht lokal nichts hinaus. Das braucht
einen Lauf auf dem Server.
- Mehrere Anlässe erscheinen als mehrere Nachrichten. Ob eine Sammelmeldung
fehlt, zeigt erst der Alltag.
### Commits
- 24f613d feat: Datenmodell und Umgebung für Benachrichtigungen
- ddb13d7 feat: Regeln für Benachrichtigungen als reine Logik
- e0e11c8 feat: Versand per Web-Push und E-Mail
- b622c1b feat: Benachrichtigungen in den Einstellungen bedienen
- 047a4a1 feat: Meldungen alle zehn Minuten prüfen und verschicken
- 5bbbdd5 chore: Demo-Daten und Bildaufnahme für Benachrichtigungen
- f03ea73 docs: Handbuchseite und README-Abschnitt zu Benachrichtigungen
- 6e1f279 test: Benachrichtigungen end-to-end und in der Datenbank absichern
- 7bef435 fix: Serverzustand geht der Browsermeldung vor
- 35ecb2f chore: Screenshot-Instanz mit eingerichteten Meldewegen
- bb7b189 docs: Handbuchbilder für M5 neu aufnehmen
---
## 2026-09-10 – M4: Wochenplanung und Zeitblöcke
**Dauer:** 167 Minuten (20:42–23:29), Art `claude`.
**Ziel der Sitzung:** Die Kernidee des Projekts — Aufgaben per Ziehen und
Ablegen in echte Kalendertermine verwandeln, mit Rückweg.
### Erledigt
- `Zeitblock` samt Migration und Schleifenschutz (30 Tests)
- Schreibweg zu Graph und Google mit `If-Match`, Rückweg im Abgleich
- Rasterlogik für die Wochenansicht (21 Tests)
- Wochenansicht mit `@dnd-kit/core`: einplanen, verschieben, ausplanen
- Kapazität und Belegung je Wochentag
- 3 neue E2E-Tests, Handbuchseite
### Entscheidungen mit Begründung
**Der Fingerabdruck geht nur über Beginn und Ende.** Titel und Beschreibung
ändern sich auch aus harmlosen Gründen — etwa weil der Aufgabentitel
bearbeitet wurde. Für die Planung zählt allein, wann der Block liegt; alles
andere in den Fingerabdruck zu nehmen hieße, den Anbieter grundlos zu
behelligen.
**Der Kalender gewinnt, ausnahmslos.** Wer im Kalender etwas verschiebt,
meint es so. Säße der Planer am längeren Hebel, zöge er dem Benutzer den
Termin wieder weg — und der wüsste nicht, warum. Bei HTTP 412 verwirft der
Planer sogar seinen eigenen Fingerabdruck, damit der nächste Abgleich die
fremde Änderung sicher als solche erkennt.
**Scheitert das Schreiben, überlebt die Planung.** Der Zeitblock bleibt mit
vermerktem Fehler stehen. Ihn zu verwerfen hieße, die Planung des Benutzers
wegen eines Netzproblems zu vergessen; das nächste Verschieben trägt ihn
nach. Beim Löschen ist es umgekehrt: Da verschwindet der Zeitblock auch bei
unerreichbarem Kalender, sonst wäre die Planung blockiert.
**Die Dauer kommt aus dem geschätzten Aufwand.** Wer schätzt, bekommt die
passende Blockgröße geschenkt. Das belohnt genau die Gewohnheit, die die
Planung überhaupt erst tragfähig macht.
**Ziehen beginnt erst nach acht Pixeln.** Ohne diese Schwelle löst jeder
Klick einen Zug aus und die Knöpfe im Block wären nicht mehr bedienbar.
**Überbuchte Tage werden rot, aber nicht gesperrt.** Der Planer ist ein
Werkzeug, kein Aufpasser. Manchmal muss ein Tag überbucht sein.
### Probleme und Lösungen
| Problem | Lösung |
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Ein Zeitblock steht auch im Terminspiegel und erschien doppelt — einmal als eigene Planung, einmal als fremder Termin | Die Wochenabfrage filtert Termine heraus, deren `extId` zu einem Zeitblock gehört. |
| Ein Build lief 32 Minuten statt Sekunden | Systemlast durch einen gleichzeitigen Docker-Bau. Ein sauberer Neubau danach: 46 Sekunden. Kein Codeproblem. |
| Sich überschneidende Termine lagen unsichtbar übereinander | `spurenVerteilen` legt sie nebeneinander und gibt eine Spur wieder frei, sobald sie zu Ende ist. |
### Offene Punkte
- **Der Schreibweg ist nicht gegen die echten APIs geprüft.** Wie in M3
fehlen die Zugangsdaten. Die Logik ist getestet, der erste echte Schreib-
und Rückweg ist Florians.
- Die Dauer lässt sich nicht am Balken ziehen; wer eine andere will, ändert
den Aufwand der Aufgabe.
- Ein Zeitblock landet immer im ersten verbundenen Kalender. Das
Standard-Kalenderkonto je Bereich ist im Datenmodell vorbereitet, aber
noch nicht in der Oberfläche.
### Commits
- 5791047 feat(zeitbloecke): Datenmodell und Schleifenschutz
- 9c1c916 feat(zeitbloecke): Schreibweg in den Kalender und Rückweg zurück
- bcb4b8e feat(woche): Rasterlogik für die Wochenansicht
- fada445 feat(woche): Wochenansicht mit Ziehen und Ablegen
- 5b1e998 test: Wochenansicht und Ausplanen absichern
- 0974695 docs: Handbuchseite zur Wochenplanung
---
## 2026-09-10 – M3: Kalender lesen
**Dauer:** 41 Minuten (17:48–18:29), Art `claude`.
**Ziel der Sitzung:** Termine aus Microsoft 365 und Google lesen, damit der
Planer weiß, wann überhaupt Platz ist.
### Erledigt
- `Kalenderkonto` und `Termin` samt Migration; Bereich bekommt sein
Standard-Kalenderkonto für M4
- Tokenverschlüsselung mit AES-256-GCM
- Antworten beider Anbieter mit zod geprüft und in eine gemeinsame Form
gebracht, geprüft gegen anonymisierte Beispielantworten
- Belegungsrechnung: Kapazität gegen Termine, freie Fenster für M4
- OAuth-Verbinden, Tokenerneuerung, seitenweiser Abgleich mit Rückfall auf
einen vollständigen Durchlauf
- Seite für die Kalenderkonten, Belegung des Tages auf der Startseite
- Hintergrundjobs für Abgleich und nächtliches Aufräumen
- 68 neue Unit-Tests, 10 neue Integrationsprüfungen
- Handbuchseite und Einrichtungsanleitung für Entra ID und Google
### Entscheidungen mit Begründung
**Tokens verschlüsselt, Schlüssel aus AUTH_SECRET abgeleitet.** Wer die
Datenbank oder eine Sicherung bekommt, hätte sonst direkten Zugriff auf
fremde Kalender — das wiegt schwerer als bei den OIDC-Tokens aus M1, die
nur die Anmeldung betreffen. Eine eigene Pflichtvariable wäre eine weitere
Stelle, an der die Einrichtung scheitert; die Ableitung über HKDF kostet
nichts und hat die richtige Nebenwirkung: wer AUTH_SECRET wechselt, muss
die Konten neu verbinden — dann sind ohnehin alle Sitzungen ungültig.
**Bei fremder Zeitzone abbrechen statt raten.** Graph liefert Datum und
Zeitzone getrennt, ohne Versatz im Text. Wir fragen ausdrücklich UTC an;
kommt etwas anderes zurück, wäre jede Umrechnung geraten und würde Termine
um Stunden verschieben. Ein Abbruch mit klarer Meldung ist besser als ein
Kalender, der leise falsch liegt.
**Unbekanntes showAs zählt als belegt.** Lieber eine Stunde zu viel
blockiert als eine Doppelbuchung.
**Schreibrechte schon jetzt anfragen.** M3 liest nur, aber M4 schreibt.
Später erneut zuzustimmen ist genau der Schritt, an dem Einrichtung
scheitert — einmal breiter fragen ist das kleinere Übel.
**Ganztägige Termine zählen nicht zur Belegung.** Ein Urlaubstag oder
Geburtstag blockiert sonst den ganzen Tag, obwohl er keine Arbeitszeit
kostet.
**Alle Kalender zählen gegen jeden Bereich.** Ein Termin blockiert die Zeit
ohnehin, gleich zu welchem Lebensbereich er gehört.
**Kein SDK für Graph und Google.** Es geht um drei Aufrufe je Anbieter, und
die Antworten werden ohnehin mit zod geprüft. Ein SDK brächte vor allem
Abhängigkeiten und blinde Flecken.
### Probleme und Lösungen
| Problem | Lösung |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Prisma lässt in einem zusammengesetzten Unique-Schlüssel kein null zu | `kalenderId` ist nicht optional, sondern trägt den sprechenden Wert `"standard"` für den Standardkalender. |
| Googles `syncToken` verträgt sich nicht mit `timeMin`/`timeMax` | Folgeabgleiche liefern auch Änderungen außerhalb des Fensters. Was daneben liegt, wird beim Spiegeln verworfen — sonst wüchse der Spiegel unbegrenzt. |
| Der Verweis für die nächste Runde kann ablaufen (HTTP 410) | Verweis verwerfen, Spiegel leeren, von vorn lesen. Der Ablauf ist so gebaut, dass ein vollständiger Durchlauf jederzeit möglich ist. |
| Die Shell zerlegte in mehreren Anläufen Template-Literale und Backticks | Auf `node - <<'SKRIPT'` umgestellt; damit geht der Text unverändert durch. |
### Offene Punkte
- **Nicht gegen die echten APIs getestet.** Ich habe keine Zugangsdaten für
Entra ID oder Google. Provider und Abgleich sind gegen anonymisierte
Beispielantworten geprüft; den ersten echten Verbindungsversuch muss
Florian machen. Die Anleitung dafür steht in der README.
- Nur der Standardkalender je Konto. Mehrere Kalender desselben Kontos sind
im Datenmodell vorgesehen, es fehlt die Auswahl in der Oberfläche.
- Der Abgleich läuft in einer Schleife über alle Konten. Bei vielen Konten
wäre das nacheinander zu langsam; bei der erwarteten Zahl ist es das
einfachere Verhalten.
### Commits
- 79e6c0d feat(kalender): Datenmodell für Kalenderkonten und Termine
- 6fda2a5 feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen
- 9946e7c feat(kalender): OAuth und Abgleich für Graph und Google
- 0c67b73 feat(kalender): Seite für die Kalenderkonten
- 42c98c8 feat(kalender): Hintergrundjobs für Abgleich und Aufräumen
- d8d3227 feat(kalender): Belegung des Tages auf der Startseite
- df50627 test: Verschlüsselung und Kalendermodelle absichern
- a8b2ff0 docs: Handbuchseite und Einrichtung der Kalenderanbieter
---
+3
View File
@@ -1,3 +1,6 @@
datum;start;ende;minuten;meilenstein;art;notiz
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig
2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
1 datum start ende minuten meilenstein art notiz
2 2026-09-10 12:06 14:47 161 M1 claude M1 Fundament fertiggestellt
3 2026-09-10 15:57 16:41 44 M2 claude M2 Aufgaben und Projekte fertig
4 2026-09-10 17:48 18:29 41 M3 claude M3 Kalender lesen fertig
5 2026-09-10 20:42 23:29 167 M4 claude M4 Wochenplanung fertig
6 2026-09-11 06:35 13:35 420 M5 claude M5 Benachrichtigungen fertig
+11
View File
@@ -57,5 +57,16 @@ services:
WEBAUTHN_RP_NAME: Planer
WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
DEMO_MODUS: 'true'
# Damit die Karte "Benachrichtigungen" den Normalfall zeigt und nicht
# den Hinweis, dass nichts eingerichtet ist. Das Schluesselpaar erzeugt
# scripts/screenshots.ts fuer jeden Lauf neu – es steht bewusst nirgends
# im Verzeichnis.
VAPID_OEFFENTLICH: ${SCREENSHOT_VAPID_OEFFENTLICH:-}
VAPID_PRIVAT: ${SCREENSHOT_VAPID_PRIVAT:-}
VAPID_KONTAKT: mailto:planer@planer.invalid
# Ein Host, den es nie geben wird: die Oberflaeche zeigt E-Mail als
# moeglich, hinaus geht trotzdem nichts.
SMTP_HOST: smtp.planer.invalid
SMTP_ABSENDER: Planer <planer@planer.invalid>
ports:
- '${SCREENSHOT_APP_PORT:-3200}:3000'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+7 -4
View File
@@ -16,10 +16,16 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
Fortschritt
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
verbinden, Belegung verstehen
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
Kalender legen
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push und E-Mail
einrichten, Ruhezeiten setzen
## Verwaltung
6. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
9. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
## Was noch kommt
@@ -27,9 +33,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
| Thema | Meilenstein |
| ------------------------------- | ----------- |
| Kalenderkonten verbinden | M3 |
| Wochenplanung und Zeitblöcke | M4 |
| Benachrichtigungen | M5 |
| Integrationen | M6 |
| Schnellerfassung vom Handy, API | M7 |
| MCP | M8 |
+108
View File
@@ -0,0 +1,108 @@
# Benachrichtigungen
Ab Werk meldet der Planer **gar nichts**. Das ist Absicht: Eine Anwendung,
die zu oft meldet, wird stummgeschaltet — und dann verpasst du auch das
Wichtige. Was du hier einschaltest, kommt. Alles andere nicht.
![Benachrichtigungen in den Einstellungen](../bilder/benachrichtigungen-hell-schreibtisch.png)
_Der Browser auf dem Bild hat Benachrichtigungen blockiert — so sieht die
Karte aus, wenn die Erlaubnis fehlt. Wie du sie erteilst, steht weiter unten._
Du findest die Einstellung unter **Einstellungen → Benachrichtigungen**.
## Die zwei Wege
| | Web-Push | E-Mail |
| --------- | ---------------------------------------- | ---------------------------------------- |
| Kommt an | als Systemmeldung auf dem Gerät | im Postfach |
| Braucht | Erlaubnis des Browsers, je Gerät einzeln | nichts weiter |
| Gut für | „gleich beginnt ein Zeitblock" | den Tagesüberblick am Morgen |
| Fällt aus | wenn der Browser die Erlaubnis entzieht | wenn der Mailserver nicht erreichbar ist |
Beide Wege lassen sich gleichzeitig einschalten. Dann kommt jede Meldung
über beide — der Planer verschickt sie trotzdem nur **einmal je Anlass**.
Ist ein Weg auf dem Server nicht eingerichtet, steht das direkt daneben.
Was dafür nötig ist, steht im [README](../../README.md), Abschnitt
_Benachrichtigungen_.
## Push auf einem Gerät einschalten
1. Auf **Anmelden** klicken.
2. Der Browser fragt nach Erlaubnis — **Zulassen**.
3. Das Gerät erscheint in der Liste darunter.
Das gilt **je Gerät und je Browser**. Der Rechner im Büro und das Handy
sind zwei Anmeldungen; Firefox und Chrome auf demselben Rechner ebenfalls.
> **Der Knopf fehlt oder ist grau?** Push braucht HTTPS. Unter
> `http://localhost` geht es zum Entwickeln, unter jeder anderen
> Klartextadresse nicht. Hat der Browser Benachrichtigungen einmal
> blockiert, lässt sich das nur in seinen eigenen Einstellungen
> zurücknehmen — die Anwendung darf dann nicht mehr fragen.
Ein Gerät, das der Push-Dienst dauerhaft ablehnt, entfernt der Planer von
selbst aus der Liste. Du musst nichts aufräumen.
## Was gemeldet wird
| Anlass | Wann |
| ---------------------------------------- | ------------------------------------------------------------------------ |
| **Tagesüberblick** | einmal morgens zur eingestellten Zeit |
| **Fällige Aufgaben** | wenn eine Aufgabe an diesem Tag fällig wird, höchstens einmal je Aufgabe |
| **Gleich beginnt ein Zeitblock** | kurz vor dem Beginn, Vorlauf einstellbar |
| **Kalender lässt sich nicht abgleichen** | erst nach dem dritten Fehlversuch hintereinander |
Eingeschaltet sind anfangs nur Tagesüberblick und fällige Aufgaben — und
selbst die kommen erst, wenn du einen Weg (Push oder E-Mail) aktiviert hast.
Der **Tagesüberblick** entfällt an Tagen, an denen es nichts zu sagen gibt.
Eine Meldung „heute steht nichts an" ist keine Information, sondern Lärm.
Der **Abgleichfehler** kommt bewusst spät: Ein Netz hustet, ein Anbieter hat
Wartung. Erst wenn es dreimal hintereinander nicht klappt, ist es eine
Nachricht wert — dann allerdings dringend, denn solange bleibt deine
Wochenansicht auf einem alten Stand.
## Ruhezeit
Voreingestellt ist **20:00 bis 07:00**. In diesem Fenster kommt nichts.
- Die Ruhezeit läuft über Mitternacht hinweg — das ist der Normalfall.
- Gleiche Start- und Endzeit heißt: **keine** Ruhezeit.
- **Am Wochenende still** legt Samstag und Sonntag komplett still.
Eine Ausnahme: Der **Tagesüberblick** kommt zu seiner festen Zeit, auch wenn
die noch in der Ruhezeit liegt. Wer ihn auf 06:00 stellt, steht um 06:00 auf
und will ihn dann auch haben.
Was in der Ruhezeit anfällt, wird **nicht nachgeholt**. Eine Erinnerung an
einen Zeitblock, der längst begonnen hat, hilft niemandem.
## Ausprobieren
Der Knopf **Probe senden** schickt eine Testmeldung über alle
eingeschalteten Wege — er umgeht Ruhezeit und Wiederholungssperre. Wer
darauf drückt, will jetzt wissen, ob es ankommt.
Die Rückmeldung sagt, was passiert ist: an wie viele Geräte der Push ging,
ob die Mail hinausging. Kommt „kein Gerät für Push angemeldet", fehlt die
Anmeldung dieses Browsers.
## Wenn nichts ankommt
| Symptom | Ursache |
| ----------------------------------------- | ----------------------------------------------------------------- |
| Probe sagt „verschickt", nichts kommt an | Betriebssystem blendet Browsermeldungen aus (Fokus, Nicht stören) |
| Gerät verschwindet immer wieder | Der Push-Dienst lehnt das Abo ab — neu anmelden |
| Gar nichts, seit der Server umgezogen ist | Neues Schlüsselpaar auf dem Server. Alle Geräte neu anmelden |
| Mail fehlt, Push kommt | SMTP prüfen; Fehler stehen im Serverprotokoll |
Der Planer prüft alle zehn Minuten, ob etwas zu melden ist. Eine Erinnerung
kann also ein paar Minuten vor der eingestellten Zeit kommen — nie danach.
---
Weiter zur [Wochenplanung](woche.md) oder zurück zum
[Handbuch](README.md).
+116
View File
@@ -0,0 +1,116 @@
# Kalenderkonten verbinden
Der Planer liest deine Termine, um zu wissen, **wann überhaupt Platz ist**.
Ab M4 schreibt er Zeitblöcke in denselben Kalender zurück.
![Die Seite Kalenderkonten](../bilder/kalenderkonten-hell-schreibtisch.png)
## Was der Planer liest — und was nicht
| Liest er | Nutzt er wofür |
| --------------------------------- | ------------------------------------------------ |
| Beginn und Ende | Belegung gegen die Tageskapazität |
| Betreff | Anzeige in der Wochenansicht |
| Ganztägig ja/nein | Ganztägige Termine zählen **nicht** zur Belegung |
| „Zeigt an als" (frei, gebucht, …) | Als frei markierte Termine zählen nicht |
**Nicht gelesen** werden Teilnehmer, Beschreibungstexte, Anhänge und Orte.
Betreffs stehen in der Datenbank, damit du sie in der Ansicht siehst — sie
landen aber **niemals in Protokollen**.
Gelesen wird ein Fenster von **7 Tagen zurück bis 90 Tage voraus**. Was aus
diesem Fenster herauswandert, wird nachts wieder entfernt.
## Einen Kalender verbinden
1. Im Menü auf **Kalender**.
2. Auf **Microsoft 365 verbinden** beziehungsweise **Google verbinden**.
3. Beim Anbieter anmelden und der Berechtigung zustimmen.
4. Du landest wieder hier, und der erste Abgleich läuft sofort.
> **Warum fragt der Planer auch nach Schreibrechten?** Weil er ab M4
> Zeitblöcke in den Kalender schreibt. Würde er jetzt nur Leserechte
> anfordern, müsstest du später jedem Konto erneut zustimmen — und genau
> daran scheitert Einrichtung erfahrungsgemäß.
### Welche Anbieter es gibt
Das hängt an der Instanz und ist nicht verhandelbar:
| | Firma | Daheim |
| ------------- | -------- | ------ |
| Microsoft 365 | ja | ja |
| Google | **nein** | ja |
In der Firmeninstanz gibt es die Google-Seite nicht — der Aufruf endet dort
mit „Diese Seite gibt es nicht". Das ist Absicht, kein Fehler.
## Der Abgleich
Alle 15 Minuten läuft der Abgleich von selbst. Mit **Jetzt abgleichen**
stößt du ihn von Hand an.
Beide Anbieter liefern nur die **Änderungen** seit dem letzten Mal, nicht
jedes Mal alle Termine. Läuft dieser Merker ab — das passiert nach längerer
Pause —, liest der Planer den ganzen Zeitraum neu ein. Davon merkst du außer
einem etwas längeren Abgleich nichts.
## Die Belegung
Auf der Startseite zeigt jeder Bereich, wie voll der heutige Tag schon ist:
Termine aus **allen** verbundenen Kalendern gegen die Tageskapazität dieses
Bereichs.
> **Warum zählen alle Kalender gegen jeden Bereich?** Weil ein Termin die
> Zeit ohnehin blockiert, ganz gleich, zu welchem Lebensbereich er gehört.
> Wer um 14 Uhr beim Zahnarzt sitzt, kann zur selben Zeit nichts für die
> Firma tun.
Gezählt wird nur, was **ins Arbeitsfenster** des Bereichs fällt. Ein Termin
um 22 Uhr verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um
17 Uhr endet.
Überlappende Termine werden zusammengelegt — eine Doppelbuchung zählt nur
einmal.
## Ein Konto umbenennen
Die Bezeichnung ist frei wählbar. „Firma Outlook" oder „Gewerbe" liest sich
besser als die Mailadresse, gerade wenn du mehrere Konten desselben
Anbieters hast.
## Ein Konto trennen
Der rote Knopf, mit Rückfrage. Die gespiegelten Termine verschwinden dabei —
**im Kalender selbst bleibt alles unangetastet**. Der Planer hat bis M4
ohnehin nur gelesen.
Zusätzlich solltest du die Berechtigung beim Anbieter selbst zurückziehen:
- Microsoft: <https://myapps.microsoft.com> → _Zugriff verwalten_
- Google: <https://myaccount.google.com/permissions>
## Wenn es klemmt
| Was du siehst | Was dahintersteckt |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| „Der Anbieter hat kein Aktualisierungstoken herausgegeben." | Meist bei Google, wenn du schon einmal zugestimmt hattest. Berechtigung im Google-Konto entziehen und neu verbinden. |
| „Die Anfrage war ungültig oder zu alt." | Zwischen Klick und Zustimmung lagen mehr als 30 Minuten. Einfach neu beginnen. |
| „Kein Aktualisierungstoken hinterlegt." | Das Konto muss neu verbunden werden. Das passiert auch, wenn `AUTH_SECRET` gewechselt wurde. |
| „Der Anbieter antwortete mit 401." | Die Berechtigung wurde beim Anbieter entzogen. Neu verbinden. |
| „unerwartete Zeitzone …, erwartet wurde UTC" | Ein Konfigurationsfehler auf Serverseite. Bitte melden — der Planer bricht hier bewusst ab, statt Termine um Stunden zu verschieben. |
| Die Belegung bleibt leer | Kein Kalender verbunden, oder der erste Abgleich lief noch nicht. Die Karte zeigt die Belegung erst, wenn wirklich ein Kalender dranhängt. |
## Sicherheit
Die Zugangstoken liegen **verschlüsselt** in der Datenbank (AES-256-GCM).
Wer eine Sicherung in die Hände bekommt, kann damit nichts anfangen, solange
er nicht auch `AUTH_SECRET` hat.
Wird `AUTH_SECRET` gewechselt, müssen alle Kalenderkonten neu verbunden
werden. Das ist gewollt: mit dem alten Geheimnis sind auch alle Sitzungen
ungültig.
Für die Einrichtung auf Serverseite — was in Entra ID und in der Google
Cloud Console anzulegen ist — siehe
[README, Abschnitt Kalenderanbieter](../../README.md#kalenderanbieter-einrichten).
+106
View File
@@ -0,0 +1,106 @@
# Wochenplanung und Zeitblöcke
Hier wird aus einer Aufgabe ein Termin. Du ziehst sie in ein Zeitfenster, und
der Planer legt sie als **Zeitblock** in deinen echten Kalender.
![Die Wochenansicht](../bilder/woche-hell-schreibtisch.png)
## Der Aufbau
| Wo | Was |
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Links** | Alles, was noch eingeplant werden kann — offen, geplant oder wartend. Ideen bleiben draußen |
| **Rechts** | Sieben Tage im Viertelstundenraster |
| **Über jeder Spalte** | Belegte Zeit gegen die Kapazität dieses Tages |
| **Graue Balken** | Termine aus deinem Kalender. Sie gehören dem Kalender, nicht dem Planer — hier lassen sie sich nicht anfassen |
| **Farbige Balken** | Deine Zeitblöcke, in der Farbe ihres Bereichs |
Das sichtbare Stundenfenster ergibt sich aus den Arbeitsfenstern **aller**
Bereiche, mit einer Stunde Luft nach oben und unten.
## Eine Aufgabe einplanen
Aufgabe links am Griff packen und in ein Zeitfenster ziehen. Fertig — der
Zeitblock steht sofort im Raster und wenige Augenblicke später in deinem
Kalender.
> **Die Dauer kommt aus dem geschätzten Aufwand.** Wer bei der Aufgabe
> „90 Minuten" einträgt, bekommt einen 90-Minuten-Block. Ohne Schätzung sind
> es 60 Minuten, die du danach anpassen kannst.
Eine eingeplante Aufgabe wechselt automatisch von _Offen_ auf _Geplant_.
## Verschieben
Am Griff im Block packen und woandershin ziehen. Der Planer zieht den
Kalendertermin nach.
## Ausplanen
Das Kreuz im Block. Der Termin verschwindet aus dem Kalender; die Aufgabe
bleibt und fällt zurück auf _Offen_, wenn nichts anderes mehr für sie geplant
ist.
## Wenn du im Kalender verschiebst
Das darfst du — und der Planer zieht nach. Beim nächsten Abgleich (spätestens
nach 15 Minuten) steht der Block dort, wo du ihn im Kalender hingelegt hast.
> **Der Kalender gewinnt.** Immer. Wer dort etwas verschiebt, meint es so;
> säße der Planer am längeren Hebel, würde er dir den Termin wieder wegziehen
> und du wüsstest nicht, warum.
>
> Löschst du den Termin im Kalender, verschwindet auch der Zeitblock. Die
> Aufgabe selbst bleibt.
### Warum sich das nicht aufschaukelt
Der Planer merkt sich zu jedem Zeitblock einen Fingerabdruck des Zeitraums,
den er zuletzt geschrieben hat. Findet er beim Abgleich genau diesen
Fingerabdruck wieder, war es seine eigene Änderung — er tut nichts. Weicht er
ab, hat jemand anderes verschoben.
Ohne diesen Kniff würden App und Kalender einander endlos hinterherschreiben.
## Das Warnzeichen im Block
Ein kleines Dreieck heißt: **noch nicht im Kalender**. Meist war der Anbieter
gerade nicht erreichbar.
Der Zeitblock bleibt trotzdem stehen — deine Planung soll nicht verlorengehen,
bloß weil ein Server hustet. Verschiebe den Block einmal, dann trägt der
Planer ihn nach.
## Die Zahl über der Spalte
`3 h / 6 h` heißt: sechs Stunden Kapazität an diesem Tag, drei davon schon
belegt. Gezählt werden Kalendertermine **und** deine Zeitblöcke.
Wird die Zahl rot, ist der Tag überbucht. Das ist keine Sperre — der Planer
lässt dich weiterplanen. Es ist ein Hinweis, dass der Tag nicht aufgeht.
Ganztägige Termine zählen nicht mit. Ein Urlaubstag im Kalender blockiert
sonst rechnerisch den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
## Blättern
**Zurück**, **Diese Woche** und **Weiter**. Die Woche steht in der Adresse —
eine bestimmte Woche lässt sich also als Lesezeichen ablegen.
## Wenn es klemmt
| Was du siehst | Was dahintersteckt |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| „Ohne verbundenen Kalender lässt sich nichts einplanen." | Erst unter [Kalender](kalenderkonten.md) ein Konto verbinden |
| „Eingeplant, aber nicht in den Kalender geschrieben" | Der Anbieter war nicht erreichbar. Der Block steht, verschiebe ihn einmal zum Nachtragen |
| „Der Termin wurde inzwischen im Kalender geändert." | Jemand war schneller. Der Kalender gewinnt; der nächste Abgleich holt den dortigen Stand |
| „Zeitblöcke rasten in 15-Minuten-Schritten ein." | Das Raster ist Absicht. Genauer zu planen bringt nichts |
| Ein Block ist nicht zu sehen | Er liegt außerhalb des sichtbaren Stundenfensters. Das Fenster folgt den Arbeitsfenstern der Bereiche — siehe [Bereiche](bereiche.md) |
## Was noch nicht geht
- **Ein Zeitblock landet immer im ersten verbundenen Kalender.** Die Auswahl
je Bereich ist im Datenmodell vorbereitet, aber noch nicht in der
Oberfläche.
- **Die Dauer lässt sich nicht am Balken ziehen.** Wer eine andere Dauer will,
ändert den Aufwand der Aufgabe und plant neu ein.
+9 -14
View File
@@ -1,6 +1,7 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
/**
* Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken.
@@ -9,16 +10,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js'
* damit die Entwicklungsdatenbank hinterher aussieht wie vorher.
*/
const EMAIL = process.env.ADMIN_EMAIL ?? ''
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
const PRAEFIX = `E2E-${Date.now()}`
const verbindung = process.env.DATABASE_URL ?? ''
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
test.describe('Aufgaben', () => {
test.skip(
!EMAIL || !PASSWORT || !verbindung,
!EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
)
@@ -29,14 +27,6 @@ test.describe('Aufgaben', () => {
await db.$disconnect()
})
async function anmelden(seite: Page): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
await seite.getByLabel('Passwort').fill(PASSWORT)
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
}
test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => {
const titel = `${PRAEFIX} Angebot schreiben`
await anmelden(page)
@@ -72,6 +62,11 @@ test.describe('Aufgaben', () => {
await expect(inListe).toBeVisible()
await inListe.getByRole('button', { name: 'Als erledigt abhaken' }).click()
// Auf die Bestätigung warten, bevor es weitergeht: der Klick stößt eine
// Server Action an, und ohne diesen Haltepunkt navigiert der Test
// gelegentlich schneller, als gespeichert wird.
await expect(page.getByText('ist jetzt erledigt')).toBeVisible()
// Erledigtes fällt aus der Standardansicht heraus.
await page.goto('/aufgaben')
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
+91
View File
@@ -0,0 +1,91 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
/**
* Was in M5 wirklich zählt: die Einstellung hält, und ohne eingeschalteten
* Weg geht nichts hinaus.
*
* Der Test rührt nur an der Einstellung des Testkontos und stellt sie am
* Ende wieder her.
*/
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
test.describe('Benachrichtigungen', () => {
test.skip(
!EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
)
test.afterAll(async () => {
const benutzer = await db.benutzer.findUnique({
where: { email: EMAIL },
select: { id: true },
})
if (benutzer) {
await db.benachrichtigungseinstellung.deleteMany({
where: { benutzerId: benutzer.id },
})
await db.meldungsnachweis.deleteMany({ where: { benutzerId: benutzer.id } })
}
await db.auditEintrag.deleteMany({
where: { aktion: { startsWith: 'benachrichtigung.' } },
})
await db.$disconnect()
})
test('speichert die Einstellung und hält sie', async ({ page }) => {
await anmelden(page)
await page.goto('/einstellungen')
const karte = page.locator('[data-bild="benachrichtigungen"]')
await expect(karte).toBeVisible()
// --- Ab Werk ist nichts eingeschaltet ---------------------------------
await expect(karte.locator('input[name="perPush"]')).not.toBeChecked()
await expect(karte.locator('input[name="perMail"]')).not.toBeChecked()
// --- Ohne Weg keine Probe ---------------------------------------------
await karte.getByRole('button', { name: 'Probe schicken' }).click()
await expect(page.getByText('Erst einen Weg einschalten')).toBeVisible()
// --- Ändern und speichern ---------------------------------------------
await karte.locator('input[name="perMail"]').check()
await karte.locator('input[value="zeitblock"]').check()
await karte.getByLabel('Tagesüberblick um').fill('06:30')
await karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)').fill('20')
await karte.getByLabel('Am Wochenende gar nichts melden').check()
await karte.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Einstellungen gespeichert.')).toBeVisible()
// --- Nach dem Neuladen steht alles noch da ----------------------------
await page.reload()
await expect(karte.locator('input[name="perMail"]')).toBeChecked()
await expect(karte.locator('input[value="zeitblock"]')).toBeChecked()
await expect(karte.getByLabel('Tagesüberblick um')).toHaveValue('06:30')
await expect(karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)')).toHaveValue(
'20',
)
await expect(karte.getByLabel('Am Wochenende gar nichts melden')).toBeChecked()
})
test('weist eine unsinnige Vorlaufzeit zurück', async ({ page }) => {
await anmelden(page)
await page.goto('/einstellungen')
const karte = page.locator('[data-bild="benachrichtigungen"]')
const vorlauf = karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)')
// Das Zahlenfeld begrenzt auf 120; der Server darf sich darauf nicht
// verlassen, deshalb kommt der Wert an der Oberfläche vorbei.
await vorlauf.evaluate((feld: HTMLInputElement) => {
feld.max = '600'
feld.value = '300'
})
await karte.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Bitte die markierten Felder prüfen.')).toBeVisible()
await expect(karte.getByText('Mehr als zwei Stunden Vorlauf')).toBeVisible()
})
})
+35
View File
@@ -0,0 +1,35 @@
import { expect, type Page } from '@playwright/test'
/** Gemeinsames für alle E2E-Tests. */
export const EMAIL = process.env.ADMIN_EMAIL ?? ''
export const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
export const VERBINDUNG = process.env.DATABASE_URL ?? ''
/**
* Meldet das Testkonto mit Passwort an.
*
* Der Knopf gehört zu einem Client-Formular. Ein Klick, der vor der
* Hydratation ankommt, verpufft folgenlos — die Seite bleibt einfach stehen.
* Deshalb wird so lange nachgefasst, bis die Startseite da ist; hat der erste
* Klick schon gewirkt, wird nicht noch einmal geklickt.
*/
export async function anmelden(
seite: Page,
email: string = EMAIL,
passwort: string = PASSWORT,
): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(email)
await seite.getByLabel('Passwort').fill(passwort)
const knopf = seite.getByRole('button', { name: 'Mit Passwort anmelden' })
const begruessung = seite.getByRole('heading', { name: /Guten Tag/ })
await expect(async () => {
if (new URL(seite.url()).pathname === '/anmelden') {
await knopf.click({ timeout: 5_000 })
}
await expect(begruessung).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 45_000 })
}
+5 -12
View File
@@ -1,7 +1,8 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import bcrypt from 'bcryptjs'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden as anmeldenMit } from './hilfe'
/**
* Prüft, dass die Rollentrennung serverseitig hält.
@@ -38,23 +39,15 @@ test.describe('Rollentrennung', () => {
await db.$disconnect()
})
async function anmelden(seite: Page): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
await seite.getByLabel('Passwort').fill(PASSWORT)
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
}
test('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => {
await anmelden(page)
await anmeldenMit(page, EMAIL, PASSWORT)
await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
})
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
page,
}) => {
await anmelden(page)
await anmeldenMit(page, EMAIL, PASSWORT)
await page.goto('/verwaltung/protokoll')
await expect(page).toHaveURL(/\/keine-berechtigung/)
@@ -64,7 +57,7 @@ test.describe('Rollentrennung', () => {
})
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
await anmelden(page)
await anmeldenMit(page, EMAIL, PASSWORT)
await page.goto('/bereiche')
await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible()
+116
View File
@@ -0,0 +1,116 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
/**
* Wochenansicht und Zeitblöcke.
*
* Ohne echtes Kalenderkonto lässt sich der Schreibweg zum Anbieter nicht
* prüfen — er scheitert dann und der Zeitblock trägt einen Fehler. Genau das
* ist aber selbst prüfenswert: Die Planung muss auch dann überleben, wenn der
* Kalender nicht erreichbar ist.
*/
const PRAEFIX = `E2E-W-${Date.now()}`
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
test.describe('Woche', () => {
test.skip(
!EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
)
test.afterAll(async () => {
await db.zeitblock.deleteMany({
where: { aufgabe: { titel: { startsWith: PRAEFIX } } },
})
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: PRAEFIX } } })
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'zeitblock.' } } })
await db.$disconnect()
})
test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => {
await db.kalenderkonto.deleteMany({})
await anmelden(page)
await page.goto('/woche')
await expect(page.getByRole('heading', { name: 'Woche' })).toBeVisible()
await expect(page.getByText('Noch kein Kalender verbunden')).toBeVisible()
await expect(
page.getByText('Ohne verbundenen Kalender lässt sich nichts'),
).toBeVisible()
})
test('zeigt einen Zeitblock im Raster und plant ihn wieder aus', async ({ page }) => {
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
// Ein Kalenderkonto ohne gültige Tokens: die Ansicht funktioniert, der
// Schreibweg zum Anbieter nicht.
const konto = await db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: `${PRAEFIX} Testkalender`,
extKontoId: `${PRAEFIX}-konto`,
},
})
const aufgabe = await db.aufgabe.create({
data: { titel: `${PRAEFIX} Angebot schreiben`, aufwandMinuten: 60 },
})
// Zeitblock auf 10:00 Uhr des Montags dieser Woche legen.
const jetzt = new Date()
const montag = new Date(jetzt)
montag.setUTCHours(0, 0, 0, 0)
montag.setUTCDate(montag.getUTCDate() - ((montag.getUTCDay() + 6) % 7))
const beginn = new Date(montag.getTime() + 10 * 60 * 60 * 1000)
await db.zeitblock.create({
data: {
aufgabeId: aufgabe.id,
kalenderkontoId: konto.id,
beginn,
ende: new Date(beginn.getTime() + 60 * 60 * 1000),
},
})
await db.aufgabe.update({
where: { id: aufgabe.id },
data: { status: 'GEPLANT' },
})
await anmelden(page)
await page.goto('/woche')
const block = page.getByTitle(new RegExp(`${PRAEFIX} Angebot schreiben`))
await expect(block).toBeVisible()
// Ausplanen: der Block verschwindet, die Aufgabe fällt zurück auf offen.
await block.getByRole('button', { name: 'Ausplanen' }).click()
await expect(page.getByText('ausgeplant')).toBeVisible()
await expect
.poll(async () => db.zeitblock.count({ where: { aufgabeId: aufgabe.id } }))
.toBe(0)
const danach = await db.aufgabe.findUniqueOrThrow({ where: { id: aufgabe.id } })
expect(danach.status).toBe('OFFEN')
})
test('lässt sich durch die Wochen blättern', async ({ page }) => {
await anmelden(page)
await page.goto('/woche')
await page.getByRole('link', { name: 'Weiter' }).click()
await expect(page).toHaveURL(/versatz=1/)
await page.getByRole('link', { name: 'Zurück' }).click()
await expect(page).toHaveURL(/versatz=0/)
await page.getByRole('link', { name: 'Diese Woche' }).click()
await expect(page).toHaveURL(/\/woche$/)
})
})
+2
View File
@@ -16,6 +16,8 @@ export default tseslint.config(
// Vom Generator erzeugt, nicht von Hand gepflegt.
'src/generated/**',
'next-env.d.ts',
// Service Worker: laeuft im Browser, nicht in unserer Werkzeugkette.
'public/sw.js',
],
},
...naechstesWebVitals,
+191 -6
View File
@@ -1,15 +1,17 @@
{
"name": "planer",
"version": "0.2.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "planer",
"version": "0.2.0",
"version": "0.5.0",
"dependencies": {
"@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0",
"@radix-ui/react-avatar": "1.2.6",
@@ -36,6 +38,7 @@
"server-only": "0.0.1",
"sonner": "2.0.8",
"tailwind-merge": "3.6.0",
"web-push": "3.6.7",
"zod": "4.6.1"
},
"devDependencies": {
@@ -47,6 +50,7 @@
"@types/pg": "8.23.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/web-push": "3.6.4",
"@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "5.0.0",
"dotenv": "17.4.2",
@@ -593,6 +597,45 @@
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@electric-sql/pglite": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
@@ -4504,6 +4547,16 @@
"@types/react": "^19.3.0"
}
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
@@ -5405,6 +5458,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -5637,6 +5699,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -5791,6 +5865,12 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
@@ -5851,6 +5931,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/c12": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
@@ -6372,7 +6458,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -6556,6 +6641,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/effect": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
@@ -8044,6 +8138,28 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -8098,6 +8214,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -8763,6 +8885,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9239,6 +9382,12 @@
"node": ">=8.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -9259,7 +9408,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -9269,7 +9417,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mysql2": {
@@ -10690,6 +10837,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -10752,7 +10919,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/saxes": {
@@ -12357,6 +12523,25 @@
"node": ">=18"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planer",
"version": "0.2.0",
"version": "0.5.0",
"private": true,
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
"type": "module",
@@ -40,6 +40,8 @@
"dependencies": {
"@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0",
"@radix-ui/react-avatar": "1.2.6",
@@ -66,6 +68,7 @@
"server-only": "0.0.1",
"sonner": "2.0.8",
"tailwind-merge": "3.6.0",
"web-push": "3.6.7",
"zod": "4.6.1"
},
"devDependencies": {
@@ -77,6 +80,7 @@
"@types/pg": "8.23.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/web-push": "3.6.4",
"@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "5.0.0",
"dotenv": "17.4.2",
+101
View File
@@ -327,6 +327,83 @@ async function main(): Promise<void> {
})
}
// --- Kalenderkonto und erfundene Termine ---------------------------------
// Die Tokens sind absichtlich Unsinn: die Screenshot-Instanz spricht mit
// keinem Anbieter, sie soll die Seite nur nicht leer zeigen.
await db.kalenderkonto.deleteMany({})
const kalenderkonto = await db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: 'Firma Outlook',
extKontoId: 'martina.beispiel@firma.invalid',
letzterAbgleich: new Date(DEMO_JETZT.getTime() - 6 * 60_000),
},
})
const TERMINE = [
{ betreff: 'Wochenbesprechung', vonStunde: 8, bisStunde: 9 },
{ betreff: 'Kundengespräch Sonnenberg', vonStunde: 10, bisStunde: 11 },
{ betreff: 'Mittagspause', vonStunde: 11, bisStunde: 12, frei: true },
{ betreff: 'Abnahme Baustelle', vonStunde: 13, bisStunde: 15 },
]
for (const eintrag of TERMINE) {
await db.termin.create({
data: {
kalenderkontoId: kalenderkonto.id,
extId: `demo-${eintrag.betreff.toLowerCase().replace(/[^a-z]+/g, '-')}`,
betreff: eintrag.betreff,
beginn: new Date(Date.UTC(2026, 2, 17, eintrag.vonStunde - 1, 0)),
ende: new Date(Date.UTC(2026, 2, 17, eintrag.bisStunde - 1, 0)),
zeigtAls: eintrag.frei ? 'FREI' : 'GEBUCHT',
},
})
}
await db.termin.create({
data: {
kalenderkontoId: kalenderkonto.id,
extId: 'demo-brueckentag',
betreff: 'Brückentag',
beginn: new Date(Date.UTC(2026, 2, 20, 0, 0)),
ende: new Date(Date.UTC(2026, 2, 21, 0, 0)),
ganztaegig: true,
zeigtAls: 'ABWESEND',
},
})
// --- Zeitblöcke: eingeplante Aufgaben im Kalender -------------------------
await db.zeitblock.deleteMany({})
const einzuplanen = await db.aufgabe.findMany({
where: {
titel: {
in: [
'Wochenbericht an die Geschäftsführung',
'Angebot Heizungstausch nachfassen',
],
},
},
select: { id: true, titel: true, aufwandMinuten: true },
})
let blockStunde = 15
for (const aufgabe of einzuplanen) {
const beginn = new Date(Date.UTC(2026, 2, 17, blockStunde - 1, 0))
await db.zeitblock.create({
data: {
aufgabeId: aufgabe.id,
kalenderkontoId: kalenderkonto.id,
beginn,
ende: new Date(beginn.getTime() + (aufgabe.aufwandMinuten ?? 60) * 60_000),
extId: `demo-block-${aufgabe.id}`,
hash: 'demo',
},
})
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
blockStunde += 2
}
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
await db.aufgabe.create({
data: {
@@ -341,6 +418,30 @@ async function main(): Promise<void> {
},
})
// --- Benachrichtigungen ---------------------------------------------------
// Für die Bilder eingeschaltet. Eine leere Karte zeigt nichts von dem,
// was das Kapitel erklärt.
await db.benachrichtigungseinstellung.upsert({
where: { benutzerId: benutzer.id },
update: {},
create: {
benutzerId: benutzer.id,
anlaesse: 'tagesueberblick,faelligkeit,zeitblock',
perPush: true,
perMail: true,
ueberblickUm: 7 * 60,
ruheVon: 20 * 60,
ruheBis: 7 * 60,
ruheAmWochenende: false,
zeitblockVorlaufMinuten: 10,
},
})
// Kein Push-Gerät in den Demo-Daten: Seine Schlüssel liegen
// verschlüsselt, und der Versand entfernt jedes Gerät, dessen Schlüssel er
// nicht lesen kann. Ein erfundener Eintrag verschwände beim ersten Lauf
// wieder — die Bilder zeigen deshalb den Zustand vor der ersten Anmeldung.
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
}
@@ -0,0 +1,70 @@
-- CreateEnum
CREATE TYPE "KalenderAnbieter" AS ENUM ('GRAPH', 'GOOGLE');
-- CreateEnum
CREATE TYPE "ZeigtAls" AS ENUM ('FREI', 'GEBUCHT', 'MIT_VORBEHALT', 'ABWESEND', 'WOANDERS', 'UNBEKANNT');
-- AlterTable
ALTER TABLE "bereich" ADD COLUMN "standardKalenderkontoId" TEXT;
-- CreateTable
CREATE TABLE "kalenderkonto" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"anbieter" "KalenderAnbieter" NOT NULL,
"anzeigename" TEXT NOT NULL,
"extKontoId" TEXT NOT NULL,
"kalenderId" TEXT,
"zugriffstoken" TEXT,
"aktualisierungstoken" TEXT,
"tokenGueltigBis" TIMESTAMP(3),
"bereiche" TEXT,
"deltaVerweis" TEXT,
"letzterAbgleich" TIMESTAMP(3),
"letzterFehler" TEXT,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "kalenderkonto_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "termin" (
"id" TEXT NOT NULL,
"kalenderkontoId" TEXT NOT NULL,
"extId" TEXT NOT NULL,
"etag" TEXT,
"betreff" TEXT,
"beginn" TIMESTAMP(3) NOT NULL,
"ende" TIMESTAMP(3) NOT NULL,
"ganztaegig" BOOLEAN NOT NULL DEFAULT false,
"zeigtAls" "ZeigtAls" NOT NULL DEFAULT 'GEBUCHT',
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "termin_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "kalenderkonto_benutzerId_aktiv_idx" ON "kalenderkonto"("benutzerId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "kalenderkonto_benutzerId_anbieter_extKontoId_kalenderId_key" ON "kalenderkonto"("benutzerId", "anbieter", "extKontoId", "kalenderId");
-- CreateIndex
CREATE INDEX "termin_kalenderkontoId_beginn_idx" ON "termin"("kalenderkontoId", "beginn");
-- CreateIndex
CREATE INDEX "termin_beginn_ende_idx" ON "termin"("beginn", "ende");
-- CreateIndex
CREATE UNIQUE INDEX "termin_kalenderkontoId_extId_key" ON "termin"("kalenderkontoId", "extId");
-- AddForeignKey
ALTER TABLE "bereich" ADD CONSTRAINT "bereich_standardKalenderkontoId_fkey" FOREIGN KEY ("standardKalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kalenderkonto" ADD CONSTRAINT "kalenderkonto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "termin" ADD CONSTRAINT "termin_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
/*
Warnings:
- Made the column `kalenderId` on table `kalenderkonto` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "kalenderkonto" ALTER COLUMN "kalenderId" SET NOT NULL,
ALTER COLUMN "kalenderId" SET DEFAULT 'standard';
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "zeitblock" (
"id" TEXT NOT NULL,
"aufgabeId" TEXT NOT NULL,
"kalenderkontoId" TEXT NOT NULL,
"beginn" TIMESTAMP(3) NOT NULL,
"ende" TIMESTAMP(3) NOT NULL,
"extId" TEXT,
"etag" TEXT,
"hash" TEXT,
"letzterFehler" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "zeitblock_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "zeitblock_beginn_ende_idx" ON "zeitblock"("beginn", "ende");
-- CreateIndex
CREATE INDEX "zeitblock_aufgabeId_idx" ON "zeitblock"("aufgabeId");
-- CreateIndex
CREATE UNIQUE INDEX "zeitblock_kalenderkontoId_extId_key" ON "zeitblock"("kalenderkontoId", "extId");
-- AddForeignKey
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_aufgabeId_fkey" FOREIGN KEY ("aufgabeId") REFERENCES "aufgabe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,66 @@
-- CreateTable
CREATE TABLE "benachrichtigungseinstellung" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"anlaesse" TEXT NOT NULL DEFAULT 'tagesueberblick,faelligkeit',
"perPush" BOOLEAN NOT NULL DEFAULT false,
"perMail" BOOLEAN NOT NULL DEFAULT false,
"ueberblickUm" INTEGER NOT NULL DEFAULT 420,
"ruheVon" INTEGER NOT NULL DEFAULT 1200,
"ruheBis" INTEGER NOT NULL DEFAULT 420,
"ruheAmWochenende" BOOLEAN NOT NULL DEFAULT false,
"zeitblockVorlaufMinuten" INTEGER NOT NULL DEFAULT 10,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "benachrichtigungseinstellung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "push_geraet" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"endpunkt" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"bezeichnung" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztBenutzt" TIMESTAMP(3),
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "push_geraet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "meldungsnachweis" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"schluessel" TEXT NOT NULL,
"gesendetAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "meldungsnachweis_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "benachrichtigungseinstellung_benutzerId_key" ON "benachrichtigungseinstellung"("benutzerId");
-- CreateIndex
CREATE UNIQUE INDEX "push_geraet_endpunkt_key" ON "push_geraet"("endpunkt");
-- CreateIndex
CREATE INDEX "push_geraet_benutzerId_idx" ON "push_geraet"("benutzerId");
-- CreateIndex
CREATE INDEX "meldungsnachweis_gesendetAm_idx" ON "meldungsnachweis"("gesendetAm");
-- CreateIndex
CREATE UNIQUE INDEX "meldungsnachweis_benutzerId_schluessel_key" ON "meldungsnachweis"("benutzerId", "schluessel");
-- AddForeignKey
ALTER TABLE "benachrichtigungseinstellung" ADD CONSTRAINT "benachrichtigungseinstellung_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "push_geraet" ADD CONSTRAINT "push_geraet_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "meldungsnachweis" ADD CONSTRAINT "meldungsnachweis_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "kalenderkonto" ADD COLUMN "fehlversuche" INTEGER NOT NULL DEFAULT 0;
+201 -5
View File
@@ -31,9 +31,13 @@ model Benutzer {
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
konten Konto[]
passkeys Passkey[]
audit AuditEintrag[]
konten Konto[]
passkeys Passkey[]
audit AuditEintrag[]
kalenderkonten Kalenderkonto[]
benachrichtigung Benachrichtigungseinstellung?
pushGeraete PushGeraet[]
meldungen Meldungsnachweis[]
@@index([aktiv])
@@map("benutzer")
@@ -121,6 +125,10 @@ model Bereich {
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
/// In welchen Kalender Zeitbloecke dieses Bereichs geschrieben werden.
standardKalenderkontoId String?
standardKalenderkonto Kalenderkonto? @relation("StandardKalenderkonto", fields: [standardKalenderkontoId], references: [id], onDelete: SetNull)
aufgaben Aufgabe[]
projekte Projekt[]
@@ -217,8 +225,9 @@ model Aufgabe {
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
zeitbloecke Zeitblock[]
/// Integrationen upserten immer ueber diese beiden Felder. Von Hand
/// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte
@@ -252,3 +261,190 @@ model AuditEintrag {
@@index([benutzerId])
@@map("audit_eintrag")
}
enum KalenderAnbieter {
GRAPH
GOOGLE
}
/// Wie der Termin die Zeit belegt. Google kennt nur frei/gebucht, Graph mehr.
enum ZeigtAls {
FREI
GEBUCHT
MIT_VORBEHALT
ABWESEND
WOANDERS
UNBEKANNT
}
/// Ein verbundener Kalender bei Microsoft Graph oder Google.
model Kalenderkonto {
id String @id @default(cuid())
benutzerId String
anbieter KalenderAnbieter
/// Frei waehlbare Bezeichnung, z. B. "Firma Outlook".
anzeigename String
/// Kennung beim Anbieter, meist die Mailadresse. Dient dem Wiedererkennen.
extKontoId String
/// Welcher Kalender. Bei Google "primary", bei Graph eine Kalender-ID.
/// "standard" bedeutet: der Standardkalender des Postfachs. Nicht optional,
/// weil Prisma in einem zusammengesetzten Unique-Schluessel kein null zulaesst.
kalenderId String @default("standard")
/// Verschluesselt abgelegt, siehe src/server/krypto.ts.
zugriffstoken String?
aktualisierungstoken String?
tokenGueltigBis DateTime?
/// Erteilte Berechtigungen, kommagetrennt – zum Erkennen fehlender Rechte.
bereiche String?
/// deltaLink bei Graph, syncToken bei Google. Opak, nie auswerten.
deltaVerweis String?
letzterAbgleich DateTime?
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
letzterFehler String?
/// Wie oft der Abgleich hintereinander scheiterte. Ein Aussetzer ist keine
/// Nachricht wert; gemeldet wird erst ab mehreren.
fehlversuche Int @default(0)
aktiv Boolean @default(true)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
termine Termin[]
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
zeitblocks Zeitblock[]
/// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer.
@@unique([benutzerId, anbieter, extKontoId, kalenderId])
@@index([benutzerId, aktiv])
@@map("kalenderkonto")
}
/// Ortlicher Spiegel eines Kalendertermins. Nur so viel, wie fuer die
/// Belegungsrechnung und die Wochenansicht gebraucht wird.
model Termin {
id String @id @default(cuid())
kalenderkontoId String
/// Kennung des Termins beim Anbieter.
extId String
/// Fuer bedingte Schreibzugriffe ab M4 (If-Match).
etag String?
betreff String?
beginn DateTime
ende DateTime
ganztaegig Boolean @default(false)
zeigtAls ZeigtAls @default(GEBUCHT)
aktualisiertAm DateTime @updatedAt
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
@@unique([kalenderkontoId, extId])
@@index([kalenderkontoId, beginn])
@@index([beginn, ende])
@@map("termin")
}
/// Eine Aufgabe, die zu einer bestimmten Zeit in einem Kalender steht.
///
/// Der Zeitblock lebt an zwei Orten: hier und im Kalender des Anbieters.
/// Beide Seiten duerfen ihn verschieben; der Fingerabdruck "hash" verhindert,
/// dass sich beide gegenseitig hochschaukeln. Siehe src/lib/zeitblock.ts.
model Zeitblock {
id String @id @default(cuid())
aufgabeId String
kalenderkontoId String
beginn DateTime
ende DateTime
/// Kennung des Termins beim Anbieter; null, solange noch nicht geschrieben.
extId String?
/// Fuer bedingte Schreibzugriffe (If-Match).
etag String?
/// sha256(startISO|endeISO) dessen, was zuletzt geschrieben wurde.
hash String?
/// Klartextmeldung des letzten Fehlschlags beim Schreiben, ohne Tokens.
letzterFehler String?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
aufgabe Aufgabe @relation(fields: [aufgabeId], references: [id], onDelete: Cascade)
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
/// Ein Termin im Kalender gehoert zu hoechstens einem Zeitblock.
@@unique([kalenderkontoId, extId])
@@index([beginn, ende])
@@index([aufgabeId])
@@map("zeitblock")
}
/// Wie und worueber ein Benutzer benachrichtigt werden will.
model Benachrichtigungseinstellung {
id String @id @default(cuid())
benutzerId String @unique
/// Kommagetrennte Anlaesse, siehe src/lib/benachrichtigung.ts.
anlaesse String @default("tagesueberblick,faelligkeit")
perPush Boolean @default(false)
perMail Boolean @default(false)
/// Alles als Minuten seit Mitternacht, Europe/Berlin.
ueberblickUm Int @default(420)
ruheVon Int @default(1200)
ruheBis Int @default(420)
ruheAmWochenende Boolean @default(false)
zeitblockVorlaufMinuten Int @default(10)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@map("benachrichtigungseinstellung")
}
/// Ein Browser, der Push-Nachrichten empfangen darf.
model PushGeraet {
id String @id @default(cuid())
benutzerId String
/// Vom Browser vergebene Adresse; eindeutig je Geraet.
endpunkt String @unique
/// Schluessel aus der Push-Anmeldung, verschluesselt abgelegt.
p256dh String
auth String
bezeichnung String?
erstelltAm DateTime @default(now())
zuletztBenutzt DateTime?
/// Wie oft der Versand hintereinander scheiterte; ab einer Grenze weg.
fehlversuche Int @default(0)
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@index([benutzerId])
@@map("push_geraet")
}
/// Nachweis, dass eine Meldung schon hinausging – verhindert Wiederholungen.
model Meldungsnachweis {
id String @id @default(cuid())
benutzerId String
/// anlass:sache:tag, siehe meldungsSchluessel().
schluessel String
gesendetAm DateTime @default(now())
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@unique([benutzerId, schluessel])
@@index([gesendetAm])
@@map("meldungsnachweis")
}
+53
View File
@@ -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)
}),
)
})
+20 -4
View File
@@ -24,6 +24,13 @@ type Aufnahme = {
pfad: string
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
vorbereiten?: (seite: Page) => Promise<void>
/**
* Nur diesen Ausschnitt aufnehmen statt der ganzen Seite.
*
* Für Themen, die auf einer gemeinsamen Seite wohnen: ein Bild der ganzen
* Einstellungsseite zeigt im Handbuchkapitel zu wenig vom Eigentlichen.
*/
ausschnitt?: string
}
const AUFNAHMEN: Aufnahme[] = [
@@ -31,10 +38,17 @@ const AUFNAHMEN: Aufnahme[] = [
{ name: 'start', pfad: '/' },
{ name: 'inbox', pfad: '/inbox' },
{ name: 'aufgaben', pfad: '/aufgaben' },
{ name: 'woche', pfad: '/woche' },
{ name: 'projekte', pfad: '/projekte' },
{ name: 'ideen', pfad: '/ideen' },
{ name: 'bereiche', pfad: '/bereiche' },
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
{ name: 'einstellungen', pfad: '/einstellungen' },
{
name: 'benachrichtigungen',
pfad: '/einstellungen',
ausschnitt: '[data-bild="benachrichtigungen"]',
},
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
]
@@ -80,10 +94,12 @@ for (const schema of ['hell', 'dunkel'] as const) {
// Der Farbschema-Umschalter und Schriften sollen fertig sein.
await seite.waitForTimeout(300)
await seite.screenshot({
path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`,
fullPage: true,
})
const ziel = `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`
if (aufnahme.ausschnitt) {
await seite.locator(aufnahme.ausschnitt).screenshot({ path: ziel })
} else {
await seite.screenshot({ path: ziel, fullPage: true })
}
}
await kontext.close()
+179
View File
@@ -50,9 +50,14 @@ async function aufraeumen(): Promise<void> {
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: kennung } } })
await db.projekt.deleteMany({ where: { name: { startsWith: kennung } } })
await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } })
// Kalenderkonten hängen am Benutzer und gehen mit ihm; falls der Test
// vorher abbricht, hier trotzdem noch einmal aufräumen.
await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: kennung } } })
await db.benutzer.deleteMany({ where: { email: testEmail } })
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
await db.webauthnChallenge.deleteMany({ where: { kennung } })
await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } })
await db.meldungsnachweis.deleteMany({ where: { schluessel: { contains: kennung } } })
}
async function main(): Promise<void> {
@@ -146,6 +151,102 @@ async function main(): Promise<void> {
}),
)
// --- Kalenderkonto und Termine ------------------------------------------
const kalenderkonto = await db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: `${kennung} Firma Outlook`,
extKontoId: `${kennung}-konto`,
},
})
pruefe(
'Kalenderkonto zeigt im Zweifel auf den Standardkalender',
kalenderkonto.kalenderId === 'standard',
)
pruefe(
'Ein frisches Konto hat noch keinen Abgleichverweis',
kalenderkonto.deltaVerweis === null,
)
await scheitert('Dasselbe Konto und derselbe Kalender nur einmal', () =>
db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: `${kennung} Doppelt`,
extKontoId: `${kennung}-konto`,
},
}),
)
// Dasselbe Konto mit einem anderen Kalender ist dagegen in Ordnung.
const zweiterKalender = await db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: `${kennung} Zweiter Kalender`,
extKontoId: `${kennung}-konto`,
kalenderId: 'AAMkAD-zweiter',
},
})
pruefe(
'Dasselbe Konto mit anderem Kalender ist erlaubt',
zweiterKalender.id !== kalenderkonto.id,
)
const termin = await db.termin.create({
data: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
betreff: 'Wochenbesprechung',
beginn: new Date('2026-03-17T08:00:00Z'),
ende: new Date('2026-03-17T09:00:00Z'),
},
})
pruefe('Termin anlegen', termin.zeigtAls === 'GEBUCHT' && termin.ganztaegig === false)
await scheitert('Derselbe Termin je Konto nur einmal', () =>
db.termin.create({
data: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
beginn: new Date('2026-03-17T08:00:00Z'),
ende: new Date('2026-03-17T09:00:00Z'),
},
}),
)
// So arbeitet der Abgleich: upsert statt anlegen.
const gewandelterTermin = await db.termin.upsert({
where: {
kalenderkontoId_extId: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
},
},
update: { betreff: 'Wochenbesprechung, verschoben', zeigtAls: 'MIT_VORBEHALT' },
create: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
beginn: new Date('2026-03-17T08:00:00Z'),
ende: new Date('2026-03-17T09:00:00Z'),
},
})
pruefe(
'Ein Upsert aktualisiert den Termin, statt ihn zu doppeln',
gewandelterTermin.id === termin.id &&
gewandelterTermin.zeigtAls === 'MIT_VORBEHALT' &&
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 1,
)
// --- Standard-Kalenderkonto eines Bereichs -------------------------------
await db.bereich.update({
where: { id: bereich.id },
data: { standardKalenderkontoId: kalenderkonto.id },
})
await db.kalenderkonto.delete({ where: { id: zweiterKalender.id } })
// --- Audit überlebt den Benutzer ----------------------------------------
const audit = await db.auditEintrag.create({
data: {
@@ -160,6 +261,61 @@ async function main(): Promise<void> {
})
pruefe('Audit-Eintrag anlegen', audit.id.length > 0)
// --- Benachrichtigungen --------------------------------------------------
await db.benachrichtigungseinstellung.create({
data: { benutzerId: benutzer.id },
})
await scheitert('Je Benutzer nur eine Benachrichtigungseinstellung', () =>
db.benachrichtigungseinstellung.create({ data: { benutzerId: benutzer.id } }),
)
const voreinstellung = await db.benachrichtigungseinstellung.findUniqueOrThrow({
where: { benutzerId: benutzer.id },
})
pruefe(
'Ab Werk ist kein Weg eingeschaltet',
voreinstellung.perPush === false && voreinstellung.perMail === false,
)
await db.pushGeraet.create({
data: {
benutzerId: benutzer.id,
endpunkt: `https://push.invalid/${kennung}`,
p256dh: 'x',
auth: 'y',
},
})
await scheitert('Derselbe Push-Endpunkt geht nur einmal', () =>
db.pushGeraet.create({
data: {
benutzerId: benutzer.id,
endpunkt: `https://push.invalid/${kennung}`,
p256dh: 'x',
auth: 'y',
},
}),
)
await db.meldungsnachweis.create({
data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` },
})
await scheitert('Derselbe Meldungsschluessel geht nur einmal', () =>
db.meldungsnachweis.create({
data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` },
}),
)
pruefe(
'Derselbe Schlüssel am nächsten Tag ist erlaubt',
(
await db.meldungsnachweis.create({
data: {
benutzerId: benutzer.id,
schluessel: `faelligkeit:${kennung}:2026-03-18`,
},
})
).id.length > 0,
)
await db.benutzer.delete({ where: { id: benutzer.id } })
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
@@ -177,6 +333,29 @@ async function main(): Promise<void> {
'Verknüpfte Konten verschwinden mit dem Benutzer',
(await db.konto.count({ where: { providerAccountId: kennung } })) === 0,
)
pruefe(
'Kalenderkonten verschwinden mit dem Benutzer',
(await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0,
)
pruefe(
'Push-Geräte verschwinden mit dem Benutzer',
(await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0,
)
pruefe(
'Meldungsnachweise verschwinden mit dem Benutzer',
(await db.meldungsnachweis.count({
where: { schluessel: { contains: kennung } },
})) === 0,
)
pruefe(
'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine',
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,
)
pruefe(
'Der Bereich überlebt das Löschen seines Standard-Kalenderkontos',
(await db.bereich.findUnique({ where: { id: bereich.id } }))
?.standardKalenderkontoId === null,
)
// --- Aufgaben: Standardwerte und Inbox -----------------------------------
const aufgabe = await db.aufgabe.create({
+7
View File
@@ -14,6 +14,7 @@
*/
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import webpush from 'web-push'
const WURZEL = fileURLToPath(new URL('..', import.meta.url))
const PROJEKT = 'planer-screenshots'
@@ -22,10 +23,16 @@ const COMPOSE_DATEI = 'docker-compose.screenshots.yml'
const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455'
// Frisches Paar je Lauf: Die Bilder sollen die eingerichtete Anwendung
// zeigen, ohne dass irgendwo ein privater Schluessel liegen bleibt.
const VAPID = webpush.generateVAPIDKeys()
const UMGEBUNG = {
...process.env,
SCREENSHOT_APP_PORT: APP_PORT,
SCREENSHOT_DB_PORT: DB_PORT,
SCREENSHOT_VAPID_OEFFENTLICH: VAPID.publicKey,
SCREENSHOT_VAPID_PRIVAT: VAPID.privateKey,
}
function meldung(text: string): void {
@@ -0,0 +1,448 @@
'use client'
import { useEffect, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Bell, BellOff, Loader2, Send, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import {
ANLAESSE,
ANLASS_BESCHREIBUNG,
ANLASS_NAME,
type Anlass,
} from '@/lib/benachrichtigung'
import {
einstellungSpeichern,
geraetEntfernen,
probeSenden,
type Rueckmeldung,
} from '@/server/aktionen/benachrichtigungen'
export type GeraetAnzeige = {
id: string
bezeichnung: string | null
erstelltAm: string
zuletztBenutzt: string | null
/** Dieses Gerät ist der Browser, in dem die Seite gerade offen ist. */
diesesGeraet?: boolean
}
export type EinstellungAnzeige = {
anlaesse: Anlass[]
perPush: boolean
perMail: boolean
ueberblickUm: string
ruheVon: string
ruheBis: string
ruheAmWochenende: boolean
zeitblockVorlaufMinuten: number
}
type Eigenschaften = {
einstellung: EinstellungAnzeige
geraete: GeraetAnzeige[]
pushMoeglich: boolean
mailMoeglich: boolean
}
/**
* Wandelt den base64url-Schlüssel in die Form, die der Browser erwartet.
*
* Der Puffer wird ausdrücklich angelegt, damit der Typ `Uint8Array<ArrayBuffer>`
* lautet — `pushManager.subscribe` nimmt keinen geteilten Puffer an.
*/
function schluesselAlsBytes(base64url: string): Uint8Array<ArrayBuffer> {
const fuellung = '='.repeat((4 - (base64url.length % 4)) % 4)
const base64 = (base64url + fuellung).replace(/-/g, '+').replace(/_/g, '/')
const roh = atob(base64)
const bytes = new Uint8Array(new ArrayBuffer(roh.length))
for (let i = 0; i < roh.length; i += 1) bytes[i] = roh.charCodeAt(i)
return bytes
}
export function Benachrichtigungen({
einstellung,
geraete,
pushMoeglich,
mailMoeglich,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [felder, setFelder] = useState<Record<string, string>>({})
const [pushZustand, setPushZustand] = useState<
'unbekannt' | 'nicht-unterstuetzt' | 'aus' | 'an' | 'verweigert'
>('unbekannt')
const [pushLaeuft, setPushLaeuft] = useState(false)
useEffect(() => {
let abgebrochen = false
async function zustandPruefen() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
if (!abgebrochen) setPushZustand('nicht-unterstuetzt')
return
}
if (Notification.permission === 'denied') {
if (!abgebrochen) setPushZustand('verweigert')
return
}
const registrierung = await navigator.serviceWorker.getRegistration()
const abo = await registrierung?.pushManager.getSubscription()
if (!abgebrochen) setPushZustand(abo ? 'an' : 'aus')
}
void zustandPruefen()
return () => {
abgebrochen = true
}
}, [])
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
setFelder({})
toast.success(antwort.meldung)
router.refresh()
} else {
setFelder(antwort.felder ?? {})
toast.error(antwort.fehler)
}
})
}
async function pushAnmelden() {
setPushLaeuft(true)
try {
const erlaubnis = await Notification.requestPermission()
if (erlaubnis !== 'granted') {
setPushZustand(erlaubnis === 'denied' ? 'verweigert' : 'aus')
toast.error('Ohne Erlaubnis des Browsers geht es nicht.')
return
}
const antwort = await fetch('/api/push')
const daten = (await antwort.json()) as {
aktiv: boolean
oeffentlicherSchluessel: string | null
}
if (!daten.aktiv || !daten.oeffentlicherSchluessel) {
toast.error('Push ist auf diesem Server nicht eingerichtet.')
return
}
const registrierung = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const abo = await registrierung.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: schluesselAlsBytes(daten.oeffentlicherSchluessel),
})
const roh = abo.toJSON() as { endpoint?: string; keys?: Record<string, string> }
const gespeichert = await fetch('/api/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpunkt: roh.endpoint,
p256dh: roh.keys?.p256dh,
auth: roh.keys?.auth,
bezeichnung: geraetename(),
}),
})
if (!gespeichert.ok) {
const fehler = (await gespeichert.json()) as { fehler?: string }
toast.error(fehler.fehler ?? 'Die Anmeldung ließ sich nicht speichern.')
return
}
setPushZustand('an')
toast.success('Dieses Gerät empfängt jetzt Benachrichtigungen.')
router.refresh()
} catch {
toast.error('Die Anmeldung hat nicht geklappt.')
} finally {
setPushLaeuft(false)
}
}
async function pushAbmelden() {
setPushLaeuft(true)
try {
const registrierung = await navigator.serviceWorker.getRegistration()
const abo = await registrierung?.pushManager.getSubscription()
if (abo) {
await fetch('/api/push', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpunkt: abo.endpoint }),
})
await abo.unsubscribe()
}
setPushZustand('aus')
toast.success('Dieses Gerät empfängt keine Benachrichtigungen mehr.')
router.refresh()
} catch {
toast.error('Das Abmelden hat nicht geklappt.')
} finally {
setPushLaeuft(false)
}
}
return (
<div className="flex flex-col gap-6">
{/* Dieses Gerät */}
<div className="border-rand flex flex-wrap items-center gap-3 rounded-md border p-3">
{pushZustand === 'an' ? (
<Bell className="text-erfolg size-5 shrink-0" aria-hidden />
) : (
<BellOff className="text-gedaempft-vordergrund size-5 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Dieses Gerät</p>
<p className="text-gedaempft-vordergrund text-xs">
{/*
Fehlen die Schlüssel auf dem Server, geht hier ohnehin nichts –
dann über den Browser zu reden, schickt nur auf die falsche
Fährte.
*/}
{!pushMoeglich
? 'Ohne Push-Schlüssel auf dem Server empfängt kein Gerät etwas.'
: pushZustand === 'nicht-unterstuetzt'
? 'Dieser Browser kann keine Push-Nachrichten empfangen.'
: pushZustand === 'verweigert'
? 'Der Browser hat Benachrichtigungen blockiert. Das lässt sich nur in seinen Einstellungen ändern.'
: pushZustand === 'an'
? 'Empfängt Push-Nachrichten.'
: pushZustand === 'aus'
? 'Empfängt keine Push-Nachrichten.'
: '…'}
</p>
</div>
{!pushMoeglich ? (
<Kennzeichen art="warnung">Auf dem Server nicht eingerichtet</Kennzeichen>
) : pushZustand === 'an' ? (
<Button art="umriss" disabled={pushLaeuft} onClick={() => void pushAbmelden()}>
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
Abmelden
</Button>
) : (
pushZustand !== 'nicht-unterstuetzt' &&
pushZustand !== 'verweigert' && (
<Button disabled={pushLaeuft} onClick={() => void pushAnmelden()}>
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
Anmelden
</Button>
)
)}
</div>
{/* Einstellungen */}
<form
className="flex flex-col gap-5"
onSubmit={(ereignis) => {
ereignis.preventDefault()
const daten = new FormData(ereignis.currentTarget)
mitRueckmeldung(() => einstellungSpeichern(daten))
}}
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Worüber</legend>
{ANLAESSE.map((anlass) => (
<label key={anlass} className="flex items-start gap-3">
<input
type="checkbox"
name="anlaesse"
value={anlass}
defaultChecked={einstellung.anlaesse.includes(anlass)}
className="accent-betont mt-0.5 size-4"
/>
<span>
<span className="text-sm">{ANLASS_NAME[anlass]}</span>
<span className="text-gedaempft-vordergrund block text-xs">
{ANLASS_BESCHREIBUNG[anlass]}
</span>
</span>
</label>
))}
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Wie</legend>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perPush"
defaultChecked={einstellung.perPush}
className="accent-betont size-4"
/>
<span className="text-sm">
Push an angemeldete Geräte
{!pushMoeglich && (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
)}
</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perMail"
defaultChecked={einstellung.perMail}
className="accent-betont size-4"
/>
<span className="text-sm">
E-Mail
{!mailMoeglich && (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
)}
</span>
</label>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="ueberblickUm">Tagesüberblick um</Label>
<Input
id="ueberblickUm"
name="ueberblickUm"
type="time"
defaultValue={einstellung.ueberblickUm}
aria-invalid={Boolean(felder.ueberblickUm)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="zeitblockVorlaufMinuten">
Vorlauf vor einem Zeitblock (Minuten)
</Label>
<Input
id="zeitblockVorlaufMinuten"
name="zeitblockVorlaufMinuten"
type="number"
min={0}
max={120}
step={5}
defaultValue={einstellung.zeitblockVorlaufMinuten}
aria-invalid={Boolean(felder.zeitblockVorlaufMinuten)}
/>
{felder.zeitblockVorlaufMinuten && (
<p className="text-gefahr text-xs">{felder.zeitblockVorlaufMinuten}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="ruheVon">Ruhe von</Label>
<Input
id="ruheVon"
name="ruheVon"
type="time"
defaultValue={einstellung.ruheVon}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="ruheBis">bis</Label>
<Input
id="ruheBis"
name="ruheBis"
type="time"
defaultValue={einstellung.ruheBis}
/>
</div>
</div>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="ruheAmWochenende"
defaultChecked={einstellung.ruheAmWochenende}
className="accent-betont size-4"
/>
<span className="text-sm">Am Wochenende gar nichts melden</span>
</label>
<p className="text-gedaempft-vordergrund text-xs">
In der Ruhezeit bleibt alles still — bis auf den Tagesüberblick. Der käme sonst
nie, wenn du früh aufstehst.
</p>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
Speichern
</Button>
<Button
type="button"
art="umriss"
disabled={laeuft}
onClick={() => mitRueckmeldung(() => probeSenden())}
>
<Send aria-hidden />
Probe schicken
</Button>
</div>
</form>
{/* Angemeldete Geräte */}
{geraete.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Angemeldete Geräte</h3>
<ul className="divide-rand divide-y">
{geraete.map((geraet) => (
<li key={geraet.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="text-sm">{geraet.bezeichnung ?? 'Unbenanntes Gerät'}</p>
<p className="text-gedaempft-vordergrund text-xs">
Angemeldet {geraet.erstelltAm}
{geraet.zuletztBenutzt
? ` · zuletzt benachrichtigt ${geraet.zuletztBenutzt}`
: ' · noch nichts empfangen'}
</p>
</div>
<Button
art="leise"
groesse="quadrat"
title={`${geraet.bezeichnung ?? 'Gerät'} abmelden`}
disabled={laeuft}
onClick={() => {
const daten = new FormData()
daten.set('id', geraet.id)
mitRueckmeldung(() => geraetEntfernen(daten))
}}
>
<Trash2 aria-hidden />
<span className="sr-only">Abmelden</span>
</Button>
</li>
))}
</ul>
</div>
)}
</div>
)
}
/** Grobe Kennung des Browsers, damit sich Geräte auseinanderhalten lassen. */
function geraetename(): string {
const kennung = navigator.userAgent
if (/Android/i.test(kennung)) return 'Android-Gerät'
if (/iPhone|iPad/i.test(kennung)) return 'iPhone oder iPad'
if (/Macintosh/i.test(kennung)) return 'Mac'
if (/Windows/i.test(kennung)) return 'Windows-Rechner'
if (/Linux/i.test(kennung)) return 'Linux-Rechner'
return 'Unbekanntes Gerät'
}
+66
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import { Benachrichtigungen } from '@/app/(app)/einstellungen/benachrichtigungen'
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
import {
Card,
@@ -8,6 +9,9 @@ import {
CardTitel,
} from '@/components/ui/card'
import { ROLLEN_NAME } from '@/lib/rollen'
import { alsEinstellung } from '@/server/benachrichtigung/versand'
import { alsUhrzeit } from '@/lib/zeit'
import { umgebung } from '@/server/umgebung'
import { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
@@ -17,6 +21,35 @@ export const metadata: Metadata = { title: 'Einstellungen' }
export default async function EinstellungenSeite() {
const benutzer = await verlangeAnmeldung('/einstellungen')
const einst = umgebung()
const [benachrichtigung, pushGeraete] = await Promise.all([
db.benachrichtigungseinstellung.findUnique({
where: { benutzerId: benutzer.id },
select: {
anlaesse: true,
perPush: true,
perMail: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
ruheAmWochenende: true,
zeitblockVorlaufMinuten: true,
},
}),
db.pushGeraet.findMany({
where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' },
select: {
id: true,
bezeichnung: true,
erstelltAm: true,
zuletztBenutzt: true,
},
}),
])
const einstellung = alsEinstellung(benachrichtigung)
const passkeys = await db.passkey.findMany({
where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' },
@@ -72,6 +105,39 @@ export default async function EinstellungenSeite() {
/>
</CardInhalt>
</Card>
<Card data-bild="benachrichtigungen">
<CardKopf>
<CardTitel className="text-base">Benachrichtigungen</CardTitel>
<CardBeschreibung>
Ab Werk meldet der Planer gar nichts. Was du hier einschaltest, kommt — alles
andere nicht. Eine Anwendung, die zu oft meldet, wird stummgeschaltet und ist
dann wertlos.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<Benachrichtigungen
pushMoeglich={einst.pushAktiv}
mailMoeglich={einst.mailAktiv}
einstellung={{
anlaesse: [...einstellung.anlaesse],
perPush: einstellung.perPush,
perMail: einstellung.perMail,
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
ruheVon: alsUhrzeit(einstellung.ruheVon),
ruheBis: alsUhrzeit(einstellung.ruheBis),
ruheAmWochenende: einstellung.ruheAmWochenende,
zeitblockVorlaufMinuten: einstellung.zeitblockVorlaufMinuten,
}}
geraete={pushGeraete.map((g) => ({
id: g.id,
bezeichnung: g.bezeichnung,
erstelltAm: alsDatumUhrzeit(g.erstelltAm),
zuletztBenutzt: g.zuletztBenutzt ? alsDatumUhrzeit(g.zuletztBenutzt) : null,
}))}
/>
</CardInhalt>
</Card>
</div>
)
}
@@ -0,0 +1,149 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2, RefreshCw, Unplug } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import {
jetztAbgleichen,
kontoTrennen,
kontoUmbenennen,
type Rueckmeldung,
} from '@/server/aktionen/kalenderkonten'
export type KontoAnzeige = {
id: string
anbieter: 'GRAPH' | 'GOOGLE'
anbieterName: string
anzeigename: string
extKontoId: string
termine: number
letzterAbgleich: string | null
letzterFehler: string | null
}
export function KontoKarte({ konto }: { konto: KontoAnzeige }) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [name, setName] = useState(konto.anzeigename)
const [trennenBestaetigt, setTrennenBestaetigt] = useState(false)
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
toast.success(antwort.meldung)
router.refresh()
} else {
toast.error(antwort.fehler)
}
})
}
function felder(werte: Record<string, string>): FormData {
const daten = new FormData()
for (const [schluessel, wert] of Object.entries(werte)) daten.set(schluessel, wert)
return daten
}
return (
<Card>
<CardKopf className="flex-row flex-wrap items-center gap-2">
<CardTitel className="text-base">{konto.anzeigename}</CardTitel>
<Kennzeichen>{konto.anbieterName}</Kennzeichen>
{konto.letzterFehler && <Kennzeichen art="gefahr">Fehler</Kennzeichen>}
</CardKopf>
<CardInhalt className="flex flex-col gap-4">
<dl className="grid gap-1 text-sm sm:grid-cols-[10rem_1fr]">
<dt className="text-gedaempft-vordergrund">Konto</dt>
<dd>{konto.extKontoId}</dd>
<dt className="text-gedaempft-vordergrund">Gespiegelte Termine</dt>
<dd>{konto.termine}</dd>
<dt className="text-gedaempft-vordergrund">Letzter Abgleich</dt>
<dd>{konto.letzterAbgleich ?? 'noch nie'}</dd>
</dl>
{konto.letzterFehler && (
<p className="bg-gefahr/10 text-gefahr rounded-md px-3 py-2 text-sm">
{konto.letzterFehler}
</p>
)}
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`name-${konto.id}`}
className="text-gedaempft-vordergrund text-xs"
>
Bezeichnung
</label>
<Input
id={`name-${konto.id}`}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={60}
disabled={laeuft}
/>
</div>
<Button
art="umriss"
disabled={laeuft || name.trim() === konto.anzeigename}
onClick={() =>
mitRueckmeldung(() =>
kontoUmbenennen(felder({ id: konto.id, anzeigename: name })),
)
}
>
Speichern
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
art="umriss"
disabled={laeuft}
onClick={() =>
mitRueckmeldung(() => jetztAbgleichen(felder({ id: konto.id })))
}
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<RefreshCw aria-hidden />
)}
Jetzt abgleichen
</Button>
<Button
art="gefahr"
className="ml-auto"
disabled={laeuft}
onBlur={() => setTrennenBestaetigt(false)}
onClick={() => {
if (!trennenBestaetigt) {
setTrennenBestaetigt(true)
return
}
mitRueckmeldung(() => kontoTrennen(felder({ id: konto.id })))
}}
>
<Unplug aria-hidden />
{trennenBestaetigt ? 'Wirklich trennen?' : 'Trennen'}
</Button>
</div>
{trennenBestaetigt && (
<p className="text-gedaempft-vordergrund text-xs">
Die gespiegelten Termine verschwinden dabei. Im Kalender selbst bleibt alles
unangetastet.
</p>
)}
</CardInhalt>
</Card>
)
}
+33
View File
@@ -0,0 +1,33 @@
'use client'
import { useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
/**
* Zeigt die Rückmeldung aus der Adresszeile, die die OAuth-Rückleitung dort
* hinterlassen hat, und räumt sie danach weg — sonst erschiene sie bei jedem
* Neuladen erneut.
*/
export function Meldung({
ok,
fehler,
}: {
ok?: string | undefined
fehler?: string | undefined
}) {
const router = useRouter()
const gezeigt = useRef(false)
useEffect(() => {
if (gezeigt.current) return
if (!ok && !fehler) return
gezeigt.current = true
if (ok) toast.success(ok)
if (fehler) toast.error(fehler)
router.replace('/kalenderkonten')
}, [ok, fehler, router])
return null
}
+140
View File
@@ -0,0 +1,140 @@
import type { Metadata } from 'next'
import { CalendarDays, Plus } from 'lucide-react'
import { KontoKarte } from '@/app/(app)/kalenderkonten/konto-karte'
import { Meldung } from '@/app/(app)/kalenderkonten/meldung'
import { Button } from '@/components/ui/button'
import {
Card,
CardBeschreibung,
CardInhalt,
CardKopf,
CardTitel,
} from '@/components/ui/card'
import type { KalenderAnbieter } from '@/generated/prisma/enums'
import { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
import { erlaubteKalenderAnbieter } from '@/server/instanz'
import { umgebung } from '@/server/umgebung'
export const metadata: Metadata = { title: 'Kalenderkonten' }
const ANBIETER_NAME: Record<KalenderAnbieter, string> = {
GRAPH: 'Microsoft 365',
GOOGLE: 'Google',
}
export default async function KalenderkontenSeite({
searchParams,
}: {
searchParams: Promise<{ ok?: string; fehler?: string }>
}) {
const benutzer = await verlangeAnmeldung('/kalenderkonten')
const parameter = await searchParams
const einst = umgebung()
// Welche Anbieter es hier überhaupt gibt, entscheidet die Instanz; ob sie
// benutzbar sind, hängt zusätzlich an der Konfiguration.
const erlaubt = erlaubteKalenderAnbieter()
const verfuegbar = erlaubt.filter((anbieter) =>
anbieter === 'GRAPH' ? einst.graphAktiv : einst.googleAktiv,
)
const fehlendKonfiguriert = erlaubt.filter((a) => !verfuegbar.includes(a))
const konten = await db.kalenderkonto.findMany({
where: { benutzerId: benutzer.id },
orderBy: [{ anbieter: 'asc' }, { anzeigename: 'asc' }],
select: {
id: true,
anbieter: true,
anzeigename: true,
extKontoId: true,
letzterAbgleich: true,
letzterFehler: true,
_count: { select: { termine: true } },
},
})
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<CalendarDays className="size-6" aria-hidden />
Kalenderkonten
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Der Planer liest die Termine, um zu wissen, wann überhaupt Platz ist. Ab M4
schreibt er Zeitblöcke in denselben Kalender zurück.
</p>
</header>
<Meldung ok={parameter.ok} fehler={parameter.fehler} />
<Card>
<CardKopf>
<CardTitel className="text-base">Kalender verbinden</CardTitel>
<CardBeschreibung>
Du wirst zum Anbieter geschickt und kommst nach der Zustimmung hierher zurück.
Der Planer speichert keine Zugangsdaten im Klartext.
</CardBeschreibung>
</CardKopf>
<CardInhalt className="flex flex-col gap-3">
{verfuegbar.length === 0 ? (
<p className="text-gedaempft-vordergrund text-sm">
In dieser Instanz ist kein Kalenderanbieter eingerichtet.
</p>
) : (
<div className="flex flex-wrap gap-2">
{verfuegbar.map((anbieter) => (
<Button key={anbieter} alsKind>
<a href={`/api/kalender/verbinden/${anbieter.toLowerCase()}`}>
<Plus aria-hidden />
{ANBIETER_NAME[anbieter]} verbinden
</a>
</Button>
))}
</div>
)}
{fehlendKonfiguriert.length > 0 && (
<p className="text-gedaempft-vordergrund text-xs">
{fehlendKonfiguriert.map((a) => ANBIETER_NAME[a]).join(' und ')} wäre hier
erlaubt, ist aber noch nicht konfiguriert. Siehe README, Abschnitt
Umgebungsvariablen.
</p>
)}
</CardInhalt>
</Card>
<section className="flex flex-col gap-4">
<h2 className="text-lg font-medium">Verbundene Konten</h2>
{konten.length === 0 ? (
<Card>
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
Noch kein Kalender verbunden.
</CardInhalt>
</Card>
) : (
konten.map((konto) => (
<KontoKarte
key={konto.id}
konto={{
id: konto.id,
anbieter: konto.anbieter,
anbieterName: ANBIETER_NAME[konto.anbieter],
anzeigename: konto.anzeigename,
extKontoId: konto.extKontoId,
termine: konto._count.termine,
letzterAbgleich: konto.letzterAbgleich
? alsDatumUhrzeit(konto.letzterAbgleich)
: null,
letzterFehler: konto.letzterFehler,
}}
/>
))
)}
</section>
</div>
)
}
+38 -12
View File
@@ -1,6 +1,7 @@
import Link from 'next/link'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
import {
Card,
CardBeschreibung,
@@ -16,6 +17,7 @@ import {
projekteZurAuswahlLaden,
zaehlstaende,
} from '@/server/abfragen/aufgaben'
import { belegungHeute, hatKalenderkonto } from '@/server/abfragen/kalender'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber'
@@ -24,16 +26,19 @@ export default async function StartSeite() {
const benutzer = await verlangeAnmeldung('/')
const heute = jetzt()
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe] = await Promise.all([
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
bereicheLaden(),
projekteZurAuswahlLaden(),
zaehlstaende(),
db.bereich.findMany({
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
}),
])
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] =
await Promise.all([
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
bereicheLaden(),
projekteZurAuswahlLaden(),
zaehlstaende(),
db.bereich.findMany({
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
}),
belegungHeute(benutzer.id, heute),
hatKalenderkonto(benutzer.id),
])
// Was jetzt zählt: überfällig, heute oder morgen fällig.
const dran = alleOffenen.filter(
@@ -108,9 +113,10 @@ export default async function StartSeite() {
<ul className="grid gap-4 sm:grid-cols-2">
{bereichsListe.map((bereich) => {
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
const heuteHier = belegung.find((b) => b.id === bereich.id)?.belegung
return (
<li key={bereich.id}>
<Card>
<Card className="h-full">
<CardKopf className="flex-row items-center gap-3">
<span
aria-hidden
@@ -119,13 +125,33 @@ export default async function StartSeite() {
/>
<CardTitel className="text-base">{bereich.name}</CardTitel>
</CardKopf>
<CardInhalt>
<CardInhalt className="flex flex-col gap-2">
<p className="text-gedaempft-vordergrund text-sm">
{offeneHier.length === 0
? 'Nichts offen'
: `${offeneHier.length} offen`}{' '}
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
</p>
{/*
Ohne verbundenen Kalender wäre die Belegung immer null –
das sähe nach "alles frei" aus und wäre irreführend.
*/}
{mitKalender && heuteHier && (
<>
<Fortschrittsbalken
prozent={heuteHier.prozent}
beschriftung={`Belegung heute in ${bereich.name}`}
/>
<p className="text-gedaempft-vordergrund text-xs">
{heuteHier.kapazitaet === 0
? 'Heute nicht eingeplant'
: heuteHier.ueberbucht
? `Heute überbucht: ${alsDauer(heuteHier.belegt)} Termine bei ${alsDauer(heuteHier.kapazitaet)} Kapazität`
: `Heute noch ${alsDauer(heuteHier.frei)} frei von ${alsDauer(heuteHier.kapazitaet)}`}
</p>
</>
)}
</CardInhalt>
</Card>
</li>
+213
View File
@@ -0,0 +1,213 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { CalendarRange } from 'lucide-react'
import { Wochenraster } from '@/app/(app)/woche/wochenraster'
import { Card, CardInhalt } from '@/components/ui/card'
import { faelligkeitEinordnen, faelligkeitText, istUeberfaellig } from '@/lib/aufgabe'
import {
belegteMinuten,
arbeitsfensterAmTag,
kapazitaetAmTag,
} from '@/lib/kalender/belegung'
import { fensterHuelle, rasterBauen } from '@/lib/woche'
import { alsDatum, gleicherTag, tagesBeginn } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
import { planbareAufgabenLaden, wocheLaden } from '@/server/abfragen/woche'
import { jetzt } from '@/server/zeitgeber'
export const metadata: Metadata = { title: 'Woche' }
const WOCHENTAGE = [
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
'Sonntag',
]
export default async function WocheSeite({
searchParams,
}: {
searchParams: Promise<{ versatz?: string }>
}) {
const benutzer = await verlangeAnmeldung('/woche')
const parameter = await searchParams
// Über den Versatz lässt sich blättern; er steht in der Adresse, damit eine
// bestimmte Woche als Lesezeichen taugt.
const versatz = Number.parseInt(parameter.versatz ?? '0', 10)
const sichereWochen = Number.isFinite(versatz)
? Math.max(-52, Math.min(52, versatz))
: 0
const heute = jetzt()
const zeitpunkt = new Date(heute.getTime() + sichereWochen * 7 * 86_400_000)
const [daten, aufgaben, bereiche, konto] = await Promise.all([
wocheLaden(benutzer.id, zeitpunkt),
planbareAufgabenLaden(),
db.bereich.findMany({
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
}),
// Für den ersten Wurf: der Zeitblock landet im ersten verbundenen
// Kalender. Die Auswahl je Bereich kommt, sobald es mehrere gibt.
db.kalenderkonto.findFirst({
where: { benutzerId: benutzer.id, aktiv: true },
orderBy: { erstelltAm: 'asc' },
select: { id: true, anzeigename: true },
}),
])
const huelle = fensterHuelle(bereiche)
const raster = rasterBauen(zeitpunkt, huelle.von, huelle.bis)
// Kapazität und Belegung je Tag: alle Bereiche zusammengenommen, denn ein
// Termin blockiert die Zeit für alles gleichermaßen.
const spalten = raster.spalten.map((spalte) => {
const tag = spalte.tagesBeginn
const kapazitaet = bereiche.reduce((summe, b) => summe + kapazitaetAmTag(b, tag), 0)
const belegend = [
...daten.termine.map((t) => ({
beginn: t.beginn,
ende: t.ende,
ganztaegig: t.ganztaegig,
zeigtAls: t.zeigtAls,
})),
...daten.zeitbloecke.map((z) => ({
beginn: z.beginn,
ende: z.ende,
ganztaegig: false,
zeigtAls: 'GEBUCHT' as const,
})),
]
// Gegen die Hülle rechnen, nicht gegen ein einzelnes Arbeitsfenster:
// die Tageskapazität ist hier die Summe über alle Bereiche.
const fenster = arbeitsfensterAmTag(tag, huelle.von, huelle.bis)
const belegt = belegteMinuten(belegend, fenster)
return {
schluessel: spalte.schluessel,
tagesBeginnMs: spalte.tagesBeginn.getTime(),
name: WOCHENTAGE[spalte.wochentag] ?? '',
datum: alsDatum(tag).split(', ')[1] ?? '',
heute: gleicherTag(tag, heute),
kapazitaet,
belegt,
ueberbucht: kapazitaet > 0 && belegt > kapazitaet,
}
})
const montag = tagesBeginn(raster.spalten[0]?.tagesBeginn ?? zeitpunkt)
const sonntag = raster.spalten[6]?.tagesBeginn ?? zeitpunkt
return (
/*
Die Wochenansicht braucht mehr Breite als der Inhaltsrahmen von 64rem
hergibt – bei sieben Spalten plus Aufgabenliste wäre sonst der Sonntag
abgeschnitten. Deshalb bricht genau diese Seite aus und nimmt bis zu
100rem, mittig und mit demselben Seitenabstand wie überall.
*/
<div className="relative left-1/2 w-screen max-w-[100rem] -translate-x-1/2 px-4 sm:px-6">
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<CalendarRange className="size-6" aria-hidden />
Woche
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
{alsDatum(montag)} bis {alsDatum(sonntag)}
{konto ? ` · schreibt nach ${konto.anzeigename}` : ''}
</p>
</div>
<nav className="flex items-center gap-2" aria-label="Woche wechseln">
<Link
href={`/woche?versatz=${sichereWochen - 1}`}
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Zurück
</Link>
<Link
href="/woche"
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Diese Woche
</Link>
<Link
href={`/woche?versatz=${sichereWochen + 1}`}
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Weiter
</Link>
</nav>
</header>
{!konto && (
<Card>
<CardInhalt className="py-4 text-sm">
Noch kein Kalender verbunden. Zeitblöcke landen erst dann im Kalender —{' '}
<Link
href="/kalenderkonten"
className="text-betont underline underline-offset-4"
>
jetzt verbinden
</Link>
.
</CardInhalt>
</Card>
)}
<Wochenraster
spalten={spalten}
zeilen={raster.zeilen}
vonMinute={raster.vonMinute}
bisMinute={raster.bisMinute}
kalenderkontoId={konto?.id ?? null}
zeitbloecke={daten.zeitbloecke.map((z) => ({
id: z.id,
beginnMs: z.beginn.getTime(),
endeMs: z.ende.getTime(),
titel: z.titel,
bereichFarbe: z.bereichFarbe,
kalenderName: z.kalenderName,
letzterFehler: z.letzterFehler,
offen: z.offen,
}))}
termine={daten.termine.map((t) => ({
id: t.id,
beginnMs: t.beginn.getTime(),
endeMs: t.ende.getTime(),
betreff: t.betreff,
ganztaegig: t.ganztaegig,
frei: t.zeigtAls === 'FREI',
kalenderName: t.kalenderName,
}))}
aufgaben={aufgaben.map((a) => ({
id: a.id,
titel: a.titel,
aufwandMinuten: a.aufwandMinuten,
bereichName: a.bereich?.name ?? null,
bereichFarbe: a.bereich?.farbe ?? null,
faelligText: a.faelligAm
? faelligkeitText(faelligkeitEinordnen(a.faelligAm, heute))
: null,
ueberfaellig: istUeberfaellig(a.faelligAm, a.status, heute),
bereitsGeplant: a._count.zeitbloecke,
}))}
/>
<p className="text-gedaempft-vordergrund text-xs">
Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein
Zeitblock lässt sich am Griff verschieben; das Kreuz plant ihn wieder aus. Graue
Balken sind Termine aus dem Kalender — sie lassen sich hier nicht verschieben.
</p>
</div>
</div>
)
}
+530
View File
@@ -0,0 +1,530 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import {
DndContext,
DragOverlay,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core'
import { AlertTriangle, CalendarClock, GripVertical, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import {
ZEILE_HOEHE,
balkenlage,
spurenVerteilen,
zeilenBeschriftung,
zeitpunktAus,
type Raster,
} from '@/lib/woche'
import { alsDauer, alsUhrzeitAnzeige } from '@/lib/zeit'
import { cn } from '@/lib/utils'
import {
zeitblockAnlegen,
zeitblockLoesen,
zeitblockVerschieben,
type Rueckmeldung,
} from '@/server/aktionen/zeitbloecke'
export type PlanbareAufgabe = {
id: string
titel: string
aufwandMinuten: number | null
bereichName: string | null
bereichFarbe: string | null
faelligText: string | null
ueberfaellig: boolean
bereitsGeplant: number
}
export type RasterZeitblock = {
id: string
beginnMs: number
endeMs: number
titel: string
bereichFarbe: string | null
kalenderName: string
letzterFehler: string | null
offen: boolean
}
export type RasterTermin = {
id: string
beginnMs: number
endeMs: number
betreff: string | null
ganztaegig: boolean
frei: boolean
kalenderName: string
}
export type RasterSpalte = {
schluessel: string
tagesBeginnMs: number
name: string
datum: string
heute: boolean
kapazitaet: number
belegt: number
ueberbucht: boolean
}
type Eigenschaften = {
spalten: RasterSpalte[]
zeilen: number[]
vonMinute: number
bisMinute: number
zeitbloecke: RasterZeitblock[]
termine: RasterTermin[]
aufgaben: PlanbareAufgabe[]
kalenderkontoId: string | null
}
/** Standarddauer, wenn eine Aufgabe keinen geschätzten Aufwand trägt. */
const STANDARD_MINUTEN = 60
export function Wochenraster({
spalten,
zeilen,
vonMinute,
bisMinute,
zeitbloecke,
termine,
aufgaben,
kalenderkontoId,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [gezogen, setGezogen] = useState<string | null>(null)
const sensoren = useSensors(
// Erst ab acht Pixeln gilt es als Ziehen – sonst löst jeder Klick einen
// Zug aus und Knöpfe im Block wären nicht mehr bedienbar.
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
)
const raster: Raster = {
spalten: spalten.map((s) => ({
tagesBeginn: new Date(s.tagesBeginnMs),
wochentag: 0,
schluessel: s.schluessel,
})),
zeilen,
vonMinute,
bisMinute,
}
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) toast.success(antwort.meldung)
else toast.error(antwort.fehler)
router.refresh()
})
}
function beimStart(ereignis: DragStartEvent) {
setGezogen(String(ereignis.active.id))
}
function beimEnde(ereignis: DragEndEvent) {
setGezogen(null)
const ziel = ereignis.over?.id
if (!ziel) return
const [schluessel, minuteRoh] = String(ziel).split('@')
if (!schluessel || minuteRoh === undefined) return
const beginn = zeitpunktAus(schluessel, Number(minuteRoh))
const kennung = String(ereignis.active.id)
if (kennung.startsWith('aufgabe:')) {
if (!kalenderkontoId) {
toast.error('Erst einen Kalender verbinden, dann lässt sich einplanen.')
return
}
const aufgabeId = kennung.slice('aufgabe:'.length)
const aufgabe = aufgaben.find((a) => a.id === aufgabeId)
const dauer = aufgabe?.aufwandMinuten ?? STANDARD_MINUTEN
const ende = new Date(beginn.getTime() + dauer * 60_000)
const daten = new FormData()
daten.set('aufgabeId', aufgabeId)
daten.set('kalenderkontoId', kalenderkontoId)
daten.set('beginn', beginn.toISOString())
daten.set('ende', ende.toISOString())
mitRueckmeldung(() => zeitblockAnlegen(daten))
return
}
if (kennung.startsWith('block:')) {
const id = kennung.slice('block:'.length)
const block = zeitbloecke.find((z) => z.id === id)
if (!block) return
const dauer = block.endeMs - block.beginnMs
const ende = new Date(beginn.getTime() + dauer)
const daten = new FormData()
daten.set('id', id)
daten.set('beginn', beginn.toISOString())
daten.set('ende', ende.toISOString())
mitRueckmeldung(() => zeitblockVerschieben(daten))
}
}
function ausplanen(id: string) {
const daten = new FormData()
daten.set('id', id)
mitRueckmeldung(() => zeitblockLoesen(daten))
}
const gezogeneAufgabe = gezogen?.startsWith('aufgabe:')
? aufgaben.find((a) => a.id === gezogen.slice('aufgabe:'.length))
: null
const gezogenerBlock = gezogen?.startsWith('block:')
? zeitbloecke.find((z) => z.id === gezogen.slice('block:'.length))
: null
return (
<DndContext sensors={sensoren} onDragStart={beimStart} onDragEnd={beimEnde}>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Vorrat: was noch eingeplant werden kann */}
<aside className="lg:w-72 lg:shrink-0">
<h2 className="mb-2 text-sm font-medium">
Einzuplanen
<span className="text-gedaempft-vordergrund ml-2 font-normal">
{aufgaben.length}
</span>
</h2>
{!kalenderkontoId && (
<p className="bg-warnung/15 text-warnung mb-3 rounded-md px-3 py-2 text-xs">
Ohne verbundenen Kalender lässt sich nichts einplanen.
</p>
)}
<ul className="border-rand bg-karte max-h-[32rem] divide-y divide-[var(--rand)] overflow-y-auto rounded-xl border">
{aufgaben.length === 0 ? (
<li className="text-gedaempft-vordergrund p-4 text-center text-sm">
Nichts offen. Ein guter Tag.
</li>
) : (
aufgaben.map((aufgabe) => (
<AufgabenKachel key={aufgabe.id} aufgabe={aufgabe} gesperrt={laeuft} />
))
)}
</ul>
</aside>
{/* Das Wochenraster */}
<div className="min-w-0 flex-1 overflow-x-auto">
<div className="min-w-[46rem]">
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
<div />
{spalten.map((spalte) => (
<div
key={spalte.schluessel}
className={cn(
'border-rand border-b px-1 pb-2 text-center',
spalte.heute && 'bg-betont/5',
)}
>
<p className="text-sm font-medium">{spalte.name}</p>
<p className="text-gedaempft-vordergrund text-xs">{spalte.datum}</p>
<p
className={cn(
'mt-1 text-xs',
spalte.ueberbucht ? 'text-gefahr' : 'text-gedaempft-vordergrund',
)}
>
{spalte.kapazitaet === 0
? '—'
: `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`}
</p>
</div>
))}
</div>
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
{/* Stundenleiste */}
<div className="relative">
{zeilen.map((minute) => (
<div
key={minute}
style={{ height: ZEILE_HOEHE }}
className="text-gedaempft-vordergrund pr-2 text-right text-[0.6875rem] leading-none"
>
{zeilenBeschriftung(minute)}
</div>
))}
</div>
{spalten.map((spalte) => {
const blockeHier = zeitbloecke.filter(
(z) =>
z.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
z.endeMs > spalte.tagesBeginnMs,
)
const termineHier = termine.filter(
(t) =>
!t.ganztaegig &&
t.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
t.endeMs > spalte.tagesBeginnMs,
)
const verteilteTermine = spurenVerteilen(
termineHier.map((t) => ({
...t,
beginn: new Date(t.beginnMs),
ende: new Date(t.endeMs),
})),
)
return (
<div
key={spalte.schluessel}
className={cn(
'border-rand relative border-l',
spalte.heute && 'bg-betont/5',
)}
>
{zeilen.map((minute) => (
<Zielfeld
key={minute}
kennung={`${spalte.schluessel}@${minute}`}
volleStunde={minute % 60 === 0}
/>
))}
{/* Fremde Termine liegen hinten und lassen sich nicht ziehen */}
{verteilteTermine.map(({ eintrag, spur, spuren }) => {
const lage = balkenlage(
eintrag.beginn,
eintrag.ende,
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
raster,
)
if (lage.ausserhalb) return null
return (
<div
key={eintrag.id}
className={cn(
'pointer-events-none absolute rounded-sm px-1 text-[0.6875rem] leading-tight',
eintrag.frei
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
: 'bg-gedaempft text-gedaempft-vordergrund',
)}
style={{
top: lage.oben,
height: lage.hoehe,
left: `${(spur / spuren) * 100}%`,
width: `${100 / spuren}%`,
}}
title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
>
<span className="line-clamp-2">
{eintrag.betreff ?? 'Termin'}
</span>
</div>
)
})}
{/* Eigene Zeitblöcke liegen vorn und lassen sich ziehen */}
{blockeHier.map((block) => {
const lage = balkenlage(
new Date(block.beginnMs),
new Date(block.endeMs),
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
raster,
)
if (lage.ausserhalb) return null
return (
<Zeitblockbalken
key={block.id}
block={block}
oben={lage.oben}
hoehe={lage.hoehe}
gesperrt={laeuft}
onAusplanen={() => ausplanen(block.id)}
/>
)
})}
</div>
)
})}
</div>
</div>
</div>
</div>
<DragOverlay>
{gezogeneAufgabe && (
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
{gezogeneAufgabe.titel}
<span className="ml-1 opacity-80">
{alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
</span>
</div>
)}
{gezogenerBlock && (
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
{gezogenerBlock.titel}
</div>
)}
</DragOverlay>
</DndContext>
)
}
function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) {
const { setNodeRef, isOver } = useDroppable({ id: kennung })
return (
<div
ref={setNodeRef}
style={{ height: ZEILE_HOEHE }}
className={cn(
'border-rand/40',
volleStunde ? 'border-t' : 'border-t border-dashed',
isOver && 'bg-betont/20',
)}
/>
)
}
function AufgabenKachel({
aufgabe,
gesperrt,
}: {
aufgabe: PlanbareAufgabe
gesperrt: boolean
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `aufgabe:${aufgabe.id}`,
disabled: gesperrt,
})
return (
<li
ref={setNodeRef}
className={cn('flex items-start gap-2 p-2', isDragging && 'opacity-40')}
>
<button
type="button"
className="text-gedaempft-vordergrund mt-0.5 cursor-grab touch-none active:cursor-grabbing"
aria-label={`${aufgabe.titel} zum Einplanen ziehen`}
{...listeners}
{...attributes}
>
<GripVertical className="size-4" aria-hidden />
</button>
<div className="min-w-0 flex-1">
<p className="text-sm leading-tight">{aufgabe.titel}</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
{aufgabe.bereichName && (
<Kennzeichen>
<span
aria-hidden
className="size-2 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: aufgabe.bereichFarbe ?? undefined }}
/>
{aufgabe.bereichName}
</Kennzeichen>
)}
<Kennzeichen>
{alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
</Kennzeichen>
{aufgabe.faelligText && (
<Kennzeichen art={aufgabe.ueberfaellig ? 'gefahr' : 'neutral'}>
{aufgabe.faelligText}
</Kennzeichen>
)}
{aufgabe.bereitsGeplant > 0 && (
<Kennzeichen art="betont">
<CalendarClock className="size-3" aria-hidden />
{aufgabe.bereitsGeplant}
</Kennzeichen>
)}
</div>
</div>
</li>
)
}
function Zeitblockbalken({
block,
oben,
hoehe,
gesperrt,
onAusplanen,
}: {
block: RasterZeitblock
oben: number
hoehe: number
gesperrt: boolean
onAusplanen: () => void
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `block:${block.id}`,
disabled: gesperrt,
})
const farbe = block.bereichFarbe ?? 'var(--betont)'
return (
<div
ref={setNodeRef}
style={{
top: oben,
height: hoehe,
borderLeftColor: farbe,
backgroundColor: `color-mix(in oklab, ${farbe} 18%, var(--karte))`,
}}
className={cn(
'absolute right-0.5 left-0.5 overflow-hidden rounded-sm border-l-4 px-1 text-[0.6875rem] leading-tight',
isDragging && 'opacity-40',
)}
title={`${block.titel} · ${alsUhrzeitAnzeige(new Date(block.beginnMs))}–${alsUhrzeitAnzeige(new Date(block.endeMs))} · ${block.kalenderName}`}
>
<div className="flex items-start gap-0.5">
<button
type="button"
className="shrink-0 cursor-grab touch-none active:cursor-grabbing"
aria-label={`${block.titel} verschieben`}
{...listeners}
{...attributes}
>
<GripVertical className="size-3 opacity-60" aria-hidden />
</button>
<span className="line-clamp-3 flex-1 font-medium">{block.titel}</span>
{(block.offen || block.letzterFehler) && (
<AlertTriangle
className="text-warnung size-3 shrink-0"
aria-label="Noch nicht im Kalender"
/>
)}
<Button
art="leise"
className="size-4 shrink-0 p-0"
disabled={gesperrt}
title="Ausplanen"
onClick={onAusplanen}
>
<X className="size-3" aria-hidden />
<span className="sr-only">Ausplanen</span>
</Button>
</div>
</div>
)
}
@@ -0,0 +1,136 @@
import { NextResponse } from 'next/server'
import { notFound } from 'next/navigation'
import { auditSchreiben } from '@/server/audit'
import { db } from '@/server/db'
import { erzwingeFaehigkeit } from '@/server/instanz'
import { kontoAbgleichen } from '@/server/kalender/abgleich'
import { codeEinloesen } from '@/server/kalender/oauth'
import {
alsAnbieter,
faehigkeitFuer,
istAnbieter,
zustandLesen,
} from '@/server/kalender/verbinden'
import { verschluesseln } from '@/server/krypto'
import { umgebung } from '@/server/umgebung'
export const dynamic = 'force-dynamic'
function zurueck(nachricht: string, art: 'ok' | 'fehler'): NextResponse {
const ziel = new URL('/kalenderkonten', umgebung().APP_URL)
ziel.searchParams.set(art, nachricht)
return NextResponse.redirect(ziel)
}
/**
* Nimmt die Rückleitung des Anbieters entgegen und legt das Kalenderkonto an.
*
* Der Zustandswert ist signiert und trägt die Benutzerkennung. Ohne diese
* Prüfung ließe sich ein fremdes Kalenderkonto an ein beliebiges Konto hängen.
*/
export async function GET(
anfrage: Request,
{ params }: { params: Promise<{ anbieter: string }> },
) {
const { anbieter: roh } = await params
if (!istAnbieter(roh)) notFound()
const anbieter = alsAnbieter(roh)
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
const adresse = new URL(anfrage.url)
const fehlerAnbieter = adresse.searchParams.get('error')
if (fehlerAnbieter) {
// Meist "access_denied", weil die Zustimmung abgebrochen wurde.
return zurueck('Die Verbindung wurde abgebrochen.', 'fehler')
}
const zustand = zustandLesen(adresse.searchParams.get('state'))
if (!zustand || zustand.anbieter !== anbieter) {
return zurueck('Die Anfrage war ungültig oder zu alt. Bitte neu beginnen.', 'fehler')
}
const code = adresse.searchParams.get('code')
if (!code) {
return zurueck('Der Anbieter hat keinen Code zurückgegeben.', 'fehler')
}
const benutzer = await db.benutzer.findUnique({
where: { id: zustand.benutzerId },
select: { id: true, email: true, aktiv: true },
})
if (!benutzer?.aktiv) {
return zurueck('Das Konto ist nicht mehr aktiv.', 'fehler')
}
try {
const satz = await codeEinloesen(anbieter, code)
if (!satz.aktualisierungstoken) {
// Ohne das wäre die Verbindung nach einer Stunde tot.
return zurueck(
'Der Anbieter hat kein Aktualisierungstoken herausgegeben. Bitte die Zustimmung erneut erteilen.',
'fehler',
)
}
const extKontoId = satz.kennung ?? `${anbieter.toLowerCase()}-konto`
const kalenderId = anbieter === 'GOOGLE' ? 'primary' : 'standard'
const konto = await db.kalenderkonto.upsert({
where: {
benutzerId_anbieter_extKontoId_kalenderId: {
benutzerId: benutzer.id,
anbieter,
extKontoId,
kalenderId,
},
},
update: {
zugriffstoken: verschluesseln(satz.zugriffstoken),
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
tokenGueltigBis: satz.gueltigBis,
bereiche: satz.bereiche,
aktiv: true,
letzterFehler: null,
// Neu verbunden heißt: von vorn abgleichen.
deltaVerweis: null,
},
create: {
benutzerId: benutzer.id,
anbieter,
anzeigename:
satz.kennung ?? (anbieter === 'GRAPH' ? 'Microsoft 365' : 'Google Kalender'),
extKontoId,
kalenderId,
zugriffstoken: verschluesseln(satz.zugriffstoken),
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
tokenGueltigBis: satz.gueltigBis,
bereiche: satz.bereiche,
},
select: { id: true, anzeigename: true },
})
await auditSchreiben({
akteur: { id: benutzer.id, email: benutzer.email },
aktion: 'kalenderkonto.verbunden',
objektTyp: 'Kalenderkonto',
objektId: konto.id,
// Weder Tokens noch Termininhalte – nur, welches Konto verbunden wurde.
details: { anbieter, anzeigename: konto.anzeigename },
})
const bericht = await kontoAbgleichen(konto.id)
return zurueck(
bericht.ok
? `„${konto.anzeigename}" verbunden, ${bericht.geaendert} Termin(e) gelesen.`
: `„${konto.anzeigename}" verbunden, der erste Abgleich schlug aber fehl.`,
bericht.ok ? 'ok' : 'fehler',
)
} catch (fehler) {
const meldung =
fehler instanceof Error ? fehler.message : 'Die Verbindung ist fehlgeschlagen.'
return zurueck(meldung, 'fehler')
}
}
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server'
import { notFound } from 'next/navigation'
import { autorisierungsAdresse } from '@/server/kalender/oauth'
import {
alsAnbieter,
faehigkeitFuer,
istAnbieter,
zustandErzeugen,
} from '@/server/kalender/verbinden'
import { angemeldet } from '@/server/berechtigung'
import { erzwingeFaehigkeit } from '@/server/instanz'
export const dynamic = 'force-dynamic'
/**
* Startet das Verbinden eines Kalenderkontos und leitet zum Anbieter weiter.
*
* Der Riegel sitzt hier serverseitig: Google gibt es nur daheim. In der
* Firmeninstanz liefert diese Route 404 — nicht etwa einen Hinweis, dass es
* die Möglichkeit anderswo gäbe.
*/
export async function GET(
_anfrage: Request,
{ params }: { params: Promise<{ anbieter: string }> },
) {
const { anbieter: roh } = await params
if (!istAnbieter(roh)) notFound()
const anbieter = alsAnbieter(roh)
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
const benutzer = await angemeldet()
if (!benutzer) {
return NextResponse.redirect(
new URL('/anmelden?weiter=%2Fkalenderkonten', process.env.APP_URL ?? ''),
)
}
try {
const zustand = zustandErzeugen(anbieter, benutzer.id)
return NextResponse.redirect(autorisierungsAdresse(anbieter, zustand))
} catch (fehler) {
const meldung =
fehler instanceof Error ? fehler.message : 'Der Anbieter ist nicht eingerichtet.'
return NextResponse.redirect(
new URL(
`/kalenderkonten?fehler=${encodeURIComponent(meldung)}`,
process.env.APP_URL ?? '',
),
)
}
}
+120
View File
@@ -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 })
}
+2
View File
@@ -30,9 +30,11 @@ export function Kopfleiste({
{ pfad: '/', name: 'Start' },
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
{ pfad: '/aufgaben', name: 'Aufgaben' },
{ pfad: '/woche', name: 'Woche' },
{ pfad: '/projekte', name: 'Projekte' },
{ pfad: '/ideen', name: 'Ideen' },
{ pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/kalenderkonten', name: 'Kalender' },
{ pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
]
+15
View File
@@ -0,0 +1,15 @@
/**
* Wird von Next einmal beim Hochfahren des Servers ausgeführt.
*
* Nur in der Node-Laufzeit: im Edge-Umfeld gibt es weder Prisma noch
* node-cron. Der Demo-Modus startet bewusst keine Jobs — die
* Screenshot-Instanz soll sich nicht selbst verändern, während sie
* abfotografiert wird.
*/
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME !== 'nodejs') return
if (process.env.DEMO_MODUS === 'true') return
const { jobsStarten } = await import('@/server/jobs')
jobsStarten()
}
+256
View File
@@ -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.',
)
})
})
+229
View File
@@ -0,0 +1,229 @@
import { z } from 'zod'
import {
alsUhrzeit,
ausUhrzeit,
minutenSeitMitternacht,
wochentagIndex,
} from '@/lib/zeit'
/**
* Regeln für Benachrichtigungen.
*
* Der heikle Teil ist nicht die Technik, sondern das Maß: Eine Anwendung, die
* zu oft meldet, wird stummgeschaltet und ist dann wertlos. Deshalb sind die
* Voreinstellungen zurückhaltend, es gibt Ruhezeiten, und dieselbe Sache wird
* nie zweimal gemeldet.
*
* Reine Logik ohne Datenbank und ohne Systemzeit.
*/
export const ANLAESSE = [
'tagesueberblick',
'faelligkeit',
'zeitblock',
'abgleichfehler',
] as const
export type Anlass = (typeof ANLAESSE)[number]
export const ANLASS_NAME: Record<Anlass, string> = {
tagesueberblick: 'Tagesüberblick',
faelligkeit: 'Fällige Aufgaben',
zeitblock: 'Gleich beginnt ein Zeitblock',
abgleichfehler: 'Ein Kalender lässt sich nicht abgleichen',
}
export const ANLASS_BESCHREIBUNG: Record<Anlass, string> = {
tagesueberblick:
'Einmal morgens: was heute ansteht, was überfällig ist, wie voll der Tag wird.',
faelligkeit:
'Wenn eine Aufgabe an diesem Tag fällig wird — höchstens einmal je Aufgabe.',
zeitblock: 'Kurz bevor ein eingeplanter Zeitblock beginnt.',
abgleichfehler:
'Wenn ein Kalender mehrfach hintereinander nicht erreichbar war. Nicht beim ersten Mal.',
}
export type Einstellung = {
/** Welche Anlässe überhaupt melden. */
anlaesse: readonly Anlass[]
perPush: boolean
perMail: boolean
/** Uhrzeit des Tagesüberblicks, Minuten seit Mitternacht. */
ueberblickUm: number
/** Ruhezeit: von hier bis … nichts melden. */
ruheVon: number
ruheBis: number
/** Am Wochenende nichts melden. */
ruheAmWochenende: boolean
/** Vorlauf vor einem Zeitblock, in Minuten. */
zeitblockVorlaufMinuten: number
}
/**
* Voreinstellung: bewusst wenig.
*
* Wer mehr will, schaltet es zu. Wer beim ersten Start mit Meldungen
* überschüttet wird, schaltet alles ab — und dann auch das Nützliche.
*/
export const VOREINSTELLUNG: Einstellung = {
anlaesse: ['tagesueberblick', 'faelligkeit'],
perPush: false,
perMail: false,
ueberblickUm: 7 * 60,
ruheVon: 20 * 60,
ruheBis: 7 * 60,
ruheAmWochenende: false,
zeitblockVorlaufMinuten: 10,
}
/**
* Liegt der Zeitpunkt in der Ruhezeit?
*
* Die Ruhezeit läuft über Mitternacht hinweg — 20:00 bis 07:00 ist der
* Normalfall, nicht die Ausnahme.
*/
export function inRuhezeit(einstellung: Einstellung, zeitpunkt: Date): boolean {
if (einstellung.ruheAmWochenende && wochentagIndex(zeitpunkt) >= 5) return true
const minute = minutenSeitMitternacht(zeitpunkt)
const { ruheVon, ruheBis } = einstellung
// Gleiche Zeiten heißen: keine Ruhezeit.
if (ruheVon === ruheBis) return false
return ruheVon < ruheBis
? minute >= ruheVon && minute < ruheBis
: minute >= ruheVon || minute < ruheBis
}
export type Meldung = {
anlass: Anlass
/** Eindeutig je Sache und Tag; verhindert doppelte Meldungen. */
schluessel: string
titel: string
text: string
/** Wohin der Klick führt. */
pfad: string
}
/**
* Soll diese Meldung jetzt hinaus?
*
* Drei Gründe, es zu lassen: der Anlass ist abgeschaltet, es ist Ruhezeit,
* oder genau diese Meldung ging schon einmal hinaus.
*/
export type Entscheidung =
| { senden: true }
| { senden: false; grund: 'abgeschaltet' | 'ruhezeit' | 'schon-gemeldet' | 'kein-weg' }
export function sendenEntscheiden(
einstellung: Einstellung,
meldung: Meldung,
bereitsGemeldet: ReadonlySet<string>,
zeitpunkt: Date,
): Entscheidung {
if (!einstellung.anlaesse.includes(meldung.anlass)) {
return { senden: false, grund: 'abgeschaltet' }
}
if (!einstellung.perPush && !einstellung.perMail) {
return { senden: false, grund: 'kein-weg' }
}
if (bereitsGemeldet.has(meldung.schluessel)) {
return { senden: false, grund: 'schon-gemeldet' }
}
// Der Tagesüberblick kommt zu seiner festen Zeit; ihn wegen der Ruhezeit zu
// unterdrücken hieße, ihn nie zu schicken, wenn jemand früh aufsteht.
if (meldung.anlass !== 'tagesueberblick' && inRuhezeit(einstellung, zeitpunkt)) {
return { senden: false, grund: 'ruhezeit' }
}
return { senden: true }
}
/**
* Schlüssel einer Meldung: Anlass, Sache und Tag.
*
* Der Tag gehört dazu, damit dieselbe Aufgabe morgen wieder gemeldet werden
* darf — sie ist dann ja immer noch fällig.
*/
export function meldungsSchluessel(
anlass: Anlass,
sache: string,
tagesschluessel: string,
): string {
return `${anlass}:${sache}:${tagesschluessel}`
}
/** Ist jetzt die Zeit für den Tagesüberblick? */
export function ueberblickFaellig(
einstellung: Einstellung,
zeitpunkt: Date,
toleranzMinuten = 15,
): boolean {
const minute = minutenSeitMitternacht(zeitpunkt)
return (
minute >= einstellung.ueberblickUm &&
minute < einstellung.ueberblickUm + toleranzMinuten
)
}
/** Beginnt der Zeitblock bald genug für eine Erinnerung? */
export function zeitblockErinnerungFaellig(
einstellung: Einstellung,
beginn: Date,
zeitpunkt: Date,
toleranzMinuten = 15,
): boolean {
const minutenBis = (beginn.getTime() - zeitpunkt.getTime()) / 60_000
return (
minutenBis <= einstellung.zeitblockVorlaufMinuten &&
minutenBis > einstellung.zeitblockVorlaufMinuten - toleranzMinuten
)
}
/**
* Ab wann ein Abgleichfehler gemeldet wird.
*
* Nicht beim ersten Mal: ein Netz hustet, ein Anbieter hat eine Wartung.
* Erst wenn es mehrfach hintereinander nicht klappt, ist es eine Nachricht
* wert.
*/
export const ABGLEICHFEHLER_AB_VERSUCH = 3
// --- Eingaben --------------------------------------------------------------
const uhrzeit = z
.string()
.trim()
.refine((w) => ausUhrzeit(w) !== null, 'Bitte eine Uhrzeit wie 07:00 angeben.')
.transform((w) => ausUhrzeit(w) as number)
export const einstellungSchema = z.object({
anlaesse: z.array(z.enum(ANLAESSE)).default([]),
perPush: z.coerce.boolean().default(false),
perMail: z.coerce.boolean().default(false),
ueberblickUm: uhrzeit,
ruheVon: uhrzeit,
ruheBis: uhrzeit,
ruheAmWochenende: z.coerce.boolean().default(false),
zeitblockVorlaufMinuten: z.coerce
.number()
.int()
.min(0, 'Nicht weniger als null Minuten.')
.max(120, 'Mehr als zwei Stunden Vorlauf ist keine Erinnerung mehr.'),
})
export type EinstellungEingabe = z.infer<typeof einstellungSchema>
/** Lesbare Beschreibung der Ruhezeit für die Oberfläche. */
export function ruhezeitText(einstellung: Einstellung): string {
if (einstellung.ruheVon === einstellung.ruheBis) {
return einstellung.ruheAmWochenende
? 'Keine Ruhezeit, aber am Wochenende still.'
: 'Keine Ruhezeit.'
}
const zeiten = `${alsUhrzeit(einstellung.ruheVon)} bis ${alsUhrzeit(einstellung.ruheBis)}`
return einstellung.ruheAmWochenende
? `Still von ${zeiten} und am ganzen Wochenende.`
: `Still von ${zeiten}.`
}
+337
View File
@@ -0,0 +1,337 @@
import { describe, expect, it } from 'vitest'
import {
arbeitsfensterAmTag,
belegteMinuten,
belegtZeit,
freieFenster,
kapazitaetAmTag,
tagesBelegung,
termineAmTag,
ueberschneidungMinuten,
type BelegenderTermin,
type BereichKapazitaet,
} from './belegung'
// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1.
const dienstag = new Date('2026-03-17T09:00:00Z')
const bereich: BereichKapazitaet = {
kapazitaetMo: 360,
kapazitaetDi: 240,
kapazitaetMi: 360,
kapazitaetDo: 360,
kapazitaetFr: 120,
kapazitaetSa: 0,
kapazitaetSo: 0,
// 08:00 bis 17:00 Berliner Zeit.
arbeitsfensterVon: 480,
arbeitsfensterBis: 1020,
}
function termin(
vonUtc: string,
bisUtc: string,
teil: Partial<BelegenderTermin> = {},
): BelegenderTermin {
return {
beginn: new Date(vonUtc),
ende: new Date(bisUtc),
ganztaegig: false,
zeigtAls: 'GEBUCHT',
...teil,
}
}
describe('belegtZeit', () => {
it('zählt einen gewöhnlichen Termin', () => {
expect(belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'))).toBe(true)
})
it('zählt ganztägige Termine nicht', () => {
// Ein Urlaubstag oder Geburtstag kostet keine Arbeitszeit im Fenster.
expect(
belegtZeit(
termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true }),
),
).toBe(false)
})
it('zählt als frei markierte Termine nicht', () => {
expect(
belegtZeit(
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls: 'FREI' }),
),
).toBe(false)
})
it('zählt Vorbehalt und Abwesenheit sehr wohl', () => {
for (const zeigtAls of [
'MIT_VORBEHALT',
'ABWESEND',
'WOANDERS',
'UNBEKANNT',
] as const) {
expect(
belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls })),
).toBe(true)
}
})
})
describe('ueberschneidungMinuten', () => {
const fenster = {
von: new Date('2026-03-17T08:00:00Z'),
bis: new Date('2026-03-17T12:00:00Z'),
}
it('rechnet eine vollständige Überschneidung', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T09:00:00Z'), bis: new Date('2026-03-17T10:00:00Z') },
fenster,
),
).toBe(60)
})
it('schneidet an den Rändern ab', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T07:00:00Z'), bis: new Date('2026-03-17T09:00:00Z') },
fenster,
),
).toBe(60)
})
it('ist ohne Überschneidung null', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T13:00:00Z'), bis: new Date('2026-03-17T14:00:00Z') },
fenster,
),
).toBe(0)
})
it('zählt reines Aneinandergrenzen nicht als Überschneidung', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T12:00:00Z'), bis: new Date('2026-03-17T13:00:00Z') },
fenster,
),
).toBe(0)
})
})
describe('arbeitsfensterAmTag', () => {
it('legt das Fenster in Berliner Ortszeit', () => {
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
// 08:00 Berlin = 07:00 UTC in der Winterzeit.
expect(fenster.von.toISOString()).toBe('2026-03-17T07:00:00.000Z')
expect(fenster.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z')
})
it('verschiebt sich mit der Sommerzeit', () => {
// Ab dem 29. März 2026 gilt in Berlin UTC+2.
const fenster = arbeitsfensterAmTag(new Date('2026-04-07T09:00:00Z'), 480, 1020)
expect(fenster.von.toISOString()).toBe('2026-04-07T06:00:00.000Z')
expect(fenster.bis.toISOString()).toBe('2026-04-07T15:00:00.000Z')
})
})
describe('kapazitaetAmTag', () => {
it('nimmt den richtigen Wochentag', () => {
expect(kapazitaetAmTag(bereich, dienstag)).toBe(240)
// Montag, 16. März.
expect(kapazitaetAmTag(bereich, new Date('2026-03-16T09:00:00Z'))).toBe(360)
// Sonntag, 22. März.
expect(kapazitaetAmTag(bereich, new Date('2026-03-22T09:00:00Z'))).toBe(0)
})
})
describe('belegteMinuten', () => {
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
it('ist ohne Termine null', () => {
expect(belegteMinuten([], fenster)).toBe(0)
})
it('summiert getrennte Termine', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'),
],
fenster,
),
).toBe(120)
})
it('legt überlappende Termine zusammen, statt doppelt zu zählen', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
termin('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
],
fenster,
),
).toBe(180)
})
it('legt lückenlos aneinandergrenzende Termine zusammen', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'),
],
fenster,
),
).toBe(120)
})
it('zählt einen vollständig enthaltenen Termin nur einmal', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T12:00:00Z'),
termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'),
],
fenster,
),
).toBe(240)
})
it('schneidet an den Rändern des Arbeitsfensters ab', () => {
// 06:00–08:00 UTC = 07:00–09:00 Berlin; nur die zweite Stunde zählt.
expect(
belegteMinuten([termin('2026-03-17T06:00:00Z', '2026-03-17T08:00:00Z')], fenster),
).toBe(60)
})
it('lässt Termine außerhalb des Fensters ganz weg', () => {
// 20:00 Berlin, das Fenster endet um 17:00.
expect(
belegteMinuten([termin('2026-03-17T19:00:00Z', '2026-03-17T20:00:00Z')], fenster),
).toBe(0)
})
it('lässt ganztägige und freie Termine weg', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z', { ganztaegig: true }),
termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z', { zeigtAls: 'FREI' }),
],
fenster,
),
).toBe(0)
})
})
describe('tagesBelegung', () => {
it('rechnet Kapazität und Termine gegeneinander', () => {
const belegung = tagesBelegung(
bereich,
[termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z')],
dienstag,
)
expect(belegung.kapazitaet).toBe(240)
expect(belegung.belegt).toBe(120)
expect(belegung.frei).toBe(120)
expect(belegung.prozent).toBe(50)
expect(belegung.ueberbucht).toBe(false)
})
it('erkennt einen überbuchten Tag', () => {
const belegung = tagesBelegung(
bereich,
[termin('2026-03-17T07:00:00Z', '2026-03-17T13:00:00Z')],
dienstag,
)
expect(belegung.belegt).toBe(360)
expect(belegung.frei).toBe(0)
expect(belegung.ueberbucht).toBe(true)
expect(belegung.prozent).toBe(150)
})
it('behandelt einen Tag ohne Kapazität als voll', () => {
// Sonntag: keine Kapazität, also gibt es dort nichts zu planen.
const belegung = tagesBelegung(bereich, [], new Date('2026-03-22T09:00:00Z'))
expect(belegung.kapazitaet).toBe(0)
expect(belegung.prozent).toBe(100)
expect(belegung.frei).toBe(0)
})
})
describe('freieFenster', () => {
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
it('gibt ohne Termine das ganze Arbeitsfenster zurück', () => {
const luecken = freieFenster([], fenster)
expect(luecken).toHaveLength(1)
expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T07:00:00.000Z')
expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z')
})
it('findet die Lücke zwischen zwei Terminen', () => {
const luecken = freieFenster(
[
termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-17T11:00:00Z', '2026-03-17T16:00:00Z'),
],
fenster,
)
expect(luecken).toHaveLength(1)
expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T09:00:00.000Z')
expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T11:00:00.000Z')
})
it('lässt zu kurze Lücken weg', () => {
const luecken = freieFenster(
[
termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'),
// Nur zehn Minuten Pause.
termin('2026-03-17T09:10:00Z', '2026-03-17T16:00:00Z'),
],
fenster,
15,
)
expect(luecken).toEqual([])
})
it('ist bei durchgehender Belegung leer', () => {
expect(
freieFenster([termin('2026-03-17T07:00:00Z', '2026-03-17T16:00:00Z')], fenster),
).toEqual([])
})
it('übergeht ganztägige Termine und findet den Tag frei', () => {
const luecken = freieFenster(
[termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true })],
fenster,
)
expect(luecken).toHaveLength(1)
})
})
describe('termineAmTag', () => {
it('nimmt, was den Berliner Kalendertag berührt', () => {
const liste = [
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-18T08:00:00Z', '2026-03-18T09:00:00Z'),
]
expect(termineAmTag(liste, dienstag)).toHaveLength(1)
})
it('nimmt auch einen Termin, der in den Tag hineinragt', () => {
const uebernacht = termin('2026-03-16T22:00:00Z', '2026-03-17T02:00:00Z')
expect(termineAmTag([uebernacht], dienstag)).toHaveLength(1)
})
it('lässt einen Termin weg, der genau am Tagesbeginn endet', () => {
// 23:00 UTC am 16. = 00:00 Berlin am 17., also gerade noch der Vortag.
const davor = termin('2026-03-16T21:00:00Z', '2026-03-16T23:00:00Z')
expect(termineAmTag([davor], dienstag)).toEqual([])
})
})
+217
View File
@@ -0,0 +1,217 @@
import type { ZeigtAls } from '@/generated/prisma/enums'
import { tagesBeginn, tagesEnde, wochentagIndex } from '@/lib/zeit'
/**
* Wie voll ein Tag schon ist.
*
* Reine Logik mit dem Zeitpunkt als Parameter. Alles rechnet in Minuten und in
* Europe/Berlin — dieselbe Tagesgrenze wie überall sonst im Planer.
*/
export type BelegenderTermin = {
beginn: Date
ende: Date
ganztaegig: boolean
zeigtAls: ZeigtAls
}
export type Zeitfenster = { von: Date; bis: Date }
/**
* Zählt ein Termin gegen die Kapazität?
*
* Ganztägige Termine nicht — ein Urlaubstag oder ein Geburtstag im Kalender
* blockiert sonst den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
* Ebenso wenig zählen Termine, die ausdrücklich als frei markiert sind.
*/
export function belegtZeit(termin: BelegenderTermin): boolean {
if (termin.ganztaegig) return false
if (termin.zeigtAls === 'FREI') return false
return true
}
/** Überschneidung zweier Zeiträume in Minuten; ohne Überschneidung null. */
export function ueberschneidungMinuten(a: Zeitfenster, b: Zeitfenster): number {
const von = Math.max(a.von.getTime(), b.von.getTime())
const bis = Math.min(a.bis.getTime(), b.bis.getTime())
if (bis <= von) return 0
return Math.round((bis - von) / 60_000)
}
/** Das Arbeitsfenster eines Bereichs an einem bestimmten Tag. */
export function arbeitsfensterAmTag(
tag: Date,
arbeitsfensterVon: number,
arbeitsfensterBis: number,
): Zeitfenster {
const beginn = tagesBeginn(tag)
return {
von: new Date(beginn.getTime() + arbeitsfensterVon * 60_000),
bis: new Date(beginn.getTime() + arbeitsfensterBis * 60_000),
}
}
/**
* Wie viele Minuten des Arbeitsfensters an diesem Tag schon durch Termine
* belegt sind.
*
* Überlappende Termine werden zusammengelegt, damit eine Doppelbuchung nicht
* doppelt zählt.
*/
export function belegteMinuten(
termine: readonly BelegenderTermin[],
fenster: Zeitfenster,
): number {
const abschnitte = termine
.filter(belegtZeit)
.map((t) => ({ von: t.beginn, bis: t.ende }))
.filter((t) => ueberschneidungMinuten(t, fenster) > 0)
.map((t) => ({
von: new Date(Math.max(t.von.getTime(), fenster.von.getTime())),
bis: new Date(Math.min(t.bis.getTime(), fenster.bis.getTime())),
}))
.sort((a, b) => a.von.getTime() - b.von.getTime())
let summe = 0
let offenVon: Date | null = null
let offenBis: Date | null = null
for (const abschnitt of abschnitte) {
if (offenVon === null || offenBis === null) {
offenVon = abschnitt.von
offenBis = abschnitt.bis
continue
}
if (abschnitt.von.getTime() <= offenBis.getTime()) {
// Überlappt oder schließt an – zusammenlegen.
if (abschnitt.bis.getTime() > offenBis.getTime()) offenBis = abschnitt.bis
continue
}
summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000)
offenVon = abschnitt.von
offenBis = abschnitt.bis
}
if (offenVon !== null && offenBis !== null) {
summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000)
}
return summe
}
export type TagesBelegung = {
/** Tageskapazität des Bereichs an diesem Wochentag, in Minuten. */
kapazitaet: number
/** Durch Termine belegte Minuten innerhalb des Arbeitsfensters. */
belegt: number
/** Was von der Kapazität übrig bleibt; nie negativ. */
frei: number
/** Belegung in Prozent der Kapazität, gerundet. Ohne Kapazität 100. */
prozent: number
/** Mehr Termine als Kapazität – der Tag ist überbucht. */
ueberbucht: boolean
}
export type BereichKapazitaet = {
kapazitaetMo: number
kapazitaetDi: number
kapazitaetMi: number
kapazitaetDo: number
kapazitaetFr: number
kapazitaetSa: number
kapazitaetSo: number
arbeitsfensterVon: number
arbeitsfensterBis: number
}
const KAPAZITAET_JE_TAG = [
'kapazitaetMo',
'kapazitaetDi',
'kapazitaetMi',
'kapazitaetDo',
'kapazitaetFr',
'kapazitaetSa',
'kapazitaetSo',
] as const
/** Kapazität des Bereichs an genau diesem Tag. */
export function kapazitaetAmTag(bereich: BereichKapazitaet, tag: Date): number {
const feld = KAPAZITAET_JE_TAG[wochentagIndex(tag)]
return feld ? bereich[feld] : 0
}
/**
* Rechnet Kapazität und Termine eines Tages gegeneinander.
*
* Gezählt wird nur, was ins Arbeitsfenster fällt: ein Termin um 22 Uhr
* verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um 17 Uhr
* endet.
*/
export function tagesBelegung(
bereich: BereichKapazitaet,
termine: readonly BelegenderTermin[],
tag: Date,
): TagesBelegung {
const kapazitaet = kapazitaetAmTag(bereich, tag)
const fenster = arbeitsfensterAmTag(
tag,
bereich.arbeitsfensterVon,
bereich.arbeitsfensterBis,
)
const belegt = belegteMinuten(termine, fenster)
return {
kapazitaet,
belegt,
frei: Math.max(0, kapazitaet - belegt),
prozent: kapazitaet === 0 ? 100 : Math.round((belegt / kapazitaet) * 100),
ueberbucht: belegt > kapazitaet,
}
}
/**
* Freie Abschnitte innerhalb des Arbeitsfensters eines Tages — die Lücken,
* in die ab M4 Zeitblöcke gelegt werden können.
*/
export function freieFenster(
termine: readonly BelegenderTermin[],
fenster: Zeitfenster,
mindestensMinuten = 15,
): Zeitfenster[] {
const belegt = termine
.filter(belegtZeit)
.map((t) => ({
von: new Date(Math.max(t.beginn.getTime(), fenster.von.getTime())),
bis: new Date(Math.min(t.ende.getTime(), fenster.bis.getTime())),
}))
.filter((t) => t.bis.getTime() > t.von.getTime())
.sort((a, b) => a.von.getTime() - b.von.getTime())
const luecken: Zeitfenster[] = []
let cursor = fenster.von
for (const abschnitt of belegt) {
if (abschnitt.von.getTime() > cursor.getTime()) {
luecken.push({ von: cursor, bis: abschnitt.von })
}
if (abschnitt.bis.getTime() > cursor.getTime()) cursor = abschnitt.bis
}
if (cursor.getTime() < fenster.bis.getTime()) {
luecken.push({ von: cursor, bis: fenster.bis })
}
return luecken.filter(
(l) => (l.bis.getTime() - l.von.getTime()) / 60_000 >= mindestensMinuten,
)
}
/** Termine, die an diesem Kalendertag überhaupt vorkommen. */
export function termineAmTag<T extends { beginn: Date; ende: Date }>(
termine: readonly T[],
tag: Date,
): T[] {
const von = tagesBeginn(tag).getTime()
const bis = tagesEnde(tag).getTime()
return termine.filter((t) => t.beginn.getTime() <= bis && t.ende.getTime() > von)
}
+104
View File
@@ -0,0 +1,104 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { googleSeiteLesen, googleZeitpunkt, zeigtAlsAusGoogle } from './google'
import { AntwortUnbrauchbar } from './typen'
function fixture(name: string): unknown {
return JSON.parse(
readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'),
) as unknown
}
describe('zeigtAlsAusGoogle', () => {
it('kennt nur frei und gebucht', () => {
expect(zeigtAlsAusGoogle('transparent')).toBe('FREI')
expect(zeigtAlsAusGoogle('opaque')).toBe('GEBUCHT')
})
it('nimmt ein fehlendes Feld als gebucht', () => {
// Google lässt transparency weg, wenn der Termin die Zeit belegt.
expect(zeigtAlsAusGoogle(undefined)).toBe('GEBUCHT')
})
})
describe('googleZeitpunkt', () => {
it('liest eine Zeit mit Versatz', () => {
const ergebnis = googleZeitpunkt({ dateTime: '2026-03-17T14:00:00+01:00' })
expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-17T13:00:00.000Z')
expect(ergebnis.ganztaegig).toBe(false)
})
it('erkennt ein reines Datum als ganztägig', () => {
const ergebnis = googleZeitpunkt({ date: '2026-03-20' })
expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-20T00:00:00.000Z')
expect(ergebnis.ganztaegig).toBe(true)
})
it('bricht ab, wenn beides fehlt', () => {
expect(() => googleZeitpunkt({})).toThrow(AntwortUnbrauchbar)
})
it('bricht bei unlesbaren Angaben ab', () => {
expect(() => googleZeitpunkt({ dateTime: 'irgendwann' })).toThrow(AntwortUnbrauchbar)
expect(() => googleZeitpunkt({ date: '2026-13-45' })).toThrow(AntwortUnbrauchbar)
})
})
describe('googleSeiteLesen', () => {
it('liest die erste Seite samt Seitenverweis', () => {
const seite = googleSeiteLesen(fixture('google-events-seite1'))
expect(seite.geaendert).toHaveLength(2)
expect(seite.weiter).toBe('BEISPIEL-SEITE-2')
expect(seite.deltaVerweis).toBeNull()
})
it('rechnet den Versatz nach UTC um', () => {
const [erster] = googleSeiteLesen(fixture('google-events-seite1')).geaendert
expect(erster?.extId).toBe('beispiel-termin-a')
expect(erster?.betreff).toBe('Zahnarzt')
expect(erster?.beginn.toISOString()).toBe('2026-03-17T13:00:00.000Z')
expect(erster?.ende.toISOString()).toBe('2026-03-17T14:00:00.000Z')
expect(erster?.ganztaegig).toBe(false)
})
it('erkennt ganztägige Termine am reinen Datum', () => {
const ganztaegig = googleSeiteLesen(fixture('google-events-seite1')).geaendert[1]
expect(ganztaegig?.ganztaegig).toBe(true)
expect(ganztaegig?.betreff).toBe('Urlaubstag')
})
it('liest die letzte Seite mit dem Sync-Verweis', () => {
const seite = googleSeiteLesen(fixture('google-events-seite2'))
expect(seite.weiter).toBeNull()
expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-1')
})
it('behandelt abgesagte Termine als entfernt', () => {
const seite = googleSeiteLesen(fixture('google-events-seite2'))
expect(seite.entfernt).toEqual(['beispiel-termin-d'])
expect(seite.geaendert.map((t) => t.extId)).toEqual(['beispiel-termin-c'])
})
it('erkennt einen durchlässigen Termin als frei', () => {
const [frei] = googleSeiteLesen(fixture('google-events-seite2')).geaendert
expect(frei?.zeigtAls).toBe('FREI')
})
it('kommt mit einer leeren Antwort zurecht', () => {
const seite = googleSeiteLesen(fixture('google-events-leer'))
expect(seite.geaendert).toEqual([])
expect(seite.entfernt).toEqual([])
expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-2')
})
it('weist eine Antwort mit falschem Aufbau ab', () => {
expect(() => googleSeiteLesen({ items: 'keine Liste' })).toThrow(AntwortUnbrauchbar)
})
it('übergeht Einträge ohne Zeitraum', () => {
const seite = googleSeiteLesen({
items: [{ id: 'ohne-zeit', status: 'confirmed', summary: 'Kein Zeitraum' }],
})
expect(seite.geaendert).toEqual([])
})
})
+118
View File
@@ -0,0 +1,118 @@
import { z } from 'zod'
import type { ZeigtAls } from '@/generated/prisma/enums'
import {
AntwortUnbrauchbar,
type AbgleichSeite,
type RohTermin,
} from '@/lib/kalender/typen'
/**
* Lesen der Antworten der Google-Kalender-API (`events.list`).
*
* Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die
* gemeinsame Form zurück.
*/
/**
* Google liefert entweder `date` (ganztägig, reines Datum) oder `dateTime`
* (mit Versatz). Genau eines von beidem.
*/
const googleZeit = z.looseObject({
date: z.string().optional(),
dateTime: z.string().optional(),
timeZone: z.string().optional(),
})
const googleTermin = z.looseObject({
id: z.string().min(1),
etag: z.string().optional(),
status: z.string().optional(),
summary: z.string().nullable().optional(),
start: googleZeit.optional(),
end: googleZeit.optional(),
transparency: z.string().optional(),
})
const googleAntwort = z.looseObject({
items: z.array(googleTermin).default([]),
nextPageToken: z.string().optional(),
nextSyncToken: z.string().optional(),
})
/**
* Google kennt nur zwei Zustände: `transparency: "transparent"` heißt frei,
* alles andere (auch das Fehlen des Feldes) heißt gebucht.
*/
export function zeigtAlsAusGoogle(transparency: string | undefined): ZeigtAls {
return transparency === 'transparent' ? 'FREI' : 'GEBUCHT'
}
export type GoogleZeit = { date?: string | undefined; dateTime?: string | undefined }
/**
* Wandelt Googles Zeitangabe in einen Zeitpunkt und sagt, ob sie ganztägig
* war. Ein reines Datum wird als Mitternacht UTC gelesen — ganztägige Termine
* zählen ohnehin nicht zur Belegung, es geht nur um die Einordnung im Tag.
*/
export function googleZeitpunkt(zeit: GoogleZeit): {
zeitpunkt: Date
ganztaegig: boolean
} {
if (zeit.date) {
const zeitpunkt = new Date(`${zeit.date}T00:00:00Z`)
if (Number.isNaN(zeitpunkt.getTime())) {
throw new AntwortUnbrauchbar('Google', 'Datumsangabe nicht lesbar')
}
return { zeitpunkt, ganztaegig: true }
}
if (zeit.dateTime) {
const zeitpunkt = new Date(zeit.dateTime)
if (Number.isNaN(zeitpunkt.getTime())) {
throw new AntwortUnbrauchbar('Google', 'Zeitangabe nicht lesbar')
}
return { zeitpunkt, ganztaegig: false }
}
throw new AntwortUnbrauchbar('Google', 'Zeitangabe fehlt')
}
/** Liest eine Antwortseite von `events.list`. */
export function googleSeiteLesen(inhalt: unknown): AbgleichSeite {
const gelesen = googleAntwort.safeParse(inhalt)
if (!gelesen.success) {
throw new AntwortUnbrauchbar('Google', 'unerwarteter Aufbau')
}
const geaendert: RohTermin[] = []
const entfernt: string[] = []
for (const eintrag of gelesen.data.items) {
if (eintrag.status === 'cancelled') {
entfernt.push(eintrag.id)
continue
}
if (!eintrag.start || !eintrag.end) continue
const beginn = googleZeitpunkt(eintrag.start)
const ende = googleZeitpunkt(eintrag.end)
geaendert.push({
extId: eintrag.id,
etag: eintrag.etag ?? null,
betreff: eintrag.summary ?? null,
beginn: beginn.zeitpunkt,
ende: ende.zeitpunkt,
ganztaegig: beginn.ganztaegig,
zeigtAls: zeigtAlsAusGoogle(eintrag.transparency),
})
}
return {
geaendert,
entfernt,
weiter: gelesen.data.nextPageToken ?? null,
deltaVerweis: gelesen.data.nextSyncToken ?? null,
}
}
+130
View File
@@ -0,0 +1,130 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { graphSeiteLesen, graphZeitpunkt, zeigtAlsAusGraph } from './graph'
import { AntwortUnbrauchbar } from './typen'
/** Beispielantworten aus test/fixtures, anonymisiert. */
function fixture(name: string): unknown {
return JSON.parse(
readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'),
) as unknown
}
describe('zeigtAlsAusGraph', () => {
it('übersetzt die bekannten Werte', () => {
expect(zeigtAlsAusGraph('free')).toBe('FREI')
expect(zeigtAlsAusGraph('busy')).toBe('GEBUCHT')
expect(zeigtAlsAusGraph('tentative')).toBe('MIT_VORBEHALT')
expect(zeigtAlsAusGraph('oof')).toBe('ABWESEND')
expect(zeigtAlsAusGraph('workingElsewhere')).toBe('WOANDERS')
})
it('nimmt Unbekanntes als unbekannt, statt es zu raten', () => {
expect(zeigtAlsAusGraph('etwasNeues')).toBe('UNBEKANNT')
expect(zeigtAlsAusGraph(undefined)).toBe('UNBEKANNT')
})
})
describe('graphZeitpunkt', () => {
it('liest eine UTC-Angabe', () => {
expect(
graphZeitpunkt({
dateTime: '2026-03-17T08:00:00.0000000',
timeZone: 'UTC',
}).toISOString(),
).toBe('2026-03-17T08:00:00.000Z')
})
it('kürzt die sieben Nachkommastellen, die Date nicht versteht', () => {
expect(
graphZeitpunkt({
dateTime: '2026-03-17T08:00:00.1234567',
timeZone: 'UTC',
}).toISOString(),
).toBe('2026-03-17T08:00:00.123Z')
})
it('kommt auch ohne Nachkommastellen zurecht', () => {
expect(
graphZeitpunkt({ dateTime: '2026-03-17T08:00:00', timeZone: 'UTC' }).toISOString(),
).toBe('2026-03-17T08:00:00.000Z')
})
it('bricht bei einer anderen Zeitzone ab, statt Termine zu verschieben', () => {
expect(() =>
graphZeitpunkt({
dateTime: '2026-03-17T09:00:00.0000000',
timeZone: 'W. Europe Standard Time',
}),
).toThrow(AntwortUnbrauchbar)
})
it('bricht bei unlesbarer Zeitangabe ab', () => {
expect(() => graphZeitpunkt({ dateTime: 'morgen früh', timeZone: 'UTC' })).toThrow(
AntwortUnbrauchbar,
)
})
})
describe('graphSeiteLesen', () => {
it('liest die erste Seite samt Verweis auf die nächste', () => {
const seite = graphSeiteLesen(fixture('graph-delta-seite1'))
expect(seite.geaendert).toHaveLength(2)
expect(seite.entfernt).toEqual([])
expect(seite.weiter).toContain('$skiptoken=')
expect(seite.deltaVerweis).toBeNull()
})
it('übernimmt Betreff, Kennung und Etag', () => {
const [erster] = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert
expect(erster?.extId).toBe('BEISPIEL-TERMIN-1')
expect(erster?.betreff).toBe('Wochenbesprechung')
expect(erster?.etag).toContain('BEISPIELETAG1')
expect(erster?.beginn.toISOString()).toBe('2026-03-17T08:00:00.000Z')
expect(erster?.ende.toISOString()).toBe('2026-03-17T09:00:00.000Z')
expect(erster?.ganztaegig).toBe(false)
expect(erster?.zeigtAls).toBe('GEBUCHT')
})
it('erkennt ganztägige Termine', () => {
const ganztaegig = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert[1]
expect(ganztaegig?.ganztaegig).toBe(true)
expect(ganztaegig?.zeigtAls).toBe('ABWESEND')
})
it('liest die letzte Seite mit dem Delta-Verweis für die nächste Runde', () => {
const seite = graphSeiteLesen(fixture('graph-delta-seite2'))
expect(seite.weiter).toBeNull()
expect(seite.deltaVerweis).toContain('$deltatoken=')
})
it('trennt Entferntes von Geändertem', () => {
const seite = graphSeiteLesen(fixture('graph-delta-seite2'))
expect(seite.entfernt).toEqual(['BEISPIEL-TERMIN-9'])
expect(seite.geaendert.map((t) => t.extId)).toEqual(['BEISPIEL-TERMIN-3'])
})
it('erkennt einen als frei markierten Termin', () => {
const [frei] = graphSeiteLesen(fixture('graph-delta-seite2')).geaendert
expect(frei?.zeigtAls).toBe('FREI')
})
it('bricht ab, wenn Graph eine andere Zeitzone liefert', () => {
expect(() => graphSeiteLesen(fixture('graph-delta-fremde-zeitzone'))).toThrow(
AntwortUnbrauchbar,
)
})
it('weist eine Antwort ohne value-Liste ab', () => {
expect(() => graphSeiteLesen({ irgendwas: true })).toThrow(AntwortUnbrauchbar)
})
it('übergeht Einträge ohne Zeitraum, statt abzubrechen', () => {
const seite = graphSeiteLesen({
value: [{ id: 'ohne-zeit', subject: 'Kein Zeitraum' }],
})
expect(seite.geaendert).toEqual([])
expect(seite.entfernt).toEqual([])
})
})
+124
View File
@@ -0,0 +1,124 @@
import { z } from 'zod'
import type { ZeigtAls } from '@/generated/prisma/enums'
import {
AntwortUnbrauchbar,
type AbgleichSeite,
type RohTermin,
} from '@/lib/kalender/typen'
/**
* Lesen der Antworten von Microsoft Graph (`/me/calendarView/delta`).
*
* Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die
* gemeinsame Form zurück. Kein fetch, keine Tokens — dadurch gegen die
* Beispielantworten in test/fixtures prüfbar.
*/
/**
* Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir fragen
* mit `Prefer: outlook.timezone="UTC"` an; kommt trotzdem etwas anderes
* zurück, wäre jede Umrechnung geraten – dann lieber abbrechen.
*/
const graphZeit = z.object({
dateTime: z.string().min(1),
timeZone: z.string().min(1),
})
const graphTermin = z.looseObject({
id: z.string().min(1),
'@odata.etag': z.string().optional(),
'@removed': z.looseObject({ reason: z.string().optional() }).optional(),
subject: z.string().nullable().optional(),
start: graphZeit.optional(),
end: graphZeit.optional(),
isAllDay: z.boolean().optional(),
showAs: z.string().optional(),
})
const graphAntwort = z.looseObject({
value: z.array(graphTermin),
'@odata.nextLink': z.string().optional(),
'@odata.deltaLink': z.string().optional(),
})
/** showAs von Graph auf unsere Belegungsarten. */
export function zeigtAlsAusGraph(showAs: string | undefined): ZeigtAls {
switch (showAs) {
case 'free':
return 'FREI'
case 'busy':
return 'GEBUCHT'
case 'tentative':
return 'MIT_VORBEHALT'
case 'oof':
return 'ABWESEND'
case 'workingElsewhere':
return 'WOANDERS'
default:
return 'UNBEKANNT'
}
}
/**
* Wandelt Graphs `{ dateTime, timeZone }` in einen Zeitpunkt.
*
* Der Text trägt keinen Versatz, deshalb wird er als UTC gelesen. Meldet
* Graph eine andere Zeitzone, ist die Anfrage falsch gestellt worden — das
* schweigend zu übergehen hieße, Termine um Stunden zu verschieben.
*/
export function graphZeitpunkt(zeit: { dateTime: string; timeZone: string }): Date {
if (zeit.timeZone.toUpperCase() !== 'UTC') {
throw new AntwortUnbrauchbar(
'Graph',
`unerwartete Zeitzone "${zeit.timeZone}", erwartet wurde UTC`,
)
}
// "2016-12-09T20:30:00.0000000" – die sieben Nachkommastellen versteht
// Date nicht, drei genügen.
const gekuerzt = zeit.dateTime.replace(/(\.\d{3})\d+$/, '$1')
const zeitpunkt = new Date(`${gekuerzt}Z`)
if (Number.isNaN(zeitpunkt.getTime())) {
throw new AntwortUnbrauchbar('Graph', 'Zeitangabe nicht lesbar')
}
return zeitpunkt
}
/** Liest eine Antwortseite von `/me/calendarView/delta`. */
export function graphSeiteLesen(inhalt: unknown): AbgleichSeite {
const gelesen = graphAntwort.safeParse(inhalt)
if (!gelesen.success) {
throw new AntwortUnbrauchbar('Graph', 'unerwarteter Aufbau')
}
const geaendert: RohTermin[] = []
const entfernt: string[] = []
for (const eintrag of gelesen.data.value) {
if (eintrag['@removed']) {
entfernt.push(eintrag.id)
continue
}
// Ein Eintrag ohne Zeitraum ist kein Termin, den wir einplanen könnten.
if (!eintrag.start || !eintrag.end) continue
geaendert.push({
extId: eintrag.id,
etag: eintrag['@odata.etag'] ?? null,
betreff: eintrag.subject ?? null,
beginn: graphZeitpunkt(eintrag.start),
ende: graphZeitpunkt(eintrag.end),
ganztaegig: eintrag.isAllDay === true,
zeigtAls: zeigtAlsAusGraph(eintrag.showAs),
})
}
return {
geaendert,
entfernt,
weiter: gelesen.data['@odata.nextLink'] ?? null,
deltaVerweis: gelesen.data['@odata.deltaLink'] ?? null,
}
}

Some files were not shown because too many files have changed in this diff Show More