diff --git a/package.json b/package.json index 81525a0..330f12f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.17.1", + "version": "1.17.2", "private": true, "type": "module", "scripts": { diff --git a/src/routes/api/fitness/favorite-ingredients/+server.ts b/src/routes/api/fitness/favorite-ingredients/+server.ts index fe31e8e..c6b3aa3 100644 --- a/src/routes/api/fitness/favorite-ingredients/+server.ts +++ b/src/routes/api/fitness/favorite-ingredients/+server.ts @@ -19,8 +19,8 @@ export const POST: RequestHandler = async ({ request, locals }) => { if (!source || !sourceId || !name) { return json({ error: 'source, sourceId, and name are required' }, { status: 400 }); } - if (source !== 'bls' && source !== 'usda') { - return json({ error: 'source must be "bls" or "usda"' }, { status: 400 }); + if (source !== 'bls' && source !== 'usda' && source !== 'recipe') { + return json({ error: 'source must be "bls", "usda", or "recipe"' }, { status: 400 }); } await dbConnect(); diff --git a/src/routes/api/nutrition/lookup/+server.ts b/src/routes/api/nutrition/lookup/+server.ts index 62f523e..d557c49 100644 --- a/src/routes/api/nutrition/lookup/+server.ts +++ b/src/routes/api/nutrition/lookup/+server.ts @@ -1,6 +1,9 @@ import { json, type RequestHandler } from '@sveltejs/kit'; import { NUTRITION_DB } from '$lib/data/nutritionDb'; import { BLS_DB } from '$lib/data/blsDb'; +import { Recipe } from '$models/Recipe'; +import { dbConnect } from '$utils/db'; +import { computeRecipeNutritionTotals, parseAmount, resolveReferencedNutrition } from '$lib/server/nutritionMatcher'; export const GET: RequestHandler = async ({ url }) => { const source = url.searchParams.get('source'); @@ -23,5 +26,64 @@ export const GET: RequestHandler = async ({ url }) => { return json({ per100g: entry.per100g, portions: entry.portions }); } + if (source === 'recipe') { + await dbConnect(); + const query = id.match(/^[0-9a-fA-F]{24}$/) ? { _id: id } : { short_name: id }; + const recipe = await Recipe.findOne(query) + .select('ingredients nutritionMappings portions') + .lean(); + if (!recipe) return json({ error: 'Not found' }, { status: 404 }); + + const mappings = recipe.nutritionMappings || []; + const ingredients = recipe.ingredients || []; + + const totals = computeRecipeNutritionTotals(ingredients, mappings, 1); + + const referencedNutrition = await resolveReferencedNutrition(ingredients, mappings); + for (const ref of referencedNutrition) { + const mult = ref.baseMultiplier ?? 1; + for (const [k, v] of Object.entries(ref.nutrition)) { + if (typeof v === 'number') { + totals[k] = (totals[k] || 0) + v * mult; + } + } + } + + const mappingIndex = new Map( + mappings.map((m: any) => [`${m.sectionIndex}-${m.ingredientIndex}`, m]) + ); + let totalWeightGrams = 0; + for (let si = 0; si < ingredients.length; si++) { + const section = ingredients[si]; + if (section.type === 'reference' || !section.list) continue; + for (let ii = 0; ii < section.list.length; ii++) { + const item = section.list[ii]; + if (/ = {}; + if (totalWeightGrams > 0) { + for (const [k, v] of Object.entries(totals)) { + per100g[k] = (v / totalWeightGrams) * 100; + } + } else { + Object.assign(per100g, totals); + } + + const portions: any[] = []; + const portionsMatch = recipe.portions?.match(/^(\d+(?:[.,]\d+)?)/); + const portionCount = portionsMatch ? parseFloat(portionsMatch[1].replace(',', '.')) : 0; + if (portionCount > 0 && totalWeightGrams > 0) { + portions.push({ description: '1 Portion', grams: Math.round(totalWeightGrams / portionCount) }); + } + + return json({ per100g, ...(portions.length > 0 && { portions }) }); + } + return json({ error: 'Invalid source' }, { status: 400 }); }; diff --git a/src/routes/api/nutrition/search/+server.ts b/src/routes/api/nutrition/search/+server.ts index 0a4f410..70c5da8 100644 --- a/src/routes/api/nutrition/search/+server.ts +++ b/src/routes/api/nutrition/search/+server.ts @@ -134,11 +134,44 @@ export const GET: RequestHandler = async ({ url, locals }) => { await dbConnect(); const favDocs = await FavoriteIngredient.find({ createdBy: user.nickname }).lean(); + // Batch-load favorited recipes + const recipeFavIds = favDocs.filter(f => f.source === 'recipe').map(f => f.sourceId); + const favRecipes = recipeFavIds.length > 0 + ? await Recipe.find({ + $or: recipeFavIds.map(id => + id.match(/^[0-9a-fA-F]{24}$/) ? { _id: id } : { short_name: id } + ) + }).select('name short_name icon images ingredients nutritionMappings portions').lean() + : []; + const favRecipeMap = new Map(); + for (const r of favRecipes) { + favRecipeMap.set(String(r._id), r); + favRecipeMap.set(r.short_name, r); + } + for (const fav of favDocs) { const key = `${fav.source}:${fav.sourceId}`; - const result = fav.source === 'bls' - ? lookupBls(fav.sourceId, full) - : lookupUsda(fav.sourceId, full); + let result: SearchResult | null = null; + if (fav.source === 'bls') { + result = lookupBls(fav.sourceId, full); + } else if (fav.source === 'usda') { + result = lookupUsda(fav.sourceId, full); + } else if (fav.source === 'recipe') { + const r = favRecipeMap.get(fav.sourceId); + if (r) { + const nutrition = computeRecipePer100g(r); + const image = r.images?.[0]?.mediapath; + result = { + source: 'recipe', + id: String(r._id), + name: r.name.replace(/­|­/g, ''), + category: r.icon || '🍽️', + calories: Math.round(nutrition?.per100g.calories ?? 0), + ...(full && nutrition && { per100g: nutrition.per100g }), + ...(image && { image }), + }; + } + } if (result) { result.favorited = true; favResults.push(result); @@ -191,9 +224,10 @@ export const GET: RequestHandler = async ({ url, locals }) => { portions.push({ description: '1 Portion', grams: gramsPerPortion }); } + const recipeId = String(r._id); scored.push({ source: 'recipe', - id: String(r._id), + id: recipeId, name: r.name.replace(/­|­/g, ''), category: r.icon || '🍽️', calories: Math.round(nutrition?.per100g.calories ?? 0), @@ -201,6 +235,7 @@ export const GET: RequestHandler = async ({ url, locals }) => { ...(full && nutrition && { per100g: nutrition.per100g }), ...(portions.length > 0 && { portions }), ...(image && { image }), + ...((favKeys.has(`recipe:${recipeId}`) || favKeys.has(`recipe:${r.short_name}`)) && { favorited: true }), }); } diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte b/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte index ba3d84d..161fd84 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte @@ -1567,6 +1567,8 @@
{#if entry.source === 'bls' || entry.source === 'usda'} {entry.name} + {:else if entry.source === 'recipe' && entry.sourceId} + {entry.name} {:else} {entry.name} {/if} diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.server.ts b/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.server.ts index 20c24d2..d51eff0 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.server.ts +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.server.ts @@ -3,11 +3,65 @@ import { error } from '@sveltejs/kit'; import { NUTRITION_DB } from '$lib/data/nutritionDb'; import { BLS_DB } from '$lib/data/blsDb'; import { DRI_MALE } from '$lib/data/dailyReferenceIntake'; +import { Recipe } from '$models/Recipe'; +import { dbConnect } from '$utils/db'; +import { computeRecipeNutritionTotals, resolvePer100g, parseAmount, resolveReferencedNutrition } from '$lib/server/nutritionMatcher'; +import { FoodLogEntry } from '$models/FoodLogEntry'; -export const load: PageServerLoad = async ({ params }) => { +async function computeRecipePer100g(id: string): Promise> { + const recipeQuery = id.match(/^[0-9a-fA-F]{24}$/) ? { _id: id } : { short_name: id }; + const recipe = await Recipe.findOne(recipeQuery) + .select('ingredients nutritionMappings') + .lean(); + if (!recipe) return {}; + + const mappings = recipe.nutritionMappings || []; + const ingredients = recipe.ingredients || []; + + const totals = computeRecipeNutritionTotals(ingredients, mappings, 1); + + const referencedNutrition = await resolveReferencedNutrition(ingredients, mappings); + for (const ref of referencedNutrition) { + const mult = ref.baseMultiplier ?? 1; + for (const [k, v] of Object.entries(ref.nutrition)) { + if (typeof v === 'number') { + totals[k] = (totals[k] || 0) + v * mult; + } + } + } + + const mappingIndex = new Map( + mappings.map((m: any) => [`${m.sectionIndex}-${m.ingredientIndex}`, m]) + ); + let totalWeightGrams = 0; + for (let si = 0; si < ingredients.length; si++) { + const section = ingredients[si]; + if (section.type === 'reference' || !section.list) continue; + for (let ii = 0; ii < section.list.length; ii++) { + const item = section.list[ii]; + if (/ = {}; + if (totalWeightGrams > 0) { + for (const [k, v] of Object.entries(totals)) { + per100g[k] = (v / totalWeightGrams) * 100; + } + } else { + Object.assign(per100g, totals); + } + return per100g; +} + +export const load: PageServerLoad = async ({ params, url }) => { const { source, id } = params; - if (source !== 'bls' && source !== 'usda') { + if (source !== 'bls' && source !== 'usda' && source !== 'recipe') { throw error(404, 'Invalid source'); } @@ -28,6 +82,50 @@ export const load: PageServerLoad = async ({ params }) => { }; } + if (source === 'recipe') { + await dbConnect(); + // sourceId may be a MongoDB ObjectId or a short_name + const recipeQuery = id.match(/^[0-9a-fA-F]{24}$/) ? { _id: id } : { short_name: id }; + const recipe = await Recipe.findOne(recipeQuery) + .select('short_name name translations images') + .lean(); + if (!recipe) throw error(404, 'Recipe not found'); + + // Use logged per100g from food diary entry if provided, otherwise compute from current recipe + const logEntryId = url.searchParams.get('logEntry'); + let per100g: Record; + + if (logEntryId) { + const logEntry = await FoodLogEntry.findById(logEntryId).select('per100g').lean(); + if (logEntry?.per100g) { + per100g = logEntry.per100g as unknown as Record; + } else { + per100g = await computeRecipePer100g(id); + } + } else { + per100g = await computeRecipePer100g(id); + } + + const nameEn = recipe.translations?.en?.name; + const image = (recipe.images as any[])?.[0]?.mediapath || `${recipe.short_name}.webp`; + + return { + food: { + source: 'recipe' as const, + id: recipe.short_name, + name: recipe.name, + nameDe: recipe.name, + nameEn: nameEn || undefined, + category: 'Rezept', + per100g, + recipeSlug: recipe.short_name, + recipeSlugEn: recipe.translations?.en?.short_name || undefined, + image, + }, + dri: DRI_MALE, + }; + } + // USDA const fdcId = Number(id); const entry = NUTRITION_DB.find(e => e.fdcId === fdcId); diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte b/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte index 13b265d..b3bb541 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte @@ -1,6 +1,6 @@