From 93505e0e48b670253ba076e889bf9bd0e6242736 Mon Sep 17 00:00:00 2001 From: Erik Dunin Date: Sat, 17 Oct 2026 15:14:26 +0200 Subject: [PATCH] Beteiligte in die Projekteinstellungen, TODOs aus Protokoll, Bewegung Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und nur noch in den Projekteinstellungen editierbar (planCycleAssign()). Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen. Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst unabhaengig von den gespeicherten Protokollen - nichts davon wird abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter Import leicht 20 Vorschlaege liefert. Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt (Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog. Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe. plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit Zeilenzahl und Umsortierung nicht auseinanderlaufen. KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt (Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem alten Limit und brach bei jeder Schwankung ab. Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root, angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die Akzentleiste ist ein inset-Schatten statt eines positionierten Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens. Plan Checker: optionaler Projektabgleich des Plankopfs. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 271 +++++++++++-- public/App.html | 53 ++- public/js/board/board.js | 151 ++++---- public/js/gantt.js | 13 + public/js/plan/plan-build-gantt.js | 11 +- public/js/plan/plan-drag.js | 16 +- public/js/plan/plan-info.js | 60 ++- public/js/plan/plan-modals.js | 16 +- public/js/plan/plan-protokoll-todos.js | 464 +++++++++++++++++++++++ public/js/plan/plan-settings.js | 59 ++- public/js/plan/plan-state.js | 4 +- public/js/planchecker/planchecker.js | 58 ++- public/js/projekt-wizard.js | 2 +- public/js/wochenbericht/wochenbericht.js | 173 ++++++--- public/plan-styles.css | 106 ++++++ public/styles.css | 319 ++++++++++++---- server.js | 441 +++++++++++++++++++-- 17 files changed, 1883 insertions(+), 334 deletions(-) create mode 100644 public/js/plan/plan-protokoll-todos.js diff --git a/CLAUDE.md b/CLAUDE.md index d1fa96a..d58627f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,14 +172,20 @@ user-facing text. │ │ ├── plan-state.js # PlanApp-Zustand, Datenladepath, savePlan, Toasts │ │ ├── plan-sections.js # Bereichs-Navigation (showPlanSection, URL-Fragment) │ │ │ # + Dashboard-Karten (renderPlanDashboard) -│ │ ├── plan-info.js # Status/Kommentar, Gruppe, amtierende LPH, LPH-%, -│ │ │ # Beteiligten-Matrix (verteilt auf Dashboard+Einstellungen) +│ │ ├── plan-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 (renderPlanSettings) +│ │ │ # beauftragte Leistungsphasen, Beteiligten-Matrix +│ │ │ # (renderPlanSettings, planCycleAssign) │ │ ├── plan-tasks.js # Bereich "Aufgaben": Aufgaben-/Meilensteinliste │ │ │ # (renderPlanTasks; öffnet dieselben Dialoge wie das GANTT) │ │ ├── plan-history.js # Kommentar-Verlauf über alle Sitzungen (+ Vorwochen-Helfer) │ │ ├── plan-templates.js # Aufgaben aus LPH-Vorlagen (lph_templates) +│ │ ├── plan-protokoll-todos.js # "+ TODOs aus Protokoll": externes PDF +│ │ │ # per KI einlesen + Aufgaben daraus ableiten — +│ │ │ # unabhängig von plan-protokoll.js, nichts +│ │ │ # davon wird gespeichert │ │ ├── plan-honorar.js # Terminplan aus Honorar + beauftragten LPH errechnen │ │ ├── plan-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen) │ │ ├── plan-gantt.js # renderPlanGantt, Abhängigkeitslinien, Zoom, Ressourcen-Load @@ -809,8 +815,8 @@ on the JSON endpoints. The `js/plan/*` modules of the Projektbereich sit right after `gantt.js` (`plan-state → plan-sections → plan-info → plan-history → plan-build-gantt → plan-gantt → plan-drag → plan-modals → plan-tasks → - plan-files → plan-templates → plan-honorar → plan-settings → - plan-schedule`): they must come after `gantt.js`, whose `buildGanttHtml()` + plan-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 @@ -1006,34 +1012,60 @@ authoritative, and two people could change the same field in different places. The split follows what kind of data it is: - **Laufende Erfassung → Wochenbericht only.** The session comment - (`p.status`), the progress per Leistungsphase (`p.lph`) and the assignment of - people (`p.assign`) are produced in the weekly meeting. They are editable - **only** in the Wochenbericht. + (`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 and the Aufwands-Gewichtung live in the - Projektbereich (`einstellungen` section, `js/plan/plan-settings.js` plus - `js/plan/plan-info.js` for Gruppe/amtierende LPH). + 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, no clickable avatars, and a - `.drawer-verweis` underneath with the button "Im Wochenbericht bearbeiten →". - The Projektbereich does the same via `setzeBerichtVerweis()` - (`js/plan/plan-info.js`), filling `#planStatusVerweis`, `#planLphVerweis` - and `#planAssignVerweis`. + 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.) + 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()`, - `cycleAssign()` and `saveBoardComment()` in `js/board/board.js` are - untouched — the Wochenbericht's editing hangs off them. + 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 @@ -1230,28 +1262,129 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`: very same `openTodoModal()`/`openMilestoneModal()` as the Gantt, and the indices match because the list keeps `plan.todos`/`plan.milestones` order. The create buttons (`+ Aufgabe`, `+ Meilenstein`, `+ Vorlage`, - `+ Terminplan`) exist twice — in the Gantt toolbar and above the list, the - latter with an `…BtnList` id suffix; `renderPlanPage()` hides both sets on - locked weeks. - **A todo is only a Gantt row when it has BOTH a start and an end date.** + `+ 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 `` 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`; 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. + 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` (`scheduledIds`) — a title-only todo has - no drag handle in the Gantt to begin with and keeps its exact position in - `plan.todos` untouched by any Gantt row drag. + 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 @@ -1273,9 +1406,12 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`: "### Protokolle" below for the data model and the "Fortschreiben" mechanism. - **Einstellungen** — project number/name (display only — the page is - addressed by them), Gruppe, amtierende Leistungsphase, Beteiligten-Matrix - (all still rendered by `plan-info.js`), plus Honorar and the **beauftragte - Leistungsphasen** (`renderPlanSettings()`, `js/plan/plan-settings.js`). + 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 @@ -3235,6 +3371,65 @@ across Gewerke or users. Erik's own words: "macht so keinen Sinn mehr." no special-casing needed. A redirect `GET /Test.html`-style old link isn't needed here since nothing ever pointed at this tab before it existed. +## Bewegung (Hover/Klick-Rückmeldung) + +The app's interaction feedback is **token-driven and lives in one place**: +five variables in `:root` (`styles.css`), applied by a clearly-marked +"BEWEGUNG" block at the **end** of `styles.css` plus a smaller companion +block at the end of `plan-styles.css` for the Projektbereich-only row types. +Every rule reads through `var()`; changing the feel is a change to those +five values, not to the components. + +``` +--dur: 150ms kurze Rückmeldung (Fläche, Farbe, Anheben, Druck) +--dur-lg: 200ms grössere Zustandswechsel (Dialog, Aufklappen) +--ease-out cubic-bezier(.2,.8,.2,1), schnell los / weich aus +--row-lift: -1px Hub einer anklickbaren Zeile beim Zeigen +--press: .97 Stauchung beim Drücken +``` + +Erik picked this level ("Spürbar") from a three-way live comparison +(dezent / spürbar / lebendig) built with the Taste-Skill. **Level 3's +entrance animations were deliberately NOT taken**: with ~30 projects in a +list that re-renders on every save, rows animating in on each render is +unrest, not polish. The rule that follows from that: the surface moves +**only in response to pointing or clicking**, never on its own, and never +in a loop. + +- **Row lift + accent rail** on clickable rows (`.board-row-main`, + `.bericht-rail-item`, `.planung-row`, `.dash-rand-zeile`, and in + `plan-styles.css` `.todo-depends-item`, `.pt-row-main`, `.plan-task-row`). + The rail is an **`inset` box-shadow growing 0 → 3px**, deliberately NOT a + `::before` with `position:absolute`: `.board-row` is `draggable` + (`js/projects/projects.js`), and an extra positioned element or a new + stacking context on a dragged row can shift the drag image and hit + targets. An inset shadow touches neither layout nor stacking. For the + same reason the transform sits on the **inner** `.board-row-main`, never + on the draggable `.board-row` itself (verified: the draggable element + keeps `transform: none`). +- **`.protokoll-liste-zeile` is the one exception** and must stay one: it + already carries `border-left: 3px solid transparent`, which its + `.ist-aktiv` state colours. It therefore animates *that existing border* + instead of adding the inset shadow. Using both would paint 6 px of accent + instead of 3. +- **Dialogs** use `animation`, not `transition`: `.modal-bg` is toggled + `display:none` ⇄ `flex` from JS, and a `display` change is not + transitionable, whereas an animation restarts every time the element is + rendered again (`modal-bg-auf` / `modal-auf`). +- **`prefers-reduced-motion` was missing from this codebase entirely** and + is now handled at the end of `styles.css`. Because the level is + token-driven, the block simply flips the tokens (`--row-lift: 0px`, + `--press: 1`, durations to 1ms); a blanket + `transition-duration/animation-duration: 1ms !important` additionally + catches the ~60 older rules that still hard-code their own timings. +- **Keyboard focus** got explicit `:focus-visible` outlines on the same + rows/chips. The lift and press effects answer mouse and touch only; + without this the rework would have left keyboard use worse off than + before. +- **The Wandtafel (`body.wandtafel-aktiv`) is deliberately excluded** — a + kiosk touch monitor has no hover, and that screen carries its own + `--dash-*` design anyway. + ## Docker - `docker compose up --build` from the repo root. Compose maps `3003:3003`, diff --git a/public/App.html b/public/App.html index 797a42a..824e50c 100644 --- a/public/App.html +++ b/public/App.html @@ -323,6 +323,14 @@ +
+ + +

Prüft zusätzlich, ob der Plankopf zu diesem Projekt passt (Nummer/Bezeichnung).

+
+
@@ -879,6 +887,7 @@ + + + +
+ ${kandidaten.length ? kandidaten.map(k => { + const kuerzel = k.person.name || initialsForName(k.person.fullName || '') || '?'; + const voll = k.person.fullName || k.person.name || ''; + return ``; + }).join('') : 'Niemand diesem Projekt zugeordnet.'} +
+ ${!vorausgewaehlt.length && v.zustaendigVorschlag ? `KI-Vorschlag „${esc(v.zustaendigVorschlag)}“ — kein passendes Teammitglied gefunden.` : ''} + ${quelle ? `aus: ${esc(quelle)}` : ''} +
+ + `; + }).join(''); + + return ` +
+ + + · + + +
+
${zeilen}
`; + } + + // protokollTodosZurueck: ein Schritt zurück, ohne erneuten Serveraufruf — + // alle Daten bleiben im Modul-Zustand. Keine Parameter, keine Rückgabe. + window.protokollTodosZurueck = function(){ + if(step === 0) return; + step--; + renderStep(); + }; + + // protokollTodosWeiter: der rechte Fußzeilen-Knopf führt Schritt UND + // Netzwerkaufruf in einem aus — "Einlesen"/"Aufgaben ableiten" lösen den + // jeweils nächsten KI-Schritt aus, der letzte Klick übernimmt die + // Aufgaben. Keine Parameter, keine Rückgabe. + window.protokollTodosWeiter = function(){ + if(step === 0) return schrittEinlesen(); + if(step === 1) return schrittAbleiten(); + return schrittUebernehmen(); + }; + + function schrittEinlesen(){ + verbergeFehler(); + const fileInput = document.getElementById('protokollTodosFile'); + const file = fileInput && fileInput.files && fileInput.files[0]; + if(!file) return zeigeFehler('Bitte eine PDF-Datei auswählen.'); + gewaehlterAnbieter = (document.getElementById('protokollTodosAnbieter') || {}).value || ''; + + setzeLadezustand(true, 'Wird eingelesen … bei KI-Erkennung über einen eigenen Server kann dies bis zu 30 Minuten dauern.'); + fetch(baseUrl() + '/plan-todos-aus-protokoll/einlesen?anbieter=' + encodeURIComponent(gewaehlterAnbieter), { + method: 'POST', + headers: { 'Content-Type': file.type || 'application/pdf' }, + body: file + }) + .then(r => r.json().then(d => ({ ok: r.ok, d }))) + .then(({ ok, d }) => { + if(!ok || !d.ok) throw new Error((d && d.error) || 'Einlesen fehlgeschlagen.'); + erkanntePunkte = Array.isArray(d.punkte) ? d.punkte : []; + punkteAuswahl = {}; + if(erkanntePunkte.length === 0){ setzeLadezustand(false); zeigeFehler('Es wurden keine Punkte erkannt.'); return; } + step = 1; + setzeLadezustand(false); + if(d.erkennungsart !== 'chatgpt' && d.erkennungsart !== 'claude' && d.erkennungshinweis){ + zeigeFallbackHinweis('KI-Erkennung nicht verfügbar (' + d.erkennungshinweis + ') — Punkte per Textmuster erkannt.'); + } + }) + .catch(err => { + console.error('TODOs aus Protokoll (Einlesen) fehlgeschlagen:', err); + setzeLadezustand(false); + zeigeFehler(err.message || String(err)); + }); + } + + function schrittAbleiten(){ + verbergeFehler(); + const listEl = document.getElementById('protokollTodosPunkteListe'); + const checkedIdx = listEl + ? Array.from(listEl.querySelectorAll('input[type="checkbox"]:checked')).map(cb => parseInt(cb.value, 10)) + : []; + if(checkedIdx.length === 0) return zeigeFehler('Bitte mindestens einen Punkt auswählen.'); + + ausgewaehltePunkte = checkedIdx.map(i => erkanntePunkte[i]); + setzeLadezustand(true, 'Aufgaben werden abgeleitet …'); + fetch(baseUrl() + '/plan-todos-aus-protokoll/aufgaben-vorschlag', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ anbieter: gewaehlterAnbieter, punkte: ausgewaehltePunkte }) + }) + .then(r => r.json().then(d => ({ ok: r.ok, d }))) + .then(({ ok, d }) => { + if(!ok || !d.ok) throw new Error((d && d.error) || 'Ableiten fehlgeschlagen.'); + vorschlaege = Array.isArray(d.vorschlaege) ? d.vorschlaege : []; + step = 2; + setzeLadezustand(false); + if(d.erkennungsart === 'direkt' && d.erkennungshinweis){ + zeigeFallbackHinweis('KI-Ableitung nicht verfügbar (' + d.erkennungshinweis + ') — Punkte direkt übernommen, ohne Zusammenfassung.'); + } + }) + .catch(err => { + console.error('TODOs aus Protokoll (Ableiten) fehlgeschlagen:', err); + setzeLadezustand(false); + zeigeFehler(err.message || String(err)); + }); + } + + // schrittUebernehmen: legt für jede angehakte Vorschlagszeile einen + // normalen plan_todo-Eintrag an — IMMER mit gesetztem Start UND Ende, + // sonst bleibt die Aufgabe ein reiner Checklisten-Eintrag ohne GANTT-Zeile + // (CLAUDE.md: "Ein TODO ist nur dann eine GANTT-Zeile, wenn es BEIDE Daten + // hat"). Gleiche Form wie applyTemplate() (plan-templates.js) und + // saveTodoAusKommentar() (wochenbericht.js). Kein Netzwerkaufruf hier + // selbst — app.savePlan() übernimmt das Speichern wie überall sonst im + // Projektbereich. + function schrittUebernehmen(){ + if(app.isLocked()) return; + const rows = document.querySelectorAll('#protokollTodosVorschlaege .protokoll-todo-vorschlag'); + const plan = app.ensurePlanLocal(); + const heute = formatDateISO(new Date()); + let anzahl = 0; + rows.forEach(row => { + const check = row.querySelector('.pt-vorschlag-check'); + if(!check || !check.checked) return; + const titel = ((row.querySelector('.pt-vorschlag-titel') || {}).value || '').trim(); + if(!titel) return; + const faelligkeit = (row.querySelector('.pt-vorschlag-termin') || {}).value || ''; + const personen = Array.from(row.querySelectorAll('.pt-vorschlag-person[aria-pressed="true"]')).map(el => el.dataset.personId); + plan.todos.push({ + id: uid(), + title: titel, + start: heute, + end: faelligkeit || heute, + done: false, + progress: 0, + dependsOn: [], + assignees: personen, + }); + anzahl++; + }); + + closeProtokollTodosModal(); + if(anzahl === 0) return; + app.savePlan(); + // Wie applyTemplate(): der sichtbare GANTT-Zeitraum muss die neuen + // Balken einschließen, sonst erscheinen sie zwar in der Aufgabenliste, + // aber außerhalb des aktuellen Zooms unsichtbar im Terminplan. + fitPlanGanttRange(); + app.showToast(anzahl + ' Aufgabe(n) aus Protokoll angelegt.'); + } +})(); diff --git a/public/js/plan/plan-settings.js b/public/js/plan/plan-settings.js index 398b459..a10922e 100644 --- a/public/js/plan/plan-settings.js +++ b/public/js/plan/plan-settings.js @@ -13,7 +13,7 @@ // gespeichert von renderPlanInfo()/setPlanGroup()/setPlanActiveLp() in // js/plan/plan-info.js — dieses Modul rührt sie nicht an) // * Honorar (netto) und die BEAUFTRAGTEN LEISTUNGSPHASEN -// * Beteiligte (Rollen-/Personen-Matrix, ebenfalls aus plan-info.js) +// * Beteiligte (Rollen-/Personen-Matrix, #planAssignMatrix) // // Honorar und beauftragte Leistungsphasen standen früher ausschließlich im // Dialog "Terminplan aus Honorar" (js/plan/plan-honorar.js). Sie sind @@ -24,9 +24,21 @@ // aus plan-honorar.js und rendern einander nach jeder Änderung neu, sodass // sie nicht auseinanderlaufen können. // +// Die Beteiligten-Matrix war früher ein reines Anzeigefeld, gefüllt von +// plan-info.js, mit einem Verweis in den Wochenbericht — dort entstand die +// Zuordnung. Auf Eriks ausdrücklichen Wunsch ("In den Einstellungen der +// Projekte will ich auch die Projektbeteiligten einstellen können") ist das +// umgekehrt: Zuordnung ist jetzt ein Stammdatum wie Honorar, und zwar NUR +// HIER editierbar — der Wochenbericht zeigt sie seitdem nur noch zum Lesen +// (board.js, renderBoardDrawer()) mit einem Verweis hierher. planCycleAssign() +// unten ersetzt für diesen Zweck cycleAssign() (js/board/board.js): jene +// Funktion sucht das Projekt über currentWeek() und fände es bei einem +// Tiefenlink auf eine andere Sitzung oder einem Planung-Sandbox-Plan dort +// nicht — dieselbe Falle, vor der planOpenAufwand() unten warnt. +// // Gespeichert wird über den normalen Weg app.savePlan() (die Felder sind Teil -// des Wochen-JSON, Tabellen projects.honorar bzw. project_beauftragte_lph) — -// es gibt dafür keinen eigenen Endpunkt. +// des Wochen-JSON, Tabellen projects.honorar/project_assign bzw. +// project_beauftragte_lph) — es gibt dafür keinen eigenen Endpunkt. // // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // (plan-state.js). @@ -151,6 +163,47 @@ ${esc(l)} `).join(''); } + + // Beteiligte — Stammdatum, hier editierbar (siehe Kopfkommentar). Nicht + // zugeordnete Personen erscheinen blass, damit erkennbar bleibt, wer zur + // Auswahl stünde; ein Klick schaltet den Zustand durch (planCycleAssign()). + const assignMatrix = document.getElementById('planAssignMatrix'); + if(assignMatrix){ + const grouped = peopleByRole(app.week); + if(!p.assign) p.assign = {}; + assignMatrix.innerHTML = ROLES.map(r => { + const entries = grouped[r] || []; + const chips = entries.length ? entries.map(entry => { + const state = p.assign[assignKey(entry.person.id, r)] || 0; + const click = locked ? '' : `onclick="planCycleAssign('${entry.person.id}','${r}')"`; + const title = (entry.person.fullName || entry.person.name) + ' · ' + r; + return `${esc(entry.person.name)}`; + }).join('') : `Niemand hinterlegt`; + return `
${esc(r)}
${chips}
`; + }).join(''); + } + }; + + // planCycleAssign: wechselt die Zuordnung einer Person zu DIESEM Projekt + // zyklisch durch 0=keine → 1=Haupt → 2=optional → 0 (dieselbe Zustandsfolge + // wie cycleAssign() in js/board/board.js, hier aber auf app.project statt + // currentWeek() — siehe Kopfkommentar). Parameter: personId, role. Keine + // Rückgabe. + window.planCycleAssign = function(personId, role){ + const p = app.project; + if(!p || app.isLocked()) return; + if(!p.assign) p.assign = {}; + const key = assignKey(personId, role); + const cur = p.assign[key] || 0; + const next = (cur + 1) % 3; + if(next === 0) delete p.assign[key]; else p.assign[key] = next; + app.savePlan(); + renderPlanSettings(); + // Dieselbe Zuordnung erscheint im Projektboard und im Wochenbericht + // (dort jetzt nur noch zum Lesen) — beide sollen sofort nachziehen, + // nicht erst beim nächsten vollständigen Neuladen. + if(typeof renderBoard === 'function') renderBoard(); + if(typeof renderWochenbericht === 'function') renderWochenbericht(); }; // setPlanProjNr: ändert die Projektnummer. diff --git a/public/js/plan/plan-state.js b/public/js/plan/plan-state.js index a9e4c09..a2473a6 100644 --- a/public/js/plan/plan-state.js +++ b/public/js/plan/plan-state.js @@ -249,8 +249,8 @@ window.PlanApp = { // Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche: // Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html. const locked = isLocked(); - ['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'autoScheduleBtn', - 'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'autoScheduleBtnList'] + ['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn', 'autoScheduleBtn', + 'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList'] .forEach(id => { const btn = document.getElementById(id); if(btn) btn.style.display = locked ? 'none' : ''; diff --git a/public/js/planchecker/planchecker.js b/public/js/planchecker/planchecker.js index e392960..f4e9fc4 100644 --- a/public/js/planchecker/planchecker.js +++ b/public/js/planchecker/planchecker.js @@ -7,7 +7,11 @@ // admin-gepflegte Liste von Merkmalen prüfen (z. B. "Maßketten vorhanden", // siehe Admin → Plan-Checker-Merkmale, js/admin/admin.js). Zusätzlich lässt // sich je Prüfung EIN weiteres Merkmal ergänzen, das nur für diese eine -// Prüfung gilt und nicht gespeichert wird. +// Prüfung gilt und nicht gespeichert wird - und optional ein Projekt der +// aktuellen Sitzung wählen: Dann kommt serverseitig automatisch ein +// weiteres, ebenso nicht gespeichertes Merkmal dazu, das den Plankopf gegen +// Projektnummer/Bezeichnung dieses Projekts abgleicht (ladePlanCheckerProjekte()/ +// planCheckerPruefen() unten, server.js POST /plan-checker-pruefen). // // Darstellung "Arbeitsfläche" (Eriks Wahl "B" aus dem Design-Vergleich): // zwei Spalten dauerhaft nebeneinander — links Upload/Konfiguration, rechts @@ -41,13 +45,42 @@ let planCheckerDatei = null; let planCheckerMerkmale = []; // renderPlanChecker: Einstiegspunkt, von showTab('planchecker') gerufen. -// Lädt die Merkmale-Grundliste und die KI-Anbieter-Auswahl neu — beide -// können sich seit dem letzten Besuch geändert haben (Admin-Pflege bzw. -// eigener Schlüssel unter Einstellungen → Mein Konto). Keine Parameter, -// keine Rückgabe. +// Lädt die Merkmale-Grundliste, die KI-Anbieter-Auswahl und die Projektliste +// neu — alle drei können sich seit dem letzten Besuch geändert haben +// (Admin-Pflege, eigener Schlüssel unter Einstellungen → Mein Konto, bzw. +// ein inzwischen angelegtes/umbenanntes Projekt). Keine Parameter, keine +// Rückgabe. function renderPlanChecker(){ ladePlanCheckerMerkmale(); ladePlanCheckerAnbieter(); + ladePlanCheckerProjekte(); +} + +// ladePlanCheckerProjekte: füllt die optionale Projekt-Auswahl aus der +// bereits geladenen aktuellen Sitzung (currentWeek().projects, js/weeks.js) +// — kein eigener Server-Aufruf nötig, die Liste liegt dem Client ohnehin +// schon vor. Gruppiert wie die Projektliste selbst (GROUP_ORDER/sortGroups(), +// js/globals.js bzw. js/utils.js). Der Options-Wert ist die Projekt-ID +// (stabil innerhalb der Sitzung) statt Nummer/Name direkt zu kodieren — bei +// der Prüfung wird das Projekt darüber erneut in currentWeek().projects +// nachgeschlagen (planCheckerPruefen() unten). Keine Parameter, keine +// Rückgabe. +function ladePlanCheckerProjekte(){ + const sel = document.getElementById('planCheckerProjekt'); + if(!sel) return; + const w = typeof currentWeek === 'function' ? currentWeek() : null; + const projekte = (w && Array.isArray(w.projects)) ? w.projects : []; + const bisher = sel.value; + const gruppenNamen = sortGroups([...new Set(projekte.map(p => p.group || 'Ohne Gruppe'))]); + sel.innerHTML = '' + gruppenNamen.map(g => { + const opts = projekte.filter(p => (p.group || 'Ohne Gruppe') === g) + .slice().sort((a, b) => a.name.localeCompare(b.name)) + .map(p => ``).join(''); + return opts ? `${opts}` : ''; + }).join(''); + // Auswahl erhalten, falls sie nach dem Neuladen noch existiert (z. B. nach + // erneutem Tab-Wechsel innerhalb derselben Sitzung). + if(bisher && [...sel.options].some(o => o.value === bisher)) sel.value = bisher; } // ladePlanCheckerMerkmale: holt die admin-gepflegte Grundliste (GET @@ -143,7 +176,7 @@ function planCheckerDrop(e){ function planCheckerLadezustand(aktiv){ const btn = document.getElementById('planCheckerPruefenBtn'); if(btn){ btn.disabled = aktiv; btn.textContent = aktiv ? 'Wird geprüft…' : 'Plan prüfen'; } - ['planCheckerFile', 'planCheckerZusatz', 'planCheckerAnbieter'].forEach(id => { + ['planCheckerFile', 'planCheckerZusatz', 'planCheckerAnbieter', 'planCheckerProjekt'].forEach(id => { const el = document.getElementById(id); if(el) el.disabled = aktiv; }); @@ -164,9 +197,20 @@ function planCheckerPruefen(){ const zusatzEl = document.getElementById('planCheckerZusatz'); const zusatz = zusatzEl ? zusatzEl.value.trim() : ''; + // Projekt-Abgleich: die Auswahl trägt nur die Projekt-ID (s. + // ladePlanCheckerProjekte() oben) - Nummer/Bezeichnung werden hier erneut + // aus currentWeek().projects nachgeschlagen, statt sie schon beim Aufbau + // der Auswahl im value zu kodieren. + const projektEl = document.getElementById('planCheckerProjekt'); + const projektId = projektEl ? projektEl.value : ''; + const w = typeof currentWeek === 'function' ? currentWeek() : null; + const projekt = projektId && w ? (w.projects || []).find(p => p.id === projektId) : null; + const url = baseUrl() + '/plan-checker-pruefen' + '?provider=' + encodeURIComponent(provider) - + '&zusatzMerkmal=' + encodeURIComponent(zusatz); + + '&zusatzMerkmal=' + encodeURIComponent(zusatz) + + '&projektNr=' + encodeURIComponent(projekt ? (projekt.nr || '') : '') + + '&projektName=' + encodeURIComponent(projekt ? projekt.name : ''); const datei = planCheckerDatei; planCheckerLadezustand(true); diff --git a/public/js/projekt-wizard.js b/public/js/projekt-wizard.js index 42c6096..e3ed187 100644 --- a/public/js/projekt-wizard.js +++ b/public/js/projekt-wizard.js @@ -726,7 +726,7 @@ function wizardAnlegen(){ }; // Zuordnungen: Zustand 1 = Hauptverantwortung (dieselbe Bedeutung wie in - // cycleAssign(), js/board/board.js). + // planCycleAssign(), js/plan/plan-settings.js). ROLES.forEach(r => { wizard.team[r].forEach(id => { pr.assign[assignKey(id, r)] = 1; }); }); diff --git a/public/js/wochenbericht/wochenbericht.js b/public/js/wochenbericht/wochenbericht.js index e126b2f..2c149de 100644 --- a/public/js/wochenbericht/wochenbericht.js +++ b/public/js/wochenbericht/wochenbericht.js @@ -16,12 +16,14 @@ // Der Vergleich zur Vorwoche läuft über project_uid (siehe // migrateAddProjectUid() in server.js) — die dauerhafte, wochenunabhängige // Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu -// vergeben) nicht bieten konnte. Die Bearbeitung der Projekte selbst -// (Kommentar-Editor, LPH, Beteiligte) läuft vollständig über die bestehenden -// Funktionen aus js/board/board.js (editLph/cycleAssign/ -// renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses Modul -// ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die -// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten). +// vergeben) nicht bieten konnte. Die Bearbeitung von Kommentar und LPH läuft +// vollständig über die bestehenden Funktionen aus js/board/board.js +// (editLph/renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses +// Modul ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die +// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten). Die +// Beteiligten-Zuordnung zeigt dieser Tab seit ihrer Verlagerung in die +// Projekteinstellungen nur noch zum Lesen, mit Verweis dorthin +// (springZuEinstellungen(), js/gantt.js). // Die Anwesenheit lag früher im Projektboard; sie gehört hierher, weil in // diesem Tab die Sitzung abgearbeitet wird. // @@ -154,7 +156,10 @@ function absenceNoteFor(personId, weekDate){ } // renderAttendance: baut den Anwesenheitsblock der aktuellen Sitzung. -// Aufgerufen aus renderBoard(). Keine Parameter, keine Rückgabe. +// Aufgerufen innerhalb dieser Datei (toggleAttendance(), setAllAttendance() +// sowie innerhalb/rund um renderWochenbericht()) — NICHT aus renderBoard(), +// die Anwesenheit ist allein eine Wochenbericht-Angelegenheit. Keine +// Parameter, keine Rückgabe. function renderAttendance(){ const container = document.getElementById('weekAttendance'); if(!container) return; @@ -246,9 +251,9 @@ function setAllAttendance(present){ // renderWochenbericht: baut den kompletten Wochenbericht-Tab für die eine // offene Sitzung auf (currentWeek(), js/weeks.js) — Hinweis auf die Vorwoche, // dann die drei Gruppen. Wird nach jedem Laden sowie nach jeder Änderung -// über editLph()/cycleAssign() (js/board/board.js) neu aufgerufen, damit ein -// Projekt sofort in die passende Gruppe wandert. Keine Parameter, keine -// Rückgabe. +// über editLph() (js/board/board.js) bzw. planCycleAssign() +// (js/plan/plan-settings.js) neu aufgerufen, damit ein Projekt sofort in die +// passende Gruppe wandert. Keine Parameter, keine Rückgabe. // ---------- Tagesordnung: Zustand ---------- // berichtAuswahl: project_uid des Projekts, das gerade rechts im Detail @@ -347,6 +352,8 @@ function aktuellesBerichtProjekt(w){ // Parameter: uid (project_uid). Keine Rückgabe. function waehleBerichtProjekt(uid){ berichtAuswahl = uid; + vorwocheRadIndex = 0; // neues Projekt beginnt wieder bei der jüngsten Vorwoche + vorwocheRadScrollAkku = 0; // kein halb aufgelaufenes Scrollen vom vorherigen Projekt übernehmen renderWochenbericht(); const detail = document.getElementById('berichtDetail'); if(detail) detail.scrollIntoView({ block: 'nearest' }); @@ -389,7 +396,13 @@ function besprochenUndWeiter(pid){ function setzeAlleBesprochen(wert){ if(guardWeekLocked()) return; const w = currentWeek(); - (w.projects || []).forEach(p => { p.besprochen = !!wert; }); + // berichtProjekte(w) statt w.projects direkt: Der Knopf wirkt auf die + // gerade sichtbare, nach wochenberichtGewerk gefilterte Liste - sonst + // markierte "Alle abhaken" bei gewähltem Gewerk auch die Projekte der + // ANDEREN Gewerke als besprochen, die in dieser Besprechung nie zu sehen + // waren (widerspricht "Wochenbericht — Gewerke-Trennung": Vollständigkeit + // gilt innerhalb des gewählten Gewerks, nicht über alle hinweg). + berichtProjekte(w).forEach(p => { p.besprochen = !!wert; }); save(); renderWochenbericht(); } @@ -576,13 +589,17 @@ function renderBerichtKopf(w, liste, prevWeek, locked){ `; } -// renderBerichtListe: die Tagesordnung als schmale Nummern-Leiste (Fokus- -// Modus, Eriks Wahl "C" aus dem Darstellungs-Vergleich) — jede Kachel trägt -// nur die Position in der Liste plus ggf. den Änderungs-Punkt, Nummer und -// Name stehen in einem Tooltip. Eigene Funktion, weil das Sprung-Feld sie -// allein neu zeichnet — das Detail und vor allem ein gerade getippter -// Kommentar dürfen dabei nicht verlorengehen. Keine Parameter, keine -// Rückgabe. +// renderBerichtListe: die Tagesordnung als Leiste mit Nummer, Gruppe UND +// Name direkt sichtbar (Entwurf "Aktenregal", Eriks Wahl aus dem zweiten +// Darstellungs-Vergleich — löst die vorherige schmale Icon-Leiste ab, die +// Nummer und Namen nur im Hover-Tooltip zeigte: "so sehe ich die ganze +// Tagesordnung auf einen Blick"). Gruppiert wie die Projektliste +// (GROUP_ORDER, js/globals.js), mit einer Zwischenüberschrift bei jedem +// Gruppenwechsel — die Positionsnummer zählt dabei über alle Gruppen hinweg +// weiter, sie ist die Position in der VOLLSTÄNDIGEN Tagesordnung. Eigene +// Funktion, weil das Sprung-Feld sie allein neu zeichnet — das Detail und +// vor allem ein gerade getippter Kommentar dürfen dabei nicht +// verlorengehen. Keine Parameter, keine Rückgabe. function renderBerichtListe(){ const el = document.getElementById('berichtListe'); const w = currentWeek(); @@ -601,21 +618,21 @@ function renderBerichtListe(){ return; } + let gruppeVorher = null; el.innerHTML = liste.map(p => { const info = classifyProjectChange(p, prevWeek); const istAktiv = aktiv && aktiv.id === p.id; const position = voll.findIndex(x => x.id === p.id) + 1; - const tooltip = (p.nr ? p.nr + ' · ' : '') + p.name; - // Eigene, schwebende Tooltip-Box (wie im freigegebenen Entwurf) statt - // des nativen Browser-Tooltips: Seit die Leiste nicht mehr selbst - // scrollt (kein overflow-y:auto mehr, siehe .bericht-rail in styles.css), - // gibt es auch keine abschneidende Overflow-Begrenzung mehr dafür. - return `
${esc(gruppe)}
` : ''; + gruppeVorher = gruppe; + const name = (p.nr ? p.nr + ' · ' : '') + p.name; + return `${gruppenkopf}
+ aria-current="${istAktiv ? 'true' : 'false'}"> ${String(position).padStart(2, '0')} ${info.bucket === 'unchanged' ? '' : ``} - ${esc(tooltip)} + ${esc(name)}
`; }).join(''); @@ -653,7 +670,7 @@ function renderBerichtDetail(p, w, prevWeek, locked){

${esc(p.name)}

${subText}
${fehlen.length ? `Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.` : ''} - ${renderVorwochenzeile(p, prevWeek)} + ${renderVorwocheRad(p, w)} ${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)} ${locked ? '' : `
`}`; } -// renderVorwochenzeile: der Kommentar der Vorwoche als eine Zeile ÜBER dem -// Eingabefeld. Die häufigste Frage im Meeting ist "was hatten wir letzte -// Woche vereinbart?" — der Verlauf beantwortet sie zwar, aber erst nach dem -// Aufklappen. Parameter: p, prevWeek. Rückgabe: HTML (leer ohne Vorwoche). -function renderVorwochenzeile(p, prevWeek){ - if(!prevWeek || !p.projectUid) return ''; - const vor = (prevWeek.projects || []).find(x => x.projectUid === p.projectUid); - if(!vor) return ''; - const text = statusPlainText(vor.status); - if(!text) return ''; - return `
- Vorwoche · ${esc(prevWeek.label)} - ${esc(text)} +// vorwocheRadIndex: Position im Kommentar-Verlauf des GERADE GEÖFFNETEN +// Projekts (0 = jüngste Vorwoche, höher = weiter zurück). Ein einzelner +// modulweiter Stand genügt, da immer nur ein Projekt im Fokus steht; +// waehleBerichtProjekt() setzt ihn beim Projektwechsel zurück. +let vorwocheRadIndex = 0; + +// renderVorwocheRad: löst renderVorwochenzeile() ab, die nur die +// UNMITTELBARE Vorwoche zeigte. Eriks Rückmeldung zum Entwurfsvergleich: mit +// dem Mausrad (oder den beiden Pfeilen) durch MEHRERE vergangene Kommentare +// blättern können, nicht nur eine Vorwoche sehen. Nutzt +// projectStatusHistory() (js/utils.js) — denselben, bereits für den +// Verlaufs-Dialog geprüften Verlauf (nur Sitzungen mit tatsächlich +// geändertem Text, neueste zuerst), abzüglich der aktuellen Sitzung selbst +// (die steht ja bereits im Eingabefeld darunter). Parameter: p, w (aktuelle +// Sitzung). Rückgabe: HTML (leer ohne Verlauf). +function renderVorwocheRad(p, w){ + if(!p.projectUid) return ''; + const verlauf = projectStatusHistory(p.projectUid).filter(e => e.week.label !== w.label); + if(verlauf.length === 0) return ''; + const i = Math.max(0, Math.min(verlauf.length - 1, vorwocheRadIndex)); + const eintrag = verlauf[i]; + const punkte = verlauf.map((_, idx) => ``).join(''); + return `
+ +
+ ${esc(eintrag.week.label)} + ${esc(eintrag.text)} +
+ +
${punkte}
`; } +// vorwocheRadSchritt: bewegt vorwocheRadIndex um delta (geklemmt auf den +// vorhandenen Verlauf) und zeichnet den Bericht neu — derselbe vollständige +// Neuaufbau, den auch editLph() auslöst; ein offener +// Kommentar-Editor übersteht das über dasselbe Fokus-Wiederherstellungsnetz +// (pendingCommentFocus, js/board/board.js), eine aufgeklappte Anwesenheit +// über berichtDisclosureState. Parameter: delta (1 = weiter zurück, -1 = +// neuer). Keine Rückgabe. +function vorwocheRadSchritt(delta){ + vorwocheRadScrollAkku = 0; // ein gezielter Klick verwirft ein halb aufgelaufenes Scrollen + const w = currentWeek(); + const p = aktuellesBerichtProjekt(w); + if(!w || !p) return; + const max = projectStatusHistory(p.projectUid).filter(e => e.week.label !== w.label).length - 1; + vorwocheRadIndex = Math.max(0, Math.min(max, vorwocheRadIndex + delta)); + renderWochenbericht(); +} + +// vorwocheRadScrollAkku/-SCHWELLE: jeder einzelne wheel-Tick löste zunächst +// sofort einen Schritt aus — auf einem Trackpad erzeugt schon ein kurzes +// Antippen mehrere Ticks mit kleinem deltaY, wodurch ein einziges Scrollen +// mehrere Wochen auf einmal übersprang (Erik: "Empfindlichkeit beim Scrollen +// etwas verlangsamen"). Jetzt wird deltaY aufsummiert, bis die Schwelle +// überschritten ist — eine einzelne Mausrad-Rastung (meist ~100) überschreitet +// sie sofort und bleibt damit bei einem Schritt je Rastung, ein kurzes +// Trackpad-Antippen dagegen oft nicht. Erster Richtwert, bei Bedarf +// nachjustieren. +let vorwocheRadScrollAkku = 0; +const VORWOCHE_RAD_SCHWELLE = 50; + +// vorwocheRadScroll: Mausrad-/Trackpad-Bedienung desselben Scrollrads — +// runter (deltaY positiv, aufsummiert) blättert weiter zurück, hoch blättert +// zu neueren Einträgen. e.preventDefault() verhindert, dass dieselbe Bewegung +// zusätzlich die Seite scrollt. Parameter: e (WheelEvent). Keine Rückgabe. +function vorwocheRadScroll(e){ + e.preventDefault(); + vorwocheRadScrollAkku += e.deltaY; + if(Math.abs(vorwocheRadScrollAkku) < VORWOCHE_RAD_SCHWELLE) return; + vorwocheRadSchritt(vorwocheRadScrollAkku > 0 ? 1 : -1); +} + // ---------- Aufgabe aus dem Kommentar ---------- // // Beschlüsse fallen im Meeting und landen bisher als Fließtext im Kommentar — @@ -872,10 +946,9 @@ function protokollAlsMail(){ // ---------- Tastatur ---------- // // Wer im Meeting mitschreibt, hat keine Hand für die Maus frei. ↓/↑ wechseln -// das Projekt, Strg+↵ speichert den Kommentar und geht weiter. Die Tasten -// greifen nur im Wochenbericht und nicht, während in einem Eingabefeld -// getippt wird — mit einer Ausnahme: Strg+↵ im Kommentarfeld ist genau der -// Fall, für den es gedacht ist. +// das Projekt, Strg+↵ im Kommentarfeld speichert den Kommentar und geht +// weiter. Die Tasten greifen nur im Wochenbericht und nicht, während in +// einem Eingabefeld getippt wird. document.addEventListener('keydown', function(e){ if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return; const w = currentWeek(); @@ -888,12 +961,22 @@ document.addEventListener('keydown', function(e){ if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){ const feld = e.target && e.target.closest ? e.target.closest('.comment-field') : null; + // Strg+Enter nur abfangen, wenn der Fokus tatsächlich im Kommentarfeld + // liegt — sonst OHNE feld-Prüfung, z. B. in einem Leistungsphasen- + // Eingabefeld (.bericht-lph-input) oder im Titelfeld des "Aufgabe aus + // Kommentar"-Dialogs getippt, sprang die App sofort zum nächsten Projekt + // und baute die Liste neu auf — ein gerade eingetippter, noch nicht per + // onchange/blur gespeicherter Wert ging dabei kommentarlos verloren, und + // ein offen stehender Dialog bezog sich danach lautlos auf das falsche + // (inzwischen gewechselte) Projekt. Ohne feld bricht diese Weiche jetzt + // einfach ab und überlässt Strg+Enter dem normalen Feldverhalten. + if(!feld) return; const aktiv = aktuellesBerichtProjekt(w); if(!aktiv) return; e.preventDefault(); // Der Kommentar speichert sich über den bestehenden Weg selbst; hier // folgt nur der Sprung zum nächsten offenen Punkt. - if(feld && typeof saveBoardComment === 'function'){ + if(typeof saveBoardComment === 'function'){ const knopf = feld.closest('.comment-block').querySelector('.comment-editor-foot button'); if(knopf) saveBoardComment(knopf); } diff --git a/public/plan-styles.css b/public/plan-styles.css index 7948b36..a857980 100644 --- a/public/plan-styles.css +++ b/public/plan-styles.css @@ -754,6 +754,53 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); } animation: protokoll-import-spin .8s linear infinite; } @keyframes protokoll-import-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } +/* "TODOs aus Protokoll" (js/plan/plan-protokoll-todos.js): die drei Segmente + oben im Dialog zeigen den Fortschritt durch Hochladen → Punkte → + Aufgaben — erledigte/aktuelle Schritte gefüllt, kommende leer. Entwurf + "A — Schritt-Dialog" (Taste-Skill-Vergleich, Eriks Wahl). */ +.protokoll-todos-steps { display: flex; gap: 5px; margin: 12px 0 16px; } +.protokoll-todos-steps .seg { flex: 1; height: 4px; border-radius: 2px; background: var(--line-soft); } +.protokoll-todos-steps .seg.done { background: var(--accent); } + +/* Schritt 3 ("Aufgaben bestätigen"), Entwurfsrichtung "B — Kompakte Zeilen" + aus einem zweiten Taste-Skill-Vergleich (Eriks Wahl): eine Zeile je + Vorschlag statt einer ausführlichen Karte — ein echter Testlauf lieferte + bereits 19 Vorschläge aus einem Protokoll, als Karten wäre das nur noch + Scrollen gewesen. Termin/Zuständig stehen als Abzeichen schon in der + eingeklappten Zeile; Beschreibung und die volle Bearbeitung (Termin-Feld, + Zuständigen-Auswahl) erscheinen erst nach Antippen der Zeile + (.protokoll-todo-vorschlag.open). */ +.pt-sel-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.pt-sel-count { font-size: 12.5px; color: var(--text-muted); } +.pt-sel-count b { color: var(--accent); font-weight: 700; } +.pt-sel-actions button { appearance: none; border: none; background: none; font: inherit; font-size: 11.5px; + font-weight: 650; color: var(--accent); cursor: pointer; padding: 2px 3px; } +.pt-sel-actions button:hover { text-decoration: underline; } + +.protokoll-todo-vorschlag { border-bottom: 1px solid var(--line-soft); } +.protokoll-todo-vorschlag:last-child { border-bottom: none; } +.pt-row-main { display: flex; align-items: center; gap: 9px; padding: 8px 6px; cursor: pointer; } +.protokoll-todo-vorschlag.off .pt-row-main { opacity: .45; } +.pt-row-main .pt-vorschlag-check { flex-shrink: 0; accent-color: var(--accent); cursor: pointer; } +.pt-row-main .pt-vorschlag-titel { flex: 1; min-width: 0; border: none; background: none; font: inherit; + font-weight: 650; font-size: 12.8px; color: var(--text); padding: 2px 0; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pt-row-badges { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } +.pt-badge-date { font-size: 10.5px; font-weight: 650; padding: 2px 8px; border-radius: 999px; + background: var(--accent-soft); color: var(--accent); white-space: nowrap; } +/* Pille statt perfektem Kreis: ein Kürzel ist 2-4 Zeichen lang (initialsForName(), + js/utils.js), ein starrer 18px-Kreis hätte "THK" o. Ä. abgeschnitten. */ +.pt-badge-person { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; + background: var(--accent); color: var(--surface); + font-size: 9px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } +.pt-chevron { color: var(--text-subtle); font-size: 10.5px; width: 12px; text-align: center; + transition: transform .15s; flex-shrink: 0; } +.protokoll-todo-vorschlag.open .pt-chevron { transform: rotate(90deg); } +.pt-row-detail { display: none; padding: 0 6px 12px; } +.protokoll-todo-vorschlag.open .pt-row-detail { display: block; } +.protokoll-todo-vorschlag-beschreibung { margin: 2px 0 8px 28px; } +.protokoll-todo-vorschlag-felder { margin-left: 28px; display: flex; flex-direction: column; gap: 6px; } + .protokoll-detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--line-soft); margin-bottom: 16px; } .protokoll-detail-meta { color: var(--text-muted); font-size: 12.5px; } @@ -1009,3 +1056,62 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); } .legend-swatch { width: 13px; height: 13px; border-radius: 4px; flex-shrink: 0; display: inline-block; } .legend-swatch.neu { background: var(--protokoll-neu-bg); border: 1.5px solid var(--protokoll-neu-border); } .legend-swatch.info { background: var(--status-info-bg); border: 1.5px solid var(--status-info); } + +/* ============================================================ + BEWEGUNG - Stufe "Spürbar", Teil Projektbereich + ============================================================ + Ergänzung zum gleichnamigen Block am Ende von styles.css; die + Tokens (--dur, --ease-out, --row-lift, --press) stehen dort in + :root und gelten hier mit. Erfasst die Zeilenarten, die es nur + im Projektbereich gibt. + ============================================================ */ + +/* Anklickbare Zeilen: anheben + einfahrende Akzentleiste, exakt wie + die Projektliste. Auch hier als inset-Schatten statt ::before - + die Aufgabenzeilen des GANTT sind ziehbar (plan-drag.js), ein + Stapelkontext auf der Zeile wäre dort riskant. */ +.todo-depends-item, +.pt-row-main, +.plan-task-row { + transition: background var(--dur) var(--ease-out), + box-shadow var(--dur) var(--ease-out), + transform var(--dur) var(--ease-out); +} +.todo-depends-item:hover, +.pt-row-main:hover, +.plan-task-row:hover { + transform: translateY(var(--row-lift)); + box-shadow: inset 3px 0 0 0 var(--accent); +} +.todo-depends-item:active, +.pt-row-main:active, +.plan-task-row:active { transform: translateY(0); transition-duration: 60ms; } + +/* Die Protokoll-Zeile ist der eine Sonderfall: sie trägt bereits einen + eigenen linken Rand (border-left: 3px solid transparent, oben), den + der aktive Punkt auf die Akzentfarbe setzt. Hier NICHT zusätzlich den + inset-Schatten verwenden - beides zusammen ergäbe 6 px Akzent statt 3. + Stattdessen wird genau dieser vorhandene Rand animiert, das Anheben + bleibt identisch zu allen anderen Zeilen. */ +.protokoll-liste-zeile { + transition: background var(--dur) var(--ease-out), + border-left-color var(--dur) var(--ease-out), + transform var(--dur) var(--ease-out); +} +.protokoll-liste-zeile:hover { + transform: translateY(var(--row-lift)); + border-left-color: var(--accent); +} +.protokoll-liste-zeile:active { transform: translateY(0); transition-duration: 60ms; } + +/* Bereichs-Reiter des Projektbereichs: Druckpunkt wie jeder andere + Schalter. Die Unterstreichung des aktiven Reiters bleibt wie sie ist. */ +.plan-nav-item { transition: color var(--dur) var(--ease-out), + border-color var(--dur) var(--ease-out), + transform var(--dur) var(--ease-out); } +.plan-nav-item:active { transform: scale(var(--press)); } + +/* Aufklapp-Pfeil der Vorschlagszeile ("TODOs aus Protokoll"): dreht + sich jetzt mit derselben Dauer wie der Pfeil der Projektliste + (.board-chev), statt mit einem eigenen Wert. */ +.pt-chevron { transition: transform var(--dur) var(--ease-out); } diff --git a/public/styles.css b/public/styles.css index e95de8a..5bd1dd2 100644 --- a/public/styles.css +++ b/public/styles.css @@ -64,6 +64,31 @@ --sh-3: 0 8px 28px rgba(0,0,0,.12), 0 2px 8px rgba(0,0,0,.06); --ease: cubic-bezier(.4, 0, .2, 1); + + /* === Bewegung (Stufe "Spürbar", Eriks Wahl aus dem Interaktions- + Vergleich) ============================================================ + Die gesamte Rückmeldung der Oberfläche hängt an diesen fünf Werten. + Eine andere Stufe wäre ein Wechsel HIER, nicht in den Komponenten: + jede Regel weiter unten liest ausschliesslich über var(). + + --dur kurze Rückmeldung (Fläche, Farbe, Anheben, Druck) + --dur-lg grössere Zustandswechsel (Aufklappen, Dialog, Band) + --ease-out Ausschwingen: schnell los, weich aus. Bewusst anders als + das ältere --ease (symmetrisch), das für Bestandsregeln + unverändert bleibt. + --row-lift wie weit sich eine anklickbare Zeile beim Zeigen hebt + --press Stauchung beim Drücken (Knöpfe, Chips, Zeilen) + + Grundregeln, die überall gelten: bewegt wird nur transform und + opacity (nie Höhe/Breite, das ruckelt), nichts läuft in Dauerschleife, + und jede Regung quittiert eine echte Handlung. Wer im Betriebssystem + "Bewegung reduzieren" gesetzt hat, bekommt am Dateiende alles auf + praktisch null gesetzt - dort genügt es, diese Variablen zu kippen. */ + --dur: 150ms; + --dur-lg: 200ms; + --ease-out: cubic-bezier(.2, .8, .2, 1); + --row-lift: -1px; + --press: .97; } /* === Basis & Reset === */ @@ -1449,16 +1474,24 @@ tr:nth-child(even) td { background: var(--surface-alt); } } /* === Dichte Fassung der Karte (.board-drawer-dicht, Wochenbericht) === - Fokus-Modus (Eriks Wahl "C" aus dem Darstellungs-Vergleich): nur der - Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in - aufklappbaren Abschnitten (.bericht-disclosure, siehe renderBoardDrawer()s - dichte Fassung in js/board/board.js) statt dauerhaft nebeneinander zu - stehen — bei 29 Projekten wurde der Bericht mit allem gleichzeitig - sichtbar als "zu überladen" zurückgemeldet. Kein eigenes Raster mehr (die - drei Blöcke stehen schlicht untereinander), deshalb sitzt die dichte - Fassung flächig in ihrem Container statt als eigene Schublade mit - zusätzlichem Rahmen/Hintergrund darin. */ + Fokus-Modus, zweite Fassung (Eriks Rückmeldung zum Entwurfsvergleich "A - + Aktenregal"): der Kommentar bleibt das größte, offene Element; Leistungs- + phasen und Beteiligte stehen darunter dauerhaft sichtbar nebeneinander + (.bericht-meta-grid, siehe renderBoardDrawer()s dichte Fassung in + js/board/board.js) statt in aufklappbaren Abschnitten versteckt zu sein - + "die Beteiligten sollen mit angezeigt werden". Die erste Fassung hatte + beide hinter Aufklappern verborgen (gegen die Rückmeldung "zu überladen" + bei allem gleichzeitig sichtbar); jetzt sind beide bewusst KOMPAKT + gehalten (Kästchen-Leiste bzw. schmale Rollenzeilen statt breiter + Balkenzeilen), damit sie nebeneinander Platz finden, ohne den Kommentar zu + überragen. Nur die Anwesenheit (renderAttendance(), + js/wochenbericht/wochenbericht.js) bleibt ein Aufklapper + (.bericht-disclosure, weiter unten) — sie ist eine Sitzungs-, keine + Projekt-Angabe und sitzt ohnehin außerhalb dieser Karte am Seitenfuß. */ .board-drawer-dicht { padding: 0; background: transparent; border-top: none; } +.bericht-meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line-soft); } +@media (max-width: 860px) { .bericht-meta-grid { grid-template-columns: 1fr; gap: 16px; } } +.bericht-meta-label { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--text-subtle); margin-bottom: 8px; } .bericht-disclosure { border: 1px solid var(--line-soft); @@ -1496,33 +1529,6 @@ tr:nth-child(even) td { background: var(--surface-alt); } .bericht-disclosure[open] summary { border-bottom: 1px solid var(--line-soft); } .bericht-disclosure-body { padding: 10px 14px 12px; } -/* Leistungsphasen als Balkenzeilen statt der Kästchen-Leiste des - Projektboards (.board-lph-leiste/-cell bleiben dort unverändert) — in der - schmalen Fokus-Spalte brauchen zehn Kästchen nebeneinander zu wenig Platz - je Phase, eine Zeile je Phase dagegen hat Raum für Label, Balken UND - Eingabefeld. */ -.bericht-lph-row { display: flex; align-items: center; gap: 10px; padding: 5px 0; font-size: 12.5px; } -.bericht-lph-label { width: 48px; flex-shrink: 0; color: var(--text-muted); } -.bericht-lph-track { flex: 1; height: 4px; background: var(--line-soft); border-radius: 2px; overflow: hidden; } -.bericht-lph-track i { display: block; height: 100%; background: var(--text-subtle); } -.bericht-lph-row.is-aktiv .bericht-lph-track i { background: var(--accent); } -.bericht-lph-row.is-aktiv .bericht-lph-label { color: var(--accent); font-weight: 650; } -.bericht-lph-pct { - display: flex; align-items: center; gap: 1px; flex-shrink: 0; - color: var(--text-subtle); font-variant-numeric: tabular-nums; font-size: 11.5px; -} -/* .bericht-lph-row (zwei Klassen) statt der bloßen Element-Klasse: nötig, um - den globalen input[type=text]-Grundstil (Polsterung/Rahmen/Hintergrund, - weiter oben) zu schlagen — sonst gewinnt dessen höhere Spezifität und die - Polsterung von 7px 10px ließ "100" im 42px schmalen Feld auf "10" - abschneiden. */ -.bericht-lph-row .bericht-lph-input { - width: 28px; flex-shrink: 0; border: none; border-radius: 0; background: transparent; - text-align: right; font-size: 11.5px; font-variant-numeric: tabular-nums; color: var(--text); - font-family: inherit; padding: 1px 0; -} -.bericht-lph-row .bericht-lph-input:focus { outline: 1.5px solid var(--accent); outline-offset: -1px; border-radius: 3px; box-shadow: none; } -.bericht-lph-row .bericht-lph-input.locked-cell { cursor: default; } /* Der Kommentar bleibt das größte Element der dichten Fassung — etwas größere Schrift als im Projektboard, wo er eine Spalte unter mehreren @@ -2368,11 +2374,14 @@ a.secondary:hover { background: var(--surface-sunk); border-color: var(--text-su /* === Anwesenheit der Sitzung ============================================ - Fokus-Modus (Eriks Wahl "C"): ein aufklappbarer Abschnitt wie Leistungs- - phasen/Beteiligte im Wochenbericht (.bericht-disclosure, siehe weiter - oben), kein dauerhaft sichtbarer Block mehr. Zustände bleiben binär - (anwesend / nicht anwesend, Tabelle attendance); der Zähler in der - Kopfzeile macht sichtbar, wenn noch niemand geklickt hat. */ + Fokus-Modus: ein aufklappbarer Abschnitt (.bericht-disclosure, siehe + weiter oben) am Seitenfuß des Wochenberichts, UNABHÄNGIG vom gewählten + Projekt (eine Sitzungs-, keine Projekt-Angabe) - anders als Leistungs- + phasen/Beteiligte, die seit Eriks Rückmeldung zum Entwurfsvergleich wieder + dauerhaft sichtbar sind (.bericht-meta-grid, siehe weiter oben), bleibt + dies der einzige Aufklapper. Zustände bleiben binär (anwesend / nicht + anwesend, Tabelle attendance); der Zähler in der Kopfzeile macht sichtbar, + wenn noch niemand geklickt hat. */ #weekAttendance .bericht-disclosure { margin-top: 16px; } .attendance-actions { display: flex; gap: 8px; margin-bottom: 10px; } @@ -4174,12 +4183,15 @@ td.actions-cell button { margin: 2px; } color: var(--text-muted); } -/* === Wochenbericht als Tagesordnung, Fokus-Modus (Eriks Wahl "C" aus dem - Darstellungs-Vergleich, js/wochenbericht/wochenbericht.js) === - Links eine schmale Nummern-Leiste (jedes Projekt nur eine Position + ggf. - ein Änderungs-Punkt, Name im Tooltip), rechts das gewählte Projekt — nur - Kommentar und Kernstatus liegen dort offen, Leistungsphasen/Beteiligte/ - Anwesenheit stecken in aufklappbaren Abschnitten (.bericht-disclosure). +/* === Wochenbericht als Tagesordnung, Fokus-Modus "Aktenregal" (Eriks Wahl + aus dem Entwurfsvergleich, js/wochenbericht/wochenbericht.js) === + Links eine Leiste mit Nummer, Gruppe UND Name direkt sichtbar (keine + schmale Icon-Spalte mit Namen nur im Hover-Tooltip mehr), rechts das + gewählte Projekt: Kommentar (inkl. Scrollrad zum Verlauf), darunter + Leistungsphasen und Beteiligte dauerhaft sichtbar nebeneinander + (.bericht-meta-grid). Nur die Anwesenheit der Sitzung bleibt ein + aufklappbarer Abschnitt (.bericht-disclosure) - sie ist sitzungs-, nicht + projektbezogen und sitzt am Seitenfuß, unabhängig vom gewählten Projekt. Ersetzt die vorherige ausgeschriebene Liste|Detail-Darstellung, die mit Gewerke-Dropdown, Fortschrittsbalken, Suchfeld, mehreren Badges je Zeile und allem gleichzeitig sichtbar als "zu überladen" zurückgemeldet wurde. @@ -4201,7 +4213,12 @@ td.actions-cell button { margin: 2px; } } .bericht-gewerk-select { font-size: 12px; font-weight: 600; padding: 5px 10px; - border-radius: var(--r-sm); background: var(--surface-alt); + /* background-color statt background (Kurzform): die Kurzform setzt das vom + globalen select-Selektor gesetzte Pfeil-Icon (background-image/-repeat/ + -position) auf die Vorgabewerte zurück, wodurch das 10x6px-Pfeilchen + über das ganze Feld kachelt statt einmal rechts zu sitzen - derselbe + Fehler wie zuvor bei .pc-select. */ + border-radius: var(--r-sm); background-color: var(--surface-alt); border: 1px solid var(--line); color: var(--text); cursor: pointer; } .bericht-ring { @@ -4347,46 +4364,42 @@ td.actions-cell button { margin: 2px; } border: 1px solid var(--line-soft); border-radius: var(--r-sm); background: var(--surface-alt); } .pc-result-shot:hover img { border-color: var(--accent); } -.bericht-layout { display: grid; grid-template-columns: 64px minmax(0, 1fr); } -@media (max-width: 760px) { .bericht-layout { grid-template-columns: 48px minmax(0, 1fr); } } +/* Entwurf "Aktenregal" (zweite Fassung des Fokus-Modus, Eriks Wahl aus dem + Entwurfsvergleich): die Leiste zeigt Nummer, Gruppe UND Name direkt statt + nur eine Nummer mit Name im Hover-Tooltip (die vorherige, schmale 64px- + Fassung) — man sieht die ganze Tagesordnung auf einen Blick, nicht nur + beim Drüberfahren. */ +.bericht-layout { display: grid; grid-template-columns: 240px minmax(0, 1fr); } +@media (max-width: 860px) { .bericht-layout { grid-template-columns: 1fr; } } .bericht-rail { background: var(--surface-alt); border-right: 1px solid var(--line-soft); - padding: 10px 0; display: flex; flex-direction: column; align-items: center; gap: 3px; - /* Die Leiste bleibt beim Scrollen im Blick — sie IST die Tagesordnung. Kein - eigener Rahmen/Radius/Schatten mehr und keine eigene Höhenbegrenzung: - die Fläche gehört zu .bericht-panel, und eine gedeckelte Höhe zwang den - eigenen (inzwischen entfernten) Tooltip zum Abschneiden — ein Element mit - overflow-y ungleich visible schneidet nach der CSS-Spezifikation auch in - X-Richtung ab. */ - position: sticky; top: 64px; + padding: 10px 8px; display: flex; flex-direction: column; gap: 2px; + position: sticky; top: 64px; max-height: calc(100vh - 64px); overflow-y: auto; } +.bericht-rail-gruppe { + font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; + color: var(--text-subtle); padding: 12px 8px 5px; +} +.bericht-rail-gruppe:first-child { padding-top: 2px; } .bericht-rail-item { - position: relative; width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; - display: flex; align-items: center; justify-content: center; - color: var(--text-subtle); cursor: pointer; + position: relative; display: flex; align-items: center; gap: 8px; + padding: 6px 8px; border-radius: var(--r-sm); cursor: pointer; color: var(--text); font-size: 12.5px; } -.bericht-rail-item:hover { background: var(--surface-sunk); color: var(--text); } -.bericht-rail-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -.bericht-rail-item.is-aktiv { background: var(--accent); color: #fff; } +.bericht-rail-item:hover { background: var(--surface-sunk); } +.bericht-rail-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.bericht-rail-item.is-aktiv { background: var(--accent-soft); } +.bericht-rail-item.is-aktiv .bericht-rail-name { color: var(--accent); font-weight: 650; } /* Abgehakt: zurückgenommen, aber erkennbar — man muss im Meeting noch sehen können, was man schon besprochen hat. */ .bericht-rail-item.is-ab { opacity: .55; } .bericht-rail-item.is-aktiv.is-ab { opacity: 1; } -.bericht-rail-num { font-size: 10.5px; font-weight: 700; font-variant-numeric: tabular-nums; } -.bericht-rail-dot { position: absolute; top: 3px; right: 3px; width: 6px; height: 6px; border-radius: 50%; } +.bericht-rail-num { font-size: 11px; color: var(--text-subtle); font-variant-numeric: tabular-nums; flex-shrink: 0; width: 16px; } +.bericht-rail-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } .bericht-rail-dot.is-new, .bericht-rail-dot.is-status { background: var(--band-neu); } .bericht-rail-dot.is-progress { background: var(--band-fortschritt); } -.bericht-rail-item.is-aktiv .bericht-rail-dot { outline: 1.5px solid var(--accent-soft); outline-offset: -1px; } -.bericht-rail-tooltip { - position: absolute; left: 48px; top: 50%; transform: translateY(-50%); - background: var(--text); color: var(--surface); font-size: 11px; padding: 4px 8px; - border-radius: var(--r-sm); white-space: nowrap; opacity: 0; pointer-events: none; - transition: opacity .12s var(--ease); z-index: 5; -} -.bericht-rail-item:hover .bericht-rail-tooltip, -.bericht-rail-item:focus-visible .bericht-rail-tooltip { opacity: 1; } +.bericht-rail-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* max-width: die Hauptspalte bleibt eine schmale Lesespalte statt bis zum rechten Rand zu laufen — Teil der Fokus-Idee, nicht nur die Nebeninfos @@ -4412,17 +4425,36 @@ td.actions-cell button { margin: 2px; } color: var(--amber); font-size: 12px; margin-bottom: 16px; } -/* Der Kommentar der Vorwoche steht ÜBER dem Eingabefeld: "Was hatten wir - letzte Woche vereinbart?" ist die häufigste Frage im Meeting. */ -.bericht-vorwoche { - background: var(--surface-sunk); border-radius: var(--r-md); - padding: 9px 12px; margin-bottom: 14px; font-size: 12px; color: var(--text-muted); +/* Scrollrad über dem Kommentar-Eingabefeld: "Was hatten wir letzte Woche + vereinbart?" ist die häufigste Frage im Meeting - stand bis hierhin als + starre Einzeile mit nur der unmittelbaren Vorwoche da. Erik wollte mit dem + Mausrad durch MEHRERE vergangene Kommentare blättern können + (renderVorwocheRad(), js/wochenbericht/wochenbericht.js), nicht nur die + eine Vorwoche sehen - die Quelle ist derselbe, bereits geprüfte Verlauf wie + im Verlaufs-Dialog (projectStatusHistory(), js/utils.js). */ +.bericht-vorwoche-rad { + display: flex; align-items: center; gap: 8px; background: var(--surface-sunk); + border-radius: var(--r-md); padding: 7px 9px; margin-bottom: 14px; cursor: ns-resize; } +.bericht-vorwoche-nav { + width: 22px; height: 22px; border: none; background: none; border-radius: 50%; + color: var(--text-muted); cursor: pointer; font-size: 13px; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; font-family: inherit; +} +.bericht-vorwoche-nav:hover:not(:disabled) { background: var(--surface); color: var(--text); } +.bericht-vorwoche-nav:disabled { opacity: .3; cursor: default; } +.bericht-vorwoche-inhalt { flex: 1; min-width: 0; } .bericht-vorwoche-label { display: block; font-weight: 600; color: var(--text-subtle); text-transform: uppercase; font-size: 10.5px; letter-spacing: .05em; margin-bottom: 3px; } -.bericht-vorwoche-text { color: var(--text-muted); } +.bericht-vorwoche-text { + color: var(--text-muted); font-size: 12px; display: block; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.bericht-vorwoche-dots { display: flex; flex-direction: column; gap: 3px; flex-shrink: 0; } +.bericht-vorwoche-dots .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--line); } +.bericht-vorwoche-dots .dot.aktiv { background: var(--accent); } .bericht-detail-fuss { display: flex; align-items: center; gap: 14px; margin-top: 20px; } .bericht-fuss-spacer { flex: 1; } @@ -5095,3 +5127,128 @@ td.actions-cell button { margin: 2px; } .wiz-plan-zeile span:nth-child(2), .wiz-plan-zeile span:nth-child(4) { display: none; } } + +/* ============================================================ + BEWEGUNG - Stufe "Spürbar" + ============================================================ + Von Erik aus einem Dreier-Vergleich gewählt (dezent / spürbar / + lebendig). Die Werte stehen als Tokens oben in :root; hier wird + nur angewendet. + + Bewusst NICHT enthalten: Eintritts-Animationen für Listen und + aufziehende Balken (das war Stufe 3). Die Oberfläche bewegt sich + ausschliesslich als Antwort auf Zeigen oder Klicken, nie von + selbst - bei einer Liste mit dreissig Projekten, die mehrmals + täglich neu gerendert wird, wäre alles andere Unruhe. + + Die Wandtafel (body.wandtafel-aktiv) bleibt bewusst aussen vor: + ein Touch-Monitor kennt kein Hover, und die Tafel trägt ohnehin + ihre eigene Gestaltung (--dash-*). + ============================================================ */ + +/* --- 1. Anklickbare Zeilen: anheben + Akzentleiste --------------- + Die Leiste entsteht als inset-Schatten, NICHT als ::before mit + position:absolute. Grund: .board-row ist draggable (Drag-and-Drop + der Projektliste, js/projects/projects.js). Ein zusätzliches + positioniertes Element bzw. ein Stapelkontext auf der Zeile kann + das Ziehbild und die Trefferflächen verschieben; ein inset-Schatten + kann das nicht, weil er weder Layout noch Stapelkontext anfasst. + Der Schatten wächst von 0 auf 3px, wirkt also wie eine von links + einfahrende Leiste. */ +.board-row-main, +.bericht-rail-item, +.planung-row, +.dash-rand-zeile { + transition: background var(--dur) var(--ease-out), + box-shadow var(--dur) var(--ease-out), + transform var(--dur) var(--ease-out); +} +.board-row-main:hover, +.bericht-rail-item:hover, +.planung-row:hover, +.dash-rand-zeile:hover { + transform: translateY(var(--row-lift)); + box-shadow: inset 3px 0 0 0 var(--accent); +} +/* Beim Drücken wieder absetzen: die Zeile geht unter den Finger + zurück, statt angehoben zu bleiben. */ +.board-row-main:active, +.bericht-rail-item:active, +.planung-row:active, +.dash-rand-zeile:active { + transform: translateY(0); + transition-duration: 60ms; +} +/* Eine bereits ausgewählte Zeile trägt die Leiste dauerhaft - sonst + verschwände beim Wegfahren der Maus die einzige Markierung. */ +.bericht-rail-item.is-aktiv { box-shadow: inset 3px 0 0 0 var(--accent); } + +/* --- 2. Druckpunkt für alles Klickbare ------------------------- + button:active trug schon scale(.97); hier kommen die übrigen + Bedienelemente auf denselben Wert, damit sich nicht das eine + Element gedrückt anfühlt und das Nachbarelement nicht. */ +.board-avatar, +.person-chip, +.nav-item, +.attendance-tile, +.plan-verweis, +.tag-chip .tag-remove, +.richtext-btn, +.pc-btn { + transition: background var(--dur) var(--ease-out), + border-color var(--dur) var(--ease-out), + color var(--dur) var(--ease-out), + transform var(--dur) var(--ease-out); +} +.board-avatar:not(.is-ansicht):active, +.person-chip:active, +.nav-item:active, +.attendance-tile:active, +.plan-verweis:active, +.richtext-btn:active, +.pc-btn:active { transform: scale(var(--press)); } + +/* --- 3. Dialoge: auftauchen statt aufblitzen ------------------- + .modal-bg wird per JS von display:none auf flex geschaltet. Ein + display-Wechsel ist nicht übergangsfähig, eine ANIMATION dagegen + startet neu, sobald das Element wieder gerendert wird - deshalb + hier animation statt transition. Die Fläche blendet auf, der + Dialog kommt dabei minimal aus der Tiefe. */ +.modal-bg { animation: modal-bg-auf var(--dur-lg) var(--ease-out); } +.modal-bg > .modal { animation: modal-auf var(--dur-lg) var(--ease-out); } +@keyframes modal-bg-auf { from { opacity: 0; } to { opacity: 1; } } +@keyframes modal-auf { + from { opacity: 0; transform: translateY(6px) scale(.985); } + to { opacity: 1; transform: none; } +} + +/* --- 4. Tastatur-Fokus sichtbar halten ------------------------ + Die Hebe-/Druck-Effekte oben reagieren auf Maus und Finger. Wer + mit der Tastatur arbeitet, braucht eine eigene, deutliche Marke; + ohne sie wäre die Oberfläche nach diesem Umbau für Tastatur- + Bedienung schlechter lesbar als vorher. */ +.board-row-main:focus-visible, +.bericht-rail-item:focus-visible, +.planung-row:focus-visible, +.person-chip:focus-visible, +.board-avatar:focus-visible, +.nav-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +/* --- 5. Bewegung abbestellt ------------------------------------ + Pflichtteil, in dieser Datei bisher gar nicht vorhanden. Weil die + Stufe an Variablen hängt, genügt es, sie zu kippen: ohne Hub und + ohne Stauchung bleibt reine Farb-Rückmeldung übrig. Die + transition-/animation-Zeilen fangen zusätzlich die rund sechzig + älteren Regeln ab, die ihre Dauer noch fest eingetragen haben. */ +@media (prefers-reduced-motion: reduce) { + :root { --row-lift: 0px; --press: 1; --dur: 1ms; --dur-lg: 1ms; } + *, *::before, *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + transition-duration: 1ms !important; + scroll-behavior: auto !important; + } +} diff --git a/server.js b/server.js index 144ee99..e65c3bf 100644 --- a/server.js +++ b/server.js @@ -343,7 +343,7 @@ app.use(require('cors')()); // express.raw() ihn bekommt ("Keine Datei empfangen"). 8 MB statt 2 MB, weil // eine Sitzung mit vielen Projekten und langen Kommentaren sonst irgendwann // still am 413 scheitert. -const RAW_BODY_ROUTES = new Set(['/import', '/project-files', '/project-protokolle/import', '/plan-checker-pruefen']); +const RAW_BODY_ROUTES = new Set(['/import', '/project-files', '/project-protokolle/import', '/plan-checker-pruefen', '/plan-todos-aus-protokoll/einlesen']); const jsonBodyParser = express.json({ limit: '8mb' }); app.use((req, res, next) => { if(req.method === 'POST' && RAW_BODY_ROUTES.has(req.path)) return next(); @@ -556,6 +556,7 @@ db.exec(`CREATE TABLE IF NOT EXISTS plan_todos ( done INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, + show_in_gantt INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (week_label, project_id, todo_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees ( @@ -1050,8 +1051,8 @@ function serializeWeek(week){ VALUES (?, ?, ?, ?, ?)`); const insPlanPerson = db.prepare(`INSERT OR IGNORE INTO plan_people (week_label, project_id, person_id, array_order) VALUES (?, ?, ?, ?)`); - const insTodo = db.prepare(`INSERT INTO plan_todos (week_label, project_id, todo_id, title, start, end, done, progress, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); + const insTodo = db.prepare(`INSERT INTO plan_todos (week_label, project_id, todo_id, title, start, end, done, progress, sort_order, show_in_gantt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); const insTodoAssignee = db.prepare(`INSERT OR IGNORE INTO plan_todo_assignees (week_label, project_id, todo_id, person_id) VALUES (?, ?, ?, ?)`); const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id) @@ -1112,7 +1113,8 @@ function serializeWeek(week){ (Array.isArray(plan.people) ? plan.people : []).forEach((pid, pidx) => insPlanPerson.run(weekLabel, projId, String(pid || ''), pidx)); (Array.isArray(plan.todos) ? plan.todos : []).forEach((t, tidx) => { insTodo.run(weekLabel, projId, String(t.id || ''), String(t.title || ''), - String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx); + String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx, + t.showInGantt === false ? 0 : 1); (Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => insTodoAssignee.run(weekLabel, projId, String(t.id || ''), String(pid || ''))); (Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || ''))); }); @@ -1297,7 +1299,7 @@ function loadWeeks(){ const todoMap = {}; todos.forEach(r => { const k = todoKey(r.week_label, r.project_id, r.todo_id); - todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress }; + todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress, showInGantt: !!r.show_in_gantt }; }); todoAssignees.forEach(r => { const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)]; @@ -2238,6 +2240,24 @@ function migrateAddProjectGroesse(){ } } +// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach +// (idempotent, gleiches ALTER-TABLE-Muster wie migrateAddProjectGroesse()). +// DEFAULT 1, damit jede bestehende Aufgabe ihr bisheriges Verhalten behält +// (sichtbar im GANTT, sobald Start UND Ende gesetzt sind) — die neue +// Checkbox im Aufgaben-Dialog ("Im Terminplan anzeigen", plan-modals.js) +// blendet gezielt EINZELNE Aufgaben aus, ändert an diesem Normalfall nichts. +// Erik auf eigenen Wunsch, nachdem "TODOs aus Protokoll" (plan-protokoll- +// todos.js) gezeigt hatte, dass ein einzelner Import leicht 15-25 Aufgaben +// auf einmal anlegt — alle MIT Datum (siehe dortiger Kopfkommentar), also +// bisher alle zwingend auch als GANTT-Balken. +function migrateAddPlanTodoShowInGantt(){ + const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name); + if(!cols.includes('show_in_gantt')){ + db.exec('ALTER TABLE plan_todos ADD COLUMN show_in_gantt INTEGER NOT NULL DEFAULT 1'); + console.log('plan_todos um show_in_gantt ergänzt.'); + } +} + // migrateAddProjectUpdatedAt: rüstet die Spalte `updated_at` nach (idempotent, // gleiches ALTER-TABLE-Muster wie migrateAddProjectGroesse()). Gebraucht von // der Planung-Sandbox, siehe Spaltenkommentar am CREATE TABLE oben. @@ -2665,6 +2685,7 @@ migrateAddProjectStatusAuthor(); migrateAddProjectAuftragsstatus(); migrateAddProjectGroesse(); migrateAddProjectUpdatedAt(); +migrateAddPlanTodoShowInGantt(); migrateAddUserAiKeyModel(); migrateAddProtokollQuelle(); migrateAddProtokollPunktErstelltAm(); @@ -4264,6 +4285,29 @@ const KI_TIMEOUT_MS_CLOUD = 45000; // sichtbar, kein scheinbares Hängenbleiben. const KI_TIMEOUT_MS_LOKAL = 1800000; +// KI_TIMEOUT_MS_CLOUD_FREMD: ein abweichender, aber weiterhin REMOTE +// liegender Endpunkt (z. B. Mammouth.ai, OpenRouter, ein Firmen-Proxy) ist +// weder "die echte, bekanntermaßen schnelle OpenAI/Anthropic-API" (dort +// gilt zurecht der scharfe KI_TIMEOUT_MS_CLOUD) noch "lokale CPU-Inferenz, +// die Minuten dauern darf" (KI_TIMEOUT_MS_LOKAL) — ein eigener, +// mittlerer Wert. Gefunden bei einem echten Fehlschlag: "TODOs aus +// Protokoll" brach bei Erik zuverlässig mit "This operation was aborted" +// ab, obwohl der Protokoll-Import grundsätzlich funktionierte — derselbe +// Konto-Schlüssel zeigte auf https://api.mammouth.ai/v1 (Modell +// "gpt-6-luna"), kein Loopback, also griff bislang der scharfe +// KI_TIMEOUT_MS_CLOUD (45s). Direkt gegen den echten Schlüssel nachgestellt +// (derselbe Aufruf, derselbe Prompt, dasselbe Dokument wie im Fehlerfall): +// ein einzelnes triviales Prompt beantwortete der Anbieter in 1,3s, die +// tatsächliche Punkte-Erkennung eines realen Protokolls (29.594 Zeichen) +// brauchte dagegen 39,2s — schon im Erfolgsfall nur 6s unter dem alten +// Limit, jede normale Schwankung (Tageszeit, Dokumentlänge, Anbieterlast) +// reichte seither zuverlässig zum Abbruch. Ein Drittanbieter-Proxy ist +// grundsätzlich langsamer und weniger vorhersagbar als die Original-API, +// aber immer noch ein verwalteter Cloud-Dienst, keine CPU-Inferenz auf +// fremder Hardware — 2 Minuten geben das Dreifache der gemessenen Dauer +// als Spielraum, ohne in die Größenordnung von KI_TIMEOUT_MS_LOKAL zu gehen. +const KI_TIMEOUT_MS_CLOUD_FREMD = 120000; + // LOKAL_DISPATCHER: Node's globales fetch() läuft intern über undici, dessen // eigener Standard-Agent Verbindungen nach headersTimeout/bodyTimeout = 5 // Minuten von sich aus kappt — UNABHÄNGIG vom eigenen AbortController unten. @@ -4354,6 +4398,17 @@ function writeUserAiKey(userId, provider, patch){ .run(userId, provider, apiKey, apiUrl, model); } +// KI_DEFAULT_URL: vor istLoopbackHost()/fetchMitTimeout() platziert (stand +// ursprünglich weiter unten bei normalisiereAiUrl()), weil fetchMitTimeout() +// unten die beiden Adressen jetzt selbst braucht, um einen ECHTEN +// Anbieter-Endpunkt von einem abweichenden (lokal ODER ein fremder +// Cloud-Proxy) zu unterscheiden — siehe istStandardCloudHost() und den +// Fund bei KI_TIMEOUT_MS_CLOUD_FREMD weiter unten. +const KI_DEFAULT_URL = { + chatgpt: 'https://api.openai.com/v1/chat/completions', + claude: 'https://api.anthropic.com/v1/messages' +}; + // istLoopbackHost: gemeinsame Grundlage für istGueltigeAiUrl() (URL-Prüfung // beim Speichern) und fetchMitTimeout() (Timeout-Wahl beim Aufruf) — beide // müssen dieselbe Vorstellung von "läuft auf der eigenen Maschine" teilen. @@ -4361,6 +4416,15 @@ function istLoopbackHost(hostname){ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]'; } +// istStandardCloudHost: true nur für die beiden ECHTEN Anbieter-Hosts aus +// KI_DEFAULT_URL (api.openai.com/api.anthropic.com) — die einzigen, bei +// denen "eine Antwort dauert ungewöhnlich lange" zuverlässig "hier ist +// etwas kaputt" bedeutet. Grundlage für fetchMitTimeout()s dritte Stufe +// (KI_TIMEOUT_MS_CLOUD_FREMD) direkt darunter. +function istStandardCloudHost(hostname){ + return hostname === new URL(KI_DEFAULT_URL.chatgpt).hostname || hostname === new URL(KI_DEFAULT_URL.claude).hostname; +} + // istGueltigeAiUrl: https wird grundsätzlich verlangt (der Schlüssel reist // sonst im Klartext über ein Netz, das nicht die eigene Maschine ist) — mit // EINER Ausnahme: http auf dem Loopback. Ein lokal laufender Server wie @@ -4378,10 +4442,20 @@ function istGueltigeAiUrl(url){ } async function fetchMitTimeout(url, options){ + // Drei Stufen statt zwei: echter Anbieter-Host (knapp, 45s) < abweichender + // Remote-Host wie ein Drittanbieter-Proxy (mittel, 2min, s. + // KI_TIMEOUT_MS_CLOUD_FREMD) < Loopback (großzügig, 30min) — siehe dortigen + // Kommentar für den konkreten Fehlschlag, der die mittlere Stufe nötig machte. let istLokal = false; - try { istLokal = istLoopbackHost(new URL(url).hostname); } catch(e){ /* ungültige URL: Vorgabe bleibt Cloud-Timeout */ } + let istStandard = true; + try { + const hostname = new URL(url).hostname; + istLokal = istLoopbackHost(hostname); + istStandard = istStandardCloudHost(hostname); + } catch(e){ /* ungültige URL: Vorgabe bleibt der knappe Standard-Timeout */ } + const timeoutMs = istLokal ? KI_TIMEOUT_MS_LOKAL : (istStandard ? KI_TIMEOUT_MS_CLOUD : KI_TIMEOUT_MS_CLOUD_FREMD); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), istLokal ? KI_TIMEOUT_MS_LOKAL : KI_TIMEOUT_MS_CLOUD); + const timer = setTimeout(() => controller.abort(), timeoutMs); try { const fetchOptions = Object.assign({}, options, { signal: controller.signal }); if(istLokal) fetchOptions.dispatcher = LOKAL_DISPATCHER; @@ -4458,10 +4532,8 @@ const KI_PUNKT_SCHEMA = { additionalProperties: false }; -const KI_DEFAULT_URL = { - chatgpt: 'https://api.openai.com/v1/chat/completions', - claude: 'https://api.anthropic.com/v1/messages' -}; +// KI_DEFAULT_URL ist jetzt weiter oben definiert, direkt vor +// istLoopbackHost()/fetchMitTimeout() — siehe Kommentar dort. // normalisiereAiUrl: wer eine eigene API-URL hinterlegt, tippt bei einem // lokalen Server wie LM Studio/Ollama erfahrungsgemäß nur die Basisadresse @@ -4623,6 +4695,232 @@ async function erkennePunkteMitKI(text, userId, provider){ throw new Error('Unbekannter Anbieter: ' + provider); } +// ---------- "TODOs aus Protokoll" (public/js/plan/plan-protokoll-todos.js) ---------- +// +// Eigenständige, zweite KI-Stufe: nimmt bereits erkannte Besprechungspunkte +// (egal ob frisch aus einem Ad-hoc-PDF-Upload oder sonstwoher) und leitet +// daraus Aufgaben-VORSCHLÄGE ab (Titel, Beschreibung, Fälligkeit, Zuständig- +// Vorschlag) — anders als erkennePunkteMitKI() oben, das nur Punkte aus +// Fließtext EXTRAHIERT, beurteilt dieser Schritt, was davon tatsächlich zu +// tun ist: mehrere Punkte können zu einer Aufgabe zusammengehören, rein +// informative Punkte liefern keine. Komplett unabhängig von +// project_protokolle/protokoll_punkte — die Punkte kommen direkt im +// Request-Body, nichts wird hier gelesen oder geschrieben. + +// normalisiereAufgabeAusKi: Analoges Gegenstück zu normalisierePunktAusKi() +// oben, für eine KI-Antwortzeile dieser zweiten Stufe. +function normalisiereAufgabeAusKi(a){ + return { + titel: String((a && a.titel) || ''), + beschreibung: String((a && a.beschreibung) || ''), + faelligkeit: String((a && a.faelligkeit) || ''), + zustaendigVorschlag: String((a && a.zustaendigVorschlag) || ''), + quellRefs: Array.isArray(a && a.quellRefs) ? a.quellRefs.filter(r => Number.isInteger(r)) : [] + }; +} + +const KI_AUFGABEN_SCHEMA = { + type: 'object', + properties: { + aufgaben: { + type: 'array', + items: { + type: 'object', + properties: { + titel: { type: 'string' }, + beschreibung: { type: 'string' }, + faelligkeit: { type: 'string' }, + zustaendigVorschlag: { type: 'string' }, + quellRefs: { type: 'array', items: { type: 'integer' } } + }, + required: ['titel', 'beschreibung', 'faelligkeit', 'zustaendigVorschlag', 'quellRefs'], + additionalProperties: false + } + } + }, + required: ['aufgaben'], + additionalProperties: false +}; + +// KI_AUFGABEN_SYSTEM_PROMPT: eigener, von KI_SYSTEM_PROMPT bewusst getrennter +// Prompt — andere Aufgabe (Aufgaben aus bereits strukturierten Punkten +// ABLEITEN, nicht Punkte aus Fließtext EXTRAHIEREN). Wie bei KI_SYSTEM_PROMPT/ +// PLAN_CHECK_SYSTEM_PROMPT gilt: der Wortlaut ist ein Startpunkt, kein +// Festbetrag — vor dem produktiven Einsatz mit einem echten externen +// Protokoll bei temperature:0 testen, Änderungen einzeln bewerten (siehe +// CLAUDE.md "### Protokolle"). +const KI_AUFGABEN_SYSTEM_PROMPT = 'Du bekommst Besprechungspunkte eines Projektprotokolls (je Punkt: ref, thema, ' + + 'text, zustaendig, termin). Leite daraus konkrete, umsetzbare Aufgaben ab. Mehrere Punkte, die dieselbe Handlung ' + + 'betreffen, können in einer Aufgabe zusammengefasst werden; rein informative Punkte ohne notwendige Handlung ' + + 'liefern KEINE Aufgabe. Je Aufgabe: ein kurzer Titel (Tätigkeit zuerst), eine kurze Beschreibung, ein ' + + 'Fälligkeitsdatum NUR wenn im Text ein konkretes Datum genannt ist (sonst leer lassen, nichts erfinden), ein ' + + 'Vorschlag für die zuständige Person (wörtlich aus dem Feld "zustaendig" übernommen, nichts erfinden), und die ' + + 'refs der Punkte, aus denen sie entstand.'; + +// aufgabenTextFuerKi: baut den Nutzer-Content für beide Anbieter aus der +// Punkteliste — jeder Punkt trägt seine Array-Position als "ref", damit die +// Antwort referenzierbar ist, ohne dass ein id-Konzept nötig wäre (nichts +// hier ist persistiert). +function aufgabenTextFuerKi(punkte){ + return punkte.map((p, i) => `ref ${i}\nThema: ${p.thema || ''}\nText: ${p.text || ''}\n` + + `Zuständig: ${p.zustaendig || ''}\nTermin: ${p.termin || ''}`).join('\n\n'); +} + +async function leiteAufgabenMitChatGPT(punkte, apiKey, apiUrl, model){ + const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt); + let istLokal = false; + try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* ungültige URL: bleibt false */ } + + const body = { + model: model || 'gpt-4o-mini', + messages: [ + { role: 'system', content: KI_AUFGABEN_SYSTEM_PROMPT }, + { role: 'user', content: aufgabenTextFuerKi(punkte) } + ], + response_format: { type: 'json_schema', json_schema: { name: 'protokoll_aufgaben', strict: true, schema: KI_AUFGABEN_SCHEMA } }, + // Kleiner als bei der Punkte-Erkennung (erkennePunkteMitChatGPT oben): + // die Ausgabe ist nur eine Handvoll kurzer Aufgaben, kein ganzes Protokoll. + max_tokens: 4096, + temperature: 0 + }; + if(istLokal) body.reasoning_effort = 'none'; + + const res = await fetchMitTimeout(zielUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey }, + body: JSON.stringify(body) + }); + if(!res.ok){ + const errBody = await res.text().catch(() => ''); + throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : '')); + } + const data = await res.json(); + const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; + if(!content) throw new Error('ChatGPT lieferte keine Antwort.'); + let parsed; + try { parsed = JSON.parse(content); } catch(e){ throw new Error('ChatGPT-Antwort war kein gültiges JSON.'); } + if(!Array.isArray(parsed.aufgaben)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.'); + return parsed.aufgaben.map(normalisiereAufgabeAusKi); +} + +async function leiteAufgabenMitClaude(punkte, apiKey, apiUrl, model){ + const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, + body: JSON.stringify({ + model: model || 'claude-haiku-4-5-20251001', + max_tokens: 4096, + temperature: 0, + system: KI_AUFGABEN_SYSTEM_PROMPT, + messages: [{ role: 'user', content: aufgabenTextFuerKi(punkte) }], + tools: [{ + name: 'protokoll_aufgaben', + description: 'Die aus den Besprechungspunkten abgeleiteten Aufgaben.', + input_schema: KI_AUFGABEN_SCHEMA + }], + tool_choice: { type: 'tool', name: 'protokoll_aufgaben' } + }) + }); + if(!res.ok){ + const errBody = await res.text().catch(() => ''); + throw new Error('Claude antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : '')); + } + const data = await res.json(); + const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null; + if(!toolUse || !toolUse.input || !Array.isArray(toolUse.input.aufgaben)){ + throw new Error('Claude-Antwort hatte nicht die erwartete Form.'); + } + return toolUse.input.aufgaben.map(normalisiereAufgabeAusKi); +} + +async function leiteAufgabenMitKI(punkte, userId, provider){ + const { apiKey, apiUrl, model } = readUserAiKey(userId, provider); + if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.'); + if(provider === 'chatgpt') return leiteAufgabenMitChatGPT(punkte, apiKey, apiUrl, model); + if(provider === 'claude') return leiteAufgabenMitClaude(punkte, apiKey, apiUrl, model); + throw new Error('Unbekannter Anbieter: ' + provider); +} + +// Route 1/2: liest ein hochgeladenes PDF (roh im Body, wie +// /project-protokolle/import) und erkennt daraus Besprechungspunkte — nutzt +// DIESELBE Erkennung wie der echte Protokoll-Import (erkennePunkteMitKI()/ +// erkennePunkteAusText()), legt aber NICHTS an: kein project_protokolle- +// Eintrag, keine Datei auf der Festplatte (anders als der Import, der beides +// immer persistiert — diese Funktion ist bewusst ein Ad-hoc-Werkzeug ohne +// Archiv, siehe Kopfkommentar oben). Die erkannten Punkte gehen direkt an +// den Client zurück und werden von der zweiten Route unten (ohne erneuten +// Server-Lookup) weiterverarbeitet. +app.post('/plan-todos-aus-protokoll/einlesen', requireAuth, express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => { + try { + const buf = req.body; + if(!Buffer.isBuffer(buf) || buf.length === 0){ + return res.status(400).json({ ok: false, error: 'Keine Datei empfangen.' }); + } + const text = await extrahierePdfText(buf); + + let punkte, erkennungsart = 'textmuster', erkennungshinweis = ''; + const anbieter = req.query.anbieter; + if(anbieter === 'chatgpt' || anbieter === 'claude'){ + try { + const ergebnis = await erkennePunkteMitKI(text, req.user.id, anbieter); + punkte = ergebnis.punkte; + erkennungsart = anbieter; + } catch(err){ + console.warn('KI-Erkennung (TODOs aus Protokoll) fehlgeschlagen, Rückfall auf Textmuster:', err.message); + erkennungshinweis = err.message; + punkte = erkennePunkteAusText(text); + } + } else { + punkte = erkennePunkteAusText(text); + } + + res.json({ ok: true, punkte, erkennungsart, erkennungshinweis }); + } catch(err){ + console.error('TODOs aus Protokoll (Einlesen) fehlgeschlagen:', err); + res.status(500).json({ ok: false, error: String(err) }); + } +}); + +// Route 2/2: leitet aus den (vom Client ausgewählten) Punkten Aufgaben- +// Vorschläge ab. Die Punkte kommen direkt im Body — kein Bezug zu +// project_protokolle, siehe Kopfkommentar oben. +app.post('/plan-todos-aus-protokoll/aufgaben-vorschlag', requireAuth, async (req, res) => { + try { + const { anbieter, punkte } = req.body || {}; + if(!Array.isArray(punkte) || punkte.length === 0){ + return res.status(400).json({ ok: false, error: 'Keine Punkte ausgewählt.' }); + } + + let vorschlaege, erkennungsart = 'direkt', erkennungshinweis = ''; + if(anbieter === 'chatgpt' || anbieter === 'claude'){ + try { + vorschlaege = await leiteAufgabenMitKI(punkte, req.user.id, anbieter); + erkennungsart = anbieter; + } catch(err){ + console.warn('KI-Ableitung (TODOs aus Protokoll) fehlgeschlagen, Rückfall auf 1:1-Übernahme:', err.message); + erkennungshinweis = err.message; + vorschlaege = null; + } + } + if(!vorschlaege){ + // Direkter Rückfall: je ausgewähltem Punkt ein Vorschlag, derselbe + // Rückfall-Geist wie erkennePunkteAusText() beim Import. + vorschlaege = punkte.map((p, i) => ({ + titel: String(p.thema || '').trim() || String(p.text || '').slice(0, 80), + beschreibung: String(p.text || ''), + faelligkeit: String(p.termin || ''), + zustaendigVorschlag: String(p.zustaendig || ''), + quellRefs: [i] + })); + } + + res.json({ ok: true, vorschlaege, erkennungsart, erkennungshinweis }); + } catch(err){ + console.error('TODOs aus Protokoll (Aufgaben-Vorschlag) fehlgeschlagen:', err); + res.status(500).json({ ok: false, error: String(err) }); + } +}); + // ---------- Plan Checker (plan_checker_merkmale) ---------- // // Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt @@ -4679,18 +4977,25 @@ async function erkennePunkteMitKI(text, userId, provider){ const PLAN_CHECK_SYSTEM_PROMPT = 'Du prüfst eine technische Planzeichnung (Architektur/TGA) auf das Vorhandensein ' + 'bestimmter Merkmale. Du bekommst eine nummerierte Liste von Merkmalen mit Prüfanweisung. Gib für JEDES Merkmal aus der ' + 'Liste genau einen Eintrag zurück, mit dem Titel exakt wie angegeben. "erfuellt" ist nur dann true, wenn das Merkmal auf ' - + 'der Zeichnung eindeutig zu erkennen ist - im Zweifel false. "begruendung" ist KURZ - ein bis zwei Sätze, NIE eine ' - + 'Aufzählung einzelner Werte (z. B. nicht jede einzelne Maßzahl auflisten) - beschreibe stattdessen zusammenfassend, was ' - + 'zu sehen ist. Erfinde nichts.'; + + 'der Zeichnung eindeutig zu erkennen ist - im Zweifel false. "begruendung" ist HÖCHSTENS 150 ZEICHEN LANG - eine feste ' + + 'Obergrenze, kein Stilwunsch. Zähle NIEMALS einzelne Werte auf (keine Maßzahlen, keine Höhenangaben, keine ' + + 'Koordinaten) - auch nicht als Beispiel ("z. B. ...") und auch nicht, wenn auf der Zeichnung sehr viele davon zu sehen ' + + 'sind: beschreibe stattdessen in einem knappen Satz, DASS und WO solche Angaben vorkommen. Erfinde nichts.'; // Wie bei KI_SYSTEM_PROMPT oben gilt: Formulierungsänderungen können das -// Ergebnis stark verschieben (siehe dortige Projekthistorie). Der Zusatz -// "NIE eine Aufzählung einzelner Werte" kam nach einem echten Fehlschlag -// dazu: Ein lokal über LM Studio angebundenes Modell zählte für "Maßketten -// vorhanden" jede einzelne gefundene Maßzahl einzeln auf ("2,50 m, 3,12 m, -// 1,20 m, 2,10 m, ..."), wodurch die Antwort noch vor Abschluss des JSON an -// max_tokens (s. u.) abgeschnitten wurde und als Ganzes nicht mehr parsbar -// war ("ChatGPT-Antwort war kein gültiges JSON"). Das ursprüngliche "ein bis -// zwei Sätze" allein reichte diesem Modell als Grenze nicht aus. +// Ergebnis stark verschieben (siehe dortige Projekthistorie). Der erste +// Zusatz ("NIE eine Aufzählung einzelner Werte", ohne Zeichengrenze) kam nach +// einem echten Fehlschlag dazu: Ein lokal über LM Studio angebundenes Modell +// zählte für "Maßketten vorhanden" jede einzelne gefundene Maßzahl einzeln +// auf ("2,50 m, 3,12 m, 1,20 m, 2,10 m, ..."), wodurch die Antwort noch vor +// Abschluss des JSON an max_tokens (s. u.) abgeschnitten wurde und als Ganzes +// nicht mehr parsbar war ("ChatGPT-Antwort war kein gültiges JSON"). Reichte +// demselben Modell beim nächsten Fehlschlag aber IMMER NOCH nicht: Es zählte +// diesmal sogar "1,00, 2,00, 3,00 ... 22,00" durch und sprengte damit auch +// das inzwischen angehobene max_tokens erneut. Eine semantische Anweisung +// ("halt dich kurz") lässt sich offenbar umgehen, eine harte Zahl +// ("höchstens 150 Zeichen") ist für ein Modell mechanisch leichter zu prüfen +// - ebenfalls noch nicht an diesem konkreten Fehlschlag gegengetestet, siehe +// Hinweis bei max_tokens. // PLAN_CHECK_BILD_ANZAHL: Gesamtzahl der an die KI gesendeten Bilder (1 // Übersicht + die Kacheln, s. renderPdfAlsBildsatz() oben) - bestimmt den @@ -4798,14 +5103,15 @@ async function pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, mode ] } ], response_format: { type: 'json_schema', json_schema: { name: 'plan_pruefung', strict: true, schema: PLAN_CHECK_SCHEMA } }, - // 4096 war zu knapp bemessen: ein lokal angebundenes Modell hielt sich - // trotz "ein bis zwei Sätze" nicht an die Kürze (s. PLAN_CHECK_SYSTEM_PROMPT - // oben) und die Antwort wurde mitten im JSON abgeschnitten. 8192 als - // Sicherheitsnetz zusätzlich zur geschärften Prompt-Anweisung - beide - // Maßnahmen zusammen, da sich (wie schon bei KI_SYSTEM_PROMPT dokumentiert) - // nicht verlässlich vorhersagen lässt, ob ein Modell eine Kürze-Anweisung - // tatsächlich befolgt. - max_tokens: 8192, + // 4096 war zu knapp, dann 8192 - ein lokal angebundenes Modell hielt sich + // trotz zunehmend schärferer Anweisung (s. PLAN_CHECK_SYSTEM_PROMPT oben, + // Projekthistorie dort) wiederholt nicht an die Kürze, und die Antwort + // wurde mitten im JSON abgeschnitten. 16000 angeglichen an das Limit, das + // sich für genau dasselbe Problem beim Protokoll-Import bereits bewährt + // hat (erkennePunkteMitChatGPT() oben) - weiterhin nur ein Sicherheitsnetz + // zusätzlich zur Prompt-Anweisung, keine Garantie: Ein Modell, das + // entschlossen genug aufzählt, kann auch dieses Limit erreichen. + max_tokens: 16000, temperature: 0 }; // Siehe erkennePunkteMitChatGPT() oben: manche lokale Server laden @@ -4832,12 +5138,21 @@ async function pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, mode const bereinigt = content.trim().replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, ''); let parsed; try { parsed = JSON.parse(bereinigt); } catch(e){ - // Rohtext der Antwort im Fehler, wie bei den HTTP-Fehlern oben - // (errBody.slice(0, 200)) - ohne das war ein lokal/drittanbieter- - // bedingtes Scheitern (z. B. ein Modell, das nur EIN Bild pro Anfrage - // verarbeiten kann und bei mehreren Bildern mit Fließtext statt JSON - // antwortet) nur über die Server-Konsole nachvollziehbar. - throw new Error('ChatGPT-Antwort war kein gültiges JSON: ' + content.slice(0, 300)); + // finish_reason:'length' heißt konkret "max_tokens erreicht, bevor das + // Modell fertig war" - der häufigste real beobachtete Grund für + // ungültiges JSON hier (ein Modell, das trotz PLAN_CHECK_SYSTEM_PROMPT + // einzelne Werte aufzählt, s. dortige Projekthistorie). Eine konkrete + // Fehlermeldung dafür erspart das erneute Einschicken des rohen + // Antworttexts zur Diagnose. Rohtext zusätzlich im Fehler, wie bei den + // HTTP-Fehlern oben (errBody.slice(0, 200)) - andere Scheitern-Gründe + // (z. B. ein Modell, das nur EIN Bild pro Anfrage verarbeiten kann und + // bei mehreren Bildern mit Fließtext statt JSON antwortet) bleiben so + // trotzdem sichtbar. + const abgeschnitten = data.choices[0].finish_reason === 'length'; + const hinweis = abgeschnitten + ? 'Die Antwort wurde abgeschnitten (Modell hat das Token-Limit erreicht, bevor es fertig war - vermutlich zu ausführliche Begründungen). ' + : ''; + throw new Error('ChatGPT-Antwort war kein gültiges JSON: ' + hinweis + content.slice(0, 300)); } if(!Array.isArray(parsed.ergebnisse)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.'); return parsed.ergebnisse; @@ -4875,10 +5190,11 @@ async function pruefePlanMitClaude(bilderBase64, merkmale, apiKey, apiUrl, model // bleibt trotzdem die richtige Vorgabe für diese anspruchsvolle // Bildaufgabe. Konto-Einstellung überschreibt auch diese Vorgabe. model: model || 'claude-sonnet-5', - // 8192 statt 4096 - gleiches Sicherheitsnetz wie bei pruefePlanMitChatGPT() - // oben, selbes Risiko gilt grundsätzlich auch hier (eine zu ausführliche - // Begründung, die vor Abschluss des JSON abgeschnitten wird). - max_tokens: 8192, + // 16000 - dasselbe Sicherheitsnetz, auf denselben Wert angehoben wie bei + // pruefePlanMitChatGPT() oben (Projekthistorie dort), selbes Risiko gilt + // grundsätzlich auch hier (eine zu ausführliche Begründung, die vor + // Abschluss des JSON abgeschnitten wird). + max_tokens: 16000, temperature: 0, system: PLAN_CHECK_SYSTEM_PROMPT, messages: [{ role: 'user', content: [ @@ -5436,6 +5752,51 @@ app.post('/plan-checker-pruefen', requireAuth, express.raw({ type: '*/*', limit: } const merkmale = db.prepare('SELECT titel, prompt FROM plan_checker_merkmale ORDER BY sort_order, titel').all(); + + // Projekt-Abgleich (Eriks Wunsch): optional, genau wie das Zusatz-Merkmal + // weiter unten nur für DIESE eine Prüfung und nicht gespeichert - kein + // neues Feld in plan_checker_merkmale, sondern derselbe Mechanismus, nur + // mit einem serverseitig gebauten statt frei getipptem Text. Das Projekt + // selbst kommt aus dem bereits beim Client geladenen db.weeks (die + // Auswahl im Frontend liest currentWeek().projects, kein eigener + // Endpunkt) - der Server bekommt nur Nummer/Bezeichnung als Query-Param, + // prüft sie aber NICHT gegen die Datenbank (reine Texteingabe für den + // Prompt, wie das Zusatz-Merkmal auch). Bewusst nur Projektnummer + + // Bezeichnung: Das sind die einzigen Projekt-Stammdaten, die es im + // Datenmodell überhaupt gibt - ein Plankopf kennt zusätzlich + // Planersteller, Maßstab, Plannummer, Datum, aber dafür gibt es keine + // gespeicherte Vorgabe, gegen die man abgleichen könnte. + const projektNr = String(req.query.projektNr || '').trim(); + const projektName = String(req.query.projektName || '').trim(); + if(projektNr || projektName){ + const soll = [ + projektNr ? `Projektnummer "${projektNr}"` : null, + projektName ? `Projektbezeichnung "${projektName}"` : null + ].filter(Boolean).join(' und '); + // Erste Fassung fragte direkt "stimmt der Plankopf mit X überein?" - + // ein echter Fehlschlag zeigte, dass das zu leicht als "bestätige X" + // statt "lies ab und vergleiche dann" gelesen wird: Die KI gab exakt + // die vorgegebenen Werte als angeblich abgelesene Werte zurück und + // erklärte sie für übereinstimmend, obwohl im Plankopf tatsächlich eine + // andere Nummer stand (Erik hat das am echten Ergebnis nachgewiesen). + // Jetzt zweistufig erzwungen: ERST ablesen UND DAS GELESENE in der + // Begründung wörtlich nennen, DANN erst vergleichen - macht die + // Vorgabewerte nicht Teil der "Lese"-Aufgabe und macht einen + // Bestätigungsfehler wie diesen für einen Menschen beim Lesen der + // Begründung sofort erkennbar (genau wie Erik ihn gefunden hat). + merkmale.push({ + titel: 'Plankopf stimmt mit Projekt überein', + prompt: 'Lies zuerst die im Plankopf (Schriftfeld) TATSÄCHLICH abgedruckte Projektnummer und/oder ' + + 'Projektbezeichnung ab - unabhängig davon, was du gleich vergleichen sollst. Nenne diese tatsächlich ' + + 'abgelesenen Werte als ERSTES in der Begründung, wörtlich wie abgedruckt. Vergleiche ERST DANACH, ob ' + + `diese abgelesenen Werte zu ${soll} passen. Sinngemäße Übereinstimmung genügt (Abkürzungen, leicht ` + + 'abweichende Schreibweise, zusätzliche Wörter), eine erkennbar ANDERE Nummer oder ein erkennbar anderes ' + + 'Projekt dagegen nicht. "erfuellt" ist nur dann true, wenn die TATSÄCHLICH abgelesenen Werte passen - ' + + 'nicht schon deshalb, weil das erwartete Projekt plausibel klingt. Ist die Angabe im Plankopf nicht ' + + 'lesbar, sage das, statt die erwarteten Werte zu wiederholen.' + }); + } + const zusatz = String(req.query.zusatzMerkmal || '').trim(); if(zusatz) merkmale.push({ titel: zusatz, prompt: zusatz }); if(merkmale.length === 0){