From 6835f5479e732ea0a64d1b9f0df766cbc9203328 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 f769ae8..6b74ca8 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 86b4955..37cb50f 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 c55315b..82fd4e6 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 a2a234d..3185815 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 0000000..2433484 --- /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(/