# Planer Aufgaben, Ideen, Projekte und Zeitblöcke an einem Ort. Die Kernidee: Kalender aus Microsoft 365 (Firma, Gewerbe) und Google (privat) lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben wird. > **Stand:** Meilenstein 4 von 8. Die Kernidee steht: Aufgaben lassen sich > per Ziehen und Ablegen als Zeitblöcke in den Kalender schreiben, und > Verschiebungen von dort kommen zurück. Es folgen Benachrichtigungen, > Integrationen, Schnellerfassung vom Handy und ein MCP-Server. Der > [Fahrplan](ROADMAP.md) sagt, was wann folgt. - **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone` zur ersten Anmeldung - **[Handbuch](docs/handbuch/)** – Anleitung für den Alltag - **[Fahrplan](ROADMAP.md)** – was geplant ist und was bewusst nicht - **[Änderungen](CHANGELOG.md)** – was sich je Version getan hat ## Funktionsumfang ### Heute - **Schnellerfassung** mit einem einzigen Feld; alles ohne Bereich sammelt sich in der **Inbox** und wird später einsortiert - **Aufgaben** mit Typ, Status, Priorität, Aufwand und Fälligkeit, sortiert nach Dringlichkeit und filterbar über die Adresszeile - **Ideen** getrennt von Aufgaben — ohne Termin, per Klick übernehmbar - **Projekte** mit Arbeitspaketen, Meilensteinen und Fortschritt - **Kalender lesen** aus Microsoft 365 und Google; die Startseite zeigt je Bereich, wie voll der heutige Tag schon ist - **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten Kalenderterminen, Verschiebungen im Kalender kommen zurück - **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)) - **MCP-Server** für Sprachmodelle — dieselben Tokens, dieselben Rechte ([Beschreibung](docs/MCP.md)) - Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und Passwort — je Instanz einzeln zuschaltbar - Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird serverseitig geprüft und hinterlässt einen Protokolleintrag - Bereiche mit Farbe, Arbeitsfenster und Tageskapazität je Wochentag - Hell und Dunkel, Oberfläche durchgehend auf Deutsch - Betrieb über Docker Compose, mit Sicherung, Aktualisierung und Healthcheck ### Was noch kommen könnte Der [Fahrplan](ROADMAP.md) ist abgearbeitet. Was danach denkbar wäre, steht dort unter „Ideen für später" — und was bewusst nicht kommt, gleich darunter. ## Bildergalerie | | Hell | Dunkel | | ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | Anmelden | ![Anmeldeseite, hell](docs/bilder/anmelden-hell-schreibtisch.png) | ![Anmeldeseite, dunkel](docs/bilder/anmelden-dunkel-schreibtisch.png) | | Start | ![Startseite, hell](docs/bilder/start-hell-schreibtisch.png) | ![Startseite, dunkel](docs/bilder/start-dunkel-schreibtisch.png) | | Inbox | ![Inbox, hell](docs/bilder/inbox-hell-schreibtisch.png) | ![Inbox, dunkel](docs/bilder/inbox-dunkel-schreibtisch.png) | | Woche | ![Woche, hell](docs/bilder/woche-hell-schreibtisch.png) | ![Woche, dunkel](docs/bilder/woche-dunkel-schreibtisch.png) | | Aufgaben | ![Aufgaben, hell](docs/bilder/aufgaben-hell-schreibtisch.png) | ![Aufgaben, dunkel](docs/bilder/aufgaben-dunkel-schreibtisch.png) | | Projekte | ![Projekte, hell](docs/bilder/projekte-hell-schreibtisch.png) | ![Projekte, dunkel](docs/bilder/projekte-dunkel-schreibtisch.png) | | Ideen | ![Ideen, hell](docs/bilder/ideen-hell-schreibtisch.png) | ![Ideen, dunkel](docs/bilder/ideen-dunkel-schreibtisch.png) | | Kalender | ![Kalenderkonten, hell](docs/bilder/kalenderkonten-hell-schreibtisch.png) | ![Kalenderkonten, dunkel](docs/bilder/kalenderkonten-dunkel-schreibtisch.png) | | Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) | | Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) | | Meldungen | ![Benachrichtigungen, hell](docs/bilder/benachrichtigungen-hell-schreibtisch.png) | ![Benachrichtigungen, dunkel](docs/bilder/benachrichtigungen-dunkel-schreibtisch.png) | | Integrationen | ![Integrationen, hell](docs/bilder/integrationen-hell-schreibtisch.png) | ![Integrationen, dunkel](docs/bilder/integrationen-dunkel-schreibtisch.png) | | Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) | Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten. ## Die zwei Instanzen Ein Abbild, zwei Deployments, gesteuert über `INSTANZ`. Die Umschaltung wird **serverseitig erzwungen**: Was eine Instanz nicht darf, liefert dort 404 und lädt seine Konfiguration gar nicht erst. | | `firma` | `daheim` | | -------------- | -------------------------------------- | ------------------------------ | | Anmeldung | OIDC Entra ID | OIDC authentik | | Kalenderkonten | Graph (Firmen-Tenant) | Graph (Gewerbe-Tenant), Google | | Mail | Graph Firmenpostfach | Graph Gewerbepostfach, Gmail | | Integrationen | Zammad, Wartungsplaner, Spiegel senden | Telegram, Spiegel empfangen | Ein falsch geschriebener Wert bricht den Start ab, statt still auf einen Standard zu fallen — in der Firma dürfen keine Provider anlaufen, die dort nichts zu suchen haben. ## Umgebungsvariablen Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example). `.env` gehört niemals ins Git. ### Pflicht | Variable | Beispiel | Bedeutung | | ----------------- | ---------------------------------------------------- | ---------------------------------------------- | | `INSTANZ` | `daheim` | `firma` oder `daheim` | | `APP_URL` | `https://planer.example.de` | Öffentliche Adresse, ohne Schrägstrich am Ende | | `DATABASE_URL` | `postgresql://planer:…@db:5432/planer?schema=public` | Verbindung zur Datenbank | | `AUTH_SECRET` | _(32 Byte Base64)_ | Signiert Sitzungen. `openssl rand -base64 32` | | `WEBAUTHN_RP_ID` | `planer.example.de` | Nackte Domain, ohne Schema und Port | | `WEBAUTHN_ORIGIN` | `https://planer.example.de` | Exakt die Herkunft im Browser | ### Datenbank im Compose-Stapel | Variable | Standard | Bedeutung | | ------------------- | ---------- | ---------------------------------------------- | | `POSTGRES_USER` | `planer` | Benutzer der Datenbank | | `POSTGRES_PASSWORD` | _(keiner)_ | Pflicht. Muss zu `DATABASE_URL` passen | | `POSTGRES_DB` | `planer` | Name der Datenbank | | `POSTGRES_PORT` | `5432` | Nach außen veröffentlichter Port | | `APP_PORT` | `3000` | Nach außen veröffentlichter Port der Anwendung | ### Anmeldung | Variable | Standard | Bedeutung | | -------------------- | -------- | ------------------------------------------ | | `AUTH_TRUST_HOST` | `false` | Hinter einem Reverse Proxy auf `true` | | `ANMELDUNG_PASSWORT` | `false` | Anmeldung mit E-Mail und Passwort erlauben | | `OIDC_NAME` | _(leer)_ | Beschriftung des Anmeldeknopfs | | `OIDC_ISSUER` | _(leer)_ | Issuer-URL des Anbieters | | `OIDC_CLIENT_ID` | _(leer)_ | Client-ID | | `OIDC_CLIENT_SECRET` | _(leer)_ | Client-Secret | | `WEBAUTHN_RP_NAME` | `Planer` | Name, den das Gerät beim Passkey anzeigt | OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der Rückleit-URI beim Anbieter lautet `/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 ` | | `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 | Variable | Standard | Bedeutung | | ---------------- | --------------- | ---------------------------------------------------------------------------------------- | | `ADMIN_EMAIL` | _(leer)_ | Erstes Verwaltungskonto, wird vom Seed angelegt | | `ADMIN_PASSWORT` | _(leer)_ | Mindestens 12 Zeichen. Ohne den Wert entsteht ein Konto, das nur OIDC oder Passkey kennt | | `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen | | `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` | ## Kalenderanbieter einrichten Beides ist einmalige Handarbeit beim Anbieter. Der Planer fragt Lese- **und** Schreibrechte an: ab M4 schreibt er Zeitblöcke zurück, und eine zweite Zustimmungsrunde für jedes Konto wäre lästiger als eine etwas breitere Berechtigung von Anfang an. ### Microsoft 365 (Entra ID) 1. → **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**: `/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. → 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: `/api/kalender/rueckleitung/google` 7. Client-ID und Clientschlüssel nach `GOOGLE_CLIENT_ID` und `GOOGLE_CLIENT_SECRET`. > Bleibt das Projekt im Status _Test_, laufen die Aktualisierungstoken nach > sieben Tagen ab und die Konten müssen neu verbunden werden. Für den > Dauerbetrieb den Zustimmungsbildschirm **veröffentlichen**. Nach dem Eintragen: `docker compose up -d` und im Planer unter **Kalender** verbinden. Die Anleitung für den Alltag steht im [Handbuch](docs/handbuch/kalenderkonten.md). ## Betrieb ### Starten und stoppen ```bash docker compose up -d # starten docker compose logs -f app # Protokoll mitlesen docker compose down # stoppen, Daten bleiben ``` Der Stapel besteht aus drei Diensten: `db`, ein Einmaldienst `migration`, der die Migrationen einspielt und sich beendet, und `app`. ### Healthcheck ```bash curl https://planer.example.de/api/health ``` ```json { "status": "ok", "version": "0.1.0", "instanz": "daheim", "datenbank": "ok", "zeit": "2026-03-17T08:20:00.000Z" } ``` Die Auskunft ist bewusst ohne Anmeldung erreichbar und enthält deshalb keine Zählstände, Namen oder Adressen. Bei `"status": "fehler"` antwortet der Endpunkt mit HTTP 503. ### Aktualisieren ```bash ./update.sh # auf den neuesten Tag ./update.sh --nur-pruefen # nur zeigen, was passieren würde ./update.sh --version v0.2.0 # auf eine bestimmte Version ./update.sh --ja # ohne Rückfrage ``` Das Skript prüft die Voraussetzungen, zeigt die Änderungen aus dem CHANGELOG, legt eine Sicherung an, wechselt auf den Ziel-Tag, baut, startet und wartet auf einen gesunden `/api/health` mit der erwarteten Version. Klappt das nicht, rollt es auf die vorherige Version zurück und nennt den Pfad der Sicherung. Jeder Lauf wird nach `logs/update-.log` protokolliert. Die **Datenbank wird beim Zurückrollen nicht automatisch zurückgespielt** — das wäre bei einem Teilfehler gefährlicher als hilfreich. Das Skript nennt den Befehl dafür. ### Sicherung und Wiederherstellung ```bash ./backup.sh # Datenbank, .env und Uploads ./backup.sh --tage 90 # längere Aufbewahrung ./backup.sh --nur-datenbank # nur der Dump ``` Je Lauf entstehen zwei Dateien in `sicherungen/`: ein `pg_dump` im Custom-Format und ein tar.gz mit `.env` und `uploads`. Wiederherstellen: ```bash # Datenbank docker compose exec -T db pg_restore -U planer -d planer --clean --if-exists \ < sicherungen/planer-db-2026-03-17_02-00-00.dump # Dateien tar -xzf sicherungen/planer-dateien-2026-03-17_02-00-00.tar.gz ``` Die Sicherung enthält `.env` und damit alle Geheimnisse. Sie gehört so geschützt wie die Datenbank selbst. Nächtlich per cron: ```cron 0 2 * * * cd /opt/planer && ./backup.sh --tage 30 >> logs/backup.log 2>&1 ``` ## Entwicklung ```bash npm install docker compose up -d db # nur die Datenbank npm run db:migrieren # Migrationen einspielen npm run db:seed # Bereiche und Verwaltungskonto npm run dev # Entwicklungsserver ``` ### Prüfungen ```bash npm run pruefen # Prettier, ESLint, tsc, Vitest, ShellCheck npm run test:integration # gegen eine echte Datenbank npm run test:migration # Migrationen gegen eine Wegwerf-Datenbank npm run test:e2e # Playwright gegen die gebaute Anwendung npm run screenshots # Handbuchbilder neu aufnehmen ``` `npm run pruefen` muss vor jedem Commit grün sein. Fehlt `shellcheck` lokal, wird das Docker-Abbild `koalaman/shellcheck` benutzt. ### Aufbau ``` prisma/ Schema, Migrationen, Seed, Demo-Seed scripts/ Prüf-, Test- und Betriebsskripte src/app/ Seiten und API-Routen; (app) = angemeldeter Bereich src/components/ Bausteine; ui/ enthält die Primitiven src/lib/ Reine Logik ohne DB und ohne Systemzeit, je Datei ein Test src/server/ DB-nahe Dienste, Server Actions, Provider e2e/ Playwright-Rauchtests screenshots/ Aufnahme der Handbuchbilder docs/ Schnellstart, Handbuch, Bilder devlog/ Entwicklungstagebuch und Zeiterfassung ``` Reine Funktionen bekommen die aktuelle Zeit als Parameter, damit sie ohne Tricks prüfbar bleiben. Die Tests laufen bewusst in UTC — so fällt sofort auf, wenn irgendwo die Ortszeit des Servers durchschlägt statt Europe/Berlin. ### Bekannte Meldungen `npm audit` meldet vier Funde in transitiven Abhängigkeiten der **`prisma`-CLI** (`deepmerge-ts` über `@prisma/config`, `mysql2`). Beide sind Entwicklungsabhängigkeiten und erreichen das Laufzeitabbild nicht; MySQL wird ohnehin nie geladen. Der angebotene Fix wäre ein Rückschritt auf Prisma 6. ## Sicherheit - Anmeldung, Passwort und Passkey antworten bei Fehlschlag immer gleich, damit sich nicht abfragen lässt, wer hier ein Konto hat. - Sitzungen sind JWT, aber Rolle und Sperrstatus werden bei jeder Anfrage frisch aus der Datenbank gelesen — eine Sperre wirkt sofort. - Jede schreibende Aktion prüft die Rolle in der Seite **und** noch einmal in der Server Action. Die Oberfläche blendet Knöpfe nur zusätzlich aus. - Audit-Details werden vor dem Speichern gesäubert: Felder, deren Name nach Passwort, Token, Betreff oder Mailinhalt aussieht, werden ersetzt. - Keine Geheimnisse, Tokens, Mailinhalte oder Termin-Betreffs in Protokollen. Gefundene Schwachstellen bitte nicht als Issue anlegen, sondern direkt melden. ## Lizenz Privates Projekt, keine Lizenz zur Weiterverwendung.