fitness: compute kcal server-side and store in session document
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:
@@ -1,8 +1,6 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||
import { estimateWorkoutKcal } from '$lib/data/kcalEstimate';
|
||||
import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate';
|
||||
import { Clock, Weight, Trophy, Route, Gauge, Flame } from 'lucide-svelte';
|
||||
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
|
||||
|
||||
@@ -19,6 +17,7 @@
|
||||
* totalVolume?: number,
|
||||
* totalDistance?: number,
|
||||
* prs?: Array<any>,
|
||||
* kcalEstimate?: { kcal: number, lower: number, upper: number, methods: string[] },
|
||||
* exercises: Array<{
|
||||
* exerciseId: string,
|
||||
* totalDistance?: number,
|
||||
@@ -117,57 +116,8 @@
|
||||
return { points, viewBox: `0 0 ${vbW} ${vbH}` };
|
||||
});
|
||||
|
||||
/** Estimate kcal for this session (strength + cardio) */
|
||||
const kcalResult = $derived.by(() => {
|
||||
/** @type {import('$lib/data/kcalEstimate').ExerciseData[]} */
|
||||
const strengthExercises = [];
|
||||
let cardioKcal = 0;
|
||||
let cardioMargin = 0;
|
||||
|
||||
for (const ex of session.exercises) {
|
||||
const exercise = getExerciseById(ex.exerciseId, lang);
|
||||
const metrics = getExerciseMetrics(exercise);
|
||||
if (metrics.includes('distance')) {
|
||||
// Cardio: estimate from distance + duration
|
||||
let dist = ex.totalDistance ?? 0;
|
||||
let dur = 0;
|
||||
for (const s of ex.sets) {
|
||||
if (!dist) dist += s.distance ?? 0;
|
||||
dur += s.duration ?? 0;
|
||||
}
|
||||
if (dist > 0 || dur > 0) {
|
||||
const r = estimateCardioKcal(ex.exerciseId, 80, {
|
||||
distanceKm: dist || undefined,
|
||||
durationMin: dur || undefined,
|
||||
});
|
||||
cardioKcal += r.kcal;
|
||||
cardioMargin += (r.kcal - r.lower) ** 2;
|
||||
}
|
||||
} else {
|
||||
const weightMultiplier = exercise?.bilateral ? 2 : 1;
|
||||
const sets = ex.sets
|
||||
.filter((/** @type {any} */ s) => s.reps > 0)
|
||||
.map((/** @type {any} */ s) => ({
|
||||
weight: (s.weight ?? 0) * weightMultiplier,
|
||||
reps: s.reps ?? 0
|
||||
}));
|
||||
if (sets.length > 0) strengthExercises.push({ exerciseId: ex.exerciseId, sets });
|
||||
}
|
||||
}
|
||||
|
||||
const strengthResult = strengthExercises.length > 0 ? estimateWorkoutKcal(strengthExercises) : null;
|
||||
if (!strengthResult && cardioKcal === 0) return null;
|
||||
|
||||
const totalKcal = (strengthResult?.kcal ?? 0) + cardioKcal;
|
||||
const strengthMargin = strengthResult ? (strengthResult.kcal - strengthResult.lower) : 0;
|
||||
const combinedMargin = Math.round(Math.sqrt(strengthMargin ** 2 + cardioMargin));
|
||||
|
||||
return {
|
||||
kcal: Math.round(totalKcal),
|
||||
lower: Math.max(0, Math.round(totalKcal) - combinedMargin),
|
||||
upper: Math.round(totalKcal) + combinedMargin,
|
||||
};
|
||||
});
|
||||
/** Use server-computed kcal estimate (stored at save/recalculate time) */
|
||||
const kcalResult = $derived(session.kcalEstimate ?? null);
|
||||
|
||||
/** Check if this session has any cardio exercise with GPS data */
|
||||
const hasGpsCardio = $derived(session.exercises.some(ex => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Server-side kcal computation for a workout session.
|
||||
* Uses the best available method: GPS track > distance+duration > flat rate.
|
||||
* Fetches user demographics from DB for strength estimation.
|
||||
*/
|
||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||
import { estimateWorkoutKcal, type ExerciseData, type Demographics } from '$lib/data/kcalEstimate';
|
||||
import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate';
|
||||
import { FitnessGoal } from '$models/FitnessGoal';
|
||||
import { BodyMeasurement } from '$models/BodyMeasurement';
|
||||
import type { IKcalEstimate, ICompletedExercise } from '$models/WorkoutSession';
|
||||
|
||||
export async function computeSessionKcal(
|
||||
exercises: ICompletedExercise[],
|
||||
username: string
|
||||
): Promise<IKcalEstimate | undefined> {
|
||||
// Fetch user demographics
|
||||
const [goal, latestMeasurement] = await Promise.all([
|
||||
FitnessGoal.findOne({ username }).lean() as any,
|
||||
BodyMeasurement.findOne(
|
||||
{ createdBy: username, weight: { $ne: null } },
|
||||
{ weight: 1, bodyFatPercent: 1, _id: 0 }
|
||||
).sort({ date: -1 }).lean() as any
|
||||
]);
|
||||
|
||||
const demographics: Demographics = {
|
||||
heightCm: goal?.heightCm ?? undefined,
|
||||
isMale: (goal?.sex ?? 'male') === 'male',
|
||||
bodyWeightKg: latestMeasurement?.weight ?? undefined,
|
||||
bodyFatPct: latestMeasurement?.bodyFatPercent ?? undefined,
|
||||
};
|
||||
const bodyWeightKg = demographics.bodyWeightKg ?? 80;
|
||||
|
||||
const strengthExercises: ExerciseData[] = [];
|
||||
let cardioKcal = 0;
|
||||
let cardioMarginSq = 0;
|
||||
const methods = new Set<string>();
|
||||
|
||||
for (const ex of exercises) {
|
||||
const exercise = getExerciseById(ex.exerciseId);
|
||||
const metrics = getExerciseMetrics(exercise);
|
||||
|
||||
if (metrics.includes('distance')) {
|
||||
let dist = ex.totalDistance ?? 0;
|
||||
let dur = 0;
|
||||
for (const s of ex.sets) {
|
||||
if (!s.completed) continue;
|
||||
if (!dist) dist += s.distance ?? 0;
|
||||
dur += s.duration ?? 0;
|
||||
}
|
||||
const hasGps = ex.gpsTrack && ex.gpsTrack.length >= 2;
|
||||
if (dist > 0 || dur > 0 || hasGps) {
|
||||
const r = estimateCardioKcal(ex.exerciseId, bodyWeightKg, {
|
||||
gpsTrack: hasGps ? ex.gpsTrack : undefined,
|
||||
distanceKm: dist || undefined,
|
||||
durationMin: dur || undefined,
|
||||
});
|
||||
cardioKcal += r.kcal;
|
||||
cardioMarginSq += (r.kcal - r.lower) ** 2;
|
||||
methods.add(r.method);
|
||||
}
|
||||
} else {
|
||||
const weightMultiplier = exercise?.bilateral ? 2 : 1;
|
||||
const sets = ex.sets
|
||||
.filter(s => s.completed && (s.reps ?? 0) > 0)
|
||||
.map(s => ({
|
||||
weight: (s.weight ?? 0) * weightMultiplier,
|
||||
reps: s.reps ?? 0
|
||||
}));
|
||||
if (sets.length > 0) {
|
||||
strengthExercises.push({ exerciseId: ex.exerciseId, sets });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const strengthResult = strengthExercises.length > 0
|
||||
? estimateWorkoutKcal(strengthExercises, demographics)
|
||||
: null;
|
||||
|
||||
if (!strengthResult && cardioKcal === 0) return undefined;
|
||||
|
||||
if (strengthResult) methods.add('lytle');
|
||||
|
||||
const total = (strengthResult?.kcal ?? 0) + cardioKcal;
|
||||
const sMargin = strengthResult ? (strengthResult.kcal - strengthResult.lower) : 0;
|
||||
const margin = Math.round(Math.sqrt(sMargin ** 2 + cardioMarginSq));
|
||||
|
||||
return {
|
||||
kcal: Math.round(total),
|
||||
lower: Math.max(0, Math.round(total) - margin),
|
||||
upper: Math.round(total) + margin,
|
||||
methods: [...methods],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user