From 6e48cfd27c2c1bdc0a996780ad46b83056f8de45 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Mon, 13 Apr 2026 08:59:03 +0200 Subject: [PATCH] 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. --- package.json | 2 +- src/lib/data/exercisedb.ts | 21 +- src/lib/data/exercises.ts | 29 ++ src/lib/js/fitnessI18n.ts | 7 + .../[exercises=fitnessExercises]/+page.svelte | 317 +++++++++++++----- .../[id]/+page.svelte | 34 ++ 6 files changed, 324 insertions(+), 86 deletions(-) diff --git a/package.json b/package.json index c57c9fd..87ba2c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.29.0", + "version": "1.30.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/data/exercisedb.ts b/src/lib/data/exercisedb.ts index 13eae84..fcbaf22 100644 --- a/src/lib/data/exercisedb.ts +++ b/src/lib/data/exercisedb.ts @@ -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 => { diff --git a/src/lib/data/exercises.ts b/src/lib/data/exercises.ts index 5dba08e..2b99050 100644 --- a/src/lib/data/exercises.ts +++ b/src/lib/data/exercises.ts @@ -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([ + '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(exercises.map((e) => [e.id, e])); diff --git a/src/lib/js/fitnessI18n.ts b/src/lib/js/fitnessI18n.ts index bcdfc9d..60bbbdc 100644 --- a/src/lib/js/fitnessI18n.ts +++ b/src/lib/js/fitnessI18n.ts @@ -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' }, diff --git a/src/routes/fitness/[exercises=fitnessExercises]/+page.svelte b/src/routes/fitness/[exercises=fitnessExercises]/+page.svelte index 615a203..c85b5b8 100644 --- a/src/routes/fitness/[exercises=fitnessExercises]/+page.svelte +++ b/src/routes/fitness/[exercises=fitnessExercises]/+page.svelte @@ -1,8 +1,7 @@ @@ -91,47 +113,98 @@ -
- - +
+ + +
- {#if muscleGroups.length > 0 || equipmentFilters.length > 0} -
- {#each muscleGroups as group} - - {/each} - {#each equipmentFilters as eq} - - {/each} - + {/if}
- {/if} +
+ {#each filterOptions.equipment as eq (eq)} + {@const active = equipmentFilters.includes(eq)} + {@const Icon = equipmentIcon(eq)} + + {/each} +
+ + +
+
+ {isEn ? 'Muscle Group' : 'Muskelgruppe'} + {#if muscleGroups.length > 0} + + {/if} +
+
+ {#each orderedMuscleOptions as group (group)} + {@const active = muscleGroups.includes(group)} + + {/each} +
+
    {#each filtered as exercise (exercise.id)}
  • - {exercise.localName} + + {exercise.localName} + {#if isStretchType(exercise.exerciseType)} + {t('stretch_pill', lang)} + {/if} + {exercise.localBodyPart} · {exercise.localEquipment}
    @@ -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 { diff --git a/src/routes/fitness/[exercises=fitnessExercises]/[id]/+page.svelte b/src/routes/fitness/[exercises=fitnessExercises]/[id]/+page.svelte index eccdff9..016a788 100644 --- a/src/routes/fitness/[exercises=fitnessExercises]/[id]/+page.svelte +++ b/src/routes/fitness/[exercises=fitnessExercises]/[id]/+page.svelte @@ -1,6 +1,25 @@