From 0ad72ddf24eb7279bc58abde981640494108ac94 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 10 Apr 2026 08:01:00 +0200 Subject: [PATCH] fix: compute macro targets dynamically from protein goal and body weight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fat/carb percentages were stored as absolute values that didn't account for protein g/kg varying with body weight. Now protein calories are computed first, and remaining calories are split between fat and carbs by their stored ratio — guaranteeing all macros sum to the calorie goal. Exercise burned calories also flow into fat/carb targets via a new effectiveCalorieGoal derived. Goal editor ring preview and labels updated to show computed actual percentages. --- package.json | 2 +- src/lib/js/fitnessI18n.ts | 4 +- .../api/fitness/stats/nutrition/+server.ts | 29 ++++-- .../[nutrition=fitnessNutrition]/+page.svelte | 89 ++++++++++++++----- 4 files changed, 89 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index b79505f..d8875d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.23.3", + "version": "1.23.4", "private": true, "type": "module", "scripts": { diff --git a/src/lib/js/fitnessI18n.ts b/src/lib/js/fitnessI18n.ts index 6272668..3b408d8 100644 --- a/src/lib/js/fitnessI18n.ts +++ b/src/lib/js/fitnessI18n.ts @@ -282,8 +282,8 @@ const translations: Translations = { protein_goal: { en: 'Protein goal', de: 'Proteinziel' }, protein_fixed: { en: 'Fixed (g/day)', de: 'Fest (g/Tag)' }, protein_per_kg: { en: 'Per kg bodyweight', de: 'Pro kg Körpergewicht' }, - fat_percent: { en: 'Fat (%)', de: 'Fett (%)' }, - carb_percent: { en: 'Carbs (%)', de: 'Kohlenhydrate (%)' }, + fat_percent: { en: 'Fat ratio', de: 'Fett-Anteil' }, + carb_percent: { en: 'Carbs ratio', de: 'KH-Anteil' }, kcal: { en: 'kcal', de: 'kcal' }, protein: { en: 'Protein', de: 'Protein' }, fat: { en: 'Fat', de: 'Fett' }, diff --git a/src/routes/api/fitness/stats/nutrition/+server.ts b/src/routes/api/fitness/stats/nutrition/+server.ts index b4dabec..a87ab1b 100644 --- a/src/routes/api/fitness/stats/nutrition/+server.ts +++ b/src/routes/api/fitness/stats/nutrition/+server.ts @@ -204,21 +204,34 @@ export const GET: RequestHandler = async ({ locals }) => { adherencePercent = Math.round(withinRange / totalDays * 100); } - // Macro targets from goal + // Macro targets from goal — protein gets priority, remaining kcal split + // between fat and carbs proportionally to the stored fat:carb ratio. let macroTargets: { protein: number | null; fat: number | null; carbs: number | null } = { protein: null, fat: null, carbs: null }; - if (goal) { - // Compute protein percent of calories - if (goal.proteinTarget && dailyCalorieGoal) { - let proteinGrams = goal.proteinTarget; + if (goal && dailyCalorieGoal) { + let proteinGrams: number | null = null; + if (goal.proteinTarget) { + proteinGrams = goal.proteinTarget; if (goal.proteinMode === 'per_kg' && trendWeight) { proteinGrams = goal.proteinTarget * trendWeight; } - macroTargets.protein = Math.round((proteinGrams * 4) / dailyCalorieGoal * 100); } - if (goal.fatPercent != null) macroTargets.fat = goal.fatPercent; - if (goal.carbPercent != null) macroTargets.carbs = goal.carbPercent; + if (proteinGrams != null) { + const proteinPct = Math.min(Math.round((proteinGrams * 4) / dailyCalorieGoal * 100), 100); + macroTargets.protein = proteinPct; + const remainingPct = 100 - proteinPct; + const fatRatio = goal.fatPercent ?? 0; + const carbRatio = goal.carbPercent ?? 0; + const ratioSum = fatRatio + carbRatio; + if (ratioSum > 0) { + macroTargets.fat = Math.round(remainingPct * fatRatio / ratioSum); + macroTargets.carbs = remainingPct - macroTargets.fat; + } + } else { + if (goal.fatPercent != null) macroTargets.fat = goal.fatPercent; + if (goal.carbPercent != null) macroTargets.carbs = goal.carbPercent; + } } return json({ diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte b/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte index 8b6e40c..3ed1dc5 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte @@ -149,14 +149,32 @@ } // Macro ring preview (derived from edit fields) + // Protein gets priority; remaining kcal split between fat:carb by ratio const RING_R = 48; const RING_C = 2 * Math.PI * RING_R; const RING_GAP = 4; const editMacroRing = $derived.by(() => { const cal = Number(editCalories) || 0; - const fat = Number(editFatPercent) || 0; - const carb = Number(editCarbPercent) || 0; - const prot = Math.max(0, 100 - fat - carb); + const fatRatio = Number(editFatPercent) || 0; + const carbRatio = Number(editCarbPercent) || 0; + let prot = 0, fat = 0, carb = 0; + if (cal > 0) { + // Compute protein % from target + let protGrams = Number(editProteinTarget) || 0; + if (editProteinMode === 'per_kg' && latestWeight) { + protGrams = protGrams * latestWeight; + } + prot = Math.min(Math.round((protGrams * 4) / cal * 100), 100); + const remaining = 100 - prot; + const ratioSum = fatRatio + carbRatio; + if (ratioSum > 0) { + fat = Math.round(remaining * fatRatio / ratioSum); + carb = remaining - fat; + } else { + fat = 0; + carb = remaining; + } + } const fatDeg = (fat / 100) * 360; const carbDeg = (carb / 100) * 360; const fatLen = (fat / 100) * RING_C; @@ -172,12 +190,9 @@ const stepCalSummary = $derived( editCalories ? `${editCalories} kcal` : '—' ); - const stepMacroSummary = $derived.by(() => { - const f = Number(editFatPercent) || 0; - const c = Number(editCarbPercent) || 0; - const p = Math.max(0, 100 - f - c); - return `P${p}% F${f}% C${c}%`; - }); + const stepMacroSummary = $derived( + `P${editMacroRing.prot}% F${editMacroRing.fat}% C${editMacroRing.carb}%` + ); async function saveGoals() { goalSaving = true; @@ -397,7 +412,14 @@ }; }); - // Protein goal in grams + // --- Burned kcal --- + // svelte-ignore state_referenced_locally + let exerciseKcal = $state(Number(data.exerciseKcal) || 0); + + // Effective daily calorie goal including exercise burned calories + const effectiveCalorieGoal = $derived(goalCalories ? goalCalories + (exerciseKcal || 0) : null); + + // Protein goal in grams (fixed by body weight, unaffected by exercise) const proteinGoalGrams = $derived.by(() => { if (goalProteinTarget) { if (goalProteinMode === 'per_kg' && latestWeight) { @@ -406,20 +428,34 @@ if (goalProteinMode === 'fixed') return goalProteinTarget; } // Fallback: derive from remaining calorie % (100 - fat% - carb%) - if (goalCalories && goalFatPercent != null && goalCarbPercent != null) { + if (effectiveCalorieGoal && goalFatPercent != null && goalCarbPercent != null) { const proteinPct = 100 - (goalFatPercent || 0) - (goalCarbPercent || 0); - if (proteinPct > 0) return (goalCalories * proteinPct / 100) / 4; + if (proteinPct > 0) return (effectiveCalorieGoal * proteinPct / 100) / 4; } return null; }); - // Fat/carb goals in grams (from calorie %) - const fatGoalGrams = $derived(goalCalories && goalFatPercent ? (goalCalories * goalFatPercent / 100) / 9 : null); - const carbGoalGrams = $derived(goalCalories && goalCarbPercent ? (goalCalories * goalCarbPercent / 100) / 4 : null); - - // --- Burned kcal --- - // svelte-ignore state_referenced_locally - let exerciseKcal = $state(Number(data.exerciseKcal) || 0); + // Fat/carb goals in grams — protein gets priority, remaining kcal split + // between fat and carbs proportionally to the stored fat:carb ratio. + // Extra exercise calories flow into fat/carb (protein is body-weight-based). + const fatGoalGrams = $derived.by(() => { + if (!effectiveCalorieGoal) return null; + if (proteinGoalGrams != null && goalFatPercent != null && goalCarbPercent != null) { + const remainingCal = Math.max(0, effectiveCalorieGoal - proteinGoalGrams * 4); + const ratioSum = (goalFatPercent || 0) + (goalCarbPercent || 0); + if (ratioSum > 0) return (remainingCal * goalFatPercent / ratioSum) / 9; + } + return goalFatPercent ? (effectiveCalorieGoal * goalFatPercent / 100) / 9 : null; + }); + const carbGoalGrams = $derived.by(() => { + if (!effectiveCalorieGoal) return null; + if (proteinGoalGrams != null && goalFatPercent != null && goalCarbPercent != null) { + const remainingCal = Math.max(0, effectiveCalorieGoal - proteinGoalGrams * 4); + const ratioSum = (goalFatPercent || 0) + (goalCarbPercent || 0); + if (ratioSum > 0) return (remainingCal * goalCarbPercent / ratioSum) / 4; + } + return goalCarbPercent ? (effectiveCalorieGoal * goalCarbPercent / 100) / 4 : null; + }); // BMR via Mifflin-St Jeor (doi:10.1093/ajcn/51.2.241) const birthYear = $derived(data.goal?.birthYear ?? null); @@ -466,9 +502,9 @@ - // Net calorie balance: goal + burned - eaten - const calorieBalance = $derived(goalCalories ? (goalCalories + (exerciseKcal || 0) - dayTotals.calories) : 0); - const calorieProgressRaw = $derived(goalCalories ? dayTotals.calories / (goalCalories + (exerciseKcal || 0)) * 100 : 0); + // Net calorie balance: effective goal (includes exercise) - eaten + const calorieBalance = $derived(effectiveCalorieGoal ? (effectiveCalorieGoal - dayTotals.calories) : 0); + const calorieProgressRaw = $derived(effectiveCalorieGoal ? dayTotals.calories / effectiveCalorieGoal * 100 : 0); const calorieProgress = $derived(Math.min(calorieProgressRaw, 100)); const calorieOverflow = $derived(Math.max(calorieProgressRaw - 100, 0)); @@ -1448,11 +1484,11 @@
- +
- +
@@ -2674,6 +2710,11 @@ color: var(--color-text-secondary); margin-bottom: 0.3rem; } + .macro-actual { + font-weight: 700; + color: var(--color-text-primary); + text-transform: none; + } .goal-field input, .goal-field select { width: 100%;