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",
|
||||
"@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",
|
||||
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
|
||||
@@ -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<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>
|
||||
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 ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -173,6 +175,223 @@
|
||||
const metrics = getExerciseMetrics(exercise);
|
||||
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>
|
||||
|
||||
<div class="session-detail">
|
||||
@@ -316,6 +535,72 @@
|
||||
{/each}
|
||||
</tbody>
|
||||
</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>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -350,6 +635,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</svelte:head>
|
||||
|
||||
{#if showPicker}
|
||||
<ExercisePicker
|
||||
onSelect={(id) => { addExerciseToEdit(id); showPicker = false; }}
|
||||
@@ -664,4 +953,123 @@
|
||||
font-size: 0.85rem;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user