namespace MealPlan.Api.Services.Generators; public record RecipeCandidate( int Id, string Name, int MealCategory, int Calories, decimal Protein, decimal Fat, decimal Carbs); public class DraftMealSlot { public DateOnly PlanDate { get; set; } public int MealCategory { get; set; } public int RecipeId { get; set; } public decimal Portions { get; set; } = 1m; public int CaloriesPerPortion { get; set; } public decimal ProteinPerPortion { get; set; } public decimal FatPerPortion { get; set; } public decimal CarbsPerPortion { get; set; } public bool IsLocked { get; set; } public int TotalCalories => (int)Math.Round(CaloriesPerPortion * Portions); public decimal TotalProtein => ProteinPerPortion * Portions; public decimal TotalFat => FatPerPortion * Portions; public decimal TotalCarbs => CarbsPerPortion * Portions; public DraftMealSlot CloneForDate(DateOnly planDate) => new() { PlanDate = planDate, MealCategory = MealCategory, RecipeId = RecipeId, Portions = Portions, CaloriesPerPortion = CaloriesPerPortion, ProteinPerPortion = ProteinPerPortion, FatPerPortion = FatPerPortion, CarbsPerPortion = CarbsPerPortion, IsLocked = IsLocked, }; } public static class MealPlanOptimizer { public static IReadOnlyList MealCategoriesFromMask(int mask) => Enumerable.Range(0, 5).Where(i => (mask & (1 << i)) != 0).ToList(); public static void BalanceDay(IList meals, int targetCalories, int tolerance) { if (meals.Count == 0) return; var unlocked = meals.Where(m => !m.IsLocked).ToList(); if (unlocked.Count == 0) return; var current = meals.Sum(m => m.TotalCalories); if (Math.Abs(current - targetCalories) <= tolerance) return; if (current <= 0) { foreach (var meal in unlocked) { meal.Portions = 1m; } current = meals.Sum(m => m.TotalCalories); } if (current <= 0) return; var scale = (decimal)targetCalories / current; foreach (var meal in unlocked) { meal.Portions = Math.Clamp(Math.Round(meal.Portions * scale, 2), 0.5m, 2.5m); } current = meals.Sum(m => m.TotalCalories); if (Math.Abs(current - targetCalories) <= tolerance) return; var diff = targetCalories - current; var adjustable = unlocked.OrderByDescending(m => m.CaloriesPerPortion).FirstOrDefault(); if (adjustable is null || adjustable.CaloriesPerPortion <= 0) return; var portionDelta = diff / (decimal)adjustable.CaloriesPerPortion; adjustable.Portions = Math.Clamp(Math.Round(adjustable.Portions + portionDelta, 2), 0.5m, 2.5m); } public static bool IsDayWithinTolerance(IEnumerable meals, int targetCalories, int tolerance) => Math.Abs(meals.Sum(m => m.TotalCalories) - targetCalories) <= tolerance; private static RecipeCandidate? FindGreedyCandidate( IReadOnlyList pool, int mealCategory, int targetCaloriesForSlot, HashSet excludedRecipeIds) { var candidates = pool .Where(r => r.MealCategory == mealCategory && !excludedRecipeIds.Contains(r.Id)) .OrderBy(r => Math.Abs(r.Calories - targetCaloriesForSlot)) .ToList(); if (candidates.Count == 0) { candidates = pool .Where(r => !excludedRecipeIds.Contains(r.Id)) .OrderBy(r => Math.Abs(r.Calories - targetCaloriesForSlot)) .ToList(); } return candidates.FirstOrDefault(); } public static bool TryPickGreedyMeal( IReadOnlyList pool, int mealCategory, int targetCaloriesForSlot, HashSet excludedRecipeIds, out DraftMealSlot slot) { var recipe = FindGreedyCandidate(pool, mealCategory, targetCaloriesForSlot, excludedRecipeIds); if (recipe is null) { slot = null!; return false; } var portions = recipe.Calories > 0 ? Math.Clamp(Math.Round((decimal)targetCaloriesForSlot / recipe.Calories, 2), 0.5m, 2.5m) : 1m; slot = new DraftMealSlot { MealCategory = mealCategory, RecipeId = recipe.Id, Portions = portions, CaloriesPerPortion = recipe.Calories, ProteinPerPortion = recipe.Protein, FatPerPortion = recipe.Fat, CarbsPerPortion = recipe.Carbs, }; return true; } public static DraftMealSlot PickGreedyMeal( IReadOnlyList pool, int mealCategory, int targetCaloriesForSlot, HashSet excludedRecipeIds) { if (TryPickGreedyMeal(pool, mealCategory, targetCaloriesForSlot, excludedRecipeIds, out var slot)) { return slot; } throw new InvalidOperationException("No recipes available for meal planning."); } public static IReadOnlyList BuildGreedyDay( DateOnly planDate, IReadOnlyList pool, IReadOnlyList mealCategories, int targetCalories, int tolerance, HashSet excludedRecipeIds) { var perSlot = mealCategories.Count > 0 ? targetCalories / mealCategories.Count : targetCalories; var meals = new List(); foreach (var category in mealCategories) { var slot = PickGreedyMeal(pool, category, perSlot, excludedRecipeIds); slot.PlanDate = planDate; meals.Add(slot); excludedRecipeIds.Add(slot.RecipeId); } BalanceDay(meals, targetCalories, tolerance); return meals; } public static int RequiredUniqueRecipes(int planDayCount, IReadOnlyList mealCategories) => planDayCount * mealCategories.Count; /// /// Replaces duplicate recipe picks so each recipe appears at most once in the plan. /// public static void EnsureWeeklyUniqueRecipes( IList slots, IReadOnlyList pool, IReadOnlyList mealCategories, int targetCalories, int tolerance) { if (slots.Count == 0) return; var used = new HashSet(); var perSlot = mealCategories.Count > 0 ? targetCalories / mealCategories.Count : targetCalories; foreach (var slot in slots.OrderBy(s => s.PlanDate).ThenBy(s => s.MealCategory)) { if (used.Add(slot.RecipeId)) continue; var replacement = PickGreedyMeal(pool, slot.MealCategory, perSlot, used); CopyRecipeIntoSlot(slot, replacement); used.Add(slot.RecipeId); var daySlots = slots.Where(s => s.PlanDate == slot.PlanDate).ToList(); BalanceDay(daySlots, targetCalories, tolerance); } } private static void CopyRecipeIntoSlot(DraftMealSlot target, DraftMealSlot source) { target.RecipeId = source.RecipeId; target.Portions = source.Portions; target.CaloriesPerPortion = source.CaloriesPerPortion; target.ProteinPerPortion = source.ProteinPerPortion; target.FatPerPortion = source.FatPerPortion; target.CarbsPerPortion = source.CarbsPerPortion; } }