fitness: add GPX upload with map, pace chart, and km splits
All checks were successful
CI / update (push) Successful in 2m18s
All checks were successful
CI / update (push) Successful in 2m18s
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.
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.1.3",
|
"@sveltejs/vite-plugin-svelte": "^6.1.3",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.2.9",
|
"@testing-library/svelte": "^5.2.9",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^22.12.0",
|
"@types/node": "^22.12.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@vitest/ui": "^4.0.10",
|
"@vitest/ui": "^4.0.10",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"file-type": "^19.0.0",
|
"file-type": "^19.0.0",
|
||||||
"ioredis": "^5.9.0",
|
"ioredis": "^5.9.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-svelte": "^0.575.0",
|
"lucide-svelte": "^0.575.0",
|
||||||
"mongoose": "^8.0.0",
|
"mongoose": "^8.0.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
|||||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
ioredis:
|
ioredis:
|
||||||
specifier: ^5.9.0
|
specifier: ^5.9.0
|
||||||
version: 5.9.0
|
version: 5.9.0
|
||||||
|
leaflet:
|
||||||
|
specifier: ^1.9.4
|
||||||
|
version: 1.9.4
|
||||||
lucide-svelte:
|
lucide-svelte:
|
||||||
specifier: ^0.575.0
|
specifier: ^0.575.0
|
||||||
version: 0.575.0(svelte@5.38.6)
|
version: 0.575.0(svelte@5.38.6)
|
||||||
@@ -54,6 +57,9 @@ importers:
|
|||||||
'@testing-library/svelte':
|
'@testing-library/svelte':
|
||||||
specifier: ^5.2.9
|
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))
|
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':
|
'@types/node':
|
||||||
specifier: ^22.12.0
|
specifier: ^22.12.0
|
||||||
version: 22.18.0
|
version: 22.18.0
|
||||||
@@ -888,6 +894,12 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
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':
|
'@types/node-cron@3.0.11':
|
||||||
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
|
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
|
||||||
|
|
||||||
@@ -1248,6 +1260,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
leaflet@1.9.4:
|
||||||
|
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||||
|
|
||||||
locate-character@3.0.0:
|
locate-character@3.0.0:
|
||||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
||||||
|
|
||||||
@@ -2358,6 +2373,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@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-cron@3.0.11': {}
|
||||||
|
|
||||||
'@types/node@22.18.0':
|
'@types/node@22.18.0':
|
||||||
@@ -2740,6 +2761,8 @@ snapshots:
|
|||||||
|
|
||||||
kleur@4.1.5: {}
|
kleur@4.1.5: {}
|
||||||
|
|
||||||
|
leaflet@1.9.4: {}
|
||||||
|
|
||||||
locate-character@3.0.0: {}
|
locate-character@3.0.0: {}
|
||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
lodash.defaults@4.2.0: {}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
|
|||||||
@@ -10,12 +10,22 @@ export interface ICompletedSet {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IGpsPoint {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
altitude?: number;
|
||||||
|
speed?: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ICompletedExercise {
|
export interface ICompletedExercise {
|
||||||
exerciseId: string;
|
exerciseId: string;
|
||||||
name: string;
|
name: string;
|
||||||
sets: ICompletedSet[];
|
sets: ICompletedSet[];
|
||||||
restTime?: number;
|
restTime?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
gpsTrack?: IGpsPoint[];
|
||||||
|
totalDistance?: number; // km
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkoutSession {
|
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({
|
const CompletedExerciseSchema = new mongoose.Schema({
|
||||||
exerciseId: {
|
exerciseId: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -95,6 +113,14 @@ const CompletedExerciseSchema = new mongoose.Schema({
|
|||||||
type: String,
|
type: String,
|
||||||
trim: true,
|
trim: true,
|
||||||
maxlength: 500
|
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({ createdBy: 1, startTime: -1 });
|
||||||
WorkoutSessionSchema.index({ templateId: 1 });
|
WorkoutSessionSchema.index({ templateId: 1 });
|
||||||
|
|
||||||
export const WorkoutSession = mongoose.model<IWorkoutSession>("WorkoutSession", WorkoutSessionSchema);
|
export const WorkoutSession = mongoose.models.WorkoutSession as mongoose.Model<IWorkoutSession> ?? mongoose.model<IWorkoutSession>("WorkoutSession", WorkoutSessionSchema);
|
||||||
165
src/routes/api/fitness/sessions/[id]/gpx/+server.ts
Normal file
165
src/routes/api/fitness/sessions/[id]/gpx/+server.ts
Normal file
@@ -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 <trkpt> or <rtept> 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>([^<]+)<\/ele>/);
|
||||||
|
if (eleMatch) altitude = parseFloat(eleMatch[1]);
|
||||||
|
|
||||||
|
let timestamp = Date.now();
|
||||||
|
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
|
||||||
|
if (timeMatch) timestamp = new Date(timeMatch[1]).getTime();
|
||||||
|
|
||||||
|
if (!isNaN(lat) && !isNaN(lng)) {
|
||||||
|
points.push({ lat, lng, altitude, timestamp });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/fitness/sessions/[id]/gpx — upload GPX file for an exercise
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const session = await locals.auth();
|
||||||
|
if (!session || !session.user?.nickname) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(params.id)) {
|
||||||
|
return json({ error: 'Invalid session ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('gpx');
|
||||||
|
const exerciseIdx = parseInt(formData.get('exerciseIdx') as string ?? '', 10);
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return json({ error: 'No GPX file provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (isNaN(exerciseIdx) || exerciseIdx < 0) {
|
||||||
|
return json({ error: 'Invalid exercise index' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpxText = await file.text();
|
||||||
|
const track = parseGpx(gpxText);
|
||||||
|
|
||||||
|
if (track.length === 0) {
|
||||||
|
return json({ error: 'No track points found in GPX file' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
const workoutSession = await WorkoutSession.findOne({
|
||||||
|
_id: params.id,
|
||||||
|
createdBy: session.user.nickname
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workoutSession) {
|
||||||
|
return json({ error: 'Session not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exerciseIdx >= workoutSession.exercises.length) {
|
||||||
|
return json({ error: 'Exercise index out of range' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = trackDistance(track);
|
||||||
|
const durationMin = Math.round((track[track.length - 1].timestamp - track[0].timestamp) / 60000);
|
||||||
|
|
||||||
|
workoutSession.exercises[exerciseIdx].gpsTrack = track;
|
||||||
|
workoutSession.exercises[exerciseIdx].totalDistance = Math.round(distance * 1000) / 1000;
|
||||||
|
|
||||||
|
// Auto-fill distance and duration on a single set
|
||||||
|
const sets = workoutSession.exercises[exerciseIdx].sets;
|
||||||
|
if (sets.length === 1) {
|
||||||
|
sets[0].distance = Math.round(distance * 100) / 100;
|
||||||
|
sets[0].duration = durationMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
await workoutSession.save();
|
||||||
|
|
||||||
|
return json({
|
||||||
|
points: track.length,
|
||||||
|
distance: workoutSession.exercises[exerciseIdx].totalDistance
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing GPX upload:', error);
|
||||||
|
return json({ error: 'Failed to process GPX file' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE /api/fitness/sessions/[id]/gpx — remove GPS track from an exercise
|
||||||
|
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const session = await locals.auth();
|
||||||
|
if (!session || !session.user?.nickname) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(params.id)) {
|
||||||
|
return json({ error: 'Invalid session ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { exerciseIdx } = await request.json();
|
||||||
|
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
const workoutSession = await WorkoutSession.findOne({
|
||||||
|
_id: params.id,
|
||||||
|
createdBy: session.user.nickname
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workoutSession) {
|
||||||
|
return json({ error: 'Session not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exerciseIdx >= workoutSession.exercises.length) {
|
||||||
|
return json({ error: 'Exercise index out of range' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
workoutSession.exercises[exerciseIdx].gpsTrack = undefined;
|
||||||
|
workoutSession.exercises[exerciseIdx].totalDistance = undefined;
|
||||||
|
await workoutSession.save();
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing GPS track:', error);
|
||||||
|
return json({ error: 'Failed to remove GPS track' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { goto, invalidateAll } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
import { Clock, Weight, Trophy, Trash2, Pencil, Plus } from 'lucide-svelte';
|
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X } from 'lucide-svelte';
|
||||||
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
|
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
|
||||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||||
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
||||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||||
|
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -173,6 +175,223 @@
|
|||||||
const metrics = getExerciseMetrics(exercise);
|
const metrics = getExerciseMetrics(exercise);
|
||||||
return metrics.includes('weight') && metrics.includes('reps');
|
return metrics.includes('weight') && metrics.includes('reps');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {string} exerciseId */
|
||||||
|
function isCardio(exerciseId) {
|
||||||
|
const exercise = getExerciseById(exerciseId);
|
||||||
|
return exercise?.bodyPart === 'cardio';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<number, any>} */
|
||||||
|
let maps = {};
|
||||||
|
let uploading = $state(-1);
|
||||||
|
|
||||||
|
/** Haversine distance in km */
|
||||||
|
function haversine(/** @type {any} */ a, /** @type {any} */ b) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {any[]} track */
|
||||||
|
function trackDistance(track) {
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 1; i < track.length; i++) total += haversine(track[i - 1], track[i]);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} minPerKm */
|
||||||
|
function formatPace(minPerKm) {
|
||||||
|
const m = Math.floor(minPerKm);
|
||||||
|
const s = Math.round((minPerKm - m) * 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')} /km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Svelte use:action — renders a Leaflet map for a GPS track
|
||||||
|
* @param {HTMLElement} node
|
||||||
|
* @param {any} params
|
||||||
|
*/
|
||||||
|
function renderMap(node, params) {
|
||||||
|
const { track, idx } = params;
|
||||||
|
initMapForTrack(node, track, idx);
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
if (maps[idx]) {
|
||||||
|
maps[idx].remove();
|
||||||
|
delete maps[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {HTMLElement} node @param {any[]} track @param {number} idx */
|
||||||
|
async function initMapForTrack(node, track, idx) {
|
||||||
|
const L = await import('leaflet');
|
||||||
|
if (!node.isConnected) return;
|
||||||
|
const map = L.map(node, { attributionControl: false, zoomControl: false });
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
|
||||||
|
const pts = track.map((/** @type {any} */ p) => /** @type {[number, number]} */ ([p.lat, p.lng]));
|
||||||
|
const polyline = L.polyline(pts, { color: '#88c0d0', weight: 3 }).addTo(map);
|
||||||
|
// Start/end markers
|
||||||
|
L.circleMarker(pts[0], { radius: 5, fillColor: '#a3be8c', fillOpacity: 1, color: '#fff', weight: 2 }).addTo(map);
|
||||||
|
L.circleMarker(pts[pts.length - 1], { radius: 5, fillColor: '#bf616a', fillOpacity: 1, color: '#fff', weight: 2 }).addTo(map);
|
||||||
|
map.fitBounds(polyline.getBounds(), { padding: [20, 20] });
|
||||||
|
maps[idx] = map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute km splits from a GPS track.
|
||||||
|
* Returns array of { km, pace (min/km), elapsed (min) }
|
||||||
|
* @param {any[]} track
|
||||||
|
*/
|
||||||
|
function computeSplits(track) {
|
||||||
|
if (track.length < 2) return [];
|
||||||
|
/** @type {Array<{km: number, pace: number, elapsed: number}>} */
|
||||||
|
const splits = [];
|
||||||
|
let cumDist = 0;
|
||||||
|
let splitStart = 0; // index where current km started
|
||||||
|
let splitStartTime = track[0].timestamp;
|
||||||
|
|
||||||
|
for (let i = 1; i < track.length; i++) {
|
||||||
|
cumDist += haversine(track[i - 1], track[i]);
|
||||||
|
const currentKm = Math.floor(cumDist);
|
||||||
|
const prevKm = splits.length;
|
||||||
|
|
||||||
|
if (currentKm > prevKm) {
|
||||||
|
// We crossed a km boundary
|
||||||
|
const elapsedMin = (track[i].timestamp - splitStartTime) / 60000;
|
||||||
|
// Distance covered in this segment (might be slightly over 1km)
|
||||||
|
const segDist = cumDist - prevKm;
|
||||||
|
const pace = segDist > 0 ? elapsedMin / segDist : 0;
|
||||||
|
splits.push({
|
||||||
|
km: currentKm,
|
||||||
|
pace,
|
||||||
|
elapsed: (track[i].timestamp - track[0].timestamp) / 60000
|
||||||
|
});
|
||||||
|
splitStart = i;
|
||||||
|
splitStartTime = track[i].timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final partial km
|
||||||
|
const lastKm = splits.length;
|
||||||
|
const partialDist = cumDist - lastKm;
|
||||||
|
if (partialDist > 0.05) { // only show if > 50m
|
||||||
|
const elapsedMin = (track[track.length - 1].timestamp - splitStartTime) / 60000;
|
||||||
|
const pace = elapsedMin / partialDist;
|
||||||
|
splits.push({
|
||||||
|
km: cumDist,
|
||||||
|
pace,
|
||||||
|
elapsed: (track[track.length - 1].timestamp - track[0].timestamp) / 60000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return splits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute rolling pace samples for the pace graph.
|
||||||
|
* Returns array of { dist (km), pace (min/km) } sampled every ~100m.
|
||||||
|
* @param {any[]} track
|
||||||
|
*/
|
||||||
|
function computePaceSamples(track) {
|
||||||
|
if (track.length < 2) return [];
|
||||||
|
/** @type {Array<{dist: number, pace: number}>} */
|
||||||
|
const samples = [];
|
||||||
|
let cumDist = 0;
|
||||||
|
const windowDist = 0.2; // 200m rolling window
|
||||||
|
|
||||||
|
for (let i = 1; i < track.length; i++) {
|
||||||
|
cumDist += haversine(track[i - 1], track[i]);
|
||||||
|
|
||||||
|
// Find point ~windowDist km back
|
||||||
|
let backDist = 0;
|
||||||
|
let j = i;
|
||||||
|
while (j > 0 && backDist < windowDist) {
|
||||||
|
backDist += haversine(track[j - 1], track[j]);
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
if (backDist < 0.05) continue; // too little distance
|
||||||
|
|
||||||
|
const dt = (track[i].timestamp - track[j].timestamp) / 60000;
|
||||||
|
const pace = dt / backDist;
|
||||||
|
if (pace > 0 && pace < 30) { // sanity: max 30 min/km
|
||||||
|
samples.push({ dist: cumDist, pace });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Chart.js data for pace over distance
|
||||||
|
* @param {Array<{dist: number, pace: number}>} samples
|
||||||
|
*/
|
||||||
|
function buildPaceChartData(samples) {
|
||||||
|
// Downsample to ~50 points for readability
|
||||||
|
const step = Math.max(1, Math.floor(samples.length / 50));
|
||||||
|
const filtered = samples.filter((_, i) => i % step === 0 || i === samples.length - 1);
|
||||||
|
return {
|
||||||
|
labels: filtered.map(s => s.dist.toFixed(2)),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Pace',
|
||||||
|
data: filtered.map(s => s.pace),
|
||||||
|
borderColor: '#88C0D0',
|
||||||
|
backgroundColor: 'rgba(136, 192, 208, 0.12)',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} exIdx */
|
||||||
|
async function uploadGpx(exIdx) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.gpx';
|
||||||
|
input.onchange = async () => {
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
uploading = exIdx;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('gpx', file);
|
||||||
|
fd.append('exerciseIdx', String(exIdx));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/fitness/sessions/${session._id}/gpx`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
uploading = -1;
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} exIdx */
|
||||||
|
async function removeGpx(exIdx) {
|
||||||
|
if (!confirm('Remove GPS track from this exercise?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/fitness/sessions/${session._id}/gpx`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ exerciseIdx: exIdx })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="session-detail">
|
<div class="session-detail">
|
||||||
@@ -316,6 +535,72 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{#if ex.gpsTrack?.length > 0}
|
||||||
|
{@const dist = ex.totalDistance ?? trackDistance(ex.gpsTrack)}
|
||||||
|
{@const elapsed = (ex.gpsTrack[ex.gpsTrack.length - 1].timestamp - ex.gpsTrack[0].timestamp) / 60000}
|
||||||
|
{@const pace = dist > 0 && elapsed > 0 ? elapsed / dist : 0}
|
||||||
|
<div class="gps-track-section">
|
||||||
|
<div class="gps-stats">
|
||||||
|
<span class="gps-stat"><Route size={14} /> {dist.toFixed(2)} km</span>
|
||||||
|
{#if pace > 0}
|
||||||
|
<span class="gps-stat">{formatPace(pace)} avg</span>
|
||||||
|
{/if}
|
||||||
|
<span class="gps-stat">{ex.gpsTrack.length} pts</span>
|
||||||
|
<button class="gpx-remove-btn" onclick={() => removeGpx(exIdx)} aria-label="Remove GPS track">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="track-map" use:renderMap={{ track: ex.gpsTrack, idx: exIdx }}></div>
|
||||||
|
|
||||||
|
{#if ex.gpsTrack.length >= 2}
|
||||||
|
{@const samples = computePaceSamples(ex.gpsTrack)}
|
||||||
|
{@const splits = computeSplits(ex.gpsTrack)}
|
||||||
|
{#if samples.length > 0}
|
||||||
|
<div class="pace-chart-section">
|
||||||
|
<FitnessChart
|
||||||
|
data={buildPaceChartData(samples)}
|
||||||
|
title="Pace (min/km)"
|
||||||
|
height="180px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if splits.length > 1}
|
||||||
|
{@const avgPace = splits.reduce((a, s) => a + s.pace, 0) / splits.length}
|
||||||
|
<div class="splits-section">
|
||||||
|
<h4>Splits</h4>
|
||||||
|
<table class="splits-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>KM</th>
|
||||||
|
<th>PACE</th>
|
||||||
|
<th>TIME</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each splits as split, i (i)}
|
||||||
|
{@const isFull = split.km === Math.floor(split.km)}
|
||||||
|
<tr class:partial={!isFull}>
|
||||||
|
<td class="split-km">{isFull ? split.km : split.km.toFixed(2)}</td>
|
||||||
|
<td class="split-pace" class:fast={split.pace < avgPace * 0.97} class:slow={split.pace > avgPace * 1.03}>
|
||||||
|
{formatPace(split.pace)}
|
||||||
|
</td>
|
||||||
|
<td class="split-elapsed">{Math.floor(split.elapsed)}:{Math.round((split.elapsed % 1) * 60).toString().padStart(2, '0')}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if isCardio(ex.exerciseId)}
|
||||||
|
<button class="gpx-upload-btn" onclick={() => uploadGpx(exIdx)} disabled={uploading === exIdx}>
|
||||||
|
<Upload size={14} />
|
||||||
|
{uploading === exIdx ? 'Uploading...' : 'Upload GPX'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -350,6 +635,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
{#if showPicker}
|
{#if showPicker}
|
||||||
<ExercisePicker
|
<ExercisePicker
|
||||||
onSelect={(id) => { addExerciseToEdit(id); showPicker = false; }}
|
onSelect={(id) => { addExerciseToEdit(id); showPicker = false; }}
|
||||||
@@ -664,4 +953,123 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GPS track section */
|
||||||
|
.gps-track-section {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.gps-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.gps-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.gps-stat:first-child {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nord8);
|
||||||
|
}
|
||||||
|
.gpx-remove-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--nord11);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.gpx-remove-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.track-map {
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.gpx-upload-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.gpx-upload-btn:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.gpx-upload-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pace chart */
|
||||||
|
.pace-chart-section {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.splits-section h4 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Splits table */
|
||||||
|
.splits-section {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.splits-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.splits-table th {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.splits-table td {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.split-km {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.split-pace.fast {
|
||||||
|
color: var(--nord14);
|
||||||
|
}
|
||||||
|
.split-pace.slow {
|
||||||
|
color: var(--nord12);
|
||||||
|
}
|
||||||
|
tr.partial .split-km {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.split-elapsed {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user