Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und nur noch in den Projekteinstellungen editierbar (planCycleAssign()). Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen. Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst unabhaengig von den gespeicherten Protokollen - nichts davon wird abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter Import leicht 20 Vorschlaege liefert. Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt (Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog. Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe. plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit Zeilenzahl und Umsortierung nicht auseinanderlaufen. KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt (Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem alten Limit und brach bei jeder Schwankung ab. Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root, angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die Akzentleiste ist ein inset-Schatten statt eines positionierten Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens. Plan Checker: optionaler Projektabgleich des Plankopfs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3555 lines
237 KiB
Markdown
3555 lines
237 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-%
|
||
│ │ │ # (Dashboard-Bereich; die Beteiligten-Matrix ist
|
||
│ │ │ # entfallen, siehe plan-settings.js)
|
||
│ │ ├── plan-settings.js # Bereich "Einstellungen": Projektangaben, Honorar,
|
||
│ │ │ # beauftragte Leistungsphasen, Beteiligten-Matrix
|
||
│ │ │ # (renderPlanSettings, planCycleAssign)
|
||
│ │ ├── plan-tasks.js # Bereich "Aufgaben": Aufgaben-/Meilensteinliste
|
||
│ │ │ # (renderPlanTasks; öffnet dieselben Dialoge wie das GANTT)
|
||
│ │ ├── plan-history.js # Kommentar-Verlauf über alle Sitzungen (+ Vorwochen-Helfer)
|
||
│ │ ├── plan-templates.js # Aufgaben aus LPH-Vorlagen (lph_templates)
|
||
│ │ ├── plan-protokoll-todos.js # "+ TODOs aus Protokoll": externes PDF
|
||
│ │ │ # per KI einlesen + Aufgaben daraus ableiten —
|
||
│ │ │ # unabhängig von plan-protokoll.js, nichts
|
||
│ │ │ # davon wird gespeichert
|
||
│ │ ├── plan-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-protokoll-todos → plan-honorar →
|
||
plan-settings → plan-schedule`): they must come after `gantt.js`, whose `buildGanttHtml()`
|
||
`plan-build-gantt.js` wraps, and `plan-history` must stay before
|
||
`plan-build-gantt`, which uses its previous-week helpers. None of them
|
||
collides with a main-module global — every one runs in an IIFE over
|
||
`window.PlanApp` and exports through `window.*`.
|
||
- `admin-users.html` is the second, fully independent page (the only one
|
||
outside the tab system): it loads only `globals →
|
||
utils → ui → server → auth → admin-users.js` (no `date-picker`, no
|
||
`gantt`, no `plan/*` — it only talks to `/admin/users*`).
|
||
- `currentUser`/`checkAuthAndInit()` (`js/auth.js`) are the shared client-side
|
||
auth concept across all three pages: `checkAuthAndInit(onAuthenticated)`
|
||
calls `GET /whoami`; with a session it sets `currentUser`, hides the login
|
||
overlay and runs `onAuthenticated()` (the page's normal entry point, e.g.
|
||
`initUiState()` + `switchDepartment()` in `js/init.js`); without one it shows
|
||
the blocking `#loginOverlay`/`#loginForm` and only proceeds after a
|
||
successful `POST /login`. `renderUserBar()` shows the current user's
|
||
name/role and toggles admin-only UI (the "DB Import" button, the
|
||
`#adminUsersLink` to `admin-users.html`) based on `currentUser.role`.
|
||
- Shared state object `window.PlanApp` (`js/plan/plan-state.js`) holds all
|
||
project-plan page state (week, project, zoom level, drag/`modal`-state),
|
||
`db`/`currentDepartment` live in `js/globals.js`.
|
||
- State: `let db = { weeks, currentWeek, globalPeople }` (in-memory only).
|
||
- Project/person IDs are stable across reloads because the server splits and
|
||
reassembles week objects deterministically (JSON IDs are kept in the DB
|
||
columns); projects are addressed cross-page by `department` + week `label` +
|
||
`projNr` (+ readable projName path segment) via URL params:
|
||
`openProjectPlan()` builds `/HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…`,
|
||
`findProjectForPlan` resolves them. Person IDs follow
|
||
`personIdForName(name)` → `p_<lowercased alnum>`.
|
||
- `save()` debounces 500 ms then `syncAllToServer()` writes **only dirty weeks**
|
||
(`dirtyWeeks` – track `markWeekDirty()`) via `sendWeekToServer()`. There is
|
||
no separate people-list dirty-tracking/sync path anymore (no
|
||
`dirtyPeople`/`markPeopleDirty()`/`sendPeopleToServer()`) — since
|
||
Projektbeteiligte is a read-only projection of `users`/`user_roles`/
|
||
`user_absences`, the only way the client ever changes "people" data is
|
||
either (a) admin CRUD on `admin-users.html` (writes straight to
|
||
`/admin/users*`/`/users/:id/absences*`, no debounce/dirty-tracking
|
||
involved), or (b) the cross-department roster addition
|
||
(`addUserToCurrentWeekRoster()` in `js/people/people.js`), which just
|
||
pushes into `currentWeek().people` and goes through the normal week-`save()`
|
||
path. An in-flight guard (`syncInFlight`/`syncQueued`) prevents overlapping
|
||
syncs from double-POSTing the same week with the same revision (which would
|
||
look like a spurious 409). On success the server-provided `updatedAt`
|
||
revision is adopted as the new sync base; weeks whose save failed (network
|
||
errors) stay dirty and are retried. A `409` conflict response drops the
|
||
local changes for that week and `handleWeekConflict()` reloads **only that
|
||
week** from the server (`reloadWeekFromServer()`), leaving all other dirty
|
||
weeks untouched. Exception: `js/plan/plan-state.js` `savePlan()` bypasses
|
||
`save()` and writes only the current week directly via `sendWeekToServer()`
|
||
(with the same in-flight/trailing-save guard), adopting the returned
|
||
`updatedAt` and showing a toast on conflict. `switchWeek()` (in
|
||
`js/weeks.js`) does NOT trigger a save – only actual edits do.
|
||
- `switchDepartment()` cancels a pending save debounce before switching, and
|
||
`load()` discards stale `/load-all` responses after a department switch
|
||
(frozen `requestedDepartment`), so late responses can never render or persist
|
||
data under the wrong department.
|
||
- The people tab no longer has drag-and-drop, a form, or delete/reorder
|
||
actions (`renderPeople()` in `js/people/people.js` is read-only rendering
|
||
of `db.globalPeople[currentDepartment]` plus any cross-department roster
|
||
additions on the current week). `getDragAfterElement()`/drag-and-drop
|
||
(`js/projects/projects.js`) serves the project list's `.board-row` rows
|
||
and, via `js/people/people.js`, the `.person-list-item` rows — the people tab's former
|
||
`.person-card`/`.people-sortable`/`reorderPeople()` drag machinery was
|
||
removed along with the CRUD UI. No drag handles are rendered on locked
|
||
weeks in the Projekte tab either way.
|
||
- Weeks are sorted by calendar week extracted from the label (`KW XX / YYYY`)
|
||
via `weekSortKey()`, not alphabetically/by filename.
|
||
- `applyAutoLock()` locks every week except the chronologically last one after
|
||
load/clone/new-week; `localStorage` UI state is not restored for locked tabs.
|
||
- `localStorage` keys: `projuebersicht_v1` (department only — tab is never
|
||
restored from storage; at startup the tab comes from the deep-link URL if
|
||
present, otherwise `DEFAULT_TAB` = `'dashboard'`, i.e. "Meine Aufgaben"),
|
||
`projuebersicht-theme`, header-collapse key. Do **not** store data in
|
||
`localStorage`; it is UI state only.
|
||
- Deep-link URLs (`/<Dept>/<Tab>`) are shared via the address bar:
|
||
`updateRouteUrl()` rewrites the path via `history.replaceState` on tab and
|
||
department switches. Since the whole app requires login, an unauthenticated
|
||
visitor to any deep link (including `/Projektplan/…`) simply sees the
|
||
login overlay first; the URL itself is never gated per-tab anymore (there is
|
||
no more admin/member split at the tab level — see Auth section).
|
||
- **Sidebar navigation (`App.html` only):** the former top tab bar, a
|
||
separate `<header>`, and a separate off-canvas tool drawer have been merged
|
||
into one persistent left sidebar, `#headerMenu`/`.side-menu` (the element
|
||
keeps its old id/class and `toggleHeader()`/`setHeaderCollapsed()` mechanics
|
||
from `js/ui.js`, now repurposed to hold the entire navigation). Structure
|
||
top to bottom: brand (logo/title), a "Navigation" section with the
|
||
`.nav-item.tab`/`data-tab` buttons (`showTab()`) in the order Meine
|
||
Aufgaben → Projekte → Wochenbericht → Auslastung (plus `#planNavItem`, see
|
||
below) — **no "Abteilung" section** (removed along with department
|
||
partitioning, see "Departments were retired"; `.dept-tab`/`switchDepartment()`
|
||
no longer exist) — then — below the divider — the **Einstellungen** nav item (the tab
|
||
`settings`), the **Werkzeuge** accordion and the **Admin** accordion (its
|
||
sub-items are the sections of the `admin` tab, see below), and a
|
||
`.side-menu-footer` with `#userBarInfo`/`#adminUsersLink`/logout
|
||
(`renderUserBar()`, unchanged). At `min-width: 769px` the sidebar is
|
||
`position: sticky` and permanently visible (no hamburger, `styles.css`);
|
||
below that width, a narrow `.mobile-topbar` (logo + hamburger) replaces the
|
||
old `<header>` and the sidebar reverts to its pre-existing off-canvas
|
||
slide-in behavior. `admin-users.html` deliberately does **not** get this
|
||
sidebar — it stays a focused standalone page outside the tab system, with
|
||
its own back-link and user bar. The Projektbereich needs no special case
|
||
at all any more: being the tab `plan` of `App.html`, it simply IS under
|
||
the same sidebar, with every section (Sitzung, Einstellungen, Admin, user
|
||
footer) working as everywhere else. The only sidebar addition is
|
||
`#planNavItem` — a nav item that stays hidden until a project has been
|
||
opened and then carries that project's name (`syncPlanNavItem()` in
|
||
`js/tabs-auth.js`), so the sidebar still highlights where you are while in
|
||
the Projektbereich. `showTab()` additionally hides `.container` for
|
||
`t === 'plan'`, because `#plan` deliberately sits OUTSIDE it (it brings its
|
||
own header and padding) and the empty container's padding would otherwise
|
||
show as a blank strip.
|
||
|
||
## Auth / security quirk
|
||
|
||
- Real server-side authentication (this replaced the old client-only
|
||
password modal — `PROTECTED_PASSWORD_HASH`, `isAuthenticated`,
|
||
`requestedAction`, `confirmPassword()`, `#passwordModal`, and the CryptoJS
|
||
CDN `<script>` tag are all gone; `js/tabs-auth.js` now only holds `showTab()`).
|
||
Login is required for the **entire app**, not just for mutations/admin
|
||
actions — every JSON endpoint except `/login`/`/whoami` requires at least a
|
||
valid session (see the Auth-level markers in "Server API" above).
|
||
- **Sessions:** a session is a row in `user_sessions` (`token` PK, 30-day
|
||
`expires_at`), identified via the `puebersicht_session` cookie
|
||
(`HttpOnly; SameSite=Lax`, `Secure` added automatically when the request is
|
||
HTTPS). Cookie parsing/setting is hand-rolled (`parseCookies()`,
|
||
`setSessionCookie()`/`clearSessionCookie()` in `server.js`) — no
|
||
`cookie-parser` dependency, keeping the project's express+cors-only
|
||
convention. `attachUser` middleware (registered via `app.use(attachUser)`
|
||
right after `express.json()` and before `express.static`, so it runs for
|
||
every request including static assets) reads the cookie, loads the joined
|
||
session+user row, and sets `req.user` (or `null`); it also refreshes
|
||
`last_seen_at` on every authenticated request. `requireAuth` (401 if
|
||
`req.user` is null) and `requireRole('admin')` (401 if not logged in, else
|
||
403 if `role !== 'admin'`) are the two middleware factories used on routes.
|
||
- **Passwords:** hashed with Node's built-in `crypto.scryptSync` (salt +
|
||
64-byte hash, `hashPassword()`/`verifyPassword()` in `server.js`);
|
||
`verifyPassword()` uses `crypto.timingSafeEqual()`. No bcrypt/argon2
|
||
dependency — stays within the express+cors-only convention.
|
||
`/login` returns the same generic error for wrong username, wrong password,
|
||
or an inactive account (no username-enumeration signal).
|
||
- **Roles:** `admin`, `member`, and `kiosk` (`users.role`, `VALID_ROLES` in
|
||
`server.js` — do not confuse with the discipline `roles` array on a user,
|
||
`Planung`/`Projektleitung`/`Bauüberwachung` in `user_roles`, which is
|
||
unrelated to permissions). `kiosk` is the one role that IS fully
|
||
server-enforced rather than a UI gate (see "Wandtafel" below for why it
|
||
exists): `requireAuth` rejects it outright everywhere except the handful
|
||
of read-only routes the Wandtafel needs (`requireAuthOderKiosk`), and a
|
||
`kiosk` account can never carry a discipline role (enforced on both
|
||
`POST`/`PATCH /admin/users*`, in both directions — setting `role: 'kiosk'`
|
||
on an account with existing discipline roles is rejected exactly like
|
||
adding a discipline role to an existing `kiosk` account). Otherwise, only
|
||
the genuinely destructive/administrative actions are **server-enforced**
|
||
admin-only: week deletion (`/delete-week`), week unlock (`/unlock-week`),
|
||
DB import (`/import`), and the `/admin/users*` CRUD. `/users/:id/absences*`
|
||
stays open to any logged-in user (unchanged permission level from before).
|
||
The `Projekte` tab and the (now read-only) `Projektbeteiligte` tab only
|
||
require being logged in — any role; actually adding/editing/removing team
|
||
members happens exclusively via `admin-users.html` (admin-only). The
|
||
cross-department roster-add/remove UI in the Projektbeteiligte tab
|
||
(`js/people/people.js`) is only **rendered** for `currentUser.role ===
|
||
'admin'` — like the old client-only password modal, this is a UI-level
|
||
gate, not a server-enforced one: it writes into `currentWeek().people`
|
||
through the ordinary `save()` → `POST /save-week`, which only requires
|
||
`requireAuth` (any role), so it does not add a new hard permission
|
||
boundary.
|
||
- **First-admin bootstrap:** `bootstrapInitialAdmin()` runs once at every
|
||
server start, guarded by `SELECT COUNT(*) FROM users` — if `users` is empty
|
||
(fresh install), it generates a random password
|
||
(`crypto.randomBytes(15).toString('base64url')`), creates an `admin` account
|
||
(username `admin`, `must_change_password=1`), and prints the plaintext
|
||
password to the console **and** writes it to
|
||
`sessions/INITIAL_ADMIN_PASSWORD.txt` (outside `public/`, so
|
||
`express.static` never serves it). There is no hardcoded password anywhere
|
||
in the code.
|
||
- **`users.person_id`** is the bridge between login accounts and the
|
||
pre-existing, department-independent `personIdForName()`-based assignment
|
||
scheme (e.g. `plan_todo_assignees.person_id`) — set explicitly by an admin
|
||
(not auto-derived server-side), so an account and a project-roster entry can
|
||
exist independently of each other. A user with no matching `person_id` sees
|
||
an empty dashboard with a hint instead of an error (`renderDashboard()` in
|
||
`js/dashboard/dashboard.js`).
|
||
- CryptoJS is gone; pdfmake (PDF export) is still loaded from
|
||
`cdnjs.cloudflare.com` in `App.html`, so PDF export needs internet access
|
||
and won't work offline. `admin-users.html` loads no CDN script.
|
||
- User-provided status HTML is sanitized on render via a small whitelist
|
||
(`sanitizeStatusHtml` in `js/board/board.js`) to mitigate XSS.
|
||
|
||
### Ein Ort je Angabe
|
||
|
||
Every piece of project data now has exactly ONE place where it is entered.
|
||
Before, six of nine fields were editable in three different masks
|
||
(Projektboard, Wochenbericht, Projektbereich) — nobody knew which one was
|
||
authoritative, and two people could change the same field in different places.
|
||
The split follows what kind of data it is:
|
||
|
||
- **Laufende Erfassung → Wochenbericht only.** The session comment
|
||
(`p.status`) and the progress per Leistungsphase (`p.lph`) are produced in
|
||
the weekly meeting. They are editable **only** in the Wochenbericht.
|
||
- **Stammdaten → Projektbereich only.** Projektnummer, Bezeichnung,
|
||
Schlagwörter, Gruppe, amtierende Leistungsphase, Auftragsstatus, Honorar, the
|
||
beauftragte Leistungsphasen, the Aufwands-Gewichtung, and **the assignment of
|
||
people (`p.assign`)** live in the Projektbereich (`einstellungen` section,
|
||
`js/plan/plan-settings.js` plus `js/plan/plan-info.js` for Gruppe/amtierende
|
||
LPH). `p.assign` moved here on Erik's explicit request ("In den
|
||
Einstellungen der Projekte will ich auch die Projektbeteiligten einstellen
|
||
können"), fully relocated rather than duplicated — he chose that option
|
||
outright over making it editable in both places, to keep this same "Ein Ort
|
||
je Angabe" invariant for the new field too. It is edited via
|
||
`planCycleAssign(personId, role)` in `plan-settings.js`, filling
|
||
`#planAssignMatrix` — deliberately **not** reusing `cycleAssign()`
|
||
(removed, see below) or going through `currentWeek()`/`save()` at all: the
|
||
Projektbereich can be deep-linked to an older week, or be a Planung-Sandbox
|
||
plan with no `currentWeek()` entry at all (same trap `planOpenAufwand()`
|
||
already guards against), so `planCycleAssign()` mutates `app.project`
|
||
directly and calls `app.savePlan()`, exactly like every other Stammdatum in
|
||
this file.
|
||
- **Everywhere else: display plus a way there.** `renderBoardDrawer()` takes
|
||
`opts.nurAnsicht` (set by `BOARD_OPTS` in `js/board/board.js`): same card,
|
||
same values, same order — but no input fields and a `.drawer-verweis`
|
||
underneath with the button "Im Wochenbericht bearbeiten →" for Kommentar/
|
||
Fortschritt. The Beteiligten-Zuordnung (`roleRows`) is now **always**
|
||
rendered read-only inside `renderBoardDrawer()` — in the Projektboard AND
|
||
in the Wochenbericht, regardless of `opts.nurAnsicht` — with its own
|
||
"In den Projekteinstellungen bearbeiten →" button next to it
|
||
(`springZuEinstellungen(pid)`, `js/gantt.js`). The Projektbereich's
|
||
Dashboard section does the equivalent for Kommentar/Fortschritt via
|
||
`setzeBerichtVerweis()` (`js/plan/plan-info.js`), filling
|
||
`#planStatusVerweis`/`#planLphVerweis`; it has no such verweis for
|
||
Beteiligte any more, since that matrix is edited right there in
|
||
Einstellungen now, not somewhere else.
|
||
- **The link is a real jump**, not a hint: `springZumWochenbericht(uid)`
|
||
(`js/plan/plan-info.js`, also used by `renderBerichtVerweis()` in
|
||
`board.js`) sets `berichtAuswahl` **before** `showTab('wochenbericht')` and
|
||
therefore lands on that very project. Without it the user would have to find
|
||
it again in a list of thirty. (`berichtAuswahl` is a global `let` binding
|
||
from `js/wochenbericht/wochenbericht.js` — classic scripts, no modules, so
|
||
assigning it from another file works.) `springZuEinstellungen(pid)` is the
|
||
same idea in the other direction: it sets `location.hash = 'einstellungen'`
|
||
**before** calling `openProjectPlan(pid)`, the same "set the hash first"
|
||
trick `wandSpringeZuProjekt()` already used, since `showTab('plan')`'s own
|
||
`updateRouteUrl()` preserves an already-set hash.
|
||
- **Consequence:** `planStatusBlur()`, `setPlanLph()` and `setPlanAssign()` in
|
||
`js/plan/plan-info.js` are **gone** — they had no caller left. The status
|
||
editor's formatting toolbar (`formatStatus()` buttons) went with them in the
|
||
Projektbereich; the Wochenbericht keeps the full editor. `editLph()` and
|
||
`saveBoardComment()` in `js/board/board.js` are untouched — the
|
||
Wochenbericht's editing hangs off them. `cycleAssign()` in `js/board/board.js`
|
||
is now **also gone** (the Beteiligten relocation's own consequence,
|
||
mirroring the three functions above) — it had exactly one caller
|
||
(`roleRows`'s avatar `onclick`), which is now unconditionally read-only.
|
||
- **Honorar and beauftragte Leistungsphasen stay editable in TWO places** on
|
||
purpose (Projekteinstellungen and the "Terminplan aus Honorar" dialog, so the
|
||
dialog remains usable on its own); both write through the same functions in
|
||
`js/plan/plan-honorar.js` and re-render each other, so they cannot drift.
|
||
- Changing the **Projektnummer** asks for confirmation (`setPlanProjNr()`):
|
||
uploaded files hang off `(department, project_nr)`, so a renumbered project
|
||
would lose them. Changing the **Bezeichnung** updates the plan header
|
||
(`#planTitle`), the sidebar entry (`syncPlanNavItem()`), the URL
|
||
(`updateRouteUrl('plan')`) and the list — `renderPlanPage()` would do all of
|
||
it, but it is module-local in `plan-state.js` and unreachable from
|
||
`plan-settings.js`.
|
||
|
||
### Eine Projektliste (tab `board`, labelled "Projekte")
|
||
|
||
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`, `+ TODOs aus Protokoll`) exist twice — in the Gantt
|
||
toolbar and above the list, the latter with an `…BtnList` id suffix;
|
||
`renderPlanPage()` hides both sets on locked weeks.
|
||
**"+ TODOs aus Protokoll"** (`#protokollTodosBtn`/`#protokollTodosBtnList`,
|
||
`js/plan/plan-protokoll-todos.js`) is a second, genuinely independent way
|
||
to turn a meeting protocol into tasks — Erik's explicit request: "ein
|
||
Feature... das die Protokolle per KI einliest und dann aus den
|
||
Protokollpunkten Aufgaben für die Aufgabenseite im Projekt erzeugt". It
|
||
is deliberately **unrelated to `project_protokolle`/`protokoll_punkte`
|
||
and to the Protokolle section** (`js/plan/plan-protokoll.js`) — Erik's
|
||
own words: "Es sollen externe Protokolle im PDF-Format sein, die durch
|
||
die KI eingelesen werden", i.e. an ad-hoc upload of a protocol that may
|
||
never become (or already exist as) a stored Protokoll of this project,
|
||
not a second entry point into the same table. Nothing is persisted on
|
||
either end: no `project_protokolle` row, no file under
|
||
`sessions/uploads/` — the PDF and every intermediate result live only in
|
||
the request/response and the open dialog, the same "ad-hoc tool, no
|
||
archive" model as the Plan Checker (`### Protokolle` below covers the
|
||
always-persisted Protokoll-Import; this is its lightweight opposite).
|
||
Three steps in one dialog (`#protokollTodosModal`), picked by Erik from a
|
||
three-way design comparison (Taste-Skill artifact, "A — Schritt-Dialog"
|
||
vs. a wide split-panel "Werkbank" vs. an inline card in the task list):
|
||
a persistent chrome — title, a 3-segment progress bar
|
||
(`#protokollTodosSteps`), and a fixed Zurück/Abbrechen/Weiter footer —
|
||
stays on screen across all three steps, with only
|
||
`#protokollTodosBody`'s content swapped per step (same "Container-Div
|
||
wird je Phase neu befüllt" idea `applyTemplate()`'s dialog already uses).
|
||
The right-hand "Weiter" button both advances the step AND fires that
|
||
step's network call in one click (`protokollTodosWeiter()`); "Zurück"
|
||
(`protokollTodosZurueck()`) only ever re-renders already-held module
|
||
state, no re-fetch — except a re-shown file `<input>` can't be
|
||
re-populated from script, so going back past step 0 means re-picking the
|
||
file. A genuine KI failure (`zeigeFehler()`, red) is kept visually
|
||
distinct from a successful-but-degraded fallback
|
||
(`zeigeFallbackHinweis()`, the same amber `.protokoll-ki-fallback-banner`
|
||
the Protokoll-Import uses) — a fallback is an accepted outcome, not an
|
||
error.
|
||
(1) upload a PDF + pick a provider → `POST
|
||
/plan-todos-aus-protokoll/einlesen` reuses `erkennePunkteMitKI()`/
|
||
`erkennePunkteAusText()` **unchanged** (server.js) to extract points,
|
||
returned straight to the client, nowhere written to a table; (2) the
|
||
extracted points appear as a checklist (every `status:'offen'` point
|
||
pre-checked, `erledigt`/`info` pre-unchecked but still selectable — one
|
||
surface covers both "whole protocol" and "just this one point"); the
|
||
checked point objects (already in the browser, no server round-trip to
|
||
refetch) go to `POST /plan-todos-aus-protokoll/aufgaben-vorschlag`,
|
||
which runs the **second**, independent KI step, `leiteAufgabenMitKI()` →
|
||
`leiteAufgabenMitChatGPT`/`leiteAufgabenMitClaude` (server.js, same
|
||
`fetchMitTimeout()`/`LOKAL_DISPATCHER`/`reasoning_effort` plumbing as
|
||
`erkennePunkteMitChatGPT`/`Claude`, own `KI_AUFGABEN_SCHEMA`/
|
||
`KI_AUFGABEN_SYSTEM_PROMPT`) — this step *judges* the points rather than
|
||
copying them: several points about the same action can collapse into
|
||
one task, a purely informational point yields none; on any KI failure
|
||
(or no provider chosen) the route falls back to a direct 1:1 mapping
|
||
(`erkennungsart:'direkt'`), the same "always succeed, possibly degrade"
|
||
policy as the Protokoll-Import; (3) the proposed tasks appear as a
|
||
compact row list (`renderVorschlaegeHtml()`) — Erik's pick from a second,
|
||
narrower Taste-Skill comparison scoped to just this step: a real test run
|
||
had already produced 19 proposals from one protocol, and a full card per
|
||
row would have been nothing but scrolling. Each row shows title plus a
|
||
Fälligkeit/Zuständig badge; the full description, date field, and
|
||
Zuständigen-Auswahl only appear once the row is tapped
|
||
(`.protokoll-todo-vorschlag.open`, `.pt-row-detail`). A selection bar
|
||
above the list (`N von M ausgewählt`, "Alle"/"Keine" —
|
||
`protokollTodosAuswahlAlle()`) avoids unchecking rows one by one at that
|
||
volume. Zuständig is pre-matched against `projectAssignees(app.project,
|
||
app.week)` by a simple case-insensitive name match,
|
||
`matchePersonenVonName()` — an external protocol's "Zuständig" is often
|
||
an outside firm with no match at all, which is the expected common case,
|
||
not a bug. Confirming pushes one `plan_todo` per checked row
|
||
straight into `app.ensurePlanLocal().todos` **with both `start` and
|
||
`end` always set** (today / the given Fälligkeit or today) — exactly
|
||
`applyTemplate()`'s/`saveTodoAusKommentar()`'s push shape — specifically
|
||
so every created task is a Gantt row immediately, not a dateless
|
||
checklist item (see the GANTT-row rule right above); `app.savePlan()`
|
||
then `fitPlanGanttRange()` (again mirroring `applyTemplate()`) so the
|
||
visible GANTT window actually includes the new bars instead of only
|
||
showing them in the plain Aufgaben list.
|
||
**A todo is only a Gantt row when it has BOTH a start and an end date
|
||
AND `showInGantt` is not explicitly `false`.**
|
||
`plan.todos` is the one shared list either way — every todo always shows
|
||
up here in Aufgaben, with its assignees (`t.assignees`, "Zuständige"
|
||
chips in `openTodoModal()`, unrelated to whether it has a schedule) — but
|
||
`plan-build-gantt.js` (the `buildGanttHtml()` extension that inserts
|
||
TODO/milestone rows under the project row) only inserts a row for a todo
|
||
with `todo.start && todo.end && todo.showInGantt !== false`; a
|
||
title-only todo (created via `+ Aufgabe` or `+ TODO` with the date range
|
||
left empty — nothing in `saveTodoEdit()` requires dates, only the title)
|
||
stays a pure checklist item that appears solely here, never in the
|
||
Gantt/Terminplan. The Aufgaben row for such a todo shows "ohne Termin" in
|
||
place of a date range (`.plan-task-no-schedule`). Giving it a start/end
|
||
later (editing the same todo through either view) makes a Gantt row for
|
||
it appear on the next render — same object, no separate "convert to
|
||
scheduled" action needed.
|
||
**`showInGantt`** (`plan_todos.show_in_gantt`, `DEFAULT 1`,
|
||
`migrateAddPlanTodoShowInGantt()`) is the independent half of that
|
||
condition — Erik's explicit request, after "TODOs aus Protokoll"
|
||
(`plan-protokoll-todos.js`) showed that a single import can easily create
|
||
15-25 dated tasks at once, which — before this field existed — were all
|
||
unconditionally forced into the Gantt as bars. A checkbox "Im Terminplan
|
||
(GANTT) anzeigen" in the TODO-edit dialog (`#todoEditShowInGantt`,
|
||
`App.html`, read/written in `openTodoModal()`/`saveTodoEdit()`,
|
||
`js/plan/plan-modals.js`) lets a *dated* task still exist (tracked under
|
||
Aufgaben, counted on "Meine Aufgaben", included in dependency-based
|
||
Auto-Schedule/critical-path) without drawing a bar. Checked by default;
|
||
`!== false` rather than `=== true` everywhere it's read, so a todo from
|
||
before this column existed (or any object missing the field) keeps
|
||
showing exactly as before — no retroactive behavior change. Carried
|
||
through "Speichern & Neu" (`openTodoModal(null, {..., showInGantt})`)
|
||
like the other frame fields (Zeitraum/Zuständige), so adding several
|
||
hidden-from-Gantt tasks in a row doesn't mean re-unchecking it every
|
||
time. **Not** touched by `plan-schedule.js`'s Auto-Schedule/critical-path
|
||
(`t.start && t.end`, unchanged) or `plan-gantt.js`'s ghost-date overlay —
|
||
a hidden todo is still a real, scheduled task for every purpose except
|
||
drawing its own bar.
|
||
`reorderPlanRows()` (`js/plan/plan-drag.js`) accounts for this: its
|
||
DOM-row-count safety check and the reordering itself only ever touch
|
||
todos that have `start && end && showInGantt !== false`
|
||
(`scheduledIds`) — exactly the same condition as the insertion above, so
|
||
the count of draggable DOM rows and `scheduledIds.size` never drift
|
||
apart. A title-only or Gantt-hidden todo has no drag handle in the Gantt
|
||
to begin with and keeps its exact position in `plan.todos` untouched by
|
||
any Gantt row drag.
|
||
- **Leistung** — Leistungsstand & Abrechnung (`renderPlanLeistung()`,
|
||
`js/plan/plan-leistung.js`): the value actually earned, per beauftragte
|
||
Leistungsphase, as **Honorar × HOAI-Anteil der Phase × Fortschritt der
|
||
Phase**. The shares come from `GET /honorar-config` and are normalised
|
||
across the beauftragte phases only (same as `berechneHonorarPlan()`), so a
|
||
project commissioned for three phases still adds up to 100 %. Nothing is
|
||
entered here: honorar and the beauftragte phases come from
|
||
"Einstellungen", the progress from the Wochenbericht — this section only
|
||
computes. Three cases deliberately show a hint instead of a number: no
|
||
honorar, no beauftragte phase, no shares stored. **This was the missing
|
||
link** — the app knew the honorar and it knew the progress per phase, but
|
||
never put the two together, so nobody could see what a project had earned.
|
||
It is its own section rather than a Dashboard card because the breakdown
|
||
has one row per phase with five columns; as a card it would either be
|
||
cramped or appear twice.
|
||
- **Dateien** — upload/review/comments (`js/plan/plan-files.js`), formerly a
|
||
modal.
|
||
- **Protokolle** — Besprechungsprotokolle (`js/plan/plan-protokoll.js`), see
|
||
"### Protokolle" below for the data model and the "Fortschreiben"
|
||
mechanism.
|
||
- **Einstellungen** — project number/name (display only — the page is
|
||
addressed by them), Gruppe, amtierende Leistungsphase (both still
|
||
rendered by `plan-info.js`), plus Honorar, the **beauftragte
|
||
Leistungsphasen** and the **Beteiligten-Matrix**
|
||
(`renderPlanSettings()`/`planCycleAssign()`, `js/plan/plan-settings.js` —
|
||
the Matrix moved here from a read-only `plan-info.js` display once it
|
||
became genuinely editable, see "Ein Ort je Angabe" above).
|
||
- `p.honorar`/`p.beauftragteLP` can now be edited in TWO places: here and in
|
||
the "Terminplan aus Honorar" dialog (which keeps them so it stays usable on
|
||
its own). Both write through `setHonorarBetrag()`/`toggleBeauftragteLp()` in
|
||
`js/plan/plan-honorar.js` and re-render the other mask afterwards, so they
|
||
cannot drift apart. Persistence is the ordinary `savePlan()` → `POST
|
||
/save-week` path (columns `projects.honorar` / table
|
||
`project_beauftragte_lph`) — no dedicated endpoint.
|
||
- `renderPlanGantt()` is the central refresh hook: it ends by calling
|
||
`renderPlanTasks()` and `renderPlanDashboard()`, so every existing mutation
|
||
path (drag, modals, auto-schedule, templates, honorar plan) updates all three
|
||
views without touching those modules.
|
||
- The 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.
|
||
|
||
## Bewegung (Hover/Klick-Rückmeldung)
|
||
|
||
The app's interaction feedback is **token-driven and lives in one place**:
|
||
five variables in `:root` (`styles.css`), applied by a clearly-marked
|
||
"BEWEGUNG" block at the **end** of `styles.css` plus a smaller companion
|
||
block at the end of `plan-styles.css` for the Projektbereich-only row types.
|
||
Every rule reads through `var()`; changing the feel is a change to those
|
||
five values, not to the components.
|
||
|
||
```
|
||
--dur: 150ms kurze Rückmeldung (Fläche, Farbe, Anheben, Druck)
|
||
--dur-lg: 200ms grössere Zustandswechsel (Dialog, Aufklappen)
|
||
--ease-out cubic-bezier(.2,.8,.2,1), schnell los / weich aus
|
||
--row-lift: -1px Hub einer anklickbaren Zeile beim Zeigen
|
||
--press: .97 Stauchung beim Drücken
|
||
```
|
||
|
||
Erik picked this level ("Spürbar") from a three-way live comparison
|
||
(dezent / spürbar / lebendig) built with the Taste-Skill. **Level 3's
|
||
entrance animations were deliberately NOT taken**: with ~30 projects in a
|
||
list that re-renders on every save, rows animating in on each render is
|
||
unrest, not polish. The rule that follows from that: the surface moves
|
||
**only in response to pointing or clicking**, never on its own, and never
|
||
in a loop.
|
||
|
||
- **Row lift + accent rail** on clickable rows (`.board-row-main`,
|
||
`.bericht-rail-item`, `.planung-row`, `.dash-rand-zeile`, and in
|
||
`plan-styles.css` `.todo-depends-item`, `.pt-row-main`, `.plan-task-row`).
|
||
The rail is an **`inset` box-shadow growing 0 → 3px**, deliberately NOT a
|
||
`::before` with `position:absolute`: `.board-row` is `draggable`
|
||
(`js/projects/projects.js`), and an extra positioned element or a new
|
||
stacking context on a dragged row can shift the drag image and hit
|
||
targets. An inset shadow touches neither layout nor stacking. For the
|
||
same reason the transform sits on the **inner** `.board-row-main`, never
|
||
on the draggable `.board-row` itself (verified: the draggable element
|
||
keeps `transform: none`).
|
||
- **`.protokoll-liste-zeile` is the one exception** and must stay one: it
|
||
already carries `border-left: 3px solid transparent`, which its
|
||
`.ist-aktiv` state colours. It therefore animates *that existing border*
|
||
instead of adding the inset shadow. Using both would paint 6 px of accent
|
||
instead of 3.
|
||
- **Dialogs** use `animation`, not `transition`: `.modal-bg` is toggled
|
||
`display:none` ⇄ `flex` from JS, and a `display` change is not
|
||
transitionable, whereas an animation restarts every time the element is
|
||
rendered again (`modal-bg-auf` / `modal-auf`).
|
||
- **`prefers-reduced-motion` was missing from this codebase entirely** and
|
||
is now handled at the end of `styles.css`. Because the level is
|
||
token-driven, the block simply flips the tokens (`--row-lift: 0px`,
|
||
`--press: 1`, durations to 1ms); a blanket
|
||
`transition-duration/animation-duration: 1ms !important` additionally
|
||
catches the ~60 older rules that still hard-code their own timings.
|
||
- **Keyboard focus** got explicit `:focus-visible` outlines on the same
|
||
rows/chips. The lift and press effects answer mouse and touch only;
|
||
without this the rework would have left keyboard use worse off than
|
||
before.
|
||
- **The Wandtafel (`body.wandtafel-aktiv`) is deliberately excluded** — a
|
||
kiosk touch monitor has no hover, and that screen carries its own
|
||
`--dash-*` design anyway.
|
||
|
||
## 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). |