From d10946774d40ad0e7ff858deca588f237eb168fd Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Mon, 6 Apr 2026 13:21:19 +0200 Subject: [PATCH] feat: add recipe and OpenFoodFacts search to nutrition food search Recipes from /rezepte now appear in the food search on /fitness/nutrition, with per-100g nutrition computed server-side from ingredient mappings. Recipe results are boosted above BLS/USDA/OFF in search ranking. OpenFoodFacts products are now searchable by name/brand via MongoDB text index, alongside the existing barcode lookup. Recipe and OFF queries run in parallel with in-memory BLS/USDA scans. --- src-tauri/Cargo.lock | 2 +- src/lib/components/fitness/FoodSearch.svelte | 11 +- src/models/OpenFoodFact.ts | 2 + src/routes/api/nutrition/search/+server.ts | 169 ++++++++++++++++++- 4 files changed, 178 insertions(+), 6 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7656948..0268e7c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -144,7 +144,7 @@ dependencies = [ [[package]] name = "bocken" -version = "0.2.0" +version = "0.2.1" dependencies = [ "serde", "serde_json", diff --git a/src/lib/components/fitness/FoodSearch.svelte b/src/lib/components/fitness/FoodSearch.svelte index 4cf01d5..877766d 100644 --- a/src/lib/components/fitness/FoodSearch.svelte +++ b/src/lib/components/fitness/FoodSearch.svelte @@ -156,6 +156,7 @@ if (source === 'bls') return 'BLS'; if (source === 'usda') return 'USDA'; if (source === 'off') return 'OFF'; + if (source === 'recipe') return '🍴'; return source?.toUpperCase() ?? ''; } @@ -402,7 +403,7 @@
{item.name} - {sourceLabel(item.source)} + {sourceLabel(item.source)} {#if item.brands}{item.brands}{/if} {#if item.category}{item.category}{/if} @@ -426,7 +427,7 @@
- {sourceLabel(selected.source)} + {sourceLabel(selected.source)} {selected.name} {#if selected.brands} @@ -689,6 +690,12 @@ background: color-mix(in srgb, var(--nord15) 15%, transparent); color: var(--nord15); } + .fs-source-badge.recipe { + background: color-mix(in srgb, var(--nord14) 15%, transparent); + color: var(--nord14); + font-size: 0.7rem; + padding: 0.02rem 0.15rem; + } .fs-result-cal { font-size: 0.85rem; font-weight: 700; diff --git a/src/models/OpenFoodFact.ts b/src/models/OpenFoodFact.ts index 1f0d90f..33288c4 100644 --- a/src/models/OpenFoodFact.ts +++ b/src/models/OpenFoodFact.ts @@ -47,6 +47,8 @@ const OpenFoodFactSchema = new mongoose.Schema({ per100g: { type: Per100gSchema, required: true }, }, { collection: 'openfoodfacts' }); +OpenFoodFactSchema.index({ name: 'text', nameDe: 'text', brands: 'text' }); + let _model: mongoose.Model; try { _model = mongoose.model('OpenFoodFact'); } catch { _model = mongoose.model('OpenFoodFact', OpenFoodFactSchema); } export const OpenFoodFact = _model; diff --git a/src/routes/api/nutrition/search/+server.ts b/src/routes/api/nutrition/search/+server.ts index ec1b1d4..0a4f410 100644 --- a/src/routes/api/nutrition/search/+server.ts +++ b/src/routes/api/nutrition/search/+server.ts @@ -5,8 +5,23 @@ import { fuzzyScore } from '$lib/js/fuzzy'; import { requireAuth } from '$lib/server/middleware/auth'; import { dbConnect } from '$utils/db'; import { FavoriteIngredient } from '$models/FavoriteIngredient'; +import { Recipe } from '$models/Recipe'; +import { OpenFoodFact } from '$models/OpenFoodFact'; +import { getBlsEntryByCode, getNutritionEntryByFdcId } from '$lib/server/nutritionMatcher'; -type SearchResult = { source: 'bls' | 'usda'; id: string; name: string; category: string; calories: number; favorited?: boolean; per100g?: any; portions?: any[] }; +type SearchResult = { + source: 'bls' | 'usda' | 'recipe' | 'off'; + id: string; + name: string; + category: string; + calories: number; + favorited?: boolean; + per100g?: any; + portions?: any[]; + brands?: string; + icon?: string; + image?: string; +}; function lookupBls(blsCode: string, full: boolean): SearchResult | null { const entry = BLS_DB.find(e => e.blsCode === blsCode); @@ -34,7 +49,74 @@ function lookupUsda(fdcId: string, full: boolean): SearchResult | null { }; } -/** GET: Search BLS + USDA nutrition databases by fuzzy name match */ +/** Parse ingredient amount string to a number */ +function parseAmount(amount: string | undefined): number { + if (!amount?.trim()) return 0; + let s = amount.trim().replace(',', '.'); + const rangeMatch = s.match(/^(\d+(?:\.\d+)?)\s*[-–]\s*(\d+(?:\.\d+)?)$/); + if (rangeMatch) return (parseFloat(rangeMatch[1]) + parseFloat(rangeMatch[2])) / 2; + const fractionMatch = s.match(/^(\d+)\s*\/\s*(\d+)$/); + if (fractionMatch) return parseInt(fractionMatch[1]) / parseInt(fractionMatch[2]); + const parsed = parseFloat(s); + return isNaN(parsed) ? 0 : parsed; +} + +/** Compute per-100g nutrition for a recipe from its ingredients + nutritionMappings */ +function computeRecipePer100g(recipe: any): { per100g: Record; totalGrams: number } | null { + const mappings = recipe.nutritionMappings; + if (!mappings?.length) return null; + + const keys = [ + 'calories', 'protein', 'fat', 'saturatedFat', 'carbs', 'fiber', 'sugars', + 'calcium', 'iron', 'magnesium', 'phosphorus', 'potassium', 'sodium', 'zinc', + 'vitaminA', 'vitaminC', 'vitaminD', 'vitaminE', 'vitaminK', + 'thiamin', 'riboflavin', 'niacin', 'vitaminB6', 'vitaminB12', 'folate', 'cholesterol', + ]; + const totals: Record = {}; + for (const k of keys) totals[k] = 0; + let totalGrams = 0; + + // Build mapping index + const mappingIndex = new Map(); + for (const m of mappings) { + mappingIndex.set(`${m.sectionIndex}-${m.ingredientIndex}`, m); + } + + // Resolve per100g for each mapping and sum + for (const m of mappings) { + if (m.matchMethod === 'none' || m.excluded || !m.gramsPerUnit) continue; + + let per100g = m.per100g; + if (!per100g) { + // Resolve from DB + if (m.source === 'bls' && m.blsCode) { + per100g = getBlsEntryByCode(m.blsCode)?.per100g; + } else if (m.fdcId) { + per100g = getNutritionEntryByFdcId(m.fdcId)?.per100g; + } + } + if (!per100g) continue; + + // Find the ingredient in the recipe to get its amount + const section = recipe.ingredients?.[m.sectionIndex]; + const items = section?.list ?? section?.ingredients ?? section?.items ?? []; + const ing = items[m.ingredientIndex]; + const parsedAmount = (ing ? parseAmount(ing.amount) : 0) || (m.defaultAmountUsed ? 1 : 0); + + const grams = parsedAmount * m.gramsPerUnit; + totalGrams += grams; + const factor = grams / 100; + for (const k of keys) totals[k] += factor * (per100g[k] ?? 0); + } + + if (totalGrams <= 0) return null; + + const per100g: Record = {}; + for (const k of keys) per100g[k] = totals[k] / totalGrams * 100; + return { per100g, totalGrams }; +} + +/** GET: Search recipes, BLS, USDA, and OpenFoodFacts by fuzzy name match */ export const GET: RequestHandler = async ({ url, locals }) => { const q = (url.searchParams.get('q') || '').toLowerCase().trim(); if (q.length < 2) return json([]); @@ -70,7 +152,59 @@ export const GET: RequestHandler = async ({ url, locals }) => { const scored: (SearchResult & { score: number })[] = []; - // Search BLS (primary) + // Search recipes + OFF in parallel with BLS/USDA (which are in-memory) + await dbConnect(); + const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const words = q.split(/\s+/).filter(Boolean); + const nameRegex = words.map(w => `(?=.*${w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`).join('') + '.*'; + + const [recipes, offResults] = await Promise.all([ + Recipe.find({ + $or: [ + { name: { $regex: nameRegex, $options: 'i' } }, + { short_name: { $regex: nameRegex, $options: 'i' } }, + { tags: { $regex: escaped, $options: 'i' } }, + ] + }).select('name short_name icon images ingredients nutritionMappings portions').limit(10).lean() + .catch(() => [] as any[]), + OpenFoodFact.find( + { $text: { $search: q } }, + { ...(full ? {} : { name: 1, nameDe: 1, brands: 1, category: 1, 'per100g.calories': 1, serving: 1 }), score: { $meta: 'textScore' } } + ).sort({ score: { $meta: 'textScore' } }).limit(15).lean() + .catch(() => [] as any[]), + ]); + + // Process recipe results (highest priority — scored with bonus) + for (const r of recipes as any[]) { + const scoreName = fuzzyScore(q, (r.name || '').toLowerCase()); + const scoreShort = fuzzyScore(q, (r.short_name || '').replace(/_/g, ' ').toLowerCase()); + const best = Math.max(scoreName, scoreShort); + if (best <= 0) continue; + + const nutrition = computeRecipePer100g(r); + const image = r.images?.[0]?.mediapath; + const portionsMatch = r.portions?.match(/^(\d+(?:[.,]\d+)?)/); + const portionCount = portionsMatch ? parseFloat(portionsMatch[1].replace(',', '.')) : 0; + const portions: any[] = []; + if (portionCount > 0 && nutrition) { + const gramsPerPortion = Math.round(nutrition.totalGrams / portionCount); + portions.push({ description: '1 Portion', grams: gramsPerPortion }); + } + + scored.push({ + source: 'recipe', + id: String(r._id), + name: r.name.replace(/­|­/g, ''), + category: r.icon || '🍽️', + calories: Math.round(nutrition?.per100g.calories ?? 0), + score: best + 100, // Boost recipes above BLS/USDA/OFF + ...(full && nutrition && { per100g: nutrition.per100g }), + ...(portions.length > 0 && { portions }), + ...(image && { image }), + }); + } + + // Search BLS (in-memory, primary) for (const entry of BLS_DB) { const scoreDe = fuzzyScore(q, entry.nameDe.toLowerCase()); const scoreEn = entry.nameEn ? fuzzyScore(q, entry.nameEn.toLowerCase()) : 0; @@ -106,6 +240,35 @@ export const GET: RequestHandler = async ({ url, locals }) => { } } + // Process OpenFoodFacts results + { + for (const entry of offResults as any[]) { + const displayName = entry.nameDe || entry.name; + // Use fuzzy score for ranking consistency with BLS/USDA + const scoreDe = entry.nameDe ? fuzzyScore(q, entry.nameDe.toLowerCase()) : 0; + const scoreEn = fuzzyScore(q, entry.name.toLowerCase()); + const scoreBrand = entry.brands ? fuzzyScore(q, entry.brands.toLowerCase()) : 0; + const best = Math.max(scoreDe, scoreEn, scoreBrand, 1); // text search already filtered + + const portions: any[] = []; + if (entry.serving?.grams) { + portions.push(entry.serving); + } + + scored.push({ + source: 'off', + id: entry.barcode, + name: displayName, + category: entry.category || '', + calories: entry.per100g?.calories ?? 0, + brands: entry.brands, + score: best, + ...(full && { per100g: entry.per100g }), + ...(portions.length > 0 && { portions }), + }); + } + } + // Sort by score descending, return top 30 (without score field) scored.sort((a, b) => b.score - a.score); const searchResults = scored.slice(0, 30).map(({ score, ...rest }) => rest);