* Extended functionalities
* Added different UIs
This commit is contained in:
850
MealPlan.Api/Services/DietTrackingService.cs
Normal file
850
MealPlan.Api/Services/DietTrackingService.cs
Normal file
@@ -0,0 +1,850 @@
|
||||
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<UserDailyGoalsDto?> 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<UserDailyGoalsDto> 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<DailySummaryDto> 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<IReadOnlyList<MealConsumptionDto>> 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<MealConsumptionDto?> 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<MealPreviewDto?> 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<IReadOnlyList<DietSuggestionDto>> 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<DietSuggestionDto>();
|
||||
|
||||
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<MealMacroSnapshotDto?> 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<bool> 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<IReadOnlyList<DrinkCatalogItemDto>> 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<IReadOnlyList<DrinkConsumptionDto>> 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<DrinkConsumptionDto?> 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<bool> 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<IReadOnlyList<DietReminderDto>> 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<DietReminderDto?> 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<DietReminderDto?> 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<bool> 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<IReadOnlyList<DueReminderDto>> 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<IReadOnlyList<HistoryDayDto>> 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<HistoryDayDto>();
|
||||
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<IReadOnlyList<RecentMealDto>> 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<string>();
|
||||
var result = new List<RecentMealDto>();
|
||||
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<CopyMealsResultDto?> 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<IReadOnlyList<FavoriteRecipeDto>> 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<FavoriteRecipeDto?> 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<bool> 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<IReadOnlyList<FavoriteCatalogItemDto>> 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<FavoriteCatalogItemDto?> 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<bool> 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;
|
||||
}
|
||||
Reference in New Issue
Block a user