diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 0e39c54..4a4ae85 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -26,6 +26,8 @@ import java.util.stream.Collectors; @Service public class PlanningService { + private static final double MAX_VARIETY_SCORE = 10.0; + private final WeekPlanRepository weekPlanRepository; private final WeekPlanSlotRepository weekPlanSlotRepository; private final CookingLogRepository cookingLogRepository; @@ -135,6 +137,8 @@ public class PlanningService { .map(cl -> cl.getRecipe().getId()) .collect(Collectors.toSet()); + double currentScore = computeCurrentScore(plan, config, recentlyCookedIds); + List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); Set lowerTagFilters = tagFilters.stream() @@ -145,11 +149,13 @@ public class PlanningService { .filter(r -> !usedRecipeIds.contains(r.getId())) .filter(r -> matchesAllTags(r, lowerTagFilters)) .map(candidate -> { - double score = simulateVarietyScore( + double simulatedScore = simulateVarietyScore( plan, candidate, slotDate, config, recentlyCookedIds); - return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score); + double scoreDelta = simulatedScore - currentScore; + boolean hasConflict = scoreDelta < 0; + return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict); }) - .sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore())) + .sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta())) .limit(limit) .toList(); @@ -168,12 +174,22 @@ public class PlanningService { VarietyScoreConfig config, Set recentlyCookedIds) { List simulatedSlots = new ArrayList<>(); for (WeekPlanSlot slot : plan.getSlots()) { - simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate())); + if (!slot.getSlotDate().equals(slotDate)) { + simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate())); + } } simulatedSlots.add(new SimulatedSlot(candidate, slotDate)); return scoreFromSimulatedSlots(simulatedSlots, config, recentlyCookedIds); } + private double computeCurrentScore(WeekPlan plan, VarietyScoreConfig config, Set recentlyCookedIds) { + List currentSlots = plan.getSlots().stream() + .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) + .toList(); + return currentSlots.isEmpty() ? MAX_VARIETY_SCORE + : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); + } + private record SimulatedSlot(Recipe recipe, LocalDate date) {} @Transactional(readOnly = true) @@ -191,11 +207,7 @@ public class PlanningService { .map(cl -> cl.getRecipe().getId()) .collect(Collectors.toSet()); - List currentSlots = plan.getSlots().stream() - .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) - .toList(); - double currentScore = currentSlots.isEmpty() ? 10.0 - : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); + double currentScore = computeCurrentScore(plan, config, recentlyCookedIds); double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds); return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore); @@ -204,10 +216,6 @@ public class PlanningService { private double scoreFromSimulatedSlots(List slots, VarietyScoreConfig config, Set recentlyCookedIds) { List checkedTagTypes = config.getRepeatTagTypes(); - double wTagRepeat = config.getWTagRepeat().doubleValue(); - double wIngredientOverlap = config.getWIngredientOverlap().doubleValue(); - double wRecentRepeat = config.getWRecentRepeat().doubleValue(); - double wPlanDuplicate = config.getWPlanDuplicate().doubleValue(); // 1. Tag-type repeats on consecutive days Map> tagDays = new LinkedHashMap<>(); @@ -247,12 +255,17 @@ public class PlanningService { .mapToLong(c -> c - 1) .sum(); - double score = 10.0; - score -= tagRepeatCount * wTagRepeat; - score -= ingredientOverlapCount * wIngredientOverlap; - score -= recentRepeatCount * wRecentRepeat; - score -= duplicatePenaltyCount * wPlanDuplicate; - return Math.max(0, Math.min(10, score)); + return applyPenalties(tagRepeatCount, ingredientOverlapCount, recentRepeatCount, duplicatePenaltyCount, config); + } + + private double applyPenalties(long tagRepeats, long ingredientOverlaps, long recentRepeats, + long duplicates, VarietyScoreConfig config) { + double score = MAX_VARIETY_SCORE; + score -= tagRepeats * config.getWTagRepeat().doubleValue(); + score -= ingredientOverlaps * config.getWIngredientOverlap().doubleValue(); + score -= recentRepeats * config.getWRecentRepeat().doubleValue(); + score -= duplicates * config.getWPlanDuplicate().doubleValue(); + return Math.max(0, Math.min(MAX_VARIETY_SCORE, score)); } @Transactional(readOnly = true) @@ -269,10 +282,6 @@ public class PlanningService { .orElse(VarietyScoreConfig.defaults(plan.getHousehold())); List checkedTagTypes = config.getRepeatTagTypes(); - double wTagRepeat = config.getWTagRepeat().doubleValue(); - double wIngredientOverlap = config.getWIngredientOverlap().doubleValue(); - double wRecentRepeat = config.getWRecentRepeat().doubleValue(); - double wPlanDuplicate = config.getWPlanDuplicate().doubleValue(); int historyDays = config.getHistoryDays(); // 1. Tag-type repeats on consecutive days @@ -340,13 +349,7 @@ public class PlanningService { } } - // Calculate score - double score = 10.0; - score -= tagRepeats.size() * wTagRepeat; - score -= overlaps.size() * wIngredientOverlap; - score -= recentRepeats.size() * wRecentRepeat; - score -= duplicatePenaltyCount * wPlanDuplicate; - score = Math.max(0, Math.min(10, score)); + double score = applyPenalties(tagRepeats.size(), overlaps.size(), recentRepeats.size(), duplicatePenaltyCount, config); return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan); } diff --git a/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java index 7c0f4ed..1844fcc 100644 --- a/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java +++ b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java @@ -6,6 +6,7 @@ public record SuggestionResponse(List suggestions) { public record SuggestionItem( SlotResponse.SlotRecipe recipe, - double simulatedScore + double scoreDelta, + boolean hasConflict ) {} } diff --git a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java index 3e7495f..e86ff86 100644 --- a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java +++ b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java @@ -165,7 +165,7 @@ class SuggestionsTest { } @Test - void emptyPlanWithRecipesShouldReturnAllWithPerfectScore() { + void emptyPlanWithRecipesShouldReturnAllWithZeroDelta() { var plan = createPlan(); var r1 = createRecipe("Pasta"); var r2 = createRecipe("Salad"); @@ -179,8 +179,12 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(3); - assertThat(result.suggestions()).allSatisfy(s -> - assertThat(s.simulatedScore()).isEqualTo(10.0)); + // Empty plan → currentScore = 10.0; no penalties → scoreDelta = 0.0 for all + // hasConflict = (scoreDelta < 0) = false for neutral recipes + assertThat(result.suggestions()).allSatisfy(s -> { + assertThat(s.scoreDelta()).isEqualTo(0.0); + assertThat(s.hasConflict()).isFalse(); + }); } @Test @@ -204,6 +208,28 @@ class SuggestionsTest { .isInstanceOf(ResourceNotFoundException.class); } + @Test + void topNZeroShouldReturnEmptyList() { + var plan = createPlan(); + stubPlan(plan); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 0); + + assertThat(result.suggestions()).isEmpty(); + } + + @Test + void topNNegativeShouldReturnEmptyList() { + var plan = createPlan(); + stubPlan(plan); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), -1); + + assertThat(result.suggestions()).isEmpty(); + } + @Test void singleCandidateShouldReturnOne() { var plan = createPlan(); @@ -221,6 +247,148 @@ class SuggestionsTest { } } + // ═══════════════════════════════════════════════════════════ + // Category 1b: scoreDelta and hasConflict + // ═══════════════════════════════════════════════════════════ + + @Nested + class ScoreDeltaAndHasConflict { + + @Test + void recipeWithZeroDeltaOnEmptyPlanShouldNotHaveConflict() { + // Empty plan → currentScore = 10.0. Clean recipe → simulatedScore = 10.0. + // scoreDelta = 0.0. No worsening → hasConflict = false. + var plan = createPlan(); + var recipe = createRecipe("Clean Recipe"); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(recipe); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + var item = result.suggestions().getFirst(); + assertThat(item.scoreDelta()).isEqualTo(0.0); + assertThat(item.hasConflict()).isFalse(); + } + + @Test + void recipeWithTagConflictShouldHaveNegativeDeltaAndHasConflict() { + // Existing slot Mon=Monday Pasta (cuisine tag). Adding Tue=More Pasta → tag repeat penalty (-1.5). + // currentScore = 10.0 (1 slot, no consecutive). simulatedScore = 10.0 - 1.5 = 8.5. + // scoreDelta = -1.5, hasConflict = true. + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var existingRecipe = createRecipe("Monday Pasta"); + addTag(existingRecipe, pastaTag); + addSlot(plan, existingRecipe, MONDAY); + + var candidate = createRecipe("More Pasta"); + addTag(candidate, pastaTag); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, candidate); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + var item = result.suggestions().getFirst(); + assertThat(item.scoreDelta()).isEqualTo(-1.5); + assertThat(item.hasConflict()).isTrue(); + } + + @Test + void recipeWithIngredientConflictShouldHaveNegativeDeltaAndHasConflict() { + // Existing slot Mon=Tomato Soup (tomato ingredient). Adding Tue=Tomato Pasta → overlap (-0.3). + // currentScore = 10.0, simulatedScore = 9.7, scoreDelta = -0.3, hasConflict = true. + var plan = createPlan(); + var tomato = createIngredient("Tomatoes", false); + var existingRecipe = createRecipe("Tomato Soup"); + addIngredient(existingRecipe, tomato); + addSlot(plan, existingRecipe, MONDAY); + + var candidate = createRecipe("Tomato Pasta"); + addIngredient(candidate, tomato); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, candidate); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + var item = result.suggestions().getFirst(); + assertThat(item.scoreDelta()).isCloseTo(-0.3, within(0.001)); + assertThat(item.hasConflict()).isTrue(); + } + + @Test + void swappingExistingSlotForCleanRecipeShouldHavePositiveDelta() { + // Plan has Mon=ItalianA, Tue=ItalianB → consecutive cuisine tag repeat → currentScore = 8.5 + // Asking for suggestions for Mon (swap scenario). + // CleanRecipe (no Italian tag) → correct simulation: [Mon:CleanRecipe, Tue:ItalianB] → no repeat → 10.0 + // scoreDelta = +1.5 → hasConflict = false + var plan = createPlan(); + var italianTag = createTag("Italienisch", "cuisine"); + var italianA = createRecipe("Spaghetti Carbonara"); + addTag(italianA, italianTag); + addSlot(plan, italianA, MONDAY); + var italianB = createRecipe("Penne Arrabiata"); + addTag(italianB, italianTag); + addSlot(plan, italianB, MONDAY.plusDays(1)); + var cleanRecipe = createRecipe("Grillhähnchen"); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(italianA, italianB, cleanRecipe); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + var item = result.suggestions().getFirst(); + assertThat(item.recipe().name()).isEqualTo("Grillhähnchen"); + assertThat(item.scoreDelta()).isCloseTo(1.5, within(0.001)); + assertThat(item.hasConflict()).isFalse(); + } + + @Test + void scoreDeltaIsSortedDescendingCleanBeforeConflicting() { + // Clean recipe (scoreDelta = 0.0) should rank above conflicting (scoreDelta < 0). + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var existingRecipe = createRecipe("Monday Pasta"); + addTag(existingRecipe, pastaTag); + addSlot(plan, existingRecipe, MONDAY); + + var cleanRecipe = createRecipe("Plain Rice"); + var conflictingRecipe = createRecipe("More Pasta"); + addTag(conflictingRecipe, pastaTag); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, cleanRecipe, conflictingRecipe); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(2); + assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice"); + assertThat(result.suggestions().get(0).scoreDelta()).isEqualTo(0.0); + assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("More Pasta"); + assertThat(result.suggestions().get(1).scoreDelta()).isEqualTo(-1.5); + } + } + // ═══════════════════════════════════════════════════════════ // Category 2: Exclusion of In-Plan Recipes // ═══════════════════════════════════════════════════════════ @@ -402,8 +570,8 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(2); // B should rank higher (no tag penalty) assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice"); - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); } @Test @@ -428,8 +596,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(2); - assertThat(result.suggestions().get(0).simulatedScore()) - .isEqualTo(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isEqualTo(result.suggestions().get(1).scoreDelta()); } @Test @@ -453,8 +621,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(1); - // No penalty — dietary not tracked - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // No penalty — dietary not tracked → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } } @@ -492,8 +660,8 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Mushroom Risotto"); - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); } @Test @@ -519,7 +687,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(1); - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // Staples ignored → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } } @@ -547,8 +716,8 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Stir Fry"); - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); } @Test @@ -566,7 +735,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(1); - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // No penalty → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } } @@ -631,7 +801,7 @@ class SuggestionsTest { } @Test - void rankingOrderShouldBeBySimulatedScoreDescending() { + void rankingOrderShouldBeByScoreDeltaDescending() { var plan = createPlan(); var pastaTag = createTag("Pasta", "cuisine"); var tomato = createIngredient("Tomatoes", false); @@ -666,11 +836,11 @@ class SuggestionsTest { assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Dry Pasta"); assertThat(result.suggestions().get(2).recipe().name()).isEqualTo("Tomato Pasta"); - // Verify scores are strictly descending - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); - assertThat(result.suggestions().get(1).simulatedScore()) - .isGreaterThan(result.suggestions().get(2).simulatedScore()); + // Verify scoreDelta is strictly descending + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); + assertThat(result.suggestions().get(1).scoreDelta()) + .isGreaterThan(result.suggestions().get(2).scoreDelta()); } @Test @@ -688,8 +858,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(2); - assertThat(result.suggestions().get(0).simulatedScore()) - .isEqualTo(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isEqualTo(result.suggestions().get(1).scoreDelta()); } } @@ -726,7 +896,7 @@ class SuggestionsTest { addTag(c1, pastaTag); addIngredient(c1, tomato); - // Candidate 2: Chicken only → protein repeat with Mon + // Candidate 2: Chicken only → protein repeat with Mon (Mon→Wed not consecutive) var c2 = createRecipe("Chicken Salad"); addTag(c2, chickenTag); @@ -745,7 +915,7 @@ class SuggestionsTest { stubPlan(plan); stubDefaultConfig(); stubRecipes(monRecipe, tueRecipe, c1, c2, c3, c4, c5); - // c1 was cooked recently + // c1 was cooked recently (within 14-day window) stubCookingLogs(createCookingLog(c1, MONDAY.minusDays(3))); // Slot date = Wednesday (adjacent to Tuesday) @@ -754,19 +924,20 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(5); - // c2, c4, c5 all score 10.0 (no penalties — Chicken Mon→Wed not consecutive) + // currentScore = 10.0 (Mon+Tue plan: no consecutive conflicts between just those 2 slots) + // c2, c4, c5: no additional conflicts → scoreDelta = 0.0 var topThree = result.suggestions().subList(0, 3); assertThat(topThree).extracting(s -> s.recipe().name()) .containsExactlyInAnyOrder("Chicken Salad", "Mushroom Risotto", "Lentil Soup"); - assertThat(topThree).allSatisfy(s -> assertThat(s.simulatedScore()).isEqualTo(10.0)); + assertThat(topThree).allSatisfy(s -> assertThat(s.scoreDelta()).isEqualTo(0.0)); - // c3 (Cheese Omelette) has ingredient overlap Tue→Wed: -0.3 + // c3 (Cheese Omelette) has ingredient overlap Tue→Wed: scoreDelta = -0.3 assertThat(result.suggestions().get(3).recipe().name()).isEqualTo("Cheese Omelette"); - assertThat(result.suggestions().get(3).simulatedScore()).isCloseTo(9.7, within(0.001)); + assertThat(result.suggestions().get(3).scoreDelta()).isCloseTo(-0.3, within(0.001)); - // c1 (Tomato Spaghetti) has recent repeat: -1.0 + // c1 (Tomato Spaghetti) has recent repeat: scoreDelta = -1.0 assertThat(result.suggestions().get(4).recipe().name()).isEqualTo("Tomato Spaghetti"); - assertThat(result.suggestions().get(4).simulatedScore()).isEqualTo(9.0); + assertThat(result.suggestions().get(4).scoreDelta()).isEqualTo(-1.0); } @Test @@ -800,7 +971,7 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of("Quick meal"), 5); - // Only quick recipes, ranked by variety + // Only quick recipes, ranked by scoreDelta desc assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Quick Salad"); assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Quick Pasta"); @@ -815,7 +986,7 @@ class SuggestionsTest { class EdgeCases { @Test - void recipeWithNoTagsOrIngredientsShouldGetPerfectScore() { + void recipeWithNoTagsOrIngredientsShouldGetZeroDelta() { var plan = createPlan(); var existingRecipe = createRecipe("Existing"); addSlot(plan, existingRecipe, MONDAY); @@ -832,7 +1003,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(1); - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // No conflicts → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } @Test diff --git a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java index e827493..77cd512 100644 --- a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java +++ b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java @@ -162,7 +162,7 @@ class WeekPlanControllerTest { @Test void getSuggestionsShouldReturn200() throws Exception { var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null); - var item = new SuggestionResponse.SuggestionItem(recipe, 9.5); + var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false); var response = new SuggestionResponse(List.of(item)); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); @@ -175,7 +175,8 @@ class WeekPlanControllerTest { .param("slotDate", "2026-04-08")) .andExpect(status().isOk()) .andExpect(jsonPath("$.suggestions[0].recipe.name").value("Stir Fry")) - .andExpect(jsonPath("$.suggestions[0].simulatedScore").value(9.5)); + .andExpect(jsonPath("$.suggestions[0].scoreDelta").value(1.5)) + .andExpect(jsonPath("$.suggestions[0].hasConflict").value(false)); } @Test diff --git a/frontend/src/lib/api/openapi.json b/frontend/src/lib/api/openapi.json index 0b6b1ec..91b09db 100644 --- a/frontend/src/lib/api/openapi.json +++ b/frontend/src/lib/api/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"paths":{"/v1/recipes/{id}":{"get":{"tags":["recipe-controller"],"operationId":"getRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"put":{"tags":["recipe-controller"],"operationId":"updateRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"delete":{"tags":["recipe-controller"],"operationId":"deleteRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/v1/week-plans":{"get":{"tags":["week-plan-controller"],"operationId":"getWeekPlan","parameters":[{"name":"weekStart","in":"query","required":true,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}},"post":{"tags":["week-plan-controller"],"operationId":"createWeekPlan","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWeekPlanRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/week-plans/{id}/slots":{"post":{"tags":["week-plan-controller"],"operationId":"addSlot","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/week-plans/{id}/shopping-list":{"post":{"tags":["shopping-list-controller"],"operationId":"generateFromPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/week-plans/{id}/confirm":{"post":{"tags":["week-plan-controller"],"operationId":"confirmPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/tags":{"get":{"tags":["tag-controller"],"operationId":"listTags","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"post":{"tags":["tag-controller"],"operationId":"createTag","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"/v1/shopping-lists/{id}/items":{"post":{"tags":["shopping-list-controller"],"operationId":"addItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddItemRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/recipes":{"get":{"tags":["recipe-controller"],"operationId":"listRecipes","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"effort","in":"query","required":false,"schema":{"type":"string"}},{"name":"isChildFriendly","in":"query","required":false,"schema":{"type":"boolean"}},{"name":"cookTimeMin.lte","in":"query","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"sort","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListRecipeSummaryResponse"}}}}}},"post":{"tags":["recipe-controller"],"operationId":"createRecipe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}}},"/v1/pantry-items":{"get":{"tags":["pantry-controller"],"operationId":"listItems","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"post":{"tags":["pantry-controller"],"operationId":"createItem","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/invites/{code}/accept":{"post":{"tags":["household-controller"],"operationId":"acceptInvite","parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAcceptInviteResponse"}}}}}}},"/v1/ingredient-categories":{"get":{"tags":["ingredient-category-controller"],"operationId":"listCategories","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"post":{"tags":["ingredient-category-controller"],"operationId":"createCategory","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientCategoryCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"/v1/households":{"post":{"tags":["household-controller"],"operationId":"createHousehold","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateHouseholdRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/invites":{"post":{"tags":["household-controller"],"operationId":"createInvite","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseInviteResponse"}}}}}}},"/v1/cooking-logs":{"get":{"tags":["cooking-log-controller"],"operationId":"listCookingLogs","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":30}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"post":{"tags":["cooking-log-controller"],"operationId":"createCookingLog","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCookingLogRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"/v1/auth/signup":{"post":{"tags":["auth-controller"],"operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/auth/logout":{"post":{"tags":["auth-controller"],"operationId":"logout","responses":{"200":{"description":"OK"}}}},"/v1/auth/login":{"post":{"tags":["auth-controller"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users":{"get":{"tags":["admin-controller"],"operationId":"listUsers","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isActive","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAdminUserResponse"}}}}}},"post":{"tags":["admin-controller"],"operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/admin/users/{id}/reset-password":{"post":{"tags":["admin-controller"],"operationId":"resetPassword","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResetPasswordRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseResetPasswordResponse"}}}}}}},"/v1/week-plans/{planId}/slots/{slotId}":{"delete":{"tags":["week-plan-controller"],"operationId":"deleteSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["week-plan-controller"],"operationId":"updateSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/shopping-lists/{listId}/items/{itemId}":{"delete":{"tags":["shopping-list-controller"],"operationId":"deleteItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["shopping-list-controller"],"operationId":"checkItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/pantry-items/{id}":{"delete":{"tags":["pantry-controller"],"operationId":"deleteItem_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["pantry-controller"],"operationId":"updateItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/ingredients/{id}":{"patch":{"tags":["ingredient-controller"],"operationId":"patchIngredient","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientPatchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}},"/v1/auth/me":{"get":{"tags":["auth-controller"],"operationId":"me","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}},"patch":{"tags":["auth-controller"],"operationId":"updateProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users/{id}":{"patch":{"tags":["admin-controller"],"operationId":"updateUser","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/week-plans/{id}/variety-score":{"get":{"tags":["week-plan-controller"],"operationId":"getVarietyScore","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VarietyScoreResponse"}}}}}}},"/v1/week-plans/{id}/suggestions":{"get":{"tags":["week-plan-controller"],"operationId":"getSuggestions","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotDate","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"tags","in":"query","required":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"topN","in":"query","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SuggestionResponse"}}}}}}},"/v1/shopping-lists/{id}":{"get":{"tags":["shopping-list-controller"],"operationId":"getShoppingList","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/shopping-list":{"get":{"tags":["shopping-list-controller"],"operationId":"getByWeekStart","parameters":[{"name":"weekStart","in":"query","required":false,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/ingredients":{"get":{"tags":["ingredient-controller"],"operationId":"searchIngredients","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isStaple","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}}},"/v1/households/mine":{"get":{"tags":["household-controller"],"operationId":"getMyHousehold","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/members":{"get":{"tags":["household-controller"],"operationId":"getMembers","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}}}}}},"/v1/admin/audit-log":{"get":{"tags":["admin-controller"],"operationId":"listAuditLog","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"targetUserId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAuditLogResponse"}}}}}}}},"components":{"schemas":{"IngredientEntry":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"newIngredientName":{"type":"string","maxLength":200,"minLength":0},"quantity":{"type":"number","minimum":0.01},"unit":{"type":"string","maxLength":20,"minLength":0},"sortOrder":{"type":"integer","format":"int32"}},"required":["quantity","unit"]},"RecipeCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":200,"minLength":0},"serves":{"type":"integer","format":"int32","maximum":20,"minimum":1},"cookTimeMin":{"type":"integer","format":"int32","minimum":0},"effort":{"type":"string","minLength":1,"pattern":"easy|medium|hard"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string","maxLength":500,"minLength":0},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientEntry"},"minItems":1},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepEntry"}},"tagIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1}},"required":["effort","ingredients","name","tagIds"]},"StepEntry":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32","minimum":1},"instruction":{"type":"string","minLength":1}},"required":["instruction"]},"CategoryRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"IngredientItem":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"sortOrder":{"type":"integer","format":"int32"}}},"RecipeDetailResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientItem"}},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepItem"}},"tags":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}},"StepItem":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32"},"instruction":{"type":"string"}}},"TagItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"CreateWeekPlanRequest":{"type":"object","properties":{"weekStart":{"type":"string","format":"date"}},"required":["weekStart"]},"SlotRecipe":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"effort":{"type":"string"},"cookTimeMin":{"type":"integer","format":"int32"},"heroImageUrl":{"type":"string"}}},"SlotResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slotDate":{"type":"string","format":"date"},"recipe":{"$ref":"#/components/schemas/SlotRecipe"}}},"WeekPlanResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekStart":{"type":"string","format":"date"},"status":{"type":"string"},"confirmedAt":{"type":"string","format":"date-time"},"slots":{"type":"array","items":{"$ref":"#/components/schemas/SlotResponse"}}}},"CreateSlotRequest":{"type":"object","properties":{"slotDate":{"type":"string","format":"date"},"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId","slotDate"]},"RecipeRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"ShoppingListItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"isChecked":{"type":"boolean"},"checkedBy":{"type":"string","format":"uuid"},"sourceRecipes":{"type":"array","items":{"$ref":"#/components/schemas/RecipeRef"}}}},"ShoppingListResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekPlanId":{"type":"string","format":"uuid"},"generatedAt":{"type":"string","format":"date-time"},"filteredStaplesCount":{"type":"integer","format":"int32"},"items":{"type":"array","items":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}},"TagCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0},"tagType":{"type":"string","minLength":1,"pattern":"protein|dietary|cuisine|other"}},"required":["name","tagType"]},"TagResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"AddItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"}}},"CreatePantryItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"PantryItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"AcceptInviteResponse":{"type":"object","properties":{"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"role":{"type":"string"}}},"ApiResponseAcceptInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AcceptInviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"Meta":{"type":"object","properties":{"pagination":{"$ref":"#/components/schemas/Pagination"}}},"Pagination":{"type":"object","properties":{"total":{"type":"integer","format":"int64"},"limit":{"type":"integer","format":"int32"},"offset":{"type":"integer","format":"int32"},"hasMore":{"type":"boolean"}}},"IngredientCategoryCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0}},"required":["name"]},"IngredientCategoryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"CreateHouseholdRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":100,"minLength":0}},"required":["name"]},"ApiResponseHouseholdResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/HouseholdResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"HouseholdResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"members":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}},"MemberResponse":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"},"displayName":{"type":"string"},"role":{"type":"string"},"joinedAt":{"type":"string","format":"date-time"}}},"ApiResponseInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/InviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"InviteResponse":{"type":"object","properties":{"inviteCode":{"type":"string"},"shareUrl":{"type":"string"},"expiresAt":{"type":"string","format":"date-time"}}},"CreateCookingLogRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"},"cookedOn":{"type":"string","format":"date"}},"required":["recipeId"]},"CookingLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"recipeId":{"type":"string","format":"uuid"},"recipeName":{"type":"string"},"cookedOn":{"type":"string","format":"date"},"cookedBy":{"type":"string","format":"uuid"}}},"SignupRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","maxLength":2147483647,"minLength":8},"displayName":{"type":"string","maxLength":100,"minLength":0}},"required":["displayName","email","password"]},"ApiResponseUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/UserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"UserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"householdRole":{"type":"string"},"systemRole":{"type":"string"}}},"LoginRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","minLength":1}},"required":["email","password"]},"CreateUserRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"displayName":{"type":"string","maxLength":100,"minLength":0},"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"systemRole":{"type":"string"}},"required":["displayName","email","tempPassword"]},"AdminUserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"ApiResponseAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminUserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordRequest":{"type":"object","properties":{"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"reason":{"type":"string"}},"required":["tempPassword"]},"ApiResponseResetPasswordResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/ResetPasswordResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordResponse":{"type":"object","properties":{"message":{"type":"string"},"mustChangePassword":{"type":"boolean"}}},"UpdateSlotRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId"]},"CheckItemRequest":{"type":"object","properties":{"isChecked":{"type":"boolean"}}},"UpdatePantryItemRequest":{"type":"object","properties":{"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"IngredientPatchRequest":{"type":"object","properties":{"name":{"type":"string"},"isStaple":{"type":"boolean"},"categoryId":{"type":"string","format":"uuid"}}},"IngredientResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"isStaple":{"type":"boolean"}}},"UpdateProfileRequest":{"type":"object","properties":{"displayName":{"type":"string","maxLength":100,"minLength":0},"currentPassword":{"type":"string"},"newPassword":{"type":"string","maxLength":2147483647,"minLength":8}}},"UpdateUserRequest":{"type":"object","properties":{"displayName":{"type":"string"},"email":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"}}},"IngredientOverlap":{"type":"object","properties":{"ingredientName":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"TagRepeat":{"type":"object","properties":{"tagName":{"type":"string"},"tagType":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"VarietyScoreResponse":{"type":"object","properties":{"score":{"type":"number","format":"double"},"tagRepeats":{"type":"array","items":{"$ref":"#/components/schemas/TagRepeat"}},"ingredientOverlaps":{"type":"array","items":{"$ref":"#/components/schemas/IngredientOverlap"}},"recentRepeats":{"type":"array","items":{"type":"string"}},"duplicatesInPlan":{"type":"array","items":{"type":"string"}}}},"SuggestionItem":{"type":"object","properties":{"recipe":{"$ref":"#/components/schemas/SlotRecipe"},"simulatedScore":{"type":"number","format":"double"}}},"SuggestionResponse":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/SuggestionItem"}}}},"ApiResponseListRecipeSummaryResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RecipeSummaryResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"RecipeSummaryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"}}},"ApiResponseListAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AdminUserResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"ApiResponseListAuditLogResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AuditLogResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"AuditLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"adminId":{"type":"string","format":"uuid"},"adminEmail":{"type":"string"},"targetUserId":{"type":"string","format":"uuid"},"targetEmail":{"type":"string"},"action":{"type":"string"},"detail":{"type":"object","additionalProperties":{}},"performedAt":{"type":"string","format":"date-time"}}}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"paths":{"/v1/recipes/{id}":{"get":{"tags":["recipe-controller"],"operationId":"getRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"put":{"tags":["recipe-controller"],"operationId":"updateRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}},"delete":{"tags":["recipe-controller"],"operationId":"deleteRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/v1/week-plans":{"get":{"tags":["week-plan-controller"],"operationId":"getWeekPlan","parameters":[{"name":"weekStart","in":"query","required":true,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}},"post":{"tags":["week-plan-controller"],"operationId":"createWeekPlan","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWeekPlanRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/week-plans/{id}/slots":{"post":{"tags":["week-plan-controller"],"operationId":"addSlot","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/week-plans/{id}/shopping-list":{"post":{"tags":["shopping-list-controller"],"operationId":"generateFromPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/week-plans/{id}/confirm":{"post":{"tags":["week-plan-controller"],"operationId":"confirmPlan","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WeekPlanResponse"}}}}}}},"/v1/tags":{"get":{"tags":["tag-controller"],"operationId":"listTags","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"post":{"tags":["tag-controller"],"operationId":"createTag","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TagResponse"}}}}}}},"/v1/shopping-lists/{id}/items":{"post":{"tags":["shopping-list-controller"],"operationId":"addItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddItemRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/recipes":{"get":{"tags":["recipe-controller"],"operationId":"listRecipes","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"effort","in":"query","required":false,"schema":{"type":"string"}},{"name":"isChildFriendly","in":"query","required":false,"schema":{"type":"boolean"}},{"name":"cookTimeMin.lte","in":"query","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"sort","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListRecipeSummaryResponse"}}}}}},"post":{"tags":["recipe-controller"],"operationId":"createRecipe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecipeCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeDetailResponse"}}}}}}},"/v1/pantry-items":{"get":{"tags":["pantry-controller"],"operationId":"listItems","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"post":{"tags":["pantry-controller"],"operationId":"createItem","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/invites/{code}/accept":{"post":{"tags":["household-controller"],"operationId":"acceptInvite","parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAcceptInviteResponse"}}}}}}},"/v1/ingredient-categories":{"get":{"tags":["ingredient-category-controller"],"operationId":"listCategories","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"post":{"tags":["ingredient-category-controller"],"operationId":"createCategory","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientCategoryCreateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientCategoryResponse"}}}}}}},"/v1/households":{"post":{"tags":["household-controller"],"operationId":"createHousehold","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateHouseholdRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/invites":{"post":{"tags":["household-controller"],"operationId":"createInvite","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseInviteResponse"}}}}}}},"/v1/cooking-logs":{"get":{"tags":["cooking-log-controller"],"operationId":"listCookingLogs","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":30}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"post":{"tags":["cooking-log-controller"],"operationId":"createCookingLog","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCookingLogRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CookingLogResponse"}}}}}}},"/v1/auth/signup":{"post":{"tags":["auth-controller"],"operationId":"signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/auth/logout":{"post":{"tags":["auth-controller"],"operationId":"logout","responses":{"200":{"description":"OK"}}}},"/v1/auth/login":{"post":{"tags":["auth-controller"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users":{"get":{"tags":["admin-controller"],"operationId":"listUsers","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isActive","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAdminUserResponse"}}}}}},"post":{"tags":["admin-controller"],"operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/admin/users/{id}/reset-password":{"post":{"tags":["admin-controller"],"operationId":"resetPassword","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResetPasswordRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseResetPasswordResponse"}}}}}}},"/v1/week-plans/{planId}/slots/{slotId}":{"delete":{"tags":["week-plan-controller"],"operationId":"deleteSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["week-plan-controller"],"operationId":"updateSlot","parameters":[{"name":"planId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSlotRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlotResponse"}}}}}}},"/v1/shopping-lists/{listId}/items/{itemId}":{"delete":{"tags":["shopping-list-controller"],"operationId":"deleteItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["shopping-list-controller"],"operationId":"checkItem","parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"itemId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}}}}},"/v1/pantry-items/{id}":{"delete":{"tags":["pantry-controller"],"operationId":"deleteItem_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["pantry-controller"],"operationId":"updateItem","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePantryItemRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PantryItemResponse"}}}}}}},"/v1/ingredients/{id}":{"patch":{"tags":["ingredient-controller"],"operationId":"patchIngredient","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngredientPatchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}},"/v1/auth/me":{"get":{"tags":["auth-controller"],"operationId":"me","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}},"patch":{"tags":["auth-controller"],"operationId":"updateProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseUserResponse"}}}}}}},"/v1/admin/users/{id}":{"patch":{"tags":["admin-controller"],"operationId":"updateUser","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAdminUserResponse"}}}}}}},"/v1/week-plans/{id}/variety-score":{"get":{"tags":["week-plan-controller"],"operationId":"getVarietyScore","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VarietyScoreResponse"}}}}}}},"/v1/week-plans/{id}/suggestions":{"get":{"tags":["week-plan-controller"],"operationId":"getSuggestions","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"slotDate","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"tags","in":"query","required":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"topN","in":"query","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SuggestionResponse"}}}}}}},"/v1/shopping-lists/{id}":{"get":{"tags":["shopping-list-controller"],"operationId":"getShoppingList","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/shopping-list":{"get":{"tags":["shopping-list-controller"],"operationId":"getByWeekStart","parameters":[{"name":"weekStart","in":"query","required":false,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ShoppingListResponse"}}}}}}},"/v1/ingredients":{"get":{"tags":["ingredient-controller"],"operationId":"searchIngredients","parameters":[{"name":"search","in":"query","required":false,"schema":{"type":"string"}},{"name":"isStaple","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IngredientResponse"}}}}}}}},"/v1/households/mine":{"get":{"tags":["household-controller"],"operationId":"getMyHousehold","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseHouseholdResponse"}}}}}}},"/v1/households/mine/members":{"get":{"tags":["household-controller"],"operationId":"getMembers","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}}}}}},"/v1/admin/audit-log":{"get":{"tags":["admin-controller"],"operationId":"listAuditLog","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"targetUserId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListAuditLogResponse"}}}}}}}},"components":{"schemas":{"IngredientEntry":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"newIngredientName":{"type":"string","maxLength":200,"minLength":0},"quantity":{"type":"number","minimum":0.01},"unit":{"type":"string","maxLength":20,"minLength":0},"sortOrder":{"type":"integer","format":"int32"}},"required":["quantity","unit"]},"RecipeCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":200,"minLength":0},"serves":{"type":"integer","format":"int32","maximum":20,"minimum":1},"cookTimeMin":{"type":"integer","format":"int32","minimum":0},"effort":{"type":"string","minLength":1,"pattern":"easy|medium|hard"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string","maxLength":500,"minLength":0},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientEntry"},"minItems":1},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepEntry"}},"tagIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1}},"required":["effort","ingredients","name","tagIds"]},"StepEntry":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32","minimum":1},"instruction":{"type":"string","minLength":1}},"required":["instruction"]},"CategoryRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"IngredientItem":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"sortOrder":{"type":"integer","format":"int32"}}},"RecipeDetailResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientItem"}},"steps":{"type":"array","items":{"$ref":"#/components/schemas/StepItem"}},"tags":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}},"StepItem":{"type":"object","properties":{"stepNumber":{"type":"integer","format":"int32"},"instruction":{"type":"string"}}},"TagItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"CreateWeekPlanRequest":{"type":"object","properties":{"weekStart":{"type":"string","format":"date"}},"required":["weekStart"]},"SlotRecipe":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"effort":{"type":"string"},"cookTimeMin":{"type":"integer","format":"int32"},"heroImageUrl":{"type":"string"}}},"SlotResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slotDate":{"type":"string","format":"date"},"recipe":{"$ref":"#/components/schemas/SlotRecipe"}}},"WeekPlanResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekStart":{"type":"string","format":"date"},"status":{"type":"string"},"confirmedAt":{"type":"string","format":"date-time"},"slots":{"type":"array","items":{"$ref":"#/components/schemas/SlotResponse"}}}},"CreateSlotRequest":{"type":"object","properties":{"slotDate":{"type":"string","format":"date"},"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId","slotDate"]},"RecipeRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"ShoppingListItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"isChecked":{"type":"boolean"},"checkedBy":{"type":"string","format":"uuid"},"sourceRecipes":{"type":"array","items":{"$ref":"#/components/schemas/RecipeRef"}}}},"ShoppingListResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"weekPlanId":{"type":"string","format":"uuid"},"generatedAt":{"type":"string","format":"date-time"},"filteredStaplesCount":{"type":"integer","format":"int32"},"items":{"type":"array","items":{"$ref":"#/components/schemas/ShoppingListItemResponse"}}}},"TagCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0},"tagType":{"type":"string","minLength":1,"pattern":"protein|dietary|cuisine|other"}},"required":["name","tagType"]},"TagResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"tagType":{"type":"string"}}},"AddItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"}}},"CreatePantryItemRequest":{"type":"object","properties":{"ingredientId":{"type":"string","format":"uuid"},"customName":{"type":"string"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"PantryItemResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ingredientId":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"AcceptInviteResponse":{"type":"object","properties":{"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"role":{"type":"string"}}},"ApiResponseAcceptInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AcceptInviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"Meta":{"type":"object","properties":{"pagination":{"$ref":"#/components/schemas/Pagination"}}},"Pagination":{"type":"object","properties":{"total":{"type":"integer","format":"int64"},"limit":{"type":"integer","format":"int32"},"offset":{"type":"integer","format":"int32"},"hasMore":{"type":"boolean"}}},"IngredientCategoryCreateRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":50,"minLength":0}},"required":["name"]},"IngredientCategoryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"CreateHouseholdRequest":{"type":"object","properties":{"name":{"type":"string","maxLength":100,"minLength":0}},"required":["name"]},"ApiResponseHouseholdResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/HouseholdResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"HouseholdResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"members":{"type":"array","items":{"$ref":"#/components/schemas/MemberResponse"}}}},"MemberResponse":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"},"displayName":{"type":"string"},"role":{"type":"string"},"joinedAt":{"type":"string","format":"date-time"}}},"ApiResponseInviteResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/InviteResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"InviteResponse":{"type":"object","properties":{"inviteCode":{"type":"string"},"shareUrl":{"type":"string"},"expiresAt":{"type":"string","format":"date-time"}}},"CreateCookingLogRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"},"cookedOn":{"type":"string","format":"date"}},"required":["recipeId"]},"CookingLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"recipeId":{"type":"string","format":"uuid"},"recipeName":{"type":"string"},"cookedOn":{"type":"string","format":"date"},"cookedBy":{"type":"string","format":"uuid"}}},"SignupRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","maxLength":2147483647,"minLength":8},"displayName":{"type":"string","maxLength":100,"minLength":0}},"required":["displayName","email","password"]},"ApiResponseUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/UserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"UserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"householdId":{"type":"string","format":"uuid"},"householdName":{"type":"string"},"householdRole":{"type":"string"},"systemRole":{"type":"string"}}},"LoginRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"password":{"type":"string","minLength":1}},"required":["email","password"]},"CreateUserRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","minLength":1},"displayName":{"type":"string","maxLength":100,"minLength":0},"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"systemRole":{"type":"string"}},"required":["displayName","email","tempPassword"]},"AdminUserResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"displayName":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"ApiResponseAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/AdminUserResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordRequest":{"type":"object","properties":{"tempPassword":{"type":"string","maxLength":2147483647,"minLength":8},"reason":{"type":"string"}},"required":["tempPassword"]},"ApiResponseResetPasswordResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"$ref":"#/components/schemas/ResetPasswordResponse"},"meta":{"$ref":"#/components/schemas/Meta"}}},"ResetPasswordResponse":{"type":"object","properties":{"message":{"type":"string"},"mustChangePassword":{"type":"boolean"}}},"UpdateSlotRequest":{"type":"object","properties":{"recipeId":{"type":"string","format":"uuid"}},"required":["recipeId"]},"CheckItemRequest":{"type":"object","properties":{"isChecked":{"type":"boolean"}}},"UpdatePantryItemRequest":{"type":"object","properties":{"quantity":{"type":"number"},"unit":{"type":"string"},"bestBefore":{"type":"string","format":"date"},"openedOn":{"type":"string","format":"date"}}},"IngredientPatchRequest":{"type":"object","properties":{"name":{"type":"string"},"isStaple":{"type":"boolean"},"categoryId":{"type":"string","format":"uuid"}}},"IngredientResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"category":{"$ref":"#/components/schemas/CategoryRef"},"isStaple":{"type":"boolean"}}},"UpdateProfileRequest":{"type":"object","properties":{"displayName":{"type":"string","maxLength":100,"minLength":0},"currentPassword":{"type":"string"},"newPassword":{"type":"string","maxLength":2147483647,"minLength":8}}},"UpdateUserRequest":{"type":"object","properties":{"displayName":{"type":"string"},"email":{"type":"string"},"systemRole":{"type":"string"},"isActive":{"type":"boolean"}}},"IngredientOverlap":{"type":"object","properties":{"ingredientName":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"TagRepeat":{"type":"object","properties":{"tagName":{"type":"string"},"tagType":{"type":"string"},"days":{"type":"array","items":{"type":"string","format":"date"}}}},"VarietyScoreResponse":{"type":"object","properties":{"score":{"type":"number","format":"double"},"tagRepeats":{"type":"array","items":{"$ref":"#/components/schemas/TagRepeat"}},"ingredientOverlaps":{"type":"array","items":{"$ref":"#/components/schemas/IngredientOverlap"}},"recentRepeats":{"type":"array","items":{"type":"string"}},"duplicatesInPlan":{"type":"array","items":{"type":"string"}}}},"SuggestionItem":{"type":"object","properties":{"recipe":{"$ref":"#/components/schemas/SlotRecipe"},"scoreDelta":{"type":"number","format":"double"},"hasConflict":{"type":"boolean"}}},"SuggestionResponse":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/SuggestionItem"}}}},"ApiResponseListRecipeSummaryResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/RecipeSummaryResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"RecipeSummaryResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"serves":{"type":"integer","format":"int32"},"cookTimeMin":{"type":"integer","format":"int32"},"effort":{"type":"string"},"isChildFriendly":{"type":"boolean"},"heroImageUrl":{"type":"string"}}},"ApiResponseListAdminUserResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AdminUserResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"ApiResponseListAuditLogResponse":{"type":"object","properties":{"status":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/AuditLogResponse"}},"meta":{"$ref":"#/components/schemas/Meta"}}},"AuditLogResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"adminId":{"type":"string","format":"uuid"},"adminEmail":{"type":"string"},"targetUserId":{"type":"string","format":"uuid"},"targetEmail":{"type":"string"},"action":{"type":"string"},"detail":{"type":"object","additionalProperties":{}},"performedAt":{"type":"string","format":"date-time"}}}}}} \ No newline at end of file diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index 74d1952..9ee4ede 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -914,7 +914,8 @@ export interface components { SuggestionItem: { recipe?: components["schemas"]["SlotRecipe"]; /** Format: double */ - simulatedScore?: number; + scoreDelta?: number; + hasConflict?: boolean; }; SuggestionResponse: { suggestions?: components["schemas"]["SuggestionItem"][]; diff --git a/frontend/src/lib/planner/MealActionSheet.svelte b/frontend/src/lib/planner/MealActionSheet.svelte index d0b6fde..d5a5d44 100644 --- a/frontend/src/lib/planner/MealActionSheet.svelte +++ b/frontend/src/lib/planner/MealActionSheet.svelte @@ -17,9 +17,10 @@ slot: Slot; onswap: () => void; oncancel: () => void; + onremove?: () => void; } - let { open, slot, onswap, oncancel }: Props = $props(); + let { open, slot, onswap, oncancel, onremove }: Props = $props(); const meta = $derived.by(() => { const parts: string[] = []; @@ -82,6 +83,16 @@ ↻ Gericht tauschen + {#if onremove} + + {/if} + {#if slot.recipe} { @@ -28,14 +29,29 @@ describe('MealActionSheet', () => { expect(screen.getByText(/easy/i)).toBeTruthy(); }); - it('renders all 4 action buttons', () => { + it('renders all 5 action buttons', () => { render(MealActionSheet, { props: baseProps }); expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy(); expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy(); expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /Entfernen/i })).toBeTruthy(); expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy(); }); + it('clicking Entfernen calls onremove', async () => { + const onremove = vi.fn(); + const user = userEvent.setup(); + render(MealActionSheet, { props: { ...baseProps, onremove } }); + await user.click(screen.getByRole('button', { name: /Entfernen/i })); + expect(onremove).toHaveBeenCalledOnce(); + }); + + it('does not render Entfernen button when onremove is not provided', () => { + const { onremove: _, ...propsWithoutRemove } = baseProps; + render(MealActionSheet, { props: propsWithoutRemove }); + expect(screen.queryByRole('button', { name: /Entfernen/i })).toBeNull(); + }); + it('Jetzt kochen links to the cook route', () => { render(MealActionSheet, { props: baseProps }); const link = screen.getByRole('link', { name: /Jetzt kochen/i }); diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte index 1d2ffde..e55de14 100644 --- a/frontend/src/lib/planner/RecipePicker.svelte +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -1,40 +1,50 @@ +{#snippet scoreBadge(recipeId: string, delta: number, hasConflict: boolean)} + {#if delta > 0} + + ↑ +{delta.toFixed(1)} Punkte + + {:else if hasConflict} + + ↓ {delta.toFixed(1)} Punkte + + {:else} + + Kein Einfluss + + {/if} +{/snippet} +
- -
-

- Rezept wählen -

-

- {dateLabel} -

-
+ + {#if !replacingRecipe} +
+

+ Rezept wählen +

+

+ {dateLabel} +

+
+ {/if} + + + {#if replacingRecipe} +
+

+ Wird ersetzt +

+ + {replacingRecipe.name}{#if replacingRecipe.meta} · {replacingRecipe.meta}{/if} + +
+ {/if}
@@ -71,98 +127,118 @@
- {#if suggestions.length > 0} -
- Empfohlen · Beste Abwechslung -
- - {#each suggestions as suggestion (suggestion.recipe.id)} - {@const delta = suggestion.simulatedScore - currentVarietyScore} - {@const meta = recipeMetadata(suggestion.recipe)} -
-
-

- {suggestion.recipe.name} -

- {#if meta} -

- {meta} -

- {/if} - {#if delta > 0} - - ↑ +{delta.toFixed(0)} Punkte - - {:else} - - ⚠ Variationskonflikt - - {/if} -
- +
+
+
+
+
+
+ {/each} +
+ {:else if topRecommendations.length > 0} +
+
+ Empfohlen · Beste Abwechslung
- {/each} + + {#each topRecommendations as suggestion (suggestion.recipe.id)} + {@const meta = recipeMetadata(suggestion.recipe)} +
+
+

+ {suggestion.recipe.name} +

+ {#if meta} +

+ {meta} +

+ {/if} + {@render scoreBadge(suggestion.recipe.id, suggestion.scoreDelta ?? 0, suggestion.hasConflict)} +
+ +
+ {/each} +
{/if} -
- Alle Rezepte -
+
+
+ Alle Rezepte +
- {#if filteredRecipes.length === 0} -

- Keine Treffer -

- {:else} - {#each filteredRecipes as recipe (recipe.id)} - {@const meta = recipeMetadata(recipe)} -
-
-

- {recipe.name} -

- {#if meta} -

- {meta} -

- {/if} -
- -
- {/each} - {/if} +
+

+ {recipe.name} +

+ {#if meta} +

+ {meta} +

+ {/if} + {#if score} + {@render scoreBadge(recipe.id, score.scoreDelta ?? 0, score.hasConflict)} + {/if} +
+ +
+ {/each} + {/if} + + + diff --git a/frontend/src/lib/planner/RecipePicker.test.ts b/frontend/src/lib/planner/RecipePicker.test.ts index 4ef320b..8b513d8 100644 --- a/frontend/src/lib/planner/RecipePicker.test.ts +++ b/frontend/src/lib/planner/RecipePicker.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; +import { render, screen, within } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; import RecipePicker from './RecipePicker.svelte'; const suggestions = [ - { recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, simulatedScore: 9.5 }, - { recipe: { id: 's2', name: 'Hähnchen-Curry', effort: 'easy', cookTimeMin: 35 }, simulatedScore: 6.0 } + { recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, scoreDelta: 1.5, hasConflict: false }, + { recipe: { id: 's2', name: 'Hähnchen-Curry', effort: 'easy', cookTimeMin: 35 }, scoreDelta: -1.5, hasConflict: true } ]; const allRecipes = [ @@ -18,7 +18,6 @@ const baseProps = { planId: 'plan-1', date: '2026-04-05', dateLabel: 'Samstag, 5. April', - currentVarietyScore: 7.5, suggestions, allRecipes, onpick: vi.fn() @@ -35,24 +34,32 @@ describe('RecipePicker', () => { expect(screen.getByText(/Empfohlen/i)).toBeTruthy(); }); - it('shows all suggestion recipe names', () => { + it('shows only positive-delta suggestions in Empfohlen', () => { render(RecipePicker, { props: baseProps }); + // s1 (scoreDelta=1.5) appears in Empfohlen expect(screen.getByText('Lachsfilet')).toBeTruthy(); - expect(screen.getByText('Hähnchen-Curry')).toBeTruthy(); + // s2 (scoreDelta=-1.5) is excluded from Empfohlen; not in allRecipes either → absent + expect(screen.queryByText('Hähnchen-Curry')).toBeNull(); }); - it('shows green badge for suggestions with positive delta', () => { + it('shows green badge when hasConflict is false', () => { render(RecipePicker, { props: baseProps }); - // Lachsfilet: simulatedScore 9.5 - currentVarietyScore 7.5 = +2 → green badge + // Lachsfilet: hasConflict = false → green badge const badge = screen.getByTestId('badge-s1'); expect(badge.getAttribute('data-type')).toBe('good'); }); - it('shows yellow badge for suggestions with zero or negative delta', () => { - render(RecipePicker, { props: baseProps }); - // Hähnchen-Curry: 6.0 - 7.5 = -1.5 → yellow badge - const badge = screen.getByTestId('badge-s2'); - expect(badge.getAttribute('data-type')).toBe('warning'); + it('shows red delta badge in Alle Rezepte when hasConflict is true', () => { + // r2 is in allRecipes; scoring it negative via suggestions → red badge in Alle Rezepte + const withR2Scored = [ + ...suggestions, + { recipe: { id: 'r2', name: 'Spaghetti Carbonara', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: -1.5, hasConflict: true } + ]; + render(RecipePicker, { props: { ...baseProps, suggestions: withR2Scored } }); + const alleRezepte = screen.getByTestId('alle-rezepte-section'); + const badge = within(alleRezepte).getByTestId('badge-r2'); + expect(badge.getAttribute('data-type')).toBe('bad'); + expect(badge.textContent).toContain('-1.5'); }); it('shows Alle Rezepte section', () => { @@ -87,8 +94,8 @@ describe('RecipePicker', () => { const onpick = vi.fn(); render(RecipePicker, { props: { ...baseProps, onpick } }); const buttons = screen.getAllByRole('button', { name: /Wählen/i }); - // First 2 are suggestions, rest are allRecipes - await userEvent.click(buttons[2]); + // First 1 is the positive-delta suggestion (s1), rest are allRecipes + await userEvent.click(buttons[1]); expect(onpick).toHaveBeenCalledWith('r1', 'Beef Bourguignon'); }); @@ -98,4 +105,119 @@ describe('RecipePicker', () => { await userEvent.type(input, 'xyznotfound'); expect(screen.getByText(/Keine Treffer/i)).toBeTruthy(); }); + + it('shows yellow neutral badge in Alle Rezepte when scoreDelta is zero', () => { + // r1 is in allRecipes; scoring it neutral via suggestions → yellow badge in Alle Rezepte + const neutralSuggestions = [ + { recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard' as const, cookTimeMin: 150 }, scoreDelta: 0.0, hasConflict: false } + ]; + render(RecipePicker, { props: { ...baseProps, suggestions: neutralSuggestions } }); + const alleRezepte = screen.getByTestId('alle-rezepte-section'); + const badge = within(alleRezepte).getByTestId('badge-r1'); + expect(badge.getAttribute('data-type')).toBe('neutral'); + expect(badge.textContent).toContain('Kein Einfluss'); + }); + + it('Empfohlen shows only positive-delta suggestions, capped at 5', () => { + const sixImproving = Array.from({ length: 6 }, (_, i) => ({ + recipe: { id: `imp${i}`, name: `Improving ${i}`, effort: 'easy' as const, cookTimeMin: 20 }, + scoreDelta: 1.0, + hasConflict: false + })); + render(RecipePicker, { props: { ...baseProps, suggestions: sixImproving } }); + const empfohlen = screen.getByTestId('empfohlen-section'); + const buttons = empfohlen.querySelectorAll('button'); + expect(buttons).toHaveLength(5); + }); + + it('Empfohlen excludes neutral and negative suggestions', () => { + const mixed = [ + { recipe: { id: 'pos', name: 'Positiv', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: 1.0, hasConflict: false }, + { recipe: { id: 'neu', name: 'Neutral', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: 0.0, hasConflict: false }, + { recipe: { id: 'neg', name: 'Negativ', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: -1.0, hasConflict: true } + ]; + render(RecipePicker, { props: { ...baseProps, suggestions: mixed } }); + const empfohlen = screen.getByTestId('empfohlen-section'); + expect(empfohlen.textContent).toContain('Positiv'); + expect(empfohlen.textContent).not.toContain('Neutral'); + expect(empfohlen.textContent).not.toContain('Negativ'); + }); + + it('shows score badge inside Alle Rezepte for a recipe that has a matching suggestion', () => { + // r1 is in allRecipes; scoreDelta=-0.3 → not in Empfohlen (needs >0), but scoreMap provides badge + const withR1Scored = [ + ...suggestions, + { recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard' as const, cookTimeMin: 150 }, scoreDelta: -0.3, hasConflict: true } + ]; + render(RecipePicker, { props: { ...baseProps, suggestions: withR1Scored } }); + const alleRezepte = screen.getByTestId('alle-rezepte-section'); + const badge = within(alleRezepte).getByTestId('badge-r1'); + expect(badge.getAttribute('data-type')).toBe('bad'); + }); + + it('shows no badge in Alle Rezepte for recipes with no suggestion score', () => { + // r2 and r3 have no suggestion entry + render(RecipePicker, { props: baseProps }); + const alleRezepte = screen.getByTestId('alle-rezepte-section'); + expect(within(alleRezepte).queryByTestId('badge-r2')).toBeNull(); + expect(within(alleRezepte).queryByTestId('badge-r3')).toBeNull(); + }); + + it('shows loading skeleton instead of Empfohlen section when isLoading is true', () => { + render(RecipePicker, { props: { ...baseProps, isLoading: true } }); + expect(screen.getByTestId('suggestions-loading')).toBeTruthy(); + expect(screen.queryByText(/Empfohlen/i)).toBeNull(); + }); + + it('hides loading skeleton when isLoading is false and suggestions are present', () => { + render(RecipePicker, { props: { ...baseProps, isLoading: false } }); + expect(screen.queryByTestId('suggestions-loading')).toBeNull(); + expect(screen.getByText(/Empfohlen/i)).toBeTruthy(); + }); + + it('shows Wird ersetzt banner when replacingRecipe is provided', () => { + render(RecipePicker, { props: { ...baseProps, replacingRecipe: { name: 'Pasta', meta: '20 Min · easy' } } }); + expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy(); + expect(screen.getByTestId('replacing-name').textContent).toContain('Pasta'); + }); + + it('hides Wird ersetzt banner when replacingRecipe is not provided', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.queryByText(/Wird ersetzt/i)).toBeNull(); + }); + + it('hides Rezept wählen header when replacingRecipe is set', () => { + render(RecipePicker, { props: { ...baseProps, replacingRecipe: { name: 'Pasta' } } }); + expect(screen.queryByText(/Rezept wählen/i)).toBeNull(); + }); + + it('shows Rezept wählen header when replacingRecipe is not set', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.getByText(/Rezept wählen/i)).toBeTruthy(); + }); + + it('excludes recipe from Alle Rezepte when excludeRecipeId is set', () => { + render(RecipePicker, { props: { ...baseProps, excludeRecipeId: 'r2' } }); + expect(screen.queryByText('Spaghetti Carbonara')).toBeNull(); + expect(screen.getByText('Beef Bourguignon')).toBeTruthy(); + expect(screen.getByText('Tomatensuppe')).toBeTruthy(); + }); + + it('excludes recipe from Empfohlen when excludeRecipeId matches a positive-delta suggestion', () => { + // s1 (Lachsfilet, scoreDelta=1.5) would normally appear in Empfohlen + render(RecipePicker, { props: { ...baseProps, excludeRecipeId: 's1' } }); + expect(screen.queryByText('Lachsfilet')).toBeNull(); + }); + + it('disables Wählen buttons when isDisabled is true', () => { + render(RecipePicker, { props: { ...baseProps, isDisabled: true } }); + const buttons = screen.getAllByRole('button', { name: /Wählen/i }); + buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(true)); + }); + + it('enables Wählen buttons when isDisabled is false', () => { + render(RecipePicker, { props: { ...baseProps, isDisabled: false } }); + const buttons = screen.getAllByRole('button', { name: /Wählen/i }); + buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(false)); + }); }); diff --git a/frontend/src/lib/planner/SuggestionCard.svelte b/frontend/src/lib/planner/SuggestionCard.svelte deleted file mode 100644 index 3d3f85e..0000000 --- a/frontend/src/lib/planner/SuggestionCard.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - -
- -
- {rank} -
- - -
-

- {suggestion.recipe?.name ?? 'Unbekanntes Rezept'} -

- {#if metadata} -

{metadata}

- {/if} - - - {#if suggestion.reasoningType && suggestion.reasoningLabel} -
- {suggestion.reasoningType === 'good' ? '✓' : '⚠'} {suggestion.reasoningLabel} -
- {/if} -
- - -
- - - - - -
-
diff --git a/frontend/src/lib/planner/SuggestionCard.test.ts b/frontend/src/lib/planner/SuggestionCard.test.ts deleted file mode 100644 index 48ad994..0000000 --- a/frontend/src/lib/planner/SuggestionCard.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import SuggestionCard from './SuggestionCard.svelte'; - -const goodSuggestion = { - recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 }, - simulatedScore: 9.2, - reasoningType: 'good' as const, - reasoningLabel: 'Frisches Protein · Aufwandsbalance' -}; - -const warningSuggestion = { - recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 }, - simulatedScore: 6.1, - reasoningType: 'warning' as const, - reasoningLabel: 'Hähnchen schon 2 Tage dabei' -}; - -describe('SuggestionCard', () => { - it('renders recipe name', () => { - render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - expect(screen.getByText('Pasta al Limone')).toBeTruthy(); - }); - - it('renders rank number', () => { - render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - expect(screen.getByText('1')).toBeTruthy(); - }); - - it('renders cook time and effort metadata', () => { - render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - expect(screen.getByText(/25 Min/)).toBeTruthy(); - expect(screen.getByText(/Easy/)).toBeTruthy(); - }); - - it('renders green reasoning badge for good suggestions', () => { - render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - const badge = screen.getByTestId('reasoning-badge'); - expect(badge.getAttribute('data-type')).toBe('good'); - expect(badge.textContent).toContain('Frisches Protein'); - }); - - it('renders yellow reasoning badge for warnings', () => { - render(SuggestionCard, { props: { suggestion: warningSuggestion, rank: 2, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - const badge = screen.getByTestId('reasoning-badge'); - expect(badge.getAttribute('data-type')).toBe('warning'); - expect(badge.textContent).toContain('Hähnchen'); - }); - - it('renders a pick button/form', () => { - render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - expect(screen.getByRole('button', { name: /Wählen/i })).toBeTruthy(); - }); - - it('card without reasoning renders without crashing', () => { - const noReasoning = { ...goodSuggestion, reasoningType: undefined, reasoningLabel: undefined }; - render(SuggestionCard, { props: { suggestion: noReasoning, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); - expect(screen.getByText('Pasta al Limone')).toBeTruthy(); - }); -}); diff --git a/frontend/src/lib/planner/SwapSuggestionList.svelte b/frontend/src/lib/planner/SwapSuggestionList.svelte deleted file mode 100644 index 98ee619..0000000 --- a/frontend/src/lib/planner/SwapSuggestionList.svelte +++ /dev/null @@ -1,126 +0,0 @@ - - - -
-

- Wird ersetzt -

- - {replacingName}{#if replacingMeta} · {replacingMeta}{/if} - -
- - -

- Ersetzen durch (einfachste zuerst) -

- - -{#if visibleRecipes.length === 0} -

- Keine Rezepte verfügbar. -

-{:else} - {#each visibleRecipes as recipe (recipe.id)} - {@const meta = recipeMeta(recipe)} - {@const alreadyPlanned = currentWeekRecipeIds.has(recipe.id)} -
-
-

- {recipe.name} -

- {#if meta} -

- {meta} -

- {/if} - {#if alreadyPlanned} -

- ⚠ Bereits diese Woche -

- {/if} -
- -
- {/each} -{/if} - - -{#if oncancel} - -{/if} diff --git a/frontend/src/lib/planner/SwapSuggestionList.test.ts b/frontend/src/lib/planner/SwapSuggestionList.test.ts deleted file mode 100644 index 755f2cd..0000000 --- a/frontend/src/lib/planner/SwapSuggestionList.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { userEvent } from '@testing-library/user-event'; -import SwapSuggestionList from './SwapSuggestionList.svelte'; - -const recipes = [ - { id: 'r1', name: 'Quick carbonara', effort: 'easy', cookTimeMin: 20 }, - { id: 'r2', name: 'Chicken stir-fry', effort: 'easy', cookTimeMin: 25 }, - { id: 'r3', name: 'Mushroom risotto', effort: 'medium', cookTimeMin: 50 } -]; - -const baseProps = { - replacingName: 'Tomato pasta', - replacingMeta: '45 min · Easy', - recipes, - currentWeekRecipeIds: new Set(), - onpick: vi.fn() -}; - -describe('SwapSuggestionList', () => { - it('renders the Replacing banner', () => { - render(SwapSuggestionList, { props: baseProps }); - expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy(); - }); - - it('renders old meal name with strikethrough', () => { - render(SwapSuggestionList, { props: baseProps }); - const struck = screen.getByTestId('replacing-name'); - expect(struck.textContent).toContain('Tomato pasta'); - expect(getComputedStyle(struck).textDecoration || struck.style.textDecoration).toContain('line-through'); - }); - - it('replacing-name span has title attribute for full name', () => { - render(SwapSuggestionList, { props: baseProps }); - const struck = screen.getByTestId('replacing-name'); - expect(struck.getAttribute('title')).toBe('Tomato pasta'); - }); - - it('renders the easiest-first eyebrow label', () => { - render(SwapSuggestionList, { props: baseProps }); - expect(screen.getByText(/einfachste zuerst/i)).toBeTruthy(); - }); - - it('renders all recipe names', () => { - render(SwapSuggestionList, { props: baseProps }); - expect(screen.getByText('Quick carbonara')).toBeTruthy(); - expect(screen.getByText('Chicken stir-fry')).toBeTruthy(); - expect(screen.getByText('Mushroom risotto')).toBeTruthy(); - }); - - it('clicking Wählen calls onpick with recipeId and name', async () => { - const onpick = vi.fn(); - const user = userEvent.setup(); - render(SwapSuggestionList, { props: { ...baseProps, onpick } }); - const buttons = screen.getAllByRole('button', { name: /Wählen/i }); - await user.click(buttons[0]); - expect(onpick).toHaveBeenCalledWith('r1', 'Quick carbonara'); - }); - - it('shows already-planned warning for recipes in currentWeekRecipeIds', () => { - render(SwapSuggestionList, { - props: { ...baseProps, currentWeekRecipeIds: new Set(['r2']) } - }); - expect(screen.getByTestId('already-planned-r2')).toBeTruthy(); - }); - - it('does not show already-planned warning for recipes not in currentWeekRecipeIds', () => { - render(SwapSuggestionList, { props: baseProps }); - expect(screen.queryByTestId('already-planned-r1')).toBeNull(); - }); - - it('shows empty state when no recipes', () => { - render(SwapSuggestionList, { props: { ...baseProps, recipes: [] } }); - expect(screen.getByTestId('swap-empty-state')).toBeTruthy(); - }); - - it('excludes the recipe being replaced when excludeRecipeId is provided', () => { - render(SwapSuggestionList, { props: { ...baseProps, excludeRecipeId: 'r2' } }); - expect(screen.queryByText('Chicken stir-fry')).toBeNull(); - expect(screen.getByText('Quick carbonara')).toBeTruthy(); - expect(screen.getByText('Mushroom risotto')).toBeTruthy(); - }); - - it('shows all recipes when excludeRecipeId is not provided', () => { - render(SwapSuggestionList, { props: baseProps }); - expect(screen.getByText('Quick carbonara')).toBeTruthy(); - expect(screen.getByText('Chicken stir-fry')).toBeTruthy(); - expect(screen.getByText('Mushroom risotto')).toBeTruthy(); - }); - - it('disables all Wählen buttons when isLoading is true', () => { - render(SwapSuggestionList, { props: { ...baseProps, isLoading: true } }); - const buttons = screen.getAllByRole('button', { name: /Wählen/i }); - buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(true)); - }); - - it('Wählen buttons are enabled when isLoading is false', () => { - render(SwapSuggestionList, { props: { ...baseProps, isLoading: false } }); - const buttons = screen.getAllByRole('button', { name: /Wählen/i }); - buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(false)); - }); - - it('renders optional Abbrechen button when oncancel provided', () => { - render(SwapSuggestionList, { props: { ...baseProps, oncancel: vi.fn() } }); - expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy(); - }); - - it('does not render Abbrechen button when oncancel not provided', () => { - render(SwapSuggestionList, { props: baseProps }); - expect(screen.queryByRole('button', { name: /Abbrechen/i })).toBeNull(); - }); - - it('clicking Abbrechen calls oncancel', async () => { - const oncancel = vi.fn(); - const user = userEvent.setup(); - render(SwapSuggestionList, { props: { ...baseProps, oncancel } }); - await user.click(screen.getByRole('button', { name: /Abbrechen/i })); - expect(oncancel).toHaveBeenCalledOnce(); - }); -}); diff --git a/frontend/src/lib/planner/VarietyScoreCard.svelte b/frontend/src/lib/planner/VarietyScoreCard.svelte index 27dc086..68144c0 100644 --- a/frontend/src/lib/planner/VarietyScoreCard.svelte +++ b/frontend/src/lib/planner/VarietyScoreCard.svelte @@ -20,7 +20,7 @@
- {score} + {score.toFixed(1)} /10 Abwechslungs-Score diff --git a/frontend/src/lib/planner/VarietyScoreCard.test.ts b/frontend/src/lib/planner/VarietyScoreCard.test.ts index 2bfbdd1..fbe50c7 100644 --- a/frontend/src/lib/planner/VarietyScoreCard.test.ts +++ b/frontend/src/lib/planner/VarietyScoreCard.test.ts @@ -51,7 +51,13 @@ describe('VarietyScoreCard', () => { it('renders with score 0', () => { render(VarietyScoreCard, { props: { ...baseProps, score: 0 } }); - expect(screen.getByText('0')).toBeTruthy(); + expect(screen.getByText('0.0')).toBeTruthy(); + }); + + it('rounds floating-point scores to one decimal place', () => { + render(VarietyScoreCard, { props: { ...baseProps, score: 6.199999999999999 } }); + expect(screen.getByText('6.2')).toBeTruthy(); + expect(screen.queryByText('6.199999999999999')).toBeNull(); }); it('renders multiple ingredient overlap warnings', () => { diff --git a/frontend/src/lib/planner/types.ts b/frontend/src/lib/planner/types.ts new file mode 100644 index 0000000..1dae0f5 --- /dev/null +++ b/frontend/src/lib/planner/types.ts @@ -0,0 +1,12 @@ +export interface Recipe { + id: string; + name: string; + effort?: string; + cookTimeMin?: number; +} + +export interface Suggestion { + recipe: Recipe; + scoreDelta: number; + hasConflict: boolean; +} diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index ba03be8..a64bccd 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -7,10 +7,10 @@ import DayMealCard from '$lib/planner/DayMealCard.svelte'; import RecipePicker from '$lib/planner/RecipePicker.svelte'; import MealActionSheet from '$lib/planner/MealActionSheet.svelte'; - import SwapSuggestionList from '$lib/planner/SwapSuggestionList.svelte'; import BottomSheet from '$lib/components/BottomSheet.svelte'; import UndoBar from '$lib/planner/UndoBar.svelte'; - import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange, sortEasiestFirst } from '$lib/planner/week'; + import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week'; + import type { Suggestion } from '$lib/planner/types'; let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props(); @@ -63,14 +63,21 @@ let swapSheetOpen = $state(false); let swapLoading = $state(false); + const activePickerDate = $derived( + pickerOpen ? selectedDay + : swapSheetOpen ? selectedDay + : panelState.kind === 'recipe-picker' ? panelState.date + : null + ); + + let suggestions: Suggestion[] = $state([]); + let isLoadingSuggestions = $state(false); + // Recipes already in any slot this week — used for ⚠ overlap warnings let currentWeekRecipeIds = $derived( new Set(slots.filter((s: any) => s.recipe?.id).map((s: any) => s.recipe.id)) ); - // Recipes sorted easiest-first for the swap suggestion list - let sortedRecipes = $derived(sortEasiestFirst(data.recipes)); - // Hidden form field bindings let addPlanId = $state(''); let addSlotDate = $state(''); @@ -90,6 +97,23 @@ // UndoBar let undoVisible = $state(false); let undoMessage = $state(''); + let undoCallback = $state<(() => void) | null>(null); + + $effect(() => { + if (!activePickerDate || !weekPlan?.id) { + suggestions = []; + isLoadingSuggestions = false; + return; + } + const controller = new AbortController(); + isLoadingSuggestions = true; + fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`, { signal: controller.signal }) + .then((r) => r.json()) + .then((d) => { suggestions = d.suggestions ?? []; }) + .catch((e) => { if (e.name !== 'AbortError') suggestions = []; }) + .finally(() => { isLoadingSuggestions = false; }); + return () => controller.abort(); + }); function handleSelectDay(day: string) { selectedDay = day; @@ -136,7 +160,33 @@ function handleUndo() { undoVisible = false; + undoCallback?.(); + } + + async function handleRemoveMeal(slot: { id: string; slotDate: string; recipe: { id: string; name: string } | null }) { + // Capture primitive values immediately — slot may be a reactive proxy that + // becomes stale after the first await (tick flushes state + re-render). + const slotId = slot.id; + const slotDate = slot.slotDate; + const recipeName = slot.recipe?.name ?? ''; + const recipeId = slot.recipe?.id ?? ''; + if (!slotId || !recipeId) return; + + actionSheetOpen = false; + undoCallback = async () => { + addPlanId = weekPlan!.id; + addSlotDate = slotDate; + addRecipeId = recipeId; + addRecipeName = recipeName; + await tick(); + addSlotFormEl.requestSubmit(); + }; + delPlanId = weekPlan!.id; + delSlotId = slotId; + await tick(); deleteSlotFormEl.requestSubmit(); + undoMessage = `${recipeName} entfernt`; + undoVisible = true; } async function handleSwapPick(recipeId: string, recipeName: string) { @@ -282,19 +332,20 @@ planId={weekPlan?.id ?? ''} date={selectedDay} dateLabel={formatDayLabel(selectedDay)} - currentVarietyScore={varietyScore?.score ?? 0} - suggestions={[]} + suggestions={suggestions} allRecipes={data.recipes} + isLoading={isLoadingSuggestions} onpick={handleRecipePick} /> - + { actionSheetOpen = false; swapSheetOpen = true; }} oncancel={() => (actionSheetOpen = false)} + onremove={isPlanner && selectedSlot.id ? () => handleRemoveMeal(selectedSlot as any) : undefined} /> @@ -303,18 +354,18 @@ selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null, selectedSlot.recipe?.effort ?? null ].filter(Boolean).join(' · ')} -
- (swapSheetOpen = false)} - /> -
+
@@ -504,6 +555,15 @@ > Gericht tauschen + {#if detailSlot.id} + + {/if} {/if}
{:else} @@ -544,13 +604,16 @@ pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null, pickerSlot.recipe.effort ?? null ].filter(Boolean).join(' · ')} -
- +
@@ -560,9 +623,9 @@ planId={weekPlan?.id ?? ''} date={pickerDate} dateLabel={formatDayLabel(pickerDate)} - currentVarietyScore={varietyScore?.score ?? 0} - suggestions={[]} + suggestions={suggestions} allRecipes={data.recipes} + isLoading={isLoadingSuggestions} onpick={handleRecipePick} /> @@ -585,8 +648,10 @@ formData.set('recipeId', addRecipeId); return async ({ result, update }) => { if (result.type === 'success' && result.data?.success) { + const slotId = (result.data as any)?.slot?.id ?? ''; delPlanId = addPlanId; - delSlotId = (result.data as any)?.slot?.id ?? ''; + delSlotId = slotId; + undoCallback = () => deleteSlotFormEl.requestSubmit(); undoMessage = `${addRecipeName} hinzugefügt`; undoVisible = true; } @@ -613,6 +678,7 @@ if (result.type === 'success' && result.data?.success) { delPlanId = updPlanId; delSlotId = (result.data as any)?.slot?.id ?? ''; + undoCallback = () => deleteSlotFormEl.requestSubmit(); undoMessage = `${updRecipeName} eingetragen`; undoVisible = true; } diff --git a/frontend/src/routes/(app)/planner/+server.ts b/frontend/src/routes/(app)/planner/+server.ts index 910efc5..8a116f1 100644 --- a/frontend/src/routes/(app)/planner/+server.ts +++ b/frontend/src/routes/(app)/planner/+server.ts @@ -11,14 +11,18 @@ export const GET: RequestHandler = async ({ fetch, url }) => { return json({ suggestions: [] }); } - const api = apiClient(fetch); - const { data } = await api.GET('/v1/week-plans/{id}/suggestions', { - params: { path: { id: planId }, query: { slotDate: date } } - }); + try { + const api = apiClient(fetch); + const { data } = await api.GET('/v1/week-plans/{id}/suggestions', { + params: { path: { id: planId }, query: { slotDate: date, topN: 100 } } + }); - const suggestions = (data?.suggestions ?? []).sort( - (a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0) - ); + const suggestions = (data?.suggestions ?? []).sort( + (a: any, b: any) => (b.scoreDelta ?? 0) - (a.scoreDelta ?? 0) + ); - return json({ suggestions }); + return json({ suggestions }); + } catch { + return json({ suggestions: [] }); + } }; diff --git a/frontend/src/routes/(app)/planner/page.test.ts b/frontend/src/routes/(app)/planner/page.test.ts new file mode 100644 index 0000000..674d96b --- /dev/null +++ b/frontend/src/routes/(app)/planner/page.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import Page from './+page.svelte'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() })); +vi.mock('$app/forms', () => ({ + enhance: () => () => ({ destroy: () => {} }) +})); + +const PLAN_ID = 'plan-00000000-0000-0000-0000-000000000001'; +// Use a past week so "today" is never in this range — selectedDay defaults to weekStart (Monday) +const DATE = '2025-01-06'; // Monday, January 6 2025 + +const mockData = { + weekPlan: { id: PLAN_ID, weekStart: DATE, status: 'draft', slots: [] as any[] }, + varietyScore: null, + weekStart: DATE, + recipes: [{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }], + benutzer: { rolle: 'planer' } +}; + +const mockDataWithSlot = { + ...mockData, + weekPlan: { + id: PLAN_ID, + weekStart: DATE, + status: 'draft', + slots: [{ id: 'slot-1', slotDate: DATE, recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 } }] + } +}; + +const mockSuggestions = [ + { + recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 20 }, + scoreDelta: 1.5, + hasConflict: false + } +]; + +describe('+page.svelte — $effect suggestion fetch', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls fetch when picker opens with correct planId and date', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + json: () => Promise.resolve({ suggestions: mockSuggestions }) + }) + ); + + render(Page, { props: { data: mockData } }); + + await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + expect((fetch as any).mock.calls[0][0]).toContain(`planId=${PLAN_ID}`); + expect((fetch as any).mock.calls[0][0]).toContain(`date=${DATE}`); + }); + + it('shows suggestions in RecipePicker after fetch resolves', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + json: () => Promise.resolve({ suggestions: mockSuggestions }) + }) + ); + + render(Page, { props: { data: mockData } }); + + await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]); + + expect(await screen.findByText('Lachsfilet')).toBeTruthy(); + }); + + it('passes AbortSignal to fetch so inflight requests can be cancelled', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce({ + json: () => Promise.resolve({ suggestions: [] }) + }) + ); + + render(Page, { props: { data: mockData } }); + + await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + const fetchOptions = (fetch as any).mock.calls[0][1]; + expect(fetchOptions?.signal).toBeInstanceOf(AbortSignal); + }); +}); + +describe('+page.svelte — swap sheet suggestion fetch', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('opening mobile swap sheet triggers fetch with planId and date', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) })); + + render(Page, { props: { data: mockDataWithSlot } }); + + // Open action sheet, then swap sheet + await userEvent.click(screen.getByTestId('day-meal-card')); + await userEvent.click(await screen.findByRole('button', { name: /Gericht tauschen/i })); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + expect((fetch as any).mock.calls[0][0]).toContain(`planId=${PLAN_ID}`); + expect((fetch as any).mock.calls[0][0]).toContain(`date=${DATE}`); + }); +}); + +describe('+page.svelte — remove meal', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('clicking Entfernen in MealActionSheet shows undo bar with recipe name', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) })); + + render(Page, { props: { data: mockDataWithSlot } }); + + await userEvent.click(screen.getByTestId('day-meal-card')); + await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i })); + + const undoBar = screen.getByTestId('undo-bar'); + expect(undoBar).toBeTruthy(); + expect(within(undoBar).getByText(/Beef Bourguignon/)).toBeTruthy(); + }); + + it('clicking Rückgängig after remove hides the undo bar', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) })); + + render(Page, { props: { data: mockDataWithSlot } }); + + await userEvent.click(screen.getByTestId('day-meal-card')); + await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i })); + + await userEvent.click(screen.getByRole('button', { name: /Rückgängig/i })); + + expect(screen.queryByTestId('undo-bar')).toBeNull(); + }); +}); diff --git a/frontend/src/routes/(app)/planner/server.test.ts b/frontend/src/routes/(app)/planner/server.test.ts new file mode 100644 index 0000000..bd98219 --- /dev/null +++ b/frontend/src/routes/(app)/planner/server.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +const mockGet = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ GET: mockGet }) +})); + +const PLAN_UUID = '11111111-1111-1111-1111-111111111111'; +const DATE = '2026-04-09'; + +const mockSuggestions = [ + { recipe: { id: 'r1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, scoreDelta: 0.0, hasConflict: true }, + { recipe: { id: 'r2', name: 'Nudeln', effort: 'easy', cookTimeMin: 20 }, scoreDelta: -1.5, hasConflict: true } +]; + +describe('GET /planner — suggestions route handler', () => { + let GET: any; + + beforeEach(async () => { + mockGet.mockReset(); + vi.resetModules(); + const mod = await import('./+server'); + GET = mod.GET; + }); + + it('returns { suggestions: [] } when planId is missing', async () => { + const url = new URL('http://localhost/planner?date=' + DATE); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + expect(body).toEqual({ suggestions: [] }); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('returns { suggestions: [] } when date is missing', async () => { + const url = new URL('http://localhost/planner?planId=' + PLAN_UUID); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + expect(body).toEqual({ suggestions: [] }); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('returns sorted suggestions from backend on success', async () => { + mockGet.mockResolvedValueOnce({ data: { suggestions: mockSuggestions }, error: undefined }); + + const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + + expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({ + params: { path: { id: PLAN_UUID }, query: { slotDate: DATE, topN: 100 } } + })); + expect(body.suggestions).toHaveLength(2); + // sorted by scoreDelta desc: 0.0 before -1.5 + expect(body.suggestions[0].recipe.name).toBe('Lachsfilet'); + expect(body.suggestions[1].recipe.name).toBe('Nudeln'); + }); + + it('returns { suggestions: [] } when data is undefined (error response without data)', async () => { + mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); + + const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + + expect(body).toEqual({ suggestions: [] }); + }); + + it('returns { suggestions: [] } when backend throws (network error)', async () => { + mockGet.mockRejectedValueOnce(new Error('Network error')); + + const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + + expect(body).toEqual({ suggestions: [] }); + }); + + it('returns empty suggestions when backend returns empty array', async () => { + mockGet.mockResolvedValueOnce({ data: { suggestions: [] }, error: undefined }); + + const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`); + const response = await GET({ fetch: vi.fn(), url }); + const body = await response.json(); + + expect(body).toEqual({ suggestions: [] }); + }); +});