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.
This commit is contained in:
2026-04-06 13:21:19 +02:00
parent 201847400e
commit d10946774d
4 changed files with 178 additions and 6 deletions

View File

@@ -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 @@
<div class="fs-result-info">
<span class="fs-result-name">{item.name}</span>
<span class="fs-result-meta">
<span class="fs-source-badge" class:usda={item.source === 'usda'} class:off={item.source === 'off'}>{sourceLabel(item.source)}</span>
<span class="fs-source-badge" class:usda={item.source === 'usda'} class:off={item.source === 'off'} class:recipe={item.source === 'recipe'}>{sourceLabel(item.source)}</span>
{#if item.brands}<span class="fs-result-brands">{item.brands}</span>{/if}
{#if item.category}{item.category}{/if}
</span>
@@ -426,7 +427,7 @@
<div class="fs-selected">
<div class="fs-selected-header">
<span class="fs-selected-name">
<span class="fs-source-badge" class:usda={selected.source === 'usda'} class:off={selected.source === 'off'}>{sourceLabel(selected.source)}</span>
<span class="fs-source-badge" class:usda={selected.source === 'usda'} class:off={selected.source === 'off'} class:recipe={selected.source === 'recipe'}>{sourceLabel(selected.source)}</span>
{selected.name}
</span>
{#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;

View File

@@ -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<IOpenFoodFact>;
try { _model = mongoose.model<IOpenFoodFact>('OpenFoodFact'); } catch { _model = mongoose.model<IOpenFoodFact>('OpenFoodFact', OpenFoodFactSchema); }
export const OpenFoodFact = _model;

View File

@@ -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<string, number>; 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<string, number> = {};
for (const k of keys) totals[k] = 0;
let totalGrams = 0;
// Build mapping index
const mappingIndex = new Map<string, any>();
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<string, number> = {};
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(/&shy;|­/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);