de55e51301
- 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
211 lines
4.7 KiB
Svelte
211 lines
4.7 KiB
Svelte
<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>
|