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.
This commit is contained in:
2026-05-10 14:42:50 +02:00
parent eeed31aaf4
commit e87b8bd864
4 changed files with 68 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.69.3",
"version": "1.69.4",
"private": true,
"type": "module",
"scripts": {
+23 -5
View File
@@ -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) {
@@ -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);