Files
reaktor/packages/client/src/services/syncService.js
Jose Luis 64ffa36c09 feat: Phase 2 — data sync (presets + game progress)
Server:
- GET/PUT /api/v1/sync/presets — upsert with last-write-wins
- DELETE /api/v1/sync/presets/:id
- GET/PUT /api/v1/sync/progress — game progress upsert

Client:
- syncService.js: offline-first sync layer
  - localStorage remains primary store
  - Pushes to server when logged in
  - Merges server data into local on sync
  - Auto-sync every 30s + on tab focus
- AuthContext starts/stops sync on login/logout
- Sync runs on session restore (refresh token)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:28:46 +01:00

135 lines
3.3 KiB
JavaScript

import { getAccessToken } from './api.js';
const API_BASE = '/api/v1/sync';
const SYNC_INTERVAL = 30000; // 30 seconds
let _syncTimer = null;
async function apiFetch(method, path, body = null) {
const token = getAccessToken();
if (!token) return null;
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
credentials: 'include',
body: body ? JSON.stringify(body) : null,
});
if (!res.ok) return null;
return res.json();
}
// ==================== Preset Sync ====================
export async function syncPresets() {
if (!getAccessToken()) return;
try {
// Get local presets
const localRaw = localStorage.getItem('reaktor_presets');
const localPresets = localRaw ? JSON.parse(localRaw) : [];
// Get server presets
const serverData = await apiFetch('GET', '/presets');
if (!serverData) return;
// Push local presets to server
if (localPresets.length > 0) {
await apiFetch('PUT', '/presets', {
presets: localPresets.map(p => ({
name: p.name,
data: p.data,
updatedAt: p.savedAt || new Date().toISOString(),
})),
});
}
// Merge server presets into local (add any missing)
const serverPresets = serverData.presets || [];
const localNames = new Set(localPresets.map(p => p.name));
let merged = [...localPresets];
for (const sp of serverPresets) {
if (!localNames.has(sp.name)) {
merged.push({
name: sp.name,
data: sp.data,
savedAt: sp.updatedAt,
});
}
}
localStorage.setItem('reaktor_presets', JSON.stringify(merged));
} catch (err) {
console.warn('[sync] preset sync failed:', err.message);
}
}
// ==================== Game Progress Sync ====================
export async function syncProgress() {
if (!getAccessToken()) return;
try {
const localRaw = localStorage.getItem('synthquest-progress');
const localProgress = localRaw ? JSON.parse(localRaw) : null;
// Get server progress
const serverData = await apiFetch('GET', '/progress');
if (localProgress) {
// Push local to server
await apiFetch('PUT', '/progress', {
data: localProgress,
updatedAt: new Date().toISOString(),
});
}
// If server has progress and local doesn't, pull it
if (serverData?.progress && !localProgress) {
localStorage.setItem('synthquest-progress', JSON.stringify(serverData.progress));
}
// Also sync hint data
const hintsRaw = localStorage.getItem('synthquest-hints');
// Hints are local-only for now (anti-cheat integrity)
} catch (err) {
console.warn('[sync] progress sync failed:', err.message);
}
}
// ==================== Auto Sync ====================
export function startAutoSync() {
if (_syncTimer) return;
// Initial sync
syncPresets();
syncProgress();
// Periodic sync
_syncTimer = setInterval(() => {
syncPresets();
syncProgress();
}, SYNC_INTERVAL);
// Sync on tab focus
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
syncPresets();
syncProgress();
}
});
}
export function stopAutoSync() {
if (_syncTimer) {
clearInterval(_syncTimer);
_syncTimer = null;
}
}