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:
82
src/lib/components/AddActionButton.svelte
Normal file
82
src/lib/components/AddActionButton.svelte
Normal 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>
|
||||
28
src/lib/components/fitness/ExerciseName.svelte
Normal file
28
src/lib/components/fitness/ExerciseName.svelte
Normal file
@@ -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>
|
||||
217
src/lib/components/fitness/ExercisePicker.svelte
Normal file
217
src/lib/components/fitness/ExercisePicker.svelte
Normal file
@@ -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>
|
||||
168
src/lib/components/fitness/FitnessChart.svelte
Normal file
168
src/lib/components/fitness/FitnessChart.svelte
Normal file
@@ -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>
|
||||
76
src/lib/components/fitness/RestTimer.svelte
Normal file
76
src/lib/components/fitness/RestTimer.svelte
Normal file
@@ -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>
|
||||
170
src/lib/components/fitness/SessionCard.svelte
Normal file
170
src/lib/components/fitness/SessionCard.svelte
Normal file
@@ -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>
|
||||
206
src/lib/components/fitness/SetTable.svelte
Normal file
206
src/lib/components/fitness/SetTable.svelte
Normal 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>
|
||||
130
src/lib/components/fitness/TemplateCard.svelte
Normal file
130
src/lib/components/fitness/TemplateCard.svelte
Normal file
@@ -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>
|
||||
88
src/lib/components/fitness/WorkoutFab.svelte
Normal file
88
src/lib/components/fitness/WorkoutFab.svelte
Normal file
@@ -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
src/lib/data/exercises.ts
Normal file
947
src/lib/data/exercises.ts
Normal 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
src/lib/js/workout.svelte.ts
Normal file
381
src/lib/js/workout.svelte.ts
Normal 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;
|
||||
}
|
||||
99
src/models/BodyMeasurement.ts
Normal file
99
src/models/BodyMeasurement.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface IBodyPartMeasurements {
|
||||
neck?: number;
|
||||
shoulders?: number;
|
||||
chest?: number;
|
||||
leftBicep?: number;
|
||||
rightBicep?: number;
|
||||
leftForearm?: number;
|
||||
rightForearm?: number;
|
||||
waist?: number;
|
||||
hips?: number;
|
||||
leftThigh?: number;
|
||||
rightThigh?: number;
|
||||
leftCalf?: number;
|
||||
rightCalf?: number;
|
||||
}
|
||||
|
||||
export interface IBodyMeasurement {
|
||||
_id?: string;
|
||||
date: Date;
|
||||
weight?: number;
|
||||
bodyFatPercent?: number;
|
||||
caloricIntake?: number;
|
||||
measurements?: IBodyPartMeasurements;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const BodyPartMeasurementsSchema = new mongoose.Schema(
|
||||
{
|
||||
neck: { type: Number, min: 0 },
|
||||
shoulders: { type: Number, min: 0 },
|
||||
chest: { type: Number, min: 0 },
|
||||
leftBicep: { type: Number, min: 0 },
|
||||
rightBicep: { type: Number, min: 0 },
|
||||
leftForearm: { type: Number, min: 0 },
|
||||
rightForearm: { type: Number, min: 0 },
|
||||
waist: { type: Number, min: 0 },
|
||||
hips: { type: Number, min: 0 },
|
||||
leftThigh: { type: Number, min: 0 },
|
||||
rightThigh: { type: Number, min: 0 },
|
||||
leftCalf: { type: Number, min: 0 },
|
||||
rightCalf: { type: Number, min: 0 }
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const BodyMeasurementSchema = new mongoose.Schema(
|
||||
{
|
||||
date: {
|
||||
type: Date,
|
||||
required: true,
|
||||
default: Date.now
|
||||
},
|
||||
weight: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 500
|
||||
},
|
||||
bodyFatPercent: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 100
|
||||
},
|
||||
caloricIntake: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 50000
|
||||
},
|
||||
measurements: {
|
||||
type: BodyPartMeasurementsSchema
|
||||
},
|
||||
notes: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 500
|
||||
},
|
||||
createdBy: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
}
|
||||
);
|
||||
|
||||
BodyMeasurementSchema.index({ createdBy: 1, date: -1 });
|
||||
|
||||
export const BodyMeasurement = mongoose.model<IBodyMeasurement>(
|
||||
'BodyMeasurement',
|
||||
BodyMeasurementSchema
|
||||
);
|
||||
@@ -1,30 +1,18 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { Exercise } from '$models/Exercise';
|
||||
import { getExerciseById } from '$lib/data/exercises';
|
||||
|
||||
// GET /api/fitness/exercises/[id] - Get detailed exercise information
|
||||
// GET /api/fitness/exercises/[id] - Get exercise from static data
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session || !session.user?.nickname) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const session = await locals.auth();
|
||||
if (!session || !session.user?.nickname) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await dbConnect();
|
||||
|
||||
const exercise = await Exercise.findOne({
|
||||
exerciseId: params.id,
|
||||
isActive: true
|
||||
});
|
||||
const exercise = getExerciseById(params.id);
|
||||
if (!exercise) {
|
||||
return json({ error: 'Exercise not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!exercise) {
|
||||
return json({ error: 'Exercise not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({ exercise });
|
||||
} catch (error) {
|
||||
console.error('Error fetching exercise details:', error);
|
||||
return json({ error: 'Failed to fetch exercise details' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
return json({ exercise });
|
||||
};
|
||||
|
||||
48
src/routes/api/fitness/exercises/[id]/history/+server.ts
Normal file
48
src/routes/api/fitness/exercises/[id]/history/+server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAuth } from '$lib/server/middleware/auth';
|
||||
import { getExerciseById } from '$lib/data/exercises';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { WorkoutSession } from '$models/WorkoutSession';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
const user = await requireAuth(locals);
|
||||
|
||||
const exercise = getExerciseById(params.id);
|
||||
if (!exercise) {
|
||||
return json({ error: 'Exercise not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20');
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||
|
||||
await dbConnect();
|
||||
|
||||
const sessions = await WorkoutSession.find({
|
||||
createdBy: user.nickname,
|
||||
'exercises.exerciseId': params.id
|
||||
})
|
||||
.sort({ startTime: -1 })
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
.lean();
|
||||
|
||||
// Extract only the relevant exercise data from each session
|
||||
const history = sessions.map((session) => {
|
||||
const exerciseData = session.exercises.find((e) => e.exerciseId === params.id);
|
||||
return {
|
||||
sessionId: session._id,
|
||||
sessionName: session.name,
|
||||
date: session.startTime,
|
||||
sets: exerciseData?.sets ?? [],
|
||||
notes: exerciseData?.notes
|
||||
};
|
||||
});
|
||||
|
||||
const total = await WorkoutSession.countDocuments({
|
||||
createdBy: user.nickname,
|
||||
'exercises.exerciseId': params.id
|
||||
});
|
||||
|
||||
return json({ history, total, limit, offset });
|
||||
};
|
||||
114
src/routes/api/fitness/exercises/[id]/stats/+server.ts
Normal file
114
src/routes/api/fitness/exercises/[id]/stats/+server.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAuth } from '$lib/server/middleware/auth';
|
||||
import { getExerciseById } from '$lib/data/exercises';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { WorkoutSession } from '$models/WorkoutSession';
|
||||
|
||||
/**
|
||||
* Epley formula for estimated 1RM
|
||||
*/
|
||||
function estimatedOneRepMax(weight: number, reps: number): number {
|
||||
if (reps <= 0 || weight <= 0) return 0;
|
||||
if (reps === 1) return weight;
|
||||
return Math.round(weight * (1 + reps / 30) * 10) / 10;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const user = await requireAuth(locals);
|
||||
|
||||
const exercise = getExerciseById(params.id);
|
||||
if (!exercise) {
|
||||
return json({ error: 'Exercise not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await dbConnect();
|
||||
|
||||
const sessions = await WorkoutSession.find({
|
||||
createdBy: user.nickname,
|
||||
'exercises.exerciseId': params.id
|
||||
})
|
||||
.sort({ startTime: 1 })
|
||||
.lean();
|
||||
|
||||
// Build time-series and records data
|
||||
const est1rmOverTime: { date: Date; value: number }[] = [];
|
||||
const maxWeightOverTime: { date: Date; value: number }[] = [];
|
||||
const totalVolumeOverTime: { date: Date; value: number }[] = [];
|
||||
|
||||
// Track best performance at each rep count: { reps -> { weight, date, estimated1rm } }
|
||||
const repRecords = new Map<
|
||||
number,
|
||||
{ weight: number; reps: number; date: Date; estimated1rm: number }
|
||||
>();
|
||||
let bestEst1rm = 0;
|
||||
let bestMaxWeight = 0;
|
||||
let bestMaxVolume = 0;
|
||||
|
||||
for (const session of sessions) {
|
||||
const exerciseData = session.exercises.find((e) => e.exerciseId === params.id);
|
||||
if (!exerciseData) continue;
|
||||
|
||||
const completedSets = exerciseData.sets.filter((s) => s.completed && s.weight && s.reps > 0);
|
||||
if (completedSets.length === 0) continue;
|
||||
|
||||
// Best set est. 1RM for this session
|
||||
let sessionBestEst1rm = 0;
|
||||
let sessionMaxWeight = 0;
|
||||
let sessionVolume = 0;
|
||||
|
||||
for (const set of completedSets) {
|
||||
const weight = set.weight!;
|
||||
const reps = set.reps;
|
||||
const est1rm = estimatedOneRepMax(weight, reps);
|
||||
|
||||
sessionBestEst1rm = Math.max(sessionBestEst1rm, est1rm);
|
||||
sessionMaxWeight = Math.max(sessionMaxWeight, weight);
|
||||
sessionVolume += weight * reps;
|
||||
|
||||
// Update rep records
|
||||
const existing = repRecords.get(reps);
|
||||
if (!existing || weight > existing.weight) {
|
||||
repRecords.set(reps, {
|
||||
weight,
|
||||
reps,
|
||||
date: session.startTime,
|
||||
estimated1rm: est1rm
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
est1rmOverTime.push({ date: session.startTime, value: sessionBestEst1rm });
|
||||
maxWeightOverTime.push({ date: session.startTime, value: sessionMaxWeight });
|
||||
totalVolumeOverTime.push({ date: session.startTime, value: sessionVolume });
|
||||
|
||||
bestEst1rm = Math.max(bestEst1rm, sessionBestEst1rm);
|
||||
bestMaxWeight = Math.max(bestMaxWeight, sessionMaxWeight);
|
||||
bestMaxVolume = Math.max(bestMaxVolume, sessionVolume);
|
||||
}
|
||||
|
||||
// Convert rep records to sorted array
|
||||
const records = [...repRecords.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([reps, data]) => ({
|
||||
reps,
|
||||
weight: data.weight,
|
||||
date: data.date,
|
||||
estimated1rm: data.estimated1rm
|
||||
}));
|
||||
|
||||
return json({
|
||||
charts: {
|
||||
est1rmOverTime,
|
||||
maxWeightOverTime,
|
||||
totalVolumeOverTime
|
||||
},
|
||||
personalRecords: {
|
||||
estimatedOneRepMax: bestEst1rm,
|
||||
maxWeight: bestMaxWeight,
|
||||
maxVolume: bestMaxVolume
|
||||
},
|
||||
records,
|
||||
totalSessions: sessions.length
|
||||
});
|
||||
};
|
||||
55
src/routes/api/fitness/measurements/+server.ts
Normal file
55
src/routes/api/fitness/measurements/+server.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAuth } from '$lib/server/middleware/auth';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { BodyMeasurement } from '$models/BodyMeasurement';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const user = await requireAuth(locals);
|
||||
await dbConnect();
|
||||
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50');
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||
const from = url.searchParams.get('from');
|
||||
const to = url.searchParams.get('to');
|
||||
|
||||
const query: Record<string, unknown> = { createdBy: user.nickname };
|
||||
|
||||
if (from || to) {
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (from) dateFilter.$gte = new Date(from);
|
||||
if (to) dateFilter.$lte = new Date(to);
|
||||
query.date = dateFilter;
|
||||
}
|
||||
|
||||
const measurements = await BodyMeasurement.find(query)
|
||||
.sort({ date: -1 })
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
.lean();
|
||||
|
||||
const total = await BodyMeasurement.countDocuments(query);
|
||||
|
||||
return json({ measurements, total, limit, offset });
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const user = await requireAuth(locals);
|
||||
await dbConnect();
|
||||
|
||||
const data = await request.json();
|
||||
const { date, weight, bodyFatPercent, caloricIntake, measurements, notes } = data;
|
||||
|
||||
const measurement = new BodyMeasurement({
|
||||
date: date ? new Date(date) : new Date(),
|
||||
weight,
|
||||
bodyFatPercent,
|
||||
caloricIntake,
|
||||
measurements,
|
||||
notes,
|
||||
createdBy: user.nickname
|
||||
});
|
||||
|
||||
await measurement.save();
|
||||
return json({ measurement }, { status: 201 });
|
||||
};
|
||||
78
src/routes/api/fitness/measurements/[id]/+server.ts
Normal file
78
src/routes/api/fitness/measurements/[id]/+server.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAuth } from '$lib/server/middleware/auth';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { BodyMeasurement } from '$models/BodyMeasurement';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const user = await requireAuth(locals);
|
||||
await dbConnect();
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(params.id)) {
|
||||
return json({ error: 'Invalid measurement ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const measurement = await BodyMeasurement.findOne({
|
||||
_id: params.id,
|
||||
createdBy: user.nickname
|
||||
});
|
||||
|
||||
if (!measurement) {
|
||||
return json({ error: 'Measurement not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({ measurement });
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
const user = await requireAuth(locals);
|
||||
await dbConnect();
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(params.id)) {
|
||||
return json({ error: 'Invalid measurement ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const { date, weight, bodyFatPercent, caloricIntake, measurements, notes } = data;
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (date) updateData.date = new Date(date);
|
||||
if (weight !== undefined) updateData.weight = weight;
|
||||
if (bodyFatPercent !== undefined) updateData.bodyFatPercent = bodyFatPercent;
|
||||
if (caloricIntake !== undefined) updateData.caloricIntake = caloricIntake;
|
||||
if (measurements !== undefined) updateData.measurements = measurements;
|
||||
if (notes !== undefined) updateData.notes = notes;
|
||||
|
||||
const measurement = await BodyMeasurement.findOneAndUpdate(
|
||||
{ _id: params.id, createdBy: user.nickname },
|
||||
updateData,
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!measurement) {
|
||||
return json({ error: 'Measurement not found or unauthorized' }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({ measurement });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const user = await requireAuth(locals);
|
||||
await dbConnect();
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(params.id)) {
|
||||
return json({ error: 'Invalid measurement ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const measurement = await BodyMeasurement.findOneAndDelete({
|
||||
_id: params.id,
|
||||
createdBy: user.nickname
|
||||
});
|
||||
|
||||
if (!measurement) {
|
||||
return json({ error: 'Measurement not found or unauthorized' }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({ message: 'Measurement deleted successfully' });
|
||||
};
|
||||
61
src/routes/api/fitness/measurements/latest/+server.ts
Normal file
61
src/routes/api/fitness/measurements/latest/+server.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAuth } from '$lib/server/middleware/auth';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { BodyMeasurement } from '$models/BodyMeasurement';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const user = await requireAuth(locals);
|
||||
await dbConnect();
|
||||
|
||||
// Get latest measurement that has each field
|
||||
const latestWithWeight = await BodyMeasurement.findOne({
|
||||
createdBy: user.nickname,
|
||||
weight: { $exists: true, $ne: null }
|
||||
})
|
||||
.sort({ date: -1 })
|
||||
.select('weight date')
|
||||
.lean();
|
||||
|
||||
const latestWithBodyFat = await BodyMeasurement.findOne({
|
||||
createdBy: user.nickname,
|
||||
bodyFatPercent: { $exists: true, $ne: null }
|
||||
})
|
||||
.sort({ date: -1 })
|
||||
.select('bodyFatPercent date')
|
||||
.lean();
|
||||
|
||||
const latestWithCalories = await BodyMeasurement.findOne({
|
||||
createdBy: user.nickname,
|
||||
caloricIntake: { $exists: true, $ne: null }
|
||||
})
|
||||
.sort({ date: -1 })
|
||||
.select('caloricIntake date')
|
||||
.lean();
|
||||
|
||||
const latestWithMeasurements = await BodyMeasurement.findOne({
|
||||
createdBy: user.nickname,
|
||||
measurements: { $exists: true, $ne: null }
|
||||
})
|
||||
.sort({ date: -1 })
|
||||
.select('measurements date')
|
||||
.lean();
|
||||
|
||||
return json({
|
||||
weight: latestWithWeight
|
||||
? { value: latestWithWeight.weight, date: latestWithWeight.date }
|
||||
: null,
|
||||
bodyFatPercent: latestWithBodyFat
|
||||
? { value: latestWithBodyFat.bodyFatPercent, date: latestWithBodyFat.date }
|
||||
: null,
|
||||
caloricIntake: latestWithCalories
|
||||
? { value: latestWithCalories.caloricIntake, date: latestWithCalories.date }
|
||||
: null,
|
||||
measurements: latestWithMeasurements
|
||||
? {
|
||||
value: latestWithMeasurements.measurements,
|
||||
date: latestWithMeasurements.date
|
||||
}
|
||||
: null
|
||||
});
|
||||
};
|
||||
138
src/routes/api/fitness/stats/profile/+server.ts
Normal file
138
src/routes/api/fitness/stats/profile/+server.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAuth } from '$lib/server/middleware/auth';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { WorkoutSession } from '$models/WorkoutSession';
|
||||
import { BodyMeasurement } from '$models/BodyMeasurement';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
console.time('[stats/profile] total');
|
||||
|
||||
console.time('[stats/profile] auth');
|
||||
const user = await requireAuth(locals);
|
||||
console.timeEnd('[stats/profile] auth');
|
||||
|
||||
console.time('[stats/profile] dbConnect');
|
||||
await dbConnect();
|
||||
console.timeEnd('[stats/profile] dbConnect');
|
||||
|
||||
const twelveWeeksAgo = new Date();
|
||||
twelveWeeksAgo.setDate(twelveWeeksAgo.getDate() - 84);
|
||||
|
||||
console.time('[stats/profile] countDocuments');
|
||||
const totalWorkouts = await WorkoutSession.countDocuments({ createdBy: user.nickname });
|
||||
console.timeEnd('[stats/profile] countDocuments');
|
||||
|
||||
console.time('[stats/profile] aggregate');
|
||||
const weeklyAgg = await WorkoutSession.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdBy: user.nickname,
|
||||
startTime: { $gte: twelveWeeksAgo }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
year: { $isoWeekYear: '$startTime' },
|
||||
week: { $isoWeek: '$startTime' }
|
||||
},
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { '_id.year': 1, '_id.week': 1 }
|
||||
}
|
||||
]);
|
||||
console.timeEnd('[stats/profile] aggregate');
|
||||
|
||||
console.time('[stats/profile] measurements');
|
||||
const weightMeasurements = await BodyMeasurement.find(
|
||||
{ createdBy: user.nickname, weight: { $ne: null } },
|
||||
{ date: 1, weight: 1, _id: 0 }
|
||||
)
|
||||
.sort({ date: 1 })
|
||||
.limit(30)
|
||||
.lean();
|
||||
console.timeEnd('[stats/profile] measurements');
|
||||
|
||||
// Build chart-ready workouts-per-week with filled gaps
|
||||
const weekMap = new Map<string, number>();
|
||||
for (const item of weeklyAgg) {
|
||||
weekMap.set(`${item._id.year}-${item._id.week}`, item.count);
|
||||
}
|
||||
|
||||
const workoutsChart: { labels: string[]; data: number[] } = { labels: [], data: [] };
|
||||
const now = new Date();
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i * 7);
|
||||
const year = getISOWeekYear(d);
|
||||
const week = getISOWeek(d);
|
||||
const key = `${year}-${week}`;
|
||||
workoutsChart.labels.push(`W${week}`);
|
||||
workoutsChart.data.push(weekMap.get(key) ?? 0);
|
||||
}
|
||||
|
||||
// Build chart-ready weight data with SMA ± 1 std dev confidence band
|
||||
const weightChart: {
|
||||
labels: string[];
|
||||
data: number[];
|
||||
sma: (number | null)[];
|
||||
upper: (number | null)[];
|
||||
lower: (number | null)[];
|
||||
} = { labels: [], data: [], sma: [], upper: [], lower: [] };
|
||||
const weights: number[] = [];
|
||||
for (const m of weightMeasurements) {
|
||||
const d = new Date(m.date);
|
||||
weightChart.labels.push(
|
||||
d.toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
);
|
||||
weightChart.data.push(m.weight);
|
||||
weights.push(m.weight);
|
||||
}
|
||||
|
||||
// Adaptive window: 7 if enough data, otherwise half the data (min 2)
|
||||
const w = Math.min(7, Math.max(2, Math.floor(weights.length / 2)));
|
||||
for (let i = 0; i < weights.length; i++) {
|
||||
if (i < w - 1) {
|
||||
weightChart.sma.push(null);
|
||||
weightChart.upper.push(null);
|
||||
weightChart.lower.push(null);
|
||||
} else {
|
||||
let sum = 0;
|
||||
for (let j = i - w + 1; j <= i; j++) sum += weights[j];
|
||||
const mean = sum / w;
|
||||
|
||||
let variance = 0;
|
||||
for (let j = i - w + 1; j <= i; j++) variance += (weights[j] - mean) ** 2;
|
||||
const std = Math.sqrt(variance / w);
|
||||
|
||||
const round = (v: number) => Math.round(v * 100) / 100;
|
||||
weightChart.sma.push(round(mean));
|
||||
weightChart.upper.push(round(mean + std));
|
||||
weightChart.lower.push(round(mean - std));
|
||||
}
|
||||
}
|
||||
|
||||
console.timeEnd('[stats/profile] total');
|
||||
return json({
|
||||
totalWorkouts,
|
||||
workoutsChart,
|
||||
weightChart
|
||||
});
|
||||
};
|
||||
|
||||
function getISOWeek(date: Date): number {
|
||||
const d = new Date(date.getTime());
|
||||
d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
|
||||
const week1 = new Date(d.getFullYear(), 0, 4);
|
||||
return 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7);
|
||||
}
|
||||
|
||||
function getISOWeekYear(date: Date): number {
|
||||
const d = new Date(date.getTime());
|
||||
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
|
||||
return d.getFullYear();
|
||||
}
|
||||
163
src/routes/api/fitness/templates/seed/+server.ts
Normal file
163
src/routes/api/fitness/templates/seed/+server.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAuth } from '$lib/server/middleware/auth';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { WorkoutTemplate } from '$models/WorkoutTemplate';
|
||||
|
||||
const defaultTemplates = [
|
||||
{
|
||||
name: 'Day 1 - Pull',
|
||||
description: 'Back and biceps focused pull day',
|
||||
exercises: [
|
||||
{
|
||||
exerciseId: 'bent-over-row-barbell',
|
||||
sets: [
|
||||
{ reps: 10, weight: 60, rpe: 7 },
|
||||
{ reps: 10, weight: 60, rpe: 8 },
|
||||
{ reps: 10, weight: 60, rpe: 9 }
|
||||
],
|
||||
restTime: 120
|
||||
},
|
||||
{
|
||||
exerciseId: 'pull-up',
|
||||
sets: [
|
||||
{ reps: 6, rpe: 8 },
|
||||
{ reps: 6, rpe: 8 },
|
||||
{ reps: 6, rpe: 9 }
|
||||
],
|
||||
restTime: 120
|
||||
},
|
||||
{
|
||||
exerciseId: 'lateral-raise-dumbbell',
|
||||
sets: [
|
||||
{ reps: 15, weight: 10, rpe: 7 },
|
||||
{ reps: 15, weight: 10, rpe: 8 }
|
||||
],
|
||||
restTime: 90
|
||||
},
|
||||
{
|
||||
exerciseId: 'front-raise-dumbbell',
|
||||
sets: [
|
||||
{ reps: 10, weight: 10, rpe: 7 },
|
||||
{ reps: 10, weight: 10, rpe: 8 }
|
||||
],
|
||||
restTime: 90
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Day 2 - Push',
|
||||
description: 'Chest, shoulders, and triceps push day',
|
||||
exercises: [
|
||||
{
|
||||
exerciseId: 'bench-press-barbell',
|
||||
sets: [
|
||||
{ reps: 8, weight: 80, rpe: 7 },
|
||||
{ reps: 8, weight: 80, rpe: 8 },
|
||||
{ reps: 8, weight: 80, rpe: 9 }
|
||||
],
|
||||
restTime: 120
|
||||
},
|
||||
{
|
||||
exerciseId: 'incline-bench-press-barbell',
|
||||
sets: [
|
||||
{ reps: 10, weight: 60, rpe: 7 },
|
||||
{ reps: 10, weight: 60, rpe: 8 }
|
||||
],
|
||||
restTime: 120
|
||||
},
|
||||
{
|
||||
exerciseId: 'skullcrusher-dumbbell',
|
||||
sets: [
|
||||
{ reps: 15, weight: 15, rpe: 7 },
|
||||
{ reps: 15, weight: 15, rpe: 8 }
|
||||
],
|
||||
restTime: 90
|
||||
},
|
||||
{
|
||||
exerciseId: 'hammer-curl-dumbbell',
|
||||
sets: [
|
||||
{ reps: 15, weight: 12, rpe: 7 },
|
||||
{ reps: 15, weight: 12, rpe: 8 }
|
||||
],
|
||||
restTime: 90
|
||||
},
|
||||
{
|
||||
exerciseId: 'bicep-curl-dumbbell',
|
||||
sets: [
|
||||
{ reps: 15, weight: 10, rpe: 7 },
|
||||
{ reps: 15, weight: 10, rpe: 8 }
|
||||
],
|
||||
restTime: 90
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Day 3 - Legs',
|
||||
description: 'Lower body leg day',
|
||||
exercises: [
|
||||
{
|
||||
exerciseId: 'squat-barbell',
|
||||
sets: [
|
||||
{ reps: 8, weight: 80, rpe: 7 },
|
||||
{ reps: 8, weight: 80, rpe: 8 },
|
||||
{ reps: 8, weight: 80, rpe: 9 }
|
||||
],
|
||||
restTime: 150
|
||||
},
|
||||
{
|
||||
exerciseId: 'romanian-deadlift-barbell',
|
||||
sets: [
|
||||
{ reps: 10, weight: 70, rpe: 7 },
|
||||
{ reps: 10, weight: 70, rpe: 8 }
|
||||
],
|
||||
restTime: 120
|
||||
},
|
||||
{
|
||||
exerciseId: 'leg-press-machine',
|
||||
sets: [
|
||||
{ reps: 12, weight: 100, rpe: 7 },
|
||||
{ reps: 12, weight: 100, rpe: 8 }
|
||||
],
|
||||
restTime: 120
|
||||
},
|
||||
{
|
||||
exerciseId: 'leg-curl-machine',
|
||||
sets: [
|
||||
{ reps: 12, weight: 40, rpe: 7 },
|
||||
{ reps: 12, weight: 40, rpe: 8 }
|
||||
],
|
||||
restTime: 90
|
||||
},
|
||||
{
|
||||
exerciseId: 'calf-raise-machine',
|
||||
sets: [
|
||||
{ reps: 15, weight: 60, rpe: 7 },
|
||||
{ reps: 15, weight: 60, rpe: 8 }
|
||||
],
|
||||
restTime: 60
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const POST: RequestHandler = async ({ locals }) => {
|
||||
const user = await requireAuth(locals);
|
||||
await dbConnect();
|
||||
|
||||
// Check if user already has templates (don't re-seed)
|
||||
const existingCount = await WorkoutTemplate.countDocuments({ createdBy: user.nickname });
|
||||
if (existingCount > 0) {
|
||||
return json({ message: 'Templates already exist', seeded: false });
|
||||
}
|
||||
|
||||
const templates = await WorkoutTemplate.insertMany(
|
||||
defaultTemplates.map((t) => ({
|
||||
...t,
|
||||
createdBy: user.nickname,
|
||||
isDefault: true
|
||||
}))
|
||||
);
|
||||
|
||||
return json({ message: 'Default templates created', templates, seeded: true }, { status: 201 });
|
||||
};
|
||||
7
src/routes/fitness/+layout.server.ts
Normal file
7
src/routes/fitness/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
session: await locals.auth()
|
||||
};
|
||||
};
|
||||
69
src/routes/fitness/+layout.svelte
Normal file
69
src/routes/fitness/+layout.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import UserHeader from '$lib/components/UserHeader.svelte';
|
||||
import { User, Clock, Dumbbell, ListChecks, Ruler } from 'lucide-svelte';
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let user = $derived(data.session?.user);
|
||||
|
||||
const workout = getWorkout();
|
||||
|
||||
onMount(() => {
|
||||
workout.restore();
|
||||
});
|
||||
|
||||
/** @param {string} path */
|
||||
function isActive(path) {
|
||||
const currentPath = $page.url.pathname;
|
||||
return currentPath.startsWith(path);
|
||||
}
|
||||
|
||||
const isOnActivePage = $derived($page.url.pathname === '/fitness/workout/active');
|
||||
|
||||
/** @param {number} secs */
|
||||
function formatElapsed(secs) {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Header>
|
||||
{#snippet links()}
|
||||
<ul class="site_header">
|
||||
<li style="--active-fill: var(--nord15)"><a href="/fitness/profile" class:active={isActive('/fitness/profile')}><User size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Profile</span></a></li>
|
||||
<li style="--active-fill: var(--nord13)"><a href="/fitness/history" class:active={isActive('/fitness/history')}><Clock size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">History</span></a></li>
|
||||
<li style="--active-fill: var(--nord8)"><a href="/fitness/workout" class:active={isActive('/fitness/workout')}><Dumbbell size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Workout</span></a></li>
|
||||
<li style="--active-fill: var(--nord14)"><a href="/fitness/exercises" class:active={isActive('/fitness/exercises')}><ListChecks size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Exercises</span></a></li>
|
||||
<li style="--active-fill: var(--nord12)"><a href="/fitness/measure" class:active={isActive('/fitness/measure')}><Ruler size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Measure</span></a></li>
|
||||
</ul>
|
||||
{/snippet}
|
||||
|
||||
{#snippet right_side()}
|
||||
<UserHeader {user} />
|
||||
{/snippet}
|
||||
|
||||
<div class="fitness-content">
|
||||
{@render children()}
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
{#if workout.active && !isOnActivePage}
|
||||
<WorkoutFab
|
||||
href="/fitness/workout/active"
|
||||
elapsed={formatElapsed(workout.elapsedSeconds)}
|
||||
paused={workout.paused}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.fitness-content {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-md, 1rem);
|
||||
}
|
||||
</style>
|
||||
6
src/routes/fitness/+page.server.ts
Normal file
6
src/routes/fitness/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
redirect(302, '/fitness/workout');
|
||||
};
|
||||
8
src/routes/fitness/exercises/+page.server.ts
Normal file
8
src/routes/fitness/exercises/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const res = await fetch('/api/fitness/exercises');
|
||||
return {
|
||||
exercises: await res.json()
|
||||
};
|
||||
};
|
||||
152
src/routes/fitness/exercises/+page.svelte
Normal file
152
src/routes/fitness/exercises/+page.svelte
Normal file
@@ -0,0 +1,152 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { Search } from 'lucide-svelte';
|
||||
import { getFilterOptions, searchExercises } from '$lib/data/exercises';
|
||||
|
||||
let { data } = $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
|
||||
}));
|
||||
</script>
|
||||
|
||||
<div class="exercises-page">
|
||||
<h1>Exercises</h1>
|
||||
|
||||
<div class="search-bar">
|
||||
<Search size={16} />
|
||||
<input type="text" placeholder="Search exercises…" bind:value={query} />
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<select bind:value={bodyPartFilter}>
|
||||
<option value="">All body parts</option>
|
||||
{#each filterOptions.bodyParts as 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}
|
||||
<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>
|
||||
<a href="/fitness/exercises/{exercise.id}" class="exercise-row">
|
||||
<div class="exercise-info">
|
||||
<span class="exercise-name">{exercise.name}</span>
|
||||
<span class="exercise-meta">{exercise.bodyPart} · {exercise.equipment}</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
{#if filtered.length === 0}
|
||||
<li class="no-results">No exercises match your search.</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.exercises-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--accent-dark);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
color: var(--nord4);
|
||||
}
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
.search-bar input::placeholder {
|
||||
color: var(--nord3);
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.filters select {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--accent-dark);
|
||||
border: 1px solid var(--nord3, #ddd);
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.exercise-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.exercise-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-bottom: 1px solid var(--nord3, rgba(0,0,0,0.08));
|
||||
}
|
||||
.exercise-row:hover {
|
||||
background: rgba(136, 192, 208, 0.05);
|
||||
}
|
||||
.exercise-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.exercise-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.exercise-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--nord4);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.no-results {
|
||||
text-align: center;
|
||||
color: var(--nord4);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme])) .search-bar,
|
||||
:global(:root:not([data-theme])) .filters select {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .search-bar,
|
||||
:global(:root[data-theme="light"]) .filters select {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
20
src/routes/fitness/exercises/[id]/+page.server.ts
Normal file
20
src/routes/fitness/exercises/[id]/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
const [exerciseRes, historyRes, statsRes] = await Promise.all([
|
||||
fetch(`/api/fitness/exercises/${params.id}`),
|
||||
fetch(`/api/fitness/exercises/${params.id}/history?limit=20`),
|
||||
fetch(`/api/fitness/exercises/${params.id}/stats`)
|
||||
]);
|
||||
|
||||
if (!exerciseRes.ok) {
|
||||
error(404, 'Exercise not found');
|
||||
}
|
||||
|
||||
return {
|
||||
exercise: await exerciseRes.json(),
|
||||
history: await historyRes.json(),
|
||||
stats: await statsRes.json()
|
||||
};
|
||||
};
|
||||
367
src/routes/fitness/exercises/[id]/+page.svelte
Normal file
367
src/routes/fitness/exercises/[id]/+page.svelte
Normal file
@@ -0,0 +1,367 @@
|
||||
<script>
|
||||
import { getExerciseById } from '$lib/data/exercises';
|
||||
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let activeTab = $state('about');
|
||||
|
||||
const exercise = $derived(data.exercise?.exercise ?? getExerciseById(data.exercise?.id));
|
||||
// History API returns { history: [{ sessionId, sessionName, date, sets }], total }
|
||||
const history = $derived(data.history?.history ?? []);
|
||||
// Stats API returns { charts: { est1rmOverTime, ... }, personalRecords: { ... }, records }
|
||||
const stats = $derived(data.stats ?? {});
|
||||
const charts = $derived(stats.charts ?? {});
|
||||
const prs = $derived(stats.personalRecords ?? {});
|
||||
const records = $derived(stats.records ?? []);
|
||||
|
||||
const tabs = ['about', 'history', 'charts', 'records'];
|
||||
|
||||
const est1rmChartData = $derived.by(() => {
|
||||
const points = charts.est1rmOverTime ?? [];
|
||||
return {
|
||||
labels: points.map((/** @type {any} */ p) => new Date(p.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
|
||||
datasets: [{ label: 'Est. 1RM (kg)', data: points.map((/** @type {any} */ p) => p.value) }]
|
||||
};
|
||||
});
|
||||
|
||||
const maxWeightChartData = $derived.by(() => {
|
||||
const points = charts.maxWeightOverTime ?? [];
|
||||
return {
|
||||
labels: points.map((/** @type {any} */ p) => new Date(p.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
|
||||
datasets: [{ label: 'Max Weight (kg)', data: points.map((/** @type {any} */ p) => p.value), borderColor: '#A3BE8C' }]
|
||||
};
|
||||
});
|
||||
|
||||
const volumeChartData = $derived.by(() => {
|
||||
const points = charts.totalVolumeOverTime ?? [];
|
||||
return {
|
||||
labels: points.map((/** @type {any} */ p) => new Date(p.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })),
|
||||
datasets: [{ label: 'Total Volume (kg)', data: points.map((/** @type {any} */ p) => p.value), borderColor: '#EBCB8B' }]
|
||||
};
|
||||
});
|
||||
|
||||
/** @param {number} weight @param {number} reps */
|
||||
function epley1rm(weight, reps) {
|
||||
if (reps <= 0) return weight;
|
||||
if (reps === 1) return weight;
|
||||
return Math.round(weight * (1 + reps / 30));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="exercise-detail">
|
||||
<h1>{exercise?.name ?? 'Exercise'}</h1>
|
||||
|
||||
<div class="tabs">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === tab}
|
||||
onclick={() => activeTab = tab}
|
||||
>
|
||||
{tab.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'about'}
|
||||
<div class="tab-content">
|
||||
{#if exercise?.imageUrl}
|
||||
<img src={exercise.imageUrl} alt={exercise.name} class="exercise-image" />
|
||||
{/if}
|
||||
<div class="tags">
|
||||
<span class="tag body-part">{exercise?.bodyPart}</span>
|
||||
<span class="tag equipment">{exercise?.equipment}</span>
|
||||
<span class="tag target">{exercise?.target}</span>
|
||||
</div>
|
||||
{#if exercise?.secondaryMuscles?.length}
|
||||
<p class="secondary">Also works: {exercise.secondaryMuscles.join(', ')}</p>
|
||||
{/if}
|
||||
{#if exercise?.instructions?.length}
|
||||
<h3>Instructions</h3>
|
||||
<ol class="instructions">
|
||||
{#each exercise.instructions as step}
|
||||
<li>{step}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'history'}
|
||||
<div class="tab-content">
|
||||
{#if history.length === 0}
|
||||
<p class="empty">No history for this exercise yet.</p>
|
||||
{:else}
|
||||
{#each history as entry (entry.sessionId)}
|
||||
<div class="history-session">
|
||||
<div class="history-header">
|
||||
<strong>{entry.sessionName || 'Workout'}</strong>
|
||||
<span class="history-date">{new Date(entry.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<table class="history-sets">
|
||||
<thead>
|
||||
<tr><th>SET</th><th>KG</th><th>REPS</th><th>EST. 1RM</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entry.sets as set, i (i)}
|
||||
<tr>
|
||||
<td>{i + 1}</td>
|
||||
<td>{set.weight}</td>
|
||||
<td>{set.reps}{#if set.rpe} <span class="rpe">@{set.rpe}</span>{/if}</td>
|
||||
<td>{epley1rm(set.weight, set.reps)} kg</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'charts'}
|
||||
<div class="tab-content charts-grid">
|
||||
{#if (charts.est1rmOverTime?.length ?? 0) > 0}
|
||||
<FitnessChart data={est1rmChartData} title="Best Set (Est. 1RM)" yUnit=" kg" />
|
||||
<FitnessChart data={maxWeightChartData} title="Best Set (Max Weight)" yUnit=" kg" />
|
||||
<FitnessChart data={volumeChartData} title="Total Volume" yUnit=" kg" />
|
||||
{:else}
|
||||
<p class="empty">Not enough data to display charts yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'records'}
|
||||
<div class="tab-content">
|
||||
<div class="records-summary">
|
||||
{#if prs.estimatedOneRepMax}
|
||||
<div class="record-card">
|
||||
<span class="record-label">Estimated 1RM</span>
|
||||
<span class="record-value">{prs.estimatedOneRepMax} kg</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if prs.maxVolume}
|
||||
<div class="record-card">
|
||||
<span class="record-label">Max Volume</span>
|
||||
<span class="record-value">{prs.maxVolume} kg</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if prs.maxWeight}
|
||||
<div class="record-card">
|
||||
<span class="record-label">Max Weight</span>
|
||||
<span class="record-value">{prs.maxWeight} kg</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if records.length}
|
||||
<h3>Rep Records</h3>
|
||||
<table class="records-table">
|
||||
<thead>
|
||||
<tr><th>REPS</th><th>BEST PERFORMANCE</th><th>EST. 1RM</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each records as rec (rec.reps)}
|
||||
<tr>
|
||||
<td>{rec.reps}</td>
|
||||
<td>{rec.weight} kg × {rec.reps}{#if rec.date} <span class="rec-date">({new Date(rec.date).toLocaleDateString()})</span>{/if}</td>
|
||||
<td>{rec.estimated1rm ?? epley1rm(rec.weight, rec.reps)} kg</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.exercise-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--nord3, #ddd);
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
color: var(--nord4);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: center;
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--nord8);
|
||||
border-bottom-color: var(--nord8);
|
||||
}
|
||||
.tab-content {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* About */
|
||||
.exercise-image {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tag {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.body-part { background: rgba(94, 129, 172, 0.2); color: var(--nord9); }
|
||||
.equipment { background: rgba(163, 190, 140, 0.2); color: var(--nord14); }
|
||||
.target { background: rgba(208, 135, 112, 0.2); color: var(--nord12); }
|
||||
.secondary {
|
||||
font-size: 0.8rem;
|
||||
color: var(--nord4);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
margin: 1rem 0 0.5rem;
|
||||
}
|
||||
.instructions {
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.instructions li {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* History */
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--nord4);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.history-session {
|
||||
background: var(--accent-dark);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.history-date {
|
||||
color: var(--nord4);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.history-sets {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.history-sets th {
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--nord4);
|
||||
padding: 0.25rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.history-sets td {
|
||||
text-align: center;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.rpe {
|
||||
color: var(--nord13);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
.charts-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Records */
|
||||
.records-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.record-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--accent-dark);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.record-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--nord4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.record-value {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--nord8);
|
||||
}
|
||||
.records-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.records-table th {
|
||||
text-align: left;
|
||||
font-size: 0.7rem;
|
||||
color: var(--nord4);
|
||||
padding: 0.4rem 0.5rem;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--nord3);
|
||||
}
|
||||
.records-table td {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid var(--nord3, rgba(0,0,0,0.05));
|
||||
}
|
||||
.rec-date {
|
||||
color: var(--nord4);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme])) .history-session,
|
||||
:global(:root:not([data-theme])) .record-card {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .history-session,
|
||||
:global(:root[data-theme="light"]) .record-card {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
12
src/routes/fitness/history/+page.server.ts
Normal file
12
src/routes/fitness/history/+page.server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||
const month = url.searchParams.get('month') || '';
|
||||
const params = new URLSearchParams({ limit: '50' });
|
||||
if (month) params.set('month', month);
|
||||
|
||||
const res = await fetch(`/api/fitness/sessions?${params}`);
|
||||
return {
|
||||
sessions: await res.json()
|
||||
};
|
||||
};
|
||||
111
src/routes/fitness/history/+page.svelte
Normal file
111
src/routes/fitness/history/+page.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script>
|
||||
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let sessions = $state(data.sessions?.sessions ? [...data.sessions.sessions] : []);
|
||||
let total = $state(data.sessions?.total ? data.sessions.total : 0);
|
||||
let loading = $state(false);
|
||||
let page = $state(1);
|
||||
|
||||
/** @type {Record<string, typeof sessions>} */
|
||||
const grouped = $derived.by(() => {
|
||||
/** @type {Record<string, typeof sessions>} */
|
||||
const groups = {};
|
||||
for (const s of sessions) {
|
||||
const d = new Date(s.startTime);
|
||||
const key = `${d.toLocaleString('default', { month: 'long' })} ${d.getFullYear()}`;
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(s);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || sessions.length >= total) return;
|
||||
loading = true;
|
||||
page++;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/sessions?limit=50&skip=${sessions.length}`);
|
||||
const data = await res.json();
|
||||
sessions = [...sessions, ...(data.sessions ?? [])];
|
||||
total = data.total ?? total;
|
||||
} catch {}
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="history-page">
|
||||
<h1>History</h1>
|
||||
|
||||
{#if sessions.length === 0}
|
||||
<p class="empty">No workouts yet. Start your first workout!</p>
|
||||
{:else}
|
||||
{#each Object.entries(grouped) as [month, monthSessions] (month)}
|
||||
<section class="month-group">
|
||||
<h2 class="month-header">{month} — {monthSessions.length} workout{monthSessions.length !== 1 ? 's' : ''}</h2>
|
||||
<div class="session-list">
|
||||
{#each monthSessions as session (session._id)}
|
||||
<SessionCard {session} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#if sessions.length < total}
|
||||
<button class="load-more" onclick={loadMore} disabled={loading}>
|
||||
{loading ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.history-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--nord4);
|
||||
padding: 3rem 0;
|
||||
}
|
||||
.month-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.month-header {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--nord4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.load-more {
|
||||
align-self: center;
|
||||
padding: 0.6rem 2rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 8px;
|
||||
color: var(--nord8);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.load-more:hover {
|
||||
border-color: var(--nord8);
|
||||
}
|
||||
.load-more:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
14
src/routes/fitness/history/[id]/+page.server.ts
Normal file
14
src/routes/fitness/history/[id]/+page.server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
const res = await fetch(`/api/fitness/sessions/${params.id}`);
|
||||
|
||||
if (!res.ok) {
|
||||
error(404, 'Session not found');
|
||||
}
|
||||
|
||||
return {
|
||||
session: (await res.json()).session
|
||||
};
|
||||
};
|
||||
313
src/routes/fitness/history/[id]/+page.svelte
Normal file
313
src/routes/fitness/history/[id]/+page.svelte
Normal file
@@ -0,0 +1,313 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { Clock, Weight, Trophy, Trash2 } from 'lucide-svelte';
|
||||
import { getExerciseById } from '$lib/data/exercises';
|
||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const session = $derived(data.session);
|
||||
let deleting = $state(false);
|
||||
|
||||
/** @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: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
/** @param {string} dateStr */
|
||||
function formatTime(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** @param {number} weight @param {number} reps */
|
||||
function epley1rm(weight, reps) {
|
||||
if (reps <= 0 || weight <= 0) return 0;
|
||||
if (reps === 1) return weight;
|
||||
return Math.round(weight * (1 + reps / 30));
|
||||
}
|
||||
|
||||
async function deleteSession() {
|
||||
if (!confirm('Delete this workout session?')) return;
|
||||
deleting = true;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/sessions/${session._id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
goto('/fitness/history');
|
||||
}
|
||||
} catch {}
|
||||
deleting = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="session-detail">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h1>{session.name}</h1>
|
||||
<p class="session-date">{formatDate(session.startTime)} · {formatTime(session.startTime)}</p>
|
||||
</div>
|
||||
<button class="delete-btn" onclick={deleteSession} disabled={deleting} aria-label="Delete session">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
{#if session.duration}
|
||||
<div class="stat-pill">
|
||||
<Clock size={14} />
|
||||
<span>{formatDuration(session.duration)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if session.totalVolume}
|
||||
<div class="stat-pill">
|
||||
<Weight size={14} />
|
||||
<span>{Math.round(session.totalVolume).toLocaleString()} kg</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if session.prs?.length > 0}
|
||||
<div class="stat-pill pr">
|
||||
<Trophy size={14} />
|
||||
<span>{session.prs.length} PR{session.prs.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each session.exercises as ex, exIdx (ex.exerciseId + '-' + exIdx)}
|
||||
<div class="exercise-block">
|
||||
<h3 class="exercise-title">
|
||||
<ExerciseName exerciseId={ex.exerciseId} />
|
||||
</h3>
|
||||
<table class="sets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SET</th>
|
||||
<th>KG</th>
|
||||
<th>REPS</th>
|
||||
<th>RPE</th>
|
||||
<th>EST. 1RM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each ex.sets as set, i (i)}
|
||||
<tr>
|
||||
<td class="set-num">{i + 1}</td>
|
||||
<td>{set.weight ?? '—'}</td>
|
||||
<td>{set.reps ?? '—'}</td>
|
||||
<td class="rpe">{set.rpe ?? '—'}</td>
|
||||
<td class="est1rm">{set.weight && set.reps ? epley1rm(set.weight, set.reps) : '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if session.prs?.length > 0}
|
||||
<div class="prs-section">
|
||||
<h2>Personal Records</h2>
|
||||
<div class="pr-list">
|
||||
{#each session.prs as pr (pr.exerciseId + pr.type)}
|
||||
{@const exercise = getExerciseById(pr.exerciseId)}
|
||||
<div class="pr-item">
|
||||
<Trophy size={14} class="pr-icon" />
|
||||
<span class="pr-exercise">{exercise?.name ?? pr.exerciseId}</span>
|
||||
<span class="pr-type">
|
||||
{#if pr.type === 'est1rm'}Est. 1RM
|
||||
{:else if pr.type === 'maxWeight'}Max Weight
|
||||
{:else if pr.type === 'repMax'}{pr.reps}-rep max
|
||||
{:else}{pr.type}{/if}
|
||||
</span>
|
||||
<span class="pr-value">{pr.value} kg</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if session.notes}
|
||||
<div class="notes-section">
|
||||
<h2>Notes</h2>
|
||||
<p>{session.notes}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.session-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.session-date {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
.delete-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--nord11);
|
||||
border-radius: 8px;
|
||||
color: var(--nord11);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
display: flex;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.delete-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: var(--accent-dark);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
font-size: 0.8rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
.stat-pill.pr {
|
||||
color: var(--nord13);
|
||||
border-color: var(--nord13);
|
||||
background: rgba(235, 203, 139, 0.1);
|
||||
}
|
||||
|
||||
.exercise-block {
|
||||
background: var(--accent-dark);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
padding: 1rem;
|
||||
}
|
||||
.exercise-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.sets-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.sets-table th {
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--nord4);
|
||||
padding: 0.3rem 0.4rem;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sets-table td {
|
||||
text-align: center;
|
||||
padding: 0.35rem 0.4rem;
|
||||
border-top: 1px solid var(--nord3, rgba(0,0,0,0.08));
|
||||
}
|
||||
.set-num {
|
||||
font-weight: 700;
|
||||
color: var(--nord4);
|
||||
}
|
||||
.rpe {
|
||||
color: var(--nord13);
|
||||
}
|
||||
.est1rm {
|
||||
color: var(--nord8);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prs-section {
|
||||
background: rgba(235, 203, 139, 0.08);
|
||||
border: 1px solid rgba(235, 203, 139, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.prs-section h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
color: var(--nord13);
|
||||
}
|
||||
.pr-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.pr-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.pr-item :global(.pr-icon) {
|
||||
color: var(--nord13);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pr-exercise {
|
||||
font-weight: 600;
|
||||
}
|
||||
.pr-type {
|
||||
color: var(--nord4);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.pr-value {
|
||||
margin-left: auto;
|
||||
font-weight: 700;
|
||||
color: var(--nord13);
|
||||
}
|
||||
|
||||
.notes-section {
|
||||
background: var(--accent-dark);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
padding: 1rem;
|
||||
}
|
||||
.notes-section h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.notes-section p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme])) .exercise-block,
|
||||
:global(:root:not([data-theme])) .notes-section,
|
||||
:global(:root:not([data-theme])) .stat-pill {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .exercise-block,
|
||||
:global(:root[data-theme="light"]) .notes-section,
|
||||
:global(:root[data-theme="light"]) .stat-pill {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
13
src/routes/fitness/measure/+page.server.ts
Normal file
13
src/routes/fitness/measure/+page.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const [latestRes, listRes] = await Promise.all([
|
||||
fetch('/api/fitness/measurements/latest'),
|
||||
fetch('/api/fitness/measurements?limit=20')
|
||||
]);
|
||||
|
||||
return {
|
||||
latest: await latestRes.json(),
|
||||
measurements: await listRes.json()
|
||||
};
|
||||
};
|
||||
364
src/routes/fitness/measure/+page.svelte
Normal file
364
src/routes/fitness/measure/+page.svelte
Normal file
@@ -0,0 +1,364 @@
|
||||
<script>
|
||||
import { X } from 'lucide-svelte';
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import AddActionButton from '$lib/components/AddActionButton.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const workout = getWorkout();
|
||||
|
||||
let latest = $state(data.latest ? { ...data.latest } : {});
|
||||
let measurements = $state(data.measurements?.measurements ? [...data.measurements.measurements] : []);
|
||||
let showForm = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
// Form fields
|
||||
let formDate = $state(new Date().toISOString().slice(0, 10));
|
||||
let formWeight = $state('');
|
||||
let formBodyFat = $state('');
|
||||
let formCalories = $state('');
|
||||
let formNeck = $state('');
|
||||
let formShoulders = $state('');
|
||||
let formChest = $state('');
|
||||
let formBicepsL = $state('');
|
||||
let formBicepsR = $state('');
|
||||
let formForearmsL = $state('');
|
||||
let formForearmsR = $state('');
|
||||
let formWaist = $state('');
|
||||
let formHips = $state('');
|
||||
let formThighsL = $state('');
|
||||
let formThighsR = $state('');
|
||||
let formCalvesL = $state('');
|
||||
let formCalvesR = $state('');
|
||||
|
||||
const bodyPartFields = $derived([
|
||||
{ label: 'Neck', key: 'neck', value: latest.measurements?.neck },
|
||||
{ label: 'Shoulders', key: 'shoulders', value: latest.measurements?.shoulders },
|
||||
{ label: 'Chest', key: 'chest', value: latest.measurements?.chest },
|
||||
{ label: 'Left Bicep', key: 'bicepsLeft', value: latest.measurements?.biceps?.left },
|
||||
{ label: 'Right Bicep', key: 'bicepsRight', value: latest.measurements?.biceps?.right },
|
||||
{ label: 'Left Forearm', key: 'forearmsLeft', value: latest.measurements?.forearms?.left },
|
||||
{ label: 'Right Forearm', key: 'forearmsRight', value: latest.measurements?.forearms?.right },
|
||||
{ label: 'Waist', key: 'waist', value: latest.measurements?.waist },
|
||||
{ label: 'Hips', key: 'hips', value: latest.measurements?.hips },
|
||||
{ label: 'Left Thigh', key: 'thighsLeft', value: latest.measurements?.thighs?.left },
|
||||
{ label: 'Right Thigh', key: 'thighsRight', value: latest.measurements?.thighs?.right },
|
||||
{ label: 'Left Calf', key: 'calvesLeft', value: latest.measurements?.calves?.left },
|
||||
{ label: 'Right Calf', key: 'calvesRight', value: latest.measurements?.calves?.right }
|
||||
]);
|
||||
|
||||
function resetForm() {
|
||||
formDate = new Date().toISOString().slice(0, 10);
|
||||
formWeight = '';
|
||||
formBodyFat = '';
|
||||
formCalories = '';
|
||||
formNeck = '';
|
||||
formShoulders = '';
|
||||
formChest = '';
|
||||
formBicepsL = '';
|
||||
formBicepsR = '';
|
||||
formForearmsL = '';
|
||||
formForearmsR = '';
|
||||
formWaist = '';
|
||||
formHips = '';
|
||||
formThighsL = '';
|
||||
formThighsR = '';
|
||||
formCalvesL = '';
|
||||
formCalvesR = '';
|
||||
}
|
||||
|
||||
async function saveMeasurement() {
|
||||
saving = true;
|
||||
|
||||
/** @type {any} */
|
||||
const body = { date: formDate };
|
||||
if (formWeight) body.weight = Number(formWeight);
|
||||
if (formBodyFat) body.bodyFatPercent = Number(formBodyFat);
|
||||
if (formCalories) body.caloricIntake = Number(formCalories);
|
||||
|
||||
/** @type {any} */
|
||||
const m = {};
|
||||
if (formNeck) m.neck = Number(formNeck);
|
||||
if (formShoulders) m.shoulders = Number(formShoulders);
|
||||
if (formChest) m.chest = Number(formChest);
|
||||
if (formBicepsL || formBicepsR) m.biceps = {};
|
||||
if (formBicepsL) m.biceps.left = Number(formBicepsL);
|
||||
if (formBicepsR) m.biceps.right = Number(formBicepsR);
|
||||
if (formForearmsL || formForearmsR) m.forearms = {};
|
||||
if (formForearmsL) m.forearms.left = Number(formForearmsL);
|
||||
if (formForearmsR) m.forearms.right = Number(formForearmsR);
|
||||
if (formWaist) m.waist = Number(formWaist);
|
||||
if (formHips) m.hips = Number(formHips);
|
||||
if (formThighsL || formThighsR) m.thighs = {};
|
||||
if (formThighsL) m.thighs.left = Number(formThighsL);
|
||||
if (formThighsR) m.thighs.right = Number(formThighsR);
|
||||
if (formCalvesL || formCalvesR) m.calves = {};
|
||||
if (formCalvesL) m.calves.left = Number(formCalvesL);
|
||||
if (formCalvesR) m.calves.right = Number(formCalvesR);
|
||||
|
||||
if (Object.keys(m).length > 0) body.measurements = m;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/fitness/measurements', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
const newEntry = await res.json();
|
||||
measurements = [newEntry, ...measurements];
|
||||
// Refresh latest
|
||||
const latestRes = await fetch('/api/fitness/measurements/latest');
|
||||
latest = await latestRes.json();
|
||||
showForm = false;
|
||||
resetForm();
|
||||
}
|
||||
} catch {}
|
||||
saving = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="measure-page">
|
||||
<h1>Measure</h1>
|
||||
|
||||
{#if showForm}
|
||||
<form class="measure-form" onsubmit={(e) => { e.preventDefault(); saveMeasurement(); }}>
|
||||
<div class="form-group">
|
||||
<label for="m-date">Date</label>
|
||||
<input id="m-date" type="date" bind:value={formDate} />
|
||||
</div>
|
||||
|
||||
<h3>General</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="m-weight">Weight (kg)</label>
|
||||
<input id="m-weight" type="number" step="0.1" bind:value={formWeight} placeholder="—" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="m-bf">Body Fat %</label>
|
||||
<input id="m-bf" type="number" step="0.1" bind:value={formBodyFat} placeholder="—" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="m-cal">Calories (kcal)</label>
|
||||
<input id="m-cal" type="number" bind:value={formCalories} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Body Parts (cm)</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-neck">Neck</label><input id="m-neck" type="number" step="0.1" bind:value={formNeck} placeholder="—" /></div>
|
||||
<div class="form-group"><label for="m-shoulders">Shoulders</label><input id="m-shoulders" type="number" step="0.1" bind:value={formShoulders} placeholder="—" /></div>
|
||||
<div class="form-group"><label for="m-chest">Chest</label><input id="m-chest" type="number" step="0.1" bind:value={formChest} placeholder="—" /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-bl">L Bicep</label><input id="m-bl" type="number" step="0.1" bind:value={formBicepsL} placeholder="—" /></div>
|
||||
<div class="form-group"><label for="m-br">R Bicep</label><input id="m-br" type="number" step="0.1" bind:value={formBicepsR} placeholder="—" /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-fl">L Forearm</label><input id="m-fl" type="number" step="0.1" bind:value={formForearmsL} placeholder="—" /></div>
|
||||
<div class="form-group"><label for="m-fr">R Forearm</label><input id="m-fr" type="number" step="0.1" bind:value={formForearmsR} placeholder="—" /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-waist">Waist</label><input id="m-waist" type="number" step="0.1" bind:value={formWaist} placeholder="—" /></div>
|
||||
<div class="form-group"><label for="m-hips">Hips</label><input id="m-hips" type="number" step="0.1" bind:value={formHips} placeholder="—" /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-tl">L Thigh</label><input id="m-tl" type="number" step="0.1" bind:value={formThighsL} placeholder="—" /></div>
|
||||
<div class="form-group"><label for="m-tr">R Thigh</label><input id="m-tr" type="number" step="0.1" bind:value={formThighsR} placeholder="—" /></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label for="m-cl">L Calf</label><input id="m-cl" type="number" step="0.1" bind:value={formCalvesL} placeholder="—" /></div>
|
||||
<div class="form-group"><label for="m-cr">R Calf</label><input id="m-cr" type="number" step="0.1" bind:value={formCalvesR} placeholder="—" /></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="save-btn" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Measurement'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<section class="latest-section">
|
||||
<h2>Latest</h2>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Weight</span>
|
||||
<span class="stat-value">{latest.weight?.value ?? '—'} <small>kg</small></span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Body Fat</span>
|
||||
<span class="stat-value">{latest.bodyFatPercent?.value ?? '—'}<small>%</small></span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Calories</span>
|
||||
<span class="stat-value">{latest.caloricIntake?.value ?? '—'} <small>kcal</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if bodyPartFields.some(f => f.value != null)}
|
||||
<section class="body-parts-section">
|
||||
<h2>Body Parts</h2>
|
||||
<div class="body-grid">
|
||||
{#each bodyPartFields.filter(f => f.value != null) as field}
|
||||
<div class="body-row">
|
||||
<span class="body-label">{field.label}</span>
|
||||
<span class="body-value">{field.value} cm</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !workout.active}
|
||||
<AddActionButton onclick={() => showForm = !showForm} ariaLabel="Add measurement" />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.measure-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.measure-form {
|
||||
background: var(--accent-dark);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
padding: 1rem;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.form-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.form-group label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--nord4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.form-group input {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 6px;
|
||||
background: var(--nord0, white);
|
||||
color: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--nord8);
|
||||
}
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.7rem;
|
||||
background: var(--nord8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.save-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Latest */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--accent-dark);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--nord4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stat-value small {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
/* Body parts */
|
||||
.body-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.body-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--nord3, rgba(0,0,0,0.05));
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.body-label {
|
||||
color: var(--nord4);
|
||||
}
|
||||
.body-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme])) .measure-form,
|
||||
:global(:root:not([data-theme])) .stat-card {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
:global(:root:not([data-theme])) .form-group input {
|
||||
background: var(--nord6, #eceff4);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .measure-form,
|
||||
:global(:root[data-theme="light"]) .stat-card {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
:global(:root[data-theme="light"]) .form-group input {
|
||||
background: var(--nord6, #eceff4);
|
||||
}
|
||||
</style>
|
||||
20
src/routes/fitness/profile/+page.server.ts
Normal file
20
src/routes/fitness/profile/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
console.time('[profile] total load');
|
||||
|
||||
console.time('[profile] auth');
|
||||
const session = await locals.auth();
|
||||
console.timeEnd('[profile] auth');
|
||||
|
||||
console.time('[profile] fetch /api/fitness/stats/profile');
|
||||
const res = await fetch('/api/fitness/stats/profile');
|
||||
console.timeEnd('[profile] fetch /api/fitness/stats/profile');
|
||||
|
||||
console.time('[profile] parse json');
|
||||
const stats = await res.json();
|
||||
console.timeEnd('[profile] parse json');
|
||||
|
||||
console.timeEnd('[profile] total load');
|
||||
return { session, stats };
|
||||
};
|
||||
162
src/routes/fitness/profile/+page.svelte
Normal file
162
src/routes/fitness/profile/+page.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script>
|
||||
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const user = $derived(data.session?.user);
|
||||
const stats = $derived(data.stats ?? {});
|
||||
|
||||
const workoutsChartData = $derived({
|
||||
labels: stats.workoutsChart?.labels ?? [],
|
||||
datasets: [{
|
||||
label: 'Workouts',
|
||||
data: stats.workoutsChart?.data ?? [],
|
||||
backgroundColor: '#88C0D0'
|
||||
}]
|
||||
});
|
||||
|
||||
const hasSma = $derived(stats.weightChart?.sma?.some((/** @type {any} */ v) => v !== null));
|
||||
|
||||
const weightChartData = $derived({
|
||||
labels: stats.weightChart?.labels ?? [],
|
||||
datasets: [
|
||||
...(hasSma ? [
|
||||
{
|
||||
label: '± 1σ',
|
||||
data: stats.weightChart.upper,
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'rgba(94, 129, 172, 0.15)',
|
||||
fill: '+1',
|
||||
pointRadius: 0,
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
label: '± 1σ (lower)',
|
||||
data: stats.weightChart.lower,
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
label: 'Trend',
|
||||
data: stats.weightChart.sma,
|
||||
borderColor: '#5E81AC',
|
||||
pointRadius: 0,
|
||||
borderWidth: 3,
|
||||
tension: 0.3,
|
||||
order: 1
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
label: 'Weight (kg)',
|
||||
data: stats.weightChart?.data ?? [],
|
||||
borderColor: '#A3BE8C',
|
||||
borderWidth: hasSma ? 1 : 2,
|
||||
pointRadius: 3,
|
||||
order: 0
|
||||
}
|
||||
]
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="profile-page">
|
||||
<h1>Profile</h1>
|
||||
|
||||
<div class="user-section">
|
||||
{#if user}
|
||||
<img
|
||||
class="avatar"
|
||||
src="https://bocken.org/static/user/thumb/{user.nickname}.webp"
|
||||
alt={user.name}
|
||||
/>
|
||||
<div class="user-info">
|
||||
<h2>{user.name}</h2>
|
||||
<p class="subtitle">{stats.totalWorkouts ?? 0} workouts</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h2 class="section-heading">Dashboard</h2>
|
||||
|
||||
{#if (stats.workoutsChart?.data?.length ?? 0) > 0}
|
||||
<FitnessChart
|
||||
type="bar"
|
||||
data={workoutsChartData}
|
||||
title="Workouts per week"
|
||||
height="220px"
|
||||
/>
|
||||
{:else}
|
||||
<p class="empty-chart">No workout data to display yet.</p>
|
||||
{/if}
|
||||
|
||||
{#if (stats.weightChart?.data?.length ?? 0) > 1}
|
||||
<FitnessChart
|
||||
data={weightChartData}
|
||||
title="Weight"
|
||||
yUnit=" kg"
|
||||
height="220px"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.profile-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.user-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--accent-dark);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.user-info h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
.section-heading {
|
||||
font-size: 1.1rem;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
.empty-chart {
|
||||
text-align: center;
|
||||
color: var(--nord4);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme])) .user-section {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .user-section {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
8
src/routes/fitness/workout/+page.server.ts
Normal file
8
src/routes/fitness/workout/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const res = await fetch('/api/fitness/templates');
|
||||
return {
|
||||
templates: await res.json()
|
||||
};
|
||||
};
|
||||
640
src/routes/fitness/workout/+page.svelte
Normal file
640
src/routes/fitness/workout/+page.svelte
Normal file
@@ -0,0 +1,640 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { Plus, Trash2, Play, Pencil, X, Save } from 'lucide-svelte';
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import { getExerciseById } from '$lib/data/exercises';
|
||||
import TemplateCard from '$lib/components/fitness/TemplateCard.svelte';
|
||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||
import AddActionButton from '$lib/components/AddActionButton.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const workout = getWorkout();
|
||||
let templates = $state(data.templates?.templates ? [...data.templates.templates] : []);
|
||||
let seeded = $state(false);
|
||||
|
||||
// Template detail modal
|
||||
/** @type {any} */
|
||||
let selectedTemplate = $state(null);
|
||||
|
||||
// Template editor
|
||||
let showTemplateEditor = $state(false);
|
||||
/** @type {any} */
|
||||
let editingTemplate = $state(null);
|
||||
let editorName = $state('');
|
||||
/** @type {Array<{ exerciseId: string, sets: Array<{ reps: number | null, weight: number | null }>, restTime: number }>} */
|
||||
let editorExercises = $state([]);
|
||||
let editorPicker = $state(false);
|
||||
let editorSaving = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
workout.restore();
|
||||
|
||||
// If there's an active workout, redirect to the active page
|
||||
if (workout.active) {
|
||||
goto('/fitness/workout/active');
|
||||
return;
|
||||
}
|
||||
|
||||
if (templates.length === 0 && !seeded) {
|
||||
seeded = true;
|
||||
fetch('/api/fitness/templates/seed', { method: 'POST' }).then(async (res) => {
|
||||
if (res.ok) {
|
||||
const refreshRes = await fetch('/api/fitness/templates');
|
||||
const refreshData = await refreshRes.json();
|
||||
templates = refreshData.templates ?? [];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** @param {any} template */
|
||||
function openTemplateDetail(template) {
|
||||
selectedTemplate = template;
|
||||
}
|
||||
|
||||
function closeTemplateDetail() {
|
||||
selectedTemplate = null;
|
||||
}
|
||||
|
||||
/** @param {any} template */
|
||||
async function startFromTemplate(template) {
|
||||
selectedTemplate = null;
|
||||
workout.startFromTemplate(template);
|
||||
goto('/fitness/workout/active');
|
||||
}
|
||||
|
||||
function startEmpty() {
|
||||
workout.startEmpty();
|
||||
goto('/fitness/workout/active');
|
||||
}
|
||||
|
||||
function openCreateTemplate() {
|
||||
editingTemplate = null;
|
||||
editorName = '';
|
||||
editorExercises = [];
|
||||
showTemplateEditor = true;
|
||||
}
|
||||
|
||||
/** @param {any} template */
|
||||
function openEditTemplate(template) {
|
||||
selectedTemplate = null;
|
||||
editingTemplate = template;
|
||||
editorName = template.name;
|
||||
editorExercises = template.exercises.map((/** @type {any} */ ex) => ({
|
||||
exerciseId: ex.exerciseId,
|
||||
sets: ex.sets.map((/** @type {any} */ s) => ({ reps: s.reps ?? null, weight: s.weight ?? null })),
|
||||
restTime: ex.restTime ?? 120
|
||||
}));
|
||||
showTemplateEditor = true;
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
showTemplateEditor = false;
|
||||
editingTemplate = null;
|
||||
}
|
||||
|
||||
/** @param {string} exerciseId */
|
||||
function editorAddExercise(exerciseId) {
|
||||
editorExercises = [...editorExercises, {
|
||||
exerciseId,
|
||||
sets: [{ reps: null, weight: null }],
|
||||
restTime: 120
|
||||
}];
|
||||
}
|
||||
|
||||
/** @param {number} idx */
|
||||
function editorRemoveExercise(idx) {
|
||||
editorExercises = editorExercises.filter((_, i) => i !== idx);
|
||||
}
|
||||
|
||||
/** @param {number} exIdx */
|
||||
function editorAddSet(exIdx) {
|
||||
editorExercises[exIdx].sets = [...editorExercises[exIdx].sets, { reps: null, weight: null }];
|
||||
}
|
||||
|
||||
/** @param {number} exIdx @param {number} setIdx */
|
||||
function editorRemoveSet(exIdx, setIdx) {
|
||||
if (editorExercises[exIdx].sets.length > 1) {
|
||||
editorExercises[exIdx].sets = editorExercises[exIdx].sets.filter((_, i) => i !== setIdx);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
if (!editorName.trim() || editorExercises.length === 0) return;
|
||||
editorSaving = true;
|
||||
|
||||
const body = {
|
||||
name: editorName.trim(),
|
||||
exercises: editorExercises.map((ex) => ({
|
||||
exerciseId: ex.exerciseId,
|
||||
sets: ex.sets.map((s) => ({
|
||||
reps: s.reps ?? 1,
|
||||
weight: s.weight ?? undefined
|
||||
})),
|
||||
restTime: ex.restTime
|
||||
}))
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingTemplate) {
|
||||
const res = await fetch(`/api/fitness/templates/${editingTemplate._id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
const { template } = await res.json();
|
||||
templates = templates.map((t) => t._id === template._id ? { ...template, lastUsed: t.lastUsed } : t);
|
||||
}
|
||||
} else {
|
||||
const res = await fetch('/api/fitness/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
const { template } = await res.json();
|
||||
templates = [...templates, template];
|
||||
}
|
||||
}
|
||||
closeEditor();
|
||||
} catch {}
|
||||
editorSaving = false;
|
||||
}
|
||||
|
||||
/** @param {any} template */
|
||||
async function deleteTemplate(template) {
|
||||
selectedTemplate = null;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/templates/${template._id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
templates = templates.filter((t) => t._id !== template._id);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="template-view">
|
||||
<section class="quick-start">
|
||||
<button class="start-empty-btn" onclick={startEmpty}>
|
||||
START AN EMPTY WORKOUT
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="templates-section">
|
||||
<h2>Templates</h2>
|
||||
{#if templates.length > 0}
|
||||
<p class="template-count">My Templates ({templates.length})</p>
|
||||
<div class="template-grid">
|
||||
{#each templates as template (template._id)}
|
||||
<TemplateCard
|
||||
{template}
|
||||
lastUsed={template.lastUsed}
|
||||
onStart={() => openTemplateDetail(template)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="no-templates">No templates yet. Create one or start an empty workout.</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Template Detail Modal -->
|
||||
{#if selectedTemplate}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="modal-overlay" onkeydown={(e) => e.key === 'Escape' && closeTemplateDetail()}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="modal-backdrop" onclick={closeTemplateDetail}></div>
|
||||
<div class="modal-panel">
|
||||
<div class="modal-header">
|
||||
<h2>{selectedTemplate.name}</h2>
|
||||
<button class="close-btn" onclick={closeTemplateDetail} aria-label="Close"><X size={20} /></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="template-exercises">
|
||||
{#each selectedTemplate.exercises as ex (ex.exerciseId)}
|
||||
{@const exercise = getExerciseById(ex.exerciseId)}
|
||||
<li>
|
||||
<span class="tex-name">{exercise?.name ?? ex.exerciseId}</span>
|
||||
<span class="tex-sets">{ex.sets.length} set{ex.sets.length !== 1 ? 's' : ''}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if selectedTemplate.lastUsed}
|
||||
<p class="modal-meta">Last performed: {new Date(selectedTemplate.lastUsed).toLocaleDateString()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-start" onclick={() => startFromTemplate(selectedTemplate)}>
|
||||
<Play size={16} /> Start Workout
|
||||
</button>
|
||||
<button class="modal-edit" onclick={() => openEditTemplate(selectedTemplate)}>
|
||||
<Pencil size={16} /> Edit Template
|
||||
</button>
|
||||
<button class="modal-delete" onclick={() => deleteTemplate(selectedTemplate)}>
|
||||
<Trash2 size={16} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Template Editor Modal -->
|
||||
{#if showTemplateEditor}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="modal-overlay" onkeydown={(e) => e.key === 'Escape' && closeEditor()}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="modal-backdrop" onclick={closeEditor}></div>
|
||||
<div class="modal-panel editor-panel">
|
||||
<div class="modal-header">
|
||||
<h2>{editingTemplate ? 'Edit Template' : 'New Template'}</h2>
|
||||
<button class="close-btn" onclick={closeEditor} aria-label="Close"><X size={20} /></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input
|
||||
class="editor-name"
|
||||
type="text"
|
||||
placeholder="Template name"
|
||||
bind:value={editorName}
|
||||
/>
|
||||
|
||||
{#each editorExercises as ex, exIdx (exIdx)}
|
||||
{@const exercise = getExerciseById(ex.exerciseId)}
|
||||
<div class="editor-exercise">
|
||||
<div class="editor-ex-header">
|
||||
<span class="editor-ex-name">{exercise?.name ?? ex.exerciseId}</span>
|
||||
<button class="remove-exercise" onclick={() => editorRemoveExercise(exIdx)} aria-label="Remove">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-sets">
|
||||
{#each ex.sets as set, setIdx (setIdx)}
|
||||
<div class="editor-set-row">
|
||||
<span class="set-num">{setIdx + 1}</span>
|
||||
<input type="number" inputmode="numeric" placeholder="reps" bind:value={set.reps} />
|
||||
<span class="set-x">×</span>
|
||||
<input type="number" inputmode="decimal" placeholder="kg" bind:value={set.weight} />
|
||||
{#if ex.sets.length > 1}
|
||||
<button class="set-remove" onclick={() => editorRemoveSet(exIdx, setIdx)} aria-label="Remove set"><X size={14} /></button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button class="editor-add-set" onclick={() => editorAddSet(exIdx)}>+ Add set</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button class="editor-add-exercise" onclick={() => editorPicker = true}>
|
||||
<Plus size={16} /> Add Exercise
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-start" onclick={saveTemplate} disabled={editorSaving || !editorName.trim() || editorExercises.length === 0}>
|
||||
<Save size={16} /> {editorSaving ? 'Saving…' : 'Save Template'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if editorPicker}
|
||||
<ExercisePicker
|
||||
onSelect={(id) => { editorAddExercise(id); editorPicker = false; }}
|
||||
onClose={() => editorPicker = false}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !workout.active}
|
||||
<AddActionButton onclick={openCreateTemplate} ariaLabel="Create template" />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Template View */
|
||||
.template-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.quick-start {
|
||||
text-align: center;
|
||||
}
|
||||
.start-empty-btn {
|
||||
width: 100%;
|
||||
padding: 0.9rem;
|
||||
background: var(--nord8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.start-empty-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.templates-section h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.template-count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--nord4);
|
||||
margin: 0.25rem 0 0.75rem;
|
||||
}
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.no-templates {
|
||||
text-align: center;
|
||||
color: var(--nord4);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
/* Modals */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.modal-panel {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
background: var(--accent-dark);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.editor-panel {
|
||||
max-width: 500px;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--nord3);
|
||||
}
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--nord4);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.template-exercises {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.template-exercises li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--nord3, rgba(0,0,0,0.05));
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tex-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.tex-sets {
|
||||
color: var(--nord4);
|
||||
}
|
||||
.modal-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--nord4);
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--nord3);
|
||||
}
|
||||
.modal-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.65rem;
|
||||
background: var(--nord8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-start:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.modal-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.65rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-edit:hover {
|
||||
border-color: var(--nord8);
|
||||
}
|
||||
.modal-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.65rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--nord11);
|
||||
border-radius: 8px;
|
||||
color: var(--nord11);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-delete:hover {
|
||||
background: rgba(191, 97, 106, 0.1);
|
||||
}
|
||||
|
||||
/* Template Editor */
|
||||
.editor-name {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--nord1);
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.editor-name:focus {
|
||||
outline: none;
|
||||
border-color: var(--nord8);
|
||||
}
|
||||
.editor-exercise {
|
||||
background: var(--nord1, #f8f8f8);
|
||||
border: 1px solid var(--nord3, #ddd);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.editor-ex-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.editor-ex-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.remove-exercise {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--nord11);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.remove-exercise:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.editor-sets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.editor-set-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.set-num {
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--nord4);
|
||||
font-weight: 700;
|
||||
}
|
||||
.editor-set-row input {
|
||||
width: 4rem;
|
||||
text-align: center;
|
||||
padding: 0.3rem;
|
||||
background: var(--nord0);
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 6px;
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.editor-set-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--nord8);
|
||||
}
|
||||
.set-x {
|
||||
color: var(--nord4);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.set-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--nord11);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.set-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.editor-add-set {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--nord8);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.editor-add-exercise {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--nord3);
|
||||
border-radius: 8px;
|
||||
color: var(--nord4);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.editor-add-exercise:hover {
|
||||
border-color: var(--nord8);
|
||||
color: var(--nord8);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme])) .modal-panel {
|
||||
background: var(--nord5);
|
||||
}
|
||||
:global(:root:not([data-theme])) .editor-exercise {
|
||||
background: var(--nord6);
|
||||
}
|
||||
:global(:root:not([data-theme])) .editor-name {
|
||||
background: var(--nord6);
|
||||
}
|
||||
:global(:root:not([data-theme])) .editor-set-row input {
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .modal-panel {
|
||||
background: var(--nord5);
|
||||
}
|
||||
:global(:root[data-theme="light"]) .editor-exercise {
|
||||
background: var(--nord6);
|
||||
}
|
||||
:global(:root[data-theme="light"]) .editor-name {
|
||||
background: var(--nord6);
|
||||
}
|
||||
:global(:root[data-theme="light"]) .editor-set-row input {
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
352
src/routes/fitness/workout/active/+page.svelte
Normal file
352
src/routes/fitness/workout/active/+page.svelte
Normal file
@@ -0,0 +1,352 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { Plus, Trash2, Play, Pause } from 'lucide-svelte';
|
||||
import { getWorkout } from '$lib/js/workout.svelte';
|
||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
||||
import RestTimer from '$lib/components/fitness/RestTimer.svelte';
|
||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const workout = getWorkout();
|
||||
let showPicker = $state(false);
|
||||
|
||||
/** @type {Record<string, Array<{ reps: number, weight: number }>>} */
|
||||
let previousData = $state({});
|
||||
|
||||
onMount(() => {
|
||||
if (!workout.active) {
|
||||
goto('/fitness/workout');
|
||||
}
|
||||
});
|
||||
|
||||
/** @param {string[]} exerciseIds */
|
||||
async function fetchPreviousData(exerciseIds) {
|
||||
const promises = exerciseIds.map(async (id) => {
|
||||
if (previousData[id]) return;
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/exercises/${id}/history?limit=1`);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
if (d.history?.length > 0 && d.history[0].sets) {
|
||||
previousData[id] = d.history[0].sets;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/** @param {string} exerciseId */
|
||||
function addExerciseFromPicker(exerciseId) {
|
||||
workout.addExercise(exerciseId);
|
||||
fetchPreviousData([exerciseId]);
|
||||
}
|
||||
|
||||
async function finishWorkout() {
|
||||
const sessionData = workout.finish();
|
||||
console.log('[finish] sessionData:', JSON.stringify(sessionData, null, 2));
|
||||
if (sessionData.exercises.length === 0) {
|
||||
console.warn('[finish] No completed exercises, aborting save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/fitness/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sessionData)
|
||||
});
|
||||
console.log('[finish] POST response:', res.status, res.statusText);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
console.error('[finish] POST error body:', body);
|
||||
}
|
||||
if (res.ok) {
|
||||
goto('/fitness/history');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[finish] fetch error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {number} secs */
|
||||
function formatElapsed(secs) {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Fetch previous data for existing exercises on mount
|
||||
onMount(() => {
|
||||
if (workout.active && workout.exercises.length > 0) {
|
||||
fetchPreviousData(workout.exercises.map((/** @type {any} */ e) => e.exerciseId));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if workout.active}
|
||||
<div class="active-workout">
|
||||
<div class="workout-topbar">
|
||||
<div class="topbar-left">
|
||||
<button class="pause-btn" onclick={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()} aria-label={workout.paused ? 'Resume' : 'Pause'}>
|
||||
{#if workout.paused}<Play size={16} />{:else}<Pause size={16} />{/if}
|
||||
</button>
|
||||
<span class="elapsed" class:paused={workout.paused}>{formatElapsed(workout.elapsedSeconds)}</span>
|
||||
</div>
|
||||
<button class="finish-btn" onclick={finishWorkout}>FINISH</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="workout-name-input"
|
||||
type="text"
|
||||
bind:value={workout.name}
|
||||
placeholder="Workout name"
|
||||
/>
|
||||
|
||||
{#if workout.restTimerActive}
|
||||
<div class="rest-timer-section">
|
||||
<RestTimer
|
||||
seconds={workout.restTimerSeconds}
|
||||
total={workout.restTimerTotal}
|
||||
onComplete={() => workout.cancelRestTimer()}
|
||||
/>
|
||||
<button class="skip-rest" onclick={() => workout.cancelRestTimer()}>Skip</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each workout.exercises as ex, exIdx (exIdx)}
|
||||
<div class="exercise-block">
|
||||
<div class="exercise-header">
|
||||
<ExerciseName exerciseId={ex.exerciseId} />
|
||||
<button
|
||||
class="remove-exercise"
|
||||
onclick={() => workout.removeExercise(exIdx)}
|
||||
aria-label="Remove exercise"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SetTable
|
||||
sets={ex.sets}
|
||||
previousSets={previousData[ex.exerciseId] ?? null}
|
||||
editable={true}
|
||||
onUpdate={(setIdx, d) => workout.updateSet(exIdx, setIdx, d)}
|
||||
onToggleComplete={(setIdx) => {
|
||||
workout.toggleSetComplete(exIdx, setIdx);
|
||||
if (ex.sets[setIdx]?.completed && !workout.restTimerActive) {
|
||||
workout.startRestTimer(ex.restTime);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<button class="add-set-btn" onclick={() => workout.addSet(exIdx)}>
|
||||
+ ADD SET
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="workout-actions">
|
||||
<button class="add-exercise-btn" onclick={() => showPicker = true}>
|
||||
<Plus size={18} /> ADD EXERCISE
|
||||
</button>
|
||||
<button class="cancel-btn" onclick={() => { workout.cancel(); goto('/fitness/workout'); }}>
|
||||
CANCEL WORKOUT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showPicker}
|
||||
<ExercisePicker
|
||||
onSelect={addExerciseFromPicker}
|
||||
onClose={() => showPicker = false}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.active-workout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.workout-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 3.5rem;
|
||||
background: var(--nord0, #2e3440);
|
||||
z-index: 10;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--nord3);
|
||||
}
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.pause-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 6px;
|
||||
color: var(--nord4);
|
||||
cursor: pointer;
|
||||
padding: 0.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.pause-btn:hover {
|
||||
border-color: var(--nord8);
|
||||
color: var(--nord8);
|
||||
}
|
||||
.elapsed {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
.elapsed.paused {
|
||||
color: var(--nord13);
|
||||
}
|
||||
.finish-btn {
|
||||
background: var(--nord8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.workout-name-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--nord3);
|
||||
color: inherit;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
padding: 0.5rem 0;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.workout-name-input:focus {
|
||||
border-bottom-color: var(--nord8);
|
||||
}
|
||||
|
||||
.rest-timer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.skip-rest {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--nord8);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.exercise-block {
|
||||
background: var(--accent-dark);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
padding: 1rem;
|
||||
}
|
||||
.exercise-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.remove-exercise {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--nord11);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.remove-exercise:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.add-set-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.4rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--nord3);
|
||||
border-radius: 8px;
|
||||
color: var(--nord4);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.add-set-btn:hover {
|
||||
border-color: var(--nord8);
|
||||
color: var(--nord8);
|
||||
}
|
||||
|
||||
.workout-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.add-exercise-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--nord8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.cancel-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--nord11);
|
||||
border-radius: 10px;
|
||||
color: var(--nord11);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.cancel-btn:hover {
|
||||
background: rgba(191, 97, 106, 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme])) .workout-topbar {
|
||||
background: white;
|
||||
}
|
||||
:global(:root:not([data-theme])) .exercise-block {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .workout-topbar {
|
||||
background: white;
|
||||
}
|
||||
:global(:root[data-theme="light"]) .exercise-block {
|
||||
background: var(--nord5);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user