Files
projektuebersicht/CLAUDE.md
T
erikandClaude Opus 5.5 4ac3d8d53d Kommentare je Gewerk klar getrennt, abgewählte Gewerke ausgeblendet
- Kommentare mehrerer Gewerke mit Überschrift und Linie: Gewerk in seiner
  Farbe, rechts Verfasser und Zeit, darunter der Text (Projekt-Dashboard,
  Projektliste, Wandtafel, Verlauf-Kacheln)
- Behoben: Im Projekt-Dashboard erschienen Kommentare von Gewerken, die das
  Projekt nicht mehr hat (z. B. HLS bei 21-4030); Anzeige und Verlauf zeigen
  nur noch Gewerke des Projekts
- Sitzungswechsel übernimmt nur noch Kommentare der Gewerke des Projekts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:08:20 +01:00

4941 lines
329 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```bash
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
<Monat>“ 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.
- **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 "-&nbsp;" (`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 …: <Text>" 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 — 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 „&amp;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 `projectUid`s,
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 kein Erledigt-Kennzeichen** (`plan_milestones` hat
keine `done`-Spalte), deshalb zählt bei ihnen allein das Datum; bei Aufgaben
gilt zusätzlich `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.
## 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;amp;…“ in Kommentaren (11/2026)
Eriks Meldung nach dem Sitzungswechsel KW 45: In Kommentaren stand
„&amp;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; → &amp;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, typecheck, or test suite. Manual verification only:
start server, open `http://localhost:3003`, exercises the relevant tab,
confirm SQLite changes (the DB file `sessions/projektuebersicht.db` grows).