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

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

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

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

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

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

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

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

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

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

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

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

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

Protokolliert werden nur Zahlen, nie Kontonamen oder Termininhalte.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:47:43 +02:00
Florian WildeandClaude Opus 5 04414af00d chore(release): 0.2.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:47:33 +02:00
Florian WildeandClaude Opus 5 7d1f91e740 docs: DEVLOG-Eintrag für M2
Ziel, Erledigtes, die Entwurfsentscheidungen mit Begründung, die
Probleme samt Lösung und die offenen Punkte. Dazu die eigenen Fehler
dieser Sitzung.

Dauer laut zeiten.csv: 44 Minuten. Gesamt bislang 205 Minuten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:46:14 +02:00
Florian WildeandClaude Opus 5 881fdf0819 test: Selektor der Anmeldung präzisieren
Die Startseite hat seit M2 zusätzlich einen Link "Bereiche verwalten";
der bisherige Selektor traf damit zwei Elemente. Kein Produktfehler,
aber der Test muss eindeutig sein.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:46:13 +02:00
Florian WildeandClaude Opus 5 36370f8ff8 docs: Handbuchbilder für M2 neu aufnehmen
36 Bilder aus der Wegwerf-Instanz: neun Seiten, je hell und dunkel,
Schreibtisch und Handy. Ausschließlich erfundene Daten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:46:13 +02:00
Florian WildeandClaude Opus 5 d01c49b5a0 docs: Handbuchseiten zu Aufgaben, Ideen und Projekten
Jede Seite erklärt nicht nur, wo man klickt, sondern warum es so gebaut
ist: warum die Schnellerfassung nur ein Feld hat, warum die Inbox kein
eigener Zustand ist, warum Ideen getrennt liegen und warum Verworfenes
nicht in den Fortschritt zählt.

README und Schnellstart auf den Stand von M2 gebracht; der Schnellstart
endet jetzt mit der ersten erfassten Aufgabe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:33:34 +02:00
Florian WildeandClaude Opus 5 4d0f83ff51 chore: Demo-Daten für die neuen Seiten erweitern
Erfundene Aufgaben mit einer Mischung aus überfällig, heute, später und
ohne Termin, dazu Inbox, Ideen und ein Projekt mit Arbeitspaketen und
einem Meilenstein. Die Fälligkeiten stehen als Versatz zum festen
Demo-Zeitpunkt, damit die Bilder zwischen zwei Läufen gleich bleiben.

Eine Aufgabe trägt eine erfundene Zammad-Herkunft, damit die Darstellung
der Quelle in den Bildern zu sehen ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:33:34 +02:00
Florian WildeandClaude Opus 5 144457b9f0 fix(aufgaben): Ideen aus Aufgabenliste und Inbox heraushalten
Der Fehler fiel erst in den aufgenommenen Bildern auf: Ideen standen
mitten in der Aufgabenliste, obwohl der ganze Sinn der getrennten
Ideenseite ist, dass sie das nicht tun. Ohne Termin hätten sie dort
entweder unsichtbar unten gestanden oder mit erfundenem Termin oben.

Der Filter kennt jetzt ohneIdeen; ein ausdrücklich gewählter Typ gewinnt,
wer nach Ideen filtert, sieht sie also weiterhin.

Die Startseite filterte Ideen bisher von Hand nach dem Laden — das
erledigt jetzt dieselbe Abfrage wie überall sonst.

Ein E2E-Test hält den Fall fest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:33:33 +02:00
Florian WildeandClaude Opus 5 7c8fe10f35 test: Erfassungsweg und Eindeutigkeit der Quellen absichern
E2E über den Weg, der in M2 zählt: schnell erfassen, einsortieren,
abhaken — und nachsehen, dass Erledigtes aus der Standardansicht fällt,
aber mit Filter wieder auftaucht.

Der Integrationstest deckt jetzt auch ab, was die Integrationen ab M6
brauchen: dieselbe Quelle mit derselben Kennung nur einmal, dieselbe
Kennung bei anderer Quelle erlaubt, mehrere Aufgaben ohne Quellverweis
nebeneinander, und ein Upsert aktualisiert statt zu doppeln.

Dazu der Nachweis, dass Aufgaben das Löschen ihres Projekts und ihres
Bereichs überleben und dabei nur die Zuordnung verlieren.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:17:45 +02:00
Florian WildeandClaude Opus 5 a4a889fcb8 feat(aufgaben): Inbox, Aufgabenliste, Ideen und Projekte
Die Schnellerfassung braucht ein Feld und einen Knopf; wer beim Erfassen
erst ein Formular ausfüllen muss, erfasst nichts. Der Fokus bleibt danach
im Feld, damit mehrere Sachen hintereinander hineingehen.

Die Filter der Aufgabenliste stehen in der Adresse, nicht im Zustand der
Komponente — so lässt sich eine gefilterte Liste als Lesezeichen ablegen,
und der Zurück-Knopf tut, was er soll.

Die Startseite zeigt jetzt, was überfällig, heute oder morgen fällig ist,
statt nur die Bereiche aufzuzählen.

Für die Auswahlfelder bewusst das native <select>: es bedient sich auf
dem Handy mit dem Systemdialog und ist ohne Zusatzcode tastaturbedienbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:17:44 +02:00
Florian WildeandClaude Opus 5 b3ca1c9ce7 feat(aufgaben): Server Actions und Abfrageschicht
Anlegen, ändern, Status setzen, einsortieren, aus einer Idee eine Aufgabe
machen und löschen — jede Aktion prüft die Rolle selbst und hinterlässt
einen Audit-Eintrag.

Statuswechsel gehen nur die Wege, die uebergangErlaubt() zulässt:
Abgeschlossenes wird erst wieder geöffnet, bevor es weitergeht. Wer per
Formular etwas anderes einreicht, bekommt eine Meldung statt eines stillen
Zustandssprungs.

Sortiert wird in der Anwendung, nicht in SQL. Die Regel ist mehrstufig
und steht als geprüfte reine Funktion in src/lib — zwei Fassungen
derselben Regel wären eine Fehlerquelle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:17:43 +02:00
Florian WildeandClaude Opus 5 63c3c15a3c feat(aufgaben): Datenmodell und Regeln für Aufgaben und Projekte
Aufgabe mit Typ, Status, Priorität, Aufwand, Fälligkeit und Herkunft;
Projekt als eigene Klammer darum.

Zwei Entwurfsentscheidungen:
- Die Inbox ist kein eigener Zustand, sondern schlicht eine Aufgabe ohne
  Bereich. Einsortieren heißt: einen Bereich setzen. Ein zusätzliches
  Feld könnte mit der Zuordnung auseinanderlaufen.
- Projekt ist ein eigenes Modell, kein Aufgabentyp. Ein Projekt hat
  selbst weder Aufwand noch Fälligkeit, sondern leitet beides aus dem
  ab, was drinsteckt. ARBEITSPAKET und MEILENSTEIN sind Typen der
  Aufgaben *innerhalb* eines Projekts.

@@unique([quelle, quelleRef]) ist die Grundlage für die Integrationen ab
M6: sie upserten darüber, statt zu doppeln. Von Hand erfasste Aufgaben
tragen quelleRef = null; PostgreSQL sieht NULL-Werte als verschieden an,
sie behindern sich also nicht.

Verworfene Aufgaben zählen bewusst gar nicht in den Fortschritt eines
Projekts — sonst sähe es fertig aus, weil die Hälfte gestrichen wurde.

Fälligkeit wird in Kalendertagen in Europe/Berlin gerechnet, nicht in
24-Stunden-Schritten: was heute um 23:00 fällig ist, ist heute fällig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:17:41 +02:00
Florian WildeandClaude Opus 5 704c490368 Merge: ROADMAP-Status für M1 nachziehen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 15:54:31 +02:00
Florian WildeandClaude Opus 5 01ab74f71d docs: M1 in der ROADMAP als erledigt führen
Der Status stand noch auf "in Arbeit", obwohl 0.1.0 getaggt und nach main
gemergt ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 15:54:31 +02:00
Florian WildeandClaude Opus 5 56e07a40e8 Merge: Meilenstein 1 – Fundament (0.1.0)
Anmeldung über OIDC, Passkeys und Passwort, Rollen mit serverseitiger
Prüfung in Seite und Server Action, Audit-Protokoll, Instanzsteuerung,
Bereiche, Betrieb über Docker Compose sowie die vollständige Test- und
Dokumentationskette.

Geprüft vor dem Merge: npm run pruefen, 19 Integrationsprüfungen,
Migrationstest und 9 E2E-Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 15:52:14 +02:00
117 changed files with 8201 additions and 133 deletions
+15
View File
@@ -59,6 +59,21 @@ WEBAUTHN_RP_NAME=Planer
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port. # Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
WEBAUTHN_ORIGIN=http://localhost:3000 WEBAUTHN_ORIGIN=http://localhost:3000
# --- Kalenderanbieter ------------------------------------------------------
# Graph gibt es in beiden Instanzen, Google nur daheim. Ein Anbieter erscheint
# erst, wenn Kennung UND Geheimnis gesetzt sind.
# Einrichtung siehe README, Abschnitt "Kalenderanbieter einrichten".
# Rueckleit-URIs:
# <APP_URL>/api/kalender/rueckleitung/graph
# <APP_URL>/api/kalender/rueckleitung/google
GRAPH_CLIENT_ID=
GRAPH_CLIENT_SECRET=
# Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant.
GRAPH_TENANT_ID=organizations
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# --- Sonstiges ------------------------------------------------------------- # --- Sonstiges -------------------------------------------------------------
# Zeitzone fuer Anzeigen und Tagesgrenzen. # Zeitzone fuer Anzeigen und Tagesgrenzen.
TZ=Europe/Berlin TZ=Europe/Berlin
+60
View File
@@ -6,6 +6,66 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.3.0] – 2026-09-10
### Hinzugefügt
- Kalenderkonten für Microsoft 365 (Graph) und Google verbinden, umbenennen,
von Hand abgleichen und trennen. Google gibt es nur in der Instanz
`daheim`; in `firma` liefert die Route 404.
- Termine werden örtlich gespiegelt: 7 Tage zurück bis 90 Tage voraus.
Gelesen werden nur Beginn, Ende, Betreff, ganztägig und "zeigt an als" —
keine Teilnehmer, Beschreibungen, Anhänge oder Orte.
- Die Startseite zeigt je Bereich, wie voll der heutige Tag ist: Termine aus
allen verbundenen Kalendern gegen die Tageskapazität.
- Hintergrundjobs: alle 15 Minuten abgleichen, nachts alte Termine und
abgelaufene WebAuthn-Herausforderungen entfernen.
- Handbuchseite zu den Kalenderkonten, Einrichtungsanleitung für Entra ID
und die Google Cloud Console in der README.
### Sicherheit
- Zugangs- und Aktualisierungstoken liegen verschlüsselt in der Datenbank
(AES-256-GCM, Schlüssel aus AUTH_SECRET abgeleitet). Wer eine Sicherung in
die Hände bekommt, kann damit ohne AUTH_SECRET nichts anfangen.
- Der Zustandswert im OAuth-Ablauf ist signiert und trägt die
Benutzerkennung; ohne das ließe sich ein fremdes Kalenderkonto an ein
beliebiges Konto hängen. Er verfällt nach 30 Minuten.
- Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der
enthält das Client-Secret oft im Echo.
- Protokolliert werden beim Abgleich nur Zahlen, nie Kontonamen oder
Termininhalte.
## [0.2.0] – 2026-09-10
### Hinzugefügt
- Aufgaben mit Typ, Status, Priorität, Aufwand, Fälligkeit und Herkunft;
Projekte als Klammer darum.
- Schnellerfassung mit einem einzigen Feld auf der Startseite und in der Inbox.
- Inbox: alles ohne Bereich sammelt sich dort und wird später einsortiert.
Die Zahl im Menü zeigt, wie viel noch liegt.
- Aufgabenliste mit Filtern nach Suche, Bereich, Status, Art und Projekt.
Die Filter stehen in der Adresse und lassen sich als Lesezeichen ablegen.
- Ideen als eigene Ansicht, ohne Termin und ohne Aufwand; per Klick wird
daraus eine Aufgabe.
- Projekte mit Arbeitspaketen, Meilensteinen und Fortschritt.
- Die Startseite zeigt, was überfällig, heute oder morgen fällig ist.
- Handbuchseiten zu Aufgaben, Ideen und Projekten.
### Geändert
- Die Startseite zählt nicht mehr nur Bereiche auf, sondern stellt voran,
was jetzt dran ist.
### Sicherheit
- Statuswechsel folgen festen Übergängen: Abgeschlossenes wird erst wieder
geöffnet. Ein abweichender Wert im Formular führt zu einer Meldung, nicht
zu einem stillen Zustandssprung.
- Angegebene Bereiche und Projekte werden vor dem Speichern geprüft; ein
erfundener Verweis gibt eine Meldung statt eines Datenbankfehlers.
## [0.1.0] – 2026-09-10 ## [0.1.0] – 2026-09-10
### Hinzugefügt ### Hinzugefügt
+87 -8
View File
@@ -7,10 +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 1 von 8. Die Grundlage steht: Anmeldung, Rollen, > **Stand:** Meilenstein 3 von 8. Aufgaben, Projekte und das Lesen der
> Protokoll, Bereiche und der Betrieb. Aufgaben kommen mit M2, das Lesen der > Kalender stehen: der Planer weiß jetzt, wann überhaupt Platz ist. Das
> Kalender mit M3, die Wochenplanung mit M4. Der [Fahrplan](ROADMAP.md) sagt, > Schreiben der Zeitblöcke per Ziehen und Ablegen kommt mit M4. Der
> 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`
zur ersten Anmeldung zur ersten Anmeldung
@@ -22,6 +22,14 @@ wird.
### Heute ### Heute
- **Schnellerfassung** mit einem einzigen Feld; alles ohne Bereich sammelt
sich in der **Inbox** und wird später einsortiert
- **Aufgaben** mit Typ, Status, Priorität, Aufwand und Fälligkeit, sortiert
nach Dringlichkeit und filterbar über die Adresszeile
- **Ideen** getrennt von Aufgaben — ohne Termin, per Klick übernehmbar
- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt
- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je
Bereich, wie voll der heutige Tag schon ist
- 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
@@ -32,16 +40,21 @@ wird.
### Geplant ### Geplant
Aufgaben und Projekte, Kalender lesen und schreiben, Wochenplanung per Ziehen Zeitblöcke in den Kalender schreiben, Wochenplanung per Ziehen und Ablegen,
und Ablegen, Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
## Bildergalerie ## Bildergalerie
| | Hell | Dunkel | | | Hell | Dunkel |
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- | | ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| 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) |
| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) |
| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) |
| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) |
| Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-dunkel-schreibtisch.png) |
| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) | | Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) |
| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) | | Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) |
| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) | | Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) |
@@ -107,6 +120,19 @@ Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example).
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`. Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
### Kalenderanbieter
| Variable | Standard | Bedeutung |
| ---------------------- | --------------- | ------------------------------------------------------------- |
| `GRAPH_CLIENT_ID` | _(leer)_ | Anwendungs-ID der App-Registrierung in Entra ID |
| `GRAPH_CLIENT_SECRET` | _(leer)_ | Geheimnis dieser Registrierung |
| `GRAPH_TENANT_ID` | `organizations` | Tenant-ID, oder `organizations` für jeden Arbeitsplatz-Tenant |
| `GOOGLE_CLIENT_ID` | _(leer)_ | OAuth-Client-ID aus der Google Cloud Console |
| `GOOGLE_CLIENT_SECRET` | _(leer)_ | Geheimnis dieses Clients |
Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`).
### Einrichtung und Sonstiges ### Einrichtung und Sonstiges
| Variable | Standard | Bedeutung | | Variable | Standard | Bedeutung |
@@ -116,6 +142,59 @@ Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen | | `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` | | `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
## Kalenderanbieter einrichten
Beides ist einmalige Handarbeit beim Anbieter. Der Planer fragt Lese- **und**
Schreibrechte an: ab M4 schreibt er Zeitblöcke zurück, und eine zweite
Zustimmungsrunde für jedes Konto wäre lästiger als eine etwas breitere
Berechtigung von Anfang an.
### Microsoft 365 (Entra ID)
1. <https://entra.microsoft.com> → **Identität → Anwendungen →
App-Registrierungen → Neue Registrierung**.
2. Name frei wählen, etwa „Planer".
3. Unterstützte Kontotypen: **Nur Konten in diesem Organisationsverzeichnis**
(dann `GRAPH_TENANT_ID` auf die Tenant-ID setzen) oder
**Konten in einem beliebigen Organisationsverzeichnis** (dann
`organizations` belassen).
4. Umleitungs-URI, Plattform **Web**:
`<APP_URL>/api/kalender/rueckleitung/graph`
5. Nach dem Anlegen: **Anwendungs-ID (Client)** nach `GRAPH_CLIENT_ID`,
**Verzeichnis-ID (Mandant)** nach `GRAPH_TENANT_ID`.
6. **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Den Wert
sofort nach `GRAPH_CLIENT_SECRET` kopieren — er wird nur einmal angezeigt.
7. **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph →
Delegierte Berechtigungen**: `Calendars.ReadWrite`, `offline_access`,
`openid`, `email`.
> Ist im Tenant die Zustimmung durch Endbenutzer abgeschaltet, muss ein
> Administrator einmalig **Administratorzustimmung erteilen** klicken.
### Google Kalender
1. <https://console.cloud.google.com> → Projekt anlegen oder wählen.
2. **APIs & Dienste → Bibliothek** → _Google Calendar API_ aktivieren.
3. **OAuth-Zustimmungsbildschirm** einrichten. Für den Eigengebrauch genügt
_Extern_ mit Veröffentlichungsstatus _Test_ und der eigenen Adresse als
Testbenutzer.
4. Bereiche (Scopes) hinzufügen:
`.../auth/calendar.events` und `.../auth/calendar.readonly`.
5. **Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID**,
Anwendungstyp **Webanwendung**.
6. Autorisierter Weiterleitungs-URI:
`<APP_URL>/api/kalender/rueckleitung/google`
7. Client-ID und Clientschlüssel nach `GOOGLE_CLIENT_ID` und
`GOOGLE_CLIENT_SECRET`.
> Bleibt das Projekt im Status _Test_, laufen die Aktualisierungstoken nach
> sieben Tagen ab und die Konten müssen neu verbunden werden. Für den
> Dauerbetrieb den Zustimmungsbildschirm **veröffentlichen**.
Nach dem Eintragen: `docker compose up -d` und im Planer unter **Kalender**
verbinden. Die Anleitung für den Alltag steht im
[Handbuch](docs/handbuch/kalenderkonten.md).
## Betrieb ## Betrieb
### Starten und stoppen ### Starten und stoppen
+27 -17
View File
@@ -4,10 +4,10 @@ Acht Meilensteine. Jeder endet mit einer lauffähigen Version auf `main`,
grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG. grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| Meilenstein | Ziel | Status | | Meilenstein | Ziel | Status |
| --------------------------------------- | ---------------------------- | --------- | | --------------------------------------- | ---------------------------- | -------- |
| [M1](#m1--fundament) | Fundament | in Arbeit | | [M1](#m1--fundament) | Fundament | erledigt |
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | geplant | | [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
| [M3](#m3--kalender-lesen) | Kalender lesen | geplant | | [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 | geplant |
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant | | [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
| [M6](#m6--integrationen) | Integrationen | geplant | | [M6](#m6--integrationen) | Integrationen | geplant |
@@ -18,6 +18,8 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
## M1 – Fundament ## M1 – Fundament
**Erledigt am 10.09.2026 mit Version 0.1.0.**
**Ziel:** Ein angemeldeter, betreibbarer Rumpf. Noch keine Aufgaben, kein **Ziel:** Ein angemeldeter, betreibbarer Rumpf. Noch keine Aufgaben, kein
Kalender — aber alles, worauf die späteren Meilensteine aufsetzen. Kalender — aber alles, worauf die späteren Meilensteine aufsetzen.
@@ -43,26 +45,34 @@ wirklich braucht, statt heute geraten.
## M2 – Aufgaben und Projekte ## M2 – Aufgaben und Projekte
**Erledigt am 10.09.2026 mit Version 0.2.0.**
**Ziel:** Alles erfassen und wiederfinden, noch ohne Kalender. **Ziel:** Alles erfassen und wiederfinden, noch ohne Kalender.
- [ ] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle - [x] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle
- [ ] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten - [x] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten
- [ ] Inbox: schnell erfassen, später einsortieren - [x] Inbox: schnell erfassen, später einsortieren
- [ ] Ideen getrennt von Aufgaben - [x] Ideen getrennt von Aufgaben
- [ ] Projekte mit Arbeitspaketen und Meilensteinen - [x] Projekte mit Arbeitspaketen und Meilensteinen
- [ ] Listen mit Filtern nach Bereich, Status und Fälligkeit - [x] Listen mit Filtern nach Bereich, Status und Fälligkeit
- [ ] Handbuchseiten und Bilder - [x] Handbuchseiten und Bilder
**Bewusst offen gelassen:** Kein Wiederholungsrhythmus für Aufgaben und keine
Verknüpfung von Aufgaben untereinander — beides steht unter „Ideen für
später" und wartet darauf, ob es im Alltag wirklich fehlt.
## M3 – Kalender lesen ## M3 – Kalender lesen
**Erledigt am 10.09.2026 mit Version 0.3.0.**
**Ziel:** Termine aus Microsoft 365 und Google sehen. **Ziel:** Termine aus Microsoft 365 und Google sehen.
- [ ] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis - [x] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
- [ ] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim - [x] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
- [ ] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft - [x] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
- [ ] Ganztägige Termine zählen nicht zur Belegung - [x] Ganztägige Termine zählen nicht zur Belegung
- [ ] Anonymisierte Beispielantworten in `test/fixtures/` - [x] Anonymisierte Beispielantworten in `test/fixtures/`
- [ ] Hintergrundjob mit node-cron - [x] Hintergrundjob mit node-cron
## M4 – Wochenplanung und Zeitblöcke ## M4 – Wochenplanung und Zeitblöcke
+183 -1
View File
@@ -8,7 +8,189 @@ die Übersicht aus `scripts/zeit.sh summe`.
| Meilenstein | Art | Minuten | Stunden | | Meilenstein | Art | Minuten | Stunden |
| ----------- | ------ | ------- | -------- | | ----------- | ------ | ------- | -------- |
| M1 | claude | 161 | 2,68 | | M1 | claude | 161 | 2,68 |
| **Gesamt** | | **161** | **2,68** | | M2 | claude | 44 | 0,73 |
| M3 | claude | 41 | 0,68 |
| **Gesamt** | | **246** | **4,10** |
---
## 2026-09-10 – M3: Kalender lesen
**Dauer:** 41 Minuten (17:48–18:29), Art `claude`.
**Ziel der Sitzung:** Termine aus Microsoft 365 und Google lesen, damit der
Planer weiß, wann überhaupt Platz ist.
### Erledigt
- `Kalenderkonto` und `Termin` samt Migration; Bereich bekommt sein
Standard-Kalenderkonto für M4
- Tokenverschlüsselung mit AES-256-GCM
- Antworten beider Anbieter mit zod geprüft und in eine gemeinsame Form
gebracht, geprüft gegen anonymisierte Beispielantworten
- Belegungsrechnung: Kapazität gegen Termine, freie Fenster für M4
- OAuth-Verbinden, Tokenerneuerung, seitenweiser Abgleich mit Rückfall auf
einen vollständigen Durchlauf
- Seite für die Kalenderkonten, Belegung des Tages auf der Startseite
- Hintergrundjobs für Abgleich und nächtliches Aufräumen
- 68 neue Unit-Tests, 10 neue Integrationsprüfungen
- Handbuchseite und Einrichtungsanleitung für Entra ID und Google
### Entscheidungen mit Begründung
**Tokens verschlüsselt, Schlüssel aus AUTH_SECRET abgeleitet.** Wer die
Datenbank oder eine Sicherung bekommt, hätte sonst direkten Zugriff auf
fremde Kalender — das wiegt schwerer als bei den OIDC-Tokens aus M1, die
nur die Anmeldung betreffen. Eine eigene Pflichtvariable wäre eine weitere
Stelle, an der die Einrichtung scheitert; die Ableitung über HKDF kostet
nichts und hat die richtige Nebenwirkung: wer AUTH_SECRET wechselt, muss
die Konten neu verbinden — dann sind ohnehin alle Sitzungen ungültig.
**Bei fremder Zeitzone abbrechen statt raten.** Graph liefert Datum und
Zeitzone getrennt, ohne Versatz im Text. Wir fragen ausdrücklich UTC an;
kommt etwas anderes zurück, wäre jede Umrechnung geraten und würde Termine
um Stunden verschieben. Ein Abbruch mit klarer Meldung ist besser als ein
Kalender, der leise falsch liegt.
**Unbekanntes showAs zählt als belegt.** Lieber eine Stunde zu viel
blockiert als eine Doppelbuchung.
**Schreibrechte schon jetzt anfragen.** M3 liest nur, aber M4 schreibt.
Später erneut zuzustimmen ist genau der Schritt, an dem Einrichtung
scheitert — einmal breiter fragen ist das kleinere Übel.
**Ganztägige Termine zählen nicht zur Belegung.** Ein Urlaubstag oder
Geburtstag blockiert sonst den ganzen Tag, obwohl er keine Arbeitszeit
kostet.
**Alle Kalender zählen gegen jeden Bereich.** Ein Termin blockiert die Zeit
ohnehin, gleich zu welchem Lebensbereich er gehört.
**Kein SDK für Graph und Google.** Es geht um drei Aufrufe je Anbieter, und
die Antworten werden ohnehin mit zod geprüft. Ein SDK brächte vor allem
Abhängigkeiten und blinde Flecken.
### Probleme und Lösungen
| Problem | Lösung |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Prisma lässt in einem zusammengesetzten Unique-Schlüssel kein null zu | `kalenderId` ist nicht optional, sondern trägt den sprechenden Wert `"standard"` für den Standardkalender. |
| Googles `syncToken` verträgt sich nicht mit `timeMin`/`timeMax` | Folgeabgleiche liefern auch Änderungen außerhalb des Fensters. Was daneben liegt, wird beim Spiegeln verworfen — sonst wüchse der Spiegel unbegrenzt. |
| Der Verweis für die nächste Runde kann ablaufen (HTTP 410) | Verweis verwerfen, Spiegel leeren, von vorn lesen. Der Ablauf ist so gebaut, dass ein vollständiger Durchlauf jederzeit möglich ist. |
| Die Shell zerlegte in mehreren Anläufen Template-Literale und Backticks | Auf `node - <<'SKRIPT'` umgestellt; damit geht der Text unverändert durch. |
### Offene Punkte
- **Nicht gegen die echten APIs getestet.** Ich habe keine Zugangsdaten für
Entra ID oder Google. Provider und Abgleich sind gegen anonymisierte
Beispielantworten geprüft; den ersten echten Verbindungsversuch muss
Florian machen. Die Anleitung dafür steht in der README.
- Nur der Standardkalender je Konto. Mehrere Kalender desselben Kontos sind
im Datenmodell vorgesehen, es fehlt die Auswahl in der Oberfläche.
- Der Abgleich läuft in einer Schleife über alle Konten. Bei vielen Konten
wäre das nacheinander zu langsam; bei der erwarteten Zahl ist es das
einfachere Verhalten.
### Commits
- 79e6c0d feat(kalender): Datenmodell für Kalenderkonten und Termine
- 6fda2a5 feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen
- 9946e7c feat(kalender): OAuth und Abgleich für Graph und Google
- 0c67b73 feat(kalender): Seite für die Kalenderkonten
- 42c98c8 feat(kalender): Hintergrundjobs für Abgleich und Aufräumen
- d8d3227 feat(kalender): Belegung des Tages auf der Startseite
- df50627 test: Verschlüsselung und Kalendermodelle absichern
- a8b2ff0 docs: Handbuchseite und Einrichtung der Kalenderanbieter
---
## 2026-09-10 – M2: Aufgaben und Projekte
**Dauer:** 44 Minuten (15:57–16:41), Art `claude`.
**Ziel der Sitzung:** Erfassen und Wiederfinden — Aufgaben, Inbox, Ideen und
Projekte. Noch ohne Kalender.
### Erledigt
- Datenmodell `Aufgabe` und `Projekt` samt Migration
- Schnellerfassung, Inbox mit Einsortieren, Aufgabenliste mit Filtern
- Ideen als eigene Ansicht, mit Übernahme in eine Aufgabe
- Projekte mit Arbeitspaketen, Meilensteinen und Fortschritt
- Startseite zeigt jetzt, was überfällig oder demnächst fällig ist
- 53 neue Unit-Tests, 11 neue Integrationsprüfungen, 4 neue E2E-Tests
- Handbuchseiten zu Aufgaben, Ideen und Projekten; Bilder neu aufgenommen
### Entscheidungen mit Begründung
**Die Inbox ist kein eigener Zustand.** Eine Aufgabe liegt genau dann in der
Inbox, wenn sie keinen Bereich hat. Ein zusätzliches Feld „einsortiert"
hätte mit der Zuordnung auseinanderlaufen können; so ist der Zustand nicht
darstellbar.
**Projekt als eigenes Modell, nicht als Aufgabentyp.** Ein Projekt hat
selbst weder Aufwand noch Fälligkeit — beides ergibt sich aus dem Inhalt.
`ARBEITSPAKET` und `MEILENSTEIN` bleiben Typen der Aufgaben _innerhalb_
eines Projekts.
**Verworfene Aufgaben zählen gar nicht in den Fortschritt.** Weder als
erledigt noch als offen. Sonst sähe ein Projekt fertig aus, weil die Hälfte
gestrichen wurde — eine Zahl, die lügt, ist schlechter als keine.
**Sortiert wird in der Anwendung, nicht in SQL.** Die Reihenfolge ist
mehrstufig (offen vor abgeschlossen, dann Termin, dann Priorität, dann Titel
mit deutscher Sortierung). Als geprüfte reine Funktion steht sie an einer
Stelle; eine zweite Fassung in SQL wäre eine Fehlerquelle. Bei den erwarteten
Mengen ist das unkritisch — sollte es einmal eng werden, ist der Ort für die
Änderung klar.
**Filter stehen in der Adresse.** Eine gefilterte Liste lässt sich als
Lesezeichen ablegen, und der Zurück-Knopf tut, was er soll.
**Statuswechsel folgen festen Übergängen.** Aus `ERLEDIGT` und `VERWORFEN`
geht es nur zurück nach `OFFEN`. Wer per Formular etwas anderes einreicht,
bekommt eine Meldung — nicht einen stillen Zustandssprung.
**Fälligkeit in Kalendertagen, nicht in Stunden.** Was heute um 23:00 fällig
ist, ist heute fällig. Alles andere fühlt sich beim Benutzen falsch an.
**Natives `<select>` statt einer eigenen Auswahlkomponente.** Es bedient
sich auf dem Handy mit dem Systemdialog, ist ohne Zusatzcode
tastaturbedienbar und braucht kein JavaScript zum Absenden.
### Probleme und Lösungen
| Problem | Lösung |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ESLint beanstandete `String(antwort.meldung)` | Ich hatte die Rückmeldung als `Record<string, unknown>` typisiert, statt den vorhandenen `Rueckmeldung`-Typ zu nehmen. Mit dem richtigen Typ entfällt die Umwandlung ganz. |
| Zwei Stellen rechneten die Tagesgrenze mit einem `toLocaleString`-Trick | `src/lib/zeit.ts` konnte das längst sauber. Beide Stellen nutzen jetzt `tagesBeginn()` beziehungsweise `alsTagesschluessel()`; die Tests belegen gleiche Ergebnisse bei weniger Code. |
| Typfehler nach der Migration | Der Prisma-Client muss nach jeder Schemaänderung neu erzeugt werden. `npm run pruefen` tut das inzwischen selbst, wenn er fehlt — hier war er nur veraltet. |
### Eigene Fehler, die ich korrigiert habe
- Im Demo-Seed stand versehentlich ein kyrillisches Wort als Titel eines
Meilensteins. Ersetzt durch „Seite ist online".
- Ein Test erwartete, dass 481 Minuten Aufwand erlaubt sind; die Grenze
liegt bei 480. Der Test war falsch, nicht der Code — jetzt prüft er die
Grenze selbst und den ersten Wert darüber.
### Offene Punkte
- Kein Wiederholungsrhythmus für Aufgaben und keine Verknüpfung von Aufgaben
untereinander. Beides steht bewusst unter „Ideen für später" und wartet
darauf, ob es im Alltag wirklich fehlt.
- Die Aufgabenliste lädt bis zu 500 Einträge auf einmal. Für den erwarteten
Bestand reicht das; eine Blätterfunktion kommt, wenn es eng wird.
### Commits
- 63c3c15 feat(aufgaben): Datenmodell und Regeln für Aufgaben und Projekte
- b3ca1c9 feat(aufgaben): Server Actions und Abfrageschicht
- a4a889f feat(aufgaben): Inbox, Aufgabenliste, Ideen und Projekte
- 7c8fe10 test: Erfassungsweg und Eindeutigkeit der Quellen absichern
- 144457b fix(aufgaben): Ideen aus Aufgabenliste und Inbox heraushalten
- 4d0f83f chore: Demo-Daten für die neuen Seiten erweitern
- d01c49b docs: Handbuchseiten zu Aufgaben, Ideen und Projekten
--- ---
+2
View File
@@ -1,2 +1,4 @@
datum;start;ende;minuten;meilenstein;art;notiz 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;17:48;18:29;41;M3;claude;M3 Kalender lesen 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
+14 -2
View File
@@ -133,14 +133,26 @@ E-Mail-Adresse und **Mit Passkey anmelden**.
> Hinterlege am besten zwei Passkeys auf verschiedenen Geräten. Sonst sperrst > Hinterlege am besten zwei Passkeys auf verschiedenen Geräten. Sonst sperrst
> du dich aus, wenn ein Gerät verlorengeht. > du dich aus, wenn ein Gerät verlorengeht.
## 8. Aufräumen ## 8. Die erste Aufgabe erfassen
Auf der Startseite steht oben ein Feld **Was ist zu tun?**. Titel eintippen,
Enter.
**Erwartet:** Eine kurze Bestätigung, und der Zähler neben **Inbox** im Menü
springt auf 1. Dort kannst du die Aufgabe einem Bereich zuordnen.
Mehr dazu: [Aufgaben erfassen und wiederfinden](handbuch/aufgaben.md).
## 9. Aufräumen
Wenn `ADMIN_PASSWORT` nur zur Einrichtung gedient hat, kannst du es jetzt Wenn `ADMIN_PASSWORT` nur zur Einrichtung gedient hat, kannst du es jetzt
zusammen mit `ADMIN_EMAIL` aus `.env` entfernen. Das Konto bleibt bestehen. zusammen mit `ADMIN_EMAIL` aus `.env` entfernen. Das Konto bleibt bestehen.
## Wie es weitergeht ## Wie es weitergeht
- [Handbuch](handbuch/) – Anleitung für den Alltag - [Handbuch](handbuch/) – Anleitung für den Alltag, insbesondere
[Aufgaben](handbuch/aufgaben.md), [Ideen](handbuch/ideen.md) und
[Projekte](handbuch/projekte.md)
- [README](../README.md) – alle Umgebungsvariablen, Betrieb, Sicherung - [README](../README.md) – alle Umgebungsvariablen, Betrieb, Sicherung
- [ROADMAP](../ROADMAP.md) – was in den nächsten Meilensteinen kommt - [ROADMAP](../ROADMAP.md) – was in den nächsten Meilensteinen kommt
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.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 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: 228 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 238 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: 113 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: 116 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 240 KiB

+11 -4
View File
@@ -9,9 +9,19 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
2. **[Bereiche einrichten](bereiche.md)** – Farbe, Arbeitsfenster und 2. **[Bereiche einrichten](bereiche.md)** – Farbe, Arbeitsfenster und
Tageskapazität festlegen Tageskapazität festlegen
## Im Alltag
3. **[Aufgaben erfassen und wiederfinden](aufgaben.md)** – Schnellerfassung,
Inbox leeren, Filter, Status und Fälligkeit
4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
Fortschritt
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
verbinden, Belegung verstehen
## Verwaltung ## Verwaltung
3. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat 7. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
## Was noch kommt ## Was noch kommt
@@ -19,9 +29,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
| Thema | Meilenstein | | Thema | Meilenstein |
| ------------------------------- | ----------- | | ------------------------------- | ----------- |
| Aufgaben erfassen, Inbox, Ideen | M2 |
| Projekte und Arbeitspakete | M2 |
| Kalenderkonten verbinden | M3 |
| Wochenplanung und Zeitblöcke | M4 | | Wochenplanung und Zeitblöcke | M4 |
| Benachrichtigungen | M5 | | Benachrichtigungen | M5 |
| Integrationen | M6 | | Integrationen | M6 |
+135
View File
@@ -0,0 +1,135 @@
# Aufgaben erfassen und wiederfinden
Der Planer trennt zwei Dinge, die sonst gern durcheinandergeraten:
**etwas aufschreiben** und **entscheiden, wohin es gehört**. Das erste soll
zwei Sekunden dauern, das zweite macht man gesammelt, wenn man Ruhe hat.
## Schnell erfassen
Auf der Startseite und in der Inbox steht ein einzelnes Feld. Titel eintippen,
Enter — fertig.
![Die Inbox](../bilder/inbox-hell-schreibtisch.png)
Der Fokus bleibt danach im Feld, du kannst also mehrere Sachen hintereinander
eingeben, ohne zur Maus zu greifen.
> **Warum nur ein Feld?** Wer beim Erfassen erst Bereich, Termin und Aufwand
> ausfüllen muss, erfasst irgendwann nichts mehr, sondern merkt es sich
> „schnell" — und vergisst es. Alles Weitere kommt beim Einsortieren.
## Die Inbox leeren
Alles ohne Bereich landet in der Inbox. Die Zahl neben dem Menüpunkt sagt, wie
viel dort liegt.
Zum Einsortieren genügt das Auswahlfeld unter der Aufgabe: Bereich wählen, und
sie verschwindet aus der Inbox.
> **Die Inbox ist kein eigener Zustand.** Eine Aufgabe ist genau dann in der
> Inbox, wenn sie keinen Bereich hat. Einsortieren heißt schlicht: einen
> Bereich setzen. Deshalb kann sie auch nicht „doppelt" irgendwo sein.
Wenn beim Einsortieren mehr zu ergänzen ist als der Bereich, klick auf das
Stift-Symbol — dann öffnet sich das vollständige Formular direkt in der Liste.
## Die Aufgabenliste
![Die Aufgabenliste](../bilder/aufgaben-hell-schreibtisch.png)
Standardmäßig zeigt sie nur, was noch Arbeit bedeutet — Erledigtes und
Verworfenes fällt heraus. Über den Filter _Status_ holst du es zurück.
### Die Reihenfolge
Sortiert wird nach Dringlichkeit, in dieser Reihenfolge:
1. Offenes vor Abgeschlossenem
2. früherer Termin vor späterem
3. Terminiertes vor Unterminiertem
4. bei gleichem Termin: höhere Priorität zuerst
5. zuletzt alphabetisch
Ohne Termin heißt also nicht unwichtig — solche Aufgaben stehen nur hinter den
terminierten.
### Filter
| Filter | Was er tut |
| ----------- | ------------------------------------------------------- |
| **Suche** | Durchsucht Titel und Notiz. Enter drücken |
| **Bereich** | Ein Bereich, oder _Nur Inbox_ für alles ohne Bereich |
| **Status** | Standard ist _Nur offene_. _Alle_ zeigt auch Erledigtes |
| **Art** | Aufgabe, Arbeitspaket, Meilenstein, Idee oder Spiegel |
| **Projekt** | Nur Aufgaben eines Projekts |
Die Filter stehen in der Adresszeile. Eine gefilterte Liste lässt sich also als
Lesezeichen ablegen, und der Zurück-Knopf des Browsers tut, was er soll.
## Die Felder einer Aufgabe
| Feld | Bedeutung |
| ------------- | ------------------------------------------------------------------------------------ |
| **Titel** | Zwei bis 200 Zeichen. Am besten mit einem Verb: „Angebot schreiben", nicht „Angebot" |
| **Notiz** | Was dazugehört, aber nicht in den Titel passt |
| **Art** | Aufgabe, Idee, Arbeitspaket oder Meilenstein |
| **Status** | Offen, Geplant, Wartet, Erledigt, Verworfen |
| **Priorität** | 1 Sofort bis 5 Irgendwann. 3 ist der Normalfall |
| **Aufwand** | Geschätzte Minuten, höchstens 480 |
| **Fällig am** | Der Tag, an dem es spätestens fertig sein muss |
| **Bereich** | Leer heißt Inbox |
| **Projekt** | Optional |
> **Warum höchstens acht Stunden Aufwand?** Weil ab M4 aus einer Aufgabe ein
> Zeitblock im Kalender wird. Was nicht an einem Tag am Stück zu schaffen ist,
> gehört in mehrere Aufgaben — sonst lässt es sich nicht planen.
## Der Status
| Status | Wann |
| ------------- | ----------------------------------------------------- |
| **Offen** | Steht an, ist noch nicht eingeplant |
| **Geplant** | Hat ab M4 einen Zeitblock im Kalender |
| **Wartet** | Liegt bei jemand anderem. Du kannst gerade nichts tun |
| **Erledigt** | Fertig |
| **Verworfen** | Wird nicht gemacht — aber die Notiz bleibt erhalten |
Das Häkchen links in jeder Zeile hakt direkt ab. Bei einer erledigten Aufgabe
wird daraus ein Pfeil, der sie wieder öffnet.
> **Aus Erledigt und Verworfen geht es nur zurück nach Offen.** Nicht direkt
> nach Geplant oder Wartet. Was wieder aufgenommen wird, fängt am Anfang an —
> sonst entstehen Zustände, die niemand mehr nachvollziehen kann.
## Fälligkeit
Der Planer rechnet in **Kalendertagen in Europe/Berlin**, nicht in
24-Stunden-Schritten. Was heute um 23:00 fällig ist, ist „heute fällig" — nicht
„in 0,5 Tagen".
| Kennzeichen | Bedeutung |
| ----------- | ------------------------ |
| rot | überfällig |
| gelb | heute oder morgen |
| grau | später, oder ohne Termin |
Erledigtes wird nie als überfällig markiert, auch wenn der Termin lange
zurückliegt.
## Löschen
Der Papierkorb fragt einmal nach: Beim ersten Klick wird der Knopf rot und
fragt „Wirklich?", erst der zweite löscht. Klickst du daneben, ist die Frage
wieder weg.
Meist ist **Verworfen** die bessere Wahl — dann bleibt nachvollziehbar, dass es
die Aufgabe gab und warum sie nicht gemacht wurde.
## Typische Fehler
| Meldung | Was zu tun ist |
| --------------------------------------------------- | ------------------------------------------------------ |
| „Mindestens zwei Zeichen." | Der Titel ist zu kurz |
| „Mehr als acht Stunden gehört in mehrere Aufgaben." | Aufteilen |
| „Dieser Statuswechsel ist nicht vorgesehen." | Erst wieder öffnen, dann weiter |
| „Diesen Bereich gibt es nicht." | Der Bereich wurde inzwischen gelöscht. Seite neu laden |
+48
View File
@@ -0,0 +1,48 @@
# Ideen
Nicht alles, was einem einfällt, ist eine Aufgabe. Manches ist ein „das wäre
mal schön" ohne Termin, ohne Aufwand und ohne Verpflichtung.
Solche Sachen gehören auf die Ideenseite — dort stören sie die Aufgabenliste
nicht und gehen trotzdem nicht verloren.
![Die Ideenseite](../bilder/ideen-hell-schreibtisch.png)
## Eine Idee notieren
Titel eintippen, Enter. Genau wie bei Aufgaben, nur landet es auf der
Ideenseite statt in der Inbox.
> Bewerten kannst du später. Der Sinn ist, sie festzuhalten, bevor sie weg ist.
## Aus einer Idee eine Aufgabe machen
Wenn aus der Idee etwas werden soll: **Zur Aufgabe machen**. Sie verschwindet
damit von der Ideenseite und taucht in der Inbox auf — Bereich, Termin und
Aufwand ergänzt du dann wie bei jeder anderen Aufgabe.
Der Titel und die Notiz bleiben erhalten, es entsteht kein zweiter Eintrag.
## Warum Ideen getrennt liegen
Ideen haben bewusst **keinen Termin**. Würden sie in der Aufgabenliste stehen,
träfe eines von beidem zu:
- Sie stünden ganz unten und wären damit unsichtbar, oder
- sie bekämen einen erfundenen Termin und würden irgendwann rot.
Beides führt dazu, dass man die Liste nicht mehr ernst nimmt. Getrennt bleibt
die Aufgabenliste eine Liste von Verpflichtungen — und die Ideenseite eine
Sammlung von Möglichkeiten.
## Eine Idee wieder loswerden
Der Papierkorb löscht endgültig. Wer sie lieber behalten möchte, ohne sie
weiter zu sehen, setzt sie über das Stift-Symbol auf **Verworfen** — dann
bleibt sie nachlesbar, verschwindet aber aus der Liste.
## Auf der Startseite
Sind Ideen da, zeigt die Startseite unten einen Hinweis mit ihrer Anzahl.
Das ist Absicht: ein leiser Anstoß, gelegentlich hineinzusehen — aber nichts,
was sich zwischen die Arbeit des Tages drängt.
+116
View File
@@ -0,0 +1,116 @@
# Kalenderkonten verbinden
Der Planer liest deine Termine, um zu wissen, **wann überhaupt Platz ist**.
Ab M4 schreibt er Zeitblöcke in denselben Kalender zurück.
![Die Seite Kalenderkonten](../bilder/kalenderkonten-hell-schreibtisch.png)
## Was der Planer liest — und was nicht
| Liest er | Nutzt er wofür |
| --------------------------------- | ------------------------------------------------ |
| Beginn und Ende | Belegung gegen die Tageskapazität |
| Betreff | Anzeige in der Wochenansicht |
| Ganztägig ja/nein | Ganztägige Termine zählen **nicht** zur Belegung |
| „Zeigt an als" (frei, gebucht, …) | Als frei markierte Termine zählen nicht |
**Nicht gelesen** werden Teilnehmer, Beschreibungstexte, Anhänge und Orte.
Betreffs stehen in der Datenbank, damit du sie in der Ansicht siehst — sie
landen aber **niemals in Protokollen**.
Gelesen wird ein Fenster von **7 Tagen zurück bis 90 Tage voraus**. Was aus
diesem Fenster herauswandert, wird nachts wieder entfernt.
## Einen Kalender verbinden
1. Im Menü auf **Kalender**.
2. Auf **Microsoft 365 verbinden** beziehungsweise **Google verbinden**.
3. Beim Anbieter anmelden und der Berechtigung zustimmen.
4. Du landest wieder hier, und der erste Abgleich läuft sofort.
> **Warum fragt der Planer auch nach Schreibrechten?** Weil er ab M4
> Zeitblöcke in den Kalender schreibt. Würde er jetzt nur Leserechte
> anfordern, müsstest du später jedem Konto erneut zustimmen — und genau
> daran scheitert Einrichtung erfahrungsgemäß.
### Welche Anbieter es gibt
Das hängt an der Instanz und ist nicht verhandelbar:
| | Firma | Daheim |
| ------------- | -------- | ------ |
| Microsoft 365 | ja | ja |
| Google | **nein** | ja |
In der Firmeninstanz gibt es die Google-Seite nicht — der Aufruf endet dort
mit „Diese Seite gibt es nicht". Das ist Absicht, kein Fehler.
## Der Abgleich
Alle 15 Minuten läuft der Abgleich von selbst. Mit **Jetzt abgleichen**
stößt du ihn von Hand an.
Beide Anbieter liefern nur die **Änderungen** seit dem letzten Mal, nicht
jedes Mal alle Termine. Läuft dieser Merker ab — das passiert nach längerer
Pause —, liest der Planer den ganzen Zeitraum neu ein. Davon merkst du außer
einem etwas längeren Abgleich nichts.
## Die Belegung
Auf der Startseite zeigt jeder Bereich, wie voll der heutige Tag schon ist:
Termine aus **allen** verbundenen Kalendern gegen die Tageskapazität dieses
Bereichs.
> **Warum zählen alle Kalender gegen jeden Bereich?** Weil ein Termin die
> Zeit ohnehin blockiert, ganz gleich, zu welchem Lebensbereich er gehört.
> Wer um 14 Uhr beim Zahnarzt sitzt, kann zur selben Zeit nichts für die
> Firma tun.
Gezählt wird nur, was **ins Arbeitsfenster** des Bereichs fällt. Ein Termin
um 22 Uhr verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um
17 Uhr endet.
Überlappende Termine werden zusammengelegt — eine Doppelbuchung zählt nur
einmal.
## Ein Konto umbenennen
Die Bezeichnung ist frei wählbar. „Firma Outlook" oder „Gewerbe" liest sich
besser als die Mailadresse, gerade wenn du mehrere Konten desselben
Anbieters hast.
## Ein Konto trennen
Der rote Knopf, mit Rückfrage. Die gespiegelten Termine verschwinden dabei —
**im Kalender selbst bleibt alles unangetastet**. Der Planer hat bis M4
ohnehin nur gelesen.
Zusätzlich solltest du die Berechtigung beim Anbieter selbst zurückziehen:
- Microsoft: <https://myapps.microsoft.com> → _Zugriff verwalten_
- Google: <https://myaccount.google.com/permissions>
## Wenn es klemmt
| Was du siehst | Was dahintersteckt |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| „Der Anbieter hat kein Aktualisierungstoken herausgegeben." | Meist bei Google, wenn du schon einmal zugestimmt hattest. Berechtigung im Google-Konto entziehen und neu verbinden. |
| „Die Anfrage war ungültig oder zu alt." | Zwischen Klick und Zustimmung lagen mehr als 30 Minuten. Einfach neu beginnen. |
| „Kein Aktualisierungstoken hinterlegt." | Das Konto muss neu verbunden werden. Das passiert auch, wenn `AUTH_SECRET` gewechselt wurde. |
| „Der Anbieter antwortete mit 401." | Die Berechtigung wurde beim Anbieter entzogen. Neu verbinden. |
| „unerwartete Zeitzone …, erwartet wurde UTC" | Ein Konfigurationsfehler auf Serverseite. Bitte melden — der Planer bricht hier bewusst ab, statt Termine um Stunden zu verschieben. |
| Die Belegung bleibt leer | Kein Kalender verbunden, oder der erste Abgleich lief noch nicht. Die Karte zeigt die Belegung erst, wenn wirklich ein Kalender dranhängt. |
## Sicherheit
Die Zugangstoken liegen **verschlüsselt** in der Datenbank (AES-256-GCM).
Wer eine Sicherung in die Hände bekommt, kann damit nichts anfangen, solange
er nicht auch `AUTH_SECRET` hat.
Wird `AUTH_SECRET` gewechselt, müssen alle Kalenderkonten neu verbunden
werden. Das ist gewollt: mit dem alten Geheimnis sind auch alle Sitzungen
ungültig.
Für die Einrichtung auf Serverseite — was in Entra ID und in der Google
Cloud Console anzulegen ist — siehe
[README, Abschnitt Kalenderanbieter](../../README.md#kalenderanbieter-einrichten).
+82
View File
@@ -0,0 +1,82 @@
# Projekte
Ein **Projekt** ist eine Klammer um mehrere Aufgaben, die zusammen ein Ziel
erreichen: „Website neu aufsetzen", „Werkstatt umräumen", „Zertifizierung".
![Die Projektübersicht](../bilder/projekte-hell-schreibtisch.png)
Ein Projekt hat selbst **keinen Aufwand und keine Fälligkeit**. Beides ergibt
sich aus dem, was drinsteckt — sonst hättest du zwei Zahlen, die auseinander
laufen können.
## Ein Projekt anlegen
**Neues Projekt** oben rechts.
| Feld | Bedeutung |
| ---------------- | ------------------------------------------------------------- |
| **Name** | Zwei bis 80 Zeichen, eindeutig |
| **Beschreibung** | Worum es geht — und woran du merkst, dass es fertig ist |
| **Status** | Aktiv, Pausiert, Abgeschlossen, Verworfen |
| **Bereich** | Optional. Neue Aufgaben im Projekt bekommen ihn vorgeschlagen |
| **Zieltermin** | Rein informativ; er macht keine Aufgabe überfällig |
> Der zweite Satz der Beschreibung ist der wichtigere. „Fertig ist es, wenn die
> neue Seite online ist und das Kontaktformular ankommt" verhindert, dass ein
> Projekt ewig weiterläuft.
## Aufgaben zuordnen
Zwei Wege:
1. Auf der Projektseite **Aufgabe hinzufügen** — Projekt und Bereich sind dann
schon vorbelegt, die Art steht auf _Arbeitspaket_.
2. In einer bestehenden Aufgabe das Feld **Projekt** setzen.
### Arbeitspaket und Meilenstein
Beides sind Arten von Aufgaben _innerhalb_ eines Projekts:
- **Arbeitspaket** — ein Stück Arbeit mit Aufwand.
- **Meilenstein** — ein Punkt, an dem etwas erreicht ist. Meist ohne Aufwand,
dafür mit Termin.
## Der Fortschritt
Der Balken zeigt, wie viele der zugeordneten Aufgaben erledigt sind.
> **Verworfene Aufgaben zählen gar nicht mit.** Weder als erledigt noch als
> offen. Sonst sähe ein Projekt fertig aus, weil die Hälfte gestrichen wurde —
> und das wäre eine Zahl, die lügt.
Ein Beispiel: drei Aufgaben, eine erledigt, eine offen, eine verworfen. Der
Fortschritt ist **1 von 2 erledigt (50 %)**, nicht 33 %.
Daneben steht der noch offene geschätzte Aufwand. Aufgaben ohne Schätzung
zählen darin als null — die Zahl ist also eine Untergrenze.
## Ein Projekt abschließen
Status auf **Abgeschlossen** setzen. Es verschwindet damit aus der Auswahl bei
neuen Aufgaben, bleibt aber in der Übersicht sichtbar.
**Pausiert** ist für Projekte, die gerade ruhen, aber weitergehen sollen.
## Ein Projekt löschen
Der rote Knopf auf der Projektseite, mit Rückfrage.
> **Die enthaltenen Aufgaben bleiben erhalten** und verlieren nur ihre
> Zuordnung. Mit dem Projekt auch die Arbeit zu löschen wäre selten das, was
> jemand will — und nie rückgängig zu machen.
Willst du wirklich alles loswerden, lösche zuerst die Aufgaben über die
Aufgabenliste (Filter _Projekt_), dann das Projekt.
## Typische Fehler
| Meldung | Was zu tun ist |
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
| „Diesen Namen gibt es schon." | Projektnamen sind eindeutig. Vielleicht gibt es das Projekt schon abgeschlossen |
| „Dieses Projekt gibt es nicht." | Es wurde inzwischen gelöscht. Seite neu laden |
| Der Fortschritt springt beim Verwerfen nach oben | Das ist richtig so: Verworfenes fällt aus der Rechnung |
+2 -1
View File
@@ -50,7 +50,8 @@ test.describe('Anmeldung', () => {
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click() await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible() await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
await expect(page.getByRole('link', { name: 'Bereiche' })).toBeVisible() // exact, weil die Startseite zusätzlich einen Link "Bereiche verwalten" hat.
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeVisible()
await page.getByTitle('Abmelden').click() await page.getByTitle('Abmelden').click()
await expect(page).toHaveURL(/\/anmelden/) await expect(page).toHaveURL(/\/anmelden/)
+187
View File
@@ -0,0 +1,187 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type Page } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
/**
* Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken.
*
* Alle Testdaten tragen ein gemeinsames Präfix und werden am Ende entfernt,
* damit die Entwicklungsdatenbank hinterher aussieht wie vorher.
*/
const EMAIL = process.env.ADMIN_EMAIL ?? ''
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
const PRAEFIX = `E2E-${Date.now()}`
const verbindung = process.env.DATABASE_URL ?? ''
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
test.describe('Aufgaben', () => {
test.skip(
!EMAIL || !PASSWORT || !verbindung,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
)
test.afterAll(async () => {
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
await db.projekt.deleteMany({ where: { name: { startsWith: PRAEFIX } } })
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'aufgabe.' } } })
await db.$disconnect()
})
async function anmelden(seite: Page): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
await seite.getByLabel('Passwort').fill(PASSWORT)
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
}
test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => {
const titel = `${PRAEFIX} Angebot schreiben`
await anmelden(page)
// --- Erfassen: ein Feld genügt --------------------------------------
await page.goto('/inbox')
await page.getByLabel('Neue Aufgabe').fill(titel)
await page.getByRole('button', { name: 'Erfassen' }).click()
const zeile = page.locator('li').filter({ hasText: titel })
await expect(zeile).toBeVisible()
// Ohne Bereich liegt sie in der Inbox – genau das ist der Unterschied.
await expect(zeile.getByText('Inbox')).toBeVisible()
// --- Einsortieren ----------------------------------------------------
const bereich = await db.bereich.findFirst({
where: { aktiv: true },
orderBy: { reihenfolge: 'asc' },
select: { id: true, name: true },
})
expect(bereich).not.toBeNull()
await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id)
await expect(page.getByText(`liegt jetzt in ${bereich!.name}`)).toBeVisible()
// Aus der Inbox ist sie damit verschwunden.
await page.goto('/inbox')
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
// --- Abhaken ---------------------------------------------------------
await page.goto('/aufgaben')
const inListe = page.locator('li').filter({ hasText: titel })
await expect(inListe).toBeVisible()
await inListe.getByRole('button', { name: 'Als erledigt abhaken' }).click()
// Auf die Bestätigung warten, bevor es weitergeht: der Klick stößt eine
// Server Action an, und ohne diesen Haltepunkt navigiert der Test
// gelegentlich schneller, als gespeichert wird.
await expect(page.getByText('ist jetzt erledigt')).toBeVisible()
// Erledigtes fällt aus der Standardansicht heraus.
await page.goto('/aufgaben')
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
// Mit Filter "Alle" ist sie wieder da.
await page.goto('/aufgaben?status=ERLEDIGT')
await expect(page.locator('li').filter({ hasText: titel })).toBeVisible()
const gespeichert = await db.aufgabe.findFirst({ where: { titel } })
expect(gespeichert?.status).toBe('ERLEDIGT')
expect(gespeichert?.erledigtAm).not.toBeNull()
})
test('macht aus einer Idee eine Aufgabe', async ({ page }) => {
const titel = `${PRAEFIX} Blog aufsetzen`
await anmelden(page)
await page.goto('/ideen')
await page.getByLabel('Neue Idee').fill(titel)
await page.getByRole('button', { name: 'Erfassen' }).click()
const zeile = page.locator('li').filter({ hasText: titel })
await expect(zeile).toBeVisible()
await zeile.getByRole('button', { name: 'Zur Aufgabe machen' }).click()
await expect(page.getByText('ist jetzt eine Aufgabe')).toBeVisible()
const danach = await db.aufgabe.findFirst({ where: { titel } })
expect(danach?.typ).toBe('AUFGABE')
// Aus der Ideenliste ist sie damit verschwunden.
await page.goto('/ideen')
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
})
test('hält Ideen aus der Aufgabenliste heraus', async ({ page }) => {
const idee = `${PRAEFIX} Nur eine Idee`
const aufgabe = `${PRAEFIX} Eine echte Aufgabe`
await db.aufgabe.createMany({
data: [
{ titel: idee, typ: 'IDEE' },
{ titel: aufgabe, typ: 'AUFGABE' },
],
})
await anmelden(page)
// In der Aufgabenliste hat die Idee nichts zu suchen.
await page.goto('/aufgaben')
await expect(page.locator('li').filter({ hasText: aufgabe })).toBeVisible()
await expect(page.locator('li').filter({ hasText: idee })).toHaveCount(0)
// Auch nicht in der Inbox, obwohl beide keinen Bereich haben.
await page.goto('/inbox')
await expect(page.locator('li').filter({ hasText: aufgabe })).toBeVisible()
await expect(page.locator('li').filter({ hasText: idee })).toHaveCount(0)
// Wer ausdrücklich danach filtert, sieht sie sehr wohl.
await page.goto('/aufgaben?typ=IDEE')
await expect(page.locator('li').filter({ hasText: idee })).toBeVisible()
await expect(page.locator('li').filter({ hasText: aufgabe })).toHaveCount(0)
})
test('filtert nach Bereich und über die Adresszeile', async ({ page }) => {
await anmelden(page)
const bereich = await db.bereich.findFirst({
where: { aktiv: true },
select: { id: true, name: true },
})
const drin = `${PRAEFIX} Gehört dazu`
const draussen = `${PRAEFIX} Gehört nicht dazu`
await db.aufgabe.createMany({
data: [
{ titel: drin, bereichId: bereich!.id },
{ titel: draussen, bereichId: null },
],
})
await page.goto(`/aufgaben?bereich=${bereich!.id}`)
await expect(page.locator('li').filter({ hasText: drin })).toBeVisible()
await expect(page.locator('li').filter({ hasText: draussen })).toHaveCount(0)
// Nur Inbox zeigt genau die andere.
await page.goto('/aufgaben?bereich=inbox')
await expect(page.locator('li').filter({ hasText: draussen })).toBeVisible()
await expect(page.locator('li').filter({ hasText: drin })).toHaveCount(0)
})
test('zeigt den Fortschritt eines Projekts', async ({ page }) => {
const projektName = `${PRAEFIX} Website neu`
const projekt = await db.projekt.create({ data: { name: projektName } })
await db.aufgabe.createMany({
data: [
{ titel: `${PRAEFIX} Entwurf`, projektId: projekt.id, status: 'ERLEDIGT' },
{ titel: `${PRAEFIX} Umsetzung`, projektId: projekt.id, status: 'OFFEN' },
// Verworfenes zählt bewusst gar nicht mit.
{ titel: `${PRAEFIX} Verworfen`, projektId: projekt.id, status: 'VERWORFEN' },
],
})
await anmelden(page)
await page.goto(`/projekte/${projekt.id}`)
await expect(page.getByRole('heading', { name: projektName })).toBeVisible()
await expect(page.getByText('1 von 2 erledigt (50 %)')).toBeVisible()
})
})
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "planer", "name": "planer",
"version": "0.1.0", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "planer", "name": "planer",
"version": "0.1.0", "version": "0.3.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",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "planer", "name": "planer",
"version": "0.1.0", "version": "0.3.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",
+229
View File
@@ -105,6 +105,118 @@ const PROTOKOLL = [
}, },
] ]
/**
* Erfundene Aufgaben. Die Fälligkeit steht als Versatz in Tagen zum festen
* Demo-Zeitpunkt, damit die Bilder immer dieselbe Mischung aus überfällig,
* heute und später zeigen.
*/
const AUFGABEN = [
// Überfällig
{
titel: 'Angebot Heizungstausch nachfassen',
bereich: 'Gewerbe',
versatz: -3,
prioritaet: 2,
aufwand: 30,
},
{
titel: 'Rechnung 2026-114 korrigieren',
bereich: 'Gewerbe',
versatz: -1,
prioritaet: 1,
aufwand: 20,
},
// Heute und morgen
{
titel: 'Wochenbericht an die Geschäftsführung',
bereich: 'Firma',
versatz: 0,
prioritaet: 2,
aufwand: 45,
},
{
titel: 'Rückruf Frau Sonnenberg',
bereich: 'Firma',
versatz: 0,
prioritaet: 1,
aufwand: 15,
},
{
titel: 'Zahnarzttermin bestätigen',
bereich: 'Privat',
versatz: 1,
prioritaet: 3,
aufwand: 10,
},
// Später
{
titel: 'Backup-Strategie für den Server überdenken',
bereich: 'Homelab',
versatz: 9,
prioritaet: 4,
aufwand: 120,
},
{
titel: 'Reifen wechseln lassen',
bereich: 'Privat',
versatz: 14,
prioritaet: 3,
aufwand: 60,
},
// Ohne Termin
{
titel: 'Ablage im Keller sortieren',
bereich: 'Privat',
versatz: null,
prioritaet: 5,
aufwand: 180,
},
// Erledigt
{
titel: 'Steuerunterlagen zusammenstellen',
bereich: 'Gewerbe',
versatz: -5,
prioritaet: 3,
aufwand: 90,
status: 'ERLEDIGT' as const,
},
]
/** Liegt noch in der Inbox – ohne Bereich. */
const INBOX = [
{ titel: 'Sicherungskasten beschriften' },
{ titel: 'Angebot Netzwerkschrank einholen' },
]
const IDEEN = [
{ titel: 'Newsletter für Bestandskunden' },
{ titel: 'Werkstatt-Podcast aufnehmen' },
{ titel: 'Regale im Lager neu beschriften' },
]
const PROJEKT_AUFGABEN = [
{
titel: 'Anforderungen sammeln',
typ: 'ARBEITSPAKET' as const,
status: 'ERLEDIGT' as const,
},
{
titel: 'Entwurf abstimmen',
typ: 'ARBEITSPAKET' as const,
status: 'ERLEDIGT' as const,
},
{ titel: 'Texte schreiben', typ: 'ARBEITSPAKET' as const, status: 'OFFEN' as const },
{ titel: 'Fotos beauftragen', typ: 'ARBEITSPAKET' as const, status: 'WARTET' as const },
{ titel: 'Seite ist online', typ: 'MEILENSTEIN' as const, status: 'OFFEN' as const },
]
/** Fester Bezugspunkt, passend zu src/server/zeitgeber.ts. */
const DEMO_JETZT = new Date('2026-03-17T08:20:00.000Z')
function tageVersetzt(tage: number): Date {
return new Date(DEMO_JETZT.getTime() + tage * 24 * 60 * 60 * 1000)
}
async function main(): Promise<void> { async function main(): Promise<void> {
console.log('Demo-Daten einspielen.') console.log('Demo-Daten einspielen.')
@@ -158,6 +270,123 @@ async function main(): Promise<void> {
}, },
}) })
// --- Aufgaben, Ideen und ein Projekt -------------------------------------
await db.aufgabe.deleteMany({})
await db.projekt.deleteMany({})
const bereichsKennung = new Map(
(await db.bereich.findMany({ select: { id: true, name: true } })).map((b) => [
b.name,
b.id,
]),
)
for (const eintrag of AUFGABEN) {
await db.aufgabe.create({
data: {
titel: eintrag.titel,
bereichId: bereichsKennung.get(eintrag.bereich) ?? null,
prioritaet: eintrag.prioritaet,
aufwandMinuten: eintrag.aufwand,
faelligAm: eintrag.versatz === null ? null : tageVersetzt(eintrag.versatz),
status: eintrag.status ?? 'OFFEN',
erledigtAm: eintrag.status === 'ERLEDIGT' ? tageVersetzt(-4) : null,
},
})
}
for (const eintrag of INBOX) {
await db.aufgabe.create({ data: { titel: eintrag.titel } })
}
for (const eintrag of IDEEN) {
await db.aufgabe.create({ data: { titel: eintrag.titel, typ: 'IDEE' } })
}
const projekt = await db.projekt.create({
data: {
name: 'Website neu aufsetzen',
beschreibung:
'Der alte Auftritt ist von 2019 und auf dem Handy kaum lesbar. Fertig ist es, wenn die neue Seite online ist und das Kontaktformular ankommt.',
bereichId: bereichsKennung.get('Gewerbe') ?? null,
zielAm: tageVersetzt(45),
},
})
for (const eintrag of PROJEKT_AUFGABEN) {
await db.aufgabe.create({
data: {
titel: eintrag.titel,
typ: eintrag.typ,
status: eintrag.status,
projektId: projekt.id,
bereichId: bereichsKennung.get('Gewerbe') ?? null,
aufwandMinuten: eintrag.typ === 'MEILENSTEIN' ? null : 120,
erledigtAm: eintrag.status === 'ERLEDIGT' ? tageVersetzt(-7) : null,
},
})
}
// --- Kalenderkonto und erfundene Termine ---------------------------------
// Die Tokens sind absichtlich Unsinn: die Screenshot-Instanz spricht mit
// keinem Anbieter, sie soll die Seite nur nicht leer zeigen.
await db.kalenderkonto.deleteMany({})
const kalenderkonto = await db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: 'Firma Outlook',
extKontoId: 'martina.beispiel@firma.invalid',
letzterAbgleich: new Date(DEMO_JETZT.getTime() - 6 * 60_000),
},
})
const TERMINE = [
{ betreff: 'Wochenbesprechung', vonStunde: 8, bisStunde: 9 },
{ betreff: 'Kundengespräch Sonnenberg', vonStunde: 10, bisStunde: 11 },
{ betreff: 'Mittagspause', vonStunde: 11, bisStunde: 12, frei: true },
{ betreff: 'Abnahme Baustelle', vonStunde: 13, bisStunde: 15 },
]
for (const eintrag of TERMINE) {
await db.termin.create({
data: {
kalenderkontoId: kalenderkonto.id,
extId: `demo-${eintrag.betreff.toLowerCase().replace(/[^a-z]+/g, '-')}`,
betreff: eintrag.betreff,
beginn: new Date(Date.UTC(2026, 2, 17, eintrag.vonStunde - 1, 0)),
ende: new Date(Date.UTC(2026, 2, 17, eintrag.bisStunde - 1, 0)),
zeigtAls: eintrag.frei ? 'FREI' : 'GEBUCHT',
},
})
}
await db.termin.create({
data: {
kalenderkontoId: kalenderkonto.id,
extId: 'demo-brueckentag',
betreff: 'Brückentag',
beginn: new Date(Date.UTC(2026, 2, 20, 0, 0)),
ende: new Date(Date.UTC(2026, 2, 21, 0, 0)),
ganztaegig: true,
zeigtAls: 'ABWESEND',
},
})
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
await db.aufgabe.create({
data: {
titel: 'Störung Klimagerät Serverraum prüfen',
bereichId: bereichsKennung.get('Firma') ?? null,
prioritaet: 2,
aufwandMinuten: 60,
faelligAm: tageVersetzt(2),
quelle: 'ZAMMAD',
quelleRef: 'demo-38421',
quelleUrl: 'https://zammad.example.invalid/ticket/38421',
},
})
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`) console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
} }
@@ -0,0 +1,80 @@
-- CreateEnum
CREATE TYPE "AufgabenTyp" AS ENUM ('AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN', 'SPIEGEL');
-- CreateEnum
CREATE TYPE "AufgabenStatus" AS ENUM ('OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN');
-- CreateEnum
CREATE TYPE "Quelle" AS ENUM ('MANUELL', 'ZAMMAD', 'WARTUNGSPLANER', 'TELEGRAM', 'SPIEGEL', 'MAIL', 'API', 'MCP');
-- CreateEnum
CREATE TYPE "ProjektStatus" AS ENUM ('AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN');
-- CreateTable
CREATE TABLE "projekt" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"beschreibung" TEXT,
"status" "ProjektStatus" NOT NULL DEFAULT 'AKTIV',
"bereichId" TEXT,
"zielAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "projekt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "aufgabe" (
"id" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"notiz" TEXT,
"typ" "AufgabenTyp" NOT NULL DEFAULT 'AUFGABE',
"status" "AufgabenStatus" NOT NULL DEFAULT 'OFFEN',
"prioritaet" INTEGER NOT NULL DEFAULT 3,
"aufwandMinuten" INTEGER,
"faelligAm" TIMESTAMP(3),
"erledigtAm" TIMESTAMP(3),
"bereichId" TEXT,
"projektId" TEXT,
"quelle" "Quelle" NOT NULL DEFAULT 'MANUELL',
"quelleUrl" TEXT,
"quelleRef" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "aufgabe_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "projekt_status_idx" ON "projekt"("status");
-- CreateIndex
CREATE INDEX "projekt_bereichId_idx" ON "projekt"("bereichId");
-- CreateIndex
CREATE UNIQUE INDEX "projekt_name_key" ON "projekt"("name");
-- CreateIndex
CREATE INDEX "aufgabe_status_faelligAm_idx" ON "aufgabe"("status", "faelligAm");
-- CreateIndex
CREATE INDEX "aufgabe_bereichId_status_idx" ON "aufgabe"("bereichId", "status");
-- CreateIndex
CREATE INDEX "aufgabe_projektId_idx" ON "aufgabe"("projektId");
-- CreateIndex
CREATE INDEX "aufgabe_typ_status_idx" ON "aufgabe"("typ", "status");
-- CreateIndex
CREATE UNIQUE INDEX "aufgabe_quelle_quelleRef_key" ON "aufgabe"("quelle", "quelleRef");
-- AddForeignKey
ALTER TABLE "projekt" ADD CONSTRAINT "projekt_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_projektId_fkey" FOREIGN KEY ("projektId") REFERENCES "projekt"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,70 @@
-- CreateEnum
CREATE TYPE "KalenderAnbieter" AS ENUM ('GRAPH', 'GOOGLE');
-- CreateEnum
CREATE TYPE "ZeigtAls" AS ENUM ('FREI', 'GEBUCHT', 'MIT_VORBEHALT', 'ABWESEND', 'WOANDERS', 'UNBEKANNT');
-- AlterTable
ALTER TABLE "bereich" ADD COLUMN "standardKalenderkontoId" TEXT;
-- CreateTable
CREATE TABLE "kalenderkonto" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"anbieter" "KalenderAnbieter" NOT NULL,
"anzeigename" TEXT NOT NULL,
"extKontoId" TEXT NOT NULL,
"kalenderId" TEXT,
"zugriffstoken" TEXT,
"aktualisierungstoken" TEXT,
"tokenGueltigBis" TIMESTAMP(3),
"bereiche" TEXT,
"deltaVerweis" TEXT,
"letzterAbgleich" TIMESTAMP(3),
"letzterFehler" TEXT,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "kalenderkonto_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "termin" (
"id" TEXT NOT NULL,
"kalenderkontoId" TEXT NOT NULL,
"extId" TEXT NOT NULL,
"etag" TEXT,
"betreff" TEXT,
"beginn" TIMESTAMP(3) NOT NULL,
"ende" TIMESTAMP(3) NOT NULL,
"ganztaegig" BOOLEAN NOT NULL DEFAULT false,
"zeigtAls" "ZeigtAls" NOT NULL DEFAULT 'GEBUCHT',
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "termin_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "kalenderkonto_benutzerId_aktiv_idx" ON "kalenderkonto"("benutzerId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "kalenderkonto_benutzerId_anbieter_extKontoId_kalenderId_key" ON "kalenderkonto"("benutzerId", "anbieter", "extKontoId", "kalenderId");
-- CreateIndex
CREATE INDEX "termin_kalenderkontoId_beginn_idx" ON "termin"("kalenderkontoId", "beginn");
-- CreateIndex
CREATE INDEX "termin_beginn_ende_idx" ON "termin"("beginn", "ende");
-- CreateIndex
CREATE UNIQUE INDEX "termin_kalenderkontoId_extId_key" ON "termin"("kalenderkontoId", "extId");
-- AddForeignKey
ALTER TABLE "bereich" ADD CONSTRAINT "bereich_standardKalenderkontoId_fkey" FOREIGN KEY ("standardKalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kalenderkonto" ADD CONSTRAINT "kalenderkonto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "termin" ADD CONSTRAINT "termin_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
/*
Warnings:
- Made the column `kalenderId` on table `kalenderkonto` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "kalenderkonto" ALTER COLUMN "kalenderId" SET NOT NULL,
ALTER COLUMN "kalenderId" SET DEFAULT 'standard';
+194
View File
@@ -34,6 +34,7 @@ model Benutzer {
konten Konto[] konten Konto[]
passkeys Passkey[] passkeys Passkey[]
audit AuditEintrag[] audit AuditEintrag[]
kalenderkonten Kalenderkonto[]
@@index([aktiv]) @@index([aktiv])
@@map("benutzer") @@map("benutzer")
@@ -121,10 +122,120 @@ model Bereich {
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt aktualisiertAm DateTime @updatedAt
/// In welchen Kalender Zeitbloecke dieses Bereichs geschrieben werden.
standardKalenderkontoId String?
standardKalenderkonto Kalenderkonto? @relation("StandardKalenderkonto", fields: [standardKalenderkontoId], references: [id], onDelete: SetNull)
aufgaben Aufgabe[]
projekte Projekt[]
@@index([aktiv, reihenfolge]) @@index([aktiv, reihenfolge])
@@map("bereich") @@map("bereich")
} }
enum AufgabenTyp {
AUFGABE
IDEE
ARBEITSPAKET
MEILENSTEIN
SPIEGEL
}
enum AufgabenStatus {
OFFEN
GEPLANT
WARTET
ERLEDIGT
VERWORFEN
}
/// Woher eine Aufgabe stammt. Integrationen upserten ueber [quelle, quelleRef].
enum Quelle {
MANUELL
ZAMMAD
WARTUNGSPLANER
TELEGRAM
SPIEGEL
MAIL
API
MCP
}
enum ProjektStatus {
AKTIV
PAUSIERT
ABGESCHLOSSEN
VERWORFEN
}
/// Klammer um mehrere Aufgaben. Hat selbst keinen Aufwand und keine
/// Faelligkeit – beides ergibt sich aus den enthaltenen Aufgaben.
model Projekt {
id String @id @default(cuid())
name String
beschreibung String?
status ProjektStatus @default(AKTIV)
bereichId String?
/// Wunschtermin fuer den Abschluss, rein informativ.
zielAm DateTime?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
aufgaben Aufgabe[]
@@unique([name])
@@index([status])
@@index([bereichId])
@@map("projekt")
}
/// Eine Aufgabe, Idee, ein Arbeitspaket oder ein Meilenstein.
///
/// Ohne Bereich liegt sie in der Inbox – das ist der einzige Unterschied,
/// "einsortieren" heisst schlicht: einen Bereich setzen.
model Aufgabe {
id String @id @default(cuid())
titel String
notiz String?
typ AufgabenTyp @default(AUFGABE)
status AufgabenStatus @default(OFFEN)
/// 1 = dringend, 5 = irgendwann. 3 ist der Normalfall.
prioritaet Int @default(3)
/// Geschaetzter Aufwand in Minuten; null heisst "noch nicht geschaetzt".
aufwandMinuten Int?
faelligAm DateTime?
erledigtAm DateTime?
bereichId String?
projektId String?
/// Herkunft. Fuer von Hand erfasste Aufgaben MANUELL ohne Verweis.
quelle Quelle @default(MANUELL)
quelleUrl String?
/// Kennung beim Ursprungssystem, z. B. die Ticketnummer.
quelleRef String?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
/// Integrationen upserten immer ueber diese beiden Felder. Von Hand
/// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte
/// als verschieden an, sie behindern sich also nicht.
@@unique([quelle, quelleRef])
@@index([status, faelligAm])
@@index([bereichId, status])
@@index([projektId])
@@index([typ, status])
@@map("aufgabe")
}
/// Unveraenderlicher Nachweis jeder schreibenden Aktion. /// Unveraenderlicher Nachweis jeder schreibenden Aktion.
model AuditEintrag { model AuditEintrag {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -146,3 +257,86 @@ model AuditEintrag {
@@index([benutzerId]) @@index([benutzerId])
@@map("audit_eintrag") @@map("audit_eintrag")
} }
enum KalenderAnbieter {
GRAPH
GOOGLE
}
/// Wie der Termin die Zeit belegt. Google kennt nur frei/gebucht, Graph mehr.
enum ZeigtAls {
FREI
GEBUCHT
MIT_VORBEHALT
ABWESEND
WOANDERS
UNBEKANNT
}
/// Ein verbundener Kalender bei Microsoft Graph oder Google.
model Kalenderkonto {
id String @id @default(cuid())
benutzerId String
anbieter KalenderAnbieter
/// Frei waehlbare Bezeichnung, z. B. "Firma Outlook".
anzeigename String
/// Kennung beim Anbieter, meist die Mailadresse. Dient dem Wiedererkennen.
extKontoId String
/// Welcher Kalender. Bei Google "primary", bei Graph eine Kalender-ID.
/// "standard" bedeutet: der Standardkalender des Postfachs. Nicht optional,
/// weil Prisma in einem zusammengesetzten Unique-Schluessel kein null zulaesst.
kalenderId String @default("standard")
/// Verschluesselt abgelegt, siehe src/server/krypto.ts.
zugriffstoken String?
aktualisierungstoken String?
tokenGueltigBis DateTime?
/// Erteilte Berechtigungen, kommagetrennt – zum Erkennen fehlender Rechte.
bereiche String?
/// deltaLink bei Graph, syncToken bei Google. Opak, nie auswerten.
deltaVerweis String?
letzterAbgleich DateTime?
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
letzterFehler String?
aktiv Boolean @default(true)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
termine Termin[]
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
/// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer.
@@unique([benutzerId, anbieter, extKontoId, kalenderId])
@@index([benutzerId, aktiv])
@@map("kalenderkonto")
}
/// Ortlicher Spiegel eines Kalendertermins. Nur so viel, wie fuer die
/// Belegungsrechnung und die Wochenansicht gebraucht wird.
model Termin {
id String @id @default(cuid())
kalenderkontoId String
/// Kennung des Termins beim Anbieter.
extId String
/// Fuer bedingte Schreibzugriffe ab M4 (If-Match).
etag String?
betreff String?
beginn DateTime
ende DateTime
ganztaegig Boolean @default(false)
zeigtAls ZeigtAls @default(GEBUCHT)
aktualisiertAm DateTime @updatedAt
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
@@unique([kalenderkontoId, extId])
@@index([kalenderkontoId, beginn])
@@index([beginn, ende])
@@map("termin")
}
+5
View File
@@ -29,7 +29,12 @@ type Aufnahme = {
const AUFNAHMEN: Aufnahme[] = [ const AUFNAHMEN: Aufnahme[] = [
{ name: 'anmelden', pfad: '/anmelden' }, { name: 'anmelden', pfad: '/anmelden' },
{ name: 'start', pfad: '/' }, { name: 'start', pfad: '/' },
{ name: 'inbox', pfad: '/inbox' },
{ name: 'aufgaben', pfad: '/aufgaben' },
{ name: 'projekte', pfad: '/projekte' },
{ name: 'ideen', pfad: '/ideen' },
{ name: 'bereiche', pfad: '/bereiche' }, { name: 'bereiche', pfad: '/bereiche' },
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
{ name: 'einstellungen', pfad: '/einstellungen' }, { name: 'einstellungen', pfad: '/einstellungen' },
{ name: 'protokoll', pfad: '/verwaltung/protokoll' }, { name: 'protokoll', pfad: '/verwaltung/protokoll' },
] ]
+231 -2
View File
@@ -44,9 +44,15 @@ const kennung = `zzz-test-${Date.now()}`
const testEmail = `${kennung}@example.invalid` const testEmail = `${kennung}@example.invalid`
async function aufraeumen(): Promise<void> { async function aufraeumen(): Promise<void> {
// Reihenfolge egal, die Kaskaden erledigen den Rest; der Audit-Eintrag // Erst die Aufgaben, dann Projekt und Bereich – sonst blieben Verweise
// hängt bewusst an keinem Benutzer mehr. // stehen, die den Bereich am Löschen hindern.
await db.aufgabe.deleteMany({ where: { titel: { startsWith: kennung } } })
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: kennung } } })
await db.projekt.deleteMany({ where: { name: { startsWith: kennung } } })
await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } }) await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } })
// Kalenderkonten hängen am Benutzer und gehen mit ihm; falls der Test
// vorher abbricht, hier trotzdem noch einmal aufräumen.
await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: kennung } } })
await db.benutzer.deleteMany({ where: { email: testEmail } }) await db.benutzer.deleteMany({ where: { email: testEmail } })
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
await db.webauthnChallenge.deleteMany({ where: { kennung } }) await db.webauthnChallenge.deleteMany({ where: { kennung } })
@@ -143,6 +149,102 @@ async function main(): Promise<void> {
}), }),
) )
// --- Kalenderkonto und Termine ------------------------------------------
const kalenderkonto = await db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: `${kennung} Firma Outlook`,
extKontoId: `${kennung}-konto`,
},
})
pruefe(
'Kalenderkonto zeigt im Zweifel auf den Standardkalender',
kalenderkonto.kalenderId === 'standard',
)
pruefe(
'Ein frisches Konto hat noch keinen Abgleichverweis',
kalenderkonto.deltaVerweis === null,
)
await scheitert('Dasselbe Konto und derselbe Kalender nur einmal', () =>
db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: `${kennung} Doppelt`,
extKontoId: `${kennung}-konto`,
},
}),
)
// Dasselbe Konto mit einem anderen Kalender ist dagegen in Ordnung.
const zweiterKalender = await db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: `${kennung} Zweiter Kalender`,
extKontoId: `${kennung}-konto`,
kalenderId: 'AAMkAD-zweiter',
},
})
pruefe(
'Dasselbe Konto mit anderem Kalender ist erlaubt',
zweiterKalender.id !== kalenderkonto.id,
)
const termin = await db.termin.create({
data: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
betreff: 'Wochenbesprechung',
beginn: new Date('2026-03-17T08:00:00Z'),
ende: new Date('2026-03-17T09:00:00Z'),
},
})
pruefe('Termin anlegen', termin.zeigtAls === 'GEBUCHT' && termin.ganztaegig === false)
await scheitert('Derselbe Termin je Konto nur einmal', () =>
db.termin.create({
data: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
beginn: new Date('2026-03-17T08:00:00Z'),
ende: new Date('2026-03-17T09:00:00Z'),
},
}),
)
// So arbeitet der Abgleich: upsert statt anlegen.
const gewandelterTermin = await db.termin.upsert({
where: {
kalenderkontoId_extId: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
},
},
update: { betreff: 'Wochenbesprechung, verschoben', zeigtAls: 'MIT_VORBEHALT' },
create: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
beginn: new Date('2026-03-17T08:00:00Z'),
ende: new Date('2026-03-17T09:00:00Z'),
},
})
pruefe(
'Ein Upsert aktualisiert den Termin, statt ihn zu doppeln',
gewandelterTermin.id === termin.id &&
gewandelterTermin.zeigtAls === 'MIT_VORBEHALT' &&
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 1,
)
// --- Standard-Kalenderkonto eines Bereichs -------------------------------
await db.bereich.update({
where: { id: bereich.id },
data: { standardKalenderkontoId: kalenderkonto.id },
})
await db.kalenderkonto.delete({ where: { id: zweiterKalender.id } })
// --- Audit überlebt den Benutzer ---------------------------------------- // --- Audit überlebt den Benutzer ----------------------------------------
const audit = await db.auditEintrag.create({ const audit = await db.auditEintrag.create({
data: { data: {
@@ -174,6 +276,133 @@ async function main(): Promise<void> {
'Verknüpfte Konten verschwinden mit dem Benutzer', 'Verknüpfte Konten verschwinden mit dem Benutzer',
(await db.konto.count({ where: { providerAccountId: kennung } })) === 0, (await db.konto.count({ where: { providerAccountId: kennung } })) === 0,
) )
pruefe(
'Kalenderkonten verschwinden mit dem Benutzer',
(await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0,
)
pruefe(
'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine',
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,
)
pruefe(
'Der Bereich überlebt das Löschen seines Standard-Kalenderkontos',
(await db.bereich.findUnique({ where: { id: bereich.id } }))
?.standardKalenderkontoId === null,
)
// --- Aufgaben: Standardwerte und Inbox -----------------------------------
const aufgabe = await db.aufgabe.create({
data: { titel: `${kennung} Angebot schreiben` },
})
pruefe('Aufgabe anlegen', aufgabe.id.length > 0)
pruefe(
'Ohne Angabe ist eine Aufgabe offen, normal und von Hand erfasst',
aufgabe.status === 'OFFEN' &&
aufgabe.typ === 'AUFGABE' &&
aufgabe.prioritaet === 3 &&
aufgabe.quelle === 'MANUELL',
)
pruefe('Ohne Bereich liegt sie in der Inbox', aufgabe.bereichId === null)
// --- Eindeutigkeit der Quelle -------------------------------------------
// Von Hand erfasste Aufgaben haben quelleRef = null. PostgreSQL sieht
// NULL-Werte als verschieden an, deshalb behindern sie sich nicht.
const zweiteOhneRef = await db.aufgabe.create({
data: { titel: `${kennung} Noch eine von Hand` },
})
pruefe(
'Mehrere Aufgaben ohne Quellverweis sind erlaubt',
zweiteOhneRef.id !== aufgabe.id,
)
await db.aufgabe.create({
data: {
titel: `${kennung} Ticket 4711`,
quelle: 'ZAMMAD',
quelleRef: `${kennung}-4711`,
quelleUrl: 'https://zammad.example.invalid/4711',
},
})
await scheitert('Dieselbe Quelle mit derselben Kennung nur einmal', () =>
db.aufgabe.create({
data: {
titel: `${kennung} Ticket 4711 doppelt`,
quelle: 'ZAMMAD',
quelleRef: `${kennung}-4711`,
},
}),
)
// Dieselbe Kennung bei anderer Quelle ist dagegen in Ordnung.
const andereQuelle = await db.aufgabe.create({
data: {
titel: `${kennung} Telegram 4711`,
quelle: 'TELEGRAM',
quelleRef: `${kennung}-4711`,
},
})
pruefe('Dieselbe Kennung bei anderer Quelle ist erlaubt', andereQuelle.id.length > 0)
// So werden Integrationen es tun: upsert statt anlegen.
const gewandelt = await db.aufgabe.upsert({
where: {
quelle_quelleRef: { quelle: 'ZAMMAD', quelleRef: `${kennung}-4711` },
},
update: { titel: `${kennung} Ticket 4711 aktualisiert` },
create: {
titel: `${kennung} Ticket 4711 neu`,
quelle: 'ZAMMAD',
quelleRef: `${kennung}-4711`,
},
})
pruefe(
'Ein Upsert über [quelle, quelleRef] aktualisiert statt zu doppeln',
gewandelt.titel.endsWith('aktualisiert') &&
(await db.aufgabe.count({
where: { quelle: 'ZAMMAD', quelleRef: `${kennung}-4711` },
})) === 1,
)
// --- Projekt: Zuordnung überlebt das Löschen nicht, die Aufgabe schon ----
const projekt = await db.projekt.create({ data: { name: `${kennung} Website` } })
await db.aufgabe.update({
where: { id: aufgabe.id },
data: { projektId: projekt.id },
})
await scheitert('Projektname ist eindeutig', () =>
db.projekt.create({ data: { name: projekt.name } }),
)
await db.projekt.delete({ where: { id: projekt.id } })
const nachProjektLoeschung = await db.aufgabe.findUnique({
where: { id: aufgabe.id },
select: { id: true, projektId: true },
})
pruefe('Die Aufgabe überlebt das Löschen ihres Projekts', nachProjektLoeschung !== null)
pruefe(
'Nur die Zuordnung zum Projekt wird gelöst',
nachProjektLoeschung?.projektId === null,
)
// --- Bereich: dasselbe Verhalten ----------------------------------------
const bereich2 = await db.bereich.create({
data: { name: `${kennung}-zweiter`, farbe: '#16a34a' },
})
await db.aufgabe.update({
where: { id: aufgabe.id },
data: { bereichId: bereich2.id },
})
await db.bereich.delete({ where: { id: bereich2.id } })
const nachBereichLoeschung = await db.aufgabe.findUnique({
where: { id: aufgabe.id },
select: { bereichId: true },
})
pruefe(
'Beim Löschen eines Bereichs fällt die Aufgabe zurück in die Inbox',
nachBereichLoeschung?.bereichId === null,
)
} }
let abbruch: unknown = null let abbruch: unknown = null
+157
View File
@@ -0,0 +1,157 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTransition } from 'react'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { STATUS_NAME, TYP_NAME } from '@/lib/aufgabe'
type Eigenschaften = {
bereiche: { id: string; name: string }[]
projekte: { id: string; name: string }[]
}
const STATUS = ['OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN'] as const
const TYPEN = ['AUFGABE', 'ARBEITSPAKET', 'MEILENSTEIN', 'IDEE', 'SPIEGEL'] as const
/**
* Die Filter stehen in der Adresse, nicht im Zustand der Komponente. So lässt
* sich eine gefilterte Liste als Lesezeichen ablegen und teilen, und der
* Zurück-Knopf tut, was er soll.
*/
export function Filterleiste({ bereiche, projekte }: Eigenschaften) {
const router = useRouter()
const parameter = useSearchParams()
const [laeuft, starte] = useTransition()
function setzen(schluessel: string, wert: string) {
const neu = new URLSearchParams(parameter.toString())
if (wert) neu.set(schluessel, wert)
else neu.delete(schluessel)
starte(() => router.push(`/aufgaben?${neu.toString()}`))
}
const gesetzt = ['bereich', 'projekt', 'status', 'typ', 'suche'].filter((s) =>
parameter.get(s),
)
return (
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-40 flex-1 flex-col gap-1">
<Label htmlFor="filter-suche" className="text-xs font-normal">
Suche
</Label>
<Input
id="filter-suche"
type="search"
className="h-8"
defaultValue={parameter.get('suche') ?? ''}
placeholder="Titel oder Notiz"
disabled={laeuft}
onKeyDown={(e) => {
if (e.key === 'Enter') setzen('suche', e.currentTarget.value.trim())
}}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="filter-bereich" className="text-xs font-normal">
Bereich
</Label>
<Select
id="filter-bereich"
className="h-8 w-40"
value={parameter.get('bereich') ?? ''}
disabled={laeuft}
onChange={(e) => setzen('bereich', e.target.value)}
>
<option value="">Alle</option>
<option value="inbox">Nur Inbox</option>
{bereiche.map((bereich) => (
<option key={bereich.id} value={bereich.id}>
{bereich.name}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="filter-status" className="text-xs font-normal">
Status
</Label>
<Select
id="filter-status"
className="h-8 w-36"
value={parameter.get('status') ?? ''}
disabled={laeuft}
onChange={(e) => setzen('status', e.target.value)}
>
<option value="">Nur offene</option>
<option value="alle">Alle</option>
{STATUS.map((status) => (
<option key={status} value={status}>
{STATUS_NAME[status]}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="filter-typ" className="text-xs font-normal">
Art
</Label>
<Select
id="filter-typ"
className="h-8 w-36"
value={parameter.get('typ') ?? ''}
disabled={laeuft}
onChange={(e) => setzen('typ', e.target.value)}
>
<option value="">Alle</option>
{TYPEN.map((typ) => (
<option key={typ} value={typ}>
{TYP_NAME[typ]}
</option>
))}
</Select>
</div>
{projekte.length > 0 && (
<div className="flex flex-col gap-1">
<Label htmlFor="filter-projekt" className="text-xs font-normal">
Projekt
</Label>
<Select
id="filter-projekt"
className="h-8 w-44"
value={parameter.get('projekt') ?? ''}
disabled={laeuft}
onChange={(e) => setzen('projekt', e.target.value)}
>
<option value="">Alle</option>
{projekte.map((projekt) => (
<option key={projekt.id} value={projekt.id}>
{projekt.name}
</option>
))}
</Select>
</div>
)}
{gesetzt.length > 0 && (
<Button
art="leise"
groesse="klein"
disabled={laeuft}
onClick={() => starte(() => router.push('/aufgaben'))}
>
<X aria-hidden />
Filter zurücksetzen
</Button>
)}
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
'use client'
import { useState } from 'react'
import { Plus, X } from 'lucide-react'
import { AufgabeFormular } from '@/components/aufgaben/aufgabe-formular'
import { Button } from '@/components/ui/button'
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
type Eigenschaften = {
bereiche: { id: string; name: string }[]
projekte: { id: string; name: string }[]
vorgabe?: { bereichId?: string; projektId?: string; typ?: string }
beschriftung?: string
}
/** Klappt das vollständige Formular auf; im Normalfall genügt die Inbox. */
export function NeueAufgabe({
bereiche,
projekte,
vorgabe,
beschriftung = 'Neue Aufgabe',
}: Eigenschaften) {
const [offen, setOffen] = useState(false)
if (!offen) {
return (
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
{beschriftung}
</Button>
)
}
return (
<Card className="w-full">
<CardKopf className="flex-row items-center justify-between">
<CardTitel className="text-base">{beschriftung}</CardTitel>
<Button art="leise" groesse="quadrat" onClick={() => setOffen(false)}>
<X aria-hidden />
<span className="sr-only">Schließen</span>
</Button>
</CardKopf>
<CardInhalt>
<AufgabeFormular
bereiche={bereiche}
projekte={projekte}
{...(vorgabe ? { vorgabe } : {})}
onFertig={() => setOffen(false)}
/>
</CardInhalt>
</Card>
)
}
+112
View File
@@ -0,0 +1,112 @@
import type { Metadata } from 'next'
import { Suspense } from 'react'
import { Filterleiste } from '@/app/(app)/aufgaben/filterleiste'
import { NeueAufgabe } from '@/app/(app)/aufgaben/neue-aufgabe'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Card, CardInhalt } from '@/components/ui/card'
import type { AufgabenStatus, AufgabenTyp } from '@/generated/prisma/enums'
import { aufwandSumme } from '@/lib/aufgabe'
import { alsDauer } from '@/lib/zeit'
import {
aufgabenLaden,
bereicheLaden,
projekteZurAuswahlLaden,
type AufgabenFilter,
} from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
export const metadata: Metadata = { title: 'Aufgaben' }
const STATUS_WERTE: readonly string[] = [
'OFFEN',
'GEPLANT',
'WARTET',
'ERLEDIGT',
'VERWORFEN',
]
const TYP_WERTE: readonly string[] = [
'AUFGABE',
'IDEE',
'ARBEITSPAKET',
'MEILENSTEIN',
'SPIEGEL',
]
/** Übersetzt die Adresszeile in einen Filter; Unbekanntes wird ignoriert. */
function filterAus(
parameter: Record<string, string | string[] | undefined>,
): AufgabenFilter {
const einer = (name: string): string | undefined => {
const wert = parameter[name]
return typeof wert === 'string' && wert.length > 0 ? wert : undefined
}
const bereich = einer('bereich')
const status = einer('status')
const typ = einer('typ')
return {
...(bereich === 'inbox' ? { nurInbox: true } : bereich ? { bereichId: bereich } : {}),
...(einer('projekt') ? { projektId: einer('projekt') } : {}),
// Ohne Angabe zeigt die Liste nur, was noch Arbeit bedeutet.
...(status === 'alle'
? {}
: status && STATUS_WERTE.includes(status)
? { status: status as AufgabenStatus }
: { nurOffen: true }),
...(typ && TYP_WERTE.includes(typ) ? { typ: typ as AufgabenTyp } : {}),
...(einer('suche') ? { suche: einer('suche') } : {}),
// Ideen haben ihre eigene Seite; wer sie hier sehen will, filtert danach.
ohneIdeen: true,
}
}
export default async function AufgabenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
await verlangeAnmeldung('/aufgaben')
const parameter = await searchParams
const [aufgaben, bereiche, projekte] = await Promise.all([
aufgabenLaden(filterAus(parameter)),
bereicheLaden(),
projekteZurAuswahlLaden(),
])
const geschaetzt = aufwandSumme(aufgaben)
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Aufgaben</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
{aufgaben.length === 0
? 'Nichts gefunden.'
: `${aufgaben.length} Aufgabe(n)${geschaetzt > 0 ? `, zusammen ${alsDauer(geschaetzt)} geschätzt` : ''}.`}
</p>
</div>
<NeueAufgabe bereiche={bereiche} projekte={projekte} />
</header>
<Card>
<CardInhalt className="py-4">
<Suspense
fallback={<p className="text-gedaempft-vordergrund text-sm">Filter …</p>}
>
<Filterleiste bereiche={bereiche} projekte={projekte} />
</Suspense>
</CardInhalt>
</Card>
<AufgabenListe
aufgaben={aufgaben}
bereiche={bereiche}
projekte={projekte}
leerText="Keine Aufgabe passt zu diesen Filtern."
/>
</div>
)
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from 'next'
import { Lightbulb } from 'lucide-react'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
import {
Card,
CardBeschreibung,
CardInhalt,
CardKopf,
CardTitel,
} from '@/components/ui/card'
import {
aufgabenLaden,
bereicheLaden,
projekteZurAuswahlLaden,
} from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
export const metadata: Metadata = { title: 'Ideen' }
export default async function IdeenSeite() {
await verlangeAnmeldung('/ideen')
const [ideen, bereiche, projekte] = await Promise.all([
aufgabenLaden({ typ: 'IDEE', nurOffen: true }),
bereicheLaden(),
projekteZurAuswahlLaden(),
])
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Lightbulb className="size-6" aria-hidden />
Ideen
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Was irgendwann einmal lohnen könnte. Ideen haben bewusst keinen Termin und
drängen sich nicht in die Aufgabenliste — bis du eine übernimmst.
</p>
</header>
<Card>
<CardKopf>
<CardTitel className="text-base">Idee notieren</CardTitel>
<CardBeschreibung>
Festhalten, bevor sie weg ist. Bewerten kannst du später.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<Schnellerfassung typ="IDEE" />
</CardInhalt>
</Card>
<section className="flex flex-col gap-3">
<h2 className="text-lg font-medium">
Gesammelt
{ideen.length > 0 && (
<span className="text-gedaempft-vordergrund ml-2 text-sm font-normal">
{ideen.length}
</span>
)}
</h2>
<AufgabenListe
aufgaben={ideen}
bereiche={bereiche}
projekte={projekte}
mitUebernehmen
leerText="Noch keine Idee notiert."
/>
</section>
</div>
)
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from 'next'
import { Inbox } from 'lucide-react'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
import {
Card,
CardBeschreibung,
CardInhalt,
CardKopf,
CardTitel,
} from '@/components/ui/card'
import {
aufgabenLaden,
bereicheLaden,
projekteZurAuswahlLaden,
} from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
export const metadata: Metadata = { title: 'Inbox' }
export default async function InboxSeite() {
await verlangeAnmeldung('/inbox')
const [aufgaben, bereiche, projekte] = await Promise.all([
aufgabenLaden({ nurInbox: true, nurOffen: true, ohneIdeen: true }),
bereicheLaden(),
projekteZurAuswahlLaden(),
])
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Inbox className="size-6" aria-hidden />
Inbox
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Alles, was noch keinem Bereich zugeordnet ist. Erst erfassen, später
einsortieren — Nachdenken beim Aufschreiben kostet nur Zeit.
</p>
</header>
<Card>
<CardKopf>
<CardTitel className="text-base">Schnell erfassen</CardTitel>
<CardBeschreibung>
Ein Titel genügt. Bereich, Termin und Aufwand kommen beim Einsortieren.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<Schnellerfassung />
</CardInhalt>
</Card>
<section className="flex flex-col gap-3">
<h2 className="text-lg font-medium">
Noch einzusortieren
{aufgaben.length > 0 && (
<span className="text-gedaempft-vordergrund ml-2 text-sm font-normal">
{aufgaben.length}
</span>
)}
</h2>
<AufgabenListe
aufgaben={aufgaben}
bereiche={bereiche}
projekte={projekte}
mitEinsortieren
leerText="Die Inbox ist leer. Alles einsortiert."
/>
</section>
</div>
)
}
@@ -0,0 +1,149 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2, RefreshCw, Unplug } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import {
jetztAbgleichen,
kontoTrennen,
kontoUmbenennen,
type Rueckmeldung,
} from '@/server/aktionen/kalenderkonten'
export type KontoAnzeige = {
id: string
anbieter: 'GRAPH' | 'GOOGLE'
anbieterName: string
anzeigename: string
extKontoId: string
termine: number
letzterAbgleich: string | null
letzterFehler: string | null
}
export function KontoKarte({ konto }: { konto: KontoAnzeige }) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [name, setName] = useState(konto.anzeigename)
const [trennenBestaetigt, setTrennenBestaetigt] = useState(false)
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
toast.success(antwort.meldung)
router.refresh()
} else {
toast.error(antwort.fehler)
}
})
}
function felder(werte: Record<string, string>): FormData {
const daten = new FormData()
for (const [schluessel, wert] of Object.entries(werte)) daten.set(schluessel, wert)
return daten
}
return (
<Card>
<CardKopf className="flex-row flex-wrap items-center gap-2">
<CardTitel className="text-base">{konto.anzeigename}</CardTitel>
<Kennzeichen>{konto.anbieterName}</Kennzeichen>
{konto.letzterFehler && <Kennzeichen art="gefahr">Fehler</Kennzeichen>}
</CardKopf>
<CardInhalt className="flex flex-col gap-4">
<dl className="grid gap-1 text-sm sm:grid-cols-[10rem_1fr]">
<dt className="text-gedaempft-vordergrund">Konto</dt>
<dd>{konto.extKontoId}</dd>
<dt className="text-gedaempft-vordergrund">Gespiegelte Termine</dt>
<dd>{konto.termine}</dd>
<dt className="text-gedaempft-vordergrund">Letzter Abgleich</dt>
<dd>{konto.letzterAbgleich ?? 'noch nie'}</dd>
</dl>
{konto.letzterFehler && (
<p className="bg-gefahr/10 text-gefahr rounded-md px-3 py-2 text-sm">
{konto.letzterFehler}
</p>
)}
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`name-${konto.id}`}
className="text-gedaempft-vordergrund text-xs"
>
Bezeichnung
</label>
<Input
id={`name-${konto.id}`}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={60}
disabled={laeuft}
/>
</div>
<Button
art="umriss"
disabled={laeuft || name.trim() === konto.anzeigename}
onClick={() =>
mitRueckmeldung(() =>
kontoUmbenennen(felder({ id: konto.id, anzeigename: name })),
)
}
>
Speichern
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
art="umriss"
disabled={laeuft}
onClick={() =>
mitRueckmeldung(() => jetztAbgleichen(felder({ id: konto.id })))
}
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<RefreshCw aria-hidden />
)}
Jetzt abgleichen
</Button>
<Button
art="gefahr"
className="ml-auto"
disabled={laeuft}
onBlur={() => setTrennenBestaetigt(false)}
onClick={() => {
if (!trennenBestaetigt) {
setTrennenBestaetigt(true)
return
}
mitRueckmeldung(() => kontoTrennen(felder({ id: konto.id })))
}}
>
<Unplug aria-hidden />
{trennenBestaetigt ? 'Wirklich trennen?' : 'Trennen'}
</Button>
</div>
{trennenBestaetigt && (
<p className="text-gedaempft-vordergrund text-xs">
Die gespiegelten Termine verschwinden dabei. Im Kalender selbst bleibt alles
unangetastet.
</p>
)}
</CardInhalt>
</Card>
)
}
+33
View File
@@ -0,0 +1,33 @@
'use client'
import { useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
/**
* Zeigt die Rückmeldung aus der Adresszeile, die die OAuth-Rückleitung dort
* hinterlassen hat, und räumt sie danach weg — sonst erschiene sie bei jedem
* Neuladen erneut.
*/
export function Meldung({
ok,
fehler,
}: {
ok?: string | undefined
fehler?: string | undefined
}) {
const router = useRouter()
const gezeigt = useRef(false)
useEffect(() => {
if (gezeigt.current) return
if (!ok && !fehler) return
gezeigt.current = true
if (ok) toast.success(ok)
if (fehler) toast.error(fehler)
router.replace('/kalenderkonten')
}, [ok, fehler, router])
return null
}
+140
View File
@@ -0,0 +1,140 @@
import type { Metadata } from 'next'
import { CalendarDays, Plus } from 'lucide-react'
import { KontoKarte } from '@/app/(app)/kalenderkonten/konto-karte'
import { Meldung } from '@/app/(app)/kalenderkonten/meldung'
import { Button } from '@/components/ui/button'
import {
Card,
CardBeschreibung,
CardInhalt,
CardKopf,
CardTitel,
} from '@/components/ui/card'
import type { KalenderAnbieter } from '@/generated/prisma/enums'
import { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
import { erlaubteKalenderAnbieter } from '@/server/instanz'
import { umgebung } from '@/server/umgebung'
export const metadata: Metadata = { title: 'Kalenderkonten' }
const ANBIETER_NAME: Record<KalenderAnbieter, string> = {
GRAPH: 'Microsoft 365',
GOOGLE: 'Google',
}
export default async function KalenderkontenSeite({
searchParams,
}: {
searchParams: Promise<{ ok?: string; fehler?: string }>
}) {
const benutzer = await verlangeAnmeldung('/kalenderkonten')
const parameter = await searchParams
const einst = umgebung()
// Welche Anbieter es hier überhaupt gibt, entscheidet die Instanz; ob sie
// benutzbar sind, hängt zusätzlich an der Konfiguration.
const erlaubt = erlaubteKalenderAnbieter()
const verfuegbar = erlaubt.filter((anbieter) =>
anbieter === 'GRAPH' ? einst.graphAktiv : einst.googleAktiv,
)
const fehlendKonfiguriert = erlaubt.filter((a) => !verfuegbar.includes(a))
const konten = await db.kalenderkonto.findMany({
where: { benutzerId: benutzer.id },
orderBy: [{ anbieter: 'asc' }, { anzeigename: 'asc' }],
select: {
id: true,
anbieter: true,
anzeigename: true,
extKontoId: true,
letzterAbgleich: true,
letzterFehler: true,
_count: { select: { termine: true } },
},
})
return (
<div className="flex flex-col gap-6">
<header>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<CalendarDays className="size-6" aria-hidden />
Kalenderkonten
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Der Planer liest die Termine, um zu wissen, wann überhaupt Platz ist. Ab M4
schreibt er Zeitblöcke in denselben Kalender zurück.
</p>
</header>
<Meldung ok={parameter.ok} fehler={parameter.fehler} />
<Card>
<CardKopf>
<CardTitel className="text-base">Kalender verbinden</CardTitel>
<CardBeschreibung>
Du wirst zum Anbieter geschickt und kommst nach der Zustimmung hierher zurück.
Der Planer speichert keine Zugangsdaten im Klartext.
</CardBeschreibung>
</CardKopf>
<CardInhalt className="flex flex-col gap-3">
{verfuegbar.length === 0 ? (
<p className="text-gedaempft-vordergrund text-sm">
In dieser Instanz ist kein Kalenderanbieter eingerichtet.
</p>
) : (
<div className="flex flex-wrap gap-2">
{verfuegbar.map((anbieter) => (
<Button key={anbieter} alsKind>
<a href={`/api/kalender/verbinden/${anbieter.toLowerCase()}`}>
<Plus aria-hidden />
{ANBIETER_NAME[anbieter]} verbinden
</a>
</Button>
))}
</div>
)}
{fehlendKonfiguriert.length > 0 && (
<p className="text-gedaempft-vordergrund text-xs">
{fehlendKonfiguriert.map((a) => ANBIETER_NAME[a]).join(' und ')} wäre hier
erlaubt, ist aber noch nicht konfiguriert. Siehe README, Abschnitt
Umgebungsvariablen.
</p>
)}
</CardInhalt>
</Card>
<section className="flex flex-col gap-4">
<h2 className="text-lg font-medium">Verbundene Konten</h2>
{konten.length === 0 ? (
<Card>
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
Noch kein Kalender verbunden.
</CardInhalt>
</Card>
) : (
konten.map((konto) => (
<KontoKarte
key={konto.id}
konto={{
id: konto.id,
anbieter: konto.anbieter,
anbieterName: ANBIETER_NAME[konto.anbieter],
anzeigename: konto.anzeigename,
extKontoId: konto.extKontoId,
termine: konto._count.termine,
letzterAbgleich: konto.letzterAbgleich
? alsDatumUhrzeit(konto.letzterAbgleich)
: null,
letzterFehler: konto.letzterFehler,
}}
/>
))
)}
</section>
</div>
)
}
+3
View File
@@ -1,4 +1,5 @@
import { Kopfleiste } from '@/components/kopfleiste' import { Kopfleiste } from '@/components/kopfleiste'
import { zaehlstaende } from '@/server/abfragen/aufgaben'
import { INSTANZ_NAME } from '@/lib/instanz' import { INSTANZ_NAME } from '@/lib/instanz'
import { versionAnzeige } from '@/lib/version' import { versionAnzeige } from '@/lib/version'
import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung' import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung'
@@ -18,6 +19,7 @@ export default async function AngemeldetesLayout({
// Anmeldung dort weitergeht, wo man hinwollte. // Anmeldung dort weitergeht, wo man hinwollte.
const benutzer = await verlangeAnmeldung(await angefragterPfad()) const benutzer = await verlangeAnmeldung(await angefragterPfad())
const instanz = aktuelleInstanz() const instanz = aktuelleInstanz()
const zahlen = await zaehlstaende()
return ( return (
<div className="flex min-h-dvh flex-col"> <div className="flex min-h-dvh flex-col">
@@ -26,6 +28,7 @@ export default async function AngemeldetesLayout({
email={benutzer.email} email={benutzer.email}
rolle={benutzer.rolle} rolle={benutzer.rolle}
instanzName={INSTANZ_NAME[instanz]} instanzName={INSTANZ_NAME[instanz]}
inbox={zahlen.inbox}
/> />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main> <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
+112 -13
View File
@@ -1,4 +1,7 @@
import Link from 'next/link' import Link from 'next/link'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
import { import {
Card, Card,
CardBeschreibung, CardBeschreibung,
@@ -8,6 +11,13 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { wochenKapazitaet } from '@/lib/bereich' import { wochenKapazitaet } from '@/lib/bereich'
import { alsDatum, alsDauer } from '@/lib/zeit' import { alsDatum, alsDauer } from '@/lib/zeit'
import {
aufgabenLaden,
bereicheLaden,
projekteZurAuswahlLaden,
zaehlstaende,
} from '@/server/abfragen/aufgaben'
import { belegungHeute, hatKalenderkonto } from '@/server/abfragen/kalender'
import { verlangeAnmeldung } from '@/server/berechtigung' import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db' import { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber' import { jetzt } from '@/server/zeitgeber'
@@ -16,10 +26,24 @@ export default async function StartSeite() {
const benutzer = await verlangeAnmeldung('/') const benutzer = await verlangeAnmeldung('/')
const heute = jetzt() const heute = jetzt()
const bereiche = await db.bereich.findMany({ const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] =
await Promise.all([
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
bereicheLaden(),
projekteZurAuswahlLaden(),
zaehlstaende(),
db.bereich.findMany({
where: { aktiv: true }, where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
}) }),
belegungHeute(benutzer.id, heute),
hatKalenderkonto(benutzer.id),
])
// Was jetzt zählt: überfällig, heute oder morgen fällig.
const dran = alleOffenen.filter(
(a) => a.ueberfaellig || a.faelligkeit === 'heute' || a.faelligkeit === 'morgen',
)
const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email
@@ -30,10 +54,53 @@ export default async function StartSeite() {
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(heute)}</p> <p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(heute)}</p>
</header> </header>
<section className="flex flex-col gap-4"> <Card>
<h2 className="text-lg font-medium">Deine Bereiche</h2> <CardKopf>
<CardTitel className="text-base">Schnell erfassen</CardTitel>
<CardBeschreibung>
Landet in der{' '}
<Link href="/inbox" className="text-betont underline underline-offset-4">
Inbox
</Link>{' '}
und wird später einsortiert.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<Schnellerfassung />
</CardInhalt>
</Card>
{bereiche.length === 0 ? ( <section className="flex flex-col gap-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-lg font-medium">Jetzt dran</h2>
<Link
href="/aufgaben"
className="text-gedaempft-vordergrund hover:text-vordergrund text-sm"
>
Alle {zahlen.offen} offenen Aufgaben
</Link>
</div>
<AufgabenListe
aufgaben={dran}
bereiche={bereiche}
projekte={projekte}
leerText="Nichts überfällig, nichts heute fällig. Ein guter Tag."
/>
</section>
<section className="flex flex-col gap-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-lg font-medium">Deine Bereiche</h2>
<Link
href="/bereiche"
className="text-gedaempft-vordergrund hover:text-vordergrund text-sm"
>
Bereiche verwalten
</Link>
</div>
{bereichsListe.length === 0 ? (
<Card> <Card>
<CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm"> <CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm">
Noch keine Bereiche angelegt.{' '} Noch keine Bereiche angelegt.{' '}
@@ -44,9 +111,12 @@ export default async function StartSeite() {
</Card> </Card>
) : ( ) : (
<ul className="grid gap-4 sm:grid-cols-2"> <ul className="grid gap-4 sm:grid-cols-2">
{bereiche.map((bereich) => ( {bereichsListe.map((bereich) => {
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
const heuteHier = belegung.find((b) => b.id === bereich.id)?.belegung
return (
<li key={bereich.id}> <li key={bereich.id}>
<Card> <Card className="h-full">
<CardKopf className="flex-row items-center gap-3"> <CardKopf className="flex-row items-center gap-3">
<span <span
aria-hidden aria-hidden
@@ -55,27 +125,56 @@ export default async function StartSeite() {
/> />
<CardTitel className="text-base">{bereich.name}</CardTitel> <CardTitel className="text-base">{bereich.name}</CardTitel>
</CardKopf> </CardKopf>
<CardInhalt> <CardInhalt className="flex flex-col gap-2">
<p className="text-gedaempft-vordergrund text-sm"> <p className="text-gedaempft-vordergrund text-sm">
{alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant {offeneHier.length === 0
? 'Nichts offen'
: `${offeneHier.length} offen`}{' '}
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
</p> </p>
{/*
Ohne verbundenen Kalender wäre die Belegung immer null –
das sähe nach "alles frei" aus und wäre irreführend.
*/}
{mitKalender && heuteHier && (
<>
<Fortschrittsbalken
prozent={heuteHier.prozent}
beschriftung={`Belegung heute in ${bereich.name}`}
/>
<p className="text-gedaempft-vordergrund text-xs">
{heuteHier.kapazitaet === 0
? 'Heute nicht eingeplant'
: heuteHier.ueberbucht
? `Heute überbucht: ${alsDauer(heuteHier.belegt)} Termine bei ${alsDauer(heuteHier.kapazitaet)} Kapazität`
: `Heute noch ${alsDauer(heuteHier.frei)} frei von ${alsDauer(heuteHier.kapazitaet)}`}
</p>
</>
)}
</CardInhalt> </CardInhalt>
</Card> </Card>
</li> </li>
))} )
})}
</ul> </ul>
)} )}
</section> </section>
{zahlen.ideen > 0 && (
<Card> <Card>
<CardKopf> <CardKopf>
<CardTitel className="text-base">Was als Nächstes kommt</CardTitel> <CardTitel className="text-base">
<Link href="/ideen" className="hover:underline hover:underline-offset-4">
{zahlen.ideen} Idee(n) warten
</Link>
</CardTitel>
<CardBeschreibung> <CardBeschreibung>
Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die Ideen drängen sich nicht in die Aufgabenliste — bis du eine übernimmst.
Wochenplanung per Ziehen und Ablegen mit M4.
</CardBeschreibung> </CardBeschreibung>
</CardKopf> </CardKopf>
</Card> </Card>
)}
</div> </div>
) )
} }
+153
View File
@@ -0,0 +1,153 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { ArrowLeft } from 'lucide-react'
import { NeueAufgabe } from '@/app/(app)/aufgaben/neue-aufgabe'
import { ProjektFormular } from '@/app/(app)/projekte/projekt-formular'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import { PROJEKT_STATUS_NAME, aufwandSumme, fortschrittBerechnen } from '@/lib/aufgabe'
import { alsDatum, alsDauer, alsTagesschluessel } from '@/lib/zeit'
import {
aufgabenLaden,
bereicheLaden,
projekteZurAuswahlLaden,
} from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const projekt = await db.projekt.findUnique({
where: { id },
select: { name: true },
})
return { title: projekt?.name ?? 'Projekt' }
}
export default async function ProjektSeite({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
await verlangeAnmeldung(`/projekte/${id}`)
const projekt = await db.projekt.findUnique({
where: { id },
select: {
id: true,
name: true,
beschreibung: true,
status: true,
zielAm: true,
bereichId: true,
bereich: { select: { id: true, name: true, farbe: true } },
},
})
if (!projekt) notFound()
const [aufgaben, bereiche, projekte] = await Promise.all([
// Hier bewusst ohne nurOffen: im Projekt gehört auch das Erledigte ins Bild.
aufgabenLaden({ projektId: projekt.id }),
bereicheLaden(),
projekteZurAuswahlLaden(),
])
const fortschritt = fortschrittBerechnen(aufgaben)
const offenerAufwand = aufwandSumme(
aufgaben.filter((a) => a.status !== 'ERLEDIGT' && a.status !== 'VERWORFEN'),
)
return (
<div className="flex flex-col gap-6">
<Link
href="/projekte"
className="text-gedaempft-vordergrund hover:text-vordergrund inline-flex w-fit items-center gap-1 text-sm"
>
<ArrowLeft className="size-4" aria-hidden />
Alle Projekte
</Link>
<header className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
{projekt.bereich && (
<span
aria-hidden
className="size-4 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: projekt.bereich.farbe }}
/>
)}
<h1 className="text-2xl font-semibold tracking-tight">{projekt.name}</h1>
{projekt.status !== 'AKTIV' && (
<Kennzeichen>{PROJEKT_STATUS_NAME[projekt.status]}</Kennzeichen>
)}
</div>
{projekt.beschreibung && (
<p className="text-gedaempft-vordergrund text-sm whitespace-pre-line">
{projekt.beschreibung}
</p>
)}
<Fortschrittsbalken
prozent={fortschritt.prozent}
beschriftung={`Fortschritt von ${projekt.name}`}
/>
<p className="text-gedaempft-vordergrund text-sm">
{fortschritt.gesamt === 0
? 'Noch keine Aufgaben zugeordnet.'
: `${fortschritt.erledigt} von ${fortschritt.gesamt} erledigt (${fortschritt.prozent} %)`}
{offenerAufwand > 0 && ` · noch ${alsDauer(offenerAufwand)} geschätzt`}
{projekt.zielAm && ` · Ziel ${alsDatum(projekt.zielAm)}`}
</p>
</header>
<div className="flex flex-wrap items-start justify-between gap-4">
<h2 className="text-lg font-medium">Aufgaben im Projekt</h2>
<NeueAufgabe
bereiche={bereiche}
projekte={projekte}
vorgabe={{
projektId: projekt.id,
...(projekt.bereichId ? { bereichId: projekt.bereichId } : {}),
typ: 'ARBEITSPAKET',
}}
beschriftung="Aufgabe hinzufügen"
/>
</div>
<AufgabenListe
aufgaben={aufgaben}
bereiche={bereiche}
projekte={projekte}
leerText="Diesem Projekt ist noch keine Aufgabe zugeordnet."
/>
<Card>
<CardKopf>
<CardTitel className="text-base">Projekt bearbeiten</CardTitel>
</CardKopf>
<CardInhalt>
<ProjektFormular
projekt={{
id: projekt.id,
name: projekt.name,
beschreibung: projekt.beschreibung,
status: projekt.status,
bereichId: projekt.bereichId,
zielWert: projekt.zielAm ? alsTagesschluessel(projekt.zielAm) : '',
}}
bereiche={bereiche}
/>
</CardInhalt>
</Card>
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { ProjektFormular } from '@/app/(app)/projekte/projekt-formular'
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import { PROJEKT_STATUS_NAME, aufwandSumme, fortschrittBerechnen } from '@/lib/aufgabe'
import { alsDatum, alsDauer } from '@/lib/zeit'
import { bereicheLaden } from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
export const metadata: Metadata = { title: 'Projekte' }
export default async function ProjekteSeite() {
await verlangeAnmeldung('/projekte')
const [projekte, bereiche] = await Promise.all([
db.projekt.findMany({
orderBy: [{ status: 'asc' }, { name: 'asc' }],
select: {
id: true,
name: true,
beschreibung: true,
status: true,
zielAm: true,
bereich: { select: { id: true, name: true, farbe: true } },
aufgaben: { select: { status: true, aufwandMinuten: true } },
},
}),
bereicheLaden(),
])
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Projekte</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
Eine Klammer um mehrere Aufgaben. Aufwand und Fortschritt ergeben sich aus
dem, was drinsteckt.
</p>
</div>
<ProjektFormular bereiche={bereiche} einklappbar />
</header>
{projekte.length === 0 ? (
<Card>
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
Noch kein Projekt angelegt.
</CardInhalt>
</Card>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{projekte.map((projekt) => {
const fortschritt = fortschrittBerechnen(projekt.aufgaben)
const offenerAufwand = aufwandSumme(
projekt.aufgaben.filter(
(a) => a.status !== 'ERLEDIGT' && a.status !== 'VERWORFEN',
),
)
return (
<li key={projekt.id}>
<Card className="h-full">
<CardKopf>
<div className="flex flex-wrap items-center gap-2">
{projekt.bereich && (
<span
aria-hidden
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: projekt.bereich.farbe }}
/>
)}
<CardTitel className="text-base">
<Link
href={`/projekte/${projekt.id}`}
className="hover:underline hover:underline-offset-4"
>
{projekt.name}
</Link>
</CardTitel>
{projekt.status !== 'AKTIV' && (
<Kennzeichen>{PROJEKT_STATUS_NAME[projekt.status]}</Kennzeichen>
)}
</div>
{projekt.beschreibung && (
<p className="text-gedaempft-vordergrund line-clamp-2 text-sm">
{projekt.beschreibung}
</p>
)}
</CardKopf>
<CardInhalt className="flex flex-col gap-3">
<Fortschrittsbalken
prozent={fortschritt.prozent}
beschriftung={`Fortschritt von ${projekt.name}`}
/>
<p className="text-gedaempft-vordergrund text-xs">
{fortschritt.gesamt === 0
? 'Noch keine Aufgaben zugeordnet.'
: `${fortschritt.erledigt} von ${fortschritt.gesamt} erledigt (${fortschritt.prozent} %)`}
{offenerAufwand > 0 && ` · noch ${alsDauer(offenerAufwand)}`}
{projekt.zielAm && ` · Ziel ${alsDatum(projekt.zielAm)}`}
</p>
</CardInhalt>
</Card>
</li>
)
})}
</ul>
)}
</div>
)
}
+234
View File
@@ -0,0 +1,234 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2, Plus, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { PROJEKT_STATUS_NAME } from '@/lib/aufgabe'
import {
projektAendern,
projektAnlegen,
projektLoeschen,
} from '@/server/aktionen/projekte'
export type ProjektWerte = {
id: string
name: string
beschreibung: string | null
status: 'AKTIV' | 'PAUSIERT' | 'ABGESCHLOSSEN' | 'VERWORFEN'
bereichId: string | null
zielWert: string
}
const STATUS = ['AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN'] as const
type Eigenschaften = {
projekt?: ProjektWerte
bereiche: { id: string; name: string }[]
/** Beim Anlegen: Formular erst auf Klick zeigen. */
einklappbar?: boolean
onFertig?: () => void
}
export function ProjektFormular({
projekt,
bereiche,
einklappbar = false,
onFertig,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [felder, setFelder] = useState<Record<string, string>>({})
const [offen, setOffen] = useState(!einklappbar)
const [loeschenBestaetigt, setLoeschenBestaetigt] = useState(false)
function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
ereignis.preventDefault()
const formular = ereignis.currentTarget
const daten = new FormData(formular)
starte(async () => {
const antwort = projekt
? await projektAendern(projekt.id, daten)
: await projektAnlegen(daten)
if (antwort.ok) {
setFelder({})
toast.success(antwort.meldung)
if (!projekt) {
formular.reset()
if (einklappbar) setOffen(false)
}
router.refresh()
onFertig?.()
} else {
setFelder(antwort.felder ?? {})
toast.error(antwort.fehler)
}
})
}
function loeschen() {
if (!projekt) return
if (!loeschenBestaetigt) {
setLoeschenBestaetigt(true)
return
}
const daten = new FormData()
daten.set('id', projekt.id)
starte(async () => {
const antwort = await projektLoeschen(daten)
if (antwort.ok) {
toast.success(antwort.meldung)
router.push('/projekte')
router.refresh()
} else {
toast.error(antwort.fehler)
}
})
}
if (einklappbar && !offen) {
return (
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
Neues Projekt
</Button>
)
}
const kennung = projekt?.id ?? 'neu'
const formular = (
<form onSubmit={absenden} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={`name-${kennung}`}>Name</Label>
<Input
id={`name-${kennung}`}
name="name"
defaultValue={projekt?.name ?? ''}
required
maxLength={80}
aria-invalid={Boolean(felder.name)}
/>
{felder.name && <p className="text-gefahr text-xs">{felder.name}</p>}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`beschreibung-${kennung}`}>Beschreibung</Label>
<Textarea
id={`beschreibung-${kennung}`}
name="beschreibung"
defaultValue={projekt?.beschreibung ?? ''}
maxLength={2000}
placeholder="Worum geht es, und woran merkst du, dass es fertig ist?"
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label htmlFor={`status-${kennung}`}>Status</Label>
<Select
id={`status-${kennung}`}
name="status"
defaultValue={projekt?.status ?? 'AKTIV'}
>
{STATUS.map((status) => (
<option key={status} value={status}>
{PROJEKT_STATUS_NAME[status]}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`bereich-${kennung}`}>Bereich</Label>
<Select
id={`bereich-${kennung}`}
name="bereichId"
defaultValue={projekt?.bereichId ?? ''}
>
<option value="">Kein Bereich</option>
{bereiche.map((bereich) => (
<option key={bereich.id} value={bereich.id}>
{bereich.name}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`ziel-${kennung}`}>Zieltermin</Label>
<Input
id={`ziel-${kennung}`}
name="zielAm"
type="date"
defaultValue={projekt?.zielWert ?? ''}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
{projekt ? 'Speichern' : 'Projekt anlegen'}
</Button>
{(onFertig ?? einklappbar) && (
<Button
type="button"
art="leise"
disabled={laeuft}
onClick={() => {
if (einklappbar) setOffen(false)
onFertig?.()
}}
>
Abbrechen
</Button>
)}
{projekt && (
<Button
type="button"
art="gefahr"
className="ml-auto"
disabled={laeuft}
onClick={loeschen}
onBlur={() => setLoeschenBestaetigt(false)}
>
<Trash2 aria-hidden />
{loeschenBestaetigt ? 'Wirklich löschen?' : 'Löschen'}
</Button>
)}
</div>
{projekt && loeschenBestaetigt && (
<p className="text-gedaempft-vordergrund text-xs">
Die enthaltenen Aufgaben bleiben erhalten und verlieren nur ihre Zuordnung.
</p>
)}
</form>
)
if (!einklappbar) return formular
return (
<Card className="w-full">
<CardKopf className="flex-row items-center justify-between">
<CardTitel className="text-base">Neues Projekt</CardTitel>
<Button art="leise" groesse="quadrat" onClick={() => setOffen(false)}>
<X aria-hidden />
<span className="sr-only">Schließen</span>
</Button>
</CardKopf>
<CardInhalt>{formular}</CardInhalt>
</Card>
)
}
@@ -0,0 +1,136 @@
import { NextResponse } from 'next/server'
import { notFound } from 'next/navigation'
import { auditSchreiben } from '@/server/audit'
import { db } from '@/server/db'
import { erzwingeFaehigkeit } from '@/server/instanz'
import { kontoAbgleichen } from '@/server/kalender/abgleich'
import { codeEinloesen } from '@/server/kalender/oauth'
import {
alsAnbieter,
faehigkeitFuer,
istAnbieter,
zustandLesen,
} from '@/server/kalender/verbinden'
import { verschluesseln } from '@/server/krypto'
import { umgebung } from '@/server/umgebung'
export const dynamic = 'force-dynamic'
function zurueck(nachricht: string, art: 'ok' | 'fehler'): NextResponse {
const ziel = new URL('/kalenderkonten', umgebung().APP_URL)
ziel.searchParams.set(art, nachricht)
return NextResponse.redirect(ziel)
}
/**
* Nimmt die Rückleitung des Anbieters entgegen und legt das Kalenderkonto an.
*
* Der Zustandswert ist signiert und trägt die Benutzerkennung. Ohne diese
* Prüfung ließe sich ein fremdes Kalenderkonto an ein beliebiges Konto hängen.
*/
export async function GET(
anfrage: Request,
{ params }: { params: Promise<{ anbieter: string }> },
) {
const { anbieter: roh } = await params
if (!istAnbieter(roh)) notFound()
const anbieter = alsAnbieter(roh)
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
const adresse = new URL(anfrage.url)
const fehlerAnbieter = adresse.searchParams.get('error')
if (fehlerAnbieter) {
// Meist "access_denied", weil die Zustimmung abgebrochen wurde.
return zurueck('Die Verbindung wurde abgebrochen.', 'fehler')
}
const zustand = zustandLesen(adresse.searchParams.get('state'))
if (!zustand || zustand.anbieter !== anbieter) {
return zurueck('Die Anfrage war ungültig oder zu alt. Bitte neu beginnen.', 'fehler')
}
const code = adresse.searchParams.get('code')
if (!code) {
return zurueck('Der Anbieter hat keinen Code zurückgegeben.', 'fehler')
}
const benutzer = await db.benutzer.findUnique({
where: { id: zustand.benutzerId },
select: { id: true, email: true, aktiv: true },
})
if (!benutzer?.aktiv) {
return zurueck('Das Konto ist nicht mehr aktiv.', 'fehler')
}
try {
const satz = await codeEinloesen(anbieter, code)
if (!satz.aktualisierungstoken) {
// Ohne das wäre die Verbindung nach einer Stunde tot.
return zurueck(
'Der Anbieter hat kein Aktualisierungstoken herausgegeben. Bitte die Zustimmung erneut erteilen.',
'fehler',
)
}
const extKontoId = satz.kennung ?? `${anbieter.toLowerCase()}-konto`
const kalenderId = anbieter === 'GOOGLE' ? 'primary' : 'standard'
const konto = await db.kalenderkonto.upsert({
where: {
benutzerId_anbieter_extKontoId_kalenderId: {
benutzerId: benutzer.id,
anbieter,
extKontoId,
kalenderId,
},
},
update: {
zugriffstoken: verschluesseln(satz.zugriffstoken),
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
tokenGueltigBis: satz.gueltigBis,
bereiche: satz.bereiche,
aktiv: true,
letzterFehler: null,
// Neu verbunden heißt: von vorn abgleichen.
deltaVerweis: null,
},
create: {
benutzerId: benutzer.id,
anbieter,
anzeigename:
satz.kennung ?? (anbieter === 'GRAPH' ? 'Microsoft 365' : 'Google Kalender'),
extKontoId,
kalenderId,
zugriffstoken: verschluesseln(satz.zugriffstoken),
aktualisierungstoken: verschluesseln(satz.aktualisierungstoken),
tokenGueltigBis: satz.gueltigBis,
bereiche: satz.bereiche,
},
select: { id: true, anzeigename: true },
})
await auditSchreiben({
akteur: { id: benutzer.id, email: benutzer.email },
aktion: 'kalenderkonto.verbunden',
objektTyp: 'Kalenderkonto',
objektId: konto.id,
// Weder Tokens noch Termininhalte – nur, welches Konto verbunden wurde.
details: { anbieter, anzeigename: konto.anzeigename },
})
const bericht = await kontoAbgleichen(konto.id)
return zurueck(
bericht.ok
? `„${konto.anzeigename}" verbunden, ${bericht.geaendert} Termin(e) gelesen.`
: `„${konto.anzeigename}" verbunden, der erste Abgleich schlug aber fehl.`,
bericht.ok ? 'ok' : 'fehler',
)
} catch (fehler) {
const meldung =
fehler instanceof Error ? fehler.message : 'Die Verbindung ist fehlgeschlagen.'
return zurueck(meldung, 'fehler')
}
}
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server'
import { notFound } from 'next/navigation'
import { autorisierungsAdresse } from '@/server/kalender/oauth'
import {
alsAnbieter,
faehigkeitFuer,
istAnbieter,
zustandErzeugen,
} from '@/server/kalender/verbinden'
import { angemeldet } from '@/server/berechtigung'
import { erzwingeFaehigkeit } from '@/server/instanz'
export const dynamic = 'force-dynamic'
/**
* Startet das Verbinden eines Kalenderkontos und leitet zum Anbieter weiter.
*
* Der Riegel sitzt hier serverseitig: Google gibt es nur daheim. In der
* Firmeninstanz liefert diese Route 404 — nicht etwa einen Hinweis, dass es
* die Möglichkeit anderswo gäbe.
*/
export async function GET(
_anfrage: Request,
{ params }: { params: Promise<{ anbieter: string }> },
) {
const { anbieter: roh } = await params
if (!istAnbieter(roh)) notFound()
const anbieter = alsAnbieter(roh)
erzwingeFaehigkeit(faehigkeitFuer(anbieter))
const benutzer = await angemeldet()
if (!benutzer) {
return NextResponse.redirect(
new URL('/anmelden?weiter=%2Fkalenderkonten', process.env.APP_URL ?? ''),
)
}
try {
const zustand = zustandErzeugen(anbieter, benutzer.id)
return NextResponse.redirect(autorisierungsAdresse(anbieter, zustand))
} catch (fehler) {
const meldung =
fehler instanceof Error ? fehler.message : 'Der Anbieter ist nicht eingerichtet.'
return NextResponse.redirect(
new URL(
`/kalenderkonten?fehler=${encodeURIComponent(meldung)}`,
process.env.APP_URL ?? '',
),
)
}
}
@@ -0,0 +1,233 @@
'use client'
import { useTransition, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
AUFWAND_MAX_MINUTEN,
PRIORITAET_NAME,
STATUS_NAME,
TYP_NAME,
moeglicheUebergaenge,
} from '@/lib/aufgabe'
import type { AufgabeAnzeige } from '@/lib/aufgabe-anzeige'
import { aufgabeAendern, aufgabeAnlegen } from '@/server/aktionen/aufgaben'
export type BereichWahl = { id: string; name: string }
export type ProjektWahl = { id: string; name: string }
type Eigenschaften = {
/** Fehlt sie, wird eine neue Aufgabe angelegt. */
aufgabe?: AufgabeAnzeige
bereiche: BereichWahl[]
projekte: ProjektWahl[]
/** Vorbelegung beim Anlegen. */
vorgabe?: { bereichId?: string; projektId?: string; typ?: string }
onFertig?: () => void
}
const TYPEN = ['AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN'] as const
export function AufgabeFormular({
aufgabe,
bereiche,
projekte,
vorgabe,
onFertig,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [felder, setFelder] = useState<Record<string, string>>({})
// Aus einem abgeschlossenen Zustand geht es nur zurück nach OFFEN. Die
// Auswahl zeigt deshalb nur, was von hier aus wirklich möglich ist.
const statusAuswahl = aufgabe
? [aufgabe.status, ...moeglicheUebergaenge(aufgabe.status)]
: (['OFFEN', 'GEPLANT', 'WARTET'] as const)
function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
ereignis.preventDefault()
const formular = ereignis.currentTarget
const daten = new FormData(formular)
starte(async () => {
const antwort = aufgabe
? await aufgabeAendern(aufgabe.id, daten)
: await aufgabeAnlegen(daten)
if (antwort.ok) {
setFelder({})
toast.success(antwort.meldung)
if (!aufgabe) formular.reset()
router.refresh()
onFertig?.()
} else {
setFelder(antwort.felder ?? {})
toast.error(antwort.fehler)
}
})
}
const kennung = aufgabe?.id ?? 'neu'
return (
<form onSubmit={absenden} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={`titel-${kennung}`}>Titel</Label>
<Input
id={`titel-${kennung}`}
name="titel"
defaultValue={aufgabe?.titel ?? ''}
required
maxLength={200}
aria-invalid={Boolean(felder.titel)}
/>
{felder.titel && <p className="text-gefahr text-xs">{felder.titel}</p>}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`notiz-${kennung}`}>Notiz</Label>
<Textarea
id={`notiz-${kennung}`}
name="notiz"
defaultValue={aufgabe?.notiz ?? ''}
maxLength={5000}
placeholder="Was dazugehört, aber nicht in den Titel passt."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor={`typ-${kennung}`}>Art</Label>
<Select
id={`typ-${kennung}`}
name="typ"
defaultValue={aufgabe?.typ ?? vorgabe?.typ ?? 'AUFGABE'}
>
{TYPEN.map((typ) => (
<option key={typ} value={typ}>
{TYP_NAME[typ]}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`status-${kennung}`}>Status</Label>
<Select
id={`status-${kennung}`}
name="status"
defaultValue={aufgabe?.status ?? 'OFFEN'}
aria-invalid={Boolean(felder.status)}
>
{[...new Set(statusAuswahl)].map((status) => (
<option key={status} value={status}>
{STATUS_NAME[status]}
</option>
))}
</Select>
{felder.status && <p className="text-gefahr text-xs">{felder.status}</p>}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`prioritaet-${kennung}`}>Priorität</Label>
<Select
id={`prioritaet-${kennung}`}
name="prioritaet"
defaultValue={String(aufgabe?.prioritaet ?? 3)}
>
{[1, 2, 3, 4, 5].map((stufe) => (
<option key={stufe} value={stufe}>
{stufe} – {PRIORITAET_NAME[stufe]}
</option>
))}
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`aufwand-${kennung}`}>Aufwand in Minuten</Label>
<Input
id={`aufwand-${kennung}`}
name="aufwandMinuten"
type="number"
min={1}
max={AUFWAND_MAX_MINUTEN}
step={15}
defaultValue={aufgabe?.aufwandMinuten ?? ''}
placeholder="noch nicht geschätzt"
aria-invalid={Boolean(felder.aufwandMinuten)}
/>
{felder.aufwandMinuten && (
<p className="text-gefahr text-xs">{felder.aufwandMinuten}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`faellig-${kennung}`}>Fällig am</Label>
<Input
id={`faellig-${kennung}`}
name="faelligAm"
type="date"
defaultValue={aufgabe?.faelligWert ?? ''}
aria-invalid={Boolean(felder.faelligAm)}
/>
{felder.faelligAm && <p className="text-gefahr text-xs">{felder.faelligAm}</p>}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`bereich-${kennung}`}>Bereich</Label>
<Select
id={`bereich-${kennung}`}
name="bereichId"
defaultValue={aufgabe?.bereich?.id ?? vorgabe?.bereichId ?? ''}
aria-invalid={Boolean(felder.bereichId)}
>
<option value="">Inbox – noch nicht einsortiert</option>
{bereiche.map((bereich) => (
<option key={bereich.id} value={bereich.id}>
{bereich.name}
</option>
))}
</Select>
{felder.bereichId && <p className="text-gefahr text-xs">{felder.bereichId}</p>}
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor={`projekt-${kennung}`}>Projekt</Label>
<Select
id={`projekt-${kennung}`}
name="projektId"
defaultValue={aufgabe?.projekt?.id ?? vorgabe?.projektId ?? ''}
aria-invalid={Boolean(felder.projektId)}
>
<option value="">Kein Projekt</option>
{projekte.map((projekt) => (
<option key={projekt.id} value={projekt.id}>
{projekt.name}
</option>
))}
</Select>
{felder.projektId && <p className="text-gefahr text-xs">{felder.projektId}</p>}
</div>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
{aufgabe ? 'Speichern' : 'Aufgabe anlegen'}
</Button>
{onFertig && (
<Button type="button" art="leise" onClick={onFertig} disabled={laeuft}>
Abbrechen
</Button>
)}
</div>
</form>
)
}
@@ -0,0 +1,73 @@
'use client'
import { useState } from 'react'
import { AufgabeFormular } from '@/components/aufgaben/aufgabe-formular'
import { AufgabenZeile } from '@/components/aufgaben/aufgaben-zeile'
import { Card, CardInhalt } from '@/components/ui/card'
import type { AufgabeAnzeige } from '@/lib/aufgabe-anzeige'
type Eigenschaften = {
aufgaben: AufgabeAnzeige[]
bereiche: { id: string; name: string; farbe: string }[]
projekte: { id: string; name: string }[]
/** Text, wenn nichts da ist. */
leerText: string
mitEinsortieren?: boolean
mitUebernehmen?: boolean
/** Bearbeiten direkt in der Liste erlauben. */
mitBearbeiten?: boolean
}
export function AufgabenListe({
aufgaben,
bereiche,
projekte,
leerText,
mitEinsortieren = false,
mitUebernehmen = false,
mitBearbeiten = true,
}: Eigenschaften) {
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
if (aufgaben.length === 0) {
return (
<Card>
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
{leerText}
</CardInhalt>
</Card>
)
}
return (
<Card>
<CardInhalt className="py-0">
<ul>
{aufgaben.map((aufgabe) =>
bearbeitet === aufgabe.id ? (
<li key={aufgabe.id} className="border-rand border-b py-4 last:border-b-0">
<AufgabeFormular
aufgabe={aufgabe}
bereiche={bereiche}
projekte={projekte}
onFertig={() => setBearbeitet(null)}
/>
</li>
) : (
<AufgabenZeile
key={aufgabe.id}
aufgabe={aufgabe}
bereiche={bereiche}
mitEinsortieren={mitEinsortieren}
mitUebernehmen={mitUebernehmen}
{...(mitBearbeiten
? { onBearbeiten: () => setBearbeitet(aufgabe.id) }
: {})}
/>
),
)}
</ul>
</CardInhalt>
</Card>
)
}
+263
View File
@@ -0,0 +1,263 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Check, ExternalLink, Loader2, Pencil, RotateCcw, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import { Select } from '@/components/ui/select'
import {
PRIORITAET_NAME,
QUELLE_NAME,
STATUS_NAME,
TYP_NAME,
istAbgeschlossen,
} from '@/lib/aufgabe'
import { faelligkeitFarbe, type AufgabeAnzeige } from '@/lib/aufgabe-anzeige'
import { alsDauer } from '@/lib/zeit'
import { cn } from '@/lib/utils'
import {
aufgabeLoeschen,
einsortieren,
ideeUebernehmen,
statusSetzen,
type Rueckmeldung,
} from '@/server/aktionen/aufgaben'
export type BereichWahl = { id: string; name: string; farbe: string }
type Eigenschaften = {
aufgabe: AufgabeAnzeige
/** Bereiche zur Auswahl; nur nötig, wenn einsortiert werden soll. */
bereiche?: BereichWahl[]
/** Zeigt das Auswahlfeld zum Einsortieren (Inbox). */
mitEinsortieren?: boolean
/** Zeigt den Knopf "Zur Aufgabe machen" (Ideen). */
mitUebernehmen?: boolean
onBearbeiten?: (aufgabe: AufgabeAnzeige) => void
}
export function AufgabenZeile({
aufgabe,
bereiche = [],
mitEinsortieren = false,
mitUebernehmen = false,
onBearbeiten,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [loeschenBestaetigt, setLoeschenBestaetigt] = useState(false)
const abgeschlossen = istAbgeschlossen(aufgabe.status)
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
toast.success(antwort.meldung)
router.refresh()
} else {
toast.error(antwort.fehler)
}
})
}
function statusWechseln(neu: string) {
const daten = new FormData()
daten.set('id', aufgabe.id)
daten.set('status', neu)
mitRueckmeldung(() => statusSetzen(daten))
}
function inBereich(bereichId: string) {
if (!bereichId) return
const daten = new FormData()
daten.set('id', aufgabe.id)
daten.set('bereichId', bereichId)
mitRueckmeldung(() => einsortieren(daten))
}
function loeschen() {
if (!loeschenBestaetigt) {
setLoeschenBestaetigt(true)
return
}
const daten = new FormData()
daten.set('id', aufgabe.id)
mitRueckmeldung(() => aufgabeLoeschen(daten))
}
function uebernehmen() {
const daten = new FormData()
daten.set('id', aufgabe.id)
mitRueckmeldung(() => ideeUebernehmen(daten))
}
return (
<li
className={cn(
'border-rand flex flex-col gap-2 border-b py-3 last:border-b-0',
abgeschlossen && 'opacity-60',
)}
>
<div className="flex items-start gap-3">
<Button
art="leise"
groesse="quadrat"
className="mt-0.5 shrink-0"
title={abgeschlossen ? 'Wieder öffnen' : 'Als erledigt abhaken'}
disabled={laeuft}
onClick={() => statusWechseln(abgeschlossen ? 'OFFEN' : 'ERLEDIGT')}
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : abgeschlossen ? (
<RotateCcw aria-hidden />
) : (
<Check aria-hidden />
)}
<span className="sr-only">
{abgeschlossen ? 'Wieder öffnen' : 'Als erledigt abhaken'}
</span>
</Button>
<div className="min-w-0 flex-1">
<p className={cn('text-sm font-medium', abgeschlossen && 'line-through')}>
{aufgabe.titel}
</p>
{aufgabe.notiz && (
<p className="text-gedaempft-vordergrund mt-0.5 line-clamp-2 text-xs">
{aufgabe.notiz}
</p>
)}
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{aufgabe.bereich ? (
<Kennzeichen>
<span
aria-hidden
className="size-2 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: aufgabe.bereich.farbe }}
/>
{aufgabe.bereich.name}
</Kennzeichen>
) : (
<Kennzeichen art="betont">Inbox</Kennzeichen>
)}
{aufgabe.typ !== 'AUFGABE' && (
<Kennzeichen>{TYP_NAME[aufgabe.typ]}</Kennzeichen>
)}
{aufgabe.faelligDatum && (
<Kennzeichen art={faelligkeitFarbe(aufgabe)} title={aufgabe.faelligDatum}>
{aufgabe.faelligText}
</Kennzeichen>
)}
{aufgabe.aufwandMinuten !== null && (
<Kennzeichen>{alsDauer(aufgabe.aufwandMinuten)}</Kennzeichen>
)}
{aufgabe.prioritaet <= 2 && (
<Kennzeichen art="warnung">
{PRIORITAET_NAME[aufgabe.prioritaet]}
</Kennzeichen>
)}
{aufgabe.status !== 'OFFEN' && (
<Kennzeichen>{STATUS_NAME[aufgabe.status]}</Kennzeichen>
)}
{aufgabe.projekt && (
<Link
href={`/projekte/${aufgabe.projekt.id}`}
className="text-betont text-xs underline underline-offset-4"
>
{aufgabe.projekt.name}
</Link>
)}
{aufgabe.quelle !== 'MANUELL' &&
(aufgabe.quelleUrl ? (
<a
href={aufgabe.quelleUrl}
target="_blank"
rel="noreferrer noopener"
className="text-gedaempft-vordergrund inline-flex items-center gap-1 text-xs underline underline-offset-4"
>
{QUELLE_NAME[aufgabe.quelle]}
<ExternalLink className="size-3" aria-hidden />
</a>
) : (
<Kennzeichen>{QUELLE_NAME[aufgabe.quelle]}</Kennzeichen>
))}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{onBearbeiten && (
<Button
art="leise"
groesse="quadrat"
title="Bearbeiten"
disabled={laeuft}
onClick={() => onBearbeiten(aufgabe)}
>
<Pencil aria-hidden />
<span className="sr-only">Bearbeiten</span>
</Button>
)}
<Button
art={loeschenBestaetigt ? 'gefahr' : 'leise'}
groesse={loeschenBestaetigt ? 'klein' : 'quadrat'}
title={loeschenBestaetigt ? 'Wirklich löschen' : 'Löschen'}
disabled={laeuft}
onClick={loeschen}
onBlur={() => setLoeschenBestaetigt(false)}
>
<Trash2 aria-hidden />
{loeschenBestaetigt ? 'Wirklich?' : <span className="sr-only">Löschen</span>}
</Button>
</div>
</div>
{(mitEinsortieren || mitUebernehmen) && (
<div className="flex flex-wrap items-center gap-2 pl-12">
{mitEinsortieren && bereiche.length > 0 && (
<>
<label
className="text-gedaempft-vordergrund text-xs"
htmlFor={`bereich-${aufgabe.id}`}
>
Einsortieren nach
</label>
<Select
id={`bereich-${aufgabe.id}`}
className="h-8 w-44"
defaultValue=""
disabled={laeuft}
onChange={(e) => inBereich(e.target.value)}
>
<option value="">Bereich wählen …</option>
{bereiche.map((bereich) => (
<option key={bereich.id} value={bereich.id}>
{bereich.name}
</option>
))}
</Select>
</>
)}
{mitUebernehmen && (
<Button art="umriss" groesse="klein" disabled={laeuft} onClick={uebernehmen}>
Zur Aufgabe machen
</Button>
)}
</div>
)}
</li>
)
}
@@ -0,0 +1,72 @@
'use client'
import { useRef, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Lightbulb, Loader2, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { schnellErfassen } from '@/server/aktionen/aufgaben'
/**
* Ein Feld, ein Knopf, fertig.
*
* Alles Weitere — Bereich, Termin, Aufwand — kommt später beim Einsortieren.
* Wer beim Erfassen erst ein Formular ausfüllen muss, erfasst nichts.
*/
export function Schnellerfassung({ typ = 'AUFGABE' }: { typ?: 'AUFGABE' | 'IDEE' }) {
const router = useRouter()
const [titel, setTitel] = useState('')
const [laeuft, starte] = useTransition()
const feld = useRef<HTMLInputElement>(null)
function absenden(ereignis: React.FormEvent) {
ereignis.preventDefault()
const wert = titel.trim()
if (wert.length < 2) {
toast.error('Bitte mindestens zwei Zeichen eingeben.')
return
}
const daten = new FormData()
daten.set('titel', wert)
daten.set('typ', typ)
starte(async () => {
const antwort = await schnellErfassen(daten)
if (antwort.ok) {
setTitel('')
toast.success(antwort.meldung)
router.refresh()
// Gleich weiter erfassen können, ohne noch einmal zu klicken.
feld.current?.focus()
} else {
toast.error(antwort.fehler)
}
})
}
return (
<form onSubmit={absenden} className="flex gap-2">
<Input
ref={feld}
value={titel}
onChange={(e) => setTitel(e.target.value)}
placeholder={typ === 'IDEE' ? 'Idee notieren …' : 'Was ist zu tun?'}
maxLength={200}
disabled={laeuft}
aria-label={typ === 'IDEE' ? 'Neue Idee' : 'Neue Aufgabe'}
/>
<Button type="submit" disabled={laeuft} className="shrink-0">
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : typ === 'IDEE' ? (
<Lightbulb aria-hidden />
) : (
<Plus aria-hidden />
)}
Erfassen
</Button>
</form>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { cn } from '@/lib/utils'
/**
* Fortschritt als Balken. Der Wert steht zusätzlich als Text daneben — ein
* Balken allein ist für Menschen mit Sehschwäche und für Vorleseprogramme
* wertlos.
*/
export function Fortschrittsbalken({
prozent,
className,
beschriftung,
}: {
prozent: number
className?: string
beschriftung?: string
}) {
const wert = Math.min(100, Math.max(0, Math.round(prozent)))
return (
<div
role="progressbar"
aria-valuenow={wert}
aria-valuemin={0}
aria-valuemax={100}
{...(beschriftung ? { 'aria-label': beschriftung } : {})}
className={cn('bg-gedaempft h-2 w-full overflow-hidden rounded-full', className)}
>
<div
className={cn(
'h-full rounded-full transition-[width]',
wert === 100 ? 'bg-erfolg' : 'bg-betont',
)}
style={{ width: `${wert}%` }}
/>
</div>
)
}
+24 -8
View File
@@ -10,26 +10,34 @@ import { ROLLEN_NAME, istAdmin } from '@/lib/rollen'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean } type Eintrag = { pfad: string; name: string; nurAdmin?: boolean; zaehler?: number }
const EINTRAEGE: Eintrag[] = [
{ pfad: '/', name: 'Start' },
{ pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
]
export function Kopfleiste({ export function Kopfleiste({
name, name,
email, email,
rolle, rolle,
instanzName, instanzName,
inbox = 0,
}: { }: {
name: string name: string
email: string email: string
rolle: Rolle rolle: Rolle
instanzName: string instanzName: string
/** Anzahl noch nicht einsortierter Aufgaben. */
inbox?: number
}) { }) {
const EINTRAEGE: Eintrag[] = [
{ pfad: '/', name: 'Start' },
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
{ pfad: '/aufgaben', name: 'Aufgaben' },
{ pfad: '/projekte', name: 'Projekte' },
{ pfad: '/ideen', name: 'Ideen' },
{ pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/kalenderkonten', name: 'Kalender' },
{ pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
]
const pfad = usePathname() const pfad = usePathname()
const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle)) const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle))
@@ -68,6 +76,14 @@ export function Kopfleiste({
<Shield className="mr-1 inline size-3.5" aria-hidden /> <Shield className="mr-1 inline size-3.5" aria-hidden />
)} )}
{eintrag.name} {eintrag.name}
{eintrag.zaehler !== undefined && eintrag.zaehler > 0 && (
<span
className="bg-betont text-betont-vordergrund ml-1.5 inline-flex min-w-4 justify-center rounded-full px-1 text-[0.6875rem] leading-4 font-medium"
aria-label={`${eintrag.zaehler} offen`}
>
{eintrag.zaehler}
</span>
)}
</Link> </Link>
) )
})} })}
+28
View File
@@ -0,0 +1,28 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const kennzeichenStile = cva(
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap',
{
variants: {
art: {
neutral: 'bg-gedaempft text-gedaempft-vordergrund',
betont: 'bg-betont/10 text-betont',
erfolg: 'bg-erfolg/15 text-erfolg',
warnung: 'bg-warnung/20 text-warnung',
gefahr: 'bg-gefahr/15 text-gefahr',
},
},
defaultVariants: { art: 'neutral' },
},
)
export type KennzeichenEigenschaften = React.ComponentProps<'span'> &
VariantProps<typeof kennzeichenStile>
/** Kleine Auszeichnung für Status, Typ oder Fälligkeit. */
function Kennzeichen({ className, art, ...rest }: KennzeichenEigenschaften) {
return <span className={cn(kennzeichenStile({ art }), className)} {...rest} />
}
export { Kennzeichen }
+25
View File
@@ -0,0 +1,25 @@
import { cn } from '@/lib/utils'
/**
* Bewusst das native <select>: es bedient sich auf dem Handy mit dem
* Systemdialog, ist ohne Zusatzcode tastaturbedienbar und braucht kein
* JavaScript, damit ein Formular abgeschickt werden kann.
*/
function Select({ className, children, ...rest }: React.ComponentProps<'select'>) {
return (
<select
className={cn(
'border-eingabe bg-karte h-9 w-full rounded-md border px-3 text-sm shadow-xs transition-colors outline-none',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-2',
'aria-invalid:border-gefahr aria-invalid:ring-gefahr/30',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...rest}
>
{children}
</select>
)
}
export { Select }
+18
View File
@@ -0,0 +1,18 @@
import { cn } from '@/lib/utils'
function Textarea({ className, ...rest }: React.ComponentProps<'textarea'>) {
return (
<textarea
className={cn(
'border-eingabe bg-karte min-h-20 w-full rounded-md border px-3 py-2 text-sm shadow-xs transition-colors outline-none',
'placeholder:text-gedaempft-vordergrund',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-2',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...rest}
/>
)
}
export { Textarea }
+15
View File
@@ -0,0 +1,15 @@
/**
* Wird von Next einmal beim Hochfahren des Servers ausgeführt.
*
* Nur in der Node-Laufzeit: im Edge-Umfeld gibt es weder Prisma noch
* node-cron. Der Demo-Modus startet bewusst keine Jobs — die
* Screenshot-Instanz soll sich nicht selbst verändern, während sie
* abfotografiert wird.
*/
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME !== 'nodejs') return
if (process.env.DEMO_MODUS === 'true') return
const { jobsStarten } = await import('@/server/jobs')
jobsStarten()
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import { faelligkeitFarbe, fuerAnzeige, type AufgabeRoh } from './aufgabe-anzeige'
// Dienstag, 17. März 2026, 10:00 Uhr Berliner Zeit.
const jetzt = new Date('2026-03-17T09:00:00Z')
function roh(teil: Partial<AufgabeRoh> = {}): AufgabeRoh {
return {
id: 'a1',
titel: 'Angebot schreiben',
notiz: null,
typ: 'AUFGABE',
status: 'OFFEN',
prioritaet: 3,
aufwandMinuten: 60,
faelligAm: null,
quelle: 'MANUELL',
quelleUrl: null,
bereich: null,
projekt: null,
...teil,
}
}
describe('fuerAnzeige', () => {
it('lässt den Datumswert leer, wenn kein Termin gesetzt ist', () => {
const anzeige = fuerAnzeige(roh(), jetzt)
expect(anzeige.faelligWert).toBe('')
expect(anzeige.faelligDatum).toBeNull()
expect(anzeige.faelligText).toBe('ohne Termin')
expect(anzeige.ueberfaellig).toBe(false)
})
it('gibt den Datumswert im Format des Eingabefelds zurück', () => {
const anzeige = fuerAnzeige(
roh({ faelligAm: new Date('2026-03-20T12:00:00Z') }),
jetzt,
)
expect(anzeige.faelligWert).toBe('2026-03-20')
})
it('nimmt den Berliner Kalendertag, nicht den UTC-Tag', () => {
// 23:30 UTC am 19. ist in Berlin bereits der 20. um 00:30.
const anzeige = fuerAnzeige(
roh({ faelligAm: new Date('2026-03-19T23:30:00Z') }),
jetzt,
)
expect(anzeige.faelligWert).toBe('2026-03-20')
})
it('markiert eine offene Aufgabe von gestern als überfällig', () => {
const anzeige = fuerAnzeige(
roh({ faelligAm: new Date('2026-03-16T09:00:00Z') }),
jetzt,
)
expect(anzeige.ueberfaellig).toBe(true)
expect(anzeige.faelligText).toBe('seit gestern überfällig')
})
it('markiert eine erledigte Aufgabe von gestern nicht als überfällig', () => {
const anzeige = fuerAnzeige(
roh({ faelligAm: new Date('2026-03-16T09:00:00Z'), status: 'ERLEDIGT' }),
jetzt,
)
expect(anzeige.ueberfaellig).toBe(false)
})
it('schreibt das Datum ausgeschrieben für den Hinweis', () => {
const anzeige = fuerAnzeige(
roh({ faelligAm: new Date('2026-03-20T12:00:00Z') }),
jetzt,
)
expect(anzeige.faelligDatum).toBe('Fr., 20.03.2026')
})
it('reicht Bereich und Projekt unverändert durch', () => {
const anzeige = fuerAnzeige(
roh({
bereich: { id: 'b1', name: 'Gewerbe', farbe: '#7c3aed' },
projekt: { id: 'p1', name: 'Website neu' },
}),
jetzt,
)
expect(anzeige.bereich?.name).toBe('Gewerbe')
expect(anzeige.projekt?.name).toBe('Website neu')
})
})
describe('faelligkeitFarbe', () => {
it('warnt rot bei Überfälligkeit', () => {
const anzeige = fuerAnzeige(
roh({ faelligAm: new Date('2026-03-15T09:00:00Z') }),
jetzt,
)
expect(faelligkeitFarbe(anzeige)).toBe('gefahr')
})
it('warnt gelb für heute und morgen', () => {
expect(
faelligkeitFarbe(
fuerAnzeige(roh({ faelligAm: new Date('2026-03-17T15:00:00Z') }), jetzt),
),
).toBe('warnung')
expect(
faelligkeitFarbe(
fuerAnzeige(roh({ faelligAm: new Date('2026-03-18T15:00:00Z') }), jetzt),
),
).toBe('warnung')
})
it('bleibt bei allem Weiteren unauffällig', () => {
expect(
faelligkeitFarbe(
fuerAnzeige(roh({ faelligAm: new Date('2026-04-01T09:00:00Z') }), jetzt),
),
).toBe('neutral')
expect(faelligkeitFarbe(fuerAnzeige(roh(), jetzt))).toBe('neutral')
})
})
+96
View File
@@ -0,0 +1,96 @@
import type { AufgabenStatus, AufgabenTyp, Quelle } from '@/generated/prisma/enums'
import {
faelligkeitEinordnen,
faelligkeitText,
istUeberfaellig,
type Faelligkeit,
} from '@/lib/aufgabe'
import { alsDatum, alsTagesschluessel } from '@/lib/zeit'
/**
* Bereitet eine Aufgabe für die Anzeige auf.
*
* Reine Funktion mit dem Zeitpunkt als Parameter: Formatierung und
* Fälligkeitsrechnung passieren serverseitig in Europe/Berlin, damit im
* Browser nichts nachgerechnet wird und alle Listen gleich aussehen.
*/
export type AufgabeRoh = {
id: string
titel: string
notiz: string | null
typ: AufgabenTyp
status: AufgabenStatus
prioritaet: number
aufwandMinuten: number | null
faelligAm: Date | null
quelle: Quelle
quelleUrl: string | null
bereich: { id: string; name: string; farbe: string } | null
projekt: { id: string; name: string } | null
}
export type AufgabeAnzeige = {
id: string
titel: string
notiz: string | null
typ: AufgabenTyp
status: AufgabenStatus
prioritaet: number
aufwandMinuten: number | null
/** ISO-Datum für das Formularfeld, sonst leer. */
faelligWert: string
/** "heute fällig", "seit 3 Tagen überfällig", "ohne Termin". */
faelligText: string
/** Ausgeschriebenes Datum für den Titelhinweis, sonst null. */
faelligDatum: string | null
faelligkeit: Faelligkeit['art']
ueberfaellig: boolean
quelle: Quelle
quelleUrl: string | null
bereich: { id: string; name: string; farbe: string } | null
projekt: { id: string; name: string } | null
}
/**
* ISO-Datum in Europe/Berlin, passend für ein <input type="date">.
* Der Wert des Feldes ist ein reines Datum ohne Zeitzone, also der Berliner
* Kalendertag — dieselbe Rechnung wie überall sonst im Planer.
*/
function alsDatumswert(zeitpunkt: Date | null): string {
return zeitpunkt ? alsTagesschluessel(zeitpunkt) : ''
}
export function fuerAnzeige(aufgabe: AufgabeRoh, jetzt: Date): AufgabeAnzeige {
const faelligkeit = faelligkeitEinordnen(aufgabe.faelligAm, jetzt)
return {
id: aufgabe.id,
titel: aufgabe.titel,
notiz: aufgabe.notiz,
typ: aufgabe.typ,
status: aufgabe.status,
prioritaet: aufgabe.prioritaet,
aufwandMinuten: aufgabe.aufwandMinuten,
faelligWert: alsDatumswert(aufgabe.faelligAm),
faelligText: faelligkeitText(faelligkeit),
faelligDatum: aufgabe.faelligAm ? alsDatum(aufgabe.faelligAm) : null,
faelligkeit: faelligkeit.art,
ueberfaellig: istUeberfaellig(aufgabe.faelligAm, aufgabe.status, jetzt),
quelle: aufgabe.quelle,
quelleUrl: aufgabe.quelleUrl,
bereich: aufgabe.bereich,
projekt: aufgabe.projekt,
}
}
/** Wie dringend die Fälligkeit aussieht — steuert die Farbe des Kennzeichens. */
export function faelligkeitFarbe(
anzeige: AufgabeAnzeige,
): 'neutral' | 'warnung' | 'gefahr' {
if (anzeige.ueberfaellig) return 'gefahr'
if (anzeige.faelligkeit === 'heute' || anzeige.faelligkeit === 'morgen') {
return 'warnung'
}
return 'neutral'
}
+384
View File
@@ -0,0 +1,384 @@
import { describe, expect, it } from 'vitest'
import {
aufgabeSchema,
aufwandSumme,
faelligkeitEinordnen,
faelligkeitText,
fortschrittBerechnen,
istAbgeschlossen,
istOffen,
istUeberfaellig,
moeglicheUebergaenge,
nachDringlichkeit,
projektSchema,
schnellerfassungSchema,
uebergangErlaubt,
type SortierbareAufgabe,
} from './aufgabe'
// Dienstag, 17. März 2026, 10:00 Uhr Berliner Zeit.
const jetzt = new Date('2026-03-17T09:00:00Z')
describe('istOffen und istAbgeschlossen', () => {
it('trennt die beiden Gruppen vollständig', () => {
for (const status of ['OFFEN', 'GEPLANT', 'WARTET'] as const) {
expect(istOffen(status)).toBe(true)
expect(istAbgeschlossen(status)).toBe(false)
}
for (const status of ['ERLEDIGT', 'VERWORFEN'] as const) {
expect(istOffen(status)).toBe(false)
expect(istAbgeschlossen(status)).toBe(true)
}
})
})
describe('uebergangErlaubt', () => {
it('lässt den Wechsel auf sich selbst zu', () => {
expect(uebergangErlaubt('OFFEN', 'OFFEN')).toBe(true)
})
it('lässt aus offenen Zuständen alles zu', () => {
expect(uebergangErlaubt('OFFEN', 'ERLEDIGT')).toBe(true)
expect(uebergangErlaubt('GEPLANT', 'WARTET')).toBe(true)
expect(uebergangErlaubt('WARTET', 'VERWORFEN')).toBe(true)
})
it('führt aus abgeschlossenen Zuständen nur zurück nach OFFEN', () => {
expect(uebergangErlaubt('ERLEDIGT', 'OFFEN')).toBe(true)
expect(uebergangErlaubt('ERLEDIGT', 'GEPLANT')).toBe(false)
expect(uebergangErlaubt('VERWORFEN', 'OFFEN')).toBe(true)
expect(uebergangErlaubt('VERWORFEN', 'ERLEDIGT')).toBe(false)
})
it('nennt für einen abgeschlossenen Zustand genau einen Ausweg', () => {
expect(moeglicheUebergaenge('ERLEDIGT')).toEqual(['OFFEN'])
})
})
describe('faelligkeitEinordnen', () => {
it('erkennt fehlenden Termin', () => {
expect(faelligkeitEinordnen(null, jetzt)).toEqual({ art: 'ohne' })
expect(faelligkeitEinordnen(undefined, jetzt)).toEqual({ art: 'ohne' })
})
it('erkennt heute, auch spät am Abend', () => {
expect(faelligkeitEinordnen(new Date('2026-03-17T22:00:00Z'), jetzt)).toEqual({
art: 'heute',
})
})
it('rechnet in Kalendertagen, nicht in 24-Stunden-Schritten', () => {
// 23:30 UTC am 17. ist in Berlin bereits der 18. um 00:30.
expect(faelligkeitEinordnen(new Date('2026-03-17T23:30:00Z'), jetzt)).toEqual({
art: 'morgen',
})
})
it('erkennt morgen', () => {
expect(faelligkeitEinordnen(new Date('2026-03-18T09:00:00Z'), jetzt)).toEqual({
art: 'morgen',
})
})
it('fasst die nächste Woche zusammen', () => {
expect(faelligkeitEinordnen(new Date('2026-03-22T09:00:00Z'), jetzt)).toEqual({
art: 'diese-woche',
tage: 5,
})
expect(faelligkeitEinordnen(new Date('2026-03-24T09:00:00Z'), jetzt)).toEqual({
art: 'diese-woche',
tage: 7,
})
})
it('schiebt alles darüber nach später', () => {
expect(faelligkeitEinordnen(new Date('2026-03-25T09:00:00Z'), jetzt)).toEqual({
art: 'spaeter',
tage: 8,
})
})
it('zählt Überfälligkeit in Tagen', () => {
expect(faelligkeitEinordnen(new Date('2026-03-16T09:00:00Z'), jetzt)).toEqual({
art: 'ueberfaellig',
tage: 1,
})
expect(faelligkeitEinordnen(new Date('2026-03-10T09:00:00Z'), jetzt)).toEqual({
art: 'ueberfaellig',
tage: 7,
})
})
})
describe('faelligkeitText', () => {
it('schreibt jeden Fall auf Deutsch', () => {
expect(faelligkeitText({ art: 'ohne' })).toBe('ohne Termin')
expect(faelligkeitText({ art: 'heute' })).toBe('heute fällig')
expect(faelligkeitText({ art: 'morgen' })).toBe('morgen fällig')
expect(faelligkeitText({ art: 'diese-woche', tage: 3 })).toBe('in 3 Tagen fällig')
expect(faelligkeitText({ art: 'spaeter', tage: 20 })).toBe('in 20 Tagen fällig')
})
it('sagt bei einem Tag Rückstand "seit gestern"', () => {
expect(faelligkeitText({ art: 'ueberfaellig', tage: 1 })).toBe(
'seit gestern überfällig',
)
expect(faelligkeitText({ art: 'ueberfaellig', tage: 4 })).toBe(
'seit 4 Tagen überfällig',
)
})
})
describe('istUeberfaellig', () => {
it('erkennt eine offene Aufgabe von gestern', () => {
expect(istUeberfaellig(new Date('2026-03-16T09:00:00Z'), 'OFFEN', jetzt)).toBe(true)
})
it('zählt den heutigen Tag nicht als überfällig', () => {
expect(istUeberfaellig(new Date('2026-03-17T06:00:00Z'), 'OFFEN', jetzt)).toBe(false)
})
it('lässt Erledigtes und Verworfenes in Ruhe', () => {
const gestern = new Date('2026-03-16T09:00:00Z')
expect(istUeberfaellig(gestern, 'ERLEDIGT', jetzt)).toBe(false)
expect(istUeberfaellig(gestern, 'VERWORFEN', jetzt)).toBe(false)
})
it('ist ohne Termin nie überfällig', () => {
expect(istUeberfaellig(null, 'OFFEN', jetzt)).toBe(false)
})
})
describe('nachDringlichkeit', () => {
function auf(teil: Partial<SortierbareAufgabe>): SortierbareAufgabe {
return {
status: 'OFFEN',
prioritaet: 3,
faelligAm: null,
titel: 'Titel',
...teil,
}
}
it('stellt Offenes vor Abgeschlossenes', () => {
expect(
nachDringlichkeit(auf({ status: 'ERLEDIGT' }), auf({ status: 'OFFEN' })),
).toBeGreaterThan(0)
})
it('stellt den früheren Termin nach vorn', () => {
const frueh = auf({ faelligAm: new Date('2026-03-18T00:00:00Z') })
const spaet = auf({ faelligAm: new Date('2026-03-20T00:00:00Z') })
expect(nachDringlichkeit(frueh, spaet)).toBeLessThan(0)
})
it('stellt Terminiertes vor Unterminiertes', () => {
const mitTermin = auf({ faelligAm: new Date('2026-04-01T00:00:00Z') })
const ohneTermin = auf({ faelligAm: null })
expect(nachDringlichkeit(mitTermin, ohneTermin)).toBeLessThan(0)
})
it('entscheidet bei gleichem Termin über die Priorität', () => {
const termin = new Date('2026-03-18T00:00:00Z')
expect(
nachDringlichkeit(
auf({ faelligAm: termin, prioritaet: 1 }),
auf({ faelligAm: termin, prioritaet: 4 }),
),
).toBeLessThan(0)
})
it('sortiert zuletzt nach dem Titel, mit deutscher Sortierung', () => {
expect(
nachDringlichkeit(auf({ titel: 'Äpfel' }), auf({ titel: 'Banane' })),
).toBeLessThan(0)
})
it('ergibt eine stabile Gesamtreihenfolge', () => {
const liste: SortierbareAufgabe[] = [
auf({ titel: 'Erledigt', status: 'ERLEDIGT' }),
auf({ titel: 'Ohne Termin' }),
auf({ titel: 'Übermorgen', faelligAm: new Date('2026-03-19T00:00:00Z') }),
auf({ titel: 'Morgen', faelligAm: new Date('2026-03-18T00:00:00Z') }),
]
expect([...liste].sort(nachDringlichkeit).map((a) => a.titel)).toEqual([
'Morgen',
'Übermorgen',
'Ohne Termin',
'Erledigt',
])
})
})
describe('fortschrittBerechnen', () => {
it('ist bei keiner Aufgabe null Prozent', () => {
expect(fortschrittBerechnen([])).toEqual({
gesamt: 0,
erledigt: 0,
offen: 0,
prozent: 0,
})
})
it('rechnet Erledigtes gegen die Gesamtzahl', () => {
expect(
fortschrittBerechnen([
{ status: 'ERLEDIGT' },
{ status: 'ERLEDIGT' },
{ status: 'OFFEN' },
{ status: 'GEPLANT' },
]),
).toEqual({ gesamt: 4, erledigt: 2, offen: 2, prozent: 50 })
})
it('lässt Verworfenes ganz aus der Rechnung', () => {
// Sonst sähe ein Projekt fertig aus, weil die Hälfte gestrichen wurde.
expect(
fortschrittBerechnen([
{ status: 'ERLEDIGT' },
{ status: 'VERWORFEN' },
{ status: 'OFFEN' },
]),
).toEqual({ gesamt: 2, erledigt: 1, offen: 1, prozent: 50 })
})
it('rundet auf ganze Prozent', () => {
expect(
fortschrittBerechnen([
{ status: 'ERLEDIGT' },
{ status: 'OFFEN' },
{ status: 'OFFEN' },
]).prozent,
).toBe(33)
})
})
describe('aufwandSumme', () => {
it('summiert und zählt Ungeschätztes als null', () => {
expect(
aufwandSumme([
{ aufwandMinuten: 30 },
{ aufwandMinuten: null },
{ aufwandMinuten: 45 },
]),
).toBe(75)
})
it('ist bei leerer Liste null', () => {
expect(aufwandSumme([])).toBe(0)
})
})
describe('aufgabeSchema', () => {
const gueltig = {
titel: 'Angebot schreiben',
notiz: '',
typ: 'AUFGABE',
status: 'OFFEN',
prioritaet: '2',
aufwandMinuten: '90',
faelligAm: '2026-03-20',
bereichId: '',
projektId: '',
}
it('nimmt ein vollständiges Formular an', () => {
const ergebnis = aufgabeSchema.parse(gueltig)
expect(ergebnis.titel).toBe('Angebot schreiben')
expect(ergebnis.prioritaet).toBe(2)
expect(ergebnis.aufwandMinuten).toBe(90)
expect(ergebnis.faelligAm).toBeInstanceOf(Date)
})
it('macht aus leeren Feldern null statt leerer Zeichenketten', () => {
const ergebnis = aufgabeSchema.parse(gueltig)
expect(ergebnis.notiz).toBeNull()
expect(ergebnis.bereichId).toBeNull()
expect(ergebnis.projektId).toBeNull()
})
it('lässt Aufwand und Termin weg, wenn nichts angegeben ist', () => {
const ergebnis = aufgabeSchema.parse({
...gueltig,
aufwandMinuten: '',
faelligAm: '',
})
expect(ergebnis.aufwandMinuten).toBeNull()
expect(ergebnis.faelligAm).toBeNull()
})
it('weist einen zu kurzen Titel ab', () => {
expect(aufgabeSchema.safeParse({ ...gueltig, titel: 'A' }).success).toBe(false)
})
it('hält die Priorität zwischen 1 und 5', () => {
expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '0' }).success).toBe(false)
expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '6' }).success).toBe(false)
expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '1' }).success).toBe(true)
expect(aufgabeSchema.safeParse({ ...gueltig, prioritaet: '5' }).success).toBe(true)
})
it('weist einen Aufwand über acht Stunden ab', () => {
// Genau acht Stunden sind noch erlaubt, eine Minute mehr nicht.
expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '480' }).success).toBe(
true,
)
expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '481' }).success).toBe(
false,
)
})
it('weist einen Aufwand von null oder weniger ab', () => {
expect(aufgabeSchema.safeParse({ ...gueltig, aufwandMinuten: '0' }).success).toBe(
false,
)
})
it('weist ein unsinniges Datum ab', () => {
expect(aufgabeSchema.safeParse({ ...gueltig, faelligAm: 'übermorgen' }).success).toBe(
false,
)
})
})
describe('schnellerfassungSchema', () => {
it('kommt mit einem Titel aus', () => {
expect(schnellerfassungSchema.parse({ titel: 'Rückruf Meier' })).toEqual({
titel: 'Rückruf Meier',
typ: 'AUFGABE',
})
})
it('kennt auch Ideen', () => {
expect(
schnellerfassungSchema.parse({ titel: 'Blog aufsetzen', typ: 'IDEE' }).typ,
).toBe('IDEE')
})
it('lässt keine anderen Typen zu', () => {
expect(
schnellerfassungSchema.safeParse({ titel: 'Etwas', typ: 'MEILENSTEIN' }).success,
).toBe(false)
})
})
describe('projektSchema', () => {
it('nimmt ein Projekt an', () => {
const ergebnis = projektSchema.parse({
name: 'Website neu',
beschreibung: '',
status: 'AKTIV',
bereichId: '',
zielAm: '',
})
expect(ergebnis.name).toBe('Website neu')
expect(ergebnis.beschreibung).toBeNull()
expect(ergebnis.zielAm).toBeNull()
})
it('weist einen zu kurzen Namen ab', () => {
expect(
projektSchema.safeParse({ name: 'X', beschreibung: '', status: 'AKTIV' }).success,
).toBe(false)
})
})
+290
View File
@@ -0,0 +1,290 @@
import { z } from 'zod'
import type {
AufgabenStatus,
AufgabenTyp,
ProjektStatus,
Quelle,
} from '@/generated/prisma/enums'
import { tageDazwischen, tagesBeginn } from '@/lib/zeit'
/**
* Regeln rund um Aufgaben. Reine Logik: keine Datenbank, keine Systemzeit —
* der Zeitpunkt kommt immer als Parameter herein.
*/
export const PRIORITAET_MIN = 1
export const PRIORITAET_MAX = 5
/** Ab hier ist eine Aufgabe zu groß für einen Zeitblock am Stück. */
export const AUFWAND_MAX_MINUTEN = 8 * 60
export const TYP_NAME: Record<AufgabenTyp, string> = {
AUFGABE: 'Aufgabe',
IDEE: 'Idee',
ARBEITSPAKET: 'Arbeitspaket',
MEILENSTEIN: 'Meilenstein',
SPIEGEL: 'Spiegel',
}
export const STATUS_NAME: Record<AufgabenStatus, string> = {
OFFEN: 'Offen',
GEPLANT: 'Geplant',
WARTET: 'Wartet',
ERLEDIGT: 'Erledigt',
VERWORFEN: 'Verworfen',
}
export const PROJEKT_STATUS_NAME: Record<ProjektStatus, string> = {
AKTIV: 'Aktiv',
PAUSIERT: 'Pausiert',
ABGESCHLOSSEN: 'Abgeschlossen',
VERWORFEN: 'Verworfen',
}
export const QUELLE_NAME: Record<Quelle, string> = {
MANUELL: 'Von Hand',
ZAMMAD: 'Zammad',
WARTUNGSPLANER: 'Wartungsplaner',
TELEGRAM: 'Telegram',
SPIEGEL: 'Spiegel',
MAIL: 'E-Mail',
API: 'API',
MCP: 'MCP',
}
export const PRIORITAET_NAME: Record<number, string> = {
1: 'Sofort',
2: 'Hoch',
3: 'Normal',
4: 'Niedrig',
5: 'Irgendwann',
}
/** Status, in denen eine Aufgabe noch Arbeit bedeutet. */
export const OFFENE_STATUS: readonly AufgabenStatus[] = ['OFFEN', 'GEPLANT', 'WARTET']
/** Status, in denen nichts mehr zu tun ist. */
export const ABGESCHLOSSENE_STATUS: readonly AufgabenStatus[] = ['ERLEDIGT', 'VERWORFEN']
export function istOffen(status: AufgabenStatus): boolean {
return OFFENE_STATUS.includes(status)
}
export function istAbgeschlossen(status: AufgabenStatus): boolean {
return ABGESCHLOSSENE_STATUS.includes(status)
}
/**
* Erlaubte Statuswechsel.
*
* Aus einem abgeschlossenen Zustand geht es nur zurueck nach OFFEN – wieder
* aufgenommen wird immer am Anfang, nicht mitten im Ablauf.
*/
const UEBERGAENGE: Record<AufgabenStatus, readonly AufgabenStatus[]> = {
OFFEN: ['GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN'],
GEPLANT: ['OFFEN', 'WARTET', 'ERLEDIGT', 'VERWORFEN'],
WARTET: ['OFFEN', 'GEPLANT', 'ERLEDIGT', 'VERWORFEN'],
ERLEDIGT: ['OFFEN'],
VERWORFEN: ['OFFEN'],
}
export function uebergangErlaubt(von: AufgabenStatus, nach: AufgabenStatus): boolean {
if (von === nach) return true
return UEBERGAENGE[von].includes(nach)
}
export function moeglicheUebergaenge(von: AufgabenStatus): readonly AufgabenStatus[] {
return UEBERGAENGE[von]
}
// --- Faelligkeit -----------------------------------------------------------
export type Faelligkeit =
| { art: 'ohne' }
| { art: 'ueberfaellig'; tage: number }
| { art: 'heute' }
| { art: 'morgen' }
| { art: 'diese-woche'; tage: number }
| { art: 'spaeter'; tage: number }
/**
* Ordnet die Faelligkeit relativ zum aktuellen Tag ein. Gerechnet wird in
* Kalendertagen in Europe/Berlin, nicht in 24-Stunden-Schritten: was heute um
* 23:00 faellig ist, ist heute faellig und nicht "in 0,5 Tagen".
*/
export function faelligkeitEinordnen(
faelligAm: Date | null | undefined,
jetzt: Date,
): Faelligkeit {
if (!faelligAm) return { art: 'ohne' }
const tage = tageDazwischen(jetzt, faelligAm)
if (tage < 0) return { art: 'ueberfaellig', tage: Math.abs(tage) }
if (tage === 0) return { art: 'heute' }
if (tage === 1) return { art: 'morgen' }
if (tage <= 7) return { art: 'diese-woche', tage }
return { art: 'spaeter', tage }
}
/** Kurztext fuer die Oberflaeche. */
export function faelligkeitText(faelligkeit: Faelligkeit): string {
switch (faelligkeit.art) {
case 'ohne':
return 'ohne Termin'
case 'ueberfaellig':
return faelligkeit.tage === 1
? 'seit gestern überfällig'
: `seit ${faelligkeit.tage} Tagen überfällig`
case 'heute':
return 'heute fällig'
case 'morgen':
return 'morgen fällig'
case 'diese-woche':
return `in ${faelligkeit.tage} Tagen fällig`
case 'spaeter':
return `in ${faelligkeit.tage} Tagen fällig`
}
}
export function istUeberfaellig(
faelligAm: Date | null | undefined,
status: AufgabenStatus,
jetzt: Date,
): boolean {
if (!faelligAm || istAbgeschlossen(status)) return false
return tagesBeginn(faelligAm).getTime() < tagesBeginn(jetzt).getTime()
}
// --- Sortierung ------------------------------------------------------------
export type SortierbareAufgabe = {
status: AufgabenStatus
prioritaet: number
faelligAm: Date | null
titel: string
}
/**
* Reihenfolge in der Liste: erst was noch offen ist, darin das Dringendste
* zuerst. Ohne Termin heisst nicht unwichtig – solche Aufgaben landen nach
* den terminierten, aber vor den abgeschlossenen.
*/
export function nachDringlichkeit(a: SortierbareAufgabe, b: SortierbareAufgabe): number {
const aOffen = istOffen(a.status)
const bOffen = istOffen(b.status)
if (aOffen !== bOffen) return aOffen ? -1 : 1
const aTermin = a.faelligAm?.getTime() ?? null
const bTermin = b.faelligAm?.getTime() ?? null
if (aTermin !== bTermin) {
if (aTermin === null) return 1
if (bTermin === null) return -1
return aTermin - bTermin
}
if (a.prioritaet !== b.prioritaet) return a.prioritaet - b.prioritaet
return a.titel.localeCompare(b.titel, 'de')
}
// --- Fortschritt eines Projekts -------------------------------------------
export type Fortschritt = {
gesamt: number
erledigt: number
offen: number
/** 0 bis 100, gerundet. Ohne Aufgaben null. */
prozent: number
}
export function fortschrittBerechnen(
aufgaben: readonly { status: AufgabenStatus }[],
): Fortschritt {
// Verworfenes zaehlt gar nicht mit – sonst sieht ein Projekt fertig aus,
// weil die Haelfte gestrichen wurde.
const zaehlende = aufgaben.filter((a) => a.status !== 'VERWORFEN')
const gesamt = zaehlende.length
const erledigt = zaehlende.filter((a) => a.status === 'ERLEDIGT').length
return {
gesamt,
erledigt,
offen: gesamt - erledigt,
prozent: gesamt === 0 ? 0 : Math.round((erledigt / gesamt) * 100),
}
}
/** Summe des geschaetzten Aufwands; nicht geschaetzte Aufgaben zaehlen null. */
export function aufwandSumme(
aufgaben: readonly { aufwandMinuten: number | null }[],
): number {
return aufgaben.reduce((summe, a) => summe + (a.aufwandMinuten ?? 0), 0)
}
// --- Eingaben --------------------------------------------------------------
const leerZuNull = (wert: unknown) =>
typeof wert === 'string' && wert.trim() === '' ? null : wert
export const aufgabeSchema = z.object({
titel: z
.string()
.trim()
.min(2, 'Mindestens zwei Zeichen.')
.max(200, 'Höchstens 200 Zeichen.'),
notiz: z.preprocess(leerZuNull, z.string().trim().max(5000).nullable().default(null)),
typ: z.enum(['AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN', 'SPIEGEL']),
status: z.enum(['OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN']),
prioritaet: z.coerce
.number()
.int()
.min(PRIORITAET_MIN, 'Priorität liegt zwischen 1 und 5.')
.max(PRIORITAET_MAX, 'Priorität liegt zwischen 1 und 5.'),
aufwandMinuten: z.preprocess(
leerZuNull,
z.coerce
.number()
.int('Bitte ganze Minuten angeben.')
.min(1, 'Mindestens eine Minute.')
.max(AUFWAND_MAX_MINUTEN, 'Mehr als acht Stunden gehört in mehrere Aufgaben.')
.nullable()
.default(null),
),
faelligAm: z.preprocess(
leerZuNull,
z.coerce.date('Bitte ein gültiges Datum angeben.').nullable().default(null),
),
bereichId: z.preprocess(leerZuNull, z.string().nullable().default(null)),
projektId: z.preprocess(leerZuNull, z.string().nullable().default(null)),
})
export type AufgabeEingabe = z.infer<typeof aufgabeSchema>
/** Schnellerfassung: nur ein Titel, alles andere bekommt Standardwerte. */
export const schnellerfassungSchema = z.object({
titel: z
.string()
.trim()
.min(2, 'Mindestens zwei Zeichen.')
.max(200, 'Höchstens 200 Zeichen.'),
typ: z.enum(['AUFGABE', 'IDEE']).default('AUFGABE'),
})
export const projektSchema = z.object({
name: z
.string()
.trim()
.min(2, 'Mindestens zwei Zeichen.')
.max(80, 'Höchstens 80 Zeichen.'),
beschreibung: z.preprocess(
leerZuNull,
z.string().trim().max(2000).nullable().default(null),
),
status: z.enum(['AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN']),
bereichId: z.preprocess(leerZuNull, z.string().nullable().default(null)),
zielAm: z.preprocess(
leerZuNull,
z.coerce.date('Bitte ein gültiges Datum angeben.').nullable().default(null),
),
})
export type ProjektEingabe = z.infer<typeof projektSchema>
+337
View File
@@ -0,0 +1,337 @@
import { describe, expect, it } from 'vitest'
import {
arbeitsfensterAmTag,
belegteMinuten,
belegtZeit,
freieFenster,
kapazitaetAmTag,
tagesBelegung,
termineAmTag,
ueberschneidungMinuten,
type BelegenderTermin,
type BereichKapazitaet,
} from './belegung'
// Dienstag, 17. März 2026. Berlin liegt dann noch auf UTC+1.
const dienstag = new Date('2026-03-17T09:00:00Z')
const bereich: BereichKapazitaet = {
kapazitaetMo: 360,
kapazitaetDi: 240,
kapazitaetMi: 360,
kapazitaetDo: 360,
kapazitaetFr: 120,
kapazitaetSa: 0,
kapazitaetSo: 0,
// 08:00 bis 17:00 Berliner Zeit.
arbeitsfensterVon: 480,
arbeitsfensterBis: 1020,
}
function termin(
vonUtc: string,
bisUtc: string,
teil: Partial<BelegenderTermin> = {},
): BelegenderTermin {
return {
beginn: new Date(vonUtc),
ende: new Date(bisUtc),
ganztaegig: false,
zeigtAls: 'GEBUCHT',
...teil,
}
}
describe('belegtZeit', () => {
it('zählt einen gewöhnlichen Termin', () => {
expect(belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'))).toBe(true)
})
it('zählt ganztägige Termine nicht', () => {
// Ein Urlaubstag oder Geburtstag kostet keine Arbeitszeit im Fenster.
expect(
belegtZeit(
termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true }),
),
).toBe(false)
})
it('zählt als frei markierte Termine nicht', () => {
expect(
belegtZeit(
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls: 'FREI' }),
),
).toBe(false)
})
it('zählt Vorbehalt und Abwesenheit sehr wohl', () => {
for (const zeigtAls of [
'MIT_VORBEHALT',
'ABWESEND',
'WOANDERS',
'UNBEKANNT',
] as const) {
expect(
belegtZeit(termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z', { zeigtAls })),
).toBe(true)
}
})
})
describe('ueberschneidungMinuten', () => {
const fenster = {
von: new Date('2026-03-17T08:00:00Z'),
bis: new Date('2026-03-17T12:00:00Z'),
}
it('rechnet eine vollständige Überschneidung', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T09:00:00Z'), bis: new Date('2026-03-17T10:00:00Z') },
fenster,
),
).toBe(60)
})
it('schneidet an den Rändern ab', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T07:00:00Z'), bis: new Date('2026-03-17T09:00:00Z') },
fenster,
),
).toBe(60)
})
it('ist ohne Überschneidung null', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T13:00:00Z'), bis: new Date('2026-03-17T14:00:00Z') },
fenster,
),
).toBe(0)
})
it('zählt reines Aneinandergrenzen nicht als Überschneidung', () => {
expect(
ueberschneidungMinuten(
{ von: new Date('2026-03-17T12:00:00Z'), bis: new Date('2026-03-17T13:00:00Z') },
fenster,
),
).toBe(0)
})
})
describe('arbeitsfensterAmTag', () => {
it('legt das Fenster in Berliner Ortszeit', () => {
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
// 08:00 Berlin = 07:00 UTC in der Winterzeit.
expect(fenster.von.toISOString()).toBe('2026-03-17T07:00:00.000Z')
expect(fenster.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z')
})
it('verschiebt sich mit der Sommerzeit', () => {
// Ab dem 29. März 2026 gilt in Berlin UTC+2.
const fenster = arbeitsfensterAmTag(new Date('2026-04-07T09:00:00Z'), 480, 1020)
expect(fenster.von.toISOString()).toBe('2026-04-07T06:00:00.000Z')
expect(fenster.bis.toISOString()).toBe('2026-04-07T15:00:00.000Z')
})
})
describe('kapazitaetAmTag', () => {
it('nimmt den richtigen Wochentag', () => {
expect(kapazitaetAmTag(bereich, dienstag)).toBe(240)
// Montag, 16. März.
expect(kapazitaetAmTag(bereich, new Date('2026-03-16T09:00:00Z'))).toBe(360)
// Sonntag, 22. März.
expect(kapazitaetAmTag(bereich, new Date('2026-03-22T09:00:00Z'))).toBe(0)
})
})
describe('belegteMinuten', () => {
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
it('ist ohne Termine null', () => {
expect(belegteMinuten([], fenster)).toBe(0)
})
it('summiert getrennte Termine', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z'),
],
fenster,
),
).toBe(120)
})
it('legt überlappende Termine zusammen, statt doppelt zu zählen', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z'),
termin('2026-03-17T09:00:00Z', '2026-03-17T11:00:00Z'),
],
fenster,
),
).toBe(180)
})
it('legt lückenlos aneinandergrenzende Termine zusammen', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'),
],
fenster,
),
).toBe(120)
})
it('zählt einen vollständig enthaltenen Termin nur einmal', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T12:00:00Z'),
termin('2026-03-17T09:00:00Z', '2026-03-17T10:00:00Z'),
],
fenster,
),
).toBe(240)
})
it('schneidet an den Rändern des Arbeitsfensters ab', () => {
// 06:00–08:00 UTC = 07:00–09:00 Berlin; nur die zweite Stunde zählt.
expect(
belegteMinuten([termin('2026-03-17T06:00:00Z', '2026-03-17T08:00:00Z')], fenster),
).toBe(60)
})
it('lässt Termine außerhalb des Fensters ganz weg', () => {
// 20:00 Berlin, das Fenster endet um 17:00.
expect(
belegteMinuten([termin('2026-03-17T19:00:00Z', '2026-03-17T20:00:00Z')], fenster),
).toBe(0)
})
it('lässt ganztägige und freie Termine weg', () => {
expect(
belegteMinuten(
[
termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z', { ganztaegig: true }),
termin('2026-03-17T10:00:00Z', '2026-03-17T11:00:00Z', { zeigtAls: 'FREI' }),
],
fenster,
),
).toBe(0)
})
})
describe('tagesBelegung', () => {
it('rechnet Kapazität und Termine gegeneinander', () => {
const belegung = tagesBelegung(
bereich,
[termin('2026-03-17T08:00:00Z', '2026-03-17T10:00:00Z')],
dienstag,
)
expect(belegung.kapazitaet).toBe(240)
expect(belegung.belegt).toBe(120)
expect(belegung.frei).toBe(120)
expect(belegung.prozent).toBe(50)
expect(belegung.ueberbucht).toBe(false)
})
it('erkennt einen überbuchten Tag', () => {
const belegung = tagesBelegung(
bereich,
[termin('2026-03-17T07:00:00Z', '2026-03-17T13:00:00Z')],
dienstag,
)
expect(belegung.belegt).toBe(360)
expect(belegung.frei).toBe(0)
expect(belegung.ueberbucht).toBe(true)
expect(belegung.prozent).toBe(150)
})
it('behandelt einen Tag ohne Kapazität als voll', () => {
// Sonntag: keine Kapazität, also gibt es dort nichts zu planen.
const belegung = tagesBelegung(bereich, [], new Date('2026-03-22T09:00:00Z'))
expect(belegung.kapazitaet).toBe(0)
expect(belegung.prozent).toBe(100)
expect(belegung.frei).toBe(0)
})
})
describe('freieFenster', () => {
const fenster = arbeitsfensterAmTag(dienstag, 480, 1020)
it('gibt ohne Termine das ganze Arbeitsfenster zurück', () => {
const luecken = freieFenster([], fenster)
expect(luecken).toHaveLength(1)
expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T07:00:00.000Z')
expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T16:00:00.000Z')
})
it('findet die Lücke zwischen zwei Terminen', () => {
const luecken = freieFenster(
[
termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-17T11:00:00Z', '2026-03-17T16:00:00Z'),
],
fenster,
)
expect(luecken).toHaveLength(1)
expect(luecken[0]?.von.toISOString()).toBe('2026-03-17T09:00:00.000Z')
expect(luecken[0]?.bis.toISOString()).toBe('2026-03-17T11:00:00.000Z')
})
it('lässt zu kurze Lücken weg', () => {
const luecken = freieFenster(
[
termin('2026-03-17T07:00:00Z', '2026-03-17T09:00:00Z'),
// Nur zehn Minuten Pause.
termin('2026-03-17T09:10:00Z', '2026-03-17T16:00:00Z'),
],
fenster,
15,
)
expect(luecken).toEqual([])
})
it('ist bei durchgehender Belegung leer', () => {
expect(
freieFenster([termin('2026-03-17T07:00:00Z', '2026-03-17T16:00:00Z')], fenster),
).toEqual([])
})
it('übergeht ganztägige Termine und findet den Tag frei', () => {
const luecken = freieFenster(
[termin('2026-03-17T00:00:00Z', '2026-03-18T00:00:00Z', { ganztaegig: true })],
fenster,
)
expect(luecken).toHaveLength(1)
})
})
describe('termineAmTag', () => {
it('nimmt, was den Berliner Kalendertag berührt', () => {
const liste = [
termin('2026-03-17T08:00:00Z', '2026-03-17T09:00:00Z'),
termin('2026-03-18T08:00:00Z', '2026-03-18T09:00:00Z'),
]
expect(termineAmTag(liste, dienstag)).toHaveLength(1)
})
it('nimmt auch einen Termin, der in den Tag hineinragt', () => {
const uebernacht = termin('2026-03-16T22:00:00Z', '2026-03-17T02:00:00Z')
expect(termineAmTag([uebernacht], dienstag)).toHaveLength(1)
})
it('lässt einen Termin weg, der genau am Tagesbeginn endet', () => {
// 23:00 UTC am 16. = 00:00 Berlin am 17., also gerade noch der Vortag.
const davor = termin('2026-03-16T21:00:00Z', '2026-03-16T23:00:00Z')
expect(termineAmTag([davor], dienstag)).toEqual([])
})
})
+217
View File
@@ -0,0 +1,217 @@
import type { ZeigtAls } from '@/generated/prisma/enums'
import { tagesBeginn, tagesEnde, wochentagIndex } from '@/lib/zeit'
/**
* Wie voll ein Tag schon ist.
*
* Reine Logik mit dem Zeitpunkt als Parameter. Alles rechnet in Minuten und in
* Europe/Berlin — dieselbe Tagesgrenze wie überall sonst im Planer.
*/
export type BelegenderTermin = {
beginn: Date
ende: Date
ganztaegig: boolean
zeigtAls: ZeigtAls
}
export type Zeitfenster = { von: Date; bis: Date }
/**
* Zählt ein Termin gegen die Kapazität?
*
* Ganztägige Termine nicht — ein Urlaubstag oder ein Geburtstag im Kalender
* blockiert sonst den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
* Ebenso wenig zählen Termine, die ausdrücklich als frei markiert sind.
*/
export function belegtZeit(termin: BelegenderTermin): boolean {
if (termin.ganztaegig) return false
if (termin.zeigtAls === 'FREI') return false
return true
}
/** Überschneidung zweier Zeiträume in Minuten; ohne Überschneidung null. */
export function ueberschneidungMinuten(a: Zeitfenster, b: Zeitfenster): number {
const von = Math.max(a.von.getTime(), b.von.getTime())
const bis = Math.min(a.bis.getTime(), b.bis.getTime())
if (bis <= von) return 0
return Math.round((bis - von) / 60_000)
}
/** Das Arbeitsfenster eines Bereichs an einem bestimmten Tag. */
export function arbeitsfensterAmTag(
tag: Date,
arbeitsfensterVon: number,
arbeitsfensterBis: number,
): Zeitfenster {
const beginn = tagesBeginn(tag)
return {
von: new Date(beginn.getTime() + arbeitsfensterVon * 60_000),
bis: new Date(beginn.getTime() + arbeitsfensterBis * 60_000),
}
}
/**
* Wie viele Minuten des Arbeitsfensters an diesem Tag schon durch Termine
* belegt sind.
*
* Überlappende Termine werden zusammengelegt, damit eine Doppelbuchung nicht
* doppelt zählt.
*/
export function belegteMinuten(
termine: readonly BelegenderTermin[],
fenster: Zeitfenster,
): number {
const abschnitte = termine
.filter(belegtZeit)
.map((t) => ({ von: t.beginn, bis: t.ende }))
.filter((t) => ueberschneidungMinuten(t, fenster) > 0)
.map((t) => ({
von: new Date(Math.max(t.von.getTime(), fenster.von.getTime())),
bis: new Date(Math.min(t.bis.getTime(), fenster.bis.getTime())),
}))
.sort((a, b) => a.von.getTime() - b.von.getTime())
let summe = 0
let offenVon: Date | null = null
let offenBis: Date | null = null
for (const abschnitt of abschnitte) {
if (offenVon === null || offenBis === null) {
offenVon = abschnitt.von
offenBis = abschnitt.bis
continue
}
if (abschnitt.von.getTime() <= offenBis.getTime()) {
// Überlappt oder schließt an – zusammenlegen.
if (abschnitt.bis.getTime() > offenBis.getTime()) offenBis = abschnitt.bis
continue
}
summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000)
offenVon = abschnitt.von
offenBis = abschnitt.bis
}
if (offenVon !== null && offenBis !== null) {
summe += Math.round((offenBis.getTime() - offenVon.getTime()) / 60_000)
}
return summe
}
export type TagesBelegung = {
/** Tageskapazität des Bereichs an diesem Wochentag, in Minuten. */
kapazitaet: number
/** Durch Termine belegte Minuten innerhalb des Arbeitsfensters. */
belegt: number
/** Was von der Kapazität übrig bleibt; nie negativ. */
frei: number
/** Belegung in Prozent der Kapazität, gerundet. Ohne Kapazität 100. */
prozent: number
/** Mehr Termine als Kapazität – der Tag ist überbucht. */
ueberbucht: boolean
}
export type BereichKapazitaet = {
kapazitaetMo: number
kapazitaetDi: number
kapazitaetMi: number
kapazitaetDo: number
kapazitaetFr: number
kapazitaetSa: number
kapazitaetSo: number
arbeitsfensterVon: number
arbeitsfensterBis: number
}
const KAPAZITAET_JE_TAG = [
'kapazitaetMo',
'kapazitaetDi',
'kapazitaetMi',
'kapazitaetDo',
'kapazitaetFr',
'kapazitaetSa',
'kapazitaetSo',
] as const
/** Kapazität des Bereichs an genau diesem Tag. */
export function kapazitaetAmTag(bereich: BereichKapazitaet, tag: Date): number {
const feld = KAPAZITAET_JE_TAG[wochentagIndex(tag)]
return feld ? bereich[feld] : 0
}
/**
* Rechnet Kapazität und Termine eines Tages gegeneinander.
*
* Gezählt wird nur, was ins Arbeitsfenster fällt: ein Termin um 22 Uhr
* verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um 17 Uhr
* endet.
*/
export function tagesBelegung(
bereich: BereichKapazitaet,
termine: readonly BelegenderTermin[],
tag: Date,
): TagesBelegung {
const kapazitaet = kapazitaetAmTag(bereich, tag)
const fenster = arbeitsfensterAmTag(
tag,
bereich.arbeitsfensterVon,
bereich.arbeitsfensterBis,
)
const belegt = belegteMinuten(termine, fenster)
return {
kapazitaet,
belegt,
frei: Math.max(0, kapazitaet - belegt),
prozent: kapazitaet === 0 ? 100 : Math.round((belegt / kapazitaet) * 100),
ueberbucht: belegt > kapazitaet,
}
}
/**
* Freie Abschnitte innerhalb des Arbeitsfensters eines Tages — die Lücken,
* in die ab M4 Zeitblöcke gelegt werden können.
*/
export function freieFenster(
termine: readonly BelegenderTermin[],
fenster: Zeitfenster,
mindestensMinuten = 15,
): Zeitfenster[] {
const belegt = termine
.filter(belegtZeit)
.map((t) => ({
von: new Date(Math.max(t.beginn.getTime(), fenster.von.getTime())),
bis: new Date(Math.min(t.ende.getTime(), fenster.bis.getTime())),
}))
.filter((t) => t.bis.getTime() > t.von.getTime())
.sort((a, b) => a.von.getTime() - b.von.getTime())
const luecken: Zeitfenster[] = []
let cursor = fenster.von
for (const abschnitt of belegt) {
if (abschnitt.von.getTime() > cursor.getTime()) {
luecken.push({ von: cursor, bis: abschnitt.von })
}
if (abschnitt.bis.getTime() > cursor.getTime()) cursor = abschnitt.bis
}
if (cursor.getTime() < fenster.bis.getTime()) {
luecken.push({ von: cursor, bis: fenster.bis })
}
return luecken.filter(
(l) => (l.bis.getTime() - l.von.getTime()) / 60_000 >= mindestensMinuten,
)
}
/** Termine, die an diesem Kalendertag überhaupt vorkommen. */
export function termineAmTag<T extends { beginn: Date; ende: Date }>(
termine: readonly T[],
tag: Date,
): T[] {
const von = tagesBeginn(tag).getTime()
const bis = tagesEnde(tag).getTime()
return termine.filter((t) => t.beginn.getTime() <= bis && t.ende.getTime() > von)
}
+104
View File
@@ -0,0 +1,104 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { googleSeiteLesen, googleZeitpunkt, zeigtAlsAusGoogle } from './google'
import { AntwortUnbrauchbar } from './typen'
function fixture(name: string): unknown {
return JSON.parse(
readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'),
) as unknown
}
describe('zeigtAlsAusGoogle', () => {
it('kennt nur frei und gebucht', () => {
expect(zeigtAlsAusGoogle('transparent')).toBe('FREI')
expect(zeigtAlsAusGoogle('opaque')).toBe('GEBUCHT')
})
it('nimmt ein fehlendes Feld als gebucht', () => {
// Google lässt transparency weg, wenn der Termin die Zeit belegt.
expect(zeigtAlsAusGoogle(undefined)).toBe('GEBUCHT')
})
})
describe('googleZeitpunkt', () => {
it('liest eine Zeit mit Versatz', () => {
const ergebnis = googleZeitpunkt({ dateTime: '2026-03-17T14:00:00+01:00' })
expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-17T13:00:00.000Z')
expect(ergebnis.ganztaegig).toBe(false)
})
it('erkennt ein reines Datum als ganztägig', () => {
const ergebnis = googleZeitpunkt({ date: '2026-03-20' })
expect(ergebnis.zeitpunkt.toISOString()).toBe('2026-03-20T00:00:00.000Z')
expect(ergebnis.ganztaegig).toBe(true)
})
it('bricht ab, wenn beides fehlt', () => {
expect(() => googleZeitpunkt({})).toThrow(AntwortUnbrauchbar)
})
it('bricht bei unlesbaren Angaben ab', () => {
expect(() => googleZeitpunkt({ dateTime: 'irgendwann' })).toThrow(AntwortUnbrauchbar)
expect(() => googleZeitpunkt({ date: '2026-13-45' })).toThrow(AntwortUnbrauchbar)
})
})
describe('googleSeiteLesen', () => {
it('liest die erste Seite samt Seitenverweis', () => {
const seite = googleSeiteLesen(fixture('google-events-seite1'))
expect(seite.geaendert).toHaveLength(2)
expect(seite.weiter).toBe('BEISPIEL-SEITE-2')
expect(seite.deltaVerweis).toBeNull()
})
it('rechnet den Versatz nach UTC um', () => {
const [erster] = googleSeiteLesen(fixture('google-events-seite1')).geaendert
expect(erster?.extId).toBe('beispiel-termin-a')
expect(erster?.betreff).toBe('Zahnarzt')
expect(erster?.beginn.toISOString()).toBe('2026-03-17T13:00:00.000Z')
expect(erster?.ende.toISOString()).toBe('2026-03-17T14:00:00.000Z')
expect(erster?.ganztaegig).toBe(false)
})
it('erkennt ganztägige Termine am reinen Datum', () => {
const ganztaegig = googleSeiteLesen(fixture('google-events-seite1')).geaendert[1]
expect(ganztaegig?.ganztaegig).toBe(true)
expect(ganztaegig?.betreff).toBe('Urlaubstag')
})
it('liest die letzte Seite mit dem Sync-Verweis', () => {
const seite = googleSeiteLesen(fixture('google-events-seite2'))
expect(seite.weiter).toBeNull()
expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-1')
})
it('behandelt abgesagte Termine als entfernt', () => {
const seite = googleSeiteLesen(fixture('google-events-seite2'))
expect(seite.entfernt).toEqual(['beispiel-termin-d'])
expect(seite.geaendert.map((t) => t.extId)).toEqual(['beispiel-termin-c'])
})
it('erkennt einen durchlässigen Termin als frei', () => {
const [frei] = googleSeiteLesen(fixture('google-events-seite2')).geaendert
expect(frei?.zeigtAls).toBe('FREI')
})
it('kommt mit einer leeren Antwort zurecht', () => {
const seite = googleSeiteLesen(fixture('google-events-leer'))
expect(seite.geaendert).toEqual([])
expect(seite.entfernt).toEqual([])
expect(seite.deltaVerweis).toBe('BEISPIEL-SYNC-2')
})
it('weist eine Antwort mit falschem Aufbau ab', () => {
expect(() => googleSeiteLesen({ items: 'keine Liste' })).toThrow(AntwortUnbrauchbar)
})
it('übergeht Einträge ohne Zeitraum', () => {
const seite = googleSeiteLesen({
items: [{ id: 'ohne-zeit', status: 'confirmed', summary: 'Kein Zeitraum' }],
})
expect(seite.geaendert).toEqual([])
})
})
+118
View File
@@ -0,0 +1,118 @@
import { z } from 'zod'
import type { ZeigtAls } from '@/generated/prisma/enums'
import {
AntwortUnbrauchbar,
type AbgleichSeite,
type RohTermin,
} from '@/lib/kalender/typen'
/**
* Lesen der Antworten der Google-Kalender-API (`events.list`).
*
* Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die
* gemeinsame Form zurück.
*/
/**
* Google liefert entweder `date` (ganztägig, reines Datum) oder `dateTime`
* (mit Versatz). Genau eines von beidem.
*/
const googleZeit = z.looseObject({
date: z.string().optional(),
dateTime: z.string().optional(),
timeZone: z.string().optional(),
})
const googleTermin = z.looseObject({
id: z.string().min(1),
etag: z.string().optional(),
status: z.string().optional(),
summary: z.string().nullable().optional(),
start: googleZeit.optional(),
end: googleZeit.optional(),
transparency: z.string().optional(),
})
const googleAntwort = z.looseObject({
items: z.array(googleTermin).default([]),
nextPageToken: z.string().optional(),
nextSyncToken: z.string().optional(),
})
/**
* Google kennt nur zwei Zustände: `transparency: "transparent"` heißt frei,
* alles andere (auch das Fehlen des Feldes) heißt gebucht.
*/
export function zeigtAlsAusGoogle(transparency: string | undefined): ZeigtAls {
return transparency === 'transparent' ? 'FREI' : 'GEBUCHT'
}
export type GoogleZeit = { date?: string | undefined; dateTime?: string | undefined }
/**
* Wandelt Googles Zeitangabe in einen Zeitpunkt und sagt, ob sie ganztägig
* war. Ein reines Datum wird als Mitternacht UTC gelesen — ganztägige Termine
* zählen ohnehin nicht zur Belegung, es geht nur um die Einordnung im Tag.
*/
export function googleZeitpunkt(zeit: GoogleZeit): {
zeitpunkt: Date
ganztaegig: boolean
} {
if (zeit.date) {
const zeitpunkt = new Date(`${zeit.date}T00:00:00Z`)
if (Number.isNaN(zeitpunkt.getTime())) {
throw new AntwortUnbrauchbar('Google', 'Datumsangabe nicht lesbar')
}
return { zeitpunkt, ganztaegig: true }
}
if (zeit.dateTime) {
const zeitpunkt = new Date(zeit.dateTime)
if (Number.isNaN(zeitpunkt.getTime())) {
throw new AntwortUnbrauchbar('Google', 'Zeitangabe nicht lesbar')
}
return { zeitpunkt, ganztaegig: false }
}
throw new AntwortUnbrauchbar('Google', 'Zeitangabe fehlt')
}
/** Liest eine Antwortseite von `events.list`. */
export function googleSeiteLesen(inhalt: unknown): AbgleichSeite {
const gelesen = googleAntwort.safeParse(inhalt)
if (!gelesen.success) {
throw new AntwortUnbrauchbar('Google', 'unerwarteter Aufbau')
}
const geaendert: RohTermin[] = []
const entfernt: string[] = []
for (const eintrag of gelesen.data.items) {
if (eintrag.status === 'cancelled') {
entfernt.push(eintrag.id)
continue
}
if (!eintrag.start || !eintrag.end) continue
const beginn = googleZeitpunkt(eintrag.start)
const ende = googleZeitpunkt(eintrag.end)
geaendert.push({
extId: eintrag.id,
etag: eintrag.etag ?? null,
betreff: eintrag.summary ?? null,
beginn: beginn.zeitpunkt,
ende: ende.zeitpunkt,
ganztaegig: beginn.ganztaegig,
zeigtAls: zeigtAlsAusGoogle(eintrag.transparency),
})
}
return {
geaendert,
entfernt,
weiter: gelesen.data.nextPageToken ?? null,
deltaVerweis: gelesen.data.nextSyncToken ?? null,
}
}
+130
View File
@@ -0,0 +1,130 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { graphSeiteLesen, graphZeitpunkt, zeigtAlsAusGraph } from './graph'
import { AntwortUnbrauchbar } from './typen'
/** Beispielantworten aus test/fixtures, anonymisiert. */
function fixture(name: string): unknown {
return JSON.parse(
readFileSync(`test/fixtures/kalender/${name}.json`, 'utf8'),
) as unknown
}
describe('zeigtAlsAusGraph', () => {
it('übersetzt die bekannten Werte', () => {
expect(zeigtAlsAusGraph('free')).toBe('FREI')
expect(zeigtAlsAusGraph('busy')).toBe('GEBUCHT')
expect(zeigtAlsAusGraph('tentative')).toBe('MIT_VORBEHALT')
expect(zeigtAlsAusGraph('oof')).toBe('ABWESEND')
expect(zeigtAlsAusGraph('workingElsewhere')).toBe('WOANDERS')
})
it('nimmt Unbekanntes als unbekannt, statt es zu raten', () => {
expect(zeigtAlsAusGraph('etwasNeues')).toBe('UNBEKANNT')
expect(zeigtAlsAusGraph(undefined)).toBe('UNBEKANNT')
})
})
describe('graphZeitpunkt', () => {
it('liest eine UTC-Angabe', () => {
expect(
graphZeitpunkt({
dateTime: '2026-03-17T08:00:00.0000000',
timeZone: 'UTC',
}).toISOString(),
).toBe('2026-03-17T08:00:00.000Z')
})
it('kürzt die sieben Nachkommastellen, die Date nicht versteht', () => {
expect(
graphZeitpunkt({
dateTime: '2026-03-17T08:00:00.1234567',
timeZone: 'UTC',
}).toISOString(),
).toBe('2026-03-17T08:00:00.123Z')
})
it('kommt auch ohne Nachkommastellen zurecht', () => {
expect(
graphZeitpunkt({ dateTime: '2026-03-17T08:00:00', timeZone: 'UTC' }).toISOString(),
).toBe('2026-03-17T08:00:00.000Z')
})
it('bricht bei einer anderen Zeitzone ab, statt Termine zu verschieben', () => {
expect(() =>
graphZeitpunkt({
dateTime: '2026-03-17T09:00:00.0000000',
timeZone: 'W. Europe Standard Time',
}),
).toThrow(AntwortUnbrauchbar)
})
it('bricht bei unlesbarer Zeitangabe ab', () => {
expect(() => graphZeitpunkt({ dateTime: 'morgen früh', timeZone: 'UTC' })).toThrow(
AntwortUnbrauchbar,
)
})
})
describe('graphSeiteLesen', () => {
it('liest die erste Seite samt Verweis auf die nächste', () => {
const seite = graphSeiteLesen(fixture('graph-delta-seite1'))
expect(seite.geaendert).toHaveLength(2)
expect(seite.entfernt).toEqual([])
expect(seite.weiter).toContain('$skiptoken=')
expect(seite.deltaVerweis).toBeNull()
})
it('übernimmt Betreff, Kennung und Etag', () => {
const [erster] = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert
expect(erster?.extId).toBe('BEISPIEL-TERMIN-1')
expect(erster?.betreff).toBe('Wochenbesprechung')
expect(erster?.etag).toContain('BEISPIELETAG1')
expect(erster?.beginn.toISOString()).toBe('2026-03-17T08:00:00.000Z')
expect(erster?.ende.toISOString()).toBe('2026-03-17T09:00:00.000Z')
expect(erster?.ganztaegig).toBe(false)
expect(erster?.zeigtAls).toBe('GEBUCHT')
})
it('erkennt ganztägige Termine', () => {
const ganztaegig = graphSeiteLesen(fixture('graph-delta-seite1')).geaendert[1]
expect(ganztaegig?.ganztaegig).toBe(true)
expect(ganztaegig?.zeigtAls).toBe('ABWESEND')
})
it('liest die letzte Seite mit dem Delta-Verweis für die nächste Runde', () => {
const seite = graphSeiteLesen(fixture('graph-delta-seite2'))
expect(seite.weiter).toBeNull()
expect(seite.deltaVerweis).toContain('$deltatoken=')
})
it('trennt Entferntes von Geändertem', () => {
const seite = graphSeiteLesen(fixture('graph-delta-seite2'))
expect(seite.entfernt).toEqual(['BEISPIEL-TERMIN-9'])
expect(seite.geaendert.map((t) => t.extId)).toEqual(['BEISPIEL-TERMIN-3'])
})
it('erkennt einen als frei markierten Termin', () => {
const [frei] = graphSeiteLesen(fixture('graph-delta-seite2')).geaendert
expect(frei?.zeigtAls).toBe('FREI')
})
it('bricht ab, wenn Graph eine andere Zeitzone liefert', () => {
expect(() => graphSeiteLesen(fixture('graph-delta-fremde-zeitzone'))).toThrow(
AntwortUnbrauchbar,
)
})
it('weist eine Antwort ohne value-Liste ab', () => {
expect(() => graphSeiteLesen({ irgendwas: true })).toThrow(AntwortUnbrauchbar)
})
it('übergeht Einträge ohne Zeitraum, statt abzubrechen', () => {
const seite = graphSeiteLesen({
value: [{ id: 'ohne-zeit', subject: 'Kein Zeitraum' }],
})
expect(seite.geaendert).toEqual([])
expect(seite.entfernt).toEqual([])
})
})
+124
View File
@@ -0,0 +1,124 @@
import { z } from 'zod'
import type { ZeigtAls } from '@/generated/prisma/enums'
import {
AntwortUnbrauchbar,
type AbgleichSeite,
type RohTermin,
} from '@/lib/kalender/typen'
/**
* Lesen der Antworten von Microsoft Graph (`/me/calendarView/delta`).
*
* Reine Logik: nimmt geparsten JSON entgegen, prüft ihn mit zod und gibt die
* gemeinsame Form zurück. Kein fetch, keine Tokens — dadurch gegen die
* Beispielantworten in test/fixtures prüfbar.
*/
/**
* Graph liefert Datum und Zeitzone getrennt, ohne Versatz im Text. Wir fragen
* mit `Prefer: outlook.timezone="UTC"` an; kommt trotzdem etwas anderes
* zurück, wäre jede Umrechnung geraten – dann lieber abbrechen.
*/
const graphZeit = z.object({
dateTime: z.string().min(1),
timeZone: z.string().min(1),
})
const graphTermin = z.looseObject({
id: z.string().min(1),
'@odata.etag': z.string().optional(),
'@removed': z.looseObject({ reason: z.string().optional() }).optional(),
subject: z.string().nullable().optional(),
start: graphZeit.optional(),
end: graphZeit.optional(),
isAllDay: z.boolean().optional(),
showAs: z.string().optional(),
})
const graphAntwort = z.looseObject({
value: z.array(graphTermin),
'@odata.nextLink': z.string().optional(),
'@odata.deltaLink': z.string().optional(),
})
/** showAs von Graph auf unsere Belegungsarten. */
export function zeigtAlsAusGraph(showAs: string | undefined): ZeigtAls {
switch (showAs) {
case 'free':
return 'FREI'
case 'busy':
return 'GEBUCHT'
case 'tentative':
return 'MIT_VORBEHALT'
case 'oof':
return 'ABWESEND'
case 'workingElsewhere':
return 'WOANDERS'
default:
return 'UNBEKANNT'
}
}
/**
* Wandelt Graphs `{ dateTime, timeZone }` in einen Zeitpunkt.
*
* Der Text trägt keinen Versatz, deshalb wird er als UTC gelesen. Meldet
* Graph eine andere Zeitzone, ist die Anfrage falsch gestellt worden — das
* schweigend zu übergehen hieße, Termine um Stunden zu verschieben.
*/
export function graphZeitpunkt(zeit: { dateTime: string; timeZone: string }): Date {
if (zeit.timeZone.toUpperCase() !== 'UTC') {
throw new AntwortUnbrauchbar(
'Graph',
`unerwartete Zeitzone "${zeit.timeZone}", erwartet wurde UTC`,
)
}
// "2016-12-09T20:30:00.0000000" – die sieben Nachkommastellen versteht
// Date nicht, drei genügen.
const gekuerzt = zeit.dateTime.replace(/(\.\d{3})\d+$/, '$1')
const zeitpunkt = new Date(`${gekuerzt}Z`)
if (Number.isNaN(zeitpunkt.getTime())) {
throw new AntwortUnbrauchbar('Graph', 'Zeitangabe nicht lesbar')
}
return zeitpunkt
}
/** Liest eine Antwortseite von `/me/calendarView/delta`. */
export function graphSeiteLesen(inhalt: unknown): AbgleichSeite {
const gelesen = graphAntwort.safeParse(inhalt)
if (!gelesen.success) {
throw new AntwortUnbrauchbar('Graph', 'unerwarteter Aufbau')
}
const geaendert: RohTermin[] = []
const entfernt: string[] = []
for (const eintrag of gelesen.data.value) {
if (eintrag['@removed']) {
entfernt.push(eintrag.id)
continue
}
// Ein Eintrag ohne Zeitraum ist kein Termin, den wir einplanen könnten.
if (!eintrag.start || !eintrag.end) continue
geaendert.push({
extId: eintrag.id,
etag: eintrag['@odata.etag'] ?? null,
betreff: eintrag.subject ?? null,
beginn: graphZeitpunkt(eintrag.start),
ende: graphZeitpunkt(eintrag.end),
ganztaegig: eintrag.isAllDay === true,
zeigtAls: zeigtAlsAusGraph(eintrag.showAs),
})
}
return {
geaendert,
entfernt,
weiter: gelesen.data['@odata.nextLink'] ?? null,
deltaVerweis: gelesen.data['@odata.deltaLink'] ?? null,
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { ZeigtAls } from '@/generated/prisma/enums'
/**
* Gemeinsame Form eines Termins, unabhängig vom Anbieter.
*
* Graph und Google liefern sehr verschiedene Antworten; alles, was danach
* kommt — Belegungsrechnung, Wochenansicht, Schreibweg ab M4 — arbeitet nur
* noch mit dieser Form.
*/
export type RohTermin = {
/** Kennung beim Anbieter. */
extId: string
/** Für bedingte Schreibzugriffe ab M4. */
etag: string | null
betreff: string | null
beginn: Date
ende: Date
ganztaegig: boolean
zeigtAls: ZeigtAls
}
/**
* Ergebnis einer einzelnen Abfrageseite.
*
* Genau eines von `weiter` und `deltaVerweis` ist gesetzt: solange `weiter`
* steht, gibt es weitere Seiten; kommt `deltaVerweis`, ist die Runde zu Ende
* und der Verweis wird für den nächsten Abgleich aufbewahrt.
*/
export type AbgleichSeite = {
geaendert: RohTermin[]
/** Kennungen entfernter Termine. */
entfernt: string[]
/** Nächste Seite derselben Runde, sonst null. */
weiter: string | null
/** Ausgangspunkt für die nächste Runde, sonst null. */
deltaVerweis: string | null
}
/** Fehler beim Lesen einer Anbieterantwort – Inhalt nie im Klartext ins Log. */
export class AntwortUnbrauchbar extends Error {
constructor(anbieter: string, grund: string) {
super(`Antwort von ${anbieter} nicht verwertbar: ${grund}`)
this.name = 'AntwortUnbrauchbar'
}
}
/**
* Der Sitzungsverweis ist abgelaufen; es muss von vorn abgeglichen werden.
* Graph antwortet dann mit 410, Google ebenfalls mit 410.
*/
export class VerweisAbgelaufen extends Error {
constructor(anbieter: string) {
super(`Der Abgleichverweis von ${anbieter} ist abgelaufen.`)
this.name = 'VerweisAbgelaufen'
}
}
+154
View File
@@ -0,0 +1,154 @@
import 'server-only'
import type { Prisma } from '@/generated/prisma/client'
import type { AufgabenStatus, AufgabenTyp } from '@/generated/prisma/enums'
import { nachDringlichkeit } from '@/lib/aufgabe'
import { fuerAnzeige, type AufgabeAnzeige } from '@/lib/aufgabe-anzeige'
import { tagesBeginn } from '@/lib/zeit'
import { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber'
/**
* Abfragen rund um Aufgaben.
*
* Alle Seiten laden über diese Stelle, damit Auswahl, Sortierung und
* Aufbereitung überall gleich sind.
*/
const AUSWAHL = {
id: true,
titel: true,
notiz: true,
typ: true,
status: true,
prioritaet: true,
aufwandMinuten: true,
faelligAm: true,
quelle: true,
quelleUrl: true,
bereich: { select: { id: true, name: true, farbe: true } },
projekt: { select: { id: true, name: true } },
} satisfies Prisma.AufgabeSelect
export type AufgabenFilter = {
bereichId?: string | undefined
projektId?: string | undefined
status?: AufgabenStatus | undefined
typ?: AufgabenTyp | undefined
/** Nur Aufgaben ohne Bereich (Inbox). */
nurInbox?: boolean | undefined
/** Nur was noch Arbeit bedeutet. */
nurOffen?: boolean | undefined
/**
* Ideen heraushalten. Sie haben ihre eigene Ansicht und sollen sich nicht
* in die Aufgabenliste drängen — sie hätten dort keinen Termin und stünden
* entweder unsichtbar unten oder mit erfundenem Termin oben.
*/
ohneIdeen?: boolean | undefined
/** Freitext über Titel und Notiz. */
suche?: string | undefined
}
function alsWhere(filter: AufgabenFilter): Prisma.AufgabeWhereInput {
const where: Prisma.AufgabeWhereInput = {}
if (filter.nurInbox) where.bereichId = null
else if (filter.bereichId) where.bereichId = filter.bereichId
if (filter.projektId) where.projektId = filter.projektId
// Ein ausdrücklich gewählter Typ gewinnt: wer nach Ideen filtert, will sie
// auch sehen.
if (filter.typ) where.typ = filter.typ
else if (filter.ohneIdeen) where.typ = { not: 'IDEE' }
if (filter.status) where.status = filter.status
else if (filter.nurOffen) where.status = { in: ['OFFEN', 'GEPLANT', 'WARTET'] }
if (filter.suche) {
where.OR = [
{ titel: { contains: filter.suche, mode: 'insensitive' } },
{ notiz: { contains: filter.suche, mode: 'insensitive' } },
]
}
return where
}
/**
* Lädt Aufgaben und bringt sie in die Reihenfolge der Dringlichkeit.
*
* Sortiert wird bewusst in der Anwendung, nicht in SQL: die Regel ist
* mehrstufig (offen vor abgeschlossen, dann Termin, dann Priorität, dann
* Titel mit deutscher Sortierung) und steht als geprüfte reine Funktion in
* src/lib. Zwei Fassungen derselben Regel wären eine Fehlerquelle.
*/
export async function aufgabenLaden(
filter: AufgabenFilter = {},
grenze = 500,
): Promise<AufgabeAnzeige[]> {
const zeilen = await db.aufgabe.findMany({
where: alsWhere(filter),
select: AUSWAHL,
orderBy: [{ faelligAm: 'asc' }, { prioritaet: 'asc' }],
take: grenze,
})
const zeitpunkt = jetzt()
return zeilen
.slice()
.sort(nachDringlichkeit)
.map((zeile) => fuerAnzeige(zeile, zeitpunkt))
}
/** Bereiche zur Auswahl in Formularen und Filtern. */
export async function bereicheLaden() {
return db.bereich.findMany({
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
select: { id: true, name: true, farbe: true },
})
}
/** Projekte zur Auswahl; abgeschlossene bleiben draußen. */
export async function projekteZurAuswahlLaden() {
return db.projekt.findMany({
where: { status: { in: ['AKTIV', 'PAUSIERT'] } },
orderBy: { name: 'asc' },
select: { id: true, name: true },
})
}
/** Zählstände für die Navigation und die Startseite. */
export async function zaehlstaende(): Promise<{
inbox: number
offen: number
ueberfaellig: number
ideen: number
}> {
const zeitpunkt = jetzt()
const heuteBeginn = tagesBeginn(zeitpunkt)
const [inbox, offen, ueberfaellig, ideen] = await Promise.all([
db.aufgabe.count({
where: {
bereichId: null,
typ: { not: 'IDEE' },
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
},
}),
db.aufgabe.count({
where: { typ: { not: 'IDEE' }, status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } },
}),
db.aufgabe.count({
where: {
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
faelligAm: { lt: heuteBeginn },
},
}),
db.aufgabe.count({
where: { typ: 'IDEE', status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } },
}),
])
return { inbox, offen, ueberfaellig, ideen }
}

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