Files
homepage/src/lib/js/workoutSync.svelte.ts
T
Alexander e87b8bd864 fix(fitness): mirror finish overview to other devices via SSE
Other devices were left on a blank active-workout page when one device
hit Finish. The finish broadcast now carries the saved session document,
and the receiving page builds the completion overview from it. If the
remote end-of-workout has no session payload (cancel, or the finishing
device couldn't post the session), receivers redirect to the workout
home instead of stranding on a blank page.
2026-05-10 14:42:50 +02:00

328 lines
9.7 KiB
TypeScript

/**
* 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, WorkoutMode, GpsActivityType } from '$lib/js/workout.svelte';
type SyncStatus = 'idle' | 'synced' | 'syncing' | 'offline' | 'conflict';
interface ServerWorkout {
version: number;
name: string;
mode: WorkoutMode;
activityType: GpsActivityType | null;
templateId: string | null;
intervalTemplateId: string | null;
exercises: WorkoutExercise[];
paused: boolean;
elapsed: number;
savedAt: number;
restStartedAt: number | null;
restTotal: number;
restExerciseIdx: number;
restSetIdx: number;
holdStartedAt: number | null;
holdTotal: number;
holdExerciseIdx: number;
holdSetIdx: number;
}
export function createWorkoutSync() {
const workout = getWorkout();
let status: SyncStatus = $state('idle');
let serverVersion = $state(0);
let lastFinishedSession: any = $state(null);
let eventSource: EventSource | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | 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,
mode: workout.mode,
activityType: workout.activityType,
templateId: workout.templateId,
intervalTemplateId: workout.intervalTemplateId,
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,
holdStartedAt: workout.holdStartedAt,
holdTotal: workout.holdTimerTotal,
holdExerciseIdx: workout.holdExerciseIdx,
holdSetIdx: workout.holdSetIdx
};
}
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,
mode: doc.mode ?? 'manual',
activityType: doc.activityType ?? null,
templateId: doc.templateId,
intervalTemplateId: doc.intervalTemplateId ?? null,
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,
holdStartedAt: doc.holdStartedAt ?? null,
holdTotal: doc.holdTotal ?? 0,
holdExerciseIdx: doc.holdExerciseIdx ?? -1,
holdSetIdx: doc.holdSetIdx ?? -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', (e) => {
// Another device finished the workout. If they passed along the saved
// session, expose it so the active page can build the completion overview.
try {
const data = JSON.parse(e.data);
lastFinishedSession = data?.session ?? null;
} catch {
lastFinishedSession = null;
}
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.
* Pass the just-saved session id so other devices receive it via SSE
* and can render the finish overview. */
async function onWorkoutEnd(sessionId?: string | null) {
disconnectSSE();
try {
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : '';
await fetch(`/api/fitness/workout/active${qs}`, { method: 'DELETE' });
} catch {}
}
/** Clear the finished-session payload after the page has consumed it. */
function clearFinishedSession() {
lastFinishedSession = null;
}
/** 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,
mode: serverDoc.mode ?? 'manual',
activityType: serverDoc.activityType ?? null,
templateId: serverDoc.templateId,
intervalTemplateId: serverDoc.intervalTemplateId ?? null,
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,
holdStartedAt: serverDoc.holdStartedAt ?? null,
holdTotal: serverDoc.holdTotal ?? 0,
holdExerciseIdx: serverDoc.holdExerciseIdx ?? -1,
holdSetIdx: serverDoc.holdSetIdx ?? -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; },
get lastFinishedSession() { return lastFinishedSession; },
init,
onWorkoutStart,
onWorkoutEnd,
clearFinishedSession,
notifyChange,
destroy
};
}
/** Shared singleton */
let _syncInstance: ReturnType<typeof createWorkoutSync> | null = null;
export function getWorkoutSync() {
if (!_syncInstance) {
_syncInstance = createWorkoutSync();
}
return _syncInstance;
}