Merge: Stufe 1 — Server-Persistenz (SQLite, Sync, Backup)
This commit is contained in:
commit
76569b94d6
18 changed files with 500 additions and 17 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.git
|
||||
.planning
|
||||
.superpowers
|
||||
docs/superpowers
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -45,3 +45,6 @@ dist/
|
|||
build/
|
||||
coverage/
|
||||
*.egg-info/
|
||||
|
||||
# Lokale Dev-DB + Backups (Laufzeitdaten, nie committen)
|
||||
data/
|
||||
|
|
|
|||
27
CLAUDE.md
27
CLAUDE.md
|
|
@ -194,22 +194,36 @@ docker run -p 3000:3000 -e PORT=3000 dienstplan-pro
|
|||
**Container:** `dienstplan-pro` on the `matrix_default` Docker network so the
|
||||
`matrix-caddy-1` reverse proxy can resolve it by hostname.
|
||||
|
||||
The Dockerfile uses `serve` from npm to serve static files:
|
||||
The Dockerfile runs an Express server (`server/index.js`) that serves the
|
||||
static frontend **and** the `/api/state` persistence API, backed by SQLite:
|
||||
```dockerfile
|
||||
FROM node:20-alpine
|
||||
RUN npm install -g serve
|
||||
FROM node:20-slim
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY . .
|
||||
CMD serve -s . -l tcp://0.0.0.0:${PORT:-3000}
|
||||
ENV PORT=3000
|
||||
ENV DATA_DIR=/data
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server/index.js"]
|
||||
```
|
||||
|
||||
Caddy block in `/opt/matrix/Caddyfile`:
|
||||
Caddy block in `/opt/matrix/Caddyfile` (app + `/api/*` behind Basic-Auth):
|
||||
```
|
||||
bonus.pixel-by-design.de {
|
||||
basic_auth {
|
||||
benad <BCRYPT_HASH>
|
||||
}
|
||||
reverse_proxy dienstplan-pro:3000
|
||||
}
|
||||
```
|
||||
|
||||
> **Wichtig:** Die SQLite-DB und die täglichen Backups liegen auf dem
|
||||
> benannten Docker-Volume `dienstplan-data` (`/data` im Container, Backups
|
||||
> unter `/data/backups/`). Niemals ohne dieses Volume deployen — sonst
|
||||
> löscht `docker rm` beim nächsten Update alle Daten unwiderruflich. Die
|
||||
> Domain ist komplett hinter Caddy Basic-Auth (gilt auch für `/api/*`).
|
||||
|
||||
**Update procedure** (when pushing new code):
|
||||
```bash
|
||||
ssh root@65.21.60.83
|
||||
|
|
@ -218,7 +232,8 @@ git pull
|
|||
docker build -t dienstplan-pro:latest .
|
||||
docker stop dienstplan-pro && docker rm dienstplan-pro
|
||||
docker run -d --name dienstplan-pro --network matrix_default \
|
||||
--restart unless-stopped -e PORT=3000 dienstplan-pro:latest
|
||||
--restart unless-stopped -e PORT=3000 -e DATA_DIR=/data \
|
||||
-v dienstplan-data:/data dienstplan-pro:latest
|
||||
```
|
||||
|
||||
Caddy reloads not needed unless the Caddyfile changes.
|
||||
|
|
|
|||
20
Dockerfile
20
Dockerfile
|
|
@ -1,14 +1,16 @@
|
|||
FROM node:20-alpine
|
||||
FROM node:20-slim
|
||||
|
||||
# Install simple static file server
|
||||
RUN npm install -g serve
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy all files
|
||||
# Erst Manifeste kopieren → Layer-Cache für npm install
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Rest der App
|
||||
COPY . .
|
||||
|
||||
# Start server on the port defined by Railway ($PORT)
|
||||
# If $PORT is not set, default to 3000
|
||||
CMD serve -s . -l tcp://0.0.0.0:${PORT:-3000}
|
||||
ENV PORT=3000
|
||||
ENV DATA_DIR=/data
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
|
|
|
|||
6
app.js
6
app.js
|
|
@ -1230,7 +1230,11 @@ class DienstplanApp {
|
|||
|
||||
// Initialize app when DOM is ready
|
||||
let app;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (window.DataSync) {
|
||||
try { await window.DataSync.boot(); }
|
||||
catch (e) { console.error('Sync-Boot fehlgeschlagen, App laeuft lokal weiter:', e); }
|
||||
}
|
||||
app = new DienstplanApp();
|
||||
window.app = app;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -326,6 +326,7 @@
|
|||
<script src="variants.js"></script>
|
||||
<script src="calculator.js"></script>
|
||||
<script src="storage.js"></script>
|
||||
<script src="sync.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script src="image-import.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "dienstplan-pro",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node server/index.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"express": "^4.21.0"
|
||||
}
|
||||
}
|
||||
34
server/backup.js
Normal file
34
server/backup.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { db, DATA_DIR } = require('./db');
|
||||
|
||||
const BACKUP_DIR = path.join(DATA_DIR, 'backups');
|
||||
const KEEP = 14;
|
||||
|
||||
async function runBackup(now) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
const stamp = now.replace(/[:.]/g, '-');
|
||||
const dest = path.join(BACKUP_DIR, `dienstplan-${stamp}.db`);
|
||||
await db.backup(dest); // Online-Backup-API von SQLite — konsistent, ohne cp-Risiko
|
||||
prune();
|
||||
return dest;
|
||||
}
|
||||
|
||||
function prune() {
|
||||
const files = fs.readdirSync(BACKUP_DIR)
|
||||
.filter(f => f.startsWith('dienstplan-') && f.endsWith('.db'))
|
||||
.sort(); // ISO-abgeleiteter Stamp → lexikografisch == chronologisch
|
||||
const excess = files.length - KEEP;
|
||||
for (let i = 0; i < excess; i++) {
|
||||
fs.unlinkSync(path.join(BACKUP_DIR, files[i]));
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBackups() {
|
||||
const tick = () => runBackup(new Date().toISOString())
|
||||
.catch(e => console.error('Backup fehlgeschlagen:', e));
|
||||
tick();
|
||||
setInterval(tick, 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
module.exports = { runBackup, scheduleBackups, BACKUP_DIR };
|
||||
17
server/backup.test.js
Normal file
17
server/backup.test.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-backup-'));
|
||||
const { putDoc } = require('./db');
|
||||
const { runBackup, BACKUP_DIR } = require('./backup');
|
||||
|
||||
test('runBackup erzeugt eine nicht-leere DB-Datei', async () => {
|
||||
putDoc('employees', ['Max'], '2026-07-07T10:00:00.000Z');
|
||||
const dest = await runBackup('2026-07-07T10:00:00.000Z');
|
||||
assert.ok(fs.existsSync(dest), 'Backup-Datei existiert');
|
||||
assert.ok(fs.statSync(dest).size > 0, 'Backup ist nicht leer');
|
||||
assert.ok(dest.startsWith(BACKUP_DIR), 'Backup liegt im BACKUP_DIR');
|
||||
});
|
||||
51
server/db.js
Normal file
51
server/db.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const DB_PATH = path.join(DATA_DIR, 'dienstplan.db');
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('synchronous = NORMAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
replaced_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
function getDoc(key) {
|
||||
const row = db.prepare('SELECT value, updated_at FROM documents WHERE key = ?').get(key);
|
||||
if (!row) return null;
|
||||
return { value: JSON.parse(row.value), updatedAt: row.updated_at };
|
||||
}
|
||||
|
||||
// ponytail: history wächst unbegrenzt. Bei Single-User/kleinen Docs jahrelang egal.
|
||||
// Pruning (z.B. > 500 Einträge pro key löschen) nachrüsten, falls es je wächst.
|
||||
function putDoc(key, value, now) {
|
||||
const json = JSON.stringify(value);
|
||||
const tx = db.transaction(() => {
|
||||
const existing = db.prepare('SELECT value FROM documents WHERE key = ?').get(key);
|
||||
if (existing) {
|
||||
db.prepare('INSERT INTO history (key, value, replaced_at) VALUES (?, ?, ?)')
|
||||
.run(key, existing.value, now);
|
||||
}
|
||||
db.prepare(`
|
||||
INSERT INTO documents (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
`).run(key, json, now);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
module.exports = { db, getDoc, putDoc, DB_PATH, DATA_DIR };
|
||||
29
server/db.test.js
Normal file
29
server/db.test.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// DATA_DIR auf Temp umbiegen, BEVOR db.js geladen wird
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-db-'));
|
||||
const { getDoc, putDoc } = require('./db');
|
||||
|
||||
test('putDoc/getDoc Roundtrip', () => {
|
||||
putDoc('employees', ['Max', 'Anna'], '2026-07-07T10:00:00.000Z');
|
||||
const doc = getDoc('employees');
|
||||
assert.deepStrictEqual(doc.value, ['Max', 'Anna']);
|
||||
assert.strictEqual(doc.updatedAt, '2026-07-07T10:00:00.000Z');
|
||||
});
|
||||
|
||||
test('getDoc für unbekannten Key liefert null', () => {
|
||||
assert.strictEqual(getDoc('gibtsnicht'), null);
|
||||
});
|
||||
|
||||
test('putDoc snapshottet den alten Wert in history', () => {
|
||||
const { db } = require('./db');
|
||||
putDoc('duties', { v: 1 }, '2026-07-07T10:00:00.000Z');
|
||||
putDoc('duties', { v: 2 }, '2026-07-07T11:00:00.000Z');
|
||||
const rows = db.prepare("SELECT value FROM history WHERE key = 'duties' ORDER BY id").all();
|
||||
assert.strictEqual(rows.length, 1);
|
||||
assert.deepStrictEqual(JSON.parse(rows[0].value), { v: 1 });
|
||||
});
|
||||
46
server/index.js
Normal file
46
server/index.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
const path = require('path');
|
||||
const express = require('express');
|
||||
const { getDoc, putDoc } = require('./db');
|
||||
const { scheduleBackups } = require('./backup');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
const KEYS = ['employees', 'duties', 'vacation'];
|
||||
const EMPTY = { employees: [], duties: {}, vacation: {} };
|
||||
|
||||
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
app.get('/api/state', (req, res) => {
|
||||
const state = { ...EMPTY, updatedAt: null };
|
||||
for (const key of KEYS) {
|
||||
const doc = getDoc(key);
|
||||
if (doc) {
|
||||
state[key] = doc.value;
|
||||
if (!state.updatedAt || doc.updatedAt > state.updatedAt) state.updatedAt = doc.updatedAt;
|
||||
}
|
||||
}
|
||||
res.json(state);
|
||||
});
|
||||
|
||||
app.put('/api/state', (req, res) => {
|
||||
const body = req.body || {};
|
||||
const now = new Date().toISOString();
|
||||
for (const key of KEYS) {
|
||||
if (body[key] !== undefined) putDoc(key, body[key], now);
|
||||
}
|
||||
res.json({ status: 'ok', updatedAt: now });
|
||||
});
|
||||
|
||||
// Statisches Frontend (Repo-Root). Dotfiles werden per Default nicht ausgeliefert.
|
||||
app.use(express.static(path.join(__dirname, '..')));
|
||||
|
||||
if (require.main === module) {
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Dienstplan-Pro auf :${PORT}`);
|
||||
scheduleBackups();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = app;
|
||||
16
server/index.test.js
Normal file
16
server/index.test.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const app = require('./index');
|
||||
|
||||
test('GET /api/health liefert ok', async () => {
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.status, 'ok');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
42
server/state.test.js
Normal file
42
server/state.test.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-state-'));
|
||||
const app = require('./index');
|
||||
|
||||
async function req(port, method, body) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/state`, opts);
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
test('leerer Server liefert leeren State', async () => {
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const { status, body } = await req(port, 'GET');
|
||||
assert.strictEqual(status, 200);
|
||||
assert.deepStrictEqual(body.employees, []);
|
||||
assert.deepStrictEqual(body.duties, {});
|
||||
assert.deepStrictEqual(body.vacation, {});
|
||||
assert.strictEqual(body.updatedAt, null);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test('PUT dann GET Roundtrip', async () => {
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const put = await req(port, 'PUT', { employees: ['Max'], duties: { Max: { '2026-07': [] } } });
|
||||
assert.strictEqual(put.status, 200);
|
||||
assert.strictEqual(put.body.status, 'ok');
|
||||
const get = await req(port, 'GET');
|
||||
assert.deepStrictEqual(get.body.employees, ['Max']);
|
||||
assert.deepStrictEqual(get.body.duties, { Max: { '2026-07': [] } });
|
||||
assert.strictEqual(typeof get.body.updatedAt, 'string');
|
||||
} finally { server.close(); }
|
||||
});
|
||||
15
storage.js
15
storage.js
|
|
@ -45,6 +45,7 @@ class DataStorage {
|
|||
throw new TypeError('employees muss ein Array sein');
|
||||
}
|
||||
localStorage.setItem(this.STORAGE_KEY_EMPLOYEES, JSON.stringify(employees));
|
||||
this._notifyChange();
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Speichern der Mitarbeiter-Daten:', e);
|
||||
throw e;
|
||||
|
|
@ -117,6 +118,7 @@ class DataStorage {
|
|||
throw new TypeError('duties muss ein gültiges Objekt sein');
|
||||
}
|
||||
localStorage.setItem(this.STORAGE_KEY_DUTIES, JSON.stringify(duties));
|
||||
this._notifyChange();
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Speichern der Dienst-Daten:', e);
|
||||
throw e;
|
||||
|
|
@ -285,6 +287,7 @@ class DataStorage {
|
|||
if (!map[employeeName]) map[employeeName] = {};
|
||||
map[employeeName][yearMonth] = Boolean(value);
|
||||
localStorage.setItem(this.STORAGE_KEY_VACATION, JSON.stringify(map));
|
||||
this._notifyChange();
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Speichern des Urlaubsmodus:', e);
|
||||
throw e;
|
||||
|
|
@ -319,6 +322,16 @@ class DataStorage {
|
|||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signalisiert dem Sync-Layer eine Aenderung (falls vorhanden).
|
||||
* Wird unterdrueckt, waehrend DataSync gerade Server-Daten uebernimmt.
|
||||
*/
|
||||
_notifyChange() {
|
||||
if (typeof window !== 'undefined' && window.DataSync && !window.DataSync._applying) {
|
||||
window.DataSync.push();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data
|
||||
*/
|
||||
|
|
@ -326,6 +339,7 @@ class DataStorage {
|
|||
localStorage.removeItem(this.STORAGE_KEY_EMPLOYEES);
|
||||
localStorage.removeItem(this.STORAGE_KEY_DUTIES);
|
||||
localStorage.removeItem(this.STORAGE_KEY_VACATION);
|
||||
this._notifyChange();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -363,6 +377,7 @@ class DataStorage {
|
|||
if (data.vacation && typeof data.vacation === 'object') {
|
||||
localStorage.setItem(this.STORAGE_KEY_VACATION, JSON.stringify(data.vacation));
|
||||
}
|
||||
this._notifyChange();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Import failed:', e);
|
||||
|
|
|
|||
3
sw.js
3
sw.js
|
|
@ -1,4 +1,4 @@
|
|||
const CACHE_NAME = 'dienstplan-pro-v4';
|
||||
const CACHE_NAME = 'dienstplan-pro-v5';
|
||||
const ASSETS = [
|
||||
'./',
|
||||
'./index.html',
|
||||
|
|
@ -8,6 +8,7 @@ const ASSETS = [
|
|||
'./variants.js',
|
||||
'./holidays.js',
|
||||
'./storage.js',
|
||||
'./sync.js',
|
||||
'./image-import.js'
|
||||
];
|
||||
|
||||
|
|
|
|||
109
sync.js
Normal file
109
sync.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* 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;
|
||||
this._dirty = (this._dirty || 0) + 1;
|
||||
localStorage.setItem(this.KEY_PENDING, '1');
|
||||
clearTimeout(this._timer);
|
||||
this._timer = setTimeout(() => this._flush(), 500);
|
||||
},
|
||||
|
||||
async _flush() {
|
||||
const gen = this._dirty; // ponytail: Generationszaehler gegen Verlust-Race bei ueberlappenden Flushes
|
||||
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);
|
||||
if (this._dirty === gen) localStorage.removeItem(this.KEY_PENDING); // nur wenn kein neuerer push() lief
|
||||
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;
|
||||
79
sync.test.js
Normal file
79
sync.test.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
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');
|
||||
});
|
||||
|
||||
// --- localStorage/fetch Stubs fuer _flush()-Race-Tests ---
|
||||
function makeLocalStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
||||
setItem: (k, v) => map.set(k, String(v)),
|
||||
removeItem: (k) => map.delete(k),
|
||||
};
|
||||
}
|
||||
|
||||
test('einzelner push() + erfolgreicher flush → pending wird geleert', async () => {
|
||||
global.localStorage = makeLocalStorage();
|
||||
global.fetch = async () => ({ ok: true, json: async () => ({}) });
|
||||
DataSync._dirty = 0;
|
||||
DataSync._applying = false;
|
||||
|
||||
DataSync.push();
|
||||
clearTimeout(DataSync._timer); // manueller Flush statt Debounce
|
||||
assert.strictEqual(localStorage.getItem(DataSync.KEY_PENDING), '1');
|
||||
|
||||
await DataSync._flush();
|
||||
|
||||
assert.strictEqual(localStorage.getItem(DataSync.KEY_PENDING), null);
|
||||
|
||||
delete global.localStorage;
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
test('push() waehrend laufendem flush() → pending bleibt gesetzt (Verlust-Race verhindert)', async () => {
|
||||
global.localStorage = makeLocalStorage();
|
||||
DataSync._dirty = 0;
|
||||
DataSync._applying = false;
|
||||
|
||||
let resolveFetch;
|
||||
global.fetch = () => new Promise((resolve) => { resolveFetch = resolve; });
|
||||
|
||||
DataSync.push(); // dirty=1, pending='1'
|
||||
clearTimeout(DataSync._timer);
|
||||
|
||||
const flushPromise = DataSync._flush(); // gen erfasst als 1, fetch haengt in flight
|
||||
|
||||
DataSync.push(); // B: dirty=2, pending erneut '1' waehrend flush#1 noch laeuft
|
||||
clearTimeout(DataSync._timer);
|
||||
|
||||
resolveFetch({ ok: true, json: async () => ({}) }); // flush#1 kommt zurueck
|
||||
await flushPromise;
|
||||
|
||||
assert.strictEqual(localStorage.getItem(DataSync.KEY_PENDING), '1');
|
||||
|
||||
delete global.localStorage;
|
||||
delete global.fetch;
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue