fitness: compute kcal server-side and store in session document
Previously kcal was computed on-the-fly in 3 places with inconsistent inputs (hardcoded 80kg, missing GPS data, no demographics). Now a shared computeSessionKcal() helper runs server-side using the best available method (GPS + real demographics) and stores the result in a new kcalEstimate field on WorkoutSession. Kcal is recomputed on save, recalculate, GPX upload, and GPX delete. The stats overview uses stored values with a legacy fallback for sessions saved before this change.
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
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 { Clock, Weight, Trophy, Route, Gauge, Flame } from 'lucide-svelte';
|
||||||
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
|
||||||
|
|
||||||
@@ -19,6 +17,7 @@
|
|||||||
* totalVolume?: number,
|
* totalVolume?: number,
|
||||||
* totalDistance?: number,
|
* totalDistance?: number,
|
||||||
* prs?: Array<any>,
|
* prs?: Array<any>,
|
||||||
|
* kcalEstimate?: { kcal: number, lower: number, upper: number, methods: string[] },
|
||||||
* exercises: Array<{
|
* exercises: Array<{
|
||||||
* exerciseId: string,
|
* exerciseId: string,
|
||||||
* totalDistance?: number,
|
* totalDistance?: number,
|
||||||
@@ -117,57 +116,8 @@
|
|||||||
return { points, viewBox: `0 0 ${vbW} ${vbH}` };
|
return { points, viewBox: `0 0 ${vbW} ${vbH}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Estimate kcal for this session (strength + cardio) */
|
/** Use server-computed kcal estimate (stored at save/recalculate time) */
|
||||||
const kcalResult = $derived.by(() => {
|
const kcalResult = $derived(session.kcalEstimate ?? null);
|
||||||
/** @type {import('$lib/data/kcalEstimate').ExerciseData[]} */
|
|
||||||
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')) {
|
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 */
|
/** Check if this session has any cardio exercise with GPS data */
|
||||||
const hasGpsCardio = $derived(session.exercises.some(ex => {
|
const hasGpsCardio = $derived(session.exercises.some(ex => {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Server-side kcal computation for a workout session.
|
||||||
|
* Uses the best available method: GPS track > distance+duration > flat rate.
|
||||||
|
* Fetches user demographics from DB for strength estimation.
|
||||||
|
*/
|
||||||
|
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||||
|
import { estimateWorkoutKcal, type ExerciseData, type Demographics } from '$lib/data/kcalEstimate';
|
||||||
|
import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate';
|
||||||
|
import { FitnessGoal } from '$models/FitnessGoal';
|
||||||
|
import { BodyMeasurement } from '$models/BodyMeasurement';
|
||||||
|
import type { IKcalEstimate, ICompletedExercise } from '$models/WorkoutSession';
|
||||||
|
|
||||||
|
export async function computeSessionKcal(
|
||||||
|
exercises: ICompletedExercise[],
|
||||||
|
username: string
|
||||||
|
): Promise<IKcalEstimate | undefined> {
|
||||||
|
// Fetch user demographics
|
||||||
|
const [goal, latestMeasurement] = await Promise.all([
|
||||||
|
FitnessGoal.findOne({ username }).lean() as any,
|
||||||
|
BodyMeasurement.findOne(
|
||||||
|
{ createdBy: username, 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,
|
||||||
|
};
|
||||||
|
const bodyWeightKg = demographics.bodyWeightKg ?? 80;
|
||||||
|
|
||||||
|
const strengthExercises: ExerciseData[] = [];
|
||||||
|
let cardioKcal = 0;
|
||||||
|
let cardioMarginSq = 0;
|
||||||
|
const methods = new Set<string>();
|
||||||
|
|
||||||
|
for (const ex of exercises) {
|
||||||
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
|
const metrics = getExerciseMetrics(exercise);
|
||||||
|
|
||||||
|
if (metrics.includes('distance')) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const hasGps = ex.gpsTrack && ex.gpsTrack.length >= 2;
|
||||||
|
if (dist > 0 || dur > 0 || hasGps) {
|
||||||
|
const r = estimateCardioKcal(ex.exerciseId, bodyWeightKg, {
|
||||||
|
gpsTrack: hasGps ? ex.gpsTrack : undefined,
|
||||||
|
distanceKm: dist || undefined,
|
||||||
|
durationMin: dur || undefined,
|
||||||
|
});
|
||||||
|
cardioKcal += r.kcal;
|
||||||
|
cardioMarginSq += (r.kcal - r.lower) ** 2;
|
||||||
|
methods.add(r.method);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const weightMultiplier = exercise?.bilateral ? 2 : 1;
|
||||||
|
const sets = ex.sets
|
||||||
|
.filter(s => s.completed && (s.reps ?? 0) > 0)
|
||||||
|
.map(s => ({
|
||||||
|
weight: (s.weight ?? 0) * weightMultiplier,
|
||||||
|
reps: s.reps ?? 0
|
||||||
|
}));
|
||||||
|
if (sets.length > 0) {
|
||||||
|
strengthExercises.push({ exerciseId: ex.exerciseId, sets });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const strengthResult = strengthExercises.length > 0
|
||||||
|
? estimateWorkoutKcal(strengthExercises, demographics)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!strengthResult && cardioKcal === 0) return undefined;
|
||||||
|
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@ export interface ICompletedExercise {
|
|||||||
totalDistance?: number; // km
|
totalDistance?: number; // km
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IKcalEstimate {
|
||||||
|
kcal: number;
|
||||||
|
lower: number;
|
||||||
|
upper: number;
|
||||||
|
methods: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IPr {
|
export interface IPr {
|
||||||
exerciseId: string;
|
exerciseId: string;
|
||||||
type: string; // 'est1rm' | 'maxWeight' | 'bestSetVolume' | 'repMax' | 'longestDistance' | 'fastestPace:<min>:<max>'
|
type: string; // 'est1rm' | 'maxWeight' | 'bestSetVolume' | 'repMax' | 'longestDistance' | 'fastestPace:<min>:<max>'
|
||||||
@@ -52,6 +59,7 @@ export interface IWorkoutSession {
|
|||||||
gpsTrack?: IGpsPoint[]; // Top-level GPS track for GPS-only workouts
|
gpsTrack?: IGpsPoint[]; // Top-level GPS track for GPS-only workouts
|
||||||
gpsPreview?: number[][]; // Downsampled [[lat,lng], ...] for card preview
|
gpsPreview?: number[][]; // Downsampled [[lat,lng], ...] for card preview
|
||||||
prs?: IPr[];
|
prs?: IPr[];
|
||||||
|
kcalEstimate?: IKcalEstimate;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
createdBy: string; // username/nickname of the person who performed the workout
|
createdBy: string; // username/nickname of the person who performed the workout
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
@@ -207,6 +215,16 @@ const WorkoutSessionSchema = new mongoose.Schema(
|
|||||||
reps: Number,
|
reps: Number,
|
||||||
_id: false
|
_id: false
|
||||||
}],
|
}],
|
||||||
|
kcalEstimate: {
|
||||||
|
type: {
|
||||||
|
kcal: { type: Number, required: true },
|
||||||
|
lower: { type: Number, required: true },
|
||||||
|
upper: { type: Number, required: true },
|
||||||
|
methods: { type: [String], required: true },
|
||||||
|
},
|
||||||
|
default: undefined,
|
||||||
|
_id: false,
|
||||||
|
},
|
||||||
notes: {
|
notes: {
|
||||||
type: String,
|
type: String,
|
||||||
trim: true,
|
trim: true,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { WorkoutTemplate } from '$models/WorkoutTemplate';
|
|||||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||||
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
|
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
|
||||||
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
||||||
|
import { computeSessionKcal } from '$lib/server/computeSessionKcal';
|
||||||
|
|
||||||
function estimatedOneRepMax(weight: number, reps: number): number {
|
function estimatedOneRepMax(weight: number, reps: number): number {
|
||||||
if (reps <= 0 || weight <= 0) return 0;
|
if (reps <= 0 || weight <= 0) return 0;
|
||||||
@@ -155,6 +156,9 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
return ex;
|
return ex;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Compute kcal estimate using best available method (GPS + demographics)
|
||||||
|
const kcalEstimate = await computeSessionKcal(processedExercises, session.user.nickname);
|
||||||
|
|
||||||
const workoutSession = new WorkoutSession({
|
const workoutSession = new WorkoutSession({
|
||||||
templateId,
|
templateId,
|
||||||
templateName,
|
templateName,
|
||||||
@@ -170,6 +174,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
gpsTrack: gpsTrack?.length ? gpsTrack : undefined,
|
gpsTrack: gpsTrack?.length ? gpsTrack : undefined,
|
||||||
gpsPreview,
|
gpsPreview,
|
||||||
prs: prs.length > 0 ? prs : undefined,
|
prs: prs.length > 0 ? prs : undefined,
|
||||||
|
kcalEstimate,
|
||||||
notes,
|
notes,
|
||||||
createdBy: session.user.nickname
|
createdBy: session.user.nickname
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { dbConnect } from '$utils/db';
|
|||||||
import { WorkoutSession } from '$models/WorkoutSession';
|
import { WorkoutSession } from '$models/WorkoutSession';
|
||||||
import type { IGpsPoint } from '$models/WorkoutSession';
|
import type { IGpsPoint } from '$models/WorkoutSession';
|
||||||
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
||||||
|
import { computeSessionKcal } from '$lib/server/computeSessionKcal';
|
||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
/** Haversine distance in km between two points */
|
/** Haversine distance in km between two points */
|
||||||
@@ -114,11 +115,18 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
sets[0].duration = durationMin;
|
sets[0].duration = durationMin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recompute kcal with the new GPS data
|
||||||
|
workoutSession.kcalEstimate = await computeSessionKcal(
|
||||||
|
workoutSession.exercises,
|
||||||
|
session.user.nickname
|
||||||
|
) ?? undefined;
|
||||||
|
|
||||||
await workoutSession.save();
|
await workoutSession.save();
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
points: track.length,
|
points: track.length,
|
||||||
distance: workoutSession.exercises[exerciseIdx].totalDistance
|
distance: workoutSession.exercises[exerciseIdx].totalDistance,
|
||||||
|
kcalEstimate: workoutSession.kcalEstimate
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing GPX upload:', error);
|
console.error('Error processing GPX upload:', error);
|
||||||
@@ -158,9 +166,16 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
workoutSession.exercises[exerciseIdx].gpsTrack = undefined;
|
workoutSession.exercises[exerciseIdx].gpsTrack = undefined;
|
||||||
workoutSession.exercises[exerciseIdx].gpsPreview = undefined;
|
workoutSession.exercises[exerciseIdx].gpsPreview = undefined;
|
||||||
workoutSession.exercises[exerciseIdx].totalDistance = undefined;
|
workoutSession.exercises[exerciseIdx].totalDistance = undefined;
|
||||||
|
|
||||||
|
// Recompute kcal without the GPS data
|
||||||
|
workoutSession.kcalEstimate = await computeSessionKcal(
|
||||||
|
workoutSession.exercises,
|
||||||
|
session.user.nickname
|
||||||
|
) ?? undefined;
|
||||||
|
|
||||||
await workoutSession.save();
|
await workoutSession.save();
|
||||||
|
|
||||||
return json({ success: true });
|
return json({ success: true, kcalEstimate: workoutSession.kcalEstimate });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error removing GPS track:', error);
|
console.error('Error removing GPS track:', error);
|
||||||
return json({ error: 'Failed to remove GPS track' }, { status: 500 });
|
return json({ error: 'Failed to remove GPS track' }, { status: 500 });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { IPr } from '$models/WorkoutSession';
|
|||||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||||
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
|
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
|
||||||
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
||||||
|
import { computeSessionKcal } from '$lib/server/computeSessionKcal';
|
||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
function estimatedOneRepMax(weight: number, reps: number): number {
|
function estimatedOneRepMax(weight: number, reps: number): number {
|
||||||
@@ -122,12 +123,19 @@ export const POST: RequestHandler = async ({ params, locals }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recompute kcal estimate using best available method
|
||||||
|
const kcalEstimate = await computeSessionKcal(
|
||||||
|
workoutSession.exercises,
|
||||||
|
session.user.nickname
|
||||||
|
);
|
||||||
|
|
||||||
// Use $set to only update computed fields, preserving gpsTrack data
|
// Use $set to only update computed fields, preserving gpsTrack data
|
||||||
await WorkoutSession.updateOne({ _id: workoutSession._id }, {
|
await WorkoutSession.updateOne({ _id: workoutSession._id }, {
|
||||||
$set: {
|
$set: {
|
||||||
totalVolume: totalVolume > 0 ? totalVolume : undefined,
|
totalVolume: totalVolume > 0 ? totalVolume : undefined,
|
||||||
totalDistance: totalDistance > 0 ? totalDistance : undefined,
|
totalDistance: totalDistance > 0 ? totalDistance : undefined,
|
||||||
prs: prs.length > 0 ? prs : undefined,
|
prs: prs.length > 0 ? prs : undefined,
|
||||||
|
kcalEstimate: kcalEstimate ?? undefined,
|
||||||
...gpsPreviewUpdates
|
...gpsPreviewUpdates
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -135,7 +143,8 @@ export const POST: RequestHandler = async ({ params, locals }) => {
|
|||||||
return json({
|
return json({
|
||||||
totalVolume: totalVolume > 0 ? totalVolume : undefined,
|
totalVolume: totalVolume > 0 ? totalVolume : undefined,
|
||||||
totalDistance: totalDistance > 0 ? totalDistance : undefined,
|
totalDistance: totalDistance > 0 ? totalDistance : undefined,
|
||||||
prs: prs.length
|
prs: prs.length,
|
||||||
|
kcalEstimate
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error recalculating session:', error);
|
console.error('Error recalculating session:', error);
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { dbConnect } from '$utils/db';
|
|||||||
import { WorkoutSession } from '$models/WorkoutSession';
|
import { WorkoutSession } from '$models/WorkoutSession';
|
||||||
import { BodyMeasurement } from '$models/BodyMeasurement';
|
import { BodyMeasurement } from '$models/BodyMeasurement';
|
||||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||||
import { estimateWorkoutKcal, estimateCumulativeKcal, type ExerciseData, type Demographics } from '$lib/data/kcalEstimate';
|
import { estimateWorkoutKcal, type ExerciseData, type Demographics } from '$lib/data/kcalEstimate';
|
||||||
import { estimateCardioKcal, estimateCumulativeCardioKcal, type CardioEstimateResult } from '$lib/data/cardioKcalEstimate';
|
import { estimateCardioKcal, type CardioEstimateResult } from '$lib/data/cardioKcalEstimate';
|
||||||
import { FitnessGoal } from '$models/FitnessGoal';
|
import { FitnessGoal } from '$models/FitnessGoal';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ locals }) => {
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
@@ -56,64 +56,90 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Lifetime totals: tonnage lifted + cardio km + kcal estimate
|
// Lifetime totals: tonnage lifted + cardio km + kcal estimate
|
||||||
|
// Use stored kcalEstimate when available; fall back to on-the-fly for legacy sessions
|
||||||
const allSessions = await WorkoutSession.find(
|
const allSessions = await WorkoutSession.find(
|
||||||
{ createdBy: user.nickname },
|
{ createdBy: user.nickname },
|
||||||
{ 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1 }
|
{ 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1, kcalEstimate: 1 }
|
||||||
).lean();
|
).lean();
|
||||||
|
|
||||||
let totalTonnage = 0;
|
let totalTonnage = 0;
|
||||||
let totalCardioKm = 0;
|
let totalCardioKm = 0;
|
||||||
const workoutKcalResults: { kcal: number; see: number }[] = [];
|
let totalKcal = 0;
|
||||||
const cardioKcalResults: CardioEstimateResult[] = [];
|
let totalMarginSq = 0;
|
||||||
const bodyWeightKg = demographics.bodyWeightKg ?? 80;
|
const bodyWeightKg = demographics.bodyWeightKg ?? 80;
|
||||||
|
|
||||||
for (const s of allSessions) {
|
for (const s of allSessions) {
|
||||||
const strengthExercises: ExerciseData[] = [];
|
// Accumulate tonnage and cardio km
|
||||||
for (const ex of s.exercises) {
|
for (const ex of s.exercises) {
|
||||||
const exercise = getExerciseById(ex.exerciseId);
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
const metrics = getExerciseMetrics(exercise);
|
const metrics = getExerciseMetrics(exercise);
|
||||||
const isCardio = metrics.includes('distance');
|
const isCardio = metrics.includes('distance');
|
||||||
const weightMultiplier = exercise?.bilateral ? 2 : 1;
|
const weightMultiplier = exercise?.bilateral ? 2 : 1;
|
||||||
const completedSets: { weight: number; reps: number }[] = [];
|
|
||||||
if (isCardio) {
|
if (isCardio) {
|
||||||
let dist = (ex as any).totalDistance ?? 0;
|
|
||||||
let dur = 0;
|
|
||||||
for (const set of ex.sets) {
|
for (const set of ex.sets) {
|
||||||
if (!set.completed) continue;
|
if (!set.completed) continue;
|
||||||
if (!dist) dist += set.distance ?? 0;
|
|
||||||
dur += set.duration ?? 0;
|
|
||||||
totalCardioKm += set.distance ?? 0;
|
totalCardioKm += set.distance ?? 0;
|
||||||
}
|
}
|
||||||
if (dist > 0 || dur > 0) {
|
|
||||||
cardioKcalResults.push(estimateCardioKcal(ex.exerciseId, bodyWeightKg, {
|
|
||||||
distanceKm: dist || undefined,
|
|
||||||
durationMin: dur || undefined,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
for (const set of ex.sets) {
|
for (const set of ex.sets) {
|
||||||
if (!set.completed) continue;
|
if (!set.completed) continue;
|
||||||
const w = (set.weight ?? 0) * weightMultiplier;
|
totalTonnage += (set.weight ?? 0) * (set.reps ?? 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);
|
// Use stored kcal or fall back to on-the-fly computation for legacy sessions
|
||||||
workoutKcalResults.push({ kcal: result.kcal, see: result.see });
|
if (s.kcalEstimate) {
|
||||||
|
totalKcal += s.kcalEstimate.kcal;
|
||||||
|
totalMarginSq += (s.kcalEstimate.kcal - s.kcalEstimate.lower) ** 2;
|
||||||
|
} else {
|
||||||
|
// Legacy session: compute on-the-fly (no GPS, uses current demographics)
|
||||||
|
const strengthExercises: ExerciseData[] = [];
|
||||||
|
const cardioKcalResults: CardioEstimateResult[] = [];
|
||||||
|
for (const ex of s.exercises) {
|
||||||
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
|
const metrics = getExerciseMetrics(exercise);
|
||||||
|
if (metrics.includes('distance')) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (dist > 0 || dur > 0) {
|
||||||
|
cardioKcalResults.push(estimateCardioKcal(ex.exerciseId, bodyWeightKg, {
|
||||||
|
distanceKm: dist || undefined,
|
||||||
|
durationMin: dur || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const weightMultiplier = exercise?.bilateral ? 2 : 1;
|
||||||
|
const sets: { weight: number; reps: number }[] = [];
|
||||||
|
for (const set of ex.sets) {
|
||||||
|
if (!set.completed) continue;
|
||||||
|
if (set.reps) sets.push({ weight: (set.weight ?? 0) * weightMultiplier, reps: set.reps });
|
||||||
|
}
|
||||||
|
if (sets.length > 0) strengthExercises.push({ exerciseId: ex.exerciseId, sets });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sessionKcal = 0;
|
||||||
|
let sessionMarginSq = 0;
|
||||||
|
if (strengthExercises.length > 0) {
|
||||||
|
const r = estimateWorkoutKcal(strengthExercises, demographics);
|
||||||
|
sessionKcal += r.kcal;
|
||||||
|
sessionMarginSq += (r.kcal - r.lower) ** 2;
|
||||||
|
}
|
||||||
|
for (const r of cardioKcalResults) {
|
||||||
|
sessionKcal += r.kcal;
|
||||||
|
sessionMarginSq += (r.kcal - r.lower) ** 2;
|
||||||
|
}
|
||||||
|
totalKcal += sessionKcal;
|
||||||
|
totalMarginSq += sessionMarginSq;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const strengthKcal = estimateCumulativeKcal(workoutKcalResults);
|
const combinedMargin = Math.round(Math.sqrt(totalMarginSq));
|
||||||
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 = {
|
const kcalEstimate = {
|
||||||
kcal: totalKcal,
|
kcal: totalKcal,
|
||||||
lower: Math.max(0, totalKcal - combinedMargin),
|
lower: Math.max(0, totalKcal - combinedMargin),
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
const sl = $derived(fitnessSlugs(lang));
|
const sl = $derived(fitnessSlugs(lang));
|
||||||
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
|
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
|
||||||
import { formatPaceRangeLabel, formatPaceValue } from '$lib/data/cardioPrRanges';
|
import { formatPaceRangeLabel, formatPaceValue } from '$lib/data/cardioPrRanges';
|
||||||
import { estimateWorkoutKcal } from '$lib/data/kcalEstimate';
|
|
||||||
import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate';
|
|
||||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||||
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
||||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||||
@@ -21,65 +19,8 @@
|
|||||||
|
|
||||||
const session = $derived(data.session);
|
const session = $derived(data.session);
|
||||||
|
|
||||||
const kcalResult = $derived.by(() => {
|
/** Use server-computed kcal estimate (stored at save/recalculate time) */
|
||||||
if (!session?.exercises) return null;
|
const kcalResult = $derived(session?.kcalEstimate ?? null);
|
||||||
/** @type {import('$lib/data/kcalEstimate').ExerciseData[]} */
|
|
||||||
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')) {
|
|
||||||
// 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)
|
|
||||||
.map((/** @type {any} */ s) => ({
|
|
||||||
weight: (s.weight ?? 0) * weightMultiplier,
|
|
||||||
reps: s.reps ?? 0
|
|
||||||
}));
|
|
||||||
if (sets.length > 0) strengthExercises.push({ exerciseId: ex.exerciseId, sets });
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }>} */
|
/** @type {Record<string, { label: string, doi?: string }>} */
|
||||||
const METHOD_CITATIONS = {
|
const METHOD_CITATIONS = {
|
||||||
|
|||||||
Reference in New Issue
Block a user