Compare commits
4 Commits
685f4cc892
...
5fd8027d3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
5fd8027d3e
|
|||
|
e87b8bd864
|
|||
|
eeed31aaf4
|
|||
|
e59e9679da
|
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.69.1",
|
||||
"version": "1.69.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script>
|
||||
import RestTimer from './RestTimer.svelte';
|
||||
import ExerciseName from './ExerciseName.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { detectFitnessLang } from '$lib/js/fitnessI18n';
|
||||
|
||||
/**
|
||||
* @type {{
|
||||
* active: boolean,
|
||||
* seconds: number,
|
||||
* total: number,
|
||||
* exerciseId?: string | null,
|
||||
* setIdx: number,
|
||||
* activeExerciseIdx: number,
|
||||
* restExerciseIdx: number,
|
||||
* onAdjust?: ((delta: number) => void) | null,
|
||||
* onSkip?: (() => void) | null
|
||||
* }}
|
||||
*/
|
||||
let {
|
||||
active,
|
||||
seconds,
|
||||
total,
|
||||
exerciseId = null,
|
||||
setIdx,
|
||||
activeExerciseIdx,
|
||||
restExerciseIdx,
|
||||
onAdjust = null,
|
||||
onSkip = null
|
||||
} = $props();
|
||||
|
||||
const lang = $derived(detectFitnessLang(page.url.pathname));
|
||||
const isEn = $derived(lang === 'en');
|
||||
const isOtherExercise = $derived(restExerciseIdx >= 0 && restExerciseIdx !== activeExerciseIdx);
|
||||
const setLabel = $derived(setIdx >= 0
|
||||
? (isEn ? `Rest · Set ${setIdx + 1}` : `Pause · Satz ${setIdx + 1}`)
|
||||
: (isEn ? 'Rest' : 'Pause'));
|
||||
</script>
|
||||
|
||||
{#if active && total > 0}
|
||||
<section class="active-rest" aria-live="polite">
|
||||
<header class="rest-context">
|
||||
<span class="rest-label">{setLabel}</span>
|
||||
{#if isOtherExercise && exerciseId}
|
||||
<span class="rest-sep" aria-hidden="true">·</span>
|
||||
<span class="rest-exercise"><ExerciseName {exerciseId} plain /></span>
|
||||
{/if}
|
||||
</header>
|
||||
<RestTimer
|
||||
{seconds}
|
||||
{total}
|
||||
onComplete={onSkip}
|
||||
{onAdjust}
|
||||
{onSkip}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.active-rest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.rest-context {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.rest-label {
|
||||
color: var(--blue);
|
||||
}
|
||||
.rest-sep {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.rest-exercise {
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,6 @@
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Square from '@lucide/svelte/icons/square';
|
||||
import { METRIC_LABELS } from '$lib/data/exercises';
|
||||
import RestTimer from './RestTimer.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
|
||||
|
||||
@@ -17,14 +16,9 @@
|
||||
* previousSets?: Array<Record<string, any>> | null,
|
||||
* metrics?: Array<'weight' | 'reps' | 'rpe' | 'distance' | 'duration'>,
|
||||
* editable?: boolean,
|
||||
* restAfterSet?: number,
|
||||
* restSeconds?: number,
|
||||
* restTotal?: number,
|
||||
* holdAfterSet?: number,
|
||||
* holdSeconds?: number,
|
||||
* holdTotal?: number,
|
||||
* onRestAdjust?: ((delta: number) => void) | null,
|
||||
* onRestSkip?: (() => void) | null,
|
||||
* timedHold?: boolean,
|
||||
* onHoldSkip?: (() => void) | null,
|
||||
* onUpdate?: ((setIndex: number, data: Record<string, number | null>) => void) | null,
|
||||
@@ -37,15 +31,10 @@
|
||||
previousSets = null,
|
||||
metrics = ['weight', 'reps', 'rpe'],
|
||||
editable = false,
|
||||
restAfterSet = -1,
|
||||
restSeconds = 0,
|
||||
restTotal = 0,
|
||||
timedHold = false,
|
||||
holdAfterSet = -1,
|
||||
holdSeconds = 0,
|
||||
holdTotal = 0,
|
||||
onRestAdjust = null,
|
||||
onRestSkip = null,
|
||||
onHoldSkip = null,
|
||||
onUpdate = null,
|
||||
onToggleComplete = null,
|
||||
@@ -215,19 +204,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if restAfterSet === i && restTotal > 0}
|
||||
<tr class="rest-row">
|
||||
<td colspan={totalCols} class="rest-cell">
|
||||
<RestTimer
|
||||
seconds={restSeconds}
|
||||
total={restTotal}
|
||||
onComplete={onRestSkip}
|
||||
onAdjust={onRestAdjust}
|
||||
onSkip={onRestSkip}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -98,6 +98,7 @@ export const de = {
|
||||
workout_name_placeholder: "Trainingsname",
|
||||
cancel_workout: "TRAINING ABBRECHEN",
|
||||
finish: "BEENDEN",
|
||||
finish_early: "VORZEITIG BEENDEN",
|
||||
new_set_added: "neuer Satz",
|
||||
new_sets_added: "neue Sätze",
|
||||
exercises_title: "Übungen",
|
||||
|
||||
@@ -98,6 +98,7 @@ export const en = {
|
||||
workout_name_placeholder: "Workout name",
|
||||
cancel_workout: "CANCEL WORKOUT",
|
||||
finish: "FINISH",
|
||||
finish_early: "FINISH EARLY",
|
||||
new_set_added: "new set",
|
||||
new_sets_added: "new sets",
|
||||
exercises_title: "Exercises",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
+3
-3
@@ -239,7 +239,7 @@
|
||||
aria-selected={view === 'ring'}
|
||||
onclick={() => (view = 'ring')}
|
||||
>
|
||||
◯ {lang === 'de' ? 'Jahr' : lang === 'la' ? 'Annus' : 'Year'}
|
||||
◯ {lang === 'de' ? 'Jahr · Ring' : lang === 'la' ? 'Annus · Annulus' : 'Year · Ring'}
|
||||
</button>
|
||||
<button
|
||||
class:active={view === 'hills'}
|
||||
@@ -247,7 +247,7 @@
|
||||
aria-selected={view === 'hills'}
|
||||
onclick={() => (view = 'hills')}
|
||||
>
|
||||
∿ {lang === 'de' ? 'Hügel' : lang === 'la' ? 'Colles' : 'Hills'}
|
||||
∿ {lang === 'de' ? 'Jahr · Hügel' : lang === 'la' ? 'Annus · Colles' : 'Year · Hills'}
|
||||
</button>
|
||||
<button
|
||||
class:active={view === 'grid'}
|
||||
@@ -255,7 +255,7 @@
|
||||
aria-selected={view === 'grid'}
|
||||
onclick={() => (view = 'grid')}
|
||||
>
|
||||
▦ {lang === 'de' ? 'Monat' : lang === 'la' ? 'Mensis' : 'Month'}
|
||||
▦ {lang === 'de' ? 'Monat · Raster' : lang === 'la' ? 'Mensis · Tabula' : 'Month · Grid'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="overview-right">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||
import WorkoutRail from '$lib/components/fitness/WorkoutRail.svelte';
|
||||
import WorkoutFocusCard from '$lib/components/fitness/WorkoutFocusCard.svelte';
|
||||
import ActiveRestTimer from '$lib/components/fitness/ActiveRestTimer.svelte';
|
||||
import Toggle from '$lib/components/Toggle.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
@@ -96,6 +97,7 @@
|
||||
const workoutSetsTotal = $derived(
|
||||
workout.exercises.reduce((/** @type {number} */ n, /** @type {any} */ ex) => n + ex.sets.length, 0)
|
||||
);
|
||||
const hasUnfinishedSets = $derived(workoutSetsTotal > 0 && workoutSetsDone < workoutSetsTotal);
|
||||
|
||||
/** @param {number} idx */
|
||||
function setFocus(idx) { focusedIdx = idx; }
|
||||
@@ -136,6 +138,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);
|
||||
@@ -724,15 +753,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);
|
||||
@@ -1681,6 +1711,20 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<ActiveRestTimer
|
||||
active={workout.restTimerActive}
|
||||
seconds={workout.restTimerSeconds}
|
||||
total={workout.restTimerTotal}
|
||||
exerciseId={workout.restExerciseIdx >= 0 && workout.exercises[workout.restExerciseIdx]
|
||||
? workout.exercises[workout.restExerciseIdx].exerciseId
|
||||
: null}
|
||||
setIdx={workout.restSetIdx}
|
||||
activeExerciseIdx={activeIdx}
|
||||
restExerciseIdx={workout.restExerciseIdx}
|
||||
onAdjust={(delta) => workout.adjustRestTimer(delta)}
|
||||
onSkip={cancelRest}
|
||||
/>
|
||||
|
||||
<div class="exercise-block focused">
|
||||
<SetTable
|
||||
sets={activeExercise.sets}
|
||||
@@ -1688,14 +1732,9 @@
|
||||
metrics={exMetrics}
|
||||
editable={true}
|
||||
timedHold={isDurationOnly}
|
||||
restAfterSet={workout.restTimerActive && workout.restExerciseIdx === activeIdx ? workout.restSetIdx : -1}
|
||||
restSeconds={workout.restTimerSeconds}
|
||||
restTotal={workout.restTimerTotal}
|
||||
holdAfterSet={workout.holdTimerActive && workout.holdExerciseIdx === activeIdx ? workout.holdSetIdx : -1}
|
||||
holdSeconds={workout.holdTimerSeconds}
|
||||
holdTotal={workout.holdTimerTotal}
|
||||
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
|
||||
onRestSkip={cancelRest}
|
||||
onHoldSkip={() => workout.cancelHoldTimer()}
|
||||
onUpdate={(setIdx, d) => workout.updateSet(activeIdx, setIdx, d)}
|
||||
onToggleComplete={(setIdx) => {
|
||||
@@ -1732,7 +1771,9 @@
|
||||
<button class="cancel-btn" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
|
||||
{t.cancel_workout}
|
||||
</button>
|
||||
<button class="finish-btn" onclick={finishWorkout}>{t.finish}</button>
|
||||
<button class="finish-btn" class:premature={hasUnfinishedSets} onclick={finishWorkout}>
|
||||
{hasUnfinishedSets ? t.finish_early : t.finish}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -2094,6 +2135,13 @@
|
||||
.finish-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.finish-btn.premature {
|
||||
background: var(--orange);
|
||||
color: var(--nord0);
|
||||
}
|
||||
.finish-btn.premature:hover {
|
||||
background: color-mix(in srgb, var(--orange) 85%, var(--nord0));
|
||||
}
|
||||
|
||||
/* GPS section */
|
||||
.gps-section {
|
||||
|
||||
Reference in New Issue
Block a user