feat: add food detail pages for OFF and custom meal sources
CI / update (push) Successful in 3m40s

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.
This commit is contained in:
2026-04-09 18:17:39 +02:00
parent 72b49baeab
commit 385e21b109
5 changed files with 187 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.19.0",
"version": "1.20.0",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -434,7 +434,7 @@
</div>
<span class="fs-result-cal">{item.calories}<small> kcal</small></span>
</button>
{#if showDetailLinks && (item.source === 'bls' || item.source === 'usda')}
{#if showDetailLinks && (item.source === 'bls' || item.source === 'usda' || item.source === 'off')}
<a class="fs-detail-link" href="/fitness/{s.nutrition}/food/{item.source}/{item.id}" aria-label="View details">
<ExternalLink size={13} />
</a>
@@ -1565,10 +1565,10 @@
<div class="food-card-accent" style="background: var(--meal-color)"></div>
{/if}
<div class="food-card-body">
{#if entry.source === 'bls' || entry.source === 'usda'}
{#if entry.source === 'bls' || entry.source === 'usda' || entry.source === 'off'}
<a class="food-card-name food-card-link" href="/fitness/{s.nutrition}/food/{entry.source}/{entry.sourceId}">{entry.name}</a>
{:else if entry.source === 'recipe' && entry.sourceId}
<a class="food-card-name food-card-link" href="/fitness/{s.nutrition}/food/recipe/{entry.sourceId}?logEntry={entry._id}">{entry.name}</a>
{:else if (entry.source === 'recipe' || entry.source === 'custom') && entry.sourceId}
<a class="food-card-name food-card-link" href="/fitness/{s.nutrition}/food/{entry.source}/{entry.sourceId}?logEntry={entry._id}">{entry.name}</a>
{:else}
<span class="food-card-name">{entry.name}</span>
{/if}
@@ -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<Record<string, number>>
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<string, number>,
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<string, number> = {};
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<string, number> = {};
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);
@@ -182,8 +182,14 @@
<p class="name-alt">{food.nameEn}</p>
{/if}
<div class="badges">
<span class="badge badge-source">{food.source === 'bls' ? 'BLS' : food.source === 'usda' ? 'USDA' : isEn ? 'Recipe' : 'Rezept'}</span>
<span class="badge badge-source">{food.source === 'bls' ? 'BLS' : food.source === 'usda' ? 'USDA' : food.source === 'off' ? 'OFF' : food.source === 'custom' ? (isEn ? 'Custom Meal' : 'Eigene Mahlzeit') : isEn ? 'Recipe' : 'Rezept'}</span>
<span class="badge badge-category">{food.category}</span>
{#if food.brands}
<span class="badge badge-category">{food.brands}</span>
{/if}
{#if food.nutriscore}
<span class="badge badge-nutriscore" data-score={food.nutriscore.toLowerCase()}>Nutri-Score {food.nutriscore.toUpperCase()}</span>
{/if}
{#if food.recipeSlug}
<a class="badge badge-recipe-link" href="/{isEn ? 'recipes' : 'rezepte'}/{isEn && food.recipeSlugEn ? food.recipeSlugEn : food.recipeSlug}">
{isEn ? 'View recipe' : 'Zum Rezept'} <ExternalLink size={12} />
@@ -256,6 +262,33 @@
</div>
</div>
<!-- Ingredients (custom meals) -->
{#if food.ingredients?.length}
<div class="section-card">
<h2>{isEn ? 'Ingredients' : 'Zutaten'} <small class="ingredient-total">({food.totalGrams}g {isEn ? 'total' : 'gesamt'})</small></h2>
<div class="ingredients-list">
{#each food.ingredients as ing}
<div class="ingredient-row">
<div class="ingredient-info">
{#if ing.sourceId && (ing.source === 'bls' || ing.source === 'usda' || ing.source === 'off')}
<a class="ingredient-name" href="/fitness/{s.nutrition}/food/{ing.source}/{ing.sourceId}">{ing.name}</a>
{:else}
<span class="ingredient-name">{ing.name}</span>
{/if}
<span class="ingredient-amount">{ing.amountGrams}g</span>
</div>
<div class="ingredient-macros">
<span>{Math.round(ing.calories)} kcal</span>
<span>{fmt(ing.protein)}P</span>
<span>{fmt(ing.fat)}F</span>
<span>{fmt(ing.carbs)}C</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Micronutrients -->
<div class="section-card">
<button class="section-toggle" onclick={() => showMicros = !showMicros}>
@@ -415,6 +448,15 @@
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.badge-nutriscore {
color: #fff;
font-weight: 700;
}
.badge-nutriscore[data-score="a"] { background: #038141; }
.badge-nutriscore[data-score="b"] { background: #85bb2f; color: #222; }
.badge-nutriscore[data-score="c"] { background: #fecb02; color: #222; }
.badge-nutriscore[data-score="d"] { background: #ee8100; }
.badge-nutriscore[data-score="e"] { background: #e63e11; }
.badge-recipe-link {
display: inline-flex;
align-items: center;
@@ -667,6 +709,62 @@
color: var(--color-text-tertiary);
}
/* Ingredients (custom meals) */
.ingredient-total {
font-size: 0.8rem;
font-weight: 400;
color: var(--color-text-tertiary);
}
.ingredients-list {
margin-top: 0.5rem;
}
.ingredient-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
gap: 0.5rem;
}
.ingredient-row:last-child {
border-bottom: none;
}
.ingredient-info {
display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
flex: 1;
}
.ingredient-name {
color: var(--color-text-primary);
font-weight: 500;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
a.ingredient-name {
color: var(--color-primary);
text-decoration: none;
}
a.ingredient-name:hover {
text-decoration: underline;
}
.ingredient-amount {
font-size: 0.75rem;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.ingredient-macros {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
@media (max-width: 500px) {
.micro-row {
grid-template-columns: 5.5rem 1fr 3.5rem 2.2rem;