Compare commits
@@ -59,6 +59,67 @@ WEBAUTHN_RP_NAME=Planer
|
|||||||
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
|
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
|
||||||
WEBAUTHN_ORIGIN=http://localhost:3000
|
WEBAUTHN_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
# --- Kalenderanbieter ------------------------------------------------------
|
||||||
|
# Graph gibt es in beiden Instanzen, Google nur daheim. Ein Anbieter erscheint
|
||||||
|
# erst, wenn Kennung UND Geheimnis gesetzt sind.
|
||||||
|
# Einrichtung siehe README, Abschnitt "Kalenderanbieter einrichten".
|
||||||
|
# Rueckleit-URIs:
|
||||||
|
# <APP_URL>/api/kalender/rueckleitung/graph
|
||||||
|
# <APP_URL>/api/kalender/rueckleitung/google
|
||||||
|
GRAPH_CLIENT_ID=
|
||||||
|
GRAPH_CLIENT_SECRET=
|
||||||
|
# Tenant-ID, oder "organizations" fuer jeden Arbeitsplatz-Tenant.
|
||||||
|
GRAPH_TENANT_ID=organizations
|
||||||
|
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ test-results
|
|||||||
playwright-report
|
playwright-report
|
||||||
coverage
|
coverage
|
||||||
devlog/zeiten.csv
|
devlog/zeiten.csv
|
||||||
|
public/sw.js
|
||||||
|
|||||||
@@ -6,6 +6,202 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.7.0] – 2026-09-13
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Schnellerfassung vom Handy** unter _Erfassen_: ein Feld, ein Knopf. Was
|
||||||
|
ohne Netz erfasst wird, bleibt im Gerät und geht hinaus, sobald wieder eine
|
||||||
|
Verbindung da ist — mit einer Kennung je Erfassung, damit eine doppelt
|
||||||
|
zugestellte Nachricht keine zweite Aufgabe ergibt.
|
||||||
|
- Der Planer lässt sich als **App auf dem Startbildschirm ablegen**
|
||||||
|
(Manifest, eigenes Symbol, eigener Startpunkt).
|
||||||
|
- **Öffentliche Schnittstelle** unter `/api/v1`: Aufgaben lesen, anlegen,
|
||||||
|
ändern und abhaken. Beschreibung in [docs/API.md](docs/API.md).
|
||||||
|
- **Tokens je Anwendung** mit Rechten je Token, wahlweise mit Ablauf,
|
||||||
|
verwaltbar unter _API_.
|
||||||
|
- Handbuchseite zur Schnellerfassung.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Von einem Token liegt nur der Fingerabdruck in der Datenbank. Der Wert
|
||||||
|
erscheint genau einmal — beim Anlegen.
|
||||||
|
- Die drei Rechte schließen einander nicht ein: Ein Dienst, der nur erfasst,
|
||||||
|
kann den Bestand nicht lesen.
|
||||||
|
- Jeder Fehlschlag beim Ausweisen sieht gleich aus. Ob ein Token unbekannt,
|
||||||
|
abgelaufen oder zurückgezogen ist, verrät die Schnittstelle nicht.
|
||||||
|
- Jede schreibende Nutzung steht im Protokoll, mitsamt dem Token — auch jeder
|
||||||
|
abgewiesene Zugriff.
|
||||||
|
- Die Schnittstelle kann weder löschen noch Bereiche, Projekte, Zeitblöcke
|
||||||
|
oder Kalender anfassen.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- Kalenderkonten für Microsoft 365 (Graph) und Google verbinden, umbenennen,
|
||||||
|
von Hand abgleichen und trennen. Google gibt es nur in der Instanz
|
||||||
|
`daheim`; in `firma` liefert die Route 404.
|
||||||
|
- Termine werden örtlich gespiegelt: 7 Tage zurück bis 90 Tage voraus.
|
||||||
|
Gelesen werden nur Beginn, Ende, Betreff, ganztägig und "zeigt an als" —
|
||||||
|
keine Teilnehmer, Beschreibungen, Anhänge oder Orte.
|
||||||
|
- Die Startseite zeigt je Bereich, wie voll der heutige Tag ist: Termine aus
|
||||||
|
allen verbundenen Kalendern gegen die Tageskapazität.
|
||||||
|
- Hintergrundjobs: alle 15 Minuten abgleichen, nachts alte Termine und
|
||||||
|
abgelaufene WebAuthn-Herausforderungen entfernen.
|
||||||
|
- Handbuchseite zu den Kalenderkonten, Einrichtungsanleitung für Entra ID
|
||||||
|
und die Google Cloud Console in der README.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Zugangs- und Aktualisierungstoken liegen verschlüsselt in der Datenbank
|
||||||
|
(AES-256-GCM, Schlüssel aus AUTH_SECRET abgeleitet). Wer eine Sicherung in
|
||||||
|
die Hände bekommt, kann damit ohne AUTH_SECRET nichts anfangen.
|
||||||
|
- Der Zustandswert im OAuth-Ablauf ist signiert und trägt die
|
||||||
|
Benutzerkennung; ohne das ließe sich ein fremdes Kalenderkonto an ein
|
||||||
|
beliebiges Konto hängen. Er verfällt nach 30 Minuten.
|
||||||
|
- Fehlermeldungen nennen nie den Antwortkörper des Token-Endpunkts — der
|
||||||
|
enthält das Client-Secret oft im Echo.
|
||||||
|
- Protokolliert werden beim Abgleich nur Zahlen, nie Kontonamen oder
|
||||||
|
Termininhalte.
|
||||||
|
|
||||||
|
## [0.2.0] – 2026-09-10
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- Aufgaben mit Typ, Status, Priorität, Aufwand, Fälligkeit und Herkunft;
|
||||||
|
Projekte als Klammer darum.
|
||||||
|
- Schnellerfassung mit einem einzigen Feld auf der Startseite und in der Inbox.
|
||||||
|
- Inbox: alles ohne Bereich sammelt sich dort und wird später einsortiert.
|
||||||
|
Die Zahl im Menü zeigt, wie viel noch liegt.
|
||||||
|
- Aufgabenliste mit Filtern nach Suche, Bereich, Status, Art und Projekt.
|
||||||
|
Die Filter stehen in der Adresse und lassen sich als Lesezeichen ablegen.
|
||||||
|
- Ideen als eigene Ansicht, ohne Termin und ohne Aufwand; per Klick wird
|
||||||
|
daraus eine Aufgabe.
|
||||||
|
- Projekte mit Arbeitspaketen, Meilensteinen und Fortschritt.
|
||||||
|
- Die Startseite zeigt, was überfällig, heute oder morgen fällig ist.
|
||||||
|
- Handbuchseiten zu Aufgaben, Ideen und Projekten.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Die Startseite zählt nicht mehr nur Bereiche auf, sondern stellt voran,
|
||||||
|
was jetzt dran ist.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Statuswechsel folgen festen Übergängen: Abgeschlossenes wird erst wieder
|
||||||
|
geöffnet. Ein abweichender Wert im Formular führt zu einer Meldung, nicht
|
||||||
|
zu einem stillen Zustandssprung.
|
||||||
|
- Angegebene Bereiche und Projekte werden vor dem Speichern geprüft; ein
|
||||||
|
erfundener Verweis gibt eine Meldung statt eines Datenbankfehlers.
|
||||||
|
|
||||||
## [0.1.0] – 2026-09-10
|
## [0.1.0] – 2026-09-10
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
|
|||||||
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
||||||
wird.
|
wird.
|
||||||
|
|
||||||
> **Stand:** Meilenstein 1 von 8. Die Grundlage steht: Anmeldung, Rollen,
|
> **Stand:** Meilenstein 4 von 8. Die Kernidee steht: Aufgaben lassen sich
|
||||||
> Protokoll, Bereiche und der Betrieb. Aufgaben kommen mit M2, das Lesen der
|
> per Ziehen und Ablegen als Zeitblöcke in den Kalender schreiben, und
|
||||||
> Kalender mit M3, die Wochenplanung mit M4. Der [Fahrplan](ROADMAP.md) sagt,
|
> Verschiebungen von dort kommen zurück. Es folgen Benachrichtigungen,
|
||||||
> was wann folgt.
|
> Integrationen, Schnellerfassung vom Handy und ein MCP-Server. Der
|
||||||
|
> [Fahrplan](ROADMAP.md) sagt, was wann folgt.
|
||||||
|
|
||||||
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
||||||
zur ersten Anmeldung
|
zur ersten Anmeldung
|
||||||
@@ -22,6 +23,26 @@ wird.
|
|||||||
|
|
||||||
### Heute
|
### Heute
|
||||||
|
|
||||||
|
- **Schnellerfassung** mit einem einzigen Feld; alles ohne Bereich sammelt
|
||||||
|
sich in der **Inbox** und wird später einsortiert
|
||||||
|
- **Aufgaben** mit Typ, Status, Priorität, Aufwand und Fälligkeit, sortiert
|
||||||
|
nach Dringlichkeit und filterbar über die Adresszeile
|
||||||
|
- **Ideen** getrennt von Aufgaben — ohne Termin, per Klick übernehmbar
|
||||||
|
- **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt
|
||||||
|
- **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je
|
||||||
|
Bereich, wie voll der heutige Tag schon ist
|
||||||
|
- **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
|
||||||
|
- **Schnellerfassung vom Handy**, die auch im Funkloch nichts verliert; der
|
||||||
|
Planer lässt sich als App auf dem Startbildschirm ablegen
|
||||||
|
- **Öffentliche Schnittstelle** mit Tokens je Anwendung, Rechten je Token und
|
||||||
|
jeder schreibenden Nutzung im Protokoll ([Beschreibung](docs/API.md))
|
||||||
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
||||||
Passwort — je Instanz einzeln zuschaltbar
|
Passwort — je Instanz einzeln zuschaltbar
|
||||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||||
@@ -32,18 +53,24 @@ wird.
|
|||||||
|
|
||||||
### Geplant
|
### Geplant
|
||||||
|
|
||||||
Aufgaben und Projekte, Kalender lesen und schreiben, Wochenplanung per Ziehen
|
Ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
und Ablegen, Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und
|
|
||||||
ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
|
||||||
|
|
||||||
## Bildergalerie
|
## Bildergalerie
|
||||||
|
|
||||||
| | Hell | Dunkel |
|
| | Hell | Dunkel |
|
||||||
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
| ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||||
| Anmelden |  |  |
|
| Anmelden |  |  |
|
||||||
| Start |  |  |
|
| Start |  |  |
|
||||||
|
| Inbox |  |  |
|
||||||
|
| Woche |  |  |
|
||||||
|
| Aufgaben |  |  |
|
||||||
|
| Projekte |  |  |
|
||||||
|
| Ideen |  |  |
|
||||||
|
| Kalender |  |  |
|
||||||
| Bereiche |  |  |
|
| Bereiche |  |  |
|
||||||
| Einstellungen |  |  |
|
| Einstellungen |  |  |
|
||||||
|
| Meldungen |  |  |
|
||||||
|
| Integrationen |  |  |
|
||||||
| Protokoll |  |  |
|
| Protokoll |  |  |
|
||||||
|
|
||||||
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
||||||
@@ -107,6 +134,89 @@ Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example).
|
|||||||
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
|
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
|
||||||
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
||||||
|
|
||||||
|
### Kalenderanbieter
|
||||||
|
|
||||||
|
| Variable | Standard | Bedeutung |
|
||||||
|
| ---------------------- | --------------- | ------------------------------------------------------------- |
|
||||||
|
| `GRAPH_CLIENT_ID` | _(leer)_ | Anwendungs-ID der App-Registrierung in Entra ID |
|
||||||
|
| `GRAPH_CLIENT_SECRET` | _(leer)_ | Geheimnis dieser Registrierung |
|
||||||
|
| `GRAPH_TENANT_ID` | `organizations` | Tenant-ID, oder `organizations` für jeden Arbeitsplatz-Tenant |
|
||||||
|
| `GOOGLE_CLIENT_ID` | _(leer)_ | OAuth-Client-ID aus der Google Cloud Console |
|
||||||
|
| `GOOGLE_CLIENT_SECRET` | _(leer)_ | Geheimnis dieses Clients |
|
||||||
|
|
||||||
|
Ein Anbieter erscheint erst, wenn Kennung **und** Geheimnis gesetzt sind —
|
||||||
|
und nur, wenn die Instanz ihn erlaubt (Google nur `daheim`).
|
||||||
|
|
||||||
|
### 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 |
|
||||||
@@ -116,6 +226,59 @@ Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
|||||||
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
|
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
|
||||||
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
|
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
|
||||||
|
|
||||||
|
## Kalenderanbieter einrichten
|
||||||
|
|
||||||
|
Beides ist einmalige Handarbeit beim Anbieter. Der Planer fragt Lese- **und**
|
||||||
|
Schreibrechte an: ab M4 schreibt er Zeitblöcke zurück, und eine zweite
|
||||||
|
Zustimmungsrunde für jedes Konto wäre lästiger als eine etwas breitere
|
||||||
|
Berechtigung von Anfang an.
|
||||||
|
|
||||||
|
### Microsoft 365 (Entra ID)
|
||||||
|
|
||||||
|
1. <https://entra.microsoft.com> → **Identität → Anwendungen →
|
||||||
|
App-Registrierungen → Neue Registrierung**.
|
||||||
|
2. Name frei wählen, etwa „Planer".
|
||||||
|
3. Unterstützte Kontotypen: **Nur Konten in diesem Organisationsverzeichnis**
|
||||||
|
(dann `GRAPH_TENANT_ID` auf die Tenant-ID setzen) oder
|
||||||
|
**Konten in einem beliebigen Organisationsverzeichnis** (dann
|
||||||
|
`organizations` belassen).
|
||||||
|
4. Umleitungs-URI, Plattform **Web**:
|
||||||
|
`<APP_URL>/api/kalender/rueckleitung/graph`
|
||||||
|
5. Nach dem Anlegen: **Anwendungs-ID (Client)** nach `GRAPH_CLIENT_ID`,
|
||||||
|
**Verzeichnis-ID (Mandant)** nach `GRAPH_TENANT_ID`.
|
||||||
|
6. **Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel**. Den Wert
|
||||||
|
sofort nach `GRAPH_CLIENT_SECRET` kopieren — er wird nur einmal angezeigt.
|
||||||
|
7. **API-Berechtigungen → Berechtigung hinzufügen → Microsoft Graph →
|
||||||
|
Delegierte Berechtigungen**: `Calendars.ReadWrite`, `offline_access`,
|
||||||
|
`openid`, `email`.
|
||||||
|
|
||||||
|
> Ist im Tenant die Zustimmung durch Endbenutzer abgeschaltet, muss ein
|
||||||
|
> Administrator einmalig **Administratorzustimmung erteilen** klicken.
|
||||||
|
|
||||||
|
### Google Kalender
|
||||||
|
|
||||||
|
1. <https://console.cloud.google.com> → Projekt anlegen oder wählen.
|
||||||
|
2. **APIs & Dienste → Bibliothek** → _Google Calendar API_ aktivieren.
|
||||||
|
3. **OAuth-Zustimmungsbildschirm** einrichten. Für den Eigengebrauch genügt
|
||||||
|
_Extern_ mit Veröffentlichungsstatus _Test_ und der eigenen Adresse als
|
||||||
|
Testbenutzer.
|
||||||
|
4. Bereiche (Scopes) hinzufügen:
|
||||||
|
`.../auth/calendar.events` und `.../auth/calendar.readonly`.
|
||||||
|
5. **Anmeldedaten → Anmeldedaten erstellen → OAuth-Client-ID**,
|
||||||
|
Anwendungstyp **Webanwendung**.
|
||||||
|
6. Autorisierter Weiterleitungs-URI:
|
||||||
|
`<APP_URL>/api/kalender/rueckleitung/google`
|
||||||
|
7. Client-ID und Clientschlüssel nach `GOOGLE_CLIENT_ID` und
|
||||||
|
`GOOGLE_CLIENT_SECRET`.
|
||||||
|
|
||||||
|
> Bleibt das Projekt im Status _Test_, laufen die Aktualisierungstoken nach
|
||||||
|
> sieben Tagen ab und die Konten müssen neu verbunden werden. Für den
|
||||||
|
> Dauerbetrieb den Zustimmungsbildschirm **veröffentlichen**.
|
||||||
|
|
||||||
|
Nach dem Eintragen: `docker compose up -d` und im Planer unter **Kalender**
|
||||||
|
verbinden. Die Anleitung für den Alltag steht im
|
||||||
|
[Handbuch](docs/handbuch/kalenderkonten.md).
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
### Starten und stoppen
|
### Starten und stoppen
|
||||||
|
|||||||
@@ -4,20 +4,22 @@ Acht Meilensteine. Jeder endet mit einer lauffähigen Version auf `main`,
|
|||||||
grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
||||||
|
|
||||||
| Meilenstein | Ziel | Status |
|
| Meilenstein | Ziel | Status |
|
||||||
| --------------------------------------- | ---------------------------- | --------- |
|
| --------------------------------------- | ---------------------------- | -------- |
|
||||||
| [M1](#m1--fundament) | Fundament | in Arbeit |
|
| [M1](#m1--fundament) | Fundament | erledigt |
|
||||||
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | geplant |
|
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | erledigt |
|
||||||
| [M3](#m3--kalender-lesen) | Kalender lesen | geplant |
|
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
|
||||||
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant |
|
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | 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 | erledigt |
|
||||||
| [M8](#m8--mcp) | MCP | geplant |
|
| [M8](#m8--mcp) | MCP | geplant |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M1 – Fundament
|
## M1 – Fundament
|
||||||
|
|
||||||
|
**Erledigt am 10.09.2026 mit Version 0.1.0.**
|
||||||
|
|
||||||
**Ziel:** Ein angemeldeter, betreibbarer Rumpf. Noch keine Aufgaben, kein
|
**Ziel:** Ein angemeldeter, betreibbarer Rumpf. Noch keine Aufgaben, kein
|
||||||
Kalender — aber alles, worauf die späteren Meilensteine aufsetzen.
|
Kalender — aber alles, worauf die späteren Meilensteine aufsetzen.
|
||||||
|
|
||||||
@@ -43,58 +45,114 @@ wirklich braucht, statt heute geraten.
|
|||||||
|
|
||||||
## M2 – Aufgaben und Projekte
|
## M2 – Aufgaben und Projekte
|
||||||
|
|
||||||
|
**Erledigt am 10.09.2026 mit Version 0.2.0.**
|
||||||
|
|
||||||
**Ziel:** Alles erfassen und wiederfinden, noch ohne Kalender.
|
**Ziel:** Alles erfassen und wiederfinden, noch ohne Kalender.
|
||||||
|
|
||||||
- [ ] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle
|
- [x] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle
|
||||||
- [ ] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten
|
- [x] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten
|
||||||
- [ ] Inbox: schnell erfassen, später einsortieren
|
- [x] Inbox: schnell erfassen, später einsortieren
|
||||||
- [ ] Ideen getrennt von Aufgaben
|
- [x] Ideen getrennt von Aufgaben
|
||||||
- [ ] Projekte mit Arbeitspaketen und Meilensteinen
|
- [x] Projekte mit Arbeitspaketen und Meilensteinen
|
||||||
- [ ] Listen mit Filtern nach Bereich, Status und Fälligkeit
|
- [x] Listen mit Filtern nach Bereich, Status und Fälligkeit
|
||||||
- [ ] Handbuchseiten und Bilder
|
- [x] Handbuchseiten und Bilder
|
||||||
|
|
||||||
|
**Bewusst offen gelassen:** Kein Wiederholungsrhythmus für Aufgaben und keine
|
||||||
|
Verknüpfung von Aufgaben untereinander — beides steht unter „Ideen für
|
||||||
|
später" und wartet darauf, ob es im Alltag wirklich fehlt.
|
||||||
|
|
||||||
## M3 – Kalender lesen
|
## M3 – Kalender lesen
|
||||||
|
|
||||||
|
**Erledigt am 10.09.2026 mit Version 0.3.0.**
|
||||||
|
|
||||||
**Ziel:** Termine aus Microsoft 365 und Google sehen.
|
**Ziel:** Termine aus Microsoft 365 und Google sehen.
|
||||||
|
|
||||||
- [ ] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
- [x] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
||||||
- [ ] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
- [x] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
||||||
- [ ] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
- [x] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
||||||
- [ ] Ganztägige Termine zählen nicht zur Belegung
|
- [x] Ganztägige Termine zählen nicht zur Belegung
|
||||||
- [ ] Anonymisierte Beispielantworten in `test/fixtures/`
|
- [x] Anonymisierte Beispielantworten in `test/fixtures/`
|
||||||
- [ ] Hintergrundjob mit node-cron
|
- [x] Hintergrundjob mit node-cron
|
||||||
|
|
||||||
## M4 – Wochenplanung und Zeitblöcke
|
## M4 – Wochenplanung und Zeitblöcke
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
- [ ] Erfassung vom Handy, auch offline
|
**Erledigt am 12.09.2026 mit Version 0.7.0.**
|
||||||
- [ ] Öffentliche API mit Token je Anwendung
|
|
||||||
- [ ] Rechte je Token, jede Nutzung im Protokoll
|
**Ziel:** Etwas festhalten können, wo man gerade steht — und andere
|
||||||
|
Anwendungen hereinlassen, ohne ihnen die Schlüssel zum ganzen Haus zu geben.
|
||||||
|
|
||||||
|
- [x] Erfassung vom Handy, auch offline
|
||||||
|
- [x] Öffentliche API mit Token je Anwendung
|
||||||
|
- [x] Rechte je Token, jede schreibende Nutzung im Protokoll
|
||||||
|
- [x] Als App ablegbar (Manifest, eigenes Symbol)
|
||||||
|
|
||||||
|
**Offen geblieben:** Die Bilder der Erfassungsseite im Handbuch zeigen noch
|
||||||
|
eine falsche Inbox-Zahl; der Fehler selbst ist behoben, nur der Bildlauf
|
||||||
|
kam danach nicht mehr durch.
|
||||||
|
|
||||||
|
**Bewusst offen gelassen:** Die Schnittstelle kann keine Bereiche, Projekte
|
||||||
|
oder Zeitblöcke anfassen und nichts löschen. Was von außen kommt, landet in
|
||||||
|
der Inbox; die Einordnung bleibt bei dem, der plant. Auch eine Begrenzung der
|
||||||
|
Aufrufzahl gibt es nicht — bei einer Handvoll eigener Tokens wäre sie mehr
|
||||||
|
Zeremonie als Schutz.
|
||||||
|
|
||||||
## M8 – MCP
|
## M8 – MCP
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,571 @@ 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 |
|
||||||
| **Gesamt** | | **161** | **2,68** |
|
| M2 | claude | 44 | 0,73 |
|
||||||
|
| M3 | claude | 41 | 0,68 |
|
||||||
|
| M4 | claude | 167 | 2,78 |
|
||||||
|
| M5 | claude | 420 | 7,00 |
|
||||||
|
| M6 | claude | 733 | 12,22 |
|
||||||
|
| M7 | claude | 886 | 14,77 |
|
||||||
|
| **Gesamt** | | **2452** | **40,87** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-13 – M7: Schnellerfassung und API
|
||||||
|
|
||||||
|
**Dauer:** 886 Minuten (10:15–01:01), Art `claude`.
|
||||||
|
|
||||||
|
> Wie schon bei M6 sagt die Zahl mehr über den Rechner als über die Arbeit:
|
||||||
|
> Fünf hängende Docker-Bauten, einer davon mit einem einzelnen Schritt über
|
||||||
|
> drei Stunden. Die Bildaufnahme musste achtmal angestoßen werden.
|
||||||
|
|
||||||
|
**Ziel der Sitzung:** Etwas festhalten können, wo man gerade steht — und
|
||||||
|
andere Anwendungen hereinlassen, ohne ihnen die Schlüssel zum ganzen Haus zu
|
||||||
|
geben.
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- Regeln der Schnittstelle als reine Logik: Rechte, Tokenaufbau, Eingaben
|
||||||
|
(30 Tests)
|
||||||
|
- `/api/v1`: Aufgaben lesen, anlegen, ändern, abhaken
|
||||||
|
- Tokens mit Rechten und wahlweise Ablauf, verwaltbar in der Anwendung
|
||||||
|
- Schnellerfassung fürs Handy mit Warteschlange in IndexedDB
|
||||||
|
- Manifest und Symbol: der Planer lässt sich als App ablegen
|
||||||
|
- 10 neue E2E-Tests, 6 neue Integrationsprüfungen, Handbuchseite, API-Doku
|
||||||
|
|
||||||
|
### Entscheidungen mit Begründung
|
||||||
|
|
||||||
|
**Von einem Token liegt nur der Hash in der Datenbank.** Ein Token ist ein
|
||||||
|
Passwort; es gibt keinen Grund, es zurücklesen zu können, und jeden Grund,
|
||||||
|
es nicht zu können. SHA-256 ohne Salz ist hier richtig — der Wert hat 32
|
||||||
|
Byte Zufall, gegen so etwas hilft kein Wörterbuch, und die Prüfung muss bei
|
||||||
|
jeder Anfrage schnell sein.
|
||||||
|
|
||||||
|
**Kein Recht schließt ein anderes ein.** „Erfassen" darf nicht lesen. Ein
|
||||||
|
Dienst, der nur meldet „hier ist etwas Neues", braucht den Bestand nicht zu
|
||||||
|
sehen — und das ist der häufigste Fall.
|
||||||
|
|
||||||
|
**Der sichtbare Anfang heißt `planer_`.** Wer so einen Wert versehentlich in
|
||||||
|
ein öffentliches Verzeichnis schiebt, soll ihn beim Durchsehen erkennen.
|
||||||
|
|
||||||
|
**`/selbst` braucht kein Recht.** Sonst müsste man beim Einrichten raten, ob
|
||||||
|
der Token falsch ist oder nur zu wenig darf.
|
||||||
|
|
||||||
|
**Die Schnittstelle kann Bereich und Projekt nicht setzen.** Was von außen
|
||||||
|
kommt, landet in der Inbox. Die Einordnung ist Sache dessen, der plant,
|
||||||
|
nicht dessen, der meldet. Löschen kann sie auch nicht: Ein Fehler im fremden
|
||||||
|
Skript soll keine Planung vernichten.
|
||||||
|
|
||||||
|
**Erst anstellen, dann senden.** Die Schnellerfassung schreibt zuerst in die
|
||||||
|
Warteschlange des Geräts und versucht dann zu senden. Andersherum wäre der
|
||||||
|
Eintrag weg, wenn der Browser mitten im Senden geschlossen wird.
|
||||||
|
|
||||||
|
**IndexedDB statt localStorage.** Das sind echte Daten des Benutzers. Ein
|
||||||
|
Eintrag, der beim Aufräumen des Speichers verschwindet, ist eine verlorene
|
||||||
|
Aufgabe — genau das, was der Planer verhindern soll.
|
||||||
|
|
||||||
|
**Der Dienstarbeiter hält genau eine Seite vor.** Ein halb veralteter Planer
|
||||||
|
wäre schlimmer als gar keiner; ein leeres Eingabefeld dagegen veraltet
|
||||||
|
nicht. Und ohne die Seite nützte die Warteschlange niemandem: Man käme gar
|
||||||
|
nicht erst hin.
|
||||||
|
|
||||||
|
**Die Navigation wurde geteilt statt weiter gedehnt.** Nach M6 hatte ich die
|
||||||
|
Leiste verbreitert; mit dem dreizehnten Eintrag verschluckte sie erneut
|
||||||
|
Punkte. Das war die falsche Antwort: Das Problem ist nicht die Breite,
|
||||||
|
sondern die Vermischung. Alltag steht in der Leiste, Einrichtung und
|
||||||
|
Verwaltung liegen hinter „Mehr".
|
||||||
|
|
||||||
|
### Probleme und Lösungen
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Die Erfassungsseite zählte die Inbox anders als die Kopfleiste | Sie zählte Ideen mit. Jetzt benutzt sie dieselbe Abfrage. Derselbe Fehlertyp wie in M2 — und wieder nur im Bild aufgefallen, im Code sah jede Seite für sich richtig aus. |
|
||||||
|
| Die Leiste verschluckte mit dem dreizehnten Eintrag wieder Punkte | Aufgeteilt statt verbreitert, siehe oben. Zwei E2E-Tests greifen den neuen Weg über „Mehr" auf. |
|
||||||
|
| Ein E2E-Test brach mit `ERR_ABORTED` ab | Die Erfolgsmeldung kommt, bevor das Neuladen der Liste fertig ist; wer dann navigiert, bricht es ab. Der Test wartet jetzt darauf. |
|
||||||
|
| `exactOptionalPropertyTypes` und Prismas Update-Typen vertragen kein `undefined` | Das Datenobjekt wird Feld für Feld gebaut statt per Spread. Ein gesetztes `undefined` ist etwas anderes als ein fehlendes Feld. |
|
||||||
|
| Ein Devanagari-Zeichen war in ein Token-Präfix der Demo-Daten geraten | Entfernt. Beim Schreiben über die Shell passiert so etwas; die Datei danach durchsucht. |
|
||||||
|
| Fünf hängende Docker-Bauten, einer über drei Stunden | Kein Codeproblem: Nach Abbruch und Neustart lief derselbe Bau in Minuten durch. Zweimal scheiterte zusätzlich das Anlegen des Compose-Netzes. |
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
- **Die Bilder der Erfassungsseite zeigen die alte Inbox-Zahl.** Der Bildlauf
|
||||||
|
ließ sich nach der Korrektur nicht mehr durchbringen. Ein Lauf, wenn Docker
|
||||||
|
wieder mitspielt, räumt das auf.
|
||||||
|
- Keine Begrenzung der Aufrufzahl je Token. Bei einer Handvoll eigener
|
||||||
|
Tokens wäre sie mehr Zeremonie als Schutz — bei einem Token, der außer
|
||||||
|
Haus geht, sähe das anders aus.
|
||||||
|
- Die Warteschlange kennt keinen Abbruch: Ein Eintrag, der dauerhaft
|
||||||
|
scheitert, wird ewig erneut versucht. Bisher gibt es keinen Fall, in dem
|
||||||
|
das passieren könnte, ohne dass gleichzeitig die Anmeldung fehlt.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
- d5c7974 feat: Datenmodell und Regeln für die öffentliche Schnittstelle
|
||||||
|
- 6d3ebd0 feat: API v1 für Aufgaben
|
||||||
|
- b17c42a feat: Tokens verwalten
|
||||||
|
- 9565b9a feat: Schnellerfassung vom Handy, auch ohne Netz
|
||||||
|
- 10b49cf test: Die Tür der Schnittstelle scharf prüfen
|
||||||
|
- 2e57713 docs: Handbuchseite zum Erfassen und Beschreibung der Schnittstelle
|
||||||
|
- 7919149 fix: Navigation aufteilen, statt sie weiter zu dehnen
|
||||||
|
- f9a616e docs: Handbuchbilder für M7 aufnehmen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-10 – M3: Kalender lesen
|
||||||
|
|
||||||
|
**Dauer:** 41 Minuten (17:48–18:29), Art `claude`.
|
||||||
|
|
||||||
|
**Ziel der Sitzung:** Termine aus Microsoft 365 und Google lesen, damit der
|
||||||
|
Planer weiß, wann überhaupt Platz ist.
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- `Kalenderkonto` und `Termin` samt Migration; Bereich bekommt sein
|
||||||
|
Standard-Kalenderkonto für M4
|
||||||
|
- Tokenverschlüsselung mit AES-256-GCM
|
||||||
|
- Antworten beider Anbieter mit zod geprüft und in eine gemeinsame Form
|
||||||
|
gebracht, geprüft gegen anonymisierte Beispielantworten
|
||||||
|
- Belegungsrechnung: Kapazität gegen Termine, freie Fenster für M4
|
||||||
|
- OAuth-Verbinden, Tokenerneuerung, seitenweiser Abgleich mit Rückfall auf
|
||||||
|
einen vollständigen Durchlauf
|
||||||
|
- Seite für die Kalenderkonten, Belegung des Tages auf der Startseite
|
||||||
|
- Hintergrundjobs für Abgleich und nächtliches Aufräumen
|
||||||
|
- 68 neue Unit-Tests, 10 neue Integrationsprüfungen
|
||||||
|
- Handbuchseite und Einrichtungsanleitung für Entra ID und Google
|
||||||
|
|
||||||
|
### Entscheidungen mit Begründung
|
||||||
|
|
||||||
|
**Tokens verschlüsselt, Schlüssel aus AUTH_SECRET abgeleitet.** Wer die
|
||||||
|
Datenbank oder eine Sicherung bekommt, hätte sonst direkten Zugriff auf
|
||||||
|
fremde Kalender — das wiegt schwerer als bei den OIDC-Tokens aus M1, die
|
||||||
|
nur die Anmeldung betreffen. Eine eigene Pflichtvariable wäre eine weitere
|
||||||
|
Stelle, an der die Einrichtung scheitert; die Ableitung über HKDF kostet
|
||||||
|
nichts und hat die richtige Nebenwirkung: wer AUTH_SECRET wechselt, muss
|
||||||
|
die Konten neu verbinden — dann sind ohnehin alle Sitzungen ungültig.
|
||||||
|
|
||||||
|
**Bei fremder Zeitzone abbrechen statt raten.** Graph liefert Datum und
|
||||||
|
Zeitzone getrennt, ohne Versatz im Text. Wir fragen ausdrücklich UTC an;
|
||||||
|
kommt etwas anderes zurück, wäre jede Umrechnung geraten und würde Termine
|
||||||
|
um Stunden verschieben. Ein Abbruch mit klarer Meldung ist besser als ein
|
||||||
|
Kalender, der leise falsch liegt.
|
||||||
|
|
||||||
|
**Unbekanntes showAs zählt als belegt.** Lieber eine Stunde zu viel
|
||||||
|
blockiert als eine Doppelbuchung.
|
||||||
|
|
||||||
|
**Schreibrechte schon jetzt anfragen.** M3 liest nur, aber M4 schreibt.
|
||||||
|
Später erneut zuzustimmen ist genau der Schritt, an dem Einrichtung
|
||||||
|
scheitert — einmal breiter fragen ist das kleinere Übel.
|
||||||
|
|
||||||
|
**Ganztägige Termine zählen nicht zur Belegung.** Ein Urlaubstag oder
|
||||||
|
Geburtstag blockiert sonst den ganzen Tag, obwohl er keine Arbeitszeit
|
||||||
|
kostet.
|
||||||
|
|
||||||
|
**Alle Kalender zählen gegen jeden Bereich.** Ein Termin blockiert die Zeit
|
||||||
|
ohnehin, gleich zu welchem Lebensbereich er gehört.
|
||||||
|
|
||||||
|
**Kein SDK für Graph und Google.** Es geht um drei Aufrufe je Anbieter, und
|
||||||
|
die Antworten werden ohnehin mit zod geprüft. Ein SDK brächte vor allem
|
||||||
|
Abhängigkeiten und blinde Flecken.
|
||||||
|
|
||||||
|
### Probleme und Lösungen
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Prisma lässt in einem zusammengesetzten Unique-Schlüssel kein null zu | `kalenderId` ist nicht optional, sondern trägt den sprechenden Wert `"standard"` für den Standardkalender. |
|
||||||
|
| Googles `syncToken` verträgt sich nicht mit `timeMin`/`timeMax` | Folgeabgleiche liefern auch Änderungen außerhalb des Fensters. Was daneben liegt, wird beim Spiegeln verworfen — sonst wüchse der Spiegel unbegrenzt. |
|
||||||
|
| Der Verweis für die nächste Runde kann ablaufen (HTTP 410) | Verweis verwerfen, Spiegel leeren, von vorn lesen. Der Ablauf ist so gebaut, dass ein vollständiger Durchlauf jederzeit möglich ist. |
|
||||||
|
| Die Shell zerlegte in mehreren Anläufen Template-Literale und Backticks | Auf `node - <<'SKRIPT'` umgestellt; damit geht der Text unverändert durch. |
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
- **Nicht gegen die echten APIs getestet.** Ich habe keine Zugangsdaten für
|
||||||
|
Entra ID oder Google. Provider und Abgleich sind gegen anonymisierte
|
||||||
|
Beispielantworten geprüft; den ersten echten Verbindungsversuch muss
|
||||||
|
Florian machen. Die Anleitung dafür steht in der README.
|
||||||
|
- Nur der Standardkalender je Konto. Mehrere Kalender desselben Kontos sind
|
||||||
|
im Datenmodell vorgesehen, es fehlt die Auswahl in der Oberfläche.
|
||||||
|
- Der Abgleich läuft in einer Schleife über alle Konten. Bei vielen Konten
|
||||||
|
wäre das nacheinander zu langsam; bei der erwarteten Zahl ist es das
|
||||||
|
einfachere Verhalten.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
- 79e6c0d feat(kalender): Datenmodell für Kalenderkonten und Termine
|
||||||
|
- 6fda2a5 feat(kalender): Antworten von Graph und Google lesen, Belegung rechnen
|
||||||
|
- 9946e7c feat(kalender): OAuth und Abgleich für Graph und Google
|
||||||
|
- 0c67b73 feat(kalender): Seite für die Kalenderkonten
|
||||||
|
- 42c98c8 feat(kalender): Hintergrundjobs für Abgleich und Aufräumen
|
||||||
|
- d8d3227 feat(kalender): Belegung des Tages auf der Startseite
|
||||||
|
- df50627 test: Verschlüsselung und Kalendermodelle absichern
|
||||||
|
- a8b2ff0 docs: Handbuchseite und Einrichtung der Kalenderanbieter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-10 – M2: Aufgaben und Projekte
|
||||||
|
|
||||||
|
**Dauer:** 44 Minuten (15:57–16:41), Art `claude`.
|
||||||
|
|
||||||
|
**Ziel der Sitzung:** Erfassen und Wiederfinden — Aufgaben, Inbox, Ideen und
|
||||||
|
Projekte. Noch ohne Kalender.
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- Datenmodell `Aufgabe` und `Projekt` samt Migration
|
||||||
|
- Schnellerfassung, Inbox mit Einsortieren, Aufgabenliste mit Filtern
|
||||||
|
- Ideen als eigene Ansicht, mit Übernahme in eine Aufgabe
|
||||||
|
- Projekte mit Arbeitspaketen, Meilensteinen und Fortschritt
|
||||||
|
- Startseite zeigt jetzt, was überfällig oder demnächst fällig ist
|
||||||
|
- 53 neue Unit-Tests, 11 neue Integrationsprüfungen, 4 neue E2E-Tests
|
||||||
|
- Handbuchseiten zu Aufgaben, Ideen und Projekten; Bilder neu aufgenommen
|
||||||
|
|
||||||
|
### Entscheidungen mit Begründung
|
||||||
|
|
||||||
|
**Die Inbox ist kein eigener Zustand.** Eine Aufgabe liegt genau dann in der
|
||||||
|
Inbox, wenn sie keinen Bereich hat. Ein zusätzliches Feld „einsortiert"
|
||||||
|
hätte mit der Zuordnung auseinanderlaufen können; so ist der Zustand nicht
|
||||||
|
darstellbar.
|
||||||
|
|
||||||
|
**Projekt als eigenes Modell, nicht als Aufgabentyp.** Ein Projekt hat
|
||||||
|
selbst weder Aufwand noch Fälligkeit — beides ergibt sich aus dem Inhalt.
|
||||||
|
`ARBEITSPAKET` und `MEILENSTEIN` bleiben Typen der Aufgaben _innerhalb_
|
||||||
|
eines Projekts.
|
||||||
|
|
||||||
|
**Verworfene Aufgaben zählen gar nicht in den Fortschritt.** Weder als
|
||||||
|
erledigt noch als offen. Sonst sähe ein Projekt fertig aus, weil die Hälfte
|
||||||
|
gestrichen wurde — eine Zahl, die lügt, ist schlechter als keine.
|
||||||
|
|
||||||
|
**Sortiert wird in der Anwendung, nicht in SQL.** Die Reihenfolge ist
|
||||||
|
mehrstufig (offen vor abgeschlossen, dann Termin, dann Priorität, dann Titel
|
||||||
|
mit deutscher Sortierung). Als geprüfte reine Funktion steht sie an einer
|
||||||
|
Stelle; eine zweite Fassung in SQL wäre eine Fehlerquelle. Bei den erwarteten
|
||||||
|
Mengen ist das unkritisch — sollte es einmal eng werden, ist der Ort für die
|
||||||
|
Änderung klar.
|
||||||
|
|
||||||
|
**Filter stehen in der Adresse.** Eine gefilterte Liste lässt sich als
|
||||||
|
Lesezeichen ablegen, und der Zurück-Knopf tut, was er soll.
|
||||||
|
|
||||||
|
**Statuswechsel folgen festen Übergängen.** Aus `ERLEDIGT` und `VERWORFEN`
|
||||||
|
geht es nur zurück nach `OFFEN`. Wer per Formular etwas anderes einreicht,
|
||||||
|
bekommt eine Meldung — nicht einen stillen Zustandssprung.
|
||||||
|
|
||||||
|
**Fälligkeit in Kalendertagen, nicht in Stunden.** Was heute um 23:00 fällig
|
||||||
|
ist, ist heute fällig. Alles andere fühlt sich beim Benutzen falsch an.
|
||||||
|
|
||||||
|
**Natives `<select>` statt einer eigenen Auswahlkomponente.** Es bedient
|
||||||
|
sich auf dem Handy mit dem Systemdialog, ist ohne Zusatzcode
|
||||||
|
tastaturbedienbar und braucht kein JavaScript zum Absenden.
|
||||||
|
|
||||||
|
### Probleme und Lösungen
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| ESLint beanstandete `String(antwort.meldung)` | Ich hatte die Rückmeldung als `Record<string, unknown>` typisiert, statt den vorhandenen `Rueckmeldung`-Typ zu nehmen. Mit dem richtigen Typ entfällt die Umwandlung ganz. |
|
||||||
|
| Zwei Stellen rechneten die Tagesgrenze mit einem `toLocaleString`-Trick | `src/lib/zeit.ts` konnte das längst sauber. Beide Stellen nutzen jetzt `tagesBeginn()` beziehungsweise `alsTagesschluessel()`; die Tests belegen gleiche Ergebnisse bei weniger Code. |
|
||||||
|
| Typfehler nach der Migration | Der Prisma-Client muss nach jeder Schemaänderung neu erzeugt werden. `npm run pruefen` tut das inzwischen selbst, wenn er fehlt — hier war er nur veraltet. |
|
||||||
|
|
||||||
|
### Eigene Fehler, die ich korrigiert habe
|
||||||
|
|
||||||
|
- Im Demo-Seed stand versehentlich ein kyrillisches Wort als Titel eines
|
||||||
|
Meilensteins. Ersetzt durch „Seite ist online".
|
||||||
|
- Ein Test erwartete, dass 481 Minuten Aufwand erlaubt sind; die Grenze
|
||||||
|
liegt bei 480. Der Test war falsch, nicht der Code — jetzt prüft er die
|
||||||
|
Grenze selbst und den ersten Wert darüber.
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
- Kein Wiederholungsrhythmus für Aufgaben und keine Verknüpfung von Aufgaben
|
||||||
|
untereinander. Beides steht bewusst unter „Ideen für später" und wartet
|
||||||
|
darauf, ob es im Alltag wirklich fehlt.
|
||||||
|
- Die Aufgabenliste lädt bis zu 500 Einträge auf einmal. Für den erwarteten
|
||||||
|
Bestand reicht das; eine Blätterfunktion kommt, wenn es eng wird.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
- 63c3c15 feat(aufgaben): Datenmodell und Regeln für Aufgaben und Projekte
|
||||||
|
- b3ca1c9 feat(aufgaben): Server Actions und Abfrageschicht
|
||||||
|
- a4a889f feat(aufgaben): Inbox, Aufgabenliste, Ideen und Projekte
|
||||||
|
- 7c8fe10 test: Erfassungsweg und Eindeutigkeit der Quellen absichern
|
||||||
|
- 144457b fix(aufgaben): Ideen aus Aufgabenliste und Inbox heraushalten
|
||||||
|
- 4d0f83f chore: Demo-Daten für die neuen Seiten erweitern
|
||||||
|
- d01c49b docs: Handbuchseiten zu Aufgaben, Ideen und Projekten
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
datum;start;ende;minuten;meilenstein;art;notiz
|
datum;start;ende;minuten;meilenstein;art;notiz
|
||||||
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
|
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
|
||||||
|
2026-09-10;15:57;16:41;44;M2;claude;M2 Aufgaben und Projekte fertig
|
||||||
|
2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig
|
||||||
|
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
|
||||||
|
2026-09-12;10:15;01:01;886;M7;claude;M7 Schnellerfassung und API fertig
|
||||||
|
|||||||
|
@@ -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'
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Öffentliche Schnittstelle
|
||||||
|
|
||||||
|
Für fremde Anwendungen, Skripte und Automaten. Wer den Planer selbst bedient,
|
||||||
|
braucht sie nicht.
|
||||||
|
|
||||||
|
Basisadresse: `<APP_URL>/api/v1`
|
||||||
|
|
||||||
|
## Ausweisen
|
||||||
|
|
||||||
|
Jede Anfrage trägt einen Token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer planer_ab12cd34_…" \
|
||||||
|
https://planer.example.de/api/v1/selbst
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ `X-Planer-Token: planer_…`, falls das Werkzeug keine
|
||||||
|
Authorization-Kopfzeile kann.
|
||||||
|
|
||||||
|
Tokens legst du in der Anwendung unter **API** an (nur Verwaltung). Der Wert
|
||||||
|
erscheint **einmal**; danach liegt in der Datenbank nur sein Fingerabdruck.
|
||||||
|
Wer ihn verliert, legt einen neuen an.
|
||||||
|
|
||||||
|
## Rechte
|
||||||
|
|
||||||
|
Drei Stück, und **keines schließt ein anderes ein**:
|
||||||
|
|
||||||
|
| Recht | Was damit geht |
|
||||||
|
| ----------- | -------------------------------------- |
|
||||||
|
| `lesen` | Aufgaben abfragen |
|
||||||
|
| `erfassen` | Neue Aufgaben anlegen — mehr nicht |
|
||||||
|
| `schreiben` | Bestehende Aufgaben ändern und abhaken |
|
||||||
|
|
||||||
|
Ein Dienst, der nur meldet „hier ist etwas Neues", bekommt `erfassen` und
|
||||||
|
sonst nichts. Das ist der häufigste Fall und der, bei dem ein mitgelesener
|
||||||
|
Token am wenigsten anrichtet.
|
||||||
|
|
||||||
|
## Fehler
|
||||||
|
|
||||||
|
| Status | Bedeutung |
|
||||||
|
| ------ | ----------------------------------------------------------- |
|
||||||
|
| 400 | Die Anfrage war fehlerhaft (Feldliste steht in der Antwort) |
|
||||||
|
| 401 | Kein gültiger Token |
|
||||||
|
| 403 | Dieser Token darf das nicht |
|
||||||
|
| 404 | Gibt es nicht |
|
||||||
|
|
||||||
|
401 sieht immer gleich aus — ob ein Token unbekannt, abgelaufen oder
|
||||||
|
zurückgezogen ist, verrät die Schnittstelle nicht. Von außen soll sich nicht
|
||||||
|
abfragen lassen, welche Tokens es gibt.
|
||||||
|
|
||||||
|
## Endpunkte
|
||||||
|
|
||||||
|
### `GET /selbst`
|
||||||
|
|
||||||
|
Wer bin ich, was darf ich? Braucht **kein** Recht — sonst müsste man raten,
|
||||||
|
ob der Token falsch ist oder nur zu wenig darf.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "Wartungsplaner-Import", "rechte": ["erfassen"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /aufgaben` · Recht `lesen`
|
||||||
|
|
||||||
|
| Parameter | Standard | Bedeutung |
|
||||||
|
| --------- | -------- | ------------------- |
|
||||||
|
| `grenze` | 25 | Höchstens 100 |
|
||||||
|
| `versatz` | 0 | Zum Blättern |
|
||||||
|
| `offen` | — | `true` oder `false` |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aufgaben": [
|
||||||
|
{
|
||||||
|
"id": "clx…",
|
||||||
|
"titel": "#4711 Drucker klemmt",
|
||||||
|
"notiz": null,
|
||||||
|
"status": "OFFEN",
|
||||||
|
"prioritaet": 3,
|
||||||
|
"aufwandMinuten": null,
|
||||||
|
"faelligAm": null,
|
||||||
|
"quelle": "API",
|
||||||
|
"ref": "ticket-4711",
|
||||||
|
"url": "https://…",
|
||||||
|
"erstelltAm": "2026-03-17T08:20:00.000Z",
|
||||||
|
"bereich": null,
|
||||||
|
"projekt": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gesamt": 42,
|
||||||
|
"grenze": 25,
|
||||||
|
"versatz": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /aufgaben` · Recht `erfassen`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://planer.example.de/api/v1/aufgaben \
|
||||||
|
-H "Authorization: Bearer planer_…" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"titel":"Drucker klemmt","ref":"ticket-4711"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
| Feld | Pflicht | Bemerkung |
|
||||||
|
| ---------------- | ------- | ----------------------------------- |
|
||||||
|
| `titel` | ja | bis 200 Zeichen |
|
||||||
|
| `notiz` | nein | bis 4000 Zeichen |
|
||||||
|
| `ref` | nein | Kennung beim Aufrufer — siehe unten |
|
||||||
|
| `url` | nein | Verweis ins Ursprungssystem |
|
||||||
|
| `faelligAm` | nein | `JJJJ-MM-TT` |
|
||||||
|
| `prioritaet` | nein | 1 (dringend) bis 5 (irgendwann) |
|
||||||
|
| `aufwandMinuten` | nein | 1 bis 1440 |
|
||||||
|
|
||||||
|
Antwort: `201` mit der angelegten Aufgabe.
|
||||||
|
|
||||||
|
**`ref` macht den Aufruf wiederholbar.** Zweimal dieselbe Kennung ergibt
|
||||||
|
**eine** Aufgabe, nicht zwei — der zweite Aufruf frischt Titel, Notiz und
|
||||||
|
Verweis auf. Ohne `ref` entsteht jedes Mal eine neue Aufgabe; dann hast du es
|
||||||
|
so gewollt.
|
||||||
|
|
||||||
|
Benutze `ref`, wann immer es drüben eine stabile Kennung gibt. Das ist der
|
||||||
|
Unterschied zwischen einer Schnittstelle, die man nach einem Netzhänger
|
||||||
|
gefahrlos wiederholen kann, und einer, die dabei Karteileichen anlegt.
|
||||||
|
|
||||||
|
### `GET /aufgaben/<id>` · Recht `lesen`
|
||||||
|
|
||||||
|
### `PATCH /aufgaben/<id>` · Recht `schreiben`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH https://planer.example.de/api/v1/aufgaben/clx… \
|
||||||
|
-H "Authorization: Bearer planer_…" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"erledigt":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Änderbar sind `titel`, `notiz`, `faelligAm`, `prioritaet`, `aufwandMinuten`
|
||||||
|
und `erledigt`. Mindestens ein Feld muss dabei sein.
|
||||||
|
|
||||||
|
## Was die Schnittstelle bewusst **nicht** kann
|
||||||
|
|
||||||
|
- **Bereich und Projekt setzen.** Was von außen kommt, landet in der Inbox.
|
||||||
|
Die Einordnung ist Sache dessen, der plant, nicht dessen, der meldet —
|
||||||
|
sonst räumt ein fremder Dienst die eigene Ordnung um.
|
||||||
|
- **Aufgaben löschen.** Abhaken ja, löschen nein. Ein Fehler im fremden
|
||||||
|
Skript soll keine Planung vernichten.
|
||||||
|
- **Zeitblöcke, Bereiche, Projekte oder Kalender anfassen.**
|
||||||
|
|
||||||
|
Fehlt dir etwas davon, ist das eine Entscheidung und kein Versehen — sag
|
||||||
|
Bescheid, dann wird darüber geredet.
|
||||||
|
|
||||||
|
## Protokoll
|
||||||
|
|
||||||
|
Jede **schreibende** Nutzung landet im Protokoll, mit dem Token, über den sie
|
||||||
|
kam. Lesen nicht: Ein Dienst, der im Minutentakt abfragt, würde das Protokoll
|
||||||
|
unbrauchbar machen.
|
||||||
|
|
||||||
|
Auch ein abgewiesener Zugriff wird vermerkt. Ein Dienst, der immer wieder an
|
||||||
|
dieselbe Wand läuft, ist entweder falsch eingerichtet oder versucht etwas.
|
||||||
@@ -133,14 +133,26 @@ E-Mail-Adresse und **Mit Passkey anmelden**.
|
|||||||
> Hinterlege am besten zwei Passkeys auf verschiedenen Geräten. Sonst sperrst
|
> Hinterlege am besten zwei Passkeys auf verschiedenen Geräten. Sonst sperrst
|
||||||
> du dich aus, wenn ein Gerät verlorengeht.
|
> du dich aus, wenn ein Gerät verlorengeht.
|
||||||
|
|
||||||
## 8. Aufräumen
|
## 8. Die erste Aufgabe erfassen
|
||||||
|
|
||||||
|
Auf der Startseite steht oben ein Feld **Was ist zu tun?**. Titel eintippen,
|
||||||
|
Enter.
|
||||||
|
|
||||||
|
**Erwartet:** Eine kurze Bestätigung, und der Zähler neben **Inbox** im Menü
|
||||||
|
springt auf 1. Dort kannst du die Aufgabe einem Bereich zuordnen.
|
||||||
|
|
||||||
|
Mehr dazu: [Aufgaben erfassen und wiederfinden](handbuch/aufgaben.md).
|
||||||
|
|
||||||
|
## 9. Aufräumen
|
||||||
|
|
||||||
Wenn `ADMIN_PASSWORT` nur zur Einrichtung gedient hat, kannst du es jetzt
|
Wenn `ADMIN_PASSWORT` nur zur Einrichtung gedient hat, kannst du es jetzt
|
||||||
zusammen mit `ADMIN_EMAIL` aus `.env` entfernen. Das Konto bleibt bestehen.
|
zusammen mit `ADMIN_EMAIL` aus `.env` entfernen. Das Konto bleibt bestehen.
|
||||||
|
|
||||||
## Wie es weitergeht
|
## Wie es weitergeht
|
||||||
|
|
||||||
- [Handbuch](handbuch/) – Anleitung für den Alltag
|
- [Handbuch](handbuch/) – Anleitung für den Alltag, insbesondere
|
||||||
|
[Aufgaben](handbuch/aufgaben.md), [Ideen](handbuch/ideen.md) und
|
||||||
|
[Projekte](handbuch/projekte.md)
|
||||||
- [README](../README.md) – alle Umgebungsvariablen, Betrieb, Sicherung
|
- [README](../README.md) – alle Umgebungsvariablen, Betrieb, Sicherung
|
||||||
- [ROADMAP](../ROADMAP.md) – was in den nächsten Meilensteinen kommt
|
- [ROADMAP](../ROADMAP.md) – was in den nächsten Meilensteinen kommt
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 350 KiB |
@@ -9,28 +9,40 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
|||||||
2. **[Bereiche einrichten](bereiche.md)** – Farbe, Arbeitsfenster und
|
2. **[Bereiche einrichten](bereiche.md)** – Farbe, Arbeitsfenster und
|
||||||
Tageskapazität festlegen
|
Tageskapazität festlegen
|
||||||
|
|
||||||
|
## Im Alltag
|
||||||
|
|
||||||
|
3. **[Aufgaben erfassen und wiederfinden](aufgaben.md)** – Schnellerfassung,
|
||||||
|
Inbox leeren, Filter, Status und Fälligkeit
|
||||||
|
4. **[Schnellerfassung vom Handy](erfassen.md)** – als App ablegen, auch
|
||||||
|
ohne Netz erfassen
|
||||||
|
5. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
|
||||||
|
6. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
|
||||||
|
Fortschritt
|
||||||
|
7. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
|
||||||
|
verbinden, Belegung verstehen
|
||||||
|
8. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
||||||
|
Kalender legen
|
||||||
|
9. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und
|
||||||
|
Telegram einrichten, Ruhezeiten setzen
|
||||||
|
10. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel
|
||||||
|
zwischen Firma und Daheim
|
||||||
|
|
||||||
## Verwaltung
|
## Verwaltung
|
||||||
|
|
||||||
3. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
11. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||||
|
|
||||||
## Was noch kommt
|
## Was noch kommt
|
||||||
|
|
||||||
Diese Seiten entstehen mit den nächsten Meilensteinen:
|
Diese Seiten entstehen mit den nächsten Meilensteinen:
|
||||||
|
|
||||||
| Thema | Meilenstein |
|
| Thema | Meilenstein |
|
||||||
| ------------------------------- | ----------- |
|
| ----- | ----------- |
|
||||||
| Aufgaben erfassen, Inbox, Ideen | M2 |
|
|
||||||
| Projekte und Arbeitspakete | M2 |
|
|
||||||
| Kalenderkonten verbinden | M3 |
|
|
||||||
| Wochenplanung und Zeitblöcke | M4 |
|
|
||||||
| Benachrichtigungen | M5 |
|
|
||||||
| Integrationen | M6 |
|
|
||||||
| Schnellerfassung vom Handy, API | M7 |
|
|
||||||
| MCP | M8 |
|
| MCP | M8 |
|
||||||
|
|
||||||
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
|
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Für fremde Anwendungen und Skripte: [Schnittstelle](../API.md).
|
||||||
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
|
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
|
||||||
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
|
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Aufgaben erfassen und wiederfinden
|
||||||
|
|
||||||
|
Der Planer trennt zwei Dinge, die sonst gern durcheinandergeraten:
|
||||||
|
**etwas aufschreiben** und **entscheiden, wohin es gehört**. Das erste soll
|
||||||
|
zwei Sekunden dauern, das zweite macht man gesammelt, wenn man Ruhe hat.
|
||||||
|
|
||||||
|
## Schnell erfassen
|
||||||
|
|
||||||
|
Auf der Startseite und in der Inbox steht ein einzelnes Feld. Titel eintippen,
|
||||||
|
Enter — fertig.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Der Fokus bleibt danach im Feld, du kannst also mehrere Sachen hintereinander
|
||||||
|
eingeben, ohne zur Maus zu greifen.
|
||||||
|
|
||||||
|
> **Warum nur ein Feld?** Wer beim Erfassen erst Bereich, Termin und Aufwand
|
||||||
|
> ausfüllen muss, erfasst irgendwann nichts mehr, sondern merkt es sich
|
||||||
|
> „schnell" — und vergisst es. Alles Weitere kommt beim Einsortieren.
|
||||||
|
|
||||||
|
## Die Inbox leeren
|
||||||
|
|
||||||
|
Alles ohne Bereich landet in der Inbox. Die Zahl neben dem Menüpunkt sagt, wie
|
||||||
|
viel dort liegt.
|
||||||
|
|
||||||
|
Zum Einsortieren genügt das Auswahlfeld unter der Aufgabe: Bereich wählen, und
|
||||||
|
sie verschwindet aus der Inbox.
|
||||||
|
|
||||||
|
> **Die Inbox ist kein eigener Zustand.** Eine Aufgabe ist genau dann in der
|
||||||
|
> Inbox, wenn sie keinen Bereich hat. Einsortieren heißt schlicht: einen
|
||||||
|
> Bereich setzen. Deshalb kann sie auch nicht „doppelt" irgendwo sein.
|
||||||
|
|
||||||
|
Wenn beim Einsortieren mehr zu ergänzen ist als der Bereich, klick auf das
|
||||||
|
Stift-Symbol — dann öffnet sich das vollständige Formular direkt in der Liste.
|
||||||
|
|
||||||
|
## Die Aufgabenliste
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Standardmäßig zeigt sie nur, was noch Arbeit bedeutet — Erledigtes und
|
||||||
|
Verworfenes fällt heraus. Über den Filter _Status_ holst du es zurück.
|
||||||
|
|
||||||
|
### Die Reihenfolge
|
||||||
|
|
||||||
|
Sortiert wird nach Dringlichkeit, in dieser Reihenfolge:
|
||||||
|
|
||||||
|
1. Offenes vor Abgeschlossenem
|
||||||
|
2. früherer Termin vor späterem
|
||||||
|
3. Terminiertes vor Unterminiertem
|
||||||
|
4. bei gleichem Termin: höhere Priorität zuerst
|
||||||
|
5. zuletzt alphabetisch
|
||||||
|
|
||||||
|
Ohne Termin heißt also nicht unwichtig — solche Aufgaben stehen nur hinter den
|
||||||
|
terminierten.
|
||||||
|
|
||||||
|
### Filter
|
||||||
|
|
||||||
|
| Filter | Was er tut |
|
||||||
|
| ----------- | ------------------------------------------------------- |
|
||||||
|
| **Suche** | Durchsucht Titel und Notiz. Enter drücken |
|
||||||
|
| **Bereich** | Ein Bereich, oder _Nur Inbox_ für alles ohne Bereich |
|
||||||
|
| **Status** | Standard ist _Nur offene_. _Alle_ zeigt auch Erledigtes |
|
||||||
|
| **Art** | Aufgabe, Arbeitspaket, Meilenstein, Idee oder Spiegel |
|
||||||
|
| **Projekt** | Nur Aufgaben eines Projekts |
|
||||||
|
|
||||||
|
Die Filter stehen in der Adresszeile. Eine gefilterte Liste lässt sich also als
|
||||||
|
Lesezeichen ablegen, und der Zurück-Knopf des Browsers tut, was er soll.
|
||||||
|
|
||||||
|
## Die Felder einer Aufgabe
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
| ------------- | ------------------------------------------------------------------------------------ |
|
||||||
|
| **Titel** | Zwei bis 200 Zeichen. Am besten mit einem Verb: „Angebot schreiben", nicht „Angebot" |
|
||||||
|
| **Notiz** | Was dazugehört, aber nicht in den Titel passt |
|
||||||
|
| **Art** | Aufgabe, Idee, Arbeitspaket oder Meilenstein |
|
||||||
|
| **Status** | Offen, Geplant, Wartet, Erledigt, Verworfen |
|
||||||
|
| **Priorität** | 1 Sofort bis 5 Irgendwann. 3 ist der Normalfall |
|
||||||
|
| **Aufwand** | Geschätzte Minuten, höchstens 480 |
|
||||||
|
| **Fällig am** | Der Tag, an dem es spätestens fertig sein muss |
|
||||||
|
| **Bereich** | Leer heißt Inbox |
|
||||||
|
| **Projekt** | Optional |
|
||||||
|
|
||||||
|
> **Warum höchstens acht Stunden Aufwand?** Weil ab M4 aus einer Aufgabe ein
|
||||||
|
> Zeitblock im Kalender wird. Was nicht an einem Tag am Stück zu schaffen ist,
|
||||||
|
> gehört in mehrere Aufgaben — sonst lässt es sich nicht planen.
|
||||||
|
|
||||||
|
## Der Status
|
||||||
|
|
||||||
|
| Status | Wann |
|
||||||
|
| ------------- | ----------------------------------------------------- |
|
||||||
|
| **Offen** | Steht an, ist noch nicht eingeplant |
|
||||||
|
| **Geplant** | Hat ab M4 einen Zeitblock im Kalender |
|
||||||
|
| **Wartet** | Liegt bei jemand anderem. Du kannst gerade nichts tun |
|
||||||
|
| **Erledigt** | Fertig |
|
||||||
|
| **Verworfen** | Wird nicht gemacht — aber die Notiz bleibt erhalten |
|
||||||
|
|
||||||
|
Das Häkchen links in jeder Zeile hakt direkt ab. Bei einer erledigten Aufgabe
|
||||||
|
wird daraus ein Pfeil, der sie wieder öffnet.
|
||||||
|
|
||||||
|
> **Aus Erledigt und Verworfen geht es nur zurück nach Offen.** Nicht direkt
|
||||||
|
> nach Geplant oder Wartet. Was wieder aufgenommen wird, fängt am Anfang an —
|
||||||
|
> sonst entstehen Zustände, die niemand mehr nachvollziehen kann.
|
||||||
|
|
||||||
|
## Fälligkeit
|
||||||
|
|
||||||
|
Der Planer rechnet in **Kalendertagen in Europe/Berlin**, nicht in
|
||||||
|
24-Stunden-Schritten. Was heute um 23:00 fällig ist, ist „heute fällig" — nicht
|
||||||
|
„in 0,5 Tagen".
|
||||||
|
|
||||||
|
| Kennzeichen | Bedeutung |
|
||||||
|
| ----------- | ------------------------ |
|
||||||
|
| rot | überfällig |
|
||||||
|
| gelb | heute oder morgen |
|
||||||
|
| grau | später, oder ohne Termin |
|
||||||
|
|
||||||
|
Erledigtes wird nie als überfällig markiert, auch wenn der Termin lange
|
||||||
|
zurückliegt.
|
||||||
|
|
||||||
|
## Löschen
|
||||||
|
|
||||||
|
Der Papierkorb fragt einmal nach: Beim ersten Klick wird der Knopf rot und
|
||||||
|
fragt „Wirklich?", erst der zweite löscht. Klickst du daneben, ist die Frage
|
||||||
|
wieder weg.
|
||||||
|
|
||||||
|
Meist ist **Verworfen** die bessere Wahl — dann bleibt nachvollziehbar, dass es
|
||||||
|
die Aufgabe gab und warum sie nicht gemacht wurde.
|
||||||
|
|
||||||
|
## Typische Fehler
|
||||||
|
|
||||||
|
| Meldung | Was zu tun ist |
|
||||||
|
| --------------------------------------------------- | ------------------------------------------------------ |
|
||||||
|
| „Mindestens zwei Zeichen." | Der Titel ist zu kurz |
|
||||||
|
| „Mehr als acht Stunden gehört in mehrere Aufgaben." | Aufteilen |
|
||||||
|
| „Dieser Statuswechsel ist nicht vorgesehen." | Erst wieder öffnen, dann weiter |
|
||||||
|
| „Diesen Bereich gibt es nicht." | Der Bereich wurde inzwischen gelöscht. Seite neu laden |
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
_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).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Schnellerfassung vom Handy
|
||||||
|
|
||||||
|
Die Seite **Erfassen** ist für den Moment gemacht, in dem dir etwas einfällt
|
||||||
|
und du keine Zeit hast, es einzusortieren: ein Feld, ein Knopf, fertig.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Alles, was hier hereinkommt, landet in der **Inbox** — ohne Bereich, ohne
|
||||||
|
Termin. Das Einsortieren passiert später am Rechner, wenn Ruhe dafür ist.
|
||||||
|
|
||||||
|
## Als Anwendung ablegen
|
||||||
|
|
||||||
|
Der Planer lässt sich auf dem Startbildschirm ablegen und verhält sich dann
|
||||||
|
wie eine App: eigenes Symbol, kein Browserrahmen, direkt auf dieser Seite.
|
||||||
|
|
||||||
|
| Gerät | Weg |
|
||||||
|
| --------------------- | -------------------------------------------- |
|
||||||
|
| Android (Chrome) | Menü ⋮ → **App installieren** |
|
||||||
|
| iPhone (Safari) | Teilen-Symbol → **Zum Home-Bildschirm** |
|
||||||
|
| Rechner (Chrome/Edge) | Symbol in der Adresszeile → **Installieren** |
|
||||||
|
|
||||||
|
## Ohne Netz
|
||||||
|
|
||||||
|
**Das ist der eigentliche Punkt dieser Seite.** Im Keller, im Aufzug, im Zug
|
||||||
|
zwischen zwei Funkzellen: Erfasst wird trotzdem.
|
||||||
|
|
||||||
|
Was passiert:
|
||||||
|
|
||||||
|
1. Das Erfasste wird **zuerst im Gerät gemerkt**, dann verschickt. Nicht
|
||||||
|
andersherum — sonst wäre es weg, wenn du den Browser mitten im Senden
|
||||||
|
schließt.
|
||||||
|
2. Ohne Netz bleibt es liegen und steht unter dem Feld als „wartet".
|
||||||
|
3. Sobald wieder Netz da ist, geht es von selbst hinaus. Du bekommst eine
|
||||||
|
kurze Rückmeldung.
|
||||||
|
|
||||||
|
Der Knopf **Jetzt versuchen** stößt das von Hand an, wenn du nicht warten
|
||||||
|
magst.
|
||||||
|
|
||||||
|
> **Geht dabei etwas verloren?** Nein. Jede Erfassung bekommt eine eigene
|
||||||
|
> Kennung, die mitgeschickt wird. Kommt dieselbe Nachricht zweimal an — weil
|
||||||
|
> die Verbindung beim ersten Versuch abbrach, die Anfrage aber trotzdem
|
||||||
|
> durchging —, entsteht trotzdem nur **eine** Aufgabe.
|
||||||
|
|
||||||
|
Die Warteschlange liegt in der Datenbank des Browsers, nicht im flüchtigen
|
||||||
|
Speicher. Sie übersteht das Schließen des Tabs und einen Neustart des
|
||||||
|
Geräts.
|
||||||
|
|
||||||
|
Damit die Seite ohne Netz überhaupt aufgeht, hält der Planer genau **diese
|
||||||
|
eine Seite** vor. Alle anderen Seiten holt er immer frisch: Ein halb
|
||||||
|
veralteter Planer wäre schlimmer als gar keiner — ein leeres Eingabefeld
|
||||||
|
dagegen veraltet nicht.
|
||||||
|
|
||||||
|
## Warum nicht die Erfassung auf der Startseite?
|
||||||
|
|
||||||
|
Die gibt es weiterhin, und sie ist am Rechner meist der kürzere Weg. Der
|
||||||
|
Unterschied ist die Verlässlichkeit: Die Schnellerfassung ist die einzige
|
||||||
|
Stelle, die ohne Netz nichts verliert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Weiter zur [Inbox](aufgaben.md) oder zurück zum [Handbuch](README.md).
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Ideen
|
||||||
|
|
||||||
|
Nicht alles, was einem einfällt, ist eine Aufgabe. Manches ist ein „das wäre
|
||||||
|
mal schön" ohne Termin, ohne Aufwand und ohne Verpflichtung.
|
||||||
|
|
||||||
|
Solche Sachen gehören auf die Ideenseite — dort stören sie die Aufgabenliste
|
||||||
|
nicht und gehen trotzdem nicht verloren.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Eine Idee notieren
|
||||||
|
|
||||||
|
Titel eintippen, Enter. Genau wie bei Aufgaben, nur landet es auf der
|
||||||
|
Ideenseite statt in der Inbox.
|
||||||
|
|
||||||
|
> Bewerten kannst du später. Der Sinn ist, sie festzuhalten, bevor sie weg ist.
|
||||||
|
|
||||||
|
## Aus einer Idee eine Aufgabe machen
|
||||||
|
|
||||||
|
Wenn aus der Idee etwas werden soll: **Zur Aufgabe machen**. Sie verschwindet
|
||||||
|
damit von der Ideenseite und taucht in der Inbox auf — Bereich, Termin und
|
||||||
|
Aufwand ergänzt du dann wie bei jeder anderen Aufgabe.
|
||||||
|
|
||||||
|
Der Titel und die Notiz bleiben erhalten, es entsteht kein zweiter Eintrag.
|
||||||
|
|
||||||
|
## Warum Ideen getrennt liegen
|
||||||
|
|
||||||
|
Ideen haben bewusst **keinen Termin**. Würden sie in der Aufgabenliste stehen,
|
||||||
|
träfe eines von beidem zu:
|
||||||
|
|
||||||
|
- Sie stünden ganz unten und wären damit unsichtbar, oder
|
||||||
|
- sie bekämen einen erfundenen Termin und würden irgendwann rot.
|
||||||
|
|
||||||
|
Beides führt dazu, dass man die Liste nicht mehr ernst nimmt. Getrennt bleibt
|
||||||
|
die Aufgabenliste eine Liste von Verpflichtungen — und die Ideenseite eine
|
||||||
|
Sammlung von Möglichkeiten.
|
||||||
|
|
||||||
|
## Eine Idee wieder loswerden
|
||||||
|
|
||||||
|
Der Papierkorb löscht endgültig. Wer sie lieber behalten möchte, ohne sie
|
||||||
|
weiter zu sehen, setzt sie über das Stift-Symbol auf **Verworfen** — dann
|
||||||
|
bleibt sie nachlesbar, verschwindet aber aus der Liste.
|
||||||
|
|
||||||
|
## Auf der Startseite
|
||||||
|
|
||||||
|
Sind Ideen da, zeigt die Startseite unten einen Hinweis mit ihrer Anzahl.
|
||||||
|
Das ist Absicht: ein leiser Anstoß, gelegentlich hineinzusehen — aber nichts,
|
||||||
|
was sich zwischen die Arbeit des Tages drängt.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Integrationen
|
||||||
|
|
||||||
|
Was von außen hereinkommt — und was diese Instanz hinausgibt. Du findest den
|
||||||
|
Überblick im Menü unter **Integrationen**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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).
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Kalenderkonten verbinden
|
||||||
|
|
||||||
|
Der Planer liest deine Termine, um zu wissen, **wann überhaupt Platz ist**.
|
||||||
|
Ab M4 schreibt er Zeitblöcke in denselben Kalender zurück.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Was der Planer liest — und was nicht
|
||||||
|
|
||||||
|
| Liest er | Nutzt er wofür |
|
||||||
|
| --------------------------------- | ------------------------------------------------ |
|
||||||
|
| Beginn und Ende | Belegung gegen die Tageskapazität |
|
||||||
|
| Betreff | Anzeige in der Wochenansicht |
|
||||||
|
| Ganztägig ja/nein | Ganztägige Termine zählen **nicht** zur Belegung |
|
||||||
|
| „Zeigt an als" (frei, gebucht, …) | Als frei markierte Termine zählen nicht |
|
||||||
|
|
||||||
|
**Nicht gelesen** werden Teilnehmer, Beschreibungstexte, Anhänge und Orte.
|
||||||
|
Betreffs stehen in der Datenbank, damit du sie in der Ansicht siehst — sie
|
||||||
|
landen aber **niemals in Protokollen**.
|
||||||
|
|
||||||
|
Gelesen wird ein Fenster von **7 Tagen zurück bis 90 Tage voraus**. Was aus
|
||||||
|
diesem Fenster herauswandert, wird nachts wieder entfernt.
|
||||||
|
|
||||||
|
## Einen Kalender verbinden
|
||||||
|
|
||||||
|
1. Im Menü auf **Kalender**.
|
||||||
|
2. Auf **Microsoft 365 verbinden** beziehungsweise **Google verbinden**.
|
||||||
|
3. Beim Anbieter anmelden und der Berechtigung zustimmen.
|
||||||
|
4. Du landest wieder hier, und der erste Abgleich läuft sofort.
|
||||||
|
|
||||||
|
> **Warum fragt der Planer auch nach Schreibrechten?** Weil er ab M4
|
||||||
|
> Zeitblöcke in den Kalender schreibt. Würde er jetzt nur Leserechte
|
||||||
|
> anfordern, müsstest du später jedem Konto erneut zustimmen — und genau
|
||||||
|
> daran scheitert Einrichtung erfahrungsgemäß.
|
||||||
|
|
||||||
|
### Welche Anbieter es gibt
|
||||||
|
|
||||||
|
Das hängt an der Instanz und ist nicht verhandelbar:
|
||||||
|
|
||||||
|
| | Firma | Daheim |
|
||||||
|
| ------------- | -------- | ------ |
|
||||||
|
| Microsoft 365 | ja | ja |
|
||||||
|
| Google | **nein** | ja |
|
||||||
|
|
||||||
|
In der Firmeninstanz gibt es die Google-Seite nicht — der Aufruf endet dort
|
||||||
|
mit „Diese Seite gibt es nicht". Das ist Absicht, kein Fehler.
|
||||||
|
|
||||||
|
## Der Abgleich
|
||||||
|
|
||||||
|
Alle 15 Minuten läuft der Abgleich von selbst. Mit **Jetzt abgleichen**
|
||||||
|
stößt du ihn von Hand an.
|
||||||
|
|
||||||
|
Beide Anbieter liefern nur die **Änderungen** seit dem letzten Mal, nicht
|
||||||
|
jedes Mal alle Termine. Läuft dieser Merker ab — das passiert nach längerer
|
||||||
|
Pause —, liest der Planer den ganzen Zeitraum neu ein. Davon merkst du außer
|
||||||
|
einem etwas längeren Abgleich nichts.
|
||||||
|
|
||||||
|
## Die Belegung
|
||||||
|
|
||||||
|
Auf der Startseite zeigt jeder Bereich, wie voll der heutige Tag schon ist:
|
||||||
|
Termine aus **allen** verbundenen Kalendern gegen die Tageskapazität dieses
|
||||||
|
Bereichs.
|
||||||
|
|
||||||
|
> **Warum zählen alle Kalender gegen jeden Bereich?** Weil ein Termin die
|
||||||
|
> Zeit ohnehin blockiert, ganz gleich, zu welchem Lebensbereich er gehört.
|
||||||
|
> Wer um 14 Uhr beim Zahnarzt sitzt, kann zur selben Zeit nichts für die
|
||||||
|
> Firma tun.
|
||||||
|
|
||||||
|
Gezählt wird nur, was **ins Arbeitsfenster** des Bereichs fällt. Ein Termin
|
||||||
|
um 22 Uhr verkleinert die Kapazität eines Bereichs nicht, dessen Fenster um
|
||||||
|
17 Uhr endet.
|
||||||
|
|
||||||
|
Überlappende Termine werden zusammengelegt — eine Doppelbuchung zählt nur
|
||||||
|
einmal.
|
||||||
|
|
||||||
|
## Ein Konto umbenennen
|
||||||
|
|
||||||
|
Die Bezeichnung ist frei wählbar. „Firma Outlook" oder „Gewerbe" liest sich
|
||||||
|
besser als die Mailadresse, gerade wenn du mehrere Konten desselben
|
||||||
|
Anbieters hast.
|
||||||
|
|
||||||
|
## Ein Konto trennen
|
||||||
|
|
||||||
|
Der rote Knopf, mit Rückfrage. Die gespiegelten Termine verschwinden dabei —
|
||||||
|
**im Kalender selbst bleibt alles unangetastet**. Der Planer hat bis M4
|
||||||
|
ohnehin nur gelesen.
|
||||||
|
|
||||||
|
Zusätzlich solltest du die Berechtigung beim Anbieter selbst zurückziehen:
|
||||||
|
|
||||||
|
- Microsoft: <https://myapps.microsoft.com> → _Zugriff verwalten_
|
||||||
|
- Google: <https://myaccount.google.com/permissions>
|
||||||
|
|
||||||
|
## Wenn es klemmt
|
||||||
|
|
||||||
|
| Was du siehst | Was dahintersteckt |
|
||||||
|
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| „Der Anbieter hat kein Aktualisierungstoken herausgegeben." | Meist bei Google, wenn du schon einmal zugestimmt hattest. Berechtigung im Google-Konto entziehen und neu verbinden. |
|
||||||
|
| „Die Anfrage war ungültig oder zu alt." | Zwischen Klick und Zustimmung lagen mehr als 30 Minuten. Einfach neu beginnen. |
|
||||||
|
| „Kein Aktualisierungstoken hinterlegt." | Das Konto muss neu verbunden werden. Das passiert auch, wenn `AUTH_SECRET` gewechselt wurde. |
|
||||||
|
| „Der Anbieter antwortete mit 401." | Die Berechtigung wurde beim Anbieter entzogen. Neu verbinden. |
|
||||||
|
| „unerwartete Zeitzone …, erwartet wurde UTC" | Ein Konfigurationsfehler auf Serverseite. Bitte melden — der Planer bricht hier bewusst ab, statt Termine um Stunden zu verschieben. |
|
||||||
|
| Die Belegung bleibt leer | Kein Kalender verbunden, oder der erste Abgleich lief noch nicht. Die Karte zeigt die Belegung erst, wenn wirklich ein Kalender dranhängt. |
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
Die Zugangstoken liegen **verschlüsselt** in der Datenbank (AES-256-GCM).
|
||||||
|
Wer eine Sicherung in die Hände bekommt, kann damit nichts anfangen, solange
|
||||||
|
er nicht auch `AUTH_SECRET` hat.
|
||||||
|
|
||||||
|
Wird `AUTH_SECRET` gewechselt, müssen alle Kalenderkonten neu verbunden
|
||||||
|
werden. Das ist gewollt: mit dem alten Geheimnis sind auch alle Sitzungen
|
||||||
|
ungültig.
|
||||||
|
|
||||||
|
Für die Einrichtung auf Serverseite — was in Entra ID und in der Google
|
||||||
|
Cloud Console anzulegen ist — siehe
|
||||||
|
[README, Abschnitt Kalenderanbieter](../../README.md#kalenderanbieter-einrichten).
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Projekte
|
||||||
|
|
||||||
|
Ein **Projekt** ist eine Klammer um mehrere Aufgaben, die zusammen ein Ziel
|
||||||
|
erreichen: „Website neu aufsetzen", „Werkstatt umräumen", „Zertifizierung".
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Ein Projekt hat selbst **keinen Aufwand und keine Fälligkeit**. Beides ergibt
|
||||||
|
sich aus dem, was drinsteckt — sonst hättest du zwei Zahlen, die auseinander
|
||||||
|
laufen können.
|
||||||
|
|
||||||
|
## Ein Projekt anlegen
|
||||||
|
|
||||||
|
**Neues Projekt** oben rechts.
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
| ---------------- | ------------------------------------------------------------- |
|
||||||
|
| **Name** | Zwei bis 80 Zeichen, eindeutig |
|
||||||
|
| **Beschreibung** | Worum es geht — und woran du merkst, dass es fertig ist |
|
||||||
|
| **Status** | Aktiv, Pausiert, Abgeschlossen, Verworfen |
|
||||||
|
| **Bereich** | Optional. Neue Aufgaben im Projekt bekommen ihn vorgeschlagen |
|
||||||
|
| **Zieltermin** | Rein informativ; er macht keine Aufgabe überfällig |
|
||||||
|
|
||||||
|
> Der zweite Satz der Beschreibung ist der wichtigere. „Fertig ist es, wenn die
|
||||||
|
> neue Seite online ist und das Kontaktformular ankommt" verhindert, dass ein
|
||||||
|
> Projekt ewig weiterläuft.
|
||||||
|
|
||||||
|
## Aufgaben zuordnen
|
||||||
|
|
||||||
|
Zwei Wege:
|
||||||
|
|
||||||
|
1. Auf der Projektseite **Aufgabe hinzufügen** — Projekt und Bereich sind dann
|
||||||
|
schon vorbelegt, die Art steht auf _Arbeitspaket_.
|
||||||
|
2. In einer bestehenden Aufgabe das Feld **Projekt** setzen.
|
||||||
|
|
||||||
|
### Arbeitspaket und Meilenstein
|
||||||
|
|
||||||
|
Beides sind Arten von Aufgaben _innerhalb_ eines Projekts:
|
||||||
|
|
||||||
|
- **Arbeitspaket** — ein Stück Arbeit mit Aufwand.
|
||||||
|
- **Meilenstein** — ein Punkt, an dem etwas erreicht ist. Meist ohne Aufwand,
|
||||||
|
dafür mit Termin.
|
||||||
|
|
||||||
|
## Der Fortschritt
|
||||||
|
|
||||||
|
Der Balken zeigt, wie viele der zugeordneten Aufgaben erledigt sind.
|
||||||
|
|
||||||
|
> **Verworfene Aufgaben zählen gar nicht mit.** Weder als erledigt noch als
|
||||||
|
> offen. Sonst sähe ein Projekt fertig aus, weil die Hälfte gestrichen wurde —
|
||||||
|
> und das wäre eine Zahl, die lügt.
|
||||||
|
|
||||||
|
Ein Beispiel: drei Aufgaben, eine erledigt, eine offen, eine verworfen. Der
|
||||||
|
Fortschritt ist **1 von 2 erledigt (50 %)**, nicht 33 %.
|
||||||
|
|
||||||
|
Daneben steht der noch offene geschätzte Aufwand. Aufgaben ohne Schätzung
|
||||||
|
zählen darin als null — die Zahl ist also eine Untergrenze.
|
||||||
|
|
||||||
|
## Ein Projekt abschließen
|
||||||
|
|
||||||
|
Status auf **Abgeschlossen** setzen. Es verschwindet damit aus der Auswahl bei
|
||||||
|
neuen Aufgaben, bleibt aber in der Übersicht sichtbar.
|
||||||
|
|
||||||
|
**Pausiert** ist für Projekte, die gerade ruhen, aber weitergehen sollen.
|
||||||
|
|
||||||
|
## Ein Projekt löschen
|
||||||
|
|
||||||
|
Der rote Knopf auf der Projektseite, mit Rückfrage.
|
||||||
|
|
||||||
|
> **Die enthaltenen Aufgaben bleiben erhalten** und verlieren nur ihre
|
||||||
|
> Zuordnung. Mit dem Projekt auch die Arbeit zu löschen wäre selten das, was
|
||||||
|
> jemand will — und nie rückgängig zu machen.
|
||||||
|
|
||||||
|
Willst du wirklich alles loswerden, lösche zuerst die Aufgaben über die
|
||||||
|
Aufgabenliste (Filter _Projekt_), dann das Projekt.
|
||||||
|
|
||||||
|
## Typische Fehler
|
||||||
|
|
||||||
|
| Meldung | Was zu tun ist |
|
||||||
|
| ------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||||
|
| „Diesen Namen gibt es schon." | Projektnamen sind eindeutig. Vielleicht gibt es das Projekt schon abgeschlossen |
|
||||||
|
| „Dieses Projekt gibt es nicht." | Es wurde inzwischen gelöscht. Seite neu laden |
|
||||||
|
| Der Fortschritt springt beim Verwerfen nach oben | Das ist richtig so: Verworfenes fällt aus der Rechnung |
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Wochenplanung und Zeitblöcke
|
||||||
|
|
||||||
|
Hier wird aus einer Aufgabe ein Termin. Du ziehst sie in ein Zeitfenster, und
|
||||||
|
der Planer legt sie als **Zeitblock** in deinen echten Kalender.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Der Aufbau
|
||||||
|
|
||||||
|
| Wo | Was |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Links** | Alles, was noch eingeplant werden kann — offen, geplant oder wartend. Ideen bleiben draußen |
|
||||||
|
| **Rechts** | Sieben Tage im Viertelstundenraster |
|
||||||
|
| **Über jeder Spalte** | Belegte Zeit gegen die Kapazität dieses Tages |
|
||||||
|
| **Graue Balken** | Termine aus deinem Kalender. Sie gehören dem Kalender, nicht dem Planer — hier lassen sie sich nicht anfassen |
|
||||||
|
| **Farbige Balken** | Deine Zeitblöcke, in der Farbe ihres Bereichs |
|
||||||
|
|
||||||
|
Das sichtbare Stundenfenster ergibt sich aus den Arbeitsfenstern **aller**
|
||||||
|
Bereiche, mit einer Stunde Luft nach oben und unten.
|
||||||
|
|
||||||
|
## Eine Aufgabe einplanen
|
||||||
|
|
||||||
|
Aufgabe links am Griff packen und in ein Zeitfenster ziehen. Fertig — der
|
||||||
|
Zeitblock steht sofort im Raster und wenige Augenblicke später in deinem
|
||||||
|
Kalender.
|
||||||
|
|
||||||
|
> **Die Dauer kommt aus dem geschätzten Aufwand.** Wer bei der Aufgabe
|
||||||
|
> „90 Minuten" einträgt, bekommt einen 90-Minuten-Block. Ohne Schätzung sind
|
||||||
|
> es 60 Minuten, die du danach anpassen kannst.
|
||||||
|
|
||||||
|
Eine eingeplante Aufgabe wechselt automatisch von _Offen_ auf _Geplant_.
|
||||||
|
|
||||||
|
## Verschieben
|
||||||
|
|
||||||
|
Am Griff im Block packen und woandershin ziehen. Der Planer zieht den
|
||||||
|
Kalendertermin nach.
|
||||||
|
|
||||||
|
## Ausplanen
|
||||||
|
|
||||||
|
Das Kreuz im Block. Der Termin verschwindet aus dem Kalender; die Aufgabe
|
||||||
|
bleibt und fällt zurück auf _Offen_, wenn nichts anderes mehr für sie geplant
|
||||||
|
ist.
|
||||||
|
|
||||||
|
## Wenn du im Kalender verschiebst
|
||||||
|
|
||||||
|
Das darfst du — und der Planer zieht nach. Beim nächsten Abgleich (spätestens
|
||||||
|
nach 15 Minuten) steht der Block dort, wo du ihn im Kalender hingelegt hast.
|
||||||
|
|
||||||
|
> **Der Kalender gewinnt.** Immer. Wer dort etwas verschiebt, meint es so;
|
||||||
|
> säße der Planer am längeren Hebel, würde er dir den Termin wieder wegziehen
|
||||||
|
> und du wüsstest nicht, warum.
|
||||||
|
>
|
||||||
|
> Löschst du den Termin im Kalender, verschwindet auch der Zeitblock. Die
|
||||||
|
> Aufgabe selbst bleibt.
|
||||||
|
|
||||||
|
### Warum sich das nicht aufschaukelt
|
||||||
|
|
||||||
|
Der Planer merkt sich zu jedem Zeitblock einen Fingerabdruck des Zeitraums,
|
||||||
|
den er zuletzt geschrieben hat. Findet er beim Abgleich genau diesen
|
||||||
|
Fingerabdruck wieder, war es seine eigene Änderung — er tut nichts. Weicht er
|
||||||
|
ab, hat jemand anderes verschoben.
|
||||||
|
|
||||||
|
Ohne diesen Kniff würden App und Kalender einander endlos hinterherschreiben.
|
||||||
|
|
||||||
|
## Das Warnzeichen im Block
|
||||||
|
|
||||||
|
Ein kleines Dreieck heißt: **noch nicht im Kalender**. Meist war der Anbieter
|
||||||
|
gerade nicht erreichbar.
|
||||||
|
|
||||||
|
Der Zeitblock bleibt trotzdem stehen — deine Planung soll nicht verlorengehen,
|
||||||
|
bloß weil ein Server hustet. Verschiebe den Block einmal, dann trägt der
|
||||||
|
Planer ihn nach.
|
||||||
|
|
||||||
|
## Die Zahl über der Spalte
|
||||||
|
|
||||||
|
`3 h / 6 h` heißt: sechs Stunden Kapazität an diesem Tag, drei davon schon
|
||||||
|
belegt. Gezählt werden Kalendertermine **und** deine Zeitblöcke.
|
||||||
|
|
||||||
|
Wird die Zahl rot, ist der Tag überbucht. Das ist keine Sperre — der Planer
|
||||||
|
lässt dich weiterplanen. Es ist ein Hinweis, dass der Tag nicht aufgeht.
|
||||||
|
|
||||||
|
Ganztägige Termine zählen nicht mit. Ein Urlaubstag im Kalender blockiert
|
||||||
|
sonst rechnerisch den ganzen Tag, obwohl er gar keine Arbeitszeit kostet.
|
||||||
|
|
||||||
|
## Blättern
|
||||||
|
|
||||||
|
**Zurück**, **Diese Woche** und **Weiter**. Die Woche steht in der Adresse —
|
||||||
|
eine bestimmte Woche lässt sich also als Lesezeichen ablegen.
|
||||||
|
|
||||||
|
## Wenn es klemmt
|
||||||
|
|
||||||
|
| Was du siehst | Was dahintersteckt |
|
||||||
|
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| „Ohne verbundenen Kalender lässt sich nichts einplanen." | Erst unter [Kalender](kalenderkonten.md) ein Konto verbinden |
|
||||||
|
| „Eingeplant, aber nicht in den Kalender geschrieben" | Der Anbieter war nicht erreichbar. Der Block steht, verschiebe ihn einmal zum Nachtragen |
|
||||||
|
| „Der Termin wurde inzwischen im Kalender geändert." | Jemand war schneller. Der Kalender gewinnt; der nächste Abgleich holt den dortigen Stand |
|
||||||
|
| „Zeitblöcke rasten in 15-Minuten-Schritten ein." | Das Raster ist Absicht. Genauer zu planen bringt nichts |
|
||||||
|
| Ein Block ist nicht zu sehen | Er liegt außerhalb des sichtbaren Stundenfensters. Das Fenster folgt den Arbeitsfenstern der Bereiche — siehe [Bereiche](bereiche.md) |
|
||||||
|
|
||||||
|
## Was noch nicht geht
|
||||||
|
|
||||||
|
- **Ein Zeitblock landet immer im ersten verbundenen Kalender.** Die Auswahl
|
||||||
|
je Bereich ist im Datenmodell vorbereitet, aber noch nicht in der
|
||||||
|
Oberfläche.
|
||||||
|
- **Die Dauer lässt sich nicht am Balken ziehen.** Wer eine andere Dauer will,
|
||||||
|
ändert den Aufwand der Aufgabe und plant neu ein.
|
||||||
@@ -50,7 +50,12 @@ test.describe('Anmeldung', () => {
|
|||||||
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||||
await expect(page.getByRole('link', { name: 'Bereiche' })).toBeVisible()
|
// Alltagspunkte stehen in der Leiste, Einrichtung liegt hinter "Mehr".
|
||||||
|
await expect(page.getByRole('link', { name: 'Aufgaben', exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeHidden()
|
||||||
|
|
||||||
|
await page.getByText('Mehr', { exact: true }).click()
|
||||||
|
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeVisible()
|
||||||
|
|
||||||
await page.getByTitle('Abmelden').click()
|
await page.getByTitle('Abmelden').click()
|
||||||
await expect(page).toHaveURL(/\/anmelden/)
|
await expect(page).toHaveURL(/\/anmelden/)
|
||||||
@@ -63,6 +68,8 @@ test.describe('Anmeldung', () => {
|
|||||||
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||||
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||||
|
|
||||||
|
// Das Protokoll liegt hinter "Mehr" – aufklappen, dann hin.
|
||||||
|
await page.getByText('Mehr', { exact: true }).click()
|
||||||
await page.getByRole('link', { name: 'Protokoll' }).click()
|
await page.getByRole('link', { name: 'Protokoll' }).click()
|
||||||
await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
import { EMAIL, VERBINDUNG } from './hilfe'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die öffentliche Schnittstelle.
|
||||||
|
*
|
||||||
|
* Geprüft wird vor allem, was **nicht** geht: ohne Token, mit falschem
|
||||||
|
* Token, mit zu wenig Rechten, mit abgelaufenem oder zurückgezogenem Token.
|
||||||
|
* Der gute Fall ist schnell erzählt — die Tür ist das Interessante.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
|
||||||
|
const PRAEFIX = `E2E-API-${Date.now()}`
|
||||||
|
|
||||||
|
/** Legt einen Token direkt in der Datenbank an und gibt den Klartext zurück. */
|
||||||
|
async function tokenBauen(
|
||||||
|
benutzerId: string,
|
||||||
|
rechte: string,
|
||||||
|
zusatz: { gueltigBis?: Date; widerrufenAm?: Date } = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const praefix = randomBytes(4).toString('hex')
|
||||||
|
const klartext = `planer_${praefix}_${randomBytes(32).toString('base64url')}`
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId,
|
||||||
|
name: `${PRAEFIX} ${rechte}`,
|
||||||
|
praefix,
|
||||||
|
hash: createHash('sha256').update(klartext, 'utf8').digest('hex'),
|
||||||
|
rechte,
|
||||||
|
...zusatz,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return klartext
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('API v1', () => {
|
||||||
|
test.skip(!EMAIL || !VERBINDUNG, 'Ohne ADMIN_EMAIL und DATABASE_URL kein Testkonto.')
|
||||||
|
|
||||||
|
let benutzerId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
|
||||||
|
benutzerId = benutzer.id
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
|
||||||
|
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
|
||||||
|
await db.apiToken.deleteMany({ where: { name: { startsWith: PRAEFIX } } })
|
||||||
|
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'api.' } } })
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist eine Anfrage ohne Token ab', async ({ request }) => {
|
||||||
|
const antwort = await request.get('/api/v1/aufgaben')
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
// Sagt Werkzeugen, wie sie sich ausweisen sollen.
|
||||||
|
expect(antwort.headers()['www-authenticate']).toBe('Bearer')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist einen erfundenen Token ab', async ({ request }) => {
|
||||||
|
const antwort = await request.get('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer planer_abcdefgh_${'x'.repeat(43)}` },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sagt einem gültigen Token, was er darf', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen,erfassen')
|
||||||
|
const antwort = await request.get('/api/v1/selbst', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(antwort.ok()).toBe(true)
|
||||||
|
expect(await antwort.json()).toMatchObject({ rechte: ['lesen', 'erfassen'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lässt einen Nur-Lesen-Token nicht erfassen', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen')
|
||||||
|
const antwort = await request.post('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { titel: `${PRAEFIX} darf nicht` },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(403)
|
||||||
|
expect(await db.aufgabe.count({ where: { titel: `${PRAEFIX} darf nicht` } })).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lässt einen Nur-Erfassen-Token nicht lesen', async ({ request }) => {
|
||||||
|
// Erfassen schließt Lesen ausdrücklich nicht ein.
|
||||||
|
const token = await tokenBauen(benutzerId, 'erfassen')
|
||||||
|
const antwort = await request.get('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist einen abgelaufenen Token ab', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen', {
|
||||||
|
gueltigBis: new Date(Date.now() - 60_000),
|
||||||
|
})
|
||||||
|
const antwort = await request.get('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist einen zurückgezogenen Token ab', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen', { widerrufenAm: new Date() })
|
||||||
|
const antwort = await request.get('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('erfasst, wiederholt sich nicht und ändert', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen,erfassen,schreiben')
|
||||||
|
const kopf = { Authorization: `Bearer ${token}` }
|
||||||
|
const ref = `${PRAEFIX}-ref`
|
||||||
|
|
||||||
|
const erste = await request.post('/api/v1/aufgaben', {
|
||||||
|
headers: kopf,
|
||||||
|
data: { titel: `${PRAEFIX} Angebot schreiben`, ref },
|
||||||
|
})
|
||||||
|
expect(erste.status()).toBe(201)
|
||||||
|
const angelegt = (await erste.json()) as { id: string; titel: string }
|
||||||
|
|
||||||
|
// Dasselbe noch einmal: eine Aufgabe, nicht zwei.
|
||||||
|
const zweite = await request.post('/api/v1/aufgaben', {
|
||||||
|
headers: kopf,
|
||||||
|
data: { titel: `${PRAEFIX} Angebot schreiben`, ref },
|
||||||
|
})
|
||||||
|
expect(zweite.ok()).toBe(true)
|
||||||
|
expect(await db.aufgabe.count({ where: { quelle: 'API', quelleRef: ref } })).toBe(1)
|
||||||
|
|
||||||
|
// Abhaken über die Schnittstelle.
|
||||||
|
const geaendert = await request.patch(`/api/v1/aufgaben/${angelegt.id}`, {
|
||||||
|
headers: kopf,
|
||||||
|
data: { erledigt: true },
|
||||||
|
})
|
||||||
|
expect(geaendert.ok()).toBe(true)
|
||||||
|
expect((await geaendert.json()) as { status: string }).toMatchObject({
|
||||||
|
status: 'ERLEDIGT',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Und im Protokoll steht, dass es über die Schnittstelle kam.
|
||||||
|
const protokoll = await db.auditEintrag.findFirst({
|
||||||
|
where: { aktion: 'api.geaendert', objektId: angelegt.id },
|
||||||
|
select: { details: true },
|
||||||
|
})
|
||||||
|
expect(protokoll).not.toBeNull()
|
||||||
|
expect(JSON.stringify(protokoll?.details)).toContain('api')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lehnt eine Aufgabe ohne Titel ab', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'erfassen')
|
||||||
|
const antwort = await request.post('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { titel: ' ' },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setzt weder Status noch Bereich aus der Anfrage', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'erfassen')
|
||||||
|
const bereich = await db.bereich.findFirst({ select: { id: true } })
|
||||||
|
|
||||||
|
const antwort = await request.post('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: {
|
||||||
|
titel: `${PRAEFIX} Einordnung bleibt hier`,
|
||||||
|
status: 'ERLEDIGT',
|
||||||
|
bereichId: bereich?.id ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(201)
|
||||||
|
|
||||||
|
const angelegt = await db.aufgabe.findFirstOrThrow({
|
||||||
|
where: { titel: `${PRAEFIX} Einordnung bleibt hier` },
|
||||||
|
select: { status: true, bereichId: true },
|
||||||
|
})
|
||||||
|
// Was von außen kommt, landet offen in der Inbox.
|
||||||
|
expect(angelegt.status).toBe('OFFEN')
|
||||||
|
expect(angelegt.bereichId).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Weg, der in M2 wirklich zählt: erfassen, einsortieren, abhaken.
|
||||||
|
*
|
||||||
|
* Alle Testdaten tragen ein gemeinsames Präfix und werden am Ende entfernt,
|
||||||
|
* damit die Entwicklungsdatenbank hinterher aussieht wie vorher.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PRAEFIX = `E2E-${Date.now()}`
|
||||||
|
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
|
||||||
|
|
||||||
|
test.describe('Aufgaben', () => {
|
||||||
|
test.skip(
|
||||||
|
!EMAIL || !PASSWORT || !VERBINDUNG,
|
||||||
|
'Ohne ADMIN_EMAIL, ADMIN_PASSWORT und DATABASE_URL gibt es kein Testkonto.',
|
||||||
|
)
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
|
||||||
|
await db.projekt.deleteMany({ where: { name: { startsWith: PRAEFIX } } })
|
||||||
|
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'aufgabe.' } } })
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('erfasst schnell, sortiert ein und hakt ab', async ({ page }) => {
|
||||||
|
const titel = `${PRAEFIX} Angebot schreiben`
|
||||||
|
await anmelden(page)
|
||||||
|
|
||||||
|
// --- Erfassen: ein Feld genügt --------------------------------------
|
||||||
|
await page.goto('/inbox')
|
||||||
|
await page.getByLabel('Neue Aufgabe').fill(titel)
|
||||||
|
await page.getByRole('button', { name: 'Erfassen' }).click()
|
||||||
|
|
||||||
|
const zeile = page.locator('li').filter({ hasText: titel })
|
||||||
|
await expect(zeile).toBeVisible()
|
||||||
|
// Ohne Bereich liegt sie in der Inbox – genau das ist der Unterschied.
|
||||||
|
await expect(zeile.getByText('Inbox')).toBeVisible()
|
||||||
|
|
||||||
|
// --- Einsortieren ----------------------------------------------------
|
||||||
|
const bereich = await db.bereich.findFirst({
|
||||||
|
where: { aktiv: true },
|
||||||
|
orderBy: { reihenfolge: 'asc' },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
expect(bereich).not.toBeNull()
|
||||||
|
|
||||||
|
await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id)
|
||||||
|
await expect(page.getByText(`liegt jetzt in ${bereich!.name}`)).toBeVisible()
|
||||||
|
|
||||||
|
// Die Meldung kommt, bevor das Neuladen der Liste fertig ist. Wer jetzt
|
||||||
|
// navigiert, bricht es ab — und Chromium meldet ERR_ABORTED.
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// Aus der Inbox ist sie damit verschwunden.
|
||||||
|
await page.goto('/inbox')
|
||||||
|
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
||||||
|
|
||||||
|
// --- Abhaken ---------------------------------------------------------
|
||||||
|
await page.goto('/aufgaben')
|
||||||
|
const inListe = page.locator('li').filter({ hasText: titel })
|
||||||
|
await expect(inListe).toBeVisible()
|
||||||
|
await inListe.getByRole('button', { name: 'Als erledigt abhaken' }).click()
|
||||||
|
|
||||||
|
// Auf die Bestätigung warten, bevor es weitergeht: der Klick stößt eine
|
||||||
|
// Server Action an, und ohne diesen Haltepunkt navigiert der Test
|
||||||
|
// gelegentlich schneller, als gespeichert wird.
|
||||||
|
await expect(page.getByText('ist jetzt erledigt')).toBeVisible()
|
||||||
|
|
||||||
|
// Erledigtes fällt aus der Standardansicht heraus.
|
||||||
|
await page.goto('/aufgaben')
|
||||||
|
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
||||||
|
|
||||||
|
// Mit Filter "Alle" ist sie wieder da.
|
||||||
|
await page.goto('/aufgaben?status=ERLEDIGT')
|
||||||
|
await expect(page.locator('li').filter({ hasText: titel })).toBeVisible()
|
||||||
|
|
||||||
|
const gespeichert = await db.aufgabe.findFirst({ where: { titel } })
|
||||||
|
expect(gespeichert?.status).toBe('ERLEDIGT')
|
||||||
|
expect(gespeichert?.erledigtAm).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('macht aus einer Idee eine Aufgabe', async ({ page }) => {
|
||||||
|
const titel = `${PRAEFIX} Blog aufsetzen`
|
||||||
|
await anmelden(page)
|
||||||
|
|
||||||
|
await page.goto('/ideen')
|
||||||
|
await page.getByLabel('Neue Idee').fill(titel)
|
||||||
|
await page.getByRole('button', { name: 'Erfassen' }).click()
|
||||||
|
|
||||||
|
const zeile = page.locator('li').filter({ hasText: titel })
|
||||||
|
await expect(zeile).toBeVisible()
|
||||||
|
|
||||||
|
await zeile.getByRole('button', { name: 'Zur Aufgabe machen' }).click()
|
||||||
|
await expect(page.getByText('ist jetzt eine Aufgabe')).toBeVisible()
|
||||||
|
|
||||||
|
const danach = await db.aufgabe.findFirst({ where: { titel } })
|
||||||
|
expect(danach?.typ).toBe('AUFGABE')
|
||||||
|
|
||||||
|
// Aus der Ideenliste ist sie damit verschwunden.
|
||||||
|
await page.goto('/ideen')
|
||||||
|
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hält Ideen aus der Aufgabenliste heraus', async ({ page }) => {
|
||||||
|
const idee = `${PRAEFIX} Nur eine Idee`
|
||||||
|
const aufgabe = `${PRAEFIX} Eine echte Aufgabe`
|
||||||
|
await db.aufgabe.createMany({
|
||||||
|
data: [
|
||||||
|
{ titel: idee, typ: 'IDEE' },
|
||||||
|
{ titel: aufgabe, typ: 'AUFGABE' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await anmelden(page)
|
||||||
|
|
||||||
|
// In der Aufgabenliste hat die Idee nichts zu suchen.
|
||||||
|
await page.goto('/aufgaben')
|
||||||
|
await expect(page.locator('li').filter({ hasText: aufgabe })).toBeVisible()
|
||||||
|
await expect(page.locator('li').filter({ hasText: idee })).toHaveCount(0)
|
||||||
|
|
||||||
|
// Auch nicht in der Inbox, obwohl beide keinen Bereich haben.
|
||||||
|
await page.goto('/inbox')
|
||||||
|
await expect(page.locator('li').filter({ hasText: aufgabe })).toBeVisible()
|
||||||
|
await expect(page.locator('li').filter({ hasText: idee })).toHaveCount(0)
|
||||||
|
|
||||||
|
// Wer ausdrücklich danach filtert, sieht sie sehr wohl.
|
||||||
|
await page.goto('/aufgaben?typ=IDEE')
|
||||||
|
await expect(page.locator('li').filter({ hasText: idee })).toBeVisible()
|
||||||
|
await expect(page.locator('li').filter({ hasText: aufgabe })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filtert nach Bereich und über die Adresszeile', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
const bereich = await db.bereich.findFirst({
|
||||||
|
where: { aktiv: true },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const drin = `${PRAEFIX} Gehört dazu`
|
||||||
|
const draussen = `${PRAEFIX} Gehört nicht dazu`
|
||||||
|
await db.aufgabe.createMany({
|
||||||
|
data: [
|
||||||
|
{ titel: drin, bereichId: bereich!.id },
|
||||||
|
{ titel: draussen, bereichId: null },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/aufgaben?bereich=${bereich!.id}`)
|
||||||
|
await expect(page.locator('li').filter({ hasText: drin })).toBeVisible()
|
||||||
|
await expect(page.locator('li').filter({ hasText: draussen })).toHaveCount(0)
|
||||||
|
|
||||||
|
// Nur Inbox zeigt genau die andere.
|
||||||
|
await page.goto('/aufgaben?bereich=inbox')
|
||||||
|
await expect(page.locator('li').filter({ hasText: draussen })).toBeVisible()
|
||||||
|
await expect(page.locator('li').filter({ hasText: drin })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeigt den Fortschritt eines Projekts', async ({ page }) => {
|
||||||
|
const projektName = `${PRAEFIX} Website neu`
|
||||||
|
const projekt = await db.projekt.create({ data: { name: projektName } })
|
||||||
|
await db.aufgabe.createMany({
|
||||||
|
data: [
|
||||||
|
{ titel: `${PRAEFIX} Entwurf`, projektId: projekt.id, status: 'ERLEDIGT' },
|
||||||
|
{ titel: `${PRAEFIX} Umsetzung`, projektId: projekt.id, status: 'OFFEN' },
|
||||||
|
// Verworfenes zählt bewusst gar nicht mit.
|
||||||
|
{ titel: `${PRAEFIX} Verworfen`, projektId: projekt.id, status: 'VERWORFEN' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto(`/projekte/${projekt.id}`)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: projektName })).toBeVisible()
|
||||||
|
await expect(page.getByText('1 von 2 erledigt (50 %)')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.1.0",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.1.0",
|
"version": "0.7.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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.1.0",
|
"version": "0.7.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",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,6 +105,118 @@ const PROTOKOLL = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erfundene Aufgaben. Die Fälligkeit steht als Versatz in Tagen zum festen
|
||||||
|
* Demo-Zeitpunkt, damit die Bilder immer dieselbe Mischung aus überfällig,
|
||||||
|
* heute und später zeigen.
|
||||||
|
*/
|
||||||
|
const AUFGABEN = [
|
||||||
|
// Überfällig
|
||||||
|
{
|
||||||
|
titel: 'Angebot Heizungstausch nachfassen',
|
||||||
|
bereich: 'Gewerbe',
|
||||||
|
versatz: -3,
|
||||||
|
prioritaet: 2,
|
||||||
|
aufwand: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titel: 'Rechnung 2026-114 korrigieren',
|
||||||
|
bereich: 'Gewerbe',
|
||||||
|
versatz: -1,
|
||||||
|
prioritaet: 1,
|
||||||
|
aufwand: 20,
|
||||||
|
},
|
||||||
|
// Heute und morgen
|
||||||
|
{
|
||||||
|
titel: 'Wochenbericht an die Geschäftsführung',
|
||||||
|
bereich: 'Firma',
|
||||||
|
versatz: 0,
|
||||||
|
prioritaet: 2,
|
||||||
|
aufwand: 45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titel: 'Rückruf Frau Sonnenberg',
|
||||||
|
bereich: 'Firma',
|
||||||
|
versatz: 0,
|
||||||
|
prioritaet: 1,
|
||||||
|
aufwand: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titel: 'Zahnarzttermin bestätigen',
|
||||||
|
bereich: 'Privat',
|
||||||
|
versatz: 1,
|
||||||
|
prioritaet: 3,
|
||||||
|
aufwand: 10,
|
||||||
|
},
|
||||||
|
// Später
|
||||||
|
{
|
||||||
|
titel: 'Backup-Strategie für den Server überdenken',
|
||||||
|
bereich: 'Homelab',
|
||||||
|
versatz: 9,
|
||||||
|
prioritaet: 4,
|
||||||
|
aufwand: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titel: 'Reifen wechseln lassen',
|
||||||
|
bereich: 'Privat',
|
||||||
|
versatz: 14,
|
||||||
|
prioritaet: 3,
|
||||||
|
aufwand: 60,
|
||||||
|
},
|
||||||
|
// Ohne Termin
|
||||||
|
{
|
||||||
|
titel: 'Ablage im Keller sortieren',
|
||||||
|
bereich: 'Privat',
|
||||||
|
versatz: null,
|
||||||
|
prioritaet: 5,
|
||||||
|
aufwand: 180,
|
||||||
|
},
|
||||||
|
// Erledigt
|
||||||
|
{
|
||||||
|
titel: 'Steuerunterlagen zusammenstellen',
|
||||||
|
bereich: 'Gewerbe',
|
||||||
|
versatz: -5,
|
||||||
|
prioritaet: 3,
|
||||||
|
aufwand: 90,
|
||||||
|
status: 'ERLEDIGT' as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Liegt noch in der Inbox – ohne Bereich. */
|
||||||
|
const INBOX = [
|
||||||
|
{ titel: 'Sicherungskasten beschriften' },
|
||||||
|
{ titel: 'Angebot Netzwerkschrank einholen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const IDEEN = [
|
||||||
|
{ titel: 'Newsletter für Bestandskunden' },
|
||||||
|
{ titel: 'Werkstatt-Podcast aufnehmen' },
|
||||||
|
{ titel: 'Regale im Lager neu beschriften' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PROJEKT_AUFGABEN = [
|
||||||
|
{
|
||||||
|
titel: 'Anforderungen sammeln',
|
||||||
|
typ: 'ARBEITSPAKET' as const,
|
||||||
|
status: 'ERLEDIGT' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titel: 'Entwurf abstimmen',
|
||||||
|
typ: 'ARBEITSPAKET' as const,
|
||||||
|
status: 'ERLEDIGT' as const,
|
||||||
|
},
|
||||||
|
{ titel: 'Texte schreiben', typ: 'ARBEITSPAKET' as const, status: 'OFFEN' as const },
|
||||||
|
{ titel: 'Fotos beauftragen', typ: 'ARBEITSPAKET' as const, status: 'WARTET' as const },
|
||||||
|
{ titel: 'Seite ist online', typ: 'MEILENSTEIN' as const, status: 'OFFEN' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Fester Bezugspunkt, passend zu src/server/zeitgeber.ts. */
|
||||||
|
const DEMO_JETZT = new Date('2026-03-17T08:20:00.000Z')
|
||||||
|
|
||||||
|
function tageVersetzt(tage: number): Date {
|
||||||
|
return new Date(DEMO_JETZT.getTime() + tage * 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
console.log('Demo-Daten einspielen.')
|
console.log('Demo-Daten einspielen.')
|
||||||
|
|
||||||
@@ -158,6 +270,263 @@ async function main(): Promise<void> {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Aufgaben, Ideen und ein Projekt -------------------------------------
|
||||||
|
await db.aufgabe.deleteMany({})
|
||||||
|
await db.projekt.deleteMany({})
|
||||||
|
|
||||||
|
const bereichsKennung = new Map(
|
||||||
|
(await db.bereich.findMany({ select: { id: true, name: true } })).map((b) => [
|
||||||
|
b.name,
|
||||||
|
b.id,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const eintrag of AUFGABEN) {
|
||||||
|
await db.aufgabe.create({
|
||||||
|
data: {
|
||||||
|
titel: eintrag.titel,
|
||||||
|
bereichId: bereichsKennung.get(eintrag.bereich) ?? null,
|
||||||
|
prioritaet: eintrag.prioritaet,
|
||||||
|
aufwandMinuten: eintrag.aufwand,
|
||||||
|
faelligAm: eintrag.versatz === null ? null : tageVersetzt(eintrag.versatz),
|
||||||
|
status: eintrag.status ?? 'OFFEN',
|
||||||
|
erledigtAm: eintrag.status === 'ERLEDIGT' ? tageVersetzt(-4) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const eintrag of INBOX) {
|
||||||
|
await db.aufgabe.create({ data: { titel: eintrag.titel } })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const eintrag of IDEEN) {
|
||||||
|
await db.aufgabe.create({ data: { titel: eintrag.titel, typ: 'IDEE' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const projekt = await db.projekt.create({
|
||||||
|
data: {
|
||||||
|
name: 'Website neu aufsetzen',
|
||||||
|
beschreibung:
|
||||||
|
'Der alte Auftritt ist von 2019 und auf dem Handy kaum lesbar. Fertig ist es, wenn die neue Seite online ist und das Kontaktformular ankommt.',
|
||||||
|
bereichId: bereichsKennung.get('Gewerbe') ?? null,
|
||||||
|
zielAm: tageVersetzt(45),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const eintrag of PROJEKT_AUFGABEN) {
|
||||||
|
await db.aufgabe.create({
|
||||||
|
data: {
|
||||||
|
titel: eintrag.titel,
|
||||||
|
typ: eintrag.typ,
|
||||||
|
status: eintrag.status,
|
||||||
|
projektId: projekt.id,
|
||||||
|
bereichId: bereichsKennung.get('Gewerbe') ?? null,
|
||||||
|
aufwandMinuten: eintrag.typ === 'MEILENSTEIN' ? null : 120,
|
||||||
|
erledigtAm: eintrag.status === 'ERLEDIGT' ? tageVersetzt(-7) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kalenderkonto und erfundene Termine ---------------------------------
|
||||||
|
// Die Tokens sind absichtlich Unsinn: die Screenshot-Instanz spricht mit
|
||||||
|
// keinem Anbieter, sie soll die Seite nur nicht leer zeigen.
|
||||||
|
await db.kalenderkonto.deleteMany({})
|
||||||
|
const kalenderkonto = await db.kalenderkonto.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
anbieter: 'GRAPH',
|
||||||
|
anzeigename: 'Firma Outlook',
|
||||||
|
extKontoId: 'martina.beispiel@firma.invalid',
|
||||||
|
letzterAbgleich: new Date(DEMO_JETZT.getTime() - 6 * 60_000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TERMINE = [
|
||||||
|
{ betreff: 'Wochenbesprechung', vonStunde: 8, bisStunde: 9 },
|
||||||
|
{ betreff: 'Kundengespräch Sonnenberg', vonStunde: 10, bisStunde: 11 },
|
||||||
|
{ betreff: 'Mittagspause', vonStunde: 11, bisStunde: 12, frei: true },
|
||||||
|
{ betreff: 'Abnahme Baustelle', vonStunde: 13, bisStunde: 15 },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const eintrag of TERMINE) {
|
||||||
|
await db.termin.create({
|
||||||
|
data: {
|
||||||
|
kalenderkontoId: kalenderkonto.id,
|
||||||
|
extId: `demo-${eintrag.betreff.toLowerCase().replace(/[^a-z]+/g, '-')}`,
|
||||||
|
betreff: eintrag.betreff,
|
||||||
|
beginn: new Date(Date.UTC(2026, 2, 17, eintrag.vonStunde - 1, 0)),
|
||||||
|
ende: new Date(Date.UTC(2026, 2, 17, eintrag.bisStunde - 1, 0)),
|
||||||
|
zeigtAls: eintrag.frei ? 'FREI' : 'GEBUCHT',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.termin.create({
|
||||||
|
data: {
|
||||||
|
kalenderkontoId: kalenderkonto.id,
|
||||||
|
extId: 'demo-brueckentag',
|
||||||
|
betreff: 'Brückentag',
|
||||||
|
beginn: new Date(Date.UTC(2026, 2, 20, 0, 0)),
|
||||||
|
ende: new Date(Date.UTC(2026, 2, 21, 0, 0)),
|
||||||
|
ganztaegig: true,
|
||||||
|
zeigtAls: 'ABWESEND',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Zeitblöcke: eingeplante Aufgaben im Kalender -------------------------
|
||||||
|
await db.zeitblock.deleteMany({})
|
||||||
|
const einzuplanen = await db.aufgabe.findMany({
|
||||||
|
where: {
|
||||||
|
titel: {
|
||||||
|
in: [
|
||||||
|
'Wochenbericht an die Geschäftsführung',
|
||||||
|
'Angebot Heizungstausch nachfassen',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, titel: true, aufwandMinuten: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let blockStunde = 15
|
||||||
|
for (const aufgabe of einzuplanen) {
|
||||||
|
const beginn = new Date(Date.UTC(2026, 2, 17, blockStunde - 1, 0))
|
||||||
|
await db.zeitblock.create({
|
||||||
|
data: {
|
||||||
|
aufgabeId: aufgabe.id,
|
||||||
|
kalenderkontoId: kalenderkonto.id,
|
||||||
|
beginn,
|
||||||
|
ende: new Date(beginn.getTime() + (aufgabe.aufwandMinuten ?? 60) * 60_000),
|
||||||
|
extId: `demo-block-${aufgabe.id}`,
|
||||||
|
hash: 'demo',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } })
|
||||||
|
blockStunde += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste.
|
||||||
|
await db.aufgabe.create({
|
||||||
|
data: {
|
||||||
|
titel: 'Störung Klimagerät Serverraum prüfen',
|
||||||
|
bereichId: bereichsKennung.get('Firma') ?? null,
|
||||||
|
prioritaet: 2,
|
||||||
|
aufwandMinuten: 60,
|
||||||
|
faelligAm: tageVersetzt(2),
|
||||||
|
quelle: 'ZAMMAD',
|
||||||
|
quelleRef: 'demo-38421',
|
||||||
|
quelleUrl: 'https://zammad.example.invalid/ticket/38421',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 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)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API-Tokens -----------------------------------------------------------
|
||||||
|
// Erfundene Hashes: Mit diesen Zeilen laesst sich nichts aufrufen, sie
|
||||||
|
// zeigen nur, wie die Liste aussieht.
|
||||||
|
await db.apiToken.deleteMany({})
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
name: 'Wartungsplaner-Import',
|
||||||
|
praefix: 'k7m2xq4b',
|
||||||
|
hash: 'demo-1',
|
||||||
|
rechte: 'erfassen',
|
||||||
|
erstelltAm: new Date(Date.UTC(2026, 0, 14, 9, 30)),
|
||||||
|
letzteNutzung: new Date(Date.UTC(2026, 2, 17, 5, 12)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
name: 'Auswertung Homelab',
|
||||||
|
praefix: 'r3wp9dt4',
|
||||||
|
hash: 'demo-2',
|
||||||
|
rechte: 'lesen',
|
||||||
|
gueltigBis: new Date(Date.UTC(2026, 8, 30, 22, 0)),
|
||||||
|
erstelltAm: new Date(Date.UTC(2026, 1, 2, 18, 45)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
name: 'Altes Skript',
|
||||||
|
praefix: 'z8n4vt6c',
|
||||||
|
hash: 'demo-3',
|
||||||
|
rechte: 'lesen,schreiben',
|
||||||
|
erstelltAm: new Date(Date.UTC(2025, 10, 3, 11, 0)),
|
||||||
|
widerrufenAm: new Date(Date.UTC(2026, 1, 20, 8, 15)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
|
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AufgabenTyp" AS ENUM ('AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN', 'SPIEGEL');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AufgabenStatus" AS ENUM ('OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Quelle" AS ENUM ('MANUELL', 'ZAMMAD', 'WARTUNGSPLANER', 'TELEGRAM', 'SPIEGEL', 'MAIL', 'API', 'MCP');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ProjektStatus" AS ENUM ('AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "projekt" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"beschreibung" TEXT,
|
||||||
|
"status" "ProjektStatus" NOT NULL DEFAULT 'AKTIV',
|
||||||
|
"bereichId" TEXT,
|
||||||
|
"zielAm" TIMESTAMP(3),
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "projekt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "aufgabe" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"titel" TEXT NOT NULL,
|
||||||
|
"notiz" TEXT,
|
||||||
|
"typ" "AufgabenTyp" NOT NULL DEFAULT 'AUFGABE',
|
||||||
|
"status" "AufgabenStatus" NOT NULL DEFAULT 'OFFEN',
|
||||||
|
"prioritaet" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"aufwandMinuten" INTEGER,
|
||||||
|
"faelligAm" TIMESTAMP(3),
|
||||||
|
"erledigtAm" TIMESTAMP(3),
|
||||||
|
"bereichId" TEXT,
|
||||||
|
"projektId" TEXT,
|
||||||
|
"quelle" "Quelle" NOT NULL DEFAULT 'MANUELL',
|
||||||
|
"quelleUrl" TEXT,
|
||||||
|
"quelleRef" TEXT,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "aufgabe_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "projekt_status_idx" ON "projekt"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "projekt_bereichId_idx" ON "projekt"("bereichId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "projekt_name_key" ON "projekt"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "aufgabe_status_faelligAm_idx" ON "aufgabe"("status", "faelligAm");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "aufgabe_bereichId_status_idx" ON "aufgabe"("bereichId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "aufgabe_projektId_idx" ON "aufgabe"("projektId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "aufgabe_typ_status_idx" ON "aufgabe"("typ", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "aufgabe_quelle_quelleRef_key" ON "aufgabe"("quelle", "quelleRef");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "projekt" ADD CONSTRAINT "projekt_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_projektId_fkey" FOREIGN KEY ("projektId") REFERENCES "projekt"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "KalenderAnbieter" AS ENUM ('GRAPH', 'GOOGLE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ZeigtAls" AS ENUM ('FREI', 'GEBUCHT', 'MIT_VORBEHALT', 'ABWESEND', 'WOANDERS', 'UNBEKANNT');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "bereich" ADD COLUMN "standardKalenderkontoId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "kalenderkonto" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"anbieter" "KalenderAnbieter" NOT NULL,
|
||||||
|
"anzeigename" TEXT NOT NULL,
|
||||||
|
"extKontoId" TEXT NOT NULL,
|
||||||
|
"kalenderId" TEXT,
|
||||||
|
"zugriffstoken" TEXT,
|
||||||
|
"aktualisierungstoken" TEXT,
|
||||||
|
"tokenGueltigBis" TIMESTAMP(3),
|
||||||
|
"bereiche" TEXT,
|
||||||
|
"deltaVerweis" TEXT,
|
||||||
|
"letzterAbgleich" TIMESTAMP(3),
|
||||||
|
"letzterFehler" TEXT,
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "kalenderkonto_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "termin" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kalenderkontoId" TEXT NOT NULL,
|
||||||
|
"extId" TEXT NOT NULL,
|
||||||
|
"etag" TEXT,
|
||||||
|
"betreff" TEXT,
|
||||||
|
"beginn" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ende" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ganztaegig" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"zeigtAls" "ZeigtAls" NOT NULL DEFAULT 'GEBUCHT',
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "termin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "kalenderkonto_benutzerId_aktiv_idx" ON "kalenderkonto"("benutzerId", "aktiv");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "kalenderkonto_benutzerId_anbieter_extKontoId_kalenderId_key" ON "kalenderkonto"("benutzerId", "anbieter", "extKontoId", "kalenderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "termin_kalenderkontoId_beginn_idx" ON "termin"("kalenderkontoId", "beginn");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "termin_beginn_ende_idx" ON "termin"("beginn", "ende");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "termin_kalenderkontoId_extId_key" ON "termin"("kalenderkontoId", "extId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "bereich" ADD CONSTRAINT "bereich_standardKalenderkontoId_fkey" FOREIGN KEY ("standardKalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "kalenderkonto" ADD CONSTRAINT "kalenderkonto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "termin" ADD CONSTRAINT "termin_kalenderkontoId_fkey" FOREIGN KEY ("kalenderkontoId") REFERENCES "kalenderkonto"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Made the column `kalenderId` on table `kalenderkonto` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "kalenderkonto" ALTER COLUMN "kalenderId" SET NOT NULL,
|
||||||
|
ALTER COLUMN "kalenderId" SET DEFAULT 'standard';
|
||||||
@@ -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");
|
||||||