/** * Workout sync layer — bridges local workout state with the server * for multi-device real-time synchronization via SSE. * * Usage: call `createWorkoutSync()` once from the fitness layout. * It wraps the existing workout singleton and keeps it in sync. */ import { getWorkout } from '$lib/js/workout.svelte'; import type { WorkoutExercise } from '$lib/js/workout.svelte'; type SyncStatus = 'idle' | 'synced' | 'syncing' | 'offline' | 'conflict'; interface ServerWorkout { version: number; name: string; templateId: string | null; exercises: WorkoutExercise[]; paused: boolean; elapsed: number; savedAt: number; restStartedAt: number | null; restTotal: number; restExerciseIdx: number; restSetIdx: number; } export function createWorkoutSync() { const workout = getWorkout(); let status: SyncStatus = $state('idle'); let serverVersion = $state(0); let eventSource: EventSource | null = null; let debounceTimer: ReturnType | null = null; let reconnectTimer: ReturnType | null = null; let reconnectDelay = 1000; let _applying = false; // guard against re-entrant syncs function getWorkoutSnapshot(): ServerWorkout { // Compute current elapsed for an accurate snapshot let elapsed = workout.elapsedSeconds; return { version: serverVersion, name: workout.name, templateId: workout.templateId, exercises: JSON.parse(JSON.stringify(workout.exercises)), paused: workout.paused, elapsed, savedAt: Date.now(), restStartedAt: workout.restStartedAt, restTotal: workout.restTimerTotal, restExerciseIdx: workout.restExerciseIdx, restSetIdx: workout.restSetIdx }; } async function pushToServer() { if (!workout.active || _applying) return; status = 'syncing'; const snapshot = getWorkoutSnapshot(); try { const res = await fetch('/api/fitness/workout/active', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...snapshot, expectedVersion: serverVersion || undefined }) }); if (res.ok) { const { workout: doc } = await res.json(); serverVersion = doc.version; // reconcile with actual server version status = 'synced'; reconnectDelay = 1000; // reset backoff on success } else if (res.status === 409) { // Conflict — server has a newer version const { workout: serverDoc } = await res.json(); status = 'conflict'; applyServerState(serverDoc); // Retry push with merged state await pushToServer(); } else if (res.status === 401) { status = 'offline'; } else { status = 'offline'; } } catch { status = 'offline'; } } function debouncedPush() { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => pushToServer(), 200); } function applyServerState(doc: ServerWorkout) { if (!doc) return; _applying = true; try { serverVersion = doc.version; // Merge strategy: server state wins for structure, // but we keep the higher value for completed sets workout.applyRemoteState({ name: doc.name, templateId: doc.templateId, exercises: doc.exercises, paused: doc.paused, elapsed: doc.elapsed, savedAt: doc.savedAt, restStartedAt: doc.restStartedAt ?? null, restTotal: doc.restTotal ?? 0, restExerciseIdx: doc.restExerciseIdx ?? -1, restSetIdx: doc.restSetIdx ?? -1 }); status = 'synced'; } finally { _applying = false; } } function connectSSE() { if (eventSource) { eventSource.close(); eventSource = null; } if (!workout.active) return; try { eventSource = new EventSource('/api/fitness/workout/active/stream'); eventSource.addEventListener('update', (e) => { try { const doc = JSON.parse(e.data); // Only apply if server version is newer than ours if (doc.version > serverVersion) { applyServerState(doc); } } catch {} }); eventSource.addEventListener('finished', () => { // Another device finished the workout workout.cancel(); disconnectSSE(); }); eventSource.onerror = () => { status = 'offline'; eventSource?.close(); eventSource = null; // Reconnect with exponential backoff reconnectTimer = setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30000); if (workout.active) connectSSE(); }, reconnectDelay); }; eventSource.onopen = () => { status = 'synced'; reconnectDelay = 1000; }; } catch { status = 'offline'; } } function disconnectSSE() { if (eventSource) { eventSource.close(); eventSource = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } status = 'idle'; serverVersion = 0; } /** Called when workout starts — push initial state and connect SSE */ async function onWorkoutStart() { serverVersion = 0; await pushToServer(); connectSSE(); } /** Called when workout finishes or is cancelled — clean up server state */ async function onWorkoutEnd() { disconnectSSE(); try { await fetch('/api/fitness/workout/active', { method: 'DELETE' }); } catch {} } /** Called on app load — reconcile local vs server state */ async function init() { try { const res = await fetch('/api/fitness/workout/active'); if (!res.ok) return; const data = await res.json(); if (data.active && data.workout) { const serverDoc = data.workout as ServerWorkout; if (workout.active) { // Both local and server have active workout — use higher version serverVersion = serverDoc.version; // Push local state to update server (will handle conflicts) await pushToServer(); } else { // Server has workout but local doesn't — restore from server serverVersion = serverDoc.version; workout.restoreFromRemote({ name: serverDoc.name, templateId: serverDoc.templateId, exercises: serverDoc.exercises, paused: serverDoc.paused, elapsed: serverDoc.elapsed, savedAt: serverDoc.savedAt, restStartedAt: serverDoc.restStartedAt ?? null, restTotal: serverDoc.restTotal ?? 0, restExerciseIdx: serverDoc.restExerciseIdx ?? -1, restSetIdx: serverDoc.restSetIdx ?? -1 }); } connectSSE(); } else if (workout.active) { // Local has workout but server doesn't — push to server await pushToServer(); connectSSE(); } } catch { // Server unreachable — continue with local-only status = 'offline'; } } /** Notify sync layer that local state changed */ function notifyChange() { if (!_applying && workout.active) { debouncedPush(); } } function destroy() { disconnectSSE(); } return { get status() { return status; }, get serverVersion() { return serverVersion; }, init, onWorkoutStart, onWorkoutEnd, notifyChange, destroy }; } /** Shared singleton */ let _syncInstance: ReturnType | null = null; export function getWorkoutSync() { if (!_syncInstance) { _syncInstance = createWorkoutSync(); } return _syncInstance; }