297 lines
12 KiB
JavaScript
297 lines
12 KiB
JavaScript
// -----------------------------
|
||
// 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){
|
||
// Den Meilenstein ZUERST einplanen: Vorher wurde gegen sein ALTES
|
||
// Datum gerechnet, und weil er erst nach allen Aufgaben verschoben
|
||
// wurde, konnte eine Aufgabe vor ihrem eigenen Vorgänger beginnen.
|
||
scheduleMilestone(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);
|
||
}
|
||
|
||
// Meilensteine zuerst: scheduleTodo() ruft scheduleMilestone() für seine
|
||
// Vorgänger zwar selbst auf, aber ein Meilenstein OHNE Nachfolger-Aufgabe
|
||
// würde sonst erst am Ende verschoben.
|
||
milestones.forEach(m => scheduleMilestone(m));
|
||
todos.forEach(t => scheduleTodo(t));
|
||
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;
|
||
};
|
||
})(); |