fitness: add per-exercise metrics, cardio support, and stats page

- Add metrics system (weight/reps/rpe/distance/duration) per exercise type
  so cardio exercises show distance+duration instead of weight+reps
- Add 8 new cardio exercises: swimming, hiking, rowing outdoor, cycling
  outdoor, elliptical, stair climber, jump rope, walking
- Add bilateral flag to dumbbell exercises for accurate tonnage calculation
- Make SetTable, SessionCard, history detail, template editor, and exercise
  stats API all render/compute dynamically based on exercise metrics
- Rename Profile to Stats with lifetime cards: workouts, tonnage, cardio km
- Move route /fitness/profile -> /fitness/stats, API /stats/profile -> /stats/overview
This commit is contained in:
2026-03-19 18:57:49 +01:00
parent 2deb2c6c09
commit de55e51301
16 changed files with 588 additions and 272 deletions
+28 -9
View File
@@ -1,5 +1,5 @@
<script>
import { getExerciseById } from '$lib/data/exercises';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { Clock, Weight, Trophy } from 'lucide-svelte';
/**
@@ -13,7 +13,7 @@
* prs?: Array<any>,
* exercises: Array<{
* exerciseId: string,
* sets: Array<{ reps: number, weight: number, rpe?: number }>
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: number }>
* }>
* }
* }}
@@ -41,16 +41,35 @@
}
/**
* @param {Array<{ reps: number, weight: number, rpe?: number }>} sets
* @param {Array<Record<string, any>>} sets
* @param {string} exerciseId
*/
function bestSet(sets) {
function bestSetLabel(sets, exerciseId) {
const exercise = getExerciseById(exerciseId);
const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance');
if (isCardio) {
let best = sets[0];
for (const s of sets) {
if ((s.distance ?? 0) > (best.distance ?? 0)) best = s;
}
const parts = [];
if (best.distance) parts.push(`${best.distance} km`);
if (best.duration) parts.push(`${best.duration} min`);
if (best.rpe) parts.push(`@ ${best.rpe}`);
return parts.join(' · ') || null;
}
let best = sets[0];
for (const s of sets) {
if (s.weight > best.weight || (s.weight === best.weight && s.reps > best.reps)) {
if ((s.weight ?? 0) > (best.weight ?? 0) || ((s.weight ?? 0) === (best.weight ?? 0) && (s.reps ?? 0) > (best.reps ?? 0))) {
best = s;
}
}
return best;
let label = `${best.weight ?? 0} kg × ${best.reps ?? 0}`;
if (best.rpe) label += ` @ ${best.rpe}`;
return label;
}
</script>
@@ -63,11 +82,11 @@
<div class="exercise-list">
{#each session.exercises.slice(0, 4) as ex (ex.exerciseId)}
{@const exercise = getExerciseById(ex.exerciseId)}
{@const best = bestSet(ex.sets)}
{@const label = bestSetLabel(ex.sets, ex.exerciseId)}
<div class="exercise-row">
<span class="ex-sets">{ex.sets.length} &times; {exercise?.name ?? ex.exerciseId}</span>
{#if best}
<span class="ex-best">{best.weight} kg &times; {best.reps}{#if best.rpe} @ {best.rpe}{/if}</span>
{#if label}
<span class="ex-best">{label}</span>
{/if}
</div>
{/each}
+50 -35
View File
@@ -1,12 +1,14 @@
<script>
import { Check } from 'lucide-svelte';
import { METRIC_LABELS } from '$lib/data/exercises';
/**
* @type {{
* sets: Array<{ reps: number | null, weight: number | null, rpe?: number | null, completed?: boolean }>,
* previousSets?: Array<{ reps: number, weight: number }> | null,
* sets: Array<{ reps?: number | null, weight?: number | null, rpe?: number | null, distance?: number | null, duration?: number | null, completed?: boolean }>,
* previousSets?: Array<Record<string, any>> | null,
* metrics?: Array<'weight' | 'reps' | 'rpe' | 'distance' | 'duration'>,
* editable?: boolean,
* onUpdate?: ((setIndex: number, data: { reps?: number | null, weight?: number | null, rpe?: number | null }) => void) | null,
* onUpdate?: ((setIndex: number, data: Record<string, number | null>) => void) | null,
* onToggleComplete?: ((setIndex: number) => void) | null,
* onRemove?: ((setIndex: number) => void) | null
* }}
@@ -14,12 +16,17 @@
let {
sets,
previousSets = null,
metrics = ['weight', 'reps', 'rpe'],
editable = false,
onUpdate = null,
onToggleComplete = null,
onRemove = null
} = $props();
/** Metrics to show in the main columns (not RPE, which is edit-only) */
const mainMetrics = $derived(metrics.filter((m) => m !== 'rpe'));
const hasRpe = $derived(metrics.includes('rpe'));
/**
* @param {number} index
* @param {string} field
@@ -30,6 +37,20 @@
const val = target.value === '' ? null : Number(target.value);
onUpdate?.(index, { [field]: val });
}
/** Format a previous set for display */
function formatPrev(/** @type {Record<string, any>} */ prev) {
const parts = [];
for (const m of mainMetrics) {
if (prev[m] != null) parts.push(`${prev[m]}`);
}
return parts.join(' × ');
}
/** @param {string} metric */
function inputMode(metric) {
return metric === 'reps' ? 'numeric' : 'decimal';
}
</script>
<table class="set-table">
@@ -39,10 +60,13 @@
{#if previousSets}
<th class="col-prev">PREVIOUS</th>
{/if}
<th class="col-weight">KG</th>
<th class="col-reps">REPS</th>
{#if editable}
{#each mainMetrics as metric (metric)}
<th class="col-metric">{METRIC_LABELS[metric]}</th>
{/each}
{#if editable && hasRpe}
<th class="col-rpe">RPE</th>
{/if}
{#if editable}
<th class="col-check"></th>
{/if}
</tr>
@@ -54,39 +78,28 @@
{#if previousSets}
<td class="col-prev">
{#if previousSets[i]}
{previousSets[i].weight} × {previousSets[i].reps}
{formatPrev(previousSets[i])}
{:else}
{/if}
</td>
{/if}
<td class="col-weight">
{#if editable}
<input
type="number"
inputmode="decimal"
value={set.weight ?? ''}
placeholder="0"
oninput={(e) => handleInput(i, 'weight', e)}
/>
{:else}
{set.weight ?? '—'}
{/if}
</td>
<td class="col-reps">
{#if editable}
<input
type="number"
inputmode="numeric"
value={set.reps ?? ''}
placeholder="0"
oninput={(e) => handleInput(i, 'reps', e)}
/>
{:else}
{set.reps ?? '—'}
{/if}
</td>
{#if editable}
{#each mainMetrics as metric (metric)}
<td class="col-metric">
{#if editable}
<input
type="number"
inputmode={inputMode(metric)}
value={set[metric] ?? ''}
placeholder="0"
oninput={(e) => handleInput(i, metric, e)}
/>
{:else}
{set[metric] ?? '—'}
{/if}
</td>
{/each}
{#if editable && hasRpe}
<td class="col-rpe">
<input
type="number"
@@ -98,6 +111,8 @@
oninput={(e) => handleInput(i, 'rpe', e)}
/>
</td>
{/if}
{#if editable}
<td class="col-check">
<button
class="check-btn"
@@ -143,7 +158,7 @@
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.col-weight, .col-reps {
.col-metric {
width: 4rem;
}
.col-rpe {
+114
View File
@@ -1,3 +1,19 @@
export type MetricField = 'weight' | 'reps' | 'rpe' | 'distance' | 'duration';
export const METRIC_LABELS: Record<MetricField, string> = {
weight: 'KG',
reps: 'REPS',
rpe: 'RPE',
distance: 'KM',
duration: 'MIN'
};
export const METRIC_PRESETS = {
strength: ['weight', 'reps', 'rpe'] as MetricField[],
cardio: ['distance', 'duration', 'rpe'] as MetricField[],
timed: ['duration', 'reps'] as MetricField[]
};
export interface Exercise {
id: string;
name: string;
@@ -6,9 +22,17 @@ export interface Exercise {
target: string;
secondaryMuscles: string[];
instructions: string[];
metrics?: MetricField[];
bilateral?: boolean; // true = weight entered is per hand, actual load is 2×
imageUrl?: string;
}
export function getExerciseMetrics(exercise: Exercise | undefined): MetricField[] {
if (exercise?.metrics) return exercise.metrics;
if (exercise?.bodyPart === 'cardio') return METRIC_PRESETS.cardio;
return METRIC_PRESETS.strength;
}
export const exercises: Exercise[] = [
// === CHEST ===
{
@@ -71,6 +95,7 @@ export const exercises: Exercise[] = [
name: 'Bench Press (Dumbbell)',
bodyPart: 'chest',
equipment: 'dumbbell',
bilateral: true,
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
@@ -84,6 +109,7 @@ export const exercises: Exercise[] = [
name: 'Incline Bench Press (Dumbbell)',
bodyPart: 'chest',
equipment: 'dumbbell',
bilateral: true,
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
@@ -98,6 +124,7 @@ export const exercises: Exercise[] = [
name: 'Chest Fly (Dumbbell)',
bodyPart: 'chest',
equipment: 'dumbbell',
bilateral: true,
target: 'pectorals',
secondaryMuscles: ['anterior deltoids'],
instructions: [
@@ -261,6 +288,7 @@ export const exercises: Exercise[] = [
name: 'Incline Row (Dumbbell)',
bodyPart: 'back',
equipment: 'dumbbell',
bilateral: true,
target: 'lats',
secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'],
instructions: [
@@ -304,6 +332,7 @@ export const exercises: Exercise[] = [
name: 'Overhead Press (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
bilateral: true,
target: 'anterior deltoids',
secondaryMuscles: ['triceps', 'lateral deltoids', 'traps'],
instructions: [
@@ -317,6 +346,7 @@ export const exercises: Exercise[] = [
name: 'Lateral Raise (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
bilateral: true,
target: 'lateral deltoids',
secondaryMuscles: ['traps'],
instructions: [
@@ -343,6 +373,7 @@ export const exercises: Exercise[] = [
name: 'Front Raise (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
bilateral: true,
target: 'anterior deltoids',
secondaryMuscles: ['lateral deltoids'],
instructions: [
@@ -356,6 +387,7 @@ export const exercises: Exercise[] = [
name: 'Reverse Fly (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
bilateral: true,
target: 'rear deltoids',
secondaryMuscles: ['rhomboids', 'traps'],
instructions: [
@@ -395,6 +427,7 @@ export const exercises: Exercise[] = [
name: 'Shrug (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
bilateral: true,
target: 'traps',
secondaryMuscles: [],
instructions: [
@@ -423,6 +456,7 @@ export const exercises: Exercise[] = [
name: 'Bicep Curl (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
bilateral: true,
target: 'biceps',
secondaryMuscles: ['forearms'],
instructions: [
@@ -436,6 +470,7 @@ export const exercises: Exercise[] = [
name: 'Hammer Curl (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
bilateral: true,
target: 'biceps',
secondaryMuscles: ['brachioradialis', 'forearms'],
instructions: [
@@ -503,6 +538,7 @@ export const exercises: Exercise[] = [
name: 'Skullcrusher (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
bilateral: true,
target: 'triceps',
secondaryMuscles: [],
instructions: [
@@ -610,6 +646,7 @@ export const exercises: Exercise[] = [
name: 'Lunge (Dumbbell)',
bodyPart: 'legs',
equipment: 'dumbbell',
bilateral: true,
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [
@@ -623,6 +660,7 @@ export const exercises: Exercise[] = [
name: 'Bulgarian Split Squat (Dumbbell)',
bodyPart: 'legs',
equipment: 'dumbbell',
bilateral: true,
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [
@@ -676,6 +714,7 @@ export const exercises: Exercise[] = [
name: 'Romanian Deadlift (Dumbbell)',
bodyPart: 'legs',
equipment: 'dumbbell',
bilateral: true,
target: 'hamstrings',
secondaryMuscles: ['glutes', 'erector spinae'],
instructions: [
@@ -775,6 +814,7 @@ export const exercises: Exercise[] = [
bodyPart: 'core',
equipment: 'body weight',
target: 'abdominals',
metrics: ['duration'],
secondaryMuscles: ['obliques', 'erector spinae'],
instructions: [
'Start in a forearm plank position with elbows under shoulders.',
@@ -888,6 +928,15 @@ export const exercises: Exercise[] = [
secondaryMuscles: ['quadriceps', 'hamstrings', 'calves', 'glutes'],
instructions: ['Run at a steady pace for the desired duration or distance.']
},
{
id: 'walking',
name: 'Walking',
bodyPart: 'cardio',
equipment: 'body weight',
target: 'cardiovascular system',
secondaryMuscles: ['quadriceps', 'hamstrings', 'calves', 'glutes'],
instructions: ['Walk at a brisk pace for the desired duration or distance.']
},
{
id: 'cycling-indoor',
name: 'Cycling (Indoor)',
@@ -897,6 +946,24 @@ export const exercises: Exercise[] = [
secondaryMuscles: ['quadriceps', 'hamstrings', 'calves'],
instructions: ['Cycle at a steady pace on a stationary bike for the desired duration.']
},
{
id: 'cycling-outdoor',
name: 'Cycling (Outdoor)',
bodyPart: 'cardio',
equipment: 'body weight',
target: 'cardiovascular system',
secondaryMuscles: ['quadriceps', 'hamstrings', 'calves', 'glutes'],
instructions: ['Cycle outdoors at a steady pace for the desired duration or distance.']
},
{
id: 'swimming',
name: 'Swimming',
bodyPart: 'cardio',
equipment: 'body weight',
target: 'cardiovascular system',
secondaryMuscles: ['lats', 'shoulders', 'core', 'quadriceps'],
instructions: ['Swim laps at a steady pace using your preferred stroke.']
},
{
id: 'rowing-machine',
name: 'Rowing Machine',
@@ -910,6 +977,52 @@ export const exercises: Exercise[] = [
'Return to the starting position by extending arms, then bending knees.'
]
},
{
id: 'rowing-outdoor',
name: 'Rowing (Outdoor)',
bodyPart: 'cardio',
equipment: 'body weight',
target: 'cardiovascular system',
secondaryMuscles: ['lats', 'biceps', 'quadriceps', 'core', 'shoulders'],
instructions: ['Row on water at a steady pace for the desired duration or distance.']
},
{
id: 'hiking',
name: 'Hiking',
bodyPart: 'cardio',
equipment: 'body weight',
target: 'cardiovascular system',
secondaryMuscles: ['quadriceps', 'hamstrings', 'calves', 'glutes', 'core'],
instructions: ['Hike at a steady pace on trails or uneven terrain.']
},
{
id: 'elliptical',
name: 'Elliptical',
bodyPart: 'cardio',
equipment: 'machine',
target: 'cardiovascular system',
secondaryMuscles: ['quadriceps', 'hamstrings', 'glutes', 'calves'],
instructions: ['Use the elliptical machine at a steady pace for the desired duration.']
},
{
id: 'stair-climber',
name: 'Stair Climber',
bodyPart: 'cardio',
equipment: 'machine',
target: 'cardiovascular system',
secondaryMuscles: ['quadriceps', 'hamstrings', 'glutes', 'calves'],
instructions: ['Climb at a steady pace on the stair climber for the desired duration.']
},
{
id: 'jump-rope',
name: 'Jump Rope',
bodyPart: 'cardio',
equipment: 'body weight',
metrics: ['duration', 'reps', 'rpe'],
target: 'cardiovascular system',
secondaryMuscles: ['calves', 'shoulders', 'forearms', 'core'],
instructions: ['Jump rope at a steady pace, landing lightly on the balls of your feet.']
},
// === ADDITIONAL COMPOUND MOVEMENTS ===
{
@@ -931,6 +1044,7 @@ export const exercises: Exercise[] = [
name: "Farmer's Walk",
bodyPart: 'core',
equipment: 'dumbbell',
bilateral: true,
target: 'forearms',
secondaryMuscles: ['traps', 'core', 'grip'],
instructions: [
+10 -4
View File
@@ -10,6 +10,8 @@ export interface WorkoutSet {
reps: number | null;
weight: number | null;
rpe: number | null;
distance: number | null;
duration: number | null;
completed: boolean;
}
@@ -24,7 +26,7 @@ export interface TemplateData {
name: string;
exercises: Array<{
exerciseId: string;
sets: Array<{ reps?: number; weight?: number; rpe?: number }>;
sets: Array<{ reps?: number; weight?: number; rpe?: number; distance?: number; duration?: number }>;
restTime?: number;
}>;
}
@@ -55,7 +57,7 @@ export interface RemoteState {
}
function createEmptySet(): WorkoutSet {
return { reps: null, weight: null, rpe: null, completed: false };
return { reps: null, weight: null, rpe: null, distance: null, duration: null, completed: false };
}
function saveToStorage(state: StoredState) {
@@ -213,6 +215,8 @@ export function createWorkout() {
reps: s.reps ?? null,
weight: s.weight ?? null,
rpe: s.rpe ?? null,
distance: s.distance ?? null,
duration: s.duration ?? null,
completed: false
}))
: [createEmptySet()],
@@ -356,9 +360,11 @@ export function createWorkout() {
sets: e.sets
.filter((s) => s.completed)
.map((s) => ({
reps: s.reps ?? 0,
weight: s.weight ?? 0,
reps: s.reps ?? undefined,
weight: s.weight ?? undefined,
rpe: s.rpe ?? undefined,
distance: s.distance ?? undefined,
duration: s.duration ?? undefined,
completed: true
}))
})),