recipes: nutrition calculator with BLS/USDA matching, manual overwrites, and skip

Dual-source nutrition system using BLS (German, primary) and USDA (English, fallback)
with ML embedding matching (multilingual-e5-small / all-MiniLM-L6-v2), hybrid
substring-first search, and position-aware scoring heuristics.

Includes per-recipe and global manual ingredient overwrites, ingredient skip/exclude,
referenced recipe nutrition (base refs + anchor tags), section-name dedup,
amino acid tracking, and reactive client-side calculator with NutritionSummary component.
This commit is contained in:
2026-04-01 13:00:52 +02:00
parent 3cafe8955a
commit 7e1181461e
30 changed files with 722384 additions and 12 deletions

View File

@@ -4,6 +4,7 @@ import { onNavigate } from "$app/navigation";
import { browser } from '$app/environment';
import { page } from '$app/stores';
import HefeSwapper from './HefeSwapper.svelte';
import NutritionSummary from './NutritionSummary.svelte';
let { data } = $props();
// Helper function to multiply numbers in ingredient amounts
@@ -365,6 +366,44 @@ function adjust_amount(string, multiplier){
}
// Collect section names for nutrition dedup (skip ingredients matching another section's name)
const nutritionSectionNames = $derived.by(() => {
if (!data.ingredients) return new Set();
const names = new Set();
for (const section of data.ingredients) {
if (section.name) {
const stripped = section.name.replace(/<[^>]*>/g, '').toLowerCase().trim();
if (stripped) names.add(stripped);
}
}
return names;
});
// Build flat ingredient list with section/ingredient indices for nutrition calculator
const nutritionFlatIngredients = $derived.by(() => {
if (!data.ingredients) return [];
/** @type {{ name: string; unit: string; amount: string; sectionIndex: number; ingredientIndex: number; sectionName: string }[]} */
const flat = [];
for (let si = 0; si < data.ingredients.length; si++) {
const section = data.ingredients[si];
if (section.type === 'reference') continue;
if (!section.list) continue;
const sectionName = (section.name || '').replace(/<[^>]*>/g, '').toLowerCase().trim();
for (let ii = 0; ii < section.list.length; ii++) {
const item = section.list[ii];
flat.push({
name: item.name,
unit: item.unit || '',
amount: item.amount || '',
sectionIndex: si,
ingredientIndex: ii,
sectionName,
});
}
}
return flat;
});
// No need for complex yeast toggle handling - everything is calculated server-side now
</script>
<style>
@@ -587,5 +626,15 @@ function adjust_amount(string, multiplier){
</div>
{/if}
{/each}
<NutritionSummary
flatIngredients={nutritionFlatIngredients}
nutritionMappings={data.nutritionMappings}
sectionNames={nutritionSectionNames}
referencedNutrition={data.referencedNutrition || []}
{multiplier}
portions={data.portions}
isEnglish={isEnglish}
/>
</div>
{/if}

View File

@@ -0,0 +1,295 @@
<script>
import { createNutritionCalculator } from '$lib/js/nutrition.svelte';
let { flatIngredients, nutritionMappings, sectionNames, referencedNutrition, multiplier, portions, isEnglish } = $props();
const nutrition = createNutritionCalculator(
() => flatIngredients,
() => nutritionMappings || [],
() => multiplier,
() => sectionNames || new Set(),
() => referencedNutrition || [],
);
let showDetails = $state(false);
const portionCount = $derived.by(() => {
if (!portions) return 0;
const match = portions.match(/^(\d+(?:[.,]\d+)?)/);
return match ? parseFloat(match[1].replace(',', '.')) : 0;
});
const adjustedPortionCount = $derived(portionCount > 0 ? portionCount * multiplier : 0);
// Divisor for per-portion values (1 if no portions → show total)
const div = $derived(adjustedPortionCount > 0 ? adjustedPortionCount : 1);
const perPortionCalories = $derived(adjustedPortionCount > 0 ? nutrition.totalMacros.calories / adjustedPortionCount : 0);
// Macro percentages by calories: protein=4kcal/g, fat=9kcal/g, carbs=4kcal/g
const macroPercent = $derived.by(() => {
const m = nutrition.totalMacros;
const proteinCal = m.protein * 4;
const fatCal = m.fat * 9;
const carbsCal = m.carbs * 4;
const total = proteinCal + fatCal + carbsCal;
if (total === 0) return { protein: 0, fat: 0, carbs: 0 };
return {
protein: Math.round(proteinCal / total * 100),
fat: Math.round(fatCal / total * 100),
carbs: 100 - Math.round(proteinCal / total * 100) - Math.round(fatCal / total * 100),
};
});
const labels = $derived({
title: isEnglish ? 'Nutrition' : 'Nährwerte',
perPortion: isEnglish ? 'per portion' : 'pro Portion',
protein: isEnglish ? 'Protein' : 'Eiweiß',
fat: isEnglish ? 'Fat' : 'Fett',
carbs: isEnglish ? 'Carbs' : 'Kohlenh.',
fiber: isEnglish ? 'Fiber' : 'Ballaststoffe',
sugars: isEnglish ? 'Sugars' : 'Zucker',
saturatedFat: isEnglish ? 'Sat. Fat' : 'Ges. Fett',
details: isEnglish ? 'Details' : 'Details',
vitamins: isEnglish ? 'Vitamins' : 'Vitamine',
minerals: isEnglish ? 'Minerals' : 'Mineralstoffe',
coverage: isEnglish ? 'coverage' : 'Abdeckung',
unmapped: isEnglish ? 'Not tracked' : 'Nicht erfasst',
aminoAcids: isEnglish ? 'Amino Acids' : 'Aminosäuren',
});
const hasAminoAcids = $derived.by(() => {
const aa = nutrition.totalAminoAcids;
return aa.leucine > 0 || aa.lysine > 0 || aa.isoleucine > 0;
});
/** @param {number} value */
function fmt(value) {
if (value >= 100) return Math.round(value).toString();
if (value >= 10) return value.toFixed(1);
return value.toFixed(1);
}
// SVG arc parameters — 300° arc with 60° gap at bottom
const RADIUS = 28;
const ARC_DEGREES = 300;
const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
// Arc starts at the left side: rotate so the gap is centered at the bottom
// 0° in SVG circle = 3 o'clock. We want the arc to start at ~210° (7 o'clock)
// and end at ~150° (5 o'clock), leaving a 60° gap at bottom center.
const ARC_ROTATE = 120; // rotate the starting point: -90 (top) + 210 offset → start at left
/** @param {number} percent */
function strokeOffset(percent) {
return ARC_LENGTH - (percent / 100) * ARC_LENGTH;
}
</script>
<style>
.nutrition-summary {
margin-top: 1.5rem;
}
.portion-cal {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary, #666);
margin: 0.25rem 0;
}
.macro-rings {
display: flex;
justify-content: space-around;
margin: 0.5rem 0;
}
.macro-ring {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex: 1;
}
.ring-bg {
fill: none;
stroke: var(--color-border, #e5e5e5);
stroke-width: 5;
stroke-linecap: round;
}
.ring-fill {
fill: none;
stroke-width: 5;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
}
.ring-text {
font-size: 14px;
font-weight: 700;
fill: currentColor;
text-anchor: middle;
dominant-baseline: central;
}
.ring-protein { stroke: var(--nord14, #a3be8c); }
.ring-fat { stroke: var(--nord12, #d08770); }
.ring-carbs { stroke: var(--nord9, #81a1c1); }
.macro-label {
font-size: 0.85rem;
font-weight: 600;
text-align: center;
}
.details-toggle-row {
text-align: center;
margin-top: 0.5rem;
}
.details-toggle {
font-size: 0.85rem;
cursor: pointer;
color: var(--color-primary);
background: none;
border: none;
padding: 0;
text-decoration: underline;
text-decoration-style: dotted;
}
.details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.5rem;
margin-top: 0.75rem;
font-size: 0.85rem;
}
.detail-section h4 {
margin: 0 0 0.35rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.15rem 0;
border-bottom: 1px solid var(--color-border, #e5e5e5);
}
.detail-row:last-child {
border-bottom: none;
}
.coverage-warning {
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--nord13, #ebcb8b);
}
@media (max-width: 500px) {
.details-grid {
grid-template-columns: 1fr;
}
}
</style>
{#if nutritionMappings && nutritionMappings.length > 0}
<div class="nutrition-summary">
<div class="macro-rings">
{#each [
{ pct: macroPercent.protein, label: labels.protein, cls: 'ring-protein' },
{ pct: macroPercent.fat, label: labels.fat, cls: 'ring-fat' },
{ pct: macroPercent.carbs, label: labels.carbs, cls: 'ring-carbs' },
] as macro}
<div class="macro-ring">
<svg width="90" height="90" viewBox="0 0 70 70">
<circle
class="ring-bg"
cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
transform="rotate({ARC_ROTATE} 35 35)"
/>
<circle
class="ring-fill {macro.cls}"
cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
stroke-dashoffset={strokeOffset(macro.pct)}
transform="rotate({ARC_ROTATE} 35 35)"
/>
<text class="ring-text" x="35" y="35">{macro.pct}%</text>
</svg>
<span class="macro-label">{macro.label}</span>
</div>
{/each}
</div>
{#if adjustedPortionCount > 0}
<p class="portion-cal">{fmt(perPortionCalories)} kcal {labels.perPortion}</p>
{/if}
{#if showDetails}
<div class="details-grid">
<div class="detail-section">
<h4>{labels.title} {adjustedPortionCount > 0 ? `(${labels.perPortion})` : ''}</h4>
<div class="detail-row"><span>{labels.protein}</span><span>{fmt(nutrition.totalMacros.protein / div)}g</span></div>
<div class="detail-row"><span>{labels.fat}</span><span>{fmt(nutrition.totalMacros.fat / div)}g</span></div>
<div class="detail-row"><span>&nbsp;&nbsp;{labels.saturatedFat}</span><span>{fmt(nutrition.totalMacros.saturatedFat / div)}g</span></div>
<div class="detail-row"><span>{labels.carbs}</span><span>{fmt(nutrition.totalMacros.carbs / div)}g</span></div>
<div class="detail-row"><span>&nbsp;&nbsp;{labels.sugars}</span><span>{fmt(nutrition.totalMacros.sugars / div)}g</span></div>
<div class="detail-row"><span>{labels.fiber}</span><span>{fmt(nutrition.totalMacros.fiber / div)}g</span></div>
</div>
<div class="detail-section">
<h4>{labels.vitamins}</h4>
<div class="detail-row"><span>Vitamin A</span><span>{fmt(nutrition.totalMicros.vitaminA / div)} mcg</span></div>
<div class="detail-row"><span>Vitamin C</span><span>{fmt(nutrition.totalMicros.vitaminC / div)} mg</span></div>
<div class="detail-row"><span>Vitamin D</span><span>{fmt(nutrition.totalMicros.vitaminD / div)} mcg</span></div>
<div class="detail-row"><span>Vitamin E</span><span>{fmt(nutrition.totalMicros.vitaminE / div)} mg</span></div>
<div class="detail-row"><span>Vitamin K</span><span>{fmt(nutrition.totalMicros.vitaminK / div)} mcg</span></div>
<div class="detail-row"><span>Vitamin B12</span><span>{fmt(nutrition.totalMicros.vitaminB12 / div)} mcg</span></div>
<div class="detail-row"><span>Folate</span><span>{fmt(nutrition.totalMicros.folate / div)} mcg</span></div>
</div>
<div class="detail-section">
<h4>{labels.minerals}</h4>
<div class="detail-row"><span>Calcium</span><span>{fmt(nutrition.totalMicros.calcium / div)} mg</span></div>
<div class="detail-row"><span>{isEnglish ? 'Iron' : 'Eisen'}</span><span>{fmt(nutrition.totalMicros.iron / div)} mg</span></div>
<div class="detail-row"><span>Magnesium</span><span>{fmt(nutrition.totalMicros.magnesium / div)} mg</span></div>
<div class="detail-row"><span>Potassium</span><span>{fmt(nutrition.totalMicros.potassium / div)} mg</span></div>
<div class="detail-row"><span>Sodium</span><span>{fmt(nutrition.totalMicros.sodium / div)} mg</span></div>
<div class="detail-row"><span>{isEnglish ? 'Zinc' : 'Zink'}</span><span>{fmt(nutrition.totalMicros.zinc / div)} mg</span></div>
</div>
{#if hasAminoAcids}
<div class="detail-section">
<h4>{labels.aminoAcids}</h4>
<div class="detail-row"><span>{isEnglish ? 'Leucine' : 'Leucin'}</span><span>{fmt(nutrition.totalAminoAcids.leucine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Isoleucine' : 'Isoleucin'}</span><span>{fmt(nutrition.totalAminoAcids.isoleucine / div)} g</span></div>
<div class="detail-row"><span>Valin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.valine / div)} g</span></div>
<div class="detail-row"><span>Lysin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.lysine / div)} g</span></div>
<div class="detail-row"><span>Methionin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.methionine / div)} g</span></div>
<div class="detail-row"><span>Phenylalanin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.phenylalanine / div)} g</span></div>
<div class="detail-row"><span>Threonin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.threonine / div)} g</span></div>
<div class="detail-row"><span>Tryptophan</span><span>{fmt(nutrition.totalAminoAcids.tryptophan / div)} g</span></div>
<div class="detail-row"><span>Histidin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.histidine / div)} g</span></div>
<div class="detail-row"><span>Arginin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.arginine / div)} g</span></div>
<div class="detail-row"><span>Alanin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.alanine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Aspartic Acid' : 'Asparaginsäure'}</span><span>{fmt(nutrition.totalAminoAcids.asparticAcid / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Cysteine' : 'Cystein'}</span><span>{fmt(nutrition.totalAminoAcids.cysteine / div)} g</span></div>
<div class="detail-row"><span>{isEnglish ? 'Glutamic Acid' : 'Glutaminsäure'}</span><span>{fmt(nutrition.totalAminoAcids.glutamicAcid / div)} g</span></div>
<div class="detail-row"><span>Glycin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.glycine / div)} g</span></div>
<div class="detail-row"><span>Prolin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.proline / div)} g</span></div>
<div class="detail-row"><span>Serin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.serine / div)} g</span></div>
<div class="detail-row"><span>Tyrosin{isEnglish ? 'e' : ''}</span><span>{fmt(nutrition.totalAminoAcids.tyrosine / div)} g</span></div>
</div>
{/if}
</div>
{/if}
{#if nutrition.coverage < 1}
<div class="coverage-warning">
{Math.round(nutrition.coverage * 100)}% {labels.coverage}
{#if nutrition.unmapped.length > 0}
{labels.unmapped}: {nutrition.unmapped.join(', ')}
{/if}
</div>
{/if}
<div class="details-toggle-row">
<button class="details-toggle" onclick={() => showDetails = !showDetails}>
{showDetails ? '' : '+'} {labels.details}
</button>
</div>
</div>
{/if}

15
src/lib/data/blsDb.ts Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,93 @@
/**
* Default amounts for ingredients listed without an explicit amount in a recipe.
* E.g., just "salt" with no quantity defaults to 1 pinch.
*
* Resolution order:
* 1. Exact match on normalized ingredient name
* 2. Category-based fallback (keyed by USDA food category prefix with '_')
* 3. Mark as unmapped
*/
export type DefaultAmount = { amount: number; unit: string };
/** Exact-match defaults for common ingredients listed without amounts */
export const DEFAULT_AMOUNTS: Record<string, DefaultAmount> = {
// Salt & pepper
'salt': { amount: 1, unit: 'pinch' },
'pepper': { amount: 1, unit: 'pinch' },
'black pepper': { amount: 1, unit: 'pinch' },
'white pepper': { amount: 1, unit: 'pinch' },
'sea salt': { amount: 1, unit: 'pinch' },
// Common spices
'cinnamon': { amount: 0.5, unit: 'tsp' },
'nutmeg': { amount: 0.25, unit: 'tsp' },
'paprika': { amount: 0.5, unit: 'tsp' },
'cumin': { amount: 0.5, unit: 'tsp' },
'turmeric': { amount: 0.25, unit: 'tsp' },
'chili flakes': { amount: 0.25, unit: 'tsp' },
'cayenne pepper': { amount: 0.25, unit: 'tsp' },
'garlic powder': { amount: 0.5, unit: 'tsp' },
'onion powder': { amount: 0.5, unit: 'tsp' },
'oregano': { amount: 0.5, unit: 'tsp' },
'thyme': { amount: 0.5, unit: 'tsp' },
'rosemary': { amount: 0.5, unit: 'tsp' },
// Fresh herbs
'parsley': { amount: 1, unit: 'tbsp' },
'basil': { amount: 1, unit: 'tbsp' },
'cilantro': { amount: 1, unit: 'tbsp' },
'coriander': { amount: 1, unit: 'tbsp' },
'dill': { amount: 1, unit: 'tbsp' },
'chives': { amount: 1, unit: 'tbsp' },
'mint': { amount: 1, unit: 'tbsp' },
// Oils/fats (when listed for greasing)
'oil': { amount: 1, unit: 'tbsp' },
'olive oil': { amount: 1, unit: 'tbsp' },
'vegetable oil': { amount: 1, unit: 'tbsp' },
'butter': { amount: 1, unit: 'tbsp' },
// Liquids
'water': { amount: 0, unit: 'ml' }, // excluded from calorie calc
'vanilla extract': { amount: 1, unit: 'tsp' },
'lemon juice': { amount: 1, unit: 'tbsp' },
'vinegar': { amount: 1, unit: 'tbsp' },
'soy sauce': { amount: 1, unit: 'tbsp' },
};
/** Category-based fallbacks when no exact match is found.
* Keyed with '_' prefix to distinguish from ingredient names. */
export const CATEGORY_FALLBACKS: Record<string, DefaultAmount> = {
'_Spices and Herbs': { amount: 0.5, unit: 'tsp' },
'_Fats and Oils': { amount: 1, unit: 'tbsp' },
'_Beverages': { amount: 100, unit: 'ml' },
};
/**
* Resolve a default amount for an ingredient that has no amount specified.
* Returns the default amount and unit, or null if no default is available.
*/
export function resolveDefaultAmount(
normalizedName: string,
usdaCategory?: string
): DefaultAmount | null {
// Exact match
if (DEFAULT_AMOUNTS[normalizedName]) {
return DEFAULT_AMOUNTS[normalizedName];
}
// Partial match: check if any key is contained in the name or vice versa
for (const [key, value] of Object.entries(DEFAULT_AMOUNTS)) {
if (normalizedName.includes(key) || key.includes(normalizedName)) {
return value;
}
}
// Category fallback
if (usdaCategory && CATEGORY_FALLBACKS[`_${usdaCategory}`]) {
return CATEGORY_FALLBACKS[`_${usdaCategory}`];
}
return null;
}

View File

@@ -0,0 +1,247 @@
/**
* Fast-path alias table mapping normalized English ingredient names to USDA nutrition DB names.
* Checked before the embedding-based matching to avoid unnecessary ML inference for common ingredients.
*
* Keys: normalized lowercase ingredient names (stripped of modifiers)
* Values: exact USDA FDC name as it appears in nutritionDb.ts
*
* Expand this table over time — run scripts/generate-ingredient-aliases.ts to suggest new entries.
*/
export const INGREDIENT_ALIASES: Record<string, string> = {
// Dairy & Eggs
'egg': 'Egg, whole, raw, fresh',
'eggs': 'Egg, whole, raw, fresh',
'egg yolk': 'Egg, yolk, raw, fresh',
'egg yolks': 'Egg, yolk, raw, fresh',
'egg white': 'Egg, white, raw, fresh',
'egg whites': 'Egg, white, raw, fresh',
'butter': 'Butter, salted',
'unsalted butter': 'Butter, without salt',
'milk': 'Milk, whole, 3.25% milkfat, with added vitamin D',
'whole milk': 'Milk, whole, 3.25% milkfat, with added vitamin D',
'cream': 'Cream, fluid, heavy whipping',
'heavy cream': 'Cream, fluid, heavy whipping',
'whipping cream': 'Cream, fluid, heavy whipping',
'sour cream': 'Cream, sour, cultured',
'cream cheese': 'Cheese, cream',
'parmesan': 'Cheese, parmesan, hard',
'mozzarella': 'Cheese, mozzarella, whole milk',
'cheddar': 'Cheese, cheddar',
'yogurt': 'Yogurt, plain, whole milk',
'greek yogurt': 'Yogurt, Greek, plain, whole milk',
// Fats & Oils
'olive oil': 'Oil, olive, salad or cooking',
'vegetable oil': 'Oil, canola',
'canola oil': 'Oil, canola',
'neutral oil': 'Oil, peanut, salad or cooking',
'peanut oil': 'Oil, peanut, salad or cooking',
'sunflower oil': 'Oil, sunflower, linoleic, (approx. 65%)',
'coconut oil': 'Oil, coconut',
'sesame oil': 'Oil, sesame, salad or cooking',
'lard': 'Lard',
'margarine': 'Margarine, regular, 80% fat, composite, stick, with salt',
// Flours & Grains
'flour': 'Wheat flour, white, all-purpose, enriched, bleached',
'all purpose flour': 'Wheat flour, white, all-purpose, enriched, bleached',
'all-purpose flour': 'Wheat flour, white, all-purpose, enriched, bleached',
'bread flour': 'Wheat flour, white, bread, enriched',
'whole wheat flour': 'Flour, whole wheat, unenriched',
'rye flour': 'Rye flour, dark',
'cornstarch': 'Cornstarch',
'corn starch': 'Cornstarch',
'rice': 'Rice, white, long-grain, regular, raw, enriched',
'white rice': 'Rice, white, long-grain, regular, raw, enriched',
'brown rice': 'Rice, brown, long-grain, raw (Includes foods for USDA\'s Food Distribution Program)',
'pasta': 'Pasta, dry, enriched',
'spaghetti': 'Pasta, dry, enriched',
'noodles': 'Noodles, egg, dry, enriched',
'oats': 'Oats (Includes foods for USDA\'s Food Distribution Program)',
'rolled oats': 'Oats (Includes foods for USDA\'s Food Distribution Program)',
'breadcrumbs': 'Bread, crumbs, dry, grated, plain',
// Sugars & Sweeteners
'sugar': 'Sugars, granulated',
'white sugar': 'Sugars, granulated',
'granulated sugar': 'Sugars, granulated',
'brown sugar': 'Sugars, brown',
'powdered sugar': 'Sugars, powdered',
'icing sugar': 'Sugars, powdered',
'honey': 'Honey',
'maple syrup': 'Syrups, maple',
'molasses': 'Molasses',
'vanilla sugar': 'Sugars, granulated', // approximate
// Leavening
'baking powder': 'Leavening agents, baking powder, double-acting, sodium aluminum sulfate',
'baking soda': 'Leavening agents, baking soda',
'bicarbonate of soda': 'Leavening agents, baking soda',
'yeast': 'Leavening agents, yeast, baker\'s, active dry',
'dry yeast': 'Leavening agents, yeast, baker\'s, active dry',
'fresh yeast': 'Leavening agents, yeast, baker\'s, compressed',
// Vegetables
'onion': 'Onions, raw',
'onions': 'Onions, raw',
'garlic': 'Garlic, raw',
'potato': 'Potatoes, flesh and skin, raw',
'potatoes': 'Potatoes, flesh and skin, raw',
'carrot': 'Carrots, raw',
'carrots': 'Carrots, raw',
'tomato': 'Tomatoes, red, ripe, raw, year round average',
'tomatoes': 'Tomatoes, red, ripe, raw, year round average',
'bell pepper': 'Peppers, sweet, red, raw',
'red bell pepper': 'Peppers, sweet, red, raw',
'green bell pepper': 'Peppers, sweet, green, raw',
'celery': 'Celery, cooked, boiled, drained, without salt',
'spinach': 'Spinach, raw',
'broccoli': 'Broccoli, raw',
'cauliflower': 'Cauliflower, raw',
'zucchini': 'Squash, summer, zucchini, includes skin, raw',
'courgette': 'Squash, summer, zucchini, includes skin, raw',
'cucumber': 'Cucumber, with peel, raw',
'lettuce': 'Lettuce, green leaf, raw',
'cabbage': 'Cabbage, common (danish, domestic, and pointed types), freshly harvest, raw',
'mushrooms': 'Mushrooms, white, raw',
'mushroom': 'Mushrooms, white, raw',
'leek': 'Leeks, (bulb and lower leaf-portion), raw',
'leeks': 'Leeks, (bulb and lower leaf-portion), raw',
'peas': 'Peas, green, raw',
'corn': 'Corn, sweet, yellow, raw',
'sweet corn': 'Corn, sweet, yellow, raw',
'eggplant': 'Eggplant, raw',
'aubergine': 'Eggplant, raw',
'pumpkin': 'Pumpkin, raw',
'sweet potato': 'Sweet potato, raw, unprepared (Includes foods for USDA\'s Food Distribution Program)',
'ginger': 'Ginger root, raw',
'shallot': 'Shallots, raw',
'shallots': 'Shallots, raw',
// Fruits
'lemon': 'Lemons, raw, without peel',
'lemon juice': 'Lemon juice, raw',
'lemon zest': 'Lemon peel, raw',
'lime': 'Limes, raw',
'lime juice': 'Lime juice, raw',
'orange': 'Oranges, raw, navels',
'orange juice': 'Orange juice, raw (Includes foods for USDA\'s Food Distribution Program)',
'apple': 'Apples, raw, with skin (Includes foods for USDA\'s Food Distribution Program)',
'banana': 'Bananas, raw',
'berries': 'Blueberries, raw',
'blueberries': 'Blueberries, raw',
'strawberries': 'Strawberries, raw',
'raspberries': 'Raspberries, raw',
'raisins': 'Raisins, dark, seedless (Includes foods for USDA\'s Food Distribution Program)',
'dried cranberries': 'Cranberries, dried, sweetened (Includes foods for USDA\'s Food Distribution Program)',
// Nuts & Seeds
'almonds': 'Nuts, almonds',
'walnuts': 'Nuts, walnuts, english',
'hazelnuts': 'Nuts, hazelnuts or filberts',
'peanuts': 'Peanuts, all types, raw',
'pine nuts': 'Nuts, pine nuts, dried',
'cashews': 'Nuts, cashew nuts, raw',
'pecans': 'Nuts, pecans',
'sesame seeds': 'Seeds, sesame seeds, whole, dried',
'sunflower seeds': 'Seeds, sunflower seed kernels, dried',
'flaxseed': 'Seeds, flaxseed',
'pumpkin seeds': 'Seeds, pumpkin and squash seed kernels, dried',
'poppy seeds': 'Seeds, sesame seeds, whole, dried', // approximate
'almond flour': 'Nuts, almonds, blanched',
'ground almonds': 'Nuts, almonds, blanched',
'coconut flakes': 'Nuts, coconut meat, dried (desiccated), sweetened, shredded',
'desiccated coconut': 'Nuts, coconut meat, dried (desiccated), sweetened, shredded',
'peanut butter': 'Peanut butter, smooth style, with salt',
// Meats
'chicken breast': 'Chicken, broiler or fryers, breast, skinless, boneless, meat only, raw',
'chicken thigh': 'Chicken, broilers or fryers, dark meat, thigh, meat only, raw',
'ground beef': 'Beef, grass-fed, ground, raw',
'minced meat': 'Beef, grass-fed, ground, raw',
'bacon': 'Pork, cured, bacon, unprepared',
'ham': 'Ham, sliced, regular (approximately 11% fat)',
'sausage': 'Sausage, pork, chorizo, link or ground, raw',
// Seafood
'salmon': 'Fish, salmon, Atlantic, wild, raw',
'tuna': 'Fish, tuna, light, canned in water, drained solids',
'shrimp': 'Crustaceans, shrimp, raw',
'prawns': 'Crustaceans, shrimp, raw',
'cod': 'Fish, cod, Atlantic, raw',
// Legumes
'chickpeas': 'Chickpeas (garbanzo beans, bengal gram), mature seeds, raw',
'lentils': 'Lentils, raw',
'black beans': 'Beans, black, mature seeds, raw',
'kidney beans': 'Beans, kidney, red, mature seeds, raw',
'white beans': 'Beans, white, mature seeds, raw',
'canned chickpeas': 'Chickpeas (garbanzo beans, bengal gram), mature seeds, canned, drained solids',
'canned beans': 'Beans, kidney, red, mature seeds, canned, drained solids',
'tofu': 'Tofu, raw, firm, prepared with calcium sulfate',
// Condiments & Sauces
'soy sauce': 'Soy sauce made from soy (tamari)',
'vinegar': 'Vinegar, distilled',
'apple cider vinegar': 'Vinegar, cider',
'balsamic vinegar': 'Vinegar, balsamic',
'mustard': 'Mustard, prepared, yellow',
'ketchup': 'Catsup',
'tomato paste': 'Tomato products, canned, paste, without salt added (Includes foods for USDA\'s Food Distribution Program)',
'tomato sauce': 'Tomato products, canned, sauce',
'canned tomatoes': 'Tomatoes, red, ripe, canned, packed in tomato juice',
'worcestershire sauce': 'Sauce, worcestershire',
'hot sauce': 'Sauce, hot chile, sriracha',
'mayonnaise': 'Salad dressing, mayonnaise, regular',
// Chocolate & Baking
'chocolate': 'Chocolate, dark, 70-85% cacao solids',
'dark chocolate': 'Chocolate, dark, 70-85% cacao solids',
'cocoa powder': 'Cocoa, dry powder, unsweetened',
'cocoa': 'Cocoa, dry powder, unsweetened',
'chocolate chips': 'Chocolate, dark, 60-69% cacao solids',
'vanilla extract': 'Vanilla extract',
'vanilla': 'Vanilla extract',
'gelatin': 'Gelatin desserts, dry mix',
// Beverages
'coffee': 'Beverages, coffee, brewed, prepared with tap water',
'tea': 'Beverages, tea, black, brewed, prepared with tap water',
'wine': 'Alcoholic beverage, wine, table, red',
'red wine': 'Alcoholic beverage, wine, table, red',
'white wine': 'Alcoholic beverage, wine, table, white',
'beer': 'Alcoholic beverage, beer, regular, all',
'coconut milk': 'Nuts, coconut milk, raw (liquid expressed from grated meat and water)',
// Misc
'salt': 'Salt, table',
'pepper': 'Spices, pepper, black',
'black pepper': 'Spices, pepper, black',
'cinnamon': 'Spices, cinnamon, ground',
'paprika': 'Spices, paprika',
'cumin': 'Spices, cumin seed',
'nutmeg': 'Spices, nutmeg, ground',
'chili powder': 'Spices, chili powder',
'oregano': 'Spices, oregano, dried',
'thyme': 'Spices, thyme, dried',
'rosemary': 'Spices, rosemary, dried',
'bay leaf': 'Spices, bay leaf',
'turmeric': 'Spices, turmeric, ground',
'basil': 'Spices, basil, dried',
'parsley': 'Spices, parsley, dried',
'dill': 'Spices, dill weed, dried',
'mint': 'Spearmint, fresh',
'cloves': 'Spices, cloves, ground',
'cardamom': 'Spices, cardamom',
'ginger powder': 'Spices, ginger, ground',
'curry powder': 'Spices, curry powder',
};
/**
* Look up a normalized ingredient name in the alias table.
* Returns the USDA name if found, null otherwise.
*/
export function lookupAlias(normalizedName: string): string | null {
return INGREDIENT_ALIASES[normalizedName] || null;
}

717861
src/lib/data/nutritionDb.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,177 @@
/**
* Unit canonicalization and gram conversion tables for recipe nutrition calculation.
*
* German and English recipe units are mapped to canonical keys.
* Ingredient-specific conversions (e.g., 1 tbsp butter = 14.2g) are resolved
* via USDA portion data at matching time — this file only handles unit normalization
* and ingredient-independent conversions.
*/
/** Maps various unit strings (German + English) to a canonical unit key */
export const UNIT_CANONICAL: Record<string, string> = {
// German → canonical
'EL': 'tbsp',
'el': 'tbsp',
'Esslöffel': 'tbsp',
'TL': 'tsp',
'tl': 'tsp',
'Teelöffel': 'tsp',
'Prise': 'pinch',
'Prisen': 'pinch',
'Msp': 'pinch',
'Msp.': 'pinch',
'Messerspitze': 'pinch',
'Bund': 'bunch',
'Stück': 'piece',
'Stk': 'piece',
'Stk.': 'piece',
'Scheibe': 'slice',
'Scheiben': 'slice',
'Zehe': 'clove',
'Zehen': 'clove',
'Blatt': 'leaf',
'Blätter': 'leaf',
'Zweig': 'sprig',
'Zweige': 'sprig',
'Dose': 'can',
'Dosen': 'can',
'Becher': 'cup',
'Tasse': 'cup',
'Tassen': 'cup',
'Packung': 'package',
'Pkg': 'package',
'Pkg.': 'package',
'Würfel': 'cube',
// English → canonical (passthrough + normalization)
'tbsp': 'tbsp',
'Tbsp': 'tbsp',
'tablespoon': 'tbsp',
'tablespoons': 'tbsp',
'tsp': 'tsp',
'Tsp': 'tsp',
'teaspoon': 'tsp',
'teaspoons': 'tsp',
'cup': 'cup',
'cups': 'cup',
'pinch': 'pinch',
'piece': 'piece',
'pieces': 'piece',
'slice': 'slice',
'slices': 'slice',
'clove': 'clove',
'cloves': 'clove',
'sprig': 'sprig',
'sprigs': 'sprig',
'bunch': 'bunch',
'leaf': 'leaf',
'leaves': 'leaf',
'can': 'can',
'package': 'package',
'cube': 'cube',
// Weight/volume units (already canonical, but normalize variants)
'g': 'g',
'gr': 'g',
'gram': 'g',
'grams': 'g',
'Gramm': 'g',
'kg': 'kg',
'kilogram': 'kg',
'ml': 'ml',
'mL': 'ml',
'milliliter': 'ml',
'Milliliter': 'ml',
'l': 'l',
'L': 'l',
'liter': 'l',
'Liter': 'l',
'oz': 'oz',
'ounce': 'oz',
'ounces': 'oz',
'lb': 'lb',
'lbs': 'lb',
'pound': 'lb',
'pounds': 'lb',
};
/** Direct gram conversions for weight/volume units (ingredient-independent) */
export const UNIT_TO_GRAMS: Record<string, number> = {
'g': 1,
'kg': 1000,
'oz': 28.3495,
'lb': 453.592,
// Volume units use water density (1 ml = 1g) as base;
// adjusted by ingredient-specific density when available
'ml': 1,
'l': 1000,
};
/**
* Fallback gram estimates for common measurement units when no USDA portion data is available.
* These are rough averages across common ingredients.
*/
export const UNIT_GRAM_FALLBACKS: Record<string, number> = {
'tbsp': 15, // ~15ml, varies by ingredient density
'tsp': 5, // ~5ml
'cup': 240, // US cup = ~240ml
'pinch': 0.3,
'slice': 30,
'clove': 3, // garlic clove
'sprig': 2,
'bunch': 30,
'leaf': 0.5,
'cube': 25, // bouillon cube
'can': 400, // standard can
'package': 200,
};
/** Canonicalize a unit string. Returns the canonical key, or the original lowercased string if unknown. */
export function canonicalizeUnit(unit: string): string {
const trimmed = unit.trim();
return UNIT_CANONICAL[trimmed] || UNIT_CANONICAL[trimmed.toLowerCase()] || trimmed.toLowerCase();
}
/**
* Get the gram weight for a given canonical unit, using USDA portion data when available.
* Falls back to standard conversions and then generic estimates.
*/
export function resolveGramsPerUnit(
canonicalUnit: string,
usdaPortions: { description: string; grams: number }[],
density?: number
): { grams: number; source: 'direct' | 'density' | 'usda_portion' | 'estimate' | 'none' } {
// Direct weight conversion
if (canonicalUnit in UNIT_TO_GRAMS) {
const baseGrams = UNIT_TO_GRAMS[canonicalUnit];
// Apply density for volume units
if ((canonicalUnit === 'ml' || canonicalUnit === 'l') && density) {
return { grams: baseGrams * density, source: 'density' };
}
return { grams: baseGrams, source: 'direct' };
}
// Try to match against USDA portions
if (usdaPortions.length > 0) {
const unitLower = canonicalUnit.toLowerCase();
for (const portion of usdaPortions) {
const descLower = portion.description.toLowerCase();
// Match "1 tbsp", "tbsp", "tablespoon", etc.
if (descLower.includes(unitLower) ||
(unitLower === 'tbsp' && descLower.includes('tablespoon')) ||
(unitLower === 'tsp' && descLower.includes('teaspoon')) ||
(unitLower === 'cup' && descLower.includes('cup')) ||
(unitLower === 'piece' && (descLower.includes('unit') || descLower.includes('medium') || descLower.includes('whole'))) ||
(unitLower === 'slice' && descLower.includes('slice'))) {
return { grams: portion.grams, source: 'usda_portion' };
}
}
}
// Fallback estimates
if (canonicalUnit in UNIT_GRAM_FALLBACKS) {
return { grams: UNIT_GRAM_FALLBACKS[canonicalUnit], source: 'estimate' };
}
return { grams: 0, source: 'none' };
}

View File

@@ -0,0 +1,302 @@
/**
* Reactive nutrition calculator factory for recipe calorie/macro display.
* Uses Svelte 5 runes ($state/$derived) with the factory pattern.
*
* Import without .ts extension: import { createNutritionCalculator } from '$lib/js/nutrition.svelte'
*
* NOTE: Does NOT import the full NUTRITION_DB — all per100g data comes pre-resolved
* in the NutritionMapping objects from the API to keep client bundle small.
*/
import type { NutritionMapping } from '$types/types';
export type MacroTotals = {
calories: number;
protein: number;
fat: number;
saturatedFat: number;
carbs: number;
fiber: number;
sugars: number;
};
export type MicroTotals = {
calcium: number;
iron: number;
magnesium: number;
phosphorus: number;
potassium: number;
sodium: number;
zinc: number;
vitaminA: number;
vitaminC: number;
vitaminD: number;
vitaminE: number;
vitaminK: number;
thiamin: number;
riboflavin: number;
niacin: number;
vitaminB6: number;
vitaminB12: number;
folate: number;
cholesterol: number;
};
export type AminoAcidTotals = {
isoleucine: number;
leucine: number;
lysine: number;
methionine: number;
phenylalanine: number;
threonine: number;
tryptophan: number;
valine: number;
histidine: number;
alanine: number;
arginine: number;
asparticAcid: number;
cysteine: number;
glutamicAcid: number;
glycine: number;
proline: number;
serine: number;
tyrosine: number;
};
const AMINO_ACID_KEYS: (keyof AminoAcidTotals)[] = [
'isoleucine', 'leucine', 'lysine', 'methionine', 'phenylalanine',
'threonine', 'tryptophan', 'valine', 'histidine', 'alanine',
'arginine', 'asparticAcid', 'cysteine', 'glutamicAcid', 'glycine',
'proline', 'serine', 'tyrosine',
];
export type IngredientNutrition = {
name: string;
calories: number;
mapped: boolean;
};
/** Parse a recipe amount string to a number */
function parseAmount(amount: string): number {
if (!amount?.trim()) return 0;
let s = amount.trim();
const rangeMatch = s.match(/^(\d+(?:[.,]\d+)?)\s*[-]\s*(\d+(?:[.,]\d+)?)$/);
if (rangeMatch) {
return (parseFloat(rangeMatch[1].replace(',', '.')) + parseFloat(rangeMatch[2].replace(',', '.'))) / 2;
}
s = s.replace(',', '.');
const fractionMatch = s.match(/^(\d+)\s*\/\s*(\d+)$/);
if (fractionMatch) return parseInt(fractionMatch[1]) / parseInt(fractionMatch[2]);
const mixedMatch = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
if (mixedMatch) return parseInt(mixedMatch[1]) + parseInt(mixedMatch[2]) / parseInt(mixedMatch[3]);
const parsed = parseFloat(s);
return isNaN(parsed) ? 0 : parsed;
}
/** Calculate grams for a single ingredient */
function calculateGrams(
amount: string,
mapping: NutritionMapping,
multiplier: number
): number {
if (mapping.excluded || !mapping.gramsPerUnit) return 0;
const parsedAmount = parseAmount(amount) || (mapping.defaultAmountUsed ? 1 : 0);
return parsedAmount * multiplier * mapping.gramsPerUnit;
}
export type ReferencedNutrition = {
shortName: string;
name: string;
nutrition: Record<string, number>;
baseMultiplier: number;
};
/** Strip HTML tags from a string */
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '');
}
/**
* Create a reactive nutrition calculator.
*
* @param getFlatIngredients - getter for flattened ingredient list
* @param getMappings - getter for nutrition mappings (with per100g resolved by the API)
* @param getMultiplier - getter for the current recipe multiplier
* @param getSectionNames - getter for section names (for dedup)
* @param getReferencedNutrition - getter for pre-computed nutrition from referenced recipes
*/
export function createNutritionCalculator(
getFlatIngredients: () => { name: string; unit: string; amount: string; sectionIndex: number; ingredientIndex: number; sectionName?: string }[],
getMappings: () => NutritionMapping[],
getMultiplier: () => number,
getSectionNames?: () => Set<string>,
getReferencedNutrition?: () => ReferencedNutrition[],
) {
const mappingIndex = $derived(
new Map(getMappings().map(m => [`${m.sectionIndex}-${m.ingredientIndex}`, m]))
);
/** Check if ingredient should be skipped (name matches a different section's name) */
function isSkippedDuplicate(ing: { name: string; sectionName?: string }): boolean {
if (!getSectionNames) return false;
const names = getSectionNames();
const stripped = stripHtml(ing.name).toLowerCase().trim();
const ownSection = (ing.sectionName || '').toLowerCase().trim();
return stripped !== '' && names.has(stripped) && stripped !== ownSection;
}
/** Check if ingredient is an anchor-tag reference to another recipe */
function isAnchorRef(ing: { name: string }): boolean {
return /<a\s/i.test(ing.name);
}
/** Check if ingredient should be excluded from direct nutrition calculation */
function shouldSkip(ing: { name: string; sectionName?: string }): boolean {
return isSkippedDuplicate(ing) || isAnchorRef(ing);
}
const perIngredient = $derived(
getFlatIngredients().map(ing => {
if (shouldSkip(ing)) return { name: ing.name, calories: 0, mapped: true };
const mapping = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
if (!mapping || mapping.matchMethod === 'none' || mapping.excluded || !mapping.per100g) {
return { name: ing.name, calories: 0, mapped: false };
}
const grams = calculateGrams(ing.amount, mapping, getMultiplier());
const calories = (grams / 100) * mapping.per100g.calories;
return { name: ing.name, calories, mapped: true };
})
);
/** Add referenced recipe nutrition totals, scaled by multiplier and baseMultiplier */
function addReferencedNutrition(result: Record<string, number>, mult: number) {
if (!getReferencedNutrition) return;
for (const ref of getReferencedNutrition()) {
const scale = mult * ref.baseMultiplier;
for (const [key, value] of Object.entries(ref.nutrition)) {
if (key in result && typeof value === 'number') {
result[key] += value * scale;
}
}
}
}
const totalMacros = $derived.by(() => {
const result: MacroTotals = { calories: 0, protein: 0, fat: 0, saturatedFat: 0, carbs: 0, fiber: 0, sugars: 0 };
const ingredients = getFlatIngredients();
const mult = getMultiplier();
for (const ing of ingredients) {
if (shouldSkip(ing)) continue;
const mapping = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
if (!mapping || mapping.matchMethod === 'none' || mapping.excluded || !mapping.per100g) continue;
const factor = calculateGrams(ing.amount, mapping, mult) / 100;
result.calories += factor * mapping.per100g.calories;
result.protein += factor * mapping.per100g.protein;
result.fat += factor * mapping.per100g.fat;
result.saturatedFat += factor * mapping.per100g.saturatedFat;
result.carbs += factor * mapping.per100g.carbs;
result.fiber += factor * mapping.per100g.fiber;
result.sugars += factor * mapping.per100g.sugars;
}
addReferencedNutrition(result, mult);
return result;
});
const totalMicros = $derived.by(() => {
const result: MicroTotals = {
calcium: 0, iron: 0, magnesium: 0, phosphorus: 0,
potassium: 0, sodium: 0, zinc: 0,
vitaminA: 0, vitaminC: 0, vitaminD: 0, vitaminE: 0,
vitaminK: 0, thiamin: 0, riboflavin: 0, niacin: 0,
vitaminB6: 0, vitaminB12: 0, folate: 0, cholesterol: 0,
};
const ingredients = getFlatIngredients();
const mult = getMultiplier();
for (const ing of ingredients) {
if (shouldSkip(ing)) continue;
const mapping = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
if (!mapping || mapping.matchMethod === 'none' || mapping.excluded || !mapping.per100g) continue;
const factor = calculateGrams(ing.amount, mapping, mult) / 100;
for (const key of Object.keys(result) as (keyof MicroTotals)[]) {
result[key] += factor * ((mapping.per100g as any)[key] || 0);
}
}
addReferencedNutrition(result as Record<string, number>, mult);
return result;
});
const totalAminoAcids = $derived.by(() => {
const result: AminoAcidTotals = {
isoleucine: 0, leucine: 0, lysine: 0, methionine: 0, phenylalanine: 0,
threonine: 0, tryptophan: 0, valine: 0, histidine: 0, alanine: 0,
arginine: 0, asparticAcid: 0, cysteine: 0, glutamicAcid: 0, glycine: 0,
proline: 0, serine: 0, tyrosine: 0,
};
const ingredients = getFlatIngredients();
const mult = getMultiplier();
for (const ing of ingredients) {
if (shouldSkip(ing)) continue;
const mapping = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
if (!mapping || mapping.matchMethod === 'none' || mapping.excluded || !mapping.per100g) continue;
const factor = calculateGrams(ing.amount, mapping, mult) / 100;
for (const key of AMINO_ACID_KEYS) {
result[key] += factor * ((mapping.per100g as any)[key] || 0);
}
}
addReferencedNutrition(result as Record<string, number>, mult);
return result;
});
const coverage = $derived.by(() => {
const ingredients = getFlatIngredients();
if (ingredients.length === 0) return 1;
let total = 0;
let mapped = 0;
for (const ing of ingredients) {
// Skipped duplicates and anchor-tag refs count as covered
if (shouldSkip(ing)) { total++; mapped++; continue; }
total++;
const m = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
// Manually excluded ingredients count as covered
if (m?.excluded) { total++; mapped++; continue; }
if (m && m.matchMethod !== 'none') mapped++;
}
return total > 0 ? mapped / total : 1;
});
const unmapped = $derived(
getFlatIngredients()
.filter(ing => {
if (shouldSkip(ing)) return false;
const m = mappingIndex.get(`${ing.sectionIndex}-${ing.ingredientIndex}`);
if (m?.excluded) return false;
return !m || m.matchMethod === 'none';
})
.map(ing => stripHtml(ing.name))
);
return {
get perIngredient() { return perIngredient; },
get totalMacros() { return totalMacros; },
get totalMicros() { return totalMicros; },
get totalAminoAcids() { return totalAminoAcids; },
get coverage() { return coverage; },
get unmapped() { return unmapped; },
};
}

View File

@@ -0,0 +1,816 @@
/**
* Dual-source embedding-based ingredient matching engine.
* Priority: global overwrite → alias → BLS (German, primary) → USDA (English, fallback) → none
*
* BLS uses multilingual-e5-small for German ingredient names.
* USDA uses all-MiniLM-L6-v2 for English ingredient names.
*/
import { pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { NUTRITION_DB, type NutritionEntry } from '$lib/data/nutritionDb';
import { BLS_DB, type BlsEntry } from '$lib/data/blsDb';
import { lookupAlias } from '$lib/data/ingredientAliases';
import { canonicalizeUnit, resolveGramsPerUnit } from '$lib/data/unitConversions';
import { resolveDefaultAmount } from '$lib/data/defaultAmounts';
import type { NutritionMapping, NutritionPer100g } from '$types/types';
import { NutritionOverwrite } from '$models/NutritionOverwrite';
const USDA_MODEL = 'Xenova/all-MiniLM-L6-v2';
const BLS_MODEL = 'Xenova/multilingual-e5-small';
const USDA_EMBEDDINGS_PATH = resolve('src/lib/data/nutritionEmbeddings.json');
const BLS_EMBEDDINGS_PATH = resolve('src/lib/data/blsEmbeddings.json');
const CONFIDENCE_THRESHOLD = 0.45;
// Lazy-loaded singletons — USDA
let usdaEmbedder: FeatureExtractionPipeline | null = null;
let usdaEmbeddingIndex: { fdcId: number; name: string; vector: number[] }[] | null = null;
let nutritionByFdcId: Map<number, NutritionEntry> | null = null;
let nutritionByName: Map<string, NutritionEntry> | null = null;
// Lazy-loaded singletons — BLS
let blsEmbedder: FeatureExtractionPipeline | null = null;
let blsEmbeddingIndex: { blsCode: string; name: string; vector: number[] }[] | null = null;
let blsByCode: Map<string, BlsEntry> | null = null;
/** Modifiers to strip from ingredient names before matching */
const STRIP_MODIFIERS = [
'warm', 'cold', 'hot', 'room temperature', 'lukewarm',
'fresh', 'freshly', 'dried', 'dry',
'finely', 'coarsely', 'roughly', 'thinly',
'chopped', 'diced', 'minced', 'sliced', 'grated', 'shredded',
'crushed', 'ground', 'whole', 'halved', 'quartered',
'peeled', 'unpeeled', 'pitted', 'seeded', 'deseeded',
'melted', 'softened', 'frozen', 'thawed', 'chilled',
'toasted', 'roasted', 'blanched', 'boiled', 'steamed',
'sifted', 'packed', 'loosely packed', 'firmly packed',
'small', 'medium', 'large', 'extra-large',
'organic', 'free-range', 'grass-fed',
'optional', 'to taste', 'as needed', 'for garnish', 'for serving',
'about', 'approximately', 'roughly',
];
/** German modifiers to strip */
const STRIP_MODIFIERS_DE = [
'warm', 'kalt', 'heiß', 'lauwarm', 'zimmerwarm',
'frisch', 'getrocknet', 'trocken',
'fein', 'grob', 'dünn',
'gehackt', 'gewürfelt', 'geschnitten', 'gerieben', 'geraspelt',
'gemahlen', 'ganz', 'halbiert', 'geviertelt',
'geschält', 'entkernt', 'entsteint',
'geschmolzen', 'weich', 'gefroren', 'aufgetaut', 'gekühlt',
'geröstet', 'blanchiert', 'gekocht', 'gedämpft',
'gesiebt',
'klein', 'mittel', 'groß',
'bio', 'optional', 'nach Geschmack', 'nach Bedarf', 'zum Garnieren',
'etwa', 'ungefähr', 'ca',
];
// ── USDA helpers ──
function getNutritionByName(): Map<string, NutritionEntry> {
if (!nutritionByName) {
nutritionByName = new Map();
for (const entry of NUTRITION_DB) nutritionByName.set(entry.name, entry);
}
return nutritionByName;
}
function getNutritionByFdcId(): Map<number, NutritionEntry> {
if (!nutritionByFdcId) {
nutritionByFdcId = new Map();
for (const entry of NUTRITION_DB) nutritionByFdcId.set(entry.fdcId, entry);
}
return nutritionByFdcId;
}
async function getUsdaEmbedder(): Promise<FeatureExtractionPipeline> {
if (!usdaEmbedder) {
usdaEmbedder = await pipeline('feature-extraction', USDA_MODEL, { dtype: 'q8' });
}
return usdaEmbedder;
}
function getUsdaEmbeddingIndex() {
if (!usdaEmbeddingIndex) {
const raw = JSON.parse(readFileSync(USDA_EMBEDDINGS_PATH, 'utf-8'));
usdaEmbeddingIndex = raw.entries;
}
return usdaEmbeddingIndex!;
}
// ── BLS helpers ──
function getBlsByCode(): Map<string, BlsEntry> {
if (!blsByCode) {
blsByCode = new Map();
for (const entry of BLS_DB) blsByCode.set(entry.blsCode, entry);
}
return blsByCode;
}
async function getBlsEmbedder(): Promise<FeatureExtractionPipeline> {
if (!blsEmbedder) {
blsEmbedder = await pipeline('feature-extraction', BLS_MODEL, { dtype: 'q8' });
}
return blsEmbedder;
}
function getBlsEmbeddingIndex() {
if (!blsEmbeddingIndex) {
try {
const raw = JSON.parse(readFileSync(BLS_EMBEDDINGS_PATH, 'utf-8'));
blsEmbeddingIndex = raw.entries;
} catch {
// BLS embeddings not yet generated — skip
blsEmbeddingIndex = [];
}
}
return blsEmbeddingIndex!;
}
// ── Shared ──
/** Normalize an ingredient name for matching (English) */
export function normalizeIngredientName(name: string): string {
let normalized = name.toLowerCase().trim();
normalized = normalized.replace(/\(.*?\)/g, '').trim();
for (const mod of STRIP_MODIFIERS) {
normalized = normalized.replace(new RegExp(`\\b${mod}\\b,?\\s*`, 'gi'), '').trim();
}
normalized = normalized.replace(/\s+/g, ' ').replace(/,\s*$/, '').trim();
return normalized;
}
/** Normalize a German ingredient name for matching */
export function normalizeIngredientNameDe(name: string): string {
let normalized = name.toLowerCase().trim();
normalized = normalized.replace(/\(.*?\)/g, '').trim();
for (const mod of STRIP_MODIFIERS_DE) {
normalized = normalized.replace(new RegExp(`\\b${mod}\\b,?\\s*`, 'gi'), '').trim();
}
normalized = normalized.replace(/\s+/g, ' ').replace(/,\s*$/, '').trim();
return normalized;
}
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
/** Replace German umlauts and ß for fuzzy substring matching */
function deUmlaut(s: string): string {
return s.replace(/ä/g, 'a').replace(/ö/g, 'o').replace(/ü/g, 'u').replace(/ß/g, 'ss');
}
/**
* Generate singular/stem forms for a German word for substring matching.
* Not full stemming — just common plural patterns.
*/
function germanSingulars(word: string): string[] {
const base = deUmlaut(word);
const forms = new Set([word, base]);
// -n: Tomaten→Tomate, Kartoffeln→Kartoffel
if (base.endsWith('n')) forms.add(base.slice(0, -1));
// -en: Bohnen→Bohn (then also try Bohne)
if (base.endsWith('en')) { forms.add(base.slice(0, -2)); forms.add(base.slice(0, -1)); }
// -er: Eier→Ei
if (base.endsWith('er')) forms.add(base.slice(0, -2));
// -e: Birne→Birn (for compound matching)
if (base.endsWith('e')) forms.add(base.slice(0, -1));
// -s: (loanwords)
if (base.endsWith('s')) forms.add(base.slice(0, -1));
return [...forms].filter(f => f.length >= 2);
}
/** BLS categories that are prepared dishes — exclude from embedding-only matching */
const EXCLUDED_BLS_CATEGORIES = new Set([
'Gerichte und Rezepte', 'Backwaren', 'Supplemente',
]);
/**
* Generate search forms for an ingredient name, including compound word parts
* and individual words for multi-word queries.
* "Zitronenschale" → ["zitronenschale", "zitronen", "zitrone", "schale", ...]
* "cinnamon stick" → ["cinnamon stick", "cinnamon", "stick", ...]
*/
function searchForms(query: string): string[] {
const forms = new Set(germanSingulars(query.toLowerCase()));
// Add individual words from multi-word queries
const words = query.toLowerCase().split(/\s+/);
for (const word of words) {
if (word.length >= 3) {
forms.add(word);
forms.add(deUmlaut(word));
for (const s of germanSingulars(word)) forms.add(s);
}
}
// Try splitting common German compound suffixes
const compoundSuffixes = [
'schale', 'saft', 'stange', 'stück', 'pulver', 'blatt', 'blätter',
'korn', 'körner', 'mehl', 'öl', 'ol', 'flocken', 'creme', 'mark',
'wasser', 'milch', 'sahne', 'butter', 'käse', 'kase', 'soße', 'sosse',
];
const base = deUmlaut(query.toLowerCase());
for (const suffix of compoundSuffixes) {
if (base.endsWith(suffix) && base.length > suffix.length + 2) {
const stem = base.slice(0, -suffix.length);
forms.add(stem);
for (const s of germanSingulars(stem)) forms.add(s);
}
}
return [...forms].filter(f => f.length >= 3);
}
/**
* Find substring matches in a name list. Returns indices of entries
* where any form of the query appears in the entry name.
*/
function findSubstringMatches(
query: string,
entries: { name: string }[],
): number[] {
const forms = searchForms(query);
const matches: number[] = [];
for (let i = 0; i < entries.length; i++) {
const entryName = deUmlaut(entries[i].name.toLowerCase());
for (const form of forms) {
if (entryName.includes(form)) {
matches.push(i);
break;
}
}
}
return matches;
}
/**
* Score a substring match, combining embedding similarity with heuristics:
* - Word-boundary matches preferred over mid-word matches
* - Shorter names are preferred (more likely base ingredients)
* - Names containing "roh" (raw) get a bonus
* - Names starting with the query get a bonus
*/
function substringMatchScore(
embeddingScore: number,
entryName: string,
queryForms: string[],
): number {
let score = embeddingScore;
const nameLower = deUmlaut(entryName.toLowerCase());
// Check how the query matches: word-start vs mid-compound vs trailing mention
let hasStartMatch = false;
let hasEarlyMatch = false; // within first 15 chars
let hasWordBoundaryMatch = false;
for (const form of queryForms) {
// Start match: name begins with the query
if (nameLower.startsWith(form + ' ') || nameLower.startsWith(form + ',') || nameLower === form) {
hasStartMatch = true;
}
// Early match: appears within first ~15 chars (likely the main ingredient)
const pos = nameLower.indexOf(form);
if (pos >= 0 && pos < 15) hasEarlyMatch = true;
// Word-boundary match
const wordBoundary = new RegExp(`(^|[\\s,/])${form}([\\s,/]|$)`);
if (wordBoundary.test(nameLower)) hasWordBoundaryMatch = true;
}
// Strong bonus for name starting with query form
if (hasStartMatch) score += 0.2;
// Moderate bonus for early appearance in name
else if (hasEarlyMatch) score += 0.12;
// Small bonus for word-boundary match
else if (hasWordBoundaryMatch) score += 0.05;
// Penalty for late/trailing mentions (e.g., "mit Zimt" at end of a dish name)
else score -= 0.05;
// Bonus for short names (base ingredients like "Apfel roh" vs long dish names)
// Short names get strong boost, long names get penalized
score += Math.max(-0.1, (20 - nameLower.length) * 0.008);
// Bonus for "roh" (raw) — but only if query starts the name (avoid boosting unrelated raw items)
if (/\broh\b/.test(nameLower) && (hasStartMatch || hasWordBoundaryMatch)) score += 0.1;
return score;
}
/**
* Find best BLS match: substring-first hybrid.
* 1. Find BLS entries whose name contains the ingredient (lexical match)
* 2. Among those, rank by embedding + heuristic score
* 3. If no lexical matches, fall back to full embedding search
*/
async function blsEmbeddingMatch(
ingredientNameDe: string
): Promise<{ entry: BlsEntry; confidence: number } | null> {
const index = getBlsEmbeddingIndex();
if (index.length === 0) return null;
const emb = await getBlsEmbedder();
const result = await emb(`query: ${ingredientNameDe}`, { pooling: 'mean', normalize: true });
const queryVector = Array.from(result.data as Float32Array);
const queryForms = searchForms(ingredientNameDe);
// Find lexical substring matches first
const substringIndices = findSubstringMatches(ingredientNameDe, index);
if (substringIndices.length > 0) {
let bestScore = -1;
let bestItem: typeof index[0] | null = null;
for (const idx of substringIndices) {
const item = index[idx];
const entry = getBlsByCode().get(item.blsCode);
if (entry && EXCLUDED_BLS_CATEGORIES.has(entry.category)) continue;
const embScore = cosineSimilarity(queryVector, item.vector);
const score = substringMatchScore(embScore, item.name, queryForms);
if (score > bestScore) {
bestScore = score;
bestItem = item;
}
}
if (bestItem) {
const entry = getBlsByCode().get(bestItem.blsCode);
if (entry) {
// Check if ANY substring match is a direct hit (query at start/early in name)
const nameNorm = deUmlaut(bestItem.name.toLowerCase());
const isDirectMatch = queryForms.some(f =>
nameNorm.startsWith(f + ' ') || nameNorm.startsWith(f + ',') ||
nameNorm.startsWith(f + '/') || nameNorm === f ||
(nameNorm.indexOf(f) >= 0 && nameNorm.indexOf(f) < 12)
);
// Only use substring match if it's a direct hit — otherwise the query
// word appears as a minor component in a dish name and we should
// fall through to full search / USDA
if (isDirectMatch) {
const conf = Math.min(Math.max(bestScore, 0.7), 1.0);
return { entry, confidence: conf };
}
}
}
}
// Fall back to full embedding search (excluding prepared dishes)
// Use higher threshold for pure embedding — short German words produce unreliable scores
const EMBEDDING_ONLY_THRESHOLD = 0.85;
let bestScore = -1;
let bestItem: typeof index[0] | null = null;
for (const item of index) {
const entry = getBlsByCode().get(item.blsCode);
if (entry && EXCLUDED_BLS_CATEGORIES.has(entry.category)) continue;
const score = cosineSimilarity(queryVector, item.vector);
if (score > bestScore) {
bestScore = score;
bestItem = item;
}
}
if (!bestItem || bestScore < EMBEDDING_ONLY_THRESHOLD) return null;
const entry = getBlsByCode().get(bestItem.blsCode);
if (!entry) return null;
return { entry, confidence: bestScore };
}
/** USDA categories that are prepared dishes — exclude from matching */
const EXCLUDED_USDA_CATEGORIES = new Set(['Restaurant Foods']);
/**
* Score a USDA substring match with heuristics similar to BLS.
*/
function usdaSubstringMatchScore(
embeddingScore: number,
entryName: string,
query: string,
): number {
let score = embeddingScore;
const nameLower = entryName.toLowerCase();
const queryForms = searchForms(query);
// Check match position
let hasStartMatch = false;
let hasEarlyMatch = false;
for (const form of queryForms) {
if (nameLower.startsWith(form + ',') || nameLower.startsWith(form + ' ') || nameLower === form) {
hasStartMatch = true;
}
const pos = nameLower.indexOf(form);
if (pos >= 0 && pos < 15) hasEarlyMatch = true;
}
if (hasStartMatch) score += 0.2;
else if (hasEarlyMatch) score += 0.1;
else score -= 0.05;
// Bonus for short names — but moderate to avoid "Bread, X" beating "Spices, X, ground"
score += Math.max(-0.1, (25 - nameLower.length) * 0.003);
// Bonus for "raw" — base ingredient indicator (only if direct match)
if (/\braw\b/.test(nameLower) && (hasStartMatch || hasEarlyMatch)) score += 0.1;
// Bonus for category-style entries ("Spices, X" / "Seeds, X" / "Oil, X")
if (/^(spices|seeds|oil|nuts|fish|cheese|milk|cream|butter|flour|sugar),/i.test(nameLower)) {
score += 0.08;
}
return score;
}
/**
* Find best USDA match: substring-first hybrid.
* Same strategy as BLS: lexical matches first, heuristic re-ranking, then fallback.
*/
async function usdaEmbeddingMatch(
ingredientNameEn: string
): Promise<{ entry: NutritionEntry; confidence: number } | null> {
const emb = await getUsdaEmbedder();
const index = getUsdaEmbeddingIndex();
const result = await emb(ingredientNameEn, { pooling: 'mean', normalize: true });
const queryVector = Array.from(result.data as Float32Array);
// Find lexical substring matches
const substringIndices = findSubstringMatches(ingredientNameEn, index);
if (substringIndices.length > 0) {
let bestScore = -1;
let bestItem: typeof index[0] | null = null;
for (const idx of substringIndices) {
const item = index[idx];
const entry = getNutritionByFdcId().get(item.fdcId);
if (entry && EXCLUDED_USDA_CATEGORIES.has(entry.category)) continue;
const embScore = cosineSimilarity(queryVector, item.vector);
const score = usdaSubstringMatchScore(embScore, item.name, ingredientNameEn);
if (score > bestScore) {
bestScore = score;
bestItem = item;
}
}
if (bestItem) {
const nutrition = getNutritionByFdcId().get(bestItem.fdcId);
if (nutrition) {
const nameNorm = bestItem.name.toLowerCase();
const forms = searchForms(ingredientNameEn);
const isDirectMatch = forms.some(f =>
nameNorm.startsWith(f + ',') || nameNorm.startsWith(f + ' ') ||
nameNorm === f || (nameNorm.indexOf(f) >= 0 && nameNorm.indexOf(f) < 15)
);
if (isDirectMatch) {
return { entry: nutrition, confidence: Math.min(Math.max(bestScore, 0.7), 1.0) };
}
}
}
}
// Full embedding search fallback (excluding restaurant foods)
let bestScore = -1;
let bestEntry: typeof index[0] | null = null;
for (const item of index) {
const entry = getNutritionByFdcId().get(item.fdcId);
if (entry && EXCLUDED_USDA_CATEGORIES.has(entry.category)) continue;
const score = cosineSimilarity(queryVector, item.vector);
if (score > bestScore) {
bestScore = score;
bestEntry = item;
}
}
if (!bestEntry || bestScore < CONFIDENCE_THRESHOLD) return null;
const nutrition = getNutritionByFdcId().get(bestEntry.fdcId);
if (!nutrition) return null;
return { entry: nutrition, confidence: bestScore };
}
/** Parse a recipe amount string to a number */
export function parseAmount(amount: string): number {
if (!amount || !amount.trim()) return 0;
let s = amount.trim();
const rangeMatch = s.match(/^(\d+(?:[.,]\d+)?)\s*[-]\s*(\d+(?:[.,]\d+)?)$/);
if (rangeMatch) {
return (parseFloat(rangeMatch[1].replace(',', '.')) + parseFloat(rangeMatch[2].replace(',', '.'))) / 2;
}
s = s.replace(',', '.');
const fractionMatch = s.match(/^(\d+)\s*\/\s*(\d+)$/);
if (fractionMatch) return parseInt(fractionMatch[1]) / parseInt(fractionMatch[2]);
const mixedMatch = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
if (mixedMatch) return parseInt(mixedMatch[1]) + parseInt(mixedMatch[2]) / parseInt(mixedMatch[3]);
const parsed = parseFloat(s);
return isNaN(parsed) ? 0 : parsed;
}
// ── Global overwrite cache ──
let overwriteCache: Map<string, any> | null = null;
let overwriteCacheTime = 0;
const OVERWRITE_CACHE_TTL = 60_000; // 1 minute
async function lookupGlobalOverwrite(normalizedNameDe: string): Promise<any | null> {
const now = Date.now();
if (!overwriteCache || now - overwriteCacheTime > OVERWRITE_CACHE_TTL) {
try {
const all = await NutritionOverwrite.find({}).lean();
overwriteCache = new Map(all.map((o: any) => [o.ingredientNameDe, o]));
overwriteCacheTime = now;
} catch {
overwriteCache = new Map();
overwriteCacheTime = now;
}
}
return overwriteCache.get(normalizedNameDe) || null;
}
/** Invalidate the overwrite cache (call after creating/updating/deleting overwrites) */
export function invalidateOverwriteCache() {
overwriteCache = null;
}
/**
* Match a single ingredient against BLS (German, primary) then USDA (English, fallback).
*/
export async function matchIngredient(
ingredientNameDe: string,
ingredientNameEn: string | undefined,
unit: string,
amount: string,
sectionIndex: number,
ingredientIndex: number,
): Promise<NutritionMapping> {
const normalizedEn = ingredientNameEn ? normalizeIngredientName(ingredientNameEn) : '';
const normalizedDe = normalizeIngredientNameDe(ingredientNameDe);
let source: 'bls' | 'usda' = 'usda';
let fdcId: number | undefined;
let blsCode: string | undefined;
let nutritionDbName: string | undefined;
let matchMethod: NutritionMapping['matchMethod'] = 'none';
let confidence = 0;
let portions: { description: string; grams: number }[] = [];
let category = '';
// 0. Check global overwrites (DB-stored manual mappings)
const overwrite = await lookupGlobalOverwrite(normalizedDe);
if (overwrite) {
if (overwrite.excluded || overwrite.source === 'skip') {
return {
sectionIndex, ingredientIndex,
ingredientName: ingredientNameEn || ingredientNameDe,
ingredientNameDe,
source: 'usda', matchMethod: 'manual', matchConfidence: 1,
gramsPerUnit: 0, defaultAmountUsed: false,
unitConversionSource: 'none', manuallyEdited: false, excluded: true,
};
}
if (overwrite.source === 'bls' && overwrite.blsCode) {
const entry = getBlsByCode().get(overwrite.blsCode);
if (entry) {
source = 'bls'; blsCode = overwrite.blsCode;
nutritionDbName = entry.nameDe; matchMethod = 'exact';
confidence = 1.0; category = entry.category;
}
} else if (overwrite.source === 'usda' && overwrite.fdcId) {
const entry = getNutritionByFdcId().get(overwrite.fdcId);
if (entry) {
source = 'usda'; fdcId = overwrite.fdcId;
nutritionDbName = entry.name; matchMethod = 'exact';
confidence = 1.0; portions = entry.portions; category = entry.category;
}
}
}
// 1. Try alias table (English, fast path → USDA)
if (matchMethod === 'none' && normalizedEn) {
const aliasResult = lookupAlias(normalizedEn);
if (aliasResult) {
const entry = getNutritionByName().get(aliasResult);
if (entry) {
source = 'usda';
fdcId = entry.fdcId;
nutritionDbName = entry.name;
matchMethod = 'exact';
confidence = 1.0;
portions = entry.portions;
category = entry.category;
}
}
}
// 2. Try BLS embedding match (German name, primary)
if (matchMethod === 'none' && normalizedDe) {
const blsResult = await blsEmbeddingMatch(normalizedDe);
if (blsResult) {
source = 'bls';
blsCode = blsResult.entry.blsCode;
nutritionDbName = blsResult.entry.nameDe;
matchMethod = 'embedding';
confidence = blsResult.confidence;
category = blsResult.entry.category;
// BLS has no portion data — will use unit conversion tables
}
}
// 3. Try USDA embedding match (English name, fallback)
if (matchMethod === 'none' && normalizedEn) {
const usdaResult = await usdaEmbeddingMatch(normalizedEn);
if (usdaResult) {
source = 'usda';
fdcId = usdaResult.entry.fdcId;
nutritionDbName = usdaResult.entry.name;
matchMethod = 'embedding';
confidence = usdaResult.confidence;
portions = usdaResult.entry.portions;
category = usdaResult.entry.category;
}
}
// Resolve unit conversion
const canonicalUnit = canonicalizeUnit(unit);
let parsedAmount = parseAmount(amount);
let defaultAmountUsed = false;
// If no amount given, try default amounts
if (!parsedAmount && matchMethod !== 'none') {
const nameForDefault = normalizedEn || normalizedDe;
const defaultAmt = resolveDefaultAmount(nameForDefault, category);
if (defaultAmt) {
parsedAmount = defaultAmt.amount;
const defaultCanonical = canonicalizeUnit(defaultAmt.unit);
const unitResolution = resolveGramsPerUnit(defaultCanonical, portions);
defaultAmountUsed = true;
return {
sectionIndex, ingredientIndex,
ingredientName: ingredientNameEn || ingredientNameDe,
ingredientNameDe,
source, fdcId, blsCode, nutritionDbName,
matchConfidence: confidence, matchMethod,
gramsPerUnit: unitResolution.grams,
defaultAmountUsed,
unitConversionSource: unitResolution.source,
manuallyEdited: false,
excluded: defaultAmt.amount === 0,
};
}
}
const unitResolution = resolveGramsPerUnit(canonicalUnit, portions);
return {
sectionIndex, ingredientIndex,
ingredientName: ingredientNameEn || ingredientNameDe,
ingredientNameDe,
source, fdcId, blsCode, nutritionDbName,
matchConfidence: confidence, matchMethod,
gramsPerUnit: unitResolution.grams,
defaultAmountUsed,
unitConversionSource: unitResolution.source,
manuallyEdited: false,
excluded: false,
};
}
/**
* Generate nutrition mappings for all ingredients in a recipe.
* Uses German names for BLS matching and English names for USDA fallback.
*/
export async function generateNutritionMappings(
ingredients: any[],
translatedIngredients?: any[],
): Promise<NutritionMapping[]> {
const mappings: NutritionMapping[] = [];
for (let sectionIdx = 0; sectionIdx < ingredients.length; sectionIdx++) {
const sectionDe = ingredients[sectionIdx];
const sectionEn = translatedIngredients?.[sectionIdx];
if (sectionDe.type === 'reference' || !sectionDe.list) continue;
for (let itemIdx = 0; itemIdx < sectionDe.list.length; itemIdx++) {
const itemDe = sectionDe.list[itemIdx];
const itemEn = sectionEn?.list?.[itemIdx];
const mapping = await matchIngredient(
itemDe.name,
itemEn?.name || undefined,
itemDe.unit || '',
itemDe.amount || '',
sectionIdx,
itemIdx,
);
mappings.push(mapping);
}
}
return mappings;
}
/** Look up a USDA NutritionEntry by fdcId */
export function getNutritionEntryByFdcId(fdcId: number): NutritionEntry | undefined {
return getNutritionByFdcId().get(fdcId);
}
/** Look up a BLS entry by blsCode */
export function getBlsEntryByCode(code: string): BlsEntry | undefined {
return getBlsByCode().get(code);
}
/** Resolve per100g data for a mapping from BLS or USDA */
export function resolvePer100g(mapping: any): NutritionPer100g | null {
if (mapping.blsCode && mapping.source === 'bls') {
const entry = getBlsByCode().get(mapping.blsCode);
return entry?.per100g ?? null;
}
if (mapping.fdcId) {
const entry = getNutritionByFdcId().get(mapping.fdcId);
return entry?.per100g ?? null;
}
return null;
}
/**
* Compute absolute nutrition totals for a recipe's ingredients using its nutritionMappings.
* Returns total nutrients (not per-100g), optionally scaled by a multiplier.
*/
export function computeRecipeNutritionTotals(
ingredients: any[],
nutritionMappings: any[],
multiplier = 1,
): Record<string, number> {
const index = new Map(
(nutritionMappings || []).map((m: any) => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
);
const totals: Record<string, number> = {};
// Collect section names for dedup (skip ingredients referencing earlier sections)
const sectionNames = new Set<string>();
for (let si = 0; si < ingredients.length; si++) {
const section = ingredients[si];
if (section.type === 'reference' || !section.list) {
if (section.name) sectionNames.add(stripHtml(section.name).toLowerCase().trim());
continue;
}
if (section.name) sectionNames.add(stripHtml(section.name).toLowerCase().trim());
}
for (let si = 0; si < ingredients.length; si++) {
const section = ingredients[si];
if (section.type === 'reference' || !section.list) continue;
const currentSectionName = section.name ? stripHtml(section.name).toLowerCase().trim() : '';
for (let ii = 0; ii < section.list.length; ii++) {
const item = section.list[ii];
const rawName = item.name || '';
const itemName = stripHtml(rawName).toLowerCase().trim();
// Skip anchor-tag references to other recipes (handled separately)
if (/<a\s/i.test(rawName)) continue;
// Skip if this ingredient name matches a DIFFERENT section's name
if (itemName && sectionNames.has(itemName) && itemName !== currentSectionName) continue;
const mapping = index.get(`${si}-${ii}`);
if (!mapping || mapping.matchMethod === 'none' || mapping.excluded) continue;
const per100g = resolvePer100g(mapping);
if (!per100g) continue;
const amount = parseAmount(item.amount || '') || (mapping.defaultAmountUsed ? 1 : 0);
const grams = amount * multiplier * (mapping.gramsPerUnit || 0);
const factor = grams / 100;
for (const [key, value] of Object.entries(per100g)) {
if (typeof value === 'number') {
totals[key] = (totals[key] || 0) + factor * value;
}
}
}
}
return totals;
}
/** Strip HTML tags from a string */
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '');
}

View File

@@ -0,0 +1,25 @@
import mongoose from 'mongoose';
/**
* Global nutrition overwrites — manually map ingredient names to BLS/USDA entries.
* Checked during nutrition generation before embedding search.
* Can also mark ingredients as excluded (skipped).
*/
const NutritionOverwriteSchema = new mongoose.Schema({
// The normalized ingredient name this overwrite matches (German, lowercase)
ingredientNameDe: { type: String, required: true },
// Optional English name for display
ingredientNameEn: { type: String },
// What to map to
source: { type: String, enum: ['bls', 'usda', 'skip'], required: true },
fdcId: { type: Number },
blsCode: { type: String },
nutritionDbName: { type: String },
// Whether this ingredient should be excluded from nutrition calculation
excluded: { type: Boolean, default: false },
}, { timestamps: true });
NutritionOverwriteSchema.index({ ingredientNameDe: 1 }, { unique: true });
delete mongoose.models.NutritionOverwrite;
export const NutritionOverwrite = mongoose.model('NutritionOverwrite', NutritionOverwriteSchema);

View File

@@ -163,6 +163,25 @@ const RecipeSchema = new mongoose.Schema(
}
},
// Nutrition calorie/macro mapping for each ingredient
nutritionMappings: [{
sectionIndex: { type: Number, required: true },
ingredientIndex: { type: Number, required: true },
ingredientName: { type: String },
ingredientNameDe: { type: String },
source: { type: String, enum: ['bls', 'usda', 'manual'] },
fdcId: { type: Number },
blsCode: { type: String },
nutritionDbName: { type: String },
matchConfidence: { type: Number },
matchMethod: { type: String, enum: ['exact', 'embedding', 'manual', 'none'] },
gramsPerUnit: { type: Number },
defaultAmountUsed: { type: Boolean, default: false },
unitConversionSource: { type: String, enum: ['direct', 'density', 'usda_portion', 'estimate', 'manual', 'none'] },
manuallyEdited: { type: Boolean, default: false },
excluded: { type: Boolean, default: false },
}],
// Translation metadata for tracking changes
translationMetadata: {
lastModifiedGerman: {type: Date},
@@ -177,6 +196,6 @@ RecipeSchema.index({ "translations.en.translationStatus": 1 });
import type { RecipeModelType } from '$types/types';
let _recipeModel: mongoose.Model<RecipeModelType>;
try { _recipeModel = mongoose.model<RecipeModelType>("Recipe"); } catch { _recipeModel = mongoose.model<RecipeModelType>("Recipe", RecipeSchema); }
export const Recipe = _recipeModel;
// Delete cached model on HMR so schema changes (e.g. new fields) are picked up
delete mongoose.models.Recipe;
export const Recipe = mongoose.model<RecipeModelType>("Recipe", RecipeSchema);

View File

@@ -0,0 +1,308 @@
<script>
let { data } = $props();
const isEnglish = data.lang === 'en';
const recipeLang = data.recipeLang;
let processing = $state(false);
let singleProcessing = $state('');
/** @type {any} */
let batchResult = $state(null);
/** @type {any} */
let singleResult = $state(null);
let errorMsg = $state('');
let recipeName = $state('');
async function generateAll() {
processing = true;
errorMsg = '';
batchResult = null;
try {
const response = await fetch(`/api/${recipeLang}/nutrition/generate-all`, {
method: 'POST',
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Failed to generate nutrition mappings');
}
batchResult = result;
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'An error occurred';
} finally {
processing = false;
}
}
async function generateSingle() {
if (!recipeName.trim()) return;
singleProcessing = recipeName.trim();
singleResult = null;
errorMsg = '';
try {
const response = await fetch(`/api/${recipeLang}/nutrition/generate/${encodeURIComponent(recipeName.trim())}`, {
method: 'POST',
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Failed to generate nutrition mappings');
}
singleResult = result;
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'An error occurred';
} finally {
singleProcessing = '';
}
}
</script>
<style>
.container {
max-width: 1000px;
margin: 2rem auto;
padding: 2rem;
}
h1 {
color: var(--nord0);
margin-bottom: 0.5rem;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) h1 { color: white; }
}
:global(:root[data-theme="dark"]) h1 { color: white; }
.subtitle {
color: var(--nord3);
margin-bottom: 2rem;
}
.section {
background: var(--nord6, #eceff4);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .section { background: var(--nord1); }
}
:global(:root[data-theme="dark"]) .section { background: var(--nord1); }
.section h2 {
margin-top: 0;
font-size: 1.3rem;
}
.input-row {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.input-row input {
flex: 1;
min-width: 200px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #ccc);
border-radius: 6px;
font-size: 1rem;
background: transparent;
color: inherit;
}
button {
padding: 0.5rem 1.25rem;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
background: var(--nord10, #5e81ac);
color: white;
transition: opacity 150ms;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:hover:not(:disabled) {
opacity: 0.85;
}
.btn-danger {
background: var(--nord11, #bf616a);
}
.result-box {
margin-top: 1rem;
padding: 1rem;
border-radius: 6px;
background: var(--nord0, #2e3440);
color: var(--nord6, #eceff4);
font-family: monospace;
font-size: 0.85rem;
max-height: 400px;
overflow-y: auto;
}
.result-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.result-table th,
.result-table td {
text-align: left;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--nord3, #4c566a);
}
.result-table th {
color: var(--nord9, #81a1c1);
}
.coverage-bar {
display: inline-block;
height: 6px;
border-radius: 3px;
background: var(--nord14, #a3be8c);
vertical-align: middle;
}
.coverage-bar-bg {
display: inline-block;
width: 60px;
height: 6px;
border-radius: 3px;
background: var(--nord3, #4c566a);
vertical-align: middle;
margin-right: 0.4rem;
}
.error {
color: var(--nord11, #bf616a);
margin-top: 0.75rem;
font-weight: bold;
}
.summary {
display: flex;
gap: 2rem;
flex-wrap: wrap;
margin-top: 0.75rem;
font-size: 1.1rem;
}
.summary-stat {
text-align: center;
}
.summary-stat .value {
font-size: 1.8rem;
font-weight: bold;
color: var(--nord10, #5e81ac);
}
.summary-stat .label {
font-size: 0.85rem;
color: var(--nord3);
}
</style>
<svelte:head>
<title>{isEnglish ? 'Nutrition Mappings' : 'Nährwert-Zuordnungen'}</title>
</svelte:head>
<div class="container">
<h1>{isEnglish ? 'Nutrition Mappings' : 'Nährwert-Zuordnungen'}</h1>
<p class="subtitle">
{isEnglish
? 'Generate ingredient-to-calorie mappings using ML embeddings. Manually edited mappings are preserved.'
: 'Zutatenzuordnungen zu Kaloriendaten mittels ML-Embeddings generieren. Manuell bearbeitete Zuordnungen bleiben erhalten.'}
</p>
<!-- Single recipe -->
<div class="section">
<h2>{isEnglish ? 'Single Recipe' : 'Einzelnes Rezept'}</h2>
<div class="input-row">
<input
type="text"
placeholder={isEnglish ? 'Recipe short_name (e.g., maccaroni)' : 'Rezept short_name (z.B. maccaroni)'}
bind:value={recipeName}
onkeydown={(e) => e.key === 'Enter' && generateSingle()}
/>
<button disabled={!!singleProcessing || !recipeName.trim()} onclick={generateSingle}>
{singleProcessing ? (isEnglish ? 'Processing...' : 'Verarbeite...') : (isEnglish ? 'Generate' : 'Generieren')}
</button>
</div>
{#if singleResult}
<div class="result-box">
<p>{singleResult.count} {isEnglish ? 'ingredients mapped' : 'Zutaten zugeordnet'}</p>
<table class="result-table">
<thead><tr><th>#</th><th>{isEnglish ? 'Ingredient' : 'Zutat'}</th><th>{isEnglish ? 'Match' : 'Treffer'}</th><th>{isEnglish ? 'Confidence' : 'Konfidenz'}</th><th>g/unit</th></tr></thead>
<tbody>
{#each singleResult.mappings as m, i}
<tr>
<td>{i + 1}</td>
<td>{m.ingredientName}</td>
<td>{m.nutritionDbName || '—'}</td>
<td>{m.matchConfidence ? (m.matchConfidence * 100).toFixed(0) + '%' : '—'}</td>
<td>{m.gramsPerUnit || '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Batch all recipes -->
<div class="section">
<h2>{isEnglish ? 'Batch: All Recipes' : 'Batch: Alle Rezepte'}</h2>
<p>{isEnglish
? 'Regenerate nutrition mappings for all recipes. This may take a few minutes on first run (ML model loading).'
: 'Nährwertzuordnungen für alle Rezepte neu generieren. Beim ersten Durchlauf kann dies einige Minuten dauern (ML-Modell wird geladen).'}
</p>
<button class="btn-danger" disabled={processing} onclick={generateAll}>
{processing ? (isEnglish ? 'Processing all recipes...' : 'Verarbeite alle Rezepte...') : (isEnglish ? 'Generate All' : 'Alle generieren')}
</button>
{#if batchResult}
<div class="summary">
<div class="summary-stat">
<div class="value">{batchResult.recipes}</div>
<div class="label">{isEnglish ? 'Recipes' : 'Rezepte'}</div>
</div>
<div class="summary-stat">
<div class="value">{batchResult.totalMapped}/{batchResult.totalIngredients}</div>
<div class="label">{isEnglish ? 'Ingredients Mapped' : 'Zutaten zugeordnet'}</div>
</div>
<div class="summary-stat">
<div class="value">{batchResult.coverage}</div>
<div class="label">{isEnglish ? 'Coverage' : 'Abdeckung'}</div>
</div>
</div>
<div class="result-box">
<table class="result-table">
<thead><tr><th>{isEnglish ? 'Recipe' : 'Rezept'}</th><th>{isEnglish ? 'Mapped' : 'Zugeordnet'}</th><th>{isEnglish ? 'Coverage' : 'Abdeckung'}</th></tr></thead>
<tbody>
{#each batchResult.details as detail}
<tr>
<td>{detail.name}</td>
<td>{detail.mapped}/{detail.total}</td>
<td>
<span class="coverage-bar-bg">
<span class="coverage-bar" style="width: {detail.total ? (detail.mapped / detail.total * 60) : 0}px"></span>
</span>
{detail.total ? Math.round(detail.mapped / detail.total * 100) : 0}%
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#if errorMsg}
<p class="error">{errorMsg}</p>
{/if}
</div>

View File

@@ -33,6 +33,14 @@
: 'Dominante Farben aus Rezeptbildern für Ladeplatzhalter extrahieren',
href: `/${data.recipeLang}/admin/image-colors`,
icon: '🎨'
},
{
title: isEnglish ? 'Nutrition Mappings' : 'Nährwert-Zuordnungen',
description: isEnglish
? 'Generate or regenerate calorie and nutrition data for all recipes'
: 'Kalorien- und Nährwertdaten für alle Rezepte generieren oder aktualisieren',
href: `/${data.recipeLang}/admin/nutrition`,
icon: '🥗'
}
];
</script>

View File

@@ -237,6 +237,167 @@
showTranslationWorkflow = false;
}
// Nutrition generation
let generatingNutrition = $state(false);
let nutritionResult = $state<{ count: number; mappings: any[] } | null>(null);
async function generateNutrition() {
generatingNutrition = true;
nutritionResult = null;
try {
const res = await fetch(`/api/rezepte/nutrition/generate/${encodeURIComponent(short_name.trim())}`, { method: 'POST' });
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || `HTTP ${res.status}`);
}
const result = await res.json();
nutritionResult = result;
const mapped = result.mappings.filter((/** @type {any} */ m) => m.matchMethod !== 'none').length;
toast.success(`Nährwerte generiert: ${mapped}/${result.count} Zutaten zugeordnet`);
} catch (e: any) {
toast.error(`Fehler: ${e.message}`);
} finally {
generatingNutrition = false;
}
}
// Manual nutrition search
let searchQueries = $state<Record<string, string>>({});
let searchResults = $state<Record<string, { source: 'bls' | 'usda'; id: string; name: string; category: string; calories: number }[]>>({});
let searchTimers = $state<Record<string, ReturnType<typeof setTimeout>>>({});
let savingMapping = $state<string | null>(null);
let globalToggle = $state<Record<string, boolean>>({});
function mappingKey(m: any) {
return `${m.sectionIndex}-${m.ingredientIndex}`;
}
function handleSearchInput(key: string, value: string) {
searchQueries[key] = value;
if (searchTimers[key]) clearTimeout(searchTimers[key]);
if (value.length < 2) {
searchResults[key] = [];
return;
}
searchTimers[key] = setTimeout(async () => {
try {
const res = await fetch(`/api/nutrition/search?q=${encodeURIComponent(value)}`);
if (res.ok) searchResults[key] = await res.json();
} catch { /* ignore */ }
}, 250);
}
async function assignNutritionEntry(mapping: any, entry: { source: 'bls' | 'usda'; id: string; name: string }) {
const key = mappingKey(mapping);
const isGlobal = globalToggle[key] || false;
savingMapping = key;
try {
const patchBody: Record<string, any> = {
sectionIndex: mapping.sectionIndex,
ingredientIndex: mapping.ingredientIndex,
ingredientName: mapping.ingredientName,
ingredientNameDe: mapping.ingredientNameDe,
source: entry.source,
nutritionDbName: entry.name,
matchMethod: 'manual',
matchConfidence: 1,
excluded: false,
global: isGlobal,
};
if (entry.source === 'bls') {
patchBody.blsCode = entry.id;
} else {
patchBody.fdcId = parseInt(entry.id);
}
const res = await fetch(`/api/rezepte/nutrition/${encodeURIComponent(short_name.trim())}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([patchBody]),
});
if (!res.ok) throw new Error('Failed to save');
if (entry.source === 'bls') {
mapping.blsCode = entry.id;
mapping.source = 'bls';
} else {
mapping.fdcId = parseInt(entry.id);
mapping.source = 'usda';
}
mapping.nutritionDbName = entry.name;
mapping.matchMethod = 'manual';
mapping.matchConfidence = 1;
mapping.excluded = false;
mapping.manuallyEdited = true;
searchResults[key] = [];
searchQueries[key] = '';
toast.success(`${mapping.ingredientName}${entry.name}${isGlobal ? ' (global)' : ''}`);
} catch (e: any) {
toast.error(`Fehler: ${e.message}`);
} finally {
savingMapping = null;
}
}
async function skipIngredient(mapping: any) {
const key = mappingKey(mapping);
const isGlobal = globalToggle[key] || false;
savingMapping = key;
try {
const res = await fetch(`/api/rezepte/nutrition/${encodeURIComponent(short_name.trim())}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([{
sectionIndex: mapping.sectionIndex,
ingredientIndex: mapping.ingredientIndex,
ingredientName: mapping.ingredientName,
ingredientNameDe: mapping.ingredientNameDe,
matchMethod: 'manual',
matchConfidence: 1,
excluded: true,
global: isGlobal,
}]),
});
if (!res.ok) throw new Error('Failed to save');
mapping.excluded = true;
mapping.matchMethod = 'manual';
mapping.manuallyEdited = true;
searchResults[key] = [];
searchQueries[key] = '';
toast.success(`${mapping.ingredientName} übersprungen${isGlobal ? ' (global)' : ''}`);
} catch (e: any) {
toast.error(`Fehler: ${e.message}`);
} finally {
savingMapping = null;
}
}
async function revertToAuto(mapping: any) {
const key = mappingKey(mapping);
savingMapping = key;
try {
const res = await fetch(`/api/rezepte/nutrition/${encodeURIComponent(short_name.trim())}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([{
sectionIndex: mapping.sectionIndex,
ingredientIndex: mapping.ingredientIndex,
ingredientName: mapping.ingredientName,
ingredientNameDe: mapping.ingredientNameDe,
manuallyEdited: false,
excluded: false,
}]),
});
if (!res.ok) throw new Error('Failed to save');
// Re-generate to get the auto match
await generateNutrition();
toast.success(`${mapping.ingredientName} → automatisch`);
} catch (e: any) {
toast.error(`Fehler: ${e.message}`);
} finally {
savingMapping = null;
}
}
// Display form errors if any
$effect(() => {
if (form?.error) {
@@ -381,6 +542,225 @@
max-width: 800px;
text-align: center;
}
.nutrition-generate {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin: 1.5rem auto;
max-width: 1000px;
}
.nutrition-result-box {
width: 100%;
margin-top: 0.5rem;
padding: 1rem;
border-radius: 6px;
background: var(--nord0, #2e3440);
color: var(--nord6, #eceff4);
font-family: monospace;
font-size: 0.85rem;
max-height: 400px;
overflow-y: auto;
}
.nutrition-result-summary {
margin: 0 0 0.5rem;
font-weight: bold;
}
.nutrition-result-table {
width: 100%;
border-collapse: collapse;
}
.nutrition-result-table th,
.nutrition-result-table td {
text-align: left;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--nord3, #4c566a);
}
.nutrition-result-table th {
color: var(--nord9, #81a1c1);
}
.unmapped-row {
opacity: 0.7;
}
.usda-search-cell {
position: relative;
}
.usda-search-input {
display: inline !important;
width: 100%;
padding: 0.2rem 0.4rem !important;
margin: 0 !important;
border: 1px solid var(--nord3) !important;
border-radius: 3px !important;
background: var(--nord1, #3b4252) !important;
color: inherit !important;
font-size: 0.85rem !important;
scale: 1 !important;
}
.usda-search-input:hover,
.usda-search-input:focus-visible {
scale: 1 !important;
}
.usda-search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
list-style: none;
margin: 0;
padding: 0;
background: var(--nord1, #3b4252);
border: 1px solid var(--nord3);
border-radius: 3px;
max-height: 200px;
overflow-y: auto;
min-width: 300px;
}
.usda-search-dropdown li button {
display: block;
width: 100%;
text-align: left;
padding: 0.35rem 0.5rem;
border: none;
background: none;
color: inherit;
font-size: 0.8rem;
cursor: pointer;
font-family: monospace;
}
.usda-search-dropdown li button:hover {
background: var(--nord2, #434c5e);
}
.usda-cal {
color: var(--nord9, #81a1c1);
margin-left: 0.5rem;
font-size: 0.75rem;
}
.source-badge {
display: inline-block;
font-size: 0.6rem;
font-weight: 700;
padding: 0.1rem 0.3rem;
border-radius: 2px;
margin-right: 0.3rem;
background: var(--nord10, #5e81ac);
color: var(--nord6, #eceff4);
vertical-align: middle;
}
.source-badge.bls {
background: var(--nord14, #a3be8c);
color: var(--nord0, #2e3440);
}
.source-badge.skip {
background: var(--nord11, #bf616a);
}
.manual-indicator {
display: inline-block;
font-size: 0.55rem;
font-weight: 700;
color: var(--nord13, #ebcb8b);
margin-left: 0.2rem;
vertical-align: super;
}
.excluded-row {
opacity: 0.45;
}
.excluded-row td {
text-decoration: line-through;
}
.excluded-row td:last-child,
.excluded-row td:nth-last-child(2),
.excluded-row td:nth-last-child(3) {
text-decoration: none;
}
.manual-row {
border-left: 2px solid var(--nord13, #ebcb8b);
}
.excluded-label {
font-style: italic;
color: var(--nord11, #bf616a);
font-size: 0.8rem;
}
.de-name {
color: var(--nord9, #81a1c1);
font-size: 0.75rem;
}
.current-match {
display: block;
font-size: 0.8rem;
margin-bottom: 0.2rem;
}
.current-match.manual-match {
color: var(--nord13, #ebcb8b);
}
.usda-search-input.has-match {
opacity: 0.5;
font-size: 0.75rem !important;
}
.usda-search-input.has-match:focus {
opacity: 1;
font-size: 0.85rem !important;
}
.row-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.global-toggle {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
color: var(--nord9, #81a1c1);
cursor: pointer;
}
.global-toggle input {
display: inline !important;
width: auto !important;
margin: 0 !important;
padding: 0 !important;
scale: 1 !important;
}
.revert-btn {
background: none;
border: 1px solid var(--nord3, #4c566a);
border-radius: 3px;
color: var(--nord9, #81a1c1);
cursor: pointer;
padding: 0.1rem 0.35rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.revert-btn:hover {
background: var(--nord9, #81a1c1);
color: var(--nord0, #2e3440);
}
.skip-btn {
background: none;
border: 1px solid var(--nord3, #4c566a);
border-radius: 3px;
color: var(--nord11, #bf616a);
cursor: pointer;
padding: 0.15rem 0.4rem;
font-size: 0.8rem;
line-height: 1;
}
.skip-btn:hover {
background: var(--nord11, #bf616a);
color: var(--nord6, #eceff4);
}
.skip-btn.active {
background: var(--nord11, #bf616a);
color: var(--nord6, #eceff4);
}
.skip-btn.active:hover {
background: var(--nord14, #a3be8c);
color: var(--nord0, #2e3440);
}
</style>
<svelte:head>
@@ -535,6 +915,110 @@
<input type="hidden" name="addendum" value={addendum} />
</div>
<!-- Nutrition generation -->
<div class="nutrition-generate">
<button
type="button"
class="action_button"
onclick={generateNutrition}
disabled={generatingNutrition || !short_name.trim()}
style="background-color: var(--nord10);"
>
<p>{generatingNutrition ? 'Generiere…' : 'Nährwerte generieren'}</p>
</button>
{#if nutritionResult}
<div class="nutrition-result-box">
<p class="nutrition-result-summary">
{nutritionResult.mappings.filter((m) => m.matchMethod !== 'none').length}/{nutritionResult.count} Zutaten zugeordnet
</p>
<table class="nutrition-result-table">
<thead><tr><th>#</th><th>Zutat</th><th>Quelle</th><th>Treffer / Suche</th><th>Konf.</th><th>g/u</th><th></th></tr></thead>
<tbody>
{#each nutritionResult.mappings as m, i}
{@const key = mappingKey(m)}
<tr class:unmapped-row={m.matchMethod === 'none' && !m.excluded} class:excluded-row={m.excluded} class:manual-row={m.manuallyEdited && !m.excluded}>
<td>{i + 1}</td>
<td>
{m.ingredientName}
{#if m.ingredientNameDe && m.ingredientNameDe !== m.ingredientName}
<span class="de-name">({m.ingredientNameDe})</span>
{/if}
</td>
<td>
{#if m.excluded}
<span class="source-badge skip">SKIP</span>
{:else if m.matchMethod !== 'none'}
<span class="source-badge" class:bls={m.source === 'bls'}>{(m.source || 'usda').toUpperCase()}</span>
{#if m.manuallyEdited}<span class="manual-indicator" title="Manuell zugeordnet">M</span>{/if}
{:else}
{/if}
</td>
<td>
<div class="usda-search-cell">
{#if m.excluded}
<span class="excluded-label">Übersprungen</span>
{:else if m.matchMethod !== 'none' && !searchQueries[key]}
<span class="current-match" class:manual-match={m.manuallyEdited}>{m.nutritionDbName || '—'}</span>
{/if}
<input
type="text"
class="usda-search-input"
class:has-match={m.matchMethod !== 'none' && !m.excluded && !searchQueries[key]}
placeholder={m.excluded ? 'Suche für neuen Treffer…' : (m.matchMethod !== 'none' ? 'Überschreiben…' : 'BLS/USDA suchen…')}
value={searchQueries[key] || ''}
oninput={(e) => handleSearchInput(key, e.currentTarget.value)}
/>
{#if searchResults[key]?.length > 0}
<ul class="usda-search-dropdown">
{#each searchResults[key] as result}
<li>
<button
type="button"
disabled={savingMapping === key}
onclick={() => assignNutritionEntry(m, result)}
>
<span class="source-badge" class:bls={result.source === 'bls'}>{result.source.toUpperCase()}</span>
{result.name}
<span class="usda-cal">{Math.round(result.calories)} kcal</span>
</button>
</li>
{/each}
</ul>
{/if}
<div class="row-controls">
<label class="global-toggle">
<input type="checkbox" checked={globalToggle[key] || false} onchange={() => { globalToggle[key] = !globalToggle[key]; }} />
global
</label>
{#if m.manuallyEdited || m.excluded}
<button type="button" class="revert-btn" disabled={savingMapping === key} onclick={() => revertToAuto(m)} title="Zurück auf automatisch">auto</button>
{/if}
</div>
</div>
</td>
<td>{m.matchConfidence ? (m.matchConfidence * 100).toFixed(0) + '%' : '—'}</td>
<td>{m.gramsPerUnit || '—'}</td>
<td>
<button
type="button"
class="skip-btn"
class:active={m.excluded}
disabled={savingMapping === key}
onclick={() => m.excluded ? revertToAuto(m) : skipIngredient(m)}
title={m.excluded ? 'Wieder aktivieren' : 'Überspringen'}
>
{m.excluded ? '↩' : '✕'}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#if !showTranslationWorkflow}
<div class="submit_buttons">
<button

View File

@@ -4,6 +4,7 @@ import { dbConnect } from '$utils/db';
import { error } from '@sveltejs/kit';
import type { RecipeModelType, IngredientItem, InstructionItem } from '$types/types';
import { isEnglish } from '$lib/server/recipeHelpers';
import { getNutritionEntryByFdcId, getBlsEntryByCode, computeRecipeNutritionTotals } from '$lib/server/nutritionMatcher';
/** Recursively map populated baseRecipeRef to resolvedRecipe field */
function mapBaseRecipeRefs(items: any[]): any[] {
@@ -22,6 +23,91 @@ function mapBaseRecipeRefs(items: any[]): any[] {
});
}
/** Resolve per100g nutrition data into mappings so client doesn't need the full DB */
function resolveNutritionData(mappings: any[]): any[] {
if (!mappings || mappings.length === 0) return [];
return mappings.map((m: any) => {
if (m.matchMethod === 'none') return m;
// BLS source: look up by blsCode
if (m.blsCode && m.source === 'bls') {
const entry = getBlsEntryByCode(m.blsCode);
if (entry) return { ...m, per100g: entry.per100g };
}
// USDA source: look up by fdcId
if (m.fdcId) {
const entry = getNutritionEntryByFdcId(m.fdcId);
if (entry) return { ...m, per100g: entry.per100g };
}
return m;
});
}
/** Parse anchor href from ingredient name, return short_name or null */
function parseAnchorRecipeRef(ingredientName: string): string | null {
const match = ingredientName.match(/<a\s+href=["']?([^"' >]+)["']?[^>]*>/i);
if (!match) return null;
let href = match[1].trim();
// Strip query params (e.g., ?multiplier={{multiplier}})
href = href.split('?')[0];
// Skip external links
if (href.startsWith('http') || href.includes('://')) return null;
// Strip leading path components like /rezepte/ or ./
href = href.replace(/^(\.?\/?rezepte\/|\.\/|\/)/, '');
// Skip if contains a dot (file extensions, external domains)
if (href.includes('.')) return null;
return href || null;
}
/**
* Build nutrition totals for referenced recipes:
* 1. Base recipe references (type='reference' with populated baseRecipeRef)
* 2. Anchor-tag references in ingredient names (<a href=...>)
*/
async function resolveReferencedNutrition(
ingredients: any[],
): Promise<{ shortName: string; name: string; nutrition: Record<string, number>; baseMultiplier: number }[]> {
const results: { shortName: string; name: string; nutrition: Record<string, number>; baseMultiplier: number }[] = [];
const processedSlugs = new Set<string>();
for (const section of ingredients) {
// Type 1: Base recipe references
if (section.type === 'reference' && section.baseRecipeRef) {
const ref = section.baseRecipeRef;
const slug = ref.short_name;
if (processedSlugs.has(slug)) continue;
processedSlugs.add(slug);
if (ref.nutritionMappings?.length > 0) {
const mult = section.baseMultiplier || 1;
const nutrition = computeRecipeNutritionTotals(ref.ingredients || [], ref.nutritionMappings, 1);
results.push({ shortName: slug, name: ref.name, nutrition, baseMultiplier: mult });
}
}
// Type 2: Anchor-tag references in ingredient names
if (section.list) {
for (const item of section.list) {
const refSlug = parseAnchorRecipeRef(item.name || '');
if (!refSlug || processedSlugs.has(refSlug)) continue;
processedSlugs.add(refSlug);
// Look up the referenced recipe
const refRecipe = await Recipe.findOne({ short_name: refSlug })
.select('short_name name ingredients nutritionMappings portions')
.lean();
if (!refRecipe?.nutritionMappings?.length) continue;
const nutrition = computeRecipeNutritionTotals(
refRecipe.ingredients || [], refRecipe.nutritionMappings, 1
);
results.push({ shortName: refSlug, name: refRecipe.name, nutrition, baseMultiplier: 1 });
}
}
}
return results;
}
export const GET: RequestHandler = async ({ params }) => {
await dbConnect();
const en = isEnglish(params.recipeLang!);
@@ -34,25 +120,25 @@ export const GET: RequestHandler = async ({ params }) => {
? [
{
path: 'translations.en.ingredients.baseRecipeRef',
select: 'short_name name ingredients instructions translations',
select: 'short_name name ingredients instructions translations nutritionMappings portions',
populate: {
path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients instructions translations',
select: 'short_name name ingredients instructions translations nutritionMappings portions',
populate: {
path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients instructions translations'
select: 'short_name name ingredients instructions translations nutritionMappings portions'
}
}
},
{
path: 'translations.en.instructions.baseRecipeRef',
select: 'short_name name ingredients instructions translations',
select: 'short_name name ingredients instructions translations nutritionMappings portions',
populate: {
path: 'instructions.baseRecipeRef',
select: 'short_name name ingredients instructions translations',
select: 'short_name name ingredients instructions translations nutritionMappings portions',
populate: {
path: 'instructions.baseRecipeRef',
select: 'short_name name ingredients instructions translations'
select: 'short_name name ingredients instructions translations nutritionMappings portions'
}
}
}
@@ -60,13 +146,13 @@ export const GET: RequestHandler = async ({ params }) => {
: [
{
path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations',
select: 'short_name name ingredients translations nutritionMappings portions',
populate: {
path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations',
select: 'short_name name ingredients translations nutritionMappings portions',
populate: {
path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations'
select: 'short_name name ingredients translations nutritionMappings portions'
}
}
},
@@ -126,6 +212,7 @@ export const GET: RequestHandler = async ({ params }) => {
total_time: t.total_time || rawRecipe.total_time || '',
translationStatus: t.translationStatus,
germanShortName: rawRecipe.short_name,
nutritionMappings: resolveNutritionData(rawRecipe.nutritionMappings || []),
};
if (recipe.ingredients) {
@@ -135,6 +222,9 @@ export const GET: RequestHandler = async ({ params }) => {
recipe.instructions = mapBaseRecipeRefs(recipe.instructions as any[]);
}
// Resolve nutrition from referenced recipes (base refs + anchor tags)
recipe.referencedNutrition = await resolveReferencedNutrition(rawRecipe.ingredients || []);
// Merge English alt/caption with original image paths
const imagesArray = Array.isArray(rawRecipe.images) ? rawRecipe.images : (rawRecipe.images ? [rawRecipe.images] : []);
if (imagesArray.length > 0) {
@@ -152,11 +242,14 @@ export const GET: RequestHandler = async ({ params }) => {
// German: pass through with base recipe ref mapping
let recipe = JSON.parse(JSON.stringify(rawRecipe));
recipe.nutritionMappings = resolveNutritionData(recipe.nutritionMappings || []);
if (recipe.ingredients) {
recipe.ingredients = mapBaseRecipeRefs(recipe.ingredients);
}
if (recipe.instructions) {
recipe.instructions = mapBaseRecipeRefs(recipe.instructions);
}
// Resolve nutrition from referenced recipes (base refs + anchor tags)
recipe.referencedNutrition = await resolveReferencedNutrition(rawRecipe.ingredients || []);
return json(recipe);
};

View File

@@ -0,0 +1,80 @@
import { json, error, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import { isEnglish } from '$lib/server/recipeHelpers';
import { getNutritionEntryByFdcId, getBlsEntryByCode, invalidateOverwriteCache } from '$lib/server/nutritionMatcher';
import { NutritionOverwrite } from '$models/NutritionOverwrite';
import { canonicalizeUnit, resolveGramsPerUnit } from '$lib/data/unitConversions';
import type { NutritionMapping } from '$types/types';
/** PATCH: Update individual nutrition mappings (manual edit UI) */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
await locals.auth();
await dbConnect();
const en = isEnglish(params.recipeLang!);
const query = en
? { 'translations.en.short_name': params.name }
: { short_name: params.name };
const recipe = await Recipe.findOne(query);
if (!recipe) throw error(404, 'Recipe not found');
const updates: (Partial<NutritionMapping> & { global?: boolean; ingredientNameDe?: string })[] = await request.json();
const mappings: any[] = recipe.nutritionMappings || [];
for (const update of updates) {
// If global flag is set, also create/update a NutritionOverwrite
if (update.global && update.ingredientNameDe) {
const owData: Record<string, any> = {
ingredientNameDe: update.ingredientNameDe.toLowerCase().trim(),
source: update.excluded ? 'skip' : (update.source || 'usda'),
excluded: update.excluded || false,
};
if (update.ingredientName) owData.ingredientNameEn = update.ingredientName;
if (update.fdcId) owData.fdcId = update.fdcId;
if (update.blsCode) owData.blsCode = update.blsCode;
if (update.nutritionDbName) owData.nutritionDbName = update.nutritionDbName;
await NutritionOverwrite.findOneAndUpdate(
{ ingredientNameDe: owData.ingredientNameDe },
owData,
{ upsert: true, runValidators: true },
);
invalidateOverwriteCache();
}
// Resolve gramsPerUnit from source DB portions if not provided
if (!update.gramsPerUnit && !update.excluded) {
if (update.blsCode && update.source === 'bls') {
update.gramsPerUnit = 1;
update.unitConversionSource = update.unitConversionSource || 'manual';
} else if (update.fdcId) {
const entry = getNutritionEntryByFdcId(update.fdcId);
if (entry) {
const resolved = resolveGramsPerUnit('g', entry.portions);
update.gramsPerUnit = resolved.grams;
update.unitConversionSource = resolved.source;
}
}
}
// Clean up non-schema fields before saving
delete update.global;
delete update.ingredientNameDe;
const idx = mappings.findIndex(
(m: any) => m.sectionIndex === update.sectionIndex && m.ingredientIndex === update.ingredientIndex
);
if (idx >= 0) {
Object.assign(mappings[idx], update, { manuallyEdited: true });
} else {
mappings.push({ ...update, manuallyEdited: true });
}
}
recipe.nutritionMappings = mappings;
await recipe.save();
return json({ updated: updates.length });
};

View File

@@ -0,0 +1,51 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import { generateNutritionMappings } from '$lib/server/nutritionMatcher';
export const POST: RequestHandler = async ({ locals }) => {
await locals.auth();
await dbConnect();
const recipes = await Recipe.find({}).lean();
const results: { name: string; mapped: number; total: number }[] = [];
for (const recipe of recipes) {
const ingredients = recipe.ingredients || [];
const translatedIngredients = recipe.translations?.en?.ingredients;
// Preserve manually edited mappings
const existingMappings = recipe.nutritionMappings || [];
const manualMappings = new Map(
existingMappings
.filter((m: any) => m.manuallyEdited)
.map((m: any) => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
);
const newMappings = await generateNutritionMappings(ingredients, translatedIngredients);
const finalMappings = newMappings.map(m => {
const key = `${m.sectionIndex}-${m.ingredientIndex}`;
return manualMappings.get(key) || m;
});
await Recipe.updateOne(
{ _id: recipe._id },
{ $set: { nutritionMappings: finalMappings } }
);
const mapped = finalMappings.filter(m => m.matchMethod !== 'none').length;
results.push({ name: recipe.name, mapped, total: finalMappings.length });
}
const totalMapped = results.reduce((sum, r) => sum + r.mapped, 0);
const totalIngredients = results.reduce((sum, r) => sum + r.total, 0);
return json({
recipes: results.length,
totalIngredients,
totalMapped,
coverage: totalIngredients ? (totalMapped / totalIngredients * 100).toFixed(1) + '%' : '0%',
details: results,
});
};

View File

@@ -0,0 +1,44 @@
import { json, error, type RequestHandler } from '@sveltejs/kit';
import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import { isEnglish } from '$lib/server/recipeHelpers';
import { generateNutritionMappings } from '$lib/server/nutritionMatcher';
export const POST: RequestHandler = async ({ params, locals }) => {
await locals.auth();
await dbConnect();
const en = isEnglish(params.recipeLang!);
const query = en
? { 'translations.en.short_name': params.name }
: { short_name: params.name };
const recipe = await Recipe.findOne(query).lean();
if (!recipe) throw error(404, 'Recipe not found');
const ingredients = recipe.ingredients || [];
const translatedIngredients = recipe.translations?.en?.ingredients;
// Preserve manually edited mappings
const existingMappings = recipe.nutritionMappings || [];
const manualMappings = new Map(
existingMappings
.filter((m: any) => m.manuallyEdited)
.map((m: any) => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
);
const newMappings = await generateNutritionMappings(ingredients, translatedIngredients);
// Merge: keep manual edits, use new auto-matches for the rest
const finalMappings = newMappings.map(m => {
const key = `${m.sectionIndex}-${m.ingredientIndex}`;
return manualMappings.get(key) || m;
});
await Recipe.updateOne(
{ _id: recipe._id },
{ $set: { nutritionMappings: finalMappings } }
);
return json({ mappings: finalMappings, count: finalMappings.length });
};

View File

@@ -0,0 +1,60 @@
import { json, error, type RequestHandler } from '@sveltejs/kit';
import { dbConnect } from '$utils/db';
import { NutritionOverwrite } from '$models/NutritionOverwrite';
import { invalidateOverwriteCache } from '$lib/server/nutritionMatcher';
/** GET: List all global nutrition overwrites */
export const GET: RequestHandler = async ({ locals }) => {
await locals.auth();
await dbConnect();
const overwrites = await NutritionOverwrite.find({}).sort({ ingredientNameDe: 1 }).lean();
return json(overwrites);
};
/** POST: Create a new global nutrition overwrite */
export const POST: RequestHandler = async ({ request, locals }) => {
await locals.auth();
await dbConnect();
const body = await request.json();
if (!body.ingredientNameDe || !body.source) {
throw error(400, 'ingredientNameDe and source are required');
}
const data: Record<string, any> = {
ingredientNameDe: body.ingredientNameDe.toLowerCase().trim(),
source: body.source,
};
if (body.ingredientNameEn) data.ingredientNameEn = body.ingredientNameEn;
if (body.fdcId) data.fdcId = body.fdcId;
if (body.blsCode) data.blsCode = body.blsCode;
if (body.nutritionDbName) data.nutritionDbName = body.nutritionDbName;
if (body.source === 'skip') data.excluded = true;
const overwrite = await NutritionOverwrite.findOneAndUpdate(
{ ingredientNameDe: data.ingredientNameDe },
data,
{ upsert: true, new: true, runValidators: true },
).lean();
invalidateOverwriteCache();
return json(overwrite, { status: 201 });
};
/** DELETE: Remove a global nutrition overwrite by ingredientNameDe */
export const DELETE: RequestHandler = async ({ request, locals }) => {
await locals.auth();
await dbConnect();
const body = await request.json();
if (!body.ingredientNameDe) {
throw error(400, 'ingredientNameDe is required');
}
const result = await NutritionOverwrite.deleteOne({
ingredientNameDe: body.ingredientNameDe.toLowerCase().trim(),
});
invalidateOverwriteCache();
return json({ deleted: result.deletedCount });
};

View File

@@ -0,0 +1,41 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { NUTRITION_DB } from '$lib/data/nutritionDb';
import { BLS_DB } from '$lib/data/blsDb';
/** GET: Search BLS + USDA nutrition databases by name substring */
export const GET: RequestHandler = async ({ url }) => {
const q = (url.searchParams.get('q') || '').toLowerCase().trim();
if (q.length < 2) return json([]);
const results: { source: 'bls' | 'usda'; id: string; name: string; category: string; calories: number }[] = [];
// Search BLS first (primary)
for (const entry of BLS_DB) {
if (results.length >= 30) break;
if (entry.nameDe.toLowerCase().includes(q) || entry.nameEn.toLowerCase().includes(q)) {
results.push({
source: 'bls',
id: entry.blsCode,
name: `${entry.nameDe}${entry.nameEn ? ` (${entry.nameEn})` : ''}`,
category: entry.category,
calories: entry.per100g.calories,
});
}
}
// Then USDA
for (const entry of NUTRITION_DB) {
if (results.length >= 40) break;
if (entry.name.toLowerCase().includes(q)) {
results.push({
source: 'usda',
id: String(entry.fdcId),
name: entry.name,
category: entry.category,
calories: entry.per100g.calories,
});
}
}
return json(results.slice(0, 30));
};

View File

@@ -1,3 +1,40 @@
// Nutrition per-100g data shape (shared by BLS and USDA sources)
export type NutritionPer100g = {
calories: number; protein: number; fat: number; saturatedFat: number;
carbs: number; fiber: number; sugars: number;
calcium: number; iron: number; magnesium: number; phosphorus: number;
potassium: number; sodium: number; zinc: number;
vitaminA: number; vitaminC: number; vitaminD: number; vitaminE: number;
vitaminK: number; thiamin: number; riboflavin: number; niacin: number;
vitaminB6: number; vitaminB12: number; folate: number; cholesterol: number;
// Amino acids (g/100g) — available from BLS, partially from USDA
isoleucine?: number; leucine?: number; lysine?: number; methionine?: number;
phenylalanine?: number; threonine?: number; tryptophan?: number; valine?: number;
histidine?: number; alanine?: number; arginine?: number; asparticAcid?: number;
cysteine?: number; glutamicAcid?: number; glycine?: number; proline?: number;
serine?: number; tyrosine?: number;
};
// Nutrition mapping for calorie/macro tracking per ingredient
export type NutritionMapping = {
sectionIndex: number;
ingredientIndex: number;
ingredientName?: string;
ingredientNameDe?: string;
source?: 'bls' | 'usda' | 'manual';
fdcId?: number;
blsCode?: string;
nutritionDbName?: string;
matchConfidence?: number;
matchMethod: 'exact' | 'embedding' | 'manual' | 'none';
gramsPerUnit?: number;
defaultAmountUsed?: boolean;
unitConversionSource: 'direct' | 'density' | 'usda_portion' | 'estimate' | 'manual' | 'none';
manuallyEdited: boolean;
excluded: boolean;
per100g?: NutritionPer100g;
};
// Translation status enum
export type TranslationStatus = 'pending' | 'approved' | 'needs_update';
@@ -163,6 +200,7 @@ export type RecipeModelType = {
addendum?: string
note?: string;
isBaseRecipe?: boolean;
nutritionMappings?: NutritionMapping[];
translations?: {
en?: TranslatedRecipeType;
};