fitness: add bilingual EN/DE support for all fitness routes and components
All checks were successful
CI / update (push) Successful in 2m4s

Use SvelteKit param matchers for bilingual URL routing (e.g. /fitness/stats
and /fitness/statistik). Add centralized i18n module with translation
dictionary, language detection from URL, and path conversion utilities.
Translate all UI text across pages, components, and navigation.
This commit is contained in:
2026-03-22 21:24:56 +01:00
parent f83bc7fa1e
commit f5420badc1
31 changed files with 524 additions and 179 deletions

View File

@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { recipeTranslationStore } from '$lib/stores/recipeTranslation';
import { languageStore } from '$lib/stores/language';
import { convertFitnessPath } from '$lib/js/fitnessI18n';
import { onMount } from 'svelte';
let { lang = undefined }: { lang?: 'de' | 'en' } = $props();
@@ -29,6 +30,8 @@
languageStore.set('en');
} else if (path.startsWith('/rezepte') || path.startsWith('/glaube')) {
languageStore.set('de');
} else if (path.startsWith('/fitness')) {
// Language is determined by sub-route slugs; don't override store
} else {
// On other pages, read from localStorage
if (typeof localStorage !== 'undefined') {
@@ -67,6 +70,10 @@
return convertFaithPath(path, targetLang);
}
if (path.startsWith('/fitness')) {
return convertFitnessPath(path, targetLang);
}
// Use translated recipe slugs from page data when available (works during SSR)
const pageData = $page.data;
if (targetLang === 'en' && path.startsWith('/rezepte')) {
@@ -105,7 +112,8 @@
// For pages that handle their own translations inline (not recipe/faith routes),
// dispatch event and stay on the page
if (!path.startsWith('/rezepte') && !path.startsWith('/recipes')
&& !path.startsWith('/glaube') && !path.startsWith('/faith')) {
&& !path.startsWith('/glaube') && !path.startsWith('/faith')
&& !path.startsWith('/fitness')) {
window.dispatchEvent(new CustomEvent('languagechange', { detail: { lang } }));
return;
}
@@ -117,6 +125,13 @@
return;
}
// Handle fitness pages
if (path.startsWith('/fitness')) {
const newPath = convertFitnessPath(path, lang);
await goto(newPath);
return;
}
// If we have recipe translation data from store, use the correct short names
const recipeData = $recipeTranslationStore;
if (recipeData) {

View File

@@ -1,13 +1,16 @@
<script>
import { page } from '$app/stores';
import { getExerciseById } from '$lib/data/exercises';
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
let { exerciseId } = $props();
const exercise = $derived(getExerciseById(exerciseId));
const sl = $derived(fitnessSlugs(detectFitnessLang($page.url.pathname)));
</script>
{#if exercise}
<a href="/fitness/exercises/{exerciseId}" class="exercise-link">{exercise.name}</a>
<a href="/fitness/{sl.exercises}/{exerciseId}" class="exercise-link">{exercise.name}</a>
{:else}
<span class="exercise-unknown">Unknown Exercise</span>
{/if}

View File

@@ -1,6 +1,10 @@
<script>
import { exercises, getFilterOptions, searchExercises } from '$lib/data/exercises';
import { Search, X } from 'lucide-svelte';
import { page } from '$app/stores';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
/**
* @type {{
@@ -35,7 +39,7 @@
<div class="picker-backdrop" onclick={onClose}></div>
<div class="picker-panel">
<div class="picker-header">
<h2>Add Exercise</h2>
<h2>{t('picker_title', lang)}</h2>
<button class="close-btn" onclick={onClose} aria-label="Close">
<X size={20} />
</button>
@@ -45,20 +49,20 @@
<Search size={16} />
<input
type="text"
placeholder="Search exercises…"
placeholder={t('search_exercises', lang)}
bind:value={query}
/>
</div>
<div class="picker-filters">
<select bind:value={bodyPartFilter}>
<option value="">All body parts</option>
<option value="">{t('all_body_parts', lang)}</option>
{#each filterOptions.bodyParts as bp (bp)}
<option value={bp}>{bp.charAt(0).toUpperCase() + bp.slice(1)}</option>
{/each}
</select>
<select bind:value={equipmentFilter}>
<option value="">All equipment</option>
<option value="">{t('all_equipment', lang)}</option>
{#each filterOptions.equipment as eq (eq)}
<option value={eq}>{eq.charAt(0).toUpperCase() + eq.slice(1)}</option>
{/each}
@@ -75,7 +79,7 @@
</li>
{/each}
{#if filtered.length === 0}
<li class="no-results">No exercises found</li>
<li class="no-results">{t('no_exercises_found', lang)}</li>
{/if}
</ul>
</div>

View File

@@ -1,6 +1,10 @@
<script>
import { page } from '$app/stores';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { Clock, Weight, Trophy, Route, Gauge } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
const sl = $derived(fitnessSlugs(detectFitnessLang($page.url.pathname)));
/**
* @type {{
@@ -138,7 +142,7 @@
});
</script>
<a href="/fitness/history/{session._id}" class="session-card">
<a href="/fitness/{sl.history}/{session._id}" class="session-card">
<div class="card-top">
<h3 class="session-name">{session.name}</h3>
<span class="session-date">{formatDate(session.startTime)} &middot; {formatTime(session.startTime)}</span>

View File

@@ -2,6 +2,10 @@
import { Check, X } from 'lucide-svelte';
import { METRIC_LABELS } from '$lib/data/exercises';
import RestTimer from './RestTimer.svelte';
import { page } from '$app/stores';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
/**
* @type {{
@@ -75,16 +79,16 @@
{#if editable && onRemove}
<th class="col-remove"></th>
{/if}
<th class="col-set">SET</th>
<th class="col-set">{t('set_header', lang)}</th>
{#if previousSets}
<th class="col-prev">PREV</th>
<th class="col-prev">{t('prev_header', lang)}</th>
{/if}
{#each mainMetrics as metric (metric)}
<th class="col-metric">{METRIC_LABELS[metric]}</th>
{/each}
{#if editable && hasRpe}
<th class="col-at"></th>
<th class="col-rpe">RPE</th>
<th class="col-rpe">{t('rpe', lang)}</th>
{/if}
{#if editable}
<th class="col-check"></th>

View File

@@ -1,6 +1,10 @@
<script>
import { getExerciseById } from '$lib/data/exercises';
import { EllipsisVertical } from 'lucide-svelte';
import { page } from '$app/stores';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
/**
* @type {{
@@ -18,10 +22,10 @@
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / 86400000);
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
if (diffDays === 0) return t('today', lang);
if (diffDays === 1) return t('yesterday', lang);
if (diffDays < 7) return lang === 'en' ? `${diffDays} days ago` : `vor ${diffDays} Tagen`;
return d.toLocaleDateString(lang === 'en' ? 'en' : 'de', { month: 'short', day: 'numeric' });
}
</script>
@@ -44,11 +48,11 @@
<li>{ex.sets.length} &times; {exercise?.name ?? ex.exerciseId}</li>
{/each}
{#if template.exercises.length > 4}
<li class="more">+{template.exercises.length - 4} more</li>
<li class="more">+{template.exercises.length - 4} {t('more', lang)}</li>
{/if}
</ul>
{#if lastUsed}
<p class="last-used">Last performed: {formatDate(lastUsed)}</p>
<p class="last-used">{t('last_performed', lang)} {formatDate(lastUsed)}</p>
{/if}
</div>

View File

@@ -2,6 +2,10 @@
import { goto } from '$app/navigation';
import { Play, Pause } from 'lucide-svelte';
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
import { page } from '$app/stores';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
let { href, elapsed = '0:00', paused = false, syncStatus = 'idle', onPauseToggle,
restSeconds = 0, restTotal = 0, onRestAdjust = null, onRestSkip = null } = $props();
@@ -35,7 +39,7 @@ const restProgress = $derived(restTotal > 0 ? restSeconds / restTotal : 0);
</div>
</div>
{:else}
<span class="bar-label">Active Workout</span>
<span class="bar-label">{t('active_workout', lang)}</span>
{/if}
</div>

View File

@@ -613,7 +613,9 @@ export const exercises: Exercise[] = [
'Stand with feet shoulder-width apart.',
'Squat down until thighs are at least parallel to the floor.',
'Drive through your heels to stand back up.'
]
],
imageUrl: "/fitness/squat-barbell/0.svg"
},
{
id: 'front-squat-barbell',

224
src/lib/js/fitnessI18n.ts Normal file
View File

@@ -0,0 +1,224 @@
/** Fitness route i18n — slug mappings and UI translations */
const slugMap: Record<string, Record<string, string>> = {
en: { statistik: 'stats', verlauf: 'history', training: 'workout', aktiv: 'active', uebungen: 'exercises', messen: 'measure' },
de: { stats: 'statistik', history: 'verlauf', workout: 'training', active: 'aktiv', exercises: 'uebungen', measure: 'messen' }
};
const germanSlugs = new Set(Object.keys(slugMap.en));
/** Detect language from a fitness path by checking for any German slug */
export function detectFitnessLang(pathname: string): 'en' | 'de' {
const segments = pathname.replace(/^\/fitness\/?/, '').split('/');
for (const seg of segments) {
if (germanSlugs.has(seg)) return 'de';
}
return 'en';
}
/** Convert a fitness path to the target language */
export function convertFitnessPath(pathname: string, targetLang: 'en' | 'de'): string {
const map = slugMap[targetLang];
const segments = pathname.split('/');
return segments.map(seg => map[seg] ?? seg).join('/');
}
/** Get translated sub-route slugs for a given language */
export function fitnessSlugs(lang: 'en' | 'de') {
return {
stats: lang === 'en' ? 'stats' : 'statistik',
history: lang === 'en' ? 'history' : 'verlauf',
workout: lang === 'en' ? 'workout' : 'training',
active: lang === 'en' ? 'active' : 'aktiv',
exercises: lang === 'en' ? 'exercises' : 'uebungen',
measure: lang === 'en' ? 'measure' : 'messen'
};
}
/** Get translated nav labels */
export function fitnessLabels(lang: 'en' | 'de') {
return {
stats: lang === 'en' ? 'Stats' : 'Statistik',
history: lang === 'en' ? 'History' : 'Verlauf',
workout: lang === 'en' ? 'Workout' : 'Training',
exercises: lang === 'en' ? 'Exercises' : 'Übungen',
measure: lang === 'en' ? 'Measure' : 'Messen'
};
}
type Translations = Record<string, Record<string, string>>;
const translations: Translations = {
// Common
save: { en: 'Save', de: 'Speichern' },
saving: { en: 'Saving…', de: 'Speichern…' },
cancel: { en: 'CANCEL', de: 'ABBRECHEN' },
delete_: { en: 'Delete', de: 'Löschen' },
edit: { en: 'Edit', de: 'Bearbeiten' },
loading: { en: 'Loading…', de: 'Laden…' },
set: { en: 'set', de: 'Satz' },
sets: { en: 'sets', de: 'Sätze' },
exercise: { en: 'exercise', de: 'Übung' },
exercises_word: { en: 'exercises', de: 'Übungen' },
// Units
kg: { en: 'kg', de: 'kg' },
km: { en: 'km', de: 'km' },
min: { en: 'min', de: 'Min' },
// Stats page
stats_title: { en: 'Stats', de: 'Statistik' },
workout_singular: { en: 'Workout', de: 'Training' },
workouts_plural: { en: 'Workouts', de: 'Trainings' },
lifted: { en: 'Lifted', de: 'Gehoben' },
distance_covered: { en: 'Distance Covered', de: 'Zurückgelegt' },
workouts_per_week: { en: 'Workouts per week', de: 'Trainings pro Woche' },
no_workout_data: { en: 'No workout data to display yet.', de: 'Noch keine Trainingsdaten vorhanden.' },
weight: { en: 'Weight', de: 'Gewicht' },
// History page
history_title: { en: 'History', de: 'Verlauf' },
no_workouts_yet: { en: 'No workouts yet. Start your first workout!', de: 'Noch keine Trainings. Starte dein erstes Training!' },
load_more: { en: 'Load more', de: 'Mehr laden' },
// History detail
date: { en: 'Date', de: 'Datum' },
time: { en: 'Time', de: 'Uhrzeit' },
duration_min: { en: 'Duration (min)', de: 'Dauer (Min)' },
notes: { en: 'Notes', de: 'Notizen' },
notes_placeholder: { en: 'Workout notes...', de: 'Trainingsnotizen...' },
gps_track_stored: { en: 'GPS track stored', de: 'GPS-Track gespeichert' },
add_set: { en: '+ ADD SET', de: '+ SATZ HINZUFÜGEN' },
add_exercise: { en: '+ ADD EXERCISE', de: '+ ÜBUNG HINZUFÜGEN' },
splits: { en: 'Splits', de: 'Splits' },
pace: { en: 'PACE', de: 'TEMPO' },
upload_gpx: { en: 'Upload GPX', de: 'GPX hochladen' },
uploading: { en: 'Uploading...', de: 'Hochladen...' },
personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' },
delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' },
remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' },
recalc_title: { en: 'Recalculate volume, PRs, and GPS previews', de: 'Volumen, PRs und GPS-Vorschauen neu berechnen' },
// Workout templates page
next_in_schedule: { en: 'Next in schedule', de: 'Nächstes im Plan' },
start_empty_workout: { en: 'START AN EMPTY WORKOUT', de: 'LEERES TRAINING STARTEN' },
templates: { en: 'Templates', de: 'Vorlagen' },
schedule: { en: 'Schedule', de: 'Zeitplan' },
my_templates: { en: 'My Templates', de: 'Meine Vorlagen' },
no_templates_yet: { en: 'No templates yet. Create one or start an empty workout.', de: 'Noch keine Vorlagen. Erstelle eine oder starte ein leeres Training.' },
edit_template: { en: 'Edit Template', de: 'Vorlage bearbeiten' },
new_template: { en: 'New Template', de: 'Neue Vorlage' },
template_name_placeholder: { en: 'Template name', de: 'Vorlagenname' },
add_set_lower: { en: '+ Add set', de: '+ Satz hinzufügen' },
add_exercise_btn: { en: 'Add Exercise', de: 'Übung hinzufügen' },
save_template: { en: 'Save Template', de: 'Vorlage speichern' },
workout_schedule: { en: 'Workout Schedule', de: 'Trainingsplan' },
schedule_hint: { en: 'Select templates and arrange their order. After completing a workout, the next one in the rotation will be suggested.', de: 'Wähle Vorlagen und ordne sie an. Nach Abschluss eines Trainings wird das nächste in der Rotation vorgeschlagen.' },
available_templates: { en: 'Available templates', de: 'Verfügbare Vorlagen' },
all_templates_scheduled: { en: 'All templates are in the schedule', de: 'Alle Vorlagen sind im Zeitplan' },
save_schedule: { en: 'Save Schedule', de: 'Zeitplan speichern' },
start_workout: { en: 'Start Workout', de: 'Training starten' },
delete_template: { en: 'Delete', de: 'Löschen' },
// Active workout / completion
workout_complete: { en: 'Workout Complete', de: 'Training abgeschlossen' },
duration: { en: 'Duration', de: 'Dauer' },
tonnage: { en: 'Tonnage', de: 'Tonnage' },
distance: { en: 'Distance', de: 'Distanz' },
exercises_heading: { en: 'Exercises', de: 'Übungen' },
volume: { en: 'volume', de: 'Volumen' },
avg: { en: 'avg', de: 'Ø' },
update_template: { en: 'Update Template', de: 'Vorlage aktualisieren' },
template_updated: { en: 'Template updated', de: 'Vorlage aktualisiert' },
template_diff_desc: { en: 'Your weights or reps differ from the template:', de: 'Gewichte oder Wiederholungen weichen von der Vorlage ab:' },
updating: { en: 'Updating...', de: 'Aktualisieren...' },
view_workout: { en: 'VIEW WORKOUT', de: 'TRAINING ANSEHEN' },
workout_name_placeholder: { en: 'Workout name', de: 'Trainingsname' },
cancel_workout: { en: 'CANCEL WORKOUT', de: 'TRAINING ABBRECHEN' },
finish: { en: 'FINISH', de: 'BEENDEN' },
new_set_added: { en: 'new set', de: 'neuer Satz' },
new_sets_added: { en: 'new sets', de: 'neue Sätze' },
// Exercises page
exercises_title: { en: 'Exercises', de: 'Übungen' },
search_exercises: { en: 'Search exercises…', de: 'Übungen suchen…' },
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.' },
// Exercise detail
about: { en: 'ABOUT', de: 'INFO' },
history_tab: { en: 'HISTORY', de: 'VERLAUF' },
charts: { en: 'CHARTS', de: 'DIAGRAMME' },
records: { en: 'RECORDS', de: 'REKORDE' },
instructions: { en: 'Instructions', de: 'Anleitung' },
no_history_yet: { en: 'No history for this exercise yet.', de: 'Noch kein Verlauf für diese Übung.' },
est_1rm: { en: 'EST. 1RM', de: 'GESCH. 1RM' },
best_set_1rm: { en: 'Best Set (Est. 1RM)', de: 'Bester Satz (Gesch. 1RM)' },
best_set_max: { en: 'Best Set (Max Weight)', de: 'Bester Satz (Max. Gewicht)' },
total_volume: { en: 'Total Volume', de: 'Gesamtvolumen' },
not_enough_data: { en: 'Not enough data to display charts yet.', de: 'Noch nicht genug Daten für Diagramme.' },
estimated_1rm: { en: 'Estimated 1RM', de: 'Geschätztes 1RM' },
max_volume: { en: 'Max Volume', de: 'Max. Volumen' },
max_weight: { en: 'Max Weight', de: 'Max. Gewicht' },
rep_records: { en: 'Rep Records', de: 'Wiederholungsrekorde' },
reps: { en: 'REPS', de: 'WDH' },
best_performance: { en: 'BEST PERFORMANCE', de: 'BESTLEISTUNG' },
// Measure page
measure_title: { en: 'Measure', de: 'Messen' },
new_measurement: { en: 'New Measurement', de: 'Neue Messung' },
edit_measurement: { en: 'Edit Measurement', de: 'Messung bearbeiten' },
weight_kg: { en: 'Weight (kg)', de: 'Gewicht (kg)' },
body_fat: { en: 'Body Fat %', de: 'Körperfett %' },
calories_kcal: { en: 'Calories (kcal)', de: 'Kalorien (kcal)' },
body_parts_cm: { en: 'Body Parts (cm)', de: 'Körpermaße (cm)' },
neck: { en: 'Neck', de: 'Hals' },
shoulders: { en: 'Shoulders', de: 'Schultern' },
chest: { en: 'Chest', de: 'Brust' },
l_bicep: { en: 'L Bicep', de: 'L Bizeps' },
r_bicep: { en: 'R Bicep', de: 'R Bizeps' },
l_forearm: { en: 'L Forearm', de: 'L Unterarm' },
r_forearm: { en: 'R Forearm', de: 'R Unterarm' },
waist: { en: 'Waist', de: 'Taille' },
hips: { en: 'Hips', de: 'Hüfte' },
l_thigh: { en: 'L Thigh', de: 'L Oberschenkel' },
r_thigh: { en: 'R Thigh', de: 'R Oberschenkel' },
l_calf: { en: 'L Calf', de: 'L Wade' },
r_calf: { en: 'R Calf', de: 'R Wade' },
save_measurement: { en: 'Save Measurement', de: 'Messung speichern' },
update_measurement: { en: 'Update Measurement', de: 'Messung aktualisieren' },
latest: { en: 'Latest', de: 'Aktuell' },
body_fat_short: { en: 'Body Fat', de: 'Körperfett' },
calories: { en: 'Calories', de: 'Kalorien' },
body_parts: { en: 'Body Parts', de: 'Körpermaße' },
body_measurements_only: { en: 'Body measurements only', de: 'Nur Körpermaße' },
delete_measurement_confirm: { en: 'Delete this measurement?', de: 'Diese Messung löschen?' },
general: { en: 'General', de: 'Allgemein' },
body_fat_pct: { en: 'Body Fat (%)', de: 'Körperfett (%)' },
history: { en: 'History', de: 'Verlauf' },
// SetTable
set_header: { en: 'SET', de: 'SATZ' },
prev_header: { en: 'PREV', de: 'VORH' },
rpe: { en: 'RPE', de: 'RPE' },
// ExercisePicker
picker_title: { en: 'Add Exercise', de: 'Übung hinzufügen' },
no_exercises_found: { en: 'No exercises found', de: 'Keine Übungen gefunden' },
// TemplateCard
last_performed: { en: 'Last performed:', de: 'Zuletzt durchgeführt:' },
today: { en: 'Today', de: 'Heute' },
yesterday: { en: 'Yesterday', de: 'Gestern' },
days_ago: { en: 'days ago', de: 'Tagen' },
more: { en: 'more', de: 'weitere' },
// WorkoutFab
active_workout: { en: 'Active Workout', de: 'Aktives Training' },
};
/** Get a translated string */
export function t(key: string, lang: 'en' | 'de'): string {
return translations[key]?.[lang] ?? translations[key]?.en ?? key;
}

View File

@@ -0,0 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return param === 'active' || param === 'aktiv';
};

View File

@@ -0,0 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return param === 'exercises' || param === 'uebungen';
};

View File

@@ -0,0 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return param === 'history' || param === 'verlauf';
};

View File

@@ -0,0 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return param === 'measure' || param === 'messen';
};

View File

@@ -0,0 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return param === 'stats' || param === 'statistik';
};

View File

@@ -0,0 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return param === 'workout' || param === 'training';
};

View File

@@ -3,10 +3,12 @@
import { onMount, onDestroy } from 'svelte';
import Header from '$lib/components/Header.svelte';
import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { BarChart3, Clock, Dumbbell, ListChecks, Ruler } from 'lucide-svelte';
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte';
import { detectFitnessLang, fitnessSlugs, fitnessLabels } from '$lib/js/fitnessI18n';
let { data, children } = $props();
let user = $derived(data.session?.user);
@@ -14,6 +16,10 @@
const workout = getWorkout();
const sync = getWorkoutSync();
const lang = $derived(detectFitnessLang($page.url.pathname));
const s = $derived(fitnessSlugs(lang));
const labels = $derived(fitnessLabels(lang));
onMount(async () => {
workout.restore();
workout.onChange(() => sync.notifyChange());
@@ -30,27 +36,36 @@
return currentPath.startsWith(path);
}
const isOnActivePage = $derived($page.url.pathname === '/fitness/workout/active');
const activePath = $derived(`/fitness/${s.workout}/${s.active}`);
const isOnActivePage = $derived($page.url.pathname === activePath);
/** @param {number} secs */
function formatElapsed(secs) {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
const sec = secs % 60;
return `${m}:${sec.toString().padStart(2, '0')}`;
}
</script>
<Header>
{#snippet links()}
<ul class="site_header">
<li><a href="/fitness/stats" class:active={isActive('/fitness/stats')}><BarChart3 size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Stats</span></a></li>
<li style="--active-fill: var(--nord13)"><a href="/fitness/history" class:active={isActive('/fitness/history')}><Clock size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">History</span></a></li>
<li style="--active-fill: var(--nord8)"><a href="/fitness/workout" class:active={isActive('/fitness/workout')}><Dumbbell size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Workout</span></a></li>
<li style="--active-fill: var(--nord14)"><a href="/fitness/exercises" class:active={isActive('/fitness/exercises')}><ListChecks size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Exercises</span></a></li>
<li style="--active-fill: var(--nord12)"><a href="/fitness/measure" class:active={isActive('/fitness/measure')}><Ruler size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Measure</span></a></li>
<li><a href="/fitness/{s.stats}" class:active={isActive(`/fitness/${s.stats}`)}><BarChart3 size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.stats}</span></a></li>
<li style="--active-fill: var(--nord13)"><a href="/fitness/{s.history}" class:active={isActive(`/fitness/${s.history}`)}><Clock size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.history}</span></a></li>
<li style="--active-fill: var(--nord8)"><a href="/fitness/{s.workout}" class:active={isActive(`/fitness/${s.workout}`)}><Dumbbell size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.workout}</span></a></li>
<li style="--active-fill: var(--nord14)"><a href="/fitness/{s.exercises}" class:active={isActive(`/fitness/${s.exercises}`)}><ListChecks size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.exercises}</span></a></li>
<li style="--active-fill: var(--nord12)"><a href="/fitness/{s.measure}" class:active={isActive(`/fitness/${s.measure}`)}><Ruler size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.measure}</span></a></li>
</ul>
{/snippet}
{#snippet language_selector_mobile()}
<LanguageSelector lang={lang} />
{/snippet}
{#snippet language_selector_desktop()}
<LanguageSelector lang={lang} />
{/snippet}
{#snippet right_side()}
<UserHeader {user} />
{/snippet}
@@ -62,7 +77,7 @@
{#if workout.active && !isOnActivePage}
<WorkoutFab
href="/fitness/workout/active"
href={activePath}
elapsed={formatElapsed(workout.elapsedSeconds)}
paused={workout.paused}
syncStatus={sync.status}

View File

@@ -1,7 +1,12 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Search } from 'lucide-svelte';
import { getFilterOptions, searchExercises } from '$lib/data/exercises';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
const sl = $derived(fitnessSlugs(lang));
let { data } = $props();
@@ -18,25 +23,25 @@
}));
</script>
<svelte:head><title>Exercises - Fitness</title></svelte:head>
<svelte:head><title>{lang === 'en' ? 'Exercises' : 'Übungen'} - Fitness</title></svelte:head>
<div class="exercises-page">
<h1>Exercises</h1>
<h1>{t('exercises_title', lang)}</h1>
<div class="search-bar">
<Search size={16} />
<input type="text" placeholder="Search exercises…" bind:value={query} />
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
</div>
<div class="filters">
<select bind:value={bodyPartFilter}>
<option value="">All body parts</option>
<option value="">{t('all_body_parts', lang)}</option>
{#each filterOptions.bodyParts as bp}
<option value={bp}>{bp.charAt(0).toUpperCase() + bp.slice(1)}</option>
{/each}
</select>
<select bind:value={equipmentFilter}>
<option value="">All equipment</option>
<option value="">{t('all_equipment', lang)}</option>
{#each filterOptions.equipment as eq}
<option value={eq}>{eq.charAt(0).toUpperCase() + eq.slice(1)}</option>
{/each}
@@ -46,7 +51,7 @@
<ul class="exercise-list">
{#each filtered as exercise (exercise.id)}
<li>
<a href="/fitness/exercises/{exercise.id}" class="exercise-row">
<a href="/fitness/{sl.exercises}/{exercise.id}" class="exercise-row">
<div class="exercise-info">
<span class="exercise-name">{exercise.name}</span>
<span class="exercise-meta">{exercise.bodyPart} · {exercise.equipment}</span>
@@ -55,7 +60,7 @@
</li>
{/each}
{#if filtered.length === 0}
<li class="no-results">No exercises match your search.</li>
<li class="no-results">{t('no_exercises_match', lang)}</li>
{/if}
</ul>
</div>

View File

@@ -1,5 +1,9 @@
<script>
import { page } from '$app/stores';
import { getExerciseById } from '$lib/data/exercises';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import { onMount } from 'svelte';
@@ -158,7 +162,7 @@
}
</script>
<svelte:head><title>{exercise?.name ?? 'Exercise'} - Fitness</title></svelte:head>
<svelte:head><title>{exercise?.name ?? (lang === 'en' ? 'Exercise' : 'Übung')} - Fitness</title></svelte:head>
<div class="exercise-detail">
<h1>{exercise?.name ?? 'Exercise'}</h1>
@@ -170,7 +174,7 @@
class:active={activeTab === tab}
onclick={() => activeTab = tab}
>
{tab.toUpperCase()}
{{ about: t('about', lang), history: t('history_tab', lang), charts: t('charts', lang), records: t('records', lang) }[tab]}
</button>
{/each}
</div>
@@ -189,7 +193,7 @@
<p class="secondary">Also works: {exercise.secondaryMuscles.join(', ')}</p>
{/if}
{#if exercise?.instructions?.length}
<h3>Instructions</h3>
<h3>{t('instructions', lang)}</h3>
<ol class="instructions">
{#each exercise.instructions as step}
<li>{step}</li>
@@ -200,7 +204,7 @@
{:else if activeTab === 'history'}
<div class="tab-content">
{#if history.length === 0}
<p class="empty">No history for this exercise yet.</p>
<p class="empty">{t('no_history_yet', lang)}</p>
{:else}
{#each history as entry (entry.sessionId)}
<div class="history-session">
@@ -210,7 +214,7 @@
</div>
<table class="history-sets">
<thead>
<tr><th>SET</th><th>KG</th><th>REPS</th><th>EST. 1RM</th></tr>
<tr><th>{t('set', lang)}</th><th>{t('kg', lang)}</th><th>{t('reps', lang)}</th><th>{t('est_1rm', lang)}</th></tr>
</thead>
<tbody>
{#each entry.sets as set, i (i)}
@@ -230,11 +234,11 @@
{:else if activeTab === 'charts'}
<div class="tab-content charts-grid">
{#if (charts.est1rmOverTime?.length ?? 0) > 0}
<FitnessChart data={est1rmChartData} title="Best Set (Est. 1RM)" yUnit=" kg" />
<FitnessChart data={maxWeightChartData} title="Best Set (Max Weight)" yUnit=" kg" />
<FitnessChart data={volumeChartData} title="Total Volume" yUnit=" kg" />
<FitnessChart data={est1rmChartData} title={t('best_set_1rm', lang)} yUnit=" kg" />
<FitnessChart data={maxWeightChartData} title={t('best_set_max', lang)} yUnit=" kg" />
<FitnessChart data={volumeChartData} title={t('total_volume', lang)} yUnit=" kg" />
{:else}
<p class="empty">Not enough data to display charts yet.</p>
<p class="empty">{t('not_enough_data', lang)}</p>
{/if}
</div>
{:else if activeTab === 'records'}
@@ -242,29 +246,29 @@
<div class="records-summary">
{#if prs.estimatedOneRepMax}
<div class="record-card">
<span class="record-label">Estimated 1RM</span>
<span class="record-label">{t('estimated_1rm', lang)}</span>
<span class="record-value">{prs.estimatedOneRepMax} kg</span>
</div>
{/if}
{#if prs.maxVolume}
<div class="record-card">
<span class="record-label">Max Volume</span>
<span class="record-label">{t('max_volume', lang)}</span>
<span class="record-value">{prs.maxVolume} kg</span>
</div>
{/if}
{#if prs.maxWeight}
<div class="record-card">
<span class="record-label">Max Weight</span>
<span class="record-label">{t('max_weight', lang)}</span>
<span class="record-value">{prs.maxWeight} kg</span>
</div>
{/if}
</div>
{#if records.length}
<h3>Rep Records</h3>
<h3>{t('rep_records', lang)}</h3>
<table class="records-table">
<thead>
<tr><th>REPS</th><th>BEST PERFORMANCE</th><th>EST. 1RM</th></tr>
<tr><th>{t('reps', lang)}</th><th>{t('best_performance', lang)}</th><th>{t('est_1rm', lang)}</th></tr>
</thead>
<tbody>
{#each records as rec (rec.reps)}

View File

@@ -1,5 +1,9 @@
<script>
import { page as appPage } from '$app/stores';
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($appPage.url.pathname));
let { data } = $props();
@@ -35,17 +39,17 @@
}
</script>
<svelte:head><title>History - Fitness</title></svelte:head>
<svelte:head><title>{t('history_title', lang)} - Fitness</title></svelte:head>
<div class="history-page">
<h1>History</h1>
<h1>{t('history_title', lang)}</h1>
{#if sessions.length === 0}
<p class="empty">No workouts yet. Start your first workout!</p>
<p class="empty">{t('no_workouts_yet', lang)}</p>
{:else}
{#each Object.entries(grouped) as [month, monthSessions] (month)}
<section class="month-group">
<h2 class="month-header">{month}{monthSessions.length} workout{monthSessions.length !== 1 ? 's' : ''}</h2>
<h2 class="month-header">{month}{monthSessions.length} {monthSessions.length !== 1 ? t('workouts_plural', lang) : t('workout_singular', lang)}</h2>
<div class="session-list">
{#each monthSessions as session (session._id)}
<SessionCard {session} />
@@ -56,7 +60,7 @@
{#if sessions.length < total}
<button class="load-more" onclick={loadMore} disabled={loading}>
{loading ? 'Loading…' : 'Load more'}
{loading ? t('loading', lang) : t('load_more', lang)}
</button>
{/if}
{/if}

View File

@@ -1,6 +1,11 @@
<script>
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X, RefreshCw, Gauge } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
const sl = $derived(fitnessSlugs(lang));
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import SetTable from '$lib/components/fitness/SetTable.svelte';
@@ -189,12 +194,12 @@
}
async function deleteSession() {
if (!confirm('Delete this workout session?')) return;
if (!confirm(t('delete_session_confirm', lang))) return;
deleting = true;
try {
const res = await fetch(`/api/fitness/sessions/${session._id}`, { method: 'DELETE' });
if (res.ok) {
await goto('/fitness/history');
await goto(`/fitness/${sl.history}`);
}
} catch {}
deleting = false;
@@ -413,7 +418,7 @@
/** @param {number} exIdx */
async function removeGpx(exIdx) {
if (!confirm('Remove GPS track from this exercise?')) return;
if (!confirm(t('remove_gps_confirm', lang))) return;
try {
const res = await fetch(`/api/fitness/sessions/${session._id}/gpx`, {
method: 'DELETE',
@@ -428,7 +433,7 @@
</script>
<svelte:head>
<title>{session?.name ?? 'Workout'} - Fitness</title>
<title>{session?.name ?? (lang === 'en' ? 'Workout' : 'Training')} - Fitness</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</svelte:head>
@@ -444,13 +449,13 @@
</div>
<div class="header-actions">
{#if editing}
<button class="recalc-btn" onclick={recalculate} disabled={recalculating} title="Recalculate volume, PRs, and GPS previews">
<button class="recalc-btn" onclick={recalculate} disabled={recalculating} title={t('recalc_title', lang)}>
<RefreshCw size={14} class={recalculating ? 'spinning' : ''} />
</button>
<button class="save-btn" onclick={saveEdit} disabled={saving}>
{saving ? 'SAVING...' : 'SAVE'}
{saving ? t('saving', lang).toUpperCase() : t('save', lang).toUpperCase()}
</button>
<button class="cancel-edit-btn" onclick={cancelEdit}>CANCEL</button>
<button class="cancel-edit-btn" onclick={cancelEdit}>{t('cancel', lang)}</button>
{:else}
<button class="edit-btn" onclick={startEdit} aria-label="Edit session">
<Pencil size={16} />
@@ -465,20 +470,20 @@
{#if editing}
<div class="edit-meta">
<div class="meta-row">
<label for="edit-date">Date</label>
<label for="edit-date">{t('date', lang)}</label>
<input id="edit-date" type="date" bind:value={editData.date} />
</div>
<div class="meta-row">
<label for="edit-time">Time</label>
<label for="edit-time">{t('time', lang)}</label>
<input id="edit-time" type="time" bind:value={editData.time} />
</div>
<div class="meta-row">
<label for="edit-duration">Duration (min)</label>
<label for="edit-duration">{t('duration_min', lang)}</label>
<input id="edit-duration" type="number" min="0" bind:value={editData.duration} />
</div>
<div class="meta-row">
<label for="edit-notes">Notes</label>
<textarea id="edit-notes" bind:value={editData.notes} rows="2" placeholder="Workout notes..."></textarea>
<label for="edit-notes">{t('notes', lang)}</label>
<textarea id="edit-notes" bind:value={editData.notes} rows="2" placeholder={t('notes_placeholder', lang)}></textarea>
</div>
</div>
{:else}
@@ -522,7 +527,7 @@
{@const exData = session.exercises[exIdx]}
<div class="gps-indicator">
<Route size={14} />
<span>GPS track stored{exData.totalDistance ? ` · ${exData.totalDistance.toFixed(2)} km` : ''}</span>
<span>{t('gps_track_stored', lang)}{exData.totalDistance ? ` · ${exData.totalDistance.toFixed(2)} km` : ''}</span>
<button class="gpx-remove-btn" onclick={() => removeGpx(exIdx)} aria-label="Remove GPS track">
<X size={14} />
</button>
@@ -544,13 +549,13 @@
/>
<button class="add-set-btn" onclick={() => addSetToEdit(exIdx)}>
+ ADD SET
{t('add_set', lang)}
</button>
</div>
{/each}
<button class="add-exercise-btn" onclick={() => showPicker = true}>
<Plus size={18} /> ADD EXERCISE
<Plus size={18} /> {t('add_exercise', lang)}
</button>
{:else}
{#each session.exercises as ex, exIdx (ex.exerciseId + '-' + exIdx)}
@@ -565,13 +570,13 @@
<table class="sets-table">
<thead>
<tr>
<th>SET</th>
<th>{t('set_header', lang)}</th>
{#each mainMetrics as metric (metric)}
<th>{METRIC_LABELS[metric]}</th>
{/each}
<th>RPE</th>
{#if showEst1rm}
<th>EST. 1RM</th>
<th>{t('est_1rm', lang)}</th>
{/if}
</tr>
</thead>
@@ -620,12 +625,12 @@
{#if splits.length > 1}
{@const avgPace = splits.reduce((a, s) => a + s.pace, 0) / splits.length}
<div class="splits-section">
<h4>Splits</h4>
<h4>{t('splits', lang)}</h4>
<table class="splits-table">
<thead>
<tr>
<th>KM</th>
<th>PACE</th>
<th>{t('pace', lang)}</th>
<th>TIME</th>
</tr>
</thead>
@@ -649,7 +654,7 @@
{:else if isCardio(ex.exerciseId)}
<button class="gpx-upload-btn" onclick={() => uploadGpx(exIdx)} disabled={uploading === exIdx}>
<Upload size={14} />
{uploading === exIdx ? 'Uploading...' : 'Upload GPX'}
{uploading === exIdx ? t('uploading', lang) : t('upload_gpx', lang)}
</button>
{/if}
</div>
@@ -658,7 +663,7 @@
{#if !editing && session.prs?.length > 0}
<div class="prs-section">
<h2>Personal Records</h2>
<h2>{t('personal_records', lang)}</h2>
<div class="pr-list">
{#each session.prs as pr (pr.exerciseId + pr.type)}
{@const exercise = getExerciseById(pr.exerciseId)}
@@ -680,7 +685,7 @@
{#if !editing && session.notes}
<div class="notes-section">
<h2>Notes</h2>
<h2>{t('notes', lang)}</h2>
<p>{session.notes}</p>
</div>
{/if}

View File

@@ -1,5 +1,9 @@
<script>
import { page } from '$app/stores';
import { Pencil, Trash2 } from 'lucide-svelte';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
import { getWorkout } from '$lib/js/workout.svelte';
import AddActionButton from '$lib/components/AddActionButton.svelte';
@@ -33,19 +37,19 @@
let formCalvesR = $state('');
const bodyPartFields = $derived([
{ label: 'Neck', key: 'neck', value: latest.measurements?.neck },
{ label: 'Shoulders', key: 'shoulders', value: latest.measurements?.shoulders },
{ label: 'Chest', key: 'chest', value: latest.measurements?.chest },
{ label: 'Left Bicep', key: 'bicepsLeft', value: latest.measurements?.biceps?.left },
{ label: 'Right Bicep', key: 'bicepsRight', value: latest.measurements?.biceps?.right },
{ label: 'Left Forearm', key: 'forearmsLeft', value: latest.measurements?.forearms?.left },
{ label: 'Right Forearm', key: 'forearmsRight', value: latest.measurements?.forearms?.right },
{ label: 'Waist', key: 'waist', value: latest.measurements?.waist },
{ label: 'Hips', key: 'hips', value: latest.measurements?.hips },
{ label: 'Left Thigh', key: 'thighsLeft', value: latest.measurements?.thighs?.left },
{ label: 'Right Thigh', key: 'thighsRight', value: latest.measurements?.thighs?.right },
{ label: 'Left Calf', key: 'calvesLeft', value: latest.measurements?.calves?.left },
{ label: 'Right Calf', key: 'calvesRight', value: latest.measurements?.calves?.right }
{ label: t('neck', lang), key: 'neck', value: latest.measurements?.neck },
{ label: t('shoulders', lang), key: 'shoulders', value: latest.measurements?.shoulders },
{ label: t('chest', lang), key: 'chest', value: latest.measurements?.chest },
{ label: t('l_bicep', lang), key: 'bicepsLeft', value: latest.measurements?.biceps?.left },
{ label: t('r_bicep', lang), key: 'bicepsRight', value: latest.measurements?.biceps?.right },
{ label: t('l_forearm', lang), key: 'forearmsLeft', value: latest.measurements?.forearms?.left },
{ label: t('r_forearm', lang), key: 'forearmsRight', value: latest.measurements?.forearms?.right },
{ label: t('waist', lang), key: 'waist', value: latest.measurements?.waist },
{ label: t('hips', lang), key: 'hips', value: latest.measurements?.hips },
{ label: t('l_thigh', lang), key: 'thighsLeft', value: latest.measurements?.thighs?.left },
{ label: t('r_thigh', lang), key: 'thighsRight', value: latest.measurements?.thighs?.right },
{ label: t('l_calf', lang), key: 'calvesLeft', value: latest.measurements?.calves?.left },
{ label: t('r_calf', lang), key: 'calvesRight', value: latest.measurements?.calves?.right }
]);
function resetForm() {
@@ -191,7 +195,7 @@
/** @param {string} id */
async function deleteMeasurement(id) {
if (!confirm('Delete this measurement?')) return;
if (!confirm(t('delete_measurement_confirm', lang))) return;
try {
const res = await fetch(`/api/fitness/measurements/${id}`, { method: 'DELETE' });
if (res.ok) {
@@ -218,89 +222,89 @@
if (m.weight != null) parts.push(`${m.weight} kg`);
if (m.bodyFatPercent != null) parts.push(`${m.bodyFatPercent}% bf`);
if (m.caloricIntake != null) parts.push(`${m.caloricIntake} kcal`);
return parts.join(' · ') || 'Body measurements only';
return parts.join(' · ') || t('body_measurements_only', lang);
}
</script>
<svelte:head><title>Measure - Fitness</title></svelte:head>
<svelte:head><title>{lang === 'en' ? 'Measure' : 'Messen'} - Fitness</title></svelte:head>
<div class="measure-page">
<h1>Measure</h1>
<h1>{t('measure_title', lang)}</h1>
{#if showForm}
<form class="measure-form" onsubmit={(e) => { e.preventDefault(); saveMeasurement(); }}>
<div class="form-header">
<h2>{editingId ? 'Edit' : 'New'} Measurement</h2>
<button type="button" class="cancel-form-btn" onclick={() => { showForm = false; resetForm(); }}>CANCEL</button>
<h2>{editingId ? t('edit_measurement', lang) : t('new_measurement', lang)}</h2>
<button type="button" class="cancel-form-btn" onclick={() => { showForm = false; resetForm(); }}>{t('cancel', lang)}</button>
</div>
<div class="form-group">
<label for="m-date">Date</label>
<label for="m-date">{t('date', lang)}</label>
<input id="m-date" type="date" bind:value={formDate} />
</div>
<h3>General</h3>
<h3>{t('general', lang)}</h3>
<div class="form-row">
<div class="form-group">
<label for="m-weight">Weight (kg)</label>
<label for="m-weight">{t('weight_kg', lang)}</label>
<input id="m-weight" type="number" step="0.1" bind:value={formWeight} placeholder="—" />
</div>
<div class="form-group">
<label for="m-bf">Body Fat %</label>
<label for="m-bf">{t('body_fat_pct', lang)}</label>
<input id="m-bf" type="number" step="0.1" bind:value={formBodyFat} placeholder="—" />
</div>
<div class="form-group">
<label for="m-cal">Calories (kcal)</label>
<label for="m-cal">{t('calories_kcal', lang)}</label>
<input id="m-cal" type="number" bind:value={formCalories} placeholder="—" />
</div>
</div>
<h3>Body Parts (cm)</h3>
<h3>{t('body_parts_cm', lang)}</h3>
<div class="form-row">
<div class="form-group"><label for="m-neck">Neck</label><input id="m-neck" type="number" step="0.1" bind:value={formNeck} placeholder="—" /></div>
<div class="form-group"><label for="m-shoulders">Shoulders</label><input id="m-shoulders" type="number" step="0.1" bind:value={formShoulders} placeholder="—" /></div>
<div class="form-group"><label for="m-chest">Chest</label><input id="m-chest" type="number" step="0.1" bind:value={formChest} placeholder="—" /></div>
<div class="form-group"><label for="m-neck">{t('neck', lang)}</label><input id="m-neck" type="number" step="0.1" bind:value={formNeck} placeholder="—" /></div>
<div class="form-group"><label for="m-shoulders">{t('shoulders', lang)}</label><input id="m-shoulders" type="number" step="0.1" bind:value={formShoulders} placeholder="—" /></div>
<div class="form-group"><label for="m-chest">{t('chest', lang)}</label><input id="m-chest" type="number" step="0.1" bind:value={formChest} placeholder="—" /></div>
</div>
<div class="form-row">
<div class="form-group"><label for="m-bl">L Bicep</label><input id="m-bl" type="number" step="0.1" bind:value={formBicepsL} placeholder="—" /></div>
<div class="form-group"><label for="m-br">R Bicep</label><input id="m-br" type="number" step="0.1" bind:value={formBicepsR} placeholder="—" /></div>
<div class="form-group"><label for="m-bl">{t('l_bicep', lang)}</label><input id="m-bl" type="number" step="0.1" bind:value={formBicepsL} placeholder="—" /></div>
<div class="form-group"><label for="m-br">{t('r_bicep', lang)}</label><input id="m-br" type="number" step="0.1" bind:value={formBicepsR} placeholder="—" /></div>
</div>
<div class="form-row">
<div class="form-group"><label for="m-fl">L Forearm</label><input id="m-fl" type="number" step="0.1" bind:value={formForearmsL} placeholder="—" /></div>
<div class="form-group"><label for="m-fr">R Forearm</label><input id="m-fr" type="number" step="0.1" bind:value={formForearmsR} placeholder="—" /></div>
<div class="form-group"><label for="m-fl">{t('l_forearm', lang)}</label><input id="m-fl" type="number" step="0.1" bind:value={formForearmsL} placeholder="—" /></div>
<div class="form-group"><label for="m-fr">{t('r_forearm', lang)}</label><input id="m-fr" type="number" step="0.1" bind:value={formForearmsR} placeholder="—" /></div>
</div>
<div class="form-row">
<div class="form-group"><label for="m-waist">Waist</label><input id="m-waist" type="number" step="0.1" bind:value={formWaist} placeholder="—" /></div>
<div class="form-group"><label for="m-hips">Hips</label><input id="m-hips" type="number" step="0.1" bind:value={formHips} placeholder="—" /></div>
<div class="form-group"><label for="m-waist">{t('waist', lang)}</label><input id="m-waist" type="number" step="0.1" bind:value={formWaist} placeholder="—" /></div>
<div class="form-group"><label for="m-hips">{t('hips', lang)}</label><input id="m-hips" type="number" step="0.1" bind:value={formHips} placeholder="—" /></div>
</div>
<div class="form-row">
<div class="form-group"><label for="m-tl">L Thigh</label><input id="m-tl" type="number" step="0.1" bind:value={formThighsL} placeholder="—" /></div>
<div class="form-group"><label for="m-tr">R Thigh</label><input id="m-tr" type="number" step="0.1" bind:value={formThighsR} placeholder="—" /></div>
<div class="form-group"><label for="m-tl">{t('l_thigh', lang)}</label><input id="m-tl" type="number" step="0.1" bind:value={formThighsL} placeholder="—" /></div>
<div class="form-group"><label for="m-tr">{t('r_thigh', lang)}</label><input id="m-tr" type="number" step="0.1" bind:value={formThighsR} placeholder="—" /></div>
</div>
<div class="form-row">
<div class="form-group"><label for="m-cl">L Calf</label><input id="m-cl" type="number" step="0.1" bind:value={formCalvesL} placeholder="—" /></div>
<div class="form-group"><label for="m-cr">R Calf</label><input id="m-cr" type="number" step="0.1" bind:value={formCalvesR} placeholder="—" /></div>
<div class="form-group"><label for="m-cl">{t('l_calf', lang)}</label><input id="m-cl" type="number" step="0.1" bind:value={formCalvesL} placeholder="—" /></div>
<div class="form-group"><label for="m-cr">{t('r_calf', lang)}</label><input id="m-cr" type="number" step="0.1" bind:value={formCalvesR} placeholder="—" /></div>
</div>
<button type="submit" class="save-btn" disabled={saving}>
{saving ? 'Saving' : editingId ? 'Update Measurement' : 'Save Measurement'}
{saving ? t('saving', lang) : editingId ? t('update_measurement', lang) : t('save_measurement', lang)}
</button>
</form>
{/if}
<section class="latest-section">
<h2>Latest</h2>
<h2>{t('latest', lang)}</h2>
<div class="stat-grid">
<div class="stat-card">
<span class="stat-label">Weight</span>
<span class="stat-label">{t('weight', lang)}</span>
<span class="stat-value">{latest.weight?.value ?? '—'} <small>kg</small></span>
</div>
<div class="stat-card">
<span class="stat-label">Body Fat</span>
<span class="stat-label">{t('body_fat', lang)}</span>
<span class="stat-value">{latest.bodyFatPercent?.value ?? '—'}<small>%</small></span>
</div>
<div class="stat-card">
<span class="stat-label">Calories</span>
<span class="stat-label">{t('calories', lang)}</span>
<span class="stat-value">{latest.caloricIntake?.value ?? '—'} <small>kcal</small></span>
</div>
</div>
@@ -308,7 +312,7 @@
{#if bodyPartFields.some(f => f.value != null)}
<section class="body-parts-section">
<h2>Body Parts</h2>
<h2>{t('body_parts', lang)}</h2>
<div class="body-grid">
{#each bodyPartFields.filter(f => f.value != null) as field}
<div class="body-row">
@@ -322,7 +326,7 @@
{#if measurements.length > 0}
<section class="history-section">
<h2>History</h2>
<h2>{t('history', lang)}</h2>
<div class="history-list">
{#each measurements as m (m._id)}
<div class="history-item" class:editing={editingId === m._id}>

View File

@@ -1,7 +1,11 @@
<script>
import { page } from '$app/stores';
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import { Dumbbell, Route, Flame } from 'lucide-svelte';
import { onMount } from 'svelte';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
let { data } = $props();
@@ -88,26 +92,26 @@
</script>
<svelte:head><title>Stats - Fitness</title></svelte:head>
<svelte:head><title>{t('stats_title', lang)} - Fitness</title></svelte:head>
<div class="stats-page">
<h1>Stats</h1>
<h1>{t('stats_title', lang)}</h1>
<div class="lifetime-cards">
<div class="lifetime-card workouts">
<div class="card-icon"><Dumbbell size={24} /></div>
<div class="card-value">{stats.totalWorkouts ?? 0}</div>
<div class="card-label">{(stats.totalWorkouts ?? 0) === 1 ? 'Workout' : 'Workouts'}</div>
<div class="card-label">{(stats.totalWorkouts ?? 0) === 1 ? t('workout_singular', lang) : t('workouts_plural', lang)}</div>
</div>
<div class="lifetime-card tonnage">
<div class="card-icon"><Flame size={24} /></div>
<div class="card-value">{stats.totalTonnage ?? 0}<span class="card-unit">t</span></div>
<div class="card-label">Lifted</div>
<div class="card-label">{t('lifted', lang)}</div>
</div>
<div class="lifetime-card cardio">
<div class="card-icon"><Route size={24} /></div>
<div class="card-value">{stats.totalCardioKm ?? 0}<span class="card-unit">km</span></div>
<div class="card-label">Distance Covered</div>
<div class="card-label">{t('distance_covered', lang)}</div>
</div>
</div>
@@ -115,17 +119,17 @@
<FitnessChart
type="bar"
data={workoutsChartData}
title="Workouts per week"
title={t('workouts_per_week', lang)}
height="220px"
/>
{:else}
<p class="empty-chart">No workout data to display yet.</p>
<p class="empty-chart">{t('no_workout_data', lang)}</p>
{/if}
{#if (stats.weightChart?.data?.length ?? 0) > 1}
<FitnessChart
data={weightChartData}
title="Weight"
title={t('weight', lang)}
yUnit=" kg"
height="220px"
/>

View File

@@ -1,9 +1,14 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight } from 'lucide-svelte';
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
const sl = $derived(fitnessSlugs(lang));
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
import TemplateCard from '$lib/components/fitness/TemplateCard.svelte';
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
@@ -48,7 +53,7 @@
// If there's an active workout, redirect to the active page
if (workout.active) {
goto('/fitness/workout/active');
goto(`/fitness/${sl.workout}/${sl.active}`);
return;
}
@@ -78,13 +83,13 @@
selectedTemplate = null;
workout.startFromTemplate(template);
await sync.onWorkoutStart();
goto('/fitness/workout/active');
goto(`/fitness/${sl.workout}/${sl.active}`);
}
async function startEmpty() {
workout.startEmpty();
await sync.onWorkoutStart();
goto('/fitness/workout/active');
goto(`/fitness/${sl.workout}/${sl.active}`);
}
async function startNextScheduled() {
@@ -285,19 +290,19 @@
}
</script>
<svelte:head><title>Workout - Fitness</title></svelte:head>
<svelte:head><title>{lang === 'en' ? 'Workout' : 'Training'} - Fitness</title></svelte:head>
<div class="template-view">
{#if hasSchedule && nextTemplate}
<section class="next-workout">
<div class="next-label">
<CalendarClock size={16} />
<span>Next in schedule</span>
<span>{t('next_in_schedule', lang)}</span>
</div>
<button class="next-workout-btn" onclick={startNextScheduled}>
<div class="next-info">
<span class="next-name">{nextTemplate.name}</span>
<span class="next-exercises">{nextTemplate.exercises.length} exercise{nextTemplate.exercises.length !== 1 ? 's' : ''}</span>
<span class="next-exercises">{nextTemplate.exercises.length} {nextTemplate.exercises.length !== 1 ? t('exercises_word', lang) : t('exercise', lang)}</span>
</div>
<div class="next-go">
<Play size={18} />
@@ -316,25 +321,25 @@
<section class="quick-start">
<button class="start-empty-btn" onclick={startEmpty}>
START AN EMPTY WORKOUT
{t('start_empty_workout', lang)}
</button>
</section>
<section class="templates-section">
<div class="templates-header">
<h2>Templates</h2>
<h2>{t('templates', lang)}</h2>
<div class="templates-header-actions">
<button class="header-icon-btn" onclick={openCreateTemplate} aria-label="Create template">
<Plus size={18} />
</button>
<button class="schedule-btn" onclick={openScheduleEditor} aria-label="Edit workout schedule">
<CalendarClock size={16} />
Schedule
{t('schedule', lang)}
</button>
</div>
</div>
{#if templates.length > 0}
<p class="template-count">My Templates ({templates.length})</p>
<p class="template-count">{t('my_templates', lang)} ({templates.length})</p>
<div class="template-grid">
{#each templates as template (template._id)}
<TemplateCard
@@ -345,7 +350,7 @@
{/each}
</div>
{:else}
<p class="no-templates">No templates yet. Create one or start an empty workout.</p>
<p class="no-templates">{t('no_templates_yet', lang)}</p>
{/if}
</section>
</div>
@@ -367,7 +372,7 @@
{@const exercise = getExerciseById(ex.exerciseId)}
<li>
<span class="tex-name">{exercise?.name ?? ex.exerciseId}</span>
<span class="tex-sets">{ex.sets.length} set{ex.sets.length !== 1 ? 's' : ''}</span>
<span class="tex-sets">{ex.sets.length} {ex.sets.length !== 1 ? t('sets', lang) : t('set', lang)}</span>
</li>
{/each}
</ul>
@@ -377,13 +382,13 @@
</div>
<div class="modal-actions">
<button class="modal-start" onclick={() => startFromTemplate(selectedTemplate)}>
<Play size={16} /> Start Workout
<Play size={16} /> {t('start_workout', lang)}
</button>
<button class="modal-edit" onclick={() => openEditTemplate(selectedTemplate)}>
<Pencil size={16} /> Edit Template
<Pencil size={16} /> {t('edit_template', lang)}
</button>
<button class="modal-delete" onclick={() => deleteTemplate(selectedTemplate)}>
<Trash2 size={16} /> Delete
<Trash2 size={16} /> {t('delete_template', lang)}
</button>
</div>
</div>
@@ -398,14 +403,14 @@
<div class="modal-backdrop" onclick={closeEditor}></div>
<div class="modal-panel editor-panel">
<div class="modal-header">
<h2>{editingTemplate ? 'Edit Template' : 'New Template'}</h2>
<h2>{editingTemplate ? t('edit_template', lang) : t('new_template', lang)}</h2>
<button class="close-btn" onclick={closeEditor} aria-label="Close"><X size={20} /></button>
</div>
<div class="modal-body">
<input
class="editor-name"
type="text"
placeholder="Template name"
placeholder={t('template_name_placeholder', lang)}
bind:value={editorName}
/>
@@ -445,18 +450,18 @@
{/if}
</div>
{/each}
<button class="editor-add-set" onclick={() => editorAddSet(exIdx)}>+ Add set</button>
<button class="editor-add-set" onclick={() => editorAddSet(exIdx)}>{t('add_set_lower', lang)}</button>
</div>
</div>
{/each}
<button class="editor-add-exercise" onclick={() => editorPicker = true}>
<Plus size={16} /> Add Exercise
<Plus size={16} /> {t('add_exercise_btn', lang)}
</button>
</div>
<div class="modal-actions">
<button class="modal-start" onclick={saveTemplate} disabled={editorSaving || !editorName.trim() || editorExercises.length === 0}>
<Save size={16} /> {editorSaving ? 'Saving…' : 'Save Template'}
<Save size={16} /> {editorSaving ? t('saving', lang) : t('save_template', lang)}
</button>
</div>
</div>
@@ -478,11 +483,11 @@
<div class="modal-backdrop" onclick={closeScheduleEditor}></div>
<div class="modal-panel editor-panel">
<div class="modal-header">
<h2>Workout Schedule</h2>
<h2>{t('workout_schedule', lang)}</h2>
<button class="close-btn" onclick={closeScheduleEditor} aria-label="Close"><X size={20} /></button>
</div>
<div class="modal-body">
<p class="schedule-hint">Select templates and arrange their order. After completing a workout, the next one in the rotation will be suggested.</p>
<p class="schedule-hint">{t('schedule_hint', lang)}</p>
{#if editorScheduleOrder.length > 0}
<div class="schedule-order">
@@ -507,7 +512,7 @@
{/if}
<div class="schedule-available">
<p class="schedule-available-label">Available templates</p>
<p class="schedule-available-label">{t('available_templates', lang)}</p>
{#each templates.filter((t) => !editorScheduleOrder.includes(t._id)) as template (template._id)}
<button class="schedule-add-item" onclick={() => toggleScheduleTemplate(template._id)}>
<Plus size={14} />
@@ -515,13 +520,13 @@
</button>
{/each}
{#if templates.filter((t) => !editorScheduleOrder.includes(t._id)).length === 0}
<p class="schedule-all-added">All templates are in the schedule</p>
<p class="schedule-all-added">{t('all_templates_scheduled', lang)}</p>
{/if}
</div>
</div>
<div class="modal-actions">
<button class="modal-start" onclick={saveAndCloseSchedule} disabled={scheduleSaving}>
<Save size={16} /> {scheduleSaving ? 'Saving…' : 'Save Schedule'}
<Save size={16} /> {scheduleSaving ? t('saving', lang) : t('save_schedule', lang)}
</button>
</div>
</div>

View File

@@ -1,6 +1,11 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Plus, Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
const sl = $derived(fitnessSlugs(lang));
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
@@ -29,7 +34,7 @@
onMount(() => {
if (!workout.active && !completionData) {
goto('/fitness/workout');
goto(`/fitness/${sl.workout}`);
}
});
@@ -60,7 +65,7 @@
const sessionData = workout.finish();
if (sessionData.exercises.length === 0) {
await sync.onWorkoutEnd();
await goto('/fitness/workout');
await goto(`/fitness/${sl.workout}`);
return;
}
@@ -322,12 +327,12 @@
});
</script>
<svelte:head><title>{workout.name || 'Workout'} - Fitness</title></svelte:head>
<svelte:head><title>{workout.name || (lang === 'en' ? 'Workout' : 'Training')} - Fitness</title></svelte:head>
{#if completionData}
<div class="completion">
<div class="completion-header">
<h1>Workout Complete</h1>
<h1>{t('workout_complete', lang)}</h1>
{#if completionData.prs.length > 0}
<div class="pr-badge">
<span class="pr-badge-count">{completionData.prs.length}</span>
@@ -341,7 +346,7 @@
<div class="comp-stat">
<Clock size={18} />
<span class="comp-stat-value">{formatDuration(completionData.durationMin)}</span>
<span class="comp-stat-label">Duration</span>
<span class="comp-stat-label">{t('duration', lang)}</span>
</div>
{#if completionData.totalTonnage > 0}
<div class="comp-stat">
@@ -351,21 +356,21 @@
? `${(completionData.totalTonnage / 1000).toFixed(1)}t`
: `${Math.round(completionData.totalTonnage)} kg`}
</span>
<span class="comp-stat-label">Tonnage</span>
<span class="comp-stat-label">{t('tonnage', lang)}</span>
</div>
{/if}
{#if completionData.totalDistance > 0}
<div class="comp-stat">
<Route size={18} />
<span class="comp-stat-value">{completionData.totalDistance.toFixed(1)} km</span>
<span class="comp-stat-label">Distance</span>
<span class="comp-stat-label">{t('distance', lang)}</span>
</div>
{/if}
</div>
{#if completionData.prs.length > 0}
<div class="prs-section">
<h2><Trophy size={16} /> Personal Records</h2>
<h2><Trophy size={16} /> {t('personal_records', lang)}</h2>
<div class="pr-list">
{#each completionData.prs as pr}
<div class="pr-item">
@@ -378,12 +383,12 @@
{/if}
<div class="exercise-summaries">
<h2>Exercises</h2>
<h2>{t('exercises_heading', lang)}</h2>
{#each completionData.exerciseSummaries as ex}
<div class="ex-summary">
<div class="ex-summary-header">
<span class="ex-summary-name">{getExerciseById(ex.exerciseId)?.name ?? ex.exerciseId}</span>
<span class="ex-summary-sets">{ex.sets} set{ex.sets !== 1 ? 's' : ''}</span>
<span class="ex-summary-sets">{ex.sets} {ex.sets !== 1 ? t('sets', lang) : t('set', lang)}</span>
</div>
<div class="ex-summary-stats">
{#if ex.isCardio}
@@ -394,11 +399,11 @@
<span>{ex.duration} min</span>
{/if}
{#if ex.pace > 0}
<span>{formatPace(ex.pace)} avg</span>
<span>{formatPace(ex.pace)} {t('avg', lang)}</span>
{/if}
{:else}
{#if ex.tonnage > 0}
<span>{ex.tonnage >= 1000 ? `${(ex.tonnage / 1000).toFixed(1)}t` : `${Math.round(ex.tonnage)} kg`} volume</span>
<span>{ex.tonnage >= 1000 ? `${(ex.tonnage / 1000).toFixed(1)}t` : `${Math.round(ex.tonnage)} kg`} {t('volume', lang)}</span>
{/if}
{#if ex.bestWeight > 0}
<span>Top: {ex.bestWeight} kg</span>
@@ -417,11 +422,11 @@
{#if templateUpdateStatus === 'done'}
<div class="template-updated">
<Check size={16} />
<span>Template updated</span>
<span>{t('template_updated', lang)}</span>
</div>
{:else}
<h2><RefreshCw size={16} /> Update Template</h2>
<p class="template-update-desc">Your weights or reps differ from the template:</p>
<h2><RefreshCw size={16} /> {t('update_template', lang)}</h2>
<p class="template-update-desc">{t('template_diff_desc', lang)}</p>
<div class="template-diff-list">
{#each templateDiffs as diff}
<div class="diff-item">
@@ -439,7 +444,7 @@
{/each}
{#if diff.newSets.length > diff.oldSets.length}
<div class="diff-set-row">
<span class="diff-new">+{diff.newSets.length - diff.oldSets.length} new set{diff.newSets.length - diff.oldSets.length > 1 ? 's' : ''}</span>
<span class="diff-new">+{diff.newSets.length - diff.oldSets.length} {diff.newSets.length - diff.oldSets.length > 1 ? t('new_sets_added', lang) : t('new_set_added', lang)}</span>
</div>
{/if}
</div>
@@ -447,14 +452,14 @@
{/each}
</div>
<button class="update-template-btn" onclick={updateTemplate} disabled={templateUpdateStatus === 'updating'}>
{templateUpdateStatus === 'updating' ? 'Updating...' : 'Update Template'}
{templateUpdateStatus === 'updating' ? t('updating', lang) : t('update_template', lang)}
</button>
{/if}
</div>
{/if}
<button class="done-btn" onclick={() => goto(`/fitness/history/${completionData.sessionId}`)}>
VIEW WORKOUT
<button class="done-btn" onclick={() => goto(`/fitness/${sl.history}/${completionData.sessionId}`)}>
{t('view_workout', lang)}
</button>
</div>
@@ -467,7 +472,7 @@
onfocus={() => { nameEditing = true; }}
onblur={() => { nameEditing = false; workout.name = nameInput; }}
onkeydown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
placeholder="Workout name"
placeholder={t('workout_name_placeholder', lang)}
/>
{#each workout.exercises as ex, exIdx (exIdx)}
@@ -512,17 +517,17 @@
/>
<button class="add-set-btn" onclick={() => workout.addSet(exIdx)}>
+ ADD SET
{t('add_set', lang)}
</button>
</div>
{/each}
<div class="workout-actions">
<button class="add-exercise-btn" onclick={() => showPicker = true}>
<Plus size={18} /> ADD EXERCISE
<Plus size={18} /> {t('add_exercise', lang)}
</button>
<button class="cancel-btn" onclick={async () => { workout.cancel(); await sync.onWorkoutEnd(); await goto('/fitness/workout'); }}>
CANCEL WORKOUT
<button class="cancel-btn" onclick={async () => { workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
{t('cancel_workout', lang)}
</button>
</div>
@@ -534,7 +539,7 @@
<span class="elapsed" class:paused={workout.paused}>{formatElapsed(workout.elapsedSeconds)}</span>
<SyncIndicator status={sync.status} />
</div>
<button class="finish-btn" onclick={finishWorkout}>FINISH</button>
<button class="finish-btn" onclick={finishWorkout}>{t('finish', lang)}</button>
</div>
</div>
{/if}