diff --git a/MealPlan.Api/DTOs/Recipe/RecalculateRecipeCaloriesDto.cs b/MealPlan.Api/DTOs/Recipe/RecalculateRecipeCaloriesDto.cs
new file mode 100644
index 0000000..e4ed9c9
--- /dev/null
+++ b/MealPlan.Api/DTOs/Recipe/RecalculateRecipeCaloriesDto.cs
@@ -0,0 +1,25 @@
+namespace MealPlan.Api.DTOs.Recipe;
+
+public class RecalculateRecipeCaloriesRequestDto
+{
+ public int TargetCalories { get; set; }
+ public int CalorieToleranceKcal { get; set; } = 10;
+ public bool Apply { get; set; }
+}
+
+public class RecalculateRecipeCaloriesResultDto
+{
+ public int RecipeId { get; set; }
+ public int? PreviousCalories { get; set; }
+ public int TargetCalories { get; set; }
+ public int? NewCalories { get; set; }
+ public decimal? NewProtein { get; set; }
+ public decimal? NewFat { get; set; }
+ public decimal? NewCarbs { get; set; }
+ public bool Applied { get; set; }
+ /// catalog | ai | catalog+ai
+ public string Method { get; set; } = string.Empty;
+ public int UnlinkedIngredientCount { get; set; }
+ public List Ingredients { get; set; } = new();
+ public RecipeDetailDto? Recipe { get; set; }
+}
diff --git a/MealPlan.Api/Services/RecipeCalorieRecalculationService.cs b/MealPlan.Api/Services/RecipeCalorieRecalculationService.cs
new file mode 100644
index 0000000..cd47817
--- /dev/null
+++ b/MealPlan.Api/Services/RecipeCalorieRecalculationService.cs
@@ -0,0 +1,250 @@
+using System.Text.Json;
+using MealPlan.Api.Data;
+using MealPlan.Api.DTOs.Recipe;
+using MealPlan.Api.Models;
+using MealPlan.Api.Services.AI;
+using MealPlan.Api.Services.Generators;
+using Microsoft.EntityFrameworkCore;
+
+namespace MealPlan.Api.Services;
+
+public interface IRecipeCalorieRecalculationService
+{
+ Task RecalculateAsync(
+ int recipeId,
+ RecalculateRecipeCaloriesRequestDto request,
+ CancellationToken ct = default);
+}
+
+public class RecipeCalorieRecalculationService : IRecipeCalorieRecalculationService
+{
+ private readonly AppDbContext _db;
+ private readonly IOpenAiChatService _openAi;
+ private readonly IRecipeManagementService _recipeManagement;
+
+ public RecipeCalorieRecalculationService(
+ AppDbContext db,
+ IOpenAiChatService openAi,
+ IRecipeManagementService recipeManagement)
+ {
+ _db = db;
+ _openAi = openAi;
+ _recipeManagement = recipeManagement;
+ }
+
+ public async Task RecalculateAsync(
+ int recipeId,
+ RecalculateRecipeCaloriesRequestDto request,
+ CancellationToken ct = default)
+ {
+ if (request.TargetCalories is < 50 or > 10000)
+ {
+ throw new InvalidOperationException("Target calories must be between 50 and 10000.");
+ }
+
+ var recipe = await _db.Recipes.AsNoTracking()
+ .Include(r => r.Ingredients)
+ .Include(r => r.Steps)
+ .FirstOrDefaultAsync(r => r.Id == recipeId && r.IsActive, ct);
+ if (recipe is null) return null;
+
+ var tolerance = request.CalorieToleranceKcal > 0 ? request.CalorieToleranceKcal : 10;
+ var workingIngredients = recipe.Ingredients
+ .OrderBy(i => i.SortOrder)
+ .Select(CloneIngredient)
+ .ToList();
+
+ var methods = new List();
+ var currentTotals = await ComputeTotalsAsync(workingIngredients, ct);
+ var scaleFactor = RecipeMacroCalculator.ResolveScaleFactor(
+ currentTotals?.Calories ?? recipe.Calories ?? 0,
+ request.TargetCalories,
+ tolerance);
+
+ if (scaleFactor != 1m)
+ {
+ RecipeMacroCalculator.ScaleIngredientAmounts(
+ workingIngredients,
+ scaleFactor,
+ tolerance,
+ request.TargetCalories,
+ currentTotals);
+ methods.Add("catalog");
+ }
+
+ currentTotals = await ComputeTotalsAsync(workingIngredients, ct);
+ var needsAi = _openAi.IsConfigured &&
+ (currentTotals is null ||
+ Math.Abs(currentTotals.Value.Calories - request.TargetCalories) > tolerance ||
+ RecipeMacroCalculator.CountUnlinkedMacroIngredients(workingIngredients) > 0);
+
+ if (needsAi)
+ {
+ await ApplyAiAdjustedAmountsAsync(recipe, workingIngredients, request.TargetCalories, ct);
+ methods.Add("ai");
+ currentTotals = await ComputeTotalsAsync(workingIngredients, ct);
+ }
+
+ if (currentTotals is null && recipe.Calories is > 0)
+ {
+ var fallbackScale = RecipeMacroCalculator.ResolveScaleFactor(
+ recipe.Calories.Value,
+ request.TargetCalories,
+ tolerance);
+ if (fallbackScale != 1m)
+ {
+ foreach (var ing in workingIngredients)
+ {
+ if (ing.AmountGrams is not > 0 || RecipeMacroCalculator.IsSpiceUnit(ing.Unit)) continue;
+ ing.AmountGrams = Math.Round(ing.AmountGrams.Value * fallbackScale, 1);
+ }
+
+ if (!methods.Contains("catalog")) methods.Add("catalog");
+ }
+ }
+
+ currentTotals = await ComputeTotalsAsync(workingIngredients, ct);
+ var ingredientDtos = MapIngredients(workingIngredients);
+ var unlinked = RecipeMacroCalculator.CountUnlinkedMacroIngredients(workingIngredients);
+
+ RecipeDetailDto? savedRecipe = null;
+ if (request.Apply)
+ {
+ var updateDto = new CreateRecipeDto
+ {
+ Name = recipe.Name,
+ MealCategory = recipe.MealCategory,
+ Calories = currentTotals?.Calories ?? request.TargetCalories,
+ Protein = currentTotals?.Protein,
+ Fat = currentTotals?.Fat,
+ Carbs = currentTotals?.Carbs,
+ PrepTimeMinutes = recipe.PrepTimeMinutes,
+ Ingredients = workingIngredients.Select((i, index) => new CreateIngredientDto
+ {
+ Name = i.Name,
+ AmountGrams = i.AmountGrams,
+ Unit = i.Unit,
+ SortOrder = index,
+ CatalogItemId = i.CatalogItemId,
+ }).ToList(),
+ Steps = recipe.Steps
+ .OrderBy(s => s.StepNumber)
+ .Select(s => new CreateRecipeStepDto
+ {
+ StepNumber = s.StepNumber,
+ Description = s.Description,
+ }).ToList(),
+ };
+
+ savedRecipe = await _recipeManagement.UpdateRecipeAsync(recipeId, updateDto, ct);
+ if (savedRecipe is null) return null;
+ }
+
+ return new RecalculateRecipeCaloriesResultDto
+ {
+ RecipeId = recipeId,
+ PreviousCalories = recipe.Calories,
+ TargetCalories = request.TargetCalories,
+ NewCalories = currentTotals?.Calories ?? savedRecipe?.Calories,
+ NewProtein = currentTotals?.Protein ?? savedRecipe?.Protein,
+ NewFat = currentTotals?.Fat ?? savedRecipe?.Fat,
+ NewCarbs = currentTotals?.Carbs ?? savedRecipe?.Carbs,
+ Applied = request.Apply && savedRecipe is not null,
+ Method = methods.Count == 0 ? "none" : string.Join('+', methods),
+ UnlinkedIngredientCount = unlinked,
+ Ingredients = savedRecipe?.Ingredients ?? ingredientDtos,
+ Recipe = savedRecipe,
+ };
+ }
+
+ private async Task ApplyAiAdjustedAmountsAsync(
+ Recipe source,
+ IList ingredients,
+ int targetCalories,
+ CancellationToken ct)
+ {
+ var system = """
+ You adjust recipe ingredient amounts to match a calorie target for a meal planning app.
+ Respond ONLY JSON:
+ {"ingredients":[{"name":"exact ingredient name","amountGrams":100,"unit":null}]}
+ Rules:
+ - Keep every ingredient from the input list (same names).
+ - Change amountGrams for macro ingredients so the recipe totals about targetCalories kcal.
+ - Use realistic gram amounts. Round to one decimal.
+ - Spices with unit "do smaku" / "to taste" keep null amountGrams.
+ - Do not add or remove ingredients.
+ """;
+
+ var user = JsonSerializer.Serialize(new
+ {
+ recipeName = source.Name,
+ targetCalories,
+ currentCalories = source.Calories,
+ ingredients = ingredients.Select(i => new
+ {
+ i.Name,
+ i.AmountGrams,
+ i.Unit,
+ i.CatalogItemId,
+ }),
+ });
+
+ var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct);
+ using var doc = JsonDocument.Parse(json);
+ if (!doc.RootElement.TryGetProperty("ingredients", out var ingredientsEl) ||
+ ingredientsEl.ValueKind != JsonValueKind.Array)
+ {
+ throw new InvalidOperationException("AI did not return adjusted ingredients. Try again.");
+ }
+
+ var byName = ingredients.ToDictionary(i => i.Name.Trim(), StringComparer.OrdinalIgnoreCase);
+ foreach (var item in ingredientsEl.EnumerateArray())
+ {
+ var name = item.TryGetProperty("name", out var nameEl)
+ ? AiRecipeJsonParser.GetString(nameEl)
+ : null;
+ if (string.IsNullOrWhiteSpace(name) || !byName.TryGetValue(name.Trim(), out var target)) continue;
+
+ if (item.TryGetProperty("amountGrams", out var gramsEl))
+ {
+ target.AmountGrams = AiRecipeJsonParser.GetNullableDecimal(gramsEl);
+ }
+
+ if (item.TryGetProperty("unit", out var unitEl) && unitEl.ValueKind != JsonValueKind.Null)
+ {
+ target.Unit = AiRecipeJsonParser.GetString(unitEl);
+ }
+ }
+ }
+
+ private async Task ComputeTotalsAsync(IEnumerable ingredients, CancellationToken ct)
+ {
+ return await RecipeMacroCalculator.ComputeFromCatalogAsync(
+ _db,
+ ingredients.Select(i => (i.AmountGrams, i.Unit, i.CatalogItemId)),
+ ct);
+ }
+
+ private static Ingredient CloneIngredient(Ingredient source) => new()
+ {
+ Id = source.Id,
+ Name = source.Name,
+ AmountGrams = source.AmountGrams,
+ Unit = source.Unit,
+ SortOrder = source.SortOrder,
+ CatalogItemId = source.CatalogItemId,
+ };
+
+ private static List MapIngredients(IEnumerable ingredients) =>
+ ingredients
+ .OrderBy(i => i.SortOrder)
+ .Select(i => new IngredientDto
+ {
+ Id = i.Id,
+ Name = i.Name,
+ AmountGrams = i.AmountGrams,
+ Unit = i.Unit,
+ SortOrder = i.SortOrder,
+ CatalogItemId = i.CatalogItemId,
+ }).ToList();
+}
diff --git a/MealPlan.Api/Services/RecipeMacroCalculator.cs b/MealPlan.Api/Services/RecipeMacroCalculator.cs
new file mode 100644
index 0000000..faa6fd0
--- /dev/null
+++ b/MealPlan.Api/Services/RecipeMacroCalculator.cs
@@ -0,0 +1,83 @@
+using MealPlan.Api.Data;
+using MealPlan.Api.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace MealPlan.Api.Services;
+
+public readonly record struct MacroTotals(int Calories, decimal Protein, decimal Fat, decimal Carbs);
+
+public static class RecipeMacroCalculator
+{
+ public static bool IsSpiceUnit(string? unit)
+ {
+ if (string.IsNullOrWhiteSpace(unit)) return false;
+ var u = unit.Trim().ToLowerInvariant();
+ return u is "do smaku" or "optional" or "opcjonalnie" or "szczypta" or "to taste";
+ }
+
+ public static bool IsQuantifiedMacroIngredient(decimal? amountGrams, string? unit, int? catalogItemId) =>
+ catalogItemId.HasValue && amountGrams is > 0 && !IsSpiceUnit(unit);
+
+ public static async Task ComputeFromCatalogAsync(
+ AppDbContext db,
+ IEnumerable<(decimal? AmountGrams, string? Unit, int? CatalogItemId)> ingredients,
+ CancellationToken ct = default)
+ {
+ var rows = ingredients.ToList();
+ var ids = rows
+ .Where(i => IsQuantifiedMacroIngredient(i.AmountGrams, i.Unit, i.CatalogItemId))
+ .Select(i => i.CatalogItemId!.Value)
+ .Distinct()
+ .ToList();
+ if (ids.Count == 0) return null;
+
+ var catalog = await db.IngredientNutritionCatalog.AsNoTracking()
+ .Where(i => ids.Contains(i.Id) && i.IsActive)
+ .ToDictionaryAsync(i => i.Id, ct);
+
+ int calories = 0;
+ decimal protein = 0, fat = 0, carbs = 0;
+ foreach (var ing in rows)
+ {
+ if (!IsQuantifiedMacroIngredient(ing.AmountGrams, ing.Unit, ing.CatalogItemId)) continue;
+ if (!catalog.TryGetValue(ing.CatalogItemId!.Value, out var cat)) continue;
+
+ var scale = ing.AmountGrams!.Value / 100m;
+ calories += (int)Math.Round(cat.CaloriesPer100G * scale);
+ protein += cat.ProteinGPer100G * scale;
+ fat += cat.FatGPer100G * scale;
+ carbs += cat.CarbsGPer100G * scale;
+ }
+
+ if (calories <= 0) return null;
+ return new MacroTotals(calories, Math.Round(protein, 2), Math.Round(fat, 2), Math.Round(carbs, 2));
+ }
+
+ public static decimal ResolveScaleFactor(int currentCalories, int targetCalories, int toleranceKcal)
+ {
+ if (currentCalories <= 0 || targetCalories <= 0) return 1m;
+ if (Math.Abs(currentCalories - targetCalories) <= toleranceKcal) return 1m;
+ return Math.Clamp((decimal)targetCalories / currentCalories, 0.25m, 4m);
+ }
+
+ public static void ScaleIngredientAmounts(
+ IList ingredients,
+ decimal scaleFactor,
+ int toleranceKcal,
+ int targetCalories,
+ MacroTotals? currentTotals)
+ {
+ if (scaleFactor == 1m) return;
+ if (currentTotals is null || currentTotals.Value.Calories <= 0) return;
+ if (Math.Abs(currentTotals.Value.Calories - targetCalories) <= toleranceKcal) return;
+
+ foreach (var ing in ingredients)
+ {
+ if (!IsQuantifiedMacroIngredient(ing.AmountGrams, ing.Unit, ing.CatalogItemId)) continue;
+ ing.AmountGrams = Math.Round(ing.AmountGrams!.Value * scaleFactor, 1);
+ }
+ }
+
+ public static int CountUnlinkedMacroIngredients(IEnumerable ingredients) =>
+ ingredients.Count(i => i.AmountGrams is > 0 && !IsSpiceUnit(i.Unit) && !i.CatalogItemId.HasValue);
+}