273 lines
17 KiB
Markdown
273 lines
17 KiB
Markdown
# AGENTS.md
|
||
|
||
## 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`.
|
||
|
||
Two HTML entrypoints, both served statically by Express:
|
||
- `Test.html` — the main app (tabs: Projektboard, Auslastung, Projekte,
|
||
Projektbeteiligte). Served at `/`.
|
||
- `project-plan.html` — per-project Gantt/TODO planning view, opened in a new
|
||
tab via `openProjectPlan()` in `js/gantt.js`. Loads the shared helper modules
|
||
(`globals`, `utils`, `ui`, `date-picker`, `server`, `gantt`) plus the
|
||
plan modules under `js/plan/` — NOT the full main module set.
|
||
|
||
All UI strings/comments are German. Preserve German in
|
||
user-facing text.
|
||
|
||
## File structure
|
||
|
||
```
|
||
.
|
||
├── server.js # Express server + SQLite API
|
||
├── package.json # deps: express, cors only
|
||
├── docker-compose.yml # at repo ROOT; build context ., dockerfile Docker/Dockerfile
|
||
├── Docker/Dockerfile # node:22-alpine (nötig für node:sqlite)
|
||
├── .dockerignore
|
||
├── .vscode/extensions.json
|
||
├── public/
|
||
│ ├── Test.html # main app (served at /)
|
||
│ ├── js/ # shared modules (classic scripts, global fns)
|
||
│ │ ├── globals.js # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS) + db/currentDepartment
|
||
│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName, peopleByRole, assignKey, weekSortKey
|
||
│ │ ├── ui.js # LocalStorage-UI-State, Header-Collapse, Theme
|
||
│ │ ├── date-picker.js # Datums-/Bereichspicker-Overlays
|
||
│ │ ├── server.js # baseUrl, sendWeekToServer, sendPeopleToServer, blobToDataUrl
|
||
│ │ ├── store.js # save/syncAllToServer, switchDepartment, load, seed
|
||
│ │ ├── weeks.js # Wochen-Auswahl, -Erzeugung, -Klonen, Auto-Lock, -Löschung
|
||
│ │ ├── gantt.js # GANTT-Diagramm (buildGanttHtml, openProjectPlan), geshared
|
||
│ │ ├── showmode.js # Show-Mode (Präsentationsmodus)
|
||
│ │ ├── tabs-auth.js # showTab, confirmPassword (SHA256-Passwortschutz)
|
||
│ │ ├── pdf.js # PDF-Export (pdfmake, A3 quer)
|
||
│ │ ├── csv.js # exportCSV (lädt ZIP von GET /export-csv)
|
||
│ │ ├── backup.js # downloadBackup (lädt SQLite-Snapshot von GET /backup)
|
||
│ │ ├── import.js # importDb (Backup-Wiederherstellung über POST /import, passwortgeschützt)
|
||
│ │ └── init.js # renderAll/initUiState + Seiteneinstieg (nur Test.html)
|
||
│ │ ├── board/ # tab-specific: Projektboard
|
||
│ │ │ └── board.js # Projektboard-Rendering, Status-Editor, Anwesenheit, Zuordnung
|
||
│ │ ├── people/ # tab-specific: Projektbeteiligte
|
||
│ │ │ └── people.js # Personen-Tab-Rendering + Drag-and-Drop + Abwesenheiten
|
||
│ │ ├── projects/ # tab-specific: Projekte
|
||
│ │ │ └── projects.js # Projekte-Tab-Rendering + Drag-and-Drop
|
||
│ │ ├── load/ # tab-specific: Auslastung
|
||
│ │ │ └── load.js # Auslastungs-Tab, LP-Modal, Neues-Projekt-Modal
|
||
│ ├── project-plan.html # Gantt/TODO planning view
|
||
│ ├── js/plan/ # planning modules (nur von project-plan.html geladen)
|
||
│ │ ├── plan-state.js # PlanApp-Zustand, Datenladepath, savePlan, Toasts
|
||
│ │ ├── 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
|
||
│ │ └── plan-schedule.js # Kritischer Pfad, Auto-Schedule, Mail-Link
|
||
│ ├── styles.css
|
||
│ └── dein_bild.ico
|
||
└── sessions/ # SQLite-DB; bind-mounted into Docker container
|
||
└── projektuebersicht.db # node:sqlite database (WAL mode)
|
||
```
|
||
|
||
## Running
|
||
|
||
```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.
|
||
|
||
## Server API (`server.js`)
|
||
|
||
- `GET /` → `Test.html`; `GET /health`.
|
||
- Deep links per department + tab: `GET /<Dept>/<Tab>` → `Test.html`, e.g.
|
||
`/HLS/Auslastung` or `/ELT/Projektübersicht`. Tab slugs (`TAB_SLUGS` in
|
||
`js/globals.js`) are `Projektübersicht|Auslastung|Projekte|Projektbeteiligte`;
|
||
anything else returns 404. Exception: `GET /<Dept>/Projektplan(/<ProjName>)?`
|
||
(`PLAN_SLUG`) serves `project-plan.html`, the GANTT view, 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.
|
||
- `GET /load-all?department=` → `{weeks:[weekObj], people:[personObj]}` with the
|
||
global people list in `people`; weeks are full JSON week objects (id, label,
|
||
date, locked, updatedAt, people, attendance, projects), sorted by label.
|
||
- `POST /save-week {department, week, baseUpdatedAt}` → upserts one week (JSON
|
||
object, keyed by department + label). 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 /save-people {department, people}` → replaces the department's global
|
||
people list (JSON array).
|
||
- `POST /delete-week {department, label}` → deletes the week; missing label
|
||
yields 400, missing week tolerates with `deleted:false`.
|
||
- `GET /export-csv?department=` → ZIP with one readable CSV per logical entity
|
||
(wochen, personen, sitzungsteilnehmer, projekte, zuordnungen,
|
||
leistungsphasen, aktuelle-leistungsphasen, aufwand-je-leistungsphase,
|
||
projektplaene, todos, meilensteine) for that department. CSV: UTF-8 with BOM,
|
||
CRLF, `;` separator by default (overridable via `?sep=`; German Excel opens
|
||
it directly). ZIP is built in pure Node (`zlib.deflateRawSync`, manual CRC32,
|
||
no external library). Used by `exportCSV()` in `js/csv.js`.
|
||
- `GET /backup` → 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` → 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`, which is password-gated (`requestedAction` type `'importDb'`
|
||
in `js/tabs-auth.js`) like week deletion.
|
||
- Valid departments hard-coded as `ELT|HLS|ARCH` in `server.js`
|
||
(`VALID_DEPARTMENTS`); anything else silently falls back to `ELT`. Same set in
|
||
`DEPARTMENTS` in `js/globals.js`.
|
||
- Static assets get `Cache-Control: no-store`.
|
||
- JSON body limit is 2 MB.
|
||
|
||
## Persistence (SQLite)
|
||
|
||
- Data lives in `sessions/projektuebersicht.db` (`node:sqlite` `DatabaseSync`,
|
||
WAL mode, `PRAGMA foreign_keys = ON`), created automatically. The schema is
|
||
**fully relational** (18 tables) 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` (`array_order` = position in the JSON array, `order_value` = the
|
||
`p.order` field, which is only unique *within a group*, not globally!)
|
||
- `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`
|
||
- `people` / `person_roles` / `person_absences` — global people per department
|
||
- The HTTP API stays JSON-shaped: `serializeWeek()` /
|
||
`serializeGlobalPeople()` split incoming week/people objects across the
|
||
tables inside a transaction, `loadWeeks()` / `loadGlobalPeople()`
|
||
(re)assemble them 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()`.
|
||
- Migration on every start (idempotent):
|
||
`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 relational schema and inserts everything in one
|
||
transaction. Afterwards `migrateWeeksUpdatedAt()` adds the `updated_at`
|
||
column to `weeks` (idempotent, `ALTER TABLE … ADD COLUMN`).
|
||
Note: legacy rows keep `updated_at = ''`, so the first client save with an
|
||
empty `baseUpdatedAt` matches and only then a real revision is written.
|
||
- 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`**. Two HTML files include
|
||
them via `<script src>` tags; load order in the HTML defines dependencies,
|
||
since there is no bundler:
|
||
- `Test.html` loads `globals → utils → ui → date-picker → server →
|
||
store → weeks` (shared modules), then the tab-specific folders `board →
|
||
people → projects → load`, finally `gantt → showmode → tabs-auth → pdf →
|
||
init`. See the exact list in `Test.html`.
|
||
- `project-plan.html` loads only `globals → utils → ui → date-picker →
|
||
server → gantt` plus the `js/plan/*` modules. It must NOT load modules that
|
||
touch main-only page elements (`store`, `weeks`, `board`, …, `init`).
|
||
- 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()`, plus the
|
||
people list `markPeopleDirty()` via `sendPeopleToServer()` if marked dirty.
|
||
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 and the
|
||
people list 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.
|
||
- Person drag-and-drop in the people tab: drag handle (`.drag-handle`) binds
|
||
`ondragstart`/`ondragend`, the `.people-sortable` container binds
|
||
`ondragover`/`ondrop`; insertion is computed by `getDragAfterElement(container,
|
||
y, selector)` (`js/projects/projects.js`, used with `.person-card` here and
|
||
`.person-list-item` in the projects tab). `reorderPeople()` updates both
|
||
`db.globalPeople` and the current week's person copy, then `save()`. No drag
|
||
handles are rendered on locked weeks.
|
||
- 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 `board`), `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; protected tabs keep the URL but open the password modal.
|
||
|
||
## Auth / security quirk
|
||
|
||
- The `projects` and `people` tabs, week deletion, and week unlock are gated by
|
||
a password modal comparing `CryptoJS.SHA256(input)` to
|
||
`PROTECTED_PASSWORD_HASH` (`js/globals.js`, modal in `js/tabs-auth.js`).
|
||
Client-side only — not real security. CryptoJS and pdfmake (PDF export) are
|
||
loaded from `cdnjs.cloudflare.com` in `Test.html`, so those features need
|
||
internet access and won't work offline. `project-plan.html` loads neither.
|
||
- User-provided status HTML is sanitized on render via a small whitelist
|
||
(`sanitizeStatusHtml` in `js/board/board.js`) to mitigate XSS.
|
||
|
||
## Docker
|
||
|
||
- `docker compose up --build` from the repo root. Compose maps `3003:3003`,
|
||
bind-mounts `./sessions:/app/sessions`, `restart: unless-stopped`.
|
||
- 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`.
|
||
|
||
## 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). |