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

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
<script> <script>
import { page } from '$app/stores';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises'; import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { Clock, Weight, Trophy, Route, Gauge } from 'lucide-svelte'; 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 {{ * @type {{
@@ -138,7 +142,7 @@
}); });
</script> </script>
<a href="/fitness/history/{session._id}" class="session-card"> <a href="/fitness/{sl.history}/{session._id}" class="session-card">
<div class="card-top"> <div class="card-top">
<h3 class="session-name">{session.name}</h3> <h3 class="session-name">{session.name}</h3>
<span class="session-date">{formatDate(session.startTime)} &middot; {formatTime(session.startTime)}</span> <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 { Check, X } from 'lucide-svelte';
import { METRIC_LABELS } from '$lib/data/exercises'; import { METRIC_LABELS } from '$lib/data/exercises';
import RestTimer from './RestTimer.svelte'; 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 {{ * @type {{
@@ -75,16 +79,16 @@
{#if editable && onRemove} {#if editable && onRemove}
<th class="col-remove"></th> <th class="col-remove"></th>
{/if} {/if}
<th class="col-set">SET</th> <th class="col-set">{t('set_header', lang)}</th>
{#if previousSets} {#if previousSets}
<th class="col-prev">PREV</th> <th class="col-prev">{t('prev_header', lang)}</th>
{/if} {/if}
{#each mainMetrics as metric (metric)} {#each mainMetrics as metric (metric)}
<th class="col-metric">{METRIC_LABELS[metric]}</th> <th class="col-metric">{METRIC_LABELS[metric]}</th>
{/each} {/each}
{#if editable && hasRpe} {#if editable && hasRpe}
<th class="col-at"></th> <th class="col-at"></th>
<th class="col-rpe">RPE</th> <th class="col-rpe">{t('rpe', lang)}</th>
{/if} {/if}
{#if editable} {#if editable}
<th class="col-check"></th> <th class="col-check"></th>

View File

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

View File

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

View File

@@ -613,7 +613,9 @@ export const exercises: Exercise[] = [
'Stand with feet shoulder-width apart.', 'Stand with feet shoulder-width apart.',
'Squat down until thighs are at least parallel to the floor.', 'Squat down until thighs are at least parallel to the floor.',
'Drive through your heels to stand back up.' 'Drive through your heels to stand back up.'
] ],
imageUrl: "/fitness/squat-barbell/0.svg"
}, },
{ {
id: 'front-squat-barbell', 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 { onMount, onDestroy } from 'svelte';
import Header from '$lib/components/Header.svelte'; import Header from '$lib/components/Header.svelte';
import UserHeader from '$lib/components/UserHeader.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 { BarChart3, Clock, Dumbbell, ListChecks, Ruler } from 'lucide-svelte';
import { getWorkout } from '$lib/js/workout.svelte'; import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte'; import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte'; import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte';
import { detectFitnessLang, fitnessSlugs, fitnessLabels } from '$lib/js/fitnessI18n';
let { data, children } = $props(); let { data, children } = $props();
let user = $derived(data.session?.user); let user = $derived(data.session?.user);
@@ -14,6 +16,10 @@
const workout = getWorkout(); const workout = getWorkout();
const sync = getWorkoutSync(); const sync = getWorkoutSync();
const lang = $derived(detectFitnessLang($page.url.pathname));
const s = $derived(fitnessSlugs(lang));
const labels = $derived(fitnessLabels(lang));
onMount(async () => { onMount(async () => {
workout.restore(); workout.restore();
workout.onChange(() => sync.notifyChange()); workout.onChange(() => sync.notifyChange());
@@ -30,27 +36,36 @@
return currentPath.startsWith(path); 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 */ /** @param {number} secs */
function formatElapsed(secs) { function formatElapsed(secs) {
const m = Math.floor(secs / 60); const m = Math.floor(secs / 60);
const s = secs % 60; const sec = secs % 60;
return `${m}:${s.toString().padStart(2, '0')}`; return `${m}:${sec.toString().padStart(2, '0')}`;
} }
</script> </script>
<Header> <Header>
{#snippet links()} {#snippet links()}
<ul class="site_header"> <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><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/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(--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/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(--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/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(--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/measure" class:active={isActive('/fitness/measure')}><Ruler size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Measure</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> </ul>
{/snippet} {/snippet}
{#snippet language_selector_mobile()}
<LanguageSelector lang={lang} />
{/snippet}
{#snippet language_selector_desktop()}
<LanguageSelector lang={lang} />
{/snippet}
{#snippet right_side()} {#snippet right_side()}
<UserHeader {user} /> <UserHeader {user} />
{/snippet} {/snippet}
@@ -62,7 +77,7 @@
{#if workout.active && !isOnActivePage} {#if workout.active && !isOnActivePage}
<WorkoutFab <WorkoutFab
href="/fitness/workout/active" href={activePath}
elapsed={formatElapsed(workout.elapsedSeconds)} elapsed={formatElapsed(workout.elapsedSeconds)}
paused={workout.paused} paused={workout.paused}
syncStatus={sync.status} syncStatus={sync.status}

View File

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

View File

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

View File

@@ -1,5 +1,9 @@
<script> <script>
import { page as appPage } from '$app/stores';
import SessionCard from '$lib/components/fitness/SessionCard.svelte'; 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(); let { data } = $props();
@@ -35,17 +39,17 @@
} }
</script> </script>
<svelte:head><title>History - Fitness</title></svelte:head> <svelte:head><title>{t('history_title', lang)} - Fitness</title></svelte:head>
<div class="history-page"> <div class="history-page">
<h1>History</h1> <h1>{t('history_title', lang)}</h1>
{#if sessions.length === 0} {#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} {:else}
{#each Object.entries(grouped) as [month, monthSessions] (month)} {#each Object.entries(grouped) as [month, monthSessions] (month)}
<section class="month-group"> <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"> <div class="session-list">
{#each monthSessions as session (session._id)} {#each monthSessions as session (session._id)}
<SessionCard {session} /> <SessionCard {session} />
@@ -56,7 +60,7 @@
{#if sessions.length < total} {#if sessions.length < total}
<button class="load-more" onclick={loadMore} disabled={loading}> <button class="load-more" onclick={loadMore} disabled={loading}>
{loading ? 'Loading…' : 'Load more'} {loading ? t('loading', lang) : t('load_more', lang)}
</button> </button>
{/if} {/if}
{/if} {/if}

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
<script> <script>
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight } from 'lucide-svelte'; import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight } from 'lucide-svelte';
import { getWorkout } from '$lib/js/workout.svelte'; import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.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 { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
import TemplateCard from '$lib/components/fitness/TemplateCard.svelte'; import TemplateCard from '$lib/components/fitness/TemplateCard.svelte';
import ExercisePicker from '$lib/components/fitness/ExercisePicker.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 there's an active workout, redirect to the active page
if (workout.active) { if (workout.active) {
goto('/fitness/workout/active'); goto(`/fitness/${sl.workout}/${sl.active}`);
return; return;
} }
@@ -78,13 +83,13 @@
selectedTemplate = null; selectedTemplate = null;
workout.startFromTemplate(template); workout.startFromTemplate(template);
await sync.onWorkoutStart(); await sync.onWorkoutStart();
goto('/fitness/workout/active'); goto(`/fitness/${sl.workout}/${sl.active}`);
} }
async function startEmpty() { async function startEmpty() {
workout.startEmpty(); workout.startEmpty();
await sync.onWorkoutStart(); await sync.onWorkoutStart();
goto('/fitness/workout/active'); goto(`/fitness/${sl.workout}/${sl.active}`);
} }
async function startNextScheduled() { async function startNextScheduled() {
@@ -285,19 +290,19 @@
} }
</script> </script>
<svelte:head><title>Workout - Fitness</title></svelte:head> <svelte:head><title>{lang === 'en' ? 'Workout' : 'Training'} - Fitness</title></svelte:head>
<div class="template-view"> <div class="template-view">
{#if hasSchedule && nextTemplate} {#if hasSchedule && nextTemplate}
<section class="next-workout"> <section class="next-workout">
<div class="next-label"> <div class="next-label">
<CalendarClock size={16} /> <CalendarClock size={16} />
<span>Next in schedule</span> <span>{t('next_in_schedule', lang)}</span>
</div> </div>
<button class="next-workout-btn" onclick={startNextScheduled}> <button class="next-workout-btn" onclick={startNextScheduled}>
<div class="next-info"> <div class="next-info">
<span class="next-name">{nextTemplate.name}</span> <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>
<div class="next-go"> <div class="next-go">
<Play size={18} /> <Play size={18} />
@@ -316,25 +321,25 @@
<section class="quick-start"> <section class="quick-start">
<button class="start-empty-btn" onclick={startEmpty}> <button class="start-empty-btn" onclick={startEmpty}>
START AN EMPTY WORKOUT {t('start_empty_workout', lang)}
</button> </button>
</section> </section>
<section class="templates-section"> <section class="templates-section">
<div class="templates-header"> <div class="templates-header">
<h2>Templates</h2> <h2>{t('templates', lang)}</h2>
<div class="templates-header-actions"> <div class="templates-header-actions">
<button class="header-icon-btn" onclick={openCreateTemplate} aria-label="Create template"> <button class="header-icon-btn" onclick={openCreateTemplate} aria-label="Create template">
<Plus size={18} /> <Plus size={18} />
</button> </button>
<button class="schedule-btn" onclick={openScheduleEditor} aria-label="Edit workout schedule"> <button class="schedule-btn" onclick={openScheduleEditor} aria-label="Edit workout schedule">
<CalendarClock size={16} /> <CalendarClock size={16} />
Schedule {t('schedule', lang)}
</button> </button>
</div> </div>
</div> </div>
{#if templates.length > 0} {#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"> <div class="template-grid">
{#each templates as template (template._id)} {#each templates as template (template._id)}
<TemplateCard <TemplateCard
@@ -345,7 +350,7 @@
{/each} {/each}
</div> </div>
{:else} {: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} {/if}
</section> </section>
</div> </div>
@@ -367,7 +372,7 @@
{@const exercise = getExerciseById(ex.exerciseId)} {@const exercise = getExerciseById(ex.exerciseId)}
<li> <li>
<span class="tex-name">{exercise?.name ?? ex.exerciseId}</span> <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> </li>
{/each} {/each}
</ul> </ul>
@@ -377,13 +382,13 @@
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="modal-start" onclick={() => startFromTemplate(selectedTemplate)}> <button class="modal-start" onclick={() => startFromTemplate(selectedTemplate)}>
<Play size={16} /> Start Workout <Play size={16} /> {t('start_workout', lang)}
</button> </button>
<button class="modal-edit" onclick={() => openEditTemplate(selectedTemplate)}> <button class="modal-edit" onclick={() => openEditTemplate(selectedTemplate)}>
<Pencil size={16} /> Edit Template <Pencil size={16} /> {t('edit_template', lang)}
</button> </button>
<button class="modal-delete" onclick={() => deleteTemplate(selectedTemplate)}> <button class="modal-delete" onclick={() => deleteTemplate(selectedTemplate)}>
<Trash2 size={16} /> Delete <Trash2 size={16} /> {t('delete_template', lang)}
</button> </button>
</div> </div>
</div> </div>
@@ -398,14 +403,14 @@
<div class="modal-backdrop" onclick={closeEditor}></div> <div class="modal-backdrop" onclick={closeEditor}></div>
<div class="modal-panel editor-panel"> <div class="modal-panel editor-panel">
<div class="modal-header"> <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> <button class="close-btn" onclick={closeEditor} aria-label="Close"><X size={20} /></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input <input
class="editor-name" class="editor-name"
type="text" type="text"
placeholder="Template name" placeholder={t('template_name_placeholder', lang)}
bind:value={editorName} bind:value={editorName}
/> />
@@ -445,18 +450,18 @@
{/if} {/if}
</div> </div>
{/each} {/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>
</div> </div>
{/each} {/each}
<button class="editor-add-exercise" onclick={() => editorPicker = true}> <button class="editor-add-exercise" onclick={() => editorPicker = true}>
<Plus size={16} /> Add Exercise <Plus size={16} /> {t('add_exercise_btn', lang)}
</button> </button>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="modal-start" onclick={saveTemplate} disabled={editorSaving || !editorName.trim() || editorExercises.length === 0}> <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> </button>
</div> </div>
</div> </div>
@@ -478,11 +483,11 @@
<div class="modal-backdrop" onclick={closeScheduleEditor}></div> <div class="modal-backdrop" onclick={closeScheduleEditor}></div>
<div class="modal-panel editor-panel"> <div class="modal-panel editor-panel">
<div class="modal-header"> <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> <button class="close-btn" onclick={closeScheduleEditor} aria-label="Close"><X size={20} /></button>
</div> </div>
<div class="modal-body"> <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} {#if editorScheduleOrder.length > 0}
<div class="schedule-order"> <div class="schedule-order">
@@ -507,7 +512,7 @@
{/if} {/if}
<div class="schedule-available"> <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)} {#each templates.filter((t) => !editorScheduleOrder.includes(t._id)) as template (template._id)}
<button class="schedule-add-item" onclick={() => toggleScheduleTemplate(template._id)}> <button class="schedule-add-item" onclick={() => toggleScheduleTemplate(template._id)}>
<Plus size={14} /> <Plus size={14} />
@@ -515,13 +520,13 @@
</button> </button>
{/each} {/each}
{#if templates.filter((t) => !editorScheduleOrder.includes(t._id)).length === 0} {#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} {/if}
</div> </div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="modal-start" onclick={saveAndCloseSchedule} disabled={scheduleSaving}> <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> </button>
</div> </div>
</div> </div>

View File

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