Files
homepage/src/lib/components/fitness/SetTable.svelte
T
Alexander de55e51301 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
2026-03-19 18:57:52 +01:00

211 lines
4.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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, 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: Record<string, number | null>) => void) | null,
* onToggleComplete?: ((setIndex: number) => void) | null,
* onRemove?: ((setIndex: number) => void) | null
* }}
*/
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
* @param {Event} e
*/
function handleInput(index, field, e) {
const target = /** @type {HTMLInputElement} */ (e.target);
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">
<thead>
<tr>
<th class="col-set">SET</th>
{#if previousSets}
<th class="col-prev">PREVIOUS</th>
{/if}
{#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>
</thead>
<tbody>
{#each sets as set, i (i)}
<tr class:completed={set.completed}>
<td class="col-set">{i + 1}</td>
{#if previousSets}
<td class="col-prev">
{#if previousSets[i]}
{formatPrev(previousSets[i])}
{:else}
{/if}
</td>
{/if}
{#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"
inputmode="numeric"
min="1"
max="10"
value={set.rpe ?? ''}
placeholder="—"
oninput={(e) => handleInput(i, 'rpe', e)}
/>
</td>
{/if}
{#if editable}
<td class="col-check">
<button
class="check-btn"
class:checked={set.completed}
onclick={() => onToggleComplete?.(i)}
aria-label="Mark set complete"
>
<Check size={16} />
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
<style>
.set-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
thead th {
text-transform: uppercase;
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-secondary);
padding: 0.4rem 0.5rem;
text-align: center;
letter-spacing: 0.05em;
}
tbody td {
padding: 0.35rem 0.5rem;
text-align: center;
border-top: 1px solid var(--color-border);
}
.col-set {
width: 2.5rem;
font-weight: 700;
color: var(--color-text-secondary);
}
.col-prev {
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.col-metric {
width: 4rem;
}
.col-rpe {
width: 3rem;
}
.col-check {
width: 2.5rem;
}
tr.completed {
background: color-mix(in srgb, var(--nord14) 10%, transparent);
}
input {
width: 100%;
max-width: 4rem;
text-align: center;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.3rem 0.25rem;
font-size: 0.875rem;
color: inherit;
}
.col-rpe input {
max-width: 3rem;
}
input:focus {
outline: none;
border-color: var(--color-primary);
}
.check-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 2px solid var(--color-border);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 150ms;
margin: 0 auto;
}
.check-btn.checked {
background: var(--nord14);
border-color: var(--nord14);
color: white;
}
</style>