Files
homepage/src/routes/fitness/workout/active/+page.svelte
Alexander Bocken 0e5d6dceb9
All checks were successful
CI / update (push) Successful in 2m6s
fitness: add workout completion summary with PR detection
Show summary screen after finishing a workout instead of immediately
redirecting. Displays duration, tonnage, distance, per-exercise stats
(pace, e1RM, top weight), and detected PRs compared to previous session.
2026-03-20 07:14:24 +01:00

691 lines
17 KiB
Svelte

<script>
import { goto } from '$app/navigation';
import { Plus, Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route } from 'lucide-svelte';
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import SetTable from '$lib/components/fitness/SetTable.svelte';
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
import { onMount } from 'svelte';
const workout = getWorkout();
const sync = getWorkoutSync();
let showPicker = $state(false);
let restExerciseIdx = $state(-1);
let restSetIdx = $state(-1);
/** @type {Record<string, Array<Record<string, any>>>} */
let previousData = $state({});
/** @type {any} */
let completionData = $state(null);
onMount(() => {
if (!workout.active && !completionData) {
goto('/fitness/workout');
}
});
/** @param {string[]} exerciseIds */
async function fetchPreviousData(exerciseIds) {
const promises = exerciseIds.map(async (id) => {
if (previousData[id]) return;
try {
const res = await fetch(`/api/fitness/exercises/${id}/history?limit=1`);
if (res.ok) {
const d = await res.json();
if (d.history?.length > 0 && d.history[0].sets) {
previousData[id] = d.history[0].sets;
}
}
} catch {}
});
await Promise.all(promises);
}
/** @param {string} exerciseId */
function addExerciseFromPicker(exerciseId) {
workout.addExercise(exerciseId);
fetchPreviousData([exerciseId]);
}
async function finishWorkout() {
const sessionData = workout.finish();
if (sessionData.exercises.length === 0) {
await sync.onWorkoutEnd();
await goto('/fitness/workout');
return;
}
try {
const res = await fetch('/api/fitness/sessions', {
method: 'POST',
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);
}
} catch (err) {
console.error('[finish] fetch error:', err);
await sync.onWorkoutEnd();
}
}
/**
* Build the completion summary from local session data + server response
* @param {any} local
* @param {any} saved
*/
function buildCompletion(local, saved) {
const startTime = new Date(local.startTime);
const endTime = new Date(local.endTime);
const durationMin = Math.round((endTime.getTime() - startTime.getTime()) / 60000);
let totalTonnage = 0;
let totalDistance = 0;
/** @type {any[]} */
const prs = [];
const exerciseSummaries = local.exercises.map((/** @type {any} */ ex) => {
const exercise = getExerciseById(ex.exerciseId);
const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance');
const isBilateral = exercise?.bilateral ?? false;
const weightMul = isBilateral ? 2 : 1;
const prev = previousData[ex.exerciseId] ?? [];
let exTonnage = 0;
let exDistance = 0;
let exDuration = 0;
let bestWeight = 0;
let bestEst1rm = 0;
let bestVolume = 0;
let sets = 0;
for (const s of ex.sets) {
if (!s.completed) continue;
sets++;
if (isCardio) {
exDistance += s.distance ?? 0;
exDuration += s.duration ?? 0;
} else {
const w = (s.weight ?? 0) * weightMul;
const r = s.reps ?? 0;
const vol = w * r;
exTonnage += vol;
if (s.weight > bestWeight) bestWeight = s.weight;
const e1rm = r > 0 && s.weight > 0 ? (r === 1 ? s.weight : Math.round(s.weight * (1 + r / 30))) : 0;
if (e1rm > bestEst1rm) bestEst1rm = e1rm;
if (vol > bestVolume) bestVolume = vol;
}
}
totalTonnage += exTonnage;
totalDistance += exDistance;
// Detect PRs by comparing against previous session
if (prev.length > 0) {
let prevBestWeight = 0;
let prevBestEst1rm = 0;
let prevBestVolume = 0;
let prevBestDistance = 0;
for (const ps of prev) {
if (isCardio) {
prevBestDistance += ps.distance ?? 0;
} else {
const pw = ps.weight ?? 0;
const pr = ps.reps ?? 0;
if (pw > prevBestWeight) prevBestWeight = pw;
const pe = pr > 0 && pw > 0 ? (pr === 1 ? pw : Math.round(pw * (1 + pr / 30))) : 0;
if (pe > prevBestEst1rm) prevBestEst1rm = pe;
const pv = pw * pr * (isBilateral ? 2 : 1);
if (pv > prevBestVolume) prevBestVolume = pv;
}
}
if (!isCardio) {
if (bestWeight > prevBestWeight && prevBestWeight > 0) {
prs.push({ exerciseId: ex.exerciseId, type: 'Max Weight', value: `${bestWeight} kg` });
}
if (bestEst1rm > prevBestEst1rm && prevBestEst1rm > 0) {
prs.push({ exerciseId: ex.exerciseId, type: 'Est. 1RM', value: `${bestEst1rm} kg` });
}
if (bestVolume > prevBestVolume && prevBestVolume > 0) {
prs.push({ exerciseId: ex.exerciseId, type: 'Best Set Volume', value: `${Math.round(bestVolume)} kg` });
}
} else {
if (exDistance > prevBestDistance && prevBestDistance > 0) {
prs.push({ exerciseId: ex.exerciseId, type: 'Distance', value: `${exDistance.toFixed(1)} km` });
}
}
}
const pace = isCardio && exDistance > 0 && exDuration > 0 ? exDuration / exDistance : 0;
return {
exerciseId: ex.exerciseId,
sets,
isCardio,
tonnage: exTonnage,
distance: exDistance,
duration: exDuration,
pace,
bestWeight,
bestEst1rm
};
});
return {
sessionId: saved._id,
name: local.name,
durationMin,
totalTonnage,
totalDistance,
exerciseSummaries,
prs
};
}
/** @param {number} secs */
function formatElapsed(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/** @param {number} mins */
function formatDuration(mins) {
const h = Math.floor(mins / 60);
const m = mins % 60;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
/** @param {number} minPerKm */
function formatPace(minPerKm) {
const m = Math.floor(minPerKm);
const s = Math.round((minPerKm - m) * 60);
return `${m}:${s.toString().padStart(2, '0')} /km`;
}
function cancelRest() {
workout.cancelRestTimer();
restExerciseIdx = -1;
restSetIdx = -1;
}
// Fetch previous data for existing exercises on mount
onMount(() => {
if (workout.active && workout.exercises.length > 0) {
fetchPreviousData(workout.exercises.map((/** @type {any} */ e) => e.exerciseId));
}
});
</script>
{#if completionData}
<div class="completion">
<div class="completion-header">
<h1>Workout Complete</h1>
{#if completionData.prs.length > 0}
<div class="pr-badge">
<span class="pr-badge-count">{completionData.prs.length}</span>
<Trophy size={20} />
</div>
{/if}
<p class="completion-name">{completionData.name}</p>
</div>
<div class="completion-stats">
<div class="comp-stat">
<Clock size={18} />
<span class="comp-stat-value">{formatDuration(completionData.durationMin)}</span>
<span class="comp-stat-label">Duration</span>
</div>
{#if completionData.totalTonnage > 0}
<div class="comp-stat">
<Dumbbell size={18} />
<span class="comp-stat-value">
{completionData.totalTonnage >= 1000
? `${(completionData.totalTonnage / 1000).toFixed(1)}t`
: `${Math.round(completionData.totalTonnage)} kg`}
</span>
<span class="comp-stat-label">Tonnage</span>
</div>
{/if}
{#if completionData.totalDistance > 0}
<div class="comp-stat">
<Route size={18} />
<span class="comp-stat-value">{completionData.totalDistance.toFixed(1)} km</span>
<span class="comp-stat-label">Distance</span>
</div>
{/if}
</div>
{#if completionData.prs.length > 0}
<div class="prs-section">
<h2><Trophy size={16} /> Personal Records</h2>
<div class="pr-list">
{#each completionData.prs as pr}
<div class="pr-item">
<span class="pr-exercise">{getExerciseById(pr.exerciseId)?.name ?? pr.exerciseId}</span>
<span class="pr-detail">{pr.type}: <strong>{pr.value}</strong></span>
</div>
{/each}
</div>
</div>
{/if}
<div class="exercise-summaries">
<h2>Exercises</h2>
{#each completionData.exerciseSummaries as ex}
<div class="ex-summary">
<div class="ex-summary-header">
<span class="ex-summary-name">{getExerciseById(ex.exerciseId)?.name ?? ex.exerciseId}</span>
<span class="ex-summary-sets">{ex.sets} set{ex.sets !== 1 ? 's' : ''}</span>
</div>
<div class="ex-summary-stats">
{#if ex.isCardio}
{#if ex.distance > 0}
<span>{ex.distance.toFixed(1)} km</span>
{/if}
{#if ex.duration > 0}
<span>{ex.duration} min</span>
{/if}
{#if ex.pace > 0}
<span>{formatPace(ex.pace)} avg</span>
{/if}
{:else}
{#if ex.tonnage > 0}
<span>{ex.tonnage >= 1000 ? `${(ex.tonnage / 1000).toFixed(1)}t` : `${Math.round(ex.tonnage)} kg`} volume</span>
{/if}
{#if ex.bestWeight > 0}
<span>Top: {ex.bestWeight} kg</span>
{/if}
{#if ex.bestEst1rm > 0}
<span>e1RM: {ex.bestEst1rm} kg</span>
{/if}
{/if}
</div>
</div>
{/each}
</div>
<button class="done-btn" onclick={() => goto(`/fitness/history/${completionData.sessionId}`)}>
VIEW WORKOUT
</button>
</div>
{:else if workout.active}
<div class="active-workout">
<div class="workout-topbar">
<div class="topbar-left">
<button class="pause-btn" onclick={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()} aria-label={workout.paused ? 'Resume' : 'Pause'}>
{#if workout.paused}<Play size={16} />{:else}<Pause size={16} />{/if}
</button>
<span class="elapsed" class:paused={workout.paused}>{formatElapsed(workout.elapsedSeconds)}</span>
<SyncIndicator status={sync.status} />
</div>
<button class="finish-btn" onclick={finishWorkout}>FINISH</button>
</div>
<input
class="workout-name-input"
type="text"
bind:value={workout.name}
placeholder="Workout name"
/>
{#each workout.exercises as ex, exIdx (exIdx)}
<div class="exercise-block">
<div class="exercise-header">
<ExerciseName exerciseId={ex.exerciseId} />
<button
class="remove-exercise"
onclick={() => workout.removeExercise(exIdx)}
aria-label="Remove exercise"
>
<Trash2 size={16} />
</button>
</div>
<SetTable
sets={ex.sets}
previousSets={previousData[ex.exerciseId] ?? []}
metrics={getExerciseMetrics(getExerciseById(ex.exerciseId))}
editable={true}
restAfterSet={workout.restTimerActive && restExerciseIdx === exIdx ? restSetIdx : -1}
restSeconds={workout.restTimerSeconds}
restTotal={workout.restTimerTotal}
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
onRestSkip={cancelRest}
onUpdate={(setIdx, d) => workout.updateSet(exIdx, setIdx, d)}
onToggleComplete={(setIdx) => {
workout.toggleSetComplete(exIdx, setIdx);
if (ex.sets[setIdx]?.completed && !workout.restTimerActive) {
restExerciseIdx = exIdx;
restSetIdx = setIdx;
workout.startRestTimer(ex.restTime);
}
}}
onRemove={(setIdx) => workout.removeSet(exIdx, setIdx)}
/>
<button class="add-set-btn" onclick={() => workout.addSet(exIdx)}>
+ ADD SET
</button>
</div>
{/each}
<div class="workout-actions">
<button class="add-exercise-btn" onclick={() => showPicker = true}>
<Plus size={18} /> ADD EXERCISE
</button>
<button class="cancel-btn" onclick={async () => { workout.cancel(); await sync.onWorkoutEnd(); await goto('/fitness/workout'); }}>
CANCEL WORKOUT
</button>
</div>
</div>
{/if}
{#if showPicker}
<ExercisePicker
onSelect={addExerciseFromPicker}
onClose={() => showPicker = false}
/>
{/if}
<style>
/* Completion screen */
.completion {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.completion-header {
text-align: center;
padding: 1rem 0 0;
}
.completion-header h1 {
margin: 0;
font-size: 1.5rem;
}
.completion-name {
margin: 0.25rem 0 0;
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.pr-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--nord13);
margin-top: 0.4rem;
}
.pr-badge-count {
font-size: 1.5rem;
font-weight: 800;
}
.completion-stats {
display: flex;
justify-content: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.comp-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
color: var(--color-text-secondary);
}
.comp-stat-value {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-text-primary);
}
.comp-stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.prs-section {
background: color-mix(in srgb, var(--nord13) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--nord13) 30%, transparent);
border-radius: 12px;
padding: 1rem;
}
.prs-section h2 {
margin: 0 0 0.6rem;
font-size: 1rem;
color: var(--nord13);
display: flex;
align-items: center;
gap: 0.4rem;
}
.pr-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.pr-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
}
.pr-exercise {
font-weight: 600;
}
.pr-detail {
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.pr-detail strong {
color: var(--nord13);
}
.exercise-summaries h2 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.ex-summary {
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
}
.ex-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.ex-summary-name {
font-weight: 600;
font-size: 0.9rem;
}
.ex-summary-sets {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.ex-summary-stats {
display: flex;
gap: 1rem;
margin-top: 0.3rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.done-btn {
width: 100%;
padding: 0.85rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 10px;
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
letter-spacing: 0.03em;
}
/* Active workout */
.active-workout {
display: flex;
flex-direction: column;
gap: 1rem;
}
.workout-topbar {
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 3.5rem;
background: var(--color-bg-primary);
z-index: 10;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
}
.topbar-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pause-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0.3rem;
display: flex;
align-items: center;
}
.pause-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.elapsed {
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 1.1rem;
color: var(--color-text-secondary);
}
.elapsed.paused {
color: var(--nord13);
}
.finish-btn {
background: var(--color-primary);
color: white;
border: none;
border-radius: 8px;
padding: 0.5rem 1.25rem;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
letter-spacing: 0.03em;
}
.workout-name-input {
background: transparent;
border: none;
border-bottom: 1px solid var(--color-border);
color: inherit;
font-size: 1.2rem;
font-weight: 700;
padding: 0.5rem 0;
width: 100%;
outline: none;
}
.workout-name-input:focus {
border-bottom-color: var(--color-primary);
}
.exercise-block {
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
padding: 1rem;
}
.exercise-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.remove-exercise {
background: none;
border: none;
color: var(--nord11);
cursor: pointer;
padding: 0.25rem;
opacity: 0.6;
}
.remove-exercise:hover {
opacity: 1;
}
.add-set-btn {
display: block;
width: 100%;
margin-top: 0.5rem;
padding: 0.4rem;
background: transparent;
border: 1px dashed var(--color-border);
border-radius: 8px;
color: var(--color-text-secondary);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.add-set-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.workout-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 0;
}
.add-exercise-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
width: 100%;
padding: 0.75rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 10px;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
letter-spacing: 0.03em;
}
.cancel-btn {
width: 100%;
padding: 0.75rem;
background: transparent;
border: 1px solid var(--nord11);
border-radius: 10px;
color: var(--nord11);
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
letter-spacing: 0.03em;
}
.cancel-btn:hover {
background: rgba(191, 97, 106, 0.1);
}
</style>