Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ public class RecipeAvailabilityDTO {
private boolean canCook;
private List<String> insufficientIngredients;
private List<String> missingIngredients;
private List<MakeableIngredientDTO> makeableIngredients;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -152,12 +155,22 @@ public RecipeAvailabilityDTO checkAvailability(Long recipeId, int requestedServi
double scale = scale(recipe.getServings(), requestedServings);
List<String> insufficient = new ArrayList<>();
List<String> missing = new ArrayList<>();
List<MakeableIngredientDTO> makeable = new ArrayList<>();

for (RecipeIngredient ingredient : recipe.getIngredients()) {
if (isAlwaysAvailable(ingredient)) continue;
List<InventoryItem> 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;
Expand All @@ -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<Long> 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<Long> visited) {
visited.add(recipe.getId());
for (RecipeIngredient ingredient : recipe.getIngredients()) {
if (isAlwaysAvailable(ingredient)) continue;
List<InventoryItem> 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<String> skippedIngredients) {
Recipe recipe = findOwnedRecipe(recipeId);

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
8 changes: 7 additions & 1 deletion frontend/src/components/RecipeDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -167,14 +168,19 @@ export default function RecipeDetailModal({ recipe, onClose }: { recipe: Recipe;
<UtensilsCrossed className="h-4 w-4" />
{cookRecipeMutation.isPending ? t("detail.cooking") : t("detail.cooked")}
</button>
{(filteredInsufficient.length > 0 || filteredMissing.length > 0) && (
{(filteredInsufficient.length > 0 || filteredMissing.length > 0 || filteredMakeable.length > 0) && (
<div className="space-y-1 text-sm">
{filteredInsufficient.map((line) => (
<p key={line} className="text-amber-600 dark:text-amber-300">· {t("recommendations.notEnough", { items: line })}</p>
))}
{filteredMissing.map((line) => (
<p key={line} className="text-rose-600 dark:text-rose-300">· {t("recommendations.missing", { items: line })}</p>
))}
{filteredMakeable.map((m) => (
<p key={m.ingredientName} className="text-sky-600 dark:text-sky-300">
· {t("detail.makeable", { ingredient: m.ingredientName, recipe: m.recipeName })}
</p>
))}
<p className="text-slate-500 dark:text-slate-400 text-xs pt-1">
{t("detail.skipHint")}
</p>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const es: Record<keyof typeof en, string> = {
"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}.",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export const nl: Record<keyof typeof en, string> = {
"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}.",
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading