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:
1 parent
f4fb3c550b
commit
b7e0bd0d92
56 files changed
+12265
-2974
No files matched your search
+501
-21
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user