Compare commits

2 Commits

Author SHA1 Message Date
593f211252 feat: ExerciseDB integration with muscle heatmap, SVG body filter, and enriched exercises
All checks were successful
CI / update (push) Successful in 3m31s
Integrate ExerciseDB v2 data layer (muscleMap.ts, exercisedb.ts) to enrich
the 77 static exercises with detailed muscle targeting, similar exercises,
and expand the catalog to 254 exercises. Add interactive SVG muscle body
diagrams for both the stats page heatmap and exercise list filtering, with
split front/back views flanking the exercise list on desktop. Replace body
part dropdown with unified muscle group multi-select with pill tags.
2026-04-06 20:57:49 +02:00
0874283146 fitness: add ExerciseDB v2 scrape data, media, and ID mapping
Scrape scripts for ExerciseDB v2 API (scrape-exercises.ts,
download-exercise-media.ts), raw data for 200 exercises with
images/videos, and a 1:1 mapping from ExerciseDB IDs to internal
kebab-case slugs (exercisedb-map.ts). 23 exercises matched to
existing internal IDs, 177 new slugs generated.
2026-04-06 15:46:29 +02:00
1022 changed files with 16264 additions and 123 deletions

View File

@@ -32,3 +32,6 @@ OLLAMA_URL="http://localhost:11434" # Local Ollama server URL
# HuggingFace Transformers Model Cache (for nutrition embedding models)
TRANSFORMERS_CACHE="/var/cache/transformers" # Must be writable by build and runtime user
# ExerciseDB v2 API (RapidAPI) - for scraping exercise data
RAPIDAPI_KEY="your-rapidapi-key"

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -0,0 +1,117 @@
/**
* Downloads all exercise images and videos from the ExerciseDB CDN.
*
* Run with: pnpm exec vite-node scripts/download-exercise-media.ts
*
* Reads: src/lib/data/exercisedb-raw.json
* Outputs: static/fitness/exercises/<exerciseId>/
* - images: 360p.jpg, 480p.jpg, 720p.jpg, 1080p.jpg
* - video: video.mp4
*
* Resumes automatically — skips files that already exist on disk.
*/
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
import { resolve, extname } from 'path';
const RAW_PATH = resolve('src/lib/data/exercisedb-raw.json');
const OUT_DIR = resolve('static/fitness/exercises');
const CONCURRENCY = 10;
interface DownloadTask {
url: string;
dest: string;
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}
async function download(url: string, dest: string, retries = 3): Promise<boolean> {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const buf = Buffer.from(await res.arrayBuffer());
writeFileSync(dest, buf);
return true;
} catch (err: any) {
if (attempt === retries) {
console.error(` FAILED ${url}: ${err.message}`);
return false;
}
await sleep(1000 * attempt);
}
}
return false;
}
async function runQueue(tasks: DownloadTask[]) {
let done = 0;
let failed = 0;
const total = tasks.length;
async function worker() {
while (tasks.length > 0) {
const task = tasks.shift()!;
const ok = await download(task.url, task.dest);
if (!ok) failed++;
done++;
if (done % 50 === 0 || done === total) {
console.log(` ${done}/${total} downloaded${failed ? ` (${failed} failed)` : ''}`);
}
}
}
const workers = Array.from({ length: CONCURRENCY }, () => worker());
await Promise.all(workers);
return { done, failed };
}
async function main() {
console.log('=== Exercise Media Downloader ===\n');
if (!existsSync(RAW_PATH)) {
console.error(`Missing ${RAW_PATH} — run scrape-exercises.ts first`);
process.exit(1);
}
const data = JSON.parse(readFileSync(RAW_PATH, 'utf-8'));
const exercises: any[] = data.exercises;
console.log(`${exercises.length} exercises in raw data\n`);
const tasks: DownloadTask[] = [];
for (const ex of exercises) {
const dir = resolve(OUT_DIR, ex.exerciseId);
mkdirSync(dir, { recursive: true });
// Multi-resolution images
if (ex.imageUrls) {
for (const [res, url] of Object.entries(ex.imageUrls as Record<string, string>)) {
const ext = extname(new URL(url).pathname) || '.jpg';
const dest = resolve(dir, `${res}${ext}`);
if (!existsSync(dest)) tasks.push({ url, dest });
}
}
// Video
if (ex.videoUrl) {
const dest = resolve(dir, 'video.mp4');
if (!existsSync(dest)) tasks.push({ url: ex.videoUrl, dest });
}
}
if (tasks.length === 0) {
console.log('All media already downloaded!');
return;
}
console.log(`${tasks.length} files to download (skipping existing)\n`);
const { done, failed } = await runQueue(tasks);
console.log(`\nDone! ${done - failed} downloaded, ${failed} failed.`);
}
main().catch(err => {
console.error(err);
process.exit(1);
});

156
scripts/scrape-exercises.ts Normal file
View File

@@ -0,0 +1,156 @@
/**
* Scrapes the full ExerciseDB v2 API (via RapidAPI) and saves raw data.
*
* Run with: RAPIDAPI_KEY=... pnpm exec vite-node scripts/scrape-exercises.ts
*
* Outputs: src/lib/data/exercisedb-raw.json
*
* Supports resuming — already-fetched exercises are read from the output file
* and skipped. Saves to disk after every detail fetch.
*/
import { writeFileSync, readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
const API_HOST = 'edb-with-videos-and-images-by-ascendapi.p.rapidapi.com';
const API_KEY = process.env.RAPIDAPI_KEY;
if (!API_KEY) {
console.error('Set RAPIDAPI_KEY environment variable');
process.exit(1);
}
const BASE = `https://${API_HOST}/api/v1`;
const HEADERS = {
'x-rapidapi-host': API_HOST,
'x-rapidapi-key': API_KEY,
};
const OUTPUT_PATH = resolve('src/lib/data/exercisedb-raw.json');
const IDS_CACHE_PATH = resolve('src/lib/data/.exercisedb-ids.json');
const DELAY_MS = 1500;
const MAX_RETRIES = 5;
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}
async function apiFetch(path: string, attempt = 1): Promise<any> {
const res = await fetch(`${BASE}${path}`, { headers: HEADERS });
if (res.status === 429 && attempt <= MAX_RETRIES) {
const wait = DELAY_MS * 2 ** attempt;
console.warn(` rate limited on ${path}, retrying in ${wait}ms...`);
await sleep(wait);
return apiFetch(path, attempt + 1);
}
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${path}`);
return res.json();
}
function loadExisting(): { metadata: any; exercises: any[] } | null {
if (!existsSync(OUTPUT_PATH)) return null;
try {
const data = JSON.parse(readFileSync(OUTPUT_PATH, 'utf-8'));
if (data.exercises?.length) {
console.log(` found existing file with ${data.exercises.length} exercises`);
return { metadata: data.metadata, exercises: data.exercises };
}
} catch {}
return null;
}
function saveToDisk(metadata: any, exercises: any[]) {
const output = {
scrapedAt: new Date().toISOString(),
metadata,
exercises,
};
writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2));
}
async function fetchAllIds(): Promise<string[]> {
const ids: string[] = [];
let cursor: string | undefined;
while (true) {
const params = new URLSearchParams({ limit: '100' });
if (cursor) params.set('after', cursor);
const res = await apiFetch(`/exercises?${params}`);
for (const ex of res.data) {
ids.push(ex.exerciseId);
}
console.log(` fetched page, ${ids.length} IDs so far`);
if (!res.meta.hasNextPage) break;
cursor = res.meta.nextCursor;
await sleep(DELAY_MS);
}
return ids;
}
async function fetchMetadata() {
const endpoints = ['/bodyparts', '/equipments', '/muscles', '/exercisetypes'] as const;
const keys = ['bodyParts', 'equipments', 'muscles', 'exerciseTypes'] as const;
const result: Record<string, any> = {};
for (let i = 0; i < endpoints.length; i++) {
const res = await apiFetch(endpoints[i]);
result[keys[i]] = res.data;
await sleep(DELAY_MS);
}
return result;
}
async function main() {
console.log('=== ExerciseDB v2 Scraper ===\n');
const existing = loadExisting();
const fetchedIds = new Set(existing?.exercises.map((e: any) => e.exerciseId) ?? []);
console.log('Fetching metadata...');
const metadata = existing?.metadata ?? await fetchMetadata();
if (!existing?.metadata) {
console.log(` ${metadata.bodyParts.length} body parts, ${metadata.equipments.length} equipments, ${metadata.muscles.length} muscles, ${metadata.exerciseTypes.length} exercise types\n`);
} else {
console.log(' using cached metadata\n');
}
let ids: string[];
if (existsSync(IDS_CACHE_PATH)) {
ids = JSON.parse(readFileSync(IDS_CACHE_PATH, 'utf-8'));
console.log(`Using cached exercise IDs (${ids.length})\n`);
} else {
console.log('Fetching exercise IDs...');
ids = await fetchAllIds();
writeFileSync(IDS_CACHE_PATH, JSON.stringify(ids));
console.log(` ${ids.length} total exercises\n`);
}
const remaining = ids.filter(id => !fetchedIds.has(id));
if (remaining.length === 0) {
console.log('All exercises already fetched!');
return;
}
console.log(`Fetching ${remaining.length} remaining details (${fetchedIds.size} already cached)...`);
const exercises = [...(existing?.exercises ?? [])];
for (const id of remaining) {
const detail = await apiFetch(`/exercises/${id}`);
exercises.push(detail.data);
saveToDisk(metadata, exercises);
if (exercises.length % 10 === 0 || exercises.length === ids.length) {
console.log(` ${exercises.length}/${ids.length} details fetched`);
}
await sleep(DELAY_MS);
}
console.log(`\nDone! ${exercises.length} exercises written to ${OUTPUT_PATH}`);
}
main().catch(err => {
console.error(err);
process.exit(1);
});

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1,12 +1,12 @@
<script>
import { page } from '$app/stores';
import { getExerciseById } from '$lib/data/exercises';
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
let { exerciseId } = $props();
const lang = $derived(detectFitnessLang($page.url.pathname));
const exercise = $derived(getExerciseById(exerciseId, lang));
const exercise = $derived(getEnrichedExerciseById(exerciseId, lang));
const sl = $derived(fitnessSlugs(lang));
</script>

View File

@@ -1,5 +1,6 @@
<script>
import { getFilterOptions, searchExercises, translateTerm } from '$lib/data/exercises';
import { getFilterOptionsAll, searchAllExercises } from '$lib/data/exercisedb';
import { translateTerm } from '$lib/data/exercises';
import { Search, X } from '@lucide/svelte';
import { page } from '$app/stores';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
@@ -18,9 +19,9 @@
let bodyPartFilter = $state('');
let equipmentFilter = $state('');
const filterOptions = getFilterOptions();
const filterOptions = getFilterOptionsAll();
const filtered = $derived(searchExercises({
const filtered = $derived(searchAllExercises({
search: query || undefined,
bodyPart: bodyPartFilter || undefined,
equipment: equipmentFilter || undefined,

View File

@@ -0,0 +1,260 @@
<script>
import { onMount } from 'svelte';
import frontSvgRaw from '$lib/assets/muscle-front.svg?raw';
import backSvgRaw from '$lib/assets/muscle-back.svg?raw';
let { selectedGroups = $bindable([]), lang = 'en', split = false } = $props();
const isEn = $derived(lang === 'en');
const FRONT_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'front-shoulders': { groups: ['anterior deltoids', 'lateral deltoids'], label: { en: 'Front Delts', de: 'Vord. Schultern' } },
'chest': { groups: ['pectorals'], label: { en: 'Chest', de: 'Brust' } },
'biceps': { groups: ['biceps', 'brachioradialis'], label: { en: 'Biceps', de: 'Bizeps' } },
'forearms': { groups: ['forearms'], label: { en: 'Forearms', de: 'Unterarme' } },
'abdominals': { groups: ['abdominals'], label: { en: 'Abs', de: 'Bauchmuskeln' } },
'obliques': { groups: ['obliques'], label: { en: 'Obliques', de: 'Seitl. Bauch' } },
'quads': { groups: ['quadriceps', 'hip flexors'], label: { en: 'Quads', de: 'Quadrizeps' } },
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
const BACK_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'traps-middle': { groups: ['traps'], label: { en: 'Mid Traps', de: 'Mittl. Trapez' } },
'rear-shoulders': { groups: ['rear deltoids', 'rotator cuff'], label: { en: 'Rear Delts', de: 'Hint. Schultern' } },
'lats': { groups: ['lats'], label: { en: 'Lats', de: 'Latissimus' } },
'triceps': { groups: ['triceps'], label: { en: 'Triceps', de: 'Trizeps' } },
'forearms': { groups: ['forearms'], label: { en: 'Forearms', de: 'Unterarme' } },
'lowerback': { groups: ['erector spinae'], label: { en: 'Lower Back', de: 'Rückenstrecker' } },
'glutes': { groups: ['glutes'], label: { en: 'Glutes', de: 'Gesäss' } },
'hamstrings': { groups: ['hamstrings'], label: { en: 'Hamstrings', de: 'Beinbeuger' } },
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** Check if a region's groups overlap with selectedGroups */
function isRegionSelected(groups) {
if (selectedGroups.length === 0) return false;
return groups.some(g => selectedGroups.includes(g));
}
/** Compute fill for a region based on selection state */
function regionFill(groups) {
if (isRegionSelected(groups)) return 'var(--color-primary)';
return 'var(--color-bg-tertiary)';
}
/** Inject fill styles into SVG string */
function injectFills(svgStr, map) {
let result = svgStr;
for (const [svgId, region] of Object.entries(map)) {
const fill = regionFill(region.groups);
const re = new RegExp(`(<g\\s+id="${svgId}")([^>]*>)`);
result = result.replace(re, `$1 style="--region-fill: ${fill}; cursor: pointer;"$2`);
}
return result;
}
const frontSvg = $derived(injectFills(frontSvgRaw, FRONT_MAP));
const backSvg = $derived(injectFills(backSvgRaw, BACK_MAP));
/** Currently hovered region for tooltip */
let hovered = $state(null);
let hoveredSide = $state('front');
const hoveredLabel = $derived.by(() => {
if (!hovered) return null;
return isEn ? hovered.label.en : hovered.label.de;
});
let frontEl = $state(null);
let backEl = $state(null);
/** Toggle a region's muscle groups in/out of selection */
function toggleRegion(region) {
const groups = region.groups;
const allSelected = groups.every(g => selectedGroups.includes(g));
if (allSelected) {
selectedGroups = selectedGroups.filter(g => !groups.includes(g));
} else {
const toAdd = groups.filter(g => !selectedGroups.includes(g));
selectedGroups = [...selectedGroups, ...toAdd];
}
}
function setupEvents(container, map, side) {
if (!container) return;
container.addEventListener('mouseover', (e) => {
const g = e.target.closest('g[id]');
if (g && map[g.id]) {
hovered = map[g.id];
hoveredSide = side;
g.classList.add('highlighted');
}
});
container.addEventListener('mouseout', (e) => {
const g = e.target.closest('g[id]');
if (g) g.classList.remove('highlighted');
});
container.addEventListener('mouseleave', () => {
hovered = null;
});
container.addEventListener('click', (e) => {
const g = e.target.closest('g[id]');
if (g && map[g.id]) {
toggleRegion(map[g.id]);
}
});
}
onMount(() => {
setupEvents(frontEl, FRONT_MAP, 'front');
setupEvents(backEl, BACK_MAP, 'back');
});
</script>
{#if split}
<div class="muscle-filter-split">
<div class="split-left">
<div class="figure">
<div class="svg-wrap" bind:this={frontEl}>
{@html frontSvg}
</div>
</div>
{#if hoveredLabel && hoveredSide === 'front'}
<div class="hover-label">{hoveredLabel}</div>
{:else if selectedGroups.length > 0}
<button class="clear-btn" onclick={() => selectedGroups = []}>
{isEn ? 'Clear' : 'Zurücksetzen'}
</button>
{/if}
</div>
<div class="split-right">
<div class="figure">
<div class="svg-wrap" bind:this={backEl}>
{@html backSvg}
</div>
</div>
{#if hoveredLabel && hoveredSide === 'back'}
<div class="hover-label">{hoveredLabel}</div>
{/if}
</div>
</div>
{:else}
<div class="muscle-filter">
<div class="body-figures">
<div class="figure">
<div class="svg-wrap" bind:this={frontEl}>
{@html frontSvg}
</div>
</div>
<div class="figure">
<div class="svg-wrap" bind:this={backEl}>
{@html backSvg}
</div>
</div>
</div>
{#if hoveredLabel}
<div class="hover-label">{hoveredLabel}</div>
{:else if selectedGroups.length > 0}
<button class="clear-btn" onclick={() => selectedGroups = []}>
{isEn ? 'Clear filter' : 'Filter zurücksetzen'}
</button>
{/if}
</div>
{/if}
<style>
.muscle-filter {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
}
.body-figures {
display: flex;
gap: 0.5rem;
justify-content: center;
width: 100%;
}
.figure {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
max-width: 150px;
}
.svg-wrap {
width: 100%;
}
.svg-wrap :global(svg) {
width: 100%;
height: auto;
display: block;
}
.svg-wrap :global(g:not(#body):not(#head) path) {
fill: var(--region-fill, var(--color-bg-tertiary));
stroke: var(--color-text-primary);
stroke-width: 0.5;
stroke-linejoin: round;
transition: fill 0.15s, stroke 0.15s, stroke-width 0.15s;
}
.svg-wrap :global(g.highlighted:not(#body):not(#head) path) {
fill: color-mix(in srgb, var(--region-fill, var(--color-bg-tertiary)), var(--color-primary) 40%);
stroke: var(--color-primary);
stroke-width: 3;
}
.svg-wrap :global(#body path),
.svg-wrap :global(#body line) {
stroke: var(--color-text-primary) !important;
}
.hover-label {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-primary);
text-align: center;
}
.clear-btn {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.7rem;
font-weight: 600;
cursor: pointer;
padding: 0.2rem 0.5rem;
}
.clear-btn:hover {
text-decoration: underline;
}
/* Split mode: two independent columns for parent to position */
.muscle-filter-split {
display: contents;
}
.split-left, .split-right {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
}
.split-left .figure, .split-right .figure {
max-width: none;
}
</style>

View File

@@ -0,0 +1,311 @@
<script>
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { detectFitnessLang } from '$lib/js/fitnessI18n';
import frontSvgRaw from '$lib/assets/muscle-front.svg?raw';
import backSvgRaw from '$lib/assets/muscle-back.svg?raw';
let { data } = $props();
const lang = $derived(detectFitnessLang($page.url.pathname));
const isEn = $derived(lang === 'en');
const totals = $derived(data?.totals ?? {});
const FRONT_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'front-shoulders': { groups: ['anterior deltoids', 'lateral deltoids'], label: { en: 'Front Delts', de: 'Vord. Schultern' } },
'chest': { groups: ['pectorals'], label: { en: 'Chest', de: 'Brust' } },
'biceps': { groups: ['biceps', 'brachioradialis'], label: { en: 'Biceps', de: 'Bizeps' } },
'forearms': { groups: ['forearms'], label: { en: 'Forearms', de: 'Unterarme' } },
'abdominals': { groups: ['abdominals'], label: { en: 'Abs', de: 'Bauchmuskeln' } },
'obliques': { groups: ['obliques'], label: { en: 'Obliques', de: 'Seitl. Bauch' } },
'quads': { groups: ['quadriceps', 'hip flexors'], label: { en: 'Quads', de: 'Quadrizeps' } },
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
const BACK_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'traps-middle': { groups: ['traps'], label: { en: 'Mid Traps', de: 'Mittl. Trapez' } },
'rear-shoulders': { groups: ['rear deltoids', 'rotator cuff'], label: { en: 'Rear Delts', de: 'Hint. Schultern' } },
'lats': { groups: ['lats'], label: { en: 'Lats', de: 'Latissimus' } },
'triceps': { groups: ['triceps'], label: { en: 'Triceps', de: 'Trizeps' } },
'forearms': { groups: ['forearms'], label: { en: 'Forearms', de: 'Unterarme' } },
'lowerback': { groups: ['erector spinae'], label: { en: 'Lower Back', de: 'Rückenstrecker' } },
'glutes': { groups: ['glutes'], label: { en: 'Glutes', de: 'Gesäss' } },
'hamstrings': { groups: ['hamstrings'], label: { en: 'Hamstrings', de: 'Beinbeuger' } },
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** Sum weeklyAvg across all muscle groups for a region */
function regionScore(groups) {
let score = 0;
for (const g of groups) {
score += totals[g]?.weeklyAvg ?? 0;
}
return score;
}
/** Max score across all regions for color scaling */
const maxScore = $derived.by(() => {
let max = 1;
for (const r of [...Object.values(FRONT_MAP), ...Object.values(BACK_MAP)]) {
const s = regionScore(r.groups);
if (s > max) max = s;
}
return max;
});
/** Compute fill as a color-mix CSS value — resolved natively by the browser */
function scoreFill(score) {
if (score === 0) return 'var(--color-bg-tertiary)';
const pct = Math.round(Math.min(score / maxScore, 1) * 100);
return `color-mix(in srgb, var(--color-bg-tertiary), var(--color-primary) ${pct}%)`;
}
/**
* Preprocess an SVG string: inject fill styles into each muscle group.
* Replaces `<g id="groupId">` with `<g id="groupId" style="...">`.
*/
function injectFills(svgStr, map) {
let result = svgStr;
for (const [svgId, region] of Object.entries(map)) {
const fill = scoreFill(regionScore(region.groups));
// Match <g id="svgId"> or <g id="svgId" ...>
const re = new RegExp(`(<g\\s+id="${svgId}")([^>]*>)`);
result = result.replace(re, `$1 style="--region-fill: ${fill}; cursor: pointer;"$2`);
}
return result;
}
/** Reactively build SVG strings with fills baked in */
const frontSvg = $derived(injectFills(frontSvgRaw, FRONT_MAP));
const backSvg = $derived(injectFills(backSvgRaw, BACK_MAP));
/** Currently selected region info */
let selected = $state(null);
const selectedInfo = $derived.by(() => {
if (!selected) return null;
const label = isEn ? selected.label.en : selected.label.de;
let totalPrimary = 0, totalSecondary = 0, weeklyAvg = 0;
for (const g of selected.groups) {
totalPrimary += totals[g]?.primary ?? 0;
totalSecondary += totals[g]?.secondary ?? 0;
weeklyAvg += totals[g]?.weeklyAvg ?? 0;
}
return { label, weeklyAvg, totalPrimary, totalSecondary };
});
const hasData = $derived(Object.keys(totals).length > 0);
/** DOM refs for event delegation */
let frontEl = $state(null);
let backEl = $state(null);
function setupEvents(container, map) {
if (!container) return;
container.addEventListener('mouseover', (e) => {
const g = e.target.closest('g[id]');
if (g && map[g.id]) {
selected = { ...map[g.id], svgId: g.id };
g.classList.add('highlighted');
}
});
container.addEventListener('mouseout', (e) => {
const g = e.target.closest('g[id]');
if (g) g.classList.remove('highlighted');
});
container.addEventListener('mouseleave', () => {
selected = null;
});
container.addEventListener('click', (e) => {
const g = e.target.closest('g[id]');
if (g && map[g.id]) {
selected = { ...map[g.id], svgId: g.id };
}
});
}
onMount(() => {
setupEvents(frontEl, FRONT_MAP);
setupEvents(backEl, BACK_MAP);
});
</script>
{#if hasData}
<div class="body-map">
<div class="body-figures">
<div class="figure">
<span class="figure-label">{isEn ? 'Front' : 'Vorne'}</span>
<div class="svg-wrap" bind:this={frontEl}>
{@html frontSvg}
</div>
</div>
<div class="figure">
<span class="figure-label">{isEn ? 'Back' : 'Hinten'}</span>
<div class="svg-wrap" bind:this={backEl}>
{@html backSvg}
</div>
</div>
</div>
{#if selectedInfo}
<div class="muscle-info">
<span class="info-label">{selectedInfo.label}</span>
<span class="info-score">{selectedInfo.weeklyAvg.toFixed(1)} {isEn ? 'sets/wk' : 'Sätze/Wo'}</span>
<span class="info-detail">
{selectedInfo.totalPrimary}&times; {isEn ? 'primary' : 'primär'}
&middot;
{selectedInfo.totalSecondary}&times; {isEn ? 'secondary' : 'sekundär'}
</span>
</div>
{:else}
<div class="muscle-info hint">
<span class="info-hint">{isEn ? 'Tap a muscle to see details' : 'Muskel antippen für Details'}</span>
</div>
{/if}
<div class="map-legend">
<span class="legend-lo">0</span>
<div class="legend-gradient"></div>
<span class="legend-hi">{Math.round(maxScore)} {isEn ? 'sets/wk' : 'Sätze/Wo'}</span>
</div>
</div>
{:else}
<p class="empty">{isEn ? 'No workout data yet' : 'Noch keine Trainingsdaten'}</p>
{/if}
<style>
.body-map {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
.body-figures {
display: flex;
gap: 0.5rem;
justify-content: center;
width: 100%;
}
.figure {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex: 1;
max-width: 220px;
}
.figure-label {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-primary);
}
.svg-wrap {
width: 100%;
}
.svg-wrap :global(svg) {
width: 100%;
height: auto;
display: block;
}
/* Muscle region fills: use the CSS variable injected per-group */
.svg-wrap :global(g:not(#body):not(#head) path) {
fill: var(--region-fill, var(--color-bg-tertiary));
stroke: var(--color-text-primary);
stroke-width: 0.5;
stroke-linejoin: round;
transition: stroke 0.15s, stroke-width 0.15s;
}
/* Highlight on hover */
.svg-wrap :global(g.highlighted path) {
stroke: var(--color-primary);
stroke-width: 3;
}
/* Body wireframe outline */
.svg-wrap :global(#body path),
.svg-wrap :global(#body line) {
stroke: var(--color-text-primary) !important;
}
/* Selected muscle info panel */
.muscle-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--color-bg-tertiary);
border-radius: 8px;
min-height: 2rem;
width: 100%;
justify-content: center;
flex-wrap: wrap;
}
.muscle-info.hint {
opacity: 0.6;
}
.info-label {
font-weight: 700;
font-size: 0.8rem;
}
.info-score {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-primary);
}
.info-detail {
font-size: 0.7rem;
color: var(--color-text-secondary);
}
.info-hint {
font-size: 0.75rem;
color: var(--color-text-primary);
}
/* Legend */
.map-legend {
display: flex;
align-items: center;
gap: 0.35rem;
align-self: flex-end;
}
.legend-lo, .legend-hi {
font-size: 0.55rem;
color: var(--color-text-primary);
}
.legend-gradient {
width: 60px;
height: 8px;
border-radius: 4px;
background: linear-gradient(to right, var(--color-bg-tertiary), var(--color-primary));
}
.empty {
text-align: center;
color: var(--color-text-secondary);
padding: 1.5rem 0;
font-size: 0.85rem;
}
</style>

View File

@@ -0,0 +1,59 @@
<script>
import { X } from '@lucide/svelte';
let { src, poster = '', onClose } = $props();
function handleKeydown(e) {
if (e.key === 'Escape') onClose();
}
function handleBackdrop(e) {
if (e.target === e.currentTarget) onClose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="video-overlay" onclick={handleBackdrop}>
<button class="close-btn" onclick={onClose} aria-label="Close video">
<X size={24} />
</button>
<!-- svelte-ignore a11y_media_has_caption -->
<video autoplay controls playsinline {poster}>
<source src={src} type="video/mp4" />
</video>
</div>
<style>
.video-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: white;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s;
z-index: 1;
}
.close-btn:hover {
opacity: 1;
}
video {
max-width: 100%;
max-height: 90vh;
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1 @@
["exr_41n2ha5iPFpN3hEJ", "exr_41n2haAabPyN5t8y", "exr_41n2hadPLLFRGvFk", "exr_41n2hadQgEEX8wDN", "exr_41n2haNJ3NA8yCE2", "exr_41n2havo95Y2QpkW", "exr_41n2hbdZww1thMKz", "exr_41n2hbLX4XH8xgN7", "exr_41n2hbYPY4jLKxW3", "exr_41n2hc2VrB8ofxrW", "exr_41n2hcFJpBvAkXCP", "exr_41n2hcm5HH6H684G", "exr_41n2hcw2FN534HcA", "exr_41n2hd6SThQhAdnZ", "exr_41n2hd78zujKUEWK", "exr_41n2hdCBvmbCPaVE", "exr_41n2hdHtZrMPkcqY", "exr_41n2hdkBpqwoDmVq", "exr_41n2hdo2vCtq4F3E", "exr_41n2hdsGcuzs4WrV", "exr_41n2hdWu3oaCGdWT", "exr_41n2he2doZNpmXkX", "exr_41n2hek6i3exMARx", "exr_41n2hezAZ6CdkAcM", "exr_41n2hfa11fPnk8y9", "exr_41n2hfnnXz9shkBi", "exr_41n2hftBVLiXgtRQ", "exr_41n2hfYD2sH4TRCH", "exr_41n2hG9pRT55cGVk", "exr_41n2hGbCptD8Nosk", "exr_41n2hgCHNgtVLHna", "exr_41n2hGD4omjWVnbS", "exr_41n2hGioS8HumEF7", "exr_41n2hGNrmUnF58Yy", "exr_41n2hGRSg9WCoTYT", "exr_41n2hGUso7JFmuYR", "exr_41n2hGy6zE7fN6v2", "exr_41n2hH6VGNz6cNtv", "exr_41n2hhBHuvSdAeCJ", "exr_41n2hHCXQpZYhxhc", "exr_41n2hHdjQpnyNdie", "exr_41n2hHH9bNfi98YU", "exr_41n2hhiWL8njJDZe", "exr_41n2hHLE8aJXaxKR", "exr_41n2hHRszDHarrxK", "exr_41n2hhumxqyAFuTb", "exr_41n2hJ5Harig7K7F", "exr_41n2hJFwC7ocdiNm", "exr_41n2hjkBReJMbDJk", "exr_41n2hjuGpcex14w7", "exr_41n2hk3YSCjnZ9um", "exr_41n2hkB3FeGM3DEL", "exr_41n2hkCHzg1AXdkV", "exr_41n2hKiaWSZQTqgE", "exr_41n2hkK8hGAcSnW7", "exr_41n2hkknYAEEE3tc", "exr_41n2hkmMrSwcHkZ8", "exr_41n2hKoQnnSRPZrE", "exr_41n2hKZmyYXB2UL4", "exr_41n2hLA8xydD4dzE", "exr_41n2hLpyk6MsV85U", "exr_41n2hLUqpev5gSzJ", "exr_41n2hLx2rvhz95GC", "exr_41n2hLZZAH2F2UkS", "exr_41n2hM8vgMA6MREd", "exr_41n2hmbfYcYtedgz", "exr_41n2hmFcGGUCS289", "exr_41n2hmGR8WuVfe1U", "exr_41n2hmhb4jD7H8Qk", "exr_41n2hmhxk35fbHbC", "exr_41n2hMRXm49mM62z", "exr_41n2hmvGdVRvvnNY", "exr_41n2hMydkzFvswVX", "exr_41n2hMZCmZBvQApL", "exr_41n2hn2kPMag9WCf", "exr_41n2hN468sP27Sac", "exr_41n2hn8rpbYihzEW", "exr_41n2hnAGfMhp95LQ", "exr_41n2hnbt5GwwY7gr", "exr_41n2hNCTCWtWAqzH", "exr_41n2hndkoGHD1ogh", "exr_41n2hNfaYkEKLQHK", "exr_41n2hnFD2bT6sruf", "exr_41n2hNifNwh2tbR2", "exr_41n2hNjcmNgtPJ1H", "exr_41n2hnougzKKhhqu", "exr_41n2hNRM1dGGhGYL", "exr_41n2hnx1hnDdketU", "exr_41n2hNXJadYcfjnd", "exr_41n2hoFGGwZDNFT1", "exr_41n2hoifHqpb7WK9", "exr_41n2homrPqqs8coG", "exr_41n2howQHvcrcrW6", "exr_41n2hoyHUrhBiEWg", "exr_41n2hozyXuCmDTdZ", "exr_41n2hpDWoTxocW8G", "exr_41n2hpeHAizgtrEw", "exr_41n2hPgRbN1KtJuD", "exr_41n2hpJxS5VQKtBL", "exr_41n2hpLLs1uU5atr", "exr_41n2hpnZ6oASM662", "exr_41n2hPRWorCfCPov", "exr_41n2hpTMDhTxYkvi", "exr_41n2hPvwqp7Pwvks", "exr_41n2hPxDaq9kFjiL", "exr_41n2hq3Wm6ANkgUz", "exr_41n2hQcqPQ37Dmxj", "exr_41n2hQDiSwTZXM4F", "exr_41n2hQeNyCnt3uFh", "exr_41n2hQEqKxuAfV1D", "exr_41n2hqfZb8UHBvB9", "exr_41n2hQHmRSoUkk9F", "exr_41n2hqjVS3nwBoyr", "exr_41n2hQp1pR7heQq9", "exr_41n2hQtaWxPLNFwX", "exr_41n2hqw5LsDpeE2i", "exr_41n2hqYdxG87hXz1", "exr_41n2hR12rPqdpWP8", "exr_41n2hRaLxY7YfNbg", "exr_41n2hRH4aTB379Tp", "exr_41n2hrHSqBnVWRRB", "exr_41n2hRicz5MdZEns", "exr_41n2hrN2RCZBZU9h", "exr_41n2hrSQZRD4yG7P", "exr_41n2hRZ6fgsLyd77", "exr_41n2hS5UrMusVCEE", "exr_41n2hs6camM22yBG", "exr_41n2hsBtDXapcADg", "exr_41n2hSF5U97sFAr8", "exr_41n2hSGs9Q3NVGhs", "exr_41n2hSkc2tRLxVVS", "exr_41n2hskeb9dXgBoC", "exr_41n2hSMjcavNjk3c", "exr_41n2hSq88Ni3KCny", "exr_41n2hsSnmS946i2k", "exr_41n2hSvEPVntpxSG", "exr_41n2hsVHu7B1MTdr", "exr_41n2hSxsNAV8tGS6", "exr_41n2hsZWJA1ujZUd", "exr_41n2hTaeNKWhMQHH", "exr_41n2hTCBiQVsEfZ7", "exr_41n2hTkfLWpc57BQ", "exr_41n2hTs4q3ihihZs", "exr_41n2htTnk4CuspZh", "exr_41n2htzPyjcc3Mt2", "exr_41n2hU3XPwUFSpkC", "exr_41n2hU4y6EaYXFhr", "exr_41n2hu5r8WMaLUkH", "exr_41n2hUBVSgXaKhau", "exr_41n2huc12BsuDNYQ", "exr_41n2hUDuvCas2EB3", "exr_41n2huf7mAC2rhfC", "exr_41n2hUKc7JPrtJQj", "exr_41n2hupxPcdnktBC", "exr_41n2huQw1pHKH9cw", "exr_41n2hushK9NGVfyK", "exr_41n2hUVNhvcS73Dt", "exr_41n2huXeEFSaqo4G", "exr_41n2hVCJfpAvJcdU", "exr_41n2hvg2FRT5XMyJ", "exr_41n2hvjrFJ2KjzGm", "exr_41n2hvrsUaWWb9Mk", "exr_41n2hvzxocyjoGgL", "exr_41n2hw1QspZ6uXoW", "exr_41n2hw4iksLYXESz", "exr_41n2hw8nSYiaCXW1", "exr_41n2hW9gDXAJJMmH", "exr_41n2hWbP5uF6PQpU", "exr_41n2hWgAAtQeA3Lh", "exr_41n2hwio5ECAfLuS", "exr_41n2hwoc6PkW1UJJ", "exr_41n2hWQVZanCB1d7", "exr_41n2hWVVEwU54UtF", "exr_41n2hWxnJoGwbJpa", "exr_41n2hx6oyEujP1B6", "exr_41n2hx9wyaRGNyvs", "exr_41n2hXfpvSshoXWG", "exr_41n2hxg75dFGERdp", "exr_41n2hxnFMotsXTj3", "exr_41n2hxqpSU5p6DZv", "exr_41n2hXQw5yAbbXL8", "exr_41n2hXszY7TgwKy4", "exr_41n2hXvPyEyMBgNR", "exr_41n2hxxePSdr5oN1", "exr_41n2hXXpvbykPY3q", "exr_41n2hXYRxFHnQAD4", "exr_41n2hy8pKXtzuBh8", "exr_41n2hY9EdwkdGz9a", "exr_41n2hYAP9oGEZk2P", "exr_41n2hynD9srC1kY7", "exr_41n2hyNf5GebszTf", "exr_41n2hyWsNxNYWpk3", "exr_41n2hYWXejezzLjv", "exr_41n2hYXWwoxiUk57", "exr_41n2hZ7uoN5JnUJY", "exr_41n2hzAMXkkQQ5T2", "exr_41n2hzfRXQDaLYJh", "exr_41n2hZqkvM55qJve", "exr_41n2hZqwsLkCVnzr", "exr_41n2hzZBVbWFoLK3"]

View File

@@ -0,0 +1,219 @@
/**
* 1:1 mapping from ExerciseDB v2 exercise IDs to internal kebab-case slugs.
*
* - Entries that match an existing exercise in exercises.ts reuse that ID.
* - All other entries get a new slug derived from the ExerciseDB name.
*
* Regenerate with: scripts/scrape-exercises.ts (source data)
*/
export const exerciseDbMap: Record<string, string> = {
// ── Matched to existing internal exercises ────────────────────────
'exr_41n2hxnFMotsXTj3': 'bench-press-barbell', // Bench Press (BARBELL)
'exr_41n2hpLLs1uU5atr': 'bulgarian-split-squat-dumbbell', // Bulgarian Split Squat
'exr_41n2hSGs9Q3NVGhs': 'calf-raise-standing', // Bodyweight Standing Calf Raise
'exr_41n2hkK8hGAcSnW7': 'chest-dip', // Chest Dip
'exr_41n2hXszY7TgwKy4': 'chin-up', // Chin-Up
'exr_41n2hskeb9dXgBoC': 'crunch', // Crunch Floor
'exr_41n2hY9EdwkdGz9a': 'dumbbell-row', // Dumbbell One Arm Bent-over Row
'exr_41n2howQHvcrcrW6': 'front-raise-dumbbell', // Front Raise (DUMBBELL)
'exr_41n2hQDiSwTZXM4F': 'goblet-squat-dumbbell', // Goblet Squat (DUMBBELL)
'exr_41n2hxxePSdr5oN1': 'hip-thrust-barbell', // Hip Thrusts
'exr_41n2hfYD2sH4TRCH': 'hyperextension', // Hyperextension
'exr_41n2hN468sP27Sac': 'jump-rope', // Jump Rope (ROPE)
'exr_41n2hjuGpcex14w7': 'lateral-raise-dumbbell', // Lateral Raise (DUMBBELL)
'exr_41n2hYXWwoxiUk57': 'lunge-dumbbell', // Lunge
'exr_41n2hs6camM22yBG': 'overhead-press-dumbbell', // Seated Shoulder Press (DUMBBELL)
'exr_41n2hXQw5yAbbXL8': 'plank', // Front Plank
'exr_41n2hsBtDXapcADg': 'pull-up', // Pull-up
'exr_41n2hNXJadYcfjnd': 'push-up', // Push-up
'exr_41n2hyNf5GebszTf': 'reverse-fly-dumbbell', // Dumbbell Rear Delt Fly
'exr_41n2hn8rpbYihzEW': 'romanian-deadlift-dumbbell', // Romanian Deadlift (DUMBBELL)
'exr_41n2hWVVEwU54UtF': 'russian-twist', // Russian Twist
'exr_41n2hdHtZrMPkcqY': 'skullcrusher-dumbbell', // Dumbbell Lying Floor Skull Crusher
'exr_41n2hadQgEEX8wDN': 'tricep-dip', // Triceps Dip
// ── New slugs (no existing internal match) ───────────────────────
'exr_41n2hRH4aTB379Tp': '45-degree-hyperextension', // 45 degree hyperextension
'exr_41n2hqfZb8UHBvB9': 'alternate-lying-floor-leg-raise', // Alternate Lying Floor Leg Raise
'exr_41n2hezAZ6CdkAcM': 'alternate-punching', // Alternate Punching
'exr_41n2hNifNwh2tbR2': 'ankle-dorsal-flexion-articulations', // Ankle - Dorsal Flexion - Articulations
'exr_41n2he2doZNpmXkX': 'ankle-plantar-flexion-articulations', // Ankle - Plantar Flexion - Articulations
'exr_41n2hMRXm49mM62z': 'arnold-press', // Arnold Press (DUMBBELL)
'exr_41n2hmhb4jD7H8Qk': 'assault-bike-run', // Assault Bike Run (MACHINE)
'exr_41n2hQeNyCnt3uFh': 'back-pec-stretch', // Back Pec Stretch
'exr_41n2hwoc6PkW1UJJ': 'barbell-standing-calf-raise', // Barbell Standing Calf Raise
'exr_41n2hoFGGwZDNFT1': 'bench-dip-on-floor', // Bench dip on floor
'exr_41n2hxqpSU5p6DZv': 'biceps-leg-concentration-curl', // Biceps Leg Concentration Curl
'exr_41n2hTkfLWpc57BQ': 'body-up', // Body-Up
'exr_41n2hNjcmNgtPJ1H': 'bodyweight-rear-lunge', // Bodyweight Rear Lunge
'exr_41n2hrHSqBnVWRRB': 'bodyweight-single-leg-deadlift', // Bodyweight Single Leg Deadlift
'exr_41n2ha5iPFpN3hEJ': 'bridge-mountain-climber', // Bridge - Mountain Climber
'exr_41n2hmbfYcYtedgz': 'bridge-pose-setu-bandhasana', // Bridge Pose Setu Bandhasana
'exr_41n2hqYdxG87hXz1': 'burpee', // Burpee
'exr_41n2hZqwsLkCVnzr': 'butt-kicks', // Butt Kicks
'exr_41n2hnougzKKhhqu': 'butterfly-yoga-pose', // Butterfly Yoga Pose
'exr_41n2hn2kPMag9WCf': 'cable-seated-neck-extension', // Cable Seated Neck Extension (CABLE)
'exr_41n2hUDuvCas2EB3': 'cable-seated-neck-flexion', // Cable Seated Neck Flexion (CABLE)
'exr_41n2hcm5HH6H684G': 'calf-raise-from-deficit-chair', // Calf Raise from Deficit with Chair Supported
'exr_41n2hHCXQpZYhxhc': 'calf-stretch-wall', // Calf Stretch With Hands Against Wall
'exr_41n2hSkc2tRLxVVS': 'calf-stretch-rope', // Calf Stretch with Rope
'exr_41n2hG9pRT55cGVk': 'calves-stretch', // Calves stretch
'exr_41n2hGD4omjWVnbS': 'calves-stretch-standing', // Calves Stretch (variant)
'exr_41n2hd6SThQhAdnZ': 'chin-ups-neutral', // Chin-ups (variant)
'exr_41n2hWQVZanCB1d7': 'circles-arm', // Circles Arm
'exr_41n2hk3YSCjnZ9um': 'circles-knee-stretch', // Circles Knee Stretch
'exr_41n2hrSQZRD4yG7P': 'circles-knee-stretch-alt', // Circles Knee Stretch (variant)
'exr_41n2hkmMrSwcHkZ8': 'clap-push-up', // Clap Push Up
'exr_41n2hPgRbN1KtJuD': 'close-grip-push-up', // Close-grip Push-up
'exr_41n2hHLE8aJXaxKR': 'cobra-push-up', // Cobra Push-up
'exr_41n2hVCJfpAvJcdU': 'commando-pull-up', // Commando Pull-up
'exr_41n2hNCTCWtWAqzH': 'cow-stretch', // Cow Stretch
'exr_41n2hgCHNgtVLHna': 'cross-body-hammer-curl', // Cross Body Hammer Curl (DUMBBELL)
'exr_41n2hGUso7JFmuYR': 'decline-push-up', // Decline Push-Up
'exr_41n2hdCBvmbCPaVE': 'diamond-press', // Diamond Press
'exr_41n2hnbt5GwwY7gr': 'diamond-push-up', // Diamond Push up
'exr_41n2hRZ6fgsLyd77': 'diamond-push-up-alt', // Diamond Push-up (variant)
'exr_41n2hUKc7JPrtJQj': 'dip-on-floor-with-chair', // Dip on Floor with Chair
'exr_41n2hWbP5uF6PQpU': 'donkey-calf-raise', // Donkey Calf Raise
'exr_41n2hW9gDXAJJMmH': 'downward-facing-dog', // Downward Facing Dog
'exr_41n2hTaeNKWhMQHH': 'dumbbell-burpee', // Dumbbell burpee
'exr_41n2hXfpvSshoXWG': 'dumbbell-clean-and-press', // Dumbbell Clean and Press
'exr_41n2hZ7uoN5JnUJY': 'dumbbell-decline-one-arm-hammer-press', // Dumbbell Decline One Arm Hammer Press
'exr_41n2haNJ3NA8yCE2': 'dumbbell-incline-one-arm-hammer-press', // Dumbbell Incline One Arm Hammer Press
'exr_41n2huf7mAC2rhfC': 'dumbbell-jumping-squat', // Dumbbell Jumping Squat
'exr_41n2hbLX4XH8xgN7': 'dumbbell-single-leg-calf-raise', // Dumbbell Single Leg Calf Raise
'exr_41n2hQtaWxPLNFwX': 'dumbbell-standing-calf-raise', // Dumbbell Standing Calf Raise
'exr_41n2hTCBiQVsEfZ7': 'dumbbell-side-bend', // Dumbbell Side Bend
'exr_41n2hcw2FN534HcA': 'dumbbell-stiff-leg-deadlift', // Dumbbell Stiff Leg Deadlift
'exr_41n2hQp1pR7heQq9': 'dumbbell-stiff-leg-deadlift-alt', // Dumbbell Stiff Leg Deadlift (variant)
'exr_41n2hek6i3exMARx': 'elbow-flexion-articulations', // Elbow - Flexion - Articulations
'exr_41n2hqw5LsDpeE2i': 'elbow-flexor-stretch', // Elbow Flexor Stretch
'exr_41n2hy8pKXtzuBh8': 'elbow-up-and-down-dynamic-plank', // Elbow Up and Down Dynamic Plank
'exr_41n2hKZmyYXB2UL4': 'elbows-back-stretch', // Elbows Back Stretch
'exr_41n2hNfaYkEKLQHK': 'elevated-push-up', // Elevanted Push-Up
'exr_41n2hfa11fPnk8y9': 'elliptical-machine-walk', // Elliptical Machine Walk (MACHINE)
'exr_41n2hpTMDhTxYkvi': 'elliptical-machine-walk-alt', // Elliptical Machine Walk (variant)
'exr_41n2hH6VGNz6cNtv': 'extension-inclination-neck-stretch', // Extension And Inclination Neck Stretch
'exr_41n2hLx2rvhz95GC': 'feet-ankles-rotation-stretch', // Feet and Ankles Rotation Stretch
'exr_41n2hzZBVbWFoLK3': 'feet-ankles-side-to-side-stretch', // Feet and Ankles Side to Side Stretch
'exr_41n2havo95Y2QpkW': 'feet-ankles-stretch', // Feet and Ankles Stretch
'exr_41n2hnx1hnDdketU': 'feet-ankles-stretch-alt', // Feet and Ankles Stretch (variant)
'exr_41n2hPRWorCfCPov': 'flutter-kicks', // Flutter Kicks
'exr_41n2hvg2FRT5XMyJ': 'forearm-supination-articulations', // Forearm - Supination - Articulations
'exr_41n2hJFwC7ocdiNm': 'forward-flexion-neck-stretch', // Forward Flexion Neck Stretch
'exr_41n2huQw1pHKH9cw': 'front-and-back-neck-stretch', // Front and Back Neck Stretch
'exr_41n2htzPyjcc3Mt2': 'front-plank-toe-tap', // Front Plank Toe Tap
'exr_41n2hKoQnnSRPZrE': 'front-plank-with-leg-lift', // Front Plank with Leg Lift
'exr_41n2hkknYAEEE3tc': 'front-toe-touching', // Front Toe Touching
'exr_41n2hGioS8HumEF7': 'hammer-curl-cable', // Hammer Curl (CABLE)
'exr_41n2hushK9NGVfyK': 'handstand-push-up', // Handstand Push-Up
'exr_41n2hMZCmZBvQApL': 'hanging-straight-leg-raise', // Hanging Straight Leg Raise
'exr_41n2hXXpvbykPY3q': 'incline-push-up', // Incline Push-up
'exr_41n2hzAMXkkQQ5T2': 'inverted-chin-curl-bent-knee-chairs', // Inverted Chin Curl with Bent Knee between Chairs
'exr_41n2hUVNhvcS73Dt': 'jump-split', // Jump Split
'exr_41n2hbdZww1thMKz': 'jump-squat', // Jump Squat
'exr_41n2hupxPcdnktBC': 'jumping-jack', // Jumping Jack
'exr_41n2hGRSg9WCoTYT': 'jumping-pistol-squat', // Jumping Pistol Squat
'exr_41n2hpnZ6oASM662': 'kneeling-push-up-to-child-pose', // Kneeling Push up to Child Pose
'exr_41n2hSvEPVntpxSG': 'kneeling-rotational-push-up', // Kneeling Rotational Push-up
'exr_41n2hyWsNxNYWpk3': 'kneeling-toe-up-hamstring-stretch', // Kneeling Toe Up Hamstring Stretch
'exr_41n2hxg75dFGERdp': 'l-pull-up', // L-Pull-up
'exr_41n2hU3XPwUFSpkC': 'l-sit-on-floor', // L-Sit on Floor
'exr_41n2hS5UrMusVCEE': 'lateral-raise-towel', // Lateral Raise with Towel
'exr_41n2hM8vgMA6MREd': 'left-hook-boxing', // Left hook. Boxing
'exr_41n2hhiWL8njJDZe': 'lever-seated-calf-raise', // Lever Seated Calf Raise (MACHINE)
'exr_41n2hKiaWSZQTqgE': 'lever-stepper', // Lever stepper
'exr_41n2hc2VrB8ofxrW': 'lying-double-legs-biceps-curl-towel', // Lying Double Legs Biceps Curl with Towel
'exr_41n2hx9wyaRGNyvs': 'lying-double-legs-hammer-curl-towel', // Lying Double Legs Hammer Curl with Towel
'exr_41n2hq3Wm6ANkgUz': 'lying-leg-raise-and-hold', // Lying Leg Raise and Hold
'exr_41n2hfnnXz9shkBi': 'lying-lower-back-stretch', // Lying Lower Back Stretch
'exr_41n2hpJxS5VQKtBL': 'lying-lower-back-stretch-alt', // Lying Lower Back Stretch (variant)
'exr_41n2hkB3FeGM3DEL': 'lying-scissor-kick', // Lying Scissor Kick
'exr_41n2hhBHuvSdAeCJ': 'neck-circle-stretch', // Neck Circle Stretch
'exr_41n2hGbCptD8Nosk': 'neck-side-stretch', // Neck Side Stretch
'exr_41n2hvrsUaWWb9Mk': 'neck-side-stretch-alt', // Neck Side Stretch (variant)
'exr_41n2hHdjQpnyNdie': 'one-arm-bent-over-row', // One Arm Bent-over Row (DUMBBELL)
'exr_41n2hpeHAizgtrEw': 'one-arm-reverse-wrist-curl', // One arm Revers Wrist Curl (DUMBBELL)
'exr_41n2hGy6zE7fN6v2': 'one-arm-wrist-curl', // One arm Wrist Curl (DUMBBELL)
'exr_41n2hhumxqyAFuTb': 'one-leg-floor-calf-raise', // One Leg Floor Calf Raise
'exr_41n2hsVHu7B1MTdr': 'palms-in-incline-bench-press', // Palms In Incline Bench Press (DUMBBELL)
'exr_41n2hbYPY4jLKxW3': 'peroneals-stretch', // Peroneals Stretch
'exr_41n2hmvGdVRvvnNY': 'pike-push-up', // Pike Push up
'exr_41n2hR12rPqdpWP8': 'pike-to-cobra-push-up', // Pike-to-Cobra Push-up
'exr_41n2hU4y6EaYXFhr': 'pull-up-alt', // Pull up (variant)
'exr_41n2hQEqKxuAfV1D': 'pull-up-bent-knee-chairs', // Pull-up with Bent Knee between Chairs
'exr_41n2hmhxk35fbHbC': 'push-up-on-forearms', // Push-up on Forearms
'exr_41n2hSMjcavNjk3c': 'quick-feet', // Quick Feet
'exr_41n2hGNrmUnF58Yy': 'reverse-lunge', // Reverse Lunge
'exr_41n2hdo2vCtq4F3E': 'right-cross-boxing', // Right Cross. Boxing
'exr_41n2hwio5ECAfLuS': 'right-hook-boxing', // Right Hook. Boxing
'exr_41n2htTnk4CuspZh': 'rotating-neck-stretch', // Rotating Neck Stretch
'exr_41n2hjkBReJMbDJk': 'run-on-treadmill', // Run on Treadmill (MACHINE)
'exr_41n2hpDWoTxocW8G': 'scissors', // Scissors
'exr_41n2hTs4q3ihihZs': 'seated-calf-raise-barbell', // Seated Calf Raise (BARBELL)
'exr_41n2hNRM1dGGhGYL': 'seated-flexion-extension-neck', // Seated Flexion And Extension Neck
'exr_41n2hcFJpBvAkXCP': 'seated-row-towel', // Seated Row with Towel
'exr_41n2hJ5Harig7K7F': 'seated-shoulder-flexor-stretch', // Seated Shoulder Flexor Depresor Retractor Stretch
'exr_41n2hw4iksLYXESz': 'seated-shoulder-flexor-stretch-bent-knee', // Seated Shoulder Flexor ... Bent Knee
'exr_41n2hPvwqp7Pwvks': 'seated-single-leg-hamstring-stretch', // Seated Single Leg Hamstring Stretch
'exr_41n2hu5r8WMaLUkH': 'seated-straight-leg-calf-stretch', // Seated Straight Leg Calf Stretch
'exr_41n2hdsGcuzs4WrV': 'self-assisted-inverted-pullover', // Self Assisted Inverted Pullover
'exr_41n2hZqkvM55qJve': 'shoulder-stretch-behind-back', // Shoulder Stretch Behind the Back
'exr_41n2hnAGfMhp95LQ': 'shoulder-tap-push-up', // Shoulder Tap Push-up
'exr_41n2haAabPyN5t8y': 'side-lunge', // Side Lunge
'exr_41n2hRaLxY7YfNbg': 'side-lunge-stretch', // Side Lunge Stretch
'exr_41n2hrN2RCZBZU9h': 'side-neck-stretch', // Side Neck Stretch
'exr_41n2hsZWJA1ujZUd': 'side-push-neck-stretch', // Side Push Neck Stretch
'exr_41n2hw1QspZ6uXoW': 'side-toe-touching', // Side Toe Touching
'exr_41n2hMydkzFvswVX': 'side-two-front-toe-touching', // Side Two Front Toe Touching
'exr_41n2hPxDaq9kFjiL': 'side-wrist-pull-stretch', // Side Wrist Pull Stretch
'exr_41n2hLpyk6MsV85U': 'single-leg-deadlift-arm-extended', // Single Leg Bodyweight Deadlift with Arm and Leg Extended
'exr_41n2hd78zujKUEWK': 'single-leg-squat', // Single Leg Squat
'exr_41n2hsSnmS946i2k': 'single-leg-squat-with-support', // Single Leg Squat with Support
'exr_41n2hw8nSYiaCXW1': 'sissy-squat', // Sissy Squat
'exr_41n2hvjrFJ2KjzGm': 'sit', // Sit
'exr_41n2hnFD2bT6sruf': 'sliding-floor-bridge-curl-towel', // Sliding Floor Bridge Curl on Towel
'exr_41n2hadPLLFRGvFk': 'sliding-floor-pulldown-towel', // Sliding Floor Pulldown on Towel
'exr_41n2hSxsNAV8tGS6': 'split-squat', // Split Squat
'exr_41n2hHRszDHarrxK': 'split-squats', // Split Squats (variant)
'exr_41n2hmGR8WuVfe1U': 'squat', // Squat (bodyweight)
'exr_41n2hRicz5MdZEns': 'squat-thrust', // Squat Thrust
'exr_41n2homrPqqs8coG': 'stair-up', // Stair Up
'exr_41n2hSq88Ni3KCny': 'standing-alternate-arms-circling', // Standing Alternate Arms Circling
'exr_41n2hUBVSgXaKhau': 'standing-arms-circling', // Standing Arms Circling
'exr_41n2hynD9srC1kY7': 'standing-arms-flinging', // Standing Arms Flinging
'exr_41n2hzfRXQDaLYJh': 'standing-calf-raise', // Standing Calf Raise (bodyweight)
'exr_41n2hdWu3oaCGdWT': 'standing-calf-raise-dumbbell', // Standing Calf Raise (DUMBBELL)
'exr_41n2hvzxocyjoGgL': 'standing-leg-calf-raise-barbell', // Standing Leg Calf Raise (BARBELL)
'exr_41n2huXeEFSaqo4G': 'standing-one-arm-circling', // Standing One Arm Circling
'exr_41n2hXYRxFHnQAD4': 'stationary-bike-run', // Stationary Bike Run (MACHINE)
'exr_41n2hXvPyEyMBgNR': 'step-up-on-chair', // Step-up on Chair
'exr_41n2hYAP9oGEZk2P': 'sumo-squat', // Sumo Squat
'exr_41n2hWxnJoGwbJpa': 'superman-row-towel', // Superman Row with Towel
'exr_41n2hoifHqpb7WK9': 'supination-bar-suspension-stretch', // Supination Bar Suspension Stretch
'exr_41n2hdkBpqwoDmVq': 'suspended-row', // Suspended Row
'exr_41n2hLUqpev5gSzJ': 'thoracic-bridge', // Thoracic Bridge
'exr_41n2hndkoGHD1ogh': 'tricep-dip-alt', // Triceps Dip (variant)
'exr_41n2hHH9bNfi98YU': 'triceps-dips-floor', // Triceps Dips Floor
'exr_41n2hLA8xydD4dzE': 'triceps-press', // Triceps Press
'exr_41n2hYWXejezzLjv': 'two-front-toe-touching', // Two Front Toe Touching
'exr_41n2hqjVS3nwBoyr': 'two-legs-hammer-curl-towel', // Two Legs Hammer Curl with Towel
'exr_41n2hx6oyEujP1B6': 'two-legs-reverse-biceps-curl-towel', // Two Legs Reverse Biceps Curl with Towel
'exr_41n2huc12BsuDNYQ': 'v-up', // V-up
'exr_41n2hLZZAH2F2UkS': 'walk-elliptical-cross-trainer', // Walk Elliptical Cross Trainer (MACHINE)
'exr_41n2hmFcGGUCS289': 'walking-high-knees-lunge', // Walking High Knees Lunge
'exr_41n2hQHmRSoUkk9F': 'walking-lunge', // Walking Lunge
'exr_41n2hkCHzg1AXdkV': 'walking-on-incline-treadmill', // Walking on Incline Treadmill (MACHINE)
'exr_41n2hoyHUrhBiEWg': 'walking-on-treadmill', // Walking on Treadmill (MACHINE)
'exr_41n2hftBVLiXgtRQ': 'wide-grip-pull-up', // Wide Grip Pull-Up
'exr_41n2hWgAAtQeA3Lh': 'wide-hand-push-up', // Wide Hand Push up
'exr_41n2hozyXuCmDTdZ': 'wrist-circles', // Wrist Circles
'exr_41n2hSF5U97sFAr8': 'wrist-extension-articulations', // Wrist - Extension - Articulations
'exr_41n2hQcqPQ37Dmxj': 'wrist-flexion-articulations', // Wrist - Flexion - Articulations
};
/** Reverse lookup: internal slug → ExerciseDB ID */
export const slugToExerciseDbId: Record<string, string> = Object.fromEntries(
Object.entries(exerciseDbMap).map(([edbId, slug]) => [slug, edbId])
);

File diff suppressed because it is too large Load Diff

278
src/lib/data/exercisedb.ts Normal file
View File

@@ -0,0 +1,278 @@
/**
* ExerciseDB enrichment layer.
* Merges the static exercises.ts catalog with ExerciseDB v2 data
* to provide a unified, enriched exercise set.
*/
import type { Exercise, LocalizedExercise, MetricField } from './exercises';
import { localizeExercise, translateTerm, getExerciseMetrics, METRIC_PRESETS } from './exercises';
import { exerciseDbMap, slugToExerciseDbId } from './exercisedb-map';
import { edbMuscleToSimple, edbMusclesToGroups, edbBodyPartToSimple, edbEquipmentToSimple } from './muscleMap';
import rawData from './exercisedb-raw.json';
import { fuzzyScore } from '$lib/js/fuzzy';
// Access static exercises via the exported map
import { exercises as staticExercises } from './exercises';
interface EdbRawExercise {
exerciseId: string;
name: string;
overview?: string;
instructions?: string[];
exerciseTips?: string[];
variations?: string[];
targetMuscles?: string[];
secondaryMuscles?: string[];
bodyParts?: string[];
equipments?: string[];
exerciseType?: string;
relatedExerciseIds?: string[];
}
export interface EnrichedExercise extends Exercise {
edbId: string | null;
overview: string | null;
tips: string[];
variations: string[];
targetMusclesDetailed: string[];
secondaryMusclesDetailed: string[];
heroImage: string | null;
videoUrl: string | null;
}
export interface LocalizedEnrichedExercise extends LocalizedExercise {
edbId: string | null;
overview: string | null;
tips: string[];
variations: string[];
targetMusclesDetailed: string[];
secondaryMusclesDetailed: string[];
heroImage: string | null;
videoUrl: string | null;
}
// Build static exercise lookup
const staticMap = new Map<string, Exercise>(staticExercises.map(e => [e.id, e]));
// Build EDB exercise lookup by exerciseId
const edbExercises = (rawData as { exercises: EdbRawExercise[] }).exercises;
const edbById = new Map<string, EdbRawExercise>(edbExercises.map(e => [e.exerciseId, e]));
// Set of all EDB IDs in our dataset (for filtering relatedExerciseIds)
const edbIdSet = new Set(edbExercises.map(e => e.exerciseId));
/** Convert an EDB exercise to an enriched Exercise */
function edbToEnriched(edb: EdbRawExercise, slug: string, staticEx?: Exercise): EnrichedExercise {
const targetGroups = edbMusclesToGroups(edb.targetMuscles ?? []);
const secondaryGroups = edbMusclesToGroups(edb.secondaryMuscles ?? []);
// Base exercise fields — prefer static data when available
const base: Exercise = staticEx
? { ...staticEx }
: {
id: slug,
name: edb.name.trim(),
bodyPart: edbBodyPartToSimple(edb.bodyParts?.[0] ?? ''),
equipment: edbEquipmentToSimple(edb.equipments?.[0] ?? 'body weight'),
target: targetGroups[0] ?? 'full body',
secondaryMuscles: secondaryGroups.filter(g => !targetGroups.includes(g)),
instructions: edb.instructions ?? [],
};
// For static exercises, merge in EDB secondary muscles if richer
if (staticEx && edb.secondaryMuscles && edb.secondaryMuscles.length > staticEx.secondaryMuscles.length) {
base.secondaryMuscles = secondaryGroups.filter(g => !targetGroups.includes(g));
}
return {
...base,
edbId: edb.exerciseId,
overview: edb.overview ?? null,
tips: edb.exerciseTips ?? [],
variations: edb.variations ?? [],
targetMusclesDetailed: edb.targetMuscles ?? [],
secondaryMusclesDetailed: edb.secondaryMuscles ?? [],
heroImage: `/fitness/exercises/${edb.exerciseId}/720p.jpg`,
videoUrl: `/fitness/exercises/${edb.exerciseId}/video.mp4`,
};
}
// Build the unified exercise set
const allEnriched = new Map<string, EnrichedExercise>();
// 1. Add all EDB-mapped exercises (200)
for (const [edbId, slug] of Object.entries(exerciseDbMap)) {
const edb = edbById.get(edbId);
if (!edb) continue;
const staticEx = staticMap.get(slug);
allEnriched.set(slug, edbToEnriched(edb, slug, staticEx));
}
// 2. Add remaining static exercises not in EDB (77 - 23 = 54)
for (const ex of staticExercises) {
if (!allEnriched.has(ex.id)) {
allEnriched.set(ex.id, {
...ex,
edbId: null,
overview: null,
tips: [],
variations: [],
targetMusclesDetailed: [],
secondaryMusclesDetailed: [],
heroImage: ex.imageUrl ?? null,
videoUrl: null,
});
}
}
const allExercisesArray = [...allEnriched.values()];
/** Localize an enriched exercise */
export function localizeEnriched(e: EnrichedExercise, lang: 'en' | 'de'): LocalizedEnrichedExercise {
const localized = localizeExercise(e, lang);
return {
...localized,
edbId: e.edbId,
overview: e.overview,
tips: e.tips,
variations: e.variations,
targetMusclesDetailed: e.targetMusclesDetailed,
secondaryMusclesDetailed: e.secondaryMusclesDetailed,
heroImage: e.heroImage,
videoUrl: e.videoUrl,
};
}
/** Get a single enriched exercise by slug */
export function getEnrichedExerciseById(id: string, lang?: 'en' | 'de'): LocalizedEnrichedExercise | undefined {
const e = allEnriched.get(id);
if (!e) return undefined;
return localizeEnriched(e, lang ?? 'en');
}
/** Get exercise metrics (delegates to static logic) */
export { getExerciseMetrics } from './exercises';
/** Get all filter options across the full exercise set */
export function getFilterOptionsAll(): {
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 allExercisesArray) {
bodyParts.add(e.bodyPart);
equipment.add(e.equipment);
targets.add(e.target);
}
return {
bodyParts: [...bodyParts].sort(),
equipment: [...equipment].sort(),
targets: [...targets].sort(),
};
}
/** Search all exercises with fuzzy matching */
export function searchAllExercises(opts: {
search?: string;
bodyPart?: string;
equipment?: string | string[];
target?: string;
muscleGroups?: string[];
lang?: 'en' | 'de';
}): LocalizedEnrichedExercise[] {
const lang = opts.lang ?? 'en';
let results: LocalizedEnrichedExercise[] = allExercisesArray.map(e => localizeEnriched(e, lang));
if (opts.bodyPart) {
results = results.filter(e => e.bodyPart === opts.bodyPart);
}
if (opts.equipment) {
const eqSet = Array.isArray(opts.equipment) ? new Set(opts.equipment) : new Set([opts.equipment]);
results = results.filter(e => eqSet.has(e.equipment));
}
if (opts.target) {
results = results.filter(e => e.target === opts.target);
}
if (opts.muscleGroups?.length) {
const groups = new Set(opts.muscleGroups);
results = results.filter(e => {
// Check detailed EDB muscles
if (e.targetMusclesDetailed?.length) {
const tg = edbMusclesToGroups(e.targetMusclesDetailed);
const sg = edbMusclesToGroups(e.secondaryMusclesDetailed ?? []);
return tg.some(g => groups.has(g)) || sg.some(g => groups.has(g));
}
// Fallback: check simplified target/secondaryMuscles
return groups.has(e.target) || e.secondaryMuscles.some(m => groups.has(m));
});
}
if (opts.search) {
const query = opts.search.toLowerCase();
const scored: { exercise: LocalizedEnrichedExercise; score: number }[] = [];
for (const e of results) {
const text = `${e.localName} ${e.name} ${e.localTarget} ${e.localBodyPart} ${e.localEquipment} ${e.localSecondaryMuscles.join(' ')}`.toLowerCase();
const score = fuzzyScore(query, text);
if (score > 0) scored.push({ exercise: e, score });
}
scored.sort((a, b) => b.score - a.score);
results = scored.map(s => s.exercise);
}
return results;
}
/** Find similar exercises by target muscle + body part */
export function findSimilarExercises(id: string, limit = 4, lang?: 'en' | 'de'): LocalizedEnrichedExercise[] {
const source = allEnriched.get(id);
if (!source) return [];
// First try: relatedExerciseIds that exist in our set
const related: LocalizedEnrichedExercise[] = [];
const edbId = source.edbId;
if (edbId) {
const edb = edbById.get(edbId);
if (edb?.relatedExerciseIds) {
for (const relId of edb.relatedExerciseIds) {
if (related.length >= limit) break;
if (!edbIdSet.has(relId)) continue;
const slug = exerciseDbMap[relId];
if (slug && slug !== id) {
const enriched = getEnrichedExerciseById(slug, lang);
if (enriched) related.push(enriched);
}
}
}
}
// Fill remaining slots with target muscle + body part matches
if (related.length < limit) {
const relatedIds = new Set(related.map(r => r.id));
relatedIds.add(id);
const candidates = allExercisesArray
.filter(e => !relatedIds.has(e.id))
.map(e => {
let score = 0;
if (e.target === source.target) score += 3;
if (e.bodyPart === source.bodyPart) score += 2;
if (e.equipment === source.equipment) score += 1;
return { exercise: e, score };
})
.filter(c => c.score > 0)
.sort((a, b) => b.score - a.score);
for (const c of candidates) {
if (related.length >= limit) break;
related.push(localizeEnriched(c.exercise, lang ?? 'en'));
}
}
return related;
}
/** Total count of all exercises */
export const totalExerciseCount = allExercisesArray.length;

View File

@@ -1746,7 +1746,7 @@ export const exercises: Exercise[] = [
];
// Lookup map for O(1) access by ID
const exerciseMap = new Map<string, Exercise>(exercises.map((e) => [e.id, e]));
export const exerciseMap = new Map<string, Exercise>(exercises.map((e) => [e.id, e]));
export function getExerciseById(id: string, lang?: 'en' | 'de'): LocalizedExercise | undefined {
const e = exerciseMap.get(id);

173
src/lib/data/muscleMap.ts Normal file
View File

@@ -0,0 +1,173 @@
/**
* Maps ExerciseDB anatomical muscle names (CAPS) to simplified display names
* used in exercises.ts and throughout the UI.
*/
/** ExerciseDB anatomical name → simplified group name */
const MUSCLE_NAME_MAP: Record<string, string> = {
// Chest
'PECTORALIS MAJOR STERNAL HEAD': 'pectorals',
'PECTORALIS MAJOR CLAVICULAR HEAD': 'pectorals',
'SERRATUS ANTERIOR': 'pectorals',
'SERRATUS ANTE': 'pectorals',
// Shoulders
'ANTERIOR DELTOID': 'anterior deltoids',
'LATERAL DELTOID': 'lateral deltoids',
'POSTERIOR DELTOID': 'rear deltoids',
'INFRASPINATUS': 'rotator cuff',
'TERES MINOR': 'rotator cuff',
'SUBSCAPULARIS': 'rotator cuff',
// Arms
'BICEPS BRACHII': 'biceps',
'BRACHIALIS': 'biceps',
'BRACHIORADIALIS': 'brachioradialis',
'TRICEPS BRACHII': 'triceps',
'WRIST FLEXORS': 'forearms',
'WRIST EXTENSORS': 'forearms',
// Back
'LATISSIMUS DORSI': 'lats',
'TERES MAJOR': 'lats',
'TRAPEZIUS UPPER FIBERS': 'traps',
'TRAPEZIUS MIDDLE FIBERS': 'traps',
'TRAPEZIUS LOWER FIBERS': 'traps',
'LEVATOR SCAPULAE': 'traps',
'ERECTOR SPINAE': 'erector spinae',
// Core
'RECTUS ABDOMINIS': 'abdominals',
'TRANSVERSUS ABDOMINIS': 'abdominals',
'OBLIQUES': 'obliques',
// Hips & Glutes
'GLUTEUS MAXIMUS': 'glutes',
'GLUTEUS MEDIUS': 'glutes',
'GLUTEUS MINIMUS': 'glutes',
'ILIOPSOAS': 'hip flexors',
'DEEP HIP EXTERNAL ROTATORS': 'glutes',
'TENSOR FASCIAE LATAE': 'hip flexors',
// Upper Legs
'QUADRICEPS': 'quadriceps',
'HAMSTRINGS': 'hamstrings',
'ADDUCTOR LONGUS': 'hip flexors',
'ADDUCTOR BREVIS': 'hip flexors',
'ADDUCTOR MAGNUS': 'hamstrings',
'PECTINEUS': 'hip flexors',
'GRACILIS': 'hip flexors',
'SARTORIUS': 'quadriceps',
// Lower Legs
'GASTROCNEMIUS': 'calves',
'SOLEUS': 'calves',
'TIBIALIS ANTERIOR': 'calves',
// Neck
'STERNOCLEIDOMASTOID': 'neck',
'SPLENIUS': 'neck',
};
/** ExerciseDB body part → simplified body part used in exercises.ts */
const BODY_PART_MAP: Record<string, string> = {
'BACK': 'back',
'BICEPS': 'arms',
'CALVES': 'legs',
'CHEST': 'chest',
'FOREARMS': 'arms',
'FULL BODY': 'cardio',
'HAMSTRINGS': 'legs',
'HIPS': 'legs',
'NECK': 'shoulders',
'QUADRICEPS': 'legs',
'SHOULDERS': 'shoulders',
'THIGHS': 'legs',
'TRICEPS': 'arms',
'UPPER ARMS': 'arms',
'WAIST': 'core',
};
/** ExerciseDB equipment → simplified equipment name */
const EQUIPMENT_MAP: Record<string, string> = {
'BARBELL': 'barbell',
'BODY WEIGHT': 'body weight',
'CABLE': 'cable',
'DUMBBELL': 'dumbbell',
'LEVERAGE MACHINE': 'machine',
'ROPE': 'other',
};
/** Convert ExerciseDB anatomical muscle name to simplified group */
export function edbMuscleToSimple(name: string): string {
return MUSCLE_NAME_MAP[name] ?? name.toLowerCase();
}
/** Convert ExerciseDB body part to simplified name */
export function edbBodyPartToSimple(name: string): string {
return BODY_PART_MAP[name] ?? name.toLowerCase();
}
/** Convert ExerciseDB equipment to simplified name */
export function edbEquipmentToSimple(name: string): string {
return EQUIPMENT_MAP[name] ?? name.toLowerCase();
}
/** Ordered muscle groups for consistent display (anatomical top→bottom, front→back) */
export const MUSCLE_GROUPS = [
'neck',
'traps',
'anterior deltoids',
'lateral deltoids',
'rear deltoids',
'rotator cuff',
'pectorals',
'biceps',
'brachioradialis',
'triceps',
'forearms',
'lats',
'erector spinae',
'abdominals',
'obliques',
'hip flexors',
'glutes',
'quadriceps',
'hamstrings',
'calves',
] as const;
export type MuscleGroup = typeof MUSCLE_GROUPS[number];
/** German translations for muscle group display names */
export const MUSCLE_GROUP_DE: Record<string, string> = {
'neck': 'Nacken',
'traps': 'Trapezmuskel',
'anterior deltoids': 'Vordere Deltamuskeln',
'lateral deltoids': 'Seitliche Deltamuskeln',
'rear deltoids': 'Hintere Deltamuskeln',
'rotator cuff': 'Rotatorenmanschette',
'pectorals': 'Brustmuskel',
'biceps': 'Bizeps',
'brachioradialis': 'Oberarmspeichenmuskel',
'triceps': 'Trizeps',
'forearms': 'Unterarme',
'lats': 'Latissimus',
'erector spinae': 'Rückenstrecker',
'abdominals': 'Bauchmuskeln',
'obliques': 'Schräge Bauchmuskeln',
'hip flexors': 'Hüftbeuger',
'glutes': 'Gesäss',
'quadriceps': 'Quadrizeps',
'hamstrings': 'Beinbeuger',
'calves': 'Waden',
};
/** Deduplicate muscle groups from an array of anatomical muscle names */
export function edbMusclesToGroups(muscles: string[]): string[] {
const groups = new Set<string>();
for (const m of muscles) {
groups.add(edbMuscleToSimple(m));
}
return [...groups];
}

View File

@@ -330,6 +330,18 @@ const translations: Translations = {
predicted_ovulation: { en: 'Predicted ovulation', de: 'Voraussichtlicher Eisprung' },
to: { en: 'to', de: 'bis' },
// Exercise detail (enriched)
overview: { en: 'Overview', de: 'Überblick' },
tips: { en: 'Tips', de: 'Tipps' },
similar_exercises: { en: 'Similar Exercises', de: 'Ähnliche Übungen' },
primary_muscles: { en: 'Primary', de: 'Primär' },
secondary_muscles: { en: 'Secondary', de: 'Sekundär' },
play_video: { en: 'Play Video', de: 'Video abspielen' },
// Muscle heatmap
muscle_balance: { en: 'Muscle Balance', de: 'Muskelbalance' },
weekly_sets: { en: 'Sets per week', de: 'Sätze pro Woche' },
// Custom meals
custom_meals: { en: 'Custom Meals', de: 'Eigene Mahlzeiten' },
custom_meal: { en: 'Custom Meal', de: 'Eigene Mahlzeit' },

View File

@@ -1,18 +1,21 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getExerciseById } from '$lib/data/exercises';
import { getEnrichedExerciseById, findSimilarExercises } from '$lib/data/exercisedb';
// GET /api/fitness/exercises/[id] - Get exercise from static data
export const GET: RequestHandler = async ({ params, locals }) => {
// GET /api/fitness/exercises/[id] - Get enriched exercise with EDB data + similar exercises
export const GET: RequestHandler = async ({ params, locals, url }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const exercise = getExerciseById(params.id);
const lang = url.searchParams.get('lang') === 'de' ? 'de' : 'en';
const exercise = getEnrichedExerciseById(params.id, lang);
if (!exercise) {
return json({ error: 'Exercise not found' }, { status: 404 });
}
return json({ exercise });
const similar = findSimilarExercises(params.id, 4, lang);
return json({ exercise, similar });
};

View File

@@ -1,14 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { getExerciseById } from '$lib/data/exercises';
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
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);
const exercise = getEnrichedExerciseById(params.id);
if (!exercise) {
return json({ error: 'Exercise not found' }, { status: 404 });
}

View File

@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { getEnrichedExerciseById, getExerciseMetrics } from '$lib/data/exercisedb';
import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession';
@@ -17,7 +17,7 @@ function estimatedOneRepMax(weight: number, reps: number): number {
export const GET: RequestHandler = async ({ params, locals }) => {
const user = await requireAuth(locals);
const exercise = getExerciseById(params.id);
const exercise = getEnrichedExerciseById(params.id);
if (!exercise) {
return json({ error: 'Exercise not found' }, { status: 404 });
}

View File

@@ -0,0 +1,116 @@
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 { getEnrichedExerciseById } from '$lib/data/exercisedb';
import { edbMuscleToSimple, MUSCLE_GROUPS } from '$lib/data/muscleMap';
/**
* GET /api/fitness/stats/muscle-heatmap?weeks=8
*
* Returns weekly muscle usage data from workout history.
* Primary muscles get 1× set count, secondary get 0.5×.
*/
export const GET: RequestHandler = async ({ url, locals }) => {
const user = await requireAuth(locals);
const weeks = Math.min(parseInt(url.searchParams.get('weeks') || '8'), 26);
await dbConnect();
const since = new Date();
since.setDate(since.getDate() - weeks * 7);
const sessions = await WorkoutSession.find({
createdBy: user.nickname,
startTime: { $gte: since }
}).lean();
// Build weekly buckets
type MuscleData = { primary: number; secondary: number };
const weeklyData: { weekStart: string; muscles: Record<string, MuscleData> }[] = [];
// Initialize week buckets
for (let w = 0; w < weeks; w++) {
const d = new Date();
d.setDate(d.getDate() - (weeks - 1 - w) * 7);
// Find Monday of that week
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
d.setDate(diff);
const weekStart = d.toISOString().slice(0, 10);
const muscles: Record<string, MuscleData> = {};
for (const g of MUSCLE_GROUPS) {
muscles[g] = { primary: 0, secondary: 0 };
}
weeklyData.push({ weekStart, muscles });
}
// Aggregate muscle usage
for (const session of sessions) {
const sessionDate = new Date(session.startTime);
// Find which week bucket
const weekIdx = weeklyData.findIndex((w, i) => {
const start = new Date(w.weekStart);
const nextStart = i + 1 < weeklyData.length
? new Date(weeklyData[i + 1].weekStart)
: new Date(start.getTime() + 7 * 86400000);
return sessionDate >= start && sessionDate < nextStart;
});
if (weekIdx === -1) continue;
const bucket = weeklyData[weekIdx].muscles;
for (const ex of session.exercises) {
const enriched = getEnrichedExerciseById(ex.exerciseId);
if (!enriched) continue;
const setCount = ex.sets?.filter((s: any) => s.completed !== false).length ?? 0;
if (setCount === 0) continue;
// Primary muscles
const primaryGroups = new Set<string>();
if (enriched.targetMusclesDetailed?.length) {
for (const m of enriched.targetMusclesDetailed) {
const group = edbMuscleToSimple(m);
primaryGroups.add(group);
if (bucket[group]) bucket[group].primary += setCount;
}
} else if (enriched.target) {
primaryGroups.add(enriched.target);
if (bucket[enriched.target]) bucket[enriched.target].primary += setCount;
}
// Secondary muscles
if (enriched.secondaryMusclesDetailed?.length) {
for (const m of enriched.secondaryMusclesDetailed) {
const group = edbMuscleToSimple(m);
if (!primaryGroups.has(group) && bucket[group]) {
bucket[group].secondary += setCount;
}
}
} else if (enriched.secondaryMuscles) {
for (const m of enriched.secondaryMuscles) {
if (!primaryGroups.has(m) && bucket[m]) {
bucket[m].secondary += setCount;
}
}
}
}
}
// Compute totals
const totals: Record<string, { primary: number; secondary: number; total: number; weeklyAvg: number }> = {};
for (const g of MUSCLE_GROUPS) {
let primary = 0, secondary = 0;
for (const w of weeklyData) {
primary += w.muscles[g].primary;
secondary += w.muscles[g].secondary;
}
const total = primary + secondary * 0.5;
totals[g] = { primary, secondary, total, weeklyAvg: total / weeks };
}
return json({ weeks: weeklyData, totals, muscleGroups: [...MUSCLE_GROUPS] });
};

View File

@@ -2,24 +2,71 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Search } from '@lucide/svelte';
import { getFilterOptions, searchExercises, translateTerm } from '$lib/data/exercises';
import { getFilterOptionsAll, searchAllExercises } from '$lib/data/exercisedb';
import { translateTerm } from '$lib/data/exercises';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { MUSCLE_GROUPS, MUSCLE_GROUP_DE } from '$lib/data/muscleMap';
import MuscleFilter from '$lib/components/fitness/MuscleFilter.svelte';
const lang = $derived(detectFitnessLang($page.url.pathname));
const isEn = $derived(lang === 'en');
const sl = $derived(fitnessSlugs(lang));
let { data } = $props();
let query = $state('');
let bodyPartFilter = $state('');
let equipmentFilter = $state('');
let equipmentFilters = $state([]);
let muscleGroups = $state([]);
const filterOptions = getFilterOptions();
const filterOptions = getFilterOptionsAll();
const filtered = $derived(searchExercises({
/** All selectable muscle/body-part options for the dropdown */
const allMuscleOptions = [...MUSCLE_GROUPS];
/** Display label for a muscle group */
function muscleLabel(group) {
const raw = isEn ? group : (MUSCLE_GROUP_DE[group] ?? group);
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
/** Options not yet selected, for the dropdown */
const availableOptions = $derived(
allMuscleOptions.filter(g => !muscleGroups.includes(g))
);
function addMuscle(group) {
if (group && !muscleGroups.includes(group)) {
muscleGroups = [...muscleGroups, group];
}
}
function removeMuscle(group) {
muscleGroups = muscleGroups.filter(g => g !== group);
}
const availableEquipment = $derived(
filterOptions.equipment.filter(e => !equipmentFilters.includes(e))
);
function addEquipment(eq) {
if (eq && !equipmentFilters.includes(eq)) {
equipmentFilters = [...equipmentFilters, eq];
}
}
function removeEquipment(eq) {
equipmentFilters = equipmentFilters.filter(e => e !== eq);
}
function equipmentLabel(eq) {
const raw = translateTerm(eq, lang);
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
const filtered = $derived(searchAllExercises({
search: query || undefined,
bodyPart: bodyPartFilter || undefined,
equipment: equipmentFilter || undefined,
equipment: equipmentFilters.length ? equipmentFilters : undefined,
muscleGroups: muscleGroups.length ? muscleGroups : undefined,
lang
}));
</script>
@@ -27,28 +74,55 @@
<svelte:head><title>{lang === 'en' ? 'Exercises' : 'Übungen'} - Bocken</title></svelte:head>
<div class="exercises-page">
<!-- Desktop: split front/back absolutely positioned outside content -->
<div class="desktop-filter">
<MuscleFilter bind:selectedGroups={muscleGroups} {lang} split />
</div>
<h1>{t('exercises_title', lang)}</h1>
<!-- Mobile: inline, not split -->
<div class="mobile-filter">
<MuscleFilter bind:selectedGroups={muscleGroups} {lang} />
</div>
<div class="search-bar">
<Search size={16} />
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
</div>
<div class="filters">
<select bind:value={bodyPartFilter}>
<option value="">{t('all_body_parts', lang)}</option>
{#each filterOptions.bodyParts as bp}
{@const label = translateTerm(bp, lang)}<option value={bp}>{label.charAt(0).toUpperCase() + label.slice(1)}</option>
<select onchange={(e) => { addMuscle(e.target.value); e.target.value = ''; }}>
<option value="">{isEn ? 'Muscle group' : 'Muskelgruppe'}</option>
{#each availableOptions as group}
<option value={group}>{muscleLabel(group)}</option>
{/each}
</select>
<select bind:value={equipmentFilter}>
<select onchange={(e) => { addEquipment(e.target.value); e.target.value = ''; }}>
<option value="">{t('all_equipment', lang)}</option>
{#each filterOptions.equipment as eq}
{@const label = translateTerm(eq, lang)}<option value={eq}>{label.charAt(0).toUpperCase() + label.slice(1)}</option>
{#each availableEquipment as eq}
<option value={eq}>{equipmentLabel(eq)}</option>
{/each}
</select>
</div>
{#if muscleGroups.length > 0 || equipmentFilters.length > 0}
<div class="selected-pills">
{#each muscleGroups as group}
<button class="filter-pill muscle" onclick={() => removeMuscle(group)}>
{muscleLabel(group)}
<span class="pill-remove" aria-hidden="true">×</span>
</button>
{/each}
{#each equipmentFilters as eq}
<button class="filter-pill equipment" onclick={() => removeEquipment(eq)}>
{equipmentLabel(eq)}
<span class="pill-remove" aria-hidden="true">×</span>
</button>
{/each}
</div>
{/if}
<ul class="exercise-list">
{#each filtered as exercise (exercise.id)}
<li>
@@ -71,11 +145,46 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 620px;
margin: 0 auto;
position: relative;
}
h1 {
margin: 0;
font-size: 1.4rem;
}
/* Mobile: show inline filter, hide desktop split */
.desktop-filter {
display: none;
}
/* Desktop: front/back absolutely positioned outside content flow */
@media (min-width: 1024px) {
.mobile-filter {
display: none;
}
.desktop-filter {
display: contents;
}
.exercises-page :global(.split-left),
.exercises-page :global(.split-right) {
position: fixed;
top: calc(8.5rem + env(safe-area-inset-top, 0px));
width: clamp(140px, 14vw, 200px);
}
.exercises-page :global(.split-left) {
right: calc(50% + 310px + 1.5rem);
}
.exercises-page :global(.split-right) {
left: calc(50% + 310px + 1.5rem);
}
}
.search-bar {
display: flex;
align-items: center;
@@ -110,6 +219,45 @@
color: inherit;
font-size: 0.8rem;
}
.selected-pills {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.filter-pill {
all: unset;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.6rem;
border-radius: var(--radius-pill, 100px);
color: var(--color-primary-contrast);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.1s, transform 0.1s;
}
.filter-pill:hover {
filter: brightness(1.1);
transform: scale(1.05);
}
.filter-pill:active {
transform: scale(0.95);
}
.filter-pill.muscle {
background: var(--lightblue);
color: black;
}
.filter-pill.equipment {
background: var(--blue);
color: white;
}
.pill-remove {
font-size: 0.7rem;
font-weight: bold;
margin-left: 0.1rem;
}
.exercise-list {
list-style: none;
padding: 0;
@@ -118,7 +266,8 @@
.exercise-row {
display: flex;
align-items: center;
padding: 0.75rem 0;
gap: 0.6rem;
padding: 0.5rem 0;
text-decoration: none;
color: inherit;
border-bottom: 1px solid var(--color-border);
@@ -129,6 +278,7 @@
.exercise-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.exercise-name {
font-weight: 600;

View File

@@ -1,9 +1,10 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, fetch }) => {
export const load: PageServerLoad = async ({ params, fetch, url }) => {
const lang = url.pathname.includes('/uebungen') ? 'de' : 'en';
const [exerciseRes, historyRes, statsRes] = await Promise.all([
fetch(`/api/fitness/exercises/${params.id}`),
fetch(`/api/fitness/exercises/${params.id}?lang=${lang}`),
fetch(`/api/fitness/exercises/${params.id}/history?limit=20`),
fetch(`/api/fitness/exercises/${params.id}/stats`)
]);
@@ -12,8 +13,11 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
error(404, 'Exercise not found');
}
const exerciseData = await exerciseRes.json();
return {
exercise: await exerciseRes.json(),
exercise: exerciseData.exercise,
similar: exerciseData.similar ?? [],
history: await historyRes.json(),
stats: await statsRes.json()
};

View File

@@ -1,9 +1,12 @@
<script>
import { page } from '$app/stores';
import { getExerciseById, localizeExercise } from '$lib/data/exercises';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
import { localizeExercise, translateTerm } from '$lib/data/exercises';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { ChevronRight } from '@lucide/svelte';
const lang = $derived(detectFitnessLang($page.url.pathname));
const s = $derived(fitnessSlugs(lang));
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import { onMount } from 'svelte';
@@ -31,11 +34,9 @@
let activeTab = $state('about');
const rawExercise = $derived(data.exercise?.exercise ?? getExerciseById(data.exercise?.id));
const exercise = $derived(rawExercise ? localizeExercise(rawExercise, lang) : undefined);
// History API returns { history: [{ sessionId, sessionName, date, sets }], total }
const exercise = $derived(data.exercise ?? getEnrichedExerciseById($page.params.id, lang));
const similar = $derived(data.similar ?? []);
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 ?? {});
@@ -67,90 +68,36 @@
}, '#EBCB8B');
});
/**
* Compute linear regression trendline + ±1σ bands for a data array.
* Returns { trend, upper, lower } arrays of same length.
* @param {number[]} data
*/
/** @param {number[]} data */
function trendWithBands(data) {
const n = data.length;
if (n < 3) return null;
// Linear regression
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);
// Residual standard deviation
let ssRes = 0;
for (let i = 0; i < n; i++) {
const r = data[i] - trend[i];
ssRes += r * r;
}
for (let i = 0; i < n; i++) { const r = data[i] - trend[i]; ssRes += r * r; }
const sigma = Math.sqrt(ssRes / (n - 2));
const upper = trend.map(v => Math.round((v + sigma) * 10) / 10);
const lower = trend.map(v => Math.round((v - sigma) * 10) / 10);
return { trend, upper, lower };
return { trend, upper: trend.map(v => Math.round((v + sigma) * 10) / 10), lower: trend.map(v => Math.round((v - sigma) * 10) / 10) };
}
/**
* Add trendline + uncertainty datasets to a chart data object.
* @param {{ labels: string[], datasets: Array<any> }} chartData
* @param {string} trendColor
*/
/** @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
}
{ 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 }
]
};
}
@@ -182,17 +129,29 @@
{#if activeTab === 'about'}
<div class="tab-content">
{#if exercise?.imageUrl}
<img src={exercise.imageUrl} alt={exercise.localName} class="exercise-image" />
{/if}
<!-- Tags -->
<div class="tags">
<span class="tag body-part">{exercise?.localBodyPart}</span>
<span class="tag equipment">{exercise?.localEquipment}</span>
<span class="tag target">{exercise?.localTarget}</span>
</div>
{#if exercise?.localSecondaryMuscles?.length}
<p class="secondary">{lang === 'en' ? 'Also works' : 'Trainiert auch'}: {exercise.localSecondaryMuscles.join(', ')}</p>
<!-- Muscle pills -->
{#if exercise?.localSecondaryMuscles?.length || exercise?.localTarget}
<div class="muscle-section">
<h3>{lang === 'en' ? 'Muscles' : 'Muskeln'}</h3>
<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}
<!-- Instructions -->
{#if exercise?.localInstructions?.length}
<h3>{t('instructions', lang)}</h3>
<ol class="instructions">
@@ -201,6 +160,24 @@
{/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="/fitness/{s.exercises}/{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>
{:else if activeTab === 'history'}
<div class="tab-content">
@@ -325,14 +302,7 @@
padding: 0.5rem 0;
}
/* About */
.exercise-image {
width: 100%;
max-height: 300px;
object-fit: cover;
border-radius: 12px;
margin-bottom: 0.75rem;
}
/* Tags */
.tags {
display: flex;
gap: 0.5rem;
@@ -349,14 +319,39 @@
.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(--color-text-secondary);
/* Muscle pills */
.muscle-section {
margin-bottom: 0.25rem;
}
.muscle-pills {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.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);
}
h3 {
font-size: 1rem;
margin: 1rem 0 0.5rem;
margin: 0.75rem 0 0.4rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.instructions {
padding-left: 1.25rem;
@@ -367,6 +362,54 @@
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;

View File

@@ -2,11 +2,13 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, locals }) => {
const session = await locals.auth();
const [res, goalRes] = await Promise.all([
const [res, goalRes, heatmapRes] = await Promise.all([
fetch('/api/fitness/stats/overview'),
fetch('/api/fitness/goal')
fetch('/api/fitness/goal'),
fetch('/api/fitness/stats/muscle-heatmap?weeks=8')
]);
const stats = await res.json();
const goal = goalRes.ok ? await goalRes.json() : { weeklyWorkouts: null, streak: 0 };
return { session, stats, goal };
const muscleHeatmap = heatmapRes.ok ? await heatmapRes.json() : { weeks: [], totals: {}, muscleGroups: [] };
return { session, stats, goal, muscleHeatmap };
};

View File

@@ -1,6 +1,7 @@
<script>
import { page } from '$app/stores';
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import MuscleHeatmap from '$lib/components/fitness/MuscleHeatmap.svelte';
import { Dumbbell, Route, Flame, Weight } from '@lucide/svelte';
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
import { onMount } from 'svelte';
@@ -221,6 +222,11 @@
height="220px"
/>
{/if}
<div class="section-block">
<h2 class="section-title">{t('muscle_balance', lang)}</h2>
<MuscleHeatmap data={data.muscleHeatmap} />
</div>
</div>
<style>
@@ -519,4 +525,16 @@
color: var(--color-text-secondary);
padding: 2rem 0;
}
.section-block {
background: var(--color-surface);
border-radius: 12px;
padding: 1rem;
box-shadow: var(--shadow-sm);
}
.section-title {
margin: 0 0 0.75rem;
font-size: 1rem;
font-weight: 700;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Some files were not shown because too many files have changed in this diff Show More