feat: redesign GPS workout UI with Runkeeper-style map overlay
All checks were successful
CI / update (push) Successful in 2m32s

- Full-screen fixed map with controls overlaid at the bottom
- Activity type selector (running/walking/cycling/hiking) with proper
  exercise mapping for history display
- GPS starts immediately on entering workout screen for faster lock
- GPS track attached to cardio exercise (like GPX upload) so history
  shows distance, pace, splits, and map
- Add activityType field to workout state, session model, and sync
- Cancel button appears when workout is paused
- GPS Workout button only shown in Tauri app
This commit is contained in:
2026-03-25 19:54:18 +01:00
parent d75e2354f6
commit 8b63812734
9 changed files with 705 additions and 46 deletions

View File

@@ -221,6 +221,22 @@ export function createGpsTracker() {
bridge?.resumeTracking(); bridge?.resumeTracking();
} }
/** Request location permissions without starting the tracking service.
* Returns true if permissions were granted. */
async function ensurePermissions(): Promise<boolean> {
if (!checkTauri()) return false;
try {
const geo = await import('@tauri-apps/plugin-geolocation');
let perms = await geo.checkPermissions();
if (perms.location !== 'granted') {
perms = await geo.requestPermissions(['location']);
}
return perms.location === 'granted';
} catch {
return false;
}
}
return { return {
get track() { return track; }, get track() { return track; },
get isTracking() { return isTracking; }, get isTracking() { return isTracking; },
@@ -237,7 +253,8 @@ export function createGpsTracker() {
hasTtsEngine, hasTtsEngine,
installTtsEngine, installTtsEngine,
pauseTracking, pauseTracking,
resumeTracking resumeTracking,
ensurePermissions
}; };
} }

View File

@@ -33,9 +33,14 @@ export interface TemplateData {
const STORAGE_KEY = 'fitness-active-workout'; const STORAGE_KEY = 'fitness-active-workout';
export type WorkoutMode = 'manual' | 'gps';
export type GpsActivityType = 'running' | 'walking' | 'cycling' | 'hiking';
export interface StoredState { export interface StoredState {
active: boolean; active: boolean;
paused: boolean; paused: boolean;
mode: WorkoutMode;
activityType: GpsActivityType | null;
name: string; name: string;
templateId: string | null; templateId: string | null;
exercises: WorkoutExercise[]; exercises: WorkoutExercise[];
@@ -49,6 +54,8 @@ export interface StoredState {
export interface RemoteState { export interface RemoteState {
name: string; name: string;
mode: WorkoutMode;
activityType: GpsActivityType | null;
templateId: string | null; templateId: string | null;
exercises: WorkoutExercise[]; exercises: WorkoutExercise[];
paused: boolean; paused: boolean;
@@ -89,6 +96,8 @@ function clearStorage() {
export function createWorkout() { export function createWorkout() {
let active = $state(false); let active = $state(false);
let paused = $state(false); let paused = $state(false);
let mode = $state<WorkoutMode>('manual');
let activityType = $state<GpsActivityType | null>(null);
let name = $state(''); let name = $state('');
let templateId: string | null = $state(null); let templateId: string | null = $state(null);
let exercises = $state<WorkoutExercise[]>([]); let exercises = $state<WorkoutExercise[]>([]);
@@ -115,6 +124,8 @@ export function createWorkout() {
saveToStorage({ saveToStorage({
active, active,
paused, paused,
mode,
activityType,
name, name,
templateId, templateId,
exercises: JSON.parse(JSON.stringify(exercises)), exercises: JSON.parse(JSON.stringify(exercises)),
@@ -182,6 +193,8 @@ export function createWorkout() {
active = true; active = true;
paused = stored.paused; paused = stored.paused;
mode = stored.mode ?? 'manual';
activityType = stored.activityType ?? null;
name = stored.name; name = stored.name;
templateId = stored.templateId; templateId = stored.templateId;
exercises = stored.exercises; exercises = stored.exercises;
@@ -220,6 +233,7 @@ export function createWorkout() {
function startFromTemplate(template: TemplateData) { function startFromTemplate(template: TemplateData) {
name = template.name; name = template.name;
templateId = template._id; templateId = template._id;
mode = 'manual';
exercises = template.exercises.map((e) => ({ exercises = template.exercises.map((e) => ({
exerciseId: e.exerciseId, exerciseId: e.exerciseId,
sets: e.sets.length > 0 sets: e.sets.length > 0
@@ -246,6 +260,7 @@ export function createWorkout() {
function startEmpty() { function startEmpty() {
name = 'Quick Workout'; name = 'Quick Workout';
templateId = null; templateId = null;
mode = 'manual';
exercises = []; exercises = [];
startTime = new Date(); startTime = new Date();
_pausedElapsed = 0; _pausedElapsed = 0;
@@ -256,6 +271,26 @@ export function createWorkout() {
_persist(); _persist();
} }
function startGpsWorkout(activity: GpsActivityType = 'running') {
const labels: Record<GpsActivityType, string> = {
running: 'Running',
walking: 'Walking',
cycling: 'Cycling',
hiking: 'Hiking'
};
name = labels[activity];
templateId = null;
mode = 'gps';
activityType = activity;
exercises = [];
startTime = null;
_pausedElapsed = 0;
_elapsed = 0;
paused = true;
active = true;
_persist();
}
function pauseTimer() { function pauseTimer() {
if (!active || paused) return; if (!active || paused) return;
_computeElapsed(); _computeElapsed();
@@ -374,6 +409,8 @@ export function createWorkout() {
templateId, templateId,
templateName: templateId ? name : undefined, templateName: templateId ? name : undefined,
name, name,
mode,
activityType,
exercises: exercises exercises: exercises
.filter((e) => e.sets.some((s) => s.completed)) .filter((e) => e.sets.some((s) => s.completed))
.map((e) => ({ .map((e) => ({
@@ -409,6 +446,8 @@ export function createWorkout() {
function _reset() { function _reset() {
active = false; active = false;
paused = false; paused = false;
mode = 'manual';
activityType = null;
name = ''; name = '';
templateId = null; templateId = null;
exercises = []; exercises = [];
@@ -427,6 +466,8 @@ export function createWorkout() {
/** Apply state from another device (merge strategy: incoming wins) */ /** Apply state from another device (merge strategy: incoming wins) */
function applyRemoteState(remote: RemoteState) { function applyRemoteState(remote: RemoteState) {
name = remote.name; name = remote.name;
mode = remote.mode ?? 'manual';
activityType = remote.activityType ?? null;
templateId = remote.templateId; templateId = remote.templateId;
exercises = remote.exercises; exercises = remote.exercises;
@@ -470,6 +511,8 @@ export function createWorkout() {
saveToStorage({ saveToStorage({
active: true, active: true,
paused, paused,
mode,
activityType,
name, name,
templateId, templateId,
exercises: JSON.parse(JSON.stringify(exercises)), exercises: JSON.parse(JSON.stringify(exercises)),
@@ -496,6 +539,8 @@ export function createWorkout() {
return { return {
get active() { return active; }, get active() { return active; },
get paused() { return paused; }, get paused() { return paused; },
get mode() { return mode; },
get activityType() { return activityType; },
get name() { return name; }, get name() { return name; },
set name(v: string) { name = v; _persist(); }, set name(v: string) { name = v; _persist(); },
get templateId() { return templateId; }, get templateId() { return templateId; },
@@ -511,6 +556,7 @@ export function createWorkout() {
restore, restore,
startFromTemplate, startFromTemplate,
startEmpty, startEmpty,
startGpsWorkout,
pauseTimer, pauseTimer,
resumeTimer, resumeTimer,
addExercise, addExercise,

View File

@@ -7,13 +7,15 @@
*/ */
import { getWorkout } from '$lib/js/workout.svelte'; import { getWorkout } from '$lib/js/workout.svelte';
import type { WorkoutExercise } from '$lib/js/workout.svelte'; import type { WorkoutExercise, WorkoutMode, GpsActivityType } from '$lib/js/workout.svelte';
type SyncStatus = 'idle' | 'synced' | 'syncing' | 'offline' | 'conflict'; type SyncStatus = 'idle' | 'synced' | 'syncing' | 'offline' | 'conflict';
interface ServerWorkout { interface ServerWorkout {
version: number; version: number;
name: string; name: string;
mode: WorkoutMode;
activityType: GpsActivityType | null;
templateId: string | null; templateId: string | null;
exercises: WorkoutExercise[]; exercises: WorkoutExercise[];
paused: boolean; paused: boolean;
@@ -42,6 +44,8 @@ export function createWorkoutSync() {
return { return {
version: serverVersion, version: serverVersion,
name: workout.name, name: workout.name,
mode: workout.mode,
activityType: workout.activityType,
templateId: workout.templateId, templateId: workout.templateId,
exercises: JSON.parse(JSON.stringify(workout.exercises)), exercises: JSON.parse(JSON.stringify(workout.exercises)),
paused: workout.paused, paused: workout.paused,
@@ -107,6 +111,8 @@ export function createWorkoutSync() {
// but we keep the higher value for completed sets // but we keep the higher value for completed sets
workout.applyRemoteState({ workout.applyRemoteState({
name: doc.name, name: doc.name,
mode: doc.mode ?? 'manual',
activityType: doc.activityType ?? null,
templateId: doc.templateId, templateId: doc.templateId,
exercises: doc.exercises, exercises: doc.exercises,
paused: doc.paused, paused: doc.paused,
@@ -225,6 +231,8 @@ export function createWorkoutSync() {
serverVersion = serverDoc.version; serverVersion = serverDoc.version;
workout.restoreFromRemote({ workout.restoreFromRemote({
name: serverDoc.name, name: serverDoc.name,
mode: serverDoc.mode ?? 'manual',
activityType: serverDoc.activityType ?? null,
templateId: serverDoc.templateId, templateId: serverDoc.templateId,
exercises: serverDoc.exercises, exercises: serverDoc.exercises,
paused: serverDoc.paused, paused: serverDoc.paused,

View File

@@ -18,6 +18,8 @@ export interface IActiveWorkout {
userId: string; userId: string;
version: number; version: number;
name: string; name: string;
mode: 'manual' | 'gps';
activityType: 'running' | 'walking' | 'cycling' | 'hiking' | null;
templateId: string | null; templateId: string | null;
exercises: IActiveWorkoutExercise[]; exercises: IActiveWorkoutExercise[];
paused: boolean; paused: boolean;
@@ -62,6 +64,16 @@ const ActiveWorkoutSchema = new mongoose.Schema(
trim: true, trim: true,
maxlength: 100 maxlength: 100
}, },
mode: {
type: String,
enum: ['manual', 'gps'],
default: 'manual'
},
activityType: {
type: String,
enum: ['running', 'walking', 'cycling', 'hiking'],
default: null
},
templateId: { templateId: {
type: String, type: String,
default: null default: null

View File

@@ -41,12 +41,16 @@ export interface IWorkoutSession {
templateId?: string; // Reference to WorkoutTemplate if based on template templateId?: string; // Reference to WorkoutTemplate if based on template
templateName?: string; // Snapshot of template name for history templateName?: string; // Snapshot of template name for history
name: string; name: string;
mode?: 'manual' | 'gps';
activityType?: 'running' | 'walking' | 'cycling' | 'hiking';
exercises: ICompletedExercise[]; exercises: ICompletedExercise[];
startTime: Date; startTime: Date;
endTime?: Date; endTime?: Date;
duration?: number; // Duration in minutes duration?: number; // Duration in minutes
totalVolume?: number; // Total weight × reps across all exercises totalVolume?: number; // Total weight × reps across all exercises
totalDistance?: number; // Total distance across all cardio exercises totalDistance?: number; // Total distance across all cardio exercises
gpsTrack?: IGpsPoint[]; // Top-level GPS track for GPS-only workouts
gpsPreview?: number[][]; // Downsampled [[lat,lng], ...] for card preview
prs?: IPr[]; prs?: IPr[];
notes?: string; notes?: string;
createdBy: string; // username/nickname of the person who performed the workout createdBy: string; // username/nickname of the person who performed the workout
@@ -155,15 +159,18 @@ const WorkoutSessionSchema = new mongoose.Schema(
trim: true, trim: true,
maxlength: 100 maxlength: 100
}, },
mode: {
type: String,
enum: ['manual', 'gps'],
default: 'manual'
},
activityType: {
type: String,
enum: ['running', 'walking', 'cycling', 'hiking']
},
exercises: { exercises: {
type: [CompletedExerciseSchema], type: [CompletedExerciseSchema],
required: true, default: []
validate: {
validator: function(exercises: ICompletedExercise[]) {
return exercises.length > 0;
},
message: 'A workout session must have at least one exercise'
}
}, },
startTime: { startTime: {
type: Date, type: Date,
@@ -185,6 +192,14 @@ const WorkoutSessionSchema = new mongoose.Schema(
type: Number, type: Number,
min: 0 min: 0
}, },
gpsTrack: {
type: [GpsPointSchema],
default: undefined
},
gpsPreview: {
type: [[Number]],
default: undefined
},
prs: [{ prs: [{
exerciseId: { type: String, required: true }, exerciseId: { type: String, required: true },
type: { type: String, required: true }, type: { type: String, required: true },

View File

@@ -6,6 +6,7 @@ import type { IPr } from '$models/WorkoutSession';
import { WorkoutTemplate } from '$models/WorkoutTemplate'; import { WorkoutTemplate } from '$models/WorkoutTemplate';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises'; import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { detectCardioPrs } from '$lib/data/cardioPrRanges'; import { detectCardioPrs } from '$lib/data/cardioPrRanges';
import { simplifyTrack } from '$lib/server/simplifyTrack';
function estimatedOneRepMax(weight: number, reps: number): number { function estimatedOneRepMax(weight: number, reps: number): number {
if (reps <= 0 || weight <= 0) return 0; if (reps <= 0 || weight <= 0) return 0;
@@ -27,7 +28,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const offset = parseInt(url.searchParams.get('offset') || '0'); const offset = parseInt(url.searchParams.get('offset') || '0');
const sessions = await WorkoutSession.find({ createdBy: session.user.nickname }) const sessions = await WorkoutSession.find({ createdBy: session.user.nickname })
.select('-exercises.gpsTrack') .select('-exercises.gpsTrack -gpsTrack')
.sort({ startTime: -1 }) .sort({ startTime: -1 })
.limit(limit) .limit(limit)
.skip(offset); .skip(offset);
@@ -52,10 +53,10 @@ export const POST: RequestHandler = async ({ request, locals }) => {
await dbConnect(); await dbConnect();
const data = await request.json(); const data = await request.json();
const { templateId, name, exercises, startTime, endTime, notes } = data; const { templateId, name, mode, activityType, exercises, startTime, endTime, notes, gpsTrack, totalDistance: gpsDistance } = data;
if (!name || !exercises || !Array.isArray(exercises) || exercises.length === 0) { if (!name || (!exercises?.length && !gpsTrack?.length)) {
return json({ error: 'Name and at least one exercise are required' }, { status: 400 }); return json({ error: 'Name and at least one exercise or GPS track required' }, { status: 400 });
} }
let templateName; let templateName;
@@ -68,8 +69,8 @@ export const POST: RequestHandler = async ({ request, locals }) => {
// Compute totalVolume and totalDistance // Compute totalVolume and totalDistance
let totalVolume = 0; let totalVolume = 0;
let totalDistance = 0; let totalDistance = gpsDistance ?? 0;
for (const ex of exercises) { for (const ex of (exercises ?? [])) {
const exercise = getExerciseById(ex.exerciseId); const exercise = getExerciseById(ex.exerciseId);
const metrics = getExerciseMetrics(exercise); const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance'); const isCardio = metrics.includes('distance');
@@ -86,7 +87,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
// Detect PRs by comparing against previous best for each exercise // Detect PRs by comparing against previous best for each exercise
const prs: IPr[] = []; const prs: IPr[] = [];
for (const ex of exercises) { for (const ex of (exercises ?? [])) {
const exercise = getExerciseById(ex.exerciseId); const exercise = getExerciseById(ex.exerciseId);
const metrics = getExerciseMetrics(exercise); const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance'); const isCardio = metrics.includes('distance');
@@ -143,16 +144,31 @@ export const POST: RequestHandler = async ({ request, locals }) => {
} }
} }
// Generate GPS preview for top-level GPS track
const gpsPreview = gpsTrack?.length >= 2 ? simplifyTrack(gpsTrack) : undefined;
// Generate gpsPreview for exercise-level GPS tracks
const processedExercises = (exercises ?? []).map((ex: any) => {
if (ex.gpsTrack?.length >= 2 && !ex.gpsPreview) {
return { ...ex, gpsPreview: simplifyTrack(ex.gpsTrack) };
}
return ex;
});
const workoutSession = new WorkoutSession({ const workoutSession = new WorkoutSession({
templateId, templateId,
templateName, templateName,
name, name,
exercises, mode: mode ?? (gpsTrack?.length ? 'gps' : 'manual'),
activityType: activityType ?? undefined,
exercises: processedExercises,
startTime: startTime ? new Date(startTime) : new Date(), startTime: startTime ? new Date(startTime) : new Date(),
endTime: endTime ? new Date(endTime) : undefined, endTime: endTime ? new Date(endTime) : undefined,
duration: endTime && startTime ? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / (1000 * 60)) : undefined, duration: endTime && startTime ? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / (1000 * 60)) : undefined,
totalVolume: totalVolume > 0 ? totalVolume : undefined, totalVolume: totalVolume > 0 ? totalVolume : undefined,
totalDistance: totalDistance > 0 ? totalDistance : undefined, totalDistance: totalDistance > 0 ? totalDistance : undefined,
gpsTrack: gpsTrack?.length ? gpsTrack : undefined,
gpsPreview,
prs: prs.length > 0 ? prs : undefined, prs: prs.length > 0 ? prs : undefined,
notes, notes,
createdBy: session.user.nickname createdBy: session.user.nickname

View File

@@ -34,7 +34,7 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
try { try {
await dbConnect(); await dbConnect();
const data = await request.json(); const data = await request.json();
const { name, templateId, exercises, paused, elapsed, savedAt, expectedVersion, restStartedAt, restTotal, restExerciseIdx, restSetIdx } = data; const { name, mode, activityType, templateId, exercises, paused, elapsed, savedAt, expectedVersion, restStartedAt, restTotal, restExerciseIdx, restSetIdx } = data;
if (!name) { if (!name) {
return json({ error: 'Name is required' }, { status: 400 }); return json({ error: 'Name is required' }, { status: 400 });
@@ -58,6 +58,8 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
{ {
$set: { $set: {
name, name,
mode: mode ?? 'manual',
activityType: activityType ?? null,
templateId: templateId ?? null, templateId: templateId ?? null,
exercises: exercises ?? [], exercises: exercises ?? [],
paused: paused ?? false, paused: paused ?? false,

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; 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, MapPin, Dumbbell } 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'; import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
@@ -48,8 +48,10 @@
/** @type {any} */ /** @type {any} */
let nextTemplate = $derived(nextTemplateId ? templates.find((t) => t._id === nextTemplateId) : null); let nextTemplate = $derived(nextTemplateId ? templates.find((t) => t._id === nextTemplateId) : null);
let hasSchedule = $derived(scheduleOrder.length > 0); let hasSchedule = $derived(scheduleOrder.length > 0);
let isApp = $state(false);
onMount(() => { onMount(() => {
isApp = '__TAURI__' in window;
workout.restore(); workout.restore();
// If there's an active workout, redirect to the active page // If there's an active workout, redirect to the active page
@@ -93,6 +95,12 @@
goto(`/fitness/${sl.workout}/${sl.active}`); goto(`/fitness/${sl.workout}/${sl.active}`);
} }
async function startGps() {
workout.startGpsWorkout('running');
await sync.onWorkoutStart();
goto(`/fitness/${sl.workout}/${sl.active}`);
}
async function startNextScheduled() { async function startNextScheduled() {
if (!nextTemplate) return; if (!nextTemplate) return;
await startFromTemplate(nextTemplate); await startFromTemplate(nextTemplate);
@@ -333,9 +341,18 @@
{/if} {/if}
<section class="quick-start"> <section class="quick-start">
<button class="start-empty-btn" onclick={startEmpty}> <div class="quick-start-row">
{t('start_empty_workout', lang)} {#if isApp}
<button class="start-choice-btn" onclick={startGps}>
<MapPin size={18} />
<span>GPS Workout</span>
</button> </button>
{/if}
<button class="start-choice-btn" onclick={startEmpty}>
{#if isApp}<Dumbbell size={18} />{/if}
<span>{t('start_empty_workout', lang)}</span>
</button>
</div>
</section> </section>
<section class="templates-section"> <section class="templates-section">
@@ -638,19 +655,27 @@
.quick-start { .quick-start {
text-align: center; text-align: center;
} }
.start-empty-btn { .quick-start-row {
width: 100%; display: flex;
padding: 0.9rem; gap: 0.5rem;
}
.start-choice-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 1rem 0.5rem;
background: var(--color-primary); background: var(--color-primary);
color: var(--primary-contrast); color: var(--primary-contrast);
border: none; border: none;
border-radius: 10px; border-radius: 10px;
font-weight: 700; font-weight: 700;
font-size: 0.9rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
.start-empty-btn:hover { .start-choice-btn:hover {
opacity: 0.9; opacity: 0.9;
} }
.templates-header { .templates-header {

View File

@@ -1,7 +1,7 @@
<script> <script>
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2 } from 'lucide-svelte'; import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n'; import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname)); const lang = $derived(detectFitnessLang($page.url.pathname));
@@ -49,6 +49,30 @@
let vgLanguage = $state('en'); let vgLanguage = $state('en');
let vgShowPanel = $state(false); let vgShowPanel = $state(false);
// 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);
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.name = labels[selectedActivity] ?? 'GPS Workout';
showActivityPicker = false;
}
const availableMetrics = [ const availableMetrics = [
{ id: 'totalTime', label: 'Total Time' }, { id: 'totalTime', label: 'Total Time' },
{ id: 'totalDistance', label: 'Total Distance' }, { id: 'totalDistance', label: 'Total Distance' },
@@ -125,6 +149,22 @@
liveMarker.setLatLng(pts[pts.length - 1]); liveMarker.setLatLng(pts[pts.length - 1]);
liveMap.setView(pts[pts.length - 1], 16); liveMap.setView(pts[pts.length - 1], 16);
prevTrackLen = gps.track.length; prevTrackLen = gps.track.length;
} else {
// No track yet — show fallback until GPS kicks in
liveMap.setView([51.5, 10], 16);
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
if (liveMap) {
const ll = [pos.coords.latitude, pos.coords.longitude];
liveMap.setView(ll, 16);
liveMarker.setLatLng(ll);
}
},
() => {},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
} }
} }
@@ -167,11 +207,14 @@
$effect(() => { $effect(() => {
const len = gps.track.length; const len = gps.track.length;
if (len > prevTrackLen && liveMap && gps.latestPoint) { if (len > prevTrackLen && liveMap && gps.latestPoint) {
// Add all new points since last update (native polling delivers batches) if (gpsStarted) {
// Only draw the trail once the workout has actually started
for (let i = prevTrackLen; i < len; i++) { for (let i = prevTrackLen; i < len; i++) {
const p = gps.track[i]; const p = gps.track[i];
livePolyline.addLatLng([p.lat, p.lng]); livePolyline.addLatLng([p.lat, p.lng]);
} }
}
// Always update the position marker
const pt = [gps.latestPoint.lat, gps.latestPoint.lng]; const pt = [gps.latestPoint.lat, gps.latestPoint.lng];
liveMarker.setLatLng(pt); liveMarker.setLatLng(pt);
const zoom = liveMap.getZoom() || 16; const zoom = liveMap.getZoom() || 16;
@@ -188,9 +231,19 @@
}); });
} }
let _prestartGps = false;
onMount(() => { onMount(() => {
if (!workout.active && !completionData) { if (!workout.active && !completionData) {
goto(`/fitness/${sl.workout}`); goto(`/fitness/${sl.workout}`);
return;
}
// 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();
} }
}); });
@@ -223,19 +276,71 @@
} }
} }
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();
}
const started = await gps.start(getVoiceGuidanceConfig());
if (started) {
gpsStarted = true;
useGps = true;
workout.resumeTimer();
}
} 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() { async function finishWorkout() {
// Stop GPS tracking and collect track data // Stop GPS tracking and collect track data
const gpsTrack = gps.isTracking ? await gps.stop() : []; const gpsTrack = gps.isTracking ? await gps.stop() : [];
const wasGpsMode = workout.mode === 'gps';
const actType = workout.activityType;
const sessionData = workout.finish(); const sessionData = workout.finish();
if (sessionData.exercises.length === 0) {
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 = trackDistance(gpsTrack);
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 = [{
exerciseId,
name: exerciseName,
sets: [{
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(); gps.reset();
await sync.onWorkoutEnd(); await sync.onWorkoutEnd();
await goto(`/fitness/${sl.workout}`); await goto(`/fitness/${sl.workout}`);
return; return;
} } else {
// Manual workout: attach GPS to cardio exercises
// Only save GPS points recorded while the workout timer was running
const workoutStart = new Date(sessionData.startTime).getTime(); const workoutStart = new Date(sessionData.startTime).getTime();
const filteredTrack = gpsTrack.filter((/** @type {any} */ p) => p.timestamp >= workoutStart); const filteredTrack = gpsTrack.filter((/** @type {any} */ p) => p.timestamp >= workoutStart);
const filteredDistance = trackDistance(filteredTrack); const filteredDistance = trackDistance(filteredTrack);
@@ -249,6 +354,7 @@
} }
} }
} }
}
gps.reset(); gps.reset();
try { try {
@@ -286,7 +392,7 @@
const durationMin = Math.round((endTime.getTime() - startTime.getTime()) / 60000); const durationMin = Math.round((endTime.getTime() - startTime.getTime()) / 60000);
let totalTonnage = 0; let totalTonnage = 0;
let totalDistance = 0; let totalDistance = local.totalDistance ?? 0;
/** @type {any[]} */ /** @type {any[]} */
const prs = []; const prs = [];
@@ -750,6 +856,155 @@
</button> </button>
</div> </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">
{#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}
<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; }} 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; }} 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>
</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}
<label class="vg-row">
<input type="checkbox" bind:checked={vgEnabled} />
<span>Enable voice announcements</span>
</label>
{#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">
<span class="vg-label">Language</span>
<select class="vg-select" bind:value={vgLanguage}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</div>
{/if}
{/if}
</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>
</div>
{:else if workout.active} {:else if workout.active}
<div class="active-workout"> <div class="active-workout">
<input <input
@@ -1543,4 +1798,267 @@
color: var(--nord14); color: var(--nord14);
opacity: 0.8; opacity: 0.8;
} }
.gps-overlay .vg-active-badge {
color: var(--nord7);
}
.gps-overlay .vg-panel {
background: rgba(255,255,255,0.1);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.15);
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-row {
color: #fff;
}
.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: linear-gradient(to top, rgba(0,0,0,0.7) 60%, transparent);
color: #fff;
pointer-events: none;
}
.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: #fff;
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
}
.gps-stat-unit {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255,255,255,0.75);
}
.gps-options-grid {
display: grid;
grid-template-columns: 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(255,255,255,0.12);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.2);
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(255,255,255,0.1);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.2);
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(136,192,208,0.25);
color: var(--nord8);
}
.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(--color-primary);
color: var(--primary-contrast);
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;
background: none;
border: none;
color: rgba(255,255,255,0.5);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
padding: 0.25rem;
}
.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(255,255,255,0.15);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.25);
border-radius: 50%;
color: #fff;
cursor: pointer;
flex-shrink: 0;
}
.gps-overlay-pause:hover {
background: rgba(255,255,255,0.25);
}
.gps-overlay-cancel {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: rgba(191,97,106,0.25);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
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;
}
</style> </style>