fix: calorie balance uses per-day TDEE from SMA trend weight + workout kcal

Balance is now intake minus estimated expenditure rather than intake
minus calorie goal. TDEE computed per day using that day's SMA trend
weight (Mifflin-St Jeor BMR × NEAT multiplier) plus tracked workout
calories, so a -500 kcal cut shows ~-500 on the balance card.
This commit is contained in:
2026-04-08 16:56:10 +02:00
parent 111fa91427
commit 340b4f6023
3 changed files with 94 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.14.0",
"version": "1.14.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -5,6 +5,7 @@ import { dbConnect } from '$utils/db';
import { FoodLogEntry } from '$models/FoodLogEntry';
import { FitnessGoal } from '$models/FitnessGoal';
import { BodyMeasurement } from '$models/BodyMeasurement';
import { WorkoutSession } from '$models/WorkoutSession';
export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
@@ -21,7 +22,7 @@ export const GET: RequestHandler = async ({ locals }) => {
const thirtyDaysAgo = new Date(todayStart);
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
const [entries30d, goal, weightMeasurements] = await Promise.all([
const [entries30d, goal, weightMeasurements, workoutSessions7d] = await Promise.all([
FoodLogEntry.find({
createdBy: user.nickname,
date: { $gte: thirtyDaysAgo, $lt: todayStart },
@@ -31,21 +32,53 @@ export const GET: RequestHandler = async ({ locals }) => {
BodyMeasurement.find(
{ createdBy: user.nickname, weight: { $ne: null } },
{ date: 1, weight: 1, _id: 0 }
).sort({ date: -1 }).limit(14).lean() as any[],
).sort({ date: 1 }).lean() as any[],
WorkoutSession.find(
{ createdBy: user.nickname, startTime: { $gte: sevenDaysAgo, $lt: todayStart }, 'kcalEstimate.kcal': { $gt: 0 } },
{ startTime: 1, 'kcalEstimate.kcal': 1, _id: 0 }
).lean() as any[],
]);
// Compute trend weight (SMA of last measurements, same algo as overview)
// Also build per-date SMA lookup for daily TDEE calculation
let trendWeight: number | null = null;
const trendWeightByDate = new Map<string, number>();
if (weightMeasurements.length > 0) {
const weights = weightMeasurements.slice().reverse().map((m: any) => m.weight as number);
const w = Math.min(7, Math.max(2, Math.floor(weights.length / 2)));
const lastIdx = weights.length - 1;
// weightMeasurements sorted chronologically (ascending)
const allWeights = weightMeasurements.map((m: any) => ({
date: new Date(m.date).toISOString().slice(0, 10),
weight: m.weight as number
}));
const w = Math.min(7, Math.max(2, Math.floor(allWeights.length / 2)));
// Compute SMA at each measurement point
for (let idx = 0; idx < allWeights.length; idx++) {
const k = Math.min(w, idx + 1);
let sum = 0;
for (let j = idx - k + 1; j <= idx; j++) sum += allWeights[j].weight;
const sma = Math.round((sum / k) * 100) / 100;
trendWeightByDate.set(allWeights[idx].date, sma);
}
// Latest trend weight (for protein/kg and fallback)
const lastIdx = allWeights.length - 1;
const k = Math.min(w, lastIdx + 1);
let sum = 0;
for (let j = lastIdx - k + 1; j <= lastIdx; j++) sum += weights[j];
for (let j = lastIdx - k + 1; j <= lastIdx; j++) sum += allWeights[j].weight;
trendWeight = Math.round((sum / k) * 100) / 100;
}
/** Get trend weight for a date: exact match or most recent prior measurement's SMA */
function getTrendWeightForDate(dateStr: string): number | null {
if (trendWeightByDate.has(dateStr)) return trendWeightByDate.get(dateStr)!;
// Find the latest measurement on or before this date
let latest: number | null = null;
for (const [d, tw] of trendWeightByDate) {
if (d <= dateStr) latest = tw;
}
return latest;
}
// Group entries by date string
const byDate = new Map<string, typeof entries30d>();
for (const entry of entries30d) {
@@ -70,12 +103,38 @@ export const GET: RequestHandler = async ({ locals }) => {
const dailyCalorieGoal = goal?.dailyCalories ?? null;
// NEAT-only multipliers (lower than standard TDEE multipliers because
// we add tracked workout kcal separately to avoid double-counting)
const neatMultipliers: Record<string, number> = {
sedentary: 1.2, light: 1.3, moderate: 1.4, very_active: 1.5
};
const neatMult = neatMultipliers[goal?.activityLevel ?? 'light'] ?? 1.3;
const canComputeTdee = !!(goal?.heightCm && goal?.birthYear && trendWeight);
/** Compute daily TDEE using per-day trend weight */
function getDailyTdee(dateStr: string): number | null {
if (!canComputeTdee) return null;
const dayWeight = getTrendWeightForDate(dateStr);
if (!dayWeight) return null;
const age = now.getFullYear() - goal.birthYear;
const bmr = 10 * dayWeight + 6.25 * goal.heightCm - 5 * age + (goal.sex === 'female' ? -161 : 5);
return bmr * neatMult;
}
// Group workout kcal by date for the 7-day window
const workoutKcalByDate = new Map<string, number>();
for (const s of workoutSessions7d) {
const key = new Date(s.startTime).toISOString().slice(0, 10);
workoutKcalByDate.set(key, (workoutKcalByDate.get(key) ?? 0) + (s.kcalEstimate?.kcal ?? 0));
}
// 7-day averages (only days with logged entries)
const sevenDayStr = sevenDaysAgo.toISOString().slice(0, 10);
const recent7 = dailyTotals.filter(d => d.date >= sevenDayStr);
let avgProteinPerKg: number | null = null;
let avgCalorieBalance: number | null = null;
let avgDailyExpenditure: number | null = null;
let macroSplit: { protein: number; fat: number; carbs: number } | null = null;
if (recent7.length > 0) {
@@ -88,8 +147,25 @@ export const GET: RequestHandler = async ({ locals }) => {
avgProteinPerKg = Math.round((avgProtein / trendWeight) * 100) / 100;
}
if (dailyCalorieGoal) {
avgCalorieBalance = Math.round(avgCalories - dailyCalorieGoal);
// Calorie balance: intake minus estimated expenditure (per-day TDEE + workout kcal)
if (canComputeTdee) {
// Build all 7 calendar days and compute expenditure for each
let totalExpenditure = 0;
let expenditureDays = 0;
for (let i = 1; i <= 7; i++) {
const d = new Date(todayStart);
d.setUTCDate(d.getUTCDate() - i);
const dateStr = d.toISOString().slice(0, 10);
const dayTdee = getDailyTdee(dateStr);
if (dayTdee != null) {
totalExpenditure += dayTdee + (workoutKcalByDate.get(dateStr) ?? 0);
expenditureDays++;
}
}
if (expenditureDays > 0) {
avgDailyExpenditure = Math.round(totalExpenditure / expenditureDays);
avgCalorieBalance = Math.round(avgCalories - avgDailyExpenditure);
}
}
// Macro split by calorie contribution
@@ -147,6 +223,7 @@ export const GET: RequestHandler = async ({ locals }) => {
return json({
avgProteinPerKg,
avgCalorieBalance,
avgDailyExpenditure,
adherencePercent,
adherenceDays,
macroSplit,

View File

@@ -301,8 +301,13 @@
{#if showBalanceInfo}
<div class="card-info-tooltip">
{lang === 'en'
? 'Average daily calories eaten minus your calorie goal over the last 7 days. Negative = deficit, positive = surplus.'
: 'Durchschnittlich gegessene Kalorien minus dein Kalorienziel der letzten 7 Tage. Negativ = Defizit, positiv = Überschuss.'}
? 'Average daily calories eaten minus estimated expenditure (TDEE + tracked workout calories) over the last 7 days. Negative = deficit, positive = surplus.'
: 'Durchschnittlich gegessene Kalorien minus geschätzter Verbrauch (TDEE + erfasste Trainingskilokalorien) der letzten 7 Tage. Negativ = Defizit, positiv = Überschuss.'}
{#if ns.avgDailyExpenditure}
{lang === 'en'
? `Est. daily expenditure: ~${ns.avgDailyExpenditure} kcal`
: `Geschätzter Tagesverbrauch: ~${ns.avgDailyExpenditure} kcal`}
{/if}
</div>
{/if}
</div>
@@ -310,7 +315,7 @@
{#if ns.avgCalorieBalance != null}
{t('seven_day_avg', lang)}
{:else}
{t('no_calorie_goal', lang)}
{lang === 'en' ? 'Set height, birth year & weight' : 'Größe, Geburtsjahr & Gewicht eintragen'}
{/if}
</div>
</div>