Compare commits
2 Commits
d0e123018a
...
593f211252
| Author | SHA1 | Date | |
|---|---|---|---|
|
593f211252
|
|||
|
0874283146
|
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
117
scripts/download-exercise-media.ts
Normal 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
@@ -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);
|
||||
});
|
||||
13
src/lib/assets/muscle-back.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
12
src/lib/assets/muscle-front.svg
Normal file
|
After Width: | Height: | Size: 29 KiB |
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
260
src/lib/components/fitness/MuscleFilter.svelte
Normal 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>
|
||||
311
src/lib/components/fitness/MuscleHeatmap.svelte
Normal 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}× {isEn ? 'primary' : 'primär'}
|
||||
·
|
||||
{selectedInfo.totalSecondary}× {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>
|
||||
59
src/lib/components/fitness/VideoOverlay.svelte
Normal 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>
|
||||
1
src/lib/data/.exercisedb-ids.json
Normal 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"]
|
||||
219
src/lib/data/exercisedb-map.ts
Normal 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])
|
||||
);
|
||||
14190
src/lib/data/exercisedb-raw.json
Normal file
278
src/lib/data/exercisedb.ts
Normal 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;
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
116
src/routes/api/fitness/stats/muscle-heatmap/+server.ts
Normal 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] });
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
static/fitness/exercises/exr_41n2hG9pRT55cGVk/1080p.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
static/fitness/exercises/exr_41n2hG9pRT55cGVk/360p.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
static/fitness/exercises/exr_41n2hG9pRT55cGVk/480p.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
static/fitness/exercises/exr_41n2hG9pRT55cGVk/720p.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
static/fitness/exercises/exr_41n2hG9pRT55cGVk/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hGD4omjWVnbS/1080p.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
static/fitness/exercises/exr_41n2hGD4omjWVnbS/360p.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
static/fitness/exercises/exr_41n2hGD4omjWVnbS/480p.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
static/fitness/exercises/exr_41n2hGD4omjWVnbS/720p.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
static/fitness/exercises/exr_41n2hGD4omjWVnbS/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hGNrmUnF58Yy/1080p.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
static/fitness/exercises/exr_41n2hGNrmUnF58Yy/360p.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
static/fitness/exercises/exr_41n2hGNrmUnF58Yy/480p.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
static/fitness/exercises/exr_41n2hGNrmUnF58Yy/720p.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
static/fitness/exercises/exr_41n2hGNrmUnF58Yy/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hGRSg9WCoTYT/1080p.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
static/fitness/exercises/exr_41n2hGRSg9WCoTYT/360p.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
static/fitness/exercises/exr_41n2hGRSg9WCoTYT/480p.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
static/fitness/exercises/exr_41n2hGRSg9WCoTYT/720p.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
static/fitness/exercises/exr_41n2hGRSg9WCoTYT/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hGUso7JFmuYR/1080p.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
static/fitness/exercises/exr_41n2hGUso7JFmuYR/360p.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
static/fitness/exercises/exr_41n2hGUso7JFmuYR/480p.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
static/fitness/exercises/exr_41n2hGUso7JFmuYR/720p.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
static/fitness/exercises/exr_41n2hGUso7JFmuYR/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hGbCptD8Nosk/1080p.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
static/fitness/exercises/exr_41n2hGbCptD8Nosk/360p.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
static/fitness/exercises/exr_41n2hGbCptD8Nosk/480p.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
static/fitness/exercises/exr_41n2hGbCptD8Nosk/720p.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
static/fitness/exercises/exr_41n2hGbCptD8Nosk/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hGioS8HumEF7/1080p.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
static/fitness/exercises/exr_41n2hGioS8HumEF7/360p.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/fitness/exercises/exr_41n2hGioS8HumEF7/480p.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
static/fitness/exercises/exr_41n2hGioS8HumEF7/720p.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
static/fitness/exercises/exr_41n2hGioS8HumEF7/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hGy6zE7fN6v2/1080p.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
static/fitness/exercises/exr_41n2hGy6zE7fN6v2/360p.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
static/fitness/exercises/exr_41n2hGy6zE7fN6v2/480p.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
static/fitness/exercises/exr_41n2hGy6zE7fN6v2/720p.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
static/fitness/exercises/exr_41n2hGy6zE7fN6v2/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hH6VGNz6cNtv/1080p.jpg
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
static/fitness/exercises/exr_41n2hH6VGNz6cNtv/360p.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
static/fitness/exercises/exr_41n2hH6VGNz6cNtv/480p.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
static/fitness/exercises/exr_41n2hH6VGNz6cNtv/720p.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
static/fitness/exercises/exr_41n2hH6VGNz6cNtv/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hHCXQpZYhxhc/1080p.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
static/fitness/exercises/exr_41n2hHCXQpZYhxhc/360p.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
static/fitness/exercises/exr_41n2hHCXQpZYhxhc/480p.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
static/fitness/exercises/exr_41n2hHCXQpZYhxhc/720p.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
static/fitness/exercises/exr_41n2hHCXQpZYhxhc/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hHH9bNfi98YU/1080p.jpg
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
static/fitness/exercises/exr_41n2hHH9bNfi98YU/360p.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/fitness/exercises/exr_41n2hHH9bNfi98YU/480p.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
static/fitness/exercises/exr_41n2hHH9bNfi98YU/720p.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
static/fitness/exercises/exr_41n2hHH9bNfi98YU/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hHLE8aJXaxKR/1080p.jpg
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
static/fitness/exercises/exr_41n2hHLE8aJXaxKR/360p.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
static/fitness/exercises/exr_41n2hHLE8aJXaxKR/480p.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
static/fitness/exercises/exr_41n2hHLE8aJXaxKR/720p.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
static/fitness/exercises/exr_41n2hHLE8aJXaxKR/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hHRszDHarrxK/1080p.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
static/fitness/exercises/exr_41n2hHRszDHarrxK/360p.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
static/fitness/exercises/exr_41n2hHRszDHarrxK/480p.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
static/fitness/exercises/exr_41n2hHRszDHarrxK/720p.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
static/fitness/exercises/exr_41n2hHRszDHarrxK/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hHdjQpnyNdie/1080p.jpg
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
static/fitness/exercises/exr_41n2hHdjQpnyNdie/360p.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
static/fitness/exercises/exr_41n2hHdjQpnyNdie/480p.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
static/fitness/exercises/exr_41n2hHdjQpnyNdie/720p.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
static/fitness/exercises/exr_41n2hHdjQpnyNdie/video.mp4
Normal file
BIN
static/fitness/exercises/exr_41n2hJ5Harig7K7F/1080p.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
static/fitness/exercises/exr_41n2hJ5Harig7K7F/360p.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
static/fitness/exercises/exr_41n2hJ5Harig7K7F/480p.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |