fitness: add complete fitness tracker frontend

- 5-tab layout (Profile, History, Workout, Exercises, Measure) with shared header nav
- Workout system: template CRUD, active workout on /fitness/workout/active with localStorage persistence, pause/resume timer, rest timer, RPE input
- Shared workout singleton (getWorkout) so active workout state is accessible across all fitness routes
- Floating workout FAB indicator on all /fitness routes when workout is active
- AddActionButton component for button-based FABs (measure + template creation)
- Profile page with workouts-per-week bar chart and weight line chart with SMA trend line + ±1σ confidence band
- Exercise detail with history, charts, and records tabs using static exercise data
- Session history with grouped-by-month list, session detail with stats/PRs
- Body measurements with latest values, body part display, add form
- Card styling matching rosary/prayer route patterns (accent-dark, nord5 light, box-shadow, hover lift)
- FitnessChart: fix SSR hang by moving Chart.register to client-side, remove redundant $effect
- Exercise API: use static in-repo data instead of empty MongoDB collection
- Workout finish: include exercise name for WorkoutSession model validation
This commit is contained in:
2026-03-19 08:17:51 +01:00
parent e427dc2d25
commit 1c62819d18
38 changed files with 5899 additions and 24 deletions
@@ -1,30 +1,18 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { Exercise } from '$models/Exercise';
import { getExerciseById } from '$lib/data/exercises';
// GET /api/fitness/exercises/[id] - Get detailed exercise information
// GET /api/fitness/exercises/[id] - Get exercise from static data
export const GET: RequestHandler = async ({ params, locals }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const session = await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
const exercise = await Exercise.findOne({
exerciseId: params.id,
isActive: true
});
const exercise = getExerciseById(params.id);
if (!exercise) {
return json({ error: 'Exercise not found' }, { status: 404 });
}
if (!exercise) {
return json({ error: 'Exercise not found' }, { status: 404 });
}
return json({ exercise });
} catch (error) {
console.error('Error fetching exercise details:', error);
return json({ error: 'Failed to fetch exercise details' }, { status: 500 });
}
};
return json({ exercise });
};
@@ -0,0 +1,48 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { getExerciseById } from '$lib/data/exercises';
import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession';
export const GET: RequestHandler = async ({ params, url, locals }) => {
const user = await requireAuth(locals);
const exercise = getExerciseById(params.id);
if (!exercise) {
return json({ error: 'Exercise not found' }, { status: 404 });
}
const limit = parseInt(url.searchParams.get('limit') || '20');
const offset = parseInt(url.searchParams.get('offset') || '0');
await dbConnect();
const sessions = await WorkoutSession.find({
createdBy: user.nickname,
'exercises.exerciseId': params.id
})
.sort({ startTime: -1 })
.skip(offset)
.limit(limit)
.lean();
// Extract only the relevant exercise data from each session
const history = sessions.map((session) => {
const exerciseData = session.exercises.find((e) => e.exerciseId === params.id);
return {
sessionId: session._id,
sessionName: session.name,
date: session.startTime,
sets: exerciseData?.sets ?? [],
notes: exerciseData?.notes
};
});
const total = await WorkoutSession.countDocuments({
createdBy: user.nickname,
'exercises.exerciseId': params.id
});
return json({ history, total, limit, offset });
};
@@ -0,0 +1,114 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { getExerciseById } from '$lib/data/exercises';
import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession';
/**
* Epley formula for estimated 1RM
*/
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) * 10) / 10;
}
export const GET: RequestHandler = async ({ params, locals }) => {
const user = await requireAuth(locals);
const exercise = getExerciseById(params.id);
if (!exercise) {
return json({ error: 'Exercise not found' }, { status: 404 });
}
await dbConnect();
const sessions = await WorkoutSession.find({
createdBy: user.nickname,
'exercises.exerciseId': params.id
})
.sort({ startTime: 1 })
.lean();
// Build time-series and records data
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 }
>();
let bestEst1rm = 0;
let bestMaxWeight = 0;
let bestMaxVolume = 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 && s.weight && 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 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, {
weight,
reps,
date: session.startTime,
estimated1rm: est1rm
});
}
}
est1rmOverTime.push({ date: session.startTime, value: sessionBestEst1rm });
maxWeightOverTime.push({ date: session.startTime, value: sessionMaxWeight });
totalVolumeOverTime.push({ date: session.startTime, value: sessionVolume });
bestEst1rm = Math.max(bestEst1rm, sessionBestEst1rm);
bestMaxWeight = Math.max(bestMaxWeight, sessionMaxWeight);
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]) => ({
reps,
weight: data.weight,
date: data.date,
estimated1rm: data.estimated1rm
}));
return json({
charts: {
est1rmOverTime,
maxWeightOverTime,
totalVolumeOverTime
},
personalRecords: {
estimatedOneRepMax: bestEst1rm,
maxWeight: bestMaxWeight,
maxVolume: bestMaxVolume
},
records,
totalSessions: sessions.length
});
};
@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { BodyMeasurement } from '$models/BodyMeasurement';
export const GET: RequestHandler = async ({ url, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const limit = parseInt(url.searchParams.get('limit') || '50');
const offset = parseInt(url.searchParams.get('offset') || '0');
const from = url.searchParams.get('from');
const to = url.searchParams.get('to');
const query: Record<string, unknown> = { createdBy: user.nickname };
if (from || to) {
const dateFilter: Record<string, Date> = {};
if (from) dateFilter.$gte = new Date(from);
if (to) dateFilter.$lte = new Date(to);
query.date = dateFilter;
}
const measurements = await BodyMeasurement.find(query)
.sort({ date: -1 })
.skip(offset)
.limit(limit)
.lean();
const total = await BodyMeasurement.countDocuments(query);
return json({ measurements, total, limit, offset });
};
export const POST: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const data = await request.json();
const { date, weight, bodyFatPercent, caloricIntake, measurements, notes } = data;
const measurement = new BodyMeasurement({
date: date ? new Date(date) : new Date(),
weight,
bodyFatPercent,
caloricIntake,
measurements,
notes,
createdBy: user.nickname
});
await measurement.save();
return json({ measurement }, { status: 201 });
};
@@ -0,0 +1,78 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { BodyMeasurement } from '$models/BodyMeasurement';
import mongoose from 'mongoose';
export const GET: RequestHandler = async ({ params, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid measurement ID' }, { status: 400 });
}
const measurement = await BodyMeasurement.findOne({
_id: params.id,
createdBy: user.nickname
});
if (!measurement) {
return json({ error: 'Measurement not found' }, { status: 404 });
}
return json({ measurement });
};
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid measurement ID' }, { status: 400 });
}
const data = await request.json();
const { date, weight, bodyFatPercent, caloricIntake, measurements, notes } = data;
const updateData: Record<string, unknown> = {};
if (date) updateData.date = new Date(date);
if (weight !== undefined) updateData.weight = weight;
if (bodyFatPercent !== undefined) updateData.bodyFatPercent = bodyFatPercent;
if (caloricIntake !== undefined) updateData.caloricIntake = caloricIntake;
if (measurements !== undefined) updateData.measurements = measurements;
if (notes !== undefined) updateData.notes = notes;
const measurement = await BodyMeasurement.findOneAndUpdate(
{ _id: params.id, createdBy: user.nickname },
updateData,
{ new: true }
);
if (!measurement) {
return json({ error: 'Measurement not found or unauthorized' }, { status: 404 });
}
return json({ measurement });
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
if (!mongoose.Types.ObjectId.isValid(params.id)) {
return json({ error: 'Invalid measurement ID' }, { status: 400 });
}
const measurement = await BodyMeasurement.findOneAndDelete({
_id: params.id,
createdBy: user.nickname
});
if (!measurement) {
return json({ error: 'Measurement not found or unauthorized' }, { status: 404 });
}
return json({ message: 'Measurement deleted successfully' });
};
@@ -0,0 +1,61 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { BodyMeasurement } from '$models/BodyMeasurement';
export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
await dbConnect();
// Get latest measurement that has each field
const latestWithWeight = await BodyMeasurement.findOne({
createdBy: user.nickname,
weight: { $exists: true, $ne: null }
})
.sort({ date: -1 })
.select('weight date')
.lean();
const latestWithBodyFat = await BodyMeasurement.findOne({
createdBy: user.nickname,
bodyFatPercent: { $exists: true, $ne: null }
})
.sort({ date: -1 })
.select('bodyFatPercent date')
.lean();
const latestWithCalories = await BodyMeasurement.findOne({
createdBy: user.nickname,
caloricIntake: { $exists: true, $ne: null }
})
.sort({ date: -1 })
.select('caloricIntake date')
.lean();
const latestWithMeasurements = await BodyMeasurement.findOne({
createdBy: user.nickname,
measurements: { $exists: true, $ne: null }
})
.sort({ date: -1 })
.select('measurements date')
.lean();
return json({
weight: latestWithWeight
? { value: latestWithWeight.weight, date: latestWithWeight.date }
: null,
bodyFatPercent: latestWithBodyFat
? { value: latestWithBodyFat.bodyFatPercent, date: latestWithBodyFat.date }
: null,
caloricIntake: latestWithCalories
? { value: latestWithCalories.caloricIntake, date: latestWithCalories.date }
: null,
measurements: latestWithMeasurements
? {
value: latestWithMeasurements.measurements,
date: latestWithMeasurements.date
}
: null
});
};
@@ -0,0 +1,138 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession';
import { BodyMeasurement } from '$models/BodyMeasurement';
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 twelveWeeksAgo = new Date();
twelveWeeksAgo.setDate(twelveWeeksAgo.getDate() - 84);
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: {
createdBy: user.nickname,
startTime: { $gte: twelveWeeksAgo }
}
},
{
$group: {
_id: {
year: { $isoWeekYear: '$startTime' },
week: { $isoWeek: '$startTime' }
},
count: { $sum: 1 }
}
},
{
$sort: { '_id.year': 1, '_id.week': 1 }
}
]);
console.timeEnd('[stats/profile] aggregate');
console.time('[stats/profile] measurements');
const weightMeasurements = await BodyMeasurement.find(
{ createdBy: user.nickname, weight: { $ne: null } },
{ date: 1, weight: 1, _id: 0 }
)
.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>();
for (const item of weeklyAgg) {
weekMap.set(`${item._id.year}-${item._id.week}`, item.count);
}
const workoutsChart: { labels: string[]; data: number[] } = { labels: [], data: [] };
const now = new Date();
for (let i = 11; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i * 7);
const year = getISOWeekYear(d);
const week = getISOWeek(d);
const key = `${year}-${week}`;
workoutsChart.labels.push(`W${week}`);
workoutsChart.data.push(weekMap.get(key) ?? 0);
}
// Build chart-ready weight data with SMA ± 1 std dev confidence band
const weightChart: {
labels: string[];
data: number[];
sma: (number | null)[];
upper: (number | null)[];
lower: (number | null)[];
} = { labels: [], data: [], sma: [], upper: [], lower: [] };
const weights: number[] = [];
for (const m of weightMeasurements) {
const d = new Date(m.date);
weightChart.labels.push(
d.toLocaleDateString('en', { month: 'short', day: 'numeric' })
);
weightChart.data.push(m.weight);
weights.push(m.weight);
}
// Adaptive window: 7 if enough data, otherwise half the data (min 2)
const w = Math.min(7, Math.max(2, Math.floor(weights.length / 2)));
for (let i = 0; i < weights.length; i++) {
if (i < w - 1) {
weightChart.sma.push(null);
weightChart.upper.push(null);
weightChart.lower.push(null);
} else {
let sum = 0;
for (let j = i - w + 1; j <= i; j++) sum += weights[j];
const mean = sum / w;
let variance = 0;
for (let j = i - w + 1; j <= i; j++) variance += (weights[j] - mean) ** 2;
const std = Math.sqrt(variance / w);
const round = (v: number) => Math.round(v * 100) / 100;
weightChart.sma.push(round(mean));
weightChart.upper.push(round(mean + std));
weightChart.lower.push(round(mean - std));
}
}
console.timeEnd('[stats/profile] total');
return json({
totalWorkouts,
workoutsChart,
weightChart
});
};
function getISOWeek(date: Date): number {
const d = new Date(date.getTime());
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
const week1 = new Date(d.getFullYear(), 0, 4);
return 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7);
}
function getISOWeekYear(date: Date): number {
const d = new Date(date.getTime());
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
return d.getFullYear();
}
@@ -0,0 +1,163 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { WorkoutTemplate } from '$models/WorkoutTemplate';
const defaultTemplates = [
{
name: 'Day 1 - Pull',
description: 'Back and biceps focused pull day',
exercises: [
{
exerciseId: 'bent-over-row-barbell',
sets: [
{ reps: 10, weight: 60, rpe: 7 },
{ reps: 10, weight: 60, rpe: 8 },
{ reps: 10, weight: 60, rpe: 9 }
],
restTime: 120
},
{
exerciseId: 'pull-up',
sets: [
{ reps: 6, rpe: 8 },
{ reps: 6, rpe: 8 },
{ reps: 6, rpe: 9 }
],
restTime: 120
},
{
exerciseId: 'lateral-raise-dumbbell',
sets: [
{ reps: 15, weight: 10, rpe: 7 },
{ reps: 15, weight: 10, rpe: 8 }
],
restTime: 90
},
{
exerciseId: 'front-raise-dumbbell',
sets: [
{ reps: 10, weight: 10, rpe: 7 },
{ reps: 10, weight: 10, rpe: 8 }
],
restTime: 90
}
]
},
{
name: 'Day 2 - Push',
description: 'Chest, shoulders, and triceps push day',
exercises: [
{
exerciseId: 'bench-press-barbell',
sets: [
{ reps: 8, weight: 80, rpe: 7 },
{ reps: 8, weight: 80, rpe: 8 },
{ reps: 8, weight: 80, rpe: 9 }
],
restTime: 120
},
{
exerciseId: 'incline-bench-press-barbell',
sets: [
{ reps: 10, weight: 60, rpe: 7 },
{ reps: 10, weight: 60, rpe: 8 }
],
restTime: 120
},
{
exerciseId: 'skullcrusher-dumbbell',
sets: [
{ reps: 15, weight: 15, rpe: 7 },
{ reps: 15, weight: 15, rpe: 8 }
],
restTime: 90
},
{
exerciseId: 'hammer-curl-dumbbell',
sets: [
{ reps: 15, weight: 12, rpe: 7 },
{ reps: 15, weight: 12, rpe: 8 }
],
restTime: 90
},
{
exerciseId: 'bicep-curl-dumbbell',
sets: [
{ reps: 15, weight: 10, rpe: 7 },
{ reps: 15, weight: 10, rpe: 8 }
],
restTime: 90
}
]
},
{
name: 'Day 3 - Legs',
description: 'Lower body leg day',
exercises: [
{
exerciseId: 'squat-barbell',
sets: [
{ reps: 8, weight: 80, rpe: 7 },
{ reps: 8, weight: 80, rpe: 8 },
{ reps: 8, weight: 80, rpe: 9 }
],
restTime: 150
},
{
exerciseId: 'romanian-deadlift-barbell',
sets: [
{ reps: 10, weight: 70, rpe: 7 },
{ reps: 10, weight: 70, rpe: 8 }
],
restTime: 120
},
{
exerciseId: 'leg-press-machine',
sets: [
{ reps: 12, weight: 100, rpe: 7 },
{ reps: 12, weight: 100, rpe: 8 }
],
restTime: 120
},
{
exerciseId: 'leg-curl-machine',
sets: [
{ reps: 12, weight: 40, rpe: 7 },
{ reps: 12, weight: 40, rpe: 8 }
],
restTime: 90
},
{
exerciseId: 'calf-raise-machine',
sets: [
{ reps: 15, weight: 60, rpe: 7 },
{ reps: 15, weight: 60, rpe: 8 }
],
restTime: 60
}
]
}
];
export const POST: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
await dbConnect();
// Check if user already has templates (don't re-seed)
const existingCount = await WorkoutTemplate.countDocuments({ createdBy: user.nickname });
if (existingCount > 0) {
return json({ message: 'Templates already exist', seeded: false });
}
const templates = await WorkoutTemplate.insertMany(
defaultTemplates.map((t) => ({
...t,
createdBy: user.nickname,
isDefault: true
}))
);
return json({ message: 'Default templates created', templates, seeded: true }, { status: 201 });
};