Files
homepage/src/routes/fitness/[exercises=fitnessExercises]/[id]/+page.svelte
T
Alexander e5d218820b refactor: migrate hrefs to resolve()/asset() from $app/paths
Replace string-literal and template-literal hrefs across the codebase
with the modern SvelteKit 2.26+ resolve() and asset() APIs. Migration
makes route IDs explicit, type-checked against generated $app/types,
and base-path-aware. Two codemod scripts handle the bulk; remaining
ambiguous, query-bearing, and precomputed-href cases are converted
manually at the assignment sites.
2026-04-29 22:14:29 +02:00

622 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { resolve } from '$app/paths';
import { page } from '$app/stores';
/** @param {string | undefined | null} type @param {'en'|'de'} lang */
function exerciseTypeInfo(type, lang) {
if (!type) return null;
switch (type) {
case 'STRETCHING':
case 'YOGA':
return { key: 'stretch', label: t('stretch_pill', lang) };
case 'STRENGTH':
case 'WEIGHTLIFTING':
return { key: 'strength', label: t('strength_pill', lang) };
case 'CARDIO':
return { key: 'cardio', label: t('cardio_pill', lang) };
case 'PLYOMETRICS':
return { key: 'plyo', label: t('plyo_pill', lang) };
default:
return null;
}
}
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
const lang = $derived(detectFitnessLang($page.url.pathname));
const s = $derived(fitnessSlugs(lang));
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import MuscleMap from '$lib/components/fitness/MuscleMap.svelte';
import { onMount } from 'svelte';
let { data } = $props();
function checkDark() {
if (typeof document === 'undefined') return false;
const t = document.documentElement.dataset.theme;
if (t === 'dark') return true;
if (t === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
let dark = $state(checkDark());
onMount(() => {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const onMql = () => { dark = checkDark(); };
mql.addEventListener('change', onMql);
const obs = new MutationObserver(() => { dark = checkDark(); });
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => { mql.removeEventListener('change', onMql); obs.disconnect(); };
});
const primary = $derived(dark ? '#88C0D0' : '#5E81AC');
let activeTab = $state('about');
const exercise = $derived(data.exercise);
const typeInfo = $derived(exerciseTypeInfo(exercise?.exerciseType, lang));
const similar = $derived(data.similar ?? []);
const history = $derived(data.history?.history ?? []);
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 withTrend({
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), borderColor: primary }]
});
});
const maxWeightChartData = $derived.by(() => {
const points = charts.maxWeightOverTime ?? [];
return withTrend({
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' }]
}, '#A3BE8C');
});
const volumeChartData = $derived.by(() => {
const points = charts.totalVolumeOverTime ?? [];
return withTrend({
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' }]
}, '#EBCB8B');
});
/** @param {number[]} data */
function trendWithBands(data) {
const n = data.length;
if (n < 3) return null;
let sx = 0, sy = 0, sxx = 0, sxy = 0;
for (let i = 0; i < n; i++) {
sx += i; sy += data[i]; sxx += i * i; sxy += i * data[i];
}
const slope = (n * sxy - sx * sy) / (n * sxx - sx * sx);
const intercept = (sy - slope * sx) / n;
const trend = data.map((_, i) => Math.round((intercept + slope * i) * 10) / 10);
let ssRes = 0;
for (let i = 0; i < n; i++) { const r = data[i] - trend[i]; ssRes += r * r; }
const sigma = Math.sqrt(ssRes / (n - 2));
return { trend, upper: trend.map(v => Math.round((v + sigma) * 10) / 10), lower: trend.map(v => Math.round((v - sigma) * 10) / 10) };
}
/** @param {{ labels: string[], datasets: Array<any> }} chartData @param {string} trendColor */
function withTrend(chartData, trendColor = primary) {
const values = chartData.datasets[0]?.data;
if (!values || values.length < 3) return chartData;
const bands = trendWithBands(values);
if (!bands) return chartData;
return {
labels: chartData.labels,
datasets: [
{ label: '± 1σ', data: bands.upper, borderColor: 'transparent', backgroundColor: `${trendColor}26`, fill: '+1', pointRadius: 0, borderWidth: 0, tension: 0.3, order: 2 },
{ label: '± 1σ (lower)', data: bands.lower, borderColor: 'transparent', backgroundColor: 'transparent', fill: false, pointRadius: 0, borderWidth: 0, tension: 0.3, order: 2 },
{ label: 'Trend', data: bands.trend, borderColor: trendColor, pointRadius: 0, borderWidth: 2, tension: 0.3, order: 1 },
{ ...chartData.datasets[0], borderWidth: 1, order: 0 }
]
};
}
/** @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>
<svelte:head><title>{exercise?.localName ?? (lang === 'en' ? 'Exercise' : 'Übung')} - Bocken</title></svelte:head>
<div class="exercise-detail">
<h1>{exercise?.localName ?? 'Exercise'}</h1>
<div class="tabs">
{#each tabs as tab}
<button
class="tab"
class:active={activeTab === tab}
onclick={() => activeTab = tab}
>
{{ about: t('about', lang), history: t('history_tab', lang), charts: t('charts', lang), records: t('records', lang) }[tab]}
</button>
{/each}
</div>
{#if activeTab === 'about'}
<div class="tab-content about-layout">
<div class="about-main">
<!-- Tags -->
<div class="tags">
{#if typeInfo}
<span class="tag type-{typeInfo.key}">{typeInfo.label}</span>
{/if}
<span class="tag body-part">{exercise?.localBodyPart}</span>
<span class="tag equipment">{exercise?.localEquipment}</span>
<span class="tag target">{exercise?.localTarget}</span>
</div>
<!-- Muscle map (mobile only — shown inline) -->
{#if exercise?.localSecondaryMuscles?.length || exercise?.localTarget}
<div class="muscle-section-mobile">
<MuscleMap
primaryGroups={[exercise?.target].filter(Boolean)}
secondaryGroups={exercise?.secondaryMuscles ?? []}
{lang}
/>
<div class="muscle-pills">
{#if exercise?.localTarget}
<span class="muscle-pill primary">{exercise.localTarget}</span>
{/if}
{#each exercise?.localSecondaryMuscles ?? [] as muscle}
<span class="muscle-pill secondary">{muscle}</span>
{/each}
</div>
</div>
{/if}
<!-- Overview -->
{#if exercise?.overview}
<p class="overview">{exercise.overview}</p>
{/if}
<!-- Instructions -->
{#if exercise?.localInstructions?.length}
<h3>{t('instructions', lang)}</h3>
<ol class="instructions">
{#each exercise.localInstructions as step}
<li>{step}</li>
{/each}
</ol>
{/if}
<!-- Similar exercises -->
{#if similar.length > 0}
<div class="similar-section">
<h3>{lang === 'en' ? 'Similar Exercises' : 'Ähnliche Übungen'}</h3>
<div class="similar-scroll">
{#each similar as sim}
<a class="similar-card" href={resolve('/fitness/[exercises=fitnessExercises]/[id]', { exercises: s.exercises, id: sim.id })}>
<div class="similar-info">
<span class="similar-name">{sim.localName}</span>
<span class="similar-meta">{sim.localBodyPart} · {sim.localEquipment}</span>
</div>
<ChevronRight size={14} />
</a>
{/each}
</div>
</div>
{/if}
</div>
<!-- Muscle map sidebar (desktop only) -->
{#if exercise?.localSecondaryMuscles?.length || exercise?.localTarget}
<aside class="muscle-sidebar">
<MuscleMap
primaryGroups={[exercise?.target].filter(Boolean)}
secondaryGroups={exercise?.secondaryMuscles ?? []}
{lang}
/>
<div class="muscle-pills">
{#if exercise?.localTarget}
<span class="muscle-pill primary">{exercise.localTarget}</span>
{/if}
{#each exercise?.localSecondaryMuscles ?? [] as muscle}
<span class="muscle-pill secondary">{muscle}</span>
{/each}
</div>
</aside>
{/if}
</div>
{:else if activeTab === 'history'}
<div class="tab-content">
{#if history.length === 0}
<p class="empty">{t('no_history_yet', lang)}</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>{t('set', lang)}</th><th>{t('kg', lang)}</th><th>{t('reps', lang)}</th><th>{t('est_1rm', lang)}</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={t('best_set_1rm', lang)} yUnit=" kg" />
<FitnessChart data={maxWeightChartData} title={t('best_set_max', lang)} yUnit=" kg" />
<FitnessChart data={volumeChartData} title={t('total_volume', lang)} yUnit=" kg" />
{:else}
<p class="empty">{t('not_enough_data', lang)}</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">{t('estimated_1rm', lang)}</span>
<span class="record-value">{prs.estimatedOneRepMax} kg</span>
</div>
{/if}
{#if prs.maxVolume}
<div class="record-card">
<span class="record-label">{t('max_volume', lang)}</span>
<span class="record-value">{prs.maxVolume} kg</span>
</div>
{/if}
{#if prs.maxWeight}
<div class="record-card">
<span class="record-label">{t('max_weight', lang)}</span>
<span class="record-value">{prs.maxWeight} kg</span>
</div>
{/if}
</div>
{#if records.length}
<h3>{t('rep_records', lang)}</h3>
<table class="records-table">
<thead>
<tr><th>{t('reps', lang)}</th><th>{t('best_performance', lang)}</th><th>{t('est_1rm', lang)}</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(--color-border);
}
.tab {
flex: 1;
padding: 0.6rem 0.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
color: var(--color-text-secondary);
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
letter-spacing: 0.05em;
text-align: center;
}
.tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.tab-content {
padding: 0.5rem 0;
}
/* Tags */
.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); }
.tag.type-stretch,
.tag.type-strength,
.tag.type-cardio,
.tag.type-plyo {
font-weight: 700;
letter-spacing: 0.04em;
}
.tag.type-stretch { background: rgba(180, 142, 173, 0.2); color: var(--nord15); }
.tag.type-strength { background: rgba(94, 129, 172, 0.2); color: var(--nord10); }
.tag.type-cardio { background: rgba(191, 97, 106, 0.2); color: var(--nord11); }
.tag.type-plyo { background: rgba(235, 203, 139, 0.22); color: var(--nord13); }
/* About layout — two-column on wide screens */
.about-layout {
display: flex;
flex-direction: column;
}
.about-main {
flex: 1;
min-width: 0;
}
.muscle-sidebar {
display: none;
}
.muscle-section-mobile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
@media (min-width: 600px) {
.about-layout {
flex-direction: row-reverse;
gap: 1.5rem;
align-items: flex-start;
}
.muscle-sidebar {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
position: sticky;
top: 1rem;
flex-shrink: 0;
width: 180px;
}
.muscle-section-mobile {
display: none;
}
}
/* Muscle pills */
.muscle-pills {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
justify-content: center;
}
.muscle-pill {
padding: 0.2rem 0.55rem;
border-radius: 16px;
font-size: 0.7rem;
font-weight: 600;
text-transform: capitalize;
}
.muscle-pill.primary {
background: rgba(94, 129, 172, 0.2);
color: var(--nord9);
}
.muscle-pill.secondary {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-tertiary);
}
.overview {
font-size: 0.85rem;
line-height: 1.6;
color: var(--color-text-secondary);
margin: 0.25rem 0 0.5rem;
}
h3 {
font-size: 1rem;
margin: 0.75rem 0 0.4rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.instructions {
padding-left: 1.25rem;
font-size: 0.85rem;
line-height: 1.6;
}
.instructions li {
margin-bottom: 0.4rem;
}
/* Similar exercises */
.similar-section {
margin-top: 0.25rem;
}
.similar-scroll {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.similar-card {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem;
background: var(--color-surface);
border-radius: 10px;
box-shadow: var(--shadow-sm);
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s;
}
.similar-card:hover {
box-shadow: var(--shadow-md, 0 2px 8px rgba(0,0,0,0.12));
}
.similar-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.similar-name {
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.similar-meta {
font-size: 0.65rem;
color: var(--color-text-tertiary);
text-transform: capitalize;
}
.similar-card :global(svg) {
color: var(--color-text-tertiary);
flex-shrink: 0;
}
/* History */
.empty {
text-align: center;
color: var(--color-text-secondary);
padding: 2rem 0;
}
.history-session {
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
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(--color-text-secondary);
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(--color-text-secondary);
padding: 0.25rem;
letter-spacing: 0.05em;
}
.history-sets td {
text-align: center;
padding: 0.25rem;
}
.rpe {
color: var(--nord12);
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(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
}
.record-label {
font-size: 0.7rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.record-value {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-primary);
}
.records-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.records-table th {
text-align: left;
font-size: 0.7rem;
color: var(--color-text-secondary);
padding: 0.4rem 0.5rem;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border);
}
.records-table td {
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.rec-date {
color: var(--color-text-secondary);
font-size: 0.75rem;
}
</style>