188 KiB
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:
Test.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 tabplan(<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 viaopenProjectPlan()injs/gantt.js, which is now justsetPlanTarget()+showTab('plan')— the same kind of switch as Dashboard → Projekte, sidebar and topbar stay put. There used to be a separateProjektetab 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 tabboard, 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.htmlno longer exists either. It was a standalone admin-only page with a hand-built header instead of the sidebar and its owncheckAuthAndInit()+ role check. Its markup moved intoTest.htmlas thebenutzersection of the Admin area, its<style>block intostyles.css, andjs/admin-users.jsnow exportsrenderAdminUsers()instead of running its own page entry.GET /admin-users.html301-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 Test.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
Test.html. GET /project-plan.html 301-redirects to the new route.
The whole app requires login (see "Auth / security quirk" below):
Test.html shows a blocking login overlay (checkAuthAndInit() in
js/auth.js) until GET /whoami confirms a session.
Test.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
├── 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/
│ ├── Test.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
│ │ ├── showmode.js # Show-Mode (Präsentationsmodus)
│ │ ├── 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
│ │ ├── 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 Test.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)
│ │ ├── 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 Test.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-%,
│ │ │ # Beteiligten-Matrix (verteilt auf Dashboard+Einstellungen)
│ │ ├── plan-settings.js # Bereich "Einstellungen": Projektangaben, Honorar,
│ │ │ # beauftragte Leistungsphasen (renderPlanSettings)
│ │ ├── 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-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
│ └── dein_bild.ico
└── sessions/ # SQLite-DB; bind-mounted into Docker container
└── projektuebersicht.db # node:sqlite database (WAL mode)
Running
npm install
npm start # http://localhost:3003, or PORT=3004 if occupied
docker compose up --build # Docker variant (compose file is at repo root)
- PORT env var overrides default 3003 in
server.js. - Frontend must be served over HTTP:
baseUrl()injs/server.jsuseswindow.location.originor falls back tohttp://localhost:3003when opened viafile://(then only seed/demo data loads). - Check
/healthreturns{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 inadmin-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 /→Test.html;GET /health. Public.- Deep links per department + tab:
GET /<Dept>/<Tab>→Test.html, e.g./HLS/Auslastungor/ELT/Projektübersicht. Public. Tab slugs (TAB_SLUGSinjs/globals.js,VALID_TAB_SLUGSinserver.js) areProjektübersicht|Wochenbericht|Auslastung|Projekte|Projektbeteiligte|MeineAufgaben|Einstellungen|Admin; anything else returns 404. Exception:GET /<Dept>/Projektplan(/<ProjName>)?(PLAN_SLUG) servesTest.htmlas well (the Projektbereich is a tab of it now, selected client-side byapplyUrlRoute()), withweekLabel|projNras query params (the project name is a readable path segment only; identification happens via weekLabel + projNr). The frontend reads/writes these routes viaapplyUrlRoute()/updateRouteUrl()injs/ui.jsand all static assets in the HTML use absolute paths (/js/...,/styles.css) so they resolve on nested routes.admin-users.htmlitself is just a static file underpublic/(no dedicated server route/slug). POST /login {username,password}→ Public. Verifies againstusers(scrypt, see Auth section), creates auser_sessionsrow 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 whenmust_change_passwordis 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 carriesmust_change_password. Until that dialog existed the endpoint had no caller at all: every new and every reset account got the flag,admin-users.htmldisplayed "Passwortwechsel erforderlich", and nobody could act on it.POST /change-email {email}→ Auth. Scoped toreq.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 viaPATCH /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 theirroles(discipline roles fromuser_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 bystart) fromuser_absences— loaded on demand by the admin-users.js edit modal, not embedded inGET /admin/users(would bloat the list response).POST /admin/users {username, displayName, email?, role?, personId?, department, initials?, roles?, password?, generatePassword?}→ Admin. Creates a user withmust_change_password=1;departmentis required (must be aVALID_DEPARTMENTSvalue, 400 otherwise — empty is only tolerated for the pre-existing bootstrap admin, not for new accounts);initialsis the short board-column label, stored as given (empty is allowed —loadGlobalPeople()falls back tousernameat read time, see Persistence);rolesis an array validated againstVALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwachung'])(400 on an unknown role) and written touser_roles. Password either given explicitly (≥ 8 chars) or auto-generated (generatePassword:true, returned once asgeneratedPassword).PATCH /admin/users/:id {displayName?, email?, role?, active?, personId?, department?, initials?, roles?}→ Admin. Partial update; samedepartment/rolesvalidation asPOST(only when the field is present);rolesis a replace-all (delete then re-insertuser_roles, same pattern as the oldserializeGlobalPeople). An admin can't deactivate or demote their own account. Deactivating a user immediately deletes all their sessions. Aperson_idcollision (now globally unique, see Persistence) is caught and returned as 409.POST /admin/users/:id/reset-password→ Admin. Sets a new random password, forcesmust_change_password=1, deletes all sessions of that user (forces re-login), returns the plaintext once asgeneratedPassword.DELETE /admin/users/:id[?hard=1]→ Admin. Default is a soft-delete (active=0+ delete sessions);?hard=1permanently deletes the row, but only if the user is referenced nowhere inproject_files.uploaded_by_user_id/project_file_reviewers.user_id/project_file_comments.user_id(else 409). 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). Requiresstart/end(400 otherwise); server generates the absenceid(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}— nodepartmentparam: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 toactive=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/sandboxUpdatedAtare the reserved Planung-Sandbox "week" (SANDBOX_WEEK_LABEL) split OUT ofweeks[]— see "Planung — projektunabhängige Terminplan-Sandbox" below — so it can never leak into any code that iteratesweeks.POST /save-week {week, baseUpdatedAt}→ Auth. Upserts one week (JSON object, keyed by label only). Used bysendWeekToServer(). The client sends its local revision inbaseUpdatedAt(theupdatedAtvalue fromload-all); the server compares it toweeks.updated_atbefore writing and answers409 {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 WITHOUTbaseUpdatedAt(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 withdeleted:false.POST /unlock-week {department, label}→ Admin. Unlocks a locked week (locked=0, newupdated_atrevision, 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. Lieferttasks(persönlich zugewiesene offene Aufgaben),projectDates(Aufgaben und Meilensteine der Projekte, denen der Aufrufer zugeordnet ist — siehe „Dashboard" unten),pendingReviewsundcolleagueAbsences. Aggregates, per department (ELT/HLS/ARCH), the currently unlocked week's open (done=0)plan_todosassigned to the caller'sperson_id(sorted by due date, empty dates last), pluspendingReviews— files where the caller is apendingreviewer (project_file_reviewersjoined withproject_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 matchingperson_idgets 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/missingdepartment(stricter thandepartmentKey()'s ELT-fallback — an upload must never land in the wrong department silently) and an emptyprojectNr(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()fromsessions/uploads/<department>/<stored_name>, withoriginal_filenameonly used as the download'sContent-Dispositionfilename.POST /project-files/:id/review {decision:'approved'|'rejected', note?}→ Auth. Only for a caller who is an eligiblependingreviewer of that file; 409 if the file's overall status is already terminal (approved/rejected). Recomputes the overall status: anyrejecteddecision →rejectedimmediately; all reviewersapproved→approved; otherwise stayspending.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 stillpending, 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 bynummerdescending — see "### Protokolle" below.GET /project-protokolle/:id→ Auth. One full Protokoll incl. allteilnehmer/punkte.POST /project-protokolle?projectNr=&projectName=&weekLabel= {datum, betreff, ort, teilnehmer}→ Auth. Creates a new Protokoll;nummeris server-assigned (MAX(nummer)+1for thatproject_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" patternsaveWeekRelational()uses for a week, not a REST route per field. A point with a knownidkeeps its server-trackedstamm_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 likePOST /project-files(express.raw, 50 MB limit; the route is listed inRAW_BODY_ROUTESso the globalexpress.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 theerkennePunkteAusText()heuristic on any failure — anything else in that param skips straight to the heuristic; creates the Protokoll through the samelegeProtokollAn()path as the plainPOSTabove — see "### Protokolle" below for both recognition paths and the PDF-library history. Response includeserkennungsart/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 ofGET /project-files/:id/download.GET /my-ai-settings→ Auth (not admin-only — scoped toreq.user.id, same pattern as/change-email). Returns, per provider, only whether an API key is stored (configured: bool) plus the — non-secret —apiUrlto pre-fill an edit form; never the key itself.PUT /my-ai-settings {chatgpt?: {apiKey?, apiUrl?}, claude?: {apiKey?, apiUrl?}}→ Auth, same scoping. A givenapiKeyreplaces it (empty string clears it); a givenapiUrlis validated viaistGueltigeAiUrl()—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 plainhttpis 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 viaerkennePunkteMitKI()(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_DEFSinserver.jsis 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 againstVALID_LPHS/VALID_DISCIPLINE_ROLES, values 0–1),autoLockAktiv,feiertagBundesland,maxBackups,sessionTage,loginMaxVersuche,loginSperreMinuten.GET /holidays→ Auth;POST /holidays {date,name}/DELETE /holidays/:date→ Admin.dateis the primary key, so posting an existing date just renames it.GET /holidaysalso returnsstates(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()inserver.jscombines 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 usesINSERT 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 inapp_settings.feiertagBundesland(defaultNW). 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.dbfile download (temp file inos.tmpdir(), deleted after send). Used bydownloadBackup()injs/backup.js. Additionally, on server startup the server writes a timestamped copy tosessions/backups/(also viaVACUUM INTO) and keeps only theMAX_BACKUPSnewest ones (default 10).POST /import→ Admin. Restores the whole database from a.dbbackup file sent as a raw binary body (express.raw, limit 100 MB). Validates the schema (pragma_table_infoon every table, exact column match vs. live DB), then swaps all data viaATTACH 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 viarunAutoBackup()(so a restore can be undone), then performs the swap. Used byimportDb()injs/import.js(the "DB Import" button is only shown to admins,renderUserBar()injs/auth.js, and the route itself is admin-only regardless of UI). Note:IMPORT_TABLESinserver.jslists the 16 original per-week/session relational tables (weeks…plan_milestone_deps) plusproject_protokolle/protokoll_teilnehmer/protokoll_punkte(added with the Protokolle feature — unlikeproject_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_absenceswere removed fromIMPORT_TABLESalong with the tables themselves, see Persistence.)ELT|HLS|ARCH(VALID_DEPARTMENTSinserver.js,DEPARTMENTSinjs/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_DEPARTMENTSis the shared value set for both.- Static assets get
Cache-Control: no-store. - JSON body limit is 2 MB.
Persistence (SQLite)
-
Data lives in
sessions/projektuebersicht.db(node:sqliteDatabaseSync, WAL mode,PRAGMA foreign_keys = ON,PRAGMA busy_timeout = 5000to avoidSQLITE_BUSYunder concurrent writers), created automatically. The schema is fully relational (26 tables, in the unconditionalCREATE TABLEblock at the top ofserver.js) instead of one big JSON document per week:weeks(department, label, week_id, date, locked, updated_at; PK(department,label)) —updated_atis the revision timestamp for the single-user conflict handling (see/save-weekabove)week_people/week_people_roles— person copies per week (array_orderkeeps the list order,role_orderthe role array order)attendance— présence per week/personprojects— 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= thep.orderfield, which is only unique within a group, not globally!;besprochen= ticked off in the team meeting — it belongs to the SESSION, not the project, socloneWeek()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 wieusers.department); steuert die Auswahl der Beteiligten im Assistentenproject_lph,project_assign(person_id|role keys split into columns),project_aufwand(LP effort per role),project_aktuelle_lpproject_plan,plan_people,plan_todos/plan_todo_assignees/plan_todo_deps,plan_milestones(positioncolumn!) /plan_milestone_depsusers(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 onperson_idandCREATE UNIQUE INDEX … ON users(person_id) WHERE person_id != ''(seeensureUserPersonIdUniqueIndex()below) —capacity_hours(contractual hours per week, added bymigrateUsersDepartment();0= not maintained and counts as a full position),person_id = personIdForName(display_name), the bridge to the existingperson_id-based assignment scheme (e.g.plan_todo_assignees) so dashboard aggregation needs no schema change there;departmentis aVALID_DEPARTMENTSvalue (empty only tolerated for the pre-existing bootstrap admin),initialsis the short board-column labeluser_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 PKYYYY-MM-DD, name) — firmenweite arbeitsfreie Tage. A table rather than a JSON value inapp_settingsbecause it is a list that grows and is maintained entry by entry. It feedsHOLIDAYS(js/globals.js) and thusisWorkday()/workdaysBetween()/addWorkdays()inpublic/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 onproject_nr— scoped likeproject_files, see "### Protokolle"protokoll_teilnehmer(id PK, protokoll_id, name, firma, anwesend, array_order) + index onprotokoll_idprotokoll_punkte(id PK, protokoll_id, stamm_id, nummer, gruppe, neu, thema, text, zustaendig, termin, erledigt, erstellt_am, array_order) + index onprotokoll_id—stamm_idlinks copies of "the same" point across Protokolle (the "Fortschreiben"),nummer/gruppeare free text.erstellt_am(added on Erik's follow-up request, migrationmigrateAddProtokollPunktErstelltAm(), 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 owndatum(a point added by hand or newly recognized on import) — and, likestamm_id, carried forward UNCHANGED by the Fortschreiben-copy inlegeProtokollAn()and byPUT /project-protokolle/:id(which looks up the existing row byidand 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 existingidgets a fresherstellt_am, from thedatumbeing saved in that very request — the same ruleneu/stamm_idalready 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 bymigrateLegacySchema()when converting a very old single-JSON-document DB, and then immediately retired bymigrateRetirePeopleTablesIntoUsers()(see Migration below) in the same startup.loadGlobalPeople(department)now reads exclusively fromusers/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/fullNamemap toperson_id/initials||username/display_name), soGET /load-all,board.js,load.js,gantt.js,plan-modals.jsneeded no changes.FK constraint note: although
PRAGMA foreign_keys = ONis set, onlyuser_sessions.user_id,user_roles.user_id,user_absences.user_idanduser_ai_keys.user_idhave a declaredFOREIGN 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/:idtherefore deletesproject_file_commentsandproject_file_reviewersrows explicitly before theproject_filesrow. -
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(), noteweeksuseslabel, all child tablesweek_label) and returns the newupdated_atrevision written byserializeWeek(). There is noserializeGlobalPeople()anymore — the global people list has no write path of its own; it is a read-only projection ofusers/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):
migrateLegacySchema()converts a DB from the old single-JSON-document format (detected via adatacolumn inweeks): it reads the documents, drops all tables, recreates the (then-current, including the now-retiredpeople/person_roles/person_absences) relational schema and inserts everything in one transaction.migrateWeeksUpdatedAt()adds theupdated_atcolumn toweeks(idempotent,ALTER TABLE … ADD COLUMN). Legacy rows keepupdated_at = '', so the first client save with an emptybaseUpdatedAtmatches and only then a real revision is written.migrateUsersDepartment()adds thedepartment/initialscolumns tousersif missing (idempotentALTER TABLE … ADD COLUMN, same pattern as step 2).ensureUserPersonIdUniqueIndex()first callsdedupeUserPersonIds()—person_idused to only need to be unique per department (the oldpeopletable); now that it globally identifies a user, any existing duplicates are resolved by appending_<user id>to every row but the first (logged withconsole.warn) — then createsidx_users_person_id_unique(a partial unique index skipping emptyperson_id).migrateRetirePeopleTablesIntoUsers()runs only while apeopletable still exists (no-op/idempotent otherwise): for every legacypeoplerow it looks up ausersrow by(department, person_id); on a match it copies that person'sperson_rolesintouser_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). Historicalperson_absencesrows are not migrated (serializeGlobalPeople()'s old code wroteString(absenceObject), i.e. literally"[object Object]", into that column, so there was nothing meaningful to carry over). It then dropspeople,person_roles, andperson_absences. Historical weeks are unaffected either way:week_people/week_people_rolesalready freeze each week's person name/roles at save time, independent of the global people table.bootstrapInitialAdmin()(see Auth section).
-
The DB file sits in
sessions/, so the existing Docker bind-mount./sessions:/app/sessionskeeps it persistent. The path is overridable via theDB_PATHenvironment variable (used for tests), production default issessions/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 underpublic/js/; all functions are globals onwindow. Three HTML files include them via<script src>tags; load order in the HTML defines dependencies, since there is no bundler:Test.htmlloadsglobals → utils → ui → date-picker → server → auth → store → weeks(shared modules), then the tab-specific foldersboard → people → projects → load → dashboard, finallygantt → showmode → tabs-auth → pdf → csv → backup → import → init. See the exact list inTest.html.auth.jssits right afterserver.jsbecausecheckAuthAndInit()needsbaseUrl(), and beforestore.js/weeks.jssince nothing may talk to the data endpoints before a session exists. Thejs/plan/*modules of the Projektbereich sit right aftergantt.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-honorar → plan-settings → plan-schedule): they must come aftergantt.js, whosebuildGanttHtml()plan-build-gantt.jswraps, andplan-historymust stay beforeplan-build-gantt, which uses its previous-week helpers. None of them collides with a main-module global — every one runs in an IIFE overwindow.PlanAppand exports throughwindow.*.admin-users.htmlis the second, fully independent page (the only one outside the tab system): it loads onlyglobals → utils → ui → server → auth → admin-users.js(nodate-picker, nogantt, noplan/*— it only talks to/admin/users*).
currentUser/checkAuthAndInit()(js/auth.js) are the shared client-side auth concept across all three pages:checkAuthAndInit(onAuthenticated)callsGET /whoami; with a session it setscurrentUser, hides the login overlay and runsonAuthenticated()(the page's normal entry point, e.g.initUiState()+switchDepartment()injs/init.js); without one it shows the blocking#loginOverlay/#loginFormand only proceeds after a successfulPOST /login.renderUserBar()shows the current user's name/role and toggles admin-only UI (the "DB Import" button, the#adminUsersLinktoadmin-users.html) based oncurrentUser.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/currentDepartmentlive injs/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+ weeklabel+projNr(+ readable projName path segment) via URL params:openProjectPlan()builds/HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…,findProjectForPlanresolves them. Person IDs followpersonIdForName(name)→p_<lowercased alnum>. save()debounces 500 ms thensyncAllToServer()writes only dirty weeks (dirtyWeeks– trackmarkWeekDirty()) viasendWeekToServer(). There is no separate people-list dirty-tracking/sync path anymore (nodirtyPeople/markPeopleDirty()/sendPeopleToServer()) — since Projektbeteiligte is a read-only projection ofusers/user_roles/user_absences, the only way the client ever changes "people" data is either (a) admin CRUD onadmin-users.html(writes straight to/admin/users*//users/:id/absences*, no debounce/dirty-tracking involved), or (b) the cross-department roster addition (addUserToCurrentWeekRoster()injs/people/people.js), which just pushes intocurrentWeek().peopleand 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-providedupdatedAtrevision is adopted as the new sync base; weeks whose save failed (network errors) stay dirty and are retried. A409conflict response drops the local changes for that week andhandleWeekConflict()reloads only that week from the server (reloadWeekFromServer()), leaving all other dirty weeks untouched. Exception:js/plan/plan-state.jssavePlan()bypassessave()and writes only the current week directly viasendWeekToServer()(with the same in-flight/trailing-save guard), adopting the returnedupdatedAtand showing a toast on conflict.switchWeek()(injs/weeks.js) does NOT trigger a save – only actual edits do.switchDepartment()cancels a pending save debounce before switching, andload()discards stale/load-allresponses after a department switch (frozenrequestedDepartment), 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()injs/people/people.jsis read-only rendering ofdb.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-rowrows and, viajs/people/people.js, the.person-list-itemrows — 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) viaweekSortKey(), not alphabetically/by filename. applyAutoLock()locks every week except the chronologically last one after load/clone/new-week;localStorageUI state is not restored for locked tabs.localStoragekeys:projuebersicht_v1(department only — tab is never restored from storage; at startup the tab comes from the deep-link URL if present, otherwiseDEFAULT_TAB='dashboard', i.e. "Meine Aufgaben"),projuebersicht-theme, header-collapse key. Do not store data inlocalStorage; it is UI state only.- Deep-link URLs (
/<Dept>/<Tab>) are shared via the address bar:updateRouteUrl()rewrites the path viahistory.replaceStateon 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 (
Test.htmlonly): 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 andtoggleHeader()/setHeaderCollapsed()mechanics fromjs/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-tabbuttons (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 tabsettings), the Werkzeuge accordion and the Admin accordion (its sub-items are the sections of theadmintab, see below), and a.side-menu-footerwith#userBarInfo/#adminUsersLink/logout (renderUserBar(), unchanged). Atmin-width: 769pxthe sidebar isposition: stickyand 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.htmldeliberately 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 tabplanofTest.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()injs/tabs-auth.js), so the sidebar still highlights where you are while in the Projektbereich.showTab()additionally hides.containerfort === 'plan', because#plandeliberately 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.jsnow only holdsshowTab()). Login is required for the entire app, not just for mutations/admin actions — every JSON endpoint except/login//whoamirequires at least a valid session (see the Auth-level markers in "Server API" above). - Sessions: a session is a row in
user_sessions(tokenPK, 30-dayexpires_at), identified via thepuebersicht_sessioncookie (HttpOnly; SameSite=Lax,Secureadded automatically when the request is HTTPS). Cookie parsing/setting is hand-rolled (parseCookies(),setSessionCookie()/clearSessionCookie()inserver.js) — nocookie-parserdependency, keeping the project's express+cors-only convention.attachUsermiddleware (registered viaapp.use(attachUser)right afterexpress.json()and beforeexpress.static, so it runs for every request including static assets) reads the cookie, loads the joined session+user row, and setsreq.user(ornull); it also refresheslast_seen_aton every authenticated request.requireAuth(401 ifreq.useris null) andrequireRole('admin')(401 if not logged in, else 403 ifrole !== 'admin') are the two middleware factories used on routes. - Passwords: hashed with Node's built-in
crypto.scryptSync(salt + 64-byte hash,hashPassword()/verifyPassword()inserver.js);verifyPassword()usescrypto.timingSafeEqual(). No bcrypt/argon2 dependency — stays within the express+cors-only convention./loginreturns the same generic error for wrong username, wrong password, or an inactive account (no username-enumeration signal). - Roles:
adminandmember(users.role— do not confuse with the disciplinerolesarray on a user,Planung/Projektleitung/Bauüberwachunginuser_roles, which is unrelated to permissions). 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). TheProjektetab and the (now read-only)Projektbeteiligtetab only require being logged in — any role; actually adding/editing/removing team members happens exclusively viaadmin-users.html(admin-only). The cross-department roster-add/remove UI in the Projektbeteiligte tab (js/people/people.js) is only rendered forcurrentUser.role === 'admin'— like the old client-only password modal, this is a UI-level gate, not a server-enforced one: it writes intocurrentWeek().peoplethrough the ordinarysave()→POST /save-week, which only requiresrequireAuth(any role), so it does not add a new hard permission boundary. - First-admin bootstrap:
bootstrapInitialAdmin()runs once at every server start, guarded bySELECT COUNT(*) FROM users— ifusersis empty (fresh install), it generates a random password (crypto.randomBytes(15).toString('base64url')), creates anadminaccount (usernameadmin,must_change_password=1), and prints the plaintext password to the console and writes it tosessions/INITIAL_ADMIN_PASSWORD.txt(outsidepublic/, soexpress.staticnever serves it). There is no hardcoded password anywhere in the code. users.person_idis the bridge between login accounts and the pre-existing, department-independentpersonIdForName()-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 matchingperson_idsees an empty dashboard with a hint instead of an error (renderDashboard()injs/dashboard/dashboard.js).- CryptoJS is gone; pdfmake (PDF export) is still loaded from
cdnjs.cloudflare.cominTest.html, so PDF export needs internet access and won't work offline.admin-users.htmlloads no CDN script. - User-provided status HTML is sanitized on render via a small whitelist
(
sanitizeStatusHtmlinjs/board/board.js) to mitigate XSS.
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), the progress per Leistungsphase (p.lph) and the assignment of people (p.assign) 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 and the Aufwands-Gewichtung live in the
Projektbereich (
einstellungensection,js/plan/plan-settings.jsplusjs/plan/plan-info.jsfor Gruppe/amtierende LPH). - Everywhere else: display plus a way there.
renderBoardDrawer()takesopts.nurAnsicht(set byBOARD_OPTSinjs/board/board.js): same card, same values, same order — but no input fields, no clickable avatars, and a.drawer-verweisunderneath with the button "Im Wochenbericht bearbeiten →". The Projektbereich does the same viasetzeBerichtVerweis()(js/plan/plan-info.js), filling#planStatusVerweis,#planLphVerweisand#planAssignVerweis. - The link is a real jump, not a hint:
springZumWochenbericht(uid)(js/plan/plan-info.js, also used byrenderBerichtVerweis()inboard.js) setsberichtAuswahlbeforeshowTab('wochenbericht')and therefore lands on that very project. Without it the user would have to find it again in a list of thirty. (berichtAuswahlis a globalletbinding fromjs/wochenbericht/wochenbericht.js— classic scripts, no modules, so assigning it from another file works.) - Consequence:
planStatusBlur(),setPlanLph()andsetPlanAssign()injs/plan/plan-info.jsare 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(),cycleAssign()andsaveBoardComment()injs/board/board.jsare untouched — the Wochenbericht's editing hangs off them. - 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.jsand 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 inplan-state.jsand unreachable fromplan-settings.js.
Eine Projektliste (tab board, labelled "Projekte")
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 rowdraggablewith a.drag-handle. The handlers stay injs/projects/projects.js(handleDragStart/handleDrop/handleDragEnd/reorderProjects) — they hold the ordering logic and are also used byjs/people/people.js. OnlyhandleDrop()had to learn which selector to use: it picks.board-row:not([style*="opacity"])when the drop zone contains board rows,.person-list-itemotherwise. Without that, every drop landed at the end of the group. - All four groups from
GROUP_ORDERare 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-actionsat the end of each row: "Öffnen" (Projektbereich) and "Löschen" (delProjectFromList()), 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.jsno longer renders anything by default:renderProjects()returns immediately unless#projectsListexists. Every caller (renderAll(),editProjectField(),auftrag.js,load.js) can stay as it is.TAB_SLUGS(js/globals.js) lostprojects, butTAB_BY_SLUG['Projekte']is set to'board'andVALID_TAB_SLUGS(server.js) still acceptsProjekte, 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.
- Projekt — Nummer, Bezeichnung, Gewerke, Gruppe, Schlagwörter
- Auftrag — Auftragsstatus, Honorar oder Projektgröße, beauftragte Leistungsphasen
- Stand — amtierende Leistungsphase
- Team — Beteiligte je Rolle, auf die Gewerke des Projekts eingeschränkt
- Terminplan — aus dem Honorar errechnet
- 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.
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 forbeauftragt— 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
beauftragtdoes 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()injs/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-weekrejects an unknown status with 400 before writing anything;auftragsstatusis inIMPORT_OPTIONAL_COLSso older backups still import, andprojekte.csvcarries the column.
Projektbereich (tab plan in Test.html)
- Each project has its OWN area, not just a Gantt view. The area is split into
six sections switched by the
.plan-navbar (showPlanSection()injs/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 inplan-info.js,plan-gantt.js,plan-files.js). The current section is written to the URL fragment withhistory.replaceState(…#aufgaben) and restored from it on load (app.initPlanSections(), called at the end ofrenderPlanPage()); ahashchangelistener follows external changes.- Dashboard — KPI tiles (
renderPlanKpis()), the session comment and the LPH progress list (renderPlanInfo()), "Nächste Termine" and "Team" (renderPlanDashboard()), plus three compact cards that jump to the Ressourcen/Dateien section or open the Verlauf modal. - Terminplan — the Gantt with its toolbar and, below it, the resource
load (
#resourceLoadContainer, formerly a modal). - 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 sameopenTodoModal()/openMilestoneModal()as the Gantt, and the indices match because the list keepsplan.todos/plan.milestonesorder. The create buttons (+ Aufgabe,+ Meilenstein,+ Vorlage,+ Terminplan) exist twice — in the Gantt toolbar and above the list, the latter with an…BtnListid suffix;renderPlanPage()hides both sets on locked weeks. A todo is only a Gantt row when it has BOTH a start and an end date.plan.todosis the one shared list either way — every todo always shows up here in Aufgaben, with its assignees (t.assignees, "Zuständige" chips inopenTodoModal(), unrelated to whether it has a schedule) — butplan-build-gantt.js(thebuildGanttHtml()extension that inserts TODO/milestone rows under the project row) only inserts a row for a todo withtodo.start && todo.end; a title-only todo (created via+ Aufgabeor+ TODOwith the date range left empty — nothing insaveTodoEdit()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.reorderPlanRows()(js/plan/plan-drag.js) accounts for this: its DOM-row-count safety check and the reordering itself only ever touch todos that havestart && end(scheduledIds) — a title-only todo has no drag handle in the Gantt to begin with and keeps its exact position inplan.todosuntouched 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 fromGET /honorar-configand are normalised across the beauftragte phases only (same asberechneHonorarPlan()), 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 — project number/name (display only — the page is
addressed by them), Gruppe, amtierende Leistungsphase, Beteiligten-Matrix
(all still rendered by
plan-info.js), plus Honorar and the beauftragte Leistungsphasen (renderPlanSettings(),js/plan/plan-settings.js).
- Dashboard — KPI tiles (
p.honorar/p.beauftragteLPcan 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 throughsetHonorarBetrag()/toggleBeauftragteLp()injs/plan/plan-honorar.jsand re-render the other mask afterwards, so they cannot drift apart. Persistence is the ordinarysavePlan()→POST /save-weekpath (columnsprojects.honorar/ tableproject_beauftragte_lph) — no dedicated endpoint.renderPlanGantt()is the central refresh hook: it ends by callingrenderPlanTasks()andrenderPlanDashboard(), so every existing mutation path (drag, modals, auto-schedule, templates, honorar plan) updates all three views without touching those modules.- The four former modals (
#planInfoModal,#planResourceModal,#planFilesModal) are gone; only#planHistoryModal(Verlauf) remains.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-loadeddb.weeksand 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 ininitUiState()therefore only callsetPlanTarget(). Addressing is by week label + project nr/name (neverproject.id, whichcloneWeek()re-assigns). On the very first deep-link renderdb.weeksis still empty; that is not an error but a "wird geladen…" state, andload()callsshowTab(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-allitself and wrote with its ownsendWeekToServer(), becausestore.jswasn'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 justmarkWeekDirty(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 callingsave(), sincesave()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=…fromwindow.PlanAppand keepswindow.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
hashchangelistener inplan-sections.jsand theresizelistener inplan-gantt.jsboth bail out unlesscurrentTab === 'plan'— they now run while completely different tabs are on screen.
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 everycloneWeek()(public/js/weeks.js), whilep.nrstays stable across the project's whole lifecycle. A project without anrcannot receive files: the upload button is disabled client-side (openFilesModal()injs/plan/plan-files.js) and the server rejects with 400 regardless. - Storage:
sessions/uploads/<department>/<uuid>.<ext>(uploadDirFor()/extractFileExtension()inserver.js). The on-disk filename is always a freshcrypto.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,filenamequery param) is ever interpolated into a filesystem path.original_filenameis stored purely for display and is only ever used as the second argument tores.download()(theContent-Dispositionfilename), 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 aproject_file_reviewersrow withdecision='pending'.POST /project-files/:id/reviewrecomputes the file's overallstatusafter every decision: any singlerejecteddecision blocks the file immediately (terminal); only once all reviewers haveapproveddoes the file becomeapproved(also terminal). Once terminal, no further review decisions are accepted (409). - Deletion (
DELETE /project-files/:id) is allowed for the uploader whilestatus='pending', or for an admin at any time; it deletes theproject_file_commentsandproject_file_reviewersrows explicitly (no FK cascade — see Persistence section) before theproject_filesrow, then best-effort removes the physical file. importnever touches these tables (see/importnote 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 itslegacy/build/pdf.mjsNode-compatible entry point, loaded with a dynamicimport()fromserver.js's otherwise-CommonJS code) andmammoth(.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; seeLOKAL_DISPATCHERfurther below.)extrahierePdfText(buf)(server.js) opens the document and walks every page'sgetTextContent()items, inserting a newline whenever an item's Y coordinate (item.transform[5]) changes — the same reconstruction a naive.join(' ')would lose, anderkennePunkteAusText()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 anoptionalDependencyofpdfjs-dist, not a hard one — the project's.npmrc(omit=optional) keepsnpm installfrom ever fetching it, so no native addon ships regardless of platform. First choice waspdf-parse@1.x, abandoned after it failed under real testing: pinned initially to dodgepdf-parse@2.x's hard dependency onpdfjs-dist+@napi-rs/canvas, butpdf-parse@1.1.4bundles apdfjsbuild frozen at 2017 (v1.10.100) that throwsUnknownErrorException: bad XRef entryon any PDF, valid or not, the moment Node's ownhttpmodule has been loaded anywhere in the process — reproducible with nothing more thanrequire('http')before the parse call, confirmed unrelated to the PDF's bytes (identical MD5 in and out) or their transmission (curl vs. direct Nodehttp.requestvs. a plain localfs.readFileSyncin the same process — only the last one, withhttpnever loaded, succeeded). Sinceexpressitself transitively loadshttp, this madepdf-parsefail 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 topdfjs-distdirectly (current, actively maintained, zero hard dependencies) was unaffected by the samerequire('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 asthemaand everything up to the next match astext; within that text, labeled fields ("Zuständig:", "Termin:" …,PROTOKOLL_LABEL_MUSTER) are pulled out if present, and the words "erledigt"/"geschlossen" seterledigt=true.gruppeis 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",#protokollImportErkennunginTest.html, built at dialog-open time fromGET /my-ai-settingsso an option can carry a live "(kein Schlüssel hinterlegt)" hint); the chosen provider is sent as?erkennung=chatgpt|claudetoPOST /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 toerkennePunkteAusText()) reads that ACCOUNT's own key viareadUserAiKey(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 callserkennePunkteAusText(text)instead, so the import always succeeds, even with no internet access at all. The response carrieserkennungsart('chatgpt'|'claude'|'textmuster') and, only on a fallback,erkennungshinweis(the failure reason); the client surfaces that via the existingapp.showToast()(plan-state.js) rather than silently pretending the AI path was used. Unlike the heuristic, the KI path can also attemptgruppeand 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), thingserkennePunkteAusText()deliberately never attempts (previous bullet).KI_SYSTEM_PROMPTstayed 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 EMPTYtextfields for every point — a longer, more explicit prompt (covering the multi-level numbering, an anti-hallucination rule forgruppe, 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 leavingtextitself 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 attemperature: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.04all under the same01.0 Organisationheading) 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 attemperature:0individually 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 ateilnehmerarray, with nothing genuine to put in it — span into a token-repetition loop (the same short name fragment repeated well over a thousand times) untilmax_tokenscut the response off mid-string, yielding invalid, unparseable JSON that discarded the otherwise-correctpunkteresult 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 booleanerledigtoutput with a tri-statestatus— matching the app's ownoffen/erledigt/infostatus already added for manual editing (see theprotokoll_punkte.statusbullet 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, whicherkennePunkteMitChatGPT()'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 correctlyinfo(matching their literal "Info" marker), others correctlyoffenorerledigtfrom the narrative content when no literal marker applied. Normalisation lives innormalisierePunktAusKi()(validates againstVALID_PROTOKOLL_PUNKT_STATUS, falls back tooffen) and inlegeProtokollAn()'szusatzPunkteloop, which accepts EITHER a validstatusstring (the KI path) OR falls back to deriving one from the older booleanerledigt(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 like01.0 01.06fully merged (2144 characters, three dated revisions in onetext) now split it again into two or three same-themed entries oncestatusentered 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 Cloudgpt-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:0was 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_tokenswas 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 samenormalisierePunktAusKi()into the same{nummer, gruppe, thema, text, zustaendig, termin, erledigt}shapeerkennePunkteAusText()already produces (heregruppeis a real pass-through, not hardcoded empty — see above), solegeProtokollAn()and everything downstream needs no branching on where a point came from. Each recognizer additionally returns a siblingteilnehmerarray (normalised throughnormalisiereTeilnehmerAusKi()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/importroute destructures it into its ownzusatzPunkte/teilnehmerAusDokumentlocals, passing the latter tolegeProtokollAn()instead of the hardcoded[]it started with. On the Textmuster-Fallback pathteilnehmerAusDokumentstays[], same as before — the heuristic still never attempts Teilnehmer recognition. Text sent to either API is capped atKI_MAX_TEXT_LAENGE(40,000 characters) — bounds cost/latency on a pathologically large upload, comfortably more than any real Besprechungsprotokoll needs. AnapiUrlargument overridesKI_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_keystable (user_id, providerprimary key,api_key,api_url,FOREIGN KEY … ON DELETE CASCADE— same User-Kind-Tabelle pattern asuser_roles/user_absences) replaces the two rows that used to live in the firmenweitapp_settingssingleton store.readUserAiKey(userId, provider)/writeUserAiKey(userId, provider, patch)(server.js) are the read/write path.GET/PUT /my-ai-settings(scoped toreq.user.id, never a client-supplied id — same pattern asPOST /change-email) manage them:GETreturns, per provider, only{configured, apiUrl}— never the key itself, the same "write-only, never read back" shape as a password, whileapiUrlis not secret and comes back so the form can be pre-filled;PUTsets or (given an explicit empty string) clears a key, and separately validates/sets or clears anapiUrlviaistGueltigeAiUrl()(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()insettings.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 savedapiUrlis 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 reuseserkennePunkteMitKI()unchanged, scoped toreq.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 localreasoning_efforthandling, 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 onokrather than on HTTP status.testMyAiConnection()(settings.js) drives the.ai-reconnect-btnnext to the status pill (spins via.is-testingwhile 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 transientshowSettingsNote()— good news is allowed to fade.istGueltigeAiUrl()allows plainhttp://on the loopback (localhost/127.0.0.1/::1) — everywhere elsehttps://is required. First shipped ashttps://-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 plainhttp://localhost:1234/…(a local dev server essentially never carries a TLS certificate forlocalhost). ThePUTsilently rejected that URL with 400, so the account'sapi_urlstayed 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 ownIncorrect API key provided…message, i.e. the request really did leave the building instead of staying onlocalhostas intended.httpstays 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 normalnpm start); inside the Docker container (see "Docker" below),localhostmeans the container, not the host, so reaching a host-side LM Studio/Ollama instance needshost.docker.internal(Docker Desktop) or the host's LAN address instead.normalisiereAiUrl(apiUrl, defaultUrl)fills in the provider's default path when a customapiUrlhas 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/completionsrequest path a rawfetch()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, socurl 127.0.0.1:1234reached his real instance, not a stand-in): its log readReceived request: POST to //Unexpected endpoint or method … Returning 200 anywayfor the bare origin, and correctly hit/v1/chat/completionsonce the path was appended. Only a missing or empty path (new URL(apiUrl).pathnameis''/'/') 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 originalKI_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 reportedThis 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 sameistLoopbackHost()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()sendsreasoning_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 asserver.jsitself, socurl 127.0.0.1:1234reaches 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 ownreasoning_contentfield, separate fromcontent) 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 underchat_template_kwargs; the/no_thinkprompt suffix) — onlyreasoning_effort: 'none'(the OpenAI o-series parameter, which LM Studio's compatibility layer honors) did:reasoning_tokensdropped to 0 and the same trivial prompt returned in 0.4s. Re-verified with the actualKI_SYSTEM_PROMPTandKI_PUNKT_SCHEMAagainst a real sample protocol text: correct, fully valid points (including correctly inferringerledigt: truefrom 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 whenistLoopbackHost()is true for the resolved URL — never to the real OpenAI endpoint, wheregpt-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-inthinkingparameter that this code never sends, so there's no equivalent default-reasoning trap to guard against there.LOKAL_DISPATCHER(server.js, a dedicatedundici.Agent) — the fix for a second, independent abort Erik hit onceKI_TIMEOUT_MS_LOKALwas first raised past 5 minutes:fetch failed/Headers Timeout Error, even though LM Studio itself kept computing. Node's globalfetch()runs on an internal undici client whose OWN defaultAgentcloses a connection afterheadersTimeout/bodyTimeout= 5 minutes each, entirely independent of the app's ownAbortController— invisible as long asKI_TIMEOUT_MS_LOKALitself 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-levelfetch failed. Reproduced directly: a throwaway local HTTP server that delays its response, fetched with a deliberately short custom dispatcher, failed with the exact samefetch failed/Headers Timeout Errorpair 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)dispatcherfield in its request options can override it, which needs an actualAgentinstance, hence the one new dependency:undici(a second dependency beyond pdfjs-dist/ mammoth, and the only one of the three NOT used for its ownfetch— only for itsAgentclass, passed into Node's own globalfetch()viadispatcher).fetchMitTimeout()attachesLOKAL_DISPATCHERonly for loopback targets, withheadersTimeout/bodyTimeoutkept 10s aboveKI_TIMEOUT_MS_LOKALso the app's own, friendlier abort message always wins the race against undici's lower-level one. Cloud targets are unaffected (KI_TIMEOUT_MS_CLOUDat 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 inserver.js). KI-gestützte Erkennung inherently needs outbound internet access from the server; the automatic fallback toerkennePunkteAusText()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).
- Both providers run over Node's built-in global
- 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-bannerinplan-styles.css, reusing the same--protokoll-neu-bg/--protokoll-neu-bordertokens as the.is-neupoint highlight) prompting a check before saving, plus a "Quelldatei herunterladen" link (GET /project-protokolle/:id/quelldatei, the Protokoll equivalent ofGET /project-files/:id/download) so recognized text can always be checked against the original. Both are driven purely by whetherquelleDateinameis 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-protokollewas extracted intolegeProtokollAn(opts), called by both routes, so an imported Protokoll gets the exact samenummerassignment 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, freshstamm_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(migrationmigrateAddProtokollQuelle(), same idempotentALTER TABLEpattern asmigrateAddProjectGroesse()), empty for every hand-created Protokoll. The uploaded file itself is stored the same way asproject_files—sessions/uploads/<uuid>.<ext>(uploadDirFor()/extractFileExtension(), both reused unchanged) — but not in theproject_filestable itself: that table's reviewer/approval workflow doesn't apply here (nobody "approves" someone else's protocol), soquelle_stored_nameis the only pointer to the file, andquelle_stored_name(unlikequelle_dateiname) is never sent to the client — only used server-side to resolve the download route.
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):
- Basis —
projektBasisHonorar(): das Honorar des Projekts. Fehlt es, trittprojects.groesse(0–1) malAPP_SETTINGS.referenzHonoraran seine Stelle. Fehlt beides, ist das Projekt nicht bewertbar — es wird als Lücke ausgewiesen, nicht als Null gezählt. - 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. - Stunden — offener Wert geteilt durch
APP_SETTINGS.stundensatz. - Rolle —
rollenGewicht()(Projektmatrix, sonst firmenweite Vorlage, unverändert), hier aber als Anteil: Gewicht der Rolle durch die Summe aller Rollengewichte dieser Phase. - 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 bekommtberechneAuslastung()dasassign-Objekt des Projekts als letzten Parameter. - Zeit —
projektVerteilWochen(): die Wochen bis zum spätesten Datum im Terminplan; ohne Terminplan (oder bei bereits verstrichenem Ende) giltAPP_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 — samtloadBarMax(), dem Admin-Feld und dem Eintrag inAPP_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.jsrechnet 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 — 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].projectshä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 inPROJEKT_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-subtlestatt 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#c53030kam 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-detailbleiben 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 Test.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_absencesrows with client-side CRUD/drag-reorder — has been fully replaced by the globaluserstable (see Persistence for the migration).db.globalPeople[department]is now populated purely by the server:loadGlobalPeople(department)(server.js) readsusers/user_roles/user_absencesfiltered todepartmentandactive=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 throughadmin-users.html(departmentis a required field there,initialsis the board-column label,rolesare 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()injs/people/people.js), sourced fromGET /usersfiltered todepartment !== currentDepartment, a setpersonId, and not already incurrentWeek().people.addUserToCurrentWeekRoster(user)pushes a normal person object ({id, name, fullName, email, role, absences:[]}, built from the chosen user) directly intocurrentWeek().peopleand calls the ordinarysave()— no new endpoint needed, sinceweek.peopleis already part of the whole week JSON that/save-weekwrites. Becauseboard.js'srenderBoard()/peopleByRole()/cycleAssign()andload.jsiterate exclusively overcurrentWeek().people(neverdb.globalPeople[dept]directly), the added person immediately shows up as its own assignable board column — no changes were needed inboard.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 olddelPerson()had).cloneWeek()carries these cross-department additions into the next week automatically (it copiesweek.peopleverbatim);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)
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_LABELlöst zudb.sandboxWeekstatt zudb.weeks.find(...)auf — ohne diesen Zweig würden Sandbox-Änderungen lautlos verworfen (dirtyWeeks.delete(), ohne je zu speichern), weildb.weeks.find()die Sandbox nie findet.openPlanView()(js/plan/plan-state.js): eigener Zweig fürapp.weekLabel === SANDBOX_WEEK_LABEL, derapp.week = db.sandboxWeeksetzt und das Projekt über die stabileidsucht statt übernr/namewie bei einem echten Projekt (openSandboxPlan(planId),app.sandboxPlanId) — Sandbox-Projekte werden nie geklont, ihreidist also im Unterschied zu einem echten Projekt dauerhaft stabil, und sie haben keinnr, also auch keine sinnvollenr/name-Adressierung.applyAutoLock()(weeks.js): filtertSANDBOX_WEEK_LABELheraus, bevor "welche Woche ist die späteste" berechnet wird — ein reines Sicherheitsnetz, da die Sandbox-Woche strukturell ohnehin nie indb.weeksankommt.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) undinitUiState()(js/init.js): die Deep-Link-Adresse eines Sandbox-Plans trägtplanIdstattprojNr(/Projektplan/<Name>?weekLabel=__sandbox__&planId=…), weil ein Sandbox-Plan keinnrhat.
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
Kompakt-Karte "Verlauf" (renderPlanHistory(), plan-history.js) zeigt einen
eigenen Hinweistext statt der (für eine nie geklonte Sitzung bedeutungslosen)
Versions-Historie. 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-settingsmechanism. "Mein Konto" itself holds master data for display, the account's own e-mail address, and the password change. Display name, role andpersonIdstay an admin's job viaPATCH /admin/users/:id; the area calls out a missingpersonIdexplicitly, since that silently empties "Meine Aufgaben" (GET /my-dashboardmatches onplan_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, idstoolsAccordionToggle/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 /backupalready 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.jslost every helper exclusive to it (pdfRoleBg(),pdfTableLayout(),pdfHeaderRows(),pdfProjectRow(),pdfGroupTitleRow(),pdfColumnWidths(),pdfCheckWidth(),pdfAttendanceBlock(),pdfLoadSection(), and the color constantsPDF_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. Werkzeuge is down to two items:+ Neues Projekt,Show Mode.
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 callshowAdminSection().ADMIN_SECTIONSis the single place a section is defined (key, heading); the binding to the markup runs through thedata-admin-sectionattribute on both the card inTest.htmland the sidebar item. The chosen section lives in the URL fragment (/Admin#auslastung) — same pattern as.plan-navin the Projektbereich — which is whyupdateRouteUrl()keepslocation.hashforadmin. - 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-activehighlight.syncAdminNavState()injs/tabs-auth.jsmarks the head active while the area is open, because it is no longer a.tabbutton and the generic highlight does not reach it. showTab('admin')redirects non-admins toDEFAULT_TAB: without it a forwarded/Adminlink showed a member an empty page, sincerenderAdminArea()renders nothing for them.- Sections:
- Benutzerverwaltung — accounts, roles, weekly hours, absences
(
js/admin-users.js,renderAdminUsers()); 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). Markedwide: trueinADMIN_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, andaufwandVorlage(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 —
arbeitsstundenProTagandautoLockAktiv. 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 underapplyAutoLock()); 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 intodb.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, butapplyAutoLock()re-locks every week except the latest, and runs again ondeleteWeekById()— 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 respectapp_settings.autoLockAktiv. Surfacing "Entsperrt" as its own row state only made this pre-existing risk visible, not new. - Feiertage — the
holidaystable, 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/suggestabove). 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 — create/delete steps per LPH
(
POST/DELETE /lph-templates). - Betrieb & Sicherheit —
maxBackups,sessionTage,loginMaxVersuche,loginSperreMinuten. The session lifetime applies to new logins; existing sessions keep their storedexpires_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.
- Benutzerverwaltung — accounts, roles, weekly hours, absences
(
- 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()). Sinceplan-honorar.jscaches the config for the whole page lifetime,saveHonorarConfig()callsinvalidateHonorarConfig()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 fromAPP_SETTINGS(js/globals.js), filled byloadAppSettings()injs/store.jsbefore/load-all, sincerenderAll()already computes with them. If that fetch fails the defaults injs/globals.jsstand — deliberately identical toAPP_SETTING_DEFS, so the app keeps computing as it did instead of showing wrong numbers. - Part-time capacity.
users.capacity_hours(maintained per account inadmin-users.html) is what the Auslastung now measures a person against:kapazitaetsFaktor()injs/load/load.jsdivides the raw weekly load bycapacity_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 hasp_apwhere the account now carriesp_alexander_plum, so the id alone would never find them and the full name is the second bridge.js/pdf.jsapplies 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')onPUT /app-settings,PUT /honorar-config,POST/DELETE /lph-templates,POST/DELETE /holidays,GET /backup,POST /importandPOST /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. berichtAuswahlholds theproject_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 fromrenderWochenbericht()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 ordinarysave()path). - Besprochen & weiter — ticks off and jumps to the next open item, searching behind the current position first, then from the top.
- Keyboard: ↓/↑ switch project, Ctrl+↵ saves the comment and moves on.
Bound at document level but gated on
currentTab === 'wochenbericht'. - Vorwochen-Zeile — last session's comment above the input field, the answer to "what did we agree last week?" without opening the Verlauf.
- 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 isexportSandboxPlanPDF()for Planung-Terminpläne; the third, the board's own gewerkeübergreifende A3 session-overviewexportPDF(), 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(), readingw.attendance/w.peopleexactly like the on-screenrenderAttendance(): a missing entry counts as not present, same as an explicitfalse— 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 byclassifyProjectChange()'s bucket (PDF_WB_CHANGE_COLORS: new/status/progress/unchanged), two columns inside — comment on the left viapdfStatusText()(same bold/color fidelity as the screen), a gridded Leistungsphasen-table and Beteiligte-table on the right (pdfWbLphMiniTable()/pdfWbBeteiligteTable(), falling back to allLPHSwhen none are beauftragt — same rule asrenderBoardDrawer()), plus who's assigned but marked absent (abwesendeBeteiligte()). The amtierende-LPH value is computed once (pdfWbAktuelleLphInfo(), samelpActiveList()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. ReusesPDF_STYLES/pdfStatusText()/loadLogoPng()from the existing module.pdfHslToHex()is the one color helper: it lets the browser itself convertgetProgressColor()'shsl()output (pdfmake doesn't understandhsl()) into the hexpdfNormalizeColor()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 — marks projects whose assignees are absent, read
from
week.attendance. Stays silent while nobody is marked present at all, since "everyone is missing" would be a false alarm.
- Besprochen-Haken + "12 von 29 besprochen" (
- 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-dichtitself. - 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 anis-prefix now.
- The dense-card rules were scoped to
- 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-levellet,nulluntil resolved. Deliberately not persisted tolocalStorage— 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 ofrenderWochenbericht()and, once per page load, setswochenberichtGewerktocurrentUser.department(added toreq.userinattachUser(),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 maintaineddepartment(e.g. the bootstrap admin) falls through withwochenberichtGewerkstillnull. - Changing it is a plain
<select>, not a full-screen step (renderGewerkDropdown(), rendered both in the normal header viarenderBerichtKopf()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 andoeffneGewerkWizard()are gone. Picking an option callswaehleWochenberichtGewerk(g)directly (onchange), which re-renders. - The filter lives in ONE place,
berichtProjekte(w)itself: whenwochenberichtGewerkis set, it returns only projects whosep.gewerke(fromproject_gewerke, populated by the "Neues Projekt" wizard's Gewerke step,js/projekt-wizard.js) includes it. BecauserenderWochenbericht(),renderBerichtListe(),aktuellesBerichtProjekt(),openBerichtProtokoll()(the Protokoll text export) ANDexportWochenberichtPDF()(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 maintaineddepartment, since everyone else gets one fromwochenberichtGewerkVoreinstellen()— the guard costs nothing either way). - Attendance is unaffected —
w.attendance/w.peopleare session-wide, not per-Gewerk, sopdfWbAnwesenheitBlock()/renderAttendance()keep showing everyone regardless of the chosen Gewerk. - Backfill for pre-existing projects
(
migrateBackfillProjectGewerkeAusTags(),server.js, runs at every startup): every project that predates the Gewerke step — i.e. has zeroproject_gewerkerows — gets its Gewerk copied from whicheverELT/HLS/ARCHentry already sits in itsproject_tags(the markermigrateRemoveDepartmentPartition()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 zeroproject_gewerkerows, 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 throughtogglePlanGewerk()— the exact same mutate-p.field-then-savePlan()pattern astoggleBeauftragteLp()(js/plan/plan-honorar.js). Before this, a project's Gewerke could only ever be set once, during creation.
Wochenbericht — Verlauf der Vorwochen
- 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. - 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 formerBERICHT_OPTS.timelineflag is gone, since the history now hangs off every card rather than only the Wochenbericht's.
Dashboard ("Meine Aufgaben", now the default landing tab)
Aufbau: Kopf — Band — zwei Zonen. Die Anordnung folgt der Frage, die beim Blick aufs Dashboard zuerst ansteht: „Was muss ich heute anpacken?"
- 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. - 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 ist1.15emgroß — 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. - Linke Zone — der eigene Arbeitsstrom: „Meine Aufgaben" und „Termine
meiner Projekte", beide in derselben
.dash-karte. - 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
taskssteht, wird ausprojectDatesherausgefiltert (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_milestoneshat keinedone-Spalte), deshalb zählt bei ihnen allein das Datum; bei Aufgaben gilt zusätzlichdone = 0und ein gesetztes Enddatum. -
Die Namen der Zuständigen kommen aus
week_people, nicht ausloadGlobalPeople(): 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 Listep_apstatt „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 übernew 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 alsoTZ=Europe/Berlin. -
GET /my-dashboard(Auth) is the sole data source for this tab (js/dashboard/dashboard.js, slugMeineAufgaben, first item in the sidebar nav andDEFAULT_TABinjs/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 viaweekSortKeyServer(), a server-side port ofweekSortKey()(public/js/utils.js). Tasks are matched byplan_todo_assignees.person_id = req.user.personIdand sorted by due date (empty dates last). The response also includespendingReviews(files awaiting the caller's review decision), populated by the file-upload feature described above.
Docker
docker compose up --buildfrom the repo root. Compose maps3003:3003, bind-mounts./sessions:/app/sessions,restart: unless-stoppedand setsTZ: 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-rootnode, HEALTHCHECK on/health..dockerignoreexcludesDocker/*(exceptDocker/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 /backupist admin-only (requireRole('admin')). Der Snapshot enthält die komplette Datenbank samtuser_sessions.tokenim 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-weekweistlocked: true → falsefür Nicht-Admins mit 403 ab. Das Sperren (false → true) bleibt jedem erlaubt, weilapplyAutoLock()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 gesetzterperson_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 perarray_orderzu indizieren — kollidierende Ordnungszahlen hätten Personen still überschrieben.- JSON-Dateien lassen sich hochladen: Der globale
express.json()-Parser läuft nicht mehr über die inRAW_BODY_ROUTESgelisteten 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()injs/people/people.js. Vorher entstandweek.peoplenur beinewWeek(), 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.jszä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 fehlgeschlagenenload()werden keine Beispieldaten mehr erzeugt (seed()lief früher auch im Fehlerfall und überschrieb persave()die echte "KW 30 / 2026"). sanitizeStatusHtml()packte unerlaubte Elemente mit einem falschen Referenzknoten aus (insertBefore(child.firstChild, node)) und warf dabeiNotFoundError— 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()injs/weeks.js): keine Dubletten (der Server schlüsselt allein über das Label) und Rückfrage bei fehlendemKW …-Muster (sonst sortiert die Sitzung hinter alle echten undapplyAutoLock()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 (
>= 1statt> 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-bgz-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 VorlageLP_AUFWAND_VORLAGEfü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_byhält fest, wer den Eintrag angelegt hat.DELETE /users/:id/absences/:absenceIderlaubt das Löschen nur der betroffenen Person, dem Ersteller oder einem Administrator (sonst 403). Die Spalte wird beim Start idempotent nachgerüstet; Altbestand hatcreated_by = 0und ist damit nur von der betroffenen Person oder von Administratoren löschbar. - Fokus beim Speichern per Fokuswechsel (
js/board/board.js): Einmousedown-Lauscher in der Capture-Phase merkt sich, in welches Kommentarfeld geklickt wird; nach dem Neu-Rendern stelltrestorePendingCommentFocus()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()injs/gantt.js(griffen auf Elemente zu, die es in keiner Seite mehr gibt),statusBlur()unddelProj()injs/board/board.js(seit dem Textfeld-Editor bzw. der neuen Zeilendarstellung ohne Aufrufer), 16 CSS-Regelblöcke der früheren TODO-Liste (damals inproject-plan.html), die wirkungslose Klasseplan-main-content-fullund der gegenstandslose 900px-Umbruch.buildGanttHtml()bleibt injs/gantt.js—js/plan/plan-build-gantt.jsumhüllt sie (originalBuildGanttHtml), sie ist also die Grundlage des gesamten GANTT. Der Link "Zurück zur Übersicht" inadmin-users.htmlnutzt jetzt die neue Regela.secondarystatt Inline-Stilen. esc()verträgt Nicht-Strings: Leistungsphasen-Prozentwerte kommen je nach Herkunft als Zahl an;(100).replaceist keine Funktion und riss das Rendern des Wochenberichts mit.esc()wandelt jetzt überString()um (nurnull/undefinedwerden zu'').
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 filesessions/projektuebersicht.dbgrows).