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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:42:00 +02:00
70 changed files with 2987 additions and 21 deletions
+30
View File
@@ -6,6 +6,36 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [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 ## [0.3.0] – 2026-09-10
### Hinzugefügt ### Hinzugefügt
+7 -4
View File
@@ -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 Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
wird. wird.
> **Stand:** Meilenstein 3 von 8. Aufgaben, Projekte und das Lesen der > **Stand:** Meilenstein 4 von 8. Die Kernidee steht: Aufgaben lassen sich
> Kalender stehen: der Planer weiß jetzt, wann überhaupt Platz ist. Das > per Ziehen und Ablegen als Zeitblöcke in den Kalender schreiben, und
> Schreiben der Zeitblöcke per Ziehen und Ablegen kommt mit M4. Der > 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. > [Fahrplan](ROADMAP.md) sagt, was wann folgt.
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone` - **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
@@ -30,6 +31,8 @@ wird.
- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt - **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt
- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je - **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je
Bereich, wie voll der heutige Tag schon ist Bereich, wie voll der heutige Tag schon ist
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
Kalenderterminen, Verschiebungen im Kalender kommen zurück
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und - Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
Passwort — je Instanz einzeln zuschaltbar Passwort — je Instanz einzeln zuschaltbar
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird - Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
@@ -40,7 +43,6 @@ wird.
### Geplant ### Geplant
Zeitblöcke in den Kalender schreiben, Wochenplanung per Ziehen und Ablegen,
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
@@ -51,6 +53,7 @@ MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) | | Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) |
| Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) | | Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) |
| Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) | | Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) |
| Woche | ![Woche, hell](docs/bilder/woche-hell-schreibtisch.png) | ![Woche, dunkel](docs/bilder/woche-dunkel-schreibtisch.png) |
| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) | | Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) |
| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) | | Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) |
| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) | | Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) |
+13 -7
View File
@@ -8,7 +8,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| [M1](#m1--fundament) | Fundament | erledigt | | [M1](#m1--fundament) | Fundament | erledigt |
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt | | [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt | | [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant | | [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant | | [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
| [M6](#m6--integrationen) | Integrationen | geplant | | [M6](#m6--integrationen) | Integrationen | geplant |
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | 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 ## 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 **Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und
Verschiebungen von dort zurückholen. Verschiebungen von dort zurückholen.
- [ ] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto - [x] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
- [ ] Wochenansicht mit `@dnd-kit/core` - [x] Wochenansicht mit `@dnd-kit/core`
- [ ] Schreiben in den Kalender, Rückweg beim Verschieben - [x] Schreiben in den Kalender, Rückweg beim Verschieben
- [ ] Schleifenschutz über `hash = sha256(startISO|endeISO)` - [x] Schleifenschutz über `hash = sha256(startISO|endeISO)`
- [ ] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match) - [x] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
- [ ] Belegung gegen die Tageskapazität des Bereichs - [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 ## M5 – Benachrichtigungen
+78 -1
View File
@@ -10,7 +10,84 @@ die Übersicht aus `scripts/zeit.sh summe`.
| M1 | claude | 161 | 2,68 | | M1 | claude | 161 | 2,68 |
| M2 | claude | 44 | 0,73 | | M2 | claude | 44 | 0,73 |
| M3 | claude | 41 | 0,68 | | M3 | claude | 41 | 0,68 |
| **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
--- ---
+1
View File
@@ -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;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;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig
2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig 2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
1 datum start ende minuten meilenstein art notiz
2 2026-09-10 12:06 14:47 161 M1 claude M1 Fundament fertiggestellt
3 2026-09-10 15:57 16:41 44 M2 claude M2 Aufgaben und Projekte fertig
4 2026-09-10 17:48 18:29 41 M3 claude M3 Kalender lesen fertig
5 2026-09-10 20:42 23:29 167 M4 claude M4 Wochenplanung fertig
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+3 -2
View File
@@ -18,10 +18,12 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
Fortschritt Fortschritt
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google 6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
verbinden, Belegung verstehen verbinden, Belegung verstehen
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
Kalender legen
## Verwaltung ## 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 ## Was noch kommt
@@ -29,7 +31,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
| Thema | Meilenstein | | Thema | Meilenstein |
| ------------------------------- | ----------- | | ------------------------------- | ----------- |
| Wochenplanung und Zeitblöcke | M4 |
| Benachrichtigungen | M5 | | Benachrichtigungen | M5 |
| Integrationen | M6 | | Integrationen | M6 |
| Schnellerfassung vom Handy, API | M7 | | Schnellerfassung vom Handy, API | M7 |
+106
View File
@@ -0,0 +1,106 @@
# Wochenplanung und Zeitblöcke
Hier wird aus einer Aufgabe ein Termin. Du ziehst sie in ein Zeitfenster, und
der Planer legt sie als **Zeitblock** in deinen echten Kalender.
![Die Wochenansicht](../bilder/woche-hell-schreibtisch.png)
## Der Aufbau
| Wo | Was |
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Links** | Alles, was noch eingeplant werden kann — offen, geplant oder wartend. Ideen bleiben draußen |
| **Rechts** | Sieben Tage im Viertelstundenraster |
| **Über jeder Spalte** | Belegte Zeit gegen die Kapazität dieses Tages |
| **Graue Balken** | Termine aus deinem Kalender. Sie gehören dem Kalender, nicht dem Planer — hier lassen sie sich nicht anfassen |
| **Farbige Balken** | Deine Zeitblöcke, in der Farbe ihres Bereichs |
Das sichtbare Stundenfenster ergibt sich aus den Arbeitsfenstern **aller**
Bereiche, mit einer Stunde Luft nach oben und unten.
## Eine Aufgabe einplanen
Aufgabe links am Griff packen und in ein Zeitfenster ziehen. Fertig — der
Zeitblock steht sofort im Raster und wenige Augenblicke später in deinem
Kalender.
> **Die Dauer kommt aus dem geschätzten Aufwand.** Wer bei der Aufgabe
> „90 Minuten" einträgt, bekommt einen 90-Minuten-Block. Ohne Schätzung sind
> es 60 Minuten, die du danach anpassen kannst.
Eine eingeplante Aufgabe wechselt automatisch von _Offen_ auf _Geplant_.
## Verschieben
Am Griff im Block packen und woandershin ziehen. Der Planer zieht den
Kalendertermin nach.
## Ausplanen
Das Kreuz im Block. Der Termin verschwindet aus dem Kalender; die Aufgabe
bleibt und fällt zurück auf _Offen_, wenn nichts anderes mehr für sie geplant
ist.
## Wenn du im Kalender verschiebst
Das darfst du — und der Planer zieht nach. Beim nächsten Abgleich (spätestens
nach 15 Minuten) steht der Block dort, wo du ihn im Kalender hingelegt hast.
> **Der Kalender gewinnt.** Immer. Wer dort etwas verschiebt, meint es so;
> säße der Planer am längeren Hebel, würde er dir den Termin wieder wegziehen
> und du wüsstest nicht, warum.
>
> Löschst du den Termin im Kalender, verschwindet auch der Zeitblock. Die
> Aufgabe selbst bleibt.
### Warum sich das nicht aufschaukelt
Der Planer merkt sich zu jedem Zeitblock einen Fingerabdruck des Zeitraums,
den er zuletzt geschrieben hat. Findet er beim Abgleich genau diesen
Fingerabdruck wieder, war es seine eigene Änderung — er tut nichts. Weicht er
ab, hat jemand anderes verschoben.
Ohne diesen Kniff würden App und Kalender einander endlos hinterherschreiben.
## Das Warnzeichen im Block
Ein kleines Dreieck heißt: **noch nicht im Kalender**. Meist war der Anbieter
gerade nicht erreichbar.
Der Zeitblock bleibt trotzdem stehen — deine Planung soll nicht verlorengehen,
bloß weil ein Server hustet. Verschiebe den Block einmal, dann trägt der
Planer ihn nach.
## Die Zahl über der Spalte
`3 h / 6 h` heißt: sechs Stunden Kapazität an diesem Tag, drei davon schon
belegt. Gezählt werden Kalendertermine **und** deine Zeitblöcke.
Wird die Zahl rot, ist der Tag überbucht. Das ist keine Sperre — der Planer
lässt dich weiterplanen. Es ist ein Hinweis, dass der Tag nicht aufgeht.
Ganztägige Termine zählen nicht mit. Ein Urlaubstag im Kalender blockiert
sonst rechnerisch den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
## Blättern
**Zurück**, **Diese Woche** und **Weiter**. Die Woche steht in der Adresse —
eine bestimmte Woche lässt sich also als Lesezeichen ablegen.
## Wenn es klemmt
| Was du siehst | Was dahintersteckt |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| „Ohne verbundenen Kalender lässt sich nichts einplanen." | Erst unter [Kalender](kalenderkonten.md) ein Konto verbinden |
| „Eingeplant, aber nicht in den Kalender geschrieben" | Der Anbieter war nicht erreichbar. Der Block steht, verschiebe ihn einmal zum Nachtragen |
| „Der Termin wurde inzwischen im Kalender geändert." | Jemand war schneller. Der Kalender gewinnt; der nächste Abgleich holt den dortigen Stand |
| „Zeitblöcke rasten in 15-Minuten-Schritten ein." | Das Raster ist Absicht. Genauer zu planen bringt nichts |
| Ein Block ist nicht zu sehen | Er liegt außerhalb des sichtbaren Stundenfensters. Das Fenster folgt den Arbeitsfenstern der Bereiche — siehe [Bereiche](bereiche.md) |
## Was noch nicht geht
- **Ein Zeitblock landet immer im ersten verbundenen Kalender.** Die Auswahl
je Bereich ist im Datenmodell vorbereitet, aber noch nicht in der
Oberfläche.
- **Die Dauer lässt sich nicht am Balken ziehen.** Wer eine andere Dauer will,
ändert den Aufwand der Aufgabe und plant neu ein.
+126
View File
@@ -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$/)
})
})
+43 -2
View File
@@ -1,15 +1,17 @@
{ {
"name": "planer", "name": "planer",
"version": "0.3.0", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "planer", "name": "planer",
"version": "0.3.0", "version": "0.4.0",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "2.11.3", "@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0", "@date-fns/tz": "1.5.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@prisma/adapter-pg": "7.10.0", "@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@radix-ui/react-avatar": "1.2.6", "@radix-ui/react-avatar": "1.2.6",
@@ -593,6 +595,45 @@
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT" "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": { "node_modules/@electric-sql/pglite": {
"version": "0.4.3", "version": "0.4.3",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "planer", "name": "planer",
"version": "0.3.0", "version": "0.4.0",
"private": true, "private": true,
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender", "description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
"type": "module", "type": "module",
@@ -40,6 +40,8 @@
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "2.11.3", "@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0", "@date-fns/tz": "1.5.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@prisma/adapter-pg": "7.10.0", "@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@radix-ui/react-avatar": "1.2.6", "@radix-ui/react-avatar": "1.2.6",
+31
View File
@@ -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. // Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
await db.aufgabe.create({ await db.aufgabe.create({
data: { 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;
+38
View File
@@ -224,6 +224,7 @@ model Aufgabe {
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull) bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull) projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
zeitbloecke Zeitblock[]
/// Integrationen upserten immer ueber diese beiden Felder. Von Hand /// Integrationen upserten immer ueber diese beiden Felder. Von Hand
/// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte /// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte
@@ -308,6 +309,7 @@ model Kalenderkonto {
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
termine Termin[] termine Termin[]
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto") standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
zeitblocks Zeitblock[]
/// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer. /// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer.
@@unique([benutzerId, anbieter, extKontoId, kalenderId]) @@unique([benutzerId, anbieter, extKontoId, kalenderId])
@@ -340,3 +342,39 @@ model Termin {
@@index([beginn, ende]) @@index([beginn, ende])
@@map("termin") @@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")
}
+1
View File
@@ -31,6 +31,7 @@ const AUFNAHMEN: Aufnahme[] = [
{ name: 'start', pfad: '/' }, { name: 'start', pfad: '/' },
{ name: 'inbox', pfad: '/inbox' }, { name: 'inbox', pfad: '/inbox' },
{ name: 'aufgaben', pfad: '/aufgaben' }, { name: 'aufgaben', pfad: '/aufgaben' },
{ name: 'woche', pfad: '/woche' },
{ name: 'projekte', pfad: '/projekte' }, { name: 'projekte', pfad: '/projekte' },
{ name: 'ideen', pfad: '/ideen' }, { name: 'ideen', pfad: '/ideen' },
{ name: 'bereiche', pfad: '/bereiche' }, { name: 'bereiche', pfad: '/bereiche' },
+213
View File
@@ -0,0 +1,213 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { CalendarRange } from 'lucide-react'
import { Wochenraster } from '@/app/(app)/woche/wochenraster'
import { Card, CardInhalt } from '@/components/ui/card'
import { faelligkeitEinordnen, faelligkeitText, istUeberfaellig } from '@/lib/aufgabe'
import {
belegteMinuten,
arbeitsfensterAmTag,
kapazitaetAmTag,
} from '@/lib/kalender/belegung'
import { fensterHuelle, rasterBauen } from '@/lib/woche'
import { alsDatum, gleicherTag, tagesBeginn } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
import { planbareAufgabenLaden, wocheLaden } from '@/server/abfragen/woche'
import { jetzt } from '@/server/zeitgeber'
export const metadata: Metadata = { title: 'Woche' }
const WOCHENTAGE = [
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
'Sonntag',
]
export default async function WocheSeite({
searchParams,
}: {
searchParams: Promise<{ versatz?: string }>
}) {
const benutzer = await verlangeAnmeldung('/woche')
const parameter = await searchParams
// Über den Versatz lässt sich blättern; er steht in der Adresse, damit eine
// bestimmte Woche als Lesezeichen taugt.
const versatz = Number.parseInt(parameter.versatz ?? '0', 10)
const sichereWochen = Number.isFinite(versatz)
? Math.max(-52, Math.min(52, versatz))
: 0
const heute = jetzt()
const zeitpunkt = new Date(heute.getTime() + sichereWochen * 7 * 86_400_000)
const [daten, aufgaben, bereiche, konto] = await Promise.all([
wocheLaden(benutzer.id, zeitpunkt),
planbareAufgabenLaden(),
db.bereich.findMany({
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
}),
// Für den ersten Wurf: der Zeitblock landet im ersten verbundenen
// Kalender. Die Auswahl je Bereich kommt, sobald es mehrere gibt.
db.kalenderkonto.findFirst({
where: { benutzerId: benutzer.id, aktiv: true },
orderBy: { erstelltAm: 'asc' },
select: { id: true, anzeigename: true },
}),
])
const huelle = fensterHuelle(bereiche)
const raster = rasterBauen(zeitpunkt, huelle.von, huelle.bis)
// Kapazität und Belegung je Tag: alle Bereiche zusammengenommen, denn ein
// Termin blockiert die Zeit für alles gleichermaßen.
const spalten = raster.spalten.map((spalte) => {
const tag = spalte.tagesBeginn
const kapazitaet = bereiche.reduce((summe, b) => summe + kapazitaetAmTag(b, tag), 0)
const belegend = [
...daten.termine.map((t) => ({
beginn: t.beginn,
ende: t.ende,
ganztaegig: t.ganztaegig,
zeigtAls: t.zeigtAls,
})),
...daten.zeitbloecke.map((z) => ({
beginn: z.beginn,
ende: z.ende,
ganztaegig: false,
zeigtAls: 'GEBUCHT' as const,
})),
]
// Gegen die Hülle rechnen, nicht gegen ein einzelnes Arbeitsfenster:
// die Tageskapazität ist hier die Summe über alle Bereiche.
const fenster = arbeitsfensterAmTag(tag, huelle.von, huelle.bis)
const belegt = belegteMinuten(belegend, fenster)
return {
schluessel: spalte.schluessel,
tagesBeginnMs: spalte.tagesBeginn.getTime(),
name: WOCHENTAGE[spalte.wochentag] ?? '',
datum: alsDatum(tag).split(', ')[1] ?? '',
heute: gleicherTag(tag, heute),
kapazitaet,
belegt,
ueberbucht: kapazitaet > 0 && belegt > kapazitaet,
}
})
const montag = tagesBeginn(raster.spalten[0]?.tagesBeginn ?? zeitpunkt)
const sonntag = raster.spalten[6]?.tagesBeginn ?? zeitpunkt
return (
/*
Die Wochenansicht braucht mehr Breite als der Inhaltsrahmen von 64rem
hergibt – bei sieben Spalten plus Aufgabenliste wäre sonst der Sonntag
abgeschnitten. Deshalb bricht genau diese Seite aus und nimmt bis zu
100rem, mittig und mit demselben Seitenabstand wie überall.
*/
<div className="relative left-1/2 w-screen max-w-[100rem] -translate-x-1/2 px-4 sm:px-6">
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<CalendarRange className="size-6" aria-hidden />
Woche
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
{alsDatum(montag)} bis {alsDatum(sonntag)}
{konto ? ` · schreibt nach ${konto.anzeigename}` : ''}
</p>
</div>
<nav className="flex items-center gap-2" aria-label="Woche wechseln">
<Link
href={`/woche?versatz=${sichereWochen - 1}`}
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Zurück
</Link>
<Link
href="/woche"
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Diese Woche
</Link>
<Link
href={`/woche?versatz=${sichereWochen + 1}`}
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Weiter
</Link>
</nav>
</header>
{!konto && (
<Card>
<CardInhalt className="py-4 text-sm">
Noch kein Kalender verbunden. Zeitblöcke landen erst dann im Kalender —{' '}
<Link
href="/kalenderkonten"
className="text-betont underline underline-offset-4"
>
jetzt verbinden
</Link>
.
</CardInhalt>
</Card>
)}
<Wochenraster
spalten={spalten}
zeilen={raster.zeilen}
vonMinute={raster.vonMinute}
bisMinute={raster.bisMinute}
kalenderkontoId={konto?.id ?? null}
zeitbloecke={daten.zeitbloecke.map((z) => ({
id: z.id,
beginnMs: z.beginn.getTime(),
endeMs: z.ende.getTime(),
titel: z.titel,
bereichFarbe: z.bereichFarbe,
kalenderName: z.kalenderName,
letzterFehler: z.letzterFehler,
offen: z.offen,
}))}
termine={daten.termine.map((t) => ({
id: t.id,
beginnMs: t.beginn.getTime(),
endeMs: t.ende.getTime(),
betreff: t.betreff,
ganztaegig: t.ganztaegig,
frei: t.zeigtAls === 'FREI',
kalenderName: t.kalenderName,
}))}
aufgaben={aufgaben.map((a) => ({
id: a.id,
titel: a.titel,
aufwandMinuten: a.aufwandMinuten,
bereichName: a.bereich?.name ?? null,
bereichFarbe: a.bereich?.farbe ?? null,
faelligText: a.faelligAm
? faelligkeitText(faelligkeitEinordnen(a.faelligAm, heute))
: null,
ueberfaellig: istUeberfaellig(a.faelligAm, a.status, heute),
bereitsGeplant: a._count.zeitbloecke,
}))}
/>
<p className="text-gedaempft-vordergrund text-xs">
Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein
Zeitblock lässt sich am Griff verschieben; das Kreuz plant ihn wieder aus. Graue
Balken sind Termine aus dem Kalender — sie lassen sich hier nicht verschieben.
</p>
</div>
</div>
)
}
+530
View File
@@ -0,0 +1,530 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import {
DndContext,
DragOverlay,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core'
import { AlertTriangle, CalendarClock, GripVertical, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import {
ZEILE_HOEHE,
balkenlage,
spurenVerteilen,
zeilenBeschriftung,
zeitpunktAus,
type Raster,
} from '@/lib/woche'
import { alsDauer, alsUhrzeitAnzeige } from '@/lib/zeit'
import { cn } from '@/lib/utils'
import {
zeitblockAnlegen,
zeitblockLoesen,
zeitblockVerschieben,
type Rueckmeldung,
} from '@/server/aktionen/zeitbloecke'
export type PlanbareAufgabe = {
id: string
titel: string
aufwandMinuten: number | null
bereichName: string | null
bereichFarbe: string | null
faelligText: string | null
ueberfaellig: boolean
bereitsGeplant: number
}
export type RasterZeitblock = {
id: string
beginnMs: number
endeMs: number
titel: string
bereichFarbe: string | null
kalenderName: string
letzterFehler: string | null
offen: boolean
}
export type RasterTermin = {
id: string
beginnMs: number
endeMs: number
betreff: string | null
ganztaegig: boolean
frei: boolean
kalenderName: string
}
export type RasterSpalte = {
schluessel: string
tagesBeginnMs: number
name: string
datum: string
heute: boolean
kapazitaet: number
belegt: number
ueberbucht: boolean
}
type Eigenschaften = {
spalten: RasterSpalte[]
zeilen: number[]
vonMinute: number
bisMinute: number
zeitbloecke: RasterZeitblock[]
termine: RasterTermin[]
aufgaben: PlanbareAufgabe[]
kalenderkontoId: string | null
}
/** Standarddauer, wenn eine Aufgabe keinen geschätzten Aufwand trägt. */
const STANDARD_MINUTEN = 60
export function Wochenraster({
spalten,
zeilen,
vonMinute,
bisMinute,
zeitbloecke,
termine,
aufgaben,
kalenderkontoId,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [gezogen, setGezogen] = useState<string | null>(null)
const sensoren = useSensors(
// Erst ab acht Pixeln gilt es als Ziehen – sonst löst jeder Klick einen
// Zug aus und Knöpfe im Block wären nicht mehr bedienbar.
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
)
const raster: Raster = {
spalten: spalten.map((s) => ({
tagesBeginn: new Date(s.tagesBeginnMs),
wochentag: 0,
schluessel: s.schluessel,
})),
zeilen,
vonMinute,
bisMinute,
}
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) toast.success(antwort.meldung)
else toast.error(antwort.fehler)
router.refresh()
})
}
function beimStart(ereignis: DragStartEvent) {
setGezogen(String(ereignis.active.id))
}
function beimEnde(ereignis: DragEndEvent) {
setGezogen(null)
const ziel = ereignis.over?.id
if (!ziel) return
const [schluessel, minuteRoh] = String(ziel).split('@')
if (!schluessel || minuteRoh === undefined) return
const beginn = zeitpunktAus(schluessel, Number(minuteRoh))
const kennung = String(ereignis.active.id)
if (kennung.startsWith('aufgabe:')) {
if (!kalenderkontoId) {
toast.error('Erst einen Kalender verbinden, dann lässt sich einplanen.')
return
}
const aufgabeId = kennung.slice('aufgabe:'.length)
const aufgabe = aufgaben.find((a) => a.id === aufgabeId)
const dauer = aufgabe?.aufwandMinuten ?? STANDARD_MINUTEN
const ende = new Date(beginn.getTime() + dauer * 60_000)
const daten = new FormData()
daten.set('aufgabeId', aufgabeId)
daten.set('kalenderkontoId', kalenderkontoId)
daten.set('beginn', beginn.toISOString())
daten.set('ende', ende.toISOString())
mitRueckmeldung(() => zeitblockAnlegen(daten))
return
}
if (kennung.startsWith('block:')) {
const id = kennung.slice('block:'.length)
const block = zeitbloecke.find((z) => z.id === id)
if (!block) return
const dauer = block.endeMs - block.beginnMs
const ende = new Date(beginn.getTime() + dauer)
const daten = new FormData()
daten.set('id', id)
daten.set('beginn', beginn.toISOString())
daten.set('ende', ende.toISOString())
mitRueckmeldung(() => zeitblockVerschieben(daten))
}
}
function ausplanen(id: string) {
const daten = new FormData()
daten.set('id', id)
mitRueckmeldung(() => zeitblockLoesen(daten))
}
const gezogeneAufgabe = gezogen?.startsWith('aufgabe:')
? aufgaben.find((a) => a.id === gezogen.slice('aufgabe:'.length))
: null
const gezogenerBlock = gezogen?.startsWith('block:')
? zeitbloecke.find((z) => z.id === gezogen.slice('block:'.length))
: null
return (
<DndContext sensors={sensoren} onDragStart={beimStart} onDragEnd={beimEnde}>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Vorrat: was noch eingeplant werden kann */}
<aside className="lg:w-72 lg:shrink-0">
<h2 className="mb-2 text-sm font-medium">
Einzuplanen
<span className="text-gedaempft-vordergrund ml-2 font-normal">
{aufgaben.length}
</span>
</h2>
{!kalenderkontoId && (
<p className="bg-warnung/15 text-warnung mb-3 rounded-md px-3 py-2 text-xs">
Ohne verbundenen Kalender lässt sich nichts einplanen.
</p>
)}
<ul className="border-rand bg-karte max-h-[32rem] divide-y divide-[var(--rand)] overflow-y-auto rounded-xl border">
{aufgaben.length === 0 ? (
<li className="text-gedaempft-vordergrund p-4 text-center text-sm">
Nichts offen. Ein guter Tag.
</li>
) : (
aufgaben.map((aufgabe) => (
<AufgabenKachel key={aufgabe.id} aufgabe={aufgabe} gesperrt={laeuft} />
))
)}
</ul>
</aside>
{/* Das Wochenraster */}
<div className="min-w-0 flex-1 overflow-x-auto">
<div className="min-w-[46rem]">
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
<div />
{spalten.map((spalte) => (
<div
key={spalte.schluessel}
className={cn(
'border-rand border-b px-1 pb-2 text-center',
spalte.heute && 'bg-betont/5',
)}
>
<p className="text-sm font-medium">{spalte.name}</p>
<p className="text-gedaempft-vordergrund text-xs">{spalte.datum}</p>
<p
className={cn(
'mt-1 text-xs',
spalte.ueberbucht ? 'text-gefahr' : 'text-gedaempft-vordergrund',
)}
>
{spalte.kapazitaet === 0
? '—'
: `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`}
</p>
</div>
))}
</div>
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
{/* Stundenleiste */}
<div className="relative">
{zeilen.map((minute) => (
<div
key={minute}
style={{ height: ZEILE_HOEHE }}
className="text-gedaempft-vordergrund pr-2 text-right text-[0.6875rem] leading-none"
>
{zeilenBeschriftung(minute)}
</div>
))}
</div>
{spalten.map((spalte) => {
const blockeHier = zeitbloecke.filter(
(z) =>
z.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
z.endeMs > spalte.tagesBeginnMs,
)
const termineHier = termine.filter(
(t) =>
!t.ganztaegig &&
t.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
t.endeMs > spalte.tagesBeginnMs,
)
const verteilteTermine = spurenVerteilen(
termineHier.map((t) => ({
...t,
beginn: new Date(t.beginnMs),
ende: new Date(t.endeMs),
})),
)
return (
<div
key={spalte.schluessel}
className={cn(
'border-rand relative border-l',
spalte.heute && 'bg-betont/5',
)}
>
{zeilen.map((minute) => (
<Zielfeld
key={minute}
kennung={`${spalte.schluessel}@${minute}`}
volleStunde={minute % 60 === 0}
/>
))}
{/* Fremde Termine liegen hinten und lassen sich nicht ziehen */}
{verteilteTermine.map(({ eintrag, spur, spuren }) => {
const lage = balkenlage(
eintrag.beginn,
eintrag.ende,
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
raster,
)
if (lage.ausserhalb) return null
return (
<div
key={eintrag.id}
className={cn(
'pointer-events-none absolute rounded-sm px-1 text-[0.6875rem] leading-tight',
eintrag.frei
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
: 'bg-gedaempft text-gedaempft-vordergrund',
)}
style={{
top: lage.oben,
height: lage.hoehe,
left: `${(spur / spuren) * 100}%`,
width: `${100 / spuren}%`,
}}
title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
>
<span className="line-clamp-2">
{eintrag.betreff ?? 'Termin'}
</span>
</div>
)
})}
{/* Eigene Zeitblöcke liegen vorn und lassen sich ziehen */}
{blockeHier.map((block) => {
const lage = balkenlage(
new Date(block.beginnMs),
new Date(block.endeMs),
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
raster,
)
if (lage.ausserhalb) return null
return (
<Zeitblockbalken
key={block.id}
block={block}
oben={lage.oben}
hoehe={lage.hoehe}
gesperrt={laeuft}
onAusplanen={() => ausplanen(block.id)}
/>
)
})}
</div>
)
})}
</div>
</div>
</div>
</div>
<DragOverlay>
{gezogeneAufgabe && (
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
{gezogeneAufgabe.titel}
<span className="ml-1 opacity-80">
{alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
</span>
</div>
)}
{gezogenerBlock && (
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
{gezogenerBlock.titel}
</div>
)}
</DragOverlay>
</DndContext>
)
}
function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) {
const { setNodeRef, isOver } = useDroppable({ id: kennung })
return (
<div
ref={setNodeRef}
style={{ height: ZEILE_HOEHE }}
className={cn(
'border-rand/40',
volleStunde ? 'border-t' : 'border-t border-dashed',
isOver && 'bg-betont/20',
)}
/>
)
}
function AufgabenKachel({
aufgabe,
gesperrt,
}: {
aufgabe: PlanbareAufgabe
gesperrt: boolean
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `aufgabe:${aufgabe.id}`,
disabled: gesperrt,
})
return (
<li
ref={setNodeRef}
className={cn('flex items-start gap-2 p-2', isDragging && 'opacity-40')}
>
<button
type="button"
className="text-gedaempft-vordergrund mt-0.5 cursor-grab touch-none active:cursor-grabbing"
aria-label={`${aufgabe.titel} zum Einplanen ziehen`}
{...listeners}
{...attributes}
>
<GripVertical className="size-4" aria-hidden />
</button>
<div className="min-w-0 flex-1">
<p className="text-sm leading-tight">{aufgabe.titel}</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
{aufgabe.bereichName && (
<Kennzeichen>
<span
aria-hidden
className="size-2 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: aufgabe.bereichFarbe ?? undefined }}
/>
{aufgabe.bereichName}
</Kennzeichen>
)}
<Kennzeichen>
{alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
</Kennzeichen>
{aufgabe.faelligText && (
<Kennzeichen art={aufgabe.ueberfaellig ? 'gefahr' : 'neutral'}>
{aufgabe.faelligText}
</Kennzeichen>
)}
{aufgabe.bereitsGeplant > 0 && (
<Kennzeichen art="betont">
<CalendarClock className="size-3" aria-hidden />
{aufgabe.bereitsGeplant}
</Kennzeichen>
)}
</div>
</div>
</li>
)
}
function Zeitblockbalken({
block,
oben,
hoehe,
gesperrt,
onAusplanen,
}: {
block: RasterZeitblock
oben: number
hoehe: number
gesperrt: boolean
onAusplanen: () => void
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `block:${block.id}`,
disabled: gesperrt,
})
const farbe = block.bereichFarbe ?? 'var(--betont)'
return (
<div
ref={setNodeRef}
style={{
top: oben,
height: hoehe,
borderLeftColor: farbe,
backgroundColor: `color-mix(in oklab, ${farbe} 18%, var(--karte))`,
}}
className={cn(
'absolute right-0.5 left-0.5 overflow-hidden rounded-sm border-l-4 px-1 text-[0.6875rem] leading-tight',
isDragging && 'opacity-40',
)}
title={`${block.titel} · ${alsUhrzeitAnzeige(new Date(block.beginnMs))}–${alsUhrzeitAnzeige(new Date(block.endeMs))} · ${block.kalenderName}`}
>
<div className="flex items-start gap-0.5">
<button
type="button"
className="shrink-0 cursor-grab touch-none active:cursor-grabbing"
aria-label={`${block.titel} verschieben`}
{...listeners}
{...attributes}
>
<GripVertical className="size-3 opacity-60" aria-hidden />
</button>
<span className="line-clamp-3 flex-1 font-medium">{block.titel}</span>
{(block.offen || block.letzterFehler) && (
<AlertTriangle
className="text-warnung size-3 shrink-0"
aria-label="Noch nicht im Kalender"
/>
)}
<Button
art="leise"
className="size-4 shrink-0 p-0"
disabled={gesperrt}
title="Ausplanen"
onClick={onAusplanen}
>
<X className="size-3" aria-hidden />
<span className="sr-only">Ausplanen</span>
</Button>
</div>
</div>
)
}
+1
View File
@@ -30,6 +30,7 @@ export function Kopfleiste({
{ pfad: '/', name: 'Start' }, { pfad: '/', name: 'Start' },
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox }, { pfad: '/inbox', name: 'Inbox', zaehler: inbox },
{ pfad: '/aufgaben', name: 'Aufgaben' }, { pfad: '/aufgaben', name: 'Aufgaben' },
{ pfad: '/woche', name: 'Woche' },
{ pfad: '/projekte', name: 'Projekte' }, { pfad: '/projekte', name: 'Projekte' },
{ pfad: '/ideen', name: 'Ideen' }, { pfad: '/ideen', name: 'Ideen' },
{ pfad: '/bereiche', name: 'Bereiche' }, { pfad: '/bereiche', name: 'Bereiche' },
+210
View File
@@ -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)
})
})
+194
View File
@@ -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
}
+291
View File
@@ -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,
)
})
})
+177
View File
@@ -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>
+155
View File
@@ -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 } },
},
})
}
+352
View File
@@ -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}` }
}
+31
View File
@@ -11,6 +11,7 @@ import { tagesBeginn } from '@/lib/zeit'
import { db } from '@/server/db' import { db } from '@/server/db'
import { entschluesseln, verschluesseln } from '@/server/krypto' import { entschluesseln, verschluesseln } from '@/server/krypto'
import { tokenErneuern } from '@/server/kalender/oauth' import { tokenErneuern } from '@/server/kalender/oauth'
import { rueckwegAnwenden } from '@/server/kalender/rueckweg'
import { jetzt } from '@/server/zeitgeber' import { jetzt } from '@/server/zeitgeber'
/** /**
@@ -97,6 +98,31 @@ async function gueltigesToken(konto: Konto, zeitpunkt: Date): Promise<string> {
return satz.zugriffstoken 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. */ /** Adresse der ersten Seite einer Runde. */
function ersteAdresse(konto: Konto, fenster: { von: Date; bis: Date }): string { function ersteAdresse(konto: Konto, fenster: { von: Date; bis: Date }): string {
if (konto.deltaVerweis) return konto.deltaVerweis if (konto.deltaVerweis) return konto.deltaVerweis
@@ -302,6 +328,11 @@ export async function kontoAbgleichen(
adresse = naechsteAdresse(konto, adresse, seite.weiter) 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({ await db.kalenderkonto.update({
where: { id: konto.id }, where: { id: konto.id },
data: { data: {
+93
View File
@@ -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 }
}
+225
View File
@@ -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
}
}