From d008a17735a41a62e454f01ccf79ca2b3d7b893a Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:33:52 +0200 Subject: [PATCH 01/30] feat(planner): replace simulatedScore with scoreDelta + hasConflict in SuggestionItem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SuggestionItem now exposes scoreDelta (simulatedScore − currentScore) and hasConflict (scoreDelta ≤ 0) so the frontend can render badges without needing to pass currentVarietyScore as a separate prop. PlanningService.getSuggestions() computes currentScore once per request and derives scoreDelta + hasConflict per candidate. Sorting is unchanged (scoreDelta desc = simulatedScore desc since currentScore is constant). Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/planning/PlanningService.java | 14 +- .../planning/dto/SuggestionResponse.java | 3 +- .../recipeapp/planning/SuggestionsTest.java | 182 ++++++++++++++---- .../planning/WeekPlanControllerTest.java | 5 +- 4 files changed, 165 insertions(+), 39 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 0e39c54..356c694 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -135,6 +135,12 @@ 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); + List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); Set lowerTagFilters = tagFilters.stream() @@ -145,11 +151,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(); 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..97eecb2 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,9 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(3); + // Empty plan → currentScore = 10.0; no conflicts → scoreDelta = 0.0 for all assertThat(result.suggestions()).allSatisfy(s -> - assertThat(s.simulatedScore()).isEqualTo(10.0)); + assertThat(s.scoreDelta()).isEqualTo(0.0)); } @Test @@ -221,6 +222,117 @@ class SuggestionsTest { } } + // ═══════════════════════════════════════════════════════════ + // Category 1b: scoreDelta and hasConflict + // ═══════════════════════════════════════════════════════════ + + @Nested + class ScoreDeltaAndHasConflict { + + @Test + void recipeWithNoConflictsOnEmptyPlanShouldHaveZeroDeltaAndHasConflict() { + // Empty plan → currentScore = 10.0. Clean recipe → simulatedScore = 10.0. + // scoreDelta = 0.0, hasConflict = (0.0 <= 0) = true + 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()).isTrue(); + } + + @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 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 +514,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 +540,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 +565,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 +604,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 +631,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 +660,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 +679,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 +745,7 @@ class SuggestionsTest { } @Test - void rankingOrderShouldBeBySimulatedScoreDescending() { + void rankingOrderShouldBeByScoreDeltaDescending() { var plan = createPlan(); var pastaTag = createTag("Pasta", "cuisine"); var tomato = createIngredient("Tomatoes", false); @@ -666,11 +780,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 +802,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 +840,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 +859,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 +868,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 +915,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 +930,7 @@ class SuggestionsTest { class EdgeCases { @Test - void recipeWithNoTagsOrIngredientsShouldGetPerfectScore() { + void recipeWithNoTagsOrIngredientsShouldGetZeroDelta() { var plan = createPlan(); var existingRecipe = createRecipe("Existing"); addSlot(plan, existingRecipe, MONDAY); @@ -832,7 +947,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 -- 2.49.1 From 7e97d2dc5805edd1a0e36d3deb69dfedce0a6cb0 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:35:33 +0200 Subject: [PATCH 02/30] chore(planner): delete orphaned SuggestionCard component and test Unused since the suggestions route was removed (commit 4333dc0). RecipePicker.test.ts is the active coverage for suggestion rendering. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/SuggestionCard.svelte | 83 ------------------- .../src/lib/planner/SuggestionCard.test.ts | 60 -------------- 2 files changed, 143 deletions(-) delete mode 100644 frontend/src/lib/planner/SuggestionCard.svelte delete mode 100644 frontend/src/lib/planner/SuggestionCard.test.ts 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(); - }); -}); -- 2.49.1 From b6ad64ea534788f25ff723cf749f2c4c5db22040 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:35:57 +0200 Subject: [PATCH 03/30] =?UTF-8?q?chore(api):=20update=20SuggestionItem=20s?= =?UTF-8?q?chema=20=E2=80=94=20scoreDelta=20+=20hasConflict=20replace=20si?= =?UTF-8?q?mulatedScore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/api/openapi.json | 2 +- frontend/src/lib/api/schema.d.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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"][]; -- 2.49.1 From 4549e9a7fdba996140a265e8a61bd377ec3b674e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:38:47 +0200 Subject: [PATCH 04/30] feat(planner): RecipePicker uses scoreDelta/hasConflict, drop currentVarietyScore, add isLoading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Suggestion interface: { recipe, scoreDelta, hasConflict } (no simulatedScore) - Badge renders from hasConflict directly — no client-side delta computation needed - New isLoading prop shows skeleton rows while suggestions fetch is in flight - currentVarietyScore prop removed from component and both call sites follow in next commit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/RecipePicker.svelte | 34 +++++++++++++++---- frontend/src/lib/planner/RecipePicker.test.ts | 25 ++++++++++---- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte index 1d2ffde..51940ac 100644 --- a/frontend/src/lib/planner/RecipePicker.svelte +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -8,24 +8,25 @@ interface Suggestion { recipe: Recipe; - simulatedScore: number; + scoreDelta: number; + hasConflict: boolean; } let { planId, date, dateLabel, - currentVarietyScore = 0, suggestions = [], allRecipes = [], + isLoading = false, onpick }: { planId: string; date: string; dateLabel: string; - currentVarietyScore?: number; suggestions: Suggestion[]; allRecipes: Recipe[]; + isLoading?: boolean; onpick: (recipeId: string, recipeName: string) => void; } = $props(); @@ -71,7 +72,27 @@ - {#if suggestions.length > 0} + {#if isLoading} +
+ {#each [1, 2, 3] as i (i)} +
+
+
+
+
+
+
+ {/each} +
+ {:else if suggestions.length > 0}
@@ -79,7 +100,6 @@
{#each suggestions as suggestion (suggestion.recipe.id)} - {@const delta = suggestion.simulatedScore - currentVarietyScore} {@const meta = recipeMetadata(suggestion.recipe)}
{/if} - {#if delta > 0} + {#if !suggestion.hasConflict} - ↑ +{delta.toFixed(0)} Punkte + ↑ +{suggestion.scoreDelta.toFixed(0)} Punkte {:else} { expect(screen.getByText('Hähnchen-Curry')).toBeTruthy(); }); - 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', () => { + it('shows yellow badge when hasConflict is true', () => { render(RecipePicker, { props: baseProps }); - // Hähnchen-Curry: 6.0 - 7.5 = -1.5 → yellow badge + // Hähnchen-Curry: hasConflict = true → yellow badge const badge = screen.getByTestId('badge-s2'); expect(badge.getAttribute('data-type')).toBe('warning'); }); @@ -98,4 +97,16 @@ describe('RecipePicker', () => { await userEvent.type(input, 'xyznotfound'); expect(screen.getByText(/Keine Treffer/i)).toBeTruthy(); }); + + 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(); + }); }); -- 2.49.1 From 59366b6e9c5983b88ff8c7f842c18d09da8bed7d Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:39:50 +0200 Subject: [PATCH 05/30] feat(planner): add server.test.ts for GET /planner, fix sort + add error handling - Sort uses scoreDelta instead of removed simulatedScore - try/catch degrades gracefully to suggestions=[] on backend errors - 6 tests cover: missing params, success, backend error, network throw, empty result Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/(app)/planner/+server.ts | 20 ++-- .../src/routes/(app)/planner/server.test.ts | 91 +++++++++++++++++++ 2 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 frontend/src/routes/(app)/planner/server.test.ts diff --git a/frontend/src/routes/(app)/planner/+server.ts b/frontend/src/routes/(app)/planner/+server.ts index 910efc5..af3aa80 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 } } + }); - 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/server.test.ts b/frontend/src/routes/(app)/planner/server.test.ts new file mode 100644 index 0000000..023d0fd --- /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 } } + })); + 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 backend returns error', 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: [] }); + }); +}); -- 2.49.1 From ab662691312e83eb289d2d3de8a760bcda983aed Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:46:25 +0200 Subject: [PATCH 06/30] feat(planner): lazy-fetch variety suggestions in RecipePicker for empty slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derives activePickerDate from mobile pickerOpen/selectedDay and desktop recipe-picker panel state, then uses $effect to fetch /planner?planId&date on demand — wires suggestions and isLoading into both RecipePicker instances. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/(app)/planner/+page.svelte | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index ba03be8..bf055d1 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -63,6 +63,15 @@ let swapSheetOpen = $state(false); let swapLoading = $state(false); + const activePickerDate = $derived( + pickerOpen ? selectedDay + : panelState.kind === 'recipe-picker' ? panelState.date + : null + ); + + let suggestions: any[] = $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)) @@ -91,6 +100,20 @@ let undoVisible = $state(false); let undoMessage = $state(''); + $effect(() => { + if (!activePickerDate || !weekPlan?.id) { + suggestions = []; + isLoadingSuggestions = false; + return; + } + isLoadingSuggestions = true; + fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`) + .then((r) => r.json()) + .then((d) => { suggestions = d.suggestions ?? []; }) + .catch(() => { suggestions = []; }) + .finally(() => { isLoadingSuggestions = false; }); + }); + function handleSelectDay(day: string) { selectedDay = day; panelState = { kind: 'day-detail', date: day }; @@ -282,9 +305,9 @@ planId={weekPlan?.id ?? ''} date={selectedDay} dateLabel={formatDayLabel(selectedDay)} - currentVarietyScore={varietyScore?.score ?? 0} - suggestions={[]} + suggestions={suggestions} allRecipes={data.recipes} + isLoading={isLoadingSuggestions} onpick={handleRecipePick} /> @@ -560,9 +583,9 @@ planId={weekPlan?.id ?? ''} date={pickerDate} dateLabel={formatDayLabel(pickerDate)} - currentVarietyScore={varietyScore?.score ?? 0} - suggestions={[]} + suggestions={suggestions} allRecipes={data.recipes} + isLoading={isLoadingSuggestions} onpick={handleRecipePick} />
-- 2.49.1 From 387d0705a4846f09a174929e4e6a90396b224331 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:00:37 +0200 Subject: [PATCH 07/30] fix(planner): guard scoreDelta against undefined in RecipePicker badge Defensive null-coalescing prevents crash when suggestion data arrives without scoreDelta (e.g. stale backend or mismatched schema). Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/RecipePicker.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte index 51940ac..ec3fd69 100644 --- a/frontend/src/lib/planner/RecipePicker.svelte +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -121,7 +121,7 @@ data-type="good" style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);" > - ↑ +{suggestion.scoreDelta.toFixed(0)} Punkte + ↑ +{(suggestion.scoreDelta ?? 0).toFixed(0)} Punkte {:else} Date: Thu, 9 Apr 2026 12:09:08 +0200 Subject: [PATCH 08/30] refactor(planner): extract MAX_VARIETY_SCORE constant in PlanningService Replaces magic literal 10.0 with a named constant in all four scoring sites: getSuggestions, getVarietyPreview, scoreFromSimulatedSlots, and getVarietyScore. Co-Authored-By: Claude Sonnet 4.6 --- .../com/recipeapp/planning/PlanningService.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 356c694..04fcf11 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; @@ -138,7 +140,7 @@ public class PlanningService { List currentSlots = plan.getSlots().stream() .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) .toList(); - double currentScore = currentSlots.isEmpty() ? 10.0 + double currentScore = currentSlots.isEmpty() ? MAX_VARIETY_SCORE : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); @@ -202,7 +204,7 @@ public class PlanningService { List currentSlots = plan.getSlots().stream() .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) .toList(); - double currentScore = currentSlots.isEmpty() ? 10.0 + double currentScore = currentSlots.isEmpty() ? MAX_VARIETY_SCORE : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds); @@ -255,12 +257,12 @@ public class PlanningService { .mapToLong(c -> c - 1) .sum(); - double score = 10.0; + double score = MAX_VARIETY_SCORE; score -= tagRepeatCount * wTagRepeat; score -= ingredientOverlapCount * wIngredientOverlap; score -= recentRepeatCount * wRecentRepeat; score -= duplicatePenaltyCount * wPlanDuplicate; - return Math.max(0, Math.min(10, score)); + return Math.max(0, Math.min(MAX_VARIETY_SCORE, score)); } @Transactional(readOnly = true) @@ -349,12 +351,12 @@ public class PlanningService { } // Calculate score - double score = 10.0; + double score = MAX_VARIETY_SCORE; score -= tagRepeats.size() * wTagRepeat; score -= overlaps.size() * wIngredientOverlap; score -= recentRepeats.size() * wRecentRepeat; score -= duplicatePenaltyCount * wPlanDuplicate; - score = Math.max(0, Math.min(10, score)); + score = Math.max(0, Math.min(MAX_VARIETY_SCORE, score)); return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan); } -- 2.49.1 From e17e8d4630d95f91bf2b056a96356fd3f5993176 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:10:33 +0200 Subject: [PATCH 09/30] test(planner): cover topN=0 and topN=-1 boundary in SuggestionsTest Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/planning/SuggestionsTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java index 97eecb2..333193d 100644 --- a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java +++ b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java @@ -205,6 +205,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(); -- 2.49.1 From f84a647b8d0dc6b5d3763bf7dbd1c700ec01d3d4 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:11:00 +0200 Subject: [PATCH 10/30] test(planner): assert hasConflict=true for neutral scoreDelta on empty plan Documents the surprising-but-correct behavior: recipes on an empty plan get scoreDelta=0.0, which satisfies scoreDelta<=0, so hasConflict=true. Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/recipeapp/planning/SuggestionsTest.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java index 333193d..b461ffb 100644 --- a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java +++ b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java @@ -179,9 +179,12 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(3); - // Empty plan → currentScore = 10.0; no conflicts → scoreDelta = 0.0 for all - assertThat(result.suggestions()).allSatisfy(s -> - assertThat(s.scoreDelta()).isEqualTo(0.0)); + // Empty plan → currentScore = 10.0; no penalties → scoreDelta = 0.0 for all + // hasConflict = (scoreDelta <= 0) = true even for neutral recipes + assertThat(result.suggestions()).allSatisfy(s -> { + assertThat(s.scoreDelta()).isEqualTo(0.0); + assertThat(s.hasConflict()).isTrue(); + }); } @Test -- 2.49.1 From 0a9e8032cf5f73a856b575b7ad774c522e47c02d Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:11:44 +0200 Subject: [PATCH 11/30] refactor(planner): extract computeCurrentScore helper in PlanningService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates duplicated currentSlots→score pattern that appeared in both getSuggestions and getVarietyPreview. Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/planning/PlanningService.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 04fcf11..c85dcc1 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -137,11 +137,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() ? MAX_VARIETY_SCORE - : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); + double currentScore = computeCurrentScore(plan, config, recentlyCookedIds); List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); @@ -184,6 +180,14 @@ public class PlanningService { 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) @@ -201,11 +205,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() ? MAX_VARIETY_SCORE - : 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); -- 2.49.1 From 539ca5d231f0da7a835deda9ed323f91a7e9360e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:15:17 +0200 Subject: [PATCH 12/30] feat(planner): add AbortController to suggestion fetch $effect Cancels the inflight request when activePickerDate changes or picker closes, preventing stale responses from overwriting suggestions. Adds page.test.ts covering fetch trigger, suggestion rendering, and AbortSignal presence. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/(app)/planner/+page.svelte | 6 +- .../src/routes/(app)/planner/page.test.ts | 84 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/(app)/planner/page.test.ts diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index bf055d1..b6f5e4d 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -106,12 +106,14 @@ isLoadingSuggestions = false; return; } + const controller = new AbortController(); isLoadingSuggestions = true; - fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`) + fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`, { signal: controller.signal }) .then((r) => r.json()) .then((d) => { suggestions = d.suggestions ?? []; }) - .catch(() => { suggestions = []; }) + .catch((e) => { if (e.name !== 'AbortError') suggestions = []; }) .finally(() => { isLoadingSuggestions = false; }); + return () => controller.abort(); }); function handleSelectDay(day: string) { 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..c400a88 --- /dev/null +++ b/frontend/src/routes/(app)/planner/page.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor } 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: [] }, + varietyScore: null, + weekStart: DATE, + recipes: [{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }], + benutzer: { rolle: 'planer' } +}; + +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); + }); +}); -- 2.49.1 From f7a239655a8983f1721749c3f32801c1820ebfc6 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 12:16:02 +0200 Subject: [PATCH 13/30] refactor(planner): extract Suggestion type to $lib/planner/types.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the inline interface from RecipePicker.svelte and replaces any[] in +page.svelte with Suggestion[] — compile-time safety. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/RecipePicker.svelte | 13 +------------ frontend/src/lib/planner/types.ts | 12 ++++++++++++ frontend/src/routes/(app)/planner/+page.svelte | 3 ++- 3 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 frontend/src/lib/planner/types.ts diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte index ec3fd69..41544f2 100644 --- a/frontend/src/lib/planner/RecipePicker.svelte +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -1,16 +1,5 @@ +{#snippet scoreBadge(recipeId: string, delta: number, hasConflict: boolean)} + {#if delta > 0} + + ↑ +{delta.toFixed(1)} Punkte + + {:else if hasConflict} + + ↓ {delta.toFixed(1)} Punkte + + {:else} + + = {delta.toFixed(1)} Punkte + + {/if} +{/snippet} +
@@ -81,107 +117,91 @@
{/each}
- {:else if suggestions.length > 0} -
- Empfohlen · Beste Abwechslung -
- - {#each suggestions as suggestion (suggestion.recipe.id)} - {@const meta = recipeMetadata(suggestion.recipe)} + {:else if topRecommendations.length > 0} +
-
-

- {suggestion.recipe.name} -

- {#if meta} -

- {meta} -

- {/if} - {#if (suggestion.scoreDelta ?? 0) > 0} - - ↑ +{(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte - - {:else if suggestion.hasConflict} - - ↓ {(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte - - {:else} - - = {(suggestion.scoreDelta ?? 0).toFixed(1)} Punkte - - {/if} -
- + 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} +