107 lines
3.6 KiB
JavaScript
107 lines
3.6 KiB
JavaScript
/**
|
|
* DataSync — hält LocalStorage (synchrone Working-Copy) und Server-DB
|
|
* (dauerhafte Wahrheit + Backup + Historie) synchron.
|
|
* ponytail: last-write-wins auf ganzen Dokumenten. Single-User → genügt.
|
|
*/
|
|
const DataSync = {
|
|
KEY_EMPLOYEES: 'dienstplan_employees',
|
|
KEY_DUTIES: 'dienstplan_duties',
|
|
KEY_VACATION: 'dienstplan_vacation',
|
|
KEY_PENDING: 'dienstplan_sync_pending',
|
|
_applying: false,
|
|
_timer: null,
|
|
online: false,
|
|
|
|
_local() {
|
|
const parse = (k, fb) => {
|
|
try { const v = JSON.parse(localStorage.getItem(k)); return v ?? fb; }
|
|
catch { return fb; }
|
|
};
|
|
return {
|
|
employees: parse(this.KEY_EMPLOYEES, []),
|
|
duties: parse(this.KEY_DUTIES, {}),
|
|
vacation: parse(this.KEY_VACATION, {}),
|
|
};
|
|
},
|
|
|
|
_isEmpty(s) {
|
|
return (!s.employees || s.employees.length === 0)
|
|
&& (!s.duties || Object.keys(s.duties).length === 0)
|
|
&& (!s.vacation || Object.keys(s.vacation).length === 0);
|
|
},
|
|
|
|
// Reine Entscheidung — in Node unit-getestet.
|
|
decideSync(local, server, pending) {
|
|
if (server === null) return 'offline';
|
|
if (pending) return 'push-local';
|
|
if (this._isEmpty(server) && !this._isEmpty(local)) return 'push-local';
|
|
return 'adopt-server';
|
|
},
|
|
|
|
_applyServer(server) {
|
|
this._applying = true;
|
|
try {
|
|
localStorage.setItem(this.KEY_EMPLOYEES, JSON.stringify(server.employees ?? []));
|
|
localStorage.setItem(this.KEY_DUTIES, JSON.stringify(server.duties ?? {}));
|
|
localStorage.setItem(this.KEY_VACATION, JSON.stringify(server.vacation ?? {}));
|
|
} finally {
|
|
this._applying = false;
|
|
}
|
|
},
|
|
|
|
async boot() {
|
|
let server = null;
|
|
try {
|
|
const res = await fetch('/api/state', { cache: 'no-store' });
|
|
if (res.ok) server = await res.json();
|
|
} catch { /* offline */ }
|
|
|
|
const action = this.decideSync(this._local(), server, localStorage.getItem(this.KEY_PENDING) === '1');
|
|
if (action === 'offline') { this.online = false; this._renderStatus(); return; }
|
|
this.online = true;
|
|
if (action === 'adopt-server') this._applyServer(server);
|
|
else await this._flush(); // push-local: Erstmigration oder pending Offline-Aenderungen
|
|
this._renderStatus();
|
|
},
|
|
|
|
push() {
|
|
if (this._applying) return;
|
|
localStorage.setItem(this.KEY_PENDING, '1');
|
|
clearTimeout(this._timer);
|
|
this._timer = setTimeout(() => this._flush(), 500);
|
|
},
|
|
|
|
async _flush() {
|
|
try {
|
|
const res = await fetch('/api/state', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(this._local()),
|
|
});
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
localStorage.removeItem(this.KEY_PENDING); // erst nach Erfolg
|
|
this.online = true;
|
|
} catch (e) {
|
|
console.error('Sync fehlgeschlagen, Daten bleiben lokal:', e);
|
|
this.online = false; // pending bleibt gesetzt → naechster boot() pusht
|
|
}
|
|
this._renderStatus();
|
|
},
|
|
|
|
_renderStatus() {
|
|
if (typeof document === 'undefined' || !document.body) return;
|
|
let el = document.getElementById('sync-status');
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.id = 'sync-status';
|
|
el.style.cssText = 'position:fixed;bottom:8px;right:8px;font-size:12px;'
|
|
+ 'padding:4px 8px;border-radius:6px;z-index:9999;opacity:.85;color:#fff';
|
|
document.body.appendChild(el);
|
|
}
|
|
el.textContent = this.online ? '● Synchronisiert' : '● Offline (nur lokal)';
|
|
el.style.background = this.online ? '#1e7e34' : '#856404';
|
|
},
|
|
};
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = DataSync;
|
|
if (typeof window !== 'undefined') window.DataSync = DataSync;
|