From 340b4f60238bfc5b21001609df51a9d5d8c54c59 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Wed, 8 Apr 2026 16:56:10 +0200 Subject: [PATCH] fix: calorie balance uses per-day TDEE from SMA trend weight + workout kcal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 2 +- .../api/fitness/stats/nutrition/+server.ts | 93 +++++++++++++++++-- .../fitness/[stats=fitnessStats]/+page.svelte | 11 ++- 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 8c88391..71bb464 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.14.0", + "version": "1.14.1", "private": true, "type": "module", "scripts": { diff --git a/src/routes/api/fitness/stats/nutrition/+server.ts b/src/routes/api/fitness/stats/nutrition/+server.ts index 6422b92..a15fa0b 100644 --- a/src/routes/api/fitness/stats/nutrition/+server.ts +++ b/src/routes/api/fitness/stats/nutrition/+server.ts @@ -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(); 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(); 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 = { + 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(); + 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, diff --git a/src/routes/fitness/[stats=fitnessStats]/+page.svelte b/src/routes/fitness/[stats=fitnessStats]/+page.svelte index 243a3ce..3c2d0b8 100644 --- a/src/routes/fitness/[stats=fitnessStats]/+page.svelte +++ b/src/routes/fitness/[stats=fitnessStats]/+page.svelte @@ -301,8 +301,13 @@ {#if showBalanceInfo}
{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}
{/if} @@ -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}