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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:42:00 +02:00
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
Florian WildeandClaude Opus 5 8ab4b97655 chore(release): 0.1.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 15:51:52 +02:00
Florian WildeandClaude Opus 5 b4d8c19ce4 docs: DEVLOG-Eintrag für M1
Ziel, Erledigtes, die Entscheidungen mit Begründung, die Probleme samt
Lösung und die offenen Punkte. Dazu die eigenen Fehler dieser Sitzung —
die Commits auf main statt auf dem Meilenstein-Zweig, der vermischte
Doku-Commit und das ungeprüfte Skript — damit sie nicht wiederkehren.

Dauer laut zeiten.csv: 161 Minuten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:48:55 +02:00
Florian WildeandClaude Opus 5 688d9f59a8 docs: Handbuchbilder aufnehmen
20 Bilder aus der Wegwerf-Instanz: fünf 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 14:46:16 +02:00
Florian WildeandClaude Opus 5 6d04bc2c1c fix(oberflaeche): Kopfleiste einzeilig halten, Demo-Uhr feststellen
Zwei Mängel, die erst in den aufgenommenen Bildern auffielen.

Auf Handybreite brach die Kopfleiste um und drängte den Namen der
Anwendung in die zweite Zeile, mitten zwischen die Menüpunkte. Sie bleibt
jetzt auf allen Breiten einzeilig; wird es eng, scrollt die Navigation
waagerecht.

Das Tagesdatum auf der Startseite wird serverseitig gerendert, weshalb
Playwrights Uhr nicht griff — die Bilder hätten sich täglich geändert. In
der Screenshot-Instanz (DEMO_MODUS=true) steht die Uhr jetzt fest. Reine
Funktionen bekommen den Zeitpunkt weiterhin als Parameter; src/server/
zeitgeber.ts ist die eine Stelle, an der er entsteht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:36:35 +02:00
Florian WildeandClaude Opus 5 cf28ec19f5 test: Rollentrennung end-to-end absichern
Legt einen Benutzer ohne Verwaltungsrechte an und prüft, dass der direkte
Aufruf der Protokollseite auf den Hinweis führt — nicht nur, dass der
Menüpunkt fehlt. Sich darauf zu verlassen, dass die Oberfläche Knöpfe
ausblendet, wäre kein Nachweis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:32:52 +02:00
Florian WildeandClaude Opus 5 a88273117c chore: Screenshot-Kette und Demo-Seed
npm run screenshots startet eine Wegwerf-Instanz in einem eigenen
Compose-Projekt mit eigener Datenbank, spielt ausschließlich erfundene
Daten ein, fährt die Seiten mit Playwright ab und räumt am Ende alles
wieder weg. Je Seite entstehen vier Bilder: hell und dunkel, Schreibtisch
und Handy. Die Uhr steht fest, damit sich zwischen zwei Läufen nur das
ändert, was sich wirklich geändert hat.

Der Demo-Seed verweigert den Dienst ohne DEMO_MODUS=true — er würde sonst
erfundene Daten in eine echte Datenbank schreiben.

Test- und Werkzeugordner sind jetzt aus dem Docker-Kontext ausgenommen.
Ein Typfehler in scripts/screenshots.ts hatte den Bau des Abbilds
blockiert, obwohl die Datei dort nichts zu suchen hat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:29:52 +02:00
Florian WildeandClaude Opus 5 e3390c88f4 refactor: middleware in proxy umbenennen
Next 16 hat die Dateikonvention "middleware" abgekündigt und in "proxy"
umbenannt; der Bau warnte bei jedem Lauf. Umbenannt sind Datei und
Funktion, die Aufgabe bleibt dieselbe.

Die E2E-Tests laufen unverändert durch: die Umleitung samt gemerktem Ziel
funktioniert weiter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:29:52 +02:00
Florian WildeandClaude Opus 5 56b5356708 docs: README, Schnellstart, Handbuch, Fahrplan und CHANGELOG
README mit Funktionsumfang, Bildergalerie, allen Umgebungsvariablen als
Tabelle und dem Betrieb inklusive Sicherung und Wiederherstellung.

Der Schnellstart führt in acht Schritten von git clone zur ersten
Anmeldung, jeder Schritt mit erwartetem Ergebnis. Er endet mit dem
Hinterlegen eines Passkeys statt beim ersten Kalenderkonto oder der
ersten Aufgabe — beides gibt es erst mit M3 und M2. Das ist in ROADMAP
und Handbuch so vermerkt, damit niemand danach sucht.

Handbuch mit Seiten zu Anmelden, Bereichen und Protokoll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:29:51 +02:00
Florian WildeandClaude Opus 5 f91d015cc4 feat(betrieb): Abbild, Compose-Stapel und Betriebsskripte
Dreistufiges Dockerfile mit standalone-Ausgabe; im Laufzeitabbild liegen
weder Quelltext noch Bauwerkzeuge, und es läuft unter einem eigenen
Benutzer.

Die Migrationen erledigt ein eigener Einmaldienst aus der Bau-Stufe. Der
Versuch, nur die Prisma-CLI ins Laufzeitabbild zu kopieren, scheiterte an
ihren transitiven Modulen; sie alle mitzunehmen hätte das Abbild
aufgebläht. Der Dienst läuft vor der App und muss sauber durchlaufen.

Zwei Stellen mussten faul werden, weil Next beim Bauen Seitendaten
sammelt und dabei Module lädt, die es zur Bauzeit noch ohne
Umgebungsvariablen gibt:
- Der Prisma-Client entsteht erst beim ersten Zugriff, über einen Proxy.
- Die Auth.js-Konfiguration wird als Funktion übergeben.
Ebenso liest prisma.config.ts die URL über process.env statt über env(),
das beim Bauen ohne Datenbank abbricht.

Dazu backup.sh, update.sh mit Rückrollpfad und scripts/release.sh. Der
Dump wurde nachweislich in eine Wegwerf-Datenbank zurückgespielt und
stimmt zeilengenau mit dem Original überein. Alle Skripte sind
shellcheck-sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:08:04 +02:00
Florian WildeandClaude Opus 5 126ad93301 test: Integrations-, Migrations- und E2E-Tests ergänzen
Der Integrationstest prüft gegen eine echte Datenbank, was Vitest nicht
sieht: Eindeutigkeit, Fremdschlüssel, Kaskaden und dass das Protokoll den
gelöschten Benutzer überlebt. Er läuft bewusst ohne umschließende
Transaktion — er prüft absichtlich Fälle, die scheitern müssen, und ein
gescheiterter Befehl bricht in PostgreSQL die ganze Transaktion ab.
Stattdessen tragen alle Testdaten dieselbe Kennung und werden am Ende
wieder entfernt.

Der Migrationstest legt eine Wegwerf-Datenbank an, spielt alle
Migrationen ein und vergleicht das Ergebnis mit dem Schema. Dass er
Abweichungen wirklich bemerkt, wurde mit einem probeweise ergänzten Feld
nachgewiesen.

Der E2E-Rauchtest deckt den Weg ab, der in M1 trägt: Umleitung ohne
Anmeldung samt gemerktem Ziel, Abweisung falscher Zugangsdaten,
erfolgreiche Anmeldung und die Verwaltungsseite.

Dafür neu: eine Middleware, die nur den angefragten Pfad als Kopfzeile
durchreicht. Ein Layout kennt seinen Pfad nicht, braucht ihn aber für die
Rückkehr nach der Anmeldung. Anmeldelogik steht dort bewusst nicht — die
Middleware läuft im Edge-Umfeld ohne Prisma.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:10:54 +02:00
158 changed files with 13743 additions and 126 deletions
+16 -1
View File
@@ -6,7 +6,22 @@ node_modules
logs
sicherungen
coverage
# Nur für die Entwicklung. Halten den Kontext klein und sorgen dafür, dass ein
# defektes Werkzeugskript nicht den Bau des Abbilds blockiert – geprüft werden
# sie ohnehin von "npm run pruefen".
e2e
screenshots
test
test-results
playwright-report
docs/bilder
playwright.config.ts
playwright.screenshots.config.ts
vitest.config.ts
docs
devlog
*.sh
scripts/pruefen.mjs
scripts/integrationstest.ts
scripts/migrationstest.ts
scripts/screenshots.ts
+15
View File
@@ -59,6 +59,21 @@ WEBAUTHN_RP_NAME=Planer
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
WEBAUTHN_ORIGIN=http://localhost:3000
# --- Kalenderanbieter ------------------------------------------------------
# Graph gibt es in beiden Instanzen, Google nur daheim. Ein Anbieter erscheint
# erst, wenn Kennung UND Geheimnis gesetzt sind.
# Einrichtung siehe README, Abschnitt "Kalenderanbieter einrichten".
# Rueckleit-URIs:
# <APP_URL>/api/kalender/rueckleitung/graph
# <APP_URL>/api/kalender/rueckleitung/google
GRAPH_CLIENT_ID=
GRAPH_CLIENT_SECRET=
# Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant.
GRAPH_TENANT_ID=organizations
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# --- Sonstiges -------------------------------------------------------------
# Zeitzone fuer Anzeigen und Tagesgrenzen.
TZ=Europe/Berlin
+1
View File
@@ -39,3 +39,4 @@ Thumbs.db
.vscode/*
!.vscode/extensions.json
.idea/
screenshot-lauf.log
+141
View File
@@ -0,0 +1,141 @@
# Änderungen
Alle nennenswerten Änderungen an diesem Projekt stehen hier.
Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
## [0.4.0] – 2026-09-11
### Hinzugefügt
- **Wochenansicht** mit Ziehen und Ablegen: Aufgaben aus der linken Spalte in
ein Zeitfenster ziehen legt einen Zeitblock an und schreibt ihn in den
Kalender. Die Dauer kommt aus dem geschätzten Aufwand der Aufgabe.
- Zeitblöcke verschieben und ausplanen; der Kalendertermin zieht mit.
- **Rückweg**: Wird ein Zeitblock im Kalender verschoben, übernimmt der Planer
das beim nächsten Abgleich. Wird er dort gelöscht, verschwindet auch der
Zeitblock — die Aufgabe bleibt und fällt zurück auf offen.
- Kapazität und Belegung je Wochentag über jeder Spalte; überbuchte Tage
werden rot, sperren aber nichts.
- Eine eingeplante Aufgabe wechselt selbsttätig von _Offen_ auf _Geplant_ und
wieder zurück, wenn nichts mehr für sie geplant ist.
- Handbuchseite zur Wochenplanung.
### Sicherheit
- **Schleifenschutz** über `hash = sha256(startISO|endeISO)`: Der Planer
schreibt nur bei eigener Änderung und übernimmt aus dem Kalender nur, wenn
der Fingerabdruck abweicht. Ohne das würden App und Kalender einander
endlos hinterherschreiben.
- Jeder Schreibzugriff auf einen bestehenden Termin geht mit `If-Match`
heraus. Bei HTTP 412 **gewinnt der Kalender**: der Planer schreibt nicht,
verwirft seinen Fingerabdruck und überlässt dem nächsten Abgleich, den
fremden Stand zu holen.
- Zeitblöcke gehören dem Benutzer über sein Kalenderkonto; die Kennung geht
in jede where-Bedingung, nicht in einen nachträglichen Vergleich.
## [0.3.0] – 2026-09-10
### Hinzugefügt
- 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
### Hinzugefügt
- Projektgerüst mit Next 16 (App Router, `output: "standalone"`), React 19,
TypeScript strict, Tailwind 4 und shadcn-Primitiven.
- `npm run pruefen` bündelt Prettier, ESLint, `tsc --noEmit`, Vitest und
ShellCheck. ShellCheck läuft über das Docker-Abbild, wenn es lokal fehlt.
- Datenmodell für Benutzer mit Rolle, OIDC-Konten, Passkeys, Bereiche und
Audit-Einträge; PostgreSQL 16 über Prisma 7 mit pg-Treiberadapter.
- Anmeldung über einen generischen OIDC-Anbieter, über Passkeys
(SimpleWebAuthn) und wahlweise über E-Mail und Passwort.
- Rollen `BENUTZER` und `ADMIN`. Jede schreibende Aktion prüft die Rolle
serverseitig in der Seite **und** noch einmal in der Server Action.
- Audit-Protokoll für jede schreibende Aktion, mit Protokollseite für die
Verwaltung. Details werden vor dem Speichern gesäubert.
- Instanzsteuerung über `INSTANZ=firma|daheim`. Was eine Instanz nicht darf,
liefert dort 404 und lädt seine Konfiguration gar nicht erst.
- Bereiche mit Farbe, Arbeitsfenster und Tageskapazität je Wochentag.
- Zeitrechnung durchgehend in Europe/Berlin, mit Tagesgrenzen, Wochenbeginn
am Montag und deutschen Anzeigeformaten.
- `/api/health` mit Status, Version, Instanz und Erreichbarkeit der Datenbank.
- Hell und Dunkel, Navigation mit Rollenfilter, Oberfläche auf Deutsch.
- Dockerfile in drei Stufen und Compose-Stapel mit eigenem Migrationsdienst.
- `backup.sh` (pg_dump im Custom-Format, `.env` und Uploads),
`update.sh` (mit Rückrollpfad), `scripts/release.sh` und `scripts/zeit.sh`.
- Tests: Unit mit Vitest, Integrationstest und Migrationstest gegen eine
echte Datenbank, E2E-Rauchtest mit Playwright.
- `npm run screenshots` nimmt die Handbuchbilder in einer Wegwerf-Instanz mit
ausschließlich erfundenen Daten auf.
- README, Schnellstart, Handbuch, ROADMAP und DEVLOG.
### Sicherheit
- Anmeldung, Passwort und Passkey antworten bei Fehlschlag immer gleich,
damit sich nicht abfragen lässt, wer ein Konto hat.
- Sitzungen als JWT, aber Rolle und Sperrstatus werden bei jeder Anfrage
frisch aus der Datenbank gelesen — eine Sperre wirkt sofort.
- Die WebAuthn-Herausforderung wird nach einem Versuch verworfen, gleich ob
er geglückt ist.
- Der Demo-Seed verweigert den Dienst ohne `DEMO_MODUS=true`.
- `allowDangerousEmailAccountLinking` ist bewusst aktiv: je Instanz gibt es
genau einen vertrauenswürdigen OIDC-Anbieter, der die Adresse selbst prüft.
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Planer – Abbild für den Betrieb.
#
# Die Stufe "bauen" dient zugleich als Abbild für den Migrationsdienst: die
# Prisma-CLI bringt eine ganze Reihe Module mit, die im schlanken
# Laufzeitabbild nichts zu suchen haben. Siehe docker-compose.yml.
# ---------------------------------------------------------------------------
FROM node:24-alpine AS abhaengigkeiten
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
# Die Bauwerkzeuge werden gebraucht und bleiben in dieser Stufe.
RUN npm ci --ignore-scripts && npm rebuild prisma @prisma/engines esbuild unrs-resolver
FROM node:24-alpine AS bauen
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=abhaengigkeiten /app/node_modules ./node_modules
COPY . .
# Der Prisma-Client wird erzeugt, bevor Next baut.
RUN npx prisma generate && npm run build
FROM node:24-alpine AS betrieb
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0 \
TZ=Europe/Berlin
RUN apk add --no-cache tzdata curl \
&& addgroup --system --gid 1001 planer \
&& adduser --system --uid 1001 --ingroup planer planer
# Die standalone-Ausgabe bringt nur die wirklich benutzten Module mit.
COPY --from=bauen --chown=planer:planer /app/.next/standalone ./
COPY --from=bauen --chown=planer:planer /app/.next/static ./.next/static
COPY --from=bauen --chown=planer:planer /app/public ./public
USER planer
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=5 \
CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server.js"]
+349
View File
@@ -0,0 +1,349 @@
# Planer
Aufgaben, Ideen, Projekte und Zeitblöcke an einem Ort.
Die Kernidee: Kalender aus Microsoft 365 (Firma, Gewerbe) und Google (privat)
lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
wird.
> **Stand:** Meilenstein 4 von 8. Die Kernidee steht: Aufgaben lassen sich
> per Ziehen und Ablegen als Zeitblöcke in den Kalender schreiben, und
> Verschiebungen von dort kommen zurück. Es folgen Benachrichtigungen,
> Integrationen, Schnellerfassung vom Handy und ein MCP-Server. Der
> [Fahrplan](ROADMAP.md) sagt, was wann folgt.
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
zur ersten Anmeldung
- **[Handbuch](docs/handbuch/)** – Anleitung für den Alltag
- **[Fahrplan](ROADMAP.md)** – was geplant ist und was bewusst nicht
- **[Änderungen](CHANGELOG.md)** – was sich je Version getan hat
## Funktionsumfang
### 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
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
Kalenderterminen, Verschiebungen im Kalender kommen zurück
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
Passwort — je Instanz einzeln zuschaltbar
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
serverseitig geprüft und hinterlässt einen Protokolleintrag
- Bereiche mit Farbe, Arbeitsfenster und Tageskapazität je Wochentag
- Hell und Dunkel, Oberfläche durchgehend auf Deutsch
- Betrieb über Docker Compose, mit Sicherung, Aktualisierung und Healthcheck
### Geplant
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
## Bildergalerie
| | Hell | Dunkel |
| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) |
| Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) |
| Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) |
| Woche | ![Woche, hell](docs/bilder/woche-hell-schreibtisch.png) | ![Woche, dunkel](docs/bilder/woche-dunkel-schreibtisch.png) |
| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) |
| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) |
| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) |
| Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-dunkel-schreibtisch.png) |
| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) |
| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) |
| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) |
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
## Die zwei Instanzen
Ein Abbild, zwei Deployments, gesteuert über `INSTANZ`. Die Umschaltung wird
**serverseitig erzwungen**: Was eine Instanz nicht darf, liefert dort 404 und
lädt seine Konfiguration gar nicht erst.
| | `firma` | `daheim` |
| -------------- | -------------------------------------- | ------------------------------ |
| Anmeldung | OIDC Entra ID | OIDC authentik |
| Kalenderkonten | Graph (Firmen-Tenant) | Graph (Gewerbe-Tenant), Google |
| Mail | Graph Firmenpostfach | Graph Gewerbepostfach, Gmail |
| Integrationen | Zammad, Wartungsplaner, Spiegel senden | Telegram, Spiegel empfangen |
Ein falsch geschriebener Wert bricht den Start ab, statt still auf einen
Standard zu fallen — in der Firma dürfen keine Provider anlaufen, die dort
nichts zu suchen haben.
## Umgebungsvariablen
Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example).
`.env` gehört niemals ins Git.
### Pflicht
| Variable | Beispiel | Bedeutung |
| ----------------- | ---------------------------------------------------- | ---------------------------------------------- |
| `INSTANZ` | `daheim` | `firma` oder `daheim` |
| `APP_URL` | `https://planer.example.de` | Öffentliche Adresse, ohne Schrägstrich am Ende |
| `DATABASE_URL` | `postgresql://planer:…@db:5432/planer?schema=public` | Verbindung zur Datenbank |
| `AUTH_SECRET` | _(32 Byte Base64)_ | Signiert Sitzungen. `openssl rand -base64 32` |
| `WEBAUTHN_RP_ID` | `planer.example.de` | Nackte Domain, ohne Schema und Port |
| `WEBAUTHN_ORIGIN` | `https://planer.example.de` | Exakt die Herkunft im Browser |
### Datenbank im Compose-Stapel
| Variable | Standard | Bedeutung |
| ------------------- | ---------- | ---------------------------------------------- |
| `POSTGRES_USER` | `planer` | Benutzer der Datenbank |
| `POSTGRES_PASSWORD` | _(keiner)_ | Pflicht. Muss zu `DATABASE_URL` passen |
| `POSTGRES_DB` | `planer` | Name der Datenbank |
| `POSTGRES_PORT` | `5432` | Nach außen veröffentlichter Port |
| `APP_PORT` | `3000` | Nach außen veröffentlichter Port der Anwendung |
### Anmeldung
| Variable | Standard | Bedeutung |
| -------------------- | -------- | ------------------------------------------ |
| `AUTH_TRUST_HOST` | `false` | Hinter einem Reverse Proxy auf `true` |
| `ANMELDUNG_PASSWORT` | `false` | Anmeldung mit E-Mail und Passwort erlauben |
| `OIDC_NAME` | _(leer)_ | Beschriftung des Anmeldeknopfs |
| `OIDC_ISSUER` | _(leer)_ | Issuer-URL des Anbieters |
| `OIDC_CLIENT_ID` | _(leer)_ | Client-ID |
| `OIDC_CLIENT_SECRET` | _(leer)_ | Client-Secret |
| `WEBAUTHN_RP_NAME` | `Planer` | Name, den das Gerät beim Passkey anzeigt |
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
### Kalenderanbieter
| Variable | Standard | Bedeutung |
| ---------------------- | --------------- | ------------------------------------------------------------- |
| `GRAPH_CLIENT_ID` | _(leer)_ | Anwendungs-ID der App-Registrierung in Entra ID |
| `GRAPH_CLIENT_SECRET` | _(leer)_ | Geheimnis dieser Registrierung |
| `GRAPH_TENANT_ID` | `organizations` | Tenant-ID, oder `organizations` für jeden Arbeitsplatz-Tenant |
| `GOOGLE_CLIENT_ID` | _(leer)_ | OAuth-Client-ID aus der Google Cloud Console |
| `GOOGLE_CLIENT_SECRET` | _(leer)_ | Geheimnis dieses Clients |
Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`).
### Einrichtung und Sonstiges
| Variable | Standard | Bedeutung |
| ---------------- | --------------- | ---------------------------------------------------------------------------------------- |
| `ADMIN_EMAIL` | _(leer)_ | Erstes Verwaltungskonto, wird vom Seed angelegt |
| `ADMIN_PASSWORT` | _(leer)_ | Mindestens 12 Zeichen. Ohne den Wert entsteht ein Konto, das nur OIDC oder Passkey kennt |
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
## Kalenderanbieter einrichten
Beides ist einmalige Handarbeit beim Anbieter. Der Planer fragt Lese- **und**
Schreibrechte an: ab M4 schreibt er Zeitblöcke zurück, und eine zweite
Zustimmungsrunde für jedes Konto wäre lästiger als eine etwas breitere
Berechtigung von Anfang an.
### Microsoft 365 (Entra ID)
1. <https://entra.microsoft.com> → **Identität → Anwendungen →
App-Registrierungen → Neue Registrierung**.
2. Name frei wählen, etwa „Planer".
3. Unterstützte Kontotypen: **Nur Konten in diesem Organisationsverzeichnis**
(dann `GRAPH_TENANT_ID` auf die Tenant-ID setzen) oder
**Konten in einem beliebigen Organisationsverzeichnis** (dann
`organizations` belassen).
4. Umleitungs-URI, Plattform **Web**:
`<APP_URL>/api/kalender/rueckleitung/graph`
5. Nach dem Anlegen: **Anwendungs-ID (Client)** nach `GRAPH_CLIENT_ID`,
**Verzeichnis-ID (Mandant)** nach `GRAPH_TENANT_ID`.
6. **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Den Wert
sofort nach `GRAPH_CLIENT_SECRET` kopieren — er wird nur einmal angezeigt.
7. **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph →
Delegierte Berechtigungen**: `Calendars.ReadWrite`, `offline_access`,
`openid`, `email`.
> Ist im Tenant die Zustimmung durch Endbenutzer abgeschaltet, muss ein
> Administrator einmalig **Administratorzustimmung erteilen** klicken.
### Google Kalender
1. <https://console.cloud.google.com> → Projekt anlegen oder wählen.
2. **APIs & Dienste → Bibliothek** → _Google Calendar API_ aktivieren.
3. **OAuth-Zustimmungsbildschirm** einrichten. Für den Eigengebrauch genügt
_Extern_ mit Veröffentlichungsstatus _Test_ und der eigenen Adresse als
Testbenutzer.
4. Bereiche (Scopes) hinzufügen:
`.../auth/calendar.events` und `.../auth/calendar.readonly`.
5. **Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID**,
Anwendungstyp **Webanwendung**.
6. Autorisierter Weiterleitungs-URI:
`<APP_URL>/api/kalender/rueckleitung/google`
7. Client-ID und Clientschlüssel nach `GOOGLE_CLIENT_ID` und
`GOOGLE_CLIENT_SECRET`.
> Bleibt das Projekt im Status _Test_, laufen die Aktualisierungstoken nach
> sieben Tagen ab und die Konten müssen neu verbunden werden. Für den
> Dauerbetrieb den Zustimmungsbildschirm **veröffentlichen**.
Nach dem Eintragen: `docker compose up -d` und im Planer unter **Kalender**
verbinden. Die Anleitung für den Alltag steht im
[Handbuch](docs/handbuch/kalenderkonten.md).
## Betrieb
### Starten und stoppen
```bash
docker compose up -d # starten
docker compose logs -f app # Protokoll mitlesen
docker compose down # stoppen, Daten bleiben
```
Der Stapel besteht aus drei Diensten: `db`, ein Einmaldienst `migration`, der
die Migrationen einspielt und sich beendet, und `app`.
### Healthcheck
```bash
curl https://planer.example.de/api/health
```
```json
{
"status": "ok",
"version": "0.1.0",
"instanz": "daheim",
"datenbank": "ok",
"zeit": "2026-03-17T08:20:00.000Z"
}
```
Die Auskunft ist bewusst ohne Anmeldung erreichbar und enthält deshalb keine
Zählstände, Namen oder Adressen. Bei `"status": "fehler"` antwortet der
Endpunkt mit HTTP 503.
### Aktualisieren
```bash
./update.sh # auf den neuesten Tag
./update.sh --nur-pruefen # nur zeigen, was passieren würde
./update.sh --version v0.2.0 # auf eine bestimmte Version
./update.sh --ja # ohne Rückfrage
```
Das Skript prüft die Voraussetzungen, zeigt die Änderungen aus dem CHANGELOG,
legt eine Sicherung an, wechselt auf den Ziel-Tag, baut, startet und wartet
auf einen gesunden `/api/health` mit der erwarteten Version. Klappt das nicht,
rollt es auf die vorherige Version zurück und nennt den Pfad der Sicherung.
Jeder Lauf wird nach `logs/update-<Datum>.log` protokolliert.
Die **Datenbank wird beim Zurückrollen nicht automatisch zurückgespielt** —
das wäre bei einem Teilfehler gefährlicher als hilfreich. Das Skript nennt den
Befehl dafür.
### Sicherung und Wiederherstellung
```bash
./backup.sh # Datenbank, .env und Uploads
./backup.sh --tage 90 # längere Aufbewahrung
./backup.sh --nur-datenbank # nur der Dump
```
Je Lauf entstehen zwei Dateien in `sicherungen/`:
ein `pg_dump` im Custom-Format und ein tar.gz mit `.env` und `uploads`.
Wiederherstellen:
```bash
# Datenbank
docker compose exec -T db pg_restore -U planer -d planer --clean --if-exists \
< sicherungen/planer-db-2026-03-17_02-00-00.dump
# Dateien
tar -xzf sicherungen/planer-dateien-2026-03-17_02-00-00.tar.gz
```
Die Sicherung enthält `.env` und damit alle Geheimnisse. Sie gehört so
geschützt wie die Datenbank selbst.
Nächtlich per cron:
```cron
0 2 * * * cd /opt/planer && ./backup.sh --tage 30 >> logs/backup.log 2>&1
```
## Entwicklung
```bash
npm install
docker compose up -d db # nur die Datenbank
npm run db:migrieren # Migrationen einspielen
npm run db:seed # Bereiche und Verwaltungskonto
npm run dev # Entwicklungsserver
```
### Prüfungen
```bash
npm run pruefen # Prettier, ESLint, tsc, Vitest, ShellCheck
npm run test:integration # gegen eine echte Datenbank
npm run test:migration # Migrationen gegen eine Wegwerf-Datenbank
npm run test:e2e # Playwright gegen die gebaute Anwendung
npm run screenshots # Handbuchbilder neu aufnehmen
```
`npm run pruefen` muss vor jedem Commit grün sein. Fehlt `shellcheck` lokal,
wird das Docker-Abbild `koalaman/shellcheck` benutzt.
### Aufbau
```
prisma/ Schema, Migrationen, Seed, Demo-Seed
scripts/ Prüf-, Test- und Betriebsskripte
src/app/ Seiten und API-Routen; (app) = angemeldeter Bereich
src/components/ Bausteine; ui/ enthält die Primitiven
src/lib/ Reine Logik ohne DB und ohne Systemzeit, je Datei ein Test
src/server/ DB-nahe Dienste, Server Actions, Provider
e2e/ Playwright-Rauchtests
screenshots/ Aufnahme der Handbuchbilder
docs/ Schnellstart, Handbuch, Bilder
devlog/ Entwicklungstagebuch und Zeiterfassung
```
Reine Funktionen bekommen die aktuelle Zeit als Parameter, damit sie ohne
Tricks prüfbar bleiben. Die Tests laufen bewusst in UTC — so fällt sofort auf,
wenn irgendwo die Ortszeit des Servers durchschlägt statt Europe/Berlin.
### Bekannte Meldungen
`npm audit` meldet vier Funde in transitiven Abhängigkeiten der
**`prisma`-CLI** (`deepmerge-ts` über `@prisma/config`, `mysql2`). Beide sind
Entwicklungsabhängigkeiten und erreichen das Laufzeitabbild nicht; MySQL wird
ohnehin nie geladen. Der angebotene Fix wäre ein Rückschritt auf Prisma 6.
## Sicherheit
- Anmeldung, Passwort und Passkey antworten bei Fehlschlag immer gleich, damit
sich nicht abfragen lässt, wer hier ein Konto hat.
- Sitzungen sind JWT, aber Rolle und Sperrstatus werden bei jeder Anfrage
frisch aus der Datenbank gelesen — eine Sperre wirkt sofort.
- Jede schreibende Aktion prüft die Rolle in der Seite **und** noch einmal in
der Server Action. Die Oberfläche blendet Knöpfe nur zusätzlich aus.
- Audit-Details werden vor dem Speichern gesäubert: Felder, deren Name nach
Passwort, Token, Betreff oder Mailinhalt aussieht, werden ersetzt.
- Keine Geheimnisse, Tokens, Mailinhalte oder Termin-Betreffs in Protokollen.
Gefundene Schwachstellen bitte nicht als Issue anlegen, sondern direkt melden.
## Lizenz
Privates Projekt, keine Lizenz zur Weiterverwendung.
+140
View File
@@ -0,0 +1,140 @@
# Fahrplan
Acht Meilensteine. Jeder endet mit einer lauffähigen Version auf `main`,
grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| Meilenstein | Ziel | Status |
| --------------------------------------- | ---------------------------- | -------- |
| [M1](#m1--fundament) | Fundament | erledigt |
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
| [M6](#m6--integrationen) | Integrationen | geplant |
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
| [M8](#m8--mcp) | MCP | geplant |
---
## M1 – Fundament
**Erledigt am 10.09.2026 mit Version 0.1.0.**
**Ziel:** Ein angemeldeter, betreibbarer Rumpf. Noch keine Aufgaben, kein
Kalender — aber alles, worauf die späteren Meilensteine aufsetzen.
- [x] Projektgerüst: Next 16, React 19, TypeScript strict, Tailwind 4
- [x] `npm run pruefen`: Prettier, ESLint, tsc, Vitest, ShellCheck
- [x] Datenmodell für Anmeldung, Passkeys, Bereiche und Audit
- [x] Anmeldung über OIDC, Passkey und wahlweise Passwort
- [x] Rollen `BENUTZER` und `ADMIN`, serverseitig in Seite **und** Server Action
- [x] Audit-Eintrag je schreibender Aktion, Details werden gesäubert
- [x] Instanzsteuerung `INSTANZ=firma|daheim` mit 404 statt Hinweis
- [x] Bereiche verwalten: Farbe, Arbeitsfenster, Tageskapazität
- [x] Protokollseite für die Verwaltung
- [x] `/api/health` mit Status, Version und Instanz
- [x] Dockerfile, Compose-Stapel mit eigenem Migrationsdienst
- [x] `backup.sh`, `update.sh`, `scripts/release.sh`, `scripts/zeit.sh`
- [x] Unit-, Integrations-, Migrations- und E2E-Tests
- [x] Screenshot-Kette mit eigener Wegwerf-Instanz
- [x] README, Schnellstart, Handbuch, CHANGELOG, DEVLOG
**Bewusst verschoben:** `Aufgabe` und `Zeitblock` kommen mit M2 und M4,
`Kalenderkonto` mit M3 — jeweils mit den Feldern, die der Provider dann
wirklich braucht, statt heute geraten.
## M2 – Aufgaben und Projekte
**Erledigt am 10.09.2026 mit Version 0.2.0.**
**Ziel:** Alles erfassen und wiederfinden, noch ohne Kalender.
- [x] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle
- [x] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten
- [x] Inbox: schnell erfassen, später einsortieren
- [x] Ideen getrennt von Aufgaben
- [x] Projekte mit Arbeitspaketen und Meilensteinen
- [x] Listen mit Filtern nach Bereich, Status und Fälligkeit
- [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
**Erledigt am 10.09.2026 mit Version 0.3.0.**
**Ziel:** Termine aus Microsoft 365 und Google sehen.
- [x] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
- [x] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
- [x] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
- [x] Ganztägige Termine zählen nicht zur Belegung
- [x] Anonymisierte Beispielantworten in `test/fixtures/`
- [x] Hintergrundjob mit node-cron
## M4 – Wochenplanung und Zeitblöcke
**Erledigt am 11.09.2026 mit Version 0.4.0.**
**Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und
Verschiebungen von dort zurückholen.
- [x] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
- [x] Wochenansicht mit `@dnd-kit/core`
- [x] Schreiben in den Kalender, Rückweg beim Verschieben
- [x] Schleifenschutz über `hash = sha256(startISO|endeISO)`
- [x] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
- [x] Belegung gegen die Tageskapazität des Bereichs
**Bewusst offen gelassen:** Die Dauer lässt sich nicht am Balken ziehen, und
ein Zeitblock landet immer im ersten verbundenen Kalender. Beides steht unter
"Ideen für später" — erst zeigt der Alltag, ob es wirklich fehlt.
## M5 – Benachrichtigungen
- [ ] Web-Push mit `web-push`
- [ ] Tagesüberblick per E-Mail über nodemailer
- [ ] Erinnerungen an Fälligkeiten, Jobs über node-cron
- [ ] Je Benutzer einstellbar, mit Ruhezeiten
## M6 – Integrationen
- [ ] Zammad und Wartungsplaner-API (nur `firma`)
- [ ] Telegram (nur `daheim`)
- [ ] Spiegel: `firma` sendet, `daheim` empfängt
- [ ] Jede Integration upsertet über `[quelle, quelleRef]`
## M7 – Schnellerfassung und API
- [ ] Erfassung vom Handy, auch offline
- [ ] Öffentliche API mit Token je Anwendung
- [ ] Rechte je Token, jede Nutzung im Protokoll
## M8 – MCP
- [ ] MCP-Server über `@modelcontextprotocol/sdk`
- [ ] Lesen und Anlegen von Aufgaben und Zeitblöcken
- [ ] Rechte wie bei der API, jede Nutzung im Protokoll
---
## Ideen für später
- Auswertung: wie viel Zeit ging tatsächlich in welchen Bereich
- Wiederkehrende Aufgaben mit eigenem Rhythmus
- Vorlagen für wiederkehrende Projekte
- Verknüpfung von Aufgaben untereinander (blockiert, gehört zu)
- Export der eigenen Daten als JSON
## Bewusst nicht geplant
- **Wiederkehrende Wartungsaufgaben mit Nachweispflicht.** Die bleiben im
Wartungsplaner. Der Planer liest nur Fälligkeiten über dessen API.
- **Mehrbenutzerbetrieb mit geteilten Aufgaben.** Der Planer ist für eine
Person gedacht; Kollegen melden sich in der Firmeninstanz nur an ihren
eigenen Daten an.
- **Eigene Kalenderdarstellung als Ersatz für Outlook.** Der Planer schreibt
in die vorhandenen Kalender, er ersetzt sie nicht.
- **Zeiterfassung zur Abrechnung.** Dafür gibt es andere Werkzeuge.
Executable
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Sichert Datenbank, .env und Uploads des Planers.
# Wiederherstellung: siehe README.md, Abschnitt "Sicherung und Wiederherstellung".
set -euo pipefail
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$WURZEL"
ZIEL="${WURZEL}/sicherungen"
AUFBEWAHRUNG_TAGE=30
NUR_DATENBANK=0
hilfe() {
cat <<'ENDE'
Verwendung: backup.sh [Optionen]
--ziel <Pfad> Ablageort der Sicherung (Standard: ./sicherungen)
--tage <Anzahl> Aufbewahrung in Tagen (Standard: 30, 0 = unbegrenzt)
--nur-datenbank Nur den Datenbank-Dump erzeugen
--hilfe Diese Übersicht
Erzeugt je Lauf zwei Dateien:
planer-db-<Zeitstempel>.dump Datenbank im Custom-Format von pg_dump
planer-dateien-<Zeitstempel>.tar.gz .env und der Ordner uploads
ENDE
}
meldung() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1"; }
fehler() {
printf '%s Fehler: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >&2
exit 1
}
while [ $# -gt 0 ]; do
case "$1" in
--ziel)
ZIEL="${2:-}"
[ -n "$ZIEL" ] || fehler '--ziel braucht einen Pfad.'
shift 2
;;
--tage)
AUFBEWAHRUNG_TAGE="${2:-}"
case "$AUFBEWAHRUNG_TAGE" in
''|*[!0-9]*) fehler '--tage braucht eine ganze Zahl.' ;;
esac
shift 2
;;
--nur-datenbank)
NUR_DATENBANK=1
shift
;;
--hilfe|-h)
hilfe
exit 0
;;
*) fehler "Unbekannte Option '$1'. 'backup.sh --hilfe' zeigt die Übersicht." ;;
esac
done
command -v docker >/dev/null 2>&1 || fehler 'docker fehlt.'
[ -f "$WURZEL/.env" ] || fehler '.env fehlt – im Projektverzeichnis aufrufen.'
# Nur die benötigten Werte lesen, nicht die ganze Datei ausführen.
lies_env() {
local schluessel="$1" standard="${2:-}"
local wert
wert="$(grep -E "^${schluessel}=" "$WURZEL/.env" | tail -1 | cut -d= -f2- || true)"
wert="${wert%\"}"
wert="${wert#\"}"
printf '%s' "${wert:-$standard}"
}
PG_BENUTZER="$(lies_env POSTGRES_USER planer)"
PG_DATENBANK="$(lies_env POSTGRES_DB planer)"
DB_CONTAINER="$(docker compose ps -q db)"
[ -n "$DB_CONTAINER" ] || fehler 'Der Datenbank-Container läuft nicht. Erst "docker compose up -d db".'
ZEITSTEMPEL="$(date '+%Y-%m-%d_%H-%M-%S')"
mkdir -p "$ZIEL"
DUMP="$ZIEL/planer-db-${ZEITSTEMPEL}.dump"
meldung "Datenbank sichern nach $DUMP"
# -Fc ist das Custom-Format: komprimiert und mit pg_restore selektiv einspielbar.
docker exec "$DB_CONTAINER" pg_dump -U "$PG_BENUTZER" -d "$PG_DATENBANK" -Fc > "$DUMP"
if [ ! -s "$DUMP" ]; then
rm -f "$DUMP"
fehler 'Der Dump ist leer geblieben.'
fi
meldung "Datenbank gesichert ($(du -h "$DUMP" | cut -f1))."
if [ "$NUR_DATENBANK" -eq 0 ]; then
DATEIEN="$ZIEL/planer-dateien-${ZEITSTEMPEL}.tar.gz"
meldung "Dateien sichern nach $DATEIEN"
ZU_SICHERN=(.env)
[ -d "$WURZEL/uploads" ] && ZU_SICHERN+=(uploads)
tar -czf "$DATEIEN" -C "$WURZEL" "${ZU_SICHERN[@]}"
meldung "Dateien gesichert ($(du -h "$DATEIEN" | cut -f1))."
fi
if [ "$AUFBEWAHRUNG_TAGE" -gt 0 ]; then
meldung "Sicherungen älter als $AUFBEWAHRUNG_TAGE Tage entfernen."
find "$ZIEL" -maxdepth 1 -type f -name 'planer-*' -mtime "+$AUFBEWAHRUNG_TAGE" -print -delete
fi
meldung 'Sicherung abgeschlossen.'
+396
View File
@@ -0,0 +1,396 @@
# Entwicklungstagebuch
Neuester Eintrag oben. Die Dauer je Sitzung stammt aus `devlog/zeiten.csv`,
die Übersicht aus `scripts/zeit.sh summe`.
## Zeit je Meilenstein
| Meilenstein | Art | Minuten | Stunden |
| ----------- | ------ | ------- | -------- |
| M1 | claude | 161 | 2,68 |
| M2 | claude | 44 | 0,73 |
| M3 | claude | 41 | 0,68 |
| M4 | claude | 167 | 2,78 |
| **Gesamt** | | **413** | **6,88** |
---
## 2026-09-10 – M4: Wochenplanung und Zeitblöcke
**Dauer:** 167 Minuten (20:42–23:29), Art `claude`.
**Ziel der Sitzung:** Die Kernidee des Projekts — Aufgaben per Ziehen und
Ablegen in echte Kalendertermine verwandeln, mit Rückweg.
### Erledigt
- `Zeitblock` samt Migration und Schleifenschutz (30 Tests)
- Schreibweg zu Graph und Google mit `If-Match`, Rückweg im Abgleich
- Rasterlogik für die Wochenansicht (21 Tests)
- Wochenansicht mit `@dnd-kit/core`: einplanen, verschieben, ausplanen
- Kapazität und Belegung je Wochentag
- 3 neue E2E-Tests, Handbuchseite
### Entscheidungen mit Begründung
**Der Fingerabdruck geht nur über Beginn und Ende.** Titel und Beschreibung
ändern sich auch aus harmlosen Gründen — etwa weil der Aufgabentitel
bearbeitet wurde. Für die Planung zählt allein, wann der Block liegt; alles
andere in den Fingerabdruck zu nehmen hieße, den Anbieter grundlos zu
behelligen.
**Der Kalender gewinnt, ausnahmslos.** Wer im Kalender etwas verschiebt,
meint es so. Säße der Planer am längeren Hebel, zöge er dem Benutzer den
Termin wieder weg — und der wüsste nicht, warum. Bei HTTP 412 verwirft der
Planer sogar seinen eigenen Fingerabdruck, damit der nächste Abgleich die
fremde Änderung sicher als solche erkennt.
**Scheitert das Schreiben, überlebt die Planung.** Der Zeitblock bleibt mit
vermerktem Fehler stehen. Ihn zu verwerfen hieße, die Planung des Benutzers
wegen eines Netzproblems zu vergessen; das nächste Verschieben trägt ihn
nach. Beim Löschen ist es umgekehrt: Da verschwindet der Zeitblock auch bei
unerreichbarem Kalender, sonst wäre die Planung blockiert.
**Die Dauer kommt aus dem geschätzten Aufwand.** Wer schätzt, bekommt die
passende Blockgröße geschenkt. Das belohnt genau die Gewohnheit, die die
Planung überhaupt erst tragfähig macht.
**Ziehen beginnt erst nach acht Pixeln.** Ohne diese Schwelle löst jeder
Klick einen Zug aus und die Knöpfe im Block wären nicht mehr bedienbar.
**Überbuchte Tage werden rot, aber nicht gesperrt.** Der Planer ist ein
Werkzeug, kein Aufpasser. Manchmal muss ein Tag überbucht sein.
### Probleme und Lösungen
| Problem | Lösung |
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Ein Zeitblock steht auch im Terminspiegel und erschien doppelt — einmal als eigene Planung, einmal als fremder Termin | Die Wochenabfrage filtert Termine heraus, deren `extId` zu einem Zeitblock gehört. |
| Ein Build lief 32 Minuten statt Sekunden | Systemlast durch einen gleichzeitigen Docker-Bau. Ein sauberer Neubau danach: 46 Sekunden. Kein Codeproblem. |
| Sich überschneidende Termine lagen unsichtbar übereinander | `spurenVerteilen` legt sie nebeneinander und gibt eine Spur wieder frei, sobald sie zu Ende ist. |
### Offene Punkte
- **Der Schreibweg ist nicht gegen die echten APIs geprüft.** Wie in M3
fehlen die Zugangsdaten. Die Logik ist getestet, der erste echte Schreib-
und Rückweg ist Florians.
- Die Dauer lässt sich nicht am Balken ziehen; wer eine andere will, ändert
den Aufwand der Aufgabe.
- Ein Zeitblock landet immer im ersten verbundenen Kalender. Das
Standard-Kalenderkonto je Bereich ist im Datenmodell vorbereitet, aber
noch nicht in der Oberfläche.
### Commits
- 5791047 feat(zeitbloecke): Datenmodell und Schleifenschutz
- 9c1c916 feat(zeitbloecke): Schreibweg in den Kalender und Rückweg zurück
- bcb4b8e feat(woche): Rasterlogik für die Wochenansicht
- fada445 feat(woche): Wochenansicht mit Ziehen und Ablegen
- 5b1e998 test: Wochenansicht und Ausplanen absichern
- 0974695 docs: Handbuchseite zur Wochenplanung
---
## 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
---
## 2026-09-10 – M1: Fundament
**Dauer:** 161 Minuten (12:06–14:47), Art `claude`.
**Ziel der Sitzung:** Das Projekt von Grund auf aufsetzen: Gerüst,
Datenbank, Anmeldung, Rollen, Audit, Instanzsteuerung, Betrieb und
Dokumentation.
### Erledigt
- Projektgerüst mit Next 16, React 19, TypeScript strict, Tailwind 4,
alle Versionen exakt gepinnt
- `npm run pruefen` als Sammelprüfung: Prettier, ESLint, `tsc`, Vitest,
ShellCheck
- Datenmodell und erste Migration: Benutzer mit Rolle, OIDC-Konten,
Passkeys, WebAuthn-Herausforderungen, Bereiche, Audit
- Anmeldung über OIDC, Passkey und Passwort, je Instanz zuschaltbar
- Rollenprüfung in Seite **und** Server Action, Audit je Schreibaktion
- Instanzsteuerung mit 404 statt Hinweis
- Bereiche verwalten, Protokollseite, Startseite, Einstellungen
- `/api/health`, Dockerfile, Compose-Stapel mit Migrationsdienst
- `backup.sh`, `update.sh`, `scripts/release.sh`, `scripts/zeit.sh`
- Unit-, Integrations-, Migrations- und E2E-Tests
- Screenshot-Kette mit eigener Wegwerf-Instanz
- README, Schnellstart, Handbuch, ROADMAP, CHANGELOG
### Entscheidungen mit Begründung
**Prisma 7.10.0 statt der notierten Fassung 6.** Fassung 6 ist überholt,
Fassung 8 liegt nur als RC auf `latest`. Prisma 7 nimmt die Verbindung
nicht mehr im Schema entgegen — sie steht in `prisma.config.ts`, der
Client bekommt den pg-Treiberadapter.
**TypeScript 6.0.3 statt 7.0.2.** TS 7 ist zwar `latest`, aber
`typescript-eslint@8.70` verlangt `typescript >=4.8.4 <6.1.0`. Mit TS 7
wäre die Lint-Kette kaputt gewesen.
**ESLint 9.39.5 statt 10.** Das in `eslint-config-next@16` gebündelte
`eslint-plugin-react` ruft noch `context.getFilename()`, das ESLint 10
entfernt hat. Der Lint-Lauf brach reproduzierbar ab.
**Eigener Auth.js-Adapter.** Der mitgelieferte `@auth/prisma-adapter`
greift fest auf `prisma.user` und `prisma.account` zu; unsere Modelle
heißen `Benutzer` und `Konto`. Der Adapter ist schmal und ersparte es,
die Fachbegriffe der Konvention zu opfern.
**Passkeys direkt über SimpleWebAuthn 14.** Der WebAuthn-Provider von
Auth.js ist als experimentell markiert und auf SimpleWebAuthn 9
festgelegt. Direkt angebunden bleibt die Fassung aktuell und der Ablauf
nachvollziehbar. Für die npm-Auflösung brauchte es `overrides`, weil
next-auth die älteren Fassungen als optionale Peers deklariert.
**Sitzungen als JWT, Rolle trotzdem aus der Datenbank.** Credentials-
Provider verlangen JWT. Damit eine Sperre nicht bis zum Ablauf des Tokens
wirkungslos bleibt, liest der `session`-Callback Rolle und Status bei
jeder Anfrage frisch. Die Tabellen `sitzung` und `verifizierungs_token`
wären damit tot und wurden wieder entfernt, solange es nur eine einzige
Migration gab.
**Migrationen als eigener Compose-Dienst.** Der Versuch, nur die
Prisma-CLI ins schlanke Laufzeitabbild zu kopieren, scheiterte an ihren
transitiven Modulen (`effect` über `@prisma/config`). Alle mitzunehmen
hätte das Abbild aufgebläht. Ein Einmaldienst aus der Bau-Stufe läuft vor
der App und muss sauber durchlaufen.
**Aufgabe, Zeitblock und Kalenderkonto bewusst nicht modelliert.** Sie
kommen mit M2, M4 und M3 — dann mit den Feldern, die der jeweilige
Provider wirklich braucht, statt heute geraten.
### Probleme und Lösungen
| Problem | Lösung |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prisma validate` lehnte `url` im Schema ab | Prisma 7 erwartet die Verbindung in `prisma.config.ts` und einen Treiberadapter im Client. |
| Integrationstest brach nach der ersten erwarteten Verletzung ab | In PostgreSQL bricht ein fehlgeschlagener Befehl die ganze Transaktion ab. Der Test läuft jetzt ohne Sammeltransaktion; alle Testdaten tragen eine Kennung und werden am Ende entfernt. |
| `prisma migrate diff` kannte `--from-url` nicht | In Prisma 7 heißen die Flaggen anders. Der Test vergleicht jetzt die eingespielte Wegwerf-Datenbank über `--from-config-datasource` gegen das Schema — und braucht damit keine Schattendatenbank. |
| Nach der Anmeldung ging es nicht zum ursprünglichen Ziel zurück | Das Layout leitet um, bevor die Seite drankommt, und kennt seinen Pfad nicht. Ein schlanker Proxy reicht ihn als Kopfzeile durch — ohne Anmeldelogik, weil er im Edge-Umfeld läuft. |
| Der Docker-Bau brach beim Sammeln der Seitendaten ab | Prisma-Client und Auth.js-Konfiguration wurden beim Laden des Moduls ausgewertet und brauchten Umgebungsvariablen, die es zur Bauzeit nicht gibt. Beide entstehen jetzt faul. |
| Ein Typfehler in `scripts/screenshots.ts` blockierte den Abbild-Bau | Entwicklungsskripte gehören nicht in den Docker-Kontext. `.dockerignore` schließt sie aus; geprüft werden sie von `npm run pruefen`. |
| ESLint beanstandete `setState` im Effekt | Der Hydratations-Trick im Farbschema-Knopf war unnötig: `next-themes` setzt die `dark`-Klasse vor der Hydratation, das erledigt CSS ohne Zustand. |
### Eigene Fehler, die ich korrigiert habe
- Die ersten vier Commits lagen direkt auf `main` statt auf einem
Meilenstein-Zweig und ohne Attributionszeile. Historie umgebaut:
`main` trägt nur noch einen leeren Wurzel-Commit, die Arbeit liegt auf
`m1-fundament`.
- Ein `git add -A` zog die Proxy-Umbenennung in den Doku-Commit. Wieder
getrennt.
- `scripts/screenshots.ts` wurde geschrieben, ohne danach `npm run
pruefen` laufen zu lassen; der Typfehler fiel erst im Docker-Bau auf.
### Offene Punkte
- Die `origin`-URL fehlt noch. Push und Merge stehen ohnehin erst nach
der Freigabe an.
- Der Schnellstart endet beim Passkey statt beim ersten Kalenderkonto und
der ersten Aufgabe — beides gibt es erst mit M3 und M2.
- Der Demo-Seed enthält noch keine erfundenen Termine; einen
Demo-Kalenderprovider gibt es erst mit M3.
- Lokal weichen die Ports ab (Datenbank 5435, App 3001), weil 5432 und
3000 auf diesem Rechner belegt sind. In `.env.example` stehen die
üblichen Werte.
### Commits
- de1f923 chore: Projektgerüst und Werkzeugkette einrichten
- 748d032 feat(datenbank): Datenmodell für Anmeldung, Bereiche und Audit
- e2b00f4 feat(instanz): Instanzsteuerung mit serverseitigem Riegel
- 19ca082 feat(zeit): Zeitrechnung und Anzeigeformate in Europe/Berlin
- 4ff67c1 feat(oberflaeche): Grundlayout, Navigation und Farbschema
- cc013fd feat(anmeldung): OIDC, Passkeys und Passwort mit Auth.js v5
- e9a2b4b feat(bereiche): Bereiche verwalten mit Rollenprüfung und Audit
- 1876ae4 feat(betrieb): Health-Endpunkt und Seed für eine frische Datenbank
- 24a799d chore: tsconfig-Anpassungen von Next 16 übernehmen
- 126ad93 test: Integrations-, Migrations- und E2E-Tests ergänzen
- f91d015 feat(betrieb): Abbild, Compose-Stapel und Betriebsskripte
- 56b5356 docs: README, Schnellstart, Handbuch, Fahrplan und CHANGELOG
- e3390c8 refactor: middleware in proxy umbenennen
- a882731 chore: Screenshot-Kette und Demo-Seed
- cf28ec1 test: Rollentrennung end-to-end absichern
- 6d04bc2 fix(oberflaeche): Kopfleiste einzeilig halten, Demo-Uhr feststellen
- 688d9f5 docs: Handbuchbilder aufnehmen
+4
View File
@@ -1 +1,5 @@
datum;start;ende;minuten;meilenstein;art;notiz
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig
2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
1 datum start ende minuten meilenstein art notiz
2 2026-09-10 12:06 14:47 161 M1 claude M1 Fundament fertiggestellt
3 2026-09-10 15:57 16:41 44 M2 claude M2 Aufgaben und Projekte fertig
4 2026-09-10 17:48 18:29 41 M3 claude M3 Kalender lesen fertig
5 2026-09-10 20:42 23:29 167 M4 claude M4 Wochenplanung fertig
+61
View File
@@ -0,0 +1,61 @@
# Wegwerf-Instanz nur für "npm run screenshots".
#
# Eigenes Compose-Projekt (planer-screenshots), eigenes Volume, eigene Ports.
# Sie kommt der Entwicklungs- oder Betriebsinstanz damit nicht in die Quere und
# wird nach dem Lauf samt Volume wieder entfernt.
services:
db:
image: postgres:16-alpine
restart: 'no'
environment:
POSTGRES_USER: demo
POSTGRES_PASSWORD: demo
POSTGRES_DB: demo
TZ: Europe/Berlin
ports:
- '${SCREENSHOT_DB_PORT:-5455}:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U demo -d demo']
interval: 3s
timeout: 3s
retries: 20
start_period: 10s
migration:
build:
context: .
dockerfile: Dockerfile
target: bauen
restart: 'no'
depends_on:
db:
condition: service_healthy
environment:
TZ: Europe/Berlin
DATABASE_URL: postgresql://demo:demo@db:5432/demo?schema=public
command: node node_modules/prisma/build/index.js migrate deploy
app:
build:
context: .
dockerfile: Dockerfile
target: betrieb
restart: 'no'
depends_on:
migration:
condition: service_completed_successfully
environment:
NODE_ENV: production
TZ: Europe/Berlin
INSTANZ: daheim
APP_URL: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
DATABASE_URL: postgresql://demo:demo@db:5432/demo?schema=public
AUTH_SECRET: nur-fuer-die-screenshot-instanz-nicht-geheim
AUTH_TRUST_HOST: 'true'
ANMELDUNG_PASSWORT: 'true'
WEBAUTHN_RP_ID: 127.0.0.1
WEBAUTHN_RP_NAME: Planer
WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
DEMO_MODUS: 'true'
ports:
- '${SCREENSHOT_APP_PORT:-3200}:3000'
+22 -12
View File
@@ -20,15 +20,36 @@ services:
retries: 10
start_period: 20s
# Einmaldienst: spielt die Migrationen ein und beendet sich. Läuft aus der
# Bau-Stufe, weil die Prisma-CLI eine Reihe Module braucht, die im schlanken
# Laufzeitabbild nichts zu suchen haben.
migration:
build:
context: .
dockerfile: Dockerfile
target: bauen
container_name: planer-migration
restart: 'no'
depends_on:
db:
condition: service_healthy
environment:
TZ: ${TZ:-Europe/Berlin}
DATABASE_URL: postgresql://${POSTGRES_USER:-planer}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planer}?schema=public
command: node node_modules/prisma/build/index.js migrate deploy
app:
build:
context: .
dockerfile: Dockerfile
target: betrieb
container_name: planer-app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
migration:
condition: service_completed_successfully
environment:
NODE_ENV: production
TZ: ${TZ:-Europe/Berlin}
@@ -45,21 +66,10 @@ services:
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:?WEBAUTHN_RP_ID fehlt in .env}
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Planer}
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:?WEBAUTHN_ORIGIN fehlt in .env}
# Der Demo-Modus ist im Betrieb nie erlaubt.
DEMO_MODUS: 'false'
ports:
- '${APP_PORT:-3000}:3000'
healthcheck:
test:
[
'CMD',
'node',
'-e',
"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
volumes:
db-daten:
+167
View File
@@ -0,0 +1,167 @@
# Schnellstart
Von `git clone` bis zur ersten Anmeldung in höchstens zehn Minuten.
Jeder Befehl ist zum Kopieren, jeder Schritt nennt das erwartete Ergebnis.
## Voraussetzungen
| Werkzeug | Fassung | Prüfen mit |
| ------------------ | ---------------- | ------------------------ |
| Docker mit Compose | 25 oder neuer | `docker compose version` |
| Git | 2.40 oder neuer | `git --version` |
| Node.js | 22.12 oder neuer | `node -v` |
Node wird nur für die Entwicklung gebraucht. Für den reinen Betrieb genügen
Docker und Git.
## 1. Holen und Verzeichnis wechseln
```bash
git clone <REMOTE_URL> planer
cd planer
```
**Erwartet:** Das Verzeichnis `planer` enthält unter anderem `package.json`,
`docker-compose.yml` und `.env.example`.
## 2. Konfiguration anlegen
```bash
cp .env.example .env
```
Jetzt die Pflichtwerte setzen. Die Geheimnisse werden erzeugt, nicht erfunden:
```bash
# Signiert die Sitzungen. Ohne diesen Wert startet die Anwendung nicht.
openssl rand -base64 32
# Ein Passwort für die Datenbank.
openssl rand -hex 16
```
Unter Windows ohne OpenSSL geht auch:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```
Diese Werte in `.env` eintragen:
| Variable | Beispiel | Bedeutung |
| ------------------- | -------------------------------------------------------------------- | ------------------------------------------------------- |
| `INSTANZ` | `daheim` | `firma` oder `daheim` – bestimmt, was es überhaupt gibt |
| `APP_URL` | `http://localhost:3000` | Adresse ohne Schrägstrich am Ende |
| `AUTH_SECRET` | _(erzeugt)_ | Signatur der Sitzungen |
| `POSTGRES_PASSWORD` | _(erzeugt)_ | Passwort der Datenbank |
| `DATABASE_URL` | `postgresql://planer:<Passwort>@localhost:5432/planer?schema=public` | muss dasselbe Passwort enthalten |
| `WEBAUTHN_RP_ID` | `localhost` | nackte Domain, ohne Schema und Port |
| `WEBAUTHN_ORIGIN` | `http://localhost:3000` | exakt wie im Browser, mit Port |
| `ADMIN_EMAIL` | `du@example.de` | erstes Verwaltungskonto |
| `ADMIN_PASSWORT` | _(mindestens 12 Zeichen)_ | nur nötig, wenn du dich ohne OIDC anmelden willst |
> **Sind die Ports 5432 oder 3000 auf deinem Rechner belegt?**
> Dann `POSTGRES_PORT` beziehungsweise `APP_PORT` in `.env` auf einen freien
> Wert setzen. `DATABASE_URL`, `APP_URL` und `WEBAUTHN_ORIGIN` müssen denselben
> Port nennen.
**Erwartet:** `.env` liegt im Projektverzeichnis. Sie gehört niemals ins Git —
`.gitignore` sorgt dafür.
## 3. Starten
```bash
docker compose up -d --build
```
Beim ersten Mal dauert der Bau einige Minuten. Der Ablauf ist: Datenbank
starten, Migrationen einspielen, Anwendung starten.
**Erwartet:**
```bash
docker compose ps
```
zeigt `db` als `healthy` und `app` als `Up`. Der Dienst `migration` taucht
nicht mehr auf — er hat seine Arbeit getan und sich beendet.
## 4. Prüfen, ob alles läuft
```bash
curl http://localhost:3000/api/health
```
**Erwartet:**
```json
{ "status": "ok", "version": "0.1.0", "instanz": "daheim", "datenbank": "ok" }
```
Steht dort `"datenbank": "fehler"`, stimmt `DATABASE_URL` nicht mit
`POSTGRES_PASSWORD` überein.
## 5. Erste Bereiche und das Verwaltungskonto anlegen
```bash
docker compose run --rm migration node node_modules/prisma/build/index.js db seed
```
**Erwartet:** Die Ausgabe nennt die vier angelegten Bereiche (Firma, Gewerbe,
Privat, Homelab) und bestätigt das Verwaltungskonto.
## 6. Anmelden
Im Browser <http://localhost:3000> öffnen.
**Erwartet:** Die Anmeldeseite. Mit `ADMIN_EMAIL` und `ADMIN_PASSWORT`
anmelden — danach steht oben „Guten Tag" und darunter die vier Bereiche.
> Ist OIDC eingerichtet, erscheint zusätzlich ein Knopf mit dem Namen aus
> `OIDC_NAME`. Der Rückleit-URI, den du beim Anbieter hinterlegen musst,
> lautet `<APP_URL>/api/auth/callback/oidc`.
## 7. Einen Passkey hinterlegen
Auf **Einstellungen** gehen, unter _Passkeys_ einen Namen für das Gerät
eintragen (etwa „Arbeitsrechner") und auf **Anlegen** klicken. Das Gerät
fragt nach Fingerabdruck, Gesicht oder PIN.
**Erwartet:** Der Passkey steht in der Liste. Ab jetzt genügt zum Anmelden die
E-Mail-Adresse und **Mit Passkey anmelden**.
> Hinterlege am besten zwei Passkeys auf verschiedenen Geräten. Sonst sperrst
> du dich aus, wenn ein Gerät verlorengeht.
## 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
zusammen mit `ADMIN_EMAIL` aus `.env` entfernen. Das Konto bleibt bestehen.
## Wie es weitergeht
- [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
- [ROADMAP](../ROADMAP.md) – was in den nächsten Meilensteinen kommt
## Wenn etwas klemmt
| Symptom | Ursache und Abhilfe |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `INSTANZ fehlt in .env` beim Start | `INSTANZ` ist leer. Erlaubt sind nur `firma` und `daheim`. |
| `Die Konfiguration ist unvollständig` | Die Meldung nennt die fehlende Variable. Sie nennt nie den Wert. |
| `port is already allocated` | Ein anderer Dienst hält den Port. `APP_PORT` oder `POSTGRES_PORT` ändern. |
| Passkey lässt sich nicht anlegen | `WEBAUTHN_ORIGIN` muss exakt der Adresse im Browser entsprechen, inklusive Port. `WEBAUTHN_RP_ID` ist die nackte Domain, ohne `https://`. |
| Anmeldung schlägt immer fehl | Bewusst gibt es nur eine Meldung für alle Fälle. Prüfe im Protokoll der Anwendung: `docker compose logs app`. |
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 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: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 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: 136 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.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+44
View File
@@ -0,0 +1,44 @@
# Handbuch
Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
## Erste Schritte
1. **[Anmelden](anmelden.md)** – die drei Wege hinein, Passkeys einrichten,
was tun, wenn nichts mehr geht
2. **[Bereiche einrichten](bereiche.md)** – Farbe, Arbeitsfenster und
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
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
Kalender legen
## Verwaltung
8. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
## Was noch kommt
Diese Seiten entstehen mit den nächsten Meilensteinen:
| Thema | Meilenstein |
| ------------------------------- | ----------- |
| Benachrichtigungen | M5 |
| Integrationen | M6 |
| Schnellerfassung vom Handy, API | M7 |
| MCP | M8 |
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
---
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
+83
View File
@@ -0,0 +1,83 @@
# Anmelden
Es gibt drei Wege in den Planer. Welche davon zu sehen sind, hängt davon ab,
was für deine Instanz eingerichtet wurde.
![Die Anmeldeseite](../bilder/anmelden-hell-schreibtisch.png)
| Weg | Wann sinnvoll |
| -------------------------------- | --------------------------------------------------------------------------------------------------- |
| **Unternehmensanmeldung** (OIDC) | In der Firma der Normalfall. Ein Klick, die Anmeldung übernimmt Entra ID beziehungsweise authentik. |
| **Passkey** | Der bequemste Weg am eigenen Gerät: Fingerabdruck, Gesicht oder Geräte-PIN, kein Passwort. |
| **E-Mail und Passwort** | Der Rückfallweg. Oft nur daheim freigeschaltet. |
## Mit Passkey anmelden
1. E-Mail-Adresse eintragen.
2. Auf **Mit Passkey anmelden** klicken.
3. Das Gerät fragt nach Fingerabdruck, Gesicht oder PIN.
Fertig — du landest auf der Startseite.
> **Warum erst die E-Mail-Adresse?** Damit der Planer weiß, welche Passkeys er
> anbieten soll. Er antwortet dabei für unbekannte Adressen genauso wie für
> bekannte, damit sich von außen nicht abfragen lässt, wer hier ein Konto hat.
## Einen Passkey einrichten
Das geht erst, wenn du angemeldet bist — also beim ersten Mal über OIDC oder
über E-Mail und Passwort.
1. Oben rechts auf **Einstellungen**.
2. Im Abschnitt _Passkeys_ einen Namen für das Gerät eintragen, etwa
„Arbeitsrechner" oder „Diensthandy".
3. Auf **Anlegen** klicken und die Abfrage des Geräts bestätigen.
![Passkeys in den Einstellungen](../bilder/einstellungen-hell-schreibtisch.png)
Der Passkey steht danach in der Liste, mit Anlegedatum und letzter Benutzung.
> **Lege zwei Passkeys an, auf zwei verschiedenen Geräten.** Sonst sperrst du
> dich aus, sobald ein Gerät verlorengeht oder zurückgesetzt wird.
Unter jedem Eintrag steht, ob der Passkey **geräteübergreifend** ist — dann
liegt er im Schlüsselbund von Apple, Google oder deinem Passwortmanager und ist
auf deinen anderen Geräten ebenfalls verfügbar. Steht dort **nur dieses
Gerät**, ist er an genau dieses Gerät gebunden.
## Einen Passkey entfernen
In den Einstellungen auf das Papierkorb-Symbol neben dem Eintrag. Der Passkey
ist sofort ungültig; auf dem Gerät selbst bleibt ein Rest zurück, den du dort
im Schlüsselbund löschen kannst.
## Abmelden
Oben rechts auf das Symbol mit dem Pfeil aus der Tür.
## Wenn es klemmt
| Was du siehst | Was dahintersteckt |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| „Anmeldung nicht möglich. Bitte Angaben prüfen." | Diese eine Meldung gilt für alle Fälle: falsches Passwort, unbekannte Adresse, gesperrtes Konto. Das ist Absicht — sonst ließe sich abfragen, wer hier ein Konto hat. |
| Es passiert nichts nach dem Klick auf **Mit Passkey anmelden** | Du hast den Systemdialog abgebrochen. Einfach noch einmal klicken. |
| „Dieses Gerät ist hier schon hinterlegt." | Für dieses Gerät gibt es bereits einen Passkey. Ein zweiter bringt nichts. |
| Der Passkey-Knopf tut gar nichts | Der Browser unterstützt WebAuthn nicht, oder die Seite läuft nicht über HTTPS. Passkeys brauchen eine sichere Verbindung — `localhost` ausgenommen. |
| Passkey lässt sich nicht anlegen | Meist stimmt die Konfiguration nicht: `WEBAUTHN_ORIGIN` muss exakt der Adresse im Browser entsprechen, inklusive Port. Siehe [README](../../README.md). |
## Ausgesperrt?
Wenn kein Weg mehr hineinführt, hilft nur jemand mit Zugriff auf den Server.
Dort lässt sich mit `ADMIN_EMAIL` und `ADMIN_PASSWORT` in der `.env` und einem
erneuten Seed-Lauf wieder ein Passwort setzen — siehe
[Schnellstart, Schritt 5](../SCHNELLSTART.md).
## Rollen
Unten in der Fußzeile und in den Einstellungen steht, welche Rolle du hast.
- **Benutzer** – sieht und plant die eigenen Sachen.
- **Verwaltung** – darf zusätzlich Bereiche ändern und das
[Protokoll](protokoll.md) einsehen.
Die Rolle vergibt die Verwaltung; sie lässt sich nicht selbst hochstufen.
+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 |
+80
View File
@@ -0,0 +1,80 @@
# Bereiche einrichten
Ein **Bereich** ist ein Lebensbereich: Firma, Gewerbe, Privat, Homelab. Jede
Aufgabe und jeder Zeitblock gehört später zu genau einem davon.
Der Bereich bestimmt drei Dinge:
- **die Farbe**, an der du im Kalender sofort erkennst, worum es geht,
- **das Arbeitsfenster** — die Tageszeit, in der für diesen Bereich überhaupt
geplant wird,
- **die Tageskapazität** — wie viele Minuten je Wochentag höchstens
hineingehen.
![Die Bereichsseite](../bilder/bereiche-hell-schreibtisch.png)
> Bereiche ändern darf nur die **Verwaltung**. Als Benutzer siehst du sie,
> aber ohne die Formulare.
## Einen Bereich anlegen
1. Im Menü auf **Bereiche**.
2. Ganz unten unter _Neuen Bereich anlegen_ die Felder ausfüllen.
3. Auf **Bereich anlegen** klicken.
| Feld | Bedeutung |
| ---------------------------- | ----------------------------------------------------------------------------------------------- |
| **Name** | Zwei bis 40 Zeichen, muss eindeutig sein |
| **Farbe** | Wird zum Farbpunkt in Listen und später im Kalender |
| **Arbeitsfenster von / bis** | Zwischen diesen Uhrzeiten wird geplant. Muss nach dem Beginn enden |
| **Tageskapazität** | Minuten je Wochentag. 0 heißt: an diesem Tag gar nicht |
| **In Benutzung** | Abgehakt lassen. Ohne Haken bleibt der Bereich erhalten, taucht aber nicht mehr zur Auswahl auf |
| **Reihenfolge** | Kleinere Zahlen stehen weiter oben |
## Was eine sinnvolle Kapazität ist
Nicht die Zeit, die der Tag hergibt — die Zeit, die **wirklich für geplante
Arbeit** übrig bleibt, nach Besprechungen, Störungen und Pausen.
Ein brauchbarer Anfang:
| Bereich | Montag bis Donnerstag | Freitag | Wochenende |
| ------- | --------------------- | ------- | --------------- |
| Firma | 360 min (6 h) | 240 min | 0 |
| Gewerbe | 90 min | 60 min | Samstag 180 min |
| Privat | 60 min | 120 min | je 240 min |
| Homelab | 0 bis 60 min | 0 | Samstag 120 min |
Wenn am Ende der Woche regelmäßig etwas liegenbleibt, ist die Kapazität zu
hoch angesetzt, nicht der Tag zu kurz.
> Die Tageskapazität darf nie länger sein als das Arbeitsfenster. Ein Fenster
> von 08:00 bis 17:00 fasst höchstens 540 Minuten; trägst du mehr ein, weist
> das Formular es ab.
## Einen Bereich ändern
Auf **Bearbeiten** unter dem Bereich klicken, ändern, **Speichern**. Mit
**Schließen** verwirfst du, was du noch nicht gespeichert hast.
## Einen Bereich loswerden
Zwei Wege, und meistens ist der erste der richtige:
1. **Stilllegen** – den Haken bei _In Benutzung_ entfernen und speichern. Alles
Bisherige bleibt erhalten, der Bereich taucht nur nicht mehr zur Auswahl auf.
2. **Löschen** – der rote Knopf. Endgültig.
> Ab M2 hängen Aufgaben an Bereichen. Dann ist Stilllegen fast immer die
> richtige Wahl, weil beim Löschen auch die Zuordnung verlorenginge.
Jede Änderung landet im [Protokoll](protokoll.md).
## Typische Fehler
| Meldung | Was zu tun ist |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| „Diesen Namen gibt es schon." | Namen sind eindeutig. Vielleicht existiert der Bereich stillgelegt schon. |
| „Das Arbeitsfenster muss nach seinem Beginn enden." | Von-Zeit liegt hinter der Bis-Zeit. Über Mitternacht hinweg geht nicht — dafür lieber zwei Bereiche. |
| „Keine Tageskapazität darf länger sein als das Arbeitsfenster." | Entweder die Kapazität senken oder das Fenster weiten. |
| „Dazu fehlt dir die Berechtigung." | Du bist als Benutzer angemeldet, nicht als Verwaltung. |
+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 |
+57
View File
@@ -0,0 +1,57 @@
# Protokoll
Jede schreibende Aktion im Planer hinterlässt einen Eintrag: wer, wann, was,
und woran. Einträge lassen sich weder ändern noch löschen.
Die Seite steht nur der **Verwaltung** offen. Als Benutzer siehst du den
Menüpunkt gar nicht erst; wer die Adresse direkt aufruft, landet auf einem
Hinweis.
![Die Protokollseite](../bilder/protokoll-hell-schreibtisch.png)
## Was dort steht
| Spalte | Bedeutung |
| ------------- | --------------------------------------------------------------------- |
| **Zeitpunkt** | Datum und Uhrzeit in Europe/Berlin |
| **Wer** | Die E-Mail-Adresse zum Zeitpunkt der Aktion |
| **Was** | Die Aktion, in der Form `objekt.verb` |
| **Objekt** | Die Art des betroffenen Objekts, dahinter die fachlichen Einzelheiten |
Angezeigt werden die letzten 100 Einträge, die neuesten zuerst.
## Die Aktionen
| Aktion | Bedeutung |
| ------------------- | --------------------------------------------------- |
| `bereich.angelegt` | Ein neuer Bereich ist entstanden |
| `bereich.geaendert` | Name, Farbe, Fenster oder Kapazität wurden geändert |
| `bereich.geloescht` | Ein Bereich wurde entfernt |
| `passkey.angelegt` | Jemand hat ein Gerät hinterlegt |
| `passkey.entfernt` | Ein Gerät wurde entfernt |
Mit jedem Meilenstein kommen Aktionen hinzu.
## Was dort bewusst **nicht** steht
- Keine Passwörter, Hashes oder Tokens.
- Keine Termin-Betreffs und keine Mailinhalte.
- Keine Zugangsdaten der Kalenderkonten.
Das ist doppelt abgesichert: Solche Felder werden gar nicht erst übergeben,
und vor dem Speichern ersetzt der Planer zusätzlich jedes Feld, dessen Name
nach einem Geheimnis aussieht, durch `[entfernt]`. Wenn du dort `[entfernt]`
liest, hat dieser zweite Riegel gegriffen.
## Warum die E-Mail-Adresse und nicht nur die Kennung
Wird ein Benutzer gelöscht, verschwinden seine Passkeys und verknüpften
Konten — der Protokolleintrag bleibt. Die Verknüpfung zum Konto wird gelöst,
die Adresse bleibt als Nachweis stehen. Sonst wäre das Protokoll nach einer
Löschung wertlos.
## Aufbewahrung
Einträge werden nicht automatisch entfernt. Die Tabelle wächst langsam — ein
Eintrag je Änderung, nicht je Seitenaufruf. Sollte sie einmal stören, gehört
das Aufräumen in die Sicherungsroutine und nicht in die Anwendung.
+106
View File
@@ -0,0 +1,106 @@
# Wochenplanung und Zeitblöcke
Hier wird aus einer Aufgabe ein Termin. Du ziehst sie in ein Zeitfenster, und
der Planer legt sie als **Zeitblock** in deinen echten Kalender.
![Die Wochenansicht](../bilder/woche-hell-schreibtisch.png)
## Der Aufbau
| Wo | Was |
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Links** | Alles, was noch eingeplant werden kann — offen, geplant oder wartend. Ideen bleiben draußen |
| **Rechts** | Sieben Tage im Viertelstundenraster |
| **Über jeder Spalte** | Belegte Zeit gegen die Kapazität dieses Tages |
| **Graue Balken** | Termine aus deinem Kalender. Sie gehören dem Kalender, nicht dem Planer — hier lassen sie sich nicht anfassen |
| **Farbige Balken** | Deine Zeitblöcke, in der Farbe ihres Bereichs |
Das sichtbare Stundenfenster ergibt sich aus den Arbeitsfenstern **aller**
Bereiche, mit einer Stunde Luft nach oben und unten.
## Eine Aufgabe einplanen
Aufgabe links am Griff packen und in ein Zeitfenster ziehen. Fertig — der
Zeitblock steht sofort im Raster und wenige Augenblicke später in deinem
Kalender.
> **Die Dauer kommt aus dem geschätzten Aufwand.** Wer bei der Aufgabe
> „90 Minuten" einträgt, bekommt einen 90-Minuten-Block. Ohne Schätzung sind
> es 60 Minuten, die du danach anpassen kannst.
Eine eingeplante Aufgabe wechselt automatisch von _Offen_ auf _Geplant_.
## Verschieben
Am Griff im Block packen und woandershin ziehen. Der Planer zieht den
Kalendertermin nach.
## Ausplanen
Das Kreuz im Block. Der Termin verschwindet aus dem Kalender; die Aufgabe
bleibt und fällt zurück auf _Offen_, wenn nichts anderes mehr für sie geplant
ist.
## Wenn du im Kalender verschiebst
Das darfst du — und der Planer zieht nach. Beim nächsten Abgleich (spätestens
nach 15 Minuten) steht der Block dort, wo du ihn im Kalender hingelegt hast.
> **Der Kalender gewinnt.** Immer. Wer dort etwas verschiebt, meint es so;
> säße der Planer am längeren Hebel, würde er dir den Termin wieder wegziehen
> und du wüsstest nicht, warum.
>
> Löschst du den Termin im Kalender, verschwindet auch der Zeitblock. Die
> Aufgabe selbst bleibt.
### Warum sich das nicht aufschaukelt
Der Planer merkt sich zu jedem Zeitblock einen Fingerabdruck des Zeitraums,
den er zuletzt geschrieben hat. Findet er beim Abgleich genau diesen
Fingerabdruck wieder, war es seine eigene Änderung — er tut nichts. Weicht er
ab, hat jemand anderes verschoben.
Ohne diesen Kniff würden App und Kalender einander endlos hinterherschreiben.
## Das Warnzeichen im Block
Ein kleines Dreieck heißt: **noch nicht im Kalender**. Meist war der Anbieter
gerade nicht erreichbar.
Der Zeitblock bleibt trotzdem stehen — deine Planung soll nicht verlorengehen,
bloß weil ein Server hustet. Verschiebe den Block einmal, dann trägt der
Planer ihn nach.
## Die Zahl über der Spalte
`3 h / 6 h` heißt: sechs Stunden Kapazität an diesem Tag, drei davon schon
belegt. Gezählt werden Kalendertermine **und** deine Zeitblöcke.
Wird die Zahl rot, ist der Tag überbucht. Das ist keine Sperre — der Planer
lässt dich weiterplanen. Es ist ein Hinweis, dass der Tag nicht aufgeht.
Ganztägige Termine zählen nicht mit. Ein Urlaubstag im Kalender blockiert
sonst rechnerisch den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
## Blättern
**Zurück**, **Diese Woche** und **Weiter**. Die Woche steht in der Adresse —
eine bestimmte Woche lässt sich also als Lesezeichen ablegen.
## Wenn es klemmt
| Was du siehst | Was dahintersteckt |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| „Ohne verbundenen Kalender lässt sich nichts einplanen." | Erst unter [Kalender](kalenderkonten.md) ein Konto verbinden |
| „Eingeplant, aber nicht in den Kalender geschrieben" | Der Anbieter war nicht erreichbar. Der Block steht, verschiebe ihn einmal zum Nachtragen |
| „Der Termin wurde inzwischen im Kalender geändert." | Jemand war schneller. Der Kalender gewinnt; der nächste Abgleich holt den dortigen Stand |
| „Zeitblöcke rasten in 15-Minuten-Schritten ein." | Das Raster ist Absicht. Genauer zu planen bringt nichts |
| Ein Block ist nicht zu sehen | Er liegt außerhalb des sichtbaren Stundenfensters. Das Fenster folgt den Arbeitsfenstern der Bereiche — siehe [Bereiche](bereiche.md) |
## Was noch nicht geht
- **Ein Zeitblock landet immer im ersten verbundenen Kalender.** Die Auswahl
je Bereich ist im Datenmodell vorbereitet, aber noch nicht in der
Oberfläche.
- **Die Dauer lässt sich nicht am Balken ziehen.** Wer eine andere Dauer will,
ändert den Aufwand der Aufgabe und plant neu ein.
+80
View File
@@ -0,0 +1,80 @@
import { expect, test } from '@playwright/test'
/**
* Rauchtest der Anmeldung.
*
* Prüft den Weg, der in M1 wirklich trägt: Wer nicht angemeldet ist, kommt
* nirgends hin; mit richtigem Passwort schon; und die Verwaltungsseite bleibt
* verschlossen, wenn die Rolle nicht reicht.
*
* Voraussetzung: "npm run db:seed" ist gelaufen und ADMIN_EMAIL sowie
* ADMIN_PASSWORT sind gesetzt.
*/
const EMAIL = process.env.ADMIN_EMAIL ?? ''
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
test.describe('Anmeldung', () => {
test.skip(
!EMAIL || !PASSWORT,
'ADMIN_EMAIL und ADMIN_PASSWORT sind nicht gesetzt – ohne sie gibt es kein Testkonto.',
)
test('leitet nicht Angemeldete zur Anmeldeseite', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/anmelden/)
await expect(page.getByRole('heading', { name: 'Planer' })).toBeVisible()
})
test('merkt sich das Ziel und kehrt nach der Anmeldung dorthin zurück', async ({
page,
}) => {
await page.goto('/bereiche')
await expect(page).toHaveURL(/\/anmelden\?weiter=%2Fbereiche/)
})
test('weist falsche Zugangsdaten ab, ohne zu verraten warum', async ({ page }) => {
await page.goto('/anmelden')
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
await page.getByLabel('Passwort').fill('ganz-sicher-falsch-123')
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(page.getByText('Anmeldung nicht möglich')).toBeVisible()
await expect(page).toHaveURL(/\/anmelden/)
})
test('lässt mit richtigem Passwort hinein und wieder hinaus', async ({ page }) => {
await page.goto('/anmelden')
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
await page.getByLabel('Passwort').fill(PASSWORT)
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(page.getByRole('heading', { name: /Guten Tag/ })).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 expect(page).toHaveURL(/\/anmelden/)
})
test('zeigt der Verwaltung das Protokoll', async ({ page }) => {
await page.goto('/anmelden')
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
await page.getByLabel('Passwort').fill(PASSWORT)
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
await page.getByRole('link', { name: 'Protokoll' }).click()
await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible()
})
})
test('der Health-Endpunkt antwortet ohne Anmeldung', async ({ request }) => {
const antwort = await request.get('/api/health')
expect(antwort.ok()).toBe(true)
const inhalt = (await antwort.json()) as Record<string, unknown>
expect(inhalt.status).toBe('ok')
expect(inhalt.datenbank).toBe('ok')
expect(typeof inhalt.version).toBe('string')
})
+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()
})
})
+74
View File
@@ -0,0 +1,74 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type Page } from '@playwright/test'
import bcrypt from 'bcryptjs'
import { PrismaClient } from '../src/generated/prisma/client.js'
/**
* Prüft, dass die Rollentrennung serverseitig hält.
*
* Die Oberfläche blendet Knöpfe aus — darauf allein darf man sich nicht
* verlassen. Deshalb ruft dieser Test die Verwaltungsseite direkt auf, statt
* nach einem Menüpunkt zu suchen.
*/
const EMAIL = 'zzz-e2e-benutzer@example.invalid'
const PASSWORT = 'nur-fuer-den-test-1234'
const verbindung = process.env.DATABASE_URL ?? ''
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
test.describe('Rollentrennung', () => {
test.skip(!verbindung, 'DATABASE_URL ist nicht gesetzt.')
test.beforeAll(async () => {
await db.benutzer.deleteMany({ where: { email: EMAIL } })
await db.benutzer.create({
data: {
email: EMAIL,
name: 'Testbenutzer ohne Rechte',
rolle: 'BENUTZER',
passwortHash: await bcrypt.hash(PASSWORT, 10),
},
})
})
test.afterAll(async () => {
await db.auditEintrag.deleteMany({ where: { benutzerEmail: EMAIL } })
await db.benutzer.deleteMany({ where: { email: EMAIL } })
await db.$disconnect()
})
async function anmelden(seite: Page): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
await seite.getByLabel('Passwort').fill(PASSWORT)
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
}
test('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => {
await anmelden(page)
await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
})
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
page,
}) => {
await anmelden(page)
await page.goto('/verwaltung/protokoll')
await expect(page).toHaveURL(/\/keine-berechtigung/)
await expect(
page.getByRole('heading', { name: /Dazu fehlt dir die Berechtigung/ }),
).toBeVisible()
})
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
await anmelden(page)
await page.goto('/bereiche')
await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Bearbeiten' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Bereich anlegen' })).toHaveCount(0)
})
})
+126
View File
@@ -0,0 +1,126 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type Page } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
/**
* Wochenansicht und Zeitblöcke.
*
* Ohne echtes Kalenderkonto lässt sich der Schreibweg zum Anbieter nicht
* prüfen — er scheitert dann und der Zeitblock trägt einen Fehler. Genau das
* ist aber selbst prüfenswert: Die Planung muss auch dann überleben, wenn der
* Kalender nicht erreichbar ist.
*/
const EMAIL = process.env.ADMIN_EMAIL ?? ''
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
const PRAEFIX = `E2E-W-${Date.now()}`
const verbindung = process.env.DATABASE_URL ?? ''
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
test.describe('Woche', () => {
test.skip(
!EMAIL || !PASSWORT || !verbindung,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
)
test.afterAll(async () => {
await db.zeitblock.deleteMany({
where: { aufgabe: { titel: { startsWith: PRAEFIX } } },
})
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: PRAEFIX } } })
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'zeitblock.' } } })
await db.$disconnect()
})
async function anmelden(seite: Page): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
await seite.getByLabel('Passwort').fill(PASSWORT)
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
}
test('weist ohne verbundenen Kalender darauf hin', async ({ page }) => {
await db.kalenderkonto.deleteMany({})
await anmelden(page)
await page.goto('/woche')
await expect(page.getByRole('heading', { name: 'Woche' })).toBeVisible()
await expect(page.getByText('Noch kein Kalender verbunden')).toBeVisible()
await expect(
page.getByText('Ohne verbundenen Kalender lässt sich nichts'),
).toBeVisible()
})
test('zeigt einen Zeitblock im Raster und plant ihn wieder aus', async ({ page }) => {
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
// Ein Kalenderkonto ohne gültige Tokens: die Ansicht funktioniert, der
// Schreibweg zum Anbieter nicht.
const konto = await db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: `${PRAEFIX} Testkalender`,
extKontoId: `${PRAEFIX}-konto`,
},
})
const aufgabe = await db.aufgabe.create({
data: { titel: `${PRAEFIX} Angebot schreiben`, aufwandMinuten: 60 },
})
// Zeitblock auf 10:00 Uhr des Montags dieser Woche legen.
const jetzt = new Date()
const montag = new Date(jetzt)
montag.setUTCHours(0, 0, 0, 0)
montag.setUTCDate(montag.getUTCDate() - ((montag.getUTCDay() + 6) % 7))
const beginn = new Date(montag.getTime() + 10 * 60 * 60 * 1000)
await db.zeitblock.create({
data: {
aufgabeId: aufgabe.id,
kalenderkontoId: konto.id,
beginn,
ende: new Date(beginn.getTime() + 60 * 60 * 1000),
},
})
await db.aufgabe.update({
where: { id: aufgabe.id },
data: { status: 'GEPLANT' },
})
await anmelden(page)
await page.goto('/woche')
const block = page.getByTitle(new RegExp(`${PRAEFIX} Angebot schreiben`))
await expect(block).toBeVisible()
// Ausplanen: der Block verschwindet, die Aufgabe fällt zurück auf offen.
await block.getByRole('button', { name: 'Ausplanen' }).click()
await expect(page.getByText('ausgeplant')).toBeVisible()
await expect
.poll(async () => db.zeitblock.count({ where: { aufgabeId: aufgabe.id } }))
.toBe(0)
const danach = await db.aufgabe.findUniqueOrThrow({ where: { id: aufgabe.id } })
expect(danach.status).toBe('OFFEN')
})
test('lässt sich durch die Wochen blättern', async ({ page }) => {
await anmelden(page)
await page.goto('/woche')
await page.getByRole('link', { name: 'Weiter' }).click()
await expect(page).toHaveURL(/versatz=1/)
await page.getByRole('link', { name: 'Zurück' }).click()
await expect(page).toHaveURL(/versatz=0/)
await page.getByRole('link', { name: 'Diese Woche' }).click()
await expect(page).toHaveURL(/\/woche$/)
})
})
+43 -2
View File
@@ -1,15 +1,17 @@
{
"name": "planer",
"version": "0.0.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "planer",
"version": "0.0.0",
"version": "0.4.0",
"dependencies": {
"@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0",
"@radix-ui/react-avatar": "1.2.6",
@@ -593,6 +595,45 @@
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@electric-sql/pglite": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "planer",
"version": "0.0.0",
"version": "0.4.0",
"private": true,
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
"type": "module",
@@ -26,7 +26,8 @@
"db:migrieren": "prisma migrate dev",
"db:einspielen": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"db:demo-seed": "tsx prisma/demo-seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
@@ -39,6 +40,8 @@
"dependencies": {
"@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0",
"@radix-ui/react-avatar": "1.2.6",
+46
View File
@@ -0,0 +1,46 @@
import { defineConfig, devices } from '@playwright/test'
import 'dotenv/config'
const PORT = Number(process.env.E2E_PORT ?? 3100)
const BASIS = `http://127.0.0.1:${PORT}`
/**
* E2E-Tests gegen eine echte, gebaute Anwendung.
*
* Der Testserver läuft auf einem eigenen Port, damit er einer laufenden
* Entwicklungsinstanz nicht in die Quere kommt. Die Datenbank ist dieselbe wie
* im Entwicklungsbetrieb – die Tests legen nur Daten mit eigenem Präfix an und
* räumen hinter sich auf.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI ? 'github' : 'list',
timeout: 30_000,
use: {
baseURL: BASIS,
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'npm run start',
url: `${BASIS}/api/health`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
PORT: String(PORT),
APP_URL: BASIS,
WEBAUTHN_ORIGIN: BASIS,
WEBAUTHN_RP_ID: '127.0.0.1',
},
},
})
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from '@playwright/test'
const PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
/**
* Eigene Konfiguration für die Screenshot-Instanz. Sie startet keinen Server —
* darum kümmert sich scripts/screenshots.ts über Docker Compose.
*/
export default defineConfig({
testDir: './screenshots',
fullyParallel: false,
workers: 1,
reporter: 'list',
timeout: 60_000,
use: {
baseURL: `http://127.0.0.1:${PORT}`,
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
},
})
+6 -2
View File
@@ -1,5 +1,5 @@
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'
import { defineConfig } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
@@ -8,6 +8,10 @@ export default defineConfig({
seed: 'tsx prisma/seed.ts',
},
datasource: {
url: env('DATABASE_URL'),
// Bewusst über process.env statt über den env()-Helfer: "prisma generate"
// läuft auch beim Bauen des Abbilds, wo es noch gar keine Datenbank gibt.
// env() bricht dort ab. Fehlt die URL für migrate oder studio, meldet
// Prisma das selbst.
url: process.env.DATABASE_URL ?? '',
},
})
+432
View File
@@ -0,0 +1,432 @@
/**
* Daten für die Screenshot-Instanz.
*
* Alles hier ist frei erfunden. Diese Datei darf niemals gegen eine
* Datenbank mit echten Daten laufen — deshalb bricht sie ab, wenn
* DEMO_MODUS nicht ausdrücklich auf true steht.
*
* Aufruf: über npm run screenshots, nicht von Hand.
*/
import 'dotenv/config'
import { PrismaPg } from '@prisma/adapter-pg'
import bcrypt from 'bcryptjs'
import { PrismaClient } from '../src/generated/prisma/client.js'
if (process.env.DEMO_MODUS !== 'true') {
console.error(
'Der Demo-Seed läuft nur mit DEMO_MODUS=true. Das ist Absicht: er würde ' +
'sonst erfundene Daten in eine echte Datenbank schreiben.',
)
process.exit(1)
}
const verbindung = process.env.DATABASE_URL
if (!verbindung) {
console.error('DATABASE_URL ist nicht gesetzt.')
process.exit(1)
}
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
export const DEMO_EMAIL = 'martina.beispiel@planer.invalid'
export const DEMO_PASSWORT = 'demo-instanz-nur-fuer-bilder'
const BEREICHE = [
{
name: 'Firma',
farbe: '#2563eb',
reihenfolge: 1,
arbeitsfensterVon: 450,
arbeitsfensterBis: 990,
kapazitaetMo: 360,
kapazitaetDi: 360,
kapazitaetMi: 360,
kapazitaetDo: 360,
kapazitaetFr: 240,
kapazitaetSa: 0,
kapazitaetSo: 0,
},
{
name: 'Gewerbe',
farbe: '#7c3aed',
reihenfolge: 2,
arbeitsfensterVon: 1020,
arbeitsfensterBis: 1260,
kapazitaetMo: 90,
kapazitaetDi: 90,
kapazitaetMi: 90,
kapazitaetDo: 90,
kapazitaetFr: 60,
kapazitaetSa: 180,
kapazitaetSo: 0,
},
{
name: 'Privat',
farbe: '#16a34a',
reihenfolge: 3,
arbeitsfensterVon: 1020,
arbeitsfensterBis: 1320,
kapazitaetMo: 60,
kapazitaetDi: 60,
kapazitaetMi: 60,
kapazitaetDo: 60,
kapazitaetFr: 120,
kapazitaetSa: 240,
kapazitaetSo: 240,
},
{
name: 'Homelab',
farbe: '#ea580c',
reihenfolge: 4,
arbeitsfensterVon: 1140,
arbeitsfensterBis: 1380,
kapazitaetMo: 0,
kapazitaetDi: 60,
kapazitaetMi: 0,
kapazitaetDo: 60,
kapazitaetFr: 0,
kapazitaetSa: 120,
kapazitaetSo: 60,
},
]
/** Erfundene Protokolleinträge, damit die Protokollseite nicht leer wirkt. */
const PROTOKOLL = [
{ aktion: 'bereich.angelegt', objektTyp: 'Bereich', details: { name: 'Homelab' } },
{
aktion: 'bereich.geaendert',
objektTyp: 'Bereich',
details: { vorher: 'Nebenerwerb', nachher: 'Gewerbe' },
},
{
aktion: 'passkey.angelegt',
objektTyp: 'Passkey',
details: { bezeichnung: 'Diensthandy' },
},
]
/**
* 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> {
console.log('Demo-Daten einspielen.')
for (const bereich of BEREICHE) {
await db.bereich.upsert({
where: { name: bereich.name },
update: bereich,
create: bereich,
})
}
const benutzer = await db.benutzer.upsert({
where: { email: DEMO_EMAIL },
update: { rolle: 'ADMIN', name: 'Martina Beispiel' },
create: {
email: DEMO_EMAIL,
name: 'Martina Beispiel',
rolle: 'ADMIN',
passwortHash: await bcrypt.hash(DEMO_PASSWORT, 10),
},
})
await db.auditEintrag.deleteMany({})
// Feste Zeitpunkte, damit die Bilder zwischen zwei Läufen gleich bleiben.
let versatz = 0
for (const eintrag of PROTOKOLL) {
versatz += 37
await db.auditEintrag.create({
data: {
zeitpunkt: new Date(Date.UTC(2026, 2, 17, 9, versatz % 60)),
benutzerId: benutzer.id,
benutzerEmail: benutzer.email,
aktion: eintrag.aktion,
objektTyp: eintrag.objektTyp,
details: eintrag.details,
instanz: 'daheim',
},
})
}
await db.passkey.deleteMany({ where: { benutzerId: benutzer.id } })
await db.passkey.create({
data: {
benutzerId: benutzer.id,
credentialId: 'demo-passkey-diensthandy',
publicKey: Buffer.from([0]),
bezeichnung: 'Diensthandy',
gesichert: true,
erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 15)),
zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 7, 42)),
},
})
// --- 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',
},
})
// --- Zeitblöcke: eingeplante Aufgaben im Kalender -------------------------
await db.zeitblock.deleteMany({})
const einzuplanen = await db.aufgabe.findMany({
where: {
titel: {
in: [
'Wochenbericht an die Geschäftsführung',
'Angebot Heizungstausch nachfassen',
],
},
},
select: { id: true, titel: true, aufwandMinuten: true },
})
let blockStunde = 15
for (const aufgabe of einzuplanen) {
const beginn = new Date(Date.UTC(2026, 2, 17, blockStunde - 1, 0))
await db.zeitblock.create({
data: {
aufgabeId: aufgabe.id,
kalenderkontoId: kalenderkonto.id,
beginn,
ende: new Date(beginn.getTime() + (aufgabe.aufwandMinuten ?? 60) * 60_000),
extId: `demo-block-${aufgabe.id}`,
hash: 'demo',
},
})
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
blockStunde += 2
}
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
await db.aufgabe.create({
data: {
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}`)
}
main()
.then(async () => {
await db.$disconnect()
})
.catch(async (fehler: unknown) => {
console.error(fehler)
await db.$disconnect()
process.exit(1)
})
@@ -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';
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "zeitblock" (
"id" TEXT NOT NULL,
"aufgabeId" TEXT NOT NULL,
"kalenderkontoId" TEXT NOT NULL,
"beginn" TIMESTAMP(3) NOT NULL,
"ende" TIMESTAMP(3) NOT NULL,
"extId" TEXT,
"etag" TEXT,
"hash" TEXT,
"letzterFehler" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "zeitblock_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "zeitblock_beginn_ende_idx" ON "zeitblock"("beginn", "ende");
-- CreateIndex
CREATE INDEX "zeitblock_aufgabeId_idx" ON "zeitblock"("aufgabeId");
-- CreateIndex
CREATE UNIQUE INDEX "zeitblock_kalenderkontoId_extId_key" ON "zeitblock"("kalenderkontoId", "extId");
-- AddForeignKey
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_aufgabeId_fkey" FOREIGN KEY ("aufgabeId") REFERENCES "aufgabe"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "zeitblock" ADD CONSTRAINT "zeitblock_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+272 -40
View File
@@ -2,8 +2,8 @@
// Fachbegriffe auf Deutsch, technische Felder auf Englisch.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "esm"
}
@@ -18,22 +18,23 @@ enum Rolle {
/// Ein Mensch, der sich anmelden darf.
model Benutzer {
id String @id @default(cuid())
email String @unique
name String?
id String @id @default(cuid())
email String @unique
name String?
/// Nur gesetzt, wenn die Anmeldung per Passwort erlaubt ist.
passwortHash String?
rolle Rolle @default(BENUTZER)
aktiv Boolean @default(true)
bildUrl String?
emailGeprueft DateTime?
zuletztAktiv DateTime?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
passwortHash String?
rolle Rolle @default(BENUTZER)
aktiv Boolean @default(true)
bildUrl String?
emailGeprueft DateTime?
zuletztAktiv DateTime?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
konten Konto[]
passkeys Passkey[]
audit AuditEintrag[]
konten Konto[]
passkeys Passkey[]
audit AuditEintrag[]
kalenderkonten Kalenderkonto[]
@@index([aktiv])
@@map("benutzer")
@@ -63,19 +64,19 @@ model Konto {
/// Ein registrierter Passkey (WebAuthn) eines Benutzers.
model Passkey {
id String @id @default(cuid())
benutzerId String
id String @id @default(cuid())
benutzerId String
/// Base64URL der Credential-ID.
credentialId String @unique
publicKey Bytes
counter BigInt @default(0)
credentialId String @unique
publicKey Bytes
counter BigInt @default(0)
/// Kommagetrennt, z. B. "internal,hybrid".
transports String?
geraetetyp String?
gesichert Boolean @default(false)
bezeichnung String
erstelltAm DateTime @default(now())
zuletztBenutzt DateTime?
transports String?
geraetetyp String?
gesichert Boolean @default(false)
bezeichnung String
erstelltAm DateTime @default(now())
zuletztBenutzt DateTime?
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@ -98,12 +99,12 @@ model WebauthnChallenge {
/// Lebensbereich mit Farbe, Tageskapazitaet und Arbeitsfenster.
model Bereich {
id String @id @default(cuid())
name String @unique
id String @id @default(cuid())
name String @unique
/// Hex-Farbe inklusive Raute, z. B. "#2563eb".
farbe String
reihenfolge Int @default(0)
aktiv Boolean @default(true)
farbe String
reihenfolge Int @default(0)
aktiv Boolean @default(true)
/// Tageskapazitaet in Minuten, Montag bis Sonntag.
kapazitaetMo Int @default(0)
@@ -121,23 +122,134 @@ model Bereich {
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
/// In welchen Kalender Zeitbloecke dieses Bereichs geschrieben werden.
standardKalenderkontoId String?
standardKalenderkonto Kalenderkonto? @relation("StandardKalenderkonto", fields: [standardKalenderkontoId], references: [id], onDelete: SetNull)
aufgaben Aufgabe[]
projekte Projekt[]
@@index([aktiv, reihenfolge])
@@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)
zeitbloecke Zeitblock[]
/// 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.
model AuditEintrag {
id String @id @default(cuid())
zeitpunkt DateTime @default(now())
benutzerId String?
id String @id @default(cuid())
zeitpunkt DateTime @default(now())
benutzerId String?
/// Bleibt erhalten, auch wenn der Benutzer spaeter geloescht wird.
benutzerEmail String
aktion String
objektTyp String
objektId String?
aktion String
objektTyp String
objektId String?
/// Nur fachliche Felder, niemals Tokens, Mailinhalte oder Termin-Betreffs.
details Json?
instanz String
details Json?
instanz String
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
@@ -146,3 +258,123 @@ model AuditEintrag {
@@index([benutzerId])
@@map("audit_eintrag")
}
enum KalenderAnbieter {
GRAPH
GOOGLE
}
/// Wie der Termin die Zeit belegt. Google kennt nur frei/gebucht, Graph mehr.
enum ZeigtAls {
FREI
GEBUCHT
MIT_VORBEHALT
ABWESEND
WOANDERS
UNBEKANNT
}
/// Ein verbundener Kalender bei Microsoft Graph oder Google.
model Kalenderkonto {
id String @id @default(cuid())
benutzerId String
anbieter KalenderAnbieter
/// Frei waehlbare Bezeichnung, z. B. "Firma Outlook".
anzeigename String
/// Kennung beim Anbieter, meist die Mailadresse. Dient dem Wiedererkennen.
extKontoId String
/// Welcher Kalender. Bei Google "primary", bei Graph eine Kalender-ID.
/// "standard" bedeutet: der Standardkalender des Postfachs. Nicht optional,
/// weil Prisma in einem zusammengesetzten Unique-Schluessel kein null zulaesst.
kalenderId String @default("standard")
/// Verschluesselt abgelegt, siehe src/server/krypto.ts.
zugriffstoken String?
aktualisierungstoken String?
tokenGueltigBis DateTime?
/// Erteilte Berechtigungen, kommagetrennt – zum Erkennen fehlender Rechte.
bereiche String?
/// deltaLink bei Graph, syncToken bei Google. Opak, nie auswerten.
deltaVerweis String?
letzterAbgleich DateTime?
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
letzterFehler String?
aktiv Boolean @default(true)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
termine Termin[]
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
zeitblocks Zeitblock[]
/// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer.
@@unique([benutzerId, anbieter, extKontoId, kalenderId])
@@index([benutzerId, aktiv])
@@map("kalenderkonto")
}
/// Ortlicher Spiegel eines Kalendertermins. Nur so viel, wie fuer die
/// Belegungsrechnung und die Wochenansicht gebraucht wird.
model Termin {
id String @id @default(cuid())
kalenderkontoId String
/// Kennung des Termins beim Anbieter.
extId String
/// Fuer bedingte Schreibzugriffe ab M4 (If-Match).
etag String?
betreff String?
beginn DateTime
ende DateTime
ganztaegig Boolean @default(false)
zeigtAls ZeigtAls @default(GEBUCHT)
aktualisiertAm DateTime @updatedAt
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
@@unique([kalenderkontoId, extId])
@@index([kalenderkontoId, beginn])
@@index([beginn, ende])
@@map("termin")
}
/// Eine Aufgabe, die zu einer bestimmten Zeit in einem Kalender steht.
///
/// Der Zeitblock lebt an zwei Orten: hier und im Kalender des Anbieters.
/// Beide Seiten duerfen ihn verschieben; der Fingerabdruck "hash" verhindert,
/// dass sich beide gegenseitig hochschaukeln. Siehe src/lib/zeitblock.ts.
model Zeitblock {
id String @id @default(cuid())
aufgabeId String
kalenderkontoId String
beginn DateTime
ende DateTime
/// Kennung des Termins beim Anbieter; null, solange noch nicht geschrieben.
extId String?
/// Fuer bedingte Schreibzugriffe (If-Match).
etag String?
/// sha256(startISO|endeISO) dessen, was zuletzt geschrieben wurde.
hash String?
/// Klartextmeldung des letzten Fehlschlags beim Schreiben, ohne Tokens.
letzterFehler String?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
aufgabe Aufgabe @relation(fields: [aufgabeId], references: [id], onDelete: Cascade)
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
/// Ein Termin im Kalender gehoert zu hoechstens einem Zeitblock.
@@unique([kalenderkontoId, extId])
@@index([beginn, ende])
@@index([aufgabeId])
@@map("zeitblock")
}
+1
View File
@@ -0,0 +1 @@
# Der Ordner bleibt im Git, damit der Docker-Bau ihn kopieren kann.
+94
View File
@@ -0,0 +1,94 @@
import { mkdir } from 'node:fs/promises'
import { test, type Page } from '@playwright/test'
/**
* Nimmt die Bilder für das Handbuch auf.
*
* Je Seite vier Bilder: hell und dunkel, Schreibtisch und Handy. Die Uhr steht
* auf einem festen Zeitpunkt, damit sich zwischen zwei Läufen nur das ändert,
* was sich wirklich geändert hat.
*/
const ZIEL = 'docs/bilder'
const SCHREIBTISCH = { width: 1440, height: 900 }
const HANDY = { width: 390, height: 844 }
// Dienstag, 17. März 2026, 09:20 Uhr in Berlin.
const FESTE_ZEIT = new Date('2026-03-17T08:20:00.000Z')
const EMAIL = 'martina.beispiel@planer.invalid'
const PASSWORT = 'demo-instanz-nur-fuer-bilder'
type Aufnahme = {
name: string
pfad: string
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
vorbereiten?: (seite: Page) => Promise<void>
}
const AUFNAHMEN: Aufnahme[] = [
{ name: 'anmelden', pfad: '/anmelden' },
{ name: 'start', pfad: '/' },
{ name: 'inbox', pfad: '/inbox' },
{ name: 'aufgaben', pfad: '/aufgaben' },
{ name: 'woche', pfad: '/woche' },
{ name: 'projekte', pfad: '/projekte' },
{ name: 'ideen', pfad: '/ideen' },
{ name: 'bereiche', pfad: '/bereiche' },
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
{ name: 'einstellungen', pfad: '/einstellungen' },
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
]
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 seite.waitForURL('/')
}
test.beforeAll(async () => {
await mkdir(ZIEL, { recursive: true })
})
for (const schema of ['hell', 'dunkel'] as const) {
for (const [geraet, groesse] of [
['schreibtisch', SCHREIBTISCH],
['handy', HANDY],
] as const) {
test(`Bilder: ${schema}, ${geraet}`, async ({ browser }) => {
const kontext = await browser.newContext({
viewport: groesse,
colorScheme: schema === 'dunkel' ? 'dark' : 'light',
deviceScaleFactor: 2,
})
const seite = await kontext.newPage()
await seite.clock.setFixedTime(FESTE_ZEIT)
for (const aufnahme of AUFNAHMEN) {
if (aufnahme.pfad !== '/anmelden') {
// Für alles außer der Anmeldeseite braucht es eine Sitzung.
if (
!(await seite.context().cookies()).some((k) => k.name.includes('session'))
) {
await anmelden(seite)
}
}
await seite.goto(aufnahme.pfad)
await seite.waitForLoadState('networkidle')
await aufnahme.vorbereiten?.(seite)
// Der Farbschema-Umschalter und Schriften sollen fertig sein.
await seite.waitForTimeout(300)
await seite.screenshot({
path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`,
fullPage: true,
})
}
await kontext.close()
})
}
}
+437
View File
@@ -0,0 +1,437 @@
/**
* Integrationstest gegen eine echte Datenbank.
*
* Prüft, was Vitest nicht abdeckt: Eindeutigkeit, Fremdschlüssel, Kaskaden
* und dass das Audit-Protokoll den Benutzer überlebt.
*
* Bewusst ohne umschließende Transaktion: Der Test prüft absichtlich Fälle,
* die scheitern müssen, und ein gescheiterter Befehl bricht in PostgreSQL die
* ganze Transaktion ab. Stattdessen tragen alle Testdaten dieselbe Kennung
* und werden am Ende wieder entfernt — auch wenn der Test unterwegs abbricht.
*
* Aufruf: npm run test:integration
*/
import 'dotenv/config'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '../src/generated/prisma/client.js'
const verbindung = process.env.DATABASE_URL
if (!verbindung) {
console.error('DATABASE_URL ist nicht gesetzt.')
process.exit(1)
}
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
type Pruefung = { name: string; ok: boolean; hinweis?: string }
const ergebnisse: Pruefung[] = []
function pruefe(name: string, bedingung: boolean, hinweis?: string): void {
ergebnisse.push(hinweis ? { name, ok: bedingung, hinweis } : { name, ok: bedingung })
}
/** Erwartet, dass die Aktion scheitert. */
async function scheitert(name: string, tun: () => Promise<unknown>): Promise<void> {
try {
await tun()
pruefe(name, false, 'Die Aktion war erlaubt, obwohl sie scheitern sollte.')
} catch {
pruefe(name, true)
}
}
const kennung = `zzz-test-${Date.now()}`
const testEmail = `${kennung}@example.invalid`
async function aufraeumen(): Promise<void> {
// Erst die Aufgaben, dann Projekt und Bereich – sonst blieben Verweise
// 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 } })
// Kalenderkonten hängen am Benutzer und gehen mit ihm; falls der Test
// vorher abbricht, hier trotzdem noch einmal aufräumen.
await db.kalenderkonto.deleteMany({ where: { extKontoId: { startsWith: kennung } } })
await db.benutzer.deleteMany({ where: { email: testEmail } })
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
await db.webauthnChallenge.deleteMany({ where: { kennung } })
}
async function main(): Promise<void> {
// --- Benutzer und Eindeutigkeit der Adresse ------------------------------
const benutzer = await db.benutzer.create({
data: { email: testEmail, name: 'Testkonto' },
})
pruefe('Benutzer anlegen', benutzer.id.length > 0)
pruefe('Rolle ist im Zweifel BENUTZER', benutzer.rolle === 'BENUTZER')
pruefe('Konto ist im Zweifel aktiv', benutzer.aktiv === true)
await scheitert('E-Mail-Adresse ist eindeutig', () =>
db.benutzer.create({ data: { email: testEmail } }),
)
// --- Bereich und Eindeutigkeit des Namens --------------------------------
const bereich = await db.bereich.create({
data: { name: `${kennung}-bereich`, farbe: '#2563eb', kapazitaetMo: 120 },
})
pruefe('Bereich anlegen', bereich.name.endsWith('-bereich'))
pruefe(
'Arbeitsfenster hat brauchbare Standardwerte',
bereich.arbeitsfensterVon === 480 && bereich.arbeitsfensterBis === 1020,
)
await scheitert('Bereichsname ist eindeutig', () =>
db.bereich.create({ data: { name: bereich.name, farbe: '#000000' } }),
)
// --- Passkey, Eindeutigkeit und Fremdschlüssel ---------------------------
const passkey = await db.passkey.create({
data: {
benutzerId: benutzer.id,
credentialId: `${kennung}-cred`,
publicKey: Buffer.from([1, 2, 3]),
bezeichnung: 'Testgerät',
},
})
pruefe('Passkey anlegen', passkey.counter === 0n)
pruefe(
'Zähler ist ein BigInt und beginnt bei null',
typeof passkey.counter === 'bigint',
)
await scheitert('Credential-ID ist eindeutig', () =>
db.passkey.create({
data: {
benutzerId: benutzer.id,
credentialId: passkey.credentialId,
publicKey: Buffer.from([4]),
bezeichnung: 'Zweitgerät',
},
}),
)
await scheitert('Passkey ohne gültigen Benutzer ist nicht möglich', () =>
db.passkey.create({
data: {
benutzerId: 'gibt-es-nicht',
credentialId: `${kennung}-waise`,
publicKey: Buffer.from([9]),
bezeichnung: 'Waise',
},
}),
)
// --- Öffentlicher Schlüssel kommt unverändert zurück ---------------------
const gelesen = await db.passkey.findUnique({ where: { id: passkey.id } })
pruefe(
'Der öffentliche Schlüssel kommt Byte für Byte zurück',
Buffer.from(gelesen?.publicKey ?? []).equals(Buffer.from([1, 2, 3])),
)
// --- Konto und Eindeutigkeit je Anbieter ---------------------------------
await db.konto.create({
data: {
benutzerId: benutzer.id,
type: 'oidc',
provider: 'oidc',
providerAccountId: kennung,
},
})
await scheitert('Ein Anbieterkonto ist nur einmal verknüpfbar', () =>
db.konto.create({
data: {
benutzerId: benutzer.id,
type: 'oidc',
provider: 'oidc',
providerAccountId: kennung,
},
}),
)
// --- Kalenderkonto und Termine ------------------------------------------
const kalenderkonto = await db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: `${kennung} Firma Outlook`,
extKontoId: `${kennung}-konto`,
},
})
pruefe(
'Kalenderkonto zeigt im Zweifel auf den Standardkalender',
kalenderkonto.kalenderId === 'standard',
)
pruefe(
'Ein frisches Konto hat noch keinen Abgleichverweis',
kalenderkonto.deltaVerweis === null,
)
await scheitert('Dasselbe Konto und derselbe Kalender nur einmal', () =>
db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: `${kennung} Doppelt`,
extKontoId: `${kennung}-konto`,
},
}),
)
// Dasselbe Konto mit einem anderen Kalender ist dagegen in Ordnung.
const zweiterKalender = await db.kalenderkonto.create({
data: {
benutzerId: benutzer.id,
anbieter: 'GRAPH',
anzeigename: `${kennung} Zweiter Kalender`,
extKontoId: `${kennung}-konto`,
kalenderId: 'AAMkAD-zweiter',
},
})
pruefe(
'Dasselbe Konto mit anderem Kalender ist erlaubt',
zweiterKalender.id !== kalenderkonto.id,
)
const termin = await db.termin.create({
data: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
betreff: 'Wochenbesprechung',
beginn: new Date('2026-03-17T08:00:00Z'),
ende: new Date('2026-03-17T09:00:00Z'),
},
})
pruefe('Termin anlegen', termin.zeigtAls === 'GEBUCHT' && termin.ganztaegig === false)
await scheitert('Derselbe Termin je Konto nur einmal', () =>
db.termin.create({
data: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
beginn: new Date('2026-03-17T08:00:00Z'),
ende: new Date('2026-03-17T09:00:00Z'),
},
}),
)
// So arbeitet der Abgleich: upsert statt anlegen.
const gewandelterTermin = await db.termin.upsert({
where: {
kalenderkontoId_extId: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
},
},
update: { betreff: 'Wochenbesprechung, verschoben', zeigtAls: 'MIT_VORBEHALT' },
create: {
kalenderkontoId: kalenderkonto.id,
extId: `${kennung}-termin`,
beginn: new Date('2026-03-17T08:00:00Z'),
ende: new Date('2026-03-17T09:00:00Z'),
},
})
pruefe(
'Ein Upsert aktualisiert den Termin, statt ihn zu doppeln',
gewandelterTermin.id === termin.id &&
gewandelterTermin.zeigtAls === 'MIT_VORBEHALT' &&
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 1,
)
// --- Standard-Kalenderkonto eines Bereichs -------------------------------
await db.bereich.update({
where: { id: bereich.id },
data: { standardKalenderkontoId: kalenderkonto.id },
})
await db.kalenderkonto.delete({ where: { id: zweiterKalender.id } })
// --- Audit überlebt den Benutzer ----------------------------------------
const audit = await db.auditEintrag.create({
data: {
benutzerId: benutzer.id,
benutzerEmail: benutzer.email,
aktion: 'test.durchgefuehrt',
objektTyp: 'Bereich',
objektId: bereich.id,
details: { name: bereich.name },
instanz: 'daheim',
},
})
pruefe('Audit-Eintrag anlegen', audit.id.length > 0)
await db.benutzer.delete({ where: { id: benutzer.id } })
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
pruefe('Audit-Eintrag überlebt das Löschen des Benutzers', nachher !== null)
pruefe(
'Die Kennung wird gelöst, die Adresse bleibt als Nachweis stehen',
nachher?.benutzerId === null && nachher?.benutzerEmail === testEmail,
)
pruefe(
'Passkeys verschwinden mit dem Benutzer',
(await db.passkey.count({ where: { credentialId: passkey.credentialId } })) === 0,
)
pruefe(
'Verknüpfte Konten verschwinden mit dem Benutzer',
(await db.konto.count({ where: { providerAccountId: kennung } })) === 0,
)
pruefe(
'Kalenderkonten verschwinden mit dem Benutzer',
(await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0,
)
pruefe(
'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
main()
.catch((fehler: unknown) => {
abbruch = fehler
})
.finally(async () => {
await aufraeumen()
const uebrig =
(await db.benutzer.count({ where: { email: testEmail } })) +
(await db.bereich.count({ where: { name: { startsWith: kennung } } }))
pruefe('Aufräumen hat nichts übrig gelassen', uebrig === 0)
await db.$disconnect()
if (abbruch) {
console.error('Der Integrationstest ist abgebrochen:', abbruch)
process.exit(1)
}
const gefallen = ergebnisse.filter((e) => !e.ok)
for (const e of ergebnisse) {
console.log(`${e.ok ? '✓' : '✗'} ${e.name}${e.hinweis ? ` – ${e.hinweis}` : ''}`)
}
console.log(
`\n${ergebnisse.length - gefallen.length} von ${ergebnisse.length} Prüfungen bestanden.`,
)
process.exit(gefallen.length === 0 ? 0 : 1)
})
+121
View File
@@ -0,0 +1,121 @@
/**
* Migrationstest.
*
* Legt eine Wegwerf-Datenbank an, spielt alle Migrationen der Reihe nach ein
* und prüft anschließend, ob das Ergebnis genau dem Schema entspricht. Damit
* fällt auf, wenn jemand das Schema ändert, ohne eine Migration zu erzeugen —
* beim Aufspielen im Betrieb wäre das sonst erst spät zu merken.
*
* Aufruf: npm run test:migration
*/
import 'dotenv/config'
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import { Client } from 'pg'
const WURZEL = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
const verbindung = process.env.DATABASE_URL
if (!verbindung) {
console.error('DATABASE_URL ist nicht gesetzt.')
process.exit(1)
}
const testDatenbank = `planer_migrationstest_${Date.now()}`
/** Dieselbe Verbindung, aber auf eine andere Datenbank gerichtet. */
function mitDatenbank(url: string, name: string): string {
const zerlegt = new URL(url)
zerlegt.pathname = `/${name}`
return zerlegt.toString()
}
function prisma(argumente: string[], zusatz: Record<string, string>): number {
const ergebnis = spawnSync(
process.execPath,
[join(WURZEL, 'node_modules', 'prisma', 'build', 'index.js'), ...argumente],
{
cwd: WURZEL,
stdio: 'inherit',
env: { ...process.env, ...zusatz },
},
)
return ergebnis.status ?? 1
}
async function aufVerwaltungsdatenbank<T>(
tun: (klient: Client) => Promise<T>,
): Promise<T> {
// "postgres" ist auf jedem Server vorhanden und wird hier nur benutzt, um
// die Wegwerf-Datenbank anzulegen und wieder zu entfernen.
const klient = new Client({ connectionString: mitDatenbank(verbindung!, 'postgres') })
await klient.connect()
try {
return await tun(klient)
} finally {
await klient.end()
}
}
async function main(): Promise<number> {
console.log(`▸ Wegwerf-Datenbank ${testDatenbank} anlegen`)
await aufVerwaltungsdatenbank((k) => k.query(`CREATE DATABASE "${testDatenbank}"`))
const testUrl = mitDatenbank(verbindung!, testDatenbank)
console.log('▸ Alle Migrationen einspielen')
if (prisma(['migrate', 'deploy'], { DATABASE_URL: testUrl }) !== 0) {
console.error('✗ Die Migrationen ließen sich nicht einspielen.')
return 1
}
console.log('▸ Ergebnis mit dem Schema vergleichen')
// --exit-code liefert 2, wenn ein Unterschied bleibt.
const abweichung = prisma(
[
'migrate',
'diff',
// Die Quelle ist die eben eingespielte Wegwerf-Datenbank, das Ziel das
// Schema. So braucht der Vergleich keine Schattendatenbank.
'--from-config-datasource',
'--to-schema',
'prisma/schema.prisma',
'--exit-code',
],
{ DATABASE_URL: testUrl },
)
if (abweichung === 2) {
console.error(
'\n✗ Nach dem Einspielen weicht die Datenbank vom Schema ab.\n' +
' Vermutlich fehlt eine Migration: "npx prisma migrate dev --name <name>".',
)
return 1
}
if (abweichung !== 0) {
console.error('✗ Der Vergleich ließ sich nicht durchführen.')
return 1
}
console.log('\n✓ Die Migrationen ergeben genau das Schema.')
return 0
}
let code = 1
try {
code = await main()
} catch (fehler) {
console.error('Der Migrationstest ist abgebrochen:', fehler)
} finally {
await aufVerwaltungsdatenbank(async (k) => {
// Offene Verbindungen trennen, sonst lässt sich die Datenbank nicht löschen.
await k.query(
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1',
[testDatenbank],
)
await k.query(`DROP DATABASE IF EXISTS "${testDatenbank}"`)
})
console.log(`▸ Wegwerf-Datenbank ${testDatenbank} entfernt`)
}
process.exit(code)
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Setzt eine neue Version: package.json, CHANGELOG, Commit und Tag.
# Pusht bewusst nicht – das geschieht erst nach ausdrücklicher Freigabe.
set -euo pipefail
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$WURZEL"
meldung() { printf '▸ %s\n' "$1"; }
fehler() {
printf 'Fehler: %s\n' "$1" >&2
exit 1
}
hilfe() {
cat <<'ENDE'
Verwendung: scripts/release.sh <version>
<version> Semantische Version ohne führendes v, z. B. 0.1.0
Ablauf:
1. Sauberen Arbeitsbaum und grüne Prüfungen sicherstellen
2. Version in package.json setzen
3. "Unveröffentlicht" im CHANGELOG in einen datierten Abschnitt überführen
4. chore(release): <version> committen und Tag v<version> anlegen
Es wird nicht gepusht.
ENDE
}
VERSION="${1:-}"
case "$VERSION" in
''|--hilfe|-h) hilfe; [ -z "$VERSION" ] && exit 1; exit 0 ;;
esac
printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' \
|| fehler "\"$VERSION\" sieht nicht nach einer semantischen Version aus (z. B. 0.1.0)."
TAG="v$VERSION"
# --- 1. Voraussetzungen ----------------------------------------------------
meldung 'Arbeitsbaum prüfen.'
[ -d .git ] || fehler 'Kein Git-Arbeitsbaum.'
[ -z "$(git status --porcelain)" ] || fehler 'Der Arbeitsbaum ist nicht sauber.'
git rev-parse -q --verify "refs/tags/$TAG" >/dev/null \
&& fehler "Den Tag $TAG gibt es schon."
[ -f CHANGELOG.md ] || fehler 'CHANGELOG.md fehlt.'
grep -q '^## \[Unveröffentlicht\]' CHANGELOG.md \
|| fehler 'Im CHANGELOG fehlt der Abschnitt "## [Unveröffentlicht]".'
INHALT="$(awk '
/^## \[Unveröffentlicht\]/ { sammeln = 1; next }
sammeln && /^## / { exit }
sammeln { print }
' CHANGELOG.md | grep -v '^[[:space:]]*$' || true)"
[ -n "$INHALT" ] || fehler 'Der Abschnitt "Unveröffentlicht" ist leer.'
meldung 'Prüfungen laufen lassen.'
npm run pruefen || fehler 'Die Prüfungen sind nicht grün.'
# --- 2. Version setzen -----------------------------------------------------
meldung "Version auf $VERSION setzen."
npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null
# --- 3. CHANGELOG ----------------------------------------------------------
meldung 'CHANGELOG fortschreiben.'
HEUTE="$(date '+%Y-%m-%d')"
TEMP="$(mktemp)"
awk -v version="$VERSION" -v heute="$HEUTE" '
/^## \[Unveröffentlicht\]/ {
print "## [Unveröffentlicht]"
print ""
print "## [" version "] – " heute
next
}
{ print }
' CHANGELOG.md > "$TEMP"
mv "$TEMP" CHANGELOG.md
npx prettier --write package.json CHANGELOG.md >/dev/null 2>&1 || true
# --- 4. Commit und Tag -----------------------------------------------------
meldung "Commit und Tag $TAG anlegen."
git add package.json package-lock.json CHANGELOG.md
git commit -m "chore(release): $VERSION"
git tag -a "$TAG" -m "Planer $VERSION"
meldung "Fertig. $TAG ist angelegt, aber nicht gepusht."
printf '\nNach der Freigabe:\n'
printf ' git push origin %s\n' "$(git rev-parse --abbrev-ref HEAD)"
printf ' git push origin %s\n' "$TAG"
+142
View File
@@ -0,0 +1,142 @@
/**
* Nimmt die Bilder für das Handbuch auf.
*
* Startet eine Wegwerf-Instanz in einem eigenen Compose-Projekt mit eigener
* Datenbank, spielt ausschließlich erfundene Daten ein, fährt die Seiten mit
* Playwright ab und räumt am Ende alles wieder weg — auch wenn unterwegs etwas
* schiefgeht.
*
* An eine Instanz mit echten Daten kommt dieser Ablauf nicht heran: die
* Datenbank entsteht neu, wird nur über ihren eigenen Port angesprochen und
* samt Volume wieder entfernt.
*
* Aufruf: npm run screenshots
*/
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const WURZEL = fileURLToPath(new URL('..', import.meta.url))
const PROJEKT = 'planer-screenshots'
const COMPOSE_DATEI = 'docker-compose.screenshots.yml'
const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455'
const UMGEBUNG = {
...process.env,
SCREENSHOT_APP_PORT: APP_PORT,
SCREENSHOT_DB_PORT: DB_PORT,
}
function meldung(text: string): void {
console.log(`▸ ${text}`)
}
function laufen(
befehl: string,
argumente: string[],
zusatz: Record<string, string> = {},
): number {
const ergebnis = spawnSync(befehl, argumente, {
cwd: WURZEL,
stdio: 'inherit',
env: { ...UMGEBUNG, ...zusatz },
})
return ergebnis.status ?? 1
}
function compose(argumente: string[]): number {
return laufen('docker', ['compose', '-p', PROJEKT, '-f', COMPOSE_DATEI, ...argumente])
}
function node(skript: string, zusatz: Record<string, string>): number {
return laufen(
process.execPath,
[`${WURZEL}node_modules/tsx/dist/cli.mjs`, skript],
zusatz,
)
}
async function wartenAufGesundheit(sekunden: number): Promise<boolean> {
const ziel = `http://127.0.0.1:${APP_PORT}/api/health`
const frist = Date.now() + sekunden * 1000
while (Date.now() < frist) {
try {
const antwort = await fetch(ziel)
if (antwort.ok) {
const inhalt = (await antwort.json()) as { status?: string }
if (inhalt.status === 'ok') return true
}
} catch {
// Noch nicht da, gleich noch einmal.
}
await new Promise((weiter) => setTimeout(weiter, 2000))
}
return false
}
function aufraeumen(): void {
meldung('Wegwerf-Instanz entfernen.')
compose(['down', '-v', '--remove-orphans'])
}
async function main(): Promise<number> {
if (laufen('docker', ['info']) !== 0) {
console.error('Der Docker-Daemon läuft nicht.')
return 1
}
// Reste eines abgebrochenen Laufs zuerst wegräumen.
compose(['down', '-v', '--remove-orphans'])
meldung(`Wegwerf-Instanz bauen und starten (App auf ${APP_PORT}, DB auf ${DB_PORT}).`)
if (compose(['up', '-d', '--build']) !== 0) {
console.error('Die Wegwerf-Instanz ließ sich nicht starten.')
return 1
}
meldung('Auf die Anwendung warten.')
if (!(await wartenAufGesundheit(180))) {
console.error('Die Anwendung ist nicht gesund geworden.')
compose(['logs', '--tail', '50', 'app'])
return 1
}
meldung('Erfundene Daten einspielen.')
const demoUrl = `postgresql://demo:demo@127.0.0.1:${DB_PORT}/demo?schema=public`
if (node('prisma/demo-seed.ts', { DATABASE_URL: demoUrl, DEMO_MODUS: 'true' }) !== 0) {
console.error('Der Demo-Seed ist fehlgeschlagen.')
return 1
}
meldung('Bilder aufnehmen.')
const playwright = laufen(
process.execPath,
[
`${WURZEL}node_modules/@playwright/test/cli.js`,
'test',
'--config',
'playwright.screenshots.config.ts',
],
{ SCREENSHOT_APP_PORT: APP_PORT },
)
if (playwright !== 0) {
console.error('Die Aufnahme ist fehlgeschlagen.')
return 1
}
meldung('Fertig. Die Bilder liegen in docs/bilder/.')
return 0
}
let code = 1
try {
code = await main()
} catch (fehler) {
console.error('Der Screenshot-Lauf ist abgebrochen:', fehler)
} finally {
aufraeumen()
}
process.exit(code)
+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>
)
}
+7 -2
View File
@@ -1,7 +1,8 @@
import { Kopfleiste } from '@/components/kopfleiste'
import { zaehlstaende } from '@/server/abfragen/aufgaben'
import { INSTANZ_NAME } from '@/lib/instanz'
import { versionAnzeige } from '@/lib/version'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung'
import { aktuelleInstanz } from '@/server/instanz'
/**
@@ -14,8 +15,11 @@ export default async function AngemeldetesLayout({
}: {
children: React.ReactNode
}) {
const benutzer = await verlangeAnmeldung()
// Der Pfad kommt aus der Kopfzeile des Proxys, damit es nach der
// Anmeldung dort weitergeht, wo man hinwollte.
const benutzer = await verlangeAnmeldung(await angefragterPfad())
const instanz = aktuelleInstanz()
const zahlen = await zaehlstaende()
return (
<div className="flex min-h-dvh flex-col">
@@ -24,6 +28,7 @@ export default async function AngemeldetesLayout({
email={benutzer.email}
rolle={benutzer.rolle}
instanzName={INSTANZ_NAME[instanz]}
inbox={zahlen.inbox}
/>
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
+137 -37
View File
@@ -1,4 +1,7 @@
import Link from 'next/link'
import { AufgabenListe } from '@/components/aufgaben/aufgaben-liste'
import { Schnellerfassung } from '@/components/aufgaben/schnellerfassung'
import { Fortschrittsbalken } from '@/components/fortschrittsbalken'
import {
Card,
CardBeschreibung,
@@ -8,17 +11,39 @@ import {
} from '@/components/ui/card'
import { wochenKapazitaet } from '@/lib/bereich'
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 { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber'
export default async function StartSeite() {
const benutzer = await verlangeAnmeldung('/')
const jetzt = new Date()
const heute = jetzt()
const bereiche = await db.bereich.findMany({
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
})
const [alleOffenen, bereiche, projekte, zahlen, bereichsListe, belegung, mitKalender] =
await Promise.all([
aufgabenLaden({ nurOffen: true, ohneIdeen: true }, 200),
bereicheLaden(),
projekteZurAuswahlLaden(),
zaehlstaende(),
db.bereich.findMany({
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
}),
belegungHeute(benutzer.id, heute),
hatKalenderkonto(benutzer.id),
])
// Was jetzt zählt: überfällig, heute oder morgen fällig.
const dran = alleOffenen.filter(
(a) => a.ueberfaellig || a.faelligkeit === 'heute' || a.faelligkeit === 'morgen',
)
const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email
@@ -26,13 +51,56 @@ export default async function StartSeite() {
<div className="flex flex-col gap-8">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Guten Tag, {anrede}</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(jetzt)}</p>
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(heute)}</p>
</header>
<section className="flex flex-col gap-4">
<h2 className="text-lg font-medium">Deine Bereiche</h2>
<Card>
<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>
<CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm">
Noch keine Bereiche angelegt.{' '}
@@ -43,38 +111,70 @@ export default async function StartSeite() {
</Card>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{bereiche.map((bereich) => (
<li key={bereich.id}>
<Card>
<CardKopf className="flex-row items-center gap-3">
<span
aria-hidden
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: bereich.farbe }}
/>
<CardTitel className="text-base">{bereich.name}</CardTitel>
</CardKopf>
<CardInhalt>
<p className="text-gedaempft-vordergrund text-sm">
{alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant
</p>
</CardInhalt>
</Card>
</li>
))}
{bereichsListe.map((bereich) => {
const offeneHier = alleOffenen.filter((a) => a.bereich?.id === bereich.id)
const heuteHier = belegung.find((b) => b.id === bereich.id)?.belegung
return (
<li key={bereich.id}>
<Card className="h-full">
<CardKopf className="flex-row items-center gap-3">
<span
aria-hidden
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: bereich.farbe }}
/>
<CardTitel className="text-base">{bereich.name}</CardTitel>
</CardKopf>
<CardInhalt className="flex flex-col gap-2">
<p className="text-gedaempft-vordergrund text-sm">
{offeneHier.length === 0
? 'Nichts offen'
: `${offeneHier.length} offen`}{' '}
· {alsDauer(wochenKapazitaet(bereich))} Kapazität je Woche
</p>
{/*
Ohne verbundenen Kalender wäre die Belegung immer null –
das sähe nach "alles frei" aus und wäre irreführend.
*/}
{mitKalender && heuteHier && (
<>
<Fortschrittsbalken
prozent={heuteHier.prozent}
beschriftung={`Belegung heute in ${bereich.name}`}
/>
<p className="text-gedaempft-vordergrund text-xs">
{heuteHier.kapazitaet === 0
? 'Heute nicht eingeplant'
: heuteHier.ueberbucht
? `Heute überbucht: ${alsDauer(heuteHier.belegt)} Termine bei ${alsDauer(heuteHier.kapazitaet)} Kapazität`
: `Heute noch ${alsDauer(heuteHier.frei)} frei von ${alsDauer(heuteHier.kapazitaet)}`}
</p>
</>
)}
</CardInhalt>
</Card>
</li>
)
})}
</ul>
)}
</section>
<Card>
<CardKopf>
<CardTitel className="text-base">Was als Nächstes kommt</CardTitel>
<CardBeschreibung>
Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die
Wochenplanung per Ziehen und Ablegen mit M4.
</CardBeschreibung>
</CardKopf>
</Card>
{zahlen.ideen > 0 && (
<Card>
<CardKopf>
<CardTitel className="text-base">
<Link href="/ideen" className="hover:underline hover:underline-offset-4">
{zahlen.ideen} Idee(n) warten
</Link>
</CardTitel>
<CardBeschreibung>
Ideen drängen sich nicht in die Aufgabenliste — bis du eine übernimmst.
</CardBeschreibung>
</CardKopf>
</Card>
)}
</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>
)
}

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