* Added recalculation

This commit is contained in:
2026-06-24 22:56:40 +02:00
parent 7b8dd3ff40
commit beb08f2e6f
14 changed files with 509 additions and 90 deletions

View File

@@ -13,13 +13,16 @@ public class RecipeManagementController : ControllerBase
{ {
private readonly IRecipeManagementService _managementService; private readonly IRecipeManagementService _managementService;
private readonly IRecipeExcelService _excelService; private readonly IRecipeExcelService _excelService;
private readonly IRecipeCalorieRecalculationService _calorieRecalculation;
public RecipeManagementController( public RecipeManagementController(
IRecipeManagementService managementService, IRecipeManagementService managementService,
IRecipeExcelService excelService) IRecipeExcelService excelService,
IRecipeCalorieRecalculationService calorieRecalculation)
{ {
_managementService = managementService; _managementService = managementService;
_excelService = excelService; _excelService = excelService;
_calorieRecalculation = calorieRecalculation;
} }
/// <summary>Creates a new recipe with ingredients and steps.</summary> /// <summary>Creates a new recipe with ingredients and steps.</summary>
@@ -90,4 +93,26 @@ public class RecipeManagementController : ControllerBase
var result = await _managementService.RecalculateAllMacrosFromCatalogAsync(ct); var result = await _managementService.RecalculateAllMacrosFromCatalogAsync(ct);
return Ok(result); return Ok(result);
} }
/// <summary>Scales or AI-adjusts ingredient amounts to match a calorie target.</summary>
[HttpPost("{id:int}/recalculate-calories")]
[ProducesResponseType(typeof(RecalculateRecipeCaloriesResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RecalculateCalories(
int id,
[FromBody] RecalculateRecipeCaloriesRequestDto request,
CancellationToken ct)
{
try
{
var result = await _calorieRecalculation.RecalculateAsync(id, request, ct);
return result is null
? NotFound(new { error = $"Recipe {id} was not found." })
: Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
} }

View File

@@ -69,6 +69,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IRecipeService, RecipeService>(); builder.Services.AddScoped<IRecipeService, RecipeService>();
builder.Services.AddScoped<IRecipeManagementService, RecipeManagementService>(); builder.Services.AddScoped<IRecipeManagementService, RecipeManagementService>();
builder.Services.AddScoped<IRecipeCalorieRecalculationService, RecipeCalorieRecalculationService>();
builder.Services.AddScoped<IRecipeExcelService, RecipeExcelService>(); builder.Services.AddScoped<IRecipeExcelService, RecipeExcelService>();
builder.Services.AddScoped<IDietTrackingService, DietTrackingService>(); builder.Services.AddScoped<IDietTrackingService, DietTrackingService>();
builder.Services.AddScoped<IIngredientCatalogService, IngredientCatalogService>(); builder.Services.AddScoped<IIngredientCatalogService, IngredientCatalogService>();

View File

@@ -160,7 +160,16 @@
"noIngredients": "No ingredients listed.", "noIngredients": "No ingredients listed.",
"noSteps": "No steps listed.", "noSteps": "No steps listed.",
"kcal": "{{count}} kcal", "kcal": "{{count}} kcal",
"minutes": "{{count}} min" "minutes": "{{count}} min",
"recalculateCaloriesTitle": "Adjust calories",
"recalculateCaloriesHint": "Enter a target kcal — ingredient amounts will be scaled (catalog + AI).",
"targetCalories": "Target kcal",
"recalculate": "Recalculate",
"applyRecalculation": "Save changes",
"recalculatePreview": "Preview: {{previous}} → {{next}} kcal (target: {{target}})",
"recalculateUnlinkedHint": "{{count}} ingredient(s) not linked to the catalog — values may be less accurate.",
"recalculateCaloriesInvalid": "Enter calories between 50 and 10000 kcal.",
"recalculateCaloriesFailed": "Could not recalculate the recipe."
}, },
"manage": { "manage": {
"title": "Manage Meals", "title": "Manage Meals",

View File

@@ -164,7 +164,16 @@
"noIngredients": "Brak składników.", "noIngredients": "Brak składników.",
"noSteps": "Brak kroków.", "noSteps": "Brak kroków.",
"kcal": "{{count}} kcal", "kcal": "{{count}} kcal",
"minutes": "{{count}} min" "minutes": "{{count}} min",
"recalculateCaloriesTitle": "Dopasuj kaloryczność",
"recalculateCaloriesHint": "Podaj docelową liczbę kcal — przeliczymy gramaturę składników (katalog + AI).",
"targetCalories": "Docelowe kcal",
"recalculate": "Przelicz",
"applyRecalculation": "Zapisz zmiany",
"recalculatePreview": "Podgląd: {{previous}} → {{next}} kcal (cel: {{target}})",
"recalculateUnlinkedHint": "{{count}} składnik(ów) bez powiązania z katalogiem — wartości mogą być mniej dokładne.",
"recalculateCaloriesInvalid": "Podaj kaloryczność między 50 a 10000 kcal.",
"recalculateCaloriesFailed": "Nie udało się przeliczyć przepisu."
}, },
"manage": { "manage": {
"title": "Zarządzaj posiłkami", "title": "Zarządzaj posiłkami",

View File

@@ -98,3 +98,18 @@ export interface RecipeMacroRecalcResult {
recipesUpdated: number; recipesUpdated: number;
unlinkedIngredientRows: number; unlinkedIngredientRows: number;
} }
export interface RecalculateRecipeCaloriesResult {
recipeId: number;
previousCalories?: number | null;
targetCalories: number;
newCalories?: number | null;
newProtein?: number | null;
newFat?: number | null;
newCarbs?: number | null;
applied: boolean;
method: string;
unlinkedIngredientCount: number;
ingredients: Ingredient[];
recipe?: RecipeDetail | null;
}

View File

@@ -6,6 +6,7 @@ import {
RecipeDetail, RecipeDetail,
RecipeImportResult, RecipeImportResult,
RecipeMacroRecalcResult, RecipeMacroRecalcResult,
RecalculateRecipeCaloriesResult,
} from '../models/recipe.models'; } from '../models/recipe.models';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -34,6 +35,17 @@ export class RecipeManagementService {
return this.http.post<RecipeMacroRecalcResult>('/api/recipes/recalculate-macros', {}); return this.http.post<RecipeMacroRecalcResult>('/api/recipes/recalculate-macros', {});
} }
recalculateRecipeCalories(
recipeId: number,
targetCalories: number,
apply = false,
): Observable<RecalculateRecipeCaloriesResult> {
return this.http.post<RecalculateRecipeCaloriesResult>(
`/api/recipes/${recipeId}/recalculate-calories`,
{ targetCalories, apply },
);
}
private toPayload(input: CreateRecipeInput) { private toPayload(input: CreateRecipeInput) {
return { return {
name: input.name, name: input.name,

View File

@@ -37,7 +37,7 @@ import { extractApiError } from '../../core/utils/api-error.util';
} }
@if (!loading() && !error() && recipe()) { @if (!loading() && !error() && recipe()) {
<app-recipe-detail-view [recipe]="recipe()!" /> <app-recipe-detail-view [recipe]="recipe()!" (recipeUpdated)="recipe.set($event)" />
} }
</div> </div>
`, `,

View File

@@ -1,7 +1,11 @@
import { Component, Input, computed, signal } from '@angular/core'; import { Component, EventEmitter, Input, OnChanges, Output, computed, inject, signal } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core'; import { NgClass } from '@angular/common';
import { RecipeDetail } from '../../core/models/recipe.models'; import { FormsModule } from '@angular/forms';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { RecipeManagementService } from '../../core/services/recipe-management.service';
import { RecipeDetail, RecalculateRecipeCaloriesResult } from '../../core/models/recipe.models';
import { CategoryBadgeComponent } from '../../shared/components/category-badge.component'; import { CategoryBadgeComponent } from '../../shared/components/category-badge.component';
import { extractApiError } from '../../core/utils/api-error.util';
import { nextSort, sortBy, sortIndicator, type SortDirection } from '../../core/utils/sort.util'; import { nextSort, sortBy, sortIndicator, type SortDirection } from '../../core/utils/sort.util';
type IngredientSortColumn = 'name' | 'amount'; type IngredientSortColumn = 'name' | 'amount';
@@ -9,71 +13,84 @@ type IngredientSortColumn = 'name' | 'amount';
@Component({ @Component({
selector: 'app-recipe-detail-view', selector: 'app-recipe-detail-view',
standalone: true, standalone: true,
imports: [TranslatePipe, CategoryBadgeComponent], imports: [NgClass, FormsModule, TranslatePipe, CategoryBadgeComponent],
template: ` template: `
<article class="space-y-8"> <article class="space-y-8">
<header class="space-y-3"> <header class="space-y-3">
<app-category-badge [category]="recipe.mealCategory" /> <app-category-badge [category]="displayRecipe().mealCategory" />
<h1 class="text-3xl font-extrabold tracking-tight">{{ recipe.name }}</h1> <h1 class="text-3xl font-extrabold tracking-tight">{{ displayRecipe().name }}</h1>
@if (recipe.prepTimeMinutes != null) { @if (displayRecipe().prepTimeMinutes != null) {
<p class="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400"> <p class="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
<span aria-hidden="true">⏱</span> <span aria-hidden="true">⏱</span>
{{ 'recipes.prepTime' | translate: { count: recipe.prepTimeMinutes } }} {{ 'recipes.prepTime' | translate: { count: displayRecipe().prepTimeMinutes } }}
</p> </p>
} }
</header> </header>
<section class="grid grid-cols-2 gap-3 sm:grid-cols-4"> <section class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900"> @for (macro of macroCards(); track macro.label) {
<p class="text-2xl font-extrabold text-orange-500"> <div
{{ recipe.calories ?? '—' }} class="rounded-2xl border px-4 py-3 text-center"
@if (recipe.calories != null) { [ngClass]="
<span class="text-sm font-semibold"> kcal</span> macro.highlight
? 'border-brand-300 bg-brand-50 dark:border-brand-700 dark:bg-brand-950/30'
: 'border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900'
"
>
<p class="text-2xl font-extrabold" [class]="macro.accent">
{{ macro.value ?? '—' }}
@if (macro.value != null) {
<span class="text-sm font-semibold"> {{ macro.suffix }}</span>
}
</p>
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{{ macro.label | translate }}
</p>
</div>
}
</section>
<section class="card space-y-3 p-4">
<h2 class="text-sm font-bold">{{ 'recipes.recalculateCaloriesTitle' | translate }}</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ 'recipes.recalculateCaloriesHint' | translate }}</p>
<div class="flex flex-wrap items-end gap-3">
<label class="block text-sm">
{{ 'recipes.targetCalories' | translate }}
<input type="number" min="50" max="10000" class="input mt-1 w-36" [(ngModel)]="targetCalories" (ngModelChange)="preview.set(null)" />
</label>
<button type="button" class="btn-secondary" [disabled]="pending() || applyPending()" (click)="runRecalculate(false)">
↻ {{ 'recipes.recalculate' | translate }}
</button>
@if (showingPreview()) {
<button type="button" class="btn-primary" [disabled]="pending() || applyPending()" (click)="runRecalculate(true)">
{{ 'recipes.applyRecalculation' | translate }}
</button>
}
</div>
@if (showingPreview() && preview()) {
<p class="text-sm text-brand-700 dark:text-brand-300">
{{
'recipes.recalculatePreview'
| translate: {
previous: preview()!.previousCalories ?? '—',
next: preview()!.newCalories ?? '—',
target: preview()!.targetCalories,
}
}}
@if (preview()!.unlinkedIngredientCount > 0) {
{{ 'recipes.recalculateUnlinkedHint' | translate: { count: preview()!.unlinkedIngredientCount } }}
} }
</p> </p>
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400"> }
{{ 'recipes.calories' | translate }} @if (error()) {
</p> <p role="alert" class="text-sm text-red-600 dark:text-red-400">{{ error() }}</p>
</div> }
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
<p class="text-2xl font-extrabold text-brand-600">
{{ recipe.protein ?? '—' }}
@if (recipe.protein != null) {
<span class="text-sm font-semibold"> g</span>
}
</p>
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{{ 'recipes.protein' | translate }}
</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
<p class="text-2xl font-extrabold text-amber-500">
{{ recipe.fat ?? '—' }}
@if (recipe.fat != null) {
<span class="text-sm font-semibold"> g</span>
}
</p>
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{{ 'recipes.fat' | translate }}
</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900">
<p class="text-2xl font-extrabold text-sky-500">
{{ recipe.carbs ?? '—' }}
@if (recipe.carbs != null) {
<span class="text-sm font-semibold"> g</span>
}
</p>
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{{ 'recipes.carbs' | translate }}
</p>
</div>
</section> </section>
<div class="grid gap-8 lg:grid-cols-[1fr_1.4fr]"> <div class="grid gap-8 lg:grid-cols-[1fr_1.4fr]">
<section> <section>
<h2 class="text-lg font-bold">{{ 'recipes.ingredients' | translate }}</h2> <h2 class="text-lg font-bold">{{ 'recipes.ingredients' | translate }}</h2>
@if (recipe.ingredients.length === 0) { @if (displayRecipe().ingredients.length === 0) {
<p class="mt-3 text-sm text-slate-500">{{ 'recipes.noIngredients' | translate }}</p> <p class="mt-3 text-sm text-slate-500">{{ 'recipes.noIngredients' | translate }}</p>
} @else { } @else {
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800"> <div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800">
@@ -89,8 +106,20 @@ type IngredientSortColumn = 'name' | 'amount';
@for (ing of sortedIngredients(); track ing.id) { @for (ing of sortedIngredients(); track ing.id) {
<li class="grid grid-cols-[1fr_auto] items-center px-4 py-3 text-sm"> <li class="grid grid-cols-[1fr_auto] items-center px-4 py-3 text-sm">
<span>{{ ing.name }}</span> <span>{{ ing.name }}</span>
<span class="font-medium text-slate-500 dark:text-slate-400"> <span
class="font-medium"
[ngClass]="
amountChanged(ing.id, ing.amountGrams)
? 'text-brand-700 dark:text-brand-300'
: 'text-slate-500 dark:text-slate-400'
"
>
{{ formatAmount(ing.amountGrams, ing.unit) }} {{ formatAmount(ing.amountGrams, ing.unit) }}
@if (amountChanged(ing.id, ing.amountGrams)) {
<span class="ml-2 text-xs text-slate-400 line-through">
{{ formatAmount(originalAmounts().get(ing.id) ?? null, ing.unit) }}
</span>
}
</span> </span>
</li> </li>
} }
@@ -101,11 +130,11 @@ type IngredientSortColumn = 'name' | 'amount';
<section> <section>
<h2 class="text-lg font-bold">{{ 'recipes.preparation' | translate }}</h2> <h2 class="text-lg font-bold">{{ 'recipes.preparation' | translate }}</h2>
@if (recipe.steps.length === 0) { @if (displayRecipe().steps.length === 0) {
<p class="mt-3 text-sm text-slate-500">{{ 'recipes.noSteps' | translate }}</p> <p class="mt-3 text-sm text-slate-500">{{ 'recipes.noSteps' | translate }}</p>
} @else { } @else {
<ol class="mt-4 space-y-4"> <ol class="mt-4 space-y-4">
@for (step of recipe.steps; track step.id) { @for (step of displayRecipe().steps; track step.id) {
<li class="flex gap-4"> <li class="flex gap-4">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white"> <span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white">
{{ step.stepNumber }} {{ step.stepNumber }}
@@ -122,21 +151,73 @@ type IngredientSortColumn = 'name' | 'amount';
</article> </article>
`, `,
}) })
export class RecipeDetailViewComponent { export class RecipeDetailViewComponent implements OnChanges {
private readonly management = inject(RecipeManagementService);
private readonly translate = inject(TranslateService);
@Input({ required: true }) recipe!: RecipeDetail; @Input({ required: true }) recipe!: RecipeDetail;
@Output() recipeUpdated = new EventEmitter<RecipeDetail>();
readonly preview = signal<RecalculateRecipeCaloriesResult | null>(null);
readonly pending = signal(false);
readonly applyPending = signal(false);
readonly error = signal<string | null>(null);
targetCalories = '';
readonly ingredientSort = signal<{ column: IngredientSortColumn; direction: SortDirection }>({ readonly ingredientSort = signal<{ column: IngredientSortColumn; direction: SortDirection }>({
column: 'name', column: 'name',
direction: 'asc', direction: 'asc',
}); });
readonly originalAmounts = computed(
() => new Map(this.recipe.ingredients.map((ing) => [ing.id, ing.amountGrams])),
);
readonly displayRecipe = computed(() => {
const preview = this.preview();
if (preview?.applied && preview.recipe) return preview.recipe;
if (preview) {
return {
...this.recipe,
calories: preview.newCalories ?? this.recipe.calories,
protein: preview.newProtein ?? this.recipe.protein,
fat: preview.newFat ?? this.recipe.fat,
carbs: preview.newCarbs ?? this.recipe.carbs,
ingredients: preview.ingredients,
};
}
return this.recipe;
});
readonly showingPreview = computed(() => {
const preview = this.preview();
return preview != null && !preview.applied;
});
readonly macroCards = computed(() => {
const recipe = this.displayRecipe();
const highlight = this.showingPreview();
return [
{ label: 'recipes.calories', value: recipe.calories, suffix: 'kcal', accent: 'text-orange-500', highlight },
{ label: 'recipes.protein', value: recipe.protein, suffix: 'g', accent: 'text-brand-600', highlight },
{ label: 'recipes.fat', value: recipe.fat, suffix: 'g', accent: 'text-amber-500', highlight },
{ label: 'recipes.carbs', value: recipe.carbs, suffix: 'g', accent: 'text-sky-500', highlight },
];
});
readonly sortedIngredients = computed(() => { readonly sortedIngredients = computed(() => {
const { column, direction } = this.ingredientSort(); const { column, direction } = this.ingredientSort();
return sortBy(this.recipe.ingredients, direction, (ing) => return sortBy(this.displayRecipe().ingredients, direction, (ing) =>
column === 'name' ? ing.name : ing.amountGrams, column === 'name' ? ing.name : ing.amountGrams,
); );
}); });
ngOnChanges(): void {
if (this.recipe && !this.targetCalories) {
this.targetCalories = this.recipe.calories != null ? String(this.recipe.calories) : '';
}
}
formatAmount(amount: number | null, unit: string | null): string { formatAmount(amount: number | null, unit: string | null): string {
if (amount == null && !unit) return ''; if (amount == null && !unit) return '';
if (amount == null) return unit ?? ''; if (amount == null) return unit ?? '';
@@ -144,6 +225,12 @@ export class RecipeDetailViewComponent {
return unit ? `${display} ${unit}` : `${display} g`; return unit ? `${display} ${unit}` : `${display} g`;
} }
amountChanged(id: number, amount: number | null): boolean {
if (!this.showingPreview()) return false;
const previous = this.originalAmounts().get(id);
return previous != null && amount != null && previous !== amount;
}
toggleIngredientSort(column: IngredientSortColumn): void { toggleIngredientSort(column: IngredientSortColumn): void {
this.ingredientSort.update((current) => nextSort(current, column)); this.ingredientSort.update((current) => nextSort(current, column));
} }
@@ -151,4 +238,34 @@ export class RecipeDetailViewComponent {
ingredientIndicator(column: IngredientSortColumn): string { ingredientIndicator(column: IngredientSortColumn): string {
return sortIndicator(this.ingredientSort(), column); return sortIndicator(this.ingredientSort(), column);
} }
runRecalculate(apply: boolean): void {
const target = Number(this.targetCalories);
if (!Number.isFinite(target) || target < 50) {
this.error.set(this.translate.instant('recipes.recalculateCaloriesInvalid'));
return;
}
this.error.set(null);
if (apply) this.applyPending.set(true);
else this.pending.set(true);
this.management.recalculateRecipeCalories(this.recipe.id, target, apply).subscribe({
next: (result) => {
this.preview.set(result);
if (result.applied && result.recipe) {
this.recipe = result.recipe;
this.recipeUpdated.emit(result.recipe);
this.preview.set(null);
}
this.pending.set(false);
this.applyPending.set(false);
},
error: (err) => {
this.error.set(extractApiError(err, this.translate.instant('recipes.recalculateCaloriesFailed')));
this.pending.set(false);
this.applyPending.set(false);
},
});
}
} }

View File

@@ -1,6 +1,6 @@
import { api } from "./axiosInstance"; import { api } from "./axiosInstance";
import type { RecipeDetail } from "@/types/recipe.types"; import type { RecipeDetail } from "@/types/recipe.types";
import type { CreateRecipeInput, RecipeImportResult, RecipeMacroRecalcResult } from "@/types/recipe-management.types"; import type { CreateRecipeInput, RecipeImportResult, RecipeMacroRecalcResult, RecalculateRecipeCaloriesResult } from "@/types/recipe-management.types";
function toPayload(input: CreateRecipeInput) { function toPayload(input: CreateRecipeInput) {
return { return {
@@ -55,3 +55,15 @@ export async function recalculateAllRecipeMacros(): Promise<RecipeMacroRecalcRes
const { data } = await api.post<RecipeMacroRecalcResult>("/recipes/recalculate-macros"); const { data } = await api.post<RecipeMacroRecalcResult>("/recipes/recalculate-macros");
return data; return data;
} }
export async function recalculateRecipeCalories(
recipeId: number,
targetCalories: number,
apply = false,
): Promise<RecalculateRecipeCaloriesResult> {
const { data } = await api.post<RecalculateRecipeCaloriesResult>(
`/recipes/${recipeId}/recalculate-calories`,
{ targetCalories, apply },
);
return data;
}

View File

@@ -1,8 +1,11 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Clock } from "lucide-react"; import { Clock, Loader2, RefreshCw, Save } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { recalculateRecipeCalories } from "@/api/recipe-management.api";
import { extractApiError } from "@/api/axiosInstance";
import { CategoryBadge } from "@/components/ui/CategoryBadge"; import { CategoryBadge } from "@/components/ui/CategoryBadge";
import type { RecipeDetail as RecipeDetailModel } from "@/types/recipe.types"; import type { RecipeDetail as RecipeDetailModel } from "@/types/recipe.types";
import type { RecalculateRecipeCaloriesResult } from "@/types/recipe-management.types";
import { nextSort, sortBy, sortIndicator, type SortDirection } from "@/utils/sort.util"; import { nextSort, sortBy, sortIndicator, type SortDirection } from "@/utils/sort.util";
type IngredientSortColumn = "name" | "amount"; type IngredientSortColumn = "name" | "amount";
@@ -12,11 +15,18 @@ interface MacroProps {
value: number | null; value: number | null;
suffix: string; suffix: string;
accent: string; accent: string;
highlight?: boolean;
} }
function Macro({ label, value, suffix, accent }: MacroProps) { function Macro({ label, value, suffix, accent, highlight }: MacroProps) {
return ( return (
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center dark:border-slate-800 dark:bg-slate-900"> <div
className={`rounded-2xl border px-4 py-3 text-center ${
highlight
? "border-brand-300 bg-brand-50 dark:border-brand-700 dark:bg-brand-950/30"
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
}`}
>
<p className={`text-2xl font-extrabold ${accent}`}> <p className={`text-2xl font-extrabold ${accent}`}>
{value != null ? value : "—"} {value != null ? value : "—"}
{value != null && <span className="text-sm font-semibold"> {suffix}</span>} {value != null && <span className="text-sm font-semibold"> {suffix}</span>}
@@ -37,51 +47,198 @@ function formatAmount(amount: number | null, unit: string | null): string {
interface RecipeDetailProps { interface RecipeDetailProps {
recipe: RecipeDetailModel; recipe: RecipeDetailModel;
onRecipeUpdated?: (recipe: RecipeDetailModel) => void;
} }
export function RecipeDetail({ recipe }: RecipeDetailProps) { function mergePreview(recipe: RecipeDetailModel, preview: RecalculateRecipeCaloriesResult): RecipeDetailModel {
return {
...recipe,
calories: preview.newCalories ?? recipe.calories,
protein: preview.newProtein ?? recipe.protein,
fat: preview.newFat ?? recipe.fat,
carbs: preview.newCarbs ?? recipe.carbs,
ingredients: preview.ingredients.map((ing) => ({
id: ing.id,
name: ing.name,
amountGrams: ing.amountGrams,
unit: ing.unit,
sortOrder: ing.sortOrder,
catalogItemId: ing.catalogItemId,
})),
};
}
export function RecipeDetail({ recipe, onRecipeUpdated }: RecipeDetailProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [ingredientSort, setIngredientSort] = useState<{ const [ingredientSort, setIngredientSort] = useState<{
column: IngredientSortColumn; column: IngredientSortColumn;
direction: SortDirection; direction: SortDirection;
}>({ column: "name", direction: "asc" }); }>({ column: "name", direction: "asc" });
const [targetCalories, setTargetCalories] = useState(
recipe.calories != null ? String(recipe.calories) : "",
);
const [preview, setPreview] = useState<RecalculateRecipeCaloriesResult | null>(null);
const [pending, setPending] = useState(false);
const [applyPending, setApplyPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const displayRecipe = useMemo(() => {
if (preview?.applied && preview.recipe) return preview.recipe;
if (preview) return mergePreview(recipe, preview);
return recipe;
}, [preview, recipe]);
const originalAmounts = useMemo(
() => new Map(recipe.ingredients.map((ing) => [ing.id, ing.amountGrams])),
[recipe.ingredients],
);
const sortedIngredients = useMemo( const sortedIngredients = useMemo(
() => () =>
sortBy(recipe.ingredients, ingredientSort.direction, (ing) => sortBy(displayRecipe.ingredients, ingredientSort.direction, (ing) =>
ingredientSort.column === "name" ? ing.name : ing.amountGrams, ingredientSort.column === "name" ? ing.name : ing.amountGrams,
), ),
[recipe.ingredients, ingredientSort], [displayRecipe.ingredients, ingredientSort],
); );
const toggleIngredientSort = (column: IngredientSortColumn) => { const toggleIngredientSort = (column: IngredientSortColumn) => {
setIngredientSort((current) => nextSort(current, column)); setIngredientSort((current) => nextSort(current, column));
}; };
const runRecalculate = async (apply: boolean) => {
const target = Number(targetCalories);
if (!Number.isFinite(target) || target < 50) {
setError(t("recipes.recalculateCaloriesInvalid"));
return;
}
setError(null);
if (apply) setApplyPending(true);
else setPending(true);
try {
const result = await recalculateRecipeCalories(recipe.id, target, apply);
setPreview(result);
if (result.applied && result.recipe) {
onRecipeUpdated?.(result.recipe);
setPreview(null);
}
} catch (e) {
setError(extractApiError(e, t("recipes.recalculateCaloriesFailed")));
} finally {
setPending(false);
setApplyPending(false);
}
};
const showingPreview = preview != null && !preview.applied;
return ( return (
<article className="space-y-8"> <article className="space-y-8">
<header className="space-y-3"> <header className="space-y-3">
<CategoryBadge category={recipe.mealCategory} /> <CategoryBadge category={displayRecipe.mealCategory} />
<h1 className="text-3xl font-extrabold tracking-tight">{recipe.name}</h1> <h1 className="text-3xl font-extrabold tracking-tight">{displayRecipe.name}</h1>
{recipe.prepTimeMinutes != null && ( {displayRecipe.prepTimeMinutes != null && (
<p className="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400"> <p className="inline-flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
{t("recipes.prepTime", { count: recipe.prepTimeMinutes })} {t("recipes.prepTime", { count: displayRecipe.prepTimeMinutes })}
</p> </p>
)} )}
</header> </header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Macro label={t("recipes.calories")} value={recipe.calories} suffix="kcal" accent="text-orange-500" /> <Macro
<Macro label={t("recipes.protein")} value={recipe.protein} suffix="g" accent="text-brand-600" /> label={t("recipes.calories")}
<Macro label={t("recipes.fat")} value={recipe.fat} suffix="g" accent="text-amber-500" /> value={displayRecipe.calories}
<Macro label={t("recipes.carbs")} value={recipe.carbs} suffix="g" accent="text-sky-500" /> suffix="kcal"
accent="text-orange-500"
highlight={showingPreview}
/>
<Macro
label={t("recipes.protein")}
value={displayRecipe.protein}
suffix="g"
accent="text-brand-600"
highlight={showingPreview}
/>
<Macro
label={t("recipes.fat")}
value={displayRecipe.fat}
suffix="g"
accent="text-amber-500"
highlight={showingPreview}
/>
<Macro
label={t("recipes.carbs")}
value={displayRecipe.carbs}
suffix="g"
accent="text-sky-500"
highlight={showingPreview}
/>
</section>
<section className="card space-y-3 p-4">
<h2 className="text-sm font-bold">{t("recipes.recalculateCaloriesTitle")}</h2>
<p className="text-sm text-slate-500">{t("recipes.recalculateCaloriesHint")}</p>
<div className="flex flex-wrap items-end gap-3">
<label className="block text-sm">
{t("recipes.targetCalories")}
<input
type="number"
min={50}
max={10000}
className="input mt-1 w-36"
value={targetCalories}
onChange={(e) => {
setTargetCalories(e.target.value);
setPreview(null);
}}
/>
</label>
<button
type="button"
className="btn-secondary"
disabled={pending || applyPending}
onClick={() => void runRecalculate(false)}
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t("recipes.recalculate")}
</button>
{showingPreview ? (
<button
type="button"
className="btn-primary"
disabled={pending || applyPending}
onClick={() => void runRecalculate(true)}
>
{applyPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{t("recipes.applyRecalculation")}
</button>
) : null}
</div>
{showingPreview ? (
<p className="text-sm text-brand-700 dark:text-brand-300">
{t("recipes.recalculatePreview", {
previous: preview.previousCalories ?? "—",
next: preview.newCalories ?? "—",
target: preview.targetCalories,
})}
{preview.unlinkedIngredientCount > 0
? ` ${t("recipes.recalculateUnlinkedHint", { count: preview.unlinkedIngredientCount })}`
: ""}
</p>
) : null}
{error ? (
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
) : null}
</section> </section>
<div className="grid gap-8 lg:grid-cols-[1fr_1.4fr]"> <div className="grid gap-8 lg:grid-cols-[1fr_1.4fr]">
<section> <section>
<h2 className="text-lg font-bold">{t("recipes.ingredients")}</h2> <h2 className="text-lg font-bold">{t("recipes.ingredients")}</h2>
{recipe.ingredients.length === 0 ? ( {displayRecipe.ingredients.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">{t("recipes.noIngredients")}</p> <p className="mt-3 text-sm text-slate-500">{t("recipes.noIngredients")}</p>
) : ( ) : (
<div className="mt-4 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800"> <div className="mt-4 overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800">
@@ -104,14 +261,31 @@ export function RecipeDetail({ recipe }: RecipeDetailProps) {
</button> </button>
</div> </div>
<ul className="divide-y divide-slate-200 dark:divide-slate-800"> <ul className="divide-y divide-slate-200 dark:divide-slate-800">
{sortedIngredients.map((ing) => ( {sortedIngredients.map((ing) => {
<li key={ing.id} className="grid grid-cols-[1fr_auto] items-center px-4 py-3 text-sm"> const previous = originalAmounts.get(ing.id);
<span>{ing.name}</span> const changed =
<span className="font-medium text-slate-500 dark:text-slate-400"> showingPreview &&
{formatAmount(ing.amountGrams, ing.unit)} previous != null &&
</span> ing.amountGrams != null &&
</li> previous !== ing.amountGrams;
))} return (
<li key={ing.id} className="grid grid-cols-[1fr_auto] items-center px-4 py-3 text-sm">
<span>{ing.name}</span>
<span
className={`font-medium ${
changed ? "text-brand-700 dark:text-brand-300" : "text-slate-500 dark:text-slate-400"
}`}
>
{formatAmount(ing.amountGrams, ing.unit)}
{changed ? (
<span className="ml-2 text-xs text-slate-400 line-through">
{formatAmount(previous, ing.unit)}
</span>
) : null}
</span>
</li>
);
})}
</ul> </ul>
</div> </div>
)} )}
@@ -119,11 +293,11 @@ export function RecipeDetail({ recipe }: RecipeDetailProps) {
<section> <section>
<h2 className="text-lg font-bold">{t("recipes.preparation")}</h2> <h2 className="text-lg font-bold">{t("recipes.preparation")}</h2>
{recipe.steps.length === 0 ? ( {displayRecipe.steps.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">{t("recipes.noSteps")}</p> <p className="mt-3 text-sm text-slate-500">{t("recipes.noSteps")}</p>
) : ( ) : (
<ol className="mt-4 space-y-4"> <ol className="mt-4 space-y-4">
{recipe.steps.map((step) => ( {displayRecipe.steps.map((step) => (
<li key={step.id} className="flex gap-4"> <li key={step.id} className="flex gap-4">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white"> <span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-brand-600 text-sm font-bold text-white">
{step.stepNumber} {step.stepNumber}

View File

@@ -161,7 +161,16 @@
"noIngredients": "No ingredients listed.", "noIngredients": "No ingredients listed.",
"noSteps": "No steps listed.", "noSteps": "No steps listed.",
"kcal": "{{count}} kcal", "kcal": "{{count}} kcal",
"minutes": "{{count}} min" "minutes": "{{count}} min",
"recalculateCaloriesTitle": "Adjust calories",
"recalculateCaloriesHint": "Enter a target kcal — ingredient amounts will be scaled (catalog + AI).",
"targetCalories": "Target kcal",
"recalculate": "Recalculate",
"applyRecalculation": "Save changes",
"recalculatePreview": "Preview: {{previous}} → {{next}} kcal (target: {{target}})",
"recalculateUnlinkedHint": "{{count}} ingredient(s) not linked to the catalog — values may be less accurate.",
"recalculateCaloriesInvalid": "Enter calories between 50 and 10000 kcal.",
"recalculateCaloriesFailed": "Could not recalculate the recipe."
}, },
"manage": { "manage": {
"title": "Manage Meals", "title": "Manage Meals",

View File

@@ -165,7 +165,16 @@
"noIngredients": "Brak składników.", "noIngredients": "Brak składników.",
"noSteps": "Brak kroków.", "noSteps": "Brak kroków.",
"kcal": "{{count}} kcal", "kcal": "{{count}} kcal",
"minutes": "{{count}} min" "minutes": "{{count}} min",
"recalculateCaloriesTitle": "Dopasuj kaloryczność",
"recalculateCaloriesHint": "Podaj docelową liczbę kcal — przeliczymy gramaturę składników (katalog + AI).",
"targetCalories": "Docelowe kcal",
"recalculate": "Przelicz",
"applyRecalculation": "Zapisz zmiany",
"recalculatePreview": "Podgląd: {{previous}} → {{next}} kcal (cel: {{target}})",
"recalculateUnlinkedHint": "{{count}} składnik(ów) bez powiązania z katalogiem — wartości mogą być mniej dokładne.",
"recalculateCaloriesInvalid": "Podaj kaloryczność między 50 a 10000 kcal.",
"recalculateCaloriesFailed": "Nie udało się przeliczyć przepisu."
}, },
"manage": { "manage": {
"title": "Zarządzaj posiłkami", "title": "Zarządzaj posiłkami",

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -6,13 +7,16 @@ import { RecipeDetail } from "@/components/recipes/RecipeDetail";
import { Skeleton } from "@/components/ui/Skeleton"; import { Skeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/ui/ErrorState"; import { ErrorState } from "@/components/ui/ErrorState";
import { extractApiError } from "@/api/axiosInstance"; import { extractApiError } from "@/api/axiosInstance";
import type { RecipeDetail as RecipeDetailModel } from "@/types/recipe.types";
export function RecipeDetailPage() { export function RecipeDetailPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const recipeId = Number(id); const recipeId = Number(id);
const { data: recipe, isLoading, isError, error, refetch } = useRecipe(recipeId); const { data: fetchedRecipe, isLoading, isError, error, refetch } = useRecipe(recipeId);
const [recipe, setRecipe] = useState<RecipeDetailModel | null>(null);
const displayRecipe = recipe ?? fetchedRecipe ?? null;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -41,7 +45,15 @@ export function RecipeDetailPage() {
/> />
)} )}
{!isLoading && !isError && recipe && <RecipeDetail recipe={recipe} />} {!isLoading && !isError && displayRecipe && (
<RecipeDetail
recipe={displayRecipe}
onRecipeUpdated={(updated) => {
setRecipe(updated);
void refetch();
}}
/>
)}
</div> </div>
); );
} }

View File

@@ -37,3 +37,18 @@ export interface RecipeMacroRecalcResult {
recipesUpdated: number; recipesUpdated: number;
unlinkedIngredientRows: number; unlinkedIngredientRows: number;
} }
export interface RecalculateRecipeCaloriesResult {
recipeId: number;
previousCalories?: number | null;
targetCalories: number;
newCalories?: number | null;
newProtein?: number | null;
newFat?: number | null;
newCarbs?: number | null;
applied: boolean;
method: string;
unlinkedIngredientCount: number;
ingredients: import("./recipe.types").Ingredient[];
recipe?: import("./recipe.types").RecipeDetail | null;
}