fitness: fix GPS preview aspect ratio, theme-reactive colors, and UI polish
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
This commit is contained in:
2026-03-20 14:59:30 +01:00
parent fe7c9ab2fe
commit 2ba08c51c0
8 changed files with 556 additions and 26 deletions

View File

@@ -1,6 +1,6 @@
<script> <script>
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises'; import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { Clock, Weight, Trophy } from 'lucide-svelte'; import { Clock, Weight, Trophy, Route, Gauge } from 'lucide-svelte';
/** /**
* @type {{ * @type {{
@@ -10,9 +10,12 @@
* startTime: string, * startTime: string,
* duration?: number, * duration?: number,
* totalVolume?: number, * totalVolume?: number,
* totalDistance?: number,
* prs?: Array<any>, * prs?: Array<any>,
* exercises: Array<{ * exercises: Array<{
* exerciseId: string, * exerciseId: string,
* totalDistance?: number,
* gpsPreview?: number[][],
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: number }> * sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: number }>
* }> * }>
* } * }
@@ -40,6 +43,13 @@
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
} }
/** @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`;
}
/** /**
* @param {Array<Record<string, any>>} sets * @param {Array<Record<string, any>>} sets
* @param {string} exerciseId * @param {string} exerciseId
@@ -57,7 +67,6 @@
const parts = []; const parts = [];
if (best.distance) parts.push(`${best.distance} km`); if (best.distance) parts.push(`${best.distance} km`);
if (best.duration) parts.push(`${best.duration} min`); if (best.duration) parts.push(`${best.duration} min`);
if (best.rpe) parts.push(`@ ${best.rpe}`);
return parts.join(' · ') || null; return parts.join(' · ') || null;
} }
@@ -71,6 +80,62 @@
if (best.rpe) label += ` @ ${best.rpe}`; if (best.rpe) label += ` @ ${best.rpe}`;
return label; return label;
} }
/** Find first GPS preview from cardio exercises */
const gpsPreview = $derived.by(() => {
for (const ex of session.exercises) {
if (ex.gpsPreview && ex.gpsPreview.length >= 2) return ex.gpsPreview;
}
return null;
});
/** Build SVG polyline with cosine-corrected coordinates */
const svgData = $derived.by(() => {
if (!gpsPreview) return null;
const lats = gpsPreview.map(p => p[0]);
const lngs = gpsPreview.map(p => p[1]);
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs);
const cosLat = Math.cos(((minLat + maxLat) / 2) * Math.PI / 180);
const geoW = (maxLng - minLng) * cosLat || 0.001;
const geoH = maxLat - minLat || 0.001;
const pad = Math.max(geoW, geoH) * 0.1;
const vbW = geoW + pad * 2;
const vbH = geoH + pad * 2;
const points = gpsPreview.map(p => {
const x = (p[1] - minLng) * cosLat + pad;
const y = (maxLat - p[0]) + pad;
return `${x},${y}`;
}).join(' ');
return { points, viewBox: `0 0 ${vbW} ${vbH}` };
});
/** Check if this session has any cardio exercise with GPS data */
const hasGpsCardio = $derived(session.exercises.some(ex => {
const exercise = getExerciseById(ex.exerciseId);
return exercise?.bodyPart === 'cardio' && ex.totalDistance;
}));
/** Get cardio summary: total distance and average pace */
const cardioSummary = $derived.by(() => {
let dist = 0;
let dur = 0;
for (const ex of session.exercises) {
const exercise = getExerciseById(ex.exerciseId);
if (exercise?.bodyPart !== 'cardio') continue;
if (ex.totalDistance) {
dist += ex.totalDistance;
}
for (const s of ex.sets) {
if (s.distance) dist += 0; // already counted via totalDistance
if (s.duration) dur += s.duration;
}
}
// Use session-level totalDistance if available
if (session.totalDistance) dist = session.totalDistance;
const pace = dist > 0 && dur > 0 ? dur / dist : 0;
return { distance: dist, duration: dur, pace };
});
</script> </script>
<a href="/fitness/history/{session._id}" class="session-card"> <a href="/fitness/history/{session._id}" class="session-card">
@@ -79,8 +144,24 @@
<span class="session-date">{formatDate(session.startTime)} &middot; {formatTime(session.startTime)}</span> <span class="session-date">{formatDate(session.startTime)} &middot; {formatTime(session.startTime)}</span>
</div> </div>
{#if svgData}
<div class="map-preview">
<svg viewBox={svgData.viewBox} preserveAspectRatio="xMidYMid meet">
<polyline
points={svgData.points}
fill="none"
stroke="var(--color-primary)"
stroke-width="2.5"
stroke-linejoin="round"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
</div>
{/if}
<div class="exercise-list"> <div class="exercise-list">
{#each session.exercises.slice(0, 4) as ex (ex.exerciseId)} {#each session.exercises as ex (ex.exerciseId)}
{@const exercise = getExerciseById(ex.exerciseId)} {@const exercise = getExerciseById(ex.exerciseId)}
{@const label = bestSetLabel(ex.sets, ex.exerciseId)} {@const label = bestSetLabel(ex.sets, ex.exerciseId)}
<div class="exercise-row"> <div class="exercise-row">
@@ -90,17 +171,19 @@
{/if} {/if}
</div> </div>
{/each} {/each}
{#if session.exercises.length > 4}
<div class="exercise-row more">+{session.exercises.length - 4} more exercises</div>
{/if}
</div> </div>
<div class="card-footer"> <div class="card-footer">
{#if session.duration} {#if session.duration}
<span class="stat"><Clock size={14} /> {formatDuration(session.duration)}</span> <span class="stat"><Clock size={14} /> {formatDuration(session.duration)}</span>
{/if} {/if}
{#if session.totalVolume} {#if hasGpsCardio && cardioSummary.distance > 0}
<span class="stat"><Weight size={14} /> {Math.round(session.totalVolume).toLocaleString()} kg</span> <span class="stat accent"><Route size={14} /> {cardioSummary.distance.toFixed(1)} km</span>
{#if cardioSummary.pace > 0}
<span class="stat accent"><Gauge size={14} /> {formatPace(cardioSummary.pace)}</span>
{/if}
{:else if session.totalVolume}
<span class="stat"><Weight size={14} /> {session.totalVolume >= 1000 ? `${(session.totalVolume / 1000).toFixed(1)}t` : `${Math.round(session.totalVolume).toLocaleString()} kg`}</span>
{/if} {/if}
{#if session.prs && session.prs.length > 0} {#if session.prs && session.prs.length > 0}
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span> <span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
@@ -139,6 +222,17 @@
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.map-preview {
height: 80px;
margin-bottom: 0.5rem;
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
border-radius: 6px;
overflow: hidden;
}
.map-preview svg {
width: 100%;
height: 100%;
}
.exercise-list { .exercise-list {
font-size: 0.8rem; font-size: 0.8rem;
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
@@ -155,10 +249,6 @@
font-weight: 600; font-weight: 600;
font-size: 0.78rem; font-size: 0.78rem;
} }
.more {
color: var(--color-primary);
font-style: italic;
}
.card-footer { .card-footer {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -166,12 +256,17 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
padding-top: 0.5rem; padding-top: 0.5rem;
flex-wrap: wrap;
} }
.stat { .stat {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
} }
.stat.accent {
color: var(--color-primary);
font-weight: 700;
}
.stat.pr { .stat.pr {
color: var(--nord13); color: var(--nord13);
} }

View File

@@ -0,0 +1,103 @@
/**
* Ramer-Douglas-Peucker line simplification.
* Reduces a GPS track to a visually faithful preview with far fewer points.
* Preserves curves and corners, drops redundant points on straights.
*/
interface Point {
lat: number;
lng: number;
}
/** Perpendicular distance from point P to line segment A-B (in degrees, good enough for simplification) */
function perpendicularDistance(p: Point, a: Point, b: Point): number {
const dx = b.lng - a.lng;
const dy = b.lat - a.lat;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) {
// A and B are the same point
const ex = p.lng - a.lng;
const ey = p.lat - a.lat;
return Math.sqrt(ex * ex + ey * ey);
}
const t = Math.max(0, Math.min(1, ((p.lng - a.lng) * dx + (p.lat - a.lat) * dy) / lenSq));
const projLng = a.lng + t * dx;
const projLat = a.lat + t * dy;
const ex = p.lng - projLng;
const ey = p.lat - projLat;
return Math.sqrt(ex * ex + ey * ey);
}
function rdpSimplify(points: Point[], epsilon: number): Point[] {
if (points.length <= 2) return points;
let maxDist = 0;
let maxIdx = 0;
const first = points[0];
const last = points[points.length - 1];
for (let i = 1; i < points.length - 1; i++) {
const d = perpendicularDistance(points[i], first, last);
if (d > maxDist) {
maxDist = d;
maxIdx = i;
}
}
if (maxDist > epsilon) {
const left = rdpSimplify(points.slice(0, maxIdx + 1), epsilon);
const right = rdpSimplify(points.slice(maxIdx), epsilon);
return [...left.slice(0, -1), ...right];
}
return [first, last];
}
/**
* Simplify a GPS track to a preview polyline.
* @param track - Full track with lat/lng fields
* @param maxPoints - Target maximum number of points (default 30)
* @returns Array of [lat, lng] pairs
*/
export function simplifyTrack(track: Array<{ lat: number; lng: number }>, maxPoints = 30): number[][] {
if (track.length <= maxPoints) {
return track.map(p => [p.lat, p.lng]);
}
// Start with a generous epsilon, tighten until we're under maxPoints
// Estimate initial epsilon from bounding box
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const p of track) {
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
if (p.lng < minLng) minLng = p.lng;
if (p.lng > maxLng) maxLng = p.lng;
}
const extent = Math.max(maxLat - minLat, maxLng - minLng);
// Binary search for the right epsilon
let lo = 0;
let hi = extent * 0.1;
let result = rdpSimplify(track, hi);
// If even max epsilon gives too many points, increase hi
while (result.length > maxPoints && hi < extent) {
hi *= 2;
result = rdpSimplify(track, hi);
}
// Binary search between lo and hi
for (let iter = 0; iter < 20; iter++) {
const mid = (lo + hi) / 2;
result = rdpSimplify(track, mid);
if (result.length > maxPoints) {
lo = mid;
} else {
hi = mid;
}
// Close enough
if (result.length >= maxPoints - 5 && result.length <= maxPoints) break;
}
return result.map(p => [p.lat, p.lng]);
}

View File

@@ -25,9 +25,17 @@ export interface ICompletedExercise {
restTime?: number; restTime?: number;
notes?: string; notes?: string;
gpsTrack?: IGpsPoint[]; gpsTrack?: IGpsPoint[];
gpsPreview?: number[][]; // downsampled [[lat,lng], ...] for card preview
totalDistance?: number; // km totalDistance?: number; // km
} }
export interface IPr {
exerciseId: string;
type: string; // 'est1rm' | 'maxWeight' | 'repMax'
value: number;
reps?: number;
}
export interface IWorkoutSession { export interface IWorkoutSession {
_id?: string; _id?: string;
templateId?: string; // Reference to WorkoutTemplate if based on template templateId?: string; // Reference to WorkoutTemplate if based on template
@@ -37,6 +45,9 @@ export interface IWorkoutSession {
startTime: Date; startTime: Date;
endTime?: Date; endTime?: Date;
duration?: number; // Duration in minutes duration?: number; // Duration in minutes
totalVolume?: number; // Total weight × reps across all exercises
totalDistance?: number; // Total distance across all cardio exercises
prs?: IPr[];
notes?: string; notes?: string;
createdBy: string; // username/nickname of the person who performed the workout createdBy: string; // username/nickname of the person who performed the workout
createdAt?: Date; createdAt?: Date;
@@ -118,6 +129,10 @@ const CompletedExerciseSchema = new mongoose.Schema({
type: [GpsPointSchema], type: [GpsPointSchema],
default: undefined default: undefined
}, },
gpsPreview: {
type: [[Number]],
default: undefined
},
totalDistance: { totalDistance: {
type: Number, type: Number,
min: 0 min: 0
@@ -162,6 +177,21 @@ const WorkoutSessionSchema = new mongoose.Schema(
type: Number, // in minutes type: Number, // in minutes
min: 0 min: 0
}, },
totalVolume: {
type: Number,
min: 0
},
totalDistance: {
type: Number,
min: 0
},
prs: [{
exerciseId: { type: String, required: true },
type: { type: String, required: true },
value: { type: Number, required: true },
reps: Number,
_id: false
}],
notes: { notes: {
type: String, type: String,
trim: true, trim: true,

View File

@@ -2,7 +2,15 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db'; import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession'; import { WorkoutSession } from '$models/WorkoutSession';
import type { IPr } from '$models/WorkoutSession';
import { WorkoutTemplate } from '$models/WorkoutTemplate'; 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 // GET /api/fitness/sessions - Get all workout sessions for the user
export const GET: RequestHandler = async ({ url, locals }) => { 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 offset = parseInt(url.searchParams.get('offset') || '0');
const sessions = await WorkoutSession.find({ createdBy: session.user.nickname }) const sessions = await WorkoutSession.find({ createdBy: session.user.nickname })
.select('-exercises.gpsTrack')
.sort({ startTime: -1 }) .sort({ startTime: -1 })
.limit(limit) .limit(limit)
.skip(offset); .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({ const workoutSession = new WorkoutSession({
templateId, templateId,
templateName, templateName,
@@ -64,6 +136,9 @@ export const POST: RequestHandler = async ({ request, locals }) => {
startTime: startTime ? new Date(startTime) : new Date(), startTime: startTime ? new Date(startTime) : new Date(),
endTime: endTime ? new Date(endTime) : undefined, endTime: endTime ? new Date(endTime) : undefined,
duration: endTime && startTime ? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / (1000 * 60)) : 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, notes,
createdBy: session.user.nickname createdBy: session.user.nickname
}); });

View File

@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db'; import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession'; import { WorkoutSession } from '$models/WorkoutSession';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import mongoose from 'mongoose'; import mongoose from 'mongoose';
// GET /api/fitness/sessions/[id] - Get a specific workout session // 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> = {}; const updateData: Record<string, unknown> = {};
if (name) updateData.name = name; 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 (startTime) updateData.startTime = new Date(startTime);
if (endTime) updateData.endTime = new Date(endTime); if (endTime) updateData.endTime = new Date(endTime);
if (duration !== undefined) updateData.duration = duration; if (duration !== undefined) updateData.duration = duration;

View File

@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db'; import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession'; import { WorkoutSession } from '$models/WorkoutSession';
import type { IGpsPoint } from '$models/WorkoutSession'; import type { IGpsPoint } from '$models/WorkoutSession';
import { simplifyTrack } from '$lib/server/simplifyTrack';
import mongoose from 'mongoose'; import mongoose from 'mongoose';
/** Haversine distance in km between two points */ /** 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); const durationMin = Math.round((track[track.length - 1].timestamp - track[0].timestamp) / 60000);
workoutSession.exercises[exerciseIdx].gpsTrack = track; workoutSession.exercises[exerciseIdx].gpsTrack = track;
workoutSession.exercises[exerciseIdx].gpsPreview = simplifyTrack(track);
workoutSession.exercises[exerciseIdx].totalDistance = Math.round(distance * 1000) / 1000; workoutSession.exercises[exerciseIdx].totalDistance = Math.round(distance * 1000) / 1000;
// Auto-fill distance and duration on a single set // 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].gpsTrack = undefined;
workoutSession.exercises[exerciseIdx].gpsPreview = undefined;
workoutSession.exercises[exerciseIdx].totalDistance = undefined; workoutSession.exercises[exerciseIdx].totalDistance = undefined;
await workoutSession.save(); await workoutSession.save();

View File

@@ -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 });
}
};

View File

@@ -1,6 +1,6 @@
<script> <script>
import { goto, invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X } from 'lucide-svelte'; import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X, RefreshCw, Gauge } from 'lucide-svelte';
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises'; import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte'; import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import SetTable from '$lib/components/fitness/SetTable.svelte'; import SetTable from '$lib/components/fitness/SetTable.svelte';
@@ -11,9 +11,29 @@
let { data } = $props(); let { data } = $props();
const session = $derived(data.session); const session = $derived(data.session);
function checkDark() {
if (typeof document === 'undefined') return false;
const t = document.documentElement.dataset.theme;
if (t === 'dark') return true;
if (t === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
let dark = $state(checkDark());
onMount(() => {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const onMql = () => { dark = checkDark(); };
mql.addEventListener('change', onMql);
const obs = new MutationObserver(() => { dark = checkDark(); });
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => { mql.removeEventListener('change', onMql); obs.disconnect(); };
});
let deleting = $state(false); let deleting = $state(false);
let editing = $state(false); let editing = $state(false);
let saving = $state(false); let saving = $state(false);
let recalculating = $state(false);
let showPicker = $state(false); let showPicker = $state(false);
/** @type {any} */ /** @type {any} */
@@ -157,6 +177,17 @@
return Math.round(weight * (1 + reps / 30)); return Math.round(weight * (1 + reps / 30));
} }
async function recalculate() {
recalculating = true;
try {
const res = await fetch(`/api/fitness/sessions/${session._id}/recalculate`, { method: 'POST' });
if (res.ok) {
await invalidateAll();
}
} catch {}
recalculating = false;
}
async function deleteSession() { async function deleteSession() {
if (!confirm('Delete this workout session?')) return; if (!confirm('Delete this workout session?')) return;
deleting = true; deleting = true;
@@ -238,7 +269,7 @@
const map = L.map(node, { attributionControl: false, zoomControl: false }); const map = L.map(node, { attributionControl: false, zoomControl: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map); 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 pts = track.map((/** @type {any} */ p) => /** @type {[number, number]} */ ([p.lat, p.lng]));
const polyline = L.polyline(pts, { color: '#88c0d0', weight: 3 }).addTo(map); const polyline = L.polyline(pts, { color: '#5e81ac', weight: 3 }).addTo(map);
// Start/end markers // Start/end markers
L.circleMarker(pts[0], { radius: 5, fillColor: '#a3be8c', fillOpacity: 1, color: '#fff', weight: 2 }).addTo(map); 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); L.circleMarker(pts[pts.length - 1], { radius: 5, fillColor: '#bf616a', fillOpacity: 1, color: '#fff', weight: 2 }).addTo(map);
@@ -337,13 +368,15 @@
// Downsample to ~50 points for readability // Downsample to ~50 points for readability
const step = Math.max(1, Math.floor(samples.length / 50)); const step = Math.max(1, Math.floor(samples.length / 50));
const filtered = samples.filter((_, i) => i % step === 0 || i === samples.length - 1); const filtered = samples.filter((_, i) => i % step === 0 || i === samples.length - 1);
const primary = dark ? '#88C0D0' : '#5E81AC';
const fill = dark ? 'rgba(136, 192, 208, 0.12)' : 'rgba(94, 129, 172, 0.12)';
return { return {
labels: filtered.map(s => s.dist.toFixed(2)), labels: filtered.map(s => s.dist.toFixed(2)),
datasets: [{ datasets: [{
label: 'Pace', label: 'Pace',
data: filtered.map(s => s.pace), data: filtered.map(s => s.pace),
borderColor: '#88C0D0', borderColor: primary,
backgroundColor: 'rgba(136, 192, 208, 0.12)', backgroundColor: fill,
borderWidth: 1.5, borderWidth: 1.5,
pointRadius: 0, pointRadius: 0,
tension: 0.3, tension: 0.3,
@@ -406,6 +439,9 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#if editing} {#if editing}
<button class="recalc-btn" onclick={recalculate} disabled={recalculating} title="Recalculate volume, PRs, and GPS previews">
<RefreshCw size={14} class={recalculating ? 'spinning' : ''} />
</button>
<button class="save-btn" onclick={saveEdit} disabled={saving}> <button class="save-btn" onclick={saveEdit} disabled={saving}>
{saving ? 'SAVING...' : 'SAVE'} {saving ? 'SAVING...' : 'SAVE'}
</button> </button>
@@ -477,6 +513,20 @@
</button> </button>
</div> </div>
{#if session.exercises[exIdx]?.gpsTrack?.length || session.exercises[exIdx]?.gpsPreview?.length}
{@const exData = session.exercises[exIdx]}
<div class="gps-indicator">
<Route size={14} />
<span>GPS track stored{exData.totalDistance ? ` · ${exData.totalDistance.toFixed(2)} km` : ''}</span>
<button class="gpx-remove-btn" onclick={() => removeGpx(exIdx)} aria-label="Remove GPS track">
<X size={14} />
</button>
</div>
{#if exData.gpsTrack?.length >= 2}
<div class="track-map" use:renderMap={{ track: exData.gpsTrack, idx: exIdx }}></div>
{/if}
{/if}
<SetTable <SetTable
sets={ex.sets} sets={ex.sets}
metrics={getExerciseMetrics(getExerciseById(ex.exerciseId))} metrics={getExerciseMetrics(getExerciseById(ex.exerciseId))}
@@ -542,14 +592,10 @@
{@const pace = dist > 0 && elapsed > 0 ? elapsed / dist : 0} {@const pace = dist > 0 && elapsed > 0 ? elapsed / dist : 0}
<div class="gps-track-section"> <div class="gps-track-section">
<div class="gps-stats"> <div class="gps-stats">
<span class="gps-stat"><Route size={14} /> {dist.toFixed(2)} km</span> <span class="gps-stat accent"><Route size={14} /> {dist.toFixed(2)} km</span>
{#if pace > 0} {#if pace > 0}
<span class="gps-stat">{formatPace(pace)} avg</span> <span class="gps-stat accent"><Gauge size={14} /> {formatPace(pace)}</span>
{/if} {/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>
<div class="track-map" use:renderMap={{ track: ex.gpsTrack, idx: exIdx }}></div> <div class="track-map" use:renderMap={{ track: ex.gpsTrack, idx: exIdx }}></div>
@@ -684,6 +730,30 @@
border-color: var(--color-primary); border-color: var(--color-primary);
color: var(--color-primary); color: var(--color-primary);
} }
.recalc-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0.4rem;
display: flex;
align-items: center;
}
.recalc-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.recalc-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.spinning) {
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.delete-btn { .delete-btn {
background: none; background: none;
border: 1px solid var(--nord11); border: 1px solid var(--nord11);
@@ -835,6 +905,17 @@
.remove-exercise:hover { .remove-exercise:hover {
opacity: 1; opacity: 1;
} }
.gps-indicator {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--color-primary);
padding: 0.3rem 0.5rem;
margin-bottom: 0.3rem;
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
border-radius: 6px;
}
.add-set-btn { .add-set-btn {
display: block; display: block;
width: 100%; width: 100%;
@@ -974,9 +1055,9 @@
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.gps-stat:first-child { .gps-stat.accent {
font-weight: 600; font-weight: 700;
color: var(--nord8); color: var(--color-primary);
} }
.gpx-remove-btn { .gpx-remove-btn {
margin-left: auto; margin-left: auto;