fitness: add kcal estimation based on Lytle et al. (2019) regression model

Estimate strength workout energy expenditure using the Lytle et al. multiple
linear regression model. Maps all 77 exercises to 7 studied categories with
confidence levels. Shows kcal on stats page (cumulative), session cards,
workout detail, and workout completion screen. Supports sex/height demographics
via profile section on measure page. Includes info tooltip with DOI reference.
This commit is contained in:
2026-03-23 10:23:00 +01:00
parent fd580ecfe7
commit 9f45a1525b
11 changed files with 745 additions and 24 deletions

View File

@@ -1,7 +1,7 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Plus, Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown } from 'lucide-svelte';
import { Plus, Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
@@ -9,6 +9,7 @@
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { estimateWorkoutKcal } from '$lib/data/kcalEstimate';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import SetTable from '$lib/components/fitness/SetTable.svelte';
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
@@ -192,6 +193,24 @@
};
});
// Estimate kcal for strength exercises
/** @type {import('$lib/data/kcalEstimate').ExerciseData[]} */
const kcalExercises = [];
for (const ex of local.exercises) {
const exercise = getExerciseById(ex.exerciseId, lang);
const metrics = getExerciseMetrics(exercise);
if (metrics.includes('distance')) continue;
const weightMultiplier = exercise?.bilateral ? 2 : 1;
const sets = ex.sets
.filter((/** @type {any} */ s) => s.completed && s.reps > 0)
.map((/** @type {any} */ s) => ({
weight: (s.weight ?? 0) * weightMultiplier,
reps: s.reps ?? 0
}));
if (sets.length > 0) kcalExercises.push({ exerciseId: ex.exerciseId, sets });
}
const kcalResult = kcalExercises.length > 0 ? estimateWorkoutKcal(kcalExercises) : null;
return {
sessionId: saved._id,
name: local.name,
@@ -201,7 +220,8 @@
totalTonnage,
totalDistance,
exerciseSummaries,
prs
prs,
kcalResult
};
}
@@ -366,6 +386,13 @@
<span class="comp-stat-label">{t('distance', lang)}</span>
</div>
{/if}
{#if completionData.kcalResult}
<div class="comp-stat kcal">
<Flame size={18} />
<span class="comp-stat-value">{completionData.kcalResult.kcal} &plusmn; {completionData.kcalResult.kcal - completionData.kcalResult.lower} kcal</span>
<span class="comp-stat-label">{t('est_kcal', lang)}</span>
</div>
{/if}
</div>
{#if completionData.prs.length > 0}
@@ -605,6 +632,9 @@
text-transform: uppercase;
letter-spacing: 0.04em;
}
.comp-stat.kcal {
color: var(--nord12);
}
.prs-section {
background: color-mix(in srgb, var(--nord13) 8%, transparent);