// ----------------------------- // 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 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