fitness: add cardio kcal estimation with Minetti/Ainsworth models

Add cardioKcalEstimate.ts implementing tiered calorie estimation for
cardio exercises: Minetti gradient-dependent polynomials for GPS
run/walk/hike, cycling physics model, MET-based fallbacks from
Ainsworth Compendium, and flat-rate estimates. Wire cardio kcal into
SessionCard, workout completion screen, history detail, and stats
overview API alongside existing strength kcal (Lytle). Move citation
info from stats overview to clickable DOI links on workout detail
kcal pill.
This commit is contained in:
2026-03-23 12:26:16 +01:00
parent 0ba22b103b
commit 3ef61c900f
6 changed files with 653 additions and 75 deletions

View File

@@ -6,6 +6,7 @@ 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 { FitnessGoal } from '$models/FitnessGoal';
export const GET: RequestHandler = async ({ locals }) => {
@@ -57,12 +58,14 @@ export const GET: RequestHandler = async ({ locals }) => {
// Lifetime totals: tonnage lifted + cardio km + kcal estimate
const allSessions = await WorkoutSession.find(
{ createdBy: user.nickname },
{ 'exercises.exerciseId': 1, 'exercises.sets': 1 }
{ 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1 }
).lean();
let totalTonnage = 0;
let totalCardioKm = 0;
const workoutKcalResults: { kcal: number; see: number }[] = [];
const cardioKcalResults: CardioEstimateResult[] = [];
const bodyWeightKg = demographics.bodyWeightKg ?? 80;
for (const s of allSessions) {
const strengthExercises: ExerciseData[] = [];
@@ -72,18 +75,31 @@ export const GET: RequestHandler = async ({ locals }) => {
const isCardio = metrics.includes('distance');
const weightMultiplier = exercise?.bilateral ? 2 : 1;
const completedSets: { weight: number; reps: number }[] = [];
for (const set of ex.sets) {
if (!set.completed) continue;
if (isCardio) {
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;
} else {
}
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 });
if (completedSets.length > 0) {
strengthExercises.push({ exerciseId: ex.exerciseId, sets: completedSets });
}
}
}
if (strengthExercises.length > 0) {
@@ -92,7 +108,17 @@ export const GET: RequestHandler = async ({ locals }) => {
}
}
const kcalEstimate = estimateCumulativeKcal(workoutKcalResults);
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 kcalEstimate = {
kcal: totalKcal,
lower: Math.max(0, totalKcal - combinedMargin),
upper: totalKcal + combinedMargin,
};
const weightMeasurements = await BodyMeasurement.find(
{ createdBy: user.nickname, weight: { $ne: null } },