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
+82
View File
@@ -0,0 +1,82 @@
<script lang='ts'>
import "$lib/css/action_button.css"
let { onclick, ariaLabel = 'Add' } = $props<{ onclick: () => void, ariaLabel?: string }>();
</script>
<style>
.container{
position: fixed;
bottom:0;
right:0;
width: 1rem;
height: 1rem;
padding: 2rem;
border-radius: var(--radius-pill);
margin: 2rem;
transition: var(--transition-normal);
background-color: var(--red);
display: grid;
justify-content: center;
align-content: center;
z-index: 100;
}
@media screen and (max-width: 500px) {
.container{
margin: 1rem;
}
}
:global(.icon_svg){
width: 2rem;
height: 2rem;
fill: white;
}
:root{
--angle: 15deg;
}
.container:hover,
.container:focus-within
{
background-color: var(--nord0);
box-shadow: 0em 0em 0.5em 0.5em rgba(0,0,0,0.2);
animation: shake 0.5s;
animation-fill-mode: forwards;
}
:global(.container:hover .icon_svg),
:global(.container:focus-within .icon_svg){
fill: white;
}
@keyframes shake{
0%{
transform: rotate(0)
scale(1,1);
}
25%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(var(--angle))
scale(1.2,1.2)
;
}
50%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(calc(-1* var(--angle)))
scale(1.2,1.2);
}
74%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(var(--angle))
scale(1.2, 1.2);
}
100%{
transform: rotate(0)
scale(1.2,1.2);
}
}
</style>
<button class="container action_button" {onclick} aria-label={ariaLabel}>
<svg class=icon_svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 448 512"><path d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32V224H48c-17.7 0-32 14.3-32 32s14.3 32 32 32H192V432c0 17.7 14.3 32 32 32s32-14.3 32-32V288H400c17.7 0 32-14.3 32-32s-14.3-32-32-32H256V80z"/></svg>
</button>
@@ -0,0 +1,28 @@
<script>
import { getExerciseById } from '$lib/data/exercises';
let { exerciseId } = $props();
const exercise = $derived(getExerciseById(exerciseId));
</script>
{#if exercise}
<a href="/fitness/exercises/{exerciseId}" class="exercise-link">{exercise.name}</a>
{:else}
<span class="exercise-unknown">Unknown Exercise</span>
{/if}
<style>
.exercise-link {
color: var(--nord8);
text-decoration: none;
font-weight: 600;
}
.exercise-link:hover {
text-decoration: underline;
}
.exercise-unknown {
color: var(--nord11);
font-style: italic;
}
</style>
@@ -0,0 +1,217 @@
<script>
import { exercises, getFilterOptions, searchExercises } from '$lib/data/exercises';
import { Search, X } from 'lucide-svelte';
/**
* @type {{
* onSelect: (exerciseId: string) => void,
* onClose: () => void
* }}
*/
let { onSelect, onClose } = $props();
let query = $state('');
let bodyPartFilter = $state('');
let equipmentFilter = $state('');
const filterOptions = getFilterOptions();
const filtered = $derived(searchExercises({
search: query || undefined,
bodyPart: bodyPartFilter || undefined,
equipment: equipmentFilter || undefined
}));
/** @param {string} id */
function select(id) {
onSelect(id);
onClose();
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="picker-overlay" onkeydown={(e) => e.key === 'Escape' && onClose()}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="picker-backdrop" onclick={onClose}></div>
<div class="picker-panel">
<div class="picker-header">
<h2>Add Exercise</h2>
<button class="close-btn" onclick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div class="picker-search">
<Search size={16} />
<input
type="text"
placeholder="Search exercises…"
bind:value={query}
/>
</div>
<div class="picker-filters">
<select bind:value={bodyPartFilter}>
<option value="">All body parts</option>
{#each filterOptions.bodyParts as bp (bp)}
<option value={bp}>{bp.charAt(0).toUpperCase() + bp.slice(1)}</option>
{/each}
</select>
<select bind:value={equipmentFilter}>
<option value="">All equipment</option>
{#each filterOptions.equipment as eq (eq)}
<option value={eq}>{eq.charAt(0).toUpperCase() + eq.slice(1)}</option>
{/each}
</select>
</div>
<ul class="exercise-list">
{#each filtered as exercise (exercise.id)}
<li>
<button class="exercise-item" onclick={() => select(exercise.id)}>
<span class="ex-name">{exercise.name}</span>
<span class="ex-meta">{exercise.bodyPart} · {exercise.equipment}</span>
</button>
</li>
{/each}
{#if filtered.length === 0}
<li class="no-results">No exercises found</li>
{/if}
</ul>
</div>
</div>
<style>
.picker-overlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: flex-end;
justify-content: center;
}
.picker-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.picker-panel {
position: relative;
width: 100%;
max-width: 500px;
max-height: 85vh;
background: var(--nord0, #2e3440);
border-radius: 16px 16px 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--nord3);
}
.picker-header h2 {
margin: 0;
font-size: 1.1rem;
}
.close-btn {
background: none;
border: none;
color: var(--nord4);
cursor: pointer;
padding: 0.25rem;
}
.picker-search {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--nord3);
color: var(--nord4);
}
.picker-search input {
flex: 1;
background: transparent;
border: none;
color: inherit;
font-size: 0.9rem;
outline: none;
}
.picker-search input::placeholder {
color: var(--nord3);
}
.picker-filters {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--nord3);
}
.picker-filters select {
flex: 1;
background: var(--nord1);
color: inherit;
border: 1px solid var(--nord3);
border-radius: 6px;
padding: 0.35rem 0.5rem;
font-size: 0.8rem;
}
.exercise-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
flex: 1;
}
.exercise-item {
display: flex;
flex-direction: column;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
border-bottom: 1px solid var(--nord3, rgba(0,0,0,0.05));
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.exercise-item:hover {
background: var(--nord1, rgba(0,0,0,0.05));
}
.ex-name {
font-weight: 600;
font-size: 0.9rem;
}
.ex-meta {
font-size: 0.75rem;
color: var(--nord4);
text-transform: capitalize;
}
.no-results {
padding: 2rem;
text-align: center;
color: var(--nord4);
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) .picker-panel {
background: white;
}
}
:global(:root[data-theme="light"]) .picker-panel {
background: white;
}
@media (min-width: 600px) {
.picker-overlay {
align-items: center;
}
.picker-panel {
border-radius: 16px;
max-height: 70vh;
}
}
</style>
@@ -0,0 +1,168 @@
<script>
import { onMount } from 'svelte';
import { Chart, registerables } from 'chart.js';
/**
* @type {{
* type?: 'line' | 'bar',
* data: { labels: string[], datasets: Array<{ label: string, data: number[], borderColor?: string, backgroundColor?: string }> },
* title?: string,
* height?: string,
* yUnit?: string
* }}
*/
let { type = 'line', data, title = '', height = '250px', yUnit = '' } = $props();
/** @type {HTMLCanvasElement | undefined} */
let canvas = $state(undefined);
/** @type {Chart | null} */
let chart = $state(null);
let registered = false;
const nordColors = [
'#88C0D0', '#A3BE8C', '#EBCB8B', '#D08770', '#BF616A',
'#B48EAD', '#5E81AC', '#81A1C1', '#8FBCBB'
];
function isDark() {
const theme = document.documentElement.getAttribute('data-theme');
if (theme === 'dark') return true;
if (theme === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function createChart() {
if (!canvas || !data?.datasets) return;
if (!registered) {
Chart.register(...registerables);
registered = true;
}
if (chart) chart.destroy();
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dark = isDark();
const textColor = dark ? '#D8DEE9' : '#2E3440';
const gridColor = dark ? 'rgba(216,222,233,0.1)' : 'rgba(46,52,64,0.08)';
const plainLabels = [...(data.labels || [])];
const plainDatasets = (data.datasets || []).map((ds, i) => ({
label: ds.label,
data: [...(ds.data || [])],
borderColor: ds.borderColor || nordColors[i % nordColors.length],
backgroundColor: ds.backgroundColor ?? (type === 'bar'
? (nordColors[i % nordColors.length])
: 'transparent'),
borderWidth: ds.borderWidth ?? (type === 'line' ? 2 : 1),
pointRadius: ds.pointRadius ?? (type === 'line' ? 3 : 0),
pointBackgroundColor: ds.pointBackgroundColor || ds.borderColor || nordColors[i % nordColors.length],
tension: ds.tension ?? 0.3,
fill: ds.fill ?? false,
spanGaps: true,
order: ds.order ?? i
}));
chart = new Chart(ctx, {
type,
data: { labels: plainLabels, datasets: plainDatasets },
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
grid: { display: false },
border: { display: false },
ticks: { color: textColor, font: { size: 11 }, maxTicksLimit: 8 }
},
y: {
beginAtZero: type === 'bar',
grid: { color: gridColor },
border: { display: false },
ticks: {
color: textColor,
font: { size: 11 },
stepSize: type === 'bar' ? 1 : undefined,
callback: yUnit ? (/** @type {any} */ v) => `${v}${yUnit}` : undefined
}
}
},
plugins: /** @type {any} */ ({
legend: {
display: plainDatasets.length > 1,
labels: {
color: textColor,
usePointStyle: true,
padding: 12,
filter: (/** @type {any} */ item) => !item.text?.includes('(lower)')
}
},
title: {
display: !!title,
text: title,
color: textColor,
font: { size: 14, weight: 'bold' },
padding: { bottom: 12 }
},
tooltip: {
backgroundColor: dark ? '#2E3440' : '#ECEFF4',
titleColor: dark ? '#ECEFF4' : '#2E3440',
bodyColor: dark ? '#D8DEE9' : '#3B4252',
borderWidth: 0,
cornerRadius: 8,
padding: 10
}
})
}
});
}
onMount(() => {
createChart();
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onTheme = () => setTimeout(createChart, 100);
mq.addEventListener('change', onTheme);
const obs = new MutationObserver((muts) => {
for (const m of muts) {
if (m.attributeName === 'data-theme') onTheme();
}
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => {
mq.removeEventListener('change', onTheme);
obs.disconnect();
if (chart) chart.destroy();
};
});
</script>
<div class="chart-container" style="height: {height}">
<canvas bind:this={canvas}></canvas>
</div>
<style>
.chart-container {
background: var(--nord1, #f8f8f8);
border-radius: 12px;
padding: 1rem;
border: 1px solid var(--nord3, #ddd);
}
canvas {
max-width: 100%;
height: 100% !important;
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) .chart-container {
background: white;
border-color: var(--nord4);
}
}
:global(:root[data-theme="light"]) .chart-container {
background: white;
border-color: var(--nord4);
}
</style>
@@ -0,0 +1,76 @@
<script>
/**
* @type {{
* seconds: number,
* total: number,
* onComplete?: (() => void) | null
* }}
*/
let { seconds, total, onComplete = null } = $props();
const radius = 40;
const circumference = 2 * Math.PI * radius;
const progress = $derived(total > 0 ? (total - seconds) / total : 0);
const offset = $derived(circumference * (1 - progress));
/** @param {number} secs */
function formatTime(secs) {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
$effect(() => {
if (seconds <= 0 && total > 0) {
onComplete?.();
}
});
</script>
<div class="rest-timer">
<svg viewBox="0 0 100 100" class="timer-ring">
<circle cx="50" cy="50" r={radius} class="bg-ring" />
<circle
cx="50" cy="50" r={radius}
class="progress-ring"
stroke-dasharray={circumference}
stroke-dashoffset={offset}
transform="rotate(-90 50 50)"
/>
</svg>
<span class="timer-text">{formatTime(seconds)}</span>
</div>
<style>
.rest-timer {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
}
.timer-ring {
position: absolute;
inset: 0;
}
.bg-ring {
fill: none;
stroke: var(--nord3, #ddd);
stroke-width: 4;
}
.progress-ring {
fill: none;
stroke: var(--nord8);
stroke-width: 4;
stroke-linecap: round;
transition: stroke-dashoffset 1s linear;
}
.timer-text {
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--nord8);
z-index: 1;
}
</style>
@@ -0,0 +1,170 @@
<script>
import { getExerciseById } from '$lib/data/exercises';
import { Clock, Weight, Trophy } from 'lucide-svelte';
/**
* @type {{
* session: {
* _id: string,
* name: string,
* startTime: string,
* duration?: number,
* totalVolume?: number,
* prs?: Array<any>,
* exercises: Array<{
* exerciseId: string,
* sets: Array<{ reps: number, weight: number, rpe?: number }>
* }>
* }
* }}
*/
let { session } = $props();
/** @param {number} secs */
function formatDuration(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
/** @param {string} dateStr */
function formatDate(dateStr) {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
/** @param {string} dateStr */
function formatTime(dateStr) {
const d = new Date(dateStr);
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
/**
* @param {Array<{ reps: number, weight: number, rpe?: number }>} sets
*/
function bestSet(sets) {
let best = sets[0];
for (const s of sets) {
if (s.weight > best.weight || (s.weight === best.weight && s.reps > best.reps)) {
best = s;
}
}
return best;
}
</script>
<a href="/fitness/history/{session._id}" class="session-card">
<div class="card-top">
<h3 class="session-name">{session.name}</h3>
<span class="session-date">{formatDate(session.startTime)} &middot; {formatTime(session.startTime)}</span>
</div>
<div class="exercise-list">
{#each session.exercises.slice(0, 4) as ex (ex.exerciseId)}
{@const exercise = getExerciseById(ex.exerciseId)}
{@const best = bestSet(ex.sets)}
<div class="exercise-row">
<span class="ex-sets">{ex.sets.length} &times; {exercise?.name ?? ex.exerciseId}</span>
{#if best}
<span class="ex-best">{best.weight} kg &times; {best.reps}{#if best.rpe} @ {best.rpe}{/if}</span>
{/if}
</div>
{/each}
{#if session.exercises.length > 4}
<div class="exercise-row more">+{session.exercises.length - 4} more exercises</div>
{/if}
</div>
<div class="card-footer">
{#if session.duration}
<span class="stat"><Clock size={14} /> {formatDuration(session.duration)}</span>
{/if}
{#if session.totalVolume}
<span class="stat"><Weight size={14} /> {Math.round(session.totalVolume).toLocaleString()} kg</span>
{/if}
{#if session.prs && session.prs.length > 0}
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
{/if}
</div>
</a>
<style>
.session-card {
display: block;
text-decoration: none;
color: inherit;
background: var(--accent-dark);
border: none;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
padding: 1rem;
transition: transform 150ms ease, box-shadow 150ms ease;
}
.session-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.session-card:active {
transform: translateY(0);
}
.card-top {
margin-bottom: 0.6rem;
}
.session-name {
font-size: 0.95rem;
font-weight: 700;
margin: 0;
}
.session-date {
font-size: 0.75rem;
color: var(--nord4);
}
.exercise-list {
font-size: 0.8rem;
margin-bottom: 0.6rem;
}
.exercise-row {
display: flex;
justify-content: space-between;
padding: 0.15rem 0;
}
.ex-sets {
color: var(--nord4);
}
.ex-best {
font-weight: 600;
font-size: 0.78rem;
}
.more {
color: var(--nord8);
font-style: italic;
}
.card-footer {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: var(--nord4);
border-top: 1px solid var(--nord3, rgba(0,0,0,0.1));
padding-top: 0.5rem;
}
.stat {
display: flex;
align-items: center;
gap: 0.25rem;
}
.stat.pr {
color: var(--nord13);
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) .session-card {
background: var(--nord5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
}
:global(:root[data-theme="light"]) .session-card {
background: var(--nord5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
</style>
+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>
@@ -0,0 +1,130 @@
<script>
import { getExerciseById } from '$lib/data/exercises';
import { EllipsisVertical } from 'lucide-svelte';
/**
* @type {{
* template: { _id: string, name: string, exercises: Array<{ exerciseId: string, sets: any[] }> },
* lastUsed?: string | null,
* onStart?: (() => void) | null,
* onMenu?: ((e: MouseEvent) => void) | null
* }}
*/
let { template, lastUsed = null, onStart = null, onMenu = null } = $props();
/** @param {string} dateStr */
function formatDate(dateStr) {
const d = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / 86400000);
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
</script>
<button class="template-card" onclick={() => onStart?.()}>
<div class="card-header">
<h3 class="card-title">{template.name}</h3>
{#if onMenu}
<button
class="menu-btn"
onclick={(e) => { e.stopPropagation(); onMenu?.(e); }}
aria-label="Template options"
>
<EllipsisVertical size={16} />
</button>
{/if}
</div>
<ul class="exercise-preview">
{#each template.exercises.slice(0, 4) as ex}
{@const exercise = getExerciseById(ex.exerciseId)}
<li>{ex.sets.length} &times; {exercise?.name ?? ex.exerciseId}</li>
{/each}
{#if template.exercises.length > 4}
<li class="more">+{template.exercises.length - 4} more</li>
{/if}
</ul>
{#if lastUsed}
<p class="last-used">Last performed: {formatDate(lastUsed)}</p>
{/if}
</button>
<style>
.template-card {
display: flex;
flex-direction: column;
text-align: left;
background: var(--accent-dark);
border: none;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
padding: 1rem;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
width: 100%;
font: inherit;
color: inherit;
}
.template-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.template-card:active {
transform: translateY(0);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.card-title {
font-size: 0.95rem;
font-weight: 700;
margin: 0;
}
.menu-btn {
background: none;
border: none;
color: var(--nord4);
cursor: pointer;
padding: 0.15rem;
border-radius: 4px;
}
.menu-btn:hover {
color: var(--nord8);
}
.exercise-preview {
list-style: none;
padding: 0;
margin: 0;
font-size: 0.8rem;
color: var(--nord4);
}
.exercise-preview li {
padding: 0.1rem 0;
}
.exercise-preview .more {
color: var(--nord8);
font-style: italic;
}
.last-used {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: var(--nord4);
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) .template-card {
background: var(--nord5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
}
:global(:root[data-theme="light"]) .template-card {
background: var(--nord5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
</style>
@@ -0,0 +1,88 @@
<script>
import "$lib/css/action_button.css"
import { Dumbbell } from 'lucide-svelte';
let { href, elapsed = '0:00', paused = false } = $props();
</script>
<style>
.container{
position: fixed;
bottom:0;
right:0;
width: 1rem;
height: 1rem;
padding: 2rem;
border-radius: var(--radius-pill);
margin: 2rem;
transition: var(--transition-normal);
background-color: var(--red);
display: grid;
justify-content: center;
align-content: center;
z-index: 100;
text-decoration: none;
}
@media screen and (max-width: 500px) {
.container{
margin: 1rem;
}
}
.timer {
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 0.85rem;
color: white;
line-height: 1;
text-align: center;
}
.timer.paused {
color: var(--nord13);
}
:root{
--angle: 15deg;
}
.container:hover,
.container:focus-within
{
background-color: var(--nord0);
box-shadow: 0em 0em 0.5em 0.5em rgba(0,0,0,0.2);
animation: shake 0.5s;
animation-fill-mode: forwards;
}
@keyframes shake{
0%{
transform: rotate(0)
scale(1,1);
}
25%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(var(--angle))
scale(1.2,1.2)
;
}
50%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(calc(-1* var(--angle)))
scale(1.2,1.2);
}
74%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(var(--angle))
scale(1.2, 1.2);
}
100%{
transform: rotate(0)
scale(1.2,1.2);
}
}
</style>
<a class="container action_button" {href} aria-label="Return to active workout">
<span class="timer" class:paused>{elapsed}</span>
<Dumbbell size={26} color="white" />
</a>
+947
View File
@@ -0,0 +1,947 @@
export interface Exercise {
id: string;
name: string;
bodyPart: string;
equipment: string;
target: string;
secondaryMuscles: string[];
instructions: string[];
imageUrl?: string;
}
export const exercises: Exercise[] = [
// === CHEST ===
{
id: 'bench-press-barbell',
name: 'Bench Press (Barbell)',
bodyPart: 'chest',
equipment: 'barbell',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Lie flat on the bench holding the barbell with a shoulder-width pronated grip.',
'Retract scapula and have elbows between 45 to 90 degree angle.',
'Lower the bar to mid-chest level.',
'Press the bar back up to the starting position, fully extending the arms.'
]
},
{
id: 'incline-bench-press-barbell',
name: 'Incline Bench Press (Barbell)',
bodyPart: 'chest',
equipment: 'barbell',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Set the bench to a 30-45 degree incline.',
'Lie back and grip the barbell slightly wider than shoulder width.',
'Lower the bar to the upper chest.',
'Press back up to full extension.'
]
},
{
id: 'decline-bench-press-barbell',
name: 'Decline Bench Press (Barbell)',
bodyPart: 'chest',
equipment: 'barbell',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Set the bench to a decline angle and secure your legs.',
'Grip the barbell slightly wider than shoulder width.',
'Lower the bar to the lower chest.',
'Press back up to full extension.'
]
},
{
id: 'bench-press-close-grip-barbell',
name: 'Bench Press - Close Grip (Barbell)',
bodyPart: 'arms',
equipment: 'barbell',
target: 'triceps',
secondaryMuscles: ['pectorals', 'anterior deltoids'],
instructions: [
'Lie flat on the bench and grip the barbell with hands shoulder-width apart or slightly narrower.',
'Lower the bar to the lower chest, keeping elbows close to the body.',
'Press the bar back up to full extension.'
]
},
{
id: 'bench-press-dumbbell',
name: 'Bench Press (Dumbbell)',
bodyPart: 'chest',
equipment: 'dumbbell',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Lie flat on the bench holding a dumbbell in each hand at chest level.',
'Press the dumbbells up until arms are fully extended.',
'Lower the dumbbells back to chest level with control.'
]
},
{
id: 'incline-bench-press-dumbbell',
name: 'Incline Bench Press (Dumbbell)',
bodyPart: 'chest',
equipment: 'dumbbell',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Set the bench to a 30-45 degree incline.',
'Hold a dumbbell in each hand at chest level.',
'Press the dumbbells up until arms are fully extended.',
'Lower with control.'
]
},
{
id: 'chest-fly-dumbbell',
name: 'Chest Fly (Dumbbell)',
bodyPart: 'chest',
equipment: 'dumbbell',
target: 'pectorals',
secondaryMuscles: ['anterior deltoids'],
instructions: [
'Lie flat on the bench holding dumbbells above your chest with arms slightly bent.',
'Lower the dumbbells out to the sides in a wide arc.',
'Bring the dumbbells back together above your chest.'
]
},
{
id: 'chest-dip',
name: 'Chest Dip',
bodyPart: 'chest',
equipment: 'body weight',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Grip the parallel bars and lift yourself up.',
'Lean forward slightly and lower your body by bending the elbows.',
'Lower until you feel a stretch in the chest.',
'Push back up to the starting position.'
]
},
{
id: 'push-up',
name: 'Push Up',
bodyPart: 'chest',
equipment: 'body weight',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids', 'core'],
instructions: [
'Start in a plank position with hands slightly wider than shoulder width.',
'Lower your body until your chest nearly touches the floor.',
'Push back up to the starting position.'
]
},
{
id: 'cable-crossover',
name: 'Cable Crossover',
bodyPart: 'chest',
equipment: 'cable',
target: 'pectorals',
secondaryMuscles: ['anterior deltoids'],
instructions: [
'Set both pulleys to the highest position and grip the handles.',
'Step forward and bring the handles together in front of your chest in a hugging motion.',
'Slowly return to the starting position.'
]
},
// === BACK ===
{
id: 'bent-over-row-barbell',
name: 'Bent Over Row (Barbell)',
bodyPart: 'back',
equipment: 'barbell',
target: 'lats',
secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'],
instructions: [
'Stand with feet shoulder-width apart, bend at the hips with a slight knee bend.',
'Grip the barbell with an overhand grip, hands slightly wider than shoulder width.',
'Pull the bar towards your lower chest/upper abdomen.',
'Lower the bar back down with control.'
]
},
{
id: 'deadlift-barbell',
name: 'Deadlift (Barbell)',
bodyPart: 'back',
equipment: 'barbell',
target: 'erector spinae',
secondaryMuscles: ['glutes', 'hamstrings', 'lats', 'traps'],
instructions: [
'Stand with feet hip-width apart, barbell over mid-foot.',
'Bend at hips and knees, grip the bar just outside your knees.',
'Keep your back flat, chest up, and drive through your heels to stand up.',
'Lower the bar back to the floor with control.'
]
},
{
id: 'pull-up',
name: 'Pull Up',
bodyPart: 'back',
equipment: 'body weight',
target: 'lats',
secondaryMuscles: ['biceps', 'rhomboids', 'rear deltoids'],
instructions: [
'Hang from a pull-up bar with an overhand grip, hands slightly wider than shoulder width.',
'Pull yourself up until your chin is above the bar.',
'Lower yourself back down with control.'
]
},
{
id: 'chin-up',
name: 'Chin Up',
bodyPart: 'back',
equipment: 'body weight',
target: 'lats',
secondaryMuscles: ['biceps', 'rhomboids'],
instructions: [
'Hang from a pull-up bar with an underhand (supinated) grip, hands shoulder-width apart.',
'Pull yourself up until your chin is above the bar.',
'Lower yourself back down with control.'
]
},
{
id: 'lat-pulldown-cable',
name: 'Lat Pulldown (Cable)',
bodyPart: 'back',
equipment: 'cable',
target: 'lats',
secondaryMuscles: ['biceps', 'rhomboids', 'rear deltoids'],
instructions: [
'Sit at the lat pulldown machine and grip the bar wider than shoulder width.',
'Pull the bar down to your upper chest.',
'Slowly return the bar to the starting position.'
]
},
{
id: 'seated-row-cable',
name: 'Seated Row (Cable)',
bodyPart: 'back',
equipment: 'cable',
target: 'lats',
secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'],
instructions: [
'Sit at the cable row machine with feet on the platform.',
'Grip the handle and pull it towards your abdomen.',
'Squeeze your shoulder blades together at the end of the movement.',
'Slowly return to the starting position.'
]
},
{
id: 'dumbbell-row',
name: 'Dumbbell Row',
bodyPart: 'back',
equipment: 'dumbbell',
target: 'lats',
secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'],
instructions: [
'Place one knee and hand on a bench, holding a dumbbell in the other hand.',
'Pull the dumbbell up to your hip, keeping the elbow close to your body.',
'Lower the dumbbell back down with control.'
]
},
{
id: 't-bar-row',
name: 'T-Bar Row',
bodyPart: 'back',
equipment: 'barbell',
target: 'lats',
secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids', 'traps'],
instructions: [
'Straddle the T-bar row machine or landmine attachment.',
'Bend at the hips and grip the handle.',
'Pull the weight towards your chest.',
'Lower with control.'
]
},
{
id: 'face-pull-cable',
name: 'Face Pull (Cable)',
bodyPart: 'back',
equipment: 'cable',
target: 'rear deltoids',
secondaryMuscles: ['rhomboids', 'traps', 'rotator cuff'],
instructions: [
'Set the cable to upper chest height with a rope attachment.',
'Pull the rope towards your face, separating the ends.',
'Squeeze your shoulder blades and externally rotate at the end.',
'Slowly return to the starting position.'
]
},
// === SHOULDERS ===
{
id: 'overhead-press-barbell',
name: 'Overhead Press (Barbell)',
bodyPart: 'shoulders',
equipment: 'barbell',
target: 'anterior deltoids',
secondaryMuscles: ['triceps', 'lateral deltoids', 'traps'],
instructions: [
'Stand with feet shoulder-width apart, barbell at shoulder height.',
'Press the bar overhead until arms are fully extended.',
'Lower the bar back to shoulder height with control.'
]
},
{
id: 'overhead-press-dumbbell',
name: 'Overhead Press (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
target: 'anterior deltoids',
secondaryMuscles: ['triceps', 'lateral deltoids', 'traps'],
instructions: [
'Sit or stand holding dumbbells at shoulder height.',
'Press the dumbbells overhead until arms are fully extended.',
'Lower the dumbbells back to shoulder height with control.'
]
},
{
id: 'lateral-raise-dumbbell',
name: 'Lateral Raise (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
target: 'lateral deltoids',
secondaryMuscles: ['traps'],
instructions: [
'Stand with dumbbells at your sides.',
'Raise the dumbbells out to the sides until arms are parallel to the floor.',
'Lower with control.'
]
},
{
id: 'lateral-raise-cable',
name: 'Lateral Raise (Cable)',
bodyPart: 'shoulders',
equipment: 'cable',
target: 'lateral deltoids',
secondaryMuscles: ['traps'],
instructions: [
'Stand sideways to a low cable pulley, gripping the handle with the far hand.',
'Raise your arm out to the side until parallel to the floor.',
'Lower with control.'
]
},
{
id: 'front-raise-dumbbell',
name: 'Front Raise (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
target: 'anterior deltoids',
secondaryMuscles: ['lateral deltoids'],
instructions: [
'Stand with dumbbells in front of your thighs.',
'Raise one or both dumbbells to the front until arms are parallel to the floor.',
'Lower with control.'
]
},
{
id: 'reverse-fly-dumbbell',
name: 'Reverse Fly (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
target: 'rear deltoids',
secondaryMuscles: ['rhomboids', 'traps'],
instructions: [
'Bend forward at the hips holding dumbbells.',
'Raise the dumbbells out to the sides, squeezing shoulder blades together.',
'Lower with control.'
]
},
{
id: 'upright-row-barbell',
name: 'Upright Row (Barbell)',
bodyPart: 'shoulders',
equipment: 'barbell',
target: 'lateral deltoids',
secondaryMuscles: ['traps', 'biceps'],
instructions: [
'Stand holding a barbell with a narrow grip in front of your thighs.',
'Pull the bar up along your body to chin height, leading with the elbows.',
'Lower with control.'
]
},
{
id: 'shrug-barbell',
name: 'Shrug (Barbell)',
bodyPart: 'shoulders',
equipment: 'barbell',
target: 'traps',
secondaryMuscles: [],
instructions: [
'Stand holding a barbell with arms extended.',
'Shrug your shoulders straight up towards your ears.',
'Hold briefly at the top, then lower with control.'
]
},
{
id: 'shrug-dumbbell',
name: 'Shrug (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
target: 'traps',
secondaryMuscles: [],
instructions: [
'Stand holding dumbbells at your sides.',
'Shrug your shoulders straight up towards your ears.',
'Hold briefly at the top, then lower with control.'
]
},
// === ARMS — BICEPS ===
{
id: 'bicep-curl-barbell',
name: 'Bicep Curl (Barbell)',
bodyPart: 'arms',
equipment: 'barbell',
target: 'biceps',
secondaryMuscles: ['forearms'],
instructions: [
'Stand holding a barbell with an underhand grip, arms extended.',
'Curl the bar up towards your shoulders.',
'Lower with control.'
]
},
{
id: 'bicep-curl-dumbbell',
name: 'Bicep Curl (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'biceps',
secondaryMuscles: ['forearms'],
instructions: [
'Stand holding dumbbells at your sides with palms facing forward.',
'Curl the dumbbells up towards your shoulders.',
'Lower with control.'
]
},
{
id: 'hammer-curl-dumbbell',
name: 'Hammer Curl (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'biceps',
secondaryMuscles: ['brachioradialis', 'forearms'],
instructions: [
'Stand holding dumbbells at your sides with palms facing each other (neutral grip).',
'Curl the dumbbells up towards your shoulders.',
'Lower with control.'
]
},
{
id: 'preacher-curl-barbell',
name: 'Preacher Curl (Barbell)',
bodyPart: 'arms',
equipment: 'barbell',
target: 'biceps',
secondaryMuscles: ['forearms'],
instructions: [
'Sit at a preacher bench with upper arms resting on the pad.',
'Grip the barbell with an underhand grip.',
'Curl the bar up, then lower with control.'
]
},
{
id: 'concentration-curl-dumbbell',
name: 'Concentration Curl (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'biceps',
secondaryMuscles: [],
instructions: [
'Sit on a bench, rest your elbow against the inside of your thigh.',
'Curl the dumbbell up towards your shoulder.',
'Lower with control.'
]
},
{
id: 'cable-curl',
name: 'Cable Curl',
bodyPart: 'arms',
equipment: 'cable',
target: 'biceps',
secondaryMuscles: ['forearms'],
instructions: [
'Stand facing a low cable pulley with a straight or EZ-bar attachment.',
'Curl the bar up towards your shoulders.',
'Lower with control.'
]
},
// === ARMS — TRICEPS ===
{
id: 'tricep-pushdown-cable',
name: 'Tricep Pushdown (Cable)',
bodyPart: 'arms',
equipment: 'cable',
target: 'triceps',
secondaryMuscles: [],
instructions: [
'Stand facing a high cable pulley with a straight bar or rope attachment.',
'Push the bar down until arms are fully extended.',
'Slowly return to the starting position.'
]
},
{
id: 'skullcrusher-dumbbell',
name: 'Skullcrusher (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'triceps',
secondaryMuscles: [],
instructions: [
'Lie flat on a bench holding dumbbells with arms extended above your chest.',
'Lower the dumbbells towards your forehead by bending at the elbows.',
'Extend the arms back to the starting position.'
]
},
{
id: 'skullcrusher-barbell',
name: 'Skullcrusher (Barbell)',
bodyPart: 'arms',
equipment: 'barbell',
target: 'triceps',
secondaryMuscles: [],
instructions: [
'Lie flat on a bench holding a barbell with arms extended above your chest.',
'Lower the bar towards your forehead by bending at the elbows.',
'Extend the arms back to the starting position.'
]
},
{
id: 'overhead-tricep-extension-dumbbell',
name: 'Overhead Tricep Extension (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'triceps',
secondaryMuscles: [],
instructions: [
'Hold a dumbbell overhead with both hands.',
'Lower the dumbbell behind your head by bending at the elbows.',
'Extend back to the starting position.'
]
},
{
id: 'tricep-dip',
name: 'Tricep Dip',
bodyPart: 'arms',
equipment: 'body weight',
target: 'triceps',
secondaryMuscles: ['pectorals', 'anterior deltoids'],
instructions: [
'Grip the parallel bars and lift yourself up, keeping torso upright.',
'Lower your body by bending the elbows, keeping them close to your body.',
'Push back up to the starting position.'
]
},
{
id: 'kickback-dumbbell',
name: 'Kickback (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'triceps',
secondaryMuscles: [],
instructions: [
'Bend forward at the hips, upper arm parallel to the floor.',
'Extend the dumbbell backwards until the arm is straight.',
'Lower with control.'
]
},
// === LEGS ===
{
id: 'squat-barbell',
name: 'Squat (Barbell)',
bodyPart: 'legs',
equipment: 'barbell',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings', 'core'],
instructions: [
'Position the barbell on your upper back (high bar) or rear deltoids (low bar).',
'Stand with feet shoulder-width apart.',
'Squat down until thighs are at least parallel to the floor.',
'Drive through your heels to stand back up.'
]
},
{
id: 'front-squat-barbell',
name: 'Front Squat (Barbell)',
bodyPart: 'legs',
equipment: 'barbell',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'core'],
instructions: [
'Position the barbell across the front of your shoulders.',
'Squat down, keeping the elbows high and torso upright.',
'Drive through your heels to stand back up.'
]
},
{
id: 'leg-press-machine',
name: 'Leg Press (Machine)',
bodyPart: 'legs',
equipment: 'machine',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [
'Sit in the leg press machine with feet shoulder-width apart on the platform.',
'Lower the platform by bending your knees to about 90 degrees.',
'Push the platform back up without locking your knees.'
]
},
{
id: 'lunge-dumbbell',
name: 'Lunge (Dumbbell)',
bodyPart: 'legs',
equipment: 'dumbbell',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [
'Stand holding dumbbells at your sides.',
'Step forward and lower your body until both knees are at 90 degrees.',
'Push back to the starting position.'
]
},
{
id: 'bulgarian-split-squat-dumbbell',
name: 'Bulgarian Split Squat (Dumbbell)',
bodyPart: 'legs',
equipment: 'dumbbell',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [
'Stand with one foot on a bench behind you, holding dumbbells.',
'Lower your body until the front thigh is parallel to the floor.',
'Drive through the front heel to stand back up.'
]
},
{
id: 'leg-extension-machine',
name: 'Leg Extension (Machine)',
bodyPart: 'legs',
equipment: 'machine',
target: 'quadriceps',
secondaryMuscles: [],
instructions: [
'Sit in the leg extension machine with the pad against your lower shins.',
'Extend your legs until they are straight.',
'Lower with control.'
]
},
{
id: 'leg-curl-machine',
name: 'Leg Curl (Machine)',
bodyPart: 'legs',
equipment: 'machine',
target: 'hamstrings',
secondaryMuscles: ['calves'],
instructions: [
'Lie face down on the leg curl machine with the pad against the back of your ankles.',
'Curl your legs up towards your glutes.',
'Lower with control.'
]
},
{
id: 'romanian-deadlift-barbell',
name: 'Romanian Deadlift (Barbell)',
bodyPart: 'legs',
equipment: 'barbell',
target: 'hamstrings',
secondaryMuscles: ['glutes', 'erector spinae'],
instructions: [
'Stand holding a barbell with an overhand grip.',
'Hinge at the hips, pushing them back while keeping legs nearly straight.',
'Lower the bar along your legs until you feel a stretch in the hamstrings.',
'Drive the hips forward to return to standing.'
]
},
{
id: 'romanian-deadlift-dumbbell',
name: 'Romanian Deadlift (Dumbbell)',
bodyPart: 'legs',
equipment: 'dumbbell',
target: 'hamstrings',
secondaryMuscles: ['glutes', 'erector spinae'],
instructions: [
'Stand holding dumbbells in front of your thighs.',
'Hinge at the hips, pushing them back while keeping legs nearly straight.',
'Lower the dumbbells along your legs until you feel a stretch in the hamstrings.',
'Drive the hips forward to return to standing.'
]
},
{
id: 'hip-thrust-barbell',
name: 'Hip Thrust (Barbell)',
bodyPart: 'legs',
equipment: 'barbell',
target: 'glutes',
secondaryMuscles: ['hamstrings'],
instructions: [
'Sit on the floor with your upper back against a bench, barbell over your hips.',
'Drive through your heels to lift your hips until your body forms a straight line.',
'Squeeze your glutes at the top.',
'Lower with control.'
]
},
{
id: 'calf-raise-machine',
name: 'Calf Raise (Machine)',
bodyPart: 'legs',
equipment: 'machine',
target: 'calves',
secondaryMuscles: [],
instructions: [
'Stand on the machine platform with the balls of your feet on the edge.',
'Lower your heels as far as comfortable.',
'Push up onto your toes as high as possible.',
'Lower with control.'
]
},
{
id: 'calf-raise-standing',
name: 'Calf Raise (Standing)',
bodyPart: 'legs',
equipment: 'body weight',
target: 'calves',
secondaryMuscles: [],
instructions: [
'Stand on a step or platform with the balls of your feet on the edge.',
'Lower your heels below the platform.',
'Push up onto your toes as high as possible.',
'Lower with control.'
]
},
{
id: 'goblet-squat-dumbbell',
name: 'Goblet Squat (Dumbbell)',
bodyPart: 'legs',
equipment: 'dumbbell',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'core'],
instructions: [
'Hold a dumbbell vertically at chest level.',
'Squat down, keeping the torso upright.',
'Drive through your heels to stand back up.'
]
},
{
id: 'hack-squat-machine',
name: 'Hack Squat (Machine)',
bodyPart: 'legs',
equipment: 'machine',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [
'Position yourself in the hack squat machine with shoulders against the pads.',
'Lower the platform by bending your knees.',
'Push back up without locking your knees.'
]
},
// === CORE ===
{
id: 'plank',
name: 'Plank',
bodyPart: 'core',
equipment: 'body weight',
target: 'abdominals',
secondaryMuscles: ['obliques', 'erector spinae'],
instructions: [
'Start in a forearm plank position with elbows under shoulders.',
'Keep your body in a straight line from head to heels.',
'Hold the position for the desired duration.'
]
},
{
id: 'crunch',
name: 'Crunch',
bodyPart: 'core',
equipment: 'body weight',
target: 'abdominals',
secondaryMuscles: [],
instructions: [
'Lie on your back with knees bent and feet flat on the floor.',
'Place hands behind your head or across your chest.',
'Curl your upper body towards your knees.',
'Lower with control.'
]
},
{
id: 'hanging-leg-raise',
name: 'Hanging Leg Raise',
bodyPart: 'core',
equipment: 'body weight',
target: 'abdominals',
secondaryMuscles: ['hip flexors'],
instructions: [
'Hang from a pull-up bar with arms extended.',
'Raise your legs until they are parallel to the floor (or higher).',
'Lower with control.'
]
},
{
id: 'cable-crunch',
name: 'Cable Crunch',
bodyPart: 'core',
equipment: 'cable',
target: 'abdominals',
secondaryMuscles: [],
instructions: [
'Kneel in front of a high cable pulley with a rope attachment.',
'Hold the rope behind your head.',
'Crunch down, bringing your elbows towards your knees.',
'Return to the starting position with control.'
]
},
{
id: 'russian-twist',
name: 'Russian Twist',
bodyPart: 'core',
equipment: 'body weight',
target: 'obliques',
secondaryMuscles: ['abdominals'],
instructions: [
'Sit on the floor with knees bent, lean back slightly.',
'Rotate your torso from side to side.',
'Optionally hold a weight for added resistance.'
]
},
{
id: 'ab-wheel-rollout',
name: 'Ab Wheel Rollout',
bodyPart: 'core',
equipment: 'other',
target: 'abdominals',
secondaryMuscles: ['erector spinae', 'lats'],
instructions: [
'Kneel on the floor holding an ab wheel.',
'Roll the wheel forward, extending your body as far as possible.',
'Use your core to pull back to the starting position.'
]
},
// === CARDIO / FULL BODY ===
{
id: 'running',
name: 'Running',
bodyPart: 'cardio',
equipment: 'body weight',
target: 'cardiovascular system',
secondaryMuscles: ['quadriceps', 'hamstrings', 'calves', 'glutes'],
instructions: ['Run at a steady pace for the desired duration or distance.']
},
{
id: 'cycling-indoor',
name: 'Cycling (Indoor)',
bodyPart: 'cardio',
equipment: 'machine',
target: 'cardiovascular system',
secondaryMuscles: ['quadriceps', 'hamstrings', 'calves'],
instructions: ['Cycle at a steady pace on a stationary bike for the desired duration.']
},
{
id: 'rowing-machine',
name: 'Rowing Machine',
bodyPart: 'cardio',
equipment: 'machine',
target: 'cardiovascular system',
secondaryMuscles: ['lats', 'biceps', 'quadriceps', 'core'],
instructions: [
'Sit at the rowing machine and strap your feet in.',
'Drive with your legs first, then pull the handle to your lower chest.',
'Return to the starting position by extending arms, then bending knees.'
]
},
// === ADDITIONAL COMPOUND MOVEMENTS ===
{
id: 'clean-and-press-barbell',
name: 'Clean and Press (Barbell)',
bodyPart: 'shoulders',
equipment: 'barbell',
target: 'anterior deltoids',
secondaryMuscles: ['traps', 'quadriceps', 'glutes', 'core'],
instructions: [
'Start with the barbell on the floor.',
'Pull the bar explosively to your shoulders (clean).',
'Press the bar overhead.',
'Lower back to shoulders, then to the floor.'
]
},
{
id: 'farmers-walk',
name: "Farmer's Walk",
bodyPart: 'core',
equipment: 'dumbbell',
target: 'forearms',
secondaryMuscles: ['traps', 'core', 'grip'],
instructions: [
'Hold heavy dumbbells or farmer walk handles at your sides.',
'Walk with controlled steps for the desired distance or duration.',
'Keep your core tight and shoulders back.'
]
}
];
// Lookup map for O(1) access by ID
const exerciseMap = new Map<string, Exercise>(exercises.map((e) => [e.id, e]));
export function getExerciseById(id: string): Exercise | undefined {
return exerciseMap.get(id);
}
export function getFilterOptions(): {
bodyParts: string[];
equipment: string[];
targets: string[];
} {
const bodyParts = new Set<string>();
const equipment = new Set<string>();
const targets = new Set<string>();
for (const e of exercises) {
bodyParts.add(e.bodyPart);
equipment.add(e.equipment);
targets.add(e.target);
}
return {
bodyParts: [...bodyParts].sort(),
equipment: [...equipment].sort(),
targets: [...targets].sort()
};
}
export function searchExercises(opts: {
search?: string;
bodyPart?: string;
equipment?: string;
target?: string;
}): Exercise[] {
let results = exercises;
if (opts.bodyPart) {
results = results.filter((e) => e.bodyPart === opts.bodyPart);
}
if (opts.equipment) {
results = results.filter((e) => e.equipment === opts.equipment);
}
if (opts.target) {
results = results.filter((e) => e.target === opts.target);
}
if (opts.search) {
const query = opts.search.toLowerCase();
results = results.filter(
(e) =>
e.name.toLowerCase().includes(query) ||
e.target.toLowerCase().includes(query) ||
e.bodyPart.toLowerCase().includes(query) ||
e.equipment.toLowerCase().includes(query) ||
e.secondaryMuscles.some((m) => m.toLowerCase().includes(query))
);
}
return results;
}
+381
View File
@@ -0,0 +1,381 @@
/**
* Active workout state store — factory pattern.
* Client-side only; persisted to localStorage so state survives navigation.
* Saved to server on finish via POST /api/fitness/sessions.
*/
import { getExerciseById } from '$lib/data/exercises';
export interface WorkoutSet {
reps: number | null;
weight: number | null;
rpe: number | null;
completed: boolean;
}
export interface WorkoutExercise {
exerciseId: string;
sets: WorkoutSet[];
restTime: number; // seconds
}
export interface TemplateData {
_id: string;
name: string;
exercises: Array<{
exerciseId: string;
sets: Array<{ reps?: number; weight?: number; rpe?: number }>;
restTime?: number;
}>;
}
const STORAGE_KEY = 'fitness-active-workout';
interface StoredState {
active: boolean;
paused: boolean;
name: string;
templateId: string | null;
exercises: WorkoutExercise[];
elapsed: number; // total elapsed seconds at time of save
savedAt: number; // Date.now() at time of save
}
function createEmptySet(): WorkoutSet {
return { reps: null, weight: null, rpe: null, completed: false };
}
function saveToStorage(state: StoredState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {}
}
function loadFromStorage(): StoredState | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
function clearStorage() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
}
export function createWorkout() {
let active = $state(false);
let paused = $state(false);
let name = $state('');
let templateId: string | null = $state(null);
let exercises = $state<WorkoutExercise[]>([]);
let startTime: Date | null = $state(null);
let _pausedElapsed = $state(0); // seconds accumulated before current run
let _elapsed = $state(0);
let _restSeconds = $state(0);
let _restTotal = $state(0);
let _restActive = $state(false);
let _timerInterval: ReturnType<typeof setInterval> | null = null;
let _restInterval: ReturnType<typeof setInterval> | null = null;
function _persist() {
if (!active) return;
// When running, compute current elapsed before saving
if (!paused && startTime) {
_elapsed = _pausedElapsed + Math.floor((Date.now() - startTime.getTime()) / 1000);
}
saveToStorage({
active,
paused,
name,
templateId,
exercises: JSON.parse(JSON.stringify(exercises)),
elapsed: _elapsed,
savedAt: Date.now()
});
}
function _computeElapsed() {
if (paused || !startTime) return;
_elapsed = _pausedElapsed + Math.floor((Date.now() - startTime.getTime()) / 1000);
}
function _startTimer() {
_stopTimer();
_timerInterval = setInterval(() => {
_computeElapsed();
}, 1000);
}
function _stopTimer() {
if (_timerInterval) {
clearInterval(_timerInterval);
_timerInterval = null;
}
}
function _stopRestTimer() {
if (_restInterval) {
clearInterval(_restInterval);
_restInterval = null;
}
_restActive = false;
_restSeconds = 0;
_restTotal = 0;
}
// Restore from localStorage on creation
function restore() {
const stored = loadFromStorage();
if (!stored || !stored.active) return;
active = true;
paused = stored.paused;
name = stored.name;
templateId = stored.templateId;
exercises = stored.exercises;
if (stored.paused) {
// Was paused: elapsed is exactly what was saved
_pausedElapsed = stored.elapsed;
_elapsed = stored.elapsed;
startTime = null;
} else {
// Was running: add the time that passed since we last saved
const secondsSinceSave = Math.floor((Date.now() - stored.savedAt) / 1000);
const totalElapsed = stored.elapsed + secondsSinceSave;
_pausedElapsed = totalElapsed;
_elapsed = totalElapsed;
startTime = new Date(); // start counting from now
_startTimer();
}
}
function startFromTemplate(template: TemplateData) {
name = template.name;
templateId = template._id;
exercises = template.exercises.map((e) => ({
exerciseId: e.exerciseId,
sets: e.sets.length > 0
? e.sets.map((s) => ({
reps: s.reps ?? null,
weight: s.weight ?? null,
rpe: s.rpe ?? null,
completed: false
}))
: [createEmptySet()],
restTime: e.restTime ?? 120
}));
startTime = new Date();
_pausedElapsed = 0;
_elapsed = 0;
paused = false;
active = true;
_startTimer();
_persist();
}
function startEmpty() {
name = 'Quick Workout';
templateId = null;
exercises = [];
startTime = new Date();
_pausedElapsed = 0;
_elapsed = 0;
paused = false;
active = true;
_startTimer();
_persist();
}
function pauseTimer() {
if (!active || paused) return;
_computeElapsed();
_pausedElapsed = _elapsed;
paused = true;
startTime = null;
_stopTimer();
_persist();
}
function resumeTimer() {
if (!active || !paused) return;
paused = false;
startTime = new Date();
_startTimer();
_persist();
}
function addExercise(exerciseId: string) {
exercises.push({
exerciseId,
sets: [createEmptySet()],
restTime: 120
});
_persist();
}
function removeExercise(index: number) {
exercises.splice(index, 1);
_persist();
}
function addSet(exerciseIndex: number) {
const ex = exercises[exerciseIndex];
if (ex) {
ex.sets.push(createEmptySet());
_persist();
}
}
function removeSet(exerciseIndex: number, setIndex: number) {
const ex = exercises[exerciseIndex];
if (ex && ex.sets.length > 1) {
ex.sets.splice(setIndex, 1);
_persist();
}
}
function updateSet(exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) {
const ex = exercises[exerciseIndex];
if (ex?.sets[setIndex]) {
Object.assign(ex.sets[setIndex], data);
_persist();
}
}
function toggleSetComplete(exerciseIndex: number, setIndex: number) {
const ex = exercises[exerciseIndex];
if (ex?.sets[setIndex]) {
const wasCompleted = ex.sets[setIndex].completed;
ex.sets[setIndex].completed = !wasCompleted;
if (wasCompleted) {
// Unticked — cancel rest timer
_stopRestTimer();
}
_persist();
}
}
function startRestTimer(seconds: number) {
_stopRestTimer();
_restSeconds = seconds;
_restTotal = seconds;
_restActive = true;
_restInterval = setInterval(() => {
_restSeconds--;
if (_restSeconds <= 0) {
_stopRestTimer();
}
}, 1000);
}
function cancelRestTimer() {
_stopRestTimer();
}
function finish() {
_stopTimer();
_stopRestTimer();
const endTime = new Date();
_computeElapsed();
const sessionData = {
templateId,
templateName: templateId ? name : undefined,
name,
exercises: exercises
.filter((e) => e.sets.some((s) => s.completed))
.map((e) => ({
exerciseId: e.exerciseId,
name: getExerciseById(e.exerciseId)?.name ?? e.exerciseId,
sets: e.sets
.filter((s) => s.completed)
.map((s) => ({
reps: s.reps ?? 0,
weight: s.weight ?? 0,
rpe: s.rpe ?? undefined,
completed: true
}))
})),
startTime: _getOriginalStartTime()?.toISOString() ?? new Date().toISOString(),
endTime: endTime.toISOString()
};
_reset();
return sessionData;
}
function _getOriginalStartTime(): Date | null {
// Compute original start from elapsed
if (_elapsed > 0) {
return new Date(Date.now() - _elapsed * 1000);
}
return startTime;
}
function _reset() {
active = false;
paused = false;
name = '';
templateId = null;
exercises = [];
startTime = null;
_pausedElapsed = 0;
_elapsed = 0;
clearStorage();
}
function cancel() {
_stopTimer();
_stopRestTimer();
_reset();
}
return {
get active() { return active; },
get paused() { return paused; },
get name() { return name; },
set name(v: string) { name = v; _persist(); },
get templateId() { return templateId; },
get exercises() { return exercises; },
get startTime() { return startTime; },
get elapsedSeconds() { return _elapsed; },
get restTimerSeconds() { return _restSeconds; },
get restTimerTotal() { return _restTotal; },
get restTimerActive() { return _restActive; },
restore,
startFromTemplate,
startEmpty,
pauseTimer,
resumeTimer,
addExercise,
removeExercise,
addSet,
removeSet,
updateSet,
toggleSetComplete,
startRestTimer,
cancelRestTimer,
finish,
cancel
};
}
/** Shared singleton — use this instead of createWorkout() in components */
let _instance: ReturnType<typeof createWorkout> | null = null;
export function getWorkout() {
if (!_instance) {
_instance = createWorkout();
}
return _instance;
}