fitness: compute kcal server-side and store in session document
CI / update (push) Successful in 3m43s

Previously kcal was computed on-the-fly in 3 places with inconsistent
inputs (hardcoded 80kg, missing GPS data, no demographics). Now a
shared computeSessionKcal() helper runs server-side using the best
available method (GPS + real demographics) and stores the result in
a new kcalEstimate field on WorkoutSession.

Kcal is recomputed on save, recalculate, GPX upload, and GPX delete.
The stats overview uses stored values with a legacy fallback for
sessions saved before this change.
This commit is contained in:
2026-04-03 08:24:42 +02:00
parent cee20f6bb3
commit eda8502568
8 changed files with 207 additions and 149 deletions
@@ -7,6 +7,7 @@ import { WorkoutTemplate } from '$models/WorkoutTemplate';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
import { simplifyTrack } from '$lib/server/simplifyTrack';
import { computeSessionKcal } from '$lib/server/computeSessionKcal';
function estimatedOneRepMax(weight: number, reps: number): number {
if (reps <= 0 || weight <= 0) return 0;
@@ -155,6 +156,9 @@ export const POST: RequestHandler = async ({ request, locals }) => {
return ex;
});
// Compute kcal estimate using best available method (GPS + demographics)
const kcalEstimate = await computeSessionKcal(processedExercises, session.user.nickname);
const workoutSession = new WorkoutSession({
templateId,
templateName,
@@ -170,6 +174,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
gpsTrack: gpsTrack?.length ? gpsTrack : undefined,
gpsPreview,
prs: prs.length > 0 ? prs : undefined,
kcalEstimate,
notes,
createdBy: session.user.nickname
});
@@ -4,6 +4,7 @@ import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession';
import type { IGpsPoint } from '$models/WorkoutSession';
import { simplifyTrack } from '$lib/server/simplifyTrack';
import { computeSessionKcal } from '$lib/server/computeSessionKcal';
import mongoose from 'mongoose';
/** Haversine distance in km between two points */
@@ -114,11 +115,18 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
sets[0].duration = durationMin;
}
// Recompute kcal with the new GPS data
workoutSession.kcalEstimate = await computeSessionKcal(
workoutSession.exercises,
session.user.nickname
) ?? undefined;
await workoutSession.save();
return json({
points: track.length,
distance: workoutSession.exercises[exerciseIdx].totalDistance
distance: workoutSession.exercises[exerciseIdx].totalDistance,
kcalEstimate: workoutSession.kcalEstimate
});
} catch (error) {
console.error('Error processing GPX upload:', error);
@@ -158,9 +166,16 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
workoutSession.exercises[exerciseIdx].gpsTrack = undefined;
workoutSession.exercises[exerciseIdx].gpsPreview = undefined;
workoutSession.exercises[exerciseIdx].totalDistance = undefined;
// Recompute kcal without the GPS data
workoutSession.kcalEstimate = await computeSessionKcal(
workoutSession.exercises,
session.user.nickname
) ?? undefined;
await workoutSession.save();
return json({ success: true });
return json({ success: true, kcalEstimate: workoutSession.kcalEstimate });
} catch (error) {
console.error('Error removing GPS track:', error);
return json({ error: 'Failed to remove GPS track' }, { status: 500 });
@@ -6,6 +6,7 @@ import type { IPr } from '$models/WorkoutSession';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
import { simplifyTrack } from '$lib/server/simplifyTrack';
import { computeSessionKcal } from '$lib/server/computeSessionKcal';
import mongoose from 'mongoose';
function estimatedOneRepMax(weight: number, reps: number): number {
@@ -122,12 +123,19 @@ export const POST: RequestHandler = async ({ params, locals }) => {
}
}
// Recompute kcal estimate using best available method
const kcalEstimate = await computeSessionKcal(
workoutSession.exercises,
session.user.nickname
);
// Use $set to only update computed fields, preserving gpsTrack data
await WorkoutSession.updateOne({ _id: workoutSession._id }, {
$set: {
totalVolume: totalVolume > 0 ? totalVolume : undefined,
totalDistance: totalDistance > 0 ? totalDistance : undefined,
prs: prs.length > 0 ? prs : undefined,
kcalEstimate: kcalEstimate ?? undefined,
...gpsPreviewUpdates
}
});
@@ -135,7 +143,8 @@ export const POST: RequestHandler = async ({ params, locals }) => {
return json({
totalVolume: totalVolume > 0 ? totalVolume : undefined,
totalDistance: totalDistance > 0 ? totalDistance : undefined,
prs: prs.length
prs: prs.length,
kcalEstimate
});
} catch (error) {
console.error('Error recalculating session:', error);
@@ -5,8 +5,8 @@ import { dbConnect } from '$utils/db';
import { WorkoutSession } from '$models/WorkoutSession';
import { BodyMeasurement } from '$models/BodyMeasurement';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { estimateWorkoutKcal, estimateCumulativeKcal, type ExerciseData, type Demographics } from '$lib/data/kcalEstimate';
import { estimateCardioKcal, estimateCumulativeCardioKcal, type CardioEstimateResult } from '$lib/data/cardioKcalEstimate';
import { estimateWorkoutKcal, type ExerciseData, type Demographics } from '$lib/data/kcalEstimate';
import { estimateCardioKcal, type CardioEstimateResult } from '$lib/data/cardioKcalEstimate';
import { FitnessGoal } from '$models/FitnessGoal';
export const GET: RequestHandler = async ({ locals }) => {
@@ -56,64 +56,90 @@ export const GET: RequestHandler = async ({ locals }) => {
};
// Lifetime totals: tonnage lifted + cardio km + kcal estimate
// Use stored kcalEstimate when available; fall back to on-the-fly for legacy sessions
const allSessions = await WorkoutSession.find(
{ createdBy: user.nickname },
{ 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1 }
{ 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1, kcalEstimate: 1 }
).lean();
let totalTonnage = 0;
let totalCardioKm = 0;
const workoutKcalResults: { kcal: number; see: number }[] = [];
const cardioKcalResults: CardioEstimateResult[] = [];
let totalKcal = 0;
let totalMarginSq = 0;
const bodyWeightKg = demographics.bodyWeightKg ?? 80;
for (const s of allSessions) {
const strengthExercises: ExerciseData[] = [];
// Accumulate tonnage and cardio km
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;
const completedSets: { weight: number; reps: number }[] = [];
if (isCardio) {
let dist = (ex as any).totalDistance ?? 0;
let dur = 0;
for (const set of ex.sets) {
if (!set.completed) continue;
if (!dist) dist += set.distance ?? 0;
dur += set.duration ?? 0;
totalCardioKm += set.distance ?? 0;
}
if (dist > 0 || dur > 0) {
cardioKcalResults.push(estimateCardioKcal(ex.exerciseId, bodyWeightKg, {
distanceKm: dist || undefined,
durationMin: dur || undefined,
}));
}
} else {
for (const set of ex.sets) {
if (!set.completed) continue;
const w = (set.weight ?? 0) * weightMultiplier;
totalTonnage += w * (set.reps ?? 0);
if (set.reps) completedSets.push({ weight: w, reps: set.reps });
}
if (completedSets.length > 0) {
strengthExercises.push({ exerciseId: ex.exerciseId, sets: completedSets });
totalTonnage += (set.weight ?? 0) * (set.reps ?? 0) * weightMultiplier;
}
}
}
if (strengthExercises.length > 0) {
const result = estimateWorkoutKcal(strengthExercises, demographics);
workoutKcalResults.push({ kcal: result.kcal, see: result.see });
// Use stored kcal or fall back to on-the-fly computation for legacy sessions
if (s.kcalEstimate) {
totalKcal += s.kcalEstimate.kcal;
totalMarginSq += (s.kcalEstimate.kcal - s.kcalEstimate.lower) ** 2;
} else {
// Legacy session: compute on-the-fly (no GPS, uses current demographics)
const strengthExercises: ExerciseData[] = [];
const cardioKcalResults: CardioEstimateResult[] = [];
for (const ex of s.exercises) {
const exercise = getExerciseById(ex.exerciseId);
const metrics = getExerciseMetrics(exercise);
if (metrics.includes('distance')) {
let dist = (ex as any).totalDistance ?? 0;
let dur = 0;
for (const set of ex.sets) {
if (!set.completed) continue;
if (!dist) dist += set.distance ?? 0;
dur += set.duration ?? 0;
}
if (dist > 0 || dur > 0) {
cardioKcalResults.push(estimateCardioKcal(ex.exerciseId, bodyWeightKg, {
distanceKm: dist || undefined,
durationMin: dur || undefined,
}));
}
} else {
const weightMultiplier = exercise?.bilateral ? 2 : 1;
const sets: { weight: number; reps: number }[] = [];
for (const set of ex.sets) {
if (!set.completed) continue;
if (set.reps) sets.push({ weight: (set.weight ?? 0) * weightMultiplier, reps: set.reps });
}
if (sets.length > 0) strengthExercises.push({ exerciseId: ex.exerciseId, sets });
}
}
let sessionKcal = 0;
let sessionMarginSq = 0;
if (strengthExercises.length > 0) {
const r = estimateWorkoutKcal(strengthExercises, demographics);
sessionKcal += r.kcal;
sessionMarginSq += (r.kcal - r.lower) ** 2;
}
for (const r of cardioKcalResults) {
sessionKcal += r.kcal;
sessionMarginSq += (r.kcal - r.lower) ** 2;
}
totalKcal += sessionKcal;
totalMarginSq += sessionMarginSq;
}
}
const strengthKcal = estimateCumulativeKcal(workoutKcalResults);
const cardioKcal = estimateCumulativeCardioKcal(cardioKcalResults);
const totalKcal = strengthKcal.kcal + cardioKcal.kcal;
const sMargin = strengthKcal.kcal - strengthKcal.lower;
const cMargin = cardioKcal.kcal - cardioKcal.lower;
const combinedMargin = Math.round(Math.sqrt(sMargin ** 2 + cMargin ** 2));
const combinedMargin = Math.round(Math.sqrt(totalMarginSq));
const kcalEstimate = {
kcal: totalKcal,
lower: Math.max(0, totalKcal - combinedMargin),