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:
@@ -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)} · {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} × {exercise?.name ?? ex.exerciseId}</span>
|
||||
{#if best}
|
||||
<span class="ex-best">{best.weight} kg × {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>
|
||||
@@ -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} × {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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user