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
This commit is contained in:
@@ -2,7 +2,15 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { WorkoutSession } from '$models/WorkoutSession';
|
||||
import type { IPr } from '$models/WorkoutSession';
|
||||
import { WorkoutTemplate } from '$models/WorkoutTemplate';
|
||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||
|
||||
function estimatedOneRepMax(weight: number, reps: number): number {
|
||||
if (reps <= 0 || weight <= 0) return 0;
|
||||
if (reps === 1) return weight;
|
||||
return Math.round(weight * (1 + reps / 30));
|
||||
}
|
||||
|
||||
// GET /api/fitness/sessions - Get all workout sessions for the user
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
@@ -18,6 +26,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||
|
||||
const sessions = await WorkoutSession.find({ createdBy: session.user.nickname })
|
||||
.select('-exercises.gpsTrack')
|
||||
.sort({ startTime: -1 })
|
||||
.limit(limit)
|
||||
.skip(offset);
|
||||
@@ -56,6 +65,69 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Compute totalVolume and totalDistance
|
||||
let totalVolume = 0;
|
||||
let totalDistance = 0;
|
||||
for (const ex of exercises) {
|
||||
const exercise = getExerciseById(ex.exerciseId);
|
||||
const metrics = getExerciseMetrics(exercise);
|
||||
const isCardio = metrics.includes('distance');
|
||||
const isBilateral = exercise?.bilateral ?? false;
|
||||
for (const s of (ex.sets ?? [])) {
|
||||
if (!s.completed) continue;
|
||||
if (isCardio) {
|
||||
totalDistance += s.distance ?? 0;
|
||||
} else {
|
||||
totalVolume += (s.weight ?? 0) * (s.reps ?? 0) * (isBilateral ? 2 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect PRs by comparing against previous best for each exercise
|
||||
const prs: IPr[] = [];
|
||||
for (const ex of exercises) {
|
||||
const exercise = getExerciseById(ex.exerciseId);
|
||||
const metrics = getExerciseMetrics(exercise);
|
||||
const isCardio = metrics.includes('distance');
|
||||
if (isCardio) continue;
|
||||
|
||||
const completedSets = (ex.sets ?? []).filter((s: { completed: boolean }) => s.completed);
|
||||
if (completedSets.length === 0) continue;
|
||||
|
||||
// Find previous best for this exercise
|
||||
const prevSessions = await WorkoutSession.find({
|
||||
createdBy: session.user!.nickname,
|
||||
'exercises.exerciseId': ex.exerciseId,
|
||||
}).sort({ startTime: -1 }).limit(50).lean();
|
||||
|
||||
let prevBestWeight = 0;
|
||||
let prevBestEst1rm = 0;
|
||||
for (const ps of prevSessions) {
|
||||
const pe = ps.exercises.find((e) => e.exerciseId === ex.exerciseId);
|
||||
if (!pe) continue;
|
||||
for (const s of pe.sets) {
|
||||
if (!s.completed || !s.weight || !s.reps) continue;
|
||||
prevBestWeight = Math.max(prevBestWeight, s.weight);
|
||||
prevBestEst1rm = Math.max(prevBestEst1rm, estimatedOneRepMax(s.weight, s.reps));
|
||||
}
|
||||
}
|
||||
|
||||
let bestWeight = 0;
|
||||
let bestEst1rm = 0;
|
||||
for (const s of completedSets) {
|
||||
if (!s.weight || !s.reps) continue;
|
||||
bestWeight = Math.max(bestWeight, s.weight);
|
||||
bestEst1rm = Math.max(bestEst1rm, estimatedOneRepMax(s.weight, s.reps));
|
||||
}
|
||||
|
||||
if (bestWeight > prevBestWeight && prevBestWeight > 0) {
|
||||
prs.push({ exerciseId: ex.exerciseId, type: 'maxWeight', value: bestWeight });
|
||||
}
|
||||
if (bestEst1rm > prevBestEst1rm && prevBestEst1rm > 0) {
|
||||
prs.push({ exerciseId: ex.exerciseId, type: 'est1rm', value: bestEst1rm });
|
||||
}
|
||||
}
|
||||
|
||||
const workoutSession = new WorkoutSession({
|
||||
templateId,
|
||||
templateName,
|
||||
@@ -64,6 +136,9 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
startTime: startTime ? new Date(startTime) : new Date(),
|
||||
endTime: endTime ? new Date(endTime) : undefined,
|
||||
duration: endTime && startTime ? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / (1000 * 60)) : undefined,
|
||||
totalVolume: totalVolume > 0 ? totalVolume : undefined,
|
||||
totalDistance: totalDistance > 0 ? totalDistance : undefined,
|
||||
prs: prs.length > 0 ? prs : undefined,
|
||||
notes,
|
||||
createdBy: session.user.nickname
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { WorkoutSession } from '$models/WorkoutSession';
|
||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
// GET /api/fitness/sessions/[id] - Get a specific workout session
|
||||
@@ -57,7 +58,28 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (name) updateData.name = name;
|
||||
if (exercises) updateData.exercises = exercises;
|
||||
if (exercises) {
|
||||
updateData.exercises = exercises;
|
||||
// Recompute totalVolume
|
||||
let totalVolume = 0;
|
||||
let totalDistance = 0;
|
||||
for (const ex of exercises) {
|
||||
const exercise = getExerciseById(ex.exerciseId);
|
||||
const metrics = getExerciseMetrics(exercise);
|
||||
const isCardio = metrics.includes('distance');
|
||||
const isBilateral = exercise?.bilateral ?? false;
|
||||
for (const s of (ex.sets ?? [])) {
|
||||
if (!s.completed) continue;
|
||||
if (isCardio) {
|
||||
totalDistance += s.distance ?? 0;
|
||||
} else {
|
||||
totalVolume += (s.weight ?? 0) * (s.reps ?? 0) * (isBilateral ? 2 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateData.totalVolume = totalVolume > 0 ? totalVolume : undefined;
|
||||
updateData.totalDistance = totalDistance > 0 ? totalDistance : undefined;
|
||||
}
|
||||
if (startTime) updateData.startTime = new Date(startTime);
|
||||
if (endTime) updateData.endTime = new Date(endTime);
|
||||
if (duration !== undefined) updateData.duration = duration;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 */
|
||||
@@ -103,6 +104,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
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
|
||||
@@ -154,6 +156,7 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||
}
|
||||
|
||||
workoutSession.exercises[exerciseIdx].gpsTrack = undefined;
|
||||
workoutSession.exercises[exerciseIdx].gpsPreview = undefined;
|
||||
workoutSession.exercises[exerciseIdx].totalDistance = undefined;
|
||||
await workoutSession.save();
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { WorkoutSession } from '$models/WorkoutSession';
|
||||
import type { IPr } from '$models/WorkoutSession';
|
||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
function estimatedOneRepMax(weight: number, reps: number): number {
|
||||
if (reps <= 0 || weight <= 0) return 0;
|
||||
if (reps === 1) return weight;
|
||||
return Math.round(weight * (1 + reps / 30));
|
||||
}
|
||||
|
||||
// POST /api/fitness/sessions/[id]/recalculate — recompute derived fields
|
||||
export const POST: RequestHandler = async ({ params, 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 {
|
||||
await dbConnect();
|
||||
|
||||
const workoutSession = await WorkoutSession.findOne({
|
||||
_id: params.id,
|
||||
createdBy: session.user.nickname
|
||||
});
|
||||
|
||||
if (!workoutSession) {
|
||||
return json({ error: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Recompute totalVolume and totalDistance
|
||||
let totalVolume = 0;
|
||||
let totalDistance = 0;
|
||||
for (const ex of workoutSession.exercises) {
|
||||
const exercise = getExerciseById(ex.exerciseId);
|
||||
const metrics = getExerciseMetrics(exercise);
|
||||
const isCardio = metrics.includes('distance');
|
||||
const isBilateral = exercise?.bilateral ?? false;
|
||||
for (const s of ex.sets) {
|
||||
if (!s.completed) continue;
|
||||
if (isCardio) {
|
||||
totalDistance += s.distance ?? 0;
|
||||
} else {
|
||||
totalVolume += (s.weight ?? 0) * (s.reps ?? 0) * (isBilateral ? 2 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate gpsPreview from gpsTrack if present
|
||||
if (ex.gpsTrack && ex.gpsTrack.length >= 2) {
|
||||
ex.gpsPreview = simplifyTrack(ex.gpsTrack);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect PRs
|
||||
const prs: IPr[] = [];
|
||||
for (const ex of workoutSession.exercises) {
|
||||
const exercise = getExerciseById(ex.exerciseId);
|
||||
const metrics = getExerciseMetrics(exercise);
|
||||
if (metrics.includes('distance')) continue;
|
||||
|
||||
const completedSets = ex.sets.filter(s => s.completed);
|
||||
if (completedSets.length === 0) continue;
|
||||
|
||||
// Find previous best (sessions before this one)
|
||||
const prevSessions = await WorkoutSession.find({
|
||||
createdBy: session.user!.nickname,
|
||||
'exercises.exerciseId': ex.exerciseId,
|
||||
startTime: { $lt: workoutSession.startTime }
|
||||
}).sort({ startTime: -1 }).limit(50).lean();
|
||||
|
||||
let prevBestWeight = 0;
|
||||
let prevBestEst1rm = 0;
|
||||
for (const ps of prevSessions) {
|
||||
const pe = ps.exercises.find(e => e.exerciseId === ex.exerciseId);
|
||||
if (!pe) continue;
|
||||
for (const s of pe.sets) {
|
||||
if (!s.completed || !s.weight || !s.reps) continue;
|
||||
prevBestWeight = Math.max(prevBestWeight, s.weight);
|
||||
prevBestEst1rm = Math.max(prevBestEst1rm, estimatedOneRepMax(s.weight, s.reps));
|
||||
}
|
||||
}
|
||||
|
||||
let bestWeight = 0;
|
||||
let bestEst1rm = 0;
|
||||
for (const s of completedSets) {
|
||||
if (!s.weight || !s.reps) continue;
|
||||
bestWeight = Math.max(bestWeight, s.weight);
|
||||
bestEst1rm = Math.max(bestEst1rm, estimatedOneRepMax(s.weight, s.reps));
|
||||
}
|
||||
|
||||
if (bestWeight > prevBestWeight && prevBestWeight > 0) {
|
||||
prs.push({ exerciseId: ex.exerciseId, type: 'maxWeight', value: bestWeight });
|
||||
}
|
||||
if (bestEst1rm > prevBestEst1rm && prevBestEst1rm > 0) {
|
||||
prs.push({ exerciseId: ex.exerciseId, type: 'est1rm', value: bestEst1rm });
|
||||
}
|
||||
}
|
||||
|
||||
workoutSession.totalVolume = totalVolume > 0 ? totalVolume : undefined;
|
||||
workoutSession.totalDistance = totalDistance > 0 ? totalDistance : undefined;
|
||||
workoutSession.prs = prs.length > 0 ? prs : undefined;
|
||||
await workoutSession.save();
|
||||
|
||||
return json({
|
||||
totalVolume: workoutSession.totalVolume,
|
||||
totalDistance: workoutSession.totalDistance,
|
||||
prs: workoutSession.prs?.length ?? 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error recalculating session:', error);
|
||||
return json({ error: 'Failed to recalculate' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user