fitness: add complete fitness tracker frontend

- 5-tab layout (Profile, History, Workout, Exercises, Measure) with shared header nav
- Workout system: template CRUD, active workout on /fitness/workout/active with localStorage persistence, pause/resume timer, rest timer, RPE input
- Shared workout singleton (getWorkout) so active workout state is accessible across all fitness routes
- Floating workout FAB indicator on all /fitness routes when workout is active
- AddActionButton component for button-based FABs (measure + template creation)
- Profile page with workouts-per-week bar chart and weight line chart with SMA trend line + ±1σ confidence band
- Exercise detail with history, charts, and records tabs using static exercise data
- Session history with grouped-by-month list, session detail with stats/PRs
- Body measurements with latest values, body part display, add form
- Card styling matching rosary/prayer route patterns (accent-dark, nord5 light, box-shadow, hover lift)
- FitnessChart: fix SSR hang by moving Chart.register to client-side, remove redundant $effect
- Exercise API: use static in-repo data instead of empty MongoDB collection
- Workout finish: include exercise name for WorkoutSession model validation
This commit is contained in:
2026-03-19 08:17:51 +01:00
parent 28d5f4b0a0
commit c5e3719a0c
38 changed files with 5899 additions and 24 deletions
+206
View File
@@ -0,0 +1,206 @@
<script>
import { Check } from 'lucide-svelte';
/**
* @type {{
* sets: Array<{ reps: number | null, weight: number | null, rpe?: number | null, completed?: boolean }>,
* previousSets?: Array<{ reps: number, weight: number }> | null,
* editable?: boolean,
* onUpdate?: ((setIndex: number, data: { reps?: number | null, weight?: number | null, rpe?: number | null }) => void) | null,
* onToggleComplete?: ((setIndex: number) => void) | null,
* onRemove?: ((setIndex: number) => void) | null
* }}
*/
let {
sets,
previousSets = null,
editable = false,
onUpdate = null,
onToggleComplete = null,
onRemove = null
} = $props();
/**
* @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 });
}
</script>
<table class="set-table">
<thead>
<tr>
<th class="col-set">SET</th>
{#if previousSets}
<th class="col-prev">PREVIOUS</th>
{/if}
<th class="col-weight">KG</th>
<th class="col-reps">REPS</th>
{#if editable}
<th class="col-rpe">RPE</th>
<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]}
{previousSets[i].weight} × {previousSets[i].reps}
{: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}
<td class="col-rpe">
<input
type="number"
inputmode="numeric"
min="1"
max="10"
value={set.rpe ?? ''}
placeholder="—"
oninput={(e) => handleInput(i, 'rpe', e)}
/>
</td>
<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(--nord4);
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(--nord3, rgba(0,0,0,0.1));
}
.col-set {
width: 2.5rem;
font-weight: 700;
color: var(--nord4);
}
.col-prev {
color: var(--nord4);
font-size: 0.8rem;
}
.col-weight, .col-reps {
width: 4rem;
}
.col-rpe {
width: 3rem;
}
.col-check {
width: 2.5rem;
}
tr.completed {
background: rgba(163, 190, 140, 0.1);
}
input {
width: 100%;
max-width: 4rem;
text-align: center;
background: var(--nord1, #f0f0f0);
border: 1px solid var(--nord3, #ddd);
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(--nord8);
}
.check-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 2px solid var(--nord3);
background: transparent;
color: var(--nord4);
cursor: pointer;
transition: all 150ms;
margin: 0 auto;
}
.check-btn.checked {
background: var(--nord14);
border-color: var(--nord14);
color: white;
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) input {
background: var(--nord6, #eceff4);
border-color: var(--nord4);
}
}
:global(:root[data-theme="light"]) input {
background: var(--nord6, #eceff4);
border-color: var(--nord4);
}
</style>