// -----------------------------
// Projektübersicht — allgemeine Helferfunktionen
// -----------------------------
// Diese Datei bündelt kleine, allgemein verwendbare Hilfsfunktionen (Escape-,
// ID-, Sortier- und Datumsfunktionen) und wird nach js/globals.js von beiden
// HTML-Einstiegspunkten geladen. Sie stellt ausschließlich globale Funktionen
// ohne eigenen Zustand bereit; genutzt wird sie praktisch von allen Modulen,
// u. a. 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.
// esc: einfache Escape-Funktion für Ausgabe in HTML
// 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,'&').replace(//g,'>')
.replace(/"/g,'"').replace(/'/g,''');
}
// uid: generiert eine einfache, ausreichend eindeutige ID
// Parameter: keine. Rückgabe: String aus Zeitstempel und Zufallsteil.
function uid(){ return Date.now().toString(36)+Math.random().toString(36).slice(2,7); }
// assignKey: Schlüssel für Projektzuordnungen (Person + Rolle getrennt)
// Parameter: personId (String), role (String). Rückgabe: kombinierter Schlüssel 'personId|role'.
function assignKey(personId, role){ return personId + '|' + role; }
// sortGroups: sortiert Gruppen-Namen nach GROUP_ORDER, unbekannte ans Ende
// Parameter: arr (Array von Gruppennamen, wird in-place sortiert). Rückgabe: dasselbe Array.
function sortGroups(arr){
return arr.sort((a,b)=>{
const ia=GROUP_ORDER.indexOf(a), ib=GROUP_ORDER.indexOf(b);
return (ia===-1?999:ia)-(ib===-1?999:ib);
});
}
// peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt.
// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle
// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung.
// Liegt im gemeinsamen Modul, weil board/load/pdf auf sie zugreifen.
function peopleByRole(target){
const m = {}; ROLES.forEach(r=>m[r]=[]);
const people = [];
if(Array.isArray(target)){
target.forEach(w => { if(w && Array.isArray(w.people)) people.push(...w.people); });
} else if(target && Array.isArray(target.people)){
people.push(...target.people);
} else if(Array.isArray(currentWeek()?.people)){
people.push(...currentWeek().people);
}
const seen = new Set();
people.forEach(p=>{
const personRoles = Array.isArray(p.role) ? p.role : [p.role];
personRoles.forEach(role=>{
if(!m[role]) m[role]=[];
const key = `${p.id}|${role}`;
if(!seen.has(key)){
seen.add(key);
m[role].push({person:p, role});
}
});
});
return m;
}
// weekSortKey: liefert einen nach Jahr und Kalenderwoche sortierbaren Schlüssel,
// sodass z.B. "KW 9 / 2026" vor "KW 10 / 2026" kommt.
// Parameter: label (String). Rückgabe: String (Jahr-KW) oder kleingeschriebenes Label.
function weekSortKey(label){
const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i);
if(m){
const kw = m[1].padStart(2,'0');
return `${m[2]}-${kw}`;
}
return String(label || '').toLowerCase();
}
// Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und
// Zuordnungen auch nach einem Neuladen stabil bleiben.
// Parameter: name (String). Rückgabe: String wie 'p_ab'.
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){
if(!dateString) return '';
const parts = dateString.split('-');
if(parts.length!==3) return dateString;
return `${parts[2]}.${parts[1]}.${parts[0]}`;
}
// ---------- Datumshilfsfunktionen (GANTT) ----------
// parseDateISO: parst einen ISO-Datumsstring (YYYY-MM-DD) in ein Date-Objekt (lokal)
// Parameter: d (String). Rückgabe: Date-Objekt oder null bei ungültigem Format.
function parseDateISO(d){
if(!d) return null;
const m = String(d).match(/^(\d{4})-(\d{2})-(\d{2})$/);
if(!m) return null;
const date = new Date(parseInt(m[1]), parseInt(m[2])-1, parseInt(m[3]));
if(isNaN(date.getTime())) return null;
return date;
}
// formatDateISO: formatiert ein Date-Objekt als ISO-Datum (YYYY-MM-DD)
// Parameter: d (Date). Rückgabe: String oder leer bei ungültigem Datum.
function formatDateISO(d){
if(!d || isNaN(d.getTime())) return '';
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,'0');
const day = String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${day}`;
}
// addDays: addiert n Tage zu einem Datum (ohne das Original zu verändern)
// Parameter: d (Date), n (Anzahl Tage). Rückgabe: neues Date-Objekt.
function addDays(d, n){
const res = new Date(d.getTime());
res.setDate(res.getDate() + 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){
const ms = b.getTime() - a.getTime();
return Math.round(ms / (1000 * 60 * 60 * 24));
}
// 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){
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){
if(!d || isNaN(d.getTime())) return '';
return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`;
}
// ISO-Kalenderwoche nach https://en.wikipedia.org/wiki/ISO_week_date
// Parameter: d (Date). Rückgabe: Kalenderwochennummer (1-53).
function getWeekNumber(d){
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const dayNum = date.getUTCDay() || 7;
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 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).
//
// DOMParser statt `div.innerHTML = html` — das ist der GANZE Sicherheitsgewinn
// dieser Funktion, nicht bloß Geschmackssache: innerHTML lädt eingebettete
// Ressourcen und führt deren Fehler-Handler aus, AUCH wenn das Element gar
// nicht im Dokument hängt. Ein `
` lief damit schon
// WÄHREND des Bereinigens — also auf jedem Anzeigepfad, der hier
// durchkommt, obwohl das Bild danach korrekt entfernt wurde. Der Server
// speichert Kommentare und Protokolltexte unverändert, ein angemeldetes
// Konto konnte solches HTML also über die API hinterlegen und damit Code in
// der Sitzung aller anderen ausführen. parseFromString() erzeugt dagegen ein
// inertes Dokument: kein Laden, kein Handler. Nachgemessen — mit innerHTML
// feuerte der Handler, mit DOMParser nicht, bei identischem Ergebnisbaum.
function sanitizeStatusHtml(html){
if(!html) return '';
// Die Bereinigung läuft vollständig IM inerten Dokument ab: Würden die
// Knoten vorher in ein Element des Hauptdokuments übernommen, zöge
// appendChild() ein adoptNode() nach sich — und ein dabei übernommenes
//
begänne wieder zu laden, also genau das, was hier
// verhindert werden soll.
const inert = new DOMParser().parseFromString(String(html), 'text/html');
const div = inert.body;
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