fitness: add per-exercise metrics, cardio support, and stats page
All checks were successful
CI / update (push) Successful in 2m0s
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:
@@ -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} × {exercise?.name ?? ex.exerciseId}</span>
|
||||
{#if best}
|
||||
<span class="ex-best">{best.weight} kg × {best.reps}{#if best.rpe} @ {best.rpe}{/if}</span>
|
||||
{#if label}
|
||||
<span class="ex-best">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
}))
|
||||
})),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface ICompletedSet {
|
||||
reps: number;
|
||||
reps?: number;
|
||||
weight?: number;
|
||||
rpe?: number; // Rate of Perceived Exertion (1-10)
|
||||
distance?: number; // km
|
||||
duration?: number; // minutes
|
||||
completed: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -34,7 +36,6 @@ export interface IWorkoutSession {
|
||||
const CompletedSetSchema = new mongoose.Schema({
|
||||
reps: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0,
|
||||
max: 1000
|
||||
},
|
||||
@@ -48,6 +49,16 @@ const CompletedSetSchema = new mongoose.Schema({
|
||||
min: 1,
|
||||
max: 10
|
||||
},
|
||||
distance: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 1000 // km
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 6000 // minutes
|
||||
},
|
||||
completed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface ISet {
|
||||
reps: number;
|
||||
reps?: number;
|
||||
weight?: number;
|
||||
rpe?: number; // Rate of Perceived Exertion (1-10)
|
||||
distance?: number; // km
|
||||
duration?: number; // minutes
|
||||
}
|
||||
|
||||
export interface IExercise {
|
||||
@@ -27,8 +29,7 @@ export interface IWorkoutTemplate {
|
||||
const SetSchema = new mongoose.Schema({
|
||||
reps: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1,
|
||||
min: 0,
|
||||
max: 1000
|
||||
},
|
||||
weight: {
|
||||
@@ -40,6 +41,16 @@ const SetSchema = new mongoose.Schema({
|
||||
type: Number,
|
||||
min: 1,
|
||||
max: 10
|
||||
},
|
||||
distance: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 1000 // km
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 6000 // minutes
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
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 { WorkoutSession } from '$models/WorkoutSession';
|
||||
|
||||
@@ -31,12 +31,50 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
.sort({ startTime: 1 })
|
||||
.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 maxWeightOverTime: { 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<
|
||||
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);
|
||||
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;
|
||||
|
||||
// Best set est. 1RM for this session
|
||||
let sessionBestEst1rm = 0;
|
||||
let sessionMaxWeight = 0;
|
||||
let sessionVolume = 0;
|
||||
|
||||
for (const set of completedSets) {
|
||||
const weight = set.weight!;
|
||||
const reps = set.reps;
|
||||
const reps = set.reps!;
|
||||
const est1rm = estimatedOneRepMax(weight, reps);
|
||||
|
||||
sessionBestEst1rm = Math.max(sessionBestEst1rm, est1rm);
|
||||
sessionMaxWeight = Math.max(sessionMaxWeight, weight);
|
||||
sessionVolume += weight * reps;
|
||||
|
||||
// Update rep records
|
||||
const existing = repRecords.get(reps);
|
||||
if (!existing || weight > existing.weight) {
|
||||
repRecords.set(reps, {
|
||||
@@ -87,7 +123,6 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
bestMaxVolume = Math.max(bestMaxVolume, sessionVolume);
|
||||
}
|
||||
|
||||
// Convert rep records to sorted array
|
||||
const records = [...repRecords.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([reps, data]) => ({
|
||||
|
||||
@@ -4,26 +4,17 @@ import { requireAuth } from '$lib/server/middleware/auth';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { WorkoutSession } from '$models/WorkoutSession';
|
||||
import { BodyMeasurement } from '$models/BodyMeasurement';
|
||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
console.time('[stats/profile] total');
|
||||
|
||||
console.time('[stats/profile] auth');
|
||||
const user = await requireAuth(locals);
|
||||
console.timeEnd('[stats/profile] auth');
|
||||
|
||||
console.time('[stats/profile] dbConnect');
|
||||
await dbConnect();
|
||||
console.timeEnd('[stats/profile] dbConnect');
|
||||
|
||||
const tenWeeksAgo = new Date();
|
||||
tenWeeksAgo.setDate(tenWeeksAgo.getDate() - 70);
|
||||
|
||||
console.time('[stats/profile] countDocuments');
|
||||
const totalWorkouts = await WorkoutSession.countDocuments({ createdBy: user.nickname });
|
||||
console.timeEnd('[stats/profile] countDocuments');
|
||||
|
||||
console.time('[stats/profile] aggregate');
|
||||
const weeklyAgg = await WorkoutSession.aggregate([
|
||||
{
|
||||
$match: {
|
||||
@@ -44,9 +35,32 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
$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(
|
||||
{ createdBy: user.nickname, weight: { $ne: null } },
|
||||
{ date: 1, weight: 1, _id: 0 }
|
||||
@@ -54,7 +68,6 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
.sort({ date: 1 })
|
||||
.limit(30)
|
||||
.lean();
|
||||
console.timeEnd('[stats/profile] measurements');
|
||||
|
||||
// Build chart-ready workouts-per-week with filled gaps
|
||||
const weekMap = new Map<string, number>();
|
||||
@@ -124,9 +137,10 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
}
|
||||
}
|
||||
|
||||
console.timeEnd('[stats/profile] total');
|
||||
return json({
|
||||
totalWorkouts,
|
||||
totalTonnage: Math.round(totalTonnage / 1000 * 10) / 10,
|
||||
totalCardioKm: Math.round(totalCardioKm * 10) / 10,
|
||||
workoutsChart,
|
||||
weightChart
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Header from '$lib/components/Header.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 { getWorkoutSync } from '$lib/js/workoutSync.svelte';
|
||||
import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte';
|
||||
@@ -43,7 +43,7 @@
|
||||
<Header>
|
||||
{#snippet links()}
|
||||
<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(--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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
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';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -47,6 +47,13 @@
|
||||
} catch {}
|
||||
deleting = false;
|
||||
}
|
||||
|
||||
/** @param {string} exerciseId */
|
||||
function isStrength(exerciseId) {
|
||||
const exercise = getExerciseById(exerciseId);
|
||||
const metrics = getExerciseMetrics(exercise);
|
||||
return metrics.includes('weight') && metrics.includes('reps');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="session-detail">
|
||||
@@ -82,6 +89,10 @@
|
||||
</div>
|
||||
|
||||
{#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">
|
||||
<h3 class="exercise-title">
|
||||
<ExerciseName exerciseId={ex.exerciseId} />
|
||||
@@ -90,20 +101,26 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SET</th>
|
||||
<th>KG</th>
|
||||
<th>REPS</th>
|
||||
{#each mainMetrics as metric (metric)}
|
||||
<th>{METRIC_LABELS[metric]}</th>
|
||||
{/each}
|
||||
<th>RPE</th>
|
||||
<th>EST. 1RM</th>
|
||||
{#if showEst1rm}
|
||||
<th>EST. 1RM</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each ex.sets as set, i (i)}
|
||||
<tr>
|
||||
<td class="set-num">{i + 1}</td>
|
||||
<td>{set.weight ?? '—'}</td>
|
||||
<td>{set.reps ?? '—'}</td>
|
||||
{#each mainMetrics as metric (metric)}
|
||||
<td>{set[metric] ?? '—'}</td>
|
||||
{/each}
|
||||
<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>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
8
src/routes/fitness/stats/+page.server.ts
Normal file
8
src/routes/fitness/stats/+page.server.ts
Normal 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 };
|
||||
};
|
||||
218
src/routes/fitness/stats/+page.svelte
Normal file
218
src/routes/fitness/stats/+page.svelte
Normal 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>
|
||||
@@ -4,7 +4,7 @@
|
||||
import { Plus, Trash2, Play, Pencil, X, Save } from 'lucide-svelte';
|
||||
import { getWorkout } from '$lib/js/workout.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 ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||
import AddActionButton from '$lib/components/AddActionButton.svelte';
|
||||
@@ -25,7 +25,7 @@
|
||||
/** @type {any} */
|
||||
let editingTemplate = $state(null);
|
||||
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 editorPicker = $state(false);
|
||||
let editorSaving = $state(false);
|
||||
@@ -88,7 +88,7 @@
|
||||
editorName = template.name;
|
||||
editorExercises = template.exercises.map((/** @type {any} */ ex) => ({
|
||||
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
|
||||
}));
|
||||
showTemplateEditor = true;
|
||||
@@ -101,9 +101,13 @@
|
||||
|
||||
/** @param {string} 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, {
|
||||
exerciseId,
|
||||
sets: [{ reps: null, weight: null }],
|
||||
sets: [emptySet],
|
||||
restTime: 120
|
||||
}];
|
||||
}
|
||||
@@ -115,7 +119,11 @@
|
||||
|
||||
/** @param {number} 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 */
|
||||
@@ -131,14 +139,21 @@
|
||||
|
||||
const body = {
|
||||
name: editorName.trim(),
|
||||
exercises: editorExercises.map((ex) => ({
|
||||
exerciseId: ex.exerciseId,
|
||||
sets: ex.sets.map((s) => ({
|
||||
reps: s.reps ?? 1,
|
||||
weight: s.weight ?? undefined
|
||||
})),
|
||||
restTime: ex.restTime
|
||||
}))
|
||||
exercises: editorExercises.map((ex) => {
|
||||
const metrics = getExerciseMetrics(getExerciseById(ex.exerciseId));
|
||||
return {
|
||||
exerciseId: ex.exerciseId,
|
||||
sets: ex.sets.map((s) => {
|
||||
/** @type {Record<string, any>} */
|
||||
const set = {};
|
||||
for (const m of metrics) {
|
||||
if (s[m] != null) set[m] = s[m];
|
||||
}
|
||||
return set;
|
||||
}),
|
||||
restTime: ex.restTime
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -267,6 +282,7 @@
|
||||
|
||||
{#each editorExercises as ex, exIdx (exIdx)}
|
||||
{@const exercise = getExerciseById(ex.exerciseId)}
|
||||
{@const exMetrics = getExerciseMetrics(exercise).filter((/** @type {string} */ m) => m !== 'rpe')}
|
||||
<div class="editor-exercise">
|
||||
<div class="editor-ex-header">
|
||||
<span class="editor-ex-name">{exercise?.name ?? ex.exerciseId}</span>
|
||||
@@ -278,9 +294,10 @@
|
||||
{#each ex.sets as set, setIdx (setIdx)}
|
||||
<div class="editor-set-row">
|
||||
<span class="set-num">{setIdx + 1}</span>
|
||||
<input type="number" inputmode="numeric" placeholder="reps" bind:value={set.reps} />
|
||||
<span class="set-x">×</span>
|
||||
<input type="number" inputmode="decimal" placeholder="kg" bind:value={set.weight} />
|
||||
{#each exMetrics as metric, mIdx (metric)}
|
||||
{#if mIdx > 0}<span class="set-x">×</span>{/if}
|
||||
<input type="number" inputmode={metric === 'reps' ? 'numeric' : 'decimal'} placeholder={METRIC_LABELS[metric].toLowerCase()} bind:value={set[metric]} />
|
||||
{/each}
|
||||
{#if ex.sets.length > 1}
|
||||
<button class="set-remove" onclick={() => editorRemoveSet(exIdx, setIdx)} aria-label="Remove set"><X size={14} /></button>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Plus, Trash2, Play, Pause } 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 RestTimer from '$lib/components/fitness/RestTimer.svelte';
|
||||
@@ -14,7 +15,7 @@
|
||||
const sync = getWorkoutSync();
|
||||
let showPicker = $state(false);
|
||||
|
||||
/** @type {Record<string, Array<{ reps: number, weight: number }>>} */
|
||||
/** @type {Record<string, Array<Record<string, any>>>} */
|
||||
let previousData = $state({});
|
||||
|
||||
onMount(() => {
|
||||
@@ -137,6 +138,7 @@
|
||||
<SetTable
|
||||
sets={ex.sets}
|
||||
previousSets={previousData[ex.exerciseId] ?? null}
|
||||
metrics={getExerciseMetrics(getExerciseById(ex.exerciseId))}
|
||||
editable={true}
|
||||
onUpdate={(setIdx, d) => workout.updateSet(exIdx, setIdx, d)}
|
||||
onToggleComplete={(setIdx) => {
|
||||
|
||||
Reference in New Issue
Block a user