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

@@ -2,6 +2,7 @@
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';
@@ -116,25 +117,56 @@
return { points, viewBox: `0 0 ${vbW} ${vbH}` };
});
/** Estimate kcal for this session */
/** Estimate kcal for this session (strength + cardio) */
const kcalResult = $derived.by(() => {
/** @type {import('$lib/data/kcalEstimate').ExerciseData[]} */
const exercises = [];
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')) continue; // skip cardio
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) exercises.push({ exerciseId: ex.exerciseId, sets });
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 });
}
}
if (exercises.length === 0) return null;
return estimateWorkoutKcal(exercises);
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,
};
});
/** Check if this session has any cardio exercise with GPS data */

View File

@@ -0,0 +1,420 @@
/**
* Kilocalorie estimation for cardio activities.
*
* ─── Running & Walking (with GPS elevation) ──────────────────────────────
*
* Minetti, A.E. et al. (2002) "Energy cost of walking and running at
* extreme uphill and downhill slopes."
* J. Appl. Physiol., 93, pp.10391046.
* DOI: 10.1152/japplphysiol.01177.2001
*
* 5th-order polynomial regressions for cost of transport (J·kg⁻¹·m⁻¹)
* as a function of gradient i (decimal, e.g. 0.10 = 10%):
*
* Cw (walking) = 280.5i⁵ 58.7i⁴ 76.8i³ + 51.9i² + 19.6i + 2.5
* Cr (running) = 155.4i⁵ 30.4i⁴ 43.3i³ + 46.3i² + 19.5i + 3.6
*
* Both R² = 0.999, valid for gradients from 0.45 to +0.45.
* Units: J per kg body mass per metre of horizontal distance.
*
* On flat ground: Cw ≈ 2.5 J/kg/m, Cr ≈ 3.6 J/kg/m
* → Walking ≈ 0.60 kcal/kg/km, Running ≈ 0.86 kcal/kg/km
*
* ─── Running & Walking (flat, no GPS) ────────────────────────────────────
*
* Léger, L. & Mercier, D. (1984) — net cost of running ≈ 1 kcal/kg/km
* (remarkably speed-independent for running). Walking ≈ 0.7 kcal/kg/km.
*
* ─── Cycling ─────────────────────────────────────────────────────────────
*
* With GPS: physics model — power = aero drag + rolling resistance + gravity
* P = (0.5·CdA·ρ·v² + Cr·m·g + m·g·sin(θ)) · v
* kcal = P · time / (efficiency · 4184)
*
* Without GPS: MET-based from average speed.
* Ainsworth, B.E. et al. (2011) "Compendium of Physical Activities."
* Med. Sci. Sports Exerc., 43(8), pp.15751581.
* DOI: 10.1249/MSS.0b013e31821ece12
*
* kcal/hr = MET × bodyweight_kg × 1.05
*
* ─── Other cardio (swimming, rowing, elliptical, etc.) ───────────────────
*
* MET-based fallback from the Compendium.
*/
// ── Minetti polynomials ──────────────────────────────────────────────────
/** Cost of walking in J/kg/m as function of gradient (decimal) */
function minettiWalking(i: number): number {
// Clamp to studied range
i = Math.max(-0.45, Math.min(0.45, i));
return 280.5*i**5 - 58.7*i**4 - 76.8*i**3 + 51.9*i**2 + 19.6*i + 2.5;
}
/** Cost of running in J/kg/m as function of gradient (decimal) */
function minettiRunning(i: number): number {
i = Math.max(-0.45, Math.min(0.45, i));
return 155.4*i**5 - 30.4*i**4 - 43.3*i**3 + 46.3*i**2 + 19.5*i + 3.6;
}
// ── GPS helpers ──────────────────────────────────────────────────────────
interface GpsPoint {
lat: number;
lng: number;
altitude?: number;
timestamp: number;
}
/** Haversine distance in metres */
function haversineM(a: GpsPoint, b: GpsPoint): number {
const R = 6371000;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
const sinLat = Math.sin(dLat / 2);
const sinLng = Math.sin(dLng / 2);
const h = sinLat * sinLat +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((b.lat * Math.PI) / 180) *
sinLng * sinLng;
return 2 * R * Math.asin(Math.sqrt(h));
}
// ── GPS-based estimation (running/walking/hiking) ────────────────────────
type Gait = 'running' | 'walking';
/**
* Estimate kcal from a GPS track using Minetti's gradient-dependent
* cost-of-transport polynomials. Splits the track into segments, computes
* gradient per segment, looks up CoT, sums energy across all segments.
*
* @param track - GPS track with lat, lng, altitude, timestamp
* @param bodyWeightKg - user body weight
* @param gait - 'running' or 'walking' (hiking uses walking)
* @returns { kcal, distanceKm }
*/
export function estimateGpsRunWalkKcal(
track: GpsPoint[],
bodyWeightKg: number,
gait: Gait = 'running'
): { kcal: number; distanceKm: number } {
if (track.length < 2) return { kcal: 0, distanceKm: 0 };
const costFn = gait === 'running' ? minettiRunning : minettiWalking;
const hasElevation = track.some(p => p.altitude != null);
let totalEnergy = 0; // Joules
let totalDistance = 0; // metres
for (let idx = 1; idx < track.length; idx++) {
const a = track[idx - 1];
const b = track[idx];
const horizDist = haversineM(a, b);
if (horizDist < 0.5) continue; // skip near-duplicate points
totalDistance += horizDist;
let gradient = 0;
if (hasElevation && a.altitude != null && b.altitude != null) {
const elevChange = b.altitude - a.altitude;
gradient = elevChange / horizDist;
}
const costPerKgPerM = costFn(gradient);
totalEnergy += costPerKgPerM * bodyWeightKg * horizDist;
}
const kcal = totalEnergy / 4184; // J → kcal
return { kcal: Math.round(kcal), distanceKm: totalDistance / 1000 };
}
// ── GPS-based cycling estimation ─────────────────────────────────────────
// Default cycling parameters
const CDA = 0.35; // drag area (m²) — road cyclist, hoods position
const RHO = 1.225; // air density (kg/m³) at sea level
const CR_ROLLING = 0.005; // rolling resistance coefficient (road tyres)
const G = 9.81; // gravity (m/s²)
const EFFICIENCY = 0.22; // gross mechanical efficiency of cycling
/**
* Estimate cycling kcal from GPS track using a physics model.
* Power = aerodynamic drag + rolling resistance + gravity component.
* Energy = power × time, converted via mechanical efficiency.
*
* For downhill segments where net power would be negative (coasting),
* we use a minimum metabolic cost (freewheeling ≈ 3.5 METs).
*/
export function estimateGpsCyclingKcal(
track: GpsPoint[],
bodyWeightKg: number,
bikeWeightKg = 10
): { kcal: number; distanceKm: number } {
if (track.length < 2) return { kcal: 0, distanceKm: 0 };
const totalMass = bodyWeightKg + bikeWeightKg;
// Minimum metabolic rate while cycling (coasting) ≈ 3.5 METs
const minKcalPerSec = (3.5 * bodyWeightKg * 3.5 / 1000) * (5.0 / 60);
// 3.5 METs = 3.5 × 3.5 mL O₂/kg/min, and 1 L O₂ ≈ 5.0 kcal
let totalKcal = 0;
let totalDistance = 0;
for (let idx = 1; idx < track.length; idx++) {
const a = track[idx - 1];
const b = track[idx];
const horizDist = haversineM(a, b);
if (horizDist < 0.5) continue;
const dt = (b.timestamp - a.timestamp) / 1000; // seconds
if (dt <= 0) continue;
totalDistance += horizDist;
const speed = horizDist / dt; // m/s
// Gradient
let sinTheta = 0;
if (a.altitude != null && b.altitude != null) {
const elevChange = b.altitude - a.altitude;
const slopeDist = Math.sqrt(horizDist * horizDist + elevChange * elevChange);
sinTheta = elevChange / slopeDist;
}
// Power components (watts)
const pAero = 0.5 * CDA * RHO * speed * speed * speed;
const pRoll = CR_ROLLING * totalMass * G * speed;
const pGrav = totalMass * G * sinTheta * speed;
const pTotal = pAero + pRoll + pGrav;
// Energy for this segment (kcal)
let segmentKcal: number;
if (pTotal > 0) {
// Metabolic energy = mechanical energy / efficiency
segmentKcal = (pTotal * dt) / (EFFICIENCY * 4184);
} else {
// Coasting downhill — use minimum metabolic cost
segmentKcal = minKcalPerSec * dt;
}
totalKcal += segmentKcal;
}
return { kcal: Math.round(totalKcal), distanceKm: totalDistance / 1000 };
}
// ── MET-based fallback estimates (no GPS) ────────────────────────────────
/**
* MET values from Ainsworth et al. (2011) Compendium of Physical Activities.
* kcal/hr = MET × bodyweight_kg × 1.05 (correction for RMR definition)
*
* For running/cycling with distance+duration, we derive average speed
* and look up the corresponding MET value.
*/
/** Running METs by speed (km/h). Interpolated from Compendium codes 12xxx. */
const RUNNING_METS: [number, number][] = [
[6.4, 6.0], // jogging, very slow
[8.0, 8.3], // 12:00 min/mile
[9.7, 9.8], // 10:00 min/mile
[10.8, 10.5], // 9:00 min/mile
[11.3, 11.0], // 8:30 min/mile
[12.1, 11.8], // 8:00 min/mile
[12.9, 12.8], // 7:30 min/mile
[13.8, 13.5], // 7:00 min/mile
[14.5, 14.5], // 6:30 min/mile
[16.1, 15.0], // 6:00 min/mile
[17.7, 16.0], // 5:30 min/mile
[19.3, 23.0], // 5:00 min/mile — very fast
];
/** Cycling METs by speed (km/h). From Compendium codes 01xxx. */
const CYCLING_METS: [number, number][] = [
[8.9, 3.5], // leisure, very slow
[16.0, 6.8], // leisure, 10-11.9 mph
[19.3, 8.0], // moderate, 12-13.9 mph
[22.5, 10.0], // vigorous, 14-15.9 mph
[25.7, 12.0], // racing, 16-19 mph
[30.6, 15.8], // racing, > 20 mph
];
/** Interpolate MET from speed using a lookup table */
function interpolateMet(table: [number, number][], speedKmh: number): number {
if (speedKmh <= table[0][0]) return table[0][1];
if (speedKmh >= table[table.length - 1][0]) return table[table.length - 1][1];
for (let i = 1; i < table.length; i++) {
if (speedKmh <= table[i][0]) {
const [s0, m0] = table[i - 1];
const [s1, m1] = table[i];
const t = (speedKmh - s0) / (s1 - s0);
return m0 + t * (m1 - m0);
}
}
return table[table.length - 1][1];
}
/** Fixed MET values for activities without speed data */
const FIXED_METS: Record<string, number> = {
'swimming': 5.8, // Compendium 18310, moderate effort
'rowing-machine': 7.0, // Compendium 15552, moderate
'rowing-outdoor': 5.8, // Compendium 18070, moderate
'elliptical': 5.0, // Compendium 02048
'stair-climber': 9.0, // Compendium 17133
'jump-rope': 11.8, // Compendium 15551, moderate
'cycling-indoor': 6.8, // Compendium 02014, moderate
};
// ── Main cardio estimation interface ─────────────────────────────────────
export interface CardioEstimateResult {
kcal: number;
lower: number;
upper: number;
method: 'minetti-gps' | 'cycling-physics' | 'met-speed' | 'met-fixed' | 'flat-rate';
}
/**
* Estimate cardio kcal for a single exercise.
*
* Priority:
* 1. GPS track available → Minetti (run/walk/hike) or physics model (cycling)
* 2. Distance + duration available → MET from average speed
* 3. Duration only → fixed MET for exercise type
* 4. Distance only → flat-rate kcal/kg/km
*
* Uncertainty: ±15% for GPS-based, ±25% for MET-based, ±30% for flat-rate
*/
export function estimateCardioKcal(
exerciseId: string,
bodyWeightKg: number,
options: {
gpsTrack?: GpsPoint[];
distanceKm?: number;
durationMin?: number;
}
): CardioEstimateResult {
const { gpsTrack, distanceKm, durationMin } = options;
// Determine activity category
const isRunning = exerciseId === 'running';
const isWalking = exerciseId === 'walking' || exerciseId === 'hiking';
const isCycling = exerciseId === 'cycling-outdoor' || exerciseId === 'cycling-indoor';
const isRunOrWalk = isRunning || isWalking;
// 1. GPS-based estimation
if (gpsTrack && gpsTrack.length >= 2) {
if (isRunOrWalk) {
const gait: Gait = isRunning ? 'running' : 'walking';
const result = estimateGpsRunWalkKcal(gpsTrack, bodyWeightKg, gait);
return withUncertainty(result.kcal, 0.15, 'minetti-gps');
}
if (isCycling) {
const result = estimateGpsCyclingKcal(gpsTrack, bodyWeightKg);
return withUncertainty(result.kcal, 0.15, 'cycling-physics');
}
}
// 2. Distance + duration → average speed → MET lookup
if (distanceKm && distanceKm > 0 && durationMin && durationMin > 0) {
const speedKmh = distanceKm / (durationMin / 60);
if (isRunning) {
const met = interpolateMet(RUNNING_METS, speedKmh);
const kcal = met * bodyWeightKg * (durationMin / 60) * 1.05;
return withUncertainty(kcal, 0.20, 'met-speed');
}
if (isWalking) {
// Walking: ~3.5 METs at 5 km/h, scales roughly with speed
const met = Math.max(2.0, 0.7 * speedKmh);
const kcal = met * bodyWeightKg * (durationMin / 60) * 1.05;
return withUncertainty(kcal, 0.20, 'met-speed');
}
if (isCycling) {
const met = interpolateMet(CYCLING_METS, speedKmh);
const kcal = met * bodyWeightKg * (durationMin / 60) * 1.05;
return withUncertainty(kcal, 0.20, 'met-speed');
}
}
// 3. Duration only → fixed MET
if (durationMin && durationMin > 0) {
const met = FIXED_METS[exerciseId];
if (met) {
const kcal = met * bodyWeightKg * (durationMin / 60) * 1.05;
return withUncertainty(kcal, 0.25, 'met-fixed');
}
// Running/walking/cycling without distance — use moderate METs
if (isRunning) {
const kcal = 9.8 * bodyWeightKg * (durationMin / 60) * 1.05;
return withUncertainty(kcal, 0.30, 'met-fixed');
}
if (isWalking) {
const kcal = 3.5 * bodyWeightKg * (durationMin / 60) * 1.05;
return withUncertainty(kcal, 0.30, 'met-fixed');
}
if (isCycling) {
const kcal = 6.8 * bodyWeightKg * (durationMin / 60) * 1.05;
return withUncertainty(kcal, 0.30, 'met-fixed');
}
}
// 4. Distance only → flat-rate kcal/kg/km
if (distanceKm && distanceKm > 0) {
let kcalPerKgPerKm: number;
if (isRunning) kcalPerKgPerKm = 1.0; // Léger & Mercier
else if (isWalking) kcalPerKgPerKm = 0.7; // walking on flat
else if (isCycling) kcalPerKgPerKm = 0.3; // rough cycling estimate
else kcalPerKgPerKm = 0.8; // generic cardio
const kcal = kcalPerKgPerKm * bodyWeightKg * distanceKm;
return withUncertainty(kcal, 0.30, 'flat-rate');
}
return { kcal: 0, lower: 0, upper: 0, method: 'flat-rate' };
}
function withUncertainty(
kcal: number,
pct: number,
method: CardioEstimateResult['method']
): CardioEstimateResult {
const rounded = Math.round(kcal);
const margin = Math.round(kcal * pct);
return {
kcal: rounded,
lower: Math.max(0, rounded - margin),
upper: rounded + margin,
method,
};
}
/**
* Estimate cumulative cardio kcal across multiple exercises/workouts.
* Simple sum with combined uncertainty (root-sum-of-squares of margins).
*/
export function estimateCumulativeCardioKcal(
results: CardioEstimateResult[]
): { kcal: number; lower: number; upper: number } {
let totalKcal = 0;
let sumMarginSq = 0;
for (const r of results) {
totalKcal += r.kcal;
const margin = r.kcal - r.lower;
sumMarginSq += margin * margin;
}
const combinedMargin = Math.round(Math.sqrt(sumMarginSq));
return {
kcal: Math.round(totalKcal),
lower: Math.max(0, Math.round(totalKcal) - combinedMargin),
upper: Math.round(totalKcal) + combinedMargin,
};
}

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 } },

View File

@@ -1,13 +1,14 @@
<script>
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X, RefreshCw, Gauge, Flame } from 'lucide-svelte';
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X, RefreshCw, Gauge, Flame, Info } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
const sl = $derived(fitnessSlugs(lang));
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
import { estimateWorkoutKcal } from '$lib/data/kcalEstimate';
import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import SetTable from '$lib/components/fitness/SetTable.svelte';
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
@@ -21,11 +22,36 @@
const kcalResult = $derived.by(() => {
if (!session?.exercises) return null;
/** @type {import('$lib/data/kcalEstimate').ExerciseData[]} */
const exercises = [];
const strengthExercises = [];
let cardioKcal = 0;
let cardioMarginSq = 0;
/** @type {Set<string>} */
const methods = new Set();
for (const ex of session.exercises) {
const exercise = getExerciseById(ex.exerciseId, lang);
const metrics = getExerciseMetrics(exercise);
if (metrics.includes('distance')) continue;
if (metrics.includes('distance')) {
// Cardio: prefer GPS track, fall back to distance+duration
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;
}
if (dist > 0 || dur > 0 || ex.gpsTrack?.length >= 2) {
const r = estimateCardioKcal(ex.exerciseId, 80, {
gpsTrack: ex.gpsTrack?.length >= 2 ? ex.gpsTrack : undefined,
distanceKm: dist || undefined,
durationMin: dur || undefined,
});
cardioKcal += r.kcal;
cardioMarginSq += (r.kcal - r.lower) ** 2;
methods.add(r.method);
}
continue;
}
const weightMultiplier = exercise?.bilateral ? 2 : 1;
const sets = ex.sets
.filter((/** @type {any} */ s) => s.completed && s.reps > 0)
@@ -33,12 +59,36 @@
weight: (s.weight ?? 0) * weightMultiplier,
reps: s.reps ?? 0
}));
if (sets.length > 0) exercises.push({ exerciseId: ex.exerciseId, sets });
if (sets.length > 0) strengthExercises.push({ exerciseId: ex.exerciseId, sets });
}
if (exercises.length === 0) return null;
return estimateWorkoutKcal(exercises);
const strengthResult = strengthExercises.length > 0 ? estimateWorkoutKcal(strengthExercises) : null;
if (!strengthResult && cardioKcal === 0) return null;
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],
};
});
/** @type {Record<string, { label: string, doi?: string }>} */
const METHOD_CITATIONS = {
'lytle': { label: 'Lytle et al. (2019)', doi: '10.1249/MSS.0000000000001925' },
'minetti-gps': { label: 'Minetti et al. (2002)', doi: '10.1152/japplphysiol.01177.2001' },
'cycling-physics': { label: 'Cycling physics model' },
'met-speed': { label: 'Ainsworth et al. (2011)', doi: '10.1249/MSS.0b013e31821ece12' },
'met-fixed': { label: 'Ainsworth et al. (2011)', doi: '10.1249/MSS.0b013e31821ece12' },
'flat-rate': { label: 'Flat-rate estimate' },
};
function checkDark() {
if (typeof document === 'undefined') return false;
const t = document.documentElement.dataset.theme;
@@ -48,6 +98,7 @@
}
let dark = $state(checkDark());
let showKcalInfo = $state(false);
onMount(() => {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const onMql = () => { dark = checkDark(); };
@@ -523,9 +574,28 @@
</div>
{/if}
{#if kcalResult}
<div class="stat-pill kcal">
<div class="stat-pill kcal has-info">
<Flame size={14} />
<span>{kcalResult.kcal} &plusmn; {kcalResult.kcal - kcalResult.lower} kcal</span>
<button class="kcal-info-trigger" onclick={() => showKcalInfo = !showKcalInfo} aria-label="Show estimation sources">
<Info size={12} />
</button>
{#if showKcalInfo}
<div class="kcal-info-tooltip">
{#each kcalResult.methods as method}
{@const cite = METHOD_CITATIONS[method]}
{#if cite}
<span class="cite-line">
{#if cite.doi}
<a href="https://doi.org/{cite.doi}" target="_blank" rel="noopener">{cite.label}</a>
{:else}
{cite.label}
{/if}
</span>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
{#if session.prs?.length > 0}
@@ -911,6 +981,47 @@
border-color: var(--nord12);
background: color-mix(in srgb, var(--nord12) 10%, transparent);
}
.stat-pill.has-info {
position: relative;
}
.kcal-info-trigger {
display: flex;
align-items: center;
opacity: 0.5;
cursor: pointer;
margin-left: 0.15rem;
background: none;
border: none;
padding: 0;
color: inherit;
}
.kcal-info-trigger:hover {
opacity: 0.9;
}
.kcal-info-tooltip {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.35rem;
background: var(--color-surface);
border: 1px solid var(--color-border, var(--nord3));
border-radius: 8px;
padding: 0.45rem 0.6rem;
font-size: 0.65rem;
font-weight: 400;
line-height: 1.5;
color: var(--color-text-secondary);
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.kcal-info-tooltip a {
color: var(--nord12);
text-decoration: underline;
}
.stat-pill.pr {
color: var(--nord13);
border-color: var(--nord13);

View File

@@ -1,7 +1,7 @@
<script>
import { page } from '$app/stores';
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import { Dumbbell, Route, Flame, Weight, Info } from 'lucide-svelte';
import { Dumbbell, Route, Flame, Weight } from 'lucide-svelte';
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
import { onMount } from 'svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
@@ -143,10 +143,6 @@
</div>
{#if stats.kcalEstimate}
<div class="lifetime-card kcal">
<a href="https://doi.org/10.1249/MSS.0000000000001925" target="_blank" rel="noopener" class="info-trigger">
<Info size={14} />
<span class="info-tooltip">Lytle et al. (2019)<br/>Med. Sci. Sports Exerc.<br/>DOI: 10.1249/MSS.0000000000001925</span>
</a>
<div class="card-icon"><Flame size={24} /></div>
<div class="card-value">~{stats.kcalEstimate.kcal.toLocaleString()}<span class="card-unit">kcal</span></div>
<div class="card-label">{t('est_kcal', lang)}</div>
@@ -318,44 +314,6 @@
margin-top: 0.1rem;
line-height: 1.3;
}
.info-trigger {
position: absolute;
top: 0.45rem;
right: 0.45rem;
color: var(--color-text-secondary);
opacity: 0.4;
cursor: help;
z-index: 2;
padding: 0.25rem;
text-decoration: none;
}
.info-trigger:hover {
opacity: 0.8;
}
.info-tooltip {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 0.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border, var(--nord3));
border-radius: 8px;
padding: 0.5rem 0.65rem;
font-size: 0.65rem;
font-weight: 400;
line-height: 1.4;
color: var(--color-text-secondary);
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 20;
text-transform: none;
letter-spacing: 0;
}
.info-trigger:hover .info-tooltip {
display: block;
}
.card-hint a {
color: var(--nord12);
text-decoration: underline;

View File

@@ -10,6 +10,7 @@
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { estimateWorkoutKcal } from '$lib/data/kcalEstimate';
import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import SetTable from '$lib/components/fitness/SetTable.svelte';
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
@@ -193,13 +194,32 @@
};
});
// Estimate kcal for strength exercises
// Estimate kcal for strength + cardio exercises
/** @type {import('$lib/data/kcalEstimate').ExerciseData[]} */
const kcalExercises = [];
let cardioKcal = 0;
let cardioMarginSq = 0;
for (const ex of local.exercises) {
const exercise = getExerciseById(ex.exerciseId, lang);
const metrics = getExerciseMetrics(exercise);
if (metrics.includes('distance')) continue;
if (metrics.includes('distance')) {
let dist = 0;
let dur = 0;
for (const s of ex.sets) {
if (!s.completed) continue;
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;
cardioMarginSq += (r.kcal - r.lower) ** 2;
}
continue;
}
const weightMultiplier = exercise?.bilateral ? 2 : 1;
const sets = ex.sets
.filter((/** @type {any} */ s) => s.completed && s.reps > 0)
@@ -209,7 +229,18 @@
}));
if (sets.length > 0) kcalExercises.push({ exerciseId: ex.exerciseId, sets });
}
const kcalResult = kcalExercises.length > 0 ? estimateWorkoutKcal(kcalExercises) : null;
const strengthResult = kcalExercises.length > 0 ? estimateWorkoutKcal(kcalExercises) : null;
let kcalResult = null;
if (strengthResult || cardioKcal > 0) {
const total = (strengthResult?.kcal ?? 0) + cardioKcal;
const sMargin = strengthResult ? (strengthResult.kcal - strengthResult.lower) : 0;
const margin = Math.round(Math.sqrt(sMargin ** 2 + cardioMarginSq));
kcalResult = {
kcal: Math.round(total),
lower: Math.max(0, Math.round(total) - margin),
upper: Math.round(total) + margin,
};
}
return {
sessionId: saved._id,