// ----------------------------- // Projektplan — Kritischer Pfad, Auto-Schedule & Mail-Link // ----------------------------- // Dieses Modul berechnet über computeCriticalPath die Aufgabe(n) auf dem // kritischen Pfad (längste Vorgänger-Kette) und hebt sie im GANTT hervor, // verschiebt bei autoScheduleTodos TODOs und Meilensteine automatisch hinter // ihre Vorgänger und erstellt bei todoMail einen mailto-Link an alle // zugeordneten Personen. Die Hervorhebung übergibt renderPlanGantt als // Option criticalPath an buildGanttHtml (plan-build-gantt.js); den Schalter // criticalPathActive hält der Zustand in window.PlanApp (plan-state.js). // // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // (plan-state.js). (function(){ const app = window.PlanApp; // ---------- Kritischer Pfad & Auto-Schedule ---------- // computeCriticalPath: liefert Array der TODO-IDs, die auf einem kritischen Pfad liegen // (längste Kette von Vorgänger-Abhängigkeiten, End-to-Start) window.computeCriticalPath = function(project){ if(!project || !project.plan) return []; const todos = (project.plan.todos || []).filter(t => t.start && t.end); const milestones = (project.plan.milestones || []).filter(m => m.date); if(todos.length === 0 && milestones.length === 0) return []; // Einheitliche Knoten: TODOs und Meilensteine (Dauer 1) const nodes = todos.map(t => ({ type: 'todo', id: t.id, obj: t, start: parseDateISO(t.start), end: parseDateISO(t.end), duration: Math.max(1, daysBetween(parseDateISO(t.start), parseDateISO(t.end)) + 1) })); milestones.forEach(m => { const d = parseDateISO(m.date); nodes.push({ type: 'milestone', id: 'm_' + m.id, obj: m, start: d, end: d, duration: 1 }); }); const nodeMap = new Map(nodes.map(n => [n.id, n])); // depsOf: liefert die Vorgänger-Liste eines Knotens (leeres Array, falls // keine vorhanden). function depsOf(n){ return Array.isArray(n.obj.dependsOn) ? n.obj.dependsOn : []; } // Frühester Start (ES) und frühestes Ende (EF) berechnen. // visiting-Set schützt vor Endlos-Rekursion bei zyklischen Abhängigkeiten. const es = {}, ef = {}; const esVisiting = new Set(); // computeES: berechnet rekursiv den frühesten Start (ES) und frühestes // Ende (EF) eines Knotens aus seinen Vorgängern. Liefert ES oder null // bei Zyklen (visiting-Set). function computeES(n){ if(es[n.id] !== undefined) return es[n.id]; if(esVisiting.has(n.id)){ es[n.id] = null; ef[n.id] = null; return null; } // Zyklus if(!n.start || !n.end){ es[n.id] = null; ef[n.id] = null; return null; } esVisiting.add(n.id); const deps = depsOf(n); let maxEF = null; deps.forEach(depId => { const dep = nodeMap.get(depId); if(!dep) return; const depStart = computeES(dep); if(!depStart) return; const depEnd = addDays(depStart, dep.duration - 1); if(maxEF === null || depEnd > maxEF) maxEF = depEnd; }); esVisiting.delete(n.id); es[n.id] = maxEF === null ? n.start : addDays(maxEF, 1); ef[n.id] = addDays(es[n.id], n.duration - 1); return es[n.id]; } nodes.forEach(n => computeES(n)); const validNodes = nodes.filter(n => es[n.id]); if(validNodes.length === 0) return []; const maxEF = validNodes.reduce((max, n) => { const d = ef[n.id]; return !max || d > max ? d : max; }, null); // Spätestes Ende (LF) und spätester Start (LS) rückwärts berechnen. // Wichtig: LF eines Knotens ist der Tag VOR dem spätesten Start (LS) // seines frühesten Nachfolgers – nicht vor dessen LF. const lf = {}, ls = {}; const lfVisiting = new Set(); // computeLF: berechnet rekursiv den spätesten Start (LS) und das späteste // Ende (LF) eines Knotens rückwärts aus seinen Nachfolgern. Liefert LF // oder null bei Zyklen (visiting-Set). function computeLF(n){ if(lf[n.id] !== undefined) return lf[n.id]; if(lfVisiting.has(n.id)){ lf[n.id] = null; ls[n.id] = null; return null; } // Zyklus if(!n.start || !n.end){ lf[n.id] = null; ls[n.id] = null; return null; } lfVisiting.add(n.id); const successors = validNodes.filter(succ => depsOf(succ).includes(n.id)); let minLF = null; successors.forEach(succ => { if(!computeLF(succ)) return; const succLS = ls[succ.id]; if(!succLS) return; const candidate = addDays(succLS, -1); if(minLF === null || candidate < minLF) minLF = candidate; }); lfVisiting.delete(n.id); lf[n.id] = minLF === null ? maxEF : minLF; ls[n.id] = addDays(lf[n.id], -(n.duration - 1)); return lf[n.id]; } validNodes.forEach(n => computeLF(n)); return validNodes.filter(n => n.type === 'todo' && es[n.id] && ls[n.id] && daysBetween(es[n.id], ls[n.id]) === 0).map(n => n.id); }; // toggleCriticalPath: schaltet die Hervorhebung des kritischen Pfads im // GANTT ein/aus (Wert in app.criticalPathActive), aktualisiert den // Toolbar-Button und zeigt über einen Toast an, wie viele Aufgaben auf // dem kritischen Pfad liegen. window.toggleCriticalPath = function(){ app.criticalPathActive = !app.criticalPathActive; const btn = document.getElementById('criticalPathBtn'); if(btn) btn.classList.toggle('active', app.criticalPathActive); renderPlanGantt(); if(app.criticalPathActive){ const path = computeCriticalPath(app.project); if(path.length === 0){ app.showToast('Kein kritischer Pfad ermittelt (zu wenig Aufgaben oder Abhängigkeiten).'); } else { app.showToast(path.length + ' Aufgabe(n) auf dem kritischen Pfad hervorgehoben.'); } } }; // autoScheduleTodos: verschiebt alle TODOs und Meilensteine, die von // anderen Elementen abhängen, automatisch auf den frühesten möglichen // Termin (einen Tag nach dem Ende des spätesten Vorgängers). Erkennt // zyklische Abhängigkeiten und verhindert Endlos-Rekursion; danach // Speichern, Neu-Rendern und Hinweis per Toast. window.autoScheduleTodos = function(){ if(app.isLocked()) return; const plan = app.ensurePlanLocal(); if(!Array.isArray(plan.todos) || plan.todos.length === 0) return; const todos = plan.todos.filter(t => t.start && t.end); const todoMap = new Map(todos.map(t => [t.id, t])); const milestones = Array.isArray(plan.milestones) ? plan.milestones.filter(m => m.date) : []; const milestoneMap = new Map(milestones.map(m => ['m_' + m.id, m])); // duration: liefert die Dauer einer TODO in Tagen (mindestens 1 Tag). function duration(t){ const s = parseDateISO(t.start), e = parseDateISO(t.end); return s && e ? Math.max(1, daysBetween(s, e) + 1) : 1; } // processed: bereits fertig eingeplante Knoten // visiting: aktuell im Rekursionspfad -> schützt vor Endlos-Rekursion bei Zyklen // moved: Knoten, deren Datum sich tatsächlich geändert hat const processed = new Set(); const visiting = new Set(); const moved = new Set(); let hasCycle = false; // scheduleTodo: plant eine TODO rekursiv ein – nutzt den spätesten // Vorgänger-Termin (TODO oder Meilenstein) als frühesten Start und // verschiebt Start/Ende entsprechend (Eintrag in moved bei Änderung). function scheduleTodo(t){ if(processed.has(t.id)) return; if(visiting.has(t.id)){ hasCycle = true; return; } const tStart = parseDateISO(t.start); if(!tStart){ processed.add(t.id); return; } visiting.add(t.id); const deps = Array.isArray(t.dependsOn) ? t.dependsOn : []; let earliest = tStart; deps.forEach(depId => { let depEnd = null; const depTodo = todoMap.get(depId); if(depTodo){ scheduleTodo(depTodo); depEnd = parseDateISO(depTodo.end); } else { const depMs = milestoneMap.get(depId); if(depMs) depEnd = parseDateISO(depMs.date); } if(!depEnd) return; const candidate = addDays(depEnd, 1); if(candidate > earliest) earliest = candidate; }); visiting.delete(t.id); const dur = duration(t); const newStart = formatDateISO(earliest); const newEnd = formatDateISO(addDays(earliest, dur - 1)); if(newStart !== t.start || newEnd !== t.end){ t.start = newStart; t.end = newEnd; moved.add(t.id); } processed.add(t.id); } // scheduleMilestone: plant einen Meilenstein ein, sofern er von anderen // TODOs/Meilensteinen abhängig ist, und verschiebt sein Datum auf den // frühestmöglichen Termin (Eintrag in moved bei Änderung). function scheduleMilestone(m){ const mId = 'm_' + m.id; if(processed.has(mId)) return; if(visiting.has(mId)){ hasCycle = true; return; } const deps = Array.isArray(m.dependsOn) ? m.dependsOn : []; let earliest = parseDateISO(m.date); if(!earliest){ processed.add(mId); return; } visiting.add(mId); deps.forEach(depId => { let depEnd = null; const depTodo = todoMap.get(depId); if(depTodo){ scheduleTodo(depTodo); depEnd = parseDateISO(depTodo.end); } else { const depMs = milestoneMap.get(depId); if(depMs && depMs.id !== m.id){ scheduleMilestone(depMs); depEnd = parseDateISO(depMs.date); } } if(!depEnd) return; const candidate = addDays(depEnd, 1); if(candidate > earliest) earliest = candidate; }); visiting.delete(mId); const newDate = formatDateISO(earliest); if(newDate !== m.date){ m.date = newDate; moved.add(mId); } processed.add(mId); } todos.forEach(t => scheduleTodo(t)); milestones.forEach(m => scheduleMilestone(m)); if(moved.size > 0){ app.savePlan(); renderPlanGantt(); app.showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Achtung: zyklische Abhängigkeit gefunden!' : '')); } else if(hasCycle){ app.showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.'); } else { app.showToast('Keine Änderungen nötig – alle Termine passen zu den Abhängigkeiten.'); } }; // ---------- Mail-Link für alle zugeordneten Personen ---------- // todoMail: baut aus den E-Mail-Adressen der zugeordneten Personen und // Projektinformationen einen mailto-Link zusammen und öffnet ihn. Zeigt // Warnungen, wenn keine E-Mail-Adressen vorhanden sind oder der Link sehr // lang wird. window.todoMail = function(){ if(!app.project || !app.week){ console.warn('Kein aktuelles Projekt/keine aktuelle Woche gefunden – Mail kann nicht erstellt werden.'); return; } const assignees = projectAssignees(app.project, app.week); const empfaengerListe = assignees .map(a => a.email || (a.person && a.person.email) || '') .filter(email => email && email.trim() !== ''); const eindeutigeEmpfaenger = [...new Set(empfaengerListe)]; if(eindeutigeEmpfaenger.length === 0){ console.warn('Keine gültigen E-Mail-Adressen der zugeordneten Personen gefunden.'); return; } const empfaenger = eindeutigeEmpfaenger.join(','); const titleEl = document.getElementById('planTitle'); const projektname = titleEl ? titleEl.textContent.trim() : 'Projekt'; const seitenLink = window.location.href; const betreff = 'Neue Aufgaben im Projekt ' + projektname; const nachricht = 'Guten Tag,\n\n' + 'es wurden neue Aufgaben im Projekt ' + projektname + ' hinzugefügt.\n\n' + 'Link zum Projekt:\n' + seitenLink + '\n\n' + 'Mit freundlichen Grüßen'; const mailtoLink = 'mailto:' + encodeURIComponent(empfaenger).replace(/%2C/g, ',') + '?subject=' + encodeURIComponent(betreff) + '&body=' + encodeURIComponent(nachricht); if(mailtoLink.length > 1800){ console.warn('mailto-Link ist sehr lang (' + mailtoLink.length + ' Zeichen) – manche Mail-Clients könnten ihn abschneiden.'); } window.location.href = mailtoLink; }; })();