alle new
This commit is contained in:
erik committed 2026-10-01 06:46:51 +02:00
1 parent da64472251
commit 5b20adee48
1605 files changed
+396517 -1674

No files matched your search

+504 -14
View File
@@ -8,15 +8,23 @@
// u. a. js/csv.js, js/board/board.js, js/load/load.js sowie den Plan-Modulen unter js/plan/*
// (z. B. für ISO-Datumsformate und Werktage) und von js/gantt.js.
// departmentParam: gibt ein Query-String-Fragment fuer die aktuelle Abteilung zurueck.
// Parameter: keine. Rückgabe: String wie '?department=ELT'.
function departmentParam(){
return '?department=' + encodeURIComponent(currentDepartment);
}
// esc: einfache Escape-Funktion für Ausgabe in HTML
// Parameter: s (String). Rückgabe: HTML-sicherer String (&, <, >, " ersetzt).
function esc(s){ return (s||"").replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// Parameter: s (String). Rückgabe: HTML-sicherer String (&, <, >, ", ' ersetzt).
// Das einfache Anführungszeichen MUSS mit maskiert werden: An mehreren Stellen
// landet der Rückgabewert in einem einfach gequoteten JS-String innerhalb eines
// onclick-Attributs (z. B. takeCommentFromHistory in js/board/board.js,
// setPlanAssign in js/plan/plan-info.js). Ein Apostroph im Sitzungslabel oder
// in einer Personen-Kennung brach dort sonst den Handler auf.
function esc(s){
// String() statt (s||""): Leistungsphasen-Prozentwerte und andere Zahlen
// kommen je nach Herkunft als Number an (Beispieldaten, ältere Datensätze),
// und (100).replace ist keine Funktion — der Aufruf riss bisher das ganze
// Rendern mit. 0 und false sollen als "0"/"false" erscheinen, nur null und
// undefined werden zu "".
return (s === null || s === undefined ? '' : String(s))
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// uid: generiert eine einfache, ausreichend eindeutige ID
// Parameter: keine. Rückgabe: String aus Zeitstempel und Zufallsteil.
@@ -72,6 +80,19 @@ function personIdForName(name){
return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_');
}
// Schlägt aus einem Namen ein kurzes Kürzel vor (für schmale Board-Spalten-
// köpfe, 2-4 Zeichen). Bei mehreren durch Leerzeichen getrennten Wörtern die
// Anfangsbuchstaben der ersten vier Wörter, bei einem einzelnen Wort dessen
// erste drei Buchstaben. Nur ein Vorschlag — bleibt manuell überschreibbar
// (siehe personIdManuallyEdited-Muster in admin-users.js).
// Parameter: name (String). Rückgabe: String wie 'MM' oder ''.
function initialsForName(name){
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
if(words.length === 0) return '';
if(words.length === 1) return words[0].slice(0, 3).toUpperCase();
return words.slice(0, 4).map(w => w[0]).join('').toUpperCase();
}
// formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um
// Parameter: dateString (String). Rückgabe: formatierter String oder leer.
function formatWeekDate(dateString){
@@ -112,6 +133,30 @@ function addDays(d, n){
return res;
}
// heuteIso: das heutige Datum als ISO-Zeichenkette (YYYY-MM-TT) in ORTSZEIT.
//
// Nicht new Date().toISOString().slice(0,10): Das rechnet nach UTC um. In
// deutscher Sommerzeit (UTC+2) ist damit zwischen 00:00 und 02:00 Ortszeit
// noch der Vortag gemeint — ein Termin von heute galt in diesem Zeitfenster
// als überfällig. Rückgabe: String.
function heuteIso(){
const d = new Date();
const m = String(d.getMonth() + 1).padStart(2, '0');
const t = String(d.getDate()).padStart(2, '0');
return d.getFullYear() + '-' + m + '-' + t;
}
// isoPlusTage: Datum in n Tagen als ISO-Zeichenkette in Ortszeit (siehe
// heuteIso zur Begründung). Parameter: n (ganze Zahl, auch negativ).
// Rückgabe: String.
function isoPlusTage(n){
const d = new Date();
d.setDate(d.getDate() + (Number(n) || 0));
const m = String(d.getMonth() + 1).padStart(2, '0');
const t = String(d.getDate()).padStart(2, '0');
return d.getFullYear() + '-' + m + '-' + t;
}
// daysBetween: Anzahl der Kalendertage zwischen zwei Daten
// Parameter: a, b (Date). Rückgabe: ganzzahlige Tagesdifferenz (b - a).
function daysBetween(a, b){
@@ -119,21 +164,55 @@ function daysBetween(a, b){
return Math.round(ms / (1000 * 60 * 60 * 24));
}
// workdaysBetween: Anzahl der Werktage (Mo–Fr) im Zeitraum inkl. Start- und Endtag.
// Wochenendtage (Sa/So) werden nicht mitgezählt – z.B. für die Auslastung.
// Parameter: a, b (Date). Rückgabe: Anzahl der Werktage oder 0 bei ungültigem Zeitraum.
// isWorkday: prüft, ob ein Datum ein Arbeitstag ist — kein Wochenende und
// kein gepflegter Feiertag (HOLIDAYS in js/globals.js, gefüllt aus
// GET /holidays). Vor dem Ausbau der Einstellungen kannte die Anwendung nur
// Wochenenden; ein Terminplan über Weihnachten fiel damit um mehrere Tage zu
// kurz aus. Parameter: d (Date). Rückgabe: Boolean.
function isWorkday(d){
if(!d) return false;
const day = d.getDay();
if(day === 0 || day === 6) return false;
return !(typeof HOLIDAYS !== 'undefined' && HOLIDAYS.has(formatDateISO(d)));
}
// workdaysBetween: Anzahl der Arbeitstage im Zeitraum inkl. Start- und Endtag.
// Wochenenden und Feiertage werden nicht mitgezählt – z.B. für die Auslastung.
// Parameter: a, b (Date). Rückgabe: Anzahl der Arbeitstage oder 0 bei ungültigem Zeitraum.
function workdaysBetween(a, b){
if(!a || !b || b < a) return 0;
let count = 0;
let current = new Date(a.getTime());
while(current <= b){
const day = current.getDay();
if(day !== 0 && day !== 6) count++;
if(isWorkday(current)) count++;
current = addDays(current, 1);
}
return count;
}
// addWorkdays: liefert das Datum, das `n` Arbeitstage nach `d` liegt.
// n = 0 ergibt `d` selbst, sofern das ein Arbeitstag ist — fällt `d` auf ein
// Wochenende oder einen Feiertag, wird auf den nächsten Arbeitstag vorgerückt.
// Gegenstück zu workdaysBetween(); gebraucht von der Honorar-Terminplanung
// (js/plan/plan-honorar.js), die Phasen nahtlos in Arbeitstagen aneinanderreiht.
// Die Obergrenze der Schleifen ist eine Reißleine: Wären versehentlich alle
// Tage als Feiertag gepflegt, liefe sie sonst endlos.
// Parameter: d (Date), n (Anzahl Arbeitstage, >= 0). Rückgabe: Date.
function addWorkdays(d, n){
if(!d) return null;
let current = new Date(d.getTime());
let guard = 0;
// Startdatum selbst auf einen Arbeitstag schieben
while(!isWorkday(current) && guard++ < 400) current = addDays(current, 1);
let remaining = Math.max(0, Math.floor(n));
guard = 0;
while(remaining > 0 && guard++ < 100000){
current = addDays(current, 1);
if(isWorkday(current)) remaining--;
}
return current;
}
// formatDateDE: formatiert ein Date-Objekt als deutsches Datum (DD.MM.YYYY)
// Parameter: d (Date). Rückgabe: String oder leer bei ungültigem Datum.
function formatDateDE(d){
@@ -149,4 +228,415 @@ function getWeekNumber(d){
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
}
}
// ---------- Status-Editor (Board & Projektplan) ----------
// Der contenteditable Status-/Kommentar-Editor (kleine Formatierungs-Toolbar:
// Fett, Farben, Absätze) wird sowohl im Projektboard (js/board/board.js,
// Detailpanel) als auch auf der Projektplan-Seite (js/plan/plan-info.js)
// verwendet — deshalb liegen die reinen Hilfsfunktionen hier im gemeinsamen
// Modul. Nur statusBlur() (board.js, ruft editProj() auf) bzw. der
// Plan-Gegenpart (planStatusBlur() in plan-info.js) bleiben modul-eigen, da
// sie unterschiedliche Speicherpfade ansteuern.
// currentStatusEditor: aktuell fokussiertes contenteditable-Element des
// Status-Editors; wird für die Formatierungs-Toolbar (`formatStatus`) benötigt.
let currentStatusEditor = null;
// currentStatusPid: Projekt-ID der gerade fokussierten Status-Zelle (nur vom
// Board genutzt, siehe statusBlur() dort); auf der Projektplan-Seite bleibt
// sie ungenutzt (dort gibt es nur einen Status-Editor pro Seite).
let currentStatusPid = null;
// statusFocus: merkt sich das fokussierte Status-Element und die zugehörige
// Projekt-ID (`el`, `pid`), damit die Toolbar und das Blur-Speichern sie nutzen.
function statusFocus(el, pid){
currentStatusEditor = el;
currentStatusPid = pid;
}
// statusValueFromHtml: normalisiert den HTML-Inhalt eines Status-Editors.
// Liefert bei reinem Leerraum '' zurück, sonst den unveränderten HTML-String
// (Formatierung bleibt erhalten). Parameter: `html`, Rückgabe: String.
function statusValueFromHtml(html){
const tmp = document.createElement('div');
tmp.innerHTML = html;
const text = (tmp.textContent || tmp.innerText || '').replace(/ /g, ' ');
return text.trim() === '' ? '' : html;
}
// formatStatus: wendet einen document.execCommand-Befehl (z.B. 'bold',
// 'foreColor') auf das fokussierte Status-Element an. Parameter: `command`,
// optionaler `value`; keine Rückgabe.
function formatStatus(command, value){
if(!currentStatusEditor) return;
currentStatusEditor.focus();
document.execCommand(command, false, value || null);
}
// sanitizeStatusHtml: bereinigt übergebenen Status-HTML. Erlaubt nur eine kleine
// Whitelist an HTML-Tags/Attributen (Formatierung), damit keine Skripte
// eingeschleust werden können; im style-Attribut sind nur color/font-weight
// erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei
// leerem Eingabewert).
function sanitizeStatusHtml(html){
if(!html) return '';
const div = document.createElement('div');
div.innerHTML = html;
const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']);
const allowedAttrs = new Set(['style']);
// Elemente, deren INHALT mit verschwindet, statt ausgepackt zu werden:
// Bei <script> wäre der ausgepackte Rest sonst der sichtbare Quelltext.
const dropWithContent = new Set(['SCRIPT','STYLE','NOSCRIPT','TEMPLATE','IFRAME','OBJECT','EMBED']);
const walk = (node) => {
for(let i = node.childNodes.length - 1; i >= 0; i--){
const child = node.childNodes[i];
if(child.nodeType === 3) continue;
if(child.nodeType === 1){
const tag = child.tagName.toUpperCase();
if(dropWithContent.has(tag)){
node.removeChild(child);
continue;
}
if(!allowedTags.has(tag)){
// Referenzknoten ist `child` (ein Kind von `node`) — mit `node`
// selbst wirft insertBefore laut DOM-Spezifikation NotFoundError,
// und schon ein eingefügter Link hätte das ganze Rendern abgebrochen.
let ausgepackt = 0;
while(child.firstChild){ node.insertBefore(child.firstChild, child); ausgepackt++; }
node.removeChild(child);
// Die ausgepackten Knoten stehen jetzt an den Positionen i … i+n-1.
// Ohne diese Korrektur liefe die absteigende Schleife an ihnen
// vorbei — aus einer eingefügten Liste blieben dann die <li>-Tags
// und aus einer Tabelle <tbody>/<tr>/<td> stehen, obwohl die
// Positivliste beides ausschließt.
i += ausgepackt;
continue;
}
for(let j = child.attributes.length - 1; j >= 0; j--){
const attr = child.attributes[j].name;
if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr);
}
if(child.hasAttribute('style')){
// Erlaubt nur color und font-weight im style-Attribut
const style = child.getAttribute('style');
const safe = (style || '').split(';').map(s => s.trim()).filter(s => {
const prop = s.split(':')[0].trim().toLowerCase();
return prop === 'color' || prop === 'font-weight';
}).join('; ');
if(safe) child.setAttribute('style', safe);
else child.removeAttribute('style');
}
walk(child);
}
}
};
walk(div);
return div.innerHTML;
}
// statusHtmlToEditText: wandelt einen gespeicherten Kommentar (HTML) in den
// Klartext, der im Textfeld des Kommentar-Editors steht. Fettschrift wird zu
// **Sternchen**, Absätze und <br> werden zu Zeilenumbrüchen, alles andere
// fällt weg. Gegenstück zu statusEditTextToHtml(). Nötig, weil der Editor im
// Board seit der Kommentar-Überarbeitung ein normales <textarea> ist und kein
// contenteditable mehr (document.execCommand ist abgekündigt) — gespeichert
// wird aber weiterhin HTML, damit alle vorhandenen Anzeigepfade (Board-
// Vorschau, Wochenbericht, PDF-Export, Show-Mode, Verlauf) unverändert
// funktionieren und Altbestände lesbar bleiben.
// Parameter: html (String). Rückgabe: String.
function statusHtmlToEditText(html){
if(!html) return '';
const div = document.createElement('div');
div.innerHTML = sanitizeStatusHtml(html);
// Fett-Auszeichnungen vor dem Auflösen in Sternchen überführen
div.querySelectorAll('b, strong').forEach(el => {
const inner = (el.textContent || '').trim();
el.replaceWith(document.createTextNode(inner ? '**' + inner + '**' : ''));
});
// Block-Grenzen zu Zeilenumbrüchen
div.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode('\n')));
div.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode('\n')));
return (div.textContent || '')
.replace(/ /g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
// statusEditTextToHtml: wandelt den Klartext aus dem Kommentar-Editor in das
// gespeicherte HTML. Escapt zuerst vollständig (esc()), sodass eingetippte
// spitze Klammern niemals als Markup ankommen, und setzt danach die einzige
// erlaubte Auszeichnung **fett** sowie Zeilenumbrüche. Gegenstück zu
// statusHtmlToEditText(). Leerer oder reiner Leerraum-Text ergibt '' — die
// bisherige Bedeutung von "kein Kommentar" bleibt damit erhalten.
// Parameter: text (String). Rückgabe: String (HTML).
function statusEditTextToHtml(text){
const clean = String(text || '').trim();
if(clean === '') return '';
return esc(clean)
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
.replace(/\n/g, '<br>');
}
// -------- Projekt-Verlauf über mehrere Sitzungen --------
// Diese drei Helfer lagen ursprünglich in js/plan/plan-history.js und damit
// nur auf der Projektplan-Seite. Seit der Kommentar-Editor im Board den
// Verlauf ebenfalls anzeigt (renderBoardDrawer(), js/board/board.js), stehen
// sie hier im gemeinsamen Modul — sonst gäbe es zwei Implementierungen
// derselben Logik, die auseinanderlaufen können. plan-history.js greift
// weiterhin darauf zu.
//
// Kein Server-Endpunkt nötig: GET /load-all liefert ohnehin ALLE Sitzungen
// mit allen Projekten nach db.weeks. Zusammengeführt wird über projectUid
// (die dauerhafte, wochenunabhängige Projekt-Identität aus
// migrateAddProjectUid() in server.js) — project.id taugt dafür nicht, weil
// cloneWeek() sie bei jeder Kopie neu vergibt.
// projectVersionsForUid: alle Fassungen eines Projekts über alle geladenen
// Sitzungen, chronologisch aufsteigend (weekSortKey, also nach Kalenderwoche
// und nicht alphabetisch). Projekte aus Altdaten ohne projectUid liefern eine
// leere Liste — ein Fallback über nr/name wäre bei wiederverwendeten
// Projektnummern stillschweigend falsch. Parameter: uid (String).
// Rückgabe: Array von { week, project }.
function projectVersionsForUid(uid){
if(!uid) return [];
return (db.weeks || [])
.map(w => ({ week: w, project: (w.projects || []).find(p => p.projectUid === uid) }))
.filter(entry => !!entry.project)
.sort((a, b) => weekSortKey(a.week.label).localeCompare(weekSortKey(b.week.label)));
}
// statusPlainText: löst einen (HTML-formatierten) Kommentartext in reinen
// Text auf — für Vergleich und Kurzvorschau. Parameter: html (String).
// Rückgabe: String (leer, wenn kein Text vorhanden).
function statusPlainText(html){
const tmp = document.createElement('div');
tmp.innerHTML = sanitizeStatusHtml(html || '');
// wie in statusPreviewText(): Zeilenumbrüche zu Leerzeichen, sonst würden
// zwei Absätze beim Vergleich zu einem zusammengezogenen Wort
tmp.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode(' ')));
tmp.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode(' ')));
return (tmp.textContent || '').replace(/\s+/g, ' ').trim();
}
// projectStatusHistory: die Verlaufsliste eines Projekts — je Sitzung ein
// Eintrag, aber NUR für Sitzungen, in denen sich der Kommentartext gegenüber
// der vorherigen Fassung tatsächlich geändert hat. Ohne diese Filterung
// stünde derselbe Text mehrfach in der Liste: Sitzungen, die vor der
// Umstellung auf leere Kommentare (siehe cloneWeek(), js/weeks.js) dupliziert
// wurden, tragen den Text der Vorwoche unverändert mit. Parameter: uid
// (String). Rückgabe: Array von { week, text, html, author, at, isFirst },
// neueste zuerst.
function projectStatusHistory(uid){
const versions = projectVersionsForUid(uid);
const entries = [];
let lastText = null;
versions.forEach((v, idx) => {
const text = statusPlainText(v.project.status);
const entry = {
week: v.week,
text,
html: sanitizeStatusHtml(v.project.status || ''),
author: v.project.statusAuthor || '',
at: v.project.statusAt || '',
isFirst: idx === 0
};
if(idx === 0){
if(text) entries.push(entry);
} else if(text && text !== lastText){
entries.push(entry);
}
lastText = text;
});
return entries.reverse();
}
// getProgressColor: erzeugt eine HSL-Farbe basierend auf Prozentwert
// (grün → gelb → rot). Parameter `pct`: Zahl 0–100 (wird geklemmt). Genutzt
// für die LPH-Fortschrittsbalken im Board und auf der Projektplan-Seite.
// Rückgabe: Farb-String für den Fortschrittsbalken.
function getProgressColor(pct){
const hue = Math.round(Math.max(0, Math.min(100, pct)) * 1.2);
return `hsl(${hue},70%,40%)`;
}
// ---------- Verlauf eines Projekts über die Sitzungen ----------
//
// Jede Sitzung hält eine eigene Fassung jedes Projekts fest — eine "Kachel"
// je Woche. projectVersionsForUid() (weiter oben) liefert sie chronologisch.
// Der Verlauf macht damit nur sichtbar, was ohnehin gespeichert ist.
//
// Diese Darstellung steht hier in js/utils.js und nicht bei einem einzelnen
// Bereich, weil sie an DREI Stellen gebraucht wird: im Wochenbericht unter
// der Projektkarte, im Projektboard und im Verlaufs-Dialog des
// Projektbereichs. Vorher hatte jede dieser Stellen ihre eigene Fassung —
// der Aufklapper "Frühere Sitzungen" zeigte nur den Kommentartext und auch
// den nur bei tatsächlicher Änderung, der Dialog im Projektbereich zeigte
// eine dritte Auswahl. Die drei konnten sich damit widersprechen, und
// niemand wusste, welche die vollständige war.
//
// Gezeigt wird die ganze Kachel: Kommentar, Leistungsphasen, die Termine aus
// dem Projektplan und die Beteiligten JENER Woche.
// timelineLphText: die Leistungsphasen-Stände einer Projektfassung als
// kompakte Liste. Gezeigt werden nur Phasen mit einem Wert — eine Kachel mit
// zehn leeren Zeilen trägt nichts bei. Wie im übrigen Bericht zählen die
// beauftragten Phasen, sofern welche hinterlegt sind.
// Parameter: p (Projektfassung). Rückgabe: Array von { lph, wert }.
function verlaufLphWerte(p){
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
const phasen = beauftragt.length > 0 ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
const lph = p.lph || {};
return phasen
.filter(l => lph[l] !== undefined && lph[l] !== '')
.map(l => ({ lph: l, wert: String(lph[l]) }));
}
// verlaufTermine: die Termine aus dem Projektplan einer Fassung — das
// späteste Datum im Plan (Projektende) und der nächste Meilenstein. Daran
// lässt sich über die Kacheln hinweg ablesen, ob sich Termine verschoben
// haben. Parameter: p (Projektfassung). Rückgabe: { ende, meilenstein } mit
// Strings (leer, wenn der Plan nichts hergibt).
function verlaufTermine(p){
const plan = p.plan || {};
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const daten = [];
todos.forEach(t => { const d = parseDateISO(t.end); if(d) daten.push(d); });
milestones.forEach(m => { const d = parseDateISO(m.date); if(d) daten.push(d); });
const ende = daten.length ? new Date(Math.max.apply(null, daten)) : null;
// Nächster Meilenstein: der früheste, der zum Zeitpunkt JENER Sitzung noch
// bevorstand. Ohne Sitzungsdatum (Altbestand) der früheste überhaupt.
const mitDatum = milestones
.map(m => ({ name: m.name || 'Meilenstein', datum: parseDateISO(m.date) }))
.filter(m => !!m.datum)
.sort((a, b) => a.datum - b.datum);
const naechster = mitDatum[0] || null;
return {
ende: ende ? formatDateDE(ende) : '',
meilenstein: naechster ? (naechster.name + ' · ' + formatDateDE(naechster.datum)) : '',
};
}
// verlaufBeteiligte: die Kürzel der Personen, die dieser Fassung zugeordnet
// waren. Gelesen wird der Teilnehmerkreis JENER Sitzung, nicht der heutige —
// sonst stünden in einer alten Kachel Personen, die damals gar nicht dabei
// waren. Parameter: version ({week, project}). Rückgabe: Array von Kürzeln.
function verlaufBeteiligte(version){
const p = version.project;
const personen = Array.isArray(version.week.people) ? version.week.people : [];
const namen = [];
personen.forEach(person => {
const rollen = Array.isArray(person.role) ? person.role : [person.role];
const zugeordnet = rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0);
if(zugeordnet && namen.indexOf(person.name) === -1) namen.push(person.name);
});
return namen;
}
// renderProjektVerlauf: der aufklappbare Verlauf unter einer Projektkarte.
// Zeigt je früherer Sitzung eine Kachel, neueste zuerst. Geänderte Werte sind
// gegenüber der jeweils DAVOR liegenden Kachel dezent hervorgehoben — die
// Kacheln stehen zwar nebeneinander, aber bei zehn Leistungsphasen findet man
// die eine geänderte Zahl sonst nicht.
// Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell"
// gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML.
function renderProjektVerlauf(p, weekLabel, locked){
const versionen = projectVersionsForUid(p.projectUid);
// Neueste zuerst, damit links die jüngste Vergangenheit steht.
//
// Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im
// Projektbereich hat keine "aktuelle" Fassung, die auszulassen wäre. Mit
// weekLabel bleibt die genannte Sitzung außen vor — sie steht dort als
// Karte bzw. Eingabemaske ohnehin darüber.
const idx = weekLabel ? versionen.findIndex(v => v.week.label === weekLabel) : -1;
const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse();
if(frueher.length === 0){
return `<details class="timeline-block">
<summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span>
<span>Verlauf</span>
<span class="comment-history-count zero">0</span>
</summary>
<div class="timeline-body"><span class="hint" style="margin:0">Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.</span></div>
</details>`;
}
const kacheln = frueher.map((v, i) => {
// Vergleichsfassung ist die zeitlich DAVOR liegende, also der nächste
// Eintrag in dieser (absteigend sortierten) Liste.
const davor = frueher[i + 1] || null;
const werte = verlaufLphWerte(v.project);
const werteDavor = davor ? verlaufLphWerte(davor.project) : [];
const wertVon = l => { const t = werteDavor.find(x => x.lph === l); return t ? t.wert : null; };
const lphHtml = werte.length === 0
? '<div class="timeline-leer">keine Angabe</div>'
: werte.map(w => {
const alt = wertVon(w.lph);
const geaendert = davor && alt !== null && alt !== w.wert;
return `<div class="timeline-lph${geaendert ? ' is-changed' : ''}">
<span>${esc(w.lph)}</span><b>${esc(w.wert)} %</b>
</div>`;
}).join('');
const text = statusPlainText(v.project.status);
const termine = verlaufTermine(v.project);
const termineDavor = davor ? verlaufTermine(davor.project) : { ende:'', meilenstein:'' };
const beteiligte = verlaufBeteiligte(v);
const zeilen = [];
if(termine.meilenstein){
const geaendert = davor && termineDavor.meilenstein && termineDavor.meilenstein !== termine.meilenstein;
zeilen.push(`<div class="timeline-termin${geaendert ? ' is-changed' : ''}">↦ ${esc(termine.meilenstein)}</div>`);
}
if(termine.ende){
const geaendert = davor && termineDavor.ende && termineDavor.ende !== termine.ende;
zeilen.push(`<div class="timeline-termin${geaendert ? ' is-changed' : ''}">Ende ${esc(termine.ende)}</div>`);
}
// "übernehmen" schreibt den Kommentar jener Woche ins Textfeld der
// aktuellen Sitzung. Diese Funktion lag bis zum Verdichten der Karte im
// Aufklapper "Frühere Sitzungen"; der ist entfallen, weil er dieselben
// Kommentare noch einmal zeigte. onmousedown + preventDefault wie dort:
// Sonst verliert das Textfeld zuerst den Fokus, das Blur-Netz speichert
// den Zwischenstand, und die Übernahme liefe gegen einen bereits
// geschriebenen Text.
// Ohne weekLabel gibt es keine Sitzung, in die übernommen werden könnte
// (Verlaufs-Dialog) — dann entfällt der Knopf, statt ins Leere zu greifen.
const uebernehmen = (locked || !text || !weekLabel) ? '' :
`<button type="button" class="timeline-take"
onmousedown="event.preventDefault();takeCommentFromHistory(this,'${esc(v.week.label)}')"
title="Diesen Kommentar in die aktuelle Sitzung übernehmen">übernehmen</button>`;
return `<article class="timeline-card">
<header class="timeline-card-head">
<span class="timeline-kw">${esc(v.week.label)}</span>
<span class="timeline-datum">${esc(v.week.date ? formatWeekDate(v.week.date) : '')}</span>
</header>
<div class="timeline-text${text ? '' : ' timeline-leer'}" title="${esc(text)}">${text ? esc(text) : 'Kein Kommentar'}</div>
${uebernehmen}
<div class="timeline-lph-list">${lphHtml}</div>
${zeilen.length ? `<div class="timeline-termine">${zeilen.join('')}</div>` : ''}
<footer class="timeline-people">${
beteiligte.length
? beteiligte.map(n => `<span class="timeline-chip">${esc(n)}</span>`).join('')
: '<span class="timeline-leer">keine Zuordnung</span>'
}</footer>
</article>`;
}).join('');
return `<details class="timeline-block">
<summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span>
<span>Verlauf</span>
<span class="comment-history-count">${frueher.length}</span>
</summary>
<div class="timeline-body">
<div class="timeline-rail">${kacheln}</div>
</div>
</details>`;
}