feat(fitness): classify exercises by type and redesign filter UI

Distinguish stretches, strength, cardio, and plyometric exercises
with a curated `exerciseType` field that overrides mislabels in
ExerciseDB for the hand-curated stretch set. Surface the type as
pills on the detail page and as a filter toggle on the list page.

Replace the muscle-group and equipment dropdowns with horizontally
scrolling pill rows; equipment pills carry Lucide icons, and the
type toggle shows a flexed-bicep / person / layers glyph. Selected
muscle groups hoist to the front of the scroller.
This commit is contained in:
2026-04-13 08:59:03 +02:00
parent 5416110e81
commit 6e48cfd27c
6 changed files with 324 additions and 86 deletions

View File

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

View File

@@ -3,7 +3,7 @@
* 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 type { Exercise, ExerciseType, 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';
@@ -39,6 +39,7 @@ export interface EnrichedExercise extends Exercise {
secondaryMusclesDetailed: string[];
heroImage: string | null;
videoUrl: string | null;
exerciseType?: ExerciseType;
}
export interface LocalizedEnrichedExercise extends LocalizedExercise {
@@ -50,6 +51,12 @@ export interface LocalizedEnrichedExercise extends LocalizedExercise {
secondaryMusclesDetailed: string[];
heroImage: string | null;
videoUrl: string | null;
exerciseType?: ExerciseType;
}
/** True if exercise is a stretch or yoga-style flexibility pose */
export function isStretchType(t: ExerciseType | string | null | undefined): boolean {
return t === 'STRETCHING' || t === 'YOGA';
}
// Build static exercise lookup
@@ -97,6 +104,9 @@ function edbToEnriched(edb: EdbRawExercise, slug: string, staticEx?: Exercise):
secondaryMusclesDetailed: edb.secondaryMuscles ?? [],
heroImage: `/fitness/exercises/${edb.exerciseId}/720p.jpg`,
videoUrl: `/fitness/exercises/${edb.exerciseId}/video.mp4`,
// static classification wins: our curated STATIC_STRETCH_IDS is more accurate than EDB
// (EDB often tags hand-curated stretches as "STRENGTH")
exerciseType: base.exerciseType ?? (edb.exerciseType as ExerciseType | undefined),
};
}
@@ -124,6 +134,7 @@ for (const ex of staticExercises) {
secondaryMusclesDetailed: [],
heroImage: ex.imageUrl ?? null,
videoUrl: null,
exerciseType: ex.exerciseType,
});
}
}
@@ -144,6 +155,7 @@ export function localizeEnriched(e: EnrichedExercise, lang: 'en' | 'de'): Locali
secondaryMusclesDetailed: e.secondaryMusclesDetailed,
heroImage: e.heroImage,
videoUrl: e.videoUrl,
exerciseType: e.exerciseType,
};
}
@@ -187,6 +199,8 @@ export function searchAllExercises(opts: {
equipment?: string | string[];
target?: string;
muscleGroups?: string[];
/** 'stretch' keeps only stretches/yoga; 'non-stretch' excludes them */
stretchFilter?: 'stretch' | 'non-stretch';
lang?: 'en' | 'de';
}): LocalizedEnrichedExercise[] {
const lang = opts.lang ?? 'en';
@@ -202,6 +216,11 @@ export function searchAllExercises(opts: {
if (opts.target) {
results = results.filter(e => e.target === opts.target);
}
if (opts.stretchFilter === 'stretch') {
results = results.filter(e => isStretchType(e.exerciseType));
} else if (opts.stretchFilter === 'non-stretch') {
results = results.filter(e => !isStretchType(e.exerciseType));
}
if (opts.muscleGroups?.length) {
const groups = new Set(opts.muscleGroups);
results = results.filter(e => {

View File

@@ -14,6 +14,8 @@ export const METRIC_PRESETS = {
timed: ['duration', 'reps'] as MetricField[]
};
export type ExerciseType = 'STRENGTH' | 'CARDIO' | 'PLYOMETRICS' | 'YOGA' | 'STRETCHING' | 'WEIGHTLIFTING';
export interface Exercise {
id: string;
name: string;
@@ -25,6 +27,7 @@ export interface Exercise {
metrics?: MetricField[];
bilateral?: boolean; // true = weight entered is per hand, actual load is 2×
imageUrl?: string;
exerciseType?: ExerciseType;
de?: { name: string; instructions: string[] };
}
@@ -2107,6 +2110,32 @@ export const exercises: Exercise[] = [
},
];
/** IDs of static exercises classified as stretches (incl. yoga-style flexibility poses) */
const STATIC_STRETCH_IDS = new Set<string>([
'neck-circle-stretch',
'side-push-neck-stretch',
'seated-shoulder-flexor-stretch-bent-knee',
'shoulder-stretch-behind-back',
'elbows-back-stretch',
'back-pec-stretch',
'cow-stretch',
'thoracic-bridge',
'butterfly-yoga-pose',
'seated-single-leg-hamstring-stretch',
'kneeling-toe-up-hamstring-stretch',
'side-lunge-stretch',
'lying-lower-back-stretch',
'calf-stretch-wall',
'elbow-flexor-stretch',
]);
// Apply exerciseType classification post-hoc so we don't need to touch each entry
for (const ex of exercises) {
if (!ex.exerciseType && STATIC_STRETCH_IDS.has(ex.id)) {
ex.exerciseType = ex.id === 'butterfly-yoga-pose' ? 'YOGA' : 'STRETCHING';
}
}
// Lookup map for O(1) access by ID
export const exerciseMap = new Map<string, Exercise>(exercises.map((e) => [e.id, e]));

View File

@@ -167,6 +167,13 @@ const translations: Translations = {
all_body_parts: { en: 'All body parts', de: 'Alle Körperteile' },
all_equipment: { en: 'All equipment', de: 'Alle Geräte' },
no_exercises_match: { en: 'No exercises match your search.', de: 'Keine Übungen gefunden.' },
type_any: { en: 'Any type', de: 'Alle Arten' },
type_weights: { en: 'Strength', de: 'Kraft' },
type_stretches: { en: 'Stretches', de: 'Dehnen' },
stretch_pill: { en: 'Stretch', de: 'Dehnung' },
strength_pill: { en: 'Strength', de: 'Kraft' },
cardio_pill: { en: 'Cardio', de: 'Cardio' },
plyo_pill: { en: 'Plyo', de: 'Plyo' },
// Exercise detail
about: { en: 'ABOUT', de: 'INFO' },

View File

@@ -1,8 +1,7 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Search } from '@lucide/svelte';
import { getFilterOptionsAll, searchAllExercises } from '$lib/data/exercisedb';
import { Search, Cable, Cog, Dumbbell, PersonStanding, Shapes, Weight, BicepsFlexed, Layers } from '@lucide/svelte';
import { getFilterOptionsAll, searchAllExercises, isStretchType } 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';
@@ -17,23 +16,27 @@
let query = $state('');
let equipmentFilters = $state([]);
let muscleGroups = $state([]);
/** @type {'all' | 'stretch' | 'non-stretch'} */
let typeFilter = $state('all');
const filterOptions = getFilterOptionsAll();
/** All selectable muscle/body-part options for the dropdown */
/** All selectable muscle/body-part options (anatomical order) */
const allMuscleOptions = [...MUSCLE_GROUPS];
/** Muscle list with selected groups hoisted to the front, preserving anatomical order within each partition */
const orderedMuscleOptions = $derived.by(() => {
const selected = allMuscleOptions.filter(g => muscleGroups.includes(g));
const rest = allMuscleOptions.filter(g => !muscleGroups.includes(g));
return [...selected, ...rest];
});
/** 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];
@@ -44,10 +47,6 @@
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];
@@ -63,10 +62,33 @@
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
/** @param {string} eq lucide icon component for equipment type */
function equipmentIcon(eq) {
switch (eq) {
case 'barbell': return Weight;
case 'dumbbell': return Dumbbell;
case 'body weight': return PersonStanding;
case 'cable': return Cable;
case 'machine': return Cog;
default: return Shapes;
}
}
function toggleEquipment(eq) {
if (equipmentFilters.includes(eq)) removeEquipment(eq);
else addEquipment(eq);
}
function toggleMuscle(group) {
if (muscleGroups.includes(group)) removeMuscle(group);
else addMuscle(group);
}
const filtered = $derived(searchAllExercises({
search: query || undefined,
equipment: equipmentFilters.length ? equipmentFilters : undefined,
muscleGroups: muscleGroups.length ? muscleGroups : undefined,
stretchFilter: typeFilter === 'all' ? undefined : typeFilter,
lang
}));
</script>
@@ -91,47 +113,98 @@
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
</div>
<div class="filters">
<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 onchange={(e) => { addEquipment(e.target.value); e.target.value = ''; }}>
<option value="">{t('all_equipment', lang)}</option>
{#each availableEquipment as eq}
<option value={eq}>{equipmentLabel(eq)}</option>
{/each}
</select>
<div class="type-toggle" role="tablist" aria-label={isEn ? 'Exercise type filter' : 'Filter nach Übungsart'}>
<button
role="tab"
aria-selected={typeFilter === 'all'}
class="type-btn"
class:active={typeFilter === 'all'}
onclick={() => typeFilter = 'all'}
>
<Layers size={14} strokeWidth={2.2} />
<span>{t('type_any', lang)}</span>
</button>
<button
role="tab"
aria-selected={typeFilter === 'non-stretch'}
class="type-btn"
class:active={typeFilter === 'non-stretch'}
onclick={() => typeFilter = 'non-stretch'}
>
<BicepsFlexed size={14} strokeWidth={2.2} />
<span>{t('type_weights', lang)}</span>
</button>
<button
role="tab"
aria-selected={typeFilter === 'stretch'}
class="type-btn"
class:active={typeFilter === 'stretch'}
onclick={() => typeFilter = 'stretch'}
>
<PersonStanding size={14} strokeWidth={2.2} />
<span>{t('type_stretches', lang)}</span>
</button>
</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>
<section class="pill-group">
<div class="pill-group-header">
<span class="pill-group-label">{isEn ? 'Equipment' : 'Ausrüstung'}</span>
{#if equipmentFilters.length > 0}
<button class="mini-clear" onclick={() => equipmentFilters = []}>
{isEn ? 'clear' : 'löschen'}
</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}
<button class="clear-filters" onclick={() => { muscleGroups = []; equipmentFilters = []; }}>
{isEn ? 'Clear all' : 'Alle löschen'}
</button>
{/if}
</div>
{/if}
<div class="pill-scroll">
{#each filterOptions.equipment as eq (eq)}
{@const active = equipmentFilters.includes(eq)}
{@const Icon = equipmentIcon(eq)}
<button
class="chip equipment-chip"
class:active
aria-pressed={active}
onclick={() => toggleEquipment(eq)}
>
<Icon size={14} strokeWidth={2.2} />
<span>{equipmentLabel(eq)}</span>
</button>
{/each}
</div>
</section>
<section class="pill-group">
<div class="pill-group-header">
<span class="pill-group-label">{isEn ? 'Muscle Group' : 'Muskelgruppe'}</span>
{#if muscleGroups.length > 0}
<button class="mini-clear" onclick={() => muscleGroups = []}>
{isEn ? 'clear' : 'löschen'}
</button>
{/if}
</div>
<div class="pill-scroll no-left-fade">
{#each orderedMuscleOptions as group (group)}
{@const active = muscleGroups.includes(group)}
<button
class="chip muscle-chip"
class:active
aria-pressed={active}
onclick={() => toggleMuscle(group)}
>{muscleLabel(group)}</button>
{/each}
</div>
</section>
<ul class="exercise-list">
{#each filtered as exercise (exercise.id)}
<li>
<a href="/fitness/{sl.exercises}/{exercise.id}" class="exercise-row">
<div class="exercise-info">
<span class="exercise-name">{exercise.localName}</span>
<span class="exercise-name">
{exercise.localName}
{#if isStretchType(exercise.exerciseType)}
<span class="stretch-badge">{t('stretch_pill', lang)}</span>
{/if}
</span>
<span class="exercise-meta">{exercise.localBodyPart} · {exercise.localEquipment}</span>
</div>
</a>
@@ -209,69 +282,145 @@
.search-bar input::placeholder {
color: var(--color-text-muted);
}
.filters {
/* Pill group filters (equipment + muscle) */
.pill-group {
display: flex;
gap: 0.5rem;
flex-direction: column;
gap: 0.35rem;
}
.filters select {
flex: 1;
padding: 0.4rem 0.5rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
color: inherit;
font-size: 0.8rem;
}
.selected-pills {
.pill-group-header {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: baseline;
justify-content: space-between;
padding-inline: 0.1rem;
}
.filter-pill {
.pill-group-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-text-tertiary);
}
.mini-clear {
all: unset;
-webkit-tap-highlight-color: transparent;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-secondary);
cursor: pointer;
}
.mini-clear:hover {
color: var(--color-primary);
}
.pill-scroll {
display: flex;
gap: 0.4rem;
overflow-x: auto;
scrollbar-width: none;
padding: 0.15rem 0.1rem 0.35rem;
mask-image: linear-gradient(to right, transparent 0, #000 0.6rem, #000 calc(100% - 0.6rem), transparent 100%);
}
.pill-scroll.no-left-fade {
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.6rem), transparent 100%);
}
.pill-scroll::-webkit-scrollbar {
display: none;
}
.chip {
all: unset;
-webkit-tap-highlight-color: transparent;
flex-shrink: 0;
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;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
border-radius: var(--radius-pill, 1000px);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.01em;
text-transform: capitalize;
cursor: pointer;
transition: filter 0.1s, transform 0.1s;
white-space: nowrap;
border: 1px solid transparent;
transition: background var(--transition-fast, 100ms), color var(--transition-fast, 100ms), border-color var(--transition-fast, 100ms), transform var(--transition-fast, 100ms), box-shadow var(--transition-fast, 100ms);
}
.filter-pill:hover {
filter: brightness(1.1);
transform: scale(1.05);
.chip :global(svg) {
flex-shrink: 0;
}
.filter-pill:active {
transform: scale(0.95);
.chip:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
transform: scale(1.04);
}
.filter-pill.muscle {
background: var(--lightblue);
color: black;
.chip:active {
transform: scale(0.96);
}
.filter-pill.equipment {
background: var(--blue);
color: white;
.chip.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
.pill-remove {
font-size: 0.7rem;
font-weight: bold;
margin-left: 0.1rem;
.chip.equipment-chip :global(svg) {
opacity: 0.85;
}
.clear-filters {
.chip.equipment-chip.active :global(svg) {
opacity: 1;
}
.type-toggle {
display: flex;
gap: 0.25rem;
background: var(--color-bg-tertiary);
border-radius: var(--radius-pill, 100px);
padding: 0.2rem;
}
.type-btn {
all: unset;
-webkit-tap-highlight-color: transparent;
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
text-align: center;
padding: 0.35rem 0.6rem;
border-radius: var(--radius-pill, 100px);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0.25rem 0.4rem;
transition: background 0.15s, color 0.15s;
}
.clear-filters:hover {
.type-btn :global(svg) {
flex-shrink: 0;
opacity: 0.8;
}
.type-btn.active :global(svg) {
opacity: 1;
}
.type-btn:hover {
color: var(--color-text-primary);
text-decoration: underline;
}
.type-btn.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.stretch-badge {
display: inline-block;
vertical-align: middle;
margin-left: 0.4rem;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-pill, 100px);
background: rgba(180, 142, 173, 0.2);
color: var(--nord15);
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.exercise-list {

View File

@@ -1,6 +1,25 @@
<script>
import { page } from '$app/stores';
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
/** @param {string | undefined | null} type @param {'en'|'de'} lang */
function exerciseTypeInfo(type, lang) {
if (!type) return null;
switch (type) {
case 'STRETCHING':
case 'YOGA':
return { key: 'stretch', label: t('stretch_pill', lang) };
case 'STRENGTH':
case 'WEIGHTLIFTING':
return { key: 'strength', label: t('strength_pill', lang) };
case 'CARDIO':
return { key: 'cardio', label: t('cardio_pill', lang) };
case 'PLYOMETRICS':
return { key: 'plyo', label: t('plyo_pill', lang) };
default:
return null;
}
}
import { localizeExercise, translateTerm } from '$lib/data/exercises';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { ChevronRight } from '@lucide/svelte';
@@ -36,6 +55,7 @@
let activeTab = $state('about');
const exercise = $derived(data.exercise ?? getEnrichedExerciseById($page.params.id, lang));
const typeInfo = $derived(exerciseTypeInfo(exercise?.exerciseType, lang));
const similar = $derived(data.similar ?? []);
const history = $derived(data.history?.history ?? []);
const stats = $derived(data.stats ?? {});
@@ -133,6 +153,9 @@
<div class="about-main">
<!-- Tags -->
<div class="tags">
{#if typeInfo}
<span class="tag type-{typeInfo.key}">{typeInfo.label}</span>
{/if}
<span class="tag body-part">{exercise?.localBodyPart}</span>
<span class="tag equipment">{exercise?.localEquipment}</span>
<span class="tag target">{exercise?.localTarget}</span>
@@ -350,6 +373,17 @@
.body-part { background: rgba(94, 129, 172, 0.2); color: var(--nord9); }
.equipment { background: rgba(163, 190, 140, 0.2); color: var(--nord14); }
.target { background: rgba(208, 135, 112, 0.2); color: var(--nord12); }
.tag.type-stretch,
.tag.type-strength,
.tag.type-cardio,
.tag.type-plyo {
font-weight: 700;
letter-spacing: 0.04em;
}
.tag.type-stretch { background: rgba(180, 142, 173, 0.2); color: var(--nord15); }
.tag.type-strength { background: rgba(94, 129, 172, 0.2); color: var(--nord10); }
.tag.type-cardio { background: rgba(191, 97, 106, 0.2); color: var(--nord11); }
.tag.type-plyo { background: rgba(235, 203, 139, 0.22); color: var(--nord13); }
/* About layout — two-column on wide screens */
.about-layout {