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,8 @@
<script>
import { page } from '$app/stores';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { Clock, Weight, Trophy, Route, Gauge } from 'lucide-svelte';
import { estimateWorkoutKcal } from '$lib/data/kcalEstimate';
import { Clock, Weight, Trophy, Route, Gauge, Flame } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
@@ -115,6 +116,27 @@
return { points, viewBox: `0 0 ${vbW} ${vbH}` };
});
/** Estimate kcal for this session */
const kcalResult = $derived.by(() => {
/** @type {import('$lib/data/kcalEstimate').ExerciseData[]} */
const exercises = [];
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 (exercises.length === 0) return null;
return estimateWorkoutKcal(exercises);
});
/** Check if this session has any cardio exercise with GPS data */
const hasGpsCardio = $derived(session.exercises.some(ex => {
const exercise = getExerciseById(ex.exerciseId, lang);
@@ -190,6 +212,9 @@
{:else if session.totalVolume}
<span class="stat"><Weight size={14} /> {session.totalVolume >= 1000 ? `${(session.totalVolume / 1000).toFixed(1)}t` : `${Math.round(session.totalVolume).toLocaleString()} kg`}</span>
{/if}
{#if kcalResult}
<span class="stat kcal"><Flame size={14} /> {kcalResult.kcal} &plusmn; {kcalResult.kcal - kcalResult.lower} kcal</span>
{/if}
{#if session.prs && session.prs.length > 0}
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
{/if}
@@ -272,6 +297,9 @@
color: var(--color-primary);
font-weight: 700;
}
.stat.kcal {
color: var(--nord12);
}
.stat.pr {
color: var(--nord13);
}

View File

@@ -0,0 +1,410 @@
/**
* Kilocalorie estimation for strength training based on:
*
* Lytle, J.R. et al. (2019) "Predicting Energy Expenditure of an Acute
* Resistance Exercise Bout in Men and Women."
* Med. Sci. Sports Exerc., 51(7), pp.15321537.
* DOI: 10.1249/MSS.0000000000001925
*
* The paper provides a multiple-linear-regression model trained on indirect
* calorimetry (VO₂) from 52 subjects performing 7 resistance exercises at
* 6070 % 1RM. The model predicts **net** kilocalories (total minus resting
* metabolic cost) from demographic and training-volume variables.
*
* ─── Total bout equation (R² = 0.773, SEE = 28.5 kcal) ───────────────────
*
* Net kcal = 0.874·height_cm 0.596·age_yr 1.016·fat_mass_kg
* + 1.638·lean_mass_kg + 2.461·(TV / 1000) 110.742
*
* where TV = Σ(sets × reps × weight_kg) across the workout.
*
* NOTE: The published abstract prints the TV scaling factor as "×10⁻²" but
* verification against the paper's own reported means (male = 161.2 kcal)
* shows the correct factor is **×10⁻³** (i.e. TV / 1000). The Table 2
* column header "Volume m³ (kg)" confirms this.
*
* ─── Individual exercise equations (Table 2) ─────────────────────────────
*
* Seven exercise-specific equations are also provided. In these, the
* "Weight (kg)" coefficient applies to the exercise weight / 100 and the
* "Volume" coefficient applies to exercise TV / 1000. (Verified by
* back-calculating against the study's reported demographics and means.)
*
* ─── Limitations & uncertainty ────────────────────────────────────────────
*
* • Calibrated on Keiser pneumatic machines, 6070 % 1RM, 23 sets of
* 812 reps. Accuracy degrades for very different protocols.
* • Demographics limited to ages 2058.
* • We map all 77 app exercises to the 7 studied exercises. Exercises that
* are close analogues (e.g. incline bench → chest press) inherit the
* equation directly; distant mappings (e.g. hip thrust → leg press)
* carry additional uncertainty.
* • Bodyweight exercises (pull-ups, dips, push-ups) add a fraction of
* body mass to the logged weight before computing TV.
* • When height, age, or body composition are unknown, defaults are used
* (175 cm, 30 yr, 22 % body fat) which adds ~±10 kcal per workout
* to the uncertainty.
*/
// ── Individual exercise regression coefficients (Table 2) ──────────────
interface ExerciseCoeffs {
h: number; // height (cm)
age: number; // age (yr)
g: number; // gender (male=1, female=0)
fm: number; // DEXA fat mass (kg)
lm: number; // DEXA lean mass (kg)
w: number; // exercise weight / 100
v: number; // exercise TV / 1000
c: number; // constant
see: number; // standard error of estimate (kcal)
}
const LYTLE_EXERCISES: Record<string, ExerciseCoeffs> = {
'leg-press': {
h: 0.120, age: -0.093, g: 0, fm: 0, lm: 0.297,
w: 1.169, v: 0, c: -13.837, see: 4.4
},
'chest-press': {
h: 0.186, age: -0.317, g: -0.198, fm: 0, lm: 0.271,
w: 4.211, v: 0, c: -28.468, see: 4.7
},
'leg-curl': {
h: 0, age: -0.129, g: 0, fm: 0, lm: 0.245,
w: 5.189, v: -0.100, c: 6.633, see: 5.36
},
'lat-pulldown': {
h: 0, age: -0.165, g: 0, fm: -0.128, lm: 0.187,
w: 4.725, v: 0, c: 8.483, see: 4.96
},
'leg-extension': {
h: 0, age: -0.08, g: -1.635, fm: -0.185, lm: 0.394,
w: 4.252, v: 0, c: 1.444, see: 5.31
},
'triceps-pushdown': {
h: 0.255, age: 0, g: -5.124, fm: -0.239, lm: 0.390,
w: 1.919, v: 0, c: -44.891, see: 4.99
},
'biceps-curl': {
h: 0.292, age: -0.091, g: -7.068, fm: 0, lm: 0.351,
w: 1.510, v: -0.156, c: -44.262, see: 5.60
},
};
// ── Total bout equation coefficients ───────────────────────────────────
const TOTAL_BOUT = {
h: 0.874,
age: -0.596,
fm: -1.016,
lm: 1.638,
tv: 2.461, // applied to TV / 1000
c: -110.742,
see: 28.465,
r2: 0.773,
};
// ── Exercise → Lytle category mapping ──────────────────────────────────
//
// confidence: 'direct' = very close analogue of the studied exercise
// 'close' = same movement pattern / muscle group
// 'distant' = best available but mechanically different
type MappingConfidence = 'direct' | 'close' | 'distant';
interface ExerciseMapping {
lytleKey: string;
confidence: MappingConfidence;
bwFraction?: number; // fraction of bodyweight to add (for bodyweight exercises)
}
export const EXERCISE_MAP: Record<string, ExerciseMapping> = {
// === CHEST → chest-press ===
'bench-press-barbell': { lytleKey: 'chest-press', confidence: 'direct' },
'incline-bench-press-barbell': { lytleKey: 'chest-press', confidence: 'close' },
'decline-bench-press-barbell': { lytleKey: 'chest-press', confidence: 'close' },
'bench-press-close-grip-barbell': { lytleKey: 'chest-press', confidence: 'close' },
'bench-press-dumbbell': { lytleKey: 'chest-press', confidence: 'close' },
'incline-bench-press-dumbbell': { lytleKey: 'chest-press', confidence: 'close' },
'chest-fly-dumbbell': { lytleKey: 'chest-press', confidence: 'distant' },
'chest-dip': { lytleKey: 'chest-press', confidence: 'close', bwFraction: 0.90 },
'push-up': { lytleKey: 'chest-press', confidence: 'close', bwFraction: 0.64 },
'cable-crossover': { lytleKey: 'chest-press', confidence: 'distant' },
// === BACK → lat-pulldown ===
'bent-over-row-barbell': { lytleKey: 'lat-pulldown', confidence: 'close' },
'deadlift-barbell': { lytleKey: 'leg-press', confidence: 'distant' }, // full-body → leg-press as largest compound
'pull-up': { lytleKey: 'lat-pulldown', confidence: 'direct', bwFraction: 1.0 },
'chin-up': { lytleKey: 'lat-pulldown', confidence: 'direct', bwFraction: 1.0 },
'lat-pulldown-cable': { lytleKey: 'lat-pulldown', confidence: 'direct' },
'seated-row-cable': { lytleKey: 'lat-pulldown', confidence: 'close' },
'dumbbell-row': { lytleKey: 'lat-pulldown', confidence: 'close' },
't-bar-row': { lytleKey: 'lat-pulldown', confidence: 'close' },
'incline-row-dumbbell': { lytleKey: 'lat-pulldown', confidence: 'close' },
'face-pull-cable': { lytleKey: 'lat-pulldown', confidence: 'distant' },
// === SHOULDERS → chest-press (pressing) or lat-pulldown (pulling) ===
'overhead-press-barbell': { lytleKey: 'chest-press', confidence: 'close' },
'overhead-press-dumbbell': { lytleKey: 'chest-press', confidence: 'close' },
'lateral-raise-dumbbell': { lytleKey: 'biceps-curl', confidence: 'distant' }, // isolation, similar load range
'lateral-raise-cable': { lytleKey: 'biceps-curl', confidence: 'distant' },
'front-raise-dumbbell': { lytleKey: 'biceps-curl', confidence: 'distant' },
'reverse-fly-dumbbell': { lytleKey: 'biceps-curl', confidence: 'distant' },
'upright-row-barbell': { lytleKey: 'lat-pulldown', confidence: 'distant' },
'shrug-barbell': { lytleKey: 'lat-pulldown', confidence: 'distant' },
'shrug-dumbbell': { lytleKey: 'lat-pulldown', confidence: 'distant' },
// === ARMS (biceps) → biceps-curl ===
'bicep-curl-barbell': { lytleKey: 'biceps-curl', confidence: 'direct' },
'bicep-curl-dumbbell': { lytleKey: 'biceps-curl', confidence: 'direct' },
'hammer-curl-dumbbell': { lytleKey: 'biceps-curl', confidence: 'close' },
'preacher-curl-barbell': { lytleKey: 'biceps-curl', confidence: 'close' },
'concentration-curl-dumbbell': { lytleKey: 'biceps-curl', confidence: 'close' },
'cable-curl': { lytleKey: 'biceps-curl', confidence: 'close' },
// === ARMS (triceps) → triceps-pushdown ===
'tricep-pushdown-cable': { lytleKey: 'triceps-pushdown', confidence: 'direct' },
'skullcrusher-dumbbell': { lytleKey: 'triceps-pushdown', confidence: 'close' },
'skullcrusher-barbell': { lytleKey: 'triceps-pushdown', confidence: 'close' },
'overhead-tricep-extension-dumbbell': { lytleKey: 'triceps-pushdown', confidence: 'close' },
'tricep-dip': { lytleKey: 'triceps-pushdown', confidence: 'close', bwFraction: 0.90 },
'kickback-dumbbell': { lytleKey: 'triceps-pushdown', confidence: 'close' },
// === LEGS (quad-dominant) → leg-press or leg-extension ===
'squat-barbell': { lytleKey: 'leg-press', confidence: 'close' },
'front-squat-barbell': { lytleKey: 'leg-press', confidence: 'close' },
'leg-press-machine': { lytleKey: 'leg-press', confidence: 'direct' },
'lunge-dumbbell': { lytleKey: 'leg-press', confidence: 'close' },
'bulgarian-split-squat-dumbbell': { lytleKey: 'leg-press', confidence: 'close' },
'leg-extension-machine': { lytleKey: 'leg-extension', confidence: 'direct' },
'goblet-squat-dumbbell': { lytleKey: 'leg-press', confidence: 'close' },
'hack-squat-machine': { lytleKey: 'leg-press', confidence: 'close' },
// === LEGS (hamstring/posterior) → leg-curl ===
'leg-curl-machine': { lytleKey: 'leg-curl', confidence: 'direct' },
'romanian-deadlift-barbell': { lytleKey: 'leg-curl', confidence: 'close' },
'romanian-deadlift-dumbbell': { lytleKey: 'leg-curl', confidence: 'close' },
'hip-thrust-barbell': { lytleKey: 'leg-press', confidence: 'distant' },
'nordic-hamstring-curl': { lytleKey: 'leg-curl', confidence: 'close', bwFraction: 0.70 },
// === LEGS (calves) → leg-extension (closest isolation) ===
'calf-raise-machine': { lytleKey: 'leg-extension', confidence: 'distant' },
'calf-raise-standing': { lytleKey: 'leg-extension', confidence: 'distant' },
// === CORE → biceps-curl (similar isolation load range) ===
'plank': { lytleKey: 'biceps-curl', confidence: 'distant' },
'decline-crunch': { lytleKey: 'biceps-curl', confidence: 'distant' },
'flat-leg-raise': { lytleKey: 'biceps-curl', confidence: 'distant' },
'crunch': { lytleKey: 'biceps-curl', confidence: 'distant' },
'hanging-leg-raise': { lytleKey: 'biceps-curl', confidence: 'distant' },
'cable-crunch': { lytleKey: 'biceps-curl', confidence: 'distant' },
'russian-twist': { lytleKey: 'biceps-curl', confidence: 'distant' },
'ab-wheel-rollout': { lytleKey: 'biceps-curl', confidence: 'distant', bwFraction: 0.50 },
// === OTHER ===
'clean-and-press-barbell': { lytleKey: 'leg-press', confidence: 'distant' },
'farmers-walk': { lytleKey: 'leg-press', confidence: 'distant' },
};
// ── Confidence penalty for uncertainty ──────────────────────────────────
// Additional SEE (kcal) added per exercise based on mapping confidence.
// 'direct' = same exercise, no additional error.
// 'close' = same pattern, small extrapolation error.
// 'distant' = different pattern, larger extrapolation.
const CONFIDENCE_SEE: Record<MappingConfidence, number> = {
direct: 0,
close: 2,
distant: 5,
};
// ── Demographics type ──────────────────────────────────────────────────
export interface Demographics {
heightCm?: number; // default: 175
ageYr?: number; // default: 30
isMale?: boolean; // default: true (gender=1)
bodyWeightKg?: number; // default: 80
bodyFatPct?: number; // default: 22
}
const DEFAULTS: Required<Demographics> = {
heightCm: 175,
ageYr: 30,
isMale: true,
bodyWeightKg: 80,
bodyFatPct: 22,
};
function resolveDemographics(d?: Demographics) {
const heightCm = d?.heightCm ?? DEFAULTS.heightCm;
const ageYr = d?.ageYr ?? DEFAULTS.ageYr;
const isMale = d?.isMale ?? DEFAULTS.isMale;
const bodyWeightKg = d?.bodyWeightKg ?? DEFAULTS.bodyWeightKg;
const bodyFatPct = d?.bodyFatPct ?? DEFAULTS.bodyFatPct;
const fatMassKg = bodyWeightKg * bodyFatPct / 100;
const leanMassKg = bodyWeightKg - fatMassKg;
const gender = isMale ? 1 : 0;
// Track how many fields used defaults (for uncertainty)
let defaultCount = 0;
if (d?.heightCm == null) defaultCount++;
if (d?.ageYr == null) defaultCount++;
if (d?.bodyWeightKg == null) defaultCount++;
if (d?.bodyFatPct == null) defaultCount++;
return { heightCm, ageYr, gender, fatMassKg, leanMassKg, bodyWeightKg, defaultCount };
}
// ── Per-exercise set data ──────────────────────────────────────────────
export interface SetData {
weight: number; // external load in kg (already ×2 for bilateral)
reps: number;
}
export interface ExerciseData {
exerciseId: string;
sets: SetData[];
}
// ── Core estimation functions ──────────────────────────────────────────
/**
* Compute effective weight for a set, adding bodyweight fraction where applicable.
*/
function effectiveWeight(exerciseId: string, externalWeight: number, bodyMassKg: number): number {
const mapping = EXERCISE_MAP[exerciseId];
if (mapping?.bwFraction) {
return externalWeight + bodyMassKg * mapping.bwFraction;
}
return externalWeight;
}
/**
* Estimate kcal for a single exercise using the individual Lytle equation.
*/
function estimateExerciseKcal(
exerciseId: string,
sets: SetData[],
demo: ReturnType<typeof resolveDemographics>
): { kcal: number; see: number } {
const mapping = EXERCISE_MAP[exerciseId];
if (!mapping) return { kcal: 0, see: 0 };
const coeffs = LYTLE_EXERCISES[mapping.lytleKey];
if (!coeffs) return { kcal: 0, see: 0 };
// Compute average exercise weight and total TV for this exercise
let totalTV = 0;
let totalWeightedWeight = 0;
let totalReps = 0;
for (const s of sets) {
if (s.reps <= 0) continue;
const w = effectiveWeight(exerciseId, s.weight, demo.bodyWeightKg);
totalTV += w * s.reps;
totalWeightedWeight += w * s.reps;
totalReps += s.reps;
}
if (totalReps === 0) return { kcal: 0, see: 0 };
const avgWeight = totalWeightedWeight / totalReps;
const kcal = coeffs.h * demo.heightCm
+ coeffs.age * demo.ageYr
+ coeffs.g * demo.gender
+ coeffs.fm * demo.fatMassKg
+ coeffs.lm * demo.leanMassKg
+ coeffs.w * (avgWeight / 100)
+ coeffs.v * (totalTV / 1000)
+ coeffs.c;
const see = Math.sqrt(coeffs.see ** 2 + CONFIDENCE_SEE[mapping.confidence] ** 2);
return { kcal: Math.max(0, kcal), see };
}
/**
* Estimate kcal for an entire workout using the total bout equation.
*
* Returns point estimate and 95% confidence interval.
*/
export function estimateWorkoutKcal(
exercises: ExerciseData[],
demographics?: Demographics
): { kcal: number; lower: number; upper: number; see: number } {
const demo = resolveDemographics(demographics);
// Compute total TV across all exercises
let totalTV = 0;
for (const ex of exercises) {
for (const s of ex.sets) {
if (s.reps <= 0) continue;
const w = effectiveWeight(ex.exerciseId, s.weight, demo.bodyWeightKg);
totalTV += w * s.reps;
}
}
const kcal = TOTAL_BOUT.h * demo.heightCm
+ TOTAL_BOUT.age * demo.ageYr
+ TOTAL_BOUT.fm * demo.fatMassKg
+ TOTAL_BOUT.lm * demo.leanMassKg
+ TOTAL_BOUT.tv * (totalTV / 1000)
+ TOTAL_BOUT.c;
// Uncertainty: model SEE + demographic default uncertainty
// Each defaulted demographic adds ~3-4 kcal of uncertainty (height ±10cm = ±8.7,
// age ±10yr = ±6.0, body comp ±5% = ±5-8 kcal; halved for ±1σ)
const demoUncertainty = demo.defaultCount * 4;
const see = Math.sqrt(TOTAL_BOUT.see ** 2 + demoUncertainty ** 2);
const point = Math.max(0, kcal);
return {
kcal: Math.round(point),
lower: Math.max(0, Math.round(point - 1.96 * see)),
upper: Math.round(point + 1.96 * see),
see: Math.round(see * 10) / 10,
};
}
/**
* Estimate kcal per exercise within a workout (using individual equations).
* Useful for showing which exercises contributed most.
*/
export function estimatePerExerciseKcal(
exercises: ExerciseData[],
demographics?: Demographics
): { exerciseId: string; kcal: number; see: number; confidence: MappingConfidence }[] {
const demo = resolveDemographics(demographics);
return exercises.map((ex) => {
const { kcal, see } = estimateExerciseKcal(ex.exerciseId, ex.sets, demo);
const confidence = EXERCISE_MAP[ex.exerciseId]?.confidence ?? 'distant';
return { exerciseId: ex.exerciseId, kcal: Math.round(kcal * 10) / 10, see: Math.round(see * 10) / 10, confidence };
});
}
/**
* Estimate cumulative kcal across multiple workouts.
* Uncertainty decreases relative to the total as errors partially cancel.
*/
export function estimateCumulativeKcal(
workoutResults: { kcal: number; see: number }[]
): { kcal: number; lower: number; upper: number } {
let totalKcal = 0;
let sumSeeSquared = 0;
for (const w of workoutResults) {
totalKcal += w.kcal;
sumSeeSquared += w.see ** 2;
}
// Cumulative SEE = sqrt(sum of individual SEE²)
// This assumes errors are independent across workouts
const cumulativeSee = Math.sqrt(sumSeeSquared);
return {
kcal: Math.round(totalKcal),
lower: Math.max(0, Math.round(totalKcal - 1.96 * cumulativeSee)),
upper: Math.round(totalKcal + 1.96 * cumulativeSee),
};
}

View File

@@ -71,8 +71,14 @@ const translations: Translations = {
workout_singular: { en: 'Workout', de: 'Training' },
workouts_plural: { en: 'Workouts', de: 'Trainings' },
lifted: { en: 'Lifted', de: 'Gehoben' },
est_kcal: { en: 'Est. kcal', de: 'Gesch. kcal' },
kcal_set_profile: { en: 'Set sex & height in', de: 'Geschlecht & Grösse unter' },
distance_covered: { en: 'Distance Covered', de: 'Zurückgelegt' },
workouts_per_week: { en: 'Workouts per week', de: 'Trainings pro Woche' },
sex: { en: 'Sex', de: 'Geschlecht' },
male: { en: 'Male', de: 'Männlich' },
female: { en: 'Female', de: 'Weiblich' },
height: { en: 'Height (cm)', de: 'Grösse (cm)' },
no_workout_data: { en: 'No workout data to display yet.', de: 'Noch keine Trainingsdaten vorhanden.' },
weight: { en: 'Weight', de: 'Gewicht' },
@@ -167,6 +173,7 @@ const translations: Translations = {
// Measure page
measure_title: { en: 'Measure', de: 'Messen' },
profile: { en: 'Profile', de: 'Profil' },
new_measurement: { en: 'New Measurement', de: 'Neue Messung' },
edit_measurement: { en: 'Edit Measurement', de: 'Messung bearbeiten' },
weight_kg: { en: 'Weight (kg)', de: 'Gewicht (kg)' },

View File

@@ -3,7 +3,9 @@ import mongoose from 'mongoose';
const FitnessGoalSchema = new mongoose.Schema(
{
username: { type: String, required: true, unique: true },
weeklyWorkouts: { type: Number, required: true, default: 4, min: 1, max: 14 }
weeklyWorkouts: { type: Number, required: true, default: 4, min: 1, max: 14 },
sex: { type: String, enum: ['male', 'female'], default: 'male' },
heightCm: { type: Number, min: 100, max: 250 }
},
{ timestamps: true }
);
@@ -11,6 +13,8 @@ const FitnessGoalSchema = new mongoose.Schema(
interface IFitnessGoal {
username: string;
weeklyWorkouts: number;
sex?: 'male' | 'female';
heightCm?: number;
}
let _model: mongoose.Model<IFitnessGoal>;

View File

@@ -14,31 +14,36 @@ export const GET: RequestHandler = async ({ locals }) => {
// If no goal set, return early
if (weeklyWorkouts === null) {
return json({ weeklyWorkouts: null, streak: 0 });
return json({ weeklyWorkouts: null, streak: 0, sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null });
}
const streak = await computeStreak(user.nickname, weeklyWorkouts);
return json({ weeklyWorkouts, streak });
return json({ weeklyWorkouts, streak, sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null });
};
export const PUT: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
const { weeklyWorkouts } = await request.json();
const body = await request.json();
const { weeklyWorkouts, sex, heightCm } = body;
if (typeof weeklyWorkouts !== 'number' || weeklyWorkouts < 1 || weeklyWorkouts > 14 || !Number.isInteger(weeklyWorkouts)) {
return json({ error: 'weeklyWorkouts must be an integer between 1 and 14' }, { status: 400 });
}
const update: Record<string, unknown> = { weeklyWorkouts };
if (sex === 'male' || sex === 'female') update.sex = sex;
if (typeof heightCm === 'number' && heightCm >= 100 && heightCm <= 250) update.heightCm = heightCm;
await dbConnect();
await FitnessGoal.findOneAndUpdate(
const goal = await FitnessGoal.findOneAndUpdate(
{ username: user.nickname },
{ weeklyWorkouts },
{ upsert: true }
);
update,
{ upsert: true, new: true }
).lean() as any;
const streak = await computeStreak(user.nickname, weeklyWorkouts);
return json({ weeklyWorkouts, streak });
return json({ weeklyWorkouts, streak, sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null });
};
async function computeStreak(username: string, weeklyGoal: number): Promise<number> {

View File

@@ -5,6 +5,8 @@ import { dbConnect } from '$utils/db';
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 { FitnessGoal } from '$models/FitnessGoal';
export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
@@ -36,7 +38,23 @@ export const GET: RequestHandler = async ({ locals }) => {
}
]);
// Lifetime totals: tonnage lifted + cardio km
// Fetch user demographics for kcal estimation
const [goal, latestMeasurement] = await Promise.all([
FitnessGoal.findOne({ username: user.nickname }).lean() as any,
BodyMeasurement.findOne(
{ createdBy: user.nickname, 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,
};
// Lifetime totals: tonnage lifted + cardio km + kcal estimate
const allSessions = await WorkoutSession.find(
{ createdBy: user.nickname },
{ 'exercises.exerciseId': 1, 'exercises.sets': 1 }
@@ -44,23 +62,38 @@ export const GET: RequestHandler = async ({ locals }) => {
let totalTonnage = 0;
let totalCardioKm = 0;
const workoutKcalResults: { kcal: number; see: number }[] = [];
for (const s of allSessions) {
const strengthExercises: ExerciseData[] = [];
for (const ex of s.exercises) {
const exercise = getExerciseById(ex.exerciseId);
const metrics = getExerciseMetrics(exercise);
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) {
totalCardioKm += set.distance ?? 0;
} else {
totalTonnage += (set.weight ?? 0) * weightMultiplier * (set.reps ?? 0);
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 (strengthExercises.length > 0) {
const result = estimateWorkoutKcal(strengthExercises, demographics);
workoutKcalResults.push({ kcal: result.kcal, see: result.see });
}
}
const kcalEstimate = estimateCumulativeKcal(workoutKcalResults);
const weightMeasurements = await BodyMeasurement.find(
{ createdBy: user.nickname, weight: { $ne: null } },
{ date: 1, weight: 1, _id: 0 }
@@ -141,6 +174,7 @@ export const GET: RequestHandler = async ({ locals }) => {
totalWorkouts,
totalTonnage: Math.round(totalTonnage / 1000 * 10) / 10,
totalCardioKm: Math.round(totalCardioKm * 10) / 10,
kcalEstimate,
workoutsChart,
weightChart
});

View File

@@ -1,12 +1,13 @@
<script>
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X, RefreshCw, Gauge } from 'lucide-svelte';
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X, RefreshCw, Gauge, Flame } 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 ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import SetTable from '$lib/components/fitness/SetTable.svelte';
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
@@ -17,6 +18,27 @@
const session = $derived(data.session);
const kcalResult = $derived.by(() => {
if (!session?.exercises) return null;
/** @type {import('$lib/data/kcalEstimate').ExerciseData[]} */
const exercises = [];
for (const ex of session.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) exercises.push({ exerciseId: ex.exerciseId, sets });
}
if (exercises.length === 0) return null;
return estimateWorkoutKcal(exercises);
});
function checkDark() {
if (typeof document === 'undefined') return false;
const t = document.documentElement.dataset.theme;
@@ -500,6 +522,12 @@
<span>{Math.round(session.totalVolume).toLocaleString()} kg</span>
</div>
{/if}
{#if kcalResult}
<div class="stat-pill kcal">
<Flame size={14} />
<span>{kcalResult.kcal} &plusmn; {kcalResult.kcal - kcalResult.lower} kcal</span>
</div>
{/if}
{#if session.prs?.length > 0}
<div class="stat-pill pr">
<Trophy size={14} />
@@ -878,6 +906,11 @@
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.stat-pill.kcal {
color: var(--nord12);
border-color: var(--nord12);
background: color-mix(in srgb, var(--nord12) 10%, transparent);
}
.stat-pill.pr {
color: var(--nord13);
border-color: var(--nord13);

View File

@@ -1,13 +1,15 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const [latestRes, listRes] = await Promise.all([
const [latestRes, listRes, goalRes] = await Promise.all([
fetch('/api/fitness/measurements/latest'),
fetch('/api/fitness/measurements?limit=20')
fetch('/api/fitness/measurements?limit=20'),
fetch('/api/fitness/goal')
]);
return {
latest: await latestRes.json(),
measurements: await listRes.json()
measurements: await listRes.json(),
profile: goalRes.ok ? await goalRes.json() : {}
};
};

View File

@@ -14,6 +14,39 @@
let measurements = $state(data.measurements?.measurements ? [...data.measurements.measurements] : []);
let showForm = $state(false);
let saving = $state(false);
// Profile fields (sex, height) — stored in FitnessGoal
let profileSex = $state(data.profile?.sex ?? 'male');
let profileHeight = $state(data.profile?.heightCm != null ? String(data.profile.heightCm) : '');
let profileSaving = $state(false);
let profileDirty = $derived(
profileSex !== (data.profile?.sex ?? 'male') ||
profileHeight !== (data.profile?.heightCm != null ? String(data.profile.heightCm) : '')
);
async function saveProfile() {
profileSaving = true;
try {
/** @type {Record<string, unknown>} */
const body = {
weeklyWorkouts: data.profile?.weeklyWorkouts ?? 4,
sex: profileSex
};
const h = Number(profileHeight);
if (h >= 100 && h <= 250) body.heightCm = h;
const res = await fetch('/api/fitness/goal', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) {
const d = await res.json();
data.profile = d;
}
} finally {
profileSaving = false;
}
}
/** @type {string | null} */
let editingId = $state(null);
@@ -231,6 +264,28 @@
<div class="measure-page">
<h1>{t('measure_title', lang)}</h1>
<section class="profile-section">
<h2>{t('profile', lang)}</h2>
<div class="profile-row">
<div class="form-group">
<label for="p-sex">{t('sex', lang)}</label>
<select id="p-sex" bind:value={profileSex}>
<option value="male">{t('male', lang)}</option>
<option value="female">{t('female', lang)}</option>
</select>
</div>
<div class="form-group">
<label for="p-height">{t('height', lang)}</label>
<input id="p-height" type="number" min="100" max="250" placeholder="175" bind:value={profileHeight} />
</div>
{#if profileDirty}
<button class="profile-save-btn" onclick={saveProfile} disabled={profileSaving}>
{profileSaving ? t('saving', lang) : t('save', lang)}
</button>
{/if}
</div>
</section>
{#if showForm}
<form class="measure-form" onsubmit={(e) => { e.preventDefault(); saveMeasurement(); }}>
<div class="form-header">
@@ -375,6 +430,48 @@
color: var(--color-text-secondary);
}
/* Profile */
.profile-section {
background: var(--color-surface);
border-radius: 8px;
box-shadow: var(--shadow-sm);
padding: 0.75rem 1rem;
}
.profile-section h2 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
}
.profile-row {
display: flex;
gap: 0.75rem;
align-items: flex-end;
}
.profile-row select {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-elevated);
color: inherit;
font-size: 0.85rem;
}
.profile-save-btn {
padding: 0.4rem 0.75rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
align-self: flex-end;
margin-bottom: 0.4rem;
}
.profile-save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Form */
.measure-form {
background: var(--color-surface);

View File

@@ -1,9 +1,9 @@
<script>
import { page } from '$app/stores';
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import { Dumbbell, Route, Flame, Zap } from 'lucide-svelte';
import { Dumbbell, Route, Flame, Zap, Weight, Info } from 'lucide-svelte';
import { onMount } from 'svelte';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
@@ -38,6 +38,8 @@
let goalInput = $state(4);
let goalSaving = $state(false);
const hasDemographics = $derived(data.goal?.sex != null && data.goal?.heightCm != null);
function startGoalEdit() {
goalInput = goalWeekly ?? 4;
goalEditing = true;
@@ -134,10 +136,24 @@
<div class="card-label">{(stats.totalWorkouts ?? 0) === 1 ? t('workout_singular', lang) : t('workouts_plural', lang)}</div>
</div>
<div class="lifetime-card tonnage">
<div class="card-icon"><Flame size={24} /></div>
<div class="card-icon"><Weight size={24} /></div>
<div class="card-value">{stats.totalTonnage ?? 0}<span class="card-unit">t</span></div>
<div class="card-label">{t('lifted', lang)}</div>
</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>
{#if !hasDemographics}
<div class="card-hint">{t('kcal_set_profile', lang)} <a href="/fitness/{fitnessSlugs(lang).measure}">{t('measure_title', lang)}</a></div>
{/if}
</div>
{/if}
<div class="lifetime-card cardio">
<div class="card-icon"><Route size={24} /></div>
<div class="card-value">{stats.totalCardioKm ?? 0}<span class="card-unit">km</span></div>
@@ -210,7 +226,7 @@
.lifetime-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, 1fr);
gap: 0.6rem;
}
.lifetime-card {
@@ -224,7 +240,6 @@
box-shadow: var(--shadow-sm);
text-align: center;
position: relative;
overflow: hidden;
}
button.lifetime-card {
border: none;
@@ -247,6 +262,9 @@
background: var(--color-primary);
}
.lifetime-card.tonnage::before {
background: var(--nord10);
}
.lifetime-card.kcal::before {
background: var(--nord12);
}
.lifetime-card.cardio::before {
@@ -269,6 +287,10 @@
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.tonnage .card-icon {
color: var(--nord10);
background: color-mix(in srgb, var(--nord10) 15%, transparent);
}
.kcal .card-icon {
color: var(--nord12);
background: color-mix(in srgb, var(--nord12) 15%, transparent);
}
@@ -305,8 +327,57 @@
opacity: 0.7;
margin-top: 0.1rem;
}
.card-hint {
font-size: 0.55rem;
color: var(--color-text-secondary);
opacity: 0.7;
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;
}
@media (max-width: 600px) {
.card-hint a {
color: var(--nord12);
text-decoration: underline;
}
@media (max-width: 750px) {
.lifetime-cards {
grid-template-columns: repeat(2, 1fr);
}

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);