Files
homepage/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte
T
Alexander e5d218820b refactor: migrate hrefs to resolve()/asset() from $app/paths
Replace string-literal and template-literal hrefs across the codebase
with the modern SvelteKit 2.26+ resolve() and asset() APIs. Migration
makes route IDs explicit, type-checked against generated $app/types,
and base-path-aware. Two codemod scripts handle the bulk; remaining
ambiguous, query-bearing, and precomputed-href cases are converted
manually at the assignment sites.
2026-04-29 22:14:29 +02:00

3046 lines
85 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import Trophy from '@lucide/svelte/icons/trophy';
import Clock from '@lucide/svelte/icons/clock';
import Dumbbell from '@lucide/svelte/icons/dumbbell';
import Route from '@lucide/svelte/icons/route';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Check from '@lucide/svelte/icons/check';
import ChevronUp from '@lucide/svelte/icons/chevron-up';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Flame from '@lucide/svelte/icons/flame';
import MapPin from '@lucide/svelte/icons/map-pin';
import Volume2 from '@lucide/svelte/icons/volume-2';
import X from '@lucide/svelte/icons/x';
import Timer from '@lucide/svelte/icons/timer';
import Plus from '@lucide/svelte/icons/plus';
import GripVertical from '@lucide/svelte/icons/grip-vertical';
import Repeat from '@lucide/svelte/icons/repeat';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { confirm } from '$lib/js/confirmDialog.svelte';
import { toast } from '$lib/js/toast.svelte';
const lang = $derived(detectFitnessLang($page.url.pathname));
const isEn = $derived(lang === 'en');
const sl = $derived(fitnessSlugs(lang));
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { getGpsTracker, trackDistance, flattenIntervals } from '$lib/js/gps.svelte';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { formatPaceRangeLabel, formatPaceValue } from '$lib/data/cardioPrRanges';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import { queueSession } from '$lib/offline/fitnessQueue';
import SetTable from '$lib/components/fitness/SetTable.svelte';
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
import WorkoutRail from '$lib/components/fitness/WorkoutRail.svelte';
import WorkoutFocusCard from '$lib/components/fitness/WorkoutFocusCard.svelte';
import Toggle from '$lib/components/Toggle.svelte';
import { onMount } from 'svelte';
const workout = getWorkout();
const sync = getWorkoutSync();
const gps = getGpsTracker();
let nameInput = $state(workout.name);
let nameEditing = $state(false);
$effect(() => { if (!nameEditing) nameInput = workout.name; });
let showPicker = $state(false);
/** @type {Record<string, Array<Record<string, any>>>} */
let previousData = $state({});
/** User-pinned exercise index for the focus pane (null = auto-follow first incomplete) */
/** @type {number | null} */
let focusedIdx = $state(null);
const autoCurrentIdx = $derived.by(() => {
const exs = workout.exercises;
for (let i = 0; i < exs.length; i++) {
if (exs[i].sets.some((/** @type {any} */ s) => !s.completed)) return i;
}
return Math.max(0, exs.length - 1);
});
const activeIdx = $derived.by(() => {
const exs = workout.exercises;
if (exs.length === 0) return -1;
if (focusedIdx != null && focusedIdx >= 0 && focusedIdx < exs.length) return focusedIdx;
return autoCurrentIdx;
});
const activeExercise = $derived(activeIdx >= 0 ? workout.exercises[activeIdx] : null);
const activeExerciseMeta = $derived(activeExercise ? getExerciseById(activeExercise.exerciseId, lang) : null);
const activeSetIdx = $derived.by(() => {
const ex = activeExercise;
if (!ex || !ex.sets.length) return 0;
for (let i = 0; i < ex.sets.length; i++) {
if (!ex.sets[i].completed) return i;
}
return ex.sets.length;
});
const activeExDoneCount = $derived(activeExercise ? activeExercise.sets.filter((/** @type {any} */ s) => s.completed).length : 0);
const workoutSetsDone = $derived(
workout.exercises.reduce(
(/** @type {number} */ n, /** @type {any} */ ex) => n + ex.sets.filter((/** @type {any} */ s) => s.completed).length,
0
)
);
const workoutSetsTotal = $derived(
workout.exercises.reduce((/** @type {number} */ n, /** @type {any} */ ex) => n + ex.sets.length, 0)
);
/** @param {number} idx */
function setFocus(idx) { focusedIdx = idx; }
/**
* Reorder an exercise from one index to another, adjusting focus to follow.
* @param {number} fromIdx
* @param {number} toIdx
*/
function reorderExercise(fromIdx, toIdx) {
if (fromIdx === toIdx) return;
const dir = fromIdx < toIdx ? 1 : -1;
let i = fromIdx;
while (i !== toIdx) {
workout.moveExercise(i, dir);
i += dir;
}
// Track the moved exercise if it was focused
if (focusedIdx === fromIdx) {
focusedIdx = toIdx;
} else if (focusedIdx != null) {
if (fromIdx < focusedIdx && toIdx >= focusedIdx) focusedIdx = focusedIdx - 1;
else if (fromIdx > focusedIdx && toIdx <= focusedIdx) focusedIdx = focusedIdx + 1;
}
}
/** @param {number} idx */
function removeExerciseFromRail(idx) {
workout.removeExercise(idx);
// Unpin focus so auto-current takes over (handles removing the focused one)
if (focusedIdx === idx) focusedIdx = null;
else if (focusedIdx != null && idx < focusedIdx) focusedIdx = focusedIdx - 1;
}
/** @type {any} */
let completionData = $state(null);
/** @type {any[]} */
let templateDiffs = $state([]);
let templateUpdateStatus = $state('idle'); // 'idle' | 'updating' | 'done'
let offlineQueued = $state(false);
let useGps = $state(gps.isTracking);
// Voice guidance config (defaults, overridden from localStorage in onMount)
let vgEnabled = $state(false);
let vgTriggerType = $state(/** @type {'distance' | 'time'} */ ('distance'));
let vgTriggerValue = $state(1);
/** @type {string[]} */
let vgMetrics = $state(['totalTime', 'totalDistance', 'avgPace']);
let vgVolume = $state(0.8);
let vgAudioDuck = $state(false);
const vgLanguage = $derived(lang);
let vgShowPanel = $state(false);
let vgLoaded = $state(false);
// Persist voice guidance settings to localStorage
$effect(() => {
const settings = {
enabled: vgEnabled,
triggerType: vgTriggerType,
triggerValue: vgTriggerValue,
metrics: vgMetrics,
volume: vgVolume,
audioDuck: vgAudioDuck,
};
if (!vgLoaded) return;
localStorage.setItem('vg_settings', JSON.stringify(settings));
});
// GPS workout mode state — if we're restoring a GPS workout that was already tracking, it's started
let gpsStarted = $state(gps.isTracking && workout.mode === 'gps' && !workout.paused);
let gpsStarting = $state(false);
// Activity type for GPS workouts
/** @type {import('$lib/js/workout.svelte').GpsActivityType} */
let selectedActivity = $state(workout.activityType ?? 'running');
let showActivityPicker = $state(false);
let showAudioPanel = $state(false);
let showIntervalPanel = $state(false);
// Interval templates
/** @type {any[]} */
let intervalTemplates = $state([]);
let selectedIntervalId = $state(/** @type {string | null} */ (null));
let showIntervalEditor = $state(false);
let editingIntervalId = $state(/** @type {string | null} */ (null));
let intervalEditorName = $state('');
/**
* @typedef {{ type: 'step', label: string, durationType: 'distance' | 'time', durationValue: number, customLabel: boolean }} EditorLeaf
* @typedef {{ type: 'group', repeat: number, steps: EditorLeaf[] }} EditorGroup
* @typedef {EditorLeaf | EditorGroup} EditorEntry
*/
/** @type {EditorEntry[]} */
let intervalEditorSteps = $state([]);
let intervalSaving = $state(false);
const PRESET_LABELS = ['Easy', 'Moderate', 'Hard', 'Sprint', 'Recovery', 'Hill Sprints', 'Tempo', 'Warm Up', 'Cool Down'];
const selectedInterval = $derived(intervalTemplates.find((/** @type {any} */ t) => t._id === selectedIntervalId) ?? null);
/** @returns {EditorLeaf} */
function makeLeaf(label = 'Sprint', durationType = /** @type {'distance'|'time'} */ ('distance'), durationValue = 400) {
return { type: 'step', label, durationType, durationValue, customLabel: !PRESET_LABELS.includes(label) };
}
/** @param {any} s @returns {EditorLeaf} */
function leafFromDb(s) {
return {
type: 'step',
label: s.label,
durationType: s.durationType,
durationValue: s.durationValue,
customLabel: !PRESET_LABELS.includes(s.label)
};
}
/** @param {any} e @returns {EditorEntry} */
function entryFromDb(e) {
if (e?.type === 'group') {
return { type: 'group', repeat: e.repeat ?? 2, steps: (e.steps ?? []).map(leafFromDb) };
}
return leafFromDb(e);
}
async function fetchIntervalTemplates() {
try {
const res = await fetch('/api/fitness/intervals');
if (res.ok) {
const d = await res.json();
intervalTemplates = d.templates ?? [];
}
} catch {}
}
function openNewInterval() {
editingIntervalId = null;
intervalEditorName = '';
intervalEditorSteps = [makeLeaf()];
showIntervalEditor = true;
}
function openEditInterval(/** @type {any} */ tmpl) {
editingIntervalId = tmpl._id;
intervalEditorName = tmpl.name;
intervalEditorSteps = (tmpl.steps ?? []).map(entryFromDb);
showIntervalEditor = true;
}
function addIntervalStep() {
intervalEditorSteps = [...intervalEditorSteps, makeLeaf('Recovery', 'time', 60)];
}
function addIntervalGroup() {
intervalEditorSteps = [...intervalEditorSteps, {
type: 'group',
repeat: 5,
steps: [makeLeaf('Sprint', 'time', 30), makeLeaf('Recovery', 'time', 60)]
}];
}
function ungroupAt(/** @type {number} */ idx) {
const g = intervalEditorSteps[idx];
if (!g || g.type !== 'group') return;
intervalEditorSteps = [
...intervalEditorSteps.slice(0, idx),
...g.steps,
...intervalEditorSteps.slice(idx + 1)
];
}
function removeIntervalStep(/** @type {number} */ idx) {
intervalEditorSteps = intervalEditorSteps.filter((_, i) => i !== idx);
}
function moveIntervalStep(/** @type {number} */ idx, /** @type {number} */ dir) {
const target = idx + dir;
if (target < 0 || target >= intervalEditorSteps.length) return;
const arr = [...intervalEditorSteps];
[arr[idx], arr[target]] = [arr[target], arr[idx]];
intervalEditorSteps = arr;
}
function addStepToGroup(/** @type {number} */ groupIdx) {
const g = intervalEditorSteps[groupIdx];
if (!g || g.type !== 'group') return;
g.steps = [...g.steps, makeLeaf('Recovery', 'time', 60)];
}
function removeStepFromGroup(/** @type {number} */ groupIdx, /** @type {number} */ stepIdx) {
const g = intervalEditorSteps[groupIdx];
if (!g || g.type !== 'group') return;
if (g.steps.length <= 1) return;
g.steps = g.steps.filter((_, i) => i !== stepIdx);
}
function moveStepInGroup(/** @type {number} */ groupIdx, /** @type {number} */ stepIdx, /** @type {number} */ dir) {
const g = intervalEditorSteps[groupIdx];
if (!g || g.type !== 'group') return;
const target = stepIdx + dir;
if (target < 0 || target >= g.steps.length) return;
const arr = [...g.steps];
[arr[stepIdx], arr[target]] = [arr[target], arr[stepIdx]];
g.steps = arr;
}
/** @param {EditorLeaf} s */
function serializeLeaf(s) {
return { type: 'step', label: s.label, durationType: s.durationType, durationValue: s.durationValue };
}
/** @param {EditorEntry} e */
function serializeEntry(e) {
if (e.type === 'group') {
return { type: 'group', repeat: e.repeat, steps: e.steps.map(serializeLeaf) };
}
return serializeLeaf(e);
}
async function saveInterval() {
if (intervalSaving || !intervalEditorName.trim() || intervalEditorSteps.length === 0) return;
intervalSaving = true;
try {
const body = {
name: intervalEditorName.trim(),
steps: intervalEditorSteps.map(serializeEntry)
};
const url = editingIntervalId ? `/api/fitness/intervals/${editingIntervalId}` : '/api/fitness/intervals';
const method = editingIntervalId ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (res.ok) {
showIntervalEditor = false;
await fetchIntervalTemplates();
}
} finally {
intervalSaving = false;
}
}
async function deleteInterval(/** @type {string} */ id) {
if (!await confirm(t('delete_interval_confirm', lang))) return;
await fetch(`/api/fitness/intervals/${id}`, { method: 'DELETE' });
if (selectedIntervalId === id) selectedIntervalId = null;
await fetchIntervalTemplates();
}
const GPS_ACTIVITIES = [
{ id: 'running', label: 'Running', icon: '🏃' },
{ id: 'walking', label: 'Walking', icon: '🚶' },
{ id: 'cycling', label: 'Cycling', icon: '🚴' },
{ id: 'hiking', label: 'Hiking', icon: '🥾' },
];
function selectActivity(/** @type {string} */ id) {
selectedActivity = /** @type {import('$lib/js/workout.svelte').GpsActivityType} */ (id);
const labels = { running: 'Running', walking: 'Walking', cycling: 'Cycling', hiking: 'Hiking' };
workout.activityType = selectedActivity;
workout.name = labels[selectedActivity] ?? 'GPS Workout';
showActivityPicker = false;
}
const availableMetrics = [
{ id: 'totalTime', label: 'Total Time' },
{ id: 'totalDistance', label: 'Total Distance' },
{ id: 'avgPace', label: 'Average Pace' },
{ id: 'splitPace', label: 'Split Pace' },
{ id: 'currentPace', label: 'Current Pace' },
];
function getVoiceGuidanceConfig() {
const hasIntervals = selectedInterval?.steps?.length > 0;
if (!vgEnabled && !hasIntervals) return undefined;
return {
enabled: true,
triggerType: vgTriggerType,
triggerValue: vgTriggerValue,
metrics: vgEnabled ? vgMetrics : [],
language: vgLanguage,
ttsVolume: vgVolume,
audioDuck: vgAudioDuck,
...(hasIntervals ? { intervals: flattenIntervals(selectedInterval.steps) } : {})
};
}
/** @param {string} id */
function toggleMetric(id) {
if (vgMetrics.includes(id)) {
vgMetrics = vgMetrics.filter((/** @type {string} */ m) => m !== id);
} else {
vgMetrics = [...vgMetrics, id];
}
}
/** @type {any} */
let liveMap = $state(null);
/** @type {any} */
let livePolyline = $state(null);
/** @type {any} */
let liveMarker = $state(null);
/** @type {any} */
let leafletLib = null;
let prevTrackLen = 0;
/** Svelte use:action — called when the map div enters the DOM */
function mountMap(/** @type {HTMLElement} */ node) {
initMap(node);
return {
destroy() {
if (liveMap) {
liveMap.remove();
}
liveMap = null;
livePolyline = null;
liveMarker = null;
leafletLib = null;
prevTrackLen = 0;
}
};
}
/** @param {HTMLElement} node */
async function initMap(node) {
leafletLib = await import('leaflet');
if (!node.isConnected) return;
liveMap = leafletLib.map(node, {
attributionControl: false,
zoomControl: false
});
leafletLib.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19
}).addTo(liveMap);
livePolyline = leafletLib.polyline([], { color: '#88c0d0', weight: 3 }).addTo(liveMap);
liveMarker = leafletLib.circleMarker([0, 0], {
radius: 6, fillColor: '#a3be8c', color: '#fff', weight: 2, opacity: 0, fillOpacity: 0
}).addTo(liveMap);
if (gps.track.length > 0) {
// Restore existing trail on the polyline
if (gpsStarted) {
const pts = gps.track.map((/** @type {any} */ p) => [p.lat, p.lng]);
livePolyline.setLatLngs(pts);
}
// Center on latest point — the marker $effect will also kick in
const last = gps.track[gps.track.length - 1];
liveMap.setView([last.lat, last.lng], 16);
liveMarker.setLatLng([last.lat, last.lng]);
prevTrackLen = gps.track.length;
} else {
// No track yet — get current position to center the map
liveMap.setView([51.5, 10], 5);
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
if (liveMap) {
liveMap.setView([pos.coords.latitude, pos.coords.longitude], 16);
}
},
() => {},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
}
}
let _cadenceWarned = false;
/** After the user resolves the permission dialog, check if cadence will work.
* We wait a few seconds to let the system dialog settle before toasting. */
function maybeWarnCadence() {
if (_cadenceWarned) return;
setTimeout(() => {
if (_cadenceWarned) return;
if (!gps.cadenceAvailable()) {
_cadenceWarned = true;
toast.info(t('cadence_permission_missing', lang));
}
}, 4000);
}
let gpsToggling = $state(false);
async function toggleGps() {
if (gpsToggling) return;
gpsToggling = true;
try {
if (!useGps) {
if (gps.isTracking) {
useGps = true;
} else {
useGps = await gps.start(getVoiceGuidanceConfig());
if (useGps) maybeWarnCadence();
}
} else {
await gps.stop();
useGps = false;
if (liveMap) {
liveMap.remove();
liveMap = null;
livePolyline = null;
liveMarker = null;
}
}
} finally {
gpsToggling = false;
}
}
// Sync workout pause state to native GPS/TTS service
$effect(() => {
if (!gps.isTracking) return;
if (workout.paused) {
gps.pauseTracking();
} else {
gps.resumeTracking();
}
});
// Update polyline incrementally when new track points arrive
$effect(() => {
const len = gps.track.length;
if (len > prevTrackLen && liveMap && livePolyline && gpsStarted) {
for (let i = prevTrackLen; i < len; i++) {
const p = gps.track[i];
livePolyline.addLatLng([p.lat, p.lng]);
}
}
// Always sync prevTrackLen even if we didn't draw (e.g. pre-start)
if (len > prevTrackLen) {
prevTrackLen = len;
}
});
// Always keep marker and map view centered on the latest GPS position
$effect(() => {
const pt = gps.latestPoint;
if (pt && liveMap && liveMarker) {
const ll = [pt.lat, pt.lng];
liveMarker.setLatLng(ll);
liveMarker.setStyle({ opacity: 1, fillOpacity: 1 });
const zoom = liveMap.getZoom();
liveMap.setView(ll, zoom < 14 ? 16 : zoom);
}
});
/** Check if any exercise in the workout is cardio */
function hasCardioExercise() {
return workout.exercises.some((/** @type {any} */ e) => {
const exercise = getExerciseById(e.exerciseId);
return exercise?.bodyPart === 'cardio';
});
}
let _prestartGps = false;
onMount(() => {
if (!workout.active && !completionData) {
goto(`/fitness/${sl.workout}`);
return;
}
// Restore voice guidance settings from localStorage
try {
const saved = localStorage.getItem('vg_settings');
if (saved) {
const s = JSON.parse(saved);
if (typeof s.enabled === 'boolean') vgEnabled = s.enabled;
if (s.triggerType === 'distance' || s.triggerType === 'time') vgTriggerType = s.triggerType;
if (typeof s.triggerValue === 'number' && s.triggerValue > 0) vgTriggerValue = s.triggerValue;
if (Array.isArray(s.metrics)) vgMetrics = s.metrics;
if (typeof s.volume === 'number' && s.volume >= 0 && s.volume <= 1) vgVolume = s.volume;
if (typeof s.audioDuck === 'boolean') vgAudioDuck = s.audioDuck;
}
} catch {}
vgLoaded = true;
// Pre-select interval from GPS template, or restore from localStorage
if (workout.intervalTemplateId) {
selectedIntervalId = workout.intervalTemplateId;
} else {
try {
const savedId = localStorage.getItem('selected_interval_id');
if (savedId) selectedIntervalId = savedId;
} catch {}
}
// Fetch interval templates
fetchIntervalTemplates();
// For GPS workouts in pre-start: start GPS immediately so the map
// shows the user's position while they configure activity/audio.
if (workout.mode === 'gps' && !gpsStarted && !gps.isTracking) {
_prestartGps = true;
gps.start(undefined, true);
maybeWarnCadence();
}
});
// Persist selected interval ID
$effect(() => {
if (!vgLoaded) return;
if (selectedIntervalId) {
localStorage.setItem('selected_interval_id', selectedIntervalId);
} else {
localStorage.removeItem('selected_interval_id');
}
});
/** @param {string[]} exerciseIds */
async function fetchPreviousData(exerciseIds) {
const promises = exerciseIds.map(async (id) => {
if (previousData[id]) return;
try {
const res = await fetch(`/api/fitness/exercises/${id}/history?limit=1`);
if (res.ok) {
const d = await res.json();
if (d.history?.length > 0 && d.history[0].sets) {
previousData[id] = d.history[0].sets;
}
}
} catch {}
});
await Promise.all(promises);
}
/** @param {string} exerciseId */
async function addExerciseFromPicker(exerciseId) {
workout.addExercise(exerciseId);
fetchPreviousData([exerciseId]);
// Auto-start GPS when adding a cardio exercise
const exercise = getExerciseById(exerciseId);
if (exercise?.bodyPart === 'cardio' && gps.available && !useGps && !gps.isTracking) {
useGps = await gps.start(getVoiceGuidanceConfig());
if (useGps) maybeWarnCadence();
}
}
async function startGpsWorkout() {
if (gpsStarting) return;
gpsStarting = true;
try {
if (_prestartGps && gps.isTracking) {
// GPS was running for pre-start preview — stop and restart
// so the native service resets time/distance to zero
await gps.stop();
gps.reset();
prevTrackLen = 0;
}
const started = await gps.start(getVoiceGuidanceConfig());
if (started) {
gpsStarted = true;
useGps = true;
workout.resumeTimer();
maybeWarnCadence();
}
} finally {
gpsStarting = false;
_prestartGps = false;
}
}
/** Map GPS activity types to exercise IDs */
const ACTIVITY_EXERCISE_MAP = /** @type {Record<string, string>} */ ({
running: 'running',
walking: 'walking',
cycling: 'cycling-outdoor',
hiking: 'hiking',
});
async function finishWorkout() {
// Stop GPS tracking and collect track data
const gpsTrack = gps.isTracking ? await gps.stop() : [];
const wasGpsMode = workout.mode === 'gps';
const actType = workout.activityType;
const sessionData = workout.finish();
if (wasGpsMode && gpsTrack.length >= 2) {
// GPS workout: create a cardio exercise entry with the track attached,
// just like a manually-added workout with GPX upload
const filteredDistance = Math.round(trackDistance(gpsTrack) * 100) / 100;
const durationMin = (gpsTrack[gpsTrack.length - 1].timestamp - gpsTrack[0].timestamp) / 60000;
const exerciseId = ACTIVITY_EXERCISE_MAP[actType ?? 'running'] ?? 'running';
const exerciseName = getExerciseById(exerciseId)?.name ?? exerciseId;
sessionData.exercises = /** @type {typeof sessionData.exercises} */ (
/** @type {unknown} */ ([{
exerciseId,
name: exerciseName,
sets: [{
reps: undefined,
weight: undefined,
rpe: undefined,
distance: filteredDistance,
duration: Math.round(durationMin * 100) / 100,
completed: true,
}],
gpsTrack,
totalDistance: filteredDistance,
}])
);
} else if (wasGpsMode && gpsTrack.length === 0) {
// GPS workout with no track data — nothing to save
gps.reset();
await sync.onWorkoutEnd();
await goto(`/fitness/${sl.workout}`);
return;
} else {
// Manual workout: attach GPS to cardio exercises
const workoutStart = new Date(sessionData.startTime).getTime();
const filteredTrack = gpsTrack.filter((/** @type {any} */ p) => p.timestamp >= workoutStart);
const filteredDistance = Math.round(trackDistance(filteredTrack) * 100) / 100;
if (filteredTrack.length > 0) {
for (const ex of sessionData.exercises) {
const exercise = getExerciseById(ex.exerciseId);
if (exercise?.bodyPart === 'cardio') {
/** @type {any} */ (ex).gpsTrack = filteredTrack;
/** @type {any} */ (ex).totalDistance = filteredDistance;
}
}
}
}
gps.reset();
try {
const res = await fetch('/api/fitness/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sessionData)
});
await sync.onWorkoutEnd();
if (res.ok) {
const d = await res.json();
completionData = buildCompletion(sessionData, d.session);
computeTemplateDiff(completionData);
} else {
await queueSession(sessionData);
offlineQueued = true;
completionData = buildCompletion(sessionData, { _id: null });
}
} catch {
await queueSession(sessionData);
offlineQueued = true;
completionData = buildCompletion(sessionData, { _id: null });
await sync.onWorkoutEnd();
}
}
/**
* Build the completion summary from local session data + server response
* @param {any} local
* @param {any} saved
*/
/** Format a stored PR (machine format) for display */
function formatPr(/** @type {any} */ pr) {
/** @type {Record<string, string>} */
const TYPE_LABELS = {
est1rm: 'Est. 1RM',
maxWeight: 'Max Weight',
bestSetVolume: 'Best Set Volume',
};
let type = TYPE_LABELS[pr.type] ?? pr.type;
let value = `${pr.value} kg`;
if (pr.type === 'repMax') {
type = `${pr.reps}-rep max`;
} else if (pr.type === 'longestDistance') {
type = 'Longest Distance';
value = `${pr.value} km`;
} else if (pr.type.startsWith('fastestPace:')) {
type = `Fastest Pace (${formatPaceRangeLabel(pr.type)})`;
value = formatPaceValue(pr.value);
}
return { exerciseId: pr.exerciseId, type, value };
}
/**
* @param {any} local
* @param {any} saved
*/
function buildCompletion(local, saved) {
const startTime = new Date(local.startTime);
const endTime = new Date(local.endTime);
const durationMin = Math.round((endTime.getTime() - startTime.getTime()) / 60000);
let totalTonnage = 0;
let totalDistance = local.totalDistance ?? 0;
const exerciseSummaries = local.exercises.map((/** @type {any} */ ex) => {
const exercise = getExerciseById(ex.exerciseId, lang);
const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance');
const isDurationOnly = metrics.includes('duration') && !metrics.includes('weight') && !metrics.includes('reps');
const isBilateral = exercise?.bilateral ?? false;
const weightMul = isBilateral ? 2 : 1;
let exTonnage = 0;
let exDistance = 0;
let exDuration = 0;
let bestWeight = 0;
let bestEst1rm = 0;
let sets = 0;
for (const s of ex.sets) {
if (!s.completed) continue;
sets++;
if (isCardio) {
exDistance += s.distance ?? 0;
exDuration += s.duration ?? 0;
} else if (isDurationOnly) {
exDuration += s.duration ?? 0;
} else {
const w = (s.weight ?? 0) * weightMul;
const r = s.reps ?? 0;
exTonnage += w * r;
if (s.weight > bestWeight) bestWeight = s.weight;
const e1rm = r > 0 && s.weight > 0 ? (r === 1 ? s.weight : Math.round(s.weight * (1 + r / 30))) : 0;
if (e1rm > bestEst1rm) bestEst1rm = e1rm;
}
}
totalTonnage += exTonnage;
totalDistance += exDistance;
const pace = isCardio && exDistance > 0 && exDuration > 0 ? exDuration / exDistance : 0;
return {
exerciseId: ex.exerciseId,
sets,
isCardio,
isDurationOnly,
tonnage: exTonnage,
distance: exDistance,
duration: exDuration,
pace,
bestWeight,
bestEst1rm
};
});
// Use server-computed PRs and kcal (accurate, uses all history + GPS + demographics)
const prs = (saved.prs ?? []).map(formatPr);
const kcalResult = saved.kcalEstimate ?? null;
return {
sessionId: saved._id,
name: local.name,
templateId: local.templateId,
exercises: local.exercises,
durationMin,
totalTonnage,
totalDistance,
exerciseSummaries,
prs,
kcalResult
};
}
/**
* Compare completed workout exercises against the source template
* and compute diffs for weight/reps changes.
* @param {any} completion
*/
async function computeTemplateDiff(completion) {
if (!completion.templateId) return;
try {
const res = await fetch(`/api/fitness/templates/${completion.templateId}`);
if (!res.ok) return;
const { template } = await res.json();
/** @type {any[]} */
const diffs = [];
for (const actual of completion.exercises) {
const tmplEx = template.exercises?.find((/** @type {any} */ e) => e.exerciseId === actual.exerciseId);
if (!tmplEx) continue;
const exercise = getExerciseById(actual.exerciseId, lang);
const metrics = getExerciseMetrics(exercise);
if (metrics.includes('distance')) continue; // skip cardio
const isDurOnly = metrics.includes('duration') && !metrics.includes('weight') && !metrics.includes('reps');
const completedSets = actual.sets.filter((/** @type {any} */ s) => s.completed);
if (completedSets.length === 0) continue;
// Check if sets differ
const tmplSets = tmplEx.sets ?? [];
let changed = completedSets.length !== tmplSets.length;
if (!changed) {
for (let i = 0; i < completedSets.length; i++) {
const a = completedSets[i];
const tp = tmplSets[i];
if (isDurOnly) {
if ((a.duration ?? 0) !== (tp.duration ?? 0)) { changed = true; break; }
} else {
if ((a.reps ?? 0) !== (tp.reps ?? 0) || (a.weight ?? 0) !== (tp.weight ?? 0)) { changed = true; break; }
}
}
}
if (changed) {
diffs.push({
exerciseId: actual.exerciseId,
name: exercise?.localName ?? actual.exerciseId,
isDurationOnly: isDurOnly,
oldSets: tmplSets,
newSets: completedSets.map((/** @type {any} */ s) => isDurOnly
? { duration: s.duration ?? undefined }
: { reps: s.reps ?? undefined, weight: s.weight ?? undefined, rpe: s.rpe ?? undefined }
)
});
}
}
templateDiffs = diffs;
} catch {}
}
async function updateTemplate() {
if (!completionData?.templateId || templateDiffs.length === 0) return;
templateUpdateStatus = 'updating';
try {
// Fetch current template to get full data
const res = await fetch(`/api/fitness/templates/${completionData.templateId}`);
if (!res.ok) { templateUpdateStatus = 'idle'; return; }
const { template } = await res.json();
// Apply diffs to template exercises
const updatedExercises = template.exercises.map((/** @type {any} */ ex) => {
const diff = templateDiffs.find((/** @type {any} */ d) => d.exerciseId === ex.exerciseId);
if (diff) {
return { ...ex, sets: diff.newSets };
}
return ex;
});
const putRes = await fetch(`/api/fitness/templates/${completionData.templateId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: template.name, exercises: updatedExercises })
});
templateUpdateStatus = putRes.ok ? 'done' : 'idle';
} catch {
templateUpdateStatus = 'idle';
}
}
/** @param {number} secs */
function formatElapsed(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/** @param {number} mins */
function formatDuration(mins) {
const h = Math.floor(mins / 60);
const m = mins % 60;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
/** @param {number} minPerKm */
function formatPace(minPerKm) {
const m = Math.floor(minPerKm);
const s = Math.round((minPerKm - m) * 60);
return `${m}:${s.toString().padStart(2, '0')} /km`;
}
function cancelRest() {
workout.cancelRestTimer();
}
// Fetch previous data for existing exercises on mount
onMount(() => {
if (workout.active && workout.exercises.length > 0) {
fetchPreviousData(workout.exercises.map((/** @type {any} */ e) => e.exerciseId));
}
});
</script>
<svelte:head>
<title>{workout.name || (lang === 'en' ? 'Workout' : 'Training')} - Bocken</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</svelte:head>
{#if completionData}
<div class="completion">
<div class="completion-header">
<h1>{t('workout_complete', lang)}</h1>
{#if completionData.prs.length > 0}
<div class="pr-badge">
<span class="pr-badge-count">{completionData.prs.length}</span>
<Trophy size={20} />
</div>
{/if}
<p class="completion-name">{completionData.name}</p>
{#if offlineQueued}
<p class="offline-banner">{t('workout_saved_offline', lang)}</p>
{/if}
</div>
<div class="completion-stats">
<div class="comp-stat">
<Clock size={18} />
<span class="comp-stat-value">{formatDuration(completionData.durationMin)}</span>
<span class="comp-stat-label">{t('duration', lang)}</span>
</div>
{#if completionData.totalTonnage > 0}
<div class="comp-stat">
<Dumbbell size={18} />
<span class="comp-stat-value">
{completionData.totalTonnage >= 1000
? `${(completionData.totalTonnage / 1000).toFixed(1)}t`
: `${Math.round(completionData.totalTonnage)} kg`}
</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">{t('distance', lang)}</span>
</div>
{/if}
{#if completionData.kcalResult}
<div class="comp-stat kcal">
<Flame size={18} />
<span class="comp-stat-value">{completionData.kcalResult.kcal} &plusmn; {completionData.kcalResult.kcal - completionData.kcalResult.lower} kcal</span>
<span class="comp-stat-label">{t('est_kcal', lang)}</span>
</div>
{/if}
</div>
{#if completionData.prs.length > 0}
<div class="prs-section">
<h2><Trophy size={16} /> {t('personal_records', lang)}</h2>
<div class="pr-list">
{#each completionData.prs as pr}
<div class="pr-item">
<span class="pr-exercise">{getExerciseById(pr.exerciseId, lang)?.localName ?? pr.exerciseId}</span>
<span class="pr-detail">{pr.type}: <strong>{pr.value}</strong></span>
</div>
{/each}
</div>
</div>
{/if}
<div class="exercise-summaries">
<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, lang)?.localName ?? ex.exerciseId}</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.isDurationOnly}
<span>{ex.sets > 0 ? Math.round((ex.duration / ex.sets) * 60) : 0}s × {ex.sets}</span>
{:else if ex.isCardio}
{#if ex.distance > 0}
<span>{ex.distance.toFixed(1)} km</span>
{/if}
{#if ex.duration > 0}
<span>{ex.duration} min</span>
{/if}
{#if ex.pace > 0}
<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`} {t('volume', lang)}</span>
{/if}
{#if ex.bestWeight > 0}
<span>Top: {ex.bestWeight} kg</span>
{/if}
{#if ex.bestEst1rm > 0}
<span>e1RM: {ex.bestEst1rm} kg</span>
{/if}
{/if}
</div>
</div>
{/each}
</div>
{#if templateDiffs.length > 0}
<div class="template-update-section">
{#if templateUpdateStatus === 'done'}
<div class="template-updated">
<Check size={16} />
<span>{t('template_updated', lang)}</span>
</div>
{:else}
<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">
<span class="diff-name">{diff.name}</span>
<div class="diff-sets">
{#each diff.newSets as set, i}
{@const old = diff.oldSets[i]}
<div class="diff-set-row">
{#if diff.isDurationOnly}
{#if old}
<span class="diff-old">{old.duration != null ? Math.round(old.duration * 60) : '—'}s</span>
<span class="diff-arrow"></span>
{/if}
<span class="diff-new">{set.duration != null ? Math.round(set.duration * 60) : '—'}s</span>
{:else}
{#if old}
<span class="diff-old">{old.weight ?? '—'} kg × {old.reps ?? '—'}</span>
<span class="diff-arrow"></span>
{/if}
<span class="diff-new">{set.weight ?? '—'} kg × {set.reps ?? '—'}</span>
{/if}
</div>
{/each}
{#if diff.newSets.length > diff.oldSets.length}
<div class="diff-set-row">
<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>
</div>
{/each}
</div>
<button class="update-template-btn" onclick={updateTemplate} disabled={templateUpdateStatus === 'updating'}>
{templateUpdateStatus === 'updating' ? t('updating', lang) : t('update_template', lang)}
</button>
{/if}
</div>
{/if}
<button class="done-btn" onclick={() => goto(offlineQueued ? `/fitness/${sl.workout}` : `/fitness/${sl.history}/${completionData.sessionId}`)}>
{offlineQueued ? t('done', lang) : t('view_workout', lang)}
</button>
</div>
{:else if workout.active && workout.mode === 'gps'}
<div class="gps-workout">
<div class="gps-workout-map" use:mountMap></div>
<!-- Overlay: sits on top of the map at the bottom -->
<div class="gps-overlay" class:gps-overlay-prestart={!gpsStarted}>
{#if gpsStarted}
<div class="gps-workout-stats">
<div class="gps-stat">
<span class="gps-stat-value">{gps.distance.toFixed(2)}</span>
<span class="gps-stat-unit">km</span>
</div>
<div class="gps-stat">
<span class="gps-stat-value">{formatElapsed(workout.elapsedSeconds)}</span>
<span class="gps-stat-unit">time</span>
</div>
{#if gps.currentPace > 0}
<div class="gps-stat">
<span class="gps-stat-value">{Math.floor(gps.currentPace)}:{Math.round((gps.currentPace % 1) * 60).toString().padStart(2, '0')}</span>
<span class="gps-stat-unit">/km</span>
</div>
{/if}
</div>
{#if vgEnabled}
<div class="vg-active-badge">
<Volume2 size={12} />
<span>Voice: every {vgTriggerValue} {vgTriggerType === 'distance' ? 'km' : 'min'}</span>
</div>
{/if}
{#if gps.intervalState}
<div class="interval-active-overlay">
{#if gps.intervalState.complete}
<div class="interval-complete-badge">
<Check size={14} />
<span>{t('intervals_complete', lang)}</span>
</div>
{:else}
<div class="interval-current-label">{gps.intervalState.currentLabel}</div>
<div class="interval-progress-row">
<span class="interval-step-count">{gps.intervalState.currentIndex + 1} / {gps.intervalState.totalSteps}</span>
<div class="interval-progress-bar">
<div class="interval-progress-fill" style="width: {Math.round(gps.intervalState.progress * 100)}%"></div>
</div>
</div>
{/if}
</div>
{/if}
<div class="gps-overlay-actions">
<button class="gps-overlay-pause" onclick={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()} aria-label={workout.paused ? 'Resume' : 'Pause'}>
{#if workout.paused}<Play size={22} />{:else}<Pause size={22} />{/if}
</button>
{#if workout.paused}
<button class="gps-overlay-cancel" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
<Trash2 size={18} />
</button>
{/if}
<button class="gps-overlay-finish" onclick={finishWorkout}>Finish</button>
</div>
{:else}
<div class="gps-options-grid">
<button class="gps-option-tile" onclick={() => { showActivityPicker = !showActivityPicker; showAudioPanel = false; showIntervalPanel = false; }} type="button">
<span class="gps-option-icon">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.icon ?? '🏃'}</span>
<span class="gps-option-label">Activity</span>
<span class="gps-option-value">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.label ?? 'Running'}</span>
</button>
<button class="gps-option-tile" onclick={() => { showAudioPanel = !showAudioPanel; showActivityPicker = false; showIntervalPanel = false; }} type="button">
<Volume2 size={20} />
<span class="gps-option-label">Audio Stats</span>
<span class="gps-option-value">{vgEnabled ? `Every ${vgTriggerValue} ${vgTriggerType === 'distance' ? 'km' : 'min'}` : 'Off'}</span>
</button>
<button class="gps-option-tile" onclick={() => { showIntervalPanel = !showIntervalPanel; showActivityPicker = false; showAudioPanel = false; }} type="button">
<Timer size={20} />
<span class="gps-option-label">{t('intervals', lang)}</span>
<span class="gps-option-value">{selectedInterval?.name ?? t('no_intervals', lang)}</span>
</button>
</div>
{#if showActivityPicker}
<div class="gps-activity-picker">
{#each GPS_ACTIVITIES as act (act.id)}
<button
class="gps-activity-choice"
class:active={selectedActivity === act.id}
onclick={() => selectActivity(act.id)}
type="button"
>
<span class="gps-activity-icon">{act.icon}</span>
<span>{act.label}</span>
</button>
{/each}
</div>
{/if}
{#if showAudioPanel}
<div class="vg-panel">
{#if !gps.hasTtsEngine()}
<div class="vg-no-engine">
<span>No text-to-speech engine installed.</span>
<button class="vg-install-btn" onclick={() => gps.installTtsEngine()} type="button">
Install TTS Engine
</button>
</div>
{:else}
<Toggle bind:checked={vgEnabled} label="Enable voice announcements" />
{#if vgEnabled}
<div class="vg-group">
<span class="vg-label">Announce every</span>
<div class="vg-trigger-row">
<input
class="vg-number"
type="number"
min="0.1"
step="0.5"
bind:value={vgTriggerValue}
/>
<select class="vg-select" bind:value={vgTriggerType}>
<option value="distance">km</option>
<option value="time">min</option>
</select>
</div>
</div>
<div class="vg-group">
<span class="vg-label">Metrics</span>
<div class="vg-metrics">
{#each availableMetrics as m (m.id)}
<button
class="vg-metric-chip"
class:selected={vgMetrics.includes(m.id)}
onclick={() => toggleMetric(m.id)}
type="button"
>
{m.label}
</button>
{/each}
</div>
</div>
<div class="vg-group">
<label class="vg-label" for="vg-volume">
TTS Volume
<span class="vg-volume-value">{Math.round(vgVolume * 100)}%</span>
</label>
<input
id="vg-volume"
class="vg-range"
type="range"
min="0"
max="1"
step="0.05"
bind:value={vgVolume}
/>
</div>
<Toggle bind:checked={vgAudioDuck} label="Duck other audio during TTS" />
{/if}
{/if}
</div>
{/if}
{#if showIntervalPanel}
<div class="interval-panel">
{#if intervalTemplates.length > 0}
<div class="interval-list">
{#each intervalTemplates as tmpl (tmpl._id)}
<div class="interval-card" class:selected={selectedIntervalId === tmpl._id}>
<button class="interval-card-main" type="button" onclick={() => { selectedIntervalId = selectedIntervalId === tmpl._id ? null : tmpl._id; }}>
<span class="interval-card-name">{tmpl.name}</span>
<span class="interval-card-info">{flattenIntervals(tmpl.steps).length} {t('steps_count', lang)}</span>
</button>
<div class="interval-card-actions">
<button class="interval-card-edit" type="button" onclick={() => openEditInterval(tmpl)}>{t('edit', lang)}</button>
<button class="interval-card-delete" type="button" onclick={() => deleteInterval(tmpl._id)}><Trash2 size={14} /></button>
</div>
</div>
{/each}
</div>
{:else}
<p class="interval-empty">{t('no_intervals', lang)}</p>
{/if}
<button class="interval-new-btn" type="button" onclick={openNewInterval}>
<Plus size={16} />
{t('new_interval', lang)}
</button>
</div>
{/if}
<button class="gps-start-btn" onclick={startGpsWorkout} disabled={gpsStarting}>
{#if gpsStarting}
<span class="gps-spinner"></span> Initializing GPS…
{:else}
Start
{/if}
</button>
<button class="gps-cancel-link" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }} type="button">
<X size={14} />
{t('cancel_workout', lang)}
</button>
{/if}
</div>
{#if showIntervalEditor}
<div class="interval-editor-overlay">
<div class="interval-editor">
<div class="interval-editor-header">
<h2>{editingIntervalId ? t('edit_interval', lang) : t('new_interval', lang)}</h2>
<button class="interval-editor-close" type="button" onclick={() => showIntervalEditor = false}>
<X size={20} />
</button>
</div>
<input
class="interval-editor-name"
type="text"
placeholder={t('interval_name_placeholder', lang)}
bind:value={intervalEditorName}
/>
{#snippet stepCard(
/** @type {EditorLeaf} */ step,
/** @type {string} */ num,
/** @type {() => void} */ onMoveUp,
/** @type {() => void} */ onMoveDown,
/** @type {() => void} */ onRemove,
/** @type {boolean} */ canMoveUp,
/** @type {boolean} */ canMoveDown,
/** @type {boolean} */ canRemove
)}
<div class="interval-step-card">
<div class="interval-step-header">
<span class="interval-step-num">{num}</span>
<div class="interval-step-move">
<button type="button" onclick={onMoveUp} disabled={!canMoveUp}><ChevronUp size={14} /></button>
<button type="button" onclick={onMoveDown} disabled={!canMoveDown}><ChevronDown size={14} /></button>
</div>
<button class="interval-step-remove" type="button" onclick={onRemove} disabled={!canRemove}>
<Trash2 size={14} />
</button>
</div>
<div class="interval-step-labels">
{#each PRESET_LABELS as preset}
<button
class="interval-label-chip"
class:selected={!step.customLabel && step.label === preset}
type="button"
onclick={() => { step.label = preset; step.customLabel = false; }}
>{preset}</button>
{/each}
<button
class="interval-label-chip"
class:selected={step.customLabel}
type="button"
onclick={() => { step.customLabel = true; }}
>{t('custom', lang)}</button>
</div>
{#if step.customLabel}
<input
class="interval-step-custom-input"
type="text"
placeholder={t('step_label', lang)}
bind:value={step.label}
/>
{/if}
<div class="interval-step-duration">
<input
class="interval-step-value"
type="number"
min="1"
bind:value={step.durationValue}
/>
<div class="interval-step-type-toggle">
<button
class="interval-type-btn"
class:active={step.durationType === 'distance'}
type="button"
onclick={() => { step.durationType = 'distance'; }}
>{t('meters', lang)}</button>
<button
class="interval-type-btn"
class:active={step.durationType === 'time'}
type="button"
onclick={() => { step.durationType = 'time'; }}
>{t('seconds', lang)}</button>
</div>
</div>
</div>
{/snippet}
<div class="interval-editor-steps">
{#each intervalEditorSteps as entry, idx (idx)}
{#if entry.type === 'group'}
<div class="interval-group-card">
<div class="interval-group-header">
<Repeat size={16} />
<span class="interval-group-label">{t('group_label', lang)}</span>
<input
class="interval-group-repeat"
type="number"
min="1"
max="99"
bind:value={entry.repeat}
/>
<span class="interval-group-times">× {t('repeat_times', lang)}</span>
<div class="interval-group-actions">
<button type="button" onclick={() => moveIntervalStep(idx, -1)} disabled={idx === 0}><ChevronUp size={14} /></button>
<button type="button" onclick={() => moveIntervalStep(idx, 1)} disabled={idx === intervalEditorSteps.length - 1}><ChevronDown size={14} /></button>
<button class="interval-group-ungroup" type="button" onclick={() => ungroupAt(idx)}>{t('ungroup', lang)}</button>
<button class="interval-step-remove" type="button" onclick={() => removeIntervalStep(idx)} disabled={intervalEditorSteps.length <= 1}>
<Trash2 size={14} />
</button>
</div>
</div>
<div class="interval-group-steps">
{#each entry.steps as gStep, gIdx (gIdx)}
{@render stepCard(
gStep,
`${idx + 1}.${gIdx + 1}`,
() => moveStepInGroup(idx, gIdx, -1),
() => moveStepInGroup(idx, gIdx, 1),
() => removeStepFromGroup(idx, gIdx),
gIdx > 0,
gIdx < entry.steps.length - 1,
entry.steps.length > 1
)}
{/each}
<button class="interval-add-step-btn interval-add-step-btn--inner" type="button" onclick={() => addStepToGroup(idx)}>
<Plus size={14} />
{t('add_step', lang)}
</button>
</div>
</div>
{:else}
{@render stepCard(
entry,
`${idx + 1}`,
() => moveIntervalStep(idx, -1),
() => moveIntervalStep(idx, 1),
() => removeIntervalStep(idx),
idx > 0,
idx < intervalEditorSteps.length - 1,
intervalEditorSteps.length > 1
)}
{/if}
{/each}
</div>
<div class="interval-add-row">
<button class="interval-add-step-btn" type="button" onclick={addIntervalStep}>
<Plus size={16} />
{t('add_step', lang)}
</button>
<button class="interval-add-step-btn" type="button" onclick={addIntervalGroup}>
<Repeat size={16} />
{t('add_group', lang)}
</button>
</div>
<button
class="interval-save-btn"
type="button"
onclick={saveInterval}
disabled={intervalSaving || !intervalEditorName.trim() || intervalEditorSteps.length === 0}
>
{intervalSaving ? t('saving', lang) : t('save_interval', lang)}
</button>
</div>
</div>
{/if}
</div>
{:else if workout.active}
<div class="active-workout">
{#snippet workoutTitle()}
<input
class="workout-name-input"
type="text"
bind:value={nameInput}
onfocus={() => { nameEditing = true; }}
onblur={() => { nameEditing = false; workout.name = nameInput; }}
onkeydown={(e) => { if (e.key === 'Enter' && e.target instanceof HTMLElement) e.target.blur(); }}
placeholder={t('workout_name_placeholder', lang)}
/>
{/snippet}
{#if gps.available && hasCardioExercise()}
<div class="gps-section">
<button class="gps-toggle-row" onclick={toggleGps} type="button">
<MapPin size={14} />
<span class="gps-toggle-track" class:checked={useGps}></span>
<span>GPS Tracking</span>
</button>
{#if gpsToggling}
<div class="gps-initializing">
<span class="gps-spinner"></span> {t('initializing_gps', lang) ?? 'Initializing GPS…'}
</div>
{/if}
{#if !useGps}
<button class="vg-toggle-row" onclick={() => vgShowPanel = !vgShowPanel} type="button">
<Volume2 size={14} />
<span class="gps-toggle-track" class:checked={vgEnabled}></span>
<span>Voice Guidance</span>
</button>
{#if vgShowPanel}
<div class="vg-panel">
{#if !gps.hasTtsEngine()}
<div class="vg-no-engine">
<span>No text-to-speech engine installed.</span>
<button class="vg-install-btn" onclick={() => gps.installTtsEngine()} type="button">
Install TTS Engine
</button>
</div>
{:else}
<Toggle bind:checked={vgEnabled} label="Enable voice announcements" />
{#if vgEnabled}
<div class="vg-group">
<span class="vg-label">Announce every</span>
<div class="vg-trigger-row">
<input
class="vg-number"
type="number"
min="0.1"
step="0.5"
bind:value={vgTriggerValue}
/>
<select class="vg-select" bind:value={vgTriggerType}>
<option value="distance">km</option>
<option value="time">min</option>
</select>
</div>
</div>
<div class="vg-group">
<span class="vg-label">Metrics</span>
<div class="vg-metrics">
{#each availableMetrics as m (m.id)}
<button
class="vg-metric-chip"
class:selected={vgMetrics.includes(m.id)}
onclick={() => toggleMetric(m.id)}
type="button"
>
{m.label}
</button>
{/each}
</div>
</div>
<div class="vg-group">
<label class="vg-label" for="vg-volume-gps">
TTS Volume
<span class="vg-volume-value">{Math.round(vgVolume * 100)}%</span>
</label>
<input
id="vg-volume-gps"
class="vg-range"
type="range"
min="0"
max="1"
step="0.05"
bind:value={vgVolume}
/>
</div>
<Toggle bind:checked={vgAudioDuck} label="Duck other audio during TTS" />
{/if}
{/if}
</div>
{/if}
{/if}
{#if useGps}
<div class="gps-bar active">
<span class="gps-distance">{gps.distance.toFixed(2)} km</span>
{#if gps.currentPace > 0}
<span class="gps-pace">{Math.floor(gps.currentPace)}:{Math.round((gps.currentPace % 1) * 60).toString().padStart(2, '0')} /km</span>
{/if}
<span class="gps-label">{gps.track.length} pts</span>
</div>
{#if vgEnabled}
<div class="vg-active-badge">
<Volume2 size={12} />
<span>Voice: every {vgTriggerValue} {vgTriggerType === 'distance' ? 'km' : 'min'}</span>
</div>
{/if}
<div class="live-map" use:mountMap></div>
{/if}
</div>
{/if}
<div class="workout-grid">
<WorkoutRail
title={workoutTitle}
exercises={workout.exercises}
activeIdx={activeIdx}
activeSetIdx={activeSetIdx}
elapsedLabel={formatElapsed(workout.elapsedSeconds)}
paused={workout.paused}
syncStatus={sync.status}
setsDone={workoutSetsDone}
setsTotal={workoutSetsTotal}
addLabel={t('add_exercise', lang)}
pauseLabel={isEn ? 'Pause' : 'Pause'}
resumeLabel={isEn ? 'Resume' : 'Fortsetzen'}
removeLabel={isEn ? 'Remove exercise' : 'Übung entfernen'}
previousData={previousData}
onPauseToggle={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()}
onFocus={setFocus}
onAddExercise={() => showPicker = true}
onRemove={removeExerciseFromRail}
onReorder={reorderExercise}
/>
<main class="workout-stage">
{#if activeExercise}
{@const exMetrics = getExerciseMetrics(getExerciseById(activeExercise.exerciseId))}
{@const isDurationOnly = exMetrics.includes('duration') && !exMetrics.includes('weight') && !exMetrics.includes('reps')}
<WorkoutFocusCard
exerciseId={activeExercise.exerciseId}
bodyPart={activeExerciseMeta?.localBodyPart ?? null}
equipment={activeExerciseMeta?.localEquipment ?? null}
detailsHref={resolve('/fitness/[exercises=fitnessExercises]/[id]', { exercises: sl.exercises, id: activeExercise.exerciseId })}
detailsLabel={isEn ? 'Exercise details' : 'Übungsdetails'}
exerciseIndex={activeIdx}
totalExercises={workout.exercises.length}
sets={activeExercise.sets}
activeSetIdx={activeSetIdx}
labels={{
exerciseOf: (i, n) => isEn ? `Exercise ${i} of ${n}` : `Übung ${i} von ${n}`,
setOf: (i, n) => isEn ? `Set ${i} of ${n}` : `Satz ${i} von ${n}`,
done: (n) => isEn ? `${n}/${n} complete` : `${n}/${n} erledigt`,
}}
/>
<div class="exercise-block focused">
<SetTable
sets={activeExercise.sets}
previousSets={previousData[activeExercise.exerciseId] ?? []}
metrics={exMetrics}
editable={true}
timedHold={isDurationOnly}
restAfterSet={workout.restTimerActive && workout.restExerciseIdx === activeIdx ? workout.restSetIdx : -1}
restSeconds={workout.restTimerSeconds}
restTotal={workout.restTimerTotal}
holdAfterSet={workout.holdTimerActive && workout.holdExerciseIdx === activeIdx ? workout.holdSetIdx : -1}
holdSeconds={workout.holdTimerSeconds}
holdTotal={workout.holdTimerTotal}
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
onRestSkip={cancelRest}
onHoldSkip={() => workout.cancelHoldTimer()}
onUpdate={(setIdx, d) => workout.updateSet(activeIdx, setIdx, d)}
onToggleComplete={(setIdx) => {
const ex = activeExercise;
if (!ex) return;
const set = ex.sets[setIdx];
if (workout.holdTimerActive && workout.holdExerciseIdx === activeIdx && workout.holdSetIdx === setIdx) {
workout.cancelHoldTimer();
return;
}
if (isDurationOnly && set?.duration && !set.completed) {
workout.startHoldTimer(Math.round(set.duration * 60), activeIdx, setIdx);
} else {
workout.toggleSetComplete(activeIdx, setIdx);
if (ex.sets[setIdx]?.completed) {
workout.startRestTimer(ex.restTime, activeIdx, setIdx);
}
}
}}
onRemove={(setIdx) => workout.removeSet(activeIdx, setIdx)}
/>
<button class="add-set-btn" onclick={() => workout.addSet(activeIdx)}>
{t('add_set', lang)}
</button>
</div>
{:else}
<div class="empty-stage">
<p>{isEn ? 'Add an exercise to get started.' : 'Füge eine Übung hinzu, um zu starten.'}</p>
</div>
{/if}
<div class="workout-actions">
<button class="cancel-btn" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
{t('cancel_workout', lang)}
</button>
<button class="finish-btn" onclick={finishWorkout}>{t('finish', lang)}</button>
</div>
</main>
</div>
</div>
{/if}
{#if showPicker}
<ExercisePicker
onSelect={addExerciseFromPicker}
onClose={() => showPicker = false}
/>
{/if}
<style>
:global(:root) { --primary-contrast: white; }
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) { --primary-contrast: var(--nord0); }
}
:global(:root[data-theme="dark"]) { --primary-contrast: var(--nord0); }
/* Completion screen */
.completion {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.completion-header {
text-align: center;
padding: 1rem 0 0;
}
.completion-header h1 {
margin: 0;
font-size: 1.5rem;
}
.completion-name {
margin: 0.25rem 0 0;
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.offline-banner {
margin: 0.5rem 0 0;
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
color: var(--nord0);
background: var(--nord13);
border-radius: 0.4rem;
}
.pr-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--nord13);
margin-top: 0.4rem;
}
.pr-badge-count {
font-size: 1.5rem;
font-weight: 800;
}
.completion-stats {
display: flex;
justify-content: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.comp-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
color: var(--color-text-secondary);
}
.comp-stat-value {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-text-primary);
}
.comp-stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.comp-stat.kcal {
color: var(--nord12);
}
.prs-section {
background: color-mix(in srgb, var(--nord13) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--nord13) 30%, transparent);
border-radius: 12px;
padding: 1rem;
}
.prs-section h2 {
margin: 0 0 0.6rem;
font-size: 1rem;
color: var(--nord13);
display: flex;
align-items: center;
gap: 0.4rem;
}
.pr-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.pr-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
}
.pr-exercise {
font-weight: 600;
}
.pr-detail {
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.pr-detail strong {
color: var(--nord13);
}
.exercise-summaries h2 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.ex-summary {
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
}
.ex-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.ex-summary-name {
font-weight: 600;
font-size: 0.9rem;
}
.ex-summary-sets {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.ex-summary-stats {
display: flex;
gap: 1rem;
margin-top: 0.3rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.template-update-section {
background: var(--color-surface);
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
}
.template-update-section h2 {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.95rem;
margin: 0 0 0.3rem;
}
.template-update-desc {
font-size: 0.8rem;
color: var(--color-text-secondary);
margin: 0 0 0.75rem;
}
.template-diff-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 0.75rem;
}
.diff-name {
font-weight: 600;
font-size: 0.85rem;
}
.diff-sets {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: 0.25rem;
}
.diff-set-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
}
.diff-old {
color: var(--color-text-secondary);
text-decoration: line-through;
}
.diff-arrow {
color: var(--color-text-secondary);
}
.diff-new {
color: var(--color-primary);
font-weight: 600;
}
.update-template-btn {
width: 100%;
padding: 0.6rem;
background: transparent;
border: 1.5px solid var(--color-primary);
border-radius: 10px;
color: var(--color-primary);
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
}
.update-template-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.template-updated {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
color: var(--nord14);
font-weight: 600;
font-size: 0.9rem;
padding: 0.4rem;
}
.done-btn {
width: 100%;
padding: 0.85rem;
background: var(--color-primary);
color: var(--primary-contrast);
border: none;
border-radius: 10px;
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
letter-spacing: 0.03em;
}
/* Active workout */
.active-workout {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Active workout: sidebar rail + focus stage */
.workout-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.workout-stage {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
@media (min-width: 900px) {
.workout-grid {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.workout-grid :global(.workout-rail) {
position: sticky;
top: calc(8.5rem + env(safe-area-inset-top, 0px));
}
}
@media (min-width: 1180px) {
.workout-grid {
grid-template-columns: 360px minmax(0, 1fr);
gap: 2rem;
}
}
.empty-stage {
padding: 2rem 1rem;
text-align: center;
background: var(--color-surface);
border: 1px dashed var(--color-border);
border-radius: var(--radius-card);
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.exercise-block {
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
padding: 1rem;
}
.exercise-block.focused {
background: transparent;
box-shadow: none;
padding: 0.25rem 0 0;
}
.add-set-btn {
display: block;
width: 100%;
margin-top: 0.5rem;
padding: 0.4rem;
background: transparent;
border: 1px dashed var(--color-border);
border-radius: 8px;
color: var(--color-text-secondary);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.add-set-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.workout-actions {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 0 0;
}
/* Cancel: ghost style (less prominent) — matches body-parts .ghost pattern */
.cancel-btn {
background: transparent;
border: none;
color: var(--color-text-tertiary);
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.03em;
padding: 0.6rem 0.9rem;
border-radius: var(--radius-md, 0.5rem);
cursor: pointer;
transition: color 150ms, background 150ms;
flex-shrink: 0;
}
.cancel-btn:hover {
color: var(--nord11);
background: color-mix(in srgb, var(--nord11), transparent 92%);
}
/* Finish: primary, dominant */
.finish-btn {
flex: 1;
padding: 0.75rem 1.25rem;
background: var(--color-primary);
color: var(--primary-contrast);
border: none;
border-radius: 10px;
font-weight: 700;
font-size: 0.9rem;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 140ms, transform 120ms, box-shadow 140ms;
}
.finish-btn:hover {
background: var(--color-primary-hover, var(--color-primary));
box-shadow: var(--shadow-md);
}
.finish-btn:active {
transform: scale(0.98);
}
/* GPS section */
.gps-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
padding: 0.75rem;
}
.gps-toggle-row {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--color-text-primary);
cursor: pointer;
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.9rem;
}
.gps-toggle-track {
width: 44px;
height: 24px;
background: var(--nord3);
border-radius: 24px;
position: relative;
transition: background 0.3s ease;
flex-shrink: 0;
}
.gps-toggle-track::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
top: 2px;
left: 2px;
background: white;
transition: transform 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.gps-toggle-track.checked {
background: var(--nord14);
}
.gps-toggle-track.checked::before {
transform: translateX(20px);
}
.gps-bar {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.gps-bar.active {
color: var(--nord14);
}
.gps-distance {
font-weight: 700;
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
}
.gps-pace {
font-variant-numeric: tabular-nums;
}
.gps-label {
margin-left: auto;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.7;
}
.live-map {
height: 200px;
border-radius: 8px;
overflow: hidden;
}
.gps-initializing {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
padding: 0.25rem 0;
}
.gps-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--color-border);
border-top-color: var(--nord8);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Voice Guidance */
.vg-toggle-row {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--color-text-primary);
cursor: pointer;
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.9rem;
}
.vg-panel {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.5rem 0 0;
border-top: 1px solid var(--color-border);
}
.vg-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.vg-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
display: flex;
justify-content: space-between;
align-items: center;
}
.vg-volume-value {
font-variant-numeric: tabular-nums;
}
.vg-range {
width: 100%;
accent-color: var(--nord10);
}
.vg-trigger-row {
display: flex;
gap: 0.4rem;
}
.vg-number {
width: 70px;
padding: 0.3rem 0.4rem;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 0.9rem;
}
.vg-select {
padding: 0.3rem 0.4rem;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 0.9rem;
}
.vg-metrics {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.vg-metric-chip {
padding: 0.25rem 0.6rem;
border-radius: 20px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s ease;
}
.vg-metric-chip.selected {
background: var(--nord14);
color: var(--nord0);
border-color: var(--nord14);
}
.vg-no-engine {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.vg-install-btn {
padding: 0.4rem 0.8rem;
border-radius: 6px;
border: 1px solid var(--nord14);
background: transparent;
color: var(--nord14);
font-size: 0.85rem;
cursor: pointer;
align-self: flex-start;
}
.vg-active-badge {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--nord14);
opacity: 0.8;
}
.gps-overlay .vg-active-badge {
color: var(--nord7);
}
.gps-overlay .vg-panel {
background: rgba(46, 52, 64, 0.82);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
padding: 0.6rem;
border-top: none;
color: #fff;
}
.gps-overlay .vg-label {
color: rgba(255,255,255,0.6);
}
.gps-overlay .vg-number,
.gps-overlay .vg-select {
background: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.2);
color: #fff;
}
.gps-overlay .vg-metric-chip {
background: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.2);
color: rgba(255,255,255,0.7);
}
.gps-overlay .vg-metric-chip.selected {
background: var(--nord14);
color: var(--nord0);
border-color: var(--nord14);
}
/* GPS Workout Mode — full-bleed map with overlay */
.gps-workout {
position: fixed;
inset: 0;
z-index: 50;
}
.gps-workout-map {
position: absolute;
inset: 0;
z-index: 0;
}
/* Dark gradient at top so status bar text stays readable */
.gps-workout::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: calc(env(safe-area-inset-top, 0px) + 3rem + 24px);
background: linear-gradient(to bottom, rgba(0,0,0,0.45), transparent);
z-index: 1;
pointer-events: none;
}
:global(.gps-workout-map .leaflet-control-container) {
/* push leaflet's own controls above our overlay */
position: relative;
z-index: 5;
}
.gps-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
background: var(--nav-bg, rgba(46, 52, 64, 0.82));
backdrop-filter: blur(16px);
border-top: 1px solid var(--nav-border, rgba(255,255,255,0.08));
box-shadow: 0 -4px 24px var(--nav-shadow, rgba(0,0,0,0.25));
color: var(--nav-text-active, #fff);
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
.gps-overlay {
--nav-bg: rgba(20, 20, 20, 0.78);
--nav-border: rgba(255,255,255,0.06);
}
}
:global(:root[data-theme="dark"]) .gps-overlay {
--nav-bg: rgba(20, 20, 20, 0.78);
--nav-border: rgba(255,255,255,0.06);
}
:global(:root[data-theme="light"]) .gps-overlay {
--nav-bg: rgba(255, 255, 255, 0.82);
--nav-border: rgba(0,0,0,0.08);
--nav-shadow: rgba(0,0,0,0.1);
--nav-text-active: var(--nord0);
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) .gps-overlay {
--nav-bg: rgba(255, 255, 255, 0.82);
--nav-border: rgba(0,0,0,0.08);
--nav-shadow: rgba(0,0,0,0.1);
--nav-text-active: var(--nord0);
}
}
.gps-overlay-prestart {
background: none !important;
backdrop-filter: none !important;
border-top: none !important;
box-shadow: none !important;
}
.gps-overlay > * {
pointer-events: auto;
}
.gps-workout-stats {
display: flex;
justify-content: space-around;
padding: 0.5rem 0;
}
.gps-stat {
display: flex;
flex-direction: column;
align-items: center;
}
.gps-stat-value {
font-size: 1.8rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: inherit;
}
.gps-stat-unit {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.65;
}
.gps-options-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.5rem;
}
.gps-option-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.65rem 0.5rem;
background: rgba(46, 52, 64, 0.82);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
cursor: pointer;
font: inherit;
color: #fff;
transition: border-color 0.15s ease;
}
.gps-option-tile:hover {
border-color: rgba(255,255,255,0.5);
}
.gps-option-icon {
font-size: 1.25rem;
}
.gps-option-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255,255,255,0.6);
}
.gps-option-value {
font-size: 0.85rem;
font-weight: 700;
}
.gps-activity-picker {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem;
}
.gps-activity-choice {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: rgba(46, 52, 64, 0.82);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
color: #fff;
transition: all 0.15s ease;
}
.gps-activity-choice.active {
border-color: var(--nord8);
background: rgba(46, 52, 64, 0.9);
color: var(--nord8);
box-shadow: inset 0 0 0 1px rgba(136,192,208,0.25);
}
.gps-activity-choice:hover:not(.active) {
border-color: rgba(255,255,255,0.4);
}
.gps-activity-icon {
font-size: 1.1rem;
}
.gps-start-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
width: 100%;
padding: 1rem;
background: var(--blue);
color: white;
border: none;
border-radius: 50px;
font-weight: 800;
font-size: 1.2rem;
cursor: pointer;
letter-spacing: 0.04em;
}
.gps-start-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.gps-cancel-link {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
border: none;
color: #fff;
text-shadow: 0 1px 4px rgba(0,0,0,0.6);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
padding: 1rem 0.75rem calc(0.75rem + env(safe-area-inset-bottom, 0px));
margin: 0 -0.75rem calc(-0.75rem - env(safe-area-inset-bottom, 0px));
background: linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.3) 50%, transparent 100%);
}
.gps-cancel-link:hover {
color: var(--nord11);
}
.gps-overlay-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.gps-overlay-pause {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: rgba(128,128,128,0.12);
border: 1px solid var(--nav-border, rgba(255,255,255,0.25));
border-radius: 50%;
color: inherit;
cursor: pointer;
flex-shrink: 0;
}
.gps-overlay-pause:hover {
background: rgba(128,128,128,0.2);
}
.gps-overlay-cancel {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: rgba(191,97,106,0.25);
border: 1px solid var(--nord11);
border-radius: 50%;
color: var(--nord11);
cursor: pointer;
flex-shrink: 0;
}
.gps-overlay-cancel:hover {
background: rgba(191,97,106,0.4);
}
.gps-overlay-finish {
flex: 1;
padding: 0.85rem;
background: var(--nord11);
color: #fff;
border: none;
border-radius: 50px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
}
/* Interval Panel (pre-start selection) */
.interval-panel {
background: rgba(46, 52, 64, 0.82);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
padding: 0.6rem;
color: #fff;
}
.interval-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.interval-card {
display: flex;
align-items: center;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
overflow: hidden;
transition: border-color 0.15s;
}
.interval-card.selected {
border-color: var(--nord8);
background: rgba(136,192,208,0.12);
}
.interval-card-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.5rem 0.6rem;
background: none;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
text-align: left;
}
.interval-card-name {
font-weight: 600;
font-size: 0.85rem;
}
.interval-card-info {
font-size: 0.7rem;
opacity: 0.6;
}
.interval-card-actions {
display: flex;
gap: 0.2rem;
padding-right: 0.4rem;
}
.interval-card-edit, .interval-card-delete {
background: none;
border: none;
color: rgba(255,255,255,0.5);
font: inherit;
font-size: 0.7rem;
cursor: pointer;
padding: 0.3rem;
}
.interval-card-edit:hover, .interval-card-delete:hover {
color: #fff;
}
.interval-empty {
text-align: center;
font-size: 0.8rem;
opacity: 0.5;
margin: 0.5rem 0;
}
.interval-new-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
width: 100%;
padding: 0.5rem;
background: rgba(255,255,255,0.08);
border: 1px dashed rgba(255,255,255,0.2);
border-radius: 8px;
color: var(--nord8);
font: inherit;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.interval-new-btn:hover {
background: rgba(255,255,255,0.12);
}
/* Interval Editor (full-screen overlay on map) */
.interval-editor-overlay {
position: absolute;
inset: 0;
z-index: 100;
background: rgba(46, 52, 64, 0.95);
backdrop-filter: blur(12px);
display: flex;
flex-direction: column;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.interval-editor {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
padding-top: calc(1rem + env(safe-area-inset-top, 0px));
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
color: #fff;
max-width: 500px;
width: 100%;
margin: 0 auto;
}
.interval-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.interval-editor-header h2 {
margin: 0;
font-size: 1.2rem;
}
.interval-editor-close {
background: none;
border: none;
color: rgba(255,255,255,0.6);
cursor: pointer;
padding: 0.3rem;
}
.interval-editor-name {
width: 100%;
padding: 0.6rem 0.75rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 8px;
color: #fff;
font: inherit;
font-size: 0.95rem;
}
.interval-editor-name::placeholder {
color: rgba(255,255,255,0.35);
}
.interval-editor-steps {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.interval-step-card {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
padding: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.interval-step-header {
display: flex;
align-items: center;
gap: 0.4rem;
}
.interval-step-num {
font-weight: 700;
font-size: 0.8rem;
background: rgba(255,255,255,0.12);
border-radius: 50%;
width: 1.4rem;
height: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.interval-step-move {
display: flex;
gap: 0.1rem;
margin-left: auto;
}
.interval-step-move button {
background: none;
border: none;
color: rgba(255,255,255,0.4);
cursor: pointer;
padding: 0.15rem;
}
.interval-step-move button:hover:not(:disabled) {
color: #fff;
}
.interval-step-move button:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.interval-step-remove {
background: none;
border: none;
color: rgba(255,255,255,0.3);
cursor: pointer;
padding: 0.15rem;
}
.interval-step-remove:hover:not(:disabled) {
color: var(--nord11);
}
.interval-step-remove:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.interval-step-labels {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.interval-label-chip {
padding: 0.25rem 0.55rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 20px;
color: rgba(255,255,255,0.7);
font: inherit;
font-size: 0.72rem;
cursor: pointer;
transition: all 0.12s;
}
.interval-label-chip.selected {
background: var(--nord8);
border-color: var(--nord8);
color: var(--nord0);
font-weight: 600;
}
.interval-step-custom-input {
width: 100%;
padding: 0.4rem 0.6rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
color: #fff;
font: inherit;
font-size: 0.85rem;
}
.interval-step-duration {
display: flex;
align-items: center;
gap: 0.5rem;
}
.interval-step-value {
width: 5rem;
padding: 0.4rem 0.5rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
color: #fff;
font: inherit;
font-size: 0.9rem;
text-align: center;
}
.interval-step-type-toggle {
display: flex;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
overflow: hidden;
}
.interval-type-btn {
padding: 0.4rem 0.65rem;
background: rgba(255,255,255,0.06);
border: none;
color: rgba(255,255,255,0.5);
font: inherit;
font-size: 0.78rem;
cursor: pointer;
transition: all 0.12s;
}
.interval-type-btn.active {
background: var(--nord8);
color: var(--nord0);
font-weight: 600;
}
.interval-add-row {
display: flex;
gap: 0.5rem;
}
.interval-add-row .interval-add-step-btn {
flex: 1;
}
.interval-add-step-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
width: 100%;
padding: 0.55rem;
background: rgba(255,255,255,0.06);
border: 1px dashed rgba(255,255,255,0.2);
border-radius: 8px;
color: var(--nord8);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.interval-add-step-btn--inner {
padding: 0.4rem;
font-size: 0.78rem;
}
.interval-group-card {
background: rgba(136, 192, 208, 0.08);
border: 1px solid rgba(136, 192, 208, 0.25);
border-radius: 12px;
padding: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.interval-group-header {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--nord8);
font-size: 0.8rem;
}
.interval-group-label {
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 0.7rem;
}
.interval-group-repeat {
width: 3rem;
padding: 0.25rem 0.4rem;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(136, 192, 208, 0.4);
border-radius: 6px;
color: #fff;
font: inherit;
font-size: 0.9rem;
font-weight: 700;
text-align: center;
}
.interval-group-times {
color: rgba(255,255,255,0.55);
font-size: 0.78rem;
}
.interval-group-actions {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
}
.interval-group-actions button {
background: none;
border: none;
color: rgba(255,255,255,0.4);
cursor: pointer;
padding: 0.2rem;
}
.interval-group-actions button:hover:not(:disabled) {
color: #fff;
}
.interval-group-actions button:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.interval-group-ungroup {
font: inherit;
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-weight: 600;
padding: 0.2rem 0.45rem !important;
border-radius: 4px;
}
.interval-group-ungroup:hover {
background: rgba(255,255,255,0.08);
}
.interval-group-steps {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-left: 0.6rem;
border-left: 2px solid rgba(136, 192, 208, 0.3);
}
.interval-save-btn {
width: 100%;
padding: 0.8rem;
background: var(--nord14);
border: none;
border-radius: 50px;
color: var(--nord0);
font: inherit;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
}
.interval-save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Active Interval Overlay */
.interval-active-overlay {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.4rem 0.5rem;
background: rgba(136,192,208,0.12);
border: 1px solid rgba(136,192,208,0.25);
border-radius: 8px;
}
.interval-current-label {
font-size: 1.1rem;
font-weight: 800;
color: var(--nord8);
text-align: center;
}
.interval-progress-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.interval-step-count {
font-size: 0.72rem;
font-weight: 600;
opacity: 0.7;
white-space: nowrap;
}
.interval-progress-bar {
flex: 1;
height: 4px;
background: rgba(255,255,255,0.12);
border-radius: 2px;
overflow: hidden;
}
.interval-progress-fill {
height: 100%;
background: var(--nord8);
border-radius: 2px;
transition: width 0.5s ease;
}
.interval-complete-badge {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.3rem;
color: var(--nord14);
font-size: 0.85rem;
font-weight: 600;
}
</style>