Files
projektuebersicht/public/js/store.js
T
erikandClaude Opus 5.5 4c2e04e78b Kleine Verbesserungen und Teams, Outlook Integration
- Outlook: Kalender-Abo je Konto (eigene Aufgaben, Meilensteine,
  Abwesenheiten als .ics), einzurichten unter Einstellungen
- Teams: Nachricht in einen Kanal bei neuen Aufgaben, mit Link direkt
  auf die Aufgabe; einzurichten unter Admin → Teams
- Sitzungen nach Bedarf laden: beim Start nur die aktuelle und drei
  davor, im Verlauf „Weitere laden“ in Fünferschritten
- Termin aus Mail: Wahl Aufgabe/Meilenstein, Notiz auch bei
  Meilensteinen, nur aktiv mit eingerichteter KI
- Kalender zeigt die KW, auch statt der Browser-Datumsfelder
- Pfeile bei den Abwesenheiten, kleinere Server-Korrekturen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:23:02 +01:00

971 lines
48 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.
// -----------------------------
// Projektübersicht — Daten laden, Speichern
// -----------------------------
// Diese Datei bildet die Schnittstelle zum Server: Sie lädt beim Start alle
// (firmenweit globalen, nicht mehr abteilungsgetrennten) Sitzungen und speichert
// den kompletten Datenbestand (alle Wochen + globale Personenliste). Die Daten
// liegen serverseitig in einer SQLite-Datenbank und werden als strukturierte
// JSON-Objekte über die API ausgetauscht (keine CSV-Strings). Sie wird von
// `App.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt
// die globalen Funktionen `save()`, `syncAllToServer()`, `load()`, `seed()`
// und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`, `js/board/board.js`,
// `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das
// Speichern ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms
// debounced.
// save: plant einen (debounced) Sync. Standardmäßig wird die aktuell gewählte
// Woche als geändert markiert, da sich die meisten Bearbeitungen auf die
// aktuelle Sitzung beziehen. Optionen:
// { week:false } – aktuelle Woche NICHT markieren (z. B. reine Personen-Aktionen)
function save(opts = {}){
if(opts.week !== false){
const w = currentWeek();
if(w) markWeekDirty(w.label);
}
scheduleSessionSync();
}
// Dirty-Tracking: merkt sich, welche Wochen seit dem letzten Sync lokal
// geändert wurden. Gespeichert wird nur das, was sich wirklich geändert hat
// (statt wie früher immer alle Wochen). Die globale Personenliste hat seit der
// Ablösung von people/person_roles/person_absences keinen eigenen Schreibpfad
// mehr (Projektbeteiligte werden über die Benutzerverwaltung gepflegt).
let dirtyWeeks = new Set();
// weekChangeCounter: zählt je Woche die Änderungen mit. syncAllToServer merkt
// sich den Stand beim Absenden und räumt die Dirty-Markierung nur auf, wenn er
// sich bis zur Antwort nicht verändert hat. Ohne diesen Zähler verlor eine
// Änderung, die WÄHREND eines laufenden Speichervorgangs entsteht, ihre
// Markierung: Der eingereihte Nachlauf-Sync fand dann nichts mehr zu senden,
// und das Getippte stand nur noch im Browser.
const weekChangeCounter = new Map();
// syncProblemTimer: Wiederholungsversuch nach einem fehlgeschlagenen Speichern.
let syncProblemTimer = null;
// In-Flight-Schutz für syncAllToServer: läuft bereits ein Sync, wird kein
// zweiter gestartet (sonst senden zwei Syncs dieselbe Woche mit gleicher
// Revisions-Basis und der zweite bekäme einen scheinbaren 409-Konflikt).
// Stattdessen wird ein Nachlauf-Sync eingereiht und nach Abschluss ausgeführt.
let syncInFlight = false;
let syncQueued = false;
// showSyncProblem: blendet unten rechts ein Band ein, wenn das Speichern
// fehlschlägt. Vorher stand der Fehler ausschließlich in der Browser-Konsole —
// der Nutzer arbeitete ahnungslos weiter, während seit Stunden nichts mehr
// gespeichert wurde. Parameter: message (Text) oder null zum Ausblenden.
function showSyncProblem(message){
let el = document.getElementById('syncProblemBar');
if(!message){
if(el) el.style.display = 'none';
return;
}
if(!el){
el = document.createElement('div');
el.id = 'syncProblemBar';
el.className = 'sync-problem-bar';
document.body.appendChild(el);
}
el.textContent = message;
el.style.display = '';
}
// scheduleSyncRetry: versucht ein fehlgeschlagenes Speichern nach 20 Sekunden
// erneut. Ohne das blieb eine Woche nach einem Netzaussetzer dauerhaft
// ungespeichert, bis der Nutzer zufällig etwas anderes bearbeitete.
function scheduleSyncRetry(){
if(syncProblemTimer || dirtyWeeks.size === 0) return;
syncProblemTimer = setTimeout(() => {
syncProblemTimer = null;
if(dirtyWeeks.size > 0) syncAllToServer();
}, 20000);
}
// scheduleSessionSync: plant den Speichervorgang für alle Wochen und Personen
// über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender
// Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden.
function scheduleSessionSync(){
if(saveSyncTimer){ clearTimeout(saveSyncTimer); }
saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500);
}
// syncAllToServer: schreibt nur die seit dem letzten Sync geänderten Wochen
// zum Server. Konflikte (409) auf einer Woche werden über
// handleWeekConflict() gemeldet. Fehlerhaft gespeicherte Wochen bleiben dirty,
// damit der nächste Sync sie erneut versucht; nur erfolgreich gespeicherte
// werden aus dem Dirty-Satz entfernt.
function syncAllToServer(){
if(syncInFlight){ syncQueued = true; return; }
syncInFlight = true;
const jobs = [];
const weekLabels = Array.from(dirtyWeeks);
let problem = '';
weekLabels.forEach(label => {
// Die Planung-Sandbox steckt bewusst nicht in db.weeks (siehe
// db.sandboxWeek, oben in load()) — ohne diesen Zweig würde jede
// Sandbox-Änderung hier lautlos verworfen (dirtyWeeks.delete ohne
// jemals zu speichern), weil db.weeks.find() sie nie findet.
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : db.weeks.find(x => x.label === label);
if(!w){ dirtyWeeks.delete(label); return; }
// Stand der Änderungen zum Zeitpunkt des Absendens merken (siehe
// weekChangeCounter): Nur wenn er bei der Antwort unverändert ist, sind
// wirklich alle Änderungen dieser Woche beim Server angekommen.
const sentGeneration = weekChangeCounter.get(label) || 0;
// Nur die geänderten Projekte schicken, wenn sich an der Sitzung selbst
// nichts geändert hat (geaenderteProjekte(), siehe unten). Sonst — oder
// ohne bekannten Server-Stand — wie bisher die ganze Sitzung.
const delta = geaenderteProjekte(wochenBasis.get(label), w);
if(delta && delta.length === 0){
// Nichts zu senden (z. B. eine Änderung, die wieder zurückgenommen wurde).
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
return;
}
if(delta){
const gesendeteProjekte = delta.map(p => JSON.parse(JSON.stringify(p)));
jobs.push(sendProjectsToServer(label, gesendeteProjekte)
.then(res => {
console.log('Projekte gespeichert:', label, gesendeteProjekte.length);
const basis = wochenBasis.get(label);
gesendeteProjekte.forEach(gp => {
const rev = (res.projekte || {})[gp.id] || res.updatedAt;
gp.updatedAt = rev;
const lokal = (w.projects || []).find(x => x.id === gp.id);
if(lokal) lokal.updatedAt = rev;
// Basis nur für die gesendeten Projekte nachziehen — fremde
// Änderungen, die während des Speicherns eintrafen, stehen dort schon.
if(basis){
const i = basis.projects.findIndex(x => x.id === gp.id);
if(i >= 0) basis.projects[i] = gp;
}
});
revisionUebernehmen(w, res.updatedAt);
konfliktVersuche.delete(label);
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
})
.catch(err => { problem = speicherFehler(label, w, err) || problem; }));
return;
}
// Genau dieser Stand geht an den Server — nach Erfolg ist er die neue
// Basis für ein späteres Zusammenführen (wochenBasis).
const gesendet = wocheKopie(w);
jobs.push(sendWeekToServer(w)
.then(res => {
if(res && res.ok){
console.log('Sitzung gespeichert:', w.label);
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// nicht fälschlich als Konflikt gemeldet werden.
if(res.updatedAt) w.updatedAt = res.updatedAt;
gesendet.updatedAt = w.updatedAt;
// Das ganze Speichern setzt am Server JEDES Projekt der Sitzung auf
// diese Revision (projects.updated_at) — hier nachziehen, sonst
// scheiterte das nächste Speichern einzelner Projekte als Konflikt.
(w.projects || []).forEach(x => { x.updatedAt = w.updatedAt; });
(gesendet.projects || []).forEach(x => { x.updatedAt = w.updatedAt; });
wochenBasis.set(label, gesendet);
konfliktVersuche.delete(label);
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
} else {
problem = 'Sitzung "' + w.label + '" konnte nicht gespeichert werden.';
console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler');
}
})
.catch(err => { problem = speicherFehler(label, w, err) || problem; }));
});
Promise.all(jobs).then(() => {
syncInFlight = false;
showSyncProblem(problem || null);
if(problem) scheduleSyncRetry();
if(syncQueued){ syncQueued = false; syncAllToServer(); }
});
}
// speicherFehler: gemeinsame Fehlerbehandlung beider Speicherwege. Rückgabe:
// Text für das Fehlerband oder '' (Konflikt: wird zusammengeführt).
function speicherFehler(label, w, err){
if(err && err.conflict){
// Jemand anderes hat die Sitzung bzw. das Projekt inzwischen gespeichert:
// nicht verwerfen, sondern zusammenführen und erneut speichern.
zusammenfuehrenUndSpeichern(label, w, err);
return '';
}
if(err && err.status === 403){
// Fehlendes Recht (Rollen & Rechte): Ein erneuter Versuch hilft nicht.
// Änderung verwerfen, Hinweis zeigen und den Stand vom Server holen.
dirtyWeeks.delete(label);
zeigeHinweis((err.message || 'Keine Berechtigung.') + ' Die Änderung wurde nicht gespeichert.');
reloadWeekFromServer(label);
return '';
}
if(err && err.status === 401){
// Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still
// weiterzuarbeiten und alles Weitere zu verlieren.
if(typeof showLoginOverlay === 'function') showLoginOverlay();
console.error('Speichern abgelehnt (nicht angemeldet):', label);
return 'Nicht mehr angemeldet — bitte neu anmelden, sonst gehen Änderungen verloren.';
}
console.error('Sitzung konnte nicht gespeichert werden:', label, (err && err.message) || err);
// Label bleibt dirty -> der nächste Sync versucht es erneut.
return 'Speichern fehlgeschlagen (' + (err && err.message ? err.message : 'unbekannt') + ') — neuer Versuch in 20 Sekunden.';
}
// geaenderteProjekte: vergleicht eine Sitzung mit ihrem zuletzt bekannten
// Server-Stand. Rückgabe: die geänderten Projekte (Array, evtl. leer), wenn
// sich NUR Projektinhalte geändert haben; null, wenn die ganze Sitzung
// gespeichert werden muss — Teilnehmer, Anwesenheit, Sperre, Datum, ein
// angelegtes, gelöschtes oder umsortiertes Projekt, oder kein Server-Stand.
function geaenderteProjekte(basis, w){
if(!basis || !w) return null;
const rahmen = x => { const k = Object.assign({}, x); delete k.projects; delete k.updatedAt; return k; };
if(!gleich(rahmen(basis), rahmen(w))) return null;
const bp = basis.projects || [], wp = w.projects || [];
if(bp.length !== wp.length || bp.some((p, i) => p.id !== wp[i].id)) return null;
return wp.filter((p, i) => !gleich(p, bp[i]));
}
// revisionUebernehmen: setzt die Revision einer Sitzung (lokal und Basis),
// aber nie zurück — Antworten und Ereignisse können sich überholen.
function revisionUebernehmen(w, rev){
if(!w || !rev) return;
if(!w.updatedAt || rev > w.updatedAt) w.updatedAt = rev;
const basis = wochenBasis.get(w.label);
if(basis && (!basis.updatedAt || rev > basis.updatedAt)) basis.updatedAt = rev;
}
// markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für
// Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock).
function markWeekDirty(label){
if(typeof label !== 'string' || !label) return;
dirtyWeeks.add(label);
weekChangeCounter.set(label, (weekChangeCounter.get(label) || 0) + 1);
}
// ===========================================================================
// Gleichzeitiges Arbeiten: Zusammenführen statt Verwerfen
// ---------------------------------------------------------------------------
// Eine Sitzung wird immer als Ganzes gespeichert (POST /save-week mit
// Revisionsstand). Hat jemand anderes sie inzwischen gespeichert, antwortet
// der Server mit 409. Früher verwarf der Browser daraufhin die eigenen
// Änderungen mit einem alert() — bei zwei Personen in derselben Sitzung
// passierte das ständig, auch wenn sie an verschiedenen Projekten arbeiteten.
//
// Jetzt merkt sich der Browser je Sitzung den Stand, den er zuletzt vom
// Server kennt (wochenBasis). Bei einem 409 holt er den aktuellen Stand und
// führt drei Fassungen zusammen (dreiwegeZusammenfuehren(): Basis, eigene,
// fremde): Was nur eine Seite geändert hat, wird übernommen — bis hinunter zu
// einzelnen Feldern, Projekten, Aufgaben/Meilensteinen (über ihre id),
// Kommentaren je Gewerk und der Anwesenheit je Person. Nur wenn beide
// dasselbe Feld unterschiedlich geändert haben, gilt die eigene (jüngste)
// Fassung, und ein kurzer Hinweis nennt das Projekt. Danach wird erneut
// gespeichert. Das Ergebnis wird IN die vorhandenen Objekte geschrieben
// (inPlaceUebernehmen()), damit z. B. der Projektbereich, der eine Referenz
// auf sein Projekt hält (window.PlanApp), weiter auf dasselbe Objekt zeigt.
//
// Zusätzlich gleicht wochenLiveAbgleich() alle 20 Sekunden ab: Hat sich eine
// Sitzung am Server geändert und gibt es hier nichts Ungespeichertes, werden
// die fremden Änderungen übernommen — außer während jemand gerade tippt oder
// ein Dialog offen ist (dann beim nächsten Durchlauf).
// ===========================================================================
const wochenBasis = new Map(); // label -> zuletzt bekannter Server-Stand
const konfliktVersuche = new Map(); // label -> Zahl der Zusammenführungen in Folge
function wocheKopie(w){ return JSON.parse(JSON.stringify(w)); }
function basisMerken(w){ if(w && w.label) wochenBasis.set(w.label, wocheKopie(w)); }
function istObjekt(x){ return !!x && typeof x === 'object' && !Array.isArray(x); }
function istIdListe(a){ return Array.isArray(a) && a.every(x => istObjekt(x) && x.id !== undefined && x.id !== null && x.id !== ''); }
// gleich: inhaltlicher Vergleich, unabhängig von der Reihenfolge der Felder
// (inPlaceUebernehmen() lässt vorhandene Felder an ihrer Stelle und hängt
// neue an — JSON.stringify allein hielte dann Gleiches für verschieden, und
// zwei Browser schickten sich über die Live-Ereignisse endlos dieselben
// Projekte zu). undefined-Felder zählen wie fehlende.
function stabilerText(x){
if(Array.isArray(x)) return '[' + x.map(v => v === undefined ? 'null' : stabilerText(v)).join(',') + ']';
if(x && typeof x === 'object'){
return '{' + Object.keys(x).filter(k => x[k] !== undefined).sort()
.map(k => JSON.stringify(k) + ':' + stabilerText(x[k])).join(',') + '}';
}
return x === undefined ? 'null' : JSON.stringify(x);
}
function gleich(a, b){ return a === b || stabilerText(a) === stabilerText(b); }
// dreiwegeZusammenfuehren: Basis, eigene und fremde Fassung → Ergebnis.
// konflikte sammelt die Stellen, an denen beide Seiten dasselbe
// unterschiedlich geändert haben (dort gewinnt die eigene Fassung).
// Parameter: basis, eigen, fremd, pfad (Text für Hinweise), konflikte (Array).
function dreiwegeZusammenfuehren(basis, eigen, fremd, pfad, konflikte){
if(gleich(eigen, fremd)) return eigen;
if(basis !== undefined && gleich(eigen, basis)) return fremd;
if(basis !== undefined && gleich(fremd, basis)) return eigen;
if(istObjekt(eigen) && istObjekt(fremd)){
const b = istObjekt(basis) ? basis : {};
const ergebnis = {};
new Set([...Object.keys(fremd), ...Object.keys(eigen)]).forEach(k => {
const inE = k in eigen, inF = k in fremd, inB = k in b;
if(inE && inF){ ergebnis[k] = dreiwegeZusammenfuehren(inB ? b[k] : undefined, eigen[k], fremd[k], pfad, konflikte); return; }
if(inE){ // fremd fehlt: dort gelöscht (wenn in der Basis) oder hier neu
if(inB && gleich(eigen[k], b[k])) return; // fremd hat gelöscht, hier unverändert
if(inB) konflikte.push(pfad); // hier geändert, dort gelöscht → behalten
ergebnis[k] = eigen[k]; return;
}
// eigen fehlt
if(inB && gleich(fremd[k], b[k])) return; // hier gelöscht, dort unverändert
if(inB){ konflikte.push(pfad); return; } // hier gelöscht, dort geändert → gelöscht lassen
ergebnis[k] = fremd[k]; // dort neu
});
return ergebnis;
}
if(istIdListe(eigen) && istIdListe(fremd)){
const bListe = istIdListe(basis) ? basis : [];
const bMap = new Map(bListe.map(x => [String(x.id), x]));
const eMap = new Map(eigen.map(x => [String(x.id), x]));
const fMap = new Map(fremd.map(x => [String(x.id), x]));
// Reihenfolge: hat nur diese Seite umsortiert, gilt ihre, sonst die fremde;
// Neues der anderen Seite wird an seiner Stelle angehängt.
const ids = l => l.map(x => String(x.id));
const eigeneReihe = !gleich(ids(eigen), ids(bListe)) && gleich(ids(fremd), ids(bListe));
const reihe = eigeneReihe ? ids(eigen) : ids(fremd);
(eigeneReihe ? ids(fremd) : ids(eigen)).forEach(id => { if(!reihe.includes(id)) reihe.push(id); });
const ergebnis = [];
reihe.forEach(id => {
const e = eMap.get(id), f = fMap.get(id), b = bMap.get(id);
const name = (e || f || {}).name || (e || f || {}).title || id;
const unterpfad = pfad ? pfad : name;
if(e && f){ ergebnis.push(dreiwegeZusammenfuehren(b, e, f, unterpfad, konflikte)); return; }
if(e){ if(b && gleich(e, b)) return; if(b) konflikte.push(unterpfad); ergebnis.push(e); return; }
if(f){ if(b && gleich(f, b)) return; if(b){ konflikte.push(unterpfad); return; } ergebnis.push(f); }
});
return ergebnis;
}
konflikte.push(pfad);
return eigen;
}
// inPlaceUebernehmen: schreibt quelle in das bestehende Objekt ziel — Objekte
// mit gleicher id (Projekte, Aufgaben, Meilensteine, Personen) bleiben
// dieselben Objekte, damit offene Ansichten ihre Referenzen behalten.
function inPlaceUebernehmen(ziel, quelle){
Object.keys(ziel).forEach(k => { if(!(k in quelle)) delete ziel[k]; });
Object.keys(quelle).forEach(k => {
const z = ziel[k], q = quelle[k];
if(istObjekt(z) && istObjekt(q)) inPlaceUebernehmen(z, q);
else if(istIdListe(z) && istIdListe(q)){
const alt = new Map(z.map(x => [String(x.id), x]));
const neu = q.map(x => { const o = alt.get(String(x.id)); if(o){ inPlaceUebernehmen(o, x); return o; } return x; });
z.length = 0; neu.forEach(x => z.push(x));
} else ziel[k] = q;
});
}
// wocheVomServer: holt den aktuellen Stand EINER Sitzung (über /load-all,
// die Sandbox steckt dort in sandboxPlans). Rückgabe: Promise<Woche|null>.
// Seit GET /week nur diese eine Sitzung statt aller über /load-all.
function wocheVomServer(label){
return fetch(baseUrl() + '/week?label=' + encodeURIComponent(label))
.then(r => {
if(r.status === 404) return null;
if(!r.ok) throw new Error('Server HTTP ' + r.status);
return r.json().then(data => {
const w = data && data.week;
if(!w) return null;
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
if(label === SANDBOX_WEEK_LABEL) return { label, projects: w.projects, updatedAt: w.updatedAt || '' };
return w;
});
});
}
// zusammenfuehren: führt eine lokale Woche mit einem frischen Server-Stand
// zusammen und schreibt das Ergebnis in die lokale Woche. Rückgabe: Array
// der Projekte/Stellen mit echtem Konflikt.
function zusammenfuehren(lokal, frisch){
const basis = wochenBasis.get(lokal.label);
const ohneRev = w => { const k = wocheKopie(w); delete k.updatedAt; return k; };
const konflikte = [];
let ergebnis;
if(lokal.label === SANDBOX_WEEK_LABEL){
// Die Sandbox: nur die Terminpläne zusammenführen, der Rest ist lokal gebaut.
ergebnis = Object.assign({}, ohneRev(lokal), {
projects: dreiwegeZusammenfuehren(basis ? basis.projects : undefined, wocheKopie(lokal.projects), frisch.projects, '', konflikte)
});
} else {
ergebnis = dreiwegeZusammenfuehren(basis ? ohneRev(basis) : undefined, ohneRev(lokal), ohneRev(frisch), '', konflikte);
}
inPlaceUebernehmen(lokal, ergebnis);
lokal.updatedAt = frisch.updatedAt;
const serverStand = lokal.label === SANDBOX_WEEK_LABEL ? Object.assign(wocheKopie(lokal), { projects: wocheKopie(frisch.projects) }) : wocheKopie(frisch);
wochenBasis.set(lokal.label, serverStand);
return [...new Set(konflikte.filter(Boolean))];
}
// zusammenfuehrenUndSpeichern: Antwort auf einen 409 beim Speichern.
function zusammenfuehrenUndSpeichern(label, lokal, err){
const n = (konfliktVersuche.get(label) || 0) + 1;
konfliktVersuche.set(label, n);
if(n > 3){
// Ungewöhnlich: dreimal in Folge überholt. Dann der alte, sichere Weg.
konfliktVersuche.delete(label);
dirtyWeeks.delete(label);
handleWeekConflict(lokal, err);
return;
}
wocheVomServer(label).then(frisch => {
if(!frisch){
// Die Sitzung wurde am Server gelöscht — hier nicht wiederbeleben.
dirtyWeeks.delete(label);
handleWeekConflict(lokal, err);
return;
}
const konflikte = zusammenfuehren(lokal, frisch);
markWeekDirty(label);
ansichtAuffrischen();
if(konflikte.length){
zeigeHinweis('Gleichzeitig geändert: ' + konflikte.slice(0, 3).join(', ')
+ (konflikte.length > 3 ? ' …' : '') + '. Ihre Fassung wurde übernommen.');
}
scheduleSessionSync();
}).catch(e => {
console.error('Zusammenführen fehlgeschlagen:', e);
showSyncProblem('Speichern fehlgeschlagen — neuer Versuch in 20 Sekunden.');
scheduleSyncRetry();
});
}
// ansichtAuffrischen: zeichnet die offene Ansicht nach übernommenen Daten neu.
function ansichtAuffrischen(){
if(typeof renderAll === 'function') renderAll();
if(typeof currentTab !== 'undefined' && currentTab === 'plan'){
if(typeof renderPlanGantt === 'function') renderPlanGantt();
} else if(typeof currentTab !== 'undefined' && currentTab === 'wand'){
if(typeof renderWandboard === 'function') renderWandboard();
}
}
// zeigeHinweis: kurze, nicht blockierende Meldung unten rechts (8 s).
let hinweisTimer = null;
function zeigeHinweis(text){
let el = document.getElementById('syncHinweis');
if(!el){
el = document.createElement('div');
el.id = 'syncHinweis';
el.className = 'sync-problem-bar sync-hinweis';
document.body.appendChild(el);
}
el.textContent = text;
el.style.display = '';
clearTimeout(hinweisTimer);
hinweisTimer = setTimeout(() => { el.style.display = 'none'; }, 8000);
}
// wirdGeradeBearbeitet: tippt jemand oder ist ein Dialog offen? Dann keine
// fremden Änderungen einspielen — ein Neuzeichnen würde Eingaben verwerfen.
function wirdGeradeBearbeitet(){
const a = document.activeElement;
if(a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName))) return true;
return [...document.querySelectorAll('.modal-bg')].some(m => m.style.display && m.style.display !== 'none');
}
// wochenLiveAbgleich: übernimmt Änderungen anderer, ohne dass jemand neu
// laden muss. Läuft alle 20 Sekunden; die Wandtafel lädt ohnehin selbst.
let liveAbgleichLaeuft = false;
function wochenLiveAbgleich(){
if(liveAbgleichLaeuft || !currentUser || !hatRecht('app')) return;
if(syncInFlight || dirtyWeeks.size > 0 || wirdGeradeBearbeitet()) return;
if(!Array.isArray(db.weeks) || db.weeks.length === 0) return;
liveAbgleichLaeuft = true;
fetch(baseUrl() + '/week-revisionen')
.then(r => r.ok ? r.json() : null)
.then(data => {
if(!data || !data.ok) return;
const rev = data.revisionen || {};
const lokal = db.weeks.concat(db.sandboxWeek ? [db.sandboxWeek] : []);
const geaendert = lokal.filter(w => w.label in rev && rev[w.label] && rev[w.label] !== (w.updatedAt || ''));
if(!geaendert.length) return;
return Promise.all(geaendert.map(w => wocheVomServer(w.label).then(frisch => ({ w, frisch })))).then(liste => {
// Zwischenzeitlich doch etwas bearbeitet? Dann beim nächsten Mal.
if(dirtyWeeks.size > 0 || syncInFlight || wirdGeradeBearbeitet()) return;
liste.forEach(({ w, frisch }) => { if(frisch) zusammenfuehren(w, frisch); });
ansichtAuffrischen();
});
})
.catch(() => { /* still — der nächste Durchlauf versucht es wieder */ })
.finally(() => { liveAbgleichLaeuft = false; });
}
// Nur noch Rückfall: Änderungen kommen sofort über die Live-Ereignisse
// (liveVerbinden(), unten). Der Abgleich fängt ab, was während einer
// Verbindungsunterbrechung verpasst wurde, und läuft zusätzlich direkt nach
// jedem Wiederverbinden.
setInterval(wochenLiveAbgleich, 60000);
// ===========================================================================
// Live-Ereignisse (GET /ereignisse, Server-Sent Events)
// ---------------------------------------------------------------------------
// Sobald jemand speichert, meldet der Server es allen anderen Browsern —
// bei einzelnen Projekten gleich mit deren neuem Stand, sodass nur diese
// übertragen werden. Eigene ungespeicherte Änderungen an demselben Projekt
// bleiben erhalten (Dreiwege-Zusammenführung wie beim Speicherkonflikt).
// Während jemand tippt oder ein Dialog offen ist, werden die Daten
// übernommen, aber erst danach neu gezeichnet (ansichtNachBearbeitung()).
// ===========================================================================
let liveQuelle = null;
let liveGetrennt = false;
let liveNeuzeichnenAusstehend = false;
function liveVerbinden(){
if(liveQuelle || typeof EventSource === 'undefined' || !currentUser) return;
liveQuelle = new EventSource(baseUrl() + '/ereignisse?client=' + encodeURIComponent(CLIENT_ID));
liveQuelle.onmessage = ev => {
let d; try { d = JSON.parse(ev.data); } catch(e){ return; }
liveEreignis(d);
};
liveQuelle.onopen = () => {
// Nach einer Unterbrechung nachholen, was in der Zeit passiert ist.
if(liveGetrennt){ liveGetrennt = false; wochenLiveAbgleich(); }
};
liveQuelle.onerror = () => { liveGetrennt = true; }; // EventSource verbindet selbst neu
}
function liveTrennen(){
if(liveQuelle){ liveQuelle.close(); liveQuelle = null; }
}
// Beim Verlassen der Seite die Verbindung sauber schließen.
window.addEventListener('pagehide', liveTrennen);
function liveEreignis(d){
if(!d || !d.typ) return;
if(d.typ === 'projekte') return liveProjekte(d);
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
if(d.typ === 'neu') return liveAllesNeu();
if(d.typ === 'personen') return personenNachladen();
}
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
// Abwesenheits-Änderung, hier oder in einem anderen Browser) und neu zeichnen.
// Rückgabe: Promise.
function personenNachladen(){
return fetch(baseUrl() + '/personen', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(d => {
if(!d || !Array.isArray(d.people)) return;
db.globalPeople = d.people;
ansichtNachBearbeitung();
})
.catch(err => console.error('Personen nachladen fehlgeschlagen:', err));
}
// liveProjekte: übernimmt die mitgeschickten Projekte einer Sitzung.
function liveProjekte(d){
const w = d.label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === d.label);
if(!w) return;
const basis = wochenBasis.get(d.label);
const konflikte = [];
let eigeneOffen = false;
for(const fp of (d.projekte || [])){
const lp = (w.projects || []).find(x => x.id === fp.id);
if(!lp) return liveWocheNachladen(d.label); // Projekt hier unbekannt: ganze Sitzung holen
const bp = basis ? (basis.projects || []).find(x => x.id === fp.id) : undefined;
if(bp && gleich(lp, bp)){
inPlaceUebernehmen(lp, JSON.parse(JSON.stringify(fp)));
} else {
// Hier gibt es noch ungespeicherte Änderungen an diesem Projekt.
const erg = dreiwegeZusammenfuehren(bp, JSON.parse(JSON.stringify(lp)), fp, lp.name || '', konflikte);
inPlaceUebernehmen(lp, erg);
lp.updatedAt = fp.updatedAt;
eigeneOffen = true;
}
if(basis){
const i = basis.projects.findIndex(x => x.id === fp.id);
if(i >= 0) basis.projects[i] = JSON.parse(JSON.stringify(fp));
}
}
revisionUebernehmen(w, d.updatedAt);
if(eigeneOffen){ markWeekDirty(d.label); scheduleSessionSync(); }
if(konflikte.length){
zeigeHinweis('Gleichzeitig geändert: ' + [...new Set(konflikte.filter(Boolean))].slice(0, 3).join(', ') + '. Ihre Fassung wurde übernommen.');
}
ansichtNachBearbeitung();
}
// liveWocheNachladen: eine ganze Sitzung hat sich geändert — nur sie holen.
function liveWocheNachladen(label, geloescht){
if(geloescht || !label) return liveAllesNeu();
// Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach.
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label);
if(!w){
// Eine ältere, hier nicht geladene Sitzung (z. B. entsperrt): nur ihren
// Eintrag in der Sitzungsliste nachziehen, nicht alles neu laden.
const kurz = (db.wochenListe || []).find(x => x.label === label);
if(kurz) return wocheVomServer(label).then(f => { if(f) Object.assign(kurz, sitzungKurz(f)); ansichtNachBearbeitung(); }).catch(() => {});
return liveAllesNeu();
}
wocheVomServer(label).then(frisch => {
if(!frisch) return liveAllesNeu();
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
const hatteEigene = dirtyWeeks.has(label);
const konflikte = zusammenfuehren(w, frisch);
if(hatteEigene){ markWeekDirty(label); scheduleSessionSync(); }
if(konflikte.length){
zeigeHinweis('Gleichzeitig geändert: ' + konflikte.slice(0, 3).join(', ') + '. Ihre Fassung wurde übernommen.');
}
ansichtNachBearbeitung();
}).catch(e => console.error('Live-Nachladen fehlgeschlagen:', e));
}
// liveAllesNeu: neue Sitzung, gelöschte Sitzung oder eingespieltes Backup —
// alles neu laden, aber erst, wenn hier nichts mehr ungespeichert ist.
function liveAllesNeu(){
if(dirtyWeeks.size > 0 || syncInFlight || wirdGeradeBearbeitet()){
setTimeout(liveAllesNeu, 2000);
return;
}
load(true);
}
// ansichtNachBearbeitung: neu zeichnen — sofort, oder sobald niemand mehr
// tippt bzw. kein Dialog mehr offen ist (ein Neuzeichnen verwürfe Eingaben).
function ansichtNachBearbeitung(){
if(!wirdGeradeBearbeitet()){ liveNeuzeichnenAusstehend = false; ansichtAuffrischen(); return; }
if(liveNeuzeichnenAusstehend) return;
liveNeuzeichnenAusstehend = true;
const pruefen = () => {
if(!liveNeuzeichnenAusstehend) return;
if(wirdGeradeBearbeitet()){ setTimeout(pruefen, 1500); return; }
liveNeuzeichnenAusstehend = false;
ansichtAuffrischen();
};
setTimeout(pruefen, 1500);
}
// handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich
// geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu
// geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste
// bleiben unangetastet und gehen nicht verloren.
function handleWeekConflict(week, err){
const label = (week && week.label) || (err && err.label) || '';
alert('Achtung: ' + label + ' wurde zwischenzeitlich von einer anderen Person '
+ 'geändert. Ihre Änderungen an dieser Sitzung wurden nicht gespeichert; '
+ 'die aktuellen Daten dieser Sitzung werden geladen.');
if(label) reloadWeekFromServer(label);
else load();
}
// ---------------------------------------------------------------------------
// Sitzungen nach Bedarf (Eriks Wunsch: beim Start nur die aktuelle und drei
// davor; im Verlauf „Weitere laden“ holt je fünf ältere nach)
// ---------------------------------------------------------------------------
// db.weeks hält nur die GELADENEN Sitzungen, db.wochenListe ALLE schlank
// ({id, label, date, locked, updatedAt}, älteste zuerst). Wer eine nicht
// geladene Sitzung braucht (Admin → Sitzungen, Deep-Link auf ein Projekt
// einer älteren Sitzung), holt sie über sitzungSicherLaden().
const SITZUNGEN_START = 4;
const SITZUNGEN_NACHLADEN = 5;
function wocheAbsichern(w){
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
return w;
}
function sitzungKurz(w){
return { id: w.id, label: w.label, date: w.date || '', locked: !!w.locked, updatedAt: w.updatedAt || '' };
}
// sitzungKurzAbgleichen: den schlanken Eintrag einer Sitzung in
// db.wochenListe auf den Stand des vollen Objekts bringen (Sperre, Revision).
function sitzungKurzAbgleichen(w){
const k = (db.wochenListe || []).find(x => x.label === w.label);
if(k) Object.assign(k, sitzungKurz(w));
}
// wochenEinfuegen: geladene Sitzungen in db.weeks aufnehmen (ohne Doppel),
// sortieren und als Basis fürs Zusammenführen merken.
function wochenEinfuegen(liste){
(liste || []).forEach(w => {
if(!w || typeof w.label !== 'string' || db.weeks.some(x => x.label === w.label)) return;
db.weeks.push(wocheAbsichern(w));
basisMerken(w);
});
db.weeks.sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
}
// aeltereSitzungenAnzahl: wie viele Sitzungen älter als die älteste geladene
// noch nicht geladen sind.
function aeltereSitzungenAnzahl(){
const liste = db.wochenListe || [];
if(!db.weeks.length) return 0;
const aelteste = db.weeks[0].label;
const idx = liste.findIndex(w => w.label === aelteste);
return idx < 0 ? 0 : idx;
}
// weitereSitzungenLaden: holt die nächsten älteren Sitzungen und zeichnet
// danach neu — die ganze Ansicht, oder nur, was nachher (Name einer globalen
// Funktion) zeichnet. Der Wochenbericht nutzt das, damit ein angefangener
// Kommentar nicht durch ein Neuzeichnen verloren geht. Rückgabe: Promise.
let weitereLaufen = null;
function weitereSitzungenLaden(anzahl, nachher){
if(weitereLaufen) return weitereLaufen;
if(!db.weeks.length || aeltereSitzungenAnzahl() === 0) return Promise.resolve();
const vor = db.weeks[0].label;
weitereLaufen = fetch(baseUrl() + '/weeks-aelter?vor=' + encodeURIComponent(vor) + '&anzahl=' + (anzahl || SITZUNGEN_NACHLADEN))
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(d => {
wochenEinfuegen(d.weeks);
if(nachher && typeof window[nachher] === 'function'){ window[nachher](); return; }
ansichtAuffrischen();
// Kommentar-Verlauf im Projekt-Dashboard hängt an renderPlanInfo().
if(typeof currentTab !== 'undefined' && currentTab === 'plan' && typeof renderPlanInfo === 'function') renderPlanInfo();
})
.catch(err => { console.error('Ältere Sitzungen laden fehlgeschlagen:', err); alert('Ältere Sitzungen konnten nicht geladen werden.'); })
.finally(() => { weitereLaufen = null; });
return weitereLaufen;
}
// weitereLadenKnopf: Knopf „Weitere laden“ für einen Verlauf — nur, solange
// es ältere, nicht geladene Sitzungen gibt. Parameter: nachher (optional,
// siehe weitereSitzungenLaden()). Rückgabe: HTML-String ('' sonst).
function weitereLadenKnopf(nachher){
const n = aeltereSitzungenAnzahl();
if(!n) return '';
const k = Math.min(n, SITZUNGEN_NACHLADEN);
return `<button type="button" class="secondary small verlauf-weitere" onmousedown="event.preventDefault()" onclick="event.stopPropagation(); weitereSitzungenLaden(0${nachher ? ", '" + nachher + "'" : ''})"
title="Noch ${n} ältere Sitzung${n === 1 ? '' : 'en'} am Server">Weitere laden (${k} ${k === 1 ? 'Sitzung' : 'Sitzungen'})</button>`;
}
// sitzungSicherLaden: liefert eine Sitzung, lädt sie bei Bedarf einzeln
// nach. Rückgabe: Promise<Woche|null>.
function sitzungSicherLaden(label){
const da = db.weeks.find(w => w.label === label);
if(da) return Promise.resolve(da);
return wocheVomServer(label).then(w => {
if(!w) return null;
wochenEinfuegen([w]);
return db.weeks.find(x => x.label === label) || null;
});
}
// reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
// syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten.
function reloadWeekFromServer(label){
wocheVomServer(label)
.then(fresh => {
const idx = db.weeks.findIndex(w => w.label === label);
if(idx >= 0){
if(fresh){
// Felder absichern (wie in load()), damit keine kaputten Daten crashen
if(!Array.isArray(fresh.people)) fresh.people = [];
if(!Array.isArray(fresh.projects)) fresh.projects = [];
if(!fresh.attendance) fresh.attendance = {};
db.weeks[idx] = fresh;
basisMerken(fresh);
} else {
db.weeks.splice(idx, 1);
}
// Wurde die aktuelle Woche entfernt, auf die letzte verbleibende wechseln
if(!db.weeks.some(w => w.id === db.currentWeek)){
db.currentWeek = db.weeks.length ? db.weeks[db.weeks.length - 1].id : null;
}
}
renderAll();
showTab(currentTab);
})
.catch(err => {
// KEIN load() als Rückfall: Das leert dirtyWeeks und ersetzt alle Wochen,
// also gingen auch die noch ungespeicherten Änderungen an ANDEREN
// Sitzungen verloren — genau das, was diese Funktion vermeiden soll.
console.error('Neu laden der Sitzung fehlgeschlagen:', err);
showSyncProblem('Die Sitzung "' + label + '" konnte nicht neu geladen werden. Bitte die Seite neu laden.');
});
}
// load: liest alle Sitzungen und die globale Personenliste über `/load-all`
// als JSON vom Server und baut daraus `db.weeks` auf. Sortiert nach
// `weekSortKey()`, wendet `applyAutoLock()` an und rendert die Ansicht. Kein
// Parameter, keine Rückgabe; bei Fehlern oder fehlenden Daten werden
// Beispieldaten über `seed()` geladen.
// loadAppSettings: holt die firmenweiten Einstellungen (GET /app-settings)
// und die Feiertage (GET /holidays) und legt sie in APP_SETTINGS/HOLIDAYS ab
// (js/globals.js). Beides wird zum RECHNEN gebraucht — Auslastung, Ampel,
// Terminplan aus dem Honorar —, deshalb sind die Routen für jedes angemeldete
// Konto lesbar; nur das Ändern ist Administratoren vorbehalten.
//
// Scheitert der Abruf, bleiben die Vorgaben aus js/globals.js stehen. Das ist
// Absicht: Die Anwendung rechnet dann wie vor dem Ausbau der Einstellungen
// weiter, statt mit leeren Werten falsche Zahlen zu zeigen.
// Rückgabe: Promise (wird nie abgelehnt).
function loadAppSettings(){
const settings = fetch(baseUrl() + '/app-settings')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => { if(data && data.settings) APP_SETTINGS = Object.assign({}, APP_SETTINGS, data.settings); })
.catch(err => { console.warn('Firmenweite Einstellungen konnten nicht geladen werden, es gelten die Vorgabewerte:', err); });
const holidays = fetch(baseUrl() + '/holidays')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
const list = Array.isArray(data.holidays) ? data.holidays : [];
HOLIDAYS = new Set(list.map(h => h.date));
HOLIDAY_NAMES = {};
list.forEach(h => { HOLIDAY_NAMES[h.date] = h.name || ''; });
// Die Bundesländer liefert dieselbe Route mit (siehe server.js); die
// Pflege-Maske im Admin-Bereich baut daraus ihre Auswahl.
if(data.states) GERMAN_STATES = data.states;
})
.catch(err => { console.warn('Feiertage konnten nicht geladen werden:', err); });
// Honorar-Anteile je Leistungsphase: Die Auslastung rechnet den offenen
// Wert einer Phase in Stunden um und braucht dafür deren Anteil am
// Gesamthonorar. Zusammen mit den übrigen Vorgaben geladen, also VOR
// /load-all und damit vor dem ersten renderAll().
const honorar = fetch(baseUrl() + '/honorar-config')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => { HONORAR_ANTEILE = (data && data.anteile) || {}; })
.catch(err => { console.warn('Honorar-Anteile konnten nicht geladen werden, die Auslastung weist sie als fehlend aus:', err); });
return Promise.all([settings, holidays, honorar]);
}
// load: lädt die Sitzungsdaten vom Server neu. Parameter: silent (optional) —
// true für eine stille Hintergrund-Aktualisierung (js/wand/wand.js, alle 60s):
// ein Fehlschlag wird nur geloggt statt per alert() gemeldet, und der
// bisherige Datenstand bleibt stehen statt geleert zu werden. Ohne diesen
// Zweig stapelten sich auf einem unbeaufsichtigten Touch-Display bei jedem
// Fehlschlag blockierende alert()-Dialoge übereinander.
function load(silent){
// Nach einem (Neu-)Load sind die lokalen Daten serverseitig aktuell:
// laufenden Debounce abbrechen und Dirty-Zustand verwerfen.
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
dirtyWeeks.clear();
if(!window.fetch){
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
return;
}
// Nur die jüngsten SITZUNGEN_START Sitzungen (aktuelle + drei davor);
// ältere holt weitereSitzungenLaden() bei Bedarf (Eriks Wunsch).
const url = baseUrl() + '/load-all?wochen=' + SITZUNGEN_START;
console.log('Loading from', url);
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
// Aufwandsvorlage, Arbeitstage). loadAppSettings() lehnt nie ab, ein
// Fehlschlag lässt also nur die Vorgabewerte stehen.
loadAppSettings().then(() => fetch(url)
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
// Globale Personenliste laden (flaches Array, keine Abteilungstrennung)
db.globalPeople = Array.isArray(data.people) ? data.people : [];
// db.sandboxWeek: die Planung-Sandbox (SANDBOX_WEEK_LABEL) — bewusst
// NICHT Teil von db.weeks (siehe /load-all, server.js), sondern ein
// eigenes, synthetisches Wochen-ähnliches Objekt, hier und nur hier
// aufgebaut. Damit sehen renderBoard()/berichtProjekte()/
// berechneAuslastung()/die Sitzungs-PDFs/Admin→Sitzungen sie nie —
// sie iterieren alle ausschließlich db.weeks. people wird aus der
// globalen Personenliste abgeleitet (dieselbe Form wie week.people),
// damit die Beteiligten-Matrix im Projektbereich auch für einen
// Sandbox-Plan Kandidaten zur Auswahl hat.
db.sandboxWeek = {
id: 'sandbox', label: SANDBOX_WEEK_LABEL, date: '',
people: db.globalPeople.map(p => ({ id: p.id, name: p.name, fullName: p.fullName })),
projects: Array.isArray(data.sandboxPlans) ? data.sandboxPlans : [],
attendance: {}, locked: false, updatedAt: data.sandboxUpdatedAt || ''
};
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string')
.map(wocheAbsichern);
// Alle Sitzungen schlank (Name, Datum, Sperre) — auch die nicht geladenen.
db.wochenListe = Array.isArray(data.wochenListe) ? data.wochenListe : db.weeks.map(sitzungKurz);
// Server-Stand als Basis für späteres Zusammenführen merken.
wochenBasis.clear();
db.weeks.forEach(basisMerken);
basisMerken(db.sandboxWeek);
if(db.weeks.length === 0){
console.warn('Server lieferte 0 Sitzungen. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob die SQLite-Datenbank sessions/projektuebersicht.db existiert.');
seed();
}
else {
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
applyAutoLock();
const latest = db.weeks[db.weeks.length - 1];
if(!db.currentWeek || !db.weeks.some(w => w.id === db.currentWeek)){
db.currentWeek = latest.id;
}
console.log(`Server-Load erfolgreich: ${db.weeks.length} Sitzung(en).`);
}
// Geschützte Tabs ('projects'/'people') dürfen nur mit Passwort angezeigt
// werden. showTab() öffnet dafür automatisch das Passwort-Modal – auch
// wenn der Tab per Deep-Link-URL (z. B. "/Projekte") gewählt wurde.
renderAll();
showTab(currentTab);
liveVerbinden();
})
.catch(err => {
console.error('Laden vom Server fehlgeschlagen:', err);
if(silent) return;
const serverUrl = baseUrl();
const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' +
'Mögliche Ursachen:\n' +
'- Der Server wurde nicht gestartet\n' +
'- Der Server läuft auf einem anderen Port (Standard: 3003)\n' +
'- Firewall/Antivirus blockiert ' + serverUrl + '\n\n' +
'Es werden keine Daten angezeigt. Bitte die Seite neu laden, sobald der Server wieder läuft.';
alert(msg);
// BEWUSST KEIN seed(): Die Beispieldaten enthalten eine Woche "KW 30 / 2026"
// und wurden früher über save() an den Server geschickt, sobald die
// Verbindung zurückkam — sie überschrieben damit die echte gleichnamige
// Sitzung. Bei einem Ladefehler bleibt der Bestand jetzt einfach leer.
db.weeks = [];
db.currentWeek = null;
renderAll();
}));
}
// seed: initialisiert Beispiel-Daten (Personen + eine Woche mit Projekten).
// Wird als Fallback verwendet, wenn der Server nicht erreichbar ist oder keine
// Sitzungsdateien liefert.
function seed(){
const people = [
{id:personIdForName('AP'), name:"AP", fullName:"Anton Peters", role:["Planung"]},
{id:personIdForName('SK'), name:"SK", fullName:"Stefan Krause", role:["Planung"]},
{id:personIdForName('AK'), name:"AK", fullName:"Alexander Klein", role:["Planung"]},
{id:personIdForName('ED'), name:"ED", fullName:"Eva Dreyer", role:["Planung"]},
{id:personIdForName('SE'), name:"SE", fullName:"Stefan Eschweiler", role:["Planung"]},
{id:personIdForName('THK'), name:"THK", fullName:"Thorsten H. Klein", role:["Projektleitung"]},
{id:personIdForName('SP'), name:"SP", fullName:"Sven Peschke", role:["Projektleitung"]},
{id:personIdForName('SG'), name:"SG", fullName:"Sandra Gloag", role:["Projektleitung"]},
{id:personIdForName('TW'), name:"TW", fullName:"Thorsten Wörster", role:["Projektleitung"]},
{id:personIdForName('RR'), name:"RR", fullName:"Robert Rahn", role:["Bauüberwachung"]},
{id:personIdForName('MK'), name:"MK", fullName:"Martin Kluge", role:["Bauüberwachung"]},
{id:personIdForName('HR'), name:"HR", fullName:"Hartwig Rauch", role:["Bauüberwachung"]},
];
db.globalPeople = JSON.parse(JSON.stringify(people));
const wid = uid();
db.weeks = [{
id: wid, label:"KW 30 / 2026", date:"2026-07-24", attendance:{}, people, projects: [
demoProj("18-4007","Janusz-Korczak-Gesamtsch. Bottrop","Aktive Projekte","LPH5 als Vorabzug raus. NW-Räume vorauss. August.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":95}),
demoProj("21-4030","NSHV Köln Immobilienprüfung in Hof","Aktive Projekte","Thorsten W. klärt und übernimmt Schriftverkehr. LV liegt zur Sichtung bei T.W.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100}),
demoProj("22-4056","Gymnasium Hemer","Aktive Projekte","",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":90}),
demoProj("22-4066","DSW 21 – Umbau E-Mobilität","Aktive Projekte","Elt-Montagen ab KW 33.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":20}),
demoProj("24-4043","S-K ODL-Heim Sanierung Krefeld","Aktive Projekte","Voraussichtlich LV-Abgabe.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100}),
]
}];
db.currentWeek = wid;
save();
}
// demoProj: Helfer zum Erzeugen eines Projekt-Objekts (für seed/Tests).
// projectUid: dauerhafte, wochenunabhängige Identität (siehe saveNewProj()
// in js/load/load.js für die Erklärung, warum sie sich von id unterscheidet).
function demoProj(nr,name,group,status,lph){
return {id:uid(), projectUid: uid(), nr, name, group, status, lph: {...lph}, assign:{}, plan: null, aufwandProLP: undefined, aktuelleLP: undefined};
}