fitness: add per-exercise metrics, cardio support, and stats page
All checks were successful
CI / update (push) Successful in 2m0s

- 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 14da4064a5
commit 828d4a83b0
16 changed files with 588 additions and 272 deletions

View File

@@ -1,5 +1,5 @@
<script> <script>
import { getExerciseById } from '$lib/data/exercises'; import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { Clock, Weight, Trophy } from 'lucide-svelte'; import { Clock, Weight, Trophy } from 'lucide-svelte';
/** /**
@@ -13,7 +13,7 @@
* prs?: Array<any>, * prs?: Array<any>,
* exercises: Array<{ * exercises: Array<{
* exerciseId: string, * 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]; let best = sets[0];
for (const s of sets) { 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; best = s;
} }
} }
return best; let label = `${best.weight ?? 0} kg × ${best.reps ?? 0}`;
if (best.rpe) label += ` @ ${best.rpe}`;
return label;
} }
</script> </script>
@@ -63,11 +82,11 @@
<div class="exercise-list"> <div class="exercise-list">
{#each session.exercises.slice(0, 4) as ex (ex.exerciseId)} {#each session.exercises.slice(0, 4) as ex (ex.exerciseId)}
{@const exercise = getExerciseById(ex.exerciseId)} {@const exercise = getExerciseById(ex.exerciseId)}
{@const best = bestSet(ex.sets)} {@const label = bestSetLabel(ex.sets, ex.exerciseId)}
<div class="exercise-row"> <div class="exercise-row">
<span class="ex-sets">{ex.sets.length} &times; {exercise?.name ?? ex.exerciseId}</span> <span class="ex-sets">{ex.sets.length} &times; {exercise?.name ?? ex.exerciseId}</span>
{#if best} {#if label}
<span class="ex-best">{best.weight} kg &times; {best.reps}{#if best.rpe} @ {best.rpe}{/if}</span> <span class="ex-best">{label}</span>
{/if} {/if}
</div> </div>
{/each} {/each}

View File

@@ -1,12 +1,14 @@
<script> <script>
import { Check } from 'lucide-svelte'; import { Check } from 'lucide-svelte';
import { METRIC_LABELS } from '$lib/data/exercises';
/** /**
* @type {{ * @type {{
* sets: Array<{ reps: number | null, weight: number | null, rpe?: number | null, completed?: boolean }>, * sets: Array<{ reps?: number | null, weight?: number | null, rpe?: number | null, distance?: number | null, duration?: number | null, completed?: boolean }>,
* previousSets?: Array<{ reps: number, weight: number }> | null, * previousSets?: Array<Record<string, any>> | null,
* metrics?: Array<'weight' | 'reps' | 'rpe' | 'distance' | 'duration'>,
* editable?: boolean, * 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, * onToggleComplete?: ((setIndex: number) => void) | null,
* onRemove?: ((setIndex: number) => void) | null * onRemove?: ((setIndex: number) => void) | null
* }} * }}
@@ -14,12 +16,17 @@
let { let {
sets, sets,
previousSets = null, previousSets = null,
metrics = ['weight', 'reps', 'rpe'],
editable = false, editable = false,
onUpdate = null, onUpdate = null,
onToggleComplete = null, onToggleComplete = null,
onRemove = null onRemove = null
} = $props(); } = $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 {number} index
* @param {string} field * @param {string} field
@@ -30,6 +37,20 @@
const val = target.value === '' ? null : Number(target.value); const val = target.value === '' ? null : Number(target.value);
onUpdate?.(index, { [field]: val }); 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> </script>
<table class="set-table"> <table class="set-table">
@@ -39,10 +60,13 @@
{#if previousSets} {#if previousSets}
<th class="col-prev">PREVIOUS</th> <th class="col-prev">PREVIOUS</th>
{/if} {/if}
<th class="col-weight">KG</th> {#each mainMetrics as metric (metric)}
<th class="col-reps">REPS</th> <th class="col-metric">{METRIC_LABELS[metric]}</th>
{#if editable} {/each}
{#if editable && hasRpe}
<th class="col-rpe">RPE</th> <th class="col-rpe">RPE</th>
{/if}
{#if editable}
<th class="col-check"></th> <th class="col-check"></th>
{/if} {/if}
</tr> </tr>
@@ -54,39 +78,28 @@
{#if previousSets} {#if previousSets}
<td class="col-prev"> <td class="col-prev">
{#if previousSets[i]} {#if previousSets[i]}
{previousSets[i].weight} × {previousSets[i].reps} {formatPrev(previousSets[i])}
{:else} {:else}
{/if} {/if}
</td> </td>
{/if} {/if}
<td class="col-weight"> {#each mainMetrics as metric (metric)}
{#if editable} <td class="col-metric">
<input {#if editable}
type="number" <input
inputmode="decimal" type="number"
value={set.weight ?? ''} inputmode={inputMode(metric)}
placeholder="0" value={set[metric] ?? ''}
oninput={(e) => handleInput(i, 'weight', e)} placeholder="0"
/> oninput={(e) => handleInput(i, metric, e)}
{:else} />
{set.weight ?? '—'} {:else}
{/if} {set[metric] ?? '—'}
</td> {/if}
<td class="col-reps"> </td>
{#if editable} {/each}
<input {#if editable && hasRpe}
type="number"
inputmode="numeric"
value={set.reps ?? ''}
placeholder="0"
oninput={(e) => handleInput(i, 'reps', e)}
/>
{:else}
{set.reps ?? '—'}
{/if}
</td>
{#if editable}
<td class="col-rpe"> <td class="col-rpe">
<input <input
type="number" type="number"
@@ -98,6 +111,8 @@
oninput={(e) => handleInput(i, 'rpe', e)} oninput={(e) => handleInput(i, 'rpe', e)}
/> />
</td> </td>
{/if}
{#if editable}
<td class="col-check"> <td class="col-check">
<button <button
class="check-btn" class="check-btn"
@@ -143,7 +158,7 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 0.8rem; font-size: 0.8rem;
} }
.col-weight, .col-reps { .col-metric {
width: 4rem; width: 4rem;
} }
.col-rpe { .col-rpe {

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 { export interface Exercise {
id: string; id: string;
name: string; name: string;
@@ -6,9 +22,17 @@ export interface Exercise {
target: string; target: string;
secondaryMuscles: string[]; secondaryMuscles: string[];
instructions: string[]; instructions: string[];
metrics?: MetricField[];
bilateral?: boolean; // true = weight entered is per hand, actual load is 2×
imageUrl?: string; 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[] = [ export const exercises: Exercise[] = [
// === CHEST === // === CHEST ===
{ {
@@ -71,6 +95,7 @@ export const exercises: Exercise[] = [
name: 'Bench Press (Dumbbell)', name: 'Bench Press (Dumbbell)',
bodyPart: 'chest', bodyPart: 'chest',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'pectorals', target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'], secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [ instructions: [
@@ -84,6 +109,7 @@ export const exercises: Exercise[] = [
name: 'Incline Bench Press (Dumbbell)', name: 'Incline Bench Press (Dumbbell)',
bodyPart: 'chest', bodyPart: 'chest',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'pectorals', target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'], secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [ instructions: [
@@ -98,6 +124,7 @@ export const exercises: Exercise[] = [
name: 'Chest Fly (Dumbbell)', name: 'Chest Fly (Dumbbell)',
bodyPart: 'chest', bodyPart: 'chest',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'pectorals', target: 'pectorals',
secondaryMuscles: ['anterior deltoids'], secondaryMuscles: ['anterior deltoids'],
instructions: [ instructions: [
@@ -261,6 +288,7 @@ export const exercises: Exercise[] = [
name: 'Incline Row (Dumbbell)', name: 'Incline Row (Dumbbell)',
bodyPart: 'back', bodyPart: 'back',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'lats', target: 'lats',
secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'], secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'],
instructions: [ instructions: [
@@ -304,6 +332,7 @@ export const exercises: Exercise[] = [
name: 'Overhead Press (Dumbbell)', name: 'Overhead Press (Dumbbell)',
bodyPart: 'shoulders', bodyPart: 'shoulders',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'anterior deltoids', target: 'anterior deltoids',
secondaryMuscles: ['triceps', 'lateral deltoids', 'traps'], secondaryMuscles: ['triceps', 'lateral deltoids', 'traps'],
instructions: [ instructions: [
@@ -317,6 +346,7 @@ export const exercises: Exercise[] = [
name: 'Lateral Raise (Dumbbell)', name: 'Lateral Raise (Dumbbell)',
bodyPart: 'shoulders', bodyPart: 'shoulders',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'lateral deltoids', target: 'lateral deltoids',
secondaryMuscles: ['traps'], secondaryMuscles: ['traps'],
instructions: [ instructions: [
@@ -343,6 +373,7 @@ export const exercises: Exercise[] = [
name: 'Front Raise (Dumbbell)', name: 'Front Raise (Dumbbell)',
bodyPart: 'shoulders', bodyPart: 'shoulders',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'anterior deltoids', target: 'anterior deltoids',
secondaryMuscles: ['lateral deltoids'], secondaryMuscles: ['lateral deltoids'],
instructions: [ instructions: [
@@ -356,6 +387,7 @@ export const exercises: Exercise[] = [
name: 'Reverse Fly (Dumbbell)', name: 'Reverse Fly (Dumbbell)',
bodyPart: 'shoulders', bodyPart: 'shoulders',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'rear deltoids', target: 'rear deltoids',
secondaryMuscles: ['rhomboids', 'traps'], secondaryMuscles: ['rhomboids', 'traps'],
instructions: [ instructions: [
@@ -395,6 +427,7 @@ export const exercises: Exercise[] = [
name: 'Shrug (Dumbbell)', name: 'Shrug (Dumbbell)',
bodyPart: 'shoulders', bodyPart: 'shoulders',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'traps', target: 'traps',
secondaryMuscles: [], secondaryMuscles: [],
instructions: [ instructions: [
@@ -423,6 +456,7 @@ export const exercises: Exercise[] = [
name: 'Bicep Curl (Dumbbell)', name: 'Bicep Curl (Dumbbell)',
bodyPart: 'arms', bodyPart: 'arms',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'biceps', target: 'biceps',
secondaryMuscles: ['forearms'], secondaryMuscles: ['forearms'],
instructions: [ instructions: [
@@ -436,6 +470,7 @@ export const exercises: Exercise[] = [
name: 'Hammer Curl (Dumbbell)', name: 'Hammer Curl (Dumbbell)',
bodyPart: 'arms', bodyPart: 'arms',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'biceps', target: 'biceps',
secondaryMuscles: ['brachioradialis', 'forearms'], secondaryMuscles: ['brachioradialis', 'forearms'],
instructions: [ instructions: [
@@ -503,6 +538,7 @@ export const exercises: Exercise[] = [
name: 'Skullcrusher (Dumbbell)', name: 'Skullcrusher (Dumbbell)',
bodyPart: 'arms', bodyPart: 'arms',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'triceps', target: 'triceps',
secondaryMuscles: [], secondaryMuscles: [],
instructions: [ instructions: [
@@ -610,6 +646,7 @@ export const exercises: Exercise[] = [
name: 'Lunge (Dumbbell)', name: 'Lunge (Dumbbell)',
bodyPart: 'legs', bodyPart: 'legs',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'quadriceps', target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'], secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [ instructions: [
@@ -623,6 +660,7 @@ export const exercises: Exercise[] = [
name: 'Bulgarian Split Squat (Dumbbell)', name: 'Bulgarian Split Squat (Dumbbell)',
bodyPart: 'legs', bodyPart: 'legs',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'quadriceps', target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'], secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [ instructions: [
@@ -676,6 +714,7 @@ export const exercises: Exercise[] = [
name: 'Romanian Deadlift (Dumbbell)', name: 'Romanian Deadlift (Dumbbell)',
bodyPart: 'legs', bodyPart: 'legs',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'hamstrings', target: 'hamstrings',
secondaryMuscles: ['glutes', 'erector spinae'], secondaryMuscles: ['glutes', 'erector spinae'],
instructions: [ instructions: [
@@ -775,6 +814,7 @@ export const exercises: Exercise[] = [
bodyPart: 'core', bodyPart: 'core',
equipment: 'body weight', equipment: 'body weight',
target: 'abdominals', target: 'abdominals',
metrics: ['duration'],
secondaryMuscles: ['obliques', 'erector spinae'], secondaryMuscles: ['obliques', 'erector spinae'],
instructions: [ instructions: [
'Start in a forearm plank position with elbows under shoulders.', 'Start in a forearm plank position with elbows under shoulders.',
@@ -888,6 +928,15 @@ export const exercises: Exercise[] = [
secondaryMuscles: ['quadriceps', 'hamstrings', 'calves', 'glutes'], secondaryMuscles: ['quadriceps', 'hamstrings', 'calves', 'glutes'],
instructions: ['Run at a steady pace for the desired duration or distance.'] 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', id: 'cycling-indoor',
name: 'Cycling (Indoor)', name: 'Cycling (Indoor)',
@@ -897,6 +946,24 @@ export const exercises: Exercise[] = [
secondaryMuscles: ['quadriceps', 'hamstrings', 'calves'], secondaryMuscles: ['quadriceps', 'hamstrings', 'calves'],
instructions: ['Cycle at a steady pace on a stationary bike for the desired duration.'] 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', id: 'rowing-machine',
name: 'Rowing Machine', name: 'Rowing Machine',
@@ -910,6 +977,52 @@ export const exercises: Exercise[] = [
'Return to the starting position by extending arms, then bending knees.' '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 === // === ADDITIONAL COMPOUND MOVEMENTS ===
{ {
@@ -931,6 +1044,7 @@ export const exercises: Exercise[] = [
name: "Farmer's Walk", name: "Farmer's Walk",
bodyPart: 'core', bodyPart: 'core',
equipment: 'dumbbell', equipment: 'dumbbell',
bilateral: true,
target: 'forearms', target: 'forearms',
secondaryMuscles: ['traps', 'core', 'grip'], secondaryMuscles: ['traps', 'core', 'grip'],
instructions: [ instructions: [

View File

@@ -10,6 +10,8 @@ export interface WorkoutSet {
reps: number | null; reps: number | null;
weight: number | null; weight: number | null;
rpe: number | null; rpe: number | null;
distance: number | null;
duration: number | null;
completed: boolean; completed: boolean;
} }
@@ -24,7 +26,7 @@ export interface TemplateData {
name: string; name: string;
exercises: Array<{ exercises: Array<{
exerciseId: string; exerciseId: string;
sets: Array<{ reps?: number; weight?: number; rpe?: number }>; sets: Array<{ reps?: number; weight?: number; rpe?: number; distance?: number; duration?: number }>;
restTime?: number; restTime?: number;
}>; }>;
} }
@@ -55,7 +57,7 @@ export interface RemoteState {
} }
function createEmptySet(): WorkoutSet { 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) { function saveToStorage(state: StoredState) {
@@ -213,6 +215,8 @@ export function createWorkout() {
reps: s.reps ?? null, reps: s.reps ?? null,
weight: s.weight ?? null, weight: s.weight ?? null,
rpe: s.rpe ?? null, rpe: s.rpe ?? null,
distance: s.distance ?? null,
duration: s.duration ?? null,
completed: false completed: false
})) }))
: [createEmptySet()], : [createEmptySet()],
@@ -356,9 +360,11 @@ export function createWorkout() {
sets: e.sets sets: e.sets
.filter((s) => s.completed) .filter((s) => s.completed)
.map((s) => ({ .map((s) => ({
reps: s.reps ?? 0, reps: s.reps ?? undefined,
weight: s.weight ?? 0, weight: s.weight ?? undefined,
rpe: s.rpe ?? undefined, rpe: s.rpe ?? undefined,
distance: s.distance ?? undefined,
duration: s.duration ?? undefined,
completed: true completed: true
})) }))
})), })),

View File

@@ -1,9 +1,11 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
export interface ICompletedSet { export interface ICompletedSet {
reps: number; reps?: number;
weight?: number; weight?: number;
rpe?: number; // Rate of Perceived Exertion (1-10) rpe?: number; // Rate of Perceived Exertion (1-10)
distance?: number; // km
duration?: number; // minutes
completed: boolean; completed: boolean;
notes?: string; notes?: string;
} }
@@ -34,7 +36,6 @@ export interface IWorkoutSession {
const CompletedSetSchema = new mongoose.Schema({ const CompletedSetSchema = new mongoose.Schema({
reps: { reps: {
type: Number, type: Number,
required: true,
min: 0, min: 0,
max: 1000 max: 1000
}, },
@@ -48,6 +49,16 @@ const CompletedSetSchema = new mongoose.Schema({
min: 1, min: 1,
max: 10 max: 10
}, },
distance: {
type: Number,
min: 0,
max: 1000 // km
},
duration: {
type: Number,
min: 0,
max: 6000 // minutes
},
completed: { completed: {
type: Boolean, type: Boolean,
default: false default: false

View File

@@ -1,9 +1,11 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
export interface ISet { export interface ISet {
reps: number; reps?: number;
weight?: number; weight?: number;
rpe?: number; // Rate of Perceived Exertion (1-10) rpe?: number; // Rate of Perceived Exertion (1-10)
distance?: number; // km
duration?: number; // minutes
} }
export interface IExercise { export interface IExercise {
@@ -27,8 +29,7 @@ export interface IWorkoutTemplate {
const SetSchema = new mongoose.Schema({ const SetSchema = new mongoose.Schema({
reps: { reps: {
type: Number, type: Number,
required: true, min: 0,
min: 1,
max: 1000 max: 1000
}, },
weight: { weight: {
@@ -40,6 +41,16 @@ const SetSchema = new mongoose.Schema({
type: Number, type: Number,
min: 1, min: 1,
max: 10 max: 10
},
distance: {
type: Number,
min: 0,
max: 1000 // km
},
duration: {
type: Number,
min: 0,
max: 6000 // minutes
} }
}); });

View File

@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth'; import { requireAuth } from '$lib/server/middleware/auth';
import { getExerciseById } from '$lib/data/exercises'; import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { dbConnect } from '$utils/db'; import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession'; import { WorkoutSession } from '$models/WorkoutSession';
@@ -31,12 +31,50 @@ export const GET: RequestHandler = async ({ params, locals }) => {
.sort({ startTime: 1 }) .sort({ startTime: 1 })
.lean(); .lean();
// Build time-series and records data const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance');
if (isCardio) {
// Cardio stats: distance and duration over time
const distanceOverTime: { date: Date; value: number }[] = [];
const durationOverTime: { date: Date; value: number }[] = [];
let bestDistance = 0;
let bestDuration = 0;
for (const session of sessions) {
const exerciseData = session.exercises.find((e) => e.exerciseId === params.id);
if (!exerciseData) continue;
const completedSets = exerciseData.sets.filter((s) => s.completed);
if (completedSets.length === 0) continue;
let sessionDistance = 0;
let sessionDuration = 0;
for (const set of completedSets) {
sessionDistance += set.distance ?? 0;
sessionDuration += set.duration ?? 0;
}
if (sessionDistance > 0) distanceOverTime.push({ date: session.startTime, value: sessionDistance });
if (sessionDuration > 0) durationOverTime.push({ date: session.startTime, value: sessionDuration });
bestDistance = Math.max(bestDistance, sessionDistance);
bestDuration = Math.max(bestDuration, sessionDuration);
}
return json({
charts: { distanceOverTime, durationOverTime },
personalRecords: { bestDistance, bestDuration },
records: [],
totalSessions: sessions.length
});
}
// Strength stats
const est1rmOverTime: { date: Date; value: number }[] = []; const est1rmOverTime: { date: Date; value: number }[] = [];
const maxWeightOverTime: { date: Date; value: number }[] = []; const maxWeightOverTime: { date: Date; value: number }[] = [];
const totalVolumeOverTime: { date: Date; value: number }[] = []; const totalVolumeOverTime: { date: Date; value: number }[] = [];
// Track best performance at each rep count: { reps -> { weight, date, estimated1rm } }
const repRecords = new Map< const repRecords = new Map<
number, number,
{ weight: number; reps: number; date: Date; estimated1rm: number } { weight: number; reps: number; date: Date; estimated1rm: number }
@@ -49,24 +87,22 @@ export const GET: RequestHandler = async ({ params, locals }) => {
const exerciseData = session.exercises.find((e) => e.exerciseId === params.id); const exerciseData = session.exercises.find((e) => e.exerciseId === params.id);
if (!exerciseData) continue; if (!exerciseData) continue;
const completedSets = exerciseData.sets.filter((s) => s.completed && s.weight && s.reps > 0); const completedSets = exerciseData.sets.filter((s) => s.completed && s.weight && s.reps && s.reps > 0);
if (completedSets.length === 0) continue; if (completedSets.length === 0) continue;
// Best set est. 1RM for this session
let sessionBestEst1rm = 0; let sessionBestEst1rm = 0;
let sessionMaxWeight = 0; let sessionMaxWeight = 0;
let sessionVolume = 0; let sessionVolume = 0;
for (const set of completedSets) { for (const set of completedSets) {
const weight = set.weight!; const weight = set.weight!;
const reps = set.reps; const reps = set.reps!;
const est1rm = estimatedOneRepMax(weight, reps); const est1rm = estimatedOneRepMax(weight, reps);
sessionBestEst1rm = Math.max(sessionBestEst1rm, est1rm); sessionBestEst1rm = Math.max(sessionBestEst1rm, est1rm);
sessionMaxWeight = Math.max(sessionMaxWeight, weight); sessionMaxWeight = Math.max(sessionMaxWeight, weight);
sessionVolume += weight * reps; sessionVolume += weight * reps;
// Update rep records
const existing = repRecords.get(reps); const existing = repRecords.get(reps);
if (!existing || weight > existing.weight) { if (!existing || weight > existing.weight) {
repRecords.set(reps, { repRecords.set(reps, {
@@ -87,7 +123,6 @@ export const GET: RequestHandler = async ({ params, locals }) => {
bestMaxVolume = Math.max(bestMaxVolume, sessionVolume); bestMaxVolume = Math.max(bestMaxVolume, sessionVolume);
} }
// Convert rep records to sorted array
const records = [...repRecords.entries()] const records = [...repRecords.entries()]
.sort((a, b) => a[0] - b[0]) .sort((a, b) => a[0] - b[0])
.map(([reps, data]) => ({ .map(([reps, data]) => ({

View File

@@ -4,26 +4,17 @@ import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db'; import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession'; import { WorkoutSession } from '$models/WorkoutSession';
import { BodyMeasurement } from '$models/BodyMeasurement'; import { BodyMeasurement } from '$models/BodyMeasurement';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
export const GET: RequestHandler = async ({ locals }) => { export const GET: RequestHandler = async ({ locals }) => {
console.time('[stats/profile] total');
console.time('[stats/profile] auth');
const user = await requireAuth(locals); const user = await requireAuth(locals);
console.timeEnd('[stats/profile] auth');
console.time('[stats/profile] dbConnect');
await dbConnect(); await dbConnect();
console.timeEnd('[stats/profile] dbConnect');
const tenWeeksAgo = new Date(); const tenWeeksAgo = new Date();
tenWeeksAgo.setDate(tenWeeksAgo.getDate() - 70); tenWeeksAgo.setDate(tenWeeksAgo.getDate() - 70);
console.time('[stats/profile] countDocuments');
const totalWorkouts = await WorkoutSession.countDocuments({ createdBy: user.nickname }); const totalWorkouts = await WorkoutSession.countDocuments({ createdBy: user.nickname });
console.timeEnd('[stats/profile] countDocuments');
console.time('[stats/profile] aggregate');
const weeklyAgg = await WorkoutSession.aggregate([ const weeklyAgg = await WorkoutSession.aggregate([
{ {
$match: { $match: {
@@ -44,9 +35,32 @@ export const GET: RequestHandler = async ({ locals }) => {
$sort: { '_id.year': 1, '_id.week': 1 } $sort: { '_id.year': 1, '_id.week': 1 }
} }
]); ]);
console.timeEnd('[stats/profile] aggregate');
console.time('[stats/profile] measurements'); // Lifetime totals: tonnage lifted + cardio km
const allSessions = await WorkoutSession.find(
{ createdBy: user.nickname },
{ 'exercises.exerciseId': 1, 'exercises.sets': 1 }
).lean();
let totalTonnage = 0;
let totalCardioKm = 0;
for (const s of allSessions) {
for (const ex of s.exercises) {
const exercise = getExerciseById(ex.exerciseId);
const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance');
const weightMultiplier = exercise?.bilateral ? 2 : 1;
for (const set of ex.sets) {
if (!set.completed) continue;
if (isCardio) {
totalCardioKm += set.distance ?? 0;
} else {
totalTonnage += (set.weight ?? 0) * weightMultiplier * (set.reps ?? 0);
}
}
}
}
const weightMeasurements = await BodyMeasurement.find( const weightMeasurements = await BodyMeasurement.find(
{ createdBy: user.nickname, weight: { $ne: null } }, { createdBy: user.nickname, weight: { $ne: null } },
{ date: 1, weight: 1, _id: 0 } { date: 1, weight: 1, _id: 0 }
@@ -54,7 +68,6 @@ export const GET: RequestHandler = async ({ locals }) => {
.sort({ date: 1 }) .sort({ date: 1 })
.limit(30) .limit(30)
.lean(); .lean();
console.timeEnd('[stats/profile] measurements');
// Build chart-ready workouts-per-week with filled gaps // Build chart-ready workouts-per-week with filled gaps
const weekMap = new Map<string, number>(); const weekMap = new Map<string, number>();
@@ -124,9 +137,10 @@ export const GET: RequestHandler = async ({ locals }) => {
} }
} }
console.timeEnd('[stats/profile] total');
return json({ return json({
totalWorkouts, totalWorkouts,
totalTonnage: Math.round(totalTonnage / 1000 * 10) / 10,
totalCardioKm: Math.round(totalCardioKm * 10) / 10,
workoutsChart, workoutsChart,
weightChart weightChart
}); });

View File

@@ -3,7 +3,7 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import Header from '$lib/components/Header.svelte'; import Header from '$lib/components/Header.svelte';
import UserHeader from '$lib/components/UserHeader.svelte'; import UserHeader from '$lib/components/UserHeader.svelte';
import { User, Clock, Dumbbell, ListChecks, Ruler } from 'lucide-svelte'; import { BarChart3, Clock, Dumbbell, ListChecks, Ruler } from 'lucide-svelte';
import { getWorkout } from '$lib/js/workout.svelte'; import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte'; import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte'; import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte';
@@ -43,7 +43,7 @@
<Header> <Header>
{#snippet links()} {#snippet links()}
<ul class="site_header"> <ul class="site_header">
<li style="--active-fill: var(--nord15)"><a href="/fitness/profile" class:active={isActive('/fitness/profile')}><User size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Profile</span></a></li> <li><a href="/fitness/stats" class:active={isActive('/fitness/stats')}><BarChart3 size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Stats</span></a></li>
<li style="--active-fill: var(--nord13)"><a href="/fitness/history" class:active={isActive('/fitness/history')}><Clock size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">History</span></a></li> <li style="--active-fill: var(--nord13)"><a href="/fitness/history" class:active={isActive('/fitness/history')}><Clock size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">History</span></a></li>
<li style="--active-fill: var(--nord8)"><a href="/fitness/workout" class:active={isActive('/fitness/workout')}><Dumbbell size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Workout</span></a></li> <li style="--active-fill: var(--nord8)"><a href="/fitness/workout" class:active={isActive('/fitness/workout')}><Dumbbell size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Workout</span></a></li>
<li style="--active-fill: var(--nord14)"><a href="/fitness/exercises" class:active={isActive('/fitness/exercises')}><ListChecks size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Exercises</span></a></li> <li style="--active-fill: var(--nord14)"><a href="/fitness/exercises" class:active={isActive('/fitness/exercises')}><ListChecks size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Exercises</span></a></li>

View File

@@ -1,7 +1,7 @@
<script> <script>
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { Clock, Weight, Trophy, Trash2 } from 'lucide-svelte'; import { Clock, Weight, Trophy, Trash2 } from 'lucide-svelte';
import { getExerciseById } from '$lib/data/exercises'; import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte'; import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
let { data } = $props(); let { data } = $props();
@@ -47,6 +47,13 @@
} catch {} } catch {}
deleting = false; deleting = false;
} }
/** @param {string} exerciseId */
function isStrength(exerciseId) {
const exercise = getExerciseById(exerciseId);
const metrics = getExerciseMetrics(exercise);
return metrics.includes('weight') && metrics.includes('reps');
}
</script> </script>
<div class="session-detail"> <div class="session-detail">
@@ -82,6 +89,10 @@
</div> </div>
{#each session.exercises as ex, exIdx (ex.exerciseId + '-' + exIdx)} {#each session.exercises as ex, exIdx (ex.exerciseId + '-' + exIdx)}
{@const exercise = getExerciseById(ex.exerciseId)}
{@const metrics = getExerciseMetrics(exercise)}
{@const mainMetrics = metrics.filter((/** @type {string} */ m) => m !== 'rpe')}
{@const showEst1rm = isStrength(ex.exerciseId)}
<div class="exercise-block"> <div class="exercise-block">
<h3 class="exercise-title"> <h3 class="exercise-title">
<ExerciseName exerciseId={ex.exerciseId} /> <ExerciseName exerciseId={ex.exerciseId} />
@@ -90,20 +101,26 @@
<thead> <thead>
<tr> <tr>
<th>SET</th> <th>SET</th>
<th>KG</th> {#each mainMetrics as metric (metric)}
<th>REPS</th> <th>{METRIC_LABELS[metric]}</th>
{/each}
<th>RPE</th> <th>RPE</th>
<th>EST. 1RM</th> {#if showEst1rm}
<th>EST. 1RM</th>
{/if}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each ex.sets as set, i (i)} {#each ex.sets as set, i (i)}
<tr> <tr>
<td class="set-num">{i + 1}</td> <td class="set-num">{i + 1}</td>
<td>{set.weight ?? '—'}</td> {#each mainMetrics as metric (metric)}
<td>{set.reps ?? '—'}</td> <td>{set[metric] ?? '—'}</td>
{/each}
<td class="rpe">{set.rpe ?? '—'}</td> <td class="rpe">{set.rpe ?? '—'}</td>
<td class="est1rm">{set.weight && set.reps ? epley1rm(set.weight, set.reps) : '—'}</td> {#if showEst1rm}
<td class="est1rm">{set.weight && set.reps ? epley1rm(set.weight, set.reps) : '—'}</td>
{/if}
</tr> </tr>
{/each} {/each}
</tbody> </tbody>

View File

@@ -1,20 +0,0 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, locals }) => {
console.time('[profile] total load');
console.time('[profile] auth');
const session = await locals.auth();
console.timeEnd('[profile] auth');
console.time('[profile] fetch /api/fitness/stats/profile');
const res = await fetch('/api/fitness/stats/profile');
console.timeEnd('[profile] fetch /api/fitness/stats/profile');
console.time('[profile] parse json');
const stats = await res.json();
console.timeEnd('[profile] parse json');
console.timeEnd('[profile] total load');
return { session, stats };
};

View File

@@ -1,151 +0,0 @@
<script>
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
let { data } = $props();
const user = $derived(data.session?.user);
const stats = $derived(data.stats ?? {});
const workoutsChartData = $derived({
labels: stats.workoutsChart?.labels ?? [],
datasets: [{
label: 'Workouts',
data: stats.workoutsChart?.data ?? [],
backgroundColor: '#88C0D0'
}]
});
const hasSma = $derived(stats.weightChart?.sma?.some((/** @type {any} */ v) => v !== null));
const weightChartData = $derived({
labels: stats.weightChart?.labels ?? [],
datasets: [
...(hasSma ? [
{
label: '± 1σ',
data: stats.weightChart.upper,
borderColor: 'transparent',
backgroundColor: 'rgba(94, 129, 172, 0.15)',
fill: '+1',
pointRadius: 0,
borderWidth: 0,
tension: 0.3,
order: 2
},
{
label: '± 1σ (lower)',
data: stats.weightChart.lower,
borderColor: 'transparent',
backgroundColor: 'transparent',
fill: false,
pointRadius: 0,
borderWidth: 0,
tension: 0.3,
order: 2
},
{
label: 'Trend',
data: stats.weightChart.sma,
borderColor: '#5E81AC',
pointRadius: 0,
borderWidth: 3,
tension: 0.3,
order: 1
}
] : []),
{
label: 'Weight (kg)',
data: stats.weightChart?.data ?? [],
borderColor: '#A3BE8C',
borderWidth: hasSma ? 1 : 2,
pointRadius: 3,
order: 0
}
]
});
</script>
<div class="profile-page">
<h1>Profile</h1>
<div class="user-section">
{#if user}
<img
class="avatar"
src="https://bocken.org/static/user/thumb/{user.nickname}.webp"
alt={user.name}
/>
<div class="user-info">
<h2>{user.name}</h2>
<p class="subtitle">{stats.totalWorkouts ?? 0} workouts</p>
</div>
{/if}
</div>
<h2 class="section-heading">Dashboard</h2>
{#if (stats.workoutsChart?.data?.length ?? 0) > 0}
<FitnessChart
type="bar"
data={workoutsChartData}
title="Workouts per week"
height="220px"
/>
{:else}
<p class="empty-chart">No workout data to display yet.</p>
{/if}
{#if (stats.weightChart?.data?.length ?? 0) > 1}
<FitnessChart
data={weightChartData}
title="Weight"
yUnit=" kg"
height="220px"
/>
{/if}
</div>
<style>
.profile-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.4rem;
}
.user-section {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
}
.avatar {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
}
.user-info h2 {
margin: 0;
font-size: 1.1rem;
}
.subtitle {
margin: 0.15rem 0 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.section-heading {
font-size: 1.1rem;
margin: 0.5rem 0 0;
}
.empty-chart {
text-align: center;
color: var(--color-text-secondary);
padding: 2rem 0;
}
</style>

View File

@@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, locals }) => {
const session = await locals.auth();
const res = await fetch('/api/fitness/stats/overview');
const stats = await res.json();
return { session, stats };
};

View File

@@ -0,0 +1,218 @@
<script>
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import { Dumbbell, Route, Flame } from 'lucide-svelte';
let { data } = $props();
const stats = $derived(data.stats ?? {});
const workoutsChartData = $derived({
labels: stats.workoutsChart?.labels ?? [],
datasets: [{
label: 'Workouts',
data: stats.workoutsChart?.data ?? [],
backgroundColor: '#88C0D0'
}]
});
const hasSma = $derived(stats.weightChart?.sma?.some((/** @type {any} */ v) => v !== null));
const weightChartData = $derived({
labels: stats.weightChart?.labels ?? [],
datasets: [
...(hasSma ? [
{
label: '± 1σ',
data: stats.weightChart.upper,
borderColor: 'transparent',
backgroundColor: 'rgba(94, 129, 172, 0.15)',
fill: '+1',
pointRadius: 0,
borderWidth: 0,
tension: 0.3,
order: 2
},
{
label: '± 1σ (lower)',
data: stats.weightChart.lower,
borderColor: 'transparent',
backgroundColor: 'transparent',
fill: false,
pointRadius: 0,
borderWidth: 0,
tension: 0.3,
order: 2
},
{
label: 'Trend',
data: stats.weightChart.sma,
borderColor: '#5E81AC',
pointRadius: 0,
borderWidth: 3,
tension: 0.3,
order: 1
}
] : []),
{
label: 'Weight (kg)',
data: stats.weightChart?.data ?? [],
borderColor: '#A3BE8C',
borderWidth: hasSma ? 1 : 2,
pointRadius: 3,
order: 0
}
]
});
</script>
<div class="stats-page">
<h1>Stats</h1>
<div class="lifetime-cards">
<div class="lifetime-card workouts">
<div class="card-icon"><Dumbbell size={24} /></div>
<div class="card-value">{stats.totalWorkouts ?? 0}</div>
<div class="card-label">Workouts</div>
</div>
<div class="lifetime-card tonnage">
<div class="card-icon"><Flame size={24} /></div>
<div class="card-value">{stats.totalTonnage ?? 0}<span class="card-unit">t</span></div>
<div class="card-label">Tonnage Lifted</div>
</div>
<div class="lifetime-card cardio">
<div class="card-icon"><Route size={24} /></div>
<div class="card-value">{stats.totalCardioKm ?? 0}<span class="card-unit">km</span></div>
<div class="card-label">Cardio Distance</div>
</div>
</div>
{#if (stats.workoutsChart?.data?.length ?? 0) > 0}
<FitnessChart
type="bar"
data={workoutsChartData}
title="Workouts per week"
height="220px"
/>
{:else}
<p class="empty-chart">No workout data to display yet.</p>
{/if}
{#if (stats.weightChart?.data?.length ?? 0) > 1}
<FitnessChart
data={weightChartData}
title="Weight"
yUnit=" kg"
height="220px"
/>
{/if}
</div>
<style>
.stats-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.4rem;
}
.lifetime-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
}
.lifetime-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
padding: 1rem 0.5rem;
border-radius: 12px;
background: var(--color-surface);
box-shadow: var(--shadow-sm);
text-align: center;
position: relative;
overflow: hidden;
}
.lifetime-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: 12px;
opacity: 0.08;
}
.lifetime-card.workouts::before {
background: var(--nord8);
}
.lifetime-card.tonnage::before {
background: var(--nord12);
}
.lifetime-card.cardio::before {
background: var(--nord14);
}
.card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
margin-bottom: 0.15rem;
}
.workouts .card-icon {
color: var(--nord8);
background: color-mix(in srgb, var(--nord8) 15%, transparent);
}
.tonnage .card-icon {
color: var(--nord12);
background: color-mix(in srgb, var(--nord12) 15%, transparent);
}
.cardio .card-icon {
color: var(--nord14);
background: color-mix(in srgb, var(--nord14) 15%, transparent);
}
.card-value {
font-size: 1.4rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.card-unit {
font-size: 0.7rem;
font-weight: 600;
opacity: 0.6;
margin-left: 0.15rem;
}
.card-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-secondary);
}
@media (max-width: 400px) {
.lifetime-cards {
grid-template-columns: 1fr;
}
.lifetime-card {
flex-direction: row;
justify-content: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
text-align: left;
}
.card-icon {
margin-bottom: 0;
}
}
.empty-chart {
text-align: center;
color: var(--color-text-secondary);
padding: 2rem 0;
}
</style>

View File

@@ -4,7 +4,7 @@
import { Plus, Trash2, Play, Pencil, X, Save } from 'lucide-svelte'; import { Plus, Trash2, Play, Pencil, X, Save } from 'lucide-svelte';
import { getWorkout } from '$lib/js/workout.svelte'; import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte'; import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { getExerciseById } from '$lib/data/exercises'; import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
import TemplateCard from '$lib/components/fitness/TemplateCard.svelte'; import TemplateCard from '$lib/components/fitness/TemplateCard.svelte';
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte'; import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
import AddActionButton from '$lib/components/AddActionButton.svelte'; import AddActionButton from '$lib/components/AddActionButton.svelte';
@@ -25,7 +25,7 @@
/** @type {any} */ /** @type {any} */
let editingTemplate = $state(null); let editingTemplate = $state(null);
let editorName = $state(''); let editorName = $state('');
/** @type {Array<{ exerciseId: string, sets: Array<{ reps: number | null, weight: number | null }>, restTime: number }>} */ /** @type {Array<{ exerciseId: string, sets: Array<Record<string, any>>, restTime: number }>} */
let editorExercises = $state([]); let editorExercises = $state([]);
let editorPicker = $state(false); let editorPicker = $state(false);
let editorSaving = $state(false); let editorSaving = $state(false);
@@ -88,7 +88,7 @@
editorName = template.name; editorName = template.name;
editorExercises = template.exercises.map((/** @type {any} */ ex) => ({ editorExercises = template.exercises.map((/** @type {any} */ ex) => ({
exerciseId: ex.exerciseId, exerciseId: ex.exerciseId,
sets: ex.sets.map((/** @type {any} */ s) => ({ reps: s.reps ?? null, weight: s.weight ?? null })), sets: ex.sets.map((/** @type {any} */ s) => ({ ...s })),
restTime: ex.restTime ?? 120 restTime: ex.restTime ?? 120
})); }));
showTemplateEditor = true; showTemplateEditor = true;
@@ -101,9 +101,13 @@
/** @param {string} exerciseId */ /** @param {string} exerciseId */
function editorAddExercise(exerciseId) { function editorAddExercise(exerciseId) {
const metrics = getExerciseMetrics(getExerciseById(exerciseId));
/** @type {Record<string, any>} */
const emptySet = {};
for (const m of metrics) emptySet[m] = null;
editorExercises = [...editorExercises, { editorExercises = [...editorExercises, {
exerciseId, exerciseId,
sets: [{ reps: null, weight: null }], sets: [emptySet],
restTime: 120 restTime: 120
}]; }];
} }
@@ -115,7 +119,11 @@
/** @param {number} exIdx */ /** @param {number} exIdx */
function editorAddSet(exIdx) { function editorAddSet(exIdx) {
editorExercises[exIdx].sets = [...editorExercises[exIdx].sets, { reps: null, weight: null }]; const metrics = getExerciseMetrics(getExerciseById(editorExercises[exIdx].exerciseId));
/** @type {Record<string, any>} */
const emptySet = {};
for (const m of metrics) emptySet[m] = null;
editorExercises[exIdx].sets = [...editorExercises[exIdx].sets, emptySet];
} }
/** @param {number} exIdx @param {number} setIdx */ /** @param {number} exIdx @param {number} setIdx */
@@ -131,14 +139,21 @@
const body = { const body = {
name: editorName.trim(), name: editorName.trim(),
exercises: editorExercises.map((ex) => ({ exercises: editorExercises.map((ex) => {
exerciseId: ex.exerciseId, const metrics = getExerciseMetrics(getExerciseById(ex.exerciseId));
sets: ex.sets.map((s) => ({ return {
reps: s.reps ?? 1, exerciseId: ex.exerciseId,
weight: s.weight ?? undefined sets: ex.sets.map((s) => {
})), /** @type {Record<string, any>} */
restTime: ex.restTime const set = {};
})) for (const m of metrics) {
if (s[m] != null) set[m] = s[m];
}
return set;
}),
restTime: ex.restTime
};
})
}; };
try { try {
@@ -267,6 +282,7 @@
{#each editorExercises as ex, exIdx (exIdx)} {#each editorExercises as ex, exIdx (exIdx)}
{@const exercise = getExerciseById(ex.exerciseId)} {@const exercise = getExerciseById(ex.exerciseId)}
{@const exMetrics = getExerciseMetrics(exercise).filter((/** @type {string} */ m) => m !== 'rpe')}
<div class="editor-exercise"> <div class="editor-exercise">
<div class="editor-ex-header"> <div class="editor-ex-header">
<span class="editor-ex-name">{exercise?.name ?? ex.exerciseId}</span> <span class="editor-ex-name">{exercise?.name ?? ex.exerciseId}</span>
@@ -278,9 +294,10 @@
{#each ex.sets as set, setIdx (setIdx)} {#each ex.sets as set, setIdx (setIdx)}
<div class="editor-set-row"> <div class="editor-set-row">
<span class="set-num">{setIdx + 1}</span> <span class="set-num">{setIdx + 1}</span>
<input type="number" inputmode="numeric" placeholder="reps" bind:value={set.reps} /> {#each exMetrics as metric, mIdx (metric)}
<span class="set-x">&times;</span> {#if mIdx > 0}<span class="set-x">&times;</span>{/if}
<input type="number" inputmode="decimal" placeholder="kg" bind:value={set.weight} /> <input type="number" inputmode={metric === 'reps' ? 'numeric' : 'decimal'} placeholder={METRIC_LABELS[metric].toLowerCase()} bind:value={set[metric]} />
{/each}
{#if ex.sets.length > 1} {#if ex.sets.length > 1}
<button class="set-remove" onclick={() => editorRemoveSet(exIdx, setIdx)} aria-label="Remove set"><X size={14} /></button> <button class="set-remove" onclick={() => editorRemoveSet(exIdx, setIdx)} aria-label="Remove set"><X size={14} /></button>
{/if} {/if}

View File

@@ -3,6 +3,7 @@
import { Plus, Trash2, Play, Pause } from 'lucide-svelte'; import { Plus, Trash2, Play, Pause } from 'lucide-svelte';
import { getWorkout } from '$lib/js/workout.svelte'; import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte'; import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte'; import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import SetTable from '$lib/components/fitness/SetTable.svelte'; import SetTable from '$lib/components/fitness/SetTable.svelte';
import RestTimer from '$lib/components/fitness/RestTimer.svelte'; import RestTimer from '$lib/components/fitness/RestTimer.svelte';
@@ -14,7 +15,7 @@
const sync = getWorkoutSync(); const sync = getWorkoutSync();
let showPicker = $state(false); let showPicker = $state(false);
/** @type {Record<string, Array<{ reps: number, weight: number }>>} */ /** @type {Record<string, Array<Record<string, any>>>} */
let previousData = $state({}); let previousData = $state({});
onMount(() => { onMount(() => {
@@ -137,6 +138,7 @@
<SetTable <SetTable
sets={ex.sets} sets={ex.sets}
previousSets={previousData[ex.exerciseId] ?? null} previousSets={previousData[ex.exerciseId] ?? null}
metrics={getExerciseMetrics(getExerciseById(ex.exerciseId))}
editable={true} editable={true}
onUpdate={(setIdx, d) => workout.updateSet(exIdx, setIdx, d)} onUpdate={(setIdx, d) => workout.updateSet(exIdx, setIdx, d)}
onToggleComplete={(setIdx) => { onToggleComplete={(setIdx) => {