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:
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user