diff --git a/sync.js b/sync.js new file mode 100644 index 0000000..9341be3 --- /dev/null +++ b/sync.js @@ -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; diff --git a/sync.test.js b/sync.test.js new file mode 100644 index 0000000..1d5d25b --- /dev/null +++ b/sync.test.js @@ -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'); +});