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.
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user