- Meine Aufgaben: Projekte in der Reihenfolge der Projektliste, je Konto per Ziehen umsortierbar (users.dashboard_reihenfolge) - Projektliste: Öffnen/Löschen hinter einem "⋯"-Menü - Avatare je Person statt je Rolle; mehrere Rollen als senkrechte Farbstreifen; Titelleiste zieht Rollenänderungen sofort nach - Nach dem Anmelden vollständiges Konto über /whoami laden (Seitenleiste zeigte sonst erst nach Neuladen alle Einträge) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4624 lines
309 KiB
Markdown
4624 lines
309 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
|
||
├── pdf-arbeit.js # PDF lesen/rendern im Kindprozess (siehe "Betrieb auf dem Pi")
|
||
├── package.json # deps: express, cors, plus pdfjs-dist/mammoth
|
||
│ # (added for Protokoll-Import, see "### Protokolle")
|
||
├── .npmrc # omit=optional — hält pdfjs-dist's rein optionales
|
||
│ # @napi-rs/canvas (nur für Seiten-Rendering, hier
|
||
│ # ungenutzt) aus node_modules heraus
|
||
├── docker-compose.yml # at repo ROOT; build context ., dockerfile Docker/Dockerfile
|
||
├── Docker/Dockerfile # node:22-alpine (nötig für node:sqlite)
|
||
├── .dockerignore
|
||
├── public/
|
||
│ ├── App.html # main app (served at /)
|
||
│ ├── admin-users.html # standalone admin-only user management page
|
||
│ ├── js/ # shared modules (classic scripts, global fns)
|
||
│ │ ├── globals.js # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS, TAB_SLUGS) + db/currentDepartment
|
||
│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName,
|
||
│ │ │ # peopleByRole, assignKey, weekSortKey +
|
||
│ │ │ # renderProjektVerlauf() (die EINE Verlaufs-
|
||
│ │ │ # Darstellung, von allen drei Stellen genutzt)
|
||
│ │ ├── ui.js # LocalStorage-UI-State, Header-Collapse, Theme
|
||
│ │ ├── date-picker.js # Datums-/Bereichspicker-Overlays
|
||
│ │ ├── server.js # baseUrl, sendWeekToServer, deleteWeekFromServer, blobToDataUrl (global people list has no client write path anymore, see Persistence)
|
||
│ │ ├── auth.js # currentUser, checkAuthAndInit (Login-Overlay), logout, renderUserBar
|
||
│ │ ├── store.js # save/syncAllToServer, switchDepartment, load, seed
|
||
│ │ ├── weeks.js # Wochen-Auswahl, Auto-Lock, -Löschung, unlockCurrentWeek (POST /unlock-week) —
|
||
│ │ │ # Erzeugung/Klonen entfallen, siehe "Automatischer Sitzungswechsel"
|
||
│ │ ├── gantt.js # GANTT-Diagramm (buildGanttHtml, openProjectPlan), geshared
|
||
│ │ ├── tabs-auth.js # showTab (5 Tabs; kein Passwortschutz mehr, siehe Auth-Abschnitt)
|
||
│ │ ├── pdf.js # drei PDF-Exporte (pdfmake): exportWochenberichtPDF()
|
||
│ │ │ # ist die Tagesordnung als Bericht je Gewerk — Übersicht
|
||
│ │ │ # + eine Akte je Projekt (A4 hochkant), exportSandboxPlanPDF()
|
||
│ │ │ # der Balkenplan eines Planung-Terminplans (A3 quer),
|
||
│ │ │ # exportProtokollPDF() das Besprechungsprotokoll eines
|
||
│ │ │ # Projekts (A4 hochkant, Punkte gruppiert, neue gelb)
|
||
│ │ ├── projekt-wizard.js # Assistent "Neues Projekt" (sechs Schritte,
|
||
│ │ │ # Gewerke, gefiltertes Team, Terminplan aus Honorar)
|
||
│ │ ├── auftrag.js # Auftragsstatus (Anfrage→Angebot→beauftragt→
|
||
│ │ │ # abgeschlossen), Chip/Auswahlfeld und der
|
||
│ │ │ # Beauftragungs-Dialog
|
||
│ │ ├── benachrichtigungen.js # Glocke im Menüband + "Neu zugewiesen" auf dem Dashboard
|
||
│ │ ├── backup.js # downloadBackup (lädt SQLite-Snapshot von GET /backup)
|
||
│ │ ├── import.js # importDb (Backup-Wiederherstellung über POST /import, admin-only)
|
||
│ │ └── init.js # renderAll/initUiState + Seiteneinstieg hinter checkAuthAndInit() (nur App.html)
|
||
│ │ ├── board/ # tab-specific: die Projektliste ("Projekte")
|
||
│ │ │ └── board.js # Projektlisten-Rendering (Anzeige, BOARD_OPTS
|
||
│ │ │ # .nurAnsicht), Reihenfolge/Löschen und der
|
||
│ │ │ # Kommentar-Editor (Textfeld mit Verfasser/Zeitstempel,
|
||
│ │ │ # ausdrücklichem Speichern und scrollbarem Verlauf der
|
||
│ │ │ # früheren Sitzungen; auch vom Wochenbericht genutzt —
|
||
│ │ │ # Bedienelemente adressieren ihr Feld über closest(),
|
||
│ │ │ # nicht über IDs, da beide Tabs dieselben Projekte
|
||
│ │ │ # rendern). Kürzel-Legende und Anwesenheit sind hier
|
||
│ │ │ # entfallen (Anwesenheit siehe wochenbericht/)
|
||
│ │ ├── wochenbericht/ # tab-specific: Wochenbericht (Tagesordnung des
|
||
│ │ │ # wöchentlichen Teammeetings — dort wird JEDES
|
||
│ │ │ # Projekt angesehen, Vollständigkeit vor Filterung)
|
||
│ │ │ └── wochenbericht.js # Gruppierung "Neu kommentiert"/"Fortschritt"/
|
||
│ │ │ # "Unverändert" per project_uid-Vergleich zur Vorwoche
|
||
│ │ │ # (nutzt renderBoardDrawer/renderBoardRow aus board.js;
|
||
│ │ │ # die Karten tragen ein farbiges Kopfband je
|
||
│ │ │ # Änderungsart — .report-card-neu/-fortschritt,
|
||
│ │ │ # Farben in --band-neu/--band-fortschritt — und
|
||
│ │ │ # stellen die Schubladen-Blöcke dreispaltig; zeigen
|
||
│ │ │ # über BERICHT_OPTS nur die beauftragten
|
||
│ │ │ # Leistungsphasen, Rückfall auf alle wenn leer)
|
||
│ │ │ # + renderProjectTimeline(): der aufklappbare
|
||
│ │ │ # "Verlauf" je Projekt — die Kacheln der früheren
|
||
│ │ │ # Sitzungen waagerecht und scrollbar, mit
|
||
│ │ │ # Kommentar, LPH-Ständen, Terminen aus dem Plan
|
||
│ │ │ # und Beteiligten JENER Woche; gegenüber der
|
||
│ │ │ # Kachel davor geänderte Werte hervorgehoben
|
||
│ │ │ # + Anwesenheitserfassung am Seitenfuß
|
||
│ │ │ # (renderAttendance/toggleAttendance/setAllAttendance,
|
||
│ │ │ # binär an/abwesend über currentWeek().people mit Zähler)
|
||
│ │ ├── people/ # tab-specific: Projektbeteiligte
|
||
│ │ │ └── people.js # read-only Personen-Liste (aus db.globalPeople, kein CRUD/Drag-
|
||
│ │ │ # and-Drop mehr) + abteilungsübergreifende Besetzung (admin-only)
|
||
│ │ ├── projects/ # Ablage-Logik der Projektliste (der eigene
|
||
│ │ │ └── projects.js # Projekte-Tab ist entfallen): Drag-and-Drop,
|
||
│ │ │ # reorderProjects(), delProjectFromList();
|
||
│ │ │ # renderProjects() rendert nur noch, wenn
|
||
│ │ │ # #projectsList existiert
|
||
│ │ ├── load/ # tab-specific: Auslastung
|
||
│ │ │ └── load.js # Auslastungs-Tab als LASTBAND (Zeile je Person
|
||
│ │ │ # und Rolle, Balken nach Projekten aufgeteilt),
|
||
│ │ │ # LP-Modal, Neues-Projekt-Modal
|
||
│ │ │ # (inkl. Auswahl der beauftragten Leistungsphasen,
|
||
│ │ │ # p.beauftragteLP — dieselbe Liste wie im
|
||
│ │ │ # Honorar-Dialog der Projektplan-Seite)
|
||
│ │ ├── dashboard/ # tab-specific: Meine Aufgaben
|
||
│ │ │ └── dashboard.js # renderDashboard (GET /my-dashboard: offene TODOs + ausstehende Freigaben)
|
||
│ │ ├── wand/ # tab-specific: Wandtafel (Touch-Dashboard, ersetzt Show Mode)
|
||
│ │ │ └── wand.js # renderWandboard — rein client-seitige Kacheln aus db (kein eigener Endpunkt)
|
||
│ │ ├── settings/ # tab-specific: Einstellungen
|
||
│ │ │ └── settings.js # NUR "Mein Konto": Stammdaten + Passwortwechsel
|
||
│ │ ├── admin/ # tab-specific: Admin (nur Administratoren)
|
||
│ │ │ └── admin.js # Abschnitte Honorar, Auslastung (Schwellen/Skala/
|
||
│ │ │ # Aufwandsvorlage), Termine & Sitzungen, Feiertage,
|
||
│ │ │ # Aufgaben-Vorlagen, Betrieb & Sicherheit,
|
||
│ │ │ # Datenbank & Sitzungen (Backup/Import/Löschen)
|
||
│ │ └── admin-users.js # Abschnitt "Benutzerverwaltung" des Bereichs #admin
|
||
│ │ # (renderAdminUsers, CRUD via /admin/users*)
|
||
│ ├── plan-styles.css # Stile des Projektbereichs (früher <style> in project-plan.html)
|
||
│ ├── js/plan/ # Module des Projektbereichs (#plan in App.html)
|
||
│ │ ├── plan-state.js # PlanApp-Zustand, Datenladepath, savePlan, Toasts
|
||
│ │ ├── plan-sections.js # Bereichs-Navigation (showPlanSection, URL-Fragment)
|
||
│ │ │ # + Dashboard-Karten (renderPlanDashboard)
|
||
│ │ ├── plan-info.js # Status/Kommentar, Gruppe, amtierende LPH, LPH-%
|
||
│ │ │ # (Dashboard-Bereich; die Beteiligten-Matrix ist
|
||
│ │ │ # entfallen, siehe plan-settings.js)
|
||
│ │ ├── plan-settings.js # Bereich "Einstellungen": Projektangaben, Honorar,
|
||
│ │ │ # beauftragte Leistungsphasen, Beteiligten-Matrix
|
||
│ │ │ # (renderPlanSettings, planCycleAssign)
|
||
│ │ ├── plan-phasen.js # Bereich "Leistungsphasen": Ablaufbalken + Kachel
|
||
│ │ │ # je Phase (renderPlanPhasen), aufgabenPhase()
|
||
│ │ ├── plan-tasks.js # Bereich "Aufgaben": Aufgaben-/Meilensteinliste
|
||
│ │ │ # (renderPlanTasks; öffnet dieselben Dialoge wie das GANTT)
|
||
│ │ ├── plan-history.js # Kommentar-Verlauf über alle Sitzungen (+ Vorwochen-Helfer)
|
||
│ │ ├── plan-templates.js # Aufgaben aus LPH-Vorlagen (lph_templates)
|
||
│ │ ├── plan-protokoll-todos.js # "+ TODOs aus Protokoll": externes PDF
|
||
│ │ │ # per KI einlesen + Aufgaben daraus ableiten —
|
||
│ │ │ # unabhängig von plan-protokoll.js, nichts
|
||
│ │ │ # davon wird gespeichert
|
||
│ │ ├── plan-mail-eintrag.js # "Termin aus Mail": Drag & Drop/Strg+V → KI → Aufgaben-Panel
|
||
│ │ ├── plan-honorar.js # Terminplan aus Honorar + beauftragten LPH errechnen
|
||
│ │ ├── plan-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen)
|
||
│ │ ├── plan-gantt.js # renderPlanGantt, Abhängigkeitslinien, Zoom, Ressourcen-Load
|
||
│ │ ├── plan-drag.js # GANTT-Balken-Drag/Resize + TODO-/Meilenstein-Zeilen-Drag
|
||
│ │ ├── plan-modals.js # TODO-, Meilenstein- und Abwesenheits-Modals; der
|
||
│ │ │ # TODO-Dialog vereint "Erledigt"+Fortschritt in einem
|
||
│ │ │ # Status-Segment (offen/arbeit/fertig -> done+progress),
|
||
│ │ │ # Zuständige als Personen-Chips, Vorgänger eingeklappt
|
||
│ │ │ # mit Suche und gesperrten Zirkelbezügen; Esc/Strg+Enter
|
||
│ │ │ # und Hintergrund-Klick zentral für alle drei Dialoge
|
||
│ │ ├── plan-leistung.js # Bereich "Leistung": erbrachter Leistungswert
|
||
│ │ │ # (Honorar × HOAI-Anteil × Fortschritt je Phase)
|
||
│ │ ├── plan-files.js # Datei-Upload/-Liste/-Freigabe/-Kommentare (project-files-Endpunkte)
|
||
│ │ ├── plan-protokoll.js # Bereich "Protokolle": Besprechungsprotokolle
|
||
│ │ │ # je Projekt, mit Fortschreiben offener Punkte
|
||
│ │ │ # und gelber Neu-Markierung (project-protokolle-Endpunkte)
|
||
│ │ └── plan-schedule.js # Kritischer Pfad, Auto-Schedule, Mail-Link
|
||
│ ├── styles.css
|
||
│ ├── glas.css # Glas-Darstellung (html.glas), siehe "Glas-Darstellung"
|
||
│ ├── logo.svg # vektorisiertes Logo (Kopfzeile, Login, Wasserzeichen)
|
||
│ └── logo.ico
|
||
└── sessions/ # SQLite-DB; bind-mounted into Docker container
|
||
└── projektuebersicht.db # node:sqlite database (WAL mode)
|
||
```
|
||
|
||
## Running
|
||
|
||
```bash
|
||
npm install
|
||
npm start # http://localhost:3003, or PORT=3004 if occupied
|
||
docker compose up --build # Docker variant (compose file is at repo root)
|
||
```
|
||
|
||
- PORT env var overrides default 3003 in `server.js`.
|
||
- Frontend must be served over HTTP: `baseUrl()` in `js/server.js` uses
|
||
`window.location.origin` or falls back to `http://localhost:3003` when opened
|
||
via `file://` (then only seed/demo data loads).
|
||
- Check `/health` returns `{ok:true}` after starting.
|
||
|
||
## Departments were retired (read this before trusting "department" elsewhere)
|
||
|
||
This file historically described `ELT`/`HLS`/`ARCH` as three fully separate
|
||
partitions — each with its OWN `weeks`, `projects`, and people list, switched
|
||
via a sidebar "Abteilung" selector and `switchDepartment()`. **That is no
|
||
longer true.** `migrateRemoveDepartmentPartition()` (`server.js`, runs
|
||
idempotently at every startup, guarded on `weeks` still having a `department`
|
||
column) merged all per-department weeks sharing the same label into one
|
||
global week (and merged same-`nr` projects across the former departments into
|
||
one project, tagging every former department as a `project_tags` entry —
|
||
confirmed deterministically, ELT→HLS→ARCH precedence, nothing silently
|
||
dropped). There is now **one shared week/project/people list**, full stop:
|
||
`GET /load-all` and `POST /save-week` take no `department` param,
|
||
`weeks`/`projects`/`week_people`/`attendance` have no `department` column,
|
||
`switchDepartment()` and the `.dept-tab` sidebar no longer exist client-side,
|
||
and `currentDepartment` is gone.
|
||
|
||
`VALID_DEPARTMENTS`/`DEPARTMENTS` (`ELT|HLS|ARCH`) still exist, but now mean
|
||
two different, narrower things:
|
||
- **A user's own discipline** — `users.department`, maintained in
|
||
`admin-users.html`, used for staffing/team pickers.
|
||
- **A project's discipline(s)** — `project_gewerke` (one or more per
|
||
project), see "Wochenbericht — Gewerke-Trennung" below.
|
||
|
||
**Known documentation debt:** many mentions of "department"/"Abteilung"
|
||
further down in this file — `db.globalPeople[currentDepartment]`,
|
||
`openProjectPlan()` building `/HLS/Projektplan/…`, `switchDepartment()`
|
||
behavior, the "Eine Projektliste"/"Projektbeteiligte" sections, etc. — still
|
||
describe the pre-migration, per-department architecture and were not swept
|
||
in the same pass that added this note. Treat any such mention as unverified
|
||
until checked against the actual code; do not assume it still applies.
|
||
|
||
## Automatischer Sitzungswechsel (read this before trusting "newWeek"/"cloneWeek" elsewhere)
|
||
|
||
Sessions ("Sitzungen", `weeks.label` = "KW XX / YYYY") are still the backbone
|
||
of the whole app — Wochenbericht, Board and Auslastung all key off the
|
||
current one, and the Wochenbericht's own week-over-week comparison/Verlauf
|
||
features (`renderProjektVerlauf()`, `classifyProjectChange()`, "Vergleich mit
|
||
KW…") depend on there being a fresh session every week. What changed: **a new
|
||
session is no longer a user action.** It used to be `newWeek()`/`cloneWeek()`
|
||
(`public/js/weeks.js`), triggered by two buttons in the Werkzeuge sidebar
|
||
group. Both are gone — on Erik's explicit call: "eine Sitzung stellt eine
|
||
Woche dar… kommt eine neue Woche kommt auch eine neue Sitzung unabhängig von
|
||
den Wochenberichten" (a session should follow the calendar, not whether
|
||
anyone happens to open the Wochenbericht that week), and the transition
|
||
should be **completely silent** — no toast, no confirmation.
|
||
|
||
**`ensureCurrentWeekRollover()` (`server.js`) replaces both functions,
|
||
server-side.** It runs once right after the HTTP server starts listening
|
||
(catches a rollover that should have happened while the server was down) and
|
||
then every hour via `setInterval` — deliberately NOT tied to any route or
|
||
tab, so a session change happens even if nobody ever opens the app that
|
||
week. (This is the first recurring background job in this codebase; there
|
||
was no prior pattern to mirror.) On each run: compute today's ISO
|
||
calendar-week/year (`getIsoWeekInfo()`, a straight port of `getWeekNumber()`
|
||
from `public/js/utils.js`, extended to also return the ISO week-*year*, which
|
||
can differ from the calendar year right at year boundaries); build the label
|
||
via `formatWeekLabel()` ("KW 05 / 2026" — the exact shape both the old
|
||
`validateWeekLabel()`'s regex and `weekSortKey()`/`weekSortKeyServer()`'s
|
||
parsing expect); compare against the chronologically latest **real** (i.e.
|
||
non-`SANDBOX_WEEK_LABEL`) session via the already-existing
|
||
`weekSortKeyServer()` (previously only used by `/my-dashboard`'s fallback).
|
||
If the calendar hasn't moved into a new week since then, it's a no-op — the
|
||
function is safe to call as often as the interval likes, and safe to run
|
||
again on every server restart without ever creating a duplicate.
|
||
|
||
**When a new week IS due, the reset is a deliberate, exact port of what
|
||
`cloneWeek()` used to do client-side** — same fields, same rationale, just
|
||
executed server-side against a plain JS object built from `loadWeeks()`
|
||
(itself already callable with no HTTP context) and written via
|
||
`saveWeekRelational()`/`serializeWeek()` (also already pure DB-writing
|
||
functions with zero dependency on `req`/`req.user` — confirmed by reading
|
||
both top to bottom before relying on it): deep-clone the latest real week,
|
||
give it a fresh `id` (`crypto.randomUUID()`, matching the ID scheme already
|
||
used elsewhere in server.js) and the new label/`date`
|
||
(`heuteIsoLokal()`), reset `attendance={}`, `locked=false`, drop `updatedAt`
|
||
(a carried-over revision would make `/save-week`'s conflict check reject the
|
||
brand-new label — moot here since this bypasses that route entirely, but the
|
||
field is dropped anyway for cleanliness); per project, a fresh
|
||
`id` (`crypto.randomUUID()`) and cleared `status`/`statusAuthor`/`statusAt`/
|
||
`besprochen`/`statusState`. Everything else — Beteiligte, `lph`, `assign`,
|
||
the whole `plan` (todos/milestones keep THEIR OWN ids unchanged, only the
|
||
owning project's id changes), `project_uid`, `honorar`, `beauftragteLP`,
|
||
`gewerke`, tags — carries over untouched, exactly as `cloneWeek()` always
|
||
left it.
|
||
|
||
**Locking**: there was, and is, no server-side equivalent of
|
||
`applyAutoLock()` (still client-side-only, still exactly as before). The
|
||
rollover replicates its invariant itself instead of relying on some client
|
||
being open to do it: once the new week is saved, if
|
||
`app_settings.autoLockAktiv` is on (read via the already-existing
|
||
`readAppSettings()`), every other real week gets `locked=1` in one `UPDATE`.
|
||
Turning `autoLockAktiv` off does not stop new sessions from being created —
|
||
only stops them from locking everything else, same distinction the setting
|
||
already made for `applyAutoLock()`.
|
||
|
||
**Fresh-install edge case**: if there is no real week at all yet (nothing to
|
||
clone), the rollover falls back to building an empty one — global people
|
||
list, no projects — mirroring what `newWeek()` used to produce, so a brand
|
||
new deployment still ends up with a session without needing a button to
|
||
press.
|
||
|
||
**Verified live** against a scratch copy of the production DB before ever
|
||
touching the real one (the real database's latest session, KW 33/2026,
|
||
predated "today" by seven ISO weeks — restarting the server for real was
|
||
always going to trigger a real jump to KW 40/2026, so correctness was
|
||
checked against a copy first): all 29 real projects carried over with fresh
|
||
project ids, comments/`besprochen` cleared, LPH values and `plan_todos`
|
||
(with their original todo ids) preserved byte-for-byte, older sessions
|
||
correctly locked, a second server start produced no duplicate, and disabling
|
||
`autoLockAktiv` left the previous latest week unlocked as expected.
|
||
|
||
`newWeek()`, `cloneWeek()`, and `validateWeekLabel()` are gone from
|
||
`public/js/weeks.js` (confirmed unreferenced anywhere else before removal).
|
||
**Known documentation debt**, same caveat as the department note above: this
|
||
file still has scattered comments elsewhere (in `board.js`, `utils.js`,
|
||
`wochenbericht.js`, `plan-state.js`, `plan-history.js`,
|
||
`plan-build-gantt.js`, and a few spots in this very file) that name
|
||
`cloneWeek()`/`newWeek()` as the live mechanism for "project ids change every
|
||
week"/"a new session starts with an empty comment" and similar — the
|
||
underlying *facts* they describe are still true, only the mechanism producing
|
||
them moved to `ensureCurrentWeekRollover()`. Treat a `cloneWeek()`/`newWeek()`
|
||
mention elsewhere as describing that historical mechanism, not code that
|
||
still exists, until it's been swept.
|
||
|
||
## Server API (`server.js`)
|
||
|
||
Auth levels (see "Auth / security quirk" below for the mechanism): **Public**
|
||
(no session needed) / **Auth** (any valid session) / **Admin** (`role='admin'`,
|
||
via `requireRole('admin')`). HTML shell routes stay Public — they only ever
|
||
serve static markup, never data; the actual security boundary is the 401/403
|
||
on the JSON endpoints.
|
||
|
||
- `GET /` → `App.html`; `GET /health`. Public.
|
||
- Deep links per department + tab: `GET /<Dept>/<Tab>` → `App.html`, e.g.
|
||
`/HLS/Auslastung` or `/ELT/Projektübersicht`. Public. Tab slugs
|
||
(`TAB_SLUGS` in `js/globals.js`, `VALID_TAB_SLUGS` in `server.js`) are
|
||
`Projektübersicht|Wochenbericht|Auslastung|Projekte|Projektbeteiligte|MeineAufgaben|Einstellungen|Admin`;
|
||
anything else returns 404. Exception: `GET /<Dept>/Projektplan(/<ProjName>)?`
|
||
(`PLAN_SLUG`) serves `App.html` as well (the Projektbereich is a tab of it
|
||
now, selected client-side by `applyUrlRoute()`), with
|
||
`weekLabel|projNr` as query params (the project name is a readable path
|
||
segment only; identification happens via weekLabel + projNr). The frontend
|
||
reads/writes these routes via `applyUrlRoute()`/`updateRouteUrl()` in
|
||
`js/ui.js` and all static assets in the HTML use **absolute** paths
|
||
(`/js/...`, `/styles.css`) so they resolve on nested routes. `admin-users.html`
|
||
itself is just a static file under `public/` (no dedicated server route/slug).
|
||
- `POST /login {username,password}` → Public. Verifies against `users` (scrypt,
|
||
see Auth section), creates a `user_sessions` row and sets the session cookie.
|
||
Same generic error for wrong username, wrong password, or inactive account
|
||
(no user enumeration).
|
||
- `POST /logout` → Auth. Deletes the current session row, clears the cookie.
|
||
- `GET /whoami` → Public. Returns `{ok:true, user: req.user || null}` — the
|
||
basis for the client-side login overlay (`checkAuthAndInit()`).
|
||
- `POST /change-password {currentPassword, newPassword}` → Auth. Requires the
|
||
current password even when `must_change_password` is set; new password ≥ 8
|
||
chars. Its UI is `#passwordModal` (`js/auth.js`) — reachable from
|
||
Einstellungen → Mein Konto, and shown non-dismissibly right after login when
|
||
the account carries `must_change_password`. Until that dialog existed the
|
||
endpoint had **no** caller at all: every new and every reset account got the
|
||
flag, `admin-users.html` displayed "Passwortwechsel erforderlich", and
|
||
nobody could act on it.
|
||
- `POST /change-email {email}` → Auth. Scoped to `req.user.id` (like
|
||
`/change-password`, not a body-supplied user id), no admin role needed.
|
||
Rejects an empty value with 400; no format validation beyond trimming
|
||
(same permissiveness as the admin path below). Its UI is the inline
|
||
E-Mail field in Einstellungen → Mein Konto (`js/settings/settings.js`,
|
||
`submitEmailChange()`) — until this route existed the address was
|
||
view-only there and only an admin could set it via `PATCH
|
||
/admin/users/:id`; an admin can still set/correct it there, e.g. for a
|
||
colleague who hasn't logged in yet.
|
||
- `GET /admin/users` → Admin. Lists all users (incl. inactive) with their
|
||
`roles` (discipline roles from `user_roles`, see below), without password
|
||
fields or absences (those are loaded separately, see next bullet).
|
||
- `GET /admin/users/:id/absences` → Admin. Lists one user's absences
|
||
(`id, start, end, type, note`, sorted by `start`) from `user_absences` —
|
||
loaded on demand by the admin-users.js edit modal, not embedded in
|
||
`GET /admin/users` (would bloat the list response).
|
||
- `POST /admin/users {username, displayName, email?, role?, personId?, department, initials?, roles?, password?, generatePassword?}`
|
||
→ Admin. Creates a user with `must_change_password=1`; `department` is
|
||
**required** (must be a `VALID_DEPARTMENTS` value, 400 otherwise — empty is
|
||
only tolerated for the pre-existing bootstrap admin, not for new accounts);
|
||
`initials` is the short board-column label, stored as given (empty is
|
||
allowed — `loadGlobalPeople()` falls back to `username` at read time, see
|
||
Persistence); `roles` is an array validated against
|
||
`VALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwachung'])`
|
||
(400 on an unknown role) and written to `user_roles`. Password either given
|
||
explicitly (≥ 8 chars) or auto-generated (`generatePassword:true`, returned
|
||
once as `generatedPassword`).
|
||
- `PATCH /admin/users/:id {displayName?, email?, role?, active?, personId?, department?, initials?, roles?}` →
|
||
Admin. Partial update; same `department`/`roles` validation as `POST`
|
||
(only when the field is present); `roles` is a replace-all (delete then
|
||
re-insert `user_roles`, same pattern as the old `serializeGlobalPeople`).
|
||
An admin can't deactivate or demote their own account. Deactivating a user
|
||
immediately deletes all their sessions. A `person_id` collision (now
|
||
globally unique, see Persistence) is caught and returned as 409.
|
||
- `POST /admin/users/:id/reset-password` → Admin. Sets a new random password,
|
||
forces `must_change_password=1`, deletes all sessions of that user (forces
|
||
re-login), returns the plaintext once as `generatedPassword`.
|
||
- `DELETE /admin/users/:id[?hard=1]` → Admin. Default is a soft-delete
|
||
(`active=0` + delete sessions). `?hard=1` (`loescheBenutzerEndgueltig()`,
|
||
one transaction) permanently deletes the account **and, on Erik's request,
|
||
removes the person from the projects and thereby the Auslastung — but only
|
||
in the current session (latest real week) and the Planung sandbox**; older
|
||
sessions are history and stay untouched. Removed there: `project_assign`,
|
||
`plan_todo_assignees`, `plan_people`, `week_people`/`_roles`,
|
||
`attendance`, matched via `personenKennungenInWoche()` = `users.person_id`
|
||
plus every id in that week's `week_people` with the same full name
|
||
(legacy short ids like `p_ap`). Those weeks get a new `updated_at`, so a
|
||
stale browser gets a 409 instead of writing the person back. Files are no
|
||
longer a blocker: uploads/comments stay and show as "Gelöschter Benutzer"
|
||
(`COALESCE(..., 'Gelöschter Benutzer')` in the file queries), the person's
|
||
review assignments are deleted and pending files' status recomputed.
|
||
Response includes counts (`zuordnungen`, `aufgaben`, `pruefauftraege`).
|
||
An admin can never delete their own account (soft or hard).
|
||
- `GET /users` → Auth (not admin-only). Lists active users
|
||
(`id, username, displayName, email, personId, department, initials, roles`)
|
||
— used both by the reviewer-picker in the file upload UI and by the
|
||
cross-department staffing picker in the (now read-only) Projektbeteiligte
|
||
tab (`js/people/people.js`) — deliberately separate from `/admin/users`,
|
||
which is admin-only and exposes more fields/inactive accounts.
|
||
- `POST /users/:id/absences {start, end, type, note?}` → Auth (not
|
||
admin-only — matches the previous permission level: any logged-in user
|
||
maintains absences in the Projektbeteiligte tab / on the Gantt page).
|
||
Requires `start`/`end` (400 otherwise); server generates the absence `id`
|
||
(`crypto.randomUUID()`).
|
||
- `DELETE /users/:id/absences/:absenceId` → Auth. Idempotent (no error if the
|
||
absence is already gone).
|
||
- `GET /load-all` → Auth. `{weeks:[weekObj], people:[personObj], sandboxPlans:[projectObj], sandboxUpdatedAt}` — **no
|
||
`department` param**: `migrateRemoveDepartmentPartition()` (see "Departments
|
||
were retired" below) dissolved the former per-department partitioning, so
|
||
this returns the one shared set of weeks and the one shared people list
|
||
(`loadGlobalPeople()`, see Persistence — still filtered to `active=1`, just
|
||
not to a department anymore); weeks are full JSON week objects (id, label,
|
||
date, locked, updatedAt, people, attendance, projects), sorted by label.
|
||
`sandboxPlans`/`sandboxUpdatedAt` are the reserved Planung-Sandbox "week"
|
||
(`SANDBOX_WEEK_LABEL`) split OUT of `weeks[]` — see "Planung —
|
||
projektunabhängige Terminplan-Sandbox" below — so it can never leak into
|
||
any code that iterates `weeks`.
|
||
- `POST /save-week {week, baseUpdatedAt}` → Auth. Upserts one week (JSON
|
||
object, keyed by label only). Used by `sendWeekToServer()`. The
|
||
client sends its local revision in `baseUpdatedAt` (the `updatedAt` value
|
||
from `load-all`); the server compares it to `weeks.updated_at` before writing
|
||
and answers `409 {ok:false, conflict:true, error, updatedAt}` if someone else
|
||
saved the week in the meantime. A save with a **non-empty** base for a week
|
||
the server no longer knows is also rejected with 409 ("wurde gelöscht") so a
|
||
deleted week can't silently resurrect; only new weeks (empty/missing base)
|
||
pass. On success it returns `{ok:true, updatedAt}` (the new revision), which
|
||
the client stores as the new sync base. Clients WITHOUT `baseUpdatedAt` (old
|
||
versions) always pass. The check runs synchronously in-process before the
|
||
write transaction (no race between check and write).
|
||
- `POST /delete-week {department, label}` → Admin. Deletes the week; missing
|
||
label yields 400, missing week tolerates with `deleted:false`.
|
||
- `POST /unlock-week {department, label}` → Admin. Unlocks a locked week
|
||
(`locked=0`, new `updated_at` revision, same convention as `/save-week`) —
|
||
replaces the formerly client-only unlock logic
|
||
(`unlockCurrentWeek()`/`js/weeks.js`); 404 if the week doesn't exist.
|
||
- `GET /my-dashboard` → Auth. Liefert `tasks` (persönlich zugewiesene offene
|
||
Aufgaben), **`projectDates`** (Aufgaben und Meilensteine der Projekte, denen
|
||
der Aufrufer zugeordnet ist — siehe „Dashboard" unten), `pendingReviews` und
|
||
`colleagueAbsences`. Aggregates, per department (`ELT`/`HLS`/`ARCH`),
|
||
the currently unlocked week's open (`done=0`) `plan_todos` assigned to the
|
||
caller's `person_id` (sorted by due date, empty dates last), plus
|
||
`pendingReviews` — files where the caller is a `pending` reviewer
|
||
(`project_file_reviewers` joined with `project_files`/`users`). Normally
|
||
exactly one unlocked week exists per department (`applyAutoLock()`
|
||
invariant); if that invariant is violated (0 or >1 unlocked weeks),
|
||
`weekSortKeyServer()` picks the most recent week as a fallback. A user
|
||
without a matching `person_id` gets an empty task list per department (no
|
||
error).
|
||
- `POST /project-files?department=&projectNr=&projectName=&filename=&reviewers=id1,id2`
|
||
→ Auth. Raw binary body like `/import` (`express.raw({type:'*/*', limit:'200mb'})`).
|
||
Rejects an invalid/missing `department` (stricter than `departmentKey()`'s
|
||
ELT-fallback — an upload must never land in the wrong department silently)
|
||
and an empty `projectNr` (400). Reviewer IDs are parsed from the
|
||
comma-separated query param, deduplicated, filtered to active users, and the
|
||
uploader is removed (no self-review); at least one reviewer must remain
|
||
(400 otherwise). See "File uploads" below for storage/path details.
|
||
- `GET /project-files?department=&projectNr=` → Auth. Lists a project's files
|
||
incl. uploader name, per-reviewer decision, and comment count.
|
||
- `GET /project-files/:id/download` → Auth. `res.download()` from
|
||
`sessions/uploads/<department>/<stored_name>`, with `original_filename` only
|
||
used as the download's `Content-Disposition` filename.
|
||
- `POST /project-files/:id/review {decision:'approved'|'rejected', note?}` →
|
||
Auth. Only for a caller who is an eligible `pending` reviewer of that file;
|
||
409 if the file's overall status is already terminal
|
||
(`approved`/`rejected`). Recomputes the overall status: any `rejected`
|
||
decision → `rejected` immediately; all reviewers `approved` → `approved`;
|
||
otherwise stays `pending`.
|
||
- `GET /project-files/:id/comments` → Auth. Chronological comment list.
|
||
- `POST /project-files/:id/comments {body}` → Auth. Any logged-in user may
|
||
comment, not just the assigned reviewers.
|
||
- `DELETE /project-files/:id` → Auth (not admin-only, but scoped): allowed for
|
||
the uploader while the file is still `pending`, or for an admin at any time
|
||
(403 otherwise). Deletes the DB row (and its reviewer/comment rows, deleted
|
||
explicitly rather than via FK cascade — see Persistence section) before
|
||
best-effort deleting the physical file.
|
||
- `GET /project-protokolle?projectNr=` → Auth. Summary list of a project's
|
||
Protokolle (counts of open/erledigt points, Teilnehmer count), sorted by
|
||
`nummer` descending — see "### Protokolle" below.
|
||
- `GET /project-protokolle/:id` → Auth. One full Protokoll incl. all
|
||
`teilnehmer`/`punkte`.
|
||
- `POST /project-protokolle?projectNr=&projectName=&weekLabel=
|
||
{datum, betreff, ort, teilnehmer}` → Auth. Creates a new Protokoll;
|
||
`nummer` is server-assigned (`MAX(nummer)+1` for that `project_nr`), and
|
||
every still-open (`erledigt=0`) point of that project's most recently
|
||
created Protokoll is copied into it (the "Fortschreiben" — see "###
|
||
Protokolle").
|
||
- `PUT /project-protokolle/:id {datum, betreff, ort, teilnehmer, punkte}` →
|
||
Auth. Replaces the Protokoll's Teilnehmer/Punkte wholesale (delete +
|
||
re-insert, one transaction) — the same "whole object" pattern
|
||
`saveWeekRelational()` uses for a week, not a REST route per field. A point
|
||
with a known `id` keeps its server-tracked `stamm_id`/`neu`; a point
|
||
without one is treated as newly added (`neu=1`).
|
||
- `DELETE /project-protokolle/:id` → Auth (not admin-only — same permission
|
||
level as deleting a todo/milestone). Idempotent.
|
||
- `POST /project-protokolle/import?projectNr=&projectName=&weekLabel=&datum=
|
||
&betreff=&ort=&filename=&erkennung=` → Auth. Raw-binary body like `POST
|
||
/project-files` (`express.raw`, 50 MB limit; the route is listed in
|
||
`RAW_BODY_ROUTES` so the global `express.json()` parser never touches it —
|
||
see "JSON-Dateien lassen sich hochladen" below, same reasoning). Extracts
|
||
text from an uploaded `.pdf`/`.docx` (400 on any other extension). `erkennung`
|
||
(`'chatgpt'|'claude'`, optional) tries KI-gestützte Erkennung first,
|
||
falling back to the `erkennePunkteAusText()` heuristic on any failure —
|
||
anything else in that param skips straight to the heuristic; creates the
|
||
Protokoll through the same `legeProtokollAn()` path as the plain `POST`
|
||
above — see "### Protokolle" below for both recognition paths and the
|
||
PDF-library history. Response includes `erkennungsart`/`erkennungshinweis`.
|
||
- `GET /project-protokolle/:id/quelldatei` → Auth. Downloads the original
|
||
uploaded file of an imported Protokoll (404 if it wasn't imported) — the
|
||
Protokoll equivalent of `GET /project-files/:id/download`.
|
||
- `GET /my-ai-settings` → Auth (not admin-only — scoped to `req.user.id`,
|
||
same pattern as `/change-email`). Returns, per provider, only whether an
|
||
API key is stored (`configured: bool`) plus the — non-secret — `apiUrl`
|
||
to pre-fill an edit form; never the key itself. `PUT /my-ai-settings
|
||
{chatgpt?: {apiKey?, apiUrl?}, claude?: {apiKey?, apiUrl?}}` → Auth, same
|
||
scoping. A given `apiKey` replaces it (empty string clears it); a given
|
||
`apiUrl` is validated via `istGueltigeAiUrl()` — `https://` always passes,
|
||
`http://` only on the loopback (`localhost`/`127.0.0.1`/`::1`), anything
|
||
else is 400 — or clears an override back to the default endpoint; an
|
||
omitted sub-field is left untouched. See "### Protokolle" below for why
|
||
each account keeps its own key rather than one firmenweit, and for why
|
||
plain `http` is allowed on the loopback.
|
||
- `POST /my-ai-settings/test {provider}` → Auth, same scoping. "Neu
|
||
verbinden": actually exercises the caller's saved key/URL for that
|
||
provider via `erkennePunkteMitKI()` (a short dummy text, the same code
|
||
path an import uses). Responds `{ok:true}` or, on any failure,
|
||
`{ok:false, error}` with HTTP 200 — a failed test is an expected outcome,
|
||
not a server error. See "### Protokolle" below.
|
||
- `GET /app-settings` → **Auth** (not admin-only — the frontend *computes*
|
||
with these: Auslastung, ampel, Honorar schedule). `PUT /app-settings` →
|
||
**Admin**, partial update. `APP_SETTING_DEFS` in `server.js` is the single
|
||
place a key is defined (default, parser, label); `readAppSettings()` always
|
||
returns a complete object, falling back to the default for a missing or
|
||
unusable row. Unknown keys and invalid values are rejected with 400 *before*
|
||
anything is written, and gelb > rot is refused outright. Keys:
|
||
`stundensatz`, `arbeitsstundenProTag`, `vollzeitStundenProWoche`,
|
||
`auslastungSchwelleGelb`, `auslastungSchwelleRot`, `referenzHonorar`,
|
||
`verteilzeitraumWochen`,
|
||
`aufwandVorlage` (JSON, validated against `VALID_LPHS`/
|
||
`VALID_DISCIPLINE_ROLES`, values 0–1), `autoLockAktiv`,
|
||
`feiertagBundesland`, `maxBackups`, `sessionTage`, `loginMaxVersuche`,
|
||
`loginSperreMinuten`.
|
||
- `GET /holidays` → Auth; `POST /holidays {date,name}` / `DELETE /holidays/:date`
|
||
→ Admin. `date` is the primary key, so posting an existing date just renames
|
||
it. `GET /holidays` also returns `states` (the 16 German states), since the
|
||
server is the single place that list lives and the maintenance mask needs it
|
||
right away.
|
||
- `GET /holidays/suggest?year=&state=` → Auth; `POST /holidays/import {year,state}`
|
||
→ Admin. German public holidays are **computed**, not fetched from a web
|
||
service: `holidaysForYear()` in `server.js` combines fixed dates with
|
||
everything hanging off Easter (`easterSunday()`, the Gaussian/Meeus formula;
|
||
`bussUndBettag()` for Saxony). No external dependency, no internet access on
|
||
the server — which a shop network does not necessarily have — and the result
|
||
is deterministic and works offline. The suggestion route marks days already
|
||
stored (`vorhanden`); the import uses `INSERT OR IGNORE`, so a hand-edited
|
||
name on an existing date survives. Nothing is written automatically: a
|
||
holiday shifts every computed schedule, so an admin confirms it. The state
|
||
is remembered in `app_settings.feiertagBundesland` (default `NW`).
|
||
Municipality-level cases (Fronleichnam in parts of SN/TH, Mariä Himmelfahrt
|
||
in Bavarian catholic municipalities) are deliberately left out — they cannot
|
||
be derived from the state and can be added by hand.
|
||
- `GET /backup` → **Admin**. Consistent SQLite snapshot (`VACUUM INTO`, WAL-safe) as
|
||
a `.db` file download (temp file in `os.tmpdir()`, deleted after send). Used
|
||
by `downloadBackup()` in `js/backup.js`. Additionally, on server startup the
|
||
server writes a timestamped copy to `sessions/backups/` (also via
|
||
`VACUUM INTO`) and keeps only the `MAX_BACKUPS` newest ones (default 10).
|
||
- `POST /import` → Admin. Restores the whole database from a `.db` backup file
|
||
sent as a raw binary body (`express.raw`, limit 100 MB). Validates the schema
|
||
(`pragma_table_info` on every table, exact column match vs. live DB), then
|
||
swaps all data via `ATTACH DATABASE …` + DELETE/INSERT-SELECT inside one
|
||
transaction (rollback on error). Immediately BEFORE the restore the server
|
||
saves a fresh auto-backup of the current state via `runAutoBackup()` (so a
|
||
restore can be undone), then performs the swap. Used by `importDb()` in
|
||
`js/import.js` (the "DB Import" button is only shown to admins,
|
||
`renderUserBar()` in `js/auth.js`, and the route itself is admin-only
|
||
regardless of UI). Note: `IMPORT_TABLES` in `server.js` lists the 16
|
||
original per-week/session relational tables (`weeks` … `plan_milestone_deps`)
|
||
plus `project_protokolle`/`protokoll_teilnehmer`/`protokoll_punkte` (added
|
||
with the Protokolle feature — unlike `project_files`, a Protokoll has no
|
||
on-disk component, so it's safe to include in a restore, see "###
|
||
Protokolle"), i.e. the 7 auth/user/file tables (`users`, `user_sessions`,
|
||
`user_roles`, `user_absences`, `project_files`, `project_file_reviewers`,
|
||
`project_file_comments`) are **not** touched by import/export — a DB restore
|
||
never overwrites accounts, roles, absences, sessions, or file-review state.
|
||
(`people`/`person_roles`/`person_absences` were removed from `IMPORT_TABLES`
|
||
along with the tables themselves, see Persistence.)
|
||
- `ELT|HLS|ARCH` (`VALID_DEPARTMENTS` in `server.js`, `DEPARTMENTS` in
|
||
`js/globals.js`) are **no longer a partitioning of weeks/projects/people** —
|
||
see "Departments were retired" below. They still identify a **user's own**
|
||
discipline (`users.department`) and, since the Wochenbericht-Gewerke feature,
|
||
a **project's** discipline(s) (`project_gewerke` — see "Wochenbericht —
|
||
Gewerke-Trennung" below); `VALID_DEPARTMENTS` is the shared value set for
|
||
both.
|
||
- Static assets: files requested with `?v=N` (every JS/CSS in `App.html`)
|
||
get `Cache-Control: public, max-age=31536000, immutable`; everything else
|
||
(App.html, unversioned files) `no-cache` + ETag. **This makes bumping
|
||
`?v=N` for every changed CSS/JS file mandatory** — an unbumped file stays
|
||
stale in browsers for a year. See "Ladezeit (Pi)" below.
|
||
- JSON body limit is 2 MB.
|
||
|
||
## Persistence (SQLite)
|
||
|
||
- Data lives in `sessions/projektuebersicht.db` (`node:sqlite` `DatabaseSync`,
|
||
WAL mode, `PRAGMA foreign_keys = ON`, `PRAGMA busy_timeout = 5000`, `PRAGMA synchronous = NORMAL` to avoid
|
||
`SQLITE_BUSY` under concurrent writers), created automatically. The schema is
|
||
**fully relational** (26 tables, in the unconditional `CREATE TABLE`
|
||
block at the top of `server.js`) instead of one big JSON document per week:
|
||
- `weeks` (department, label, week_id, date, locked, updated_at; PK
|
||
`(department,label)`) — `updated_at` is the revision timestamp for the
|
||
single-user conflict handling (see `/save-week` above)
|
||
- `week_people` / `week_people_roles` — person copies per week (`array_order`
|
||
keeps the list order, `role_order` the role array order)
|
||
- `attendance` — présence per week/person
|
||
- `projects` — u. a. `groesse` (REAL 0–1, Ersatz für ein fehlendes Honorar
|
||
in der Auslastungsrechnung, siehe "Auslastung — Wochenstunden statt
|
||
Faktoren"); (`array_order` = position in the JSON array, `order_value` = the
|
||
`p.order` field, which is only unique *within a group*, not globally!;
|
||
`besprochen` = ticked off in the team meeting — it belongs to the
|
||
SESSION, not the project, so `cloneWeek()` resets it: a new session
|
||
starts with an empty agenda)
|
||
- `project_gewerke` (week_label, project_id, gewerk; PK über alle drei) —
|
||
die Fachbereiche, die das Projekt bearbeiten (`ELT`/`HLS`/`ARCH`, dieselben
|
||
Werte wie `users.department`); steuert die Auswahl der Beteiligten im
|
||
Assistenten
|
||
- `project_lph`, `project_assign` (person_id|role keys split into columns),
|
||
`project_aufwand` (LP effort per role), `project_aktuelle_lp`
|
||
- `project_plan`, `plan_people`, `plan_todos` / `plan_todo_assignees` /
|
||
`plan_todo_deps`, `plan_milestones` (`position` column!) /
|
||
`plan_milestone_deps`
|
||
- `users` (id, username UNIQUE, display_name, email, password_hash,
|
||
password_salt, role `'admin'|'member'`, person_id, active,
|
||
must_change_password, created_at, updated_at, **department, initials**) +
|
||
index on `person_id` **and** `CREATE UNIQUE INDEX … ON users(person_id)
|
||
WHERE person_id != ''` (see `ensureUserPersonIdUniqueIndex()` below) —
|
||
`capacity_hours` (contractual hours per week, added by
|
||
`migrateUsersDepartment()`; `0` = not maintained and counts as a full
|
||
position),
|
||
`person_id = personIdForName(display_name)`, the bridge to the existing
|
||
`person_id`-based assignment scheme (e.g. `plan_todo_assignees`) so
|
||
dashboard aggregation needs no schema change there; `department` is a
|
||
`VALID_DEPARTMENTS` value (empty only tolerated for the pre-existing
|
||
bootstrap admin), `initials` is the short board-column label
|
||
- `user_roles` (user_id, role, role_order; PK `(user_id,role)`, FK →
|
||
`users(id) ON DELETE CASCADE`) — discipline roles
|
||
(`Planung`/`Projektleitung`/`Bauüberwachung`, `VALID_DISCIPLINE_ROLES`)
|
||
- `user_absences` (user_id, id, start, end, type, note; PK `(user_id,id)`,
|
||
FK → `users(id) ON DELETE CASCADE`)
|
||
- `user_ai_keys` (user_id, provider, api_key, api_url; PK
|
||
`(user_id,provider)`, FK → `users(id) ON DELETE CASCADE`) — each
|
||
account's own OpenAI/Claude API key + optional API-URL override for the
|
||
Protokoll-Import's KI-gestützte Erkennung, see "### Protokolle"
|
||
- `user_sessions` (token PK, user_id, created_at, last_seen_at, expires_at)
|
||
- `holidays` (date PK `YYYY-MM-DD`, name) — firmenweite arbeitsfreie Tage.
|
||
A table rather than a JSON value in `app_settings` because it is a list
|
||
that grows and is maintained entry by entry. It feeds `HOLIDAYS`
|
||
(`js/globals.js`) and thus `isWorkday()`/`workdaysBetween()`/
|
||
`addWorkdays()` in `public/js/utils.js` — i.e. the Honorar schedule and
|
||
the Projektbereich's resource load. Dependency-based scheduling
|
||
(Auto-Schedule) still counts calendar days, as it always did.
|
||
- `project_files` (id PK, department, project_nr, project_name,
|
||
original_filename, stored_name, content_type, size_bytes,
|
||
uploaded_by_user_id, status `'pending'|'approved'|'rejected'`, created_at,
|
||
updated_at) + index on `(department, project_nr)`
|
||
- `project_file_reviewers` (file_id, user_id, decision `'pending'|'approved'|'rejected'`,
|
||
decided_at, note; PK `(file_id, user_id)`) + index on `(user_id, decision)`
|
||
- `project_file_comments` (id PK, file_id, user_id, body, created_at)
|
||
- `project_protokolle` (id PK, project_nr, project_name, nummer, datum,
|
||
betreff, ort, verfasser_user_id, week_label, created_at, updated_at) +
|
||
index on `project_nr` — scoped like `project_files`, see "### Protokolle"
|
||
- `protokoll_teilnehmer` (id PK, protokoll_id, name, firma, anwesend,
|
||
array_order) + index on `protokoll_id`
|
||
- `protokoll_punkte` (id PK, protokoll_id, stamm_id, nummer, gruppe, neu,
|
||
thema, text, zustaendig, termin, erledigt, erstellt_am, array_order) +
|
||
index on `protokoll_id` — `stamm_id` links copies of "the same" point
|
||
across Protokolle (the "Fortschreiben"), `nummer`/`gruppe` are free text.
|
||
`erstellt_am` (added on Erik's follow-up request, migration
|
||
`migrateAddProtokollPunktErstelltAm()`, existing rows stay empty — their
|
||
real origin date can't be reconstructed) is set exactly once, the moment
|
||
a point is first saved — either the owning Protokoll's own `datum` (a
|
||
point added by hand or newly recognized on import) — and, like
|
||
`stamm_id`, carried forward UNCHANGED by the Fortschreiben-copy in
|
||
`legeProtokollAn()` and by `PUT /project-protokolle/:id` (which looks up
|
||
the existing row by `id` and keeps its stored value rather than trusting
|
||
whatever the client sends): a Fortschreiben copy or an ordinary edit of
|
||
an existing point is not a new creation, so its original date must not
|
||
move just because it now also appears in a later Protokoll. Only a point
|
||
with no matching existing `id` gets a *fresh* `erstellt_am`, from the
|
||
`datum` being saved in that very request — the same rule `neu`/`stamm_id`
|
||
already follow for telling "genuinely new" apart from "carried over".
|
||
|
||
`people` / `person_roles` / `person_absences` (the former per-department
|
||
global-people schema) are **gone** from the live schema — they are only
|
||
ever created transiently by `migrateLegacySchema()` when converting a very
|
||
old single-JSON-document DB, and then immediately retired by
|
||
`migrateRetirePeopleTablesIntoUsers()` (see Migration below) in the same
|
||
startup. `loadGlobalPeople(department)` now reads exclusively from
|
||
`users`/`user_roles`/`user_absences` (`WHERE department = ? AND active = 1`)
|
||
and returns the unchanged shape `{id, name, fullName, email, role:[],
|
||
absences:[{id,start,end,type,note}]}` (`id`/`name`/`fullName` map to
|
||
`person_id`/`initials||username`/`display_name`), so `GET /load-all`,
|
||
`board.js`, `load.js`, `gantt.js`, `plan-modals.js` needed no changes.
|
||
|
||
**FK constraint note:** although `PRAGMA foreign_keys = ON` is set, only
|
||
`user_sessions.user_id`, `user_roles.user_id`, `user_absences.user_id` and
|
||
`user_ai_keys.user_id` have a declared `FOREIGN KEY … REFERENCES
|
||
users(id) ON DELETE CASCADE` (deleting a user auto-deletes their
|
||
sessions/roles/absences/AI keys). Every other
|
||
table — the 16 original per-week/session tables, the 4 file-related ones
|
||
(`users`, `project_files`, `project_file_reviewers`,
|
||
`project_file_comments`) and the 3 Protokolle tables (`project_protokolle`,
|
||
`protokoll_teilnehmer`, `protokoll_punkte`) — has **no** declared FK
|
||
constraints; this is a deliberate continuation of the pre-existing
|
||
convention (referential
|
||
integrity is enforced in application code, e.g. `deleteWeekData()`), not an
|
||
oversight. `DELETE /project-files/:id` therefore deletes
|
||
`project_file_comments` and `project_file_reviewers` rows explicitly before
|
||
the `project_files` row.
|
||
- The HTTP API stays JSON-shaped: `serializeWeek()` splits an incoming week
|
||
object across the tables inside a transaction, `loadWeeks()` /
|
||
`loadGlobalPeople()` (re)assemble week/people data on read.
|
||
`saveWeekRelational()` deletes the week's rows first (`deleteWeekData()`,
|
||
note `weeks` uses `label`, all child tables `week_label`) and returns the
|
||
new `updated_at` revision written by `serializeWeek()`. There is no
|
||
`serializeGlobalPeople()` anymore — the global people list has no write
|
||
path of its own; it is a read-only projection of `users`/`user_roles`/
|
||
`user_absences`, which are written only through `/admin/users*` and
|
||
`/users/:id/absences*`.
|
||
- Migration on every start (idempotent, run in this order after schema
|
||
creation):
|
||
1. `migrateLegacySchema()` converts a DB from the old single-JSON-document
|
||
format (detected via a `data` column in `weeks`): it reads the
|
||
documents, drops all tables, recreates the (then-current, including the
|
||
now-retired `people`/`person_roles`/`person_absences`) relational schema
|
||
and inserts everything in one transaction.
|
||
2. `migrateWeeksUpdatedAt()` adds the `updated_at` column to `weeks`
|
||
(idempotent, `ALTER TABLE … ADD COLUMN`). Legacy rows keep
|
||
`updated_at = ''`, so the first client save with an empty
|
||
`baseUpdatedAt` matches and only then a real revision is written.
|
||
3. `migrateUsersDepartment()` adds the `department`/`initials` columns to
|
||
`users` if missing (idempotent `ALTER TABLE … ADD COLUMN`, same pattern
|
||
as step 2).
|
||
4. `ensureUserPersonIdUniqueIndex()` first calls `dedupeUserPersonIds()` —
|
||
`person_id` used to only need to be unique *per department* (the old
|
||
`people` table); now that it globally identifies a user, any existing
|
||
duplicates are resolved by appending `_<user id>` to every row but the
|
||
first (logged with `console.warn`) — then creates
|
||
`idx_users_person_id_unique` (a partial unique index skipping empty
|
||
`person_id`).
|
||
5. `migrateRetirePeopleTablesIntoUsers()` runs only while a `people` table
|
||
still exists (no-op/idempotent otherwise): for every legacy `people` row
|
||
it looks up a `users` row by `(department, person_id)`; on a match it
|
||
copies that person's `person_roles` into `user_roles`
|
||
(`INSERT OR IGNORE`); on no match the row is only logged to the console
|
||
as unmatched — **no placeholder user is created** for it (would pollute
|
||
the user list with non-loginable dead entries). Historical `person_absences`
|
||
rows are **not** migrated (`serializeGlobalPeople()`'s old code wrote
|
||
`String(absenceObject)`, i.e. literally `"[object Object]"`, into that
|
||
column, so there was nothing meaningful to carry over). It then drops
|
||
`people`, `person_roles`, and `person_absences`. Historical weeks are
|
||
unaffected either way: `week_people`/`week_people_roles` already freeze
|
||
each week's person name/roles at save time, independent of the global
|
||
people table.
|
||
6. `bootstrapInitialAdmin()` (see Auth section).
|
||
- The DB file sits in `sessions/`, so the existing Docker bind-mount
|
||
`./sessions:/app/sessions` keeps it persistent. The path is overridable via
|
||
the `DB_PATH` environment variable (used for tests), production default is
|
||
`sessions/projektuebersicht.db`.
|
||
- Project/person IDs are stable across reloads; only week-level fields are
|
||
queryable columns.
|
||
|
||
## Frontend architecture
|
||
|
||
- The frontend is split into small classic non-module `<script>` files under
|
||
`public/js/`; **all functions are globals on `window`**. Three HTML files
|
||
include them via `<script src>` tags; load order in the HTML defines
|
||
dependencies, since there is no bundler:
|
||
- `App.html` loads `globals → utils → ui → date-picker → server → auth →
|
||
store → weeks` (shared modules), then the tab-specific folders `board →
|
||
people → projects → load → dashboard → wand`, finally `gantt →
|
||
tabs-auth → pdf → backup → import → init`. See the exact list in
|
||
`App.html`. `auth.js` sits right after `server.js` because
|
||
`checkAuthAndInit()` needs `baseUrl()`, and before `store.js`/`weeks.js`
|
||
since nothing may talk to the data endpoints before a session exists.
|
||
The `js/plan/*` modules of the Projektbereich sit right after `gantt.js`
|
||
(`plan-state → plan-sections → plan-info → plan-history →
|
||
plan-build-gantt → plan-gantt → plan-drag → plan-modals → plan-tasks →
|
||
plan-files → plan-templates → plan-protokoll-todos → plan-honorar →
|
||
plan-settings → plan-schedule`): they must come after `gantt.js`, whose `buildGanttHtml()`
|
||
`plan-build-gantt.js` wraps, and `plan-history` must stay before
|
||
`plan-build-gantt`, which uses its previous-week helpers. None of them
|
||
collides with a main-module global — every one runs in an IIFE over
|
||
`window.PlanApp` and exports through `window.*`.
|
||
- `admin-users.html` is the second, fully independent page (the only one
|
||
outside the tab system): it loads only `globals →
|
||
utils → ui → server → auth → admin-users.js` (no `date-picker`, no
|
||
`gantt`, no `plan/*` — it only talks to `/admin/users*`).
|
||
- `currentUser`/`checkAuthAndInit()` (`js/auth.js`) are the shared client-side
|
||
auth concept across all three pages: `checkAuthAndInit(onAuthenticated)`
|
||
calls `GET /whoami`; with a session it sets `currentUser`, hides the login
|
||
overlay and runs `onAuthenticated()` (the page's normal entry point, e.g.
|
||
`initUiState()` + `switchDepartment()` in `js/init.js`); without one it shows
|
||
the blocking `#loginOverlay`/`#loginForm` and only proceeds after a
|
||
successful `POST /login`. `renderUserBar()` shows the current user's
|
||
name/role and toggles admin-only UI (the "DB Import" button, the
|
||
`#adminUsersLink` to `admin-users.html`) based on `currentUser.role`.
|
||
- Shared state object `window.PlanApp` (`js/plan/plan-state.js`) holds all
|
||
project-plan page state (week, project, zoom level, drag/`modal`-state),
|
||
`db`/`currentDepartment` live in `js/globals.js`.
|
||
- State: `let db = { weeks, currentWeek, globalPeople }` (in-memory only).
|
||
- Project/person IDs are stable across reloads because the server splits and
|
||
reassembles week objects deterministically (JSON IDs are kept in the DB
|
||
columns); projects are addressed cross-page by `department` + week `label` +
|
||
`projNr` (+ readable projName path segment) via URL params:
|
||
`openProjectPlan()` builds `/HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…`,
|
||
`findProjectForPlan` resolves them. Person IDs follow
|
||
`personIdForName(name)` → `p_<lowercased alnum>`.
|
||
- `save()` debounces 500 ms then `syncAllToServer()` writes **only dirty weeks**
|
||
(`dirtyWeeks` – track `markWeekDirty()`) via `sendWeekToServer()`. There is
|
||
no separate people-list dirty-tracking/sync path anymore (no
|
||
`dirtyPeople`/`markPeopleDirty()`/`sendPeopleToServer()`) — since
|
||
Projektbeteiligte is a read-only projection of `users`/`user_roles`/
|
||
`user_absences`, the only way the client ever changes "people" data is
|
||
either (a) admin CRUD on `admin-users.html` (writes straight to
|
||
`/admin/users*`/`/users/:id/absences*`, no debounce/dirty-tracking
|
||
involved), or (b) the cross-department roster addition
|
||
(`addUserToCurrentWeekRoster()` in `js/people/people.js`), which just
|
||
pushes into `currentWeek().people` and goes through the normal week-`save()`
|
||
path. An in-flight guard (`syncInFlight`/`syncQueued`) prevents overlapping
|
||
syncs from double-POSTing the same week with the same revision (which would
|
||
look like a spurious 409). On success the server-provided `updatedAt`
|
||
revision is adopted as the new sync base; weeks whose save failed (network
|
||
errors) stay dirty and are retried. A `409` conflict response drops the
|
||
local changes for that week and `handleWeekConflict()` reloads **only that
|
||
week** from the server (`reloadWeekFromServer()`), leaving all other dirty
|
||
weeks untouched. Exception: `js/plan/plan-state.js` `savePlan()` bypasses
|
||
`save()` and writes only the current week directly via `sendWeekToServer()`
|
||
(with the same in-flight/trailing-save guard), adopting the returned
|
||
`updatedAt` and showing a toast on conflict. `switchWeek()` (in
|
||
`js/weeks.js`) does NOT trigger a save – only actual edits do.
|
||
- `switchDepartment()` cancels a pending save debounce before switching, and
|
||
`load()` discards stale `/load-all` responses after a department switch
|
||
(frozen `requestedDepartment`), so late responses can never render or persist
|
||
data under the wrong department.
|
||
- The people tab no longer has drag-and-drop, a form, or delete/reorder
|
||
actions (`renderPeople()` in `js/people/people.js` is read-only rendering
|
||
of `db.globalPeople[currentDepartment]` plus any cross-department roster
|
||
additions on the current week). `getDragAfterElement()`/drag-and-drop
|
||
(`js/projects/projects.js`) serves the project list's `.board-row` rows
|
||
and, via `js/people/people.js`, the `.person-list-item` rows — the people tab's former
|
||
`.person-card`/`.people-sortable`/`reorderPeople()` drag machinery was
|
||
removed along with the CRUD UI. No drag handles are rendered on locked
|
||
weeks in the Projekte tab either way.
|
||
- Weeks are sorted by calendar week extracted from the label (`KW XX / YYYY`)
|
||
via `weekSortKey()`, not alphabetically/by filename.
|
||
- `applyAutoLock()` locks every week except the chronologically last one after
|
||
load/clone/new-week; `localStorage` UI state is not restored for locked tabs.
|
||
- `localStorage` keys: `projuebersicht_v1` (department only — tab is never
|
||
restored from storage; at startup the tab comes from the deep-link URL if
|
||
present, otherwise `DEFAULT_TAB` = `'dashboard'`, i.e. "Meine Aufgaben"),
|
||
`projuebersicht-theme`, header-collapse key. Do **not** store data in
|
||
`localStorage`; it is UI state only.
|
||
- Deep-link URLs (`/<Dept>/<Tab>`) are shared via the address bar:
|
||
`updateRouteUrl()` rewrites the path via `history.replaceState` on tab and
|
||
department switches. Since the whole app requires login, an unauthenticated
|
||
visitor to any deep link (including `/Projektplan/…`) simply sees the
|
||
login overlay first; the URL itself is never gated per-tab anymore (there is
|
||
no more admin/member split at the tab level — see Auth section).
|
||
- **Sidebar navigation (`App.html` only):** the former top tab bar, a
|
||
separate `<header>`, and a separate off-canvas tool drawer have been merged
|
||
into one persistent left sidebar, `#headerMenu`/`.side-menu` (the element
|
||
keeps its old id/class and `toggleHeader()`/`setHeaderCollapsed()` mechanics
|
||
from `js/ui.js`, now repurposed to hold the entire navigation). Structure
|
||
top to bottom: brand (logo/title), a "Navigation" section with the
|
||
`.nav-item.tab`/`data-tab` buttons (`showTab()`) in the order Meine
|
||
Aufgaben → Projekte → Wochenbericht → Auslastung (plus `#planNavItem`, see
|
||
below) — **no "Abteilung" section** (removed along with department
|
||
partitioning, see "Departments were retired"; `.dept-tab`/`switchDepartment()`
|
||
no longer exist) — then — below the divider — the **Einstellungen** nav item (the tab
|
||
`settings`), the **Werkzeuge** accordion and the **Admin** accordion (its
|
||
sub-items are the sections of the `admin` tab, see below), and a
|
||
`.side-menu-footer` with `#userBarInfo`/`#adminUsersLink`/logout
|
||
(`renderUserBar()`, unchanged). At `min-width: 769px` the sidebar is
|
||
`position: sticky` and permanently visible (no hamburger, `styles.css`);
|
||
below that width, a narrow `.mobile-topbar` (logo + hamburger) replaces the
|
||
old `<header>` and the sidebar reverts to its pre-existing off-canvas
|
||
slide-in behavior. `admin-users.html` deliberately does **not** get this
|
||
sidebar — it stays a focused standalone page outside the tab system, with
|
||
its own back-link and user bar. The Projektbereich needs no special case
|
||
at all any more: being the tab `plan` of `App.html`, it simply IS under
|
||
the same sidebar, with every section (Sitzung, Einstellungen, Admin, user
|
||
footer) working as everywhere else. The only sidebar addition is
|
||
`#planNavItem` — a nav item that stays hidden until a project has been
|
||
opened and then carries that project's name (`syncPlanNavItem()` in
|
||
`js/tabs-auth.js`), so the sidebar still highlights where you are while in
|
||
the Projektbereich. `showTab()` additionally hides `.container` for
|
||
`t === 'plan'`, because `#plan` deliberately sits OUTSIDE it (it brings its
|
||
own header and padding) and the empty container's padding would otherwise
|
||
show as a blank strip.
|
||
|
||
## Auth / security quirk
|
||
|
||
- Real server-side authentication (this replaced the old client-only
|
||
password modal — `PROTECTED_PASSWORD_HASH`, `isAuthenticated`,
|
||
`requestedAction`, `confirmPassword()`, `#passwordModal`, and the CryptoJS
|
||
CDN `<script>` tag are all gone; `js/tabs-auth.js` now only holds `showTab()`).
|
||
Login is required for the **entire app**, not just for mutations/admin
|
||
actions — every JSON endpoint except `/login`/`/whoami` requires at least a
|
||
valid session (see the Auth-level markers in "Server API" above).
|
||
- **Sessions:** a session is a row in `user_sessions` (`token` PK, 30-day
|
||
`expires_at`), identified via the `puebersicht_session` cookie
|
||
(`HttpOnly; SameSite=Lax`, `Secure` added automatically when the request is
|
||
HTTPS). Cookie parsing/setting is hand-rolled (`parseCookies()`,
|
||
`setSessionCookie()`/`clearSessionCookie()` in `server.js`) — no
|
||
`cookie-parser` dependency, keeping the project's express+cors-only
|
||
convention. `attachUser` middleware (registered via `app.use(attachUser)`
|
||
right after `express.json()` and before `express.static`, so it runs for
|
||
every request including static assets) reads the cookie, loads the joined
|
||
session+user row, and sets `req.user` (or `null`); it also refreshes
|
||
`last_seen_at` on every authenticated request. `requireAuth` (401 if
|
||
`req.user` is null) and `requireRole('admin')` (401 if not logged in, else
|
||
403 if `role !== 'admin'`) are the two middleware factories used on routes.
|
||
- **Passwords:** hashed with Node's built-in `crypto.scryptSync` (salt +
|
||
64-byte hash, `hashPassword()`/`verifyPassword()` in `server.js`);
|
||
`verifyPassword()` uses `crypto.timingSafeEqual()`. No bcrypt/argon2
|
||
dependency — stays within the express+cors-only convention.
|
||
`/login` returns the same generic error for wrong username, wrong password,
|
||
or an inactive account (no username-enumeration signal).
|
||
- **Roles:** `admin`, `member`, and `kiosk` (`users.role`, `VALID_ROLES` in
|
||
`server.js` — do not confuse with the discipline `roles` array on a user,
|
||
`Planung`/`Projektleitung`/`Bauüberwachung` in `user_roles`, which is
|
||
unrelated to permissions). `kiosk` is the one role that IS fully
|
||
server-enforced rather than a UI gate (see "Wandtafel" below for why it
|
||
exists): `requireAuth` rejects it outright everywhere except the handful
|
||
of read-only routes the Wandtafel needs (`requireAuthOderKiosk`), and a
|
||
`kiosk` account can never carry a discipline role (enforced on both
|
||
`POST`/`PATCH /admin/users*`, in both directions — setting `role: 'kiosk'`
|
||
on an account with existing discipline roles is rejected exactly like
|
||
adding a discipline role to an existing `kiosk` account). Otherwise, only
|
||
the genuinely destructive/administrative actions are **server-enforced**
|
||
admin-only: week deletion (`/delete-week`), week unlock (`/unlock-week`),
|
||
DB import (`/import`), and the `/admin/users*` CRUD. `/users/:id/absences*`
|
||
stays open to any logged-in user (unchanged permission level from before).
|
||
The `Projekte` tab and the (now read-only) `Projektbeteiligte` tab only
|
||
require being logged in — any role; actually adding/editing/removing team
|
||
members happens exclusively via `admin-users.html` (admin-only). The
|
||
cross-department roster-add/remove UI in the Projektbeteiligte tab
|
||
(`js/people/people.js`) is only **rendered** for `currentUser.role ===
|
||
'admin'` — like the old client-only password modal, this is a UI-level
|
||
gate, not a server-enforced one: it writes into `currentWeek().people`
|
||
through the ordinary `save()` → `POST /save-week`, which only requires
|
||
`requireAuth` (any role), so it does not add a new hard permission
|
||
boundary.
|
||
- **First-admin bootstrap:** `bootstrapInitialAdmin()` runs once at every
|
||
server start, guarded by `SELECT COUNT(*) FROM users` — if `users` is empty
|
||
(fresh install), it generates a random password
|
||
(`crypto.randomBytes(15).toString('base64url')`), creates an `admin` account
|
||
(username `admin`, `must_change_password=1`), and prints the plaintext
|
||
password to the console **and** writes it to
|
||
`sessions/INITIAL_ADMIN_PASSWORD.txt` (outside `public/`, so
|
||
`express.static` never serves it). There is no hardcoded password anywhere
|
||
in the code.
|
||
- **`users.person_id`** is the bridge between login accounts and the
|
||
pre-existing, department-independent `personIdForName()`-based assignment
|
||
scheme (e.g. `plan_todo_assignees.person_id`) — set explicitly by an admin
|
||
(not auto-derived server-side), so an account and a project-roster entry can
|
||
exist independently of each other. A user with no matching `person_id` sees
|
||
an empty dashboard with a hint instead of an error (`renderDashboard()` in
|
||
`js/dashboard/dashboard.js`).
|
||
- CryptoJS is gone; pdfmake (PDF export) is still loaded from
|
||
`cdnjs.cloudflare.com` in `App.html`, so PDF export needs internet access
|
||
and won't work offline. `admin-users.html` loads no CDN script.
|
||
- User-provided status HTML is sanitized on render via a small whitelist
|
||
(`sanitizeStatusHtml` in `js/board/board.js`) to mitigate XSS.
|
||
|
||
### Einführung — geführter Rundgang (`js/einfuehrung.js`)
|
||
|
||
Eriks Wunsch: Jeder Nutzer, auch bestehende, macht einmal einen geführten
|
||
Rundgang; unter Einstellungen → Mein Konto „Rundgang starten“ jederzeit
|
||
wieder. Abgedunkelte Ebene mit Aussparung um das Ziel (`.tour-loch`,
|
||
box-shadow) und Sprechblase (`.tour-blase`, rechts vom Ziel, sonst
|
||
darunter/darüber, auf Handybreite unten), Fortschrittspunkte, Zurück/Weiter,
|
||
Pfeiltasten, Esc. Die Schritte (`EINFUEHRUNG_SCHRITTE`) wechseln selbst in den
|
||
Bereich (`showTab()`) und zeigen nur, was die Rolle darf (`recht`/`wenn`,
|
||
Rollen & Rechte); ohne „Anwendung nutzen“ (Wandtafel) kein Rundgang. Der
|
||
Projektbereich wird erklärt, nicht geöffnet. Abschluss nennt Fehlendes
|
||
(z. B. keine Person-ID).
|
||
- `users.einfuehrung_am` (Migration in `migrateUsersDepartment()`, leer = steht
|
||
aus — auch für alle bestehenden Konten), `req.user.einfuehrungFaellig`,
|
||
`POST /einfuehrung {erledigt}` (nur eigenes Konto). „Fertig“ und
|
||
„Überspringen“ vermerken; „Später“/Esc schließt nur für den Seitenaufruf
|
||
(`sessionStorage`).
|
||
- Start über `einfuehrungBeiBedarf()` aus `afterAuthenticated()` bzw. nach
|
||
dem Pflicht-Passwortwechsel; wartet, bis Daten geladen und kein Dialog
|
||
offen ist. Stile: Block „RUNDGANG“ am Ende von `styles.css` (z-index 450).
|
||
|
||
### Abwesenheiten (Seitenleiste, `js/abwesenheit/abwesenheit.js`)
|
||
|
||
Menüpunkt „Abwesenheiten“ (Tab `abwesenheit`, `/Abwesenheiten`) nach
|
||
Auslastung. **Gestaltung „Variante 2 im Stil der Projektübersicht“** (Eriks
|
||
Wahl): ohne Kopfbereich (Titel, Legende, „+ Abwesenheit“ und Hinweise auf
|
||
Eriks Wunsch entfernt), oben direkt zwei
|
||
Spalten — links Karte „Neue Abwesenheit“ (Für-Zeile mit Kürzel-Kreis, große
|
||
Von/Bis-Kacheln öffnen `openRangePicker(... onApply)`, Art als vier Kacheln,
|
||
Notiz, Warnungen: Überschneidung, offene Aufgaben der Person im Zeitraum,
|
||
Kollegen desselben Gewerks), rechts die Liste der gewählten Person als
|
||
Projektzeilen (`.board-row`: Startdatum in `.board-nr`, KW als `.phase-pill`,
|
||
Ändern/Löschen als `.board-row-actions`, Löschen zweistufig); unten „Team im
|
||
<Monat>“ mit Gewerk-Filter und **je Gewerk eine Karte** (`.board-list`) mit
|
||
Kürzel-Kreis in Fachrollen-Farbe (`.board-avatar`) und Balken je Abwesenheit
|
||
(Klick auf einen Tag = für diese Person eintragen, auf einen Balken = ändern).
|
||
Keine halben Tage.
|
||
- **Rechte** (Gruppe „Team“, Schalter wie alle anderen): `team.abw` (für
|
||
mich), `team.abwProjekte` (Beteiligte gemeinsamer Projekte der jüngsten
|
||
Sitzung, `project_assign.state > 0`), `team.abwGewerk` (gleiches
|
||
`users.department`), `team.abwAlle`. Keiner = keine Abwesenheiten, kein
|
||
Menüpunkt (`data-recht="a|b"` = eines genügt, `renderUserBar()`).
|
||
`team.abwFremd` ist entfallen; einmalige Umstellung
|
||
`abwesenheitsRechteUmstellen()`: Mitglied nur `team.abw`, Projektleiter
|
||
zusätzlich Gewerk + Projekte, selbst angelegte Rollen mit dem alten Recht
|
||
bekommen „für alle“.
|
||
- **Server:** `abwesenheitZiele(user)`/`darfAbwesenheit(user, zielId)` prüfen
|
||
`POST`/`PATCH`/`DELETE /users/:id/absences…` (403 sonst; der frühere
|
||
Sonderweg „Ersteller darf löschen“ ist weg). Arten fest (`ABWESENHEIT_ARTEN`),
|
||
Datum ISO, Ende ≥ Beginn. **Sichtbarkeit:** `loadGlobalPeople(req.user)`
|
||
(in `/load-all`, `/my-dashboard`, `GET /personen`) liefert Abwesenheiten
|
||
außerhalb des eigenen Umfangs als `{type:'abwesend', note:'', verdeckt:true}`
|
||
und je Person `userId`/`darfAbw`. Jede Änderung sendet das Live-Ereignis
|
||
`{typ:'personen'}`; `personenNachladen()` (`store.js`) holt `GET /personen`.
|
||
- Der Abwesenheits-Dialog im Terminplan und die Benutzerverwaltung nutzen
|
||
dieselben Routen und damit dieselben Rechte. Rundgang: Schritt
|
||
„Abwesenheiten“. Stile: Block „ABWESENHEITEN“ am Ende von `styles.css`.
|
||
|
||
### Rollen & Rechte (aktueller Stand — löst die festen Rollen ab)
|
||
|
||
Eriks Wunsch (Entwurf „B“): Admin → **Rollen & Rechte** (`js/admin/rollen.js`,
|
||
Karte `#adminRollenCard`). Links die Rollen (Konten, Anteil der Rechte),
|
||
darunter „+ Neue Rolle“ (leer oder mit den Rechten einer anderen); rechts die
|
||
gewählte Rolle mit einem Schalter je Recht nach Gruppen, „alle/keine“ je
|
||
Gruppe, „Vergleich mit“ einer zweiten Rolle, Umbenennen, Kopieren, Löschen
|
||
(zweistufig im Knopf). Jede Änderung speichert sofort.
|
||
|
||
- **Katalog** `RECHTE` (server.js, 30 Rechte in 7 Gruppen: Grundzugang,
|
||
Projekte, Wochenbericht, Team, Werkzeuge, Sitzungen, Admin-Bereich). Die
|
||
Schlüssel stehen im Code, nur die Zuordnung ist frei.
|
||
- **Tabellen** `roles` (key = `users.role`, name, sort_order, fest) und
|
||
`role_rechte`; NICHT in `IMPORT_TABLES`. Startbelegung (nur bei leerer
|
||
Tabelle) = Stand vorher: Administrator fest mit allem, Projektleiter und
|
||
Mitglied alles aus Gruppe 1–5 (Mitglied ohne „Projekte anlegen und
|
||
löschen“), Wandtafel nur „Wandtafel anzeigen“. `VALID_ROLES` ist weg
|
||
(`rolleExistiert()`).
|
||
- **Server:** `hatRecht(user, recht)`, `requireRecht()` ersetzt alle
|
||
`requireRole('admin')`; `/app-settings` prüft je Einstellung
|
||
(`APP_SETTING_RECHT`). Eine Rolle ohne „Anwendung nutzen“
|
||
(`istNurWandtafel()`) ist das frühere Kiosk-Konto: `requireAuth` weist sie
|
||
ab, SQL schließt sie über `sqlOhneWandtafel()` aus Teams aus, keine
|
||
Fachrolle. **Speichern:** `fehlendesBearbeitungsrecht()` vergleicht in
|
||
`/save-week` und `/save-projects` je Projekt mit dem gespeicherten Stand
|
||
(Kommentar/`lph` → wb.kommentar, besprochen → wb.besprochen, `plan` →
|
||
proj.plan, sonst → proj.stamm, Anwesenheit → wb.anwesenheit, Sandbox →
|
||
wz.planung; `normWert()` behandelt leere Werte und Zahl/Text gleich). Wer
|
||
alle diese Rechte hat, überspringt den Vergleich. 403 trägt `recht`.
|
||
- **Schutz:** Administrator nicht änderbar/löschbar; nur ein Administrator
|
||
vergibt/entzieht Administrator; der letzte aktive Administrator bleibt;
|
||
„Rollen & Rechte“ kann man der eigenen Rolle nicht entziehen; Rolle mit
|
||
Konten nicht löschbar (409 nennt sie).
|
||
- **Routen:** `GET /rollen` (Katalog + Rollen mit Kontenzahl, requireAuth),
|
||
`POST /rollen {name, vonRolle}`, `PATCH /rollen/:key {name}`,
|
||
`PUT /rollen/:key/rechte {rechte, an}`, `DELETE /rollen/:key` — alle mit
|
||
Recht a.rollen. `/whoami` liefert `rechte` und `rolleName`.
|
||
- **Browser:** `hatRecht()`/`hatAdminRecht()` (`js/auth.js`); Admin-Abschnitte
|
||
je Recht (`ADMIN_SECTIONS[].recht`, `adminNavAbgleichen()`); Elemente mit
|
||
`.nur-recht[data-recht]` werden ausgeblendet; `showTab()` leitet ohne
|
||
„Anwendung nutzen“ auf die Wandtafel. Projektbereich: `app.isLocked()`
|
||
(Terminplan, proj.plan), `app.stammGesperrt()` (Einstellungen, Gruppe,
|
||
amtierende LPH, Honorar), `app.protokolleGesperrt()`. Wochenbericht:
|
||
Kommentar/Fortschritt, „Besprochen“, Anwesenheit je Recht. Ein 403 beim
|
||
Speichern zeigt einen Hinweis und lädt die Sitzung neu (`speicherFehler()`,
|
||
kein Wiederholen). Benutzerverwaltung: Rollenauswahl aus `GET /rollen`.
|
||
- Abschnitte weiter unten, die „admin-only“, `requireRole('admin')`,
|
||
`kiosk` oder „Projektleiter und Administratoren“ nennen, beschreiben den
|
||
Stand davor — gemeint ist jetzt das jeweilige Recht.
|
||
|
||
### Ein Ort je Angabe
|
||
|
||
Every piece of project data now has exactly ONE place where it is entered.
|
||
Before, six of nine fields were editable in three different masks
|
||
(Projektboard, Wochenbericht, Projektbereich) — nobody knew which one was
|
||
authoritative, and two people could change the same field in different places.
|
||
The split follows what kind of data it is:
|
||
|
||
- **Laufende Erfassung → Wochenbericht only.** The session comment
|
||
(`p.status`) and the progress per Leistungsphase (`p.lph`) are produced in
|
||
the weekly meeting. They are editable **only** in the Wochenbericht.
|
||
- **Stammdaten → Projektbereich only.** Projektnummer, Bezeichnung,
|
||
Schlagwörter, Gruppe, amtierende Leistungsphase, Auftragsstatus, Honorar, the
|
||
beauftragte Leistungsphasen, the Aufwands-Gewichtung, and **the assignment of
|
||
people (`p.assign`)** live in the Projektbereich (`einstellungen` section,
|
||
`js/plan/plan-settings.js` plus `js/plan/plan-info.js` for Gruppe/amtierende
|
||
LPH). `p.assign` moved here on Erik's explicit request ("In den
|
||
Einstellungen der Projekte will ich auch die Projektbeteiligten einstellen
|
||
können"), fully relocated rather than duplicated — he chose that option
|
||
outright over making it editable in both places, to keep this same "Ein Ort
|
||
je Angabe" invariant for the new field too. It is edited via
|
||
`planCycleAssign(personId, role)` in `plan-settings.js`, filling
|
||
`#planAssignMatrix` — deliberately **not** reusing `cycleAssign()`
|
||
(removed, see below) or going through `currentWeek()`/`save()` at all: the
|
||
Projektbereich can be deep-linked to an older week, or be a Planung-Sandbox
|
||
plan with no `currentWeek()` entry at all (same trap `planOpenAufwand()`
|
||
already guards against), so `planCycleAssign()` mutates `app.project`
|
||
directly and calls `app.savePlan()`, exactly like every other Stammdatum in
|
||
this file.
|
||
- **Everywhere else: display plus a way there.** `renderBoardDrawer()` takes
|
||
`opts.nurAnsicht` (set by `BOARD_OPTS` in `js/board/board.js`): same card,
|
||
same values, same order — but no input fields and a `.drawer-verweis`
|
||
underneath with the button "Im Wochenbericht bearbeiten →" for Kommentar/
|
||
Fortschritt. The Beteiligten-Zuordnung (`roleRows`) is now **always**
|
||
rendered read-only inside `renderBoardDrawer()` — in the Projektboard AND
|
||
in the Wochenbericht, regardless of `opts.nurAnsicht` — with its own
|
||
"In den Projekteinstellungen bearbeiten →" button next to it
|
||
(`springZuEinstellungen(pid)`, `js/gantt.js`). The Projektbereich's
|
||
Dashboard section does the equivalent for Kommentar/Fortschritt via
|
||
`setzeBerichtVerweis()` (`js/plan/plan-info.js`), filling
|
||
`#planStatusVerweis`/`#planLphVerweis`; it has no such verweis for
|
||
Beteiligte any more, since that matrix is edited right there in
|
||
Einstellungen now, not somewhere else.
|
||
- **The link is a real jump**, not a hint: `springZumWochenbericht(uid)`
|
||
(`js/plan/plan-info.js`, also used by `renderBerichtVerweis()` in
|
||
`board.js`) sets `berichtAuswahl` **before** `showTab('wochenbericht')` and
|
||
therefore lands on that very project. Without it the user would have to find
|
||
it again in a list of thirty. (`berichtAuswahl` is a global `let` binding
|
||
from `js/wochenbericht/wochenbericht.js` — classic scripts, no modules, so
|
||
assigning it from another file works.) `springZuEinstellungen(pid)` is the
|
||
same idea in the other direction: it sets `location.hash = 'einstellungen'`
|
||
**before** calling `openProjectPlan(pid)`, the same "set the hash first"
|
||
trick `wandSpringeZuProjekt()` already used, since `showTab('plan')`'s own
|
||
`updateRouteUrl()` preserves an already-set hash.
|
||
- **Consequence:** `planStatusBlur()`, `setPlanLph()` and `setPlanAssign()` in
|
||
`js/plan/plan-info.js` are **gone** — they had no caller left. The status
|
||
editor's formatting toolbar (`formatStatus()` buttons) went with them in the
|
||
Projektbereich; the Wochenbericht keeps the full editor. `editLph()` and
|
||
`saveBoardComment()` in `js/board/board.js` are untouched — the
|
||
Wochenbericht's editing hangs off them. `cycleAssign()` in `js/board/board.js`
|
||
is now **also gone** (the Beteiligten relocation's own consequence,
|
||
mirroring the three functions above) — it had exactly one caller
|
||
(`roleRows`'s avatar `onclick`), which is now unconditionally read-only.
|
||
- **Honorar and beauftragte Leistungsphasen stay editable in TWO places** on
|
||
purpose (Projekteinstellungen and the "Terminplan aus Honorar" dialog, so the
|
||
dialog remains usable on its own); both write through the same functions in
|
||
`js/plan/plan-honorar.js` and re-render each other, so they cannot drift.
|
||
- Changing the **Projektnummer** asks for confirmation (`setPlanProjNr()`):
|
||
uploaded files hang off `(department, project_nr)`, so a renumbered project
|
||
would lose them. Changing the **Bezeichnung** updates the plan header
|
||
(`#planTitle`), the sidebar entry (`syncPlanNavItem()`), the URL
|
||
(`updateRouteUrl('plan')`) and the list — `renderPlanPage()` would do all of
|
||
it, but it is module-local in `plan-state.js` and unreachable from
|
||
`plan-settings.js`.
|
||
|
||
### Eine Projektliste (tab `board`, labelled "Projekte")
|
||
|
||
- **Avatar-Stapel je Person, nicht je Rolle** (Projektliste und Titelzeile
|
||
des Projektbereichs, Eriks Wunsch): `beteiligteJePerson()` (`js/utils.js`)
|
||
fasst die Rollen einer Person zusammen; `beteiligtenAvatarHtml()` zeichnet
|
||
bei mehreren Rollen einen Kreis aus senkrechten Streifen in den
|
||
Rollenfarben (`linear-gradient(to right …)`, `ROLLEN_FARBE`, immer volle
|
||
Farbe — Haupt/Unterstützung nur im Tooltip,
|
||
`.board-avatar.ist-mehrfach`), der Tooltip nennt alle Rollen.
|
||
|
||
The former `Projekte` tab is gone; `renderBoard()` is the single project list.
|
||
It kept everything that concerns the LIST and gave up everything that concerns
|
||
a single project's data:
|
||
|
||
- Each group is a drop zone: `<div class="board-list projects-sortable"
|
||
data-group="…" ondragover ondrop>`, each row `draggable` with a
|
||
`.drag-handle`. The handlers stay in `js/projects/projects.js`
|
||
(`handleDragStart`/`handleDrop`/`handleDragEnd`/`reorderProjects`) — they
|
||
hold the ordering logic and are also used by `js/people/people.js`. Only
|
||
`handleDrop()` had to learn which selector to use: it picks
|
||
`.board-row:not([style*="opacity"])` when the drop zone contains board rows,
|
||
`.person-list-item` otherwise. Without that, every drop landed at the end of
|
||
the group.
|
||
- All four groups from `GROUP_ORDER` are pre-seeded even when empty, so a group
|
||
emptied by dragging stays a drop target (a bug already fixed once in the old
|
||
Projekte tab).
|
||
- `.board-row-actions` at the end of each row: a "⋯" button
|
||
(`oeffneProjektMenue()` in `board.js`, Eriks Wunsch; same `.user-kebab`/
|
||
`.user-menu` styling as the Benutzerverwaltung) with "Öffnen"
|
||
(Projektbereich) and "Löschen" — the latter confirms inside the menu and
|
||
then calls `delProjectFromList(id, true)` without `confirm()`. Hidden until
|
||
hover/focus so the list stays calm. "+ Projekt" sits in the list's title row now (it was only in the
|
||
sidebar before); the sidebar entry stays and opens the same mask.
|
||
- `js/projects/projects.js` no longer renders anything by default:
|
||
`renderProjects()` returns immediately unless `#projectsList` exists. Every
|
||
caller (`renderAll()`, `editProjectField()`, `auftrag.js`, `load.js`) can
|
||
stay as it is.
|
||
- `TAB_SLUGS` (`js/globals.js`) lost `projects`, but `TAB_BY_SLUG['Projekte']`
|
||
is set to `'board'` and `VALID_TAB_SLUGS` (`server.js`) still accepts
|
||
`Projekte`, so forwarded links keep working instead of 404ing.
|
||
|
||
### Assistent "Neues Projekt" (`js/projekt-wizard.js`)
|
||
|
||
Sechs Schritte statt eines Formulars mit allen Feldern auf einmal. Der frühere
|
||
Dialog `#newProjModal` ist entfallen, ebenso `openNewProjModal()`,
|
||
`closeNewProjModal()`, `newProjStatusGewechselt()` und `saveNewProj()` in
|
||
`js/load/load.js`.
|
||
|
||
1. **Projekt** — Nummer, Bezeichnung, **Gewerke**, Gruppe, Schlagwörter
|
||
2. **Auftrag** — Auftragsstatus, Honorar **oder** Projektgröße, beauftragte
|
||
Leistungsphasen
|
||
3. **Stand** — amtierende Leistungsphase
|
||
4. **Team** — Beteiligte je Rolle, auf die Gewerke des Projekts eingeschränkt
|
||
5. **Terminplan** — aus dem Honorar errechnet
|
||
6. **Prüfen** — Zusammenfassung, dann Anlegen
|
||
|
||
**Warum überhaupt:** Honorar, Team und Schlagwörter mussten zuvor NACH dem
|
||
Anlegen an drei weiteren Stellen nachgetragen werden — und wurden es meist
|
||
nicht. Genau daher rühren die Projekte, die in der Auslastung als „nicht
|
||
bewertbar" erscheinen. Umgekehrt ist die 27-Felder-Aufwandsmatrix aus dem
|
||
Anlegen verschwunden: Die firmenweite Vorlage genügt, die Feinjustierung
|
||
gehört in den Projektbereich (`openLpModal()` lebt dort weiter).
|
||
|
||
**Jeder Schritt ist Pflicht.** `wizardFehlt(key)` liefert die fehlenden
|
||
Angaben als Klartext; solange etwas fehlt, bleibt „Weiter" gesperrt und
|
||
daneben steht, *was* fehlt — ein gesperrter Knopf ohne Begründung ist eine
|
||
Sackgasse. Ein Assistent, der unvollständige Projekte durchlässt, löst das
|
||
Problem nicht, das er lösen soll.
|
||
|
||
**Gewerke** (`project_gewerke`, Tabelle nach dem Muster von
|
||
`project_beauftragte_lph`; Werte wie `VALID_DEPARTMENTS`): Ein Projekt gehört
|
||
zu einem oder mehreren Fachbereichen. Im Schritt „Team" stehen nur die
|
||
Mitarbeiter daraus zur Auswahl — ein reines Elektro-Projekt soll nicht die
|
||
ganze Belegschaft anbieten. Wer jemanden aus einem anderen Gewerk braucht,
|
||
holt ihn über das Auswahlfeld „Mitarbeiter aus anderen Gewerken" ausdrücklich
|
||
dazu; solche Personen tragen in der Auswahl einen gestrichelten Rand. Wird ein
|
||
Gewerk wieder abgewählt, fallen seine Beteiligten aus dem Team — sonst stünde
|
||
im Projekt jemand, der nach der eigenen Regel gar nicht auswählbar wäre.
|
||
|
||
Das **Gewerk einer Person** kommt aus `users.department`; `loadGlobalPeople()`
|
||
liefert es als Feld `gewerk` mit. Gewählt wird aber aus `week.people` und
|
||
nicht aus `db.globalPeople`: Die Zuordnung landet in `project.assign` und wird
|
||
vom Board über `currentWeek().people` aufgelöst — wer dort fehlt, erschiene
|
||
nirgends.
|
||
|
||
**Honorar oder Projektgröße, nie beides.** Das jeweils andere Feld wird
|
||
gesperrt statt nur ignoriert (ein Feld, das man ausfüllen kann, ohne dass es
|
||
wirkt, ist eine Falle) und beim Umschalten geleert. Die Projektgröße ist ein
|
||
**Zahlenfeld**, kein Regler: Ein Schieber legt eine Feinabstufung nahe, die
|
||
eine grobe Schätzung nicht hat. Neben ihr steht **kein Eurobetrag** — intern
|
||
rechnet `projektBasisHonorar()` zwar über `referenzHonorar`, aber die Summe
|
||
auszuweisen gäbe der Schätzung eine Genauigkeit, die sie nicht hat
|
||
(`groesseText()` gibt deshalb nur noch die Zahl zurück).
|
||
|
||
**Terminplan** (`wizardPlanZeilen()`): derselbe Rechenweg wie
|
||
`berechneHonorarPlan()` — Phasenhonorar ÷ Stundensatz = Stunden, ÷
|
||
(Arbeitsstunden pro Tag × Personen) = Dauer, Phasen hintereinander über
|
||
`addWorkdays()` (kennt die gepflegten Feiertage). Erzeugt je beauftragter
|
||
Phase eine Aufgabe in `project.plan.todos`.
|
||
- **Aus der Projektgröße wird KEIN Terminplan gerechnet.** Sie ist eine
|
||
Schätzung, kein vereinbarter Betrag; daraus abgeleitete Termine sähen im
|
||
Gantt so verbindlich aus wie errechnete. Der Schritt zeigt das als
|
||
erklärten Hinweis, nicht als kommentarlos gesperrtes Feld.
|
||
- Das **Team steht vor dem Terminplan**, weil die Dauer davon abhängt, wie
|
||
viele daran arbeiten. Die Teamgröße ist die Vorgabe für „gleichzeitig
|
||
daran" und überschreibbar. Ohne diesen Schritt ergab eine Phase über
|
||
180.000 € rechnerisch 172 Arbeitstage für eine Person, und der Plan lief
|
||
über Jahre.
|
||
|
||
### Projekte anlegen/löschen — Rolle „Projektleiter" und Papierkorb
|
||
|
||
- **Projekte anlegen und aus der Projektliste löschen dürfen nur
|
||
`projektleiter` und `admin`** (`users.role`; `VALID_ROLES` enthält jetzt
|
||
`admin|member|projektleiter|kiosk`). `projektleiter` ist sonst ein
|
||
gewöhnliches Mitglied — nicht zu verwechseln mit der Fachrolle
|
||
`Projektleitung` (`user_roles`), die keine Berechtigung verleiht. Vergeben
|
||
in Admin → Benutzerverwaltung. Bearbeiten bestehender Projekte bleibt
|
||
jedem angemeldeten Konto erlaubt.
|
||
- **Serverseitig durchgesetzt**, nicht nur UI: Da `/save-week` immer die ganze
|
||
Woche schreibt, vergleicht `projektlisteAenderungen()` (`server.js`) den
|
||
eingehenden mit dem gespeicherten Stand (Abgleich per `id` oder
|
||
`projectUid`) und liefert neue und fehlende Projekte; ohne
|
||
`darfProjektlisteAendern(req.user)` → 403 vor jedem Schreiben. Die
|
||
Planung-Sandbox ist ausgenommen. Clientseitig blendet
|
||
`darfProjektlisteAendern()` (`js/auth.js`) den Löschen-Knopf in
|
||
`renderBoardRow()` aus, `renderUserBar()` alle Elemente mit der Klasse
|
||
`.nur-projektleitung` (die beiden „+ Projekt"-Knöpfe), und
|
||
`openNewProjWizard()` weist zusätzlich ab.
|
||
- **Papierkorb** (`deleted_projects`: Schnappschuss des kompletten
|
||
Projekt-Objekts als JSON, wer/wann/aus welcher Sitzung). Nach dem
|
||
erfolgreichen Speichern legt `legeInPapierkorb()` die gelöschten Projekte
|
||
dort ab. `purgeGeloeschteProjekte()` entfernt Einträge nach
|
||
`GELOESCHTE_PROJEKTE_TAGE` (30) — beim Start und stündlich.
|
||
Admin → **Papierkorb** (`loadAdminPapierkorb()`, `js/admin/admin.js`):
|
||
`GET /admin/deleted-projects`, `POST /admin/deleted-projects/:id/restore`
|
||
(setzt das Projekt ans Ende seiner Gruppe in der **jüngsten echten
|
||
Sitzung**, leert den Sitzungskommentar, wenn es aus einer älteren Sitzung
|
||
stammt; 409 bei Nummern-Kollision), `DELETE /admin/deleted-projects/:id`
|
||
(sofort endgültig) — alle `requireRole('admin')`. Dateien/Protokolle
|
||
hängen an `project_nr` und werden beim Löschen nie angefasst. Nicht in
|
||
`IMPORT_TABLES` — ein DB-Import lässt den Papierkorb unberührt.
|
||
|
||
### Auftragsstatus — von der Anfrage zum Projekt
|
||
|
||
`projects.auftragsstatus` (`anfrage|angebot|beauftragt|abgeschlossen`, default
|
||
`beauftragt`, column added by `migrateAddProjectAuftragsstatus()`) makes the
|
||
step into a project explicit. Everything lives in `public/js/auftrag.js`:
|
||
|
||
- `auftragsstatusChip(p)` returns an **empty string** for `beauftragt` — the
|
||
normal case must not add noise to thirty rows. Shown in the project list
|
||
(`renderBoardRow()`), in the Wochenbericht agenda row and in its detail head.
|
||
- `auftragsstatusSelectHtml(p)` is the mask, used by the Projekteinstellungen
|
||
(`#settingsAuftragsstatus`) and the "Neues Projekt" dialog.
|
||
- Switching TO `beauftragt` does not write directly: `setAuftragsstatus()`
|
||
opens `#beauftragungModal`, because honorar and the beauftragte
|
||
Leistungsphasen belong to the commissioning. The dialog requires at least one
|
||
Leistungsphase, asks back when honorar is missing, offers to move the project
|
||
out of "In Anbahnung" (that group deliberately does not count towards
|
||
Auslastung) and can jump straight to the Terminplan.
|
||
- In the "Neues Projekt" dialog the group follows the status
|
||
(`newProjStatusGewechselt()` in `js/load/load.js`): Anfrage/Angebot proposes
|
||
"In Anbahnung", back to Beauftragt proposes "Aktive Projekte". It is a
|
||
visible proposal in the select, so it can be overridden.
|
||
- `POST /save-week` rejects an unknown status with 400 before writing anything;
|
||
`auftragsstatus` is in `IMPORT_OPTIONAL_COLS` so older backups still import,
|
||
and `projekte.csv` carries the column.
|
||
|
||
### Projektbereich (tab `plan` in `App.html`)
|
||
|
||
- Each project has its OWN area, not just a Gantt view. The area is split into
|
||
six sections switched by the `.plan-nav` bar (`showPlanSection()` in
|
||
`js/plan/plan-sections.js`); all six live in the DOM at once and are only
|
||
toggled via `.is-active`, so every render function keeps filling its elements
|
||
regardless of visibility (that is why the split needed no changes in
|
||
`plan-info.js`, `plan-gantt.js`, `plan-files.js`). The current section is
|
||
written to the URL fragment with `history.replaceState` (`…#aufgaben`) and
|
||
restored from it on load (`app.initPlanSections()`, called at the end of
|
||
`renderPlanPage()`); a `hashchange` listener follows external changes.
|
||
- **Dashboard** — Anordnung **„Lagebild“** (Eriks Wahl aus drei Entwürfen):
|
||
oben ein schmales **Lageband** (`#planKpiRow`, `renderPlanKpis()`: laufende
|
||
Leistungsphase mit Ring + offene Aufgaben, nächster Meilenstein,
|
||
Team-Auslastung; der frühere „Gesamtfortschritt“ der Aufgaben ist
|
||
entfallen), darunter zwei Spalten (`.plan-lage-raster`), die sich
|
||
unabhängig füllen: links Kommentar der Sitzung (mit Verfasser/Zeit,
|
||
`#planStatusAutor`) und **Termine und Aufgaben** (`renderPlanDashboard()`:
|
||
gegliedert Überfällig / Diese Woche / Später mit festen Anteilen
|
||
`PLAN_DASH_ANTEIL`, Rest als „… weitere in Aufgaben“; ein Klick öffnet
|
||
denselben Aufgaben-/Meilenstein-Dialog wie der Terminplan), rechts
|
||
Leistungsphasen, **Team** (eine Zeile je PERSON, Rollen als Chips —
|
||
vorher stand jemand mit drei Rollen dreimal da) und **Unterlagen** (die
|
||
früheren Kurzkarten Dateien/Auslastung als eine
|
||
Liste; ihre Inhalte füllen weiterhin dieselben Funktionen. Der Eintrag
|
||
„Verlauf“ samt `#planHistoryModal`, `renderPlanHistory()` und
|
||
`openHistoryModal()` ist entfallen — der Verlauf steht jetzt in der
|
||
Kachel „Kommentar der Sitzung“). Stile:
|
||
Block „Projekt-Dashboard Lagebild“ am Ende von `plan-styles.css`. **The LPH progress
|
||
list shows only the BEAUFTRAGTEN Leistungsphasen** (`p.beauftragteLP`,
|
||
set in "Einstellungen") — Erik reported it still listing phases that
|
||
weren't marked as commissioned there. `renderPlanInfo()` previously
|
||
mapped over the full `LPHS` constant unconditionally; it now filters to
|
||
`p.beauftragteLP` when that list is non-empty, falling back to all of
|
||
`LPHS` otherwise (older projects with nothing beauftragt) — the exact
|
||
same `nurBeauftragteLph` rule `BOARD_OPTS` already applies to the
|
||
Wochenbericht/Board card (`js/board/board.js`), just unconditional here
|
||
rather than opt-in, since the Dashboard has no second, unfiltered
|
||
rendering of this list to preserve.
|
||
**Die Kachel „Kommentar der Sitzung“ zeigt den ZULETZT GESCHRIEBENEN
|
||
Kommentar** (Eriks Wahl „A“): Hat die angezeigte Sitzung keinen, den
|
||
jüngsten aus einer früheren, mit Hinweis „angezeigt wird der letzte aus
|
||
KW …“ (`#planStatusHinweis`). Darunter eine Zeitleiste der früheren
|
||
Kommentare (`#planStatusVerlauf`, `planKommentarDaten()`/
|
||
`renderPlanKommentarVerlauf()` in `plan-info.js`): je Gewerk nur, wenn
|
||
sich der Text gegenüber der Sitzung davor geändert hat; gleicher Text in
|
||
mehreren Gewerken einer Sitzung (Altbestand aus der Umstellung auf
|
||
Kommentare je Gewerk) wird EIN Eintrag „HLS, ARCH“. Drei sichtbar, Rest
|
||
über „Alle N anzeigen“. Keine neue Datenhaltung — jede Sitzung hält ihre
|
||
Kopie des Projekts. Stile: Block „Kommentar der Sitzung mit Verlauf“ am
|
||
Ende von `plan-styles.css`.
|
||
**Eigene Kachel „Protokolle“** (Eriks Wunsch; links unter „Termine und
|
||
Aufgaben“, `#planDashProtokollePanel`, vorher nur eine Zeile in
|
||
„Unterlagen“): das letzte Protokoll (Nr., Betreff, Datum, Ort,
|
||
Teilnehmer, offen/erledigt/Info), darunter seine OFFENEN Punkte
|
||
(höchstens 5, nach Termin, überfällige rot — zweiter Abruf
|
||
`GET /project-protokolle/:id`, Anzeige-Nummer über
|
||
`berechneProtokollNummern()`), dann bis zu drei frühere Protokolle.
|
||
Jeder Klick öffnet DIESES Protokoll (`dashOeffneProtokoll()`). Gerendert
|
||
von `renderProtokolleSummary()` in `plan-protokoll.js`; ohne
|
||
Projektnummer (Planung) ausgeblendet. Stile: Block „Dashboard-Kachel
|
||
Protokolle“ am Ende von `plan-styles.css`.
|
||
**Kachel „Team“ nach Gewerk gruppiert** (Eriks Wahl „A“,
|
||
`renderPlanDashboard()` in `plan-sections.js`): erst die Gewerke des
|
||
Projekts (`p.gewerke`, Reihenfolge `DEPARTMENTS`) — ein unbesetztes mit
|
||
Hinweis „Noch niemand aus diesem Gewerk zugeordnet“ —, dann Gewerke von
|
||
Beteiligten, die nicht zum Projekt gehören („kein Gewerk des
|
||
Projekts“), zuletzt „Ohne Gewerk“. Das Gewerk einer Person kommt aus
|
||
ihrem Konto (`personGewerk()`, `users.department`).
|
||
- **Terminplan aufgeräumt** (Eriks Wahl „A“ aus drei Entwürfen):
|
||
**Eine Leiste** statt zwei Zeilen mit neun Knöpfen — Maßstab
|
||
„Übersicht | Wochen | Tage“ (`tpMassstab()`, feste px/Tag statt −/+),
|
||
„Heute“ (`tpHeute()`), Zeitraumfeld (zeigt jetzt den tatsächlichen
|
||
Zeitraum), Filter „Alle | Meine | Überfällig“ (`tpFilter()`), Menü
|
||
**„Ansicht“** (Urlaub, Vorwoche, „Abhängigkeiten als Linien“
|
||
`#planShowPfeile`, Sandbox-PDF; „Zeitraum anpassen“, „Nach
|
||
Abhängigkeiten anordnen“ und „Kritischer Pfad“ hat Erik daraus
|
||
gestrichen — der kritische Pfad ist samt Code entfernt
|
||
(`computeCriticalPath()`, `toggleCriticalPath()`, `.critical`);
|
||
`fitPlanGanttRange()` und `autoScheduleTodos()` bleiben, weil Vorlagen,
|
||
Honorar-Plan, Protokoll-Aufgaben bzw. das Ziehen von Balken sie nutzen)
|
||
und **„+ Neu“** (Aufgabe, Meilenstein, Vorlage, Terminplan aus Honorar,
|
||
Aufgaben aus Protokoll) als `<details class="tp-menue">`; alle Knopf-ids
|
||
unverändert. **Gliederung nach Leistungsphasen**
|
||
(`plan-build-gantt.js`): Phasenbalken (`balkenPhase()`) werden
|
||
zuklappbare Gruppenköpfe (`.gantt-phase`, `tpPhaseUmschalten()`,
|
||
`app.phasenZu`), jede andere Aufgabe steht unter der Phase, in deren
|
||
Balken sie beginnt; außerhalb jedes Balkens unter „Weitere Aufgaben“.
|
||
Nur die ANZEIGE wird umsortiert — `plan.todos` übernimmt die
|
||
Reihenfolge erst beim Ziehen. Zugeklappte und weggefilterte Zeilen sind
|
||
`display:none`, bleiben aber im DOM (der Zählcheck in
|
||
`reorderPlanRows()`); Abhängigkeitslinien zu ihnen werden nicht
|
||
gezeichnet. Namensspalte: Vorgänger nicht mehr als Schildchen, sondern
|
||
im Tooltip (Warnzeichen „!“ bei Terminkonflikt), rechts die Kürzel der
|
||
Zuständigen. Die Projektzeile heißt „Projekt gesamt“. **Meilensteine ohne Aufgabenbezug** (`position` leer) stehen nicht mehr in der Projektzeile, sondern je in einer eigenen Zeile direkt darunter, nach Datum (Eriks Meldung: ohne Aufgaben lagen alle übereinander); der Umschalter im Panel heißt „Oben, eigene Zeile | Bei einer Aufgabe“. `reorderPlanRows()` zählt dafür alle Meilensteine und lässt einen Meilenstein vor der ersten Aufgabe ohne Position. Unter dem
|
||
Diagramm ein Hinweis auf offene Aufgaben ohne Termin (`#planOhneTermin`
|
||
→ Bereich „Aufgaben“). **Phasenbalken farblich getrennt** (Eriks Wunsch): Token
|
||
`--gantt-phase` (Blauviolett, hell `#6a5fc7`, dunkel `#a59ef2` — bläulicher als das Lila der Meilensteine) am
|
||
Ende von `plan-styles.css`. Aufgaben bleiben im Akzentblau, erledigte
|
||
grün, Meilensteine lila. Stile: Block „Terminplan aufgeräumt“ am Ende von
|
||
`plan-styles.css`.
|
||
- **Terminplan** — **Spalte „Projekt“ in der Breite veränderbar wie in
|
||
Excel** (Eriks Wunsch): Griff am rechten Rand JEDER Namenszelle ziehen
|
||
(`ganttGriffeEinsetzen()` setzt ihn nach jedem `renderPlanGantt()` in
|
||
alle Zeilen, auch die aus `plan-build-gantt.js`; click/dblclick werden
|
||
dort abgefangen), Doppelklick passt an den längsten Eintrag an
|
||
(`ganttSpalteZiehen()`/`ganttSpalteAnpassen()` in `js/gantt.js`). Die
|
||
Abhängigkeitspfeile werden beim Ziehen je Bild neu gezeichnet
|
||
(`ganttPfeileNachziehen()` → `renderGanttDependencies()`), sonst blieben
|
||
sie an der alten Stelle stehen. Die Breite steht in der
|
||
CSS-Variable `--gantt-label-w` (alle Zeilen gehen ohne Neuaufbau mit) und
|
||
je Browser in `localStorage['projuebersicht-gantt-spalte']` (120–640px,
|
||
Standard 220px); Zoom und Mindestbreite rechnen mit `ganttSpaltenBreite()`
|
||
statt der früheren festen 220/250px.
|
||
The Gantt with its toolbar and, below it, the resource
|
||
load (`#resourceLoadContainer`, formerly a modal). **Uses the exact same
|
||
Wochenstunden calculation as the Auslastung tab/Wandtafel**
|
||
(`computeResourceLoads()`, `js/plan/plan-gantt.js`, calling
|
||
`berechneAuslastung()`/`loadAmpelColor()`/`kapazitaetsFaktor()` from
|
||
`js/load/load.js`) — Erik reported the two disagreeing ("Die Auslastung
|
||
der Projektbeteiligten im Terminplan ist eine andere als die in der
|
||
sonstigen Anwendung"). It previously counted something unrelated: workday
|
||
spans derived from each person's assigned TODO date ranges in THIS
|
||
Gantt, relative to a locally-scoped maximum among this project's own
|
||
team — a scheduling-density number with no connection to honorar,
|
||
Leistungsphase, or capacity, so the same person could show a completely
|
||
different figure here than on the Auslastung tab. Now, for every project
|
||
of the session (this one and every other), it runs the identical
|
||
per-role `berechneAuslastung()` call `renderLoad()` makes and sums it per
|
||
person — the headline number and Ampel color shown here are therefore
|
||
now literally the same as that person's total on the Auslastung tab, not
|
||
just similarly computed. The split into "aus diesem Projekt" vs. "aus
|
||
den übrigen Projekten der Sitzung" (`stundenProjekt`/`stundenAndere`,
|
||
surfaced as a `plan-resource-other` badge) is kept from the old
|
||
days-based version — without it, someone could look idle in this one
|
||
project while fully booked elsewhere — just re-expressed in hours
|
||
instead of workdays. The Team-Auslastung KPI tile (`renderPlanKpis()`)
|
||
and the compact heat-row preview (`#planResourceSummary`) consume the
|
||
same `computeResourceLoads()` output and were updated the same way
|
||
(percentage now relative to capacity, not to the locally-scoped
|
||
maximum). A fixed side effect: the old bar/number colors were hardcoded
|
||
hex (`#c53030`/`#d69e2e`/`#2f855a`), invisible to the dark-mode token
|
||
system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this
|
||
view now also follows the app-wide dark-mode palette like everywhere
|
||
else.
|
||
- **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“):
|
||
`#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am
|
||
Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der
|
||
Terminplan sichtbar bleibt. Beide gleich gebaut (`.pd`): Kopf mit Art,
|
||
Esc-Hinweis, × und Titelfeld; Abschnitte „Wann“, „Wer“/„Wo im
|
||
Terminplan“, „Stand“, „Hängt ab von“; feste Fußleiste mit „Löschen“
|
||
links (nur beim Bearbeiten — beim Meilenstein stand es vorher auch beim
|
||
Anlegen). Die Meilenstein-Position ist ein Umschalter „In der
|
||
Projektzeile | Bei einer Aufgabe“ (`msLageSetzen()`/`msLageSync()`,
|
||
`plan-modals.js`) über dem bisherigen Feld `#milestoneEditPosition`,
|
||
das allein gespeichert wird. Alle Feld-ids und Schreibwege unverändert.
|
||
Stile: Block „Seitenpanel“ am Ende von `plan-styles.css`.
|
||
- **Leistungsphasen** (eigener Reiter zwischen Terminplan und Aufgaben,
|
||
Eriks Wahl „B“, `js/plan/plan-phasen.js`): oben ein Ablaufbalken aller
|
||
Phasen mit Zeitraum (Heute-Linie, Klick hebt die Kachel hervor), darunter
|
||
eine Kachel je Phase — beauftragte plus alle mit Phasenbalken
|
||
(`balkenPhase()`), sortiert läuft → geplant → fertig. Fortschritt aus dem
|
||
Wochenbericht (`fortschrittProzent()`), „läuft“ = `arbeitsPhase()`,
|
||
Zeitraum aus dem Balken, Aufwand = Summe der Aufgabenstunden der Phase,
|
||
Aufgaben = die, die im Balken beginnen (`aufgabenPhase()`,
|
||
dieselbe Regel wie die Gliederung im Terminplan). „Bearbeiten“ öffnet den
|
||
Balken im Phasen-Panel, ohne Balken „Zeitraum festlegen“
|
||
(`addPlanPhase(lph)` wählt die Phase vor); „Aufgaben ansehen“ springt
|
||
gefiltert in die Aufgaben. „Terminplan aus Honorar“ sitzt jetzt hier
|
||
statt in der Aufgaben-Leiste. Stile: Block „Bereich Leistungsphasen“ am
|
||
Ende von `plan-styles.css`.
|
||
- **Aufgaben ohne Phasenbalken**: `renderPlanTasks()` lässt Aufgaben mit
|
||
`t.lph` weg (Indizes bleiben die aus `plan.todos`); jede Aufgabe trägt ihr
|
||
Phasen-Kennzeichen (`.plan-task-phase`), darüber ein Filter Alle / je
|
||
Phase / ohne Phase (`setzeAufgabenFilter()`, gilt je Projekt).
|
||
- **Aufgaben** — the same todos/milestones as a list (`renderPlanTasks()`,
|
||
`js/plan/plan-tasks.js`). No own data and no own dialogs: a row opens the
|
||
very same `openTodoModal()`/`openMilestoneModal()` as the Gantt, and the
|
||
indices match because the list keeps `plan.todos`/`plan.milestones` order.
|
||
The create buttons (`+ Aufgabe`, `+ Meilenstein`, `+ Vorlage`,
|
||
`+ Terminplan`, `+ TODOs aus Protokoll`) exist twice — in the Gantt
|
||
toolbar and above the list, the latter with an `…BtnList` id suffix;
|
||
`renderPlanPage()` hides both sets on locked weeks.
|
||
**"+ TODOs aus Protokoll"** (`#protokollTodosBtn`/`#protokollTodosBtnList`,
|
||
`js/plan/plan-protokoll-todos.js`) is a second, genuinely independent way
|
||
to turn a meeting protocol into tasks — Erik's explicit request: "ein
|
||
Feature... das die Protokolle per KI einliest und dann aus den
|
||
Protokollpunkten Aufgaben für die Aufgabenseite im Projekt erzeugt". It
|
||
is deliberately **unrelated to `project_protokolle`/`protokoll_punkte`
|
||
and to the Protokolle section** (`js/plan/plan-protokoll.js`) — Erik's
|
||
own words: "Es sollen externe Protokolle im PDF-Format sein, die durch
|
||
die KI eingelesen werden", i.e. an ad-hoc upload of a protocol that may
|
||
never become (or already exist as) a stored Protokoll of this project,
|
||
not a second entry point into the same table. Nothing is persisted on
|
||
either end: no `project_protokolle` row, no file under
|
||
`sessions/uploads/` — the PDF and every intermediate result live only in
|
||
the request/response and the open dialog, the same "ad-hoc tool, no
|
||
archive" model as the Plan Checker (`### Protokolle` below covers the
|
||
always-persisted Protokoll-Import; this is its lightweight opposite).
|
||
Three steps in one dialog (`#protokollTodosModal`), picked by Erik from a
|
||
three-way design comparison (Taste-Skill artifact, "A — Schritt-Dialog"
|
||
vs. a wide split-panel "Werkbank" vs. an inline card in the task list):
|
||
a persistent chrome — title, a 3-segment progress bar
|
||
(`#protokollTodosSteps`), and a fixed Zurück/Abbrechen/Weiter footer —
|
||
stays on screen across all three steps, with only
|
||
`#protokollTodosBody`'s content swapped per step (same "Container-Div
|
||
wird je Phase neu befüllt" idea `applyTemplate()`'s dialog already uses).
|
||
The right-hand "Weiter" button both advances the step AND fires that
|
||
step's network call in one click (`protokollTodosWeiter()`); "Zurück"
|
||
(`protokollTodosZurueck()`) only ever re-renders already-held module
|
||
state, no re-fetch — except a re-shown file `<input>` can't be
|
||
re-populated from script, so going back past step 0 means re-picking the
|
||
file. A genuine KI failure (`zeigeFehler()`, red) is kept visually
|
||
distinct from a successful-but-degraded fallback
|
||
(`zeigeFallbackHinweis()`, the same amber `.protokoll-ki-fallback-banner`
|
||
the Protokoll-Import uses) — a fallback is an accepted outcome, not an
|
||
error.
|
||
(1) upload a PDF + pick a provider → `POST
|
||
/plan-todos-aus-protokoll/einlesen` reuses `erkennePunkteMitKI()`/
|
||
`erkennePunkteAusText()` **unchanged** (server.js) to extract points,
|
||
returned straight to the client, nowhere written to a table; (2) the
|
||
extracted points appear as a checklist (every `status:'offen'` point
|
||
pre-checked, `erledigt`/`info` pre-unchecked but still selectable — one
|
||
surface covers both "whole protocol" and "just this one point"); the
|
||
checked point objects (already in the browser, no server round-trip to
|
||
refetch) go to `POST /plan-todos-aus-protokoll/aufgaben-vorschlag`,
|
||
which runs the **second**, independent KI step, `leiteAufgabenMitKI()` →
|
||
`leiteAufgabenMitChatGPT`/`leiteAufgabenMitClaude` (server.js, same
|
||
`fetchMitTimeout()`/`LOKAL_DISPATCHER`/`reasoning_effort` plumbing as
|
||
`erkennePunkteMitChatGPT`/`Claude`, own `KI_AUFGABEN_SCHEMA`/
|
||
`KI_AUFGABEN_SYSTEM_PROMPT`) — this step *judges* the points rather than
|
||
copying them: several points about the same action can collapse into
|
||
one task, a purely informational point yields none; on any KI failure
|
||
(or no provider chosen) the route falls back to a direct 1:1 mapping
|
||
(`erkennungsart:'direkt'`), the same "always succeed, possibly degrade"
|
||
policy as the Protokoll-Import; (3) the proposed tasks appear as a
|
||
compact row list (`renderVorschlaegeHtml()`) — Erik's pick from a second,
|
||
narrower Taste-Skill comparison scoped to just this step: a real test run
|
||
had already produced 19 proposals from one protocol, and a full card per
|
||
row would have been nothing but scrolling. Each row shows title plus a
|
||
Fälligkeit/Zuständig badge; the full description, date field, and
|
||
Zuständigen-Auswahl only appear once the row is tapped
|
||
(`.protokoll-todo-vorschlag.open`, `.pt-row-detail`). A selection bar
|
||
above the list (`N von M ausgewählt`, "Alle"/"Keine" —
|
||
`protokollTodosAuswahlAlle()`) avoids unchecking rows one by one at that
|
||
volume. Zuständig is pre-matched against `projectAssignees(app.project,
|
||
app.week)` by a simple case-insensitive name match,
|
||
`matchePersonenVonName()` — an external protocol's "Zuständig" is often
|
||
an outside firm with no match at all, which is the expected common case,
|
||
not a bug. Confirming pushes one `plan_todo` per checked row
|
||
straight into `app.ensurePlanLocal().todos` **with both `start` and
|
||
`end` always set** (today / the given Fälligkeit or today) — exactly
|
||
`applyTemplate()`'s/`saveTodoAusKommentar()`'s push shape — specifically
|
||
so every created task is a Gantt row immediately, not a dateless
|
||
checklist item (see the GANTT-row rule right above); `app.savePlan()`
|
||
then `fitPlanGanttRange()` (again mirroring `applyTemplate()`) so the
|
||
visible GANTT window actually includes the new bars instead of only
|
||
showing them in the plain Aufgaben list.
|
||
**A todo is only a Gantt row when it has BOTH a start and an end date
|
||
AND `showInGantt` is not explicitly `false`.**
|
||
`plan.todos` is the one shared list either way — every todo always shows
|
||
up here in Aufgaben, with its assignees (`t.assignees`, "Zuständige"
|
||
chips in `openTodoModal()`, unrelated to whether it has a schedule) — but
|
||
`plan-build-gantt.js` (the `buildGanttHtml()` extension that inserts
|
||
TODO/milestone rows under the project row) only inserts a row for a todo
|
||
with `todo.start && todo.end && todo.showInGantt !== false`; a
|
||
title-only todo (created via `+ Aufgabe` or `+ TODO` with the date range
|
||
left empty — nothing in `saveTodoEdit()` requires dates, only the title)
|
||
stays a pure checklist item that appears solely here, never in the
|
||
Gantt/Terminplan. The Aufgaben row for such a todo shows "ohne Termin" in
|
||
place of a date range (`.plan-task-no-schedule`). Giving it a start/end
|
||
later (editing the same todo through either view) makes a Gantt row for
|
||
it appear on the next render — same object, no separate "convert to
|
||
scheduled" action needed.
|
||
**`showInGantt`** (`plan_todos.show_in_gantt`, `DEFAULT 1`,
|
||
`migrateAddPlanTodoShowInGantt()`) is the independent half of that
|
||
condition — Erik's explicit request, after "TODOs aus Protokoll"
|
||
(`plan-protokoll-todos.js`) showed that a single import can easily create
|
||
15-25 dated tasks at once, which — before this field existed — were all
|
||
unconditionally forced into the Gantt as bars. A checkbox "Im Terminplan
|
||
(GANTT) anzeigen" in the TODO-edit dialog (`#todoEditShowInGantt`,
|
||
`App.html`, read/written in `openTodoModal()`/`saveTodoEdit()`,
|
||
`js/plan/plan-modals.js`) lets a *dated* task still exist (tracked under
|
||
Aufgaben, counted on "Meine Aufgaben", included in dependency-based
|
||
Auto-Schedule/critical-path) without drawing a bar. Checked by default;
|
||
`!== false` rather than `=== true` everywhere it's read, so a todo from
|
||
before this column existed (or any object missing the field) keeps
|
||
showing exactly as before — no retroactive behavior change. Carried
|
||
through "Speichern & Neu" (`openTodoModal(null, {..., showInGantt})`)
|
||
like the other frame fields (Zeitraum/Zuständige), so adding several
|
||
hidden-from-Gantt tasks in a row doesn't mean re-unchecking it every
|
||
time. **Not** touched by `plan-schedule.js`'s Auto-Schedule/critical-path
|
||
(`t.start && t.end`, unchanged) or `plan-gantt.js`'s ghost-date overlay —
|
||
a hidden todo is still a real, scheduled task for every purpose except
|
||
drawing its own bar.
|
||
`reorderPlanRows()` (`js/plan/plan-drag.js`) accounts for this: its
|
||
DOM-row-count safety check and the reordering itself only ever touch
|
||
todos that have `start && end && showInGantt !== false`
|
||
(`scheduledIds`) — exactly the same condition as the insertion above, so
|
||
the count of draggable DOM rows and `scheduledIds.size` never drift
|
||
apart. A title-only or Gantt-hidden todo has no drag handle in the Gantt
|
||
to begin with and keeps its exact position in `plan.todos` untouched by
|
||
any Gantt row drag.
|
||
- **Leistung** — Leistungsstand & Abrechnung (`renderPlanLeistung()`,
|
||
`js/plan/plan-leistung.js`): the value actually earned, per beauftragte
|
||
Leistungsphase, as **Honorar × HOAI-Anteil der Phase × Fortschritt der
|
||
Phase**. The shares come from `GET /honorar-config` and are normalised
|
||
across the beauftragte phases only (same as `berechneHonorarPlan()`), so a
|
||
project commissioned for three phases still adds up to 100 %. Nothing is
|
||
entered here: honorar and the beauftragte phases come from
|
||
"Einstellungen", the progress from the Wochenbericht — this section only
|
||
computes. Three cases deliberately show a hint instead of a number: no
|
||
honorar, no beauftragte phase, no shares stored. **This was the missing
|
||
link** — the app knew the honorar and it knew the progress per phase, but
|
||
never put the two together, so nobody could see what a project had earned.
|
||
It is its own section rather than a Dashboard card because the breakdown
|
||
has one row per phase with five columns; as a card it would either be
|
||
cramped or appear twice.
|
||
- **Dateien** — upload/review/comments (`js/plan/plan-files.js`), formerly a
|
||
modal.
|
||
- **Protokolle** — Besprechungsprotokolle (`js/plan/plan-protokoll.js`), see
|
||
"### Protokolle" below for the data model and the "Fortschreiben"
|
||
mechanism.
|
||
- **Einstellungen — Aufbau mit Unternavigation** (Eriks Wahl „C“): links
|
||
vier Abschnitte (`.plan-einst-nav`) mit Kurzfassung darunter
|
||
(`renderEinstUebersicht()`: „21-4001 · ELT · LPH 5“, „Beauftragt · 230.000 €“,
|
||
„4 von 10 beauftragt · läuft: LPH 5“, „4 Personen · HLS unbesetzt“),
|
||
rechts immer genau einer (`zeigeEinstTeil()`, `plan-settings.js`):
|
||
**Allgemein** (Nummer, Name, Gewerke als Umschalt-Knöpfe, Gruppe,
|
||
amtierende Leistungsphase — Eriks Wunsch, hierhin statt unter
|
||
„Leistungsphasen“ —, Schlagwörter), **Auftrag & Honorar** (Status,
|
||
Honorar, Projektgröße, Aufwand), **Leistungsphasen** (kräftig hervorgehoben: beauftragte Phase = getönte
|
||
Zeile + gefüllte Akzentkapsel „Beauftragt“, nicht beauftragte = blasse
|
||
Zeile + neutrale Kapsel, „läuft“ gefüllt; je Phase eine Zeile
|
||
„Beauftragt | Nicht beauftragt“ mit HOAI-Bezeichnung und „läuft“),
|
||
**Beteiligte** — als **Personenliste** (Eriks Wahl „B“ aus drei
|
||
Entwürfen, dann ausgearbeitet; `renderEinstTeam()`): oben eine
|
||
Besetzungsleiste je Rolle (wer die Hauptverantwortung trägt; fehlt sie,
|
||
orange gestrichelt), ein Suchfeld „Person hinzufügen“ (Vorschläge erst
|
||
aus den Gewerken des Projekts, dann andere; Enter nimmt den ersten;
|
||
hinzugefügt wird mit der ersten Fachrolle als Haupt, die Zeile kurz
|
||
hervorgehoben), darunter eine Zeile je beteiligter Person, nach Gewerk
|
||
gruppiert, mit drei Rollen-Kapseln (umrandet = aus, voll = Haupt,
|
||
gestrichelt = Unterstützung; Antippen schaltet über `planCycleAssign()`
|
||
weiter; Rollen, die das Konto nicht hat, durchgestrichen und gesperrt)
|
||
und „ד (`einstEntfernen()`, 8 s „Rückgängig“ über
|
||
`einstRueckgaengig()`). **Keine Auslastung in diesem Bereich** (Eriks
|
||
ausdrücklicher Wunsch). Angeboten wird jede Person der Sitzung mit
|
||
Fachrolle; wer zugeordnet ist, ohne die Rolle noch zu haben, bleibt
|
||
sichtbar, damit er sich entfernen lässt. Gesperrt: Hinweis, kein
|
||
Suchfeld, kein „ד, Kapseln zeigen den Stand. Alle
|
||
Feld-ids und Schreibwege sind unverändert; die Sandbox blendet weiter
|
||
über `.form-row` aus. `setPlanActiveLp()` rendert die Einstellungen mit
|
||
nach, damit „läuft“ und die Kurzfassung stimmen. Stile: Block
|
||
„Einstellungen mit Unternavigation“ am Ende von `plan-styles.css`.
|
||
- **Einstellungen** — project number/name (display only — the page is
|
||
addressed by them), Gruppe, amtierende Leistungsphase (both still
|
||
rendered by `plan-info.js`), plus Honorar, the **beauftragte
|
||
Leistungsphasen** and the **Beteiligten-Matrix**
|
||
(`renderPlanSettings()`/`planCycleAssign()`, `js/plan/plan-settings.js` —
|
||
the Matrix moved here from a read-only `plan-info.js` display once it
|
||
became genuinely editable, see "Ein Ort je Angabe" above).
|
||
- `p.honorar`/`p.beauftragteLP` can now be edited in TWO places: here and in
|
||
the "Terminplan aus Honorar" dialog (which keeps them so it stays usable on
|
||
its own). Both write through `setHonorarBetrag()`/`toggleBeauftragteLp()` in
|
||
`js/plan/plan-honorar.js` and re-render the other mask afterwards, so they
|
||
cannot drift apart. Persistence is the ordinary `savePlan()` → `POST
|
||
/save-week` path (columns `projects.honorar` / table
|
||
`project_beauftragte_lph`) — no dedicated endpoint.
|
||
- `renderPlanGantt()` is the central refresh hook: it ends by calling
|
||
`renderPlanTasks()` and `renderPlanDashboard()`, so every existing mutation
|
||
path (drag, modals, auto-schedule, templates, honorar plan) updates all three
|
||
views without touching those modules.
|
||
- The former modals (`#planInfoModal`, `#planResourceModal`,
|
||
`#planFilesModal`, and since the comment history moved into the
|
||
Dashboard tile also `#planHistoryModal`) are gone.
|
||
`openResourceModal()`/`openFilesPanel()` kept their names but now just switch
|
||
sections. On a load error (missing params, unknown week/project, network)
|
||
`app.hidePlanNav()` hides the section bar, since every section would be empty.
|
||
Because the area now shows one project after another within the same
|
||
document, `app.showPlanNav()` exists as its counterpart (a fresh page load
|
||
used to reset it for free).
|
||
- **Entry point (two steps, deliberately split).** `setPlanTarget(weekLabel,
|
||
projNr, projName)` only *stores* the target; `openPlanView()` resolves it
|
||
against the already-loaded `db.weeks` and renders. `showTab('plan')` does
|
||
the second step itself, AFTER the area is visible — the Gantt measures its
|
||
container width, which is 0 in a hidden element. `openProjectPlan()` and
|
||
the deep-link path in `initUiState()` therefore only call `setPlanTarget()`.
|
||
Addressing is by week label + project nr/name (never `project.id`, which
|
||
`cloneWeek()` re-assigns). On the very first deep-link render `db.weeks` is
|
||
still empty; that is not an error but a "wird geladen…" state, and `load()`
|
||
calls `showTab(currentTab)` again once the data is there.
|
||
- **There is no separate data load and no separate save path any more.** The
|
||
old page fetched `/load-all` itself and wrote with its own
|
||
`sendWeekToServer()`, because `store.js` wasn't loaded there. In one
|
||
document that would be two writers on the same week sending the same
|
||
revision base — the slower one getting a bogus 409. `savePlan()` is now
|
||
just `markWeekDirty(app.week.label)` + `scheduleSessionSync()`, i.e. the
|
||
ordinary debounced path with its in-flight guard, retry banner, conflict
|
||
handling and 401 login overlay. It passes the label explicitly rather than
|
||
calling `save()`, since `save()` marks the *currently selected* week and a
|
||
deep link may have pointed the Projektbereich at a different one.
|
||
- `updateRouteUrl('plan')` (`js/ui.js`) is the one tab whose URL is not a
|
||
fixed slug: it builds `/Projektplan/<Name>?weekLabel=…&projNr=…` from
|
||
`window.PlanApp` and **keeps `window.location.hash`**, so a link to a
|
||
specific section survives the switch. `applyUrlRoute()` parses the same
|
||
shape back into `{tab:'plan', weekLabel, projNr, projName}`.
|
||
- The `hashchange` listener in `plan-sections.js` and the `resize` listener in
|
||
`plan-gantt.js` both bail out unless `currentTab === 'plan'` — they now run
|
||
while completely different tabs are on screen.
|
||
|
||
### Termin aus Mail (`js/plan/plan-mail-eintrag.js`)
|
||
|
||
Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze
|
||
`.eml`-Datei) oder Strg+V (außerhalb von Eingabefeldern) auf die Bereiche
|
||
**Terminplan** oder **Aufgaben** eines Projekts; eine KI macht daraus
|
||
Einträge. **Nur Aufgaben, keine Meilensteine** — Eriks Vorgabe: Der
|
||
Terminplan ist nur die grafische Darstellung der Aufgabenliste.
|
||
- Beim Hineinziehen erscheint die Fläche `#mailAblage` (fest, `pointer-events:
|
||
none`, Zähler für dragenter/-leave). Nicht in gesperrten Sitzungen. Kein
|
||
Konflikt mit dem Balken-Ziehen (das läuft über Maus-Ereignisse).
|
||
- `POST /plan-eintrag-aus-mail {anbieter, text|eml, kontext}` (`proj.plan`):
|
||
`.eml` wird am Server zerlegt (`leseEml()`: Kopfzeilen, RFC 2047,
|
||
multipart, base64/quoted-printable, Zeichensatz), zitierte Vorgänger-Mails
|
||
abgeschnitten (`mailZitateAbschneiden()`), dann `mailEintraegeMitKI()` mit
|
||
`KI_MAIL_SCHEMA` (Titel, Start/Ende ISO, Zuständige aus der Liste der
|
||
Beteiligten, Aufwand, Zitat). Datum der Mail und heute gehen mit, damit
|
||
„nächsten Freitag“ stimmt. Ohne KI/bei Fehler: ein Eintrag mit dem Betreff
|
||
als Titel. **Speichert nichts.** `.msg` (Outlook) wird abgewiesen mit dem
|
||
Hinweis, Text zu markieren.
|
||
- Anbieter: zuletzt erfolgreich genutzter (`localStorage
|
||
projuebersicht-mail-ki`), sonst der erste mit Schlüssel.
|
||
- Jeder Vorschlag öffnet das normale Aufgaben-Panel vorausgefüllt
|
||
(`openTodoModal(null, prefill)`, neu: `title`, `aufwand`, `notiz`,
|
||
`mailHinweis`, `nachSchliessen`); bei mehreren erst eine Auswahl
|
||
(`#mailAuswahlModal`), dann nacheinander. Ohne Speichern entsteht nichts.
|
||
- **Notiz je Aufgabe** (`t.notiz`, `plan_todos.notiz`, Migration
|
||
`migrateAddPlanTodoNotiz()`, in `IMPORT_OPTIONAL_COLS`): Feld „Notiz“ im
|
||
Aufgaben-Panel für jede Aufgabe; aus einer Mail mit Absender, Datum,
|
||
Betreff, Bezugssatz und Mailtext. In der Aufgabenliste ein Symbol mit
|
||
Tooltip. Stile: Block „Termin aus Mail“ am Ende von `plan-styles.css`.
|
||
|
||
### File uploads (`project_files` / review / comments)
|
||
|
||
- Files are linked to a project via **`(department, project_nr)`**, NOT
|
||
`(department, week_label, project_id)` — `project_id` (`p.id`) is
|
||
regenerated on every `cloneWeek()` (`public/js/weeks.js`), while `p.nr`
|
||
stays stable across the project's whole lifecycle. A project without a
|
||
`nr` cannot receive files: the upload button is disabled client-side
|
||
(`openFilesModal()` in `js/plan/plan-files.js`) and the server rejects with
|
||
400 regardless.
|
||
- **Storage:** `sessions/uploads/<department>/<uuid>.<ext>`
|
||
(`uploadDirFor()`/`extractFileExtension()` in `server.js`). The on-disk
|
||
filename is always a fresh `crypto.randomUUID()` plus an extension taken
|
||
from a whitelist regex (`/\.([a-zA-Z0-9]{1,8})$/`) applied to the
|
||
client-supplied filename — no client-controlled string (`original_filename`,
|
||
`project_nr`, `project_name`, `filename` query param) is ever interpolated
|
||
into a filesystem path. `original_filename` is stored purely for display and
|
||
is only ever used as the second argument to `res.download()` (the
|
||
`Content-Disposition` filename), never as a path segment.
|
||
- **Multi-reviewer workflow:** an upload lists one or more reviewer user IDs
|
||
(query param `reviewers=id1,id2`, deduplicated, filtered to active users,
|
||
uploader removed — no self-review, at least one must remain or the upload is
|
||
rejected with 400). Each reviewer gets a `project_file_reviewers` row with
|
||
`decision='pending'`. `POST /project-files/:id/review` recomputes the
|
||
file's overall `status` after every decision: **any single `rejected` decision
|
||
blocks the file immediately** (terminal); only once **all** reviewers have
|
||
`approved` does the file become `approved` (also terminal). Once terminal, no
|
||
further review decisions are accepted (409).
|
||
- Deletion (`DELETE /project-files/:id`) is allowed for the uploader while
|
||
`status='pending'`, or for an admin at any time; it deletes the
|
||
`project_file_comments` and `project_file_reviewers` rows explicitly (no FK
|
||
cascade — see Persistence section) before the `project_files` row, then
|
||
best-effort removes the physical file.
|
||
- `import` never touches these tables (see `/import` note above) — a DB
|
||
restore neither includes nor overwrites uploaded files or their
|
||
review/comment state.
|
||
|
||
### Protokolle (`project_protokolle` / `protokoll_teilnehmer` / `protokoll_punkte`)
|
||
|
||
Besprechungsprotokolle je Projekt (classic Baubesprechungsprotokoll: Bauherr,
|
||
ausführende Firmen and the office's own team meet, discuss numbered points,
|
||
and unresolved points carry over meeting to meeting) — the seventh section of
|
||
the Projektbereich (`js/plan/plan-protokoll.js`), a persisted document type
|
||
that did not exist before this feature. The "Protokoll" inside the
|
||
Wochenbericht (`openBerichtProtokoll()`) is unrelated: that one is a
|
||
throwaway, client-only text export of what changed in a session, never
|
||
stored and not tied to a single project — this is a genuinely different,
|
||
stored, per-project document.
|
||
|
||
**Scoped by `project_nr`, exactly like `project_files`, NOT by
|
||
`week_label`/`project_id`.** `project_id` is regenerated on every automatic
|
||
session rollover (`ensureCurrentWeekRollover()`, see above), but a Protokoll
|
||
must survive across sessions. `week_label` is still stored on
|
||
`project_protokolle` — purely informational (which session was current when
|
||
the Protokoll was created), never used to look one up. A project without a
|
||
`nr` (a Planung-Sandbox plan) can't have Protokolle — the section is hidden
|
||
there, same reasoning and same mechanism as Dateien (see "Planung —
|
||
projektunabhängige Terminplan-Sandbox" above).
|
||
|
||
**Save pattern: one PUT replaces the whole editable content**, not a REST
|
||
route per field. `PUT /project-protokolle/:id` deletes and re-inserts every
|
||
`protokoll_teilnehmer`/`protokoll_punkte` row of that Protokoll in one
|
||
transaction (same "delete then re-insert" shape as
|
||
`deleteWeekData()`/`serializeWeek()` for a whole week) — closer in spirit to
|
||
how `plan.todos` is saved as one object via `savePlan()` than to
|
||
`project_files`' many small endpoints, because Teilnehmer and Punkte are
|
||
authored together in one sitting. The detail view in
|
||
`js/plan/plan-protokoll.js` is accordingly **inline in the Protokolle
|
||
section, not a modal** — too much content for a dialog — with an explicit
|
||
"Speichern" button (no autosave) and simple text/checkbox fields mutating an
|
||
in-memory copy directly (no re-render per keystroke, to avoid losing input
|
||
focus); only structural changes (add/remove a row, change a point's group)
|
||
re-render.
|
||
|
||
**"Fortschreiben" — the core mechanism, entirely server-side
|
||
(`POST /project-protokolle`, `server.js`):** creating a new Protokoll for a
|
||
project **copies** every `erledigt=0` point of that project's most recently
|
||
created Protokoll into the new one — fresh `id`, same `stamm_id` and
|
||
`nummer`, `neu=0`. `stamm_id` is what makes a point "the same point" across
|
||
Protokolle; it is never exposed to or trusted from the client. A point newly
|
||
added within a Protokoll (no existing `id` in the client's `PUT` payload)
|
||
gets a fresh `stamm_id` and `neu=1` — which drives the yellow highlight
|
||
(`.protokoll-punkt.is-neu`, own CSS tokens `--protokoll-neu-bg`/
|
||
`--protokoll-neu-border` in `styles.css`, deliberately NOT `--band-neu`,
|
||
which already means something else — "neu kommentiertes Projekt", blue — in
|
||
the Wochenbericht). Marking a point `erledigt` in one Protokoll does not
|
||
retroactively change it in earlier, already-created Protokolle (each
|
||
Protokoll's own copy is an immutable snapshot, like a distributed paper
|
||
protocol); it simply isn't copied into the *next* one. The very first
|
||
Protokoll of a project has nothing to copy from, so every one of its points
|
||
is `neu=1` by definition.
|
||
|
||
**`nummer` is computed, never edited — entirely decoupled from the
|
||
Fortschreiben/`neu` logic.** First built as a free-text field the user typed
|
||
by hand; changed on Erik's follow-up request to be fully automatic —
|
||
`berechneProtokollNummern(punkte)` (`js/plan/plan-protokoll.js`, exposed on
|
||
`window` so `exportProtokollPDF()` in `js/pdf.js` can call the identical
|
||
function — same cross-file pattern as `classifyProjectChange()` in
|
||
`wochenbericht.js`, which `pdf.js` also calls directly) derives every point's
|
||
displayed number purely from its **current** `gruppe` and position: a group
|
||
gets a top-level number in the order it first appears among the points, and
|
||
each point inside it gets "`<Gruppennummer>.<Position>`" (e.g. "1.1", "1.2",
|
||
"1.3" for the first group's three points, "2.1" for the second group's
|
||
first); a point with no group is just a plain top-level number on its own
|
||
("1", "2", "3" …). **Groups and ungrouped points share ONE counter for that
|
||
top-level number**, not two separate ones — on Erik's explicit follow-up
|
||
("keine Nummer doppelt… wenn die 1 schon in Ohne Gruppe gibt darf sie nicht
|
||
nochmal in ELT oder HLS auftauchen"): whichever comes first in the points'
|
||
order — an ungrouped point, or the first point of a not-yet-seen group —
|
||
claims the next number, so a plain "1" (an ungrouped point) and a group
|
||
numbered "1" (whose points would read "1.1", "1.2"…) can never coexist in
|
||
the same Protokoll; every top-level number is unique across the whole
|
||
document, standalone points and group prefixes alike. **Never trusts the stored
|
||
`protokoll_punkte.nummer` column** — recomputed fresh on every render (and
|
||
again by `exportProtokollPDF()` from its own freshly-fetched copy), so
|
||
screen and PDF can never disagree and a point's number always reflects
|
||
reality even if it moved groups or another point was added/removed/reordered
|
||
around it. The column still exists and the client fills it with the current
|
||
computed value right before every `PUT` purely for the sake of anyone
|
||
reading the row directly (e.g. via the API) — it is never read back as a
|
||
source of truth. Renumbering therefore never affects whether a point is "the
|
||
same point" across Protokolle (that's `stamm_id`) or whether it counts as
|
||
new (`neu`, set once, only at creation) — moving a point to a different
|
||
group, or another point being deleted and shifting everyone's numbers, has
|
||
zero effect on Fortschreiben/`neu`. `gruppe` itself is a genuinely free-text
|
||
field with no backing table and no fixed list — unlike
|
||
`project_gewerke`/`VALID_DEPARTMENTS`, which are a closed enumeration, a
|
||
Protokoll group is created simply by typing it on a point; a `<datalist>`
|
||
suggests names already used within the currently open Protokoll to curb
|
||
accidental near-duplicates ("ELT" vs "Elt"), fed from already-loaded data —
|
||
no extra server round-trip. Points are displayed **clustered into sections
|
||
by `gruppe`**, in the same first-appearance order that drives the
|
||
Gruppennummer, with an "Ohne Gruppe" section always first when any ungrouped
|
||
points exist. Changing a point's group moves it to the matching section (and
|
||
recomputes every affected number) on the next render (`onchange`, not
|
||
`oninput`, so re-clustering doesn't fire mid-keystroke and steal focus from
|
||
other fields).
|
||
|
||
**Reordering after the fact** (Erik's follow-up request) is two separate,
|
||
matching controls, both plain ↑/↓ buttons re-rendering just the Punkte-Block
|
||
— no drag-and-drop, since a point's *visual* section is already fixed by its
|
||
`gruppe` (dragging it across group sections wouldn't make sense without also
|
||
changing that field, which the Gruppe input already does): `verschiebePunkt(
|
||
idx, richtung)` swaps a point with its neighbour **within the same group**
|
||
(or within "Ohne Gruppe") only — disabled at the top/bottom of that group's
|
||
own list; `verschiebeGruppe(gruppe, richtung)` moves an entire group's points
|
||
as one block relative to the other groups (rebuilds `currentProtokoll.punkte`
|
||
as "Ohne Gruppe" points first, unchanged, then each group's points
|
||
concatenated in the new group order) — this is what actually changes which
|
||
group gets Gruppennummer "1" vs. "2". "Ohne Gruppe" itself is not
|
||
reorderable relative to the groups (it stays the fixed first section, same
|
||
reasoning as its render order above); both buttons are simply absent/disabled
|
||
at a list's boundary rather than wrapping around.
|
||
|
||
**Teilnehmer are free text** (`protokoll_teilnehmer`: name + optional
|
||
Firma/Rolle, binary `anwesend` — deliberately the same "no third state" rule
|
||
as `week.attendance`), independent of user accounts entirely — construction
|
||
meetings routinely include externals (Bauherr, ausführende Firmen) who have
|
||
no login here and never should. Two separate, non-overlapping paths fill it:
|
||
creating a new Protokoll by hand (`neuesProtokoll()` in `plan-protokoll.js`)
|
||
pre-fills the Teilnehmer list from the names of the project's *most recently
|
||
created* Protokoll (with `anwesend` reset to `true` — presence must be
|
||
reconfirmed every meeting, never assumed), falling back to
|
||
`projectAssignees(app.project, app.week)` (internal team only, no Firma) if
|
||
no earlier Protokoll exists, or an empty list otherwise — this prefill is
|
||
pure client-side convenience before the `POST`. Importing a document
|
||
(`POST /project-protokolle/import`) instead sources Teilnehmer from the
|
||
document itself via the KI-gestützte Erkennung (see below) — the two paths
|
||
are never combined/merged: an import shows only what the uploaded document
|
||
actually contains, never the previous Protokoll's roster, so the user is
|
||
never left guessing which source a listed name came from. Either way, the
|
||
server only ever persists whatever Teilnehmer array `legeProtokollAn()` is
|
||
given — it does no inference of its own.
|
||
|
||
**No lock/finalize concept in this first version** — unlike a real paper
|
||
Protokoll (which is fixed once distributed), every Protokoll here stays
|
||
editable indefinitely by any logged-in user, the same permission level as
|
||
editing a todo or milestone (`DELETE /project-protokolle/:id` is likewise
|
||
`requireAuth`, not admin-only — deleting a Protokoll is ordinary project
|
||
content, not an administrative action). A future "abgeschlossen" status
|
||
would be a natural, small follow-up, not part of this feature.
|
||
|
||
**PDF export** (`exportProtokollPDF(protokollId)`, `js/pdf.js`) is the third
|
||
PDF export the app has (alongside `exportWochenberichtPDF()`/
|
||
`exportSandboxPlanPDF()`) — A4 portrait like the Wochenbericht (same
|
||
`PDF_WB_PAGE_W`/`PDF_WB_MARGIN_X`/`pdfWbGridLayout()`), reusing `PDF_STYLES`/
|
||
`loadLogoPng()`/`PDF_ASSIGN_MAIN`. It deliberately re-fetches the Protokoll
|
||
fresh from the server rather than reading `plan-protokoll.js`'s private
|
||
in-memory state, so the exported PDF always reflects the last **saved**
|
||
state, never unsaved edits sitting in an open form. Like the screen, the
|
||
Punktetabelle is split into sections by `gruppe`, and `neu=1` rows get a
|
||
pale-yellow `fillColor` — a literal hex value, not the CSS custom property,
|
||
since a PDF does not react to the viewer's light/dark theme.
|
||
|
||
`project_protokolle`/`protokoll_teilnehmer`/`protokoll_punkte` are part of
|
||
`IMPORT_TABLES` (`server.js`) — a Protokoll created by hand has no on-disk
|
||
component and lives entirely in the database (same reasoning as
|
||
`plan_todos`); an **imported** one additionally references a file on disk
|
||
(see next paragraph), which — like `project_files` — a DB restore neither
|
||
includes nor overwrites.
|
||
|
||
**Import: turning an external protocol into one of these** (`POST
|
||
/project-protokolle/import`, `server.js`) — a second way to create a
|
||
Protokoll, alongside the "+ Neues Protokoll" button, for the common case
|
||
where the Bauherr or an ausführende Firma writes and circulates their own
|
||
protocol instead of the office's own. Rather than just filing the document,
|
||
the route extracts its text and turns it into ordinary Protokoll-Punkte, so
|
||
the result is immediately usable through every feature described above
|
||
(Fortschreiben, Gruppen, Verschieben, PDF-Export) instead of being a second,
|
||
parallel kind of content.
|
||
|
||
- **Two new dependencies**, the first this project has ever needed beyond
|
||
express/cors: `pdfjs-dist` (PDF → text, via its `legacy/build/pdf.mjs`
|
||
Node-compatible entry point, loaded with a dynamic `import()` from
|
||
`server.js`'s otherwise-CommonJS code) and `mammoth` (`.docx` → text). Both
|
||
run entirely locally — no network call, no API key, no per-use cost.
|
||
(A third, `undici`, followed later — not for text extraction but to fix a
|
||
local-server timeout bug in the KI recognition itself; see
|
||
`LOKAL_DISPATCHER` further below.)
|
||
`extrahierePdfText(buf)` (`server.js`) opens the document and walks every
|
||
page's `getTextContent()` items, inserting a newline whenever an item's Y
|
||
coordinate (`item.transform[5]`) changes — the same reconstruction a naive
|
||
`.join(' ')` would lose, and `erkennePunkteAusText()` needs real line
|
||
breaks, since a Punkt-Marker must sit at a line's start.
|
||
**`@napi-rs/canvas`** (only for page-to-image rendering, never used here)
|
||
is merely an `optionalDependency` of `pdfjs-dist`, not a hard one — the
|
||
project's `.npmrc` (`omit=optional`) keeps `npm install` from ever
|
||
fetching it, so no native addon ships regardless of platform.
|
||
**First choice was `pdf-parse@1.x`, abandoned after it failed under
|
||
real testing**: pinned initially to dodge `pdf-parse@2.x`'s hard
|
||
dependency on `pdfjs-dist` + `@napi-rs/canvas`, but `pdf-parse@1.1.4`
|
||
bundles a `pdfjs` build frozen at 2017 (`v1.10.100`) that throws
|
||
`UnknownErrorException: bad XRef entry` on **any** PDF, valid or not, the
|
||
moment Node's own `http` module has been loaded anywhere in the process —
|
||
reproducible with nothing more than `require('http')` before the parse
|
||
call, confirmed unrelated to the PDF's bytes (identical MD5 in and out) or
|
||
their transmission (curl vs. direct Node `http.request` vs. a plain local
|
||
`fs.readFileSync` in the same process — only the last one, with `http`
|
||
never loaded, succeeded). Since `express` itself transitively loads
|
||
`http`, this made `pdf-parse` fail on *every* request through the actual
|
||
server while appearing to work fine in an isolated throwaway script —
|
||
exactly the gap a smoke test that only calls the parser directly would
|
||
miss. Switching to `pdfjs-dist` directly (current, actively maintained,
|
||
zero hard dependencies) was unaffected by the same `require('http')`
|
||
repro. Found and fixed before this reached Erik, by testing the actual
|
||
route end-to-end (script → curl → server) rather than trusting a
|
||
standalone unit-style check of the parsing function alone.
|
||
- **`erkennePunkteAusText(text)`** (`server.js`) is the heuristic, plain
|
||
regex/string-processing, no AI: it looks for line-starting numbering
|
||
markers ("1.1", "TOP 3", "Punkt 2" …, `PROTOKOLL_PUNKT_MARKER`), and for
|
||
each match treats the rest of the line as `thema` and everything up to the
|
||
next match as `text`; within that text, labeled fields ("Zuständig:",
|
||
"Termin:" …, `PROTOKOLL_LABEL_MUSTER`) are pulled out if present, and the
|
||
words "erledigt"/"geschlossen" set `erledigt=true`. **`gruppe` is
|
||
deliberately never guessed** — external documents group too
|
||
inconsistently (by Gewerk, by chapter, not at all) to infer reliably, so
|
||
every recognized point lands in "Ohne Gruppe" for the user to sort by
|
||
hand. If no numbering pattern matches anywhere in the whole document, a
|
||
fallback kicks in: every non-empty paragraph becomes its own point with no
|
||
further parsing. The function is pure (no I/O), independent of whether the
|
||
text came from a PDF or a Word file.
|
||
- **KI-gestützte Erkennung (ChatGPT/Claude) as the primary path, with
|
||
`erkennePunkteAusText()` above as the automatic fallback** — on Erik's
|
||
explicit follow-up request, after the heuristic-only version above had
|
||
already shipped. The import dialog offers a `<select>` ("ChatGPT"/
|
||
"Claude", `#protokollImportErkennung` in `App.html`, built at dialog-open
|
||
time from `GET /my-ai-settings` so an option can carry a live "(kein
|
||
Schlüssel hinterlegt)" hint); the chosen provider is sent as
|
||
`?erkennung=chatgpt|claude` to `POST /project-protokolle/import`.
|
||
Deliberately **not** a third "Textmuster" menu entry — that path is a
|
||
safety net, never a conscious choice, exactly mirroring how a
|
||
missing/failing key behaves. `erkennePunkteMitKI(text, userId, provider)`
|
||
(`server.js`, right next to `erkennePunkteAusText()`) reads that ACCOUNT's
|
||
own key via `readUserAiKey(userId, provider)`; on **any** failure — no key
|
||
stored, network error, non-2xx response, a timeout (`AbortController`,
|
||
`KI_TIMEOUT_MS_CLOUD`/`KI_TIMEOUT_MS_LOKAL`), or a response that doesn't
|
||
match the expected shape —
|
||
the route catches it and calls `erkennePunkteAusText(text)` instead, so
|
||
the import always succeeds, even with no internet access at all. The
|
||
response carries `erkennungsart` (`'chatgpt'|'claude'|'textmuster'`) and,
|
||
only on a fallback, `erkennungshinweis` (the failure reason); the client
|
||
surfaces that via the existing `app.showToast()` (`plan-state.js`) rather
|
||
than silently pretending the AI path was used. **Unlike the heuristic,
|
||
the KI path can also attempt `gruppe` and Teilnehmer recognition** (added
|
||
on Erik's follow-up request, after both had shipped empty/absent from
|
||
every import) — a language model can use context the heuristic's plain
|
||
regex cannot: it recognises an actual section heading ("Elektro:", "TOP
|
||
HLS") and a genuine Teilnehmer-/Anwesenheitsliste (usually at the
|
||
document's start/end), things `erkennePunkteAusText()` deliberately never
|
||
attempts (previous bullet).
|
||
**`KI_SYSTEM_PROMPT` stayed deliberately short after it turned out to be
|
||
extremely sensitive to added instructions** — found when Erik reported
|
||
that a real import ("kopiert die Texte nicht mit, erstellt nur die
|
||
Überschriften") produced almost no usable point text at all, diagnosed
|
||
against his actual, real-world sample document (a professional
|
||
PromanX/Phenix "Besprechungsbericht" export) rather than a synthetic
|
||
toy text. That document turned out to be multi-level numbered — a
|
||
top-level chapter ("01"), a sub-chapter ("01.0"), and only the deepest
|
||
level ("01.0 01.03") carrying real descriptive text — and repeated,
|
||
dated revision paragraphs under the same point across several past
|
||
meetings. Direct, repeatable testing against Erik's local LM Studio
|
||
(`temperature:0`, so not sampling noise) showed the SAME request, with
|
||
only small prompt wording changes, swinging between 0, 1, 10, and 11
|
||
recognised points, and between fully-populated and completely EMPTY
|
||
`text` fields for every point — a longer, more explicit prompt (covering
|
||
the multi-level numbering, an anti-hallucination rule for `gruppe`, an
|
||
explicit Teilnehmer section, a rule for merging dated revision
|
||
paragraphs) reliably performed WORSE than a much shorter one covering
|
||
only the essentials, and even appending one extra, individually
|
||
reasonable sentence to an already-working prompt was enough to regress
|
||
it back to near-total failure. The worst single cause found: a leading,
|
||
general "erfinde nichts, lass im Zweifel leer" instruction appeared to
|
||
make the model default to leaving `text` itself empty out of the same
|
||
caution — the opposite of Erik's actual complaint. `KI_SYSTEM_PROMPT`'s
|
||
final, shipped wording is therefore intentionally minimal (no explicit
|
||
Teilnehmer instructions, no revision-merging rule) and was kept VERBATIM
|
||
from the one specific phrasing that was empirically confirmed to work
|
||
(same token counts/results across repeated runs at `temperature:0`) —
|
||
not polished for house style, since even small rewordings had already
|
||
proven capable of silently breaking it again. At the time this was a
|
||
real, but partial, fix: it recovered most point text where the previous
|
||
prompt recovered almost none, but did not reliably suppress a bare
|
||
chapter/sub-chapter heading from occasionally still becoming its own
|
||
empty point, and revision continuations of an earlier point could still
|
||
surface as separate, oddly-numbered entries.
|
||
**A follow-up round (Erik: multiple distinct points under one heading
|
||
should each come through as their own point, AND the existing paragraphs
|
||
under a point should be carried over) added exactly ONE further sentence**
|
||
and, against the same real-document stability test, did NOT regress —
|
||
"Stehen unter einer Überschrift mehrere eigene Punkte, gib jeden einzeln
|
||
aus. Folgen auf einen Punkt weitere Absätze ohne neue eigene Nummer,
|
||
gehören sie zu diesem Punkt dazu." Confirmed against the document's own
|
||
worst case (`01.0 01.06`, three dated revision paragraphs spanning three
|
||
past meetings): previously these surfaced as orphaned, theme-less
|
||
fragments; with the new sentence they came back as ONE point carrying all
|
||
three paragraphs concatenated (2144 characters), and the earlier
|
||
multi-point case (`01.0 01.01`/`01.0 01.03`/`01.0 01.04` all under the
|
||
same `01.0 Organisation` heading) stayed correctly split into separate
|
||
points. So the earlier "not something further prompt tuning could fix"
|
||
conclusion was an overstatement from stopping after ONE failed attempt,
|
||
not a hard ceiling — the real lesson is that EVERY wording change here
|
||
must be tested against the real document at `temperature:0` individually
|
||
before shipping, since nothing about this model's reaction to a given
|
||
sentence can be predicted from how reasonable the sentence reads.
|
||
**A second, independent failure mode turned up while testing the above**:
|
||
against a test excerpt that was artificially cut to exclude the
|
||
document's own real Teilnehmer-/Verteiler table, the model — still obliged
|
||
by the schema to produce a `teilnehmer` array, with nothing genuine to put
|
||
in it — span into a token-repetition loop (the same short name fragment
|
||
repeated well over a thousand times) until `max_tokens` cut the response
|
||
off mid-string, yielding invalid, unparseable JSON that discarded the
|
||
otherwise-correct `punkte` result too. Re-run with the document's actual
|
||
opening Teilnehmer table included, the loop did not recur, and the request
|
||
finished in a tenth of the time (73s vs. 700+s) — strong evidence the
|
||
trigger was the absence of real participant data for that one field, not
|
||
the new point-splitting instruction, and not something every real import
|
||
will hit (a real Besprechungsbericht always carries its own participant
|
||
table). `frequency_penalty`/`presence_penalty` (`erkennePunkteMitChatGPT()`
|
||
only — Anthropic's Messages API has no equivalent parameter) were added
|
||
as a defensive measure regardless, since a repetition loop is a known,
|
||
generic small-model failure mode worth guarding against independent of
|
||
this one trigger being understood.
|
||
**A third round (Erik: the KI should also judge whether a point is open,
|
||
done, or purely informational) replaced the point's boolean `erledigt`
|
||
output with a tri-state `status` — matching the app's own `offen`/
|
||
`erledigt`/`info` status already added for manual editing (see the
|
||
`protokoll_punkte.status` bullet above) — and taught the model to prefer a
|
||
literal in-document marker over its own judgement: the real sample
|
||
document, it turns out, prints the word "Info" directly under several
|
||
points as their own status field, which `erkennePunkteMitChatGPT()`'s new
|
||
instruction now explicitly looks for first. Confirmed against the same
|
||
real document: status came back correctly split across all three values —
|
||
several points correctly `info` (matching their literal "Info" marker),
|
||
others correctly `offen` or `erledigt` from the narrative content when no
|
||
literal marker applied. Normalisation lives in `normalisierePunktAusKi()`
|
||
(validates against `VALID_PROTOKOLL_PUNKT_STATUS`, falls back to `offen`)
|
||
and in `legeProtokollAn()`'s `zusatzPunkte` loop, which accepts EITHER a
|
||
valid `status` string (the KI path) OR falls back to deriving one from the
|
||
older boolean `erledigt` (the Textmuster heuristic, `erkennePunkteAusText()`,
|
||
was deliberately left alone — it has no way to judge "informational vs.
|
||
actionable", only regex pattern matches for "erledigt"/"geschlossen").
|
||
**This change cost back some of the previous round's paragraph-merging
|
||
quality** — the same real-document test that previously returned a point
|
||
like `01.0 01.06` fully merged (2144 characters, three dated revisions in
|
||
one `text`) now split it again into two or three same-themed entries once
|
||
`status` entered the picture, even after adding one more connecting
|
||
sentence ("bleibt es EIN Punkt mit dem Status des letzten Absatzes") that
|
||
measurably helped (11 fragments down to 9) without fully fixing it. This
|
||
is accepted rather than chased further: unlike the earlier regressions,
|
||
nothing here comes back empty, orphaned, or theme-less any more — every
|
||
fragment still carries its own real thema, text, and now-correct status,
|
||
it is simply split one topic too finely in places. The status-rule and the
|
||
merge-rule competing for the same limited headroom is, like the two
|
||
previous rounds, a model-capability ceiling rather than a wording problem
|
||
left unsolved — the same point about
|
||
a stronger model having more room applies here too.
|
||
A more capable model — the real Cloud `gpt-4o-mini`, or a
|
||
larger local model — has more room for further instructions than this
|
||
one could handle; that remains a model-capability question, not a prompt one.
|
||
`temperature:0` was added to both providers' requests for the same
|
||
reason the instability was diagnosable in the first place: without it,
|
||
identical requests returned different results from run to run, making
|
||
it impossible to tell a prompt regression from ordinary sampling noise.
|
||
`max_tokens` was also added to both (16000 for ChatGPT, 8192 for
|
||
Claude — previously unset for ChatGPT, too low at 4096 for Claude):
|
||
a long Besprechungsbericht with many points needs more completion budget
|
||
than either default/prior value guaranteed, and a response cut short by
|
||
a token limit would look identical to one where the model simply decided
|
||
to stop — a second, independent way the old behaviour could under-report
|
||
points regardless of prompt wording.
|
||
- **Both providers run over Node's built-in global `fetch()`** — no new
|
||
npm dependency, unlike the PDF/Word text extraction above, which
|
||
genuinely needed libraries. `erkennePunkteMitChatGPT(text, apiKey,
|
||
apiUrl)` uses the Chat Completions API (`gpt-4o-mini`) with Structured
|
||
Outputs (`response_format: json_schema`, strict mode) to force the exact
|
||
point shape; `erkennePunkteMitClaude(text, apiKey, apiUrl)` uses the
|
||
Messages API (`claude-haiku-4-5-20251001`) with forced tool-use
|
||
(`tool_choice: {type:'tool', …}`) as the equivalent structured-output
|
||
mechanism — Claude has no separate JSON-schema response mode. Both
|
||
normalise each point through the same `normalisierePunktAusKi()` into the
|
||
same `{nummer, gruppe, thema, text, zustaendig, termin, erledigt}` shape
|
||
`erkennePunkteAusText()` already produces (here `gruppe` is a real
|
||
pass-through, not hardcoded empty — see above), so `legeProtokollAn()`
|
||
and everything downstream needs no branching on where a point came from.
|
||
Each recognizer additionally returns a sibling `teilnehmer` array
|
||
(normalised through `normalisiereTeilnehmerAusKi()` into
|
||
`{name, firma, anwesend}`) — both functions' return value changed from a
|
||
plain points array to `{punkte, teilnehmer}` for this; `erkennePunkteMitKI()`
|
||
passes that object straight through, and the `/project-protokolle/import`
|
||
route destructures it into its own `zusatzPunkte`/`teilnehmerAusDokument`
|
||
locals, passing the latter to `legeProtokollAn()` instead of the
|
||
hardcoded `[]` it started with. On the Textmuster-Fallback path
|
||
`teilnehmerAusDokument` stays `[]`, same as before — the heuristic still
|
||
never attempts Teilnehmer recognition.
|
||
Text sent to either API is capped at `KI_MAX_TEXT_LAENGE` (40,000
|
||
characters) — bounds cost/latency on a pathologically large upload,
|
||
comfortably more than any real Besprechungsprotokoll needs. An `apiUrl`
|
||
argument overrides `KI_DEFAULT_URL[provider]` when the account has one
|
||
configured (see next bullet) — e.g. a locally running, OpenAI-compatible
|
||
server (LM Studio, Ollama) instead of the public cloud API, so the
|
||
feature can be tried with a free local model, or a company proxy/
|
||
self-hosted gateway.
|
||
- **Every account holds its OWN key (and optional URL) — not a firmenweit
|
||
setting.** First built admin-managed/firmenweit, changed on Erik's
|
||
explicit follow-up ("jeder user soll für sich seinen schlüssel
|
||
hinterlegen können und die url angeben können"): a new, small
|
||
**`user_ai_keys`** table (`user_id, provider` primary key, `api_key`,
|
||
`api_url`, `FOREIGN KEY … ON DELETE CASCADE` — same User-Kind-Tabelle
|
||
pattern as `user_roles`/`user_absences`) replaces the two rows that used
|
||
to live in the firmenweit `app_settings` singleton store.
|
||
`readUserAiKey(userId, provider)`/`writeUserAiKey(userId, provider,
|
||
patch)` (`server.js`) are the read/write path. `GET`/`PUT
|
||
/my-ai-settings` (scoped to `req.user.id`, never a client-supplied id —
|
||
same pattern as `POST /change-email`) manage them: `GET` returns, per
|
||
provider, only `{configured, apiUrl}` — **never the key itself**, the
|
||
same "write-only, never read back" shape as a password, while `apiUrl`
|
||
is not secret and comes back so the form can be pre-filled; `PUT` sets
|
||
or (given an explicit empty string) clears a key, and separately
|
||
validates/sets or clears an `apiUrl` via `istGueltigeAiUrl()` (400 if
|
||
invalid), leaving an omitted sub-field untouched. The UI lives
|
||
under **Einstellungen → Mein Konto** (`settings.js`, card
|
||
`#settingsAiCard`) — not in Admin, since it is now personal account
|
||
data like the e-mail address and password right above it in the same
|
||
card list, not an administrative setting. No admin role is required or
|
||
checked anywhere in this path.
|
||
- **The card shows a "Cloud"/"Eigener Server" toggle (`.ai-mode-toggle`,
|
||
`setAiMode()` in `settings.js`) instead of the collapsed "API-URL" link
|
||
the first design used.** Redesigned after real use: connecting a local
|
||
server (LM Studio) turned out to be the first thing Erik tried, not an
|
||
edge case, so hiding the URL field behind a small disclosure undersold
|
||
it — the toggle makes "Cloud" vs. "Eigener Server" a visible choice,
|
||
and the URL field's placeholder names a concrete example
|
||
(`http://localhost:1234 für LM Studio`) instead of an abstract
|
||
explanation. `renderSettingsAiKeys()` opens "Eigener Server"
|
||
automatically whenever a saved `apiUrl` is non-empty, so a configured
|
||
override is never hidden on page load.
|
||
- **`POST /my-ai-settings/test` ("Neu verbinden") actually exercises the
|
||
saved connection** — the card previously only showed whether a key was
|
||
*stored* (`configured`), never whether it *worked*; diagnosing Erik's
|
||
LM Studio setup this week (wrong path, too-short timeout, a reasoning
|
||
model burning almost all its tokens on invisible "thinking") had to
|
||
happen entirely through real import attempts because nothing surfaced
|
||
a connection problem on its own. The route reuses
|
||
`erkennePunkteMitKI()` unchanged, scoped to `req.user.id`, with a short
|
||
dummy text — the exact same code path an import uses, so a successful
|
||
test means an import would succeed too (timeout choice, the local
|
||
`reasoning_effort` handling, and URL normalisation all apply
|
||
identically). Returns `{ok:false, error}` with **HTTP 200** on a failed
|
||
test — a failed connection attempt is an expected, valid outcome to
|
||
report, not a server error, so the client branches on `ok` rather than
|
||
on HTTP status. `testMyAiConnection()` (`settings.js`) drives the
|
||
`.ai-reconnect-btn` next to the status pill (spins via
|
||
`.is-testing` while in flight, disabled whenever the provider isn't
|
||
configured — same disabled-state pattern as the "Entfernen" button) and
|
||
writes a failure into `.ai-test-error`, a plain line under the key row
|
||
that **stays visible** until the next test result replaces or clears
|
||
it — deliberately not a fading toast: a toast firing at the same moment
|
||
the surrounding view changes is exactly how the Protokoll-Import's own
|
||
KI-fallback banner was first missed (see below), the identical lesson
|
||
applied here before it could repeat. A successful test instead uses the
|
||
existing transient `showSettingsNote()` — good news is allowed to fade.
|
||
- **`istGueltigeAiUrl()` allows plain `http://` on the loopback
|
||
(`localhost`/`127.0.0.1`/`::1`) — everywhere else `https://` is
|
||
required.** First shipped as `https://`-only, which broke the single
|
||
most obvious reason to set a custom URL at all: Erik tried pointing it
|
||
at a locally running LM Studio, whose OpenAI-compatible server is
|
||
plain `http://localhost:1234/…` (a local dev server essentially never
|
||
carries a TLS certificate for `localhost`). The `PUT` silently rejected
|
||
that URL with 400, so the account's `api_url` stayed empty and every
|
||
import kept hitting the real, public OpenAI endpoint with an
|
||
OpenAI-shaped key that was never meant for it — confirmed from the
|
||
live server log, which showed OpenAI's own `Incorrect API key
|
||
provided…` message, i.e. the request really did leave the building
|
||
instead of staying on `localhost` as intended. `http` stays disallowed
|
||
for every non-loopback host — the key would otherwise cross a real
|
||
network in plaintext — but on the loopback the request never leaves the
|
||
machine, so there is nothing to protect against. Only meaningful when
|
||
the server itself runs on the same machine as the local LLM server
|
||
(true for a normal `npm start`); inside the Docker container (see
|
||
"Docker" below), `localhost` means the container, not the host, so
|
||
reaching a host-side LM Studio/Ollama instance needs
|
||
`host.docker.internal` (Docker Desktop) or the host's LAN address
|
||
instead.
|
||
- **`normalisiereAiUrl(apiUrl, defaultUrl)` fills in the provider's
|
||
default path when a custom `apiUrl` has none.** LM Studio and Ollama
|
||
are both conventionally addressed by their bare origin
|
||
(`http://localhost:1234`, `http://localhost:11434`) — that's the
|
||
address either tool's own UI shows, not the full
|
||
`…/v1/chat/completions` request path a raw `fetch()` needs. Saving just
|
||
the origin therefore isn't a user mistake to reject, it's the expected
|
||
input; without normalising it, the request landed on `/` at the local
|
||
server. Reproduced directly against Erik's own running LM Studio (this
|
||
Bash tool executes on the same machine the app runs on, so `curl
|
||
127.0.0.1:1234` reached his real instance, not a stand-in): its log
|
||
read `Received request: POST to /` / `Unexpected endpoint or method …
|
||
Returning 200 anyway` for the bare origin, and correctly hit
|
||
`/v1/chat/completions` once the path was appended. Only a *missing or
|
||
empty* path (`new URL(apiUrl).pathname` is `''`/`'/'`) gets the
|
||
default appended — an explicit, different path (e.g. a company proxy
|
||
with its own route) is always left exactly as entered.
|
||
- **`KI_TIMEOUT_MS_LOKAL` (30 min — raised twice from an initial 5 min:
|
||
first to 15 min after Erik reported it still aborted often once the
|
||
prompt fix below made the model generate real text for every point
|
||
instead of staying mostly empty — see that bullet — then to 30 min
|
||
after even 15 min still aborted while LM Studio itself finished cleanly
|
||
at roughly 12,000 generated tokens on his hardware) applies whenever the
|
||
resolved URL is on the loopback; every
|
||
other host keeps the original `KI_TIMEOUT_MS_CLOUD` (45 s).** A single
|
||
45 s timeout across the board silently aborted every
|
||
request to a local model before it could finish — confirmed against
|
||
Erik's own LM Studio, which remained correctly computing well past that
|
||
point ("läuft aber durch") while the app had already given up and
|
||
reported `This operation was aborted`. Cloud APIs keep the short
|
||
timeout: a hung request there really does mean something is wrong and
|
||
should fail fast, whereas CPU/modest-GPU local inference routinely
|
||
takes minutes for a single structured-output completion — nothing to
|
||
time out early for. `fetchMitTimeout()` decides via the same
|
||
`istLoopbackHost()` the URL validation above uses, so both paths share
|
||
one definition of "runs on this machine". On its own this raised
|
||
timeout still wasn't enough for Erik's setup — see the next bullet for
|
||
why, and for the fix that actually resolved it.
|
||
- **`erkennePunkteMitChatGPT()` sends `reasoning_effort: 'none'` to local
|
||
targets — the real fix for "This operation was aborted" even at a 5
|
||
minute timeout.** Diagnosed by querying Erik's actual, running LM
|
||
Studio directly (this Bash tool runs on the same machine as `server.js`
|
||
itself, so `curl 127.0.0.1:1234` reaches it for real, not a stand-in):
|
||
the loaded model, `qwen/qwen3.5-9b`, is a **reasoning model** that
|
||
emits a hidden "thinking" transcript (its own `reasoning_content`
|
||
field, separate from `content`) before every answer. For the trivial
|
||
prompt "Sag nur das Wort: Test", it spent 260 of 264 completion tokens
|
||
— 98.5% — on thinking alone, taking 15 seconds for a one-word answer;
|
||
scaled up to the real task (an 11-page Besprechungsprotokoll, 100+
|
||
points, strict JSON schema), that invisible phase alone plausibly runs
|
||
to thousands of tokens, comfortably past any timeout regardless of how
|
||
generous, even though the model reliably finishes if simply given
|
||
enough time ("läuft aber durch" — Erik's own description). Two
|
||
documented Qwen3 conventions for suppressing it were tried against this
|
||
server and neither worked (`enable_thinking: false`, both top-level and
|
||
nested under `chat_template_kwargs`; the `/no_think` prompt suffix) —
|
||
only `reasoning_effort: 'none'` (the OpenAI o-series parameter, which
|
||
LM Studio's compatibility layer honors) did: `reasoning_tokens` dropped
|
||
to 0 and the same trivial prompt returned in 0.4s. Re-verified with the
|
||
actual `KI_SYSTEM_PROMPT` and `KI_PUNKT_SCHEMA` against a real sample
|
||
protocol text: correct, fully valid points (including correctly
|
||
inferring `erledigt: true` from a point's text) in 11s with zero
|
||
reasoning tokens, and end-to-end through the real import route in
|
||
9.5s (`erkennungsart: 'chatgpt'`, no fallback needed) — the existing
|
||
prompt and schema design needed no changes at all once the reasoning
|
||
phase was out of the way. Sent **only** when `istLoopbackHost()` is
|
||
true for the resolved URL — never to the real OpenAI endpoint, where
|
||
`gpt-4o-mini` (not a reasoning model) doesn't support the parameter and
|
||
an unrecognized field could be rejected outright. Claude is unaffected
|
||
by any of this: Anthropic's Messages API only enables thinking via an
|
||
explicit, opt-in `thinking` parameter that this code never sends, so
|
||
there's no equivalent default-reasoning trap to guard against there.
|
||
- **`LOKAL_DISPATCHER` (`server.js`, a dedicated `undici.Agent`) — the fix
|
||
for a second, independent abort Erik hit once `KI_TIMEOUT_MS_LOKAL` was
|
||
first raised past 5 minutes: `fetch failed` / `Headers Timeout Error`,
|
||
even though LM Studio itself kept computing.** Node's global `fetch()`
|
||
runs on an internal undici client whose OWN default `Agent` closes a
|
||
connection after `headersTimeout`/`bodyTimeout` = 5 minutes each,
|
||
entirely independent of the app's own `AbortController` — invisible as
|
||
long as `KI_TIMEOUT_MS_LOKAL` itself was also ~5 minutes (both fired at
|
||
about the same time, so only the app's own clean "This operation was
|
||
aborted" was ever seen), but once the app's own timeout was raised,
|
||
undici's shorter built-in one fired first instead, surfacing as the
|
||
generic, lower-level `fetch failed`. Reproduced directly: a throwaway
|
||
local HTTP server that delays its response, fetched with a
|
||
deliberately short custom dispatcher, failed with the exact same
|
||
`fetch failed` / `Headers Timeout Error` pair Erik saw; the same setup
|
||
with a long dispatcher let the delayed response through. `fetch()`
|
||
itself has no option for this — only the non-standard (but
|
||
Node-honored) `dispatcher` field in its request options can override
|
||
it, which needs an actual `Agent` instance, hence the one new
|
||
dependency: **`undici`** (a second dependency beyond pdfjs-dist/
|
||
mammoth, and the only one of the three NOT used for its own `fetch` —
|
||
only for its `Agent` class, passed into Node's own global `fetch()` via
|
||
`dispatcher`). `fetchMitTimeout()` attaches `LOKAL_DISPATCHER` only for
|
||
loopback targets, with `headersTimeout`/`bodyTimeout` kept 10s above
|
||
`KI_TIMEOUT_MS_LOKAL` so the app's own, friendlier abort message always
|
||
wins the race against undici's lower-level one. Cloud targets are
|
||
unaffected (`KI_TIMEOUT_MS_CLOUD` at 45s is far under undici's 5-minute
|
||
default, so the problem never applied there).
|
||
- **Stored in plaintext**, the same trust boundary as every other column
|
||
in the same database file (e.g. `user_sessions.token`) — not a new or
|
||
weaker guarantee than the rest of the schema.
|
||
- **Breaks, for this one feature only, the server's previous "no internet
|
||
access needed" property** — `holidaysForYear()` was deliberately built
|
||
without a network dependency so the whole app keeps working on an
|
||
office network without internet (see its own comment in `server.js`).
|
||
KI-gestützte Erkennung inherently needs outbound internet access from
|
||
the server; the automatic fallback to `erkennePunkteAusText()` means the
|
||
app as a whole is unaffected — only this one recognition path silently
|
||
degrades to the offline heuristic when there's no connectivity (or when
|
||
the calling account simply hasn't configured a key).
|
||
- **The result is explicitly a draft, not a finished record** — recognition
|
||
from an arbitrarily formatted foreign document, with or without AI, cannot
|
||
be perfectly reliable (table columns routinely collapse in a plain-text
|
||
extract, and even a capable model can misread a badly scanned source), so
|
||
the design leans toward leaving a field empty rather than guessing wrong:
|
||
an empty field is obviously incomplete when reviewed, a wrongly filled one
|
||
is not. Every imported Protokoll shows a banner in the detail view
|
||
(`.protokoll-import-banner` in `plan-styles.css`, reusing the same
|
||
`--protokoll-neu-bg`/`--protokoll-neu-border` tokens as the `.is-neu`
|
||
point highlight) prompting a check before saving, plus a "Quelldatei
|
||
herunterladen" link (`GET /project-protokolle/:id/quelldatei`, the
|
||
Protokoll equivalent of `GET /project-files/:id/download`) so recognized
|
||
text can always be checked against the original. Both are driven purely by
|
||
whether `quelleDateiname` is set on the loaded Protokoll — no separate
|
||
"was this imported" flag or client-side state.
|
||
- **Shares the ordinary creation path, not a separate one**: the pre-existing
|
||
inline logic of `POST /project-protokolle` was extracted into
|
||
`legeProtokollAn(opts)`, called by both routes, so an imported Protokoll
|
||
gets the exact same `nummer` assignment and Fortschreiben-copy of the
|
||
previous Protokoll's open points as a manually created one — the import
|
||
only adds the recognized points on top (`neu=1`, fresh `stamm_id`, appended
|
||
after the copied-forward points). Nothing about numbering, grouping, or
|
||
reordering (described above) needed to change for this feature — imported
|
||
points are ordinary points from the moment they exist.
|
||
- **Two new nullable columns on `project_protokolle`**,
|
||
`quelle_dateiname`/`quelle_stored_name` (migration
|
||
`migrateAddProtokollQuelle()`, same idempotent `ALTER TABLE` pattern as
|
||
`migrateAddProjectGroesse()`), empty for every hand-created Protokoll. The
|
||
uploaded file itself is stored the same way as `project_files` —
|
||
`sessions/uploads/<uuid>.<ext>` (`uploadDirFor()`/`extractFileExtension()`,
|
||
both reused unchanged) — but **not** in the `project_files` table itself:
|
||
that table's reviewer/approval workflow doesn't apply here (nobody
|
||
"approves" someone else's protocol), so `quelle_stored_name` is the only
|
||
pointer to the file, and `quelle_stored_name` (unlike `quelle_dateiname`)
|
||
is never sent to the client — only used server-side to resolve the
|
||
download route.
|
||
|
||
### Auslastung — nur aus Aufgaben (aktueller Stand, 10/2026)
|
||
|
||
**Löst die drei folgenden Auslastungs-Abschnitte in der RECHNUNG ab** (die
|
||
Darstellung als Personenkarten gilt weiter). Eriks Vorgabe: „Wenn einem User
|
||
eine Aufgabe zugeteilt wird, dann hat diese Aufgabe 8 Stunden und dann ist
|
||
diese Person auch mit 8 Std. ausgelastet. Zwei Personen → jeder die Hälfte.“
|
||
- Gezählt werden NUR offene Aufgaben mit Aufwand (`t.aufwand`) und
|
||
Zuständigen: offener Teil (Aufwand × (1 − Fortschritt)) zu gleichen Teilen
|
||
auf die Zuständigen und auf die Arbeitstage der Aufgabe
|
||
(`aufgabenVerlaufJePerson()`, ohne Termine die nächsten 4 Wochen).
|
||
Aufgaben ohne Aufwand zählen nicht; Phasenbalken nicht.
|
||
- **Entfallen:** das Phasenbudget aus Honorar × Phasenanteil ÷ Stundensatz
|
||
und die **Gewichtungstabelle** Leistungsphase × Rolle — firmenweit (Admin →
|
||
Auslastung) wie je Projekt („Gewichtung bearbeiten“, `#lpModal`). Gelöscht
|
||
in `load.js`: `zuordnungsVerlauf()`, `berechneAuslastung()`,
|
||
`rollenGewicht()`, `aufwandVorlage()`, `aufwandMatrixWert()`,
|
||
`aufgabenAbzug()`, `projektBasisHonorar()`, `phasenAnteilProzent()`,
|
||
`projektVerteilWochen()`, `phasenBalken()`, `verteileGleichmaessig()`,
|
||
`zuordnungsGewicht()` und die LP-Aufwand-Maske. Die Admin-Felder
|
||
Referenzhonorar und Verteilzeitraum sind ebenfalls weg.
|
||
`app_settings.aufwandVorlage`/`referenzHonorar`/`verteilzeitraumWochen`
|
||
und `projects.aufwandProLP` bleiben am Server bestehen, werden aber nicht
|
||
mehr gelesen.
|
||
- Dieselbe Rechnung nutzen Auslastungsseite (`renderLoad()`: Kopf „N von M
|
||
Projekten mit Aufwand in Aufgaben“, Lücken „keine Aufgabe mit Aufwand“
|
||
usw.), Ressourcenansicht im Terminplan (`computeResourceLoads()`),
|
||
Wandtafel (`wandAuslastungJeGewerk()`) und der Aufwand im Bereich
|
||
Leistungsphasen (Summe der Aufgabenstunden der Phase).
|
||
- **Ressourcenansicht im Terminplan** (`computeResourceLoads()`): Zahl =
|
||
Aufgabenstunden DIESER Woche („h diese Woche“); darunter je Person die
|
||
offenen Aufgabenstunden dieses Projekts über alle Wochen
|
||
(`offenProjekt`) — sonst stand bei einer erst später terminierten
|
||
Aufgabe nur „0,0 h“. Zuständige einer Aufgabe, die nicht unter
|
||
„Beteiligte“ stehen, bekommen eine eigene Karte („nur Aufgabe“);
|
||
Phasenbalken stehen nicht mehr in der Aufgabenliste der Karte.
|
||
- **Terminplan als PDF auch für echte Projekte** (Eriks Wunsch): „Ansicht →
|
||
Als PDF exportieren“ (`#sandboxPdfBtn`) ist immer sichtbar.
|
||
`exportSandboxPlanPDF(id)` findet einen Planung-Plan oder das geöffnete
|
||
Projekt und ruft `exportTerminplanPDF(p, sandbox, week)` (`js/pdf.js`):
|
||
Kopf mit Nummer, Name und Sitzung, Datei `Terminplan_<Nr>_<Name>.pdf`.
|
||
Es kommen nur Aufgaben mit Start und Ende und `showInGantt !== false` ins
|
||
PDF, wie am Bildschirm.
|
||
**Gestaltung „A2 — volles Raster“** (Eriks Wahl aus einem Entwurfsvergleich,
|
||
`PDF_TP`, `pdfTpZeilen()`/`pdfTpSeite()`/`pdfTpLegende()`): links Vorgang,
|
||
Start und Ende, rechts Balken über Monaten (im Wechsel hinterlegt) und
|
||
Kalenderwochen, Gitter über das ganze Blatt. Jeder Phasenbalken wird eine
|
||
dunkelblaue Kopfzeile mit Sammelbalken und Fortschritt aus dem
|
||
Wochenbericht, darunter seine Aufgaben (`aufgabenPhase()`) und die
|
||
Meilensteine in seinem Zeitraum, der Rest unter „Weitere Aufgaben“.
|
||
Bewusst ohne Zuständige, Arbeitstage und Heute-Linie. Die Seite ist frei
|
||
positioniert (`absolutePosition`, Canvas für Flächen und Balken). Dabei gilt:
|
||
Oberer Seitenrand 0, Legende in der Fußzeile, kein Text über den rechten
|
||
Rand und der Kopf je Seite neu gebaut — sonst erzeugt pdfmake leere
|
||
Zusatzseiten oder lässt den Kopf ab Seite 2 weg. Zeilenhöhe 17–30 pt, bei
|
||
vielen Aufgaben weitere Seiten mit demselben Kopf.
|
||
- **Projektgröße entfernt** (Eriks Wunsch): kein Feld mehr in den
|
||
Projekteinstellungen (`setPlanGroesse()` weg) und im Assistenten.
|
||
Die Spalte `projects.groesse` ist auch aus der Datenbank entfernt
|
||
(`migrateDropProjectGroesse()`, `ALTER TABLE … DROP COLUMN`, idempotent,
|
||
ersetzt `migrateAddProjectGroesse()`; `normalizeGroesse()` entfällt).
|
||
Ältere Backups mit der Spalte bleiben importierbar — der Import überträgt
|
||
nur Spalten, die beide Schemas haben.
|
||
- **Honorar beim Anlegen freiwillig**: Der Assistent (`projekt-wizard.js`)
|
||
hat im Schritt „Auftrag“ nur noch ein optionales Honorarfeld (keine Wahl
|
||
„Grundlage“ mehr); ohne Honorar zeigt der Schritt „Terminplan“ „Kein
|
||
Terminplan ohne Honorar“, und „Prüfen“ nennt „Honorar: nicht angegeben“.
|
||
Die Beschreibung „Honorar ODER Projektgröße, nie beides“ weiter unten ist
|
||
damit überholt.
|
||
|
||
### Auslastung — Wochenstunden statt Faktoren
|
||
|
||
Die Auslastung wird in **Wochenstunden** gerechnet und erst zum Schluss auf die
|
||
Kapazität der Person bezogen. Vorher war sie eine Summe dimensionsloser
|
||
Gewichte: Ein Projekt in LPH 5 zählte für die Planung 1,0 — ob es 20.000 € oder
|
||
2 Mio. € umfasste. Zwei kleine Projekte wogen damit schwerer als ein großes,
|
||
und die Zahl sagte nichts darüber, wie viel Arbeit anliegt.
|
||
|
||
**Der Weg von einem Projekt zu den Wochenstunden einer Person**
|
||
(`berechneAuslastung()` in `js/load/load.js`, sechs Schritte):
|
||
|
||
1. **Basis** — `projektBasisHonorar()`: das Honorar des Projekts. Fehlt es,
|
||
tritt `projects.groesse` (0–1) mal `APP_SETTINGS.referenzHonorar` an seine
|
||
Stelle. Fehlt beides, ist das Projekt **nicht bewertbar** — es wird als
|
||
Lücke ausgewiesen, nicht als Null gezählt.
|
||
2. **Phase** — `arbeitsPhase()` bestimmt, womit gerechnet wird: die amtierende
|
||
Leistungsphase, und wenn die bereits zu 100 % erledigt ist, die nächste
|
||
beauftragte, die noch offen ist. Ohne diesen Schritt fiel ein Projekt aus
|
||
der Auslastung, sobald jemand seine Phase auf 100 % setzte, ohne
|
||
weiterzuschalten — im Bestand der Normalfall. Sind alle beauftragten Phasen
|
||
fertig, ist das Projekt fertig und bindet zu Recht keine Kapazität mehr.
|
||
`phasenAnteilProzent()` liefert den Anteil dieser Phase am Honorar
|
||
(`HONORAR_ANTEILE`, auf die **beauftragten** Phasen normiert — dieselbe
|
||
Rechnung wie im Leistungsstand), abzüglich des Fortschritts. Übrig bleibt
|
||
der offene Wert.
|
||
3. **Stunden** — offener Wert geteilt durch `APP_SETTINGS.stundensatz`.
|
||
4. **Rolle** — `rollenGewicht()` (Projektmatrix, sonst firmenweite Vorlage,
|
||
unverändert), hier aber als **Anteil**: Gewicht der Rolle durch die Summe
|
||
aller Rollengewichte dieser Phase.
|
||
5. **Köpfe** — Aufteilung auf die Personen, die dieser Rolle im Projekt
|
||
zugeordnet sind (`zuordnungsGewicht()`: Hauptverantwortung 1,
|
||
Unterstützung 0,5). Ohne diesen Schritt zählte jeder von drei Planern die
|
||
vollen Phasenstunden — der Aufwand verdreifacht sich nicht dadurch, dass
|
||
mehr Leute daran arbeiten. Dafür bekommt `berechneAuslastung()` das
|
||
`assign`-Objekt des Projekts als letzten Parameter.
|
||
6. **Zeit** — `projektVerteilWochen()`: die Wochen bis zum spätesten Datum im
|
||
Terminplan; ohne Terminplan (oder bei bereits verstrichenem Ende) gilt
|
||
`APP_SETTINGS.verteilzeitraumWochen`. Ohne Horizont wäre die Wochenlast
|
||
nicht bestimmt — dieselben Reststunden bedeuten über vier Wochen etwas
|
||
anderes als über vierzig.
|
||
|
||
`HONORAR_ANTEILE` (`js/globals.js`) wird von `loadAppSettings()` (`js/store.js`)
|
||
zusammen mit den übrigen Vorgaben aus `GET /honorar-config` geladen, also
|
||
**vor** `/load-all` und damit vor dem ersten `renderAll()`. Der Projektbereich
|
||
holt sie sich für den Leistungsstand weiterhin selbst (`plan-leistung.js`),
|
||
weil er sie auch nach einer Änderung im laufenden Betrieb neu braucht.
|
||
|
||
**Was die Seite sonst noch anders macht als zuvor:**
|
||
|
||
- **Eine Summenzeile je MENSCH** über alle Rollen steht vor den Rollenblöcken.
|
||
Bisher fehlte sie: Wer in drei Rollen arbeitet, hat trotzdem nur eine
|
||
Arbeitswoche, und jede einzelne Rollenzeile blieb unter der Schwelle. Erik
|
||
Dunin kam mit 0,80 Planung und 0,40 Projektleitung auf 1,20 — rot war keine
|
||
der drei Zeilen.
|
||
- **Die Kopfzeile sagt, was die Zahlen abdecken** („1 von 25 Projekten
|
||
bewertet"), und ein Aufklapper listet die nicht bewertbaren Projekte mit
|
||
Grund. Vier Gründe kommen vor: ohne amtierende Leistungsphase, ohne Honorar
|
||
und ohne Projektgröße, ohne Honorar-Anteil für diese Phase, und **niemand
|
||
für \<Rolle\> zugeordnet** — ein Projekt kann bewertbar sein und trotzdem
|
||
bei niemandem ankommen, etwa wenn es in LPH 8 steht (die die Bauüberwachung
|
||
trägt), aber nur ein Planer zugeordnet ist. Eine fehlende Angabe ist keine
|
||
fehlende Arbeit; vorher war beides eine Null.
|
||
- **Das volle Band ist die Wochenkapazität.** Die frühere Skala
|
||
`auslastungBalkenMax` (Vorgabe 8) ist ersatzlos entfallen — samt
|
||
`loadBarMax()`, dem Admin-Feld und dem Eintrag in `APP_SETTING_DEFS`: Sie
|
||
stand im Widerspruch zur Ampel, die ab 1,0 rot wird, sodass ein roter Balken
|
||
nur zu einem Achtel gefüllt war.
|
||
- **`js/pdf.js` rechnet dieselbe Rechnung** (Spalten „h / Woche" und „Anteil
|
||
der Kapazität"), sonst wiesen Bildschirm und Druck verschiedene Zahlen aus.
|
||
|
||
**Die Projektgröße** (`projects.groesse`, REAL 0–1, Migration
|
||
`migrateAddProjectGroesse()`) wird als Regler im Bereich „Einstellungen" des
|
||
Projekts und im Dialog „Neues Projekt" gepflegt; `groesseText()` zeigt neben
|
||
dem Regler den Eurobetrag, weil eine nackte Zahl zwischen 0 und 1 niemandem
|
||
etwas sagt. Unter dem Regler steht, ob er gerade überhaupt zählt — eine Zahl,
|
||
die wirkungslos ist, ohne dass man es sieht, wäre schlimmer als keine.
|
||
Bestandszeilen starten mit 0 (= keine Angabe), absichtlich ohne Vorgabewert
|
||
wie 0,5: Eine erfundene Größe erzeugte Zahlen, die nach Messung aussehen.
|
||
`normalizeGroesse()` (`server.js`) kappt auf 0…1.
|
||
|
||
### Auslastung — Wochenvorschau und Personenkarten (aktueller Stand)
|
||
|
||
Eriks Wahl „Konzept 1“ (Rechnung) und „B“ (Darstellung). **Löst die beiden
|
||
folgenden Abschnitte teilweise ab:** Die Herleitung der Stunden je Phase
|
||
(Honorar × Anteil × Rest ÷ Stundensatz × Rolle × Kopf) gilt weiter, die
|
||
Verteilung über die Zeit und die Darstellung sind neu.
|
||
|
||
- **Verteilung über die Phasenbalken** (`zuordnungsVerlauf()` in
|
||
`js/load/load.js`): Für jede beauftragte Phase AB der laufenden
|
||
(`arbeitsPhase()`; frühere gelten als erledigt) werden die Reststunden auf
|
||
die Arbeitstage IHRES Balkens im Terminplan verteilt
|
||
(`verteileAufBalken()`, ohne Wochenenden/Feiertage) und je Kalenderwoche
|
||
summiert — `VORSCHAU_WOCHEN` (12) ab dieser Woche. Vorher wurde die
|
||
laufende Phase bis zum Projektende gestreckt; eine Phase, die in drei
|
||
Wochen endet, erschien dadurch viel zu leicht. Liegt ein Balken in der
|
||
Vergangenheit, die Phase ist aber offen, fällt der Rest in die nächsten
|
||
`UEBERFAELLIG_WOCHEN` (4). Ohne jeden Phasenbalken gilt das frühere
|
||
Verfahren (`verteileGleichmaessig()`, `projektVerteilWochen()`); die
|
||
laufende Phase ohne Balken ebenso, spätere Phasen ohne Balken zählen
|
||
nicht (kein Zeitpunkt bekannt).
|
||
- **Welcher Balken zu welcher Phase gehört:** AUSSCHLIESSLICH
|
||
`plan_todos.leistungsphase` (Client `t.lph`, `balkenPhase()`; Migration
|
||
`migrateAddPlanTodoLeistungsphase()`, in `IMPORT_OPTIONAL_COLS`) — Eriks
|
||
Wunsch: nicht mehr über den Titel. Gesetzt von „+ Neu“ →
|
||
**„Leistungsphase“** (`addPlanPhase()`: Aufgaben-Panel im Phasen-Modus mit
|
||
Auswahl `#todoEditLph`, Titelvorschlag mit HOAI-Bezeichnung, Phasen mit
|
||
schon vorhandenem Balken gesperrt, „Wer“/Aufwand/„Speichern & neu“
|
||
ausgeblendet; ein Phasenbalken öffnet beim Bearbeiten ebenfalls so), von
|
||
„Terminplan aus Honorar“ und vom Projekt-Assistenten. Ältere Balken, die
|
||
bis dahin nur am Titel erkannt wurden („LPH 5 (307 h)“, „LPH 5 …“, „W+M“),
|
||
hat `phasenbalkenAusTitel()` (server.js) EINMAL gekennzeichnet — Merker in
|
||
der neuen Tabelle `umstellungen` (`einmaligeUmstellung()`); nach einem
|
||
Backup-Import läuft sie erneut, weil ältere Backups das Feld nicht kennen.
|
||
- **Aufgaben zählen mit** (Eriks Meldung: „TODO zugewiesen, aber keine
|
||
Auslastung“). (A) Zuständige einer offenen Aufgabe zählen als Beteiligte
|
||
des Projekts — es erscheint auf ihrer Karte, auch ohne Eintrag unter
|
||
„Beteiligte“, und wer keine Fachrolle hat, bekommt trotzdem eine Karte.
|
||
(B) Optionales Feld **„Aufwand“ in Stunden** im Aufgaben-Panel
|
||
(`#todoEditAufwand`, `t.aufwand`, `plan_todos.aufwand_stunden`, in
|
||
`IMPORT_OPTIONAL_COLS`): der offene Teil (Aufwand × (1 − Fortschritt))
|
||
verteilt sich auf die Zuständigen und die Arbeitstage der Aufgabe
|
||
(`aufgabenVerlaufJePerson()`; ohne Termine die nächsten 4 Wochen) — auch
|
||
in Projekten, die nicht bewertbar oder fertig gerechnet sind. Damit
|
||
nichts doppelt zählt, mindert er das Budget der Phase, in deren Balken die
|
||
Aufgabe beginnt, sonst der laufenden (`aufgabenAbzug()`). Phasenbalken
|
||
selbst zählen nicht als Aufgabe. Die Ressourcenansicht im Terminplan
|
||
(`computeResourceLoads()`) rechnet die Aufgabenstunden dieser Woche mit;
|
||
die Wandtafel (Summen je Gewerk) noch nicht.
|
||
- **Kapazität je Woche** (`wochenKapazitaet()`): Vertragsstunden je
|
||
Arbeitstag × verfügbare Tage — ohne Feiertage und ohne eingetragene
|
||
Abwesenheiten (`db.globalPeople[].absences`). Eine ganz abwesende Woche
|
||
erscheint schraffiert.
|
||
- **`berechneAuslastung()` liefert Woche 0** dieser Vorschau (plus
|
||
`verlauf`) — Wandtafel, Projekt-Dashboard und Ressourcenansicht zeigen
|
||
damit dieselbe Zahl wie „diese Woche“ auf der Auslastungsseite.
|
||
Abwesenheiten berücksichtigt nur die Auslastungsseite.
|
||
- **Darstellung** (`renderLoad()`): eine Karte je Person mit Projektanzahl
|
||
(„3 Projekte, davon 2 in Hauptverantwortung“, Projekte „In Anbahnung“
|
||
getrennt genannt), Wert dieser Woche, zwölf Wochensäulen mit
|
||
gestrichelter 100-%-Linie, Hinweis auf die Spitze und die zwei größten
|
||
Projekte. Sortiert nach Spitze; Personen ohne Projekt zuletzt. Ab dem
|
||
dritten Projekt aufklappbar (Knopf „+ N weitere“ / „weniger“,
|
||
`loadProjekteUmschalten()`, Zustand je Person in `loadProjekteOffen`,
|
||
übersteht das Neuzeichnen; Tooltip je Projekt mit Stunden). Die
|
||
Rollenzeilen, das Lastband und die Projektlegende der Seite sind
|
||
entfallen. Stile: Block „Auslastung als Personenkarten“ am Ende von
|
||
`styles.css`.
|
||
|
||
### Auslastung — das Lastband
|
||
|
||
`renderLoad()` (`js/load/load.js`) zeigt eine **Zeile je Person und Rolle**,
|
||
und der Balken ist nach Projekten aufgeteilt: ein Segment je Projekt, seine
|
||
Breite der Beitrag zur Last. Vorher stand jede Person in einer eigenen
|
||
`.load-card` mit ihrer Projektliste klein darunter — man sah die Zahl, aber
|
||
nicht, woraus sie besteht. Auf eine Auslastung von 1,80 folgt aber immer
|
||
dieselbe Frage: aus einem großen Projekt oder aus fünf kleinen, und was davon
|
||
lässt sich abgeben? Genau das beantwortet das Band.
|
||
|
||
- **Gerechnet wird nichts Zusätzliches.** `stats[key].projects` hält
|
||
`{anzahl, stunden, nr, quelle}` statt nur einer Zählung — derselbe Wert, der
|
||
ohnehin aufaddiert wird, wurde bisher nur verworfen. Die Segmentbreiten sind
|
||
Anteile der Wochenkapazität, sodass ein volles Band eine volle Woche ist.
|
||
Segmente aus einer geschätzten Projektgröße tragen in der Legende ein `≈`.
|
||
- **`projektFarbe(name)`** hasht den Projektnamen in `PROJEKT_FARBEN`, statt
|
||
nach Position zu vergeben: Die Auswertung läuft auch über alle Sitzungen, in
|
||
denen ein Projekt an wechselnder Stelle steht — eine Farbe, die je Sitzung
|
||
springt, wäre schlimmer als keine. Die Palette ist bewusst gedämpft und vom
|
||
Ampelspektrum getrennt (ein Segment sagt, WELCHES Projekt, nicht ob es gut
|
||
steht) und trägt weiße Schrift mit mindestens 4,5:1.
|
||
- **Beschriftung erst ab 7 % der Bandbreite**, sonst bliebe von "21-4001" ein
|
||
angeschnittenes "21-40" stehen. Schmale Segmente behalten Farbe, Tooltip und
|
||
ihren Eintrag in der `.load-legende`, die nur die tatsächlich vorkommenden
|
||
Projekte auflistet.
|
||
- **Eine Null bekommt KEINE Ampelfarbe** (`--text-subtle` statt Grün): Grün
|
||
hieße "gut ausgelastet", die Null heißt aber "nichts zugeordnet". In einer
|
||
Sitzung mit ungepflegten Zuordnungen stünden sonst dreizehn grüne Nullen
|
||
untereinander und meldeten Entwarnung, wo nur Daten fehlen — im Bestand
|
||
genau der Fall.
|
||
- **`loadAmpelColor()` liefert Design-Tokens** (`var(--red)` …) statt der
|
||
früheren Festwerte. Die Ampel färbt jetzt die große Zahl am Zeilenende, und
|
||
`#c53030` kam auf schwarzem Grund nur auf 3,6:1; mit dem Token sind es 6,2:1.
|
||
`pdfLoadAmpelColor()` behält seine druckfreundlichen Festwerte — ein PDF hat
|
||
kein Thema.
|
||
- Innerhalb einer Rolle wird **absteigend nach Auslastung** sortiert: Wer zu
|
||
viel hat, steht oben, und die Zeilen ohne Zuordnung sammeln sich unten,
|
||
statt die Liste zu durchsetzen.
|
||
- **`.load-card`/`.load-row`/`.load-num`/`.load-detail` bleiben bestehen** —
|
||
das Dashboard (`js/dashboard/dashboard.js`) und die Ressourcenansicht des
|
||
Projektbereichs (`js/plan/plan-gantt.js`) bauen darauf auf. Der Show Mode
|
||
kopiert das gerenderte HTML und zeigt das Band deshalb ohne Änderung; die
|
||
drei `.show-mode-content .load-*`-Regeln wurden auf die Zeilenform
|
||
umgestellt.
|
||
|
||
### Projektbeteiligte (global users, cross-department staffing)
|
||
|
||
**Removed from the sidebar navigation** (on Erik's request): once departments
|
||
stopped partitioning weeks/projects/people, this tab's original job —
|
||
pulling a Beteiligter from an ELT/HLS/ARCH-specific roster into a
|
||
department-scoped session — no longer applied to a single shared roster, and
|
||
what remained (a read-only list) duplicated the role/avatar chips already
|
||
visible in Wochenbericht/Board/Auslastung. The `.nav-item.tab[data-tab="people"]`
|
||
button in `App.html` is gone; the tab panel (`#people`), its module
|
||
(`js/people/people.js`) and its deep-link slug (`Projektbeteiligte` in
|
||
`VALID_TAB_SLUGS`) are all still there and still reachable by URL — only the
|
||
sidebar entry point was removed, not the feature itself. The bullets below
|
||
describe that still-live feature; where they mention department-scoped
|
||
rosters, treat that as unverified against current reality (see "Departments
|
||
were retired").
|
||
|
||
- The per-department "Projektbeteiligte" concept — its own `people`/
|
||
`person_roles`/`person_absences` rows with client-side CRUD/drag-reorder —
|
||
has been fully replaced by the global `users` table (see Persistence for
|
||
the migration). `db.globalPeople[department]` is now populated purely by
|
||
the server: `loadGlobalPeople(department)` (`server.js`) reads
|
||
`users`/`user_roles`/`user_absences` filtered to `department` and
|
||
`active=1`, so the Projektbeteiligte tab (`js/people/people.js`,
|
||
`renderPeople()`) is **read-only** — no add/edit/delete/reorder form, no
|
||
drag handles. Only admins maintain team members, exclusively through
|
||
`admin-users.html` (`department` is a required field there, `initials` is
|
||
the board-column label, `roles` are the discipline roles).
|
||
- **Cross-department staffing:** a project's team is not restricted to its
|
||
department's own users. In the Projektbeteiligte tab, an admin (UI-gated
|
||
only, see Auth section) on an unlocked week sees a "+ Person aus anderer
|
||
Abteilung zur aktuellen Sitzung hinzufügen" picker (`loadCrossDeptUserOptions()`/
|
||
`addSelectedUserToCurrentWeekRoster()` in `js/people/people.js`), sourced
|
||
from `GET /users` filtered to `department !== currentDepartment`, a set
|
||
`personId`, and not already in `currentWeek().people`.
|
||
`addUserToCurrentWeekRoster(user)` pushes a normal person object
|
||
(`{id, name, fullName, email, role, absences:[]}`, built from the chosen
|
||
user) directly into `currentWeek().people` and calls the ordinary `save()`
|
||
— no new endpoint needed, since `week.people` is already part of the whole
|
||
week JSON that `/save-week` writes. Because `board.js`'s `renderBoard()`/
|
||
`peopleByRole()`/`cycleAssign()` and `load.js` iterate exclusively over
|
||
`currentWeek().people` (never `db.globalPeople[dept]` directly), the added
|
||
person immediately shows up as its own assignable board column — no
|
||
changes were needed in `board.js`/`load.js`. `renderPeople()` shows such
|
||
entries with an "(andere Abteilung)" hint and a "✕ Aus dieser Sitzung
|
||
entfernen" button (`removeForeignUserFromCurrentWeekRoster()`), which
|
||
refuses to remove a person still assigned to a project in that week (same
|
||
guard the old `delPerson()` had). `cloneWeek()` carries these
|
||
cross-department additions into the next week automatically (it copies
|
||
`week.people` verbatim); `newWeek()` (a fresh start) does not — the roster
|
||
resets to the department's own home users, which is intentional (with an
|
||
explanatory hint in the UI).
|
||
|
||
### Planung — projektunabhängige Terminplan-Sandbox (tab `planung`, `js/planung/planung.js`)
|
||
|
||
**Der Menüpunkt heißt „Terminplan“** (Eriks Wunsch), der Abschnitt der
|
||
Seitenleiste darüber weiter „Planung“. Ebenso „Terminplan“: die Überschrift
|
||
der Seite und das Recht `wz.planung`. Intern bleibt alles `planung` (Tab,
|
||
Dateien, URL `/Planung`).
|
||
|
||
Ein zweiter, eigener Abschnitt der Seitenleiste (nicht Teil von
|
||
"Navigation", zwischen Auslastung und Einstellungen) — eine **Sandbox**, in
|
||
der sich Terminpläne **unabhängig von einem echten Kundenprojekt** anlegen
|
||
lassen: entweder aus einem Honorar errechnet (derselbe Rechner wie bei einem
|
||
echten Projekt) oder frei von Hand begonnen. Jeder angemeldete Nutzer darf
|
||
das, keine Admin-Sperre. Mit voller Terminplan-Funktionalität — Drag&Drop,
|
||
Abhängigkeiten, kritischer Pfad, Auto-Schedule, Zoom — weil ein Sandbox-Plan
|
||
technisch ein ganz normales Projekt ist, nur an einer besonderen Stelle.
|
||
|
||
**Architektur: eine reservierte Sitzung statt eines Flags.** Ein erster
|
||
Ansatz (ein Flag auf einem Projekt der jeweils aktuellen Sitzung) wurde
|
||
verworfen: `cloneWeek()` (`public/js/weeks.js`) vergibt beim Duplizieren
|
||
einer Sitzung jedem Projekt eine neue `id` — ein Sandbox-Plan verlöre bei
|
||
jeder neuen Sitzung seine Identität. `newWeek()` startet dagegen mit
|
||
`projects:[]` — ein Sandbox-Plan in der "aktuellen Woche" verschwände beim
|
||
nächsten `newWeek()` einfach. Und `applyAutoLock()` sperrt jede Woche außer
|
||
der zeitlich letzten — ein Sandbox-Plan würde unvermittelt schreibgeschützt.
|
||
Stattdessen: **eine einzige, dauerhaft reservierte "Sitzung"**,
|
||
`SANDBOX_WEEK_LABEL = '__sandbox__'` (identisch in `server.js` und
|
||
`js/globals.js` — kein gemeinsames Modulsystem zwischen Server und Client).
|
||
`ensureSandboxWeek()` (`server.js`, läuft bei jedem Start wie die übrigen
|
||
Migrationen) legt die `weeks`-Zeile per `INSERT OR IGNORE` einmalig an.
|
||
Jeder Sandbox-Terminplan ist eine ganz normale Zeile in
|
||
`projects`/`project_plan`/`plan_todos`/`plan_milestones`/… mit diesem
|
||
`week_label` — **keine neue Spalte, keine Migration an `projects`** (bis auf
|
||
`updated_at`, siehe unten), `loadWeeks()`/`serializeWeek()` bleiben
|
||
unverändert.
|
||
|
||
**Der entscheidende Vorteil: strukturelle statt gemerkter Isolation.**
|
||
`GET /load-all` (`server.js`) spaltet `loadWeeks()`s Ergebnis auf — die
|
||
Sandbox-Woche wird NICHT als Teil von `weeks[]` ausgeliefert, sondern separat
|
||
als `sandboxPlans` (nur ihre `.projects`) plus `sandboxUpdatedAt`. Der Client
|
||
baut daraus in `load()` (`js/store.js`) ein eigenes, synthetisches Objekt
|
||
`db.sandboxWeek` — niemals Teil von `db.weeks`. Weil `renderBoard()`
|
||
(board.js), `berichtProjekte()` (wochenbericht.js), `berechneAuslastung()`
|
||
(load.js), `exportWochenberichtPDF()` (pdf.js),
|
||
`renderAdminSitzungen()` (admin.js) und jeder künftige Code, der `db.weeks`
|
||
durchläuft, die Sandbox-Daten also **strukturell nie sehen können**, gibt es
|
||
keinen Filter, den man in sechs Dateien hätte vergessen können — anders als
|
||
bei einem Flag-Ansatz. Nur an den wenigen Stellen, die `db.weeks` selbst
|
||
zusammensetzen oder adressieren, gibt es einen expliziten, kleinen Zweig:
|
||
|
||
**Achtung, echte Ausnahme dieser Garantie:** Sie gilt nur für den Weg über
|
||
`GET /load-all` (also für `db.weeks` clientseitig). Jeder serverseitige Code,
|
||
der `loadWeeks()` selbst ein zweites Mal aufruft, statt sich auf `db.weeks`
|
||
zu verlassen, braucht denselben `.filter(w => w.label !== SANDBOX_WEEK_LABEL)`
|
||
direkt nach dem Aufruf erneut — sonst bekäme die reservierte Sandbox-Woche
|
||
dort eine eigene Zeile, als wäre sie eine echte Sitzung. Vor jeder neuen
|
||
serverseitigen Route, die `loadWeeks()` aufruft, lohnt sich deshalb ein
|
||
kurzer Blick auf diesen Abschnitt.
|
||
- `syncAllToServer()` (store.js): `label === SANDBOX_WEEK_LABEL` löst zu
|
||
`db.sandboxWeek` statt zu `db.weeks.find(...)` auf — ohne diesen Zweig
|
||
würden Sandbox-Änderungen lautlos verworfen (`dirtyWeeks.delete()`, ohne je
|
||
zu speichern), weil `db.weeks.find()` die Sandbox nie findet.
|
||
- `openPlanView()` (`js/plan/plan-state.js`): eigener Zweig für
|
||
`app.weekLabel === SANDBOX_WEEK_LABEL`, der `app.week = db.sandboxWeek`
|
||
setzt und das Projekt über die stabile **`id`** sucht statt über `nr`/`name`
|
||
wie bei einem echten Projekt (`openSandboxPlan(planId)`,
|
||
`app.sandboxPlanId`) — Sandbox-Projekte werden nie geklont, ihre `id` ist
|
||
also im Unterschied zu einem echten Projekt dauerhaft stabil, und sie haben
|
||
kein `nr`, also auch keine sinnvolle `nr`/`name`-Adressierung.
|
||
- `applyAutoLock()` (weeks.js): filtert `SANDBOX_WEEK_LABEL` heraus, bevor
|
||
"welche Woche ist die späteste" berechnet wird — ein reines Sicherheitsnetz,
|
||
da die Sandbox-Woche strukturell ohnehin nie in `db.weeks` ankommt.
|
||
- `validateWeekLabel()` (weeks.js) lehnt den reservierten Namen als von Hand
|
||
eingegebene Sitzungsbezeichnung ab; `/delete-week`/`/unlock-week`
|
||
(server.js) lehnen ihn mit 400 ab — die reservierte Woche ist über die
|
||
normale Sitzungsverwaltung weder löschbar noch entsperrbar.
|
||
- `updateRouteUrl()`/`applyUrlRoute()` (`js/ui.js`) und `initUiState()`
|
||
(`js/init.js`): die Deep-Link-Adresse eines Sandbox-Plans trägt `planId`
|
||
statt `projNr` (`/Projektplan/<Name>?weekLabel=__sandbox__&planId=…`), weil
|
||
ein Sandbox-Plan kein `nr` hat.
|
||
|
||
**`projects.updated_at`** (neue, idempotente Migration
|
||
`migrateAddProjectUpdatedAt()`, gleiches Muster wie
|
||
`migrateAddProjectGroesse()`) ist die einzige neue Spalte — nötig, weil
|
||
mehrere, voneinander unabhängige Sandbox-Pläne in EINER Woche liegen und sich
|
||
"zuletzt bearbeitet" bis dahin nur je WOCHE unterscheiden ließ
|
||
(`weeks.updated_at`). Für echte Projekte harmlos mitgeführt, aber ungenutzt.
|
||
Befüllt in `serializeWeek()`s bestehendem `insProject.run(...)`.
|
||
|
||
**Welche Projektfelder ein Sandbox-Plan nutzt:** `name` (einzige Identität,
|
||
Pflichtfeld, keine Duplikate — geprüft in `erstellePlanungTerminplan()`),
|
||
`honorar` + `beauftragteLP` (treiben den vorhandenen "Terminplan aus
|
||
Honorar"-Rechner, `plan-honorar.js`, unverändert), der komplette
|
||
`plan`-Baum (Todos/Meilensteine/Abhängigkeiten/Beteiligte). NICHT genutzt:
|
||
`nr`, `group`, `auftragsstatus`, `groesse`, `project_gewerke` — jeweils an
|
||
echte Geschäftslogik gebunden, die für eine Sandbox keinen Sinn ergibt.
|
||
|
||
**Wiederverwendeter Projektbereich, mit ausgeblendeten Abschnitten.**
|
||
`openSandboxPlan(planId)` führt in denselben Projektbereich (`#plan`), der
|
||
auch für echte Projekte dient — keine zweite Gantt-Implementierung. Ein
|
||
zentraler Merker `app.isSandboxPlan()` (`plan-state.js`, neben dem
|
||
bestehenden `app.isLocked()`) blendet aus: die Bereiche **Dateien** (kein
|
||
`nr`, kein sinnvoller Prüf-Workflow), **Leistung** (echte
|
||
Abrechnung/Auftragsstatus-abhängig) und **Protokolle** (hängt wie Dateien an
|
||
`project.nr`) ganz (`plan-nav`-Knöpfe versteckt, `initPlanSections()` in
|
||
plan-sections.js fängt zusätzlich einen direkten
|
||
`#dateien`/`#leistung`/`#protokolle`-Link ab und fällt auf Dashboard zurück); in
|
||
**Einstellungen** (`plan-settings.js`) die Felder Projektnummer,
|
||
Auftragsstatus, Gewerke, Projektgröße — **Honorar, beauftragte
|
||
Leistungsphasen und die Beteiligten-Matrix bleiben sichtbar**. Die
|
||
Kachel „Kommentar der Sitzung“ zeigt dort keinen Verlauf (eine nie
|
||
geklonte Sitzung hat keine früheren Fassungen). Die Kopfzeile zeigt bewusst NICHT das rohe
|
||
`week.label` ("__sandbox__", ein interner Marker) als Untertitel, sondern
|
||
"Planung — unabhängig von einem laufenden Projekt".
|
||
|
||
**Anlegen** — bewusst kein mehrstufiger Assistent wie "Neues Projekt" (der
|
||
verlangt Gewerke/Team/Auftragsstatus, die hier nicht zutreffen), sondern ein
|
||
schlanker Dialog (`#neuerTerminplanModal`, `erstellePlanungTerminplan()`):
|
||
Name + Wahl zwischen "Aus Honorar berechnen" (landet in Einstellungen mit
|
||
fokussiertem Honorar-Feld; der Nutzer nutzt danach den bereits vorhandenen
|
||
"Terminplan aus Honorar"-Knopf dort — kein eigener Rechen-Code) und "Leer
|
||
beginnen" (landet direkt im leeren Terminplan).
|
||
|
||
**Bekannte, unveränderte Alteigenheit:** `saveWeekRelational()` löscht und
|
||
schreibt beim Speichern die komplette Woche neu — bearbeiten zwei Personen
|
||
gleichzeitig zwei verschiedene Sandbox-Pläne, kann es zu einem 409-Konflikt
|
||
kommen, obwohl sich die Änderungen inhaltlich nicht überschneiden. Keine neue
|
||
Schwäche dieses Features, sondern dieselbe Begrenzung, die heute schon gilt,
|
||
wenn zwei Personen zwei verschiedene echte Projekte derselben Woche
|
||
gleichzeitig bearbeiten.
|
||
|
||
### Einstellungen (tab `settings`, `js/settings/settings.js`)
|
||
|
||
- This area belongs to the signed-in account itself and holds **"Mein
|
||
Konto"** plus, in a second card, its own **"KI-Zugangsdaten"** — the
|
||
account's personal OpenAI/Claude API key (+ optional API-URL override) for
|
||
the Protokoll-Import's KI-gestützte Erkennung, see "### Protokolle" for
|
||
the full `user_ai_keys`/`GET`+`PUT /my-ai-settings` mechanism. "Mein
|
||
Konto" itself holds master data for display, the account's own e-mail
|
||
address, and the password change. Display name, role and `personId` stay an admin's
|
||
job via `PATCH /admin/users/:id`; the area calls out a missing `personId`
|
||
explicitly, since that silently empties "Meine Aufgaben"
|
||
(`GET /my-dashboard` matches on `plan_todo_assignees.person_id`).
|
||
**E-mail is the one field the account edits itself** (inline input next to
|
||
the value, `submitEmailChange()` → `POST /change-email`, see Server API) —
|
||
it used to be admin-only like the rest of that group, but unlike display
|
||
name or role it is purely contact information the account owner is best
|
||
placed to keep current, and making someone wait on an admin just to enter
|
||
their own address was needless friction. An admin can still set/correct it
|
||
via the Benutzerverwaltung, e.g. for a colleague who hasn't logged in yet.
|
||
- Everything company-wide used to sit here next to it. That mixed two
|
||
different things in one list — your own account details, which concern
|
||
everyone, and defaults that shift *everyone's* numbers and belong to admins
|
||
alone. The latter moved to the Admin area below.
|
||
- Until this area existed the app had **no settings page at all**. The sidebar
|
||
group labelled "Einstellungen" contained only *actions* (Neue Sitzung,
|
||
Sitzung duplizieren, PDF-Export, Show Mode, CSV-Export) and is therefore now
|
||
labelled **"Werkzeuge"** (accordion key `tools`, ids `toolsAccordionToggle`/
|
||
`toolsSubnav`).
|
||
- **CSV-Export removed entirely** (route, `js/csv.js`, the "CSV Export"
|
||
button) — on Erik's explicit call: with a real SQLite database behind the
|
||
app now, a standing CSV export of the same data no longer earns its place;
|
||
`GET /backup` already covers taking the data out wholesale. Unlike the
|
||
Projektbeteiligte nav removal, this was a full removal, not just an
|
||
unlinking — no route, no deep link, nothing left to find by URL.
|
||
`buildExportCsvs()`/`buildZip()`/`csvFile()`/`csvCell()`/`CSV_SEP` (all
|
||
server.js) went with it.
|
||
- **"Neue Sitzung"/"Sitzung duplizieren" removed as well**, for an unrelated
|
||
reason: see "Automatischer Sitzungswechsel" below — creating a session is
|
||
no longer a user action at all.
|
||
- **"PDF Export" (`exportPDF()`) removed too** — on Erik's call, PDF export
|
||
should live only in the Wochenbericht and in Planung-Terminpläne, not as a
|
||
standalone whole-session export. Along with the button and the function
|
||
itself, `pdf.js` lost every helper exclusive to it (`pdfRoleBg()`,
|
||
`pdfTableLayout()`, `pdfHeaderRows()`, `pdfProjectRow()`,
|
||
`pdfGroupTitleRow()`, `pdfColumnWidths()`, `pdfCheckWidth()`,
|
||
`pdfAttendanceBlock()`, `pdfLoadSection()`, and the color constants
|
||
`PDF_ROLE_BG`/`PDF_ALT_ROW_BG`/`PDF_PAD_X`/`PDF_LINE_W`) — everything the
|
||
remaining two exports (`exportWochenberichtPDF()`/`exportSandboxPlanPDF()`)
|
||
still share (`PDF_STYLES`, `PDF_NEUTRAL_BG`, page geometry,
|
||
`pdfStatusText()`, `pdfNormalizeColor()`, `loadLogoPng()`) stayed. This was
|
||
the one export that showed every project across every Gewerk in a single
|
||
file — that capability is gone with it; the per-Gewerk Wochenbericht PDFs
|
||
are now the only filed record. **The whole „Werkzeuge“ group is gone
|
||
from the sidebar** (Eriks Wunsch): new projects are created via
|
||
„+ Projekt“ in the project list, and only the Wandtafel account needs the
|
||
Wandtafel (it lands there automatically via `showTab()`; anyone else can
|
||
still open `/Wandtafel` directly).
|
||
|
||
### Login-Seite (`#loginOverlay`)
|
||
|
||
Full-screen page, not a dialog: design "Kalenderwoche" (Erik's pick B from
|
||
three Taste-Skill drafts). The logo is a large, pale watermark across the
|
||
whole page (`.login-wasserzeichen`, `/logo.png` converted from `logo.ico`;
|
||
Erik's pick E from a second comparison of logo placements) — deliberately
|
||
no small logo. Left: heading and form; right: the current ISO calendar week as a large
|
||
number with Mo-Fr and today highlighted (`renderLoginWoche()` in
|
||
`js/auth.js`, called by `showLoginOverlay()`) — the app thinks in weekly
|
||
sessions, so the week is what you see before logging in. Form has inline
|
||
errors (`.login-feld.hat-fehler`, `#loginError`), a loading state on the
|
||
button, "Anzeigen" for the password and a caps-lock hint
|
||
(`verdrahteLoginHilfen()`). Styles: "LOGIN-SEITE" block at the end of
|
||
`styles.css`, app tokens plus `--login-zahl`/`--login-jahr`. The number uses
|
||
the web font Outfit (same Google-Fonts link as the Wandtafel, falls back to
|
||
the system font offline). `z-index: 400`, above `.modal-bg` (300).
|
||
|
||
### Admin (tab `admin`, `js/admin/admin.js`)
|
||
|
||
- Everything only admins may do, in one place. Split into **sections, one
|
||
visible at a time**, switched from the sidebar: "Admin" is an accordion
|
||
(`#adminAccordionToggle`/`#adminSubnav`) whose sub-items call
|
||
`showAdminSection()`. `ADMIN_SECTIONS` is the single place a section is
|
||
defined (key, heading); the binding to the markup runs through the
|
||
`data-admin-section` attribute on both the card in `App.html` and the
|
||
sidebar item. The chosen section lives in the URL fragment
|
||
(`/Admin#auslastung`) — same pattern as `.plan-nav` in the Projektbereich —
|
||
which is why `updateRouteUrl()` keeps `location.hash` for `admin`.
|
||
- Two deviations from the "Werkzeuge" accordion, both deliberate: its head
|
||
entry also *navigates* (the sub-items are in-page navigation, not actions,
|
||
so a head that shows nothing would mislead) — from elsewhere a click always
|
||
enters the area and expands, only from inside does it toggle; and its
|
||
sub-items carry a persistent `.is-active` highlight.
|
||
`syncAdminNavState()` in `js/tabs-auth.js` marks the head active while the
|
||
area is open, because it is no longer a `.tab` button and the generic
|
||
highlight does not reach it.
|
||
- `showTab('admin')` redirects non-admins to `DEFAULT_TAB`: without it a
|
||
forwarded `/Admin` link showed a member an empty page, since
|
||
`renderAdminArea()` renders nothing for them.
|
||
- Sections:
|
||
- **Benutzerverwaltung** — accounts, roles, weekly hours, absences
|
||
(`js/admin-users.js`, `renderAdminUsers()`). Row actions: "Bearbeiten"
|
||
stays a visible button, everything else sits behind a "⋯" menu
|
||
(`openUserActionMenu()`, Erik's pick "C" from a three-way comparison):
|
||
Passwort zurücksetzen, De-/Aktivieren, and — only for deactivated
|
||
accounts — Endgültig löschen. Irreversible actions confirm inside the
|
||
menu instead of `confirm()`; the action functions take an
|
||
`ohneRueckfrage` flag for that. The menu is appended to `<body>` with
|
||
`position: fixed` (the table's horizontally scrolling frame would clip
|
||
it) and flips upward when there is no room below.
|
||
**Neue, geänderte und reaktivierte Konten landen sofort im
|
||
Teilnehmerkreis der aktuellen Sitzung** (`kontenInAktuelleSitzung()`,
|
||
`server.js`; Kürzel, Name und Fachrollen werden dort abgeglichen,
|
||
ältere Sitzungen bleiben unberührt) — beim Serverstart zusätzlich für
|
||
alle aktiven Konten. Vorher entstand `week_people` nur beim
|
||
Sitzungswechsel; auf einer frischen Datenbank, deren Sitzung vor den
|
||
Konten angelegt wurde, bot der Assistent „Neues Projekt" niemanden an.
|
||
**Vor- und Nachname** are separate fields and columns
|
||
(`users.first_name`/`last_name`, migration `migrateUsersVorNachname()`);
|
||
`display_name` stays and is always written as "Vorname Nachname" by
|
||
`POST`/`PATCH /admin/users*` — every other place still reads
|
||
`display_name`; existing accounts got Vor-/Nachname once from
|
||
`display_name` (first word / rest). **The username is the local part of
|
||
the e-mail address** (`e.dunin`) — Erik first wanted "Vorname Nachname",
|
||
then reverted to this before it ever reached the real database, so
|
||
existing usernames were never renamed. In the new-user dialog the
|
||
username follows the e-mail field (`benutzernameAusEmail()`), which
|
||
itself follows Vor-/Nachname; each field stops following once edited by
|
||
hand. **Person-IDs of new accounts come from the Kürzel** (`p_mk`,
|
||
`personIdAusKuerzel()`), with a digit appended if taken (`p_mk2`) — the
|
||
scheme every existing account and its project data already use. Before,
|
||
the dialog derived them from the full name (`p_miriam_k_rner`), which
|
||
split one person into two ids (account vs. project data).
|
||
`/login` matches `COLLATE NOCASE` and also accepts the full
|
||
address (everything from "@" is dropped); `POST /admin/users` checks
|
||
uniqueness case-insensitively (409).
|
||
The e-mail field
|
||
pre-fills `v.nachname@rauschenberg-ing.de` from Vor-/Nachname
|
||
(`emailVorschlag()`/`EMAIL_DOMAIN`, `js/utils.js`; umlauts transliterated)
|
||
while not edited by hand — same pattern as Person-ID/Kürzel; the edit
|
||
dialog and Einstellungen → Mein Konto only show it as a placeholder and
|
||
never overwrite a stored address; reloaded from the server
|
||
every time the section is entered, since the list changes outside this
|
||
browser and the other masks compute with it (weekly hours → Auslastung).
|
||
Marked `wide: true` in `ADMIN_SECTIONS`, which lifts the grid's width cap
|
||
— the table has ten columns.
|
||
- **Honorar** — `stundensatz` + the per-LPH shares (`PUT /honorar-config`).
|
||
A running sum turns amber unless the shares add up to 100 %.
|
||
- **Auslastung** — `vollzeitStundenProWoche`, the ampel thresholds,
|
||
`referenzHonorar`, `verteilzeitraumWochen`, and `aufwandVorlage` (the per-LPH/role weighting that applies to
|
||
every project without its own matrix — i.e. most of them, which is why it
|
||
quietly determined a large part of the Auslastung while it was a code
|
||
constant).
|
||
- **Termine & Sitzungen** — `arbeitsstundenProTag` and `autoLockAktiv`.
|
||
Switching auto-lock off does **not** retroactively unlock: already-locked
|
||
weeks stay locked, unlocking remains a deliberate admin action
|
||
(`POST /unlock-week`).
|
||
**Also holds the "Sitzungen" list** (`renderAdminSitzungen()`,
|
||
`js/admin/admin.js`) — every week with its lock state and, for anything
|
||
that isn't the one open week, an action to reach it. This replaced the
|
||
week `<select>` that used to sit in the topbar (`#appTopbarWeek`,
|
||
removed): sessions are storage and history now, not something the whole
|
||
UI asks you to pick from. In normal operation there's exactly one row
|
||
without an action (the open week, `db.currentWeek`, always the
|
||
chronologically latest under `applyAutoLock()`); everything else is
|
||
**Gesperrt** (`Entsperren` → `unlockWeek()`) or **Entsperrt**
|
||
(`Öffnen` → `switchWeek()` + jump to Board — the one place left that can
|
||
put a week other than the latest into `db.currentWeek`). Deleting a
|
||
session also moved here (`Löschen` → `deleteWeekById()`) from its old
|
||
home in *Datenbank & Sitzungen*, which depended on "the week chosen in
|
||
the topbar" and had no other way to name a target once that selector was
|
||
gone.
|
||
**The unlock window is shorter than it looks**: `unlockWeek()` only
|
||
flips that one week's flag, but `applyAutoLock()` re-locks every week
|
||
except the latest, and runs again on `deleteWeekById()` — not just on
|
||
page reload. Deleting an unrelated session while a correction is in
|
||
progress silently re-locks it before the fix is saved. Since the
|
||
automatic Sitzungswechsel (see "Automatischer Sitzungswechsel" above),
|
||
there is a second, independent trigger for the same outcome:
|
||
`ensureCurrentWeekRollover()` re-locks every other real week itself,
|
||
server-side, the moment the calendar rolls into a new ISO week — so an
|
||
in-progress correction on an older week can now also get silently
|
||
re-locked purely by time passing, with nobody clicking anything at all.
|
||
Both respect `app_settings.autoLockAktiv`. Surfacing "Entsperrt" as its
|
||
own row state only made this pre-existing risk visible, not new.
|
||
- **Feiertage** — the `holidays` table, plus a "gesetzliche Feiertage
|
||
übernehmen" mask that computes a year's public holidays for a chosen state
|
||
and lets the admin take them over (see `/holidays/suggest` above). It
|
||
flags when the current or next year has no day stored at all — otherwise
|
||
missing holidays only surface via a wrongly computed schedule.
|
||
- **Aufgaben-Vorlagen** — **je Gewerk und Leistungsphase** (Eriks
|
||
Wunsch): `lph_templates.gewerk` (`VALID_DEPARTMENTS`, Migration
|
||
`migrateAddLphTemplateGewerk()`; `''` = Altbestand, gilt für alle
|
||
Gewerke). Oben Gewerk-Umschalter (mit Anzahl) und Leistungsphase (mit
|
||
Anzahl), darunter die Schritte mit direkt bearbeitbarer Bezeichnung,
|
||
Start und Dauer (`PATCH /lph-templates/:id`, gespeichert beim Verlassen
|
||
des Feldes), ↑/↓ (`POST /lph-templates/:id/verschieben {richtung}`,
|
||
innerhalb von Phase und Gewerk) und × (`DELETE`). `POST /lph-templates`
|
||
verlangt ein Gewerk. Alles `requireRole('admin')`. Im Dialog „+ Vorlage“
|
||
(`plan-templates.js`) steht zusätzlich die Auswahl Gewerk — vorgewählt
|
||
das eigene, wenn das Projekt es hat, sonst das erste Gewerk des
|
||
Projekts; Gewerke außerhalb des Projekts tragen „(nicht im Projekt)“.
|
||
Der Admin-Verweis im Dialog führt nach Admin → Aufgaben-Vorlagen.
|
||
**Vorbelegt mit den HOAI-Grundleistungen** (`HOAI_GRUNDLEISTUNGEN`,
|
||
`hoaiVorlagenEinfuegen()` in server.js, einmalig über
|
||
`einmaligeUmstellung('hoai-aufgabenvorlagen')`, nur für Gewerke ohne
|
||
eigene Vorlagen): ARCH nach Anlage 10.1, ELT und HLS nach Anlage 15.1,
|
||
gekürzte Titel mit HOAI-Buchstabe, 177 Schritte. **Ohne Dauer**
|
||
(Eriks Vorgabe): `duration_days = 0` heißt „ohne Termin“ — „+ Vorlage“
|
||
fügt solche Schritte ohne Start/Ende ein und verlangt dafür kein
|
||
Startdatum. Die Prozentanteile der Siemon-Tabelle sind NICHT hinterlegt
|
||
(lagen nicht vor).
|
||
**Anteil % je Schritt** (Eriks Wunsch, z. B. nach Siemon-Tabelle):
|
||
`lph_templates.anteil` (REAL, Migration in
|
||
`migrateAddLphTemplateGewerk()`, `templateAnteil()` prüft 0–100, Komma
|
||
erlaubt). Spalte „Anteil %“ in der Admin-Maske mit Summe je Phase. Beim
|
||
Einfügen (`templatePlan()` in `plan-templates.js`) bekommt jeder Schritt
|
||
mit Anteil Anteil ÷ Summe ALLER Schritte der Phase an der „Dauer der
|
||
Phase“ (Arbeitstage), nacheinander ab dem Startdatum; abgewählte Schritte
|
||
lassen ihre Lücke. Startdatum und Dauer der Phase kommen aus dem
|
||
Phasenbalken der Leistungsphase (`templatePhaseVorbelegen()`), sonst von
|
||
Hand. Ohne Anteil gelten Start/Dauer der Vorlage, ohne beides ohne
|
||
Termin. Rundung über die laufende Summe — der letzte Schritt endet genau
|
||
am Phasenende. **Siemon-Werte hinterlegt** (`SIEMON_ANTEILE` in server.js,
|
||
Quelle: Siemon-Tabellen zur HOAI 2013, ibr-online Okt. 2013, Gebäude und
|
||
Technische Ausrüstung): je Grundleistung der MITTELWERT der Spanne
|
||
„von–bis“; eingetragen einmalig von `siemonAnteileEintragen()`
|
||
(`einmaligeUmstellung('siemon-anteile')`) über den Buchstaben am Titelende,
|
||
nur in Schritte ohne Anteil. „in a) enth.“ steht in der neuen Spalte
|
||
`lph_templates.enthalten_in` ('a' bzw. 'a,b'): solche Schritte laufen beim
|
||
Einfügen zeitgleich mit a) (frühester Beginn bis spätestes Ende der
|
||
genannten Schritte, zweiter Durchgang in `templatePlan()`). In der
|
||
Admin-Spalte „Anteil %“ als „in a)“ angezeigt und so auch eingebbar
|
||
(`templateAnteilEingabe()`).
|
||
**Umgestellt auf das Siemon-Gutachten zur HOAI 2021** (Eriks Meldung
|
||
„die Werte sind nicht richtig“; Anlagenband zum Gutachten 2021-1,
|
||
Anlage 1.1 Gebäude, 1.7 Technische Ausrüstung): ein fester Wert je
|
||
Grundleistung, Summe je Phase = HOAI-Prozentsatz, kein „enthalten in“
|
||
mehr. `SIEMON_ANTEILE` enthält jetzt diese Werte; `SIEMON_2013` nur noch
|
||
zum Erkennen unveränderter Altwerte. `siemon2021Umstellen()`
|
||
(`einmaligeUmstellung('siemon-anteile-2021')`) ersetzt nur Schritte, die
|
||
noch genau den 2013 eingetragenen Wert tragen.
|
||
- **Betrieb & Sicherheit** — `maxBackups`, `sessionTage`,
|
||
`loginMaxVersuche`, `loginSperreMinuten`. The session lifetime applies to
|
||
*new* logins; existing sessions keep their stored `expires_at`.
|
||
- **Datenbank & Sitzungen** — DB Backup, DB Import, Sitzung löschen. These
|
||
three used to be bare sidebar menu entries where a mis-click was enough;
|
||
they now sit behind an explanation of what each one does.
|
||
- **Honorar and Aufgaben-Vorlagen were moved out of project dialogs.** They
|
||
used to sit inside `#honorarPlanModal` (`js/plan/plan-honorar.js`) and
|
||
`#lphTemplateModal` (`js/plan/plan-templates.js`) — findable only by someone
|
||
who happened to be computing a schedule. Those dialogs now only *use* the
|
||
values and show admins a link here (`renderHonorarHint()`/
|
||
`renderTemplateHint()`). Since `plan-honorar.js` caches the config for the
|
||
whole page lifetime, `saveHonorarConfig()` calls
|
||
`invalidateHonorarConfig()` afterwards — otherwise a dialog opened later in
|
||
the same session would keep computing with the old rate.
|
||
- **Honorar, Auslastung, Termine and Betrieb used to be constants in the
|
||
source** — partly in the frontend (`LP_AUFWAND_VORLAGE`,
|
||
`THRESHOLD_RATIO_*`, `LOAD_BAR_MAX`, `STUNDEN_PRO_TAG`), partly in the
|
||
server (`MAX_BACKUPS`, `SESSION_MAX_AGE_MS`, `LOGIN_MAX_ATTEMPTS`,
|
||
`LOGIN_BLOCK_MS`). They moved here not because they change often but because
|
||
they shift the numbers *everyone* sees, which makes maintaining them an
|
||
administration task rather than a source edit. All of those identifiers are
|
||
gone; the client reads the values from `APP_SETTINGS` (`js/globals.js`),
|
||
filled by `loadAppSettings()` in `js/store.js` **before** `/load-all`, since
|
||
`renderAll()` already computes with them. If that fetch fails the defaults
|
||
in `js/globals.js` stand — deliberately identical to `APP_SETTING_DEFS`, so
|
||
the app keeps computing as it did instead of showing wrong numbers.
|
||
- **Part-time capacity.** `users.capacity_hours` (maintained per account in
|
||
`admin-users.html`) is what the Auslastung now measures a person against:
|
||
`kapazitaetsFaktor()` in `js/load/load.js` divides the raw weekly load by
|
||
`capacity_hours / vollzeitStundenProWoche`, so a half position counts as
|
||
fully loaded at half the work. `0` = not maintained = full position, so
|
||
existing accounts keep their previous picture. **`kapazitaetsFaktor()`
|
||
takes the person OBJECT, not just its id**: a week's participants are
|
||
frozen copies (`week_people`) whose id dates from when they were created —
|
||
legacy data has `p_ap` where the account now carries `p_alexander_plum`, so
|
||
the id alone would never find them and the full name is the second bridge.
|
||
`js/pdf.js` applies the same factor, otherwise screen and PDF would report
|
||
different numbers for the same part-timer.
|
||
- **"+ Neues Projekt" is no longer an admin entry** — it moved to
|
||
"Werkzeuge". Creating a project goes through the ordinary week save
|
||
(`POST /save-week`, `requireAuth`); the server never required the admin
|
||
role for it, so the gate was purely cosmetic and merely kept members from
|
||
ordinary work.
|
||
- The area being hidden for non-admins is a **UI gate only**, like everywhere
|
||
else in this app; the real barrier is `requireRole('admin')` on
|
||
`PUT /app-settings`, `PUT /honorar-config`, `POST`/`DELETE /lph-templates`,
|
||
`POST`/`DELETE /holidays`, `GET /backup`, `POST /import` and
|
||
`POST /delete-week` (verified: a member account gets 403 on each and the
|
||
stored values stay unchanged).
|
||
|
||
### Wochenbericht — die Tagesordnung
|
||
|
||
- The Wochenbericht is laid out as the **agenda of the weekly team meeting**:
|
||
the full project list on the left, the selected project full-size on the
|
||
right. It replaced a stack of 29 open cards that ran to 18 screens (11.6
|
||
after the cards were compressed) — **now 1.1**. The list stays put, so you
|
||
always see where you are and what is still to come.
|
||
- **Ordered by project group** (`GROUP_ORDER`, like the project list), no
|
||
longer by kind of change: every project gets discussed whether it changed or
|
||
not, and sorting by change would force jumping between sections. What
|
||
changed is still computed (`classifyProjectChange()`) and shown — as a dot
|
||
in the list and a label in the detail, not as a sort key.
|
||
- `berichtAuswahl` holds the **`project_uid`**, not an index: saving a comment
|
||
re-renders the list, and an index would point at a different project if the
|
||
order changed.
|
||
- `renderBerichtListe()` is separate from `renderWochenbericht()` because the
|
||
jump field redraws only the list — the detail, and above all a comment being
|
||
typed, must survive.
|
||
- The eight functions, all built in:
|
||
- **Besprochen-Haken** + "12 von 29 besprochen" (`projects.besprochen`,
|
||
saved through the ordinary `save()` path).
|
||
- **Besprochen & weiter** — ticks off and jumps to the next open item,
|
||
searching behind the current position first, then from the top.
|
||
Daneben **„Weiter →“** (`berichtWeiter()`/`berichtNaechstes()`, Eriks
|
||
Wunsch): springt nur zum nächsten Projekt der Liste (wie ↓), ohne Haken;
|
||
auch in gesperrten Sitzungen, am Listenende gesperrt.
|
||
- **Keyboard**: ↓/↑ switch project, Ctrl+↵ saves the comment and moves on.
|
||
Bound at document level but gated on `currentTab === 'wochenbericht'`.
|
||
- **Kommentar-Formatierung** (`richtextToolbarHtml()`, `js/utils.js`,
|
||
shared with the Protokoll-Punkte): Fett/Kursiv/Farbe **toggle** — a
|
||
second click on a selection that fully carries it removes it again
|
||
(`richtextUmschalten()`/`richtextArtEntfernen()`, splitting a
|
||
surrounding element so text before/after keeps its format); another
|
||
colour recolours instead of nesting. "Standard"
|
||
(`richtextFormatLoeschen()`) strips all formatting from the selection,
|
||
or from the whole field without one. Enter in the comment starts the new
|
||
line with "- " (`richtextNeueZeileMitStrich()`, nbsp because Chrome
|
||
drops a trailing normal space); deletable like any text, Shift+Enter
|
||
stays the browser's plain line break.
|
||
- **Vorwochen-Zeile** — "Zuletzt KW …: <Text>" above the input field
|
||
(`renderBerichtZuletzt()`), the latest earlier comment of the chosen
|
||
Gewerk — WITH its formatting (bold/italic/colour/lines, sanitized HTML,
|
||
at most five lines), on Erik's request; the Verlauf entries keep it too. Replaced the scroll wheel `renderVorwocheRad()` (removed), see
|
||
"Verlauf der Vorwochen" below.
|
||
- **Aufgabe aus Kommentar** — turns a decision into a real `plan_todo`
|
||
(title suggested from the *unsaved* field content, since in a meeting you
|
||
type the decision and create the task before saving).
|
||
- **Protokoll** — what changed versus the previous session plus the tasks
|
||
created today, as text to copy or mail. The mailto route refuses overlong
|
||
bodies rather than opening a truncated draft. **Not** the same thing as
|
||
the PDF below: the Protokoll is a delta (what changed + what's new), meant
|
||
to be pasted somewhere else; the PDF is the full agenda as a standalone
|
||
file.
|
||
- **„Als PDF ablegen"** (`exportWochenberichtPDF()`, `js/pdf.js`) — one of
|
||
the two remaining PDF exports (the other is `exportSandboxPlanPDF()` for
|
||
Planung-Terminpläne; the third, the board's own gewerkeübergreifende A3
|
||
session-overview `exportPDF()`, was removed — see "Werkzeuge" above). The
|
||
Wochenbericht is itself two-part, A4 portrait: **page 1** is a lined overview
|
||
table per group (change dot, Nr., Name, Änderungsgrund, amtierende LPH +
|
||
Fortschritt, Besprochen-Haken — `pdfWbUebersichtBlock()`) followed by the
|
||
**Anwesenheit der Sitzung** as its own gridded table
|
||
(`pdfWbAnwesenheitBlock()`, reading `w.attendance`/`w.people` exactly like
|
||
the on-screen `renderAttendance()`: a missing entry counts as not present,
|
||
same as an explicit `false` — there is no third "not yet recorded"
|
||
state, so the count is literal even when nobody has been marked yet).
|
||
**Page 2 onward** ("Vollständiger Bericht") is one bordered Akte per
|
||
project with a left spine colored by `classifyProjectChange()`'s bucket
|
||
(`PDF_WB_CHANGE_COLORS`: new/status/progress/unchanged), two columns
|
||
inside — comment on the left via `pdfStatusText()` (same bold/color
|
||
fidelity as the screen), a gridded Leistungsphasen-table and
|
||
Beteiligte-table on the right (`pdfWbLphMiniTable()`/
|
||
`pdfWbBeteiligteTable()`, falling back to all `LPHS` when none are
|
||
beauftragt — same rule as `renderBoardDrawer()`), plus who's assigned but
|
||
marked absent (`abwesendeBeteiligte()`). Under the comment sits the
|
||
latest EARLIER comment of the same Gewerk as a grey box
|
||
(`pdfWbVorigerKommentar()`, Erik's request) — same source as the
|
||
screen's "Zuletzt KW …" (`berichtVerlaufDaten()`), titled "Vorwoche KW …"
|
||
when it is from the directly preceding session, else "Zuletzt KW …",
|
||
with author and formatting. The amtierende-LPH value is
|
||
computed once (`pdfWbAktuelleLphInfo()`, same `lpActiveList()` the
|
||
Auslastung uses) and read by both the overview row and, indirectly, the
|
||
Akte — so the two pages can never show conflicting figures for the same
|
||
project. Reuses `PDF_STYLES`/`pdfStatusText()`/`loadLogoPng()` from the
|
||
existing module. `pdfHslToHex()` is the one color helper: it lets the
|
||
browser itself convert `getProgressColor()`'s `hsl()` output (pdfmake
|
||
doesn't understand `hsl()`) into the hex `pdfNormalizeColor()` already
|
||
handles, so the bar in the PDF is the exact same color as on screen
|
||
instead of a second, invented scale. The overview/attendance/mini-tables
|
||
all share one grid layout (`pdfWbGridLayout()`) so the whole document
|
||
reads as tables throughout rather than mixing ruled and unruled blocks.
|
||
- **Sprung-Feld** — filters the list only while something is typed; the
|
||
agenda must stay complete.
|
||
- **Abwesenheits-Hinweis** („Heute abwesend: Name (Urlaub bis 28.10.) —
|
||
eventuell vertagen“, auch im PDF) — liest seit Eriks Wunsch die
|
||
**eingetragenen Abwesenheiten** der zugeordneten Personen
|
||
(`db.globalPeople[].absences`, Benutzerverwaltung) für den heutigen Tag,
|
||
NICHT mehr die Anwesenheitsliste der Sitzung (`abwesendeBeteiligte()`).
|
||
Die Anwesenheitsabfrage am Fuß bleibt unverändert bestehen (Protokoll,
|
||
PDF), sie ist nur nicht mehr Quelle dieses Hinweises.
|
||
- Two bugs worth remembering, both found by measuring, not by looking:
|
||
- The dense-card rules were scoped to `.report-card`, a container this
|
||
rewrite removed — the card silently fell back to the Board layout
|
||
(Leistungsphasen missing, people stacked). They now hang off
|
||
`.board-drawer-dicht` itself.
|
||
- The state class was literally `status`, which matched the unrelated
|
||
`.status { min-width: 240px }` — the 7 px dot became 240 px wide and
|
||
squeezed the project name to zero. State classes carry an `is-` prefix now.
|
||
- The dense card is **two columns again** (comment | phases + people). It had
|
||
three while 29 cards stood stacked and height mattered; in the agenda only
|
||
one project is ever open, so height is free while width is scarce — beside
|
||
the list, three columns left the people 137 px.
|
||
|
||
### Wochenbericht — Gewerke-Trennung
|
||
|
||
Since departments stopped partitioning the data (see "Departments were
|
||
retired" above), every project from every discipline sat in the same, single
|
||
Tagesordnung — but ELT, HLS and ARCH hold their weekly team meeting
|
||
separately, at different times, and each meeting only needs its own trade's
|
||
projects, not the other two's. Completeness ("every project gets discussed")
|
||
now applies **within the chosen Gewerk**, not across all three at once; the
|
||
one place that still shows every project regardless of Gewerk is the
|
||
unfiltered "Projekte" board (`renderBoard()`) — untouched by this feature.
|
||
|
||
- **`wochenberichtGewerk`** (`js/wochenbericht/wochenbericht.js`) is a
|
||
module-level `let`, `null` until resolved. Deliberately **not** persisted to
|
||
`localStorage` — it resets on every page load/login, so a stale choice from
|
||
a different meeting can never carry over silently.
|
||
- **Defaults from the account's own Gewerk, no dialog needed for the normal
|
||
case**: `wochenberichtGewerkVoreinstellen()` runs at the top of
|
||
`renderWochenbericht()` and, once per page load, sets
|
||
`wochenberichtGewerk` to `currentUser.department` (added to `req.user` in
|
||
`attachUser()`, `server.js`, specifically for this) if that's a valid
|
||
Gewerk — most people only ever need their own trade's meeting, so this
|
||
alone gets them straight to a filtered Tagesordnung with no extra step.
|
||
Only an account without a maintained `department` (e.g. the bootstrap admin)
|
||
falls through with `wochenberichtGewerk` still `null`.
|
||
- **Changing it is a plain `<select>`, not a full-screen step**
|
||
(`renderGewerkDropdown()`, rendered both in the normal header via
|
||
`renderBerichtKopf()` and, as a bare prompt, when no default could be
|
||
resolved) — switching trades mid-session is a quick correction, not its own
|
||
workflow, so it deliberately does NOT interrupt the page the way the first
|
||
version of this feature (a blocking `#gewerkWizardModal`) did; that modal
|
||
and `oeffneGewerkWizard()` are gone. Picking an option calls
|
||
`waehleWochenberichtGewerk(g)` directly (`onchange`), which re-renders.
|
||
- **The filter lives in ONE place**, `berichtProjekte(w)` itself: when
|
||
`wochenberichtGewerk` is set, it returns only projects whose `p.gewerke`
|
||
(from `project_gewerke`, populated by the "Neues Projekt" wizard's Gewerke
|
||
step, `js/projekt-wizard.js`) includes it. Because `renderWochenbericht()`,
|
||
`renderBerichtListe()`, `aktuellesBerichtProjekt()`, `openBerichtProtokoll()`
|
||
(the Protokoll text export) AND `exportWochenberichtPDF()` (`js/pdf.js`)
|
||
all call this same function, the filter applies everywhere at once with no
|
||
separate plumbing — screen, Protokoll and PDF can never disagree about
|
||
which projects belong to the current Gewerke-Besprechung. The PDF's header,
|
||
footer and filename additionally spell out the Gewerk
|
||
(`Wochenbericht_ELT_KW_33___2026.pdf`); `exportWochenberichtPDF()` refuses
|
||
with a message if called with no Gewerk chosen (only reachable for an
|
||
account with no maintained `department`, since everyone else gets one from
|
||
`wochenberichtGewerkVoreinstellen()` — the guard costs nothing either way).
|
||
- **Kommentar und Besprochen-Haken sind je Gewerk getrennt** (Erik: "Die
|
||
Kommentare und Anwesenheiten von ELT brauchen nicht in HLS oder Arch zu
|
||
sein" / "auch wenn das Projekt in ELT besprochen ist, heißt das nicht, dass
|
||
HLS das Projekt auch besprochen hat"). Table **`project_kommentare`**
|
||
(week_label, project_id, gewerk, status, status_author, status_at,
|
||
besprochen), client shape `p.kommentare = {ELT: {status, statusAuthor,
|
||
statusAt, besprochen}, …}`. `projects.status/status_author/status_at/
|
||
besprochen` are legacy: always written empty, only read by
|
||
`migrateKommentareJeGewerk()` (startup + after `/import`; author's
|
||
`users.department` if the project has that Gewerk, else its only Gewerk,
|
||
else all its Gewerke). **All reads/writes go through `kommentarVon(p, g)`/
|
||
`kommentarSetzen(p, g, patch)`** (`js/utils.js`); `kommentarGewerk()` is
|
||
the context Gewerk (`wochenberichtGewerk` while on the Wochenbericht tab,
|
||
else null). With `g` null, `kommentarVon()` returns the combined display
|
||
(each Gewerk's comment under a bold "ELT:" label, besprochen only if all
|
||
Gewerke ticked it) — used by the Projekte list, Wandtafel, Projektbereich.
|
||
`classifyProjectChange(p, prevWeek, gewerk)` and `projectStatusHistory(uid,
|
||
g)` compare/list per Gewerk. Rollover and Papierkorb-restore reset
|
||
`p.kommentare = {}`. `project_kommentare` is in `IMPORT_TABLES` but also in
|
||
`IMPORT_OPTIONAL_TABLES`, so older backups without it still import.
|
||
- **Attendance is filtered per Gewerk**: `w.attendance` is still one map per
|
||
session, but `renderAttendance()`, `setAllAttendance()`,
|
||
`abwesendeBeteiligte()`, the Protokoll text and `pdfWbAnwesenheitBlock()`
|
||
only show/affect `personenDesGewerks(w, g)` — people whose account
|
||
department (`personGewerk()`, via `db.globalPeople` by id or full name) is
|
||
that Gewerk. People with no known Gewerk stay visible everywhere.
|
||
- **Backfill for pre-existing projects**
|
||
(`migrateBackfillProjectGewerkeAusTags()`, `server.js`, runs at every
|
||
startup): every project that predates the Gewerke step — i.e. has zero
|
||
`project_gewerke` rows — gets its Gewerk copied from whichever
|
||
`ELT`/`HLS`/`ARCH` entry already sits in its `project_tags` (the marker
|
||
`migrateRemoveDepartmentPartition()` left behind when it merged the former
|
||
per-department copies). Without this, every pre-existing project would
|
||
silently vanish from every Gewerke-filtered Tagesordnung the first time
|
||
this feature shipped. Idempotent and non-destructive: it only ever inserts
|
||
for a project with **zero** `project_gewerke` rows, so a manual correction
|
||
in Projekteinstellungen (see next bullet) is never overwritten by a later
|
||
server restart.
|
||
- **Gewerke are now correctable outside the wizard**: Projekteinstellungen
|
||
(`renderPlanSettings()`, `js/plan/plan-settings.js`) gained a "Gewerke"
|
||
checkbox row next to Schlagwörter, saved through `togglePlanGewerk()` —
|
||
the exact same mutate-`p.field`-then-`savePlan()` pattern as
|
||
`toggleBeauftragteLp()` (`js/plan/plan-honorar.js`). Before this, a
|
||
project's Gewerke could only ever be set once, during creation.
|
||
|
||
### Kommentar der Vorwoche wird übernommen (aktueller Stand)
|
||
|
||
Eriks Wunsch: Jede neue Sitzung beginnt mit dem Kommentar der Vorwoche (je
|
||
Gewerk), der in der Besprechung angepasst wird. `ensureCurrentWeekRollover()`
|
||
kopiert deshalb `p.kommentare` (Text, Verfasser, Zeit) und setzt nur
|
||
`besprochen` zurück — die Beschreibungen „cleared status“/„kommentare = {}“
|
||
weiter oben sind überholt. Unveränderte Übernahmen gelten in
|
||
`classifyProjectChange()` als „unverändert“ (gleicher Text, gleiches
|
||
`statusAt`). Einmalig hat `kommentareInAktuelleSitzungUebernehmen()`
|
||
(`einmaligeUmstellung('kommentare-uebernehmen')`) in der damals jüngsten
|
||
Sitzung jeden leeren Kommentar je Gewerk mit dem zuletzt geschriebenen
|
||
gefüllt (Haken unverändert). Der Papierkorb leert beim Wiederherstellen
|
||
weiterhin. Übernommen wird das gespeicherte HTML, also mit Farben und
|
||
Formatierung. Die Zeile „Zuletzt KW …“ über dem Kommentarfeld
|
||
(`renderBerichtZuletzt()`) wird deshalb nicht mehr angezeigt; der „Verlauf
|
||
der Vorwochen“ darunter bleibt.
|
||
„Verwerfen“ (und Esc) leert bei einem unverändert übernommenen Kommentar
|
||
das ganze Feld (`boardCommentUebernommen`, `kommentarAusVorwocheUebernommen()`
|
||
in `board.js`: gleicher Text und `statusAt` wie der jüngste frühere
|
||
Kommentar des Gewerks); sonst stellt es wie bisher den gespeicherten Stand her.
|
||
|
||
### Wochenbericht — Verlauf der Vorwochen
|
||
|
||
**Aktueller Stand (Eriks Wahl „B“):** Im Wochenbericht steht der Verlauf
|
||
nicht mehr als Kachel-Streifen, sondern wie im Projekt-Dashboard als
|
||
senkrechte Zeitleiste — ein eigener Abschnitt „Verlauf der Vorwochen“ über
|
||
die volle Breite unter der Bearbeitungsmaske (`renderBerichtVerlauf()`,
|
||
`berichtVerlaufDaten()` in `js/wochenbericht/wochenbericht.js`). Ein Eintrag
|
||
nur, wenn sich der Kommentar DES GEWÄHLTEN GEWERKS gegenüber der Sitzung davor
|
||
geändert hat; Sitzungen dazwischen als grauer Vermerk („KW 40 bis KW 43 ohne
|
||
neuen Kommentar“); geänderte Leistungsphasen seit dem vorigen Eintrag als
|
||
Chips („LPH 5: 60 → 75 %“); Verfasser und Datum; „übernehmen“
|
||
(`berichtVerlaufUebernehmen()` → `takeCommentFromHistory()`); drei
|
||
sichtbar, Rest über „Alle N anzeigen“ (`berichtVerlaufUmschalten()` zeichnet
|
||
nur den Abschnitt neu, ein angefangener Kommentar bleibt). Die dichte Karte
|
||
bekommt dafür `opts.ohneVerlauf` (`renderBoardDrawer()`), damit der
|
||
Kachel-Streifen dort nicht zusätzlich erscheint. Projektliste und
|
||
Projektbereich nutzen weiter `renderProjektVerlauf()` wie unten beschrieben.
|
||
Stile: Block „Verlauf der Vorwochen im Wochenbericht“ (`.bv-*`,
|
||
`.bericht-zuletzt`) in `styles.css`.
|
||
|
||
**Absätze, Formatierung, Vorsatz (Eriks Nachtrag):**
|
||
- **Bestandskommentare einmalig umgestellt**
|
||
(`einmaligeUmstellung('kommentar-spiegelstriche')`,
|
||
`kommentarSpiegelstricheUmbrechen()` in `server.js`): Jeder Spiegelstrich
|
||
„ - “ in `project_kommentare.status` beginnt eine eigene Zeile (`<br>`),
|
||
sofern er nicht schon am Zeilenanfang oder direkt hinter einem Vorsatz wie
|
||
„[ELT]“ steht; Bindestriche in Wörtern bleiben. Repariert dabei auch als
|
||
Text gespeicherte „&nbsp;“ (eine ältere Fassung des Enter-Strichs).
|
||
Rein textuell, ohne DOM. **Nur einmal** — neue Kommentare werden NICHT an
|
||
jedem „-“ umbrochen; dort zählen allein die beim Schreiben gesetzten
|
||
Absätze.
|
||
- **Farben aus `<font color>`** bleiben jetzt erhalten
|
||
(`sanitizeStatusHtml()` erlaubt das Attribut an `FONT`, geprüft durch
|
||
`fontFarbeErlaubt()`: nur Hex, keine Grau-/Schwarz-/Weißtöne wie das
|
||
häufige eingefügte `#1a1a1a`). Vorher fiel es still weg. Die Werkzeugleiste
|
||
schreibt ohnehin `<span style="color:…">`.
|
||
- **Gewerke-Vorsatz „[ELT] “/„[ELT+HLS] “ aus den Daten entfernt**
|
||
(`kommentareVorsatzUmstellen()`/`kommentarVorsatzEntfernen()` in
|
||
`server.js`, bei jedem Start und nach `/import`, idempotent). Er stammt von
|
||
`migrateRemoveDepartmentPartition()` und gehört nicht zum Kommentar. Das
|
||
Gewerk erscheint stattdessen als Systemtext: in den Verlauf-Kacheln über
|
||
`kommentarAnzeigeHtml()` (`js/utils.js`, Beschriftung `.kommentar-gewerk`,
|
||
nur bei mehreren Gewerken), im Wochenbericht-Verlauf im Kopf, im
|
||
Projekt-Dashboard in der Meta-Zeile.
|
||
- **„übernehmen“ lässt den Gewerke-Vorsatz weg**
|
||
(`kommentarVorsatzEntfernen()` in `board.js`, in
|
||
`takeCommentFromHistory()`): „[ELT] “, „[ELT+HLS] “ am Anfang entfällt,
|
||
die Formatierung bleibt.
|
||
|
||
- The Wochenbericht is the **agenda of a weekly team meeting in which every
|
||
project is looked at**, changed or not. Completeness beats filtering here:
|
||
proposals that hide unchanged projects or reorder the list by kind of change
|
||
work against that routine — they force jumping between sections instead of
|
||
working down one list.
|
||
- What is wanted instead is **being able to trace what changed from week to
|
||
week**, as a history available on demand next to the current state. That is
|
||
what `renderProjektVerlauf()` provides: an expandable "Verlauf" under each
|
||
project card showing one tile per earlier session, horizontally scrollable,
|
||
newest first. The current session is the card itself.
|
||
- **It lives in `js/utils.js`, not in one tab**, because all three places that
|
||
show a history use it: the Wochenbericht under the project card, the project
|
||
list, and the Verlauf dialog of the Projektbereich (`openHistoryModal()`,
|
||
`js/plan/plan-history.js`). Each of them used to have its own version with
|
||
its own filter — the dialog showed comment texts only, and only for sessions
|
||
where the text actually changed — so the same project's history looked
|
||
different depending on where you opened it and could contradict itself. The
|
||
former "Frühere Sitzungen" expander under the comment field (a fourth, even
|
||
narrower selection) is gone with it.
|
||
- Two things depend on `weekLabel`: with one, that session is left out (it is
|
||
the card above) and each tile offers "übernehmen" to copy an old comment into
|
||
the current one; **without** one — the dialog, which has no "current"
|
||
version — all sessions are shown and no "übernehmen" button appears, since
|
||
there would be no field to copy into.
|
||
- **No new data storage.** Every session already holds a full copy of every
|
||
project — one tile per week — and `projectVersionsForUid()` (`js/utils.js`)
|
||
returns them chronologically. The timeline only makes visible what was
|
||
always there.
|
||
- A tile carries the session's comment, the LPH values, the plan's dates
|
||
(latest date = project end, plus the next milestone) and the people assigned
|
||
**in that week** — read from that session's `week.people`, not today's, so an
|
||
old tile never shows people who weren't there yet.
|
||
- The tile shows the comment WITH its formatting (colours, bold, Absätze;
|
||
`sanitizeStatusHtml()`, cut off after six lines) — it used to be flattened
|
||
to plain text via `statusPlainText()`, which Erik reported as broken.
|
||
- Values that differ from the tile *before* it are highlighted: the tiles sit
|
||
side by side, but with ten Leistungsphasen the one changed number is
|
||
otherwise impossible to spot.
|
||
- Rendered by `renderBoardDrawer()` (`js/board/board.js`) in **both** of its
|
||
variants; the former `BERICHT_OPTS.timeline` flag is gone, since the history
|
||
now hangs off every card rather than only the Wochenbericht's.
|
||
|
||
### Dashboard ("Meine Aufgaben", now the default landing tab)
|
||
|
||
**Aktueller Aufbau (Entwurf „B — Monatsblatt“, Eriks Wahl aus drei Varianten):**
|
||
Kopf und Band wie unten beschrieben; darunter **links „Meine Projekte“**, je
|
||
Projekt ein Block (`renderDashProjekte()`): eigene Rollen als Chips (gestrichelt
|
||
= Unterstützung, „nur Aufgabe“ = nicht zugeordnet, aber mit eigener Aufgabe),
|
||
amtierende LPH mit Fortschritt, darunter bis zu `DASH_TERMINE_JE_PROJEKT` (6)
|
||
offene Termine, Überfälliges zuerst, der Rest hinter „N weitere Termine im
|
||
Projekt“. Eigene Aufgaben tragen ein „A“ in Akzentfarbe, fremde die
|
||
Zuständigen, Meilensteine eine Raute (`--meilenstein`, eigenes Token). **Jeder
|
||
Block ist einklappbar** (`dashProjUmschalten()`, „Alle einklappen/aufklappen“
|
||
in der Abschnittsüberschrift); eingeklappt zeigt der Kopf „N überfällig / N
|
||
offen“. Der Zustand liegt je `projectUid` in `localStorage`
|
||
(`projuebersicht-dash-zu`) — reiner UI-Zustand. **Reihenfolge der Projekte** (Eriks Wunsch): wie in der Projektliste
|
||
(`GROUP_ORDER`, dann `p.order`; `dashSortieren()`), nicht mehr nach dem
|
||
dringendsten Termin. Jedes Konto kann sie am Griff vorn im Projektkopf
|
||
umziehen (oder Griff fokussieren + Pfeiltasten); gespeichert je Konto am
|
||
Server (`users.dashboard_reihenfolge`, JSON-Liste von `projectUid`s,
|
||
Migration in `migrateUsersDepartment()`, `POST /dashboard-reihenfolge`,
|
||
geliefert als `req.user.dashboardReihenfolge` über `/whoami`). Projekte, die
|
||
darin fehlen, folgen dahinter in Listenreihenfolge; „Wie Projektliste“ setzt
|
||
zurück. **Rechts ein Monatskalender**
|
||
(`renderDashKalender()`, KW-Spalte, Feiertage aus `HOLIDAYS`, Punkte für
|
||
eigene Aufgabe / Projektaufgabe / Meilenstein / überfällig / Abwesenheit, Klick
|
||
auf einen Tag listet ihn), darunter Freigaben und Abwesenheiten.
|
||
**Projekte und Kalender rechnen client-seitig aus `currentWeek()`**
|
||
(`dashDaten()`), nicht aus `GET /my-dashboard`: Der Kalender soll beliebig weit
|
||
blättern, der Server liefert Projekttermine nur 21 Tage voraus. Kopf und Band
|
||
behalten die Server-Zahlen. Abwesenheiten im Kalender: das eigene Konto plus
|
||
alle, die einem der eigenen Projekte zugeordnet sind (`db.globalPeople`).
|
||
Solange `/load-all` noch fehlt, steht „Projekte werden geladen…“; `renderAll()`
|
||
zeichnet das Dashboard danach neu. Die früheren Listen „Meine Aufgaben“ und
|
||
„Termine meiner Projekte“ (`renderDashboardAufgabe()`,
|
||
`renderDashboardFristen()`, `renderFristenZeile()`) sind entfallen — ihr Inhalt
|
||
steckt in den Projektblöcken. Der Text darunter beschreibt den Stand davor,
|
||
soweit er Kopf und Band betrifft, gilt er weiter.
|
||
|
||
**Aufbau: Kopf — Band — zwei Zonen.** Die Anordnung folgt der Frage, die beim
|
||
Blick aufs Dashboard zuerst ansteht: „Was muss ich heute anpacken?"
|
||
|
||
1. **Kopf** (`renderDashboardKopf()`) — Begrüßung und Datum links, Sitzung und
|
||
Uhr klein rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm;
|
||
sie ist die Angabe, nach der man am wenigsten handelt.
|
||
2. **Band** (`renderDashboardBand()`) — der Befund des Tages als **Satz**
|
||
(„8 Termine sind überfällig, 1 wird heute fällig"), daneben vier
|
||
Kennzahlen. Zuvor standen hier vier gleich große Kacheln, von denen meist
|
||
drei eine Null zeigten — die eine Zahl, auf die es ankommt, bekam dadurch
|
||
kein Gewicht. Ist nichts überfällig und nichts heute fällig, wird das Band
|
||
farblos (`.ist-ruhig`): Eine Null in Alarmfarbe behauptet ein Problem, das
|
||
es nicht gibt. Die Zahl im Satz ist `1.15em` groß — ab 19 px fett gilt für
|
||
den Kontrast die Schwelle für große Schrift, die Ampelfarbe erreicht auf
|
||
der abgesetzten Bandfläche sonst nur 4,1:1 im dunklen Thema.
|
||
3. **Linke Zone** — der eigene Arbeitsstrom: „Meine Aufgaben" und „Termine
|
||
meiner Projekte", beide in derselben `.dash-karte`.
|
||
4. **Rechte Zone** (300 px) — was man wissen muss, ohne selbst tätig zu
|
||
werden: ausstehende Freigaben und Abwesenheiten, beide in derselben
|
||
schmalen Zeilenform (`.dash-rand-zeile`). Zuvor stand der Urlaub eines
|
||
Kollegen gleichrangig unter den eigenen offenen Aufgaben. Unter 1080 px
|
||
fällt das Raster auf eine Spalte zurück.
|
||
|
||
**Aufgaben sind Zeilen, keine Karten** (`renderDashboardAufgabe()`): Eine Karte
|
||
belegte eine halbe Bildschirmbreite für vier Zeilen Inhalt. Dieselben Angaben —
|
||
Titel, Projekt, Fortschritt, Fälligkeit, Schlagwörter — stehen jetzt in einer
|
||
Zeile. Entfallen sind damit `renderStatTile()`, `renderDashboardTaskCard()`,
|
||
`renderDashboardReviewCard()`, `renderDashboardGreeting()` und die CSS-Klassen
|
||
`.dashboard-greeting`/`.dashboard-clock`/`.dashboard-absence-*`/
|
||
`.absence-chip-live`.
|
||
|
||
**Zwei Listen, die nicht dasselbe sind** — das ist der Kern dieses Bereichs:
|
||
|
||
- **Meine Aufgaben** (`tasks`): die Aufgaben, die der Person PERSÖNLICH
|
||
zugewiesen sind (`plan_todo_assignees.person_id`), auch in Projekten, mit
|
||
denen sie sonst nichts zu tun hat. Als Karten.
|
||
- **Termine meiner Projekte** (`projectDates`): die Aufgaben UND Meilensteine
|
||
der PROJEKTE, denen die Person zugeordnet ist (`project_assign`, state 1
|
||
oder 2) — unabhängig davon, wer die einzelne Aufgabe erledigt. Eine
|
||
Projektleitung muss wissen, wann im eigenen Projekt etwas fällig wird, auch
|
||
wenn ein anderer es abarbeitet. Als Liste, nach Dringlichkeit gegliedert
|
||
(Überfällig / Diese Woche / Danach).
|
||
|
||
Beides zusammen beantwortet die Frage, die sonst nur durch Öffnen von dreißig
|
||
Terminplänen zu beantworten war. Details:
|
||
|
||
- **Keine Doppelung:** Was bereits unter `tasks` steht, wird aus
|
||
`projectDates` herausgefiltert (`selbst`-Kennzeichen, serverseitig).
|
||
- **Zeitfenster 21 Tage** nach vorn; überfällige Termine bleiben dagegen ohne
|
||
Altersgrenze stehen — eine Frist verfällt nicht dadurch, dass sie lange
|
||
gerissen ist. Ohne die Grenze nach vorn stünden Termine aus einem Jahr in
|
||
der Liste und die dringenden gingen darin unter.
|
||
- **Meilensteine haben kein Erledigt-Kennzeichen** (`plan_milestones` hat
|
||
keine `done`-Spalte), deshalb zählt bei ihnen allein das Datum; bei Aufgaben
|
||
gilt zusätzlich `done = 0` und ein gesetztes Enddatum.
|
||
- **Die Namen der Zuständigen kommen aus `week_people`**, nicht aus
|
||
`loadGlobalPeople()`: Die globale Liste enthält nur Personen mit
|
||
Benutzerkonto, die Teilnehmerliste einer Sitzung dagegen jede Person, die
|
||
dort mitarbeitet. Ohne diesen Weg stand in der Liste `p_ap` statt
|
||
„Alexander Plum".
|
||
- **Die Kachel „Überfällig"** zählt beide Listen zusammen; damit die Summe
|
||
nachvollziehbar bleibt, kennzeichnet auch die Aufgabenkarte ein überfälliges
|
||
Fälligkeitsdatum (`b.ist-ueberfaellig`). Eine Null bleibt neutral statt rot —
|
||
„0 überfällig" ist eine gute Nachricht.
|
||
- Eine Zeile führt in den Terminplan des Projekts (`…#aufgaben`) — dorthin,
|
||
wo sich der Termin ändern lässt.
|
||
- **Datumsvergleiche laufen über `heuteIso()`/`isoPlusTage()` (js/utils.js)
|
||
bzw. `heuteIsoLokal()`/`isoPlusTageLokal()` (server.js), nicht über
|
||
`new Date().toISOString().slice(0,10)`.** Letzteres rechnet nach UTC um: In
|
||
deutscher Sommerzeit (UTC+2) ist damit zwischen 00:00 und 02:00 Ortszeit
|
||
noch der Vortag gemeint — Termine von heute galten in diesem Fenster als
|
||
überfällig, und eine heute beginnende Abwesenheit fiel aus der Liste. Die
|
||
Feiertagsberechnung (`holidaysForYear()`) rechnet dagegen bewusst
|
||
durchgehend in UTC (`Date.UTC`, `isoFromDate()`) und ist davon nicht
|
||
betroffen. Serverseitig setzt die Korrektur voraus, dass der Server auf die
|
||
Zeitzone des Büros gestellt ist — im Docker-Container also
|
||
`TZ=Europe/Berlin`.
|
||
|
||
|
||
- `GET /my-dashboard` (Auth) is the sole data source for this tab
|
||
(`js/dashboard/dashboard.js`, slug `MeineAufgaben`, first item in the
|
||
sidebar nav and `DEFAULT_TAB` in `js/globals.js` — the app lands here right
|
||
after login, not on the Projektboard). It aggregates, per
|
||
department (`ELT`/`HLS`/`ARCH`), the caller's open TODOs from that
|
||
department's currently unlocked week — normally there's exactly one
|
||
(`applyAutoLock()` invariant); if that's ever violated (0 or >1 unlocked
|
||
weeks) the server falls back to the most recent week via
|
||
`weekSortKeyServer()`, a server-side port of `weekSortKey()`
|
||
(`public/js/utils.js`). Tasks are matched by `plan_todo_assignees.person_id
|
||
= req.user.personId` and sorted by due date (empty dates last). The
|
||
response also includes `pendingReviews` (files awaiting the caller's
|
||
review decision), populated by the file-upload feature described above.
|
||
|
||
### Wandtafel (tab `wand`, `js/wand/wand.js`)
|
||
|
||
A tile dashboard meant for a dedicated, permanently-running touch monitor in
|
||
the office — gewerke- and user-wide, the opposite scope of the Dashboard
|
||
above (which is deliberately personal, "Meine Aufgaben"). **Replaces Show
|
||
Mode outright** (`js/showmode.js`, the whole `.show-mode-*` CSS block, and
|
||
the sidebar's old "Show Mode" entry are gone, not just unlinked). Show Mode
|
||
only ever cloned whatever the ONE logged-in session's `#board`/`#load` tab
|
||
happened to be rendering at the time (`copyCurrentViewToShowMode()` did a
|
||
literal `innerHTML` copy, no independent data path) — it pre-dates both the
|
||
department-retirement and the Dashboard, and never aggregated anything
|
||
across Gewerke or users. Erik's own words: "macht so keinen Sinn mehr."
|
||
|
||
- **No new server endpoint.** Every tile is a client-side projection of the
|
||
same `db` state every other tab already has after `GET /load-all` —
|
||
deliberately, to avoid holding `berechneAuslastung()`'s business logic in
|
||
two places (server SQL and client JS) for the sake of one dashboard. The
|
||
background refresh (`wandRefreshTick()`, every 60 s) checks `GET /whoami`
|
||
first, then calls the ordinary `load(true)` — the `true` is a new, optional
|
||
`silent` parameter on `load()` (`js/store.js`): on failure it only
|
||
`console.error()`s and leaves the last-known-good data on screen, instead
|
||
of `load()`'s normal blocking `alert()` + data-wipe, which would otherwise
|
||
stack unusable alert dialogs on an unattended touch screen every minute.
|
||
- **Dedicated `kiosk` role** (`users.role`, alongside `admin`/`member` —
|
||
`VALID_ROLES` in `server.js` is now the one place that set is defined).
|
||
First built on a plain `member` account, changed on Erik's explicit
|
||
follow-up request for a real, separate role rather than relying on an
|
||
ordinary account's full write access. A `kiosk` account logs into the
|
||
kiosk browser once and is pointed at `/Wandtafel` directly (OS-level kiosk
|
||
browser config, e.g. a `--kiosk` flag — outside this codebase); its
|
||
session lives the normal, app-wide `sessionTage` (default 30 days,
|
||
unchanged) like any other account — no separate session-lifetime
|
||
mechanism was added. If the session expires, `wandRefreshTick()` calls the
|
||
existing `showLoginOverlay()` (`js/auth.js`) in place — the login form was
|
||
already wired once at page load by `checkAuthAndInit()`, so re-entering
|
||
the password resumes the Wandtafel via the same `afterAuthenticated()` →
|
||
`initUiState()` → `load()` path every normal page load already uses,
|
||
without a full reload (`initUiState()` re-derives `currentTab` from the
|
||
still-unchanged `/Wandtafel` URL).
|
||
- **Enforced server-side, not just a UI gate** (unlike most role
|
||
distinctions in this app, which are only hidden in the UI while the
|
||
server accepts any logged-in account — see "Auth / security quirk"):
|
||
`requireAuth` (`server.js`) now rejects `role === 'kiosk'` with 403
|
||
outright, on every route that used to accept any authenticated account.
|
||
A new, narrower `requireAuthOderKiosk` replaces plain `requireAuth` on
|
||
exactly the four read-only routes the Wandtafel's own `load()` call
|
||
needs — `GET /load-all`, `/app-settings`, `/holidays`,
|
||
`/honorar-config` — plus `POST /logout` (a kiosk account can still end
|
||
its own session, even though the UI has no reachable button for it once
|
||
the topbar is hidden). Every write route, `GET /users`, `GET
|
||
/my-dashboard`, and everything admin-only stays on plain `requireAuth`/
|
||
`requireRole('admin')` and therefore 403s a kiosk account exactly like
|
||
an anonymous visitor would get 401'd.
|
||
- **No Fachrolle, enforced in both directions.** `POST /admin/users` and
|
||
`PATCH /admin/users/:id` reject a `kiosk` role combined with a non-empty
|
||
discipline-`roles` array (400, "Ein Kiosk-Konto kann keine Fachrolle
|
||
haben.") — checked against the EFFECTIVE role and roles a request would
|
||
produce, not just the fields present in that one request body: setting
|
||
`role: 'kiosk'` on an account that already carries discipline roles in
|
||
the database is rejected exactly like sending `roles` alongside `role:
|
||
'kiosk'` in the same request, by reading the existing `user_roles` rows
|
||
when `roles` itself is absent from the PATCH body. The reverse direction
|
||
needed its own fix: the pre-existing self-demotion guard only checked
|
||
`role === 'admin' && newRole === 'member'`, so an admin could otherwise
|
||
have stepped themselves down to `kiosk` (a stricter demotion than to
|
||
`member`) through a gap the original check never anticipated — it now
|
||
checks `newRole !== 'admin'` generically. Client-side,
|
||
`saveNewUser()`'s existing "at least one Fachrolle required" check
|
||
(`admin-users.js`) is skipped when the selected role is `kiosk` (the
|
||
whole reason that check exists — `peopleByRole()` makes a roleless
|
||
account invisible everywhere — is exactly the outcome wanted here), and
|
||
selecting "Kiosk" in either user form disables and unchecks the
|
||
Fachrolle boxes live (`onUserRoleChange()`), so the 400 is never
|
||
actually hit in normal use — it exists as the real enforcement, the
|
||
client behavior is just not making the user discover it the hard way.
|
||
- **`GET /users` excludes `role = 'kiosk'`** — this endpoint (reviewer
|
||
picker, cross-department staffing) isn't Fachrolle-scoped the way
|
||
`peopleByRole()` is, so a kiosk account without discipline roles would
|
||
otherwise still turn up there as a selectable reviewer or team member,
|
||
where it could never actually act (every write route it would need
|
||
already 403s it) — just a dead end for whoever picked it, closed before
|
||
it could confuse anyone.
|
||
- **Seitenleiste und Kopfzeile blenden sich aus** while on this tab
|
||
(`body.wandtafel-aktiv`, toggled in `showTab()`, hides `.app-topbar`/
|
||
`.side-menu`/`.side-menu-backdrop`). Written before the dedicated `kiosk`
|
||
role existed, when the account behind the display was an ordinary
|
||
`member` with real write access everywhere else — the hiding was a
|
||
security measure then, not just tidiness. The `kiosk` role closed that
|
||
hole server-side, but the hiding stayed: every other tab would now just
|
||
403 on its own data fetch for a kiosk account, and a sidebar full of
|
||
buttons that each lead to a broken-looking error view is a worse
|
||
experience than no sidebar at all. A small, deliberately unobtrusive
|
||
"← Zur Übersicht" link inside the panel (`App.html`) is the only way
|
||
back — meant for a staff member previewing the tab via their own,
|
||
non-kiosk account, not for the kiosk account itself, which has no reason
|
||
to ever leave it (and would just hit 403s on whatever tab it landed on).
|
||
- **AKTUELLER STAND: „Wandtafel in Glas“ (Eriks Wahl, löst die unten
|
||
beschriebene Panel-Reihe ab).** Gleicher Hintergrund wie die Glas-App
|
||
(Lichtflächen in den Logo-Blautönen + `logo.svg` als Wasserzeichen, über
|
||
`body.wandtafel-aktiv .container` — unabhängig vom Glas-Schalter), Felder
|
||
halbdeckend OHNE `backdrop-filter` (Kiosk-Rechner), Schrift **Outfit**
|
||
(Fredoka/Nunito Sans sind entfallen, auch in den Übersichten). Alle Maße über
|
||
`--u` = `min(1cqw, 1.7778cqh)` (`#wandContent` ist Container mit
|
||
`container-type: size`; `--u` steht eine Ebene tiefer, weil
|
||
Container-Einheiten nie das Element selbst meinen): 1 % der Breite, aber
|
||
nie mehr, als die Höhe bei 16:9 hergäbe — auf flacheren Fenstern schrumpft
|
||
die Tafel gleichmäßig, statt unten abgeschnitten zu werden (so war der
|
||
Bereich Gewerke abgeschnitten). `.wand-ambient.wt` ist genau einen
|
||
Bildschirm hoch. Aufbau (`wt*`-Funktionen in `js/wand/wand.js`, Stile im
|
||
Block „WANDTAFEL IN GLAS“ am Ende von `styles.css`, Tokens `--wt-*`):
|
||
Kopf (`wtKopfHtml()`: Logo, Sitzung, heute Abwesende → Übersicht, große
|
||
Uhr `#wtUhr`, sekündlich über `wandCountdownTick()`), vier Kennzahlen
|
||
(`wtKpiHtml()`, Ringe für Auslastung/Besprochen), links **Gewerke**
|
||
(`wtGewerkeHtml()`: je Gewerk Auslastung + Besprochen-Kästchen, jede
|
||
Teilzeile → bisherige Übersicht), rechts **Die nächsten Tage**
|
||
(`wtTageDaten()`/`wtTageHtml()`: fünf Arbeitstage ab heute über
|
||
`isWorkday()`, **nur Meilensteine** aller Projekte der Sitzung — Eriks
|
||
Wunsch; Aufgaben/Überfälliges stehen in den Kennzahlen, Abwesende im Kopf.
|
||
Ein Termin an einem Wochenende/Feiertag zwischen den Spalten landet beim
|
||
Arbeitstag davor mit Vermerk „am Sa 24.10.“ — vorher fiel er ganz heraus;
|
||
`wtSpaltenEinpassen()` blendet nach dem Zeichnen aus, was nicht in die
|
||
Spaltenhöhe passt, und zählt es zu „+ n weitere“), Fuß „Wischen für alle
|
||
Projekte“. Eine Null-Auslastung ist grau, nie grün (`wtAmpel()`).
|
||
**Der Rücksprung-Zähler ruht im Grundzustand** (`wandImGrundzustand()`:
|
||
ganz oben gescrollt und keine Übersicht offen — Eriks Wunsch): dann ist
|
||
`#wandIdleCountdown` leer und `wandCountdownTick()` hält die volle Minute
|
||
vor; erst Scrollen oder eine geöffnete Übersicht lässt ihn ab 60 s laufen.
|
||
`wandDashKopfHtml()`/`wandKpiReiheHtml()`/`wandPanel()`/
|
||
`wandAuslastungInhalt()`/`wandBesprochenInhalt()`/`wandAktuellInhalt()`/
|
||
`wandAktuelleEreignisse()` sind entfernt; Rechenfunktionen und Übersichten
|
||
bleiben. Die folgenden Absätze beschreiben frühere Stände.
|
||
- **Redesigned again, on-screen label now "Dashboard".** After the
|
||
two-column layout below had been live for a while, Erik asked for a round
|
||
of completely fresh full-board concepts (not incremental tweaks to the
|
||
existing look). From a 3-way comparison of genuinely different structures
|
||
(a "Leitstand"/control-room instrument panel, a size-varied card mosaic, an
|
||
auto-rotating single-focus carousel) he picked the Leitstand's
|
||
**structure** — not its monochrome/terminal skin. A second round then
|
||
explored skins of that SAME structure (Erik: "es muss nicht retro
|
||
aussehen... es darf auch vom grundlegenden Design abgewichen werden, es
|
||
soll nur die selbe Struktur sein"), landing on "Pastell Freundlich",
|
||
finally recolored into the two blues of the company logo (sampled directly
|
||
from `public/logo.ico`: `#94b0cc` light, `#116896` dark) instead of a
|
||
generic pastel mix or the app's own `--accent`. The **current** structure,
|
||
replacing the two-column one described below:
|
||
- **Kopf** (`wandDashKopfHtml()`) — the marke now reads **"Dashboard"**
|
||
(Erik: "ersetze den Schriftzug Wandtafel durch Dashboard" — a label
|
||
invented for this screen's own header, not a rename of the tab/route/
|
||
sidebar entry, which all stay "Wandtafel" throughout this file and the
|
||
code), the current session label + a live clock, and — moved out of the
|
||
old "Heute" tile — a small antippable avatar cluster for who's absent
|
||
today (`wandZeigeUebersicht('abwesend')`, unchanged target).
|
||
- **Kennzahlen-Reihe** (`wandKpiReiheHtml()`) — four tiles: Auslastung
|
||
gesamt and Besprochen gesamt (`wandGesamtKennzahlen()`, new — the SAME
|
||
per-Gewerk sums from `wandAuslastungJeGewerk()`/`wandBesprochenDaten()`
|
||
simply totalled across all three Gewerke rather than broken out, so the
|
||
deliberate multi-Gewerke double-count applies here too automatically),
|
||
plus offene Aufgaben/überfällig (unchanged counts from
|
||
`wandHeuteDaten()`, moved out of the old "Heute" tile and still
|
||
individually clickable to the same two overviews).
|
||
- **Panel-Reihe** (`.dash-grid`, three panels framed by the shared
|
||
`wandPanel()`) — Auslastung je Gewerk and Besprochen je Gewerk keep
|
||
their exact previous per-row content and click targets
|
||
(`wandAuslastungInhalt()`/`wandBesprochenInhalt()`, simply the old tile
|
||
functions with the `wandKachel()` wrapper stripped off); the third
|
||
panel, "Aktuell" (`wandAktuellInhalt()`), reverts the feed from the
|
||
three-column by-type grouping (see further below, now superseded) back
|
||
to one flat list sorted by closeness to today, capped at `WAND_FEED_MAX`
|
||
(6) — a colored dot per entry (`.dash-feed-punkt`) still marks
|
||
Neu/Überfällig/Termin, just without the column headers/counts that
|
||
needed more width than a third of this narrower row affords. Every
|
||
entry stays individually clickable into its project, unchanged.
|
||
- The **Übersicht drill-downs and the full project list below are
|
||
unchanged in content and behavior** — only restyled with the same
|
||
`--dash-*` tokens so the whole screen reads as one identity rather than
|
||
a themed top half bolted onto a generically-styled bottom half. The
|
||
project list specifically was deliberately left on the app's normal
|
||
`.board-row` styling, not reskinned — see its own bullet further below,
|
||
"Ein Ort je Angabe"-adjacent reasoning that still applies unchanged.
|
||
- **New CSS custom properties, `--dash-*`, declared on
|
||
`body.wandtafel-aktiv`** (styles.css) with the usual three-way light/
|
||
dark pairing (bare block, `@media (prefers-color-scheme:dark)
|
||
html:not([data-theme="light"])`, `html[data-theme="dark"]`) — kept
|
||
deliberately separate from the app-wide `--accent`/`--surface` tokens,
|
||
since this one screen now intentionally carries its own brand identity
|
||
rather than the generic app chrome. `--dash-hover` exists only because a
|
||
plain semi-transparent white overlay (the obvious reach for "lighten on
|
||
hover") looks wrong once panels are already dark in dark mode — it's
|
||
tuned per theme instead of hardcoded once.
|
||
- **First web font this app has ever loaded**: `Fredoka` (numerals/marke)
|
||
and `Nunito Sans` (body), both via a Google Fonts `<link>` in
|
||
`App.html`, scoped in a comment to "only the Wandtafel". Breaks, for
|
||
this one screen, the otherwise all-system-font convention — accepted
|
||
because the approved design specifically depended on Fredoka's
|
||
character, and because the `font-family` fallback stack degrades
|
||
silently (system sans) rather than breaking anything if the kiosk has no
|
||
internet access, the same acceptable-offline-degradation precedent
|
||
pdfmake's CDN script already set for PDF export.
|
||
- **`renderWandStand()` ("Stand: HH:MM Uhr") is gone** — the header's own
|
||
live clock next to the session label now serves the same "hasn't frozen"
|
||
purpose, so a second, separate timestamp at the old feed column's foot
|
||
(which no longer exists as a column) would have been redundant.
|
||
- **Known documentation debt**: the "Two columns, version 2" bullet and
|
||
its sub-bullets immediately below describe the PREVIOUS structure this
|
||
redesign replaced. The underlying per-element click-target
|
||
philosophy, the `wandUebersicht` drill-down mechanism, and the specific
|
||
overview targets they describe are all still accurate and unchanged —
|
||
only the surrounding layout (two columns of tiles vs. the current
|
||
kopf+KPI-reihe+panel-reihe) and some renamed functions
|
||
(`wandTileAuslastung`→`wandAuslastungInhalt`,
|
||
`wandTileBesprochen`→`wandBesprochenInhalt`, `wandTileHeute` removed,
|
||
`wandKachel`→`wandPanel`, `wandLinkeSpalteHtml` removed,
|
||
`wandFeedHtml`/`WAND_FEED_SPALTEN` → `wandAktuellInhalt`) are stale.
|
||
Treat class names like `.wand-c-layout`/`.wand-c-rechts`/`.wand-kachel`/
|
||
`.wand-stat`/`.wand-namen`/`.wand-avatar`/`.wand-feed-gruppe*` mentioned
|
||
below as historical — none of them exist in styles.css any more.
|
||
- **Follow-up: the Dashboard's logo-blue palette then became the WHOLE
|
||
app's dark mode**, not just this one screen — Erik: "kannst du das gesamte
|
||
dark mode Farbschema an das der Wandtafel anpassen?". The dark-mode
|
||
`--accent`/`--text`/`--bg`/`--surface*`/`--line*` tokens (the ONE shared
|
||
`:root`/`html[data-theme="dark"]` definition at the top of styles.css,
|
||
confirmed by grep to be the only place these specific hex values appear —
|
||
every component in the app already read them exclusively through
|
||
`var(...)`, so this was a token-value swap, not a sweep across individual
|
||
rules) now carry the same cool blue-teal family as `--dash-*` above,
|
||
instead of the previous generic Apple-style blue/near-black. `--green`/
|
||
`--amber`/`--red` were retuned to the same gedeckter palette rather than
|
||
left alone or collapsed into the accent hue — they stay functionally
|
||
independent status colors (Ampel-Logik, Auftragsstatus, etc.), per
|
||
"semantic color is separate from the brand accent," just recolored to sit
|
||
well against the new cooler background instead of clashing with it.
|
||
`--band-neu` (the Wochenbericht card header color for "neu kommentiert")
|
||
reuses the exact same dark logo blue as `--dash-ink`'s light-mode value
|
||
(`#116896`) — dark enough under its own white text regardless of page
|
||
theme. Light mode is untouched; only the dark-mode token blocks changed.
|
||
- **Two columns, version 2 (Erik's choice from the layout comparison),
|
||
replacing an earlier six-tile 3×2 grid.** The grid version left visibly
|
||
uneven whitespace: a tile with a single number (e.g. "Abwesend heute")
|
||
shared a row with a tile holding three meters, and — since every tile in a
|
||
row shares that row's height — the thin tile stretched to match without
|
||
having the content to fill it. Erik's own words: "ich finde es blöd das
|
||
Leere bereiche da sind." The fix is structural, not cosmetic: fewer,
|
||
heavier-weighted components (three left tiles, one feed) rather than many
|
||
thin ones, each stretched via `.wand-kachel-body { justify-content:
|
||
space-evenly }` so its own content distributes across its own full height
|
||
instead of clumping at the top. `body.wandtafel-aktiv` additionally makes
|
||
`.app-main`/`.container`/`#wand` behave like a fixed `100vh` viewport with
|
||
`overflow: hidden` instead of a normally-scrolling page — an unattended
|
||
display should never need scrolling, and the two columns size themselves
|
||
to exactly fill it rather than growing with content.
|
||
- **Left column — three tiles, never tappable as a whole.** An earlier
|
||
version let the whole tile expand/collapse (`wandKachelKlick()`,
|
||
`wandOffenKachel`, a chevron in the title). Erik's explicit follow-up
|
||
request removed that outright: "Das ein- und ausklappen der
|
||
Informationen in der Heute-Kachel kann entfallen. Die einzelnen dort
|
||
dargestellten Elemente sollen anklickbar sein, nicht die Kachel selber.
|
||
Das soll auch bei allen anderen Kacheln so sein." Every tile now always
|
||
renders its one, compact view — `wandKachel()` lost its interactive
|
||
wrapper entirely — and instead each individual element INSIDE a tile is
|
||
its own click target (`.ist-klickbar` on the specific row/stat/avatar,
|
||
never on `.wand-kachel`), each jumping straight to the matching detail
|
||
rather than toggling anything in place. Tapping one opens a full-screen
|
||
**Übersicht** (overview), the same drill-down mechanism the überfällige-
|
||
Aufgaben list introduced, now generalized to every clickable element via
|
||
one piece of module state: `wandUebersicht` (`null`, or
|
||
`{typ, gewerk?}` — `gewerk` only for the two per-Gewerk types) set by
|
||
`wandZeigeUebersicht(typ, gewerk)` and cleared by
|
||
`wandSchliesseUebersicht()`; `wandUebersichtHtml(w)` dispatches on `typ`
|
||
to the matching builder. `wandUebersicht` resets to `null` inside
|
||
`startWandboard()`'s guarded (once-per-real-entry) branch, so leaving
|
||
and returning to the Wandtafel always starts back at the normal
|
||
two-column view rather than wherever it was left.
|
||
- **Auslastung je Gewerk** — each of the three Gewerk meter rows is its
|
||
own click target (`wandZeigeUebersicht('auslastung', g)`).
|
||
`wandAuslastungJeGewerk()` collects, alongside the existing per-Gewerk
|
||
totals, a `projekte:[{id,name,nr,stunden}]` list — reusing the exact
|
||
same `berechneAuslastung()` call `renderLoad()` already makes, just
|
||
also keeping the per-project number instead of only summing it. The
|
||
deliberate multi-Gewerke double-count (a project tagged with several
|
||
Gewerke counts its full contribution at each) is unchanged. The
|
||
opened overview (`wandUebersichtAuslastung()`) lists every
|
||
contributing project of that Gewerk, highest hours first; a row jumps
|
||
into the project's **Leistung** section (`wandSpringeZuProjekt(pid,
|
||
'leistung')`) — the section that already computes honorar × Phase ×
|
||
Fortschritt, the most directly relevant place for an Auslastung
|
||
question to land.
|
||
- **Wochenbericht besprochen** — each of the three Gewerk meter rows
|
||
(only rendered clickable when that Gewerk actually has relevant
|
||
projects) opens `wandZeigeUebersicht('besprochen', g)`.
|
||
`wandBesprochenDaten()` now keeps `offeneProjekte:[{id,uid,name,nr}]`
|
||
per Gewerk (replacing the plain-string `offeneNamen` the first
|
||
version had) so each row in the opened overview
|
||
(`wandUebersichtBesprochen()`) can carry its `project_uid`. Clicking a
|
||
row does **not** open the Projektbereich — it calls
|
||
`springZumWochenbericht(uid)` (`js/plan/plan-info.js`), landing
|
||
directly on that project in the Wochenbericht. This follows "Ein Ort
|
||
je Angabe" (CLAUDE.md): the `besprochen`-Haken and the session
|
||
comment are editable *only* in the Wochenbericht, never in the
|
||
Projektbereich, so a jump into the Projektbereich would only land on
|
||
its own read-only verweis back to the Wochenbericht — one hop that
|
||
the Wandtafel now skips entirely.
|
||
- **Heute** — the two stat numbers (offene Aufgaben/überfällig) and the
|
||
avatar row are each independently clickable, no merged
|
||
expand/collapse state any more:
|
||
- **"offene Aufgaben"** → `wandZeigeUebersicht('offen')` — EVERY open
|
||
task across every project (not just overdue), nearest due date
|
||
first, empty dates last (`wandHeuteDaten()`'s new `offeneListe`,
|
||
the same sort convention "Meine Aufgaben" uses on the Dashboard).
|
||
- **"überfällig"** → `wandZeigeUebersicht('ueberfaellig')` — unchanged
|
||
from the previous version, now reached directly instead of as a
|
||
tap-target nested inside an otherwise expand/collapse-only tile.
|
||
- **the avatar row** → `wandZeigeUebersicht('abwesend')` — previously
|
||
only visible as an expanded detail list, now its own overview
|
||
reachable straight from the avatars; a row shows name/type/bis with
|
||
no click-through (an absence belongs to no project to jump to).
|
||
All three overviews share one row renderer
|
||
(`wandUebersichtZeile(links, mitte, rechts, onclick, rechtsKlasse)`)
|
||
and one frame (`wandUebersichtRahmen()`) — the per-type builders only
|
||
supply the three columns' content and (where it applies) the
|
||
`onclick`. A clicked task row calls `wandSpringeZuProjekt(pid,
|
||
'aufgaben')`, which sets `location.hash = 'aufgaben'` *before*
|
||
`openProjectPlan(pid)` (`js/gantt.js`) — the same "set the hash
|
||
first, `showTab('plan')`'s own `updateRouteUrl()` preserves it"
|
||
mechanism the Dashboard's own task links rely on
|
||
(`js/dashboard/dashboard.js`). This is a deliberate, explicit exit
|
||
from the Wandtafel: `showTab('plan')` clears `body.wandtafel-aktiv`
|
||
(sidebar/topbar return) exactly like navigating to any other tab
|
||
would, and a kiosk account landing on the Projektbereich can view it
|
||
(todos render from already-loaded `db` data, no fetch needed) but not
|
||
edit anything there (every write route still 403s) — a few of the
|
||
Projektbereich's OTHER sections do prefetch their own data
|
||
unconditionally on load (Dateien, Protokolle, the Benutzerliste for
|
||
the reviewer-picker), and those specific fetches 403 and log a
|
||
caught, non-fatal error for a kiosk account; the Aufgaben/Terminplan
|
||
sections themselves are unaffected. Every overview's list is the one
|
||
deliberate exception to the Wandtafel's "never scrolls" rule
|
||
(`.wand-uebersicht-liste { overflow-y: auto }`) — a genuinely long
|
||
list belongs to an intentional drill-down, not the passive ambient
|
||
view.
|
||
- **Right column — "Aktuell diese Woche"** replacing the old standalone
|
||
Projektportfolio tile outright (portfolio composition by
|
||
Gruppe/Auftragsstatus is not currently shown anywhere on the Wandtafel —
|
||
a deliberate scope cut in this version, not an oversight, left for a
|
||
future addition if wanted). `wandAktuelleEreignisse()` collects three
|
||
event kinds pulled from every project in the session:
|
||
- **Neu** — a project whose `classifyProjectChange()` bucket (reused
|
||
directly from `js/wochenbericht/wochenbericht.js`, same function the
|
||
Wochenbericht tab itself uses to diff against `findPreviousWeek()`) is
|
||
`'status'` or `'new'`, with a plain-text comment preview via
|
||
`statusPreviewText()` (`js/board/board.js`, HTML stripped, 70 chars).
|
||
- **Überfällig** — any open `plan_todos` entry whose `end` date has
|
||
already passed, across every project (not scoped to a person, unlike
|
||
`GET /my-dashboard`).
|
||
- **Termin** — any upcoming `plan_milestones` entry, across every
|
||
project.
|
||
Within each kind, entries are sorted by **closeness to today regardless
|
||
of direction** (`Math.abs(tageBis(datum))` ascending, `tageBis()` reused
|
||
from `js/dashboard/dashboard.js`) rather than by date — an event from
|
||
yesterday and one from tomorrow are both "relevant right now" in a way a
|
||
plain chronological sort wouldn't group together.
|
||
**On Erik's follow-up ("ein anderes Design für die Kachel Aktuell diese
|
||
Woche"), `wandFeedHtml()` groups these events into THREE SIDE-BY-SIDE
|
||
COLUMNS by kind (`WAND_FEED_SPALTEN`: Neu/Überfällig/Termine,
|
||
`.wand-feed-gruppen`/`.wand-feed-gruppe`) instead of one chronologically
|
||
mixed list** — a design choice ("Variante C") picked from a 3-way visual
|
||
comparison (alongside a connecting timeline and larger accent-bordered
|
||
cards). Trades away the previous single-list "what matters most right
|
||
now regardless of kind" ordering for an at-a-glance read of HOW MANY
|
||
events of each kind are pending — each column header shows the kind's
|
||
TRUE total count (`.wand-feed-gruppe-anzahl`) even though only
|
||
`WAND_FEED_MAX` (5, now a PER-COLUMN cap rather than a global one)
|
||
entries are actually listed underneath; an empty column shows "keine"
|
||
rather than being hidden, since the column's presence itself is part of
|
||
the at-a-glance count. Each row's timestamp (`relativerTag()`, also from
|
||
`dashboard.js`) is the same relative-day phrasing ("heute"/"morgen"/"seit
|
||
3 Tagen") used throughout the rest of the app.
|
||
**Every entry is its own click target** (`.wand-feed-eintrag`, part of
|
||
the same per-element interaction model as the left column). Each event
|
||
object carries `pid` and an `abschnitt` matched to its kind (`aufgaben`
|
||
for an überfällig entry, `terminplan` for a termin entry, `dashboard`
|
||
for a neu entry), and a click calls the shared `wandSpringeZuProjekt(pid,
|
||
abschnitt)` — an entry is already a single, concrete event, so unlike
|
||
the left column's meter rows it jumps straight into the project with no
|
||
intermediate overview.
|
||
- **Stand** — unchanged: a plain "Stand: HH:MM Uhr" timestamp under the
|
||
feed, the cheapest possible signal that the kiosk hasn't silently frozen
|
||
over the days/weeks it runs unattended.
|
||
- **Below the fold: the complete project list, reachable by scrolling — the
|
||
one deliberate exception to "this display never scrolls".** Erik's
|
||
follow-up, after being told the ambient view couldn't fit a full project
|
||
list without breaking the fixed-viewport design: the Wandtafel's monitor is
|
||
a real touch screen, so he can walk up and scroll it by hand ("ich habe
|
||
einen Touch Monitor... ich kann mich vor die Tafel stellen und scrollen").
|
||
`renderWandboard()` now renders the two-column area wrapped in a new
|
||
`.wand-ambient` box (still exactly one screen tall, `min-height: 100%`)
|
||
followed by `wandProjektlisteHtml(w)` — every project, grouped and ordered
|
||
exactly like the Projekte list (`GROUP_ORDER`/`sortGroups()`, unlike the
|
||
Auslastung/Besprochen tiles this one deliberately INCLUDES "In Anbahnung",
|
||
since the point here is completeness, not bewertbare Arbeitslast).
|
||
**On Erik's explicit follow-up ("Die Projektliste soll so aussehen wie die
|
||
Originale"), each row is NOT a bespoke Wandtafel row** — it is built with
|
||
the exact same `renderBoardRow()` + `BOARD_OPTS` call `renderBoard()`
|
||
itself uses for the real Projekte tab (`js/board/board.js`): same Nr.,
|
||
name, Auftragsstatus-Kennzeichen, comment preview, avatar stack and
|
||
Phasen-Badge, and the row expands to the identical `renderBoardDrawer()`
|
||
detail panel. `locked` is passed as a hardcoded `true` regardless of the
|
||
session's actual lock state — the Wandtafel never writes (see file-header
|
||
comment), so this always hides the drag handle and the "Löschen" button
|
||
and always renders the comment as `.comment-readonly`, independent of
|
||
whether the real session happens to be unlocked; a kiosk account's write
|
||
attempt would 403 server-side anyway, so there is nothing to protect by
|
||
mirroring the real lock state here — only an unconditional, hardcoded
|
||
read-only rendering makes the guarantee independent of that state.
|
||
`toggleBoardRow()` (`board.js`) now also calls `renderWandboard()` after
|
||
flipping a row's expanded state, exactly as it already did for
|
||
`renderBoard()`/`renderWochenbericht()`, so expanding/collapsing a row in
|
||
the Wandtafel's own list takes effect immediately instead of only on the
|
||
next full re-render. Only `#wandContent` itself scrolls (class
|
||
`.wand-scrollable`, toggled by `renderWandboard()` — present
|
||
for the normal ambient view, absent while an Übersicht is open, which stays
|
||
exactly one screen tall with its own existing internal list scroll) —
|
||
`.app-main`/`.container` above it are untouched, still `overflow: hidden`.
|
||
**An unattended touch display can't be left in whatever state the last
|
||
passer-by scrolled it to**, so `WAND_IDLE_MS` (60 s) drives an inactivity
|
||
timer: `wandAktivitaetErkannt()` is registered once, globally, on
|
||
`pointerdown`/`touchstart`/`wheel`/`keydown`/`scroll` (the `scroll`
|
||
listener needs the **capturing** phase specifically — a scroll event on an
|
||
element, unlike on `window`/`document`, does not bubble, but a capturing
|
||
listener on an ancestor still sees it on the way down regardless of
|
||
bubbling) and is a no-op unless `body.wandtafel-aktiv` is set, so it costs
|
||
nothing on every other tab. Each call resets the timer; after `WAND_IDLE_MS`
|
||
without any of those events, `wandBeiUntaetigkeitZuruecksetzen()` clears
|
||
`wandUebersicht` and calls `renderWandboard({resetScroll:true})`,
|
||
unconditionally — even if nobody opened an Übersicht and only scrolled the
|
||
project list, that scroll position must still snap back. The same
|
||
`resetScroll` flag is also why an ordinary 60-second background data
|
||
refresh (`wandRefreshTick()` → `load(true)` → `renderWandboard()`, no
|
||
`opts`) does **not** reset anyone's scroll position — `renderWandboard()`
|
||
reads and restores `container.scrollTop` across every re-render unless
|
||
`resetScroll` is explicitly passed, so a person actively scrolling the
|
||
project list is never yanked back to the top by the routine data refresh,
|
||
only by genuine inactivity. **`#wandIdleCountdown`** (a `<span>` next to
|
||
"← Zur Übersicht" in `App.html`'s `.wand-kopfzeile`, deliberately OUTSIDE
|
||
`#wandContent` so it survives every data/content re-render untouched) —
|
||
Erik's follow-up wanting to see the countdown, not just experience the
|
||
jump. `wandAktivitaetErkannt()` additionally stores the absolute fire time
|
||
in `wandIdleDeadline`; a separate 1-second `setInterval`
|
||
(`wandCountdownTimer`, started/stopped alongside the others in
|
||
`startWandboard()`/`stopWandboard()`) runs `wandCountdownTick()`, which
|
||
writes "Rücksprung in N s" from `wandIdleDeadline - Date.now()`.
|
||
`wandBeiUntaetigkeitZuruecksetzen()` calls `wandAktivitaetErkannt()` again
|
||
at its own end, so the countdown keeps cycling continuously for as long as
|
||
the Wandtafel is open, rather than running once and going silent after the
|
||
first automatic reset.
|
||
- **The same auto-return also applies AFTER leaving the Wandtafel through one
|
||
of its own clickable areas** — Erik's follow-up: "auch wenn ich eine der
|
||
Flächen/Projekte auf der Tafel anklicke soll nach 1 min das Bild wieder
|
||
zurückspringen". A tap on a project (the project list, a feed row, an
|
||
Übersicht row) or a Gewerk meter deliberately navigates away into the
|
||
Projektbereich or the Wochenbericht — without this, the display would be
|
||
left there indefinitely once nobody is around to notice. `wandUnterwegs`
|
||
(a module-level flag) tracks "away because of such a click"; while true,
|
||
`wandAktivitaetErkannt()` drives a *second* timer, `wandRueckkehrTimer`
|
||
(same `WAND_IDLE_MS`), instead of the on-Wandtafel idle timer, and
|
||
`wandKehreZurueck()` calls `showTab('wand')` once it fires — same
|
||
`WAND_IDLE_MS` constant, same "any touch resets it" semantics as the
|
||
on-Wandtafel case, just pointed the opposite direction.
|
||
**`wandUnterwegs` is set by one generic, capturing `click` listener on
|
||
`document` that checks `#wandContent.contains(event.target)`** — not by
|
||
wrapping every individual navigation call. The project list's rows render
|
||
through the real `renderBoardRow()` (see above), whose own `.board-name`
|
||
handler calls `openProjectPlan()` **directly** and calls
|
||
`event.stopPropagation()` first; a normal bubbling listener on
|
||
`#wandContent` would never see that click at all. A *capturing* listener
|
||
does, because capturing runs on the way down to the target regardless of
|
||
any `stopPropagation()` called later during bubbling. The listener only
|
||
ever sets the flag — arming `wandRueckkehrTimer` itself happens at the end
|
||
of `stopWandboard()` (`if(wandUnterwegs) wandAktivitaetErkannt();`), which
|
||
runs synchronously, in the same click, right after `body.wandtafel-aktiv`
|
||
has already been removed by `showTab()` — so by the time it checks,
|
||
`wandAktivitaetErkannt()` reliably takes the "away" branch rather than the
|
||
on-Wandtafel one. The Besprochen-Übersicht's jump into the Wochenbericht
|
||
(`springZumWochenbericht()`, shared with `board.js`/`plan-info.js`) needed
|
||
no wrapper or special-casing either, for the same reason — the click still
|
||
originates inside `#wandContent`. **The explicit "← Zur Übersicht" exit is
|
||
the one deliberate exception**: that button sits in `.wand-kopfzeile`,
|
||
OUTSIDE `#wandContent`, so the listener never sees it and `wandUnterwegs`
|
||
stays `false` — a staff member's deliberate preview exit is not
|
||
interrupted after a minute, only an accidental or exploratory tap on the
|
||
Wandtafel's own content is. `startWandboard()` resets `wandUnterwegs` to
|
||
`false` and clears `wandRueckkehrTimer` on every fresh arrival at the
|
||
Wandtafel (whether via `wandKehreZurueck()` or an ordinary manual
|
||
navigation back to it), so a completed round trip never leaves a stale
|
||
timer armed.
|
||
- `TAB_SLUGS`/`VALID_TAB_SLUGS` gained `wand: 'Wandtafel'`
|
||
(`js/globals.js`/`server.js`) — the deep-link `/Wandtafel` works through
|
||
the same `applyUrlRoute()`/`updateRouteUrl()` mechanism as every other tab,
|
||
no special-casing needed. A redirect `GET /Test.html`-style old link isn't
|
||
needed here since nothing ever pointed at this tab before it existed.
|
||
|
||
## Gleichzeitiges Arbeiten (Zusammenführen statt Verwerfen, `js/store.js`)
|
||
|
||
Eine Sitzung wird weiterhin als Ganzes über `POST /save-week` mit
|
||
Revisionsstand gespeichert, und der Server antwortet weiterhin mit 409, wenn
|
||
jemand anderes sie inzwischen gespeichert hat. **Geändert hat sich die
|
||
Reaktion im Browser:** Früher verwarf `handleWeekConflict()` die eigenen
|
||
Änderungen mit einem `alert()` — bei zwei Personen in derselben (laufenden)
|
||
Sitzung ständig, auch an verschiedenen Projekten (Eriks Meldung: „immer
|
||
Fehlermeldungen, sobald mehr als eine Person in der App arbeitet“).
|
||
|
||
- `wochenBasis` (Map label → Kopie) hält je Sitzung den zuletzt bekannten
|
||
Server-Stand: gesetzt in `load()`/`reloadWeekFromServer()` und nach jedem
|
||
erfolgreichen Speichern auf genau den gesendeten Stand (`gesendet`).
|
||
- Bei 409 holt `zusammenfuehrenUndSpeichern()` den frischen Stand und
|
||
`zusammenfuehren()` führt drei Fassungen zusammen
|
||
(`dreiwegeZusammenfuehren(basis, eigen, fremd)`): Objekte je Schlüssel,
|
||
Listen von Objekten mit `id` (Projekte, Aufgaben, Meilensteine, Personen)
|
||
je id — damit auch Kommentare je Gewerk und Anwesenheit je Person
|
||
getrennt. Nur echte Doppeländerungen desselben Felds sind Konflikte; dort
|
||
gilt die eigene Fassung, `zeigeHinweis()` nennt das Projekt (8 s, nicht
|
||
blockierend). Danach wird erneut gespeichert; nach drei Überholungen in
|
||
Folge oder bei einer gelöschten Sitzung greift der alte Weg.
|
||
- `inPlaceUebernehmen()` schreibt das Ergebnis IN die bestehenden Objekte
|
||
(gleiche id → gleiches Objekt), weil z. B. `window.PlanApp` eine Referenz
|
||
auf Woche und Projekt hält; ein Ersetzen hätte dessen weitere Änderungen
|
||
ins Leere laufen lassen.
|
||
- **Live-Abgleich:** `wochenLiveAbgleich()` fragt alle 20 s
|
||
`GET /week-revisionen` (nur `label → updated_at`) und übernimmt fremde
|
||
Änderungen über denselben Weg — nur wenn hier nichts ungespeichert ist,
|
||
kein Speichern läuft und niemand tippt oder einen Dialog offen hat
|
||
(`wirdGeradeBearbeitet()`), sonst beim nächsten Durchlauf.
|
||
- Getestet (zwei Konten): verschiedene Projekte → beide gespeichert;
|
||
dasselbe Kommentarfeld → eigene Fassung + Hinweis; fremde Änderung ohne
|
||
eigene → nach ≤ 20 s sichtbar; in keinem Fall ein Dialog.
|
||
|
||
### Live-Ereignisse und Speichern einzelner Projekte (aktueller Stand)
|
||
|
||
Eriks Wunsch: Änderungen sofort bei allen, und nur den geänderten Teil
|
||
übertragen. Ergänzt (und ersetzt teilweise) die Beschreibung oben:
|
||
- **Speichern nur des Geänderten:** `syncAllToServer()` vergleicht jede
|
||
geänderte Sitzung mit ihrem zuletzt bekannten Server-Stand
|
||
(`geaenderteProjekte()`). Haben sich NUR Projektinhalte geändert, gehen nur
|
||
diese Projekte an **`POST /save-projects {label, projects:[{project,
|
||
baseUpdatedAt}]}`** (`sendProjectsToServer()`, `js/server.js`; gemessen
|
||
~1,9 KB statt ~22 KB). Server: Konfliktprüfung JE PROJEKT über
|
||
`projects.updated_at` (409 `{conflict, projekte:[ids]}` → dieselbe
|
||
Dreiwege-Zusammenführung wie oben), dann `deleteProjectData()` +
|
||
`serializeProject()` (aus `serializeWeek()` herausgelöst) in einer
|
||
Transaktion, `weeks.updated_at` wird mitgezogen. Sitzungsebene
|
||
(Teilnehmer, Anwesenheit, Sperre), Anlegen/Löschen/Umsortieren von
|
||
Projekten → weiter `/save-week`. Nach einem `/save-week` steht am Server
|
||
jedes Projekt auf der Wochen-Revision; der Client zieht `p.updatedAt`
|
||
deshalb für alle Projekte nach.
|
||
- **`GET /ereignisse`** (Server-Sent Events, `requireAuthOderKiosk`, ohne
|
||
Abhängigkeit; `X-Accel-Buffering: no` für einen Proxy, Herzschlag alle
|
||
25 s): `ereignisSenden()` meldet nach jedem Speichern allen ANDEREN
|
||
Browsern (`X-Client-Id`/`?client=` = `CLIENT_ID` je Tab)
|
||
`{typ:'projekte', label, updatedAt, projekte}` (der neue Stand der
|
||
Projekte), `{typ:'woche', label, geloescht?}` (`/save-week`, Entsperren,
|
||
Löschen, Papierkorb) oder `{typ:'neu'}` (Sitzungswechsel, Backup-Import).
|
||
Abgemeldete/abgelaufene Sitzungen werden dabei getrennt.
|
||
- **Browser** (`liveVerbinden()` nach `load()`, `liveProjekte()`,
|
||
`liveWocheNachladen()` über **`GET /week?label=`** = `loadWeeks(label)`,
|
||
`liveAllesNeu()`): Projekte ohne eigene ungespeicherte Änderung werden
|
||
übernommen, sonst dreiwege-zusammengeführt (Hinweis bei echtem Konflikt).
|
||
Neu gezeichnet wird sofort oder, solange jemand tippt/ein Dialog offen ist,
|
||
danach (`ansichtNachBearbeitung()`). `wochenLiveAbgleich()` ist nur noch
|
||
Rückfall (60 s und nach jedem Wiederverbinden).
|
||
- **`gleich()` vergleicht reihenfolgeunabhängig** (`stabilerText()`): Mit
|
||
`JSON.stringify` hielte es nach `inPlaceUebernehmen()` Gleiches für
|
||
verschieden, und zwei Browser schickten sich endlos dieselben Projekte zu.
|
||
- Getestet mit zwei Browsern: Änderung nach ~0,6 s beim anderen (davon 0,5 s
|
||
Entprellung), sichtbar im offenen Wochenbericht; verschiedene Projekte
|
||
gleichzeitig → beide gespeichert; gleiches Feld → Hinweis; Anwesenheit über
|
||
`/save-week` → ~0,6 s; Sandbox-Plan einzeln; in Ruhe keine Speicherungen.
|
||
|
||
## Benachrichtigungen (Glocke, `js/benachrichtigungen.js`)
|
||
|
||
Eriks Wunsch: Wer eine neue Aufgabe zugewiesen bekommt, sieht das in der
|
||
Übersicht. **Entstehen** server-seitig in `POST /save-week`: Vor dem Speichern
|
||
merkt sich die Route alle `plan_todo_assignees` der Woche, danach legt
|
||
`neueZuweisungenMelden()` für jede NEU hinzugekommene Kombination (Aufgabe ×
|
||
Person) einer offenen Aufgabe einen Eintrag in **`benachrichtigungen`** an —
|
||
nur für aktive Nicht-Kiosk-Konten mit passender `person_id`, nie für den, der
|
||
speichert (Selbstzuweisung), nicht doppelt, solange eine ungelesene zur selben
|
||
Aufgabe existiert, und nicht für eine Woche, die mit diesem Speichern erst
|
||
entsteht. Fehler dort werden nur geloggt, das Speichern scheitert nie daran.
|
||
Tabelle mit FK → `users ON DELETE CASCADE`, nicht in `IMPORT_TABLES`;
|
||
`purgeBenachrichtigungen()` (Start + stündlich) entfernt gelesene nach 60,
|
||
alle nach 180 Tagen. **Routen:** `GET /benachrichtigungen` (30 jüngste +
|
||
`ungelesen`), `POST /benachrichtigungen/gelesen {ids}|{alle:true}` — beide
|
||
`requireAuth`, immer nur das eigene Konto.
|
||
**Löschen** (Eriks Wunsch): `POST /benachrichtigungen/loeschen {ids}|{alle:true}`
|
||
(ebenfalls nur eigenes Konto, endgültig). In der Glocke trägt jede Zeile
|
||
(`.glocke-zeile` = Öffnen-Knopf + `.glocke-loeschen` „ד) einen eigenen
|
||
Löschen-Knopf, der Kopf „Alle gelesen“/„Alle löschen“
|
||
(`loescheBenachrichtigungen()`).
|
||
**Anzeige:** Glocke im Menüband (`#glockeBtn`, rote Zahl, Liste
|
||
`#glockeMenue`) und auf „Meine Aufgaben“ der Block „Neu zugewiesen“
|
||
(`#dashNeu`, nur bei Ungelesenem). Abgefragt bei jedem `renderAll()` und
|
||
minütlich. Ein Klick markiert als gelesen und springt in die Aufgaben des
|
||
Projekts — gesucht in der AKTUELLEN Sitzung über die Projektnummer (die
|
||
Projekt-ID wechselt je Sitzung), Planung-Terminpläne über ihre ID.
|
||
|
||
## Glas-Darstellung (`public/glas.css`)
|
||
|
||
Eriks Wahl aus dem Gestaltungsvorschlag „Projektübersicht in Glas“ (Annäherung
|
||
an Apple Liquid Glass). **Eine eigene Schicht, als letztes Stylesheet geladen,
|
||
die nur mit der Klasse `html.glas` greift** — ohne sie sieht die App aus wie
|
||
zuvor. Ein Inline-Skript im `<head>` von `App.html` setzt die Klasse vor dem
|
||
ersten Zeichnen, außer `localStorage['projuebersicht-glas'] === 'aus'`;
|
||
abschaltbar je Browser unter Einstellungen → Mein Konto („Darstellung“,
|
||
`setGlasDarstellung()` in `js/settings/settings.js`), gedacht für langsame
|
||
Rechner.
|
||
|
||
- **Tokens statt Einzelregeln:** `--surface`/`--surface-alt`/`--surface-sunk`/
|
||
`--line*` werden durchscheinend, `--accent` wird das Logo-Dunkelblau
|
||
(`#0f5a85`, dunkel `#8cc9e6`), Rundungen größer (`--r-lg` 18px, `--r-xl`
|
||
22px). Damit wirken die ~70 Stellen mit `var(--surface)` ohne Einzelanpassung.
|
||
**Folge:** `var(--surface)` ist unter Glas KEINE deckende Farbe mehr — Text
|
||
auf Akzentfläche braucht eine feste Farbe (siehe `.dash-kal-tag.ist-gewaehlt`,
|
||
`.pt-badge-person` in glas.css); wer neu `color: var(--surface)` schreibt,
|
||
baut denselben Fehler.
|
||
- **Echtes Glas (`backdrop-filter`) nur auf den großen Flächen** — eine Liste
|
||
von rund 30 Selektoren in glas.css (Menüband, Seitenleiste, Karten, Dialoge,
|
||
Projektbereichs-Panels). Neue Karten-Container dort ergänzen. Dialoge,
|
||
Menüs und Hinweise sind fast deckend (`--glas-dicht`).
|
||
- **Hintergrund:** Lichtflächen in den Logo-Blautönen und `public/logo.svg`
|
||
(aus `logo.png` vektorisiert, scharf in jeder Größe) als fixiertes
|
||
Wasserzeichen über `body::before`. Login-Seite und Kopfzeile nutzen
|
||
ebenfalls `logo.svg`; die Login-Seite hat denselben, aber deckenden
|
||
Hintergrund.
|
||
- **Seitenleiste** schwebt auf Desktop-Breite mit 12px Abstand und runden
|
||
Ecken; aktiver Eintrag als helle Kapsel.
|
||
- **GANTT-Namensspalte deckend:** Die feste (sticky) Namensspalte hatte
|
||
unter Glas das halbdurchsichtige `--glas-deckend`; beim waagerechten
|
||
Scrollen schienen die Balken durch die Namen. Jetzt `--gantt-label-grund`
|
||
(derselbe Ton, deckend, hell/dunkel in `glas.css`); getönte Zeilen
|
||
(Projekt, Phasenköpfe) legen ihre Tönung als `background-image` darüber.
|
||
- **Projektbereich zeigt den Glas-Hintergrund:** `.plan-main` hat in
|
||
`plan-styles.css` einen deckenden Grund (`var(--bg)`); unter Glas ist er
|
||
durchsichtig (`glas.css`), sonst sah man im Projekt nur flaches
|
||
Dunkelblau/Grau statt Lichtflächen und Wasserzeichen.
|
||
- **„Alles schwebt“** (Eriks Wahl C, Desktop): Auch das Menüband
|
||
(`.app-topbar`, sticky bei `top: 12px`) und der Projektkopf schweben mit
|
||
12px Abstand und runden Ecken. Der Projektkopf ist dafür in
|
||
`App.html` in **`.plan-kopf`** gefasst (`.plan-header` + `.plan-nav`
|
||
darin ohne eigene Fläche) — vorher stieß ein eckiger, durchgehender
|
||
Streifen an die runde Seitenleiste. Der aktive Reiter ist eine Kapsel wie
|
||
der aktive Eintrag der Seitenleiste; `.plan-main-content` hat unter Glas
|
||
12px statt 20px Rand, damit die Kacheln bündig unter der Kopfkarte
|
||
stehen. Ohne Glas und unter 769px bleibt alles wie zuvor.
|
||
- **Ausgenommen:** die Wandtafel (`body.wandtafel-aktiv`: kein Wasserzeichen,
|
||
kein `backdrop-filter` — eigenes Design, Kiosk-Rechner) und
|
||
`prefers-reduced-transparency` (deckende Flächen).
|
||
|
||
## Dunkelmodus nachgeschärft (Kalender, Sitzungskommentar)
|
||
|
||
Eriks Wahl „A“ aus einem Kontrast-Vergleich; Block „DUNKELMODUS
|
||
NACHGESCHÄRFT“ am Ende von `styles.css`. Alles über Tokens (`--kal-*`,
|
||
`--rt-*`, `--kommentar-ansicht-bg`), hell unverändert:
|
||
- `--meilenstein` fehlte im Block `html[data-theme="dark"]` — wer den
|
||
Dunkelmodus ausdrücklich gewählt hatte, sah das dunkle Lila (2:1).
|
||
- Kalender: „meine Aufgabe“ (`--kal-meine` = Akzent) und „Projektaufgabe“
|
||
(`--kal-projekt`, Grün-Türkis) sind verschiedene Farben statt desselben
|
||
Blaus mit Transparenz; Punkte dunkel 6px, Nebentage/Legende heller.
|
||
- Kommentarfarben: Die Werkzeugleiste speichert feste Farbwerte (`#d70015`
|
||
usw., vom Browser teils als `rgb(...)` geschrieben). Attributselektoren
|
||
setzen sie beim ANZEIGEN auf `--rt-rot/-gruen/-blau/-orange` um (dunkel
|
||
hellere Fassung); `:not([style*="background"])` lässt Farbflächen
|
||
(Werkzeugleiste, Gantt) aus. Gespeichert und im PDF bleibt der Originalwert.
|
||
- Projekt-Dashboard: `#planStatusWrap` bekam `.locked-cell` (55 % Deckkraft,
|
||
dunkel grauer Grund) — entfernt (`renderPlanInfo()`), der Kommentar ist
|
||
über `.is-ansicht` als Anzeige erkennbar.
|
||
|
||
## Bewegung (Hover/Klick-Rückmeldung)
|
||
|
||
The app's interaction feedback is **token-driven and lives in one place**:
|
||
five variables in `:root` (`styles.css`), applied by a clearly-marked
|
||
"BEWEGUNG" block at the **end** of `styles.css` plus a smaller companion
|
||
block at the end of `plan-styles.css` for the Projektbereich-only row types.
|
||
Every rule reads through `var()`; changing the feel is a change to those
|
||
five values, not to the components.
|
||
|
||
```
|
||
--dur: 150ms kurze Rückmeldung (Fläche, Farbe, Anheben, Druck)
|
||
--dur-lg: 200ms grössere Zustandswechsel (Dialog, Aufklappen)
|
||
--ease-out cubic-bezier(.2,.8,.2,1), schnell los / weich aus
|
||
--row-lift: -1px Hub einer anklickbaren Zeile beim Zeigen
|
||
--press: .97 Stauchung beim Drücken
|
||
```
|
||
|
||
Erik picked this level ("Spürbar") from a three-way live comparison
|
||
(dezent / spürbar / lebendig) built with the Taste-Skill. **Level 3's
|
||
entrance animations were deliberately NOT taken**: with ~30 projects in a
|
||
list that re-renders on every save, rows animating in on each render is
|
||
unrest, not polish. The rule that follows from that: the surface moves
|
||
**only in response to pointing or clicking**, never on its own, and never
|
||
in a loop.
|
||
|
||
- **Row lift + accent rail** on clickable rows (`.board-row-main`,
|
||
`.bericht-rail-item`, `.planung-row`, `.dash-rand-zeile`, and in
|
||
`plan-styles.css` `.todo-depends-item`, `.pt-row-main`, `.plan-task-row`).
|
||
The rail is an **`inset` box-shadow growing 0 → 3px**, deliberately NOT a
|
||
`::before` with `position:absolute`: `.board-row` is `draggable`
|
||
(`js/projects/projects.js`), and an extra positioned element or a new
|
||
stacking context on a dragged row can shift the drag image and hit
|
||
targets. An inset shadow touches neither layout nor stacking. For the
|
||
same reason the transform sits on the **inner** `.board-row-main`, never
|
||
on the draggable `.board-row` itself (verified: the draggable element
|
||
keeps `transform: none`).
|
||
- **`.protokoll-liste-zeile` is the one exception** and must stay one: it
|
||
already carries `border-left: 3px solid transparent`, which its
|
||
`.ist-aktiv` state colours. It therefore animates *that existing border*
|
||
instead of adding the inset shadow. Using both would paint 6 px of accent
|
||
instead of 3.
|
||
- **Dialogs** use `animation`, not `transition`: `.modal-bg` is toggled
|
||
`display:none` ⇄ `flex` from JS, and a `display` change is not
|
||
transitionable, whereas an animation restarts every time the element is
|
||
rendered again (`modal-bg-auf` / `modal-auf`).
|
||
- **`prefers-reduced-motion` was missing from this codebase entirely** and
|
||
is now handled at the end of `styles.css`. Because the level is
|
||
token-driven, the block simply flips the tokens (`--row-lift: 0px`,
|
||
`--press: 1`, durations to 1ms); a blanket
|
||
`transition-duration/animation-duration: 1ms !important` additionally
|
||
catches the ~60 older rules that still hard-code their own timings.
|
||
- **Keyboard focus** got explicit `:focus-visible` outlines on the same
|
||
rows/chips. The lift and press effects answer mouse and touch only;
|
||
without this the rework would have left keyboard use worse off than
|
||
before.
|
||
- **The Wandtafel (`body.wandtafel-aktiv`) is deliberately excluded** — a
|
||
kiosk touch monitor has no hover, and that screen carries its own
|
||
`--dash-*` design anyway.
|
||
|
||
## Ladezeit (Pi)
|
||
|
||
Eriks Meldung „das Laden der Seite auf dem Pi dauert extrem lange“. Gemessen
|
||
und behoben (`server.js`, `App.html`):
|
||
- **Sitzungs-Schreibzugriff je Anfrage:** `attachUser()` schrieb bei JEDER
|
||
Anfrage — auch jeder der rund 60 JS/CSS-Dateien — `last_seen_at`; jetzt
|
||
höchstens einmal je Minute und Sitzung (`sitzungZuletztGeschrieben`).
|
||
- **`PRAGMA synchronous = NORMAL`** (WAL-üblich): Schreibvorgänge warten
|
||
nicht mehr jedes Mal auf die SD-Karte; bei Stromausfall können nur die
|
||
allerletzten Schreibvorgänge fehlen, die Datenbank bleibt konsistent.
|
||
- **gzip:** `gzipJson` (res.json ab 1 KB; `/load-all` 155 KB → 14 KB) und
|
||
`gzipStatisch()` für JS/CSS/HTML/SVG (je Datei+mtime einmal komprimiert,
|
||
im Speicher), `appSenden()` für die Seitenrouten (App.html 122 → 30 KB).
|
||
Ohne `Accept-Encoding: gzip` unverändert über express.static/sendFile.
|
||
- **Browser-Cache** über `?v=N` (siehe Server API). Erster Aufruf 1,4 MB →
|
||
0,46 MB, jeder weitere ~2 KB.
|
||
- **Externe Dateien blockieren nicht mehr:** Google-Schrift per
|
||
`rel=preload` nachgeladen, pdfmake + vfs_fonts erst nach `load`
|
||
(nacheinander); die PDF-Exporte prüfen `window.pdfMake` ohnehin.
|
||
|
||
## Betrieb auf dem Pi (Pi Zero 2, 512 MB, Ubuntu Server)
|
||
|
||
Eriks Wunsch: möglichst nicht abstürzen, sonst von selbst neu starten, kein
|
||
Speicherüberlauf. Vier Ebenen, jede fängt ab, was die darüber verpasst:
|
||
- **App (`server.js`):**
|
||
- **PDF-Arbeit im Kindprozess** (`pdf-arbeit.js`, `pdfImKindprozess()`):
|
||
`extrahierePdfText()`/`renderPdfAlsBildsatz()` laufen je Auftrag in einem
|
||
frisch geforkten Prozess (Heap `PDF_KIND_HEAP_MB`, Selbstabbruch über
|
||
`PDF_KIND_MAX_MB` RSS, 3 min Zeitlimit, höchstens einer gleichzeitig).
|
||
Stirbt er, scheitert nur der Import mit „PDF ist zu groß …“. Der
|
||
Plan-Checker-Render braucht viel Speicher (am Mac gemessen bis ~600 MB)
|
||
und kann auf dem Pi daran scheitern — gewollt, statt den Pi umzuwerfen.
|
||
- **Datei-Upload streamt auf die Platte** (`empfangeInDatei()`, 200 MB);
|
||
die übrigen Roh-Uploads sind kleiner: Protokoll/PDF/Plan Checker 25 MB,
|
||
DB-Import 50 MB. Fehler-Middleware vor `app.listen` macht aus 413 JSON.
|
||
- **Wächter** (`betriebsWaechterStarten()`): Speicher über `SPEICHER_MAX_MB`
|
||
→ sauberes Beenden mit Code 1; Event-Loop steht länger als
|
||
`HAENGER_MAX_S` → ein Worker-Thread (Herzschlag über SharedArrayBuffer)
|
||
beendet den Prozess hart. `uncaughtException` → Beenden mit Code 1,
|
||
`unhandledRejection` nur protokolliert. Ohne die Umgebungsvariablen ist
|
||
der Speicher-Wächter aus (lokal), der Hänger-Wächter läuft mit 90 s.
|
||
- `betriebAufraeumen()` stündlich: Login-Sperren, `wal_checkpoint(TRUNCATE)`.
|
||
Sitzungswechsel lädt nur noch die jüngste Sitzung. gzip Stufe 6.
|
||
`/health` meldet `speicherMb`/`laufzeitMin`.
|
||
- **Docker** (`docker-compose.yml`, `Docker/Dockerfile`): `mem_limit 320m`,
|
||
`memswap_limit 512m`, `init: true`, Log-Rotation 3×5 MB, Healthcheck per
|
||
`wget` statt eigenem Node-Prozess, `node --max-old-space-size=160`.
|
||
Docker startet „unhealthy“ NICHT neu — dafür ist der Hänger-Wächter da.
|
||
- **Pi** (`Docker/pi-einrichten.sh`, einmal mit sudo): Hardware-Watchdog
|
||
(systemd `RuntimeWatchdogSec=14s`), zram (halber RAM, zstd), cgroup memory
|
||
in `cmdline.txt` (sonst wirkt `mem_limit` nicht), `gpu_mem=16`, Dienste aus
|
||
(multipathd, ModemManager, …; snapd nur mit `--snapd-aus`), earlyoom,
|
||
journald 50 MB, Container-Neustart täglich 3:30 (cron), Unterspannungs-Check.
|
||
**WLAN** (Befund 29.10.: kein Absturz, sondern WLAN verloren und nie
|
||
wieder verbunden, ASSOC-REJECT in Schleife): Energiesparen aus
|
||
(`wlan-energiesparen-aus.service`), `wlan-waechter.timer` jede Minute —
|
||
ohne Gateway ab 2 min neu verbinden, nach 10 min `systemctl reboot`.
|
||
- **Prüfen:** `Docker/pi-pruefen.sh` (Speicher, Neustarts, OOM, Strom).
|
||
- Offen (Schritt 4 des Plans): `/load-all` liefert weiter ALLE Sitzungen —
|
||
wächst wöchentlich auf Server und Browser.
|
||
|
||
## Docker
|
||
|
||
- `docker compose up --build` from the repo root. Compose maps `3003:3003`,
|
||
bind-mounts `./sessions:/app/sessions`, `restart: always` (startet beim Booten auch nach einem manuellen `docker compose stop` wieder; nur `down` entfernt ihn) and sets
|
||
**`TZ: Europe/Berlin`** — ohne das liefe der Container auf UTC, und die
|
||
Datumsvergleiche des Servers (`heuteIsoLokal()`) lägen zwischen Mitternacht
|
||
und 2 Uhr Ortszeit einen Tag zurück.
|
||
- Dockerfile: `node:22-alpine` (nötig für node:sqlite), `npm ci --production`,
|
||
runs as non-root `node`, HEALTHCHECK on `/health`. `.dockerignore` excludes
|
||
`Docker/*` (except `Docker/Dockerfile`), `node_modules/`, `.git`,
|
||
`npm-debug.log`.
|
||
|
||
## Behobene Fehler (Prüfung 09/2026)
|
||
|
||
Aus einer systematischen Durchsicht (Bericht mit 58 Funden) wurden folgende
|
||
Verhaltensweisen geändert — wer den alten Zustand im Kopf hat, findet hier die
|
||
Abweichungen:
|
||
|
||
- **`GET /backup` ist admin-only** (`requireRole('admin')`). Der Snapshot
|
||
enthält die komplette Datenbank samt `user_sessions.token` im Klartext; mit
|
||
einem fremden Token ließ sich jede Admin-Grenze umgehen. Die Schaltfläche
|
||
"DB Backup" steht deshalb jetzt im Admin-Bereich der Seitenleiste.
|
||
- **Die Sperre gesperrter Sitzungen wird serverseitig durchgesetzt**:
|
||
`/save-week` weist `locked: true → false` für Nicht-Admins mit 403 ab. Das
|
||
Sperren (false → true) bleibt jedem erlaubt, weil `applyAutoLock()` es beim
|
||
normalen Speichern mitschreibt.
|
||
- **Anmeldung gebremst**: 10 Fehlversuche je Konto und Absender-Adresse sperren
|
||
für 10 Minuten (429). Der Schlüssel enthält den Benutzernamen, damit nicht
|
||
ein Tippfehler alle hinter derselben Büro-Adresse aussperrt. Bei unbekanntem
|
||
Benutzernamen wird trotzdem einmal gehasht (sonst verriete die Antwortzeit
|
||
gültige Konten). Abgelaufene Sitzungen werden beim Start gelöscht.
|
||
- **`loadGlobalPeople()` liefert nur Konten mit gesetzter `person_id`.** Zwei
|
||
Konten ohne Kennung hätten sonst denselben leeren Primärschlüssel im
|
||
Teilnehmerkreis einer Sitzung belegt und jedes Speichern mit 500 abgebrochen;
|
||
`serializeWeek()` filtert doppelte Kennungen zusätzlich heraus.
|
||
- **`loadWeeks()` hängt Teilnehmer an, statt sie per `array_order` zu
|
||
indizieren** — kollidierende Ordnungszahlen hätten Personen still
|
||
überschrieben.
|
||
- **JSON-Dateien lassen sich hochladen**: Der globale `express.json()`-Parser
|
||
läuft nicht mehr über die in `RAW_BODY_ROUTES` gelisteten Routen (`/import`,
|
||
`POST /project-files`, `POST /project-protokolle/import` — die nehmen rohe
|
||
Binärdaten entgegen); das Limit für Sitzungsdaten liegt bei 8 MB.
|
||
- **Der Teilnehmerkreis einer laufenden Sitzung ist wieder pflegbar**:
|
||
`addPersonToCurrentWeek()`/`removePersonFromCurrentWeek()` in
|
||
`js/people/people.js`. Vorher entstand `week.people` nur bei `newWeek()`, ein
|
||
später angelegter Benutzer war in keiner laufenden Sitzung zuordenbar. Das
|
||
Entfernen verweigert sich, solange die Person noch einem Projekt der Sitzung
|
||
zugeordnet ist.
|
||
- **Speichern meldet sich**: `store.js` zählt Änderungen je Woche mit
|
||
(`weekChangeCounter`), damit eine Bearbeitung WÄHREND eines laufenden
|
||
Speichervorgangs ihre Dirty-Markierung behält; Fehler erscheinen als Band
|
||
unten rechts (`.sync-problem-bar`), werden nach 20 Sekunden erneut versucht,
|
||
und ein 401 öffnet wieder das Anmeldefenster. Nach einem fehlgeschlagenen
|
||
`load()` werden **keine** Beispieldaten mehr erzeugt (`seed()` lief früher
|
||
auch im Fehlerfall und überschrieb per `save()` die echte "KW 30 / 2026").
|
||
- **`sanitizeStatusHtml()`** packte unerlaubte Elemente mit einem falschen
|
||
Referenzknoten aus (`insertBefore(child.firstChild, node)`) und warf dabei
|
||
`NotFoundError` — ein eingefügter Link genügte, um das Board-Rendering
|
||
abzubrechen. Zusätzlich werden ausgepackte Kinder jetzt erneut geprüft
|
||
(sonst blieben `<li>`/`<tbody>` stehen) und `<script>`/`<style>` samt Inhalt
|
||
verworfen. `esc()` maskiert nun auch `'`, weil mehrere Inline-Handler den
|
||
Wert in einfach gequotete JS-Strings setzen.
|
||
- **Sitzungsbezeichnungen werden geprüft** (`validateWeekLabel()` in
|
||
`js/weeks.js`): keine Dubletten (der Server schlüsselt allein über das Label)
|
||
und Rückfrage bei fehlendem `KW …`-Muster (sonst sortiert die Sitzung hinter
|
||
alle echten und `applyAutoLock()` sperrt den Rest).
|
||
- **Auslastung**: Die Ampel bezieht sich auf die Kapazität (gelb ab 0,75, rot
|
||
ab 1,0) statt auf das Skalenmaximum `LOAD_BAR_MAX`; der Mittelwert teilt nur
|
||
durch die Sitzungen, in denen die Person im Teilnehmerkreis stand.
|
||
- **Projektbereich**: Auto-Schedule terminiert Meilensteine vor ihren
|
||
Nachfolgern, der Meilenstein-Dialog sperrt Zirkelbezüge, die Vorgängerliste
|
||
zeigt auch die einzige vorhandene Aufgabe (`>= 1` statt `> 1`), Enter im
|
||
Vorgänger-Suchfeld speichert nicht mehr, und das Verschieben einer
|
||
Abwesenheit legt erst neu an, wenn das Löschen bestätigt ist.
|
||
- **Modals liegen über der Navigation** (`.modal-bg` `z-index: 300`); gesperrte
|
||
Bedienelemente sind an `:disabled`-Regeln erkennbar.
|
||
|
||
### Nachtrag: die zunächst offen gebliebenen Punkte
|
||
|
||
- **Aufwands-Gewichtung ohne Eintrag** (`berechneAuslastung()`, `js/load/load.js`):
|
||
Fehlt am Projekt ein Matrixwert, gilt jetzt der Wert aus der firmenweiten
|
||
Vorlage `LP_AUFWAND_VORLAGE` für diese Phase und Rolle — vorher pauschal 1,0.
|
||
Damit zählen zwei gleich gelagerte Projekte gleich, unabhängig davon, ob die
|
||
Aufwandsmaske schon einmal gespeichert wurde (LPH 9 steht in der Vorlage
|
||
bewusst auf 0). Nur wenn die Vorlage die Phase gar nicht kennt, bleibt es bei
|
||
1,0. Das Eingabefeld zeigt den jeweiligen Vorgabewert als Platzhalter.
|
||
- **Abwesenheiten fremder Konten**: Eintragen bleibt für jedes angemeldete
|
||
Konto erlaubt (die Projektleitung pflegt Urlaube ihres Teams mit), aber
|
||
`user_absences.created_by` hält fest, wer den Eintrag angelegt hat.
|
||
`DELETE /users/:id/absences/:absenceId` erlaubt das Löschen nur der
|
||
betroffenen Person, dem Ersteller oder einem Administrator (sonst 403).
|
||
Die Spalte wird beim Start idempotent nachgerüstet; Altbestand hat
|
||
`created_by = 0` und ist damit nur von der betroffenen Person oder von
|
||
Administratoren löschbar.
|
||
- **Fokus beim Speichern per Fokuswechsel** (`js/board/board.js`): Ein
|
||
`mousedown`-Lauscher in der Capture-Phase merkt sich, in welches
|
||
Kommentarfeld geklickt wird; nach dem Neu-Rendern stellt
|
||
`restorePendingCommentFocus()` den Fokus dort wieder her (Cursor ans
|
||
Textende). Die Schaltflächen "Speichern" und der Verlaufs-Aufklapper
|
||
unterdrücken zusätzlich den Fokusverlust (`onmousedown`/`preventDefault`,
|
||
wie zuvor schon "Verwerfen" und "übernehmen").
|
||
- **Tote Reste entfernt**: `renderGantt()`/`fitGanttRange()` in `js/gantt.js`
|
||
(griffen auf Elemente zu, die es in keiner Seite mehr gibt), `statusBlur()`
|
||
und `delProj()` in `js/board/board.js` (seit dem Textfeld-Editor bzw. der
|
||
neuen Zeilendarstellung ohne Aufrufer), 16 CSS-Regelblöcke der früheren
|
||
TODO-Liste (damals in `project-plan.html`), die wirkungslose Klasse
|
||
`plan-main-content-full` und der gegenstandslose 900px-Umbruch.
|
||
**`buildGanttHtml()` bleibt in `js/gantt.js`** — `js/plan/plan-build-gantt.js`
|
||
umhüllt sie (`originalBuildGanttHtml`), sie ist also die Grundlage des
|
||
gesamten GANTT. Der Link "Zurück zur Übersicht" in `admin-users.html` nutzt
|
||
jetzt die neue Regel `a.secondary` statt Inline-Stilen.
|
||
- **`esc()` verträgt Nicht-Strings**: Leistungsphasen-Prozentwerte kommen je
|
||
nach Herkunft als Zahl an; `(100).replace` ist keine Funktion und riss das
|
||
Rendern des Wochenberichts mit. `esc()` wandelt jetzt über `String()` um
|
||
(nur `null`/`undefined` werden zu `''`).
|
||
|
||
## Behobene Fehler (Testlauf 10/2026, fünf Test-Agents)
|
||
|
||
- **Gesperrte Sitzungen sind für Nicht-Admins auch inhaltlich unveränderlich**:
|
||
`/save-week` antwortet bei einer am Server gesperrten Woche mit **409**
|
||
(`conflict:true`), nicht nur beim Entsperren mit 403 — der Browser lädt die
|
||
Woche dann neu. Die Auto-Sperre ist nicht betroffen (sie schreibt nur
|
||
Wochen, die am Server noch offen sind). Admins dürfen weiter.
|
||
- `/save-week` lehnt doppelte Projekt-`id` oder doppelte `projectUid` in einer
|
||
Woche mit 400 ab (vorher 500 bzw. ein Weg für Mitglieder, über eine Kopie
|
||
mit gleicher uid doch Projekte anzulegen).
|
||
- **Login-Sperre**: Schlüssel aus der normalisierten Kennung (`loginKennung()`:
|
||
ohne "@…", getrimmt, klein) und `req.socket.remoteAddress` —
|
||
`X-Forwarded-For` wird nicht mehr gelesen. Nicht-String-Passwörter → 400.
|
||
- `/import` prüft die Datei (`pruefeBackupDatei()`) **vor** dem Auto-Backup;
|
||
eine ungültige Datei gibt 400 und verdrängt keine echten Backups mehr.
|
||
- **Kiosk-Konten** stehen weder in `loadGlobalPeople()` noch im
|
||
Teilnehmerkreis (`kontenInAktuelleSitzung()` trägt sie aus, solange sie
|
||
keinem Projekt zugeordnet sind) und sind keine gültigen Dateiprüfer.
|
||
- Ein **deaktiviertes Konto** wird aus offenen Dateiprüfungen genommen
|
||
(`offenePruefungenAufraeumen()`), sofern es nicht der einzige Prüfer ist.
|
||
`/my-dashboard` zeigt nur Freigaben noch offener Dateien und wählt die
|
||
Woche ohne die Sandbox.
|
||
- **Person-ID-Wechsel** (`PATCH /admin/users/:id`) zieht die ID in der
|
||
aktuellen Sitzung und der Sandbox in allen Personen-Tabellen mit.
|
||
- **Protokoll-Termine** werden auf ISO normalisiert (`normalisiereTermin()`,
|
||
"15.10.2026" → "2026-10-15"); die Textmuster-Punkte tragen `status`.
|
||
Kaputte PDF/DOCX → 400. Gelöschte Protokolle entfernen ihre Quelldatei.
|
||
Die Protokoll-Liste zählt Info-Punkte separat (`infoCount`).
|
||
- Frontend: `[hidden]{display:none!important}` global (styles.css); der
|
||
TODO-Dialog zeigt Zuständige außerhalb des Projekts als gestrichelte Chips
|
||
statt sie beim Speichern zu verwerfen; `toggleTodoDone()` führt den
|
||
Fortschritt mit; `editLph()` nimmt nur 0–100; "Aufgabe aus Kommentar"
|
||
lehnt ein Fälligkeitsdatum vor heute ab; neue Protokoll-Punkte werden hinter
|
||
ihrer Gruppe eingefügt; "Terminplan aus Honorar" beginnt am nächsten
|
||
Arbeitstag; "Entsperren" im Wochenbericht nur für Admins.
|
||
|
||
## Conventions & verification
|
||
|
||
- Git repo; commits are German (e.g. "PDF Export von A4 auf A3 …"). Remote:
|
||
`http://192.168.3.187:3000/erik/projektuebersicht.git`.
|
||
- No linter, formatter, typecheck, or test suite. Manual verification only:
|
||
start server, open `http://localhost:3003`, exercises the relevant tab,
|
||
confirm SQLite changes (the DB file `sessions/projektuebersicht.db` grows). |