Files
homepage/src/routes/api/fitness/sessions/[id]/gpx/+server.ts
Alexander Bocken 2ba08c51c0
All checks were successful
CI / update (push) Successful in 2m17s
fitness: fix GPS preview aspect ratio, theme-reactive colors, and UI polish
- SessionCard SVG: cosine-corrected coordinates with proper aspect ratio (xMidYMid meet)
- SessionCard: use --color-primary for track/distance/pace, add Gauge icon for pace
- History detail: theme-reactive pace chart colors via MutationObserver + matchMedia
- History detail: add Gauge icon, accent color for distance/pace stats, remove "avg" label
- Move GPS remove button from info view to edit screen
- Add Leaflet map preview to edit screen
- Remove data points count from GPS indicators
2026-03-20 14:59:31 +01:00

169 lines
5.2 KiB
TypeScript

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 { simplifyTrack } from '$lib/server/simplifyTrack';
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].gpsPreview = simplifyTrack(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].gpsPreview = 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 });
}
};