From 95756778d8eb2789775773f3382adf5afe6d2393 Mon Sep 17 00:00:00 2001 From: ferdi2go Date: Wed, 16 Sep 2026 06:39:37 +0000 Subject: [PATCH] Rotatory anlegen: Morning-Meeting-Moderation auslosen und nach Nextcloud schreiben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Express-App mit JSON-Speicher unter VCH_DATA_DIR, Monatskalender mit Fotos, Personen mit Rollen und Teilzeit-Freitagen, Abwesenheiten, Feiertage je Bundesland, faire Zufallsauslosung mit Fixieren und Neu-Auslosen. Nextcloud: CalDAV-Discovery, Zielkalender wählbar, Termine je Werktag schreiben und aktualisieren, Abwesenheiten aus Kalender importieren, Personen samt Fotos aus dem Systemadressbuch übernehmen. Co-Authored-By: Claude Fable 5.1 --- .env.example | 10 + .gitignore | 4 + .vch-description | 1 + .vch/PLATFORM.md | 326 ++++++++++++++++ .vch/deploy.yaml | 4 + CLAUDE.md | 29 ++ README.md | 114 ++++++ package-lock.json | 835 ++++++++++++++++++++++++++++++++++++++++ package.json | 21 + public/app.js | 856 +++++++++++++++++++++++++++++++++++++++++ public/index.html | 244 ++++++++++++ public/style.css | 204 ++++++++++ server/env.js | 18 + server/holidays.js | 66 ++++ server/ical.js | 260 +++++++++++++ server/ics.js | 126 ++++++ server/index.js | 612 +++++++++++++++++++++++++++++ server/nextcloud.js | 227 +++++++++++ server/scheduler.js | 151 ++++++++ server/store.js | 63 +++ test/env.test.js | 22 ++ test/holidays.test.js | 31 ++ test/ical.test.js | 114 ++++++ test/ics.test.js | 51 +++ test/nextcloud.test.js | 149 +++++++ test/scheduler.test.js | 159 ++++++++ 26 files changed, 4697 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .vch-description create mode 100644 .vch/PLATFORM.md create mode 100644 .vch/deploy.yaml create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 server/env.js create mode 100644 server/holidays.js create mode 100644 server/ical.js create mode 100644 server/ics.js create mode 100644 server/index.js create mode 100644 server/nextcloud.js create mode 100644 server/scheduler.js create mode 100644 server/store.js create mode 100644 test/env.test.js create mode 100644 test/holidays.test.js create mode 100644 test/ical.test.js create mode 100644 test/ics.test.js create mode 100644 test/nextcloud.test.js create mode 100644 test/scheduler.test.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cc6820d --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Nextcloud-Anbindung (CalDAV/CardDAV). Ohne diese Werte läuft die App, nur Export und Import sind deaktiviert. +NEXTCLOUD_URL= # optional z. B. https://cloud.example.de +NEXTCLOUD_USER= # optional Anmeldename (E-Mail-Adresse geht auch); die Benutzer-ID ermittelt die App selbst +NEXTCLOUD_APP_PASSWORD= # optional App-Passwort aus Nextcloud → Einstellungen → Sicherheit → Geräte & Sitzungen +# ID des eigenen Kalenders, falls kein vorhandener Zielkalender gewählt wird +NEXTCLOUD_CALENDAR=morning-meeting +# Nur setzen, wenn die Nextcloud ein selbstsigniertes Zertifikat hat +NEXTCLOUD_INSECURE_TLS=false +# Optionaler Zugriffsschutz der App (HTTP Basic Auth). Leer = kein Passwort. +APP_PASSWORD="" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97725b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +.env +*.log diff --git a/.vch-description b/.vch-description new file mode 100644 index 0000000..7d062da --- /dev/null +++ b/.vch-description @@ -0,0 +1 @@ +Rotatory lost fair und zufällig aus, wer das tägliche Morning Meeting moderiert: pro Wochentag ein Hauptmoderator und ein Nebenmoderator aus der Belegschaft. Urlaub, Krankheit, Feiertage, Teilzeit-Freitage und Personen, die nicht moderieren, werden automatisch berücksichtigt. Die Monatsübersicht zeigt auf jedem Tag die Fotos der beiden Moderatoren, sodass jeder auf einen Blick sieht, wer dran ist. Einzelne Tage lassen sich manuell festlegen oder neu auslosen. Die Belegschaft samt Fotos und die Abwesenheiten kommen auf Wunsch direkt aus der Nextcloud, und auf Knopfdruck landet der Plan als Termine im geteilten Nextcloud-Kalender des Teams. diff --git a/.vch/PLATFORM.md b/.vch/PLATFORM.md new file mode 100644 index 0000000..fd642e0 --- /dev/null +++ b/.vch/PLATFORM.md @@ -0,0 +1,326 @@ +# VCH-Plattform — die Regeln dieser Umgebung + +Diese Datei gehört VCH und wird bei jedem Abgleich **überschrieben**. Projektwissen gehört +in die `CLAUDE.md` daneben, nicht hierher. + +Sie beschreibt, was VCH mit diesem Projekt macht — vor allem die Stellen, an denen ein +Verstoß **nicht als Fehler auffällt, sondern als falsches Ergebnis**: ein Deploy, der grün +meldet und nichts ausliefert, eine Datenbank, die niemand verbindet, ein Audit, das etwas +prüft, das du gar nicht gepusht hast. + +## 1. Zwei Maschinen, nicht eine + +- **Hier (DEV):** ein Debian-LXC. Nutzer `vchuser`, Heimat `/home/vchuser`, Projekte unter + `/home/vchuser/projects/`. code-server auf Port 8080, Claude Code als `claude`, + Git gegen Gitea vorkonfiguriert. +- **Dort (PROD):** eine **andere** Maschine. VCH klont dein Repository dorthin und startet es. + +Daraus folgt die wichtigste Regel überhaupt: **VCH deployt und auditiert deinen gepushten +Commit, nie dein Arbeitsverzeichnis.** Nicht committete Änderungen existieren für Deploy, +Audit und Veröffentlichung nicht — auch dann nicht, wenn hier alles läuft. + +## 2. Vorschau im Browser läuft über einen Proxy + +Deine Entwicklungs-Server sind nicht direkt erreichbar. VCH erkennt lauschende Ports und +stellt zwei Wege bereit: + +1. **Subdomain** (bevorzugt): `https://lxc-./` — deine App liegt auf der + Wurzel, alles verhält sich normal. +2. **Pfad-Proxy** (Rückfallweg): `https:///proxy//p//` — deine App liegt + hinter einem Pfadpräfix. + +Damit beides funktioniert: **relative Pfade verwenden.** + +- `css/style.css` ✅ — nicht `/css/style.css` ❌ +- `fetch('api/daten')` ✅ — nicht `fetch('/api/daten')` ❌ +- Wenn absolute Pfade unvermeidbar sind: `` in den ``. + +Rahmenwerke: **Vite** `base: './'` · **Create React App** `"homepage": "."` in der +`package.json` · **Next.js** Vorgabe belassen (Subdomain) oder `basePath` setzen (Pfad-Proxy) +· **Express** `express.static` genügt, solange das HTML relativ verweist. + +## 3. Dateien, die VCH im Projektstamm liest + +| Datei | Pflicht | Wozu | +|---|---|---| +| `README.md` | ja | Audit-Prüfung `readme` — blockiert die Veröffentlichung | +| `.vch-description` | ja | Audit-Prüfung `description`; der Text steht später im Showcase | +| `.env.example` | wenn die App Umgebungsvariablen liest | die **Deklaration** aller Variablen (Abschnitt 6) | +| `.vch/deploy.yaml` | wenn die Autoerkennung nicht reicht | wie gebaut und gestartet wird (Abschnitt 5) | +| `CLAUDE.md` | — | dein Projektwissen; VCH fasst sie nicht mehr an | + +`README.md` muss beschreiben, **was** das Projekt tut, wie man es installiert, wie man es +startet und worauf es aufbaut. Die unveränderte Vorlage besteht die Prüfung nicht. + +`.vch-description` ist **nicht technisch**: was das Projekt tut, welches Problem es löst, +für wen. Kein Technologie-Stapel, keine Installationsanleitung — die stehen im README. +Halte beide aktuell, wenn sich Zweck oder Aufbau ändern. + +## 4. Der Port gehört VCH + +VCH weist jedem Projekt auf der PROD-Maschine einen festen Port zu (Bereich `13000–13999`) +und hält ihn über alle Deploys hinweg stabil. Du wählst ihn nicht und stimmst dich mit +niemandem ab. + +**Deine App MUSS auf `PORT` aus der Umgebung lauschen:** + +- Node/Express: `app.listen(process.env.PORT || 3000)` +- Next.js: `next start` beachtet `PORT` von selbst +- Python: `gunicorn app:app -b 0.0.0.0:$PORT` bzw. `uvicorn main:app --port $PORT` +- Go/andere: den Wert aus der Umgebung lesen, nicht hartkodieren + +Ein fest verdrahteter Port läuft hier und scheitert dort am Healthcheck. `PORT` und +`VCH_DATA_DIR` sind **VCH-eigene** Schlüssel: setzt du sie selbst — in `.env.example`, im +Env-Editor oder im Compose — wird dein Wert verworfen. + +## 5. `.vch/deploy.yaml` — die vollständige Referenz + +Ohne diese Datei versucht VCH, das Projekt selbst zu erkennen (Abschnitt 5.3). Reicht das +nicht, bricht der Deploy ab **und legt dir eine fertige Vorlage vor**, die du nur noch +committen musst. + +### 5.1 Alle Felder + +| Feld | Typ | Vorgabe | Bedeutung | +|---|---|---|---| +| `runtime` | `auto` \| `systemd` \| `docker` | `auto` | Wie gestartet wird. `auto`: Dockerfile oder Compose vorhanden → `docker`, sonst `systemd`. | +| `build` | Zeichenkette | — | Shell-Befehl im Release-Verzeichnis. **Nur `systemd`** — bei `docker` baut das Dockerfile. | +| `start` | Zeichenkette | — | Startbefehl des Dienstes. **Nur `systemd`.** Muss `$PORT` binden. | +| `port` | Ganzzahl 1–65535 | `3000` | **Nur `docker`:** der Port, auf dem deine App **im Container** lauscht. VCH bildet den zugewiesenen Host-Port darauf ab. | +| `health` | Pfad | `/` | Pfad, der 2xx oder 3xx liefert, sobald die App bereit ist. | +| `env` | Liste von Zeichenketten | `[]` | Zusätzliche Variablennamen, die VCH ausliefern soll. Wird mit den Schlüsseln aus `.env.example` vereinigt. | +| `compose` | Pfad | — | Welche Compose-Datei gilt, wenn mehrere gefunden werden. | + +Unbekannte Felder und Tippfehler in `runtime` brechen den Deploy mit einer benannten +Fehlermeldung ab — sie werden nicht still ignoriert. + +### 5.2 Beispiele + +Node mit eigenem Build (systemd): + +```yaml +runtime: systemd +build: "npm ci && npm run build" +start: "npm start" +health: / +``` + +Python (systemd) — beachte `$PORT` im Startbefehl: + +```yaml +runtime: systemd +build: "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" +start: ".venv/bin/gunicorn app:app -b 0.0.0.0:$PORT" +health: / +``` + +Statische Seite (systemd) — irgendetwas muss lauschen: + +```yaml +runtime: systemd +build: "npm ci && npm run build" +start: "python3 -m http.server $PORT --directory dist" +``` + +Monorepo, Code in einem Unterordner: + +```yaml +runtime: systemd +build: "cd backend && npm ci && npm run build" +start: "cd backend && npm start" +``` + +Dockerfile: + +```yaml +runtime: docker +port: 3000 # der Port INNERHALB des Containers +health: / +``` + +Mehrere Compose-Dateien im Repository: + +```yaml +runtime: docker +compose: docker/compose.yaml +``` + +### 5.3 Was ohne die Datei passiert + +In dieser Reihenfolge: + +1. Ein echtes `Dockerfile` gewinnt → `runtime: docker` mit dem Image. +2. Eine Compose-Datei, die `${VCH_PORT}` veröffentlicht → Compose-Deploy. +3. Eine Compose-Datei, die **nur Infrastruktur** startet (Postgres, Redis, … und keinen + App-Dienst), wird als Entwicklungs-Beiwerk erkannt und **nicht** deployt — VCH sucht + weiter nach der Sprache. +4. Eine Compose-Datei mit App-Dienst, die `${VCH_PORT}` **nicht** veröffentlicht → Abbruch + mit Vorlage (Abschnitt 10). +5. `package.json` im Stamm: `build`-Skript → Build, `start`-Skript bzw. `main` → Start. + Paketmanager wird am Lockfile erkannt (`pnpm`/`yarn`/`npm`). +6. Alles andere — Python, PHP/Laravel, Go, reines Frontend ohne `start`, Code nur in + Unterordnern — endet mit einer passenden Vorlage zum Committen. + +## 6. Umgebungsvariablen: `.env.example` ist die Deklaration + +VCH liest keine Umgebungsvariablen aus deinem Code. **Was in einer Beispieldatei steht, +existiert; alles andere nicht.** + +- Erkannte Dateinamen: `.env.example`, `.env.sample`, `.env.template` sowie dieselben ohne + führenden Punkt (`env.example`, …). Der erste Fund je Schlüssel gewinnt. +- Gesucht wird **im ganzen Repository** (ohne `node_modules`, `.git`, `dist`, `build`, + `.next`, `out`, `vendor`, `coverage`, `.turbo`), Stammdateien zuerst, höchstens 25 Dateien. +- Jede gefundene Beispieldatei bestimmt ein Ziel: `backend/.env.example` → VCH schreibt die + Produktionswerte nach `backend/.env`. Für Monorepos ist das der Weg, mehrere `.env` zu + bekommen. + +**Was den Deploy blockiert:** ein Schlüssel, der **keinen Wert nennt** (`KEY=`) und für den +noch kein Produktionswert hinterlegt ist. Nicht blockierend sind: + +```dotenv +API_URL=https://beispiel.test # Vorgabewert vorhanden +OPTIONAL_TOKEN="" # ausdrücklich leer erlaubt +FEATURE_FLAG= # optional +``` + +Der Marker muss direkt hinter dem `#` stehen (`# optional`); Fließtext, in dem das Wort +vorkommt, zählt nicht. Die Vorgabewerte sind **Hinweise für den Menschen** — in Produktion +wird nichts davon verwendet. + +Im Zweifel: + +```bash +grep -RhoE "process\.env\.[A-Z_]+" src/ | sort -u +``` + +und jeden Treffer in die `.env.example` schreiben. + +**Bei Docker:** nur Variablen mit dem Präfix `NEXT_PUBLIC_`, `VITE_` oder `PUBLIC_` gehen als +`--build-arg` in den Build (sie sind ohnehin im Auslieferungsstand sichtbar) — dafür brauchst +du passende `ARG`-Zeilen im Dockerfile. Alle übrigen Werte kommen erst zur Laufzeit über +`--env-file` und landen nie im Image. + +## 7. Datenbank + +Deklariert deine App Verbindungsvariablen, bietet VCH eine verwaltete Datenbank an und trägt +die Werte **automatisch** in genau die Schlüssel ein, die du deklariert hast. Du musst deine +Variablennamen also nicht ändern — VCH erkennt sie. + +**Erkannt wird** (Groß-/Kleinschreibung egal): + +- Verbindungszeichenketten: `DATABASE_URL`, `DB_URL`, jeder Name auf `_DSN` +- Einzelteile mit Familienwort (`DB`, `DATABASE`, `POSTGRES`, `MYSQL`) plus Rolle: + `..._HOST`, `..._PORT`, `..._USER`/`..._USERNAME`, `..._PASSWORD`/`..._PASS`, + `..._NAME`/`..._DATABASE`/`..._DB` +- Die üblichen nackten Namen: `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, + `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`, `POSTGRES_DB` + +**Bewusst nicht erkannt** (und deshalb nie überschrieben): + +- alles mit `ROOT` im Namen (`POSTGRES_ROOT_PASSWORD`, `MYSQL_ROOT_PASSWORD`) — das sind die + Zugangsdaten des Datenbankservers, nicht die deiner App +- `SENTRY_DSN` und verwandte Fehlermelder (`GLITCHTIP`, `BUGSNAG`, `ROLLBAR`, `AIRBRAKE`, + `RAYGUN`, `APPSIGNAL`) — ein DSN ist dort keine Datenbank +- `HISTORY_DB=/data/verlauf.sqlite` und Ähnliches: ein `..._DB` ohne Datenbank-Familienwort + im übrigen Namen gilt nicht als Verbindung + +Die **Engine** (Postgres oder MySQL) leitet VCH aus deinen Schlüsselnamen und dem Schema in +`DATABASE_URL` ab; ist das mehrdeutig, entscheidest du im Projektarbeitsplatz. + +**Bringt dein Compose seine eigene Datenbank mit**, startet VCH sie mit und bietet **keine** +zweite an. Achte dann selbst auf ein dauerhaftes Volume — ein Datenbankdienst ohne Volume +verliert seine Daten beim nächsten Neubau, und VCH warnt nur. + +Eine Compose-Datei, die **nur** eine Datenbank startet, ist Entwicklungs-Beiwerk: sie läuft in +Produktion nicht mit. Nimm dort die verwaltete Datenbank. + +## 8. Dauerhafte Daten: `VCH_DATA_DIR` + +Jedes Release liegt in einem eigenen Verzeichnis und verschwindet wieder. Was überleben soll — +SQLite-Datei, Uploads, erzeugte Dateien — gehört in das Verzeichnis aus **`VCH_DATA_DIR`**. + +```js +const datenVerzeichnis = process.env.VCH_DATA_DIR || './data'; +``` + +VCH setzt die Variable bei allen Betriebsarten (bei Docker als Einhängepunkt `/data`). **Lies +sie zur Laufzeit**, schreibe den Pfad nirgends fest und trage sie nicht in `.env`-Dateien ein. +Ein Verzeichnis relativ zum Release (`./data`) ist beim nächsten Deploy weg. + +Der Prüfling während des Audits (Abschnitt 9) bekommt diesen Speicher **nicht** — er läuft +gegen einen leeren. Das ist Absicht: zwei Prozesse dürfen nicht gleichzeitig auf dieselbe +SQLite-Datei schreiben. + +## 9. Wie ein Deploy abläuft + +``` +Klonen → Bauen → Healthcheck (Prüfling) → Audit → Umschalten → Live +``` + +1. **Klonen.** VCH klont den Deploy-Branch auf die PROD-Maschine — genau den gepushten Stand. +2. **Torprüfung.** Fehlt ein blockierender Wert aus `.env.example` (Abschnitt 6), endet es hier, + bevor irgendetwas läuft. +3. **Bauen** nach `.vch/deploy.yaml` bzw. Autoerkennung. +4. **Healthcheck.** Der neue Stand startet als **Prüfling** auf einem freien Port aus dem + Bereich `14000–14999`, neben der weiterhin laufenden alten Fassung. VCH ruft dort deinen + `health`-Pfad auf: rund 30 Sekunden lang, alle 1,5 s ein Versuch, Erfolg beim ersten + 2xx/3xx. Bis dahin muss die App antworten. +5. **Audit** (Abschnitt 10) gegen den deployten Commit. +6. **Umschalten.** Erst wenn das Audit besteht, wird der Prüfling zur laufenden Fassung auf + dem stabilen Port. **Ein durchgefallenes Audit heißt: der alte Stand bleibt live.** + +Vor dem Umschalten legt VCH einen Datenschnappschuss an. Geht das Umschalten schief, werden +erst die Daten und dann der Code zurückgerollt. Frühere Releases lassen sich im +Projektarbeitsplatz per Rollback zurückholen. + +**Auto-Deploy:** ist er eingeschaltet, löst jeder Push auf den konfigurierten Deploy-Branch +einen Deploy aus. Pushes auf andere Branches werden ignoriert. + +## 10. Audit — was geprüft wird + +Das Audit läuft nicht hier, sondern in einem eigenen Runner gegen einen **sauberen Checkout +des deployten Commits**. Nicht Gepushtes wird nicht geprüft — und ein `.gitignore`-Eintrag +macht eine Datei für das Audit unsichtbar. + +| Prüfung | Schwere | Darf entfallen | +|---|---|---| +| `readme` | Fehler | nein | +| `secrets` | Fehler | nein | +| `description` | Warnung | nein | +| `lint` | Fehler | ja | +| `typescript` | Fehler | ja | +| `npm-audit` | Fehler | ja | + +- Eine **Warnung** blockiert nie. `description` hält dich also nicht auf, kostet dich aber den + Platz im Showcase. +- „Darf entfallen" heißt: die Prüfung meldet `skipped`, wenn sie auf dein Projekt nicht passt + (kein TypeScript, kein npm). Eine Prüfung, die **nicht** entfallen darf, aber trotzdem + `skipped` meldet, gilt als **durchgefallen** — sie ist nie gelaufen. +- `secrets` sucht eingecheckte Zugangsdaten. Echte Werte gehören nie ins Repository: in die + `.env.example` nur Beispiele, die echten Werte in den Env-Editor von VCH. + +## 11. Veröffentlichen + +- **Intern erreichbar** ist dein Projekt, sobald ein Deploy läuft — ohne Audit, ohne weiteren + Schritt, unter `http://:`. +- **Öffentlich** unter einer Subdomain mit SSL wird es erst mit laufendem Deploy **und** + bestandenem Audit für genau diesen Deploy. Die Subdomain kommt oben drauf; entfernst du sie + wieder, bleibt das Projekt intern erreichbar. +- Im **Showcase** erscheinen veröffentlichte Projekte mit bestandenem Audit und einer + eingecheckten `.vch-description`. + +## 12. Compose — die vier Regeln, an denen es sonst scheitert + +1. **Der App-Dienst muss seinen Port als `ports: ["${VCH_PORT}:"]` veröffentlichen.** + VCH setzt `VCH_PORT` und kann so einen Prüfling neben deiner laufenden Fassung starten. + Ohne diese Zeile bricht der Deploy ab. +2. **VCH führt genau EINE Compose-Datei aus — keine `-f`-Ketten.** Gesucht wird + `compose.yaml`/`compose.yml`/`docker-compose.yaml`/`docker-compose.yml` im Stamm **oder in + einem Unterordner**. Eine Override-Datei (`docker-compose.ports.yml`, `*.override.yml`) + wird **ignoriert** — die `${VCH_PORT}`-Zeile gehört in die Hauptdatei. Bei mehreren + Fundstellen: `compose: ` in die `.vch/deploy.yaml`. +3. **Jeder Dienst, der Produktionswerte braucht, bekommt `env_file: [.env]`.** VCH schreibt + deine Werte in eine `.env` neben der Compose-Datei. +4. **Der ganze Stapel startet.** Datenbanken, Caches und Ähnliches aus deinem Compose laufen + automatisch mit — eine zusätzliche verwaltete Datenbank brauchst du dafür nicht. Dienste + hinter einem `profiles:`-Schlüssel bleiben aus. diff --git a/.vch/deploy.yaml b/.vch/deploy.yaml new file mode 100644 index 0000000..0847acf --- /dev/null +++ b/.vch/deploy.yaml @@ -0,0 +1,4 @@ +runtime: systemd +build: "npm ci --omit=dev" +start: "npm start" +health: /health diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d7594ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +@.vch/PLATFORM.md + + + +# Rotatory + +Wofür dieses Projekt gut ist, und was jemand wissen muss, der hier weiterarbeitet. + +## Befehle + +- **Abhängigkeiten installieren:** `npm install` +- **Entwicklungsserver:** `npm run dev` +- **Tests:** `npm test` +- **Bauen:** `npm run build` +- **Linten:** `npm run lint` + +## Konventionen + +- Aussagekräftige Commit-Nachrichten im Imperativ („Suche ergänzen", nicht „Suche ergänzt") +- Lieber kleine, abgeschlossene Commits als große +- Vor dem Commit linten und formatieren +- Tests für die tragende Fachlogik +- Wenig Abhängigkeiten — was Browser und Node schon können, reicht meistens +- Semantisches HTML, mobil zuerst, bedienbar per Tastatur, ausreichender Kontrast +- Konfiguration über Umgebungsvariablen; keine Zugangsdaten im Quelltext diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a83389 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Rotatory + +Rotatory plant die Moderation des werktäglichen Morning Meetings: Pro Tag werden ein +**Hauptmoderator** und ein **Nebenmoderator** aus der Belegschaft fair und zufällig +ausgelost. Die Monatsansicht zeigt auf jedem Tag die Fotos der beiden Personen. Der fertige +Plan lässt sich per Knopfdruck als Termine in einen Nextcloud-Kalender schreiben. + +## Funktionen + +- Personen mit Vor- und Nachname, optionaler E-Mail, Foto (Upload, wird im Browser auf + 320 px zugeschnitten) und Rolle: Hauptmoderation / Nebenmoderation / beides / keins + (Nicht-Moderator). Feste freie Wochentage für Teilzeitkräfte. Inaktive Personen bleiben + angelegt, werden aber nicht ausgelost. +- Personenimport aus Nextcloud: alle Konten aus dem Systemadressbuch zur Auswahl, Profilbilder + werden übernommen. +- Abwesenheiten (Urlaub, geplante Krankheit, Frei, Sonstiges) je Person mit Zeitraum – manuell + oder importiert aus einem Nextcloud-Kalender mit Terminen wie „Vorname Nachname (Urlaub)", + „… (krank)", „… (frei)". Wiederholungen werden aufgelöst, Namen den Personen zugeordnet, + nicht zuordenbare Namen aufgelistet. +- Feiertage: gesetzliche Feiertage je Bundesland auf Knopfdruck erzeugen, dazu eigene + betriebsfreie Tage. An diesen Tagen wird nicht geplant. +- Auslosung für beliebige Monatsbereiche. Regeln: nur Mo–Fr, keine Feiertage, keine + Abwesenden, niemand an zwei Werktagen hintereinander (wenn vermeidbar), Einsätze + gleichmäßig verteilt (Fairness-Fenster berücksichtigt auch vergangene Einsätze), Rollen + werden abgewechselt, dieselbe Paarung wiederholt sich ungern. +- Einzelne Tage manuell setzen, fixieren (überlebt jede Neuauslosung), neu auslosen oder + als „Kein Meeting" markieren. +- Einsatzstatistik je Monat. +- Export als ICS-Datei oder direkt per CalDAV in einen Nextcloud-Kalender: wahlweise ein + vorhandener (auch geteilter) Kalender oder ein neu angelegter. Wiederholtes Schreiben + aktualisiert bestehende Termine und löscht entfallene; fremde Termine im Zeitraum können auf + Wunsch mit entfernt werden. + +## Installation + +Voraussetzung: Node.js 20 oder neuer. + +```bash +npm install +``` + +## Starten + +```bash +npm run dev # Entwicklung, startet bei Änderungen neu +npm start # Produktion +``` + +Die App lauscht auf `PORT` (Vorgabe 3000) und speichert alle Daten unter `VCH_DATA_DIR` +(Vorgabe `./data`): eine JSON-Datei plus die Fotos. Ein Datenbankserver ist nicht nötig. +Eine `.env` im Projektstamm wird beim Start gelesen (bereits gesetzte Variablen gewinnen). +Beim ersten Start werden die gesetzlichen Feiertage für dieses und nächstes Jahr angelegt; +Vorgaben: Bayern, Meeting 09:30 Uhr, 15 Minuten. + +## Konfiguration + +Alle Variablen sind optional, siehe `.env.example`: + +| Variable | Bedeutung | +|---|---| +| `NEXTCLOUD_URL` | Basis-URL der Nextcloud, z. B. `https://cloud.example.de` | +| `NEXTCLOUD_USER` | Anmeldename, E-Mail-Adresse geht auch; die Benutzer-ID wird per CalDAV-Discovery ermittelt | +| `NEXTCLOUD_APP_PASSWORD` | App-Passwort (Nextcloud → Einstellungen → Sicherheit → Geräte & Sitzungen) | +| `NEXTCLOUD_CALENDAR` | ID des eigenen Kalenders, falls kein vorhandener Zielkalender gewählt wird (Vorgabe `morning-meeting`) | +| `NEXTCLOUD_INSECURE_TLS` | `true`, wenn die Nextcloud ein selbstsigniertes Zertifikat nutzt | +| `APP_PASSWORD` | Wenn gesetzt, schützt HTTP Basic Auth die ganze App (Benutzername beliebig) | + +Ohne Nextcloud-Variablen läuft die App vollständig, nur der Button „Go – schreiben" ist +deaktiviert; der ICS-Download bleibt verfügbar. + +## Nextcloud + +1. Umgebungsvariablen setzen und App neu starten. +2. **Personen** → „Aus Nextcloud importieren …": Konten auswählen, Fotos kommen mit. +3. **Abwesenheiten** → Quellkalender (z. B. „Urlaub/Krankheit") und Zeitraum wählen, + importieren. Vor jeder Auslosung wiederholen, damit neue Urlaube berücksichtigt sind. +4. **Kalender** → „Auslosen …" für die kommenden Monate. +5. **Kalender** → „Nextcloud …": Zielkalender wählen (ein vorhandener geteilter Kalender wird + vorgeschlagen), **Verbindung testen**, Zeitraum wählen und **Go – schreiben**. Je Werktag + entsteht ein Termin „Morning Meeting: Haupt / Neben" zur eingestellten Uhrzeit. + +Termine tragen eine stabile UID je Tag (`rotatory-@`), daher aktualisiert ein +erneutes Schreiben statt zu duplizieren. Termine anderer Herkunft im Zeitraum werden gezählt +und nur auf ausdrücklichen Wunsch (Häkchen) entfernt. + +Mit der Einstellung „Moderatoren als Teilnehmer eintragen" werden die beiden Personen (bei +hinterlegter E-Mail) als Teilnehmer eingetragen; Nextcloud verschickt dann Einladungen. + +## Tests und Prüfung + +```bash +npm test # Auslosung, Feiertage, ICS, iCal/vCard-Parser, CalDAV-Client (mit simulierter Nextcloud) +npm run lint # Syntaxprüfung aller Quelldateien +``` + +## Aufbau + +``` +server/ + index.js Express-App, REST-API, statische Dateien + store.js JSON-Speicher (atomar) und Fotoablage + scheduler.js Auslosungslogik (rein, testbar) + holidays.js Gesetzliche Feiertage je Bundesland + ics.js iCalendar-Erzeugung + nextcloud.js CalDAV/CardDAV-Client (Discovery, PROPFIND, MKCALENDAR, REPORT, PUT, DELETE) + ical.js iCalendar- und vCard-Leser, Namenszuordnung + env.js .env-Leser +public/ + index.html, app.js, style.css Oberfläche ohne Framework, relative Pfade +test/ node:test +``` + +Abhängigkeiten: nur `express`. Fotos werden im Browser per Canvas verkleinert, `fetch` und +`crypto` kommen aus Node. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e94b5a4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,835 @@ +{ + "name": "rotatory", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rotatory", + "version": "1.0.0", + "dependencies": { + "express": "^4.21.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e8cf764 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "rotatory", + "version": "1.0.0", + "description": "Zufällige, faire Moderationsplanung für das Morning Meeting mit Kalenderansicht und Nextcloud-Export", + "type": "module", + "private": true, + "main": "server/index.js", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node server/index.js", + "dev": "node --watch server/index.js", + "test": "node --test test/", + "lint": "for f in server/*.js public/*.js; do node --check \"$f\" || exit 1; done && echo \"Syntax ok\"", + "build": "echo 'Kein Build nötig'" + }, + "dependencies": { + "express": "^4.21.2" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..ee9d0a3 --- /dev/null +++ b/public/app.js @@ -0,0 +1,856 @@ +// Rotatory – Frontend ohne Abhängigkeiten. Alle Pfade relativ (Proxy-tauglich). + +const $ = (sel, root = document) => root.querySelector(sel); +const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; + +const state = { data: null, month: startOfMonth(new Date()) }; + +// ---------- Hilfen ---------- +function pad(n) { return String(n).padStart(2, '0'); } +function iso(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } +function startOfMonth(d) { return new Date(d.getFullYear(), d.getMonth(), 1); } +function monthKey(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; } +function monthRange(fromKey, toKey) { + const [fy, fm] = fromKey.split('-').map(Number); + const [ty, tm] = toKey.split('-').map(Number); + return { from: `${fy}-${pad(fm)}-01`, to: iso(new Date(ty, tm, 0)) }; +} +function todayIso() { return iso(new Date()); } +function fmtDate(isoStr) { + const [y, m, d] = isoStr.split('-'); + return `${d}.${m}.${y}`; +} +function fmtLong(isoStr) { + return new Date(isoStr + 'T12:00:00').toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); +} +const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; + +let toastTimer; +function toast(msg, isError = false) { + const el = $('#toast'); + el.textContent = msg; + el.classList.toggle('error', isError); + el.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { el.hidden = true; }, isError ? 6000 : 3000); +} + +async function api(path, { method = 'GET', body, raw } = {}) { + const opts = { method, headers: {} }; + if (raw) { opts.body = raw.body; opts.headers['Content-Type'] = raw.type; } + else if (body !== undefined) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; } + const res = await fetch(path, opts); + if (res.status === 204) return null; + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; +} + +async function load() { + state.data = await api('api/state'); + renderAll(); +} + +/** Aktion ausführen, Zustand neu laden, Fehler als Hinweis zeigen. */ +async function run(action, okMessage) { + try { + await action(); + await load(); + if (okMessage) toast(okMessage); + } catch (err) { + toast(err.message, true); + } +} + +function personById(id) { return state.data.persons.find((p) => p.id === id); } +function fullName(p) { return p ? `${p.firstName} ${p.lastName}` : 'unbesetzt'; } +function shortName(p) { return p ? `${p.firstName} ${p.lastName.charAt(0)}.` : '?'; } +function initials(p) { return `${p.firstName.charAt(0)}${p.lastName.charAt(0)}`.toUpperCase(); } +function colorFor(id) { + let h = 0; + for (const c of id) h = (h * 31 + c.charCodeAt(0)) >>> 0; + return `hsl(${h % 360} 55% 45%)`; +} +function isAbsent(personId, date) { + return state.data.absences.some((a) => a.personId === personId && a.from <= date && a.to >= date); +} +function holidayFor(date) { return state.data.holidays.find((h) => h.date === date); } + +function avatarEl(p, cls = '') { + if (!p) { + const e = document.createElement('span'); + e.className = `avatar empty ${cls}`; + e.textContent = '?'; + e.setAttribute('aria-label', 'unbesetzt'); + return e; + } + if (p.hasPhoto) { + const img = document.createElement('img'); + img.className = `avatar ${cls}`; + img.src = `photos/${p.id}?v=${p.photoVersion || 0}`; + img.alt = fullName(p); + img.loading = 'lazy'; + return img; + } + const e = document.createElement('span'); + e.className = `avatar ${cls}`; + e.style.background = colorFor(p.id); + e.textContent = initials(p); + e.setAttribute('role', 'img'); + e.setAttribute('aria-label', fullName(p)); + return e; +} + +// ---------- Navigation ---------- +function showView(name) { + const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen']; + if (!known.includes(name)) name = 'kalender'; + if (name === 'abwesenheiten' && state.data) prepareAbsenceImport(); + for (const v of $$('.view')) v.hidden = v.id !== `view-${name}`; + for (const a of $$('.topbar nav a')) { + if (a.dataset.view === name) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); + } +} +window.addEventListener('hashchange', () => showView(location.hash.slice(1))); + +// ---------- Kalender ---------- +function renderCalendar() { + const d = state.month; + $('#cal-title').textContent = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`; + const grid = $('#cal-grid'); + grid.textContent = ''; + const { assignments, persons } = state.data; + const today = todayIso(); + const first = new Date(d.getFullYear(), d.getMonth(), 1); + const last = new Date(d.getFullYear(), d.getMonth() + 1, 0); + // Auf Montag der ersten Woche zurückgehen + const start = new Date(first); + const offset = (first.getDay() + 6) % 7; + start.setDate(first.getDate() - offset); + let planned = 0; + let unfilled = 0; + for (let cur = new Date(start); cur <= last; cur.setDate(cur.getDate() + 1)) { + if (cur.getDay() === 0 || cur.getDay() === 6) continue; + const date = iso(cur); + const outside = cur.getMonth() !== d.getMonth(); + const cell = document.createElement(outside ? 'div' : 'button'); + cell.className = 'day'; + if (!outside) { cell.type = 'button'; cell.dataset.date = date; } + if (outside) cell.classList.add('outside'); + if (date === today) cell.classList.add('today'); + if (date < today) cell.classList.add('past'); + const num = document.createElement('div'); + num.className = 'num'; + num.textContent = cur.getDate(); + cell.appendChild(num); + if (outside) { grid.appendChild(cell); continue; } + + const hol = holidayFor(date); + const a = assignments[date]; + if (hol) { + cell.classList.add('holiday'); + const l = document.createElement('div'); + l.className = 'label'; + l.textContent = hol.name; + cell.appendChild(l); + cell.setAttribute('aria-label', `${fmtLong(date)}, ${hol.name}`); + } else if (a && a.off) { + const l = document.createElement('div'); + l.className = 'label off'; + l.textContent = 'Kein Meeting'; + cell.appendChild(l); + cell.setAttribute('aria-label', `${fmtLong(date)}, kein Meeting`); + } else if (a) { + planned += 1; + if (a.locked) { const lock = document.createElement('span'); lock.className = 'lock'; lock.textContent = '🔒'; lock.title = 'fixiert'; num.appendChild(lock); } + const pair = document.createElement('div'); + pair.className = 'pair'; + const main = personById(a.main); + const co = personById(a.co); + if (!main || !co) unfilled += 1; + pair.appendChild(slotEl(main, 'main', date)); + pair.appendChild(slotEl(co, 'co', date)); + cell.appendChild(pair); + const absentNote = (p) => (p && isAbsent(p.id, date) ? ' (abwesend!)' : ''); + cell.setAttribute('aria-label', `${fmtLong(date)}: Haupt ${fullName(main)}${absentNote(main)}, Neben ${fullName(co)}${absentNote(co)}${a.locked ? ', fixiert' : ''}`); + } else { + cell.setAttribute('aria-label', `${fmtLong(date)}, noch nicht ausgelost`); + } + grid.appendChild(cell); + } + const hint = $('#cal-hint'); + if (persons.length === 0) { hint.textContent = 'Noch keine Personen angelegt – zuerst unter „Personen" die Belegschaft anlegen.'; hint.hidden = false; } + else if (planned === 0) { hint.textContent = 'Für diesen Monat ist noch nichts ausgelost. Mit „Auslosen …" starten.'; hint.hidden = false; } + else if (unfilled > 0) { hint.textContent = `${unfilled} Tag(e) sind nicht vollständig besetzt – im Kalender anklicken und manuell zuweisen oder neu auslosen.`; hint.hidden = false; } + else hint.hidden = true; + + const range = monthRange(monthKey(d), monthKey(d)); + $('#download-ics').href = `api/export.ics?from=${range.from}&to=${range.to}`; + renderStats(range); +} + +function slotEl(p, role, date) { + const slot = document.createElement('div'); + slot.className = 'slot'; + const wrap = document.createElement('span'); + wrap.className = 'avatar-wrap'; + const absent = Boolean(p && isAbsent(p.id, date)); + wrap.appendChild(avatarEl(p, `${role === 'co' ? 'co' : ''}${absent ? ' absent' : ''}`)); + const badge = document.createElement('span'); + badge.className = `badge ${role}`; + badge.textContent = role === 'main' ? 'H' : 'N'; + badge.setAttribute('aria-hidden', 'true'); + wrap.appendChild(badge); + slot.appendChild(wrap); + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = shortName(p); + name.title = fullName(p); + if (absent) { name.classList.add('absent'); name.title += ' – abwesend!'; } + slot.appendChild(name); + return slot; +} + +function renderStats(range) { + const counts = {}; + for (const [date, a] of Object.entries(state.data.assignments)) { + if (date < range.from || date > range.to || a.off) continue; + for (const [role, id] of [['main', a.main], ['co', a.co]]) { + if (!id) continue; + counts[id] = counts[id] || { main: 0, co: 0 }; + counts[id][role] += 1; + } + } + const box = $('#stats'); + box.textContent = ''; + const rows = state.data.persons + .filter((p) => p.active !== false && (p.canMain || p.canCo || counts[p.id])) + .map((p) => ({ p, c: counts[p.id] || { main: 0, co: 0 } })) + .sort((a, b) => (b.c.main + b.c.co) - (a.c.main + a.c.co) || fullName(a.p).localeCompare(fullName(b.p))); + if (rows.length === 0) { box.innerHTML = 'Keine aktiven Moderatoren.'; return; } + for (const { p, c } of rows) { + const chip = document.createElement('span'); + chip.className = 'chip'; + chip.appendChild(avatarEl(p, 'plain')); + const t = document.createElement('span'); + t.textContent = shortName(p); + chip.appendChild(t); + const n = document.createElement('span'); + n.className = 'count'; + n.textContent = `${c.main + c.co}× (H ${c.main} · N ${c.co})`; + chip.appendChild(n); + box.appendChild(chip); + } +} + +$('#prev-month').addEventListener('click', () => { state.month = new Date(state.month.getFullYear(), state.month.getMonth() - 1, 1); renderCalendar(); }); +$('#next-month').addEventListener('click', () => { state.month = new Date(state.month.getFullYear(), state.month.getMonth() + 1, 1); renderCalendar(); }); +$('#today-month').addEventListener('click', () => { state.month = startOfMonth(new Date()); renderCalendar(); }); +$('#cal-grid').addEventListener('click', (e) => { + const cell = e.target.closest('.day[data-date]'); + if (cell) openDayDialog(cell.dataset.date); +}); + +// ---------- Tag-Dialog ---------- +const dayDialog = $('#day-dialog'); +const dayForm = $('#day-form'); +let dayDialogDate = null; + +function fillPersonSelect(select, role, date, selected) { + select.textContent = ''; + const none = document.createElement('option'); + none.value = ''; + none.textContent = '– unbesetzt –'; + select.appendChild(none); + for (const p of state.data.persons.filter((x) => x.active !== false).sort((a, b) => fullName(a).localeCompare(fullName(b)))) { + const o = document.createElement('option'); + o.value = p.id; + const flags = []; + if (role === 'main' && !p.canMain) flags.push('keine Hauptmoderation'); + if (role === 'co' && !p.canCo) flags.push('keine Nebenmoderation'); + if (isAbsent(p.id, date)) flags.push('abwesend'); + o.textContent = fullName(p) + (flags.length ? ` (${flags.join(', ')})` : ''); + select.appendChild(o); + } + select.value = selected || ''; +} + +function openDayDialog(date) { + dayDialogDate = date; + const a = state.data.assignments[date] || {}; + const hol = holidayFor(date); + $('#day-dialog-title').textContent = fmtLong(date); + $('#day-dialog-info').textContent = hol ? `${hol.name} – an diesem Tag findet kein Meeting statt. Der Feiertag lässt sich unter Einstellungen entfernen.` : ''; + fillPersonSelect(dayForm.elements.main, 'main', date, a.main); + fillPersonSelect(dayForm.elements.co, 'co', date, a.co); + dayForm.elements.locked.checked = Boolean(a.locked); + dayForm.elements.off.checked = Boolean(a.off); + for (const el of [dayForm.elements.main, dayForm.elements.co, dayForm.elements.locked, dayForm.elements.off, $('#day-reroll'), $('#day-clear'), dayForm.querySelector('button[type="submit"]')]) el.disabled = Boolean(hol); + dayDialog.showModal(); +} + +dayForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = dayForm.elements; + try { + await api(`api/assignments/${dayDialogDate}`, { method: 'PUT', body: { main: f.main.value || null, co: f.co.value || null, locked: f.locked.checked, off: f.off.checked } }); + dayDialog.close(); + await load(); + toast('Tag gespeichert.'); + } catch (err) { toast(err.message, true); } +}); +$('#day-close').addEventListener('click', () => dayDialog.close()); +$('#day-reroll').addEventListener('click', async () => { + try { + // Nur ein Vorschlag – gespeichert wird erst mit „Speichern" + const a = await api(`api/assignments/${dayDialogDate}/reroll`, { method: 'POST', body: { preview: true } }); + dayForm.elements.main.value = a.main || ''; + dayForm.elements.co.value = a.co || ''; + dayForm.elements.off.checked = false; + toast('Vorschlag ausgelost – mit „Speichern" übernehmen.'); + } catch (err) { toast(err.message, true); } +}); +$('#day-clear').addEventListener('click', async () => { + try { + await api(`api/assignments/${dayDialogDate}`, { method: 'PUT', body: { main: null, co: null, locked: false, off: false } }); + dayDialog.close(); + await load(); + } catch (err) { toast(err.message, true); } +}); + +// ---------- Auslosen-Dialog ---------- +const planDialog = $('#plan-dialog'); +const planForm = $('#plan-form'); +$('#open-plan').addEventListener('click', () => { + const next = new Date(state.month.getFullYear(), state.month.getMonth() + 2, 1); + planForm.elements.from.value = monthKey(state.month); + planForm.elements.to.value = monthKey(next); + $('#plan-result').hidden = true; + planDialog.showModal(); +}); +$('#plan-close').addEventListener('click', () => planDialog.close()); +planForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const r = monthRange(planForm.elements.from.value, planForm.elements.to.value); + const out = $('#plan-result'); + try { + const res = await api('api/schedule/generate', { method: 'POST', body: { ...r, overwrite: planForm.elements.overwrite.checked } }); + await load(); + out.className = 'result'; + out.textContent = `${res.planned} Werktage geplant.` + (res.unfilled.length ? `\n${res.unfilled.length} Tag(e) konnten nicht voll besetzt werden: ${res.unfilled.map(fmtDate).join(', ')}` : ' Alle Tage besetzt.'); + out.hidden = false; + } catch (err) { out.className = 'result error'; out.textContent = err.message; out.hidden = false; } +}); +$('#plan-clear').addEventListener('click', async () => { + const r = monthRange(planForm.elements.from.value, planForm.elements.to.value); + if (!confirm(`Alle nicht fixierten Zuteilungen von ${fmtDate(r.from)} bis ${fmtDate(r.to)} löschen?`)) return; + try { + await api('api/schedule/clear', { method: 'POST', body: r }); + await load(); + $('#plan-result').className = 'result'; + $('#plan-result').textContent = 'Zeitraum geleert.'; + $('#plan-result').hidden = false; + } catch (err) { toast(err.message, true); } +}); + +// ---------- Nextcloud: Kalenderliste ---------- +let calendarsPromise = null; +function loadCalendars(force = false) { + if (!calendarsPromise || force) calendarsPromise = api('api/nextcloud/calendars').catch((err) => { calendarsPromise = null; throw err; }); + return calendarsPromise; +} +function calendarLabel(c) { + // Nextcloud hängt bei geteilten Kalendern den Besitzer schon an den Namen an + return `${c.name}${c.writable ? '' : ' – nur lesen'}`; +} +/** Select mit Kalendern füllen; onlyWritable filtert, extra fügt eine Option vorne an. */ +async function fillCalendarSelect(select, { selected = '', onlyWritable = false, extra = null } = {}) { + select.textContent = ''; + if (!state.data.nextcloud.configured) { select.appendChild(new Option('Nextcloud nicht konfiguriert', '')); select.disabled = true; return []; } + select.disabled = true; + select.appendChild(new Option('Lade Kalender …', '')); + try { + const { calendars } = await loadCalendars(); + select.textContent = ''; + if (extra) select.appendChild(new Option(extra.label, extra.value)); + const list = calendars.filter((c) => !onlyWritable || c.writable); + for (const c of list) select.appendChild(new Option(calendarLabel(c), c.href)); + if (selected && list.some((c) => c.href === selected)) select.value = selected; + else if (!extra && list.length) select.value = list[0].href; + select.disabled = false; + return list; + } catch (err) { + select.textContent = ''; + select.appendChild(new Option(`Fehler: ${err.message}`, '')); + return []; + } +} +function selectedCalendarName(select) { + return select.selectedOptions[0]?.textContent || ''; +} + +// ---------- Nextcloud-Dialog ---------- +const ncDialog = $('#nextcloud-dialog'); +const ncForm = $('#nextcloud-form'); +$('#open-nextcloud').addEventListener('click', async () => { + const nc = state.data.nextcloud; + const next = new Date(state.month.getFullYear(), state.month.getMonth() + 2, 1); + ncForm.elements.from.value = monthKey(state.month); + ncForm.elements.to.value = monthKey(next); + ncForm.elements.removeForeign.checked = false; + $('#nextcloud-info').textContent = nc.configured + ? `${nc.baseUrl} · angemeldet als ${nc.user}` + : 'Nextcloud ist nicht konfiguriert. NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_APP_PASSWORD als Umgebungsvariablen setzen.'; + $('#nextcloud-go').disabled = !nc.configured; + $('#nextcloud-test').disabled = !nc.configured; + $('#nextcloud-result').hidden = true; + ncDialog.showModal(); + const list = await fillCalendarSelect(ncForm.elements.target, { + selected: state.data.settings.targetCalendarHref, + onlyWritable: true, + extra: { label: `Neuen Kalender „${state.data.settings.calendarDisplayName}" anlegen (${nc.calendar})`, value: '' }, + }); + // Noch kein Ziel gewählt: vorhandenen Morning-Meeting-Kalender vorschlagen + if (!state.data.settings.targetCalendarHref) { + const guess = list.find((c) => /morning|meeting|moderation/i.test(c.name)); + if (guess) ncForm.elements.target.value = guess.href; + } +}); +ncForm.elements.target.addEventListener('change', async () => { + const sel = ncForm.elements.target; + try { + await api('api/settings', { method: 'PUT', body: { targetCalendarHref: sel.value, targetCalendarName: sel.value ? selectedCalendarName(sel) : '' } }); + state.data.settings.targetCalendarHref = sel.value; + state.data.settings.targetCalendarName = sel.value ? selectedCalendarName(sel) : ''; + renderSettings(); + } catch (err) { toast(err.message, true); } +}); +$('#nextcloud-close').addEventListener('click', () => ncDialog.close()); +$('#nextcloud-test').addEventListener('click', async () => { + const out = $('#nextcloud-result'); + const btn = $('#nextcloud-test'); + btn.disabled = true; + try { + const r = await api('api/nextcloud/test', { method: 'POST' }); + out.className = 'result'; + const writable = r.calendars.filter((c) => c.writable).length; + out.textContent = `Verbindung OK – angemeldet als ${r.displayName || r.userId} (ID ${r.userId}).\n${r.calendars.length} Kalender sichtbar, ${writable} davon beschreibbar.`; + await fillCalendarSelect(ncForm.elements.target, { selected: ncForm.elements.target.value || state.data.settings.targetCalendarHref, onlyWritable: true, extra: { label: `Neuen Kalender „${state.data.settings.calendarDisplayName}" anlegen`, value: '' } }); + } catch (err) { out.className = 'result error'; out.textContent = err.message; } + out.hidden = false; + btn.disabled = false; +}); +ncForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const r = monthRange(ncForm.elements.from.value, ncForm.elements.to.value); + const out = $('#nextcloud-result'); + const btn = $('#nextcloud-go'); + btn.disabled = true; + out.className = 'result'; + out.textContent = 'Schreibe Termine …'; + out.hidden = false; + try { + const sel = ncForm.elements.target; + const res = await api('api/nextcloud/sync', { method: 'POST', body: { ...r, removeForeign: ncForm.elements.removeForeign.checked, targetCalendarHref: sel.value, targetCalendarName: sel.value ? selectedCalendarName(sel) : '' } }); + await load(); + const parts = [`${res.created ? 'Kalender angelegt. ' : ''}${res.written} Termin(e) geschrieben, ${res.removed} entfernt.`]; + if (res.moved) parts.push(`${res.moved} Termin(e) aus dem vorherigen Zielkalender entfernt.`); + if (res.foreignRemoved) parts.push(`${res.foreignRemoved} fremde Termin(e) im Zeitraum gelöscht.`); + else if (res.foreign) parts.push(`Hinweis: ${res.foreign} fremde Termin(e) im Zeitraum vorhanden (nicht entfernt).`); + if (res.foreignRecurring) parts.push(`${res.foreignRecurring} wiederkehrende fremde Serie(n) bleiben unangetastet – bei Bedarf in Nextcloud löschen.`); + if (res.errors.length) parts.push(`Fehler:\n${res.errors.join('\n')}`); + out.textContent = parts.join('\n'); + if (res.errors.length) out.className = 'result error'; + } catch (err) { out.className = 'result error'; out.textContent = err.message; } + btn.disabled = false; +}); + +// ---------- Personen ---------- +const personForm = $('#person-form'); +const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr']; +function renderPersons() { + const list = $('#person-list'); + list.textContent = ''; + const persons = [...state.data.persons].sort((a, b) => fullName(a).localeCompare(fullName(b))); + if (persons.length === 0) { list.innerHTML = '
  • Noch niemand angelegt.
  • '; return; } + for (const p of persons) { + const li = document.createElement('li'); + if (p.active === false) li.classList.add('inactive'); + const av = avatarEl(p, 'plain'); + av.title = 'Foto hochladen (Klick)'; + av.tabIndex = 0; + av.setAttribute('role', 'button'); + av.setAttribute('aria-label', `Foto für ${fullName(p)} hochladen`); + av.addEventListener('click', () => choosePhoto(p.id)); + av.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); choosePhoto(p.id); } }); + li.appendChild(av); + const who = document.createElement('div'); + who.className = 'who'; + const strong = document.createElement('strong'); + strong.textContent = fullName(p); + who.appendChild(strong); + if (p.email || p.note) { const s = document.createElement('small'); s.textContent = [p.email, p.note].filter(Boolean).join(' · '); who.appendChild(s); } + const tags = document.createElement('div'); + tags.className = 'tags'; + if (p.canMain) tags.appendChild(tag('Haupt', 'main')); + if (p.canCo) tags.appendChild(tag('Neben', 'co')); + if (!p.canMain && !p.canCo) tags.appendChild(tag('Nicht-Moderator', 'off')); + if (p.freeDays && p.freeDays.length) tags.appendChild(tag(`frei: ${p.freeDays.map((d) => WEEKDAYS[d - 1]).join(', ')}`)); + if (p.active === false) tags.appendChild(tag('Inaktiv')); + who.appendChild(tags); + li.appendChild(who); + const edit = document.createElement('button'); + edit.type = 'button'; + edit.textContent = 'Bearbeiten'; + edit.addEventListener('click', () => editPerson(p)); + li.appendChild(edit); + if (p.hasPhoto) { + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'ghost'; + rm.textContent = 'Foto entfernen'; + rm.addEventListener('click', () => run(() => api(`api/persons/${p.id}/photo`, { method: 'DELETE' }), 'Foto entfernt.')); + li.appendChild(rm); + } + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'ghost danger'; + del.textContent = 'Löschen'; + del.addEventListener('click', async () => { + if (!confirm(`${fullName(p)} wirklich löschen? Zuteilungen dieser Person werden geleert.`)) return; + try { await api(`api/persons/${p.id}`, { method: 'DELETE' }); await load(); toast('Person gelöscht.'); } catch (err) { toast(err.message, true); } + }); + li.appendChild(del); + list.appendChild(li); + } +} +function tag(text, cls = '') { const s = document.createElement('span'); s.className = `tag ${cls}`; s.textContent = text; return s; } + +function editPerson(p) { + const f = personForm.elements; + f.id.value = p.id; + f.firstName.value = p.firstName; + f.lastName.value = p.lastName; + f.email.value = p.email || ''; + f.note.value = p.note || ''; + f.canMain.checked = Boolean(p.canMain); + f.canCo.checked = Boolean(p.canCo); + f.active.checked = p.active !== false; + for (const cb of personForm.querySelectorAll('input[name="freeDays"]')) cb.checked = (p.freeDays || []).includes(Number(cb.value)); + $('#person-submit').textContent = 'Änderungen speichern'; + $('#person-cancel').hidden = false; + personForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + f.firstName.focus(); +} +function resetPersonForm() { + personForm.reset(); + personForm.elements.id.value = ''; + $('#person-submit').textContent = 'Person anlegen'; + $('#person-cancel').hidden = true; +} +$('#person-cancel').addEventListener('click', resetPersonForm); +personForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = personForm.elements; + const freeDays = [...personForm.querySelectorAll('input[name="freeDays"]:checked')].map((cb) => Number(cb.value)); + const body = { firstName: f.firstName.value, lastName: f.lastName.value, email: f.email.value, note: f.note.value, canMain: f.canMain.checked, canCo: f.canCo.checked, active: f.active.checked, freeDays }; + try { + if (f.id.value) await api(`api/persons/${f.id.value}`, { method: 'PUT', body }); + else await api('api/persons', { method: 'POST', body }); + resetPersonForm(); + await load(); + toast('Gespeichert.'); + } catch (err) { toast(err.message, true); } +}); + +const contactsDialog = $('#contacts-dialog'); +const contactsForm = $('#contacts-form'); +let contactsData = null; +async function loadContacts(addressbook = '') { + const info = $('#contacts-info'); + const list = $('#contacts-list'); + list.textContent = ''; + info.textContent = 'Lade Kontakte …'; + $('#contacts-go').disabled = true; + try { + contactsData = await api(`api/nextcloud/contacts${addressbook ? `?addressbook=${encodeURIComponent(addressbook)}` : ''}`); + const sel = contactsForm.elements.addressbook; + sel.textContent = ''; + for (const b of contactsData.addressbooks) sel.appendChild(new Option(b.system ? `${b.name} (alle Nextcloud-Konten)` : b.name, b.href)); + sel.value = contactsData.addressbook; + const fresh = contactsData.contacts.filter((c) => !c.existingId); + info.textContent = contactsData.contacts.length + ? `${contactsData.contacts.length} Kontakte, ${fresh.length} davon noch nicht angelegt. Fotos werden übernommen, wenn vorhanden.` + : 'Keine Kontakte gefunden.'; + for (const c of contactsData.contacts) { + const li = document.createElement('li'); + if (c.existingId) li.classList.add('exists'); + const label = document.createElement('label'); + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.value = c.uid; + cb.disabled = Boolean(c.existingId); + cb.checked = !c.existingId; + label.appendChild(cb); + const text = document.createElement('span'); + text.textContent = `${c.firstName} ${c.lastName}`.trim(); + label.appendChild(text); + const small = document.createElement('small'); + small.textContent = [c.email, c.existingId ? 'bereits angelegt' : '', c.hasPhoto ? '' : 'kein Foto'].filter(Boolean).join(' · '); + label.appendChild(small); + li.appendChild(label); + list.appendChild(li); + } + $('#contacts-go').disabled = fresh.length === 0; + } catch (err) { + info.textContent = `Fehler: ${err.message}`; + } +} +$('#open-contacts').addEventListener('click', () => { + $('#contacts-result').hidden = true; + contactsDialog.showModal(); + if (!state.data.nextcloud.configured) { $('#contacts-info').textContent = 'Nextcloud ist nicht konfiguriert.'; $('#contacts-go').disabled = true; return; } + loadContacts(); +}); +contactsForm.elements.addressbook.addEventListener('change', (e) => loadContacts(e.target.value)); +$('#contacts-close').addEventListener('click', () => contactsDialog.close()); +$('#contacts-all').addEventListener('click', () => { for (const cb of contactsForm.querySelectorAll('#contacts-list input:not(:disabled)')) cb.checked = true; }); +$('#contacts-none').addEventListener('click', () => { for (const cb of contactsForm.querySelectorAll('#contacts-list input:not(:disabled)')) cb.checked = false; }); +contactsForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const uids = [...contactsForm.querySelectorAll('#contacts-list input:checked:not(:disabled)')].map((cb) => cb.value); + const out = $('#contacts-result'); + if (uids.length === 0) { out.className = 'result error'; out.textContent = 'Nichts ausgewählt.'; out.hidden = false; return; } + const btn = $('#contacts-go'); + btn.disabled = true; + out.className = 'result'; + out.textContent = `Lege ${uids.length} Person(en) an und hole Fotos …`; + out.hidden = false; + try { + const res = await api('api/nextcloud/contacts/import', { method: 'POST', body: { uids, addressbook: contactsForm.elements.addressbook.value } }); + await load(); + out.textContent = `${res.created} Person(en) angelegt, ${res.withPhoto} mit Foto.${res.skipped ? ` ${res.skipped} übersprungen.` : ''}`; + await loadContacts(contactsForm.elements.addressbook.value); + } catch (err) { out.className = 'result error'; out.textContent = err.message; } + btn.disabled = false; +}); + +let photoTarget = null; +function choosePhoto(id) { photoTarget = id; $('#photo-input').value = ''; $('#photo-input').click(); } +$('#photo-input').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file || !photoTarget) return; + try { + const blob = await squareJpeg(file, 320); + await api(`api/persons/${photoTarget}/photo`, { method: 'PUT', raw: { body: blob, type: 'image/jpeg' } }); + await load(); + toast('Foto gespeichert.'); + } catch (err) { toast(`Foto konnte nicht verarbeitet werden: ${err.message}`, true); } +}); + +/** Bild im Browser quadratisch zuschneiden und verkleinern – spart Speicher und Bandbreite. */ +async function squareJpeg(file, size) { + const bitmap = await createImageBitmap(file); + const side = Math.min(bitmap.width, bitmap.height); + const sx = (bitmap.width - side) / 2; + const sy = (bitmap.height - side) / 2; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + canvas.getContext('2d').drawImage(bitmap, sx, sy, side, side, 0, 0, size, size); + return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas leer'))), 'image/jpeg', 0.86)); +} + +// ---------- Abwesenheiten ---------- +const absenceForm = $('#absence-form'); +const TYPE_LABEL = { urlaub: 'Urlaub', krankheit: 'Krankheit', frei: 'Frei', sonstiges: 'Sonstiges' }; +const absenceImport = $('#absence-import'); +let absenceCalendarsLoaded = false; +async function prepareAbsenceImport() { + if (!absenceImport.elements.from.value) { + const now = startOfMonth(new Date()); + absenceImport.elements.from.value = monthKey(now); + absenceImport.elements.to.value = monthKey(new Date(now.getFullYear(), now.getMonth() + 3, 1)); + } + if (absenceCalendarsLoaded) return; + absenceCalendarsLoaded = true; + const list = await fillCalendarSelect(absenceImport.elements.calendarHref, { selected: state.data.settings.absenceCalendarHref }); + if (!state.data.settings.absenceCalendarHref) { + const guess = list.find((c) => /urlaub|abwesen|krank|ferien|vacation|absence/i.test(c.name)); + if (guess) absenceImport.elements.calendarHref.value = guess.href; + } +} +absenceImport.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = absenceImport.elements; + const out = $('#absence-import-result'); + const r = monthRange(f.from.value, f.to.value); + const btn = $('#absence-import-go'); + btn.disabled = true; + out.className = 'result'; + out.textContent = 'Lese Kalender …'; + out.hidden = false; + try { + const res = await api('api/nextcloud/absences/import', { method: 'POST', body: { ...r, calendarHref: f.calendarHref.value, calendarName: selectedCalendarName(f.calendarHref) } }); + await load(); + const parts = [`${res.imported} Abwesenheit(en) übernommen (${res.events} Termine gelesen, ${res.replaced} frühere Importe ersetzt).`]; + if (res.unmatched.length) parts.push(`Nicht zugeordnet – Person fehlt oder Name weicht ab:\n${res.unmatched.map((u) => `• ${u.name} (${u.count}×)`).join('\n')}`); + out.textContent = parts.join('\n'); + } catch (err) { out.className = 'result error'; out.textContent = err.message; } + btn.disabled = false; +}); +function renderAbsences() { + const sel = absenceForm.elements.personId; + const current = sel.value; + sel.textContent = ''; + for (const p of [...state.data.persons].filter((p) => p.active !== false).sort((a, b) => fullName(a).localeCompare(fullName(b)))) { + const o = document.createElement('option'); + o.value = p.id; + o.textContent = fullName(p); + sel.appendChild(o); + } + if (current) sel.value = current; + const list = $('#absence-list'); + list.textContent = ''; + const showPast = $('#absence-show-past').checked; + const today = todayIso(); + const items = state.data.absences.filter((a) => showPast || a.to >= today).sort((a, b) => a.from.localeCompare(b.from)); + if (items.length === 0) { list.innerHTML = '
  • Keine Abwesenheiten eingetragen.
  • '; return; } + for (const a of items) { + const p = personById(a.personId); + const li = document.createElement('li'); + li.appendChild(avatarEl(p, 'plain small')); + const grow = document.createElement('div'); + grow.className = 'grow'; + const strong = document.createElement('strong'); + strong.textContent = fullName(p); + grow.appendChild(strong); + const small = document.createElement('small'); + small.innerHTML = ` ${TYPE_LABEL[a.type] || a.type} · ${fmtDate(a.from)} – ${fmtDate(a.to)}${a.note ? ' · ' : ''}`; + small.appendChild(document.createTextNode(a.note || '')); + grow.appendChild(small); + li.appendChild(grow); + if (a.source === 'nextcloud') li.appendChild(tag('Nextcloud')); + const conflicts = Object.entries(state.data.assignments).filter(([d, x]) => d >= a.from && d <= a.to && !x.off && (x.main === a.personId || x.co === a.personId)); + if (conflicts.length) { + const warn = document.createElement('span'); + warn.className = 'tag off'; + warn.textContent = `${conflicts.length}× eingeplant!`; + warn.title = 'Diese Person ist an Tagen im Kalender eingeplant, an denen sie abwesend ist. Im Kalender neu auslosen.'; + li.appendChild(warn); + } + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'ghost danger'; + del.textContent = 'Löschen'; + del.addEventListener('click', () => run(() => api(`api/absences/${a.id}`, { method: 'DELETE' }), 'Abwesenheit gelöscht.')); + li.appendChild(del); + list.appendChild(li); + } +} +$('#absence-show-past').addEventListener('change', renderAbsences); +absenceForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = absenceForm.elements; + try { + await api('api/absences', { method: 'POST', body: { personId: f.personId.value, type: f.type.value, from: f.from.value, to: f.to.value, note: f.note.value } }); + f.note.value = ''; + await load(); + toast('Abwesenheit eingetragen.'); + } catch (err) { toast(err.message, true); } +}); +absenceForm.elements.from.addEventListener('change', () => { if (!absenceForm.elements.to.value || absenceForm.elements.to.value < absenceForm.elements.from.value) absenceForm.elements.to.value = absenceForm.elements.from.value; }); + +// ---------- Einstellungen ---------- +const settingsForm = $('#settings-form'); +function renderSettings() { + const s = state.data.settings; + const f = settingsForm.elements; + const sel = f.bundesland; + if (sel.options.length === 0) { + for (const [code, name] of Object.entries(state.data.bundeslaender)) { + const o = document.createElement('option'); + o.value = code; + o.textContent = name; + sel.appendChild(o); + } + } + f.meetingTime.value = s.meetingTime || ''; + f.durationMinutes.value = s.durationMinutes; + f.bundesland.value = s.bundesland || ''; + f.historyWindowDays.value = s.historyWindowDays; + f.calendarDisplayName.value = s.calendarDisplayName; + f.inviteAttendees.checked = Boolean(s.inviteAttendees); + if (!$('#holiday-generate').elements.year.value) $('#holiday-generate').elements.year.value = new Date().getFullYear(); + + const list = $('#holiday-list'); + list.textContent = ''; + const today = todayIso(); + const items = state.data.holidays.filter((h) => h.date >= today.slice(0, 4) + '-01-01'); + if (items.length === 0) list.innerHTML = '
  • Keine Feiertage hinterlegt – oben für das Jahr erzeugen.
  • '; + for (const h of items) { + const li = document.createElement('li'); + const grow = document.createElement('span'); + grow.className = 'grow'; + grow.textContent = `${fmtDate(h.date)} – ${h.name}`; + li.appendChild(grow); + if (!h.auto) li.appendChild(tag('manuell')); + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'ghost danger'; + del.textContent = 'Entfernen'; + del.addEventListener('click', () => run(() => api(`api/holidays/${h.date}`, { method: 'DELETE' }), 'Eintrag entfernt.')); + li.appendChild(del); + list.appendChild(li); + } + + const nc = state.data.nextcloud; + const box = $('#nextcloud-status'); + box.innerHTML = nc.configured + ? `

    Konfiguriert: ${escapeHtml(nc.baseUrl)}, angemeldet als ${escapeHtml(nc.user)}.

    ` + + `

    Zielkalender: ${s.targetCalendarHref ? escapeHtml(s.targetCalendarName || s.targetCalendarHref) : `neuer Kalender „${escapeHtml(s.calendarDisplayName)}" (${escapeHtml(nc.calendar)})`} – wählbar im Dialog „Nextcloud …" im Kalender.

    ` + + `

    Quellkalender für Abwesenheiten: ${s.absenceCalendarHref ? escapeHtml(s.absenceCalendarName || s.absenceCalendarHref) : 'noch keiner'} – wählbar unter „Abwesenheiten".

    ` + : `

    Nicht konfiguriert.

    Umgebungsvariablen setzen: NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional NEXTCLOUD_CALENDAR. Bis dahin steht der ICS-Download zur Verfügung.

    `; +} +function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } + +settingsForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const f = settingsForm.elements; + try { + await api('api/settings', { method: 'PUT', body: { + meetingTime: f.meetingTime.value, durationMinutes: Number(f.durationMinutes.value), bundesland: f.bundesland.value, + historyWindowDays: Number(f.historyWindowDays.value), calendarDisplayName: f.calendarDisplayName.value, inviteAttendees: f.inviteAttendees.checked, + } }); + await load(); + toast('Einstellungen gespeichert.'); + } catch (err) { toast(err.message, true); } +}); +$('#holiday-generate').addEventListener('submit', async (e) => { + e.preventDefault(); + try { + await api('api/holidays/generate', { method: 'POST', body: { year: Number(e.target.elements.year.value) } }); + await load(); + toast('Feiertage erzeugt.'); + } catch (err) { toast(err.message, true); } +}); +$('#holiday-add').addEventListener('submit', async (e) => { + e.preventDefault(); + const f = e.target.elements; + try { + await api('api/holidays', { method: 'POST', body: { date: f.date.value, name: f.name.value } }); + f.name.value = ''; + await load(); + } catch (err) { toast(err.message, true); } +}); + +// ---------- Start ---------- +function renderAll() { + renderCalendar(); + renderPersons(); + renderAbsences(); + renderSettings(); + if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport(); +} +showView(location.hash.slice(1)); +load().catch((err) => toast(`Laden fehlgeschlagen: ${err.message}`, true)); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0d19fc0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,244 @@ + + + + + + + Rotatory – Morning-Meeting-Moderation + + + + +
    +

    Rotatory

    + +
    + +
    + +
    +
    +
    + +

    Monat

    + + +
    +
    + + + ICS +
    +
    + +
    + +
    +
    +
    + H Hauptmoderation + N Nebenmoderation + fixiert + unbesetzt +
    +
    +

    Einsätze in diesem Monat

    +
    +
    +
    + + + + + + + + + +
    + + + +
    +

    Tag

    +

    + + + + +
    + + + + + +
    +
    +
    + + + +
    +

    Moderation auslosen

    + + + +

    Werktage ohne Feiertag. Abwesende werden übersprungen, niemand zwei Werktage in Folge, Einsätze möglichst gleich verteilt.

    + +
    + + + + +
    +
    +
    + + + +
    +

    In Nextcloud schreiben

    +

    + + + + +

    Je Werktag ein Termin. Wiederholtes Schreiben aktualisiert bestehende Termine; entfallene Tage werden im Kalender gelöscht.

    + +
    + + + + +
    +
    +
    + + + +
    +

    Personen aus Nextcloud

    + +

    Lade Kontakte …

    +
    + + +
    +
      + +
      + + + +
      +
      +
      + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..65e03ff --- /dev/null +++ b/public/style.css @@ -0,0 +1,204 @@ +:root { + --bg: #f6f7fb; + --card: #ffffff; + --text: #1c2130; + --muted: #5d6578; + --line: #dde1ea; + --accent: #2f6fed; + --accent-text: #ffffff; + --main: #2f6fed; + --co: #e08a1e; + --danger: #c93b3b; + --holiday: #eef0f5; + --today: #fff6d6; + --warn: #b25e00; + --ok: #1d7a3e; + --radius: 10px; + --cell-min: 64px; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #14171f; + --card: #1e222c; + --text: #e8eaf0; + --muted: #a0a7b8; + --line: #313746; + --holiday: #1a1d25; + --today: #3a3420; + --accent: #5b8dff; + --main: #5b8dff; + --co: #f0a23a; + --danger: #ff7b7b; + --warn: #f2b45a; + --ok: #5fcf7f; + } +} + +* { box-sizing: border-box; } +html { color-scheme: light dark; } +body { + margin: 0; + font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color: var(--text); + background: var(--bg); +} +h1, h2, h3 { margin: 0 0 .5rem; line-height: 1.2; } +h1 { font-size: 1.25rem; } +h2 { font-size: 1.2rem; } +h3 { font-size: 1rem; margin-top: 1.5rem; } +a { color: var(--accent); } +[hidden] { display: none !important; } + +.topbar { + display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: center; + padding: .6rem 1rem; background: var(--card); border-bottom: 1px solid var(--line); + position: sticky; top: 0; z-index: 5; +} +.topbar nav { display: flex; gap: .25rem; flex-wrap: wrap; } +.topbar nav a { + text-decoration: none; color: var(--muted); padding: .35rem .7rem; border-radius: 999px; font-weight: 500; +} +.topbar nav a[aria-current="page"] { background: var(--accent); color: var(--accent-text); } +.topbar nav a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, .day:focus-visible { + outline: 3px solid var(--accent); outline-offset: 2px; +} + +main { padding: 1rem; max-width: 1100px; margin: 0 auto; } +.view > h2 { margin-bottom: 1rem; } + +.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; margin-bottom: 1rem; } +.form-grid { display: grid; gap: .75rem; grid-template-columns: 1fr; } +@media (min-width: 640px) { .form-grid { grid-template-columns: 1fr 1fr; } .form-grid .wide, .form-grid fieldset, .form-grid .form-actions, .form-grid .hint { grid-column: 1 / -1; } } +label { display: flex; flex-direction: column; gap: .25rem; font-size: .9rem; color: var(--muted); } +label.inline { flex-direction: row; align-items: center; gap: .5rem; color: var(--text); } +input, select, button, .button { + font: inherit; border-radius: 8px; border: 1px solid var(--line); padding: .5rem .65rem; background: var(--card); color: var(--text); + min-height: 40px; +} +input[type="checkbox"] { min-height: 0; width: 1.1rem; height: 1.1rem; accent-color: var(--accent); } +/* Felder dürfen nie breiter werden als ihr Container – lange Kalendernamen in