Plan Checker (neues Studenten-Werkzeug, Seitenleiste + Admin → Plan-Checker- Merkmale): lädt einen Bauplan als PDF hoch und lässt ChatGPT oder Claude ihn gegen eine gepflegte Merkmalsliste prüfen. PDF-Rendering (pdf.js + @napi-rs/canvas) berücksichtigt jetzt Ebenen (Optional Content Groups) und WASM-Decoder korrekt, sendet eine Übersicht plus mehrere hochaufgelöste Kachel-Ausschnitte statt eines einzelnen, zu groben Bilds, und beide Anbieter bekommen dasselbe Bildmaterial. Jedes Prüfergebnis kann auf eines der gesendeten Bilder als Beleg verweisen (bildIndex) — als Screenshot im Ergebnis anklickbar, öffnet vergrößert in einem Modal statt eines von Chrome blockierten target=_blank-Links auf eine data:-URL. KI-Modellname ist jetzt pro Konto überschreibbar (Einstellungen → Mein Konto) für Drittanbieter mit abweichendem Modell-Katalog. Wochenbericht: dichte Kartenansicht bekommt einen Fokus-Modus — nur der Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in aufklappbaren Abschnitten statt dauerhaft nebeneinander zu stehen. Projektbereich: Terminplan-Auslastung rechnet jetzt exakt wie der Auslastungs-Tab/die Wandtafel (statt einer unabhängigen, tageweisen Schätzung); die LPH-Fortschrittsliste im Dashboard zeigt nur noch die beauftragten Leistungsphasen. Wandtafel: Dashboard-Kopf mit Kennzahlen-Reihe und Panel-Reihe neu strukturiert, in den Blautönen des Firmenlogos; dieselbe Palette wurde anschließend auf den gesamten App-weiten Dark Mode übernommen. Dateibereinigung: nicht mehr benötigte Test-Uploads/Backups entfernt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3360 lines
224 KiB
Markdown
3360 lines
224 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## Overview
|
||
|
||
Single-page app for tracking engineering projects across weekly sessions
|
||
("Sitzungen"). Vanilla JS/HTML/CSS frontend + Node.js/Express server. No build
|
||
step, no bundler, no tests. Data is persisted in a SQLite database
|
||
(`node:sqlite`, needs Node >= 22.13) at `sessions/projektuebersicht.db`.
|
||
|
||
**ONE** HTML entrypoint, served statically by Express:
|
||
- `App.html` — the whole app (sidebar nav items, in order: Meine Aufgaben,
|
||
**Projekte**, Wochenbericht, Auslastung, Projektbeteiligte, then
|
||
**Einstellungen** and — for admins only — **Admin**). Served at `/`. It
|
||
also contains the **Projektbereich** — the area of ONE project — as the
|
||
tab `plan` (`<div id="plan">`), with its own sub-navigation (`.plan-nav`)
|
||
over six sections: **Dashboard**, **Terminplan** (Gantt), **Aufgaben**
|
||
(task/milestone list), **Leistung** (erbrachter Leistungswert, see below),
|
||
**Dateien**, **Einstellungen** (project master data incl. honorar and the
|
||
beauftragte Leistungsphasen). Opened via `openProjectPlan()` in
|
||
`js/gantt.js`, which is now just `setPlanTarget()` + `showTab('plan')` —
|
||
the same kind of switch as Dashboard → Projekte, sidebar and topbar
|
||
stay put.
|
||
**There used to be a separate `Projekte` tab next to the Projektboard.** It
|
||
listed the very same projects a second time and offered a second editing
|
||
mask for the very same fields. Both are now ONE list — the tab `board`,
|
||
labelled **Projekte** (see "Eine Projektliste" below). The URL still reads `/Projektplan/<Name>?weekLabel=…&projNr=…`
|
||
and the selected section still lives in the URL fragment (`…#aufgaben`),
|
||
so both stay linkable. See "Projektbereich" below.
|
||
**`admin-users.html` no longer exists either.** It was a standalone
|
||
admin-only page with a hand-built header instead of the sidebar and its own
|
||
`checkAuthAndInit()` + role check. Its markup moved into `App.html` as the
|
||
`benutzer` section of the Admin area, its `<style>` block into `styles.css`,
|
||
and `js/admin-users.js` now exports `renderAdminUsers()` instead of running
|
||
its own page entry. `GET /admin-users.html` 301-redirects to `/Admin#benutzer`.
|
||
The role check inside it is gone: the Admin area is unreachable for members
|
||
anyway (`showTab()` redirects them), and the endpoints are server-enforced.
|
||
|
||
**`project-plan.html` no longer exists.** It used to be a separate document
|
||
with a reduced imitation of the sidebar (plain `<a href>` links, no
|
||
Einstellungen/Admin sections) and its own `/load-all` fetch. Its markup moved
|
||
into `App.html` (`#plan` plus the plan dialogs), its `<style>` block moved to
|
||
`public/plan-styles.css`, and its modules under `js/plan/` are now loaded by
|
||
`App.html`. `GET /project-plan.html` 301-redirects to the new route.
|
||
|
||
The whole app requires login (see "Auth / security quirk" below):
|
||
`App.html` shows a blocking login overlay (`checkAuthAndInit()` in
|
||
`js/auth.js`) until `GET /whoami` confirms a session.
|
||
|
||
`App.html`'s navigation is a single persistent **left sidebar**
|
||
(`#headerMenu`/`.side-menu`) — it replaced the former top tab bar, a
|
||
separate `<header>`, and a separate off-canvas tool drawer, which have all
|
||
been merged into this one element (see "Frontend architecture" below).
|
||
After login the app lands on the **Dashboard ("Meine Aufgaben")**, not the
|
||
project list (`DEFAULT_TAB` in `js/globals.js`); the list remains
|
||
fully available as a sidebar nav item. The former "Projektbeteiligte" tab
|
||
(per-department `people`/`person_roles`/`person_absences` CRUD) has been
|
||
fully replaced by the global `users` table: it is now a read-only view of
|
||
that department's active users, and only admins manage team members, via
|
||
`admin-users.html` (see "Projektbeteiligte" below).
|
||
|
||
All UI strings/comments are German. Preserve German in
|
||
user-facing text.
|
||
|
||
## File structure
|
||
|
||
```
|
||
.
|
||
├── server.js # Express server + SQLite API
|
||
├── package.json # deps: express, cors, plus pdfjs-dist/mammoth
|
||
│ # (added for Protokoll-Import, see "### Protokolle")
|
||
├── .npmrc # omit=optional — hält pdfjs-dist's rein optionales
|
||
│ # @napi-rs/canvas (nur für Seiten-Rendering, hier
|
||
│ # ungenutzt) aus node_modules heraus
|
||
├── docker-compose.yml # at repo ROOT; build context ., dockerfile Docker/Dockerfile
|
||
├── Docker/Dockerfile # node:22-alpine (nötig für node:sqlite)
|
||
├── .dockerignore
|
||
├── public/
|
||
│ ├── App.html # main app (served at /)
|
||
│ ├── admin-users.html # standalone admin-only user management page
|
||
│ ├── js/ # shared modules (classic scripts, global fns)
|
||
│ │ ├── globals.js # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS, TAB_SLUGS) + db/currentDepartment
|
||
│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName,
|
||
│ │ │ # peopleByRole, assignKey, weekSortKey +
|
||
│ │ │ # renderProjektVerlauf() (die EINE Verlaufs-
|
||
│ │ │ # Darstellung, von allen drei Stellen genutzt)
|
||
│ │ ├── ui.js # LocalStorage-UI-State, Header-Collapse, Theme
|
||
│ │ ├── date-picker.js # Datums-/Bereichspicker-Overlays
|
||
│ │ ├── server.js # baseUrl, sendWeekToServer, deleteWeekFromServer, blobToDataUrl (global people list has no client write path anymore, see Persistence)
|
||
│ │ ├── auth.js # currentUser, checkAuthAndInit (Login-Overlay), logout, renderUserBar
|
||
│ │ ├── store.js # save/syncAllToServer, switchDepartment, load, seed
|
||
│ │ ├── weeks.js # Wochen-Auswahl, Auto-Lock, -Löschung, unlockCurrentWeek (POST /unlock-week) —
|
||
│ │ │ # Erzeugung/Klonen entfallen, siehe "Automatischer Sitzungswechsel"
|
||
│ │ ├── gantt.js # GANTT-Diagramm (buildGanttHtml, openProjectPlan), geshared
|
||
│ │ ├── tabs-auth.js # showTab (5 Tabs; kein Passwortschutz mehr, siehe Auth-Abschnitt)
|
||
│ │ ├── pdf.js # drei PDF-Exporte (pdfmake): exportWochenberichtPDF()
|
||
│ │ │ # ist die Tagesordnung als Bericht je Gewerk — Übersicht
|
||
│ │ │ # + eine Akte je Projekt (A4 hochkant), exportSandboxPlanPDF()
|
||
│ │ │ # der Balkenplan eines Planung-Terminplans (A3 quer),
|
||
│ │ │ # exportProtokollPDF() das Besprechungsprotokoll eines
|
||
│ │ │ # Projekts (A4 hochkant, Punkte gruppiert, neue gelb)
|
||
│ │ ├── projekt-wizard.js # Assistent "Neues Projekt" (sechs Schritte,
|
||
│ │ │ # Gewerke, gefiltertes Team, Terminplan aus Honorar)
|
||
│ │ ├── auftrag.js # Auftragsstatus (Anfrage→Angebot→beauftragt→
|
||
│ │ │ # abgeschlossen), Chip/Auswahlfeld und der
|
||
│ │ │ # Beauftragungs-Dialog
|
||
│ │ ├── backup.js # downloadBackup (lädt SQLite-Snapshot von GET /backup)
|
||
│ │ ├── import.js # importDb (Backup-Wiederherstellung über POST /import, admin-only)
|
||
│ │ └── init.js # renderAll/initUiState + Seiteneinstieg hinter checkAuthAndInit() (nur App.html)
|
||
│ │ ├── board/ # tab-specific: die Projektliste ("Projekte")
|
||
│ │ │ └── board.js # Projektlisten-Rendering (Anzeige, BOARD_OPTS
|
||
│ │ │ # .nurAnsicht), Reihenfolge/Löschen und der
|
||
│ │ │ # Kommentar-Editor (Textfeld mit Verfasser/Zeitstempel,
|
||
│ │ │ # ausdrücklichem Speichern und scrollbarem Verlauf der
|
||
│ │ │ # früheren Sitzungen; auch vom Wochenbericht genutzt —
|
||
│ │ │ # Bedienelemente adressieren ihr Feld über closest(),
|
||
│ │ │ # nicht über IDs, da beide Tabs dieselben Projekte
|
||
│ │ │ # rendern). Kürzel-Legende und Anwesenheit sind hier
|
||
│ │ │ # entfallen (Anwesenheit siehe wochenbericht/)
|
||
│ │ ├── wochenbericht/ # tab-specific: Wochenbericht (Tagesordnung des
|
||
│ │ │ # wöchentlichen Teammeetings — dort wird JEDES
|
||
│ │ │ # Projekt angesehen, Vollständigkeit vor Filterung)
|
||
│ │ │ └── wochenbericht.js # Gruppierung "Neu kommentiert"/"Fortschritt"/
|
||
│ │ │ # "Unverändert" per project_uid-Vergleich zur Vorwoche
|
||
│ │ │ # (nutzt renderBoardDrawer/renderBoardRow aus board.js;
|
||
│ │ │ # die Karten tragen ein farbiges Kopfband je
|
||
│ │ │ # Änderungsart — .report-card-neu/-fortschritt,
|
||
│ │ │ # Farben in --band-neu/--band-fortschritt — und
|
||
│ │ │ # stellen die Schubladen-Blöcke dreispaltig; zeigen
|
||
│ │ │ # über BERICHT_OPTS nur die beauftragten
|
||
│ │ │ # Leistungsphasen, Rückfall auf alle wenn leer)
|
||
│ │ │ # + renderProjectTimeline(): der aufklappbare
|
||
│ │ │ # "Verlauf" je Projekt — die Kacheln der früheren
|
||
│ │ │ # Sitzungen waagerecht und scrollbar, mit
|
||
│ │ │ # Kommentar, LPH-Ständen, Terminen aus dem Plan
|
||
│ │ │ # und Beteiligten JENER Woche; gegenüber der
|
||
│ │ │ # Kachel davor geänderte Werte hervorgehoben
|
||
│ │ │ # + Anwesenheitserfassung am Seitenfuß
|
||
│ │ │ # (renderAttendance/toggleAttendance/setAllAttendance,
|
||
│ │ │ # binär an/abwesend über currentWeek().people mit Zähler)
|
||
│ │ ├── people/ # tab-specific: Projektbeteiligte
|
||
│ │ │ └── people.js # read-only Personen-Liste (aus db.globalPeople, kein CRUD/Drag-
|
||
│ │ │ # and-Drop mehr) + abteilungsübergreifende Besetzung (admin-only)
|
||
│ │ ├── projects/ # Ablage-Logik der Projektliste (der eigene
|
||
│ │ │ └── projects.js # Projekte-Tab ist entfallen): Drag-and-Drop,
|
||
│ │ │ # reorderProjects(), delProjectFromList();
|
||
│ │ │ # renderProjects() rendert nur noch, wenn
|
||
│ │ │ # #projectsList existiert
|
||
│ │ ├── load/ # tab-specific: Auslastung
|
||
│ │ │ └── load.js # Auslastungs-Tab als LASTBAND (Zeile je Person
|
||
│ │ │ # und Rolle, Balken nach Projekten aufgeteilt),
|
||
│ │ │ # LP-Modal, Neues-Projekt-Modal
|
||
│ │ │ # (inkl. Auswahl der beauftragten Leistungsphasen,
|
||
│ │ │ # p.beauftragteLP — dieselbe Liste wie im
|
||
│ │ │ # Honorar-Dialog der Projektplan-Seite)
|
||
│ │ ├── dashboard/ # tab-specific: Meine Aufgaben
|
||
│ │ │ └── dashboard.js # renderDashboard (GET /my-dashboard: offene TODOs + ausstehende Freigaben)
|
||
│ │ ├── wand/ # tab-specific: Wandtafel (Touch-Dashboard, ersetzt Show Mode)
|
||
│ │ │ └── wand.js # renderWandboard — rein client-seitige Kacheln aus db (kein eigener Endpunkt)
|
||
│ │ ├── settings/ # tab-specific: Einstellungen
|
||
│ │ │ └── settings.js # NUR "Mein Konto": Stammdaten + Passwortwechsel
|
||
│ │ ├── admin/ # tab-specific: Admin (nur Administratoren)
|
||
│ │ │ └── admin.js # Abschnitte Honorar, Auslastung (Schwellen/Skala/
|
||
│ │ │ # Aufwandsvorlage), Termine & Sitzungen, Feiertage,
|
||
│ │ │ # Aufgaben-Vorlagen, Betrieb & Sicherheit,
|
||
│ │ │ # Datenbank & Sitzungen (Backup/Import/Löschen)
|
||
│ │ └── admin-users.js # Abschnitt "Benutzerverwaltung" des Bereichs #admin
|
||
│ │ # (renderAdminUsers, CRUD via /admin/users*)
|
||
│ ├── plan-styles.css # Stile des Projektbereichs (früher <style> in project-plan.html)
|
||
│ ├── js/plan/ # Module des Projektbereichs (#plan in App.html)
|
||
│ │ ├── plan-state.js # PlanApp-Zustand, Datenladepath, savePlan, Toasts
|
||
│ │ ├── plan-sections.js # Bereichs-Navigation (showPlanSection, URL-Fragment)
|
||
│ │ │ # + Dashboard-Karten (renderPlanDashboard)
|
||
│ │ ├── plan-info.js # Status/Kommentar, Gruppe, amtierende LPH, LPH-%,
|
||
│ │ │ # 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
|
||
│ └── logo.ico
|
||
└── sessions/ # SQLite-DB; bind-mounted into Docker container
|
||
└── projektuebersicht.db # node:sqlite database (WAL mode)
|
||
```
|
||
|
||
## Running
|
||
|
||
```bash
|
||
npm install
|
||
npm start # http://localhost:3003, or PORT=3004 if occupied
|
||
docker compose up --build # Docker variant (compose file is at repo root)
|
||
```
|
||
|
||
- PORT env var overrides default 3003 in `server.js`.
|
||
- Frontend must be served over HTTP: `baseUrl()` in `js/server.js` uses
|
||
`window.location.origin` or falls back to `http://localhost:3003` when opened
|
||
via `file://` (then only seed/demo data loads).
|
||
- Check `/health` returns `{ok:true}` after starting.
|
||
|
||
## Departments were retired (read this before trusting "department" elsewhere)
|
||
|
||
This file historically described `ELT`/`HLS`/`ARCH` as three fully separate
|
||
partitions — each with its OWN `weeks`, `projects`, and people list, switched
|
||
via a sidebar "Abteilung" selector and `switchDepartment()`. **That is no
|
||
longer true.** `migrateRemoveDepartmentPartition()` (`server.js`, runs
|
||
idempotently at every startup, guarded on `weeks` still having a `department`
|
||
column) merged all per-department weeks sharing the same label into one
|
||
global week (and merged same-`nr` projects across the former departments into
|
||
one project, tagging every former department as a `project_tags` entry —
|
||
confirmed deterministically, ELT→HLS→ARCH precedence, nothing silently
|
||
dropped). There is now **one shared week/project/people list**, full stop:
|
||
`GET /load-all` and `POST /save-week` take no `department` param,
|
||
`weeks`/`projects`/`week_people`/`attendance` have no `department` column,
|
||
`switchDepartment()` and the `.dept-tab` sidebar no longer exist client-side,
|
||
and `currentDepartment` is gone.
|
||
|
||
`VALID_DEPARTMENTS`/`DEPARTMENTS` (`ELT|HLS|ARCH`) still exist, but now mean
|
||
two different, narrower things:
|
||
- **A user's own discipline** — `users.department`, maintained in
|
||
`admin-users.html`, used for staffing/team pickers.
|
||
- **A project's discipline(s)** — `project_gewerke` (one or more per
|
||
project), see "Wochenbericht — Gewerke-Trennung" below.
|
||
|
||
**Known documentation debt:** many mentions of "department"/"Abteilung"
|
||
further down in this file — `db.globalPeople[currentDepartment]`,
|
||
`openProjectPlan()` building `/HLS/Projektplan/…`, `switchDepartment()`
|
||
behavior, the "Eine Projektliste"/"Projektbeteiligte" sections, etc. — still
|
||
describe the pre-migration, per-department architecture and were not swept
|
||
in the same pass that added this note. Treat any such mention as unverified
|
||
until checked against the actual code; do not assume it still applies.
|
||
|
||
## Automatischer Sitzungswechsel (read this before trusting "newWeek"/"cloneWeek" elsewhere)
|
||
|
||
Sessions ("Sitzungen", `weeks.label` = "KW XX / YYYY") are still the backbone
|
||
of the whole app — Wochenbericht, Board and Auslastung all key off the
|
||
current one, and the Wochenbericht's own week-over-week comparison/Verlauf
|
||
features (`renderProjektVerlauf()`, `classifyProjectChange()`, "Vergleich mit
|
||
KW…") depend on there being a fresh session every week. What changed: **a new
|
||
session is no longer a user action.** It used to be `newWeek()`/`cloneWeek()`
|
||
(`public/js/weeks.js`), triggered by two buttons in the Werkzeuge sidebar
|
||
group. Both are gone — on Erik's explicit call: "eine Sitzung stellt eine
|
||
Woche dar… kommt eine neue Woche kommt auch eine neue Sitzung unabhängig von
|
||
den Wochenberichten" (a session should follow the calendar, not whether
|
||
anyone happens to open the Wochenbericht that week), and the transition
|
||
should be **completely silent** — no toast, no confirmation.
|
||
|
||
**`ensureCurrentWeekRollover()` (`server.js`) replaces both functions,
|
||
server-side.** It runs once right after the HTTP server starts listening
|
||
(catches a rollover that should have happened while the server was down) and
|
||
then every hour via `setInterval` — deliberately NOT tied to any route or
|
||
tab, so a session change happens even if nobody ever opens the app that
|
||
week. (This is the first recurring background job in this codebase; there
|
||
was no prior pattern to mirror.) On each run: compute today's ISO
|
||
calendar-week/year (`getIsoWeekInfo()`, a straight port of `getWeekNumber()`
|
||
from `public/js/utils.js`, extended to also return the ISO week-*year*, which
|
||
can differ from the calendar year right at year boundaries); build the label
|
||
via `formatWeekLabel()` ("KW 05 / 2026" — the exact shape both the old
|
||
`validateWeekLabel()`'s regex and `weekSortKey()`/`weekSortKeyServer()`'s
|
||
parsing expect); compare against the chronologically latest **real** (i.e.
|
||
non-`SANDBOX_WEEK_LABEL`) session via the already-existing
|
||
`weekSortKeyServer()` (previously only used by `/my-dashboard`'s fallback).
|
||
If the calendar hasn't moved into a new week since then, it's a no-op — the
|
||
function is safe to call as often as the interval likes, and safe to run
|
||
again on every server restart without ever creating a duplicate.
|
||
|
||
**When a new week IS due, the reset is a deliberate, exact port of what
|
||
`cloneWeek()` used to do client-side** — same fields, same rationale, just
|
||
executed server-side against a plain JS object built from `loadWeeks()`
|
||
(itself already callable with no HTTP context) and written via
|
||
`saveWeekRelational()`/`serializeWeek()` (also already pure DB-writing
|
||
functions with zero dependency on `req`/`req.user` — confirmed by reading
|
||
both top to bottom before relying on it): deep-clone the latest real week,
|
||
give it a fresh `id` (`crypto.randomUUID()`, matching the ID scheme already
|
||
used elsewhere in server.js) and the new label/`date`
|
||
(`heuteIsoLokal()`), reset `attendance={}`, `locked=false`, drop `updatedAt`
|
||
(a carried-over revision would make `/save-week`'s conflict check reject the
|
||
brand-new label — moot here since this bypasses that route entirely, but the
|
||
field is dropped anyway for cleanliness); per project, a fresh
|
||
`id` (`crypto.randomUUID()`) and cleared `status`/`statusAuthor`/`statusAt`/
|
||
`besprochen`/`statusState`. Everything else — Beteiligte, `lph`, `assign`,
|
||
the whole `plan` (todos/milestones keep THEIR OWN ids unchanged, only the
|
||
owning project's id changes), `project_uid`, `honorar`, `beauftragteLP`,
|
||
`gewerke`, tags — carries over untouched, exactly as `cloneWeek()` always
|
||
left it.
|
||
|
||
**Locking**: there was, and is, no server-side equivalent of
|
||
`applyAutoLock()` (still client-side-only, still exactly as before). The
|
||
rollover replicates its invariant itself instead of relying on some client
|
||
being open to do it: once the new week is saved, if
|
||
`app_settings.autoLockAktiv` is on (read via the already-existing
|
||
`readAppSettings()`), every other real week gets `locked=1` in one `UPDATE`.
|
||
Turning `autoLockAktiv` off does not stop new sessions from being created —
|
||
only stops them from locking everything else, same distinction the setting
|
||
already made for `applyAutoLock()`.
|
||
|
||
**Fresh-install edge case**: if there is no real week at all yet (nothing to
|
||
clone), the rollover falls back to building an empty one — global people
|
||
list, no projects — mirroring what `newWeek()` used to produce, so a brand
|
||
new deployment still ends up with a session without needing a button to
|
||
press.
|
||
|
||
**Verified live** against a scratch copy of the production DB before ever
|
||
touching the real one (the real database's latest session, KW 33/2026,
|
||
predated "today" by seven ISO weeks — restarting the server for real was
|
||
always going to trigger a real jump to KW 40/2026, so correctness was
|
||
checked against a copy first): all 29 real projects carried over with fresh
|
||
project ids, comments/`besprochen` cleared, LPH values and `plan_todos`
|
||
(with their original todo ids) preserved byte-for-byte, older sessions
|
||
correctly locked, a second server start produced no duplicate, and disabling
|
||
`autoLockAktiv` left the previous latest week unlocked as expected.
|
||
|
||
`newWeek()`, `cloneWeek()`, and `validateWeekLabel()` are gone from
|
||
`public/js/weeks.js` (confirmed unreferenced anywhere else before removal).
|
||
**Known documentation debt**, same caveat as the department note above: this
|
||
file still has scattered comments elsewhere (in `board.js`, `utils.js`,
|
||
`wochenbericht.js`, `plan-state.js`, `plan-history.js`,
|
||
`plan-build-gantt.js`, and a few spots in this very file) that name
|
||
`cloneWeek()`/`newWeek()` as the live mechanism for "project ids change every
|
||
week"/"a new session starts with an empty comment" and similar — the
|
||
underlying *facts* they describe are still true, only the mechanism producing
|
||
them moved to `ensureCurrentWeekRollover()`. Treat a `cloneWeek()`/`newWeek()`
|
||
mention elsewhere as describing that historical mechanism, not code that
|
||
still exists, until it's been swept.
|
||
|
||
## Server API (`server.js`)
|
||
|
||
Auth levels (see "Auth / security quirk" below for the mechanism): **Public**
|
||
(no session needed) / **Auth** (any valid session) / **Admin** (`role='admin'`,
|
||
via `requireRole('admin')`). HTML shell routes stay Public — they only ever
|
||
serve static markup, never data; the actual security boundary is the 401/403
|
||
on the JSON endpoints.
|
||
|
||
- `GET /` → `App.html`; `GET /health`. Public.
|
||
- Deep links per department + tab: `GET /<Dept>/<Tab>` → `App.html`, e.g.
|
||
`/HLS/Auslastung` or `/ELT/Projektübersicht`. Public. Tab slugs
|
||
(`TAB_SLUGS` in `js/globals.js`, `VALID_TAB_SLUGS` in `server.js`) are
|
||
`Projektübersicht|Wochenbericht|Auslastung|Projekte|Projektbeteiligte|MeineAufgaben|Einstellungen|Admin`;
|
||
anything else returns 404. Exception: `GET /<Dept>/Projektplan(/<ProjName>)?`
|
||
(`PLAN_SLUG`) serves `App.html` as well (the Projektbereich is a tab of it
|
||
now, selected client-side by `applyUrlRoute()`), with
|
||
`weekLabel|projNr` as query params (the project name is a readable path
|
||
segment only; identification happens via weekLabel + projNr). The frontend
|
||
reads/writes these routes via `applyUrlRoute()`/`updateRouteUrl()` in
|
||
`js/ui.js` and all static assets in the HTML use **absolute** paths
|
||
(`/js/...`, `/styles.css`) so they resolve on nested routes. `admin-users.html`
|
||
itself is just a static file under `public/` (no dedicated server route/slug).
|
||
- `POST /login {username,password}` → Public. Verifies against `users` (scrypt,
|
||
see Auth section), creates a `user_sessions` row and sets the session cookie.
|
||
Same generic error for wrong username, wrong password, or inactive account
|
||
(no user enumeration).
|
||
- `POST /logout` → Auth. Deletes the current session row, clears the cookie.
|
||
- `GET /whoami` → Public. Returns `{ok:true, user: req.user || null}` — the
|
||
basis for the client-side login overlay (`checkAuthAndInit()`).
|
||
- `POST /change-password {currentPassword, newPassword}` → Auth. Requires the
|
||
current password even when `must_change_password` is set; new password ≥ 8
|
||
chars. Its UI is `#passwordModal` (`js/auth.js`) — reachable from
|
||
Einstellungen → Mein Konto, and shown non-dismissibly right after login when
|
||
the account carries `must_change_password`. Until that dialog existed the
|
||
endpoint had **no** caller at all: every new and every reset account got the
|
||
flag, `admin-users.html` displayed "Passwortwechsel erforderlich", and
|
||
nobody could act on it.
|
||
- `POST /change-email {email}` → Auth. Scoped to `req.user.id` (like
|
||
`/change-password`, not a body-supplied user id), no admin role needed.
|
||
Rejects an empty value with 400; no format validation beyond trimming
|
||
(same permissiveness as the admin path below). Its UI is the inline
|
||
E-Mail field in Einstellungen → Mein Konto (`js/settings/settings.js`,
|
||
`submitEmailChange()`) — until this route existed the address was
|
||
view-only there and only an admin could set it via `PATCH
|
||
/admin/users/:id`; an admin can still set/correct it there, e.g. for a
|
||
colleague who hasn't logged in yet.
|
||
- `GET /admin/users` → Admin. Lists all users (incl. inactive) with their
|
||
`roles` (discipline roles from `user_roles`, see below), without password
|
||
fields or absences (those are loaded separately, see next bullet).
|
||
- `GET /admin/users/:id/absences` → Admin. Lists one user's absences
|
||
(`id, start, end, type, note`, sorted by `start`) from `user_absences` —
|
||
loaded on demand by the admin-users.js edit modal, not embedded in
|
||
`GET /admin/users` (would bloat the list response).
|
||
- `POST /admin/users {username, displayName, email?, role?, personId?, department, initials?, roles?, password?, generatePassword?}`
|
||
→ Admin. Creates a user with `must_change_password=1`; `department` is
|
||
**required** (must be a `VALID_DEPARTMENTS` value, 400 otherwise — empty is
|
||
only tolerated for the pre-existing bootstrap admin, not for new accounts);
|
||
`initials` is the short board-column label, stored as given (empty is
|
||
allowed — `loadGlobalPeople()` falls back to `username` at read time, see
|
||
Persistence); `roles` is an array validated against
|
||
`VALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwachung'])`
|
||
(400 on an unknown role) and written to `user_roles`. Password either given
|
||
explicitly (≥ 8 chars) or auto-generated (`generatePassword:true`, returned
|
||
once as `generatedPassword`).
|
||
- `PATCH /admin/users/:id {displayName?, email?, role?, active?, personId?, department?, initials?, roles?}` →
|
||
Admin. Partial update; same `department`/`roles` validation as `POST`
|
||
(only when the field is present); `roles` is a replace-all (delete then
|
||
re-insert `user_roles`, same pattern as the old `serializeGlobalPeople`).
|
||
An admin can't deactivate or demote their own account. Deactivating a user
|
||
immediately deletes all their sessions. A `person_id` collision (now
|
||
globally unique, see Persistence) is caught and returned as 409.
|
||
- `POST /admin/users/:id/reset-password` → Admin. Sets a new random password,
|
||
forces `must_change_password=1`, deletes all sessions of that user (forces
|
||
re-login), returns the plaintext once as `generatedPassword`.
|
||
- `DELETE /admin/users/:id[?hard=1]` → Admin. Default is a soft-delete
|
||
(`active=0` + delete sessions); `?hard=1` permanently deletes the row, but
|
||
only if the user is referenced nowhere in `project_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).
|
||
Requires `start`/`end` (400 otherwise); server generates the absence `id`
|
||
(`crypto.randomUUID()`).
|
||
- `DELETE /users/:id/absences/:absenceId` → Auth. Idempotent (no error if the
|
||
absence is already gone).
|
||
- `GET /load-all` → Auth. `{weeks:[weekObj], people:[personObj], sandboxPlans:[projectObj], sandboxUpdatedAt}` — **no
|
||
`department` param**: `migrateRemoveDepartmentPartition()` (see "Departments
|
||
were retired" below) dissolved the former per-department partitioning, so
|
||
this returns the one shared set of weeks and the one shared people list
|
||
(`loadGlobalPeople()`, see Persistence — still filtered to `active=1`, just
|
||
not to a department anymore); weeks are full JSON week objects (id, label,
|
||
date, locked, updatedAt, people, attendance, projects), sorted by label.
|
||
`sandboxPlans`/`sandboxUpdatedAt` are the reserved Planung-Sandbox "week"
|
||
(`SANDBOX_WEEK_LABEL`) split OUT of `weeks[]` — see "Planung —
|
||
projektunabhängige Terminplan-Sandbox" below — so it can never leak into
|
||
any code that iterates `weeks`.
|
||
- `POST /save-week {week, baseUpdatedAt}` → Auth. Upserts one week (JSON
|
||
object, keyed by label only). Used by `sendWeekToServer()`. The
|
||
client sends its local revision in `baseUpdatedAt` (the `updatedAt` value
|
||
from `load-all`); the server compares it to `weeks.updated_at` before writing
|
||
and answers `409 {ok:false, conflict:true, error, updatedAt}` if someone else
|
||
saved the week in the meantime. A save with a **non-empty** base for a week
|
||
the server no longer knows is also rejected with 409 ("wurde gelöscht") so a
|
||
deleted week can't silently resurrect; only new weeks (empty/missing base)
|
||
pass. On success it returns `{ok:true, updatedAt}` (the new revision), which
|
||
the client stores as the new sync base. Clients WITHOUT `baseUpdatedAt` (old
|
||
versions) always pass. The check runs synchronously in-process before the
|
||
write transaction (no race between check and write).
|
||
- `POST /delete-week {department, label}` → Admin. Deletes the week; missing
|
||
label yields 400, missing week tolerates with `deleted:false`.
|
||
- `POST /unlock-week {department, label}` → Admin. Unlocks a locked week
|
||
(`locked=0`, new `updated_at` revision, same convention as `/save-week`) —
|
||
replaces the formerly client-only unlock logic
|
||
(`unlockCurrentWeek()`/`js/weeks.js`); 404 if the week doesn't exist.
|
||
- `GET /my-dashboard` → Auth. Liefert `tasks` (persönlich zugewiesene offene
|
||
Aufgaben), **`projectDates`** (Aufgaben und Meilensteine der Projekte, denen
|
||
der Aufrufer zugeordnet ist — siehe „Dashboard" unten), `pendingReviews` und
|
||
`colleagueAbsences`. Aggregates, per department (`ELT`/`HLS`/`ARCH`),
|
||
the currently unlocked week's open (`done=0`) `plan_todos` assigned to the
|
||
caller's `person_id` (sorted by due date, empty dates last), plus
|
||
`pendingReviews` — files where the caller is a `pending` reviewer
|
||
(`project_file_reviewers` joined with `project_files`/`users`). Normally
|
||
exactly one unlocked week exists per department (`applyAutoLock()`
|
||
invariant); if that invariant is violated (0 or >1 unlocked weeks),
|
||
`weekSortKeyServer()` picks the most recent week as a fallback. A user
|
||
without a matching `person_id` gets an empty task list per department (no
|
||
error).
|
||
- `POST /project-files?department=&projectNr=&projectName=&filename=&reviewers=id1,id2`
|
||
→ Auth. Raw binary body like `/import` (`express.raw({type:'*/*', limit:'200mb'})`).
|
||
Rejects an invalid/missing `department` (stricter than `departmentKey()`'s
|
||
ELT-fallback — an upload must never land in the wrong department silently)
|
||
and an empty `projectNr` (400). Reviewer IDs are parsed from the
|
||
comma-separated query param, deduplicated, filtered to active users, and the
|
||
uploader is removed (no self-review); at least one reviewer must remain
|
||
(400 otherwise). See "File uploads" below for storage/path details.
|
||
- `GET /project-files?department=&projectNr=` → Auth. Lists a project's files
|
||
incl. uploader name, per-reviewer decision, and comment count.
|
||
- `GET /project-files/:id/download` → Auth. `res.download()` from
|
||
`sessions/uploads/<department>/<stored_name>`, with `original_filename` only
|
||
used as the download's `Content-Disposition` filename.
|
||
- `POST /project-files/:id/review {decision:'approved'|'rejected', note?}` →
|
||
Auth. Only for a caller who is an eligible `pending` reviewer of that file;
|
||
409 if the file's overall status is already terminal
|
||
(`approved`/`rejected`). Recomputes the overall status: any `rejected`
|
||
decision → `rejected` immediately; all reviewers `approved` → `approved`;
|
||
otherwise stays `pending`.
|
||
- `GET /project-files/:id/comments` → Auth. Chronological comment list.
|
||
- `POST /project-files/:id/comments {body}` → Auth. Any logged-in user may
|
||
comment, not just the assigned reviewers.
|
||
- `DELETE /project-files/:id` → Auth (not admin-only, but scoped): allowed for
|
||
the uploader while the file is still `pending`, or for an admin at any time
|
||
(403 otherwise). Deletes the DB row (and its reviewer/comment rows, deleted
|
||
explicitly rather than via FK cascade — see Persistence section) before
|
||
best-effort deleting the physical file.
|
||
- `GET /project-protokolle?projectNr=` → Auth. Summary list of a project's
|
||
Protokolle (counts of open/erledigt points, Teilnehmer count), sorted by
|
||
`nummer` descending — see "### Protokolle" below.
|
||
- `GET /project-protokolle/:id` → Auth. One full Protokoll incl. all
|
||
`teilnehmer`/`punkte`.
|
||
- `POST /project-protokolle?projectNr=&projectName=&weekLabel=
|
||
{datum, betreff, ort, teilnehmer}` → Auth. Creates a new Protokoll;
|
||
`nummer` is server-assigned (`MAX(nummer)+1` for that `project_nr`), and
|
||
every still-open (`erledigt=0`) point of that project's most recently
|
||
created Protokoll is copied into it (the "Fortschreiben" — see "###
|
||
Protokolle").
|
||
- `PUT /project-protokolle/:id {datum, betreff, ort, teilnehmer, punkte}` →
|
||
Auth. Replaces the Protokoll's Teilnehmer/Punkte wholesale (delete +
|
||
re-insert, one transaction) — the same "whole object" pattern
|
||
`saveWeekRelational()` uses for a week, not a REST route per field. A point
|
||
with a known `id` keeps its server-tracked `stamm_id`/`neu`; a point
|
||
without one is treated as newly added (`neu=1`).
|
||
- `DELETE /project-protokolle/:id` → Auth (not admin-only — same permission
|
||
level as deleting a todo/milestone). Idempotent.
|
||
- `POST /project-protokolle/import?projectNr=&projectName=&weekLabel=&datum=
|
||
&betreff=&ort=&filename=&erkennung=` → Auth. Raw-binary body like `POST
|
||
/project-files` (`express.raw`, 50 MB limit; the route is listed in
|
||
`RAW_BODY_ROUTES` so the global `express.json()` parser never touches it —
|
||
see "JSON-Dateien lassen sich hochladen" below, same reasoning). Extracts
|
||
text from an uploaded `.pdf`/`.docx` (400 on any other extension). `erkennung`
|
||
(`'chatgpt'|'claude'`, optional) tries KI-gestützte Erkennung first,
|
||
falling back to the `erkennePunkteAusText()` heuristic on any failure —
|
||
anything else in that param skips straight to the heuristic; creates the
|
||
Protokoll through the same `legeProtokollAn()` path as the plain `POST`
|
||
above — see "### Protokolle" below for both recognition paths and the
|
||
PDF-library history. Response includes `erkennungsart`/`erkennungshinweis`.
|
||
- `GET /project-protokolle/:id/quelldatei` → Auth. Downloads the original
|
||
uploaded file of an imported Protokoll (404 if it wasn't imported) — the
|
||
Protokoll equivalent of `GET /project-files/:id/download`.
|
||
- `GET /my-ai-settings` → Auth (not admin-only — scoped to `req.user.id`,
|
||
same pattern as `/change-email`). Returns, per provider, only whether an
|
||
API key is stored (`configured: bool`) plus the — non-secret — `apiUrl`
|
||
to pre-fill an edit form; never the key itself. `PUT /my-ai-settings
|
||
{chatgpt?: {apiKey?, apiUrl?}, claude?: {apiKey?, apiUrl?}}` → Auth, same
|
||
scoping. A given `apiKey` replaces it (empty string clears it); a given
|
||
`apiUrl` is validated via `istGueltigeAiUrl()` — `https://` always passes,
|
||
`http://` only on the loopback (`localhost`/`127.0.0.1`/`::1`), anything
|
||
else is 400 — or clears an override back to the default endpoint; an
|
||
omitted sub-field is left untouched. See "### Protokolle" below for why
|
||
each account keeps its own key rather than one firmenweit, and for why
|
||
plain `http` is allowed on the loopback.
|
||
- `POST /my-ai-settings/test {provider}` → Auth, same scoping. "Neu
|
||
verbinden": actually exercises the caller's saved key/URL for that
|
||
provider via `erkennePunkteMitKI()` (a short dummy text, the same code
|
||
path an import uses). Responds `{ok:true}` or, on any failure,
|
||
`{ok:false, error}` with HTTP 200 — a failed test is an expected outcome,
|
||
not a server error. See "### Protokolle" below.
|
||
- `GET /app-settings` → **Auth** (not admin-only — the frontend *computes*
|
||
with these: Auslastung, ampel, Honorar schedule). `PUT /app-settings` →
|
||
**Admin**, partial update. `APP_SETTING_DEFS` in `server.js` is the single
|
||
place a key is defined (default, parser, label); `readAppSettings()` always
|
||
returns a complete object, falling back to the default for a missing or
|
||
unusable row. Unknown keys and invalid values are rejected with 400 *before*
|
||
anything is written, and gelb > rot is refused outright. Keys:
|
||
`stundensatz`, `arbeitsstundenProTag`, `vollzeitStundenProWoche`,
|
||
`auslastungSchwelleGelb`, `auslastungSchwelleRot`, `referenzHonorar`,
|
||
`verteilzeitraumWochen`,
|
||
`aufwandVorlage` (JSON, validated against `VALID_LPHS`/
|
||
`VALID_DISCIPLINE_ROLES`, values 0–1), `autoLockAktiv`,
|
||
`feiertagBundesland`, `maxBackups`, `sessionTage`, `loginMaxVersuche`,
|
||
`loginSperreMinuten`.
|
||
- `GET /holidays` → Auth; `POST /holidays {date,name}` / `DELETE /holidays/:date`
|
||
→ Admin. `date` is the primary key, so posting an existing date just renames
|
||
it. `GET /holidays` also returns `states` (the 16 German states), since the
|
||
server is the single place that list lives and the maintenance mask needs it
|
||
right away.
|
||
- `GET /holidays/suggest?year=&state=` → Auth; `POST /holidays/import {year,state}`
|
||
→ Admin. German public holidays are **computed**, not fetched from a web
|
||
service: `holidaysForYear()` in `server.js` combines fixed dates with
|
||
everything hanging off Easter (`easterSunday()`, the Gaussian/Meeus formula;
|
||
`bussUndBettag()` for Saxony). No external dependency, no internet access on
|
||
the server — which a shop network does not necessarily have — and the result
|
||
is deterministic and works offline. The suggestion route marks days already
|
||
stored (`vorhanden`); the import uses `INSERT OR IGNORE`, so a hand-edited
|
||
name on an existing date survives. Nothing is written automatically: a
|
||
holiday shifts every computed schedule, so an admin confirms it. The state
|
||
is remembered in `app_settings.feiertagBundesland` (default `NW`).
|
||
Municipality-level cases (Fronleichnam in parts of SN/TH, Mariä Himmelfahrt
|
||
in Bavarian catholic municipalities) are deliberately left out — they cannot
|
||
be derived from the state and can be added by hand.
|
||
- `GET /backup` → **Admin**. Consistent SQLite snapshot (`VACUUM INTO`, WAL-safe) as
|
||
a `.db` file download (temp file in `os.tmpdir()`, deleted after send). Used
|
||
by `downloadBackup()` in `js/backup.js`. Additionally, on server startup the
|
||
server writes a timestamped copy to `sessions/backups/` (also via
|
||
`VACUUM INTO`) and keeps only the `MAX_BACKUPS` newest ones (default 10).
|
||
- `POST /import` → Admin. Restores the whole database from a `.db` backup file
|
||
sent as a raw binary body (`express.raw`, limit 100 MB). Validates the schema
|
||
(`pragma_table_info` on every table, exact column match vs. live DB), then
|
||
swaps all data via `ATTACH DATABASE …` + DELETE/INSERT-SELECT inside one
|
||
transaction (rollback on error). Immediately BEFORE the restore the server
|
||
saves a fresh auto-backup of the current state via `runAutoBackup()` (so a
|
||
restore can be undone), then performs the swap. Used by `importDb()` in
|
||
`js/import.js` (the "DB Import" button is only shown to admins,
|
||
`renderUserBar()` in `js/auth.js`, and the route itself is admin-only
|
||
regardless of UI). Note: `IMPORT_TABLES` in `server.js` lists the 16
|
||
original per-week/session relational tables (`weeks` … `plan_milestone_deps`)
|
||
plus `project_protokolle`/`protokoll_teilnehmer`/`protokoll_punkte` (added
|
||
with the Protokolle feature — unlike `project_files`, a Protokoll has no
|
||
on-disk component, so it's safe to include in a restore, see "###
|
||
Protokolle"), i.e. the 7 auth/user/file tables (`users`, `user_sessions`,
|
||
`user_roles`, `user_absences`, `project_files`, `project_file_reviewers`,
|
||
`project_file_comments`) are **not** touched by import/export — a DB restore
|
||
never overwrites accounts, roles, absences, sessions, or file-review state.
|
||
(`people`/`person_roles`/`person_absences` were removed from `IMPORT_TABLES`
|
||
along with the tables themselves, see Persistence.)
|
||
- `ELT|HLS|ARCH` (`VALID_DEPARTMENTS` in `server.js`, `DEPARTMENTS` in
|
||
`js/globals.js`) are **no longer a partitioning of weeks/projects/people** —
|
||
see "Departments were retired" below. They still identify a **user's own**
|
||
discipline (`users.department`) and, since the Wochenbericht-Gewerke feature,
|
||
a **project's** discipline(s) (`project_gewerke` — see "Wochenbericht —
|
||
Gewerke-Trennung" below); `VALID_DEPARTMENTS` is the shared value set for
|
||
both.
|
||
- Static assets get `Cache-Control: no-store`.
|
||
- JSON body limit is 2 MB.
|
||
|
||
## Persistence (SQLite)
|
||
|
||
- Data lives in `sessions/projektuebersicht.db` (`node:sqlite` `DatabaseSync`,
|
||
WAL mode, `PRAGMA foreign_keys = ON`, `PRAGMA busy_timeout = 5000` to avoid
|
||
`SQLITE_BUSY` under concurrent writers), created automatically. The schema is
|
||
**fully relational** (26 tables, in the unconditional `CREATE TABLE`
|
||
block at the top of `server.js`) instead of one big JSON document per week:
|
||
- `weeks` (department, label, week_id, date, locked, updated_at; PK
|
||
`(department,label)`) — `updated_at` is the revision timestamp for the
|
||
single-user conflict handling (see `/save-week` above)
|
||
- `week_people` / `week_people_roles` — person copies per week (`array_order`
|
||
keeps the list order, `role_order` the role array order)
|
||
- `attendance` — présence per week/person
|
||
- `projects` — u. a. `groesse` (REAL 0–1, Ersatz für ein fehlendes Honorar
|
||
in der Auslastungsrechnung, siehe "Auslastung — Wochenstunden statt
|
||
Faktoren"); (`array_order` = position in the JSON array, `order_value` = the
|
||
`p.order` field, which is only unique *within a group*, not globally!;
|
||
`besprochen` = ticked off in the team meeting — it belongs to the
|
||
SESSION, not the project, so `cloneWeek()` resets it: a new session
|
||
starts with an empty agenda)
|
||
- `project_gewerke` (week_label, project_id, gewerk; PK über alle drei) —
|
||
die Fachbereiche, die das Projekt bearbeiten (`ELT`/`HLS`/`ARCH`, dieselben
|
||
Werte wie `users.department`); steuert die Auswahl der Beteiligten im
|
||
Assistenten
|
||
- `project_lph`, `project_assign` (person_id|role keys split into columns),
|
||
`project_aufwand` (LP effort per role), `project_aktuelle_lp`
|
||
- `project_plan`, `plan_people`, `plan_todos` / `plan_todo_assignees` /
|
||
`plan_todo_deps`, `plan_milestones` (`position` column!) /
|
||
`plan_milestone_deps`
|
||
- `users` (id, username UNIQUE, display_name, email, password_hash,
|
||
password_salt, role `'admin'|'member'`, person_id, active,
|
||
must_change_password, created_at, updated_at, **department, initials**) +
|
||
index on `person_id` **and** `CREATE UNIQUE INDEX … ON users(person_id)
|
||
WHERE person_id != ''` (see `ensureUserPersonIdUniqueIndex()` below) —
|
||
`capacity_hours` (contractual hours per week, added by
|
||
`migrateUsersDepartment()`; `0` = not maintained and counts as a full
|
||
position),
|
||
`person_id = personIdForName(display_name)`, the bridge to the existing
|
||
`person_id`-based assignment scheme (e.g. `plan_todo_assignees`) so
|
||
dashboard aggregation needs no schema change there; `department` is a
|
||
`VALID_DEPARTMENTS` value (empty only tolerated for the pre-existing
|
||
bootstrap admin), `initials` is the short board-column label
|
||
- `user_roles` (user_id, role, role_order; PK `(user_id,role)`, FK →
|
||
`users(id) ON DELETE CASCADE`) — discipline roles
|
||
(`Planung`/`Projektleitung`/`Bauüberwachung`, `VALID_DISCIPLINE_ROLES`)
|
||
- `user_absences` (user_id, id, start, end, type, note; PK `(user_id,id)`,
|
||
FK → `users(id) ON DELETE CASCADE`)
|
||
- `user_ai_keys` (user_id, provider, api_key, api_url; PK
|
||
`(user_id,provider)`, FK → `users(id) ON DELETE CASCADE`) — each
|
||
account's own OpenAI/Claude API key + optional API-URL override for the
|
||
Protokoll-Import's KI-gestützte Erkennung, see "### Protokolle"
|
||
- `user_sessions` (token PK, user_id, created_at, last_seen_at, expires_at)
|
||
- `holidays` (date PK `YYYY-MM-DD`, name) — firmenweite arbeitsfreie Tage.
|
||
A table rather than a JSON value in `app_settings` because it is a list
|
||
that grows and is maintained entry by entry. It feeds `HOLIDAYS`
|
||
(`js/globals.js`) and thus `isWorkday()`/`workdaysBetween()`/
|
||
`addWorkdays()` in `public/js/utils.js` — i.e. the Honorar schedule and
|
||
the Projektbereich's resource load. Dependency-based scheduling
|
||
(Auto-Schedule) still counts calendar days, as it always did.
|
||
- `project_files` (id PK, department, project_nr, project_name,
|
||
original_filename, stored_name, content_type, size_bytes,
|
||
uploaded_by_user_id, status `'pending'|'approved'|'rejected'`, created_at,
|
||
updated_at) + index on `(department, project_nr)`
|
||
- `project_file_reviewers` (file_id, user_id, decision `'pending'|'approved'|'rejected'`,
|
||
decided_at, note; PK `(file_id, user_id)`) + index on `(user_id, decision)`
|
||
- `project_file_comments` (id PK, file_id, user_id, body, created_at)
|
||
- `project_protokolle` (id PK, project_nr, project_name, nummer, datum,
|
||
betreff, ort, verfasser_user_id, week_label, created_at, updated_at) +
|
||
index on `project_nr` — scoped like `project_files`, see "### Protokolle"
|
||
- `protokoll_teilnehmer` (id PK, protokoll_id, name, firma, anwesend,
|
||
array_order) + index on `protokoll_id`
|
||
- `protokoll_punkte` (id PK, protokoll_id, stamm_id, nummer, gruppe, neu,
|
||
thema, text, zustaendig, termin, erledigt, erstellt_am, array_order) +
|
||
index on `protokoll_id` — `stamm_id` links copies of "the same" point
|
||
across Protokolle (the "Fortschreiben"), `nummer`/`gruppe` are free text.
|
||
`erstellt_am` (added on Erik's follow-up request, migration
|
||
`migrateAddProtokollPunktErstelltAm()`, existing rows stay empty — their
|
||
real origin date can't be reconstructed) is set exactly once, the moment
|
||
a point is first saved — either the owning Protokoll's own `datum` (a
|
||
point added by hand or newly recognized on import) — and, like
|
||
`stamm_id`, carried forward UNCHANGED by the Fortschreiben-copy in
|
||
`legeProtokollAn()` and by `PUT /project-protokolle/:id` (which looks up
|
||
the existing row by `id` and keeps its stored value rather than trusting
|
||
whatever the client sends): a Fortschreiben copy or an ordinary edit of
|
||
an existing point is not a new creation, so its original date must not
|
||
move just because it now also appears in a later Protokoll. Only a point
|
||
with no matching existing `id` gets a *fresh* `erstellt_am`, from the
|
||
`datum` being saved in that very request — the same rule `neu`/`stamm_id`
|
||
already follow for telling "genuinely new" apart from "carried over".
|
||
|
||
`people` / `person_roles` / `person_absences` (the former per-department
|
||
global-people schema) are **gone** from the live schema — they are only
|
||
ever created transiently by `migrateLegacySchema()` when converting a very
|
||
old single-JSON-document DB, and then immediately retired by
|
||
`migrateRetirePeopleTablesIntoUsers()` (see Migration below) in the same
|
||
startup. `loadGlobalPeople(department)` now reads exclusively from
|
||
`users`/`user_roles`/`user_absences` (`WHERE department = ? AND active = 1`)
|
||
and returns the unchanged shape `{id, name, fullName, email, role:[],
|
||
absences:[{id,start,end,type,note}]}` (`id`/`name`/`fullName` map to
|
||
`person_id`/`initials||username`/`display_name`), so `GET /load-all`,
|
||
`board.js`, `load.js`, `gantt.js`, `plan-modals.js` needed no changes.
|
||
|
||
**FK constraint note:** although `PRAGMA foreign_keys = ON` is set, only
|
||
`user_sessions.user_id`, `user_roles.user_id`, `user_absences.user_id` and
|
||
`user_ai_keys.user_id` have a declared `FOREIGN KEY … REFERENCES
|
||
users(id) ON DELETE CASCADE` (deleting a user auto-deletes their
|
||
sessions/roles/absences/AI keys). Every other
|
||
table — the 16 original per-week/session tables, the 4 file-related ones
|
||
(`users`, `project_files`, `project_file_reviewers`,
|
||
`project_file_comments`) and the 3 Protokolle tables (`project_protokolle`,
|
||
`protokoll_teilnehmer`, `protokoll_punkte`) — has **no** declared FK
|
||
constraints; this is a deliberate continuation of the pre-existing
|
||
convention (referential
|
||
integrity is enforced in application code, e.g. `deleteWeekData()`), not an
|
||
oversight. `DELETE /project-files/:id` therefore deletes
|
||
`project_file_comments` and `project_file_reviewers` rows explicitly before
|
||
the `project_files` row.
|
||
- The HTTP API stays JSON-shaped: `serializeWeek()` splits an incoming week
|
||
object across the tables inside a transaction, `loadWeeks()` /
|
||
`loadGlobalPeople()` (re)assemble week/people data on read.
|
||
`saveWeekRelational()` deletes the week's rows first (`deleteWeekData()`,
|
||
note `weeks` uses `label`, all child tables `week_label`) and returns the
|
||
new `updated_at` revision written by `serializeWeek()`. There is no
|
||
`serializeGlobalPeople()` anymore — the global people list has no write
|
||
path of its own; it is a read-only projection of `users`/`user_roles`/
|
||
`user_absences`, which are written only through `/admin/users*` and
|
||
`/users/:id/absences*`.
|
||
- Migration on every start (idempotent, run in this order after schema
|
||
creation):
|
||
1. `migrateLegacySchema()` converts a DB from the old single-JSON-document
|
||
format (detected via a `data` column in `weeks`): it reads the
|
||
documents, drops all tables, recreates the (then-current, including the
|
||
now-retired `people`/`person_roles`/`person_absences`) relational schema
|
||
and inserts everything in one transaction.
|
||
2. `migrateWeeksUpdatedAt()` adds the `updated_at` column to `weeks`
|
||
(idempotent, `ALTER TABLE … ADD COLUMN`). Legacy rows keep
|
||
`updated_at = ''`, so the first client save with an empty
|
||
`baseUpdatedAt` matches and only then a real revision is written.
|
||
3. `migrateUsersDepartment()` adds the `department`/`initials` columns to
|
||
`users` if missing (idempotent `ALTER TABLE … ADD COLUMN`, same pattern
|
||
as step 2).
|
||
4. `ensureUserPersonIdUniqueIndex()` first calls `dedupeUserPersonIds()` —
|
||
`person_id` used to only need to be unique *per department* (the old
|
||
`people` table); now that it globally identifies a user, any existing
|
||
duplicates are resolved by appending `_<user id>` to every row but the
|
||
first (logged with `console.warn`) — then creates
|
||
`idx_users_person_id_unique` (a partial unique index skipping empty
|
||
`person_id`).
|
||
5. `migrateRetirePeopleTablesIntoUsers()` runs only while a `people` table
|
||
still exists (no-op/idempotent otherwise): for every legacy `people` row
|
||
it looks up a `users` row by `(department, person_id)`; on a match it
|
||
copies that person's `person_roles` into `user_roles`
|
||
(`INSERT OR IGNORE`); on no match the row is only logged to the console
|
||
as unmatched — **no placeholder user is created** for it (would pollute
|
||
the user list with non-loginable dead entries). Historical `person_absences`
|
||
rows are **not** migrated (`serializeGlobalPeople()`'s old code wrote
|
||
`String(absenceObject)`, i.e. literally `"[object Object]"`, into that
|
||
column, so there was nothing meaningful to carry over). It then drops
|
||
`people`, `person_roles`, and `person_absences`. Historical weeks are
|
||
unaffected either way: `week_people`/`week_people_roles` already freeze
|
||
each week's person name/roles at save time, independent of the global
|
||
people table.
|
||
6. `bootstrapInitialAdmin()` (see Auth section).
|
||
- The DB file sits in `sessions/`, so the existing Docker bind-mount
|
||
`./sessions:/app/sessions` keeps it persistent. The path is overridable via
|
||
the `DB_PATH` environment variable (used for tests), production default is
|
||
`sessions/projektuebersicht.db`.
|
||
- Project/person IDs are stable across reloads; only week-level fields are
|
||
queryable columns.
|
||
|
||
## Frontend architecture
|
||
|
||
- The frontend is split into small classic non-module `<script>` files under
|
||
`public/js/`; **all functions are globals on `window`**. Three HTML files
|
||
include them via `<script src>` tags; load order in the HTML defines
|
||
dependencies, since there is no bundler:
|
||
- `App.html` loads `globals → utils → ui → date-picker → server → auth →
|
||
store → weeks` (shared modules), then the tab-specific folders `board →
|
||
people → projects → load → dashboard → wand`, finally `gantt →
|
||
tabs-auth → pdf → backup → import → init`. See the exact list in
|
||
`App.html`. `auth.js` sits right after `server.js` because
|
||
`checkAuthAndInit()` needs `baseUrl()`, and before `store.js`/`weeks.js`
|
||
since nothing may talk to the data endpoints before a session exists.
|
||
The `js/plan/*` modules of the Projektbereich sit right after `gantt.js`
|
||
(`plan-state → plan-sections → plan-info → plan-history →
|
||
plan-build-gantt → plan-gantt → plan-drag → plan-modals → plan-tasks →
|
||
plan-files → plan-templates → plan-honorar → plan-settings →
|
||
plan-schedule`): they must come after `gantt.js`, whose `buildGanttHtml()`
|
||
`plan-build-gantt.js` wraps, and `plan-history` must stay before
|
||
`plan-build-gantt`, which uses its previous-week helpers. None of them
|
||
collides with a main-module global — every one runs in an IIFE over
|
||
`window.PlanApp` and exports through `window.*`.
|
||
- `admin-users.html` is the second, fully independent page (the only one
|
||
outside the tab system): it loads only `globals →
|
||
utils → ui → server → auth → admin-users.js` (no `date-picker`, no
|
||
`gantt`, no `plan/*` — it only talks to `/admin/users*`).
|
||
- `currentUser`/`checkAuthAndInit()` (`js/auth.js`) are the shared client-side
|
||
auth concept across all three pages: `checkAuthAndInit(onAuthenticated)`
|
||
calls `GET /whoami`; with a session it sets `currentUser`, hides the login
|
||
overlay and runs `onAuthenticated()` (the page's normal entry point, e.g.
|
||
`initUiState()` + `switchDepartment()` in `js/init.js`); without one it shows
|
||
the blocking `#loginOverlay`/`#loginForm` and only proceeds after a
|
||
successful `POST /login`. `renderUserBar()` shows the current user's
|
||
name/role and toggles admin-only UI (the "DB Import" button, the
|
||
`#adminUsersLink` to `admin-users.html`) based on `currentUser.role`.
|
||
- Shared state object `window.PlanApp` (`js/plan/plan-state.js`) holds all
|
||
project-plan page state (week, project, zoom level, drag/`modal`-state),
|
||
`db`/`currentDepartment` live in `js/globals.js`.
|
||
- State: `let db = { weeks, currentWeek, globalPeople }` (in-memory only).
|
||
- Project/person IDs are stable across reloads because the server splits and
|
||
reassembles week objects deterministically (JSON IDs are kept in the DB
|
||
columns); projects are addressed cross-page by `department` + week `label` +
|
||
`projNr` (+ readable projName path segment) via URL params:
|
||
`openProjectPlan()` builds `/HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…`,
|
||
`findProjectForPlan` resolves them. Person IDs follow
|
||
`personIdForName(name)` → `p_<lowercased alnum>`.
|
||
- `save()` debounces 500 ms then `syncAllToServer()` writes **only dirty weeks**
|
||
(`dirtyWeeks` – track `markWeekDirty()`) via `sendWeekToServer()`. There is
|
||
no separate people-list dirty-tracking/sync path anymore (no
|
||
`dirtyPeople`/`markPeopleDirty()`/`sendPeopleToServer()`) — since
|
||
Projektbeteiligte is a read-only projection of `users`/`user_roles`/
|
||
`user_absences`, the only way the client ever changes "people" data is
|
||
either (a) admin CRUD on `admin-users.html` (writes straight to
|
||
`/admin/users*`/`/users/:id/absences*`, no debounce/dirty-tracking
|
||
involved), or (b) the cross-department roster addition
|
||
(`addUserToCurrentWeekRoster()` in `js/people/people.js`), which just
|
||
pushes into `currentWeek().people` and goes through the normal week-`save()`
|
||
path. An in-flight guard (`syncInFlight`/`syncQueued`) prevents overlapping
|
||
syncs from double-POSTing the same week with the same revision (which would
|
||
look like a spurious 409). On success the server-provided `updatedAt`
|
||
revision is adopted as the new sync base; weeks whose save failed (network
|
||
errors) stay dirty and are retried. A `409` conflict response drops the
|
||
local changes for that week and `handleWeekConflict()` reloads **only that
|
||
week** from the server (`reloadWeekFromServer()`), leaving all other dirty
|
||
weeks untouched. Exception: `js/plan/plan-state.js` `savePlan()` bypasses
|
||
`save()` and writes only the current week directly via `sendWeekToServer()`
|
||
(with the same in-flight/trailing-save guard), adopting the returned
|
||
`updatedAt` and showing a toast on conflict. `switchWeek()` (in
|
||
`js/weeks.js`) does NOT trigger a save – only actual edits do.
|
||
- `switchDepartment()` cancels a pending save debounce before switching, and
|
||
`load()` discards stale `/load-all` responses after a department switch
|
||
(frozen `requestedDepartment`), so late responses can never render or persist
|
||
data under the wrong department.
|
||
- The people tab no longer has drag-and-drop, a form, or delete/reorder
|
||
actions (`renderPeople()` in `js/people/people.js` is read-only rendering
|
||
of `db.globalPeople[currentDepartment]` plus any cross-department roster
|
||
additions on the current week). `getDragAfterElement()`/drag-and-drop
|
||
(`js/projects/projects.js`) serves the project list's `.board-row` rows
|
||
and, via `js/people/people.js`, the `.person-list-item` rows — the people tab's former
|
||
`.person-card`/`.people-sortable`/`reorderPeople()` drag machinery was
|
||
removed along with the CRUD UI. No drag handles are rendered on locked
|
||
weeks in the Projekte tab either way.
|
||
- Weeks are sorted by calendar week extracted from the label (`KW XX / YYYY`)
|
||
via `weekSortKey()`, not alphabetically/by filename.
|
||
- `applyAutoLock()` locks every week except the chronologically last one after
|
||
load/clone/new-week; `localStorage` UI state is not restored for locked tabs.
|
||
- `localStorage` keys: `projuebersicht_v1` (department only — tab is never
|
||
restored from storage; at startup the tab comes from the deep-link URL if
|
||
present, otherwise `DEFAULT_TAB` = `'dashboard'`, i.e. "Meine Aufgaben"),
|
||
`projuebersicht-theme`, header-collapse key. Do **not** store data in
|
||
`localStorage`; it is UI state only.
|
||
- Deep-link URLs (`/<Dept>/<Tab>`) are shared via the address bar:
|
||
`updateRouteUrl()` rewrites the path via `history.replaceState` on tab and
|
||
department switches. Since the whole app requires login, an unauthenticated
|
||
visitor to any deep link (including `/Projektplan/…`) simply sees the
|
||
login overlay first; the URL itself is never gated per-tab anymore (there is
|
||
no more admin/member split at the tab level — see Auth section).
|
||
- **Sidebar navigation (`App.html` only):** the former top tab bar, a
|
||
separate `<header>`, and a separate off-canvas tool drawer have been merged
|
||
into one persistent left sidebar, `#headerMenu`/`.side-menu` (the element
|
||
keeps its old id/class and `toggleHeader()`/`setHeaderCollapsed()` mechanics
|
||
from `js/ui.js`, now repurposed to hold the entire navigation). Structure
|
||
top to bottom: brand (logo/title), a "Navigation" section with the
|
||
`.nav-item.tab`/`data-tab` buttons (`showTab()`) in the order Meine
|
||
Aufgaben → Projekte → Wochenbericht → Auslastung (plus `#planNavItem`, see
|
||
below) — **no "Abteilung" section** (removed along with department
|
||
partitioning, see "Departments were retired"; `.dept-tab`/`switchDepartment()`
|
||
no longer exist) — then — below the divider — the **Einstellungen** nav item (the tab
|
||
`settings`), the **Werkzeuge** accordion and the **Admin** accordion (its
|
||
sub-items are the sections of the `admin` tab, see below), and a
|
||
`.side-menu-footer` with `#userBarInfo`/`#adminUsersLink`/logout
|
||
(`renderUserBar()`, unchanged). At `min-width: 769px` the sidebar is
|
||
`position: sticky` and permanently visible (no hamburger, `styles.css`);
|
||
below that width, a narrow `.mobile-topbar` (logo + hamburger) replaces the
|
||
old `<header>` and the sidebar reverts to its pre-existing off-canvas
|
||
slide-in behavior. `admin-users.html` deliberately does **not** get this
|
||
sidebar — it stays a focused standalone page outside the tab system, with
|
||
its own back-link and user bar. The Projektbereich needs no special case
|
||
at all any more: being the tab `plan` of `App.html`, it simply IS under
|
||
the same sidebar, with every section (Sitzung, Einstellungen, Admin, user
|
||
footer) working as everywhere else. The only sidebar addition is
|
||
`#planNavItem` — a nav item that stays hidden until a project has been
|
||
opened and then carries that project's name (`syncPlanNavItem()` in
|
||
`js/tabs-auth.js`), so the sidebar still highlights where you are while in
|
||
the Projektbereich. `showTab()` additionally hides `.container` for
|
||
`t === 'plan'`, because `#plan` deliberately sits OUTSIDE it (it brings its
|
||
own header and padding) and the empty container's padding would otherwise
|
||
show as a blank strip.
|
||
|
||
## Auth / security quirk
|
||
|
||
- Real server-side authentication (this replaced the old client-only
|
||
password modal — `PROTECTED_PASSWORD_HASH`, `isAuthenticated`,
|
||
`requestedAction`, `confirmPassword()`, `#passwordModal`, and the CryptoJS
|
||
CDN `<script>` tag are all gone; `js/tabs-auth.js` now only holds `showTab()`).
|
||
Login is required for the **entire app**, not just for mutations/admin
|
||
actions — every JSON endpoint except `/login`/`/whoami` requires at least a
|
||
valid session (see the Auth-level markers in "Server API" above).
|
||
- **Sessions:** a session is a row in `user_sessions` (`token` PK, 30-day
|
||
`expires_at`), identified via the `puebersicht_session` cookie
|
||
(`HttpOnly; SameSite=Lax`, `Secure` added automatically when the request is
|
||
HTTPS). Cookie parsing/setting is hand-rolled (`parseCookies()`,
|
||
`setSessionCookie()`/`clearSessionCookie()` in `server.js`) — no
|
||
`cookie-parser` dependency, keeping the project's express+cors-only
|
||
convention. `attachUser` middleware (registered via `app.use(attachUser)`
|
||
right after `express.json()` and before `express.static`, so it runs for
|
||
every request including static assets) reads the cookie, loads the joined
|
||
session+user row, and sets `req.user` (or `null`); it also refreshes
|
||
`last_seen_at` on every authenticated request. `requireAuth` (401 if
|
||
`req.user` is null) and `requireRole('admin')` (401 if not logged in, else
|
||
403 if `role !== 'admin'`) are the two middleware factories used on routes.
|
||
- **Passwords:** hashed with Node's built-in `crypto.scryptSync` (salt +
|
||
64-byte hash, `hashPassword()`/`verifyPassword()` in `server.js`);
|
||
`verifyPassword()` uses `crypto.timingSafeEqual()`. No bcrypt/argon2
|
||
dependency — stays within the express+cors-only convention.
|
||
`/login` returns the same generic error for wrong username, wrong password,
|
||
or an inactive account (no username-enumeration signal).
|
||
- **Roles:** `admin`, `member`, and `kiosk` (`users.role`, `VALID_ROLES` in
|
||
`server.js` — do not confuse with the discipline `roles` array on a user,
|
||
`Planung`/`Projektleitung`/`Bauüberwachung` in `user_roles`, which is
|
||
unrelated to permissions). `kiosk` is the one role that IS fully
|
||
server-enforced rather than a UI gate (see "Wandtafel" below for why it
|
||
exists): `requireAuth` rejects it outright everywhere except the handful
|
||
of read-only routes the Wandtafel needs (`requireAuthOderKiosk`), and a
|
||
`kiosk` account can never carry a discipline role (enforced on both
|
||
`POST`/`PATCH /admin/users*`, in both directions — setting `role: 'kiosk'`
|
||
on an account with existing discipline roles is rejected exactly like
|
||
adding a discipline role to an existing `kiosk` account). Otherwise, only
|
||
the genuinely destructive/administrative actions are **server-enforced**
|
||
admin-only: week deletion (`/delete-week`), week unlock (`/unlock-week`),
|
||
DB import (`/import`), and the `/admin/users*` CRUD. `/users/:id/absences*`
|
||
stays open to any logged-in user (unchanged permission level from before).
|
||
The `Projekte` tab and the (now read-only) `Projektbeteiligte` tab only
|
||
require being logged in — any role; actually adding/editing/removing team
|
||
members happens exclusively via `admin-users.html` (admin-only). The
|
||
cross-department roster-add/remove UI in the Projektbeteiligte tab
|
||
(`js/people/people.js`) is only **rendered** for `currentUser.role ===
|
||
'admin'` — like the old client-only password modal, this is a UI-level
|
||
gate, not a server-enforced one: it writes into `currentWeek().people`
|
||
through the ordinary `save()` → `POST /save-week`, which only requires
|
||
`requireAuth` (any role), so it does not add a new hard permission
|
||
boundary.
|
||
- **First-admin bootstrap:** `bootstrapInitialAdmin()` runs once at every
|
||
server start, guarded by `SELECT COUNT(*) FROM users` — if `users` is empty
|
||
(fresh install), it generates a random password
|
||
(`crypto.randomBytes(15).toString('base64url')`), creates an `admin` account
|
||
(username `admin`, `must_change_password=1`), and prints the plaintext
|
||
password to the console **and** writes it to
|
||
`sessions/INITIAL_ADMIN_PASSWORD.txt` (outside `public/`, so
|
||
`express.static` never serves it). There is no hardcoded password anywhere
|
||
in the code.
|
||
- **`users.person_id`** is the bridge between login accounts and the
|
||
pre-existing, department-independent `personIdForName()`-based assignment
|
||
scheme (e.g. `plan_todo_assignees.person_id`) — set explicitly by an admin
|
||
(not auto-derived server-side), so an account and a project-roster entry can
|
||
exist independently of each other. A user with no matching `person_id` sees
|
||
an empty dashboard with a hint instead of an error (`renderDashboard()` in
|
||
`js/dashboard/dashboard.js`).
|
||
- CryptoJS is gone; pdfmake (PDF export) is still loaded from
|
||
`cdnjs.cloudflare.com` in `App.html`, so PDF export needs internet access
|
||
and won't work offline. `admin-users.html` loads no CDN script.
|
||
- User-provided status HTML is sanitized on render via a small whitelist
|
||
(`sanitizeStatusHtml` in `js/board/board.js`) to mitigate XSS.
|
||
|
||
### 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 (`einstellungen` section, `js/plan/plan-settings.js` plus
|
||
`js/plan/plan-info.js` for Gruppe/amtierende LPH).
|
||
- **Everywhere else: display plus a way there.** `renderBoardDrawer()` takes
|
||
`opts.nurAnsicht` (set by `BOARD_OPTS` in `js/board/board.js`): same card,
|
||
same values, same order — but no input fields, no clickable avatars, and a
|
||
`.drawer-verweis` underneath with the button "Im Wochenbericht bearbeiten →".
|
||
The Projektbereich does the same via `setzeBerichtVerweis()`
|
||
(`js/plan/plan-info.js`), filling `#planStatusVerweis`, `#planLphVerweis`
|
||
and `#planAssignVerweis`.
|
||
- **The link is a real jump**, not a hint: `springZumWochenbericht(uid)`
|
||
(`js/plan/plan-info.js`, also used by `renderBerichtVerweis()` in
|
||
`board.js`) sets `berichtAuswahl` **before** `showTab('wochenbericht')` and
|
||
therefore lands on that very project. Without it the user would have to find
|
||
it again in a list of thirty. (`berichtAuswahl` is a global `let` binding
|
||
from `js/wochenbericht/wochenbericht.js` — classic scripts, no modules, so
|
||
assigning it from another file works.)
|
||
- **Consequence:** `planStatusBlur()`, `setPlanLph()` and `setPlanAssign()` in
|
||
`js/plan/plan-info.js` are **gone** — they had no caller left. The status
|
||
editor's formatting toolbar (`formatStatus()` buttons) went with them in the
|
||
Projektbereich; the Wochenbericht keeps the full editor. `editLph()`,
|
||
`cycleAssign()` and `saveBoardComment()` in `js/board/board.js` are
|
||
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.js` and re-render each other, so they cannot drift.
|
||
- Changing the **Projektnummer** asks for confirmation (`setPlanProjNr()`):
|
||
uploaded files hang off `(department, project_nr)`, so a renumbered project
|
||
would lose them. Changing the **Bezeichnung** updates the plan header
|
||
(`#planTitle`), the sidebar entry (`syncPlanNavItem()`), the URL
|
||
(`updateRouteUrl('plan')`) and the list — `renderPlanPage()` would do all of
|
||
it, but it is module-local in `plan-state.js` and unreachable from
|
||
`plan-settings.js`.
|
||
|
||
### Eine Projektliste (tab `board`, labelled "Projekte")
|
||
|
||
The former `Projekte` tab is gone; `renderBoard()` is the single project list.
|
||
It kept everything that concerns the LIST and gave up everything that concerns
|
||
a single project's data:
|
||
|
||
- Each group is a drop zone: `<div class="board-list projects-sortable"
|
||
data-group="…" ondragover ondrop>`, each row `draggable` with a
|
||
`.drag-handle`. The handlers stay in `js/projects/projects.js`
|
||
(`handleDragStart`/`handleDrop`/`handleDragEnd`/`reorderProjects`) — they
|
||
hold the ordering logic and are also used by `js/people/people.js`. Only
|
||
`handleDrop()` had to learn which selector to use: it picks
|
||
`.board-row:not([style*="opacity"])` when the drop zone contains board rows,
|
||
`.person-list-item` otherwise. Without that, every drop landed at the end of
|
||
the group.
|
||
- All four groups from `GROUP_ORDER` are pre-seeded even when empty, so a group
|
||
emptied by dragging stays a drop target (a bug already fixed once in the old
|
||
Projekte tab).
|
||
- `.board-row-actions` at the end of each row: "Ö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.js` no longer renders anything by default:
|
||
`renderProjects()` returns immediately unless `#projectsList` exists. Every
|
||
caller (`renderAll()`, `editProjectField()`, `auftrag.js`, `load.js`) can
|
||
stay as it is.
|
||
- `TAB_SLUGS` (`js/globals.js`) lost `projects`, but `TAB_BY_SLUG['Projekte']`
|
||
is set to `'board'` and `VALID_TAB_SLUGS` (`server.js`) still accepts
|
||
`Projekte`, so forwarded links keep working instead of 404ing.
|
||
|
||
### Assistent "Neues Projekt" (`js/projekt-wizard.js`)
|
||
|
||
Sechs Schritte statt eines Formulars mit allen Feldern auf einmal. Der frühere
|
||
Dialog `#newProjModal` ist entfallen, ebenso `openNewProjModal()`,
|
||
`closeNewProjModal()`, `newProjStatusGewechselt()` und `saveNewProj()` in
|
||
`js/load/load.js`.
|
||
|
||
1. **Projekt** — Nummer, Bezeichnung, **Gewerke**, Gruppe, Schlagwörter
|
||
2. **Auftrag** — Auftragsstatus, Honorar **oder** Projektgröße, beauftragte
|
||
Leistungsphasen
|
||
3. **Stand** — amtierende Leistungsphase
|
||
4. **Team** — Beteiligte je Rolle, auf die Gewerke des Projekts eingeschränkt
|
||
5. **Terminplan** — aus dem Honorar errechnet
|
||
6. **Prüfen** — Zusammenfassung, dann Anlegen
|
||
|
||
**Warum überhaupt:** Honorar, Team und Schlagwörter mussten zuvor NACH dem
|
||
Anlegen an drei weiteren Stellen nachgetragen werden — und wurden es meist
|
||
nicht. Genau daher rühren die Projekte, die in der Auslastung als „nicht
|
||
bewertbar" erscheinen. Umgekehrt ist die 27-Felder-Aufwandsmatrix aus dem
|
||
Anlegen verschwunden: Die firmenweite Vorlage genügt, die Feinjustierung
|
||
gehört in den Projektbereich (`openLpModal()` lebt dort weiter).
|
||
|
||
**Jeder Schritt ist Pflicht.** `wizardFehlt(key)` liefert die fehlenden
|
||
Angaben als Klartext; solange etwas fehlt, bleibt „Weiter" gesperrt und
|
||
daneben steht, *was* fehlt — ein gesperrter Knopf ohne Begründung ist eine
|
||
Sackgasse. Ein Assistent, der unvollständige Projekte durchlässt, löst das
|
||
Problem nicht, das er lösen soll.
|
||
|
||
**Gewerke** (`project_gewerke`, Tabelle nach dem Muster von
|
||
`project_beauftragte_lph`; Werte wie `VALID_DEPARTMENTS`): Ein Projekt gehört
|
||
zu einem oder mehreren Fachbereichen. Im Schritt „Team" stehen nur die
|
||
Mitarbeiter daraus zur Auswahl — ein reines Elektro-Projekt soll nicht die
|
||
ganze Belegschaft anbieten. Wer jemanden aus einem anderen Gewerk braucht,
|
||
holt ihn über das Auswahlfeld „Mitarbeiter aus anderen Gewerken" ausdrücklich
|
||
dazu; solche Personen tragen in der Auswahl einen gestrichelten Rand. Wird ein
|
||
Gewerk wieder abgewählt, fallen seine Beteiligten aus dem Team — sonst stünde
|
||
im Projekt jemand, der nach der eigenen Regel gar nicht auswählbar wäre.
|
||
|
||
Das **Gewerk einer Person** kommt aus `users.department`; `loadGlobalPeople()`
|
||
liefert es als Feld `gewerk` mit. Gewählt wird aber aus `week.people` und
|
||
nicht aus `db.globalPeople`: Die Zuordnung landet in `project.assign` und wird
|
||
vom Board über `currentWeek().people` aufgelöst — wer dort fehlt, erschiene
|
||
nirgends.
|
||
|
||
**Honorar oder Projektgröße, nie beides.** Das jeweils andere Feld wird
|
||
gesperrt statt nur ignoriert (ein Feld, das man ausfüllen kann, ohne dass es
|
||
wirkt, ist eine Falle) und beim Umschalten geleert. Die Projektgröße ist ein
|
||
**Zahlenfeld**, kein Regler: Ein Schieber legt eine Feinabstufung nahe, die
|
||
eine grobe Schätzung nicht hat. Neben ihr steht **kein Eurobetrag** — intern
|
||
rechnet `projektBasisHonorar()` zwar über `referenzHonorar`, aber die Summe
|
||
auszuweisen gäbe der Schätzung eine Genauigkeit, die sie nicht hat
|
||
(`groesseText()` gibt deshalb nur noch die Zahl zurück).
|
||
|
||
**Terminplan** (`wizardPlanZeilen()`): derselbe Rechenweg wie
|
||
`berechneHonorarPlan()` — Phasenhonorar ÷ Stundensatz = Stunden, ÷
|
||
(Arbeitsstunden pro Tag × Personen) = Dauer, Phasen hintereinander über
|
||
`addWorkdays()` (kennt die gepflegten Feiertage). Erzeugt je beauftragter
|
||
Phase eine Aufgabe in `project.plan.todos`.
|
||
- **Aus der Projektgröße wird KEIN Terminplan gerechnet.** Sie ist eine
|
||
Schätzung, kein vereinbarter Betrag; daraus abgeleitete Termine sähen im
|
||
Gantt so verbindlich aus wie errechnete. Der Schritt zeigt das als
|
||
erklärten Hinweis, nicht als kommentarlos gesperrtes Feld.
|
||
- Das **Team steht vor dem Terminplan**, weil die Dauer davon abhängt, wie
|
||
viele daran arbeiten. Die Teamgröße ist die Vorgabe für „gleichzeitig
|
||
daran" und überschreibbar. Ohne diesen Schritt ergab eine Phase über
|
||
180.000 € rechnerisch 172 Arbeitstage für eine Person, und der Plan lief
|
||
über Jahre.
|
||
|
||
### Auftragsstatus — von der Anfrage zum Projekt
|
||
|
||
`projects.auftragsstatus` (`anfrage|angebot|beauftragt|abgeschlossen`, default
|
||
`beauftragt`, column added by `migrateAddProjectAuftragsstatus()`) makes the
|
||
step into a project explicit. Everything lives in `public/js/auftrag.js`:
|
||
|
||
- `auftragsstatusChip(p)` returns an **empty string** for `beauftragt` — the
|
||
normal case must not add noise to thirty rows. Shown in the project list
|
||
(`renderBoardRow()`), in the Wochenbericht agenda row and in its detail head.
|
||
- `auftragsstatusSelectHtml(p)` is the mask, used by the Projekteinstellungen
|
||
(`#settingsAuftragsstatus`) and the "Neues Projekt" dialog.
|
||
- Switching TO `beauftragt` does not write directly: `setAuftragsstatus()`
|
||
opens `#beauftragungModal`, because honorar and the beauftragte
|
||
Leistungsphasen belong to the commissioning. The dialog requires at least one
|
||
Leistungsphase, asks back when honorar is missing, offers to move the project
|
||
out of "In Anbahnung" (that group deliberately does not count towards
|
||
Auslastung) and can jump straight to the Terminplan.
|
||
- In the "Neues Projekt" dialog the group follows the status
|
||
(`newProjStatusGewechselt()` in `js/load/load.js`): Anfrage/Angebot proposes
|
||
"In Anbahnung", back to Beauftragt proposes "Aktive Projekte". It is a
|
||
visible proposal in the select, so it can be overridden.
|
||
- `POST /save-week` rejects an unknown status with 400 before writing anything;
|
||
`auftragsstatus` is in `IMPORT_OPTIONAL_COLS` so older backups still import,
|
||
and `projekte.csv` carries the column.
|
||
|
||
### Projektbereich (tab `plan` in `App.html`)
|
||
|
||
- Each project has its OWN area, not just a Gantt view. The area is split into
|
||
six sections switched by the `.plan-nav` bar (`showPlanSection()` in
|
||
`js/plan/plan-sections.js`); all six live in the DOM at once and are only
|
||
toggled via `.is-active`, so every render function keeps filling its elements
|
||
regardless of visibility (that is why the split needed no changes in
|
||
`plan-info.js`, `plan-gantt.js`, `plan-files.js`). The current section is
|
||
written to the URL fragment with `history.replaceState` (`…#aufgaben`) and
|
||
restored from it on load (`app.initPlanSections()`, called at the end of
|
||
`renderPlanPage()`); a `hashchange` listener follows external changes.
|
||
- **Dashboard** — 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. **The LPH progress
|
||
list shows only the BEAUFTRAGTEN Leistungsphasen** (`p.beauftragteLP`,
|
||
set in "Einstellungen") — Erik reported it still listing phases that
|
||
weren't marked as commissioned there. `renderPlanInfo()` previously
|
||
mapped over the full `LPHS` constant unconditionally; it now filters to
|
||
`p.beauftragteLP` when that list is non-empty, falling back to all of
|
||
`LPHS` otherwise (older projects with nothing beauftragt) — the exact
|
||
same `nurBeauftragteLph` rule `BOARD_OPTS` already applies to the
|
||
Wochenbericht/Board card (`js/board/board.js`), just unconditional here
|
||
rather than opt-in, since the Dashboard has no second, unfiltered
|
||
rendering of this list to preserve.
|
||
- **Terminplan** — the Gantt with its toolbar and, below it, the resource
|
||
load (`#resourceLoadContainer`, formerly a modal). **Uses the exact same
|
||
Wochenstunden calculation as the Auslastung tab/Wandtafel**
|
||
(`computeResourceLoads()`, `js/plan/plan-gantt.js`, calling
|
||
`berechneAuslastung()`/`loadAmpelColor()`/`kapazitaetsFaktor()` from
|
||
`js/load/load.js`) — Erik reported the two disagreeing ("Die Auslastung
|
||
der Projektbeteiligten im Terminplan ist eine andere als die in der
|
||
sonstigen Anwendung"). It previously counted something unrelated: workday
|
||
spans derived from each person's assigned TODO date ranges in THIS
|
||
Gantt, relative to a locally-scoped maximum among this project's own
|
||
team — a scheduling-density number with no connection to honorar,
|
||
Leistungsphase, or capacity, so the same person could show a completely
|
||
different figure here than on the Auslastung tab. Now, for every project
|
||
of the session (this one and every other), it runs the identical
|
||
per-role `berechneAuslastung()` call `renderLoad()` makes and sums it per
|
||
person — the headline number and Ampel color shown here are therefore
|
||
now literally the same as that person's total on the Auslastung tab, not
|
||
just similarly computed. The split into "aus diesem Projekt" vs. "aus
|
||
den übrigen Projekten der Sitzung" (`stundenProjekt`/`stundenAndere`,
|
||
surfaced as a `plan-resource-other` badge) is kept from the old
|
||
days-based version — without it, someone could look idle in this one
|
||
project while fully booked elsewhere — just re-expressed in hours
|
||
instead of workdays. The Team-Auslastung KPI tile (`renderPlanKpis()`)
|
||
and the compact heat-row preview (`#planResourceSummary`) consume the
|
||
same `computeResourceLoads()` output and were updated the same way
|
||
(percentage now relative to capacity, not to the locally-scoped
|
||
maximum). A fixed side effect: the old bar/number colors were hardcoded
|
||
hex (`#c53030`/`#d69e2e`/`#2f855a`), invisible to the dark-mode token
|
||
system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this
|
||
view now also follows the app-wide dark-mode palette like everywhere
|
||
else.
|
||
- **Aufgaben** — the same todos/milestones as a list (`renderPlanTasks()`,
|
||
`js/plan/plan-tasks.js`). No own data and no own dialogs: a row opens the
|
||
very same `openTodoModal()`/`openMilestoneModal()` as the Gantt, and the
|
||
indices match because the list keeps `plan.todos`/`plan.milestones` order.
|
||
The create buttons (`+ Aufgabe`, `+ Meilenstein`, `+ Vorlage`,
|
||
`+ Terminplan`) exist twice — in the Gantt toolbar and above the list, the
|
||
latter with an `…BtnList` id 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.todos` is the one shared list either way — every todo always shows
|
||
up here in Aufgaben, with its assignees (`t.assignees`, "Zuständige"
|
||
chips in `openTodoModal()`, unrelated to whether it has a schedule) — but
|
||
`plan-build-gantt.js` (the `buildGanttHtml()` extension that inserts
|
||
TODO/milestone rows under the project row) only inserts a row for a todo
|
||
with `todo.start && todo.end`; a title-only todo (created via `+ Aufgabe`
|
||
or `+ TODO` with the date range left empty — nothing in `saveTodoEdit()`
|
||
requires dates, only the title) stays a pure checklist item that appears
|
||
solely here, never in the Gantt/Terminplan. The Aufgaben row for such a
|
||
todo shows "ohne Termin" in place of a date range
|
||
(`.plan-task-no-schedule`). Giving it a start/end later (editing the same
|
||
todo through either view) makes a Gantt row for it appear on the next
|
||
render — same object, no separate "convert to scheduled" action needed.
|
||
`reorderPlanRows()` (`js/plan/plan-drag.js`) accounts for this: its
|
||
DOM-row-count safety check and the reordering itself only ever touch
|
||
todos that have `start && end` (`scheduledIds`) — a title-only todo has
|
||
no drag handle in the Gantt to begin with and keeps its exact position in
|
||
`plan.todos` untouched by any Gantt row drag.
|
||
- **Leistung** — Leistungsstand & Abrechnung (`renderPlanLeistung()`,
|
||
`js/plan/plan-leistung.js`): the value actually earned, per beauftragte
|
||
Leistungsphase, as **Honorar × HOAI-Anteil der Phase × Fortschritt der
|
||
Phase**. The shares come from `GET /honorar-config` and are normalised
|
||
across the beauftragte phases only (same as `berechneHonorarPlan()`), so a
|
||
project commissioned for three phases still adds up to 100 %. Nothing is
|
||
entered here: honorar and the beauftragte phases come from
|
||
"Einstellungen", the progress from the Wochenbericht — this section only
|
||
computes. Three cases deliberately show a hint instead of a number: no
|
||
honorar, no beauftragte phase, no shares stored. **This was the missing
|
||
link** — the app knew the honorar and it knew the progress per phase, but
|
||
never put the two together, so nobody could see what a project had earned.
|
||
It is its own section rather than a Dashboard card because the breakdown
|
||
has one row per phase with five columns; as a card it would either be
|
||
cramped or appear twice.
|
||
- **Dateien** — upload/review/comments (`js/plan/plan-files.js`), formerly a
|
||
modal.
|
||
- **Protokolle** — Besprechungsprotokolle (`js/plan/plan-protokoll.js`), see
|
||
"### Protokolle" below for the data model and the "Fortschreiben"
|
||
mechanism.
|
||
- **Einstellungen** — 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`).
|
||
- `p.honorar`/`p.beauftragteLP` can now be edited in TWO places: here and in
|
||
the "Terminplan aus Honorar" dialog (which keeps them so it stays usable on
|
||
its own). Both write through `setHonorarBetrag()`/`toggleBeauftragteLp()` in
|
||
`js/plan/plan-honorar.js` and re-render the other mask afterwards, so they
|
||
cannot drift apart. Persistence is the ordinary `savePlan()` → `POST
|
||
/save-week` path (columns `projects.honorar` / table
|
||
`project_beauftragte_lph`) — no dedicated endpoint.
|
||
- `renderPlanGantt()` is the central refresh hook: it ends by calling
|
||
`renderPlanTasks()` and `renderPlanDashboard()`, so every existing mutation
|
||
path (drag, modals, auto-schedule, templates, honorar plan) updates all three
|
||
views without touching those modules.
|
||
- The 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-loaded `db.weeks` and renders. `showTab('plan')` does
|
||
the second step itself, AFTER the area is visible — the Gantt measures its
|
||
container width, which is 0 in a hidden element. `openProjectPlan()` and
|
||
the deep-link path in `initUiState()` therefore only call `setPlanTarget()`.
|
||
Addressing is by week label + project nr/name (never `project.id`, which
|
||
`cloneWeek()` re-assigns). On the very first deep-link render `db.weeks` is
|
||
still empty; that is not an error but a "wird geladen…" state, and `load()`
|
||
calls `showTab(currentTab)` again once the data is there.
|
||
- **There is no separate data load and no separate save path any more.** The
|
||
old page fetched `/load-all` itself and wrote with its own
|
||
`sendWeekToServer()`, because `store.js` wasn't loaded there. In one
|
||
document that would be two writers on the same week sending the same
|
||
revision base — the slower one getting a bogus 409. `savePlan()` is now
|
||
just `markWeekDirty(app.week.label)` + `scheduleSessionSync()`, i.e. the
|
||
ordinary debounced path with its in-flight guard, retry banner, conflict
|
||
handling and 401 login overlay. It passes the label explicitly rather than
|
||
calling `save()`, since `save()` marks the *currently selected* week and a
|
||
deep link may have pointed the Projektbereich at a different one.
|
||
- `updateRouteUrl('plan')` (`js/ui.js`) is the one tab whose URL is not a
|
||
fixed slug: it builds `/Projektplan/<Name>?weekLabel=…&projNr=…` from
|
||
`window.PlanApp` and **keeps `window.location.hash`**, so a link to a
|
||
specific section survives the switch. `applyUrlRoute()` parses the same
|
||
shape back into `{tab:'plan', weekLabel, projNr, projName}`.
|
||
- The `hashchange` listener in `plan-sections.js` and the `resize` listener in
|
||
`plan-gantt.js` both bail out unless `currentTab === 'plan'` — they now run
|
||
while completely different tabs are on screen.
|
||
|
||
### File uploads (`project_files` / review / comments)
|
||
|
||
- Files are linked to a project via **`(department, project_nr)`**, NOT
|
||
`(department, week_label, project_id)` — `project_id` (`p.id`) is
|
||
regenerated on every `cloneWeek()` (`public/js/weeks.js`), while `p.nr`
|
||
stays stable across the project's whole lifecycle. A project without a
|
||
`nr` cannot receive files: the upload button is disabled client-side
|
||
(`openFilesModal()` in `js/plan/plan-files.js`) and the server rejects with
|
||
400 regardless.
|
||
- **Storage:** `sessions/uploads/<department>/<uuid>.<ext>`
|
||
(`uploadDirFor()`/`extractFileExtension()` in `server.js`). The on-disk
|
||
filename is always a fresh `crypto.randomUUID()` plus an extension taken
|
||
from a whitelist regex (`/\.([a-zA-Z0-9]{1,8})$/`) applied to the
|
||
client-supplied filename — no client-controlled string (`original_filename`,
|
||
`project_nr`, `project_name`, `filename` query param) is ever interpolated
|
||
into a filesystem path. `original_filename` is stored purely for display and
|
||
is only ever used as the second argument to `res.download()` (the
|
||
`Content-Disposition` filename), never as a path segment.
|
||
- **Multi-reviewer workflow:** an upload lists one or more reviewer user IDs
|
||
(query param `reviewers=id1,id2`, deduplicated, filtered to active users,
|
||
uploader removed — no self-review, at least one must remain or the upload is
|
||
rejected with 400). Each reviewer gets a `project_file_reviewers` row with
|
||
`decision='pending'`. `POST /project-files/:id/review` recomputes the
|
||
file's overall `status` after every decision: **any single `rejected` decision
|
||
blocks the file immediately** (terminal); only once **all** reviewers have
|
||
`approved` does the file become `approved` (also terminal). Once terminal, no
|
||
further review decisions are accepted (409).
|
||
- Deletion (`DELETE /project-files/:id`) is allowed for the uploader while
|
||
`status='pending'`, or for an admin at any time; it deletes the
|
||
`project_file_comments` and `project_file_reviewers` rows explicitly (no FK
|
||
cascade — see Persistence section) before the `project_files` row, then
|
||
best-effort removes the physical file.
|
||
- `import` never touches these tables (see `/import` note above) — a DB
|
||
restore neither includes nor overwrites uploaded files or their
|
||
review/comment state.
|
||
|
||
### Protokolle (`project_protokolle` / `protokoll_teilnehmer` / `protokoll_punkte`)
|
||
|
||
Besprechungsprotokolle je Projekt (classic Baubesprechungsprotokoll: Bauherr,
|
||
ausführende Firmen and the office's own team meet, discuss numbered points,
|
||
and unresolved points carry over meeting to meeting) — the seventh section of
|
||
the Projektbereich (`js/plan/plan-protokoll.js`), a persisted document type
|
||
that did not exist before this feature. The "Protokoll" inside the
|
||
Wochenbericht (`openBerichtProtokoll()`) is unrelated: that one is a
|
||
throwaway, client-only text export of what changed in a session, never
|
||
stored and not tied to a single project — this is a genuinely different,
|
||
stored, per-project document.
|
||
|
||
**Scoped by `project_nr`, exactly like `project_files`, NOT by
|
||
`week_label`/`project_id`.** `project_id` is regenerated on every automatic
|
||
session rollover (`ensureCurrentWeekRollover()`, see above), but a Protokoll
|
||
must survive across sessions. `week_label` is still stored on
|
||
`project_protokolle` — purely informational (which session was current when
|
||
the Protokoll was created), never used to look one up. A project without a
|
||
`nr` (a Planung-Sandbox plan) can't have Protokolle — the section is hidden
|
||
there, same reasoning and same mechanism as Dateien (see "Planung —
|
||
projektunabhängige Terminplan-Sandbox" above).
|
||
|
||
**Save pattern: one PUT replaces the whole editable content**, not a REST
|
||
route per field. `PUT /project-protokolle/:id` deletes and re-inserts every
|
||
`protokoll_teilnehmer`/`protokoll_punkte` row of that Protokoll in one
|
||
transaction (same "delete then re-insert" shape as
|
||
`deleteWeekData()`/`serializeWeek()` for a whole week) — closer in spirit to
|
||
how `plan.todos` is saved as one object via `savePlan()` than to
|
||
`project_files`' many small endpoints, because Teilnehmer and Punkte are
|
||
authored together in one sitting. The detail view in
|
||
`js/plan/plan-protokoll.js` is accordingly **inline in the Protokolle
|
||
section, not a modal** — too much content for a dialog — with an explicit
|
||
"Speichern" button (no autosave) and simple text/checkbox fields mutating an
|
||
in-memory copy directly (no re-render per keystroke, to avoid losing input
|
||
focus); only structural changes (add/remove a row, change a point's group)
|
||
re-render.
|
||
|
||
**"Fortschreiben" — the core mechanism, entirely server-side
|
||
(`POST /project-protokolle`, `server.js`):** creating a new Protokoll for a
|
||
project **copies** every `erledigt=0` point of that project's most recently
|
||
created Protokoll into the new one — fresh `id`, same `stamm_id` and
|
||
`nummer`, `neu=0`. `stamm_id` is what makes a point "the same point" across
|
||
Protokolle; it is never exposed to or trusted from the client. A point newly
|
||
added within a Protokoll (no existing `id` in the client's `PUT` payload)
|
||
gets a fresh `stamm_id` and `neu=1` — which drives the yellow highlight
|
||
(`.protokoll-punkt.is-neu`, own CSS tokens `--protokoll-neu-bg`/
|
||
`--protokoll-neu-border` in `styles.css`, deliberately NOT `--band-neu`,
|
||
which already means something else — "neu kommentiertes Projekt", blue — in
|
||
the Wochenbericht). Marking a point `erledigt` in one Protokoll does not
|
||
retroactively change it in earlier, already-created Protokolle (each
|
||
Protokoll's own copy is an immutable snapshot, like a distributed paper
|
||
protocol); it simply isn't copied into the *next* one. The very first
|
||
Protokoll of a project has nothing to copy from, so every one of its points
|
||
is `neu=1` by definition.
|
||
|
||
**`nummer` is computed, never edited — entirely decoupled from the
|
||
Fortschreiben/`neu` logic.** First built as a free-text field the user typed
|
||
by hand; changed on Erik's follow-up request to be fully automatic —
|
||
`berechneProtokollNummern(punkte)` (`js/plan/plan-protokoll.js`, exposed on
|
||
`window` so `exportProtokollPDF()` in `js/pdf.js` can call the identical
|
||
function — same cross-file pattern as `classifyProjectChange()` in
|
||
`wochenbericht.js`, which `pdf.js` also calls directly) derives every point's
|
||
displayed number purely from its **current** `gruppe` and position: a group
|
||
gets a top-level number in the order it first appears among the points, and
|
||
each point inside it gets "`<Gruppennummer>.<Position>`" (e.g. "1.1", "1.2",
|
||
"1.3" for the first group's three points, "2.1" for the second group's
|
||
first); a point with no group is just a plain top-level number on its own
|
||
("1", "2", "3" …). **Groups and ungrouped points share ONE counter for that
|
||
top-level number**, not two separate ones — on Erik's explicit follow-up
|
||
("keine Nummer doppelt… wenn die 1 schon in Ohne Gruppe gibt darf sie nicht
|
||
nochmal in ELT oder HLS auftauchen"): whichever comes first in the points'
|
||
order — an ungrouped point, or the first point of a not-yet-seen group —
|
||
claims the next number, so a plain "1" (an ungrouped point) and a group
|
||
numbered "1" (whose points would read "1.1", "1.2"…) can never coexist in
|
||
the same Protokoll; every top-level number is unique across the whole
|
||
document, standalone points and group prefixes alike. **Never trusts the stored
|
||
`protokoll_punkte.nummer` column** — recomputed fresh on every render (and
|
||
again by `exportProtokollPDF()` from its own freshly-fetched copy), so
|
||
screen and PDF can never disagree and a point's number always reflects
|
||
reality even if it moved groups or another point was added/removed/reordered
|
||
around it. The column still exists and the client fills it with the current
|
||
computed value right before every `PUT` purely for the sake of anyone
|
||
reading the row directly (e.g. via the API) — it is never read back as a
|
||
source of truth. Renumbering therefore never affects whether a point is "the
|
||
same point" across Protokolle (that's `stamm_id`) or whether it counts as
|
||
new (`neu`, set once, only at creation) — moving a point to a different
|
||
group, or another point being deleted and shifting everyone's numbers, has
|
||
zero effect on Fortschreiben/`neu`. `gruppe` itself is a genuinely free-text
|
||
field with no backing table and no fixed list — unlike
|
||
`project_gewerke`/`VALID_DEPARTMENTS`, which are a closed enumeration, a
|
||
Protokoll group is created simply by typing it on a point; a `<datalist>`
|
||
suggests names already used within the currently open Protokoll to curb
|
||
accidental near-duplicates ("ELT" vs "Elt"), fed from already-loaded data —
|
||
no extra server round-trip. Points are displayed **clustered into sections
|
||
by `gruppe`**, in the same first-appearance order that drives the
|
||
Gruppennummer, with an "Ohne Gruppe" section always first when any ungrouped
|
||
points exist. Changing a point's group moves it to the matching section (and
|
||
recomputes every affected number) on the next render (`onchange`, not
|
||
`oninput`, so re-clustering doesn't fire mid-keystroke and steal focus from
|
||
other fields).
|
||
|
||
**Reordering after the fact** (Erik's follow-up request) is two separate,
|
||
matching controls, both plain ↑/↓ buttons re-rendering just the Punkte-Block
|
||
— no drag-and-drop, since a point's *visual* section is already fixed by its
|
||
`gruppe` (dragging it across group sections wouldn't make sense without also
|
||
changing that field, which the Gruppe input already does): `verschiebePunkt(
|
||
idx, richtung)` swaps a point with its neighbour **within the same group**
|
||
(or within "Ohne Gruppe") only — disabled at the top/bottom of that group's
|
||
own list; `verschiebeGruppe(gruppe, richtung)` moves an entire group's points
|
||
as one block relative to the other groups (rebuilds `currentProtokoll.punkte`
|
||
as "Ohne Gruppe" points first, unchanged, then each group's points
|
||
concatenated in the new group order) — this is what actually changes which
|
||
group gets Gruppennummer "1" vs. "2". "Ohne Gruppe" itself is not
|
||
reorderable relative to the groups (it stays the fixed first section, same
|
||
reasoning as its render order above); both buttons are simply absent/disabled
|
||
at a list's boundary rather than wrapping around.
|
||
|
||
**Teilnehmer are free text** (`protokoll_teilnehmer`: name + optional
|
||
Firma/Rolle, binary `anwesend` — deliberately the same "no third state" rule
|
||
as `week.attendance`), independent of user accounts entirely — construction
|
||
meetings routinely include externals (Bauherr, ausführende Firmen) who have
|
||
no login here and never should. Two separate, non-overlapping paths fill it:
|
||
creating a new Protokoll by hand (`neuesProtokoll()` in `plan-protokoll.js`)
|
||
pre-fills the Teilnehmer list from the names of the project's *most recently
|
||
created* Protokoll (with `anwesend` reset to `true` — presence must be
|
||
reconfirmed every meeting, never assumed), falling back to
|
||
`projectAssignees(app.project, app.week)` (internal team only, no Firma) if
|
||
no earlier Protokoll exists, or an empty list otherwise — this prefill is
|
||
pure client-side convenience before the `POST`. Importing a document
|
||
(`POST /project-protokolle/import`) instead sources Teilnehmer from the
|
||
document itself via the KI-gestützte Erkennung (see below) — the two paths
|
||
are never combined/merged: an import shows only what the uploaded document
|
||
actually contains, never the previous Protokoll's roster, so the user is
|
||
never left guessing which source a listed name came from. Either way, the
|
||
server only ever persists whatever Teilnehmer array `legeProtokollAn()` is
|
||
given — it does no inference of its own.
|
||
|
||
**No lock/finalize concept in this first version** — unlike a real paper
|
||
Protokoll (which is fixed once distributed), every Protokoll here stays
|
||
editable indefinitely by any logged-in user, the same permission level as
|
||
editing a todo or milestone (`DELETE /project-protokolle/:id` is likewise
|
||
`requireAuth`, not admin-only — deleting a Protokoll is ordinary project
|
||
content, not an administrative action). A future "abgeschlossen" status
|
||
would be a natural, small follow-up, not part of this feature.
|
||
|
||
**PDF export** (`exportProtokollPDF(protokollId)`, `js/pdf.js`) is the third
|
||
PDF export the app has (alongside `exportWochenberichtPDF()`/
|
||
`exportSandboxPlanPDF()`) — A4 portrait like the Wochenbericht (same
|
||
`PDF_WB_PAGE_W`/`PDF_WB_MARGIN_X`/`pdfWbGridLayout()`), reusing `PDF_STYLES`/
|
||
`loadLogoPng()`/`PDF_ASSIGN_MAIN`. It deliberately re-fetches the Protokoll
|
||
fresh from the server rather than reading `plan-protokoll.js`'s private
|
||
in-memory state, so the exported PDF always reflects the last **saved**
|
||
state, never unsaved edits sitting in an open form. Like the screen, the
|
||
Punktetabelle is split into sections by `gruppe`, and `neu=1` rows get a
|
||
pale-yellow `fillColor` — a literal hex value, not the CSS custom property,
|
||
since a PDF does not react to the viewer's light/dark theme.
|
||
|
||
`project_protokolle`/`protokoll_teilnehmer`/`protokoll_punkte` are part of
|
||
`IMPORT_TABLES` (`server.js`) — a Protokoll created by hand has no on-disk
|
||
component and lives entirely in the database (same reasoning as
|
||
`plan_todos`); an **imported** one additionally references a file on disk
|
||
(see next paragraph), which — like `project_files` — a DB restore neither
|
||
includes nor overwrites.
|
||
|
||
**Import: turning an external protocol into one of these** (`POST
|
||
/project-protokolle/import`, `server.js`) — a second way to create a
|
||
Protokoll, alongside the "+ Neues Protokoll" button, for the common case
|
||
where the Bauherr or an ausführende Firma writes and circulates their own
|
||
protocol instead of the office's own. Rather than just filing the document,
|
||
the route extracts its text and turns it into ordinary Protokoll-Punkte, so
|
||
the result is immediately usable through every feature described above
|
||
(Fortschreiben, Gruppen, Verschieben, PDF-Export) instead of being a second,
|
||
parallel kind of content.
|
||
|
||
- **Two new dependencies**, the first this project has ever needed beyond
|
||
express/cors: `pdfjs-dist` (PDF → text, via its `legacy/build/pdf.mjs`
|
||
Node-compatible entry point, loaded with a dynamic `import()` from
|
||
`server.js`'s otherwise-CommonJS code) and `mammoth` (`.docx` → text). Both
|
||
run entirely locally — no network call, no API key, no per-use cost.
|
||
(A third, `undici`, followed later — not for text extraction but to fix a
|
||
local-server timeout bug in the KI recognition itself; see
|
||
`LOKAL_DISPATCHER` further below.)
|
||
`extrahierePdfText(buf)` (`server.js`) opens the document and walks every
|
||
page's `getTextContent()` items, inserting a newline whenever an item's Y
|
||
coordinate (`item.transform[5]`) changes — the same reconstruction a naive
|
||
`.join(' ')` would lose, and `erkennePunkteAusText()` needs real line
|
||
breaks, since a Punkt-Marker must sit at a line's start.
|
||
**`@napi-rs/canvas`** (only for page-to-image rendering, never used here)
|
||
is merely an `optionalDependency` of `pdfjs-dist`, not a hard one — the
|
||
project's `.npmrc` (`omit=optional`) keeps `npm install` from ever
|
||
fetching it, so no native addon ships regardless of platform.
|
||
**First choice was `pdf-parse@1.x`, abandoned after it failed under
|
||
real testing**: pinned initially to dodge `pdf-parse@2.x`'s hard
|
||
dependency on `pdfjs-dist` + `@napi-rs/canvas`, but `pdf-parse@1.1.4`
|
||
bundles a `pdfjs` build frozen at 2017 (`v1.10.100`) that throws
|
||
`UnknownErrorException: bad XRef entry` on **any** PDF, valid or not, the
|
||
moment Node's own `http` module has been loaded anywhere in the process —
|
||
reproducible with nothing more than `require('http')` before the parse
|
||
call, confirmed unrelated to the PDF's bytes (identical MD5 in and out) or
|
||
their transmission (curl vs. direct Node `http.request` vs. a plain local
|
||
`fs.readFileSync` in the same process — only the last one, with `http`
|
||
never loaded, succeeded). Since `express` itself transitively loads
|
||
`http`, this made `pdf-parse` fail on *every* request through the actual
|
||
server while appearing to work fine in an isolated throwaway script —
|
||
exactly the gap a smoke test that only calls the parser directly would
|
||
miss. Switching to `pdfjs-dist` directly (current, actively maintained,
|
||
zero hard dependencies) was unaffected by the same `require('http')`
|
||
repro. Found and fixed before this reached Erik, by testing the actual
|
||
route end-to-end (script → curl → server) rather than trusting a
|
||
standalone unit-style check of the parsing function alone.
|
||
- **`erkennePunkteAusText(text)`** (`server.js`) is the heuristic, plain
|
||
regex/string-processing, no AI: it looks for line-starting numbering
|
||
markers ("1.1", "TOP 3", "Punkt 2" …, `PROTOKOLL_PUNKT_MARKER`), and for
|
||
each match treats the rest of the line as `thema` and everything up to the
|
||
next match as `text`; within that text, labeled fields ("Zuständig:",
|
||
"Termin:" …, `PROTOKOLL_LABEL_MUSTER`) are pulled out if present, and the
|
||
words "erledigt"/"geschlossen" set `erledigt=true`. **`gruppe` is
|
||
deliberately never guessed** — external documents group too
|
||
inconsistently (by Gewerk, by chapter, not at all) to infer reliably, so
|
||
every recognized point lands in "Ohne Gruppe" for the user to sort by
|
||
hand. If no numbering pattern matches anywhere in the whole document, a
|
||
fallback kicks in: every non-empty paragraph becomes its own point with no
|
||
further parsing. The function is pure (no I/O), independent of whether the
|
||
text came from a PDF or a Word file.
|
||
- **KI-gestützte Erkennung (ChatGPT/Claude) as the primary path, with
|
||
`erkennePunkteAusText()` above as the automatic fallback** — on Erik's
|
||
explicit follow-up request, after the heuristic-only version above had
|
||
already shipped. The import dialog offers a `<select>` ("ChatGPT"/
|
||
"Claude", `#protokollImportErkennung` in `App.html`, built at dialog-open
|
||
time from `GET /my-ai-settings` so an option can carry a live "(kein
|
||
Schlüssel hinterlegt)" hint); the chosen provider is sent as
|
||
`?erkennung=chatgpt|claude` to `POST /project-protokolle/import`.
|
||
Deliberately **not** a third "Textmuster" menu entry — that path is a
|
||
safety net, never a conscious choice, exactly mirroring how a
|
||
missing/failing key behaves. `erkennePunkteMitKI(text, userId, provider)`
|
||
(`server.js`, right next to `erkennePunkteAusText()`) reads that ACCOUNT's
|
||
own key via `readUserAiKey(userId, provider)`; on **any** failure — no key
|
||
stored, network error, non-2xx response, a timeout (`AbortController`,
|
||
`KI_TIMEOUT_MS_CLOUD`/`KI_TIMEOUT_MS_LOKAL`), or a response that doesn't
|
||
match the expected shape —
|
||
the route catches it and calls `erkennePunkteAusText(text)` instead, so
|
||
the import always succeeds, even with no internet access at all. The
|
||
response carries `erkennungsart` (`'chatgpt'|'claude'|'textmuster'`) and,
|
||
only on a fallback, `erkennungshinweis` (the failure reason); the client
|
||
surfaces that via the existing `app.showToast()` (`plan-state.js`) rather
|
||
than silently pretending the AI path was used. **Unlike the heuristic,
|
||
the KI path can also attempt `gruppe` and Teilnehmer recognition** (added
|
||
on Erik's follow-up request, after both had shipped empty/absent from
|
||
every import) — a language model can use context the heuristic's plain
|
||
regex cannot: it recognises an actual section heading ("Elektro:", "TOP
|
||
HLS") and a genuine Teilnehmer-/Anwesenheitsliste (usually at the
|
||
document's start/end), things `erkennePunkteAusText()` deliberately never
|
||
attempts (previous bullet).
|
||
**`KI_SYSTEM_PROMPT` stayed deliberately short after it turned out to be
|
||
extremely sensitive to added instructions** — found when Erik reported
|
||
that a real import ("kopiert die Texte nicht mit, erstellt nur die
|
||
Überschriften") produced almost no usable point text at all, diagnosed
|
||
against his actual, real-world sample document (a professional
|
||
PromanX/Phenix "Besprechungsbericht" export) rather than a synthetic
|
||
toy text. That document turned out to be multi-level numbered — a
|
||
top-level chapter ("01"), a sub-chapter ("01.0"), and only the deepest
|
||
level ("01.0 01.03") carrying real descriptive text — and repeated,
|
||
dated revision paragraphs under the same point across several past
|
||
meetings. Direct, repeatable testing against Erik's local LM Studio
|
||
(`temperature:0`, so not sampling noise) showed the SAME request, with
|
||
only small prompt wording changes, swinging between 0, 1, 10, and 11
|
||
recognised points, and between fully-populated and completely EMPTY
|
||
`text` fields for every point — a longer, more explicit prompt (covering
|
||
the multi-level numbering, an anti-hallucination rule for `gruppe`, an
|
||
explicit Teilnehmer section, a rule for merging dated revision
|
||
paragraphs) reliably performed WORSE than a much shorter one covering
|
||
only the essentials, and even appending one extra, individually
|
||
reasonable sentence to an already-working prompt was enough to regress
|
||
it back to near-total failure. The worst single cause found: a leading,
|
||
general "erfinde nichts, lass im Zweifel leer" instruction appeared to
|
||
make the model default to leaving `text` itself empty out of the same
|
||
caution — the opposite of Erik's actual complaint. `KI_SYSTEM_PROMPT`'s
|
||
final, shipped wording is therefore intentionally minimal (no explicit
|
||
Teilnehmer instructions, no revision-merging rule) and was kept VERBATIM
|
||
from the one specific phrasing that was empirically confirmed to work
|
||
(same token counts/results across repeated runs at `temperature:0`) —
|
||
not polished for house style, since even small rewordings had already
|
||
proven capable of silently breaking it again. At the time this was a
|
||
real, but partial, fix: it recovered most point text where the previous
|
||
prompt recovered almost none, but did not reliably suppress a bare
|
||
chapter/sub-chapter heading from occasionally still becoming its own
|
||
empty point, and revision continuations of an earlier point could still
|
||
surface as separate, oddly-numbered entries.
|
||
**A follow-up round (Erik: multiple distinct points under one heading
|
||
should each come through as their own point, AND the existing paragraphs
|
||
under a point should be carried over) added exactly ONE further sentence**
|
||
and, against the same real-document stability test, did NOT regress —
|
||
"Stehen unter einer Überschrift mehrere eigene Punkte, gib jeden einzeln
|
||
aus. Folgen auf einen Punkt weitere Absätze ohne neue eigene Nummer,
|
||
gehören sie zu diesem Punkt dazu." Confirmed against the document's own
|
||
worst case (`01.0 01.06`, three dated revision paragraphs spanning three
|
||
past meetings): previously these surfaced as orphaned, theme-less
|
||
fragments; with the new sentence they came back as ONE point carrying all
|
||
three paragraphs concatenated (2144 characters), and the earlier
|
||
multi-point case (`01.0 01.01`/`01.0 01.03`/`01.0 01.04` all under the
|
||
same `01.0 Organisation` heading) stayed correctly split into separate
|
||
points. So the earlier "not something further prompt tuning could fix"
|
||
conclusion was an overstatement from stopping after ONE failed attempt,
|
||
not a hard ceiling — the real lesson is that EVERY wording change here
|
||
must be tested against the real document at `temperature:0` individually
|
||
before shipping, since nothing about this model's reaction to a given
|
||
sentence can be predicted from how reasonable the sentence reads.
|
||
**A second, independent failure mode turned up while testing the above**:
|
||
against a test excerpt that was artificially cut to exclude the
|
||
document's own real Teilnehmer-/Verteiler table, the model — still obliged
|
||
by the schema to produce a `teilnehmer` array, with nothing genuine to put
|
||
in it — span into a token-repetition loop (the same short name fragment
|
||
repeated well over a thousand times) until `max_tokens` cut the response
|
||
off mid-string, yielding invalid, unparseable JSON that discarded the
|
||
otherwise-correct `punkte` result too. Re-run with the document's actual
|
||
opening Teilnehmer table included, the loop did not recur, and the request
|
||
finished in a tenth of the time (73s vs. 700+s) — strong evidence the
|
||
trigger was the absence of real participant data for that one field, not
|
||
the new point-splitting instruction, and not something every real import
|
||
will hit (a real Besprechungsbericht always carries its own participant
|
||
table). `frequency_penalty`/`presence_penalty` (`erkennePunkteMitChatGPT()`
|
||
only — Anthropic's Messages API has no equivalent parameter) were added
|
||
as a defensive measure regardless, since a repetition loop is a known,
|
||
generic small-model failure mode worth guarding against independent of
|
||
this one trigger being understood.
|
||
**A third round (Erik: the KI should also judge whether a point is open,
|
||
done, or purely informational) replaced the point's boolean `erledigt`
|
||
output with a tri-state `status` — matching the app's own `offen`/
|
||
`erledigt`/`info` status already added for manual editing (see the
|
||
`protokoll_punkte.status` bullet above) — and taught the model to prefer a
|
||
literal in-document marker over its own judgement: the real sample
|
||
document, it turns out, prints the word "Info" directly under several
|
||
points as their own status field, which `erkennePunkteMitChatGPT()`'s new
|
||
instruction now explicitly looks for first. Confirmed against the same
|
||
real document: status came back correctly split across all three values —
|
||
several points correctly `info` (matching their literal "Info" marker),
|
||
others correctly `offen` or `erledigt` from the narrative content when no
|
||
literal marker applied. Normalisation lives in `normalisierePunktAusKi()`
|
||
(validates against `VALID_PROTOKOLL_PUNKT_STATUS`, falls back to `offen`)
|
||
and in `legeProtokollAn()`'s `zusatzPunkte` loop, which accepts EITHER a
|
||
valid `status` string (the KI path) OR falls back to deriving one from the
|
||
older boolean `erledigt` (the Textmuster heuristic, `erkennePunkteAusText()`,
|
||
was deliberately left alone — it has no way to judge "informational vs.
|
||
actionable", only regex pattern matches for "erledigt"/"geschlossen").
|
||
**This change cost back some of the previous round's paragraph-merging
|
||
quality** — the same real-document test that previously returned a point
|
||
like `01.0 01.06` fully merged (2144 characters, three dated revisions in
|
||
one `text`) now split it again into two or three same-themed entries once
|
||
`status` entered the picture, even after adding one more connecting
|
||
sentence ("bleibt es EIN Punkt mit dem Status des letzten Absatzes") that
|
||
measurably helped (11 fragments down to 9) without fully fixing it. This
|
||
is accepted rather than chased further: unlike the earlier regressions,
|
||
nothing here comes back empty, orphaned, or theme-less any more — every
|
||
fragment still carries its own real thema, text, and now-correct status,
|
||
it is simply split one topic too finely in places. The status-rule and the
|
||
merge-rule competing for the same limited headroom is, like the two
|
||
previous rounds, a model-capability ceiling rather than a wording problem
|
||
left unsolved — the same point about
|
||
a stronger model having more room applies here too.
|
||
A more capable model — the real Cloud `gpt-4o-mini`, or a
|
||
larger local model — has more room for further instructions than this
|
||
one could handle; that remains a model-capability question, not a prompt one.
|
||
`temperature:0` was added to both providers' requests for the same
|
||
reason the instability was diagnosable in the first place: without it,
|
||
identical requests returned different results from run to run, making
|
||
it impossible to tell a prompt regression from ordinary sampling noise.
|
||
`max_tokens` was also added to both (16000 for ChatGPT, 8192 for
|
||
Claude — previously unset for ChatGPT, too low at 4096 for Claude):
|
||
a long Besprechungsbericht with many points needs more completion budget
|
||
than either default/prior value guaranteed, and a response cut short by
|
||
a token limit would look identical to one where the model simply decided
|
||
to stop — a second, independent way the old behaviour could under-report
|
||
points regardless of prompt wording.
|
||
- **Both providers run over Node's built-in global `fetch()`** — no new
|
||
npm dependency, unlike the PDF/Word text extraction above, which
|
||
genuinely needed libraries. `erkennePunkteMitChatGPT(text, apiKey,
|
||
apiUrl)` uses the Chat Completions API (`gpt-4o-mini`) with Structured
|
||
Outputs (`response_format: json_schema`, strict mode) to force the exact
|
||
point shape; `erkennePunkteMitClaude(text, apiKey, apiUrl)` uses the
|
||
Messages API (`claude-haiku-4-5-20251001`) with forced tool-use
|
||
(`tool_choice: {type:'tool', …}`) as the equivalent structured-output
|
||
mechanism — Claude has no separate JSON-schema response mode. Both
|
||
normalise each point through the same `normalisierePunktAusKi()` into the
|
||
same `{nummer, gruppe, thema, text, zustaendig, termin, erledigt}` shape
|
||
`erkennePunkteAusText()` already produces (here `gruppe` is a real
|
||
pass-through, not hardcoded empty — see above), so `legeProtokollAn()`
|
||
and everything downstream needs no branching on where a point came from.
|
||
Each recognizer additionally returns a sibling `teilnehmer` array
|
||
(normalised through `normalisiereTeilnehmerAusKi()` into
|
||
`{name, firma, anwesend}`) — both functions' return value changed from a
|
||
plain points array to `{punkte, teilnehmer}` for this; `erkennePunkteMitKI()`
|
||
passes that object straight through, and the `/project-protokolle/import`
|
||
route destructures it into its own `zusatzPunkte`/`teilnehmerAusDokument`
|
||
locals, passing the latter to `legeProtokollAn()` instead of the
|
||
hardcoded `[]` it started with. On the Textmuster-Fallback path
|
||
`teilnehmerAusDokument` stays `[]`, same as before — the heuristic still
|
||
never attempts Teilnehmer recognition.
|
||
Text sent to either API is capped at `KI_MAX_TEXT_LAENGE` (40,000
|
||
characters) — bounds cost/latency on a pathologically large upload,
|
||
comfortably more than any real Besprechungsprotokoll needs. An `apiUrl`
|
||
argument overrides `KI_DEFAULT_URL[provider]` when the account has one
|
||
configured (see next bullet) — e.g. a locally running, OpenAI-compatible
|
||
server (LM Studio, Ollama) instead of the public cloud API, so the
|
||
feature can be tried with a free local model, or a company proxy/
|
||
self-hosted gateway.
|
||
- **Every account holds its OWN key (and optional URL) — not a firmenweit
|
||
setting.** First built admin-managed/firmenweit, changed on Erik's
|
||
explicit follow-up ("jeder user soll für sich seinen schlüssel
|
||
hinterlegen können und die url angeben können"): a new, small
|
||
**`user_ai_keys`** table (`user_id, provider` primary key, `api_key`,
|
||
`api_url`, `FOREIGN KEY … ON DELETE CASCADE` — same User-Kind-Tabelle
|
||
pattern as `user_roles`/`user_absences`) replaces the two rows that used
|
||
to live in the firmenweit `app_settings` singleton store.
|
||
`readUserAiKey(userId, provider)`/`writeUserAiKey(userId, provider,
|
||
patch)` (`server.js`) are the read/write path. `GET`/`PUT
|
||
/my-ai-settings` (scoped to `req.user.id`, never a client-supplied id —
|
||
same pattern as `POST /change-email`) manage them: `GET` returns, per
|
||
provider, only `{configured, apiUrl}` — **never the key itself**, the
|
||
same "write-only, never read back" shape as a password, while `apiUrl`
|
||
is not secret and comes back so the form can be pre-filled; `PUT` sets
|
||
or (given an explicit empty string) clears a key, and separately
|
||
validates/sets or clears an `apiUrl` via `istGueltigeAiUrl()` (400 if
|
||
invalid), leaving an omitted sub-field untouched. The UI lives
|
||
under **Einstellungen → Mein Konto** (`settings.js`, card
|
||
`#settingsAiCard`) — not in Admin, since it is now personal account
|
||
data like the e-mail address and password right above it in the same
|
||
card list, not an administrative setting. No admin role is required or
|
||
checked anywhere in this path.
|
||
- **The card shows a "Cloud"/"Eigener Server" toggle (`.ai-mode-toggle`,
|
||
`setAiMode()` in `settings.js`) instead of the collapsed "API-URL" link
|
||
the first design used.** Redesigned after real use: connecting a local
|
||
server (LM Studio) turned out to be the first thing Erik tried, not an
|
||
edge case, so hiding the URL field behind a small disclosure undersold
|
||
it — the toggle makes "Cloud" vs. "Eigener Server" a visible choice,
|
||
and the URL field's placeholder names a concrete example
|
||
(`http://localhost:1234 für LM Studio`) instead of an abstract
|
||
explanation. `renderSettingsAiKeys()` opens "Eigener Server"
|
||
automatically whenever a saved `apiUrl` is non-empty, so a configured
|
||
override is never hidden on page load.
|
||
- **`POST /my-ai-settings/test` ("Neu verbinden") actually exercises the
|
||
saved connection** — the card previously only showed whether a key was
|
||
*stored* (`configured`), never whether it *worked*; diagnosing Erik's
|
||
LM Studio setup this week (wrong path, too-short timeout, a reasoning
|
||
model burning almost all its tokens on invisible "thinking") had to
|
||
happen entirely through real import attempts because nothing surfaced
|
||
a connection problem on its own. The route reuses
|
||
`erkennePunkteMitKI()` unchanged, scoped to `req.user.id`, with a short
|
||
dummy text — the exact same code path an import uses, so a successful
|
||
test means an import would succeed too (timeout choice, the local
|
||
`reasoning_effort` handling, and URL normalisation all apply
|
||
identically). Returns `{ok:false, error}` with **HTTP 200** on a failed
|
||
test — a failed connection attempt is an expected, valid outcome to
|
||
report, not a server error, so the client branches on `ok` rather than
|
||
on HTTP status. `testMyAiConnection()` (`settings.js`) drives the
|
||
`.ai-reconnect-btn` next to the status pill (spins via
|
||
`.is-testing` while in flight, disabled whenever the provider isn't
|
||
configured — same disabled-state pattern as the "Entfernen" button) and
|
||
writes a failure into `.ai-test-error`, a plain line under the key row
|
||
that **stays visible** until the next test result replaces or clears
|
||
it — deliberately not a fading toast: a toast firing at the same moment
|
||
the surrounding view changes is exactly how the Protokoll-Import's own
|
||
KI-fallback banner was first missed (see below), the identical lesson
|
||
applied here before it could repeat. A successful test instead uses the
|
||
existing transient `showSettingsNote()` — good news is allowed to fade.
|
||
- **`istGueltigeAiUrl()` allows plain `http://` on the loopback
|
||
(`localhost`/`127.0.0.1`/`::1`) — everywhere else `https://` is
|
||
required.** First shipped as `https://`-only, which broke the single
|
||
most obvious reason to set a custom URL at all: Erik tried pointing it
|
||
at a locally running LM Studio, whose OpenAI-compatible server is
|
||
plain `http://localhost:1234/…` (a local dev server essentially never
|
||
carries a TLS certificate for `localhost`). The `PUT` silently rejected
|
||
that URL with 400, so the account's `api_url` stayed empty and every
|
||
import kept hitting the real, public OpenAI endpoint with an
|
||
OpenAI-shaped key that was never meant for it — confirmed from the
|
||
live server log, which showed OpenAI's own `Incorrect API key
|
||
provided…` message, i.e. the request really did leave the building
|
||
instead of staying on `localhost` as intended. `http` stays disallowed
|
||
for every non-loopback host — the key would otherwise cross a real
|
||
network in plaintext — but on the loopback the request never leaves the
|
||
machine, so there is nothing to protect against. Only meaningful when
|
||
the server itself runs on the same machine as the local LLM server
|
||
(true for a normal `npm start`); inside the Docker container (see
|
||
"Docker" below), `localhost` means the container, not the host, so
|
||
reaching a host-side LM Studio/Ollama instance needs
|
||
`host.docker.internal` (Docker Desktop) or the host's LAN address
|
||
instead.
|
||
- **`normalisiereAiUrl(apiUrl, defaultUrl)` fills in the provider's
|
||
default path when a custom `apiUrl` has none.** LM Studio and Ollama
|
||
are both conventionally addressed by their bare origin
|
||
(`http://localhost:1234`, `http://localhost:11434`) — that's the
|
||
address either tool's own UI shows, not the full
|
||
`…/v1/chat/completions` request path a raw `fetch()` needs. Saving just
|
||
the origin therefore isn't a user mistake to reject, it's the expected
|
||
input; without normalising it, the request landed on `/` at the local
|
||
server. Reproduced directly against Erik's own running LM Studio (this
|
||
Bash tool executes on the same machine the app runs on, so `curl
|
||
127.0.0.1:1234` reached his real instance, not a stand-in): its log
|
||
read `Received request: POST to /` / `Unexpected endpoint or method …
|
||
Returning 200 anyway` for the bare origin, and correctly hit
|
||
`/v1/chat/completions` once the path was appended. Only a *missing or
|
||
empty* path (`new URL(apiUrl).pathname` is `''`/`'/'`) gets the
|
||
default appended — an explicit, different path (e.g. a company proxy
|
||
with its own route) is always left exactly as entered.
|
||
- **`KI_TIMEOUT_MS_LOKAL` (30 min — raised twice from an initial 5 min:
|
||
first to 15 min after Erik reported it still aborted often once the
|
||
prompt fix below made the model generate real text for every point
|
||
instead of staying mostly empty — see that bullet — then to 30 min
|
||
after even 15 min still aborted while LM Studio itself finished cleanly
|
||
at roughly 12,000 generated tokens on his hardware) applies whenever the
|
||
resolved URL is on the loopback; every
|
||
other host keeps the original `KI_TIMEOUT_MS_CLOUD` (45 s).** A single
|
||
45 s timeout across the board silently aborted every
|
||
request to a local model before it could finish — confirmed against
|
||
Erik's own LM Studio, which remained correctly computing well past that
|
||
point ("läuft aber durch") while the app had already given up and
|
||
reported `This operation was aborted`. Cloud APIs keep the short
|
||
timeout: a hung request there really does mean something is wrong and
|
||
should fail fast, whereas CPU/modest-GPU local inference routinely
|
||
takes minutes for a single structured-output completion — nothing to
|
||
time out early for. `fetchMitTimeout()` decides via the same
|
||
`istLoopbackHost()` the URL validation above uses, so both paths share
|
||
one definition of "runs on this machine". On its own this raised
|
||
timeout still wasn't enough for Erik's setup — see the next bullet for
|
||
why, and for the fix that actually resolved it.
|
||
- **`erkennePunkteMitChatGPT()` sends `reasoning_effort: 'none'` to local
|
||
targets — the real fix for "This operation was aborted" even at a 5
|
||
minute timeout.** Diagnosed by querying Erik's actual, running LM
|
||
Studio directly (this Bash tool runs on the same machine as `server.js`
|
||
itself, so `curl 127.0.0.1:1234` reaches it for real, not a stand-in):
|
||
the loaded model, `qwen/qwen3.5-9b`, is a **reasoning model** that
|
||
emits a hidden "thinking" transcript (its own `reasoning_content`
|
||
field, separate from `content`) before every answer. For the trivial
|
||
prompt "Sag nur das Wort: Test", it spent 260 of 264 completion tokens
|
||
— 98.5% — on thinking alone, taking 15 seconds for a one-word answer;
|
||
scaled up to the real task (an 11-page Besprechungsprotokoll, 100+
|
||
points, strict JSON schema), that invisible phase alone plausibly runs
|
||
to thousands of tokens, comfortably past any timeout regardless of how
|
||
generous, even though the model reliably finishes if simply given
|
||
enough time ("läuft aber durch" — Erik's own description). Two
|
||
documented Qwen3 conventions for suppressing it were tried against this
|
||
server and neither worked (`enable_thinking: false`, both top-level and
|
||
nested under `chat_template_kwargs`; the `/no_think` prompt suffix) —
|
||
only `reasoning_effort: 'none'` (the OpenAI o-series parameter, which
|
||
LM Studio's compatibility layer honors) did: `reasoning_tokens` dropped
|
||
to 0 and the same trivial prompt returned in 0.4s. Re-verified with the
|
||
actual `KI_SYSTEM_PROMPT` and `KI_PUNKT_SCHEMA` against a real sample
|
||
protocol text: correct, fully valid points (including correctly
|
||
inferring `erledigt: true` from a point's text) in 11s with zero
|
||
reasoning tokens, and end-to-end through the real import route in
|
||
9.5s (`erkennungsart: 'chatgpt'`, no fallback needed) — the existing
|
||
prompt and schema design needed no changes at all once the reasoning
|
||
phase was out of the way. Sent **only** when `istLoopbackHost()` is
|
||
true for the resolved URL — never to the real OpenAI endpoint, where
|
||
`gpt-4o-mini` (not a reasoning model) doesn't support the parameter and
|
||
an unrecognized field could be rejected outright. Claude is unaffected
|
||
by any of this: Anthropic's Messages API only enables thinking via an
|
||
explicit, opt-in `thinking` parameter that this code never sends, so
|
||
there's no equivalent default-reasoning trap to guard against there.
|
||
- **`LOKAL_DISPATCHER` (`server.js`, a dedicated `undici.Agent`) — the fix
|
||
for a second, independent abort Erik hit once `KI_TIMEOUT_MS_LOKAL` was
|
||
first raised past 5 minutes: `fetch failed` / `Headers Timeout Error`,
|
||
even though LM Studio itself kept computing.** Node's global `fetch()`
|
||
runs on an internal undici client whose OWN default `Agent` closes a
|
||
connection after `headersTimeout`/`bodyTimeout` = 5 minutes each,
|
||
entirely independent of the app's own `AbortController` — invisible as
|
||
long as `KI_TIMEOUT_MS_LOKAL` itself was also ~5 minutes (both fired at
|
||
about the same time, so only the app's own clean "This operation was
|
||
aborted" was ever seen), but once the app's own timeout was raised,
|
||
undici's shorter built-in one fired first instead, surfacing as the
|
||
generic, lower-level `fetch failed`. Reproduced directly: a throwaway
|
||
local HTTP server that delays its response, fetched with a
|
||
deliberately short custom dispatcher, failed with the exact same
|
||
`fetch failed` / `Headers Timeout Error` pair Erik saw; the same setup
|
||
with a long dispatcher let the delayed response through. `fetch()`
|
||
itself has no option for this — only the non-standard (but
|
||
Node-honored) `dispatcher` field in its request options can override
|
||
it, which needs an actual `Agent` instance, hence the one new
|
||
dependency: **`undici`** (a second dependency beyond pdfjs-dist/
|
||
mammoth, and the only one of the three NOT used for its own `fetch` —
|
||
only for its `Agent` class, passed into Node's own global `fetch()` via
|
||
`dispatcher`). `fetchMitTimeout()` attaches `LOKAL_DISPATCHER` only for
|
||
loopback targets, with `headersTimeout`/`bodyTimeout` kept 10s above
|
||
`KI_TIMEOUT_MS_LOKAL` so the app's own, friendlier abort message always
|
||
wins the race against undici's lower-level one. Cloud targets are
|
||
unaffected (`KI_TIMEOUT_MS_CLOUD` at 45s is far under undici's 5-minute
|
||
default, so the problem never applied there).
|
||
- **Stored in plaintext**, the same trust boundary as every other column
|
||
in the same database file (e.g. `user_sessions.token`) — not a new or
|
||
weaker guarantee than the rest of the schema.
|
||
- **Breaks, for this one feature only, the server's previous "no internet
|
||
access needed" property** — `holidaysForYear()` was deliberately built
|
||
without a network dependency so the whole app keeps working on an
|
||
office network without internet (see its own comment in `server.js`).
|
||
KI-gestützte Erkennung inherently needs outbound internet access from
|
||
the server; the automatic fallback to `erkennePunkteAusText()` means the
|
||
app as a whole is unaffected — only this one recognition path silently
|
||
degrades to the offline heuristic when there's no connectivity (or when
|
||
the calling account simply hasn't configured a key).
|
||
- **The result is explicitly a draft, not a finished record** — recognition
|
||
from an arbitrarily formatted foreign document, with or without AI, cannot
|
||
be perfectly reliable (table columns routinely collapse in a plain-text
|
||
extract, and even a capable model can misread a badly scanned source), so
|
||
the design leans toward leaving a field empty rather than guessing wrong:
|
||
an empty field is obviously incomplete when reviewed, a wrongly filled one
|
||
is not. Every imported Protokoll shows a banner in the detail view
|
||
(`.protokoll-import-banner` in `plan-styles.css`, reusing the same
|
||
`--protokoll-neu-bg`/`--protokoll-neu-border` tokens as the `.is-neu`
|
||
point highlight) prompting a check before saving, plus a "Quelldatei
|
||
herunterladen" link (`GET /project-protokolle/:id/quelldatei`, the
|
||
Protokoll equivalent of `GET /project-files/:id/download`) so recognized
|
||
text can always be checked against the original. Both are driven purely by
|
||
whether `quelleDateiname` is set on the loaded Protokoll — no separate
|
||
"was this imported" flag or client-side state.
|
||
- **Shares the ordinary creation path, not a separate one**: the pre-existing
|
||
inline logic of `POST /project-protokolle` was extracted into
|
||
`legeProtokollAn(opts)`, called by both routes, so an imported Protokoll
|
||
gets the exact same `nummer` assignment and Fortschreiben-copy of the
|
||
previous Protokoll's open points as a manually created one — the import
|
||
only adds the recognized points on top (`neu=1`, fresh `stamm_id`, appended
|
||
after the copied-forward points). Nothing about numbering, grouping, or
|
||
reordering (described above) needed to change for this feature — imported
|
||
points are ordinary points from the moment they exist.
|
||
- **Two new nullable columns on `project_protokolle`**,
|
||
`quelle_dateiname`/`quelle_stored_name` (migration
|
||
`migrateAddProtokollQuelle()`, same idempotent `ALTER TABLE` pattern as
|
||
`migrateAddProjectGroesse()`), empty for every hand-created Protokoll. The
|
||
uploaded file itself is stored the same way as `project_files` —
|
||
`sessions/uploads/<uuid>.<ext>` (`uploadDirFor()`/`extractFileExtension()`,
|
||
both reused unchanged) — but **not** in the `project_files` table itself:
|
||
that table's reviewer/approval workflow doesn't apply here (nobody
|
||
"approves" someone else's protocol), so `quelle_stored_name` is the only
|
||
pointer to the file, and `quelle_stored_name` (unlike `quelle_dateiname`)
|
||
is never sent to the client — only used server-side to resolve the
|
||
download route.
|
||
|
||
### Auslastung — Wochenstunden statt Faktoren
|
||
|
||
Die Auslastung wird in **Wochenstunden** gerechnet und erst zum Schluss auf die
|
||
Kapazität der Person bezogen. Vorher war sie eine Summe dimensionsloser
|
||
Gewichte: Ein Projekt in LPH 5 zählte für die Planung 1,0 — ob es 20.000 € oder
|
||
2 Mio. € umfasste. Zwei kleine Projekte wogen damit schwerer als ein großes,
|
||
und die Zahl sagte nichts darüber, wie viel Arbeit anliegt.
|
||
|
||
**Der Weg von einem Projekt zu den Wochenstunden einer Person**
|
||
(`berechneAuslastung()` in `js/load/load.js`, sechs Schritte):
|
||
|
||
1. **Basis** — `projektBasisHonorar()`: das Honorar des Projekts. Fehlt es,
|
||
tritt `projects.groesse` (0–1) mal `APP_SETTINGS.referenzHonorar` an seine
|
||
Stelle. Fehlt beides, ist das Projekt **nicht bewertbar** — es wird als
|
||
Lücke ausgewiesen, nicht als Null gezählt.
|
||
2. **Phase** — `arbeitsPhase()` bestimmt, womit gerechnet wird: die amtierende
|
||
Leistungsphase, und wenn die bereits zu 100 % erledigt ist, die nächste
|
||
beauftragte, die noch offen ist. Ohne diesen Schritt fiel ein Projekt aus
|
||
der Auslastung, sobald jemand seine Phase auf 100 % setzte, ohne
|
||
weiterzuschalten — im Bestand der Normalfall. Sind alle beauftragten Phasen
|
||
fertig, ist das Projekt fertig und bindet zu Recht keine Kapazität mehr.
|
||
`phasenAnteilProzent()` liefert den Anteil dieser Phase am Honorar
|
||
(`HONORAR_ANTEILE`, auf die **beauftragten** Phasen normiert — dieselbe
|
||
Rechnung wie im Leistungsstand), abzüglich des Fortschritts. Übrig bleibt
|
||
der offene Wert.
|
||
3. **Stunden** — offener Wert geteilt durch `APP_SETTINGS.stundensatz`.
|
||
4. **Rolle** — `rollenGewicht()` (Projektmatrix, sonst firmenweite Vorlage,
|
||
unverändert), hier aber als **Anteil**: Gewicht der Rolle durch die Summe
|
||
aller Rollengewichte dieser Phase.
|
||
5. **Köpfe** — Aufteilung auf die Personen, die dieser Rolle im Projekt
|
||
zugeordnet sind (`zuordnungsGewicht()`: Hauptverantwortung 1,
|
||
Unterstützung 0,5). Ohne diesen Schritt zählte jeder von drei Planern die
|
||
vollen Phasenstunden — der Aufwand verdreifacht sich nicht dadurch, dass
|
||
mehr Leute daran arbeiten. Dafür bekommt `berechneAuslastung()` das
|
||
`assign`-Objekt des Projekts als letzten Parameter.
|
||
6. **Zeit** — `projektVerteilWochen()`: die Wochen bis zum spätesten Datum im
|
||
Terminplan; ohne Terminplan (oder bei bereits verstrichenem Ende) gilt
|
||
`APP_SETTINGS.verteilzeitraumWochen`. Ohne Horizont wäre die Wochenlast
|
||
nicht bestimmt — dieselben Reststunden bedeuten über vier Wochen etwas
|
||
anderes als über vierzig.
|
||
|
||
`HONORAR_ANTEILE` (`js/globals.js`) wird von `loadAppSettings()` (`js/store.js`)
|
||
zusammen mit den übrigen Vorgaben aus `GET /honorar-config` geladen, also
|
||
**vor** `/load-all` und damit vor dem ersten `renderAll()`. Der Projektbereich
|
||
holt sie sich für den Leistungsstand weiterhin selbst (`plan-leistung.js`),
|
||
weil er sie auch nach einer Änderung im laufenden Betrieb neu braucht.
|
||
|
||
**Was die Seite sonst noch anders macht als zuvor:**
|
||
|
||
- **Eine Summenzeile je MENSCH** über alle Rollen steht vor den Rollenblöcken.
|
||
Bisher fehlte sie: Wer in drei Rollen arbeitet, hat trotzdem nur eine
|
||
Arbeitswoche, und jede einzelne Rollenzeile blieb unter der Schwelle. Erik
|
||
Dunin kam mit 0,80 Planung und 0,40 Projektleitung auf 1,20 — rot war keine
|
||
der drei Zeilen.
|
||
- **Die Kopfzeile sagt, was die Zahlen abdecken** („1 von 25 Projekten
|
||
bewertet"), und ein Aufklapper listet die nicht bewertbaren Projekte mit
|
||
Grund. Vier Gründe kommen vor: ohne amtierende Leistungsphase, ohne Honorar
|
||
und ohne Projektgröße, ohne Honorar-Anteil für diese Phase, und **niemand
|
||
für \<Rolle\> zugeordnet** — ein Projekt kann bewertbar sein und trotzdem
|
||
bei niemandem ankommen, etwa wenn es in LPH 8 steht (die die Bauüberwachung
|
||
trägt), aber nur ein Planer zugeordnet ist. Eine fehlende Angabe ist keine
|
||
fehlende Arbeit; vorher war beides eine Null.
|
||
- **Das volle Band ist die Wochenkapazität.** Die frühere Skala
|
||
`auslastungBalkenMax` (Vorgabe 8) ist ersatzlos entfallen — samt
|
||
`loadBarMax()`, dem Admin-Feld und dem Eintrag in `APP_SETTING_DEFS`: Sie
|
||
stand im Widerspruch zur Ampel, die ab 1,0 rot wird, sodass ein roter Balken
|
||
nur zu einem Achtel gefüllt war.
|
||
- **`js/pdf.js` rechnet dieselbe Rechnung** (Spalten „h / Woche" und „Anteil
|
||
der Kapazität"), sonst wiesen Bildschirm und Druck verschiedene Zahlen aus.
|
||
|
||
**Die Projektgröße** (`projects.groesse`, REAL 0–1, Migration
|
||
`migrateAddProjectGroesse()`) wird als Regler im Bereich „Einstellungen" des
|
||
Projekts und im Dialog „Neues Projekt" gepflegt; `groesseText()` zeigt neben
|
||
dem Regler den Eurobetrag, weil eine nackte Zahl zwischen 0 und 1 niemandem
|
||
etwas sagt. Unter dem Regler steht, ob er gerade überhaupt zählt — eine Zahl,
|
||
die wirkungslos ist, ohne dass man es sieht, wäre schlimmer als keine.
|
||
Bestandszeilen starten mit 0 (= keine Angabe), absichtlich ohne Vorgabewert
|
||
wie 0,5: Eine erfundene Größe erzeugte Zahlen, die nach Messung aussehen.
|
||
`normalizeGroesse()` (`server.js`) kappt auf 0…1.
|
||
|
||
### Auslastung — das Lastband
|
||
|
||
`renderLoad()` (`js/load/load.js`) zeigt eine **Zeile je Person und Rolle**,
|
||
und der Balken ist nach Projekten aufgeteilt: ein Segment je Projekt, seine
|
||
Breite der Beitrag zur Last. Vorher stand jede Person in einer eigenen
|
||
`.load-card` mit ihrer Projektliste klein darunter — man sah die Zahl, aber
|
||
nicht, woraus sie besteht. Auf eine Auslastung von 1,80 folgt aber immer
|
||
dieselbe Frage: aus einem großen Projekt oder aus fünf kleinen, und was davon
|
||
lässt sich abgeben? Genau das beantwortet das Band.
|
||
|
||
- **Gerechnet wird nichts Zusätzliches.** `stats[key].projects` hält
|
||
`{anzahl, stunden, nr, quelle}` statt nur einer Zählung — derselbe Wert, der
|
||
ohnehin aufaddiert wird, wurde bisher nur verworfen. Die Segmentbreiten sind
|
||
Anteile der Wochenkapazität, sodass ein volles Band eine volle Woche ist.
|
||
Segmente aus einer geschätzten Projektgröße tragen in der Legende ein `≈`.
|
||
- **`projektFarbe(name)`** hasht den Projektnamen in `PROJEKT_FARBEN`, statt
|
||
nach Position zu vergeben: Die Auswertung läuft auch über alle Sitzungen, in
|
||
denen ein Projekt an wechselnder Stelle steht — eine Farbe, die je Sitzung
|
||
springt, wäre schlimmer als keine. Die Palette ist bewusst gedämpft und vom
|
||
Ampelspektrum getrennt (ein Segment sagt, WELCHES Projekt, nicht ob es gut
|
||
steht) und trägt weiße Schrift mit mindestens 4,5:1.
|
||
- **Beschriftung erst ab 7 % der Bandbreite**, sonst bliebe von "21-4001" ein
|
||
angeschnittenes "21-40" stehen. Schmale Segmente behalten Farbe, Tooltip und
|
||
ihren Eintrag in der `.load-legende`, die nur die tatsächlich vorkommenden
|
||
Projekte auflistet.
|
||
- **Eine Null bekommt KEINE Ampelfarbe** (`--text-subtle` statt Grün): Grün
|
||
hieße "gut ausgelastet", die Null heißt aber "nichts zugeordnet". In einer
|
||
Sitzung mit ungepflegten Zuordnungen stünden sonst dreizehn grüne Nullen
|
||
untereinander und meldeten Entwarnung, wo nur Daten fehlen — im Bestand
|
||
genau der Fall.
|
||
- **`loadAmpelColor()` liefert Design-Tokens** (`var(--red)` …) statt der
|
||
früheren Festwerte. Die Ampel färbt jetzt die große Zahl am Zeilenende, und
|
||
`#c53030` kam auf schwarzem Grund nur auf 3,6:1; mit dem Token sind es 6,2:1.
|
||
`pdfLoadAmpelColor()` behält seine druckfreundlichen Festwerte — ein PDF hat
|
||
kein Thema.
|
||
- Innerhalb einer Rolle wird **absteigend nach Auslastung** sortiert: Wer zu
|
||
viel hat, steht oben, und die Zeilen ohne Zuordnung sammeln sich unten,
|
||
statt die Liste zu durchsetzen.
|
||
- **`.load-card`/`.load-row`/`.load-num`/`.load-detail` bleiben bestehen** —
|
||
das Dashboard (`js/dashboard/dashboard.js`) und die Ressourcenansicht des
|
||
Projektbereichs (`js/plan/plan-gantt.js`) bauen darauf auf. Der Show Mode
|
||
kopiert das gerenderte HTML und zeigt das Band deshalb ohne Änderung; die
|
||
drei `.show-mode-content .load-*`-Regeln wurden auf die Zeilenform
|
||
umgestellt.
|
||
|
||
### Projektbeteiligte (global users, cross-department staffing)
|
||
|
||
**Removed from the sidebar navigation** (on Erik's request): once departments
|
||
stopped partitioning weeks/projects/people, this tab's original job —
|
||
pulling a Beteiligter from an ELT/HLS/ARCH-specific roster into a
|
||
department-scoped session — no longer applied to a single shared roster, and
|
||
what remained (a read-only list) duplicated the role/avatar chips already
|
||
visible in Wochenbericht/Board/Auslastung. The `.nav-item.tab[data-tab="people"]`
|
||
button in `App.html` is gone; the tab panel (`#people`), its module
|
||
(`js/people/people.js`) and its deep-link slug (`Projektbeteiligte` in
|
||
`VALID_TAB_SLUGS`) are all still there and still reachable by URL — only the
|
||
sidebar entry point was removed, not the feature itself. The bullets below
|
||
describe that still-live feature; where they mention department-scoped
|
||
rosters, treat that as unverified against current reality (see "Departments
|
||
were retired").
|
||
|
||
- The per-department "Projektbeteiligte" concept — its own `people`/
|
||
`person_roles`/`person_absences` rows with client-side CRUD/drag-reorder —
|
||
has been fully replaced by the global `users` table (see Persistence for
|
||
the migration). `db.globalPeople[department]` is now populated purely by
|
||
the server: `loadGlobalPeople(department)` (`server.js`) reads
|
||
`users`/`user_roles`/`user_absences` filtered to `department` and
|
||
`active=1`, so the Projektbeteiligte tab (`js/people/people.js`,
|
||
`renderPeople()`) is **read-only** — no add/edit/delete/reorder form, no
|
||
drag handles. Only admins maintain team members, exclusively through
|
||
`admin-users.html` (`department` is a required field there, `initials` is
|
||
the board-column label, `roles` are the discipline roles).
|
||
- **Cross-department staffing:** a project's team is not restricted to its
|
||
department's own users. In the Projektbeteiligte tab, an admin (UI-gated
|
||
only, see Auth section) on an unlocked week sees a "+ Person aus anderer
|
||
Abteilung zur aktuellen Sitzung hinzufügen" picker (`loadCrossDeptUserOptions()`/
|
||
`addSelectedUserToCurrentWeekRoster()` in `js/people/people.js`), sourced
|
||
from `GET /users` filtered to `department !== currentDepartment`, a set
|
||
`personId`, and not already in `currentWeek().people`.
|
||
`addUserToCurrentWeekRoster(user)` pushes a normal person object
|
||
(`{id, name, fullName, email, role, absences:[]}`, built from the chosen
|
||
user) directly into `currentWeek().people` and calls the ordinary `save()`
|
||
— no new endpoint needed, since `week.people` is already part of the whole
|
||
week JSON that `/save-week` writes. Because `board.js`'s `renderBoard()`/
|
||
`peopleByRole()`/`cycleAssign()` and `load.js` iterate exclusively over
|
||
`currentWeek().people` (never `db.globalPeople[dept]` directly), the added
|
||
person immediately shows up as its own assignable board column — no
|
||
changes were needed in `board.js`/`load.js`. `renderPeople()` shows such
|
||
entries with an "(andere Abteilung)" hint and a "✕ Aus dieser Sitzung
|
||
entfernen" button (`removeForeignUserFromCurrentWeekRoster()`), which
|
||
refuses to remove a person still assigned to a project in that week (same
|
||
guard the old `delPerson()` had). `cloneWeek()` carries these
|
||
cross-department additions into the next week automatically (it copies
|
||
`week.people` verbatim); `newWeek()` (a fresh start) does not — the roster
|
||
resets to the department's own home users, which is intentional (with an
|
||
explanatory hint in the UI).
|
||
|
||
### Planung — projektunabhängige Terminplan-Sandbox (tab `planung`, `js/planung/planung.js`)
|
||
|
||
Ein zweiter, eigener Abschnitt der Seitenleiste (nicht Teil von
|
||
"Navigation", zwischen Auslastung und Einstellungen) — eine **Sandbox**, in
|
||
der sich Terminpläne **unabhängig von einem echten Kundenprojekt** anlegen
|
||
lassen: entweder aus einem Honorar errechnet (derselbe Rechner wie bei einem
|
||
echten Projekt) oder frei von Hand begonnen. Jeder angemeldete Nutzer darf
|
||
das, keine Admin-Sperre. Mit voller Terminplan-Funktionalität — Drag&Drop,
|
||
Abhängigkeiten, kritischer Pfad, Auto-Schedule, Zoom — weil ein Sandbox-Plan
|
||
technisch ein ganz normales Projekt ist, nur an einer besonderen Stelle.
|
||
|
||
**Architektur: eine reservierte Sitzung statt eines Flags.** Ein erster
|
||
Ansatz (ein Flag auf einem Projekt der jeweils aktuellen Sitzung) wurde
|
||
verworfen: `cloneWeek()` (`public/js/weeks.js`) vergibt beim Duplizieren
|
||
einer Sitzung jedem Projekt eine neue `id` — ein Sandbox-Plan verlöre bei
|
||
jeder neuen Sitzung seine Identität. `newWeek()` startet dagegen mit
|
||
`projects:[]` — ein Sandbox-Plan in der "aktuellen Woche" verschwände beim
|
||
nächsten `newWeek()` einfach. Und `applyAutoLock()` sperrt jede Woche außer
|
||
der zeitlich letzten — ein Sandbox-Plan würde unvermittelt schreibgeschützt.
|
||
Stattdessen: **eine einzige, dauerhaft reservierte "Sitzung"**,
|
||
`SANDBOX_WEEK_LABEL = '__sandbox__'` (identisch in `server.js` und
|
||
`js/globals.js` — kein gemeinsames Modulsystem zwischen Server und Client).
|
||
`ensureSandboxWeek()` (`server.js`, läuft bei jedem Start wie die übrigen
|
||
Migrationen) legt die `weeks`-Zeile per `INSERT OR IGNORE` einmalig an.
|
||
Jeder Sandbox-Terminplan ist eine ganz normale Zeile in
|
||
`projects`/`project_plan`/`plan_todos`/`plan_milestones`/… mit diesem
|
||
`week_label` — **keine neue Spalte, keine Migration an `projects`** (bis auf
|
||
`updated_at`, siehe unten), `loadWeeks()`/`serializeWeek()` bleiben
|
||
unverändert.
|
||
|
||
**Der entscheidende Vorteil: strukturelle statt gemerkter Isolation.**
|
||
`GET /load-all` (`server.js`) spaltet `loadWeeks()`s Ergebnis auf — die
|
||
Sandbox-Woche wird NICHT als Teil von `weeks[]` ausgeliefert, sondern separat
|
||
als `sandboxPlans` (nur ihre `.projects`) plus `sandboxUpdatedAt`. Der Client
|
||
baut daraus in `load()` (`js/store.js`) ein eigenes, synthetisches Objekt
|
||
`db.sandboxWeek` — niemals Teil von `db.weeks`. Weil `renderBoard()`
|
||
(board.js), `berichtProjekte()` (wochenbericht.js), `berechneAuslastung()`
|
||
(load.js), `exportWochenberichtPDF()` (pdf.js),
|
||
`renderAdminSitzungen()` (admin.js) und jeder künftige Code, der `db.weeks`
|
||
durchläuft, die Sandbox-Daten also **strukturell nie sehen können**, gibt es
|
||
keinen Filter, den man in sechs Dateien hätte vergessen können — anders als
|
||
bei einem Flag-Ansatz. Nur an den wenigen Stellen, die `db.weeks` selbst
|
||
zusammensetzen oder adressieren, gibt es einen expliziten, kleinen Zweig:
|
||
|
||
**Achtung, echte Ausnahme dieser Garantie:** Sie gilt nur für den Weg über
|
||
`GET /load-all` (also für `db.weeks` clientseitig). Jeder serverseitige Code,
|
||
der `loadWeeks()` selbst ein zweites Mal aufruft, statt sich auf `db.weeks`
|
||
zu verlassen, braucht denselben `.filter(w => w.label !== SANDBOX_WEEK_LABEL)`
|
||
direkt nach dem Aufruf erneut — sonst bekäme die reservierte Sandbox-Woche
|
||
dort eine eigene Zeile, als wäre sie eine echte Sitzung. Vor jeder neuen
|
||
serverseitigen Route, die `loadWeeks()` aufruft, lohnt sich deshalb ein
|
||
kurzer Blick auf diesen Abschnitt.
|
||
- `syncAllToServer()` (store.js): `label === SANDBOX_WEEK_LABEL` löst zu
|
||
`db.sandboxWeek` statt zu `db.weeks.find(...)` auf — ohne diesen Zweig
|
||
würden Sandbox-Änderungen lautlos verworfen (`dirtyWeeks.delete()`, ohne je
|
||
zu speichern), weil `db.weeks.find()` die Sandbox nie findet.
|
||
- `openPlanView()` (`js/plan/plan-state.js`): eigener Zweig für
|
||
`app.weekLabel === SANDBOX_WEEK_LABEL`, der `app.week = db.sandboxWeek`
|
||
setzt und das Projekt über die stabile **`id`** sucht statt über `nr`/`name`
|
||
wie bei einem echten Projekt (`openSandboxPlan(planId)`,
|
||
`app.sandboxPlanId`) — Sandbox-Projekte werden nie geklont, ihre `id` ist
|
||
also im Unterschied zu einem echten Projekt dauerhaft stabil, und sie haben
|
||
kein `nr`, also auch keine sinnvolle `nr`/`name`-Adressierung.
|
||
- `applyAutoLock()` (weeks.js): filtert `SANDBOX_WEEK_LABEL` heraus, bevor
|
||
"welche Woche ist die späteste" berechnet wird — ein reines Sicherheitsnetz,
|
||
da die Sandbox-Woche strukturell ohnehin nie in `db.weeks` ankommt.
|
||
- `validateWeekLabel()` (weeks.js) lehnt den reservierten Namen als von Hand
|
||
eingegebene Sitzungsbezeichnung ab; `/delete-week`/`/unlock-week`
|
||
(server.js) lehnen ihn mit 400 ab — die reservierte Woche ist über die
|
||
normale Sitzungsverwaltung weder löschbar noch entsperrbar.
|
||
- `updateRouteUrl()`/`applyUrlRoute()` (`js/ui.js`) und `initUiState()`
|
||
(`js/init.js`): die Deep-Link-Adresse eines Sandbox-Plans trägt `planId`
|
||
statt `projNr` (`/Projektplan/<Name>?weekLabel=__sandbox__&planId=…`), weil
|
||
ein Sandbox-Plan kein `nr` hat.
|
||
|
||
**`projects.updated_at`** (neue, idempotente Migration
|
||
`migrateAddProjectUpdatedAt()`, gleiches Muster wie
|
||
`migrateAddProjectGroesse()`) ist die einzige neue Spalte — nötig, weil
|
||
mehrere, voneinander unabhängige Sandbox-Pläne in EINER Woche liegen und sich
|
||
"zuletzt bearbeitet" bis dahin nur je WOCHE unterscheiden ließ
|
||
(`weeks.updated_at`). Für echte Projekte harmlos mitgeführt, aber ungenutzt.
|
||
Befüllt in `serializeWeek()`s bestehendem `insProject.run(...)`.
|
||
|
||
**Welche Projektfelder ein Sandbox-Plan nutzt:** `name` (einzige Identität,
|
||
Pflichtfeld, keine Duplikate — geprüft in `erstellePlanungTerminplan()`),
|
||
`honorar` + `beauftragteLP` (treiben den vorhandenen "Terminplan aus
|
||
Honorar"-Rechner, `plan-honorar.js`, unverändert), der komplette
|
||
`plan`-Baum (Todos/Meilensteine/Abhängigkeiten/Beteiligte). NICHT genutzt:
|
||
`nr`, `group`, `auftragsstatus`, `groesse`, `project_gewerke` — jeweils an
|
||
echte Geschäftslogik gebunden, die für eine Sandbox keinen Sinn ergibt.
|
||
|
||
**Wiederverwendeter Projektbereich, mit ausgeblendeten Abschnitten.**
|
||
`openSandboxPlan(planId)` führt in denselben Projektbereich (`#plan`), der
|
||
auch für echte Projekte dient — keine zweite Gantt-Implementierung. Ein
|
||
zentraler Merker `app.isSandboxPlan()` (`plan-state.js`, neben dem
|
||
bestehenden `app.isLocked()`) blendet aus: die Bereiche **Dateien** (kein
|
||
`nr`, kein sinnvoller Prüf-Workflow), **Leistung** (echte
|
||
Abrechnung/Auftragsstatus-abhängig) und **Protokolle** (hängt wie Dateien an
|
||
`project.nr`) ganz (`plan-nav`-Knöpfe versteckt, `initPlanSections()` in
|
||
plan-sections.js fängt zusätzlich einen direkten
|
||
`#dateien`/`#leistung`/`#protokolle`-Link ab und fällt auf Dashboard zurück); in
|
||
**Einstellungen** (`plan-settings.js`) die Felder Projektnummer,
|
||
Auftragsstatus, Gewerke, Projektgröße — **Honorar, beauftragte
|
||
Leistungsphasen und die Beteiligten-Matrix bleiben sichtbar**. Die
|
||
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-settings` mechanism. "Mein
|
||
Konto" itself holds master data for display, the account's own e-mail
|
||
address, and the password change. Display name, role and `personId` stay an admin's
|
||
job via `PATCH /admin/users/:id`; the area calls out a missing `personId`
|
||
explicitly, since that silently empties "Meine Aufgaben"
|
||
(`GET /my-dashboard` matches on `plan_todo_assignees.person_id`).
|
||
**E-mail is the one field the account edits itself** (inline input next to
|
||
the value, `submitEmailChange()` → `POST /change-email`, see Server API) —
|
||
it used to be admin-only like the rest of that group, but unlike display
|
||
name or role it is purely contact information the account owner is best
|
||
placed to keep current, and making someone wait on an admin just to enter
|
||
their own address was needless friction. An admin can still set/correct it
|
||
via the Benutzerverwaltung, e.g. for a colleague who hasn't logged in yet.
|
||
- Everything company-wide used to sit here next to it. That mixed two
|
||
different things in one list — your own account details, which concern
|
||
everyone, and defaults that shift *everyone's* numbers and belong to admins
|
||
alone. The latter moved to the Admin area below.
|
||
- Until this area existed the app had **no settings page at all**. The sidebar
|
||
group labelled "Einstellungen" contained only *actions* (Neue Sitzung,
|
||
Sitzung duplizieren, PDF-Export, Show Mode, CSV-Export) and is therefore now
|
||
labelled **"Werkzeuge"** (accordion key `tools`, ids `toolsAccordionToggle`/
|
||
`toolsSubnav`).
|
||
- **CSV-Export removed entirely** (route, `js/csv.js`, the "CSV Export"
|
||
button) — on Erik's explicit call: with a real SQLite database behind the
|
||
app now, a standing CSV export of the same data no longer earns its place;
|
||
`GET /backup` already covers taking the data out wholesale. Unlike the
|
||
Projektbeteiligte nav removal, this was a full removal, not just an
|
||
unlinking — no route, no deep link, nothing left to find by URL.
|
||
`buildExportCsvs()`/`buildZip()`/`csvFile()`/`csvCell()`/`CSV_SEP` (all
|
||
server.js) went with it.
|
||
- **"Neue Sitzung"/"Sitzung duplizieren" removed as well**, for an unrelated
|
||
reason: see "Automatischer Sitzungswechsel" below — creating a session is
|
||
no longer a user action at all.
|
||
- **"PDF Export" (`exportPDF()`) removed too** — on Erik's call, PDF export
|
||
should live only in the Wochenbericht and in Planung-Terminpläne, not as a
|
||
standalone whole-session export. Along with the button and the function
|
||
itself, `pdf.js` lost every helper exclusive to it (`pdfRoleBg()`,
|
||
`pdfTableLayout()`, `pdfHeaderRows()`, `pdfProjectRow()`,
|
||
`pdfGroupTitleRow()`, `pdfColumnWidths()`, `pdfCheckWidth()`,
|
||
`pdfAttendanceBlock()`, `pdfLoadSection()`, and the color constants
|
||
`PDF_ROLE_BG`/`PDF_ALT_ROW_BG`/`PDF_PAD_X`/`PDF_LINE_W`) — everything the
|
||
remaining two exports (`exportWochenberichtPDF()`/`exportSandboxPlanPDF()`)
|
||
still share (`PDF_STYLES`, `PDF_NEUTRAL_BG`, page geometry,
|
||
`pdfStatusText()`, `pdfNormalizeColor()`, `loadLogoPng()`) stayed. This was
|
||
the one export that showed every project across every Gewerk in a single
|
||
file — that capability is gone with it; the per-Gewerk Wochenbericht PDFs
|
||
are now the only filed record. Werkzeuge is down to two items:
|
||
`+ Neues Projekt`, `Wandtafel` (the former `Show Mode` entry — see
|
||
"Wandtafel" below).
|
||
|
||
### Admin (tab `admin`, `js/admin/admin.js`)
|
||
|
||
- Everything only admins may do, in one place. Split into **sections, one
|
||
visible at a time**, switched from the sidebar: "Admin" is an accordion
|
||
(`#adminAccordionToggle`/`#adminSubnav`) whose sub-items call
|
||
`showAdminSection()`. `ADMIN_SECTIONS` is the single place a section is
|
||
defined (key, heading); the binding to the markup runs through the
|
||
`data-admin-section` attribute on both the card in `App.html` and the
|
||
sidebar item. The chosen section lives in the URL fragment
|
||
(`/Admin#auslastung`) — same pattern as `.plan-nav` in the Projektbereich —
|
||
which is why `updateRouteUrl()` keeps `location.hash` for `admin`.
|
||
- Two deviations from the "Werkzeuge" accordion, both deliberate: its head
|
||
entry also *navigates* (the sub-items are in-page navigation, not actions,
|
||
so a head that shows nothing would mislead) — from elsewhere a click always
|
||
enters the area and expands, only from inside does it toggle; and its
|
||
sub-items carry a persistent `.is-active` highlight.
|
||
`syncAdminNavState()` in `js/tabs-auth.js` marks the head active while the
|
||
area is open, because it is no longer a `.tab` button and the generic
|
||
highlight does not reach it.
|
||
- `showTab('admin')` redirects non-admins to `DEFAULT_TAB`: without it a
|
||
forwarded `/Admin` link showed a member an empty page, since
|
||
`renderAdminArea()` renders nothing for them.
|
||
- Sections:
|
||
- **Benutzerverwaltung** — accounts, roles, weekly hours, absences
|
||
(`js/admin-users.js`, `renderAdminUsers()`); reloaded from the server
|
||
every time the section is entered, since the list changes outside this
|
||
browser and the other masks compute with it (weekly hours → Auslastung).
|
||
Marked `wide: true` in `ADMIN_SECTIONS`, which lifts the grid's width cap
|
||
— the table has ten columns.
|
||
- **Honorar** — `stundensatz` + the per-LPH shares (`PUT /honorar-config`).
|
||
A running sum turns amber unless the shares add up to 100 %.
|
||
- **Auslastung** — `vollzeitStundenProWoche`, the ampel thresholds,
|
||
`referenzHonorar`, `verteilzeitraumWochen`, and `aufwandVorlage` (the per-LPH/role weighting that applies to
|
||
every project without its own matrix — i.e. most of them, which is why it
|
||
quietly determined a large part of the Auslastung while it was a code
|
||
constant).
|
||
- **Termine & Sitzungen** — `arbeitsstundenProTag` and `autoLockAktiv`.
|
||
Switching auto-lock off does **not** retroactively unlock: already-locked
|
||
weeks stay locked, unlocking remains a deliberate admin action
|
||
(`POST /unlock-week`).
|
||
**Also holds the "Sitzungen" list** (`renderAdminSitzungen()`,
|
||
`js/admin/admin.js`) — every week with its lock state and, for anything
|
||
that isn't the one open week, an action to reach it. This replaced the
|
||
week `<select>` that used to sit in the topbar (`#appTopbarWeek`,
|
||
removed): sessions are storage and history now, not something the whole
|
||
UI asks you to pick from. In normal operation there's exactly one row
|
||
without an action (the open week, `db.currentWeek`, always the
|
||
chronologically latest under `applyAutoLock()`); everything else is
|
||
**Gesperrt** (`Entsperren` → `unlockWeek()`) or **Entsperrt**
|
||
(`Öffnen` → `switchWeek()` + jump to Board — the one place left that can
|
||
put a week other than the latest into `db.currentWeek`). Deleting a
|
||
session also moved here (`Löschen` → `deleteWeekById()`) from its old
|
||
home in *Datenbank & Sitzungen*, which depended on "the week chosen in
|
||
the topbar" and had no other way to name a target once that selector was
|
||
gone.
|
||
**The unlock window is shorter than it looks**: `unlockWeek()` only
|
||
flips that one week's flag, but `applyAutoLock()` re-locks every week
|
||
except the latest, and runs again on `deleteWeekById()` — not just on
|
||
page reload. Deleting an unrelated session while a correction is in
|
||
progress silently re-locks it before the fix is saved. Since the
|
||
automatic Sitzungswechsel (see "Automatischer Sitzungswechsel" above),
|
||
there is a second, independent trigger for the same outcome:
|
||
`ensureCurrentWeekRollover()` re-locks every other real week itself,
|
||
server-side, the moment the calendar rolls into a new ISO week — so an
|
||
in-progress correction on an older week can now also get silently
|
||
re-locked purely by time passing, with nobody clicking anything at all.
|
||
Both respect `app_settings.autoLockAktiv`. Surfacing "Entsperrt" as its
|
||
own row state only made this pre-existing risk visible, not new.
|
||
- **Feiertage** — the `holidays` table, plus a "gesetzliche Feiertage
|
||
übernehmen" mask that computes a year's public holidays for a chosen state
|
||
and lets the admin take them over (see `/holidays/suggest` above). It
|
||
flags when the current or next year has no day stored at all — otherwise
|
||
missing holidays only surface via a wrongly computed schedule.
|
||
- **Aufgaben-Vorlagen** — 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 stored `expires_at`.
|
||
- **Datenbank & Sitzungen** — DB Backup, DB Import, Sitzung löschen. These
|
||
three used to be bare sidebar menu entries where a mis-click was enough;
|
||
they now sit behind an explanation of what each one does.
|
||
- **Honorar and Aufgaben-Vorlagen were moved out of project dialogs.** They
|
||
used to sit inside `#honorarPlanModal` (`js/plan/plan-honorar.js`) and
|
||
`#lphTemplateModal` (`js/plan/plan-templates.js`) — findable only by someone
|
||
who happened to be computing a schedule. Those dialogs now only *use* the
|
||
values and show admins a link here (`renderHonorarHint()`/
|
||
`renderTemplateHint()`). Since `plan-honorar.js` caches the config for the
|
||
whole page lifetime, `saveHonorarConfig()` calls
|
||
`invalidateHonorarConfig()` afterwards — otherwise a dialog opened later in
|
||
the same session would keep computing with the old rate.
|
||
- **Honorar, Auslastung, Termine and Betrieb used to be constants in the
|
||
source** — partly in the frontend (`LP_AUFWAND_VORLAGE`,
|
||
`THRESHOLD_RATIO_*`, `LOAD_BAR_MAX`, `STUNDEN_PRO_TAG`), partly in the
|
||
server (`MAX_BACKUPS`, `SESSION_MAX_AGE_MS`, `LOGIN_MAX_ATTEMPTS`,
|
||
`LOGIN_BLOCK_MS`). They moved here not because they change often but because
|
||
they shift the numbers *everyone* sees, which makes maintaining them an
|
||
administration task rather than a source edit. All of those identifiers are
|
||
gone; the client reads the values from `APP_SETTINGS` (`js/globals.js`),
|
||
filled by `loadAppSettings()` in `js/store.js` **before** `/load-all`, since
|
||
`renderAll()` already computes with them. If that fetch fails the defaults
|
||
in `js/globals.js` stand — deliberately identical to `APP_SETTING_DEFS`, so
|
||
the app keeps computing as it did instead of showing wrong numbers.
|
||
- **Part-time capacity.** `users.capacity_hours` (maintained per account in
|
||
`admin-users.html`) is what the Auslastung now measures a person against:
|
||
`kapazitaetsFaktor()` in `js/load/load.js` divides the raw weekly load by
|
||
`capacity_hours / vollzeitStundenProWoche`, so a half position counts as
|
||
fully loaded at half the work. `0` = not maintained = full position, so
|
||
existing accounts keep their previous picture. **`kapazitaetsFaktor()`
|
||
takes the person OBJECT, not just its id**: a week's participants are
|
||
frozen copies (`week_people`) whose id dates from when they were created —
|
||
legacy data has `p_ap` where the account now carries `p_alexander_plum`, so
|
||
the id alone would never find them and the full name is the second bridge.
|
||
`js/pdf.js` applies the same factor, otherwise screen and PDF would report
|
||
different numbers for the same part-timer.
|
||
- **"+ Neues Projekt" is no longer an admin entry** — it moved to
|
||
"Werkzeuge". Creating a project goes through the ordinary week save
|
||
(`POST /save-week`, `requireAuth`); the server never required the admin
|
||
role for it, so the gate was purely cosmetic and merely kept members from
|
||
ordinary work.
|
||
- The area being hidden for non-admins is a **UI gate only**, like everywhere
|
||
else in this app; the real barrier is `requireRole('admin')` on
|
||
`PUT /app-settings`, `PUT /honorar-config`, `POST`/`DELETE /lph-templates`,
|
||
`POST`/`DELETE /holidays`, `GET /backup`, `POST /import` and
|
||
`POST /delete-week` (verified: a member account gets 403 on each and the
|
||
stored values stay unchanged).
|
||
|
||
### Wochenbericht — die Tagesordnung
|
||
|
||
- The Wochenbericht is laid out as the **agenda of the weekly team meeting**:
|
||
the full project list on the left, the selected project full-size on the
|
||
right. It replaced a stack of 29 open cards that ran to 18 screens (11.6
|
||
after the cards were compressed) — **now 1.1**. The list stays put, so you
|
||
always see where you are and what is still to come.
|
||
- **Ordered by project group** (`GROUP_ORDER`, like the project list), no
|
||
longer by kind of change: every project gets discussed whether it changed or
|
||
not, and sorting by change would force jumping between sections. What
|
||
changed is still computed (`classifyProjectChange()`) and shown — as a dot
|
||
in the list and a label in the detail, not as a sort key.
|
||
- `berichtAuswahl` holds the **`project_uid`**, not an index: saving a comment
|
||
re-renders the list, and an index would point at a different project if the
|
||
order changed.
|
||
- `renderBerichtListe()` is separate from `renderWochenbericht()` because the
|
||
jump field redraws only the list — the detail, and above all a comment being
|
||
typed, must survive.
|
||
- The eight functions, all built in:
|
||
- **Besprochen-Haken** + "12 von 29 besprochen" (`projects.besprochen`,
|
||
saved through the ordinary `save()` path).
|
||
- **Besprochen & weiter** — ticks off and jumps to the next open item,
|
||
searching behind the current position first, then from the top.
|
||
- **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 is `exportSandboxPlanPDF()` for
|
||
Planung-Terminpläne; the third, the board's own gewerkeübergreifende A3
|
||
session-overview `exportPDF()`, was removed — see "Werkzeuge" above). The
|
||
Wochenbericht is itself two-part, A4 portrait: **page 1** is a lined overview
|
||
table per group (change dot, Nr., Name, Änderungsgrund, amtierende LPH +
|
||
Fortschritt, Besprochen-Haken — `pdfWbUebersichtBlock()`) followed by the
|
||
**Anwesenheit der Sitzung** as its own gridded table
|
||
(`pdfWbAnwesenheitBlock()`, reading `w.attendance`/`w.people` exactly like
|
||
the on-screen `renderAttendance()`: a missing entry counts as not present,
|
||
same as an explicit `false` — there is no third "not yet recorded"
|
||
state, so the count is literal even when nobody has been marked yet).
|
||
**Page 2 onward** ("Vollständiger Bericht") is one bordered Akte per
|
||
project with a left spine colored by `classifyProjectChange()`'s bucket
|
||
(`PDF_WB_CHANGE_COLORS`: new/status/progress/unchanged), two columns
|
||
inside — comment on the left via `pdfStatusText()` (same bold/color
|
||
fidelity as the screen), a gridded Leistungsphasen-table and
|
||
Beteiligte-table on the right (`pdfWbLphMiniTable()`/
|
||
`pdfWbBeteiligteTable()`, falling back to all `LPHS` when none are
|
||
beauftragt — same rule as `renderBoardDrawer()`), plus who's assigned but
|
||
marked absent (`abwesendeBeteiligte()`). The amtierende-LPH value is
|
||
computed once (`pdfWbAktuelleLphInfo()`, same `lpActiveList()` the
|
||
Auslastung uses) and read by both the overview row and, indirectly, the
|
||
Akte — so the two pages can never show conflicting figures for the same
|
||
project. Reuses `PDF_STYLES`/`pdfStatusText()`/`loadLogoPng()` from the
|
||
existing module. `pdfHslToHex()` is the one color helper: it lets the
|
||
browser itself convert `getProgressColor()`'s `hsl()` output (pdfmake
|
||
doesn't understand `hsl()`) into the hex `pdfNormalizeColor()` already
|
||
handles, so the bar in the PDF is the exact same color as on screen
|
||
instead of a second, invented scale. The overview/attendance/mini-tables
|
||
all share one grid layout (`pdfWbGridLayout()`) so the whole document
|
||
reads as tables throughout rather than mixing ruled and unruled blocks.
|
||
- **Sprung-Feld** — filters the list only while something is typed; the
|
||
agenda must stay complete.
|
||
- **Abwesenheits-Hinweis** — 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.
|
||
- Two bugs worth remembering, both found by measuring, not by looking:
|
||
- The dense-card rules were scoped to `.report-card`, a container this
|
||
rewrite removed — the card silently fell back to the Board layout
|
||
(Leistungsphasen missing, people stacked). They now hang off
|
||
`.board-drawer-dicht` itself.
|
||
- The state class was literally `status`, which matched the unrelated
|
||
`.status { min-width: 240px }` — the 7 px dot became 240 px wide and
|
||
squeezed the project name to zero. State classes carry an `is-` prefix now.
|
||
- The dense card is **two columns again** (comment | phases + people). It had
|
||
three while 29 cards stood stacked and height mattered; in the agenda only
|
||
one project is ever open, so height is free while width is scarce — beside
|
||
the list, three columns left the people 137 px.
|
||
|
||
### Wochenbericht — Gewerke-Trennung
|
||
|
||
Since departments stopped partitioning the data (see "Departments were
|
||
retired" above), every project from every discipline sat in the same, single
|
||
Tagesordnung — but ELT, HLS and ARCH hold their weekly team meeting
|
||
separately, at different times, and each meeting only needs its own trade's
|
||
projects, not the other two's. Completeness ("every project gets discussed")
|
||
now applies **within the chosen Gewerk**, not across all three at once; the
|
||
one place that still shows every project regardless of Gewerk is the
|
||
unfiltered "Projekte" board (`renderBoard()`) — untouched by this feature.
|
||
|
||
- **`wochenberichtGewerk`** (`js/wochenbericht/wochenbericht.js`) is a
|
||
module-level `let`, `null` until resolved. Deliberately **not** persisted to
|
||
`localStorage` — it resets on every page load/login, so a stale choice from
|
||
a different meeting can never carry over silently.
|
||
- **Defaults from the account's own Gewerk, no dialog needed for the normal
|
||
case**: `wochenberichtGewerkVoreinstellen()` runs at the top of
|
||
`renderWochenbericht()` and, once per page load, sets
|
||
`wochenberichtGewerk` to `currentUser.department` (added to `req.user` in
|
||
`attachUser()`, `server.js`, specifically for this) if that's a valid
|
||
Gewerk — most people only ever need their own trade's meeting, so this
|
||
alone gets them straight to a filtered Tagesordnung with no extra step.
|
||
Only an account without a maintained `department` (e.g. the bootstrap admin)
|
||
falls through with `wochenberichtGewerk` still `null`.
|
||
- **Changing it is a plain `<select>`, not a full-screen step**
|
||
(`renderGewerkDropdown()`, rendered both in the normal header via
|
||
`renderBerichtKopf()` and, as a bare prompt, when no default could be
|
||
resolved) — switching trades mid-session is a quick correction, not its own
|
||
workflow, so it deliberately does NOT interrupt the page the way the first
|
||
version of this feature (a blocking `#gewerkWizardModal`) did; that modal
|
||
and `oeffneGewerkWizard()` are gone. Picking an option calls
|
||
`waehleWochenberichtGewerk(g)` directly (`onchange`), which re-renders.
|
||
- **The filter lives in ONE place**, `berichtProjekte(w)` itself: when
|
||
`wochenberichtGewerk` is set, it returns only projects whose `p.gewerke`
|
||
(from `project_gewerke`, populated by the "Neues Projekt" wizard's Gewerke
|
||
step, `js/projekt-wizard.js`) includes it. Because `renderWochenbericht()`,
|
||
`renderBerichtListe()`, `aktuellesBerichtProjekt()`, `openBerichtProtokoll()`
|
||
(the Protokoll text export) AND `exportWochenberichtPDF()` (`js/pdf.js`)
|
||
all call this same function, the filter applies everywhere at once with no
|
||
separate plumbing — screen, Protokoll and PDF can never disagree about
|
||
which projects belong to the current Gewerke-Besprechung. The PDF's header,
|
||
footer and filename additionally spell out the Gewerk
|
||
(`Wochenbericht_ELT_KW_33___2026.pdf`); `exportWochenberichtPDF()` refuses
|
||
with a message if called with no Gewerk chosen (only reachable for an
|
||
account with no maintained `department`, since everyone else gets one from
|
||
`wochenberichtGewerkVoreinstellen()` — the guard costs nothing either way).
|
||
- **Attendance is unaffected** — `w.attendance`/`w.people` are session-wide,
|
||
not per-Gewerk, so `pdfWbAnwesenheitBlock()`/`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 zero
|
||
`project_gewerke` rows — gets its Gewerk copied from whichever
|
||
`ELT`/`HLS`/`ARCH` entry already sits in its `project_tags` (the marker
|
||
`migrateRemoveDepartmentPartition()` left behind when it merged the former
|
||
per-department copies). Without this, every pre-existing project would
|
||
silently vanish from every Gewerke-filtered Tagesordnung the first time
|
||
this feature shipped. Idempotent and non-destructive: it only ever inserts
|
||
for a project with **zero** `project_gewerke` rows, so a manual correction
|
||
in Projekteinstellungen (see next bullet) is never overwritten by a later
|
||
server restart.
|
||
- **Gewerke are now correctable outside the wizard**: Projekteinstellungen
|
||
(`renderPlanSettings()`, `js/plan/plan-settings.js`) gained a "Gewerke"
|
||
checkbox row next to Schlagwörter, saved through `togglePlanGewerk()` —
|
||
the exact same mutate-`p.field`-then-`savePlan()` pattern as
|
||
`toggleBeauftragteLp()` (`js/plan/plan-honorar.js`). Before this, a
|
||
project's Gewerke could only ever be set once, during creation.
|
||
|
||
### 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 former `BERICHT_OPTS.timeline` flag is gone, since the history
|
||
now hangs off every card rather than only the Wochenbericht's.
|
||
|
||
### Dashboard ("Meine Aufgaben", now the default landing tab)
|
||
|
||
**Aufbau: Kopf — Band — zwei Zonen.** Die Anordnung folgt der Frage, die beim
|
||
Blick aufs Dashboard zuerst ansteht: „Was muss ich heute anpacken?"
|
||
|
||
1. **Kopf** (`renderDashboardKopf()`) — Begrüßung und Datum links, Sitzung und
|
||
Uhr klein rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm;
|
||
sie ist die Angabe, nach der man am wenigsten handelt.
|
||
2. **Band** (`renderDashboardBand()`) — der Befund des Tages als **Satz**
|
||
(„8 Termine sind überfällig, 1 wird heute fällig"), daneben vier
|
||
Kennzahlen. Zuvor standen hier vier gleich große Kacheln, von denen meist
|
||
drei eine Null zeigten — die eine Zahl, auf die es ankommt, bekam dadurch
|
||
kein Gewicht. Ist nichts überfällig und nichts heute fällig, wird das Band
|
||
farblos (`.ist-ruhig`): Eine Null in Alarmfarbe behauptet ein Problem, das
|
||
es nicht gibt. Die Zahl im Satz ist `1.15em` groß — ab 19 px fett gilt für
|
||
den Kontrast die Schwelle für große Schrift, die Ampelfarbe erreicht auf
|
||
der abgesetzten Bandfläche sonst nur 4,1:1 im dunklen Thema.
|
||
3. **Linke Zone** — der eigene Arbeitsstrom: „Meine Aufgaben" und „Termine
|
||
meiner Projekte", beide in derselben `.dash-karte`.
|
||
4. **Rechte Zone** (300 px) — was man wissen muss, ohne selbst tätig zu
|
||
werden: ausstehende Freigaben und Abwesenheiten, beide in derselben
|
||
schmalen Zeilenform (`.dash-rand-zeile`). Zuvor stand der Urlaub eines
|
||
Kollegen gleichrangig unter den eigenen offenen Aufgaben. Unter 1080 px
|
||
fällt das Raster auf eine Spalte zurück.
|
||
|
||
**Aufgaben sind Zeilen, keine Karten** (`renderDashboardAufgabe()`): Eine Karte
|
||
belegte eine halbe Bildschirmbreite für vier Zeilen Inhalt. Dieselben Angaben —
|
||
Titel, Projekt, Fortschritt, Fälligkeit, Schlagwörter — stehen jetzt in einer
|
||
Zeile. Entfallen sind damit `renderStatTile()`, `renderDashboardTaskCard()`,
|
||
`renderDashboardReviewCard()`, `renderDashboardGreeting()` und die CSS-Klassen
|
||
`.dashboard-greeting`/`.dashboard-clock`/`.dashboard-absence-*`/
|
||
`.absence-chip-live`.
|
||
|
||
**Zwei Listen, die nicht dasselbe sind** — das ist der Kern dieses Bereichs:
|
||
|
||
- **Meine Aufgaben** (`tasks`): die Aufgaben, die der Person PERSÖNLICH
|
||
zugewiesen sind (`plan_todo_assignees.person_id`), auch in Projekten, mit
|
||
denen sie sonst nichts zu tun hat. Als Karten.
|
||
- **Termine meiner Projekte** (`projectDates`): die Aufgaben UND Meilensteine
|
||
der PROJEKTE, denen die Person zugeordnet ist (`project_assign`, state 1
|
||
oder 2) — unabhängig davon, wer die einzelne Aufgabe erledigt. Eine
|
||
Projektleitung muss wissen, wann im eigenen Projekt etwas fällig wird, auch
|
||
wenn ein anderer es abarbeitet. Als Liste, nach Dringlichkeit gegliedert
|
||
(Überfällig / Diese Woche / Danach).
|
||
|
||
Beides zusammen beantwortet die Frage, die sonst nur durch Öffnen von dreißig
|
||
Terminplänen zu beantworten war. Details:
|
||
|
||
- **Keine Doppelung:** Was bereits unter `tasks` steht, wird aus
|
||
`projectDates` herausgefiltert (`selbst`-Kennzeichen, serverseitig).
|
||
- **Zeitfenster 21 Tage** nach vorn; überfällige Termine bleiben dagegen ohne
|
||
Altersgrenze stehen — eine Frist verfällt nicht dadurch, dass sie lange
|
||
gerissen ist. Ohne die Grenze nach vorn stünden Termine aus einem Jahr in
|
||
der Liste und die dringenden gingen darin unter.
|
||
- **Meilensteine haben kein Erledigt-Kennzeichen** (`plan_milestones` hat
|
||
keine `done`-Spalte), deshalb zählt bei ihnen allein das Datum; bei Aufgaben
|
||
gilt zusätzlich `done = 0` und ein gesetztes Enddatum.
|
||
- **Die Namen der Zuständigen kommen aus `week_people`**, nicht aus
|
||
`loadGlobalPeople()`: Die globale Liste enthält nur Personen mit
|
||
Benutzerkonto, die Teilnehmerliste einer Sitzung dagegen jede Person, die
|
||
dort mitarbeitet. Ohne diesen Weg stand in der Liste `p_ap` statt
|
||
„Alexander Plum".
|
||
- **Die Kachel „Überfällig"** zählt beide Listen zusammen; damit die Summe
|
||
nachvollziehbar bleibt, kennzeichnet auch die Aufgabenkarte ein überfälliges
|
||
Fälligkeitsdatum (`b.ist-ueberfaellig`). Eine Null bleibt neutral statt rot —
|
||
„0 überfällig" ist eine gute Nachricht.
|
||
- Eine Zeile führt in den Terminplan des Projekts (`…#aufgaben`) — dorthin,
|
||
wo sich der Termin ändern lässt.
|
||
- **Datumsvergleiche laufen über `heuteIso()`/`isoPlusTage()` (js/utils.js)
|
||
bzw. `heuteIsoLokal()`/`isoPlusTageLokal()` (server.js), nicht über
|
||
`new Date().toISOString().slice(0,10)`.** Letzteres rechnet nach UTC um: In
|
||
deutscher Sommerzeit (UTC+2) ist damit zwischen 00:00 und 02:00 Ortszeit
|
||
noch der Vortag gemeint — Termine von heute galten in diesem Fenster als
|
||
überfällig, und eine heute beginnende Abwesenheit fiel aus der Liste. Die
|
||
Feiertagsberechnung (`holidaysForYear()`) rechnet dagegen bewusst
|
||
durchgehend in UTC (`Date.UTC`, `isoFromDate()`) und ist davon nicht
|
||
betroffen. Serverseitig setzt die Korrektur voraus, dass der Server auf die
|
||
Zeitzone des Büros gestellt ist — im Docker-Container also
|
||
`TZ=Europe/Berlin`.
|
||
|
||
|
||
- `GET /my-dashboard` (Auth) is the sole data source for this tab
|
||
(`js/dashboard/dashboard.js`, slug `MeineAufgaben`, first item in the
|
||
sidebar nav and `DEFAULT_TAB` in `js/globals.js` — the app lands here right
|
||
after login, not on the Projektboard). It aggregates, per
|
||
department (`ELT`/`HLS`/`ARCH`), the caller's open TODOs from that
|
||
department's currently unlocked week — normally there's exactly one
|
||
(`applyAutoLock()` invariant); if that's ever violated (0 or >1 unlocked
|
||
weeks) the server falls back to the most recent week via
|
||
`weekSortKeyServer()`, a server-side port of `weekSortKey()`
|
||
(`public/js/utils.js`). Tasks are matched by `plan_todo_assignees.person_id
|
||
= req.user.personId` and sorted by due date (empty dates last). The
|
||
response also includes `pendingReviews` (files awaiting the caller's
|
||
review decision), populated by the file-upload feature described above.
|
||
|
||
### Wandtafel (tab `wand`, `js/wand/wand.js`)
|
||
|
||
A tile dashboard meant for a dedicated, permanently-running touch monitor in
|
||
the office — gewerke- and user-wide, the opposite scope of the Dashboard
|
||
above (which is deliberately personal, "Meine Aufgaben"). **Replaces Show
|
||
Mode outright** (`js/showmode.js`, the whole `.show-mode-*` CSS block, and
|
||
the sidebar's old "Show Mode" entry are gone, not just unlinked). Show Mode
|
||
only ever cloned whatever the ONE logged-in session's `#board`/`#load` tab
|
||
happened to be rendering at the time (`copyCurrentViewToShowMode()` did a
|
||
literal `innerHTML` copy, no independent data path) — it pre-dates both the
|
||
department-retirement and the Dashboard, and never aggregated anything
|
||
across Gewerke or users. Erik's own words: "macht so keinen Sinn mehr."
|
||
|
||
- **No new server endpoint.** Every tile is a client-side projection of the
|
||
same `db` state every other tab already has after `GET /load-all` —
|
||
deliberately, to avoid holding `berechneAuslastung()`'s business logic in
|
||
two places (server SQL and client JS) for the sake of one dashboard. The
|
||
background refresh (`wandRefreshTick()`, every 60 s) checks `GET /whoami`
|
||
first, then calls the ordinary `load(true)` — the `true` is a new, optional
|
||
`silent` parameter on `load()` (`js/store.js`): on failure it only
|
||
`console.error()`s and leaves the last-known-good data on screen, instead
|
||
of `load()`'s normal blocking `alert()` + data-wipe, which would otherwise
|
||
stack unusable alert dialogs on an unattended touch screen every minute.
|
||
- **Dedicated `kiosk` role** (`users.role`, alongside `admin`/`member` —
|
||
`VALID_ROLES` in `server.js` is now the one place that set is defined).
|
||
First built on a plain `member` account, changed on Erik's explicit
|
||
follow-up request for a real, separate role rather than relying on an
|
||
ordinary account's full write access. A `kiosk` account logs into the
|
||
kiosk browser once and is pointed at `/Wandtafel` directly (OS-level kiosk
|
||
browser config, e.g. a `--kiosk` flag — outside this codebase); its
|
||
session lives the normal, app-wide `sessionTage` (default 30 days,
|
||
unchanged) like any other account — no separate session-lifetime
|
||
mechanism was added. If the session expires, `wandRefreshTick()` calls the
|
||
existing `showLoginOverlay()` (`js/auth.js`) in place — the login form was
|
||
already wired once at page load by `checkAuthAndInit()`, so re-entering
|
||
the password resumes the Wandtafel via the same `afterAuthenticated()` →
|
||
`initUiState()` → `load()` path every normal page load already uses,
|
||
without a full reload (`initUiState()` re-derives `currentTab` from the
|
||
still-unchanged `/Wandtafel` URL).
|
||
- **Enforced server-side, not just a UI gate** (unlike most role
|
||
distinctions in this app, which are only hidden in the UI while the
|
||
server accepts any logged-in account — see "Auth / security quirk"):
|
||
`requireAuth` (`server.js`) now rejects `role === 'kiosk'` with 403
|
||
outright, on every route that used to accept any authenticated account.
|
||
A new, narrower `requireAuthOderKiosk` replaces plain `requireAuth` on
|
||
exactly the four read-only routes the Wandtafel's own `load()` call
|
||
needs — `GET /load-all`, `/app-settings`, `/holidays`,
|
||
`/honorar-config` — plus `POST /logout` (a kiosk account can still end
|
||
its own session, even though the UI has no reachable button for it once
|
||
the topbar is hidden). Every write route, `GET /users`, `GET
|
||
/my-dashboard`, and everything admin-only stays on plain `requireAuth`/
|
||
`requireRole('admin')` and therefore 403s a kiosk account exactly like
|
||
an anonymous visitor would get 401'd.
|
||
- **No Fachrolle, enforced in both directions.** `POST /admin/users` and
|
||
`PATCH /admin/users/:id` reject a `kiosk` role combined with a non-empty
|
||
discipline-`roles` array (400, "Ein Kiosk-Konto kann keine Fachrolle
|
||
haben.") — checked against the EFFECTIVE role and roles a request would
|
||
produce, not just the fields present in that one request body: setting
|
||
`role: 'kiosk'` on an account that already carries discipline roles in
|
||
the database is rejected exactly like sending `roles` alongside `role:
|
||
'kiosk'` in the same request, by reading the existing `user_roles` rows
|
||
when `roles` itself is absent from the PATCH body. The reverse direction
|
||
needed its own fix: the pre-existing self-demotion guard only checked
|
||
`role === 'admin' && newRole === 'member'`, so an admin could otherwise
|
||
have stepped themselves down to `kiosk` (a stricter demotion than to
|
||
`member`) through a gap the original check never anticipated — it now
|
||
checks `newRole !== 'admin'` generically. Client-side,
|
||
`saveNewUser()`'s existing "at least one Fachrolle required" check
|
||
(`admin-users.js`) is skipped when the selected role is `kiosk` (the
|
||
whole reason that check exists — `peopleByRole()` makes a roleless
|
||
account invisible everywhere — is exactly the outcome wanted here), and
|
||
selecting "Kiosk" in either user form disables and unchecks the
|
||
Fachrolle boxes live (`onUserRoleChange()`), so the 400 is never
|
||
actually hit in normal use — it exists as the real enforcement, the
|
||
client behavior is just not making the user discover it the hard way.
|
||
- **`GET /users` excludes `role = 'kiosk'`** — this endpoint (reviewer
|
||
picker, cross-department staffing) isn't Fachrolle-scoped the way
|
||
`peopleByRole()` is, so a kiosk account without discipline roles would
|
||
otherwise still turn up there as a selectable reviewer or team member,
|
||
where it could never actually act (every write route it would need
|
||
already 403s it) — just a dead end for whoever picked it, closed before
|
||
it could confuse anyone.
|
||
- **Seitenleiste und Kopfzeile blenden sich aus** while on this tab
|
||
(`body.wandtafel-aktiv`, toggled in `showTab()`, hides `.app-topbar`/
|
||
`.side-menu`/`.side-menu-backdrop`). Written before the dedicated `kiosk`
|
||
role existed, when the account behind the display was an ordinary
|
||
`member` with real write access everywhere else — the hiding was a
|
||
security measure then, not just tidiness. The `kiosk` role closed that
|
||
hole server-side, but the hiding stayed: every other tab would now just
|
||
403 on its own data fetch for a kiosk account, and a sidebar full of
|
||
buttons that each lead to a broken-looking error view is a worse
|
||
experience than no sidebar at all. A small, deliberately unobtrusive
|
||
"← Zur Übersicht" link inside the panel (`App.html`) is the only way
|
||
back — meant for a staff member previewing the tab via their own,
|
||
non-kiosk account, not for the kiosk account itself, which has no reason
|
||
to ever leave it (and would just hit 403s on whatever tab it landed on).
|
||
- **Redesigned again, on-screen label now "Dashboard".** After the
|
||
two-column layout below had been live for a while, Erik asked for a round
|
||
of completely fresh full-board concepts (not incremental tweaks to the
|
||
existing look). From a 3-way comparison of genuinely different structures
|
||
(a "Leitstand"/control-room instrument panel, a size-varied card mosaic, an
|
||
auto-rotating single-focus carousel) he picked the Leitstand's
|
||
**structure** — not its monochrome/terminal skin. A second round then
|
||
explored skins of that SAME structure (Erik: "es muss nicht retro
|
||
aussehen... es darf auch vom grundlegenden Design abgewichen werden, es
|
||
soll nur die selbe Struktur sein"), landing on "Pastell Freundlich",
|
||
finally recolored into the two blues of the company logo (sampled directly
|
||
from `public/logo.ico`: `#94b0cc` light, `#116896` dark) instead of a
|
||
generic pastel mix or the app's own `--accent`. The **current** structure,
|
||
replacing the two-column one described below:
|
||
- **Kopf** (`wandDashKopfHtml()`) — the marke now reads **"Dashboard"**
|
||
(Erik: "ersetze den Schriftzug Wandtafel durch Dashboard" — a label
|
||
invented for this screen's own header, not a rename of the tab/route/
|
||
sidebar entry, which all stay "Wandtafel" throughout this file and the
|
||
code), the current session label + a live clock, and — moved out of the
|
||
old "Heute" tile — a small antippable avatar cluster for who's absent
|
||
today (`wandZeigeUebersicht('abwesend')`, unchanged target).
|
||
- **Kennzahlen-Reihe** (`wandKpiReiheHtml()`) — four tiles: Auslastung
|
||
gesamt and Besprochen gesamt (`wandGesamtKennzahlen()`, new — the SAME
|
||
per-Gewerk sums from `wandAuslastungJeGewerk()`/`wandBesprochenDaten()`
|
||
simply totalled across all three Gewerke rather than broken out, so the
|
||
deliberate multi-Gewerke double-count applies here too automatically),
|
||
plus offene Aufgaben/überfällig (unchanged counts from
|
||
`wandHeuteDaten()`, moved out of the old "Heute" tile and still
|
||
individually clickable to the same two overviews).
|
||
- **Panel-Reihe** (`.dash-grid`, three panels framed by the shared
|
||
`wandPanel()`) — Auslastung je Gewerk and Besprochen je Gewerk keep
|
||
their exact previous per-row content and click targets
|
||
(`wandAuslastungInhalt()`/`wandBesprochenInhalt()`, simply the old tile
|
||
functions with the `wandKachel()` wrapper stripped off); the third
|
||
panel, "Aktuell" (`wandAktuellInhalt()`), reverts the feed from the
|
||
three-column by-type grouping (see further below, now superseded) back
|
||
to one flat list sorted by closeness to today, capped at `WAND_FEED_MAX`
|
||
(6) — a colored dot per entry (`.dash-feed-punkt`) still marks
|
||
Neu/Überfällig/Termin, just without the column headers/counts that
|
||
needed more width than a third of this narrower row affords. Every
|
||
entry stays individually clickable into its project, unchanged.
|
||
- The **Übersicht drill-downs and the full project list below are
|
||
unchanged in content and behavior** — only restyled with the same
|
||
`--dash-*` tokens so the whole screen reads as one identity rather than
|
||
a themed top half bolted onto a generically-styled bottom half. The
|
||
project list specifically was deliberately left on the app's normal
|
||
`.board-row` styling, not reskinned — see its own bullet further below,
|
||
"Ein Ort je Angabe"-adjacent reasoning that still applies unchanged.
|
||
- **New CSS custom properties, `--dash-*`, declared on
|
||
`body.wandtafel-aktiv`** (styles.css) with the usual three-way light/
|
||
dark pairing (bare block, `@media (prefers-color-scheme:dark)
|
||
html:not([data-theme="light"])`, `html[data-theme="dark"]`) — kept
|
||
deliberately separate from the app-wide `--accent`/`--surface` tokens,
|
||
since this one screen now intentionally carries its own brand identity
|
||
rather than the generic app chrome. `--dash-hover` exists only because a
|
||
plain semi-transparent white overlay (the obvious reach for "lighten on
|
||
hover") looks wrong once panels are already dark in dark mode — it's
|
||
tuned per theme instead of hardcoded once.
|
||
- **First web font this app has ever loaded**: `Fredoka` (numerals/marke)
|
||
and `Nunito Sans` (body), both via a Google Fonts `<link>` in
|
||
`App.html`, scoped in a comment to "only the Wandtafel". Breaks, for
|
||
this one screen, the otherwise all-system-font convention — accepted
|
||
because the approved design specifically depended on Fredoka's
|
||
character, and because the `font-family` fallback stack degrades
|
||
silently (system sans) rather than breaking anything if the kiosk has no
|
||
internet access, the same acceptable-offline-degradation precedent
|
||
pdfmake's CDN script already set for PDF export.
|
||
- **`renderWandStand()` ("Stand: HH:MM Uhr") is gone** — the header's own
|
||
live clock next to the session label now serves the same "hasn't frozen"
|
||
purpose, so a second, separate timestamp at the old feed column's foot
|
||
(which no longer exists as a column) would have been redundant.
|
||
- **Known documentation debt**: the "Two columns, version 2" bullet and
|
||
its sub-bullets immediately below describe the PREVIOUS structure this
|
||
redesign replaced. The underlying per-element click-target
|
||
philosophy, the `wandUebersicht` drill-down mechanism, and the specific
|
||
overview targets they describe are all still accurate and unchanged —
|
||
only the surrounding layout (two columns of tiles vs. the current
|
||
kopf+KPI-reihe+panel-reihe) and some renamed functions
|
||
(`wandTileAuslastung`→`wandAuslastungInhalt`,
|
||
`wandTileBesprochen`→`wandBesprochenInhalt`, `wandTileHeute` removed,
|
||
`wandKachel`→`wandPanel`, `wandLinkeSpalteHtml` removed,
|
||
`wandFeedHtml`/`WAND_FEED_SPALTEN` → `wandAktuellInhalt`) are stale.
|
||
Treat class names like `.wand-c-layout`/`.wand-c-rechts`/`.wand-kachel`/
|
||
`.wand-stat`/`.wand-namen`/`.wand-avatar`/`.wand-feed-gruppe*` mentioned
|
||
below as historical — none of them exist in styles.css any more.
|
||
- **Follow-up: the Dashboard's logo-blue palette then became the WHOLE
|
||
app's dark mode**, not just this one screen — Erik: "kannst du das gesamte
|
||
dark mode Farbschema an das der Wandtafel anpassen?". The dark-mode
|
||
`--accent`/`--text`/`--bg`/`--surface*`/`--line*` tokens (the ONE shared
|
||
`:root`/`html[data-theme="dark"]` definition at the top of styles.css,
|
||
confirmed by grep to be the only place these specific hex values appear —
|
||
every component in the app already read them exclusively through
|
||
`var(...)`, so this was a token-value swap, not a sweep across individual
|
||
rules) now carry the same cool blue-teal family as `--dash-*` above,
|
||
instead of the previous generic Apple-style blue/near-black. `--green`/
|
||
`--amber`/`--red` were retuned to the same gedeckter palette rather than
|
||
left alone or collapsed into the accent hue — they stay functionally
|
||
independent status colors (Ampel-Logik, Auftragsstatus, etc.), per
|
||
"semantic color is separate from the brand accent," just recolored to sit
|
||
well against the new cooler background instead of clashing with it.
|
||
`--band-neu` (the Wochenbericht card header color for "neu kommentiert")
|
||
reuses the exact same dark logo blue as `--dash-ink`'s light-mode value
|
||
(`#116896`) — dark enough under its own white text regardless of page
|
||
theme. Light mode is untouched; only the dark-mode token blocks changed.
|
||
- **Two columns, version 2 (Erik's choice from the layout comparison),
|
||
replacing an earlier six-tile 3×2 grid.** The grid version left visibly
|
||
uneven whitespace: a tile with a single number (e.g. "Abwesend heute")
|
||
shared a row with a tile holding three meters, and — since every tile in a
|
||
row shares that row's height — the thin tile stretched to match without
|
||
having the content to fill it. Erik's own words: "ich finde es blöd das
|
||
Leere bereiche da sind." The fix is structural, not cosmetic: fewer,
|
||
heavier-weighted components (three left tiles, one feed) rather than many
|
||
thin ones, each stretched via `.wand-kachel-body { justify-content:
|
||
space-evenly }` so its own content distributes across its own full height
|
||
instead of clumping at the top. `body.wandtafel-aktiv` additionally makes
|
||
`.app-main`/`.container`/`#wand` behave like a fixed `100vh` viewport with
|
||
`overflow: hidden` instead of a normally-scrolling page — an unattended
|
||
display should never need scrolling, and the two columns size themselves
|
||
to exactly fill it rather than growing with content.
|
||
- **Left column — three tiles, never tappable as a whole.** An earlier
|
||
version let the whole tile expand/collapse (`wandKachelKlick()`,
|
||
`wandOffenKachel`, a chevron in the title). Erik's explicit follow-up
|
||
request removed that outright: "Das ein- und ausklappen der
|
||
Informationen in der Heute-Kachel kann entfallen. Die einzelnen dort
|
||
dargestellten Elemente sollen anklickbar sein, nicht die Kachel selber.
|
||
Das soll auch bei allen anderen Kacheln so sein." Every tile now always
|
||
renders its one, compact view — `wandKachel()` lost its interactive
|
||
wrapper entirely — and instead each individual element INSIDE a tile is
|
||
its own click target (`.ist-klickbar` on the specific row/stat/avatar,
|
||
never on `.wand-kachel`), each jumping straight to the matching detail
|
||
rather than toggling anything in place. Tapping one opens a full-screen
|
||
**Übersicht** (overview), the same drill-down mechanism the überfällige-
|
||
Aufgaben list introduced, now generalized to every clickable element via
|
||
one piece of module state: `wandUebersicht` (`null`, or
|
||
`{typ, gewerk?}` — `gewerk` only for the two per-Gewerk types) set by
|
||
`wandZeigeUebersicht(typ, gewerk)` and cleared by
|
||
`wandSchliesseUebersicht()`; `wandUebersichtHtml(w)` dispatches on `typ`
|
||
to the matching builder. `wandUebersicht` resets to `null` inside
|
||
`startWandboard()`'s guarded (once-per-real-entry) branch, so leaving
|
||
and returning to the Wandtafel always starts back at the normal
|
||
two-column view rather than wherever it was left.
|
||
- **Auslastung je Gewerk** — each of the three Gewerk meter rows is its
|
||
own click target (`wandZeigeUebersicht('auslastung', g)`).
|
||
`wandAuslastungJeGewerk()` collects, alongside the existing per-Gewerk
|
||
totals, a `projekte:[{id,name,nr,stunden}]` list — reusing the exact
|
||
same `berechneAuslastung()` call `renderLoad()` already makes, just
|
||
also keeping the per-project number instead of only summing it. The
|
||
deliberate multi-Gewerke double-count (a project tagged with several
|
||
Gewerke counts its full contribution at each) is unchanged. The
|
||
opened overview (`wandUebersichtAuslastung()`) lists every
|
||
contributing project of that Gewerk, highest hours first; a row jumps
|
||
into the project's **Leistung** section (`wandSpringeZuProjekt(pid,
|
||
'leistung')`) — the section that already computes honorar × Phase ×
|
||
Fortschritt, the most directly relevant place for an Auslastung
|
||
question to land.
|
||
- **Wochenbericht besprochen** — each of the three Gewerk meter rows
|
||
(only rendered clickable when that Gewerk actually has relevant
|
||
projects) opens `wandZeigeUebersicht('besprochen', g)`.
|
||
`wandBesprochenDaten()` now keeps `offeneProjekte:[{id,uid,name,nr}]`
|
||
per Gewerk (replacing the plain-string `offeneNamen` the first
|
||
version had) so each row in the opened overview
|
||
(`wandUebersichtBesprochen()`) can carry its `project_uid`. Clicking a
|
||
row does **not** open the Projektbereich — it calls
|
||
`springZumWochenbericht(uid)` (`js/plan/plan-info.js`), landing
|
||
directly on that project in the Wochenbericht. This follows "Ein Ort
|
||
je Angabe" (CLAUDE.md): the `besprochen`-Haken and the session
|
||
comment are editable *only* in the Wochenbericht, never in the
|
||
Projektbereich, so a jump into the Projektbereich would only land on
|
||
its own read-only verweis back to the Wochenbericht — one hop that
|
||
the Wandtafel now skips entirely.
|
||
- **Heute** — the two stat numbers (offene Aufgaben/überfällig) and the
|
||
avatar row are each independently clickable, no merged
|
||
expand/collapse state any more:
|
||
- **"offene Aufgaben"** → `wandZeigeUebersicht('offen')` — EVERY open
|
||
task across every project (not just overdue), nearest due date
|
||
first, empty dates last (`wandHeuteDaten()`'s new `offeneListe`,
|
||
the same sort convention "Meine Aufgaben" uses on the Dashboard).
|
||
- **"überfällig"** → `wandZeigeUebersicht('ueberfaellig')` — unchanged
|
||
from the previous version, now reached directly instead of as a
|
||
tap-target nested inside an otherwise expand/collapse-only tile.
|
||
- **the avatar row** → `wandZeigeUebersicht('abwesend')` — previously
|
||
only visible as an expanded detail list, now its own overview
|
||
reachable straight from the avatars; a row shows name/type/bis with
|
||
no click-through (an absence belongs to no project to jump to).
|
||
All three overviews share one row renderer
|
||
(`wandUebersichtZeile(links, mitte, rechts, onclick, rechtsKlasse)`)
|
||
and one frame (`wandUebersichtRahmen()`) — the per-type builders only
|
||
supply the three columns' content and (where it applies) the
|
||
`onclick`. A clicked task row calls `wandSpringeZuProjekt(pid,
|
||
'aufgaben')`, which sets `location.hash = 'aufgaben'` *before*
|
||
`openProjectPlan(pid)` (`js/gantt.js`) — the same "set the hash
|
||
first, `showTab('plan')`'s own `updateRouteUrl()` preserves it"
|
||
mechanism the Dashboard's own task links rely on
|
||
(`js/dashboard/dashboard.js`). This is a deliberate, explicit exit
|
||
from the Wandtafel: `showTab('plan')` clears `body.wandtafel-aktiv`
|
||
(sidebar/topbar return) exactly like navigating to any other tab
|
||
would, and a kiosk account landing on the Projektbereich can view it
|
||
(todos render from already-loaded `db` data, no fetch needed) but not
|
||
edit anything there (every write route still 403s) — a few of the
|
||
Projektbereich's OTHER sections do prefetch their own data
|
||
unconditionally on load (Dateien, Protokolle, the Benutzerliste for
|
||
the reviewer-picker), and those specific fetches 403 and log a
|
||
caught, non-fatal error for a kiosk account; the Aufgaben/Terminplan
|
||
sections themselves are unaffected. Every overview's list is the one
|
||
deliberate exception to the Wandtafel's "never scrolls" rule
|
||
(`.wand-uebersicht-liste { overflow-y: auto }`) — a genuinely long
|
||
list belongs to an intentional drill-down, not the passive ambient
|
||
view.
|
||
- **Right column — "Aktuell diese Woche"** replacing the old standalone
|
||
Projektportfolio tile outright (portfolio composition by
|
||
Gruppe/Auftragsstatus is not currently shown anywhere on the Wandtafel —
|
||
a deliberate scope cut in this version, not an oversight, left for a
|
||
future addition if wanted). `wandAktuelleEreignisse()` collects three
|
||
event kinds pulled from every project in the session:
|
||
- **Neu** — a project whose `classifyProjectChange()` bucket (reused
|
||
directly from `js/wochenbericht/wochenbericht.js`, same function the
|
||
Wochenbericht tab itself uses to diff against `findPreviousWeek()`) is
|
||
`'status'` or `'new'`, with a plain-text comment preview via
|
||
`statusPreviewText()` (`js/board/board.js`, HTML stripped, 70 chars).
|
||
- **Überfällig** — any open `plan_todos` entry whose `end` date has
|
||
already passed, across every project (not scoped to a person, unlike
|
||
`GET /my-dashboard`).
|
||
- **Termin** — any upcoming `plan_milestones` entry, across every
|
||
project.
|
||
Within each kind, entries are sorted by **closeness to today regardless
|
||
of direction** (`Math.abs(tageBis(datum))` ascending, `tageBis()` reused
|
||
from `js/dashboard/dashboard.js`) rather than by date — an event from
|
||
yesterday and one from tomorrow are both "relevant right now" in a way a
|
||
plain chronological sort wouldn't group together.
|
||
**On Erik's follow-up ("ein anderes Design für die Kachel Aktuell diese
|
||
Woche"), `wandFeedHtml()` groups these events into THREE SIDE-BY-SIDE
|
||
COLUMNS by kind (`WAND_FEED_SPALTEN`: Neu/Überfällig/Termine,
|
||
`.wand-feed-gruppen`/`.wand-feed-gruppe`) instead of one chronologically
|
||
mixed list** — a design choice ("Variante C") picked from a 3-way visual
|
||
comparison (alongside a connecting timeline and larger accent-bordered
|
||
cards). Trades away the previous single-list "what matters most right
|
||
now regardless of kind" ordering for an at-a-glance read of HOW MANY
|
||
events of each kind are pending — each column header shows the kind's
|
||
TRUE total count (`.wand-feed-gruppe-anzahl`) even though only
|
||
`WAND_FEED_MAX` (5, now a PER-COLUMN cap rather than a global one)
|
||
entries are actually listed underneath; an empty column shows "keine"
|
||
rather than being hidden, since the column's presence itself is part of
|
||
the at-a-glance count. Each row's timestamp (`relativerTag()`, also from
|
||
`dashboard.js`) is the same relative-day phrasing ("heute"/"morgen"/"seit
|
||
3 Tagen") used throughout the rest of the app.
|
||
**Every entry is its own click target** (`.wand-feed-eintrag`, part of
|
||
the same per-element interaction model as the left column). Each event
|
||
object carries `pid` and an `abschnitt` matched to its kind (`aufgaben`
|
||
for an überfällig entry, `terminplan` for a termin entry, `dashboard`
|
||
for a neu entry), and a click calls the shared `wandSpringeZuProjekt(pid,
|
||
abschnitt)` — an entry is already a single, concrete event, so unlike
|
||
the left column's meter rows it jumps straight into the project with no
|
||
intermediate overview.
|
||
- **Stand** — unchanged: a plain "Stand: HH:MM Uhr" timestamp under the
|
||
feed, the cheapest possible signal that the kiosk hasn't silently frozen
|
||
over the days/weeks it runs unattended.
|
||
- **Below the fold: the complete project list, reachable by scrolling — the
|
||
one deliberate exception to "this display never scrolls".** Erik's
|
||
follow-up, after being told the ambient view couldn't fit a full project
|
||
list without breaking the fixed-viewport design: the Wandtafel's monitor is
|
||
a real touch screen, so he can walk up and scroll it by hand ("ich habe
|
||
einen Touch Monitor... ich kann mich vor die Tafel stellen und scrollen").
|
||
`renderWandboard()` now renders the two-column area wrapped in a new
|
||
`.wand-ambient` box (still exactly one screen tall, `min-height: 100%`)
|
||
followed by `wandProjektlisteHtml(w)` — every project, grouped and ordered
|
||
exactly like the Projekte list (`GROUP_ORDER`/`sortGroups()`, unlike the
|
||
Auslastung/Besprochen tiles this one deliberately INCLUDES "In Anbahnung",
|
||
since the point here is completeness, not bewertbare Arbeitslast).
|
||
**On Erik's explicit follow-up ("Die Projektliste soll so aussehen wie die
|
||
Originale"), each row is NOT a bespoke Wandtafel row** — it is built with
|
||
the exact same `renderBoardRow()` + `BOARD_OPTS` call `renderBoard()`
|
||
itself uses for the real Projekte tab (`js/board/board.js`): same Nr.,
|
||
name, Auftragsstatus-Kennzeichen, comment preview, avatar stack and
|
||
Phasen-Badge, and the row expands to the identical `renderBoardDrawer()`
|
||
detail panel. `locked` is passed as a hardcoded `true` regardless of the
|
||
session's actual lock state — the Wandtafel never writes (see file-header
|
||
comment), so this always hides the drag handle and the "Löschen" button
|
||
and always renders the comment as `.comment-readonly`, independent of
|
||
whether the real session happens to be unlocked; a kiosk account's write
|
||
attempt would 403 server-side anyway, so there is nothing to protect by
|
||
mirroring the real lock state here — only an unconditional, hardcoded
|
||
read-only rendering makes the guarantee independent of that state.
|
||
`toggleBoardRow()` (`board.js`) now also calls `renderWandboard()` after
|
||
flipping a row's expanded state, exactly as it already did for
|
||
`renderBoard()`/`renderWochenbericht()`, so expanding/collapsing a row in
|
||
the Wandtafel's own list takes effect immediately instead of only on the
|
||
next full re-render. Only `#wandContent` itself scrolls (class
|
||
`.wand-scrollable`, toggled by `renderWandboard()` — present
|
||
for the normal ambient view, absent while an Übersicht is open, which stays
|
||
exactly one screen tall with its own existing internal list scroll) —
|
||
`.app-main`/`.container` above it are untouched, still `overflow: hidden`.
|
||
**An unattended touch display can't be left in whatever state the last
|
||
passer-by scrolled it to**, so `WAND_IDLE_MS` (60 s) drives an inactivity
|
||
timer: `wandAktivitaetErkannt()` is registered once, globally, on
|
||
`pointerdown`/`touchstart`/`wheel`/`keydown`/`scroll` (the `scroll`
|
||
listener needs the **capturing** phase specifically — a scroll event on an
|
||
element, unlike on `window`/`document`, does not bubble, but a capturing
|
||
listener on an ancestor still sees it on the way down regardless of
|
||
bubbling) and is a no-op unless `body.wandtafel-aktiv` is set, so it costs
|
||
nothing on every other tab. Each call resets the timer; after `WAND_IDLE_MS`
|
||
without any of those events, `wandBeiUntaetigkeitZuruecksetzen()` clears
|
||
`wandUebersicht` and calls `renderWandboard({resetScroll:true})`,
|
||
unconditionally — even if nobody opened an Übersicht and only scrolled the
|
||
project list, that scroll position must still snap back. The same
|
||
`resetScroll` flag is also why an ordinary 60-second background data
|
||
refresh (`wandRefreshTick()` → `load(true)` → `renderWandboard()`, no
|
||
`opts`) does **not** reset anyone's scroll position — `renderWandboard()`
|
||
reads and restores `container.scrollTop` across every re-render unless
|
||
`resetScroll` is explicitly passed, so a person actively scrolling the
|
||
project list is never yanked back to the top by the routine data refresh,
|
||
only by genuine inactivity. **`#wandIdleCountdown`** (a `<span>` next to
|
||
"← Zur Übersicht" in `App.html`'s `.wand-kopfzeile`, deliberately OUTSIDE
|
||
`#wandContent` so it survives every data/content re-render untouched) —
|
||
Erik's follow-up wanting to see the countdown, not just experience the
|
||
jump. `wandAktivitaetErkannt()` additionally stores the absolute fire time
|
||
in `wandIdleDeadline`; a separate 1-second `setInterval`
|
||
(`wandCountdownTimer`, started/stopped alongside the others in
|
||
`startWandboard()`/`stopWandboard()`) runs `wandCountdownTick()`, which
|
||
writes "Rücksprung in N s" from `wandIdleDeadline - Date.now()`.
|
||
`wandBeiUntaetigkeitZuruecksetzen()` calls `wandAktivitaetErkannt()` again
|
||
at its own end, so the countdown keeps cycling continuously for as long as
|
||
the Wandtafel is open, rather than running once and going silent after the
|
||
first automatic reset.
|
||
- **The same auto-return also applies AFTER leaving the Wandtafel through one
|
||
of its own clickable areas** — Erik's follow-up: "auch wenn ich eine der
|
||
Flächen/Projekte auf der Tafel anklicke soll nach 1 min das Bild wieder
|
||
zurückspringen". A tap on a project (the project list, a feed row, an
|
||
Übersicht row) or a Gewerk meter deliberately navigates away into the
|
||
Projektbereich or the Wochenbericht — without this, the display would be
|
||
left there indefinitely once nobody is around to notice. `wandUnterwegs`
|
||
(a module-level flag) tracks "away because of such a click"; while true,
|
||
`wandAktivitaetErkannt()` drives a *second* timer, `wandRueckkehrTimer`
|
||
(same `WAND_IDLE_MS`), instead of the on-Wandtafel idle timer, and
|
||
`wandKehreZurueck()` calls `showTab('wand')` once it fires — same
|
||
`WAND_IDLE_MS` constant, same "any touch resets it" semantics as the
|
||
on-Wandtafel case, just pointed the opposite direction.
|
||
**`wandUnterwegs` is set by one generic, capturing `click` listener on
|
||
`document` that checks `#wandContent.contains(event.target)`** — not by
|
||
wrapping every individual navigation call. The project list's rows render
|
||
through the real `renderBoardRow()` (see above), whose own `.board-name`
|
||
handler calls `openProjectPlan()` **directly** and calls
|
||
`event.stopPropagation()` first; a normal bubbling listener on
|
||
`#wandContent` would never see that click at all. A *capturing* listener
|
||
does, because capturing runs on the way down to the target regardless of
|
||
any `stopPropagation()` called later during bubbling. The listener only
|
||
ever sets the flag — arming `wandRueckkehrTimer` itself happens at the end
|
||
of `stopWandboard()` (`if(wandUnterwegs) wandAktivitaetErkannt();`), which
|
||
runs synchronously, in the same click, right after `body.wandtafel-aktiv`
|
||
has already been removed by `showTab()` — so by the time it checks,
|
||
`wandAktivitaetErkannt()` reliably takes the "away" branch rather than the
|
||
on-Wandtafel one. The Besprochen-Übersicht's jump into the Wochenbericht
|
||
(`springZumWochenbericht()`, shared with `board.js`/`plan-info.js`) needed
|
||
no wrapper or special-casing either, for the same reason — the click still
|
||
originates inside `#wandContent`. **The explicit "← Zur Übersicht" exit is
|
||
the one deliberate exception**: that button sits in `.wand-kopfzeile`,
|
||
OUTSIDE `#wandContent`, so the listener never sees it and `wandUnterwegs`
|
||
stays `false` — a staff member's deliberate preview exit is not
|
||
interrupted after a minute, only an accidental or exploratory tap on the
|
||
Wandtafel's own content is. `startWandboard()` resets `wandUnterwegs` to
|
||
`false` and clears `wandRueckkehrTimer` on every fresh arrival at the
|
||
Wandtafel (whether via `wandKehreZurueck()` or an ordinary manual
|
||
navigation back to it), so a completed round trip never leaves a stale
|
||
timer armed.
|
||
- `TAB_SLUGS`/`VALID_TAB_SLUGS` gained `wand: 'Wandtafel'`
|
||
(`js/globals.js`/`server.js`) — the deep-link `/Wandtafel` works through
|
||
the same `applyUrlRoute()`/`updateRouteUrl()` mechanism as every other tab,
|
||
no special-casing needed. A redirect `GET /Test.html`-style old link isn't
|
||
needed here since nothing ever pointed at this tab before it existed.
|
||
|
||
## Docker
|
||
|
||
- `docker compose up --build` from the repo root. Compose maps `3003:3003`,
|
||
bind-mounts `./sessions:/app/sessions`, `restart: unless-stopped` and sets
|
||
**`TZ: Europe/Berlin`** — ohne das liefe der Container auf UTC, und die
|
||
Datumsvergleiche des Servers (`heuteIsoLokal()`) lägen zwischen Mitternacht
|
||
und 2 Uhr Ortszeit einen Tag zurück.
|
||
- Dockerfile: `node:22-alpine` (nötig für node:sqlite), `npm ci --production`,
|
||
runs as non-root `node`, HEALTHCHECK on `/health`. `.dockerignore` excludes
|
||
`Docker/*` (except `Docker/Dockerfile`), `node_modules/`, `.git`,
|
||
`npm-debug.log`.
|
||
|
||
## Behobene Fehler (Prüfung 09/2026)
|
||
|
||
Aus einer systematischen Durchsicht (Bericht mit 58 Funden) wurden folgende
|
||
Verhaltensweisen geändert — wer den alten Zustand im Kopf hat, findet hier die
|
||
Abweichungen:
|
||
|
||
- **`GET /backup` ist admin-only** (`requireRole('admin')`). Der Snapshot
|
||
enthält die komplette Datenbank samt `user_sessions.token` im Klartext; mit
|
||
einem fremden Token ließ sich jede Admin-Grenze umgehen. Die Schaltfläche
|
||
"DB Backup" steht deshalb jetzt im Admin-Bereich der Seitenleiste.
|
||
- **Die Sperre gesperrter Sitzungen wird serverseitig durchgesetzt**:
|
||
`/save-week` weist `locked: true → false` für Nicht-Admins mit 403 ab. Das
|
||
Sperren (false → true) bleibt jedem erlaubt, weil `applyAutoLock()` es beim
|
||
normalen Speichern mitschreibt.
|
||
- **Anmeldung gebremst**: 10 Fehlversuche je Konto und Absender-Adresse sperren
|
||
für 10 Minuten (429). Der Schlüssel enthält den Benutzernamen, damit nicht
|
||
ein Tippfehler alle hinter derselben Büro-Adresse aussperrt. Bei unbekanntem
|
||
Benutzernamen wird trotzdem einmal gehasht (sonst verriete die Antwortzeit
|
||
gültige Konten). Abgelaufene Sitzungen werden beim Start gelöscht.
|
||
- **`loadGlobalPeople()` liefert nur Konten mit gesetzter `person_id`.** Zwei
|
||
Konten ohne Kennung hätten sonst denselben leeren Primärschlüssel im
|
||
Teilnehmerkreis einer Sitzung belegt und jedes Speichern mit 500 abgebrochen;
|
||
`serializeWeek()` filtert doppelte Kennungen zusätzlich heraus.
|
||
- **`loadWeeks()` hängt Teilnehmer an, statt sie per `array_order` zu
|
||
indizieren** — kollidierende Ordnungszahlen hätten Personen still
|
||
überschrieben.
|
||
- **JSON-Dateien lassen sich hochladen**: Der globale `express.json()`-Parser
|
||
läuft nicht mehr über die in `RAW_BODY_ROUTES` gelisteten Routen (`/import`,
|
||
`POST /project-files`, `POST /project-protokolle/import` — die nehmen rohe
|
||
Binärdaten entgegen); das Limit für Sitzungsdaten liegt bei 8 MB.
|
||
- **Der Teilnehmerkreis einer laufenden Sitzung ist wieder pflegbar**:
|
||
`addPersonToCurrentWeek()`/`removePersonFromCurrentWeek()` in
|
||
`js/people/people.js`. Vorher entstand `week.people` nur bei `newWeek()`, ein
|
||
später angelegter Benutzer war in keiner laufenden Sitzung zuordenbar. Das
|
||
Entfernen verweigert sich, solange die Person noch einem Projekt der Sitzung
|
||
zugeordnet ist.
|
||
- **Speichern meldet sich**: `store.js` zählt Änderungen je Woche mit
|
||
(`weekChangeCounter`), damit eine Bearbeitung WÄHREND eines laufenden
|
||
Speichervorgangs ihre Dirty-Markierung behält; Fehler erscheinen als Band
|
||
unten rechts (`.sync-problem-bar`), werden nach 20 Sekunden erneut versucht,
|
||
und ein 401 öffnet wieder das Anmeldefenster. Nach einem fehlgeschlagenen
|
||
`load()` werden **keine** Beispieldaten mehr erzeugt (`seed()` lief früher
|
||
auch im Fehlerfall und überschrieb per `save()` die echte "KW 30 / 2026").
|
||
- **`sanitizeStatusHtml()`** packte unerlaubte Elemente mit einem falschen
|
||
Referenzknoten aus (`insertBefore(child.firstChild, node)`) und warf dabei
|
||
`NotFoundError` — ein eingefügter Link genügte, um das Board-Rendering
|
||
abzubrechen. Zusätzlich werden ausgepackte Kinder jetzt erneut geprüft
|
||
(sonst blieben `<li>`/`<tbody>` stehen) und `<script>`/`<style>` samt Inhalt
|
||
verworfen. `esc()` maskiert nun auch `'`, weil mehrere Inline-Handler den
|
||
Wert in einfach gequotete JS-Strings setzen.
|
||
- **Sitzungsbezeichnungen werden geprüft** (`validateWeekLabel()` in
|
||
`js/weeks.js`): keine Dubletten (der Server schlüsselt allein über das Label)
|
||
und Rückfrage bei fehlendem `KW …`-Muster (sonst sortiert die Sitzung hinter
|
||
alle echten und `applyAutoLock()` sperrt den Rest).
|
||
- **Auslastung**: Die Ampel bezieht sich auf die Kapazität (gelb ab 0,75, rot
|
||
ab 1,0) statt auf das Skalenmaximum `LOAD_BAR_MAX`; der Mittelwert teilt nur
|
||
durch die Sitzungen, in denen die Person im Teilnehmerkreis stand.
|
||
- **Projektbereich**: Auto-Schedule terminiert Meilensteine vor ihren
|
||
Nachfolgern, der Meilenstein-Dialog sperrt Zirkelbezüge, die Vorgängerliste
|
||
zeigt auch die einzige vorhandene Aufgabe (`>= 1` statt `> 1`), Enter im
|
||
Vorgänger-Suchfeld speichert nicht mehr, und das Verschieben einer
|
||
Abwesenheit legt erst neu an, wenn das Löschen bestätigt ist.
|
||
- **Modals liegen über der Navigation** (`.modal-bg` `z-index: 300`); gesperrte
|
||
Bedienelemente sind an `:disabled`-Regeln erkennbar.
|
||
|
||
### Nachtrag: die zunächst offen gebliebenen Punkte
|
||
|
||
- **Aufwands-Gewichtung ohne Eintrag** (`berechneAuslastung()`, `js/load/load.js`):
|
||
Fehlt am Projekt ein Matrixwert, gilt jetzt der Wert aus der firmenweiten
|
||
Vorlage `LP_AUFWAND_VORLAGE` für diese Phase und Rolle — vorher pauschal 1,0.
|
||
Damit zählen zwei gleich gelagerte Projekte gleich, unabhängig davon, ob die
|
||
Aufwandsmaske schon einmal gespeichert wurde (LPH 9 steht in der Vorlage
|
||
bewusst auf 0). Nur wenn die Vorlage die Phase gar nicht kennt, bleibt es bei
|
||
1,0. Das Eingabefeld zeigt den jeweiligen Vorgabewert als Platzhalter.
|
||
- **Abwesenheiten fremder Konten**: Eintragen bleibt für jedes angemeldete
|
||
Konto erlaubt (die Projektleitung pflegt Urlaube ihres Teams mit), aber
|
||
`user_absences.created_by` hält fest, wer den Eintrag angelegt hat.
|
||
`DELETE /users/:id/absences/:absenceId` erlaubt das Löschen nur der
|
||
betroffenen Person, dem Ersteller oder einem Administrator (sonst 403).
|
||
Die Spalte wird beim Start idempotent nachgerüstet; Altbestand hat
|
||
`created_by = 0` und ist damit nur von der betroffenen Person oder von
|
||
Administratoren löschbar.
|
||
- **Fokus beim Speichern per Fokuswechsel** (`js/board/board.js`): Ein
|
||
`mousedown`-Lauscher in der Capture-Phase merkt sich, in welches
|
||
Kommentarfeld geklickt wird; nach dem Neu-Rendern stellt
|
||
`restorePendingCommentFocus()` den Fokus dort wieder her (Cursor ans
|
||
Textende). Die Schaltflächen "Speichern" und der Verlaufs-Aufklapper
|
||
unterdrücken zusätzlich den Fokusverlust (`onmousedown`/`preventDefault`,
|
||
wie zuvor schon "Verwerfen" und "übernehmen").
|
||
- **Tote Reste entfernt**: `renderGantt()`/`fitGanttRange()` in `js/gantt.js`
|
||
(griffen auf Elemente zu, die es in keiner Seite mehr gibt), `statusBlur()`
|
||
und `delProj()` in `js/board/board.js` (seit dem Textfeld-Editor bzw. der
|
||
neuen Zeilendarstellung ohne Aufrufer), 16 CSS-Regelblöcke der früheren
|
||
TODO-Liste (damals in `project-plan.html`), die wirkungslose Klasse
|
||
`plan-main-content-full` und der gegenstandslose 900px-Umbruch.
|
||
**`buildGanttHtml()` bleibt in `js/gantt.js`** — `js/plan/plan-build-gantt.js`
|
||
umhüllt sie (`originalBuildGanttHtml`), sie ist also die Grundlage des
|
||
gesamten GANTT. Der Link "Zurück zur Übersicht" in `admin-users.html` nutzt
|
||
jetzt die neue Regel `a.secondary` statt Inline-Stilen.
|
||
- **`esc()` verträgt Nicht-Strings**: Leistungsphasen-Prozentwerte kommen je
|
||
nach Herkunft als Zahl an; `(100).replace` ist keine Funktion und riss das
|
||
Rendern des Wochenberichts mit. `esc()` wandelt jetzt über `String()` um
|
||
(nur `null`/`undefined` werden zu `''`).
|
||
|
||
## Conventions & verification
|
||
|
||
- Git repo; commits are German (e.g. "PDF Export von A4 auf A3 …"). Remote:
|
||
`http://192.168.3.187:3000/erik/projektuebersicht.git`.
|
||
- No linter, formatter, typecheck, or test suite. Manual verification only:
|
||
start server, open `http://localhost:3003`, exercises the relevant tab,
|
||
confirm SQLite changes (the DB file `sessions/projektuebersicht.db` grows). |