// ----------------------------- // 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. // 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(); if(d.typ === 'notizen'){ if(typeof meineNotizenLive === 'function') meineNotizenLive(d.uid); if(typeof notizenLiveAenderung === 'function') notizenLiveAenderung(d.uid); return; } } // 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 ``; } // sitzungSicherLaden: liefert eine Sitzung, lädt sie bei Bedarf einzeln // nach. Rückgabe: Promise. 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}; }