From 385e21b1099e0f32bcedcffde973b77f1f24035f Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Thu, 9 Apr 2026 18:17:39 +0200 Subject: [PATCH] feat: add food detail pages for OFF and custom meal sources Extend the nutrition detail page to support OpenFoodFacts items (looked up by barcode) and custom meals (with ingredient breakdown). All food diary cards and search results now link to detail pages regardless of source. --- package.json | 2 +- src/lib/components/fitness/FoodSearch.svelte | 2 +- .../[nutrition=fitnessNutrition]/+page.svelte | 6 +- .../food/[source]/[id]/+page.server.ts | 84 ++++++++++++++- .../food/[source]/[id]/+page.svelte | 100 +++++++++++++++++- 5 files changed, 187 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 3cb192a..9b44e8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.19.0", + "version": "1.20.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/fitness/FoodSearch.svelte b/src/lib/components/fitness/FoodSearch.svelte index 94db09f..e04c93c 100644 --- a/src/lib/components/fitness/FoodSearch.svelte +++ b/src/lib/components/fitness/FoodSearch.svelte @@ -434,7 +434,7 @@ {item.calories} kcal - {#if showDetailLinks && (item.source === 'bls' || item.source === 'usda')} + {#if showDetailLinks && (item.source === 'bls' || item.source === 'usda' || item.source === 'off')} diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte b/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte index 161fd84..2ab5102 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte @@ -1565,10 +1565,10 @@
{/if}
- {#if entry.source === 'bls' || entry.source === 'usda'} + {#if entry.source === 'bls' || entry.source === 'usda' || entry.source === 'off'} {entry.name} - {:else if entry.source === 'recipe' && entry.sourceId} - {entry.name} + {:else if (entry.source === 'recipe' || entry.source === 'custom') && entry.sourceId} + {entry.name} {:else} {entry.name} {/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 index d51eff0..214d403 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.server.ts +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.server.ts @@ -4,6 +4,8 @@ import { NUTRITION_DB } from '$lib/data/nutritionDb'; import { BLS_DB } from '$lib/data/blsDb'; import { DRI_MALE } from '$lib/data/dailyReferenceIntake'; import { Recipe } from '$models/Recipe'; +import { OpenFoodFact } from '$models/OpenFoodFact'; +import { CustomMeal } from '$models/CustomMeal'; import { dbConnect } from '$utils/db'; import { computeRecipeNutritionTotals, resolvePer100g, parseAmount, resolveReferencedNutrition } from '$lib/server/nutritionMatcher'; import { FoodLogEntry } from '$models/FoodLogEntry'; @@ -61,7 +63,7 @@ async function computeRecipePer100g(id: string): Promise> export const load: PageServerLoad = async ({ params, url }) => { const { source, id } = params; - if (source !== 'bls' && source !== 'usda' && source !== 'recipe') { + if (source !== 'bls' && source !== 'usda' && source !== 'recipe' && source !== 'off' && source !== 'custom') { throw error(404, 'Invalid source'); } @@ -126,6 +128,86 @@ export const load: PageServerLoad = async ({ params, url }) => { }; } + if (source === 'off') { + await dbConnect(); + const entry = await OpenFoodFact.findOne({ barcode: id }).lean(); + if (!entry) throw error(404, 'Food not found'); + const portions: { description: string; grams: number }[] = []; + if (entry.serving?.grams) { + portions.push(entry.serving as { description: string; grams: number }); + } + return { + food: { + source: 'off' as const, + id: entry.barcode, + name: entry.nameDe || entry.name, + nameDe: entry.nameDe, + nameEn: entry.nameDe ? entry.name : undefined, + category: entry.category || '', + per100g: entry.per100g as unknown as Record, + brands: entry.brands, + nutriscore: entry.nutriscore, + portions, + }, + dri: DRI_MALE, + }; + } + + if (source === 'custom') { + await dbConnect(); + const meal = await CustomMeal.findById(id).lean(); + if (!meal) throw error(404, 'Meal not found'); + + // Aggregate per100g from ingredients + const totals: Record = {}; + let totalGrams = 0; + for (const ing of meal.ingredients) { + const r = ing.amountGrams / 100; + totalGrams += ing.amountGrams; + for (const [k, v] of Object.entries(ing.per100g)) { + if (typeof v === 'number') { + totals[k] = (totals[k] || 0) + v * r; + } + } + } + const per100g: Record = {}; + const scale = totalGrams > 0 ? 100 / totalGrams : 0; + for (const [k, v] of Object.entries(totals)) { + per100g[k] = v * scale; + } + + // Use logged per100g snapshot if provided + const logEntryId = url.searchParams.get('logEntry'); + if (logEntryId) { + const logEntry = await FoodLogEntry.findById(logEntryId).select('per100g').lean(); + if (logEntry?.per100g) { + Object.assign(per100g, logEntry.per100g); + } + } + + return { + food: { + source: 'custom' as const, + id: String(meal._id), + name: meal.name, + category: 'Custom Meal', + per100g, + totalGrams, + ingredients: meal.ingredients.map((ing: any) => ({ + name: ing.name, + source: ing.source, + sourceId: ing.sourceId, + amountGrams: ing.amountGrams, + calories: (ing.per100g?.calories ?? 0) * ing.amountGrams / 100, + protein: (ing.per100g?.protein ?? 0) * ing.amountGrams / 100, + fat: (ing.per100g?.fat ?? 0) * ing.amountGrams / 100, + carbs: (ing.per100g?.carbs ?? 0) * ing.amountGrams / 100, + })), + }, + dri: DRI_MALE, + }; + } + // USDA const fdcId = Number(id); const entry = NUTRITION_DB.find(e => e.fdcId === fdcId); diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte b/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte index b3bb541..614bbd9 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte @@ -182,8 +182,14 @@

{food.nameEn}

{/if}
- {food.source === 'bls' ? 'BLS' : food.source === 'usda' ? 'USDA' : isEn ? 'Recipe' : 'Rezept'} + {food.source === 'bls' ? 'BLS' : food.source === 'usda' ? 'USDA' : food.source === 'off' ? 'OFF' : food.source === 'custom' ? (isEn ? 'Custom Meal' : 'Eigene Mahlzeit') : isEn ? 'Recipe' : 'Rezept'} {food.category} + {#if food.brands} + {food.brands} + {/if} + {#if food.nutriscore} + Nutri-Score {food.nutriscore.toUpperCase()} + {/if} {#if food.recipeSlug} {isEn ? 'View recipe' : 'Zum Rezept'} @@ -256,6 +262,33 @@
+ + {#if food.ingredients?.length} +
+

{isEn ? 'Ingredients' : 'Zutaten'} ({food.totalGrams}g {isEn ? 'total' : 'gesamt'})

+
+ {#each food.ingredients as ing} +
+ +
+ {Math.round(ing.calories)} kcal + {fmt(ing.protein)}P + {fmt(ing.fat)}F + {fmt(ing.carbs)}C +
+
+ {/each} +
+
+ {/if} +