42 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 70ed3a5220 chore(release): 0.6.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:18:51 +02:00
Florian WildeandClaude Opus 5 928f841adb docs: DEVLOG-Eintrag fuer M6
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:02:58 +02:00
Florian WildeandClaude Opus 5 98d170b68a docs: Handbuchbilder fuer M6 neu aufnehmen
Der Hinweis unter dem Wochenraster erklaerte nur die grauen Balken; die
gestreiften aus der anderen Instanz fehlten darin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:00:49 +02:00
Florian WildeandClaude Opus 5 475b1d5c08 docs: Handbuchseite, README und Fahrplan fuer M6
Der Wartungsplaner steht als offener Punkt drin, nicht als Haken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 04:51:38 +02:00
Florian WildeandClaude Opus 5 3c7cdbbbf3 chore: Demo-Daten, Bildaufnahme und Platz in der Kopfleiste
Die Demo-Instanz redet mit niemandem mehr: Solange DEMO_MODUS gilt,
starten die Jobs fuer Zammad, Telegram und den Spiegel gar nicht erst.
Sie ist mit erfundenen Daten gefuellt und steht nur fuer Bilder — ein
Aufruf nach draussen waere bestenfalls sinnlos.

Die Kopfleiste ist breiter als die Inhaltsspalte. Mit dem elften Eintrag
passte sie dort nicht mehr, und "Einstellungen" verschwand
stillschweigend am rechten Rand. Aufgefallen ist das erst im
aufgenommenen Bild.

Der Kartentext beschrieb noch den Weg von vor dem Kopplungscode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 04:51:37 +02:00
Florian WildeandClaude Opus 5 f5eadefdbb feat: Lauf von Hand anstossen und Spiegelschalter in der Liste
Direkt nach dem Einrichten wuesste man sonst erst beim naechsten Takt,
ob die Zugangsdaten stimmen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:59:14 +02:00
Florian WildeandClaude Opus 5 3befcc19a3 feat: Aufgaben einzeln zum Spiegeln freigeben
Der Schalter erscheint nur, wo auch gespiegelt wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:58:46 +02:00
Florian WildeandClaude Opus 5 4da2f3d9c6 test: Spiegel-Eingang und Integrationstabellen absichern
Der Eingang des Spiegels ist die einzige Tuer, die ohne Anmeldung
aufgeht. Vier Faelle: ohne Signatur, falsch signiert, zu alt, und der
gute Fall — bei dem eine nicht mehr geschickte Belegung auch hier
verschwinden muss.

Der Testserver bekommt dafuer dasselbe Geheimnis wie der Test. Eine
Pruefung, die immer uebersprungen wird, prueft nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:58:43 +02:00
Florian WildeandClaude Opus 5 0d0f8503cc feat: Gespiegelte Belegung in der Wochenansicht zeigen
Sie zaehlt gegen die Tageskapazitaet wie jeder fremde Termin, traegt
aber keinen Betreff und ist gestreift gezeichnet — damit niemand darin
einen Termin vermutet, dem der Titel fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:04:35 +02:00
Florian WildeandClaude Opus 5 1281c3ea96 feat: Integrationen sichtbar und bedienbar machen
Eine Seite zeigt je Integration, ob sie vorgesehen, eingerichtet oder
gestoert ist, wann sie zuletzt geglueckt ist und woran sie klemmt. Der
Wartungsplaner steht sichtbar als offene Luecke darin, nicht als
Leerstelle.

Telegram-Chats werden hier gekoppelt und wieder getrennt, Meldungen je
Chat einzeln geschaltet. Aufgaben bekommen einen Schalter zum Spiegeln —
einzeln und je Aufgabe, denn wer versehentlich einen ganzen Bereich
spiegelt, schickt Kundennamen nach Hause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:04:33 +02:00
Florian WildeandClaude Opus 5 bc60e5f864 feat: Integrationen takten und Telegram als dritten Meldeweg
Telegram alle zwei Minuten, Zammad und Spiegel alle fuenfzehn. Wer dem
Bot etwas schickt, erwartet die Aufgabe gleich in der Liste; in Zammad
aendert sich dagegen nichts im Minutentakt.

Meldungen gehen jetzt auch per Telegram hinaus — mit einem Knopf zum
Abhaken, wenn es um genau eine Aufgabe geht. Eine klemmende Integration
meldet sich wie ein klemmender Kalender: erst nach dem dritten
Fehlversuch, dann aber deutlich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:04:05 +02:00
Florian WildeandClaude Opus 5 6dad3331e2 feat: Zammad, Telegram und den Spiegel anbinden
Zammad liest ueber die Suche und schreibt nichts zurueck. Telegram holt
per getUpdates statt per Webhook — der Planer soll auch hinter einem
Anschluss ohne feste Adresse laufen. Der Spiegel schickt Belegungen ohne
Titel und nur die ausdruecklich markierten Aufgaben.

Wer dem Telegram-Bot schreibt, ohne im Adressbuch zu stehen, bekommt
keine Antwort. Die einzige Ausnahme ist ein gueltiger Kopplungscode: Er
ist der Beweis, dass jemand mit Zugang zur Anwendung diesen Chat will.
Ohne ihn muesste man die Chatkennung von Hand eintragen — und um sie zu
erfahren, muesste der Bot Unbekannten antworten.

Jede Sendung des Spiegels traegt eine Signatur ueber den Rumpf und gilt
fuenf Minuten. Der Empfaenger antwortet auf jeden Fehlschlag gleich:
falsches Geheimnis, verstellte Uhr oder Unsinn im Inhalt sind von aussen
nicht zu unterscheiden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:04:03 +02:00
Florian WildeandClaude Opus 5 e4d3547457 feat: Aufnahmeregeln fuer Integrationen als reine Logik
Was aus einem Fund wird, entscheidet fuer alle Integrationen dieselbe
Stelle: anlegen, aktualisieren, schliessen oder nichts tun.

Gefuehrt werden nur Titel, Notiz, Verweis und Faelligkeit. Bereich,
Projekt, Prioritaet und geschaetzter Aufwand gehoeren der Planung — wer
ein Ticket einsortiert und schaetzt, will nicht, dass der naechste Lauf
das wieder wegraeumt.

Dazu die Regeln des Spiegels: Aufbau der Sendung und ihre Frist. Ohne
Frist liesse sich ein abgefangenes Paket beliebig oft erneut einspielen.

21 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:03:33 +02:00
Florian WildeandClaude Opus 5 7f8f505126 feat: Datenmodell und Umgebung fuer Integrationen
Ein Zustand je Quelle haelt fest, wie weit gelesen wurde und ob es
klappt. Der Merker ist bewusst ein freier Text: Zammad braucht darin
einen Zeitstempel, Telegram den Zaehler der zuletzt geholten Nachricht.

Dazu die gespiegelte Belegung ohne Titel, das Adressbuch der erlaubten
Telegram-Chats und der Einmalcode, mit dem sich ein Chat anmeldet.

Welche Integration eine Instanz darf, entscheidet weiterhin INSTANZ. Ein
Zammad-Token in der Instanz daheim bleibt wirkungslos — das steht in den
Faehigkeiten, nicht in einer Einstellung, die sich verstellen liesse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:03:32 +02:00
Florian WildeandClaude Opus 5 ab1b898e44 Merge: Meilenstein 5 – Benachrichtigungen (0.5.0)
Der Planer sagt jetzt, was ansteht: Tagesueberblick am Morgen, faellige
Aufgaben, Vorlauf vor einem Zeitblock und ein Kalender, der sich nicht
abgleichen laesst. Per Web-Push, per E-Mail oder beides.

Ab Werk meldet er gar nichts. Eine Anwendung, die zu oft meldet, wird
stummgeschaltet und ist dann wertlos — deshalb ist die Einrichtung ein
bewusster Handgriff, dafuer kommt dann auch nur, was gewollt ist.

Der Nachweis einer Meldung wird vor dem Senden geschrieben: lieber eine
verpasste Nachricht als eine, die im Minutentakt erneut versucht wird.

Geprueft vor dem Merge: npm run pruefen mit 280 Unit-Tests, 47
Integrationspruefungen, Migrationstest und 20 E2E-Tests. Der Versandweg ist
gegen die Logik geprueft, nicht gegen echte Dienste — dafuer fehlen
VAPID-Schluessel und SMTP-Zugang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:43:55 +02:00
Florian WildeandClaude Opus 5 1da2068440 chore(release): 0.5.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:39:02 +02:00
Florian WildeandClaude Opus 5 01d7b10599 docs: DEVLOG-Eintrag fuer M5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:37:04 +02:00
Florian WildeandClaude Opus 5 350f5fdbfc docs: Fahrplan und Aenderungen fuer M5 fortschreiben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:37:02 +02:00
Florian WildeandClaude Opus 5 bb7b189e53 docs: Handbuchbilder fuer M5 neu aufnehmen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:34:44 +02:00
Florian WildeandClaude Opus 5 35ecb2fa78 chore: Screenshot-Instanz mit eingerichteten Meldewegen
Die Wegwerf-Instanz bekommt je Lauf ein frisch erzeugtes VAPID-Paar und
einen Mailhost, den es nie geben wird. Damit zeigen die Bilder den
eingerichteten Normalfall statt zweier Warnhinweise — und im Verzeichnis
bleibt kein privater Schluessel liegen.

Kein Push-Geraet mehr in den Demo-Daten: Seine Schluessel liegen
verschluesselt, und der Versand entfernt jedes Geraet, dessen Schluessel
er nicht lesen kann. Der erfundene Eintrag waere beim ersten Lauf wieder
verschwunden.

Dass der Browser im Bild "blockiert" meldet, laesst sich nicht abstellen:
Chromium im Kopflosbetrieb antwortet auf Notification.permission immer so.
Das Handbuch sagt es jetzt dazu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:34:40 +02:00
Florian WildeandClaude Opus 5 7bef4351ec fix: Serverzustand geht der Browsermeldung vor
Fehlen die Push-Schluessel auf dem Server, stand unter "Dieses Geraet"
trotzdem, der Browser habe blockiert — zwei Aussagen nebeneinander, von
denen nur eine zaehlt. Jetzt sagt die Karte zuerst das, was den Ausschlag
gibt.

Aufgefallen ist es erst im aufgenommenen Bild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:34:39 +02:00
Florian WildeandClaude Opus 5 6e1f2791cd test: Benachrichtigungen end-to-end und in der Datenbank absichern
Die Einstellung haelt ueber das Neuladen, ohne eingeschalteten Weg geht
nichts hinaus, und ein unsinniger Vorlauf wird auch dann abgewiesen, wenn
er an der Oberflaeche vorbei hereinkommt.

Der Anmeldehelfer liegt jetzt einmal in e2e/hilfe.ts und fasst nach: ein
Klick, der vor der Hydratation ankommt, verpufft folgenlos — das hat die
Testlaeufe unter Last wiederholt scheitern lassen.

Der Integrationstest prueft die drei neuen Tabellen samt Kaskade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:28:32 +02:00
Florian WildeandClaude Opus 5 f03ea73dcd docs: Handbuchseite und README-Abschnitt zu Benachrichtigungen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:00:23 +02:00
Florian WildeandClaude Opus 5 5bbbdd5209 chore: Demo-Daten und Bildaufnahme fuer Benachrichtigungen
Die Aufnahme kann jetzt einen einzelnen Ausschnitt statt der ganzen Seite
festhalten. Ein Bild der kompletten Einstellungsseite zeigt im Kapitel zu
den Benachrichtigungen zu wenig vom Eigentlichen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:00:20 +02:00
Florian WildeandClaude Opus 5 047a4a1c74 feat: Meldungen alle zehn Minuten pruefen und verschicken
Der Takt passt zu den Toleranzfenstern: ein Anlass in einem
Viertelstundenfenster wird sicher getroffen, dank Sperre aber nur einmal
gemeldet. Eine Erinnerung kommt damit hoechstens ein paar Minuten frueher
als gewollt, nie spaeter.

Der Kalenderabgleich zaehlt seine Fehlversuche jetzt mit und setzt den
Zaehler bei Erfolg zurueck.

Alte Nachweise raeumt der naechtliche Lauf weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 09:00:00 +02:00
Florian WildeandClaude Opus 5 b622c1bb28 feat: Benachrichtigungen in den Einstellungen bedienen
Anmelden dieses Browsers, Liste der angemeldeten Geraete, Auswahl der
Anlaesse, Ruhezeit und Vorlauf. Ist ein Weg auf dem Server nicht
eingerichtet, steht das direkt daneben statt in einem Fehler beim
Speichern.

Der Knopf "Probe senden" umgeht Ruhezeit und Sperre absichtlich: wer
darauf drueckt, will jetzt wissen, ob es ankommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:59:57 +02:00
Florian WildeandClaude Opus 5 e0e11c85b2 feat: Versand per Web-Push und E-Mail
Push ueber VAPID, Mail ueber SMTP, beide unabhaengig zuschaltbar. Die
Schluessel eines Push-Geraets liegen verschluesselt in der Datenbank.

Ein Geraet, das der Push-Dienst mit 404 oder 410 ablehnt, wird sofort
entfernt; andere Fehler zaehlen hoch und fuehren nach fuenf Versuchen
zum selben Ergebnis. Eine Liste toter Abos putzt sich so von allein.

Der Nachweis wird vor dem Senden geschrieben, nicht danach: lieber eine
verpasste Nachricht als eine, die im Minutentakt erneut versucht wird.

Weder Endpunkt noch Schluessel noch Mailinhalt landen im Protokoll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:59:31 +02:00
Florian WildeandClaude Opus 5 ddb13d7dd0 feat: Regeln fuer Benachrichtigungen als reine Logik
Anlaesse, Ruhezeit ueber Mitternacht, Wiederholungssperre und die
Entscheidung, ob eine Meldung jetzt hinausgeht. Ohne Datenbank und ohne
Systemzeit — der Zeitpunkt kommt immer als Parameter herein, damit sich
alles ohne Warten pruefen laesst.

Der Tagesueberblick ist von der Ruhezeit ausgenommen: wer ihn auf 06:00
stellt, bekaeme ihn sonst nie.

Die Voreinstellung ist bewusst zurueckhaltend. Wer beim ersten Start mit
Meldungen ueberschuettet wird, schaltet alles ab — und dann auch das
Nuetzliche.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:59:27 +02:00
Florian WildeandClaude Opus 5 24f613d372 feat: Datenmodell und Umgebung fuer Benachrichtigungen
Drei Tabellen: die Einstellung je Benutzer, angemeldete Push-Geraete und
ein Nachweis je verschickter Meldung. Der Nachweis ist das Gedaechtnis
gegen Wiederholungen — ohne ihn kaeme dieselbe Sache bei jedem Durchlauf
erneut.

Kalenderkonten bekommen einen Zaehler fuer Fehlversuche in Folge, damit
ein Abgleichfehler erst nach mehreren Versuchen gemeldet wird.

VAPID- und SMTP-Werte sind freiwillig: fehlen sie, laeuft der Planer
ohne den jeweiligen Weg weiter und sagt es in den Einstellungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:58:59 +02:00
Florian WildeandClaude Opus 5 ef79b24a96 Merge: Meilenstein 4 – Wochenplanung und Zeitblöcke (0.4.0)
Damit steht die Kernidee des Projekts: Aufgaben werden per Ziehen und
Ablegen zu echten Kalenderterminen, und Verschiebungen aus dem Kalender
kommen zurück.

Der Schleifenschutz über sha256(startISO|endeISO) verhindert, dass App
und Kalender einander endlos hinterherschreiben. Bei HTTP 412 gewinnt der
Kalender.

Geprüft vor dem Merge: npm run pruefen mit 250 Unit-Tests, 40
Integrationsprüfungen, Migrationstest und 17 E2E-Tests. Der Schreibweg ist
gegen die Logik geprüft, nicht gegen die echten APIs — dafür fehlen die
Zugangsdaten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 06:33:28 +02:00
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
126 changed files with 9039 additions and 95 deletions
+46
View File
@@ -74,6 +74,52 @@ GRAPH_TENANT_ID=organizations
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
# --- Benachrichtigungen ----------------------------------------------------
# Beides ist freiwillig. Fehlt es, laeuft der Planer ohne Benachrichtigungen
# weiter und sagt es in den Einstellungen.
# Web-Push. Schluesselpaar einmalig erzeugen mit:
# npx web-push generate-vapid-keys
# Der oeffentliche Schluessel geht an den Browser, der private bleibt hier.
# Wird das Paar getauscht, muessen sich alle Geraete neu anmelden.
VAPID_OEFFENTLICH=
VAPID_PRIVAT=
# Kontakt fuer den Push-Dienst, als mailto: oder https:-Adresse.
VAPID_KONTAKT=
# E-Mail. Push braucht SMTP nicht und umgekehrt.
SMTP_HOST=
SMTP_PORT=587
SMTP_BENUTZER=
SMTP_PASSWORT=
# Absender, z. B. "Planer <planer@example.de>".
SMTP_ABSENDER=
# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS.
SMTP_TLS=false
# --- Integrationen ---------------------------------------------------------
# Alles freiwillig. Was fehlt, erscheint unter "Integrationen" als nicht
# eingerichtet; der Rest laeuft weiter.
# Welche Integration eine Instanz ueberhaupt darf, entscheidet INSTANZ:
# Zammad und Wartungsplaner nur "firma", Telegram nur "daheim".
# Zammad (nur firma). Token in Zammad unter Profil > Token-Zugriff anlegen;
# er sieht genau das, was der zugehoerige Benutzer sehen darf.
ZAMMAD_URL=
ZAMMAD_TOKEN=
# Suchausdruck in Zammads Syntax. Was darauf passt, wird zur Aufgabe.
ZAMMAD_ABFRAGE=owner_id:me AND (state.name:new OR state.name:open)
# Telegram (nur daheim). Bot bei @BotFather anlegen, Token hier eintragen.
# Welche Chats mitreden duerfen, wird in der Anwendung gekoppelt.
TELEGRAM_TOKEN=
# Spiegel: firma sendet, daheim empfaengt. Dasselbe Geheimnis auf beiden
# Seiten, mindestens 32 Zeichen: openssl rand -base64 32
SPIEGEL_GEHEIMNIS=
# Nur auf der sendenden Seite (firma): die Adresse der anderen Instanz.
SPIEGEL_ZIEL=
# --- Sonstiges ------------------------------------------------------------- # --- Sonstiges -------------------------------------------------------------
# Zeitzone fuer Anzeigen und Tagesgrenzen. # Zeitzone fuer Anzeigen und Tagesgrenzen.
TZ=Europe/Berlin TZ=Europe/Berlin
+1
View File
@@ -8,3 +8,4 @@ test-results
playwright-report playwright-report
coverage coverage
devlog/zeiten.csv devlog/zeiten.csv
public/sw.js
+107
View File
@@ -6,6 +6,113 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.6.0] – 2026-09-12
### Hinzugefügt
- **Zammad** (nur `firma`): Tickets, auf die der Suchausdruck passt, werden zu
Aufgaben — mit Verweis ins Ticket. Wird ein Ticket dort geschlossen, hakt
der Planer die Aufgabe ab.
- **Telegram** (nur `daheim`): Eine Nachricht an den Bot wird zur Aufgabe.
Meldungen gehen denselben Weg zurück, mit einem Knopf zum Abhaken. Ein Chat
meldet sich über einen Einmalcode an, der zehn Minuten gilt.
- **Telegram als dritter Meldeweg** neben Push und E-Mail.
- **Spiegel** zwischen den Instanzen: `firma` schickt Belegungen ohne Titel
und die einzeln dafür markierten Aufgaben, `daheim` empfängt. Gespiegelte
Belegung erscheint in der Wochenansicht als „Dienstlich belegt" und zählt
gegen die Tageskapazität.
- Seite **Integrationen**: je Integration Zustand, letzter Erfolg, letzter
Fehler und ein Knopf, der einen Lauf sofort anstößt.
- Handbuchseite zu den Integrationen.
### Geändert
- Was aus einem Fund wird, entscheidet für alle Integrationen dieselbe
Stelle. Geführt werden nur Titel, Notiz, Verweis und Fälligkeit — Bereich,
Projekt, Priorität und geschätzter Aufwand gehören der Planung und werden
nie von außen überschrieben.
- Eine klemmende Integration meldet sich wie ein klemmender Kalender: erst
nach dem dritten Fehlversuch in Folge.
- Die Demo-Instanz für die Bilder spricht nicht mehr nach außen.
### Sicherheit
- Wer dem Telegram-Bot schreibt, ohne verbunden zu sein, bekommt **keine
Antwort** — auch keine Fehlermeldung. Sonst ließe sich herausfinden, dass
es diesen Planer gibt.
- Jede Sendung des Spiegels trägt eine Signatur über den Rumpf und ist nach
fünf Minuten wertlos. Der Empfänger antwortet auf jeden Fehlschlag gleich:
falsches Geheimnis, verstellte Uhr und Unsinn im Inhalt sind von außen
nicht zu unterscheiden.
- Der Signaturvergleich läuft in gleichbleibender Zeit.
- Aufgaben verlassen die Instanz nur mit ausdrücklicher Freigabe je Aufgabe.
Ein Schalter für einen ganzen Bereich würde zu leicht Kundennamen nach
Hause schicken.
## [0.5.0] – 2026-09-11
### Hinzugefügt
- **Benachrichtigungen** per Web-Push und E-Mail, je Benutzer einstellbar.
Vier Anlässe: Tagesüberblick am Morgen, fällige Aufgaben, Vorlauf vor einem
Zeitblock und ein Kalender, der sich nicht abgleichen lässt.
- **Ruhezeit** von 20:00 bis 07:00 ab Werk, über Mitternacht hinweg; wahlweise
das ganze Wochenende. Der Tagesüberblick kommt trotzdem zu seiner Zeit — er
käme sonst nie, wenn jemand früh aufsteht.
- Anmeldung eines Browsers für Push, Liste der angemeldeten Geräte und ein
Knopf, der eine Probemeldung über alle eingeschalteten Wege schickt.
- Ein Lauf alle zehn Minuten prüft, ob etwas zu melden ist; die nächtliche
Aufräumrunde entfernt Nachweise, die älter als 30 Tage sind.
- Handbuchseite zu Benachrichtigungen.
### Geändert
- Der Kalenderabgleich zählt Fehlversuche in Folge mit und meldet erst ab dem
dritten. Ein Netz hustet, ein Anbieter hat Wartung — das ist noch keine
Nachricht wert.
- Ab Werk meldet der Planer gar nichts: kein Weg ist eingeschaltet. Wer beim
ersten Start mit Meldungen überschüttet wird, schaltet alles ab — und dann
auch das Nützliche.
### Sicherheit
- Die Push-Schlüssel eines Geräts liegen verschlüsselt in der Datenbank.
Weder Endpunkt noch Schlüssel noch Mailinhalte landen im Protokoll.
- Ein Gerät, das der Push-Dienst mit 404 oder 410 ablehnt, wird sofort
entfernt; andere Fehler führen nach fünf Versuchen zum selben Ergebnis.
- Der Nachweis einer Meldung wird **vor** dem Senden geschrieben: lieber eine
verpasste Nachricht als eine, die im Minutentakt erneut versucht wird.
## [0.4.0] – 2026-09-11
### Hinzugefügt
- **Wochenansicht** mit Ziehen und Ablegen: Aufgaben aus der linken Spalte in
ein Zeitfenster ziehen legt einen Zeitblock an und schreibt ihn in den
Kalender. Die Dauer kommt aus dem geschätzten Aufwand der Aufgabe.
- Zeitblöcke verschieben und ausplanen; der Kalendertermin zieht mit.
- **Rückweg**: Wird ein Zeitblock im Kalender verschoben, übernimmt der Planer
das beim nächsten Abgleich. Wird er dort gelöscht, verschwindet auch der
Zeitblock — die Aufgabe bleibt und fällt zurück auf offen.
- Kapazität und Belegung je Wochentag über jeder Spalte; überbuchte Tage
werden rot, sperren aber nichts.
- Eine eingeplante Aufgabe wechselt selbsttätig von _Offen_ auf _Geplant_ und
wieder zurück, wenn nichts mehr für sie geplant ist.
- Handbuchseite zur Wochenplanung.
### Sicherheit
- **Schleifenschutz** über `hash = sha256(startISO|endeISO)`: Der Planer
schreibt nur bei eigener Änderung und übernimmt aus dem Kalender nur, wenn
der Fingerabdruck abweicht. Ohne das würden App und Kalender einander
endlos hinterherschreiben.
- Jeder Schreibzugriff auf einen bestehenden Termin geht mit `If-Match`
heraus. Bei HTTP 412 **gewinnt der Kalender**: der Planer schreibt nicht,
verwirft seinen Fingerabdruck und überlässt dem nächsten Abgleich, den
fremden Stand zu holen.
- Zeitblöcke gehören dem Benutzer über sein Kalenderkonto; die Kennung geht
in jede where-Bedingung, nicht in einen nachträglichen Vergleich.
## [0.3.0] – 2026-09-10 ## [0.3.0] – 2026-09-10
### Hinzugefügt ### Hinzugefügt
+88 -7
View File
@@ -7,9 +7,10 @@ lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
wird. wird.
> **Stand:** Meilenstein 3 von 8. Aufgaben, Projekte und das Lesen der > **Stand:** Meilenstein 4 von 8. Die Kernidee steht: Aufgaben lassen sich
> Kalender stehen: der Planer weiß jetzt, wann überhaupt Platz ist. Das > per Ziehen und Ablegen als Zeitblöcke in den Kalender schreiben, und
> Schreiben der Zeitblöcke per Ziehen und Ablegen kommt mit M4. Der > Verschiebungen von dort kommen zurück. Es folgen Benachrichtigungen,
> Integrationen, Schnellerfassung vom Handy und ein MCP-Server. Der
> [Fahrplan](ROADMAP.md) sagt, was wann folgt. > [Fahrplan](ROADMAP.md) sagt, was wann folgt.
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone` - **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
@@ -30,6 +31,14 @@ wird.
- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt - **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt
- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je - **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je
Bereich, wie voll der heutige Tag schon ist Bereich, wie voll der heutige Tag schon ist
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
Kalenderterminen, Verschiebungen im Kalender kommen zurück
- **Benachrichtigungen** per Web-Push, E-Mail und Telegram: Tagesüberblick,
fällige Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk
still
- **Integrationen**: Zammad-Tickets werden zu Aufgaben, Telegram erfasst und
hakt ab, und der Spiegel bringt dienstliche Belegung nach Hause — ohne
Titel
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und - Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
Passwort — je Instanz einzeln zuschaltbar Passwort — je Instanz einzeln zuschaltbar
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird - Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
@@ -40,23 +49,25 @@ wird.
### Geplant ### Geplant
Zeitblöcke in den Kalender schreiben, Wochenplanung per Ziehen und Ablegen, Schnellerfassung vom Handy, eine öffentliche API und ein MCP-Server.
Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und ein Siehe [ROADMAP.md](ROADMAP.md).
MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
## Bildergalerie ## Bildergalerie
| | Hell | Dunkel | | | Hell | Dunkel |
| ------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) | | Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) |
| Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) | | Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) |
| Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) | | Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) |
| Woche | ![Woche, hell](docs/bilder/woche-hell-schreibtisch.png) | ![Woche, dunkel](docs/bilder/woche-dunkel-schreibtisch.png) |
| Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) | | Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) |
| Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) | | Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) |
| Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) | | Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) |
| Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-dunkel-schreibtisch.png) | | Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-dunkel-schreibtisch.png) |
| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) | | Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) |
| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) | | Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) |
| Meldungen | ![Benachrichtigungen, hell](docs/bilder/benachrichtigungen-hell-schreibtisch.png) | ![Benachrichtigungen, dunkel](docs/bilder/benachrichtigungen-dunkel-schreibtisch.png) |
| Integrationen | ![Integrationen, hell](docs/bilder/integrationen-hell-schreibtisch.png) | ![Integrationen, dunkel](docs/bilder/integrationen-dunkel-schreibtisch.png) |
| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) | | Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) |
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
@@ -133,6 +144,76 @@ Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind — Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`). und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`).
### Benachrichtigungen
Beides ist freiwillig. Fehlt ein Satz, bleibt der Weg in den Einstellungen
ausgegraut, und der Planer läuft ohne ihn weiter.
| Variable | Standard | Bedeutung |
| ------------------- | -------- | --------------------------------------------------------- |
| `VAPID_OEFFENTLICH` | _(leer)_ | Öffentlicher Push-Schlüssel, geht an den Browser |
| `VAPID_PRIVAT` | _(leer)_ | Privater Push-Schlüssel, bleibt auf dem Server |
| `VAPID_KONTAKT` | _(leer)_ | Kontakt für den Push-Dienst, `mailto:` oder `https:` |
| `SMTP_HOST` | _(leer)_ | Mailserver. Ohne ihn kein Mailversand |
| `SMTP_PORT` | `587` | Port des Mailservers |
| `SMTP_BENUTZER` | _(leer)_ | Anmeldename. Leer lassen für einen Server ohne Anmeldung |
| `SMTP_PASSWORT` | _(leer)_ | Passwort dazu |
| `SMTP_ABSENDER` | _(leer)_ | Absenderadresse, z. B. `Planer <planer@example.de>` |
| `SMTP_TLS` | `false` | `true` bei Port 465 (SMTPS), `false` bei 587 mit STARTTLS |
Push braucht **alle drei** VAPID-Werte, Mail mindestens `SMTP_HOST` und
`SMTP_ABSENDER`. Die Schlüssel entstehen einmalig mit:
```bash
npx web-push generate-vapid-keys
```
Wird das Schlüsselpaar später getauscht, müssen sich alle Geräte neu
anmelden — die alten Abos sind dann wertlos. Push setzt außerdem HTTPS
voraus; `http://localhost` gilt dem Browser als sicher, jede andere
Klartextadresse nicht.
Was der Planer wann meldet, steht im
[Handbuch](docs/handbuch/benachrichtigungen.md).
### Integrationen
Welche Integration eine Instanz überhaupt darf, entscheidet `INSTANZ` — nicht
die Konfiguration. Ein Zammad-Token in der Instanz `daheim` bleibt wirkungslos.
| Variable | Standard | Bedeutung |
| ------------------- | ------------------- | ---------------------------------------------------------------- |
| `ZAMMAD_URL` | _(leer)_ | Adresse der Zammad-Instanz, nur `firma` |
| `ZAMMAD_TOKEN` | _(leer)_ | Token aus **Profil → Token-Zugriff** |
| `ZAMMAD_ABFRAGE` | `owner_id:me AND …` | Suchausdruck; was darauf passt, wird zur Aufgabe |
| `TELEGRAM_TOKEN` | _(leer)_ | Bot-Token von [@BotFather](https://t.me/BotFather), nur `daheim` |
| `SPIEGEL_GEHEIMNIS` | _(leer)_ | Gemeinsames Geheimnis beider Instanzen |
| `SPIEGEL_ZIEL` | _(leer)_ | Adresse der empfangenden Instanz; nur auf der sendenden Seite |
**Zammad** liest nur. Ein Ticket schließt man im Ticketsystem — sonst weiß die
Kollegin nicht, warum es plötzlich zu ist. Umgekehrt schließt der Planer eine
Aufgabe, sobald Zammad das Ticket als erledigt meldet.
**Telegram** braucht keinen Webhook und damit keine von außen erreichbare
Adresse. Welche Chats mitreden dürfen, wird in der Anwendung unter
**Integrationen** gekoppelt: Knopf drücken, Code an den Bot schicken, fertig.
Nachrichten aus unbekannten Chats werden verworfen, ohne zu antworten.
**Der Spiegel** geht in eine Richtung: `firma` sendet, `daheim` empfängt.
Hinüber gehen Belegungen **ohne Titel** — daheim ist zu sehen, _dass_ die Zeit
weg ist, nicht woran gearbeitet wird — und jene Aufgaben, die einzeln zum
Spiegeln markiert wurden. Jede Sendung ist mit dem gemeinsamen Geheimnis
signiert und älter als fünf Minuten wertlos.
Das Geheimnis erzeugst du wie das Sitzungsgeheimnis:
```bash
openssl rand -base64 32
```
Was der Alltag damit anfängt, steht im
[Handbuch](docs/handbuch/integrationen.md).
### Einrichtung und Sonstiges ### Einrichtung und Sonstiges
| Variable | Standard | Bedeutung | | Variable | Standard | Bedeutung |
+49 -17
View File
@@ -8,9 +8,9 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| [M1](#m1--fundament) | Fundament | erledigt | | [M1](#m1--fundament) | Fundament | erledigt |
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt | | [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt | | [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant | | [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant | | [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
| [M6](#m6--integrationen) | Integrationen | geplant | | [M6](#m6--integrationen) | Integrationen | erledigt |
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant | | [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
| [M8](#m8--mcp) | MCP | geplant | | [M8](#m8--mcp) | MCP | geplant |
@@ -76,29 +76,61 @@ später" und wartet darauf, ob es im Alltag wirklich fehlt.
## M4 – Wochenplanung und Zeitblöcke ## M4 – Wochenplanung und Zeitblöcke
**Erledigt am 11.09.2026 mit Version 0.4.0.**
**Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und **Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und
Verschiebungen von dort zurückholen. Verschiebungen von dort zurückholen.
- [ ] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto - [x] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
- [ ] Wochenansicht mit `@dnd-kit/core` - [x] Wochenansicht mit `@dnd-kit/core`
- [ ] Schreiben in den Kalender, Rückweg beim Verschieben - [x] Schreiben in den Kalender, Rückweg beim Verschieben
- [ ] Schleifenschutz über `hash = sha256(startISO|endeISO)` - [x] Schleifenschutz über `hash = sha256(startISO|endeISO)`
- [ ] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match) - [x] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
- [ ] Belegung gegen die Tageskapazität des Bereichs - [x] Belegung gegen die Tageskapazität des Bereichs
**Bewusst offen gelassen:** Die Dauer lässt sich nicht am Balken ziehen, und
ein Zeitblock landet immer im ersten verbundenen Kalender. Beides steht unter
"Ideen für später" — erst zeigt der Alltag, ob es wirklich fehlt.
## M5 – Benachrichtigungen ## M5 – Benachrichtigungen
- [ ] Web-Push mit `web-push` **Erledigt am 11.09.2026 mit Version 0.5.0.**
- [ ] Tagesüberblick per E-Mail über nodemailer
- [ ] Erinnerungen an Fälligkeiten, Jobs über node-cron **Ziel:** Sagen, was ansteht — und dabei so selten stören, dass niemand auf
- [ ] Je Benutzer einstellbar, mit Ruhezeiten die Idee kommt, alles abzuschalten.
- [x] Web-Push mit `web-push`, Anmeldung je Browser
- [x] Tagesüberblick per E-Mail über nodemailer
- [x] Erinnerungen an Fälligkeiten und vor Zeitblöcken, Jobs über node-cron
- [x] Je Benutzer einstellbar, mit Ruhezeiten über Mitternacht
- [x] Wiederholungssperre je Anlass, Sache und Tag
- [x] Abgleichfehler erst nach mehreren Versuchen hintereinander
**Bewusst offen gelassen:** Keine Sammelmeldung mehrerer Anlässe in einer
Nachricht und kein Nachholen dessen, was in die Ruhezeit fiel. Eine
Erinnerung an einen Zeitblock, der längst begonnen hat, hilft niemandem.
## M6 – Integrationen ## M6 – Integrationen
- [ ] Zammad und Wartungsplaner-API (nur `firma`) **Erledigt am 12.09.2026 mit Version 0.6.0.**
- [ ] Telegram (nur `daheim`)
- [ ] Spiegel: `firma` sendet, `daheim` empfängt **Ziel:** Aufgaben dort abholen, wo sie entstehen — ohne dass die Planung
- [ ] Jede Integration upsertet über `[quelle, quelleRef]` darunter leidet, die hier passiert.
- [x] Gemeinsame Aufnahmestelle: jede Integration liefert nur Funde
- [x] Zammad (nur `firma`), lesend
- [ ] **Wartungsplaner-API** — Schnittstelle steht noch nicht fest
- [x] Telegram (nur `daheim`): erfassen, melden, abhaken
- [x] Spiegel: `firma` sendet, `daheim` empfängt
- [x] Jede Integration upsertet über `[quelle, quelleRef]`
**Offen geblieben:** Der **Wartungsplaner** ist nicht angebunden. Die
Aufnahmestelle steht bereit und die Seite „Integrationen" führt ihn als
sichtbare Lücke; es fehlt allein das Abholen, und dafür muss erst feststehen,
was das Produkt an Schnittstelle hergibt.
**Bewusst offen gelassen:** Zammad wird nur gelesen. Ein Ticket schließt man
im Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
## M7 – Schnellerfassung und API ## M7 – Schnellerfassung und API
+281 -2
View File
@@ -6,11 +6,290 @@ die Übersicht aus `scripts/zeit.sh summe`.
## Zeit je Meilenstein ## Zeit je Meilenstein
| Meilenstein | Art | Minuten | Stunden | | Meilenstein | Art | Minuten | Stunden |
| ----------- | ------ | ------- | -------- | | ----------- | ------ | -------- | --------- |
| M1 | claude | 161 | 2,68 | | M1 | claude | 161 | 2,68 |
| M2 | claude | 44 | 0,73 | | M2 | claude | 44 | 0,73 |
| M3 | claude | 41 | 0,68 | | M3 | claude | 41 | 0,68 |
| **Gesamt** | | **246** | **4,10** | | M4 | claude | 167 | 2,78 |
| M5 | claude | 420 | 7,00 |
| M6 | claude | 733 | 12,22 |
| **Gesamt** | | **1566** | **26,10** |
---
## 2026-09-12 – M6: Integrationen
**Dauer:** 733 Minuten (20:47–09:00), Art `claude`.
> Die Zahl täuscht. Ein großer Teil davon war Warten auf Docker-Bauten, die
> in der Nacht dreimal hängen blieben — einmal 39 Minuten ohne eine Zeile
> Ausgabe. Die eigentliche Arbeit war knapper.
**Ziel der Sitzung:** Aufgaben dort abholen, wo sie entstehen — ohne dass die
Planung darunter leidet, die hier passiert.
### Erledigt
- Gemeinsame Aufnahmestelle für alle Integrationen (21 Tests)
- Zammad, lesend, mit Suchausdruck aus der Konfiguration
- Telegram: erfassen, melden, abhaken — mit Kopplung über Einmalcode
- Spiegel zwischen den Instanzen: Belegung ohne Titel, Aufgaben nur markiert
- Seite „Integrationen" mit Zustand, Fehler und Knopf für den Sofortlauf
- Gespiegelte Belegung in der Wochenansicht
- 4 neue E2E-Tests, 9 neue Integrationsprüfungen, Handbuchseite
### Entscheidungen mit Begründung
**Das Ursprungssystem führt Titel, Notiz, Verweis und Fälligkeit — mehr
nicht.** Bereich, Projekt, Priorität und geschätzter Aufwand gehören der
Planung. Wer ein Ticket einsortiert und schätzt, muss sich darauf verlassen
können, dass der nächste Abgleich das nicht wegräumt. Ohne diese Trennung
wäre die Wochenplanung nach jedem Lauf kaputt.
**Zammad wird nur gelesen.** Ein Ticket schließt man im Ticketsystem, sonst
weiß die Kollegin nicht, warum es plötzlich zu ist. Umgekehrt darf Zammad
hier abhaken — dort ist die Wahrheit über den Zustand des Tickets.
**Telegram holt ab, statt einen Webhook zu bekommen.** Ein Webhook verlangt
eine von außen erreichbare Adresse mit gültigem Zertifikat. Der Planer soll
auch hinter einem Anschluss ohne feste Adresse laufen, und der Preis dafür
sind zwei Minuten Verzögerung.
**Unbekannte Chats bekommen keine Antwort — auch keine Fehlermeldung.** Wer
die Kennung des Bots kennt, könnte ihm sonst schreiben und daran erkennen,
dass es diesen Planer gibt. Die einzige Ausnahme ist ein gültiger
Kopplungscode.
**Der Kopplungscode entstand aus einem Widerspruch.** Erst sollte die
Chatkennung von Hand eingetragen werden. Nur: Um sie zu erfahren, müsste der
Bot Unbekannten antworten — genau das, was er nicht darf. Der Code dreht die
Richtung um: Er kommt aus der Anwendung, in die man sich anmelden muss.
**Der Spiegel schickt Belegung ohne Titel.** Daheim soll zu sehen sein,
_dass_ die Zeit weg ist, nicht woran gearbeitet wird. Aufgaben gehen nur
einzeln markiert mit — ein Schalter für einen ganzen Bereich würde zu leicht
Kundennamen nach Hause schicken.
**Was der Sender nicht mehr schickt, verschwindet beim Empfänger.** Sonst
stünde daheim für immer eine Sperre, die es längst nicht mehr gibt. Für
Aufgaben gilt das nicht: Sie sind Planung, keine Sperre.
**Jede Sendung ist fünf Minuten gültig.** Ohne Frist ließe sich ein
abgefangenes Paket beliebig oft erneut einspielen. Der Empfänger antwortet
auf jeden Fehlschlag gleich — falsches Geheimnis, verstellte Uhr und Unsinn
im Inhalt sind von außen nicht zu unterscheiden.
**Die Demo-Instanz redet mit niemandem.** Sie ist mit erfundenen Daten
gefüllt und steht nur für Bilder. Solange `DEMO_MODUS` gilt, starten die
Jobs für Zammad, Telegram und den Spiegel gar nicht erst.
### Probleme und Lösungen
| Problem | Lösung |
| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Die Kopfleiste verschluckte mit dem elften Eintrag „Einstellungen" und „Protokoll" | Sie ist jetzt breiter als die Inhaltsspalte. Eine Leiste, die Punkte stillschweigend abschneidet, ist schlimmer als eine, die nicht bündig mit dem Text steht. Gefunden im aufgenommenen Bild. |
| Der Kartentext erklärte noch den Weg von vor dem Kopplungscode | Ersetzt. Ebenfalls erst im Bild aufgefallen — im Code war beides für sich stimmig. |
| Ein Kürzungstest schlug an der Grenze fehl | Nicht der Test war falsch, sondern der Vergleich: Eine Worttrennung bei genau der halben Titellänge wurde abgelehnt. Jetzt `>=`. |
| Ein regulärer Ausdruck ging beim Schreiben über die Shell kaputt | Das Muster steht jetzt als benannte Konstante im Code, geschrieben mit dem Edit-Werkzeug statt über ein Skript. |
| Ein unsichtbares Zeichen (U+200B) landete mitten in einem Funktionsnamen | Entfernt und die Datei danach abgesucht. |
| Das Aufräumen im Integrationstest hätte fremde Telegram-Chats treffen können | Es trifft jetzt genau die Kennung, die der Lauf selbst angelegt hat. |
| Docker-Bauten blieben dreimal hängen, einmal 39 Minuten ohne Ausgabe | Kein Codeproblem: Nach Abbruch und Neustart lief derselbe Bau in Minuten durch. Die Bilder mussten deshalb viermal angestoßen werden. |
### Offene Punkte
- **Der Wartungsplaner ist nicht angebunden.** Die Aufnahmestelle steht
bereit und die Seite führt ihn als sichtbare Lücke; es fehlt allein das
Abholen. Dafür muss erst feststehen, was das Produkt an Schnittstelle
hergibt.
- Zammad und Telegram sind gegen die Logik geprüft, nicht gegen die echten
Dienste — dafür fehlen Zugangsdaten. Der Spiegel dagegen ist end-to-end
geprüft, weil beide Enden dieselbe Anwendung sind.
- Ein Telegram-Chat lässt sich nicht umbenennen. Fällt das im Alltag auf,
ist es ein Einzeiler.
### Commits
- 7f8f505 feat: Datenmodell und Umgebung für Integrationen
- e4d3547 feat: Aufnahmeregeln für Integrationen als reine Logik
- 6dad333 feat: Zammad, Telegram und den Spiegel anbinden
- bc60e5f feat: Integrationen takten und Telegram als dritten Meldeweg
- 1281c3e feat: Integrationen sichtbar und bedienbar machen
- 0d0f850 feat: Gespiegelte Belegung in der Wochenansicht zeigen
- 4da2f3d test: Spiegel-Eingang und Integrationstabellen absichern
- 3befcc1 feat: Aufgaben einzeln zum Spiegeln freigeben
- f5eadef feat: Lauf von Hand anstoßen und Spiegelschalter in der Liste
- 3c7cdbb chore: Demo-Daten, Bildaufnahme und Platz in der Kopfleiste
- 475b1d5 docs: Handbuchseite, README und Fahrplan für M6
- 98d170b docs: Handbuchbilder für M6 neu aufnehmen
---
## 2026-09-11 – M5: Benachrichtigungen
**Dauer:** 420 Minuten (06:35–13:35), Art `claude`.
**Ziel der Sitzung:** Sagen, was ansteht — und dabei so selten stören, dass
niemand auf die Idee kommt, alles abzuschalten.
### Erledigt
- Regeln als reine Logik: Anlässe, Ruhezeit, Wiederholungssperre (30 Tests)
- Web-Push über VAPID samt Service Worker, An- und Abmeldung je Browser
- Mailversand über nodemailer, als Text und als HTML
- Vier Anlässe: Tagesüberblick, Fälligkeit, Vorlauf vor einem Zeitblock,
Abgleichfehler
- Bedienung in den Einstellungen mit Probemeldung und Geräteliste
- Ein Lauf alle zehn Minuten, Nachweise räumt die Nachtrunde weg
- 2 neue E2E-Tests, 7 neue Integrationsprüfungen, Handbuchseite
### Entscheidungen mit Begründung
**Ab Werk meldet der Planer gar nichts.** Kein Weg ist eingeschaltet. Wer
beim ersten Start mit Meldungen überschüttet wird, schaltet alles ab — und
dann auch das Nützliche. Die Einrichtung ist ein bewusster Handgriff, und
das ist der Preis dafür, dass die Meldungen später ernst genommen werden.
**Der Tagesüberblick ist von der Ruhezeit ausgenommen.** Sonst bekäme ihn
nie, wer ihn auf 06:00 stellt — die voreingestellte Ruhezeit läuft bis
07:00. Die Ruhezeit schützt vor Störungen, nicht vor der Nachricht, um die
ausdrücklich gebeten wurde.
**Der Nachweis wird vor dem Senden geschrieben, nicht danach.** Bricht der
Versand mittendrin ab, gilt die Meldung als zugestellt und kommt nicht
wieder. Andersherum würde ein hängender Mailserver dieselbe Nachricht alle
zehn Minuten erneut versuchen. Lieber eine verpasste Nachricht als eine,
die im Minutentakt klopft.
**Was in die Ruhezeit fällt, wird nicht nachgeholt.** Eine Erinnerung an
einen Zeitblock, der längst begonnen hat, ist nur der Beweis, dass die
Anwendung zu spät kommt.
**Abgleichfehler erst ab dem dritten Versuch in Folge.** Ein Netz hustet,
ein Anbieter hat Wartung. Beim ersten Fehlschlag zu melden hieße, für
Dinge zu wecken, die sich von selbst erledigen. Nach dem dritten Mal ist es
dagegen dringend — solange steht die Wochenansicht auf altem Stand.
**Der Tagesüberblick entfällt, wenn es nichts zu sagen gibt.** „Heute steht
nichts an" ist keine Information, sondern Lärm.
**Tote Abos putzen sich selbst weg.** 404 und 410 heißen endgültig, das
Gerät verschwindet sofort; andere Fehler zählen hoch und führen nach fünf
Versuchen zum selben Ergebnis. Eine Liste, die von Hand gepflegt werden
muss, wird nicht gepflegt.
**Push-Schlüssel liegen verschlüsselt, Endpunkte nie im Protokoll.** Ein
Endpunkt genügt, um jemandem Nachrichten zu schicken — er gehört damit in
dieselbe Klasse wie ein Token.
### Probleme und Lösungen
| Problem | Lösung |
| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pushManager.subscribe` lehnte den VAPID-Schlüssel als `Uint8Array<ArrayBufferLike>` ab | Der Puffer wird jetzt ausdrücklich als `ArrayBuffer` angelegt; ein geteilter Puffer ist dort nicht erlaubt. |
| Der Integrationstest brach mit einem Fremdschlüsselfehler ab | Die neuen Prüfungen standen hinter dem Löschen des Testbenutzers. Jetzt davor — und danach wird geprüft, dass Geräte und Nachweise mit ihm verschwinden. |
| E2E-Läufe scheiterten unter Last immer wieder beim Anmelden | Ein Klick, der vor der Hydratation ankommt, verpufft folgenlos. Der Anmeldehelfer liegt jetzt einmal in `e2e/hilfe.ts` und fasst nach, bis die Startseite da ist. |
| Die Karte meldete gleichzeitig „Browser blockiert" und „Server nicht eingerichtet" | Zwei Aussagen, von denen nur eine zählt. Der Serverzustand geht jetzt vor. Gefunden nur durch das Ansehen des aufgenommenen Bildes. |
| Das erfundene Push-Gerät in den Demo-Daten wäre beim ersten Versandlauf gelöscht worden | Seine Schlüssel lagen als Klartext-Platzhalter, und unlesbare Schlüssel führen zum Entfernen. Die Demo-Daten enthalten jetzt kein Gerät mehr — die Bilder zeigen den Zustand davor. |
| Im Bild meldet der Browser „blockiert", obwohl die Erlaubnis erteilt wurde | Chromium im Kopflosbetrieb antwortet auf `Notification.permission` immer mit `denied`; `grantPermissions` ändert daran nichts. Das Handbuch schreibt es jetzt unter das Bild. |
| Ein Docker-Bau brauchte 14 Minuten, ein E2E-Lauf 18 | Systemlast, kein Codeproblem: dieselben Läufe danach in 1,5 beziehungsweise 0,4 Minuten. Ein vergessener Testserver auf Port 3100 hat kräftig mitgeholfen. |
### Offene Punkte
- Der Versandweg ist gegen die Logik geprüft, nicht gegen echte Dienste:
ohne VAPID-Schlüssel und SMTP-Zugang geht lokal nichts hinaus. Das braucht
einen Lauf auf dem Server.
- Mehrere Anlässe erscheinen als mehrere Nachrichten. Ob eine Sammelmeldung
fehlt, zeigt erst der Alltag.
### Commits
- 24f613d feat: Datenmodell und Umgebung für Benachrichtigungen
- ddb13d7 feat: Regeln für Benachrichtigungen als reine Logik
- e0e11c8 feat: Versand per Web-Push und E-Mail
- b622c1b feat: Benachrichtigungen in den Einstellungen bedienen
- 047a4a1 feat: Meldungen alle zehn Minuten prüfen und verschicken
- 5bbbdd5 chore: Demo-Daten und Bildaufnahme für Benachrichtigungen
- f03ea73 docs: Handbuchseite und README-Abschnitt zu Benachrichtigungen
- 6e1f279 test: Benachrichtigungen end-to-end und in der Datenbank absichern
- 7bef435 fix: Serverzustand geht der Browsermeldung vor
- 35ecb2f chore: Screenshot-Instanz mit eingerichteten Meldewegen
- bb7b189 docs: Handbuchbilder für M5 neu aufnehmen
---
## 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
--- ---
+3
View File
@@ -2,3 +2,6 @@ datum;start;ende;minuten;meilenstein;art;notiz
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt 2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig 2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig
2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig 2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
2026-09-11;20:47;09:00;733;M6;claude;M6 Integrationen 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
6 2026-09-11 06:35 13:35 420 M5 claude M5 Benachrichtigungen fertig
7 2026-09-11 20:47 09:00 733 M6 claude M6 Integrationen fertig
+16
View File
@@ -57,5 +57,21 @@ services:
WEBAUTHN_RP_NAME: Planer WEBAUTHN_RP_NAME: Planer
WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200} WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
DEMO_MODUS: 'true' DEMO_MODUS: 'true'
# Damit die Karte "Benachrichtigungen" den Normalfall zeigt und nicht
# den Hinweis, dass nichts eingerichtet ist. Das Schluesselpaar erzeugt
# scripts/screenshots.ts fuer jeden Lauf neu – es steht bewusst nirgends
# im Verzeichnis.
VAPID_OEFFENTLICH: ${SCREENSHOT_VAPID_OEFFENTLICH:-}
VAPID_PRIVAT: ${SCREENSHOT_VAPID_PRIVAT:-}
VAPID_KONTAKT: mailto:planer@planer.invalid
# Ein Host, den es nie geben wird: die Oberflaeche zeigt E-Mail als
# moeglich, hinaus geht trotzdem nichts.
SMTP_HOST: smtp.planer.invalid
SMTP_ABSENDER: Planer <planer@planer.invalid>
# Erfundene Werte, damit die Seite "Integrationen" den eingerichteten
# Fall zeigt. Nach draussen geht davon nichts: Die Jobs schweigen,
# solange DEMO_MODUS true ist.
TELEGRAM_TOKEN: 000000:demo-token-ohne-funktion
SPIEGEL_GEHEIMNIS: demo-geheimnis-ohne-funktion
ports: ports:
- '${SCREENSHOT_APP_PORT:-3200}:3000' - '${SCREENSHOT_APP_PORT:-3200}:3000'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

+7 -4
View File
@@ -18,10 +18,16 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
Fortschritt Fortschritt
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google 6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
verbinden, Belegung verstehen verbinden, Belegung verstehen
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
Kalender legen
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und
Telegram einrichten, Ruhezeiten setzen
9. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel
zwischen Firma und Daheim
## Verwaltung ## Verwaltung
7. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat 10. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
## Was noch kommt ## Was noch kommt
@@ -29,9 +35,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
| Thema | Meilenstein | | Thema | Meilenstein |
| ------------------------------- | ----------- | | ------------------------------- | ----------- |
| Wochenplanung und Zeitblöcke | M4 |
| Benachrichtigungen | M5 |
| Integrationen | M6 |
| Schnellerfassung vom Handy, API | M7 | | Schnellerfassung vom Handy, API | M7 |
| MCP | M8 | | MCP | M8 |
+108
View File
@@ -0,0 +1,108 @@
# Benachrichtigungen
Ab Werk meldet der Planer **gar nichts**. Das ist Absicht: Eine Anwendung,
die zu oft meldet, wird stummgeschaltet — und dann verpasst du auch das
Wichtige. Was du hier einschaltest, kommt. Alles andere nicht.
![Benachrichtigungen in den Einstellungen](../bilder/benachrichtigungen-hell-schreibtisch.png)
_Der Browser auf dem Bild hat Benachrichtigungen blockiert — so sieht die
Karte aus, wenn die Erlaubnis fehlt. Wie du sie erteilst, steht weiter unten._
Du findest die Einstellung unter **Einstellungen → Benachrichtigungen**.
## Die zwei Wege
| | Web-Push | E-Mail |
| --------- | ---------------------------------------- | ---------------------------------------- |
| Kommt an | als Systemmeldung auf dem Gerät | im Postfach |
| Braucht | Erlaubnis des Browsers, je Gerät einzeln | nichts weiter |
| Gut für | „gleich beginnt ein Zeitblock" | den Tagesüberblick am Morgen |
| Fällt aus | wenn der Browser die Erlaubnis entzieht | wenn der Mailserver nicht erreichbar ist |
Beide Wege lassen sich gleichzeitig einschalten. Dann kommt jede Meldung
über beide — der Planer verschickt sie trotzdem nur **einmal je Anlass**.
Ist ein Weg auf dem Server nicht eingerichtet, steht das direkt daneben.
Was dafür nötig ist, steht im [README](../../README.md), Abschnitt
_Benachrichtigungen_.
## Push auf einem Gerät einschalten
1. Auf **Anmelden** klicken.
2. Der Browser fragt nach Erlaubnis — **Zulassen**.
3. Das Gerät erscheint in der Liste darunter.
Das gilt **je Gerät und je Browser**. Der Rechner im Büro und das Handy
sind zwei Anmeldungen; Firefox und Chrome auf demselben Rechner ebenfalls.
> **Der Knopf fehlt oder ist grau?** Push braucht HTTPS. Unter
> `http://localhost` geht es zum Entwickeln, unter jeder anderen
> Klartextadresse nicht. Hat der Browser Benachrichtigungen einmal
> blockiert, lässt sich das nur in seinen eigenen Einstellungen
> zurücknehmen — die Anwendung darf dann nicht mehr fragen.
Ein Gerät, das der Push-Dienst dauerhaft ablehnt, entfernt der Planer von
selbst aus der Liste. Du musst nichts aufräumen.
## Was gemeldet wird
| Anlass | Wann |
| ---------------------------------------- | ------------------------------------------------------------------------ |
| **Tagesüberblick** | einmal morgens zur eingestellten Zeit |
| **Fällige Aufgaben** | wenn eine Aufgabe an diesem Tag fällig wird, höchstens einmal je Aufgabe |
| **Gleich beginnt ein Zeitblock** | kurz vor dem Beginn, Vorlauf einstellbar |
| **Kalender lässt sich nicht abgleichen** | erst nach dem dritten Fehlversuch hintereinander |
Eingeschaltet sind anfangs nur Tagesüberblick und fällige Aufgaben — und
selbst die kommen erst, wenn du einen Weg (Push oder E-Mail) aktiviert hast.
Der **Tagesüberblick** entfällt an Tagen, an denen es nichts zu sagen gibt.
Eine Meldung „heute steht nichts an" ist keine Information, sondern Lärm.
Der **Abgleichfehler** kommt bewusst spät: Ein Netz hustet, ein Anbieter hat
Wartung. Erst wenn es dreimal hintereinander nicht klappt, ist es eine
Nachricht wert — dann allerdings dringend, denn solange bleibt deine
Wochenansicht auf einem alten Stand.
## Ruhezeit
Voreingestellt ist **20:00 bis 07:00**. In diesem Fenster kommt nichts.
- Die Ruhezeit läuft über Mitternacht hinweg — das ist der Normalfall.
- Gleiche Start- und Endzeit heißt: **keine** Ruhezeit.
- **Am Wochenende still** legt Samstag und Sonntag komplett still.
Eine Ausnahme: Der **Tagesüberblick** kommt zu seiner festen Zeit, auch wenn
die noch in der Ruhezeit liegt. Wer ihn auf 06:00 stellt, steht um 06:00 auf
und will ihn dann auch haben.
Was in der Ruhezeit anfällt, wird **nicht nachgeholt**. Eine Erinnerung an
einen Zeitblock, der längst begonnen hat, hilft niemandem.
## Ausprobieren
Der Knopf **Probe senden** schickt eine Testmeldung über alle
eingeschalteten Wege — er umgeht Ruhezeit und Wiederholungssperre. Wer
darauf drückt, will jetzt wissen, ob es ankommt.
Die Rückmeldung sagt, was passiert ist: an wie viele Geräte der Push ging,
ob die Mail hinausging. Kommt „kein Gerät für Push angemeldet", fehlt die
Anmeldung dieses Browsers.
## Wenn nichts ankommt
| Symptom | Ursache |
| ----------------------------------------- | ----------------------------------------------------------------- |
| Probe sagt „verschickt", nichts kommt an | Betriebssystem blendet Browsermeldungen aus (Fokus, Nicht stören) |
| Gerät verschwindet immer wieder | Der Push-Dienst lehnt das Abo ab — neu anmelden |
| Gar nichts, seit der Server umgezogen ist | Neues Schlüsselpaar auf dem Server. Alle Geräte neu anmelden |
| Mail fehlt, Push kommt | SMTP prüfen; Fehler stehen im Serverprotokoll |
Der Planer prüft alle zehn Minuten, ob etwas zu melden ist. Eine Erinnerung
kann also ein paar Minuten vor der eingestellten Zeit kommen — nie danach.
---
Weiter zur [Wochenplanung](woche.md) oder zurück zum
[Handbuch](README.md).
+151
View File
@@ -0,0 +1,151 @@
# Integrationen
Was von außen hereinkommt — und was diese Instanz hinausgibt. Du findest den
Überblick im Menü unter **Integrationen**.
![Die Seite Integrationen](../bilder/integrationen-hell-schreibtisch.png)
Welche Integration es überhaupt gibt, hängt an der Instanz und ist nicht
verhandelbar:
| | Firma | Daheim |
| -------------- | ------ | -------- |
| Zammad | ja | nein |
| Wartungsplaner | ja | nein |
| Telegram | nein | ja |
| Spiegel | sendet | empfängt |
Eine Integration, die deine Instanz nicht vorsieht, lässt sich auch mit
vollständiger Konfiguration nicht anschalten. Das steckt in der Anwendung,
nicht in einer Einstellung, die sich versehentlich verstellen ließe.
## Was jede Integration mit deinen Aufgaben darf
Für alle gilt dieselbe Regel — und die ist wichtiger als jedes Detail
darunter:
| Führt das Ursprungssystem | Führst du |
| --------------------------------- | ------------------------------------------------ |
| Titel, Notiz, Verweis, Fälligkeit | Bereich, Projekt, Priorität, geschätzter Aufwand |
Wer ein Ticket in einen Bereich einsortiert und den Aufwand schätzt, muss
sich darauf verlassen können, dass der nächste Abgleich das nicht wegräumt.
Umgekehrt: Ändert sich drüben der Titel, zieht er hier mit.
Dieselbe Sache ergibt nie eine zweite Aufgabe. Der Planer merkt sich zu jeder
Aufgabe, woher sie kam und unter welcher Kennung.
## Zammad
Tickets, auf die der Suchausdruck passt, werden zu Aufgaben. Voreingestellt
sind die dir zugewiesenen offenen Tickets; der Ausdruck steht in der
Konfiguration (`ZAMMAD_ABFRAGE`) und versteht Zammads Suchsyntax.
- Der Titel wird `#Nummer Betreff`, der Verweis führt direkt ins Ticket.
- Ein Ticket, das Zammad als geschlossen meldet, hakt der Planer ab.
- **Geschrieben wird nach Zammad nichts.** Ein Ticket schließt man im
Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
Der Token gehört einem Benutzer und sieht genau das, was dieser Benutzer
sehen darf. Der Planer umgeht keine Rechte.
## Wartungsplaner
Noch nicht angebunden. Die Aufnahmestelle steht bereit — es fehlt allein das
Abholen, und dafür muss die Schnittstelle feststehen. Auf der Seite
**Integrationen** ist der Punkt als offene Lücke sichtbar, damit er nicht in
Vergessenheit gerät.
## Telegram
Drei Dinge in einem Kanal.
### Chat verbinden
1. Unter **Integrationen** auf **Chat verbinden**.
2. Der achtstellige Code erscheint. Er gilt **zehn Minuten** und genau
**einmal**.
3. Schick ihn dem Bot — als Nachricht oder als `/start ABCD2345`.
4. Der Bot bestätigt, und der Chat erscheint in der Liste.
> **Warum der Umweg?** Wer nicht in der Liste steht, bekommt vom Bot keine
> Antwort — auch keine Fehlermeldung. Sonst könnte jeder, der die Kennung des
> Bots kennt, herausfinden, dass es dich gibt. Der Code ist der Beweis, dass
> jemand mit Zugang zur Anwendung diesen Chat wirklich will.
### Erfassen
Jede Nachricht an den Bot wird eine Aufgabe. Lange Texte werden gekürzt; der
Rest landet in der Notiz und geht nicht verloren. Die Aufgabe hat keinen
Bereich — sie liegt also in der **Inbox** und will einsortiert werden.
### Melden und abhaken
Ist Telegram in den [Benachrichtigungen](benachrichtigungen.md) als Weg
eingeschaltet, kommen Meldungen auch hier an. Geht es um genau eine Aufgabe,
hängt darunter ein Knopf **✓ Erledigt** — ein Druck, und sie ist abgehakt,
ohne die Anwendung zu öffnen.
Mehr kann der Knopf nicht. Löschen oder Verschieben aus einem Chat heraus
wäre zu leicht versehentlich getroffen.
Je Chat lässt sich einzeln einstellen, ob er Meldungen bekommt: Das Glöckchen
in der Liste schaltet um.
## Der Spiegel
Die Firma schickt nach Hause, nicht umgekehrt. Zwei Dinge gehen hinüber.
**Belegungen.** Wann dienstlich Zeit verplant ist — ohne Titel, ohne Aufgabe,
ohne Kunde. Daheim erscheinen sie in der Wochenansicht als gestreifte Balken
mit der Aufschrift „Dienstlich belegt" und zählen gegen die Tageskapazität.
So kollidiert die private Planung nicht mit der dienstlichen, und trotzdem
verlässt kein Firmeninhalt die Firma.
**Markierte Aufgaben.** Nur was du an der Aufgabe ausdrücklich zum Spiegeln
freigibst, geht mit Titel hinüber. Das ist eine Einzelentscheidung je
Aufgabe, kein Schalter für alles: Wer versehentlich einen ganzen Bereich
spiegelt, schickt Kundennamen nach Hause.
Was der Sender nicht mehr schickt, verschwindet auch drüben. Wird ein
Zeitblock in der Firma ausgeplant, ist die Sperre daheim beim nächsten Lauf
weg — sonst stünde dort für immer eine Blockade, die es längst nicht mehr
gibt.
## Wann läuft was
| Integration | Takt |
| ----------- | --------------- |
| Telegram | alle 2 Minuten |
| Zammad | alle 15 Minuten |
| Spiegel | alle 15 Minuten |
Wer dem Bot etwas schickt, erwartet die Aufgabe gleich in der Liste. In
Zammad ändert sich dagegen nichts im Minutentakt.
Warten muss man trotzdem nicht: Auf der Seite **Integrationen** stößt
**Jetzt abrufen** einen Lauf von Hand an. Das lohnt vor allem direkt nach dem
Einrichten — sonst wüsstest du erst beim nächsten Takt, ob die Zugangsdaten
stimmen.
## Wenn etwas klemmt
Unter jeder Integration steht, wann sie zuletzt geglückt ist und woran sie
zuletzt gescheitert ist. Nach dem dritten Fehlschlag hintereinander gilt sie
als **gestört** — und dann meldet sich der Planer auch, sofern du den Anlass
„Ein Kalender lässt sich nicht abgleichen" eingeschaltet hast.
Erst nach dem dritten Mal: Ein Netz hustet, ein Anbieter hat Wartung. Beim
ersten Fehlschlag zu melden hieße, dich für Dinge zu wecken, die sich von
selbst erledigen.
| Symptom | Ursache |
| ---------------------------------------- | --------------------------------------------------------------------------------- |
| „Nicht eingerichtet" trotz Konfiguration | Die Instanz sieht diese Integration nicht vor |
| Zammad findet nichts | Der Suchausdruck passt auf kein Ticket — in Zammad selbst probieren |
| Der Bot antwortet nicht | Der Chat ist nicht verbunden, oder der Code war abgelaufen |
| Der Spiegel wird abgelehnt | Verschiedene Geheimnisse, oder die Uhren laufen mehr als fünf Minuten auseinander |
---
Zurück zum [Handbuch](README.md).
+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.
+4 -14
View File
@@ -1,6 +1,7 @@
import { PrismaPg } from '@prisma/adapter-pg' import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type Page } from '@playwright/test' import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js' import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
/** /**
* Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken. * Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken.
@@ -9,16 +10,13 @@ import { PrismaClient } from '../src/generated/prisma/client.js'
* damit die Entwicklungsdatenbank hinterher aussieht wie vorher. * 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 PRAEFIX = `E2E-${Date.now()}`
const verbindung = process.env.DATABASE_URL ?? '' const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
test.describe('Aufgaben', () => { test.describe('Aufgaben', () => {
test.skip( test.skip(
!EMAIL || !PASSWORT || !verbindung, !EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.', 'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
) )
@@ -29,14 +27,6 @@ test.describe('Aufgaben', () => {
await db.$disconnect() 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 }) => { test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => {
const titel = `${PRAEFIX} Angebot schreiben` const titel = `${PRAEFIX} Angebot schreiben`
await anmelden(page) await anmelden(page)
+91
View File
@@ -0,0 +1,91 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
/**
* Was in M5 wirklich zählt: die Einstellung hält, und ohne eingeschalteten
* Weg geht nichts hinaus.
*
* Der Test rührt nur an der Einstellung des Testkontos und stellt sie am
* Ende wieder her.
*/
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
test.describe('Benachrichtigungen', () => {
test.skip(
!EMAIL || !PASSWORT || !VERBINDUNG,
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
)
test.afterAll(async () => {
const benutzer = await db.benutzer.findUnique({
where: { email: EMAIL },
select: { id: true },
})
if (benutzer) {
await db.benachrichtigungseinstellung.deleteMany({
where: { benutzerId: benutzer.id },
})
await db.meldungsnachweis.deleteMany({ where: { benutzerId: benutzer.id } })
}
await db.auditEintrag.deleteMany({
where: { aktion: { startsWith: 'benachrichtigung.' } },
})
await db.$disconnect()
})
test('speichert die Einstellung und hält sie', async ({ page }) => {
await anmelden(page)
await page.goto('/einstellungen')
const karte = page.locator('[data-bild="benachrichtigungen"]')
await expect(karte).toBeVisible()
// --- Ab Werk ist nichts eingeschaltet ---------------------------------
await expect(karte.locator('input[name="perPush"]')).not.toBeChecked()
await expect(karte.locator('input[name="perMail"]')).not.toBeChecked()
// --- Ohne Weg keine Probe ---------------------------------------------
await karte.getByRole('button', { name: 'Probe schicken' }).click()
await expect(page.getByText('Erst einen Weg einschalten')).toBeVisible()
// --- Ändern und speichern ---------------------------------------------
await karte.locator('input[name="perMail"]').check()
await karte.locator('input[value="zeitblock"]').check()
await karte.getByLabel('Tagesüberblick um').fill('06:30')
await karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)').fill('20')
await karte.getByLabel('Am Wochenende gar nichts melden').check()
await karte.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Einstellungen gespeichert.')).toBeVisible()
// --- Nach dem Neuladen steht alles noch da ----------------------------
await page.reload()
await expect(karte.locator('input[name="perMail"]')).toBeChecked()
await expect(karte.locator('input[value="zeitblock"]')).toBeChecked()
await expect(karte.getByLabel('Tagesüberblick um')).toHaveValue('06:30')
await expect(karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)')).toHaveValue(
'20',
)
await expect(karte.getByLabel('Am Wochenende gar nichts melden')).toBeChecked()
})
test('weist eine unsinnige Vorlaufzeit zurück', async ({ page }) => {
await anmelden(page)
await page.goto('/einstellungen')
const karte = page.locator('[data-bild="benachrichtigungen"]')
const vorlauf = karte.getByLabel('Vorlauf vor einem Zeitblock (Minuten)')
// Das Zahlenfeld begrenzt auf 120; der Server darf sich darauf nicht
// verlassen, deshalb kommt der Wert an der Oberfläche vorbei.
await vorlauf.evaluate((feld: HTMLInputElement) => {
feld.max = '600'
feld.value = '300'
})
await karte.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Bitte die markierten Felder prüfen.')).toBeVisible()
await expect(karte.getByText('Mehr als zwei Stunden Vorlauf')).toBeVisible()
})
})
+45
View File
@@ -0,0 +1,45 @@
import { expect, type Page } from '@playwright/test'
/** Gemeinsames für alle E2E-Tests. */
export const EMAIL = process.env.ADMIN_EMAIL ?? ''
export const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
export const VERBINDUNG = process.env.DATABASE_URL ?? ''
/**
* Geheimnis fuer den Spiegel im Test.
*
* Der Testserver bekommt denselben Wert ueber playwright.config.ts, sonst
* liesse sich die Tuer gar nicht pruefen — und eine Pruefung, die immer
* uebersprungen wird, prueft nichts.
*/
export const SPIEGEL_GEHEIMNIS =
process.env.SPIEGEL_GEHEIMNIS ?? 'e2e-spiegel-geheimnis-nur-lokal'
/**
* Meldet das Testkonto mit Passwort an.
*
* Der Knopf gehört zu einem Client-Formular. Ein Klick, der vor der
* Hydratation ankommt, verpufft folgenlos — die Seite bleibt einfach stehen.
* Deshalb wird so lange nachgefasst, bis die Startseite da ist; hat der erste
* Klick schon gewirkt, wird nicht noch einmal geklickt.
*/
export async function anmelden(
seite: Page,
email: string = EMAIL,
passwort: string = PASSWORT,
): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(email)
await seite.getByLabel('Passwort').fill(passwort)
const knopf = seite.getByRole('button', { name: 'Mit Passwort anmelden' })
const begruessung = seite.getByRole('heading', { name: /Guten Tag/ })
await expect(async () => {
if (new URL(seite.url()).pathname === '/anmelden') {
await knopf.click({ timeout: 5_000 })
}
await expect(begruessung).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 45_000 })
}
+5 -12
View File
@@ -1,7 +1,8 @@
import { PrismaPg } from '@prisma/adapter-pg' import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test, type Page } from '@playwright/test' import { expect, test } from '@playwright/test'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { PrismaClient } from '../src/generated/prisma/client.js' import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden as anmeldenMit } from './hilfe'
/** /**
* Prüft, dass die Rollentrennung serverseitig hält. * Prüft, dass die Rollentrennung serverseitig hält.
@@ -38,23 +39,15 @@ test.describe('Rollentrennung', () => {
await db.$disconnect() 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 }) => { test('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => {
await anmelden(page) await anmeldenMit(page, EMAIL, PASSWORT)
await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0) await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
}) })
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({ test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
page, page,
}) => { }) => {
await anmelden(page) await anmeldenMit(page, EMAIL, PASSWORT)
await page.goto('/verwaltung/protokoll') await page.goto('/verwaltung/protokoll')
await expect(page).toHaveURL(/\/keine-berechtigung/) await expect(page).toHaveURL(/\/keine-berechtigung/)
@@ -64,7 +57,7 @@ test.describe('Rollentrennung', () => {
}) })
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => { test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
await anmelden(page) await anmeldenMit(page, EMAIL, PASSWORT)
await page.goto('/bereiche') await page.goto('/bereiche')
await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible()
+138
View File
@@ -0,0 +1,138 @@
import { createHmac } from 'node:crypto'
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { SPIEGEL_GEHEIMNIS, VERBINDUNG } from './hilfe'
/**
* Der Eingang des Spiegels.
*
* Hier meldet sich kein Mensch an — die Gegenstelle weist sich allein über
* eine Signatur aus. Deshalb wird hier nicht die Oberfläche geprüft, sondern
* genau diese Tür: Sie muss zu sein, wenn irgendetwas nicht stimmt.
*/
const GEHEIMNIS = SPIEGEL_GEHEIMNIS
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
const PRAEFIX = `E2E-S-${Date.now()}`
function signieren(koerper: string, geheimnis = GEHEIMNIS): string {
return createHmac('sha256', geheimnis).update(koerper).digest('hex')
}
function sendung(teile: Record<string, unknown> = {}): string {
return JSON.stringify({
gesendetAm: new Date().toISOString(),
belegungen: [],
aufgaben: [],
...teile,
})
}
test.describe('Spiegel', () => {
test.afterAll(async () => {
await db.spiegelbelegung.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
await db.auditEintrag.deleteMany({ where: { aktion: 'spiegel.empfangen' } })
await db.$disconnect()
})
test('weist eine Sendung ohne Signatur ab', async ({ request }) => {
const antwort = await request.post('/api/spiegel', {
headers: { 'Content-Type': 'application/json' },
data: sendung(),
})
expect(antwort.status()).toBe(401)
})
test('weist eine falsch signierte Sendung ab', async ({ request }) => {
const koerper = sendung()
const antwort = await request.post('/api/spiegel', {
headers: {
'Content-Type': 'application/json',
'X-Planer-Signatur': signieren(koerper, 'das-ist-nicht-das-geheimnis'),
},
data: koerper,
})
expect(antwort.status()).toBe(401)
})
test('weist eine Sendung ab, die zu lange unterwegs war', async ({ request }) => {
test.skip(!GEHEIMNIS, 'Ohne SPIEGEL_GEHEIMNIS ist der Eingang ohnehin zu.')
// Eine Stunde alt: richtig signiert, trotzdem wertlos. Sonst ließe sich
// ein abgefangenes Paket beliebig oft erneut einspielen.
const koerper = sendung({
gesendetAm: new Date(Date.now() - 3_600_000).toISOString(),
})
const antwort = await request.post('/api/spiegel', {
headers: {
'Content-Type': 'application/json',
'X-Planer-Signatur': signieren(koerper),
},
data: koerper,
})
expect(antwort.status()).toBe(401)
})
test('nimmt eine gültige Sendung an und räumt Veraltetes weg', async ({ request }) => {
test.skip(
!GEHEIMNIS,
'Ohne SPIEGEL_GEHEIMNIS auf dem Testserver lässt sich das nicht prüfen.',
)
const beginn = new Date(Date.now() + 86_400_000)
const ende = new Date(beginn.getTime() + 3_600_000)
const erste = sendung({
belegungen: [
{
ref: `${PRAEFIX}-a`,
beginn: beginn.toISOString(),
ende: ende.toISOString(),
},
],
aufgaben: [{ ref: `${PRAEFIX}-1`, titel: `${PRAEFIX} Angebot schreiben` }],
})
const antwort = await request.post('/api/spiegel', {
headers: {
'Content-Type': 'application/json',
'X-Planer-Signatur': signieren(erste),
},
data: erste,
})
expect(antwort.ok()).toBe(true)
expect(await db.spiegelbelegung.count({ where: { quelleRef: `${PRAEFIX}-a` } })).toBe(
1,
)
const aufgabe = await db.aufgabe.findFirst({
where: { quelle: 'SPIEGEL', quelleRef: `${PRAEFIX}-1` },
select: { titel: true },
})
expect(aufgabe?.titel).toBe(`${PRAEFIX} Angebot schreiben`)
// Zweite Runde ohne die Belegung: Sie wurde drüben ausgeplant und muss
// auch hier verschwinden — sonst stünde für immer eine Sperre, die es
// längst nicht mehr gibt.
const zweite = sendung({ aufgaben: [] })
const nachher = await request.post('/api/spiegel', {
headers: {
'Content-Type': 'application/json',
'X-Planer-Signatur': signieren(zweite),
},
data: zweite,
})
expect(nachher.ok()).toBe(true)
expect(await db.spiegelbelegung.count({ where: { quelleRef: `${PRAEFIX}-a` } })).toBe(
0,
)
// Die Aufgabe bleibt: Sie ist Planung, keine Sperre.
expect(
await db.aufgabe.count({ where: { quelle: 'SPIEGEL', quelleRef: `${PRAEFIX}-1` } }),
).toBe(1)
})
})
+116
View File
@@ -0,0 +1,116 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { expect, test } from '@playwright/test'
import { PrismaClient } from '../src/generated/prisma/client.js'
import { anmelden, EMAIL, PASSWORT, VERBINDUNG } from './hilfe'
/**
* 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 PRAEFIX = `E2E-W-${Date.now()}`
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()
})
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$/)
})
})
+2
View File
@@ -16,6 +16,8 @@ export default tseslint.config(
// Vom Generator erzeugt, nicht von Hand gepflegt. // Vom Generator erzeugt, nicht von Hand gepflegt.
'src/generated/**', 'src/generated/**',
'next-env.d.ts', 'next-env.d.ts',
// Service Worker: laeuft im Browser, nicht in unserer Werkzeugkette.
'public/sw.js',
], ],
}, },
...naechstesWebVitals, ...naechstesWebVitals,
+191 -6
View File
@@ -1,15 +1,17 @@
{ {
"name": "planer", "name": "planer",
"version": "0.3.0", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "planer", "name": "planer",
"version": "0.3.0", "version": "0.6.0",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "2.11.3", "@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0", "@date-fns/tz": "1.5.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@prisma/adapter-pg": "7.10.0", "@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@radix-ui/react-avatar": "1.2.6", "@radix-ui/react-avatar": "1.2.6",
@@ -36,6 +38,7 @@
"server-only": "0.0.1", "server-only": "0.0.1",
"sonner": "2.0.8", "sonner": "2.0.8",
"tailwind-merge": "3.6.0", "tailwind-merge": "3.6.0",
"web-push": "3.6.7",
"zod": "4.6.1" "zod": "4.6.1"
}, },
"devDependencies": { "devDependencies": {
@@ -47,6 +50,7 @@
"@types/pg": "8.23.1", "@types/pg": "8.23.1",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@types/web-push": "3.6.4",
"@vitejs/plugin-react": "6.1.1", "@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "5.0.0", "@vitest/coverage-v8": "5.0.0",
"dotenv": "17.4.2", "dotenv": "17.4.2",
@@ -593,6 +597,45 @@
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@electric-sql/pglite": { "node_modules/@electric-sql/pglite": {
"version": "0.4.3", "version": "0.4.3",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
@@ -4504,6 +4547,16 @@
"@types/react": "^19.3.0" "@types/react": "^19.3.0"
} }
}, },
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.70.0", "version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
@@ -5405,6 +5458,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -5637,6 +5699,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": { "node_modules/asn1js": {
"version": "3.0.10", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -5791,6 +5865,12 @@
"require-from-string": "^2.0.2" "require-from-string": "^2.0.2"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.9", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
@@ -5851,6 +5931,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/c12": { "node_modules/c12": {
"version": "3.3.4", "version": "3.3.4",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
@@ -6372,7 +6458,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -6556,6 +6641,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/effect": { "node_modules/effect": {
"version": "3.20.0", "version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
@@ -8044,6 +8138,28 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
} }
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -8098,6 +8214,12 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -8763,6 +8885,27 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9239,6 +9382,12 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -9259,7 +9408,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -9269,7 +9417,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": { "node_modules/mysql2": {
@@ -10690,6 +10837,26 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -10752,7 +10919,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/saxes": { "node_modules/saxes": {
@@ -12357,6 +12523,25 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "planer", "name": "planer",
"version": "0.3.0", "version": "0.6.0",
"private": true, "private": true,
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender", "description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
"type": "module", "type": "module",
@@ -40,6 +40,8 @@
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "2.11.3", "@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0", "@date-fns/tz": "1.5.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@prisma/adapter-pg": "7.10.0", "@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@radix-ui/react-avatar": "1.2.6", "@radix-ui/react-avatar": "1.2.6",
@@ -66,6 +68,7 @@
"server-only": "0.0.1", "server-only": "0.0.1",
"sonner": "2.0.8", "sonner": "2.0.8",
"tailwind-merge": "3.6.0", "tailwind-merge": "3.6.0",
"web-push": "3.6.7",
"zod": "4.6.1" "zod": "4.6.1"
}, },
"devDependencies": { "devDependencies": {
@@ -77,6 +80,7 @@
"@types/pg": "8.23.1", "@types/pg": "8.23.1",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@types/web-push": "3.6.4",
"@vitejs/plugin-react": "6.1.1", "@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "5.0.0", "@vitest/coverage-v8": "5.0.0",
"dotenv": "17.4.2", "dotenv": "17.4.2",
+3
View File
@@ -1,5 +1,6 @@
import { defineConfig, devices } from '@playwright/test' import { defineConfig, devices } from '@playwright/test'
import 'dotenv/config' import 'dotenv/config'
import { SPIEGEL_GEHEIMNIS } from './e2e/hilfe'
const PORT = Number(process.env.E2E_PORT ?? 3100) const PORT = Number(process.env.E2E_PORT ?? 3100)
const BASIS = `http://127.0.0.1:${PORT}` const BASIS = `http://127.0.0.1:${PORT}`
@@ -41,6 +42,8 @@ export default defineConfig({
APP_URL: BASIS, APP_URL: BASIS,
WEBAUTHN_ORIGIN: BASIS, WEBAUTHN_ORIGIN: BASIS,
WEBAUTHN_RP_ID: '127.0.0.1', WEBAUTHN_RP_ID: '127.0.0.1',
// Ohne Geheimnis waere der Spiegel-Eingang zu und sein Test sinnlos.
SPIEGEL_GEHEIMNIS,
}, },
}, },
}) })
+102
View File
@@ -373,6 +373,37 @@ async function main(): Promise<void> {
}, },
}) })
// --- Zeitblöcke: eingeplante Aufgaben im Kalender -------------------------
await db.zeitblock.deleteMany({})
const einzuplanen = await db.aufgabe.findMany({
where: {
titel: {
in: [
'Wochenbericht an die Geschäftsführung',
'Angebot Heizungstausch nachfassen',
],
},
},
select: { id: true, titel: true, aufwandMinuten: true },
})
let blockStunde = 15
for (const aufgabe of einzuplanen) {
const beginn = new Date(Date.UTC(2026, 2, 17, blockStunde - 1, 0))
await db.zeitblock.create({
data: {
aufgabeId: aufgabe.id,
kalenderkontoId: kalenderkonto.id,
beginn,
ende: new Date(beginn.getTime() + (aufgabe.aufwandMinuten ?? 60) * 60_000),
extId: `demo-block-${aufgabe.id}`,
hash: 'demo',
},
})
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
blockStunde += 2
}
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste. // Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
await db.aufgabe.create({ await db.aufgabe.create({
data: { data: {
@@ -387,6 +418,77 @@ async function main(): Promise<void> {
}, },
}) })
// --- Benachrichtigungen ---------------------------------------------------
// Für die Bilder eingeschaltet. Eine leere Karte zeigt nichts von dem,
// was das Kapitel erklärt.
await db.benachrichtigungseinstellung.upsert({
where: { benutzerId: benutzer.id },
update: {},
create: {
benutzerId: benutzer.id,
anlaesse: 'tagesueberblick,faelligkeit,zeitblock',
perPush: true,
perMail: true,
perTelegram: true,
ueberblickUm: 7 * 60,
ruheVon: 20 * 60,
ruheBis: 7 * 60,
ruheAmWochenende: false,
zeitblockVorlaufMinuten: 10,
},
})
// Kein Push-Gerät in den Demo-Daten: Seine Schlüssel liegen
// verschlüsselt, und der Versand entfernt jedes Gerät, dessen Schlüssel er
// nicht lesen kann. Ein erfundener Eintrag verschwände beim ersten Lauf
// wieder — die Bilder zeigen deshalb den Zustand vor der ersten Anmeldung.
// --- Integrationen --------------------------------------------------------
await db.integrationszustand.deleteMany({})
await db.integrationszustand.create({
data: {
quelle: 'TELEGRAM',
merker: '4711',
letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 18)),
letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 18)),
},
})
await db.integrationszustand.create({
data: {
quelle: 'SPIEGEL',
letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 15)),
letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 15)),
},
})
await db.telegramChat.deleteMany({})
await db.telegramChat.create({
data: {
// Erfunden. Der Bot ist ohnehin abgeschaltet, solange DEMO_MODUS gilt.
chatId: BigInt(147258369),
benutzerId: benutzer.id,
bezeichnung: 'Martina',
erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 25)),
zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 6, 40)),
},
})
// Dienstliche Belegung, wie sie aus der Firma hereinkaeme: ohne Titel.
await db.spiegelbelegung.deleteMany({})
for (const [tag, stunde, dauer] of [
[17, 9, 2],
[18, 13, 3],
[19, 8, 1],
] as const) {
await db.spiegelbelegung.create({
data: {
quelleRef: `demo-spiegel-${tag}-${stunde}`,
beginn: new Date(Date.UTC(2026, 2, tag, stunde - 1, 0)),
ende: new Date(Date.UTC(2026, 2, tag, stunde - 1 + dauer, 0)),
},
})
}
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`) console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
} }
@@ -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;
@@ -0,0 +1,66 @@
-- CreateTable
CREATE TABLE "benachrichtigungseinstellung" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"anlaesse" TEXT NOT NULL DEFAULT 'tagesueberblick,faelligkeit',
"perPush" BOOLEAN NOT NULL DEFAULT false,
"perMail" BOOLEAN NOT NULL DEFAULT false,
"ueberblickUm" INTEGER NOT NULL DEFAULT 420,
"ruheVon" INTEGER NOT NULL DEFAULT 1200,
"ruheBis" INTEGER NOT NULL DEFAULT 420,
"ruheAmWochenende" BOOLEAN NOT NULL DEFAULT false,
"zeitblockVorlaufMinuten" INTEGER NOT NULL DEFAULT 10,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "benachrichtigungseinstellung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "push_geraet" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"endpunkt" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"bezeichnung" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztBenutzt" TIMESTAMP(3),
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "push_geraet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "meldungsnachweis" (
"id" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"schluessel" TEXT NOT NULL,
"gesendetAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "meldungsnachweis_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "benachrichtigungseinstellung_benutzerId_key" ON "benachrichtigungseinstellung"("benutzerId");
-- CreateIndex
CREATE UNIQUE INDEX "push_geraet_endpunkt_key" ON "push_geraet"("endpunkt");
-- CreateIndex
CREATE INDEX "push_geraet_benutzerId_idx" ON "push_geraet"("benutzerId");
-- CreateIndex
CREATE INDEX "meldungsnachweis_gesendetAm_idx" ON "meldungsnachweis"("gesendetAm");
-- CreateIndex
CREATE UNIQUE INDEX "meldungsnachweis_benutzerId_schluessel_key" ON "meldungsnachweis"("benutzerId", "schluessel");
-- AddForeignKey
ALTER TABLE "benachrichtigungseinstellung" ADD CONSTRAINT "benachrichtigungseinstellung_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "push_geraet" ADD CONSTRAINT "push_geraet_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "meldungsnachweis" ADD CONSTRAINT "meldungsnachweis_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "kalenderkonto" ADD COLUMN "fehlversuche" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,62 @@
-- AlterTable
ALTER TABLE "aufgabe" ADD COLUMN "spiegeln" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "benachrichtigungseinstellung" ADD COLUMN "perTelegram" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "integrationszustand" (
"id" TEXT NOT NULL,
"quelle" "Quelle" NOT NULL,
"merker" TEXT,
"letzterLauf" TIMESTAMP(3),
"letzterErfolg" TIMESTAMP(3),
"letzterFehler" TEXT,
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "integrationszustand_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "spiegelbelegung" (
"id" TEXT NOT NULL,
"quelleRef" TEXT NOT NULL,
"beginn" TIMESTAMP(3) NOT NULL,
"ende" TIMESTAMP(3) NOT NULL,
"empfangenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "spiegelbelegung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "telegram_chat" (
"id" TEXT NOT NULL,
"chatId" BIGINT NOT NULL,
"bezeichnung" TEXT,
"benutzerId" TEXT,
"meldungen" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztBenutzt" TIMESTAMP(3),
CONSTRAINT "telegram_chat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "integrationszustand_quelle_key" ON "integrationszustand"("quelle");
-- CreateIndex
CREATE UNIQUE INDEX "spiegelbelegung_quelleRef_key" ON "spiegelbelegung"("quelleRef");
-- CreateIndex
CREATE INDEX "spiegelbelegung_beginn_idx" ON "spiegelbelegung"("beginn");
-- CreateIndex
CREATE UNIQUE INDEX "telegram_chat_chatId_key" ON "telegram_chat"("chatId");
-- CreateIndex
CREATE INDEX "telegram_chat_benutzerId_idx" ON "telegram_chat"("benutzerId");
-- AddForeignKey
ALTER TABLE "telegram_chat" ADD CONSTRAINT "telegram_chat_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "telegram_kopplung" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"gueltigBis" TIMESTAMP(3) NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "telegram_kopplung_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "telegram_kopplung_code_key" ON "telegram_kopplung"("code");
-- CreateIndex
CREATE INDEX "telegram_kopplung_gueltigBis_idx" ON "telegram_kopplung"("gueltigBis");
+198
View File
@@ -35,6 +35,10 @@ model Benutzer {
passkeys Passkey[] passkeys Passkey[]
audit AuditEintrag[] audit AuditEintrag[]
kalenderkonten Kalenderkonto[] kalenderkonten Kalenderkonto[]
benachrichtigung Benachrichtigungseinstellung?
pushGeraete PushGeraet[]
meldungen Meldungsnachweis[]
telegramChats TelegramChat[]
@@index([aktiv]) @@index([aktiv])
@@map("benutzer") @@map("benutzer")
@@ -219,11 +223,16 @@ model Aufgabe {
/// Kennung beim Ursprungssystem, z. B. die Ticketnummer. /// Kennung beim Ursprungssystem, z. B. die Ticketnummer.
quelleRef String? quelleRef String?
/// Ausdruecklich zum Spiegeln freigegeben. Nur was hier true ist, verlaesst
/// die Instanz mit Titel – alles andere bleibt, wo es ist.
spiegeln Boolean @default(false)
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt aktualisiertAm DateTime @updatedAt
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull) bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull) projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
zeitbloecke Zeitblock[]
/// Integrationen upserten immer ueber diese beiden Felder. Von Hand /// Integrationen upserten immer ueber diese beiden Felder. Von Hand
/// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte /// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte
@@ -299,6 +308,9 @@ model Kalenderkonto {
letzterAbgleich DateTime? letzterAbgleich DateTime?
/// Klartextmeldung des letzten Fehlschlags, ohne Tokens. /// Klartextmeldung des letzten Fehlschlags, ohne Tokens.
letzterFehler String? letzterFehler String?
/// Wie oft der Abgleich hintereinander scheiterte. Ein Aussetzer ist keine
/// Nachricht wert; gemeldet wird erst ab mehreren.
fehlversuche Int @default(0)
aktiv Boolean @default(true) aktiv Boolean @default(true)
@@ -308,6 +320,7 @@ model Kalenderkonto {
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
termine Termin[] termine Termin[]
standardFuerBereiche Bereich[] @relation("StandardKalenderkonto") standardFuerBereiche Bereich[] @relation("StandardKalenderkonto")
zeitblocks Zeitblock[]
/// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer. /// Dasselbe Konto und derselbe Kalender nur einmal je Benutzer.
@@unique([benutzerId, anbieter, extKontoId, kalenderId]) @@unique([benutzerId, anbieter, extKontoId, kalenderId])
@@ -340,3 +353,188 @@ model Termin {
@@index([beginn, ende]) @@index([beginn, ende])
@@map("termin") @@map("termin")
} }
/// Eine Aufgabe, die zu einer bestimmten Zeit in einem Kalender steht.
///
/// Der Zeitblock lebt an zwei Orten: hier und im Kalender des Anbieters.
/// Beide Seiten duerfen ihn verschieben; der Fingerabdruck "hash" verhindert,
/// dass sich beide gegenseitig hochschaukeln. Siehe src/lib/zeitblock.ts.
model Zeitblock {
id String @id @default(cuid())
aufgabeId String
kalenderkontoId String
beginn DateTime
ende DateTime
/// Kennung des Termins beim Anbieter; null, solange noch nicht geschrieben.
extId String?
/// Fuer bedingte Schreibzugriffe (If-Match).
etag String?
/// sha256(startISO|endeISO) dessen, was zuletzt geschrieben wurde.
hash String?
/// Klartextmeldung des letzten Fehlschlags beim Schreiben, ohne Tokens.
letzterFehler String?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
aufgabe Aufgabe @relation(fields: [aufgabeId], references: [id], onDelete: Cascade)
kalenderkonto Kalenderkonto @relation(fields: [kalenderkontoId], references: [id], onDelete: Cascade)
/// Ein Termin im Kalender gehoert zu hoechstens einem Zeitblock.
@@unique([kalenderkontoId, extId])
@@index([beginn, ende])
@@index([aufgabeId])
@@map("zeitblock")
}
/// Wie und worueber ein Benutzer benachrichtigt werden will.
model Benachrichtigungseinstellung {
id String @id @default(cuid())
benutzerId String @unique
/// Kommagetrennte Anlaesse, siehe src/lib/benachrichtigung.ts.
anlaesse String @default("tagesueberblick,faelligkeit")
perPush Boolean @default(false)
perMail Boolean @default(false)
perTelegram Boolean @default(false)
/// Alles als Minuten seit Mitternacht, Europe/Berlin.
ueberblickUm Int @default(420)
ruheVon Int @default(1200)
ruheBis Int @default(420)
ruheAmWochenende Boolean @default(false)
zeitblockVorlaufMinuten Int @default(10)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@map("benachrichtigungseinstellung")
}
/// Ein Browser, der Push-Nachrichten empfangen darf.
model PushGeraet {
id String @id @default(cuid())
benutzerId String
/// Vom Browser vergebene Adresse; eindeutig je Geraet.
endpunkt String @unique
/// Schluessel aus der Push-Anmeldung, verschluesselt abgelegt.
p256dh String
auth String
bezeichnung String?
erstelltAm DateTime @default(now())
zuletztBenutzt DateTime?
/// Wie oft der Versand hintereinander scheiterte; ab einer Grenze weg.
fehlversuche Int @default(0)
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@index([benutzerId])
@@map("push_geraet")
}
/// Nachweis, dass eine Meldung schon hinausging – verhindert Wiederholungen.
model Meldungsnachweis {
id String @id @default(cuid())
benutzerId String
/// anlass:sache:tag, siehe meldungsSchluessel().
schluessel String
gesendetAm DateTime @default(now())
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@unique([benutzerId, schluessel])
@@index([gesendetAm])
@@map("meldungsnachweis")
}
/// Zustand einer Integration: wie weit sie gelesen hat und ob es klappt.
///
/// Eine Zeile je Quelle. Der Merker ist bewusst ein freier Text – jede
/// Integration legt darin ab, was sie zum Fortsetzen braucht: Zammad einen
/// Zeitstempel, Telegram den Zaehler der zuletzt geholten Nachricht.
model Integrationszustand {
id String @id @default(cuid())
quelle Quelle @unique
/// Woher es weitergeht. Bedeutung haengt an der Quelle.
merker String?
letzterLauf DateTime?
letzterErfolg DateTime?
letzterFehler String?
/// Fehlschlaege in Folge; bei Erfolg wieder null.
fehlversuche Int @default(0)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
@@map("integrationszustand")
}
/// Dienstliche Belegung, die aus der anderen Instanz kommt.
///
/// Bewusst ohne Titel: Daheim soll sehen, *dass* die Zeit weg ist, nicht
/// woran gearbeitet wird. Firmeninhalte verlassen die Firma nicht.
model Spiegelbelegung {
id String @id @default(cuid())
/// Kennung des Zeitblocks in der sendenden Instanz.
quelleRef String @unique
beginn DateTime
ende DateTime
empfangenAm DateTime @default(now())
@@index([beginn])
@@map("spiegelbelegung")
}
/// Ein Telegram-Chat, der mit dem Planer reden darf.
///
/// Wer nicht hier steht, wird verworfen – ein Bot-Token laesst sich erraten
/// oder abgreifen, die Chatkennung ist der zweite Riegel.
model TelegramChat {
id String @id @default(cuid())
/// Chatkennung bei Telegram; kann negativ sein (Gruppen).
chatId BigInt @unique
bezeichnung String?
benutzerId String?
/// Empfaengt dieser Chat auch Meldungen, nicht nur Aufgaben?
meldungen Boolean @default(true)
erstelltAm DateTime @default(now())
zuletztBenutzt DateTime?
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
@@index([benutzerId])
@@map("telegram_chat")
}
/// Einmalcode, mit dem sich ein Telegram-Chat anmeldet.
///
/// Ohne ihn muesste die Chatkennung von Hand eingetragen werden – und um sie
/// zu erfahren, muesste der Planer auf Nachrichten Unbekannter antworten.
/// Genau das soll er nicht.
model TelegramKopplung {
id String @id @default(cuid())
code String @unique
benutzerId String
gueltigBis DateTime
erstelltAm DateTime @default(now())
@@index([gueltigBis])
@@map("telegram_kopplung")
}
+53
View File
@@ -0,0 +1,53 @@
/*
* Service Worker des Planers.
*
* Er tut genau eine Sache: Push-Nachrichten anzeigen und beim Klick die
* richtige Seite öffnen. Kein Zwischenspeichern von Seiten — ein halb
* veralteter Planer wäre schlimmer als gar keiner.
*/
self.addEventListener('install', () => {
// Sofort übernehmen, nicht auf das Schließen aller Tabs warten.
self.skipWaiting()
})
self.addEventListener('activate', (ereignis) => {
ereignis.waitUntil(self.clients.claim())
})
self.addEventListener('push', (ereignis) => {
let daten = { titel: 'Planer', text: '', pfad: '/' }
try {
if (ereignis.data) daten = { ...daten, ...ereignis.data.json() }
} catch {
// Kaputte Nutzlast: lieber eine karge Meldung als gar keine.
}
ereignis.waitUntil(
self.registration.showNotification(daten.titel, {
body: daten.text,
lang: 'de',
// Gleiche Anlässe überschreiben einander, statt sich zu stapeln.
tag: daten.anlass || 'planer',
data: { pfad: daten.pfad },
}),
)
})
self.addEventListener('notificationclick', (ereignis) => {
ereignis.notification.close()
const pfad = (ereignis.notification.data && ereignis.notification.data.pfad) || '/'
ereignis.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((fenster) => {
// Ist der Planer schon offen, dorthin wechseln statt ein zweites
// Fenster aufzumachen.
for (const f of fenster) {
if (f.url.includes(self.location.origin)) {
return f.focus().then(() => f.navigate(pfad))
}
}
return self.clients.openWindow(pfad)
}),
)
})
+20 -4
View File
@@ -24,6 +24,13 @@ type Aufnahme = {
pfad: string pfad: string
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */ /** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
vorbereiten?: (seite: Page) => Promise<void> vorbereiten?: (seite: Page) => Promise<void>
/**
* Nur diesen Ausschnitt aufnehmen statt der ganzen Seite.
*
* Für Themen, die auf einer gemeinsamen Seite wohnen: ein Bild der ganzen
* Einstellungsseite zeigt im Handbuchkapitel zu wenig vom Eigentlichen.
*/
ausschnitt?: string
} }
const AUFNAHMEN: Aufnahme[] = [ const AUFNAHMEN: Aufnahme[] = [
@@ -31,11 +38,18 @@ const AUFNAHMEN: Aufnahme[] = [
{ name: 'start', pfad: '/' }, { name: 'start', pfad: '/' },
{ name: 'inbox', pfad: '/inbox' }, { name: 'inbox', pfad: '/inbox' },
{ name: 'aufgaben', pfad: '/aufgaben' }, { name: 'aufgaben', pfad: '/aufgaben' },
{ name: 'woche', pfad: '/woche' },
{ name: 'projekte', pfad: '/projekte' }, { name: 'projekte', pfad: '/projekte' },
{ name: 'ideen', pfad: '/ideen' }, { name: 'ideen', pfad: '/ideen' },
{ name: 'bereiche', pfad: '/bereiche' }, { name: 'bereiche', pfad: '/bereiche' },
{ name: 'kalenderkonten', pfad: '/kalenderkonten' }, { name: 'kalenderkonten', pfad: '/kalenderkonten' },
{ name: 'integrationen', pfad: '/integrationen' },
{ name: 'einstellungen', pfad: '/einstellungen' }, { name: 'einstellungen', pfad: '/einstellungen' },
{
name: 'benachrichtigungen',
pfad: '/einstellungen',
ausschnitt: '[data-bild="benachrichtigungen"]',
},
{ name: 'protokoll', pfad: '/verwaltung/protokoll' }, { name: 'protokoll', pfad: '/verwaltung/protokoll' },
] ]
@@ -81,10 +95,12 @@ for (const schema of ['hell', 'dunkel'] as const) {
// Der Farbschema-Umschalter und Schriften sollen fertig sein. // Der Farbschema-Umschalter und Schriften sollen fertig sein.
await seite.waitForTimeout(300) await seite.waitForTimeout(300)
await seite.screenshot({ const ziel = `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`
path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`, if (aufnahme.ausschnitt) {
fullPage: true, await seite.locator(aufnahme.ausschnitt).screenshot({ path: ziel })
}) } else {
await seite.screenshot({ path: ziel, fullPage: true })
}
} }
await kontext.close() await kontext.close()
+150
View File
@@ -41,6 +41,9 @@ async function scheitert(name: string, tun: () => Promise<unknown>): Promise<voi
} }
const kennung = `zzz-test-${Date.now()}` const kennung = `zzz-test-${Date.now()}`
// Eigene Chatkennung je Lauf, damit das Aufraeumen genau diese Zeile trifft
// und nicht irgendeine, die zufaellig aehnlich aussieht.
const testChatId = BigInt(Date.now())
const testEmail = `${kennung}@example.invalid` const testEmail = `${kennung}@example.invalid`
async function aufraeumen(): Promise<void> { async function aufraeumen(): Promise<void> {
@@ -56,6 +59,14 @@ async function aufraeumen(): Promise<void> {
await db.benutzer.deleteMany({ where: { email: testEmail } }) await db.benutzer.deleteMany({ where: { email: testEmail } })
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } }) await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
await db.webauthnChallenge.deleteMany({ where: { kennung } }) await db.webauthnChallenge.deleteMany({ where: { kennung } })
await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } })
await db.meldungsnachweis.deleteMany({ where: { schluessel: { contains: kennung } } })
await db.spiegelbelegung.deleteMany({ where: { quelleRef: { contains: kennung } } })
await db.telegramKopplung.deleteMany({
where: { code: { contains: kennung.slice(-8) } },
})
await db.telegramChat.deleteMany({ where: { chatId: testChatId } })
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
} }
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -259,6 +270,131 @@ async function main(): Promise<void> {
}) })
pruefe('Audit-Eintrag anlegen', audit.id.length > 0) pruefe('Audit-Eintrag anlegen', audit.id.length > 0)
// --- Benachrichtigungen --------------------------------------------------
await db.benachrichtigungseinstellung.create({
data: { benutzerId: benutzer.id },
})
await scheitert('Je Benutzer nur eine Benachrichtigungseinstellung', () =>
db.benachrichtigungseinstellung.create({ data: { benutzerId: benutzer.id } }),
)
const voreinstellung = await db.benachrichtigungseinstellung.findUniqueOrThrow({
where: { benutzerId: benutzer.id },
})
pruefe(
'Ab Werk ist kein Weg eingeschaltet',
voreinstellung.perPush === false && voreinstellung.perMail === false,
)
await db.pushGeraet.create({
data: {
benutzerId: benutzer.id,
endpunkt: `https://push.invalid/${kennung}`,
p256dh: 'x',
auth: 'y',
},
})
await scheitert('Derselbe Push-Endpunkt geht nur einmal', () =>
db.pushGeraet.create({
data: {
benutzerId: benutzer.id,
endpunkt: `https://push.invalid/${kennung}`,
p256dh: 'x',
auth: 'y',
},
}),
)
await db.meldungsnachweis.create({
data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` },
})
await scheitert('Derselbe Meldungsschluessel geht nur einmal', () =>
db.meldungsnachweis.create({
data: { benutzerId: benutzer.id, schluessel: `faelligkeit:${kennung}:2026-03-17` },
}),
)
pruefe(
'Derselbe Schlüssel am nächsten Tag ist erlaubt',
(
await db.meldungsnachweis.create({
data: {
benutzerId: benutzer.id,
schluessel: `faelligkeit:${kennung}:2026-03-18`,
},
})
).id.length > 0,
)
// --- Integrationen -------------------------------------------------------
// Der Zustand haengt an der Quelle: eine Zeile je Integration, nicht mehr.
await db.integrationszustand.create({
data: { quelle: 'TELEGRAM', merker: '42' },
})
await scheitert('Je Quelle nur ein Integrationszustand', () =>
db.integrationszustand.create({ data: { quelle: 'TELEGRAM' } }),
)
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
// Zwei Aufgaben aus derselben Quelle mit derselben Kennung darf es nicht
// geben – sonst legte jeder Lauf dieselbe Sache erneut an.
const ausZammad = await db.aufgabe.create({
data: { titel: `${kennung}-ticket`, quelle: 'ZAMMAD', quelleRef: `${kennung}-1` },
})
pruefe('Aufgabe aus einer Integration anlegen', ausZammad.id.length > 0)
pruefe('Ohne Freigabe wird nicht gespiegelt', ausZammad.spiegeln === false)
await scheitert('Dieselbe Quelle mit derselben Kennung nur einmal (Integration)', () =>
db.aufgabe.create({
data: {
titel: `${kennung}-ticket-doppelt`,
quelle: 'ZAMMAD',
quelleRef: `${kennung}-1`,
},
}),
)
await db.spiegelbelegung.create({
data: {
quelleRef: `${kennung}-zb`,
beginn: new Date(Date.UTC(2026, 2, 17, 8, 0)),
ende: new Date(Date.UTC(2026, 2, 17, 9, 0)),
},
})
await scheitert('Dieselbe gespiegelte Belegung nur einmal', () =>
db.spiegelbelegung.create({
data: {
quelleRef: `${kennung}-zb`,
beginn: new Date(Date.UTC(2026, 2, 18, 8, 0)),
ende: new Date(Date.UTC(2026, 2, 18, 9, 0)),
},
}),
)
const chat = await db.telegramChat.create({
data: { chatId: testChatId, benutzerId: benutzer.id },
})
pruefe('Telegram-Chat empfaengt im Zweifel Meldungen', chat.meldungen === true)
await scheitert('Dieselbe Chatkennung nur einmal', () =>
db.telegramChat.create({ data: { chatId: chat.chatId } }),
)
await db.telegramKopplung.create({
data: {
code: `${kennung.slice(-8)}`,
benutzerId: benutzer.id,
gueltigBis: new Date(Date.now() + 600_000),
},
})
await scheitert('Denselben Kopplungscode nur einmal', () =>
db.telegramKopplung.create({
data: {
code: `${kennung.slice(-8)}`,
benutzerId: benutzer.id,
gueltigBis: new Date(Date.now() + 600_000),
},
}),
)
await db.benutzer.delete({ where: { id: benutzer.id } }) await db.benutzer.delete({ where: { id: benutzer.id } })
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } }) const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
@@ -280,6 +416,20 @@ async function main(): Promise<void> {
'Kalenderkonten verschwinden mit dem Benutzer', 'Kalenderkonten verschwinden mit dem Benutzer',
(await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0, (await db.kalenderkonto.count({ where: { id: kalenderkonto.id } })) === 0,
) )
pruefe(
'Push-Geräte verschwinden mit dem Benutzer',
(await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0,
)
pruefe(
'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer',
(await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0,
)
pruefe(
'Meldungsnachweise verschwinden mit dem Benutzer',
(await db.meldungsnachweis.count({
where: { schluessel: { contains: kennung } },
})) === 0,
)
pruefe( pruefe(
'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine', 'Mit dem Kalenderkonto verschwinden auch die gespiegelten Termine',
(await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0, (await db.termin.count({ where: { kalenderkontoId: kalenderkonto.id } })) === 0,
+7
View File
@@ -14,6 +14,7 @@
*/ */
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import webpush from 'web-push'
const WURZEL = fileURLToPath(new URL('..', import.meta.url)) const WURZEL = fileURLToPath(new URL('..', import.meta.url))
const PROJEKT = 'planer-screenshots' const PROJEKT = 'planer-screenshots'
@@ -22,10 +23,16 @@ const COMPOSE_DATEI = 'docker-compose.screenshots.yml'
const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200' const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455' const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455'
// Frisches Paar je Lauf: Die Bilder sollen die eingerichtete Anwendung
// zeigen, ohne dass irgendwo ein privater Schluessel liegen bleibt.
const VAPID = webpush.generateVAPIDKeys()
const UMGEBUNG = { const UMGEBUNG = {
...process.env, ...process.env,
SCREENSHOT_APP_PORT: APP_PORT, SCREENSHOT_APP_PORT: APP_PORT,
SCREENSHOT_DB_PORT: DB_PORT, SCREENSHOT_DB_PORT: DB_PORT,
SCREENSHOT_VAPID_OEFFENTLICH: VAPID.publicKey,
SCREENSHOT_VAPID_PRIVAT: VAPID.privateKey,
} }
function meldung(text: string): void { function meldung(text: string): void {
+4
View File
@@ -14,6 +14,7 @@ import {
type AufgabenFilter, type AufgabenFilter,
} from '@/server/abfragen/aufgaben' } from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung' import { verlangeAnmeldung } from '@/server/berechtigung'
import { umgebung } from '@/server/umgebung'
export const metadata: Metadata = { title: 'Aufgaben' } export const metadata: Metadata = { title: 'Aufgaben' }
@@ -67,6 +68,7 @@ export default async function AufgabenSeite({
searchParams: Promise<Record<string, string | string[] | undefined>> searchParams: Promise<Record<string, string | string[] | undefined>>
}) { }) {
await verlangeAnmeldung('/aufgaben') await verlangeAnmeldung('/aufgaben')
const spiegelt = umgebung().spiegelSendenAktiv
const parameter = await searchParams const parameter = await searchParams
const [aufgaben, bereiche, projekte] = await Promise.all([ const [aufgaben, bereiche, projekte] = await Promise.all([
@@ -101,11 +103,13 @@ export default async function AufgabenSeite({
</CardInhalt> </CardInhalt>
</Card> </Card>
{/* Nur wo auch gespiegelt wird, hat der Schalter einen Sinn. */}
<AufgabenListe <AufgabenListe
aufgaben={aufgaben} aufgaben={aufgaben}
bereiche={bereiche} bereiche={bereiche}
projekte={projekte} projekte={projekte}
leerText="Keine Aufgabe passt zu diesen Filtern." leerText="Keine Aufgabe passt zu diesen Filtern."
mitSpiegeln={spiegelt}
/> />
</div> </div>
) )
@@ -0,0 +1,483 @@
'use client'
import { useEffect, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Bell, BellOff, Loader2, Send, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import {
ANLAESSE,
ANLASS_BESCHREIBUNG,
ANLASS_NAME,
type Anlass,
} from '@/lib/benachrichtigung'
import {
einstellungSpeichern,
geraetEntfernen,
probeSenden,
type Rueckmeldung,
} from '@/server/aktionen/benachrichtigungen'
export type GeraetAnzeige = {
id: string
bezeichnung: string | null
erstelltAm: string
zuletztBenutzt: string | null
/** Dieses Gerät ist der Browser, in dem die Seite gerade offen ist. */
diesesGeraet?: boolean
}
export type EinstellungAnzeige = {
anlaesse: Anlass[]
perPush: boolean
perMail: boolean
perTelegram: boolean
ueberblickUm: string
ruheVon: string
ruheBis: string
ruheAmWochenende: boolean
zeitblockVorlaufMinuten: number
}
type Eigenschaften = {
einstellung: EinstellungAnzeige
geraete: GeraetAnzeige[]
pushMoeglich: boolean
mailMoeglich: boolean
telegramMoeglich: boolean
/** Wie viele Chats Meldungen empfangen. Null heisst: noch keiner. */
telegramChats: number
}
/**
* Wandelt den base64url-Schlüssel in die Form, die der Browser erwartet.
*
* Der Puffer wird ausdrücklich angelegt, damit der Typ `Uint8Array<ArrayBuffer>`
* lautet — `pushManager.subscribe` nimmt keinen geteilten Puffer an.
*/
function schluesselAlsBytes(base64url: string): Uint8Array<ArrayBuffer> {
const fuellung = '='.repeat((4 - (base64url.length % 4)) % 4)
const base64 = (base64url + fuellung).replace(/-/g, '+').replace(/_/g, '/')
const roh = atob(base64)
const bytes = new Uint8Array(new ArrayBuffer(roh.length))
for (let i = 0; i < roh.length; i += 1) bytes[i] = roh.charCodeAt(i)
return bytes
}
export function Benachrichtigungen({
einstellung,
geraete,
pushMoeglich,
mailMoeglich,
telegramMoeglich,
telegramChats,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [felder, setFelder] = useState<Record<string, string>>({})
const [pushZustand, setPushZustand] = useState<
'unbekannt' | 'nicht-unterstuetzt' | 'aus' | 'an' | 'verweigert'
>('unbekannt')
const [pushLaeuft, setPushLaeuft] = useState(false)
useEffect(() => {
let abgebrochen = false
async function zustandPruefen() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
if (!abgebrochen) setPushZustand('nicht-unterstuetzt')
return
}
if (Notification.permission === 'denied') {
if (!abgebrochen) setPushZustand('verweigert')
return
}
const registrierung = await navigator.serviceWorker.getRegistration()
const abo = await registrierung?.pushManager.getSubscription()
if (!abgebrochen) setPushZustand(abo ? 'an' : 'aus')
}
void zustandPruefen()
return () => {
abgebrochen = true
}
}, [])
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
setFelder({})
toast.success(antwort.meldung)
router.refresh()
} else {
setFelder(antwort.felder ?? {})
toast.error(antwort.fehler)
}
})
}
async function pushAnmelden() {
setPushLaeuft(true)
try {
const erlaubnis = await Notification.requestPermission()
if (erlaubnis !== 'granted') {
setPushZustand(erlaubnis === 'denied' ? 'verweigert' : 'aus')
toast.error('Ohne Erlaubnis des Browsers geht es nicht.')
return
}
const antwort = await fetch('/api/push')
const daten = (await antwort.json()) as {
aktiv: boolean
oeffentlicherSchluessel: string | null
}
if (!daten.aktiv || !daten.oeffentlicherSchluessel) {
toast.error('Push ist auf diesem Server nicht eingerichtet.')
return
}
const registrierung = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const abo = await registrierung.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: schluesselAlsBytes(daten.oeffentlicherSchluessel),
})
const roh = abo.toJSON() as { endpoint?: string; keys?: Record<string, string> }
const gespeichert = await fetch('/api/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpunkt: roh.endpoint,
p256dh: roh.keys?.p256dh,
auth: roh.keys?.auth,
bezeichnung: geraetename(),
}),
})
if (!gespeichert.ok) {
const fehler = (await gespeichert.json()) as { fehler?: string }
toast.error(fehler.fehler ?? 'Die Anmeldung ließ sich nicht speichern.')
return
}
setPushZustand('an')
toast.success('Dieses Gerät empfängt jetzt Benachrichtigungen.')
router.refresh()
} catch {
toast.error('Die Anmeldung hat nicht geklappt.')
} finally {
setPushLaeuft(false)
}
}
async function pushAbmelden() {
setPushLaeuft(true)
try {
const registrierung = await navigator.serviceWorker.getRegistration()
const abo = await registrierung?.pushManager.getSubscription()
if (abo) {
await fetch('/api/push', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpunkt: abo.endpoint }),
})
await abo.unsubscribe()
}
setPushZustand('aus')
toast.success('Dieses Gerät empfängt keine Benachrichtigungen mehr.')
router.refresh()
} catch {
toast.error('Das Abmelden hat nicht geklappt.')
} finally {
setPushLaeuft(false)
}
}
return (
<div className="flex flex-col gap-6">
{/* Dieses Gerät */}
<div className="border-rand flex flex-wrap items-center gap-3 rounded-md border p-3">
{pushZustand === 'an' ? (
<Bell className="text-erfolg size-5 shrink-0" aria-hidden />
) : (
<BellOff className="text-gedaempft-vordergrund size-5 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Dieses Gerät</p>
<p className="text-gedaempft-vordergrund text-xs">
{/*
Fehlen die Schlüssel auf dem Server, geht hier ohnehin nichts –
dann über den Browser zu reden, schickt nur auf die falsche
Fährte.
*/}
{!pushMoeglich
? 'Ohne Push-Schlüssel auf dem Server empfängt kein Gerät etwas.'
: pushZustand === 'nicht-unterstuetzt'
? 'Dieser Browser kann keine Push-Nachrichten empfangen.'
: pushZustand === 'verweigert'
? 'Der Browser hat Benachrichtigungen blockiert. Das lässt sich nur in seinen Einstellungen ändern.'
: pushZustand === 'an'
? 'Empfängt Push-Nachrichten.'
: pushZustand === 'aus'
? 'Empfängt keine Push-Nachrichten.'
: '…'}
</p>
</div>
{!pushMoeglich ? (
<Kennzeichen art="warnung">Auf dem Server nicht eingerichtet</Kennzeichen>
) : pushZustand === 'an' ? (
<Button art="umriss" disabled={pushLaeuft} onClick={() => void pushAbmelden()}>
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
Abmelden
</Button>
) : (
pushZustand !== 'nicht-unterstuetzt' &&
pushZustand !== 'verweigert' && (
<Button disabled={pushLaeuft} onClick={() => void pushAnmelden()}>
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
Anmelden
</Button>
)
)}
</div>
{/* Einstellungen */}
<form
className="flex flex-col gap-5"
onSubmit={(ereignis) => {
ereignis.preventDefault()
const daten = new FormData(ereignis.currentTarget)
mitRueckmeldung(() => einstellungSpeichern(daten))
}}
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Worüber</legend>
{ANLAESSE.map((anlass) => (
<label key={anlass} className="flex items-start gap-3">
<input
type="checkbox"
name="anlaesse"
value={anlass}
defaultChecked={einstellung.anlaesse.includes(anlass)}
className="accent-betont mt-0.5 size-4"
/>
<span>
<span className="text-sm">{ANLASS_NAME[anlass]}</span>
<span className="text-gedaempft-vordergrund block text-xs">
{ANLASS_BESCHREIBUNG[anlass]}
</span>
</span>
</label>
))}
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Wie</legend>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perPush"
defaultChecked={einstellung.perPush}
className="accent-betont size-4"
/>
<span className="text-sm">
Push an angemeldete Geräte
{!pushMoeglich && (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
)}
</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perMail"
defaultChecked={einstellung.perMail}
className="accent-betont size-4"
/>
<span className="text-sm">
E-Mail
{!mailMoeglich && (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
)}
</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perTelegram"
defaultChecked={einstellung.perTelegram}
className="accent-betont size-4"
/>
<span className="text-sm">
Telegram
{!telegramMoeglich ? (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
) : telegramChats === 0 ? (
<span className="text-gedaempft-vordergrund">
{' '}
— noch kein Chat eingetragen
</span>
) : (
<span className="text-gedaempft-vordergrund">
{' '}
— an {telegramChats} Chat{telegramChats === 1 ? '' : 's'}, mit Knopf zum
Abhaken
</span>
)}
</span>
</label>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="ueberblickUm">Tagesüberblick um</Label>
<Input
id="ueberblickUm"
name="ueberblickUm"
type="time"
defaultValue={einstellung.ueberblickUm}
aria-invalid={Boolean(felder.ueberblickUm)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="zeitblockVorlaufMinuten">
Vorlauf vor einem Zeitblock (Minuten)
</Label>
<Input
id="zeitblockVorlaufMinuten"
name="zeitblockVorlaufMinuten"
type="number"
min={0}
max={120}
step={5}
defaultValue={einstellung.zeitblockVorlaufMinuten}
aria-invalid={Boolean(felder.zeitblockVorlaufMinuten)}
/>
{felder.zeitblockVorlaufMinuten && (
<p className="text-gefahr text-xs">{felder.zeitblockVorlaufMinuten}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="ruheVon">Ruhe von</Label>
<Input
id="ruheVon"
name="ruheVon"
type="time"
defaultValue={einstellung.ruheVon}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="ruheBis">bis</Label>
<Input
id="ruheBis"
name="ruheBis"
type="time"
defaultValue={einstellung.ruheBis}
/>
</div>
</div>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="ruheAmWochenende"
defaultChecked={einstellung.ruheAmWochenende}
className="accent-betont size-4"
/>
<span className="text-sm">Am Wochenende gar nichts melden</span>
</label>
<p className="text-gedaempft-vordergrund text-xs">
In der Ruhezeit bleibt alles still — bis auf den Tagesüberblick. Der käme sonst
nie, wenn du früh aufstehst.
</p>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
Speichern
</Button>
<Button
type="button"
art="umriss"
disabled={laeuft}
onClick={() => mitRueckmeldung(() => probeSenden())}
>
<Send aria-hidden />
Probe schicken
</Button>
</div>
</form>
{/* Angemeldete Geräte */}
{geraete.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Angemeldete Geräte</h3>
<ul className="divide-rand divide-y">
{geraete.map((geraet) => (
<li key={geraet.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="text-sm">{geraet.bezeichnung ?? 'Unbenanntes Gerät'}</p>
<p className="text-gedaempft-vordergrund text-xs">
Angemeldet {geraet.erstelltAm}
{geraet.zuletztBenutzt
? ` · zuletzt benachrichtigt ${geraet.zuletztBenutzt}`
: ' · noch nichts empfangen'}
</p>
</div>
<Button
art="leise"
groesse="quadrat"
title={`${geraet.bezeichnung ?? 'Gerät'} abmelden`}
disabled={laeuft}
onClick={() => {
const daten = new FormData()
daten.set('id', geraet.id)
mitRueckmeldung(() => geraetEntfernen(daten))
}}
>
<Trash2 aria-hidden />
<span className="sr-only">Abmelden</span>
</Button>
</li>
))}
</ul>
</div>
)}
</div>
)
}
/** Grobe Kennung des Browsers, damit sich Geräte auseinanderhalten lassen. */
function geraetename(): string {
const kennung = navigator.userAgent
if (/Android/i.test(kennung)) return 'Android-Gerät'
if (/iPhone|iPad/i.test(kennung)) return 'iPhone oder iPad'
if (/Macintosh/i.test(kennung)) return 'Mac'
if (/Windows/i.test(kennung)) return 'Windows-Rechner'
if (/Linux/i.test(kennung)) return 'Linux-Rechner'
return 'Unbekanntes Gerät'
}
+71
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Benachrichtigungen } from '@/app/(app)/einstellungen/benachrichtigungen'
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung' import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
import { import {
Card, Card,
@@ -8,6 +9,9 @@ import {
CardTitel, CardTitel,
} from '@/components/ui/card' } from '@/components/ui/card'
import { ROLLEN_NAME } from '@/lib/rollen' import { ROLLEN_NAME } from '@/lib/rollen'
import { alsEinstellung } from '@/server/benachrichtigung/versand'
import { alsUhrzeit } from '@/lib/zeit'
import { umgebung } from '@/server/umgebung'
import { alsDatumUhrzeit } from '@/lib/zeit' import { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung' import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db' import { db } from '@/server/db'
@@ -17,6 +21,37 @@ export const metadata: Metadata = { title: 'Einstellungen' }
export default async function EinstellungenSeite() { export default async function EinstellungenSeite() {
const benutzer = await verlangeAnmeldung('/einstellungen') const benutzer = await verlangeAnmeldung('/einstellungen')
const einst = umgebung()
const [benachrichtigung, telegramChats, pushGeraete] = await Promise.all([
db.benachrichtigungseinstellung.findUnique({
where: { benutzerId: benutzer.id },
select: {
anlaesse: true,
perPush: true,
perMail: true,
perTelegram: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
ruheAmWochenende: true,
zeitblockVorlaufMinuten: true,
},
}),
db.telegramChat.count({ where: { benutzerId: benutzer.id, meldungen: true } }),
db.pushGeraet.findMany({
where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' },
select: {
id: true,
bezeichnung: true,
erstelltAm: true,
zuletztBenutzt: true,
},
}),
])
const einstellung = alsEinstellung(benachrichtigung)
const passkeys = await db.passkey.findMany({ const passkeys = await db.passkey.findMany({
where: { benutzerId: benutzer.id }, where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' }, orderBy: { erstelltAm: 'asc' },
@@ -72,6 +107,42 @@ export default async function EinstellungenSeite() {
/> />
</CardInhalt> </CardInhalt>
</Card> </Card>
<Card data-bild="benachrichtigungen">
<CardKopf>
<CardTitel className="text-base">Benachrichtigungen</CardTitel>
<CardBeschreibung>
Ab Werk meldet der Planer gar nichts. Was du hier einschaltest, kommt — alles
andere nicht. Eine Anwendung, die zu oft meldet, wird stummgeschaltet und ist
dann wertlos.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<Benachrichtigungen
pushMoeglich={einst.pushAktiv}
mailMoeglich={einst.mailAktiv}
telegramMoeglich={einst.telegramAktiv}
telegramChats={telegramChats}
einstellung={{
anlaesse: [...einstellung.anlaesse],
perPush: einstellung.perPush,
perMail: einstellung.perMail,
perTelegram: einstellung.perTelegram,
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
ruheVon: alsUhrzeit(einstellung.ruheVon),
ruheBis: alsUhrzeit(einstellung.ruheBis),
ruheAmWochenende: einstellung.ruheAmWochenende,
zeitblockVorlaufMinuten: einstellung.zeitblockVorlaufMinuten,
}}
geraete={pushGeraete.map((g) => ({
id: g.id,
bezeichnung: g.bezeichnung,
erstelltAm: alsDatumUhrzeit(g.erstelltAm),
zuletztBenutzt: g.zuletztBenutzt ? alsDatumUhrzeit(g.zuletztBenutzt) : null,
}))}
/>
</CardInhalt>
</Card>
</div> </div>
) )
} }
@@ -0,0 +1,46 @@
'use client'
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { laufAnstossen } from '@/server/aktionen/integrationen'
/**
* Stößt einen Lauf von Hand an.
*
* Vor allem direkt nach dem Einrichten: Sonst wüsste man erst beim nächsten
* Takt, ob die Zugangsdaten stimmen.
*/
export function LaufKnopf({ quelle }: { quelle: string }) {
const router = useRouter()
const [laeuft, starte] = useTransition()
return (
<Button
art="umriss"
disabled={laeuft}
onClick={() => {
starte(async () => {
const daten = new FormData()
daten.set('quelle', quelle)
const antwort = await laufAnstossen(daten)
if (antwort.ok) {
toast.success(antwort.meldung)
router.refresh()
} else {
toast.error(antwort.fehler)
}
})
}}
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<RefreshCw aria-hidden />
)}
Jetzt abrufen
</Button>
)
}
+192
View File
@@ -0,0 +1,192 @@
import type { Metadata } from 'next'
import { Plug } from 'lucide-react'
import { LaufKnopf } from '@/app/(app)/integrationen/lauf-knopf'
import { TelegramChats } from '@/app/(app)/integrationen/telegram-chats'
import {
Card,
CardBeschreibung,
CardInhalt,
CardKopf,
CardTitel,
} from '@/components/ui/card'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import type { Quelle } from '@/generated/prisma/enums'
import { INTEGRATION_FEHLER_AB_VERSUCH } from '@/lib/integration'
import { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
export const metadata: Metadata = { title: 'Integrationen' }
type Zeile = {
quelle: Quelle
name: string
beschreibung: string
/** In dieser Instanz überhaupt vorgesehen? */
vorgesehen: boolean
/** Und auch eingerichtet? */
aktiv: boolean
hinweis: string
/** Laesst sich der Lauf von Hand anstossen? Der Empfaenger wartet nur. */
abrufbar?: boolean
}
export default async function IntegrationenSeite() {
await verlangeAnmeldung('/integrationen')
const einst = umgebung()
const [zustaende, chats] = await Promise.all([
db.integrationszustand.findMany(),
db.telegramChat.findMany({
orderBy: { erstelltAm: 'asc' },
select: {
id: true,
chatId: true,
bezeichnung: true,
meldungen: true,
erstelltAm: true,
zuletztBenutzt: true,
},
}),
])
const nachQuelle = new Map(zustaende.map((z) => [z.quelle, z]))
const zeilen: Zeile[] = [
{
quelle: 'ZAMMAD',
name: 'Zammad',
beschreibung:
'Tickets, auf die der Suchausdruck passt, werden zu Aufgaben. Geschrieben wird nach Zammad nichts — ein Ticket schließt man im Ticketsystem.',
vorgesehen: einst.instanz === 'firma',
aktiv: einst.zammadAktiv,
hinweis: 'ZAMMAD_URL und ZAMMAD_TOKEN fehlen.',
abrufbar: einst.zammadAktiv,
},
{
quelle: 'WARTUNGSPLANER',
name: 'Wartungsplaner',
beschreibung:
'Noch nicht angebunden: Die Schnittstelle ist offen. Die Aufnahmestelle steht bereit, es fehlt allein das Abholen.',
vorgesehen: einst.instanz === 'firma',
aktiv: false,
hinweis: 'Kommt, sobald die Schnittstelle feststeht.',
},
{
quelle: 'TELEGRAM',
name: 'Telegram',
beschreibung:
'Eine Nachricht an den Bot wird zur Aufgabe. Meldungen gehen denselben Weg zurück — mit einem Knopf zum Abhaken.',
vorgesehen: einst.instanz === 'daheim',
aktiv: einst.telegramAktiv,
hinweis: 'TELEGRAM_TOKEN fehlt.',
abrufbar: einst.telegramAktiv,
},
{
quelle: 'SPIEGEL',
name: 'Spiegel',
beschreibung:
einst.instanz === 'firma'
? 'Schickt Belegungen ohne Titel nach Hause, dazu die Aufgaben, die ausdrücklich zum Spiegeln markiert sind.'
: 'Empfängt dienstliche Belegungen. Sie erscheinen in der Wochenansicht als „Dienstlich belegt", ohne Betreff.',
vorgesehen: true,
aktiv: einst.spiegelSendenAktiv || einst.spiegelEmpfangenAktiv,
hinweis: 'SPIEGEL_GEHEIMNIS fehlt.',
// Die empfangende Seite ruft nichts ab – sie wartet.
abrufbar: einst.spiegelSendenAktiv,
},
]
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Plug className="text-gedaempft-vordergrund size-6" aria-hidden />
<div>
<h1 className="text-2xl font-semibold tracking-tight">Integrationen</h1>
<p className="text-gedaempft-vordergrund text-sm">
Was von außen hereinkommt — und was diese Instanz hinausgibt.
</p>
</div>
</div>
<div className="flex flex-col gap-4">
{zeilen.map((zeile) => {
const zustand = nachQuelle.get(zeile.quelle)
const gestoert = (zustand?.fehlversuche ?? 0) >= INTEGRATION_FEHLER_AB_VERSUCH
return (
<Card key={zeile.quelle} className={zeile.vorgesehen ? '' : 'opacity-60'}>
<CardKopf>
<div className="flex flex-wrap items-center gap-2">
<CardTitel className="text-base">{zeile.name}</CardTitel>
{!zeile.vorgesehen ? (
<Kennzeichen art="neutral">
In dieser Instanz nicht vorgesehen
</Kennzeichen>
) : gestoert ? (
<Kennzeichen art="gefahr">Gestört</Kennzeichen>
) : zeile.aktiv ? (
<Kennzeichen art="erfolg">Läuft</Kennzeichen>
) : (
<Kennzeichen art="warnung">Nicht eingerichtet</Kennzeichen>
)}
</div>
<CardBeschreibung>{zeile.beschreibung}</CardBeschreibung>
</CardKopf>
<CardInhalt className="text-gedaempft-vordergrund flex flex-col gap-1 text-sm">
{zeile.vorgesehen && !zeile.aktiv && <p>{zeile.hinweis}</p>}
{zustand?.letzterErfolg && (
<p>Zuletzt geglückt: {alsDatumUhrzeit(zustand.letzterErfolg)}</p>
)}
{zustand?.letzterFehler && (
<p className="text-gefahr">
Letzter Fehler: {zustand.letzterFehler}
{zustand.fehlversuche > 1 && ` (${zustand.fehlversuche}× in Folge)`}
</p>
)}
{!zustand && zeile.aktiv && <p>Noch kein Lauf.</p>}
{zeile.abrufbar && (
<div className="pt-2">
<LaufKnopf quelle={zeile.quelle} />
</div>
)}
</CardInhalt>
</Card>
)
})}
</div>
{einst.telegramAktiv && (
<Card>
<CardKopf>
<CardTitel className="text-base">Telegram-Chats</CardTitel>
<CardBeschreibung>
Nur wer hier steht, darf mit dem Bot reden — und nur diese Chats bekommen
Meldungen. Alles andere wird verworfen, ohne zu antworten. Zum Verbinden
holst du dir hier einen Code und schickst ihn dem Bot; er gilt zehn Minuten
und genau einmal.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<TelegramChats
chats={chats.map((c) => ({
id: c.id,
chatId: c.chatId.toString(),
bezeichnung: c.bezeichnung,
meldungen: c.meldungen,
erstelltAm: alsDatumUhrzeit(c.erstelltAm),
zuletztBenutzt: c.zuletztBenutzt
? alsDatumUhrzeit(c.zuletztBenutzt)
: null,
}))}
/>
</CardInhalt>
</Card>
)}
</div>
)
}
@@ -0,0 +1,139 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Bell, BellOff, Link2, Loader2, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
chatEntfernen,
chatMeldungenUmschalten,
kopplungAnfordern,
} from '@/server/aktionen/integrationen'
export type ChatAnzeige = {
id: string
chatId: string
bezeichnung: string | null
meldungen: boolean
erstelltAm: string
zuletztBenutzt: string | null
}
export function TelegramChats({ chats }: { chats: ChatAnzeige[] }) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [code, setCode] = useState<{ code: string; minuten: number } | null>(null)
function anfordern() {
starte(async () => {
const antwort = await kopplungAnfordern()
if (antwort.ok) {
setCode({ code: antwort.code, minuten: antwort.minuten })
} else {
toast.error(antwort.fehler)
}
})
}
function mitRueckmeldung(
tun: () => Promise<{ ok: boolean; meldung?: string; fehler?: string }>,
) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
toast.success(antwort.meldung ?? 'Erledigt.')
router.refresh()
} else {
toast.error(antwort.fehler ?? 'Das hat nicht geklappt.')
}
})
}
return (
<div className="flex flex-col gap-4">
{chats.length === 0 ? (
<p className="text-gedaempft-vordergrund text-sm">
Noch kein Chat verbunden. Ohne Verbindung nimmt der Bot nichts an.
</p>
) : (
<ul className="divide-rand divide-y">
{chats.map((chat) => (
<li key={chat.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="text-sm">{chat.bezeichnung ?? `Chat ${chat.chatId}`}</p>
<p className="text-gedaempft-vordergrund text-xs">
Verbunden {chat.erstelltAm}
{chat.zuletztBenutzt
? ` · zuletzt benutzt ${chat.zuletztBenutzt}`
: ' · noch nichts geschickt'}
</p>
</div>
<Button
art="leise"
groesse="quadrat"
title={
chat.meldungen ? 'Keine Meldungen mehr hierher' : 'Meldungen hierher'
}
disabled={laeuft}
onClick={() => {
const daten = new FormData()
daten.set('id', chat.id)
mitRueckmeldung(() => chatMeldungenUmschalten(daten))
}}
>
{chat.meldungen ? (
<Bell className="text-erfolg" aria-hidden />
) : (
<BellOff aria-hidden />
)}
<span className="sr-only">Meldungen umschalten</span>
</Button>
<Button
art="leise"
groesse="quadrat"
title="Chat trennen"
disabled={laeuft}
onClick={() => {
const daten = new FormData()
daten.set('id', chat.id)
mitRueckmeldung(() => chatEntfernen(daten))
}}
>
<Trash2 aria-hidden />
<span className="sr-only">Trennen</span>
</Button>
</li>
))}
</ul>
)}
{code ? (
<div className="border-rand rounded-md border p-3">
<p className="text-sm">
Schick dem Bot jetzt diesen Code — als Nachricht oder als{' '}
<code className="text-xs">/start {code.code}</code>:
</p>
<p className="my-2 font-mono text-2xl tracking-widest">{code.code}</p>
<p className="text-gedaempft-vordergrund text-xs">
Gültig für {code.minuten} Minuten und nur ein einziges Mal. Danach erscheint
der Chat hier — spätestens nach dem nächsten Abholen.
</p>
</div>
) : (
<div>
<Button art="umriss" disabled={laeuft} onClick={anfordern}>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<Link2 aria-hidden />
)}
Chat verbinden
</Button>
</div>
)}
</div>
)
}
+215
View File
@@ -0,0 +1,215 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { CalendarRange } from 'lucide-react'
import { Wochenraster } from '@/app/(app)/woche/wochenraster'
import { Card, CardInhalt } from '@/components/ui/card'
import { faelligkeitEinordnen, faelligkeitText, istUeberfaellig } from '@/lib/aufgabe'
import {
belegteMinuten,
arbeitsfensterAmTag,
kapazitaetAmTag,
} from '@/lib/kalender/belegung'
import { fensterHuelle, rasterBauen } from '@/lib/woche'
import { alsDatum, gleicherTag, tagesBeginn } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
import { planbareAufgabenLaden, wocheLaden } from '@/server/abfragen/woche'
import { jetzt } from '@/server/zeitgeber'
export const metadata: Metadata = { title: 'Woche' }
const WOCHENTAGE = [
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
'Sonntag',
]
export default async function WocheSeite({
searchParams,
}: {
searchParams: Promise<{ versatz?: string }>
}) {
const benutzer = await verlangeAnmeldung('/woche')
const parameter = await searchParams
// Über den Versatz lässt sich blättern; er steht in der Adresse, damit eine
// bestimmte Woche als Lesezeichen taugt.
const versatz = Number.parseInt(parameter.versatz ?? '0', 10)
const sichereWochen = Number.isFinite(versatz)
? Math.max(-52, Math.min(52, versatz))
: 0
const heute = jetzt()
const zeitpunkt = new Date(heute.getTime() + sichereWochen * 7 * 86_400_000)
const [daten, aufgaben, bereiche, konto] = await Promise.all([
wocheLaden(benutzer.id, zeitpunkt),
planbareAufgabenLaden(),
db.bereich.findMany({
where: { aktiv: true },
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
}),
// Für den ersten Wurf: der Zeitblock landet im ersten verbundenen
// Kalender. Die Auswahl je Bereich kommt, sobald es mehrere gibt.
db.kalenderkonto.findFirst({
where: { benutzerId: benutzer.id, aktiv: true },
orderBy: { erstelltAm: 'asc' },
select: { id: true, anzeigename: true },
}),
])
const huelle = fensterHuelle(bereiche)
const raster = rasterBauen(zeitpunkt, huelle.von, huelle.bis)
// Kapazität und Belegung je Tag: alle Bereiche zusammengenommen, denn ein
// Termin blockiert die Zeit für alles gleichermaßen.
const spalten = raster.spalten.map((spalte) => {
const tag = spalte.tagesBeginn
const kapazitaet = bereiche.reduce((summe, b) => summe + kapazitaetAmTag(b, tag), 0)
const belegend = [
...daten.termine.map((t) => ({
beginn: t.beginn,
ende: t.ende,
ganztaegig: t.ganztaegig,
zeigtAls: t.zeigtAls,
})),
...daten.zeitbloecke.map((z) => ({
beginn: z.beginn,
ende: z.ende,
ganztaegig: false,
zeigtAls: 'GEBUCHT' as const,
})),
]
// Gegen die Hülle rechnen, nicht gegen ein einzelnes Arbeitsfenster:
// die Tageskapazität ist hier die Summe über alle Bereiche.
const fenster = arbeitsfensterAmTag(tag, huelle.von, huelle.bis)
const belegt = belegteMinuten(belegend, fenster)
return {
schluessel: spalte.schluessel,
tagesBeginnMs: spalte.tagesBeginn.getTime(),
name: WOCHENTAGE[spalte.wochentag] ?? '',
datum: alsDatum(tag).split(', ')[1] ?? '',
heute: gleicherTag(tag, heute),
kapazitaet,
belegt,
ueberbucht: kapazitaet > 0 && belegt > kapazitaet,
}
})
const montag = tagesBeginn(raster.spalten[0]?.tagesBeginn ?? zeitpunkt)
const sonntag = raster.spalten[6]?.tagesBeginn ?? zeitpunkt
return (
/*
Die Wochenansicht braucht mehr Breite als der Inhaltsrahmen von 64rem
hergibt – bei sieben Spalten plus Aufgabenliste wäre sonst der Sonntag
abgeschnitten. Deshalb bricht genau diese Seite aus und nimmt bis zu
100rem, mittig und mit demselben Seitenabstand wie überall.
*/
<div className="relative left-1/2 w-screen max-w-[100rem] -translate-x-1/2 px-4 sm:px-6">
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<CalendarRange className="size-6" aria-hidden />
Woche
</h1>
<p className="text-gedaempft-vordergrund mt-1 text-sm">
{alsDatum(montag)} bis {alsDatum(sonntag)}
{konto ? ` · schreibt nach ${konto.anzeigename}` : ''}
</p>
</div>
<nav className="flex items-center gap-2" aria-label="Woche wechseln">
<Link
href={`/woche?versatz=${sichereWochen - 1}`}
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Zurück
</Link>
<Link
href="/woche"
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Diese Woche
</Link>
<Link
href={`/woche?versatz=${sichereWochen + 1}`}
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
>
Weiter
</Link>
</nav>
</header>
{!konto && (
<Card>
<CardInhalt className="py-4 text-sm">
Noch kein Kalender verbunden. Zeitblöcke landen erst dann im Kalender —{' '}
<Link
href="/kalenderkonten"
className="text-betont underline underline-offset-4"
>
jetzt verbinden
</Link>
.
</CardInhalt>
</Card>
)}
<Wochenraster
spalten={spalten}
zeilen={raster.zeilen}
vonMinute={raster.vonMinute}
bisMinute={raster.bisMinute}
kalenderkontoId={konto?.id ?? null}
zeitbloecke={daten.zeitbloecke.map((z) => ({
id: z.id,
beginnMs: z.beginn.getTime(),
endeMs: z.ende.getTime(),
titel: z.titel,
bereichFarbe: z.bereichFarbe,
kalenderName: z.kalenderName,
letzterFehler: z.letzterFehler,
offen: z.offen,
}))}
termine={daten.termine.map((t) => ({
id: t.id,
beginnMs: t.beginn.getTime(),
endeMs: t.ende.getTime(),
betreff: t.betreff,
ganztaegig: t.ganztaegig,
frei: t.zeigtAls === 'FREI',
kalenderName: t.kalenderName,
gespiegelt: t.gespiegelt,
}))}
aufgaben={aufgaben.map((a) => ({
id: a.id,
titel: a.titel,
aufwandMinuten: a.aufwandMinuten,
bereichName: a.bereich?.name ?? null,
bereichFarbe: a.bereich?.farbe ?? null,
faelligText: a.faelligAm
? faelligkeitText(faelligkeitEinordnen(a.faelligAm, heute))
: null,
ueberfaellig: istUeberfaellig(a.faelligAm, a.status, heute),
bereitsGeplant: a._count.zeitbloecke,
}))}
/>
<p className="text-gedaempft-vordergrund text-xs">
Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein
Zeitblock lässt sich am Griff verschieben; das Kreuz plant ihn wieder aus. Graue
Balken sind Termine aus dem Kalender, gestreifte kommen aus der anderen Instanz
— beide lassen sich hier nicht verschieben.
</p>
</div>
</div>
)
}
+543
View File
@@ -0,0 +1,543 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import {
DndContext,
DragOverlay,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core'
import { AlertTriangle, CalendarClock, GripVertical, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import {
ZEILE_HOEHE,
balkenlage,
spurenVerteilen,
zeilenBeschriftung,
zeitpunktAus,
type Raster,
} from '@/lib/woche'
import { alsDauer, alsUhrzeitAnzeige } from '@/lib/zeit'
import { cn } from '@/lib/utils'
import {
zeitblockAnlegen,
zeitblockLoesen,
zeitblockVerschieben,
type Rueckmeldung,
} from '@/server/aktionen/zeitbloecke'
export type PlanbareAufgabe = {
id: string
titel: string
aufwandMinuten: number | null
bereichName: string | null
bereichFarbe: string | null
faelligText: string | null
ueberfaellig: boolean
bereitsGeplant: number
}
export type RasterZeitblock = {
id: string
beginnMs: number
endeMs: number
titel: string
bereichFarbe: string | null
kalenderName: string
letzterFehler: string | null
offen: boolean
}
export type RasterTermin = {
id: string
beginnMs: number
endeMs: number
betreff: string | null
ganztaegig: boolean
frei: boolean
kalenderName: string
/** Aus der anderen Instanz gespiegelt: belegt, aber ohne Betreff. */
gespiegelt?: boolean
}
export type RasterSpalte = {
schluessel: string
tagesBeginnMs: number
name: string
datum: string
heute: boolean
kapazitaet: number
belegt: number
ueberbucht: boolean
}
type Eigenschaften = {
spalten: RasterSpalte[]
zeilen: number[]
vonMinute: number
bisMinute: number
zeitbloecke: RasterZeitblock[]
termine: RasterTermin[]
aufgaben: PlanbareAufgabe[]
kalenderkontoId: string | null
}
/** Standarddauer, wenn eine Aufgabe keinen geschätzten Aufwand trägt. */
const STANDARD_MINUTEN = 60
export function Wochenraster({
spalten,
zeilen,
vonMinute,
bisMinute,
zeitbloecke,
termine,
aufgaben,
kalenderkontoId,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [gezogen, setGezogen] = useState<string | null>(null)
const sensoren = useSensors(
// Erst ab acht Pixeln gilt es als Ziehen – sonst löst jeder Klick einen
// Zug aus und Knöpfe im Block wären nicht mehr bedienbar.
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
)
const raster: Raster = {
spalten: spalten.map((s) => ({
tagesBeginn: new Date(s.tagesBeginnMs),
wochentag: 0,
schluessel: s.schluessel,
})),
zeilen,
vonMinute,
bisMinute,
}
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) toast.success(antwort.meldung)
else toast.error(antwort.fehler)
router.refresh()
})
}
function beimStart(ereignis: DragStartEvent) {
setGezogen(String(ereignis.active.id))
}
function beimEnde(ereignis: DragEndEvent) {
setGezogen(null)
const ziel = ereignis.over?.id
if (!ziel) return
const [schluessel, minuteRoh] = String(ziel).split('@')
if (!schluessel || minuteRoh === undefined) return
const beginn = zeitpunktAus(schluessel, Number(minuteRoh))
const kennung = String(ereignis.active.id)
if (kennung.startsWith('aufgabe:')) {
if (!kalenderkontoId) {
toast.error('Erst einen Kalender verbinden, dann lässt sich einplanen.')
return
}
const aufgabeId = kennung.slice('aufgabe:'.length)
const aufgabe = aufgaben.find((a) => a.id === aufgabeId)
const dauer = aufgabe?.aufwandMinuten ?? STANDARD_MINUTEN
const ende = new Date(beginn.getTime() + dauer * 60_000)
const daten = new FormData()
daten.set('aufgabeId', aufgabeId)
daten.set('kalenderkontoId', kalenderkontoId)
daten.set('beginn', beginn.toISOString())
daten.set('ende', ende.toISOString())
mitRueckmeldung(() => zeitblockAnlegen(daten))
return
}
if (kennung.startsWith('block:')) {
const id = kennung.slice('block:'.length)
const block = zeitbloecke.find((z) => z.id === id)
if (!block) return
const dauer = block.endeMs - block.beginnMs
const ende = new Date(beginn.getTime() + dauer)
const daten = new FormData()
daten.set('id', id)
daten.set('beginn', beginn.toISOString())
daten.set('ende', ende.toISOString())
mitRueckmeldung(() => zeitblockVerschieben(daten))
}
}
function ausplanen(id: string) {
const daten = new FormData()
daten.set('id', id)
mitRueckmeldung(() => zeitblockLoesen(daten))
}
const gezogeneAufgabe = gezogen?.startsWith('aufgabe:')
? aufgaben.find((a) => a.id === gezogen.slice('aufgabe:'.length))
: null
const gezogenerBlock = gezogen?.startsWith('block:')
? zeitbloecke.find((z) => z.id === gezogen.slice('block:'.length))
: null
return (
<DndContext sensors={sensoren} onDragStart={beimStart} onDragEnd={beimEnde}>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Vorrat: was noch eingeplant werden kann */}
<aside className="lg:w-72 lg:shrink-0">
<h2 className="mb-2 text-sm font-medium">
Einzuplanen
<span className="text-gedaempft-vordergrund ml-2 font-normal">
{aufgaben.length}
</span>
</h2>
{!kalenderkontoId && (
<p className="bg-warnung/15 text-warnung mb-3 rounded-md px-3 py-2 text-xs">
Ohne verbundenen Kalender lässt sich nichts einplanen.
</p>
)}
<ul className="border-rand bg-karte max-h-[32rem] divide-y divide-[var(--rand)] overflow-y-auto rounded-xl border">
{aufgaben.length === 0 ? (
<li className="text-gedaempft-vordergrund p-4 text-center text-sm">
Nichts offen. Ein guter Tag.
</li>
) : (
aufgaben.map((aufgabe) => (
<AufgabenKachel key={aufgabe.id} aufgabe={aufgabe} gesperrt={laeuft} />
))
)}
</ul>
</aside>
{/* Das Wochenraster */}
<div className="min-w-0 flex-1 overflow-x-auto">
<div className="min-w-[46rem]">
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
<div />
{spalten.map((spalte) => (
<div
key={spalte.schluessel}
className={cn(
'border-rand border-b px-1 pb-2 text-center',
spalte.heute && 'bg-betont/5',
)}
>
<p className="text-sm font-medium">{spalte.name}</p>
<p className="text-gedaempft-vordergrund text-xs">{spalte.datum}</p>
<p
className={cn(
'mt-1 text-xs',
spalte.ueberbucht ? 'text-gefahr' : 'text-gedaempft-vordergrund',
)}
>
{spalte.kapazitaet === 0
? '—'
: `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`}
</p>
</div>
))}
</div>
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
{/* Stundenleiste */}
<div className="relative">
{zeilen.map((minute) => (
<div
key={minute}
style={{ height: ZEILE_HOEHE }}
className="text-gedaempft-vordergrund pr-2 text-right text-[0.6875rem] leading-none"
>
{zeilenBeschriftung(minute)}
</div>
))}
</div>
{spalten.map((spalte) => {
const blockeHier = zeitbloecke.filter(
(z) =>
z.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
z.endeMs > spalte.tagesBeginnMs,
)
const termineHier = termine.filter(
(t) =>
!t.ganztaegig &&
t.beginnMs < spalte.tagesBeginnMs + 86_400_000 &&
t.endeMs > spalte.tagesBeginnMs,
)
const verteilteTermine = spurenVerteilen(
termineHier.map((t) => ({
...t,
beginn: new Date(t.beginnMs),
ende: new Date(t.endeMs),
})),
)
return (
<div
key={spalte.schluessel}
className={cn(
'border-rand relative border-l',
spalte.heute && 'bg-betont/5',
)}
>
{zeilen.map((minute) => (
<Zielfeld
key={minute}
kennung={`${spalte.schluessel}@${minute}`}
volleStunde={minute % 60 === 0}
/>
))}
{/* Fremde Termine liegen hinten und lassen sich nicht ziehen */}
{verteilteTermine.map(({ eintrag, spur, spuren }) => {
const lage = balkenlage(
eintrag.beginn,
eintrag.ende,
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
raster,
)
if (lage.ausserhalb) return null
return (
<div
key={eintrag.id}
className={cn(
'pointer-events-none absolute rounded-sm px-1 text-[0.6875rem] leading-tight',
eintrag.frei
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
: 'bg-gedaempft text-gedaempft-vordergrund',
// Gespiegeltes gehört sichtbar in eine eigene
// Klasse: gestreift, damit niemand darin einen
// Termin mit fehlendem Betreff vermutet.
eintrag.gespiegelt &&
'border-rand border border-dashed bg-[repeating-linear-gradient(45deg,transparent,transparent_4px,color-mix(in_oklch,currentColor_12%,transparent)_4px,color-mix(in_oklch,currentColor_12%,transparent)_8px)]',
)}
style={{
top: lage.oben,
height: lage.hoehe,
left: `${(spur / spuren) * 100}%`,
width: `${100 / spuren}%`,
}}
title={
eintrag.gespiegelt
? 'Dienstlich belegt – gespiegelt aus der anderen Instanz'
: `${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`
}
>
<span className="line-clamp-2">
{eintrag.gespiegelt
? 'Dienstlich belegt'
: (eintrag.betreff ?? 'Termin')}
</span>
</div>
)
})}
{/* Eigene Zeitblöcke liegen vorn und lassen sich ziehen */}
{blockeHier.map((block) => {
const lage = balkenlage(
new Date(block.beginnMs),
new Date(block.endeMs),
raster.spalten.find((s) => s.schluessel === spalte.schluessel)!,
raster,
)
if (lage.ausserhalb) return null
return (
<Zeitblockbalken
key={block.id}
block={block}
oben={lage.oben}
hoehe={lage.hoehe}
gesperrt={laeuft}
onAusplanen={() => ausplanen(block.id)}
/>
)
})}
</div>
)
})}
</div>
</div>
</div>
</div>
<DragOverlay>
{gezogeneAufgabe && (
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
{gezogeneAufgabe.titel}
<span className="ml-1 opacity-80">
{alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
</span>
</div>
)}
{gezogenerBlock && (
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
{gezogenerBlock.titel}
</div>
)}
</DragOverlay>
</DndContext>
)
}
function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) {
const { setNodeRef, isOver } = useDroppable({ id: kennung })
return (
<div
ref={setNodeRef}
style={{ height: ZEILE_HOEHE }}
className={cn(
'border-rand/40',
volleStunde ? 'border-t' : 'border-t border-dashed',
isOver && 'bg-betont/20',
)}
/>
)
}
function AufgabenKachel({
aufgabe,
gesperrt,
}: {
aufgabe: PlanbareAufgabe
gesperrt: boolean
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `aufgabe:${aufgabe.id}`,
disabled: gesperrt,
})
return (
<li
ref={setNodeRef}
className={cn('flex items-start gap-2 p-2', isDragging && 'opacity-40')}
>
<button
type="button"
className="text-gedaempft-vordergrund mt-0.5 cursor-grab touch-none active:cursor-grabbing"
aria-label={`${aufgabe.titel} zum Einplanen ziehen`}
{...listeners}
{...attributes}
>
<GripVertical className="size-4" aria-hidden />
</button>
<div className="min-w-0 flex-1">
<p className="text-sm leading-tight">{aufgabe.titel}</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
{aufgabe.bereichName && (
<Kennzeichen>
<span
aria-hidden
className="size-2 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: aufgabe.bereichFarbe ?? undefined }}
/>
{aufgabe.bereichName}
</Kennzeichen>
)}
<Kennzeichen>
{alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
</Kennzeichen>
{aufgabe.faelligText && (
<Kennzeichen art={aufgabe.ueberfaellig ? 'gefahr' : 'neutral'}>
{aufgabe.faelligText}
</Kennzeichen>
)}
{aufgabe.bereitsGeplant > 0 && (
<Kennzeichen art="betont">
<CalendarClock className="size-3" aria-hidden />
{aufgabe.bereitsGeplant}
</Kennzeichen>
)}
</div>
</div>
</li>
)
}
function Zeitblockbalken({
block,
oben,
hoehe,
gesperrt,
onAusplanen,
}: {
block: RasterZeitblock
oben: number
hoehe: number
gesperrt: boolean
onAusplanen: () => void
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `block:${block.id}`,
disabled: gesperrt,
})
const farbe = block.bereichFarbe ?? 'var(--betont)'
return (
<div
ref={setNodeRef}
style={{
top: oben,
height: hoehe,
borderLeftColor: farbe,
backgroundColor: `color-mix(in oklab, ${farbe} 18%, var(--karte))`,
}}
className={cn(
'absolute right-0.5 left-0.5 overflow-hidden rounded-sm border-l-4 px-1 text-[0.6875rem] leading-tight',
isDragging && 'opacity-40',
)}
title={`${block.titel} · ${alsUhrzeitAnzeige(new Date(block.beginnMs))}–${alsUhrzeitAnzeige(new Date(block.endeMs))} · ${block.kalenderName}`}
>
<div className="flex items-start gap-0.5">
<button
type="button"
className="shrink-0 cursor-grab touch-none active:cursor-grabbing"
aria-label={`${block.titel} verschieben`}
{...listeners}
{...attributes}
>
<GripVertical className="size-3 opacity-60" aria-hidden />
</button>
<span className="line-clamp-3 flex-1 font-medium">{block.titel}</span>
{(block.offen || block.letzterFehler) && (
<AlertTriangle
className="text-warnung size-3 shrink-0"
aria-label="Noch nicht im Kalender"
/>
)}
<Button
art="leise"
className="size-4 shrink-0 p-0"
disabled={gesperrt}
title="Ausplanen"
onClick={onAusplanen}
>
<X className="size-3" aria-hidden />
<span className="sr-only">Ausplanen</span>
</Button>
</div>
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auditSchreiben } from '@/server/audit'
import { verlangeAnmeldungFuerApi } from '@/server/berechtigung'
import { geraetAbmelden, geraetAnmelden } from '@/server/benachrichtigung/push'
import { umgebung } from '@/server/umgebung'
export const dynamic = 'force-dynamic'
/**
* An- und Abmelden eines Browsers für Push-Nachrichten.
*
* Der öffentliche VAPID-Schlüssel kommt über GET; er ist nicht geheim, wird
* aber nur an Angemeldete herausgegeben — es gibt keinen Grund, die
* Einrichtung nach außen zu zeigen.
*/
const anmeldung = z.object({
endpunkt: z.url(),
p256dh: z.string().min(1).max(500),
auth: z.string().min(1).max(500),
bezeichnung: z.string().trim().max(60).nullable().default(null),
})
export async function GET() {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
const einst = umgebung()
return NextResponse.json(
{
aktiv: einst.pushAktiv,
oeffentlicherSchluessel: einst.pushAktiv ? einst.VAPID_OEFFENTLICH : null,
},
{ headers: { 'Cache-Control': 'no-store' } },
)
}
export async function POST(anfrage: Request) {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
if (!umgebung().pushAktiv) {
return NextResponse.json(
{ fehler: 'Push ist auf diesem Server nicht eingerichtet.' },
{ status: 400 },
)
}
let inhalt: unknown
try {
inhalt = await anfrage.json()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const gelesen = anmeldung.safeParse(inhalt)
if (!gelesen.success) {
return NextResponse.json(
{ fehler: 'Die Anmeldung war unvollständig.' },
{ status: 400 },
)
}
const geraet = await geraetAnmelden(
benutzer.id,
{
endpunkt: gelesen.data.endpunkt,
p256dh: gelesen.data.p256dh,
auth: gelesen.data.auth,
},
gelesen.data.bezeichnung,
)
await auditSchreiben({
akteur: { id: benutzer.id, email: benutzer.email },
aktion: 'push.angemeldet',
objektTyp: 'PushGeraet',
objektId: geraet.id,
// Weder Endpunkt noch Schlüssel – beides taugt zum Zustellen.
details: { bezeichnung: gelesen.data.bezeichnung },
})
return NextResponse.json({ ok: true })
}
const abmeldung = z.object({ endpunkt: z.url() })
export async function DELETE(anfrage: Request) {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
let inhalt: unknown
try {
inhalt = await anfrage.json()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const gelesen = abmeldung.safeParse(inhalt)
if (!gelesen.success) {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
await geraetAbmelden(gelesen.data.endpunkt)
await auditSchreiben({
akteur: { id: benutzer.id, email: benutzer.email },
aktion: 'push.abgemeldet',
objektTyp: 'PushGeraet',
objektId: null,
})
return NextResponse.json({ ok: true })
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { auditSchreiben } from '@/server/audit'
import { sendungAufnehmen, sendungPruefen } from '@/server/integration/spiegel'
import { jetzt } from '@/server/zeitgeber'
export const dynamic = 'force-dynamic'
/**
* Der Eingang des Spiegels — nur in der Instanz `daheim`.
*
* Hier meldet sich kein Mensch an: Die Gegenstelle weist sich über eine
* Signatur aus, die sie mit dem gemeinsamen Geheimnis über den Rumpf bildet.
*
* Jede Zurückweisung sieht gleich aus. Ob das Geheimnis falsch war, die Uhr
* verstellt oder der Inhalt Unsinn — von außen ist das nicht zu unterscheiden,
* und genau so soll es sein.
*/
export async function POST(anfrage: Request) {
// Der Rumpf muss als Text gelesen werden: Die Signatur gilt für genau diese
// Zeichen, nicht für das, was ein JSON-Parser daraus und wieder zurück macht.
const koerper = await anfrage.text()
const signatur = anfrage.headers.get('X-Planer-Signatur')
const geprueft = sendungPruefen(koerper, signatur, jetzt())
if (!geprueft.ok) {
return NextResponse.json({ fehler: 'Abgelehnt.' }, { status: 401 })
}
const bericht = await sendungAufnehmen(geprueft.sendung, {
id: null,
email: 'spiegel@intern',
})
await auditSchreiben({
akteur: { id: null, email: 'spiegel@intern' },
aktion: 'spiegel.empfangen',
objektTyp: 'Spiegelbelegung',
objektId: null,
// Nur Zahlen: Was gespiegelt wurde, steht in den Datensätzen selbst.
details: bericht,
})
return NextResponse.json({ ok: true, ...bericht })
}
@@ -14,6 +14,8 @@ type Eigenschaften = {
leerText: string leerText: string
mitEinsortieren?: boolean mitEinsortieren?: boolean
mitUebernehmen?: boolean mitUebernehmen?: boolean
/** Schalter zum Spiegeln zeigen; nur auf der sendenden Instanz sinnvoll. */
mitSpiegeln?: boolean
/** Bearbeiten direkt in der Liste erlauben. */ /** Bearbeiten direkt in der Liste erlauben. */
mitBearbeiten?: boolean mitBearbeiten?: boolean
} }
@@ -25,6 +27,7 @@ export function AufgabenListe({
leerText, leerText,
mitEinsortieren = false, mitEinsortieren = false,
mitUebernehmen = false, mitUebernehmen = false,
mitSpiegeln = false,
mitBearbeiten = true, mitBearbeiten = true,
}: Eigenschaften) { }: Eigenschaften) {
const [bearbeitet, setBearbeitet] = useState<string | null>(null) const [bearbeitet, setBearbeitet] = useState<string | null>(null)
@@ -60,6 +63,7 @@ export function AufgabenListe({
bereiche={bereiche} bereiche={bereiche}
mitEinsortieren={mitEinsortieren} mitEinsortieren={mitEinsortieren}
mitUebernehmen={mitUebernehmen} mitUebernehmen={mitUebernehmen}
mitSpiegeln={mitSpiegeln}
{...(mitBearbeiten {...(mitBearbeiten
? { onBearbeiten: () => setBearbeitet(aufgabe.id) } ? { onBearbeiten: () => setBearbeitet(aufgabe.id) }
: {})} : {})}
+36 -1
View File
@@ -3,7 +3,15 @@
import { useState, useTransition } from 'react' import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { Check, ExternalLink, Loader2, Pencil, RotateCcw, Trash2 } from 'lucide-react' import {
Check,
ExternalLink,
Loader2,
Pencil,
RotateCcw,
Share2,
Trash2,
} from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Kennzeichen } from '@/components/ui/kennzeichen' import { Kennzeichen } from '@/components/ui/kennzeichen'
@@ -22,6 +30,7 @@ import {
aufgabeLoeschen, aufgabeLoeschen,
einsortieren, einsortieren,
ideeUebernehmen, ideeUebernehmen,
spiegelnUmschalten,
statusSetzen, statusSetzen,
type Rueckmeldung, type Rueckmeldung,
} from '@/server/aktionen/aufgaben' } from '@/server/aktionen/aufgaben'
@@ -36,6 +45,8 @@ type Eigenschaften = {
mitEinsortieren?: boolean mitEinsortieren?: boolean
/** Zeigt den Knopf "Zur Aufgabe machen" (Ideen). */ /** Zeigt den Knopf "Zur Aufgabe machen" (Ideen). */
mitUebernehmen?: boolean mitUebernehmen?: boolean
/** Zeigt den Schalter zum Spiegeln; nur auf der sendenden Instanz. */
mitSpiegeln?: boolean
onBearbeiten?: (aufgabe: AufgabeAnzeige) => void onBearbeiten?: (aufgabe: AufgabeAnzeige) => void
} }
@@ -44,6 +55,7 @@ export function AufgabenZeile({
bereiche = [], bereiche = [],
mitEinsortieren = false, mitEinsortieren = false,
mitUebernehmen = false, mitUebernehmen = false,
mitSpiegeln = false,
onBearbeiten, onBearbeiten,
}: Eigenschaften) { }: Eigenschaften) {
const router = useRouter() const router = useRouter()
@@ -198,6 +210,29 @@ export function AufgabenZeile({
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{mitSpiegeln && (
<Button
art="leise"
groesse="quadrat"
title={
aufgabe.spiegeln
? 'Geht mit nach Hause – hier abschalten'
: 'Mit nach Hause spiegeln'
}
disabled={laeuft}
onClick={() => {
const daten = new FormData()
daten.set('id', aufgabe.id)
mitRueckmeldung(() => spiegelnUmschalten(daten))
}}
>
<Share2
className={aufgabe.spiegeln ? 'text-betont' : undefined}
aria-hidden
/>
<span className="sr-only">Spiegeln</span>
</Button>
)}
{onBearbeiten && ( {onBearbeiten && (
<Button <Button
art="leise" art="leise"
+9 -1
View File
@@ -30,10 +30,12 @@ export function Kopfleiste({
{ pfad: '/', name: 'Start' }, { pfad: '/', name: 'Start' },
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox }, { pfad: '/inbox', name: 'Inbox', zaehler: inbox },
{ pfad: '/aufgaben', name: 'Aufgaben' }, { pfad: '/aufgaben', name: 'Aufgaben' },
{ pfad: '/woche', name: 'Woche' },
{ pfad: '/projekte', name: 'Projekte' }, { pfad: '/projekte', name: 'Projekte' },
{ pfad: '/ideen', name: 'Ideen' }, { pfad: '/ideen', name: 'Ideen' },
{ pfad: '/bereiche', name: 'Bereiche' }, { pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/kalenderkonten', name: 'Kalender' }, { pfad: '/kalenderkonten', name: 'Kalender' },
{ pfad: '/integrationen', name: 'Integrationen' },
{ pfad: '/einstellungen', name: 'Einstellungen' }, { pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true }, { pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
] ]
@@ -48,7 +50,13 @@ export function Kopfleiste({
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
die zweite Zeile zu drängen. die zweite Zeile zu drängen.
*/} */}
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:gap-6"> {/*
Breiter als die Inhaltsspalte (max-w-5xl): Mit dem elften Eintrag
passte die Leiste dort nicht mehr, und "Einstellungen" verschwand
stillschweigend am rechten Rand. Eine Leiste, die Punkte verschluckt,
ist schlimmer als eine, die nicht buendig mit dem Text steht.
*/}
<div className="mx-auto flex w-full max-w-7xl items-center gap-3 px-4 py-3 sm:gap-5">
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight"> <Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
Planer Planer
</Link> </Link>
+1
View File
@@ -16,6 +16,7 @@ function roh(teil: Partial<AufgabeRoh> = {}): AufgabeRoh {
faelligAm: null, faelligAm: null,
quelle: 'MANUELL', quelle: 'MANUELL',
quelleUrl: null, quelleUrl: null,
spiegeln: false,
bereich: null, bereich: null,
projekt: null, projekt: null,
...teil, ...teil,
+4
View File
@@ -26,6 +26,7 @@ export type AufgabeRoh = {
faelligAm: Date | null faelligAm: Date | null
quelle: Quelle quelle: Quelle
quelleUrl: string | null quelleUrl: string | null
spiegeln: boolean
bereich: { id: string; name: string; farbe: string } | null bereich: { id: string; name: string; farbe: string } | null
projekt: { id: string; name: string } | null projekt: { id: string; name: string } | null
} }
@@ -48,6 +49,8 @@ export type AufgabeAnzeige = {
ueberfaellig: boolean ueberfaellig: boolean
quelle: Quelle quelle: Quelle
quelleUrl: string | null quelleUrl: string | null
/** Ausdruecklich zum Spiegeln freigegeben. */
spiegeln: boolean
bereich: { id: string; name: string; farbe: string } | null bereich: { id: string; name: string; farbe: string } | null
projekt: { id: string; name: string } | null projekt: { id: string; name: string } | null
} }
@@ -79,6 +82,7 @@ export function fuerAnzeige(aufgabe: AufgabeRoh, jetzt: Date): AufgabeAnzeige {
ueberfaellig: istUeberfaellig(aufgabe.faelligAm, aufgabe.status, jetzt), ueberfaellig: istUeberfaellig(aufgabe.faelligAm, aufgabe.status, jetzt),
quelle: aufgabe.quelle, quelle: aufgabe.quelle,
quelleUrl: aufgabe.quelleUrl, quelleUrl: aufgabe.quelleUrl,
spiegeln: aufgabe.spiegeln,
bereich: aufgabe.bereich, bereich: aufgabe.bereich,
projekt: aufgabe.projekt, projekt: aufgabe.projekt,
} }

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