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(/