fitness: add per-exercise metrics, cardio support, and stats page
CI / update (push) Successful in 2m0s

- Add metrics system (weight/reps/rpe/distance/duration) per exercise type
  so cardio exercises show distance+duration instead of weight+reps
- Add 8 new cardio exercises: swimming, hiking, rowing outdoor, cycling
  outdoor, elliptical, stair climber, jump rope, walking
- Add bilateral flag to dumbbell exercises for accurate tonnage calculation
- Make SetTable, SessionCard, history detail, template editor, and exercise
  stats API all render/compute dynamically based on exercise metrics
- Rename Profile to Stats with lifetime cards: workouts, tonnage, cardio km
- Move route /fitness/profile -> /fitness/stats, API /stats/profile -> /stats/overview
This commit is contained in:
2026-03-19 18:57:49 +01:00
parent 14da4064a5
commit 828d4a83b0
16 changed files with 588 additions and 272 deletions
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { getExerciseById } from '$lib/data/exercises';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession';
@@ -31,12 +31,50 @@ export const GET: RequestHandler = async ({ params, locals }) => {
.sort({ startTime: 1 })
.lean();
// Build time-series and records data
const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance');
if (isCardio) {
// Cardio stats: distance and duration over time
const distanceOverTime: { date: Date; value: number }[] = [];
const durationOverTime: { date: Date; value: number }[] = [];
let bestDistance = 0;
let bestDuration = 0;
for (const session of sessions) {
const exerciseData = session.exercises.find((e) => e.exerciseId === params.id);
if (!exerciseData) continue;
const completedSets = exerciseData.sets.filter((s) => s.completed);
if (completedSets.length === 0) continue;
let sessionDistance = 0;
let sessionDuration = 0;
for (const set of completedSets) {
sessionDistance += set.distance ?? 0;
sessionDuration += set.duration ?? 0;
}
if (sessionDistance > 0) distanceOverTime.push({ date: session.startTime, value: sessionDistance });
if (sessionDuration > 0) durationOverTime.push({ date: session.startTime, value: sessionDuration });
bestDistance = Math.max(bestDistance, sessionDistance);
bestDuration = Math.max(bestDuration, sessionDuration);
}
return json({
charts: { distanceOverTime, durationOverTime },
personalRecords: { bestDistance, bestDuration },
records: [],
totalSessions: sessions.length
});
}
// Strength stats
const est1rmOverTime: { date: Date; value: number }[] = [];
const maxWeightOverTime: { date: Date; value: number }[] = [];
const totalVolumeOverTime: { date: Date; value: number }[] = [];
// Track best performance at each rep count: { reps -> { weight, date, estimated1rm } }
const repRecords = new Map<
number,
{ weight: number; reps: number; date: Date; estimated1rm: number }
@@ -49,24 +87,22 @@ export const GET: RequestHandler = async ({ params, locals }) => {
const exerciseData = session.exercises.find((e) => e.exerciseId === params.id);
if (!exerciseData) continue;
const completedSets = exerciseData.sets.filter((s) => s.completed && s.weight && s.reps > 0);
const completedSets = exerciseData.sets.filter((s) => s.completed && s.weight && s.reps && s.reps > 0);
if (completedSets.length === 0) continue;
// Best set est. 1RM for this session
let sessionBestEst1rm = 0;
let sessionMaxWeight = 0;
let sessionVolume = 0;
for (const set of completedSets) {
const weight = set.weight!;
const reps = set.reps;
const reps = set.reps!;
const est1rm = estimatedOneRepMax(weight, reps);
sessionBestEst1rm = Math.max(sessionBestEst1rm, est1rm);
sessionMaxWeight = Math.max(sessionMaxWeight, weight);
sessionVolume += weight * reps;
// Update rep records
const existing = repRecords.get(reps);
if (!existing || weight > existing.weight) {
repRecords.set(reps, {
@@ -87,7 +123,6 @@ export const GET: RequestHandler = async ({ params, locals }) => {
bestMaxVolume = Math.max(bestMaxVolume, sessionVolume);
}
// Convert rep records to sorted array
const records = [...repRecords.entries()]
.sort((a, b) => a[0] - b[0])
.map(([reps, data]) => ({
@@ -4,26 +4,17 @@ import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession';
import { BodyMeasurement } from '$models/BodyMeasurement';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
export const GET: RequestHandler = async ({ locals }) => {
console.time('[stats/profile] total');
console.time('[stats/profile] auth');
const user = await requireAuth(locals);
console.timeEnd('[stats/profile] auth');
console.time('[stats/profile] dbConnect');
await dbConnect();
console.timeEnd('[stats/profile] dbConnect');
const tenWeeksAgo = new Date();
tenWeeksAgo.setDate(tenWeeksAgo.getDate() - 70);
console.time('[stats/profile] countDocuments');
const totalWorkouts = await WorkoutSession.countDocuments({ createdBy: user.nickname });
console.timeEnd('[stats/profile] countDocuments');
console.time('[stats/profile] aggregate');
const weeklyAgg = await WorkoutSession.aggregate([
{
$match: {
@@ -44,9 +35,32 @@ export const GET: RequestHandler = async ({ locals }) => {
$sort: { '_id.year': 1, '_id.week': 1 }
}
]);
console.timeEnd('[stats/profile] aggregate');
console.time('[stats/profile] measurements');
// Lifetime totals: tonnage lifted + cardio km
const allSessions = await WorkoutSession.find(
{ createdBy: user.nickname },
{ 'exercises.exerciseId': 1, 'exercises.sets': 1 }
).lean();
let totalTonnage = 0;
let totalCardioKm = 0;
for (const s of allSessions) {
for (const ex of s.exercises) {
const exercise = getExerciseById(ex.exerciseId);
const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance');
const weightMultiplier = exercise?.bilateral ? 2 : 1;
for (const set of ex.sets) {
if (!set.completed) continue;
if (isCardio) {
totalCardioKm += set.distance ?? 0;
} else {
totalTonnage += (set.weight ?? 0) * weightMultiplier * (set.reps ?? 0);
}
}
}
}
const weightMeasurements = await BodyMeasurement.find(
{ createdBy: user.nickname, weight: { $ne: null } },
{ date: 1, weight: 1, _id: 0 }
@@ -54,7 +68,6 @@ export const GET: RequestHandler = async ({ locals }) => {
.sort({ date: 1 })
.limit(30)
.lean();
console.timeEnd('[stats/profile] measurements');
// Build chart-ready workouts-per-week with filled gaps
const weekMap = new Map<string, number>();
@@ -124,9 +137,10 @@ export const GET: RequestHandler = async ({ locals }) => {
}
}
console.timeEnd('[stats/profile] total');
return json({
totalWorkouts,
totalTonnage: Math.round(totalTonnage / 1000 * 10) / 10,
totalCardioKm: Math.round(totalCardioKm * 10) / 10,
workoutsChart,
weightChart
});