From e87b8bd8647961e0f6a4d93f5fe106440b7ffe0e Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sun, 10 May 2026 14:42:50 +0200 Subject: [PATCH] fix(fitness): mirror finish overview to other devices via SSE Other devices were left on a blank active-workout page when one device hit Finish. The finish broadcast now carries the saved session document, and the receiving page builds the completion overview from it. If the remote end-of-workout has no session payload (cancel, or the finishing device couldn't post the session), receivers redirect to the workout home instead of stranding on a blank page. --- package.json | 2 +- src/lib/js/workoutSync.svelte.ts | 28 +++++++++++++---- .../api/fitness/workout/active/+server.ts | 18 +++++++++-- .../[active=fitnessActive]/+page.svelte | 30 ++++++++++++++++++- 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 849469f2..1829975a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.69.3", + "version": "1.69.4", "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 f4be0f61..bd079c88 100644 --- a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte +++ b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte @@ -137,6 +137,33 @@ /** @type {any[]} */ 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; + + // 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 || completionData || sync.lastFinishedSession) return; + goto(`/fitness/${sl.workout}`); + }); let offlineQueued = $state(false); let useGps = $state(gps.isTracking); @@ -725,15 +752,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);