diff --git a/backend/src/main/java/nl/seanderoo/inventory/dto/MakeableIngredientDTO.java b/backend/src/main/java/nl/seanderoo/inventory/dto/MakeableIngredientDTO.java new file mode 100644 index 0000000..a46afd9 --- /dev/null +++ b/backend/src/main/java/nl/seanderoo/inventory/dto/MakeableIngredientDTO.java @@ -0,0 +1,17 @@ +package nl.seanderoo.inventory.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** An ingredient that is not in stock but can be made with another recipe of the household. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MakeableIngredientDTO { + private String ingredientName; + private Long recipeId; + private String recipeName; +} diff --git a/backend/src/main/java/nl/seanderoo/inventory/dto/RecipeAvailabilityDTO.java b/backend/src/main/java/nl/seanderoo/inventory/dto/RecipeAvailabilityDTO.java index ed299a4..411717a 100644 --- a/backend/src/main/java/nl/seanderoo/inventory/dto/RecipeAvailabilityDTO.java +++ b/backend/src/main/java/nl/seanderoo/inventory/dto/RecipeAvailabilityDTO.java @@ -15,4 +15,5 @@ public class RecipeAvailabilityDTO { private boolean canCook; private List insufficientIngredients; private List missingIngredients; + private List makeableIngredients; } diff --git a/backend/src/main/java/nl/seanderoo/inventory/service/InventoryService.java b/backend/src/main/java/nl/seanderoo/inventory/service/InventoryService.java index daad7d7..9be839e 100644 --- a/backend/src/main/java/nl/seanderoo/inventory/service/InventoryService.java +++ b/backend/src/main/java/nl/seanderoo/inventory/service/InventoryService.java @@ -2,6 +2,7 @@ import nl.seanderoo.inventory.dto.CookResultDTO; import nl.seanderoo.inventory.dto.InventoryItemDTO; +import nl.seanderoo.inventory.dto.MakeableIngredientDTO; import nl.seanderoo.inventory.dto.RecipeAvailabilityDTO; import nl.seanderoo.inventory.exception.BadRequestException; import nl.seanderoo.inventory.exception.ResourceNotFoundException; @@ -16,6 +17,8 @@ import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.OptionalDouble; @@ -152,12 +155,22 @@ public RecipeAvailabilityDTO checkAvailability(Long recipeId, int requestedServi double scale = scale(recipe.getServings(), requestedServings); List insufficient = new ArrayList<>(); List missing = new ArrayList<>(); + List makeable = new ArrayList<>(); for (RecipeIngredient ingredient : recipe.getIngredients()) { if (isAlwaysAvailable(ingredient)) continue; List candidates = findCandidates(ingredient.getIngredientName()); if (candidates.isEmpty()) { - missing.add(ingredient.getIngredientName()); + Recipe subRecipe = findMakeableSubRecipe(ingredient.getIngredientName(), new HashSet<>(Set.of(recipe.getId()))); + if (subRecipe != null) { + makeable.add(MakeableIngredientDTO.builder() + .ingredientName(ingredient.getIngredientName()) + .recipeId(subRecipe.getId()) + .recipeName(subRecipe.getName()) + .build()); + } else { + missing.add(ingredient.getIngredientName()); + } continue; } if (containsHerb(candidates)) continue; @@ -178,9 +191,48 @@ public RecipeAvailabilityDTO checkAvailability(Long recipeId, int requestedServi .canCook(insufficient.isEmpty() && missing.isEmpty()) .insufficientIngredients(insufficient) .missingIngredients(missing) + .makeableIngredients(makeable) .build(); } + /** + * Finds a household recipe that produces the given ingredient (matched by name, exact match + * preferred) and can itself be made from current stock — so a missing ingredient like + * "tzatziki" counts as makeable when there is a cookable Tzatziki recipe. + * {@code visited} holds recipe ids already being checked, to break recipe cycles. + */ + private Recipe findMakeableSubRecipe(String ingredientName, Set visited) { + String needle = ingredientName.toLowerCase().trim(); + return recipeRepository.findAllByHouseholdId(currentHouseholdProvider.getHouseholdId()).stream() + .filter(recipe -> !visited.contains(recipe.getId())) + .filter(recipe -> { + String name = recipe.getName().toLowerCase().trim(); + return name.contains(needle) || needle.contains(name); + }) + .sorted(Comparator.comparing(recipe -> !recipe.getName().trim().equalsIgnoreCase(needle))) + .filter(recipe -> canMake(recipe, visited)) + .findFirst() + .orElse(null); + } + + /** Whether one batch of the recipe (at its base servings) can be made from current stock. */ + private boolean canMake(Recipe recipe, Set visited) { + visited.add(recipe.getId()); + for (RecipeIngredient ingredient : recipe.getIngredients()) { + if (isAlwaysAvailable(ingredient)) continue; + List candidates = findCandidates(ingredient.getIngredientName()); + if (candidates.isEmpty()) { + if (findMakeableSubRecipe(ingredient.getIngredientName(), visited) == null) return false; + continue; + } + if (containsHerb(candidates)) continue; + + IngredientMatch match = matchIngredient(candidates, ingredient, ingredient.getQuantity()); + if (match.item().getQuantity() < match.neededQuantity()) return false; + } + return true; + } + public CookResultDTO cookRecipe(Long recipeId, int requestedServings, List skippedIngredients) { Recipe recipe = findOwnedRecipe(recipeId); diff --git a/backend/src/test/java/nl/seanderoo/inventory/service/InventoryServiceIntegrationTest.java b/backend/src/test/java/nl/seanderoo/inventory/service/InventoryServiceIntegrationTest.java new file mode 100644 index 0000000..9f8a14b --- /dev/null +++ b/backend/src/test/java/nl/seanderoo/inventory/service/InventoryServiceIntegrationTest.java @@ -0,0 +1,92 @@ +package nl.seanderoo.inventory.service; + +import nl.seanderoo.inventory.dto.InventoryItemDTO; +import nl.seanderoo.inventory.dto.MakeableIngredientDTO; +import nl.seanderoo.inventory.dto.RecipeAvailabilityDTO; +import nl.seanderoo.inventory.dto.RecipeDTO; +import nl.seanderoo.inventory.dto.RecipeIngredientDTO; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +@SpringBootTest +class InventoryServiceIntegrationTest { + + @Autowired + private InventoryService inventoryService; + + @Autowired + private RecipeService recipeService; + + @Test + void checkAvailability_missingIngredientWithCookableSubRecipe_reportsMakeable() { + addItem("Xyogurt", 500, "grams"); + addItem("Xcucumber", 2, "pieces"); + addRecipe("Xtzatziki", ingredient("xyogurt", 200, "grams"), ingredient("xcucumber", 1, "pieces")); + RecipeDTO main = addRecipe("Xgyros", ingredient("xtzatziki", 1, "cup")); + + RecipeAvailabilityDTO availability = inventoryService.checkAvailability(main.getId(), 1); + + assertThat(availability.getMissingIngredients()).isEmpty(); + assertThat(availability.getMakeableIngredients()) + .extracting(MakeableIngredientDTO::getIngredientName, MakeableIngredientDTO::getRecipeName) + .containsExactly(tuple("xtzatziki", "Xtzatziki")); + assertThat(availability.isCanCook()).isTrue(); + } + + @Test + void checkAvailability_subRecipeItselfNotCookable_reportsMissing() { + addRecipe("Ytzatziki", ingredient("yyogurt", 200, "grams")); + RecipeDTO main = addRecipe("Ygyros", ingredient("ytzatziki", 1, "cup")); + + RecipeAvailabilityDTO availability = inventoryService.checkAvailability(main.getId(), 1); + + assertThat(availability.getMakeableIngredients()).isEmpty(); + assertThat(availability.getMissingIngredients()).containsExactly("ytzatziki"); + assertThat(availability.isCanCook()).isFalse(); + } + + @Test + void checkAvailability_selfReferencingRecipe_terminatesAndReportsMissing() { + RecipeDTO main = addRecipe("Zsourdough", ingredient("zsourdough starter", 100, "grams")); + addRecipe("Zsourdough starter", ingredient("zsourdough starter", 50, "grams")); + + RecipeAvailabilityDTO availability = inventoryService.checkAvailability(main.getId(), 1); + + assertThat(availability.getMakeableIngredients()).isEmpty(); + assertThat(availability.getMissingIngredients()).containsExactly("zsourdough starter"); + assertThat(availability.isCanCook()).isFalse(); + } + + private void addItem(String name, double quantity, String unit) { + inventoryService.addItem(InventoryItemDTO.builder() + .name(name) + .category("other") + .location("Pantry") + .quantity(quantity) + .unit(unit) + .build()); + } + + private RecipeDTO addRecipe(String name, RecipeIngredientDTO... ingredients) { + return recipeService.addRecipe(RecipeDTO.builder() + .name(name) + .servings(1) + .ingredients(Set.of(ingredients)) + .build()); + } + + private static RecipeIngredientDTO ingredient(String name, double quantity, String unit) { + return RecipeIngredientDTO.builder() + .ingredientName(name) + .quantity(quantity) + .unit(unit) + .optional(false) + .build(); + } +} diff --git a/frontend/src/components/RecipeDetailModal.tsx b/frontend/src/components/RecipeDetailModal.tsx index c099ea0..fc7441b 100644 --- a/frontend/src/components/RecipeDetailModal.tsx +++ b/frontend/src/components/RecipeDetailModal.tsx @@ -41,6 +41,7 @@ export default function RecipeDetailModal({ recipe, onClose }: { recipe: Recipe; Array.from(skippedIngredients).some((skip) => warning.toLowerCase().includes(skip.toLowerCase())); const filteredInsufficient = (availability?.insufficientIngredients ?? []).filter((s) => !isSkipped(s)); const filteredMissing = (availability?.missingIngredients ?? []).filter((s) => !isSkipped(s)); + const filteredMakeable = (availability?.makeableIngredients ?? []).filter((m) => !isSkipped(m.ingredientName)); const canCook = !checkingAvailability && availability != null && filteredInsufficient.length === 0 && filteredMissing.length === 0; @@ -167,7 +168,7 @@ export default function RecipeDetailModal({ recipe, onClose }: { recipe: Recipe; {cookRecipeMutation.isPending ? t("detail.cooking") : t("detail.cooked")} - {(filteredInsufficient.length > 0 || filteredMissing.length > 0) && ( + {(filteredInsufficient.length > 0 || filteredMissing.length > 0 || filteredMakeable.length > 0) && (
{filteredInsufficient.map((line) => (

· {t("recommendations.notEnough", { items: line })}

@@ -175,6 +176,11 @@ export default function RecipeDetailModal({ recipe, onClose }: { recipe: Recipe; {filteredMissing.map((line) => (

· {t("recommendations.missing", { items: line })}

))} + {filteredMakeable.map((m) => ( +

+ · {t("detail.makeable", { ingredient: m.ingredientName, recipe: m.recipeName })} +

+ ))}

{t("detail.skipHint")}

diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index eb9cd0f..5a43b59 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -103,6 +103,7 @@ export const en = { "detail.cooking": "Updating inventory...", "detail.cooked": "Cooked this!", "detail.skipHint": "Click × next to an ingredient above to skip it for this cook.", + "detail.makeable": "{ingredient}: not in stock, but you can make it with your \"{recipe}\" recipe — or buy it ready-made.", "detail.error.cook": "Could not update inventory. Try again.", "invite.sent": "Invite sent to {email}.", diff --git a/frontend/src/i18n/es.ts b/frontend/src/i18n/es.ts index 1f1de19..1a596c0 100644 --- a/frontend/src/i18n/es.ts +++ b/frontend/src/i18n/es.ts @@ -106,6 +106,7 @@ export const es: Record = { "detail.cooking": "Actualizando inventario...", "detail.cooked": "¡Ya lo cociné!", "detail.skipHint": "Haz clic en la × junto a un ingrediente arriba para omitirlo por esta vez.", + "detail.makeable": "{ingredient}: no está en stock, pero puedes prepararlo con tu receta \"{recipe}\" — o comprarlo ya hecho.", "detail.error.cook": "No se pudo actualizar el inventario. Intenta de nuevo.", "invite.sent": "Invitación enviada a {email}.", diff --git a/frontend/src/i18n/nl.ts b/frontend/src/i18n/nl.ts index a594e29..245de7d 100644 --- a/frontend/src/i18n/nl.ts +++ b/frontend/src/i18n/nl.ts @@ -105,6 +105,7 @@ export const nl: Record = { "detail.cooking": "Voorraad bijwerken...", "detail.cooked": "Dit gekookt!", "detail.skipHint": "Klik op × naast een ingrediënt hierboven om het deze keer over te slaan.", + "detail.makeable": "{ingredient}: niet op voorraad, maar je kunt het maken met je recept \"{recipe}\" — of kant-en-klaar kopen.", "detail.error.cook": "Kon voorraad niet bijwerken. Probeer opnieuw.", "invite.sent": "Uitnodiging verstuurd naar {email}.", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 16334dc..4e4fc44 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -62,10 +62,17 @@ export type CookResult = { unmatched: string[]; }; +export type MakeableIngredient = { + ingredientName: string; + recipeId: number; + recipeName: string; +}; + export type RecipeAvailability = { canCook: boolean; insufficientIngredients: string[]; missingIngredients: string[]; + makeableIngredients: MakeableIngredient[]; }; export type RecipeRecommendation = {