Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57bf7748be | ||
|
|
30387ea5af | ||
|
|
e9e2ae51ec | ||
|
|
5429b960fd | ||
|
|
0b967d4a33 | ||
|
|
0974695d93 | ||
|
|
5b1e9981e4 | ||
|
|
fada4450bd | ||
|
|
bcb4b8eb02 | ||
|
|
9c1c916b58 | ||
|
|
5791047982 | ||
|
|
bd9df22a18 |
@@ -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
|
||||
|
||||
@@ -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 |  |  |
|
||||
| Start |  |  |
|
||||
| Inbox |  |  |
|
||||
| Woche |  |  |
|
||||
| Aufgaben |  |  |
|
||||
| Projekte |  |  |
|
||||
| Ideen |  |  |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 301 KiB |
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Wochenplanung und Zeitblöcke
|
||||
|
||||
Hier wird aus einer Aufgabe ein Termin. Du ziehst sie in ein Zeitfenster, und
|
||||
der Planer legt sie als **Zeitblock** in deinen echten Kalender.
|
||||
|
||||

|
||||
|
||||
## Der Aufbau
|
||||
|
||||
| Wo | Was |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| **Links** | Alles, was noch eingeplant werden kann — offen, geplant oder wartend. Ideen bleiben draußen |
|
||||
| **Rechts** | Sieben Tage im Viertelstundenraster |
|
||||
| **Über jeder Spalte** | Belegte Zeit gegen die Kapazität dieses Tages |
|
||||
| **Graue Balken** | Termine aus deinem Kalender. Sie gehören dem Kalender, nicht dem Planer — hier lassen sie sich nicht anfassen |
|
||||
| **Farbige Balken** | Deine Zeitblöcke, in der Farbe ihres Bereichs |
|
||||
|
||||
Das sichtbare Stundenfenster ergibt sich aus den Arbeitsfenstern **aller**
|
||||
Bereiche, mit einer Stunde Luft nach oben und unten.
|
||||
|
||||
## Eine Aufgabe einplanen
|
||||
|
||||
Aufgabe links am Griff packen und in ein Zeitfenster ziehen. Fertig — der
|
||||
Zeitblock steht sofort im Raster und wenige Augenblicke später in deinem
|
||||
Kalender.
|
||||
|
||||
> **Die Dauer kommt aus dem geschätzten Aufwand.** Wer bei der Aufgabe
|
||||
> „90 Minuten" einträgt, bekommt einen 90-Minuten-Block. Ohne Schätzung sind
|
||||
> es 60 Minuten, die du danach anpassen kannst.
|
||||
|
||||
Eine eingeplante Aufgabe wechselt automatisch von _Offen_ auf _Geplant_.
|
||||
|
||||
## Verschieben
|
||||
|
||||
Am Griff im Block packen und woandershin ziehen. Der Planer zieht den
|
||||
Kalendertermin nach.
|
||||
|
||||
## Ausplanen
|
||||
|
||||
Das Kreuz im Block. Der Termin verschwindet aus dem Kalender; die Aufgabe
|
||||
bleibt und fällt zurück auf _Offen_, wenn nichts anderes mehr für sie geplant
|
||||
ist.
|
||||
|
||||
## Wenn du im Kalender verschiebst
|
||||
|
||||
Das darfst du — und der Planer zieht nach. Beim nächsten Abgleich (spätestens
|
||||
nach 15 Minuten) steht der Block dort, wo du ihn im Kalender hingelegt hast.
|
||||
|
||||
> **Der Kalender gewinnt.** Immer. Wer dort etwas verschiebt, meint es so;
|
||||
> säße der Planer am längeren Hebel, würde er dir den Termin wieder wegziehen
|
||||
> und du wüsstest nicht, warum.
|
||||
>
|
||||
> Löschst du den Termin im Kalender, verschwindet auch der Zeitblock. Die
|
||||
> Aufgabe selbst bleibt.
|
||||
|
||||
### Warum sich das nicht aufschaukelt
|
||||
|
||||
Der Planer merkt sich zu jedem Zeitblock einen Fingerabdruck des Zeitraums,
|
||||
den er zuletzt geschrieben hat. Findet er beim Abgleich genau diesen
|
||||
Fingerabdruck wieder, war es seine eigene Änderung — er tut nichts. Weicht er
|
||||
ab, hat jemand anderes verschoben.
|
||||
|
||||
Ohne diesen Kniff würden App und Kalender einander endlos hinterherschreiben.
|
||||
|
||||
## Das Warnzeichen im Block
|
||||
|
||||
Ein kleines Dreieck heißt: **noch nicht im Kalender**. Meist war der Anbieter
|
||||
gerade nicht erreichbar.
|
||||
|
||||
Der Zeitblock bleibt trotzdem stehen — deine Planung soll nicht verlorengehen,
|
||||
bloß weil ein Server hustet. Verschiebe den Block einmal, dann trägt der
|
||||
Planer ihn nach.
|
||||
|
||||
## Die Zahl über der Spalte
|
||||
|
||||
`3 h / 6 h` heißt: sechs Stunden Kapazität an diesem Tag, drei davon schon
|
||||
belegt. Gezählt werden Kalendertermine **und** deine Zeitblöcke.
|
||||
|
||||
Wird die Zahl rot, ist der Tag überbucht. Das ist keine Sperre — der Planer
|
||||
lässt dich weiterplanen. Es ist ein Hinweis, dass der Tag nicht aufgeht.
|
||||
|
||||
Ganztägige Termine zählen nicht mit. Ein Urlaubstag im Kalender blockiert
|
||||
sonst rechnerisch den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
|
||||
|
||||
## Blättern
|
||||
|
||||
**Zurück**, **Diese Woche** und **Weiter**. Die Woche steht in der Adresse —
|
||||
eine bestimmte Woche lässt sich also als Lesezeichen ablegen.
|
||||
|
||||
## Wenn es klemmt
|
||||
|
||||
| Was du siehst | Was dahintersteckt |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| „Ohne verbundenen Kalender lässt sich nichts einplanen." | Erst unter [Kalender](kalenderkonten.md) ein Konto verbinden |
|
||||
| „Eingeplant, aber nicht in den Kalender geschrieben" | Der Anbieter war nicht erreichbar. Der Block steht, verschiebe ihn einmal zum Nachtragen |
|
||||
| „Der Termin wurde inzwischen im Kalender geändert." | Jemand war schneller. Der Kalender gewinnt; der nächste Abgleich holt den dortigen Stand |
|
||||
| „Zeitblöcke rasten in 15-Minuten-Schritten ein." | Das Raster ist Absicht. Genauer zu planen bringt nichts |
|
||||
| Ein Block ist nicht zu sehen | Er liegt außerhalb des sichtbaren Stundenfensters. Das Fenster folgt den Arbeitsfenstern der Bereiche — siehe [Bereiche](bereiche.md) |
|
||||
|
||||
## Was noch nicht geht
|
||||
|
||||
- **Ein Zeitblock landet immer im ersten verbundenen Kalender.** Die Auswahl
|
||||
je Bereich ist im Datenmodell vorbereitet, aber noch nicht in der
|
||||
Oberfläche.
|
||||
- **Die Dauer lässt sich nicht am Balken ziehen.** Wer eine andere Dauer will,
|
||||
ändert den Aufwand der Aufgabe und plant neu ein.
|
||||
@@ -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<void> {
|
||||
await seite.goto('/anmelden')
|
||||
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
||||
await seite.getByLabel('Passwort').fill(PASSWORT)
|
||||
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||
}
|
||||
|
||||
test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => {
|
||||
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$/)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -373,6 +373,37 @@ async function main(): Promise<void> {
|
||||
},
|
||||
})
|
||||
|
||||
// --- Zeitblöcke: eingeplante Aufgaben im Kalender -------------------------
|
||||
await db.zeitblock.deleteMany({})
|
||||
const einzuplanen = await db.aufgabe.findMany({
|
||||
where: {
|
||||
titel: {
|
||||
in: [
|
||||
'Wochenbericht an die Geschäftsführung',
|
||||
'Angebot Heizungstausch nachfassen',
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { id: true, titel: true, aufwandMinuten: true },
|
||||
})
|
||||
|
||||
let blockStunde = 15
|
||||
for (const aufgabe of einzuplanen) {
|
||||
const beginn = new Date(Date.UTC(2026, 2, 17, blockStunde - 1, 0))
|
||||
await db.zeitblock.create({
|
||||
data: {
|
||||
aufgabeId: aufgabe.id,
|
||||
kalenderkontoId: kalenderkonto.id,
|
||||
beginn,
|
||||
ende: new Date(beginn.getTime() + (aufgabe.aufwandMinuten ?? 60) * 60_000),
|
||||
extId: `demo-block-${aufgabe.id}`,
|
||||
hash: 'demo',
|
||||
},
|
||||
})
|
||||
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
|
||||
blockStunde += 2
|
||||
}
|
||||
|
||||
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
|
||||
await db.aufgabe.create({
|
||||
data: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { CalendarRange } from 'lucide-react'
|
||||
import { Wochenraster } from '@/app/(app)/woche/wochenraster'
|
||||
import { Card, CardInhalt } from '@/components/ui/card'
|
||||
import { faelligkeitEinordnen, faelligkeitText, istUeberfaellig } from '@/lib/aufgabe'
|
||||
import {
|
||||
belegteMinuten,
|
||||
arbeitsfensterAmTag,
|
||||
kapazitaetAmTag,
|
||||
} from '@/lib/kalender/belegung'
|
||||
import { fensterHuelle, rasterBauen } from '@/lib/woche'
|
||||
import { alsDatum, gleicherTag, tagesBeginn } from '@/lib/zeit'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { planbareAufgabenLaden, wocheLaden } from '@/server/abfragen/woche'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
export const metadata: Metadata = { title: 'Woche' }
|
||||
|
||||
const WOCHENTAGE = [
|
||||
'Montag',
|
||||
'Dienstag',
|
||||
'Mittwoch',
|
||||
'Donnerstag',
|
||||
'Freitag',
|
||||
'Samstag',
|
||||
'Sonntag',
|
||||
]
|
||||
|
||||
export default async function WocheSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ versatz?: string }>
|
||||
}) {
|
||||
const benutzer = await verlangeAnmeldung('/woche')
|
||||
const parameter = await searchParams
|
||||
|
||||
// Über den Versatz lässt sich blättern; er steht in der Adresse, damit eine
|
||||
// bestimmte Woche als Lesezeichen taugt.
|
||||
const versatz = Number.parseInt(parameter.versatz ?? '0', 10)
|
||||
const sichereWochen = Number.isFinite(versatz)
|
||||
? Math.max(-52, Math.min(52, versatz))
|
||||
: 0
|
||||
const heute = jetzt()
|
||||
const zeitpunkt = new Date(heute.getTime() + sichereWochen * 7 * 86_400_000)
|
||||
|
||||
const [daten, aufgaben, bereiche, konto] = await Promise.all([
|
||||
wocheLaden(benutzer.id, zeitpunkt),
|
||||
planbareAufgabenLaden(),
|
||||
db.bereich.findMany({
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
// Für den ersten Wurf: der Zeitblock landet im ersten verbundenen
|
||||
// Kalender. Die Auswahl je Bereich kommt, sobald es mehrere gibt.
|
||||
db.kalenderkonto.findFirst({
|
||||
where: { benutzerId: benutzer.id, aktiv: true },
|
||||
orderBy: { erstelltAm: 'asc' },
|
||||
select: { id: true, anzeigename: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const huelle = fensterHuelle(bereiche)
|
||||
const raster = rasterBauen(zeitpunkt, huelle.von, huelle.bis)
|
||||
|
||||
// Kapazität und Belegung je Tag: alle Bereiche zusammengenommen, denn ein
|
||||
// Termin blockiert die Zeit für alles gleichermaßen.
|
||||
const spalten = raster.spalten.map((spalte) => {
|
||||
const tag = spalte.tagesBeginn
|
||||
const kapazitaet = bereiche.reduce((summe, b) => summe + kapazitaetAmTag(b, tag), 0)
|
||||
|
||||
const belegend = [
|
||||
...daten.termine.map((t) => ({
|
||||
beginn: t.beginn,
|
||||
ende: t.ende,
|
||||
ganztaegig: t.ganztaegig,
|
||||
zeigtAls: t.zeigtAls,
|
||||
})),
|
||||
...daten.zeitbloecke.map((z) => ({
|
||||
beginn: z.beginn,
|
||||
ende: z.ende,
|
||||
ganztaegig: false,
|
||||
zeigtAls: 'GEBUCHT' as const,
|
||||
})),
|
||||
]
|
||||
|
||||
// Gegen die Hülle rechnen, nicht gegen ein einzelnes Arbeitsfenster:
|
||||
// die Tageskapazität ist hier die Summe über alle Bereiche.
|
||||
const fenster = arbeitsfensterAmTag(tag, huelle.von, huelle.bis)
|
||||
const belegt = belegteMinuten(belegend, fenster)
|
||||
|
||||
return {
|
||||
schluessel: spalte.schluessel,
|
||||
tagesBeginnMs: spalte.tagesBeginn.getTime(),
|
||||
name: WOCHENTAGE[spalte.wochentag] ?? '',
|
||||
datum: alsDatum(tag).split(', ')[1] ?? '',
|
||||
heute: gleicherTag(tag, heute),
|
||||
kapazitaet,
|
||||
belegt,
|
||||
ueberbucht: kapazitaet > 0 && belegt > kapazitaet,
|
||||
}
|
||||
})
|
||||
|
||||
const montag = tagesBeginn(raster.spalten[0]?.tagesBeginn ?? zeitpunkt)
|
||||
const sonntag = raster.spalten[6]?.tagesBeginn ?? zeitpunkt
|
||||
|
||||
return (
|
||||
/*
|
||||
Die Wochenansicht braucht mehr Breite als der Inhaltsrahmen von 64rem
|
||||
hergibt – bei sieben Spalten plus Aufgabenliste wäre sonst der Sonntag
|
||||
abgeschnitten. Deshalb bricht genau diese Seite aus und nimmt bis zu
|
||||
100rem, mittig und mit demselben Seitenabstand wie überall.
|
||||
*/
|
||||
<div className="relative left-1/2 w-screen max-w-[100rem] -translate-x-1/2 px-4 sm:px-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<CalendarRange className="size-6" aria-hidden />
|
||||
Woche
|
||||
</h1>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||
{alsDatum(montag)} bis {alsDatum(sonntag)}
|
||||
{konto ? ` · schreibt nach ${konto.anzeigename}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-2" aria-label="Woche wechseln">
|
||||
<Link
|
||||
href={`/woche?versatz=${sichereWochen - 1}`}
|
||||
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
Zurück
|
||||
</Link>
|
||||
<Link
|
||||
href="/woche"
|
||||
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
Diese Woche
|
||||
</Link>
|
||||
<Link
|
||||
href={`/woche?versatz=${sichereWochen + 1}`}
|
||||
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
Weiter
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{!konto && (
|
||||
<Card>
|
||||
<CardInhalt className="py-4 text-sm">
|
||||
Noch kein Kalender verbunden. Zeitblöcke landen erst dann im Kalender —{' '}
|
||||
<Link
|
||||
href="/kalenderkonten"
|
||||
className="text-betont underline underline-offset-4"
|
||||
>
|
||||
jetzt verbinden
|
||||
</Link>
|
||||
.
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Wochenraster
|
||||
spalten={spalten}
|
||||
zeilen={raster.zeilen}
|
||||
vonMinute={raster.vonMinute}
|
||||
bisMinute={raster.bisMinute}
|
||||
kalenderkontoId={konto?.id ?? null}
|
||||
zeitbloecke={daten.zeitbloecke.map((z) => ({
|
||||
id: z.id,
|
||||
beginnMs: z.beginn.getTime(),
|
||||
endeMs: z.ende.getTime(),
|
||||
titel: z.titel,
|
||||
bereichFarbe: z.bereichFarbe,
|
||||
kalenderName: z.kalenderName,
|
||||
letzterFehler: z.letzterFehler,
|
||||
offen: z.offen,
|
||||
}))}
|
||||
termine={daten.termine.map((t) => ({
|
||||
id: t.id,
|
||||
beginnMs: t.beginn.getTime(),
|
||||
endeMs: t.ende.getTime(),
|
||||
betreff: t.betreff,
|
||||
ganztaegig: t.ganztaegig,
|
||||
frei: t.zeigtAls === 'FREI',
|
||||
kalenderName: t.kalenderName,
|
||||
}))}
|
||||
aufgaben={aufgaben.map((a) => ({
|
||||
id: a.id,
|
||||
titel: a.titel,
|
||||
aufwandMinuten: a.aufwandMinuten,
|
||||
bereichName: a.bereich?.name ?? null,
|
||||
bereichFarbe: a.bereich?.farbe ?? null,
|
||||
faelligText: a.faelligAm
|
||||
? faelligkeitText(faelligkeitEinordnen(a.faelligAm, heute))
|
||||
: null,
|
||||
ueberfaellig: istUeberfaellig(a.faelligAm, a.status, heute),
|
||||
bereitsGeplant: a._count.zeitbloecke,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein
|
||||
Zeitblock lässt sich am Griff verschieben; das Kreuz plant ihn wieder aus. Graue
|
||||
Balken sind Termine aus dem Kalender — sie lassen sich hier nicht verschieben.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import { AlertTriangle, CalendarClock, GripVertical, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||
import {
|
||||
ZEILE_HOEHE,
|
||||
balkenlage,
|
||||
spurenVerteilen,
|
||||
zeilenBeschriftung,
|
||||
zeitpunktAus,
|
||||
type Raster,
|
||||
} from '@/lib/woche'
|
||||
import { alsDauer, alsUhrzeitAnzeige } from '@/lib/zeit'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
zeitblockAnlegen,
|
||||
zeitblockLoesen,
|
||||
zeitblockVerschieben,
|
||||
type Rueckmeldung,
|
||||
} from '@/server/aktionen/zeitbloecke'
|
||||
|
||||
export type PlanbareAufgabe = {
|
||||
id: string
|
||||
titel: string
|
||||
aufwandMinuten: number | null
|
||||
bereichName: string | null
|
||||
bereichFarbe: string | null
|
||||
faelligText: string | null
|
||||
ueberfaellig: boolean
|
||||
bereitsGeplant: number
|
||||
}
|
||||
|
||||
export type RasterZeitblock = {
|
||||
id: string
|
||||
beginnMs: number
|
||||
endeMs: number
|
||||
titel: string
|
||||
bereichFarbe: string | null
|
||||
kalenderName: string
|
||||
letzterFehler: string | null
|
||||
offen: boolean
|
||||
}
|
||||
|
||||
export type RasterTermin = {
|
||||
id: string
|
||||
beginnMs: number
|
||||
endeMs: number
|
||||
betreff: string | null
|
||||
ganztaegig: boolean
|
||||
frei: boolean
|
||||
kalenderName: string
|
||||
}
|
||||
|
||||
export type RasterSpalte = {
|
||||
schluessel: string
|
||||
tagesBeginnMs: number
|
||||
name: string
|
||||
datum: string
|
||||
heute: boolean
|
||||
kapazitaet: number
|
||||
belegt: number
|
||||
ueberbucht: boolean
|
||||
}
|
||||
|
||||
type Eigenschaften = {
|
||||
spalten: RasterSpalte[]
|
||||
zeilen: number[]
|
||||
vonMinute: number
|
||||
bisMinute: number
|
||||
zeitbloecke: RasterZeitblock[]
|
||||
termine: RasterTermin[]
|
||||
aufgaben: PlanbareAufgabe[]
|
||||
kalenderkontoId: string | null
|
||||
}
|
||||
|
||||
/** Standarddauer, wenn eine Aufgabe keinen geschätzten Aufwand trägt. */
|
||||
const STANDARD_MINUTEN = 60
|
||||
|
||||
export function Wochenraster({
|
||||
spalten,
|
||||
zeilen,
|
||||
vonMinute,
|
||||
bisMinute,
|
||||
zeitbloecke,
|
||||
termine,
|
||||
aufgaben,
|
||||
kalenderkontoId,
|
||||
}: Eigenschaften) {
|
||||
const router = useRouter()
|
||||
const [laeuft, starte] = useTransition()
|
||||
const [gezogen, setGezogen] = useState<string | null>(null)
|
||||
|
||||
const sensoren = useSensors(
|
||||
// Erst ab acht Pixeln gilt es als Ziehen – sonst löst jeder Klick einen
|
||||
// Zug aus und Knöpfe im Block wären nicht mehr bedienbar.
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
)
|
||||
|
||||
const raster: Raster = {
|
||||
spalten: spalten.map((s) => ({
|
||||
tagesBeginn: new Date(s.tagesBeginnMs),
|
||||
wochentag: 0,
|
||||
schluessel: s.schluessel,
|
||||
})),
|
||||
zeilen,
|
||||
vonMinute,
|
||||
bisMinute,
|
||||
}
|
||||
|
||||
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
|
||||
starte(async () => {
|
||||
const antwort = await tun()
|
||||
if (antwort.ok) toast.success(antwort.meldung)
|
||||
else toast.error(antwort.fehler)
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
function beimStart(ereignis: DragStartEvent) {
|
||||
setGezogen(String(ereignis.active.id))
|
||||
}
|
||||
|
||||
function beimEnde(ereignis: DragEndEvent) {
|
||||
setGezogen(null)
|
||||
const ziel = ereignis.over?.id
|
||||
if (!ziel) return
|
||||
|
||||
const [schluessel, minuteRoh] = String(ziel).split('@')
|
||||
if (!schluessel || minuteRoh === undefined) return
|
||||
const beginn = zeitpunktAus(schluessel, Number(minuteRoh))
|
||||
|
||||
const kennung = String(ereignis.active.id)
|
||||
|
||||
if (kennung.startsWith('aufgabe:')) {
|
||||
if (!kalenderkontoId) {
|
||||
toast.error('Erst einen Kalender verbinden, dann lässt sich einplanen.')
|
||||
return
|
||||
}
|
||||
const aufgabeId = kennung.slice('aufgabe:'.length)
|
||||
const aufgabe = aufgaben.find((a) => a.id === aufgabeId)
|
||||
const dauer = aufgabe?.aufwandMinuten ?? STANDARD_MINUTEN
|
||||
const ende = new Date(beginn.getTime() + dauer * 60_000)
|
||||
|
||||
const daten = new FormData()
|
||||
daten.set('aufgabeId', aufgabeId)
|
||||
daten.set('kalenderkontoId', kalenderkontoId)
|
||||
daten.set('beginn', beginn.toISOString())
|
||||
daten.set('ende', ende.toISOString())
|
||||
mitRueckmeldung(() => zeitblockAnlegen(daten))
|
||||
return
|
||||
}
|
||||
|
||||
if (kennung.startsWith('block:')) {
|
||||
const id = kennung.slice('block:'.length)
|
||||
const block = zeitbloecke.find((z) => z.id === id)
|
||||
if (!block) return
|
||||
const dauer = block.endeMs - block.beginnMs
|
||||
const ende = new Date(beginn.getTime() + dauer)
|
||||
|
||||
const daten = new FormData()
|
||||
daten.set('id', id)
|
||||
daten.set('beginn', beginn.toISOString())
|
||||
daten.set('ende', ende.toISOString())
|
||||
mitRueckmeldung(() => zeitblockVerschieben(daten))
|
||||
}
|
||||
}
|
||||
|
||||
function ausplanen(id: string) {
|
||||
const daten = new FormData()
|
||||
daten.set('id', id)
|
||||
mitRueckmeldung(() => zeitblockLoesen(daten))
|
||||
}
|
||||
|
||||
const gezogeneAufgabe = gezogen?.startsWith('aufgabe:')
|
||||
? aufgaben.find((a) => a.id === gezogen.slice('aufgabe:'.length))
|
||||
: null
|
||||
const gezogenerBlock = gezogen?.startsWith('block:')
|
||||
? zeitbloecke.find((z) => z.id === gezogen.slice('block:'.length))
|
||||
: null
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensoren} onDragStart={beimStart} onDragEnd={beimEnde}>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
{/* Vorrat: was noch eingeplant werden kann */}
|
||||
<aside className="lg:w-72 lg:shrink-0">
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
Einzuplanen
|
||||
<span className="text-gedaempft-vordergrund ml-2 font-normal">
|
||||
{aufgaben.length}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{!kalenderkontoId && (
|
||||
<p className="bg-warnung/15 text-warnung mb-3 rounded-md px-3 py-2 text-xs">
|
||||
Ohne verbundenen Kalender lässt sich nichts einplanen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="border-rand bg-karte max-h-[32rem] divide-y divide-[var(--rand)] overflow-y-auto rounded-xl border">
|
||||
{aufgaben.length === 0 ? (
|
||||
<li className="text-gedaempft-vordergrund p-4 text-center text-sm">
|
||||
Nichts offen. Ein guter Tag.
|
||||
</li>
|
||||
) : (
|
||||
aufgaben.map((aufgabe) => (
|
||||
<AufgabenKachel key={aufgabe.id} aufgabe={aufgabe} gesperrt={laeuft} />
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{/* Das Wochenraster */}
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
<div className="min-w-[46rem]">
|
||||
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
|
||||
<div />
|
||||
{spalten.map((spalte) => (
|
||||
<div
|
||||
key={spalte.schluessel}
|
||||
className={cn(
|
||||
'border-rand border-b px-1 pb-2 text-center',
|
||||
spalte.heute && 'bg-betont/5',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">{spalte.name}</p>
|
||||
<p className="text-gedaempft-vordergrund text-xs">{spalte.datum}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-xs',
|
||||
spalte.ueberbucht ? 'text-gefahr' : 'text-gedaempft-vordergrund',
|
||||
)}
|
||||
>
|
||||
{spalte.kapazitaet === 0
|
||||
? '—'
|
||||
: `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
|
||||
{/* Stundenleiste */}
|
||||
<div className="relative">
|
||||
{zeilen.map((minute) => (
|
||||
<div
|
||||
key={minute}
|
||||
style={{ height: ZEILE_HOEHE }}
|
||||
className="text-gedaempft-vordergrund pr-2 text-right text-[0.6875rem] leading-none"
|
||||
>
|
||||
{zeilenBeschriftung(minute)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{spalten.map((spalte) => {
|
||||
const blockeHier = zeitbloecke.filter(
|
||||
(z) =>
|
||||
z.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
|
||||
z.endeMs > spalte.tagesBeginnMs,
|
||||
)
|
||||
const termineHier = termine.filter(
|
||||
(t) =>
|
||||
!t.ganztaegig &&
|
||||
t.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
|
||||
t.endeMs > spalte.tagesBeginnMs,
|
||||
)
|
||||
|
||||
const verteilteTermine = spurenVerteilen(
|
||||
termineHier.map((t) => ({
|
||||
...t,
|
||||
beginn: new Date(t.beginnMs),
|
||||
ende: new Date(t.endeMs),
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={spalte.schluessel}
|
||||
className={cn(
|
||||
'border-rand relative border-l',
|
||||
spalte.heute && 'bg-betont/5',
|
||||
)}
|
||||
>
|
||||
{zeilen.map((minute) => (
|
||||
<Zielfeld
|
||||
key={minute}
|
||||
kennung={`${spalte.schluessel}@${minute}`}
|
||||
volleStunde={minute % 60 === 0}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Fremde Termine liegen hinten und lassen sich nicht ziehen */}
|
||||
{verteilteTermine.map(({ eintrag, spur, spuren }) => {
|
||||
const lage = balkenlage(
|
||||
eintrag.beginn,
|
||||
eintrag.ende,
|
||||
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
|
||||
raster,
|
||||
)
|
||||
if (lage.ausserhalb) return null
|
||||
return (
|
||||
<div
|
||||
key={eintrag.id}
|
||||
className={cn(
|
||||
'pointer-events-none absolute rounded-sm px-1 text-[0.6875rem] leading-tight',
|
||||
eintrag.frei
|
||||
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
|
||||
: 'bg-gedaempft text-gedaempft-vordergrund',
|
||||
)}
|
||||
style={{
|
||||
top: lage.oben,
|
||||
height: lage.hoehe,
|
||||
left: `${(spur / spuren) * 100}%`,
|
||||
width: `${100 / spuren}%`,
|
||||
}}
|
||||
title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
|
||||
>
|
||||
<span className="line-clamp-2">
|
||||
{eintrag.betreff ?? 'Termin'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Eigene Zeitblöcke liegen vorn und lassen sich ziehen */}
|
||||
{blockeHier.map((block) => {
|
||||
const lage = balkenlage(
|
||||
new Date(block.beginnMs),
|
||||
new Date(block.endeMs),
|
||||
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
|
||||
raster,
|
||||
)
|
||||
if (lage.ausserhalb) return null
|
||||
return (
|
||||
<Zeitblockbalken
|
||||
key={block.id}
|
||||
block={block}
|
||||
oben={lage.oben}
|
||||
hoehe={lage.hoehe}
|
||||
gesperrt={laeuft}
|
||||
onAusplanen={() => ausplanen(block.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{gezogeneAufgabe && (
|
||||
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
|
||||
{gezogeneAufgabe.titel}
|
||||
<span className="ml-1 opacity-80">
|
||||
{alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{gezogenerBlock && (
|
||||
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
|
||||
{gezogenerBlock.titel}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: kennung })
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ height: ZEILE_HOEHE }}
|
||||
className={cn(
|
||||
'border-rand/40',
|
||||
volleStunde ? 'border-t' : 'border-t border-dashed',
|
||||
isOver && 'bg-betont/20',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AufgabenKachel({
|
||||
aufgabe,
|
||||
gesperrt,
|
||||
}: {
|
||||
aufgabe: PlanbareAufgabe
|
||||
gesperrt: boolean
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `aufgabe:${aufgabe.id}`,
|
||||
disabled: gesperrt,
|
||||
})
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
className={cn('flex items-start gap-2 p-2', isDragging && 'opacity-40')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gedaempft-vordergrund mt-0.5 cursor-grab touch-none active:cursor-grabbing"
|
||||
aria-label={`${aufgabe.titel} zum Einplanen ziehen`}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<GripVertical className="size-4" aria-hidden />
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm leading-tight">{aufgabe.titel}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{aufgabe.bereichName && (
|
||||
<Kennzeichen>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: aufgabe.bereichFarbe ?? undefined }}
|
||||
/>
|
||||
{aufgabe.bereichName}
|
||||
</Kennzeichen>
|
||||
)}
|
||||
<Kennzeichen>
|
||||
{alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
|
||||
</Kennzeichen>
|
||||
{aufgabe.faelligText && (
|
||||
<Kennzeichen art={aufgabe.ueberfaellig ? 'gefahr' : 'neutral'}>
|
||||
{aufgabe.faelligText}
|
||||
</Kennzeichen>
|
||||
)}
|
||||
{aufgabe.bereitsGeplant > 0 && (
|
||||
<Kennzeichen art="betont">
|
||||
<CalendarClock className="size-3" aria-hidden />
|
||||
{aufgabe.bereitsGeplant}
|
||||
</Kennzeichen>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Zeitblockbalken({
|
||||
block,
|
||||
oben,
|
||||
hoehe,
|
||||
gesperrt,
|
||||
onAusplanen,
|
||||
}: {
|
||||
block: RasterZeitblock
|
||||
oben: number
|
||||
hoehe: number
|
||||
gesperrt: boolean
|
||||
onAusplanen: () => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `block:${block.id}`,
|
||||
disabled: gesperrt,
|
||||
})
|
||||
|
||||
const farbe = block.bereichFarbe ?? 'var(--betont)'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
top: oben,
|
||||
height: hoehe,
|
||||
borderLeftColor: farbe,
|
||||
backgroundColor: `color-mix(in oklab, ${farbe} 18%, var(--karte))`,
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-0.5 left-0.5 overflow-hidden rounded-sm border-l-4 px-1 text-[0.6875rem] leading-tight',
|
||||
isDragging && 'opacity-40',
|
||||
)}
|
||||
title={`${block.titel} · ${alsUhrzeitAnzeige(new Date(block.beginnMs))}–${alsUhrzeitAnzeige(new Date(block.endeMs))} · ${block.kalenderName}`}
|
||||
>
|
||||
<div className="flex items-start gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-grab touch-none active:cursor-grabbing"
|
||||
aria-label={`${block.titel} verschieben`}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<GripVertical className="size-3 opacity-60" aria-hidden />
|
||||
</button>
|
||||
|
||||
<span className="line-clamp-3 flex-1 font-medium">{block.titel}</span>
|
||||
|
||||
{(block.offen || block.letzterFehler) && (
|
||||
<AlertTriangle
|
||||
className="text-warnung size-3 shrink-0"
|
||||
aria-label="Noch nicht im Kalender"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
art="leise"
|
||||
className="size-4 shrink-0 p-0"
|
||||
disabled={gesperrt}
|
||||
title="Ausplanen"
|
||||
onClick={onAusplanen}
|
||||
>
|
||||
<X className="size-3" aria-hidden />
|
||||
<span className="sr-only">Ausplanen</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ZEILE_HOEHE,
|
||||
balkenlage,
|
||||
fensterHuelle,
|
||||
rasterBauen,
|
||||
schluesselFuer,
|
||||
spurenVerteilen,
|
||||
zeilenBeschriftung,
|
||||
zeitpunktAus,
|
||||
} from './woche'
|
||||
|
||||
// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1.
|
||||
const dienstag = new Date('2026-03-17T09:00:00Z')
|
||||
|
||||
describe('rasterBauen', () => {
|
||||
it('beginnt am Montag und hat sieben Spalten', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||
expect(raster.spalten).toHaveLength(7)
|
||||
expect(raster.spalten[0]?.wochentag).toBe(0)
|
||||
expect(raster.spalten[6]?.wochentag).toBe(6)
|
||||
})
|
||||
|
||||
it('legt den Montag auf den richtigen Tag', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||
// Montag, 16. März, 00:00 Berlin = 23:00 UTC am 15.
|
||||
expect(raster.spalten[0]?.tagesBeginn.toISOString()).toBe('2026-03-15T23:00:00.000Z')
|
||||
})
|
||||
|
||||
it('teilt das Fenster in Viertelstunden', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 10 * 60)
|
||||
expect(raster.zeilen).toEqual([480, 495, 510, 525, 540, 555, 570, 585])
|
||||
})
|
||||
|
||||
it('lässt die letzte Zeile vor dem Ende enden', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 9 * 60)
|
||||
expect(raster.zeilen.at(-1)).toBe(525)
|
||||
})
|
||||
})
|
||||
|
||||
describe('schluesselFuer und zeitpunktAus', () => {
|
||||
it('finden zurück zum Ausgangspunkt', () => {
|
||||
const schluessel = schluesselFuer(dienstag)
|
||||
// 09:00 Berlin am selben Tag.
|
||||
expect(zeitpunktAus(schluessel, 9 * 60).toISOString()).toBe(
|
||||
'2026-03-17T08:00:00.000Z',
|
||||
)
|
||||
})
|
||||
|
||||
it('geben für denselben Tag denselben Schlüssel', () => {
|
||||
expect(schluesselFuer(new Date('2026-03-17T06:00:00Z'))).toBe(
|
||||
schluesselFuer(new Date('2026-03-17T20:00:00Z')),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fensterHuelle', () => {
|
||||
it('umspannt alle Arbeitsfenster mit etwas Luft', () => {
|
||||
const huelle = fensterHuelle([
|
||||
{ arbeitsfensterVon: 480, arbeitsfensterBis: 1020 },
|
||||
{ arbeitsfensterVon: 1020, arbeitsfensterBis: 1260 },
|
||||
])
|
||||
expect(huelle.von).toBe(420)
|
||||
expect(huelle.bis).toBe(1320)
|
||||
})
|
||||
|
||||
it('bleibt innerhalb eines Tages', () => {
|
||||
const huelle = fensterHuelle([{ arbeitsfensterVon: 30, arbeitsfensterBis: 1410 }])
|
||||
expect(huelle.von).toBe(0)
|
||||
expect(huelle.bis).toBe(1440)
|
||||
})
|
||||
|
||||
it('nimmt ohne Bereiche einen brauchbaren Standard', () => {
|
||||
expect(fensterHuelle([])).toEqual({ von: 480, bis: 1080 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('balkenlage', () => {
|
||||
const raster = rasterBauen(dienstag, 8 * 60, 18 * 60)
|
||||
const spalte = raster.spalten[1]!
|
||||
|
||||
it('setzt einen Block an die richtige Stelle', () => {
|
||||
// 09:00 bis 10:00 Berlin.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T08:00:00Z'),
|
||||
new Date('2026-03-17T09:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
// Eine Stunde nach Fensterbeginn = vier Zeilen.
|
||||
expect(lage.oben).toBe(4 * ZEILE_HOEHE)
|
||||
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||
expect(lage.ausserhalb).toBe(false)
|
||||
})
|
||||
|
||||
it('schneidet oben ab, was vor dem Fenster beginnt', () => {
|
||||
// 07:00 bis 09:00 Berlin, Fenster beginnt um 08:00.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T06:00:00Z'),
|
||||
new Date('2026-03-17T08:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.oben).toBe(0)
|
||||
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||
})
|
||||
|
||||
it('schneidet unten ab, was über das Fenster hinausgeht', () => {
|
||||
// 17:00 bis 20:00 Berlin, Fenster endet um 18:00.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T16:00:00Z'),
|
||||
new Date('2026-03-17T19:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.hoehe).toBe(4 * ZEILE_HOEHE)
|
||||
})
|
||||
|
||||
it('erkennt einen Block ganz außerhalb des Fensters', () => {
|
||||
// 22:00 bis 23:00 Berlin.
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T21:00:00Z'),
|
||||
new Date('2026-03-17T22:00:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.ausserhalb).toBe(true)
|
||||
})
|
||||
|
||||
it('gibt auch dem kürzesten Block eine sichtbare Höhe', () => {
|
||||
const lage = balkenlage(
|
||||
new Date('2026-03-17T08:00:00Z'),
|
||||
new Date('2026-03-17T08:15:00Z'),
|
||||
spalte,
|
||||
raster,
|
||||
)
|
||||
expect(lage.hoehe).toBeGreaterThanOrEqual(ZEILE_HOEHE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zeilenBeschriftung', () => {
|
||||
it('beschriftet nur volle Stunden', () => {
|
||||
expect(zeilenBeschriftung(480)).toBe('08:00')
|
||||
expect(zeilenBeschriftung(495)).toBeNull()
|
||||
expect(zeilenBeschriftung(540)).toBe('09:00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('spurenVerteilen', () => {
|
||||
function eintrag(von: string, bis: string) {
|
||||
return { beginn: new Date(von), ende: new Date(bis) }
|
||||
}
|
||||
|
||||
it('gibt einem einzelnen Eintrag die volle Breite', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
])
|
||||
expect(verteilt[0]?.spur).toBe(0)
|
||||
expect(verteilt[0]?.spuren).toBe(1)
|
||||
})
|
||||
|
||||
it('legt zwei sich überschneidende Einträge nebeneinander', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
])
|
||||
expect(verteilt.map((v) => v.spur)).toEqual([0, 1])
|
||||
expect(verteilt.every((v) => v.spuren === 2)).toBe(true)
|
||||
})
|
||||
|
||||
it('lässt getrennte Einträge beide die volle Breite behalten', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
eintrag('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
])
|
||||
expect(verteilt.every((v) => v.spuren === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('gibt eine Spur wieder frei, sobald sie zu Ende ist', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
// Beginnt nach dem ersten, kann dessen Spur übernehmen.
|
||||
eintrag('2026-03-17T10:00:00Z', '2026-03-17T12:00:00Z'),
|
||||
])
|
||||
expect(verteilt.map((v) => v.spur)).toEqual([0, 1, 0])
|
||||
// Alle drei hängen über eine Kette zusammen und teilen sich zwei Spuren.
|
||||
expect(verteilt.every((v) => v.spuren === 2)).toBe(true)
|
||||
})
|
||||
|
||||
it('trennt Gruppen, die sich nicht berühren', () => {
|
||||
const verteilt = spurenVerteilen([
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
|
||||
eintrag('2026-03-17T08:30:00Z', '2026-03-17T09:30:00Z'),
|
||||
eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'),
|
||||
])
|
||||
expect(verteilt[0]?.spuren).toBe(2)
|
||||
expect(verteilt[1]?.spuren).toBe(2)
|
||||
expect(verteilt[2]?.spuren).toBe(1)
|
||||
})
|
||||
|
||||
it('behält jeden Eintrag genau einmal', () => {
|
||||
const eintraege = [
|
||||
eintrag('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
|
||||
eintrag('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
|
||||
eintrag('2026-03-17T14:00:00Z', '2026-03-17T15:00:00Z'),
|
||||
]
|
||||
expect(spurenVerteilen(eintraege)).toHaveLength(eintraege.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { alsUhrzeit, tagesBeginn, wochenTage, wochentagIndex } from '@/lib/zeit'
|
||||
import { RASTER_MINUTEN } from '@/lib/zeitblock'
|
||||
|
||||
/**
|
||||
* Aufbau des Wochenrasters.
|
||||
*
|
||||
* Reine Logik: aus einem Zeitpunkt, einem Stundenfenster und einer Liste von
|
||||
* Blöcken entsteht alles, was die Ansicht braucht — Spalten, Zeilen und die
|
||||
* Lage jedes Blocks darin. Keine Datenbank, keine Systemzeit.
|
||||
*/
|
||||
|
||||
/** Höhe einer Rasterzeile in Pixeln. 15 Minuten = 14 px. */
|
||||
export const ZEILE_HOEHE = 14
|
||||
|
||||
export type Tagesspalte = {
|
||||
/** 00:00 dieses Tages in Europe/Berlin. */
|
||||
tagesBeginn: Date
|
||||
/** 0 = Montag. */
|
||||
wochentag: number
|
||||
/** ISO-Datum, taugt als Schlüssel. */
|
||||
schluessel: string
|
||||
}
|
||||
|
||||
export type Raster = {
|
||||
spalten: Tagesspalte[]
|
||||
/** Minuten seit Mitternacht, an denen die Zeilen beginnen. */
|
||||
zeilen: number[]
|
||||
vonMinute: number
|
||||
bisMinute: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Raster für die Woche, in der der Zeitpunkt liegt.
|
||||
*
|
||||
* Das Stundenfenster kommt von außen — sinnvollerweise als Hülle über die
|
||||
* Arbeitsfenster aller Bereiche, damit nichts abgeschnitten wird, was
|
||||
* geplant werden darf.
|
||||
*/
|
||||
export function rasterBauen(
|
||||
zeitpunkt: Date,
|
||||
vonMinute: number,
|
||||
bisMinute: number,
|
||||
): Raster {
|
||||
const tage = wochenTage(zeitpunkt)
|
||||
|
||||
const zeilen: number[] = []
|
||||
for (let minute = vonMinute; minute < bisMinute; minute += RASTER_MINUTEN) {
|
||||
zeilen.push(minute)
|
||||
}
|
||||
|
||||
return {
|
||||
spalten: tage.map((tag) => ({
|
||||
tagesBeginn: tag,
|
||||
wochentag: wochentagIndex(tag),
|
||||
schluessel: schluesselFuer(tag),
|
||||
})),
|
||||
zeilen,
|
||||
vonMinute,
|
||||
bisMinute,
|
||||
}
|
||||
}
|
||||
|
||||
/** Schlüssel eines Tages im Raster, unabhängig von der Zeitzone lesbar. */
|
||||
export function schluesselFuer(tag: Date): string {
|
||||
const beginn = tagesBeginn(tag)
|
||||
return String(beginn.getTime())
|
||||
}
|
||||
|
||||
/** Zeitpunkt aus Tagesschlüssel und Minute seit Mitternacht. */
|
||||
export function zeitpunktAus(schluessel: string, minute: number): Date {
|
||||
return new Date(Number(schluessel) + minute * 60_000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hülle über alle Arbeitsfenster, mit etwas Luft nach oben und unten.
|
||||
*
|
||||
* Ohne die Luft läge ein Block, der genau am Rand beginnt, an der Kante des
|
||||
* Rasters und ließe sich kaum greifen.
|
||||
*/
|
||||
export function fensterHuelle(
|
||||
bereiche: readonly { arbeitsfensterVon: number; arbeitsfensterBis: number }[],
|
||||
luftMinuten = 60,
|
||||
): { von: number; bis: number } {
|
||||
if (bereiche.length === 0) return { von: 8 * 60, bis: 18 * 60 }
|
||||
|
||||
const von = Math.min(...bereiche.map((b) => b.arbeitsfensterVon))
|
||||
const bis = Math.max(...bereiche.map((b) => b.arbeitsfensterBis))
|
||||
|
||||
return {
|
||||
von: Math.max(0, aufRasterMinute(von - luftMinuten)),
|
||||
bis: Math.min(24 * 60, aufRasterMinute(bis + luftMinuten)),
|
||||
}
|
||||
}
|
||||
|
||||
function aufRasterMinute(minute: number): number {
|
||||
return Math.round(minute / RASTER_MINUTEN) * RASTER_MINUTEN
|
||||
}
|
||||
|
||||
export type Balkenlage = {
|
||||
/** Abstand von oben in Pixeln. */
|
||||
oben: number
|
||||
/** Höhe in Pixeln. */
|
||||
hoehe: number
|
||||
/** Liegt der Block ganz außerhalb des sichtbaren Fensters? */
|
||||
ausserhalb: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Wo ein Zeitraum im Raster eines Tages liegt.
|
||||
*
|
||||
* Ragt er über den sichtbaren Bereich hinaus, wird er abgeschnitten — sonst
|
||||
* würde ein Termin von 6 bis 23 Uhr das ganze Raster sprengen.
|
||||
*/
|
||||
export function balkenlage(
|
||||
beginn: Date,
|
||||
ende: Date,
|
||||
spalte: Tagesspalte,
|
||||
raster: Raster,
|
||||
): Balkenlage {
|
||||
const tagBeginn = spalte.tagesBeginn.getTime()
|
||||
const vonMinute = (beginn.getTime() - tagBeginn) / 60_000
|
||||
const bisMinute = (ende.getTime() - tagBeginn) / 60_000
|
||||
|
||||
if (bisMinute <= raster.vonMinute || vonMinute >= raster.bisMinute) {
|
||||
return { oben: 0, hoehe: 0, ausserhalb: true }
|
||||
}
|
||||
|
||||
const sichtbarVon = Math.max(vonMinute, raster.vonMinute)
|
||||
const sichtbarBis = Math.min(bisMinute, raster.bisMinute)
|
||||
|
||||
return {
|
||||
oben: ((sichtbarVon - raster.vonMinute) / RASTER_MINUTEN) * ZEILE_HOEHE,
|
||||
hoehe: Math.max(
|
||||
ZEILE_HOEHE,
|
||||
((sichtbarBis - sichtbarVon) / RASTER_MINUTEN) * ZEILE_HOEHE,
|
||||
),
|
||||
ausserhalb: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Beschriftung einer Rasterzeile; nur volle Stunden bekommen eine. */
|
||||
export function zeilenBeschriftung(minute: number): string | null {
|
||||
return minute % 60 === 0 ? alsUhrzeit(minute) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Verteilt sich überschneidende Einträge auf Spuren nebeneinander.
|
||||
*
|
||||
* Ohne das läge ein Termin genau über dem anderen und einer wäre unsichtbar.
|
||||
*/
|
||||
export function spurenVerteilen<T extends { beginn: Date; ende: Date }>(
|
||||
eintraege: readonly T[],
|
||||
): { eintrag: T; spur: number; spuren: number }[] {
|
||||
const sortiert = [...eintraege].sort((a, b) => a.beginn.getTime() - b.beginn.getTime())
|
||||
|
||||
// Gruppen bilden: alles, was sich über eine Kette von Überschneidungen
|
||||
// berührt, teilt sich dieselbe Anzahl Spuren.
|
||||
const gruppen: T[][] = []
|
||||
let aktuelle: T[] = []
|
||||
let gruppenEnde = 0
|
||||
|
||||
for (const eintrag of sortiert) {
|
||||
if (aktuelle.length > 0 && eintrag.beginn.getTime() >= gruppenEnde) {
|
||||
gruppen.push(aktuelle)
|
||||
aktuelle = []
|
||||
}
|
||||
aktuelle.push(eintrag)
|
||||
gruppenEnde = Math.max(gruppenEnde, eintrag.ende.getTime())
|
||||
}
|
||||
if (aktuelle.length > 0) gruppen.push(aktuelle)
|
||||
|
||||
const ergebnis: { eintrag: T; spur: number; spuren: number }[] = []
|
||||
|
||||
for (const gruppe of gruppen) {
|
||||
const spurEnden: number[] = []
|
||||
const zuordnung: { eintrag: T; spur: number }[] = []
|
||||
|
||||
for (const eintrag of gruppe) {
|
||||
let spur = spurEnden.findIndex((ende) => ende <= eintrag.beginn.getTime())
|
||||
if (spur === -1) {
|
||||
spur = spurEnden.length
|
||||
spurEnden.push(0)
|
||||
}
|
||||
spurEnden[spur] = eintrag.ende.getTime()
|
||||
zuordnung.push({ eintrag, spur })
|
||||
}
|
||||
|
||||
for (const { eintrag, spur } of zuordnung) {
|
||||
ergebnis.push({ eintrag, spur, spuren: spurEnden.length })
|
||||
}
|
||||
}
|
||||
|
||||
return ergebnis
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RASTER_MINUTEN,
|
||||
ZEITBLOCK_MAX_MINUTEN,
|
||||
ZEITBLOCK_MIN_MINUTEN,
|
||||
abgleichEntscheiden,
|
||||
aufRaster,
|
||||
dauerMinuten,
|
||||
lagePruefen,
|
||||
mussSchreiben,
|
||||
ueberschneidetSich,
|
||||
zeitblockSchema,
|
||||
zeitraumHash,
|
||||
} from './zeitblock'
|
||||
|
||||
const beginn = new Date('2026-03-17T09:00:00Z')
|
||||
const ende = new Date('2026-03-17T10:00:00Z')
|
||||
|
||||
describe('zeitraumHash', () => {
|
||||
it('ist für denselben Zeitraum gleich', () => {
|
||||
expect(zeitraumHash(beginn, ende)).toBe(
|
||||
zeitraumHash(new Date(beginn), new Date(ende)),
|
||||
)
|
||||
})
|
||||
|
||||
it('ändert sich, wenn der Beginn wandert', () => {
|
||||
expect(zeitraumHash(beginn, ende)).not.toBe(
|
||||
zeitraumHash(new Date('2026-03-17T09:15:00Z'), ende),
|
||||
)
|
||||
})
|
||||
|
||||
it('ändert sich, wenn das Ende wandert', () => {
|
||||
expect(zeitraumHash(beginn, ende)).not.toBe(
|
||||
zeitraumHash(beginn, new Date('2026-03-17T10:30:00Z')),
|
||||
)
|
||||
})
|
||||
|
||||
it('ist unabhängig davon, wie der Zeitpunkt entstanden ist', () => {
|
||||
// Derselbe Augenblick, anders geschrieben.
|
||||
expect(zeitraumHash(new Date('2026-03-17T09:00:00.000Z'), ende)).toBe(
|
||||
zeitraumHash(new Date('2026-03-17T10:00:00+01:00'), ende),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('abgleichEntscheiden — der Schleifenschutz', () => {
|
||||
it('tut nichts, wenn im Kalender genau das steht, was wir geschrieben haben', () => {
|
||||
// Das ist der Fall, der die Schleife bricht: der Abgleich liest die
|
||||
// eigene Änderung und erkennt sie am Fingerabdruck wieder.
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
{ beginn, ende },
|
||||
)
|
||||
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'eigene-aenderung' })
|
||||
})
|
||||
|
||||
it('tut auch ohne Fingerabdruck nichts, wenn beide Seiten übereinstimmen', () => {
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: null, beginn, ende },
|
||||
{ beginn, ende },
|
||||
)
|
||||
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
|
||||
})
|
||||
|
||||
it('übernimmt eine fremde Verschiebung — der Kalender gewinnt', () => {
|
||||
const verschobenerBeginn = new Date('2026-03-17T11:00:00Z')
|
||||
const verschobenesEnde = new Date('2026-03-17T12:00:00Z')
|
||||
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
{ beginn: verschobenerBeginn, ende: verschobenesEnde },
|
||||
)
|
||||
|
||||
expect(entscheidung).toEqual({
|
||||
tun: 'uebernehmen',
|
||||
grund: 'fremd-verschoben',
|
||||
beginn: verschobenerBeginn,
|
||||
ende: verschobenesEnde,
|
||||
})
|
||||
})
|
||||
|
||||
it('erkennt eine fremde Verschiebung auch ohne vorherigen Fingerabdruck', () => {
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: null, beginn, ende },
|
||||
{
|
||||
beginn: new Date('2026-03-17T11:00:00Z'),
|
||||
ende: new Date('2026-03-17T12:00:00Z'),
|
||||
},
|
||||
)
|
||||
expect(entscheidung.tun).toBe('uebernehmen')
|
||||
})
|
||||
|
||||
it('löst den Zeitblock, wenn der Termin im Kalender verschwunden ist', () => {
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
null,
|
||||
)
|
||||
expect(entscheidung).toEqual({ tun: 'loesen', grund: 'im-kalender-entfernt' })
|
||||
})
|
||||
|
||||
it('schaukelt sich über mehrere Runden nicht hoch', () => {
|
||||
// Die App schreibt, der Abgleich läuft dreimal – und tut jedes Mal nichts.
|
||||
let stand = { hash: zeitraumHash(beginn, ende), beginn, ende }
|
||||
for (let runde = 0; runde < 3; runde++) {
|
||||
const entscheidung = abgleichEntscheiden(stand, {
|
||||
beginn: stand.beginn,
|
||||
ende: stand.ende,
|
||||
})
|
||||
expect(entscheidung.tun).toBe('nichts')
|
||||
stand = { ...stand }
|
||||
}
|
||||
})
|
||||
|
||||
it('kommt nach einer übernommenen Verschiebung zur Ruhe', () => {
|
||||
const neuerBeginn = new Date('2026-03-17T11:00:00Z')
|
||||
const neuesEnde = new Date('2026-03-17T12:00:00Z')
|
||||
|
||||
// Erste Runde: fremde Verschiebung wird übernommen.
|
||||
const erste = abgleichEntscheiden(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
{ beginn: neuerBeginn, ende: neuesEnde },
|
||||
)
|
||||
expect(erste.tun).toBe('uebernehmen')
|
||||
|
||||
// Danach steht der neue Stand samt Fingerabdruck; die zweite Runde
|
||||
// erkennt ihn wieder.
|
||||
const zweite = abgleichEntscheiden(
|
||||
{
|
||||
hash: zeitraumHash(neuerBeginn, neuesEnde),
|
||||
beginn: neuerBeginn,
|
||||
ende: neuesEnde,
|
||||
},
|
||||
{ beginn: neuerBeginn, ende: neuesEnde },
|
||||
)
|
||||
expect(zweite.tun).toBe('nichts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mussSchreiben', () => {
|
||||
it('schreibt bei einer echten Verschiebung', () => {
|
||||
expect(
|
||||
mussSchreiben(
|
||||
{ hash: zeitraumHash(beginn, ende), beginn, ende },
|
||||
new Date('2026-03-17T11:00:00Z'),
|
||||
new Date('2026-03-17T12:00:00Z'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('schreibt nicht, wenn der Zeitraum gleich bleibt', () => {
|
||||
// Etwa wenn nur der Aufgabentitel geändert wurde.
|
||||
expect(
|
||||
mussSchreiben({ hash: zeitraumHash(beginn, ende), beginn, ende }, beginn, ende),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('schreibt, solange noch nichts geschrieben wurde', () => {
|
||||
expect(mussSchreiben({ hash: null, beginn, ende }, beginn, ende)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dauerMinuten', () => {
|
||||
it('rechnet in Minuten', () => {
|
||||
expect(dauerMinuten(beginn, ende)).toBe(60)
|
||||
expect(dauerMinuten(beginn, new Date('2026-03-17T09:45:00Z'))).toBe(45)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aufRaster', () => {
|
||||
it('rastet auf die nächste Viertelstunde ein', () => {
|
||||
expect(aufRaster(new Date('2026-03-17T09:07:00Z')).toISOString()).toBe(
|
||||
'2026-03-17T09:00:00.000Z',
|
||||
)
|
||||
expect(aufRaster(new Date('2026-03-17T09:08:00Z')).toISOString()).toBe(
|
||||
'2026-03-17T09:15:00.000Z',
|
||||
)
|
||||
})
|
||||
|
||||
it('lässt einen bereits eingerasteten Zeitpunkt in Ruhe', () => {
|
||||
expect(aufRaster(beginn).toISOString()).toBe(beginn.toISOString())
|
||||
})
|
||||
})
|
||||
|
||||
describe('lagePruefen', () => {
|
||||
it('nimmt eine gültige Lage an', () => {
|
||||
expect(lagePruefen(beginn, ende)).toBeNull()
|
||||
})
|
||||
|
||||
it('weist ein Ende vor dem Beginn ab', () => {
|
||||
expect(lagePruefen(ende, beginn)).toBe('ende-vor-beginn')
|
||||
expect(lagePruefen(beginn, beginn)).toBe('ende-vor-beginn')
|
||||
})
|
||||
|
||||
it('weist zu kurze Blöcke ab und nimmt die Untergrenze an', () => {
|
||||
const knappZuKurz = new Date(beginn.getTime() + (ZEITBLOCK_MIN_MINUTEN - 15) * 60_000)
|
||||
expect(lagePruefen(beginn, knappZuKurz)).toBe('ende-vor-beginn')
|
||||
|
||||
const genauMinimum = new Date(beginn.getTime() + ZEITBLOCK_MIN_MINUTEN * 60_000)
|
||||
expect(lagePruefen(beginn, genauMinimum)).toBeNull()
|
||||
})
|
||||
|
||||
it('weist zu lange Blöcke ab und nimmt die Obergrenze an', () => {
|
||||
const genauMaximum = new Date(beginn.getTime() + ZEITBLOCK_MAX_MINUTEN * 60_000)
|
||||
expect(lagePruefen(beginn, genauMaximum)).toBeNull()
|
||||
|
||||
const zuLang = new Date(
|
||||
beginn.getTime() + (ZEITBLOCK_MAX_MINUTEN + RASTER_MINUTEN) * 60_000,
|
||||
)
|
||||
expect(lagePruefen(beginn, zuLang)).toBe('zu-lang')
|
||||
})
|
||||
|
||||
it('weist Zeiten außerhalb des Rasters ab', () => {
|
||||
expect(lagePruefen(new Date('2026-03-17T09:07:00Z'), ende)).toBe('nicht-im-raster')
|
||||
expect(lagePruefen(beginn, new Date('2026-03-17T10:07:00Z'))).toBe('nicht-im-raster')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ueberschneidetSich', () => {
|
||||
const block = { beginn, ende }
|
||||
|
||||
it('erkennt eine Überschneidung', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: new Date('2026-03-17T09:30:00Z'),
|
||||
ende: new Date('2026-03-17T10:30:00Z'),
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('zählt reines Aneinandergrenzen nicht', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: ende,
|
||||
ende: new Date('2026-03-17T11:00:00Z'),
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('erkennt vollständige Umschließung', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: new Date('2026-03-17T08:00:00Z'),
|
||||
ende: new Date('2026-03-17T12:00:00Z'),
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('erkennt getrennte Zeiträume', () => {
|
||||
expect(
|
||||
ueberschneidetSich(block, {
|
||||
beginn: new Date('2026-03-17T14:00:00Z'),
|
||||
ende: new Date('2026-03-17T15:00:00Z'),
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zeitblockSchema', () => {
|
||||
const gueltig = {
|
||||
aufgabeId: 'a1',
|
||||
kalenderkontoId: 'k1',
|
||||
beginn: '2026-03-17T09:00:00.000Z',
|
||||
ende: '2026-03-17T10:00:00.000Z',
|
||||
}
|
||||
|
||||
it('nimmt eine gültige Eingabe an', () => {
|
||||
const ergebnis = zeitblockSchema.parse(gueltig)
|
||||
expect(ergebnis.beginn).toBeInstanceOf(Date)
|
||||
expect(dauerMinuten(ergebnis.beginn, ergebnis.ende)).toBe(60)
|
||||
})
|
||||
|
||||
it('weist eine unmögliche Lage ab', () => {
|
||||
expect(
|
||||
zeitblockSchema.safeParse({ ...gueltig, ende: '2026-03-17T08:00:00.000Z' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('weist Zeiten außerhalb des Rasters ab', () => {
|
||||
expect(
|
||||
zeitblockSchema.safeParse({ ...gueltig, beginn: '2026-03-17T09:07:00.000Z' })
|
||||
.success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('verlangt Aufgabe und Kalenderkonto', () => {
|
||||
expect(zeitblockSchema.safeParse({ ...gueltig, aufgabeId: '' }).success).toBe(false)
|
||||
expect(zeitblockSchema.safeParse({ ...gueltig, kalenderkontoId: '' }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Zeitblöcke und der Schleifenschutz.
|
||||
*
|
||||
* Ein Zeitblock ist eine Aufgabe, die zu einer bestimmten Zeit in einem
|
||||
* Kalender steht. Er lebt an zwei Orten: hier und im Kalender des Anbieters.
|
||||
* Beide Seiten dürfen ihn verschieben — und genau daraus entsteht die Gefahr
|
||||
* einer Endlosschleife:
|
||||
*
|
||||
* Die App schreibt → der Abgleich liest die eigene Änderung → hält sie für
|
||||
* fremd → schreibt zurück → der Abgleich liest wieder → …
|
||||
*
|
||||
* Der Ausweg ist ein Fingerabdruck über den Zeitraum. Beim Schreiben merkt
|
||||
* sich der Zeitblock, was er in den Kalender gelegt hat. Kommt beim nächsten
|
||||
* Abgleich derselbe Fingerabdruck zurück, war es die eigene Änderung und es
|
||||
* passiert nichts. Weicht er ab, hat jemand anderes verschoben — dann gewinnt
|
||||
* der Kalender.
|
||||
*
|
||||
* Reine Logik ohne Datenbank und ohne Systemzeit.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fingerabdruck eines Zeitraums.
|
||||
*
|
||||
* Bewusst nur über Beginn und Ende: Titel und Beschreibung ändern sich auch
|
||||
* aus harmlosen Gründen (etwa weil der Aufgabentitel bearbeitet wurde), und
|
||||
* für die Planung zählt allein, wann der Block liegt.
|
||||
*/
|
||||
export function zeitraumHash(beginn: Date, ende: Date): string {
|
||||
return createHash('sha256')
|
||||
.update(`${beginn.toISOString()}|${ende.toISOString()}`)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/** Was beim Abgleich mit einem Zeitblock zu tun ist. */
|
||||
export type Abgleichentscheidung =
|
||||
| { tun: 'nichts'; grund: 'unveraendert' | 'eigene-aenderung' }
|
||||
| { tun: 'uebernehmen'; grund: 'fremd-verschoben'; beginn: Date; ende: Date }
|
||||
| { tun: 'loesen'; grund: 'im-kalender-entfernt' }
|
||||
|
||||
export type ZeitblockStand = {
|
||||
/** Was zuletzt in den Kalender geschrieben wurde. */
|
||||
hash: string | null
|
||||
beginn: Date
|
||||
ende: Date
|
||||
}
|
||||
|
||||
export type KalenderStand = {
|
||||
beginn: Date
|
||||
ende: Date
|
||||
} | null
|
||||
|
||||
/**
|
||||
* Entscheidet, was beim Abgleich mit einem Zeitblock geschehen soll.
|
||||
*
|
||||
* Der Kalender gewinnt: Wurde ein Block dort verschoben, übernimmt der Planer
|
||||
* das, statt zurückzuschreiben. Wer im Kalender etwas verschiebt, meint es so
|
||||
* — und merkt es sofort, wenn der Planer es wieder wegzieht.
|
||||
*/
|
||||
export function abgleichEntscheiden(
|
||||
zeitblock: ZeitblockStand,
|
||||
imKalender: KalenderStand,
|
||||
): Abgleichentscheidung {
|
||||
if (!imKalender) {
|
||||
// Im Kalender gelöscht. Der Zeitblock verliert seine Verankerung; die
|
||||
// Aufgabe selbst bleibt bestehen.
|
||||
return { tun: 'loesen', grund: 'im-kalender-entfernt' }
|
||||
}
|
||||
|
||||
const imKalenderHash = zeitraumHash(imKalender.beginn, imKalender.ende)
|
||||
|
||||
// Steht im Kalender genau das, was wir zuletzt geschrieben haben, war es
|
||||
// die eigene Änderung – nichts zu tun. Das bricht die Schleife.
|
||||
if (zeitblock.hash !== null && imKalenderHash === zeitblock.hash) {
|
||||
return { tun: 'nichts', grund: 'eigene-aenderung' }
|
||||
}
|
||||
|
||||
// Stimmt der Kalender ohnehin mit unserem Stand überein, ist auch nichts
|
||||
// zu tun – etwa beim allerersten Abgleich nach dem Anlegen.
|
||||
const eigenerHash = zeitraumHash(zeitblock.beginn, zeitblock.ende)
|
||||
if (imKalenderHash === eigenerHash) {
|
||||
return { tun: 'nichts', grund: 'unveraendert' }
|
||||
}
|
||||
|
||||
return {
|
||||
tun: 'uebernehmen',
|
||||
grund: 'fremd-verschoben',
|
||||
beginn: imKalender.beginn,
|
||||
ende: imKalender.ende,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Muss beim Speichern in den Kalender geschrieben werden?
|
||||
*
|
||||
* Nur bei einer echten Verschiebung. Wird bloß der Aufgabentitel geändert,
|
||||
* bleibt der Fingerabdruck gleich und es gibt keinen Grund, den Anbieter zu
|
||||
* behelligen.
|
||||
*/
|
||||
export function mussSchreiben(
|
||||
zeitblock: ZeitblockStand,
|
||||
neuerBeginn: Date,
|
||||
neuesEnde: Date,
|
||||
): boolean {
|
||||
return zeitblock.hash !== zeitraumHash(neuerBeginn, neuesEnde)
|
||||
}
|
||||
|
||||
// --- Lage und Dauer --------------------------------------------------------
|
||||
|
||||
export const ZEITBLOCK_MIN_MINUTEN = 15
|
||||
export const ZEITBLOCK_MAX_MINUTEN = 8 * 60
|
||||
/** Raster, in dem Zeitblöcke einrasten. */
|
||||
export const RASTER_MINUTEN = 15
|
||||
|
||||
export function dauerMinuten(beginn: Date, ende: Date): number {
|
||||
return Math.round((ende.getTime() - beginn.getTime()) / 60_000)
|
||||
}
|
||||
|
||||
/** Rundet einen Zeitpunkt auf das Raster ab. */
|
||||
export function aufRaster(zeitpunkt: Date, raster = RASTER_MINUTEN): Date {
|
||||
const minuten = raster * 60_000
|
||||
return new Date(Math.round(zeitpunkt.getTime() / minuten) * minuten)
|
||||
}
|
||||
|
||||
export type LageFehler =
|
||||
'zu-kurz' | 'zu-lang' | 'ende-vor-beginn' | 'nicht-im-raster' | null
|
||||
|
||||
/** Prüft die Lage eines Zeitblocks. Gibt null zurück, wenn sie stimmt. */
|
||||
export function lagePruefen(beginn: Date, ende: Date): LageFehler {
|
||||
if (ende.getTime() <= beginn.getTime()) return 'ende-vor-beginn'
|
||||
|
||||
const dauer = dauerMinuten(beginn, ende)
|
||||
if (dauer < ZEITBLOCK_MIN_MINUTEN) return 'zu-kurz'
|
||||
if (dauer > ZEITBLOCK_MAX_MINUTEN) return 'zu-lang'
|
||||
|
||||
if (
|
||||
beginn.getTime() % (RASTER_MINUTEN * 60_000) !== 0 ||
|
||||
ende.getTime() % (RASTER_MINUTEN * 60_000) !== 0
|
||||
) {
|
||||
return 'nicht-im-raster'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const LAGE_FEHLER_TEXT: Record<Exclude<LageFehler, null>, string> = {
|
||||
'zu-kurz': `Ein Zeitblock dauert mindestens ${ZEITBLOCK_MIN_MINUTEN} Minuten.`,
|
||||
'zu-lang': 'Mehr als acht Stunden am Stück gehört in mehrere Zeitblöcke.',
|
||||
'ende-vor-beginn': 'Das Ende muss nach dem Beginn liegen.',
|
||||
'nicht-im-raster': `Zeitblöcke rasten in ${RASTER_MINUTEN}-Minuten-Schritten ein.`,
|
||||
}
|
||||
|
||||
/** Überschneiden sich zwei Zeitblöcke? Aneinandergrenzen zählt nicht. */
|
||||
export function ueberschneidetSich(
|
||||
a: { beginn: Date; ende: Date },
|
||||
b: { beginn: Date; ende: Date },
|
||||
): boolean {
|
||||
return a.beginn.getTime() < b.ende.getTime() && b.beginn.getTime() < a.ende.getTime()
|
||||
}
|
||||
|
||||
// --- Eingaben --------------------------------------------------------------
|
||||
|
||||
export const zeitblockSchema = z
|
||||
.object({
|
||||
aufgabeId: z.string().min(1),
|
||||
kalenderkontoId: z.string().min(1),
|
||||
beginn: z.coerce.date('Bitte einen gültigen Beginn angeben.'),
|
||||
ende: z.coerce.date('Bitte ein gültiges Ende angeben.'),
|
||||
})
|
||||
.refine((w) => lagePruefen(w.beginn, w.ende) === null, {
|
||||
path: ['ende'],
|
||||
message: 'Die Lage des Zeitblocks stimmt nicht.',
|
||||
})
|
||||
|
||||
export type ZeitblockEingabe = z.infer<typeof zeitblockSchema>
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'server-only'
|
||||
import type { ZeigtAls } from '@/generated/prisma/enums'
|
||||
import { tagesBeginn, wochenBeginn } from '@/lib/zeit'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
/**
|
||||
* Alles, was die Wochenansicht braucht — in einem Zug geladen.
|
||||
*
|
||||
* Die Zeitblöcke des Benutzers und die Termine seiner Kalender kommen
|
||||
* getrennt zurück: ein Zeitblock ist eigene Planung und lässt sich
|
||||
* verschieben, ein Termin ist fremd und wird nur angezeigt.
|
||||
*/
|
||||
|
||||
export type WochenZeitblock = {
|
||||
id: string
|
||||
beginn: Date
|
||||
ende: Date
|
||||
aufgabeId: string
|
||||
titel: string
|
||||
bereichName: string | null
|
||||
bereichFarbe: string | null
|
||||
kalenderName: string
|
||||
letzterFehler: string | null
|
||||
/** Noch nicht in den Kalender geschrieben. */
|
||||
offen: boolean
|
||||
}
|
||||
|
||||
export type WochenTermin = {
|
||||
id: string
|
||||
beginn: Date
|
||||
ende: Date
|
||||
betreff: string | null
|
||||
ganztaegig: boolean
|
||||
zeigtAls: ZeigtAls
|
||||
kalenderName: string
|
||||
}
|
||||
|
||||
export type WocheDaten = {
|
||||
zeitbloecke: WochenZeitblock[]
|
||||
termine: WochenTermin[]
|
||||
}
|
||||
|
||||
/** Beginn und Ende der Woche, in der der Zeitpunkt liegt. */
|
||||
export function wochenFenster(zeitpunkt: Date): { von: Date; bis: Date } {
|
||||
const montag = wochenBeginn(zeitpunkt)
|
||||
return {
|
||||
von: montag,
|
||||
bis: new Date(tagesBeginn(new Date(montag.getTime() + 7 * 86_400_000)).getTime()),
|
||||
}
|
||||
}
|
||||
|
||||
export async function wocheLaden(
|
||||
benutzerId: string,
|
||||
zeitpunkt: Date,
|
||||
): Promise<WocheDaten> {
|
||||
const fenster = wochenFenster(zeitpunkt)
|
||||
|
||||
const [zeitbloecke, termine] = await Promise.all([
|
||||
db.zeitblock.findMany({
|
||||
where: {
|
||||
kalenderkonto: { benutzerId },
|
||||
beginn: { lt: fenster.bis },
|
||||
ende: { gt: fenster.von },
|
||||
},
|
||||
orderBy: { beginn: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
extId: true,
|
||||
letzterFehler: true,
|
||||
aufgabe: {
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
bereich: { select: { name: true, farbe: true } },
|
||||
},
|
||||
},
|
||||
kalenderkonto: { select: { anzeigename: true } },
|
||||
},
|
||||
}),
|
||||
db.termin.findMany({
|
||||
where: {
|
||||
kalenderkonto: { benutzerId, aktiv: true },
|
||||
beginn: { lt: fenster.bis },
|
||||
ende: { gt: fenster.von },
|
||||
},
|
||||
orderBy: { beginn: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
extId: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
betreff: true,
|
||||
ganztaegig: true,
|
||||
zeigtAls: true,
|
||||
kalenderkonto: { select: { anzeigename: true } },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// Ein Zeitblock steht auch im Kalender und damit im Spiegel. Er würde sonst
|
||||
// doppelt erscheinen — einmal als eigene Planung, einmal als fremder Termin.
|
||||
const eigeneExtIds = new Set(
|
||||
zeitbloecke.map((z) => z.extId).filter((id): id is string => id !== null),
|
||||
)
|
||||
|
||||
return {
|
||||
zeitbloecke: zeitbloecke.map((z) => ({
|
||||
id: z.id,
|
||||
beginn: z.beginn,
|
||||
ende: z.ende,
|
||||
aufgabeId: z.aufgabe.id,
|
||||
titel: z.aufgabe.titel,
|
||||
bereichName: z.aufgabe.bereich?.name ?? null,
|
||||
bereichFarbe: z.aufgabe.bereich?.farbe ?? null,
|
||||
kalenderName: z.kalenderkonto.anzeigename,
|
||||
letzterFehler: z.letzterFehler,
|
||||
offen: z.extId === null,
|
||||
})),
|
||||
termine: termine
|
||||
.filter((t) => !eigeneExtIds.has(t.extId))
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
beginn: t.beginn,
|
||||
ende: t.ende,
|
||||
betreff: t.betreff,
|
||||
ganztaegig: t.ganztaegig,
|
||||
zeigtAls: t.zeigtAls,
|
||||
kalenderName: t.kalenderkonto.anzeigename,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** Aufgaben, die sich einplanen lassen: offen, keine Idee, mit Aufwand. */
|
||||
export async function planbareAufgabenLaden(grenze = 50) {
|
||||
return db.aufgabe.findMany({
|
||||
where: {
|
||||
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||
typ: { not: 'IDEE' },
|
||||
},
|
||||
orderBy: [{ faelligAm: 'asc' }, { prioritaet: 'asc' }],
|
||||
take: grenze,
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
aufwandMinuten: true,
|
||||
faelligAm: true,
|
||||
prioritaet: true,
|
||||
status: true,
|
||||
bereich: { select: { id: true, name: true, farbe: true } },
|
||||
_count: { select: { zeitbloecke: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
zeitblockSchema,
|
||||
zeitraumHash,
|
||||
LAGE_FEHLER_TEXT,
|
||||
lagePruefen,
|
||||
} from '@/lib/zeitblock'
|
||||
import { auditSchreiben } from '@/server/audit'
|
||||
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { gueltigesTokenFuer } from '@/server/kalender/abgleich'
|
||||
import {
|
||||
KalenderGewinnt,
|
||||
terminAendern,
|
||||
terminAnlegen,
|
||||
terminEntfernen,
|
||||
} from '@/server/kalender/schreiben'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
export type Rueckmeldung =
|
||||
{ ok: true; meldung: string; id?: string } | { ok: false; fehler: string }
|
||||
|
||||
async function akteurOderFehler() {
|
||||
try {
|
||||
return { akteur: await pruefeRolle('BENUTZER') }
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof NichtErlaubt) {
|
||||
return { antwort: { ok: false as const, fehler: fehler.message } }
|
||||
}
|
||||
throw fehler
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt einen Zeitblock, aber nur, wenn er dem angemeldeten Benutzer gehört —
|
||||
* die Kennung geht über das Kalenderkonto in die where-Bedingung.
|
||||
*/
|
||||
async function eigenerZeitblock(id: string, benutzerId: string) {
|
||||
return db.zeitblock.findFirst({
|
||||
where: { id, kalenderkonto: { benutzerId } },
|
||||
select: {
|
||||
id: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
extId: true,
|
||||
etag: true,
|
||||
hash: true,
|
||||
aufgabe: { select: { id: true, titel: true, notiz: true, status: true } },
|
||||
kalenderkonto: {
|
||||
select: { id: true, anbieter: true, kalenderId: true, anzeigename: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Was im Kalender als Betreff steht. */
|
||||
function betreffFuer(titel: string): string {
|
||||
return titel
|
||||
}
|
||||
|
||||
// --- Anlegen ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Legt einen Zeitblock an und schreibt ihn in den Kalender.
|
||||
*
|
||||
* Klappt das Schreiben nicht, bleibt der Zeitblock trotzdem bestehen — mit
|
||||
* vermerktem Fehler. Ihn wieder zu verwerfen hieße, die Planung des Benutzers
|
||||
* wegen eines Netzproblems zu vergessen; der nächste Versuch kann ihn
|
||||
* nachtragen.
|
||||
*/
|
||||
export async function zeitblockAnlegen(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOderFehler()
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = zeitblockSchema.safeParse({
|
||||
aufgabeId: daten.get('aufgabeId'),
|
||||
kalenderkontoId: daten.get('kalenderkontoId'),
|
||||
beginn: daten.get('beginn'),
|
||||
ende: daten.get('ende'),
|
||||
})
|
||||
if (!gelesen.success) {
|
||||
const lage = gelesen.error.issues.find((i) => i.path.includes('ende'))
|
||||
return { ok: false, fehler: lage?.message ?? 'Die Angaben stimmen nicht.' }
|
||||
}
|
||||
|
||||
const [aufgabe, konto] = await Promise.all([
|
||||
db.aufgabe.findUnique({
|
||||
where: { id: gelesen.data.aufgabeId },
|
||||
select: { id: true, titel: true, notiz: true, status: true },
|
||||
}),
|
||||
db.kalenderkonto.findFirst({
|
||||
where: { id: gelesen.data.kalenderkontoId, benutzerId: akteur.id, aktiv: true },
|
||||
select: { id: true, anbieter: true, kalenderId: true, anzeigename: true },
|
||||
}),
|
||||
])
|
||||
|
||||
if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht.' }
|
||||
if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' }
|
||||
|
||||
const zeitblock = await db.zeitblock.create({
|
||||
data: {
|
||||
aufgabeId: aufgabe.id,
|
||||
kalenderkontoId: konto.id,
|
||||
beginn: gelesen.data.beginn,
|
||||
ende: gelesen.data.ende,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
// Eine eingeplante Aufgabe ist geplant, nicht mehr bloß offen.
|
||||
if (aufgabe.status === 'OFFEN') {
|
||||
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
|
||||
}
|
||||
|
||||
let schreibfehler: string | null = null
|
||||
try {
|
||||
const token = await gueltigesTokenFuer(konto.id, jetzt())
|
||||
const ergebnis = await terminAnlegen({
|
||||
anbieter: konto.anbieter,
|
||||
kalenderId: konto.kalenderId,
|
||||
token,
|
||||
betreff: betreffFuer(aufgabe.titel),
|
||||
beschreibung: aufgabe.notiz,
|
||||
beginn: gelesen.data.beginn,
|
||||
ende: gelesen.data.ende,
|
||||
})
|
||||
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: {
|
||||
extId: ergebnis.extId,
|
||||
etag: ergebnis.etag,
|
||||
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
|
||||
letzterFehler: null,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
schreibfehler =
|
||||
fehler instanceof Error ? fehler.message : 'Der Termin ließ sich nicht anlegen.'
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: { letzterFehler: schreibfehler },
|
||||
})
|
||||
}
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'zeitblock.angelegt',
|
||||
objektTyp: 'Zeitblock',
|
||||
objektId: zeitblock.id,
|
||||
details: {
|
||||
aufgabe: aufgabe.titel,
|
||||
kalender: konto.anzeigename,
|
||||
geschrieben: schreibfehler === null,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/woche')
|
||||
revalidatePath('/aufgaben')
|
||||
revalidatePath('/')
|
||||
|
||||
return schreibfehler
|
||||
? {
|
||||
ok: false,
|
||||
fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${schreibfehler}`,
|
||||
}
|
||||
: { ok: true, id: zeitblock.id, meldung: `„${aufgabe.titel}" eingeplant.` }
|
||||
}
|
||||
|
||||
// --- Verschieben -----------------------------------------------------------
|
||||
|
||||
const verschieben = z.object({
|
||||
id: z.string().min(1),
|
||||
beginn: z.coerce.date(),
|
||||
ende: z.coerce.date(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Verschiebt einen Zeitblock und zieht den Kalender nach.
|
||||
*
|
||||
* Antwortet der Anbieter mit 412, hat jemand anderes den Termin inzwischen
|
||||
* angefasst. Dann gewinnt der Kalender: der Planer schreibt nicht, sondern
|
||||
* meldet das und überlässt dem nächsten Abgleich, den fremden Stand zu holen.
|
||||
*/
|
||||
export async function zeitblockVerschieben(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOderFehler()
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = verschieben.safeParse({
|
||||
id: daten.get('id'),
|
||||
beginn: daten.get('beginn'),
|
||||
ende: daten.get('ende'),
|
||||
})
|
||||
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||
|
||||
const lage = lagePruefen(gelesen.data.beginn, gelesen.data.ende)
|
||||
if (lage) return { ok: false, fehler: LAGE_FEHLER_TEXT[lage] }
|
||||
|
||||
const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id)
|
||||
if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' }
|
||||
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: { beginn: gelesen.data.beginn, ende: gelesen.data.ende },
|
||||
})
|
||||
|
||||
let meldung = `„${zeitblock.aufgabe.titel}" verschoben.`
|
||||
let ok = true
|
||||
|
||||
try {
|
||||
const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt())
|
||||
|
||||
if (zeitblock.extId) {
|
||||
const ergebnis = await terminAendern({
|
||||
anbieter: zeitblock.kalenderkonto.anbieter,
|
||||
kalenderId: zeitblock.kalenderkonto.kalenderId,
|
||||
token,
|
||||
extId: zeitblock.extId,
|
||||
etag: zeitblock.etag,
|
||||
betreff: betreffFuer(zeitblock.aufgabe.titel),
|
||||
beschreibung: zeitblock.aufgabe.notiz,
|
||||
beginn: gelesen.data.beginn,
|
||||
ende: gelesen.data.ende,
|
||||
})
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: {
|
||||
etag: ergebnis.etag,
|
||||
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
|
||||
letzterFehler: null,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Beim Anlegen war der Kalender nicht erreichbar – jetzt nachtragen.
|
||||
const ergebnis = await terminAnlegen({
|
||||
anbieter: zeitblock.kalenderkonto.anbieter,
|
||||
kalenderId: zeitblock.kalenderkonto.kalenderId,
|
||||
token,
|
||||
betreff: betreffFuer(zeitblock.aufgabe.titel),
|
||||
beschreibung: zeitblock.aufgabe.notiz,
|
||||
beginn: gelesen.data.beginn,
|
||||
ende: gelesen.data.ende,
|
||||
})
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: {
|
||||
extId: ergebnis.extId,
|
||||
etag: ergebnis.etag,
|
||||
hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende),
|
||||
letzterFehler: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (fehler) {
|
||||
ok = false
|
||||
if (fehler instanceof KalenderGewinnt) {
|
||||
meldung =
|
||||
'Der Termin wurde inzwischen im Kalender geändert. Der Kalender gewinnt — der nächste Abgleich holt den dortigen Stand.'
|
||||
// Den eigenen Fingerabdruck verwerfen, damit der Abgleich die fremde
|
||||
// Änderung als solche erkennt.
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: { hash: null, letzterFehler: meldung },
|
||||
})
|
||||
} else {
|
||||
meldung =
|
||||
fehler instanceof Error ? fehler.message : 'Der Kalender ließ sich nicht ändern.'
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: { letzterFehler: meldung },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'zeitblock.verschoben',
|
||||
objektTyp: 'Zeitblock',
|
||||
objektId: zeitblock.id,
|
||||
details: { aufgabe: zeitblock.aufgabe.titel, geschrieben: ok },
|
||||
})
|
||||
|
||||
revalidatePath('/woche')
|
||||
revalidatePath('/')
|
||||
return ok ? { ok: true, meldung } : { ok: false, fehler: meldung }
|
||||
}
|
||||
|
||||
// --- Lösen -----------------------------------------------------------------
|
||||
|
||||
const loesen = z.object({ id: z.string().min(1) })
|
||||
|
||||
/** Nimmt einen Zeitblock zurück und entfernt den Termin aus dem Kalender. */
|
||||
export async function zeitblockLoesen(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOderFehler()
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = loesen.safeParse({ id: daten.get('id') })
|
||||
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||
|
||||
const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id)
|
||||
if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' }
|
||||
|
||||
let hinweis = ''
|
||||
if (zeitblock.extId) {
|
||||
try {
|
||||
const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt())
|
||||
await terminEntfernen({
|
||||
anbieter: zeitblock.kalenderkonto.anbieter,
|
||||
kalenderId: zeitblock.kalenderkonto.kalenderId,
|
||||
token,
|
||||
extId: zeitblock.extId,
|
||||
etag: zeitblock.etag,
|
||||
})
|
||||
} catch {
|
||||
// Der Zeitblock verschwindet trotzdem. Bliebe er stehen, weil der
|
||||
// Kalender gerade nicht erreichbar ist, wäre die Planung blockiert.
|
||||
hinweis = ' Der Termin im Kalender ließ sich nicht entfernen.'
|
||||
}
|
||||
}
|
||||
|
||||
await db.zeitblock.delete({ where: { id: zeitblock.id } })
|
||||
|
||||
// Bleibt keine Planung übrig, ist die Aufgabe wieder schlicht offen.
|
||||
const nochGeplant = await db.zeitblock.count({
|
||||
where: { aufgabeId: zeitblock.aufgabe.id },
|
||||
})
|
||||
if (nochGeplant === 0 && zeitblock.aufgabe.status === 'GEPLANT') {
|
||||
await db.aufgabe.update({
|
||||
where: { id: zeitblock.aufgabe.id },
|
||||
data: { status: 'OFFEN' },
|
||||
})
|
||||
}
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'zeitblock.geloest',
|
||||
objektTyp: 'Zeitblock',
|
||||
objektId: zeitblock.id,
|
||||
details: { aufgabe: zeitblock.aufgabe.titel },
|
||||
})
|
||||
|
||||
revalidatePath('/woche')
|
||||
revalidatePath('/aufgaben')
|
||||
revalidatePath('/')
|
||||
return { ok: true, meldung: `„${zeitblock.aufgabe.titel}" ausgeplant.${hinweis}` }
|
||||
}
|
||||
@@ -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<string> {
|
||||
return satz.zugriffstoken
|
||||
}
|
||||
|
||||
/**
|
||||
* Gültiges Zugriffstoken für ein Konto – für den Schreibweg aus M4, der
|
||||
* nicht den ganzen Abgleich braucht.
|
||||
*/
|
||||
export async function gueltigesTokenFuer(
|
||||
kalenderkontoId: string,
|
||||
zeitpunkt: Date,
|
||||
): Promise<string> {
|
||||
const konto = await db.kalenderkonto.findUnique({
|
||||
where: { id: kalenderkontoId },
|
||||
select: {
|
||||
id: true,
|
||||
anbieter: true,
|
||||
anzeigename: true,
|
||||
kalenderId: true,
|
||||
zugriffstoken: true,
|
||||
aktualisierungstoken: true,
|
||||
tokenGueltigBis: true,
|
||||
deltaVerweis: true,
|
||||
},
|
||||
})
|
||||
if (!konto) throw new Error('Dieses Kalenderkonto gibt es nicht.')
|
||||
return gueltigesToken(konto, zeitpunkt)
|
||||
}
|
||||
|
||||
/** Adresse der ersten Seite einer Runde. */
|
||||
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: {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'server-only'
|
||||
import { abgleichEntscheiden, zeitraumHash } from '@/lib/zeitblock'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
/**
|
||||
* Der Rückweg: Änderungen an Zeitblöcken aus dem Kalender übernehmen.
|
||||
*
|
||||
* Läuft nach jedem Abgleich eines Kontos. Für jeden Zeitblock wird verglichen,
|
||||
* was im Kalender steht — der örtliche Spiegel (Tabelle `termin`) ist dafür
|
||||
* bereits auf dem neuesten Stand.
|
||||
*
|
||||
* **Der Kalender gewinnt.** Wer dort etwas verschiebt, meint es so; der Planer
|
||||
* zieht nach, statt zurückzuschreiben. Sonst zöge die App dem Benutzer den
|
||||
* Termin unter den Händen weg.
|
||||
*/
|
||||
|
||||
export type RueckwegBericht = {
|
||||
uebernommen: number
|
||||
geloest: number
|
||||
}
|
||||
|
||||
export async function rueckwegAnwenden(
|
||||
kalenderkontoId: string,
|
||||
): Promise<RueckwegBericht> {
|
||||
const zeitbloecke = await db.zeitblock.findMany({
|
||||
where: { kalenderkontoId, extId: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
extId: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
hash: true,
|
||||
aufgabeId: true,
|
||||
},
|
||||
})
|
||||
if (zeitbloecke.length === 0) return { uebernommen: 0, geloest: 0 }
|
||||
|
||||
const extIds = zeitbloecke.map((z) => z.extId).filter((id): id is string => id !== null)
|
||||
|
||||
const termine = await db.termin.findMany({
|
||||
where: { kalenderkontoId, extId: { in: extIds } },
|
||||
select: { extId: true, beginn: true, ende: true, etag: true },
|
||||
})
|
||||
const nachExtId = new Map(termine.map((t) => [t.extId, t]))
|
||||
|
||||
let uebernommen = 0
|
||||
let geloest = 0
|
||||
|
||||
for (const zeitblock of zeitbloecke) {
|
||||
const imKalender = nachExtId.get(zeitblock.extId as string) ?? null
|
||||
const entscheidung = abgleichEntscheiden(
|
||||
{ hash: zeitblock.hash, beginn: zeitblock.beginn, ende: zeitblock.ende },
|
||||
imKalender ? { beginn: imKalender.beginn, ende: imKalender.ende } : null,
|
||||
)
|
||||
|
||||
if (entscheidung.tun === 'nichts') continue
|
||||
|
||||
if (entscheidung.tun === 'loesen') {
|
||||
// Im Kalender gelöscht: der Zeitblock verliert seine Verankerung. Die
|
||||
// Aufgabe bleibt bestehen und fällt zurück auf offen, wenn nichts
|
||||
// anderes mehr für sie geplant ist.
|
||||
await db.zeitblock.delete({ where: { id: zeitblock.id } })
|
||||
geloest++
|
||||
|
||||
const nochGeplant = await db.zeitblock.count({
|
||||
where: { aufgabeId: zeitblock.aufgabeId },
|
||||
})
|
||||
if (nochGeplant === 0) {
|
||||
await db.aufgabe.updateMany({
|
||||
where: { id: zeitblock.aufgabeId, status: 'GEPLANT' },
|
||||
data: { status: 'OFFEN' },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
await db.zeitblock.update({
|
||||
where: { id: zeitblock.id },
|
||||
data: {
|
||||
beginn: entscheidung.beginn,
|
||||
ende: entscheidung.ende,
|
||||
// Den Fingerabdruck auf den übernommenen Stand setzen, damit die
|
||||
// nächste Runde ihn wiedererkennt und nichts mehr tut.
|
||||
hash: zeitraumHash(entscheidung.beginn, entscheidung.ende),
|
||||
etag: imKalender?.etag ?? null,
|
||||
letzterFehler: null,
|
||||
},
|
||||
})
|
||||
uebernommen++
|
||||
}
|
||||
|
||||
return { uebernommen, geloest }
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'server-only'
|
||||
import { z } from 'zod'
|
||||
import type { KalenderAnbieter } from '@/generated/prisma/enums'
|
||||
|
||||
/**
|
||||
* Schreiben in die Kalender der Anbieter.
|
||||
*
|
||||
* Jeder Schreibzugriff auf einen bestehenden Termin geht mit `If-Match` und
|
||||
* dem zuletzt gelesenen Etag heraus. Hat inzwischen jemand anderes den Termin
|
||||
* geändert, antwortet der Anbieter mit 412 — dann **gewinnt der Kalender**,
|
||||
* und der Planer übernimmt dessen Stand, statt ihn zu überschreiben.
|
||||
*/
|
||||
|
||||
/** Der Termin wurde inzwischen anderswo geändert. */
|
||||
export class KalenderGewinnt extends Error {
|
||||
constructor(anbieter: string) {
|
||||
super(`Der Termin wurde in ${anbieter} zwischenzeitlich geändert.`)
|
||||
this.name = 'KalenderGewinnt'
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Termin ist im Kalender nicht mehr vorhanden. */
|
||||
export class TerminFort extends Error {
|
||||
constructor(anbieter: string) {
|
||||
super(`Der Termin ist in ${anbieter} nicht mehr vorhanden.`)
|
||||
this.name = 'TerminFort'
|
||||
}
|
||||
}
|
||||
|
||||
export type SchreibEingabe = {
|
||||
anbieter: KalenderAnbieter
|
||||
kalenderId: string
|
||||
token: string
|
||||
betreff: string
|
||||
beschreibung: string | null
|
||||
beginn: Date
|
||||
ende: Date
|
||||
}
|
||||
|
||||
export type SchreibErgebnis = { extId: string; etag: string | null }
|
||||
|
||||
const graphAntwort = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
'@odata.etag': z.string().optional(),
|
||||
})
|
||||
|
||||
const googleAntwort = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
etag: z.string().optional(),
|
||||
})
|
||||
|
||||
function graphBasis(kalenderId: string): string {
|
||||
return kalenderId === 'standard'
|
||||
? 'https://graph.microsoft.com/v1.0/me/events'
|
||||
: `https://graph.microsoft.com/v1.0/me/calendars/${encodeURIComponent(kalenderId)}/events`
|
||||
}
|
||||
|
||||
function googleBasis(kalenderId: string): string {
|
||||
const kalender = encodeURIComponent(kalenderId === 'standard' ? 'primary' : kalenderId)
|
||||
return `https://www.googleapis.com/calendar/v3/calendars/${kalender}/events`
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Rumpf des Termins.
|
||||
*
|
||||
* Graph erwartet Zeit und Zone getrennt; wir schicken UTC, damit auf beiden
|
||||
* Wegen dasselbe gilt wie beim Lesen. Google nimmt einen ISO-Zeitpunkt mit
|
||||
* Versatz direkt entgegen.
|
||||
*/
|
||||
function rumpf(eingabe: SchreibEingabe): Record<string, unknown> {
|
||||
if (eingabe.anbieter === 'GRAPH') {
|
||||
return {
|
||||
subject: eingabe.betreff,
|
||||
body: { contentType: 'text', content: eingabe.beschreibung ?? '' },
|
||||
start: { dateTime: eingabe.beginn.toISOString(), timeZone: 'UTC' },
|
||||
end: { dateTime: eingabe.ende.toISOString(), timeZone: 'UTC' },
|
||||
showAs: 'busy',
|
||||
// Damit im Kalender erkennbar ist, woher der Termin stammt.
|
||||
categories: ['Planer'],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
summary: eingabe.betreff,
|
||||
description: eingabe.beschreibung ?? '',
|
||||
start: { dateTime: eingabe.beginn.toISOString() },
|
||||
end: { dateTime: eingabe.ende.toISOString() },
|
||||
transparency: 'opaque',
|
||||
source: { title: 'Planer', url: process.env.APP_URL ?? '' },
|
||||
}
|
||||
}
|
||||
|
||||
async function senden(
|
||||
anbieter: KalenderAnbieter,
|
||||
adresse: string,
|
||||
verfahren: 'POST' | 'PATCH' | 'DELETE',
|
||||
token: string,
|
||||
inhalt: Record<string, unknown> | null,
|
||||
etag: string | null,
|
||||
): Promise<Response> {
|
||||
const kopfzeilen: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
}
|
||||
if (inhalt) kopfzeilen['Content-Type'] = 'application/json'
|
||||
// Ohne Etag kein If-Match: ein Schreibzugriff ohne Vergleichsstand darf
|
||||
// nicht als "unverändert" durchgehen.
|
||||
if (etag) kopfzeilen['If-Match'] = etag
|
||||
|
||||
const antwort = await fetch(adresse, {
|
||||
method: verfahren,
|
||||
headers: kopfzeilen,
|
||||
...(inhalt ? { body: JSON.stringify(inhalt) } : {}),
|
||||
})
|
||||
|
||||
if (antwort.status === 412) throw new KalenderGewinnt(anbieter)
|
||||
if (antwort.status === 404 || antwort.status === 410) throw new TerminFort(anbieter)
|
||||
|
||||
return antwort
|
||||
}
|
||||
|
||||
/** Legt einen Termin an. */
|
||||
export async function terminAnlegen(eingabe: SchreibEingabe): Promise<SchreibErgebnis> {
|
||||
const adresse =
|
||||
eingabe.anbieter === 'GRAPH'
|
||||
? graphBasis(eingabe.kalenderId)
|
||||
: googleBasis(eingabe.kalenderId)
|
||||
|
||||
const antwort = await senden(
|
||||
eingabe.anbieter,
|
||||
adresse,
|
||||
'POST',
|
||||
eingabe.token,
|
||||
rumpf(eingabe),
|
||||
null,
|
||||
)
|
||||
|
||||
if (!antwort.ok) {
|
||||
throw new Error(`Der Termin ließ sich nicht anlegen (${antwort.status}).`)
|
||||
}
|
||||
|
||||
const inhalt: unknown = await antwort.json()
|
||||
if (eingabe.anbieter === 'GRAPH') {
|
||||
const gelesen = graphAntwort.safeParse(inhalt)
|
||||
if (!gelesen.success) throw new Error('Die Antwort von Graph war nicht verwertbar.')
|
||||
return { extId: gelesen.data.id, etag: gelesen.data['@odata.etag'] ?? null }
|
||||
}
|
||||
|
||||
const gelesen = googleAntwort.safeParse(inhalt)
|
||||
if (!gelesen.success) throw new Error('Die Antwort von Google war nicht verwertbar.')
|
||||
return { extId: gelesen.data.id, etag: gelesen.data.etag ?? null }
|
||||
}
|
||||
|
||||
/** Ändert einen bestehenden Termin. */
|
||||
export async function terminAendern(
|
||||
eingabe: SchreibEingabe & { extId: string; etag: string | null },
|
||||
): Promise<SchreibErgebnis> {
|
||||
const basis =
|
||||
eingabe.anbieter === 'GRAPH'
|
||||
? graphBasis(eingabe.kalenderId)
|
||||
: googleBasis(eingabe.kalenderId)
|
||||
const adresse = `${basis}/${encodeURIComponent(eingabe.extId)}`
|
||||
|
||||
const antwort = await senden(
|
||||
eingabe.anbieter,
|
||||
adresse,
|
||||
'PATCH',
|
||||
eingabe.token,
|
||||
rumpf(eingabe),
|
||||
eingabe.etag,
|
||||
)
|
||||
|
||||
if (!antwort.ok) {
|
||||
throw new Error(`Der Termin ließ sich nicht ändern (${antwort.status}).`)
|
||||
}
|
||||
|
||||
const inhalt: unknown = await antwort.json()
|
||||
if (eingabe.anbieter === 'GRAPH') {
|
||||
const gelesen = graphAntwort.safeParse(inhalt)
|
||||
return {
|
||||
extId: eingabe.extId,
|
||||
etag: gelesen.success ? (gelesen.data['@odata.etag'] ?? null) : null,
|
||||
}
|
||||
}
|
||||
|
||||
const gelesen = googleAntwort.safeParse(inhalt)
|
||||
return {
|
||||
extId: eingabe.extId,
|
||||
etag: gelesen.success ? (gelesen.data.etag ?? null) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt einen Termin. Ist er ohnehin schon fort, gilt das als erledigt —
|
||||
* das Ziel ist erreicht.
|
||||
*/
|
||||
export async function terminEntfernen(eingabe: {
|
||||
anbieter: KalenderAnbieter
|
||||
kalenderId: string
|
||||
token: string
|
||||
extId: string
|
||||
etag: string | null
|
||||
}): Promise<void> {
|
||||
const basis =
|
||||
eingabe.anbieter === 'GRAPH'
|
||||
? graphBasis(eingabe.kalenderId)
|
||||
: googleBasis(eingabe.kalenderId)
|
||||
const adresse = `${basis}/${encodeURIComponent(eingabe.extId)}`
|
||||
|
||||
try {
|
||||
const antwort = await senden(
|
||||
eingabe.anbieter,
|
||||
adresse,
|
||||
'DELETE',
|
||||
eingabe.token,
|
||||
null,
|
||||
eingabe.etag,
|
||||
)
|
||||
if (!antwort.ok && antwort.status !== 204) {
|
||||
throw new Error(`Der Termin ließ sich nicht entfernen (${antwort.status}).`)
|
||||
}
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof TerminFort) return
|
||||
throw fehler
|
||||
}
|
||||
}
|
||||