4 Commits

Author SHA1 Message Date
Alexander 5fd8027d3e feat(fitness): label finish button "FINISH EARLY" with unfinished sets
CI / update (push) Has been cancelled
Switches the active-workout finish button to "FINISH EARLY" /
"VORZEITIG BEENDEN" with an orange tint when any set in the workout is
still incomplete, so users can tell at a glance whether they're
wrapping up cleanly or cutting it short.
2026-05-10 14:56:12 +02:00
Alexander e87b8bd864 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.
2026-05-10 14:42:50 +02:00
Alexander eeed31aaf4 fix(fitness): hoist rest timer above set table, persist across exercise switches
Rest timer was inlined as a row inside SetTable, tied to a specific set.
Switching to another exercise hid it from view. Now lives below the
exercise focus card, renders whenever a rest is active regardless of
focused exercise, and labels which set/exercise it belongs to when
looking at a different one.
2026-05-10 14:17:36 +02:00
Alexander e59e9679da fix(faith): align calendar view tab labels on timespan + shape
Year/Hills/Month mixed two axes. Now: Year · Ring, Year · Hills,
Month · Grid (with de + la translations).
2026-05-10 14:06:40 +02:00
9 changed files with 188 additions and 43 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.69.1", "version": "1.69.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "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 Play from '@lucide/svelte/icons/play';
import Square from '@lucide/svelte/icons/square'; import Square from '@lucide/svelte/icons/square';
import { METRIC_LABELS } from '$lib/data/exercises'; import { METRIC_LABELS } from '$lib/data/exercises';
import RestTimer from './RestTimer.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { detectFitnessLang, m } from '$lib/js/fitnessI18n'; import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
@@ -17,14 +16,9 @@
* previousSets?: Array<Record<string, any>> | null, * previousSets?: Array<Record<string, any>> | null,
* metrics?: Array<'weight' | 'reps' | 'rpe' | 'distance' | 'duration'>, * metrics?: Array<'weight' | 'reps' | 'rpe' | 'distance' | 'duration'>,
* editable?: boolean, * editable?: boolean,
* restAfterSet?: number,
* restSeconds?: number,
* restTotal?: number,
* holdAfterSet?: number, * holdAfterSet?: number,
* holdSeconds?: number, * holdSeconds?: number,
* holdTotal?: number, * holdTotal?: number,
* onRestAdjust?: ((delta: number) => void) | null,
* onRestSkip?: (() => void) | null,
* timedHold?: boolean, * timedHold?: boolean,
* onHoldSkip?: (() => void) | null, * onHoldSkip?: (() => void) | null,
* onUpdate?: ((setIndex: number, data: Record<string, number | null>) => void) | null, * onUpdate?: ((setIndex: number, data: Record<string, number | null>) => void) | null,
@@ -37,15 +31,10 @@
previousSets = null, previousSets = null,
metrics = ['weight', 'reps', 'rpe'], metrics = ['weight', 'reps', 'rpe'],
editable = false, editable = false,
restAfterSet = -1,
restSeconds = 0,
restTotal = 0,
timedHold = false, timedHold = false,
holdAfterSet = -1, holdAfterSet = -1,
holdSeconds = 0, holdSeconds = 0,
holdTotal = 0, holdTotal = 0,
onRestAdjust = null,
onRestSkip = null,
onHoldSkip = null, onHoldSkip = null,
onUpdate = null, onUpdate = null,
onToggleComplete = null, onToggleComplete = null,
@@ -215,19 +204,6 @@
</td> </td>
</tr> </tr>
{/if} {/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} {/each}
</tbody> </tbody>
</table> </table>
+1
View File
@@ -98,6 +98,7 @@ export const de = {
workout_name_placeholder: "Trainingsname", workout_name_placeholder: "Trainingsname",
cancel_workout: "TRAINING ABBRECHEN", cancel_workout: "TRAINING ABBRECHEN",
finish: "BEENDEN", finish: "BEENDEN",
finish_early: "VORZEITIG BEENDEN",
new_set_added: "neuer Satz", new_set_added: "neuer Satz",
new_sets_added: "neue Sätze", new_sets_added: "neue Sätze",
exercises_title: "Übungen", exercises_title: "Übungen",
+1
View File
@@ -98,6 +98,7 @@ export const en = {
workout_name_placeholder: "Workout name", workout_name_placeholder: "Workout name",
cancel_workout: "CANCEL WORKOUT", cancel_workout: "CANCEL WORKOUT",
finish: "FINISH", finish: "FINISH",
finish_early: "FINISH EARLY",
new_set_added: "new set", new_set_added: "new set",
new_sets_added: "new sets", new_sets_added: "new sets",
exercises_title: "Exercises", exercises_title: "Exercises",
+23 -5
View File
@@ -37,6 +37,7 @@ export function createWorkoutSync() {
let status: SyncStatus = $state('idle'); let status: SyncStatus = $state('idle');
let serverVersion = $state(0); let serverVersion = $state(0);
let lastFinishedSession: any = $state(null);
let eventSource: EventSource | null = null; let eventSource: EventSource | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -166,8 +167,15 @@ export function createWorkoutSync() {
} catch {} } catch {}
}); });
eventSource.addEventListener('finished', () => { eventSource.addEventListener('finished', (e) => {
// Another device finished the workout // 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(); workout.cancel();
disconnectSSE(); disconnectSSE();
}); });
@@ -217,14 +225,22 @@ export function createWorkoutSync() {
connectSSE(); connectSSE();
} }
/** Called when workout finishes or is cancelled — clean up server state */ /** Called when workout finishes or is cancelled — clean up server state.
async function onWorkoutEnd() { * 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(); disconnectSSE();
try { 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 {} } 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 */ /** Called on app load — reconcile local vs server state */
async function init() { async function init() {
try { try {
@@ -290,9 +306,11 @@ export function createWorkoutSync() {
return { return {
get status() { return status; }, get status() { return status; },
get serverVersion() { return serverVersion; }, get serverVersion() { return serverVersion; },
get lastFinishedSession() { return lastFinishedSession; },
init, init,
onWorkoutStart, onWorkoutStart,
onWorkoutEnd, onWorkoutEnd,
clearFinishedSession,
notifyChange, notifyChange,
destroy destroy
}; };
@@ -239,7 +239,7 @@
aria-selected={view === 'ring'} aria-selected={view === 'ring'}
onclick={() => (view = 'ring')} onclick={() => (view = 'ring')}
> >
{lang === 'de' ? 'Jahr' : lang === 'la' ? 'Annus' : 'Year'} {lang === 'de' ? 'Jahr · Ring' : lang === 'la' ? 'Annus · Annulus' : 'Year · Ring'}
</button> </button>
<button <button
class:active={view === 'hills'} class:active={view === 'hills'}
@@ -247,7 +247,7 @@
aria-selected={view === 'hills'} aria-selected={view === 'hills'}
onclick={() => (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>
<button <button
class:active={view === 'grid'} class:active={view === 'grid'}
@@ -255,7 +255,7 @@
aria-selected={view === 'grid'} aria-selected={view === 'grid'}
onclick={() => (view = 'grid')} onclick={() => (view = 'grid')}
> >
{lang === 'de' ? 'Monat' : lang === 'la' ? 'Mensis' : 'Month'} {lang === 'de' ? 'Monat · Raster' : lang === 'la' ? 'Mensis · Tabula' : 'Month · Grid'}
</button> </button>
</div> </div>
<div class="overview-right"> <div class="overview-right">
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db'; import { dbConnect } from '$utils/db';
import { ActiveWorkout } from '$models/ActiveWorkout'; import { ActiveWorkout } from '$models/ActiveWorkout';
import { WorkoutSession } from '$models/WorkoutSession';
import { broadcast } from '$lib/server/sseManager'; import { broadcast } from '$lib/server/sseManager';
// GET /api/fitness/workout/active — fetch current active workout // 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) // 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(); const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) { if (!session?.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 }); return json({ error: 'Unauthorized' }, { status: 401 });
@@ -102,8 +105,17 @@ export const DELETE: RequestHandler = async ({ locals }) => {
const userId = session.user.nickname; const userId = session.user.nickname;
await ActiveWorkout.deleteOne({ userId }); await ActiveWorkout.deleteOne({ userId });
// Notify all devices that workout is finished const sessionId = url.searchParams.get('sessionId');
broadcast(userId, 'finished', { active: false }); 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 }); return json({ ok: true });
} catch (error) { } catch (error) {
@@ -40,6 +40,7 @@
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte'; import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
import WorkoutRail from '$lib/components/fitness/WorkoutRail.svelte'; import WorkoutRail from '$lib/components/fitness/WorkoutRail.svelte';
import WorkoutFocusCard from '$lib/components/fitness/WorkoutFocusCard.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 Toggle from '$lib/components/Toggle.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -96,6 +97,7 @@
const workoutSetsTotal = $derived( const workoutSetsTotal = $derived(
workout.exercises.reduce((/** @type {number} */ n, /** @type {any} */ ex) => n + ex.sets.length, 0) workout.exercises.reduce((/** @type {number} */ n, /** @type {any} */ ex) => n + ex.sets.length, 0)
); );
const hasUnfinishedSets = $derived(workoutSetsTotal > 0 && workoutSetsDone < workoutSetsTotal);
/** @param {number} idx */ /** @param {number} idx */
function setFocus(idx) { focusedIdx = idx; } function setFocus(idx) { focusedIdx = idx; }
@@ -136,6 +138,33 @@
/** @type {any[]} */ /** @type {any[]} */
let templateDiffs = $state([]); let templateDiffs = $state([]);
let templateUpdateStatus = $state('idle'); // 'idle' | 'updating' | 'done' 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 offlineQueued = $state(false);
let useGps = $state(gps.isTracking); let useGps = $state(gps.isTracking);
@@ -724,15 +753,16 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sessionData) body: JSON.stringify(sessionData)
}); });
await sync.onWorkoutEnd();
if (res.ok) { if (res.ok) {
const d = await res.json(); const d = await res.json();
completionData = buildCompletion(sessionData, d.session); completionData = buildCompletion(sessionData, d.session);
computeTemplateDiff(completionData); computeTemplateDiff(completionData);
await sync.onWorkoutEnd(d.session?._id);
} else { } else {
await queueSession(sessionData); await queueSession(sessionData);
offlineQueued = true; offlineQueued = true;
completionData = buildCompletion(sessionData, { _id: null }); completionData = buildCompletion(sessionData, { _id: null });
await sync.onWorkoutEnd();
} }
} catch { } catch {
await queueSession(sessionData); 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"> <div class="exercise-block focused">
<SetTable <SetTable
sets={activeExercise.sets} sets={activeExercise.sets}
@@ -1688,14 +1732,9 @@
metrics={exMetrics} metrics={exMetrics}
editable={true} editable={true}
timedHold={isDurationOnly} 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} holdAfterSet={workout.holdTimerActive && workout.holdExerciseIdx === activeIdx ? workout.holdSetIdx : -1}
holdSeconds={workout.holdTimerSeconds} holdSeconds={workout.holdTimerSeconds}
holdTotal={workout.holdTimerTotal} holdTotal={workout.holdTimerTotal}
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
onRestSkip={cancelRest}
onHoldSkip={() => workout.cancelHoldTimer()} onHoldSkip={() => workout.cancelHoldTimer()}
onUpdate={(setIdx, d) => workout.updateSet(activeIdx, setIdx, d)} onUpdate={(setIdx, d) => workout.updateSet(activeIdx, setIdx, d)}
onToggleComplete={(setIdx) => { 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}`); }}> <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} {t.cancel_workout}
</button> </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> </div>
</main> </main>
</div> </div>
@@ -2094,6 +2135,13 @@
.finish-btn:active { .finish-btn:active {
transform: scale(0.98); 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 */
.gps-section { .gps-section {