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).
`;
+ }
+
+ // 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 ? `` : '';
+ }).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 `
`}`;
}
-// 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 `
`;
}
+// 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){