using MealPlan.Api.Data; using MealPlan.Api.DTOs.Diet; using MealPlan.Api.Models; using Microsoft.EntityFrameworkCore; namespace MealPlan.Api.Services; public class DietTrackingService : IDietTrackingService { private readonly AppDbContext _db; private readonly IRecipeService _recipeService; public DietTrackingService(AppDbContext db, IRecipeService recipeService) { _db = db; _recipeService = recipeService; } public async Task GetGoalsAsync(Guid userId, CancellationToken ct = default) { var goals = await _db.UserDailyGoals.AsNoTracking() .FirstOrDefaultAsync(g => g.UserId == userId, ct); return goals is null ? null : MapGoals(goals); } public async Task UpsertGoalsAsync(Guid userId, UserDailyGoalsDto dto, CancellationToken ct = default) { var existing = await _db.UserDailyGoals.FirstOrDefaultAsync(g => g.UserId == userId, ct); if (existing is null) { existing = new UserDailyGoal { UserId = userId, CreatedAt = DateTime.UtcNow, }; _db.UserDailyGoals.Add(existing); } existing.CalorieGoal = dto.CalorieGoal; existing.ProteinGoalG = dto.ProteinGoalG; existing.FatGoalG = dto.FatGoalG; existing.CarbsGoalG = dto.CarbsGoalG; existing.WaterGoalMl = dto.WaterGoalMl; existing.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); return MapGoals(existing); } public async Task GetDailySummaryAsync(Guid userId, DateOnly date, CancellationToken ct = default) { var goals = await GetGoalsAsync(userId, ct); var meals = await GetMealsAsync(userId, date, ct); var drinks = await GetDrinksAsync(userId, date, ct); var totalCalories = meals.Sum(m => m.Calories ?? 0); var totalProtein = meals.Sum(m => m.ProteinG ?? 0); var totalFat = meals.Sum(m => m.FatG ?? 0); var totalCarbs = meals.Sum(m => m.CarbsG ?? 0); var totalWater = drinks.Sum(d => d.VolumeMl); return new DailySummaryDto { Date = date, Goals = goals, Calories = BuildIntMacro(goals?.CalorieGoal, totalCalories), ProteinG = BuildDecimalMacro(goals?.ProteinGoalG, totalProtein), FatG = BuildDecimalMacro(goals?.FatGoalG, totalFat), CarbsG = BuildDecimalMacro(goals?.CarbsGoalG, totalCarbs), WaterMl = BuildIntMacro(goals?.WaterGoalMl, totalWater), Meals = meals, Drinks = drinks, }; } public async Task> GetMealsAsync(Guid userId, DateOnly date, CancellationToken ct = default) { var rows = await _db.MealConsumptions.AsNoTracking() .Where(m => m.UserId == userId && m.LogDate == date) .OrderByDescending(m => m.ConsumedAt) .ToListAsync(ct); return rows.Select(MapMeal).ToList(); } public async Task LogMealAsync(Guid userId, LogMealDto dto, CancellationToken ct = default) { var consumedAt = dto.ConsumedAt ?? DateTime.UtcNow; var logDate = dto.LogDate ?? DateOnly.FromDateTime(consumedAt); string name; int? calories; decimal? protein; decimal? fat; decimal? carbs; int? recipeId = dto.RecipeId; if (dto.RecipeId.HasValue) { var recipe = await _recipeService.GetRecipeByIdAsync(dto.RecipeId.Value, ct); if (recipe is null) { return null; } name = recipe.Name; calories = ScaleInt(recipe.Calories, dto.Portions); protein = ScaleDecimal(recipe.Protein, dto.Portions); fat = ScaleDecimal(recipe.Fat, dto.Portions); carbs = ScaleDecimal(recipe.Carbs, dto.Portions); if (dto.Calories.HasValue) calories = dto.Calories; if (dto.ProteinG.HasValue) protein = dto.ProteinG; if (dto.FatG.HasValue) fat = dto.FatG; if (dto.CarbsG.HasValue) carbs = dto.CarbsG; } else if (dto.CatalogItemId.HasValue && dto.Grams is > 0) { var catalog = await _db.IngredientNutritionCatalog.AsNoTracking() .FirstOrDefaultAsync(i => i.Id == dto.CatalogItemId && i.IsActive, ct); if (catalog is null) return null; name = string.IsNullOrWhiteSpace(dto.Name) ? catalog.Name : dto.Name.Trim(); var scale = dto.Grams.Value / 100m; calories = (int)Math.Round(catalog.CaloriesPer100G * scale); protein = Math.Round(catalog.ProteinGPer100G * scale, 2); fat = Math.Round(catalog.FatGPer100G * scale, 2); carbs = Math.Round(catalog.CarbsGPer100G * scale, 2); recipeId = null; } else if (!string.IsNullOrWhiteSpace(dto.Name)) { name = dto.Name.Trim(); calories = dto.Calories; protein = dto.ProteinG; fat = dto.FatG; carbs = dto.CarbsG; recipeId = null; } else { return null; } var entity = new MealConsumption { UserId = userId, RecipeId = recipeId, MealCategory = dto.MealCategory, LogDate = logDate, ConsumedAt = consumedAt, Name = name, Portions = dto.Portions, Calories = calories, ProteinG = protein, FatG = fat, CarbsG = carbs, Notes = dto.Notes, CreatedAt = DateTime.UtcNow, }; _db.MealConsumptions.Add(entity); await _db.SaveChangesAsync(ct); return MapMeal(entity); } public async Task PreviewMealAsync( Guid userId, DateOnly date, LogMealDto dto, CancellationToken ct = default) { var summary = await GetDailySummaryAsync(userId, date, ct); var snapshot = await ResolveMealSnapshotAsync(dto, ct); if (snapshot is null) return null; return new MealPreviewDto { Meal = snapshot, RemainingAfter = BuildRemainingAfter(summary, snapshot), }; } public async Task> GetMealSuggestionsAsync( Guid userId, DateOnly date, int limit = 5, CancellationToken ct = default) { limit = Math.Clamp(limit, 1, 10); var summary = await GetDailySummaryAsync(userId, date, ct); var items = await _db.IngredientNutritionCatalog.AsNoTracking() .Include(i => i.Category) .Where(i => i.IsActive && i.Category!.IsActive) .ToListAsync(ct); if (items.Count == 0) return Array.Empty(); var reasonKey = PickPrimaryDeficit(summary); const int defaultGrams = 150; var scored = items.Select(item => { var scale = defaultGrams / 100m; var calories = (int)Math.Round(item.CaloriesPer100G * scale); var protein = Math.Round(item.ProteinGPer100G * scale, 2); var fat = Math.Round(item.FatGPer100G * scale, 2); var carbs = Math.Round(item.CarbsGPer100G * scale, 2); var score = ScoreSuggestion(summary, reasonKey, calories, protein, fat, carbs); return ( score, Suggestion: new DietSuggestionDto { CatalogItemId = item.Id, Name = item.Name, NamePl = item.NamePl, CategoryCode = item.Category?.Code ?? string.Empty, SuggestedGrams = defaultGrams, Calories = calories, ProteinG = protein, FatG = fat, CarbsG = carbs, ReasonKey = reasonKey, }); }) .OrderByDescending(x => x.score) .ThenBy(x => x.Suggestion.Name) .Take(limit) .Select(x => x.Suggestion) .ToList(); return scored; } private async Task ResolveMealSnapshotAsync(LogMealDto dto, CancellationToken ct) { if (dto.RecipeId.HasValue) { var recipe = await _recipeService.GetRecipeByIdAsync(dto.RecipeId.Value, ct); if (recipe is null) return null; return new MealMacroSnapshotDto { Name = recipe.Name, Portions = dto.Portions, Calories = ScaleInt(recipe.Calories, dto.Portions), ProteinG = ScaleDecimal(recipe.Protein, dto.Portions), FatG = ScaleDecimal(recipe.Fat, dto.Portions), CarbsG = ScaleDecimal(recipe.Carbs, dto.Portions), }; } if (dto.CatalogItemId.HasValue && dto.Grams is > 0) { var catalog = await _db.IngredientNutritionCatalog.AsNoTracking() .FirstOrDefaultAsync(i => i.Id == dto.CatalogItemId && i.IsActive, ct); if (catalog is null) return null; var scale = dto.Grams.Value / 100m; return new MealMacroSnapshotDto { Name = string.IsNullOrWhiteSpace(dto.Name) ? catalog.Name : dto.Name.Trim(), Portions = 1, Calories = (int)Math.Round(catalog.CaloriesPer100G * scale), ProteinG = Math.Round(catalog.ProteinGPer100G * scale, 2), FatG = Math.Round(catalog.FatGPer100G * scale, 2), CarbsG = Math.Round(catalog.CarbsGPer100G * scale, 2), }; } if (!string.IsNullOrWhiteSpace(dto.Name)) { return new MealMacroSnapshotDto { Name = dto.Name.Trim(), Portions = dto.Portions, Calories = dto.Calories, ProteinG = dto.ProteinG, FatG = dto.FatG, CarbsG = dto.CarbsG, }; } return null; } private static RemainingAfterMealDto BuildRemainingAfter(DailySummaryDto summary, MealMacroSnapshotDto meal) { int? RemainingInt(int? goal, int consumed, int? mealValue) => goal.HasValue ? Math.Max(goal.Value - consumed - (mealValue ?? 0), 0) : null; decimal? RemainingDec(decimal? goal, decimal consumed, decimal? mealValue) => goal.HasValue ? Math.Max(goal.Value - consumed - (mealValue ?? 0), 0) : null; return new RemainingAfterMealDto { Calories = RemainingInt(summary.Calories.Goal, summary.Calories.Consumed, meal.Calories), ProteinG = RemainingDec(summary.ProteinG.Goal, summary.ProteinG.Consumed, meal.ProteinG), FatG = RemainingDec(summary.FatG.Goal, summary.FatG.Consumed, meal.FatG), CarbsG = RemainingDec(summary.CarbsG.Goal, summary.CarbsG.Consumed, meal.CarbsG), }; } private static string PickPrimaryDeficit(DailySummaryDto summary) { var deficits = new List<(string Key, decimal Weight)>(); if (summary.ProteinG.Goal is > 0 && summary.ProteinG.Remaining is > 0) { deficits.Add(("highProtein", summary.ProteinG.Remaining.Value / summary.ProteinG.Goal.Value)); } if (summary.CarbsG.Goal is > 0 && summary.CarbsG.Remaining is > 0) { deficits.Add(("needCarbs", summary.CarbsG.Remaining.Value / summary.CarbsG.Goal.Value)); } if (summary.FatG.Goal is > 0 && summary.FatG.Remaining is > 0) { deficits.Add(("needFat", summary.FatG.Remaining.Value / summary.FatG.Goal.Value)); } if (summary.Calories.Goal is > 0 && summary.Calories.Remaining is > 0) { deficits.Add(("needCalories", (decimal)summary.Calories.Remaining.Value / summary.Calories.Goal.Value)); } return deficits.OrderByDescending(d => d.Weight).FirstOrDefault().Key ?? "balanced"; } private static decimal ScoreSuggestion( DailySummaryDto summary, string reasonKey, int calories, decimal protein, decimal fat, decimal carbs) { if (calories <= 0) return 0; return reasonKey switch { "highProtein" => protein * 2m + protein / calories, "needCarbs" => carbs * 2m + carbs / calories, "needFat" => fat * 2m + fat / calories, "needCalories" => calories, _ => protein + carbs + (1000m / Math.Max(calories, 1)), }; } public async Task DeleteMealAsync(Guid userId, int id, CancellationToken ct = default) { var entity = await _db.MealConsumptions.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct); if (entity is null) return false; _db.MealConsumptions.Remove(entity); await _db.SaveChangesAsync(ct); return true; } public async Task> GetDrinkCatalogAsync(CancellationToken ct = default) { return await _db.DrinkCatalog.AsNoTracking() .Where(d => d.IsActive) .OrderBy(d => d.SortOrder) .Select(d => new DrinkCatalogItemDto { Id = d.Id, Code = d.Code, Name = d.Name, DefaultVolumeMl = d.DefaultVolumeMl, CaloriesPer100Ml = d.CaloriesPer100Ml, IconEmoji = d.IconEmoji, }) .ToListAsync(ct); } public async Task> GetDrinksAsync(Guid userId, DateOnly date, CancellationToken ct = default) { var rows = await _db.DrinkConsumptions.AsNoTracking() .Where(d => d.UserId == userId && d.LogDate == date) .OrderByDescending(d => d.ConsumedAt) .ToListAsync(ct); return rows.Select(MapDrink).ToList(); } public async Task LogDrinkAsync(Guid userId, LogDrinkDto dto, CancellationToken ct = default) { var consumedAt = dto.ConsumedAt ?? DateTime.UtcNow; var logDate = dto.LogDate ?? DateOnly.FromDateTime(consumedAt); string name; int? catalogId = dto.DrinkCatalogId; int? calories = dto.Calories; if (dto.DrinkCatalogId.HasValue) { var catalog = await _db.DrinkCatalog.AsNoTracking() .FirstOrDefaultAsync(d => d.Id == dto.DrinkCatalogId && d.IsActive, ct); if (catalog is null) return null; name = dto.Name?.Trim() ?? catalog.Name; if (!calories.HasValue && catalog.CaloriesPer100Ml.HasValue) { calories = (int)Math.Round(catalog.CaloriesPer100Ml.Value * dto.VolumeMl / 100m); } } else { name = dto.Name!.Trim(); catalogId = null; } var entity = new DrinkConsumption { UserId = userId, DrinkCatalogId = catalogId, LogDate = logDate, ConsumedAt = consumedAt, Name = name, VolumeMl = dto.VolumeMl, Calories = calories, CreatedAt = DateTime.UtcNow, }; _db.DrinkConsumptions.Add(entity); await _db.SaveChangesAsync(ct); return MapDrink(entity); } public async Task DeleteDrinkAsync(Guid userId, int id, CancellationToken ct = default) { var entity = await _db.DrinkConsumptions.FirstOrDefaultAsync(d => d.Id == id && d.UserId == userId, ct); if (entity is null) return false; _db.DrinkConsumptions.Remove(entity); await _db.SaveChangesAsync(ct); return true; } public async Task> GetRemindersAsync(Guid userId, CancellationToken ct = default) { var rows = await _db.DietReminders.AsNoTracking() .Where(r => r.UserId == userId) .OrderBy(r => r.TimeOfDay) .ToListAsync(ct); return rows.Select(MapReminder).ToList(); } public async Task CreateReminderAsync(Guid userId, CreateDietReminderDto dto, CancellationToken ct = default) { var entity = new DietReminder { UserId = userId, ReminderType = dto.ReminderType, Title = dto.Title.Trim(), Message = dto.Message?.Trim(), TimeOfDay = dto.TimeOfDay, DaysOfWeekMask = dto.DaysOfWeekMask, MealCategory = dto.MealCategory, IsEnabled = dto.IsEnabled, CreatedAt = DateTime.UtcNow, }; _db.DietReminders.Add(entity); await _db.SaveChangesAsync(ct); return MapReminder(entity); } public async Task UpdateReminderAsync(Guid userId, int id, UpdateDietReminderDto dto, CancellationToken ct = default) { var entity = await _db.DietReminders.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); if (entity is null) return null; entity.ReminderType = dto.ReminderType; entity.Title = dto.Title.Trim(); entity.Message = dto.Message?.Trim(); entity.TimeOfDay = dto.TimeOfDay; entity.DaysOfWeekMask = dto.DaysOfWeekMask; entity.MealCategory = dto.MealCategory; entity.IsEnabled = dto.IsEnabled; entity.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); return MapReminder(entity); } public async Task DeleteReminderAsync(Guid userId, int id, CancellationToken ct = default) { var entity = await _db.DietReminders.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); if (entity is null) return false; _db.DietReminders.Remove(entity); await _db.SaveChangesAsync(ct); return true; } public async Task> GetDueRemindersAsync( Guid userId, DateOnly? date, TimeOnly? time, CancellationToken ct = default) { var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow); var targetTime = time ?? TimeOnly.FromDateTime(DateTime.UtcNow); var dayOfWeek = targetDate.ToDateTime(TimeOnly.MinValue).DayOfWeek; var reminders = await _db.DietReminders.AsNoTracking() .Where(r => r.UserId == userId && r.IsEnabled) .ToListAsync(ct); return reminders .Where(r => ReminderDaysMask.IncludesDay(r.DaysOfWeekMask, dayOfWeek)) .Where(r => r.TimeOfDay.Hour == targetTime.Hour && r.TimeOfDay.Minute == targetTime.Minute) .Select(r => new DueReminderDto { Id = r.Id, ReminderType = r.ReminderType, Title = r.Title, Message = r.Message, TimeOfDay = r.TimeOfDay, MealCategory = r.MealCategory, }) .ToList(); } public async Task> GetHistoryAsync( Guid userId, DateOnly from, DateOnly to, CancellationToken ct = default) { var meals = await _db.MealConsumptions.AsNoTracking() .Where(m => m.UserId == userId && m.LogDate >= from && m.LogDate <= to) .GroupBy(m => m.LogDate) .Select(g => new { Date = g.Key, Calories = g.Sum(m => m.Calories ?? 0), ProteinG = g.Sum(m => m.ProteinG ?? 0), FatG = g.Sum(m => m.FatG ?? 0), CarbsG = g.Sum(m => m.CarbsG ?? 0), MealCount = g.Count(), }) .ToListAsync(ct); var drinks = await _db.DrinkConsumptions.AsNoTracking() .Where(d => d.UserId == userId && d.LogDate >= from && d.LogDate <= to) .GroupBy(d => d.LogDate) .Select(g => new { Date = g.Key, WaterMl = g.Sum(d => d.VolumeMl) }) .ToListAsync(ct); var drinkMap = drinks.ToDictionary(d => d.Date, d => d.WaterMl); var days = new List(); for (var d = from; d <= to; d = d.AddDays(1)) { var meal = meals.FirstOrDefault(m => m.Date == d); days.Add(new HistoryDayDto { Date = d, Calories = meal?.Calories ?? 0, ProteinG = meal?.ProteinG ?? 0, FatG = meal?.FatG ?? 0, CarbsG = meal?.CarbsG ?? 0, WaterMl = drinkMap.GetValueOrDefault(d, 0), MealCount = meal?.MealCount ?? 0, }); } return days; } public async Task> GetRecentMealsAsync( Guid userId, int limit = 10, CancellationToken ct = default) { var rows = await _db.MealConsumptions.AsNoTracking() .Where(m => m.UserId == userId) .OrderByDescending(m => m.ConsumedAt) .Take(Math.Clamp(limit * 3, 10, 100)) .ToListAsync(ct); var seen = new HashSet(); var result = new List(); foreach (var m in rows) { var key = m.RecipeId.HasValue ? $"r:{m.RecipeId}:{m.MealCategory}" : $"n:{m.Name.ToLowerInvariant()}:{m.MealCategory}"; if (!seen.Add(key)) continue; result.Add(new RecentMealDto { RecipeId = m.RecipeId, Name = m.Name, MealCategory = m.MealCategory, Portions = m.Portions, Calories = m.Calories, ProteinG = m.ProteinG, FatG = m.FatG, CarbsG = m.CarbsG, LastLoggedAt = m.ConsumedAt, }); if (result.Count >= limit) break; } return result; } public async Task CopyYesterdayMealsAsync( Guid userId, DateOnly targetDate, CancellationToken ct = default) { var sourceDate = targetDate.AddDays(-1); var sourceMeals = await _db.MealConsumptions .Where(m => m.UserId == userId && m.LogDate == sourceDate) .ToListAsync(ct); if (sourceMeals.Count == 0) { return new CopyMealsResultDto { SourceDate = sourceDate, TargetDate = targetDate, CopiedCount = 0, }; } var now = DateTime.UtcNow; foreach (var src in sourceMeals) { _db.MealConsumptions.Add(new MealConsumption { UserId = userId, RecipeId = src.RecipeId, MealCategory = src.MealCategory, LogDate = targetDate, ConsumedAt = now, Name = src.Name, Portions = src.Portions, Calories = src.Calories, ProteinG = src.ProteinG, FatG = src.FatG, CarbsG = src.CarbsG, Notes = src.Notes, CreatedAt = now, }); } await _db.SaveChangesAsync(ct); return new CopyMealsResultDto { SourceDate = sourceDate, TargetDate = targetDate, CopiedCount = sourceMeals.Count, }; } public async Task> GetFavoriteRecipesAsync( Guid userId, CancellationToken ct = default) { return await _db.UserFavoriteRecipes.AsNoTracking() .Include(f => f.Recipe) .Where(f => f.UserId == userId && f.Recipe!.IsActive) .OrderBy(f => f.Recipe!.Name) .Select(f => new FavoriteRecipeDto { RecipeId = f.RecipeId, Name = f.Recipe!.Name, MealCategory = f.Recipe!.MealCategory, Calories = f.Recipe!.Calories, }) .ToListAsync(ct); } public async Task AddFavoriteRecipeAsync( Guid userId, int recipeId, CancellationToken ct = default) { var recipe = await _db.Recipes.AsNoTracking() .FirstOrDefaultAsync(r => r.Id == recipeId && r.IsActive, ct); if (recipe is null) return null; var exists = await _db.UserFavoriteRecipes .AnyAsync(f => f.UserId == userId && f.RecipeId == recipeId, ct); if (!exists) { _db.UserFavoriteRecipes.Add(new UserFavoriteRecipe { UserId = userId, RecipeId = recipeId, CreatedAt = DateTime.UtcNow, }); await _db.SaveChangesAsync(ct); } return new FavoriteRecipeDto { RecipeId = recipe.Id, Name = recipe.Name, MealCategory = recipe.MealCategory, Calories = recipe.Calories, }; } public async Task RemoveFavoriteRecipeAsync(Guid userId, int recipeId, CancellationToken ct = default) { var fav = await _db.UserFavoriteRecipes .FirstOrDefaultAsync(f => f.UserId == userId && f.RecipeId == recipeId, ct); if (fav is null) return false; _db.UserFavoriteRecipes.Remove(fav); await _db.SaveChangesAsync(ct); return true; } public async Task> GetFavoriteCatalogItemsAsync( Guid userId, CancellationToken ct = default) { return await _db.UserFavoriteCatalogItems.AsNoTracking() .Include(f => f.CatalogItem) .ThenInclude(c => c!.Category) .Where(f => f.UserId == userId && f.CatalogItem!.IsActive) .OrderBy(f => f.CatalogItem!.Name) .Select(f => new FavoriteCatalogItemDto { CatalogItemId = f.CatalogItemId, Name = f.CatalogItem!.Name, NamePl = f.CatalogItem!.NamePl, CategoryCode = f.CatalogItem!.Category!.Code, }) .ToListAsync(ct); } public async Task AddFavoriteCatalogItemAsync( Guid userId, int catalogItemId, CancellationToken ct = default) { var item = await _db.IngredientNutritionCatalog.AsNoTracking() .Include(i => i.Category) .FirstOrDefaultAsync(i => i.Id == catalogItemId && i.IsActive, ct); if (item is null) return null; var exists = await _db.UserFavoriteCatalogItems .AnyAsync(f => f.UserId == userId && f.CatalogItemId == catalogItemId, ct); if (!exists) { _db.UserFavoriteCatalogItems.Add(new UserFavoriteCatalogItem { UserId = userId, CatalogItemId = catalogItemId, CreatedAt = DateTime.UtcNow, }); await _db.SaveChangesAsync(ct); } return new FavoriteCatalogItemDto { CatalogItemId = item.Id, Name = item.Name, NamePl = item.NamePl, CategoryCode = item.Category?.Code ?? string.Empty, }; } public async Task RemoveFavoriteCatalogItemAsync( Guid userId, int catalogItemId, CancellationToken ct = default) { var fav = await _db.UserFavoriteCatalogItems .FirstOrDefaultAsync(f => f.UserId == userId && f.CatalogItemId == catalogItemId, ct); if (fav is null) return false; _db.UserFavoriteCatalogItems.Remove(fav); await _db.SaveChangesAsync(ct); return true; } private static UserDailyGoalsDto MapGoals(UserDailyGoal g) => new() { CalorieGoal = g.CalorieGoal, ProteinGoalG = g.ProteinGoalG, FatGoalG = g.FatGoalG, CarbsGoalG = g.CarbsGoalG, WaterGoalMl = g.WaterGoalMl, }; private static MealConsumptionDto MapMeal(MealConsumption m) => new() { Id = m.Id, RecipeId = m.RecipeId, MealCategory = m.MealCategory, LogDate = m.LogDate, ConsumedAt = m.ConsumedAt, Name = m.Name, Portions = m.Portions, Calories = m.Calories, ProteinG = m.ProteinG, FatG = m.FatG, CarbsG = m.CarbsG, Notes = m.Notes, }; private static DrinkConsumptionDto MapDrink(DrinkConsumption d) => new() { Id = d.Id, DrinkCatalogId = d.DrinkCatalogId, LogDate = d.LogDate, ConsumedAt = d.ConsumedAt, Name = d.Name, VolumeMl = d.VolumeMl, Calories = d.Calories, }; private static DietReminderDto MapReminder(DietReminder r) => new() { Id = r.Id, ReminderType = r.ReminderType, Title = r.Title, Message = r.Message, TimeOfDay = r.TimeOfDay, DaysOfWeekMask = r.DaysOfWeekMask, MealCategory = r.MealCategory, IsEnabled = r.IsEnabled, }; private static MacroRemainingDto BuildIntMacro(int? goal, int consumed) => new() { Goal = goal, Consumed = consumed, Remaining = goal.HasValue ? Math.Max(goal.Value - consumed, 0) : null, ProgressPercent = goal is > 0 ? Math.Round(consumed * 100m / goal.Value, 1) : null, }; private static DecimalMacroRemainingDto BuildDecimalMacro(decimal? goal, decimal consumed) => new() { Goal = goal, Consumed = consumed, Remaining = goal.HasValue ? Math.Max(goal.Value - consumed, 0) : null, ProgressPercent = goal is > 0 ? Math.Round(consumed * 100m / goal.Value, 1) : null, }; private static int? ScaleInt(int? value, decimal portions) => value.HasValue ? (int)Math.Round(value.Value * portions) : null; private static decimal? ScaleDecimal(decimal? value, decimal portions) => value.HasValue ? Math.Round(value.Value * portions, 2) : null; }