/** * Modul: Meal Fit Service * Zweck: Deterministic context-aware meal ranking and grocery deltas for family logistics. * Dependencies: none */ const STOP_WORDS = new Set([ 'fresh', 'frozen', 'canned', 'can', 'jar', 'large', 'small', 'medium', 'chopped', 'diced', 'sliced', 'grated', 'shredded', 'minced', 'whole', 'organic', 'low', 'fat', 'lean', 'extra', 'of', 'with', 'and', 'the', 'a', 'an', 'to', 'for', 'optional', 'some', 'little', ]); const INGREDIENT_ALIASES = new Map([ ['cheddar cheese', 'cheddar'], ['grated cheddar', 'cheddar'], ['shredded cheddar', 'cheddar'], ['chicken breast', 'chicken'], ['chicken breasts', 'chicken'], ['leftover chicken', 'chicken'], ['minced beef', 'beef'], ['ground beef', 'beef'], ['beef mince', 'beef'], ['bell pepper', 'pepper'], ['bell peppers', 'pepper'], ['tortilla wraps', 'tortillas'], ['wraps', 'tortillas'], ]); const EFFORT_SCORE = { survival: 4, easy: 3, normal: 1, project: -4 }; const CLEANUP_SCORE = { low: 3, medium: 0, high: -4 }; const INTERRUPTION_SCORE = { high: 3, medium: 0, low: -4 }; const KID_SCORE = { safe: 3, mixed: 0, risky: -4 }; function toArray(value) { return Array.isArray(value) ? value : []; } export function normalizeIngredientName(value) { const raw = String(value || '').toLowerCase().replace(/[^a-z0-9æøåäöüéèàçñ\s-]/gi, ' ').replace(/\s+/g, ' ').trim(); if (!raw) return ''; if (INGREDIENT_ALIASES.has(raw)) return INGREDIENT_ALIASES.get(raw); const singularish = raw.replace(/ies$/, 'y').replace(/oes$/, 'o').replace(/s$/, ''); if (INGREDIENT_ALIASES.has(singularish)) return INGREDIENT_ALIASES.get(singularish); return singularish.split(' ').filter((part) => part && !STOP_WORDS.has(part)).join(' ') || singularish; } function ingredientName(ingredient) { return normalizeIngredientName(typeof ingredient === 'string' ? ingredient : ingredient?.name); } function inventoryMap(inventory) { const map = new Map(); for (const item of toArray(inventory)) { const normalized = normalizeIngredientName(item.normalizedName || item.normalized_name || item.name); if (!normalized) continue; const existing = map.get(normalized) || { name: item.name || normalized, portions: 0, expiresOn: null, items: [] }; existing.portions += Number(item.portions || 0); existing.expiresOn = earlierDate(existing.expiresOn, item.expiresOn || item.expires_on || null); existing.items.push(item); map.set(normalized, existing); } return map; } function earlierDate(a, b) { if (!a) return b; if (!b) return a; return String(a) < String(b) ? a : b; } function daysUntil(dateStr, today = new Date().toISOString().slice(0, 10)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(String(dateStr || ''))) return null; const ms = Date.parse(`${dateStr}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`); return Math.round(ms / 86400000); } function tagsFor(meal) { const parsed = typeof meal.tags_json === 'string' ? safeJson(meal.tags_json, []) : []; return new Set([...toArray(meal.tags), ...parsed, meal.meal_category, meal.protein, meal.style] .map((tag) => String(tag || '').toLowerCase().trim()).filter(Boolean)); } function safeJson(value, fallback) { try { return JSON.parse(value); } catch { return fallback; } } function mealIngredients(meal) { return toArray(meal.ingredients).map((ing) => ({ ...((typeof ing === 'string') ? { name: ing } : ing), normalizedName: ingredientName(ing), })).filter((ing) => ing.normalizedName); } function groceryDeltaFor(meal, inventory, pantryStaples = []) { const inv = inventoryMap(inventory); const pantry = new Set(toArray(pantryStaples).map(normalizeIngredientName).filter(Boolean)); const usesInventory = []; const pantryCovered = []; const missing = []; for (const ingredient of mealIngredients(meal)) { if (inv.has(ingredient.normalizedName)) { usesInventory.push(ingredient.normalizedName); } else if (pantry.has(ingredient.normalizedName)) { pantryCovered.push(ingredient.normalizedName); } else { missing.push(ingredient.normalizedName); } } return { newItems: new Set(missing).size, usesInventory: [...new Set(usesInventory)], pantryCovered: [...new Set(pantryCovered)], missingItems: [...new Set(missing)], }; } function hardBlocked(meal, preferences) { const allTargets = new Set([ String(meal.name || meal.title || '').toLowerCase(), ...mealIngredients(meal).map((i) => i.normalizedName), ...tagsFor(meal), ]); for (const pref of toArray(preferences)) { if ((pref.type || pref.preference) !== 'allergy') continue; const target = normalizeIngredientName(pref.target || pref.name || pref.ingredient || pref.value); if (target && allTargets.has(target)) return `Blocked: allergy/diet conflict with ${target}.`; } return null; } function preferenceEffect(meal, preferences) { let score = 0; const reasons = []; const warnings = []; const allTargets = new Set([ String(meal.name || meal.title || '').toLowerCase(), ...mealIngredients(meal).map((i) => i.normalizedName), ...tagsFor(meal), ]); for (const pref of toArray(preferences)) { const type = pref.type || pref.preference; const rawTarget = pref.target || pref.name || pref.ingredient || pref.value; const target = normalizeIngredientName(rawTarget) || String(rawTarget || '').toLowerCase(); if (!target || !allTargets.has(target)) continue; const strength = pref.strength === 'high' ? 3 : pref.strength === 'low' ? 1 : 2; if (type === 'like' || type === 'favorite') { score += strength * 2; reasons.push(`Family signal likes ${target}.`); } if (type === 'dislike') { score -= strength * 3; warnings.push(`Preference risk: ${target} is disliked.`); } } return { score, reasons, warnings }; } function recencyPenalty(meal, recentMeals) { const title = String(meal.name || meal.title || '').toLowerCase().trim(); const category = String(meal.meal_category || '').toLowerCase(); let penalty = 0; for (const recent of toArray(recentMeals)) { const recentTitle = String(recent.name || recent.title || '').toLowerCase().trim(); if (title && recentTitle === title) penalty -= 8; if (category && category === String(recent.meal_category || '').toLowerCase()) penalty -= 2; } return penalty; } function dayLabels(dayContext = {}) { return new Set([ ...toArray(dayContext.labels), dayContext.busyness === 'high' ? 'busy_evening' : null, dayContext.energy === 'low' ? 'low_energy' : null, dayContext.guests ? 'guests' : null, dayContext.soloParent ? 'solo_parent' : null, ].filter(Boolean)); } export function scoreMealSuggestions({ meals = [], dayContext = {}, preferences = [], inventory = [], recentMeals = [], pantryStaples = [], today } = {}) { const labels = dayLabels(dayContext); const inv = inventoryMap(inventory); return toArray(meals).map((meal) => { const blocked = hardBlocked(meal, preferences); const mealName = meal.name || meal.title || 'Untitled meal'; const tags = tagsFor(meal); const ingredients = mealIngredients(meal); const groceryDelta = groceryDeltaFor(meal, inventory, pantryStaples); const reasons = []; const warnings = []; let score = 50; if (blocked) { return { mealId: meal.id, mealName, score: -9999, fitLabels: ['blocked'], reasons: [], warnings: [blocked], groceryDelta }; } const effort = meal.effort || (tags.has('quick') ? 'easy' : 'normal'); const cleanup = meal.cleanup || 'medium'; const interruptionTolerance = meal.interruptionTolerance || meal.interruption_tolerance || (tags.has('leftovers') ? 'high' : 'medium'); const activeMinutes = Number(meal.activeMinutes ?? meal.active_minutes ?? meal.active_time_minutes ?? 30); const totalMinutes = Number(meal.totalMinutes ?? meal.total_minutes ?? 45); score += EFFORT_SCORE[effort] ?? 0; score += CLEANUP_SCORE[cleanup] ?? 0; score += INTERRUPTION_SCORE[interruptionTolerance] ?? 0; score += KID_SCORE[meal.kidFit || meal.kid_fit] ?? 0; if (labels.has('busy_evening') || labels.has('late_activity') || dayContext.dinnerWindowMinutes <= 30) { if (activeMinutes <= 15) { score += 10; reasons.push(`Fits a tight day: ${activeMinutes} min active time.`); } else if (activeMinutes > 30) { score -= 12; warnings.push(`Busy-day mismatch: ${activeMinutes} min active time.`); } if (cleanup === 'low') { score += 5; reasons.push('Low cleanup helps on a busy evening.'); } if (cleanup === 'high') { score -= 8; warnings.push('High cleanup on a busy evening.'); } if (interruptionTolerance === 'high') { score += 5; reasons.push('Can survive interruptions.'); } if (interruptionTolerance === 'low') { score -= 8; warnings.push('Needs continuous attention on an interruption-heavy day.'); } } if (labels.has('low_energy') || labels.has('solo_parent')) { if (effort === 'survival' || effort === 'easy') { score += 8; reasons.push(`Low-energy fit: ${effort} effort.`); } if (effort === 'project') { score -= 18; warnings.push('Project meal is a bad fit for low parent energy.'); } } if (labels.has('guests')) { if (meal.guestFit || meal.guest_fit || meal.batchFriendly || meal.batch_friendly) { score += 35; reasons.push('Guest-capable / batch-friendly.'); } else { score -= 15; warnings.push('Not marked as guest-capable.'); } } if (dayContext.weather === 'hot' && tags.has('oven')) { score -= 4; warnings.push('Oven-heavy meal on a hot day.'); } if ((dayContext.weather === 'cold' || dayContext.weather === 'rainy') && (tags.has('soup') || tags.has('cozy'))) { score += 3; reasons.push('Season/weather tie-breaker fits.'); } for (const ingredient of ingredients) { const available = inv.get(ingredient.normalizedName); if (!available) continue; score += 3; const due = daysUntil(available.expiresOn, today); if (due !== null && due <= 1) { score += 8; reasons.push(`Uses ${ingredient.normalizedName} before it expires.`); } else { reasons.push(`Uses available ${ingredient.normalizedName}.`); } } const pref = preferenceEffect(meal, preferences); score += pref.score; reasons.push(...pref.reasons); warnings.push(...pref.warnings); const repetition = recencyPenalty(meal, recentMeals); if (repetition < 0) { score += repetition; warnings.push('Recently eaten / similar category, so it is demoted for variety.'); } if (groceryDelta.newItems <= 2) { score += 7; reasons.push(`Small grocery delta: ${groceryDelta.newItems} new items.`); } else if (groceryDelta.newItems >= 8) { score -= 8; warnings.push(`Heavy grocery burden: ${groceryDelta.newItems} new items.`); } const fitLabels = []; if (activeMinutes <= 15) fitLabels.push('quick-active-time'); if (cleanup === 'low') fitLabels.push('low-cleanup'); if (interruptionTolerance === 'high') fitLabels.push('interruption-friendly'); if (groceryDelta.usesInventory.length) fitLabels.push('uses-inventory'); if (groceryDelta.newItems <= 2) fitLabels.push('low-grocery-delta'); if (meal.guestFit || meal.guest_fit) fitLabels.push('guest-capable'); return { mealId: meal.id, mealName, score: Math.round(score), fitLabels, reasons: [...new Set(reasons)].slice(0, 5), warnings: [...new Set(warnings)].slice(0, 5), groceryDelta, totalMinutes, activeMinutes, }; }).sort((a, b) => b.score - a.score || String(a.mealName).localeCompare(String(b.mealName))); } export function generateGroceryList(selectedMeals = [], { inventory = [], pantryStaples = [] } = {}) { const inv = inventoryMap(inventory); const pantry = new Set(toArray(pantryStaples).map(normalizeIngredientName).filter(Boolean)); const items = new Map(); const coveredByInventory = []; for (const meal of toArray(selectedMeals)) { for (const ingredient of mealIngredients(meal)) { const normalized = ingredient.normalizedName; if (!normalized) continue; if (inv.has(normalized)) { coveredByInventory.push({ name: normalized, sourceMealId: meal.id, sourceMealName: meal.name || meal.title }); continue; } if (pantry.has(normalized)) continue; const existing = items.get(normalized) || { name: normalized, quantity: ingredient.quantity || null, category: ingredient.category || null, sourceMeals: [] }; existing.sourceMeals.push({ id: meal.id, name: meal.name || meal.title }); items.set(normalized, existing); } } return { items: [...items.values()].sort((a, b) => a.name.localeCompare(b.name)), coveredByInventory, }; }