feat(sync): DataSync-Modul mit getesteter Reconcile-Logik
This commit is contained in:
parent
8459aaed1d
commit
787bc1416b
2 changed files with 133 additions and 0 deletions
107
sync.js
Normal file
107
sync.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* 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;
|
||||
26
sync.test.js
Normal file
26
sync.test.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const DataSync = require('./sync');
|
||||
|
||||
const empty = { employees: [], duties: {}, vacation: {} };
|
||||
const full = { employees: ['Max'], duties: { Max: {} }, vacation: {} };
|
||||
|
||||
test('Server unerreichbar → offline', () => {
|
||||
assert.strictEqual(DataSync.decideSync(full, null, false), 'offline');
|
||||
});
|
||||
|
||||
test('pending gesetzt → push-local (Offline-Aenderungen gewinnen)', () => {
|
||||
assert.strictEqual(DataSync.decideSync(full, full, true), 'push-local');
|
||||
});
|
||||
|
||||
test('Server leer, lokal voll → push-local (Erstmigration)', () => {
|
||||
assert.strictEqual(DataSync.decideSync(full, empty, false), 'push-local');
|
||||
});
|
||||
|
||||
test('Server hat Daten → adopt-server', () => {
|
||||
assert.strictEqual(DataSync.decideSync(empty, full, false), 'adopt-server');
|
||||
});
|
||||
|
||||
test('beide leer → adopt-server (nichts zu tun)', () => {
|
||||
assert.strictEqual(DataSync.decideSync(empty, empty, false), 'adopt-server');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue