All checks were successful
CI / update (push) Successful in 2m17s
- 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
169 lines
5.2 KiB
TypeScript
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 });
|
|
}
|
|
};
|