using System.Text.Json; using MealPlan.Api.Data; using MealPlan.Api.DTOs.Diet; using MealPlan.Api.DTOs.Generators; using MealPlan.Api.Models; using MealPlan.Api.Services.AI; using Microsoft.EntityFrameworkCore; namespace MealPlan.Api.Services.Generators; public interface IDietGeneratorService { Task GetProfileAsync(Guid userId, CancellationToken ct = default); Task UpsertProfileAsync(Guid userId, DietUserProfileDto dto, CancellationToken ct = default); Task CreateSessionAsync(Guid userId, CreateDietGeneratorSessionDto dto, CancellationToken ct = default); Task GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default); Task ProposeMacrosAsync(Guid userId, int sessionId, CancellationToken ct = default); Task AcceptMacrosAsync(Guid userId, int sessionId, AcceptMacrosDto dto, CancellationToken ct = default); Task GeneratePlanAsync(Guid userId, int sessionId, CancellationToken ct = default); Task RegenerateMealAsync(Guid userId, int sessionId, RegenerateDietMealDto dto, CancellationToken ct = default); Task UpdateDraftMealAsync(Guid userId, int sessionId, int mealId, UpdateDraftMealDto dto, CancellationToken ct = default); Task CommitAsync(Guid userId, int sessionId, CancellationToken ct = default); } public class DietGeneratorService : IDietGeneratorService { private readonly AppDbContext _db; private readonly IOpenAiChatService _openAi; public DietGeneratorService(AppDbContext db, IOpenAiChatService openAi) { _db = db; _openAi = openAi; } public async Task GetProfileAsync(Guid userId, CancellationToken ct = default) { var profile = await _db.DietUserProfiles.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == userId, ct); return profile is null ? null : MapProfile(profile); } public async Task UpsertProfileAsync(Guid userId, DietUserProfileDto dto, CancellationToken ct = default) { var profile = await _db.DietUserProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct); if (profile is null) { profile = new DietUserProfile { UserId = userId, CreatedAt = DateTime.UtcNow }; _db.DietUserProfiles.Add(profile); } profile.HeightCm = dto.HeightCm; profile.WeightKg = dto.WeightKg; profile.Age = dto.Age; profile.Sex = dto.Sex; profile.ActivityLevel = dto.ActivityLevel; profile.Goal = dto.Goal; profile.AllergiesNotes = dto.AllergiesNotes; profile.DislikedFoods = dto.DislikedFoods; profile.MealsPerDayMask = dto.MealsPerDayMask; profile.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); return MapProfile(profile); } public async Task CreateSessionAsync( Guid userId, CreateDietGeneratorSessionDto dto, CancellationToken ct = default) { var profile = await _db.DietUserProfiles.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == userId, ct) ?? throw new InvalidOperationException("Save your diet profile before starting a generator session."); var session = new DietGeneratorSession { UserId = userId, Status = DietGeneratorStatuses.MacrosPending, PlanStartDate = dto.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow), PlanDayCount = dto.PlanDayCount, CalorieToleranceKcal = dto.CalorieToleranceKcal, CreatedAt = DateTime.UtcNow, }; _db.DietGeneratorSessions.Add(session); await _db.SaveChangesAsync(ct); var calculated = TdeeCalculator.Calculate( profile.HeightCm, profile.WeightKg, profile.Age, profile.Sex, profile.ActivityLevel, profile.Goal); session.ProposedCalories = calculated.Calories; session.ProposedProteinG = calculated.ProteinG; session.ProposedFatG = calculated.FatG; session.ProposedCarbsG = calculated.CarbsG; session.MacroRationale = calculated.Rationale; session.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); return await MapSessionAsync(session.Id, ct) ?? throw new InvalidOperationException("Session not found."); } public async Task GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default) { await BackfillMissingDraftIngredientsAsync(userId, sessionId, ct); return await MapSessionAsync(sessionId, userId, ct); } public async Task ProposeMacrosAsync(Guid userId, int sessionId, CancellationToken ct = default) { var session = await GetEditableSessionAsync(userId, sessionId, ct); if (session is null) return null; var profile = await _db.DietUserProfiles.AsNoTracking().FirstAsync(p => p.UserId == userId, ct); var calculated = TdeeCalculator.Calculate( profile.HeightCm, profile.WeightKg, profile.Age, profile.Sex, profile.ActivityLevel, profile.Goal); if (_openAi.IsConfigured) { try { var system = """ You are a sports nutrition assistant. Respond ONLY with JSON: {"calories":number,"proteinG":number,"fatG":number,"carbsG":number,"rationale":"string"} Keep calories within 10% of the provided baseline. Use realistic macro splits. """; var user = JsonSerializer.Serialize(new { baseline = calculated, profile = new { profile.HeightCm, profile.WeightKg, profile.Age, profile.Sex, profile.ActivityLevel, profile.Goal, profile.AllergiesNotes, }, }); var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Macros, ct); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; session.ProposedCalories = root.TryGetProperty("calories", out var c) ? c.GetInt32() : calculated.Calories; session.ProposedProteinG = root.TryGetProperty("proteinG", out var p) ? p.GetDecimal() : calculated.ProteinG; session.ProposedFatG = root.TryGetProperty("fatG", out var f) ? f.GetDecimal() : calculated.FatG; session.ProposedCarbsG = root.TryGetProperty("carbsG", out var cb) ? cb.GetDecimal() : calculated.CarbsG; session.MacroRationale = root.TryGetProperty("rationale", out var r) ? r.GetString() : calculated.Rationale; } catch { ApplyCalculatedMacros(session, calculated); } } else { ApplyCalculatedMacros(session, calculated); } session.Status = DietGeneratorStatuses.MacrosPending; session.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); return await MapSessionAsync(sessionId, ct); } public async Task AcceptMacrosAsync( Guid userId, int sessionId, AcceptMacrosDto dto, CancellationToken ct = default) { var session = await GetEditableSessionAsync(userId, sessionId, ct); if (session is null) return null; if (dto.Calories.HasValue) session.ProposedCalories = dto.Calories; if (dto.ProteinG.HasValue) session.ProposedProteinG = dto.ProteinG; if (dto.FatG.HasValue) session.ProposedFatG = dto.FatG; if (dto.CarbsG.HasValue) session.ProposedCarbsG = dto.CarbsG; session.Status = DietGeneratorStatuses.MacrosAccepted; session.MacrosAcceptedAt = DateTime.UtcNow; session.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); return await MapSessionAsync(sessionId, ct); } public async Task GeneratePlanAsync(Guid userId, int sessionId, CancellationToken ct = default) { var session = await GetEditableSessionAsync(userId, sessionId, ct); if (session is null || !session.ProposedCalories.HasValue) return null; var profile = await _db.DietUserProfiles.AsNoTracking().FirstAsync(p => p.UserId == userId, ct); var pool = await LoadRecipePoolAsync(userId, ct); if (pool.Count == 0) { throw new InvalidOperationException("No recipes with complete macros are available for planning."); } var mealCategories = MealPlanOptimizer.MealCategoriesFromMask(profile.MealsPerDayMask); var requiredUnique = MealPlanOptimizer.RequiredUniqueRecipes(session.PlanDayCount, mealCategories); if (pool.Count < requiredUnique) { throw new InvalidOperationException( $"Need at least {requiredUnique} unique recipes for a {session.PlanDayCount}-day plan " + $"({mealCategories.Count} meals/day), but only {pool.Count} are available."); } var start = session.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow); var usedRecipes = new HashSet(); var allSlots = new List(); if (_openAi.IsConfigured) { try { allSlots = await GeneratePlanWithAiAsync(session, pool, mealCategories, start, usedRecipes, ct); } catch { allSlots = BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes); } } else { allSlots = BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes); } MealPlanOptimizer.EnsureWeeklyUniqueRecipes( allSlots, pool, mealCategories, session.ProposedCalories!.Value, session.CalorieToleranceKcal); _db.DietGeneratorDraftMeals.RemoveRange( await _db.DietGeneratorDraftMeals.Where(m => m.SessionId == sessionId).ToListAsync(ct)); var recipes = pool.ToDictionary(r => r.Id); foreach (var slot in allSlots) { if (!recipes.TryGetValue(slot.RecipeId, out var recipe)) continue; _db.DietGeneratorDraftMeals.Add(new DietGeneratorDraftMeal { SessionId = sessionId, PlanDate = slot.PlanDate, MealCategory = slot.MealCategory, RecipeId = slot.RecipeId, Portions = slot.Portions, Calories = slot.TotalCalories, ProteinG = Math.Round(slot.TotalProtein, 2), FatG = Math.Round(slot.TotalFat, 2), CarbsG = Math.Round(slot.TotalCarbs, 2), GenerationVersion = 1, }); } session.Status = DietGeneratorStatuses.PlanDraft; session.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); var draftMeals = await _db.DietGeneratorDraftMeals .Where(m => m.SessionId == sessionId) .ToListAsync(ct); foreach (var draftMeal in draftMeals) { await SyncDraftMealIngredientsAsync(draftMeal.Id, draftMeal.RecipeId, draftMeal.Portions, ct); } await _db.SaveChangesAsync(ct); return await MapSessionAsync(sessionId, ct); } public async Task RegenerateMealAsync( Guid userId, int sessionId, RegenerateDietMealDto dto, CancellationToken ct = default) { var session = await GetEditableSessionAsync(userId, sessionId, ct); if (session is null || !session.ProposedCalories.HasValue) return null; var draftMeals = await _db.DietGeneratorDraftMeals .Where(m => m.SessionId == sessionId) .ToListAsync(ct); var dayMeals = draftMeals.Where(m => m.PlanDate == dto.PlanDate).ToList(); var target = session.ProposedCalories.Value; var lockedCalories = dayMeals.Where(m => m.IsLocked && m.PlanDate == dto.PlanDate).Sum(m => m.Calories ?? 0); var remaining = Math.Max(200, target - lockedCalories); var unlockedSlots = dayMeals.Count(m => !m.IsLocked); var slotTarget = unlockedSlots > 0 ? remaining / unlockedSlots : remaining; var pool = await LoadRecipePoolAsync(userId, ct); var existing = dayMeals.FirstOrDefault(m => m.MealCategory == dto.MealCategory); if (existing is null) return null; if (existing.IsLocked) return await MapSessionAsync(sessionId, ct); var excluded = draftMeals .Where(m => m.Id != existing.Id) .Select(m => m.RecipeId) .ToHashSet(); excluded.Add(existing.RecipeId); if (!MealPlanOptimizer.TryPickGreedyMeal(pool, dto.MealCategory, slotTarget, excluded, out var picked)) { throw new InvalidOperationException( "No other recipes are available for this meal. All suitable options are already used in the plan."); } var recipe = pool.First(r => r.Id == picked.RecipeId); existing.RecipeId = picked.RecipeId; existing.Portions = picked.Portions; existing.Calories = picked.TotalCalories; existing.ProteinG = Math.Round(picked.TotalProtein, 2); existing.FatG = Math.Round(picked.TotalFat, 2); existing.CarbsG = Math.Round(picked.TotalCarbs, 2); existing.GenerationVersion++; var slots = dayMeals.Select(m => new DraftMealSlot { PlanDate = m.PlanDate, MealCategory = m.MealCategory, RecipeId = m.RecipeId, Portions = m.Portions, CaloriesPerPortion = recipe.Calories, FatPerPortion = recipe.Fat, ProteinPerPortion = recipe.Protein, CarbsPerPortion = recipe.Carbs, IsLocked = m.IsLocked, }).ToList(); foreach (var slot in slots) { var r = pool.First(x => x.Id == slot.RecipeId); slot.CaloriesPerPortion = r.Calories; slot.ProteinPerPortion = r.Protein; slot.FatPerPortion = r.Fat; slot.CarbsPerPortion = r.Carbs; } MealPlanOptimizer.BalanceDay(slots, target, session.CalorieToleranceKcal); foreach (var slot in slots) { var entity = dayMeals.First(m => m.MealCategory == slot.MealCategory); var r = pool.First(x => x.Id == slot.RecipeId); entity.Portions = slot.Portions; entity.Calories = slot.TotalCalories; entity.ProteinG = Math.Round(slot.TotalProtein, 2); entity.FatG = Math.Round(slot.TotalFat, 2); entity.CarbsG = Math.Round(slot.TotalCarbs, 2); } session.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); await SyncDraftMealIngredientsAsync(existing.Id, existing.RecipeId, existing.Portions, ct); await _db.SaveChangesAsync(ct); return await MapSessionAsync(sessionId, ct); } public async Task UpdateDraftMealAsync( Guid userId, int sessionId, int mealId, UpdateDraftMealDto dto, CancellationToken ct = default) { var session = await GetEditableSessionAsync(userId, sessionId, ct); if (session is null) return null; var meal = await _db.DietGeneratorDraftMeals .FirstOrDefaultAsync(m => m.Id == mealId && m.SessionId == sessionId, ct); if (meal is null) return null; if (dto.IsLocked.HasValue) meal.IsLocked = dto.IsLocked.Value; if (dto.RecipeId.HasValue) { var recipe = await _db.Recipes.AsNoTracking() .FirstOrDefaultAsync(r => r.Id == dto.RecipeId && r.IsActive && !r.IsDraft, ct); if (recipe is null || !recipe.Calories.HasValue) return null; meal.RecipeId = recipe.Id; meal.Calories = (int)Math.Round(recipe.Calories.Value * meal.Portions); meal.ProteinG = recipe.Protein.HasValue ? Math.Round(recipe.Protein.Value * meal.Portions, 2) : null; meal.FatG = recipe.Fat.HasValue ? Math.Round(recipe.Fat.Value * meal.Portions, 2) : null; meal.CarbsG = recipe.Carbs.HasValue ? Math.Round(recipe.Carbs.Value * meal.Portions, 2) : null; } if (dto.Portions.HasValue) { meal.Portions = dto.Portions.Value; var recipe = await _db.Recipes.AsNoTracking().FirstAsync(r => r.Id == meal.RecipeId, ct); meal.Calories = recipe.Calories.HasValue ? (int)Math.Round(recipe.Calories.Value * meal.Portions) : null; meal.ProteinG = recipe.Protein.HasValue ? Math.Round(recipe.Protein.Value * meal.Portions, 2) : null; meal.FatG = recipe.Fat.HasValue ? Math.Round(recipe.Fat.Value * meal.Portions, 2) : null; meal.CarbsG = recipe.Carbs.HasValue ? Math.Round(recipe.Carbs.Value * meal.Portions, 2) : null; } session.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); await SyncDraftMealIngredientsAsync(meal.Id, meal.RecipeId, meal.Portions, ct); await _db.SaveChangesAsync(ct); return await MapSessionAsync(sessionId, ct); } public async Task CommitAsync(Guid userId, int sessionId, CancellationToken ct = default) { var session = await _db.DietGeneratorSessions .Include(s => s.DraftMeals) .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct); if (session is null || session.Status != DietGeneratorStatuses.PlanDraft) return null; if (!session.ProposedCalories.HasValue || session.DraftMeals.Count == 0) return null; var start = session.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow); var end = start.AddDays(session.PlanDayCount - 1); var goals = await _db.UserDailyGoals.FirstOrDefaultAsync(g => g.UserId == userId, ct); if (goals is null) { goals = new UserDailyGoal { UserId = userId, CreatedAt = DateTime.UtcNow }; _db.UserDailyGoals.Add(goals); } goals.CalorieGoal = session.ProposedCalories; goals.ProteinGoalG = session.ProposedProteinG; goals.FatGoalG = session.ProposedFatG; goals.CarbsGoalG = session.ProposedCarbsG; goals.UpdatedAt = DateTime.UtcNow; var existingEntries = await _db.MealPlanEntries .Where(e => e.UserId == userId && e.PlanDate >= start && e.PlanDate <= end) .ToListAsync(ct); _db.MealPlanEntries.RemoveRange(existingEntries); foreach (var draft in session.DraftMeals) { _db.MealPlanEntries.Add(new MealPlanEntry { UserId = userId, PlanDate = draft.PlanDate, MealCategory = draft.MealCategory, RecipeId = draft.RecipeId, Portions = draft.Portions, CreatedAt = DateTime.UtcNow, }); } session.Status = DietGeneratorStatuses.Committed; session.CommittedAt = DateTime.UtcNow; session.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); return new CommitDietPlanResultDto { SessionId = sessionId, PlanStartDate = start, PlanEndDate = end, MealsWritten = session.DraftMeals.Count, }; } private async Task BackfillMissingDraftIngredientsAsync(Guid userId, int sessionId, CancellationToken ct) { var session = await _db.DietGeneratorSessions.AsNoTracking() .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct); if (session?.Status != DietGeneratorStatuses.PlanDraft) return; var meals = await _db.DietGeneratorDraftMeals .Where(m => m.SessionId == sessionId) .ToListAsync(ct); if (meals.Count == 0) return; foreach (var meal in meals) { await SyncDraftMealIngredientsAsync(meal.Id, meal.RecipeId, meal.Portions, ct); } await _db.SaveChangesAsync(ct); } private async Task SyncDraftMealIngredientsAsync( int draftMealId, int recipeId, decimal portions, CancellationToken ct) { var existing = await _db.DietGeneratorDraftMealIngredients .Where(i => i.DraftMealId == draftMealId) .ToListAsync(ct); _db.DietGeneratorDraftMealIngredients.RemoveRange(existing); var recipeIngredients = await _db.Ingredients.AsNoTracking() .Where(i => i.RecipeId == recipeId) .OrderBy(i => i.SortOrder) .ThenBy(i => i.Id) .ToListAsync(ct); foreach (var ingredient in recipeIngredients) { var (scaledGrams, scaledUnit) = IngredientScalingHelper.ScaleForDietPlan( ingredient.AmountGrams, ingredient.Unit, portions); _db.DietGeneratorDraftMealIngredients.Add(new DietGeneratorDraftMealIngredient { DraftMealId = draftMealId, SourceIngredientId = ingredient.Id, Name = ingredient.Name, AmountGrams = scaledGrams, Unit = scaledUnit, SortOrder = ingredient.SortOrder, }); } } private static void ApplyCalculatedMacros(DietGeneratorSession session, TdeeCalculator.MacroTargets calculated) { session.ProposedCalories = calculated.Calories; session.ProposedProteinG = calculated.ProteinG; session.ProposedFatG = calculated.FatG; session.ProposedCarbsG = calculated.CarbsG; session.MacroRationale = calculated.Rationale; } private List BuildGreedyPlan( DietGeneratorSession session, IReadOnlyList pool, IReadOnlyList mealCategories, DateOnly start, HashSet usedRecipes) { var allSlots = new List(); for (var day = 0; day < session.PlanDayCount; day++) { var date = start.AddDays(day); var daySlots = MealPlanOptimizer.BuildGreedyDay( date, pool, mealCategories, session.ProposedCalories!.Value, session.CalorieToleranceKcal, usedRecipes); allSlots.AddRange(daySlots); } return allSlots; } private async Task> GeneratePlanWithAiAsync( DietGeneratorSession session, IReadOnlyList pool, IReadOnlyList mealCategories, DateOnly start, HashSet usedRecipes, CancellationToken ct) { var compact = pool.Take(120).Select(r => new { r.Id, r.Name, r.MealCategory, r.Calories, r.Protein, r.Fat, r.Carbs, }); var system = """ You are a meal planner. Pick ONLY recipe ids from the provided catalog. Respond JSON: {"days":[{"date":"YYYY-MM-DD","meals":[{"mealCategory":0,"recipeId":1,"portions":1.0}]}]} Each day must use allowed meal categories only. Never reuse the same recipeId anywhere in the plan — each recipe may appear at most once across all days. """; var user = JsonSerializer.Serialize(new { targetCaloriesPerDay = session.ProposedCalories, toleranceKcal = session.CalorieToleranceKcal, mealCategories, planStartDate = start.ToString("yyyy-MM-dd"), planDayCount = session.PlanDayCount, recipes = compact, }); var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Plan, ct); using var doc = JsonDocument.Parse(json); var poolMap = pool.ToDictionary(r => r.Id); var allSlots = new List(); if (!doc.RootElement.TryGetProperty("days", out var daysEl)) return BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes); foreach (var dayEl in daysEl.EnumerateArray()) { var dateStr = dayEl.GetProperty("date").GetString(); if (!DateOnly.TryParse(dateStr, out var date)) continue; var daySlots = new List(); var perSlot = mealCategories.Count > 0 ? session.ProposedCalories!.Value / mealCategories.Count : session.ProposedCalories!.Value; foreach (var mealEl in dayEl.GetProperty("meals").EnumerateArray()) { var category = mealEl.GetProperty("mealCategory").GetInt32(); var recipeId = mealEl.GetProperty("recipeId").GetInt32(); DraftMealSlot slot; if (!poolMap.TryGetValue(recipeId, out var recipe) || usedRecipes.Contains(recipeId)) { slot = MealPlanOptimizer.PickGreedyMeal(pool, category, perSlot, usedRecipes); slot.PlanDate = date; } else { var portions = mealEl.TryGetProperty("portions", out var p) ? p.GetDecimal() : 1m; slot = new DraftMealSlot { PlanDate = date, MealCategory = category, RecipeId = recipeId, Portions = Math.Clamp(portions, 0.5m, 2.5m), CaloriesPerPortion = recipe.Calories, ProteinPerPortion = recipe.Protein, FatPerPortion = recipe.Fat, CarbsPerPortion = recipe.Carbs, }; } daySlots.Add(slot); usedRecipes.Add(slot.RecipeId); } if (daySlots.Count == 0) continue; MealPlanOptimizer.BalanceDay(daySlots, session.ProposedCalories!.Value, session.CalorieToleranceKcal); allSlots.AddRange(daySlots); } if (allSlots.Count == 0) return BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes); return allSlots; } private async Task> LoadRecipePoolAsync(Guid userId, CancellationToken ct) { return await _db.Recipes.AsNoTracking() .Where(r => r.IsActive && !r.IsDraft && r.Calories.HasValue) .Where(r => r.CreatedByUserId == null || r.CreatedByUserId == userId) .Select(r => new RecipeCandidate( r.Id, r.Name, r.MealCategory, r.Calories!.Value, r.Protein ?? 0, r.Fat ?? 0, r.Carbs ?? 0)) .ToListAsync(ct); } private async Task GetEditableSessionAsync(Guid userId, int sessionId, CancellationToken ct) => await _db.DietGeneratorSessions.FirstOrDefaultAsync( s => s.Id == sessionId && s.UserId == userId && s.Status != DietGeneratorStatuses.Committed, ct); private Task MapSessionAsync(int sessionId, CancellationToken ct) => MapSessionAsync(sessionId, null, ct); private async Task MapSessionAsync(int sessionId, Guid? userId, CancellationToken ct) { var query = _db.DietGeneratorSessions.AsNoTracking() .Include(s => s.DraftMeals) .ThenInclude(m => m.Recipe) .ThenInclude(r => r!.Steps) .Include(s => s.DraftMeals) .ThenInclude(m => m.PlannedIngredients) .Where(s => s.Id == sessionId); if (userId.HasValue) query = query.Where(s => s.UserId == userId.Value); var session = await query.FirstOrDefaultAsync(ct); if (session is null) return null; var meals = session.DraftMeals .OrderBy(m => m.PlanDate) .ThenBy(m => m.MealCategory) .Select(m => new DietGeneratorDraftMealDto { Id = m.Id, PlanDate = m.PlanDate, MealCategory = m.MealCategory, RecipeId = m.RecipeId, RecipeName = m.Recipe?.Name ?? string.Empty, Portions = m.Portions, Calories = m.Calories, ProteinG = m.ProteinG, FatG = m.FatG, CarbsG = m.CarbsG, IsLocked = m.IsLocked, PrepTimeMinutes = m.Recipe?.PrepTimeMinutes, Ingredients = m.PlannedIngredients .OrderBy(i => i.SortOrder) .ThenBy(i => i.Id) .Select(i => new DietGeneratorDraftMealIngredientDto { Id = i.Id, Name = i.Name, AmountGrams = i.AmountGrams, Unit = i.Unit, SortOrder = i.SortOrder, SourceIngredientId = i.SourceIngredientId, }) .ToList(), Steps = (m.Recipe?.Steps ?? []) .OrderBy(s => s.StepNumber) .Select(s => new DietGeneratorDraftMealStepDto { Id = s.Id, StepNumber = s.StepNumber, Description = s.Description, }) .ToList(), }) .ToList(); var summaries = meals .GroupBy(m => m.PlanDate) .Select(g => new DietGeneratorDaySummaryDto { PlanDate = g.Key, TotalCalories = g.Sum(x => x.Calories ?? 0), TargetCalories = session.ProposedCalories ?? 0, WithinTolerance = session.ProposedCalories.HasValue && Math.Abs(g.Sum(x => x.Calories ?? 0) - session.ProposedCalories.Value) <= session.CalorieToleranceKcal, }) .ToList(); return new DietGeneratorSessionDto { Id = session.Id, Status = session.Status, ProposedMacros = session.ProposedCalories.HasValue ? new MacroProposalDto { Calories = session.ProposedCalories.Value, ProteinG = session.ProposedProteinG ?? 0, FatG = session.ProposedFatG ?? 0, CarbsG = session.ProposedCarbsG ?? 0, Rationale = session.MacroRationale, } : null, PlanStartDate = session.PlanStartDate, PlanDayCount = session.PlanDayCount, CalorieToleranceKcal = session.CalorieToleranceKcal, DraftMeals = meals, DaySummaries = summaries, }; } private static DietUserProfileDto MapProfile(DietUserProfile profile) => new() { HeightCm = profile.HeightCm, WeightKg = profile.WeightKg, Age = profile.Age, Sex = profile.Sex, ActivityLevel = profile.ActivityLevel, Goal = profile.Goal, AllergiesNotes = profile.AllergiesNotes, DislikedFoods = profile.DislikedFoods, MealsPerDayMask = profile.MealsPerDayMask, }; }