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:
50
src/lib/components/fitness/SyncIndicator.svelte
Normal file
50
src/lib/components/fitness/SyncIndicator.svelte
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
273
src/lib/js/workoutSync.svelte.ts
Normal file
273
src/lib/js/workoutSync.svelte.ts
Normal 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
src/lib/server/sseManager.ts
Normal file
53
src/lib/server/sseManager.ts
Normal 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;
|
||||
}
|
||||
100
src/models/ActiveWorkout.ts
Normal file
100
src/models/ActiveWorkout.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface IActiveWorkoutSet {
|
||||
reps: number | null;
|
||||
weight: number | null;
|
||||
rpe: number | null;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface IActiveWorkoutExercise {
|
||||
exerciseId: string;
|
||||
sets: IActiveWorkoutSet[];
|
||||
restTime: number;
|
||||
}
|
||||
|
||||
export interface IActiveWorkout {
|
||||
_id?: string;
|
||||
userId: string;
|
||||
version: number;
|
||||
name: string;
|
||||
templateId: string | null;
|
||||
exercises: IActiveWorkoutExercise[];
|
||||
paused: boolean;
|
||||
elapsed: number;
|
||||
savedAt: number;
|
||||
restStartedAt: number | null;
|
||||
restTotal: number;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const ActiveWorkoutSetSchema = new mongoose.Schema({
|
||||
reps: { type: Number, default: null },
|
||||
weight: { type: Number, default: null },
|
||||
rpe: { type: Number, default: null },
|
||||
completed: { type: Boolean, default: false }
|
||||
}, { _id: false });
|
||||
|
||||
const ActiveWorkoutExerciseSchema = new mongoose.Schema({
|
||||
exerciseId: { type: String, required: true, trim: true },
|
||||
sets: { type: [ActiveWorkoutSetSchema], default: [] },
|
||||
restTime: { type: Number, default: 120 }
|
||||
}, { _id: false });
|
||||
|
||||
const ActiveWorkoutSchema = new mongoose.Schema(
|
||||
{
|
||||
userId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
version: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 1
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 100
|
||||
},
|
||||
templateId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
exercises: {
|
||||
type: [ActiveWorkoutExerciseSchema],
|
||||
default: []
|
||||
},
|
||||
paused: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
elapsed: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
savedAt: {
|
||||
type: Number,
|
||||
default: () => Date.now()
|
||||
},
|
||||
restStartedAt: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
restTotal: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true
|
||||
}
|
||||
);
|
||||
|
||||
// Auto-delete after 24h of inactivity
|
||||
ActiveWorkoutSchema.index({ updatedAt: 1 }, { expireAfterSeconds: 86400 });
|
||||
|
||||
export const ActiveWorkout = mongoose.model<IActiveWorkout>('ActiveWorkout', ActiveWorkoutSchema);
|
||||
105
src/routes/api/fitness/workout/active/+server.ts
Normal file
105
src/routes/api/fitness/workout/active/+server.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { ActiveWorkout } from '$models/ActiveWorkout';
|
||||
import { broadcast } from '$lib/server/sseManager';
|
||||
|
||||
// GET /api/fitness/workout/active — fetch current active workout
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session?.user?.nickname) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await dbConnect();
|
||||
const doc = await ActiveWorkout.findOne({ userId: session.user.nickname }).lean();
|
||||
if (!doc) {
|
||||
return json({ active: false });
|
||||
}
|
||||
return json({ active: true, workout: doc });
|
||||
} catch (error) {
|
||||
console.error('Error fetching active workout:', error);
|
||||
return json({ error: 'Failed to fetch active workout' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /api/fitness/workout/active — create or update active workout state
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session?.user?.nickname) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await dbConnect();
|
||||
const data = await request.json();
|
||||
const { name, templateId, exercises, paused, elapsed, savedAt, expectedVersion, restStartedAt, restTotal } = data;
|
||||
|
||||
if (!name) {
|
||||
return json({ error: 'Name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const userId = session.user.nickname;
|
||||
const existing = await ActiveWorkout.findOne({ userId });
|
||||
|
||||
if (existing && expectedVersion != null && existing.version !== expectedVersion) {
|
||||
// Conflict — client is out of date
|
||||
return json(
|
||||
{ error: 'Version conflict', workout: existing },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const newVersion = existing ? existing.version + 1 : 1;
|
||||
|
||||
const doc = await ActiveWorkout.findOneAndUpdate(
|
||||
{ userId },
|
||||
{
|
||||
$set: {
|
||||
name,
|
||||
templateId: templateId ?? null,
|
||||
exercises: exercises ?? [],
|
||||
paused: paused ?? false,
|
||||
elapsed: elapsed ?? 0,
|
||||
savedAt: savedAt ?? Date.now(),
|
||||
restStartedAt: restStartedAt ?? null,
|
||||
restTotal: restTotal ?? 0,
|
||||
version: newVersion
|
||||
},
|
||||
$setOnInsert: { userId }
|
||||
},
|
||||
{ upsert: true, new: true, lean: true }
|
||||
);
|
||||
|
||||
// Broadcast to all other connected devices
|
||||
broadcast(userId, 'update', doc);
|
||||
|
||||
return json({ workout: doc });
|
||||
} catch (error) {
|
||||
console.error('Error updating active workout:', error);
|
||||
return json({ error: 'Failed to update active workout' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/fitness/workout/active — clear active workout (finish/cancel)
|
||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session?.user?.nickname) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await dbConnect();
|
||||
const userId = session.user.nickname;
|
||||
await ActiveWorkout.deleteOne({ userId });
|
||||
|
||||
// Notify all devices that workout is finished
|
||||
broadcast(userId, 'finished', { active: false });
|
||||
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting active workout:', error);
|
||||
return json({ error: 'Failed to delete active workout' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
51
src/routes/api/fitness/workout/active/stream/+server.ts
Normal file
51
src/routes/api/fitness/workout/active/stream/+server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { addConnection, removeConnection } from '$lib/server/sseManager';
|
||||
|
||||
// GET /api/fitness/workout/active/stream — SSE endpoint
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session?.user?.nickname) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.nickname;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
let controllerRef: ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controllerRef = controller;
|
||||
addConnection(userId, controller);
|
||||
|
||||
// Send initial heartbeat
|
||||
try {
|
||||
controller.enqueue(encoder.encode(': heartbeat\n\n'));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
removeConnection(userId, controllerRef);
|
||||
}
|
||||
});
|
||||
|
||||
// Heartbeat interval to keep connection alive
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
try {
|
||||
controllerRef.enqueue(encoder.encode(': heartbeat\n\n'));
|
||||
} catch {
|
||||
clearInterval(heartbeatInterval);
|
||||
removeConnection(userId, controllerRef);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no' // disable nginx buffering
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,19 +1,27 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import UserHeader from '$lib/components/UserHeader.svelte';
|
||||
import { User, Clock, Dumbbell, ListChecks, Ruler } from 'lucide-svelte';
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
|
||||
import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let user = $derived(data.session?.user);
|
||||
|
||||
const workout = getWorkout();
|
||||
const sync = getWorkoutSync();
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
workout.restore();
|
||||
workout.onChange(() => sync.notifyChange());
|
||||
await sync.init();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
sync.destroy();
|
||||
});
|
||||
|
||||
/** @param {string} path */
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { Plus, Trash2, Play, Pencil, X, Save } from 'lucide-svelte';
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
|
||||
import { getExerciseById } from '$lib/data/exercises';
|
||||
import TemplateCard from '$lib/components/fitness/TemplateCard.svelte';
|
||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||
@@ -11,6 +12,7 @@
|
||||
let { data } = $props();
|
||||
|
||||
const workout = getWorkout();
|
||||
const sync = getWorkoutSync();
|
||||
let templates = $state(data.templates?.templates ? [...data.templates.templates] : []);
|
||||
let seeded = $state(false);
|
||||
|
||||
@@ -62,11 +64,13 @@
|
||||
async function startFromTemplate(template) {
|
||||
selectedTemplate = null;
|
||||
workout.startFromTemplate(template);
|
||||
await sync.onWorkoutStart();
|
||||
goto('/fitness/workout/active');
|
||||
}
|
||||
|
||||
function startEmpty() {
|
||||
async function startEmpty() {
|
||||
workout.startEmpty();
|
||||
await sync.onWorkoutStart();
|
||||
goto('/fitness/workout/active');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { Plus, Trash2, Play, Pause } from 'lucide-svelte';
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
|
||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
||||
import RestTimer from '$lib/components/fitness/RestTimer.svelte';
|
||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const workout = getWorkout();
|
||||
const sync = getWorkoutSync();
|
||||
let showPicker = $state(false);
|
||||
|
||||
/** @type {Record<string, Array<{ reps: number, weight: number }>>} */
|
||||
@@ -45,9 +48,8 @@
|
||||
|
||||
async function finishWorkout() {
|
||||
const sessionData = workout.finish();
|
||||
console.log('[finish] sessionData:', JSON.stringify(sessionData, null, 2));
|
||||
if (sessionData.exercises.length === 0) {
|
||||
console.warn('[finish] No completed exercises, aborting save');
|
||||
await sync.onWorkoutEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,16 +59,13 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sessionData)
|
||||
});
|
||||
console.log('[finish] POST response:', res.status, res.statusText);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
console.error('[finish] POST error body:', body);
|
||||
}
|
||||
await sync.onWorkoutEnd();
|
||||
if (res.ok) {
|
||||
goto('/fitness/history');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[finish] fetch error:', err);
|
||||
await sync.onWorkoutEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +94,7 @@
|
||||
{#if workout.paused}<Play size={16} />{:else}<Pause size={16} />{/if}
|
||||
</button>
|
||||
<span class="elapsed" class:paused={workout.paused}>{formatElapsed(workout.elapsedSeconds)}</span>
|
||||
<SyncIndicator status={sync.status} />
|
||||
</div>
|
||||
<button class="finish-btn" onclick={finishWorkout}>FINISH</button>
|
||||
</div>
|
||||
@@ -113,7 +113,11 @@
|
||||
total={workout.restTimerTotal}
|
||||
onComplete={() => workout.cancelRestTimer()}
|
||||
/>
|
||||
<button class="skip-rest" onclick={() => workout.cancelRestTimer()}>Skip</button>
|
||||
<div class="rest-controls">
|
||||
<button class="rest-adjust" onclick={() => workout.adjustRestTimer(-30)}>-30s</button>
|
||||
<button class="skip-rest" onclick={() => workout.cancelRestTimer()}>Skip</button>
|
||||
<button class="rest-adjust" onclick={() => workout.adjustRestTimer(30)}>+30s</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -153,7 +157,7 @@
|
||||
<button class="add-exercise-btn" onclick={() => showPicker = true}>
|
||||
<Plus size={18} /> ADD EXERCISE
|
||||
</button>
|
||||
<button class="cancel-btn" onclick={() => { workout.cancel(); goto('/fitness/workout'); }}>
|
||||
<button class="cancel-btn" onclick={async () => { workout.cancel(); await sync.onWorkoutEnd(); goto('/fitness/workout'); }}>
|
||||
CANCEL WORKOUT
|
||||
</button>
|
||||
</div>
|
||||
@@ -245,6 +249,25 @@
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.rest-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.rest-adjust {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.rest-adjust:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.skip-rest {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
Reference in New Issue
Block a user