Files
projektuebersicht/CLAUDE.md
T
erikandClaude Opus 5.5 fd44db2213 Update über den Git-Server, Tests, Repo ohne node_modules und Sicherungen
- Admin → Update: neuere Versionen (Tags) vom Git-Server anzeigen und per
  Knopf einspielen; ausgeführt vom Dienst auf dem Rechner
  (Docker/update-einrichten.sh, Docker/update-ausfuehren.sh), nur vorwärts,
  Fehler bei ausbleibendem Neustart, abgebrochene Läufe sperren nicht
- Tests (npm test): Zusammenführen paralleler Änderungen, Rechteprüfung
  beim Speichern; SESSIONS_DIR lenkt den Datenordner für Testserver um
- node_modules, Sicherungen und Uploads aus dem Repo genommen
- cors und requireRole() entfernt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:10:56 +01:00

335 KiB
Raw Permalink Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Overview

Single-page app for tracking engineering projects across weekly sessions ("Sitzungen"). Vanilla JS/HTML/CSS frontend + Node.js/Express server. No build step, no bundler, no tests. Data is persisted in a SQLite database (node:sqlite, needs Node >= 22.13) at sessions/projektuebersicht.db.

ONE HTML entrypoint, served statically by Express:

  • App.html — the whole app (sidebar nav items, in order: Meine Aufgaben, Projekte, Wochenbericht, Auslastung, Projektbeteiligte, then Einstellungen and — for admins only — Admin). Served at /. It also contains the Projektbereich — the area of ONE project — as the tab plan (<div id="plan">), with its own sub-navigation (.plan-nav) over six sections: Dashboard, Terminplan (Gantt), Aufgaben (task/milestone list), Leistung (erbrachter Leistungswert, see below), Dateien, Einstellungen (project master data incl. honorar and the beauftragte Leistungsphasen). Opened via openProjectPlan() in js/gantt.js, which is now just setPlanTarget() + showTab('plan') — the same kind of switch as Dashboard → Projekte, sidebar and topbar stay put. There used to be a separate Projekte tab next to the Projektboard. It listed the very same projects a second time and offered a second editing mask for the very same fields. Both are now ONE list — the tab board, labelled Projekte (see "Eine Projektliste" below). The URL still reads /Projektplan/<Name>?weekLabel=…&projNr=… and the selected section still lives in the URL fragment (…#aufgaben), so both stay linkable. See "Projektbereich" below. admin-users.html no longer exists either. It was a standalone admin-only page with a hand-built header instead of the sidebar and its own checkAuthAndInit() + role check. Its markup moved into App.html as the benutzer section of the Admin area, its <style> block into styles.css, and js/admin-users.js now exports renderAdminUsers() instead of running its own page entry. GET /admin-users.html 301-redirects to /Admin#benutzer. The role check inside it is gone: the Admin area is unreachable for members anyway (showTab() redirects them), and the endpoints are server-enforced.

project-plan.html no longer exists. It used to be a separate document with a reduced imitation of the sidebar (plain <a href> links, no Einstellungen/Admin sections) and its own /load-all fetch. Its markup moved into App.html (#plan plus the plan dialogs), its <style> block moved to public/plan-styles.css, and its modules under js/plan/ are now loaded by App.html. GET /project-plan.html 301-redirects to the new route.

The whole app requires login (see "Auth / security quirk" below): App.html shows a blocking login overlay (checkAuthAndInit() in js/auth.js) until GET /whoami confirms a session.

App.html's navigation is a single persistent left sidebar (#headerMenu/.side-menu) — it replaced the former top tab bar, a separate <header>, and a separate off-canvas tool drawer, which have all been merged into this one element (see "Frontend architecture" below). After login the app lands on the Dashboard ("Meine Aufgaben"), not the project list (DEFAULT_TAB in js/globals.js); the list remains fully available as a sidebar nav item. The former "Projektbeteiligte" tab (per-department people/person_roles/person_absences CRUD) has been fully replaced by the global users table: it is now a read-only view of that department's active users, and only admins manage team members, via admin-users.html (see "Projektbeteiligte" below).

All UI strings/comments are German. Preserve German in user-facing text.

File structure

.
├── server.js                 # Express server + SQLite API
├── pdf-arbeit.js             # PDF lesen/rendern im Kindprozess (siehe "Betrieb auf dem Pi")
├── package.json             # deps: express, cors, plus pdfjs-dist/mammoth
│                            # (added for Protokoll-Import, see "### Protokolle")
├── .npmrc                   # omit=optional — hält pdfjs-dist's rein optionales
│                            # @napi-rs/canvas (nur für Seiten-Rendering, hier
│                            # ungenutzt) aus node_modules heraus
├── docker-compose.yml       # at repo ROOT; build context ., dockerfile Docker/Dockerfile
├── Docker/Dockerfile        # node:22-alpine (nötig für node:sqlite)
├── .dockerignore
├── public/
│   ├── App.html            # main app (served at /)
│   ├── admin-users.html     # standalone admin-only user management page
│   ├── js/                  # shared modules (classic scripts, global fns)
│   │   ├── globals.js       # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS, TAB_SLUGS) + db/currentDepartment
│   │   ├── utils.js         # esc, uid, Datum-/Differenz-Helfer, personIdForName,
│   │   │                    # peopleByRole, assignKey, weekSortKey +
│   │   │                    # renderProjektVerlauf() (die EINE Verlaufs-
│   │   │                    # Darstellung, von allen drei Stellen genutzt)
│   │   ├── ui.js            # LocalStorage-UI-State, Header-Collapse, Theme
│   │   ├── date-picker.js   # Datums-/Bereichspicker-Overlays
│   │   ├── server.js        # baseUrl, sendWeekToServer, deleteWeekFromServer, blobToDataUrl (global people list has no client write path anymore, see Persistence)
│   │   ├── auth.js          # currentUser, checkAuthAndInit (Login-Overlay), logout, renderUserBar
│   │   ├── store.js         # save/syncAllToServer, switchDepartment, load, seed
│   │   ├── weeks.js         # Wochen-Auswahl, Auto-Lock, -Löschung, unlockCurrentWeek (POST /unlock-week) —
│   │   │                    # Erzeugung/Klonen entfallen, siehe "Automatischer Sitzungswechsel"
│   │   ├── gantt.js         # GANTT-Diagramm (buildGanttHtml, openProjectPlan), geshared
│   │   ├── tabs-auth.js     # showTab (5 Tabs; kein Passwortschutz mehr, siehe Auth-Abschnitt)
│   │   ├── pdf.js           # drei PDF-Exporte (pdfmake): exportWochenberichtPDF()
│   │   │                    # ist die Tagesordnung als Bericht je Gewerk — Übersicht
│   │   │                    # + eine Akte je Projekt (A4 hochkant), exportSandboxPlanPDF()
│   │   │                    # der Balkenplan eines Planung-Terminplans (A3 quer),
│   │   │                    # exportProtokollPDF() das Besprechungsprotokoll eines
│   │   │                    # Projekts (A4 hochkant, Punkte gruppiert, neue gelb)
│   │   ├── projekt-wizard.js # Assistent "Neues Projekt" (sechs Schritte,
│   │   │                    # Gewerke, gefiltertes Team, Terminplan aus Honorar)
│   │   ├── auftrag.js       # Auftragsstatus (Anfrage→Angebot→beauftragt→
│   │   │                    # abgeschlossen), Chip/Auswahlfeld und der
│   │   │                    # Beauftragungs-Dialog
│   │   ├── benachrichtigungen.js # Glocke im Menüband + "Neu zugewiesen" auf dem Dashboard
│   │   ├── backup.js        # downloadBackup (lädt SQLite-Snapshot von GET /backup)
│   │   ├── import.js        # importDb (Backup-Wiederherstellung über POST /import, admin-only)
│   │   └── init.js          # renderAll/initUiState + Seiteneinstieg hinter checkAuthAndInit() (nur App.html)
│   │   ├── board/           # tab-specific: die Projektliste ("Projekte")
│   │   │   └── board.js     # Projektlisten-Rendering (Anzeige, BOARD_OPTS
│   │   │                    # .nurAnsicht), Reihenfolge/Löschen und der
│   │   │                    # Kommentar-Editor (Textfeld mit Verfasser/Zeitstempel,
│   │   │                    # ausdrücklichem Speichern und scrollbarem Verlauf der
│   │   │                    # früheren Sitzungen; auch vom Wochenbericht genutzt —
│   │   │                    # Bedienelemente adressieren ihr Feld über closest(),
│   │   │                    # nicht über IDs, da beide Tabs dieselben Projekte
│   │   │                    # rendern). Kürzel-Legende und Anwesenheit sind hier
│   │   │                    # entfallen (Anwesenheit siehe wochenbericht/)
│   │   ├── wochenbericht/   # tab-specific: Wochenbericht (Tagesordnung des
│   │   │                    # wöchentlichen Teammeetings — dort wird JEDES
│   │   │                    # Projekt angesehen, Vollständigkeit vor Filterung)
│   │   │   └── wochenbericht.js # Gruppierung "Neu kommentiert"/"Fortschritt"/
│   │   │                    # "Unverändert" per project_uid-Vergleich zur Vorwoche
│   │   │                    # (nutzt renderBoardDrawer/renderBoardRow aus board.js;
│   │   │                    # die Karten tragen ein farbiges Kopfband je
│   │   │                    # Änderungsart — .report-card-neu/-fortschritt,
│   │   │                    # Farben in --band-neu/--band-fortschritt — und
│   │   │                    # stellen die Schubladen-Blöcke dreispaltig; zeigen
│   │   │                    # über BERICHT_OPTS nur die beauftragten
│   │   │                    # Leistungsphasen, Rückfall auf alle wenn leer)
│   │   │                    # + renderProjectTimeline(): der aufklappbare
│   │   │                    # "Verlauf" je Projekt — die Kacheln der früheren
│   │   │                    # Sitzungen waagerecht und scrollbar, mit
│   │   │                    # Kommentar, LPH-Ständen, Terminen aus dem Plan
│   │   │                    # und Beteiligten JENER Woche; gegenüber der
│   │   │                    # Kachel davor geänderte Werte hervorgehoben
│   │   │                    # + Anwesenheitserfassung am Seitenfuß
│   │   │                    # (renderAttendance/toggleAttendance/setAllAttendance,
│   │   │                    # binär an/abwesend über currentWeek().people mit Zähler)
│   │   ├── people/          # tab-specific: Projektbeteiligte
│   │   │   └── people.js    # read-only Personen-Liste (aus db.globalPeople, kein CRUD/Drag-
│   │   │                    # and-Drop mehr) + abteilungsübergreifende Besetzung (admin-only)
│   │   ├── projects/        # Ablage-Logik der Projektliste (der eigene
│   │   │   └── projects.js  # Projekte-Tab ist entfallen): Drag-and-Drop,
│   │   │                    # reorderProjects(), delProjectFromList();
│   │   │                    # renderProjects() rendert nur noch, wenn
│   │   │                    # #projectsList existiert
│   │   ├── load/            # tab-specific: Auslastung
│   │   │   └── load.js      # Auslastungs-Tab als LASTBAND (Zeile je Person
│   │   │                    # und Rolle, Balken nach Projekten aufgeteilt),
│   │   │                    # LP-Modal, Neues-Projekt-Modal
│   │   │                    # (inkl. Auswahl der beauftragten Leistungsphasen,
│   │   │                    # p.beauftragteLP — dieselbe Liste wie im
│   │   │                    # Honorar-Dialog der Projektplan-Seite)
│   │   ├── dashboard/        # tab-specific: Meine Aufgaben
│   │   │   └── dashboard.js  # renderDashboard (GET /my-dashboard: offene TODOs + ausstehende Freigaben)
│   │   ├── wand/              # tab-specific: Wandtafel (Touch-Dashboard, ersetzt Show Mode)
│   │   │   └── wand.js        # renderWandboard — rein client-seitige Kacheln aus db (kein eigener Endpunkt)
│   │   ├── settings/         # tab-specific: Einstellungen
│   │   │   └── settings.js   # NUR "Mein Konto": Stammdaten + Passwortwechsel
│   │   ├── admin/            # tab-specific: Admin (nur Administratoren)
│   │   │   └── admin.js      # Abschnitte Honorar, Auslastung (Schwellen/Skala/
│   │   │                     # Aufwandsvorlage), Termine & Sitzungen, Feiertage,
│   │   │                     # Aufgaben-Vorlagen, Betrieb & Sicherheit,
│   │   │                     # Datenbank & Sitzungen (Backup/Import/Löschen)
│   │   └── admin-users.js   # Abschnitt "Benutzerverwaltung" des Bereichs #admin
│   │                        # (renderAdminUsers, CRUD via /admin/users*)
│   ├── plan-styles.css      # Stile des Projektbereichs (früher <style> in project-plan.html)
│   ├── js/plan/             # Module des Projektbereichs (#plan in App.html)
│   │   ├── plan-state.js    # PlanApp-Zustand, Datenladepath, savePlan, Toasts
│   │   ├── plan-sections.js # Bereichs-Navigation (showPlanSection, URL-Fragment)
│   │   │                    # + Dashboard-Karten (renderPlanDashboard)
│   │   ├── plan-info.js     # Status/Kommentar, Gruppe, amtierende LPH, LPH-%
│   │   │                    # (Dashboard-Bereich; die Beteiligten-Matrix ist
│   │   │                    # entfallen, siehe plan-settings.js)
│   │   ├── plan-settings.js # Bereich "Einstellungen": Projektangaben, Honorar,
│   │   │                    # beauftragte Leistungsphasen, Beteiligten-Matrix
│   │   │                    # (renderPlanSettings, planCycleAssign)
│   │   ├── plan-phasen.js   # Bereich "Leistungsphasen": Ablaufbalken + Kachel
│   │   │                    # je Phase (renderPlanPhasen), aufgabenPhase()
│   │   ├── plan-tasks.js    # Bereich "Aufgaben": Aufgaben-/Meilensteinliste
│   │   │                    # (renderPlanTasks; öffnet dieselben Dialoge wie das GANTT)
│   │   ├── plan-history.js  # Kommentar-Verlauf über alle Sitzungen (+ Vorwochen-Helfer)
│   │   ├── plan-templates.js # Aufgaben aus LPH-Vorlagen (lph_templates)
│   │   ├── plan-protokoll-todos.js # "+ TODOs aus Protokoll": externes PDF
│   │   │                    # per KI einlesen + Aufgaben daraus ableiten —
│   │   │                    # unabhängig von plan-protokoll.js, nichts
│   │   │                    # davon wird gespeichert
│   │   ├── plan-mail-eintrag.js # "Termin aus Mail": Drag & Drop/Strg+V → KI → Aufgaben-Panel
│   │   ├── plan-honorar.js  # Terminplan aus Honorar + beauftragten LPH errechnen
│   │   ├── plan-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen)
│   │   ├── plan-gantt.js    # renderPlanGantt, Abhängigkeitslinien, Zoom, Ressourcen-Load
│   │   ├── plan-drag.js     # GANTT-Balken-Drag/Resize + TODO-/Meilenstein-Zeilen-Drag
│   │   ├── plan-modals.js   # TODO-, Meilenstein- und Abwesenheits-Modals; der
│   │   │                    # TODO-Dialog vereint "Erledigt"+Fortschritt in einem
│   │   │                    # Status-Segment (offen/arbeit/fertig -> done+progress),
│   │   │                    # Zuständige als Personen-Chips, Vorgänger eingeklappt
│   │   │                    # mit Suche und gesperrten Zirkelbezügen; Esc/Strg+Enter
│   │   │                    # und Hintergrund-Klick zentral für alle drei Dialoge
│   │   ├── plan-leistung.js # Bereich "Leistung": erbrachter Leistungswert
│   │   │                    # (Honorar × HOAI-Anteil × Fortschritt je Phase)
│   │   ├── plan-files.js    # Datei-Upload/-Liste/-Freigabe/-Kommentare (project-files-Endpunkte)
│   │   ├── plan-protokoll.js # Bereich "Protokolle": Besprechungsprotokolle
│   │   │                    # je Projekt, mit Fortschreiben offener Punkte
│   │   │                    # und gelber Neu-Markierung (project-protokolle-Endpunkte)
│   │   └── plan-schedule.js # Kritischer Pfad, Auto-Schedule, Mail-Link
│   ├── styles.css
│   ├── glas.css             # Glas-Darstellung (html.glas), siehe "Glas-Darstellung"
│   ├── logo.svg             # vektorisiertes Logo (Kopfzeile, Login, Wasserzeichen)
│   └── logo.ico
└── sessions/                # SQLite-DB; bind-mounted into Docker container
    └── projektuebersicht.db # node:sqlite database (WAL mode)

Running

npm install
npm start                 # http://localhost:3003, or PORT=3004 if occupied
docker compose up --build # Docker variant (compose file is at repo root)
  • PORT env var overrides default 3003 in server.js.
  • Frontend must be served over HTTP: baseUrl() in js/server.js uses window.location.origin or falls back to http://localhost:3003 when opened via file:// (then only seed/demo data loads).
  • Check /health returns {ok:true} after starting.

Departments were retired (read this before trusting "department" elsewhere)

This file historically described ELT/HLS/ARCH as three fully separate partitions — each with its OWN weeks, projects, and people list, switched via a sidebar "Abteilung" selector and switchDepartment(). That is no longer true. migrateRemoveDepartmentPartition() (server.js, runs idempotently at every startup, guarded on weeks still having a department column) merged all per-department weeks sharing the same label into one global week (and merged same-nr projects across the former departments into one project, tagging every former department as a project_tags entry — confirmed deterministically, ELT→HLS→ARCH precedence, nothing silently dropped). There is now one shared week/project/people list, full stop: GET /load-all and POST /save-week take no department param, weeks/projects/week_people/attendance have no department column, switchDepartment() and the .dept-tab sidebar no longer exist client-side, and currentDepartment is gone.

VALID_DEPARTMENTS/DEPARTMENTS (ELT|HLS|ARCH) still exist, but now mean two different, narrower things:

  • A user's own discipline — users.department, maintained in admin-users.html, used for staffing/team pickers.
  • A project's discipline(s) — project_gewerke (one or more per project), see "Wochenbericht — Gewerke-Trennung" below.

Known documentation debt: many mentions of "department"/"Abteilung" further down in this file — db.globalPeople[currentDepartment], openProjectPlan() building /HLS/Projektplan/…, switchDepartment() behavior, the "Eine Projektliste"/"Projektbeteiligte" sections, etc. — still describe the pre-migration, per-department architecture and were not swept in the same pass that added this note. Treat any such mention as unverified until checked against the actual code; do not assume it still applies.

Automatischer Sitzungswechsel (read this before trusting "newWeek"/"cloneWeek" elsewhere)

Sessions ("Sitzungen", weeks.label = "KW XX / YYYY") are still the backbone of the whole app — Wochenbericht, Board and Auslastung all key off the current one, and the Wochenbericht's own week-over-week comparison/Verlauf features (renderProjektVerlauf(), classifyProjectChange(), "Vergleich mit KW…") depend on there being a fresh session every week. What changed: a new session is no longer a user action. It used to be newWeek()/cloneWeek() (public/js/weeks.js), triggered by two buttons in the Werkzeuge sidebar group. Both are gone — on Erik's explicit call: "eine Sitzung stellt eine Woche dar… kommt eine neue Woche kommt auch eine neue Sitzung unabhängig von den Wochenberichten" (a session should follow the calendar, not whether anyone happens to open the Wochenbericht that week), and the transition should be completely silent — no toast, no confirmation.

ensureCurrentWeekRollover() (server.js) replaces both functions, server-side. It runs once right after the HTTP server starts listening (catches a rollover that should have happened while the server was down) and then every hour via setInterval — deliberately NOT tied to any route or tab, so a session change happens even if nobody ever opens the app that week. (This is the first recurring background job in this codebase; there was no prior pattern to mirror.) On each run: compute today's ISO calendar-week/year (getIsoWeekInfo(), a straight port of getWeekNumber() from public/js/utils.js, extended to also return the ISO week-year, which can differ from the calendar year right at year boundaries); build the label via formatWeekLabel() ("KW 05 / 2026" — the exact shape both the old validateWeekLabel()'s regex and weekSortKey()/weekSortKeyServer()'s parsing expect); compare against the chronologically latest real (i.e. non-SANDBOX_WEEK_LABEL) session via the already-existing weekSortKeyServer() (previously only used by /my-dashboard's fallback). If the calendar hasn't moved into a new week since then, it's a no-op — the function is safe to call as often as the interval likes, and safe to run again on every server restart without ever creating a duplicate.

When a new week IS due, the reset is a deliberate, exact port of what cloneWeek() used to do client-side — same fields, same rationale, just executed server-side against a plain JS object built from loadWeeks() (itself already callable with no HTTP context) and written via saveWeekRelational()/serializeWeek() (also already pure DB-writing functions with zero dependency on req/req.user — confirmed by reading both top to bottom before relying on it): deep-clone the latest real week, give it a fresh id (crypto.randomUUID(), matching the ID scheme already used elsewhere in server.js) and the new label/date (heuteIsoLokal()), reset attendance={}, locked=false, drop updatedAt (a carried-over revision would make /save-week's conflict check reject the brand-new label — moot here since this bypasses that route entirely, but the field is dropped anyway for cleanliness); per project, a fresh id (crypto.randomUUID()) and cleared status/statusAuthor/statusAt/ besprochen/statusState. Everything else — Beteiligte, lph, assign, the whole plan (todos/milestones keep THEIR OWN ids unchanged, only the owning project's id changes), project_uid, honorar, beauftragteLP, gewerke, tags — carries over untouched, exactly as cloneWeek() always left it.

Locking: there was, and is, no server-side equivalent of applyAutoLock() (still client-side-only, still exactly as before). The rollover replicates its invariant itself instead of relying on some client being open to do it: once the new week is saved, if app_settings.autoLockAktiv is on (read via the already-existing readAppSettings()), every other real week gets locked=1 in one UPDATE. Turning autoLockAktiv off does not stop new sessions from being created — only stops them from locking everything else, same distinction the setting already made for applyAutoLock().

Fresh-install edge case: if there is no real week at all yet (nothing to clone), the rollover falls back to building an empty one — global people list, no projects — mirroring what newWeek() used to produce, so a brand new deployment still ends up with a session without needing a button to press.

Verified live against a scratch copy of the production DB before ever touching the real one (the real database's latest session, KW 33/2026, predated "today" by seven ISO weeks — restarting the server for real was always going to trigger a real jump to KW 40/2026, so correctness was checked against a copy first): all 29 real projects carried over with fresh project ids, comments/besprochen cleared, LPH values and plan_todos (with their original todo ids) preserved byte-for-byte, older sessions correctly locked, a second server start produced no duplicate, and disabling autoLockAktiv left the previous latest week unlocked as expected.

newWeek(), cloneWeek(), and validateWeekLabel() are gone from public/js/weeks.js (confirmed unreferenced anywhere else before removal). Known documentation debt, same caveat as the department note above: this file still has scattered comments elsewhere (in board.js, utils.js, wochenbericht.js, plan-state.js, plan-history.js, plan-build-gantt.js, and a few spots in this very file) that name cloneWeek()/newWeek() as the live mechanism for "project ids change every week"/"a new session starts with an empty comment" and similar — the underlying facts they describe are still true, only the mechanism producing them moved to ensureCurrentWeekRollover(). Treat a cloneWeek()/newWeek() mention elsewhere as describing that historical mechanism, not code that still exists, until it's been swept.

Server API (server.js)

Auth levels (see "Auth / security quirk" below for the mechanism): Public (no session needed) / Auth (any valid session) / Admin (role='admin', via requireRole('admin')). HTML shell routes stay Public — they only ever serve static markup, never data; the actual security boundary is the 401/403 on the JSON endpoints.

  • GET / → App.html; GET /health. Public.
  • Deep links per department + tab: GET /<Dept>/<Tab> → App.html, e.g. /HLS/Auslastung or /ELT/Projektübersicht. Public. Tab slugs (TAB_SLUGS in js/globals.js, VALID_TAB_SLUGS in server.js) are Projektübersicht|Wochenbericht|Auslastung|Projekte|Projektbeteiligte|MeineAufgaben|Einstellungen|Admin; anything else returns 404. Exception: GET /<Dept>/Projektplan(/<ProjName>)? (PLAN_SLUG) serves App.html as well (the Projektbereich is a tab of it now, selected client-side by applyUrlRoute()), with weekLabel|projNr as query params (the project name is a readable path segment only; identification happens via weekLabel + projNr). The frontend reads/writes these routes via applyUrlRoute()/updateRouteUrl() in js/ui.js and all static assets in the HTML use absolute paths (/js/..., /styles.css) so they resolve on nested routes. admin-users.html itself is just a static file under public/ (no dedicated server route/slug).
  • POST /login {username,password} → Public. Verifies against users (scrypt, see Auth section), creates a user_sessions row and sets the session cookie. Same generic error for wrong username, wrong password, or inactive account (no user enumeration).
  • POST /logout → Auth. Deletes the current session row, clears the cookie.
  • GET /whoami → Public. Returns {ok:true, user: req.user || null} — the basis for the client-side login overlay (checkAuthAndInit()).
  • POST /change-password {currentPassword, newPassword} → Auth. Requires the current password even when must_change_password is set; new password ≥ 8 chars. Its UI is #passwordModal (js/auth.js) — reachable from Einstellungen → Mein Konto, and shown non-dismissibly right after login when the account carries must_change_password. Until that dialog existed the endpoint had no caller at all: every new and every reset account got the flag, admin-users.html displayed "Passwortwechsel erforderlich", and nobody could act on it.
  • POST /change-email {email} → Auth. Scoped to req.user.id (like /change-password, not a body-supplied user id), no admin role needed. Rejects an empty value with 400; no format validation beyond trimming (same permissiveness as the admin path below). Its UI is the inline E-Mail field in Einstellungen → Mein Konto (js/settings/settings.js, submitEmailChange()) — until this route existed the address was view-only there and only an admin could set it via PATCH /admin/users/:id; an admin can still set/correct it there, e.g. for a colleague who hasn't logged in yet.
  • GET /admin/users → Admin. Lists all users (incl. inactive) with their roles (discipline roles from user_roles, see below), without password fields or absences (those are loaded separately, see next bullet).
  • GET /admin/users/:id/absences → Admin. Lists one user's absences (id, start, end, type, note, sorted by start) from user_absences — loaded on demand by the admin-users.js edit modal, not embedded in GET /admin/users (would bloat the list response).
  • POST /admin/users {username, displayName, email?, role?, personId?, department, initials?, roles?, password?, generatePassword?} → Admin. Creates a user with must_change_password=1; department is required (must be a VALID_DEPARTMENTS value, 400 otherwise — empty is only tolerated for the pre-existing bootstrap admin, not for new accounts); initials is the short board-column label, stored as given (empty is allowed — loadGlobalPeople() falls back to username at read time, see Persistence); roles is an array validated against VALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwachung']) (400 on an unknown role) and written to user_roles. Password either given explicitly (≥ 8 chars) or auto-generated (generatePassword:true, returned once as generatedPassword).
  • PATCH /admin/users/:id {displayName?, email?, role?, active?, personId?, department?, initials?, roles?} → Admin. Partial update; same department/roles validation as POST (only when the field is present); roles is a replace-all (delete then re-insert user_roles, same pattern as the old serializeGlobalPeople). An admin can't deactivate or demote their own account. Deactivating a user immediately deletes all their sessions. A person_id collision (now globally unique, see Persistence) is caught and returned as 409.
  • POST /admin/users/:id/reset-password → Admin. Sets a new random password, forces must_change_password=1, deletes all sessions of that user (forces re-login), returns the plaintext once as generatedPassword.
  • DELETE /admin/users/:id[?hard=1] → Admin. Default is a soft-delete (active=0 + delete sessions). ?hard=1 (loescheBenutzerEndgueltig(), one transaction) permanently deletes the account and, on Erik's request, removes the person from the projects and thereby the Auslastung — but only in the current session (latest real week) and the Planung sandbox; older sessions are history and stay untouched. Removed there: project_assign, plan_todo_assignees, plan_people, week_people/_roles, attendance, matched via personenKennungenInWoche() = users.person_id plus every id in that week's week_people with the same full name (legacy short ids like p_ap). Those weeks get a new updated_at, so a stale browser gets a 409 instead of writing the person back. Files are no longer a blocker: uploads/comments stay and show as "Gelöschter Benutzer" (COALESCE(..., 'Gelöschter Benutzer') in the file queries), the person's review assignments are deleted and pending files' status recomputed. Response includes counts (zuordnungen, aufgaben, pruefauftraege). An admin can never delete their own account (soft or hard).
  • GET /users → Auth (not admin-only). Lists active users (id, username, displayName, email, personId, department, initials, roles) — used both by the reviewer-picker in the file upload UI and by the cross-department staffing picker in the (now read-only) Projektbeteiligte tab (js/people/people.js) — deliberately separate from /admin/users, which is admin-only and exposes more fields/inactive accounts.
  • POST /users/:id/absences {start, end, type, note?} → Auth (not admin-only — matches the previous permission level: any logged-in user maintains absences in the Projektbeteiligte tab / on the Gantt page). Requires start/end (400 otherwise); server generates the absence id (crypto.randomUUID()).
  • DELETE /users/:id/absences/:absenceId → Auth. Idempotent (no error if the absence is already gone).
  • GET /load-all → Auth. {weeks:[weekObj], people:[personObj], sandboxPlans:[projectObj], sandboxUpdatedAt} — no department param: migrateRemoveDepartmentPartition() (see "Departments were retired" below) dissolved the former per-department partitioning, so this returns the one shared set of weeks and the one shared people list (loadGlobalPeople(), see Persistence — still filtered to active=1, just not to a department anymore); weeks are full JSON week objects (id, label, date, locked, updatedAt, people, attendance, projects), sorted by label. sandboxPlans/sandboxUpdatedAt are the reserved Planung-Sandbox "week" (SANDBOX_WEEK_LABEL) split OUT of weeks[] — see "Planung — projektunabhängige Terminplan-Sandbox" below — so it can never leak into any code that iterates weeks.
  • POST /save-week {week, baseUpdatedAt} → Auth. Upserts one week (JSON object, keyed by label only). Used by sendWeekToServer(). The client sends its local revision in baseUpdatedAt (the updatedAt value from load-all); the server compares it to weeks.updated_at before writing and answers 409 {ok:false, conflict:true, error, updatedAt} if someone else saved the week in the meantime. A save with a non-empty base for a week the server no longer knows is also rejected with 409 ("wurde gelöscht") so a deleted week can't silently resurrect; only new weeks (empty/missing base) pass. On success it returns {ok:true, updatedAt} (the new revision), which the client stores as the new sync base. Clients WITHOUT baseUpdatedAt (old versions) always pass. The check runs synchronously in-process before the write transaction (no race between check and write).
  • POST /delete-week {department, label} → Admin. Deletes the week; missing label yields 400, missing week tolerates with deleted:false.
  • POST /unlock-week {department, label} → Admin. Unlocks a locked week (locked=0, new updated_at revision, same convention as /save-week) — replaces the formerly client-only unlock logic (unlockCurrentWeek()/js/weeks.js); 404 if the week doesn't exist.
  • GET /my-dashboard → Auth. Liefert tasks (persönlich zugewiesene offene Aufgaben), projectDates (Aufgaben und Meilensteine der Projekte, denen der Aufrufer zugeordnet ist — siehe „Dashboard" unten), pendingReviews und colleagueAbsences. Aggregates, per department (ELT/HLS/ARCH), the currently unlocked week's open (done=0) plan_todos assigned to the caller's person_id (sorted by due date, empty dates last), plus pendingReviews — files where the caller is a pending reviewer (project_file_reviewers joined with project_files/users). Normally exactly one unlocked week exists per department (applyAutoLock() invariant); if that invariant is violated (0 or >1 unlocked weeks), weekSortKeyServer() picks the most recent week as a fallback. A user without a matching person_id gets an empty task list per department (no error).
  • POST /project-files?department=&projectNr=&projectName=&filename=&reviewers=id1,id2 → Auth. Raw binary body like /import (express.raw({type:'*/*', limit:'200mb'})). Rejects an invalid/missing department (stricter than departmentKey()'s ELT-fallback — an upload must never land in the wrong department silently) and an empty projectNr (400). Reviewer IDs are parsed from the comma-separated query param, deduplicated, filtered to active users, and the uploader is removed (no self-review); at least one reviewer must remain (400 otherwise). See "File uploads" below for storage/path details.
  • GET /project-files?department=&projectNr= → Auth. Lists a project's files incl. uploader name, per-reviewer decision, and comment count.
  • GET /project-files/:id/download → Auth. res.download() from sessions/uploads/<department>/<stored_name>, with original_filename only used as the download's Content-Disposition filename.
  • POST /project-files/:id/review {decision:'approved'|'rejected', note?} → Auth. Only for a caller who is an eligible pending reviewer of that file; 409 if the file's overall status is already terminal (approved/rejected). Recomputes the overall status: any rejected decision → rejected immediately; all reviewers approved → approved; otherwise stays pending.
  • GET /project-files/:id/comments → Auth. Chronological comment list.
  • POST /project-files/:id/comments {body} → Auth. Any logged-in user may comment, not just the assigned reviewers.
  • DELETE /project-files/:id → Auth (not admin-only, but scoped): allowed for the uploader while the file is still pending, or for an admin at any time (403 otherwise). Deletes the DB row (and its reviewer/comment rows, deleted explicitly rather than via FK cascade — see Persistence section) before best-effort deleting the physical file.
  • GET /project-protokolle?projectNr= → Auth. Summary list of a project's Protokolle (counts of open/erledigt points, Teilnehmer count), sorted by nummer descending — see "### Protokolle" below.
  • GET /project-protokolle/:id → Auth. One full Protokoll incl. all teilnehmer/punkte.
  • POST /project-protokolle?projectNr=&projectName=&weekLabel= {datum, betreff, ort, teilnehmer} → Auth. Creates a new Protokoll; nummer is server-assigned (MAX(nummer)+1 for that project_nr), and every still-open (erledigt=0) point of that project's most recently created Protokoll is copied into it (the "Fortschreiben" — see "### Protokolle").
  • PUT /project-protokolle/:id {datum, betreff, ort, teilnehmer, punkte} → Auth. Replaces the Protokoll's Teilnehmer/Punkte wholesale (delete + re-insert, one transaction) — the same "whole object" pattern saveWeekRelational() uses for a week, not a REST route per field. A point with a known id keeps its server-tracked stamm_id/neu; a point without one is treated as newly added (neu=1).
  • DELETE /project-protokolle/:id → Auth (not admin-only — same permission level as deleting a todo/milestone). Idempotent.
  • POST /project-protokolle/import?projectNr=&projectName=&weekLabel=&datum= &betreff=&ort=&filename=&erkennung= → Auth. Raw-binary body like POST /project-files (express.raw, 50 MB limit; the route is listed in RAW_BODY_ROUTES so the global express.json() parser never touches it — see "JSON-Dateien lassen sich hochladen" below, same reasoning). Extracts text from an uploaded .pdf/.docx (400 on any other extension). erkennung ('chatgpt'|'claude', optional) tries KI-gestützte Erkennung first, falling back to the erkennePunkteAusText() heuristic on any failure — anything else in that param skips straight to the heuristic; creates the Protokoll through the same legeProtokollAn() path as the plain POST above — see "### Protokolle" below for both recognition paths and the PDF-library history. Response includes erkennungsart/erkennungshinweis.
  • GET /project-protokolle/:id/quelldatei → Auth. Downloads the original uploaded file of an imported Protokoll (404 if it wasn't imported) — the Protokoll equivalent of GET /project-files/:id/download.
  • GET /my-ai-settings → Auth (not admin-only — scoped to req.user.id, same pattern as /change-email). Returns, per provider, only whether an API key is stored (configured: bool) plus the — non-secret — apiUrl to pre-fill an edit form; never the key itself. PUT /my-ai-settings {chatgpt?: {apiKey?, apiUrl?}, claude?: {apiKey?, apiUrl?}} → Auth, same scoping. A given apiKey replaces it (empty string clears it); a given apiUrl is validated via istGueltigeAiUrl() — https:// always passes, http:// only on the loopback (localhost/127.0.0.1/::1), anything else is 400 — or clears an override back to the default endpoint; an omitted sub-field is left untouched. See "### Protokolle" below for why each account keeps its own key rather than one firmenweit, and for why plain http is allowed on the loopback.
  • POST /my-ai-settings/test {provider} → Auth, same scoping. "Neu verbinden": actually exercises the caller's saved key/URL for that provider via erkennePunkteMitKI() (a short dummy text, the same code path an import uses). Responds {ok:true} or, on any failure, {ok:false, error} with HTTP 200 — a failed test is an expected outcome, not a server error. See "### Protokolle" below.
  • GET /app-settings → Auth (not admin-only — the frontend computes with these: Auslastung, ampel, Honorar schedule). PUT /app-settings → Admin, partial update. APP_SETTING_DEFS in server.js is the single place a key is defined (default, parser, label); readAppSettings() always returns a complete object, falling back to the default for a missing or unusable row. Unknown keys and invalid values are rejected with 400 before anything is written, and gelb > rot is refused outright. Keys: stundensatz, arbeitsstundenProTag, vollzeitStundenProWoche, auslastungSchwelleGelb, auslastungSchwelleRot, referenzHonorar, verteilzeitraumWochen, aufwandVorlage (JSON, validated against VALID_LPHS/ VALID_DISCIPLINE_ROLES, values 0–1), autoLockAktiv, feiertagBundesland, maxBackups, sessionTage, loginMaxVersuche, loginSperreMinuten.
  • GET /holidays → Auth; POST /holidays {date,name} / DELETE /holidays/:date → Admin. date is the primary key, so posting an existing date just renames it. GET /holidays also returns states (the 16 German states), since the server is the single place that list lives and the maintenance mask needs it right away.
  • GET /holidays/suggest?year=&state= → Auth; POST /holidays/import {year,state} → Admin. German public holidays are computed, not fetched from a web service: holidaysForYear() in server.js combines fixed dates with everything hanging off Easter (easterSunday(), the Gaussian/Meeus formula; bussUndBettag() for Saxony). No external dependency, no internet access on the server — which a shop network does not necessarily have — and the result is deterministic and works offline. The suggestion route marks days already stored (vorhanden); the import uses INSERT OR IGNORE, so a hand-edited name on an existing date survives. Nothing is written automatically: a holiday shifts every computed schedule, so an admin confirms it. The state is remembered in app_settings.feiertagBundesland (default NW). Municipality-level cases (Fronleichnam in parts of SN/TH, Mariä Himmelfahrt in Bavarian catholic municipalities) are deliberately left out — they cannot be derived from the state and can be added by hand.
  • GET /backup → Admin. Consistent SQLite snapshot (VACUUM INTO, WAL-safe) as a .db file download (temp file in os.tmpdir(), deleted after send). Used by downloadBackup() in js/backup.js. Additionally, on server startup the server writes a timestamped copy to sessions/backups/ (also via VACUUM INTO) and keeps only the MAX_BACKUPS newest ones (default 10).
  • POST /import → Admin. Restores the whole database from a .db backup file sent as a raw binary body (express.raw, limit 100 MB). Validates the schema (pragma_table_info on every table, exact column match vs. live DB), then swaps all data via ATTACH DATABASE … + DELETE/INSERT-SELECT inside one transaction (rollback on error). Immediately BEFORE the restore the server saves a fresh auto-backup of the current state via runAutoBackup() (so a restore can be undone), then performs the swap. Used by importDb() in js/import.js (the "DB Import" button is only shown to admins, renderUserBar() in js/auth.js, and the route itself is admin-only regardless of UI). Note: IMPORT_TABLES in server.js lists the 16 original per-week/session relational tables (weeks … plan_milestone_deps) plus project_protokolle/protokoll_teilnehmer/protokoll_punkte (added with the Protokolle feature — unlike project_files, a Protokoll has no on-disk component, so it's safe to include in a restore, see "### Protokolle"), i.e. the 7 auth/user/file tables (users, user_sessions, user_roles, user_absences, project_files, project_file_reviewers, project_file_comments) are not touched by import/export — a DB restore never overwrites accounts, roles, absences, sessions, or file-review state. (people/person_roles/person_absences were removed from IMPORT_TABLES along with the tables themselves, see Persistence.)
  • ELT|HLS|ARCH (VALID_DEPARTMENTS in server.js, DEPARTMENTS in js/globals.js) are no longer a partitioning of weeks/projects/people — see "Departments were retired" below. They still identify a user's own discipline (users.department) and, since the Wochenbericht-Gewerke feature, a project's discipline(s) (project_gewerke — see "Wochenbericht — Gewerke-Trennung" below); VALID_DEPARTMENTS is the shared value set for both.
  • Static assets: files requested with ?v=N (every JS/CSS in App.html) get Cache-Control: public, max-age=31536000, immutable; everything else (App.html, unversioned files) no-cache + ETag. This makes bumping ?v=N for every changed CSS/JS file mandatory — an unbumped file stays stale in browsers for a year. See "Ladezeit (Pi)" below.
  • JSON body limit is 2 MB.

Persistence (SQLite)

  • Data lives in sessions/projektuebersicht.db (node:sqlite DatabaseSync, WAL mode, PRAGMA foreign_keys = ON, PRAGMA busy_timeout = 5000, PRAGMA synchronous = NORMAL to avoid SQLITE_BUSY under concurrent writers), created automatically. The schema is fully relational (26 tables, in the unconditional CREATE TABLE block at the top of server.js) instead of one big JSON document per week:

    • weeks (department, label, week_id, date, locked, updated_at; PK (department,label)) — updated_at is the revision timestamp for the single-user conflict handling (see /save-week above)
    • week_people / week_people_roles — person copies per week (array_order keeps the list order, role_order the role array order)
    • attendance — présence per week/person
    • projects — u. a. groesse (REAL 0–1, Ersatz für ein fehlendes Honorar in der Auslastungsrechnung, siehe "Auslastung — Wochenstunden statt Faktoren"); (array_order = position in the JSON array, order_value = the p.order field, which is only unique within a group, not globally!; besprochen = ticked off in the team meeting — it belongs to the SESSION, not the project, so cloneWeek() resets it: a new session starts with an empty agenda)
    • project_gewerke (week_label, project_id, gewerk; PK über alle drei) — die Fachbereiche, die das Projekt bearbeiten (ELT/HLS/ARCH, dieselben Werte wie users.department); steuert die Auswahl der Beteiligten im Assistenten
    • project_lph, project_assign (person_id|role keys split into columns), project_aufwand (LP effort per role), project_aktuelle_lp
    • project_plan, plan_people, plan_todos / plan_todo_assignees / plan_todo_deps, plan_milestones (position column!) / plan_milestone_deps
    • users (id, username UNIQUE, display_name, email, password_hash, password_salt, role 'admin'|'member', person_id, active, must_change_password, created_at, updated_at, department, initials) + index on person_id and CREATE UNIQUE INDEX … ON users(person_id) WHERE person_id != '' (see ensureUserPersonIdUniqueIndex() below) — capacity_hours (contractual hours per week, added by migrateUsersDepartment(); 0 = not maintained and counts as a full position), person_id = personIdForName(display_name), the bridge to the existing person_id-based assignment scheme (e.g. plan_todo_assignees) so dashboard aggregation needs no schema change there; department is a VALID_DEPARTMENTS value (empty only tolerated for the pre-existing bootstrap admin), initials is the short board-column label
    • user_roles (user_id, role, role_order; PK (user_id,role), FK → users(id) ON DELETE CASCADE) — discipline roles (Planung/Projektleitung/Bauüberwachung, VALID_DISCIPLINE_ROLES)
    • user_absences (user_id, id, start, end, type, note; PK (user_id,id), FK → users(id) ON DELETE CASCADE)
    • user_ai_keys (user_id, provider, api_key, api_url; PK (user_id,provider), FK → users(id) ON DELETE CASCADE) — each account's own OpenAI/Claude API key + optional API-URL override for the Protokoll-Import's KI-gestützte Erkennung, see "### Protokolle"
    • user_sessions (token PK, user_id, created_at, last_seen_at, expires_at)
    • holidays (date PK YYYY-MM-DD, name) — firmenweite arbeitsfreie Tage. A table rather than a JSON value in app_settings because it is a list that grows and is maintained entry by entry. It feeds HOLIDAYS (js/globals.js) and thus isWorkday()/workdaysBetween()/ addWorkdays() in public/js/utils.js — i.e. the Honorar schedule and the Projektbereich's resource load. Dependency-based scheduling (Auto-Schedule) still counts calendar days, as it always did.
    • project_files (id PK, department, project_nr, project_name, original_filename, stored_name, content_type, size_bytes, uploaded_by_user_id, status 'pending'|'approved'|'rejected', created_at, updated_at) + index on (department, project_nr)
    • project_file_reviewers (file_id, user_id, decision 'pending'|'approved'|'rejected', decided_at, note; PK (file_id, user_id)) + index on (user_id, decision)
    • project_file_comments (id PK, file_id, user_id, body, created_at)
    • project_protokolle (id PK, project_nr, project_name, nummer, datum, betreff, ort, verfasser_user_id, week_label, created_at, updated_at) + index on project_nr — scoped like project_files, see "### Protokolle"
    • protokoll_teilnehmer (id PK, protokoll_id, name, firma, anwesend, array_order) + index on protokoll_id
    • protokoll_punkte (id PK, protokoll_id, stamm_id, nummer, gruppe, neu, thema, text, zustaendig, termin, erledigt, erstellt_am, array_order) + index on protokoll_id — stamm_id links copies of "the same" point across Protokolle (the "Fortschreiben"), nummer/gruppe are free text. erstellt_am (added on Erik's follow-up request, migration migrateAddProtokollPunktErstelltAm(), existing rows stay empty — their real origin date can't be reconstructed) is set exactly once, the moment a point is first saved — either the owning Protokoll's own datum (a point added by hand or newly recognized on import) — and, like stamm_id, carried forward UNCHANGED by the Fortschreiben-copy in legeProtokollAn() and by PUT /project-protokolle/:id (which looks up the existing row by id and keeps its stored value rather than trusting whatever the client sends): a Fortschreiben copy or an ordinary edit of an existing point is not a new creation, so its original date must not move just because it now also appears in a later Protokoll. Only a point with no matching existing id gets a fresh erstellt_am, from the datum being saved in that very request — the same rule neu/stamm_id already follow for telling "genuinely new" apart from "carried over".

    people / person_roles / person_absences (the former per-department global-people schema) are gone from the live schema — they are only ever created transiently by migrateLegacySchema() when converting a very old single-JSON-document DB, and then immediately retired by migrateRetirePeopleTablesIntoUsers() (see Migration below) in the same startup. loadGlobalPeople(department) now reads exclusively from users/user_roles/user_absences (WHERE department = ? AND active = 1) and returns the unchanged shape {id, name, fullName, email, role:[], absences:[{id,start,end,type,note}]} (id/name/fullName map to person_id/initials||username/display_name), so GET /load-all, board.js, load.js, gantt.js, plan-modals.js needed no changes.

    FK constraint note: although PRAGMA foreign_keys = ON is set, only user_sessions.user_id, user_roles.user_id, user_absences.user_id and user_ai_keys.user_id have a declared FOREIGN KEY … REFERENCES users(id) ON DELETE CASCADE (deleting a user auto-deletes their sessions/roles/absences/AI keys). Every other table — the 16 original per-week/session tables, the 4 file-related ones (users, project_files, project_file_reviewers, project_file_comments) and the 3 Protokolle tables (project_protokolle, protokoll_teilnehmer, protokoll_punkte) — has no declared FK constraints; this is a deliberate continuation of the pre-existing convention (referential integrity is enforced in application code, e.g. deleteWeekData()), not an oversight. DELETE /project-files/:id therefore deletes project_file_comments and project_file_reviewers rows explicitly before the project_files row.

  • The HTTP API stays JSON-shaped: serializeWeek() splits an incoming week object across the tables inside a transaction, loadWeeks() / loadGlobalPeople() (re)assemble week/people data on read. saveWeekRelational() deletes the week's rows first (deleteWeekData(), note weeks uses label, all child tables week_label) and returns the new updated_at revision written by serializeWeek(). There is no serializeGlobalPeople() anymore — the global people list has no write path of its own; it is a read-only projection of users/user_roles/ user_absences, which are written only through /admin/users* and /users/:id/absences*.

  • Migration on every start (idempotent, run in this order after schema creation):

    1. migrateLegacySchema() converts a DB from the old single-JSON-document format (detected via a data column in weeks): it reads the documents, drops all tables, recreates the (then-current, including the now-retired people/person_roles/person_absences) relational schema and inserts everything in one transaction.
    2. migrateWeeksUpdatedAt() adds the updated_at column to weeks (idempotent, ALTER TABLE … ADD COLUMN). Legacy rows keep updated_at = '', so the first client save with an empty baseUpdatedAt matches and only then a real revision is written.
    3. migrateUsersDepartment() adds the department/initials columns to users if missing (idempotent ALTER TABLE … ADD COLUMN, same pattern as step 2).
    4. ensureUserPersonIdUniqueIndex() first calls dedupeUserPersonIds() — person_id used to only need to be unique per department (the old people table); now that it globally identifies a user, any existing duplicates are resolved by appending _<user id> to every row but the first (logged with console.warn) — then creates idx_users_person_id_unique (a partial unique index skipping empty person_id).
    5. migrateRetirePeopleTablesIntoUsers() runs only while a people table still exists (no-op/idempotent otherwise): for every legacy people row it looks up a users row by (department, person_id); on a match it copies that person's person_roles into user_roles (INSERT OR IGNORE); on no match the row is only logged to the console as unmatched — no placeholder user is created for it (would pollute the user list with non-loginable dead entries). Historical person_absences rows are not migrated (serializeGlobalPeople()'s old code wrote String(absenceObject), i.e. literally "[object Object]", into that column, so there was nothing meaningful to carry over). It then drops people, person_roles, and person_absences. Historical weeks are unaffected either way: week_people/week_people_roles already freeze each week's person name/roles at save time, independent of the global people table.
    6. bootstrapInitialAdmin() (see Auth section).
  • The DB file sits in sessions/, so the existing Docker bind-mount ./sessions:/app/sessions keeps it persistent. The path is overridable via the DB_PATH environment variable (used for tests), production default is sessions/projektuebersicht.db.

  • Project/person IDs are stable across reloads; only week-level fields are queryable columns.

Frontend architecture

  • The frontend is split into small classic non-module <script> files under public/js/; all functions are globals on window. Three HTML files include them via <script src> tags; load order in the HTML defines dependencies, since there is no bundler:
    • App.html loads globals → utils → ui → date-picker → server → auth → store → weeks (shared modules), then the tab-specific folders board → people → projects → load → dashboard → wand, finally gantt → tabs-auth → pdf → backup → import → init. See the exact list in App.html. auth.js sits right after server.js because checkAuthAndInit() needs baseUrl(), and before store.js/weeks.js since nothing may talk to the data endpoints before a session exists. The js/plan/* modules of the Projektbereich sit right after gantt.js (plan-state → plan-sections → plan-info → plan-history → plan-build-gantt → plan-gantt → plan-drag → plan-modals → plan-tasks → plan-files → plan-templates → plan-protokoll-todos → plan-honorar → plan-settings → plan-schedule): they must come after gantt.js, whose buildGanttHtml() plan-build-gantt.js wraps, and plan-history must stay before plan-build-gantt, which uses its previous-week helpers. None of them collides with a main-module global — every one runs in an IIFE over window.PlanApp and exports through window.*.
    • admin-users.html is the second, fully independent page (the only one outside the tab system): it loads only globals → utils → ui → server → auth → admin-users.js (no date-picker, no gantt, no plan/* — it only talks to /admin/users*).
  • currentUser/checkAuthAndInit() (js/auth.js) are the shared client-side auth concept across all three pages: checkAuthAndInit(onAuthenticated) calls GET /whoami; with a session it sets currentUser, hides the login overlay and runs onAuthenticated() (the page's normal entry point, e.g. initUiState() + switchDepartment() in js/init.js); without one it shows the blocking #loginOverlay/#loginForm and only proceeds after a successful POST /login. renderUserBar() shows the current user's name/role and toggles admin-only UI (the "DB Import" button, the #adminUsersLink to admin-users.html) based on currentUser.role.
  • Shared state object window.PlanApp (js/plan/plan-state.js) holds all project-plan page state (week, project, zoom level, drag/modal-state), db/currentDepartment live in js/globals.js.
  • State: let db = { weeks, currentWeek, globalPeople } (in-memory only).
  • Project/person IDs are stable across reloads because the server splits and reassembles week objects deterministically (JSON IDs are kept in the DB columns); projects are addressed cross-page by department + week label + projNr (+ readable projName path segment) via URL params: openProjectPlan() builds /HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…, findProjectForPlan resolves them. Person IDs follow personIdForName(name) → p_<lowercased alnum>.
  • save() debounces 500 ms then syncAllToServer() writes only dirty weeks (dirtyWeeks – track markWeekDirty()) via sendWeekToServer(). There is no separate people-list dirty-tracking/sync path anymore (no dirtyPeople/markPeopleDirty()/sendPeopleToServer()) — since Projektbeteiligte is a read-only projection of users/user_roles/ user_absences, the only way the client ever changes "people" data is either (a) admin CRUD on admin-users.html (writes straight to /admin/users*//users/:id/absences*, no debounce/dirty-tracking involved), or (b) the cross-department roster addition (addUserToCurrentWeekRoster() in js/people/people.js), which just pushes into currentWeek().people and goes through the normal week-save() path. An in-flight guard (syncInFlight/syncQueued) prevents overlapping syncs from double-POSTing the same week with the same revision (which would look like a spurious 409). On success the server-provided updatedAt revision is adopted as the new sync base; weeks whose save failed (network errors) stay dirty and are retried. A 409 conflict response drops the local changes for that week and handleWeekConflict() reloads only that week from the server (reloadWeekFromServer()), leaving all other dirty weeks untouched. Exception: js/plan/plan-state.js savePlan() bypasses save() and writes only the current week directly via sendWeekToServer() (with the same in-flight/trailing-save guard), adopting the returned updatedAt and showing a toast on conflict. switchWeek() (in js/weeks.js) does NOT trigger a save – only actual edits do.
  • switchDepartment() cancels a pending save debounce before switching, and load() discards stale /load-all responses after a department switch (frozen requestedDepartment), so late responses can never render or persist data under the wrong department.
  • The people tab no longer has drag-and-drop, a form, or delete/reorder actions (renderPeople() in js/people/people.js is read-only rendering of db.globalPeople[currentDepartment] plus any cross-department roster additions on the current week). getDragAfterElement()/drag-and-drop (js/projects/projects.js) serves the project list's .board-row rows and, via js/people/people.js, the .person-list-item rows — the people tab's former .person-card/.people-sortable/reorderPeople() drag machinery was removed along with the CRUD UI. No drag handles are rendered on locked weeks in the Projekte tab either way.
  • Weeks are sorted by calendar week extracted from the label (KW XX / YYYY) via weekSortKey(), not alphabetically/by filename.
  • applyAutoLock() locks every week except the chronologically last one after load/clone/new-week; localStorage UI state is not restored for locked tabs.
  • localStorage keys: projuebersicht_v1 (department only — tab is never restored from storage; at startup the tab comes from the deep-link URL if present, otherwise DEFAULT_TAB = 'dashboard', i.e. "Meine Aufgaben"), projuebersicht-theme, header-collapse key. Do not store data in localStorage; it is UI state only.
  • Deep-link URLs (/<Dept>/<Tab>) are shared via the address bar: updateRouteUrl() rewrites the path via history.replaceState on tab and department switches. Since the whole app requires login, an unauthenticated visitor to any deep link (including /Projektplan/…) simply sees the login overlay first; the URL itself is never gated per-tab anymore (there is no more admin/member split at the tab level — see Auth section).
  • Sidebar navigation (App.html only): the former top tab bar, a separate <header>, and a separate off-canvas tool drawer have been merged into one persistent left sidebar, #headerMenu/.side-menu (the element keeps its old id/class and toggleHeader()/setHeaderCollapsed() mechanics from js/ui.js, now repurposed to hold the entire navigation). Structure top to bottom: brand (logo/title), a "Navigation" section with the .nav-item.tab/data-tab buttons (showTab()) in the order Meine Aufgaben → Projekte → Wochenbericht → Auslastung (plus #planNavItem, see below) — no "Abteilung" section (removed along with department partitioning, see "Departments were retired"; .dept-tab/switchDepartment() no longer exist) — then — below the divider — the Einstellungen nav item (the tab settings), the Werkzeuge accordion and the Admin accordion (its sub-items are the sections of the admin tab, see below), and a .side-menu-footer with #userBarInfo/#adminUsersLink/logout (renderUserBar(), unchanged). At min-width: 769px the sidebar is position: sticky and permanently visible (no hamburger, styles.css); below that width, a narrow .mobile-topbar (logo + hamburger) replaces the old <header> and the sidebar reverts to its pre-existing off-canvas slide-in behavior. admin-users.html deliberately does not get this sidebar — it stays a focused standalone page outside the tab system, with its own back-link and user bar. The Projektbereich needs no special case at all any more: being the tab plan of App.html, it simply IS under the same sidebar, with every section (Sitzung, Einstellungen, Admin, user footer) working as everywhere else. The only sidebar addition is #planNavItem — a nav item that stays hidden until a project has been opened and then carries that project's name (syncPlanNavItem() in js/tabs-auth.js), so the sidebar still highlights where you are while in the Projektbereich. showTab() additionally hides .container for t === 'plan', because #plan deliberately sits OUTSIDE it (it brings its own header and padding) and the empty container's padding would otherwise show as a blank strip.

Auth / security quirk

  • Real server-side authentication (this replaced the old client-only password modal — PROTECTED_PASSWORD_HASH, isAuthenticated, requestedAction, confirmPassword(), #passwordModal, and the CryptoJS CDN <script> tag are all gone; js/tabs-auth.js now only holds showTab()). Login is required for the entire app, not just for mutations/admin actions — every JSON endpoint except /login//whoami requires at least a valid session (see the Auth-level markers in "Server API" above).
  • Sessions: a session is a row in user_sessions (token PK, 30-day expires_at), identified via the puebersicht_session cookie (HttpOnly; SameSite=Lax, Secure added automatically when the request is HTTPS). Cookie parsing/setting is hand-rolled (parseCookies(), setSessionCookie()/clearSessionCookie() in server.js) — no cookie-parser dependency, keeping the project's express+cors-only convention. attachUser middleware (registered via app.use(attachUser) right after express.json() and before express.static, so it runs for every request including static assets) reads the cookie, loads the joined session+user row, and sets req.user (or null); it also refreshes last_seen_at on every authenticated request. requireAuth (401 if req.user is null) and requireRole('admin') (401 if not logged in, else 403 if role !== 'admin') are the two middleware factories used on routes.
  • Passwords: hashed with Node's built-in crypto.scryptSync (salt + 64-byte hash, hashPassword()/verifyPassword() in server.js); verifyPassword() uses crypto.timingSafeEqual(). No bcrypt/argon2 dependency — stays within the express+cors-only convention. /login returns the same generic error for wrong username, wrong password, or an inactive account (no username-enumeration signal).
  • Roles: admin, member, and kiosk (users.role, VALID_ROLES in server.js — do not confuse with the discipline roles array on a user, Planung/Projektleitung/Bauüberwachung in user_roles, which is unrelated to permissions). kiosk is the one role that IS fully server-enforced rather than a UI gate (see "Wandtafel" below for why it exists): requireAuth rejects it outright everywhere except the handful of read-only routes the Wandtafel needs (requireAuthOderKiosk), and a kiosk account can never carry a discipline role (enforced on both POST/PATCH /admin/users*, in both directions — setting role: 'kiosk' on an account with existing discipline roles is rejected exactly like adding a discipline role to an existing kiosk account). Otherwise, only the genuinely destructive/administrative actions are server-enforced admin-only: week deletion (/delete-week), week unlock (/unlock-week), DB import (/import), and the /admin/users* CRUD. /users/:id/absences* stays open to any logged-in user (unchanged permission level from before). The Projekte tab and the (now read-only) Projektbeteiligte tab only require being logged in — any role; actually adding/editing/removing team members happens exclusively via admin-users.html (admin-only). The cross-department roster-add/remove UI in the Projektbeteiligte tab (js/people/people.js) is only rendered for currentUser.role === 'admin' — like the old client-only password modal, this is a UI-level gate, not a server-enforced one: it writes into currentWeek().people through the ordinary save() → POST /save-week, which only requires requireAuth (any role), so it does not add a new hard permission boundary.
  • First-admin bootstrap: bootstrapInitialAdmin() runs once at every server start, guarded by SELECT COUNT(*) FROM users — if users is empty (fresh install), it generates a random password (crypto.randomBytes(15).toString('base64url')), creates an admin account (username admin, must_change_password=1), and prints the plaintext password to the console and writes it to sessions/INITIAL_ADMIN_PASSWORD.txt (outside public/, so express.static never serves it). There is no hardcoded password anywhere in the code.
  • users.person_id is the bridge between login accounts and the pre-existing, department-independent personIdForName()-based assignment scheme (e.g. plan_todo_assignees.person_id) — set explicitly by an admin (not auto-derived server-side), so an account and a project-roster entry can exist independently of each other. A user with no matching person_id sees an empty dashboard with a hint instead of an error (renderDashboard() in js/dashboard/dashboard.js).
  • CryptoJS is gone; pdfmake (PDF export) is still loaded from cdnjs.cloudflare.com in App.html, so PDF export needs internet access and won't work offline. admin-users.html loads no CDN script.
  • User-provided status HTML is sanitized on render via a small whitelist (sanitizeStatusHtml in js/board/board.js) to mitigate XSS.

Einführung — geführter Rundgang (js/einfuehrung.js)

Eriks Wunsch: Jeder Nutzer, auch bestehende, macht einmal einen geführten Rundgang; unter Einstellungen → Mein Konto „Rundgang starten“ jederzeit wieder. Abgedunkelte Ebene mit Aussparung um das Ziel (.tour-loch, box-shadow) und Sprechblase (.tour-blase, rechts vom Ziel, sonst darunter/darüber, auf Handybreite unten), Fortschrittspunkte, Zurück/Weiter, Pfeiltasten, Esc. Die Schritte (EINFUEHRUNG_SCHRITTE) wechseln selbst in den Bereich (showTab()) und zeigen nur, was die Rolle darf (recht/wenn, Rollen & Rechte); ohne „Anwendung nutzen“ (Wandtafel) kein Rundgang. Der Projektbereich wird erklärt, nicht geöffnet. Abschluss nennt Fehlendes (z. B. keine Person-ID).

  • users.einfuehrung_am (Migration in migrateUsersDepartment(), leer = steht aus — auch für alle bestehenden Konten), req.user.einfuehrungFaellig, POST /einfuehrung {erledigt} (nur eigenes Konto). „Fertig“ und „Überspringen“ vermerken; „Später“/Esc schließt nur für den Seitenaufruf (sessionStorage).
  • Start über einfuehrungBeiBedarf() aus afterAuthenticated() bzw. nach dem Pflicht-Passwortwechsel; wartet, bis Daten geladen und kein Dialog offen ist. Stile: Block „RUNDGANG“ am Ende von styles.css (z-index 450).

Abwesenheiten (Seitenleiste, js/abwesenheit/abwesenheit.js)

Menüpunkt „Abwesenheiten“ (Tab abwesenheit, /Abwesenheiten) nach Auslastung. Gestaltung „Variante 2 im Stil der Projektübersicht“ (Eriks Wahl): ohne Kopfbereich (Titel, Legende, „+ Abwesenheit“ und Hinweise auf Eriks Wunsch entfernt), oben direkt zwei Spalten — links Karte „Neue Abwesenheit“ (Für-Zeile mit Kürzel-Kreis, große Von/Bis-Kacheln öffnen openRangePicker(... onApply), Art als vier Kacheln, Notiz, Warnungen: Überschneidung, offene Aufgaben der Person im Zeitraum, Kollegen desselben Gewerks), rechts die Liste der gewählten Person als Projektzeilen (.board-row: Startdatum in .board-nr, KW als .phase-pill, Ändern/Löschen als .board-row-actions, Löschen zweistufig); unten „Team im “ mit Gewerk-Filter und je Gewerk eine Karte (.board-list) mit Kürzel-Kreis in Fachrollen-Farbe (.board-avatar) und Balken je Abwesenheit (Klick auf einen Tag = für diese Person eintragen, auf einen Balken = ändern). Keine halben Tage.

  • Rechte (Gruppe „Team“, Schalter wie alle anderen): team.abw (für mich), team.abwProjekte (Beteiligte gemeinsamer Projekte der jüngsten Sitzung, project_assign.state > 0), team.abwGewerk (gleiches users.department), team.abwAlle. Keiner = keine Abwesenheiten, kein Menüpunkt (data-recht="a|b" = eines genügt, renderUserBar()). team.abwFremd ist entfallen; einmalige Umstellung abwesenheitsRechteUmstellen(): Mitglied nur team.abw, Projektleiter zusätzlich Gewerk + Projekte, selbst angelegte Rollen mit dem alten Recht bekommen „für alle“.
  • Server: abwesenheitZiele(user)/darfAbwesenheit(user, zielId) prüfen POST/PATCH/DELETE /users/:id/absences… (403 sonst; der frühere Sonderweg „Ersteller darf löschen“ ist weg). Arten fest (ABWESENHEIT_ARTEN), Datum ISO, Ende ≥ Beginn. Sichtbarkeit: loadGlobalPeople(req.user) (in /load-all, /my-dashboard, GET /personen) liefert Abwesenheiten außerhalb des eigenen Umfangs als {type:'abwesend', note:'', verdeckt:true} und je Person userId/darfAbw. Jede Änderung sendet das Live-Ereignis {typ:'personen'}; personenNachladen() (store.js) holt GET /personen.
  • Der Abwesenheits-Dialog im Terminplan und die Benutzerverwaltung nutzen dieselben Routen und damit dieselben Rechte. Rundgang: Schritt „Abwesenheiten“. Stile: Block „ABWESENHEITEN“ am Ende von styles.css.

Rollen & Rechte (aktueller Stand — löst die festen Rollen ab)

Eriks Wunsch (Entwurf „B“): Admin → Rollen & Rechte (js/admin/rollen.js, Karte #adminRollenCard). Links die Rollen (Konten, Anteil der Rechte), darunter „+ Neue Rolle“ (leer oder mit den Rechten einer anderen); rechts die gewählte Rolle mit einem Schalter je Recht nach Gruppen, „alle/keine“ je Gruppe, „Vergleich mit“ einer zweiten Rolle, Umbenennen, Kopieren, Löschen (zweistufig im Knopf). Jede Änderung speichert sofort.

  • Katalog RECHTE (server.js, 30 Rechte in 7 Gruppen: Grundzugang, Projekte, Wochenbericht, Team, Werkzeuge, Sitzungen, Admin-Bereich). Die Schlüssel stehen im Code, nur die Zuordnung ist frei.
  • Tabellen roles (key = users.role, name, sort_order, fest) und role_rechte; NICHT in IMPORT_TABLES. Startbelegung (nur bei leerer Tabelle) = Stand vorher: Administrator fest mit allem, Projektleiter und Mitglied alles aus Gruppe 1–5 (Mitglied ohne „Projekte anlegen und löschen“), Wandtafel nur „Wandtafel anzeigen“. VALID_ROLES ist weg (rolleExistiert()).
  • Server: hatRecht(user, recht), requireRecht() ersetzt alle requireRole('admin'); /app-settings prüft je Einstellung (APP_SETTING_RECHT). Eine Rolle ohne „Anwendung nutzen“ (istNurWandtafel()) ist das frühere Kiosk-Konto: requireAuth weist sie ab, SQL schließt sie über sqlOhneWandtafel() aus Teams aus, keine Fachrolle. Speichern: fehlendesBearbeitungsrecht() vergleicht in /save-week und /save-projects je Projekt mit dem gespeicherten Stand (Kommentar/lph → wb.kommentar, besprochen → wb.besprochen, plan → proj.plan, sonst → proj.stamm, Anwesenheit → wb.anwesenheit, Sandbox → wz.planung; normWert() behandelt leere Werte und Zahl/Text gleich). Wer alle diese Rechte hat, überspringt den Vergleich. 403 trägt recht.
  • Schutz: Administrator nicht änderbar/löschbar; nur ein Administrator vergibt/entzieht Administrator; der letzte aktive Administrator bleibt; „Rollen & Rechte“ kann man der eigenen Rolle nicht entziehen; Rolle mit Konten nicht löschbar (409 nennt sie).
  • Routen: GET /rollen (Katalog + Rollen mit Kontenzahl, requireAuth), POST /rollen {name, vonRolle}, PATCH /rollen/:key {name}, PUT /rollen/:key/rechte {rechte, an}, DELETE /rollen/:key — alle mit Recht a.rollen. /whoami liefert rechte und rolleName.
  • Browser: hatRecht()/hatAdminRecht() (js/auth.js); Admin-Abschnitte je Recht (ADMIN_SECTIONS[].recht, adminNavAbgleichen()); Elemente mit .nur-recht[data-recht] werden ausgeblendet; showTab() leitet ohne „Anwendung nutzen“ auf die Wandtafel. Projektbereich: app.isLocked() (Terminplan, proj.plan), app.stammGesperrt() (Einstellungen, Gruppe, amtierende LPH, Honorar), app.protokolleGesperrt(). Wochenbericht: Kommentar/Fortschritt, „Besprochen“, Anwesenheit je Recht. Ein 403 beim Speichern zeigt einen Hinweis und lädt die Sitzung neu (speicherFehler(), kein Wiederholen). Benutzerverwaltung: Rollenauswahl aus GET /rollen.
  • Abschnitte weiter unten, die „admin-only“, requireRole('admin'), kiosk oder „Projektleiter und Administratoren“ nennen, beschreiben den Stand davor — gemeint ist jetzt das jeweilige Recht.

Ein Ort je Angabe

Every piece of project data now has exactly ONE place where it is entered. Before, six of nine fields were editable in three different masks (Projektboard, Wochenbericht, Projektbereich) — nobody knew which one was authoritative, and two people could change the same field in different places. The split follows what kind of data it is:

  • Laufende Erfassung → Wochenbericht only. The session comment (p.status) and the progress per Leistungsphase (p.lph) are produced in the weekly meeting. They are editable only in the Wochenbericht.
  • Stammdaten → Projektbereich only. Projektnummer, Bezeichnung, Schlagwörter, Gruppe, amtierende Leistungsphase, Auftragsstatus, Honorar, the beauftragte Leistungsphasen, the Aufwands-Gewichtung, and the assignment of people (p.assign) live in the Projektbereich (einstellungen section, js/plan/plan-settings.js plus js/plan/plan-info.js for Gruppe/amtierende LPH). p.assign moved here on Erik's explicit request ("In den Einstellungen der Projekte will ich auch die Projektbeteiligten einstellen können"), fully relocated rather than duplicated — he chose that option outright over making it editable in both places, to keep this same "Ein Ort je Angabe" invariant for the new field too. It is edited via planCycleAssign(personId, role) in plan-settings.js, filling #planAssignMatrix — deliberately not reusing cycleAssign() (removed, see below) or going through currentWeek()/save() at all: the Projektbereich can be deep-linked to an older week, or be a Planung-Sandbox plan with no currentWeek() entry at all (same trap planOpenAufwand() already guards against), so planCycleAssign() mutates app.project directly and calls app.savePlan(), exactly like every other Stammdatum in this file.
  • Everywhere else: display plus a way there. renderBoardDrawer() takes opts.nurAnsicht (set by BOARD_OPTS in js/board/board.js): same card, same values, same order — but no input fields and a .drawer-verweis underneath with the button "Im Wochenbericht bearbeiten →" for Kommentar/ Fortschritt. The Beteiligten-Zuordnung (roleRows) is now always rendered read-only inside renderBoardDrawer() — in the Projektboard AND in the Wochenbericht, regardless of opts.nurAnsicht — with its own "In den Projekteinstellungen bearbeiten →" button next to it (springZuEinstellungen(pid), js/gantt.js). The Projektbereich's Dashboard section does the equivalent for Kommentar/Fortschritt via setzeBerichtVerweis() (js/plan/plan-info.js), filling #planStatusVerweis/#planLphVerweis; it has no such verweis for Beteiligte any more, since that matrix is edited right there in Einstellungen now, not somewhere else.
  • The link is a real jump, not a hint: springZumWochenbericht(uid) (js/plan/plan-info.js, also used by renderBerichtVerweis() in board.js) sets berichtAuswahl before showTab('wochenbericht') and therefore lands on that very project. Without it the user would have to find it again in a list of thirty. (berichtAuswahl is a global let binding from js/wochenbericht/wochenbericht.js — classic scripts, no modules, so assigning it from another file works.) springZuEinstellungen(pid) is the same idea in the other direction: it sets location.hash = 'einstellungen' before calling openProjectPlan(pid), the same "set the hash first" trick wandSpringeZuProjekt() already used, since showTab('plan')'s own updateRouteUrl() preserves an already-set hash.
  • Consequence: planStatusBlur(), setPlanLph() and setPlanAssign() in js/plan/plan-info.js are gone — they had no caller left. The status editor's formatting toolbar (formatStatus() buttons) went with them in the Projektbereich; the Wochenbericht keeps the full editor. editLph() and saveBoardComment() in js/board/board.js are untouched — the Wochenbericht's editing hangs off them. cycleAssign() in js/board/board.js is now also gone (the Beteiligten relocation's own consequence, mirroring the three functions above) — it had exactly one caller (roleRows's avatar onclick), which is now unconditionally read-only.
  • Honorar and beauftragte Leistungsphasen stay editable in TWO places on purpose (Projekteinstellungen and the "Terminplan aus Honorar" dialog, so the dialog remains usable on its own); both write through the same functions in js/plan/plan-honorar.js and re-render each other, so they cannot drift.
  • Changing the Projektnummer asks for confirmation (setPlanProjNr()): uploaded files hang off (department, project_nr), so a renumbered project would lose them. Changing the Bezeichnung updates the plan header (#planTitle), the sidebar entry (syncPlanNavItem()), the URL (updateRouteUrl('plan')) and the list — renderPlanPage() would do all of it, but it is module-local in plan-state.js and unreachable from plan-settings.js.

Eine Projektliste (tab board, labelled "Projekte")

  • Avatar-Stapel je Person, nicht je Rolle (Projektliste und Titelzeile des Projektbereichs, Eriks Wunsch): beteiligteJePerson() (js/utils.js) fasst die Rollen einer Person zusammen; beteiligtenAvatarHtml() zeichnet bei mehreren Rollen einen Kreis aus senkrechten Streifen in den Rollenfarben (linear-gradient(to right …), ROLLEN_FARBE, immer volle Farbe — Haupt/Unterstützung nur im Tooltip, .board-avatar.ist-mehrfach), der Tooltip nennt alle Rollen.

The former Projekte tab is gone; renderBoard() is the single project list. It kept everything that concerns the LIST and gave up everything that concerns a single project's data:

  • Each group is a drop zone: <div class="board-list projects-sortable" data-group="…" ondragover ondrop>, each row draggable with a .drag-handle. The handlers stay in js/projects/projects.js (handleDragStart/handleDrop/handleDragEnd/reorderProjects) — they hold the ordering logic and are also used by js/people/people.js. Only handleDrop() had to learn which selector to use: it picks .board-row:not([style*="opacity"]) when the drop zone contains board rows, .person-list-item otherwise. Without that, every drop landed at the end of the group.
  • All four groups from GROUP_ORDER are pre-seeded even when empty, so a group emptied by dragging stays a drop target (a bug already fixed once in the old Projekte tab).
  • .board-row-actions at the end of each row: a "⋯" button (oeffneProjektMenue() in board.js, Eriks Wunsch; same .user-kebab/ .user-menu styling as the Benutzerverwaltung) with "Öffnen" (Projektbereich) and "Löschen" — the latter confirms inside the menu and then calls delProjectFromList(id, true) without confirm(). Hidden until hover/focus so the list stays calm. "+ Projekt" sits in the list's title row now (it was only in the sidebar before); the sidebar entry stays and opens the same mask.
  • js/projects/projects.js no longer renders anything by default: renderProjects() returns immediately unless #projectsList exists. Every caller (renderAll(), editProjectField(), auftrag.js, load.js) can stay as it is.
  • TAB_SLUGS (js/globals.js) lost projects, but TAB_BY_SLUG['Projekte'] is set to 'board' and VALID_TAB_SLUGS (server.js) still accepts Projekte, so forwarded links keep working instead of 404ing.

Assistent "Neues Projekt" (js/projekt-wizard.js)

Sechs Schritte statt eines Formulars mit allen Feldern auf einmal. Der frühere Dialog #newProjModal ist entfallen, ebenso openNewProjModal(), closeNewProjModal(), newProjStatusGewechselt() und saveNewProj() in js/load/load.js.

  1. Projekt — Nummer, Bezeichnung, Gewerke, Gruppe, Schlagwörter
  2. Auftrag — Auftragsstatus, Honorar oder Projektgröße, beauftragte Leistungsphasen
  3. Stand — amtierende Leistungsphase
  4. Team — Beteiligte je Rolle, auf die Gewerke des Projekts eingeschränkt
  5. Terminplan — aus dem Honorar errechnet
  6. Prüfen — Zusammenfassung, dann Anlegen

Warum überhaupt: Honorar, Team und Schlagwörter mussten zuvor NACH dem Anlegen an drei weiteren Stellen nachgetragen werden — und wurden es meist nicht. Genau daher rühren die Projekte, die in der Auslastung als „nicht bewertbar" erscheinen. Umgekehrt ist die 27-Felder-Aufwandsmatrix aus dem Anlegen verschwunden: Die firmenweite Vorlage genügt, die Feinjustierung gehört in den Projektbereich (openLpModal() lebt dort weiter).

Jeder Schritt ist Pflicht. wizardFehlt(key) liefert die fehlenden Angaben als Klartext; solange etwas fehlt, bleibt „Weiter" gesperrt und daneben steht, was fehlt — ein gesperrter Knopf ohne Begründung ist eine Sackgasse. Ein Assistent, der unvollständige Projekte durchlässt, löst das Problem nicht, das er lösen soll.

Gewerke (project_gewerke, Tabelle nach dem Muster von project_beauftragte_lph; Werte wie VALID_DEPARTMENTS): Ein Projekt gehört zu einem oder mehreren Fachbereichen. Im Schritt „Team" stehen nur die Mitarbeiter daraus zur Auswahl — ein reines Elektro-Projekt soll nicht die ganze Belegschaft anbieten. Wer jemanden aus einem anderen Gewerk braucht, holt ihn über das Auswahlfeld „Mitarbeiter aus anderen Gewerken" ausdrücklich dazu; solche Personen tragen in der Auswahl einen gestrichelten Rand. Wird ein Gewerk wieder abgewählt, fallen seine Beteiligten aus dem Team — sonst stünde im Projekt jemand, der nach der eigenen Regel gar nicht auswählbar wäre.

Das Gewerk einer Person kommt aus users.department; loadGlobalPeople() liefert es als Feld gewerk mit. Gewählt wird aber aus week.people und nicht aus db.globalPeople: Die Zuordnung landet in project.assign und wird vom Board über currentWeek().people aufgelöst — wer dort fehlt, erschiene nirgends.

Honorar oder Projektgröße, nie beides. Das jeweils andere Feld wird gesperrt statt nur ignoriert (ein Feld, das man ausfüllen kann, ohne dass es wirkt, ist eine Falle) und beim Umschalten geleert. Die Projektgröße ist ein Zahlenfeld, kein Regler: Ein Schieber legt eine Feinabstufung nahe, die eine grobe Schätzung nicht hat. Neben ihr steht kein Eurobetrag — intern rechnet projektBasisHonorar() zwar über referenzHonorar, aber die Summe auszuweisen gäbe der Schätzung eine Genauigkeit, die sie nicht hat (groesseText() gibt deshalb nur noch die Zahl zurück).

Terminplan (wizardPlanZeilen()): derselbe Rechenweg wie berechneHonorarPlan() — Phasenhonorar ÷ Stundensatz = Stunden, ÷ (Arbeitsstunden pro Tag × Personen) = Dauer, Phasen hintereinander über addWorkdays() (kennt die gepflegten Feiertage). Erzeugt je beauftragter Phase eine Aufgabe in project.plan.todos.

  • Aus der Projektgröße wird KEIN Terminplan gerechnet. Sie ist eine Schätzung, kein vereinbarter Betrag; daraus abgeleitete Termine sähen im Gantt so verbindlich aus wie errechnete. Der Schritt zeigt das als erklärten Hinweis, nicht als kommentarlos gesperrtes Feld.
  • Das Team steht vor dem Terminplan, weil die Dauer davon abhängt, wie viele daran arbeiten. Die Teamgröße ist die Vorgabe für „gleichzeitig daran" und überschreibbar. Ohne diesen Schritt ergab eine Phase über 180.000 € rechnerisch 172 Arbeitstage für eine Person, und der Plan lief über Jahre.

Projekte anlegen/löschen — Rolle „Projektleiter" und Papierkorb

  • Projekte anlegen und aus der Projektliste löschen dürfen nur projektleiter und admin (users.role; VALID_ROLES enthält jetzt admin|member|projektleiter|kiosk). projektleiter ist sonst ein gewöhnliches Mitglied — nicht zu verwechseln mit der Fachrolle Projektleitung (user_roles), die keine Berechtigung verleiht. Vergeben in Admin → Benutzerverwaltung. Bearbeiten bestehender Projekte bleibt jedem angemeldeten Konto erlaubt.
  • Serverseitig durchgesetzt, nicht nur UI: Da /save-week immer die ganze Woche schreibt, vergleicht projektlisteAenderungen() (server.js) den eingehenden mit dem gespeicherten Stand (Abgleich per id oder projectUid) und liefert neue und fehlende Projekte; ohne darfProjektlisteAendern(req.user) → 403 vor jedem Schreiben. Die Planung-Sandbox ist ausgenommen. Clientseitig blendet darfProjektlisteAendern() (js/auth.js) den Löschen-Knopf in renderBoardRow() aus, renderUserBar() alle Elemente mit der Klasse .nur-projektleitung (die beiden „+ Projekt"-Knöpfe), und openNewProjWizard() weist zusätzlich ab.
  • Papierkorb (deleted_projects: Schnappschuss des kompletten Projekt-Objekts als JSON, wer/wann/aus welcher Sitzung). Nach dem erfolgreichen Speichern legt legeInPapierkorb() die gelöschten Projekte dort ab. purgeGeloeschteProjekte() entfernt Einträge nach GELOESCHTE_PROJEKTE_TAGE (30) — beim Start und stündlich. Admin → Papierkorb (loadAdminPapierkorb(), js/admin/admin.js): GET /admin/deleted-projects, POST /admin/deleted-projects/:id/restore (setzt das Projekt ans Ende seiner Gruppe in der jüngsten echten Sitzung, leert den Sitzungskommentar, wenn es aus einer älteren Sitzung stammt; 409 bei Nummern-Kollision), DELETE /admin/deleted-projects/:id (sofort endgültig) — alle requireRole('admin'). Dateien/Protokolle hängen an project_nr und werden beim Löschen nie angefasst. Nicht in IMPORT_TABLES — ein DB-Import lässt den Papierkorb unberührt.

Auftragsstatus — von der Anfrage zum Projekt

projects.auftragsstatus (anfrage|angebot|beauftragt|abgeschlossen, default beauftragt, column added by migrateAddProjectAuftragsstatus()) makes the step into a project explicit. Everything lives in public/js/auftrag.js:

  • auftragsstatusChip(p) returns an empty string for beauftragt — the normal case must not add noise to thirty rows. Shown in the project list (renderBoardRow()), in the Wochenbericht agenda row and in its detail head.
  • auftragsstatusSelectHtml(p) is the mask, used by the Projekteinstellungen (#settingsAuftragsstatus) and the "Neues Projekt" dialog.
  • Switching TO beauftragt does not write directly: setAuftragsstatus() opens #beauftragungModal, because honorar and the beauftragte Leistungsphasen belong to the commissioning. The dialog requires at least one Leistungsphase, asks back when honorar is missing, offers to move the project out of "In Anbahnung" (that group deliberately does not count towards Auslastung) and can jump straight to the Terminplan.
  • In the "Neues Projekt" dialog the group follows the status (newProjStatusGewechselt() in js/load/load.js): Anfrage/Angebot proposes "In Anbahnung", back to Beauftragt proposes "Aktive Projekte". It is a visible proposal in the select, so it can be overridden.
  • POST /save-week rejects an unknown status with 400 before writing anything; auftragsstatus is in IMPORT_OPTIONAL_COLS so older backups still import, and projekte.csv carries the column.

Projektbereich (tab plan in App.html)

  • Each project has its OWN area, not just a Gantt view. The area is split into six sections switched by the .plan-nav bar (showPlanSection() in js/plan/plan-sections.js); all six live in the DOM at once and are only toggled via .is-active, so every render function keeps filling its elements regardless of visibility (that is why the split needed no changes in plan-info.js, plan-gantt.js, plan-files.js). The current section is written to the URL fragment with history.replaceState (…#aufgaben) and restored from it on load (app.initPlanSections(), called at the end of renderPlanPage()); a hashchange listener follows external changes.
    • Dashboard — Anordnung „Lagebild“ (Eriks Wahl aus drei Entwürfen): oben ein schmales Lageband (#planKpiRow, renderPlanKpis(): laufende Leistungsphase mit Ring + offene Aufgaben, nächster Meilenstein, Team-Auslastung; der frühere „Gesamtfortschritt“ der Aufgaben ist entfallen), darunter zwei Spalten (.plan-lage-raster), die sich unabhängig füllen: links Kommentar der Sitzung (mit Verfasser/Zeit, #planStatusAutor) und Termine und Aufgaben (renderPlanDashboard(): gegliedert Überfällig / Diese Woche / Später mit festen Anteilen PLAN_DASH_ANTEIL, Rest als „… weitere in Aufgaben“; ein Klick öffnet denselben Aufgaben-/Meilenstein-Dialog wie der Terminplan), rechts Leistungsphasen, Team (eine Zeile je PERSON, Rollen als Chips — vorher stand jemand mit drei Rollen dreimal da) und Unterlagen (die früheren Kurzkarten Dateien/Auslastung als eine Liste; ihre Inhalte füllen weiterhin dieselben Funktionen. Der Eintrag „Verlauf“ samt #planHistoryModal, renderPlanHistory() und openHistoryModal() ist entfallen — der Verlauf steht jetzt in der Kachel „Kommentar der Sitzung“). Stile: Block „Projekt-Dashboard Lagebild“ am Ende von plan-styles.css. The LPH progress list shows only the BEAUFTRAGTEN Leistungsphasen (p.beauftragteLP, set in "Einstellungen") — Erik reported it still listing phases that weren't marked as commissioned there. renderPlanInfo() previously mapped over the full LPHS constant unconditionally; it now filters to p.beauftragteLP when that list is non-empty, falling back to all of LPHS otherwise (older projects with nothing beauftragt) — the exact same nurBeauftragteLph rule BOARD_OPTS already applies to the Wochenbericht/Board card (js/board/board.js), just unconditional here rather than opt-in, since the Dashboard has no second, unfiltered rendering of this list to preserve. Die Kachel „Kommentar der Sitzung“ zeigt den ZULETZT GESCHRIEBENEN Kommentar (Eriks Wahl „A“): Hat die angezeigte Sitzung keinen, den jüngsten aus einer früheren, mit Hinweis „angezeigt wird der letzte aus KW …“ (#planStatusHinweis). Darunter eine Zeitleiste der früheren Kommentare (#planStatusVerlauf, planKommentarDaten()/ renderPlanKommentarVerlauf() in plan-info.js): je Gewerk nur, wenn sich der Text gegenüber der Sitzung davor geändert hat; gleicher Text in mehreren Gewerken einer Sitzung (Altbestand aus der Umstellung auf Kommentare je Gewerk) wird EIN Eintrag „HLS, ARCH“. Gezeigt wird, was geladen ist, darunter „Weitere laden“ (siehe „Sitzungen nach Bedarf laden“). Keine neue Datenhaltung — jede Sitzung hält ihre Kopie des Projekts. Stile: Block „Kommentar der Sitzung mit Verlauf“ am Ende von plan-styles.css. Eigene Kachel „Protokolle“ (Eriks Wunsch; links unter „Termine und Aufgaben“, #planDashProtokollePanel, vorher nur eine Zeile in „Unterlagen“): das letzte Protokoll (Nr., Betreff, Datum, Ort, Teilnehmer, offen/erledigt/Info), darunter seine OFFENEN Punkte (höchstens 5, nach Termin, überfällige rot — zweiter Abruf GET /project-protokolle/:id, Anzeige-Nummer über berechneProtokollNummern()), dann bis zu drei frühere Protokolle. Jeder Klick öffnet DIESES Protokoll (dashOeffneProtokoll()). Gerendert von renderProtokolleSummary() in plan-protokoll.js; ohne Projektnummer (Planung) ausgeblendet. Stile: Block „Dashboard-Kachel Protokolle“ am Ende von plan-styles.css. Kachel „Team“ nach Gewerk gruppiert (Eriks Wahl „A“, renderPlanDashboard() in plan-sections.js): erst die Gewerke des Projekts (p.gewerke, Reihenfolge DEPARTMENTS) — ein unbesetztes mit Hinweis „Noch niemand aus diesem Gewerk zugeordnet“ —, dann Gewerke von Beteiligten, die nicht zum Projekt gehören („kein Gewerk des Projekts“), zuletzt „Ohne Gewerk“. Das Gewerk einer Person kommt aus ihrem Konto (personGewerk(), users.department).
    • Terminplan aufgeräumt (Eriks Wahl „A“ aus drei Entwürfen): Eine Leiste statt zwei Zeilen mit neun Knöpfen — Maßstab „Übersicht | Wochen | Tage“ (tpMassstab(), feste px/Tag statt −/+), „Heute“ (tpHeute()), Zeitraumfeld (zeigt jetzt den tatsächlichen Zeitraum), Filter „Alle | Meine | Überfällig“ (tpFilter()), Menü „Ansicht“ (Urlaub, Vorwoche, „Abhängigkeiten als Linien“ #planShowPfeile, Sandbox-PDF; „Zeitraum anpassen“, „Nach Abhängigkeiten anordnen“ und „Kritischer Pfad“ hat Erik daraus gestrichen — der kritische Pfad ist samt Code entfernt (computeCriticalPath(), toggleCriticalPath(), .critical); fitPlanGanttRange() und autoScheduleTodos() bleiben, weil Vorlagen, Honorar-Plan, Protokoll-Aufgaben bzw. das Ziehen von Balken sie nutzen) und „+ Neu“ (Aufgabe, Meilenstein, Vorlage, Terminplan aus Honorar, Aufgaben aus Protokoll) als <details class="tp-menue">; alle Knopf-ids unverändert. Gliederung nach Leistungsphasen (plan-build-gantt.js): Phasenbalken (balkenPhase()) werden zuklappbare Gruppenköpfe (.gantt-phase, tpPhaseUmschalten(), app.phasenZu), jede andere Aufgabe steht unter der Phase, in deren Balken sie beginnt; außerhalb jedes Balkens unter „Weitere Aufgaben“. Nur die ANZEIGE wird umsortiert — plan.todos übernimmt die Reihenfolge erst beim Ziehen. Zugeklappte und weggefilterte Zeilen sind display:none, bleiben aber im DOM (der Zählcheck in reorderPlanRows()); Abhängigkeitslinien zu ihnen werden nicht gezeichnet. Namensspalte: Vorgänger nicht mehr als Schildchen, sondern im Tooltip (Warnzeichen „!“ bei Terminkonflikt), rechts die Kürzel der Zuständigen. Die Projektzeile heißt „Projekt gesamt“. Meilensteine ohne Aufgabenbezug (position leer) stehen nicht mehr in der Projektzeile, sondern je in einer eigenen Zeile direkt darunter, nach Datum (Eriks Meldung: ohne Aufgaben lagen alle übereinander); der Umschalter im Panel heißt „Oben, eigene Zeile | Bei einer Aufgabe“. reorderPlanRows() zählt dafür alle Meilensteine und lässt einen Meilenstein vor der ersten Aufgabe ohne Position. Unter dem Diagramm ein Hinweis auf offene Aufgaben ohne Termin (#planOhneTermin → Bereich „Aufgaben“). Phasenbalken farblich getrennt (Eriks Wunsch): Token --gantt-phase (Blauviolett, hell #6a5fc7, dunkel #a59ef2 — bläulicher als das Lila der Meilensteine) am Ende von plan-styles.css. Aufgaben bleiben im Akzentblau, erledigte grün, Meilensteine lila. Stile: Block „Terminplan aufgeräumt“ am Ende von plan-styles.css.
    • Terminplan — Spalte „Projekt“ in der Breite veränderbar wie in Excel (Eriks Wunsch): Griff am rechten Rand JEDER Namenszelle ziehen (ganttGriffeEinsetzen() setzt ihn nach jedem renderPlanGantt() in alle Zeilen, auch die aus plan-build-gantt.js; click/dblclick werden dort abgefangen), Doppelklick passt an den längsten Eintrag an (ganttSpalteZiehen()/ganttSpalteAnpassen() in js/gantt.js). Die Abhängigkeitspfeile werden beim Ziehen je Bild neu gezeichnet (ganttPfeileNachziehen() → renderGanttDependencies()), sonst blieben sie an der alten Stelle stehen. Die Breite steht in der CSS-Variable --gantt-label-w (alle Zeilen gehen ohne Neuaufbau mit) und je Browser in localStorage['projuebersicht-gantt-spalte'] (120–640px, Standard 220px); Zoom und Mindestbreite rechnen mit ganttSpaltenBreite() statt der früheren festen 220/250px. The Gantt with its toolbar and, below it, the resource load (#resourceLoadContainer, formerly a modal). Uses the exact same Wochenstunden calculation as the Auslastung tab/Wandtafel (computeResourceLoads(), js/plan/plan-gantt.js, calling berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor() from js/load/load.js) — Erik reported the two disagreeing ("Die Auslastung der Projektbeteiligten im Terminplan ist eine andere als die in der sonstigen Anwendung"). It previously counted something unrelated: workday spans derived from each person's assigned TODO date ranges in THIS Gantt, relative to a locally-scoped maximum among this project's own team — a scheduling-density number with no connection to honorar, Leistungsphase, or capacity, so the same person could show a completely different figure here than on the Auslastung tab. Now, for every project of the session (this one and every other), it runs the identical per-role berechneAuslastung() call renderLoad() makes and sums it per person — the headline number and Ampel color shown here are therefore now literally the same as that person's total on the Auslastung tab, not just similarly computed. The split into "aus diesem Projekt" vs. "aus den übrigen Projekten der Sitzung" (stundenProjekt/stundenAndere, surfaced as a plan-resource-other badge) is kept from the old days-based version — without it, someone could look idle in this one project while fully booked elsewhere — just re-expressed in hours instead of workdays. The Team-Auslastung KPI tile (renderPlanKpis()) and the compact heat-row preview (#planResourceSummary) consume the same computeResourceLoads() output and were updated the same way (percentage now relative to capacity, not to the locally-scoped maximum). A fixed side effect: the old bar/number colors were hardcoded hex (#c53030/#d69e2e/#2f855a), invisible to the dark-mode token system entirely — loadAmpelColor() returns var(--red) etc., so this view now also follows the app-wide dark-mode palette like everywhere else.
    • Aufwand im Aufgaben-Panel (Eriks Wunsch „schöner darstellen“): Kapsel mit −/+ (±1 h) und Einheit „Std.“, Schnellwahl 2 h / ½ Tag / 1 Tag / 2 Tage / 1 Woche (Tage über APP_SETTINGS.arbeitsstundenProTag; erneuter Klick leert), darunter die Umrechnung in Arbeitstage (todoAufwandAendern()/todoAufwandSetzen()/todoAufwandInfo(), plan-modals.js). Feld-id todoEditAufwand und Speichern unverändert. Das Eingabefeld ist per !important durchsichtig, weil die Glas-Regeln für Eingabefelder ihm sonst eine eigene Fläche geben.
    • „Externe Personen“ im Aufgaben-Panel (Eriks Wunsch): unter „Wer“ stehen oben die Projektbeteiligten als Chips, darunter der Abschnitt „Externe Personen“ — Kollegen mit Konto, die dem Projekt NICHT zugeordnet sind, über eine Auswahl nach Gewerk (todoExterneSetzen(), todoExternHinzufuegen()/todoExternEntfernen() in plan-modals.js; Quelle: Teilnehmerkreis der Sitzung ohne die Projektbeteiligten). Sie landen ganz normal in t.assignees — damit zählen sie zu ihrer Auslastung, erscheinen unter „Meine Aufgaben“, bekommen Glocke und Teams-Nachricht. Wer einer Aufgabe zugewiesen ist, aber (nicht mehr) im Projekt, erscheint beim Öffnen automatisch dort. Keine Daten­änderung am Projekt: die Person wird NICHT unter „Beteiligte“ eingetragen.
    • Panels als „Planblatt“ (Eriks Wahl, unslop, 11/2026; löst den Aufbau mit .pd-abs-Abschnitten im folgenden Absatz ab): Kopf mit Art, Projekt (#todoModalWo/#milestoneModalWo, panelWoSetzen()), Titel ohne Kasten und dem Stand direkt darunter (Aufgabe: Status-Segment + Fortschritt; Meilenstein: „Offen | Erreicht“ über msStandSetzen(), das versteckte Feld #milestoneEditErreicht bleibt die Quelle für saveMilestoneEdit(), dazu „seit N Tagen überfällig“). Darunter die Lage im Terminplan (lageBildHtml(), todoLageNeu(), msLageBildNeu() in plan-modals.js): sechs Kalenderwochen ab einer Woche vor dem Eintrag (früher, wenn ein Vorgänger davor endet), oben Vorgänger, Mitte der Eintrag mit Fortschritt, unten Nachfolger, Heute-Linie; nur Anzeige, neu gezeichnet bei Zeitraum, Stand und Vorgängern. Dann die Angaben als Zeilen (.pd-zeile, Beschriftung links): Zeitraum, Zuständig, Aufwand (#todoEditAufwandAbs, im Phasen-Modus mit ausgeblendet), Hängt ab von (gewählte Vorgänger offen in #todoEditDependsSummary, Auswahl eingeklappt unter „Vorgänger ändern“), Notiz. Alle Feld-ids und Schreibwege unverändert. Stile: Block „Planblatt“ am Ende von plan-styles.css.
    • Aufgabe und Meilenstein als Seitenpanel (Eriks Wahl „B“): #todoModal und #milestoneModal sind .modal-bg.als-panel — rechts am Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der Terminplan sichtbar bleibt. Beide gleich gebaut (.pd): Kopf mit Art, Esc-Hinweis, × und Titelfeld; Abschnitte „Wann“, „Wer“/„Wo im Terminplan“, „Stand“, „Hängt ab von“; feste Fußleiste mit „Löschen“ links (nur beim Bearbeiten — beim Meilenstein stand es vorher auch beim Anlegen). Die Meilenstein-Position ist ein Umschalter „In der Projektzeile | Bei einer Aufgabe“ (msLageSetzen()/msLageSync(), plan-modals.js) über dem bisherigen Feld #milestoneEditPosition, das allein gespeichert wird. Alle Feld-ids und Schreibwege unverändert. Stile: Block „Seitenpanel“ am Ende von plan-styles.css.
    • Notizen (Reiter zwischen Terminplan und Aufgaben, Eriks Wahl „A – Liste + Editor“ aus drei Entwürfen, js/plan/plan-notizen.js): gemeinsame Notizen je Projekt. Links Liste (angeheftete oben, sonst zuletzt geändert zuerst, Suche über Titel und Text), rechts Editor mit Titel, Werkzeugleiste des Sitzungskommentars (richtextToolbarHtml()) und Text; automatisches Speichern 1,2 s nach der letzten Änderung und beim Verlassen des Feldes (MutationObserver, weil die Werkzeugleiste kein input-Ereignis auslöst), Anzeige „Gespeichert ✓“. Anheften, Löschen zweistufig im Knopf. Mail (Text oder .eml) per Drag & Drop auf den Bereich oder Strg+V außerhalb der Felder → neue Notiz OHNE KI (Titel = Betreff, Von/Datum oben, Zitate abgeschnitten). Server: Tabelle projekt_notizen (an project_uid, nicht an der Sitzung; in IMPORT_TABLES + IMPORT_OPTIONAL_TABLES), Routen GET /projekt-notizen?uid=, POST /projekt-notizen, PUT /projekt-notizen/:id (mit basis → 409 bei fremder Änderung; Client bietet „Meine Fassung speichern“/„Neuen Stand laden“), DELETE (nur Verfasser oder Administrator), POST /projekt-notizen/aus-mail. Lesen: Grundzugang; Schreiben: proj.plan. Live-Ereignis {typ:'notizen', uid} → notizenLiveAenderung(). Stile: Block „Notizen“ am Ende von plan-styles.css. Gefunden dabei: leseEml() zerstörte Umlaute in .eml-Dateien ohne Transfer-Kodierung (7bit/8bit, der Normalfall) — der Text kam schon als UTF-8-Zeichenkette vom Browser und wurde fälschlich als latin1-Bytes neu dekodiert. Betraf auch „Termin aus Mail“; behoben.
    • Reiter „Leistungsphasen“ entfernt (Eriks Wunsch, 30.10.2026): Nav-Knopf, Abschnitt #planSection-phasen, renderPlanPhasen() und der CSS-Block sind weg; ein alter Link auf #phasen landet auf dem Dashboard. js/plan/plan-phasen.js enthält nur noch aufgabenPhase() (genutzt von der Aufgabenliste und dem Terminplan-PDF). „+ Leistungsphase“ und „Terminplan aus Honorar“ bleiben im Terminplan unter „+ Neu“. Der folgende Absatz beschreibt den entfernten Stand.
    • Leistungsphasen (eigener Reiter zwischen Terminplan und Aufgaben, Eriks Wahl „B“, js/plan/plan-phasen.js): oben ein Ablaufbalken aller Phasen mit Zeitraum (Heute-Linie, Klick hebt die Kachel hervor), darunter eine Kachel je Phase — beauftragte plus alle mit Phasenbalken (balkenPhase()), sortiert läuft → geplant → fertig. Fortschritt aus dem Wochenbericht (fortschrittProzent()), „läuft“ = arbeitsPhase(), Zeitraum aus dem Balken, Aufwand = Summe der Aufgabenstunden der Phase, Aufgaben = die, die im Balken beginnen (aufgabenPhase(), dieselbe Regel wie die Gliederung im Terminplan). „Bearbeiten“ öffnet den Balken im Phasen-Panel, ohne Balken „Zeitraum festlegen“ (addPlanPhase(lph) wählt die Phase vor); „Aufgaben ansehen“ springt gefiltert in die Aufgaben. „Terminplan aus Honorar“ sitzt jetzt hier statt in der Aufgaben-Leiste. Stile: Block „Bereich Leistungsphasen“ am Ende von plan-styles.css.
    • Aufgaben ohne Phasenbalken: renderPlanTasks() lässt Aufgaben mit t.lph weg (Indizes bleiben die aus plan.todos); jede Aufgabe trägt ihr Phasen-Kennzeichen (.plan-task-phase), darüber ein Filter Alle / je Phase / ohne Phase (setzeAufgabenFilter(), gilt je Projekt).
    • Aufgaben — the same todos/milestones as a list (renderPlanTasks(), js/plan/plan-tasks.js). No own data and no own dialogs: a row opens the very same openTodoModal()/openMilestoneModal() as the Gantt, and the indices match because the list keeps plan.todos/plan.milestones order. The create buttons (+ Aufgabe, + Meilenstein, + Vorlage, + Terminplan, + TODOs aus Protokoll) exist twice — in the Gantt toolbar and above the list, the latter with an …BtnList id suffix; renderPlanPage() hides both sets on locked weeks. "+ TODOs aus Protokoll" (#protokollTodosBtn/#protokollTodosBtnList, js/plan/plan-protokoll-todos.js) is a second, genuinely independent way to turn a meeting protocol into tasks — Erik's explicit request: "ein Feature... das die Protokolle per KI einliest und dann aus den Protokollpunkten Aufgaben für die Aufgabenseite im Projekt erzeugt". It is deliberately unrelated to project_protokolle/protokoll_punkte and to the Protokolle section (js/plan/plan-protokoll.js) — Erik's own words: "Es sollen externe Protokolle im PDF-Format sein, die durch die KI eingelesen werden", i.e. an ad-hoc upload of a protocol that may never become (or already exist as) a stored Protokoll of this project, not a second entry point into the same table. Nothing is persisted on either end: no project_protokolle row, no file under sessions/uploads/ — the PDF and every intermediate result live only in the request/response and the open dialog, the same "ad-hoc tool, no archive" model as the Plan Checker (### Protokolle below covers the always-persisted Protokoll-Import; this is its lightweight opposite). Three steps in one dialog (#protokollTodosModal), picked by Erik from a three-way design comparison (Taste-Skill artifact, "A — Schritt-Dialog" vs. a wide split-panel "Werkbank" vs. an inline card in the task list): a persistent chrome — title, a 3-segment progress bar (#protokollTodosSteps), and a fixed Zurück/Abbrechen/Weiter footer — stays on screen across all three steps, with only #protokollTodosBody's content swapped per step (same "Container-Div wird je Phase neu befüllt" idea applyTemplate()'s dialog already uses). The right-hand "Weiter" button both advances the step AND fires that step's network call in one click (protokollTodosWeiter()); "Zurück" (protokollTodosZurueck()) only ever re-renders already-held module state, no re-fetch — except a re-shown file <input> can't be re-populated from script, so going back past step 0 means re-picking the file. A genuine KI failure (zeigeFehler(), red) is kept visually distinct from a successful-but-degraded fallback (zeigeFallbackHinweis(), the same amber .protokoll-ki-fallback-banner the Protokoll-Import uses) — a fallback is an accepted outcome, not an error. (1) upload a PDF + pick a provider → POST /plan-todos-aus-protokoll/einlesen reuses erkennePunkteMitKI()/ erkennePunkteAusText() unchanged (server.js) to extract points, returned straight to the client, nowhere written to a table; (2) the extracted points appear as a checklist (every status:'offen' point pre-checked, erledigt/info pre-unchecked but still selectable — one surface covers both "whole protocol" and "just this one point"); the checked point objects (already in the browser, no server round-trip to refetch) go to POST /plan-todos-aus-protokoll/aufgaben-vorschlag, which runs the second, independent KI step, leiteAufgabenMitKI() → leiteAufgabenMitChatGPT/leiteAufgabenMitClaude (server.js, same fetchMitTimeout()/LOKAL_DISPATCHER/reasoning_effort plumbing as erkennePunkteMitChatGPT/Claude, own KI_AUFGABEN_SCHEMA/ KI_AUFGABEN_SYSTEM_PROMPT) — this step judges the points rather than copying them: several points about the same action can collapse into one task, a purely informational point yields none; on any KI failure (or no provider chosen) the route falls back to a direct 1:1 mapping (erkennungsart:'direkt'), the same "always succeed, possibly degrade" policy as the Protokoll-Import; (3) the proposed tasks appear as a compact row list (renderVorschlaegeHtml()) — Erik's pick from a second, narrower Taste-Skill comparison scoped to just this step: a real test run had already produced 19 proposals from one protocol, and a full card per row would have been nothing but scrolling. Each row shows title plus a Fälligkeit/Zuständig badge; the full description, date field, and Zuständigen-Auswahl only appear once the row is tapped (.protokoll-todo-vorschlag.open, .pt-row-detail). A selection bar above the list (N von M ausgewählt, "Alle"/"Keine" — protokollTodosAuswahlAlle()) avoids unchecking rows one by one at that volume. Zuständig is pre-matched against projectAssignees(app.project, app.week) by a simple case-insensitive name match, matchePersonenVonName() — an external protocol's "Zuständig" is often an outside firm with no match at all, which is the expected common case, not a bug. Confirming pushes one plan_todo per checked row straight into app.ensurePlanLocal().todos with both start and end always set (today / the given Fälligkeit or today) — exactly applyTemplate()'s/saveTodoAusKommentar()'s push shape — specifically so every created task is a Gantt row immediately, not a dateless checklist item (see the GANTT-row rule right above); app.savePlan() then fitPlanGanttRange() (again mirroring applyTemplate()) so the visible GANTT window actually includes the new bars instead of only showing them in the plain Aufgaben list. A todo is only a Gantt row when it has BOTH a start and an end date AND showInGantt is not explicitly false. plan.todos is the one shared list either way — every todo always shows up here in Aufgaben, with its assignees (t.assignees, "Zuständige" chips in openTodoModal(), unrelated to whether it has a schedule) — but plan-build-gantt.js (the buildGanttHtml() extension that inserts TODO/milestone rows under the project row) only inserts a row for a todo with todo.start && todo.end && todo.showInGantt !== false; a title-only todo (created via + Aufgabe or + TODO with the date range left empty — nothing in saveTodoEdit() requires dates, only the title) stays a pure checklist item that appears solely here, never in the Gantt/Terminplan. The Aufgaben row for such a todo shows "ohne Termin" in place of a date range (.plan-task-no-schedule). Giving it a start/end later (editing the same todo through either view) makes a Gantt row for it appear on the next render — same object, no separate "convert to scheduled" action needed. showInGantt (plan_todos.show_in_gantt, DEFAULT 1, migrateAddPlanTodoShowInGantt()) is the independent half of that condition — Erik's explicit request, after "TODOs aus Protokoll" (plan-protokoll-todos.js) showed that a single import can easily create 15-25 dated tasks at once, which — before this field existed — were all unconditionally forced into the Gantt as bars. A checkbox "Im Terminplan (GANTT) anzeigen" in the TODO-edit dialog (#todoEditShowInGantt, App.html, read/written in openTodoModal()/saveTodoEdit(), js/plan/plan-modals.js) lets a dated task still exist (tracked under Aufgaben, counted on "Meine Aufgaben", included in dependency-based Auto-Schedule/critical-path) without drawing a bar. Checked by default; !== false rather than === true everywhere it's read, so a todo from before this column existed (or any object missing the field) keeps showing exactly as before — no retroactive behavior change. Carried through "Speichern & Neu" (openTodoModal(null, {..., showInGantt})) like the other frame fields (Zeitraum/Zuständige), so adding several hidden-from-Gantt tasks in a row doesn't mean re-unchecking it every time. Not touched by plan-schedule.js's Auto-Schedule/critical-path (t.start && t.end, unchanged) or plan-gantt.js's ghost-date overlay — a hidden todo is still a real, scheduled task for every purpose except drawing its own bar. reorderPlanRows() (js/plan/plan-drag.js) accounts for this: its DOM-row-count safety check and the reordering itself only ever touch todos that have start && end && showInGantt !== false (scheduledIds) — exactly the same condition as the insertion above, so the count of draggable DOM rows and scheduledIds.size never drift apart. A title-only or Gantt-hidden todo has no drag handle in the Gantt to begin with and keeps its exact position in plan.todos untouched by any Gantt row drag.
    • Leistung — Leistungsstand & Abrechnung (renderPlanLeistung(), js/plan/plan-leistung.js): the value actually earned, per beauftragte Leistungsphase, as Honorar × HOAI-Anteil der Phase × Fortschritt der Phase. The shares come from GET /honorar-config and are normalised across the beauftragte phases only (same as berechneHonorarPlan()), so a project commissioned for three phases still adds up to 100 %. Nothing is entered here: honorar and the beauftragte phases come from "Einstellungen", the progress from the Wochenbericht — this section only computes. Three cases deliberately show a hint instead of a number: no honorar, no beauftragte phase, no shares stored. This was the missing link — the app knew the honorar and it knew the progress per phase, but never put the two together, so nobody could see what a project had earned. It is its own section rather than a Dashboard card because the breakdown has one row per phase with five columns; as a card it would either be cramped or appear twice.
    • Dateien — upload/review/comments (js/plan/plan-files.js), formerly a modal.
    • Protokolle — Besprechungsprotokolle (js/plan/plan-protokoll.js), see "### Protokolle" below for the data model and the "Fortschreiben" mechanism.
    • Einstellungen — Aufbau mit Unternavigation (Eriks Wahl „C“): links vier Abschnitte (.plan-einst-nav) mit Kurzfassung darunter (renderEinstUebersicht(): „21-4001 · ELT · LPH 5“, „Beauftragt · 230.000 €“, „4 von 10 beauftragt · läuft: LPH 5“, „4 Personen · HLS unbesetzt“), rechts immer genau einer (zeigeEinstTeil(), plan-settings.js): Allgemein (Nummer, Name, Gewerke als Umschalt-Knöpfe, Gruppe, amtierende Leistungsphase — Eriks Wunsch, hierhin statt unter „Leistungsphasen“ —, Schlagwörter), Auftrag & Honorar (Status, Honorar, Projektgröße, Aufwand), Leistungsphasen (kräftig hervorgehoben: beauftragte Phase = getönte Zeile + gefüllte Akzentkapsel „Beauftragt“, nicht beauftragte = blasse Zeile + neutrale Kapsel, „läuft“ gefüllt; je Phase eine Zeile „Beauftragt | Nicht beauftragt“ mit HOAI-Bezeichnung und „läuft“), Beteiligte — als Personenliste (Eriks Wahl „B“ aus drei Entwürfen, dann ausgearbeitet; renderEinstTeam()): oben eine Besetzungsleiste je Rolle (wer die Hauptverantwortung trägt; fehlt sie, orange gestrichelt), ein Suchfeld „Person hinzufügen“ (Vorschläge erst aus den Gewerken des Projekts, dann andere; Enter nimmt den ersten; hinzugefügt wird mit der ersten Fachrolle als Haupt, die Zeile kurz hervorgehoben), darunter eine Zeile je beteiligter Person, nach Gewerk gruppiert, mit drei Rollen-Kapseln (umrandet = aus, voll = Haupt, gestrichelt = Unterstützung; Antippen schaltet über planCycleAssign() weiter; Rollen, die das Konto nicht hat, durchgestrichen und gesperrt) und „ד (einstEntfernen(), 8 s „Rückgängig“ über einstRueckgaengig()). Keine Auslastung in diesem Bereich (Eriks ausdrücklicher Wunsch). Angeboten wird jede Person der Sitzung mit Fachrolle; wer zugeordnet ist, ohne die Rolle noch zu haben, bleibt sichtbar, damit er sich entfernen lässt. Gesperrt: Hinweis, kein Suchfeld, kein „ד, Kapseln zeigen den Stand. Alle Feld-ids und Schreibwege sind unverändert; die Sandbox blendet weiter über .form-row aus. setPlanActiveLp() rendert die Einstellungen mit nach, damit „läuft“ und die Kurzfassung stimmen. Stile: Block „Einstellungen mit Unternavigation“ am Ende von plan-styles.css.
    • Einstellungen — project number/name (display only — the page is addressed by them), Gruppe, amtierende Leistungsphase (both still rendered by plan-info.js), plus Honorar, the beauftragte Leistungsphasen and the Beteiligten-Matrix (renderPlanSettings()/planCycleAssign(), js/plan/plan-settings.js — the Matrix moved here from a read-only plan-info.js display once it became genuinely editable, see "Ein Ort je Angabe" above).
  • p.honorar/p.beauftragteLP can now be edited in TWO places: here and in the "Terminplan aus Honorar" dialog (which keeps them so it stays usable on its own). Both write through setHonorarBetrag()/toggleBeauftragteLp() in js/plan/plan-honorar.js and re-render the other mask afterwards, so they cannot drift apart. Persistence is the ordinary savePlan() → POST /save-week path (columns projects.honorar / table project_beauftragte_lph) — no dedicated endpoint.
  • renderPlanGantt() is the central refresh hook: it ends by calling renderPlanTasks() and renderPlanDashboard(), so every existing mutation path (drag, modals, auto-schedule, templates, honorar plan) updates all three views without touching those modules.
  • The former modals (#planInfoModal, #planResourceModal, #planFilesModal, and since the comment history moved into the Dashboard tile also #planHistoryModal) are gone. openResourceModal()/openFilesPanel() kept their names but now just switch sections. On a load error (missing params, unknown week/project, network) app.hidePlanNav() hides the section bar, since every section would be empty. Because the area now shows one project after another within the same document, app.showPlanNav() exists as its counterpart (a fresh page load used to reset it for free).
  • Entry point (two steps, deliberately split). setPlanTarget(weekLabel, projNr, projName) only stores the target; openPlanView() resolves it against the already-loaded db.weeks and renders. showTab('plan') does the second step itself, AFTER the area is visible — the Gantt measures its container width, which is 0 in a hidden element. openProjectPlan() and the deep-link path in initUiState() therefore only call setPlanTarget(). Addressing is by week label + project nr/name (never project.id, which cloneWeek() re-assigns). On the very first deep-link render db.weeks is still empty; that is not an error but a "wird geladen…" state, and load() calls showTab(currentTab) again once the data is there.
  • There is no separate data load and no separate save path any more. The old page fetched /load-all itself and wrote with its own sendWeekToServer(), because store.js wasn't loaded there. In one document that would be two writers on the same week sending the same revision base — the slower one getting a bogus 409. savePlan() is now just markWeekDirty(app.week.label) + scheduleSessionSync(), i.e. the ordinary debounced path with its in-flight guard, retry banner, conflict handling and 401 login overlay. It passes the label explicitly rather than calling save(), since save() marks the currently selected week and a deep link may have pointed the Projektbereich at a different one.
  • updateRouteUrl('plan') (js/ui.js) is the one tab whose URL is not a fixed slug: it builds /Projektplan/<Name>?weekLabel=…&projNr=… from window.PlanApp and keeps window.location.hash, so a link to a specific section survives the switch. applyUrlRoute() parses the same shape back into {tab:'plan', weekLabel, projNr, projName}.
  • The hashchange listener in plan-sections.js and the resize listener in plan-gantt.js both bail out unless currentTab === 'plan' — they now run while completely different tabs are on screen.

Notizen in der Seitenleiste (Tab notizen, js/notizen/notizen.js)

Eriks Wunsch: ein eigener Bereich „Notizen“ (Seitenleiste, eigene Gruppe „Notizen“ unter „Team“, /Notizen), in dem jedes Konto die Notizen ALLER seiner Projekte sieht und projektunabhängige Notizen anlegt, die sich später einem Projekt zuordnen lassen. Gleicher Aufbau wie der Projekt-Reiter (Liste + Editor, Werkzeugleiste, automatisches Speichern, Anheften, Löschen zweistufig), dazu oben ein Projektfilter (Alle / Ohne Projekt / je Projekt mit Anzahl) und im Editor die Auswahl „Projekt“ (Ohne Projekt, „Meine Projekte“, „Weitere Projekte“ der aktuellen Sitzung) plus „Im Projekt öffnen“. „+ Neu“ legt im gefilterten Projekt an, sonst ohne Projekt. Eigene Element-ids (mn…) und Funktionen (mnNeu(), mnZuordnen() …), weil der Projekt-Reiter gleichzeitig im DOM liegt; Stile aus .notizen-* (plan-styles.css) plus Block „NOTIZEN (Seitenleiste)“ am Ende von styles.css.

  • Server: Eine projektunabhängige Notiz ist eine Zeile in projekt_notizen mit project_uid = ''; sie sieht und ändert NUR ihr Verfasser (notizDarf()). GET /meine-notizen liefert die Notizen der eigenen Projekte (meineProjektUids(): jüngste echte Sitzung, zugeordnet oder mit eigener Aufgabe — Regel wie dashDaten()) plus die eigenen projektunabhängigen, dazu meineProjekte. PUT /projekt-notizen/:id nimmt uid zum Zuordnen (Ziel-Projekt braucht proj.plan, Lösen nur der Verfasser). POST/PUT/DELETE/aus-mail prüfen jetzt selbst (notizDarf()) statt pauschal requireRecht('proj.plan'): ohne Projekt genügt die Anmeldung. GET /projekt-notizen/anzahl zählt nur Projekt-Notizen.
  • Live: {typ:'notizen'} geht in store.js an meineNotizenLive() und notizenLiveAenderung(); das Echo der eigenen Speicherung (kommt auch im selben Tab an) wird 3 s lang ausgelassen, sonst verschwände die Anzeige „Gespeichert ✓“.

Seitenleiste — Gliederung nach Arbeitsbereich (11/2026)

Eriks Wahl „A“ (unslop + Taste-Skill): Die Einträge stehen in Gruppen (.sidebar-gruppe in App.html): Dashboard · Projekte (Projekte, darunter eingezogen das geöffnete Projekt #planNavItem mit Projektnummer in Mono, Wochenbericht, Terminplan) · Team (Auslastung, Abwesenheiten) · Notizen (eigene Gruppe, Eriks Wunsch) · Studenten (Plan Checker, ganze Gruppe nur-recht wz.checker) · unten .sidebar-unten mit Einstellungen und Admin. Die Überschriften „Navigation“ und „Planung“ sind entfallen; der Menüpunkt heißt weiter „Dashboard“. Admin-Unterpunkte in drei Zwischengruppen (.sidebar-subgruppe: Personen, Vorgaben, System); adminNavAbgleichen() blendet eine Zwischengruppe aus, wenn keiner ihrer Punkte erlaubt ist. Maße: Einträge 32 px, Icon 18 px + 8 px, Rubrik 16 px über der Gruppe und 4 px über ihren Einträgen. Stile: Block „SEITENLEISTE“ am Ende von styles.css.

Falle in CSS-Kommentaren: */ beendet einen Kommentar auch mitten im Text (z. B. „--dash-/--wt-“). Der Rest wird dann als Regel gelesen und verschluckt die nächste echte Regel. So fehlten zeitweise --font-mono (alle Mono-Ziffern) und --accent im automatischen Dunkelmodus.

Termin aus Mail (js/plan/plan-mail-eintrag.js)

Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze .eml-Datei) oder Strg+V (außerhalb von Eingabefeldern) auf die Bereiche Terminplan oder Aufgaben eines Projekts; eine KI macht daraus Einträge — Aufgabe oder Meilenstein: Die KI schlägt die Art vor (art in KI_MAIL_SCHEMA), in der Auswahlliste #mailAuswahlModal (immer, auch bei nur einem Vorschlag) steht je Zeile der Umschalter „Aufgabe | Meilenstein“ (Eriks Wunsch). Danach öffnet sich je Eintrag das passende Panel.

  • Beim Hineinziehen erscheint die Fläche #mailAblage (fest, pointer-events: none, Zähler für dragenter/-leave). Nicht in gesperrten Sitzungen. Kein Konflikt mit dem Balken-Ziehen (das läuft über Maus-Ereignisse).
  • POST /plan-eintrag-aus-mail {anbieter, text|eml, kontext} (proj.plan): .eml wird am Server zerlegt (leseEml(): Kopfzeilen, RFC 2047, multipart, base64/quoted-printable, Zeichensatz), zitierte Vorgänger-Mails abgeschnitten (mailZitateAbschneiden()), dann mailEintraegeMitKI() mit KI_MAIL_SCHEMA (Titel, Start/Ende ISO, Zuständige aus der Liste der Beteiligten, Aufwand, Zitat). Datum der Mail und heute gehen mit, damit „nächsten Freitag“ stimmt. Ohne KI/bei Fehler: ein Eintrag mit dem Betreff als Titel. Speichert nichts. .msg (Outlook) wird abgewiesen mit dem Hinweis, Text zu markieren.
  • Nur mit eingerichteter KI aktiv (Eriks Wunsch): mailKiStatusLaden() holt nach der Anmeldung (afterAuthenticated()) und nach jeder Änderung der KI-Zugangsdaten (putMyAiKey()) die Anbieter mit Schlüssel; ohne einen gibt es weder Ablagefläche noch abgefangenes Strg+V.
  • Anbieter: zuletzt erfolgreich genutzter (localStorage projuebersicht-mail-ki), sonst der erste mit Schlüssel.
  • Umschalter „Aufgabe | Meilenstein“: eigene Stile .segmented.mail-auswahl-art und Klasse mail-art-knopf (sonst greift unter Glas html.glas button:not([class]) mit Rand und Glanzkante, und der gewählte Knopf war von var(--surface) kaum zu unterscheiden).
  • Jeder gewählte Vorschlag öffnet nacheinander das normale Panel vorausgefüllt: openTodoModal(null, prefill) (title, start/end, assignees, aufwand, notiz, mailHinweis, nachSchliessen) bzw. openMilestoneModal(null, prefill) (name, date = Ende des Vorschlags, notiz, mailHinweis, nachSchliessen). Ohne Speichern entsteht nichts.
  • Notiz je Aufgabe und Meilenstein (t.notiz/m.notiz, plan_todos.notiz/plan_milestones.notiz, Migration migrateAddPlanTodoNotiz(), in IMPORT_OPTIONAL_COLS): Feld „Notiz“ in beiden Panels; aus einer Mail mit Absender, Datum, Betreff, Bezugssatz und Mailtext. In der Aufgabenliste ein Symbol mit Tooltip. Stile: Block „Termin aus Mail“ am Ende von plan-styles.css.

File uploads (project_files / review / comments)

  • Files are linked to a project via (department, project_nr), NOT (department, week_label, project_id) — project_id (p.id) is regenerated on every cloneWeek() (public/js/weeks.js), while p.nr stays stable across the project's whole lifecycle. A project without a nr cannot receive files: the upload button is disabled client-side (openFilesModal() in js/plan/plan-files.js) and the server rejects with 400 regardless.
  • Storage: sessions/uploads/<department>/<uuid>.<ext> (uploadDirFor()/extractFileExtension() in server.js). The on-disk filename is always a fresh crypto.randomUUID() plus an extension taken from a whitelist regex (/\.([a-zA-Z0-9]{1,8})$/) applied to the client-supplied filename — no client-controlled string (original_filename, project_nr, project_name, filename query param) is ever interpolated into a filesystem path. original_filename is stored purely for display and is only ever used as the second argument to res.download() (the Content-Disposition filename), never as a path segment.
  • Multi-reviewer workflow: an upload lists one or more reviewer user IDs (query param reviewers=id1,id2, deduplicated, filtered to active users, uploader removed — no self-review, at least one must remain or the upload is rejected with 400). Each reviewer gets a project_file_reviewers row with decision='pending'. POST /project-files/:id/review recomputes the file's overall status after every decision: any single rejected decision blocks the file immediately (terminal); only once all reviewers have approved does the file become approved (also terminal). Once terminal, no further review decisions are accepted (409).
  • Deletion (DELETE /project-files/:id) is allowed for the uploader while status='pending', or for an admin at any time; it deletes the project_file_comments and project_file_reviewers rows explicitly (no FK cascade — see Persistence section) before the project_files row, then best-effort removes the physical file.
  • import never touches these tables (see /import note above) — a DB restore neither includes nor overwrites uploaded files or their review/comment state.

Protokolle (project_protokolle / protokoll_teilnehmer / protokoll_punkte)

Besprechungsprotokolle je Projekt (classic Baubesprechungsprotokoll: Bauherr, ausführende Firmen and the office's own team meet, discuss numbered points, and unresolved points carry over meeting to meeting) — the seventh section of the Projektbereich (js/plan/plan-protokoll.js), a persisted document type that did not exist before this feature. The "Protokoll" inside the Wochenbericht (openBerichtProtokoll()) is unrelated: that one is a throwaway, client-only text export of what changed in a session, never stored and not tied to a single project — this is a genuinely different, stored, per-project document.

Scoped by project_nr, exactly like project_files, NOT by week_label/project_id. project_id is regenerated on every automatic session rollover (ensureCurrentWeekRollover(), see above), but a Protokoll must survive across sessions. week_label is still stored on project_protokolle — purely informational (which session was current when the Protokoll was created), never used to look one up. A project without a nr (a Planung-Sandbox plan) can't have Protokolle — the section is hidden there, same reasoning and same mechanism as Dateien (see "Planung — projektunabhängige Terminplan-Sandbox" above).

Save pattern: one PUT replaces the whole editable content, not a REST route per field. PUT /project-protokolle/:id deletes and re-inserts every protokoll_teilnehmer/protokoll_punkte row of that Protokoll in one transaction (same "delete then re-insert" shape as deleteWeekData()/serializeWeek() for a whole week) — closer in spirit to how plan.todos is saved as one object via savePlan() than to project_files' many small endpoints, because Teilnehmer and Punkte are authored together in one sitting. The detail view in js/plan/plan-protokoll.js is accordingly inline in the Protokolle section, not a modal — too much content for a dialog — with an explicit "Speichern" button (no autosave) and simple text/checkbox fields mutating an in-memory copy directly (no re-render per keystroke, to avoid losing input focus); only structural changes (add/remove a row, change a point's group) re-render.

"Fortschreiben" — the core mechanism, entirely server-side (POST /project-protokolle, server.js): creating a new Protokoll for a project copies every erledigt=0 point of that project's most recently created Protokoll into the new one — fresh id, same stamm_id and nummer, neu=0. stamm_id is what makes a point "the same point" across Protokolle; it is never exposed to or trusted from the client. A point newly added within a Protokoll (no existing id in the client's PUT payload) gets a fresh stamm_id and neu=1 — which drives the yellow highlight (.protokoll-punkt.is-neu, own CSS tokens --protokoll-neu-bg/ --protokoll-neu-border in styles.css, deliberately NOT --band-neu, which already means something else — "neu kommentiertes Projekt", blue — in the Wochenbericht). Marking a point erledigt in one Protokoll does not retroactively change it in earlier, already-created Protokolle (each Protokoll's own copy is an immutable snapshot, like a distributed paper protocol); it simply isn't copied into the next one. The very first Protokoll of a project has nothing to copy from, so every one of its points is neu=1 by definition.

nummer is computed, never edited — entirely decoupled from the Fortschreiben/neu logic. First built as a free-text field the user typed by hand; changed on Erik's follow-up request to be fully automatic — berechneProtokollNummern(punkte) (js/plan/plan-protokoll.js, exposed on window so exportProtokollPDF() in js/pdf.js can call the identical function — same cross-file pattern as classifyProjectChange() in wochenbericht.js, which pdf.js also calls directly) derives every point's displayed number purely from its current gruppe and position: a group gets a top-level number in the order it first appears among the points, and each point inside it gets "<Gruppennummer>.<Position>" (e.g. "1.1", "1.2", "1.3" for the first group's three points, "2.1" for the second group's first); a point with no group is just a plain top-level number on its own ("1", "2", "3" …). Groups and ungrouped points share ONE counter for that top-level number, not two separate ones — on Erik's explicit follow-up ("keine Nummer doppelt… wenn die 1 schon in Ohne Gruppe gibt darf sie nicht nochmal in ELT oder HLS auftauchen"): whichever comes first in the points' order — an ungrouped point, or the first point of a not-yet-seen group — claims the next number, so a plain "1" (an ungrouped point) and a group numbered "1" (whose points would read "1.1", "1.2"…) can never coexist in the same Protokoll; every top-level number is unique across the whole document, standalone points and group prefixes alike. Never trusts the stored protokoll_punkte.nummer column — recomputed fresh on every render (and again by exportProtokollPDF() from its own freshly-fetched copy), so screen and PDF can never disagree and a point's number always reflects reality even if it moved groups or another point was added/removed/reordered around it. The column still exists and the client fills it with the current computed value right before every PUT purely for the sake of anyone reading the row directly (e.g. via the API) — it is never read back as a source of truth. Renumbering therefore never affects whether a point is "the same point" across Protokolle (that's stamm_id) or whether it counts as new (neu, set once, only at creation) — moving a point to a different group, or another point being deleted and shifting everyone's numbers, has zero effect on Fortschreiben/neu. gruppe itself is a genuinely free-text field with no backing table and no fixed list — unlike project_gewerke/VALID_DEPARTMENTS, which are a closed enumeration, a Protokoll group is created simply by typing it on a point; a <datalist> suggests names already used within the currently open Protokoll to curb accidental near-duplicates ("ELT" vs "Elt"), fed from already-loaded data — no extra server round-trip. Points are displayed clustered into sections by gruppe, in the same first-appearance order that drives the Gruppennummer, with an "Ohne Gruppe" section always first when any ungrouped points exist. Changing a point's group moves it to the matching section (and recomputes every affected number) on the next render (onchange, not oninput, so re-clustering doesn't fire mid-keystroke and steal focus from other fields).

Reordering after the fact (Erik's follow-up request) is two separate, matching controls, both plain ↑/↓ buttons re-rendering just the Punkte-Block — no drag-and-drop, since a point's visual section is already fixed by its gruppe (dragging it across group sections wouldn't make sense without also changing that field, which the Gruppe input already does): verschiebePunkt( idx, richtung) swaps a point with its neighbour within the same group (or within "Ohne Gruppe") only — disabled at the top/bottom of that group's own list; verschiebeGruppe(gruppe, richtung) moves an entire group's points as one block relative to the other groups (rebuilds currentProtokoll.punkte as "Ohne Gruppe" points first, unchanged, then each group's points concatenated in the new group order) — this is what actually changes which group gets Gruppennummer "1" vs. "2". "Ohne Gruppe" itself is not reorderable relative to the groups (it stays the fixed first section, same reasoning as its render order above); both buttons are simply absent/disabled at a list's boundary rather than wrapping around.

Teilnehmer are free text (protokoll_teilnehmer: name + optional Firma/Rolle, binary anwesend — deliberately the same "no third state" rule as week.attendance), independent of user accounts entirely — construction meetings routinely include externals (Bauherr, ausführende Firmen) who have no login here and never should. Two separate, non-overlapping paths fill it: creating a new Protokoll by hand (neuesProtokoll() in plan-protokoll.js) pre-fills the Teilnehmer list from the names of the project's most recently created Protokoll (with anwesend reset to true — presence must be reconfirmed every meeting, never assumed), falling back to projectAssignees(app.project, app.week) (internal team only, no Firma) if no earlier Protokoll exists, or an empty list otherwise — this prefill is pure client-side convenience before the POST. Importing a document (POST /project-protokolle/import) instead sources Teilnehmer from the document itself via the KI-gestützte Erkennung (see below) — the two paths are never combined/merged: an import shows only what the uploaded document actually contains, never the previous Protokoll's roster, so the user is never left guessing which source a listed name came from. Either way, the server only ever persists whatever Teilnehmer array legeProtokollAn() is given — it does no inference of its own.

No lock/finalize concept in this first version — unlike a real paper Protokoll (which is fixed once distributed), every Protokoll here stays editable indefinitely by any logged-in user, the same permission level as editing a todo or milestone (DELETE /project-protokolle/:id is likewise requireAuth, not admin-only — deleting a Protokoll is ordinary project content, not an administrative action). A future "abgeschlossen" status would be a natural, small follow-up, not part of this feature.

PDF export (exportProtokollPDF(protokollId), js/pdf.js) is the third PDF export the app has (alongside exportWochenberichtPDF()/ exportSandboxPlanPDF()) — A4 portrait like the Wochenbericht (same PDF_WB_PAGE_W/PDF_WB_MARGIN_X/pdfWbGridLayout()), reusing PDF_STYLES/ loadLogoPng()/PDF_ASSIGN_MAIN. It deliberately re-fetches the Protokoll fresh from the server rather than reading plan-protokoll.js's private in-memory state, so the exported PDF always reflects the last saved state, never unsaved edits sitting in an open form. Like the screen, the Punktetabelle is split into sections by gruppe, and neu=1 rows get a pale-yellow fillColor — a literal hex value, not the CSS custom property, since a PDF does not react to the viewer's light/dark theme.

project_protokolle/protokoll_teilnehmer/protokoll_punkte are part of IMPORT_TABLES (server.js) — a Protokoll created by hand has no on-disk component and lives entirely in the database (same reasoning as plan_todos); an imported one additionally references a file on disk (see next paragraph), which — like project_files — a DB restore neither includes nor overwrites.

Import: turning an external protocol into one of these (POST /project-protokolle/import, server.js) — a second way to create a Protokoll, alongside the "+ Neues Protokoll" button, for the common case where the Bauherr or an ausführende Firma writes and circulates their own protocol instead of the office's own. Rather than just filing the document, the route extracts its text and turns it into ordinary Protokoll-Punkte, so the result is immediately usable through every feature described above (Fortschreiben, Gruppen, Verschieben, PDF-Export) instead of being a second, parallel kind of content.

  • Two new dependencies, the first this project has ever needed beyond express/cors: pdfjs-dist (PDF → text, via its legacy/build/pdf.mjs Node-compatible entry point, loaded with a dynamic import() from server.js's otherwise-CommonJS code) and mammoth (.docx → text). Both run entirely locally — no network call, no API key, no per-use cost. (A third, undici, followed later — not for text extraction but to fix a local-server timeout bug in the KI recognition itself; see LOKAL_DISPATCHER further below.) extrahierePdfText(buf) (server.js) opens the document and walks every page's getTextContent() items, inserting a newline whenever an item's Y coordinate (item.transform[5]) changes — the same reconstruction a naive .join(' ') would lose, and erkennePunkteAusText() needs real line breaks, since a Punkt-Marker must sit at a line's start. @napi-rs/canvas (only for page-to-image rendering, never used here) is merely an optionalDependency of pdfjs-dist, not a hard one — the project's .npmrc (omit=optional) keeps npm install from ever fetching it, so no native addon ships regardless of platform. First choice was pdf-parse@1.x, abandoned after it failed under real testing: pinned initially to dodge pdf-parse@2.x's hard dependency on pdfjs-dist + @napi-rs/canvas, but pdf-parse@1.1.4 bundles a pdfjs build frozen at 2017 (v1.10.100) that throws UnknownErrorException: bad XRef entry on any PDF, valid or not, the moment Node's own http module has been loaded anywhere in the process — reproducible with nothing more than require('http') before the parse call, confirmed unrelated to the PDF's bytes (identical MD5 in and out) or their transmission (curl vs. direct Node http.request vs. a plain local fs.readFileSync in the same process — only the last one, with http never loaded, succeeded). Since express itself transitively loads http, this made pdf-parse fail on every request through the actual server while appearing to work fine in an isolated throwaway script — exactly the gap a smoke test that only calls the parser directly would miss. Switching to pdfjs-dist directly (current, actively maintained, zero hard dependencies) was unaffected by the same require('http') repro. Found and fixed before this reached Erik, by testing the actual route end-to-end (script → curl → server) rather than trusting a standalone unit-style check of the parsing function alone.
  • erkennePunkteAusText(text) (server.js) is the heuristic, plain regex/string-processing, no AI: it looks for line-starting numbering markers ("1.1", "TOP 3", "Punkt 2" …, PROTOKOLL_PUNKT_MARKER), and for each match treats the rest of the line as thema and everything up to the next match as text; within that text, labeled fields ("Zuständig:", "Termin:" …, PROTOKOLL_LABEL_MUSTER) are pulled out if present, and the words "erledigt"/"geschlossen" set erledigt=true. gruppe is deliberately never guessed — external documents group too inconsistently (by Gewerk, by chapter, not at all) to infer reliably, so every recognized point lands in "Ohne Gruppe" for the user to sort by hand. If no numbering pattern matches anywhere in the whole document, a fallback kicks in: every non-empty paragraph becomes its own point with no further parsing. The function is pure (no I/O), independent of whether the text came from a PDF or a Word file.
  • KI-gestützte Erkennung (ChatGPT/Claude) as the primary path, with erkennePunkteAusText() above as the automatic fallback — on Erik's explicit follow-up request, after the heuristic-only version above had already shipped. The import dialog offers a <select> ("ChatGPT"/ "Claude", #protokollImportErkennung in App.html, built at dialog-open time from GET /my-ai-settings so an option can carry a live "(kein Schlüssel hinterlegt)" hint); the chosen provider is sent as ?erkennung=chatgpt|claude to POST /project-protokolle/import. Deliberately not a third "Textmuster" menu entry — that path is a safety net, never a conscious choice, exactly mirroring how a missing/failing key behaves. erkennePunkteMitKI(text, userId, provider) (server.js, right next to erkennePunkteAusText()) reads that ACCOUNT's own key via readUserAiKey(userId, provider); on any failure — no key stored, network error, non-2xx response, a timeout (AbortController, KI_TIMEOUT_MS_CLOUD/KI_TIMEOUT_MS_LOKAL), or a response that doesn't match the expected shape — the route catches it and calls erkennePunkteAusText(text) instead, so the import always succeeds, even with no internet access at all. The response carries erkennungsart ('chatgpt'|'claude'|'textmuster') and, only on a fallback, erkennungshinweis (the failure reason); the client surfaces that via the existing app.showToast() (plan-state.js) rather than silently pretending the AI path was used. Unlike the heuristic, the KI path can also attempt gruppe and Teilnehmer recognition (added on Erik's follow-up request, after both had shipped empty/absent from every import) — a language model can use context the heuristic's plain regex cannot: it recognises an actual section heading ("Elektro:", "TOP HLS") and a genuine Teilnehmer-/Anwesenheitsliste (usually at the document's start/end), things erkennePunkteAusText() deliberately never attempts (previous bullet). KI_SYSTEM_PROMPT stayed deliberately short after it turned out to be extremely sensitive to added instructions — found when Erik reported that a real import ("kopiert die Texte nicht mit, erstellt nur die Überschriften") produced almost no usable point text at all, diagnosed against his actual, real-world sample document (a professional PromanX/Phenix "Besprechungsbericht" export) rather than a synthetic toy text. That document turned out to be multi-level numbered — a top-level chapter ("01"), a sub-chapter ("01.0"), and only the deepest level ("01.0 01.03") carrying real descriptive text — and repeated, dated revision paragraphs under the same point across several past meetings. Direct, repeatable testing against Erik's local LM Studio (temperature:0, so not sampling noise) showed the SAME request, with only small prompt wording changes, swinging between 0, 1, 10, and 11 recognised points, and between fully-populated and completely EMPTY text fields for every point — a longer, more explicit prompt (covering the multi-level numbering, an anti-hallucination rule for gruppe, an explicit Teilnehmer section, a rule for merging dated revision paragraphs) reliably performed WORSE than a much shorter one covering only the essentials, and even appending one extra, individually reasonable sentence to an already-working prompt was enough to regress it back to near-total failure. The worst single cause found: a leading, general "erfinde nichts, lass im Zweifel leer" instruction appeared to make the model default to leaving text itself empty out of the same caution — the opposite of Erik's actual complaint. KI_SYSTEM_PROMPT's final, shipped wording is therefore intentionally minimal (no explicit Teilnehmer instructions, no revision-merging rule) and was kept VERBATIM from the one specific phrasing that was empirically confirmed to work (same token counts/results across repeated runs at temperature:0) — not polished for house style, since even small rewordings had already proven capable of silently breaking it again. At the time this was a real, but partial, fix: it recovered most point text where the previous prompt recovered almost none, but did not reliably suppress a bare chapter/sub-chapter heading from occasionally still becoming its own empty point, and revision continuations of an earlier point could still surface as separate, oddly-numbered entries. A follow-up round (Erik: multiple distinct points under one heading should each come through as their own point, AND the existing paragraphs under a point should be carried over) added exactly ONE further sentence and, against the same real-document stability test, did NOT regress — "Stehen unter einer Überschrift mehrere eigene Punkte, gib jeden einzeln aus. Folgen auf einen Punkt weitere Absätze ohne neue eigene Nummer, gehören sie zu diesem Punkt dazu." Confirmed against the document's own worst case (01.0 01.06, three dated revision paragraphs spanning three past meetings): previously these surfaced as orphaned, theme-less fragments; with the new sentence they came back as ONE point carrying all three paragraphs concatenated (2144 characters), and the earlier multi-point case (01.0 01.01/01.0 01.03/01.0 01.04 all under the same 01.0 Organisation heading) stayed correctly split into separate points. So the earlier "not something further prompt tuning could fix" conclusion was an overstatement from stopping after ONE failed attempt, not a hard ceiling — the real lesson is that EVERY wording change here must be tested against the real document at temperature:0 individually before shipping, since nothing about this model's reaction to a given sentence can be predicted from how reasonable the sentence reads. A second, independent failure mode turned up while testing the above: against a test excerpt that was artificially cut to exclude the document's own real Teilnehmer-/Verteiler table, the model — still obliged by the schema to produce a teilnehmer array, with nothing genuine to put in it — span into a token-repetition loop (the same short name fragment repeated well over a thousand times) until max_tokens cut the response off mid-string, yielding invalid, unparseable JSON that discarded the otherwise-correct punkte result too. Re-run with the document's actual opening Teilnehmer table included, the loop did not recur, and the request finished in a tenth of the time (73s vs. 700+s) — strong evidence the trigger was the absence of real participant data for that one field, not the new point-splitting instruction, and not something every real import will hit (a real Besprechungsbericht always carries its own participant table). frequency_penalty/presence_penalty (erkennePunkteMitChatGPT() only — Anthropic's Messages API has no equivalent parameter) were added as a defensive measure regardless, since a repetition loop is a known, generic small-model failure mode worth guarding against independent of this one trigger being understood. **A third round (Erik: the KI should also judge whether a point is open, done, or purely informational) replaced the point's boolean erledigt output with a tri-state status — matching the app's own offen/ erledigt/info status already added for manual editing (see the protokoll_punkte.status bullet above) — and taught the model to prefer a literal in-document marker over its own judgement: the real sample document, it turns out, prints the word "Info" directly under several points as their own status field, which erkennePunkteMitChatGPT()'s new instruction now explicitly looks for first. Confirmed against the same real document: status came back correctly split across all three values — several points correctly info (matching their literal "Info" marker), others correctly offen or erledigt from the narrative content when no literal marker applied. Normalisation lives in normalisierePunktAusKi() (validates against VALID_PROTOKOLL_PUNKT_STATUS, falls back to offen) and in legeProtokollAn()'s zusatzPunkte loop, which accepts EITHER a valid status string (the KI path) OR falls back to deriving one from the older boolean erledigt (the Textmuster heuristic, erkennePunkteAusText(), was deliberately left alone — it has no way to judge "informational vs. actionable", only regex pattern matches for "erledigt"/"geschlossen"). This change cost back some of the previous round's paragraph-merging quality — the same real-document test that previously returned a point like 01.0 01.06 fully merged (2144 characters, three dated revisions in one text) now split it again into two or three same-themed entries once status entered the picture, even after adding one more connecting sentence ("bleibt es EIN Punkt mit dem Status des letzten Absatzes") that measurably helped (11 fragments down to 9) without fully fixing it. This is accepted rather than chased further: unlike the earlier regressions, nothing here comes back empty, orphaned, or theme-less any more — every fragment still carries its own real thema, text, and now-correct status, it is simply split one topic too finely in places. The status-rule and the merge-rule competing for the same limited headroom is, like the two previous rounds, a model-capability ceiling rather than a wording problem left unsolved — the same point about a stronger model having more room applies here too. A more capable model — the real Cloud gpt-4o-mini, or a larger local model — has more room for further instructions than this one could handle; that remains a model-capability question, not a prompt one. temperature:0 was added to both providers' requests for the same reason the instability was diagnosable in the first place: without it, identical requests returned different results from run to run, making it impossible to tell a prompt regression from ordinary sampling noise. max_tokens was also added to both (16000 for ChatGPT, 8192 for Claude — previously unset for ChatGPT, too low at 4096 for Claude): a long Besprechungsbericht with many points needs more completion budget than either default/prior value guaranteed, and a response cut short by a token limit would look identical to one where the model simply decided to stop — a second, independent way the old behaviour could under-report points regardless of prompt wording.
    • Both providers run over Node's built-in global fetch() — no new npm dependency, unlike the PDF/Word text extraction above, which genuinely needed libraries. erkennePunkteMitChatGPT(text, apiKey, apiUrl) uses the Chat Completions API (gpt-4o-mini) with Structured Outputs (response_format: json_schema, strict mode) to force the exact point shape; erkennePunkteMitClaude(text, apiKey, apiUrl) uses the Messages API (claude-haiku-4-5-20251001) with forced tool-use (tool_choice: {type:'tool', …}) as the equivalent structured-output mechanism — Claude has no separate JSON-schema response mode. Both normalise each point through the same normalisierePunktAusKi() into the same {nummer, gruppe, thema, text, zustaendig, termin, erledigt} shape erkennePunkteAusText() already produces (here gruppe is a real pass-through, not hardcoded empty — see above), so legeProtokollAn() and everything downstream needs no branching on where a point came from. Each recognizer additionally returns a sibling teilnehmer array (normalised through normalisiereTeilnehmerAusKi() into {name, firma, anwesend}) — both functions' return value changed from a plain points array to {punkte, teilnehmer} for this; erkennePunkteMitKI() passes that object straight through, and the /project-protokolle/import route destructures it into its own zusatzPunkte/teilnehmerAusDokument locals, passing the latter to legeProtokollAn() instead of the hardcoded [] it started with. On the Textmuster-Fallback path teilnehmerAusDokument stays [], same as before — the heuristic still never attempts Teilnehmer recognition. Text sent to either API is capped at KI_MAX_TEXT_LAENGE (40,000 characters) — bounds cost/latency on a pathologically large upload, comfortably more than any real Besprechungsprotokoll needs. An apiUrl argument overrides KI_DEFAULT_URL[provider] when the account has one configured (see next bullet) — e.g. a locally running, OpenAI-compatible server (LM Studio, Ollama) instead of the public cloud API, so the feature can be tried with a free local model, or a company proxy/ self-hosted gateway.
    • Every account holds its OWN key (and optional URL) — not a firmenweit setting. First built admin-managed/firmenweit, changed on Erik's explicit follow-up ("jeder user soll für sich seinen schlüssel hinterlegen können und die url angeben können"): a new, small user_ai_keys table (user_id, provider primary key, api_key, api_url, FOREIGN KEY … ON DELETE CASCADE — same User-Kind-Tabelle pattern as user_roles/user_absences) replaces the two rows that used to live in the firmenweit app_settings singleton store. readUserAiKey(userId, provider)/writeUserAiKey(userId, provider, patch) (server.js) are the read/write path. GET/PUT /my-ai-settings (scoped to req.user.id, never a client-supplied id — same pattern as POST /change-email) manage them: GET returns, per provider, only {configured, apiUrl} — never the key itself, the same "write-only, never read back" shape as a password, while apiUrl is not secret and comes back so the form can be pre-filled; PUT sets or (given an explicit empty string) clears a key, and separately validates/sets or clears an apiUrl via istGueltigeAiUrl() (400 if invalid), leaving an omitted sub-field untouched. The UI lives under Einstellungen → Mein Konto (settings.js, card #settingsAiCard) — not in Admin, since it is now personal account data like the e-mail address and password right above it in the same card list, not an administrative setting. No admin role is required or checked anywhere in this path.
    • The card shows a "Cloud"/"Eigener Server" toggle (.ai-mode-toggle, setAiMode() in settings.js) instead of the collapsed "API-URL" link the first design used. Redesigned after real use: connecting a local server (LM Studio) turned out to be the first thing Erik tried, not an edge case, so hiding the URL field behind a small disclosure undersold it — the toggle makes "Cloud" vs. "Eigener Server" a visible choice, and the URL field's placeholder names a concrete example (http://localhost:1234 für LM Studio) instead of an abstract explanation. renderSettingsAiKeys() opens "Eigener Server" automatically whenever a saved apiUrl is non-empty, so a configured override is never hidden on page load.
    • POST /my-ai-settings/test ("Neu verbinden") actually exercises the saved connection — the card previously only showed whether a key was stored (configured), never whether it worked; diagnosing Erik's LM Studio setup this week (wrong path, too-short timeout, a reasoning model burning almost all its tokens on invisible "thinking") had to happen entirely through real import attempts because nothing surfaced a connection problem on its own. The route reuses erkennePunkteMitKI() unchanged, scoped to req.user.id, with a short dummy text — the exact same code path an import uses, so a successful test means an import would succeed too (timeout choice, the local reasoning_effort handling, and URL normalisation all apply identically). Returns {ok:false, error} with HTTP 200 on a failed test — a failed connection attempt is an expected, valid outcome to report, not a server error, so the client branches on ok rather than on HTTP status. testMyAiConnection() (settings.js) drives the .ai-reconnect-btn next to the status pill (spins via .is-testing while in flight, disabled whenever the provider isn't configured — same disabled-state pattern as the "Entfernen" button) and writes a failure into .ai-test-error, a plain line under the key row that stays visible until the next test result replaces or clears it — deliberately not a fading toast: a toast firing at the same moment the surrounding view changes is exactly how the Protokoll-Import's own KI-fallback banner was first missed (see below), the identical lesson applied here before it could repeat. A successful test instead uses the existing transient showSettingsNote() — good news is allowed to fade.
    • istGueltigeAiUrl() allows plain http:// on the loopback (localhost/127.0.0.1/::1) — everywhere else https:// is required. First shipped as https://-only, which broke the single most obvious reason to set a custom URL at all: Erik tried pointing it at a locally running LM Studio, whose OpenAI-compatible server is plain http://localhost:1234/… (a local dev server essentially never carries a TLS certificate for localhost). The PUT silently rejected that URL with 400, so the account's api_url stayed empty and every import kept hitting the real, public OpenAI endpoint with an OpenAI-shaped key that was never meant for it — confirmed from the live server log, which showed OpenAI's own Incorrect API key provided… message, i.e. the request really did leave the building instead of staying on localhost as intended. http stays disallowed for every non-loopback host — the key would otherwise cross a real network in plaintext — but on the loopback the request never leaves the machine, so there is nothing to protect against. Only meaningful when the server itself runs on the same machine as the local LLM server (true for a normal npm start); inside the Docker container (see "Docker" below), localhost means the container, not the host, so reaching a host-side LM Studio/Ollama instance needs host.docker.internal (Docker Desktop) or the host's LAN address instead.
    • normalisiereAiUrl(apiUrl, defaultUrl) fills in the provider's default path when a custom apiUrl has none. LM Studio and Ollama are both conventionally addressed by their bare origin (http://localhost:1234, http://localhost:11434) — that's the address either tool's own UI shows, not the full …/v1/chat/completions request path a raw fetch() needs. Saving just the origin therefore isn't a user mistake to reject, it's the expected input; without normalising it, the request landed on / at the local server. Reproduced directly against Erik's own running LM Studio (this Bash tool executes on the same machine the app runs on, so curl 127.0.0.1:1234 reached his real instance, not a stand-in): its log read Received request: POST to / / Unexpected endpoint or method … Returning 200 anyway for the bare origin, and correctly hit /v1/chat/completions once the path was appended. Only a missing or empty path (new URL(apiUrl).pathname is ''/'/') gets the default appended — an explicit, different path (e.g. a company proxy with its own route) is always left exactly as entered.
    • KI_TIMEOUT_MS_LOKAL (30 min — raised twice from an initial 5 min: first to 15 min after Erik reported it still aborted often once the prompt fix below made the model generate real text for every point instead of staying mostly empty — see that bullet — then to 30 min after even 15 min still aborted while LM Studio itself finished cleanly at roughly 12,000 generated tokens on his hardware) applies whenever the resolved URL is on the loopback; every other host keeps the original KI_TIMEOUT_MS_CLOUD (45 s). A single 45 s timeout across the board silently aborted every request to a local model before it could finish — confirmed against Erik's own LM Studio, which remained correctly computing well past that point ("läuft aber durch") while the app had already given up and reported This operation was aborted. Cloud APIs keep the short timeout: a hung request there really does mean something is wrong and should fail fast, whereas CPU/modest-GPU local inference routinely takes minutes for a single structured-output completion — nothing to time out early for. fetchMitTimeout() decides via the same istLoopbackHost() the URL validation above uses, so both paths share one definition of "runs on this machine". On its own this raised timeout still wasn't enough for Erik's setup — see the next bullet for why, and for the fix that actually resolved it.
    • erkennePunkteMitChatGPT() sends reasoning_effort: 'none' to local targets — the real fix for "This operation was aborted" even at a 5 minute timeout. Diagnosed by querying Erik's actual, running LM Studio directly (this Bash tool runs on the same machine as server.js itself, so curl 127.0.0.1:1234 reaches it for real, not a stand-in): the loaded model, qwen/qwen3.5-9b, is a reasoning model that emits a hidden "thinking" transcript (its own reasoning_content field, separate from content) before every answer. For the trivial prompt "Sag nur das Wort: Test", it spent 260 of 264 completion tokens — 98.5% — on thinking alone, taking 15 seconds for a one-word answer; scaled up to the real task (an 11-page Besprechungsprotokoll, 100+ points, strict JSON schema), that invisible phase alone plausibly runs to thousands of tokens, comfortably past any timeout regardless of how generous, even though the model reliably finishes if simply given enough time ("läuft aber durch" — Erik's own description). Two documented Qwen3 conventions for suppressing it were tried against this server and neither worked (enable_thinking: false, both top-level and nested under chat_template_kwargs; the /no_think prompt suffix) — only reasoning_effort: 'none' (the OpenAI o-series parameter, which LM Studio's compatibility layer honors) did: reasoning_tokens dropped to 0 and the same trivial prompt returned in 0.4s. Re-verified with the actual KI_SYSTEM_PROMPT and KI_PUNKT_SCHEMA against a real sample protocol text: correct, fully valid points (including correctly inferring erledigt: true from a point's text) in 11s with zero reasoning tokens, and end-to-end through the real import route in 9.5s (erkennungsart: 'chatgpt', no fallback needed) — the existing prompt and schema design needed no changes at all once the reasoning phase was out of the way. Sent only when istLoopbackHost() is true for the resolved URL — never to the real OpenAI endpoint, where gpt-4o-mini (not a reasoning model) doesn't support the parameter and an unrecognized field could be rejected outright. Claude is unaffected by any of this: Anthropic's Messages API only enables thinking via an explicit, opt-in thinking parameter that this code never sends, so there's no equivalent default-reasoning trap to guard against there.
    • LOKAL_DISPATCHER (server.js, a dedicated undici.Agent) — the fix for a second, independent abort Erik hit once KI_TIMEOUT_MS_LOKAL was first raised past 5 minutes: fetch failed / Headers Timeout Error, even though LM Studio itself kept computing. Node's global fetch() runs on an internal undici client whose OWN default Agent closes a connection after headersTimeout/bodyTimeout = 5 minutes each, entirely independent of the app's own AbortController — invisible as long as KI_TIMEOUT_MS_LOKAL itself was also ~5 minutes (both fired at about the same time, so only the app's own clean "This operation was aborted" was ever seen), but once the app's own timeout was raised, undici's shorter built-in one fired first instead, surfacing as the generic, lower-level fetch failed. Reproduced directly: a throwaway local HTTP server that delays its response, fetched with a deliberately short custom dispatcher, failed with the exact same fetch failed / Headers Timeout Error pair Erik saw; the same setup with a long dispatcher let the delayed response through. fetch() itself has no option for this — only the non-standard (but Node-honored) dispatcher field in its request options can override it, which needs an actual Agent instance, hence the one new dependency: undici (a second dependency beyond pdfjs-dist/ mammoth, and the only one of the three NOT used for its own fetch — only for its Agent class, passed into Node's own global fetch() via dispatcher). fetchMitTimeout() attaches LOKAL_DISPATCHER only for loopback targets, with headersTimeout/bodyTimeout kept 10s above KI_TIMEOUT_MS_LOKAL so the app's own, friendlier abort message always wins the race against undici's lower-level one. Cloud targets are unaffected (KI_TIMEOUT_MS_CLOUD at 45s is far under undici's 5-minute default, so the problem never applied there).
    • Stored in plaintext, the same trust boundary as every other column in the same database file (e.g. user_sessions.token) — not a new or weaker guarantee than the rest of the schema.
    • Breaks, for this one feature only, the server's previous "no internet access needed" property — holidaysForYear() was deliberately built without a network dependency so the whole app keeps working on an office network without internet (see its own comment in server.js). KI-gestützte Erkennung inherently needs outbound internet access from the server; the automatic fallback to erkennePunkteAusText() means the app as a whole is unaffected — only this one recognition path silently degrades to the offline heuristic when there's no connectivity (or when the calling account simply hasn't configured a key).
  • The result is explicitly a draft, not a finished record — recognition from an arbitrarily formatted foreign document, with or without AI, cannot be perfectly reliable (table columns routinely collapse in a plain-text extract, and even a capable model can misread a badly scanned source), so the design leans toward leaving a field empty rather than guessing wrong: an empty field is obviously incomplete when reviewed, a wrongly filled one is not. Every imported Protokoll shows a banner in the detail view (.protokoll-import-banner in plan-styles.css, reusing the same --protokoll-neu-bg/--protokoll-neu-border tokens as the .is-neu point highlight) prompting a check before saving, plus a "Quelldatei herunterladen" link (GET /project-protokolle/:id/quelldatei, the Protokoll equivalent of GET /project-files/:id/download) so recognized text can always be checked against the original. Both are driven purely by whether quelleDateiname is set on the loaded Protokoll — no separate "was this imported" flag or client-side state.
  • Shares the ordinary creation path, not a separate one: the pre-existing inline logic of POST /project-protokolle was extracted into legeProtokollAn(opts), called by both routes, so an imported Protokoll gets the exact same nummer assignment and Fortschreiben-copy of the previous Protokoll's open points as a manually created one — the import only adds the recognized points on top (neu=1, fresh stamm_id, appended after the copied-forward points). Nothing about numbering, grouping, or reordering (described above) needed to change for this feature — imported points are ordinary points from the moment they exist.
  • Two new nullable columns on project_protokolle, quelle_dateiname/quelle_stored_name (migration migrateAddProtokollQuelle(), same idempotent ALTER TABLE pattern as migrateAddProjectGroesse()), empty for every hand-created Protokoll. The uploaded file itself is stored the same way as project_files — sessions/uploads/<uuid>.<ext> (uploadDirFor()/extractFileExtension(), both reused unchanged) — but not in the project_files table itself: that table's reviewer/approval workflow doesn't apply here (nobody "approves" someone else's protocol), so quelle_stored_name is the only pointer to the file, and quelle_stored_name (unlike quelle_dateiname) is never sent to the client — only used server-side to resolve the download route.

Auslastung — nur aus Aufgaben (aktueller Stand, 10/2026)

Löst die drei folgenden Auslastungs-Abschnitte in der RECHNUNG ab (die Darstellung als Personenkarten gilt weiter). Eriks Vorgabe: „Wenn einem User eine Aufgabe zugeteilt wird, dann hat diese Aufgabe 8 Stunden und dann ist diese Person auch mit 8 Std. ausgelastet. Zwei Personen → jeder die Hälfte.“

  • Gezählt werden NUR offene Aufgaben mit Aufwand (t.aufwand) und Zuständigen: offener Teil (Aufwand × (1 − Fortschritt)) zu gleichen Teilen auf die Zuständigen und auf die Arbeitstage der Aufgabe (aufgabenVerlaufJePerson(), ohne Termine die nächsten 4 Wochen). Aufgaben ohne Aufwand zählen nicht; Phasenbalken nicht.
  • Entfallen: das Phasenbudget aus Honorar × Phasenanteil ÷ Stundensatz und die Gewichtungstabelle Leistungsphase × Rolle — firmenweit (Admin → Auslastung) wie je Projekt („Gewichtung bearbeiten“, #lpModal). Gelöscht in load.js: zuordnungsVerlauf(), berechneAuslastung(), rollenGewicht(), aufwandVorlage(), aufwandMatrixWert(), aufgabenAbzug(), projektBasisHonorar(), phasenAnteilProzent(), projektVerteilWochen(), phasenBalken(), verteileGleichmaessig(), zuordnungsGewicht() und die LP-Aufwand-Maske. Die Admin-Felder Referenzhonorar und Verteilzeitraum sind ebenfalls weg. app_settings.aufwandVorlage/referenzHonorar/verteilzeitraumWochen und projects.aufwandProLP bleiben am Server bestehen, werden aber nicht mehr gelesen.
  • Dieselbe Rechnung nutzen Auslastungsseite (renderLoad(): Kopf „N von M Projekten mit Aufwand in Aufgaben“, Lücken „keine Aufgabe mit Aufwand“ usw.), Ressourcenansicht im Terminplan (computeResourceLoads()), Wandtafel (wandAuslastungJeGewerk()) und der Aufwand im Bereich Leistungsphasen (Summe der Aufgabenstunden der Phase).
  • Ressourcenansicht im Terminplan (computeResourceLoads()): Zahl = Aufgabenstunden DIESER Woche („h diese Woche“); darunter je Person die offenen Aufgabenstunden dieses Projekts über alle Wochen (offenProjekt) — sonst stand bei einer erst später terminierten Aufgabe nur „0,0 h“. Zuständige einer Aufgabe, die nicht unter „Beteiligte“ stehen, bekommen eine eigene Karte („nur Aufgabe“); Phasenbalken stehen nicht mehr in der Aufgabenliste der Karte.
  • Terminplan als PDF auch für echte Projekte (Eriks Wunsch): „Ansicht → Als PDF exportieren“ (#sandboxPdfBtn) ist immer sichtbar. exportSandboxPlanPDF(id) findet einen Planung-Plan oder das geöffnete Projekt und ruft exportTerminplanPDF(p, sandbox, week) (js/pdf.js): Kopf mit Nummer, Name und Sitzung, Datei Terminplan_<Nr>_<Name>.pdf. Es kommen nur Aufgaben mit Start und Ende und showInGantt !== false ins PDF, wie am Bildschirm. Gestaltung „A2 — volles Raster“ (Eriks Wahl aus einem Entwurfsvergleich, PDF_TP, pdfTpZeilen()/pdfTpSeite()/pdfTpLegende()): links Vorgang, Start und Ende, rechts Balken über Monaten (im Wechsel hinterlegt) und Kalenderwochen, Gitter über das ganze Blatt. Jeder Phasenbalken wird eine dunkelblaue Kopfzeile mit Sammelbalken und Fortschritt aus dem Wochenbericht, darunter seine Aufgaben (aufgabenPhase()) und die Meilensteine in seinem Zeitraum, der Rest unter „Weitere Aufgaben“. Bewusst ohne Zuständige, Arbeitstage und Heute-Linie. Die Seite ist frei positioniert (absolutePosition, Canvas für Flächen und Balken). Dabei gilt: Oberer Seitenrand 0, Legende in der Fußzeile, kein Text über den rechten Rand und der Kopf je Seite neu gebaut — sonst erzeugt pdfmake leere Zusatzseiten oder lässt den Kopf ab Seite 2 weg. Zeilenhöhe 17–30 pt, bei vielen Aufgaben weitere Seiten mit demselben Kopf.
  • Projektgröße entfernt (Eriks Wunsch): kein Feld mehr in den Projekteinstellungen (setPlanGroesse() weg) und im Assistenten. Die Spalte projects.groesse ist auch aus der Datenbank entfernt (migrateDropProjectGroesse(), ALTER TABLE … DROP COLUMN, idempotent, ersetzt migrateAddProjectGroesse(); normalizeGroesse() entfällt). Ältere Backups mit der Spalte bleiben importierbar — der Import überträgt nur Spalten, die beide Schemas haben.
  • Honorar beim Anlegen freiwillig: Der Assistent (projekt-wizard.js) hat im Schritt „Auftrag“ nur noch ein optionales Honorarfeld (keine Wahl „Grundlage“ mehr); ohne Honorar zeigt der Schritt „Terminplan“ „Kein Terminplan ohne Honorar“, und „Prüfen“ nennt „Honorar: nicht angegeben“. Die Beschreibung „Honorar ODER Projektgröße, nie beides“ weiter unten ist damit überholt.

Auslastung — Wochenstunden statt Faktoren

Die Auslastung wird in Wochenstunden gerechnet und erst zum Schluss auf die Kapazität der Person bezogen. Vorher war sie eine Summe dimensionsloser Gewichte: Ein Projekt in LPH 5 zählte für die Planung 1,0 — ob es 20.000 € oder 2 Mio. € umfasste. Zwei kleine Projekte wogen damit schwerer als ein großes, und die Zahl sagte nichts darüber, wie viel Arbeit anliegt.

Der Weg von einem Projekt zu den Wochenstunden einer Person (berechneAuslastung() in js/load/load.js, sechs Schritte):

  1. Basis — projektBasisHonorar(): das Honorar des Projekts. Fehlt es, tritt projects.groesse (0–1) mal APP_SETTINGS.referenzHonorar an seine Stelle. Fehlt beides, ist das Projekt nicht bewertbar — es wird als Lücke ausgewiesen, nicht als Null gezählt.
  2. Phase — arbeitsPhase() bestimmt, womit gerechnet wird: die amtierende Leistungsphase, und wenn die bereits zu 100 % erledigt ist, die nächste beauftragte, die noch offen ist. Ohne diesen Schritt fiel ein Projekt aus der Auslastung, sobald jemand seine Phase auf 100 % setzte, ohne weiterzuschalten — im Bestand der Normalfall. Sind alle beauftragten Phasen fertig, ist das Projekt fertig und bindet zu Recht keine Kapazität mehr. phasenAnteilProzent() liefert den Anteil dieser Phase am Honorar (HONORAR_ANTEILE, auf die beauftragten Phasen normiert — dieselbe Rechnung wie im Leistungsstand), abzüglich des Fortschritts. Übrig bleibt der offene Wert.
  3. Stunden — offener Wert geteilt durch APP_SETTINGS.stundensatz.
  4. Rolle — rollenGewicht() (Projektmatrix, sonst firmenweite Vorlage, unverändert), hier aber als Anteil: Gewicht der Rolle durch die Summe aller Rollengewichte dieser Phase.
  5. Köpfe — Aufteilung auf die Personen, die dieser Rolle im Projekt zugeordnet sind (zuordnungsGewicht(): Hauptverantwortung 1, Unterstützung 0,5). Ohne diesen Schritt zählte jeder von drei Planern die vollen Phasenstunden — der Aufwand verdreifacht sich nicht dadurch, dass mehr Leute daran arbeiten. Dafür bekommt berechneAuslastung() das assign-Objekt des Projekts als letzten Parameter.
  6. Zeit — projektVerteilWochen(): die Wochen bis zum spätesten Datum im Terminplan; ohne Terminplan (oder bei bereits verstrichenem Ende) gilt APP_SETTINGS.verteilzeitraumWochen. Ohne Horizont wäre die Wochenlast nicht bestimmt — dieselben Reststunden bedeuten über vier Wochen etwas anderes als über vierzig.

HONORAR_ANTEILE (js/globals.js) wird von loadAppSettings() (js/store.js) zusammen mit den übrigen Vorgaben aus GET /honorar-config geladen, also vor /load-all und damit vor dem ersten renderAll(). Der Projektbereich holt sie sich für den Leistungsstand weiterhin selbst (plan-leistung.js), weil er sie auch nach einer Änderung im laufenden Betrieb neu braucht.

Was die Seite sonst noch anders macht als zuvor:

  • Eine Summenzeile je MENSCH über alle Rollen steht vor den Rollenblöcken. Bisher fehlte sie: Wer in drei Rollen arbeitet, hat trotzdem nur eine Arbeitswoche, und jede einzelne Rollenzeile blieb unter der Schwelle. Erik Dunin kam mit 0,80 Planung und 0,40 Projektleitung auf 1,20 — rot war keine der drei Zeilen.
  • Die Kopfzeile sagt, was die Zahlen abdecken („1 von 25 Projekten bewertet"), und ein Aufklapper listet die nicht bewertbaren Projekte mit Grund. Vier Gründe kommen vor: ohne amtierende Leistungsphase, ohne Honorar und ohne Projektgröße, ohne Honorar-Anteil für diese Phase, und niemand für <Rolle> zugeordnet — ein Projekt kann bewertbar sein und trotzdem bei niemandem ankommen, etwa wenn es in LPH 8 steht (die die Bauüberwachung trägt), aber nur ein Planer zugeordnet ist. Eine fehlende Angabe ist keine fehlende Arbeit; vorher war beides eine Null.
  • Das volle Band ist die Wochenkapazität. Die frühere Skala auslastungBalkenMax (Vorgabe 8) ist ersatzlos entfallen — samt loadBarMax(), dem Admin-Feld und dem Eintrag in APP_SETTING_DEFS: Sie stand im Widerspruch zur Ampel, die ab 1,0 rot wird, sodass ein roter Balken nur zu einem Achtel gefüllt war.
  • js/pdf.js rechnet dieselbe Rechnung (Spalten „h / Woche" und „Anteil der Kapazität"), sonst wiesen Bildschirm und Druck verschiedene Zahlen aus.

Die Projektgröße (projects.groesse, REAL 0–1, Migration migrateAddProjectGroesse()) wird als Regler im Bereich „Einstellungen" des Projekts und im Dialog „Neues Projekt" gepflegt; groesseText() zeigt neben dem Regler den Eurobetrag, weil eine nackte Zahl zwischen 0 und 1 niemandem etwas sagt. Unter dem Regler steht, ob er gerade überhaupt zählt — eine Zahl, die wirkungslos ist, ohne dass man es sieht, wäre schlimmer als keine. Bestandszeilen starten mit 0 (= keine Angabe), absichtlich ohne Vorgabewert wie 0,5: Eine erfundene Größe erzeugte Zahlen, die nach Messung aussehen. normalizeGroesse() (server.js) kappt auf 0…1.

Auslastung — Wochenvorschau und Personenkarten (aktueller Stand)

Eriks Wahl „Konzept 1“ (Rechnung) und „B“ (Darstellung). Löst die beiden folgenden Abschnitte teilweise ab: Die Herleitung der Stunden je Phase (Honorar × Anteil × Rest ÷ Stundensatz × Rolle × Kopf) gilt weiter, die Verteilung über die Zeit und die Darstellung sind neu.

  • Verteilung über die Phasenbalken (zuordnungsVerlauf() in js/load/load.js): Für jede beauftragte Phase AB der laufenden (arbeitsPhase(); frühere gelten als erledigt) werden die Reststunden auf die Arbeitstage IHRES Balkens im Terminplan verteilt (verteileAufBalken(), ohne Wochenenden/Feiertage) und je Kalenderwoche summiert — VORSCHAU_WOCHEN (12) ab dieser Woche. Vorher wurde die laufende Phase bis zum Projektende gestreckt; eine Phase, die in drei Wochen endet, erschien dadurch viel zu leicht. Liegt ein Balken in der Vergangenheit, die Phase ist aber offen, fällt der Rest in die nächsten UEBERFAELLIG_WOCHEN (4). Ohne jeden Phasenbalken gilt das frühere Verfahren (verteileGleichmaessig(), projektVerteilWochen()); die laufende Phase ohne Balken ebenso, spätere Phasen ohne Balken zählen nicht (kein Zeitpunkt bekannt).
  • Welcher Balken zu welcher Phase gehört: AUSSCHLIESSLICH plan_todos.leistungsphase (Client t.lph, balkenPhase(); Migration migrateAddPlanTodoLeistungsphase(), in IMPORT_OPTIONAL_COLS) — Eriks Wunsch: nicht mehr über den Titel. Gesetzt von „+ Neu“ → „Leistungsphase“ (addPlanPhase(): Aufgaben-Panel im Phasen-Modus mit Auswahl #todoEditLph, Titelvorschlag mit HOAI-Bezeichnung, Phasen mit schon vorhandenem Balken gesperrt, „Wer“/Aufwand/„Speichern & neu“ ausgeblendet; ein Phasenbalken öffnet beim Bearbeiten ebenfalls so), von „Terminplan aus Honorar“ und vom Projekt-Assistenten. Ältere Balken, die bis dahin nur am Titel erkannt wurden („LPH 5 (307 h)“, „LPH 5 …“, „W+M“), hat phasenbalkenAusTitel() (server.js) EINMAL gekennzeichnet — Merker in der neuen Tabelle umstellungen (einmaligeUmstellung()); nach einem Backup-Import läuft sie erneut, weil ältere Backups das Feld nicht kennen.
  • Aufgaben zählen mit (Eriks Meldung: „TODO zugewiesen, aber keine Auslastung“). (A) Zuständige einer offenen Aufgabe zählen als Beteiligte des Projekts — es erscheint auf ihrer Karte, auch ohne Eintrag unter „Beteiligte“, und wer keine Fachrolle hat, bekommt trotzdem eine Karte. (B) Optionales Feld „Aufwand“ in Stunden im Aufgaben-Panel (#todoEditAufwand, t.aufwand, plan_todos.aufwand_stunden, in IMPORT_OPTIONAL_COLS): der offene Teil (Aufwand × (1 − Fortschritt)) verteilt sich auf die Zuständigen und die Arbeitstage der Aufgabe (aufgabenVerlaufJePerson(); ohne Termine die nächsten 4 Wochen) — auch in Projekten, die nicht bewertbar oder fertig gerechnet sind. Damit nichts doppelt zählt, mindert er das Budget der Phase, in deren Balken die Aufgabe beginnt, sonst der laufenden (aufgabenAbzug()). Phasenbalken selbst zählen nicht als Aufgabe. Die Ressourcenansicht im Terminplan (computeResourceLoads()) rechnet die Aufgabenstunden dieser Woche mit; die Wandtafel (Summen je Gewerk) noch nicht.
  • Kapazität je Woche (wochenKapazitaet()): Vertragsstunden je Arbeitstag × verfügbare Tage — ohne Feiertage und ohne eingetragene Abwesenheiten (db.globalPeople[].absences). Eine ganz abwesende Woche erscheint schraffiert.
  • berechneAuslastung() liefert Woche 0 dieser Vorschau (plus verlauf) — Wandtafel, Projekt-Dashboard und Ressourcenansicht zeigen damit dieselbe Zahl wie „diese Woche“ auf der Auslastungsseite. Abwesenheiten berücksichtigt nur die Auslastungsseite.
  • Darstellung (renderLoad()): eine Karte je Person mit Projektanzahl („3 Projekte, davon 2 in Hauptverantwortung“, Projekte „In Anbahnung“ getrennt genannt), Wert dieser Woche, zwölf Wochensäulen mit gestrichelter 100-%-Linie, Hinweis auf die Spitze und die zwei größten Projekte. Sortiert nach Spitze; Personen ohne Projekt zuletzt. Ab dem dritten Projekt aufklappbar (Knopf „+ N weitere“ / „weniger“, loadProjekteUmschalten(), Zustand je Person in loadProjekteOffen, übersteht das Neuzeichnen; Tooltip je Projekt mit Stunden). Die Rollenzeilen, das Lastband und die Projektlegende der Seite sind entfallen. Stile: Block „Auslastung als Personenkarten“ am Ende von styles.css.

Auslastung — das Lastband

renderLoad() (js/load/load.js) zeigt eine Zeile je Person und Rolle, und der Balken ist nach Projekten aufgeteilt: ein Segment je Projekt, seine Breite der Beitrag zur Last. Vorher stand jede Person in einer eigenen .load-card mit ihrer Projektliste klein darunter — man sah die Zahl, aber nicht, woraus sie besteht. Auf eine Auslastung von 1,80 folgt aber immer dieselbe Frage: aus einem großen Projekt oder aus fünf kleinen, und was davon lässt sich abgeben? Genau das beantwortet das Band.

  • Gerechnet wird nichts Zusätzliches. stats[key].projects hält {anzahl, stunden, nr, quelle} statt nur einer Zählung — derselbe Wert, der ohnehin aufaddiert wird, wurde bisher nur verworfen. Die Segmentbreiten sind Anteile der Wochenkapazität, sodass ein volles Band eine volle Woche ist. Segmente aus einer geschätzten Projektgröße tragen in der Legende ein ≈.
  • projektFarbe(name) hasht den Projektnamen in PROJEKT_FARBEN, statt nach Position zu vergeben: Die Auswertung läuft auch über alle Sitzungen, in denen ein Projekt an wechselnder Stelle steht — eine Farbe, die je Sitzung springt, wäre schlimmer als keine. Die Palette ist bewusst gedämpft und vom Ampelspektrum getrennt (ein Segment sagt, WELCHES Projekt, nicht ob es gut steht) und trägt weiße Schrift mit mindestens 4,5:1.
  • Beschriftung erst ab 7 % der Bandbreite, sonst bliebe von "21-4001" ein angeschnittenes "21-40" stehen. Schmale Segmente behalten Farbe, Tooltip und ihren Eintrag in der .load-legende, die nur die tatsächlich vorkommenden Projekte auflistet.
  • Eine Null bekommt KEINE Ampelfarbe (--text-subtle statt Grün): Grün hieße "gut ausgelastet", die Null heißt aber "nichts zugeordnet". In einer Sitzung mit ungepflegten Zuordnungen stünden sonst dreizehn grüne Nullen untereinander und meldeten Entwarnung, wo nur Daten fehlen — im Bestand genau der Fall.
  • loadAmpelColor() liefert Design-Tokens (var(--red) …) statt der früheren Festwerte. Die Ampel färbt jetzt die große Zahl am Zeilenende, und #c53030 kam auf schwarzem Grund nur auf 3,6:1; mit dem Token sind es 6,2:1. pdfLoadAmpelColor() behält seine druckfreundlichen Festwerte — ein PDF hat kein Thema.
  • Innerhalb einer Rolle wird absteigend nach Auslastung sortiert: Wer zu viel hat, steht oben, und die Zeilen ohne Zuordnung sammeln sich unten, statt die Liste zu durchsetzen.
  • .load-card/.load-row/.load-num/.load-detail bleiben bestehen — das Dashboard (js/dashboard/dashboard.js) und die Ressourcenansicht des Projektbereichs (js/plan/plan-gantt.js) bauen darauf auf. Der Show Mode kopiert das gerenderte HTML und zeigt das Band deshalb ohne Änderung; die drei .show-mode-content .load-*-Regeln wurden auf die Zeilenform umgestellt.

Projektbeteiligte (global users, cross-department staffing)

Removed from the sidebar navigation (on Erik's request): once departments stopped partitioning weeks/projects/people, this tab's original job — pulling a Beteiligter from an ELT/HLS/ARCH-specific roster into a department-scoped session — no longer applied to a single shared roster, and what remained (a read-only list) duplicated the role/avatar chips already visible in Wochenbericht/Board/Auslastung. The .nav-item.tab[data-tab="people"] button in App.html is gone; the tab panel (#people), its module (js/people/people.js) and its deep-link slug (Projektbeteiligte in VALID_TAB_SLUGS) are all still there and still reachable by URL — only the sidebar entry point was removed, not the feature itself. The bullets below describe that still-live feature; where they mention department-scoped rosters, treat that as unverified against current reality (see "Departments were retired").

  • The per-department "Projektbeteiligte" concept — its own people/ person_roles/person_absences rows with client-side CRUD/drag-reorder — has been fully replaced by the global users table (see Persistence for the migration). db.globalPeople[department] is now populated purely by the server: loadGlobalPeople(department) (server.js) reads users/user_roles/user_absences filtered to department and active=1, so the Projektbeteiligte tab (js/people/people.js, renderPeople()) is read-only — no add/edit/delete/reorder form, no drag handles. Only admins maintain team members, exclusively through admin-users.html (department is a required field there, initials is the board-column label, roles are the discipline roles).
  • Cross-department staffing: a project's team is not restricted to its department's own users. In the Projektbeteiligte tab, an admin (UI-gated only, see Auth section) on an unlocked week sees a "+ Person aus anderer Abteilung zur aktuellen Sitzung hinzufügen" picker (loadCrossDeptUserOptions()/ addSelectedUserToCurrentWeekRoster() in js/people/people.js), sourced from GET /users filtered to department !== currentDepartment, a set personId, and not already in currentWeek().people. addUserToCurrentWeekRoster(user) pushes a normal person object ({id, name, fullName, email, role, absences:[]}, built from the chosen user) directly into currentWeek().people and calls the ordinary save() — no new endpoint needed, since week.people is already part of the whole week JSON that /save-week writes. Because board.js's renderBoard()/ peopleByRole()/cycleAssign() and load.js iterate exclusively over currentWeek().people (never db.globalPeople[dept] directly), the added person immediately shows up as its own assignable board column — no changes were needed in board.js/load.js. renderPeople() shows such entries with an "(andere Abteilung)" hint and a "✕ Aus dieser Sitzung entfernen" button (removeForeignUserFromCurrentWeekRoster()), which refuses to remove a person still assigned to a project in that week (same guard the old delPerson() had). cloneWeek() carries these cross-department additions into the next week automatically (it copies week.people verbatim); newWeek() (a fresh start) does not — the roster resets to the department's own home users, which is intentional (with an explanatory hint in the UI).

Planung — projektunabhängige Terminplan-Sandbox (tab planung, js/planung/planung.js)

Der Menüpunkt heißt „Terminplan“ (Eriks Wunsch), der Abschnitt der Seitenleiste darüber weiter „Planung“. Ebenso „Terminplan“: die Überschrift der Seite und das Recht wz.planung. Intern bleibt alles planung (Tab, Dateien, URL /Planung).

Ein zweiter, eigener Abschnitt der Seitenleiste (nicht Teil von "Navigation", zwischen Auslastung und Einstellungen) — eine Sandbox, in der sich Terminpläne unabhängig von einem echten Kundenprojekt anlegen lassen: entweder aus einem Honorar errechnet (derselbe Rechner wie bei einem echten Projekt) oder frei von Hand begonnen. Jeder angemeldete Nutzer darf das, keine Admin-Sperre. Mit voller Terminplan-Funktionalität — Drag&Drop, Abhängigkeiten, kritischer Pfad, Auto-Schedule, Zoom — weil ein Sandbox-Plan technisch ein ganz normales Projekt ist, nur an einer besonderen Stelle.

Architektur: eine reservierte Sitzung statt eines Flags. Ein erster Ansatz (ein Flag auf einem Projekt der jeweils aktuellen Sitzung) wurde verworfen: cloneWeek() (public/js/weeks.js) vergibt beim Duplizieren einer Sitzung jedem Projekt eine neue id — ein Sandbox-Plan verlöre bei jeder neuen Sitzung seine Identität. newWeek() startet dagegen mit projects:[] — ein Sandbox-Plan in der "aktuellen Woche" verschwände beim nächsten newWeek() einfach. Und applyAutoLock() sperrt jede Woche außer der zeitlich letzten — ein Sandbox-Plan würde unvermittelt schreibgeschützt. Stattdessen: eine einzige, dauerhaft reservierte "Sitzung", SANDBOX_WEEK_LABEL = '__sandbox__' (identisch in server.js und js/globals.js — kein gemeinsames Modulsystem zwischen Server und Client). ensureSandboxWeek() (server.js, läuft bei jedem Start wie die übrigen Migrationen) legt die weeks-Zeile per INSERT OR IGNORE einmalig an. Jeder Sandbox-Terminplan ist eine ganz normale Zeile in projects/project_plan/plan_todos/plan_milestones/… mit diesem week_label — keine neue Spalte, keine Migration an projects (bis auf updated_at, siehe unten), loadWeeks()/serializeWeek() bleiben unverändert.

Der entscheidende Vorteil: strukturelle statt gemerkter Isolation. GET /load-all (server.js) spaltet loadWeeks()s Ergebnis auf — die Sandbox-Woche wird NICHT als Teil von weeks[] ausgeliefert, sondern separat als sandboxPlans (nur ihre .projects) plus sandboxUpdatedAt. Der Client baut daraus in load() (js/store.js) ein eigenes, synthetisches Objekt db.sandboxWeek — niemals Teil von db.weeks. Weil renderBoard() (board.js), berichtProjekte() (wochenbericht.js), berechneAuslastung() (load.js), exportWochenberichtPDF() (pdf.js), renderAdminSitzungen() (admin.js) und jeder künftige Code, der db.weeks durchläuft, die Sandbox-Daten also strukturell nie sehen können, gibt es keinen Filter, den man in sechs Dateien hätte vergessen können — anders als bei einem Flag-Ansatz. Nur an den wenigen Stellen, die db.weeks selbst zusammensetzen oder adressieren, gibt es einen expliziten, kleinen Zweig:

Achtung, echte Ausnahme dieser Garantie: Sie gilt nur für den Weg über GET /load-all (also für db.weeks clientseitig). Jeder serverseitige Code, der loadWeeks() selbst ein zweites Mal aufruft, statt sich auf db.weeks zu verlassen, braucht denselben .filter(w => w.label !== SANDBOX_WEEK_LABEL) direkt nach dem Aufruf erneut — sonst bekäme die reservierte Sandbox-Woche dort eine eigene Zeile, als wäre sie eine echte Sitzung. Vor jeder neuen serverseitigen Route, die loadWeeks() aufruft, lohnt sich deshalb ein kurzer Blick auf diesen Abschnitt.

  • syncAllToServer() (store.js): label === SANDBOX_WEEK_LABEL löst zu db.sandboxWeek statt zu db.weeks.find(...) auf — ohne diesen Zweig würden Sandbox-Änderungen lautlos verworfen (dirtyWeeks.delete(), ohne je zu speichern), weil db.weeks.find() die Sandbox nie findet.
  • openPlanView() (js/plan/plan-state.js): eigener Zweig für app.weekLabel === SANDBOX_WEEK_LABEL, der app.week = db.sandboxWeek setzt und das Projekt über die stabile id sucht statt über nr/name wie bei einem echten Projekt (openSandboxPlan(planId), app.sandboxPlanId) — Sandbox-Projekte werden nie geklont, ihre id ist also im Unterschied zu einem echten Projekt dauerhaft stabil, und sie haben kein nr, also auch keine sinnvolle nr/name-Adressierung.
  • applyAutoLock() (weeks.js): filtert SANDBOX_WEEK_LABEL heraus, bevor "welche Woche ist die späteste" berechnet wird — ein reines Sicherheitsnetz, da die Sandbox-Woche strukturell ohnehin nie in db.weeks ankommt.
  • validateWeekLabel() (weeks.js) lehnt den reservierten Namen als von Hand eingegebene Sitzungsbezeichnung ab; /delete-week//unlock-week (server.js) lehnen ihn mit 400 ab — die reservierte Woche ist über die normale Sitzungsverwaltung weder löschbar noch entsperrbar.
  • updateRouteUrl()/applyUrlRoute() (js/ui.js) und initUiState() (js/init.js): die Deep-Link-Adresse eines Sandbox-Plans trägt planId statt projNr (/Projektplan/<Name>?weekLabel=__sandbox__&planId=…), weil ein Sandbox-Plan kein nr hat.

projects.updated_at (neue, idempotente Migration migrateAddProjectUpdatedAt(), gleiches Muster wie migrateAddProjectGroesse()) ist die einzige neue Spalte — nötig, weil mehrere, voneinander unabhängige Sandbox-Pläne in EINER Woche liegen und sich "zuletzt bearbeitet" bis dahin nur je WOCHE unterscheiden ließ (weeks.updated_at). Für echte Projekte harmlos mitgeführt, aber ungenutzt. Befüllt in serializeWeek()s bestehendem insProject.run(...).

Welche Projektfelder ein Sandbox-Plan nutzt: name (einzige Identität, Pflichtfeld, keine Duplikate — geprüft in erstellePlanungTerminplan()), honorar + beauftragteLP (treiben den vorhandenen "Terminplan aus Honorar"-Rechner, plan-honorar.js, unverändert), der komplette plan-Baum (Todos/Meilensteine/Abhängigkeiten/Beteiligte). NICHT genutzt: nr, group, auftragsstatus, groesse, project_gewerke — jeweils an echte Geschäftslogik gebunden, die für eine Sandbox keinen Sinn ergibt.

Wiederverwendeter Projektbereich, mit ausgeblendeten Abschnitten. openSandboxPlan(planId) führt in denselben Projektbereich (#plan), der auch für echte Projekte dient — keine zweite Gantt-Implementierung. Ein zentraler Merker app.isSandboxPlan() (plan-state.js, neben dem bestehenden app.isLocked()) blendet aus: die Bereiche Dateien (kein nr, kein sinnvoller Prüf-Workflow), Leistung (echte Abrechnung/Auftragsstatus-abhängig) und Protokolle (hängt wie Dateien an project.nr) ganz (plan-nav-Knöpfe versteckt, initPlanSections() in plan-sections.js fängt zusätzlich einen direkten #dateien/#leistung/#protokolle-Link ab und fällt auf Dashboard zurück); in Einstellungen (plan-settings.js) die Felder Projektnummer, Auftragsstatus, Gewerke, Projektgröße — Honorar, beauftragte Leistungsphasen und die Beteiligten-Matrix bleiben sichtbar. Die Kachel „Kommentar der Sitzung“ zeigt dort keinen Verlauf (eine nie geklonte Sitzung hat keine früheren Fassungen). Die Kopfzeile zeigt bewusst NICHT das rohe week.label ("sandbox", ein interner Marker) als Untertitel, sondern "Planung — unabhängig von einem laufenden Projekt".

Anlegen — bewusst kein mehrstufiger Assistent wie "Neues Projekt" (der verlangt Gewerke/Team/Auftragsstatus, die hier nicht zutreffen), sondern ein schlanker Dialog (#neuerTerminplanModal, erstellePlanungTerminplan()): Name + Wahl zwischen "Aus Honorar berechnen" (landet in Einstellungen mit fokussiertem Honorar-Feld; der Nutzer nutzt danach den bereits vorhandenen "Terminplan aus Honorar"-Knopf dort — kein eigener Rechen-Code) und "Leer beginnen" (landet direkt im leeren Terminplan).

Bekannte, unveränderte Alteigenheit: saveWeekRelational() löscht und schreibt beim Speichern die komplette Woche neu — bearbeiten zwei Personen gleichzeitig zwei verschiedene Sandbox-Pläne, kann es zu einem 409-Konflikt kommen, obwohl sich die Änderungen inhaltlich nicht überschneiden. Keine neue Schwäche dieses Features, sondern dieselbe Begrenzung, die heute schon gilt, wenn zwei Personen zwei verschiedene echte Projekte derselben Woche gleichzeitig bearbeiten.

Einstellungen (tab settings, js/settings/settings.js)

  • This area belongs to the signed-in account itself and holds "Mein Konto" plus, in a second card, its own "KI-Zugangsdaten" — the account's personal OpenAI/Claude API key (+ optional API-URL override) for the Protokoll-Import's KI-gestützte Erkennung, see "### Protokolle" for the full user_ai_keys/GET+PUT /my-ai-settings mechanism. "Mein Konto" itself holds master data for display, the account's own e-mail address, and the password change. Display name, role and personId stay an admin's job via PATCH /admin/users/:id; the area calls out a missing personId explicitly, since that silently empties "Meine Aufgaben" (GET /my-dashboard matches on plan_todo_assignees.person_id). E-mail is the one field the account edits itself (inline input next to the value, submitEmailChange() → POST /change-email, see Server API) — it used to be admin-only like the rest of that group, but unlike display name or role it is purely contact information the account owner is best placed to keep current, and making someone wait on an admin just to enter their own address was needless friction. An admin can still set/correct it via the Benutzerverwaltung, e.g. for a colleague who hasn't logged in yet.
  • Everything company-wide used to sit here next to it. That mixed two different things in one list — your own account details, which concern everyone, and defaults that shift everyone's numbers and belong to admins alone. The latter moved to the Admin area below.
  • Until this area existed the app had no settings page at all. The sidebar group labelled "Einstellungen" contained only actions (Neue Sitzung, Sitzung duplizieren, PDF-Export, Show Mode, CSV-Export) and is therefore now labelled "Werkzeuge" (accordion key tools, ids toolsAccordionToggle/ toolsSubnav).
  • CSV-Export removed entirely (route, js/csv.js, the "CSV Export" button) — on Erik's explicit call: with a real SQLite database behind the app now, a standing CSV export of the same data no longer earns its place; GET /backup already covers taking the data out wholesale. Unlike the Projektbeteiligte nav removal, this was a full removal, not just an unlinking — no route, no deep link, nothing left to find by URL. buildExportCsvs()/buildZip()/csvFile()/csvCell()/CSV_SEP (all server.js) went with it.
  • "Neue Sitzung"/"Sitzung duplizieren" removed as well, for an unrelated reason: see "Automatischer Sitzungswechsel" below — creating a session is no longer a user action at all.
  • "PDF Export" (exportPDF()) removed too — on Erik's call, PDF export should live only in the Wochenbericht and in Planung-Terminpläne, not as a standalone whole-session export. Along with the button and the function itself, pdf.js lost every helper exclusive to it (pdfRoleBg(), pdfTableLayout(), pdfHeaderRows(), pdfProjectRow(), pdfGroupTitleRow(), pdfColumnWidths(), pdfCheckWidth(), pdfAttendanceBlock(), pdfLoadSection(), and the color constants PDF_ROLE_BG/PDF_ALT_ROW_BG/PDF_PAD_X/PDF_LINE_W) — everything the remaining two exports (exportWochenberichtPDF()/exportSandboxPlanPDF()) still share (PDF_STYLES, PDF_NEUTRAL_BG, page geometry, pdfStatusText(), pdfNormalizeColor(), loadLogoPng()) stayed. This was the one export that showed every project across every Gewerk in a single file — that capability is gone with it; the per-Gewerk Wochenbericht PDFs are now the only filed record. The whole „Werkzeuge“ group is gone from the sidebar (Eriks Wunsch): new projects are created via „+ Projekt“ in the project list, and only the Wandtafel account needs the Wandtafel (it lands there automatically via showTab(); anyone else can still open /Wandtafel directly).

Login-Seite (#loginOverlay)

Full-screen page, not a dialog: design "Kalenderwoche" (Erik's pick B from three Taste-Skill drafts). The logo is a large, pale watermark across the whole page (.login-wasserzeichen, /logo.png converted from logo.ico; Erik's pick E from a second comparison of logo placements) — deliberately no small logo. Left: heading and form; right: the current ISO calendar week as a large number with Mo-Fr and today highlighted (renderLoginWoche() in js/auth.js, called by showLoginOverlay()) — the app thinks in weekly sessions, so the week is what you see before logging in. Form has inline errors (.login-feld.hat-fehler, #loginError), a loading state on the button, "Anzeigen" for the password and a caps-lock hint (verdrahteLoginHilfen()). Styles: "LOGIN-SEITE" block at the end of styles.css, app tokens plus --login-zahl/--login-jahr. The number uses the web font Outfit (same Google-Fonts link as the Wandtafel, falls back to the system font offline). z-index: 400, above .modal-bg (300).

Admin (tab admin, js/admin/admin.js)

  • Everything only admins may do, in one place. Split into sections, one visible at a time, switched from the sidebar: "Admin" is an accordion (#adminAccordionToggle/#adminSubnav) whose sub-items call showAdminSection(). ADMIN_SECTIONS is the single place a section is defined (key, heading); the binding to the markup runs through the data-admin-section attribute on both the card in App.html and the sidebar item. The chosen section lives in the URL fragment (/Admin#auslastung) — same pattern as .plan-nav in the Projektbereich — which is why updateRouteUrl() keeps location.hash for admin.
  • Two deviations from the "Werkzeuge" accordion, both deliberate: its head entry also navigates (the sub-items are in-page navigation, not actions, so a head that shows nothing would mislead) — from elsewhere a click always enters the area and expands, only from inside does it toggle; and its sub-items carry a persistent .is-active highlight. syncAdminNavState() in js/tabs-auth.js marks the head active while the area is open, because it is no longer a .tab button and the generic highlight does not reach it.
  • showTab('admin') redirects non-admins to DEFAULT_TAB: without it a forwarded /Admin link showed a member an empty page, since renderAdminArea() renders nothing for them.
  • Sections:
    • Benutzerverwaltung — accounts, roles, weekly hours, absences (js/admin-users.js, renderAdminUsers()). Row actions: "Bearbeiten" stays a visible button, everything else sits behind a "⋯" menu (openUserActionMenu(), Erik's pick "C" from a three-way comparison): Passwort zurücksetzen, De-/Aktivieren, and — only for deactivated accounts — Endgültig löschen. Irreversible actions confirm inside the menu instead of confirm(); the action functions take an ohneRueckfrage flag for that. The menu is appended to <body> with position: fixed (the table's horizontally scrolling frame would clip it) and flips upward when there is no room below. Neue, geänderte und reaktivierte Konten landen sofort im Teilnehmerkreis der aktuellen Sitzung (kontenInAktuelleSitzung(), server.js; Kürzel, Name und Fachrollen werden dort abgeglichen, ältere Sitzungen bleiben unberührt) — beim Serverstart zusätzlich für alle aktiven Konten. Vorher entstand week_people nur beim Sitzungswechsel; auf einer frischen Datenbank, deren Sitzung vor den Konten angelegt wurde, bot der Assistent „Neues Projekt" niemanden an. Vor- und Nachname are separate fields and columns (users.first_name/last_name, migration migrateUsersVorNachname()); display_name stays and is always written as "Vorname Nachname" by POST/PATCH /admin/users* — every other place still reads display_name; existing accounts got Vor-/Nachname once from display_name (first word / rest). The username is the local part of the e-mail address (e.dunin) — Erik first wanted "Vorname Nachname", then reverted to this before it ever reached the real database, so existing usernames were never renamed. In the new-user dialog the username follows the e-mail field (benutzernameAusEmail()), which itself follows Vor-/Nachname; each field stops following once edited by hand. Person-IDs of new accounts come from the Kürzel (p_mk, personIdAusKuerzel()), with a digit appended if taken (p_mk2) — the scheme every existing account and its project data already use. Before, the dialog derived them from the full name (p_miriam_k_rner), which split one person into two ids (account vs. project data). /login matches COLLATE NOCASE and also accepts the full address (everything from "@" is dropped); POST /admin/users checks uniqueness case-insensitively (409). The e-mail field pre-fills v.nachname@rauschenberg-ing.de from Vor-/Nachname (emailVorschlag()/EMAIL_DOMAIN, js/utils.js; umlauts transliterated) while not edited by hand — same pattern as Person-ID/Kürzel; the edit dialog and Einstellungen → Mein Konto only show it as a placeholder and never overwrite a stored address; reloaded from the server every time the section is entered, since the list changes outside this browser and the other masks compute with it (weekly hours → Auslastung). Marked wide: true in ADMIN_SECTIONS, which lifts the grid's width cap — the table has ten columns.
    • Honorar — stundensatz + the per-LPH shares (PUT /honorar-config). A running sum turns amber unless the shares add up to 100 %.
    • Auslastung — vollzeitStundenProWoche, the ampel thresholds, referenzHonorar, verteilzeitraumWochen, and aufwandVorlage (the per-LPH/role weighting that applies to every project without its own matrix — i.e. most of them, which is why it quietly determined a large part of the Auslastung while it was a code constant).
    • Termine & Sitzungen — arbeitsstundenProTag and autoLockAktiv. Switching auto-lock off does not retroactively unlock: already-locked weeks stay locked, unlocking remains a deliberate admin action (POST /unlock-week). Also holds the "Sitzungen" list (renderAdminSitzungen(), js/admin/admin.js) — every week with its lock state and, for anything that isn't the one open week, an action to reach it. This replaced the week <select> that used to sit in the topbar (#appTopbarWeek, removed): sessions are storage and history now, not something the whole UI asks you to pick from. In normal operation there's exactly one row without an action (the open week, db.currentWeek, always the chronologically latest under applyAutoLock()); everything else is Gesperrt (Entsperren → unlockWeek()) or Entsperrt (Öffnen → switchWeek() + jump to Board — the one place left that can put a week other than the latest into db.currentWeek). Deleting a session also moved here (Löschen → deleteWeekById()) from its old home in Datenbank & Sitzungen, which depended on "the week chosen in the topbar" and had no other way to name a target once that selector was gone. The unlock window is shorter than it looks: unlockWeek() only flips that one week's flag, but applyAutoLock() re-locks every week except the latest, and runs again on deleteWeekById() — not just on page reload. Deleting an unrelated session while a correction is in progress silently re-locks it before the fix is saved. Since the automatic Sitzungswechsel (see "Automatischer Sitzungswechsel" above), there is a second, independent trigger for the same outcome: ensureCurrentWeekRollover() re-locks every other real week itself, server-side, the moment the calendar rolls into a new ISO week — so an in-progress correction on an older week can now also get silently re-locked purely by time passing, with nobody clicking anything at all. Both respect app_settings.autoLockAktiv. Surfacing "Entsperrt" as its own row state only made this pre-existing risk visible, not new.
    • Feiertage — the holidays table, plus a "gesetzliche Feiertage übernehmen" mask that computes a year's public holidays for a chosen state and lets the admin take them over (see /holidays/suggest above). It flags when the current or next year has no day stored at all — otherwise missing holidays only surface via a wrongly computed schedule.
    • Aufgaben-Vorlagen — je Gewerk und Leistungsphase (Eriks Wunsch): lph_templates.gewerk (VALID_DEPARTMENTS, Migration migrateAddLphTemplateGewerk(); '' = Altbestand, gilt für alle Gewerke). Oben Gewerk-Umschalter (mit Anzahl) und Leistungsphase (mit Anzahl), darunter die Schritte mit direkt bearbeitbarer Bezeichnung, Start und Dauer (PATCH /lph-templates/:id, gespeichert beim Verlassen des Feldes), ↑/↓ (POST /lph-templates/:id/verschieben {richtung}, innerhalb von Phase und Gewerk) und × (DELETE). POST /lph-templates verlangt ein Gewerk. Alles requireRole('admin'). Im Dialog „+ Vorlage“ (plan-templates.js) steht zusätzlich die Auswahl Gewerk — vorgewählt das eigene, wenn das Projekt es hat, sonst das erste Gewerk des Projekts; Gewerke außerhalb des Projekts tragen „(nicht im Projekt)“. Der Admin-Verweis im Dialog führt nach Admin → Aufgaben-Vorlagen. Vorbelegt mit den HOAI-Grundleistungen (HOAI_GRUNDLEISTUNGEN, hoaiVorlagenEinfuegen() in server.js, einmalig über einmaligeUmstellung('hoai-aufgabenvorlagen'), nur für Gewerke ohne eigene Vorlagen): ARCH nach Anlage 10.1, ELT und HLS nach Anlage 15.1, gekürzte Titel mit HOAI-Buchstabe, 177 Schritte. Ohne Dauer (Eriks Vorgabe): duration_days = 0 heißt „ohne Termin“ — „+ Vorlage“ fügt solche Schritte ohne Start/Ende ein und verlangt dafür kein Startdatum. Die Prozentanteile der Siemon-Tabelle sind NICHT hinterlegt (lagen nicht vor). Anteil % je Schritt (Eriks Wunsch, z. B. nach Siemon-Tabelle): lph_templates.anteil (REAL, Migration in migrateAddLphTemplateGewerk(), templateAnteil() prüft 0–100, Komma erlaubt). Spalte „Anteil %“ in der Admin-Maske mit Summe je Phase. Beim Einfügen (templatePlan() in plan-templates.js) bekommt jeder Schritt mit Anteil Anteil ÷ Summe ALLER Schritte der Phase an der „Dauer der Phase“ (Arbeitstage), nacheinander ab dem Startdatum; abgewählte Schritte lassen ihre Lücke. Startdatum und Dauer der Phase kommen aus dem Phasenbalken der Leistungsphase (templatePhaseVorbelegen()), sonst von Hand. Ohne Anteil gelten Start/Dauer der Vorlage, ohne beides ohne Termin. Rundung über die laufende Summe — der letzte Schritt endet genau am Phasenende. Siemon-Werte hinterlegt (SIEMON_ANTEILE in server.js, Quelle: Siemon-Tabellen zur HOAI 2013, ibr-online Okt. 2013, Gebäude und Technische Ausrüstung): je Grundleistung der MITTELWERT der Spanne „von–bis“; eingetragen einmalig von siemonAnteileEintragen() (einmaligeUmstellung('siemon-anteile')) über den Buchstaben am Titelende, nur in Schritte ohne Anteil. „in a) enth.“ steht in der neuen Spalte lph_templates.enthalten_in ('a' bzw. 'a,b'): solche Schritte laufen beim Einfügen zeitgleich mit a) (frühester Beginn bis spätestes Ende der genannten Schritte, zweiter Durchgang in templatePlan()). In der Admin-Spalte „Anteil %“ als „in a)“ angezeigt und so auch eingebbar (templateAnteilEingabe()). Umgestellt auf das Siemon-Gutachten zur HOAI 2021 (Eriks Meldung „die Werte sind nicht richtig“; Anlagenband zum Gutachten 2021-1, Anlage 1.1 Gebäude, 1.7 Technische Ausrüstung): ein fester Wert je Grundleistung, Summe je Phase = HOAI-Prozentsatz, kein „enthalten in“ mehr. SIEMON_ANTEILE enthält jetzt diese Werte; SIEMON_2013 nur noch zum Erkennen unveränderter Altwerte. siemon2021Umstellen() (einmaligeUmstellung('siemon-anteile-2021')) ersetzt nur Schritte, die noch genau den 2013 eingetragenen Wert tragen.
    • Betrieb & Sicherheit — maxBackups, sessionTage, loginMaxVersuche, loginSperreMinuten. The session lifetime applies to new logins; existing sessions keep their stored expires_at.
    • Datenbank & Sitzungen — DB Backup, DB Import, Sitzung löschen. These three used to be bare sidebar menu entries where a mis-click was enough; they now sit behind an explanation of what each one does.
  • Honorar and Aufgaben-Vorlagen were moved out of project dialogs. They used to sit inside #honorarPlanModal (js/plan/plan-honorar.js) and #lphTemplateModal (js/plan/plan-templates.js) — findable only by someone who happened to be computing a schedule. Those dialogs now only use the values and show admins a link here (renderHonorarHint()/ renderTemplateHint()). Since plan-honorar.js caches the config for the whole page lifetime, saveHonorarConfig() calls invalidateHonorarConfig() afterwards — otherwise a dialog opened later in the same session would keep computing with the old rate.
  • Honorar, Auslastung, Termine and Betrieb used to be constants in the source — partly in the frontend (LP_AUFWAND_VORLAGE, THRESHOLD_RATIO_*, LOAD_BAR_MAX, STUNDEN_PRO_TAG), partly in the server (MAX_BACKUPS, SESSION_MAX_AGE_MS, LOGIN_MAX_ATTEMPTS, LOGIN_BLOCK_MS). They moved here not because they change often but because they shift the numbers everyone sees, which makes maintaining them an administration task rather than a source edit. All of those identifiers are gone; the client reads the values from APP_SETTINGS (js/globals.js), filled by loadAppSettings() in js/store.js before /load-all, since renderAll() already computes with them. If that fetch fails the defaults in js/globals.js stand — deliberately identical to APP_SETTING_DEFS, so the app keeps computing as it did instead of showing wrong numbers.
  • Part-time capacity. users.capacity_hours (maintained per account in admin-users.html) is what the Auslastung now measures a person against: kapazitaetsFaktor() in js/load/load.js divides the raw weekly load by capacity_hours / vollzeitStundenProWoche, so a half position counts as fully loaded at half the work. 0 = not maintained = full position, so existing accounts keep their previous picture. kapazitaetsFaktor() takes the person OBJECT, not just its id: a week's participants are frozen copies (week_people) whose id dates from when they were created — legacy data has p_ap where the account now carries p_alexander_plum, so the id alone would never find them and the full name is the second bridge. js/pdf.js applies the same factor, otherwise screen and PDF would report different numbers for the same part-timer.
  • "+ Neues Projekt" is no longer an admin entry — it moved to "Werkzeuge". Creating a project goes through the ordinary week save (POST /save-week, requireAuth); the server never required the admin role for it, so the gate was purely cosmetic and merely kept members from ordinary work.
  • The area being hidden for non-admins is a UI gate only, like everywhere else in this app; the real barrier is requireRole('admin') on PUT /app-settings, PUT /honorar-config, POST/DELETE /lph-templates, POST/DELETE /holidays, GET /backup, POST /import and POST /delete-week (verified: a member account gets 403 on each and the stored values stay unchanged).

Wochenbericht — die Tagesordnung

  • The Wochenbericht is laid out as the agenda of the weekly team meeting: the full project list on the left, the selected project full-size on the right. It replaced a stack of 29 open cards that ran to 18 screens (11.6 after the cards were compressed) — now 1.1. The list stays put, so you always see where you are and what is still to come.
  • Ordered by project group (GROUP_ORDER, like the project list), no longer by kind of change: every project gets discussed whether it changed or not, and sorting by change would force jumping between sections. What changed is still computed (classifyProjectChange()) and shown — as a dot in the list and a label in the detail, not as a sort key.
  • berichtAuswahl holds the project_uid, not an index: saving a comment re-renders the list, and an index would point at a different project if the order changed.
  • renderBerichtListe() is separate from renderWochenbericht() because the jump field redraws only the list — the detail, and above all a comment being typed, must survive.
  • The eight functions, all built in:
    • Besprochen-Haken + "12 von 29 besprochen" (projects.besprochen, saved through the ordinary save() path).
    • Besprochen & weiter — ticks off and jumps to the next open item, searching behind the current position first, then from the top. Daneben „Weiter →“ (berichtWeiter()/berichtNaechstes(), Eriks Wunsch): springt nur zum nächsten Projekt der Liste (wie ↓), ohne Haken; auch in gesperrten Sitzungen, am Listenende gesperrt.
    • Keyboard: ↓/↑ switch project, Ctrl+↵ saves the comment and moves on. Bound at document level but gated on currentTab === 'wochenbericht'.
    • Kommentar-Formatierung (richtextToolbarHtml(), js/utils.js, shared with the Protokoll-Punkte): Fett/Kursiv/Farbe toggle — a second click on a selection that fully carries it removes it again (richtextUmschalten()/richtextArtEntfernen(), splitting a surrounding element so text before/after keeps its format); another colour recolours instead of nesting. "Standard" (richtextFormatLoeschen()) strips all formatting from the selection, or from the whole field without one. Enter in the comment starts the new line with "- " (richtextNeueZeileMitStrich(), nbsp because Chrome drops a trailing normal space); deletable like any text, Shift+Enter stays the browser's plain line break.
    • Vorwochen-Zeile — "Zuletzt KW …: " above the input field (renderBerichtZuletzt()), the latest earlier comment of the chosen Gewerk — WITH its formatting (bold/italic/colour/lines, sanitized HTML, at most five lines), on Erik's request; the Verlauf entries keep it too. Replaced the scroll wheel renderVorwocheRad() (removed), see "Verlauf der Vorwochen" below.
    • Aufgabe aus Kommentar — turns a decision into a real plan_todo (title suggested from the unsaved field content, since in a meeting you type the decision and create the task before saving).
    • Protokoll — what changed versus the previous session plus the tasks created today, as text to copy or mail. The mailto route refuses overlong bodies rather than opening a truncated draft. Not the same thing as the PDF below: the Protokoll is a delta (what changed + what's new), meant to be pasted somewhere else; the PDF is the full agenda as a standalone file.
    • „Als PDF ablegen" (exportWochenberichtPDF(), js/pdf.js) — one of the two remaining PDF exports (the other is exportSandboxPlanPDF() for Planung-Terminpläne; the third, the board's own gewerkeübergreifende A3 session-overview exportPDF(), was removed — see "Werkzeuge" above). The Wochenbericht is itself two-part, A4 portrait: page 1 is a lined overview table per group (change dot, Nr., Name, Änderungsgrund, amtierende LPH + Fortschritt, Besprochen-Haken — pdfWbUebersichtBlock()) followed by the Anwesenheit der Sitzung as its own gridded table (pdfWbAnwesenheitBlock(), reading w.attendance/w.people exactly like the on-screen renderAttendance(): a missing entry counts as not present, same as an explicit false — there is no third "not yet recorded" state, so the count is literal even when nobody has been marked yet). Page 2 onward ("Vollständiger Bericht") is one bordered Akte per project with a left spine colored by classifyProjectChange()'s bucket (PDF_WB_CHANGE_COLORS: new/status/progress/unchanged), two columns inside — comment on the left via pdfStatusText() (same bold/color fidelity as the screen), a gridded Leistungsphasen-table and Beteiligte-table on the right (pdfWbLphMiniTable()/ pdfWbBeteiligteTable(), falling back to all LPHS when none are beauftragt — same rule as renderBoardDrawer()), plus who's assigned but marked absent (abwesendeBeteiligte()). Under the comment sits the latest EARLIER comment of the same Gewerk as a grey box (pdfWbVorigerKommentar(), Erik's request) — same source as the screen's "Zuletzt KW …" (berichtVerlaufDaten()), titled "Vorwoche KW …" when it is from the directly preceding session, else "Zuletzt KW …", with author and formatting. The amtierende-LPH value is computed once (pdfWbAktuelleLphInfo(), same lpActiveList() the Auslastung uses) and read by both the overview row and, indirectly, the Akte — so the two pages can never show conflicting figures for the same project. Reuses PDF_STYLES/pdfStatusText()/loadLogoPng() from the existing module. pdfHslToHex() is the one color helper: it lets the browser itself convert getProgressColor()'s hsl() output (pdfmake doesn't understand hsl()) into the hex pdfNormalizeColor() already handles, so the bar in the PDF is the exact same color as on screen instead of a second, invented scale. The overview/attendance/mini-tables all share one grid layout (pdfWbGridLayout()) so the whole document reads as tables throughout rather than mixing ruled and unruled blocks.
    • Sprung-Feld — filters the list only while something is typed; the agenda must stay complete.
    • Abwesenheits-Hinweis („Heute abwesend: Name (Urlaub bis 28.10.) — eventuell vertagen“, auch im PDF) — liest seit Eriks Wunsch die eingetragenen Abwesenheiten der zugeordneten Personen (db.globalPeople[].absences, Benutzerverwaltung) für den heutigen Tag, NICHT mehr die Anwesenheitsliste der Sitzung (abwesendeBeteiligte()). Die Anwesenheitsabfrage am Fuß bleibt unverändert bestehen (Protokoll, PDF), sie ist nur nicht mehr Quelle dieses Hinweises.
  • Two bugs worth remembering, both found by measuring, not by looking:
    • The dense-card rules were scoped to .report-card, a container this rewrite removed — the card silently fell back to the Board layout (Leistungsphasen missing, people stacked). They now hang off .board-drawer-dicht itself.
    • The state class was literally status, which matched the unrelated .status { min-width: 240px } — the 7 px dot became 240 px wide and squeezed the project name to zero. State classes carry an is- prefix now.
  • The dense card is two columns again (comment | phases + people). It had three while 29 cards stood stacked and height mattered; in the agenda only one project is ever open, so height is free while width is scarce — beside the list, three columns left the people 137 px.

Wochenbericht — Aufgaben des Projekts (Eriks Wahl „B“, 11/2026)

Unter der Projektkarte, vor „Besprochen & weiter“, der Abschnitt „Aufgaben“ (renderBerichtAufgaben()/berichtAufgabenDaten() in js/wochenbericht/wochenbericht.js) mit drei Spalten: Erledigt seit KW … (jetzt done, in der vorigen GELADENEN Sitzung offen oder nicht vorhanden — Abgleich über die Aufgaben-id, ohne Vorwoche leer), Überfällig (offen, Ende vor heute), Nächste zwei Wochen (offene Aufgaben und Meilensteine bis heute + 14). Phasenbalken (t.lph) zählen nicht. Aufgaben mit Zuständigen aus dem gewählten Gewerk stehen zuerst, gezeigt werden aber alle. Nur Anzeige: ein Klick öffnet das normale Aufgaben-/Meilenstein-Panel ÜBER dem Wochenbericht (berichtEintragOeffnen() setzt dafür PlanApp.week/project; showTab('plan') setzt beides ohnehin neu); closeTodoModal()/closeMilestoneModal() zeichnen danach nur den Abschnitt und die Tagesordnung neu (berichtAufgabenNeu()), ein angefangener Kommentar bleibt. Die Tagesordnung zeigt je Projekt die Zahl überfälliger Aufgaben rot (.bericht-rail-ueber). Das PDF hat in jeder Akte die Tabelle „Aufgaben“ (pdfWbAufgaben(), höchstens fünf je Gruppe). Stile: Block „AUFGABEN IM WOCHENBERICHT“ am Ende von styles.css.

Wochenbericht — Gewerke-Trennung

Since departments stopped partitioning the data (see "Departments were retired" above), every project from every discipline sat in the same, single Tagesordnung — but ELT, HLS and ARCH hold their weekly team meeting separately, at different times, and each meeting only needs its own trade's projects, not the other two's. Completeness ("every project gets discussed") now applies within the chosen Gewerk, not across all three at once; the one place that still shows every project regardless of Gewerk is the unfiltered "Projekte" board (renderBoard()) — untouched by this feature.

  • wochenberichtGewerk (js/wochenbericht/wochenbericht.js) is a module-level let, null until resolved. Deliberately not persisted to localStorage — it resets on every page load/login, so a stale choice from a different meeting can never carry over silently.
  • Defaults from the account's own Gewerk, no dialog needed for the normal case: wochenberichtGewerkVoreinstellen() runs at the top of renderWochenbericht() and, once per page load, sets wochenberichtGewerk to currentUser.department (added to req.user in attachUser(), server.js, specifically for this) if that's a valid Gewerk — most people only ever need their own trade's meeting, so this alone gets them straight to a filtered Tagesordnung with no extra step. Only an account without a maintained department (e.g. the bootstrap admin) falls through with wochenberichtGewerk still null.
  • Changing it is a plain <select>, not a full-screen step (renderGewerkDropdown(), rendered both in the normal header via renderBerichtKopf() and, as a bare prompt, when no default could be resolved) — switching trades mid-session is a quick correction, not its own workflow, so it deliberately does NOT interrupt the page the way the first version of this feature (a blocking #gewerkWizardModal) did; that modal and oeffneGewerkWizard() are gone. Picking an option calls waehleWochenberichtGewerk(g) directly (onchange), which re-renders.
  • The filter lives in ONE place, berichtProjekte(w) itself: when wochenberichtGewerk is set, it returns only projects whose p.gewerke (from project_gewerke, populated by the "Neues Projekt" wizard's Gewerke step, js/projekt-wizard.js) includes it. Because renderWochenbericht(), renderBerichtListe(), aktuellesBerichtProjekt(), openBerichtProtokoll() (the Protokoll text export) AND exportWochenberichtPDF() (js/pdf.js) all call this same function, the filter applies everywhere at once with no separate plumbing — screen, Protokoll and PDF can never disagree about which projects belong to the current Gewerke-Besprechung. The PDF's header, footer and filename additionally spell out the Gewerk (Wochenbericht_ELT_KW_33___2026.pdf); exportWochenberichtPDF() refuses with a message if called with no Gewerk chosen (only reachable for an account with no maintained department, since everyone else gets one from wochenberichtGewerkVoreinstellen() — the guard costs nothing either way).
  • Kommentar und Besprochen-Haken sind je Gewerk getrennt (Erik: "Die Kommentare und Anwesenheiten von ELT brauchen nicht in HLS oder Arch zu sein" / "auch wenn das Projekt in ELT besprochen ist, heißt das nicht, dass HLS das Projekt auch besprochen hat"). Table project_kommentare (week_label, project_id, gewerk, status, status_author, status_at, besprochen), client shape p.kommentare = {ELT: {status, statusAuthor, statusAt, besprochen}, …}. projects.status/status_author/status_at/ besprochen are legacy: always written empty, only read by migrateKommentareJeGewerk() (startup + after /import; author's users.department if the project has that Gewerk, else its only Gewerk, else all its Gewerke). All reads/writes go through kommentarVon(p, g)/ kommentarSetzen(p, g, patch) (js/utils.js); kommentarGewerk() is the context Gewerk (wochenberichtGewerk while on the Wochenbericht tab, else null). With g null, kommentarVon() returns the combined display (each Gewerk's comment under a bold "ELT:" label, besprochen only if all Gewerke ticked it) — used by the Projekte list, Wandtafel, Projektbereich. classifyProjectChange(p, prevWeek, gewerk) and projectStatusHistory(uid, g) compare/list per Gewerk. Rollover and Papierkorb-restore reset p.kommentare = {}. project_kommentare is in IMPORT_TABLES but also in IMPORT_OPTIONAL_TABLES, so older backups without it still import.
  • Attendance is filtered per Gewerk: w.attendance is still one map per session, but renderAttendance(), setAllAttendance(), abwesendeBeteiligte(), the Protokoll text and pdfWbAnwesenheitBlock() only show/affect personenDesGewerks(w, g) — people whose account department (personGewerk(), via db.globalPeople by id or full name) is that Gewerk. People with no known Gewerk stay visible everywhere.
  • Backfill for pre-existing projects (migrateBackfillProjectGewerkeAusTags(), server.js, runs at every startup): every project that predates the Gewerke step — i.e. has zero project_gewerke rows — gets its Gewerk copied from whichever ELT/HLS/ARCH entry already sits in its project_tags (the marker migrateRemoveDepartmentPartition() left behind when it merged the former per-department copies). Without this, every pre-existing project would silently vanish from every Gewerke-filtered Tagesordnung the first time this feature shipped. Idempotent and non-destructive: it only ever inserts for a project with zero project_gewerke rows, so a manual correction in Projekteinstellungen (see next bullet) is never overwritten by a later server restart.
  • Gewerke are now correctable outside the wizard: Projekteinstellungen (renderPlanSettings(), js/plan/plan-settings.js) gained a "Gewerke" checkbox row next to Schlagwörter, saved through togglePlanGewerk() — the exact same mutate-p.field-then-savePlan() pattern as toggleBeauftragteLp() (js/plan/plan-honorar.js). Before this, a project's Gewerke could only ever be set once, during creation.

Kommentar der Vorwoche wird übernommen (aktueller Stand)

Eriks Wunsch: Jede neue Sitzung beginnt mit dem Kommentar der Vorwoche (je Gewerk), der in der Besprechung angepasst wird. ensureCurrentWeekRollover() kopiert deshalb p.kommentare (Text, Verfasser, Zeit) und setzt nur besprochen zurück — die Beschreibungen „cleared status“/„kommentare = {}“ weiter oben sind überholt. Unveränderte Übernahmen gelten in classifyProjectChange() als „unverändert“ (gleicher Text, gleiches statusAt). Einmalig hat kommentareInAktuelleSitzungUebernehmen() (einmaligeUmstellung('kommentare-uebernehmen')) in der damals jüngsten Sitzung jeden leeren Kommentar je Gewerk mit dem zuletzt geschriebenen gefüllt (Haken unverändert). Der Papierkorb leert beim Wiederherstellen weiterhin. Übernommen wird das gespeicherte HTML, also mit Farben und Formatierung. Die Zeile „Zuletzt KW …“ über dem Kommentarfeld (renderBerichtZuletzt()) wird deshalb nicht mehr angezeigt; der „Verlauf der Vorwochen“ darunter bleibt. „Verwerfen“ (und Esc) leert bei einem unverändert übernommenen Kommentar das ganze Feld (boardCommentUebernommen, kommentarAusVorwocheUebernommen() in board.js: gleicher Text und statusAt wie der jüngste frühere Kommentar des Gewerks); sonst stellt es wie bisher den gespeicherten Stand her.

Wochenbericht — Verlauf der Vorwochen

Aktueller Stand (Eriks Wahl „B“): Im Wochenbericht steht der Verlauf nicht mehr als Kachel-Streifen, sondern wie im Projekt-Dashboard als senkrechte Zeitleiste — ein eigener Abschnitt „Verlauf der Vorwochen“ über die volle Breite unter der Bearbeitungsmaske (renderBerichtVerlauf(), berichtVerlaufDaten() in js/wochenbericht/wochenbericht.js). Ein Eintrag nur, wenn sich der Kommentar DES GEWÄHLTEN GEWERKS gegenüber der Sitzung davor geändert hat; Sitzungen dazwischen als grauer Vermerk („KW 40 bis KW 43 ohne neuen Kommentar“); geänderte Leistungsphasen seit dem vorigen Eintrag als Chips („LPH 5: 60 → 75 %“); Verfasser und Datum; „übernehmen“ (berichtVerlaufUebernehmen() → takeCommentFromHistory()); gezeigt wird, was geladen ist, „Weitere laden“ holt ältere (berichtVerlaufNeu() zeichnet nur den Abschnitt neu, ein angefangener Kommentar bleibt). Die dichte Karte bekommt dafür opts.ohneVerlauf (renderBoardDrawer()), damit der Kachel-Streifen dort nicht zusätzlich erscheint. Projektliste und Projektbereich nutzen weiter renderProjektVerlauf() wie unten beschrieben. Stile: Block „Verlauf der Vorwochen im Wochenbericht“ (.bv-*, .bericht-zuletzt) in styles.css.

Absätze, Formatierung, Vorsatz (Eriks Nachtrag):

  • Bestandskommentare einmalig umgestellt (einmaligeUmstellung('kommentar-spiegelstriche'), kommentarSpiegelstricheUmbrechen() in server.js): Jeder Spiegelstrich „ - “ in project_kommentare.status beginnt eine eigene Zeile (<br>), sofern er nicht schon am Zeilenanfang oder direkt hinter einem Vorsatz wie „[ELT]“ steht; Bindestriche in Wörtern bleiben. Repariert dabei auch als Text gespeicherte „&nbsp;“ (eine ältere Fassung des Enter-Strichs). Rein textuell, ohne DOM. Nur einmal — neue Kommentare werden NICHT an jedem „-“ umbrochen; dort zählen allein die beim Schreiben gesetzten Absätze.

  • Farben aus <font color> bleiben jetzt erhalten (sanitizeStatusHtml() erlaubt das Attribut an FONT, geprüft durch fontFarbeErlaubt(): nur Hex, keine Grau-/Schwarz-/Weißtöne wie das häufige eingefügte #1a1a1a). Vorher fiel es still weg. Die Werkzeugleiste schreibt ohnehin <span style="color:…">.

  • Gewerke-Vorsatz „[ELT] “/„[ELT+HLS] “ aus den Daten entfernt (kommentareVorsatzUmstellen()/kommentarVorsatzEntfernen() in server.js, bei jedem Start und nach /import, idempotent). Er stammt von migrateRemoveDepartmentPartition() und gehört nicht zum Kommentar. Das Gewerk erscheint stattdessen als Systemtext: in den Verlauf-Kacheln über kommentarAnzeigeHtml() (js/utils.js, Beschriftung .kommentar-gewerk, nur bei mehreren Gewerken), im Wochenbericht-Verlauf im Kopf, im Projekt-Dashboard in der Meta-Zeile.

  • „übernehmen“ lässt den Gewerke-Vorsatz weg (kommentarVorsatzEntfernen() in board.js, in takeCommentFromHistory()): „[ELT] “, „[ELT+HLS] “ am Anfang entfällt, die Formatierung bleibt.

  • The Wochenbericht is the agenda of a weekly team meeting in which every project is looked at, changed or not. Completeness beats filtering here: proposals that hide unchanged projects or reorder the list by kind of change work against that routine — they force jumping between sections instead of working down one list.

  • What is wanted instead is being able to trace what changed from week to week, as a history available on demand next to the current state. That is what renderProjektVerlauf() provides: an expandable "Verlauf" under each project card showing one tile per earlier session, horizontally scrollable, newest first. The current session is the card itself.

  • It lives in js/utils.js, not in one tab, because all three places that show a history use it: the Wochenbericht under the project card, the project list, and the Verlauf dialog of the Projektbereich (openHistoryModal(), js/plan/plan-history.js). Each of them used to have its own version with its own filter — the dialog showed comment texts only, and only for sessions where the text actually changed — so the same project's history looked different depending on where you opened it and could contradict itself. The former "Frühere Sitzungen" expander under the comment field (a fourth, even narrower selection) is gone with it.

  • Two things depend on weekLabel: with one, that session is left out (it is the card above) and each tile offers "übernehmen" to copy an old comment into the current one; without one — the dialog, which has no "current" version — all sessions are shown and no "übernehmen" button appears, since there would be no field to copy into.

  • No new data storage. Every session already holds a full copy of every project — one tile per week — and projectVersionsForUid() (js/utils.js) returns them chronologically. The timeline only makes visible what was always there.

  • A tile carries the session's comment, the LPH values, the plan's dates (latest date = project end, plus the next milestone) and the people assigned in that week — read from that session's week.people, not today's, so an old tile never shows people who weren't there yet.

  • The tile shows the comment WITH its formatting (colours, bold, Absätze; sanitizeStatusHtml(), cut off after six lines) — it used to be flattened to plain text via statusPlainText(), which Erik reported as broken.

  • Values that differ from the tile before it are highlighted: the tiles sit side by side, but with ten Leistungsphasen the one changed number is otherwise impossible to spot.

  • Rendered by renderBoardDrawer() (js/board/board.js) in both of its variants; the former BERICHT_OPTS.timeline flag is gone, since the history now hangs off every card rather than only the Wochenbericht's.

Dashboard ("Meine Aufgaben", now the default landing tab)

Stand 11/2026 — Entwurf „Bauplan“ (Eriks Wahl, Vorlage im Artifact „Projektübersicht neu gestaltet“): Kopf ohne Begrüßung und Uhr: Zeile „KW 45, Mo 2. bis Fr 6. November 2026“ (Mono) über dem Titel „Meine Aufgaben“, rechts die Sitzung (renderDashboardKopf(), dashWochenMontag(); greetingForHour()/startDashboardClock() sind entfallen). Band mit Satz und vier Zahlen: offene Aufgaben, überfällig (rot), diese Woche fällig (aus dashDaten(), heute bis Sonntag), Freigaben. Neu darunter „Diese Woche“ (renderDashWoche()): Mo–Fr als Spalten mit den Terminen der eigenen Projekte, getönt nach eigene Aufgabe / Aufgabe im Projekt / Meilenstein / überfällig, je Tag höchstens DASH_WOCHE_JE_TAG (4). Jeder Projektblock hat die Zeile „Meine Rolle | Team | Notizen“ (renderDashProjektInfo(), Eriks Wunsch): Team = übrige Beteiligte als Kürzel-Kreise in Rollenfarben (beteiligteJePerson()/beteiligtenAvatarHtml(), höchstens 5, Namen gekürzt), Notizen nur wenn vorhanden, mit Sprung in den Reiter „Notizen“. Die Anzahl liefert GET /projekt-notizen/anzahl ({uid: {anzahl, zuletzt}}, requireAuth), parallel zu /my-dashboard geladen (dashNotizen). Die Rollen-Chips im Projektkopf sind dafür entfallen. Der Monatskalender bleibt unverändert (Eriks Vorgabe). Stile: Block „MEINE AUFGABEN — Entwurf Bauplan“ am Ende von styles.css.

Aktueller Aufbau (Entwurf „B — Monatsblatt“, Eriks Wahl aus drei Varianten): Kopf und Band wie unten beschrieben; darunter links „Meine Projekte“, je Projekt ein Block (renderDashProjekte()): eigene Rollen als Chips (gestrichelt = Unterstützung, „nur Aufgabe“ = nicht zugeordnet, aber mit eigener Aufgabe), amtierende LPH mit Fortschritt, darunter bis zu DASH_TERMINE_JE_PROJEKT (6) offene Termine, Überfälliges zuerst, der Rest hinter „N weitere Termine im Projekt“. Eigene Aufgaben tragen ein „A“ in Akzentfarbe, fremde die Zuständigen, Meilensteine eine Raute (--meilenstein, eigenes Token). Jeder Block ist einklappbar (dashProjUmschalten(), „Alle einklappen/aufklappen“ in der Abschnittsüberschrift); eingeklappt zeigt der Kopf „N überfällig / N offen“. Der Zustand liegt je projectUid in localStorage (projuebersicht-dash-zu) — reiner UI-Zustand. Reihenfolge der Projekte (Eriks Wunsch): wie in der Projektliste (GROUP_ORDER, dann p.order; dashSortieren()), nicht mehr nach dem dringendsten Termin. Jedes Konto kann sie am Griff vorn im Projektkopf umziehen (oder Griff fokussieren + Pfeiltasten); gespeichert je Konto am Server (users.dashboard_reihenfolge, JSON-Liste von projectUids, Migration in migrateUsersDepartment(), POST /dashboard-reihenfolge, geliefert als req.user.dashboardReihenfolge über /whoami). Projekte, die darin fehlen, folgen dahinter in Listenreihenfolge; „Wie Projektliste“ setzt zurück. Rechts ein Monatskalender (renderDashKalender(), KW-Spalte, Feiertage aus HOLIDAYS, Punkte für eigene Aufgabe / Projektaufgabe / Meilenstein / überfällig / Abwesenheit, Klick auf einen Tag listet ihn), darunter Freigaben und Abwesenheiten. Projekte und Kalender rechnen client-seitig aus currentWeek() (dashDaten()), nicht aus GET /my-dashboard: Der Kalender soll beliebig weit blättern, der Server liefert Projekttermine nur 21 Tage voraus. Kopf und Band behalten die Server-Zahlen. Abwesenheiten im Kalender: das eigene Konto plus alle, die einem der eigenen Projekte zugeordnet sind (db.globalPeople). Solange /load-all noch fehlt, steht „Projekte werden geladen…“; renderAll() zeichnet das Dashboard danach neu. Die früheren Listen „Meine Aufgaben“ und „Termine meiner Projekte“ (renderDashboardAufgabe(), renderDashboardFristen(), renderFristenZeile()) sind entfallen — ihr Inhalt steckt in den Projektblöcken. Der Text darunter beschreibt den Stand davor, soweit er Kopf und Band betrifft, gilt er weiter.

Aufbau: Kopf — Band — zwei Zonen. Die Anordnung folgt der Frage, die beim Blick aufs Dashboard zuerst ansteht: „Was muss ich heute anpacken?"

  1. Kopf (renderDashboardKopf()) — Begrüßung und Datum links, Sitzung und Uhr klein rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm; sie ist die Angabe, nach der man am wenigsten handelt.
  2. Band (renderDashboardBand()) — der Befund des Tages als Satz („8 Termine sind überfällig, 1 wird heute fällig"), daneben vier Kennzahlen. Zuvor standen hier vier gleich große Kacheln, von denen meist drei eine Null zeigten — die eine Zahl, auf die es ankommt, bekam dadurch kein Gewicht. Ist nichts überfällig und nichts heute fällig, wird das Band farblos (.ist-ruhig): Eine Null in Alarmfarbe behauptet ein Problem, das es nicht gibt. Die Zahl im Satz ist 1.15em groß — ab 19 px fett gilt für den Kontrast die Schwelle für große Schrift, die Ampelfarbe erreicht auf der abgesetzten Bandfläche sonst nur 4,1:1 im dunklen Thema.
  3. Linke Zone — der eigene Arbeitsstrom: „Meine Aufgaben" und „Termine meiner Projekte", beide in derselben .dash-karte.
  4. Rechte Zone (300 px) — was man wissen muss, ohne selbst tätig zu werden: ausstehende Freigaben und Abwesenheiten, beide in derselben schmalen Zeilenform (.dash-rand-zeile). Zuvor stand der Urlaub eines Kollegen gleichrangig unter den eigenen offenen Aufgaben. Unter 1080 px fällt das Raster auf eine Spalte zurück.

Aufgaben sind Zeilen, keine Karten (renderDashboardAufgabe()): Eine Karte belegte eine halbe Bildschirmbreite für vier Zeilen Inhalt. Dieselben Angaben — Titel, Projekt, Fortschritt, Fälligkeit, Schlagwörter — stehen jetzt in einer Zeile. Entfallen sind damit renderStatTile(), renderDashboardTaskCard(), renderDashboardReviewCard(), renderDashboardGreeting() und die CSS-Klassen .dashboard-greeting/.dashboard-clock/.dashboard-absence-*/ .absence-chip-live.

Zwei Listen, die nicht dasselbe sind — das ist der Kern dieses Bereichs:

  • Meine Aufgaben (tasks): die Aufgaben, die der Person PERSÖNLICH zugewiesen sind (plan_todo_assignees.person_id), auch in Projekten, mit denen sie sonst nichts zu tun hat. Als Karten.
  • Termine meiner Projekte (projectDates): die Aufgaben UND Meilensteine der PROJEKTE, denen die Person zugeordnet ist (project_assign, state 1 oder 2) — unabhängig davon, wer die einzelne Aufgabe erledigt. Eine Projektleitung muss wissen, wann im eigenen Projekt etwas fällig wird, auch wenn ein anderer es abarbeitet. Als Liste, nach Dringlichkeit gegliedert (Überfällig / Diese Woche / Danach).

Beides zusammen beantwortet die Frage, die sonst nur durch Öffnen von dreißig Terminplänen zu beantworten war. Details:

  • Keine Doppelung: Was bereits unter tasks steht, wird aus projectDates herausgefiltert (selbst-Kennzeichen, serverseitig).

  • Zeitfenster 21 Tage nach vorn; überfällige Termine bleiben dagegen ohne Altersgrenze stehen — eine Frist verfällt nicht dadurch, dass sie lange gerissen ist. Ohne die Grenze nach vorn stünden Termine aus einem Jahr in der Liste und die dringenden gingen darin unter.

  • Meilensteine haben ein Kennzeichen „Erreicht“ (m.erreicht, plan_milestones.erreicht, Migration in migrateAddPlanTodoNotiz(), in IMPORT_OPTIONAL_COLS; Häkchen im Meilenstein-Panel unter „Wann“). Ein vergangener, nicht erreichter Meilenstein gilt überall als überfällig (Band und Projektblöcke auf „Meine Aufgaben“, Projekt-Dashboard, Wochenbericht); erreichte fallen aus Terminlisten, „Nächster Meilenstein“ und Wandtafel heraus, im Kalender bleiben sie stehen. Bei Aufgaben gilt done = 0 und ein gesetztes Enddatum.

  • Die Namen der Zuständigen kommen aus week_people, nicht aus loadGlobalPeople(): Die globale Liste enthält nur Personen mit Benutzerkonto, die Teilnehmerliste einer Sitzung dagegen jede Person, die dort mitarbeitet. Ohne diesen Weg stand in der Liste p_ap statt „Alexander Plum".

  • Die Kachel „Überfällig" zählt beide Listen zusammen; damit die Summe nachvollziehbar bleibt, kennzeichnet auch die Aufgabenkarte ein überfälliges Fälligkeitsdatum (b.ist-ueberfaellig). Eine Null bleibt neutral statt rot — „0 überfällig" ist eine gute Nachricht.

  • Eine Zeile führt in den Terminplan des Projekts (…#aufgaben) — dorthin, wo sich der Termin ändern lässt.

  • Datumsvergleiche laufen über heuteIso()/isoPlusTage() (js/utils.js) bzw. heuteIsoLokal()/isoPlusTageLokal() (server.js), nicht über new Date().toISOString().slice(0,10). Letzteres rechnet nach UTC um: In deutscher Sommerzeit (UTC+2) ist damit zwischen 00:00 und 02:00 Ortszeit noch der Vortag gemeint — Termine von heute galten in diesem Fenster als überfällig, und eine heute beginnende Abwesenheit fiel aus der Liste. Die Feiertagsberechnung (holidaysForYear()) rechnet dagegen bewusst durchgehend in UTC (Date.UTC, isoFromDate()) und ist davon nicht betroffen. Serverseitig setzt die Korrektur voraus, dass der Server auf die Zeitzone des Büros gestellt ist — im Docker-Container also TZ=Europe/Berlin.

  • GET /my-dashboard (Auth) is the sole data source for this tab (js/dashboard/dashboard.js, slug MeineAufgaben, first item in the sidebar nav and DEFAULT_TAB in js/globals.js — the app lands here right after login, not on the Projektboard). It aggregates, per department (ELT/HLS/ARCH), the caller's open TODOs from that department's currently unlocked week — normally there's exactly one (applyAutoLock() invariant); if that's ever violated (0 or >1 unlocked weeks) the server falls back to the most recent week via weekSortKeyServer(), a server-side port of weekSortKey() (public/js/utils.js). Tasks are matched by plan_todo_assignees.person_id = req.user.personId and sorted by due date (empty dates last). The response also includes pendingReviews (files awaiting the caller's review decision), populated by the file-upload feature described above.

Wandtafel (tab wand, js/wand/wand.js)

A tile dashboard meant for a dedicated, permanently-running touch monitor in the office — gewerke- and user-wide, the opposite scope of the Dashboard above (which is deliberately personal, "Meine Aufgaben"). Replaces Show Mode outright (js/showmode.js, the whole .show-mode-* CSS block, and the sidebar's old "Show Mode" entry are gone, not just unlinked). Show Mode only ever cloned whatever the ONE logged-in session's #board/#load tab happened to be rendering at the time (copyCurrentViewToShowMode() did a literal innerHTML copy, no independent data path) — it pre-dates both the department-retirement and the Dashboard, and never aggregated anything across Gewerke or users. Erik's own words: "macht so keinen Sinn mehr."

  • No new server endpoint. Every tile is a client-side projection of the same db state every other tab already has after GET /load-all — deliberately, to avoid holding berechneAuslastung()'s business logic in two places (server SQL and client JS) for the sake of one dashboard. The background refresh (wandRefreshTick(), every 60 s) checks GET /whoami first, then calls the ordinary load(true) — the true is a new, optional silent parameter on load() (js/store.js): on failure it only console.error()s and leaves the last-known-good data on screen, instead of load()'s normal blocking alert() + data-wipe, which would otherwise stack unusable alert dialogs on an unattended touch screen every minute.
  • Dedicated kiosk role (users.role, alongside admin/member — VALID_ROLES in server.js is now the one place that set is defined). First built on a plain member account, changed on Erik's explicit follow-up request for a real, separate role rather than relying on an ordinary account's full write access. A kiosk account logs into the kiosk browser once and is pointed at /Wandtafel directly (OS-level kiosk browser config, e.g. a --kiosk flag — outside this codebase); its session lives the normal, app-wide sessionTage (default 30 days, unchanged) like any other account — no separate session-lifetime mechanism was added. If the session expires, wandRefreshTick() calls the existing showLoginOverlay() (js/auth.js) in place — the login form was already wired once at page load by checkAuthAndInit(), so re-entering the password resumes the Wandtafel via the same afterAuthenticated() → initUiState() → load() path every normal page load already uses, without a full reload (initUiState() re-derives currentTab from the still-unchanged /Wandtafel URL).
    • Enforced server-side, not just a UI gate (unlike most role distinctions in this app, which are only hidden in the UI while the server accepts any logged-in account — see "Auth / security quirk"): requireAuth (server.js) now rejects role === 'kiosk' with 403 outright, on every route that used to accept any authenticated account. A new, narrower requireAuthOderKiosk replaces plain requireAuth on exactly the four read-only routes the Wandtafel's own load() call needs — GET /load-all, /app-settings, /holidays, /honorar-config — plus POST /logout (a kiosk account can still end its own session, even though the UI has no reachable button for it once the topbar is hidden). Every write route, GET /users, GET /my-dashboard, and everything admin-only stays on plain requireAuth/ requireRole('admin') and therefore 403s a kiosk account exactly like an anonymous visitor would get 401'd.
    • No Fachrolle, enforced in both directions. POST /admin/users and PATCH /admin/users/:id reject a kiosk role combined with a non-empty discipline-roles array (400, "Ein Kiosk-Konto kann keine Fachrolle haben.") — checked against the EFFECTIVE role and roles a request would produce, not just the fields present in that one request body: setting role: 'kiosk' on an account that already carries discipline roles in the database is rejected exactly like sending roles alongside role: 'kiosk' in the same request, by reading the existing user_roles rows when roles itself is absent from the PATCH body. The reverse direction needed its own fix: the pre-existing self-demotion guard only checked role === 'admin' && newRole === 'member', so an admin could otherwise have stepped themselves down to kiosk (a stricter demotion than to member) through a gap the original check never anticipated — it now checks newRole !== 'admin' generically. Client-side, saveNewUser()'s existing "at least one Fachrolle required" check (admin-users.js) is skipped when the selected role is kiosk (the whole reason that check exists — peopleByRole() makes a roleless account invisible everywhere — is exactly the outcome wanted here), and selecting "Kiosk" in either user form disables and unchecks the Fachrolle boxes live (onUserRoleChange()), so the 400 is never actually hit in normal use — it exists as the real enforcement, the client behavior is just not making the user discover it the hard way.
    • GET /users excludes role = 'kiosk' — this endpoint (reviewer picker, cross-department staffing) isn't Fachrolle-scoped the way peopleByRole() is, so a kiosk account without discipline roles would otherwise still turn up there as a selectable reviewer or team member, where it could never actually act (every write route it would need already 403s it) — just a dead end for whoever picked it, closed before it could confuse anyone.
  • Seitenleiste und Kopfzeile blenden sich aus while on this tab (body.wandtafel-aktiv, toggled in showTab(), hides .app-topbar/ .side-menu/.side-menu-backdrop). Written before the dedicated kiosk role existed, when the account behind the display was an ordinary member with real write access everywhere else — the hiding was a security measure then, not just tidiness. The kiosk role closed that hole server-side, but the hiding stayed: every other tab would now just 403 on its own data fetch for a kiosk account, and a sidebar full of buttons that each lead to a broken-looking error view is a worse experience than no sidebar at all. A small, deliberately unobtrusive "← Zur Übersicht" link inside the panel (App.html) is the only way back — meant for a staff member previewing the tab via their own, non-kiosk account, not for the kiosk account itself, which has no reason to ever leave it (and would just hit 403s on whatever tab it landed on).
  • AKTUELLER STAND: „Wandtafel in Glas“ (Eriks Wahl, löst die unten beschriebene Panel-Reihe ab). Gleicher Hintergrund wie die Glas-App (Lichtflächen in den Logo-Blautönen + logo.svg als Wasserzeichen, über body.wandtafel-aktiv .container — unabhängig vom Glas-Schalter), Felder halbdeckend OHNE backdrop-filter (Kiosk-Rechner), Schrift Outfit (Fredoka/Nunito Sans sind entfallen, auch in den Übersichten). Alle Maße über --u = min(1cqw, 1.7778cqh) (#wandContent ist Container mit container-type: size; --u steht eine Ebene tiefer, weil Container-Einheiten nie das Element selbst meinen): 1 % der Breite, aber nie mehr, als die Höhe bei 16:9 hergäbe — auf flacheren Fenstern schrumpft die Tafel gleichmäßig, statt unten abgeschnitten zu werden (so war der Bereich Gewerke abgeschnitten). .wand-ambient.wt ist genau einen Bildschirm hoch. Aufbau (wt*-Funktionen in js/wand/wand.js, Stile im Block „WANDTAFEL IN GLAS“ am Ende von styles.css, Tokens --wt-*): Kopf (wtKopfHtml(): Logo, Sitzung, heute Abwesende → Übersicht, große Uhr #wtUhr, sekündlich über wandCountdownTick()), vier Kennzahlen (wtKpiHtml(), Ringe für Auslastung/Besprochen), links Gewerke (wtGewerkeHtml(): je Gewerk Auslastung + Besprochen-Kästchen, jede Teilzeile → bisherige Übersicht), rechts Die nächsten Tage (wtTageDaten()/wtTageHtml(): fünf Arbeitstage ab heute über isWorkday(), nur Meilensteine aller Projekte der Sitzung — Eriks Wunsch; Aufgaben/Überfälliges stehen in den Kennzahlen, Abwesende im Kopf. Ein Termin an einem Wochenende/Feiertag zwischen den Spalten landet beim Arbeitstag davor mit Vermerk „am Sa 24.10.“ — vorher fiel er ganz heraus; wtSpaltenEinpassen() blendet nach dem Zeichnen aus, was nicht in die Spaltenhöhe passt, und zählt es zu „+ n weitere“), Fuß „Wischen für alle Projekte“. Eine Null-Auslastung ist grau, nie grün (wtAmpel()). Der Rücksprung-Zähler ruht im Grundzustand (wandImGrundzustand(): ganz oben gescrollt und keine Übersicht offen — Eriks Wunsch): dann ist #wandIdleCountdown leer und wandCountdownTick() hält die volle Minute vor; erst Scrollen oder eine geöffnete Übersicht lässt ihn ab 60 s laufen. wandDashKopfHtml()/wandKpiReiheHtml()/wandPanel()/ wandAuslastungInhalt()/wandBesprochenInhalt()/wandAktuellInhalt()/ wandAktuelleEreignisse() sind entfernt; Rechenfunktionen und Übersichten bleiben. Die folgenden Absätze beschreiben frühere Stände.
  • Redesigned again, on-screen label now "Dashboard". After the two-column layout below had been live for a while, Erik asked for a round of completely fresh full-board concepts (not incremental tweaks to the existing look). From a 3-way comparison of genuinely different structures (a "Leitstand"/control-room instrument panel, a size-varied card mosaic, an auto-rotating single-focus carousel) he picked the Leitstand's structure — not its monochrome/terminal skin. A second round then explored skins of that SAME structure (Erik: "es muss nicht retro aussehen... es darf auch vom grundlegenden Design abgewichen werden, es soll nur die selbe Struktur sein"), landing on "Pastell Freundlich", finally recolored into the two blues of the company logo (sampled directly from public/logo.ico: #94b0cc light, #116896 dark) instead of a generic pastel mix or the app's own --accent. The current structure, replacing the two-column one described below:
    • Kopf (wandDashKopfHtml()) — the marke now reads "Dashboard" (Erik: "ersetze den Schriftzug Wandtafel durch Dashboard" — a label invented for this screen's own header, not a rename of the tab/route/ sidebar entry, which all stay "Wandtafel" throughout this file and the code), the current session label + a live clock, and — moved out of the old "Heute" tile — a small antippable avatar cluster for who's absent today (wandZeigeUebersicht('abwesend'), unchanged target).
    • Kennzahlen-Reihe (wandKpiReiheHtml()) — four tiles: Auslastung gesamt and Besprochen gesamt (wandGesamtKennzahlen(), new — the SAME per-Gewerk sums from wandAuslastungJeGewerk()/wandBesprochenDaten() simply totalled across all three Gewerke rather than broken out, so the deliberate multi-Gewerke double-count applies here too automatically), plus offene Aufgaben/überfällig (unchanged counts from wandHeuteDaten(), moved out of the old "Heute" tile and still individually clickable to the same two overviews).
    • Panel-Reihe (.dash-grid, three panels framed by the shared wandPanel()) — Auslastung je Gewerk and Besprochen je Gewerk keep their exact previous per-row content and click targets (wandAuslastungInhalt()/wandBesprochenInhalt(), simply the old tile functions with the wandKachel() wrapper stripped off); the third panel, "Aktuell" (wandAktuellInhalt()), reverts the feed from the three-column by-type grouping (see further below, now superseded) back to one flat list sorted by closeness to today, capped at WAND_FEED_MAX (6) — a colored dot per entry (.dash-feed-punkt) still marks Neu/Überfällig/Termin, just without the column headers/counts that needed more width than a third of this narrower row affords. Every entry stays individually clickable into its project, unchanged.
    • The Übersicht drill-downs and the full project list below are unchanged in content and behavior — only restyled with the same --dash-* tokens so the whole screen reads as one identity rather than a themed top half bolted onto a generically-styled bottom half. The project list specifically was deliberately left on the app's normal .board-row styling, not reskinned — see its own bullet further below, "Ein Ort je Angabe"-adjacent reasoning that still applies unchanged.
    • New CSS custom properties, --dash-*, declared on body.wandtafel-aktiv (styles.css) with the usual three-way light/ dark pairing (bare block, @media (prefers-color-scheme:dark) html:not([data-theme="light"]), html[data-theme="dark"]) — kept deliberately separate from the app-wide --accent/--surface tokens, since this one screen now intentionally carries its own brand identity rather than the generic app chrome. --dash-hover exists only because a plain semi-transparent white overlay (the obvious reach for "lighten on hover") looks wrong once panels are already dark in dark mode — it's tuned per theme instead of hardcoded once.
    • First web font this app has ever loaded: Fredoka (numerals/marke) and Nunito Sans (body), both via a Google Fonts <link> in App.html, scoped in a comment to "only the Wandtafel". Breaks, for this one screen, the otherwise all-system-font convention — accepted because the approved design specifically depended on Fredoka's character, and because the font-family fallback stack degrades silently (system sans) rather than breaking anything if the kiosk has no internet access, the same acceptable-offline-degradation precedent pdfmake's CDN script already set for PDF export.
    • renderWandStand() ("Stand: HH:MM Uhr") is gone — the header's own live clock next to the session label now serves the same "hasn't frozen" purpose, so a second, separate timestamp at the old feed column's foot (which no longer exists as a column) would have been redundant.
    • Known documentation debt: the "Two columns, version 2" bullet and its sub-bullets immediately below describe the PREVIOUS structure this redesign replaced. The underlying per-element click-target philosophy, the wandUebersicht drill-down mechanism, and the specific overview targets they describe are all still accurate and unchanged — only the surrounding layout (two columns of tiles vs. the current kopf+KPI-reihe+panel-reihe) and some renamed functions (wandTileAuslastung→wandAuslastungInhalt, wandTileBesprochen→wandBesprochenInhalt, wandTileHeute removed, wandKachel→wandPanel, wandLinkeSpalteHtml removed, wandFeedHtml/WAND_FEED_SPALTEN → wandAktuellInhalt) are stale. Treat class names like .wand-c-layout/.wand-c-rechts/.wand-kachel/ .wand-stat/.wand-namen/.wand-avatar/.wand-feed-gruppe* mentioned below as historical — none of them exist in styles.css any more.
  • Follow-up: the Dashboard's logo-blue palette then became the WHOLE app's dark mode, not just this one screen — Erik: "kannst du das gesamte dark mode Farbschema an das der Wandtafel anpassen?". The dark-mode --accent/--text/--bg/--surface*/--line* tokens (the ONE shared :root/html[data-theme="dark"] definition at the top of styles.css, confirmed by grep to be the only place these specific hex values appear — every component in the app already read them exclusively through var(...), so this was a token-value swap, not a sweep across individual rules) now carry the same cool blue-teal family as --dash-* above, instead of the previous generic Apple-style blue/near-black. --green/ --amber/--red were retuned to the same gedeckter palette rather than left alone or collapsed into the accent hue — they stay functionally independent status colors (Ampel-Logik, Auftragsstatus, etc.), per "semantic color is separate from the brand accent," just recolored to sit well against the new cooler background instead of clashing with it. --band-neu (the Wochenbericht card header color for "neu kommentiert") reuses the exact same dark logo blue as --dash-ink's light-mode value (#116896) — dark enough under its own white text regardless of page theme. Light mode is untouched; only the dark-mode token blocks changed.
  • Two columns, version 2 (Erik's choice from the layout comparison), replacing an earlier six-tile 3×2 grid. The grid version left visibly uneven whitespace: a tile with a single number (e.g. "Abwesend heute") shared a row with a tile holding three meters, and — since every tile in a row shares that row's height — the thin tile stretched to match without having the content to fill it. Erik's own words: "ich finde es blöd das Leere bereiche da sind." The fix is structural, not cosmetic: fewer, heavier-weighted components (three left tiles, one feed) rather than many thin ones, each stretched via .wand-kachel-body { justify-content: space-evenly } so its own content distributes across its own full height instead of clumping at the top. body.wandtafel-aktiv additionally makes .app-main/.container/#wand behave like a fixed 100vh viewport with overflow: hidden instead of a normally-scrolling page — an unattended display should never need scrolling, and the two columns size themselves to exactly fill it rather than growing with content.
    • Left column — three tiles, never tappable as a whole. An earlier version let the whole tile expand/collapse (wandKachelKlick(), wandOffenKachel, a chevron in the title). Erik's explicit follow-up request removed that outright: "Das ein- und ausklappen der Informationen in der Heute-Kachel kann entfallen. Die einzelnen dort dargestellten Elemente sollen anklickbar sein, nicht die Kachel selber. Das soll auch bei allen anderen Kacheln so sein." Every tile now always renders its one, compact view — wandKachel() lost its interactive wrapper entirely — and instead each individual element INSIDE a tile is its own click target (.ist-klickbar on the specific row/stat/avatar, never on .wand-kachel), each jumping straight to the matching detail rather than toggling anything in place. Tapping one opens a full-screen Übersicht (overview), the same drill-down mechanism the überfällige- Aufgaben list introduced, now generalized to every clickable element via one piece of module state: wandUebersicht (null, or {typ, gewerk?} — gewerk only for the two per-Gewerk types) set by wandZeigeUebersicht(typ, gewerk) and cleared by wandSchliesseUebersicht(); wandUebersichtHtml(w) dispatches on typ to the matching builder. wandUebersicht resets to null inside startWandboard()'s guarded (once-per-real-entry) branch, so leaving and returning to the Wandtafel always starts back at the normal two-column view rather than wherever it was left.
      • Auslastung je Gewerk — each of the three Gewerk meter rows is its own click target (wandZeigeUebersicht('auslastung', g)). wandAuslastungJeGewerk() collects, alongside the existing per-Gewerk totals, a projekte:[{id,name,nr,stunden}] list — reusing the exact same berechneAuslastung() call renderLoad() already makes, just also keeping the per-project number instead of only summing it. The deliberate multi-Gewerke double-count (a project tagged with several Gewerke counts its full contribution at each) is unchanged. The opened overview (wandUebersichtAuslastung()) lists every contributing project of that Gewerk, highest hours first; a row jumps into the project's Leistung section (wandSpringeZuProjekt(pid, 'leistung')) — the section that already computes honorar × Phase × Fortschritt, the most directly relevant place for an Auslastung question to land.
      • Wochenbericht besprochen — each of the three Gewerk meter rows (only rendered clickable when that Gewerk actually has relevant projects) opens wandZeigeUebersicht('besprochen', g). wandBesprochenDaten() now keeps offeneProjekte:[{id,uid,name,nr}] per Gewerk (replacing the plain-string offeneNamen the first version had) so each row in the opened overview (wandUebersichtBesprochen()) can carry its project_uid. Clicking a row does not open the Projektbereich — it calls springZumWochenbericht(uid) (js/plan/plan-info.js), landing directly on that project in the Wochenbericht. This follows "Ein Ort je Angabe" (CLAUDE.md): the besprochen-Haken and the session comment are editable only in the Wochenbericht, never in the Projektbereich, so a jump into the Projektbereich would only land on its own read-only verweis back to the Wochenbericht — one hop that the Wandtafel now skips entirely.
      • Heute — the two stat numbers (offene Aufgaben/überfällig) and the avatar row are each independently clickable, no merged expand/collapse state any more:
        • "offene Aufgaben" → wandZeigeUebersicht('offen') — EVERY open task across every project (not just overdue), nearest due date first, empty dates last (wandHeuteDaten()'s new offeneListe, the same sort convention "Meine Aufgaben" uses on the Dashboard).
        • "überfällig" → wandZeigeUebersicht('ueberfaellig') — unchanged from the previous version, now reached directly instead of as a tap-target nested inside an otherwise expand/collapse-only tile.
        • the avatar row → wandZeigeUebersicht('abwesend') — previously only visible as an expanded detail list, now its own overview reachable straight from the avatars; a row shows name/type/bis with no click-through (an absence belongs to no project to jump to). All three overviews share one row renderer (wandUebersichtZeile(links, mitte, rechts, onclick, rechtsKlasse)) and one frame (wandUebersichtRahmen()) — the per-type builders only supply the three columns' content and (where it applies) the onclick. A clicked task row calls wandSpringeZuProjekt(pid, 'aufgaben'), which sets location.hash = 'aufgaben' before openProjectPlan(pid) (js/gantt.js) — the same "set the hash first, showTab('plan')'s own updateRouteUrl() preserves it" mechanism the Dashboard's own task links rely on (js/dashboard/dashboard.js). This is a deliberate, explicit exit from the Wandtafel: showTab('plan') clears body.wandtafel-aktiv (sidebar/topbar return) exactly like navigating to any other tab would, and a kiosk account landing on the Projektbereich can view it (todos render from already-loaded db data, no fetch needed) but not edit anything there (every write route still 403s) — a few of the Projektbereich's OTHER sections do prefetch their own data unconditionally on load (Dateien, Protokolle, the Benutzerliste for the reviewer-picker), and those specific fetches 403 and log a caught, non-fatal error for a kiosk account; the Aufgaben/Terminplan sections themselves are unaffected. Every overview's list is the one deliberate exception to the Wandtafel's "never scrolls" rule (.wand-uebersicht-liste { overflow-y: auto }) — a genuinely long list belongs to an intentional drill-down, not the passive ambient view.
    • Right column — "Aktuell diese Woche" replacing the old standalone Projektportfolio tile outright (portfolio composition by Gruppe/Auftragsstatus is not currently shown anywhere on the Wandtafel — a deliberate scope cut in this version, not an oversight, left for a future addition if wanted). wandAktuelleEreignisse() collects three event kinds pulled from every project in the session:
      • Neu — a project whose classifyProjectChange() bucket (reused directly from js/wochenbericht/wochenbericht.js, same function the Wochenbericht tab itself uses to diff against findPreviousWeek()) is 'status' or 'new', with a plain-text comment preview via statusPreviewText() (js/board/board.js, HTML stripped, 70 chars).
      • Überfällig — any open plan_todos entry whose end date has already passed, across every project (not scoped to a person, unlike GET /my-dashboard).
      • Termin — any upcoming plan_milestones entry, across every project. Within each kind, entries are sorted by closeness to today regardless of direction (Math.abs(tageBis(datum)) ascending, tageBis() reused from js/dashboard/dashboard.js) rather than by date — an event from yesterday and one from tomorrow are both "relevant right now" in a way a plain chronological sort wouldn't group together. On Erik's follow-up ("ein anderes Design für die Kachel Aktuell diese Woche"), wandFeedHtml() groups these events into THREE SIDE-BY-SIDE COLUMNS by kind (WAND_FEED_SPALTEN: Neu/Überfällig/Termine, .wand-feed-gruppen/.wand-feed-gruppe) instead of one chronologically mixed list — a design choice ("Variante C") picked from a 3-way visual comparison (alongside a connecting timeline and larger accent-bordered cards). Trades away the previous single-list "what matters most right now regardless of kind" ordering for an at-a-glance read of HOW MANY events of each kind are pending — each column header shows the kind's TRUE total count (.wand-feed-gruppe-anzahl) even though only WAND_FEED_MAX (5, now a PER-COLUMN cap rather than a global one) entries are actually listed underneath; an empty column shows "keine" rather than being hidden, since the column's presence itself is part of the at-a-glance count. Each row's timestamp (relativerTag(), also from dashboard.js) is the same relative-day phrasing ("heute"/"morgen"/"seit 3 Tagen") used throughout the rest of the app. Every entry is its own click target (.wand-feed-eintrag, part of the same per-element interaction model as the left column). Each event object carries pid and an abschnitt matched to its kind (aufgaben for an überfällig entry, terminplan for a termin entry, dashboard for a neu entry), and a click calls the shared wandSpringeZuProjekt(pid, abschnitt) — an entry is already a single, concrete event, so unlike the left column's meter rows it jumps straight into the project with no intermediate overview.
    • Stand — unchanged: a plain "Stand: HH:MM Uhr" timestamp under the feed, the cheapest possible signal that the kiosk hasn't silently frozen over the days/weeks it runs unattended.
  • Below the fold: the complete project list, reachable by scrolling — the one deliberate exception to "this display never scrolls". Erik's follow-up, after being told the ambient view couldn't fit a full project list without breaking the fixed-viewport design: the Wandtafel's monitor is a real touch screen, so he can walk up and scroll it by hand ("ich habe einen Touch Monitor... ich kann mich vor die Tafel stellen und scrollen"). renderWandboard() now renders the two-column area wrapped in a new .wand-ambient box (still exactly one screen tall, min-height: 100%) followed by wandProjektlisteHtml(w) — every project, grouped and ordered exactly like the Projekte list (GROUP_ORDER/sortGroups(), unlike the Auslastung/Besprochen tiles this one deliberately INCLUDES "In Anbahnung", since the point here is completeness, not bewertbare Arbeitslast). On Erik's explicit follow-up ("Die Projektliste soll so aussehen wie die Originale"), each row is NOT a bespoke Wandtafel row — it is built with the exact same renderBoardRow() + BOARD_OPTS call renderBoard() itself uses for the real Projekte tab (js/board/board.js): same Nr., name, Auftragsstatus-Kennzeichen, comment preview, avatar stack and Phasen-Badge, and the row expands to the identical renderBoardDrawer() detail panel. locked is passed as a hardcoded true regardless of the session's actual lock state — the Wandtafel never writes (see file-header comment), so this always hides the drag handle and the "Löschen" button and always renders the comment as .comment-readonly, independent of whether the real session happens to be unlocked; a kiosk account's write attempt would 403 server-side anyway, so there is nothing to protect by mirroring the real lock state here — only an unconditional, hardcoded read-only rendering makes the guarantee independent of that state. toggleBoardRow() (board.js) now also calls renderWandboard() after flipping a row's expanded state, exactly as it already did for renderBoard()/renderWochenbericht(), so expanding/collapsing a row in the Wandtafel's own list takes effect immediately instead of only on the next full re-render. Only #wandContent itself scrolls (class .wand-scrollable, toggled by renderWandboard() — present for the normal ambient view, absent while an Übersicht is open, which stays exactly one screen tall with its own existing internal list scroll) — .app-main/.container above it are untouched, still overflow: hidden. An unattended touch display can't be left in whatever state the last passer-by scrolled it to, so WAND_IDLE_MS (60 s) drives an inactivity timer: wandAktivitaetErkannt() is registered once, globally, on pointerdown/touchstart/wheel/keydown/scroll (the scroll listener needs the capturing phase specifically — a scroll event on an element, unlike on window/document, does not bubble, but a capturing listener on an ancestor still sees it on the way down regardless of bubbling) and is a no-op unless body.wandtafel-aktiv is set, so it costs nothing on every other tab. Each call resets the timer; after WAND_IDLE_MS without any of those events, wandBeiUntaetigkeitZuruecksetzen() clears wandUebersicht and calls renderWandboard({resetScroll:true}), unconditionally — even if nobody opened an Übersicht and only scrolled the project list, that scroll position must still snap back. The same resetScroll flag is also why an ordinary 60-second background data refresh (wandRefreshTick() → load(true) → renderWandboard(), no opts) does not reset anyone's scroll position — renderWandboard() reads and restores container.scrollTop across every re-render unless resetScroll is explicitly passed, so a person actively scrolling the project list is never yanked back to the top by the routine data refresh, only by genuine inactivity. #wandIdleCountdown (a <span> next to "← Zur Übersicht" in App.html's .wand-kopfzeile, deliberately OUTSIDE #wandContent so it survives every data/content re-render untouched) — Erik's follow-up wanting to see the countdown, not just experience the jump. wandAktivitaetErkannt() additionally stores the absolute fire time in wandIdleDeadline; a separate 1-second setInterval (wandCountdownTimer, started/stopped alongside the others in startWandboard()/stopWandboard()) runs wandCountdownTick(), which writes "Rücksprung in N s" from wandIdleDeadline - Date.now(). wandBeiUntaetigkeitZuruecksetzen() calls wandAktivitaetErkannt() again at its own end, so the countdown keeps cycling continuously for as long as the Wandtafel is open, rather than running once and going silent after the first automatic reset.
  • The same auto-return also applies AFTER leaving the Wandtafel through one of its own clickable areas — Erik's follow-up: "auch wenn ich eine der Flächen/Projekte auf der Tafel anklicke soll nach 1 min das Bild wieder zurückspringen". A tap on a project (the project list, a feed row, an Übersicht row) or a Gewerk meter deliberately navigates away into the Projektbereich or the Wochenbericht — without this, the display would be left there indefinitely once nobody is around to notice. wandUnterwegs (a module-level flag) tracks "away because of such a click"; while true, wandAktivitaetErkannt() drives a second timer, wandRueckkehrTimer (same WAND_IDLE_MS), instead of the on-Wandtafel idle timer, and wandKehreZurueck() calls showTab('wand') once it fires — same WAND_IDLE_MS constant, same "any touch resets it" semantics as the on-Wandtafel case, just pointed the opposite direction. wandUnterwegs is set by one generic, capturing click listener on document that checks #wandContent.contains(event.target) — not by wrapping every individual navigation call. The project list's rows render through the real renderBoardRow() (see above), whose own .board-name handler calls openProjectPlan() directly and calls event.stopPropagation() first; a normal bubbling listener on #wandContent would never see that click at all. A capturing listener does, because capturing runs on the way down to the target regardless of any stopPropagation() called later during bubbling. The listener only ever sets the flag — arming wandRueckkehrTimer itself happens at the end of stopWandboard() (if(wandUnterwegs) wandAktivitaetErkannt();), which runs synchronously, in the same click, right after body.wandtafel-aktiv has already been removed by showTab() — so by the time it checks, wandAktivitaetErkannt() reliably takes the "away" branch rather than the on-Wandtafel one. The Besprochen-Übersicht's jump into the Wochenbericht (springZumWochenbericht(), shared with board.js/plan-info.js) needed no wrapper or special-casing either, for the same reason — the click still originates inside #wandContent. The explicit "← Zur Übersicht" exit is the one deliberate exception: that button sits in .wand-kopfzeile, OUTSIDE #wandContent, so the listener never sees it and wandUnterwegs stays false — a staff member's deliberate preview exit is not interrupted after a minute, only an accidental or exploratory tap on the Wandtafel's own content is. startWandboard() resets wandUnterwegs to false and clears wandRueckkehrTimer on every fresh arrival at the Wandtafel (whether via wandKehreZurueck() or an ordinary manual navigation back to it), so a completed round trip never leaves a stale timer armed.
  • TAB_SLUGS/VALID_TAB_SLUGS gained wand: 'Wandtafel' (js/globals.js/server.js) — the deep-link /Wandtafel works through the same applyUrlRoute()/updateRouteUrl() mechanism as every other tab, no special-casing needed. A redirect GET /Test.html-style old link isn't needed here since nothing ever pointed at this tab before it existed.

Kalender-Abo für Outlook (iCalendar)

Eriks Wunsch: die eigenen Termine in Outlook sehen, nur zum Lesen. Einstellungen → Mein Konto → Karte „Kalender in Outlook“ (renderSettingsKalender(), js/settings/settings.js): Adresse erzeugen, kopieren, „In Outlook öffnen“ (webcal:-Link), Anleitung, „Neue Adresse“ (alte wird ungültig), „Abschalten“.

  • Server: users.kalender_token (Migration in migrateUsersDepartment(), leer = aus). GET/POST /mein-kalender (requireAuth, nur eigenes Konto; POST {aus:true} schaltet ab, sonst neue Adresse). GET /kalender/<token>.ics ist ohne Sitzung erreichbar — der Token (24 Byte zufällig) IST die Anmeldung, weil Outlook sich nicht einloggen kann. Unbekannt, deaktiviertes Konto oder Rolle ohne „Anwendung nutzen“ → 404.
  • Inhalt (kalenderEreignisse(), rein und testbar): aus der jüngsten echten Sitzung die eigenen offenen Aufgaben mit Termin (ohne erledigte und ohne Phasenbalken), Meilensteine der Projekte, denen man zugeordnet ist oder in denen man eine eigene Aufgabe hat, dazu die eigenen Abwesenheiten. Alles ganztägig (VALUE=DATE, DTEND = Tag danach), als „frei“ (Abwesenheit „abwesend“). Notizen gehen in die Beschreibung. kalenderText() maskiert nach RFC 5545 und faltet Zeilen auf 75 Byte.
  • Adresse (kalenderUrl()): der Host, über den der Browser die App aufruft — wer über localhost öffnet, bekommt eine localhost-Adresse (eine automatische Ersetzung durch die Geräte-IP hat Erik verworfen, weil sie im Docker-Container nicht verlässlich ist).
  • Grenze: Neues Outlook / Outlook im Browser holen das Abo über Microsoft-Server aus dem Internet ab — dafür muss der Server von außen erreichbar sein. Das klassische Outlook am PC holt es im Firmennetz direkt.

Update über den Git-Server (Admin → Update)

Eriks Wunsch: Neue Versionen vom Git-Server auf Anfrage im Admin-Bereich sehen und per Knopf einspielen. Eine Version ist ein Tag vX.Y.Z (annotiert, die Nachricht ist der Änderungstext) auf main.

  • App (server.js, Abschnitt „Update über den Git-Server“, Recht a.update): GET /admin/update[?suchen=1] liest sessions/update/installiert.json/status.json und fragt nur mit suchen den Git-Server (Gitea-API UPDATE_REPO_API aus docker-compose.yml, /tags) nach neueren Versionen; bei einem Fehler kommt das Ende von update.log mit. POST /admin/update {version} prüft Muster und Existenz der Version und legt sessions/update/auftrag.json ab (409, solange ein Update läuft oder der Dienst fehlt). Karte #adminUpdateCard, loadAdminUpdate() in js/admin/admin.js: Suchen, Liste neuerer Versionen, zweistufiges „Update installieren“, Fortschritt (fragt alle 5 s, auch über den Neustart hinweg), danach „Seite neu laden“.
  • Rechner, nicht Container: Der Container bekommt keinen Zugriff auf Docker. sudo bash Docker/update-einrichten.sh (einmal, im Klon des Repos) richtet projektuebersicht-update.path/.service ein (systemd wartet auf auftrag.json), legt sessions/update/ für uid 1000 an und vermerkt die laufende Version (nächster Tag). Docker/update-ausfuehren.sh (alles in main(), weil der Checkout das Skript im Lauf ersetzt): Auftrag lesen und löschen, git fetch --tags, bei lokalen Code-Änderungen (außer sessions/) abbrechen, git checkout --detach <tag>, docker compose build (alte Version läuft weiter; schlägt es fehl, zurück auf den vorigen Stand), up -d, auf /health warten, installiert.json schreiben. Fortschritt in status.json, Protokoll in update.log.
  • Die Datenbank sichert der Server wie bei jedem Start selbst. Das Repo auf dem Rechner steht nach einem Update auf einem losgelösten HEAD (Tag).

Teams: persönliche Nachricht bei neuen Aufgaben

Eriks Wunsch („nur persönlich“): Wird jemandem eine Aufgabe zugewiesen, bekommt GENAU DIESE Person in Teams eine Nachricht im Chat mit dem Flow-Bot, mit Link direkt auf die Aufgabe. Kein Kanal.

  • Einrichten in Teams (Anleitung aufklappbar unter Admin → Teams): leerer Workflow, Auslöser „Bei Empfang einer Teams-Webhookanforderung“ (Jeder), Aktion „Karte in einem Chat oder Kanal veröffentlichen“ als Flow-Bot im Chat mit Flow-Bot, Empfänger triggerBody()?['empfaenger'], Karte string(triggerBody()?['karte']). Die Auslöser-Adresse trägt ein Admin in der App ein (loadAdminTeams(), Recht a.teams). Der Workflow läuft unter dem Konto, das ihn anlegt (besser Sammelkonto/IT).
  • Was die App schickt: je neu zuständiger Person {empfaenger, karte} — empfaenger = users.email (muss dem Teams-Anmeldenamen entsprechen), karte = Adaptive Card (teamsKarte(): „Neue Aufgabe für Sie: …“, Projekt, Fällig/Zeitraum, Aufwand, Zugewiesen von, bei mehreren auch Zuständig, Knopf „Aufgabe öffnen“). Ohne Konto/E-Mail keine Nachricht (nur ein Log-Eintrag). Testnachricht geht an die E-Mail des Admins.
  • Admin-Karte (loadAdminTeams()): Statuszeile (eingerichtet + Host bzw. „noch nicht eingerichtet“), breites mehrzeiliges Adressfeld (teamsFeldPruefen(): Speichern erst bei https ohne Leerzeichen; bei hinterlegter Adresse erst nach „Adresse ändern“ sichtbar), Schalter für die Benachrichtigungen (gesperrt ohne Adresse), „Testnachricht an mich senden“, „Anbindung entfernen“. Stile: Block „Admin → Teams“ am Ende von styles.css.
  • Speicher: Tabelle integrationen (teams_webhook, teams_neue_aufgaben), bewusst NICHT app_settings (das darf jedes Konto lesen) und nicht in IMPORT_TABLES; die Adresse geht nie an den Browser zurück. Routen GET/PUT /admin/teams, POST /admin/teams/test.
  • Auslöser: neueZuweisungenMelden() (aus /save-week und /save-projects, mit req) sammelt je Aufgabe die NEU Zuständigen — nicht wer sich selbst zuweist, keine Phasenbalken, nie die Sandbox —, teamsNeueAufgaben() sendet im Hintergrund; Fehler nur im Log.
  • Link (aufgabenLink()): /Projektplan/<Name>?weekLabel=…&projNr=… &aufgabe=<todoId>#aufgaben, Host aus der speichernden Anfrage (nur im Büronetz/VPN erreichbar). plan-state.js (startAufgabe) öffnet die jüngste Sitzung, falls der Link eine ältere nennt, und dort das Aufgaben-Panel; die Adresszeile wird nachgezogen.
  • Braucht Internetzugang nach außen.

Sitzungen nach Bedarf laden (js/store.js)

Eriks Wunsch: /load-all wuchs jede Woche (alle Sitzungen, ~17 KB je Woche). Jetzt lädt der Browser beim Start nur die jüngsten SITZUNGEN_START (4: aktuelle + drei davor) über GET /load-all?wochen=4; dieselbe Antwort trägt wochenListe = ALLE echten Sitzungen schlank ({id, label, date, locked, updatedAt}, echteSitzungenSortiert()), im Browser db.wochenListe. db.weeks hält damit nur die GELADENEN Sitzungen — wer über db.weeks iteriert, sieht nicht alle.

  • Verlauf (Wochenbericht „Verlauf der Vorwochen“, Kommentar-Verlauf im Projekt-Dashboard, Verlauf-Kacheln der Projektliste): zeigt, was geladen ist; darunter weitereLadenKnopf() → weitereSitzungenLaden() → GET /weeks-aelter?vor=<älteste geladene>&anzahl=5 holt je SITZUNGEN_NACHLADEN (5) ältere. Die früheren Knöpfe „Alle N anzeigen“ sind entfallen. Der Wochenbericht zeichnet danach nur den Verlauf neu (berichtVerlaufNeu()), damit ein angefangener Kommentar bleibt; der Kachel-Verlauf merkt sich, ob er aufgeklappt war (verlaufOffen).
  • Nicht geladene Sitzungen bei Bedarf: sitzungSicherLaden(label) (über GET /week). Genutzt von Admin → Sitzungen (Liste aus adminSitzungen() = alle; Entsperren/Öffnen/Löschen/Sperren nehmen jetzt das Label) und von openPlanView() bei einem Link auf ein Projekt einer älteren Sitzung.
  • Live: Ein Ereignis zu einer nicht geladenen Sitzung zieht nur deren Eintrag in db.wochenListe nach, statt alles neu zu laden. deleteWeekById() zählt für „mindestens eine Sitzung“ die wochenListe.
  • /load-all ohne wochen liefert weiterhin alles (ältere Browser-Stände). loadWeeks() nimmt dafür auch eine Liste von Labels (IN (…)).
  • Gemessen: 98 KB statt 158 KB bei 9 Sitzungen; bleibt künftig etwa gleich, statt mit jeder Woche zu wachsen.

Gleichzeitiges Arbeiten (Zusammenführen statt Verwerfen, js/store.js)

Eine Sitzung wird weiterhin als Ganzes über POST /save-week mit Revisionsstand gespeichert, und der Server antwortet weiterhin mit 409, wenn jemand anderes sie inzwischen gespeichert hat. Geändert hat sich die Reaktion im Browser: Früher verwarf handleWeekConflict() die eigenen Änderungen mit einem alert() — bei zwei Personen in derselben (laufenden) Sitzung ständig, auch an verschiedenen Projekten (Eriks Meldung: „immer Fehlermeldungen, sobald mehr als eine Person in der App arbeitet“).

  • wochenBasis (Map label → Kopie) hält je Sitzung den zuletzt bekannten Server-Stand: gesetzt in load()/reloadWeekFromServer() und nach jedem erfolgreichen Speichern auf genau den gesendeten Stand (gesendet).
  • Bei 409 holt zusammenfuehrenUndSpeichern() den frischen Stand und zusammenfuehren() führt drei Fassungen zusammen (dreiwegeZusammenfuehren(basis, eigen, fremd)): Objekte je Schlüssel, Listen von Objekten mit id (Projekte, Aufgaben, Meilensteine, Personen) je id — damit auch Kommentare je Gewerk und Anwesenheit je Person getrennt. Nur echte Doppeländerungen desselben Felds sind Konflikte; dort gilt die eigene Fassung, zeigeHinweis() nennt das Projekt (8 s, nicht blockierend). Danach wird erneut gespeichert; nach drei Überholungen in Folge oder bei einer gelöschten Sitzung greift der alte Weg.
  • inPlaceUebernehmen() schreibt das Ergebnis IN die bestehenden Objekte (gleiche id → gleiches Objekt), weil z. B. window.PlanApp eine Referenz auf Woche und Projekt hält; ein Ersetzen hätte dessen weitere Änderungen ins Leere laufen lassen.
  • Live-Abgleich: wochenLiveAbgleich() fragt alle 20 s GET /week-revisionen (nur label → updated_at) und übernimmt fremde Änderungen über denselben Weg — nur wenn hier nichts ungespeichert ist, kein Speichern läuft und niemand tippt oder einen Dialog offen hat (wirdGeradeBearbeitet()), sonst beim nächsten Durchlauf.
  • Getestet (zwei Konten): verschiedene Projekte → beide gespeichert; dasselbe Kommentarfeld → eigene Fassung + Hinweis; fremde Änderung ohne eigene → nach ≤ 20 s sichtbar; in keinem Fall ein Dialog.

Live-Ereignisse und Speichern einzelner Projekte (aktueller Stand)

Eriks Wunsch: Änderungen sofort bei allen, und nur den geänderten Teil übertragen. Ergänzt (und ersetzt teilweise) die Beschreibung oben:

  • Speichern nur des Geänderten: syncAllToServer() vergleicht jede geänderte Sitzung mit ihrem zuletzt bekannten Server-Stand (geaenderteProjekte()). Haben sich NUR Projektinhalte geändert, gehen nur diese Projekte an POST /save-projects {label, projects:[{project, baseUpdatedAt}]} (sendProjectsToServer(), js/server.js; gemessen ~1,9 KB statt ~22 KB). Server: Konfliktprüfung JE PROJEKT über projects.updated_at (409 {conflict, projekte:[ids]} → dieselbe Dreiwege-Zusammenführung wie oben), dann deleteProjectData() + serializeProject() (aus serializeWeek() herausgelöst) in einer Transaktion, weeks.updated_at wird mitgezogen. Sitzungsebene (Teilnehmer, Anwesenheit, Sperre), Anlegen/Löschen/Umsortieren von Projekten → weiter /save-week. Nach einem /save-week steht am Server jedes Projekt auf der Wochen-Revision; der Client zieht p.updatedAt deshalb für alle Projekte nach.
  • GET /ereignisse (Server-Sent Events, requireAuthOderKiosk, ohne Abhängigkeit; X-Accel-Buffering: no für einen Proxy, Herzschlag alle 25 s): ereignisSenden() meldet nach jedem Speichern allen ANDEREN Browsern (X-Client-Id/?client= = CLIENT_ID je Tab) {typ:'projekte', label, updatedAt, projekte} (der neue Stand der Projekte), {typ:'woche', label, geloescht?} (/save-week, Entsperren, Löschen, Papierkorb) oder {typ:'neu'} (Sitzungswechsel, Backup-Import). Abgemeldete/abgelaufene Sitzungen werden dabei getrennt.
  • Browser (liveVerbinden() nach load(), liveProjekte(), liveWocheNachladen() über GET /week?label= = loadWeeks(label), liveAllesNeu()): Projekte ohne eigene ungespeicherte Änderung werden übernommen, sonst dreiwege-zusammengeführt (Hinweis bei echtem Konflikt). Neu gezeichnet wird sofort oder, solange jemand tippt/ein Dialog offen ist, danach (ansichtNachBearbeitung()). wochenLiveAbgleich() ist nur noch Rückfall (60 s und nach jedem Wiederverbinden).
  • gleich() vergleicht reihenfolgeunabhängig (stabilerText()): Mit JSON.stringify hielte es nach inPlaceUebernehmen() Gleiches für verschieden, und zwei Browser schickten sich endlos dieselben Projekte zu.
  • Getestet mit zwei Browsern: Änderung nach ~0,6 s beim anderen (davon 0,5 s Entprellung), sichtbar im offenen Wochenbericht; verschiedene Projekte gleichzeitig → beide gespeichert; gleiches Feld → Hinweis; Anwesenheit über /save-week → ~0,6 s; Sandbox-Plan einzeln; in Ruhe keine Speicherungen.

Benachrichtigungen (Glocke, js/benachrichtigungen.js)

Eriks Wunsch: Wer eine neue Aufgabe zugewiesen bekommt, sieht das in der Übersicht. Entstehen server-seitig in POST /save-week: Vor dem Speichern merkt sich die Route alle plan_todo_assignees der Woche, danach legt neueZuweisungenMelden() für jede NEU hinzugekommene Kombination (Aufgabe × Person) einer offenen Aufgabe einen Eintrag in benachrichtigungen an — nur für aktive Nicht-Kiosk-Konten mit passender person_id, nie für den, der speichert (Selbstzuweisung), nicht doppelt, solange eine ungelesene zur selben Aufgabe existiert, und nicht für eine Woche, die mit diesem Speichern erst entsteht. Fehler dort werden nur geloggt, das Speichern scheitert nie daran. Tabelle mit FK → users ON DELETE CASCADE, nicht in IMPORT_TABLES; purgeBenachrichtigungen() (Start + stündlich) entfernt gelesene nach 60, alle nach 180 Tagen. Routen: GET /benachrichtigungen (30 jüngste + ungelesen), POST /benachrichtigungen/gelesen {ids}|{alle:true} — beide requireAuth, immer nur das eigene Konto. Löschen (Eriks Wunsch): POST /benachrichtigungen/loeschen {ids}|{alle:true} (ebenfalls nur eigenes Konto, endgültig). In der Glocke trägt jede Zeile (.glocke-zeile = Öffnen-Knopf + .glocke-loeschen „ד) einen eigenen Löschen-Knopf, der Kopf „Alle gelesen“/„Alle löschen“ (loescheBenachrichtigungen()). Anzeige: Glocke im Menüband (#glockeBtn, rote Zahl, Liste #glockeMenue) und auf „Meine Aufgaben“ der Block „Neu zugewiesen“ (#dashNeu, nur bei Ungelesenem). Abgefragt bei jedem renderAll() und minütlich. Ein Klick markiert als gelesen und springt in die Aufgaben des Projekts — gesucht in der AKTUELLEN Sitzung über die Projektnummer (die Projekt-ID wechselt je Sitzung), Planung-Terminpläne über ihre ID.

Gestaltung „Bauplan“ (unslop-Überarbeitung, 11/2026)

Eriks Wunsch: das gesamte Design mit dem Skill „unslop“ überarbeiten, Wahl „volle Überarbeitung“, Glas „beruhigen“. Inhalte, Aufbau und Funktionen sind unverändert; geändert sind nur Stylesheets, ein paar Texte und Inline-Größen.

  • Richtung: Planköpfe und Raster eines Bauplans — Flächen fast neutral (Graphit, leicht kühl), ein Akzent (Logo-Blau #0f5a85, dunkel #86bfdf), Farbe nur in Inhalten (Terminplan, Auslastung, Status). Löst die frühere Dunkel-Palette in Wandtafel-Blau ab.
  • Regeln, die neue Stile einhalten sollten: Schriftgrößen 11/12/13/15/19/24 (keine halben Pixel), Gewichte 400/500, 600 nur selten; keine gesperrten Großbuchstaben-Labels; keine negative Laufweite unter 40px; Abstände im 4px-Raster (2 und 6 nur in kleinen Bedienelementen); Rundungen 4–6 (Schildchen/Chips), 8 (Knöpfe, Eingaben, Umschalter), 12–16 (Karten); Kapselform nur für dünne Balken, Schalter, Zählpunkte und Avatare; keine farbigen Schatten/Leuchten, keine Emoji, keine Ausrufezeichen in Texten.
  • Kennzeichen: Kalenderwochen, Projektnummern und Kennzahlen in Monospace-Ziffern (--font-mono, tabellarisch) — Block „BAUPLAN“ am Ende von styles.css (auch: ruhige aktive Navigation, Knopf-Übergänge).
  • Dunkel: Hauptknöpfe in --knopf (#276f9c, weiße Schrift 5,6:1), weil weiße Schrift auf dem hellen Akzent nur ~2:1 erreicht.
  • Glas beruhigt (glas.css): Flächen fast deckend, nur noch zwei schwache Lichtflächen in den Logo-Blautönen (Mint entfallen), Rundungen 8/14/16, neutrale Schatten, kein saturate(), aktive Einträge mit Haarlinie statt farbigem Schatten.
  • Ausgenommen: die Wandtafel (eigenes Design, --dash-*/--wt-*) und die Login-Seite bei den Schriftgrößen.
  • Das Umbauskript arbeitete je CSS-Regel; geprüft: gleiche Regelzahl wie vorher (kein Parse-Verlust), keine Überläufe/Großbuchstaben auf Dashboard, Projekte, Wochenbericht, Auslastung, Abwesenheiten, Einstellungen und in den Projektbereichen. Methode nach „unslop“ von Damir Sokolovsky (@heydamir), CC BY 4.0.

Glas-Darstellung (public/glas.css)

Eriks Wahl aus dem Gestaltungsvorschlag „Projektübersicht in Glas“ (Annäherung an Apple Liquid Glass). Eine eigene Schicht, als letztes Stylesheet geladen, die nur mit der Klasse html.glas greift — ohne sie sieht die App aus wie zuvor. Ein Inline-Skript im <head> von App.html setzt die Klasse vor dem ersten Zeichnen, außer localStorage['projuebersicht-glas'] === 'aus'; abschaltbar je Browser unter Einstellungen → Mein Konto („Darstellung“, setGlasDarstellung() in js/settings/settings.js), gedacht für langsame Rechner.

  • Tokens statt Einzelregeln: --surface/--surface-alt/--surface-sunk/ --line* werden durchscheinend, --accent wird das Logo-Dunkelblau (#0f5a85, dunkel #8cc9e6), Rundungen größer (--r-lg 18px, --r-xl 22px). Damit wirken die ~70 Stellen mit var(--surface) ohne Einzelanpassung. Folge: var(--surface) ist unter Glas KEINE deckende Farbe mehr — Text auf Akzentfläche braucht eine feste Farbe (siehe .dash-kal-tag.ist-gewaehlt, .pt-badge-person in glas.css); wer neu color: var(--surface) schreibt, baut denselben Fehler.
  • Echtes Glas (backdrop-filter) nur auf den großen Flächen — eine Liste von rund 30 Selektoren in glas.css (Menüband, Seitenleiste, Karten, Dialoge, Projektbereichs-Panels). Neue Karten-Container dort ergänzen. Dialoge, Menüs und Hinweise sind fast deckend (--glas-dicht).
  • Hintergrund: Lichtflächen in den Logo-Blautönen und public/logo.svg (aus logo.png vektorisiert, scharf in jeder Größe) als fixiertes Wasserzeichen über body::before. Login-Seite und Kopfzeile nutzen ebenfalls logo.svg; die Login-Seite hat denselben, aber deckenden Hintergrund.
  • Seitenleiste schwebt auf Desktop-Breite mit 12px Abstand und runden Ecken; aktiver Eintrag als helle Kapsel.
  • GANTT-Namensspalte deckend: Die feste (sticky) Namensspalte hatte unter Glas das halbdurchsichtige --glas-deckend; beim waagerechten Scrollen schienen die Balken durch die Namen. Jetzt --gantt-label-grund (derselbe Ton, deckend, hell/dunkel in glas.css); getönte Zeilen (Projekt, Phasenköpfe) legen ihre Tönung als background-image darüber.
  • Projektbereich zeigt den Glas-Hintergrund: .plan-main hat in plan-styles.css einen deckenden Grund (var(--bg)); unter Glas ist er durchsichtig (glas.css), sonst sah man im Projekt nur flaches Dunkelblau/Grau statt Lichtflächen und Wasserzeichen.
  • „Alles schwebt“ (Eriks Wahl C, Desktop): Auch das Menüband (.app-topbar, sticky bei top: 12px) und der Projektkopf schweben mit 12px Abstand und runden Ecken. Der Projektkopf ist dafür in App.html in .plan-kopf gefasst (.plan-header + .plan-nav darin ohne eigene Fläche) — vorher stieß ein eckiger, durchgehender Streifen an die runde Seitenleiste. Der aktive Reiter ist eine Kapsel wie der aktive Eintrag der Seitenleiste; .plan-main-content hat unter Glas 12px statt 20px Rand, damit die Kacheln bündig unter der Kopfkarte stehen. Ohne Glas und unter 769px bleibt alles wie zuvor.
  • Ausgenommen: die Wandtafel (body.wandtafel-aktiv: kein Wasserzeichen, kein backdrop-filter — eigenes Design, Kiosk-Rechner) und prefers-reduced-transparency (deckende Flächen).

Datums-Kalender mit KW (js/date-picker.js)

Eriks Wunsch: Jeder Kalender beim Anlegen/Bearbeiten zeigt die Kalenderwoche. renderRangePicker() hat eine erste Spalte „KW“ (getWeekNumber(), .range-picker-kw, Popup 316px breit). Native <input type="date"> (Feiertage, Abwesenheiten in der Benutzerverwaltung, „Aufgabe aus Kommentar“, Assistent, TODOs aus Protokoll) öffnen über einen globalen Klick-Abfänger denselben Kalender statt des Browser-Kalenders; der Wert wird ins Feld geschrieben und input/change ausgelöst. Ausnahme per Attribut data-nativ.

Dunkelmodus nachgeschärft (Kalender, Sitzungskommentar)

Eriks Wahl „A“ aus einem Kontrast-Vergleich; Block „DUNKELMODUS NACHGESCHÄRFT“ am Ende von styles.css. Alles über Tokens (--kal-*, --rt-*, --kommentar-ansicht-bg), hell unverändert:

  • --meilenstein fehlte im Block html[data-theme="dark"] — wer den Dunkelmodus ausdrücklich gewählt hatte, sah das dunkle Lila (2:1).
  • Kalender: „meine Aufgabe“ (--kal-meine = Akzent) und „Projektaufgabe“ (--kal-projekt, Grün-Türkis) sind verschiedene Farben statt desselben Blaus mit Transparenz; Punkte dunkel 6px, Nebentage/Legende heller.
  • Kommentarfarben: Die Werkzeugleiste speichert feste Farbwerte (#d70015 usw., vom Browser teils als rgb(...) geschrieben). Attributselektoren setzen sie beim ANZEIGEN auf --rt-rot/-gruen/-blau/-orange um (dunkel hellere Fassung); :not([style*="background"]) lässt Farbflächen (Werkzeugleiste, Gantt) aus. Gespeichert und im PDF bleibt der Originalwert.
  • Projekt-Dashboard: #planStatusWrap bekam .locked-cell (55 % Deckkraft, dunkel grauer Grund) — entfernt (renderPlanInfo()), der Kommentar ist über .is-ansicht als Anzeige erkennbar.

Bewegung (Hover/Klick-Rückmeldung)

The app's interaction feedback is token-driven and lives in one place: five variables in :root (styles.css), applied by a clearly-marked "BEWEGUNG" block at the end of styles.css plus a smaller companion block at the end of plan-styles.css for the Projektbereich-only row types. Every rule reads through var(); changing the feel is a change to those five values, not to the components.

--dur: 150ms        kurze Rückmeldung (Fläche, Farbe, Anheben, Druck)
--dur-lg: 200ms     grössere Zustandswechsel (Dialog, Aufklappen)
--ease-out          cubic-bezier(.2,.8,.2,1), schnell los / weich aus
--row-lift: -1px    Hub einer anklickbaren Zeile beim Zeigen
--press: .97        Stauchung beim Drücken

Erik picked this level ("Spürbar") from a three-way live comparison (dezent / spürbar / lebendig) built with the Taste-Skill. Level 3's entrance animations were deliberately NOT taken: with ~30 projects in a list that re-renders on every save, rows animating in on each render is unrest, not polish. The rule that follows from that: the surface moves only in response to pointing or clicking, never on its own, and never in a loop.

  • Row lift + accent rail on clickable rows (.board-row-main, .bericht-rail-item, .planung-row, .dash-rand-zeile, and in plan-styles.css .todo-depends-item, .pt-row-main, .plan-task-row). The rail is an inset box-shadow growing 0 → 3px, deliberately NOT a ::before with position:absolute: .board-row is draggable (js/projects/projects.js), and an extra positioned element or a new stacking context on a dragged row can shift the drag image and hit targets. An inset shadow touches neither layout nor stacking. For the same reason the transform sits on the inner .board-row-main, never on the draggable .board-row itself (verified: the draggable element keeps transform: none).
  • .protokoll-liste-zeile is the one exception and must stay one: it already carries border-left: 3px solid transparent, which its .ist-aktiv state colours. It therefore animates that existing border instead of adding the inset shadow. Using both would paint 6 px of accent instead of 3.
  • Dialogs use animation, not transition: .modal-bg is toggled display:none ⇄ flex from JS, and a display change is not transitionable, whereas an animation restarts every time the element is rendered again (modal-bg-auf / modal-auf).
  • prefers-reduced-motion was missing from this codebase entirely and is now handled at the end of styles.css. Because the level is token-driven, the block simply flips the tokens (--row-lift: 0px, --press: 1, durations to 1ms); a blanket transition-duration/animation-duration: 1ms !important additionally catches the ~60 older rules that still hard-code their own timings.
  • Keyboard focus got explicit :focus-visible outlines on the same rows/chips. The lift and press effects answer mouse and touch only; without this the rework would have left keyboard use worse off than before.
  • The Wandtafel (body.wandtafel-aktiv) is deliberately excluded — a kiosk touch monitor has no hover, and that screen carries its own --dash-* design anyway.

Ladezeit (Pi)

Eriks Meldung „das Laden der Seite auf dem Pi dauert extrem lange“. Gemessen und behoben (server.js, App.html):

  • Sitzungs-Schreibzugriff je Anfrage: attachUser() schrieb bei JEDER Anfrage — auch jeder der rund 60 JS/CSS-Dateien — last_seen_at; jetzt höchstens einmal je Minute und Sitzung (sitzungZuletztGeschrieben).
  • PRAGMA synchronous = NORMAL (WAL-üblich): Schreibvorgänge warten nicht mehr jedes Mal auf die SD-Karte; bei Stromausfall können nur die allerletzten Schreibvorgänge fehlen, die Datenbank bleibt konsistent.
  • gzip: gzipJson (res.json ab 1 KB; /load-all 155 KB → 14 KB) und gzipStatisch() für JS/CSS/HTML/SVG (je Datei+mtime einmal komprimiert, im Speicher), appSenden() für die Seitenrouten (App.html 122 → 30 KB). Ohne Accept-Encoding: gzip unverändert über express.static/sendFile.
  • Browser-Cache über ?v=N (siehe Server API). Erster Aufruf 1,4 MB → 0,46 MB, jeder weitere ~2 KB.
  • Externe Dateien blockieren nicht mehr: Google-Schrift per rel=preload nachgeladen, pdfmake + vfs_fonts erst nach load (nacheinander); die PDF-Exporte prüfen window.pdfMake ohnehin.

Betrieb auf dem Pi (Pi Zero 2, 512 MB, Ubuntu Server)

Eriks Wunsch: möglichst nicht abstürzen, sonst von selbst neu starten, kein Speicherüberlauf. Vier Ebenen, jede fängt ab, was die darüber verpasst:

  • App (server.js):
    • PDF-Arbeit im Kindprozess (pdf-arbeit.js, pdfImKindprozess()): extrahierePdfText()/renderPdfAlsBildsatz() laufen je Auftrag in einem frisch geforkten Prozess (Heap PDF_KIND_HEAP_MB, Selbstabbruch über PDF_KIND_MAX_MB RSS, 3 min Zeitlimit, höchstens einer gleichzeitig). Stirbt er, scheitert nur der Import mit „PDF ist zu groß …“. Der Plan-Checker-Render braucht viel Speicher (am Mac gemessen bis ~600 MB) und kann auf dem Pi daran scheitern — gewollt, statt den Pi umzuwerfen.
    • Datei-Upload streamt auf die Platte (empfangeInDatei(), 200 MB); die übrigen Roh-Uploads sind kleiner: Protokoll/PDF/Plan Checker 25 MB, DB-Import 50 MB. Fehler-Middleware vor app.listen macht aus 413 JSON.
    • Wächter (betriebsWaechterStarten()): Speicher über SPEICHER_MAX_MB → sauberes Beenden mit Code 1; Event-Loop steht länger als HAENGER_MAX_S → ein Worker-Thread (Herzschlag über SharedArrayBuffer) beendet den Prozess hart. uncaughtException → Beenden mit Code 1, unhandledRejection nur protokolliert. Ohne die Umgebungsvariablen ist der Speicher-Wächter aus (lokal), der Hänger-Wächter läuft mit 90 s.
    • betriebAufraeumen() stündlich: Login-Sperren, wal_checkpoint(TRUNCATE). Sitzungswechsel lädt nur noch die jüngste Sitzung. gzip Stufe 6. /health meldet speicherMb/laufzeitMin.
  • Docker (docker-compose.yml, Docker/Dockerfile): mem_limit 320m, memswap_limit 512m, init: true, Log-Rotation 3×5 MB, Healthcheck per wget statt eigenem Node-Prozess, node --max-old-space-size=160. Docker startet „unhealthy“ NICHT neu — dafür ist der Hänger-Wächter da.
  • Pi (Docker/pi-einrichten.sh, einmal mit sudo): Hardware-Watchdog (systemd RuntimeWatchdogSec=14s), zram (halber RAM, zstd), cgroup memory in cmdline.txt (sonst wirkt mem_limit nicht), gpu_mem=16, Dienste aus (multipathd, ModemManager, …; snapd nur mit --snapd-aus), earlyoom, journald 50 MB, Container-Neustart täglich 3:30 (cron), Unterspannungs-Check. WLAN (Befund 29.10.: kein Absturz, sondern WLAN verloren und nie wieder verbunden, ASSOC-REJECT in Schleife): Energiesparen aus (wlan-energiesparen-aus.service), wlan-waechter.timer jede Minute — ohne Gateway ab 2 min neu verbinden, nach 10 min systemctl reboot.
  • Prüfen: Docker/pi-pruefen.sh (Speicher, Neustarts, OOM, Strom).
  • Sitzungen nach Bedarf (Schritt 4, umgesetzt 30.10.2026): siehe „Sitzungen nach Bedarf laden“ unten.

Docker

  • docker compose up --build from the repo root. Compose maps 3003:3003, bind-mounts ./sessions:/app/sessions, restart: always (startet beim Booten auch nach einem manuellen docker compose stop wieder; nur down entfernt ihn) and sets TZ: Europe/Berlin — ohne das liefe der Container auf UTC, und die Datumsvergleiche des Servers (heuteIsoLokal()) lägen zwischen Mitternacht und 2 Uhr Ortszeit einen Tag zurück.
  • Dockerfile: node:22-alpine (nötig für node:sqlite), npm ci --production, runs as non-root node, HEALTHCHECK on /health. .dockerignore excludes Docker/* (except Docker/Dockerfile), node_modules/, .git, npm-debug.log.

Behobene Fehler (Prüfung 09/2026)

Aus einer systematischen Durchsicht (Bericht mit 58 Funden) wurden folgende Verhaltensweisen geändert — wer den alten Zustand im Kopf hat, findet hier die Abweichungen:

  • GET /backup ist admin-only (requireRole('admin')). Der Snapshot enthält die komplette Datenbank samt user_sessions.token im Klartext; mit einem fremden Token ließ sich jede Admin-Grenze umgehen. Die Schaltfläche "DB Backup" steht deshalb jetzt im Admin-Bereich der Seitenleiste.
  • Die Sperre gesperrter Sitzungen wird serverseitig durchgesetzt: /save-week weist locked: true → false für Nicht-Admins mit 403 ab. Das Sperren (false → true) bleibt jedem erlaubt, weil applyAutoLock() es beim normalen Speichern mitschreibt.
  • Anmeldung gebremst: 10 Fehlversuche je Konto und Absender-Adresse sperren für 10 Minuten (429). Der Schlüssel enthält den Benutzernamen, damit nicht ein Tippfehler alle hinter derselben Büro-Adresse aussperrt. Bei unbekanntem Benutzernamen wird trotzdem einmal gehasht (sonst verriete die Antwortzeit gültige Konten). Abgelaufene Sitzungen werden beim Start gelöscht.
  • loadGlobalPeople() liefert nur Konten mit gesetzter person_id. Zwei Konten ohne Kennung hätten sonst denselben leeren Primärschlüssel im Teilnehmerkreis einer Sitzung belegt und jedes Speichern mit 500 abgebrochen; serializeWeek() filtert doppelte Kennungen zusätzlich heraus.
  • loadWeeks() hängt Teilnehmer an, statt sie per array_order zu indizieren — kollidierende Ordnungszahlen hätten Personen still überschrieben.
  • JSON-Dateien lassen sich hochladen: Der globale express.json()-Parser läuft nicht mehr über die in RAW_BODY_ROUTES gelisteten Routen (/import, POST /project-files, POST /project-protokolle/import — die nehmen rohe Binärdaten entgegen); das Limit für Sitzungsdaten liegt bei 8 MB.
  • Der Teilnehmerkreis einer laufenden Sitzung ist wieder pflegbar: addPersonToCurrentWeek()/removePersonFromCurrentWeek() in js/people/people.js. Vorher entstand week.people nur bei newWeek(), ein später angelegter Benutzer war in keiner laufenden Sitzung zuordenbar. Das Entfernen verweigert sich, solange die Person noch einem Projekt der Sitzung zugeordnet ist.
  • Speichern meldet sich: store.js zählt Änderungen je Woche mit (weekChangeCounter), damit eine Bearbeitung WÄHREND eines laufenden Speichervorgangs ihre Dirty-Markierung behält; Fehler erscheinen als Band unten rechts (.sync-problem-bar), werden nach 20 Sekunden erneut versucht, und ein 401 öffnet wieder das Anmeldefenster. Nach einem fehlgeschlagenen load() werden keine Beispieldaten mehr erzeugt (seed() lief früher auch im Fehlerfall und überschrieb per save() die echte "KW 30 / 2026").
  • sanitizeStatusHtml() packte unerlaubte Elemente mit einem falschen Referenzknoten aus (insertBefore(child.firstChild, node)) und warf dabei NotFoundError — ein eingefügter Link genügte, um das Board-Rendering abzubrechen. Zusätzlich werden ausgepackte Kinder jetzt erneut geprüft (sonst blieben <li>/<tbody> stehen) und <script>/<style> samt Inhalt verworfen. esc() maskiert nun auch ', weil mehrere Inline-Handler den Wert in einfach gequotete JS-Strings setzen.
  • Sitzungsbezeichnungen werden geprüft (validateWeekLabel() in js/weeks.js): keine Dubletten (der Server schlüsselt allein über das Label) und Rückfrage bei fehlendem KW …-Muster (sonst sortiert die Sitzung hinter alle echten und applyAutoLock() sperrt den Rest).
  • Auslastung: Die Ampel bezieht sich auf die Kapazität (gelb ab 0,75, rot ab 1,0) statt auf das Skalenmaximum LOAD_BAR_MAX; der Mittelwert teilt nur durch die Sitzungen, in denen die Person im Teilnehmerkreis stand.
  • Projektbereich: Auto-Schedule terminiert Meilensteine vor ihren Nachfolgern, der Meilenstein-Dialog sperrt Zirkelbezüge, die Vorgängerliste zeigt auch die einzige vorhandene Aufgabe (>= 1 statt > 1), Enter im Vorgänger-Suchfeld speichert nicht mehr, und das Verschieben einer Abwesenheit legt erst neu an, wenn das Löschen bestätigt ist.
  • Modals liegen über der Navigation (.modal-bg z-index: 300); gesperrte Bedienelemente sind an :disabled-Regeln erkennbar.

Nachtrag: die zunächst offen gebliebenen Punkte

  • Aufwands-Gewichtung ohne Eintrag (berechneAuslastung(), js/load/load.js): Fehlt am Projekt ein Matrixwert, gilt jetzt der Wert aus der firmenweiten Vorlage LP_AUFWAND_VORLAGE für diese Phase und Rolle — vorher pauschal 1,0. Damit zählen zwei gleich gelagerte Projekte gleich, unabhängig davon, ob die Aufwandsmaske schon einmal gespeichert wurde (LPH 9 steht in der Vorlage bewusst auf 0). Nur wenn die Vorlage die Phase gar nicht kennt, bleibt es bei 1,0. Das Eingabefeld zeigt den jeweiligen Vorgabewert als Platzhalter.
  • Abwesenheiten fremder Konten: Eintragen bleibt für jedes angemeldete Konto erlaubt (die Projektleitung pflegt Urlaube ihres Teams mit), aber user_absences.created_by hält fest, wer den Eintrag angelegt hat. DELETE /users/:id/absences/:absenceId erlaubt das Löschen nur der betroffenen Person, dem Ersteller oder einem Administrator (sonst 403). Die Spalte wird beim Start idempotent nachgerüstet; Altbestand hat created_by = 0 und ist damit nur von der betroffenen Person oder von Administratoren löschbar.
  • Fokus beim Speichern per Fokuswechsel (js/board/board.js): Ein mousedown-Lauscher in der Capture-Phase merkt sich, in welches Kommentarfeld geklickt wird; nach dem Neu-Rendern stellt restorePendingCommentFocus() den Fokus dort wieder her (Cursor ans Textende). Die Schaltflächen "Speichern" und der Verlaufs-Aufklapper unterdrücken zusätzlich den Fokusverlust (onmousedown/preventDefault, wie zuvor schon "Verwerfen" und "übernehmen").
  • Tote Reste entfernt: renderGantt()/fitGanttRange() in js/gantt.js (griffen auf Elemente zu, die es in keiner Seite mehr gibt), statusBlur() und delProj() in js/board/board.js (seit dem Textfeld-Editor bzw. der neuen Zeilendarstellung ohne Aufrufer), 16 CSS-Regelblöcke der früheren TODO-Liste (damals in project-plan.html), die wirkungslose Klasse plan-main-content-full und der gegenstandslose 900px-Umbruch. buildGanttHtml() bleibt in js/gantt.js — js/plan/plan-build-gantt.js umhüllt sie (originalBuildGanttHtml), sie ist also die Grundlage des gesamten GANTT. Der Link "Zurück zur Übersicht" in admin-users.html nutzt jetzt die neue Regel a.secondary statt Inline-Stilen.
  • esc() verträgt Nicht-Strings: Leistungsphasen-Prozentwerte kommen je nach Herkunft als Zahl an; (100).replace ist keine Funktion und riss das Rendern des Wochenberichts mit. esc() wandelt jetzt über String() um (nur null/undefined werden zu '').

Behobener Fehler: wachsende „&amp;…“ in Kommentaren (11/2026)

Eriks Meldung nach dem Sitzungswechsel KW 45: In Kommentaren stand „&amp;amp;amp;nbsp;“. Ursache: sanitizeFormatHtml() (server.js) maskierte den Text zwischen den Tags mit htmlEscape() — auch ein &, das schon eine fertige Entity begann. Weil serializeProject() die Kommentare bei JEDEM Speichern eines Projekts erneut bereinigt (auch wenn nur eine Aufgabe geändert wurde), kam pro Speichern eine Stufe dazu (  → &nbsp; → &amp;nbsp; …). Jetzt maskiert textMaskieren() ein & nur, wenn es keine Entity beginnt — beliebig oft wiederholbar (geprüft: alle Kommentare nach dreimaligem Bereinigen unverändert). entitiesReparieren() (beim Start und nach /import, idempotent) führt gestapelte Stufen in project_kommentare.status und protokoll_punkte.text aller Sitzungen über entitiesEntstapeln() zurück.

Behobener Fehler: Kommentare abgewählter Gewerke (11/2026)

Eriks Meldung zu 21-4030: Im Projekt-Dashboard stand ein HLS-Kommentar, obwohl das Projekt seit KW 31 nur noch ELT hat. Ursache: Die einmalige Umstellung kommentareInAktuelleSitzungUebernehmen() hatte je Gewerk den jüngsten früheren Kommentar übernommen, ohne die aktuellen Gewerke des Projekts zu prüfen; der Sitzungswechsel trug ihn weiter. Jetzt: kommentareDesProjekts() (js/utils.js) — die Gesamtanzeige (kommentarVon(p, null), kommentarAnzeigeHtml()) und der Verlauf im Projekt-Dashboard (planKommentarDaten(), je Sitzung mit DEREN Gewerken) zeigen nur Gewerke des Projekts; ensureCurrentWeekRollover() übernimmt nur Kommentare dieser Gewerke. Gelöscht wird nichts — die verirrte Kopie fällt beim nächsten Sitzungswechsel aus der aktuellen Sitzung heraus.

Darstellung mehrerer Gewerke „B – Überschrift mit Linie“ (Eriks Wahl aus dem Artifact „Kommentare je Gewerk“): kommentarAnzeigeHtml(p, null, mitMeta) zeichnet je Gewerk eine Kopfzeile (Gewerk in seiner Farbe --gewerk-elt/-hls/-arch, feine Linie, mit mitMeta Verfasser und Zeit) und darunter den Text; bei nur einem Gewerk wie bisher nur den Text. Genutzt von der Kachel „Kommentar der Sitzung“ im Projekt-Dashboard (dort entfällt dann die gemeinsame Verfasserzeile, kommentarGewerkeMitText()), der Kommentaransicht der Projektliste/Wandtafel (renderKommentarAnsicht()) und den Verlauf-Kacheln (ohne Verfasser). Stile .kommentar-gewerke, .kommentar-kopf in styles.css.

Behobene Fehler (Testlauf 10/2026, fünf Test-Agents)

  • Gesperrte Sitzungen sind für Nicht-Admins auch inhaltlich unveränderlich: /save-week antwortet bei einer am Server gesperrten Woche mit 409 (conflict:true), nicht nur beim Entsperren mit 403 — der Browser lädt die Woche dann neu. Die Auto-Sperre ist nicht betroffen (sie schreibt nur Wochen, die am Server noch offen sind). Admins dürfen weiter.
  • /save-week lehnt doppelte Projekt-id oder doppelte projectUid in einer Woche mit 400 ab (vorher 500 bzw. ein Weg für Mitglieder, über eine Kopie mit gleicher uid doch Projekte anzulegen).
  • Login-Sperre: Schlüssel aus der normalisierten Kennung (loginKennung(): ohne "@…", getrimmt, klein) und req.socket.remoteAddress — X-Forwarded-For wird nicht mehr gelesen. Nicht-String-Passwörter → 400.
  • /import prüft die Datei (pruefeBackupDatei()) vor dem Auto-Backup; eine ungültige Datei gibt 400 und verdrängt keine echten Backups mehr.
  • Kiosk-Konten stehen weder in loadGlobalPeople() noch im Teilnehmerkreis (kontenInAktuelleSitzung() trägt sie aus, solange sie keinem Projekt zugeordnet sind) und sind keine gültigen Dateiprüfer.
  • Ein deaktiviertes Konto wird aus offenen Dateiprüfungen genommen (offenePruefungenAufraeumen()), sofern es nicht der einzige Prüfer ist. /my-dashboard zeigt nur Freigaben noch offener Dateien und wählt die Woche ohne die Sandbox.
  • Person-ID-Wechsel (PATCH /admin/users/:id) zieht die ID in der aktuellen Sitzung und der Sandbox in allen Personen-Tabellen mit.
  • Protokoll-Termine werden auf ISO normalisiert (normalisiereTermin(), "15.10.2026" → "2026-10-15"); die Textmuster-Punkte tragen status. Kaputte PDF/DOCX → 400. Gelöschte Protokolle entfernen ihre Quelldatei. Die Protokoll-Liste zählt Info-Punkte separat (infoCount).
  • Frontend: [hidden]{display:none!important} global (styles.css); der TODO-Dialog zeigt Zuständige außerhalb des Projekts als gestrichelte Chips statt sie beim Speichern zu verwerfen; toggleTodoDone() führt den Fortschritt mit; editLph() nimmt nur 0–100; "Aufgabe aus Kommentar" lehnt ein Fälligkeitsdatum vor heute ab; neue Protokoll-Punkte werden hinter ihrer Gruppe eingefügt; "Terminplan aus Honorar" beginnt am nächsten Arbeitstag; "Entsperren" im Wochenbericht nur für Admins.

Conventions & verification

  • Git repo; commits are German (e.g. "PDF Export von A4 auf A3 …"). Remote: http://192.168.3.187:3000/erik/projektuebersicht.git.
  • No linter, formatter or typecheck. Tests: npm test (node --test test/, keine Abhängigkeit) — test/zusammenfuehren.test.js prüft dreiwegeZusammenfuehren() (lädt public/js/store.js per vm), test/rechte.test.js startet einen echten Server mit leerer Datenbank in einem Wegwerf-Ordner (SESSIONS_DIR/DB_PATH, damit keine Sicherung im echten sessions/ landet) und prüft die Rechteprüfung beim Speichern. Sonst manuell: start server, open http://localhost:3003, exercise the relevant tab, confirm SQLite changes.
  • SESSIONS_DIR (Umgebungsvariable) lenkt den Datenordner um (Sicherungen, Uploads, Update-Auftrag) — für jeden Testserver setzen.