fitness: add per-exercise metrics, cardio support, and stats page
CI / update (push) Successful in 2m0s
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:
@@ -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]) => ({
|
||||
|
||||
+28
-14
@@ -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
|
||||
});
|
||||
Reference in New Issue
Block a user