From 23b45abc5a51c811d7d4d8e469340457be5138c8 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sat, 4 Apr 2026 14:34:45 +0200 Subject: [PATCH] feat: add nutrition/food logging to fitness section Daily food log with calorie and macro tracking against configurable diet goals (presets: WHO balanced, cut, bulk, keto, etc.). Includes USDA/BLS food search with portion-based units, favorite ingredients, custom reusable meals, per-food micronutrient detail pages, and recipe-to-log integration via AddToFoodLogButton. Extends FitnessGoal with nutrition targets and adds birth year to user profile for BMR calculation. --- src/lib/components/fitness/FoodSearch.svelte | 482 +++++ .../recipes/AddToFoodLogButton.svelte | 383 ++++ .../components/recipes/IngredientsPage.svelte | 19 +- .../recipes/NutritionSummary.svelte | 10 +- src/lib/data/dailyReferenceIntake.ts | 125 ++ src/lib/js/fitnessI18n.ts | 72 +- src/models/CustomMeal.ts | 79 + src/models/FavoriteIngredient.ts | 24 + src/models/FitnessGoal.ts | 16 +- src/models/FoodLogEntry.ts | 64 + src/params/fitnessNutrition.ts | 5 + .../[name]/+page.svelte | 1 - .../api/fitness/custom-meals/+server.ts | 32 + .../api/fitness/custom-meals/[id]/+server.ts | 39 + .../fitness/favorite-ingredients/+server.ts | 54 + src/routes/api/fitness/food-log/+server.ts | 62 + .../api/fitness/food-log/[id]/+server.ts | 35 + src/routes/api/fitness/goal/+server.ts | 32 +- src/routes/api/nutrition/search/+server.ts | 79 +- src/routes/fitness/+layout.svelte | 6 +- .../[measure=fitnessMeasure]/+page.svelte | 12 +- .../+page.server.ts | 67 + .../[nutrition=fitnessNutrition]/+page.svelte | 1794 +++++++++++++++++ .../food/[source]/[id]/+page.server.ts | 46 + .../food/[source]/[id]/+page.svelte | 658 ++++++ .../meals/+page.svelte | 726 +++++++ .../fitness/[stats=fitnessStats]/+page.svelte | 2 +- 27 files changed, 4904 insertions(+), 20 deletions(-) create mode 100644 src/lib/components/fitness/FoodSearch.svelte create mode 100644 src/lib/components/recipes/AddToFoodLogButton.svelte create mode 100644 src/lib/data/dailyReferenceIntake.ts create mode 100644 src/models/CustomMeal.ts create mode 100644 src/models/FavoriteIngredient.ts create mode 100644 src/models/FoodLogEntry.ts create mode 100644 src/params/fitnessNutrition.ts create mode 100644 src/routes/api/fitness/custom-meals/+server.ts create mode 100644 src/routes/api/fitness/custom-meals/[id]/+server.ts create mode 100644 src/routes/api/fitness/favorite-ingredients/+server.ts create mode 100644 src/routes/api/fitness/food-log/+server.ts create mode 100644 src/routes/api/fitness/food-log/[id]/+server.ts create mode 100644 src/routes/fitness/[nutrition=fitnessNutrition]/+page.server.ts create mode 100644 src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte create mode 100644 src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.server.ts create mode 100644 src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte create mode 100644 src/routes/fitness/[nutrition=fitnessNutrition]/meals/+page.svelte diff --git a/src/lib/components/fitness/FoodSearch.svelte b/src/lib/components/fitness/FoodSearch.svelte new file mode 100644 index 00000000..4462d632 --- /dev/null +++ b/src/lib/components/fitness/FoodSearch.svelte @@ -0,0 +1,482 @@ + + +{#if !selected} + + + {#if loading} +

{t('loading', lang)}

+ {/if} + {#if results.length > 0} +
+ {#each results as item} +
+ {#if showFavorites} + + {/if} + + {#if showDetailLinks} + + + + {/if} +
+ {/each} +
+ {/if} + {#if oncancel} + + {/if} +{:else} + +
+
+ + {selected.source === 'bls' ? 'BLS' : 'USDA'} + {selected.name} + +
+
+ = 0 ? '0.5' : '1'} + /> + {#if selected.portions?.length > 0} + + {:else} + g + {/if} +
+ {#if previewGrams > 0} +
+ {#if portionIdx >= 0} + {previewGrams}g + {/if} + {Math.round((selected.per100g?.calories ?? 0) * previewGrams / 100)} kcal + {fmt((selected.per100g?.protein ?? 0) * previewGrams / 100)}g P + {fmt((selected.per100g?.fat ?? 0) * previewGrams / 100)}g F + {fmt((selected.per100g?.carbs ?? 0) * previewGrams / 100)}g C +
+ {/if} +
+ + +
+
+{/if} + + diff --git a/src/lib/components/recipes/AddToFoodLogButton.svelte b/src/lib/components/recipes/AddToFoodLogButton.svelte new file mode 100644 index 00000000..cfe1c2aa --- /dev/null +++ b/src/lib/components/recipes/AddToFoodLogButton.svelte @@ -0,0 +1,383 @@ + + + + +{#if showDialog} + +
showDialog = false} onkeydown={(e) => e.key === 'Escape' && (showDialog = false)}> + +
e.stopPropagation()}> +
+

{labels.addToLog}

+ +
+ +
+
+ + +
+ +
+ + +
+ + {#if useGrams} +
+ + +
+ {:else} +
+ + + {#if basePortionCount > 0} + {Math.round(portionGrams)}g + {/if} +
+ {/if} + + {#if previewCal > 0} +
+ {Math.round(previewCal)} kcal +
+ {/if} +
+ +
+ + +
+
+
+{/if} + + diff --git a/src/lib/components/recipes/IngredientsPage.svelte b/src/lib/components/recipes/IngredientsPage.svelte index 3596ef84..32126b7b 100644 --- a/src/lib/components/recipes/IngredientsPage.svelte +++ b/src/lib/components/recipes/IngredientsPage.svelte @@ -5,7 +5,10 @@ import { browser } from '$app/environment'; import { page } from '$app/stores'; import HefeSwapper from './HefeSwapper.svelte'; import NutritionSummary from './NutritionSummary.svelte'; +import AddToFoodLogButton from './AddToFoodLogButton.svelte'; let { data } = $props(); +const isLoggedIn = $derived(!!data.session?.user); +const hasNutrition = $derived(!!data.nutritionMappings?.length); // Helper function to multiply numbers in ingredient amounts /** @param {string} amount @param {number} multiplier */ @@ -635,6 +638,20 @@ const nutritionFlatIngredients = $derived.by(() => { {multiplier} portions={data.portions} isEnglish={isEnglish} -/> +> + {#snippet actions()} + {#if isLoggedIn && hasNutrition} + + {/if} + {/snippet} + {/if} diff --git a/src/lib/components/recipes/NutritionSummary.svelte b/src/lib/components/recipes/NutritionSummary.svelte index f145b6bc..264272d6 100644 --- a/src/lib/components/recipes/NutritionSummary.svelte +++ b/src/lib/components/recipes/NutritionSummary.svelte @@ -1,7 +1,7 @@ + + + {t('nutrition_title', lang)} — Fitness + + +
+ +
+ + + +
+ + + {#if goalCalories} +
+ +
+
+ {fmtCal(dayTotals.calories)} + {isEn ? 'EATEN' : 'GEGESSEN'} +
+ +
+ + + + {fmtCal(Math.abs(calorieBalance))} + {calorieBalance >= 0 ? (isEn ? 'KCAL LEFT' : 'KCAL ÜBRIG') : (isEn ? 'KCAL OVER' : 'KCAL ÜBER')} + +
+ +
+ {fmtCal(exerciseKcal)} + {isEn ? 'BURNED' : 'VERBRANNT'} + + +{fmtCal(tdeeSoFar)} TDEE + {#if showTdeeInfo} +
+ BMR: Mifflin-St Jeor (1990) + NEAT: Levine (2002) + {isEn ? 'Multipliers reduced vs. standard Harris-Benedict factors — logged exercise kcal added separately.' : 'Multiplikatoren reduziert ggü. Harris-Benedict — geloggte Trainings-kcal werden separat addiert.'} +
+ {/if} +
+ {#if !hasBmrData} +
{isEn ? 'Set profile in' : 'Profil unter'} {t('measure_title', lang)}
+ {/if} +
+
+ + +
+ {#each [ + { value: dayTotals.protein, goal: proteinGoalGrams, label: t('protein', lang), color: 'var(--nord14)' }, + { value: dayTotals.fat, goal: fatGoalGrams, label: t('fat', lang), color: 'var(--nord12)' }, + { value: dayTotals.carbs, goal: carbGoalGrams, label: t('carbs', lang), color: 'var(--nord9)' }, + ] as macro} + {@const pct = macro.goal ? macro.value / macro.goal * 100 : 0} + {@const over = pct > 100} + {@const remaining = macro.goal ? macro.goal - macro.value : 0} +
+ {macro.label} +
+
+
+ {#if macro.goal} + + {remaining >= 0 ? `${fmt(remaining)}g ${t('remaining', lang)}` : `${fmt(-remaining)}g ${t('over', lang)}`} + + {:else} + {fmt(macro.value)}g + {/if} +
+ {/each} +
+ + +
+ +
+ + {#if showMicros} +
+ {#each microSections as section} +
+

{section.title}

+ {#each section.rows as row} +
+ {row.label} +
+
+
+ {fmt(row.value)} {row.unit} + {row.pct}% +
+ {/each} +
+ {/each} +
+ {/if} +
+ {:else} +
+
+

{t('set_goal_prompt', lang)}

+ +
+ {/if} + + + {#if goalCalories && !showGoalEditor} + + {/if} + + + {#if showGoalEditor} +
+

{t('daily_goal', lang)}

+ + +
+ {isEn ? 'Presets' : 'Vorlagen'} +
+ {#each dietPresets as preset} + + {/each} +
+
+ +
+ +
+ + {#if hasBmrData} + + {/if} +
+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ {/if} + + + {#each mealTypes as meal, mi} + {@const mealEntries = grouped[meal]} + {@const mealCal = mealEntries.reduce((s, e) => s + entryCalories(e), 0)} + {@const meta = mealMeta[meal]} + {@const MealSectionIcon = meta.icon} +
+
+
+
+ +
+

{t(meal, lang)}

+
+ {#if mealEntries.length > 0} + {fmtCal(mealCal)} {t('kcal', lang)} + {/if} +
+ +
+ {#each mealEntries as entry} + {@const imgUrl = entry.source === 'recipe' && entry.sourceId ? recipeImages[entry.sourceId] : null} +
+ {#if imgUrl} + {entry.name} + {:else} +
+ {/if} +
+ {#if entry.source === 'bls' || entry.source === 'usda'} + {entry.name} + {:else} + {entry.name} + {/if} + {entry.amountGrams}g · {fmtCal(entryCalories(entry))} kcal + {fmt(entryNutrient(entry, 'protein'))}g P · {fmt(entryNutrient(entry, 'fat'))}g F · {fmt(entryNutrient(entry, 'carbs'))}g C +
+ +
+ {/each} +
+ + {#if addingMeal === meal} +
+ +
+ {:else} + + {/if} +
+ {/each} + +
+ + + + + +{#if showFabModal} + +
e.key === 'Escape' && closeFabModal()}> + +
e.stopPropagation()}> +
+

{t('add_food', lang)}

+ +
+ + +
+ {#each mealTypes as meal} + {@const meta = mealMeta[meal]} + {@const MealIcon = meta.icon} + + {/each} +
+ + +
+ + +
+ + {#if fabTab === 'search'} + + {:else} + +
+ {#if customMeals.length === 0} +

{t('no_custom_meals', lang)}

+ {/if} + {#each customMeals as meal} +
+
+ {meal.name} + {meal.ingredients.length} {t('ingredients', lang)} · {fmtCal(mealTotalCal(meal))} kcal +
+ +
+ {/each} + + + {isEn ? 'Manage meals' : 'Mahlzeiten verwalten'} + +
+ {/if} +
+
+{/if} + + diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.server.ts b/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.server.ts new file mode 100644 index 00000000..20c24d2a --- /dev/null +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.server.ts @@ -0,0 +1,46 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { NUTRITION_DB } from '$lib/data/nutritionDb'; +import { BLS_DB } from '$lib/data/blsDb'; +import { DRI_MALE } from '$lib/data/dailyReferenceIntake'; + +export const load: PageServerLoad = async ({ params }) => { + const { source, id } = params; + + if (source !== 'bls' && source !== 'usda') { + throw error(404, 'Invalid source'); + } + + if (source === 'bls') { + const entry = BLS_DB.find(e => e.blsCode === id); + if (!entry) throw error(404, 'Food not found'); + return { + food: { + source: 'bls' as const, + id: entry.blsCode, + name: `${entry.nameDe}${entry.nameEn ? ` (${entry.nameEn})` : ''}`, + nameDe: entry.nameDe, + nameEn: entry.nameEn, + category: entry.category, + per100g: entry.per100g, + }, + dri: DRI_MALE, + }; + } + + // USDA + const fdcId = Number(id); + const entry = NUTRITION_DB.find(e => e.fdcId === fdcId); + if (!entry) throw error(404, 'Food not found'); + return { + food: { + source: 'usda' as const, + id: String(entry.fdcId), + name: entry.name, + category: entry.category, + per100g: entry.per100g, + portions: entry.portions, + }, + dri: DRI_MALE, + }; +}; diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte b/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte new file mode 100644 index 00000000..71d05e27 --- /dev/null +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte @@ -0,0 +1,658 @@ + + + + {food.name} | {isEn ? 'Nutrition' : 'Ernährung'} + + +
+ + + + {t('nutrition_title', lang)} + + + +
+

{food.nameDe ?? food.name}

+ {#if food.nameEn && food.nameDe} +

{food.nameEn}

+ {/if} +
+ {food.source === 'bls' ? 'BLS' : 'USDA'} + {food.category} +
+
+ + + {#if portions.length > 0} +
+ + +
+ {/if} + + +
+ {Math.round(scaled(n.calories))} + kcal + {portionLabel} +
+ + +
+ {#each [ + { pct: macroPercent.protein, label: isEn ? 'Protein' : 'Eiweiß', cls: 'ring-protein', grams: scaled(n.protein) }, + { pct: macroPercent.fat, label: isEn ? 'Fat' : 'Fett', cls: 'ring-fat', grams: scaled(n.fat) }, + { pct: macroPercent.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', cls: 'ring-carbs', grams: scaled(n.carbs) }, + ] as macro} +
+ + + + {macro.pct}% + + {macro.label} + {fmt(macro.grams)}g +
+ {/each} +
+ + +
+
+ {isEn ? 'Protein' : 'Eiweiß'} + {fmt(scaled(n.protein))} g +
+
+ {isEn ? 'Fat' : 'Fett'} + {fmt(scaled(n.fat))} g +
+
+ {isEn ? 'Saturated Fat' : 'Ges. Fettsäuren'} + {fmt(scaled(n.saturatedFat))} g +
+
+ {isEn ? 'Carbohydrates' : 'Kohlenhydrate'} + {fmt(scaled(n.carbs))} g +
+
+ {isEn ? 'Sugars' : 'Zucker'} + {fmt(scaled(n.sugars))} g +
+
+ {isEn ? 'Fiber' : 'Ballaststoffe'} + {fmt(scaled(n.fiber))} g +
+
+ + +
+ + + {#if showMicros} +
+ {#each microSections as section} +
+

{section.title}

+ {#each section.rows as row} +
+ {row.label} +
+
+
+ {fmt(row.value)} {row.unit} + {row.pct}% +
+ {/each} +
+ {/each} +
+ {/if} +
+ + + {#if hasAminos} +
+ + + {#if showAminos} +
+ {#each aminoRows as row} +
+ {row.label} + {fmt(row.value)} g + {#if row.essential} + {isEn ? 'essential' : 'essenziell'} + {/if} +
+ {/each} +
+ {/if} +
+ {/if} + + + {#if portions.length > 0} +
+

{isEn ? 'Common Serving Sizes' : 'Übliche Portionsgrößen'}

+
+
+ {isEn ? 'Serving' : 'Portion'} + kcal + {isEn ? 'Protein' : 'Eiweiß'} + {isEn ? 'Fat' : 'Fett'} + {isEn ? 'Carbs' : 'KH'} +
+ {#each portions as portion} + {@const m = portion.grams / 100} +
+ {portion.description} ({portion.grams}g) + {Math.round(n.calories * m)} + {fmt(n.protein * m)}g + {fmt(n.fat * m)}g + {fmt(n.carbs * m)}g +
+ {/each} +
+
+ {/if} +
+ + diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/meals/+page.svelte b/src/routes/fitness/[nutrition=fitnessNutrition]/meals/+page.svelte new file mode 100644 index 00000000..65a1cd24 --- /dev/null +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/meals/+page.svelte @@ -0,0 +1,726 @@ + + + + {t('custom_meals', lang)} — Fitness + + +
+ +
+ + + {t('custom_meals', lang)} + + {#if !editing} + + {/if} +
+ + {#if loading} +
+

{t('loading', lang)}

+
+ {:else if editing} + +
+

{editingId ? t('edit', lang) : t('new_meal', lang)}

+ + + + + + + {#if ingredients.length > 0} +
+ {#each ingredients as ing, i} + {@const sp = ing.selectedPortion} + {@const displayQty = sp ? Math.round((ing.amountGrams / sp.grams) * 10) / 10 : ing.amountGrams} + {@const displayUnit = sp ? sp.description : 'g'} +
+
+
+ {ing.name} + {#if ing.source !== 'custom'} + {ing.source === 'bls' ? 'BLS' : 'USDA'} + {/if} +
+
+ { + const qty = Number(e.target.value) || 1; + ingredients[i].amountGrams = sp ? Math.round(qty * sp.grams) : qty; + ingredients = [...ingredients]; + }} + /> + {#if ing.portions?.length > 0} + + {:else} + {displayUnit} + {/if} + + {#if sp}{ing.amountGrams}g ·{/if} + {fmt((ing.per100g?.calories ?? 0) * ing.amountGrams / 100)} {t('kcal', lang)} + +
+
+ +
+ {/each} +
+ {/if} + + + {#if ingredients.length > 0} +
+ {t('total', lang)} + {Math.round(formTotals.calories)} {t('kcal', lang)} + {fmt(formTotals.protein)}g P + {fmt(formTotals.fat)}g F + {fmt(formTotals.carbs)}g C +
+ {/if} + + + {#if !showSearch} + + {:else} +
+ { showSearch = false; }} + showDetailLinks={false} + confirmLabel={t('add_ingredient', lang)} + /> +
+ {/if} + + +
+ + +
+
+ {:else if meals.length === 0} + +
+ +

{t('no_custom_meals', lang)}

+

{t('create_meal_hint', lang)}

+
+ {:else} + +
+ {#each meals as meal, i} +
+
+
+

{meal.name}

+ + {meal.ingredients.length} {t('ingredients', lang)} — {Math.round(mealTotalCal(meal))} {t('kcal', lang)} + +
+
+ + +
+
+
+ {#each meal.ingredients as ing} + {ing.name} ({ing.amountGrams}g) + {/each} +
+
+ {/each} +
+ {/if} +
+ + diff --git a/src/routes/fitness/[stats=fitnessStats]/+page.svelte b/src/routes/fitness/[stats=fitnessStats]/+page.svelte index 443dc61b..28be2e68 100644 --- a/src/routes/fitness/[stats=fitnessStats]/+page.svelte +++ b/src/routes/fitness/[stats=fitnessStats]/+page.svelte @@ -40,7 +40,7 @@ let goalInput = $state(4); let goalSaving = $state(false); - const hasDemographics = $derived(data.goal?.sex != null && data.goal?.heightCm != null); + const hasDemographics = $derived(data.goal?.sex != null && data.goal?.heightCm != null && data.goal?.birthYear != null); function startGoalEdit() { goalInput = goalWeekly ?? 4;