From 928774084f9602da83166f63f6748ba7d01cf3fd Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Tue, 12 May 2026 17:40:41 +0200 Subject: [PATCH] fix(fitness): restore SSE mirror-finish without racing local summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reapplies the e87b8bd8 mirror-finish behaviour (saved session is broadcast on Finish so other devices can render the completion overview) behind a _finishingLocally guard. workout.finish() flips workout.active synchronously, but completionData is only set after the awaited POST resolves — without the guard, the redirect effect fired in that gap and navigated away before the summary could render on desktop. --- package.json | 2 +- src/lib/js/workoutSync.svelte.ts | 28 +++++++++--- .../api/fitness/workout/active/+server.ts | 18 ++++++-- .../[active=fitnessActive]/+page.svelte | 44 ++++++++++++++++++- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 30571700..a4ee7266 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.70.1", + "version": "1.70.2", "private": true, "type": "module", "scripts": { diff --git a/src/lib/js/workoutSync.svelte.ts b/src/lib/js/workoutSync.svelte.ts index 31e4a612..f8f7a7a6 100644 --- a/src/lib/js/workoutSync.svelte.ts +++ b/src/lib/js/workoutSync.svelte.ts @@ -37,6 +37,7 @@ export function createWorkoutSync() { let status: SyncStatus = $state('idle'); let serverVersion = $state(0); + let lastFinishedSession: any = $state(null); let eventSource: EventSource | null = null; let debounceTimer: ReturnType | null = null; let reconnectTimer: ReturnType | null = null; @@ -166,8 +167,15 @@ export function createWorkoutSync() { } catch {} }); - eventSource.addEventListener('finished', () => { - // Another device finished the workout + eventSource.addEventListener('finished', (e) => { + // Another device finished the workout. If they passed along the saved + // session, expose it so the active page can build the completion overview. + try { + const data = JSON.parse(e.data); + lastFinishedSession = data?.session ?? null; + } catch { + lastFinishedSession = null; + } workout.cancel(); disconnectSSE(); }); @@ -217,14 +225,22 @@ export function createWorkoutSync() { connectSSE(); } - /** Called when workout finishes or is cancelled — clean up server state */ - async function onWorkoutEnd() { + /** Called when workout finishes or is cancelled — clean up server state. + * Pass the just-saved session id so other devices receive it via SSE + * and can render the finish overview. */ + async function onWorkoutEnd(sessionId?: string | null) { disconnectSSE(); try { - await fetch('/api/fitness/workout/active', { method: 'DELETE' }); + const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : ''; + await fetch(`/api/fitness/workout/active${qs}`, { method: 'DELETE' }); } catch {} } + /** Clear the finished-session payload after the page has consumed it. */ + function clearFinishedSession() { + lastFinishedSession = null; + } + /** Called on app load — reconcile local vs server state */ async function init() { try { @@ -290,9 +306,11 @@ export function createWorkoutSync() { return { get status() { return status; }, get serverVersion() { return serverVersion; }, + get lastFinishedSession() { return lastFinishedSession; }, init, onWorkoutStart, onWorkoutEnd, + clearFinishedSession, notifyChange, destroy }; diff --git a/src/routes/api/fitness/workout/active/+server.ts b/src/routes/api/fitness/workout/active/+server.ts index 13a6bdf3..595687eb 100644 --- a/src/routes/api/fitness/workout/active/+server.ts +++ b/src/routes/api/fitness/workout/active/+server.ts @@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { dbConnect } from '$utils/db'; import { ActiveWorkout } from '$models/ActiveWorkout'; +import { WorkoutSession } from '$models/WorkoutSession'; import { broadcast } from '$lib/server/sseManager'; // GET /api/fitness/workout/active — fetch current active workout @@ -91,7 +92,9 @@ export const PUT: RequestHandler = async ({ request, locals }) => { }; // DELETE /api/fitness/workout/active — clear active workout (finish/cancel) -export const DELETE: RequestHandler = async ({ locals }) => { +// Optional ?sessionId= attaches the just-saved session to the broadcast so +// other devices can render the finish overview instead of a blank page. +export const DELETE: RequestHandler = async ({ locals, url }) => { const session = locals.session ?? await locals.auth(); if (!session?.user?.nickname) { return json({ error: 'Unauthorized' }, { status: 401 }); @@ -102,8 +105,17 @@ export const DELETE: RequestHandler = async ({ locals }) => { const userId = session.user.nickname; await ActiveWorkout.deleteOne({ userId }); - // Notify all devices that workout is finished - broadcast(userId, 'finished', { active: false }); + const sessionId = url.searchParams.get('sessionId'); + let sessionDoc: unknown = null; + if (sessionId) { + try { + sessionDoc = await WorkoutSession.findOne({ _id: sessionId, createdBy: userId }).lean(); + } catch { + sessionDoc = null; + } + } + + broadcast(userId, 'finished', { active: false, session: sessionDoc }); return json({ ok: true }); } catch (error) { diff --git a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte index f62d0ad1..1347aa54 100644 --- a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte +++ b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte @@ -161,8 +161,42 @@ let templateDiffs = $state([]); let templateUpdateStatus = $state('idle'); // 'idle' | 'updating' | 'done' + // Track whether we've ever observed an active workout on this page so the + // remote-end redirect doesn't fire during the brief gap before localStorage + // + server hydration completes on initial mount. + let _everActive = false; + + // True while the local finishWorkout() flow is in flight. workout.active + // goes false the moment workout.finish() runs, but completionData is only + // populated after the awaited POST /api/fitness/sessions resolves — the + // redirect effect below would otherwise fire during that gap and whisk the + // user off the page before the completion overview renders. + let _finishingLocally = $state(false); + + // Mirror the finish overview when another device finishes the workout. + // Sync surfaces the saved session via SSE; treat it like the local finish path. + $effect(() => { + const remoteSession = sync.lastFinishedSession; + if (!remoteSession || completionData) return; + completionData = buildCompletion(remoteSession, remoteSession); + computeTemplateDiff(completionData); + sync.clearFinishedSession(); + }); + + // If the workout ends remotely without a session payload (cancel from another + // device, or that device couldn't post the session), exit cleanly instead of + // leaving this device on a blank active page. + $effect(() => { + if (workout.active) { + _everActive = true; + return; + } + if (!_everActive || _finishingLocally || completionData || sync.lastFinishedSession) return; + goto(`/fitness/${sl.workout}`); + }); + // Celebratory fanfare on workout completion. Fires once when completionData - // becomes truthy after a successful finish. + // becomes truthy (whether from the local finish path or remote-finish SSE). let _completionSoundPlayed = false; $effect(() => { if (completionData && !_completionSoundPlayed) { @@ -697,6 +731,11 @@ }); async function finishWorkout() { + // Guard the redirect-on-inactive effect for the duration of this flow. + // workout.finish() flips workout.active to false synchronously, but + // completionData is set only after the awaited POST below resolves. + _finishingLocally = true; + // Stop GPS tracking and collect track data const gpsTrack = gps.isTracking ? await gps.stop() : []; const wasGpsMode = workout.mode === 'gps'; @@ -758,15 +797,16 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sessionData) }); - await sync.onWorkoutEnd(); if (res.ok) { const d = await res.json(); completionData = buildCompletion(sessionData, d.session); computeTemplateDiff(completionData); + await sync.onWorkoutEnd(d.session?._id); } else { await queueSession(sessionData); offlineQueued = true; completionData = buildCompletion(sessionData, { _id: null }); + await sync.onWorkoutEnd(); } } catch { await queueSession(sessionData);