Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb

- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-29 14:16:52 +01:00
1 parent f4fb3c550b
commit b7e0bd0d92
56 files changed
+12265 -2974

No files matched your search

+501 -21
View File
@@ -112,6 +112,43 @@ function syncAllToServer(){
// 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){
@@ -119,28 +156,21 @@ function syncAllToServer(){
// 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 => {
if(err && err.conflict){
dirtyWeeks.delete(label);
handleWeekConflict(w, err);
} else if(err && err.status === 401){
// Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still
// weiterzuarbeiten und alles Weitere zu verlieren.
problem = 'Nicht mehr angemeldet — bitte neu anmelden, sonst gehen Änderungen verloren.';
if(typeof showLoginOverlay === 'function') showLoginOverlay();
console.error('Speichern abgelehnt (nicht angemeldet):', w.label);
} else {
problem = 'Speichern fehlgeschlagen (' + (err && err.message ? err.message : 'unbekannt') + ') — neuer Versuch in 20 Sekunden.';
console.error('Sitzung konnte nicht gespeichert werden:', w.label, (err && err.message) || err);
// Label bleibt dirty -> der nächste Sync versucht es erneut.
}
}));
.catch(err => { problem = speicherFehler(label, w, err) || problem; }));
});
Promise.all(jobs).then(() => {
@@ -151,6 +181,58 @@ function 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){
@@ -159,6 +241,400 @@ function markWeekDirty(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) 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
@@ -177,11 +653,8 @@ function handleWeekConflict(week, err){
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
// syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten.
function reloadWeekFromServer(label){
fetch(baseUrl() + '/load-all')
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
const fresh = (Array.isArray(data.weeks) ? data.weeks : [])
.find(w => w && w.label === label);
wocheVomServer(label)
.then(fresh => {
const idx = db.weeks.findIndex(w => w.label === label);
if(idx >= 0){
if(fresh){
@@ -190,6 +663,7 @@ function reloadWeekFromServer(label){
if(!Array.isArray(fresh.projects)) fresh.projects = [];
if(!fresh.attendance) fresh.attendance = {};
db.weeks[idx] = fresh;
basisMerken(fresh);
} else {
db.weeks.splice(idx, 1);
}
@@ -309,6 +783,11 @@ function load(silent){
return w;
});
// 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();
@@ -327,6 +806,7 @@ function load(silent){
// 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);