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:
@@ -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 */
|
||||
|
||||
420
src/lib/data/cardioKcalEstimate.ts
Normal file
420
src/lib/data/cardioKcalEstimate.ts
Normal 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.1039–1046.
|
||||
* 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.1575–1581.
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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} ± {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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user