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:
@@ -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} ± {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);
|
||||
}
|
||||
|
||||
410
src/lib/data/kcalEstimate.ts
Normal file
410
src/lib/data/kcalEstimate.ts
Normal 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.1532–1537.
|
||||
* 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
|
||||
* 60–70 % 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, 60–70 % 1RM, 2–3 sets of
|
||||
* 8–12 reps. Accuracy degrades for very different protocols.
|
||||
* • Demographics limited to ages 20–58.
|
||||
* • 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),
|
||||
};
|
||||
}
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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} ± {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);
|
||||
|
||||
@@ -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() : {}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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} ± {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);
|
||||
|
||||
Reference in New Issue
Block a user