* Extended functionalities

* Added different UIs
This commit is contained in:
2026-06-24 21:43:40 +02:00
parent 282f4864c4
commit f15bb7a916
348 changed files with 59057 additions and 498 deletions

View File

@@ -0,0 +1,284 @@
using MealPlan.Api.Extensions;
using MealPlan.Api.DTOs.Diet;
using MealPlan.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/diet")]
[Authorize]
public class DietController : ControllerBase
{
private readonly IDietTrackingService _dietService;
public DietController(IDietTrackingService dietService) => _dietService = dietService;
/// <summary>Daily intake summary with consumed totals, goals, and remaining amounts.</summary>
[HttpGet("summary")]
[ProducesResponseType(typeof(DailySummaryDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetSummary([FromQuery] DateOnly? date, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var summary = await _dietService.GetDailySummaryAsync(userId, targetDate, ct);
return Ok(summary);
}
[HttpGet("goals")]
[ProducesResponseType(typeof(UserDailyGoalsDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetGoals(CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var goals = await _dietService.GetGoalsAsync(userId, ct);
return Ok(goals ?? new UserDailyGoalsDto());
}
[HttpPut("goals")]
[ProducesResponseType(typeof(UserDailyGoalsDto), StatusCodes.Status200OK)]
public async Task<IActionResult> UpsertGoals([FromBody] UserDailyGoalsDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var goals = await _dietService.UpsertGoalsAsync(userId, dto, ct);
return Ok(goals);
}
[HttpGet("meals")]
[ProducesResponseType(typeof(IReadOnlyList<MealConsumptionDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetMeals([FromQuery] DateOnly? date, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var meals = await _dietService.GetMealsAsync(userId, targetDate, ct);
return Ok(meals);
}
[HttpPost("meals")]
[ProducesResponseType(typeof(MealConsumptionDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> LogMeal([FromBody] LogMealDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var meal = await _dietService.LogMealAsync(userId, dto, ct);
if (meal is null)
{
return dto.RecipeId.HasValue
? NotFound(new { error = $"Recipe {dto.RecipeId} was not found." })
: BadRequest(new { error = "Invalid meal log payload." });
}
return CreatedAtAction(nameof(GetMeals), new { date = meal.LogDate }, meal);
}
[HttpPost("meals/preview")]
[ProducesResponseType(typeof(MealPreviewDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> PreviewMeal([FromQuery] DateOnly? date, [FromBody] LogMealDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var preview = await _dietService.PreviewMealAsync(userId, targetDate, dto, ct);
return preview is null
? BadRequest(new { error = "Could not preview meal macros." })
: Ok(preview);
}
[HttpGet("suggestions")]
[ProducesResponseType(typeof(IReadOnlyList<DietSuggestionDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetSuggestions([FromQuery] DateOnly? date, [FromQuery] int limit = 5, CancellationToken ct = default)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var suggestions = await _dietService.GetMealSuggestionsAsync(userId, targetDate, limit, ct);
return Ok(suggestions);
}
[HttpDelete("meals/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteMeal(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _dietService.DeleteMealAsync(userId, id, ct);
return deleted ? NoContent() : NotFound(new { error = $"Meal log {id} was not found." });
}
[HttpGet("drinks/catalog")]
[ProducesResponseType(typeof(IReadOnlyList<DrinkCatalogItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetDrinkCatalog(CancellationToken ct)
{
var catalog = await _dietService.GetDrinkCatalogAsync(ct);
return Ok(catalog);
}
[HttpGet("drinks")]
[ProducesResponseType(typeof(IReadOnlyList<DrinkConsumptionDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetDrinks([FromQuery] DateOnly? date, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var drinks = await _dietService.GetDrinksAsync(userId, targetDate, ct);
return Ok(drinks);
}
[HttpPost("drinks")]
[ProducesResponseType(typeof(DrinkConsumptionDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> LogDrink([FromBody] LogDrinkDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var drink = await _dietService.LogDrinkAsync(userId, dto, ct);
if (drink is null)
{
return NotFound(new { error = "Drink catalog item was not found." });
}
return CreatedAtAction(nameof(GetDrinks), new { date = drink.LogDate }, drink);
}
[HttpDelete("drinks/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteDrink(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _dietService.DeleteDrinkAsync(userId, id, ct);
return deleted ? NoContent() : NotFound(new { error = $"Drink log {id} was not found." });
}
[HttpGet("reminders")]
[ProducesResponseType(typeof(IReadOnlyList<DietReminderDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetReminders(CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var reminders = await _dietService.GetRemindersAsync(userId, ct);
return Ok(reminders);
}
[HttpGet("reminders/due")]
[ProducesResponseType(typeof(IReadOnlyList<DueReminderDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetDueReminders(
[FromQuery] DateOnly? date,
[FromQuery] TimeOnly? time,
CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var due = await _dietService.GetDueRemindersAsync(userId, date, time, ct);
return Ok(due);
}
[HttpPost("reminders")]
[ProducesResponseType(typeof(DietReminderDto), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateReminder([FromBody] CreateDietReminderDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var reminder = await _dietService.CreateReminderAsync(userId, dto, ct);
return CreatedAtAction(nameof(GetReminders), reminder);
}
[HttpPut("reminders/{id:int}")]
[ProducesResponseType(typeof(DietReminderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateReminder(int id, [FromBody] UpdateDietReminderDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var reminder = await _dietService.UpdateReminderAsync(userId, id, dto, ct);
return reminder is null
? NotFound(new { error = $"Reminder {id} was not found." })
: Ok(reminder);
}
[HttpDelete("reminders/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteReminder(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _dietService.DeleteReminderAsync(userId, id, ct);
return deleted ? NoContent() : NotFound(new { error = $"Reminder {id} was not found." });
}
[HttpGet("history")]
[ProducesResponseType(typeof(IReadOnlyList<HistoryDayDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetHistory([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var history = await _dietService.GetHistoryAsync(userId, from, to, ct);
return Ok(history);
}
[HttpGet("meals/recent")]
[ProducesResponseType(typeof(IReadOnlyList<RecentMealDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetRecentMeals([FromQuery] int limit = 10, CancellationToken ct = default)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var meals = await _dietService.GetRecentMealsAsync(userId, limit, ct);
return Ok(meals);
}
[HttpPost("meals/copy-yesterday")]
[ProducesResponseType(typeof(CopyMealsResultDto), StatusCodes.Status200OK)]
public async Task<IActionResult> CopyYesterdayMeals([FromQuery] DateOnly? date, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var result = await _dietService.CopyYesterdayMealsAsync(userId, targetDate, ct);
return Ok(result);
}
[HttpGet("favorites/recipes")]
[ProducesResponseType(typeof(IReadOnlyList<FavoriteRecipeDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetFavoriteRecipes(CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
return Ok(await _dietService.GetFavoriteRecipesAsync(userId, ct));
}
[HttpPost("favorites/recipes/{recipeId:int}")]
[ProducesResponseType(typeof(FavoriteRecipeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AddFavoriteRecipe(int recipeId, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var fav = await _dietService.AddFavoriteRecipeAsync(userId, recipeId, ct);
return fav is null ? NotFound() : Ok(fav);
}
[HttpDelete("favorites/recipes/{recipeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> RemoveFavoriteRecipe(int recipeId, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _dietService.RemoveFavoriteRecipeAsync(userId, recipeId, ct);
return deleted ? NoContent() : NotFound();
}
[HttpGet("favorites/catalog")]
[ProducesResponseType(typeof(IReadOnlyList<FavoriteCatalogItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetFavoriteCatalogItems(CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
return Ok(await _dietService.GetFavoriteCatalogItemsAsync(userId, ct));
}
[HttpPost("favorites/catalog/{catalogItemId:int}")]
[ProducesResponseType(typeof(FavoriteCatalogItemDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AddFavoriteCatalogItem(int catalogItemId, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var fav = await _dietService.AddFavoriteCatalogItemAsync(userId, catalogItemId, ct);
return fav is null ? NotFound() : Ok(fav);
}
[HttpDelete("favorites/catalog/{catalogItemId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> RemoveFavoriteCatalogItem(int catalogItemId, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _dietService.RemoveFavoriteCatalogItemAsync(userId, catalogItemId, ct);
return deleted ? NoContent() : NotFound();
}
}

View File

@@ -0,0 +1,128 @@
using MealPlan.Api.DTOs.Generators;
using MealPlan.Api.Extensions;
using MealPlan.Api.Services.Generators;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/diet-generator")]
[Authorize]
public class DietGeneratorController : ControllerBase
{
private readonly IDietGeneratorService _service;
public DietGeneratorController(IDietGeneratorService service) => _service = service;
[HttpGet("profile")]
[ProducesResponseType(typeof(DietUserProfileDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetProfile(CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var profile = await _service.GetProfileAsync(userId, ct);
return Ok(profile ?? new DietUserProfileDto());
}
[HttpPut("profile")]
[ProducesResponseType(typeof(DietUserProfileDto), StatusCodes.Status200OK)]
public async Task<IActionResult> UpsertProfile([FromBody] DietUserProfileDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
return Ok(await _service.UpsertProfileAsync(userId, dto, ct));
}
[HttpPost("sessions")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateSession([FromBody] CreateDietGeneratorSessionDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var session = await _service.CreateSessionAsync(userId, dto, ct);
return CreatedAtAction(nameof(GetSession), new { id = session.Id }, session);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet("sessions/{id:int}")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSession(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.GetSessionAsync(userId, id, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/propose-macros")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> ProposeMacros(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.ProposeMacrosAsync(userId, id, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/accept-macros")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> AcceptMacros(int id, [FromBody] AcceptMacrosDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.AcceptMacrosAsync(userId, id, dto, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/generate-plan")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GeneratePlan(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var session = await _service.GeneratePlanAsync(userId, id, ct);
return session is null ? NotFound() : Ok(session);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("sessions/{id:int}/regenerate-meal")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> RegenerateMeal(int id, [FromBody] RegenerateDietMealDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var session = await _service.RegenerateMealAsync(userId, id, dto, ct);
return session is null ? NotFound() : Ok(session);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPatch("sessions/{id:int}/meals/{mealId:int}")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateMeal(int id, int mealId, [FromBody] UpdateDraftMealDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.UpdateDraftMealAsync(userId, id, mealId, dto, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/commit")]
[ProducesResponseType(typeof(CommitDietPlanResultDto), StatusCodes.Status200OK)]
public async Task<IActionResult> Commit(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var result = await _service.CommitAsync(userId, id, ct);
return result is null ? NotFound() : Ok(result);
}
}

View File

@@ -0,0 +1,76 @@
using MealPlan.Api.DTOs.IngredientCatalog;
using MealPlan.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/ingredient-catalog")]
[Authorize]
public class IngredientCatalogController : ControllerBase
{
private readonly IIngredientCatalogService _catalogService;
public IngredientCatalogController(IIngredientCatalogService catalogService) =>
_catalogService = catalogService;
[HttpGet("categories")]
[ProducesResponseType(typeof(IReadOnlyList<IngredientCategoryDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetCategories(CancellationToken ct)
{
var categories = await _catalogService.GetCategoriesAsync(ct);
return Ok(categories);
}
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<IngredientNutritionItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetItems(
[FromQuery] string? category,
[FromQuery] string? search,
CancellationToken ct)
{
var items = await _catalogService.GetItemsAsync(category, search, ct);
return Ok(items);
}
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItem(int id, CancellationToken ct)
{
var item = await _catalogService.GetItemByIdAsync(id, ct);
return item is null ? NotFound(new { error = $"Ingredient catalog item {id} was not found." }) : Ok(item);
}
[HttpPost]
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateItem([FromBody] UpsertIngredientNutritionItemDto dto, CancellationToken ct)
{
var item = await _catalogService.CreateItemAsync(dto, ct);
return item is null
? BadRequest(new { error = "Invalid category or payload." })
: CreatedAtAction(nameof(GetItem), new { id = item.Id }, item);
}
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateItem(int id, [FromBody] UpsertIngredientNutritionItemDto dto, CancellationToken ct)
{
var item = await _catalogService.UpdateItemAsync(id, dto, ct);
return item is null
? NotFound(new { error = $"Ingredient catalog item {id} was not found." })
: Ok(item);
}
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteItem(int id, CancellationToken ct)
{
var deleted = await _catalogService.DeleteItemAsync(id, ct);
return deleted ? NoContent() : NotFound(new { error = $"Ingredient catalog item {id} was not found." });
}
}

View File

@@ -0,0 +1,55 @@
using MealPlan.Api.DTOs.MealPlan;
using MealPlan.Api.Extensions;
using MealPlan.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/meal-plan")]
[Authorize]
public class MealPlanController : ControllerBase
{
private readonly IMealPlanService _mealPlanService;
public MealPlanController(IMealPlanService mealPlanService) => _mealPlanService = mealPlanService;
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<MealPlanEntryDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetEntries([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var entries = await _mealPlanService.GetEntriesAsync(userId, from, to, ct);
return Ok(entries);
}
[HttpPut]
[ProducesResponseType(typeof(MealPlanEntryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpsertEntry([FromBody] UpsertMealPlanEntryDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var entry = await _mealPlanService.UpsertEntryAsync(userId, dto, ct);
return entry is null ? NotFound(new { error = "Recipe not found." }) : Ok(entry);
}
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteEntry(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _mealPlanService.DeleteEntryAsync(userId, id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpGet("shopping-list")]
[ProducesResponseType(typeof(IReadOnlyList<ShoppingListItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetShoppingList([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var items = await _mealPlanService.GetShoppingListAsync(userId, from, to, ct);
return Ok(items);
}
}

View File

@@ -0,0 +1,115 @@
using System.Text.Json;
using MealPlan.Api.DTOs.Generators;
using MealPlan.Api.Extensions;
using MealPlan.Api.Services.Generators;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/recipe-generator")]
[Authorize]
public class RecipeGeneratorController : ControllerBase
{
private readonly IRecipeGeneratorService _service;
public RecipeGeneratorController(IRecipeGeneratorService service) => _service = service;
[HttpPost("sessions")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateSession([FromBody] RecipeGeneratorConstraintsDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.CreateSessionAsync(userId, dto, ct);
return CreatedAtAction(nameof(GetSession), new { id = session.Id }, session);
}
[HttpGet("sessions/{id:int}")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSession(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.GetSessionAsync(userId, id, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/generate")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> Generate(int id, [FromBody] GenerateRecipesRequestDto? request, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var session = await _service.GenerateAsync(userId, id, request, ct);
return session is null ? NotFound() : Ok(session);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("sessions/{id:int}/regenerate")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> Regenerate(int id, [FromBody] RegenerateRecipePartDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var session = await _service.RegeneratePartAsync(userId, id, dto, ct);
return session is null ? NotFound() : Ok(session);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (JsonException)
{
return BadRequest(new { error = "Could not parse AI recipe response. Try again." });
}
}
[HttpPut("sessions/{id:int}/draft")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateDraft(int id, [FromBody] GeneratedRecipeDraftDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.UpdateDraftAsync(userId, id, dto, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpDelete("sessions/{id:int}/drafts/{draftId}")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> RemoveDraft(int id, string draftId, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.RemoveDraftAsync(userId, id, draftId, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/commit")]
[ProducesResponseType(typeof(CommitRecipesResultDto), StatusCodes.Status200OK)]
public async Task<IActionResult> Commit(int id, [FromBody] CommitRecipesRequestDto? request, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var result = await _service.CommitAsync(userId, id, request, ct);
if (result is null)
{
return NotFound(new
{
error = "Nothing to save. The session may have expired, or the selected drafts were not found.",
});
}
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
}

View File

@@ -0,0 +1,93 @@
using MealPlan.Api.DTOs.Recipe;
using MealPlan.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
/// <summary>Endpoints for creating recipes manually and importing from Excel.</summary>
[ApiController]
[Route("api/recipes")]
[Authorize]
public class RecipeManagementController : ControllerBase
{
private readonly IRecipeManagementService _managementService;
private readonly IRecipeExcelService _excelService;
public RecipeManagementController(
IRecipeManagementService managementService,
IRecipeExcelService excelService)
{
_managementService = managementService;
_excelService = excelService;
}
/// <summary>Creates a new recipe with ingredients and steps.</summary>
[HttpPost]
[ProducesResponseType(typeof(RecipeDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create([FromBody] CreateRecipeDto dto, CancellationToken ct)
{
var recipe = await _managementService.CreateRecipeAsync(dto, ct);
return CreatedAtAction(
nameof(RecipesController.GetRecipe),
"Recipes",
new { id = recipe.Id },
recipe);
}
/// <summary>Updates an existing recipe (replaces ingredients and steps).</summary>
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(RecipeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(int id, [FromBody] CreateRecipeDto dto, CancellationToken ct)
{
var recipe = await _managementService.UpdateRecipeAsync(id, dto, ct);
return recipe is null
? NotFound(new { error = $"Recipe {id} was not found." })
: Ok(recipe);
}
/// <summary>Downloads an Excel template for bulk recipe import.</summary>
[HttpGet("import/template")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadTemplate()
{
var bytes = _excelService.GenerateTemplate();
return File(
bytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"DailyMeals-recipe-import-template.xlsx");
}
/// <summary>Imports recipes from an uploaded .xlsx file (Recipes + Ingredients + Steps sheets).</summary>
[HttpPost("import/excel")]
[RequestSizeLimit(10_000_000)]
[ProducesResponseType(typeof(RecipeImportResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ImportExcel(IFormFile? file, CancellationToken ct)
{
if (file is null || file.Length == 0)
{
return BadRequest(new { error = "No file uploaded." });
}
if (!file.FileName.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase))
{
return BadRequest(new { error = "Only .xlsx files are supported." });
}
await using var stream = file.OpenReadStream();
var result = await _excelService.ImportAsync(stream, ct);
return Ok(result);
}
/// <summary>Recalculates recipe macros from linked catalog ingredients (all active recipes).</summary>
[HttpPost("recalculate-macros")]
[ProducesResponseType(typeof(RecipeMacroRecalcResultDto), StatusCodes.Status200OK)]
public async Task<IActionResult> RecalculateMacros(CancellationToken ct)
{
var result = await _managementService.RecalculateAllMacrosFromCatalogAsync(ct);
return Ok(result);
}
}

View File

@@ -14,12 +14,16 @@ public class RecipesController : ControllerBase
public RecipesController(IRecipeService recipeService) => _recipeService = recipeService;
/// <summary>Lists active recipes, optionally filtered by category and/or name search.</summary>
/// <summary>
/// Lists active recipes, optionally filtered by category, recipe-name search,
/// and/or an ingredient name (returns recipes that contain that ingredient).
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<RecipeListItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetRecipes(
[FromQuery] int? category,
[FromQuery] string? search,
[FromQuery] string? ingredient,
CancellationToken ct)
{
if (category is < 0 or > 3)
@@ -27,7 +31,7 @@ public class RecipesController : ControllerBase
return BadRequest(new { error = "category must be between 0 and 3." });
}
var recipes = await _recipeService.GetRecipesAsync(category, search, ct);
var recipes = await _recipeService.GetRecipesAsync(category, search, ingredient, ct);
return Ok(recipes);
}

View File

@@ -0,0 +1,227 @@
namespace MealPlan.Api.DTOs.Diet;
public class UserDailyGoalsDto
{
public int? CalorieGoal { get; set; }
public decimal? ProteinGoalG { get; set; }
public decimal? FatGoalG { get; set; }
public decimal? CarbsGoalG { get; set; }
public int? WaterGoalMl { get; set; }
}
public class MacroRemainingDto
{
public int? Goal { get; set; }
public int Consumed { get; set; }
public int? Remaining { get; set; }
public decimal? ProgressPercent { get; set; }
}
public class DecimalMacroRemainingDto
{
public decimal? Goal { get; set; }
public decimal Consumed { get; set; }
public decimal? Remaining { get; set; }
public decimal? ProgressPercent { get; set; }
}
public class DailySummaryDto
{
public DateOnly Date { get; set; }
public UserDailyGoalsDto? Goals { get; set; }
public MacroRemainingDto Calories { get; set; } = new();
public DecimalMacroRemainingDto ProteinG { get; set; } = new();
public DecimalMacroRemainingDto FatG { get; set; } = new();
public DecimalMacroRemainingDto CarbsG { get; set; } = new();
public MacroRemainingDto WaterMl { get; set; } = new();
public IReadOnlyList<MealConsumptionDto> Meals { get; set; } = Array.Empty<MealConsumptionDto>();
public IReadOnlyList<DrinkConsumptionDto> Drinks { get; set; } = Array.Empty<DrinkConsumptionDto>();
}
public class MealConsumptionDto
{
public int Id { get; set; }
public int? RecipeId { get; set; }
public int MealCategory { get; set; }
public DateOnly LogDate { get; set; }
public DateTime ConsumedAt { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Portions { get; set; }
public int? Calories { get; set; }
public decimal? ProteinG { get; set; }
public decimal? FatG { get; set; }
public decimal? CarbsG { get; set; }
public string? Notes { get; set; }
}
public class LogMealDto
{
public int? RecipeId { get; set; }
public int? CatalogItemId { get; set; }
public decimal? Grams { get; set; }
public string? Name { get; set; }
public int MealCategory { get; set; }
public DateOnly? LogDate { get; set; }
public DateTime? ConsumedAt { get; set; }
public decimal Portions { get; set; } = 1m;
public int? Calories { get; set; }
public decimal? ProteinG { get; set; }
public decimal? FatG { get; set; }
public decimal? CarbsG { get; set; }
public string? Notes { get; set; }
}
public class MealMacroSnapshotDto
{
public string Name { get; set; } = string.Empty;
public decimal Portions { get; set; } = 1m;
public int? Calories { get; set; }
public decimal? ProteinG { get; set; }
public decimal? FatG { get; set; }
public decimal? CarbsG { get; set; }
}
public class RemainingAfterMealDto
{
public int? Calories { get; set; }
public decimal? ProteinG { get; set; }
public decimal? FatG { get; set; }
public decimal? CarbsG { get; set; }
}
public class MealPreviewDto
{
public MealMacroSnapshotDto Meal { get; set; } = new();
public RemainingAfterMealDto RemainingAfter { get; set; } = new();
}
public class DietSuggestionDto
{
public int CatalogItemId { get; set; }
public string Name { get; set; } = string.Empty;
public string? NamePl { get; set; }
public string CategoryCode { get; set; } = string.Empty;
public int SuggestedGrams { get; set; }
public int? Calories { get; set; }
public decimal? ProteinG { get; set; }
public decimal? FatG { get; set; }
public decimal? CarbsG { get; set; }
public string ReasonKey { get; set; } = string.Empty;
}
public class DrinkCatalogItemDto
{
public int Id { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int DefaultVolumeMl { get; set; }
public decimal? CaloriesPer100Ml { get; set; }
public string? IconEmoji { get; set; }
}
public class DrinkConsumptionDto
{
public int Id { get; set; }
public int? DrinkCatalogId { get; set; }
public DateOnly LogDate { get; set; }
public DateTime ConsumedAt { get; set; }
public string Name { get; set; } = string.Empty;
public int VolumeMl { get; set; }
public int? Calories { get; set; }
}
public class LogDrinkDto
{
public int? DrinkCatalogId { get; set; }
public string? Name { get; set; }
public int VolumeMl { get; set; }
public DateOnly? LogDate { get; set; }
public DateTime? ConsumedAt { get; set; }
public int? Calories { get; set; }
}
public class DietReminderDto
{
public int Id { get; set; }
public int ReminderType { get; set; }
public string Title { get; set; } = string.Empty;
public string? Message { get; set; }
public TimeOnly TimeOfDay { get; set; }
public int DaysOfWeekMask { get; set; }
public int? MealCategory { get; set; }
public bool IsEnabled { get; set; }
}
public class CreateDietReminderDto
{
public int ReminderType { get; set; }
public string Title { get; set; } = string.Empty;
public string? Message { get; set; }
public TimeOnly TimeOfDay { get; set; }
public int DaysOfWeekMask { get; set; } = Models.ReminderDaysMask.All;
public int? MealCategory { get; set; }
public bool IsEnabled { get; set; } = true;
}
public class UpdateDietReminderDto : CreateDietReminderDto
{
}
public class DueReminderDto
{
public int Id { get; set; }
public int ReminderType { get; set; }
public string Title { get; set; } = string.Empty;
public string? Message { get; set; }
public TimeOnly TimeOfDay { get; set; }
public int? MealCategory { get; set; }
}
public class HistoryDayDto
{
public DateOnly Date { get; set; }
public int Calories { get; set; }
public decimal ProteinG { get; set; }
public decimal FatG { get; set; }
public decimal CarbsG { get; set; }
public int WaterMl { get; set; }
public int MealCount { get; set; }
}
public class RecentMealDto
{
public int? RecipeId { get; set; }
public int? CatalogItemId { get; set; }
public string Name { get; set; } = string.Empty;
public int MealCategory { get; set; }
public decimal Portions { get; set; } = 1m;
public decimal? Grams { get; set; }
public int? Calories { get; set; }
public decimal? ProteinG { get; set; }
public decimal? FatG { get; set; }
public decimal? CarbsG { get; set; }
public DateTime LastLoggedAt { get; set; }
}
public class FavoriteRecipeDto
{
public int RecipeId { get; set; }
public string Name { get; set; } = string.Empty;
public int MealCategory { get; set; }
public int? Calories { get; set; }
}
public class FavoriteCatalogItemDto
{
public int CatalogItemId { get; set; }
public string Name { get; set; } = string.Empty;
public string? NamePl { get; set; }
public string CategoryCode { get; set; } = string.Empty;
}
public class CopyMealsResultDto
{
public DateOnly SourceDate { get; set; }
public DateOnly TargetDate { get; set; }
public int CopiedCount { get; set; }
}

View File

@@ -0,0 +1,201 @@
namespace MealPlan.Api.DTOs.Generators;
public class DietUserProfileDto
{
public decimal HeightCm { get; set; }
public decimal WeightKg { get; set; }
public int Age { get; set; }
public string Sex { get; set; } = "male";
public string ActivityLevel { get; set; } = "moderate";
public string Goal { get; set; } = "maintain";
public string? AllergiesNotes { get; set; }
public string? DislikedFoods { get; set; }
public int MealsPerDayMask { get; set; } = 15;
}
public class CreateDietGeneratorSessionDto
{
public DateOnly? PlanStartDate { get; set; }
public int PlanDayCount { get; set; } = 7;
public int CalorieToleranceKcal { get; set; } = 10;
}
public class MacroProposalDto
{
public int Calories { get; set; }
public decimal ProteinG { get; set; }
public decimal FatG { get; set; }
public decimal CarbsG { get; set; }
public string? Rationale { get; set; }
}
public class AcceptMacrosDto
{
public int? Calories { get; set; }
public decimal? ProteinG { get; set; }
public decimal? FatG { get; set; }
public decimal? CarbsG { get; set; }
}
public class DietGeneratorSessionDto
{
public int Id { get; set; }
public string Status { get; set; } = string.Empty;
public MacroProposalDto? ProposedMacros { get; set; }
public DateOnly? PlanStartDate { get; set; }
public int PlanDayCount { get; set; }
public int CalorieToleranceKcal { get; set; }
public IReadOnlyList<DietGeneratorDraftMealDto> DraftMeals { get; set; } = Array.Empty<DietGeneratorDraftMealDto>();
public IReadOnlyList<DietGeneratorDaySummaryDto> DaySummaries { get; set; } = Array.Empty<DietGeneratorDaySummaryDto>();
}
public class DietGeneratorDraftMealDto
{
public int Id { get; set; }
public DateOnly PlanDate { get; set; }
public int MealCategory { get; set; }
public int RecipeId { get; set; }
public string RecipeName { get; set; } = string.Empty;
public decimal Portions { get; set; }
public int? Calories { get; set; }
public decimal? ProteinG { get; set; }
public decimal? FatG { get; set; }
public decimal? CarbsG { get; set; }
public bool IsLocked { get; set; }
public int? PrepTimeMinutes { get; set; }
public IReadOnlyList<DietGeneratorDraftMealIngredientDto> Ingredients { get; set; } =
Array.Empty<DietGeneratorDraftMealIngredientDto>();
public IReadOnlyList<DietGeneratorDraftMealStepDto> Steps { get; set; } =
Array.Empty<DietGeneratorDraftMealStepDto>();
}
public class DietGeneratorDraftMealStepDto
{
public int Id { get; set; }
public int StepNumber { get; set; }
public string Description { get; set; } = string.Empty;
}
public class DietGeneratorDraftMealIngredientDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal? AmountGrams { get; set; }
public string? Unit { get; set; }
public int SortOrder { get; set; }
public int? SourceIngredientId { get; set; }
}
public class DietGeneratorDaySummaryDto
{
public DateOnly PlanDate { get; set; }
public int TotalCalories { get; set; }
public int TargetCalories { get; set; }
public bool WithinTolerance { get; set; }
}
public class RegenerateDietMealDto
{
public DateOnly PlanDate { get; set; }
public int MealCategory { get; set; }
}
public class UpdateDraftMealDto
{
public int? RecipeId { get; set; }
public decimal? Portions { get; set; }
public bool? IsLocked { get; set; }
}
public class CommitDietPlanResultDto
{
public int SessionId { get; set; }
public DateOnly PlanStartDate { get; set; }
public DateOnly PlanEndDate { get; set; }
public int MealsWritten { get; set; }
}
public class RecipeGeneratorConstraintsDto
{
public string Prompt { get; set; } = string.Empty;
public int MealCategory { get; set; }
public int? TargetCalories { get; set; }
public int CalorieToleranceKcal { get; set; } = 10;
public int? MaxPrepTimeMinutes { get; set; }
public string? CuisineStyle { get; set; }
public string? DietStyle { get; set; }
public string? Exclusions { get; set; }
}
public class GeneratedIngredientDto
{
public string Name { get; set; } = string.Empty;
public decimal? AmountGrams { get; set; }
public string? Unit { get; set; }
public int? CatalogItemId { get; set; }
public string? CatalogName { get; set; }
public bool IsLinked { get; set; }
}
public class GeneratedStepDto
{
public int StepNumber { get; set; }
public string Description { get; set; } = string.Empty;
}
public class GeneratedRecipeDraftDto
{
public string DraftId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int MealCategory { get; set; }
public int? PrepTimeMinutes { get; set; }
public int? Calories { get; set; }
public decimal? Protein { get; set; }
public decimal? Fat { get; set; }
public decimal? Carbs { get; set; }
public IReadOnlyList<GeneratedIngredientDto> Ingredients { get; set; } = Array.Empty<GeneratedIngredientDto>();
public IReadOnlyList<GeneratedStepDto> Steps { get; set; } = Array.Empty<GeneratedStepDto>();
public int UnlinkedIngredientCount { get; set; }
}
public class RecipeGeneratorSessionDto
{
public int Id { get; set; }
public string Status { get; set; } = string.Empty;
public RecipeGeneratorConstraintsDto Constraints { get; set; } = new();
public GeneratedRecipeDraftDto? Draft { get; set; }
public IReadOnlyList<GeneratedRecipeDraftDto> Drafts { get; set; } = Array.Empty<GeneratedRecipeDraftDto>();
public int? SavedRecipeId { get; set; }
public int GenerationVersion { get; set; }
}
public class GenerateRecipesRequestDto
{
public int Count { get; set; } = 3;
}
public class CommitRecipesRequestDto
{
public IReadOnlyList<string>? DraftIds { get; set; }
}
public class RegenerateRecipePartDto
{
public string DraftId { get; set; } = string.Empty;
public string Mode { get; set; } = "ingredients";
public string? Instruction { get; set; }
}
public class CommitRecipeResultDto
{
public int SessionId { get; set; }
public int RecipeId { get; set; }
public string RecipeName { get; set; } = string.Empty;
public string DraftId { get; set; } = string.Empty;
}
public class CommitRecipesResultDto
{
public int SessionId { get; set; }
public IReadOnlyList<CommitRecipeResultDto> Saved { get; set; } = Array.Empty<CommitRecipeResultDto>();
}

View File

@@ -0,0 +1,38 @@
namespace MealPlan.Api.DTOs.IngredientCatalog;
public class IngredientCategoryDto
{
public int Id { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int ItemCount { get; set; }
}
public class IngredientNutritionItemDto
{
public int Id { get; set; }
public int CategoryId { get; set; }
public string CategoryCode { get; set; } = string.Empty;
public string CategoryName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? NamePl { get; set; }
public int CaloriesPer100G { get; set; }
public decimal ProteinGPer100G { get; set; }
public decimal FatGPer100G { get; set; }
public decimal CarbsGPer100G { get; set; }
public decimal? FiberGPer100G { get; set; }
}
public class UpsertIngredientNutritionItemDto
{
public int CategoryId { get; set; }
public string Name { get; set; } = string.Empty;
public string? NamePl { get; set; }
public int CaloriesPer100G { get; set; }
public decimal ProteinGPer100G { get; set; }
public decimal FatGPer100G { get; set; }
public decimal CarbsGPer100G { get; set; }
public decimal? FiberGPer100G { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}

View File

@@ -0,0 +1,29 @@
namespace MealPlan.Api.DTOs.MealPlan;
public class MealPlanEntryDto
{
public int Id { get; set; }
public DateOnly PlanDate { get; set; }
public int MealCategory { get; set; }
public int RecipeId { get; set; }
public string RecipeName { get; set; } = string.Empty;
public decimal Portions { get; set; }
public int? Calories { get; set; }
}
public class UpsertMealPlanEntryDto
{
public DateOnly PlanDate { get; set; }
public int MealCategory { get; set; }
public int RecipeId { get; set; }
public decimal Portions { get; set; } = 1m;
}
public class ShoppingListItemDto
{
public string Name { get; set; } = string.Empty;
public string? Unit { get; set; }
public decimal? TotalGrams { get; set; }
public decimal? TotalAmount { get; set; }
public IReadOnlyList<string> Breakdown { get; set; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,59 @@
using System.ComponentModel.DataAnnotations;
namespace MealPlan.Api.DTOs.Recipe;
/// <summary>Payload for creating or updating a recipe with nested ingredients and steps.</summary>
public class CreateRecipeDto
{
[Required]
[MaxLength(255)]
public string Name { get; set; } = string.Empty;
/// <summary>0 = Breakfast, 1 = SecondBreakfast, 2 = Lunch, 3 = Dinner.</summary>
[Range(0, 3)]
public int MealCategory { get; set; }
[Range(0, 100000)]
public int? Calories { get; set; }
[Range(0, 10000)]
public decimal? Protein { get; set; }
[Range(0, 10000)]
public decimal? Fat { get; set; }
[Range(0, 10000)]
public decimal? Carbs { get; set; }
[Range(0, 100000)]
public int? PrepTimeMinutes { get; set; }
public List<CreateIngredientDto> Ingredients { get; set; } = new();
public List<CreateRecipeStepDto> Steps { get; set; } = new();
}
public class CreateIngredientDto
{
[Required]
[MaxLength(255)]
public string Name { get; set; } = string.Empty;
[Range(0, 100000)]
public decimal? AmountGrams { get; set; }
[MaxLength(50)]
public string? Unit { get; set; }
public int? CatalogItemId { get; set; }
public int SortOrder { get; set; }
}
public class CreateRecipeStepDto
{
[Range(1, 1000)]
public int StepNumber { get; set; }
[Required]
public string Description { get; set; } = string.Empty;
}

View File

@@ -23,6 +23,7 @@ public class IngredientDto
public decimal? AmountGrams { get; set; }
public string? Unit { get; set; }
public int SortOrder { get; set; }
public int? CatalogItemId { get; set; }
}
public class RecipeStepDto

View File

@@ -0,0 +1,10 @@
namespace MealPlan.Api.DTOs.Recipe;
/// <summary>Summary returned after an Excel import operation.</summary>
public class RecipeImportResultDto
{
public int CreatedCount { get; set; }
public int SkippedCount { get; set; }
public List<string> Errors { get; set; } = new();
public List<int> CreatedRecipeIds { get; set; } = new();
}

View File

@@ -0,0 +1,10 @@
namespace MealPlan.Api.DTOs.Recipe;
public class RecipeMacroRecalcResultDto
{
public int RecipesProcessed { get; init; }
public int RecipesUpdated { get; init; }
public int UnlinkedIngredientRows { get; init; }
}

View File

@@ -17,6 +17,23 @@ public class AppDbContext : DbContext
public DbSet<Ingredient> Ingredients => Set<Ingredient>();
public DbSet<RecipeStep> RecipeSteps => Set<RecipeStep>();
public DbSet<ApplicationUser> Users => Set<ApplicationUser>();
public DbSet<UserDailyGoal> UserDailyGoals => Set<UserDailyGoal>();
public DbSet<DrinkCatalogItem> DrinkCatalog => Set<DrinkCatalogItem>();
public DbSet<MealConsumption> MealConsumptions => Set<MealConsumption>();
public DbSet<DrinkConsumption> DrinkConsumptions => Set<DrinkConsumption>();
public DbSet<DietReminder> DietReminders => Set<DietReminder>();
public DbSet<IngredientCategory> IngredientCategories => Set<IngredientCategory>();
public DbSet<IngredientNutritionCatalogItem> IngredientNutritionCatalog => Set<IngredientNutritionCatalogItem>();
public DbSet<RefreshTokenEntity> RefreshTokens => Set<RefreshTokenEntity>();
public DbSet<MealPlanEntry> MealPlanEntries => Set<MealPlanEntry>();
public DbSet<UserFavoriteRecipe> UserFavoriteRecipes => Set<UserFavoriteRecipe>();
public DbSet<UserFavoriteCatalogItem> UserFavoriteCatalogItems => Set<UserFavoriteCatalogItem>();
public DbSet<DietUserProfile> DietUserProfiles => Set<DietUserProfile>();
public DbSet<DietGeneratorSession> DietGeneratorSessions => Set<DietGeneratorSession>();
public DbSet<DietGeneratorDraftMeal> DietGeneratorDraftMeals => Set<DietGeneratorDraftMeal>();
public DbSet<DietGeneratorDraftMealIngredient> DietGeneratorDraftMealIngredients =>
Set<DietGeneratorDraftMealIngredient>();
public DbSet<RecipeGeneratorSession> RecipeGeneratorSessions => Set<RecipeGeneratorSession>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -48,6 +65,10 @@ public class AppDbContext : DbContext
e.HasKey(i => i.Id);
e.Property(i => i.Id).UseIdentityByDefaultColumn();
e.Property(i => i.AmountGrams).HasColumnType("numeric(8,2)");
e.HasOne(i => i.CatalogItem)
.WithMany()
.HasForeignKey(i => i.CatalogItemId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<RecipeStep>(e =>
@@ -61,5 +82,180 @@ public class AppDbContext : DbContext
e.HasKey(u => u.Id);
e.Property(u => u.Id).HasColumnType("uuid");
});
modelBuilder.Entity<UserDailyGoal>(e =>
{
e.HasKey(g => g.UserId);
e.Property(g => g.UserId).HasColumnType("uuid");
e.Property(g => g.ProteinGoalG).HasColumnType("numeric(6,2)");
e.Property(g => g.FatGoalG).HasColumnType("numeric(6,2)");
e.Property(g => g.CarbsGoalG).HasColumnType("numeric(6,2)");
e.HasOne(g => g.User)
.WithMany()
.HasForeignKey(g => g.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<DrinkCatalogItem>(e =>
{
e.HasKey(d => d.Id);
e.Property(d => d.Id).UseIdentityByDefaultColumn();
e.Property(d => d.CaloriesPer100Ml).HasColumnType("numeric(6,2)");
});
modelBuilder.Entity<MealConsumption>(e =>
{
e.HasKey(m => m.Id);
e.Property(m => m.Id).UseIdentityByDefaultColumn();
e.Property(m => m.UserId).HasColumnType("uuid");
e.Property(m => m.Portions).HasColumnType("numeric(4,2)");
e.Property(m => m.ProteinG).HasColumnType("numeric(6,2)");
e.Property(m => m.FatG).HasColumnType("numeric(6,2)");
e.Property(m => m.CarbsG).HasColumnType("numeric(6,2)");
e.HasOne(m => m.User)
.WithMany()
.HasForeignKey(m => m.UserId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(m => m.Recipe)
.WithMany()
.HasForeignKey(m => m.RecipeId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<DrinkConsumption>(e =>
{
e.HasKey(d => d.Id);
e.Property(d => d.Id).UseIdentityByDefaultColumn();
e.Property(d => d.UserId).HasColumnType("uuid");
e.HasOne(d => d.User)
.WithMany()
.HasForeignKey(d => d.UserId)
.OnDelete(DeleteBehavior.Cascade);
e.HasOne(d => d.DrinkCatalog)
.WithMany()
.HasForeignKey(d => d.DrinkCatalogId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<DietReminder>(e =>
{
e.HasKey(r => r.Id);
e.Property(r => r.Id).UseIdentityByDefaultColumn();
e.Property(r => r.UserId).HasColumnType("uuid");
e.HasOne(r => r.User)
.WithMany()
.HasForeignKey(r => r.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<IngredientCategory>(e =>
{
e.HasKey(c => c.Id);
e.Property(c => c.Id).UseIdentityByDefaultColumn();
});
modelBuilder.Entity<IngredientNutritionCatalogItem>(e =>
{
e.HasKey(i => i.Id);
e.Property(i => i.Id).UseIdentityByDefaultColumn();
e.Property(i => i.ProteinGPer100G).HasColumnType("numeric(6,2)");
e.Property(i => i.FatGPer100G).HasColumnType("numeric(6,2)");
e.Property(i => i.CarbsGPer100G).HasColumnType("numeric(6,2)");
e.Property(i => i.FiberGPer100G).HasColumnType("numeric(6,2)");
e.HasOne(i => i.Category)
.WithMany(c => c.Items)
.HasForeignKey(i => i.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<RefreshTokenEntity>(e =>
{
e.HasKey(t => t.Token);
e.Property(t => t.UserId).HasColumnType("uuid");
e.HasOne(t => t.User)
.WithMany()
.HasForeignKey(t => t.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<MealPlanEntry>(e =>
{
e.HasKey(m => m.Id);
e.Property(m => m.Id).UseIdentityByDefaultColumn();
e.Property(m => m.UserId).HasColumnType("uuid");
e.Property(m => m.Portions).HasColumnType("numeric(4,2)");
e.HasOne(m => m.User).WithMany().HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(m => m.Recipe).WithMany().HasForeignKey(m => m.RecipeId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<UserFavoriteRecipe>(e =>
{
e.HasKey(f => new { f.UserId, f.RecipeId });
e.Property(f => f.UserId).HasColumnType("uuid");
e.HasOne(f => f.Recipe).WithMany().HasForeignKey(f => f.RecipeId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<UserFavoriteCatalogItem>(e =>
{
e.HasKey(f => new { f.UserId, f.CatalogItemId });
e.Property(f => f.UserId).HasColumnType("uuid");
e.HasOne(f => f.CatalogItem).WithMany().HasForeignKey(f => f.CatalogItemId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<DietUserProfile>(e =>
{
e.HasKey(p => p.UserId);
e.Property(p => p.UserId).HasColumnType("uuid");
e.Property(p => p.HeightCm).HasColumnType("numeric(5,2)");
e.Property(p => p.WeightKg).HasColumnType("numeric(5,2)");
e.HasOne(p => p.User)
.WithMany()
.HasForeignKey(p => p.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<DietGeneratorSession>(e =>
{
e.HasKey(s => s.Id);
e.Property(s => s.Id).UseIdentityByDefaultColumn();
e.Property(s => s.UserId).HasColumnType("uuid");
e.Property(s => s.ProposedProteinG).HasColumnType("numeric(6,2)");
e.Property(s => s.ProposedFatG).HasColumnType("numeric(6,2)");
e.Property(s => s.ProposedCarbsG).HasColumnType("numeric(6,2)");
e.Property(s => s.PreferencesJson).HasColumnType("jsonb");
e.HasMany(s => s.DraftMeals).WithOne(m => m.Session!).HasForeignKey(m => m.SessionId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<DietGeneratorDraftMeal>(e =>
{
e.HasKey(m => m.Id);
e.Property(m => m.Id).UseIdentityByDefaultColumn();
e.Property(m => m.Portions).HasColumnType("numeric(4,2)");
e.Property(m => m.ProteinG).HasColumnType("numeric(6,2)");
e.Property(m => m.FatG).HasColumnType("numeric(6,2)");
e.Property(m => m.CarbsG).HasColumnType("numeric(6,2)");
e.HasOne(m => m.Recipe).WithMany().HasForeignKey(m => m.RecipeId).OnDelete(DeleteBehavior.Cascade);
e.HasMany(m => m.PlannedIngredients).WithOne(i => i.DraftMeal!).HasForeignKey(i => i.DraftMealId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<DietGeneratorDraftMealIngredient>(e =>
{
e.HasKey(i => i.Id);
e.Property(i => i.Id).UseIdentityByDefaultColumn();
e.Property(i => i.AmountGrams).HasColumnType("numeric(8,2)");
e.HasOne(i => i.SourceIngredient).WithMany().HasForeignKey(i => i.SourceIngredientId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<RecipeGeneratorSession>(e =>
{
e.HasKey(s => s.Id);
e.Property(s => s.Id).UseIdentityByDefaultColumn();
e.Property(s => s.UserId).HasColumnType("uuid");
e.Property(s => s.ConstraintsJson).HasColumnType("jsonb");
e.Property(s => s.DraftJson).HasColumnType("jsonb");
e.HasOne(s => s.SavedRecipe).WithMany().HasForeignKey(s => s.SavedRecipeId).OnDelete(DeleteBehavior.SetNull);
});
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Data;
/// <summary>
/// Resyncs PostgreSQL identity sequences after bulk imports or manual ID inserts.
/// </summary>
public static class SequenceMaintenance
{
private static readonly (string Table, string Column)[] IdentityTables =
[
("Recipes", "Id"),
("Ingredients", "Id"),
("RecipeSteps", "Id"),
("RecipeGeneratorSessions", "Id"),
("DietGeneratorSessions", "Id"),
("DietGeneratorDraftMeals", "Id"),
("DietGeneratorDraftMealIngredients", "Id"),
("MealPlanEntries", "Id"),
];
public static async Task<IReadOnlyList<string>> ResyncIdentitySequencesAsync(
AppDbContext db,
CancellationToken ct = default)
{
var messages = new List<string>();
foreach (var (table, column) in IdentityTables)
{
var sql = $"""
SELECT setval(
pg_get_serial_sequence('diet."{table}"', '{column}'),
COALESCE((SELECT MAX("{column}") FROM diet."{table}"), 1),
(SELECT MAX("{column}") IS NOT NULL FROM diet."{table}"))
""";
try
{
await db.Database.ExecuteSqlRawAsync(sql, ct);
messages.Add($"Resynced diet.\"{table}\".\"{column}\" sequence.");
}
catch (Exception ex)
{
messages.Add($"Skipped diet.\"{table}\": {ex.Message}");
}
}
return messages;
}
}

View File

@@ -0,0 +1,27 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Extensions;
public static class ClaimsPrincipalExtensions
{
public static Guid? GetUserId(this ClaimsPrincipal user)
{
var raw = user.FindFirstValue(ClaimTypes.NameIdentifier)
?? user.FindFirstValue("sub");
return Guid.TryParse(raw, out var id) ? id : null;
}
public static IActionResult? RequireUserId(this ClaimsPrincipal user, out Guid userId)
{
var id = user.GetUserId();
if (id is null)
{
userId = Guid.Empty;
return new UnauthorizedResult();
}
userId = id.Value;
return null;
}
}

View File

@@ -10,6 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.104.2" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.3.1" />

View File

@@ -0,0 +1,41 @@
namespace MealPlan.Api.Models;
/// <summary>Meal slot when logging consumption. Extends recipe categories with snack.</summary>
public enum ConsumptionMealCategory
{
Breakfast = 0,
SecondBreakfast = 1,
Lunch = 2,
Dinner = 3,
Snack = 4,
}
public enum DietReminderType
{
Water = 0,
Meal = 1,
Snack = 2,
Custom = 3,
}
/// <summary>Bitmask for reminder days: Sun=1, Mon=2, Tue=4, Wed=8, Thu=16, Fri=32, Sat=64.</summary>
public static class ReminderDaysMask
{
public const int All = 127;
public static bool IncludesDay(int mask, DayOfWeek day)
{
var bit = day switch
{
DayOfWeek.Sunday => 1,
DayOfWeek.Monday => 2,
DayOfWeek.Tuesday => 4,
DayOfWeek.Wednesday => 8,
DayOfWeek.Thursday => 16,
DayOfWeek.Friday => 32,
DayOfWeek.Saturday => 64,
_ => 0,
};
return (mask & bit) != 0;
}
}

View File

@@ -0,0 +1,49 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("DietGeneratorDraftMeals")]
public class DietGeneratorDraftMeal
{
[Column("Id")]
public int Id { get; set; }
[Column("SessionId")]
public int SessionId { get; set; }
[Column("PlanDate")]
public DateOnly PlanDate { get; set; }
[Column("MealCategory")]
public int MealCategory { get; set; }
[Column("RecipeId")]
public int RecipeId { get; set; }
[Column("Portions")]
public decimal Portions { get; set; } = 1;
[Column("Calories")]
public int? Calories { get; set; }
[Column("ProteinG")]
public decimal? ProteinG { get; set; }
[Column("FatG")]
public decimal? FatG { get; set; }
[Column("CarbsG")]
public decimal? CarbsG { get; set; }
[Column("IsLocked")]
public bool IsLocked { get; set; }
[Column("GenerationVersion")]
public int GenerationVersion { get; set; } = 1;
public DietGeneratorSession? Session { get; set; }
public Recipe? Recipe { get; set; }
public ICollection<DietGeneratorDraftMealIngredient> PlannedIngredients { get; set; } =
new List<DietGeneratorDraftMealIngredient>();
}

View File

@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("DietGeneratorDraftMealIngredients")]
public class DietGeneratorDraftMealIngredient
{
[Column("Id")]
public int Id { get; set; }
[Column("DraftMealId")]
public int DraftMealId { get; set; }
[Column("SourceIngredientId")]
public int? SourceIngredientId { get; set; }
[Column("Name")]
[MaxLength(255)]
public string Name { get; set; } = string.Empty;
[Column("AmountGrams")]
public decimal? AmountGrams { get; set; }
[Column("Unit")]
[MaxLength(50)]
public string? Unit { get; set; }
[Column("SortOrder")]
public int SortOrder { get; set; }
public DietGeneratorDraftMeal? DraftMeal { get; set; }
public Ingredient? SourceIngredient { get; set; }
}

View File

@@ -0,0 +1,68 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("DietGeneratorSessions")]
public class DietGeneratorSession
{
[Column("Id")]
public int Id { get; set; }
[Column("UserId")]
public Guid UserId { get; set; }
[Column("Status")]
[MaxLength(32)]
public string Status { get; set; } = DietGeneratorStatuses.MacrosPending;
[Column("ProposedCalories")]
public int? ProposedCalories { get; set; }
[Column("ProposedProteinG")]
public decimal? ProposedProteinG { get; set; }
[Column("ProposedFatG")]
public decimal? ProposedFatG { get; set; }
[Column("ProposedCarbsG")]
public decimal? ProposedCarbsG { get; set; }
[Column("MacroRationale")]
public string? MacroRationale { get; set; }
[Column("MacrosAcceptedAt")]
public DateTime? MacrosAcceptedAt { get; set; }
[Column("PlanStartDate")]
public DateOnly? PlanStartDate { get; set; }
[Column("PlanDayCount")]
public int PlanDayCount { get; set; } = 7;
[Column("CalorieToleranceKcal")]
public int CalorieToleranceKcal { get; set; } = 10;
[Column("PreferencesJson")]
public string? PreferencesJson { get; set; }
[Column("CommittedAt")]
public DateTime? CommittedAt { get; set; }
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt")]
public DateTime? UpdatedAt { get; set; }
public ICollection<DietGeneratorDraftMeal> DraftMeals { get; set; } = new List<DietGeneratorDraftMeal>();
}
public static class DietGeneratorStatuses
{
public const string MacrosPending = "macros_pending";
public const string MacrosAccepted = "macros_accepted";
public const string PlanDraft = "plan_draft";
public const string Committed = "committed";
public const string Abandoned = "abandoned";
}

View File

@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("DietReminders")]
public class DietReminder
{
[Column("Id")]
public int Id { get; set; }
[Column("UserId")]
public Guid UserId { get; set; }
[Column("ReminderType")]
public int ReminderType { get; set; }
[Column("Title")]
[MaxLength(120)]
public string Title { get; set; } = string.Empty;
[Column("Message")]
[MaxLength(500)]
public string? Message { get; set; }
[Column("TimeOfDay")]
public TimeOnly TimeOfDay { get; set; }
[Column("DaysOfWeekMask")]
public int DaysOfWeekMask { get; set; } = ReminderDaysMask.All;
[Column("MealCategory")]
public int? MealCategory { get; set; }
[Column("IsEnabled")]
public bool IsEnabled { get; set; } = true;
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt")]
public DateTime? UpdatedAt { get; set; }
public ApplicationUser? User { get; set; }
}

View File

@@ -0,0 +1,49 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("DietUserProfiles")]
public class DietUserProfile
{
[Column("UserId")]
public Guid UserId { get; set; }
[Column("HeightCm")]
public decimal HeightCm { get; set; }
[Column("WeightKg")]
public decimal WeightKg { get; set; }
[Column("Age")]
public int Age { get; set; }
[Column("Sex")]
[MaxLength(16)]
public string Sex { get; set; } = "male";
[Column("ActivityLevel")]
[MaxLength(32)]
public string ActivityLevel { get; set; } = "moderate";
[Column("Goal")]
[MaxLength(32)]
public string Goal { get; set; } = "maintain";
[Column("AllergiesNotes")]
public string? AllergiesNotes { get; set; }
[Column("DislikedFoods")]
public string? DislikedFoods { get; set; }
[Column("MealsPerDayMask")]
public int MealsPerDayMask { get; set; } = 15;
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt")]
public DateTime? UpdatedAt { get; set; }
public ApplicationUser? User { get; set; }
}

View File

@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("DrinkCatalog")]
public class DrinkCatalogItem
{
[Column("Id")]
public int Id { get; set; }
[Column("Code")]
[MaxLength(32)]
public string Code { get; set; } = string.Empty;
[Column("Name")]
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
[Column("DefaultVolumeMl")]
public int DefaultVolumeMl { get; set; }
[Column("CaloriesPer100Ml")]
public decimal? CaloriesPer100Ml { get; set; }
[Column("IconEmoji")]
[MaxLength(16)]
public string? IconEmoji { get; set; }
[Column("SortOrder")]
public int SortOrder { get; set; }
[Column("IsActive")]
public bool IsActive { get; set; }
}

View File

@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("DrinkConsumptions")]
public class DrinkConsumption
{
[Column("Id")]
public int Id { get; set; }
[Column("UserId")]
public Guid UserId { get; set; }
[Column("DrinkCatalogId")]
public int? DrinkCatalogId { get; set; }
[Column("LogDate")]
public DateOnly LogDate { get; set; }
[Column("ConsumedAt")]
public DateTime ConsumedAt { get; set; }
[Column("Name")]
[MaxLength(255)]
public string Name { get; set; } = string.Empty;
[Column("VolumeMl")]
public int VolumeMl { get; set; }
[Column("Calories")]
public int? Calories { get; set; }
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
public ApplicationUser? User { get; set; }
public DrinkCatalogItem? DrinkCatalog { get; set; }
}

View File

@@ -29,5 +29,9 @@ public class Ingredient
[Column("SortOrder")]
public int SortOrder { get; set; }
[Column("CatalogItemId")]
public int? CatalogItemId { get; set; }
public Recipe? Recipe { get; set; }
public IngredientNutritionCatalogItem? CatalogItem { get; set; }
}

View File

@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("IngredientCategories")]
public class IngredientCategory
{
[Column("Id")]
public int Id { get; set; }
[Column("Code")]
[MaxLength(32)]
public string Code { get; set; } = string.Empty;
[Column("Name")]
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
[Column("SortOrder")]
public int SortOrder { get; set; }
[Column("IsActive")]
public bool IsActive { get; set; } = true;
public ICollection<IngredientNutritionCatalogItem> Items { get; set; } = new List<IngredientNutritionCatalogItem>();
}

View File

@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("IngredientNutritionCatalog")]
public class IngredientNutritionCatalogItem
{
[Column("Id")]
public int Id { get; set; }
[Column("CategoryId")]
public int CategoryId { get; set; }
[Column("Name")]
[MaxLength(255)]
public string Name { get; set; } = string.Empty;
[Column("NamePl")]
[MaxLength(255)]
public string? NamePl { get; set; }
[Column("CaloriesPer100G")]
public int CaloriesPer100G { get; set; }
[Column("ProteinGPer100G")]
public decimal ProteinGPer100G { get; set; }
[Column("FatGPer100G")]
public decimal FatGPer100G { get; set; }
[Column("CarbsGPer100G")]
public decimal CarbsGPer100G { get; set; }
[Column("FiberGPer100G")]
public decimal? FiberGPer100G { get; set; }
[Column("SortOrder")]
public int SortOrder { get; set; }
[Column("IsActive")]
public bool IsActive { get; set; } = true;
public IngredientCategory? Category { get; set; }
}

View File

@@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("MealConsumptions")]
public class MealConsumption
{
[Column("Id")]
public int Id { get; set; }
[Column("UserId")]
public Guid UserId { get; set; }
[Column("RecipeId")]
public int? RecipeId { get; set; }
[Column("MealCategory")]
public int MealCategory { get; set; }
[Column("LogDate")]
public DateOnly LogDate { get; set; }
[Column("ConsumedAt")]
public DateTime ConsumedAt { get; set; }
[Column("Name")]
[MaxLength(255)]
public string Name { get; set; } = string.Empty;
[Column("Portions")]
public decimal Portions { get; set; } = 1m;
[Column("Calories")]
public int? Calories { get; set; }
[Column("ProteinG")]
public decimal? ProteinG { get; set; }
[Column("FatG")]
public decimal? FatG { get; set; }
[Column("CarbsG")]
public decimal? CarbsG { get; set; }
[Column("Notes")]
public string? Notes { get; set; }
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
public ApplicationUser? User { get; set; }
public Recipe? Recipe { get; set; }
}

View File

@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("MealPlanEntries")]
public class MealPlanEntry
{
[Column("Id")]
public int Id { get; set; }
[Column("UserId")]
public Guid UserId { get; set; }
[Column("PlanDate")]
public DateOnly PlanDate { get; set; }
[Column("MealCategory")]
public int MealCategory { get; set; }
[Column("RecipeId")]
public int RecipeId { get; set; }
[Column("Portions")]
public decimal Portions { get; set; } = 1m;
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt")]
public DateTime? UpdatedAt { get; set; }
public ApplicationUser? User { get; set; }
public Recipe? Recipe { get; set; }
}

View File

@@ -44,6 +44,16 @@ public class Recipe
[Column("IsActive")]
public bool IsActive { get; set; }
[Column("Source")]
[MaxLength(32)]
public string Source { get; set; } = "manual";
[Column("CreatedByUserId")]
public Guid? CreatedByUserId { get; set; }
[Column("IsDraft")]
public bool IsDraft { get; set; }
public ICollection<Ingredient> Ingredients { get; set; } = new List<Ingredient>();
public ICollection<RecipeStep> Steps { get; set; } = new List<RecipeStep>();
}

View File

@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("RecipeGeneratorSessions")]
public class RecipeGeneratorSession
{
[Column("Id")]
public int Id { get; set; }
[Column("UserId")]
public Guid UserId { get; set; }
[Column("Status")]
[MaxLength(32)]
public string Status { get; set; } = RecipeGeneratorStatuses.Draft;
[Column("ConstraintsJson")]
public string ConstraintsJson { get; set; } = "{}";
[Column("DraftJson")]
public string? DraftJson { get; set; }
[Column("SavedRecipeId")]
public int? SavedRecipeId { get; set; }
[Column("GenerationVersion")]
public int GenerationVersion { get; set; } = 1;
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt")]
public DateTime? UpdatedAt { get; set; }
public Recipe? SavedRecipe { get; set; }
}
public static class RecipeGeneratorStatuses
{
public const string Draft = "draft";
public const string Saved = "saved";
public const string Abandoned = "abandoned";
}

View File

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("RefreshTokens")]
public class RefreshTokenEntity
{
[Column("Token")]
public string Token { get; set; } = string.Empty;
[Column("UserId")]
public Guid UserId { get; set; }
[Column("ExpiresAtUtc")]
public DateTime ExpiresAtUtc { get; set; }
[Column("CreatedAtUtc")]
public DateTime CreatedAtUtc { get; set; }
[Column("IsRevoked")]
public bool IsRevoked { get; set; }
public bool IsActive => !IsRevoked && DateTime.UtcNow < ExpiresAtUtc;
public ApplicationUser? User { get; set; }
}

View File

@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("UserDailyGoals")]
public class UserDailyGoal
{
[Column("UserId")]
public Guid UserId { get; set; }
[Column("CalorieGoal")]
public int? CalorieGoal { get; set; }
[Column("ProteinGoalG")]
public decimal? ProteinGoalG { get; set; }
[Column("FatGoalG")]
public decimal? FatGoalG { get; set; }
[Column("CarbsGoalG")]
public decimal? CarbsGoalG { get; set; }
[Column("WaterGoalMl")]
public int? WaterGoalMl { get; set; }
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt")]
public DateTime? UpdatedAt { get; set; }
public ApplicationUser? User { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("UserFavoriteCatalogItems")]
public class UserFavoriteCatalogItem
{
[Column("UserId")]
public Guid UserId { get; set; }
[Column("CatalogItemId")]
public int CatalogItemId { get; set; }
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
public IngredientNutritionCatalogItem? CatalogItem { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace MealPlan.Api.Models;
[Table("UserFavoriteRecipes")]
public class UserFavoriteRecipe
{
[Column("UserId")]
public Guid UserId { get; set; }
[Column("RecipeId")]
public int RecipeId { get; set; }
[Column("CreatedAt")]
public DateTime CreatedAt { get; set; }
public Recipe? Recipe { get; set; }
}

View File

@@ -62,12 +62,23 @@ builder.Services.AddDbContext<AppDbContext>(options =>
// ---------------------------------------------------------------------------
// Application services
// ---------------------------------------------------------------------------
builder.Services.AddSingleton<IRefreshTokenStore, InMemoryRefreshTokenStore>();
builder.Services.AddSingleton<IRefreshTokenStore, DbRefreshTokenStore>();
builder.Services.AddHostedService<RefreshTokenCleanupService>();
builder.Services.AddSingleton<IPasswordHasher<ApplicationUser>, PasswordHasher<ApplicationUser>>();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IRecipeService, RecipeService>();
builder.Services.AddScoped<IRecipeManagementService, RecipeManagementService>();
builder.Services.AddScoped<IRecipeExcelService, RecipeExcelService>();
builder.Services.AddScoped<IDietTrackingService, DietTrackingService>();
builder.Services.AddScoped<IIngredientCatalogService, IngredientCatalogService>();
builder.Services.AddScoped<IMealPlanService, MealPlanService>();
builder.Services.Configure<MealPlan.Api.Services.AI.OpenAiOptions>(
builder.Configuration.GetSection(MealPlan.Api.Services.AI.OpenAiOptions.SectionName));
builder.Services.AddHttpClient<MealPlan.Api.Services.AI.IOpenAiChatService, MealPlan.Api.Services.AI.OpenAiChatService>();
builder.Services.AddScoped<MealPlan.Api.Services.Generators.ICatalogMatchingService, MealPlan.Api.Services.Generators.CatalogMatchingService>();
builder.Services.AddScoped<MealPlan.Api.Services.Generators.IDietGeneratorService, MealPlan.Api.Services.Generators.DietGeneratorService>();
builder.Services.AddScoped<MealPlan.Api.Services.Generators.IRecipeGeneratorService, MealPlan.Api.Services.Generators.RecipeGeneratorService>();
// ---------------------------------------------------------------------------
// Authentication / authorization (JWT bearer)
@@ -163,6 +174,19 @@ builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
var app = builder.Build();
if (args.Contains("--fix-sequences", StringComparer.OrdinalIgnoreCase))
{
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var messages = await SequenceMaintenance.ResyncIdentitySequencesAsync(db);
foreach (var message in messages)
{
Console.WriteLine(message);
}
return;
}
// ---------------------------------------------------------------------------
// HTTP request pipeline
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,12 @@
namespace MealPlan.Api.Services.AI;
public interface IOpenAiChatService
{
bool IsConfigured { get; }
Task<string> CompleteJsonAsync(
string systemPrompt,
string userPrompt,
OpenAiUseCase useCase = OpenAiUseCase.Default,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,80 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace MealPlan.Api.Services.AI;
public class OpenAiChatService : IOpenAiChatService
{
private readonly HttpClient _http;
private readonly OpenAiOptions _options;
private readonly ILogger<OpenAiChatService> _logger;
public OpenAiChatService(HttpClient http, IOptions<OpenAiOptions> options, ILogger<OpenAiChatService> logger)
{
_http = http;
_options = options.Value;
_logger = logger;
}
public bool IsConfigured => _options.IsConfigured;
public async Task<string> CompleteJsonAsync(
string systemPrompt,
string userPrompt,
OpenAiUseCase useCase = OpenAiUseCase.Default,
CancellationToken ct = default)
{
if (!IsConfigured)
{
throw new InvalidOperationException(
"OpenAI is not configured. Set OpenAI__ApiKey in appsettings.Local.json.");
}
var model = _options.ResolveModel(useCase);
var payload = new
{
model,
max_tokens = _options.MaxTokens,
response_format = new { type = "json_object" },
messages = new[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt },
},
};
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var response = await _http.SendAsync(request, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("OpenAI API error {Status}: {Body}", response.StatusCode, body);
throw new InvalidOperationException("OpenAI request failed. Check API key and quota.");
}
using var doc = JsonDocument.Parse(body);
var content = doc.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString() ?? "{}";
return StripMarkdownFence(content);
}
private static string StripMarkdownFence(string content)
{
var trimmed = content.Trim();
if (!trimmed.StartsWith("```", StringComparison.Ordinal)) return trimmed;
var firstNewline = trimmed.IndexOf('\n');
if (firstNewline < 0) return trimmed;
var endFence = trimmed.LastIndexOf("```", StringComparison.Ordinal);
if (endFence <= firstNewline) return trimmed;
return trimmed[(firstNewline + 1)..endFence].Trim();
}
}

View File

@@ -0,0 +1,44 @@
namespace MealPlan.Api.Services.AI;
public enum OpenAiUseCase
{
Default,
Macros,
Plan,
Recipe,
}
public class OpenAiOptions
{
public const string SectionName = "OpenAI";
public string ApiKey { get; set; } = string.Empty;
/// <summary>Fallback when a task-specific model is not set.</summary>
public string Model { get; set; } = "gpt-4.1-mini";
/// <summary>Macro target refinement (diet generator step 2).</summary>
public string? MacrosModel { get; set; }
/// <summary>Weekly meal plan recipe selection (diet generator step 3).</summary>
public string? PlanModel { get; set; }
/// <summary>AI recipe draft generation and partial regen.</summary>
public string? RecipeModel { get; set; }
public int MaxTokens { get; set; } = 4096;
public bool IsConfigured => !string.IsNullOrWhiteSpace(ApiKey);
public string ResolveModel(OpenAiUseCase useCase) =>
useCase switch
{
OpenAiUseCase.Macros => FirstNonEmpty(MacrosModel, Model),
OpenAiUseCase.Plan => FirstNonEmpty(PlanModel, Model),
OpenAiUseCase.Recipe => FirstNonEmpty(RecipeModel, Model),
_ => Model,
};
private static string FirstNonEmpty(string? preferred, string fallback) =>
string.IsNullOrWhiteSpace(preferred) ? fallback : preferred;
}

View File

@@ -0,0 +1,68 @@
using MealPlan.Api.Data;
using MealPlan.Api.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace MealPlan.Api.Services;
/// <summary>Persists refresh tokens in PostgreSQL (survives API restarts).</summary>
public class DbRefreshTokenStore : IRefreshTokenStore
{
private readonly IServiceScopeFactory _scopeFactory;
public DbRefreshTokenStore(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
public void Add(RefreshToken token)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.RefreshTokens.Add(new RefreshTokenEntity
{
Token = token.Token,
UserId = Guid.Parse(token.UserId),
ExpiresAtUtc = token.ExpiresAtUtc,
CreatedAtUtc = token.CreatedAtUtc,
IsRevoked = false,
});
db.SaveChanges();
}
public RefreshToken? Get(string token)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var entity = db.RefreshTokens.AsNoTracking().FirstOrDefault(t => t.Token == token);
return entity is null ? null : Map(entity);
}
public RefreshToken? Revoke(string token)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var entity = db.RefreshTokens.FirstOrDefault(t => t.Token == token);
if (entity is null) return null;
var wasActive = entity.IsActive;
entity.IsRevoked = true;
db.SaveChanges();
return wasActive ? Map(entity) : null;
}
public void RemoveExpired()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff = DateTime.UtcNow;
db.RefreshTokens
.Where(t => t.IsRevoked || t.ExpiresAtUtc <= cutoff)
.ExecuteDelete();
}
private static RefreshToken Map(RefreshTokenEntity entity) => new()
{
Token = entity.Token,
UserId = entity.UserId.ToString(),
ExpiresAtUtc = entity.ExpiresAtUtc,
CreatedAtUtc = entity.CreatedAtUtc,
IsRevoked = entity.IsRevoked,
};
}

View 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;
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json;
namespace MealPlan.Api.Services.Generators;
internal static class AiRecipeJsonParser
{
public static int GetInt32(JsonElement element, int fallback = 0)
{
return element.ValueKind switch
{
JsonValueKind.Number when element.TryGetInt32(out var value) => value,
JsonValueKind.Number => (int)element.GetDecimal(),
JsonValueKind.String when int.TryParse(element.GetString(), out var parsed) => parsed,
JsonValueKind.True => 1,
JsonValueKind.False => 0,
_ => fallback,
};
}
public static int? GetNullableInt32(JsonElement element)
{
if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) return null;
return GetInt32(element);
}
public static decimal? GetNullableDecimal(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Null or JsonValueKind.Undefined => null,
JsonValueKind.Number => element.GetDecimal(),
JsonValueKind.String when decimal.TryParse(element.GetString(), out var parsed) => parsed,
_ => null,
};
}
public static string? GetString(JsonElement element) =>
element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString();
}

View File

@@ -0,0 +1,60 @@
using MealPlan.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Services.Generators;
public interface ICatalogMatchingService
{
Task<int?> ResolveCatalogItemIdAsync(string nameOrCatalogName, CancellationToken ct = default);
}
public class CatalogMatchingService : ICatalogMatchingService
{
private readonly AppDbContext _db;
private List<CatalogEntry>? _cache;
public CatalogMatchingService(AppDbContext db) => _db = db;
public async Task<int?> ResolveCatalogItemIdAsync(string nameOrCatalogName, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(nameOrCatalogName)) return null;
var cache = await GetCacheAsync(ct);
var key = Normalize(nameOrCatalogName);
var exact = cache.FirstOrDefault(c =>
c.NameKey == key || c.NamePlKey == key || c.CatalogNameKey == key);
if (exact is not null) return exact.Id;
return cache
.Where(c => key.Contains(c.NameKey, StringComparison.Ordinal) ||
c.NameKey.Contains(key, StringComparison.Ordinal) ||
(!string.IsNullOrEmpty(c.NamePlKey) &&
(key.Contains(c.NamePlKey, StringComparison.Ordinal) ||
c.NamePlKey.Contains(key, StringComparison.Ordinal))))
.OrderBy(c => Math.Abs(c.NameKey.Length - key.Length))
.FirstOrDefault()
?.Id;
}
private async Task<List<CatalogEntry>> GetCacheAsync(CancellationToken ct)
{
if (_cache is not null) return _cache;
_cache = await _db.IngredientNutritionCatalog.AsNoTracking()
.Where(i => i.IsActive)
.Select(i => new CatalogEntry(i.Id, i.Name, i.NamePl))
.ToListAsync(ct);
return _cache;
}
private static string Normalize(string value) => value.Trim().ToLowerInvariant();
private sealed record CatalogEntry(int Id, string Name, string? NamePl)
{
public string NameKey { get; } = Name.Trim().ToLowerInvariant();
public string NamePlKey { get; } = NamePl?.Trim().ToLowerInvariant() ?? string.Empty;
public string CatalogNameKey { get; } = Name.Trim().ToLowerInvariant();
}
}

View File

@@ -0,0 +1,780 @@
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<DietUserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct = default);
Task<DietUserProfileDto> UpsertProfileAsync(Guid userId, DietUserProfileDto dto, CancellationToken ct = default);
Task<DietGeneratorSessionDto> CreateSessionAsync(Guid userId, CreateDietGeneratorSessionDto dto, CancellationToken ct = default);
Task<DietGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default);
Task<DietGeneratorSessionDto?> ProposeMacrosAsync(Guid userId, int sessionId, CancellationToken ct = default);
Task<DietGeneratorSessionDto?> AcceptMacrosAsync(Guid userId, int sessionId, AcceptMacrosDto dto, CancellationToken ct = default);
Task<DietGeneratorSessionDto?> GeneratePlanAsync(Guid userId, int sessionId, CancellationToken ct = default);
Task<DietGeneratorSessionDto?> RegenerateMealAsync(Guid userId, int sessionId, RegenerateDietMealDto dto, CancellationToken ct = default);
Task<DietGeneratorSessionDto?> UpdateDraftMealAsync(Guid userId, int sessionId, int mealId, UpdateDraftMealDto dto, CancellationToken ct = default);
Task<CommitDietPlanResultDto?> 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<DietUserProfileDto?> 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<DietUserProfileDto> 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<DietGeneratorSessionDto> 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<DietGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default)
{
await BackfillMissingDraftIngredientsAsync(userId, sessionId, ct);
return await MapSessionAsync(sessionId, userId, ct);
}
public async Task<DietGeneratorSessionDto?> 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<DietGeneratorSessionDto?> 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<DietGeneratorSessionDto?> 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<int>();
var allSlots = new List<DraftMealSlot>();
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<DietGeneratorSessionDto?> 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<DietGeneratorSessionDto?> 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<CommitDietPlanResultDto?> 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<DraftMealSlot> BuildGreedyPlan(
DietGeneratorSession session,
IReadOnlyList<RecipeCandidate> pool,
IReadOnlyList<int> mealCategories,
DateOnly start,
HashSet<int> usedRecipes)
{
var allSlots = new List<DraftMealSlot>();
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<List<DraftMealSlot>> GeneratePlanWithAiAsync(
DietGeneratorSession session,
IReadOnlyList<RecipeCandidate> pool,
IReadOnlyList<int> mealCategories,
DateOnly start,
HashSet<int> 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<DraftMealSlot>();
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<DraftMealSlot>();
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<List<RecipeCandidate>> 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<DietGeneratorSession?> 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<DietGeneratorSessionDto?> MapSessionAsync(int sessionId, CancellationToken ct) =>
MapSessionAsync(sessionId, null, ct);
private async Task<DietGeneratorSessionDto?> 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,
};
}

View File

@@ -0,0 +1,64 @@
namespace MealPlan.Api.Services.Generators;
public static class IngredientScalingHelper
{
private const decimal CountAmountThreshold = 10m;
public static (decimal? AmountGrams, string? Unit) ScaleForDietPlan(
decimal? amountGrams,
string? unit,
decimal portions)
{
if (IsNonQuantifiedUnit(unit))
{
return (amountGrams, unit);
}
if (amountGrams is not > 0)
{
return (null, unit);
}
if (IsCountUnit(unit) && amountGrams.Value <= CountAmountThreshold)
{
var gramsPerUnit = GramsPerCountUnit(unit!);
var totalGrams = amountGrams.Value * gramsPerUnit * portions;
return (Math.Round(totalGrams, 1), null);
}
// AmountGrams stores weight in grams (even when the display unit says szt./ząbek).
return (Math.Round(amountGrams.Value * portions, 1), null);
}
public static bool IsNonQuantifiedUnit(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";
}
private static bool IsCountUnit(string? unit)
{
if (string.IsNullOrWhiteSpace(unit)) return false;
var u = NormalizeUnit(unit);
return u is "szt" or "sztuka" or "sztuki"
or "ząbek" or "zabek" or "zabki" or "clove" or "cloves"
or "łyżka" or "lyzka" or "łyżeczka" or "lyzeczka"
or "jajko" or "jajka" or "egg" or "eggs";
}
private static decimal GramsPerCountUnit(string unit)
{
var u = NormalizeUnit(unit);
return u switch
{
"ząbek" or "zabek" or "zabki" or "clove" or "cloves" => 4m,
"łyżka" or "lyzka" => 15m,
"łyżeczka" or "lyzeczka" => 5m,
_ => 60m, // szt., jajko — typical single egg / piece default
};
}
private static string NormalizeUnit(string unit) =>
unit.Trim().ToLowerInvariant().TrimEnd('.');
}

View File

@@ -0,0 +1,221 @@
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<int> MealCategoriesFromMask(int mask) =>
Enumerable.Range(0, 5).Where(i => (mask & (1 << i)) != 0).ToList();
public static void BalanceDay(IList<DraftMealSlot> 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<DraftMealSlot> meals, int targetCalories, int tolerance) =>
Math.Abs(meals.Sum(m => m.TotalCalories) - targetCalories) <= tolerance;
private static RecipeCandidate? FindGreedyCandidate(
IReadOnlyList<RecipeCandidate> pool,
int mealCategory,
int targetCaloriesForSlot,
HashSet<int> 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<RecipeCandidate> pool,
int mealCategory,
int targetCaloriesForSlot,
HashSet<int> 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<RecipeCandidate> pool,
int mealCategory,
int targetCaloriesForSlot,
HashSet<int> excludedRecipeIds)
{
if (TryPickGreedyMeal(pool, mealCategory, targetCaloriesForSlot, excludedRecipeIds, out var slot))
{
return slot;
}
throw new InvalidOperationException("No recipes available for meal planning.");
}
public static IReadOnlyList<DraftMealSlot> BuildGreedyDay(
DateOnly planDate,
IReadOnlyList<RecipeCandidate> pool,
IReadOnlyList<int> mealCategories,
int targetCalories,
int tolerance,
HashSet<int> excludedRecipeIds)
{
var perSlot = mealCategories.Count > 0 ? targetCalories / mealCategories.Count : targetCalories;
var meals = new List<DraftMealSlot>();
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<int> mealCategories) =>
planDayCount * mealCategories.Count;
/// <summary>
/// Replaces duplicate recipe picks so each recipe appears at most once in the plan.
/// </summary>
public static void EnsureWeeklyUniqueRecipes(
IList<DraftMealSlot> slots,
IReadOnlyList<RecipeCandidate> pool,
IReadOnlyList<int> mealCategories,
int targetCalories,
int tolerance)
{
if (slots.Count == 0) return;
var used = new HashSet<int>();
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;
}
}

View File

@@ -0,0 +1,683 @@
using System.Text.Json;
using MealPlan.Api.Data;
using MealPlan.Api.DTOs.Generators;
using MealPlan.Api.DTOs.Recipe;
using MealPlan.Api.Models;
using MealPlan.Api.Services.AI;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace MealPlan.Api.Services.Generators;
public interface IRecipeGeneratorService
{
Task<RecipeGeneratorSessionDto> CreateSessionAsync(Guid userId, RecipeGeneratorConstraintsDto dto, CancellationToken ct = default);
Task<RecipeGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default);
Task<RecipeGeneratorSessionDto?> GenerateAsync(Guid userId, int sessionId, GenerateRecipesRequestDto? request, CancellationToken ct = default);
Task<RecipeGeneratorSessionDto?> RegeneratePartAsync(Guid userId, int sessionId, RegenerateRecipePartDto dto, CancellationToken ct = default);
Task<RecipeGeneratorSessionDto?> UpdateDraftAsync(Guid userId, int sessionId, GeneratedRecipeDraftDto dto, CancellationToken ct = default);
Task<RecipeGeneratorSessionDto?> RemoveDraftAsync(Guid userId, int sessionId, string draftId, CancellationToken ct = default);
Task<CommitRecipesResultDto?> CommitAsync(Guid userId, int sessionId, CommitRecipesRequestDto? request, CancellationToken ct = default);
}
public class RecipeGeneratorService : IRecipeGeneratorService
{
private readonly AppDbContext _db;
private readonly IOpenAiChatService _openAi;
private readonly ICatalogMatchingService _catalogMatching;
private readonly IRecipeManagementService _recipeManagement;
public RecipeGeneratorService(
AppDbContext db,
IOpenAiChatService openAi,
ICatalogMatchingService catalogMatching,
IRecipeManagementService recipeManagement)
{
_db = db;
_openAi = openAi;
_catalogMatching = catalogMatching;
_recipeManagement = recipeManagement;
}
public async Task<RecipeGeneratorSessionDto> CreateSessionAsync(
Guid userId,
RecipeGeneratorConstraintsDto dto,
CancellationToken ct = default)
{
var session = new RecipeGeneratorSession
{
UserId = userId,
Status = RecipeGeneratorStatuses.Draft,
ConstraintsJson = JsonSerializer.Serialize(dto),
CreatedAt = DateTime.UtcNow,
};
_db.RecipeGeneratorSessions.Add(session);
await _db.SaveChangesAsync(ct);
return MapSession(session, dto, []);
}
public async Task<RecipeGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default)
{
var session = await _db.RecipeGeneratorSessions.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct);
if (session is null) return null;
var constraints = DeserializeConstraints(session.ConstraintsJson);
var drafts = DeserializeDrafts(session.DraftJson);
return MapSession(session, constraints, drafts);
}
public async Task<RecipeGeneratorSessionDto?> GenerateAsync(
Guid userId,
int sessionId,
GenerateRecipesRequestDto? request,
CancellationToken ct = default)
{
var session = await _db.RecipeGeneratorSessions
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
if (session is null) return null;
if (!_openAi.IsConfigured)
{
throw new InvalidOperationException(
"OpenAI is not configured. Set OpenAI__ApiKey in appsettings.Local.json.");
}
var count = Math.Clamp(request?.Count ?? 3, 1, 8);
var constraints = DeserializeConstraints(session.ConstraintsJson);
if (string.IsNullOrWhiteSpace(constraints.Prompt))
{
throw new InvalidOperationException("Recipe prompt is required.");
}
var catalogSample = await _db.IngredientNutritionCatalog.AsNoTracking()
.Where(i => i.IsActive)
.OrderBy(i => i.Name)
.Take(80)
.Select(i => new { i.Name, i.NamePl, i.CaloriesPer100G, i.ProteinGPer100G, i.FatGPer100G, i.CarbsGPer100G })
.ToListAsync(ct);
var drafts = await GenerateDistinctDraftsAsync(constraints, catalogSample, count, ct);
if (drafts.Count == 0)
{
throw new InvalidOperationException("AI did not return any valid recipes. Try again.");
}
var enriched = new List<GeneratedRecipeDraftDto>();
foreach (var draft in drafts)
{
enriched.Add(await EnrichDraftAsync(EnsureDraftId(draft), constraints, ct));
}
session.DraftJson = SerializeDrafts(enriched);
session.GenerationVersion++;
session.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return MapSession(session, constraints, enriched);
}
public async Task<RecipeGeneratorSessionDto?> RegeneratePartAsync(
Guid userId,
int sessionId,
RegenerateRecipePartDto dto,
CancellationToken ct = default)
{
var session = await _db.RecipeGeneratorSessions
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
if (session is null) return null;
var constraints = DeserializeConstraints(session.ConstraintsJson);
var drafts = DeserializeDrafts(session.DraftJson);
if (string.IsNullOrWhiteSpace(dto.DraftId))
return MapSession(session, constraints, drafts);
var index = drafts.FindIndex(d => d.DraftId == dto.DraftId);
if (index < 0) return null;
var current = drafts[index];
if (!_openAi.IsConfigured)
{
throw new InvalidOperationException(
"OpenAI is not configured. Set OpenAI__ApiKey in appsettings.Local.json.");
}
var mode = dto.Mode.ToLowerInvariant();
var system = mode switch
{
"steps" => """
Rewrite ONLY cooking steps for the recipe. JSON: {"steps":[{"stepNumber":1,"description":"..."}]}
Keep ingredients unchanged.
""",
"ingredients" => """
Rewrite ONLY ingredients list. JSON: {"ingredients":[{"name":"","amountGrams":0,"unit":null,"catalogName":""}]}
Keep name and steps unchanged.
""",
_ => """
Improve the full recipe. Respond ONLY JSON with name, mealCategory, prepTimeMinutes, ingredients, steps.
Use JSON numbers (not strings) for mealCategory, prepTimeMinutes, amountGrams, stepNumber.
""",
};
var user = JsonSerializer.Serialize(new
{
instruction = dto.Instruction ?? constraints.Prompt,
current,
constraints,
});
var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct);
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (mode == "steps" && root.TryGetProperty("steps", out var stepsEl))
{
current.Steps = ParseSteps(stepsEl);
}
else if (mode == "ingredients" && root.TryGetProperty("ingredients", out var ingEl))
{
current.Ingredients = await ParseIngredientsAsync(ingEl, ct);
}
else
{
var parsed = ParseDraft(json, constraints.MealCategory);
parsed.DraftId = current.DraftId;
current = parsed;
}
}
catch (JsonException ex)
{
throw new InvalidOperationException("Could not parse AI recipe response. Try again.", ex);
}
current = await EnrichDraftAsync(EnsureDraftId(current), constraints, ct);
drafts[index] = current;
session.DraftJson = SerializeDrafts(drafts);
session.GenerationVersion++;
session.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return MapSession(session, constraints, drafts);
}
public async Task<RecipeGeneratorSessionDto?> UpdateDraftAsync(
Guid userId,
int sessionId,
GeneratedRecipeDraftDto dto,
CancellationToken ct = default)
{
var session = await _db.RecipeGeneratorSessions
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
if (session is null) return null;
var drafts = DeserializeDrafts(session.DraftJson);
var constraints = DeserializeConstraints(session.ConstraintsJson);
var enriched = await EnrichDraftAsync(EnsureDraftId(dto), constraints, ct);
var index = drafts.FindIndex(d => d.DraftId == enriched.DraftId);
if (index < 0)
{
drafts.Add(enriched);
}
else
{
drafts[index] = enriched;
}
session.DraftJson = SerializeDrafts(drafts);
session.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return MapSession(session, constraints, drafts);
}
public async Task<RecipeGeneratorSessionDto?> RemoveDraftAsync(
Guid userId,
int sessionId,
string draftId,
CancellationToken ct = default)
{
var session = await _db.RecipeGeneratorSessions
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
if (session is null) return null;
var drafts = DeserializeDrafts(session.DraftJson);
var removed = drafts.RemoveAll(d => d.DraftId == draftId);
if (removed == 0) return null;
session.DraftJson = SerializeDrafts(drafts);
session.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
var constraints = DeserializeConstraints(session.ConstraintsJson);
return MapSession(session, constraints, drafts);
}
public async Task<CommitRecipesResultDto?> CommitAsync(
Guid userId,
int sessionId,
CommitRecipesRequestDto? request,
CancellationToken ct = default)
{
var session = await _db.RecipeGeneratorSessions
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
if (session is null || string.IsNullOrWhiteSpace(session.DraftJson)) return null;
var drafts = DeserializeDrafts(session.DraftJson);
if (drafts.Count == 0) return null;
var targetIds = request?.DraftIds?.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
var toSave = targetIds is { Count: > 0 }
? drafts.Where(d => targetIds.Contains(d.DraftId)).ToList()
: drafts.ToList();
if (toSave.Count == 0) return null;
var saved = new List<CommitRecipeResultDto>();
foreach (var draft in toSave)
{
if (draft.Ingredients.Count == 0 || draft.Steps.Count == 0) continue;
var input = new CreateRecipeDto
{
Name = draft.Name,
MealCategory = draft.MealCategory,
Calories = draft.Calories,
Protein = draft.Protein,
Fat = draft.Fat,
Carbs = draft.Carbs,
PrepTimeMinutes = draft.PrepTimeMinutes,
Ingredients = draft.Ingredients.Select((i, index) => new CreateIngredientDto
{
Name = i.Name,
AmountGrams = i.AmountGrams,
Unit = i.Unit,
SortOrder = index,
CatalogItemId = i.CatalogItemId,
}).ToList(),
Steps = draft.Steps.Select(s => new CreateRecipeStepDto
{
StepNumber = s.StepNumber,
Description = s.Description,
}).ToList(),
};
CommitRecipeResultDto savedEntry;
try
{
var created = await _recipeManagement.CreateRecipeAsync(input, ct);
var entity = await _db.Recipes.FirstAsync(r => r.Id == created.Id, ct);
entity.Source = "ai_generated";
entity.CreatedByUserId = userId;
entity.IsDraft = false;
savedEntry = new CommitRecipeResultDto
{
SessionId = sessionId,
RecipeId = created.Id,
RecipeName = created.Name,
DraftId = draft.DraftId,
};
}
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" })
{
throw new InvalidOperationException(
"Could not save the recipe because the database recipe ID sequence is out of sync. " +
"Run: dotnet run --project MealPlan.Api -- --fix-sequences",
ex);
}
saved.Add(savedEntry);
}
if (saved.Count == 0) return null;
var savedIds = saved.Select(s => s.DraftId).ToHashSet();
drafts.RemoveAll(d => savedIds.Contains(d.DraftId));
session.DraftJson = SerializeDrafts(drafts);
session.SavedRecipeId = saved[^1].RecipeId;
session.Status = drafts.Count == 0 ? RecipeGeneratorStatuses.Saved : RecipeGeneratorStatuses.Draft;
session.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return new CommitRecipesResultDto
{
SessionId = sessionId,
Saved = saved,
};
}
private async Task<GeneratedRecipeDraftDto> EnrichDraftAsync(
GeneratedRecipeDraftDto draft,
RecipeGeneratorConstraintsDto constraints,
CancellationToken ct)
{
var ingredients = new List<GeneratedIngredientDto>();
foreach (var ing in draft.Ingredients)
{
var catalogId = ing.CatalogItemId;
if (!catalogId.HasValue)
{
catalogId = await _catalogMatching.ResolveCatalogItemIdAsync(
ing.CatalogName ?? ing.Name, ct);
}
ingredients.Add(new GeneratedIngredientDto
{
Name = ing.Name,
AmountGrams = ing.AmountGrams,
Unit = ing.Unit,
CatalogItemId = catalogId,
CatalogName = ing.CatalogName,
IsLinked = catalogId.HasValue,
});
}
draft.Ingredients = ingredients;
draft.UnlinkedIngredientCount = ingredients.Count(i =>
i.AmountGrams is > 0 &&
!IsSpiceUnit(i.Unit) &&
!i.IsLinked);
await ScaleDraftToTargetCaloriesAsync(draft, constraints, ct);
await ApplyComputedMacrosAsync(draft, ct);
return draft;
}
private async Task ApplyComputedMacrosAsync(GeneratedRecipeDraftDto draft, CancellationToken ct)
{
var macros = await ComputeMacrosAsync(draft.Ingredients, ct);
if (!macros.HasValue) return;
draft.Calories = macros.Value.Calories;
draft.Protein = macros.Value.Protein;
draft.Fat = macros.Value.Fat;
draft.Carbs = macros.Value.Carbs;
}
private async Task ScaleDraftToTargetCaloriesAsync(
GeneratedRecipeDraftDto draft,
RecipeGeneratorConstraintsDto constraints,
CancellationToken ct)
{
if (constraints.TargetCalories is not > 0) return;
var target = constraints.TargetCalories.Value;
var tolerance = constraints.CalorieToleranceKcal > 0 ? constraints.CalorieToleranceKcal : 10;
var currentMacros = await ComputeMacrosAsync(draft.Ingredients, ct);
if (currentMacros is null || currentMacros.Value.Calories <= 0) return;
if (Math.Abs(currentMacros.Value.Calories - target) <= tolerance) return;
var scale = Math.Clamp((decimal)target / currentMacros.Value.Calories, 0.25m, 4m);
foreach (var ing in draft.Ingredients)
{
if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || IsSpiceUnit(ing.Unit)) continue;
ing.AmountGrams = Math.Round(ing.AmountGrams.Value * scale, 1);
}
}
private async Task<(int Calories, decimal Protein, decimal Fat, decimal Carbs)?> ComputeMacrosAsync(
IReadOnlyList<GeneratedIngredientDto> ingredients,
CancellationToken ct)
{
var ids = ingredients.Where(i => i.CatalogItemId.HasValue && i.AmountGrams is > 0 && !IsSpiceUnit(i.Unit))
.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))
.ToDictionaryAsync(i => i.Id, ct);
int calories = 0;
decimal protein = 0, fat = 0, carbs = 0;
foreach (var ing in ingredients)
{
if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || IsSpiceUnit(ing.Unit)) 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;
}
return (calories, Math.Round(protein, 2), Math.Round(fat, 2), Math.Round(carbs, 2));
}
private async Task<List<GeneratedRecipeDraftDto>> GenerateDistinctDraftsAsync(
RecipeGeneratorConstraintsDto constraints,
object catalogSample,
int count,
CancellationToken ct)
{
const int maxAttempts = 4;
var drafts = new List<GeneratedRecipeDraftDto>();
for (var attempt = 0; attempt < maxAttempts && drafts.Count < count; attempt++)
{
var need = count - drafts.Count;
var json = await RequestRecipeBatchJsonAsync(
constraints,
catalogSample,
need,
drafts.Select(d => d.Name).ToList(),
count,
ct);
var parsed = ParseDrafts(json, constraints.MealCategory);
foreach (var draft in parsed)
{
if (IsDuplicateDraft(draft, drafts)) continue;
drafts.Add(draft);
if (drafts.Count >= count) break;
}
}
return drafts.Take(count).ToList();
}
private async Task<string> RequestRecipeBatchJsonAsync(
RecipeGeneratorConstraintsDto constraints,
object catalogSample,
int recipeCount,
IReadOnlyList<string> excludeNames,
int totalRequested,
CancellationToken ct)
{
var system = """
You create realistic Polish home-cooking recipes for a meal planning app.
Respond ONLY JSON:
{"recipes":[{"name":"string","mealCategory":0,"prepTimeMinutes":20,"ingredients":[{"name":"Polish name","amountGrams":100,"unit":null,"catalogName":"English catalog name"}],"steps":[{"stepNumber":1,"description":"..."}]}]}
Rules:
- Follow constraints.prompt strictly for every recipe (dish type, main ingredients, style).
- If constraints.targetCalories is set, each recipe MUST total about that many kcal using realistic ingredient grams.
Aim within ±constraints.calorieToleranceKcal kcal. Do NOT put the calorie target only in the name scale amounts.
- Use catalogName from the provided catalog when possible; grams required for macro ingredients.
- Spices may use unit "do smaku" with null amountGrams.
- mealCategory: 0=Breakfast, 1=SecondBreakfast, 2=Lunch, 3=Dinner, 4=Snack.
- Return exactly recipeCount recipes in the recipes array.
- Each recipe MUST be clearly different (unique name, different ingredient mix, different preparation).
- Do NOT repeat the same recipe with only a number suffix.
- Do NOT copy any name from excludeExistingNames.
""";
var user = JsonSerializer.Serialize(new
{
constraints,
catalog = catalogSample,
recipeCount,
totalRequested,
excludeExistingNames = excludeNames,
});
return await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct);
}
private static bool IsDuplicateDraft(GeneratedRecipeDraftDto candidate, IReadOnlyList<GeneratedRecipeDraftDto> existing) =>
existing.Any(existingDraft => DraftFingerprint(existingDraft) == DraftFingerprint(candidate));
private static string DraftFingerprint(GeneratedRecipeDraftDto draft)
{
var ingredients = string.Join(
"|",
draft.Ingredients
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
.Select(i => $"{i.Name.Trim().ToLowerInvariant()}:{i.AmountGrams}:{i.Unit}"));
return $"{NormalizeRecipeName(draft.Name)}::{ingredients}";
}
private static string NormalizeRecipeName(string name) =>
name.Trim().ToLowerInvariant();
private GeneratedRecipeDraftDto ParseDraft(string json, int defaultCategory)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
return EnsureDraftId(new GeneratedRecipeDraftDto
{
Name = root.TryGetProperty("name", out var n) ? AiRecipeJsonParser.GetString(n) ?? "Nowy przepis" : "Nowy przepis",
MealCategory = root.TryGetProperty("mealCategory", out var mc)
? AiRecipeJsonParser.GetInt32(mc, defaultCategory)
: defaultCategory,
PrepTimeMinutes = root.TryGetProperty("prepTimeMinutes", out var pt)
? AiRecipeJsonParser.GetNullableInt32(pt)
: null,
Ingredients = root.TryGetProperty("ingredients", out var ing) ? ParseIngredientsSync(ing) : [],
Steps = root.TryGetProperty("steps", out var st) ? ParseSteps(st) : [],
});
}
private List<GeneratedRecipeDraftDto> ParseDrafts(string json, int defaultCategory)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var list = new List<GeneratedRecipeDraftDto>();
if (root.TryGetProperty("recipes", out var recipesEl) && recipesEl.ValueKind == JsonValueKind.Array)
{
foreach (var item in recipesEl.EnumerateArray())
{
list.Add(EnsureDraftId(ParseDraft(item.GetRawText(), defaultCategory)));
}
}
else if (root.ValueKind == JsonValueKind.Array)
{
foreach (var item in root.EnumerateArray())
{
list.Add(EnsureDraftId(ParseDraft(item.GetRawText(), defaultCategory)));
}
}
else if (root.TryGetProperty("name", out _))
{
list.Add(ParseDraft(json, defaultCategory));
}
return list;
}
private List<GeneratedIngredientDto> ParseIngredientsSync(JsonElement ingEl)
{
var list = new List<GeneratedIngredientDto>();
foreach (var item in ingEl.EnumerateArray())
{
list.Add(new GeneratedIngredientDto
{
Name = item.TryGetProperty("name", out var nameEl)
? AiRecipeJsonParser.GetString(nameEl) ?? ""
: "",
AmountGrams = item.TryGetProperty("amountGrams", out var g)
? AiRecipeJsonParser.GetNullableDecimal(g)
: null,
Unit = item.TryGetProperty("unit", out var u) && u.ValueKind != JsonValueKind.Null
? AiRecipeJsonParser.GetString(u)
: null,
CatalogName = item.TryGetProperty("catalogName", out var c)
? AiRecipeJsonParser.GetString(c)
: null,
});
}
return list;
}
private async Task<List<GeneratedIngredientDto>> ParseIngredientsAsync(JsonElement ingEl, CancellationToken ct)
{
var list = ParseIngredientsSync(ingEl);
foreach (var item in list)
{
item.CatalogItemId = await _catalogMatching.ResolveCatalogItemIdAsync(item.CatalogName ?? item.Name, ct);
item.IsLinked = item.CatalogItemId.HasValue;
}
return list;
}
private static List<GeneratedStepDto> ParseSteps(JsonElement stepsEl)
{
var list = new List<GeneratedStepDto>();
foreach (var item in stepsEl.EnumerateArray())
{
var stepNumber = item.TryGetProperty("stepNumber", out var stepEl)
? AiRecipeJsonParser.GetInt32(stepEl, list.Count + 1)
: list.Count + 1;
list.Add(new GeneratedStepDto
{
StepNumber = stepNumber,
Description = item.TryGetProperty("description", out var descEl)
? AiRecipeJsonParser.GetString(descEl) ?? ""
: "",
});
}
return list;
}
private 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";
}
private static RecipeGeneratorConstraintsDto DeserializeConstraints(string json) =>
JsonSerializer.Deserialize<RecipeGeneratorConstraintsDto>(json) ?? new RecipeGeneratorConstraintsDto();
private static List<GeneratedRecipeDraftDto> DeserializeDrafts(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
var trimmed = json.TrimStart();
if (trimmed.StartsWith('['))
{
return (JsonSerializer.Deserialize<List<GeneratedRecipeDraftDto>>(json) ?? [])
.Select(EnsureDraftId)
.ToList();
}
var single = JsonSerializer.Deserialize<GeneratedRecipeDraftDto>(json);
return single is null ? [] : [EnsureDraftId(single)];
}
private static string SerializeDrafts(IReadOnlyList<GeneratedRecipeDraftDto> drafts) =>
JsonSerializer.Serialize(drafts);
private static GeneratedRecipeDraftDto EnsureDraftId(GeneratedRecipeDraftDto draft)
{
if (string.IsNullOrWhiteSpace(draft.DraftId))
{
draft.DraftId = Guid.NewGuid().ToString();
}
return draft;
}
private static RecipeGeneratorSessionDto MapSession(
RecipeGeneratorSession session,
RecipeGeneratorConstraintsDto constraints,
IReadOnlyList<GeneratedRecipeDraftDto> drafts) => new()
{
Id = session.Id,
Status = session.Status,
Constraints = constraints,
Draft = drafts.FirstOrDefault(),
Drafts = drafts,
SavedRecipeId = session.SavedRecipeId,
GenerationVersion = session.GenerationVersion,
};
}

View File

@@ -0,0 +1,59 @@
namespace MealPlan.Api.Services.Generators;
public static class TdeeCalculator
{
public record MacroTargets(int Calories, decimal ProteinG, decimal FatG, decimal CarbsG, string Rationale);
public static MacroTargets Calculate(
decimal heightCm,
decimal weightKg,
int age,
string sex,
string activityLevel,
string goal)
{
var bmr = sex.ToLowerInvariant() switch
{
"female" => 10m * weightKg + 6.25m * heightCm - 5m * age - 161m,
_ => 10m * weightKg + 6.25m * heightCm - 5m * age + 5m,
};
var multiplier = activityLevel.ToLowerInvariant() switch
{
"sedentary" => 1.2m,
"light" => 1.375m,
"moderate" => 1.55m,
"active" => 1.725m,
"very_active" => 1.9m,
_ => 1.55m,
};
var tdee = bmr * multiplier;
var adjustment = goal.ToLowerInvariant() switch
{
"lose_weight" => -500,
"gain_muscle" => 300,
"recomp" => -200,
_ => 0,
};
var calories = Math.Max(1200, (int)Math.Round(tdee + adjustment));
var (proteinPct, fatPct, carbsPct) = goal.ToLowerInvariant() switch
{
"gain_muscle" => (0.30m, 0.25m, 0.45m),
"lose_weight" => (0.35m, 0.25m, 0.40m),
"recomp" => (0.32m, 0.28m, 0.40m),
_ => (0.25m, 0.30m, 0.45m),
};
var proteinG = Math.Round(calories * proteinPct / 4m, 1);
var fatG = Math.Round(calories * fatPct / 9m, 1);
var carbsG = Math.Round(calories * carbsPct / 4m, 1);
var rationale =
$"TDEE ≈ {(int)Math.Round(tdee)} kcal ({activityLevel}, {goal}). Target {calories} kcal after goal adjustment.";
return new MacroTargets(calories, proteinG, fatG, carbsG, rationale);
}
}

View File

@@ -0,0 +1,33 @@
using MealPlan.Api.DTOs.Diet;
namespace MealPlan.Api.Services;
public interface IDietTrackingService
{
Task<UserDailyGoalsDto?> GetGoalsAsync(Guid userId, CancellationToken ct = default);
Task<UserDailyGoalsDto> UpsertGoalsAsync(Guid userId, UserDailyGoalsDto dto, CancellationToken ct = default);
Task<DailySummaryDto> GetDailySummaryAsync(Guid userId, DateOnly date, CancellationToken ct = default);
Task<IReadOnlyList<MealConsumptionDto>> GetMealsAsync(Guid userId, DateOnly date, CancellationToken ct = default);
Task<MealConsumptionDto?> LogMealAsync(Guid userId, LogMealDto dto, CancellationToken ct = default);
Task<MealPreviewDto?> PreviewMealAsync(Guid userId, DateOnly date, LogMealDto dto, CancellationToken ct = default);
Task<IReadOnlyList<DietSuggestionDto>> GetMealSuggestionsAsync(Guid userId, DateOnly date, int limit = 5, CancellationToken ct = default);
Task<bool> DeleteMealAsync(Guid userId, int id, CancellationToken ct = default);
Task<IReadOnlyList<DrinkCatalogItemDto>> GetDrinkCatalogAsync(CancellationToken ct = default);
Task<IReadOnlyList<DrinkConsumptionDto>> GetDrinksAsync(Guid userId, DateOnly date, CancellationToken ct = default);
Task<DrinkConsumptionDto?> LogDrinkAsync(Guid userId, LogDrinkDto dto, CancellationToken ct = default);
Task<bool> DeleteDrinkAsync(Guid userId, int id, CancellationToken ct = default);
Task<IReadOnlyList<DietReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct = default);
Task<DietReminderDto?> CreateReminderAsync(Guid userId, CreateDietReminderDto dto, CancellationToken ct = default);
Task<DietReminderDto?> UpdateReminderAsync(Guid userId, int id, UpdateDietReminderDto dto, CancellationToken ct = default);
Task<bool> DeleteReminderAsync(Guid userId, int id, CancellationToken ct = default);
Task<IReadOnlyList<DueReminderDto>> GetDueRemindersAsync(Guid userId, DateOnly? date, TimeOnly? time, CancellationToken ct = default);
Task<IReadOnlyList<HistoryDayDto>> GetHistoryAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken ct = default);
Task<IReadOnlyList<RecentMealDto>> GetRecentMealsAsync(Guid userId, int limit = 10, CancellationToken ct = default);
Task<CopyMealsResultDto?> CopyYesterdayMealsAsync(Guid userId, DateOnly targetDate, CancellationToken ct = default);
Task<IReadOnlyList<FavoriteRecipeDto>> GetFavoriteRecipesAsync(Guid userId, CancellationToken ct = default);
Task<FavoriteRecipeDto?> AddFavoriteRecipeAsync(Guid userId, int recipeId, CancellationToken ct = default);
Task<bool> RemoveFavoriteRecipeAsync(Guid userId, int recipeId, CancellationToken ct = default);
Task<IReadOnlyList<FavoriteCatalogItemDto>> GetFavoriteCatalogItemsAsync(Guid userId, CancellationToken ct = default);
Task<FavoriteCatalogItemDto?> AddFavoriteCatalogItemAsync(Guid userId, int catalogItemId, CancellationToken ct = default);
Task<bool> RemoveFavoriteCatalogItemAsync(Guid userId, int catalogItemId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,16 @@
using MealPlan.Api.DTOs.IngredientCatalog;
namespace MealPlan.Api.Services;
public interface IIngredientCatalogService
{
Task<IReadOnlyList<IngredientCategoryDto>> GetCategoriesAsync(CancellationToken ct = default);
Task<IReadOnlyList<IngredientNutritionItemDto>> GetItemsAsync(
string? categoryCode,
string? search,
CancellationToken ct = default);
Task<IngredientNutritionItemDto?> GetItemByIdAsync(int id, CancellationToken ct = default);
Task<IngredientNutritionItemDto?> CreateItemAsync(UpsertIngredientNutritionItemDto dto, CancellationToken ct = default);
Task<IngredientNutritionItemDto?> UpdateItemAsync(int id, UpsertIngredientNutritionItemDto dto, CancellationToken ct = default);
Task<bool> DeleteItemAsync(int id, CancellationToken ct = default);
}

View File

@@ -0,0 +1,11 @@
using MealPlan.Api.DTOs.MealPlan;
namespace MealPlan.Api.Services;
public interface IMealPlanService
{
Task<IReadOnlyList<MealPlanEntryDto>> GetEntriesAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken ct = default);
Task<MealPlanEntryDto?> UpsertEntryAsync(Guid userId, UpsertMealPlanEntryDto dto, CancellationToken ct = default);
Task<bool> DeleteEntryAsync(Guid userId, int id, CancellationToken ct = default);
Task<IReadOnlyList<ShoppingListItemDto>> GetShoppingListAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken ct = default);
}

View File

@@ -0,0 +1,12 @@
using MealPlan.Api.DTOs.Recipe;
namespace MealPlan.Api.Services;
public interface IRecipeExcelService
{
/// <summary>Generates a blank .xlsx template with Recipes, Ingredients, and Steps sheets.</summary>
byte[] GenerateTemplate();
/// <summary>Parses an uploaded workbook and creates recipes in the database.</summary>
Task<RecipeImportResultDto> ImportAsync(Stream fileStream, CancellationToken ct = default);
}

View File

@@ -0,0 +1,12 @@
using MealPlan.Api.DTOs.Recipe;
namespace MealPlan.Api.Services;
public interface IRecipeManagementService
{
Task<RecipeDetailDto> CreateRecipeAsync(CreateRecipeDto dto, CancellationToken ct = default);
Task<RecipeDetailDto?> UpdateRecipeAsync(int id, CreateRecipeDto dto, CancellationToken ct = default);
Task<RecipeMacroRecalcResultDto> RecalculateAllMacrosFromCatalogAsync(CancellationToken ct = default);
}

View File

@@ -4,7 +4,7 @@ namespace MealPlan.Api.Services;
public interface IRecipeService
{
Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(int? category, string? search, CancellationToken ct = default);
Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(int? category, string? search, string? ingredient, CancellationToken ct = default);
Task<RecipeDetailDto?> GetRecipeByIdAsync(int id, CancellationToken ct = default);

View File

@@ -0,0 +1,153 @@
using MealPlan.Api.Data;
using MealPlan.Api.DTOs.IngredientCatalog;
using MealPlan.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Services;
public class IngredientCatalogService : IIngredientCatalogService
{
private readonly AppDbContext _db;
public IngredientCatalogService(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<IngredientCategoryDto>> GetCategoriesAsync(CancellationToken ct = default)
{
return await _db.IngredientCategories.AsNoTracking()
.Where(c => c.IsActive)
.OrderBy(c => c.SortOrder)
.ThenBy(c => c.Name)
.Select(c => new IngredientCategoryDto
{
Id = c.Id,
Code = c.Code,
Name = c.Name,
ItemCount = c.Items.Count(i => i.IsActive),
})
.ToListAsync(ct);
}
public async Task<IReadOnlyList<IngredientNutritionItemDto>> GetItemsAsync(
string? categoryCode,
string? search,
CancellationToken ct = default)
{
var query = _db.IngredientNutritionCatalog.AsNoTracking()
.Include(i => i.Category)
.Where(i => i.IsActive && i.Category!.IsActive);
if (!string.IsNullOrWhiteSpace(categoryCode))
{
var code = categoryCode.Trim().ToLowerInvariant();
query = query.Where(i => i.Category!.Code == code);
}
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim().ToLowerInvariant();
query = query.Where(i =>
i.Name.ToLower().Contains(term) ||
(i.NamePl != null && i.NamePl.ToLower().Contains(term)));
}
var rows = await query
.OrderBy(i => i.Category!.SortOrder)
.ThenBy(i => i.SortOrder)
.ThenBy(i => i.Name)
.ToListAsync(ct);
return rows.Select(MapItem).ToList();
}
public async Task<IngredientNutritionItemDto?> GetItemByIdAsync(int id, CancellationToken ct = default)
{
var row = await _db.IngredientNutritionCatalog.AsNoTracking()
.Include(i => i.Category)
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive, ct);
return row is null ? null : MapItem(row);
}
public async Task<IngredientNutritionItemDto?> CreateItemAsync(
UpsertIngredientNutritionItemDto dto,
CancellationToken ct = default)
{
var category = await _db.IngredientCategories.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == dto.CategoryId && c.IsActive, ct);
if (category is null) return null;
var entity = new IngredientNutritionCatalogItem
{
CategoryId = dto.CategoryId,
Name = dto.Name.Trim(),
NamePl = string.IsNullOrWhiteSpace(dto.NamePl) ? null : dto.NamePl.Trim(),
CaloriesPer100G = dto.CaloriesPer100G,
ProteinGPer100G = dto.ProteinGPer100G,
FatGPer100G = dto.FatGPer100G,
CarbsGPer100G = dto.CarbsGPer100G,
FiberGPer100G = dto.FiberGPer100G,
SortOrder = dto.SortOrder,
IsActive = dto.IsActive,
};
_db.IngredientNutritionCatalog.Add(entity);
await _db.SaveChangesAsync(ct);
entity.Category = category;
return MapItem(entity);
}
public async Task<IngredientNutritionItemDto?> UpdateItemAsync(
int id,
UpsertIngredientNutritionItemDto dto,
CancellationToken ct = default)
{
var entity = await _db.IngredientNutritionCatalog
.Include(i => i.Category)
.FirstOrDefaultAsync(i => i.Id == id, ct);
if (entity is null) return null;
var category = await _db.IngredientCategories.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == dto.CategoryId && c.IsActive, ct);
if (category is null) return null;
entity.CategoryId = dto.CategoryId;
entity.Name = dto.Name.Trim();
entity.NamePl = string.IsNullOrWhiteSpace(dto.NamePl) ? null : dto.NamePl.Trim();
entity.CaloriesPer100G = dto.CaloriesPer100G;
entity.ProteinGPer100G = dto.ProteinGPer100G;
entity.FatGPer100G = dto.FatGPer100G;
entity.CarbsGPer100G = dto.CarbsGPer100G;
entity.FiberGPer100G = dto.FiberGPer100G;
entity.SortOrder = dto.SortOrder;
entity.IsActive = dto.IsActive;
await _db.SaveChangesAsync(ct);
entity.Category = category;
return MapItem(entity);
}
public async Task<bool> DeleteItemAsync(int id, CancellationToken ct = default)
{
var entity = await _db.IngredientNutritionCatalog.FirstOrDefaultAsync(i => i.Id == id, ct);
if (entity is null) return false;
entity.IsActive = false;
await _db.SaveChangesAsync(ct);
return true;
}
private static IngredientNutritionItemDto MapItem(IngredientNutritionCatalogItem item) => new()
{
Id = item.Id,
CategoryId = item.CategoryId,
CategoryCode = item.Category?.Code ?? string.Empty,
CategoryName = item.Category?.Name ?? string.Empty,
Name = item.Name,
NamePl = item.NamePl,
CaloriesPer100G = item.CaloriesPer100G,
ProteinGPer100G = item.ProteinGPer100G,
FatGPer100G = item.FatGPer100G,
CarbsGPer100G = item.CarbsGPer100G,
FiberGPer100G = item.FiberGPer100G,
};
}

View File

@@ -0,0 +1,197 @@
using MealPlan.Api.Data;
using MealPlan.Api.DTOs.MealPlan;
using MealPlan.Api.Services.Generators;
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Services;
public class MealPlanService : IMealPlanService
{
private readonly AppDbContext _db;
private readonly IRecipeService _recipeService;
public MealPlanService(AppDbContext db, IRecipeService recipeService)
{
_db = db;
_recipeService = recipeService;
}
public async Task<IReadOnlyList<MealPlanEntryDto>> GetEntriesAsync(
Guid userId,
DateOnly from,
DateOnly to,
CancellationToken ct = default)
{
return await _db.MealPlanEntries.AsNoTracking()
.Include(e => e.Recipe)
.Where(e => e.UserId == userId && e.PlanDate >= from && e.PlanDate <= to)
.OrderBy(e => e.PlanDate)
.ThenBy(e => e.MealCategory)
.Select(e => new MealPlanEntryDto
{
Id = e.Id,
PlanDate = e.PlanDate,
MealCategory = e.MealCategory,
RecipeId = e.RecipeId,
RecipeName = e.Recipe!.Name,
Portions = e.Portions,
Calories = e.Recipe!.Calories,
})
.ToListAsync(ct);
}
public async Task<MealPlanEntryDto?> UpsertEntryAsync(
Guid userId,
UpsertMealPlanEntryDto dto,
CancellationToken ct = default)
{
var recipeExists = await _db.Recipes.AnyAsync(r => r.Id == dto.RecipeId && r.IsActive, ct);
if (!recipeExists) return null;
var existing = await _db.MealPlanEntries
.Include(e => e.Recipe)
.FirstOrDefaultAsync(
e => e.UserId == userId && e.PlanDate == dto.PlanDate && e.MealCategory == dto.MealCategory,
ct);
if (existing is null)
{
existing = new Models.MealPlanEntry
{
UserId = userId,
PlanDate = dto.PlanDate,
MealCategory = dto.MealCategory,
RecipeId = dto.RecipeId,
Portions = dto.Portions,
CreatedAt = DateTime.UtcNow,
};
_db.MealPlanEntries.Add(existing);
}
else
{
existing.RecipeId = dto.RecipeId;
existing.Portions = dto.Portions;
existing.UpdatedAt = DateTime.UtcNow;
if (existing.Recipe is null)
{
await _db.Entry(existing).Reference(e => e.Recipe).LoadAsync(ct);
}
}
await _db.SaveChangesAsync(ct);
await _db.Entry(existing).Reference(e => e.Recipe).LoadAsync(ct);
return new MealPlanEntryDto
{
Id = existing.Id,
PlanDate = existing.PlanDate,
MealCategory = existing.MealCategory,
RecipeId = existing.RecipeId,
RecipeName = existing.Recipe?.Name ?? string.Empty,
Portions = existing.Portions,
Calories = existing.Recipe?.Calories,
};
}
public async Task<bool> DeleteEntryAsync(Guid userId, int id, CancellationToken ct = default)
{
var entry = await _db.MealPlanEntries.FirstOrDefaultAsync(e => e.Id == id && e.UserId == userId, ct);
if (entry is null) return false;
_db.MealPlanEntries.Remove(entry);
await _db.SaveChangesAsync(ct);
return true;
}
public async Task<IReadOnlyList<ShoppingListItemDto>> GetShoppingListAsync(
Guid userId,
DateOnly from,
DateOnly to,
CancellationToken ct = default)
{
var entries = await _db.MealPlanEntries.AsNoTracking()
.Where(e => e.UserId == userId && e.PlanDate >= from && e.PlanDate <= to)
.ToListAsync(ct);
var recipeIds = entries.Select(e => e.RecipeId).Distinct().ToList();
var recipes = new List<DTOs.Recipe.RecipeDetailDto>();
foreach (var id in recipeIds)
{
var recipe = await _recipeService.GetRecipeByIdAsync(id, ct);
if (recipe is not null) recipes.Add(recipe);
}
var buckets = new Dictionary<string, ShoppingBucket>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries)
{
var recipe = recipes.FirstOrDefault(r => r.Id == entry.RecipeId);
if (recipe is null) continue;
foreach (var ing in recipe.Ingredients)
{
if (IngredientScalingHelper.IsNonQuantifiedUnit(ing.Unit))
continue;
var (scaledGrams, _) = IngredientScalingHelper.ScaleForDietPlan(
ing.AmountGrams, ing.Unit, entry.Portions);
if (scaledGrams is not > 0)
continue;
var key = $"g:{NormalizeName(ing.Name)}";
if (!buckets.TryGetValue(key, out var bucket))
{
bucket = new ShoppingBucket(ing.Name, null);
buckets[key] = bucket;
}
bucket.Add(recipe.Name, scaledGrams, null);
}
}
return buckets.Values
.Select(b => new ShoppingListItemDto
{
Name = b.Name,
Unit = b.Unit,
TotalGrams = b.TotalGrams,
TotalAmount = b.TotalAmount,
Breakdown = b.Breakdown,
})
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string NormalizeName(string name) => name.Trim().ToLowerInvariant();
private sealed class ShoppingBucket
{
public string Name { get; }
public string? Unit { get; }
public decimal? TotalGrams { get; private set; }
public decimal? TotalAmount { get; private set; }
public List<string> Breakdown { get; } = new();
public ShoppingBucket(string name, string? unit)
{
Name = name;
Unit = unit;
}
public void Add(string recipeName, decimal? grams, decimal? amount)
{
if (Unit is null && grams.HasValue)
{
TotalGrams = (TotalGrams ?? 0) + grams.Value;
Breakdown.Add($"{recipeName}: {grams.Value:0.##}g");
}
else if (amount.HasValue)
{
TotalAmount = (TotalAmount ?? 0) + amount.Value;
var suffix = Unit is null ? "g" : $" {Unit}";
Breakdown.Add($"{recipeName}: {amount.Value:0.##}{suffix}");
}
}
}
}

View File

@@ -0,0 +1,306 @@
using ClosedXML.Excel;
using MealPlan.Api.Data;
using MealPlan.Api.DTOs.Recipe;
using MealPlan.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Services;
/// <summary>
/// Imports recipes from a three-sheet Excel workbook (Recipes, Ingredients, Steps).
/// Also generates a downloadable template in the same format.
/// </summary>
public class RecipeExcelService : IRecipeExcelService
{
private readonly AppDbContext _db;
private readonly ILogger<RecipeExcelService> _logger;
public RecipeExcelService(AppDbContext db, ILogger<RecipeExcelService> logger)
{
_db = db;
_logger = logger;
}
public byte[] GenerateTemplate()
{
using var workbook = new XLWorkbook();
var recipes = workbook.Worksheets.Add("Recipes");
recipes.Cell(1, 1).Value = "Name";
recipes.Cell(1, 2).Value = "MealCategory";
recipes.Cell(1, 3).Value = "Calories";
recipes.Cell(1, 4).Value = "Protein";
recipes.Cell(1, 5).Value = "Fat";
recipes.Cell(1, 6).Value = "Carbs";
recipes.Cell(1, 7).Value = "PrepTimeMinutes";
recipes.Row(1).Style.Font.Bold = true;
recipes.Cell(2, 1).Value = "Oatmeal with berries";
recipes.Cell(2, 2).Value = "Breakfast";
recipes.Cell(2, 3).Value = 320;
recipes.Cell(2, 4).Value = 12;
recipes.Cell(2, 5).Value = 8;
recipes.Cell(2, 6).Value = 45;
recipes.Cell(2, 7).Value = 10;
recipes.Columns().AdjustToContents();
var ingredients = workbook.Worksheets.Add("Ingredients");
ingredients.Cell(1, 1).Value = "RecipeName";
ingredients.Cell(1, 2).Value = "Name";
ingredients.Cell(1, 3).Value = "AmountGrams";
ingredients.Cell(1, 4).Value = "Unit";
ingredients.Cell(1, 5).Value = "SortOrder";
ingredients.Row(1).Style.Font.Bold = true;
ingredients.Cell(2, 1).Value = "Oatmeal with berries";
ingredients.Cell(2, 2).Value = "Rolled oats";
ingredients.Cell(2, 3).Value = 80;
ingredients.Cell(2, 4).Value = "g";
ingredients.Cell(2, 5).Value = 0;
ingredients.Cell(3, 1).Value = "Oatmeal with berries";
ingredients.Cell(3, 2).Value = "Blueberries";
ingredients.Cell(3, 3).Value = 100;
ingredients.Cell(3, 4).Value = "g";
ingredients.Cell(3, 5).Value = 1;
ingredients.Columns().AdjustToContents();
var steps = workbook.Worksheets.Add("Steps");
steps.Cell(1, 1).Value = "RecipeName";
steps.Cell(1, 2).Value = "StepNumber";
steps.Cell(1, 3).Value = "Description";
steps.Row(1).Style.Font.Bold = true;
steps.Cell(2, 1).Value = "Oatmeal with berries";
steps.Cell(2, 2).Value = 1;
steps.Cell(2, 3).Value = "Cook oats in milk for 5 minutes.";
steps.Cell(3, 1).Value = "Oatmeal with berries";
steps.Cell(3, 2).Value = 2;
steps.Cell(3, 3).Value = "Top with berries and serve.";
steps.Columns().AdjustToContents();
var help = workbook.Worksheets.Add("Help");
help.Cell(1, 1).Value = "MealCategory values";
help.Cell(2, 1).Value = "Breakfast, SecondBreakfast (or Second Breakfast), Lunch, Dinner";
help.Cell(2, 2).Value = "Or numeric: 0, 1, 2, 3";
help.Cell(4, 1).Value = "RecipeName in Ingredients/Steps must match Name in Recipes sheet exactly.";
help.Columns().AdjustToContents();
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
public async Task<RecipeImportResultDto> ImportAsync(Stream fileStream, CancellationToken ct = default)
{
var result = new RecipeImportResultDto();
using var workbook = new XLWorkbook(fileStream);
var recipeSheet = workbook.Worksheets.FirstOrDefault(w =>
w.Name.Equals("Recipes", StringComparison.OrdinalIgnoreCase));
if (recipeSheet is null)
{
result.Errors.Add("Worksheet 'Recipes' not found.");
return result;
}
var ingredientSheet = workbook.Worksheets.FirstOrDefault(w =>
w.Name.Equals("Ingredients", StringComparison.OrdinalIgnoreCase));
var stepSheet = workbook.Worksheets.FirstOrDefault(w =>
w.Name.Equals("Steps", StringComparison.OrdinalIgnoreCase));
var ingredientRows = ParseIngredients(ingredientSheet, result.Errors);
var stepRows = ParseSteps(stepSheet, result.Errors);
var recipeRows = recipeSheet.RowsUsed().Skip(1);
foreach (var row in recipeRows)
{
var name = row.Cell(1).GetString().Trim();
if (string.IsNullOrEmpty(name))
{
continue;
}
if (!TryParseCategory(row.Cell(2).GetString(), out var category, out var categoryError))
{
result.Errors.Add($"Recipe '{name}': {categoryError}");
result.SkippedCount++;
continue;
}
var dto = new CreateRecipeDto
{
Name = name,
MealCategory = category,
Calories = ParseNullableInt(row.Cell(3)),
Protein = ParseNullableDecimal(row.Cell(4)),
Fat = ParseNullableDecimal(row.Cell(5)),
Carbs = ParseNullableDecimal(row.Cell(6)),
PrepTimeMinutes = ParseNullableInt(row.Cell(7)),
Ingredients = ingredientRows
.Where(i => i.RecipeName.Equals(name, StringComparison.OrdinalIgnoreCase))
.Select(i => new CreateIngredientDto
{
Name = i.Name,
AmountGrams = i.AmountGrams,
Unit = i.Unit,
SortOrder = i.SortOrder,
})
.ToList(),
Steps = stepRows
.Where(s => s.RecipeName.Equals(name, StringComparison.OrdinalIgnoreCase))
.OrderBy(s => s.StepNumber)
.Select(s => new CreateRecipeStepDto
{
StepNumber = s.StepNumber,
Description = s.Description,
})
.ToList(),
};
try
{
var exists = await _db.Recipes.AnyAsync(
r => r.IsActive && r.Name.ToLower() == name.ToLower(), ct);
if (exists)
{
result.Errors.Add($"Recipe '{name}' already exists — skipped.");
result.SkippedCount++;
continue;
}
var entity = RecipeManagementService.MapToEntity(dto);
entity.CreatedAt = DateTime.UtcNow;
entity.IsActive = true;
_db.Recipes.Add(entity);
await _db.SaveChangesAsync(ct);
result.CreatedCount++;
result.CreatedRecipeIds.Add(entity.Id);
_logger.LogInformation("Imported recipe {RecipeId} '{Name}' from Excel", entity.Id, name);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to import recipe '{Name}'", name);
result.Errors.Add($"Recipe '{name}': {ex.Message}");
result.SkippedCount++;
}
}
return result;
}
private static List<IngredientRow> ParseIngredients(IXLWorksheet? sheet, List<string> errors)
{
var rows = new List<IngredientRow>();
if (sheet is null)
{
return rows;
}
foreach (var row in sheet.RowsUsed().Skip(1))
{
var recipeName = row.Cell(1).GetString().Trim();
var name = row.Cell(2).GetString().Trim();
if (string.IsNullOrEmpty(recipeName) || string.IsNullOrEmpty(name))
{
continue;
}
rows.Add(new IngredientRow(
recipeName,
name,
ParseNullableDecimal(row.Cell(3)),
NullIfEmpty(row.Cell(4).GetString()),
ParseNullableInt(row.Cell(5)) ?? rows.Count(r =>
r.RecipeName.Equals(recipeName, StringComparison.OrdinalIgnoreCase))));
}
return rows;
}
private static List<StepRow> ParseSteps(IXLWorksheet? sheet, List<string> errors)
{
var rows = new List<StepRow>();
if (sheet is null)
{
return rows;
}
foreach (var row in sheet.RowsUsed().Skip(1))
{
var recipeName = row.Cell(1).GetString().Trim();
var stepNumber = ParseNullableInt(row.Cell(2));
var description = row.Cell(3).GetString().Trim();
if (string.IsNullOrEmpty(recipeName) || stepNumber is null or < 1 || string.IsNullOrEmpty(description))
{
continue;
}
rows.Add(new StepRow(recipeName, stepNumber.Value, description));
}
return rows;
}
internal static bool TryParseCategory(string raw, out int category, out string error)
{
error = string.Empty;
category = 0;
var value = raw.Trim();
if (int.TryParse(value, out category) && category is >= 0 and <= 3)
{
return true;
}
category = value.ToLowerInvariant().Replace(" ", "") switch
{
"breakfast" => 0,
"secondbreakfast" => 1,
"lunch" => 2,
"dinner" => 3,
_ => -1,
};
if (category < 0)
{
error = $"Invalid MealCategory '{raw}'. Use Breakfast, SecondBreakfast, Lunch, Dinner or 03.";
return false;
}
return true;
}
private static int? ParseNullableInt(IXLCell cell)
{
if (cell.IsEmpty())
{
return null;
}
if (cell.TryGetValue(out int intValue))
{
return intValue;
}
return int.TryParse(cell.GetString(), out intValue) ? intValue : null;
}
private static decimal? ParseNullableDecimal(IXLCell cell)
{
if (cell.IsEmpty())
{
return null;
}
if (cell.TryGetValue(out double doubleValue))
{
return (decimal)doubleValue;
}
return decimal.TryParse(cell.GetString(), out var decimalValue) ? decimalValue : null;
}
private static string? NullIfEmpty(string value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private sealed record IngredientRow(string RecipeName, string Name, decimal? AmountGrams, string? Unit, int SortOrder);
private sealed record StepRow(string RecipeName, int StepNumber, string Description);
}

View File

@@ -0,0 +1,190 @@
using MealPlan.Api.Data;
using MealPlan.Api.DTOs.Recipe;
using MealPlan.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Services;
/// <summary>Creates and updates recipes in the existing PostgreSQL schema.</summary>
public class RecipeManagementService : IRecipeManagementService
{
private readonly AppDbContext _db;
private readonly IRecipeService _recipeService;
public RecipeManagementService(AppDbContext db, IRecipeService recipeService)
{
_db = db;
_recipeService = recipeService;
}
public async Task<RecipeDetailDto> CreateRecipeAsync(CreateRecipeDto dto, CancellationToken ct = default)
{
var recipe = MapToEntity(dto);
recipe.CreatedAt = DateTime.UtcNow;
recipe.IsActive = true;
await ApplyMacrosFromCatalogAsync(recipe, ct);
_db.Recipes.Add(recipe);
await _db.SaveChangesAsync(ct);
return (await _recipeService.GetRecipeByIdAsync(recipe.Id, ct))!;
}
public async Task<RecipeDetailDto?> UpdateRecipeAsync(int id, CreateRecipeDto dto, CancellationToken ct = default)
{
var recipe = await _db.Recipes
.Include(r => r.Ingredients)
.Include(r => r.Steps)
.FirstOrDefaultAsync(r => r.Id == id && r.IsActive, ct);
if (recipe is null)
{
return null;
}
recipe.Name = dto.Name.Trim();
recipe.MealCategory = dto.MealCategory;
recipe.Calories = dto.Calories;
recipe.Protein = dto.Protein;
recipe.Fat = dto.Fat;
recipe.Carbs = dto.Carbs;
recipe.PrepTimeMinutes = dto.PrepTimeMinutes;
recipe.UpdatedAt = DateTime.UtcNow;
_db.Ingredients.RemoveRange(recipe.Ingredients);
_db.RecipeSteps.RemoveRange(recipe.Steps);
recipe.Ingredients = MapIngredients(dto.Ingredients);
recipe.Steps = MapSteps(dto.Steps);
await ApplyMacrosFromCatalogAsync(recipe, ct);
await _db.SaveChangesAsync(ct);
return await _recipeService.GetRecipeByIdAsync(id, ct);
}
public async Task<RecipeMacroRecalcResultDto> RecalculateAllMacrosFromCatalogAsync(CancellationToken ct = default)
{
var catalog = await _db.IngredientNutritionCatalog.AsNoTracking()
.Where(i => i.IsActive)
.ToDictionaryAsync(i => i.Id, ct);
var recipes = await _db.Recipes
.Include(r => r.Ingredients)
.Where(r => r.IsActive)
.ToListAsync(ct);
var updated = 0;
foreach (var recipe in recipes)
{
if (TrySetMacrosFromCatalog(recipe, catalog, overwrite: true))
{
recipe.UpdatedAt = DateTime.UtcNow;
updated++;
}
}
await _db.SaveChangesAsync(ct);
var unlinked = await _db.Ingredients.CountAsync(i =>
i.AmountGrams > 0 &&
i.CatalogItemId == null &&
(i.Unit == null || !HasNonMacroUnit(i.Unit)), ct);
return new RecipeMacroRecalcResultDto
{
RecipesProcessed = recipes.Count,
RecipesUpdated = updated,
UnlinkedIngredientRows = unlinked,
};
}
internal static Recipe MapToEntity(CreateRecipeDto dto) =>
new()
{
Name = dto.Name.Trim(),
MealCategory = dto.MealCategory,
Calories = dto.Calories,
Protein = dto.Protein,
Fat = dto.Fat,
Carbs = dto.Carbs,
PrepTimeMinutes = dto.PrepTimeMinutes,
Ingredients = MapIngredients(dto.Ingredients),
Steps = MapSteps(dto.Steps),
};
private static List<Ingredient> MapIngredients(IEnumerable<CreateIngredientDto> items) =>
items.Select((item, index) => new Ingredient
{
Name = item.Name.Trim(),
AmountGrams = item.AmountGrams,
Unit = string.IsNullOrWhiteSpace(item.Unit) ? null : item.Unit.Trim(),
SortOrder = item.SortOrder > 0 ? item.SortOrder : index,
CatalogItemId = item.CatalogItemId,
}).ToList();
private async Task ApplyMacrosFromCatalogAsync(Recipe recipe, CancellationToken ct)
{
var catalogIds = recipe.Ingredients
.Where(i => i.CatalogItemId.HasValue && i.AmountGrams is > 0 && !HasNonMacroUnit(i.Unit))
.Select(i => i.CatalogItemId!.Value)
.Distinct()
.ToList();
if (catalogIds.Count == 0) return;
var catalog = await _db.IngredientNutritionCatalog.AsNoTracking()
.Where(i => catalogIds.Contains(i.Id) && i.IsActive)
.ToDictionaryAsync(i => i.Id, ct);
TrySetMacrosFromCatalog(recipe, catalog, overwrite: false);
}
private static bool TrySetMacrosFromCatalog(
Recipe recipe,
IReadOnlyDictionary<int, IngredientNutritionCatalogItem> catalog,
bool overwrite)
{
int totalCalories = 0;
decimal totalProtein = 0;
decimal totalFat = 0;
decimal totalCarbs = 0;
var hasAny = false;
foreach (var ing in recipe.Ingredients)
{
if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || HasNonMacroUnit(ing.Unit)) continue;
if (!catalog.TryGetValue(ing.CatalogItemId.Value, out var cat)) continue;
var scale = ing.AmountGrams.Value / 100m;
totalCalories += (int)Math.Round(cat.CaloriesPer100G * scale);
totalProtein += cat.ProteinGPer100G * scale;
totalFat += cat.FatGPer100G * scale;
totalCarbs += cat.CarbsGPer100G * scale;
hasAny = true;
}
if (!hasAny) return false;
if (overwrite || !recipe.Calories.HasValue) recipe.Calories = totalCalories;
if (overwrite || !recipe.Protein.HasValue) recipe.Protein = Math.Round(totalProtein, 2);
if (overwrite || !recipe.Fat.HasValue) recipe.Fat = Math.Round(totalFat, 2);
if (overwrite || !recipe.Carbs.HasValue) recipe.Carbs = Math.Round(totalCarbs, 2);
return true;
}
private static bool HasNonMacroUnit(string? unit)
{
if (string.IsNullOrWhiteSpace(unit)) return false;
var u = unit.Trim().ToLowerInvariant();
return u is "do smaku" or "optional" or "opcjonalnie" or "szczypta";
}
private static List<RecipeStep> MapSteps(IEnumerable<CreateRecipeStepDto> items) =>
items.Select(item => new RecipeStep
{
StepNumber = item.StepNumber,
Description = item.Description.Trim(),
}).ToList();
}

View File

@@ -16,7 +16,7 @@ public class RecipeService : IRecipeService
public RecipeService(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(
int? category, string? search, CancellationToken ct = default)
int? category, string? search, string? ingredient, CancellationToken ct = default)
{
var query = _db.Recipes.AsNoTracking().Where(r => r.IsActive);
@@ -31,6 +31,13 @@ public class RecipeService : IRecipeService
query = query.Where(r => EF.Functions.ILike(r.Name, $"%{term}%"));
}
if (!string.IsNullOrWhiteSpace(ingredient))
{
var ingredientTerm = ingredient.Trim();
query = query.Where(r =>
r.Ingredients.Any(i => EF.Functions.ILike(i.Name, $"%{ingredientTerm}%")));
}
return await query
.OrderBy(r => r.Name)
.Select(r => new RecipeListItemDto
@@ -69,6 +76,7 @@ public class RecipeService : IRecipeService
AmountGrams = i.AmountGrams,
Unit = i.Unit,
SortOrder = i.SortOrder,
CatalogItemId = i.CatalogItemId,
})
.ToList(),
Steps = r.Steps

View File

@@ -0,0 +1,36 @@
using FluentValidation;
using MealPlan.Api.DTOs.Recipe;
namespace MealPlan.Api.Validators;
public class CreateRecipeValidator : AbstractValidator<CreateRecipeDto>
{
public CreateRecipeValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(255);
RuleFor(x => x.MealCategory).InclusiveBetween(0, 3);
RuleFor(x => x.Calories).GreaterThanOrEqualTo(0).When(x => x.Calories.HasValue);
RuleFor(x => x.PrepTimeMinutes).GreaterThanOrEqualTo(0).When(x => x.PrepTimeMinutes.HasValue);
RuleForEach(x => x.Ingredients).SetValidator(new CreateIngredientValidator());
RuleForEach(x => x.Steps).SetValidator(new CreateRecipeStepValidator());
}
}
public class CreateIngredientValidator : AbstractValidator<CreateIngredientDto>
{
public CreateIngredientValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(255);
RuleFor(x => x.Unit).MaximumLength(50).When(x => x.Unit != null);
RuleFor(x => x.AmountGrams).GreaterThanOrEqualTo(0).When(x => x.AmountGrams.HasValue);
}
}
public class CreateRecipeStepValidator : AbstractValidator<CreateRecipeStepDto>
{
public CreateRecipeStepValidator()
{
RuleFor(x => x.StepNumber).GreaterThan(0);
RuleFor(x => x.Description).NotEmpty();
}
}

View File

@@ -0,0 +1,64 @@
using FluentValidation;
using MealPlan.Api.DTOs.Diet;
namespace MealPlan.Api.Validators;
public class UserDailyGoalsValidator : AbstractValidator<UserDailyGoalsDto>
{
public UserDailyGoalsValidator()
{
RuleFor(x => x.CalorieGoal).GreaterThanOrEqualTo(0).When(x => x.CalorieGoal.HasValue);
RuleFor(x => x.ProteinGoalG).GreaterThanOrEqualTo(0).When(x => x.ProteinGoalG.HasValue);
RuleFor(x => x.FatGoalG).GreaterThanOrEqualTo(0).When(x => x.FatGoalG.HasValue);
RuleFor(x => x.CarbsGoalG).GreaterThanOrEqualTo(0).When(x => x.CarbsGoalG.HasValue);
RuleFor(x => x.WaterGoalMl).GreaterThan(0).When(x => x.WaterGoalMl.HasValue);
}
}
public class LogMealValidator : AbstractValidator<LogMealDto>
{
public LogMealValidator()
{
RuleFor(x => x.MealCategory).InclusiveBetween(0, 4);
RuleFor(x => x.Portions).GreaterThan(0);
RuleFor(x => x.Name).MaximumLength(255).When(x => x.Name != null);
RuleFor(x => x)
.Must(x => x.RecipeId.HasValue || (x.CatalogItemId.HasValue && x.Grams is > 0) || !string.IsNullOrWhiteSpace(x.Name))
.WithMessage("Provide recipeId, catalogItemId with grams, or name.");
RuleFor(x => x.Grams).GreaterThan(0).When(x => x.CatalogItemId.HasValue);
RuleFor(x => x.Calories).GreaterThanOrEqualTo(0).When(x => x.Calories.HasValue);
}
}
public class LogDrinkValidator : AbstractValidator<LogDrinkDto>
{
public LogDrinkValidator()
{
RuleFor(x => x.VolumeMl).GreaterThan(0);
RuleFor(x => x.Name).MaximumLength(255).When(x => x.Name != null);
RuleFor(x => x)
.Must(x => x.DrinkCatalogId.HasValue || !string.IsNullOrWhiteSpace(x.Name))
.WithMessage("Provide either drinkCatalogId or name.");
RuleFor(x => x.Calories).GreaterThanOrEqualTo(0).When(x => x.Calories.HasValue);
}
}
public class CreateDietReminderValidator : AbstractValidator<CreateDietReminderDto>
{
public CreateDietReminderValidator()
{
RuleFor(x => x.ReminderType).InclusiveBetween(0, 3);
RuleFor(x => x.Title).NotEmpty().MaximumLength(120);
RuleFor(x => x.Message).MaximumLength(500).When(x => x.Message != null);
RuleFor(x => x.DaysOfWeekMask).InclusiveBetween(1, 127);
RuleFor(x => x.MealCategory).InclusiveBetween(0, 4).When(x => x.MealCategory.HasValue);
}
}
public class UpdateDietReminderValidator : AbstractValidator<UpdateDietReminderDto>
{
public UpdateDietReminderValidator()
{
Include(new CreateDietReminderValidator());
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
using MealPlan.Api.DTOs.IngredientCatalog;
namespace MealPlan.Api.Validators;
public class UpsertIngredientNutritionItemValidator : AbstractValidator<UpsertIngredientNutritionItemDto>
{
public UpsertIngredientNutritionItemValidator()
{
RuleFor(x => x.CategoryId).GreaterThan(0);
RuleFor(x => x.Name).NotEmpty().MaximumLength(255);
RuleFor(x => x.NamePl).MaximumLength(255).When(x => x.NamePl != null);
RuleFor(x => x.CaloriesPer100G).GreaterThanOrEqualTo(0);
RuleFor(x => x.ProteinGPer100G).GreaterThanOrEqualTo(0);
RuleFor(x => x.FatGPer100G).GreaterThanOrEqualTo(0);
RuleFor(x => x.CarbsGPer100G).GreaterThanOrEqualTo(0);
RuleFor(x => x.FiberGPer100G).GreaterThanOrEqualTo(0).When(x => x.FiberGPer100G.HasValue);
}
}

View File

@@ -1,5 +1,13 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=your-db-host;Port=5432;Database=mealplan;Username=user;Password=secret"
},
"OpenAI": {
"ApiKey": "sk-your-openai-key",
"Model": "gpt-4.1-mini",
"MacrosModel": "gpt-4.1-mini",
"PlanModel": "gpt-4.1-mini",
"RecipeModel": "gpt-4.1",
"MaxTokens": 4096
}
}

View File

@@ -21,5 +21,13 @@
}
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"OpenAI": {
"ApiKey": "",
"Model": "gpt-4.1-mini",
"MacrosModel": "gpt-4.1-mini",
"PlanModel": "gpt-4.1-mini",
"RecipeModel": "gpt-4.1",
"MaxTokens": 4096
}
}

View File

@@ -106,8 +106,22 @@ compatible with hashes produced by ASP.NET Core Identity.
```bash
cd DailyMeals # repository root (where DailyMeals.sln lives)
./scripts/install-node.sh
```
**Angular frontend (recommended rewrite):**
```bash
cd meal-plan-frontend-angular
npm install
npm start # http://localhost:4200, proxies /api → localhost:5000
```
**Or React frontend:**
```bash
cd meal-plan-frontend
../scripts/npm install
../scripts/npm run dev # http://localhost:5173
```
4. In Rider, open the run configuration dropdown (top toolbar). You should see:
@@ -155,9 +169,13 @@ All `/api/recipes` endpoints require a valid `Authorization: Bearer <accessToken
| POST | `/api/auth/refresh` | Rotate refresh token → new token pair |
| POST | `/api/auth/logout` | Revoke a refresh token |
| GET | `/api/auth/me` | Current user info |
| GET | `/api/recipes` | List recipes — `?category=0..3` and `?search=` filters|
| GET | `/api/recipes` | List recipes — `?category=`, `?search=`, `?ingredient=` |
| GET | `/api/recipes/{id}` | Full recipe detail (ingredients + steps) |
| GET | `/api/recipes/categories` | Categories with active recipe counts |
| POST | `/api/recipes` | Create a recipe (ingredients + steps) |
| PUT | `/api/recipes/{id}` | Update a recipe |
| GET | `/api/recipes/import/template` | Download Excel import template (.xlsx) |
| POST | `/api/recipes/import/excel` | Import recipes from uploaded .xlsx file |
Meal categories: `0 = Breakfast`, `1 = SecondBreakfast`, `2 = Lunch`, `3 = Dinner`.
@@ -228,10 +246,19 @@ DailyMeals/
│ ├── Middleware/ ExceptionMiddleware
│ ├── Program.cs Composition root / pipeline
│ └── Dockerfile
├── meal-plan-frontend/ React + TypeScript SPA
├── meal-plan-frontend/ React + TypeScript SPA (original)
│ ├── src/ api, components, pages, hooks, store, types, utils
│ ├── nginx.conf TLS, gzip, security headers, /api proxy, SPA fallback
│ └── Dockerfile
├── meal-plan-frontend-angular/ Angular 19 SPA (rewrite of React frontend)
│ ├── src/app/ core, layout, features, shared
│ ├── public/i18n/ EN + PL translations
│ ├── nginx.conf
│ └── Dockerfile
├── meal-plan-frontend-mobile/ Expo (React Native) iOS + Android app
│ ├── app/ Expo Router screens
│ ├── src/ api, context, i18n, theme, components
│ └── README.md Run with `npm start` (see mobile README)
├── docker-compose.yml API + frontend (no database container)
└── .env.example
```

View File

@@ -0,0 +1,157 @@
-- DailyMeals diet tracking module
-- Apply manually on PostgreSQL (database-first; EF does not run migrations).
-- Schema: diet (same as existing recipe tables)
--
-- Inspired by patterns from MyFitnessPal / Lifesum (meal + macro logging),
-- WaterMinder / Hydro Coach (hydration goals + quick-add volumes), and
-- generic reminder schedules stored server-side for client push/local alarms.
BEGIN;
-- ---------------------------------------------------------------------------
-- Daily nutrition & hydration targets (one row per user)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."UserDailyGoals" (
"UserId" uuid PRIMARY KEY
REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
"CalorieGoal" int NULL CHECK ("CalorieGoal" IS NULL OR "CalorieGoal" >= 0),
"ProteinGoalG" numeric(6,2) NULL CHECK ("ProteinGoalG" IS NULL OR "ProteinGoalG" >= 0),
"FatGoalG" numeric(6,2) NULL CHECK ("FatGoalG" IS NULL OR "FatGoalG" >= 0),
"CarbsGoalG" numeric(6,2) NULL CHECK ("CarbsGoalG" IS NULL OR "CarbsGoalG" >= 0),
"WaterGoalMl" int NULL CHECK ("WaterGoalMl" IS NULL OR "WaterGoalMl" > 0),
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
"UpdatedAt" timestamptz NULL
);
COMMENT ON TABLE diet."UserDailyGoals" IS 'Per-user daily calorie, macro, and hydration targets (Lifesum-style goals).';
-- ---------------------------------------------------------------------------
-- Drink catalog (reference data; quick-add presets like WaterMinder)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."DrinkCatalog" (
"Id" serial PRIMARY KEY,
"Code" varchar(32) NOT NULL UNIQUE,
"Name" varchar(100) NOT NULL,
"DefaultVolumeMl" int NOT NULL CHECK ("DefaultVolumeMl" > 0),
"CaloriesPer100Ml" numeric(6,2) NULL CHECK ("CaloriesPer100Ml" IS NULL OR "CaloriesPer100Ml" >= 0),
"IconEmoji" varchar(16) NULL,
"SortOrder" int NOT NULL DEFAULT 0,
"IsActive" boolean NOT NULL DEFAULT true
);
COMMENT ON TABLE diet."DrinkCatalog" IS 'Preset drinks for one-tap logging (water, tea, coffee, etc.).';
INSERT INTO diet."DrinkCatalog" ("Code", "Name", "DefaultVolumeMl", "CaloriesPer100Ml", "IconEmoji", "SortOrder")
VALUES
('water', 'Water', 250, 0, '💧', 1),
('tea', 'Tea', 200, 1, '🍵', 2),
('coffee', 'Coffee', 150, 2, '', 3),
('juice', 'Juice', 200, 45, '🧃', 4),
('milk', 'Milk', 250, 42, '🥛', 5),
('soda', 'Soft drink', 330, 42, '🥤', 6),
('other', 'Other drink', 250, NULL, '🍶', 99)
ON CONFLICT ("Code") DO NOTHING;
-- ---------------------------------------------------------------------------
-- Logged meals (MyFitnessPal-style diary entries; optional recipe link)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."MealConsumptions" (
"Id" serial PRIMARY KEY,
"UserId" uuid NOT NULL
REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
"RecipeId" int NULL
REFERENCES diet."Recipes"("Id") ON DELETE SET NULL,
"MealCategory" int NOT NULL CHECK ("MealCategory" BETWEEN 0 AND 4),
"LogDate" date NOT NULL,
"ConsumedAt" timestamptz NOT NULL,
"Name" varchar(255) NOT NULL,
"Portions" numeric(4,2) NOT NULL DEFAULT 1 CHECK ("Portions" > 0),
"Calories" int NULL CHECK ("Calories" IS NULL OR "Calories" >= 0),
"ProteinG" numeric(6,2) NULL CHECK ("ProteinG" IS NULL OR "ProteinG" >= 0),
"FatG" numeric(6,2) NULL CHECK ("FatG" IS NULL OR "FatG" >= 0),
"CarbsG" numeric(6,2) NULL CHECK ("CarbsG" IS NULL OR "CarbsG" >= 0),
"Notes" text NULL,
"CreatedAt" timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE diet."MealConsumptions" IS 'Meals the user ate; macros snapshotted at log time.';
COMMENT ON COLUMN diet."MealConsumptions"."MealCategory" IS '0=Breakfast, 1=SecondBreakfast, 2=Lunch, 3=Dinner, 4=Snack';
CREATE INDEX IF NOT EXISTS "IX_MealConsumptions_UserId_LogDate"
ON diet."MealConsumptions" ("UserId", "LogDate" DESC);
CREATE INDEX IF NOT EXISTS "IX_MealConsumptions_UserId_ConsumedAt"
ON diet."MealConsumptions" ("UserId", "ConsumedAt" DESC);
-- ---------------------------------------------------------------------------
-- Logged drinks (water tracker entries)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."DrinkConsumptions" (
"Id" serial PRIMARY KEY,
"UserId" uuid NOT NULL
REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
"DrinkCatalogId" int NULL
REFERENCES diet."DrinkCatalog"("Id") ON DELETE SET NULL,
"LogDate" date NOT NULL,
"ConsumedAt" timestamptz NOT NULL,
"Name" varchar(255) NOT NULL,
"VolumeMl" int NOT NULL CHECK ("VolumeMl" > 0),
"Calories" int NULL CHECK ("Calories" IS NULL OR "Calories" >= 0),
"CreatedAt" timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE diet."DrinkConsumptions" IS 'Hydration and beverage intake log.';
CREATE INDEX IF NOT EXISTS "IX_DrinkConsumptions_UserId_LogDate"
ON diet."DrinkConsumptions" ("UserId", "LogDate" DESC);
-- ---------------------------------------------------------------------------
-- Reminders (server-stored schedules; clients handle local notifications)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."DietReminders" (
"Id" serial PRIMARY KEY,
"UserId" uuid NOT NULL
REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
"ReminderType" int NOT NULL CHECK ("ReminderType" BETWEEN 0 AND 3),
"Title" varchar(120) NOT NULL,
"Message" varchar(500) NULL,
"TimeOfDay" time NOT NULL,
"DaysOfWeekMask" int NOT NULL DEFAULT 127 CHECK ("DaysOfWeekMask" BETWEEN 1 AND 127),
"MealCategory" int NULL CHECK ("MealCategory" IS NULL OR "MealCategory" BETWEEN 0 AND 4),
"IsEnabled" boolean NOT NULL DEFAULT true,
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
"UpdatedAt" timestamptz NULL
);
COMMENT ON TABLE diet."DietReminders" IS 'Meal/water/custom reminder schedules (bitmask: Sun=1, Mon=2, Tue=4, Wed=8, Thu=16, Fri=32, Sat=64).';
COMMENT ON COLUMN diet."DietReminders"."ReminderType" IS '0=Water, 1=Meal, 2=Snack, 3=Custom';
CREATE INDEX IF NOT EXISTS "IX_DietReminders_UserId_IsEnabled"
ON diet."DietReminders" ("UserId", "IsEnabled");
-- ---------------------------------------------------------------------------
-- Optional views for reporting (API computes the same in LINQ)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE VIEW diet."v_DailyMealTotals" AS
SELECT
"UserId",
"LogDate",
COALESCE(SUM("Calories"), 0)::int AS "TotalCalories",
COALESCE(SUM("ProteinG"), 0) AS "TotalProteinG",
COALESCE(SUM("FatG"), 0) AS "TotalFatG",
COALESCE(SUM("CarbsG"), 0) AS "TotalCarbsG",
COUNT(*)::int AS "MealCount"
FROM diet."MealConsumptions"
GROUP BY "UserId", "LogDate";
CREATE OR REPLACE VIEW diet."v_DailyDrinkTotals" AS
SELECT
"UserId",
"LogDate",
COALESCE(SUM("VolumeMl"), 0)::int AS "TotalWaterMl",
COALESCE(SUM("Calories"), 0)::int AS "TotalDrinkCalories",
COUNT(*)::int AS "DrinkCount"
FROM diet."DrinkConsumptions"
GROUP BY "UserId", "LogDate";
COMMIT;

View File

@@ -0,0 +1,144 @@
-- DailyMeals ingredient nutrition catalog
-- Reference data: macros per 100 g, grouped by food category (meat, vegetables, etc.)
-- Apply manually: psql "$DATABASE_URL" -f database/scripts/002_ingredient_nutrition_catalog.sql
BEGIN;
CREATE TABLE IF NOT EXISTS diet."IngredientCategories" (
"Id" serial PRIMARY KEY,
"Code" varchar(32) NOT NULL UNIQUE,
"Name" varchar(100) NOT NULL,
"SortOrder" int NOT NULL DEFAULT 0,
"IsActive" boolean NOT NULL DEFAULT true
);
COMMENT ON TABLE diet."IngredientCategories" IS 'Food groups for the nutrition catalog (meat, vegetables, etc.).';
CREATE TABLE IF NOT EXISTS diet."IngredientNutritionCatalog" (
"Id" serial PRIMARY KEY,
"CategoryId" int NOT NULL
REFERENCES diet."IngredientCategories"("Id") ON DELETE RESTRICT,
"Name" varchar(255) NOT NULL,
"CaloriesPer100G" int NOT NULL CHECK ("CaloriesPer100G" >= 0),
"ProteinGPer100G" numeric(6,2) NOT NULL DEFAULT 0 CHECK ("ProteinGPer100G" >= 0),
"FatGPer100G" numeric(6,2) NOT NULL DEFAULT 0 CHECK ("FatGPer100G" >= 0),
"CarbsGPer100G" numeric(6,2) NOT NULL DEFAULT 0 CHECK ("CarbsGPer100G" >= 0),
"FiberGPer100G" numeric(6,2) NULL CHECK ("FiberGPer100G" IS NULL OR "FiberGPer100G" >= 0),
"SortOrder" int NOT NULL DEFAULT 0,
"IsActive" boolean NOT NULL DEFAULT true,
CONSTRAINT "UQ_IngredientNutritionCatalog_Category_Name" UNIQUE ("CategoryId", "Name")
);
COMMENT ON TABLE diet."IngredientNutritionCatalog" IS 'Ingredient nutrition values per 100 g (calories, protein, fat, carbs).';
CREATE INDEX IF NOT EXISTS "IX_IngredientNutritionCatalog_CategoryId"
ON diet."IngredientNutritionCatalog" ("CategoryId", "SortOrder", "Name");
CREATE INDEX IF NOT EXISTS "IX_IngredientNutritionCatalog_Name"
ON diet."IngredientNutritionCatalog" (lower("Name"));
INSERT INTO diet."IngredientCategories" ("Code", "Name", "SortOrder")
VALUES
('meat', 'Meat', 1),
('poultry', 'Poultry', 2),
('fish', 'Fish', 3),
('seafood', 'Seafood', 4),
('vegetables', 'Vegetables', 5),
('fruits', 'Fruits', 6),
('dairy', 'Dairy', 7),
('eggs', 'Eggs', 8),
('grains', 'Grains', 9),
('legumes', 'Legumes', 10),
('nuts', 'Nuts & seeds', 11),
('oils', 'Oils & fats', 12)
ON CONFLICT ("Code") DO NOTHING;
-- Helper: insert items per category code
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", v."Name", v."Calories", v."Protein", v."Fat", v."Carbs", v."Fiber", v."SortOrder"
FROM (VALUES
-- meat
('meat', 'Beef sirloin', 271, 25.80, 18.00, 0.00, NULL, 1),
('meat', 'Pork loin', 242, 27.30, 14.00, 0.00, NULL, 2),
('meat', 'Lamb leg', 258, 25.60, 16.50, 0.00, NULL, 3),
('meat', 'Ground beef (15% fat)', 250, 26.00, 15.00, 0.00, NULL, 4),
('meat', 'Ham (cooked)', 145, 21.00, 6.00, 1.50, NULL, 5),
-- poultry
('poultry', 'Chicken breast', 165, 31.00, 3.60, 0.00, NULL, 1),
('poultry', 'Chicken thigh', 209, 26.00, 10.90, 0.00, NULL, 2),
('poultry', 'Turkey breast', 135, 30.00, 1.00, 0.00, NULL, 3),
('poultry', 'Duck breast', 202, 23.50, 11.20, 0.00, NULL, 4),
-- fish
('fish', 'Salmon', 208, 20.40, 13.40, 0.00, NULL, 1),
('fish', 'Cod', 82, 17.80, 0.70, 0.00, NULL, 2),
('fish', 'Tuna (canned in water)', 116, 25.50, 0.80, 0.00, NULL, 3),
('fish', 'Trout', 148, 20.80, 6.60, 0.00, NULL, 4),
('fish', 'Mackerel', 205, 18.60, 13.90, 0.00, NULL, 5),
-- seafood
('seafood', 'Shrimp', 99, 24.00, 0.30, 0.20, NULL, 1),
('seafood', 'Mussels', 172, 23.80, 4.50, 7.40, NULL, 2),
('seafood', 'Squid', 92, 15.60, 1.40, 3.10, NULL, 3),
('seafood', 'Crab', 97, 19.40, 1.50, 0.00, NULL, 4),
-- vegetables
('vegetables', 'Tomato', 18, 0.90, 0.20, 3.90, 1.20, 1),
('vegetables', 'Carrot', 41, 0.90, 0.20, 9.60, 2.80, 2),
('vegetables', 'Broccoli', 34, 2.80, 0.40, 6.60, 2.60, 3),
('vegetables', 'Spinach', 23, 2.90, 0.40, 3.60, 2.20, 4),
('vegetables', 'Potato', 77, 2.00, 0.10, 17.50, 2.20, 5),
('vegetables', 'Cucumber', 15, 0.70, 0.10, 3.60, 0.50, 6),
('vegetables', 'Bell pepper', 31, 1.00, 0.30, 6.00, 2.10, 7),
('vegetables', 'Onion', 40, 1.10, 0.10, 9.30, 1.70, 8),
('vegetables', 'Zucchini', 17, 1.20, 0.30, 3.10, 1.00, 9),
('vegetables', 'Mushrooms', 22, 3.10, 0.30, 3.30, 1.00, 10),
-- fruits
('fruits', 'Apple', 52, 0.30, 0.20, 13.80, 2.40, 1),
('fruits', 'Banana', 89, 1.10, 0.30, 22.80, 2.60, 2),
('fruits', 'Orange', 47, 0.90, 0.10, 11.80, 2.40, 3),
('fruits', 'Strawberries', 32, 0.70, 0.30, 7.70, 2.00, 4),
('fruits', 'Blueberries', 57, 0.70, 0.30, 14.50, 2.40, 5),
('fruits', 'Grapes', 69, 0.70, 0.20, 18.10, 0.90, 6),
('fruits', 'Avocado', 160, 2.00, 14.70, 8.50, 6.70, 7),
-- dairy
('dairy', 'Whole milk', 61, 3.20, 3.30, 4.80, NULL, 1),
('dairy', 'Skim milk', 34, 3.40, 0.10, 5.00, NULL, 2),
('dairy', 'Greek yogurt (plain)', 59, 10.00, 0.40, 3.60, NULL, 3),
('dairy', 'Cheddar cheese', 403, 25.00, 33.00, 1.30, NULL, 4),
('dairy', 'Mozzarella', 280, 28.00, 17.00, 3.10, NULL, 5),
('dairy', 'Cottage cheese', 98, 11.10, 4.30, 3.40, NULL, 6),
-- eggs
('eggs', 'Chicken egg (whole)', 155, 12.60, 10.60, 1.10, NULL, 1),
('eggs', 'Egg white', 52, 10.90, 0.20, 0.70, NULL, 2),
('eggs', 'Egg yolk', 322, 15.90, 26.50, 3.60, NULL, 3),
-- grains
('grains', 'White rice (cooked)', 130, 2.70, 0.30, 28.20, 0.40, 1),
('grains', 'Brown rice (cooked)', 123, 2.70, 1.00, 25.60, 1.60, 2),
('grains', 'Pasta (cooked)', 131, 5.00, 1.10, 25.00, 1.80, 3),
('grains', 'Oats (dry)', 389, 16.90, 6.90, 66.30, 10.60, 4),
('grains', 'Whole wheat bread', 247, 13.00, 3.40, 41.00, 6.00, 5),
('grains', 'White bread', 265, 9.00, 3.20, 49.00, 2.70, 6),
('grains', 'Quinoa (cooked)', 120, 4.40, 1.90, 21.30, 2.80, 7),
-- legumes
('legumes', 'Lentils (cooked)', 116, 9.00, 0.40, 20.10, 7.90, 1),
('legumes', 'Chickpeas (cooked)', 164, 8.90, 2.60, 27.40, 7.60, 2),
('legumes', 'Black beans (cooked)', 132, 8.90, 0.50, 23.70, 8.70, 3),
('legumes', 'Kidney beans (cooked)', 127, 8.70, 0.50, 22.80, 6.40, 4),
('legumes', 'Green peas (cooked)', 84, 5.40, 0.20, 15.60, 5.50, 5),
('legumes', 'Tofu (firm)', 76, 8.10, 4.80, 1.90, 0.30, 6),
-- nuts
('nuts', 'Almonds', 579, 21.20, 49.90, 21.60, 12.50, 1),
('nuts', 'Walnuts', 654, 15.20, 65.20, 13.70, 6.70, 2),
('nuts', 'Peanuts', 567, 25.80, 49.20, 16.10, 8.50, 3),
('nuts', 'Cashews', 553, 18.20, 43.80, 30.20, 3.30, 4),
('nuts', 'Sunflower seeds', 584, 20.80, 51.50, 20.00, 8.60, 5),
('nuts', 'Chia seeds', 486, 16.50, 30.70, 42.10, 34.40, 6),
-- oils
('oils', 'Olive oil', 884, 0.00, 100.00, 0.00, NULL, 1),
('oils', 'Butter', 717, 0.90, 81.00, 0.10, NULL, 2),
('oils', 'Coconut oil', 862, 0.00, 100.00, 0.00, NULL, 3),
('oils', 'Rapeseed oil', 884, 0.00, 100.00, 0.00, NULL, 4)
) AS v("CategoryCode", "Name", "Calories", "Protein", "Fat", "Carbs", "Fiber", "SortOrder")
JOIN diet."IngredientCategories" c ON c."Code" = v."CategoryCode"
ON CONFLICT ("CategoryId", "Name") DO NOTHING;
COMMIT;

View File

@@ -0,0 +1,83 @@
-- Add Polish display names to the ingredient nutrition catalog.
-- Safe to re-run. Apply after 002_ingredient_nutrition_catalog.sql:
-- psql "$DATABASE_URL" -f database/scripts/003_ingredient_catalog_polish_names.sql
BEGIN;
ALTER TABLE diet."IngredientNutritionCatalog"
ADD COLUMN IF NOT EXISTS "NamePl" varchar(255) NULL;
COMMENT ON COLUMN diet."IngredientNutritionCatalog"."NamePl" IS 'Polish display name for the ingredient.';
CREATE INDEX IF NOT EXISTS "IX_IngredientNutritionCatalog_NamePl"
ON diet."IngredientNutritionCatalog" (lower("NamePl"));
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Polędwica wołowa' WHERE "Name" = 'Beef sirloin';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Schab wieprzowy' WHERE "Name" = 'Pork loin';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Udziec jagnięcy' WHERE "Name" = 'Lamb leg';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Mięso mielone wołowe (15% tł.)' WHERE "Name" = 'Ground beef (15% fat)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Szynka (gotowana)' WHERE "Name" = 'Ham (cooked)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pierś z kurczaka' WHERE "Name" = 'Chicken breast';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Udo z kurczaka' WHERE "Name" = 'Chicken thigh';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pierś z indyka' WHERE "Name" = 'Turkey breast';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pierś z kaczki' WHERE "Name" = 'Duck breast';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Łosoś' WHERE "Name" = 'Salmon';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Dorsz' WHERE "Name" = 'Cod';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Tuńczyk (w wodzie)' WHERE "Name" = 'Tuna (canned in water)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pstrąg' WHERE "Name" = 'Trout';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Makrela' WHERE "Name" = 'Mackerel';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Krewetki' WHERE "Name" = 'Shrimp';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Małże' WHERE "Name" = 'Mussels';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Kałamarnica' WHERE "Name" = 'Squid';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Krab' WHERE "Name" = 'Crab';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pomidor' WHERE "Name" = 'Tomato';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Marchew' WHERE "Name" = 'Carrot';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Brokuły' WHERE "Name" = 'Broccoli';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Szpinak' WHERE "Name" = 'Spinach';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ziemniak' WHERE "Name" = 'Potato';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ogórek' WHERE "Name" = 'Cucumber';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Papryka' WHERE "Name" = 'Bell pepper';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Cebula' WHERE "Name" = 'Onion';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Cukinia' WHERE "Name" = 'Zucchini';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pieczarki' WHERE "Name" = 'Mushrooms';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Jabłko' WHERE "Name" = 'Apple';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Banan' WHERE "Name" = 'Banana';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pomarańcza' WHERE "Name" = 'Orange';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Truskawki' WHERE "Name" = 'Strawberries';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Borówki' WHERE "Name" = 'Blueberries';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Winogrona' WHERE "Name" = 'Grapes';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Awokado' WHERE "Name" = 'Avocado';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Mleko pełne' WHERE "Name" = 'Whole milk';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Mleko odtłuszczone' WHERE "Name" = 'Skim milk';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Jogurt grecki (naturalny)' WHERE "Name" = 'Greek yogurt (plain)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ser cheddar' WHERE "Name" = 'Cheddar cheese';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Mozzarella' WHERE "Name" = 'Mozzarella';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Twaróg' WHERE "Name" = 'Cottage cheese';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Jajko kurze (całe)' WHERE "Name" = 'Chicken egg (whole)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Białko jaja' WHERE "Name" = 'Egg white';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Żółtko jaja' WHERE "Name" = 'Egg yolk';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ryż biały (gotowany)' WHERE "Name" = 'White rice (cooked)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ryż brązowy (gotowany)' WHERE "Name" = 'Brown rice (cooked)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Makaron (gotowany)' WHERE "Name" = 'Pasta (cooked)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Płatki owsiane (suche)' WHERE "Name" = 'Oats (dry)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Chleb pełnoziarnisty' WHERE "Name" = 'Whole wheat bread';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Chleb biały' WHERE "Name" = 'White bread';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Komosa ryżowa (gotowana)' WHERE "Name" = 'Quinoa (cooked)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Soczewica (gotowana)' WHERE "Name" = 'Lentils (cooked)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ciecierzyca (gotowana)' WHERE "Name" = 'Chickpeas (cooked)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Czarna fasola (gotowana)' WHERE "Name" = 'Black beans (cooked)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Fasola czerwona (gotowana)' WHERE "Name" = 'Kidney beans (cooked)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Groszek (gotowany)' WHERE "Name" = 'Green peas (cooked)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Tofu (twarde)' WHERE "Name" = 'Tofu (firm)';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Migdały' WHERE "Name" = 'Almonds';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Orzechy włoskie' WHERE "Name" = 'Walnuts';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Orzeszki ziemne' WHERE "Name" = 'Peanuts';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Orzechy nerkowca' WHERE "Name" = 'Cashews';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Nasiona słonecznika' WHERE "Name" = 'Sunflower seeds';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Nasiona chia' WHERE "Name" = 'Chia seeds';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Oliwa z oliwek' WHERE "Name" = 'Olive oil';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Masło' WHERE "Name" = 'Butter';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Olej kokosowy' WHERE "Name" = 'Coconut oil';
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Olej rzepakowy' WHERE "Name" = 'Rapeseed oil';
COMMIT;

View File

@@ -0,0 +1,62 @@
-- Extended features: refresh tokens, meal planner, favorites, recipe-catalog link
-- Apply after 001003. Schema: diet
BEGIN;
-- ---------------------------------------------------------------------------
-- Refresh tokens (persist sessions across API restarts)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."RefreshTokens" (
"Token" varchar(128) PRIMARY KEY,
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
"ExpiresAtUtc" timestamptz NOT NULL,
"CreatedAtUtc" timestamptz NOT NULL DEFAULT now(),
"IsRevoked" boolean NOT NULL DEFAULT false
);
CREATE INDEX IF NOT EXISTS "IX_RefreshTokens_UserId" ON diet."RefreshTokens" ("UserId");
CREATE INDEX IF NOT EXISTS "IX_RefreshTokens_ExpiresAtUtc" ON diet."RefreshTokens" ("ExpiresAtUtc");
-- ---------------------------------------------------------------------------
-- Weekly meal plan (one recipe per meal slot per day)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."MealPlanEntries" (
"Id" serial PRIMARY KEY,
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
"PlanDate" date NOT NULL,
"MealCategory" int NOT NULL CHECK ("MealCategory" BETWEEN 0 AND 4),
"RecipeId" int NOT NULL REFERENCES diet."Recipes"("Id") ON DELETE CASCADE,
"Portions" numeric(4,2) NOT NULL DEFAULT 1 CHECK ("Portions" > 0),
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
"UpdatedAt" timestamptz NULL,
UNIQUE ("UserId", "PlanDate", "MealCategory")
);
CREATE INDEX IF NOT EXISTS "IX_MealPlanEntries_UserId_PlanDate"
ON diet."MealPlanEntries" ("UserId", "PlanDate");
-- ---------------------------------------------------------------------------
-- Favorites (quick logging)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."UserFavoriteRecipes" (
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
"RecipeId" int NOT NULL REFERENCES diet."Recipes"("Id") ON DELETE CASCADE,
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("UserId", "RecipeId")
);
CREATE TABLE IF NOT EXISTS diet."UserFavoriteCatalogItems" (
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
"CatalogItemId" int NOT NULL REFERENCES diet."IngredientNutritionCatalog"("Id") ON DELETE CASCADE,
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("UserId", "CatalogItemId")
);
-- ---------------------------------------------------------------------------
-- Link recipe ingredients to nutrition catalog (auto macros)
-- ---------------------------------------------------------------------------
ALTER TABLE diet."Ingredients"
ADD COLUMN IF NOT EXISTS "CatalogItemId" int NULL
REFERENCES diet."IngredientNutritionCatalog"("Id") ON DELETE SET NULL;
COMMIT;

View File

@@ -0,0 +1,928 @@
-- 005_catalog_expansion_and_ingredient_linking.sql
-- Expands nutrition catalog, links recipe ingredients, recalculates recipe macros.
-- Coverage estimate: ~1160/1360 ingredient rows linkable; spice-skip 58; unmapped names 26 (mostly spice mixes)
-- Apply after scripts 001-004.
BEGIN;
-- 1. Add missing catalog items
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Milk 2%', 'Mleko 2%', 50, 3.4, 2.0, 4.8, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'dairy'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Feta cheese', 'Ser feta', 264, 14.0, 21.0, 4.0, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'dairy'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Ricotta', 'Ricotta', 174, 11.0, 13.0, 3.0, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'dairy'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Parmesan', 'Parmezan', 431, 38.0, 29.0, 4.1, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'dairy'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Sour cream 12%', 'Śmietana 12%', 130, 2.7, 12.0, 3.0, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'dairy'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Rye bread whole grain', 'Chleb żytni razowy', 217, 8.5, 1.8, 42.0, 5.5, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Pasta whole grain dry', 'Makaron pełnoziarnisty (suchy)', 348, 12.0, 2.0, 72.0, 3.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'White rice dry', 'Ryż biały (suchy)', 360, 7.0, 0.6, 79.0, 1.3, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Brown rice dry', 'Ryż brązowy (suchy)', 362, 7.5, 2.7, 76.0, 3.4, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Buckwheat groats dry', 'Kasza gryczana (sucha)', 343, 13.0, 3.4, 72.0, 10.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Bulgur dry', 'Kasza bulgur (sucha)', 342, 12.0, 1.3, 76.0, 12.5, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Couscous dry', 'Kuskus (suchy)', 376, 13.0, 0.6, 77.0, 5.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Whole wheat tortilla', 'Tortilla pełnoziarnista', 298, 9.0, 7.0, 48.0, 6.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Wheat flour type 1850', 'Mąka pszenna typ 1850', 340, 10.0, 1.0, 72.0, 2.7, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Breadcrumbs whole grain', 'Bułka tarta pełnoziarnista', 395, 13.0, 5.0, 72.0, 4.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Green beans', 'Fasolka szparagowa', 31, 1.8, 0.2, 7.0, 2.7, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Eggplant', 'Bakłażan', 25, 1.0, 0.2, 6.0, 3.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Radish', 'Rzodkiewka', 16, 0.7, 0.1, 3.4, 1.6, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Lettuce mix', 'Sałata / mix sałat', 15, 1.4, 0.2, 2.9, 1.3, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Leek', 'Por', 61, 1.5, 0.3, 14.0, 1.8, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Celery root', 'Seler korzeniowy', 42, 1.5, 0.3, 9.0, 1.8, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Celery stalk', 'Seler naciowy', 16, 0.7, 0.2, 3.0, 1.6, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Cauliflower', 'Kalafior', 25, 1.9, 0.3, 5.0, 2.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Parsley fresh', 'Pietruszka / natka', 36, 3.0, 0.8, 6.3, 3.3, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Chives', 'Szczypiorek', 30, 3.3, 0.7, 4.4, 2.5, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Dill fresh', 'Koper ogrodowy', 43, 3.5, 1.1, 7.0, 2.1, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Capers', 'Kapary', 23, 2.4, 0.9, 4.9, 3.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Olives black', 'Oliwki czarne', 115, 0.8, 10.7, 6.0, 3.2, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Olives green', 'Oliwki zielone', 145, 1.0, 15.0, 3.8, 3.3, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Garlic', 'Czosnek', 149, 6.4, 0.5, 33.0, 2.1, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Sweet corn canned', 'Kukurydza konserwowa', 86, 2.5, 1.0, 19.0, 2.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Raspberries', 'Maliny', 52, 1.2, 0.7, 12.0, 6.5, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Kiwi', 'Kiwi', 61, 1.1, 0.5, 15.0, 3.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Mango', 'Mango', 60, 0.8, 0.4, 15.0, 1.6, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Peach nectarine', 'Brzoskwinia / nektarynka', 44, 0.9, 0.3, 11.0, 1.5, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Lemon juice', 'Sok cytrynowy / cytryna', 22, 0.4, 0.2, 6.9, 0.3, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Pomegranate seeds', 'Pestki granatu', 83, 1.7, 1.2, 19.0, 4.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Dried apricots', 'Suszone morele', 241, 3.4, 0.5, 63.0, 7.3, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Dried figs', 'Suszone figi', 249, 3.3, 0.9, 63.9, 9.8, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Dried cranberries', 'Żurawina suszona', 308, 0.1, 1.4, 82.0, 5.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Honey', 'Miód', 304, 0.3, 0.0, 82.0, 0.2, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Tahini', 'Tahini', 595, 17.0, 54.0, 21.0, 9.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Hummus', 'Hummus', 166, 8.0, 9.6, 14.0, 6.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'legumes'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Pesto basil', 'Pesto bazyliowe', 420, 5.0, 40.0, 6.0, 2.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'oils'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Tomato passata', 'Passata / pomidory z puszki', 32, 1.5, 0.2, 5.0, 1.5, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Sun-dried tomatoes in oil', 'Suszone pomidory w oliwie', 258, 14.0, 3.0, 55.0, 12.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Jam strawberry low sugar', 'Dżem truskawkowy niskosłodzony', 180, 0.5, 0.1, 45.0, 1.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Ketchup', 'Keczup', 100, 1.0, 0.2, 25.0, 0.5, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Mustard dijon', 'Musztarda dijonska', 66, 4.0, 3.7, 5.0, 3.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Soy sauce', 'Sos sojowy', 53, 8.0, 0.0, 4.9, 0.8, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Carrot juice', 'Sok marchwiowy', 40, 0.9, 0.2, 9.6, 0.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Flaxseed oil', 'Olej lniany', 884, 0.0, 100.0, 0.0, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'oils'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Flax seeds ground', 'Siemię lniane', 534, 18.0, 42.0, 29.0, 27.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Pumpkin seeds', 'Pestki dyni', 559, 30.0, 49.0, 11.0, 6.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Pistachios', 'Pistacje', 562, 20.0, 45.0, 28.0, 10.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Pine nuts', 'Piniole', 673, 14.0, 68.0, 13.0, 3.7, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Coconut flakes', 'Wiórki kokosowe', 660, 7.0, 65.0, 24.0, 16.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Smoked salmon', 'Łosoś wędzony', 117, 18.0, 4.3, 0.0, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'fish'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Chicken liver', 'Wątróbka kurczaka', 167, 26.0, 6.5, 1.0, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'poultry'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Pork tenderloin', 'Polędwica wieprzowa', 143, 26.0, 3.5, 0.0, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'meat'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Beef stew meat', 'Wołowina gulaszowa', 250, 26.0, 15.0, 0.0, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'meat'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Beef strips', 'Wołowina (rostbef)', 271, 25.8, 18.0, 0.0, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'meat'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
-- Herbs, spices, and zero-calorie liquids (for remaining unmapped ingredient rows with grams)
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Water', 'Woda', 0, 0, 0, 0, NULL, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Basil fresh', 'Bazylia świeża', 23, 3.2, 0.6, 2.7, 1.6, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Basil dried', 'Bazylia suszona', 251, 14.4, 4.1, 47.8, 37.7, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Oregano dried', 'Oregano suszone', 265, 9.0, 4.3, 68.9, 42.5, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Thyme dried', 'Tymianek suszony', 276, 9.1, 7.4, 63.9, 37.0, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Rosemary dried', 'Rozmaryn suszony', 331, 4.9, 15.2, 64.1, 42.6, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Cinnamon ground', 'Cynamon mielony', 247, 4.0, 1.2, 80.6, 53.1, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Ginger ground', 'Imbir mielony', 335, 9.0, 4.2, 71.6, 14.1, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Coriander dried', 'Kolendra suszona', 298, 12.4, 17.8, 54.9, 41.9, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Marjoram dried', 'Majeranek suszony', 271, 12.7, 7.0, 60.6, 40.3, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Black pepper ground', 'Pieprz czarny mielony', 251, 10.4, 3.3, 63.9, 25.3, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
INSERT INTO diet."IngredientNutritionCatalog"
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
SELECT c."Id", 'Cocoa powder 16% fat', 'Kakao 16% proszek', 228, 19.6, 13.7, 57.9, 33.2, 100
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
CREATE TEMP TABLE _ingredient_name_map (pattern text PRIMARY KEY, catalog_name text NOT NULL);
INSERT INTO _ingredient_name_map VALUES ('Awokado', 'Avocado') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Bakłażan', 'Eggplant') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Bazylia suszona', 'Basil dried') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Bazylia świeża', 'Basil fresh') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Banan', 'Banana') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Bataty', 'Potato') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Borówki amerykańskie', 'Blueberries') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Brokuł', 'Broccoli') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Brokuły', 'Broccoli') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Brzoskwinia', 'Peach nectarine') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Brzoskwinia (świeża lub mrożona)', 'Peach nectarine') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Bułka tarta pełnoziarnista', 'Breadcrumbs whole grain') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Cebula', 'Onion') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Cebula czerwona', 'Onion') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Chleb pita pełnoziarnisty', 'Whole wheat bread') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Chleb żytni razowy', 'Rye bread whole grain') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ciecierzyca z puszki', 'Chickpeas (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ciecierzyca z puszki (odsączona)', 'Chickpeas (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Cukinia', 'Zucchini') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Cynamon', 'Cinnamon ground') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Cytryna', 'Lemon juice') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Cytryna (sok)', 'Lemon juice') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Czosnek', 'Garlic') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Czosnek granulowany', 'Garlic') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Czosnek, sól, pieprz', 'Garlic') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Czosnek, sół, pieprz', 'Garlic') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Czosnek, tymianek, sól, pieprz', 'Garlic') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Dorsz świeży filety bez skóry', 'Cod') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Dynia pestki łuskane', 'Pumpkin seeds') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Dżem truskawkowy niskosłodzony', 'Strawberries') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Fasola czerwona w zalewie (konserwowa)', 'Kidney beans (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Fasola szparagowa', 'Green beans') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Fasolka szparagowa', 'Green beans') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Fasolka szparagowa mrożona', 'Green beans') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Filet z dorsza lub dorada', 'Cod') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Filet z piersi indyka', 'Turkey breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Filet z piersi indyka (rozbite plastry)', 'Turkey breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Filet z łososia', 'Salmon') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Gruszka', 'Peach nectarine') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Hummus', 'Hummus') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Imbir mielony', 'Ginger ground') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Jabłko', 'Apple') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Jaja kurze całe', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Jaja kurze całe (na twardo)', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Jajko', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Jajko (do klopsików)', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Jajko (do panierki)', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Jajko ugotowane na twardo', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Jogurt grecki 2%', 'Greek yogurt (plain)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Jogurt naturalny', 'Greek yogurt (plain)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kakao 16% proszek', 'Cocoa powder 16% fat') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kabanosy z kurczaka', 'Chicken breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kalafior', 'Cauliflower') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kapary', 'Capers') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kasza bulgur', 'Bulgur dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kasza gryczana', 'Buckwheat groats dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kasza jaglana', 'Buckwheat groats dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Keczup', 'Ketchup') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kiełbasa chorizo', 'Ham (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kiełki brokuła', 'Broccoli') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kiwi', 'Kiwi') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Koncentrat pomidorowy 30%', 'Tomato passata') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kolendra suszone liście', 'Coriander dried') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Koper ogrodowy', 'Dill fresh') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kukurydza konserwowa', 'Sweet corn canned') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Kuskus pełnoziarnisty', 'Couscous dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Len mielony', 'Flax seeds ground') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Len nasiona', 'Flax seeds ground') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Len, nasiona', 'Flax seeds ground') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Makaron fusilli pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Makaron penne pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Makaron pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Makaron pełnoziarnisty (fusilli)', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Makaron pełnoziarnisty (penne lub spaghetti)', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Makaron pełnoziarnisty (tagliatelle lub penne)', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Makaron rigatoni pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Makaron spaghetti pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Makaron tagliatelle pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Majeranek', 'Marjoram dried') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Maliny', 'Raspberries') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Maliny (świeże lub mrożone)', 'Raspberries') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Maliny świeże lub mrożone', 'Raspberries') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Mango (świeże lub mrożone)', 'Mango') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Marchew', 'Carrot') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Marchewka', 'Carrot') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Masło', 'Butter') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Masło ekstra', 'Butter') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Masło orzechowe', 'Butter') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Migdały', 'Almonds') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Migdały w płatkach', 'Almonds') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Mix sałat', 'Lettuce mix') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Miód', 'Honey') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Miód pszczeli', 'Honey') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Mięso wołowe (świeżo mielone z rostbefu)', 'Ground beef (15% fat)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Mięso wołowe mielone', 'Ground beef (15% fat)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Mięso z piersi kurczaka bez skóry', 'Chicken breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Mięso z podudzia indyka bez skóry', 'Turkey breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Mleko 2%', 'Milk 2%') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Mleko spożywcze 2%', 'Milk 2%') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Morszczuk świeży', 'Cod') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Musztarda dijonska', 'Mustard dijon') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Mąka pszenna (do obtoczenia)', 'Wheat flour type 1850') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Mąka pszenna typ 1850', 'Wheat flour type 1850') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Nasiona chia', 'Chia seeds') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Natka pietruszki', 'Parsley fresh') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Nektarynka', 'Peach nectarine') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Noga (udo) kurczaka', 'Chicken breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ocet jabłkowy z dojrzałych jabłek', 'Apple') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Olej lniany tłoczony na zimno', 'Flaxseed oil') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Oliwa z oliwek', 'Olive oil') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Oliwki czarne', 'Olives black') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Oliwki czarne i zielone (mix)', 'Olives black') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Oliwki zielone', 'Olives green') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Oliwki zielone marynowane', 'Olives green') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Oregano suszone', 'Oregano dried') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Oregano, sok cytryny, sól, pieprz', 'Lemon juice') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Oregano, tymianek, czosnek granulowany, sól', 'Garlic') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Orzechy nerkowca', 'Cashews') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Orzechy nerkowca (bez soli)', 'Cashews') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Orzechy włoskie', 'Walnuts') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Papryka czerwona', 'Bell pepper') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Parmezan tarty', 'Parmesan') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Parmezan w płatkach', 'Parmesan') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Passata pomidorowa', 'Tomato passata') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Passata pomidorowa (przecier)', 'Tomato passata') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Passata pomidorowa przecier', 'Tomato passata') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pestki granatu', 'Pomegranate seeds') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pesto bazyliowe', 'Pesto basil') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pieczarka uprawna świeża', 'Mushrooms') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pieczarki świeże', 'Mushrooms') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pieprz czarny mielony', 'Black pepper ground') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pierś z indyka', 'Turkey breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pierś z kurczaka', 'Chicken breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pierś z kurczaka (rozbita cienko)', 'Chicken breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pierś z kurczaka (ugotowana lub z poprzedniego dnia)', 'Chicken breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pierś z kurczaka (z poprzedniego dnia lub świeża)', 'Chicken breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pietruszka korzeń', 'Celery root') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pietruszka liście', 'Parsley fresh') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pietruszka, liście', 'Parsley fresh') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Piniole', 'Pine nuts') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pistacje (łuskane)', 'Pistachios') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pistacje łuskane', 'Pistachios') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Polędwica sopocka', 'Ham (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Polędwica wieprzowa', 'Pork tenderloin') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Polędwica wieprzowa surowa', 'Pork tenderloin') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pomarańcza', 'Orange') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pomidor', 'Tomato') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pomidory cherry', 'Tomato') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pomidory koktajlowe', 'Tomato') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pomidory z puszki', 'Tomato passata') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Pomidory z puszki (krojone)', 'Tomato passata') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Por', 'Leek') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Płatki owsiane', 'Oats (dry)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ricotta', 'Ricotta') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Rozmaryn', 'Rosemary dried') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Rukola', 'Lettuce mix') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ryż basmati', 'White rice dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ryż brązowy', 'Brown rice dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ryż jaśminowy', 'White rice dry') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Rzodkiewka', 'Radish') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Salami', 'Ham (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Salami (cienkie plastry)', 'Ham (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Sałata', 'Lettuce mix') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Sałata lub mix sałat', 'Lettuce mix') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Seler korzeniowy', 'Celery root') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Seler naciowy', 'Celery stalk') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ser feta', 'Feta cheese') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ser gouda tłusty', 'Parmesan') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ser mozzarella', 'Mozzarella') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ser parmezan (tarty)', 'Parmesan') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ser parmezan tarty', 'Parmesan') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ser twarogowy półtłusty', 'Cottage cheese') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ser typu feta', 'Feta cheese') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Serek wiejski naturalny', 'Greek yogurt (plain)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Siemię lniane mielone', 'Flax seeds ground') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Siemię lniane świeżo mielone', 'Flax seeds ground') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Soczewica zielona', 'Lentils (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Soczewica zielona (ugotowana)', 'Lentils (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Sok cytrynowy', 'Lemon juice') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Sok marchwiowy', 'Carrot juice') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Sos sojowy', 'Soy sauce') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Sos sojowy ciemny', 'Soy sauce') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Suszone figi', 'Dried figs') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Suszone morele', 'Dried apricots') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Suszone pomidory (odsączone)', 'Sun-dried tomatoes in oil') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Suszone pomidory (w oleju z ziołami, odsączone)', 'Sun-dried tomatoes in oil') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Suszone pomidory w oliwie (odsączone)', 'Sun-dried tomatoes in oil') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Szczypiorek', 'Chives') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Szpinak', 'Spinach') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Szpinak świeży', 'Spinach') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Szynka parmeńska', 'Ham (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Szynka parmeńska (Prosciutto)', 'Ham (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Szynka parmeńska (prosciutto)', 'Ham (cooked)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Szynka z indyka', 'Turkey breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Szynka z indyka (bez konserwantów)', 'Turkey breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Słonecznik nasiona łuskane', 'Pumpkin seeds') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Tahini', 'Tahini') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Tortilla pełnoziarnista', 'Whole wheat tortilla') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Truskawki', 'Strawberries') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Truskawki (świeże lub mrożone)', 'Strawberries') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Tuńczyk w wodzie (odsączony)', 'Tuna (canned in water)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Twaróg półtłusty', 'Cottage cheese') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Tymianek', 'Thyme dried') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Tymianek, czosnek granulowany, sól', 'Garlic') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Tymianek, czosnek granulowany, sól, pieprz', 'Garlic') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Udka kurczaka (bez skóry)', 'Chicken breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Udka kurczaka bez skóry', 'Chicken breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Wiórki kokosowe', 'Coconut flakes') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Woda', 'Water') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Wołowina (rostbef lub ligawa, cienkie paski)', 'Beef stew meat') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Wołowina gulaszowa', 'Beef stew meat') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Wołowina gulaszowa (karkówka/łopatka)', 'Beef stew meat') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Wołowina mielona', 'Ground beef (15% fat)') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Wątróbka kurczaka', 'Chicken breast') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Ziemniaki', 'Potato') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Łosoś wędzony', 'Smoked salmon') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Śmietana 12%', 'Sour cream 12%') ON CONFLICT DO NOTHING;
INSERT INTO _ingredient_name_map VALUES ('Żurawina suszona', 'Dried cranberries') ON CONFLICT DO NOTHING;
UPDATE diet."Ingredients" i
SET "CatalogItemId" = c."Id"
FROM _ingredient_name_map m
JOIN diet."IngredientNutritionCatalog" c ON c."Name" = m.catalog_name AND c."IsActive" = true
WHERE i."Name" = m.pattern AND i."AmountGrams" IS NOT NULL AND i."AmountGrams" > 0;
WITH totals AS (
SELECT i."RecipeId",
ROUND(SUM(c."CaloriesPer100G" * i."AmountGrams" / 100.0))::int AS calories,
ROUND(SUM(c."ProteinGPer100G" * i."AmountGrams" / 100.0), 2) AS protein,
ROUND(SUM(c."FatGPer100G" * i."AmountGrams" / 100.0), 2) AS fat,
ROUND(SUM(c."CarbsGPer100G" * i."AmountGrams" / 100.0), 2) AS carbs
FROM diet."Ingredients" i
JOIN diet."IngredientNutritionCatalog" c ON c."Id" = i."CatalogItemId"
WHERE i."AmountGrams" IS NOT NULL AND i."AmountGrams" > 0
GROUP BY i."RecipeId"
)
UPDATE diet."Recipes" r SET
"Calories" = t.calories, "Protein" = t.protein, "Fat" = t.fat, "Carbs" = t.carbs, "UpdatedAt" = now()
FROM totals t WHERE r."Id" = t."RecipeId" AND r."IsActive" = true;
DO $$
DECLARE unlinked int; no_macros int;
BEGIN
SELECT COUNT(*) INTO unlinked FROM diet."Ingredients"
WHERE "AmountGrams" > 0 AND "CatalogItemId" IS NULL AND COALESCE("Unit", '') <> 'do smaku';
SELECT COUNT(*) INTO no_macros FROM diet."Recipes" WHERE "IsActive" AND ("Protein" IS NULL OR "Calories" IS NULL);
RAISE NOTICE 'Unlinked ingredient rows (grams, non-spice): %', unlinked;
RAISE NOTICE 'Recipes still missing macros: %', no_macros;
END $$;
COMMIT;
-- Reference mapping: database/scripts/005_ingredient_name_mapping.csv

View File

@@ -0,0 +1,279 @@
IngredientName,CatalogName,Status
Awokado,Avocado,mapped
Bakłażan,Eggplant,mapped
Banan,Banana,mapped
Bataty,Potato,mapped
Bazylia suszona,Basil dried,mapped
"Bazylia suszona, oregano, płatki chili, sół, pieprz",,skip_spice
Bazylia świeża,Basil fresh,mapped
"Bazylia świeża lub suszona, sól, pieprz",UNMAPPED,unmapped
"Bazylia świeża, pieprz",UNMAPPED,unmapped
"Bazylia świeża, pieprz czarny",UNMAPPED,unmapped
"Bazylia, oregano, pieprz",UNMAPPED,unmapped
"Bazylia, oregano, sól, pieprz",UNMAPPED,unmapped
"Bazylia, rozmaryn, sól, pieprz",UNMAPPED,unmapped
"Bazylia, sól, pieprz",UNMAPPED,unmapped
Borówki amerykańskie,Blueberries,mapped
Brokuł,Broccoli,mapped
Brokuły,Broccoli,mapped
Brzoskwinia,Peach nectarine,mapped
Brzoskwinia (świeża lub mrożona),Peach nectarine,mapped
Bułka tarta pełnoziarnista,Breadcrumbs whole grain,mapped
Cebula,Onion,mapped
Cebula czerwona,Onion,mapped
Chleb pita pełnoziarnisty,Whole wheat bread,mapped
Chleb żytni razowy,Rye bread whole grain,mapped
Ciecierzyca z puszki,Chickpeas (cooked),mapped
Ciecierzyca z puszki (odsączona),Chickpeas (cooked),mapped
Cukinia,Zucchini,mapped
Cynamon,Cinnamon ground,mapped
Cytryna,Lemon juice,mapped
Cytryna (sok),Lemon juice,mapped
Czosnek,Garlic,mapped
Czosnek granulowany,Garlic,mapped
"Czosnek, sól, pieprz",Garlic,mapped
"Czosnek, sół, pieprz",Garlic,mapped
"Czosnek, tymianek, sól, pieprz",Garlic,mapped
Dorsz świeży filety bez skóry,Cod,mapped
Dynia pestki łuskane,Pumpkin seeds,mapped
Dżem truskawkowy niskosłodzony,Strawberries,mapped
Erytrol/Erytrytol,,skip_spice
Fasola czerwona w zalewie (konserwowa),Kidney beans (cooked),mapped
Fasola szparagowa,Green beans,mapped
Fasolka szparagowa,Green beans,mapped
Fasolka szparagowa mrożona,Green beans,mapped
Filet z dorsza lub dorada,Cod,mapped
Filet z piersi indyka,Turkey breast,mapped
Filet z piersi indyka (rozbite plastry),Turkey breast,mapped
Filet z łososia,Salmon,mapped
"Gałka muszkatołowa, sól, pieprz",,skip_spice
"Gałka muszkatołowa, sół, pieprz",,skip_spice
Gruszka,Peach nectarine,mapped
Hummus,Hummus,mapped
Imbir mielony,Ginger ground,mapped
Jabłko,Apple,mapped
Jaja kurze całe,Chicken egg (whole),mapped
Jaja kurze całe (na twardo),Chicken egg (whole),mapped
Jajko,Chicken egg (whole),mapped
Jajko (do klopsików),Chicken egg (whole),mapped
Jajko (do panierki),Chicken egg (whole),mapped
Jajko ugotowane na twardo,Chicken egg (whole),mapped
Jogurt grecki 2%,Greek yogurt (plain),mapped
Jogurt naturalny,Greek yogurt (plain),mapped
Kabanosy z kurczaka,Chicken breast,mapped
Kakao 16% proszek,Cocoa powder 16% fat,mapped
Kalafior,Cauliflower,mapped
Kapary,Capers,mapped
Kasza bulgur,Bulgur dry,mapped
Kasza gryczana,Buckwheat groats dry,mapped
Kasza jaglana,Buckwheat groats dry,mapped
Keczup,Ketchup,mapped
Kiełbasa chorizo,Ham (cooked),mapped
Kiełki brokuła,Broccoli,mapped
Kiwi,Kiwi,mapped
"Kminek mielony, papryka słodka, sól, pieprz",,skip_spice
"Kminek mielony, płatki chili, sól, pieprz",,skip_spice
"Kminek, papryka słodka, chili, sól",,skip_spice
Kolendra suszone liście,Coriander dried,mapped
Koncentrat pomidorowy 30%,Tomato passata,mapped
Koper ogrodowy,Dill fresh,mapped
Ksylitol,,skip_spice
Kukurydza konserwowa,Sweet corn canned,mapped
"Kurkuma, kminek, bazylia, sól, pieprz",UNMAPPED,unmapped
"Kurkuma, kminek, cynamon, kolendra suszona, imbir mielony, sól, pieprz",UNMAPPED,unmapped
"Kurkuma, kminek, cynamon, kolendra suszona, sól, pieprz",UNMAPPED,unmapped
"Kurkuma, kminek, kolendra suszona, sól",UNMAPPED,unmapped
Kuskus pełnoziarnisty,Couscous dry,mapped
Len mielony,Flax seeds ground,mapped
Len nasiona,Flax seeds ground,mapped
"Len, nasiona",Flax seeds ground,mapped
Liść laurowy,,skip_spice
Majeranek,Marjoram dried,mapped
Makaron fusilli pełnoziarnisty,Pasta whole grain dry,mapped
Makaron penne pełnoziarnisty,Pasta whole grain dry,mapped
Makaron pełnoziarnisty,Pasta whole grain dry,mapped
Makaron pełnoziarnisty (fusilli),Pasta whole grain dry,mapped
Makaron pełnoziarnisty (penne lub spaghetti),Pasta whole grain dry,mapped
Makaron pełnoziarnisty (tagliatelle lub penne),Pasta whole grain dry,mapped
Makaron rigatoni pełnoziarnisty,Pasta whole grain dry,mapped
Makaron spaghetti pełnoziarnisty,Pasta whole grain dry,mapped
Makaron tagliatelle pełnoziarnisty,Pasta whole grain dry,mapped
Maliny,Raspberries,mapped
Maliny (świeże lub mrożone),Raspberries,mapped
Maliny świeże lub mrożone,Raspberries,mapped
Mango (świeże lub mrożone),Mango,mapped
Marchew,Carrot,mapped
Marchewka,Carrot,mapped
Masło,Butter,mapped
Masło ekstra,Butter,mapped
Masło orzechowe,Butter,mapped
Mielona gałka muszkatołowa,,skip_spice
Mielona papryka chili,,skip_spice
Mielona słodka papryka,,skip_spice
Mielony filet z indyka,,skip_spice
Mielony filet z piersi indyka (bez skóry),,skip_spice
Migdały,Almonds,mapped
Migdały w płatkach,Almonds,mapped
Mix sałat,Lettuce mix,mapped
Miód,Honey,mapped
Miód pszczeli,Honey,mapped
Mięso wołowe (świeżo mielone z rostbefu),Ground beef (15% fat),mapped
Mięso wołowe mielone,Ground beef (15% fat),mapped
Mięso z piersi kurczaka bez skóry,Chicken breast,mapped
Mięso z podudzia indyka bez skóry,Turkey breast,mapped
Mleko 2%,Milk 2%,mapped
Mleko spożywcze 2%,Milk 2%,mapped
Morszczuk świeży,Cod,mapped
Musztarda dijonska,Mustard dijon,mapped
Mąka pszenna (do obtoczenia),Wheat flour type 1850,mapped
Mąka pszenna typ 1850,Wheat flour type 1850,mapped
Nasiona chia,Chia seeds,mapped
Natka pietruszki,Parsley fresh,mapped
Nektarynka,Peach nectarine,mapped
Noga (udo) kurczaka,Chicken breast,mapped
Ocet jabłkowy z dojrzałych jabłek,Apple,mapped
"Ocet winny biały, sól, pieprz",UNMAPPED,unmapped
Olej lniany tłoczony na zimno,Flaxseed oil,mapped
Oliwa z oliwek,Olive oil,mapped
Oliwki czarne,Olives black,mapped
Oliwki czarne i zielone (mix),Olives black,mapped
Oliwki zielone,Olives green,mapped
Oliwki zielone marynowane,Olives green,mapped
Oregano suszone,Oregano dried,mapped
"Oregano, bazylia suszona, płatki chili, sól, pieprz",,skip_spice
"Oregano, bazylia, sól",UNMAPPED,unmapped
"Oregano, bazylia, sól, pieprz",UNMAPPED,unmapped
"Oregano, pieprz",UNMAPPED,unmapped
"Oregano, płatki chili, sól, pieprz",,skip_spice
"Oregano, sok cytryny, sól, pieprz",Lemon juice,mapped
"Oregano, sól, pieprz",UNMAPPED,unmapped
"Oregano, tymianek, czosnek granulowany, sól",Garlic,mapped
"Oregano, tymianek, sól, pieprz",UNMAPPED,unmapped
Orzechy nerkowca,Cashews,mapped
Orzechy nerkowca (bez soli),Cashews,mapped
Orzechy włoskie,Walnuts,mapped
Papryka czerwona,Bell pepper,mapped
"Papryka słodka, chili, oregano, sól, pieprz",,skip_spice
Parmezan tarty,Parmesan,mapped
Parmezan w płatkach,Parmesan,mapped
Passata pomidorowa,Tomato passata,mapped
Passata pomidorowa (przecier),Tomato passata,mapped
Passata pomidorowa przecier,Tomato passata,mapped
Pestki granatu,Pomegranate seeds,mapped
Pesto bazyliowe,Pesto basil,mapped
Pieczarka uprawna świeża,Mushrooms,mapped
Pieczarki świeże,Mushrooms,mapped
Pieprz czarny mielony,Black pepper ground,mapped
Pierś z indyka,Turkey breast,mapped
Pierś z kurczaka,Chicken breast,mapped
Pierś z kurczaka (rozbita cienko),Chicken breast,mapped
Pierś z kurczaka (ugotowana lub z poprzedniego dnia),Chicken breast,mapped
Pierś z kurczaka (z poprzedniego dnia lub świeża),Chicken breast,mapped
Pietruszka korzeń,Celery root,mapped
Pietruszka liście,Parsley fresh,mapped
"Pietruszka, liście",Parsley fresh,mapped
Piniole,Pine nuts,mapped
Pistacje (łuskane),Pistachios,mapped
Pistacje łuskane,Pistachios,mapped
Polędwica sopocka,Ham (cooked),mapped
Polędwica wieprzowa,Pork tenderloin,mapped
Polędwica wieprzowa surowa,Pork tenderloin,mapped
Pomarańcza,Orange,mapped
Pomidor,Tomato,mapped
Pomidory cherry,Tomato,mapped
Pomidory koktajlowe,Tomato,mapped
Pomidory z puszki,Tomato passata,mapped
Pomidory z puszki (krojone),Tomato passata,mapped
Por,Leek,mapped
Proszek do pieczenia,,skip_spice
Przyprawa do drobiu,,skip_spice
"Płatki chili, sól",,skip_spice
"Płatki chili, sól, pieprz",,skip_spice
Płatki owsiane,Oats (dry),mapped
Ricotta,Ricotta,mapped
Rozmaryn,Rosemary dried,mapped
"Rozmaryn, tymianek, bazylia, sól, pieprz",UNMAPPED,unmapped
"Rozmaryn, tymianek, oregano, sól, pieprz",UNMAPPED,unmapped
"Rozmaryn, tymianek, sól, pieprz",UNMAPPED,unmapped
Rukola,Lettuce mix,mapped
Ryż basmati,White rice dry,mapped
Ryż brązowy,Brown rice dry,mapped
Ryż jaśminowy,White rice dry,mapped
Rzodkiewka,Radish,mapped
Salami,Ham (cooked),mapped
Salami (cienkie plastry),Ham (cooked),mapped
Sałata,Lettuce mix,mapped
Sałata lub mix sałat,Lettuce mix,mapped
Seler korzeniowy,Celery root,mapped
Seler naciowy,Celery stalk,mapped
Ser feta,Feta cheese,mapped
Ser gouda tłusty,Parmesan,mapped
Ser mozzarella,Mozzarella,mapped
Ser parmezan (tarty),Parmesan,mapped
Ser parmezan tarty,Parmesan,mapped
Ser twarogowy półtłusty,Cottage cheese,mapped
Ser typu feta,Feta cheese,mapped
Serek wiejski naturalny,Greek yogurt (plain),mapped
Siemię lniane mielone,Flax seeds ground,mapped
Siemię lniane świeżo mielone,Flax seeds ground,mapped
Soczewica zielona,Lentils (cooked),mapped
Soczewica zielona (ugotowana),Lentils (cooked),mapped
Sok cytrynowy,Lemon juice,mapped
Sok marchwiowy,Carrot juice,mapped
Sos sojowy,Soy sauce,mapped
Sos sojowy ciemny,Soy sauce,mapped
Suszone figi,Dried figs,mapped
Suszone morele,Dried apricots,mapped
Suszone pomidory (odsączone),Sun-dried tomatoes in oil,mapped
"Suszone pomidory (w oleju z ziołami, odsączone)",Sun-dried tomatoes in oil,mapped
Suszone pomidory w oliwie (odsączone),Sun-dried tomatoes in oil,mapped
Szczypiorek,Chives,mapped
Szpinak,Spinach,mapped
Szpinak świeży,Spinach,mapped
Szynka parmeńska,Ham (cooked),mapped
Szynka parmeńska (Prosciutto),Ham (cooked),mapped
Szynka parmeńska (prosciutto),Ham (cooked),mapped
Szynka z indyka,Turkey breast,mapped
Szynka z indyka (bez konserwantów),Turkey breast,mapped
Sól himalajska,,skip_spice
"Sól, pieprz",UNMAPPED,unmapped
"Sól, pieprz, oregano",UNMAPPED,unmapped
"Sól, pieprz, płatki chili",,skip_spice
"Sól, pieprz, zioła prowansalskie",,skip_spice
Sół himalajska,,skip_spice
"Sół, pieprz, płatki chili",,skip_spice
Słonecznik nasiona łuskane,Pumpkin seeds,mapped
Tahini,Tahini,mapped
Tortilla pełnoziarnista,Whole wheat tortilla,mapped
Truskawki,Strawberries,mapped
Truskawki (świeże lub mrożone),Strawberries,mapped
Tuńczyk w wodzie (odsączony),Tuna (canned in water),mapped
Twaróg półtłusty,Cottage cheese,mapped
Tymianek,Thyme dried,mapped
"Tymianek, czosnek granulowany, sól",Garlic,mapped
"Tymianek, czosnek granulowany, sól, pieprz",Garlic,mapped
"Tymianek, oregano, sól, pieprz",UNMAPPED,unmapped
"Tymianek, rozmaryn, papryka słodka, sół, pieprz",,skip_spice
"Tymianek, rozmaryn, sól, pieprz",UNMAPPED,unmapped
"Tymianek, rozmaryn, zioła prowansalskie, sól, pieprz",,skip_spice
"Tymianek, sól, pieprz",UNMAPPED,unmapped
"Tymianek, sół, pieprz",UNMAPPED,unmapped
Udka kurczaka (bez skóry),Chicken breast,mapped
Udka kurczaka bez skóry,Chicken breast,mapped
Wiórki kokosowe,Coconut flakes,mapped
Woda,Water,mapped
"Wołowina (rostbef lub ligawa, cienkie paski)",Beef stew meat,mapped
Wołowina gulaszowa,Beef stew meat,mapped
Wołowina gulaszowa (karkówka/łopatka),Beef stew meat,mapped
Wołowina mielona,Ground beef (15% fat),mapped
Wątróbka kurczaka,Chicken breast,mapped
Ziele angielskie,,skip_spice
Ziemniaki,Potato,mapped
Zioła prowansalskie,,skip_spice
"Zioła prowansalskie, rozmaryn, tymianek, sól, pieprz",,skip_spice
"Zioła prowansalskie, sól, pieprz",,skip_spice
"Zioła prowansalskie, tymianek, liść laurowy, sól, pieprz",,skip_spice
"Zioła prowansalskie, tymianek, sól, pieprz",,skip_spice
Łosoś wędzony,Smoked salmon,mapped
Śmietana 12%,Sour cream 12%,mapped
Żurawina suszona,Dried cranberries,mapped
1 IngredientName CatalogName Status
2 Awokado Avocado mapped
3 Bakłażan Eggplant mapped
4 Banan Banana mapped
5 Bataty Potato mapped
6 Bazylia suszona Basil dried mapped
7 Bazylia suszona, oregano, płatki chili, sół, pieprz skip_spice
8 Bazylia świeża Basil fresh mapped
9 Bazylia świeża lub suszona, sól, pieprz UNMAPPED unmapped
10 Bazylia świeża, pieprz UNMAPPED unmapped
11 Bazylia świeża, pieprz czarny UNMAPPED unmapped
12 Bazylia, oregano, pieprz UNMAPPED unmapped
13 Bazylia, oregano, sól, pieprz UNMAPPED unmapped
14 Bazylia, rozmaryn, sól, pieprz UNMAPPED unmapped
15 Bazylia, sól, pieprz UNMAPPED unmapped
16 Borówki amerykańskie Blueberries mapped
17 Brokuł Broccoli mapped
18 Brokuły Broccoli mapped
19 Brzoskwinia Peach nectarine mapped
20 Brzoskwinia (świeża lub mrożona) Peach nectarine mapped
21 Bułka tarta pełnoziarnista Breadcrumbs whole grain mapped
22 Cebula Onion mapped
23 Cebula czerwona Onion mapped
24 Chleb pita pełnoziarnisty Whole wheat bread mapped
25 Chleb żytni razowy Rye bread whole grain mapped
26 Ciecierzyca z puszki Chickpeas (cooked) mapped
27 Ciecierzyca z puszki (odsączona) Chickpeas (cooked) mapped
28 Cukinia Zucchini mapped
29 Cynamon Cinnamon ground mapped
30 Cytryna Lemon juice mapped
31 Cytryna (sok) Lemon juice mapped
32 Czosnek Garlic mapped
33 Czosnek granulowany Garlic mapped
34 Czosnek, sól, pieprz Garlic mapped
35 Czosnek, sół, pieprz Garlic mapped
36 Czosnek, tymianek, sól, pieprz Garlic mapped
37 Dorsz świeży filety bez skóry Cod mapped
38 Dynia pestki łuskane Pumpkin seeds mapped
39 Dżem truskawkowy niskosłodzony Strawberries mapped
40 Erytrol/Erytrytol skip_spice
41 Fasola czerwona w zalewie (konserwowa) Kidney beans (cooked) mapped
42 Fasola szparagowa Green beans mapped
43 Fasolka szparagowa Green beans mapped
44 Fasolka szparagowa mrożona Green beans mapped
45 Filet z dorsza lub dorada Cod mapped
46 Filet z piersi indyka Turkey breast mapped
47 Filet z piersi indyka (rozbite plastry) Turkey breast mapped
48 Filet z łososia Salmon mapped
49 Gałka muszkatołowa, sól, pieprz skip_spice
50 Gałka muszkatołowa, sół, pieprz skip_spice
51 Gruszka Peach nectarine mapped
52 Hummus Hummus mapped
53 Imbir mielony Ginger ground mapped
54 Jabłko Apple mapped
55 Jaja kurze całe Chicken egg (whole) mapped
56 Jaja kurze całe (na twardo) Chicken egg (whole) mapped
57 Jajko Chicken egg (whole) mapped
58 Jajko (do klopsików) Chicken egg (whole) mapped
59 Jajko (do panierki) Chicken egg (whole) mapped
60 Jajko ugotowane na twardo Chicken egg (whole) mapped
61 Jogurt grecki 2% Greek yogurt (plain) mapped
62 Jogurt naturalny Greek yogurt (plain) mapped
63 Kabanosy z kurczaka Chicken breast mapped
64 Kakao 16% proszek Cocoa powder 16% fat mapped
65 Kalafior Cauliflower mapped
66 Kapary Capers mapped
67 Kasza bulgur Bulgur dry mapped
68 Kasza gryczana Buckwheat groats dry mapped
69 Kasza jaglana Buckwheat groats dry mapped
70 Keczup Ketchup mapped
71 Kiełbasa chorizo Ham (cooked) mapped
72 Kiełki brokuła Broccoli mapped
73 Kiwi Kiwi mapped
74 Kminek mielony, papryka słodka, sól, pieprz skip_spice
75 Kminek mielony, płatki chili, sól, pieprz skip_spice
76 Kminek, papryka słodka, chili, sól skip_spice
77 Kolendra suszone liście Coriander dried mapped
78 Koncentrat pomidorowy 30% Tomato passata mapped
79 Koper ogrodowy Dill fresh mapped
80 Ksylitol skip_spice
81 Kukurydza konserwowa Sweet corn canned mapped
82 Kurkuma, kminek, bazylia, sól, pieprz UNMAPPED unmapped
83 Kurkuma, kminek, cynamon, kolendra suszona, imbir mielony, sól, pieprz UNMAPPED unmapped
84 Kurkuma, kminek, cynamon, kolendra suszona, sól, pieprz UNMAPPED unmapped
85 Kurkuma, kminek, kolendra suszona, sól UNMAPPED unmapped
86 Kuskus pełnoziarnisty Couscous dry mapped
87 Len mielony Flax seeds ground mapped
88 Len nasiona Flax seeds ground mapped
89 Len, nasiona Flax seeds ground mapped
90 Liść laurowy skip_spice
91 Majeranek Marjoram dried mapped
92 Makaron fusilli pełnoziarnisty Pasta whole grain dry mapped
93 Makaron penne pełnoziarnisty Pasta whole grain dry mapped
94 Makaron pełnoziarnisty Pasta whole grain dry mapped
95 Makaron pełnoziarnisty (fusilli) Pasta whole grain dry mapped
96 Makaron pełnoziarnisty (penne lub spaghetti) Pasta whole grain dry mapped
97 Makaron pełnoziarnisty (tagliatelle lub penne) Pasta whole grain dry mapped
98 Makaron rigatoni pełnoziarnisty Pasta whole grain dry mapped
99 Makaron spaghetti pełnoziarnisty Pasta whole grain dry mapped
100 Makaron tagliatelle pełnoziarnisty Pasta whole grain dry mapped
101 Maliny Raspberries mapped
102 Maliny (świeże lub mrożone) Raspberries mapped
103 Maliny świeże lub mrożone Raspberries mapped
104 Mango (świeże lub mrożone) Mango mapped
105 Marchew Carrot mapped
106 Marchewka Carrot mapped
107 Masło Butter mapped
108 Masło ekstra Butter mapped
109 Masło orzechowe Butter mapped
110 Mielona gałka muszkatołowa skip_spice
111 Mielona papryka chili skip_spice
112 Mielona słodka papryka skip_spice
113 Mielony filet z indyka skip_spice
114 Mielony filet z piersi indyka (bez skóry) skip_spice
115 Migdały Almonds mapped
116 Migdały w płatkach Almonds mapped
117 Mix sałat Lettuce mix mapped
118 Miód Honey mapped
119 Miód pszczeli Honey mapped
120 Mięso wołowe (świeżo mielone z rostbefu) Ground beef (15% fat) mapped
121 Mięso wołowe mielone Ground beef (15% fat) mapped
122 Mięso z piersi kurczaka bez skóry Chicken breast mapped
123 Mięso z podudzia indyka bez skóry Turkey breast mapped
124 Mleko 2% Milk 2% mapped
125 Mleko spożywcze 2% Milk 2% mapped
126 Morszczuk świeży Cod mapped
127 Musztarda dijonska Mustard dijon mapped
128 Mąka pszenna (do obtoczenia) Wheat flour type 1850 mapped
129 Mąka pszenna typ 1850 Wheat flour type 1850 mapped
130 Nasiona chia Chia seeds mapped
131 Natka pietruszki Parsley fresh mapped
132 Nektarynka Peach nectarine mapped
133 Noga (udo) kurczaka Chicken breast mapped
134 Ocet jabłkowy z dojrzałych jabłek Apple mapped
135 Ocet winny biały, sól, pieprz UNMAPPED unmapped
136 Olej lniany tłoczony na zimno Flaxseed oil mapped
137 Oliwa z oliwek Olive oil mapped
138 Oliwki czarne Olives black mapped
139 Oliwki czarne i zielone (mix) Olives black mapped
140 Oliwki zielone Olives green mapped
141 Oliwki zielone marynowane Olives green mapped
142 Oregano suszone Oregano dried mapped
143 Oregano, bazylia suszona, płatki chili, sól, pieprz skip_spice
144 Oregano, bazylia, sól UNMAPPED unmapped
145 Oregano, bazylia, sól, pieprz UNMAPPED unmapped
146 Oregano, pieprz UNMAPPED unmapped
147 Oregano, płatki chili, sól, pieprz skip_spice
148 Oregano, sok cytryny, sól, pieprz Lemon juice mapped
149 Oregano, sól, pieprz UNMAPPED unmapped
150 Oregano, tymianek, czosnek granulowany, sól Garlic mapped
151 Oregano, tymianek, sól, pieprz UNMAPPED unmapped
152 Orzechy nerkowca Cashews mapped
153 Orzechy nerkowca (bez soli) Cashews mapped
154 Orzechy włoskie Walnuts mapped
155 Papryka czerwona Bell pepper mapped
156 Papryka słodka, chili, oregano, sól, pieprz skip_spice
157 Parmezan tarty Parmesan mapped
158 Parmezan w płatkach Parmesan mapped
159 Passata pomidorowa Tomato passata mapped
160 Passata pomidorowa (przecier) Tomato passata mapped
161 Passata pomidorowa przecier Tomato passata mapped
162 Pestki granatu Pomegranate seeds mapped
163 Pesto bazyliowe Pesto basil mapped
164 Pieczarka uprawna świeża Mushrooms mapped
165 Pieczarki świeże Mushrooms mapped
166 Pieprz czarny mielony Black pepper ground mapped
167 Pierś z indyka Turkey breast mapped
168 Pierś z kurczaka Chicken breast mapped
169 Pierś z kurczaka (rozbita cienko) Chicken breast mapped
170 Pierś z kurczaka (ugotowana lub z poprzedniego dnia) Chicken breast mapped
171 Pierś z kurczaka (z poprzedniego dnia lub świeża) Chicken breast mapped
172 Pietruszka korzeń Celery root mapped
173 Pietruszka liście Parsley fresh mapped
174 Pietruszka, liście Parsley fresh mapped
175 Piniole Pine nuts mapped
176 Pistacje (łuskane) Pistachios mapped
177 Pistacje łuskane Pistachios mapped
178 Polędwica sopocka Ham (cooked) mapped
179 Polędwica wieprzowa Pork tenderloin mapped
180 Polędwica wieprzowa surowa Pork tenderloin mapped
181 Pomarańcza Orange mapped
182 Pomidor Tomato mapped
183 Pomidory cherry Tomato mapped
184 Pomidory koktajlowe Tomato mapped
185 Pomidory z puszki Tomato passata mapped
186 Pomidory z puszki (krojone) Tomato passata mapped
187 Por Leek mapped
188 Proszek do pieczenia skip_spice
189 Przyprawa do drobiu skip_spice
190 Płatki chili, sól skip_spice
191 Płatki chili, sól, pieprz skip_spice
192 Płatki owsiane Oats (dry) mapped
193 Ricotta Ricotta mapped
194 Rozmaryn Rosemary dried mapped
195 Rozmaryn, tymianek, bazylia, sól, pieprz UNMAPPED unmapped
196 Rozmaryn, tymianek, oregano, sól, pieprz UNMAPPED unmapped
197 Rozmaryn, tymianek, sól, pieprz UNMAPPED unmapped
198 Rukola Lettuce mix mapped
199 Ryż basmati White rice dry mapped
200 Ryż brązowy Brown rice dry mapped
201 Ryż jaśminowy White rice dry mapped
202 Rzodkiewka Radish mapped
203 Salami Ham (cooked) mapped
204 Salami (cienkie plastry) Ham (cooked) mapped
205 Sałata Lettuce mix mapped
206 Sałata lub mix sałat Lettuce mix mapped
207 Seler korzeniowy Celery root mapped
208 Seler naciowy Celery stalk mapped
209 Ser feta Feta cheese mapped
210 Ser gouda tłusty Parmesan mapped
211 Ser mozzarella Mozzarella mapped
212 Ser parmezan (tarty) Parmesan mapped
213 Ser parmezan tarty Parmesan mapped
214 Ser twarogowy półtłusty Cottage cheese mapped
215 Ser typu feta Feta cheese mapped
216 Serek wiejski naturalny Greek yogurt (plain) mapped
217 Siemię lniane mielone Flax seeds ground mapped
218 Siemię lniane świeżo mielone Flax seeds ground mapped
219 Soczewica zielona Lentils (cooked) mapped
220 Soczewica zielona (ugotowana) Lentils (cooked) mapped
221 Sok cytrynowy Lemon juice mapped
222 Sok marchwiowy Carrot juice mapped
223 Sos sojowy Soy sauce mapped
224 Sos sojowy ciemny Soy sauce mapped
225 Suszone figi Dried figs mapped
226 Suszone morele Dried apricots mapped
227 Suszone pomidory (odsączone) Sun-dried tomatoes in oil mapped
228 Suszone pomidory (w oleju z ziołami, odsączone) Sun-dried tomatoes in oil mapped
229 Suszone pomidory w oliwie (odsączone) Sun-dried tomatoes in oil mapped
230 Szczypiorek Chives mapped
231 Szpinak Spinach mapped
232 Szpinak świeży Spinach mapped
233 Szynka parmeńska Ham (cooked) mapped
234 Szynka parmeńska (Prosciutto) Ham (cooked) mapped
235 Szynka parmeńska (prosciutto) Ham (cooked) mapped
236 Szynka z indyka Turkey breast mapped
237 Szynka z indyka (bez konserwantów) Turkey breast mapped
238 Sól himalajska skip_spice
239 Sól, pieprz UNMAPPED unmapped
240 Sól, pieprz, oregano UNMAPPED unmapped
241 Sól, pieprz, płatki chili skip_spice
242 Sól, pieprz, zioła prowansalskie skip_spice
243 Sół himalajska skip_spice
244 Sół, pieprz, płatki chili skip_spice
245 Słonecznik nasiona łuskane Pumpkin seeds mapped
246 Tahini Tahini mapped
247 Tortilla pełnoziarnista Whole wheat tortilla mapped
248 Truskawki Strawberries mapped
249 Truskawki (świeże lub mrożone) Strawberries mapped
250 Tuńczyk w wodzie (odsączony) Tuna (canned in water) mapped
251 Twaróg półtłusty Cottage cheese mapped
252 Tymianek Thyme dried mapped
253 Tymianek, czosnek granulowany, sól Garlic mapped
254 Tymianek, czosnek granulowany, sól, pieprz Garlic mapped
255 Tymianek, oregano, sól, pieprz UNMAPPED unmapped
256 Tymianek, rozmaryn, papryka słodka, sół, pieprz skip_spice
257 Tymianek, rozmaryn, sól, pieprz UNMAPPED unmapped
258 Tymianek, rozmaryn, zioła prowansalskie, sól, pieprz skip_spice
259 Tymianek, sól, pieprz UNMAPPED unmapped
260 Tymianek, sół, pieprz UNMAPPED unmapped
261 Udka kurczaka (bez skóry) Chicken breast mapped
262 Udka kurczaka bez skóry Chicken breast mapped
263 Wiórki kokosowe Coconut flakes mapped
264 Woda Water mapped
265 Wołowina (rostbef lub ligawa, cienkie paski) Beef stew meat mapped
266 Wołowina gulaszowa Beef stew meat mapped
267 Wołowina gulaszowa (karkówka/łopatka) Beef stew meat mapped
268 Wołowina mielona Ground beef (15% fat) mapped
269 Wątróbka kurczaka Chicken breast mapped
270 Ziele angielskie skip_spice
271 Ziemniaki Potato mapped
272 Zioła prowansalskie skip_spice
273 Zioła prowansalskie, rozmaryn, tymianek, sól, pieprz skip_spice
274 Zioła prowansalskie, sól, pieprz skip_spice
275 Zioła prowansalskie, tymianek, liść laurowy, sól, pieprz skip_spice
276 Zioła prowansalskie, tymianek, sól, pieprz skip_spice
277 Łosoś wędzony Smoked salmon mapped
278 Śmietana 12% Sour cream 12% mapped
279 Żurawina suszona Dried cranberries mapped

View File

@@ -0,0 +1,110 @@
-- 006_diet_and_recipe_generators.sql
-- AI-assisted diet plan and recipe generation modules.
-- Apply after scripts 001005.
BEGIN;
-- ---------------------------------------------------------------------------
-- User profile for TDEE / goal-based planning
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."DietUserProfiles" (
"UserId" uuid PRIMARY KEY
REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
"HeightCm" numeric(5,2) NOT NULL CHECK ("HeightCm" > 0),
"WeightKg" numeric(5,2) NOT NULL CHECK ("WeightKg" > 0),
"Age" int NOT NULL CHECK ("Age" BETWEEN 10 AND 120),
"Sex" varchar(16) NOT NULL CHECK ("Sex" IN ('male', 'female')),
"ActivityLevel" varchar(32) NOT NULL
CHECK ("ActivityLevel" IN ('sedentary', 'light', 'moderate', 'active', 'very_active')),
"Goal" varchar(32) NOT NULL
CHECK ("Goal" IN ('lose_weight', 'maintain', 'gain_muscle', 'recomp')),
"AllergiesNotes" text NULL,
"DislikedFoods" text NULL,
"MealsPerDayMask" int NOT NULL DEFAULT 15 CHECK ("MealsPerDayMask" BETWEEN 1 AND 31),
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
"UpdatedAt" timestamptz NULL
);
COMMENT ON COLUMN diet."DietUserProfiles"."MealsPerDayMask" IS 'Bitmask: bit0=Breakfast, bit1=SecondBreakfast, bit2=Lunch, bit3=Dinner, bit4=Snack';
-- ---------------------------------------------------------------------------
-- Diet generator wizard sessions
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."DietGeneratorSessions" (
"Id" serial PRIMARY KEY,
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
"Status" varchar(32) NOT NULL DEFAULT 'macros_pending',
"ProposedCalories" int NULL CHECK ("ProposedCalories" IS NULL OR "ProposedCalories" > 0),
"ProposedProteinG" numeric(6,2) NULL,
"ProposedFatG" numeric(6,2) NULL,
"ProposedCarbsG" numeric(6,2) NULL,
"MacroRationale" text NULL,
"MacrosAcceptedAt" timestamptz NULL,
"PlanStartDate" date NULL,
"PlanDayCount" int NOT NULL DEFAULT 7 CHECK ("PlanDayCount" BETWEEN 1 AND 28),
"CalorieToleranceKcal" int NOT NULL DEFAULT 10 CHECK ("CalorieToleranceKcal" >= 0),
"PreferencesJson" jsonb NULL,
"CommittedAt" timestamptz NULL,
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
"UpdatedAt" timestamptz NULL
);
CREATE INDEX IF NOT EXISTS "IX_DietGeneratorSessions_UserId_Status"
ON diet."DietGeneratorSessions" ("UserId", "Status");
-- ---------------------------------------------------------------------------
-- Draft meals (review before commit to MealPlanEntries)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."DietGeneratorDraftMeals" (
"Id" serial PRIMARY KEY,
"SessionId" int NOT NULL REFERENCES diet."DietGeneratorSessions"("Id") ON DELETE CASCADE,
"PlanDate" date NOT NULL,
"MealCategory" int NOT NULL CHECK ("MealCategory" BETWEEN 0 AND 4),
"RecipeId" int NOT NULL REFERENCES diet."Recipes"("Id") ON DELETE CASCADE,
"Portions" numeric(4,2) NOT NULL DEFAULT 1 CHECK ("Portions" > 0),
"Calories" int NULL,
"ProteinG" numeric(6,2) NULL,
"FatG" numeric(6,2) NULL,
"CarbsG" numeric(6,2) NULL,
"IsLocked" boolean NOT NULL DEFAULT false,
"GenerationVersion" int NOT NULL DEFAULT 1,
UNIQUE ("SessionId", "PlanDate", "MealCategory")
);
CREATE INDEX IF NOT EXISTS "IX_DietGeneratorDraftMeals_SessionId"
ON diet."DietGeneratorDraftMeals" ("SessionId");
-- ---------------------------------------------------------------------------
-- Recipe generator sessions (draft JSON until commit)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS diet."RecipeGeneratorSessions" (
"Id" serial PRIMARY KEY,
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
"Status" varchar(32) NOT NULL DEFAULT 'draft',
"ConstraintsJson" jsonb NOT NULL DEFAULT '{}',
"DraftJson" jsonb NULL,
"SavedRecipeId" int NULL REFERENCES diet."Recipes"("Id") ON DELETE SET NULL,
"GenerationVersion" int NOT NULL DEFAULT 1,
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
"UpdatedAt" timestamptz NULL
);
CREATE INDEX IF NOT EXISTS "IX_RecipeGeneratorSessions_UserId"
ON diet."RecipeGeneratorSessions" ("UserId");
-- ---------------------------------------------------------------------------
-- Recipe provenance (AI vs manual)
-- ---------------------------------------------------------------------------
ALTER TABLE diet."Recipes"
ADD COLUMN IF NOT EXISTS "Source" varchar(32) NOT NULL DEFAULT 'manual';
ALTER TABLE diet."Recipes"
ADD COLUMN IF NOT EXISTS "CreatedByUserId" uuid NULL
REFERENCES diet."AspNetUsers"("Id") ON DELETE SET NULL;
ALTER TABLE diet."Recipes"
ADD COLUMN IF NOT EXISTS "IsDraft" boolean NOT NULL DEFAULT false;
COMMENT ON COLUMN diet."Recipes"."Source" IS 'manual | excel | ai_generated';
COMMIT;

View File

@@ -0,0 +1,135 @@
-- 007_remove_duplicate_recipes_by_name.sql
-- Removes duplicate recipes that share the same "Name" (trimmed, exact match).
-- Keeps the row with the lowest "Id" for each name.
--
-- Analysis of Recipes-2.csv export (196 rows):
-- 168 unique names, 28 duplicate groups, 28 rows to delete (Ids 113140).
-- Those duplicate Ids mirror Ids 2956 one-to-one.
--
-- Run inside a transaction. Review the preview queries before COMMIT.
BEGIN;
-- ---------------------------------------------------------------------------
-- 1) Preview duplicates
-- ---------------------------------------------------------------------------
-- SELECT
-- r."Name",
-- array_agg(r."Id" ORDER BY r."Id") AS ids,
-- min(r."Id") AS keep_id,
-- array_agg(r."Id" ORDER BY r."Id") FILTER (WHERE r."Id" <> min(r."Id")) AS remove_ids
-- FROM diet."Recipes" r
-- GROUP BY r."Name"
-- HAVING count(*) > 1
-- ORDER BY min(r."Id");
-- ---------------------------------------------------------------------------
-- 2) Build duplicate map: remove_id -> keep_id
-- ---------------------------------------------------------------------------
CREATE TEMP TABLE recipe_dedup_map ON COMMIT DROP AS
SELECT
r."Id" AS remove_id,
d.keep_id
FROM diet."Recipes" r
INNER JOIN (
SELECT btrim("Name") AS name_key, MIN("Id") AS keep_id
FROM diet."Recipes"
GROUP BY btrim("Name")
HAVING COUNT(*) > 1
) d ON btrim(r."Name") = d.name_key
WHERE r."Id" <> d.keep_id;
-- Optional: restrict to known duplicate ids from export
-- DELETE FROM recipe_dedup_map WHERE remove_id NOT BETWEEN 113 AND 140;
DO $$
DECLARE
dup_count int;
BEGIN
SELECT count(*) INTO dup_count FROM recipe_dedup_map;
RAISE NOTICE 'Duplicate recipe rows to remove: %', dup_count;
END $$;
-- ---------------------------------------------------------------------------
-- 3) Re-point foreign keys to the canonical (lowest-id) recipe
-- ---------------------------------------------------------------------------
-- Meal planner / diet generator drafts
UPDATE diet."MealPlanEntries" e
SET "RecipeId" = m.keep_id,
"UpdatedAt" = now()
FROM recipe_dedup_map m
WHERE e."RecipeId" = m.remove_id;
UPDATE diet."DietGeneratorDraftMeals" d
SET "RecipeId" = m.keep_id
FROM recipe_dedup_map m
WHERE d."RecipeId" = m.remove_id;
-- AI recipe generator saved draft pointer
UPDATE diet."RecipeGeneratorSessions" s
SET "SavedRecipeId" = m.keep_id,
"UpdatedAt" = now()
FROM recipe_dedup_map m
WHERE s."SavedRecipeId" = m.remove_id;
-- Diet tracking (nullable FK)
UPDATE diet."MealConsumptions" c
SET "RecipeId" = m.keep_id
FROM recipe_dedup_map m
WHERE c."RecipeId" = m.remove_id;
-- Favorites: drop rows that would collide after merge, then re-point the rest
DELETE FROM diet."UserFavoriteRecipes" f
USING recipe_dedup_map m
WHERE f."RecipeId" = m.remove_id
AND EXISTS (
SELECT 1
FROM diet."UserFavoriteRecipes" f2
WHERE f2."UserId" = f."UserId"
AND f2."RecipeId" = m.keep_id
);
UPDATE diet."UserFavoriteRecipes" f
SET "RecipeId" = m.keep_id
FROM recipe_dedup_map m
WHERE f."RecipeId" = m.remove_id;
-- ---------------------------------------------------------------------------
-- 4) Delete duplicate recipes (Ingredients + RecipeSteps cascade)
-- ---------------------------------------------------------------------------
DELETE FROM diet."Recipes" r
USING recipe_dedup_map m
WHERE r."Id" = m.remove_id;
-- ---------------------------------------------------------------------------
-- 5) Verify
-- ---------------------------------------------------------------------------
DO $$
DECLARE
remaining_dup_names int;
recipe_count int;
BEGIN
SELECT count(*) INTO remaining_dup_names
FROM (
SELECT btrim("Name")
FROM diet."Recipes"
GROUP BY btrim("Name")
HAVING count(*) > 1
) d;
SELECT count(*) INTO recipe_count FROM diet."Recipes";
IF remaining_dup_names > 0 THEN
RAISE EXCEPTION 'Duplicate recipe names still present: % groups', remaining_dup_names;
END IF;
RAISE NOTICE 'Done. Active recipe count: %', recipe_count;
END $$;
COMMIT;
-- Optional hardening (uncomment if every recipe name must be unique going forward):
-- CREATE UNIQUE INDEX IF NOT EXISTS "UX_Recipes_Name"
-- ON diet."Recipes" (btrim("Name"))
-- WHERE "IsActive" AND NOT "IsDraft";

View File

@@ -0,0 +1,25 @@
-- 008_diet_draft_meal_ingredients.sql
-- Scaled ingredient amounts per diet generator draft meal (portions applied at plan time).
-- Apply after script 006.
BEGIN;
CREATE TABLE IF NOT EXISTS diet."DietGeneratorDraftMealIngredients" (
"Id" serial PRIMARY KEY,
"DraftMealId" int NOT NULL
REFERENCES diet."DietGeneratorDraftMeals"("Id") ON DELETE CASCADE,
"SourceIngredientId" int NULL
REFERENCES diet."Ingredients"("Id") ON DELETE SET NULL,
"Name" varchar(255) NOT NULL,
"AmountGrams" numeric(8,2) NULL,
"Unit" varchar(50) NULL,
"SortOrder" int NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS "IX_DietGeneratorDraftMealIngredients_DraftMealId"
ON diet."DietGeneratorDraftMealIngredients" ("DraftMealId");
COMMENT ON TABLE diet."DietGeneratorDraftMealIngredients" IS
'Ingredient weights recalculated for the planned portion size of each draft meal.';
COMMIT;

View File

@@ -0,0 +1,27 @@
-- Resync PostgreSQL identity sequences after bulk imports or manual ID inserts.
-- Safe to run multiple times. Run against the modwad database when new rows fail with:
-- duplicate key value violates unique constraint "Recipes_pkey"
SELECT setval(
pg_get_serial_sequence('diet."Recipes"', 'Id'),
COALESCE((SELECT MAX("Id") FROM diet."Recipes"), 1),
(SELECT MAX("Id") IS NOT NULL FROM diet."Recipes")
);
SELECT setval(
pg_get_serial_sequence('diet."Ingredients"', 'Id'),
COALESCE((SELECT MAX("Id") FROM diet."Ingredients"), 1),
(SELECT MAX("Id") IS NOT NULL FROM diet."Ingredients")
);
SELECT setval(
pg_get_serial_sequence('diet."RecipeSteps"', 'Id'),
COALESCE((SELECT MAX("Id") FROM diet."RecipeSteps"), 1),
(SELECT MAX("Id") IS NOT NULL FROM diet."RecipeSteps")
);
SELECT setval(
pg_get_serial_sequence('diet."RecipeGeneratorSessions"', 'Id'),
COALESCE((SELECT MAX("Id") FROM diet."RecipeGeneratorSessions"), 1),
(SELECT MAX("Id") IS NOT NULL FROM diet."RecipeGeneratorSessions")
);

173
database/scripts/README.md Normal file
View File

@@ -0,0 +1,173 @@
# Database scripts
SQL in this folder is applied **manually** on PostgreSQL. The API is database-first and does not run migrations.
## Diet tracking module
Run on your DailyMeals database:
```bash
psql "$DATABASE_URL" -f database/scripts/001_diet_tracking.sql
```
### Tables created
| Table | Purpose |
|-------|---------|
| `UserDailyGoals` | Daily calorie, macro, and water targets (per user) |
| `DrinkCatalog` | Preset drinks (water, tea, coffee, …) with default volumes |
| `MealConsumptions` | Meals the user ate (optional link to `Recipes`, macros snapshotted) |
| `DrinkConsumptions` | Hydration / beverage log |
| `DietReminders` | Reminder schedules (water, meal, custom) |
### API (after script + API deploy)
| Method | Endpoint |
|--------|----------|
| GET | `/api/diet/summary?date=YYYY-MM-DD` |
| GET/PUT | `/api/diet/goals` |
| GET/POST/DELETE | `/api/diet/meals` |
| GET | `/api/diet/drinks/catalog` |
| GET/POST/DELETE | `/api/diet/drinks` |
| GET/POST/PUT/DELETE | `/api/diet/reminders` |
| GET | `/api/diet/reminders/due?date=&time=` |
Web UI: **My Diet** at `/diet` in the React frontend.
## Ingredient nutrition catalog
```bash
psql "$DATABASE_URL" -f database/scripts/002_ingredient_nutrition_catalog.sql
```
### Tables created
| Table | Purpose |
|-------|---------|
| `IngredientCategories` | Food groups (meat, vegetables, dairy, …) |
| `IngredientNutritionCatalog` | Per-100 g calories, protein, fat, carbs, fiber |
### API (after script + API deploy)
| Method | Endpoint |
|--------|----------|
| GET | `/api/ingredient-catalog/categories` |
| GET | `/api/ingredient-catalog?category=&search=` |
| GET | `/api/ingredient-catalog/{id}` |
| POST | `/api/ingredient-catalog` |
| PUT | `/api/ingredient-catalog/{id}` |
| DELETE | `/api/ingredient-catalog/{id}` |
Web UI: **Ingredient catalog** at `/ingredients` in all frontends.
### Polish ingredient names
```bash
psql "$DATABASE_URL" -f database/scripts/003_ingredient_catalog_polish_names.sql
```
Adds `NamePl` column and Polish display names for all seeded catalog items. Search matches both English and Polish names.
## Extended features (meal planner, auth persistence, catalog linking)
```bash
psql "$DATABASE_URL" -f database/scripts/004_extended_features.sql
```
### Changes
| Object | Purpose |
|--------|---------|
| `RefreshTokens` | Persist web login sessions across API restarts |
| `MealPlanEntries` | Weekly meal planner (one recipe per meal slot per day) |
| `UserFavoriteRecipes` | Quick-log favorites |
| `Ingredients.CatalogItemId` | FK to nutrition catalog for auto macro calculation |
Run **after** scripts 001003.
## Catalog expansion + bulk ingredient linking
One-time migration for existing recipe data (based on your `Ingredients.csv` export):
```bash
psql "$DATABASE_URL" -f database/scripts/005_catalog_expansion_and_ingredient_linking.sql
```
### What it does
1. **Adds ~70 catalog items** missing from the seed (Polish staples: rye bread, dry pasta/rice, feta, tahini, passata, herbs/spices, …).
2. **Links `Ingredients.CatalogItemId`** via a name→catalog mapping (~218 ingredient name variants).
3. **Recalculates `Recipes` macros** as `SUM(catalog × AmountGrams / 100)` for all recipes with linked ingredients.
Reference mapping: `database/scripts/005_ingredient_name_mapping.csv` (ingredient name → catalog name → status).
After the script, check PostgreSQL notices for unlinked rows and recipes still missing macros. Spice rows (`do smaku`, `szczypta`) and rows without grams are intentionally skipped.
### API alternative (after linking)
If ingredients already have `CatalogItemId` set, you can recalculate without re-running SQL:
| Method | Endpoint |
|--------|----------|
| POST | `/api/recipes/recalculate-macros` |
Returns `{ recipesProcessed, recipesUpdated, unlinkedIngredientRows }`. Requires auth. Overwrites recipe macros when at least one linked ingredient has grams.
## AI generators (diet plan + recipes)
```bash
psql "$DATABASE_URL" -f database/scripts/006_diet_and_recipe_generators.sql
```
Configure OpenAI in `MealPlan.Api/appsettings.Local.json`:
```json
"OpenAI": {
"ApiKey": "sk-...",
"Model": "gpt-4.1-mini",
"MacrosModel": "gpt-4.1-mini",
"PlanModel": "gpt-4.1-mini",
"RecipeModel": "gpt-4.1",
"MaxTokens": 4096
}
```
| Setting | Used for |
|---------|----------|
| `MacrosModel` | Diet generator — refine daily kcal/macros |
| `PlanModel` | Diet generator — pick recipes for the week |
| `RecipeModel` | Recipe generator — ingredients + steps |
| `Model` | Fallback when a task model is omitted |
Without an API key, macro targets still use TDEE math and meal plans use a local greedy picker; recipe generation uses a simple fallback template.
### Diet generator API
| Method | Endpoint |
|--------|----------|
| GET/PUT | `/api/diet-generator/profile` |
| POST | `/api/diet-generator/sessions` |
| GET | `/api/diet-generator/sessions/{id}` |
| POST | `/api/diet-generator/sessions/{id}/propose-macros` |
| POST | `/api/diet-generator/sessions/{id}/accept-macros` |
| POST | `/api/diet-generator/sessions/{id}/generate-plan` |
| POST | `/api/diet-generator/sessions/{id}/regenerate-meal` |
| PATCH | `/api/diet-generator/sessions/{id}/meals/{mealId}` |
| POST | `/api/diet-generator/sessions/{id}/commit` |
Commit writes `UserDailyGoals` + `MealPlanEntries`. Daily calorie tolerance defaults to **10 kcal**.
### Recipe generator API
| Method | Endpoint |
|--------|----------|
| POST | `/api/recipe-generator/sessions` |
| GET | `/api/recipe-generator/sessions/{id}` |
| POST | `/api/recipe-generator/sessions/{id}/generate` |
| POST | `/api/recipe-generator/sessions/{id}/regenerate` |
| PUT | `/api/recipe-generator/sessions/{id}/draft` |
| POST | `/api/recipe-generator/sessions/{id}/commit` |
Saved recipes get `Source = ai_generated` and macros from the nutrition catalog.
Web UI: `/diet-generator` and `/recipe-generator` in the React frontend.

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
meal-plan-frontend-angular/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

View File

@@ -0,0 +1,30 @@
# ---------- Build stage ----------
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci || npm install
COPY . .
RUN npm run build
# ---------- Serve stage ----------
FROM nginx:alpine AS final
RUN apk add --no-cache openssl && \
mkdir -p /etc/nginx/certs && \
openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
-keyout /etc/nginx/certs/server.key \
-out /etc/nginx/certs/server.crt \
-subj "/CN=dailymeals.local" && \
chmod 600 /etc/nginx/certs/server.key
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/dist/meal-plan-frontend-angular/browser /usr/share/nginx/html
EXPOSE 80 443
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --no-verbose --no-check-certificate --tries=1 --spider https://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,56 @@
# DailyMeals — Angular frontend
Angular 19 rewrite of the React `meal-plan-frontend` app.
## Stack
- Angular 19 (standalone components)
- Tailwind CSS 3
- `@ngx-translate` (EN / PL)
- Reactive forms
- JWT auth with silent refresh (`AuthInterceptor`)
## Development
```bash
cd meal-plan-frontend-angular
npm install
npm start
```
App runs at **http://localhost:4200** with API proxied to `http://localhost:5000` (`proxy.conf.json`).
Ensure the ASP.NET API is running locally before signing in.
## Production build
```bash
npm run build
```
Output: `dist/meal-plan-frontend-angular/browser/`
## Docker
```bash
docker build -t dailymeals-angular .
```
Uses nginx with HTTPS and `/api` reverse proxy (same pattern as the React frontend).
## Routes
| Path | Page |
|------|------|
| `/login`, `/register` | Auth (public) |
| `/` | Dashboard |
| `/breakfast``/dinner` | Category lists |
| `/all-meals` | Search, select, PDF export |
| `/manage-meals` | Manual add + Excel import |
| `/recipes/:id` | Recipe detail |
## i18n
Translation files: `public/i18n/en.json`, `public/i18n/pl.json`
Language preference stored in `localStorage` key `dailymeals.lang`.

View File

@@ -0,0 +1,127 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"meal-plan-frontend-angular": {
"projectType": "application",
"schematics": {
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:component": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/meal-plan-frontend-angular",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1.5MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "meal-plan-frontend-angular:build:production"
},
"development": {
"buildTarget": "meal-plan-frontend-angular:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

View File

@@ -0,0 +1,84 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server_tokens off;
gzip on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
upstream api_upstream {
server api:5000;
}
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name _;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
root /usr/share/nginx/html;
index index.html;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
location /api/ {
proxy_pass http://api_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /i18n/ {
expires 1h;
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}

16757
meal-plan-frontend-angular/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
{
"name": "meal-plan-frontend-angular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"@ngx-translate/core": "^18.0.0",
"@ngx-translate/http-loader": "^18.0.0",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.27",
"@angular/cli": "^19.2.27",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"autoprefixer": "^10.5.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.14",
"typescript": "~5.7.2"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:5000",
"secure": false,
"changeOrigin": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#16774c" />
<path
d="M11 7v8a3 3 0 0 0 3 3v7a1 1 0 0 0 2 0v-7a3 3 0 0 0 3-3V7a1 1 0 0 0-2 0v5a1 1 0 0 1-2 0V7a1 1 0 0 0-2 0v5a1 1 0 0 1-2 0V7a1 1 0 0 0-2 0Z"
fill="#fff"
/>
</svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@@ -0,0 +1,434 @@
{
"app": {
"name": "DailyMeals"
},
"language": {
"label": "Language",
"en": "English",
"pl": "Polish"
},
"layout": {
"label": "Layout",
"sidebar": "Sidebar",
"topnav": "Top navigation",
"compact": "Compact sidebar",
"wide": "Wide sidebar",
"wideTagline": "Plan your meals with ease"
},
"appearance": {
"label": "Appearance",
"forest": "Forest Classic",
"forestDesc": "Green palette, outline icons",
"ocean": "Ocean Fresh",
"oceanDesc": "Blue palette, outline icons",
"sunset": "Sunset Kitchen",
"sunsetDesc": "Warm amber palette, emoji icons",
"berry": "Berry Modern",
"berryDesc": "Violet palette, bold icons"
},
"nav": {
"dashboard": "Dashboard",
"breakfast": "Breakfast",
"secondBreakfast": "Second Breakfast",
"lunch": "Lunch",
"dinner": "Dinner",
"allMeals": "All Meals",
"manageMeals": "Manage Meals",
"mealPlanner": "Meal Planner",
"shoppingList": "Shopping List",
"dietTracker": "My Diet",
"ingredientCatalog": "Ingredients",
"dietGenerator": "Diet Generator",
"recipeGenerator": "Recipe Generator",
"logout": "Logout",
"user": "User",
"toggleNav": "Toggle navigation",
"toggleTheme": "Toggle theme",
"lightMode": "Switch to light mode",
"darkMode": "Switch to dark mode"
},
"categories": {
"breakfast": "Breakfast",
"secondBreakfast": "Second Breakfast",
"lunch": "Lunch",
"dinner": "Dinner",
"unknown": "Unknown"
},
"common": {
"loading": "Loading...",
"tryAgain": "Try again",
"back": "Back",
"add": "Add",
"category": "Category",
"all": "All",
"search": "Search",
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"optional": "optional",
"recipeCount_one": "{{count}} recipe",
"recipeCount_other": "{{count}} recipes",
"selectedCount_one": "{{count}} selected",
"selectedCount_other": "{{count}} selected"
},
"auth": {
"signInSubtitle": "Sign in to plan your meals",
"email": "Email",
"password": "Password",
"emailPlaceholder": "you@example.com",
"signIn": "Sign in",
"signingIn": "Signing in...",
"noAccount": "Don't have an account?",
"createOne": "Create one",
"createAccountTitle": "Create account",
"createAccountSubtitle": "Join DailyMeals to browse and plan meals",
"displayName": "Display name",
"displayNamePlaceholder": "Piotr",
"confirmPassword": "Confirm password",
"passwordHint": "At least 8 characters with uppercase, lowercase, and a number.",
"createAccount": "Create account",
"creatingAccount": "Creating account...",
"hasAccount": "Already have an account?"
},
"validation": {
"emailRequired": "Email is required.",
"emailInvalid": "Enter a valid email address.",
"passwordRequired": "Password is required.",
"displayNameTooLong": "Display name is too long.",
"passwordMinLength": "Password must be at least 8 characters.",
"passwordUppercase": "Include at least one uppercase letter.",
"passwordLowercase": "Include at least one lowercase letter.",
"passwordDigit": "Include at least one digit.",
"confirmPasswordRequired": "Please confirm your password.",
"passwordsMismatch": "Passwords do not match.",
"recipeNameRequired": "Recipe name is required.",
"ingredientNameRequired": "Ingredient name is required.",
"stepNumberMin": "Step number must be at least 1.",
"stepDescriptionRequired": "Step description is required.",
"stepsMin": "Add at least one preparation step."
},
"errors": {
"generic": "Something went wrong.",
"signInFailed": "Unable to sign in.",
"registerFailed": "Unable to create account.",
"loadCategoriesFailed": "Failed to load categories.",
"loadRecipesFailed": "Failed to load recipes.",
"loadRecipeFailed": "Failed to load recipe.",
"loadDietFailed": "Failed to load diet data.",
"loadMealPlanFailed": "Failed to load meal plan.",
"loadShoppingListFailed": "Failed to load shopping list.",
"loadIngredientCatalogFailed": "Failed to load ingredient catalog.",
"saveIngredientFailed": "Failed to save ingredient.",
"saveRecipeFailed": "Failed to save recipe.",
"recalculateMacrosFailed": "Failed to recalculate recipe macros.",
"importFailed": "Import failed.",
"pdfLoadFailed": "Could not load recipes for export.",
"pdfGenerateFailed": "PDF generation failed.",
"pdfRenderAreaMissing": "PDF render area not found.",
"pdfNothingToExport": "Nothing to export."
},
"dashboard": {
"welcome": "Welcome back",
"welcomeNamed": "Welcome back, {{name}}",
"subtitle": "Browse recipes by meal category or explore everything at once.",
"viewRecipes": "View recipes"
},
"recipes": {
"allMealsTitle": "All Meals",
"allMealsSubtitle": "Select recipes and export them with a combined shopping list.",
"selectAllVisible": "Select all visible",
"deselectAll": "Deselect all",
"searchRecipes": "Search recipes...",
"searchByIngredient": "Search by ingredient...",
"searchByIngredientLabel": "Search by ingredient",
"searchByNameAria": "Search recipes by name",
"searchByIngredientAria": "Search recipes by ingredient",
"noMatchingTitle": "No matching recipes",
"noMatchingIngredient": "No recipes contain that ingredient. Try a different ingredient name.",
"noMatchingFilter": "Try adjusting your search or category filter.",
"emptyCategoryTitle": "No {{category}} recipes yet",
"emptyCategoryDescription": "There are currently no recipes in this category.",
"selectRecipe": "Select {{name}}",
"prepTime": "{{count}} min preparation",
"calories": "Calories",
"protein": "Protein",
"fat": "Fat",
"carbs": "Carbs",
"ingredients": "Ingredients",
"preparation": "Preparation",
"noIngredients": "No ingredients listed.",
"noSteps": "No steps listed.",
"kcal": "{{count}} kcal",
"minutes": "{{count}} min"
},
"manage": {
"title": "Manage Meals",
"subtitle": "Add recipes manually or import them from an Excel spreadsheet.",
"tabManual": "Add manually",
"tabEdit": "Edit recipes",
"tabImport": "Import Excel",
"savedSuccess": "\"{{name}}\" saved successfully.",
"basicInfo": "Basic info",
"recipeName": "Recipe name",
"recipeNamePlaceholder": "Grilled chicken salad",
"prepTimeMin": "Prep time (min)",
"caloriesKcal": "Calories (kcal)",
"proteinG": "Protein (g)",
"fatG": "Fat (g)",
"carbsG": "Carbs (g)",
"preparationSteps": "Preparation steps",
"addStep": "Add step",
"stepPlaceholder": "Describe this step...",
"ingredientName": "Name",
"ingredientGrams": "Grams",
"ingredientUnit": "Unit",
"removeIngredient": "Remove ingredient",
"removeStep": "Remove step",
"saveRecipe": "Save recipe",
"updateRecipe": "Update recipe",
"updatedSuccess": "\"{{name}}\" updated successfully.",
"selectRecipeToEdit": "Select a recipe from the list to edit.",
"catalogLink": "Link to ingredient catalog",
"estimatedFromCatalog": "Estimated from linked catalog items",
"unlinkedIngredientsWarning": "{{count}} ingredient(s) have grams but no catalog link — macros may be incomplete.",
"recalculateMacros": "Recalculate all macros",
"recalculating": "Recalculating…",
"recalculateSuccess": "Updated {{updated}} of {{processed}} recipes. {{unlinked}} ingredient row(s) still unlinked.",
"macroHint": "Estimated: {{calories}} kcal · P {{protein}}g · F {{fat}}g · C {{carbs}}g",
"saving": "Saving...",
"importTitle": "Import from Excel",
"importDescription": "Use the template with three sheets: Recipes, Ingredients, and Steps. Recipe names must match across sheets.",
"downloadTemplate": "Download template",
"uploadLabel": "Upload filled .xlsx file",
"importComplete": "Import complete",
"createdCount": "Created: {{count}}",
"skippedCount": "Skipped: {{count}}",
"importRecipes": "Import recipes",
"importing": "Importing...",
"excelFormatTitle": "Excel format",
"excelRecipesSheet": "Recipes: Name, MealCategory (Breakfast / Lunch / 03), Calories, Protein, Fat, Carbs, PrepTimeMinutes",
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
"duplicateSkipped": "Duplicate recipe names are skipped."
},
"generators": {
"diet": {
"title": "Diet Generator",
"subtitle": "Calculate targets from your profile, build a weekly plan from your recipe catalog, and save it to the meal planner.",
"profileTitle": "Your profile",
"heightCm": "Height (cm)",
"weightKg": "Weight (kg)",
"age": "Age",
"sex": "Sex",
"male": "Male",
"female": "Female",
"activity": "Activity level",
"sedentary": "Sedentary",
"light": "Light",
"moderate": "Moderate",
"active": "Active",
"veryActive": "Very active",
"goal": "Goal",
"loseWeight": "Lose weight",
"maintain": "Maintain",
"gainMuscle": "Gain muscle",
"recomp": "Recomposition",
"allergies": "Allergies / notes",
"continue": "Calculate targets",
"macrosTitle": "Daily targets",
"acceptAndGenerate": "Accept & generate 7-day plan",
"planHint": "Each day should be within ±10 kcal of your target. Lock meals you like, regenerate others.",
"lock": "Lock",
"unlock": "Unlock",
"viewRecipe": "Details",
"noAlternativeMeals": "No other recipes are available for this meal — all suitable options are already in your plan.",
"commit": "Save plan to calendar",
"done": "Plan saved to your meal planner and daily goals.",
"newPlan": "Create another plan",
"error": "Diet generator failed."
},
"recipe": {
"title": "Recipe Generator",
"subtitle": "Create several recipe drafts with AI, review them, and save the ones you like.",
"constraintsTitle": "What should we cook?",
"prompt": "Describe the meal",
"promptPlaceholder": "Light breakfast with oatmeal and fruit…",
"targetCalories": "Target calories (kcal)",
"targetCaloriesOptional": "e.g. 500",
"targetCaloriesHint": "Ingredient amounts will be scaled to match (±10 kcal).",
"caloriesWithTarget": "{{actual}} kcal (target: {{target}})",
"maxPrep": "Max prep time (min)",
"exclusions": "Exclude ingredients",
"generate": "Generate recipe",
"recipeCount": "How many recipes to generate",
"reviewHint": "Review {{count}} generated recipes. Uncheck or remove ones you don't want, then save the rest.",
"selectAll": "Select all",
"remove": "Remove",
"saveSelected": "Save selected ({{count}})",
"doneMultiple": "{{count}} recipes saved to your library.",
"generateMore": "Generate more recipes",
"regenIngredients": "Regenerate ingredients",
"regenSteps": "Regenerate steps",
"regenFull": "Regenerate all",
"save": "Save to library",
"done": "Recipe saved.",
"viewRecipe": "View recipe",
"error": "Recipe generator failed."
}
},
"pdf": {
"generate": "Generate PDF",
"preparing": "Preparing...",
"generating": "Generating...",
"prepTime": "Prep time: {{count}} min",
"shoppingList": "Shopping List",
"generatedOn_one": "Generated {{date}} · {{count}} recipe",
"generatedOn_other": "Generated {{date}} · {{count}} recipes",
"recipesInExport": "Recipes in this export",
"toTaste": "to taste",
"dayPlanTitle": "Daily meal plan — {{date}}",
"emptySlot": "Not planned",
"emptyShoppingList": "No shopping items for this day.",
"plannedMealMeta": "{{slot}} · {{portions}}× portions",
"dayShoppingSubtitle": "List for {{date}} · {{count}} meals"
},
"diet": {
"title": "My Diet",
"subtitle": "Track what you eat and drink, set daily targets, and stay on top of reminders.",
"tabToday": "Today",
"tabHistory": "History",
"tabGoals": "Goals",
"tabReminders": "Reminders",
"goToday": "Today",
"notToday": "not today",
"quickLog": "Quick log",
"copyYesterday": "Copy yesterday's meals",
"recentMeals": "Recent",
"favorites": "Favorites",
"days": "days",
"historyCalories": "Calories over time",
"historyMax": "Peak: {{max}} kcal",
"noHistory": "No history for this period.",
"mealsOnDate": "Meals on {{date}}",
"drinksOnDate": "Drinks on {{date}}",
"editReminder": "Edit reminder",
"calories": "Calories",
"water": "Water",
"snack": "Snack",
"logDrink": "Log a drink",
"logMeal": "Mark a meal as eaten",
"searchRecipe": "From a recipe",
"orCustomMeal": "Or enter a custom meal name",
"markEaten": "Mark as eaten",
"mealsToday": "Meals today",
"drinksToday": "Drinks today",
"nothingLogged": "Nothing logged yet.",
"noMacros": "No nutrition info",
"removeEntry": "Remove entry",
"dailyGoals": "Daily goals",
"goalsHint": "Set targets like Lifesum or MyFitnessPal — the Today tab shows what is left.",
"calorieGoal": "Calorie goal (kcal)",
"waterGoal": "Water goal (ml)",
"saveGoals": "Save goals",
"addReminder": "Add reminder",
"reminderType": "Type",
"reminderWater": "Drink water",
"reminderMeal": "Meal time",
"reminderCustom": "Custom",
"reminderTitle": "Title",
"reminderTitlePlaceholder": "Drink a glass of water",
"reminderTime": "Time",
"saveReminder": "Save reminder",
"yourReminders": "Your reminders",
"noReminders": "No reminders yet.",
"remaining": "Left",
"portions": "Portions",
"fromCatalog": "From ingredient catalog",
"noCatalogItem": "None",
"grams": "Amount (g)",
"mealPreview": "This meal adds",
"remainingAfter": "After logging you will still have",
"suggestionsTitle": "What to eat next",
"suggestionsHint": "Based on what you still need today.",
"logSuggestion": "Log",
"suggestionReasons": {
"highProtein": "High protein",
"needCarbs": "Good carbs",
"needFat": "Healthy fats",
"needCalories": "Energy boost",
"balanced": "Balanced choice"
}
},
"mealPlanner": {
"title": "Meal Planner",
"subtitle": "Plan recipes for each day of the week.",
"today": "This week",
"addRecipe": "Add recipe",
"dailyTarget": "Daily calorie target",
"daySummary": "{{logged}} / {{target}} kcal planned",
"remaining": "{{count}} kcal left",
"slotHint": "Suggested ~{{count}} kcal for this meal",
"suggestionsTitle": "Suggestions for this slot",
"noSuggestions": "Set a daily calorie target to see suggestions.",
"estimatedMeal": "This meal: ~{{count}} kcal",
"selectedRecipe": "Selected: {{name}}",
"clearSelection": "Clear selection",
"exportDay": "Export day plan (PDF)",
"includingSelection": "incl. current pick: {{count}} kcal"
},
"shoppingList": {
"title": "Shopping List",
"subtitle": "Aggregated ingredients from your meal plan.",
"from": "From",
"to": "To",
"load": "Load list",
"clearChecks": "Clear checked items",
"empty": "No items for this date range.",
"checklistHint": "Checked items are saved locally in your browser."
},
"ingredientCatalog": {
"title": "Ingredient catalog",
"subtitle": "Nutrition values per 100 g, grouped by food category.",
"allCategories": "All",
"searchPlaceholder": "Search ingredients…",
"noResultsTitle": "No ingredients found",
"noResultsDescription": "Try another category or search term.",
"per100gNote": "All values are per 100 g.",
"addIngredient": "Add ingredient",
"editIngredient": "Edit ingredient",
"selectCategory": "Select category",
"nameEn": "Name (English)",
"namePlLabel": "Name (Polish)",
"formInvalid": "Please fill in category and name.",
"deleteConfirm": "Remove this ingredient from the catalog?",
"columns": {
"name": "Ingredient",
"category": "Category",
"calories": "kcal",
"protein": "Protein (g)",
"fat": "Fat (g)",
"carbs": "Carbs (g)",
"fiber": "Fiber (g)"
},
"categories": {
"meat": "Meat",
"poultry": "Poultry",
"fish": "Fish",
"seafood": "Seafood",
"vegetables": "Vegetables",
"fruits": "Fruits",
"dairy": "Dairy",
"eggs": "Eggs",
"grains": "Grains",
"legumes": "Legumes",
"nuts": "Nuts & seeds",
"oils": "Oils & fats"
}
},
"notFound": {
"title": "Page not found",
"description": "The page you're looking for doesn't exist.",
"backToDashboard": "Back to dashboard"
}
}

View File

@@ -0,0 +1,440 @@
{
"app": {
"name": "DailyMeals"
},
"language": {
"label": "Język",
"en": "Angielski",
"pl": "Polski"
},
"layout": {
"label": "Układ",
"sidebar": "Panel boczny",
"topnav": "Nawigacja górna",
"compact": "Wąski panel",
"wide": "Szeroki panel",
"wideTagline": "Planuj posiłki wygodnie"
},
"appearance": {
"label": "Wygląd",
"forest": "Leśny klasyczny",
"forestDesc": "Zielona paleta, ikony konturowe",
"ocean": "Oceaniczna świeżość",
"oceanDesc": "Niebieska paleta, ikony konturowe",
"sunset": "Zachód kuchni",
"sunsetDesc": "Ciepła bursztynowa paleta, emoji",
"berry": "Jagodowy modern",
"berryDesc": "Fioletowa paleta, pogrubione ikony"
},
"nav": {
"dashboard": "Panel",
"breakfast": "Śniadanie",
"secondBreakfast": "Drugie śniadanie",
"lunch": "Obiad",
"dinner": "Kolacja",
"allMeals": "Wszystkie posiłki",
"manageMeals": "Zarządzaj posiłkami",
"mealPlanner": "Plan posiłków",
"shoppingList": "Lista zakupów",
"dietTracker": "Moja dieta",
"ingredientCatalog": "Składniki",
"dietGenerator": "Generator diety",
"recipeGenerator": "Generator przepisów",
"logout": "Wyloguj",
"user": "Użytkownik",
"toggleNav": "Przełącz nawigację",
"toggleTheme": "Przełącz motyw",
"lightMode": "Przełącz na jasny motyw",
"darkMode": "Przełącz na ciemny motyw"
},
"categories": {
"breakfast": "Śniadanie",
"secondBreakfast": "Drugie śniadanie",
"lunch": "Obiad",
"dinner": "Kolacja",
"unknown": "Nieznana"
},
"common": {
"loading": "Ładowanie...",
"tryAgain": "Spróbuj ponownie",
"back": "Wstecz",
"add": "Dodaj",
"category": "Kategoria",
"all": "Wszystkie",
"search": "Szukaj",
"save": "Zapisz",
"cancel": "Anuluj",
"edit": "Edytuj",
"delete": "Usuń",
"optional": "opcjonalnie",
"recipeCount_one": "{{count}} przepis",
"recipeCount_few": "{{count}} przepisy",
"recipeCount_many": "{{count}} przepisów",
"recipeCount_other": "{{count}} przepisów",
"selectedCount_one": "Wybrano: {{count}}",
"selectedCount_few": "Wybrano: {{count}}",
"selectedCount_many": "Wybrano: {{count}}",
"selectedCount_other": "Wybrano: {{count}}"
},
"auth": {
"signInSubtitle": "Zaloguj się, aby planować posiłki",
"email": "E-mail",
"password": "Hasło",
"emailPlaceholder": "ty@example.com",
"signIn": "Zaloguj się",
"signingIn": "Logowanie...",
"noAccount": "Nie masz konta?",
"createOne": "Utwórz je",
"createAccountTitle": "Utwórz konto",
"createAccountSubtitle": "Dołącz do DailyMeals, aby przeglądać i planować posiłki",
"displayName": "Nazwa wyświetlana",
"displayNamePlaceholder": "Piotr",
"confirmPassword": "Potwierdź hasło",
"passwordHint": "Co najmniej 8 znaków, w tym wielka litera, mała litera i cyfra.",
"createAccount": "Utwórz konto",
"creatingAccount": "Tworzenie konta...",
"hasAccount": "Masz już konto?"
},
"validation": {
"emailRequired": "E-mail jest wymagany.",
"emailInvalid": "Podaj prawidłowy adres e-mail.",
"passwordRequired": "Hasło jest wymagane.",
"displayNameTooLong": "Nazwa wyświetlana jest zbyt długa.",
"passwordMinLength": "Hasło musi mieć co najmniej 8 znaków.",
"passwordUppercase": "Dołącz co najmniej jedną wielką literę.",
"passwordLowercase": "Dołącz co najmniej jedną małą literę.",
"passwordDigit": "Dołącz co najmniej jedną cyfrę.",
"confirmPasswordRequired": "Potwierdź hasło.",
"passwordsMismatch": "Hasła nie są identyczne.",
"recipeNameRequired": "Nazwa przepisu jest wymagana.",
"ingredientNameRequired": "Nazwa składnika jest wymagana.",
"stepNumberMin": "Numer kroku musi być co najmniej 1.",
"stepDescriptionRequired": "Opis kroku jest wymagany.",
"stepsMin": "Dodaj co najmniej jeden krok przygotowania."
},
"errors": {
"generic": "Coś poszło nie tak.",
"signInFailed": "Nie udało się zalogować.",
"registerFailed": "Nie udało się utworzyć konta.",
"loadCategoriesFailed": "Nie udało się załadować kategorii.",
"loadRecipesFailed": "Nie udało się załadować przepisów.",
"loadRecipeFailed": "Nie udało się załadować przepisu.",
"loadDietFailed": "Nie udało się załadować danych diety.",
"loadMealPlanFailed": "Nie udało się załadować planu posiłków.",
"loadShoppingListFailed": "Nie udało się załadować listy zakupów.",
"loadIngredientCatalogFailed": "Nie udało się załadować katalogu składników.",
"saveIngredientFailed": "Nie udało się zapisać składnika.",
"saveRecipeFailed": "Nie udało się zapisać przepisu.",
"recalculateMacrosFailed": "Nie udało się przeliczyć makroskładników przepisów.",
"importFailed": "Import nie powiódł się.",
"pdfLoadFailed": "Nie udało się załadować przepisów do eksportu.",
"pdfGenerateFailed": "Generowanie PDF nie powiodło się.",
"pdfRenderAreaMissing": "Nie znaleziono obszaru renderowania PDF.",
"pdfNothingToExport": "Brak danych do eksportu."
},
"dashboard": {
"welcome": "Witaj ponownie",
"welcomeNamed": "Witaj ponownie, {{name}}",
"subtitle": "Przeglądaj przepisy według kategorii posiłków lub odkrywaj wszystkie naraz.",
"viewRecipes": "Zobacz przepisy"
},
"recipes": {
"allMealsTitle": "Wszystkie posiłki",
"allMealsSubtitle": "Wybierz przepisy i wyeksportuj je wraz ze wspólną listą zakupów.",
"selectAllVisible": "Zaznacz widoczne",
"deselectAll": "Odznacz wszystkie",
"searchRecipes": "Szukaj przepisów...",
"searchByIngredient": "Szukaj po składniku...",
"searchByIngredientLabel": "Szukaj po składniku",
"searchByNameAria": "Szukaj przepisów po nazwie",
"searchByIngredientAria": "Szukaj przepisów po składniku",
"noMatchingTitle": "Brak pasujących przepisów",
"noMatchingIngredient": "Żaden przepis nie zawiera tego składnika. Spróbuj innej nazwy.",
"noMatchingFilter": "Spróbuj zmienić wyszukiwanie lub filtr kategorii.",
"emptyCategoryTitle": "Brak przepisów: {{category}}",
"emptyCategoryDescription": "W tej kategorii nie ma jeszcze żadnych przepisów.",
"selectRecipe": "Wybierz {{name}}",
"prepTime": "{{count}} min przygotowania",
"calories": "Kalorie",
"protein": "Białko",
"fat": "Tłuszcz",
"carbs": "Węglowodany",
"ingredients": "Składniki",
"preparation": "Przygotowanie",
"noIngredients": "Brak składników.",
"noSteps": "Brak kroków.",
"kcal": "{{count}} kcal",
"minutes": "{{count}} min"
},
"manage": {
"title": "Zarządzaj posiłkami",
"subtitle": "Dodawaj przepisy ręcznie lub importuj je z arkusza Excel.",
"tabManual": "Dodaj ręcznie",
"tabEdit": "Edytuj przepisy",
"tabImport": "Import Excel",
"savedSuccess": "„{{name}}” zapisano pomyślnie.",
"basicInfo": "Podstawowe informacje",
"recipeName": "Nazwa przepisu",
"recipeNamePlaceholder": "Sałatka z grillowanym kurczakiem",
"prepTimeMin": "Czas przygotowania (min)",
"caloriesKcal": "Kalorie (kcal)",
"proteinG": "Białko (g)",
"fatG": "Tłuszcz (g)",
"carbsG": "Węglowodany (g)",
"preparationSteps": "Kroki przygotowania",
"addStep": "Dodaj krok",
"stepPlaceholder": "Opisz ten krok...",
"ingredientName": "Nazwa",
"ingredientGrams": "Gramy",
"ingredientUnit": "Jednostka",
"removeIngredient": "Usuń składnik",
"removeStep": "Usuń krok",
"saveRecipe": "Zapisz przepis",
"updateRecipe": "Aktualizuj przepis",
"updatedSuccess": "„{{name}}” zaktualizowano pomyślnie.",
"selectRecipeToEdit": "Wybierz przepis z listy, aby go edytować.",
"catalogLink": "Powiąż z katalogiem składników",
"estimatedFromCatalog": "Szacunkowo z powiązanych składników",
"unlinkedIngredientsWarning": "{{count}} składnik(ów) ma gramy, ale brak powiązania z katalogiem — makra mogą być niepełne.",
"recalculateMacros": "Przelicz wszystkie makra",
"recalculating": "Przeliczanie…",
"recalculateSuccess": "Zaktualizowano {{updated}} z {{processed}} przepisów. {{unlinked}} wierszy składników nadal niepowiązanych.",
"macroHint": "Szacunkowo: {{calories}} kcal · B {{protein}}g · T {{fat}}g · W {{carbs}}g",
"saving": "Zapisywanie...",
"importTitle": "Import z Excela",
"importDescription": "Użyj szablonu z trzema arkuszami: Recipes, Ingredients i Steps. Nazwy przepisów muszą się zgadzać między arkuszami.",
"downloadTemplate": "Pobierz szablon",
"uploadLabel": "Prześlij wypełniony plik .xlsx",
"importComplete": "Import zakończony",
"createdCount": "Utworzono: {{count}}",
"skippedCount": "Pominięto: {{count}}",
"importRecipes": "Importuj przepisy",
"importing": "Importowanie...",
"excelFormatTitle": "Format Excela",
"excelRecipesSheet": "Recipes: Name, MealCategory (Breakfast / Lunch / 03), Calories, Protein, Fat, Carbs, PrepTimeMinutes",
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
"duplicateSkipped": "Duplikaty nazw przepisów są pomijane."
},
"generators": {
"diet": {
"title": "Generator diety",
"subtitle": "Oblicz cele z profilu, zbuduj tygodniowy plan z katalogu przepisów i zapisz w planerze.",
"profileTitle": "Twój profil",
"heightCm": "Wzrost (cm)",
"weightKg": "Waga (kg)",
"age": "Wiek",
"sex": "Płeć",
"male": "Mężczyzna",
"female": "Kobieta",
"activity": "Poziom aktywności",
"sedentary": "Siedzący",
"light": "Lekka",
"moderate": "Umiarkowana",
"active": "Aktywna",
"veryActive": "Bardzo aktywna",
"goal": "Cel",
"loseWeight": "Redukcja wagi",
"maintain": "Utrzymanie",
"gainMuscle": "Budowa mięśni",
"recomp": "Rekompozycja",
"allergies": "Alergie / uwagi",
"continue": "Oblicz cele",
"macrosTitle": "Dzienne cele",
"acceptAndGenerate": "Akceptuj i generuj plan 7 dni",
"planHint": "Każdy dzień powinien mieścić się w ±10 kcal od celu. Zablokuj lub wygeneruj ponownie posiłki.",
"lock": "Zablokuj",
"unlock": "Odblokuj",
"viewRecipe": "Szczegóły",
"noAlternativeMeals": "Brak innych przepisów na ten posiłek — wszystkie pasujące opcje są już w planie.",
"commit": "Zapisz plan w kalendarzu",
"done": "Plan zapisany w planerze i celach dziennych.",
"newPlan": "Utwórz kolejny plan",
"error": "Generator diety nie powiódł się."
},
"recipe": {
"title": "Generator przepisów",
"subtitle": "Wygeneruj kilka propozycji przepisów z AI, przejrzyj je i zapisz te, które Ci pasują.",
"constraintsTitle": "Co ugotować?",
"prompt": "Opisz posiłek",
"promptPlaceholder": "Lekkie śniadanie z owsianką i owocami…",
"targetCalories": "Docelowa kaloryczność (kcal)",
"targetCaloriesOptional": "np. 500",
"targetCaloriesHint": "Składniki zostaną dopasowane do tej wartości (±10 kcal).",
"caloriesWithTarget": "{{actual}} kcal (cel: {{target}})",
"maxPrep": "Maks. czas przygotowania (min)",
"exclusions": "Wyklucz składniki",
"generate": "Generuj przepis",
"recipeCount": "Ile przepisów wygenerować",
"reviewHint": "Przejrzyj {{count}} wygenerowanych przepisów. Odznacz lub usuń te, których nie chcesz, i zapisz resztę.",
"selectAll": "Zaznacz wszystkie",
"remove": "Usuń",
"saveSelected": "Zapisz zaznaczone ({{count}})",
"doneMultiple": "Zapisano {{count}} przepisów w bibliotece.",
"generateMore": "Generuj kolejne przepisy",
"regenIngredients": "Generuj składniki ponownie",
"regenSteps": "Generuj kroki ponownie",
"regenFull": "Generuj całość ponownie",
"save": "Zapisz w bibliotece",
"done": "Przepis zapisany.",
"viewRecipe": "Zobacz przepis",
"error": "Generator przepisów nie powiódł się."
}
},
"pdf": {
"generate": "Generuj PDF",
"preparing": "Przygotowywanie...",
"generating": "Generowanie...",
"prepTime": "Czas przygotowania: {{count}} min",
"shoppingList": "Lista zakupów",
"generatedOn_one": "Wygenerowano {{date}} · {{count}} przepis",
"generatedOn_few": "Wygenerowano {{date}} · {{count}} przepisy",
"generatedOn_many": "Wygenerowano {{date}} · {{count}} przepisów",
"generatedOn_other": "Wygenerowano {{date}} · {{count}} przepisów",
"recipesInExport": "Przepisy w tym eksporcie",
"toTaste": "do smaku",
"dayPlanTitle": "Plan posiłków — {{date}}",
"emptySlot": "Nie zaplanowano",
"emptyShoppingList": "Brak składników do zakupu dla tego dnia.",
"plannedMealMeta": "{{slot}} · {{portions}}× porcji",
"dayShoppingSubtitle": "Lista na {{date}} · {{count}} posiłków"
},
"diet": {
"title": "Moja dieta",
"subtitle": "Śledź posiłki i napoje, ustaw cele dzienne i przypomnienia.",
"tabToday": "Dziś",
"tabHistory": "Historia",
"tabGoals": "Cele",
"tabReminders": "Przypomnienia",
"goToday": "Dziś",
"notToday": "inny dzień",
"quickLog": "Szybkie logowanie",
"copyYesterday": "Skopiuj wczorajsze posiłki",
"recentMeals": "Ostatnie",
"favorites": "Ulubione",
"days": "dni",
"historyCalories": "Kalorie w czasie",
"historyMax": "Szczyt: {{max}} kcal",
"noHistory": "Brak historii w tym okresie.",
"mealsOnDate": "Posiłki {{date}}",
"drinksOnDate": "Napoje {{date}}",
"editReminder": "Edytuj przypomnienie",
"calories": "Kalorie",
"water": "Woda",
"snack": "Przekąska",
"logDrink": "Dodaj napój",
"logMeal": "Oznacz zjedzony posiłek",
"searchRecipe": "Z przepisu",
"orCustomMeal": "Lub wpisz własną nazwę posiłku",
"markEaten": "Oznacz jako zjedzone",
"mealsToday": "Posiłki dziś",
"drinksToday": "Napoje dziś",
"nothingLogged": "Nic jeszcze nie zalogowano.",
"noMacros": "Brak danych odżywczych",
"removeEntry": "Usuń wpis",
"dailyGoals": "Cele dzienne",
"goalsHint": "Ustaw cele jak w Lifesum lub MyFitnessPal — zakładka Dziś pokaże, co zostało.",
"calorieGoal": "Cel kalorii (kcal)",
"waterGoal": "Cel wody (ml)",
"saveGoals": "Zapisz cele",
"addReminder": "Dodaj przypomnienie",
"reminderType": "Typ",
"reminderWater": "Pij wodę",
"reminderMeal": "Pora posiłku",
"reminderCustom": "Własne",
"reminderTitle": "Tytuł",
"reminderTitlePlaceholder": "Wypij szklankę wody",
"reminderTime": "Godzina",
"saveReminder": "Zapisz przypomnienie",
"yourReminders": "Twoje przypomnienia",
"noReminders": "Brak przypomnień.",
"remaining": "Pozostało",
"portions": "Porcje",
"fromCatalog": "Z katalogu składników",
"noCatalogItem": "Brak",
"grams": "Ilość (g)",
"mealPreview": "Ten posiłek dodaje",
"remainingAfter": "Po zapisaniu zostanie Ci",
"suggestionsTitle": "Co warto zjeść dalej",
"suggestionsHint": "Na podstawie tego, czego jeszcze brakuje dziś.",
"logSuggestion": "Zapisz",
"suggestionReasons": {
"highProtein": "Dużo białka",
"needCarbs": "Dobre węglowodany",
"needFat": "Zdrowe tłuszcze",
"needCalories": "Energia",
"balanced": "Zbilansowany wybór"
}
},
"mealPlanner": {
"title": "Plan posiłków",
"subtitle": "Planuj przepisy na każdy dzień tygodnia.",
"today": "Ten tydzień",
"addRecipe": "Dodaj przepis",
"dailyTarget": "Cel kalorii na dzień",
"daySummary": "Zaplanowano {{logged}} / {{target}} kcal",
"remaining": "Pozostało {{count}} kcal",
"slotHint": "Sugerowane ~{{count}} kcal na ten posiłek",
"suggestionsTitle": "Propozycje na ten posiłek",
"noSuggestions": "Ustaw cel kalorii na dzień, aby zobaczyć propozycje.",
"estimatedMeal": "Ten posiłek: ~{{count}} kcal",
"selectedRecipe": "Wybrany: {{name}}",
"clearSelection": "Wyczyść wybór",
"exportDay": "Eksportuj plan dnia (PDF)",
"includingSelection": "w tym bieżący wybór: {{count}} kcal"
},
"shoppingList": {
"title": "Lista zakupów",
"subtitle": "Składniki zebrane z planu posiłków.",
"from": "Od",
"to": "Do",
"load": "Załaduj listę",
"clearChecks": "Wyczyść zaznaczenia",
"empty": "Brak pozycji w tym zakresie dat.",
"checklistHint": "Zaznaczenia są zapisywane lokalnie w przeglądarce."
},
"ingredientCatalog": {
"title": "Katalog składników",
"subtitle": "Wartości odżywcze na 100 g, pogrupowane według kategorii.",
"allCategories": "Wszystkie",
"searchPlaceholder": "Szukaj składników…",
"noResultsTitle": "Nie znaleziono składników",
"noResultsDescription": "Spróbuj innej kategorii lub frazy.",
"per100gNote": "Wszystkie wartości dotyczą 100 g.",
"addIngredient": "Dodaj składnik",
"editIngredient": "Edytuj składnik",
"selectCategory": "Wybierz kategorię",
"nameEn": "Nazwa (angielski)",
"namePlLabel": "Nazwa (polski)",
"formInvalid": "Wypełnij kategorię i nazwę.",
"deleteConfirm": "Usunąć ten składnik z katalogu?",
"columns": {
"name": "Składnik",
"category": "Kategoria",
"calories": "kcal",
"protein": "Białko (g)",
"fat": "Tłuszcz (g)",
"carbs": "Węglowodany (g)",
"fiber": "Błonnik (g)"
},
"categories": {
"meat": "Mięso",
"poultry": "Drób",
"fish": "Ryby",
"seafood": "Owoce morza",
"vegetables": "Warzywa",
"fruits": "Owoce",
"dairy": "Nabiał",
"eggs": "Jaja",
"grains": "Zboża",
"legumes": "Rośliny strączkowe",
"nuts": "Orzechy i nasiona",
"oils": "Oleje i tłuszcze"
}
},
"notFound": {
"title": "Nie znaleziono strony",
"description": "Strona, której szukasz, nie istnieje.",
"backToDashboard": "Wróć do panelu"
}
}

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet />`,
})
export class AppComponent {}

View File

@@ -0,0 +1,43 @@
import { APP_INITIALIZER, ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { provideTranslateService } from '@ngx-translate/core';
import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
import { routes } from './app.routes';
import { AuthInterceptor } from './core/interceptors/auth.interceptor';
import { AppearanceService } from './core/services/appearance.service';
import { AuthService } from './core/services/auth.service';
import { LanguageService } from './core/services/language.service';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptorsFromDi()),
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
...provideTranslateService({
fallbackLang: 'en',
loader: provideTranslateHttpLoader({ prefix: './i18n/', suffix: '.json' }),
}),
{
provide: APP_INITIALIZER,
useFactory: (language: LanguageService) => () => language.init(),
deps: [LanguageService],
multi: true,
},
{
provide: APP_INITIALIZER,
useFactory: (auth: AuthService) => () => auth.restoreSession(),
deps: [AuthService],
multi: true,
},
{
provide: APP_INITIALIZER,
useFactory: (appearance: AppearanceService) => () => appearance.init(),
deps: [AppearanceService],
multi: true,
},
],
};

View File

@@ -0,0 +1,48 @@
import { Routes } from '@angular/router';
import { authGuard, guestGuard } from './core/guards/auth.guard';
import { AppLayoutComponent } from './layout/app-layout.component';
import { LoginComponent } from './features/auth/login.component';
import { RegisterComponent } from './features/auth/register.component';
import { DashboardComponent } from './features/dashboard/dashboard.component';
import { BreakfastComponent } from './features/category/breakfast.component';
import { SecondBreakfastComponent } from './features/category/second-breakfast.component';
import { LunchComponent } from './features/category/lunch.component';
import { DinnerComponent } from './features/category/dinner.component';
import { AllMealsComponent } from './features/all-meals/all-meals.component';
import { ManageMealsComponent } from './features/manage-meals/manage-meals.component';
import { IngredientCatalogComponent } from './features/ingredient-catalog/ingredient-catalog.component';
import { DietTrackerComponent } from './features/diet-tracker/diet-tracker.component';
import { MealPlannerComponent } from './features/meal-planner/meal-planner.component';
import { ShoppingListComponent } from './features/shopping-list/shopping-list.component';
import { RecipeDetailPageComponent } from './features/recipe-detail/recipe-detail-page.component';
import { NotFoundComponent } from './features/not-found/not-found.component';
import { DietGeneratorComponent } from './features/diet-generator/diet-generator.component';
import { RecipeGeneratorComponent } from './features/recipe-generator/recipe-generator.component';
export const routes: Routes = [
{ path: 'login', component: LoginComponent, canActivate: [guestGuard] },
{ path: 'register', component: RegisterComponent, canActivate: [guestGuard] },
{
path: '',
component: AppLayoutComponent,
canActivate: [authGuard],
children: [
{ path: '', component: DashboardComponent },
{ path: 'breakfast', component: BreakfastComponent },
{ path: 'second-breakfast', component: SecondBreakfastComponent },
{ path: 'lunch', component: LunchComponent },
{ path: 'dinner', component: DinnerComponent },
{ path: 'all-meals', component: AllMealsComponent },
{ path: 'manage-meals', component: ManageMealsComponent },
{ path: 'ingredients', component: IngredientCatalogComponent },
{ path: 'diet', component: DietTrackerComponent },
{ path: 'meal-planner', component: MealPlannerComponent },
{ path: 'diet-generator', component: DietGeneratorComponent },
{ path: 'recipe-generator', component: RecipeGeneratorComponent },
{ path: 'shopping-list', component: ShoppingListComponent },
{ path: 'recipes/:id', component: RecipeDetailPageComponent },
{ path: '404', component: NotFoundComponent },
{ path: '**', redirectTo: '404' },
],
},
];

Some files were not shown because too many files have changed in this diff Show More