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>
|
<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} × {exercise?.name ?? ex.exerciseId}</span>
|
<span class="ex-sets">{ex.sets.length} × {exercise?.name ?? ex.exerciseId}</span>
|
||||||
{#if best}
|
{#if label}
|
||||||
<span class="ex-best">{best.weight} kg × {best.reps}{#if best.rpe} @ {best.rpe}{/if}</span>
|
<span class="ex-best">{label}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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
|
||||||
}))
|
}))
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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]) => ({
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 { 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">×</span>
|
{#if mIdx > 0}<span class="set-x">×</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}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user