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 292ec20320
commit c9e8e9919c
10 changed files with 814 additions and 21 deletions

View 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>

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
};
}

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;
}

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;
}

100
src/models/ActiveWorkout.ts Normal file
View 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);

View 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 });
}
};

View 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
}
});
};

View File

@@ -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 */

View File

@@ -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');
}

View File

@@ -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;