From 8b63812734fa20078fdff9c76d5263bfae75fa17 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Wed, 25 Mar 2026 19:54:18 +0100 Subject: [PATCH] feat: redesign GPS workout UI with Runkeeper-style map overlay - 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 --- src/lib/js/gps.svelte.ts | 19 +- src/lib/js/workout.svelte.ts | 46 ++ src/lib/js/workoutSync.svelte.ts | 10 +- src/models/ActiveWorkout.ts | 12 + src/models/WorkoutSession.ts | 29 +- src/routes/api/fitness/sessions/+server.ts | 32 +- .../api/fitness/workout/active/+server.ts | 4 +- .../[workout=fitnessWorkout]/+page.svelte | 43 +- .../[active=fitnessActive]/+page.svelte | 556 +++++++++++++++++- 9 files changed, 705 insertions(+), 46 deletions(-) diff --git a/src/lib/js/gps.svelte.ts b/src/lib/js/gps.svelte.ts index 4be406f..8d47f2a 100644 --- a/src/lib/js/gps.svelte.ts +++ b/src/lib/js/gps.svelte.ts @@ -221,6 +221,22 @@ export function createGpsTracker() { bridge?.resumeTracking(); } + /** Request location permissions without starting the tracking service. + * Returns true if permissions were granted. */ + async function ensurePermissions(): Promise { + 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 { get track() { return track; }, get isTracking() { return isTracking; }, @@ -237,7 +253,8 @@ export function createGpsTracker() { hasTtsEngine, installTtsEngine, pauseTracking, - resumeTracking + resumeTracking, + ensurePermissions }; } diff --git a/src/lib/js/workout.svelte.ts b/src/lib/js/workout.svelte.ts index d33151b..7323266 100644 --- a/src/lib/js/workout.svelte.ts +++ b/src/lib/js/workout.svelte.ts @@ -33,9 +33,14 @@ export interface TemplateData { const STORAGE_KEY = 'fitness-active-workout'; +export type WorkoutMode = 'manual' | 'gps'; +export type GpsActivityType = 'running' | 'walking' | 'cycling' | 'hiking'; + export interface StoredState { active: boolean; paused: boolean; + mode: WorkoutMode; + activityType: GpsActivityType | null; name: string; templateId: string | null; exercises: WorkoutExercise[]; @@ -49,6 +54,8 @@ export interface StoredState { export interface RemoteState { name: string; + mode: WorkoutMode; + activityType: GpsActivityType | null; templateId: string | null; exercises: WorkoutExercise[]; paused: boolean; @@ -89,6 +96,8 @@ function clearStorage() { export function createWorkout() { let active = $state(false); let paused = $state(false); + let mode = $state('manual'); + let activityType = $state(null); let name = $state(''); let templateId: string | null = $state(null); let exercises = $state([]); @@ -115,6 +124,8 @@ export function createWorkout() { saveToStorage({ active, paused, + mode, + activityType, name, templateId, exercises: JSON.parse(JSON.stringify(exercises)), @@ -182,6 +193,8 @@ export function createWorkout() { active = true; paused = stored.paused; + mode = stored.mode ?? 'manual'; + activityType = stored.activityType ?? null; name = stored.name; templateId = stored.templateId; exercises = stored.exercises; @@ -220,6 +233,7 @@ export function createWorkout() { function startFromTemplate(template: TemplateData) { name = template.name; templateId = template._id; + mode = 'manual'; exercises = template.exercises.map((e) => ({ exerciseId: e.exerciseId, sets: e.sets.length > 0 @@ -246,6 +260,7 @@ export function createWorkout() { function startEmpty() { name = 'Quick Workout'; templateId = null; + mode = 'manual'; exercises = []; startTime = new Date(); _pausedElapsed = 0; @@ -256,6 +271,26 @@ export function createWorkout() { _persist(); } + function startGpsWorkout(activity: GpsActivityType = 'running') { + const labels: Record = { + 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() { if (!active || paused) return; _computeElapsed(); @@ -374,6 +409,8 @@ export function createWorkout() { templateId, templateName: templateId ? name : undefined, name, + mode, + activityType, exercises: exercises .filter((e) => e.sets.some((s) => s.completed)) .map((e) => ({ @@ -409,6 +446,8 @@ export function createWorkout() { function _reset() { active = false; paused = false; + mode = 'manual'; + activityType = null; name = ''; templateId = null; exercises = []; @@ -427,6 +466,8 @@ export function createWorkout() { /** Apply state from another device (merge strategy: incoming wins) */ function applyRemoteState(remote: RemoteState) { name = remote.name; + mode = remote.mode ?? 'manual'; + activityType = remote.activityType ?? null; templateId = remote.templateId; exercises = remote.exercises; @@ -470,6 +511,8 @@ export function createWorkout() { saveToStorage({ active: true, paused, + mode, + activityType, name, templateId, exercises: JSON.parse(JSON.stringify(exercises)), @@ -496,6 +539,8 @@ export function createWorkout() { return { get active() { return active; }, get paused() { return paused; }, + get mode() { return mode; }, + get activityType() { return activityType; }, get name() { return name; }, set name(v: string) { name = v; _persist(); }, get templateId() { return templateId; }, @@ -511,6 +556,7 @@ export function createWorkout() { restore, startFromTemplate, startEmpty, + startGpsWorkout, pauseTimer, resumeTimer, addExercise, diff --git a/src/lib/js/workoutSync.svelte.ts b/src/lib/js/workoutSync.svelte.ts index 5e49689..3e33a69 100644 --- a/src/lib/js/workoutSync.svelte.ts +++ b/src/lib/js/workoutSync.svelte.ts @@ -7,13 +7,15 @@ */ 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'; interface ServerWorkout { version: number; name: string; + mode: WorkoutMode; + activityType: GpsActivityType | null; templateId: string | null; exercises: WorkoutExercise[]; paused: boolean; @@ -42,6 +44,8 @@ export function createWorkoutSync() { return { version: serverVersion, name: workout.name, + mode: workout.mode, + activityType: workout.activityType, templateId: workout.templateId, exercises: JSON.parse(JSON.stringify(workout.exercises)), paused: workout.paused, @@ -107,6 +111,8 @@ export function createWorkoutSync() { // but we keep the higher value for completed sets workout.applyRemoteState({ name: doc.name, + mode: doc.mode ?? 'manual', + activityType: doc.activityType ?? null, templateId: doc.templateId, exercises: doc.exercises, paused: doc.paused, @@ -225,6 +231,8 @@ export function createWorkoutSync() { serverVersion = serverDoc.version; workout.restoreFromRemote({ name: serverDoc.name, + mode: serverDoc.mode ?? 'manual', + activityType: serverDoc.activityType ?? null, templateId: serverDoc.templateId, exercises: serverDoc.exercises, paused: serverDoc.paused, diff --git a/src/models/ActiveWorkout.ts b/src/models/ActiveWorkout.ts index 90f666e..c20f81b 100644 --- a/src/models/ActiveWorkout.ts +++ b/src/models/ActiveWorkout.ts @@ -18,6 +18,8 @@ export interface IActiveWorkout { userId: string; version: number; name: string; + mode: 'manual' | 'gps'; + activityType: 'running' | 'walking' | 'cycling' | 'hiking' | null; templateId: string | null; exercises: IActiveWorkoutExercise[]; paused: boolean; @@ -62,6 +64,16 @@ const ActiveWorkoutSchema = new mongoose.Schema( trim: true, maxlength: 100 }, + mode: { + type: String, + enum: ['manual', 'gps'], + default: 'manual' + }, + activityType: { + type: String, + enum: ['running', 'walking', 'cycling', 'hiking'], + default: null + }, templateId: { type: String, default: null diff --git a/src/models/WorkoutSession.ts b/src/models/WorkoutSession.ts index bb98fb5..6121ab6 100644 --- a/src/models/WorkoutSession.ts +++ b/src/models/WorkoutSession.ts @@ -41,12 +41,16 @@ export interface IWorkoutSession { templateId?: string; // Reference to WorkoutTemplate if based on template templateName?: string; // Snapshot of template name for history name: string; + mode?: 'manual' | 'gps'; + activityType?: 'running' | 'walking' | 'cycling' | 'hiking'; exercises: ICompletedExercise[]; startTime: Date; endTime?: Date; duration?: number; // Duration in minutes totalVolume?: number; // Total weight × reps across all 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[]; notes?: string; createdBy: string; // username/nickname of the person who performed the workout @@ -155,15 +159,18 @@ const WorkoutSessionSchema = new mongoose.Schema( trim: true, maxlength: 100 }, + mode: { + type: String, + enum: ['manual', 'gps'], + default: 'manual' + }, + activityType: { + type: String, + enum: ['running', 'walking', 'cycling', 'hiking'] + }, exercises: { type: [CompletedExerciseSchema], - required: true, - validate: { - validator: function(exercises: ICompletedExercise[]) { - return exercises.length > 0; - }, - message: 'A workout session must have at least one exercise' - } + default: [] }, startTime: { type: Date, @@ -185,6 +192,14 @@ const WorkoutSessionSchema = new mongoose.Schema( type: Number, min: 0 }, + gpsTrack: { + type: [GpsPointSchema], + default: undefined + }, + gpsPreview: { + type: [[Number]], + default: undefined + }, prs: [{ exerciseId: { type: String, required: true }, type: { type: String, required: true }, diff --git a/src/routes/api/fitness/sessions/+server.ts b/src/routes/api/fitness/sessions/+server.ts index 45d6bdd..d522de2 100644 --- a/src/routes/api/fitness/sessions/+server.ts +++ b/src/routes/api/fitness/sessions/+server.ts @@ -6,6 +6,7 @@ import type { IPr } from '$models/WorkoutSession'; import { WorkoutTemplate } from '$models/WorkoutTemplate'; import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises'; import { detectCardioPrs } from '$lib/data/cardioPrRanges'; +import { simplifyTrack } from '$lib/server/simplifyTrack'; function estimatedOneRepMax(weight: number, reps: number): number { 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 sessions = await WorkoutSession.find({ createdBy: session.user.nickname }) - .select('-exercises.gpsTrack') + .select('-exercises.gpsTrack -gpsTrack') .sort({ startTime: -1 }) .limit(limit) .skip(offset); @@ -52,10 +53,10 @@ export const POST: RequestHandler = async ({ request, locals }) => { await dbConnect(); 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) { - return json({ error: 'Name and at least one exercise are required' }, { status: 400 }); + if (!name || (!exercises?.length && !gpsTrack?.length)) { + return json({ error: 'Name and at least one exercise or GPS track required' }, { status: 400 }); } let templateName; @@ -68,8 +69,8 @@ export const POST: RequestHandler = async ({ request, locals }) => { // Compute totalVolume and totalDistance let totalVolume = 0; - let totalDistance = 0; - for (const ex of exercises) { + let totalDistance = gpsDistance ?? 0; + for (const ex of (exercises ?? [])) { const exercise = getExerciseById(ex.exerciseId); const metrics = getExerciseMetrics(exercise); 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 const prs: IPr[] = []; - for (const ex of exercises) { + for (const ex of (exercises ?? [])) { const exercise = getExerciseById(ex.exerciseId); const metrics = getExerciseMetrics(exercise); 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({ templateId, templateName, name, - exercises, + mode: mode ?? (gpsTrack?.length ? 'gps' : 'manual'), + activityType: activityType ?? undefined, + exercises: processedExercises, startTime: startTime ? new Date(startTime) : new Date(), endTime: endTime ? new Date(endTime) : undefined, duration: endTime && startTime ? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / (1000 * 60)) : undefined, totalVolume: totalVolume > 0 ? totalVolume : undefined, totalDistance: totalDistance > 0 ? totalDistance : undefined, + gpsTrack: gpsTrack?.length ? gpsTrack : undefined, + gpsPreview, prs: prs.length > 0 ? prs : undefined, notes, createdBy: session.user.nickname diff --git a/src/routes/api/fitness/workout/active/+server.ts b/src/routes/api/fitness/workout/active/+server.ts index 585ff96..be07673 100644 --- a/src/routes/api/fitness/workout/active/+server.ts +++ b/src/routes/api/fitness/workout/active/+server.ts @@ -34,7 +34,7 @@ export const PUT: RequestHandler = async ({ request, locals }) => { try { await dbConnect(); 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) { return json({ error: 'Name is required' }, { status: 400 }); @@ -58,6 +58,8 @@ export const PUT: RequestHandler = async ({ request, locals }) => { { $set: { name, + mode: mode ?? 'manual', + activityType: activityType ?? null, templateId: templateId ?? null, exercises: exercises ?? [], paused: paused ?? false, diff --git a/src/routes/fitness/[workout=fitnessWorkout]/+page.svelte b/src/routes/fitness/[workout=fitnessWorkout]/+page.svelte index 4dfb8d1..ae2c7ca 100644 --- a/src/routes/fitness/[workout=fitnessWorkout]/+page.svelte +++ b/src/routes/fitness/[workout=fitnessWorkout]/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { onMount } from 'svelte'; - import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight } from 'lucide-svelte'; + import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight, MapPin, Dumbbell } from 'lucide-svelte'; import { getWorkout } from '$lib/js/workout.svelte'; import { getWorkoutSync } from '$lib/js/workoutSync.svelte'; import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n'; @@ -48,8 +48,10 @@ /** @type {any} */ let nextTemplate = $derived(nextTemplateId ? templates.find((t) => t._id === nextTemplateId) : null); let hasSchedule = $derived(scheduleOrder.length > 0); + let isApp = $state(false); onMount(() => { + isApp = '__TAURI__' in window; workout.restore(); // If there's an active workout, redirect to the active page @@ -93,6 +95,12 @@ goto(`/fitness/${sl.workout}/${sl.active}`); } + async function startGps() { + workout.startGpsWorkout('running'); + await sync.onWorkoutStart(); + goto(`/fitness/${sl.workout}/${sl.active}`); + } + async function startNextScheduled() { if (!nextTemplate) return; await startFromTemplate(nextTemplate); @@ -333,9 +341,18 @@ {/if}
- +
+ {#if isApp} + + {/if} + +
@@ -638,19 +655,27 @@ .quick-start { text-align: center; } - .start-empty-btn { - width: 100%; - padding: 0.9rem; + .quick-start-row { + display: flex; + 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); color: var(--primary-contrast); border: none; border-radius: 10px; font-weight: 700; - font-size: 0.9rem; + font-size: 0.85rem; cursor: pointer; letter-spacing: 0.03em; } - .start-empty-btn:hover { + .start-choice-btn:hover { opacity: 0.9; } .templates-header { diff --git a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte index b23e6c2..39fe824 100644 --- a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte +++ b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte @@ -1,7 +1,7 @@