feat: ExerciseDB integration with muscle heatmap, SVG body filter, and enriched exercises
All checks were successful
CI / update (push) Successful in 3m31s
All checks were successful
CI / update (push) Successful in 3m31s
Integrate ExerciseDB v2 data layer (muscleMap.ts, exercisedb.ts) to enrich the 77 static exercises with detailed muscle targeting, similar exercises, and expand the catalog to 254 exercises. Add interactive SVG muscle body diagrams for both the stats page heatmap and exercise list filtering, with split front/back views flanking the exercise list on desktop. Replace body part dropdown with unified muscle group multi-select with pill tags.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
13
src/lib/assets/muscle-back.svg
Normal file
13
src/lib/assets/muscle-back.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
12
src/lib/assets/muscle-front.svg
Normal file
12
src/lib/assets/muscle-front.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
@@ -1,12 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getExerciseById } from '$lib/data/exercises';
|
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
|
||||||
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
|
||||||
|
|
||||||
let { exerciseId } = $props();
|
let { exerciseId } = $props();
|
||||||
|
|
||||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||||
const exercise = $derived(getExerciseById(exerciseId, lang));
|
const exercise = $derived(getEnrichedExerciseById(exerciseId, lang));
|
||||||
const sl = $derived(fitnessSlugs(lang));
|
const sl = $derived(fitnessSlugs(lang));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<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 { Search, X } from '@lucide/svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
||||||
@@ -18,9 +19,9 @@
|
|||||||
let bodyPartFilter = $state('');
|
let bodyPartFilter = $state('');
|
||||||
let equipmentFilter = $state('');
|
let equipmentFilter = $state('');
|
||||||
|
|
||||||
const filterOptions = getFilterOptions();
|
const filterOptions = getFilterOptionsAll();
|
||||||
|
|
||||||
const filtered = $derived(searchExercises({
|
const filtered = $derived(searchAllExercises({
|
||||||
search: query || undefined,
|
search: query || undefined,
|
||||||
bodyPart: bodyPartFilter || undefined,
|
bodyPart: bodyPartFilter || undefined,
|
||||||
equipment: equipmentFilter || undefined,
|
equipment: equipmentFilter || undefined,
|
||||||
|
|||||||
260
src/lib/components/fitness/MuscleFilter.svelte
Normal file
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
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
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>
|
||||||
278
src/lib/data/exercisedb.ts
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
|
// 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 {
|
export function getExerciseById(id: string, lang?: 'en' | 'de'): LocalizedExercise | undefined {
|
||||||
const e = exerciseMap.get(id);
|
const e = exerciseMap.get(id);
|
||||||
|
|||||||
173
src/lib/data/muscleMap.ts
Normal file
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' },
|
predicted_ovulation: { en: 'Predicted ovulation', de: 'Voraussichtlicher Eisprung' },
|
||||||
to: { en: 'to', de: 'bis' },
|
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
|
||||||
custom_meals: { en: 'Custom Meals', de: 'Eigene Mahlzeiten' },
|
custom_meals: { en: 'Custom Meals', de: 'Eigene Mahlzeiten' },
|
||||||
custom_meal: { en: 'Custom Meal', de: 'Eigene Mahlzeit' },
|
custom_meal: { en: 'Custom Meal', de: 'Eigene Mahlzeit' },
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
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
|
// GET /api/fitness/exercises/[id] - Get enriched exercise with EDB data + similar exercises
|
||||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
export const GET: RequestHandler = async ({ params, locals, url }) => {
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
if (!session || !session.user?.nickname) {
|
if (!session || !session.user?.nickname) {
|
||||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
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) {
|
if (!exercise) {
|
||||||
return json({ error: 'Exercise not found' }, { status: 404 });
|
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 { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { requireAuth } from '$lib/server/middleware/auth';
|
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 { dbConnect } from '$utils/db';
|
||||||
import { WorkoutSession } from '$models/WorkoutSession';
|
import { WorkoutSession } from '$models/WorkoutSession';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
const user = await requireAuth(locals);
|
const user = await requireAuth(locals);
|
||||||
|
|
||||||
const exercise = getExerciseById(params.id);
|
const exercise = getEnrichedExerciseById(params.id);
|
||||||
if (!exercise) {
|
if (!exercise) {
|
||||||
return json({ error: 'Exercise not found' }, { status: 404 });
|
return json({ error: 'Exercise not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { requireAuth } from '$lib/server/middleware/auth';
|
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 { dbConnect } from '$utils/db';
|
||||||
import { WorkoutSession } from '$models/WorkoutSession';
|
import { WorkoutSession } from '$models/WorkoutSession';
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ function estimatedOneRepMax(weight: number, reps: number): number {
|
|||||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
const user = await requireAuth(locals);
|
const user = await requireAuth(locals);
|
||||||
|
|
||||||
const exercise = getExerciseById(params.id);
|
const exercise = getEnrichedExerciseById(params.id);
|
||||||
if (!exercise) {
|
if (!exercise) {
|
||||||
return json({ error: 'Exercise not found' }, { status: 404 });
|
return json({ error: 'Exercise not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
116
src/routes/api/fitness/stats/muscle-heatmap/+server.ts
Normal file
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 { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { Search } from '@lucide/svelte';
|
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 { 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 lang = $derived(detectFitnessLang($page.url.pathname));
|
||||||
|
const isEn = $derived(lang === 'en');
|
||||||
const sl = $derived(fitnessSlugs(lang));
|
const sl = $derived(fitnessSlugs(lang));
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
let bodyPartFilter = $state('');
|
let equipmentFilters = $state([]);
|
||||||
let equipmentFilter = $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,
|
search: query || undefined,
|
||||||
bodyPart: bodyPartFilter || undefined,
|
equipment: equipmentFilters.length ? equipmentFilters : undefined,
|
||||||
equipment: equipmentFilter || undefined,
|
muscleGroups: muscleGroups.length ? muscleGroups : undefined,
|
||||||
lang
|
lang
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
@@ -27,28 +74,55 @@
|
|||||||
<svelte:head><title>{lang === 'en' ? 'Exercises' : 'Übungen'} - Bocken</title></svelte:head>
|
<svelte:head><title>{lang === 'en' ? 'Exercises' : 'Übungen'} - Bocken</title></svelte:head>
|
||||||
|
|
||||||
<div class="exercises-page">
|
<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>
|
<h1>{t('exercises_title', lang)}</h1>
|
||||||
|
|
||||||
|
<!-- Mobile: inline, not split -->
|
||||||
|
<div class="mobile-filter">
|
||||||
|
<MuscleFilter bind:selectedGroups={muscleGroups} {lang} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
|
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<select bind:value={bodyPartFilter}>
|
<select onchange={(e) => { addMuscle(e.target.value); e.target.value = ''; }}>
|
||||||
<option value="">{t('all_body_parts', lang)}</option>
|
<option value="">{isEn ? 'Muscle group' : 'Muskelgruppe'}</option>
|
||||||
{#each filterOptions.bodyParts as bp}
|
{#each availableOptions as group}
|
||||||
{@const label = translateTerm(bp, lang)}<option value={bp}>{label.charAt(0).toUpperCase() + label.slice(1)}</option>
|
<option value={group}>{muscleLabel(group)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<select bind:value={equipmentFilter}>
|
<select onchange={(e) => { addEquipment(e.target.value); e.target.value = ''; }}>
|
||||||
<option value="">{t('all_equipment', lang)}</option>
|
<option value="">{t('all_equipment', lang)}</option>
|
||||||
{#each filterOptions.equipment as eq}
|
{#each availableEquipment as eq}
|
||||||
{@const label = translateTerm(eq, lang)}<option value={eq}>{label.charAt(0).toUpperCase() + label.slice(1)}</option>
|
<option value={eq}>{equipmentLabel(eq)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<ul class="exercise-list">
|
||||||
{#each filtered as exercise (exercise.id)}
|
{#each filtered as exercise (exercise.id)}
|
||||||
<li>
|
<li>
|
||||||
@@ -71,11 +145,46 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.4rem;
|
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 {
|
.search-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -110,6 +219,45 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: 0.8rem;
|
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 {
|
.exercise-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -118,7 +266,8 @@
|
|||||||
.exercise-row {
|
.exercise-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem 0;
|
gap: 0.6rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
@@ -126,9 +275,10 @@
|
|||||||
.exercise-row:hover {
|
.exercise-row:hover {
|
||||||
background: var(--color-surface-hover);
|
background: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
.exercise-info {
|
.exercise-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.exercise-name {
|
.exercise-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
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([
|
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}/history?limit=20`),
|
||||||
fetch(`/api/fitness/exercises/${params.id}/stats`)
|
fetch(`/api/fitness/exercises/${params.id}/stats`)
|
||||||
]);
|
]);
|
||||||
@@ -12,8 +13,11 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
|
|||||||
error(404, 'Exercise not found');
|
error(404, 'Exercise not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exerciseData = await exerciseRes.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exercise: await exerciseRes.json(),
|
exercise: exerciseData.exercise,
|
||||||
|
similar: exerciseData.similar ?? [],
|
||||||
history: await historyRes.json(),
|
history: await historyRes.json(),
|
||||||
stats: await statsRes.json()
|
stats: await statsRes.json()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getExerciseById, localizeExercise } from '$lib/data/exercises';
|
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
|
||||||
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
|
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 lang = $derived(detectFitnessLang($page.url.pathname));
|
||||||
|
const s = $derived(fitnessSlugs(lang));
|
||||||
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
@@ -31,11 +34,9 @@
|
|||||||
|
|
||||||
let activeTab = $state('about');
|
let activeTab = $state('about');
|
||||||
|
|
||||||
const rawExercise = $derived(data.exercise?.exercise ?? getExerciseById(data.exercise?.id));
|
const exercise = $derived(data.exercise ?? getEnrichedExerciseById($page.params.id, lang));
|
||||||
const exercise = $derived(rawExercise ? localizeExercise(rawExercise, lang) : undefined);
|
const similar = $derived(data.similar ?? []);
|
||||||
// History API returns { history: [{ sessionId, sessionName, date, sets }], total }
|
|
||||||
const history = $derived(data.history?.history ?? []);
|
const history = $derived(data.history?.history ?? []);
|
||||||
// Stats API returns { charts: { est1rmOverTime, ... }, personalRecords: { ... }, records }
|
|
||||||
const stats = $derived(data.stats ?? {});
|
const stats = $derived(data.stats ?? {});
|
||||||
const charts = $derived(stats.charts ?? {});
|
const charts = $derived(stats.charts ?? {});
|
||||||
const prs = $derived(stats.personalRecords ?? {});
|
const prs = $derived(stats.personalRecords ?? {});
|
||||||
@@ -67,90 +68,36 @@
|
|||||||
}, '#EBCB8B');
|
}, '#EBCB8B');
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/** @param {number[]} data */
|
||||||
* Compute linear regression trendline + ±1σ bands for a data array.
|
|
||||||
* Returns { trend, upper, lower } arrays of same length.
|
|
||||||
* @param {number[]} data
|
|
||||||
*/
|
|
||||||
function trendWithBands(data) {
|
function trendWithBands(data) {
|
||||||
const n = data.length;
|
const n = data.length;
|
||||||
if (n < 3) return null;
|
if (n < 3) return null;
|
||||||
|
|
||||||
// Linear regression
|
|
||||||
let sx = 0, sy = 0, sxx = 0, sxy = 0;
|
let sx = 0, sy = 0, sxx = 0, sxy = 0;
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
sx += i; sy += data[i]; sxx += i * i; sxy += i * data[i];
|
sx += i; sy += data[i]; sxx += i * i; sxy += i * data[i];
|
||||||
}
|
}
|
||||||
const slope = (n * sxy - sx * sy) / (n * sxx - sx * sx);
|
const slope = (n * sxy - sx * sy) / (n * sxx - sx * sx);
|
||||||
const intercept = (sy - slope * sx) / n;
|
const intercept = (sy - slope * sx) / n;
|
||||||
|
|
||||||
const trend = data.map((_, i) => Math.round((intercept + slope * i) * 10) / 10);
|
const trend = data.map((_, i) => Math.round((intercept + slope * i) * 10) / 10);
|
||||||
|
|
||||||
// Residual standard deviation
|
|
||||||
let ssRes = 0;
|
let ssRes = 0;
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) { const r = data[i] - trend[i]; ssRes += r * r; }
|
||||||
const r = data[i] - trend[i];
|
|
||||||
ssRes += r * r;
|
|
||||||
}
|
|
||||||
const sigma = Math.sqrt(ssRes / (n - 2));
|
const sigma = Math.sqrt(ssRes / (n - 2));
|
||||||
|
return { trend, upper: trend.map(v => Math.round((v + sigma) * 10) / 10), lower: trend.map(v => Math.round((v - sigma) * 10) / 10) };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @param {{ labels: string[], datasets: Array<any> }} chartData @param {string} trendColor */
|
||||||
* Add trendline + uncertainty datasets to a chart data object.
|
|
||||||
* @param {{ labels: string[], datasets: Array<any> }} chartData
|
|
||||||
* @param {string} trendColor
|
|
||||||
*/
|
|
||||||
function withTrend(chartData, trendColor = primary) {
|
function withTrend(chartData, trendColor = primary) {
|
||||||
const values = chartData.datasets[0]?.data;
|
const values = chartData.datasets[0]?.data;
|
||||||
if (!values || values.length < 3) return chartData;
|
if (!values || values.length < 3) return chartData;
|
||||||
|
|
||||||
const bands = trendWithBands(values);
|
const bands = trendWithBands(values);
|
||||||
if (!bands) return chartData;
|
if (!bands) return chartData;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels: chartData.labels,
|
labels: chartData.labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{ label: '± 1σ', data: bands.upper, borderColor: 'transparent', backgroundColor: `${trendColor}26`, fill: '+1', pointRadius: 0, borderWidth: 0, tension: 0.3, order: 2 },
|
||||||
label: '± 1σ',
|
{ label: '± 1σ (lower)', data: bands.lower, borderColor: 'transparent', backgroundColor: 'transparent', fill: false, pointRadius: 0, borderWidth: 0, tension: 0.3, order: 2 },
|
||||||
data: bands.upper,
|
{ label: 'Trend', data: bands.trend, borderColor: trendColor, pointRadius: 0, borderWidth: 2, tension: 0.3, order: 1 },
|
||||||
borderColor: 'transparent',
|
{ ...chartData.datasets[0], borderWidth: 1, order: 0 }
|
||||||
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'}
|
{#if activeTab === 'about'}
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
{#if exercise?.imageUrl}
|
<!-- Tags -->
|
||||||
<img src={exercise.imageUrl} alt={exercise.localName} class="exercise-image" />
|
|
||||||
{/if}
|
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span class="tag body-part">{exercise?.localBodyPart}</span>
|
<span class="tag body-part">{exercise?.localBodyPart}</span>
|
||||||
<span class="tag equipment">{exercise?.localEquipment}</span>
|
<span class="tag equipment">{exercise?.localEquipment}</span>
|
||||||
<span class="tag target">{exercise?.localTarget}</span>
|
<span class="tag target">{exercise?.localTarget}</span>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
{#if exercise?.localInstructions?.length}
|
{#if exercise?.localInstructions?.length}
|
||||||
<h3>{t('instructions', lang)}</h3>
|
<h3>{t('instructions', lang)}</h3>
|
||||||
<ol class="instructions">
|
<ol class="instructions">
|
||||||
@@ -201,6 +160,24 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{:else if activeTab === 'history'}
|
{:else if activeTab === 'history'}
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@@ -325,14 +302,7 @@
|
|||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* About */
|
/* Tags */
|
||||||
.exercise-image {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 300px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
.tags {
|
.tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -349,14 +319,39 @@
|
|||||||
.body-part { background: rgba(94, 129, 172, 0.2); color: var(--nord9); }
|
.body-part { background: rgba(94, 129, 172, 0.2); color: var(--nord9); }
|
||||||
.equipment { background: rgba(163, 190, 140, 0.2); color: var(--nord14); }
|
.equipment { background: rgba(163, 190, 140, 0.2); color: var(--nord14); }
|
||||||
.target { background: rgba(208, 135, 112, 0.2); color: var(--nord12); }
|
.target { background: rgba(208, 135, 112, 0.2); color: var(--nord12); }
|
||||||
.secondary {
|
|
||||||
font-size: 0.8rem;
|
/* Muscle pills */
|
||||||
color: var(--color-text-secondary);
|
.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;
|
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 {
|
h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin: 1rem 0 0.5rem;
|
margin: 0.75rem 0 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
.instructions {
|
.instructions {
|
||||||
padding-left: 1.25rem;
|
padding-left: 1.25rem;
|
||||||
@@ -367,6 +362,54 @@
|
|||||||
margin-bottom: 0.4rem;
|
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 */
|
/* History */
|
||||||
.empty {
|
.empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { PageServerLoad } from './$types';
|
|||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||||
const session = await locals.auth();
|
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/stats/overview'),
|
||||||
fetch('/api/fitness/goal')
|
fetch('/api/fitness/goal'),
|
||||||
|
fetch('/api/fitness/stats/muscle-heatmap?weeks=8')
|
||||||
]);
|
]);
|
||||||
const stats = await res.json();
|
const stats = await res.json();
|
||||||
const goal = goalRes.ok ? await goalRes.json() : { weeklyWorkouts: null, streak: 0 };
|
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>
|
<script>
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
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 { Dumbbell, Route, Flame, Weight } from '@lucide/svelte';
|
||||||
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
|
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -221,6 +222,11 @@
|
|||||||
height="220px"
|
height="220px"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="section-block">
|
||||||
|
<h2 class="section-title">{t('muscle_balance', lang)}</h2>
|
||||||
|
<MuscleHeatmap data={data.muscleHeatmap} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -519,4 +525,16 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
padding: 2rem 0;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user