Merge branch 'recipes-calories'
CI / update (push) Successful in 4m37s

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.

recipes: overhaul nutrition editor UI and defer saves to form submission

- Nutrition mappings and global overwrites are now local-only until
  the recipe is saved, preventing premature DB writes on generate/edit
- Generate endpoint supports ?preview=true for non-persisting previews
- Show existing nutrition data immediately instead of requiring generate
- Replace raw checkboxes with Toggle component for global overwrites,
  initialized from existing NutritionOverwrite records
- Fix search dropdown readability (solid backgrounds, proper theming)
- Use fuzzy search (fzf-style) for manual nutrition ingredient lookup
- Swap ingredient display: German primary, English in brackets
- Allow editing g/u on manually mapped ingredients
- Make translation optional: separate save (FAB) and translate buttons
- "Vollständig neu übersetzen" now triggers actual full retranslation
- Show existing translation inline instead of behind a button
- Replace nord0 dark backgrounds with semantic theme variables
This commit is contained in:
2026-04-02 19:46:24 +02:00
33 changed files with 722586 additions and 73 deletions
@@ -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);
};
@@ -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 });
};
@@ -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,
});
};
@@ -0,0 +1,50 @@
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, url }) => {
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;
const newMappings = await generateNutritionMappings(ingredients, translatedIngredients);
const preview = url.searchParams.get('preview') === 'true';
// In preview mode, return pure auto-matches without saving (client merges manual edits)
if (preview) {
return json({ mappings: newMappings, count: newMappings.length });
}
// 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])
);
// 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 });
};
@@ -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 });
};
@@ -0,0 +1,48 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { NUTRITION_DB } from '$lib/data/nutritionDb';
import { BLS_DB } from '$lib/data/blsDb';
import { fuzzyScore } from '$lib/js/fuzzy';
/** GET: Search BLS + USDA nutrition databases by fuzzy name match */
export const GET: RequestHandler = async ({ url }) => {
const q = (url.searchParams.get('q') || '').toLowerCase().trim();
if (q.length < 2) return json([]);
const scored: { source: 'bls' | 'usda'; id: string; name: string; category: string; calories: number; score: number }[] = [];
// Search BLS (primary)
for (const entry of BLS_DB) {
const scoreDe = fuzzyScore(q, entry.nameDe.toLowerCase());
const scoreEn = entry.nameEn ? fuzzyScore(q, entry.nameEn.toLowerCase()) : 0;
const best = Math.max(scoreDe, scoreEn);
if (best > 0) {
scored.push({
source: 'bls',
id: entry.blsCode,
name: `${entry.nameDe}${entry.nameEn ? ` (${entry.nameEn})` : ''}`,
category: entry.category,
calories: entry.per100g.calories,
score: best,
});
}
}
// Search USDA
for (const entry of NUTRITION_DB) {
const s = fuzzyScore(q, entry.name.toLowerCase());
if (s > 0) {
scored.push({
source: 'usda',
id: String(entry.fdcId),
name: entry.name,
category: entry.category,
calories: entry.per100g.calories,
score: s,
});
}
}
// Sort by score descending, return top 30 (without score field)
scored.sort((a, b) => b.score - a.score);
return json(scored.slice(0, 30).map(({ score, ...rest }) => rest));
};