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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user