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

- Add metrics system (weight/reps/rpe/distance/duration) per exercise type
  so cardio exercises show distance+duration instead of weight+reps
- Add 8 new cardio exercises: swimming, hiking, rowing outdoor, cycling
  outdoor, elliptical, stair climber, jump rope, walking
- Add bilateral flag to dumbbell exercises for accurate tonnage calculation
- Make SetTable, SessionCard, history detail, template editor, and exercise
  stats API all render/compute dynamically based on exercise metrics
- Rename Profile to Stats with lifetime cards: workouts, tonnage, cardio km
- Move route /fitness/profile -> /fitness/stats, API /stats/profile -> /stats/overview
This commit is contained in:
2026-03-19 18:57:49 +01:00
parent 2deb2c6c09
commit de55e51301
16 changed files with 588 additions and 272 deletions
+50 -35
View File
@@ -1,12 +1,14 @@
<script>
import { Check } from 'lucide-svelte';
import { METRIC_LABELS } from '$lib/data/exercises';
/**
* @type {{
* sets: Array<{ reps: number | null, weight: number | null, rpe?: number | null, completed?: boolean }>,
* previousSets?: Array<{ reps: number, weight: number }> | null,
* sets: Array<{ reps?: number | null, weight?: number | null, rpe?: number | null, distance?: number | null, duration?: number | null, completed?: boolean }>,
* previousSets?: Array<Record<string, any>> | null,
* metrics?: Array<'weight' | 'reps' | 'rpe' | 'distance' | 'duration'>,
* editable?: boolean,
* onUpdate?: ((setIndex: number, data: { reps?: number | null, weight?: number | null, rpe?: number | null }) => void) | null,
* onUpdate?: ((setIndex: number, data: Record<string, number | null>) => void) | null,
* onToggleComplete?: ((setIndex: number) => void) | null,
* onRemove?: ((setIndex: number) => void) | null
* }}
@@ -14,12 +16,17 @@
let {
sets,
previousSets = null,
metrics = ['weight', 'reps', 'rpe'],
editable = false,
onUpdate = null,
onToggleComplete = null,
onRemove = null
} = $props();
/** Metrics to show in the main columns (not RPE, which is edit-only) */
const mainMetrics = $derived(metrics.filter((m) => m !== 'rpe'));
const hasRpe = $derived(metrics.includes('rpe'));
/**
* @param {number} index
* @param {string} field
@@ -30,6 +37,20 @@
const val = target.value === '' ? null : Number(target.value);
onUpdate?.(index, { [field]: val });
}
/** Format a previous set for display */
function formatPrev(/** @type {Record<string, any>} */ prev) {
const parts = [];
for (const m of mainMetrics) {
if (prev[m] != null) parts.push(`${prev[m]}`);
}
return parts.join(' × ');
}
/** @param {string} metric */
function inputMode(metric) {
return metric === 'reps' ? 'numeric' : 'decimal';
}
</script>
<table class="set-table">
@@ -39,10 +60,13 @@
{#if previousSets}
<th class="col-prev">PREVIOUS</th>
{/if}
<th class="col-weight">KG</th>
<th class="col-reps">REPS</th>
{#if editable}
{#each mainMetrics as metric (metric)}
<th class="col-metric">{METRIC_LABELS[metric]}</th>
{/each}
{#if editable && hasRpe}
<th class="col-rpe">RPE</th>
{/if}
{#if editable}
<th class="col-check"></th>
{/if}
</tr>
@@ -54,39 +78,28 @@
{#if previousSets}
<td class="col-prev">
{#if previousSets[i]}
{previousSets[i].weight} × {previousSets[i].reps}
{formatPrev(previousSets[i])}
{:else}
{/if}
</td>
{/if}
<td class="col-weight">
{#if editable}
<input
type="number"
inputmode="decimal"
value={set.weight ?? ''}
placeholder="0"
oninput={(e) => handleInput(i, 'weight', e)}
/>
{:else}
{set.weight ?? '—'}
{/if}
</td>
<td class="col-reps">
{#if editable}
<input
type="number"
inputmode="numeric"
value={set.reps ?? ''}
placeholder="0"
oninput={(e) => handleInput(i, 'reps', e)}
/>
{:else}
{set.reps ?? '—'}
{/if}
</td>
{#if editable}
{#each mainMetrics as metric (metric)}
<td class="col-metric">
{#if editable}
<input
type="number"
inputmode={inputMode(metric)}
value={set[metric] ?? ''}
placeholder="0"
oninput={(e) => handleInput(i, metric, e)}
/>
{:else}
{set[metric] ?? '—'}
{/if}
</td>
{/each}
{#if editable && hasRpe}
<td class="col-rpe">
<input
type="number"
@@ -98,6 +111,8 @@
oninput={(e) => handleInput(i, 'rpe', e)}
/>
</td>
{/if}
{#if editable}
<td class="col-check">
<button
class="check-btn"
@@ -143,7 +158,7 @@
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.col-weight, .col-reps {
.col-metric {
width: 4rem;
}
.col-rpe {