fix(fitness): restore SSE mirror-finish without racing local summary
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.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.70.1",
|
||||
"version": "1.70.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | 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
|
||||
};
|
||||
|
||||
@@ -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=<id> 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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user