Files
projektuebersicht/public/js/plan/plan-schedule.js
T

288 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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;
};
})();