fitness: add multi-device workout sync via SSE and rest timer improvements

Enables real-time workout synchronization across devices using
Server-Sent Events and an ephemeral MongoDB document (24h TTL).
Rest timers now use absolute timestamps instead of interval-based
countdown for accurate cross-device sync. Adds +/-30s rest timer
adjust buttons.
This commit is contained in:
2026-03-19 09:44:21 +01:00
parent 620687f8f3
commit dff67c7059
10 changed files with 814 additions and 21 deletions
@@ -0,0 +1,50 @@
<script>
import { Cloud, CloudOff, RefreshCw, AlertTriangle } from 'lucide-svelte';
/** @type {{ status: string }} */
let { status } = $props();
</script>
<span class="sync-indicator" class:synced={status === 'synced'} class:syncing={status === 'syncing'} class:offline={status === 'offline'} class:conflict={status === 'conflict'} title={status === 'synced' ? 'Synced across devices' : status === 'syncing' ? 'Syncing...' : status === 'offline' ? 'Offline — changes saved locally' : status === 'conflict' ? 'Resolving conflict...' : ''}>
{#if status === 'synced'}
<Cloud size={14} />
{:else if status === 'syncing'}
<RefreshCw size={14} class="spin" />
{:else if status === 'offline'}
<CloudOff size={14} />
{:else if status === 'conflict'}
<AlertTriangle size={14} />
{/if}
</span>
<style>
.sync-indicator {
display: inline-flex;
align-items: center;
opacity: 0.5;
transition: opacity 0.2s;
}
.synced {
color: var(--nord14);
opacity: 0.7;
}
.syncing {
color: var(--nord13);
opacity: 0.8;
}
.offline {
color: var(--color-text-secondary);
opacity: 0.5;
}
.conflict {
color: var(--nord12);
opacity: 0.9;
}
.sync-indicator :global(.spin) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
+135 -9
View File
@@ -31,7 +31,7 @@ export interface TemplateData {
const STORAGE_KEY = 'fitness-active-workout';
interface StoredState {
export interface StoredState {
active: boolean;
paused: boolean;
name: string;
@@ -39,6 +39,19 @@ interface StoredState {
exercises: WorkoutExercise[];
elapsed: number; // total elapsed seconds at time of save
savedAt: number; // Date.now() at time of save
restStartedAt: number | null; // Date.now() when rest timer started
restTotal: number; // total rest duration in seconds
}
export interface RemoteState {
name: string;
templateId: string | null;
exercises: WorkoutExercise[];
paused: boolean;
elapsed: number;
savedAt: number;
restStartedAt: number | null;
restTotal: number;
}
function createEmptySet(): WorkoutSet {
@@ -79,9 +92,11 @@ export function createWorkout() {
let _restSeconds = $state(0);
let _restTotal = $state(0);
let _restActive = $state(false);
let _restStartedAt: number | null = null; // absolute timestamp
let _timerInterval: ReturnType<typeof setInterval> | null = null;
let _restInterval: ReturnType<typeof setInterval> | null = null;
let _onChangeCallback: (() => void) | null = null;
function _persist() {
if (!active) return;
@@ -96,8 +111,11 @@ export function createWorkout() {
templateId,
exercises: JSON.parse(JSON.stringify(exercises)),
elapsed: _elapsed,
savedAt: Date.now()
savedAt: Date.now(),
restStartedAt: _restActive ? _restStartedAt : null,
restTotal: _restTotal
});
_onChangeCallback?.();
}
function _computeElapsed() {
@@ -119,6 +137,21 @@ export function createWorkout() {
}
}
function _computeRestSeconds() {
if (!_restActive || !_restStartedAt) return;
const elapsed = Math.floor((Date.now() - _restStartedAt) / 1000);
_restSeconds = Math.max(0, _restTotal - elapsed);
if (_restSeconds <= 0) {
_stopRestTimer();
_persist();
}
}
function _startRestInterval() {
if (_restInterval) clearInterval(_restInterval);
_restInterval = setInterval(() => _computeRestSeconds(), 1000);
}
function _stopRestTimer() {
if (_restInterval) {
clearInterval(_restInterval);
@@ -127,6 +160,7 @@ export function createWorkout() {
_restActive = false;
_restSeconds = 0;
_restTotal = 0;
_restStartedAt = null;
}
// Restore from localStorage on creation
@@ -154,6 +188,19 @@ export function createWorkout() {
startTime = new Date(); // start counting from now
_startTimer();
}
// Restore rest timer if it was active
if (stored.restStartedAt && stored.restTotal > 0) {
const elapsed = Math.floor((Date.now() - stored.restStartedAt) / 1000);
const remaining = stored.restTotal - elapsed;
if (remaining > 0) {
_restStartedAt = stored.restStartedAt;
_restTotal = stored.restTotal;
_restSeconds = remaining;
_restActive = true;
_startRestInterval();
}
}
}
function startFromTemplate(template: TemplateData) {
@@ -266,19 +313,28 @@ export function createWorkout() {
function startRestTimer(seconds: number) {
_stopRestTimer();
_restStartedAt = Date.now();
_restSeconds = seconds;
_restTotal = seconds;
_restActive = true;
_restInterval = setInterval(() => {
_restSeconds--;
if (_restSeconds <= 0) {
_stopRestTimer();
}
}, 1000);
_startRestInterval();
_persist();
}
function cancelRestTimer() {
_stopRestTimer();
_persist();
}
function adjustRestTimer(delta: number) {
if (!_restActive) return;
_restTotal = Math.max(1, _restTotal + delta);
// Recompute remaining from the absolute timestamp
_computeRestSeconds();
if (_restSeconds <= 0) {
_stopRestTimer();
}
_persist();
}
function finish() {
@@ -340,6 +396,71 @@ export function createWorkout() {
_reset();
}
/** Apply state from another device (merge strategy: incoming wins) */
function applyRemoteState(remote: RemoteState) {
name = remote.name;
templateId = remote.templateId;
exercises = remote.exercises;
if (remote.paused) {
_stopTimer();
paused = true;
_pausedElapsed = remote.elapsed;
_elapsed = remote.elapsed;
startTime = null;
} else {
paused = false;
// Account for time elapsed since the remote saved
const secondsSinceSave = Math.floor((Date.now() - remote.savedAt) / 1000);
const totalElapsed = remote.elapsed + secondsSinceSave;
_pausedElapsed = totalElapsed;
_elapsed = totalElapsed;
startTime = new Date();
_startTimer();
}
// Apply rest timer state — skip if already running with same parameters
const restChanged = remote.restStartedAt !== _restStartedAt || remote.restTotal !== _restTotal;
if (restChanged) {
_stopRestTimer();
if (remote.restStartedAt && remote.restTotal > 0) {
const elapsed = Math.floor((Date.now() - remote.restStartedAt) / 1000);
const remaining = remote.restTotal - elapsed;
if (remaining > 0) {
_restStartedAt = remote.restStartedAt;
_restTotal = remote.restTotal;
_restSeconds = remaining;
_restActive = true;
_startRestInterval();
}
}
}
// Persist locally but don't trigger onChange (to avoid re-push loop)
saveToStorage({
active: true,
paused,
name,
templateId,
exercises: JSON.parse(JSON.stringify(exercises)),
elapsed: _elapsed,
savedAt: Date.now(),
restStartedAt: _restActive ? _restStartedAt : null,
restTotal: _restTotal
});
}
/** Restore a workout from server when local has no active workout */
function restoreFromRemote(remote: RemoteState) {
active = true;
applyRemoteState(remote);
}
/** Register callback for state changes (used by sync layer) */
function onChange(cb: () => void) {
_onChangeCallback = cb;
}
return {
get active() { return active; },
get paused() { return paused; },
@@ -352,6 +473,7 @@ export function createWorkout() {
get restTimerSeconds() { return _restSeconds; },
get restTimerTotal() { return _restTotal; },
get restTimerActive() { return _restActive; },
get restStartedAt() { return _restStartedAt; },
restore,
startFromTemplate,
startEmpty,
@@ -365,8 +487,12 @@ export function createWorkout() {
toggleSetComplete,
startRestTimer,
cancelRestTimer,
adjustRestTimer,
finish,
cancel
cancel,
applyRemoteState,
restoreFromRemote,
onChange
};
}
+273
View File
@@ -0,0 +1,273 @@
/**
* 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;
}
export function createWorkoutSync() {
const workout = getWorkout();
let status: SyncStatus = $state('idle');
let serverVersion = $state(0);
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,
templateId: workout.templateId,
exercises: JSON.parse(JSON.stringify(workout.exercises)),
paused: workout.paused,
elapsed,
savedAt: Date.now(),
restStartedAt: workout.restStartedAt,
restTotal: workout.restTimerTotal
};
}
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
});
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
});
}
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<typeof createWorkoutSync> | null = null;
export function getWorkoutSync() {
if (!_syncInstance) {
_syncInstance = createWorkoutSync();
}
return _syncInstance;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* In-memory SSE connection manager.
* Maps userId → Set of writable stream controllers for broadcasting workout state.
*/
type SSEController = ReadableStreamDefaultController<Uint8Array>;
const connections = new Map<string, Set<SSEController>>();
const encoder = new TextEncoder();
export function addConnection(userId: string, controller: SSEController) {
if (!connections.has(userId)) {
connections.set(userId, new Set());
}
connections.get(userId)!.add(controller);
}
export function removeConnection(userId: string, controller: SSEController) {
const set = connections.get(userId);
if (set) {
set.delete(controller);
if (set.size === 0) {
connections.delete(userId);
}
}
}
export function broadcast(userId: string, event: string, data: unknown, excludeController?: SSEController) {
const set = connections.get(userId);
if (!set) return;
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
const bytes = encoder.encode(payload);
for (const controller of set) {
if (controller === excludeController) continue;
try {
controller.enqueue(bytes);
} catch {
// Client disconnected — clean up
set.delete(controller);
}
}
if (set.size === 0) {
connections.delete(userId);
}
}
export function getConnectionCount(userId: string): number {
return connections.get(userId)?.size ?? 0;
}