diff --git a/CHANGELOG.md b/CHANGELOG.md index b741729..17cd3f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] +## [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 diff --git a/README.md b/README.md index a77dcea..564a02f 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben wird. -> **Stand:** Meilenstein 3 von 8. Aufgaben, Projekte und das Lesen der -> Kalender stehen: der Planer weiß jetzt, wann überhaupt Platz ist. Das -> Schreiben der Zeitblöcke per Ziehen und Ablegen kommt mit M4. Der +> **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` @@ -30,6 +31,8 @@ wird. - **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 - 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 @@ -40,7 +43,6 @@ wird. ### Geplant -Zeitblöcke in den Kalender schreiben, Wochenplanung per Ziehen und Ablegen, Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). @@ -51,6 +53,7 @@ MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). | 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) | diff --git a/ROADMAP.md b/ROADMAP.md index 61427dc..a33ace7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ 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 | erledigt | -| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant | +| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt | | [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant | | [M6](#m6--integrationen) | Integrationen | geplant | | [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant | @@ -76,15 +76,21 @@ später" und wartet darauf, ob es im Alltag wirklich fehlt. ## 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 diff --git a/devlog/DEVLOG.md b/devlog/DEVLOG.md index 2b0c402..29e2452 100644 --- a/devlog/DEVLOG.md +++ b/devlog/DEVLOG.md @@ -10,7 +10,84 @@ die Übersicht aus `scripts/zeit.sh summe`. | M1 | claude | 161 | 2,68 | | M2 | claude | 44 | 0,73 | | M3 | claude | 41 | 0,68 | -| **Gesamt** | | **246** | **4,10** | +| M4 | claude | 167 | 2,78 | +| **Gesamt** | | **413** | **6,88** | + +--- + +## 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 --- diff --git a/devlog/zeiten.csv b/devlog/zeiten.csv index 0a5c029..0e74973 100644 --- a/devlog/zeiten.csv +++ b/devlog/zeiten.csv @@ -2,3 +2,4 @@ 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 diff --git a/docs/bilder/anmelden-dunkel-handy.png b/docs/bilder/anmelden-dunkel-handy.png index bc400aa..619bf91 100644 Binary files a/docs/bilder/anmelden-dunkel-handy.png and b/docs/bilder/anmelden-dunkel-handy.png differ diff --git a/docs/bilder/anmelden-dunkel-schreibtisch.png b/docs/bilder/anmelden-dunkel-schreibtisch.png index 4ca28d5..efeccc0 100644 Binary files a/docs/bilder/anmelden-dunkel-schreibtisch.png and b/docs/bilder/anmelden-dunkel-schreibtisch.png differ diff --git a/docs/bilder/anmelden-hell-handy.png b/docs/bilder/anmelden-hell-handy.png index e47c6cf..3eb45f2 100644 Binary files a/docs/bilder/anmelden-hell-handy.png and b/docs/bilder/anmelden-hell-handy.png differ diff --git a/docs/bilder/anmelden-hell-schreibtisch.png b/docs/bilder/anmelden-hell-schreibtisch.png index 5429f54..cc9e53c 100644 Binary files a/docs/bilder/anmelden-hell-schreibtisch.png and b/docs/bilder/anmelden-hell-schreibtisch.png differ diff --git a/docs/bilder/aufgaben-dunkel-handy.png b/docs/bilder/aufgaben-dunkel-handy.png index 065727b..5060076 100644 Binary files a/docs/bilder/aufgaben-dunkel-handy.png and b/docs/bilder/aufgaben-dunkel-handy.png differ diff --git a/docs/bilder/aufgaben-dunkel-schreibtisch.png b/docs/bilder/aufgaben-dunkel-schreibtisch.png index a43f2e8..2c74ed3 100644 Binary files a/docs/bilder/aufgaben-dunkel-schreibtisch.png and b/docs/bilder/aufgaben-dunkel-schreibtisch.png differ diff --git a/docs/bilder/aufgaben-hell-handy.png b/docs/bilder/aufgaben-hell-handy.png index bad5cbd..4557812 100644 Binary files a/docs/bilder/aufgaben-hell-handy.png and b/docs/bilder/aufgaben-hell-handy.png differ diff --git a/docs/bilder/aufgaben-hell-schreibtisch.png b/docs/bilder/aufgaben-hell-schreibtisch.png index e6d72d5..c5620f3 100644 Binary files a/docs/bilder/aufgaben-hell-schreibtisch.png and b/docs/bilder/aufgaben-hell-schreibtisch.png differ diff --git a/docs/bilder/bereiche-dunkel-handy.png b/docs/bilder/bereiche-dunkel-handy.png index bb6fbf7..0c28847 100644 Binary files a/docs/bilder/bereiche-dunkel-handy.png and b/docs/bilder/bereiche-dunkel-handy.png differ diff --git a/docs/bilder/bereiche-dunkel-schreibtisch.png b/docs/bilder/bereiche-dunkel-schreibtisch.png index 724f77a..46fd4f6 100644 Binary files a/docs/bilder/bereiche-dunkel-schreibtisch.png and b/docs/bilder/bereiche-dunkel-schreibtisch.png differ diff --git a/docs/bilder/bereiche-hell-handy.png b/docs/bilder/bereiche-hell-handy.png index 617e703..d618b11 100644 Binary files a/docs/bilder/bereiche-hell-handy.png and b/docs/bilder/bereiche-hell-handy.png differ diff --git a/docs/bilder/bereiche-hell-schreibtisch.png b/docs/bilder/bereiche-hell-schreibtisch.png index e3cbef2..94c7fca 100644 Binary files a/docs/bilder/bereiche-hell-schreibtisch.png and b/docs/bilder/bereiche-hell-schreibtisch.png differ diff --git a/docs/bilder/einstellungen-dunkel-handy.png b/docs/bilder/einstellungen-dunkel-handy.png index 2ec1dc4..a306325 100644 Binary files a/docs/bilder/einstellungen-dunkel-handy.png and b/docs/bilder/einstellungen-dunkel-handy.png differ diff --git a/docs/bilder/einstellungen-dunkel-schreibtisch.png b/docs/bilder/einstellungen-dunkel-schreibtisch.png index 18370d0..54df560 100644 Binary files a/docs/bilder/einstellungen-dunkel-schreibtisch.png and b/docs/bilder/einstellungen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/einstellungen-hell-handy.png b/docs/bilder/einstellungen-hell-handy.png index 17f0c91..091a691 100644 Binary files a/docs/bilder/einstellungen-hell-handy.png and b/docs/bilder/einstellungen-hell-handy.png differ diff --git a/docs/bilder/einstellungen-hell-schreibtisch.png b/docs/bilder/einstellungen-hell-schreibtisch.png index e12f223..d0342c0 100644 Binary files a/docs/bilder/einstellungen-hell-schreibtisch.png and b/docs/bilder/einstellungen-hell-schreibtisch.png differ diff --git a/docs/bilder/ideen-dunkel-handy.png b/docs/bilder/ideen-dunkel-handy.png index df9ed8e..7275a53 100644 Binary files a/docs/bilder/ideen-dunkel-handy.png and b/docs/bilder/ideen-dunkel-handy.png differ diff --git a/docs/bilder/ideen-dunkel-schreibtisch.png b/docs/bilder/ideen-dunkel-schreibtisch.png index 3af0300..45daccf 100644 Binary files a/docs/bilder/ideen-dunkel-schreibtisch.png and b/docs/bilder/ideen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/ideen-hell-handy.png b/docs/bilder/ideen-hell-handy.png index 16219ff..ddfc8af 100644 Binary files a/docs/bilder/ideen-hell-handy.png and b/docs/bilder/ideen-hell-handy.png differ diff --git a/docs/bilder/ideen-hell-schreibtisch.png b/docs/bilder/ideen-hell-schreibtisch.png index d87b84d..5079d2a 100644 Binary files a/docs/bilder/ideen-hell-schreibtisch.png and b/docs/bilder/ideen-hell-schreibtisch.png differ diff --git a/docs/bilder/inbox-dunkel-handy.png b/docs/bilder/inbox-dunkel-handy.png index d74215a..1112518 100644 Binary files a/docs/bilder/inbox-dunkel-handy.png and b/docs/bilder/inbox-dunkel-handy.png differ diff --git a/docs/bilder/inbox-dunkel-schreibtisch.png b/docs/bilder/inbox-dunkel-schreibtisch.png index a03a3cf..2212554 100644 Binary files a/docs/bilder/inbox-dunkel-schreibtisch.png and b/docs/bilder/inbox-dunkel-schreibtisch.png differ diff --git a/docs/bilder/inbox-hell-handy.png b/docs/bilder/inbox-hell-handy.png index 94ad907..af19af5 100644 Binary files a/docs/bilder/inbox-hell-handy.png and b/docs/bilder/inbox-hell-handy.png differ diff --git a/docs/bilder/inbox-hell-schreibtisch.png b/docs/bilder/inbox-hell-schreibtisch.png index 7ee1624..213275d 100644 Binary files a/docs/bilder/inbox-hell-schreibtisch.png and b/docs/bilder/inbox-hell-schreibtisch.png differ diff --git a/docs/bilder/kalenderkonten-dunkel-handy.png b/docs/bilder/kalenderkonten-dunkel-handy.png index 097f083..dc70a78 100644 Binary files a/docs/bilder/kalenderkonten-dunkel-handy.png and b/docs/bilder/kalenderkonten-dunkel-handy.png differ diff --git a/docs/bilder/kalenderkonten-dunkel-schreibtisch.png b/docs/bilder/kalenderkonten-dunkel-schreibtisch.png index e2028bd..c8f0c90 100644 Binary files a/docs/bilder/kalenderkonten-dunkel-schreibtisch.png and b/docs/bilder/kalenderkonten-dunkel-schreibtisch.png differ diff --git a/docs/bilder/kalenderkonten-hell-handy.png b/docs/bilder/kalenderkonten-hell-handy.png index 931a8cc..1adb304 100644 Binary files a/docs/bilder/kalenderkonten-hell-handy.png and b/docs/bilder/kalenderkonten-hell-handy.png differ diff --git a/docs/bilder/kalenderkonten-hell-schreibtisch.png b/docs/bilder/kalenderkonten-hell-schreibtisch.png index cd2c795..584a693 100644 Binary files a/docs/bilder/kalenderkonten-hell-schreibtisch.png and b/docs/bilder/kalenderkonten-hell-schreibtisch.png differ diff --git a/docs/bilder/projekte-dunkel-handy.png b/docs/bilder/projekte-dunkel-handy.png index 3411486..3f6d820 100644 Binary files a/docs/bilder/projekte-dunkel-handy.png and b/docs/bilder/projekte-dunkel-handy.png differ diff --git a/docs/bilder/projekte-dunkel-schreibtisch.png b/docs/bilder/projekte-dunkel-schreibtisch.png index 1ce8249..18e0bee 100644 Binary files a/docs/bilder/projekte-dunkel-schreibtisch.png and b/docs/bilder/projekte-dunkel-schreibtisch.png differ diff --git a/docs/bilder/projekte-hell-handy.png b/docs/bilder/projekte-hell-handy.png index cd4a172..d55e0bb 100644 Binary files a/docs/bilder/projekte-hell-handy.png and b/docs/bilder/projekte-hell-handy.png differ diff --git a/docs/bilder/projekte-hell-schreibtisch.png b/docs/bilder/projekte-hell-schreibtisch.png index 3654f03..119e83c 100644 Binary files a/docs/bilder/projekte-hell-schreibtisch.png and b/docs/bilder/projekte-hell-schreibtisch.png differ diff --git a/docs/bilder/protokoll-dunkel-handy.png b/docs/bilder/protokoll-dunkel-handy.png index f004751..0c2fff4 100644 Binary files a/docs/bilder/protokoll-dunkel-handy.png and b/docs/bilder/protokoll-dunkel-handy.png differ diff --git a/docs/bilder/protokoll-dunkel-schreibtisch.png b/docs/bilder/protokoll-dunkel-schreibtisch.png index e5ce738..3dc1734 100644 Binary files a/docs/bilder/protokoll-dunkel-schreibtisch.png and b/docs/bilder/protokoll-dunkel-schreibtisch.png differ diff --git a/docs/bilder/protokoll-hell-handy.png b/docs/bilder/protokoll-hell-handy.png index 16ba490..8091203 100644 Binary files a/docs/bilder/protokoll-hell-handy.png and b/docs/bilder/protokoll-hell-handy.png differ diff --git a/docs/bilder/protokoll-hell-schreibtisch.png b/docs/bilder/protokoll-hell-schreibtisch.png index 2348930..9daae51 100644 Binary files a/docs/bilder/protokoll-hell-schreibtisch.png and b/docs/bilder/protokoll-hell-schreibtisch.png differ diff --git a/docs/bilder/start-dunkel-handy.png b/docs/bilder/start-dunkel-handy.png index 3e78f6f..52679d6 100644 Binary files a/docs/bilder/start-dunkel-handy.png and b/docs/bilder/start-dunkel-handy.png differ diff --git a/docs/bilder/start-dunkel-schreibtisch.png b/docs/bilder/start-dunkel-schreibtisch.png index bafd19a..8933f22 100644 Binary files a/docs/bilder/start-dunkel-schreibtisch.png and b/docs/bilder/start-dunkel-schreibtisch.png differ diff --git a/docs/bilder/start-hell-handy.png b/docs/bilder/start-hell-handy.png index 60177d2..fc51ef0 100644 Binary files a/docs/bilder/start-hell-handy.png and b/docs/bilder/start-hell-handy.png differ diff --git a/docs/bilder/start-hell-schreibtisch.png b/docs/bilder/start-hell-schreibtisch.png index eb6ab12..034ac99 100644 Binary files a/docs/bilder/start-hell-schreibtisch.png and b/docs/bilder/start-hell-schreibtisch.png differ diff --git a/docs/bilder/woche-dunkel-handy.png b/docs/bilder/woche-dunkel-handy.png new file mode 100644 index 0000000..23f763f Binary files /dev/null and b/docs/bilder/woche-dunkel-handy.png differ diff --git a/docs/bilder/woche-dunkel-schreibtisch.png b/docs/bilder/woche-dunkel-schreibtisch.png new file mode 100644 index 0000000..71d42be Binary files /dev/null and b/docs/bilder/woche-dunkel-schreibtisch.png differ diff --git a/docs/bilder/woche-hell-handy.png b/docs/bilder/woche-hell-handy.png new file mode 100644 index 0000000..fc30ca6 Binary files /dev/null and b/docs/bilder/woche-hell-handy.png differ diff --git a/docs/bilder/woche-hell-schreibtisch.png b/docs/bilder/woche-hell-schreibtisch.png new file mode 100644 index 0000000..f78ac1f Binary files /dev/null and b/docs/bilder/woche-hell-schreibtisch.png differ diff --git a/docs/handbuch/README.md b/docs/handbuch/README.md index 09cc555..38933a0 100644 --- a/docs/handbuch/README.md +++ b/docs/handbuch/README.md @@ -18,10 +18,12 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema. 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 ## Verwaltung -7. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat +8. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat ## Was noch kommt @@ -29,7 +31,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen: | Thema | Meilenstein | | ------------------------------- | ----------- | -| Wochenplanung und Zeitblöcke | M4 | | Benachrichtigungen | M5 | | Integrationen | M6 | | Schnellerfassung vom Handy, API | M7 | diff --git a/docs/handbuch/woche.md b/docs/handbuch/woche.md new file mode 100644 index 0000000..beb6814 --- /dev/null +++ b/docs/handbuch/woche.md @@ -0,0 +1,106 @@ +# Wochenplanung und Zeitblöcke + +Hier wird aus einer Aufgabe ein Termin. Du ziehst sie in ein Zeitfenster, und +der Planer legt sie als **Zeitblock** in deinen echten Kalender. + +![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. diff --git a/e2e/woche.spec.ts b/e2e/woche.spec.ts new file mode 100644 index 0000000..94d7394 --- /dev/null +++ b/e2e/woche.spec.ts @@ -0,0 +1,126 @@ +import { PrismaPg } from '@prisma/adapter-pg' +import { expect, test, type Page } from '@playwright/test' +import { PrismaClient } from '../src/generated/prisma/client.js' + +/** + * 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 EMAIL = process.env.ADMIN_EMAIL ?? '' +const PASSWORT = process.env.ADMIN_PASSWORT ?? '' +const PRAEFIX = `E2E-W-${Date.now()}` + +const verbindung = process.env.DATABASE_URL ?? '' +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() + }) + + async function anmelden(seite: Page): Promise { + await seite.goto('/anmelden') + await seite.getByLabel('E-Mail-Adresse').fill(EMAIL) + await seite.getByLabel('Passwort').fill(PASSWORT) + await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click() + await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() + } + + test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => { + 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$/) + }) +}) diff --git a/package-lock.json b/package-lock.json index de91e48..843ba97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "planer", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "planer", - "version": "0.3.0", + "version": "0.4.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", @@ -593,6 +595,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", diff --git a/package.json b/package.json index f696d25..7568b03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "planer", - "version": "0.3.0", + "version": "0.4.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", diff --git a/prisma/demo-seed.ts b/prisma/demo-seed.ts index 8b960f7..fee602e 100644 --- a/prisma/demo-seed.ts +++ b/prisma/demo-seed.ts @@ -373,6 +373,37 @@ async function main(): Promise { }, }) + // --- 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: { diff --git a/prisma/migrations/20260910184542_zeitbloecke/migration.sql b/prisma/migrations/20260910184542_zeitbloecke/migration.sql new file mode 100644 index 0000000..51cabeb --- /dev/null +++ b/prisma/migrations/20260910184542_zeitbloecke/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c3c8d1..70c018f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -222,8 +222,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 @@ -305,9 +306,10 @@ model Kalenderkonto { erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt - benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) termine Termin[] - standardFuerBereiche Bereich[] @relation("StandardKalenderkonto") + standardFuerBereiche Bereich[] @relation("StandardKalenderkonto") + zeitblocks Zeitblock[] /// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer. @@unique([benutzerId, anbieter, extKontoId, kalenderId]) @@ -340,3 +342,39 @@ model Termin { @@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") +} diff --git a/screenshots/bilder.spec.ts b/screenshots/bilder.spec.ts index 9f70913..573fa12 100644 --- a/screenshots/bilder.spec.ts +++ b/screenshots/bilder.spec.ts @@ -31,6 +31,7 @@ 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' }, diff --git a/src/app/(app)/woche/page.tsx b/src/app/(app)/woche/page.tsx new file mode 100644 index 0000000..a7c7430 --- /dev/null +++ b/src/app/(app)/woche/page.tsx @@ -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. + */ +
+
+
+
+

+ + Woche +

+

+ {alsDatum(montag)} bis {alsDatum(sonntag)} + {konto ? ` · schreibt nach ${konto.anzeigename}` : ''} +

+
+ + +
+ + {!konto && ( + + + Noch kein Kalender verbunden. Zeitblöcke landen erst dann im Kalender —{' '} + + jetzt verbinden + + . + + + )} + + ({ + 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, + }))} + /> + +

+ 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. +

+
+
+ ) +} diff --git a/src/app/(app)/woche/wochenraster.tsx b/src/app/(app)/woche/wochenraster.tsx new file mode 100644 index 0000000..d1de1ed --- /dev/null +++ b/src/app/(app)/woche/wochenraster.tsx @@ -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(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) { + 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 ( + +
+ {/* Vorrat: was noch eingeplant werden kann */} + + + {/* Das Wochenraster */} +
+
+
+
+ {spalten.map((spalte) => ( +
+

{spalte.name}

+

{spalte.datum}

+

+ {spalte.kapazitaet === 0 + ? '—' + : `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`} +

+
+ ))} +
+ +
+ {/* Stundenleiste */} +
+ {zeilen.map((minute) => ( +
+ {zeilenBeschriftung(minute)} +
+ ))} +
+ + {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 ( +
+ {zeilen.map((minute) => ( + + ))} + + {/* 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 ( +
+ + {eintrag.betreff ?? 'Termin'} + +
+ ) + })} + + {/* 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 ( + ausplanen(block.id)} + /> + ) + })} +
+ ) + })} +
+
+
+
+ + + {gezogeneAufgabe && ( +
+ {gezogeneAufgabe.titel} + + {alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)} + +
+ )} + {gezogenerBlock && ( +
+ {gezogenerBlock.titel} +
+ )} +
+ + ) +} + +function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) { + const { setNodeRef, isOver } = useDroppable({ id: kennung }) + return ( +
+ ) +} + +function AufgabenKachel({ + aufgabe, + gesperrt, +}: { + aufgabe: PlanbareAufgabe + gesperrt: boolean +}) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `aufgabe:${aufgabe.id}`, + disabled: gesperrt, + }) + + return ( +
  • + + +
    +

    {aufgabe.titel}

    +
    + {aufgabe.bereichName && ( + + + {aufgabe.bereichName} + + )} + + {alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)} + + {aufgabe.faelligText && ( + + {aufgabe.faelligText} + + )} + {aufgabe.bereitsGeplant > 0 && ( + + + {aufgabe.bereitsGeplant} + + )} +
    +
    +
  • + ) +} + +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 ( +
    +
    + + + {block.titel} + + {(block.offen || block.letzterFehler) && ( + + )} + + +
    +
    + ) +} diff --git a/src/components/kopfleiste.tsx b/src/components/kopfleiste.tsx index f547cc3..e00c58d 100644 --- a/src/components/kopfleiste.tsx +++ b/src/components/kopfleiste.tsx @@ -30,6 +30,7 @@ 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' }, diff --git a/src/lib/woche.test.ts b/src/lib/woche.test.ts new file mode 100644 index 0000000..029db7a --- /dev/null +++ b/src/lib/woche.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { + ZEILE_HOEHE, + balkenlage, + fensterHuelle, + rasterBauen, + schluesselFuer, + spurenVerteilen, + zeilenBeschriftung, + zeitpunktAus, +} from './woche' + +// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1. +const dienstag = new Date('2026-03-17T09:00:00Z') + +describe('rasterBauen', () => { + it('beginnt am Montag und hat sieben Spalten', () => { + const raster = rasterBauen(dienstag, 8 * 60, 18 * 60) + expect(raster.spalten).toHaveLength(7) + expect(raster.spalten[0]?.wochentag).toBe(0) + expect(raster.spalten[6]?.wochentag).toBe(6) + }) + + it('legt den Montag auf den richtigen Tag', () => { + const raster = rasterBauen(dienstag, 8 * 60, 18 * 60) + // Montag, 16. März, 00:00 Berlin = 23:00 UTC am 15. + expect(raster.spalten[0]?.tagesBeginn.toISOString()).toBe('2026-03-15T23:00:00.000Z') + }) + + it('teilt das Fenster in Viertelstunden', () => { + const raster = rasterBauen(dienstag, 8 * 60, 10 * 60) + expect(raster.zeilen).toEqual([480, 495, 510, 525, 540, 555, 570, 585]) + }) + + it('lässt die letzte Zeile vor dem Ende enden', () => { + const raster = rasterBauen(dienstag, 8 * 60, 9 * 60) + expect(raster.zeilen.at(-1)).toBe(525) + }) +}) + +describe('schluesselFuer und zeitpunktAus', () => { + it('finden zurück zum Ausgangspunkt', () => { + const schluessel = schluesselFuer(dienstag) + // 09:00 Berlin am selben Tag. + expect(zeitpunktAus(schluessel, 9 * 60).toISOString()).toBe( + '2026-03-17T08:00:00.000Z', + ) + }) + + it('geben für denselben Tag denselben Schlüssel', () => { + expect(schluesselFuer(new Date('2026-03-17T06:00:00Z'))).toBe( + schluesselFuer(new Date('2026-03-17T20:00:00Z')), + ) + }) +}) + +describe('fensterHuelle', () => { + it('umspannt alle Arbeitsfenster mit etwas Luft', () => { + const huelle = fensterHuelle([ + { arbeitsfensterVon: 480, arbeitsfensterBis: 1020 }, + { arbeitsfensterVon: 1020, arbeitsfensterBis: 1260 }, + ]) + expect(huelle.von).toBe(420) + expect(huelle.bis).toBe(1320) + }) + + it('bleibt innerhalb eines Tages', () => { + const huelle = fensterHuelle([{ arbeitsfensterVon: 30, arbeitsfensterBis: 1410 }]) + expect(huelle.von).toBe(0) + expect(huelle.bis).toBe(1440) + }) + + it('nimmt ohne Bereiche einen brauchbaren Standard', () => { + expect(fensterHuelle([])).toEqual({ von: 480, bis: 1080 }) + }) +}) + +describe('balkenlage', () => { + const raster = rasterBauen(dienstag, 8 * 60, 18 * 60) + const spalte = raster.spalten[1]! + + it('setzt einen Block an die richtige Stelle', () => { + // 09:00 bis 10:00 Berlin. + const lage = balkenlage( + new Date('2026-03-17T08:00:00Z'), + new Date('2026-03-17T09:00:00Z'), + spalte, + raster, + ) + // Eine Stunde nach Fensterbeginn = vier Zeilen. + expect(lage.oben).toBe(4 * ZEILE_HOEHE) + expect(lage.hoehe).toBe(4 * ZEILE_HOEHE) + expect(lage.ausserhalb).toBe(false) + }) + + it('schneidet oben ab, was vor dem Fenster beginnt', () => { + // 07:00 bis 09:00 Berlin, Fenster beginnt um 08:00. + const lage = balkenlage( + new Date('2026-03-17T06:00:00Z'), + new Date('2026-03-17T08:00:00Z'), + spalte, + raster, + ) + expect(lage.oben).toBe(0) + expect(lage.hoehe).toBe(4 * ZEILE_HOEHE) + }) + + it('schneidet unten ab, was über das Fenster hinausgeht', () => { + // 17:00 bis 20:00 Berlin, Fenster endet um 18:00. + const lage = balkenlage( + new Date('2026-03-17T16:00:00Z'), + new Date('2026-03-17T19:00:00Z'), + spalte, + raster, + ) + expect(lage.hoehe).toBe(4 * ZEILE_HOEHE) + }) + + it('erkennt einen Block ganz außerhalb des Fensters', () => { + // 22:00 bis 23:00 Berlin. + const lage = balkenlage( + new Date('2026-03-17T21:00:00Z'), + new Date('2026-03-17T22:00:00Z'), + spalte, + raster, + ) + expect(lage.ausserhalb).toBe(true) + }) + + it('gibt auch dem kürzesten Block eine sichtbare Höhe', () => { + const lage = balkenlage( + new Date('2026-03-17T08:00:00Z'), + new Date('2026-03-17T08:15:00Z'), + spalte, + raster, + ) + expect(lage.hoehe).toBeGreaterThanOrEqual(ZEILE_HOEHE) + }) +}) + +describe('zeilenBeschriftung', () => { + it('beschriftet nur volle Stunden', () => { + expect(zeilenBeschriftung(480)).toBe('08:00') + expect(zeilenBeschriftung(495)).toBeNull() + expect(zeilenBeschriftung(540)).toBe('09:00') + }) +}) + +describe('spurenVerteilen', () => { + function eintrag(von: string, bis: string) { + return { beginn: new Date(von), ende: new Date(bis) } + } + + it('gibt einem einzelnen Eintrag die volle Breite', () => { + const verteilt = spurenVerteilen([ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + ]) + expect(verteilt[0]?.spur).toBe(0) + expect(verteilt[0]?.spuren).toBe(1) + }) + + it('legt zwei sich überschneidende Einträge nebeneinander', () => { + const verteilt = spurenVerteilen([ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'), + eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'), + ]) + expect(verteilt.map((v) => v.spur)).toEqual([0, 1]) + expect(verteilt.every((v) => v.spuren === 2)).toBe(true) + }) + + it('lässt getrennte Einträge beide die volle Breite behalten', () => { + const verteilt = spurenVerteilen([ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + eintrag('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'), + ]) + expect(verteilt.every((v) => v.spuren === 1)).toBe(true) + }) + + it('gibt eine Spur wieder frei, sobald sie zu Ende ist', () => { + const verteilt = spurenVerteilen([ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'), + eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'), + // Beginnt nach dem ersten, kann dessen Spur übernehmen. + eintrag('2026-03-17T10:00:00Z', '2026-03-17T12:00:00Z'), + ]) + expect(verteilt.map((v) => v.spur)).toEqual([0, 1, 0]) + // Alle drei hängen über eine Kette zusammen und teilen sich zwei Spuren. + expect(verteilt.every((v) => v.spuren === 2)).toBe(true) + }) + + it('trennt Gruppen, die sich nicht berühren', () => { + const verteilt = spurenVerteilen([ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'), + eintrag('2026-03-17T08:30:00Z', '2026-03-17T09:30:00Z'), + eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'), + ]) + expect(verteilt[0]?.spuren).toBe(2) + expect(verteilt[1]?.spuren).toBe(2) + expect(verteilt[2]?.spuren).toBe(1) + }) + + it('behält jeden Eintrag genau einmal', () => { + const eintraege = [ + eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'), + eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'), + eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'), + ] + expect(spurenVerteilen(eintraege)).toHaveLength(eintraege.length) + }) +}) diff --git a/src/lib/woche.ts b/src/lib/woche.ts new file mode 100644 index 0000000..45ad320 --- /dev/null +++ b/src/lib/woche.ts @@ -0,0 +1,194 @@ +import { alsUhrzeit, tagesBeginn, wochenTage, wochentagIndex } from '@/lib/zeit' +import { RASTER_MINUTEN } from '@/lib/zeitblock' + +/** + * Aufbau des Wochenrasters. + * + * Reine Logik: aus einem Zeitpunkt, einem Stundenfenster und einer Liste von + * Blöcken entsteht alles, was die Ansicht braucht — Spalten, Zeilen und die + * Lage jedes Blocks darin. Keine Datenbank, keine Systemzeit. + */ + +/** Höhe einer Rasterzeile in Pixeln. 15 Minuten = 14 px. */ +export const ZEILE_HOEHE = 14 + +export type Tagesspalte = { + /** 00:00 dieses Tages in Europe/Berlin. */ + tagesBeginn: Date + /** 0 = Montag. */ + wochentag: number + /** ISO-Datum, taugt als Schlüssel. */ + schluessel: string +} + +export type Raster = { + spalten: Tagesspalte[] + /** Minuten seit Mitternacht, an denen die Zeilen beginnen. */ + zeilen: number[] + vonMinute: number + bisMinute: number +} + +/** + * Baut das Raster für die Woche, in der der Zeitpunkt liegt. + * + * Das Stundenfenster kommt von außen — sinnvollerweise als Hülle über die + * Arbeitsfenster aller Bereiche, damit nichts abgeschnitten wird, was + * geplant werden darf. + */ +export function rasterBauen( + zeitpunkt: Date, + vonMinute: number, + bisMinute: number, +): Raster { + const tage = wochenTage(zeitpunkt) + + const zeilen: number[] = [] + for (let minute = vonMinute; minute < bisMinute; minute += RASTER_MINUTEN) { + zeilen.push(minute) + } + + return { + spalten: tage.map((tag) => ({ + tagesBeginn: tag, + wochentag: wochentagIndex(tag), + schluessel: schluesselFuer(tag), + })), + zeilen, + vonMinute, + bisMinute, + } +} + +/** Schlüssel eines Tages im Raster, unabhängig von der Zeitzone lesbar. */ +export function schluesselFuer(tag: Date): string { + const beginn = tagesBeginn(tag) + return String(beginn.getTime()) +} + +/** Zeitpunkt aus Tagesschlüssel und Minute seit Mitternacht. */ +export function zeitpunktAus(schluessel: string, minute: number): Date { + return new Date(Number(schluessel) + minute * 60_000) +} + +/** + * Hülle über alle Arbeitsfenster, mit etwas Luft nach oben und unten. + * + * Ohne die Luft läge ein Block, der genau am Rand beginnt, an der Kante des + * Rasters und ließe sich kaum greifen. + */ +export function fensterHuelle( + bereiche: readonly { arbeitsfensterVon: number; arbeitsfensterBis: number }[], + luftMinuten = 60, +): { von: number; bis: number } { + if (bereiche.length === 0) return { von: 8 * 60, bis: 18 * 60 } + + const von = Math.min(...bereiche.map((b) => b.arbeitsfensterVon)) + const bis = Math.max(...bereiche.map((b) => b.arbeitsfensterBis)) + + return { + von: Math.max(0, aufRasterMinute(von - luftMinuten)), + bis: Math.min(24 * 60, aufRasterMinute(bis + luftMinuten)), + } +} + +function aufRasterMinute(minute: number): number { + return Math.round(minute / RASTER_MINUTEN) * RASTER_MINUTEN +} + +export type Balkenlage = { + /** Abstand von oben in Pixeln. */ + oben: number + /** Höhe in Pixeln. */ + hoehe: number + /** Liegt der Block ganz außerhalb des sichtbaren Fensters? */ + ausserhalb: boolean +} + +/** + * Wo ein Zeitraum im Raster eines Tages liegt. + * + * Ragt er über den sichtbaren Bereich hinaus, wird er abgeschnitten — sonst + * würde ein Termin von 6 bis 23 Uhr das ganze Raster sprengen. + */ +export function balkenlage( + beginn: Date, + ende: Date, + spalte: Tagesspalte, + raster: Raster, +): Balkenlage { + const tagBeginn = spalte.tagesBeginn.getTime() + const vonMinute = (beginn.getTime() - tagBeginn) / 60_000 + const bisMinute = (ende.getTime() - tagBeginn) / 60_000 + + if (bisMinute <= raster.vonMinute || vonMinute >= raster.bisMinute) { + return { oben: 0, hoehe: 0, ausserhalb: true } + } + + const sichtbarVon = Math.max(vonMinute, raster.vonMinute) + const sichtbarBis = Math.min(bisMinute, raster.bisMinute) + + return { + oben: ((sichtbarVon - raster.vonMinute) / RASTER_MINUTEN) * ZEILE_HOEHE, + hoehe: Math.max( + ZEILE_HOEHE, + ((sichtbarBis - sichtbarVon) / RASTER_MINUTEN) * ZEILE_HOEHE, + ), + ausserhalb: false, + } +} + +/** Beschriftung einer Rasterzeile; nur volle Stunden bekommen eine. */ +export function zeilenBeschriftung(minute: number): string | null { + return minute % 60 === 0 ? alsUhrzeit(minute) : null +} + +/** + * Verteilt sich überschneidende Einträge auf Spuren nebeneinander. + * + * Ohne das läge ein Termin genau über dem anderen und einer wäre unsichtbar. + */ +export function spurenVerteilen( + eintraege: readonly T[], +): { eintrag: T; spur: number; spuren: number }[] { + const sortiert = [...eintraege].sort((a, b) => a.beginn.getTime() - b.beginn.getTime()) + + // Gruppen bilden: alles, was sich über eine Kette von Überschneidungen + // berührt, teilt sich dieselbe Anzahl Spuren. + const gruppen: T[][] = [] + let aktuelle: T[] = [] + let gruppenEnde = 0 + + for (const eintrag of sortiert) { + if (aktuelle.length > 0 && eintrag.beginn.getTime() >= gruppenEnde) { + gruppen.push(aktuelle) + aktuelle = [] + } + aktuelle.push(eintrag) + gruppenEnde = Math.max(gruppenEnde, eintrag.ende.getTime()) + } + if (aktuelle.length > 0) gruppen.push(aktuelle) + + const ergebnis: { eintrag: T; spur: number; spuren: number }[] = [] + + for (const gruppe of gruppen) { + const spurEnden: number[] = [] + const zuordnung: { eintrag: T; spur: number }[] = [] + + for (const eintrag of gruppe) { + let spur = spurEnden.findIndex((ende) => ende <= eintrag.beginn.getTime()) + if (spur === -1) { + spur = spurEnden.length + spurEnden.push(0) + } + spurEnden[spur] = eintrag.ende.getTime() + zuordnung.push({ eintrag, spur }) + } + + for (const { eintrag, spur } of zuordnung) { + ergebnis.push({ eintrag, spur, spuren: spurEnden.length }) + } + } + + return ergebnis +} diff --git a/src/lib/zeitblock.test.ts b/src/lib/zeitblock.test.ts new file mode 100644 index 0000000..596e168 --- /dev/null +++ b/src/lib/zeitblock.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from 'vitest' +import { + RASTER_MINUTEN, + ZEITBLOCK_MAX_MINUTEN, + ZEITBLOCK_MIN_MINUTEN, + abgleichEntscheiden, + aufRaster, + dauerMinuten, + lagePruefen, + mussSchreiben, + ueberschneidetSich, + zeitblockSchema, + zeitraumHash, +} from './zeitblock' + +const beginn = new Date('2026-03-17T09:00:00Z') +const ende = new Date('2026-03-17T10:00:00Z') + +describe('zeitraumHash', () => { + it('ist für denselben Zeitraum gleich', () => { + expect(zeitraumHash(beginn, ende)).toBe( + zeitraumHash(new Date(beginn), new Date(ende)), + ) + }) + + it('ändert sich, wenn der Beginn wandert', () => { + expect(zeitraumHash(beginn, ende)).not.toBe( + zeitraumHash(new Date('2026-03-17T09:15:00Z'), ende), + ) + }) + + it('ändert sich, wenn das Ende wandert', () => { + expect(zeitraumHash(beginn, ende)).not.toBe( + zeitraumHash(beginn, new Date('2026-03-17T10:30:00Z')), + ) + }) + + it('ist unabhängig davon, wie der Zeitpunkt entstanden ist', () => { + // Derselbe Augenblick, anders geschrieben. + expect(zeitraumHash(new Date('2026-03-17T09:00:00.000Z'), ende)).toBe( + zeitraumHash(new Date('2026-03-17T10:00:00+01:00'), ende), + ) + }) +}) + +describe('abgleichEntscheiden — der Schleifenschutz', () => { + it('tut nichts, wenn im Kalender genau das steht, was wir geschrieben haben', () => { + // Das ist der Fall, der die Schleife bricht: der Abgleich liest die + // eigene Änderung und erkennt sie am Fingerabdruck wieder. + const entscheidung = abgleichEntscheiden( + { hash: zeitraumHash(beginn, ende), beginn, ende }, + { beginn, ende }, + ) + expect(entscheidung).toEqual({ tun: 'nichts', grund: 'eigene-aenderung' }) + }) + + it('tut auch ohne Fingerabdruck nichts, wenn beide Seiten übereinstimmen', () => { + const entscheidung = abgleichEntscheiden( + { hash: null, beginn, ende }, + { beginn, ende }, + ) + expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' }) + }) + + it('übernimmt eine fremde Verschiebung — der Kalender gewinnt', () => { + const verschobenerBeginn = new Date('2026-03-17T11:00:00Z') + const verschobenesEnde = new Date('2026-03-17T12:00:00Z') + + const entscheidung = abgleichEntscheiden( + { hash: zeitraumHash(beginn, ende), beginn, ende }, + { beginn: verschobenerBeginn, ende: verschobenesEnde }, + ) + + expect(entscheidung).toEqual({ + tun: 'uebernehmen', + grund: 'fremd-verschoben', + beginn: verschobenerBeginn, + ende: verschobenesEnde, + }) + }) + + it('erkennt eine fremde Verschiebung auch ohne vorherigen Fingerabdruck', () => { + const entscheidung = abgleichEntscheiden( + { hash: null, beginn, ende }, + { + beginn: new Date('2026-03-17T11:00:00Z'), + ende: new Date('2026-03-17T12:00:00Z'), + }, + ) + expect(entscheidung.tun).toBe('uebernehmen') + }) + + it('löst den Zeitblock, wenn der Termin im Kalender verschwunden ist', () => { + const entscheidung = abgleichEntscheiden( + { hash: zeitraumHash(beginn, ende), beginn, ende }, + null, + ) + expect(entscheidung).toEqual({ tun: 'loesen', grund: 'im-kalender-entfernt' }) + }) + + it('schaukelt sich über mehrere Runden nicht hoch', () => { + // Die App schreibt, der Abgleich läuft dreimal – und tut jedes Mal nichts. + let stand = { hash: zeitraumHash(beginn, ende), beginn, ende } + for (let runde = 0; runde < 3; runde++) { + const entscheidung = abgleichEntscheiden(stand, { + beginn: stand.beginn, + ende: stand.ende, + }) + expect(entscheidung.tun).toBe('nichts') + stand = { ...stand } + } + }) + + it('kommt nach einer übernommenen Verschiebung zur Ruhe', () => { + const neuerBeginn = new Date('2026-03-17T11:00:00Z') + const neuesEnde = new Date('2026-03-17T12:00:00Z') + + // Erste Runde: fremde Verschiebung wird übernommen. + const erste = abgleichEntscheiden( + { hash: zeitraumHash(beginn, ende), beginn, ende }, + { beginn: neuerBeginn, ende: neuesEnde }, + ) + expect(erste.tun).toBe('uebernehmen') + + // Danach steht der neue Stand samt Fingerabdruck; die zweite Runde + // erkennt ihn wieder. + const zweite = abgleichEntscheiden( + { + hash: zeitraumHash(neuerBeginn, neuesEnde), + beginn: neuerBeginn, + ende: neuesEnde, + }, + { beginn: neuerBeginn, ende: neuesEnde }, + ) + expect(zweite.tun).toBe('nichts') + }) +}) + +describe('mussSchreiben', () => { + it('schreibt bei einer echten Verschiebung', () => { + expect( + mussSchreiben( + { hash: zeitraumHash(beginn, ende), beginn, ende }, + new Date('2026-03-17T11:00:00Z'), + new Date('2026-03-17T12:00:00Z'), + ), + ).toBe(true) + }) + + it('schreibt nicht, wenn der Zeitraum gleich bleibt', () => { + // Etwa wenn nur der Aufgabentitel geändert wurde. + expect( + mussSchreiben({ hash: zeitraumHash(beginn, ende), beginn, ende }, beginn, ende), + ).toBe(false) + }) + + it('schreibt, solange noch nichts geschrieben wurde', () => { + expect(mussSchreiben({ hash: null, beginn, ende }, beginn, ende)).toBe(true) + }) +}) + +describe('dauerMinuten', () => { + it('rechnet in Minuten', () => { + expect(dauerMinuten(beginn, ende)).toBe(60) + expect(dauerMinuten(beginn, new Date('2026-03-17T09:45:00Z'))).toBe(45) + }) +}) + +describe('aufRaster', () => { + it('rastet auf die nächste Viertelstunde ein', () => { + expect(aufRaster(new Date('2026-03-17T09:07:00Z')).toISOString()).toBe( + '2026-03-17T09:00:00.000Z', + ) + expect(aufRaster(new Date('2026-03-17T09:08:00Z')).toISOString()).toBe( + '2026-03-17T09:15:00.000Z', + ) + }) + + it('lässt einen bereits eingerasteten Zeitpunkt in Ruhe', () => { + expect(aufRaster(beginn).toISOString()).toBe(beginn.toISOString()) + }) +}) + +describe('lagePruefen', () => { + it('nimmt eine gültige Lage an', () => { + expect(lagePruefen(beginn, ende)).toBeNull() + }) + + it('weist ein Ende vor dem Beginn ab', () => { + expect(lagePruefen(ende, beginn)).toBe('ende-vor-beginn') + expect(lagePruefen(beginn, beginn)).toBe('ende-vor-beginn') + }) + + it('weist zu kurze Blöcke ab und nimmt die Untergrenze an', () => { + const knappZuKurz = new Date(beginn.getTime() + (ZEITBLOCK_MIN_MINUTEN - 15) * 60_000) + expect(lagePruefen(beginn, knappZuKurz)).toBe('ende-vor-beginn') + + const genauMinimum = new Date(beginn.getTime() + ZEITBLOCK_MIN_MINUTEN * 60_000) + expect(lagePruefen(beginn, genauMinimum)).toBeNull() + }) + + it('weist zu lange Blöcke ab und nimmt die Obergrenze an', () => { + const genauMaximum = new Date(beginn.getTime() + ZEITBLOCK_MAX_MINUTEN * 60_000) + expect(lagePruefen(beginn, genauMaximum)).toBeNull() + + const zuLang = new Date( + beginn.getTime() + (ZEITBLOCK_MAX_MINUTEN + RASTER_MINUTEN) * 60_000, + ) + expect(lagePruefen(beginn, zuLang)).toBe('zu-lang') + }) + + it('weist Zeiten außerhalb des Rasters ab', () => { + expect(lagePruefen(new Date('2026-03-17T09:07:00Z'), ende)).toBe('nicht-im-raster') + expect(lagePruefen(beginn, new Date('2026-03-17T10:07:00Z'))).toBe('nicht-im-raster') + }) +}) + +describe('ueberschneidetSich', () => { + const block = { beginn, ende } + + it('erkennt eine Überschneidung', () => { + expect( + ueberschneidetSich(block, { + beginn: new Date('2026-03-17T09:30:00Z'), + ende: new Date('2026-03-17T10:30:00Z'), + }), + ).toBe(true) + }) + + it('zählt reines Aneinandergrenzen nicht', () => { + expect( + ueberschneidetSich(block, { + beginn: ende, + ende: new Date('2026-03-17T11:00:00Z'), + }), + ).toBe(false) + }) + + it('erkennt vollständige Umschließung', () => { + expect( + ueberschneidetSich(block, { + beginn: new Date('2026-03-17T08:00:00Z'), + ende: new Date('2026-03-17T12:00:00Z'), + }), + ).toBe(true) + }) + + it('erkennt getrennte Zeiträume', () => { + expect( + ueberschneidetSich(block, { + beginn: new Date('2026-03-17T14:00:00Z'), + ende: new Date('2026-03-17T15:00:00Z'), + }), + ).toBe(false) + }) +}) + +describe('zeitblockSchema', () => { + const gueltig = { + aufgabeId: 'a1', + kalenderkontoId: 'k1', + beginn: '2026-03-17T09:00:00.000Z', + ende: '2026-03-17T10:00:00.000Z', + } + + it('nimmt eine gültige Eingabe an', () => { + const ergebnis = zeitblockSchema.parse(gueltig) + expect(ergebnis.beginn).toBeInstanceOf(Date) + expect(dauerMinuten(ergebnis.beginn, ergebnis.ende)).toBe(60) + }) + + it('weist eine unmögliche Lage ab', () => { + expect( + zeitblockSchema.safeParse({ ...gueltig, ende: '2026-03-17T08:00:00.000Z' }).success, + ).toBe(false) + }) + + it('weist Zeiten außerhalb des Rasters ab', () => { + expect( + zeitblockSchema.safeParse({ ...gueltig, beginn: '2026-03-17T09:07:00.000Z' }) + .success, + ).toBe(false) + }) + + it('verlangt Aufgabe und Kalenderkonto', () => { + expect(zeitblockSchema.safeParse({ ...gueltig, aufgabeId: '' }).success).toBe(false) + expect(zeitblockSchema.safeParse({ ...gueltig, kalenderkontoId: '' }).success).toBe( + false, + ) + }) +}) diff --git a/src/lib/zeitblock.ts b/src/lib/zeitblock.ts new file mode 100644 index 0000000..7b3d687 --- /dev/null +++ b/src/lib/zeitblock.ts @@ -0,0 +1,177 @@ +import { createHash } from 'node:crypto' +import { z } from 'zod' + +/** + * Zeitblöcke und der Schleifenschutz. + * + * Ein Zeitblock ist eine Aufgabe, die zu einer bestimmten Zeit in einem + * Kalender steht. Er lebt an zwei Orten: hier und im Kalender des Anbieters. + * Beide Seiten dürfen ihn verschieben — und genau daraus entsteht die Gefahr + * einer Endlosschleife: + * + * Die App schreibt → der Abgleich liest die eigene Änderung → hält sie für + * fremd → schreibt zurück → der Abgleich liest wieder → … + * + * Der Ausweg ist ein Fingerabdruck über den Zeitraum. Beim Schreiben merkt + * sich der Zeitblock, was er in den Kalender gelegt hat. Kommt beim nächsten + * Abgleich derselbe Fingerabdruck zurück, war es die eigene Änderung und es + * passiert nichts. Weicht er ab, hat jemand anderes verschoben — dann gewinnt + * der Kalender. + * + * Reine Logik ohne Datenbank und ohne Systemzeit. + */ + +/** + * Fingerabdruck eines Zeitraums. + * + * Bewusst nur über Beginn und Ende: Titel und Beschreibung ändern sich auch + * aus harmlosen Gründen (etwa weil der Aufgabentitel bearbeitet wurde), und + * für die Planung zählt allein, wann der Block liegt. + */ +export function zeitraumHash(beginn: Date, ende: Date): string { + return createHash('sha256') + .update(`${beginn.toISOString()}|${ende.toISOString()}`) + .digest('hex') +} + +/** Was beim Abgleich mit einem Zeitblock zu tun ist. */ +export type Abgleichentscheidung = + | { tun: 'nichts'; grund: 'unveraendert' | 'eigene-aenderung' } + | { tun: 'uebernehmen'; grund: 'fremd-verschoben'; beginn: Date; ende: Date } + | { tun: 'loesen'; grund: 'im-kalender-entfernt' } + +export type ZeitblockStand = { + /** Was zuletzt in den Kalender geschrieben wurde. */ + hash: string | null + beginn: Date + ende: Date +} + +export type KalenderStand = { + beginn: Date + ende: Date +} | null + +/** + * Entscheidet, was beim Abgleich mit einem Zeitblock geschehen soll. + * + * Der Kalender gewinnt: Wurde ein Block dort verschoben, übernimmt der Planer + * das, statt zurückzuschreiben. Wer im Kalender etwas verschiebt, meint es so + * — und merkt es sofort, wenn der Planer es wieder wegzieht. + */ +export function abgleichEntscheiden( + zeitblock: ZeitblockStand, + imKalender: KalenderStand, +): Abgleichentscheidung { + if (!imKalender) { + // Im Kalender gelöscht. Der Zeitblock verliert seine Verankerung; die + // Aufgabe selbst bleibt bestehen. + return { tun: 'loesen', grund: 'im-kalender-entfernt' } + } + + const imKalenderHash = zeitraumHash(imKalender.beginn, imKalender.ende) + + // Steht im Kalender genau das, was wir zuletzt geschrieben haben, war es + // die eigene Änderung – nichts zu tun. Das bricht die Schleife. + if (zeitblock.hash !== null && imKalenderHash === zeitblock.hash) { + return { tun: 'nichts', grund: 'eigene-aenderung' } + } + + // Stimmt der Kalender ohnehin mit unserem Stand überein, ist auch nichts + // zu tun – etwa beim allerersten Abgleich nach dem Anlegen. + const eigenerHash = zeitraumHash(zeitblock.beginn, zeitblock.ende) + if (imKalenderHash === eigenerHash) { + return { tun: 'nichts', grund: 'unveraendert' } + } + + return { + tun: 'uebernehmen', + grund: 'fremd-verschoben', + beginn: imKalender.beginn, + ende: imKalender.ende, + } +} + +/** + * Muss beim Speichern in den Kalender geschrieben werden? + * + * Nur bei einer echten Verschiebung. Wird bloß der Aufgabentitel geändert, + * bleibt der Fingerabdruck gleich und es gibt keinen Grund, den Anbieter zu + * behelligen. + */ +export function mussSchreiben( + zeitblock: ZeitblockStand, + neuerBeginn: Date, + neuesEnde: Date, +): boolean { + return zeitblock.hash !== zeitraumHash(neuerBeginn, neuesEnde) +} + +// --- Lage und Dauer -------------------------------------------------------- + +export const ZEITBLOCK_MIN_MINUTEN = 15 +export const ZEITBLOCK_MAX_MINUTEN = 8 * 60 +/** Raster, in dem Zeitblöcke einrasten. */ +export const RASTER_MINUTEN = 15 + +export function dauerMinuten(beginn: Date, ende: Date): number { + return Math.round((ende.getTime() - beginn.getTime()) / 60_000) +} + +/** Rundet einen Zeitpunkt auf das Raster ab. */ +export function aufRaster(zeitpunkt: Date, raster = RASTER_MINUTEN): Date { + const minuten = raster * 60_000 + return new Date(Math.round(zeitpunkt.getTime() / minuten) * minuten) +} + +export type LageFehler = + 'zu-kurz' | 'zu-lang' | 'ende-vor-beginn' | 'nicht-im-raster' | null + +/** Prüft die Lage eines Zeitblocks. Gibt null zurück, wenn sie stimmt. */ +export function lagePruefen(beginn: Date, ende: Date): LageFehler { + if (ende.getTime() <= beginn.getTime()) return 'ende-vor-beginn' + + const dauer = dauerMinuten(beginn, ende) + if (dauer < ZEITBLOCK_MIN_MINUTEN) return 'zu-kurz' + if (dauer > ZEITBLOCK_MAX_MINUTEN) return 'zu-lang' + + if ( + beginn.getTime() % (RASTER_MINUTEN * 60_000) !== 0 || + ende.getTime() % (RASTER_MINUTEN * 60_000) !== 0 + ) { + return 'nicht-im-raster' + } + + return null +} + +export const LAGE_FEHLER_TEXT: Record, string> = { + 'zu-kurz': `Ein Zeitblock dauert mindestens ${ZEITBLOCK_MIN_MINUTEN} Minuten.`, + 'zu-lang': 'Mehr als acht Stunden am Stück gehört in mehrere Zeitblöcke.', + 'ende-vor-beginn': 'Das Ende muss nach dem Beginn liegen.', + 'nicht-im-raster': `Zeitblöcke rasten in ${RASTER_MINUTEN}-Minuten-Schritten ein.`, +} + +/** Überschneiden sich zwei Zeitblöcke? Aneinandergrenzen zählt nicht. */ +export function ueberschneidetSich( + a: { beginn: Date; ende: Date }, + b: { beginn: Date; ende: Date }, +): boolean { + return a.beginn.getTime() < b.ende.getTime() && b.beginn.getTime() < a.ende.getTime() +} + +// --- Eingaben -------------------------------------------------------------- + +export const zeitblockSchema = z + .object({ + aufgabeId: z.string().min(1), + kalenderkontoId: z.string().min(1), + beginn: z.coerce.date('Bitte einen gültigen Beginn angeben.'), + ende: z.coerce.date('Bitte ein gültiges Ende angeben.'), + }) + .refine((w) => lagePruefen(w.beginn, w.ende) === null, { + path: ['ende'], + message: 'Die Lage des Zeitblocks stimmt nicht.', + }) + +export type ZeitblockEingabe = z.infer diff --git a/src/server/abfragen/woche.ts b/src/server/abfragen/woche.ts new file mode 100644 index 0000000..bbb7f0d --- /dev/null +++ b/src/server/abfragen/woche.ts @@ -0,0 +1,155 @@ +import 'server-only' +import type { ZeigtAls } from '@/generated/prisma/enums' +import { tagesBeginn, wochenBeginn } from '@/lib/zeit' +import { db } from '@/server/db' + +/** + * Alles, was die Wochenansicht braucht — in einem Zug geladen. + * + * Die Zeitblöcke des Benutzers und die Termine seiner Kalender kommen + * getrennt zurück: ein Zeitblock ist eigene Planung und lässt sich + * verschieben, ein Termin ist fremd und wird nur angezeigt. + */ + +export type WochenZeitblock = { + id: string + beginn: Date + ende: Date + aufgabeId: string + titel: string + bereichName: string | null + bereichFarbe: string | null + kalenderName: string + letzterFehler: string | null + /** Noch nicht in den Kalender geschrieben. */ + offen: boolean +} + +export type WochenTermin = { + id: string + beginn: Date + ende: Date + betreff: string | null + ganztaegig: boolean + zeigtAls: ZeigtAls + kalenderName: string +} + +export type WocheDaten = { + zeitbloecke: WochenZeitblock[] + termine: WochenTermin[] +} + +/** Beginn und Ende der Woche, in der der Zeitpunkt liegt. */ +export function wochenFenster(zeitpunkt: Date): { von: Date; bis: Date } { + const montag = wochenBeginn(zeitpunkt) + return { + von: montag, + bis: new Date(tagesBeginn(new Date(montag.getTime() + 7 * 86_400_000)).getTime()), + } +} + +export async function wocheLaden( + benutzerId: string, + zeitpunkt: Date, +): Promise { + const fenster = wochenFenster(zeitpunkt) + + const [zeitbloecke, termine] = await Promise.all([ + db.zeitblock.findMany({ + where: { + kalenderkonto: { benutzerId }, + beginn: { lt: fenster.bis }, + ende: { gt: fenster.von }, + }, + orderBy: { beginn: 'asc' }, + select: { + id: true, + beginn: true, + ende: true, + extId: true, + letzterFehler: true, + aufgabe: { + select: { + id: true, + titel: true, + bereich: { select: { name: true, farbe: true } }, + }, + }, + kalenderkonto: { select: { anzeigename: true } }, + }, + }), + db.termin.findMany({ + where: { + kalenderkonto: { benutzerId, aktiv: true }, + beginn: { lt: fenster.bis }, + ende: { gt: fenster.von }, + }, + orderBy: { beginn: 'asc' }, + select: { + id: true, + extId: true, + beginn: true, + ende: true, + betreff: true, + ganztaegig: true, + zeigtAls: true, + kalenderkonto: { select: { anzeigename: true } }, + }, + }), + ]) + + // Ein Zeitblock steht auch im Kalender und damit im Spiegel. Er würde sonst + // doppelt erscheinen — einmal als eigene Planung, einmal als fremder Termin. + const eigeneExtIds = new Set( + zeitbloecke.map((z) => z.extId).filter((id): id is string => id !== null), + ) + + return { + zeitbloecke: zeitbloecke.map((z) => ({ + id: z.id, + beginn: z.beginn, + ende: z.ende, + aufgabeId: z.aufgabe.id, + titel: z.aufgabe.titel, + bereichName: z.aufgabe.bereich?.name ?? null, + bereichFarbe: z.aufgabe.bereich?.farbe ?? null, + kalenderName: z.kalenderkonto.anzeigename, + letzterFehler: z.letzterFehler, + offen: z.extId === null, + })), + termine: termine + .filter((t) => !eigeneExtIds.has(t.extId)) + .map((t) => ({ + id: t.id, + beginn: t.beginn, + ende: t.ende, + betreff: t.betreff, + ganztaegig: t.ganztaegig, + zeigtAls: t.zeigtAls, + kalenderName: t.kalenderkonto.anzeigename, + })), + } +} + +/** Aufgaben, die sich einplanen lassen: offen, keine Idee, mit Aufwand. */ +export async function planbareAufgabenLaden(grenze = 50) { + return db.aufgabe.findMany({ + where: { + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + typ: { not: 'IDEE' }, + }, + orderBy: [{ faelligAm: 'asc' }, { prioritaet: 'asc' }], + take: grenze, + select: { + id: true, + titel: true, + aufwandMinuten: true, + faelligAm: true, + prioritaet: true, + status: true, + bereich: { select: { id: true, name: true, farbe: true } }, + _count: { select: { zeitbloecke: true } }, + }, + }) +} diff --git a/src/server/aktionen/zeitbloecke.ts b/src/server/aktionen/zeitbloecke.ts new file mode 100644 index 0000000..19f68cc --- /dev/null +++ b/src/server/aktionen/zeitbloecke.ts @@ -0,0 +1,352 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { + zeitblockSchema, + zeitraumHash, + LAGE_FEHLER_TEXT, + lagePruefen, +} from '@/lib/zeitblock' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' +import { gueltigesTokenFuer } from '@/server/kalender/abgleich' +import { + KalenderGewinnt, + terminAendern, + terminAnlegen, + terminEntfernen, +} from '@/server/kalender/schreiben' +import { jetzt } from '@/server/zeitgeber' + +export type Rueckmeldung = + { ok: true; meldung: string; id?: string } | { ok: false; fehler: string } + +async function akteurOderFehler() { + try { + return { akteur: await pruefeRolle('BENUTZER') } + } catch (fehler) { + if (fehler instanceof NichtErlaubt) { + return { antwort: { ok: false as const, fehler: fehler.message } } + } + throw fehler + } +} + +/** + * Lädt einen Zeitblock, aber nur, wenn er dem angemeldeten Benutzer gehört — + * die Kennung geht über das Kalenderkonto in die where-Bedingung. + */ +async function eigenerZeitblock(id: string, benutzerId: string) { + return db.zeitblock.findFirst({ + where: { id, kalenderkonto: { benutzerId } }, + select: { + id: true, + beginn: true, + ende: true, + extId: true, + etag: true, + hash: true, + aufgabe: { select: { id: true, titel: true, notiz: true, status: true } }, + kalenderkonto: { + select: { id: true, anbieter: true, kalenderId: true, anzeigename: true }, + }, + }, + }) +} + +/** Was im Kalender als Betreff steht. */ +function betreffFuer(titel: string): string { + return titel +} + +// --- Anlegen --------------------------------------------------------------- + +/** + * Legt einen Zeitblock an und schreibt ihn in den Kalender. + * + * Klappt das Schreiben nicht, bleibt der Zeitblock trotzdem bestehen — mit + * vermerktem Fehler. Ihn wieder zu verwerfen hieße, die Planung des Benutzers + * wegen eines Netzproblems zu vergessen; der nächste Versuch kann ihn + * nachtragen. + */ +export async function zeitblockAnlegen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = zeitblockSchema.safeParse({ + aufgabeId: daten.get('aufgabeId'), + kalenderkontoId: daten.get('kalenderkontoId'), + beginn: daten.get('beginn'), + ende: daten.get('ende'), + }) + if (!gelesen.success) { + const lage = gelesen.error.issues.find((i) => i.path.includes('ende')) + return { ok: false, fehler: lage?.message ?? 'Die Angaben stimmen nicht.' } + } + + const [aufgabe, konto] = await Promise.all([ + db.aufgabe.findUnique({ + where: { id: gelesen.data.aufgabeId }, + select: { id: true, titel: true, notiz: true, status: true }, + }), + db.kalenderkonto.findFirst({ + where: { id: gelesen.data.kalenderkontoId, benutzerId: akteur.id, aktiv: true }, + select: { id: true, anbieter: true, kalenderId: true, anzeigename: true }, + }), + ]) + + if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht.' } + if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } + + const zeitblock = await db.zeitblock.create({ + data: { + aufgabeId: aufgabe.id, + kalenderkontoId: konto.id, + beginn: gelesen.data.beginn, + ende: gelesen.data.ende, + }, + select: { id: true }, + }) + + // Eine eingeplante Aufgabe ist geplant, nicht mehr bloß offen. + if (aufgabe.status === 'OFFEN') { + await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } }) + } + + let schreibfehler: string | null = null + try { + const token = await gueltigesTokenFuer(konto.id, jetzt()) + const ergebnis = await terminAnlegen({ + anbieter: konto.anbieter, + kalenderId: konto.kalenderId, + token, + betreff: betreffFuer(aufgabe.titel), + beschreibung: aufgabe.notiz, + beginn: gelesen.data.beginn, + ende: gelesen.data.ende, + }) + + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { + extId: ergebnis.extId, + etag: ergebnis.etag, + hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende), + letzterFehler: null, + }, + }) + } catch (fehler) { + schreibfehler = + fehler instanceof Error ? fehler.message : 'Der Termin ließ sich nicht anlegen.' + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { letzterFehler: schreibfehler }, + }) + } + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'zeitblock.angelegt', + objektTyp: 'Zeitblock', + objektId: zeitblock.id, + details: { + aufgabe: aufgabe.titel, + kalender: konto.anzeigename, + geschrieben: schreibfehler === null, + }, + }) + + revalidatePath('/woche') + revalidatePath('/aufgaben') + revalidatePath('/') + + return schreibfehler + ? { + ok: false, + fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${schreibfehler}`, + } + : { ok: true, id: zeitblock.id, meldung: `„${aufgabe.titel}" eingeplant.` } +} + +// --- Verschieben ----------------------------------------------------------- + +const verschieben = z.object({ + id: z.string().min(1), + beginn: z.coerce.date(), + ende: z.coerce.date(), +}) + +/** + * Verschiebt einen Zeitblock und zieht den Kalender nach. + * + * Antwortet der Anbieter mit 412, hat jemand anderes den Termin inzwischen + * angefasst. Dann gewinnt der Kalender: der Planer schreibt nicht, sondern + * meldet das und überlässt dem nächsten Abgleich, den fremden Stand zu holen. + */ +export async function zeitblockVerschieben(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = verschieben.safeParse({ + id: daten.get('id'), + beginn: daten.get('beginn'), + ende: daten.get('ende'), + }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const lage = lagePruefen(gelesen.data.beginn, gelesen.data.ende) + if (lage) return { ok: false, fehler: LAGE_FEHLER_TEXT[lage] } + + const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id) + if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' } + + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { beginn: gelesen.data.beginn, ende: gelesen.data.ende }, + }) + + let meldung = `„${zeitblock.aufgabe.titel}" verschoben.` + let ok = true + + try { + const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt()) + + if (zeitblock.extId) { + const ergebnis = await terminAendern({ + anbieter: zeitblock.kalenderkonto.anbieter, + kalenderId: zeitblock.kalenderkonto.kalenderId, + token, + extId: zeitblock.extId, + etag: zeitblock.etag, + betreff: betreffFuer(zeitblock.aufgabe.titel), + beschreibung: zeitblock.aufgabe.notiz, + beginn: gelesen.data.beginn, + ende: gelesen.data.ende, + }) + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { + etag: ergebnis.etag, + hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende), + letzterFehler: null, + }, + }) + } else { + // Beim Anlegen war der Kalender nicht erreichbar – jetzt nachtragen. + const ergebnis = await terminAnlegen({ + anbieter: zeitblock.kalenderkonto.anbieter, + kalenderId: zeitblock.kalenderkonto.kalenderId, + token, + betreff: betreffFuer(zeitblock.aufgabe.titel), + beschreibung: zeitblock.aufgabe.notiz, + beginn: gelesen.data.beginn, + ende: gelesen.data.ende, + }) + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { + extId: ergebnis.extId, + etag: ergebnis.etag, + hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende), + letzterFehler: null, + }, + }) + } + } catch (fehler) { + ok = false + if (fehler instanceof KalenderGewinnt) { + meldung = + 'Der Termin wurde inzwischen im Kalender geändert. Der Kalender gewinnt — der nächste Abgleich holt den dortigen Stand.' + // Den eigenen Fingerabdruck verwerfen, damit der Abgleich die fremde + // Änderung als solche erkennt. + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { hash: null, letzterFehler: meldung }, + }) + } else { + meldung = + fehler instanceof Error ? fehler.message : 'Der Kalender ließ sich nicht ändern.' + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { letzterFehler: meldung }, + }) + } + } + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'zeitblock.verschoben', + objektTyp: 'Zeitblock', + objektId: zeitblock.id, + details: { aufgabe: zeitblock.aufgabe.titel, geschrieben: ok }, + }) + + revalidatePath('/woche') + revalidatePath('/') + return ok ? { ok: true, meldung } : { ok: false, fehler: meldung } +} + +// --- Lösen ----------------------------------------------------------------- + +const loesen = z.object({ id: z.string().min(1) }) + +/** Nimmt einen Zeitblock zurück und entfernt den Termin aus dem Kalender. */ +export async function zeitblockLoesen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = loesen.safeParse({ id: daten.get('id') }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id) + if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' } + + let hinweis = '' + if (zeitblock.extId) { + try { + const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt()) + await terminEntfernen({ + anbieter: zeitblock.kalenderkonto.anbieter, + kalenderId: zeitblock.kalenderkonto.kalenderId, + token, + extId: zeitblock.extId, + etag: zeitblock.etag, + }) + } catch { + // Der Zeitblock verschwindet trotzdem. Bliebe er stehen, weil der + // Kalender gerade nicht erreichbar ist, wäre die Planung blockiert. + hinweis = ' Der Termin im Kalender ließ sich nicht entfernen.' + } + } + + await db.zeitblock.delete({ where: { id: zeitblock.id } }) + + // Bleibt keine Planung übrig, ist die Aufgabe wieder schlicht offen. + const nochGeplant = await db.zeitblock.count({ + where: { aufgabeId: zeitblock.aufgabe.id }, + }) + if (nochGeplant === 0 && zeitblock.aufgabe.status === 'GEPLANT') { + await db.aufgabe.update({ + where: { id: zeitblock.aufgabe.id }, + data: { status: 'OFFEN' }, + }) + } + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'zeitblock.geloest', + objektTyp: 'Zeitblock', + objektId: zeitblock.id, + details: { aufgabe: zeitblock.aufgabe.titel }, + }) + + revalidatePath('/woche') + revalidatePath('/aufgaben') + revalidatePath('/') + return { ok: true, meldung: `„${zeitblock.aufgabe.titel}" ausgeplant.${hinweis}` } +} diff --git a/src/server/kalender/abgleich.ts b/src/server/kalender/abgleich.ts index 26fe4e7..e40478a 100644 --- a/src/server/kalender/abgleich.ts +++ b/src/server/kalender/abgleich.ts @@ -11,6 +11,7 @@ import { tagesBeginn } from '@/lib/zeit' import { db } from '@/server/db' import { entschluesseln, verschluesseln } from '@/server/krypto' import { tokenErneuern } from '@/server/kalender/oauth' +import { rueckwegAnwenden } from '@/server/kalender/rueckweg' import { jetzt } from '@/server/zeitgeber' /** @@ -97,6 +98,31 @@ async function gueltigesToken(konto: Konto, zeitpunkt: Date): Promise { return satz.zugriffstoken } +/** + * Gültiges Zugriffstoken für ein Konto – für den Schreibweg aus M4, der + * nicht den ganzen Abgleich braucht. + */ +export async function gueltigesTokenFuer( + kalenderkontoId: string, + zeitpunkt: Date, +): Promise { + const konto = await db.kalenderkonto.findUnique({ + where: { id: kalenderkontoId }, + select: { + id: true, + anbieter: true, + anzeigename: true, + kalenderId: true, + zugriffstoken: true, + aktualisierungstoken: true, + tokenGueltigBis: true, + deltaVerweis: true, + }, + }) + if (!konto) throw new Error('Dieses Kalenderkonto gibt es nicht.') + return gueltigesToken(konto, zeitpunkt) +} + /** Adresse der ersten Seite einer Runde. */ function ersteAdresse(konto: Konto, fenster: { von: Date; bis: Date }): string { if (konto.deltaVerweis) return konto.deltaVerweis @@ -302,6 +328,11 @@ export async function kontoAbgleichen( adresse = naechsteAdresse(konto, adresse, seite.weiter) } + // Erst den Spiegel auffrischen, dann die eigenen Zeitblöcke damit + // vergleichen: Was jemand im Kalender verschoben hat, holt der Planer + // sich hier zurück. + await rueckwegAnwenden(konto.id) + await db.kalenderkonto.update({ where: { id: konto.id }, data: { diff --git a/src/server/kalender/rueckweg.ts b/src/server/kalender/rueckweg.ts new file mode 100644 index 0000000..964e27d --- /dev/null +++ b/src/server/kalender/rueckweg.ts @@ -0,0 +1,93 @@ +import 'server-only' +import { abgleichEntscheiden, zeitraumHash } from '@/lib/zeitblock' +import { db } from '@/server/db' + +/** + * Der Rückweg: Änderungen an Zeitblöcken aus dem Kalender übernehmen. + * + * Läuft nach jedem Abgleich eines Kontos. Für jeden Zeitblock wird verglichen, + * was im Kalender steht — der örtliche Spiegel (Tabelle `termin`) ist dafür + * bereits auf dem neuesten Stand. + * + * **Der Kalender gewinnt.** Wer dort etwas verschiebt, meint es so; der Planer + * zieht nach, statt zurückzuschreiben. Sonst zöge die App dem Benutzer den + * Termin unter den Händen weg. + */ + +export type RueckwegBericht = { + uebernommen: number + geloest: number +} + +export async function rueckwegAnwenden( + kalenderkontoId: string, +): Promise { + const zeitbloecke = await db.zeitblock.findMany({ + where: { kalenderkontoId, extId: { not: null } }, + select: { + id: true, + extId: true, + beginn: true, + ende: true, + hash: true, + aufgabeId: true, + }, + }) + if (zeitbloecke.length === 0) return { uebernommen: 0, geloest: 0 } + + const extIds = zeitbloecke.map((z) => z.extId).filter((id): id is string => id !== null) + + const termine = await db.termin.findMany({ + where: { kalenderkontoId, extId: { in: extIds } }, + select: { extId: true, beginn: true, ende: true, etag: true }, + }) + const nachExtId = new Map(termine.map((t) => [t.extId, t])) + + let uebernommen = 0 + let geloest = 0 + + for (const zeitblock of zeitbloecke) { + const imKalender = nachExtId.get(zeitblock.extId as string) ?? null + const entscheidung = abgleichEntscheiden( + { hash: zeitblock.hash, beginn: zeitblock.beginn, ende: zeitblock.ende }, + imKalender ? { beginn: imKalender.beginn, ende: imKalender.ende } : null, + ) + + if (entscheidung.tun === 'nichts') continue + + if (entscheidung.tun === 'loesen') { + // Im Kalender gelöscht: der Zeitblock verliert seine Verankerung. Die + // Aufgabe bleibt bestehen und fällt zurück auf offen, wenn nichts + // anderes mehr für sie geplant ist. + await db.zeitblock.delete({ where: { id: zeitblock.id } }) + geloest++ + + const nochGeplant = await db.zeitblock.count({ + where: { aufgabeId: zeitblock.aufgabeId }, + }) + if (nochGeplant === 0) { + await db.aufgabe.updateMany({ + where: { id: zeitblock.aufgabeId, status: 'GEPLANT' }, + data: { status: 'OFFEN' }, + }) + } + continue + } + + await db.zeitblock.update({ + where: { id: zeitblock.id }, + data: { + beginn: entscheidung.beginn, + ende: entscheidung.ende, + // Den Fingerabdruck auf den übernommenen Stand setzen, damit die + // nächste Runde ihn wiedererkennt und nichts mehr tut. + hash: zeitraumHash(entscheidung.beginn, entscheidung.ende), + etag: imKalender?.etag ?? null, + letzterFehler: null, + }, + }) + uebernommen++ + } + + return { uebernommen, geloest } +} diff --git a/src/server/kalender/schreiben.ts b/src/server/kalender/schreiben.ts new file mode 100644 index 0000000..746e003 --- /dev/null +++ b/src/server/kalender/schreiben.ts @@ -0,0 +1,225 @@ +import 'server-only' +import { z } from 'zod' +import type { KalenderAnbieter } from '@/generated/prisma/enums' + +/** + * Schreiben in die Kalender der Anbieter. + * + * Jeder Schreibzugriff auf einen bestehenden Termin geht mit `If-Match` und + * dem zuletzt gelesenen Etag heraus. Hat inzwischen jemand anderes den Termin + * geändert, antwortet der Anbieter mit 412 — dann **gewinnt der Kalender**, + * und der Planer übernimmt dessen Stand, statt ihn zu überschreiben. + */ + +/** Der Termin wurde inzwischen anderswo geändert. */ +export class KalenderGewinnt extends Error { + constructor(anbieter: string) { + super(`Der Termin wurde in ${anbieter} zwischenzeitlich geändert.`) + this.name = 'KalenderGewinnt' + } +} + +/** Der Termin ist im Kalender nicht mehr vorhanden. */ +export class TerminFort extends Error { + constructor(anbieter: string) { + super(`Der Termin ist in ${anbieter} nicht mehr vorhanden.`) + this.name = 'TerminFort' + } +} + +export type SchreibEingabe = { + anbieter: KalenderAnbieter + kalenderId: string + token: string + betreff: string + beschreibung: string | null + beginn: Date + ende: Date +} + +export type SchreibErgebnis = { extId: string; etag: string | null } + +const graphAntwort = z.looseObject({ + id: z.string().min(1), + '@odata.etag': z.string().optional(), +}) + +const googleAntwort = z.looseObject({ + id: z.string().min(1), + etag: z.string().optional(), +}) + +function graphBasis(kalenderId: string): string { + return kalenderId === 'standard' + ? 'https://graph.microsoft.com/v1.0/me/events' + : `https://graph.microsoft.com/v1.0/me/calendars/${encodeURIComponent(kalenderId)}/events` +} + +function googleBasis(kalenderId: string): string { + const kalender = encodeURIComponent(kalenderId === 'standard' ? 'primary' : kalenderId) + return `https://www.googleapis.com/calendar/v3/calendars/${kalender}/events` +} + +/** + * Der Rumpf des Termins. + * + * Graph erwartet Zeit und Zone getrennt; wir schicken UTC, damit auf beiden + * Wegen dasselbe gilt wie beim Lesen. Google nimmt einen ISO-Zeitpunkt mit + * Versatz direkt entgegen. + */ +function rumpf(eingabe: SchreibEingabe): Record { + if (eingabe.anbieter === 'GRAPH') { + return { + subject: eingabe.betreff, + body: { contentType: 'text', content: eingabe.beschreibung ?? '' }, + start: { dateTime: eingabe.beginn.toISOString(), timeZone: 'UTC' }, + end: { dateTime: eingabe.ende.toISOString(), timeZone: 'UTC' }, + showAs: 'busy', + // Damit im Kalender erkennbar ist, woher der Termin stammt. + categories: ['Planer'], + } + } + + return { + summary: eingabe.betreff, + description: eingabe.beschreibung ?? '', + start: { dateTime: eingabe.beginn.toISOString() }, + end: { dateTime: eingabe.ende.toISOString() }, + transparency: 'opaque', + source: { title: 'Planer', url: process.env.APP_URL ?? '' }, + } +} + +async function senden( + anbieter: KalenderAnbieter, + adresse: string, + verfahren: 'POST' | 'PATCH' | 'DELETE', + token: string, + inhalt: Record | null, + etag: string | null, +): Promise { + const kopfzeilen: Record = { + Authorization: `Bearer ${token}`, + } + if (inhalt) kopfzeilen['Content-Type'] = 'application/json' + // Ohne Etag kein If-Match: ein Schreibzugriff ohne Vergleichsstand darf + // nicht als "unverändert" durchgehen. + if (etag) kopfzeilen['If-Match'] = etag + + const antwort = await fetch(adresse, { + method: verfahren, + headers: kopfzeilen, + ...(inhalt ? { body: JSON.stringify(inhalt) } : {}), + }) + + if (antwort.status === 412) throw new KalenderGewinnt(anbieter) + if (antwort.status === 404 || antwort.status === 410) throw new TerminFort(anbieter) + + return antwort +} + +/** Legt einen Termin an. */ +export async function terminAnlegen(eingabe: SchreibEingabe): Promise { + const adresse = + eingabe.anbieter === 'GRAPH' + ? graphBasis(eingabe.kalenderId) + : googleBasis(eingabe.kalenderId) + + const antwort = await senden( + eingabe.anbieter, + adresse, + 'POST', + eingabe.token, + rumpf(eingabe), + null, + ) + + if (!antwort.ok) { + throw new Error(`Der Termin ließ sich nicht anlegen (${antwort.status}).`) + } + + const inhalt: unknown = await antwort.json() + if (eingabe.anbieter === 'GRAPH') { + const gelesen = graphAntwort.safeParse(inhalt) + if (!gelesen.success) throw new Error('Die Antwort von Graph war nicht verwertbar.') + return { extId: gelesen.data.id, etag: gelesen.data['@odata.etag'] ?? null } + } + + const gelesen = googleAntwort.safeParse(inhalt) + if (!gelesen.success) throw new Error('Die Antwort von Google war nicht verwertbar.') + return { extId: gelesen.data.id, etag: gelesen.data.etag ?? null } +} + +/** Ändert einen bestehenden Termin. */ +export async function terminAendern( + eingabe: SchreibEingabe & { extId: string; etag: string | null }, +): Promise { + const basis = + eingabe.anbieter === 'GRAPH' + ? graphBasis(eingabe.kalenderId) + : googleBasis(eingabe.kalenderId) + const adresse = `${basis}/${encodeURIComponent(eingabe.extId)}` + + const antwort = await senden( + eingabe.anbieter, + adresse, + 'PATCH', + eingabe.token, + rumpf(eingabe), + eingabe.etag, + ) + + if (!antwort.ok) { + throw new Error(`Der Termin ließ sich nicht ändern (${antwort.status}).`) + } + + const inhalt: unknown = await antwort.json() + if (eingabe.anbieter === 'GRAPH') { + const gelesen = graphAntwort.safeParse(inhalt) + return { + extId: eingabe.extId, + etag: gelesen.success ? (gelesen.data['@odata.etag'] ?? null) : null, + } + } + + const gelesen = googleAntwort.safeParse(inhalt) + return { + extId: eingabe.extId, + etag: gelesen.success ? (gelesen.data.etag ?? null) : null, + } +} + +/** + * Entfernt einen Termin. Ist er ohnehin schon fort, gilt das als erledigt — + * das Ziel ist erreicht. + */ +export async function terminEntfernen(eingabe: { + anbieter: KalenderAnbieter + kalenderId: string + token: string + extId: string + etag: string | null +}): Promise { + const basis = + eingabe.anbieter === 'GRAPH' + ? graphBasis(eingabe.kalenderId) + : googleBasis(eingabe.kalenderId) + const adresse = `${basis}/${encodeURIComponent(eingabe.extId)}` + + try { + const antwort = await senden( + eingabe.anbieter, + adresse, + 'DELETE', + eingabe.token, + null, + eingabe.etag, + ) + if (!antwort.ok && antwort.status !== 204) { + throw new Error(`Der Termin ließ sich nicht entfernen (${antwort.status}).`) + } + } catch (fehler) { + if (fehler instanceof TerminFort) return + throw fehler + } +}