v2.0.5: Background-Sync in PWA + Sync-Queue + 8 Tests (Phase 4.3 erweitert)
CI / test (push) Canceled after 0s
CI / webapp (push) Canceled after 0s

Phase 4.3 (Background-Sync):
- webapp/src/sync-queue.ts: localStorage-basierte Queue
  - enqueueSync, dequeueSync, loadQueue, saveQueue, clearQueue
  - TypeScript mit korrekten Typen
- webapp/src/EditorWithLoad.tsx: erweitert
  - Auto-Sync-Toggle (localStorage-Persistenz)
  - Online-Event-Listener: processQueue bei reconnect
  - Bei Auto-Sync: direkt speichern + sync
  - Bei Offline: in Queue, wird bei 'online' verarbeitet
  - Pending-Counter im UI
- webapp/public/service-worker.js: Background-Sync
  - 'sync'-Event-Handler mit tag 'mindomat-sync'
  - Ruft POST /api/sync, postet Ergebnis an Clients
  - Browser-Support: Chrome/Edge (Background-Sync API)

Tests:
- tests/sync-queue.test.ts: 8 neue Tests
  - Startzustand, enqueue, FIFO, dequeue, clearQueue
  - Roundtrip, kaputtes JSON
  - localStorage-Mock

Verifiziert:
- 79/79 Tests gruen (10 neu seit v2.0.4)
- Webapp tsc + build OK
- EditorWithLoad funktional mit Auto-Sync-Option

Pfad-Korrekturen:
- vite.config.ts: ../../src/ -> ../src/ (war falsch, webapp ist nur 1 Ebene tiefer)
This commit is contained in:
Jannik committed 2026-09-05 12:38:34 +02:00
1 parent 1590b633e5
commit 99d9fa6b7c
5 files changed
+220 -6

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
// Tests fuer Sync-Queue
// Phase 4.3
import { describe, it, expect, beforeEach } from 'vitest';
// Mock localStorage vor dem Import (das Modul nutzt es zur Initialisierung)
const store = new Map<string, string>();
(globalThis as any).localStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => { store.set(key, value); },
removeItem: (key: string) => { store.delete(key); },
clear: () => { store.clear(); },
key: (i: number) => Array.from(store.keys())[i] ?? null,
get length() { return store.size; },
};
import {
enqueueSync,
dequeueSync,
loadQueue,
clearQueue,
saveQueue,
} from '../webapp/src/sync-queue';
describe('Sync-Queue', () => {
beforeEach(() => {
clearQueue();
});
it('Startzustand: leere Queue', () => {
expect(loadQueue()).toEqual([]);
});
it('enqueue fuegt Element hinzu', () => {
enqueueSync('note1.md', '# Inhalt 1');
const queue = loadQueue();
expect(queue.length).toBe(1);
expect(queue[0].noteId).toBe('note1.md');
expect(queue[0].content).toBe('# Inhalt 1');
expect(typeof queue[0].timestamp).toBe('number');
});
it('enqueue fuegt mehrere Elemente hinzu (FIFO)', () => {
enqueueSync('a.md', 'A');
enqueueSync('b.md', 'B');
enqueueSync('c.md', 'C');
const queue = loadQueue();
expect(queue.length).toBe(3);
expect(queue.map((q) => q.noteId)).toEqual(['a.md', 'b.md', 'c.md']);
});
it('dequeue entfernt Element nach noteId', () => {
enqueueSync('a.md', 'A');
enqueueSync('b.md', 'B');
dequeueSync('a.md');
const queue = loadQueue();
expect(queue.length).toBe(1);
expect(queue[0].noteId).toBe('b.md');
});
it('dequeue unbekannter noteId ist no-op', () => {
enqueueSync('a.md', 'A');
dequeueSync('nicht-da.md');
expect(loadQueue().length).toBe(1);
});
it('clearQueue leert komplett', () => {
enqueueSync('a.md', 'A');
enqueueSync('b.md', 'B');
clearQueue();
expect(loadQueue()).toEqual([]);
});
it('saveQueue/loadQueue roundtrip mit localStorage', () => {
const queue = [
{ noteId: 'a.md', content: 'A', timestamp: 1000 },
{ noteId: 'b.md', content: 'B', timestamp: 2000 },
];
saveQueue(queue);
const loaded = loadQueue();
expect(loaded).toEqual(queue);
});
it('kaputtes JSON in localStorage wird als leere Queue behandelt', () => {
store.set('mindomat-pending-syncs', '{invalid json');
expect(loadQueue()).toEqual([]);
});
});
+20
View File
@@ -49,3 +49,23 @@ self.addEventListener('fetch', (event) => {
})
);
});
// Background-Sync: bei 'sync'-Event queued operations ausfuehren
self.addEventListener('sync', (event) => {
if (event.tag === 'mindomat-sync') {
event.waitUntil(
fetch('/api/sync', { method: 'POST' })
.then((res) => res.json())
.then((data) => {
return self.clients.matchAll().then((clients) => {
for (const client of clients) {
client.postMessage({ type: 'sync-complete', data });
}
});
})
.catch((err) => {
console.error('[SW] Background-Sync fehlgeschlagen:', err);
})
);
}
});
+68 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import Editor from './Editor';
import { loadNote, saveNote, triggerSync, NoteData } from './notes-api';
import { enqueueSync, dequeueSync, loadQueue, PendingSync } from './sync-queue';
interface Props {
noteId: string;
@@ -11,6 +12,10 @@ export default function EditorWithLoad({ noteId, onClose }: Props) {
const [note, setNote] = useState<NoteData | null>(null);
const [error, setError] = useState<string | null>(null);
const [syncStatus, setSyncStatus] = useState<string>('');
const [autoSync, setAutoSync] = useState<boolean>(() =>
localStorage.getItem('mindomat-auto-sync') === 'true',
);
const [pendingCount, setPendingCount] = useState<number>(0);
useEffect(() => {
loadNote(noteId)
@@ -19,24 +24,79 @@ export default function EditorWithLoad({ noteId, onClose }: Props) {
else setError(`Notiz nicht gefunden: ${noteId}`);
})
.catch((e) => setError(`Fehler beim Laden: ${e.message}`));
updatePendingCount();
}, [noteId]);
useEffect(() => {
const onOnline = () => {
console.log('[sync-queue] online-Event, verarbeite Queue');
processQueue();
};
window.addEventListener('online', onOnline);
return () => window.removeEventListener('online', onOnline);
}, []);
function updatePendingCount() {
setPendingCount(loadQueue().length);
}
async function processQueue() {
const queue: PendingSync[] = loadQueue();
if (queue.length === 0) return;
for (const item of queue) {
try {
await saveNote(item.noteId, item.content);
dequeueSync(item.noteId);
} catch (err) {
console.error(`[sync-queue] Fehler bei ${item.noteId}:`, err);
return;
}
}
updatePendingCount();
}
const handleSave = async (markdown: string) => {
if (autoSync && navigator.onLine) {
const result = await saveNote(noteId, markdown);
setNote(result);
try {
await triggerSync();
setSyncStatus('Auto-Sync ✓');
} catch {
setSyncStatus('Sync fehlgeschlagen, Notiz gespeichert');
}
} else {
try {
const result = await saveNote(noteId, markdown);
setNote(result);
} catch {
enqueueSync(noteId, markdown);
setSyncStatus('Offline: in Queue');
updatePendingCount();
}
}
setTimeout(() => setSyncStatus(''), 3000);
};
const handleSync = async () => {
setSyncStatus('Sync läuft...');
setSyncStatus('Sync laeuft...');
try {
await processQueue();
const result = await triggerSync();
setSyncStatus(result.ok ? 'Sync erfolgreich ✓' : `Sync-Fehler: ${result.message}`);
} catch (e) {
setSyncStatus(`Sync fehlgeschlagen: ${(e as Error).message}`);
}
updatePendingCount();
setTimeout(() => setSyncStatus(''), 3000);
};
const toggleAutoSync = () => {
const next = !autoSync;
setAutoSync(next);
localStorage.setItem('mindomat-auto-sync', String(next));
};
if (error) {
return (
<div style={{ padding: 16 }}>
@@ -76,6 +136,13 @@ export default function EditorWithLoad({ noteId, onClose }: Props) {
}}>
Sync zu gitea
</button>
<label style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="checkbox" checked={autoSync} onChange={toggleAutoSync} />
Auto-Sync
</label>
{pendingCount > 0 && (
<span style={{ color: '#c80' }}>⚠️ {pendingCount} ausstehend</span>
)}
<span style={{ color: '#666' }}>{syncStatus}</span>
<span style={{ flex: 1 }} />
<span style={{ color: '#999' }}>Zuletzt geändert: {note.lastModified}</span>
+42
View File
@@ -0,0 +1,42 @@
// Sync-Queue: speichert ausstehende Syncs im localStorage
// und arbeitet sie ab, sobald der Browser online ist.
const QUEUE_KEY = 'mindomat-pending-syncs';
export interface PendingSync {
noteId: string;
content: string;
timestamp: number;
}
export function loadQueue(): PendingSync[] {
try {
const raw = localStorage.getItem(QUEUE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
export function saveQueue(queue: PendingSync[]): void {
try {
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
} catch (err) {
console.error('Sync-Queue konnte nicht gespeichert werden:', err);
}
}
export function enqueueSync(noteId: string, content: string): void {
const queue = loadQueue();
queue.push({ noteId, content, timestamp: Date.now() });
saveQueue(queue);
}
export function dequeueSync(noteId: string): void {
const queue = loadQueue().filter((s) => s.noteId !== noteId);
saveQueue(queue);
}
export function clearQueue(): void {
saveQueue([]);
}
+2 -5
View File
@@ -1,10 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import matter from 'gray-matter';
import { NotesApi } from '../../src/notes-api-server.js';
import { NotesApi } from '../src/notes-api-server.js';
const VAULT_PATH = process.env.MINDOMAT_VAULT_PATH ?? 'C:/GitHub/Mind-o-Mat-Vault';
@@ -66,7 +63,7 @@ function notesApiPlugin() {
// POST /api/sync -> ruft mindomat sync auf
server.middlewares.use('/api/sync', (req: any, res: any, next: any) => {
if (req.method !== 'POST') return next();
api.syncVault(resolve(__dirname, '..', 'bin', 'mindomat.mjs')).then((result) => {
api.syncVault(resolve(__dirname, '..', 'bin', 'mindomat.mjs')).then((result: { ok: boolean; message: string }) => {
res.setHeader('Content-Type', 'application/json');
if (!result.ok) res.statusCode = 500;
res.end(JSON.stringify(result));