From d1527b257273ace45114e48b4b2130395c4067b5 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 20 Mar 2026 13:30:47 +0100 Subject: [PATCH] fitness: add GPX upload with map, pace chart, and km splits Add GPX file upload for cardio exercises in workout history. Parses GPX track points and stores them in the session. Shows route map (Leaflet), pace-over-distance chart (Chart.js), and per-km splits table with color-coded fast/slow pacing. Auto-fills distance and duration on single-set exercises. Disables Chart.js animations. --- package.json | 2 + pnpm-lock.yaml | 23 + .../components/fitness/FitnessChart.svelte | 1 + src/models/WorkoutSession.ts | 28 +- .../api/fitness/sessions/[id]/gpx/+server.ts | 165 +++++++ src/routes/fitness/history/[id]/+page.svelte | 410 +++++++++++++++++- 6 files changed, 627 insertions(+), 2 deletions(-) create mode 100644 src/routes/api/fitness/sessions/[id]/gpx/+server.ts diff --git a/package.json b/package.json index f769ae82..6b74ca8c 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@sveltejs/vite-plugin-svelte": "^6.1.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.2.9", + "@types/leaflet": "^1.9.21", "@types/node": "^22.12.0", "@types/node-cron": "^3.0.11", "@vitest/ui": "^4.0.10", @@ -48,6 +49,7 @@ "chart.js": "^4.5.0", "file-type": "^19.0.0", "ioredis": "^5.9.0", + "leaflet": "^1.9.4", "lucide-svelte": "^0.575.0", "mongoose": "^8.0.0", "node-cron": "^4.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86b49550..37cb50f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: ioredis: specifier: ^5.9.0 version: 5.9.0 + leaflet: + specifier: ^1.9.4 + version: 1.9.4 lucide-svelte: specifier: ^0.575.0 version: 0.575.0(svelte@5.38.6) @@ -54,6 +57,9 @@ importers: '@testing-library/svelte': specifier: ^5.2.9 version: 5.2.9(svelte@5.38.6)(vite@7.1.3(@types/node@22.18.0)(terser@5.46.0))(vitest@4.0.10(@types/node@22.18.0)(@vitest/ui@4.0.10)(jsdom@27.2.0)(terser@5.46.0)) + '@types/leaflet': + specifier: ^1.9.21 + version: 1.9.21 '@types/node': specifier: ^22.12.0 version: 22.18.0 @@ -888,6 +894,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/leaflet@1.9.21': + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/node-cron@3.0.11': resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} @@ -1248,6 +1260,9 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} @@ -2358,6 +2373,12 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + + '@types/leaflet@1.9.21': + dependencies: + '@types/geojson': 7946.0.16 + '@types/node-cron@3.0.11': {} '@types/node@22.18.0': @@ -2740,6 +2761,8 @@ snapshots: kleur@4.1.5: {} + leaflet@1.9.4: {} + locate-character@3.0.0: {} lodash.defaults@4.2.0: {} diff --git a/src/lib/components/fitness/FitnessChart.svelte b/src/lib/components/fitness/FitnessChart.svelte index c55315bf..82fd4e69 100644 --- a/src/lib/components/fitness/FitnessChart.svelte +++ b/src/lib/components/fitness/FitnessChart.svelte @@ -69,6 +69,7 @@ options: { responsive: true, maintainAspectRatio: false, + animation: false, scales: { x: { grid: { display: false }, diff --git a/src/models/WorkoutSession.ts b/src/models/WorkoutSession.ts index a2a234d8..31858156 100644 --- a/src/models/WorkoutSession.ts +++ b/src/models/WorkoutSession.ts @@ -10,12 +10,22 @@ export interface ICompletedSet { notes?: string; } +export interface IGpsPoint { + lat: number; + lng: number; + altitude?: number; + speed?: number; + timestamp: number; +} + export interface ICompletedExercise { exerciseId: string; name: string; sets: ICompletedSet[]; restTime?: number; notes?: string; + gpsTrack?: IGpsPoint[]; + totalDistance?: number; // km } export interface IWorkoutSession { @@ -70,6 +80,14 @@ const CompletedSetSchema = new mongoose.Schema({ } }); +const GpsPointSchema = new mongoose.Schema({ + lat: { type: Number, required: true }, + lng: { type: Number, required: true }, + altitude: Number, + speed: Number, + timestamp: { type: Number, required: true } +}, { _id: false }); + const CompletedExerciseSchema = new mongoose.Schema({ exerciseId: { type: String, @@ -95,6 +113,14 @@ const CompletedExerciseSchema = new mongoose.Schema({ type: String, trim: true, maxlength: 500 + }, + gpsTrack: { + type: [GpsPointSchema], + default: undefined + }, + totalDistance: { + type: Number, + min: 0 } }); @@ -157,4 +183,4 @@ const WorkoutSessionSchema = new mongoose.Schema( WorkoutSessionSchema.index({ createdBy: 1, startTime: -1 }); WorkoutSessionSchema.index({ templateId: 1 }); -export const WorkoutSession = mongoose.model("WorkoutSession", WorkoutSessionSchema); \ No newline at end of file +export const WorkoutSession = mongoose.models.WorkoutSession as mongoose.Model ?? mongoose.model("WorkoutSession", WorkoutSessionSchema); \ No newline at end of file diff --git a/src/routes/api/fitness/sessions/[id]/gpx/+server.ts b/src/routes/api/fitness/sessions/[id]/gpx/+server.ts new file mode 100644 index 00000000..2433484e --- /dev/null +++ b/src/routes/api/fitness/sessions/[id]/gpx/+server.ts @@ -0,0 +1,165 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { dbConnect } from '$utils/db'; +import { WorkoutSession } from '$models/WorkoutSession'; +import type { IGpsPoint } from '$models/WorkoutSession'; +import mongoose from 'mongoose'; + +/** Haversine distance in km between two points */ +function haversine(a: IGpsPoint, b: IGpsPoint): number { + const R = 6371; + const dLat = ((b.lat - a.lat) * Math.PI) / 180; + const dLng = ((b.lng - a.lng) * Math.PI) / 180; + const sinLat = Math.sin(dLat / 2); + const sinLng = Math.sin(dLng / 2); + const h = + sinLat * sinLat + + Math.cos((a.lat * Math.PI) / 180) * + Math.cos((b.lat * Math.PI) / 180) * + sinLng * sinLng; + return 2 * R * Math.asin(Math.sqrt(h)); +} + +function trackDistance(track: IGpsPoint[]): number { + let total = 0; + for (let i = 1; i < track.length; i++) { + total += haversine(track[i - 1], track[i]); + } + return total; +} + +/** Parse a GPX XML string into an array of GpsPoints */ +function parseGpx(xml: string): IGpsPoint[] { + const points: IGpsPoint[] = []; + // Match or elements + const trkptRegex = /<(?:trkpt|rtept)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/(?:trkpt|rtept)>/gi; + let match; + while ((match = trkptRegex.exec(xml)) !== null) { + const lat = parseFloat(match[1]); + const lng = parseFloat(match[2]); + const body = match[3]; + + let altitude: number | undefined; + const eleMatch = body.match(/([^<]+)<\/ele>/); + if (eleMatch) altitude = parseFloat(eleMatch[1]); + + let timestamp = Date.now(); + const timeMatch = body.match(/