fitness: add complete fitness tracker frontend

- 5-tab layout (Profile, History, Workout, Exercises, Measure) with shared header nav
- Workout system: template CRUD, active workout on /fitness/workout/active with localStorage persistence, pause/resume timer, rest timer, RPE input
- Shared workout singleton (getWorkout) so active workout state is accessible across all fitness routes
- Floating workout FAB indicator on all /fitness routes when workout is active
- AddActionButton component for button-based FABs (measure + template creation)
- Profile page with workouts-per-week bar chart and weight line chart with SMA trend line + ±1σ confidence band
- Exercise detail with history, charts, and records tabs using static exercise data
- Session history with grouped-by-month list, session detail with stats/PRs
- Body measurements with latest values, body part display, add form
- Card styling matching rosary/prayer route patterns (accent-dark, nord5 light, box-shadow, hover lift)
- FitnessChart: fix SSR hang by moving Chart.register to client-side, remove redundant $effect
- Exercise API: use static in-repo data instead of empty MongoDB collection
- Workout finish: include exercise name for WorkoutSession model validation
This commit is contained in:
2026-03-19 08:17:51 +01:00
parent e427dc2d25
commit 1c62819d18
38 changed files with 5899 additions and 24 deletions

View File

@@ -0,0 +1,82 @@
<script lang='ts'>
import "$lib/css/action_button.css"
let { onclick, ariaLabel = 'Add' } = $props<{ onclick: () => void, ariaLabel?: string }>();
</script>
<style>
.container{
position: fixed;
bottom:0;
right:0;
width: 1rem;
height: 1rem;
padding: 2rem;
border-radius: var(--radius-pill);
margin: 2rem;
transition: var(--transition-normal);
background-color: var(--red);
display: grid;
justify-content: center;
align-content: center;
z-index: 100;
}
@media screen and (max-width: 500px) {
.container{
margin: 1rem;
}
}
:global(.icon_svg){
width: 2rem;
height: 2rem;
fill: white;
}
:root{
--angle: 15deg;
}
.container:hover,
.container:focus-within
{
background-color: var(--nord0);
box-shadow: 0em 0em 0.5em 0.5em rgba(0,0,0,0.2);
animation: shake 0.5s;
animation-fill-mode: forwards;
}
:global(.container:hover .icon_svg),
:global(.container:focus-within .icon_svg){
fill: white;
}
@keyframes shake{
0%{
transform: rotate(0)
scale(1,1);
}
25%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(var(--angle))
scale(1.2,1.2)
;
}
50%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(calc(-1* var(--angle)))
scale(1.2,1.2);
}
74%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(var(--angle))
scale(1.2, 1.2);
}
100%{
transform: rotate(0)
scale(1.2,1.2);
}
}
</style>
<button class="container action_button" {onclick} aria-label={ariaLabel}>
<svg class=icon_svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 448 512"><path d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32V224H48c-17.7 0-32 14.3-32 32s14.3 32 32 32H192V432c0 17.7 14.3 32 32 32s32-14.3 32-32V288H400c17.7 0 32-14.3 32-32s-14.3-32-32-32H256V80z"/></svg>
</button>

View 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>

View 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>

View 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>

View 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>

View 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)} &middot; {formatTime(session.startTime)}</span>
</div>
<div class="exercise-list">
{#each session.exercises.slice(0, 4) as ex (ex.exerciseId)}
{@const exercise = getExerciseById(ex.exerciseId)}
{@const best = bestSet(ex.sets)}
<div class="exercise-row">
<span class="ex-sets">{ex.sets.length} &times; {exercise?.name ?? ex.exerciseId}</span>
{#if best}
<span class="ex-best">{best.weight} kg &times; {best.reps}{#if best.rpe} @ {best.rpe}{/if}</span>
{/if}
</div>
{/each}
{#if session.exercises.length > 4}
<div class="exercise-row more">+{session.exercises.length - 4} more exercises</div>
{/if}
</div>
<div class="card-footer">
{#if session.duration}
<span class="stat"><Clock size={14} /> {formatDuration(session.duration)}</span>
{/if}
{#if session.totalVolume}
<span class="stat"><Weight size={14} /> {Math.round(session.totalVolume).toLocaleString()} kg</span>
{/if}
{#if session.prs && session.prs.length > 0}
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
{/if}
</div>
</a>
<style>
.session-card {
display: block;
text-decoration: none;
color: inherit;
background: var(--accent-dark);
border: none;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
padding: 1rem;
transition: transform 150ms ease, box-shadow 150ms ease;
}
.session-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.session-card:active {
transform: translateY(0);
}
.card-top {
margin-bottom: 0.6rem;
}
.session-name {
font-size: 0.95rem;
font-weight: 700;
margin: 0;
}
.session-date {
font-size: 0.75rem;
color: var(--nord4);
}
.exercise-list {
font-size: 0.8rem;
margin-bottom: 0.6rem;
}
.exercise-row {
display: flex;
justify-content: space-between;
padding: 0.15rem 0;
}
.ex-sets {
color: var(--nord4);
}
.ex-best {
font-weight: 600;
font-size: 0.78rem;
}
.more {
color: var(--nord8);
font-style: italic;
}
.card-footer {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: var(--nord4);
border-top: 1px solid var(--nord3, rgba(0,0,0,0.1));
padding-top: 0.5rem;
}
.stat {
display: flex;
align-items: center;
gap: 0.25rem;
}
.stat.pr {
color: var(--nord13);
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) .session-card {
background: var(--nord5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
}
:global(:root[data-theme="light"]) .session-card {
background: var(--nord5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
</style>

View File

@@ -0,0 +1,206 @@
<script>
import { Check } from 'lucide-svelte';
/**
* @type {{
* sets: Array<{ reps: number | null, weight: number | null, rpe?: number | null, completed?: boolean }>,
* previousSets?: Array<{ reps: number, weight: number }> | null,
* editable?: boolean,
* onUpdate?: ((setIndex: number, data: { reps?: number | null, weight?: number | null, rpe?: number | null }) => void) | null,
* onToggleComplete?: ((setIndex: number) => void) | null,
* onRemove?: ((setIndex: number) => void) | null
* }}
*/
let {
sets,
previousSets = null,
editable = false,
onUpdate = null,
onToggleComplete = null,
onRemove = null
} = $props();
/**
* @param {number} index
* @param {string} field
* @param {Event} e
*/
function handleInput(index, field, e) {
const target = /** @type {HTMLInputElement} */ (e.target);
const val = target.value === '' ? null : Number(target.value);
onUpdate?.(index, { [field]: val });
}
</script>
<table class="set-table">
<thead>
<tr>
<th class="col-set">SET</th>
{#if previousSets}
<th class="col-prev">PREVIOUS</th>
{/if}
<th class="col-weight">KG</th>
<th class="col-reps">REPS</th>
{#if editable}
<th class="col-rpe">RPE</th>
<th class="col-check"></th>
{/if}
</tr>
</thead>
<tbody>
{#each sets as set, i (i)}
<tr class:completed={set.completed}>
<td class="col-set">{i + 1}</td>
{#if previousSets}
<td class="col-prev">
{#if previousSets[i]}
{previousSets[i].weight} × {previousSets[i].reps}
{:else}
{/if}
</td>
{/if}
<td class="col-weight">
{#if editable}
<input
type="number"
inputmode="decimal"
value={set.weight ?? ''}
placeholder="0"
oninput={(e) => handleInput(i, 'weight', e)}
/>
{:else}
{set.weight ?? '—'}
{/if}
</td>
<td class="col-reps">
{#if editable}
<input
type="number"
inputmode="numeric"
value={set.reps ?? ''}
placeholder="0"
oninput={(e) => handleInput(i, 'reps', e)}
/>
{:else}
{set.reps ?? '—'}
{/if}
</td>
{#if editable}
<td class="col-rpe">
<input
type="number"
inputmode="numeric"
min="1"
max="10"
value={set.rpe ?? ''}
placeholder="—"
oninput={(e) => handleInput(i, 'rpe', e)}
/>
</td>
<td class="col-check">
<button
class="check-btn"
class:checked={set.completed}
onclick={() => onToggleComplete?.(i)}
aria-label="Mark set complete"
>
<Check size={16} />
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
<style>
.set-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
thead th {
text-transform: uppercase;
font-size: 0.7rem;
font-weight: 600;
color: var(--nord4);
padding: 0.4rem 0.5rem;
text-align: center;
letter-spacing: 0.05em;
}
tbody td {
padding: 0.35rem 0.5rem;
text-align: center;
border-top: 1px solid var(--nord3, rgba(0,0,0,0.1));
}
.col-set {
width: 2.5rem;
font-weight: 700;
color: var(--nord4);
}
.col-prev {
color: var(--nord4);
font-size: 0.8rem;
}
.col-weight, .col-reps {
width: 4rem;
}
.col-rpe {
width: 3rem;
}
.col-check {
width: 2.5rem;
}
tr.completed {
background: rgba(163, 190, 140, 0.1);
}
input {
width: 100%;
max-width: 4rem;
text-align: center;
background: var(--nord1, #f0f0f0);
border: 1px solid var(--nord3, #ddd);
border-radius: 6px;
padding: 0.3rem 0.25rem;
font-size: 0.875rem;
color: inherit;
}
.col-rpe input {
max-width: 3rem;
}
input:focus {
outline: none;
border-color: var(--nord8);
}
.check-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 2px solid var(--nord3);
background: transparent;
color: var(--nord4);
cursor: pointer;
transition: all 150ms;
margin: 0 auto;
}
.check-btn.checked {
background: var(--nord14);
border-color: var(--nord14);
color: white;
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) input {
background: var(--nord6, #eceff4);
border-color: var(--nord4);
}
}
:global(:root[data-theme="light"]) input {
background: var(--nord6, #eceff4);
border-color: var(--nord4);
}
</style>

View 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} &times; {exercise?.name ?? ex.exerciseId}</li>
{/each}
{#if template.exercises.length > 4}
<li class="more">+{template.exercises.length - 4} more</li>
{/if}
</ul>
{#if lastUsed}
<p class="last-used">Last performed: {formatDate(lastUsed)}</p>
{/if}
</button>
<style>
.template-card {
display: flex;
flex-direction: column;
text-align: left;
background: var(--accent-dark);
border: none;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
padding: 1rem;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
width: 100%;
font: inherit;
color: inherit;
}
.template-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.template-card:active {
transform: translateY(0);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.card-title {
font-size: 0.95rem;
font-weight: 700;
margin: 0;
}
.menu-btn {
background: none;
border: none;
color: var(--nord4);
cursor: pointer;
padding: 0.15rem;
border-radius: 4px;
}
.menu-btn:hover {
color: var(--nord8);
}
.exercise-preview {
list-style: none;
padding: 0;
margin: 0;
font-size: 0.8rem;
color: var(--nord4);
}
.exercise-preview li {
padding: 0.1rem 0;
}
.exercise-preview .more {
color: var(--nord8);
font-style: italic;
}
.last-used {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: var(--nord4);
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) .template-card {
background: var(--nord5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
}
:global(:root[data-theme="light"]) .template-card {
background: var(--nord5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
</style>

View 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
View File

@@ -0,0 +1,947 @@
export interface Exercise {
id: string;
name: string;
bodyPart: string;
equipment: string;
target: string;
secondaryMuscles: string[];
instructions: string[];
imageUrl?: string;
}
export const exercises: Exercise[] = [
// === CHEST ===
{
id: 'bench-press-barbell',
name: 'Bench Press (Barbell)',
bodyPart: 'chest',
equipment: 'barbell',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Lie flat on the bench holding the barbell with a shoulder-width pronated grip.',
'Retract scapula and have elbows between 45 to 90 degree angle.',
'Lower the bar to mid-chest level.',
'Press the bar back up to the starting position, fully extending the arms.'
]
},
{
id: 'incline-bench-press-barbell',
name: 'Incline Bench Press (Barbell)',
bodyPart: 'chest',
equipment: 'barbell',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Set the bench to a 30-45 degree incline.',
'Lie back and grip the barbell slightly wider than shoulder width.',
'Lower the bar to the upper chest.',
'Press back up to full extension.'
]
},
{
id: 'decline-bench-press-barbell',
name: 'Decline Bench Press (Barbell)',
bodyPart: 'chest',
equipment: 'barbell',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Set the bench to a decline angle and secure your legs.',
'Grip the barbell slightly wider than shoulder width.',
'Lower the bar to the lower chest.',
'Press back up to full extension.'
]
},
{
id: 'bench-press-close-grip-barbell',
name: 'Bench Press - Close Grip (Barbell)',
bodyPart: 'arms',
equipment: 'barbell',
target: 'triceps',
secondaryMuscles: ['pectorals', 'anterior deltoids'],
instructions: [
'Lie flat on the bench and grip the barbell with hands shoulder-width apart or slightly narrower.',
'Lower the bar to the lower chest, keeping elbows close to the body.',
'Press the bar back up to full extension.'
]
},
{
id: 'bench-press-dumbbell',
name: 'Bench Press (Dumbbell)',
bodyPart: 'chest',
equipment: 'dumbbell',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Lie flat on the bench holding a dumbbell in each hand at chest level.',
'Press the dumbbells up until arms are fully extended.',
'Lower the dumbbells back to chest level with control.'
]
},
{
id: 'incline-bench-press-dumbbell',
name: 'Incline Bench Press (Dumbbell)',
bodyPart: 'chest',
equipment: 'dumbbell',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Set the bench to a 30-45 degree incline.',
'Hold a dumbbell in each hand at chest level.',
'Press the dumbbells up until arms are fully extended.',
'Lower with control.'
]
},
{
id: 'chest-fly-dumbbell',
name: 'Chest Fly (Dumbbell)',
bodyPart: 'chest',
equipment: 'dumbbell',
target: 'pectorals',
secondaryMuscles: ['anterior deltoids'],
instructions: [
'Lie flat on the bench holding dumbbells above your chest with arms slightly bent.',
'Lower the dumbbells out to the sides in a wide arc.',
'Bring the dumbbells back together above your chest.'
]
},
{
id: 'chest-dip',
name: 'Chest Dip',
bodyPart: 'chest',
equipment: 'body weight',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids'],
instructions: [
'Grip the parallel bars and lift yourself up.',
'Lean forward slightly and lower your body by bending the elbows.',
'Lower until you feel a stretch in the chest.',
'Push back up to the starting position.'
]
},
{
id: 'push-up',
name: 'Push Up',
bodyPart: 'chest',
equipment: 'body weight',
target: 'pectorals',
secondaryMuscles: ['triceps', 'anterior deltoids', 'core'],
instructions: [
'Start in a plank position with hands slightly wider than shoulder width.',
'Lower your body until your chest nearly touches the floor.',
'Push back up to the starting position.'
]
},
{
id: 'cable-crossover',
name: 'Cable Crossover',
bodyPart: 'chest',
equipment: 'cable',
target: 'pectorals',
secondaryMuscles: ['anterior deltoids'],
instructions: [
'Set both pulleys to the highest position and grip the handles.',
'Step forward and bring the handles together in front of your chest in a hugging motion.',
'Slowly return to the starting position.'
]
},
// === BACK ===
{
id: 'bent-over-row-barbell',
name: 'Bent Over Row (Barbell)',
bodyPart: 'back',
equipment: 'barbell',
target: 'lats',
secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'],
instructions: [
'Stand with feet shoulder-width apart, bend at the hips with a slight knee bend.',
'Grip the barbell with an overhand grip, hands slightly wider than shoulder width.',
'Pull the bar towards your lower chest/upper abdomen.',
'Lower the bar back down with control.'
]
},
{
id: 'deadlift-barbell',
name: 'Deadlift (Barbell)',
bodyPart: 'back',
equipment: 'barbell',
target: 'erector spinae',
secondaryMuscles: ['glutes', 'hamstrings', 'lats', 'traps'],
instructions: [
'Stand with feet hip-width apart, barbell over mid-foot.',
'Bend at hips and knees, grip the bar just outside your knees.',
'Keep your back flat, chest up, and drive through your heels to stand up.',
'Lower the bar back to the floor with control.'
]
},
{
id: 'pull-up',
name: 'Pull Up',
bodyPart: 'back',
equipment: 'body weight',
target: 'lats',
secondaryMuscles: ['biceps', 'rhomboids', 'rear deltoids'],
instructions: [
'Hang from a pull-up bar with an overhand grip, hands slightly wider than shoulder width.',
'Pull yourself up until your chin is above the bar.',
'Lower yourself back down with control.'
]
},
{
id: 'chin-up',
name: 'Chin Up',
bodyPart: 'back',
equipment: 'body weight',
target: 'lats',
secondaryMuscles: ['biceps', 'rhomboids'],
instructions: [
'Hang from a pull-up bar with an underhand (supinated) grip, hands shoulder-width apart.',
'Pull yourself up until your chin is above the bar.',
'Lower yourself back down with control.'
]
},
{
id: 'lat-pulldown-cable',
name: 'Lat Pulldown (Cable)',
bodyPart: 'back',
equipment: 'cable',
target: 'lats',
secondaryMuscles: ['biceps', 'rhomboids', 'rear deltoids'],
instructions: [
'Sit at the lat pulldown machine and grip the bar wider than shoulder width.',
'Pull the bar down to your upper chest.',
'Slowly return the bar to the starting position.'
]
},
{
id: 'seated-row-cable',
name: 'Seated Row (Cable)',
bodyPart: 'back',
equipment: 'cable',
target: 'lats',
secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'],
instructions: [
'Sit at the cable row machine with feet on the platform.',
'Grip the handle and pull it towards your abdomen.',
'Squeeze your shoulder blades together at the end of the movement.',
'Slowly return to the starting position.'
]
},
{
id: 'dumbbell-row',
name: 'Dumbbell Row',
bodyPart: 'back',
equipment: 'dumbbell',
target: 'lats',
secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'],
instructions: [
'Place one knee and hand on a bench, holding a dumbbell in the other hand.',
'Pull the dumbbell up to your hip, keeping the elbow close to your body.',
'Lower the dumbbell back down with control.'
]
},
{
id: 't-bar-row',
name: 'T-Bar Row',
bodyPart: 'back',
equipment: 'barbell',
target: 'lats',
secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids', 'traps'],
instructions: [
'Straddle the T-bar row machine or landmine attachment.',
'Bend at the hips and grip the handle.',
'Pull the weight towards your chest.',
'Lower with control.'
]
},
{
id: 'face-pull-cable',
name: 'Face Pull (Cable)',
bodyPart: 'back',
equipment: 'cable',
target: 'rear deltoids',
secondaryMuscles: ['rhomboids', 'traps', 'rotator cuff'],
instructions: [
'Set the cable to upper chest height with a rope attachment.',
'Pull the rope towards your face, separating the ends.',
'Squeeze your shoulder blades and externally rotate at the end.',
'Slowly return to the starting position.'
]
},
// === SHOULDERS ===
{
id: 'overhead-press-barbell',
name: 'Overhead Press (Barbell)',
bodyPart: 'shoulders',
equipment: 'barbell',
target: 'anterior deltoids',
secondaryMuscles: ['triceps', 'lateral deltoids', 'traps'],
instructions: [
'Stand with feet shoulder-width apart, barbell at shoulder height.',
'Press the bar overhead until arms are fully extended.',
'Lower the bar back to shoulder height with control.'
]
},
{
id: 'overhead-press-dumbbell',
name: 'Overhead Press (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
target: 'anterior deltoids',
secondaryMuscles: ['triceps', 'lateral deltoids', 'traps'],
instructions: [
'Sit or stand holding dumbbells at shoulder height.',
'Press the dumbbells overhead until arms are fully extended.',
'Lower the dumbbells back to shoulder height with control.'
]
},
{
id: 'lateral-raise-dumbbell',
name: 'Lateral Raise (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
target: 'lateral deltoids',
secondaryMuscles: ['traps'],
instructions: [
'Stand with dumbbells at your sides.',
'Raise the dumbbells out to the sides until arms are parallel to the floor.',
'Lower with control.'
]
},
{
id: 'lateral-raise-cable',
name: 'Lateral Raise (Cable)',
bodyPart: 'shoulders',
equipment: 'cable',
target: 'lateral deltoids',
secondaryMuscles: ['traps'],
instructions: [
'Stand sideways to a low cable pulley, gripping the handle with the far hand.',
'Raise your arm out to the side until parallel to the floor.',
'Lower with control.'
]
},
{
id: 'front-raise-dumbbell',
name: 'Front Raise (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
target: 'anterior deltoids',
secondaryMuscles: ['lateral deltoids'],
instructions: [
'Stand with dumbbells in front of your thighs.',
'Raise one or both dumbbells to the front until arms are parallel to the floor.',
'Lower with control.'
]
},
{
id: 'reverse-fly-dumbbell',
name: 'Reverse Fly (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
target: 'rear deltoids',
secondaryMuscles: ['rhomboids', 'traps'],
instructions: [
'Bend forward at the hips holding dumbbells.',
'Raise the dumbbells out to the sides, squeezing shoulder blades together.',
'Lower with control.'
]
},
{
id: 'upright-row-barbell',
name: 'Upright Row (Barbell)',
bodyPart: 'shoulders',
equipment: 'barbell',
target: 'lateral deltoids',
secondaryMuscles: ['traps', 'biceps'],
instructions: [
'Stand holding a barbell with a narrow grip in front of your thighs.',
'Pull the bar up along your body to chin height, leading with the elbows.',
'Lower with control.'
]
},
{
id: 'shrug-barbell',
name: 'Shrug (Barbell)',
bodyPart: 'shoulders',
equipment: 'barbell',
target: 'traps',
secondaryMuscles: [],
instructions: [
'Stand holding a barbell with arms extended.',
'Shrug your shoulders straight up towards your ears.',
'Hold briefly at the top, then lower with control.'
]
},
{
id: 'shrug-dumbbell',
name: 'Shrug (Dumbbell)',
bodyPart: 'shoulders',
equipment: 'dumbbell',
target: 'traps',
secondaryMuscles: [],
instructions: [
'Stand holding dumbbells at your sides.',
'Shrug your shoulders straight up towards your ears.',
'Hold briefly at the top, then lower with control.'
]
},
// === ARMS — BICEPS ===
{
id: 'bicep-curl-barbell',
name: 'Bicep Curl (Barbell)',
bodyPart: 'arms',
equipment: 'barbell',
target: 'biceps',
secondaryMuscles: ['forearms'],
instructions: [
'Stand holding a barbell with an underhand grip, arms extended.',
'Curl the bar up towards your shoulders.',
'Lower with control.'
]
},
{
id: 'bicep-curl-dumbbell',
name: 'Bicep Curl (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'biceps',
secondaryMuscles: ['forearms'],
instructions: [
'Stand holding dumbbells at your sides with palms facing forward.',
'Curl the dumbbells up towards your shoulders.',
'Lower with control.'
]
},
{
id: 'hammer-curl-dumbbell',
name: 'Hammer Curl (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'biceps',
secondaryMuscles: ['brachioradialis', 'forearms'],
instructions: [
'Stand holding dumbbells at your sides with palms facing each other (neutral grip).',
'Curl the dumbbells up towards your shoulders.',
'Lower with control.'
]
},
{
id: 'preacher-curl-barbell',
name: 'Preacher Curl (Barbell)',
bodyPart: 'arms',
equipment: 'barbell',
target: 'biceps',
secondaryMuscles: ['forearms'],
instructions: [
'Sit at a preacher bench with upper arms resting on the pad.',
'Grip the barbell with an underhand grip.',
'Curl the bar up, then lower with control.'
]
},
{
id: 'concentration-curl-dumbbell',
name: 'Concentration Curl (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'biceps',
secondaryMuscles: [],
instructions: [
'Sit on a bench, rest your elbow against the inside of your thigh.',
'Curl the dumbbell up towards your shoulder.',
'Lower with control.'
]
},
{
id: 'cable-curl',
name: 'Cable Curl',
bodyPart: 'arms',
equipment: 'cable',
target: 'biceps',
secondaryMuscles: ['forearms'],
instructions: [
'Stand facing a low cable pulley with a straight or EZ-bar attachment.',
'Curl the bar up towards your shoulders.',
'Lower with control.'
]
},
// === ARMS — TRICEPS ===
{
id: 'tricep-pushdown-cable',
name: 'Tricep Pushdown (Cable)',
bodyPart: 'arms',
equipment: 'cable',
target: 'triceps',
secondaryMuscles: [],
instructions: [
'Stand facing a high cable pulley with a straight bar or rope attachment.',
'Push the bar down until arms are fully extended.',
'Slowly return to the starting position.'
]
},
{
id: 'skullcrusher-dumbbell',
name: 'Skullcrusher (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'triceps',
secondaryMuscles: [],
instructions: [
'Lie flat on a bench holding dumbbells with arms extended above your chest.',
'Lower the dumbbells towards your forehead by bending at the elbows.',
'Extend the arms back to the starting position.'
]
},
{
id: 'skullcrusher-barbell',
name: 'Skullcrusher (Barbell)',
bodyPart: 'arms',
equipment: 'barbell',
target: 'triceps',
secondaryMuscles: [],
instructions: [
'Lie flat on a bench holding a barbell with arms extended above your chest.',
'Lower the bar towards your forehead by bending at the elbows.',
'Extend the arms back to the starting position.'
]
},
{
id: 'overhead-tricep-extension-dumbbell',
name: 'Overhead Tricep Extension (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'triceps',
secondaryMuscles: [],
instructions: [
'Hold a dumbbell overhead with both hands.',
'Lower the dumbbell behind your head by bending at the elbows.',
'Extend back to the starting position.'
]
},
{
id: 'tricep-dip',
name: 'Tricep Dip',
bodyPart: 'arms',
equipment: 'body weight',
target: 'triceps',
secondaryMuscles: ['pectorals', 'anterior deltoids'],
instructions: [
'Grip the parallel bars and lift yourself up, keeping torso upright.',
'Lower your body by bending the elbows, keeping them close to your body.',
'Push back up to the starting position.'
]
},
{
id: 'kickback-dumbbell',
name: 'Kickback (Dumbbell)',
bodyPart: 'arms',
equipment: 'dumbbell',
target: 'triceps',
secondaryMuscles: [],
instructions: [
'Bend forward at the hips, upper arm parallel to the floor.',
'Extend the dumbbell backwards until the arm is straight.',
'Lower with control.'
]
},
// === LEGS ===
{
id: 'squat-barbell',
name: 'Squat (Barbell)',
bodyPart: 'legs',
equipment: 'barbell',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings', 'core'],
instructions: [
'Position the barbell on your upper back (high bar) or rear deltoids (low bar).',
'Stand with feet shoulder-width apart.',
'Squat down until thighs are at least parallel to the floor.',
'Drive through your heels to stand back up.'
]
},
{
id: 'front-squat-barbell',
name: 'Front Squat (Barbell)',
bodyPart: 'legs',
equipment: 'barbell',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'core'],
instructions: [
'Position the barbell across the front of your shoulders.',
'Squat down, keeping the elbows high and torso upright.',
'Drive through your heels to stand back up.'
]
},
{
id: 'leg-press-machine',
name: 'Leg Press (Machine)',
bodyPart: 'legs',
equipment: 'machine',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [
'Sit in the leg press machine with feet shoulder-width apart on the platform.',
'Lower the platform by bending your knees to about 90 degrees.',
'Push the platform back up without locking your knees.'
]
},
{
id: 'lunge-dumbbell',
name: 'Lunge (Dumbbell)',
bodyPart: 'legs',
equipment: 'dumbbell',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [
'Stand holding dumbbells at your sides.',
'Step forward and lower your body until both knees are at 90 degrees.',
'Push back to the starting position.'
]
},
{
id: 'bulgarian-split-squat-dumbbell',
name: 'Bulgarian Split Squat (Dumbbell)',
bodyPart: 'legs',
equipment: 'dumbbell',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [
'Stand with one foot on a bench behind you, holding dumbbells.',
'Lower your body until the front thigh is parallel to the floor.',
'Drive through the front heel to stand back up.'
]
},
{
id: 'leg-extension-machine',
name: 'Leg Extension (Machine)',
bodyPart: 'legs',
equipment: 'machine',
target: 'quadriceps',
secondaryMuscles: [],
instructions: [
'Sit in the leg extension machine with the pad against your lower shins.',
'Extend your legs until they are straight.',
'Lower with control.'
]
},
{
id: 'leg-curl-machine',
name: 'Leg Curl (Machine)',
bodyPart: 'legs',
equipment: 'machine',
target: 'hamstrings',
secondaryMuscles: ['calves'],
instructions: [
'Lie face down on the leg curl machine with the pad against the back of your ankles.',
'Curl your legs up towards your glutes.',
'Lower with control.'
]
},
{
id: 'romanian-deadlift-barbell',
name: 'Romanian Deadlift (Barbell)',
bodyPart: 'legs',
equipment: 'barbell',
target: 'hamstrings',
secondaryMuscles: ['glutes', 'erector spinae'],
instructions: [
'Stand holding a barbell with an overhand grip.',
'Hinge at the hips, pushing them back while keeping legs nearly straight.',
'Lower the bar along your legs until you feel a stretch in the hamstrings.',
'Drive the hips forward to return to standing.'
]
},
{
id: 'romanian-deadlift-dumbbell',
name: 'Romanian Deadlift (Dumbbell)',
bodyPart: 'legs',
equipment: 'dumbbell',
target: 'hamstrings',
secondaryMuscles: ['glutes', 'erector spinae'],
instructions: [
'Stand holding dumbbells in front of your thighs.',
'Hinge at the hips, pushing them back while keeping legs nearly straight.',
'Lower the dumbbells along your legs until you feel a stretch in the hamstrings.',
'Drive the hips forward to return to standing.'
]
},
{
id: 'hip-thrust-barbell',
name: 'Hip Thrust (Barbell)',
bodyPart: 'legs',
equipment: 'barbell',
target: 'glutes',
secondaryMuscles: ['hamstrings'],
instructions: [
'Sit on the floor with your upper back against a bench, barbell over your hips.',
'Drive through your heels to lift your hips until your body forms a straight line.',
'Squeeze your glutes at the top.',
'Lower with control.'
]
},
{
id: 'calf-raise-machine',
name: 'Calf Raise (Machine)',
bodyPart: 'legs',
equipment: 'machine',
target: 'calves',
secondaryMuscles: [],
instructions: [
'Stand on the machine platform with the balls of your feet on the edge.',
'Lower your heels as far as comfortable.',
'Push up onto your toes as high as possible.',
'Lower with control.'
]
},
{
id: 'calf-raise-standing',
name: 'Calf Raise (Standing)',
bodyPart: 'legs',
equipment: 'body weight',
target: 'calves',
secondaryMuscles: [],
instructions: [
'Stand on a step or platform with the balls of your feet on the edge.',
'Lower your heels below the platform.',
'Push up onto your toes as high as possible.',
'Lower with control.'
]
},
{
id: 'goblet-squat-dumbbell',
name: 'Goblet Squat (Dumbbell)',
bodyPart: 'legs',
equipment: 'dumbbell',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'core'],
instructions: [
'Hold a dumbbell vertically at chest level.',
'Squat down, keeping the torso upright.',
'Drive through your heels to stand back up.'
]
},
{
id: 'hack-squat-machine',
name: 'Hack Squat (Machine)',
bodyPart: 'legs',
equipment: 'machine',
target: 'quadriceps',
secondaryMuscles: ['glutes', 'hamstrings'],
instructions: [
'Position yourself in the hack squat machine with shoulders against the pads.',
'Lower the platform by bending your knees.',
'Push back up without locking your knees.'
]
},
// === CORE ===
{
id: 'plank',
name: 'Plank',
bodyPart: 'core',
equipment: 'body weight',
target: 'abdominals',
secondaryMuscles: ['obliques', 'erector spinae'],
instructions: [
'Start in a forearm plank position with elbows under shoulders.',
'Keep your body in a straight line from head to heels.',
'Hold the position for the desired duration.'
]
},
{
id: 'crunch',
name: 'Crunch',
bodyPart: 'core',
equipment: 'body weight',
target: 'abdominals',
secondaryMuscles: [],
instructions: [
'Lie on your back with knees bent and feet flat on the floor.',
'Place hands behind your head or across your chest.',
'Curl your upper body towards your knees.',
'Lower with control.'
]
},
{
id: 'hanging-leg-raise',
name: 'Hanging Leg Raise',
bodyPart: 'core',
equipment: 'body weight',
target: 'abdominals',
secondaryMuscles: ['hip flexors'],
instructions: [
'Hang from a pull-up bar with arms extended.',
'Raise your legs until they are parallel to the floor (or higher).',
'Lower with control.'
]
},
{
id: 'cable-crunch',
name: 'Cable Crunch',
bodyPart: 'core',
equipment: 'cable',
target: 'abdominals',
secondaryMuscles: [],
instructions: [
'Kneel in front of a high cable pulley with a rope attachment.',
'Hold the rope behind your head.',
'Crunch down, bringing your elbows towards your knees.',
'Return to the starting position with control.'
]
},
{
id: 'russian-twist',
name: 'Russian Twist',
bodyPart: 'core',
equipment: 'body weight',
target: 'obliques',
secondaryMuscles: ['abdominals'],
instructions: [
'Sit on the floor with knees bent, lean back slightly.',
'Rotate your torso from side to side.',
'Optionally hold a weight for added resistance.'
]
},
{
id: 'ab-wheel-rollout',
name: 'Ab Wheel Rollout',
bodyPart: 'core',
equipment: 'other',
target: 'abdominals',
secondaryMuscles: ['erector spinae', 'lats'],
instructions: [
'Kneel on the floor holding an ab wheel.',
'Roll the wheel forward, extending your body as far as possible.',
'Use your core to pull back to the starting position.'
]
},
// === CARDIO / FULL BODY ===
{
id: 'running',
name: 'Running',
bodyPart: 'cardio',
equipment: 'body weight',
target: 'cardiovascular system',
secondaryMuscles: ['quadriceps', 'hamstrings', 'calves', 'glutes'],
instructions: ['Run at a steady pace for the desired duration or distance.']
},
{
id: 'cycling-indoor',
name: 'Cycling (Indoor)',
bodyPart: 'cardio',
equipment: 'machine',
target: 'cardiovascular system',
secondaryMuscles: ['quadriceps', 'hamstrings', 'calves'],
instructions: ['Cycle at a steady pace on a stationary bike for the desired duration.']
},
{
id: 'rowing-machine',
name: 'Rowing Machine',
bodyPart: 'cardio',
equipment: 'machine',
target: 'cardiovascular system',
secondaryMuscles: ['lats', 'biceps', 'quadriceps', 'core'],
instructions: [
'Sit at the rowing machine and strap your feet in.',
'Drive with your legs first, then pull the handle to your lower chest.',
'Return to the starting position by extending arms, then bending knees.'
]
},
// === ADDITIONAL COMPOUND MOVEMENTS ===
{
id: 'clean-and-press-barbell',
name: 'Clean and Press (Barbell)',
bodyPart: 'shoulders',
equipment: 'barbell',
target: 'anterior deltoids',
secondaryMuscles: ['traps', 'quadriceps', 'glutes', 'core'],
instructions: [
'Start with the barbell on the floor.',
'Pull the bar explosively to your shoulders (clean).',
'Press the bar overhead.',
'Lower back to shoulders, then to the floor.'
]
},
{
id: 'farmers-walk',
name: "Farmer's Walk",
bodyPart: 'core',
equipment: 'dumbbell',
target: 'forearms',
secondaryMuscles: ['traps', 'core', 'grip'],
instructions: [
'Hold heavy dumbbells or farmer walk handles at your sides.',
'Walk with controlled steps for the desired distance or duration.',
'Keep your core tight and shoulders back.'
]
}
];
// Lookup map for O(1) access by ID
const exerciseMap = new Map<string, Exercise>(exercises.map((e) => [e.id, e]));
export function getExerciseById(id: string): Exercise | undefined {
return exerciseMap.get(id);
}
export function getFilterOptions(): {
bodyParts: string[];
equipment: string[];
targets: string[];
} {
const bodyParts = new Set<string>();
const equipment = new Set<string>();
const targets = new Set<string>();
for (const e of exercises) {
bodyParts.add(e.bodyPart);
equipment.add(e.equipment);
targets.add(e.target);
}
return {
bodyParts: [...bodyParts].sort(),
equipment: [...equipment].sort(),
targets: [...targets].sort()
};
}
export function searchExercises(opts: {
search?: string;
bodyPart?: string;
equipment?: string;
target?: string;
}): Exercise[] {
let results = exercises;
if (opts.bodyPart) {
results = results.filter((e) => e.bodyPart === opts.bodyPart);
}
if (opts.equipment) {
results = results.filter((e) => e.equipment === opts.equipment);
}
if (opts.target) {
results = results.filter((e) => e.target === opts.target);
}
if (opts.search) {
const query = opts.search.toLowerCase();
results = results.filter(
(e) =>
e.name.toLowerCase().includes(query) ||
e.target.toLowerCase().includes(query) ||
e.bodyPart.toLowerCase().includes(query) ||
e.equipment.toLowerCase().includes(query) ||
e.secondaryMuscles.some((m) => m.toLowerCase().includes(query))
);
}
return results;
}

View File

@@ -0,0 +1,381 @@
/**
* Active workout state store — factory pattern.
* Client-side only; persisted to localStorage so state survives navigation.
* Saved to server on finish via POST /api/fitness/sessions.
*/
import { getExerciseById } from '$lib/data/exercises';
export interface WorkoutSet {
reps: number | null;
weight: number | null;
rpe: number | null;
completed: boolean;
}
export interface WorkoutExercise {
exerciseId: string;
sets: WorkoutSet[];
restTime: number; // seconds
}
export interface TemplateData {
_id: string;
name: string;
exercises: Array<{
exerciseId: string;
sets: Array<{ reps?: number; weight?: number; rpe?: number }>;
restTime?: number;
}>;
}
const STORAGE_KEY = 'fitness-active-workout';
interface StoredState {
active: boolean;
paused: boolean;
name: string;
templateId: string | null;
exercises: WorkoutExercise[];
elapsed: number; // total elapsed seconds at time of save
savedAt: number; // Date.now() at time of save
}
function createEmptySet(): WorkoutSet {
return { reps: null, weight: null, rpe: null, completed: false };
}
function saveToStorage(state: StoredState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {}
}
function loadFromStorage(): StoredState | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
function clearStorage() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
}
export function createWorkout() {
let active = $state(false);
let paused = $state(false);
let name = $state('');
let templateId: string | null = $state(null);
let exercises = $state<WorkoutExercise[]>([]);
let startTime: Date | null = $state(null);
let _pausedElapsed = $state(0); // seconds accumulated before current run
let _elapsed = $state(0);
let _restSeconds = $state(0);
let _restTotal = $state(0);
let _restActive = $state(false);
let _timerInterval: ReturnType<typeof setInterval> | null = null;
let _restInterval: ReturnType<typeof setInterval> | null = null;
function _persist() {
if (!active) return;
// When running, compute current elapsed before saving
if (!paused && startTime) {
_elapsed = _pausedElapsed + Math.floor((Date.now() - startTime.getTime()) / 1000);
}
saveToStorage({
active,
paused,
name,
templateId,
exercises: JSON.parse(JSON.stringify(exercises)),
elapsed: _elapsed,
savedAt: Date.now()
});
}
function _computeElapsed() {
if (paused || !startTime) return;
_elapsed = _pausedElapsed + Math.floor((Date.now() - startTime.getTime()) / 1000);
}
function _startTimer() {
_stopTimer();
_timerInterval = setInterval(() => {
_computeElapsed();
}, 1000);
}
function _stopTimer() {
if (_timerInterval) {
clearInterval(_timerInterval);
_timerInterval = null;
}
}
function _stopRestTimer() {
if (_restInterval) {
clearInterval(_restInterval);
_restInterval = null;
}
_restActive = false;
_restSeconds = 0;
_restTotal = 0;
}
// Restore from localStorage on creation
function restore() {
const stored = loadFromStorage();
if (!stored || !stored.active) return;
active = true;
paused = stored.paused;
name = stored.name;
templateId = stored.templateId;
exercises = stored.exercises;
if (stored.paused) {
// Was paused: elapsed is exactly what was saved
_pausedElapsed = stored.elapsed;
_elapsed = stored.elapsed;
startTime = null;
} else {
// Was running: add the time that passed since we last saved
const secondsSinceSave = Math.floor((Date.now() - stored.savedAt) / 1000);
const totalElapsed = stored.elapsed + secondsSinceSave;
_pausedElapsed = totalElapsed;
_elapsed = totalElapsed;
startTime = new Date(); // start counting from now
_startTimer();
}
}
function startFromTemplate(template: TemplateData) {
name = template.name;
templateId = template._id;
exercises = template.exercises.map((e) => ({
exerciseId: e.exerciseId,
sets: e.sets.length > 0
? e.sets.map((s) => ({
reps: s.reps ?? null,
weight: s.weight ?? null,
rpe: s.rpe ?? null,
completed: false
}))
: [createEmptySet()],
restTime: e.restTime ?? 120
}));
startTime = new Date();
_pausedElapsed = 0;
_elapsed = 0;
paused = false;
active = true;
_startTimer();
_persist();
}
function startEmpty() {
name = 'Quick Workout';
templateId = null;
exercises = [];
startTime = new Date();
_pausedElapsed = 0;
_elapsed = 0;
paused = false;
active = true;
_startTimer();
_persist();
}
function pauseTimer() {
if (!active || paused) return;
_computeElapsed();
_pausedElapsed = _elapsed;
paused = true;
startTime = null;
_stopTimer();
_persist();
}
function resumeTimer() {
if (!active || !paused) return;
paused = false;
startTime = new Date();
_startTimer();
_persist();
}
function addExercise(exerciseId: string) {
exercises.push({
exerciseId,
sets: [createEmptySet()],
restTime: 120
});
_persist();
}
function removeExercise(index: number) {
exercises.splice(index, 1);
_persist();
}
function addSet(exerciseIndex: number) {
const ex = exercises[exerciseIndex];
if (ex) {
ex.sets.push(createEmptySet());
_persist();
}
}
function removeSet(exerciseIndex: number, setIndex: number) {
const ex = exercises[exerciseIndex];
if (ex && ex.sets.length > 1) {
ex.sets.splice(setIndex, 1);
_persist();
}
}
function updateSet(exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) {
const ex = exercises[exerciseIndex];
if (ex?.sets[setIndex]) {
Object.assign(ex.sets[setIndex], data);
_persist();
}
}
function toggleSetComplete(exerciseIndex: number, setIndex: number) {
const ex = exercises[exerciseIndex];
if (ex?.sets[setIndex]) {
const wasCompleted = ex.sets[setIndex].completed;
ex.sets[setIndex].completed = !wasCompleted;
if (wasCompleted) {
// Unticked — cancel rest timer
_stopRestTimer();
}
_persist();
}
}
function startRestTimer(seconds: number) {
_stopRestTimer();
_restSeconds = seconds;
_restTotal = seconds;
_restActive = true;
_restInterval = setInterval(() => {
_restSeconds--;
if (_restSeconds <= 0) {
_stopRestTimer();
}
}, 1000);
}
function cancelRestTimer() {
_stopRestTimer();
}
function finish() {
_stopTimer();
_stopRestTimer();
const endTime = new Date();
_computeElapsed();
const sessionData = {
templateId,
templateName: templateId ? name : undefined,
name,
exercises: exercises
.filter((e) => e.sets.some((s) => s.completed))
.map((e) => ({
exerciseId: e.exerciseId,
name: getExerciseById(e.exerciseId)?.name ?? e.exerciseId,
sets: e.sets
.filter((s) => s.completed)
.map((s) => ({
reps: s.reps ?? 0,
weight: s.weight ?? 0,
rpe: s.rpe ?? undefined,
completed: true
}))
})),
startTime: _getOriginalStartTime()?.toISOString() ?? new Date().toISOString(),
endTime: endTime.toISOString()
};
_reset();
return sessionData;
}
function _getOriginalStartTime(): Date | null {
// Compute original start from elapsed
if (_elapsed > 0) {
return new Date(Date.now() - _elapsed * 1000);
}
return startTime;
}
function _reset() {
active = false;
paused = false;
name = '';
templateId = null;
exercises = [];
startTime = null;
_pausedElapsed = 0;
_elapsed = 0;
clearStorage();
}
function cancel() {
_stopTimer();
_stopRestTimer();
_reset();
}
return {
get active() { return active; },
get paused() { return paused; },
get name() { return name; },
set name(v: string) { name = v; _persist(); },
get templateId() { return templateId; },
get exercises() { return exercises; },
get startTime() { return startTime; },
get elapsedSeconds() { return _elapsed; },
get restTimerSeconds() { return _restSeconds; },
get restTimerTotal() { return _restTotal; },
get restTimerActive() { return _restActive; },
restore,
startFromTemplate,
startEmpty,
pauseTimer,
resumeTimer,
addExercise,
removeExercise,
addSet,
removeSet,
updateSet,
toggleSetComplete,
startRestTimer,
cancelRestTimer,
finish,
cancel
};
}
/** Shared singleton — use this instead of createWorkout() in components */
let _instance: ReturnType<typeof createWorkout> | null = null;
export function getWorkout() {
if (!_instance) {
_instance = createWorkout();
}
return _instance;
}

View 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
);

View File

@@ -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 });
};

View 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 });
};

View 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
});
};

View 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 });
};

View 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' });
};

View 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
});
};

View 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();
}

View 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 });
};

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
session: await locals.auth()
};
};

View 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>

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
redirect(302, '/fitness/workout');
};

View 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()
};
};

View 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>

View 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()
};
};

View 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>

View 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()
};
};

View 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>

View 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
};
};

View 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>

View 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()
};
};

View 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>

View 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 };
};

View 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>

View 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()
};
};

View 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">&times;</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>

View 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>