From d008a17735a41a62e454f01ccf79ca2b3d7b893a Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 11:33:52 +0200 Subject: [PATCH 01/49] 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/49] 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/49] =?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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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} + + + +
+ +
+
+

API design

+

Recipe app · Spring Boot 4 · PostgreSQL · Self-hosted · Single machine

+
+
+ v1.3
+ Style: REST + JSON
+ Framework: Spring Boot 4.0
+ Auth: Spring Security 7 (session)
+ Endpoints: 33
+ Designed by: Nexus +
+
+ +
+

v1.3 changes from v1.2

+
    +
  • Ingredient category is now a reference table. ingredient.category varchar(30) replaced by ingredient.category_id FK → ingredient_category. New ingredient_category table (id, household_id, name). API responses now return "category": { "id": "...", "name": "meat" } instead of "category": "meat". Two new endpoints: GET /v1/ingredient-categories and POST /v1/ingredient-categories.
  • +
  • Endpoint count: 31 → 33.
  • +
+
+ + + + +
+
Stack — boring, predictable, yours
+
Everything runs on one machine. No managed services, no per-request pricing, no vendor lock-in. A single JAR, a single database, a single deployment target.
+ +
+
+

API framework

+
Spring Boot 4.0
+

Spring Framework 7 underneath. Modular starters (smaller JARs). JSpecify null-safety. Built-in API versioning. Java 17+ baseline, first-class Java 25 support. Embedded Tomcat — one executable JAR.

+
+
+

Database

+
PostgreSQL 16
+

Same machine. 1:1 mapping to JPA entities. Flyway for migrations (Atlas's SQL directly). HikariCP connection pool built in.

+
+
+

Auth

+
Spring Security 7 (sessions)
+

Default session-based auth. HttpOnly + Secure + SameSite=Lax cookie. No tokens, no refresh flow, no extra libraries. bcrypt password hashing built in.

+
+
+ +
+
33
REST endpoints
+
18
JPA entities
+
6
Domains
+
1
Deployable JAR
+
0
External services
+
+ +
+

Key dependencies

+

+ spring-boot-starter-web REST + Jackson · + spring-boot-starter-data-jpa Hibernate + repos · + spring-boot-starter-security session auth + CSRF · + spring-boot-starter-validation Bean Validation · + flyway-core DB migrations · + postgresql JDBC driver · + springdoc-openapi Swagger UI +

+
+
+ + + +
+
API conventions
+
+
Base URL · Headers · Naming
+
https://yourapp.com/v1                — frontend + API on same domain
+Cookie: JSESSIONID=...                 — automatic (session auth)
+X-XSRF-TOKEN: ...                     — CSRF (POST/PUT/PATCH/DELETE)
+Content-Type: application/json        — request bodies
+
+URLs:  kebab-case    /v1/week-plans/{id}/slots
+JSON:  camelCase     cookTimeMin, isChildFriendly
+Java:  camelCase     DB cols: snake_case
+
+
+
Response envelopes
+
// Success
+{ "status": "success", "data": { ... }, "meta": { "pagination": { ... } } }
+
+// Error
+{ "status": "error", "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [...] } }
+
+// Pagination & filtering
+?limit=20&offset=0&sort=-cookTimeMin&effort=easy&search=pasta
+
+
+

Click any endpoint row to expand request/response bodies

+

Every endpoint below has a collapsible detail panel showing the exact JSON shapes. Fields marked required are validated with Bean Validation annotations. Fields marked optional can be omitted. All responses are wrapped in the standard envelope above — the examples below show the data payload only.

+
+
+ + +
+
Project structure — package by domain
+
+
src/main/java/com/recipeapp
+
com.recipeapp
+├── auth/            AuthController · AuthService · SecurityConfig · CustomUserDetailsService
+├── household/       HouseholdController · InviteController · entities/ · dtos/ · repos/
+├── recipe/          RecipeController · IngredientController · TagController · RecipeService
+├── planning/        WeekPlanController · SuggestionService · VarietyService · CookingLogController
+├── shopping/        ShoppingListController · ShoppingListService
+├── pantry/          PantryController
+├── admin/           AdminController
+└── common/          ApiResponse · ApiError · GlobalExceptionHandler · HouseholdContext
+
+
+ + + +
+
Auth & household endpoints
+ +
+

Authentication

Auth
+
MethodPath / DescriptionAuthJourney
+ +
+
+ POST +
/v1/auth/signup
Create account. Sets session cookie. Returns user object.
+ public + J6 +
+
+
Request body
{
+  "email": "[email protected]",     // required, valid email
+  "password": "s3cure!Pass",         // required, min 8 chars
+  "displayName": "Sarah"             // required, 1–100 chars
+}
+
Response · 201 Created
{
+  "id": "550e8400-...",
+  "email": "[email protected]",
+  "displayName": "Sarah",
+  "householdId": null,              // no household yet
+  "householdRole": null
+}
++ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax
+
+
+ +
+
+ POST +
/v1/auth/login
Login. Creates session. Returns Set-Cookie + user.
+ public + J6 +
+
+
Request body
{
+  "email": "[email protected]",
+  "password": "s3cure!Pass"
+}
+
Response · 200 OK
{
+  "id": "550e8400-...",
+  "email": "[email protected]",
+  "displayName": "Sarah",
+  "householdId": "7c9e6679-...",
+  "householdRole": "planner",
+  "systemRole": "user"
+}
++ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax
+
+
+ +
+
+ POST +
/v1/auth/logout
Invalidate session. Clears cookie.
+ auth + +
+
+
Request body
// empty — no body needed
+
Response · 204 No Content
// empty body
++ Set-Cookie: JSESSIONID=; Max-Age=0
+
+
+ +
+
+ GET +
/v1/auth/me
Current user + household + role. Every app launch.
+ auth + +
+
+
Request
// no body — GET request
+// session cookie sent automatically
+
Response · 200 OK
{
+  "id": "550e8400-...",
+  "email": "[email protected]",
+  "displayName": "Sarah",
+  "householdId": "7c9e6679-...",
+  "householdName": "Smith family",
+  "householdRole": "planner",
+  "systemRole": "user"
+}
+
+
+ +
+
+ PATCH +
/v1/auth/me
Update own displayName or password. Screen E1.
+ auth + +
+
+
Request body
{
+  "displayName": "Sarah S.",       // optional
+  "currentPassword": "old...",     // required if changing pw
+  "newPassword": "new..."          // optional, min 8 chars
+}
+
Response · 200 OK
{
+  "id": "550e8400-...",
+  "email": "[email protected]",
+  "displayName": "Sarah S."
+}
+
+
+
+ +
+

Households & members

Auth
+
MethodPath / DescriptionAuthJourney
+ +
+
+ POST +
/v1/households
Create household + planner role + seed staples & tags. @Transactional.
+ auth + J6 +
+
+
Request body
{
+  "name": "Smith family"            // required, 1–100 chars
+}
+// Seeds ~20 default staple ingredients
+// Seeds default tags (protein types, dietary)
+// Adds current user as planner
+
Response · 201 Created
{
+  "id": "7c9e6679-...",
+  "name": "Smith family",
+  "members": [
+    {
+      "userId": "550e8400-...",
+      "displayName": "Sarah",
+      "role": "planner",
+      "joinedAt": "2026-04-01T10:00:00Z"
+    }
+  ]
+}
+
+
+ +
+
+ GET +
/v1/households/mine
Current user's household with members. Screen E2.
+ auth + J6 +
+
+
Request
// no body — GET
+
Response · 200 OK
{
+  "id": "7c9e6679-...",
+  "name": "Smith family",
+  "members": [
+    { "userId": "550e...", "displayName": "Sarah",
+      "role": "planner", "joinedAt": "..." },
+    { "userId": "661f...", "displayName": "Tom",
+      "role": "member", "joinedAt": "..." }
+  ]
+}
+
+
+ +
+
+ POST +
/v1/households/mine/invites
Generate invite code. Expires 48h.
+ planner + J6 +
+
+
Request body
// empty — server generates the code
+
Response · 201 Created
{
+  "inviteCode": "ABC12XYZ",
+  "shareUrl": "https://yourapp.com/join/ABC12XYZ",
+  "expiresAt": "2026-04-03T10:00:00Z"
+}
+
+
+ +
+
+ POST +
/v1/invites/{code}/accept
Accept invite → join as member.
+ auth + J6 +
+
+
Request
// code is in the URL path
+// no request body needed
+
Response · 200 OK
{
+  "householdId": "7c9e6679-...",
+  "householdName": "Smith family",
+  "role": "member"
+}
+// 409 if code already used
+// 422 if code expired
+// 409 if user already in a household
+
+
+ +
+
+ GET +
/v1/households/mine/members
List members with names and roles.
+ auth + J6 +
+
+
Request
// no body — GET
+
Response · 200 OK
[
+  { "userId": "550e...", "displayName": "Sarah",
+    "role": "planner", "joinedAt": "..." },
+  { "userId": "661f...", "displayName": "Tom",
+    "role": "member", "joinedAt": "..." }
+]
+
+
+
+
+ + + +
+
Recipe endpoints
+ +
+

Recipes

Recipe
+
MethodPath / DescriptionAuthJourney
+ +
+
+ GET +
/v1/recipes
List recipes (B1). Summary fields. ?search, ?effort, ?isChildFriendly, ?sort, ?limit, ?offset.
+ planner + J1J2 +
+
+
Query parameters
?search=pasta              // ILIKE on name
+?effort=easy               // exact match
+?isChildFriendly=true      // boolean
+?cookTimeMin.lte=30        // ≤ 30 minutes
+?sort=-cookTimeMin         // descending
+?limit=20&offset=0         // pagination
+
Response · 200 OK
{
+  "data": [
+    {
+      "id": "a1b2c3d4-...",
+      "name": "Spaghetti Bolognese",
+      "serves": 4,
+      "cookTimeMin": 45,
+      "effort": "medium",
+      "isChildFriendly": true,
+      "heroImageUrl": "/uploads/recipes/a1b2.jpg"
+    }
+  ],
+  "meta": {
+    "pagination": {
+      "total": 47, "limit": 20,
+      "offset": 0, "hasMore": true
+    }
+  }
+}
+
+
+ +
+
+ GET +
/v1/recipes/{id}
Full detail (B2). Ingredients, steps, tags. @EntityGraph — one query.
+ planner + J3 +
+
+
Request
// no body — GET
+// {id} = recipe UUID
+
Response · 200 OK
{
+  "id": "a1b2c3d4-...",
+  "name": "Spaghetti Bolognese",
+  "serves": 4,
+  "cookTimeMin": 45,
+  "effort": "medium",
+  "isChildFriendly": true,
+  "heroImageUrl": "/uploads/recipes/a1b2.jpg",
+  "ingredients": [
+    { "ingredientId": "f1e2-...",
+      "name": "spaghetti",
+      "category": { "id": "cat-01-...", "name": "pasta" },
+      "quantity": 400, "unit": "g", "sortOrder": 1 },
+    { "ingredientId": "d3c4-...",
+      "name": "ground beef",
+      "category": { "id": "cat-02-...", "name": "meat" },
+      "quantity": 500, "unit": "g", "sortOrder": 2 }
+  ],
+  "steps": [
+    { "stepNumber": 1,
+      "instruction": "Boil water and cook pasta." },
+    { "stepNumber": 2,
+      "instruction": "Brown the beef in a pan." }
+  ],
+  "tags": [
+    { "id": "t1-...", "name": "beef", "tagType": "protein" },
+    { "id": "t2-...", "name": "Italian", "tagType": "cuisine" }
+  ]
+}
+
+
+ +
+
+ POST +
/v1/recipes
Create with nested ingredients, steps, tag IDs. @Transactional.
+ planner + J1 +
+
+
Request body
{
+  "name": "Spaghetti Bolognese",
+  "serves": 4,                      // 1–20
+  "cookTimeMin": 45,                // ≥ 0
+  "effort": "medium",               // easy|medium|hard
+  "isChildFriendly": true,          // default false
+  "heroImageUrl": null,
+  "ingredients": [
+    { "ingredientId": "f1e2-...",   // existing id
+      "quantity": 400,
+      "unit": "g",
+      "sortOrder": 1 },
+    { "newIngredientName": "pancetta",// OR create new
+      "quantity": 100,
+      "unit": "g",
+      "sortOrder": 2 }
+  ],
+  "steps": [
+    { "stepNumber": 1,
+      "instruction": "Boil water..." }
+  ],
+  "tagIds": ["t1-...", "t2-..."]    // ≥ 2 (effort + 1 cat)
+}
+
Response · 201 Created
// full RecipeDetail (same shape as GET /recipes/{id})
+{
+  "id": "a1b2c3d4-...",
+  "name": "Spaghetti Bolognese",
+  ...
+}
++ Location: /v1/recipes/a1b2c3d4-...
+
Ingredients can reference an existing ingredientId or create a new ingredient inline via newIngredientName. Tags must include at least the effort level tag plus one category tag.
+
+ +
+
+ PUT +
/v1/recipes/{id}
Full replace. Same shape as POST body. Replaces all children.
+ planner + J1 +
+
+
Request body
// identical shape to POST /v1/recipes
+// sends the complete new state
+// server deletes old children, inserts new
+{
+  "name": "Spaghetti Bolognese (updated)",
+  "serves": 4,
+  "cookTimeMin": 40,
+  "effort": "medium",
+  "ingredients": [ ... ],
+  "steps": [ ... ],
+  "tagIds": [ ... ]
+}
+
Response · 200 OK
// full RecipeDetail with updated data
+{
+  "id": "a1b2c3d4-...",
+  "name": "Spaghetti Bolognese (updated)",
+  ...
+}
+
+
+ +
+
+ DELETE +
/v1/recipes/{id}
Soft delete (sets deletedAt).
+ planner + +
+
+
Request
// no body — DELETE
+
Response · 204 No Content
// empty body
+
+
+
+ +
+

Ingredients & tags

Recipe
+
MethodPath / DescriptionAuthJourney
+ +
+
+ GET +
/v1/ingredients?search={q}
Autocomplete by name. Limit 10.
+ auth + J1 +
+
+
Query params
?search=chick             // ILIKE '%chick%'
+?isStaple=true            // filter staples (A3/D3)
+
Response · 200 OK
[
+  { "id": "f1e2-...", "name": "chicken breast",
+    "category": { "id": "cat-02-...", "name": "meat" },
+    "isStaple": false },
+  { "id": "g3h4-...", "name": "chickpeas",
+    "category": { "id": "cat-05-...", "name": "legumes" },
+    "isStaple": false }
+]
+
+
+ +
+
+ PATCH +
/v1/ingredients/{id}
Toggle isStaple. Update name or categoryId.
+ planner + J6 +
+
+
Request body
{
+  "isStaple": true,
+  "name": "olive oil",
+  "categoryId": "cat-03-..."       // FK → ingredient_category
+}
+
Response · 200 OK
{ "id": "f1e2-...", "name": "olive oil",
+  "category": { "id": "cat-03-...", "name": "oil" },
+  "isStaple": true }
+
+
+ +
+
+ GET +
/v1/tags
All tags grouped by tagType. For B3 picker.
+ auth + J1 +
+
+
Request
// no body — GET
+
Response · 200 OK
[
+  { "id": "t1-...", "name": "chicken", "tagType": "protein" },
+  { "id": "t2-...", "name": "beef", "tagType": "protein" },
+  { "id": "t3-...", "name": "vegetarian", "tagType": "dietary" },
+  { "id": "t4-...", "name": "Italian", "tagType": "cuisine" }
+]
+
+
+ +
+
+ POST +
/v1/tags
Create custom tag.
+ planner + J1 +
+
+
Request body
{
+  "name": "Thai",
+  "tagType": "cuisine"            // protein|dietary|cuisine
+}
+
Response · 201 Created
{ "id": "t9-...", "name": "Thai",
+  "tagType": "cuisine" }
+
+
+
+ +
+

Ingredient categories

Recipe
+
MethodPath / DescriptionAuthJourney
+ +
+
+ GET +
/v1/ingredient-categories
List all ingredient categories. Used in B3 recipe form and for shopping list grouping (D1).
+ auth + J1J5 +
+
+
Request
// no body — GET
+// scoped to user's household automatically
+
Response · 200 OK
[
+  { "id": "cat-01-...", "name": "pasta" },
+  { "id": "cat-02-...", "name": "meat" },
+  { "id": "cat-03-...", "name": "oil" },
+  { "id": "cat-04-...", "name": "dairy" },
+  { "id": "cat-05-...", "name": "legumes" },
+  { "id": "cat-06-...", "name": "vegetable" },
+  { "id": "cat-07-...", "name": "spice" }
+]
+// seeded on household creation
+// ordered alphabetically by name
+
+
+ +
+
+ POST +
/v1/ingredient-categories
Create custom category. Planner can extend the default list.
+ planner + J1 +
+
+
Request body
{
+  "name": "frozen"                  // required, 1–50 chars
+}
+// 409 if name already exists in household
+
Response · 201 Created
{
+  "id": "cat-08-...",
+  "name": "frozen"
+}
+
+
+
+
+ + + +
+
Planning endpoints
+ +
+

Week plans & slots

Planning
+
MethodPath / DescriptionAuthJourney
+ +
+
+ GET +
/v1/week-plans?weekStart={date}
Week plan + slots + recipe summaries. C1 home screen.
+ auth + J2J3 +
+
+
Query params
?weekStart=2026-04-06      // ISO date, must be Monday
+
Response · 200 OK
{
+  "id": "wp-1234-...",
+  "weekStart": "2026-04-06",
+  "status": "draft",
+  "confirmedAt": null,
+  "slots": [
+    { "id": "sl-01-...",
+      "slotDate": "2026-04-06",
+      "recipe": {
+        "id": "a1b2-...",
+        "name": "Spaghetti Bolognese",
+        "effort": "medium",
+        "cookTimeMin": 45,
+        "heroImageUrl": "/uploads/recipes/a1b2.jpg"
+      }
+    },
+    { "id": "sl-02-...",
+      "slotDate": "2026-04-07",
+      "recipe": null                // empty day
+    }
+  ]
+}
+// 404 if no plan exists for that week yet
+
+
+ +
+
+ POST +
/v1/week-plans
Create week plan (draft).
+ planner + J2 +
+
+
Request body
{
+  "weekStart": "2026-04-06"        // must be a Monday
+}
+
Response · 201 Created
{
+  "id": "wp-1234-...",
+  "weekStart": "2026-04-06",
+  "status": "draft",
+  "slots": []
+}
+// 409 if plan already exists for that week
+
+
+ +
+
+ POST +
/v1/week-plans/{id}/slots
Assign recipe to a day.
+ planner + J2 +
+
+
Request body
{
+  "slotDate": "2026-04-07",        // within plan week
+  "recipeId": "a1b2c3d4-..."
+}
+
Response · 201 Created
{
+  "id": "sl-03-...",
+  "slotDate": "2026-04-07",
+  "recipe": {
+    "id": "a1b2c3d4-...",
+    "name": "Spaghetti Bolognese",
+    "effort": "medium",
+    "cookTimeMin": 45,
+    "heroImageUrl": "..."
+  }
+}
+
+
+ +
+
+ PATCH +
/v1/week-plans/{planId}/slots/{slotId}
Swap recipe. The ≤ 3-tap mid-week swap.
+ planner + J4 +
+
+
Request body
{
+  "recipeId": "x9y8z7-..."         // new recipe
+}
+
Response · 200 OK
{
+  "id": "sl-03-...",
+  "slotDate": "2026-04-07",
+  "recipe": {
+    "id": "x9y8z7-...",
+    "name": "Quick Stir Fry",
+    "effort": "easy", "cookTimeMin": 15, ...
+  }
+}
+
+
+ +
+
+ DELETE +
/v1/week-plans/{planId}/slots/{slotId}
Clear a day slot.
+ planner + J4 +
+
+
Request
// no body — DELETE
+
Response · 204 No Content
// empty body
+
+
+ +
+
+ POST +
/v1/week-plans/{id}/confirm
Confirm plan. Validates ≥1 slot. 422 if already confirmed.
+ planner + J2 +
+
+
Request body
// empty — action endpoint
+
Response · 200 OK
{
+  "id": "wp-1234-...",
+  "status": "confirmed",
+  "confirmedAt": "2026-04-05T18:30:00Z"
+}
+// 422 if no slots filled
+// 422 if already confirmed
+
+
+
+ +
+

Suggestions & variety

Planning
+
MethodPath / DescriptionAuthJourney
+ +
+
+ GET +
/v1/week-plans/{id}/suggestions?slotDate={date}
3–5 suggestions. Filters: ingredients (3d), protein, effort.
+ planner + J2J4 +
+
+
Query params
?slotDate=2026-04-08       // target day
+
Response · 200 OK
{
+  "suggestions": [
+    {
+      "recipe": {
+        "id": "r1-...", "name": "Quick Stir Fry",
+        "effort": "easy", "cookTimeMin": 15,
+        "heroImageUrl": "..."
+      },
+      "fitReasons": [
+        "not_cooked_recently",
+        "effort_balance",
+        "no_protein_repeat"
+      ],
+      "warnings": []
+    },
+    {
+      "recipe": { "id": "r2-...", "name": "Fish Tacos", ... },
+      "fitReasons": ["effort_balance"],
+      "warnings": ["shares_ingredient_with_yesterday"]
+    }
+  ]
+}
+
+
+ +
+
+ GET +
/v1/week-plans/{id}/variety-score
Computed score (0–10) + breakdown.
+ auth + J2 +
+
+
Request
// no body — GET
+
Response · 200 OK
{
+  "score": 7.5,
+  "ingredientOverlaps": [
+    { "ingredientName": "onion",
+      "days": ["2026-04-06", "2026-04-07"] }
+  ],
+  "proteinRepeats": [],
+  "effortBalance": {
+    "easy": 2, "medium": 3, "hard": 2
+  }
+}
+
+
+ +
+
+ POST +
/v1/cooking-logs
Mark meal cooked. Immutable INSERT.
+ planner + J3 +
+
+
Request body
{
+  "recipeId": "a1b2c3d4-...",
+  "cookedOn": "2026-04-07"          // default: today
+}
+
Response · 201 Created
{
+  "id": "cl-01-...",
+  "recipeId": "a1b2c3d4-...",
+  "cookedOn": "2026-04-07",
+  "cookedBy": "550e8400-..."
+}
+
+
+ +
+
+ GET +
/v1/cooking-logs?limit=30
Recent history (desc by cookedOn).
+ auth + J2 +
+
+
Query params
?limit=30                  // default 30
+?offset=0
+
Response · 200 OK
[
+  { "id": "cl-01-...", "recipeId": "a1b2-...",
+    "recipeName": "Spaghetti Bolognese",
+    "cookedOn": "2026-04-07",
+    "cookedBy": "550e8400-..." }
+]
+
+
+
+
+ + + +
+
Shopping endpoints
+ +
+

Shopping list

Shopping
+
MethodPath / DescriptionAuthJourney
+ +
+
+ POST +
/v1/week-plans/{id}/shopping-list
Generate from plan. Merge, sum, filter staples. Draft for preview.
+ planner + J5 +
+
+
Request body
// empty — generated from the week plan
+// server merges ingredients across meals,
+// sums quantities, filters staples
+
Response · 201 Created
{
+  "id": "shl-01-...",
+  "weekPlanId": "wp-1234-...",
+  "status": "draft",
+  "items": [
+    { "id": "si-01-...",
+      "ingredientId": "f1e2-...",
+      "name": "spaghetti",
+      "category": { "id": "cat-01-...", "name": "pasta" },
+      "quantity": 800, "unit": "g",
+      "isChecked": false,
+      "sourceRecipes": ["a1b2-...", "c3d4-..."] },
+    { "id": "si-02-...",
+      "ingredientId": "d3c4-...",
+      "name": "ground beef",
+      "category": { "id": "cat-02-...", "name": "meat" },
+      "quantity": 500, "unit": "g",
+      "isChecked": false,
+      "sourceRecipes": ["a1b2-..."] }
+  ]
+}
+
+
+ +
+
+ GET +
/v1/shopping-lists/{id}
Full list with items. Both roles. Pull-to-refresh target.
+ auth + J5 +
+
+
Request
// no body — GET
+// this is the refresh action:
+// pull-to-refresh calls this endpoint
+
Response · 200 OK
// same shape as POST response above
+{
+  "id": "shl-01-...",
+  "status": "published",
+  "items": [ ... ]
+}
+
+
+ +
+
+ POST +
/v1/shopping-lists/{id}/publish
Publish (draft → published). Live for members.
+ planner + J5 +
+
+
Request body
// empty — action endpoint
+
Response · 200 OK
{
+  "id": "shl-01-...",
+  "status": "published",
+  "publishedAt": "2026-04-06T09:00:00Z"
+}
+// 422 if already published
+
+
+ +
+
+ PATCH +
/v1/shopping-lists/{listId}/items/{itemId}
Check/uncheck item. Both roles.
+ auth + J5 +
+
+
Request body
{
+  "isChecked": true
+}
+
Response · 200 OK
{
+  "id": "si-01-...",
+  "name": "spaghetti",
+  "isChecked": true,
+  "checkedBy": "661f-..."           // who checked it
+}
+// other members see this on next refresh
+
+
+ +
+
+ POST +
/v1/shopping-lists/{id}/items
Add custom item. Both roles.
+ auth + J5 +
+
+
Request body
{
+  "ingredientId": null,             // or existing id
+  "customName": "Paper towels",     // if no ingredientId
+  "quantity": 1,
+  "unit": ""                         // blank for countable
+}
+
Response · 201 Created
{
+  "id": "si-10-...",
+  "ingredientId": null,
+  "name": "Paper towels",
+  "quantity": 1, "unit": "",
+  "isChecked": false,
+  "sourceRecipes": []
+}
+
+
+ +
+
+ DELETE +
/v1/shopping-lists/{listId}/items/{itemId}
Remove item. Planner only, pre-publish.
+ planner + J5 +
+
+
Request
// no body — DELETE
+
Response · 204 No Content
// 422 if list is already published
+
+
+
+
+ + + +
+
Pantry endpoints
+
+

Pantry items

Pantry
+
MethodPath / DescriptionAuth
+ +
+
+ GET +
/v1/pantry-items
List items, expiring soonest first.
+ auth +
+
+
Request
// no body — GET
+
Response · 200 OK
[
+  { "id": "pi-01-...",
+    "ingredientId": "f1e2-...",
+    "name": "chicken breast",
+    "category": { "id": "cat-02-...", "name": "meat" },
+    "quantity": 500, "unit": "g",
+    "bestBefore": "2026-04-10",
+    "openedOn": null }
+]
+
+
+ +
+
+ POST +
/v1/pantry-items
Add item.
+ planner +
+
+
Request body
{
+  "ingredientId": "f1e2-...",      // or null
+  "customName": null,               // if no ingredientId
+  "quantity": 500,
+  "unit": "g",
+  "bestBefore": "2026-04-10",
+  "openedOn": null
+}
+
Response · 201 Created
{ "id": "pi-02-...",
+  "ingredientId": "f1e2-...",
+  "name": "chicken breast",
+  "quantity": 500, "unit": "g",
+  "bestBefore": "2026-04-10",
+  "openedOn": null }
+
+
+ +
+
+ PATCH +
/v1/pantry-items/{id}
Update quantity, bestBefore, openedOn.
+ planner +
+
+
Request body
{
+  "quantity": 250,
+  "openedOn": "2026-04-07"
+}
+
Response · 200 OK
{ "id": "pi-02-...", "quantity": 250,
+  "openedOn": "2026-04-07", ... }
+
+
+ +
+
+ DELETE +
/v1/pantry-items/{id}
Remove consumed/expired item.
+ planner +
+
+
Request
// no body
+
Response · 204 No Content
// empty
+
+
+
+
+ + + +
+
Admin endpoints
+
+

Admin user management

Admin
+
MethodPath / DescriptionAuth
+ +
+
+ GET +
/v1/admin/users
List all users. Paginated.
+ admin +
+
+
Query params
?limit=50&offset=0
+?search=jane               // by email or name
+?isActive=true
+
Response · 200 OK
{
+  "data": [
+    { "id": "550e-...", "email": "[email protected]",
+      "displayName": "Sarah", "systemRole": "user",
+      "isActive": true, "createdAt": "..." }
+  ],
+  "meta": { "pagination": { "total": 24, ... } }
+}
+
+
+ +
+
+ POST +
/v1/admin/users
Create user with temp password + audit log.
+ admin +
+
+
Request body
{
+  "email": "[email protected]",
+  "displayName": "New User",
+  "tempPassword": "Change1Me!",
+  "systemRole": "user"             // default "user"
+}
+
Response · 201 Created
{
+  "id": "new-uuid-...",
+  "email": "[email protected]",
+  "displayName": "New User",
+  "systemRole": "user",
+  "isActive": true,
+  "mustChangePassword": true
+}
+
+
+ +
+
+ PATCH +
/v1/admin/users/{id}
Update user. Audit logged.
+ admin +
+
+
Request body
{
+  "displayName": "Jane Smith",
+  "email": "[email protected]",
+  "systemRole": "admin",
+  "isActive": false                 // deactivate
+}
+
Response · 200 OK
{ "id": "...", "email": "[email protected]",
+  "displayName": "Jane Smith",
+  "systemRole": "admin", "isActive": false }
+
+
+ +
+
+ POST +
/v1/admin/users/{id}/reset-password
Reset to temp password. Audit logged.
+ admin +
+
+
Request body
{
+  "tempPassword": "Reset1Me!",
+  "reason": "user requested via support"
+}
+
Response · 200 OK
{
+  "message": "Password reset successfully",
+  "mustChangePassword": true
+}
+
+
+ +
+
+ GET +
/v1/admin/audit-log
View audit trail. Read-only.
+ admin +
+
+
Query params
?limit=50&offset=0
+?targetUserId=550e-...     // filter by user
+
Response · 200 OK
[
+  { "id": "al-01-...",
+    "adminId": "adm-...",
+    "adminEmail": "[email protected]",
+    "targetUserId": "550e-...",
+    "targetEmail": "[email protected]",
+    "action": "reset_password",
+    "detail": { "reason": "user requested" },
+    "performedAt": "2026-04-01T10:05:00Z" }
+]
+
+
+
+
+ + + +
+
Journey → API mapping
+
+
+
J1 — Add a recipe

3 requests

+
  • GET /v1/ingredients?search=...
  • GET /v1/tags
  • POST /v1/recipes
+
+
+
J2 — Plan the week

4–10 requests

+
  • GET /v1/week-plans?weekStart=...
  • GET .../suggestions?slotDate=...
  • POST .../slots
  • GET .../variety-score
  • POST .../confirm
+
+
+
J3 — Cook tonight

2 requests

+
  • GET /v1/recipes/{id}
  • POST /v1/cooking-logs
+
+
+
J4 — Adapt on the fly

2 requests (≤ 3 taps)

+
  • GET .../suggestions?slotDate=...
  • PATCH .../slots/{slotId}
+
+
+
J5 — Shopping list

3–5 requests

+
  • POST .../shopping-list
  • GET /v1/shopping-lists/{id}
  • POST .../publish
  • PATCH .../items/{id}
+
+
+
J6 — Household setup

4 requests

+
  • POST /v1/auth/signup
  • POST /v1/households
  • POST .../invites
  • POST /v1/invites/{code}/accept
+
+
+
+ + + +
+
Security architecture
+
+

Three layers

+

1. Authentication: Spring Security 7 session. HttpOnly + Secure + SameSite=Lax cookie. 24h expiry.
+ 2. Role authorization: @PreAuthorize on systemRole (admin) and householdRole (planner vs member). 403 on mismatch.
+ 3. Household isolation: HouseholdContext resolves householdId from session. Every query includes AND household_id = ?. Wrong household → 404.

+
+
+
Authorization matrix
+
Role        │ Recipes  │ Plan     │ Shopping list     │ Pantry   │ Admin
+────────────┼──────────┼──────────┼───────────────────┼──────────┼──────
+Planner     │ CRUD     │ CRUD     │ generate,publish  │ CRUD     │ —
+Member      │ —        │ READ     │ read,check,add    │ —        │ —
+Admin       │ —        │ —        │ —                 │ —        │ CRUD + audit
+Unauth      │ —        │ —        │ —                 │ —        │ —
+
+
+

Never exposed

+

password_hash (@JsonIgnore) · sequential IDs (UUIDs only) · JSESSIONID (HttpOnly) · cross-household data (404, not 403) · audit_log.detail (admin-only)

+
+
+ + + +
+
Implementation phases
+
+

Phase 1 — Skeleton + Auth + CRUD (days 1–3)

+

Spring Initializr (Boot 4.0, Java 21). Flyway migrations. JPA entities. SecurityConfig with session auth + CSRF. Auth endpoints. Recipe CRUD + autocomplete + tags. Heavily AI-generatable.

+
+
+

Phase 2 — Household + Planning CRUD (days 4–5)

+

Household creation + invite flow (J6). Week plan + slot CRUD (J2). Cooking log (J3). Household scoping verified.

+
+
+

Phase 3 — Business logic (days 6–10)

+

SuggestionService · VarietyService · ShoppingListService. The 3 services that need real thinking.

+
+
+

Phase 4 — Admin + Polish (days 11–14)

+ \ No newline at end of file diff --git a/specs/backend/data-model.html b/specs/backend/data-model.html new file mode 100644 index 0000000..a6a1889 --- /dev/null +++ b/specs/backend/data-model.html @@ -0,0 +1,896 @@ + + + + + + Recipe App — Data Model v1.1 + + + + +
+ + +
+
+

Data model

+

Recipe app · PostgreSQL 16 · Normalized schema with audit trails

+
+
+ v1.1
+ Engine: PostgreSQL 16
+ Tables: 18
+ Domains: 6
+ Designed by: Atlas +
+
+ + +
+

v1.1 changes from v1.0

+
    +
  • Tag model fixed → proper M:N. Added tag reference table. recipe_tag is now a pure junction table with recipe_id FK + tag_id FK. Tags are reusable, renameable, and queryable from both directions.
  • +
  • Ingredient category normalized → 1:N. Added ingredient_category reference table. The category string on ingredient is replaced with category_id FK. Rename once, applies to all ingredients. Canonical list of aisle categories for shopping grouping.
  • +
  • Admin user management added. New system_role column on user_account (admin vs user). New admin_audit_log table tracks admin actions: account creation, updates, password resets.
  • +
  • Table count: 16 → 18. Domain count: 5 → 6 (added Admin domain).
  • +
+
+ + +
+
Overview
+
This schema covers all six user journeys (J1–J6), the suggestion/variety engine, the lightweight pantry tracker, recipe hero images, and platform-level admin user management. It is normalized by default, with computed fields (variety score) calculated at query time rather than stored. Every mutable table carries audit timestamps. Tags use a proper M:N relationship via a reference table + junction table.
+ +
+
4
Auth & household
+
7
Recipe domain
+
3
Planning domain
+
2
Shopping domain
+
1
Pantry domain
+
1
Admin domain
+
+ +
+

Design decisions

+

Variety score is computed, not stored — it's derived from cooking_log + recipe_ingredient + week_plan_slot.
+ Ingredients are a normalized reference table — enables merging, repetition tracking, and staple filtering.
+ Tags are a proper M:N: a tag reference table + recipe_tag junction. One recipe → many tags, one tag → many recipes. Rename once, applies everywhere.
+ Ingredient categories are a normalized 1:N reference table — one ingredient belongs to one category (e.g. "Produce", "Fish & Meat"). Rename a category once, applies to all ingredients. Powers the aisle-grouped shopping list (J5 variant V2).
+ Hero images store a URL/path reference to object storage (S3/R2).
+ Admin uses a system_role on user_account (not the household role). Admin actions are audit-logged in a dedicated table.
+ Pantry items link to the shared ingredient reference with best-before dates.

+
+
+ + + +
+
Entity-relationship diagram
+
Entities grouped by domain. Purple = auth, green = recipe, yellow = planning, blue = shopping, orange = pantry, red = admin. NEW marks v1.1 additions/changes.
+ +
+
+ + +
+
+
user_account CHANGED
+
+
PK iduuid
+
emailcitext UNIQUE
+
display_namevarchar(100)
+
password_hashvarchar(255)
+
system_roleenum(admin,user) NEW
+
is_activeboolean NEW
+
created_attimestamptz
+
updated_attimestamptz
+
+
+
+
household
+
+
PK iduuid
+
namevarchar(100)
+
FK created_by→ user_account
+
created_attimestamptz
+
+
+
+
household_member
+
+
PK iduuid
+
FK household_id→ household
+
FK user_id→ user_account UNIQUE
+
roleenum(planner,member)
+
joined_attimestamptz
+
+
+
+
household_invite
+
+
PK iduuid
+
FK household_id→ household
+
invite_codevarchar(20) UNIQUE
+
statusenum(pending,used,expired)
+
expires_attimestamptz
+
+
+
+ + +
+
+
recipe
+
+
PK iduuid
+
FK household_id→ household
+
namevarchar(200)
+
servessmallint
+
cook_time_minsmallint
+
effortenum(easy,medium,hard)
+
is_child_friendlyboolean
+
hero_image_urlvarchar(500) NULL
+
deleted_attimestamptz NULL
+
+
+
+
ingredient CHANGED
+
+
PK iduuid
+
FK household_id→ household
+
namecitext
+
is_stapleboolean
+
FK category_id→ ingredient_category NULL NEW
+
+
+
+
ingredient_category NEW
+
+
PK iduuid
+
FK household_id→ household
+
namecitext
+
sort_ordersmallint
+
+
+
+
recipe_ingredient
+
+
PK iduuid
+
FK recipe_id→ recipe
+
FK ingredient_id→ ingredient
+
quantitynumeric(8,2)
+
unitvarchar(20)
+
sort_ordersmallint
+
+
+
+
recipe_step
+
+
PK iduuid
+
FK recipe_id→ recipe
+
step_numbersmallint
+
instructiontext
+
+
+
+ + +
+
+
tag NEW
+
+
PK iduuid
+
FK household_id→ household
+
namecitext
+
tag_typevarchar(20)
+
+
+
+
recipe_tag CHANGED
+
+
FK recipe_id→ recipe
+
FK tag_id→ tag NEW
+
PK (recipe_id, tag_id)composite
+
+
+
+
week_plan
+
+
PK iduuid
+
FK household_id→ household
+
week_startdate (Monday)
+
statusenum(draft,confirmed)
+
confirmed_attimestamptz NULL
+
+
+
+
week_plan_slot
+
+
PK iduuid
+
FK week_plan_id→ week_plan
+
FK recipe_id→ recipe
+
slot_datedate
+
+
+
+
cooking_log
+
+
PK iduuid
+
FK recipe_id→ recipe
+
FK household_id→ household
+
cooked_ondate
+
FK cooked_by→ user_account
+
+
+
+ + +
+
+
shopping_list
+
+
PK iduuid
+
FK household_id→ household
+
FK week_plan_id→ week_plan
+
statusenum(draft,published,done)
+
published_attimestamptz NULL
+
+
+
+
shopping_list_item
+
+
PK iduuid
+
FK shopping_list_id→ shopping_list
+
FK ingredient_id→ ingredient NULL
+
custom_namevarchar(200) NULL
+
quantity / unitnumeric / varchar
+
is_checkedboolean
+
source_recipesuuid[]
+
+
+
+
pantry_item
+
+
PK iduuid
+
FK household_id→ household
+
FK ingredient_id→ ingredient NULL
+
custom_namevarchar(200) NULL
+
quantity / unitnumeric / varchar
+
best_beforedate NULL
+
opened_ondate NULL
+
+
+
+
admin_audit_log NEW
+
+
PK iduuid
+
FK admin_id→ user_account
+
FK target_user_id→ user_account
+
actionvarchar(30)
+
detailjsonb NULL
+
performed_attimestamptz
+
+
+
+ +
+
+
+ + + +
+
Tag model — M:N via reference table
+
v1.0 stored tags as raw strings in recipe_tag. v1.1 fixes this to a proper many-to-many relationship. One recipe can have many tags. One tag can appear on many recipes. Tags are owned by a household and typed (protein, dietary, cuisine) to enable structured filtering.
+ +
+

What changed and why

+

Before (v1.0): recipe_tag(recipe_id, tag varchar(50)) — tag name stored as raw string per row. 30 recipes tagged "chicken" = 30 copies of the string "chicken". Renaming requires updating every row. No canonical list of available tags. No typed categorization.

+ After (v1.1): tag(id, household_id, name, tag_type) + recipe_tag(recipe_id, tag_id) — pure M:N junction. Rename a tag in one UPDATE. List available tags with a simple SELECT. Filter by tag_type (protein, dietary, cuisine) for the J2 suggestion engine. The junction PK is composite (recipe_id, tag_id) — no surrogate key needed.

+
+ + +
+
+

tag NEW in v1.1

+ Recipe +
+
Reference table for category tags. Scoped per household — each household can have its own tag vocabulary. tag_type classifies tags for the suggestion engine: "protein" tags trigger consecutive-day avoidance, "dietary" tags are informational.
+ + + + + + + + + +
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
household_iduuidNOT NULL, FK → household ON DELETE CASCADETags belong to a household
namecitextNOT NULL"Chicken", "Fish", "Vegetarian", "Pasta"
tag_typevarchar(20)NOT NULL, CHECK(tag_type IN ('protein','dietary','cuisine','other'))Classification. "protein" powers J2 consecutive-day filter.
created_attimestamptzNOT NULL, DEFAULT now()Creation time
+ + +
+ + +
+
+

recipe_tag CHANGED in v1.1

+ Recipe +
+
Pure M:N junction table. No surrogate key — the composite PK (recipe_id, tag_id) is the natural key. A recipe can have many tags; a tag can appear on many recipes. Both directions are indexed.
+ + + + + + +
ColumnTypeConstraintsPurpose
recipe_iduuidNOT NULL, FK → recipe ON DELETE CASCADE, part of composite PKWhich recipe
tag_iduuidNOT NULL, FK → tag ON DELETE CASCADE, part of composite PKWhich tag
+ + +
+ + +
+

J2 — Same-protein consecutive day check (updated for M:N)

+
Uses tag.tag_type = 'protein' to filter only protein tags from the M:N join
+
-- What protein tags are on adjacent planned days?
+SELECT wps.slot_date, t.name AS protein
+FROM week_plan_slot wps
+JOIN recipe_tag rt ON rt.recipe_id = wps.recipe_id
+JOIN tag t ON t.id = rt.tag_id
+WHERE wps.week_plan_id = $1
+  AND t.tag_type = 'protein'
+ORDER BY wps.slot_date;
+
+
+ + + +
+
Ingredient category — 1:N reference table
+
v1.0 stored category as a raw string on ingredient. v1.1 normalizes this to a proper 1:N relationship. One ingredient belongs to one category. One category contains many ingredients. Categories are owned per household and ordered for shopping list grouping.
+ +
+

What changed and why

+

Before: ingredient.category varchar(30) — raw string. 15 ingredients labelled "Produce" = 15 copies. Rename requires updating every row. No canonical list. No display ordering for the aisle-grouped shopping list.

+ After: ingredient_category(id, household_id, name, sort_order) + ingredient.category_id FK. Rename once, applies everywhere. sort_order controls the display order on the aisle-grouped shopping list (J5 V2). Category is nullable on ingredient — uncategorized ingredients fall into an "Other" group in the UI.

+
+ +
+
+

ingredient_category NEW in v1.1

+ Recipe +
+
Reference table for ingredient aisle categories. Scoped per household — each household can customize their store layout. sort_order controls the display sequence in the aisle-grouped shopping list view (J5 screen D1 variant V2).
+ + + + + + + + + +
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
household_iduuidNOT NULL, FK → household ON DELETE CASCADECategories are per-household
namecitextNOT NULL"Produce", "Fish & Meat", "Dry Goods", "Dairy", "Sauces & Condiments"
sort_ordersmallintNOT NULL, DEFAULT 0Display order — matches supermarket aisle flow
created_attimestamptzNOT NULL, DEFAULT now()Creation time
+ + + +
+
+ + + +
+
Admin user management — new in v1.1
+
The system needs a platform-level admin who can create user accounts, update them, and reset passwords. This is separate from the household "planner" role — a planner manages meal plans, an admin manages the platform. The two role systems are orthogonal: system_role (admin vs user) lives on user_account; household_role (planner vs member) lives on household_member.
+ +
+

Two role systems — don't confuse them

+

system_role on user_account: platform-level. "admin" can manage all user accounts. "user" is a normal user. This is about platform administration.

+ household role on household_member: app-level. "planner" has full access to 18 screens. "member" sees C1 read-only + D1 collaborative. This is about what you can do within a household.

+ An admin can also be a planner in their own household. The roles are independent.

+
+ + +
+
+

user_account CHANGED in v1.1

+ Auth +
+
User identity for both app users and platform admins. system_role determines platform-level access. is_active allows admins to deactivate accounts without deleting them. Authentication handled here; household authorization in household_member.
+ + + + + + + + + + + + +
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
emailcitextNOT NULL, UNIQUELogin identifier, case-insensitive
display_namevarchar(100)NOT NULLShown in UI (sidebar avatar initials)
password_hashvarchar(255)NOT NULLbcrypt/argon2 hash — never exposed via API
system_rolevarchar(10)NOT NULL, DEFAULT 'user', CHECK(system_role IN ('admin','user'))NEW — platform role. Admin can manage all accounts.
is_activebooleanNOT NULL, DEFAULT trueNEW — admin can deactivate accounts. Inactive users cannot log in.
created_attimestamptzNOT NULL, DEFAULT now()Account creation time
updated_attimestamptzNOT NULL, DEFAULT now()Last profile edit
+ + +
+ + +
+
+

admin_audit_log NEW in v1.1

+ Admin +
+
Immutable audit trail for all admin actions on user accounts. Every account creation, update, or password reset by an admin is logged here. Never updated or deleted. Used for compliance, debugging, and accountability.
+ + + + + + + + + + + +
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
admin_iduuidNOT NULL, FK → user_account ON DELETE RESTRICTWhich admin performed the action
target_user_iduuidNOT NULL, FK → user_account ON DELETE RESTRICTWhich user was affected
actionvarchar(30)NOT NULL, CHECK(action IN ('create_account','update_account','reset_password','deactivate_account','reactivate_account','change_system_role'))What happened
detailjsonbNULLChanged fields snapshot: {"field":"email","old":"a@x.com","new":"b@x.com"}
ip_addressinetNULLAdmin's IP for security audit
performed_attimestamptzNOT NULL, DEFAULT now()When the action occurred
+ + + + +
+
+ + + +
+
Foreign key map (updated v1.1)
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
From tableColumnReferencesCardinalityOn delete
householdcreated_byuser_account.idN:1RESTRICT
household_memberhousehold_idhousehold.idN:1CASCADE
household_memberuser_iduser_account.idN:1CASCADE
household_invitehousehold_idhousehold.idN:1CASCADE
recipehousehold_idhousehold.idN:1CASCADE
ingredienthousehold_idhousehold.idN:1CASCADE
ingredient_categoryhousehold_idhousehold.idN:1CASCADE
ingredientcategory_idingredient_category.idN:1 (nullable)SET NULL
taghousehold_idhousehold.idN:1CASCADE
recipe_ingredientrecipe_idrecipe.idN:1CASCADE
recipe_ingredientingredient_idingredient.idN:1RESTRICT
recipe_steprecipe_idrecipe.idN:1CASCADE
recipe_tagrecipe_idrecipe.idM:N junctionCASCADE
recipe_tagtag_idtag.idM:N junctionCASCADE
week_planhousehold_idhousehold.idN:1CASCADE
week_plan_slotweek_plan_idweek_plan.idN:1CASCADE
week_plan_slotrecipe_idrecipe.idN:1RESTRICT
cooking_logrecipe_idrecipe.idN:1RESTRICT
cooking_logweek_plan_slot_idweek_plan_slot.idN:1 (nullable)SET NULL
shopping_listweek_plan_idweek_plan.idN:1RESTRICT
shopping_list_itemshopping_list_idshopping_list.idN:1CASCADE
shopping_list_itemingredient_idingredient.idN:1 (nullable)SET NULL
pantry_itemhousehold_idhousehold.idN:1CASCADE
pantry_itemingredient_idingredient.idN:1 (nullable)SET NULL
admin_audit_logadmin_iduser_account.idN:1RESTRICT
admin_audit_logtarget_user_iduser_account.idN:1RESTRICT
+
+
= new or changed in v1.1
+
+ + + +
+
Key query patterns
+ +
+

J2 — Ingredient repetition check (last 3 days)

+
Frequency: ~10×/week · Target: <50ms
+
WITH recent_meals AS (
+  SELECT recipe_id, cooked_on
+  FROM cooking_log
+  WHERE household_id = $1
+    AND cooked_on >= CURRENT_DATE - INTERVAL '3 days'
+)
+SELECT DISTINCT i.id, i.name
+FROM recent_meals rm
+JOIN recipe_ingredient ri ON ri.recipe_id = rm.recipe_id
+JOIN ingredient i ON i.id = ri.ingredient_id;
+
+ +
+

J2 — Protein tags on adjacent days (M:N join)

+
Frequency: ~10×/week · Target: <30ms
+
SELECT wps.slot_date, t.name AS protein
+FROM week_plan_slot wps
+JOIN recipe_tag rt ON rt.recipe_id = wps.recipe_id
+JOIN tag t ON t.id = rt.tag_id
+WHERE wps.week_plan_id = $1
+  AND t.tag_type = 'protein'
+ORDER BY wps.slot_date;
+
+ +
+

J5 — Shopping list generation (merged + staples filtered)

+
Frequency: 1×/week · Target: <200ms
+
SELECT i.id, i.name,
+       SUM(ri.quantity) AS total_qty, ri.unit,
+       ARRAY_AGG(DISTINCT r.id) AS source_recipe_ids
+FROM week_plan_slot wps
+JOIN recipe r ON r.id = wps.recipe_id
+JOIN recipe_ingredient ri ON ri.recipe_id = r.id
+JOIN ingredient i ON i.id = ri.ingredient_id
+WHERE wps.week_plan_id = $1
+  AND i.is_staple = false
+GROUP BY i.id, i.name, ri.unit
+ORDER BY i.name;
+
+ +
+

Pantry — Items expiring within 3 days

+
Frequency: daily · Target: <20ms
+
SELECT pi.id, COALESCE(i.name, pi.custom_name) AS name,
+       pi.best_before, pi.quantity, pi.unit
+FROM pantry_item pi
+LEFT JOIN ingredient i ON i.id = pi.ingredient_id
+WHERE pi.household_id = $1
+  AND pi.best_before IS NOT NULL
+  AND pi.best_before <= CURRENT_DATE + INTERVAL '3 days'
+ORDER BY pi.best_before;
+
+ +
+

Admin — All actions on a user (audit trail)

+
Frequency: on-demand · Target: <50ms
+
SELECT aal.action, aal.detail, aal.performed_at,
+       admin.display_name AS admin_name, admin.email AS admin_email
+FROM admin_audit_log aal
+JOIN user_account admin ON admin.id = aal.admin_id
+WHERE aal.target_user_id = $1
+ORDER BY aal.performed_at DESC;
+
+ +
+

All tags for a recipe (M:N forward lookup)

+
Frequency: every recipe detail load · Target: <10ms
+
SELECT t.id, t.name, t.tag_type
+FROM recipe_tag rt
+JOIN tag t ON t.id = rt.tag_id
+WHERE rt.recipe_id = $1
+ORDER BY t.tag_type, t.name;
+
+ +
+

All recipes with a specific tag (M:N reverse lookup)

+
Frequency: J2 suggestion filter, B1 filter chips · Target: <30ms
+
SELECT r.id, r.name, r.effort, r.cook_time_min
+FROM recipe_tag rt
+JOIN recipe r ON r.id = rt.recipe_id
+WHERE rt.tag_id = $1
+  AND r.deleted_at IS NULL
+ORDER BY r.name;
+
+
+ + + +
+
Migration order (v1.1)
+
+

Migration 001 — Extensions & triggers

+
Run once before any table creation
+
CREATE EXTENSION IF NOT EXISTS "pgcrypto";   -- gen_random_uuid()
+CREATE EXTENSION IF NOT EXISTS "citext";     -- case-insensitive text
+
+CREATE OR REPLACE FUNCTION trigger_set_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+  NEW.updated_at = NOW();
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+ +
+

Table creation order (respects FK dependencies)

+

1. user_account → 2. household → 3. household_member → 4. household_invite → 5. ingredient_category → 6. ingredient → 7. tag → 8. recipe → 9. recipe_ingredient → 10. recipe_step → 11. recipe_tag → 12. week_plan → 13. week_plan_slot → 14. cooking_log → 15. shopping_list → 16. shopping_list_item → 17. pantry_item → 18. admin_audit_log

+
+ +
+

Immutability rules for audit tables

+
Apply after admin_audit_log creation
+
-- Prevent accidental updates/deletes on audit log
+CREATE RULE no_update_audit AS ON UPDATE TO admin_audit_log
+  DO INSTEAD NOTHING;
+CREATE RULE no_delete_audit AS ON DELETE TO admin_audit_log
+  DO INSTEAD NOTHING;
+
+
+ + + +
+
Journey → table coverage matrix (v1.1)
+
+ + + + + + + + + + + + +
JourneyReadsWritesCritical path
J1 · Add recipeingredient, tag (autocomplete)recipe, recipe_ingredient, recipe_step, recipe_tag, ingredient, tagRecipe INSERT + child rows + tag associations in one transaction
J2 · Plan weekrecipe, recipe_ingredient, recipe_tag, tag, cooking_log, ingredientweek_plan, week_plan_slotVariety CTE joins tag (type=protein) for consecutive-day check
J3 · Cook tonightweek_plan_slot, recipe, recipe_ingredient, recipe_stepcooking_logcooking_log INSERT (immutable event)
J4 · Adapt on flyrecipe, recipe_tag, tag, cooking_logweek_plan_slot (UPDATE recipe_id)Slot UPDATE + variety recompute ≤ 3 taps
J5 · Shopping listweek_plan_slot, recipe_ingredient, ingredient, ingredient_categoryshopping_list, shopping_list_itemMerge query (GROUP BY ingredient, SUM quantity) + aisle grouping via category
J6 · Household setupuser_account, household, household_member, household_invite, ingredient (staples), tag (seed data), ingredient_category (seed data)Household creation + seed data in one transaction
Pantrypantry_item, ingredientpantry_itemExpiry notification query (daily)
Adminuser_account, admin_audit_loguser_account, admin_audit_logEvery admin action → audit log INSERT in same transaction
+
+
+ + + +
+
Pushback & trade-off log
+ +
+

v1.0 bug: recipe_tag was 1:N, not M:N

+

Fixed in v1.1. v1.0 stored tags as raw strings, making recipe_tag structurally a 1:N (one recipe → many string rows) rather than a true M:N. The tag string had no identity — "chicken" on recipe A and "chicken" on recipe B were unrelated rows. This prevented tag renaming, tag listing, and structured filtering. v1.1 adds a tag reference table, making recipe_tag a proper M:N junction with FK integrity in both directions.

+
+ +
+

v1.0 bug: ingredient.category was a raw string, not a FK

+

Fixed in v1.1. Same anti-pattern as the tag issue. 15 ingredients all storing the string "Produce" independently — no shared identity, no rename capability, no canonical list, no sort order. v1.1 extracts this to ingredient_category as a reference table with a 1:N FK from ingredient. The sort_order column enables the aisle-grouped shopping list (J5 D1 V2) to match supermarket layout. Category is nullable — uncategorized ingredients group into "Other."

+
+ +
+

source_recipes as uuid[] — trade-off accepted

+

Violates 1NF. But it's write-once display metadata, never joined against. A junction table would add ~60 rows/week for zero query benefit. Sunset plan: migrate to junction table if future requirements need "which list items came from recipe X?"

+
+ +
+

Variety score is computed, not materialized

+

At ~50 recipes × 7 slots, the CTE runs in <100ms. Materialized view adds staleness risk. At 100× scale, we add materialized view with refresh-on-mutation triggers.

+
+ +
+

admin_audit_log.detail uses JSONB — justified

+

This is the right use case for schemaless data. Each action type has a different shape (password reset has a "reason", email change has "old"+"new", creation has full profile). The data is write-once, append-only, and queried for display only. Normalizing it into typed columns would require a different schema per action type for no benefit.

+
+ +
+

Rejected: separate admin_user table

+

Having a separate table for admins would split identity across two tables. Login would need to check both. An admin who is also a household planner would need two accounts. Instead, system_role on user_account keeps one identity per person. The role check is a single column read.

+
+
+ +
+ + diff --git a/specs/e1-settings.html b/specs/e1-settings.html new file mode 100644 index 0000000..b866e6d --- /dev/null +++ b/specs/e1-settings.html @@ -0,0 +1,764 @@ + + + + + + E1 / D3 — Einstellungen & Vorräte · 5 Variationen + + + + + +
+ +
+
+

E1 / D3 — Einstellungen & Vorräte

+

5 Design-Variationen · Desktop-first · Routes /settings + /household/staples · Journey J8

+
+
+ Version: 1.0
+ Screens: E1, D3
+ Journey: J8
+ Actor: Planner
+ Last updated: 2026-04 +
+
+ +

Two pages, one journey. E1 is the settings hub at /settings — currently a placeholder. D3 is the StaplesManager component at /household/staples, rendered with context="settings". The component uses StapleChip — ingredient pills in flex-wrap grids per category, not a checklist. Five variations explore how the hub and staples editing relate: from navigating to a sub-page, to showing staples inline on the settings page itself. The recommended variation for v1 is V3 (Accordion) — one page, no navigation, staples always one tap away.

+ + + + + +
+
+
V1
+
+
Verknüpfte Abschnitte
+
Settings als Hub-Seite mit klickbaren Abschnitt-Zeilen. Jeder Bereich (Profil, Vorräte, Haushalt) ist eine Zeile mit Titel, Beschreibung, Stat und Pfeil. Tippen navigiert zur jeweiligen Unterseite. Klassisches iOS-Settings-Muster.
+ Maximal erweiterbar für künftige Settings +
+
+ + + + +
+
Desktop · D3 Vorräte-Unterseite (nach Navigation)
+
+
+
+ +
+ +
+

Vorräte

14 von 32 Zutaten als Vorrat markiert
+
+ +
+
+
Gemüse
+
+ Karotten + Zwiebeln + Lauch + Knoblauch + Fenchel + Paprika +
+
+
+
Getreide & Hülsenfrüchte
+
+ Pasta + Reis + Couscous + Linsen + Kichererbsen +
+
+
+
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Vorräte-Zeile hat einen grünen linken Rand (border-left: 3px solid --green-dark) — signalisiert "primäre Aktion" ohne ihn visuell zu überfrachten.
  • +
  • Die aktive Zutat-Zahl ("14") wird als Display-Schrift-Zahl dargestellt — schafft eine Verbindung zum Wert-Versprechen der App (Varietät-Scores sind ebenfalls Zahlen).
  • +
  • D3 Unterseite im settings-Kontext: kein Onboarding-Sidebar, kein "Weiter"-Button — nur die Kategorie-Chip-Liste. "← Einstellungen" Breadcrumb für die Rücknavigation.
  • +
  • StapleChip: Ausgewählt = --green-dark Hintergrund + weißer Text. Nicht ausgewählt = transparenter Hintergrund + --color-border Rahmen + --color-text-muted Text. Kein separater Speicher-Button — Auto-Save auf Toggle.
  • +
+
+
+ + + +
+
+
V2
+
+
Einstellungs-Kacheln
+
Jeder Einstellungsbereich als Kachel mit Titel, Beschreibung, Schlüsselstatistik und direktem Aktions-Button. Die Vorräte-Kachel zeigt "14 von 32 aktiv" und einen "Bearbeiten"-Button der direkt in den D3-Kontext führt.
+ Gute Übersicht auf großen Bildschirmen +
+
+ +
+
+
Desktop · 1200px
+
+
+
+ +
+

Einstellungen

+
+ +
+
+
Vorräte
+
14
von 32 aktiv
+
+
Zutaten, die immer im Haushalt vorhanden sind. Sie werden beim Generieren der Einkaufsliste automatisch herausgefiltert.
+ +
+ +
+
+
Haushalt
+
3
Mitglieder
+
+
Familie Raddatz. Haushaltsname und Mitgliederverwaltung.
+ +
+ +
+
Profil
+
Marcel Raddatz
marcel@email.com
+ +
+
+
+
+
+
+
+
+
Mobile · 390px
+
+
+
+
Einstellungen
+
+
+
Vorräte
14
von 32
+
Immer vorhandene Zutaten, automatisch aus Einkaufslisten gefiltert.
+ +
+
+
Haushalt
3 Mitglieder
+
Familie Raddatz
+ +
+
+
Profil
+
Marcel Raddatz · marcel@email.com
+ +
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Kacheln mit Display-Schrift-Zahlen (14, 3) als Schlüsselmetriken — konsistentes Designmuster mit der Varietätspunktzahl im Planer.
  • +
  • Der "Vorräte bearbeiten"-Button auf der Kachel führt direkt zur D3-Unterseite. Keine Zwischennavigation.
  • +
  • Nachteil gegenüber V3: erfordert einen Navigationswechsel für die häufigste Aufgabe (Vorräte bearbeiten). V3 macht das inline möglich.
  • +
+
+
+ + + +
+
+
V3
+
+
Akkordeon mit inline Vorräten
+
Die Settings-Seite zeigt alle Bereiche als aufklappbare Abschnitte. Der Vorräte-Bereich ist beim Öffnen der Seite standardmäßig aufgeklappt und zeigt direkt die StapleChip-Kategorien. Kein Seitenwechsel nötig.
+ Empfohlen · v1 +
+
+ +
+
+
Desktop · 1200px — Vorräte aufgeklappt (Standard)
+
+
+
+ +
+

Einstellungen

+
+ +
+ +
+
Tippe eine Zutat an um sie als Vorrat zu markieren oder zu entfernen. Änderungen werden sofort gespeichert.
+
+
Gemüse
+
+ Karotten + Zwiebeln + Lauch + Knoblauch + Fenchel +
+
+
+
Getreide
+
+ Pasta + Reis + Couscous + Mehl +
+
+
+
+ +
+ +
+ +
+ +
+
+
+
+
+
+
+
+
Mobile · 390px
+
+
+
+
Einstellungen
+
+ +
+ +
+
Gemüse
+
+ Karotten + Zwiebeln + Lauch + Knoblauch +
+
Getreide
+
+ Pasta + Reis + Couscous +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Vorräte-Abschnitt ist beim Öffnen der Seite standardmäßig aufgeklappt (per URL-Hash oder initial state) — der häufigste Grund für diesen Seitenaufruf ist das Bearbeiten von Vorräten.
  • +
  • Kein Seitenwechsel zu /household/staples nötig — die StaplesManager-Komponente rendert direkt im Akkordeon-Bereich. Routing-Vorteil: die /settings URL bleibt beim Bearbeiten erhalten.
  • +
  • Akkordeon-Trigger zeigt den aktuellen Wert im eingeklappten Zustand (z. B. "14 aktiv", "Marcel Raddatz") — der Nutzer kann den Status scannen ohne aufzuklappen.
  • +
  • Auf Mobile perfekt: kein separater "Zurück"-Button nötig. Ein langer Scroll kann mit einem "Nach oben"-Link oder sticky Akkordeon-Headern optimiert werden.
  • +
  • Implementierungshinweis: Der aufgeklappte Bereich enthält direkt die StaplesManager-Komponente (<StaplesManager categories={data.categories} context="settings" />). Keine Seiten-Navigation erforderlich.
  • +
+
+
+ + + +
+
+
V4
+
+
Einstellungs-Sub-Navigation
+
Auf Desktop: eine zweite Navigationsleiste links neben dem Inhalt (Profil · Vorräte · Haushalt). Der Vorräte-Abschnitt ist der Standard-Inhalt. Auf Mobile: flache Liste als Einstieg, dann Drill-down.
+ Skaliert gut bei vielen Settings-Bereichen +
+
+ +
+
+
Desktop · 1200px — Vorräte als Standardansicht
+
+
+
+ + + +
+ +
+
Einstellungen
+ Vorräte + Profil + Haushalt +
+ +
+
+

Vorräte

+ 14 von 32 aktiv +
+
Tippe eine Zutat an um den Vorrats-Status zu ändern. Automatisch gespeichert.
+
+
Gemüse
+
+ Karotten + Zwiebeln + Lauch + Knoblauch + Fenchel + Paprika +
+
+
+
Getreide
+
+ Pasta + Reis + Couscous + Mehl +
+
+
+
+
+
+
+
+
+
Mobile · 390px (Drill-down)
+
+
+
+
+
+
Vorräte
+
14 aktiv
+
+
+
Gemüse
+
+ Karotten + Zwiebeln + Lauch + Knoblauch +
+
Getreide
+
+ Pasta + Reis + Couscous + Mehl +
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Dreifache Navigationsstruktur auf Desktop (App-Sidebar → Settings-Sub-Nav → Inhalt) ist für 3 Einstellungsbereiche Overkill. Skaliert erst ab 6+ Bereichen.
  • +
  • Vorrat als Default-Selektion in der Sub-Nav sinnvoll — direktester Einstieg für J8. Verhindert eine "leere Hub"-Seite.
  • +
  • Mobile-Drill-down: Sub-Nav-Liste → Unterseite. Entspricht dem Standard-Mobile-Pattern (z. B. iOS Systemeinstellungen). Klare Navigation, aber erfordert eine Zurück-Navigation.
  • +
  • Nicht empfohlen für v1: zu viel Struktur für zu wenig Inhalt. V3 (Akkordeon) ist einfacher und erreicht dasselbe Ergebnis ohne Sub-Navigation.
  • +
+
+
+ + + +
+
+
V5
+
+
Schnellzugriff-Dashboard
+
Settings als kleines Dashboard: Haushalt-Zusammenfassung oben (Name + Mitgliederzahl), darunter ein prominenter "Vorräte bearbeiten" Einstieg mit aktuellem Zählstand und zuletzt bearbeiteten Chips als Vorschau. Alles auf einer Seite — kein Drill-down.
+ Informationsdicht · klarer Schwerpunkt +
+
+ +
+
+
Desktop · 1200px
+
+
+
+ +
+ +
+
+
MR
+
Marcel Raddatz
marcel@email.com · Planner
+
+ +
+ +
+ +
+
+
Vorräte
+
14
+
von 32 aktiv
+
+
Immer vorhandene Zutaten werden automatisch aus Einkaufslisten gefiltert.
+ +
+ Karotten + Pasta + Zwiebeln + Reis + +10 weitere +
+ +
+ +
+
Haushalt
+
3
+
Mitglieder · Familie Raddatz
+
+ +
+
+
+
+
+
+
+
+
Mobile · 390px
+
+
+
+ +
+
MR
+
Marcel Raddatz
Planner · Familie Raddatz
+
+
+ +
+
Vorräte
14
von 32
+
+ Karotten + Pasta + Zwiebeln + +11 +
+ +
+ +
+
Haushalt
Familie Raddatz · 3 Mitglieder
+ +
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Chip-Vorschau auf der Hub-Seite (4 aktive Vorräte + "+10 weitere") gibt dem Nutzer sofort Kontext über den aktuellen Zustand — kein Klick nötig um zu verstehen, was konfiguriert ist.
  • +
  • Profil-Leiste oben als Identitätsanker: Name, E-Mail, Rolle. Schafft eine klare "Wer bin ich in diesem Haushalt"-Aussage ohne eigene Profil-Seite besuchen zu müssen.
  • +
  • 2:1 Grid-Layout auf Desktop betont Vorräte als primäre Einstellung — genau das richtige Gewicht für J8.
  • +
  • Nachteil: Der "Alle Vorräte bearbeiten"-Button navigiert trotzdem zur D3-Unterseite (oder öffnet ein Modal). Keine wirklich inline-Bearbeitung wie bei V3. Funktioniert aber gut als Schnellzugriff-Übersicht.
  • +
+
+
+ + +
+ + + + +
+

Machine-readable spec — E1 Einstellungen / D3 Vorräte

+

Authoritative implementation reference for /settings and /household/staples?ctx=settings. Use before building either page.

+ +
/* E1 Settings + D3 Staples — implementation rules
+ * 1.  Recommended variation: V3 (Accordion). Vorräte section is open by default. No navigation to sub-page required.
+ * 2.  D3 = A3: StaplesManager is the same component. context="settings" removes the onboarding sidebar and "Weiter" button.
+ * 3.  StapleChip renders as a pill button, NOT a checkbox. Selected = --green-dark bg + white text. Unselected = transparent bg + --color-border border + --color-text-muted text.
+ * 4.  Auto-save on toggle. No explicit save button. The component already implements debounced PATCH to /household/staples.
+ * 5.  Category label: font-size 10px · font-weight 500 · letter-spacing 0.08em · text-transform uppercase · color --color-text-muted.
+ * 6.  Staple count display ("14 von 32 aktiv"): derive from categories prop — count isStaple=true vs total ingredients.
+ * 7.  Sidebar active item: "Einstellungen" (not "Vorräte" — there is no separate Vorräte sidebar item). Active style: --green-tint bg + --green-dark text.
+ * 8.  Mobile bottom nav active tab: "Einstellungen". Same for both /settings and /household/staples routes.
+ * 9.  Accordion trigger shows current stat in collapsed state: "14 aktiv" for Vorräte. Stat updates reactively as user toggles chips.
+ * 10. Changes to staples (J8) do NOT retroactively update an already-generated shopping list. If the current list should reflect changes, the planner must regenerate it via J5. Consider a note in the UI: "Gilt ab der nächsten Einkaufsliste."
+ * 11. Profile section: show name + email. Edit action navigates to /profile or opens an inline form. Not in scope for J8 — implement minimally.
+ */
+ + + + + + + + + + + + + + + + + + + + + + + + +
ElementValue / RuleNotes
StapleChip
Shapeborder-radius: --radius-full · padding: 6px 14pxfont-size: 13px (desktop) · 12px (mobile)
Selected statebackground: --green-dark · color: #fff · font-weight: 500Toggle off: PATCH ingredient isStaple=false
Unselected statebackground: transparent · border: 1px solid --color-border · color: --color-text-muted · font-weight: 400Toggle on: PATCH ingredient isStaple=true
Debounce300ms after last toggle before PATCH firesAlready implemented in StaplesManager. Do not add extra debounce layers.
Error stateRevert chip to previous state · show inline error messageStaplesManager already handles rollback on API error
Category section
Label10px · weight 500 · tracking 0.08em · uppercase · --color-text-mutedGerman category names from API
Chip griddisplay: flex · flex-wrap: wrap · gap: 7px (desktop) · 6px (mobile)No fixed column count — chips wrap naturally
Settings page (E1) — V3 Accordion
Vorräte sectionOpen by default on page loadUse Svelte derived state or URL hash to control. Default open state.
Collapsed statShow "N aktiv" reactively next to chevronDerive from stapleState in StaplesManager — count true values
Accordion trigger min-height48px (desktop) · 44px (mobile)WCAG: interactive controls must have 44px min touch target
Accordion chevron▲ (open) / ▼ (closed) · color: --color-text-mutedOr use CSS transform on a single chevron SVG
Responsive
Desktop (≥1024px)224px app sidebar + content area (max-width ~680px centered)Active sidebar: "Einstellungen" (Haushalt section)
Mobile (<768px)No sidebar · bottom nav "Einstellungen" active · accordion stacks full-widthChips wrap to multiple lines — no truncation
+
+ + + + diff --git a/specs/e2-members.html b/specs/e2-members.html new file mode 100644 index 0000000..ba3ef85 --- /dev/null +++ b/specs/e2-members.html @@ -0,0 +1,761 @@ + + + + + + E2 — Mitglieder · 5 Variationen + + + + + +
+ +
+
+

E2 — Mitglieder

+

5 Design-Variationen · Desktop-first · Route /members · Journey J7

+
+
+ Version: 1.0
+ Screen: E2
+ Journey: J7
+ Actor: Planner
+ Last updated: 2026-04 +
+
+ +

The members page is a rarely-visited, high-trust page. The planner opens it when the household changes — a new partner joins, a family member needs access removed. The household is typically 2–4 people. Five variations explore the range from a simple list to a panel-based layout. The recommended variation for v1 is V1 (Roster list) — fewest moving parts, matches the access frequency, household size, and task urgency.

+ + + + + +
+
+
V1
+
+
Roster-Liste
+
Lineares Listenformat. Alle Mitglieder als Zeilen mit Avatar, Name, Rolle und Beitrittsdatum. Ausstehende Einladungen darunter. Minimale kognitive Last für eine seltene Aufgabe.
+ Empfohlen · v1 +
+
+ +
+ +
+
Desktop · 1200px
+
+
+
+ + + +
+
+
+

Mitglieder

+ 3 +
+ +
+ +
+
+
MR
+
+
Marcel Raddatz
+
Beigetreten 14. Januar 2026
+
+ Planner +
+
+
+
SR
+
+
Sarah Raddatz
+
Beigetreten 15. Januar 2026
+
+ Mitglied +
+
+
+
TM
+
+
Tom Meier
+
Beigetreten 3. März 2026
+
+ Mitglied +
+
+
+ +
Ausstehende Einladungen · 1
+
+
inv_x8K2j
+
Läuft ab in 2 Tagen
+ + +
+
+
+
+
+
+ +
+
Mobile · 390px
+
+
+
+
+
Mitglieder
+ +
+
+
+
MR
+
Marcel Raddatz
Planner · Seit 14.1.26
+
+
+
+
SR
+
Sarah Raddatz
Mitglied · Seit 15.1.26
+
+
+
+
TM
+
Tom Meier
Mitglied · Seit 3.3.26
+
+
+
Einladungen · 1
+
+
inv_x8K2j2 Tage
+
+
+
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Planner-Avatar in Grün (--green-tint), Mitglieder-Avatar in Blau (--blue-tint) — Rollenfarben konsistent mit den Rollenbadges in der Spec.
  • +
  • Kebab-Menü (⋯) öffnet ein Kontextmenü mit "Zugang entziehen". Destructive action erfordert Bestätigung mit Mitgliedsnamen.
  • +
  • Ausstehende Einladungen haben einen gelben Ablauf-Badge wenn ≤ 3 Tage verbleiben, grau wenn mehr Zeit bleibt.
  • +
  • Auf Mobile: "Einladen"-Button als Kompakt-CTA im Seitenkopf (kein großes Hero-Element) — die Seite ist keine Onboarding-Seite, sondern eine Management-Seite.
  • +
+
+
+ + + +
+
+
V2
+
+
Kachelraster
+
Jedes Mitglied als eigenständige Kachel mit großem Avatar-Kreis, Name, Rolle und Datum. Eine "+" Kachel mit gestricheltem Rand fungiert als Invite-CTA. Visuelle, scannable Übersicht.
+ Geeignet ab 4+ Mitgliedern +
+
+ +
+
+
Desktop · 1200px
+
+
+
+ +
+

Mitglieder

+
+ +
+
MR
+
Marcel Raddatz
14. Jan 2026
+ Planner +
+
+
SR
+
Sarah Raddatz
15. Jan 2026
+ Mitglied +
+
+
TM
+
Tom Meier
3. Mär 2026
+ Mitglied +
+ +
+
+
+
Einladen
+
+
+
+
+
+
+
+
+
Mobile · 390px
+
+
+
+
Mitglieder
+
+
+
MR
+
Marcel R.
+ Planner +
+
+
SR
+
Sarah R.
+ Mitglied +
+
+
TM
+
Tom M.
+ Mitglied +
+
+
+
+
Einladen
+
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Kachelgröße funktioniert nur bis ca. 6 Mitgliedern — ab 7+ muss auf Liste zurückgefallen werden. Für die typische Haushaltsgröße (2–4) unnötig visuell.
  • +
  • Die "+" Einladen-Kachel mit gestricheltem Rahmen ist ein etabliertes Muster für "leerer Slot zum Hinzufügen" — sofort verständlich ohne Label-Erklärung.
  • +
  • Schlechter als V1 für Remove-Aktionen: Die Entfernen-Aktion ist auf der Kachel nicht sichtbar — man muss sie irgendwo verstecken (Hover-State, Kontextmenü). Auf Mobile nicht erreichbar ohne Tap-Geste.
  • +
+
+
+ + + +
+
+
V3
+
+
Split-Panel
+
Geteilte Ansicht: links die Mitgliederliste, rechts das Einladungs-Management-Panel. Auf Desktop zeigt das rechte Panel immer den aktuellen Invite-Status. Auf Mobile: Tab-Navigation zwischen den beiden Bereichen.
+ Für Haushalte mit häufigen Mitgliederwechseln +
+
+ +
+
+
Desktop · 1200px
+
+
+
+ + +
+ +
+
+ Mitglieder + 3 +
+
+
MR
+
Marcel Raddatz
Planner
+
+
+
SR
+
Sarah Raddatz
Mitglied
+ +
+
+
TM
+
Tom Meier
Mitglied
+ +
+
+ +
+
Mitglied einladen
+
Teile diesen Link per WhatsApp, SMS oder E-Mail. Der Empfänger erstellt ein Konto und tritt automatisch dem Haushalt bei.
+
+ https://mealprep.app/join/inv_x8K2j + +
+ +
Ausstehende Einladungen · 1
+
+ inv_x8K2j + 2 Tage + +
+
+
+
+
+
+
+
+
Mobile · 390px (Tab-Navigation)
+
+
+
+
+
Mitglieder
+
+ + +
+
+
+
+
MR
+
Marcel Raddatz
Planner
+
+
+
SR
+
Sarah Raddatz
Mitglied
+ +
+
+
TM
+
Tom Meier
Mitglied
+ +
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Das rechte Panel zeigt immer den Invite-Status — kein Modal, kein Navigationsschritt. Gut für Haushalte, die regelmäßig neue Mitglieder onboarden (z. B. Wohngemeinschaften).
  • +
  • "Entfernen" ist im linken Panel direkt sichtbar als Text-Link in Fehlerfarbe — kein verstecktes Kebab-Menü. Spart einen Schritt, erhöht aber das Risiko eines versehentlichen Taps auf Mobile.
  • +
  • Mobile Tab-Navigation: Tabs mit Unterstrich-Indikator. Die "Mitglieder"-Seite ist der Default. "Einladen"-Tab zeigt das Invite-Panel mit Link-Generator und ausstehendem Invite.
  • +
  • Nachteil: Mehr UI-Fläche für eine seltene Funktion (Invite). Für Haushalte mit 2 Personen wirkt das Panel überdimensioniert.
  • +
+
+
+ + + +
+
+
V4
+
+
Datentabelle
+
Tabellarisches Format mit sortierbaren Spalten: Name, Rolle, Beigetreten, Status, Aktionen. Inline-Aktionen statt Kontextmenü. Kompakt und informationsdicht.
+ Für Power-User · Viele Mitglieder +
+
+ +
+
+
Desktop · 1200px
+
+
+
+ +
+
+

Mitglieder

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name ↕RolleBeigetreten ↕StatusAktionen
MR
Marcel Raddatz
Planner14. Jan 2026Aktiv
SR
Sarah Raddatz
Mitglied15. Jan 2026Aktiv
TM
Tom Meier
Mitglied3. Mär 2026Aktiv
?
inv_x8K2j
Ausstehend · 2 Tage
+
+
+
+
+
+
+
Mobile · 390px (Karten-Fallback)
+
+
+
+
Mitglieder
+
+
+
MR
+
Marcel Raddatz
Planner · 14. Jan 2026
+
+
+
SR
+
Sarah Raddatz
Mitglied · 15. Jan 2026
+ +
+
+
TM
+
Tom Meier
Mitglied · 3. Mär 2026
+ +
+
+
inv_x8K2jAusstehend · 2 Tage
+
+
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Ausstehende Einladungen als Tabellenzeilen mit grauem Hintergrund und "?" Avatar — visuell klar von aktiven Mitgliedern getrennt, ohne eigenen Abschnitt zu benötigen.
  • +
  • Sortierbare Spalten (↕) nur auf Desktop sinnvoll — bei 2–4 Mitgliedern keine echte Notwendigkeit. Für Wohngemeinschaften mit 6+ Personen relevant.
  • +
  • Mobile Fallback: Karten statt Tabelle. Tabellen-Layouts kollabieren auf kleinen Bildschirmen schlecht — Karten sind das korrekte Muster für den selben Inhalt auf Mobile.
  • +
+
+
+ + + +
+
+
V5
+
+
Erweiterbare Zeilen
+
Liste mit progressiver Offenlegung. Jede Mitgliederzeile lässt sich aufklappen, um Detailinfos und Aktionen zu zeigen. Kompakte Standardansicht, Details bei Bedarf — kein Seitennavigation nötig.
+ Gute Balance zwischen V1 und V4 +
+
+ +
+
+
Desktop · 1200px (Sarah-Zeile aufgeklappt)
+
+
+
+ +
+
+

Mitglieder

3
+ +
+ +
+
MR
+
Marcel Raddatz
+ Planner +
Details ▼
+
+ +
+
+
SR
+
Sarah Raddatz
+ Mitglied +
Details ▲
+
+
+
Beigetreten 15. Januar 2026 · Zugang zu Planer (Lesen) und Einkauf
+ +
+
+ +
+
TM
+
Tom Meier
+ Mitglied +
Details ▼
+
+ +
Einladungen · 1
+
inv_x8K2j2 Tage
+
+
+
+
+
+
+
+
Mobile · 390px
+
+
+
+
Mitglieder
+
+
+
MR
+
Marcel Raddatz
Planner
+
+
+ +
+
+
SR
+
Sarah Raddatz
Mitglied
+
+
+
Seit 15. Januar 2026 · Lesen + Einkauf
+
+
+
TM
+
Tom Meier
Mitglied
+
+
+
+ +
+
+
+
+
+ +
+
Design-Entscheidungen
+
    +
  • Der "Zugang entziehen"-Button ist erst nach dem Aufklappen sichtbar — eine natürliche Bestätigungsbarriere ohne expliziten Bestätigungsdialog für das erste Tap.
  • +
  • Aufgeklappte Zeile erhält leichten Surface-Hintergrund (--color-surface) zur visuellen Abgrenzung vom Rest der Liste.
  • +
  • Besser als V1 wenn die Entfernen-Aktion prominent sein soll, aber nicht permanent sichtbar. Schlechter als V1 wenn maximale Einfachheit das Ziel ist.
  • +
  • Auf Mobile: "▼" Chevron als Tap-Target — muss mindestens 44×44px sein. Den gesamten Zeilenbereich tapbar machen ist vorzuziehen.
  • +
+
+
+ + +
+ + + + +
+

Machine-readable spec — E2 Mitglieder

+

Authoritative implementation reference for the /members page. Use before building any component for this route.

+ +
/* E2 Members page — implementation rules
+ * 1.  Recommended variation: V1 (Roster list). Simplest, lowest overhead, matches household size 2–4.
+ * 2.  Avatar colours: Planner = --green-tint bg + --green-dark text. Member = --blue-tint bg + --blue-dark text. Initials only.
+ * 3.  Role badges: same colour pairing as avatars. border-radius: --radius-full. font-size: 11px. font-weight: 500.
+ * 4.  The planner cannot remove themselves — no remove action on the Planner's row ever.
+ * 5.  Pending invites: show expiry in --yellow-tint + --yellow-text when ≤ 3 days remaining, --color-subtle + --color-text-muted otherwise.
+ * 6.  Remove action is destructive — requires a confirmation dialog with the member's name before execution.
+ * 7.  Invite mechanism: generate a link/code. Copy to clipboard. No email delivery system in v1.
+ * 8.  Expired invites: show "Abgelaufen" state. Provide one-tap regeneration — do not require re-opening an invite flow.
+ * 9.  Household members can view this page in read-only mode (no invite button, no remove actions, no pending invites section).
+ * 10. Desktop sidebar: "Mitglieder" item is active, under "Haushalt" section. Mobile: "Einstellungen" tab is active.
+ * 11. WCAG 2.2 AA: avatar initials need 4.5:1 contrast. Role badge text needs 4.5:1 contrast. Confirm before implementing.
+ */
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementValue / RuleNotes
Avatar
Size40px × 40px (desktop) · 36px × 36px (mobile)border-radius: 50%
Planner colourbg --green-tint · text --green-darkContrast OK: #2E6E39 on #E8F5EA ≈ 6.1:1
Member colourbg --blue-tint · text --blue-darkContrast OK: #0C447C on #E6F1FB ≈ 7.4:1
ContentFirst letter of first + last name (uppercase)Max 2 characters
Role badge
Shapeborder-radius: --radius-full · padding: 3px 10pxfont-size: 11px · font-weight: 500
Plannerbg --green-tint · color --green-darkLabel: "Planner"
Memberbg --blue-tint · color --blue-darkLabel: "Mitglied"
Invite / pending
Expiry badge — urgent (≤3d)bg --yellow-tint · color --yellow-text"Läuft ab in N Tagen"
Expiry badge — normalbg --color-subtle · color --color-text-muted"Läuft ab am DD. MMM"
Code fontfont-family: --font-mono · font-size: 13pxInvite codes are monospace
Interactions
Remove actionConfirmation dialog requiredDialog must show member name. Irreversible — member loses access immediately.
Copy invite linknavigator.clipboard.writeText()Show transient "Kopiert!" feedback (checkmark, 2s)
Regenerate invitePOST /household/invite — returns new codeOld code is immediately invalidated
Responsive
Desktop (≥1024px)224px sidebar + full content areaActive sidebar item: Mitglieder (Haushalt section)
Mobile (<768px)No sidebar · bottom nav · "Einstellungen" tab activeV3 mobile uses tab bar within page, not app bottom nav tabs
+
+ + + + diff --git a/specs/planner-c-e-combined.html b/specs/planner-c-e-combined.html new file mode 100644 index 0000000..7d3d294 --- /dev/null +++ b/specs/planner-c-e-combined.html @@ -0,0 +1,755 @@ + + + + + +Planner C+E — Drei Zustände + + + + + + +

Mealplan · Planer · Konzept

+

C + E Kombiniert — Drei Zustände

+

+ Der Hauptbereich unterhalb des Kalender-Grids zeigt immer nützlichen Inhalt — je nach Zustand. + Kein leerer Raum, kein Tab-Wechsel nötig. Das rechte Panel bleibt für den Rezept-Picker reserviert. +

+ +
+
Zustand 1 — Kein Tag ausgewählt
+
Zustand 2 — Tag mit Rezept angeklickt
+
Zustand 3 — Leerer Tag angeklickt
+
+ + + + + +
+
+ Zustand 1 + Kein Tag ausgewählt + Standard beim Laden der Seite +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ +
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min

mittel
+
Di
8

Pasta Bolognese

45 Min

mittel
+
Mi
9

Gemüse-Stir-fry

20 Min

einfach
+
Do
10

Lachs mit Kartoffeln

30 Min

einfach
+
Fr
11

Pizza Margherita

50 Min

aufwändig
+
Sa
12
+wählen
+
So
13
+wählen
+
+ + +
+
+ Diese Woche +
+
+
+
Mo 7.4
+
Hähnchen-Curry
+
35 Min · mittel
+
+
+
+
Di 8.4
+
Pasta Bolognese
+
45 Min · mittel
+
+
+
Mi 9.4
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+
+
Do 10.4
+
Lachs mit Kartoffeln
+
30 Min · einfach
+
+
+
Fr 11.4
+
Pizza Margherita
+
50 Min · aufwändig
+
+
+
Sa 12.4
+
Noch kein Gericht
+
+ Hinzufügen
+
+
+
So 13.4
+
Noch kein Gericht
+
+ Hinzufügen
+
+
+
+ + +
+
+ Vorschläge für ungeplante Tage + Alle Rezepte → +
+ + +
+
+ Samstag, 12. Apr + kein Gericht +
+
+
+
Ramen mit Ei
+
40 Min · mittel
+ Neues Protein +
+
+
Shakshuka
+
25 Min · einfach
+ Keine Überschneidung +
+
+
Rindfleisch-Tacos
+
30 Min · einfach
+ Aufwand: einfach +
+
+
+ + +
+
+ Sonntag, 13. Apr + kein Gericht +
+
+
+
Pho Bo
+
60 Min · aufwändig
+ Neues Protein +
+
+
Lachs-Avocado-Bowl
+
15 Min · einfach
+ Keine Überschneidung +
+
+
Kürbissuppe
+
35 Min · einfach
+ Keine Überschneidung +
+
+
+
+
+ + +
+
+
Heute Abend
+
Pasta Bolognese
+
Dienstag · 45 Min · mittel
+ +
+
+
+
Tag auswählen
+
Klicke eine Kachel um Details zu sehen oder ein Gericht zu wählen
+
+
+
+
+ +
+ Inhalt ohne Klick: Agenda aller 7 Tage (geplante + leere), darunter Vorschläge für die beiden + ungeplanten Tage — anstelle von Score-Delta zeigen die Karten kurze Begründungs-Tags + (Neues Protein / Keine Überschneidung / Aufwand-Hinweis). Das rechte Panel zeigt „Heute Abend" als + direkte Koch-Modus-Abkürzung. Kein leerer Raum, kein Klick nötig. +
+
+ + + + + +
+
+ Zustand 2 + Tag mit Rezept angeklickt + Klick auf Mi, 9. Apr → Gemüse-Stir-fry +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ +
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min

mittel
+
Di
8

Pasta Bolognese

45 Min

mittel
+ +
+
Mi
+
9
+
+

Gemüse-Stir-fry

+

20 Min

+ einfach +
+
+
+
Do
10

Lachs mit Kartoffeln

30 Min

+
Fr
11

Pizza Margherita

50 Min

+
Sa
12
+
+
So
13
+
+
+ + +
+ +
+
+
+
+
+ +
+
+
Mittwoch, 9. Apr · Abendessen
+
Gemüse-Stir-fry
+
20 Min · einfach · 4 Portionen
+
+ Tofu + Paprika + Brokkoli + Karotten + Ingwer + Zucchini + Sesamöl + Sojasauce + Knoblauch +
+
+ einfach + Protein: Tofu + Score ▲ +0.4 +
+
+
+ + + + +
+
+
+ + +
+
Restliche Woche
+
+
+
Do 10.4
+
Lachs mit Kartoffeln
+
30 Min · einfach
+
+
+
Fr 11.4
+
Pizza Margherita
+
50 Min · aufwändig
+
+
+
Sa 12.4
+
Noch kein Gericht
+
+ Hinzufügen
+
+
+
So 13.4
+
Noch kein Gericht
+
+ Hinzufügen
+
+
+
+
+ + +
+
Mittwoch, 9. Apr
+
Wie wirkt dieses Gericht?
+
+ 7.8 + /10 + ▲ +0.4 +
+
+
+
Dieses Gericht
+
+
Kein Protein-Overlap
+
Neue Zutaten (Tofu, Paprika)
+
~Tofu 2× diese Woche
+
+
+
+
+ +
+ Nach Klick auf Mittwoch: Expansion öffnet sich direkt unter dem Grid mit Pfeil-Indikator. + Enthält Rezeptname groß, Zutaten-Tags (normale vs. Grundzutaten gedimmt), + Score-Impact-Badge und alle Aktionen. Unselektierte Kacheln werden leicht ausgeblendet (opacity 55%). + Darunter: kompakte Liste der restlichen Woche — geplante Tage + leere Tage mit „+ Hinzufügen". + Das rechte Panel wechselt auf einen Variety-Kontext: zeigt was dieses Gericht zur Woche beiträgt (positiv/neutral/negativ). +
+
+ + + + + +
+
+ Zustand 3 + Leerer Tag angeklickt + Klick auf Sa, 12. Apr → kein Gericht +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ +
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min

+
Di
8

Pasta Bolognese

45 Min

+
Mi
9

Gemüse-Stir-fry

20 Min

+
Do
10

Lachs mit Kartoffeln

30 Min

+
Fr
11

Pizza Margherita

50 Min

+ +
+
Sa
+
12
+
+ + + wählen +
+
+
+
So
13
+
+
+ + +
+ +
+
+
+
+
+ +
+
Vorschläge für Samstag, 12. Apr
+
+
+
Ramen mit Ei
+
40 Min · mittel
+ Neues Protein +
+
+
Shakshuka
+
25 Min · einfach
+ Keine Überschneidung +
+
+
Rindfleisch-Tacos
+
30 Min · einfach
+ Keine Überschneidung +
+
+
Kürbissuppe
+
35 Min · einfach
+ Aufwand: einfach +
+
+
Tofu-Teriyaki
+
30 Min · einfach
+ Gleiche Zutaten +
+ +
Alle Rezepte →
+
+
+
+ + +
+
Noch diese Woche
+
+
+
Mo 7.4
+
Hähnchen-Curry
+
35 Min · mittel
+
+
+
+
Di 8.4
+
Pasta Bolognese
+
45 Min
+
+
+
So 13.4
+
Noch kein Gericht
+
+ Hinzufügen
+
+
+
+
+ + +
+
Samstag, 12. Apr
+ +
+
+
Ramen mit Ei
40 Min · mittel
+ Top +
+
+
Shakshuka
25 Min · einfach
+ Top +
+
+
Kürbissuppe
35 Min · einfach
+
+
+
Tofu-Teriyaki
30 Min · einfach
+
+
+
Gemüse-Curry
40 Min · mittel
+
+
+
Linseneintopf
50 Min · einfach
+
+
+
Ofen-Lachs
35 Min · einfach
+
+
+
+
+
+ +
+ Nach Klick auf leeren Samstag: Expansion zeigt 5 Vorschläge (3×3 Grid + „Alle Rezepte" als letztes Feld) + mit Begründungs-Tags statt Score-Delta. Die Farbkodierung der Tags macht die Qualität der Empfehlung lesbar ohne Zahlenwert: + Grün = klar besser, Gelb = neutral/tradeoff. Das rechte Panel öffnet gleichzeitig den vollständigen Rezept-Picker mit Suche — + für Nutzer die keinen der Vorschläge wollen. Klick auf eine Karte (main) oder Picker-Item (rechts) führt dasselbe aus: Rezept eintragen. +

+ Tofu-Teriyaki erscheint mit „Gleiche Zutaten" (gelb) — das ist die sinnvolle Alternative zu einem Score-Delta von −0.2: + nicht versteckt, aber klar als Abwägung markiert. +
+
+ + + diff --git a/specs/planner-fullbleed-tiles.html b/specs/planner-fullbleed-tiles.html new file mode 100644 index 0000000..1e2cb6d --- /dev/null +++ b/specs/planner-fullbleed-tiles.html @@ -0,0 +1,773 @@ + + + + + +Planner — Full-Bleed Tiles + + + + + + +

Mealplan · Planer · Full-Bleed Tiles

+

Vollflächige Kacheln

+

+ Das Bild füllt die gesamte Kachelhöhe. Text und Tags werden über einen Gradienten am unteren Rand eingeblendet. + Kein leerer Bereich mehr — jeder Pixel der Kachel ist Bild. + Leere Kacheln behalten die Vorschlagsliste von innen. +

+ + + + + +
+
+ Nahaufnahme + Kachel-Varianten +
+ +
+ + +
+
Gefüllt — Standard
+
+
+
+ Mo + 7 +
+
+
Hähnchen-Curry
+
35 Min · mittel
+
+ Hähnchen + 4 Port. +
+
+
+
+ + +
+
Gefüllt — Heute
+
+
+
+ Di + 8 +
+
+
Pasta Bolognese
+
45 Min · mittel
+
+ Rind + 4 Port. +
+
+
+
+ + +
+
Gefüllt — Ausgewählt
+
+
+
+ Mi + 9 +
+
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+ Tofu + 2 Port. +
+
+
+
+
+ + +
+
Gefüllt — Gedimmt
+
+
+
+ Do + 10 +
+
+
Lachs mit Kartoffeln
+
30 Min · einfach
+
Fisch
+
+
+
+ + +
+
Leer — Vorschläge
+
+
+ Sa + 12 +
+
+
+
+
Gericht wählen
+
+
+
Vorschläge
+
+ Ramen mit Ei + Neu +
+
+ Shakshuka + Kein Overlap +
+
+ Tacos + Leicht +
+
Alle →
+
+
+
+ + +
+
Leer — Ausgewählt
+
+
+ Sa + 12 +
+
+
+
+
Gericht wählen
+
+
+
+
Vorschläge
+
+ Ramen mit Ei + Neu +
+
+ Shakshuka + Kein Overlap +
+
+ Tacos + Leicht +
+
Alle →
+
+
+
+ +
+ +
+ Gradient-Overlay: Dunkel oben (30% → 0%) für den Tages-Header, dunkel unten (0% → 55%) für den Text. + Der mittlere Bereich ist transparent — das Bild ist klar zu sehen. + Text-Shadow auf dem Rezeptnamen sichert Lesbarkeit auch bei hellen Bildern. + Tags nutzen rgba(255,255,255,.2) als Glasmorphismus-Hintergrund. + Zustands-Borders werden per box-shadow umgesetzt (kein Layout-Shift durch border:2px). +
+
+ + + + + +
+
+ 01 + Kein Tag ausgewählt — volle Seite + Kacheln füllen die Viewport-Höhe komplett +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+
+ +
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ +
+ +
+ +
+
+
Mo7
+
+
Hähnchen-Curry
+
35 Min · mittel
+
Hähnchen4 Port.
+
+
+ +
+
+
Di8
+
+
Pasta Bolognese
+
45 Min · mittel
+
RindHeute
+
+
+ +
+
+
Mi9
+
+
Gemüse-Stir-fry
+
20 Min · einfach
+
Tofu2 Port.
+
+
+ +
+
+
Do10
+
+
Lachs mit Kartoffeln
+
30 Min · einfach
+
Fisch2 Port.
+
+
+ +
+
+
Fr11
+
+
Pizza Margherita
+
50 Min · aufwändig
+
vegetarisch4 Port.
+
+
+ +
+
Sa12
+
+
+
+
Gericht wählen
+
+
+
Vorschläge
+
Ramen mit EiNeues Protein
+
ShakshukaKein Overlap
+
TacosAufwand: leicht
+
Alle Rezepte →
+
+
+ +
+
So13
+
+
+
+
Gericht wählen
+
+
+
Vorschläge
+
Pho BoNeues Protein
+
Avocado-BowlKein Overlap
+
KürbissuppeKein Overlap
+
Alle Rezepte →
+
+
+ +
+
+ +
+
+
Heute Abend
+
Pasta Bolognese
+
Dienstag · 45 Min · mittel
+ +
+
+
+
Tag auswählen
+
Klicke eine Kachel um Details zu sehen oder ein Gericht zu planen
+
+
+
+
+
+ Kein leerer Bereich. Das Grid nimmt die volle Höhe ein (height:100% auf Kacheln und Grid). + Gefüllte Kacheln: Bild von oben bis unten, Text per Overlay am unteren Rand. + Leere Kacheln: dieselbe Höhe, oben "+ Gericht wählen", darunter die Vorschlagsliste. + Da Mo–Fr alle Bilder haben, entsteht ein visuell abwechslungsreicher Kalender ohne Blank Space. +
+
+ + + + + +
+
+ 02 + Tag mit Rezept angeklickt + Mi — Expansion öffnet sich unter dem Grid +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+
+
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
+
+ +
+ +
+ +
+
+
Mo7
+
Hähnchen-Curry
35 Min
+
+ +
+
+
Di8
+
Pasta Bolognese
45 Min
+
+ + +
+
+
Mi9
+
+
Gemüse-Stir-fry
+
20 Min · einfach
+
Tofu
+
+
+
+ +
+
+
Do10
+
Lachs mit Kartoffeln
30 Min
+
+ +
+
+
Fr11
+
Pizza Margherita
50 Min
+
+ +
+
Sa12
+
+
+
+ +
+
So13
+
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
Mittwoch, 9. Apr · Abendessen
+
Gemüse-Stir-fry
+
20 Min · einfach · 2 Portionen
+
+ TofuPaprikaBrokkoli + KarottenZucchiniIngwer + SesamölSojasauceKnoblauch +
+
+ einfach + Protein: Tofu + Score ▲ +0.4 +
+
+
+ + + + +
+
+
+
+ +
+
Mittwoch, 9. Apr
+
Wie wirkt dieses Gericht?
+
+ 7.8 + /10 + ▲ +0.4 +
+
+
+
+
✓ Kein Protein-Overlap
+
✓ Neue Zutaten
+
~ Tofu zum 2. Mal
+
+
+
+
+
+ Beim Klick auf eine gefüllte Kachel werden alle anderen auf 42% Deckkraft gedimmt + (gefüllte und leere). Die Tiles bleiben in ihrer Höhe, die Expansion wächst darunter. + Da das Bild jetzt die volle Kachelhöhe einnimmt, wirkt das Dimmen als echter Fokus-Effekt — + wie eine Lupe auf das ausgewählte Gericht. +
+
+ + + diff --git a/specs/planner-layout-mockups.html b/specs/planner-layout-mockups.html new file mode 100644 index 0000000..4984f94 --- /dev/null +++ b/specs/planner-layout-mockups.html @@ -0,0 +1,1820 @@ + + + + + +Planner Layout Mockups — 5 Konzepte + + + + + + + + + + +

Mealplan · Wochenplaner

+

Planner Layout — 5 Konzepte

+

+ Problem: Desktop zeigt ~80 % leeren Platz. Die linke Sidebar hat den Variety-Score nur am unteren Rand. + Rechtes Panel beginnt mit „Kein Tag ausgewählt". Die Kacheln sind zu flach für die Datendichte die wir hätten. + Alle 5 Konzepte nutzen ausschließlich vorhandene API-Daten. +

+ + + + + +
+
+ 01 + Score Dashboard Sidebar + Einfachster Win — Sidebar von oben befüllen +
+ +
+
+ +
+ Wochenplaner + 7.–13. Apr +
+
+ + +
+ +
+ + + + +
+
+ +
+
Mo
+
7
+
+

Hähnchen-Curry

+

35 Min

+ mittel + Hähnchen +
+
+ +
+
Di
+
8
+
+

Pasta Bolognese

+

45 Min

+ mittel + Rind +
+
+ +
+
Mi
+
9
+
+

Gemüse-Stir-fry

+

20 Min

+ einfach + Tofu +
+
+ +
+
Do
+
10
+
+

Lachsfilet mit Kartoffeln

+

30 Min

+ einfach + Fisch +
+
+ +
+
Fr
+
11
+
+

Pizza Margherita

+

50 Min

+ aufwändig + vegetarisch +
+
+ +
+
Sa
+
12
+
+ + + wählen +
+
+ +
+
So
+
13
+
+ + + wählen +
+
+
+
+ + +
+
+
Mittwoch, 9. Apr
+

Gemüse-Stir-fry

+

20 Min · einfach

+
+
+ + + + +
+
+
+
+
+ +
+ Was ändert sich: Variety Score wird an den Anfang der Sidebar verschoben. Darunter folgen Teilwerte (3 Mini-Balken), + Aufwandverteilung (Farbbalken), Top-Warnungen, und ein 5/7-Fortschrittsindikator. + Kacheln werden auf 160 px min-height erhöht und zeigen Effort-Badge + Protein-Tag. +

+ Vorteile: Minimaler Aufwand, keine Layout-Änderung, alle Daten bereits vorhanden (varietyScore API liefert sub-scores + overlaps). + Die Sidebar ist jetzt von oben bis unten gefüllt. Kacheln vermitteln mehr Kontext auf einen Blick. +
+
+ + + + + +
+
+ 02 + Stats-Leiste + Fullscreen-Kalender + 2-Spalten-Layout, Kennzahlen als Kopfzeile +
+ +
+
+ +
+ Wochenplaner + 7.–13. Apr +
+
+ + +
+ + +
+
+
7.8
+
Abwechslungs-Score
+
+
+
5/7
+
Tage geplant
+
+
+
34 Min
+
Ø Kochzeit
+
+
+
Aufwand
+
+
+
+
+
+
+
+
Protein-Verteilung
+
+ Hähnchen ×2 + Rind ×1 + Fisch ×1 + Tofu ×1 +
+
+
+ +
+ +
+
+ +
+
Montag
+
7
+
+

Hähnchen-Curry

+

35 Min

+
+ mittel + Hähnchen +
+
+
+ +
+
Dienstag
+
8
+
+

Pasta Bolognese

+

45 Min

+
+ mittel + Rind +
+
+
+ +
+
Mittwoch
+
9
+
+

Gemüse-Stir-fry

+

20 Min

+
+ einfach + Tofu +
+
+
+ +
+
Donnerstag
+
10
+
+

Lachs mit Kartoffeln

+

30 Min

+
+ einfach + Fisch +
+
+
+ +
+
Freitag
+
11
+
+

Pizza Margherita

+

50 Min

+
+ aufwändig + vegetarisch +
+
+
+ +
+
Samstag
+
12
+
+ + + Gericht wählen +
+
+ +
+
Sonntag
+
13
+
+ + + Gericht wählen +
+
+
+
+ + +
+
+
Mittwoch, 9. Apr
+

Gemüse-Stir-fry

+

20 Min · einfach

+
+
+
+
Score-Vorschau
+
+ 7.8 + /10 + ▲ +0.4 +
+
+
+
+ + + +
+
+
+
+
+ +
+ Was ändert sich: Die linke Sidebar entfällt — stattdessen gibt es eine horizontale Stats-Leiste direkt unter der Topbar. + Sie zeigt Score, geplante Tage, Ø Kochzeit, Aufwand-Farbbalken und Protein-Tags kompakt nebeneinander. + Der Kalender wächst auf die volle verbleibende Breite. Rechtes Panel zeigt beim ausgewählten Tag jetzt auch den aktuellen Score. +

+ Vorteile: Maximaler Platz für den Kalender. Stats auf einen Blick ohne Scrolling. Variety-Score immer sichtbar. + Schwäche: Keine persistente Sidebar für Warnungen. +
+
+ + + + + +
+
+ 03 + Rechtes Panel als Wochenübersicht + „Kein Tag ausgewählt" durch echte Daten ersetzen +
+ +
+
+
+ Wochenplaner + 7.–13. Apr +
+
+ + +
+ +
+ + + + +
+
+
+
Mo
+
7
+
+

Hähnchen-Curry

+

35 Min

+ mittel +
+
+
+
Di
+
8
+
+

Pasta Bolognese

+

45 Min

+ mittel +
+
+
+
Mi
+
9
+
+

Gemüse-Stir-fry

+

20 Min

+ einfach +
+
+
+
Do
+
10
+
+

Lachs mit Kartoffeln

+

30 Min

+ einfach +
+
+
+
Fr
+
11
+
+

Pizza Margherita

+

50 Min

+ aufwändig +
+
+
+
Sa
+
12
+
+
+
+
+
So
+
13
+
+
+
+
+
+ + +
+
Diese Woche
+ + +
+
+
5
+
geplant
+
+
+
2
+
offen
+
+
+
34
+
Ø Min
+
+
+ +
+ + +
Ungeplante Tage
+
+
+ Samstag, 12. Apr + Noch kein Gericht — Klicken zum Planen +
+
+ Sonntag, 13. Apr + Noch kein Gericht — Klicken zum Planen +
+
+ +
+ + +
Heute Abend
+
+

Pasta Bolognese

+

45 Min · mittel

+ +
+ + +
+
+
+
+ +
+ Was ändert sich: Nur das rechte Panel im Idle-Zustand. Statt „Kein Tag ausgewählt" zeigt es eine echte Wochenübersicht: + Geplant/Offen/Ø Kochzeit, ungeplante Tage als Einladung zum Klicken, und „Heute Abend" als schnellen Koch-Modus-Einstieg. +

+ Vorteile: Kein leerer Zustand mehr. Nutzer sehen sofort den Stand ihrer Woche. „Heute Abend" löst den häufigsten + Use-Case (abends schnell kochen starten) direkt. Minimale Änderungen am restlichen Layout. +
+
+ + + + + +
+
+ 04 + Variety rechts, Kalender breiter + Variety-Analyse ins rechte Panel, kein linkes Sidebar mehr +
+ +
+
+
+ Wochenplaner + 7.–13. Apr 2026 +
+
+ + +
+ +
+ +
+
+
+
Mo, 7.
+
+

Hähnchen-Curry

+

35 Min

+ mittel + Hähnchen +
+
+
+
Di, 8. ★
+
+

Pasta Bolognese

+

45 Min

+ mittel + Rind +
+
+
+
Mi, 9.
+
+

Gemüse-Stir-fry

+

20 Min

+ einfach + Tofu +
+
+
+
Do, 10.
+
+

Lachs mit Kartoffeln

+

30 Min

+ einfach + Fisch +
+
+
+
Fr, 11.
+
+

Pizza Margherita

+

50 Min

+ aufwändig + vegetarisch +
+
+
+
Sa, 12.
+
+ + + Gericht wählen +
+
+
+
So, 13.
+
+ + + Gericht wählen +
+
+
+
+ + +
+ +
+
+ 7.8 +
+
+ +
+ Protein +
+ 8.0 +
+
+ Zutaten +
+ 7.2 +
+
+ Aufwand +
+ 8.2 +
+
+
+ +
+ + +
+
Aufwand diese Woche
+
+
3 einfach
+
2 mittel
+
1
+
+
+ + +
+
Überschneidungen
+
⚠ Hähnchen an Mo, Mi, Do
+
⚠ Tomaten an Di, Do
+ Variety-Analyse → +
+ +
+ + +
+
Heute Abend
+
+

Pasta Bolognese

+

Dienstag · 45 Min · mittel

+ +
+
+
+
+
+
+ +
+ Was ändert sich: Linke Sidebar komplett entfernt → Kalender gewinnt ~200 px Breite. + Das rechte Panel wird zum permanenten Variety-Dashboard: Score-Ring, Teilwerte, Aufwand-Balken, Warnungen. + Ganz unten: „Heute Abend" als direkter Koch-Modus-Einstieg. Beim Klick auf einen Tag ersetzt das Day-Detail-Panel diesen Inhalt. +

+ Vorteile: Variety-Score ist immer im Blick, nicht nur am unteren Sidebar-Rand. + Breiterer Kalender = mehr Platz für Rezeptnamen. Klarer Haupt-CTA (Koch-Modus) ohne Tab-Wechsel. +
+
+ + + + + +
+
+ 05 + Mobile — Wochengitter statt Tagesstreifen + Alle 7 Tage auf einmal sichtbar, Score im Header +
+ +
+
+ + +
+
Aktueller Zustand
+
+
9:41●●●
+
+ Diese Woche +
+ + + +
+
+ +
+
+ 7.8 + /10 Abwechslungs-Score +
+
+
⚠ Hähnchen in 3 Mahlzeiten
+
+ +
+
Mo
+
Di
8
+
Mi
9
+
Do
+
Fr
+
Sa
12
+
So
13
+
+ +
Mittwoch, 9. April
+
+

Gemüse-Stir-fry

+

20 Min · einfach

+
+ + +
+
+
+
+ + +
+
Neuer Vorschlag — 2-Spalten-Gitter
+
+
9:41●●●
+ +
+ Diese Woche +
+
+ 7.8 + /10 +
+ + +
+
+ +
+
+ +
+
Montag · 7.
+
Hähnchen-Curry
+
35 Min · mittel
+
+ +
+
Dienstag · 8. ★
+
Pasta Bolognese
+
45 Min · mittel
+
+ +
+
+
+
Mittwoch · 9. — Ausgewählt
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+
+ + +
+
+
+ +
+
Donnerstag · 10.
+
Lachs mit Kartoffeln
+
30 Min · einfach
+
+ +
+
Freitag · 11.
+
Pizza Margherita
+
50 Min · aufwändig
+
+ +
+
Samstag · 12.
+
+
+
wählen
+
+ +
+
Sonntag · 13.
+
+
+
wählen
+
+
+
+
+
+ +
+
+ +
+ Was ändert sich (Mobile): Die gelbe Score-Banner wird aus dem Scroll-Bereich herausgelöst und als kompaktes Badge + in die Topbar integriert (spart ~80 px). Der horizontale Tagesstreifen + separate „Restliche Woche" Liste werden ersetzt durch ein + 2-Spalten-Gitter, das alle 7 Tage auf einmal zeigt. Der ausgewählte Tag expandiert zur vollen Breite mit integrierten Aktionen (kein separater großer Card mehr). +

+ Vorteile: Auf einem Blick sieht man die ganze Woche. Kein Scrollen nötig um alle Tage zu sehen. + Score-Badge bleibt jederzeit sichtbar ohne Platz zu fressen. Expandierter Tag ersetzt das separate Tagesdetail-Pattern. +
+
+ + + + + +
+ +
+ + + diff --git a/specs/planner-main-area-mockups.html b/specs/planner-main-area-mockups.html new file mode 100644 index 0000000..2b1d903 --- /dev/null +++ b/specs/planner-main-area-mockups.html @@ -0,0 +1,1064 @@ + + + + + +Planner Hauptbereich — 5 Ideen für den leeren Raum + + + + + + +

Mealplan · Wochenplaner · Hauptbereich

+

Was kommt unter das Kalender-Grid?

+

+ Das 7-Spalten-Grid nimmt ~150 px ein. Die Main-Area ist die volle Viewport-Höhe. + Fünf Konzepte, was den Raum darunter sinnvoll füllt — je mit dem gleichen Sidebar (Score oben) und rechtem Panel. +

+ + + + + +
+
+ A + Variety-Dashboard direkt im Planer + Kein separater /planner/variety Tab mehr nötig +
+ +
+
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ + + + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min

mittel
Hähnchen
+
Di
8

Pasta Bolognese

45 Min

mittel
Rind
+
Mi
9

Gemüse-Stir-fry

20 Min

einfach
Tofu
+
Do
10

Lachs mit Kartoffeln

30 Min

einfach
Fisch
+
Fr
11

Pizza Margherita

50 Min

aufwändig
vegetarisch
+
Sa
12
+wählen
+
So
13
+wählen
+
+ + + +
+
+ Wochenanalyse + Detailansicht → +
+ + +
+
+
8.0/10
+
Protein-Vielfalt
+
+
+
+
7.2/10
+
Zutaten-Überlappung
+
+
+
+
8.2/10
+
Aufwands-Balance
+
+
+
+ + +
+ +
+
Mo
Di
Mi
Do
Fr
Sa
So
+ +
Hähnchen
+
HÄH
+
+
+
HÄH
+
+
+
+ +
Rind
+
+
RIND
+
+
+
+
+
+ +
Tofu / veg.
+
+
+
TOFU
+
+
VEG
+
+
+ +
Fisch
+
+
+
+
FISCH
+
+
+
+
+ + +
+
+
Aufwandsverteilung
+
+
3 × einfach
+
2 × mittel
+
1
+
+
+ ● einfach + ● mittel + ● aufwändig +
+
+
+
Hinweise
+
+
+
⚠ Hähnchen
+
an Mo + Do geplant
+ Tag tauschen → +
+
+
⚠ Tomaten
+
Di + Do wiederholt
+ Tag tauschen → +
+
+
+
+
+
+ + +
+
Mittwoch, 9. Apr
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+ + + + +
+
+
+
+ +
+ Was passiert: Die /planner/variety Seite existiert weiter als Deep-Link, aber ihre Kerninfos (Sub-Scores, + Protein-Grid, Aufwands-Balken, Warnungen) sind direkt im Planer sichtbar — kein Tab-Wechsel nötig. + Alle Daten kommen aus dem bereits geladenen varietyScore Objekt. Der „Detailansicht →" Link führt zur vollen Analyse-Seite. +

+ Gelber Ring im Protein-Grid = Protein wiederholt sich an mehreren Tagen (kommt von tagRepeats). +
+
+ + + + + +
+
+ B + Mehre Wochen gleichzeitig + Aktuelle + Folgewochen im selben Grid +
+ +
+
+
+ Wochenplaner +
+ Apr 2026 +
+ + +
+ +
+ + + + +
+ +
+
Mo
+
Di
+
Mi
+
Do
+
Fr
+
Sa
+
So
+
+ + +
+
+ KW 15 + 7.–13. Apr + Aktuell + 7.8 / 10 +
+
+
7

Hähnchen-Curry

35 Min · mittel

Hähnchen
+
8

Pasta Bolognese

45 Min · mittel

Rind
+
9

Gemüse-Stir-fry

20 Min · einfach

Tofu
+
10

Lachs mit Kartoffeln

30 Min · einfach

Fisch
+
11

Pizza Margherita

50 Min · aufwändig

veg.
+
12
+wählen
+
13
+wählen
+
+
+ + +
+
+ KW 16 + 14.–20. Apr + 6.1 / 10 +
+
+
14

Hähnchen-Pfanne

25 Min · einfach

Hähnchen
+
15
+wählen
+
16

Linsensuppe

40 Min · einfach

veg.
+
17
+wählen
+
18
+wählen
+
19
+wählen
+
20
+wählen
+
+
+ + +
+
+ KW 17 + 21.–27. Apr + leer +
+
+
21
+wählen
+
22
+
23
+
24
+
25
+
26
+
27
+
+
+
+ + +
+
Mittwoch, 9. Apr · KW 15
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+ + + +
+
+
+
+ +
+ Was passiert: Die Main-Area zeigt 3 aufeinanderfolgende Wochen. Die Spaltenköpfe (Mo–So) sind geteilt. + Jede Woche hat ein Label mit KW-Nummer, Datumsbereich und Score. KW 17 ist gedimmt — noch kein Plan, aber klickbar. + Die Sidebar zeigt den Score der aktuell fokussierten Woche + eine kompakte Wochenliste. +

+ Was neu gebaut werden muss: API-Abruf für Folgewochen (gleicher Endpoint, andere weekStart Parameter). + Zwei zusätzliche Abrufe beim Laden der Seite. Score für Folgewochen wird mit kleinerem Datensatz berechnet. +
+
+ + + + + +
+
+ C + Empfehlungen unter dem Grid + Ungeplante Tage werden direkt mit Vorschlägen befüllt +
+ +
+
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ + + + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min · mittel

+
Di
8

Pasta Bolognese

45 Min · mittel

+
Mi
9

Gemüse-Stir-fry

20 Min · einfach

+
Do
10

Lachs mit Kartoffeln

30 Min · einfach

+
Fr
11

Pizza Margherita

50 Min · aufwändig

+
Sa
12
+
+
So
13
+
+
+ + +
+
+ Empfehlungen für ungeplante Tage + Alle Rezepte → +
+ + +
+
+ Samstag, 12. Apr + kein Gericht +
+
+
+
Ramen mit Ei
+
40 Min · mittel
+ +0.9 Score +
+
+
Shakshuka
+
25 Min · einfach
+ +0.7 Score +
+
+
Rindfleisch-Tacos
+
30 Min · einfach
+ +0.5 Score +
+
+
+ + +
+
+ Sonntag, 13. Apr + kein Gericht +
+
+
+
Pho Bo
+
60 Min · aufwändig
+ +1.1 Score +
+
+
Lachstartar auf Avocado
+
15 Min · einfach
+ +0.8 Score +
+
+
Hähnchen-Wrap
+
20 Min · einfach
+ −0.2 Score +
+
+
+ + +
+ Alle Tage sind geplant. Gute Woche! Score: 7.8/10 +
+
+
+ + +
+
Mittwoch, 9. Apr
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+ + + +
+
+
+
+ +
+ Was passiert: Für jede Lücke in der Woche werden 3 Rezept-Vorschläge inline gezeigt — sortiert nach Score-Delta. + Klick auf eine Karte → setzt das Rezept direkt (kein Picker-Sheet nötig). Score-Delta (grün/rot) kommt bereits + aus der SuggestionResponse API, die der Planer schon abruft. +

+ Vorteil: Wenn alle 7 Tage geplant sind, verschwindet diese Sektion und wird durch eine + Bestätigungsmeldung ersetzt. Die Hauptfunktion des Planers (leere Tage füllen) passiert ohne Panel-Wechsel. +
+
+ + + + + +
+
+ D + Einkaufsliste unter dem Grid + Was diese Woche eingekauft werden muss +
+ +
+
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min

+
Di
8

Pasta Bolognese

45 Min

+
Mi
9

Gemüse-Stir-fry

20 Min

+
Do
10

Lachs mit Kartoffeln

30 Min

+
Fr
11

Pizza Margherita

50 Min

+
Sa
12
+
+
So
13
+
+
+ + +
+
+ Einkaufsliste diese Woche +
+ Grundzutaten ausblenden + Exportieren → +
+
+ +
+ +
+
Fleisch & Fisch
+
+
+
Hähnchenbrust
Mo — Hähnchen-Curry
+
+
+
+
Rinderhack 400 g
Di — Pasta Bolognese
+
+
+
+
Lachsfilet 2×
Do — Lachs mit Kartoffeln
+
+
+ + +
+
Gemüse
+
+
+
Paprika (rot + gelb)
Mi — Gemüse-Stir-fry
+
+
+
+
Tomaten, gehackt 2×
Di, Do
+
+
+
+
Zwiebeln
Mo, Di, Mi
+
+
+
+
Kartoffeln 600 g
Do — Lachs mit Kartoffeln
+
+
+ + +
+
Grundzutaten
+
+
+
Olivenöl
Alle Gerichte
+
+
+
+
Pasta 500 g
Di — Pasta Bolognese
+
+
+
+
Kokosmilch 400 ml
Mo — Hähnchen-Curry
+
+
+
+
Pizzateig (fertig)
Fr — Pizza Margherita
+
+
+
+
+
+ +
+
Mittwoch, 9. Apr
+
Gemüse-Stir-fry
+
20 Min · einfach
+
+ + + +
+
+
+
+ +
+ Was passiert: Alle geplanten Rezepte der Woche werden zusammengeführt und ihre Zutaten in Kategorien gruppiert. + Checkboxes ermöglichen das Abhaken beim Einkaufen. Mehrfach benötigte Zutaten (z.B. Tomaten Di+Do) werden gebündelt. +

+ Was neu gebaut werden muss: Backend-Endpoint zur Zutatenaggregation (oder Frontend-Zusammenführung aus den + bereits geladenen Rezeptdaten). Checkboxen-State wäre nur clientseitig (kein Speichern nötig). Grundzutat-Flag + ist bereits im Datenmodell vorhanden (RecipeIngredient.staple), damit man diese ein-/ausblenden kann. +
+
+ + + + + +
+
+ E + Klick-Expansion direkt im Grid + Kachel aufklappen statt rechtes Panel öffnen +
+ +
+
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+ +
+ + + +
+ +
+
Mo
7

Hähnchen-Curry

35 Min · mittel

+
Di
8

Pasta Bolognese

45 Min · mittel

+ + +
+
Mi
9
+
+

Gemüse-Stir-fry

+

20 Min · einfach

+
+
+
+ +
Do
10

Lachs mit Kartoffeln

30 Min · einfach

+
Fr
11

Pizza Margherita

50 Min · aufwändig

+
Sa
12
+
+
So
13
+
+
+ + +
+ +
+
+
+
+
+
+
+ +
+
+
Mittwoch, 9. Apr · Abendessen
+
Gemüse-Stir-fry
+
20 Min · einfach · 4 Portionen
+ +
+ Tofu + Paprika + Brokkoli + Karotten + Ingwer + Sesamöl + Sojasauce + Knoblauch +
+ +
+ einfach + Protein: Tofu + Score ▲ +0.4 +
+
+
+ + + + +
+
+
+ + +
+
+ Restliche Woche +
+
+
+ Do 10.4 + Lachs mit Kartoffeln + 30 Min · einfach +
+
+ Fr 11.4 + Pizza Margherita + 50 Min · aufwändig +
+
+ Sa 12.4 + Noch kein Gericht + + Hinzufügen +
+
+ So 13.4 + Noch kein Gericht + + Hinzufügen +
+
+
+
+ + +
+
+
Detail-Bereich ist jetzt im Hauptbereich
+
Das rechte Panel könnte für den Rezept-Picker reserviert bleiben
+
+
+
Klicke einen leeren Tag um hier den Picker zu öffnen
+
+
+
+
+ +
+ Was passiert: Klick auf eine Kachel öffnet einen Expansion-Bereich direkt unter dem Grid + (volle Breite, mit Pfeil-Indikator zur aktiven Kachel). Gezeigt werden: Rezeptname groß, Metadaten, Zutaten als Tags + (normale vs. Grundzutaten farblich unterschieden), Score-Auswirkung und alle Aktionen. + Darunter folgt die restliche Woche als kompakte Agenda-Liste — ungeplante Tage als gestrichelte Zeilen mit „+ Hinzufügen". +

+ Konsequenz für rechtes Panel: Das Day-Detail zieht in die Main-Area um. Das rechte Panel bleibt + als reiner Rezept-Picker reserviert — es wäre nur offen wenn man aktiv ein Gericht auswählt. + Das eliminiert den Idle-Zustand „Kein Tag ausgewählt" komplett. +
+
+ + + diff --git a/specs/planner-tall-tiles.html b/specs/planner-tall-tiles.html new file mode 100644 index 0000000..ccdd658 --- /dev/null +++ b/specs/planner-tall-tiles.html @@ -0,0 +1,847 @@ + + + + + +Planner — Tall Tiles + + + + + + +

Mealplan · Planer · Tall Tiles

+

Hohe Kacheln — drei Zustände

+

+ Kein separater Agenda-Bereich. Die Kacheln selbst sind die Informationsschicht: + Bild-Placeholder oben, Rezeptname, Metadaten, Tags. Leere Kacheln nutzen die Höhe + für Vorschläge direkt inline. Unter dem Grid erscheint nur noch die Expansion beim Klick. +

+ + + + + +
+
+ 01 + Kein Tag ausgewählt + Standard beim Laden der Seite +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+
+ + +
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ + +
+
+ + +
+
+ Mo + 7 +
+
+ +
+
+
+
+
Hähnchen-Curry
+
35 Min
+
+ mittel + Hähnchen +
+
4 Portionen
+
+
+ + +
+
+ Di + 8 +
+
+
+
+
Pasta Bolognese
+
45 Min
+
+ mittel + Rind +
+
4 Portionen
+
+
+ + +
+
+ Mi + 9 +
+
+
+
+
Gemüse-Stir-fry
+
20 Min
+
+ einfach + Tofu +
+
2 Portionen
+
+
+ + +
+
+ Do + 10 +
+
+
+
+
Lachs mit Kartoffeln
+
30 Min
+
+ einfach + Fisch +
+
2 Portionen
+
+
+ + +
+
+ Fr + 11 +
+
+
+
+
Pizza Margherita
+
50 Min
+
+ aufwändig + vegetarisch +
+
4 Portionen
+
+
+ + +
+
+ Sa + 12 +
+
+
+
+
Gericht wählen
+
+
+
Vorschläge
+
+ Ramen mit Ei + Neues Protein +
+
+ Shakshuka + Kein Overlap +
+
+ Tacos + Aufwand: leicht +
+
Alle Rezepte →
+
+
+ + +
+
+ So + 13 +
+
+
+
+
Gericht wählen
+
+
+
Vorschläge
+
+ Pho Bo + Neues Protein +
+
+ Avocado-Bowl + Kein Overlap +
+
+ Kürbissuppe + Kein Overlap +
+
Alle Rezepte →
+
+
+ +
+
+ + +
+
+
Heute Abend
+
Pasta Bolognese
+
Dienstag · 45 Min · mittel
+ +
+
+
+
Tag auswählen
+
Klicke eine Kachel um Details zu sehen oder ein Gericht zu planen
+
+
+ +
+
+ +
+ Kein Agenda-Bereich. Die Kacheln füllen die volle Höhe des Hauptbereichs. + Geplante Tage zeigen: farbiges Bild-Placeholder (wird durch heroImageUrl ersetzt), + Rezeptname, Kochzeit, Effort-Badge, Protein-Tag, Portionenanzahl. + Leere Kacheln (Sa, So) zeigen direkt 3 Vorschläge mit Begründungs-Tags — kein Scrollen nötig. + Klick auf einen Vorschlag-Eintrag → Rezept wird direkt eingetragen. + Klick auf die Kachel selbst → öffnet Expansion unten (Zustand 3). +
+
+ + + + + +
+
+ 02 + Tag mit Rezept angeklickt + Klick auf Mi — Gemüse-Stir-fry +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+
+
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+
Geplant
+
5/ 7 Tage
+
+
+
+
+
+ +
+ +
+ +
+
Mo7
+
+
Hähnchen-Curry
35 Min
+
+ +
+
Di8
+
+
Pasta Bolognese
45 Min
+
+ + +
+
Mi9
+
+
+
Gemüse-Stir-fry
+
20 Min
+
einfachTofu
+
+
+
+ +
+
Do10
+
+
Lachs mit Kartoffeln
30 Min
+
+ +
+
Fr11
+
+
Pizza Margherita
50 Min
+
+ +
+
Sa12
+
+
+
+ +
+
So13
+
+
+
+ +
+ + +
+
+
+
+
+
+ +
+
+
Mittwoch, 9. Apr · Abendessen
+
Gemüse-Stir-fry
+
20 Min · einfach · 2 Portionen
+
+ Tofu + Paprika + Brokkoli + Karotten + Zucchini + Ingwer + Sesamöl + Sojasauce + Knoblauch + Salz, Pfeffer +
+
+ einfach + Protein: Tofu + Score ▲ +0.4 +
+
+
+ + + + +
+
+
+ +
+ + +
+
Mittwoch, 9. Apr
+
Wie wirkt dieses Gericht auf die Woche?
+
+ 7.8 + /10 + ▲ +0.4 +
+
+
+
Bewertung
+
+
✓ Kein Protein-Overlap
+
✓ Neue Zutaten
+
~ Tofu zum 2. Mal
+
+
+ +
+
+ +
+ Nach Klick auf Mittwoch: Nicht-ausgewählte Kacheln werden auf 45% Deckkraft gedimmt. + Die Expansion erscheint direkt unter dem Grid (Pfeil zeigt zu Mi). + Die Kacheln bleiben auf ihrer Höhe — der Expansion-Bereich wächst zusätzlich darunter. + Zutaten zeigen normale Zutaten als Pills; Grundzutaten (Sesamöl, Sojasauce…) gedimmt. +
+
+ + + + + +
+
+ 03 + Leerer Tag angeklickt + Klick auf Sa — kein Gericht geplant +
+ +
+
+ Wochenplaner + 7.–13. Apr +
+ + +
+
+
+
+
Abwechslungs-Score
+
7.8/10
+
+
Protein
8.0
+
Zutaten
7.2
+
Aufwand
8.2
+ Variety-Analyse → +
+
+
Überschneidungen
+
⚠ Hähnchen an Mo + Do
+
⚠ Tomaten an Di + Do
+
+
+ +
+
+ +
+
Mo7
+
+
Hähnchen-Curry
35 Min
+
+ +
+
Di8
+
+
Pasta Bolognese
45 Min
+
+ +
+
Mi9
+
+
Gemüse-Stir-fry
20 Min
+
+ +
+
Do10
+
+
Lachs mit Kartoffeln
30 Min
+
+ +
+
Fr11
+
+
Pizza Margherita
50 Min
+
+ + +
+
+ Sa + 12 +
+
+
+
+
Gericht wählen
+
+
+
+
Vorschläge
+
+ Ramen mit Ei + Neues Protein +
+
+ Shakshuka + Kein Overlap +
+
+ Tacos + Aufwand: leicht +
+
Alle Rezepte →
+
+
+ +
+
So13
+
+
+
+ +
+ + +
+
+
+
+
+
+ +
+
Samstag, 12. Apr — Alle Vorschläge
+
+ +
+
Ramen mit Ei
+
40 Min · mittel
+ Neues Protein +
+ +
+
Shakshuka
+
25 Min · einfach
+ Kein Overlap +
+ +
+
Rindfleisch-Tacos
+
30 Min · einfach
+ Gleiche Zutaten +
+ +
+
Kürbissuppe
+
35 Min · einfach
+ Kein Overlap +
+ +
+
+
+ +
+ + +
+
Samstag, 12. Apr
+ +
+
Ramen mit Ei
40 Min · mittel
Top
+
Shakshuka
25 Min · einfach
Top
+
Kürbissuppe
35 Min · einfach
+
Tofu-Teriyaki
30 Min · einfach
+
Gemüse-Curry
40 Min · mittel
+
Linseneintopf
50 Min · einfach
+
Ofen-Lachs
35 Min · einfach
+
+
+ +
+
+ +
+ Nach Klick auf leeren Samstag: Die Kachel selbst zeigt schon die 3 Inline-Vorschläge (sichtbar seit Zustand 1). + Der Pfeil-Indikator erscheint, die Expansion zeigt alle 4 Vorschläge nebeneinander als klickbare Karten. + Das rechte Panel öffnet gleichzeitig den vollständigen Rezept-Picker mit Suche — für alle anderen Optionen. + Klick auf eine Karte (main) oder Picker-Eintrag (rechts) trägt das Rezept ein und schließt die Expansion. +
+
+ + + -- 2.49.1 From 30ba53099cc3f6a9d205fa36d4bcd19340b27fa4 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 08:56:57 +0200 Subject: [PATCH 32/49] refactor(recipes): drop is_child_friendly column and remove from all layers V025 migration drops the column. Removed from Recipe entity, RecipeDetailResponse, RecipeSummaryResponse, RecipeRepository JPQL, RecipeService, and RecipeController. Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/recipe/RecipeController.java | 5 ++--- .../recipeapp/recipe/RecipeRepository.java | 6 +---- .../com/recipeapp/recipe/RecipeService.java | 22 ++++++++++--------- .../recipe/dto/RecipeDetailResponse.java | 1 - .../recipe/dto/RecipeSummaryResponse.java | 3 +-- .../com/recipeapp/recipe/entity/Recipe.java | 14 +++++------- .../V025__drop_is_child_friendly.sql | 1 + .../planning/PlanningServiceTest.java | 2 +- .../recipeapp/planning/SuggestionsTest.java | 2 +- .../recipeapp/planning/VarietyScoreTest.java | 2 +- .../recipe/RecipeControllerTest.java | 13 +++++------ .../recipeapp/recipe/RecipeServiceTest.java | 3 ++- .../shopping/ShoppingServiceTest.java | 2 +- frontend/src/lib/api/schema.d.ts | 5 +---- 14 files changed, 36 insertions(+), 45 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V025__drop_is_child_friendly.sql diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeController.java b/backend/src/main/java/com/recipeapp/recipe/RecipeController.java index 445cabe..a9c729a 100644 --- a/backend/src/main/java/com/recipeapp/recipe/RecipeController.java +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeController.java @@ -29,7 +29,6 @@ public class RecipeController { Principal principal, @RequestParam(required = false) String search, @RequestParam(required = false) String effort, - @RequestParam(required = false) Boolean isChildFriendly, @RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin, @RequestParam(required = false) String sort, @RequestParam(defaultValue = "20") int limit, @@ -37,9 +36,9 @@ public class RecipeController { UUID householdId = householdResolver.resolve(principal.getName()); List recipes = recipeService.listRecipes( - householdId, search, effort, isChildFriendly, cookTimeMaxMin, sort, limit, offset); + householdId, search, effort, cookTimeMaxMin, sort, limit, offset); long total = recipeService.countRecipes( - householdId, search, effort, isChildFriendly, cookTimeMaxMin); + householdId, search, effort, cookTimeMaxMin); var pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total); var meta = new ApiResponse.Meta(pagination); diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java b/backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java index f4b3c11..6b7a3d0 100644 --- a/backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeRepository.java @@ -18,13 +18,12 @@ public interface RecipeRepository extends JpaRepository { @Query(""" SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse( - r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.isChildFriendly, r.heroImageUrl) + r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.heroImagePreview) FROM Recipe r WHERE r.household.id = :householdId AND r.deletedAt IS NULL AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%'))) AND (:effort IS NULL OR r.effort = CAST(:effort AS string)) - AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly) AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin) ORDER BY r.createdAt DESC """) @@ -32,7 +31,6 @@ public interface RecipeRepository extends JpaRepository { @Param("householdId") UUID householdId, @Param("search") String search, @Param("effort") String effort, - @Param("isChildFriendly") Boolean isChildFriendly, @Param("cookTimeMaxMin") Integer cookTimeMaxMin, @Param("sort") String sort, @Param("limit") int limit, @@ -45,13 +43,11 @@ public interface RecipeRepository extends JpaRepository { AND r.deletedAt IS NULL AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%'))) AND (:effort IS NULL OR r.effort = CAST(:effort AS string)) - AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly) AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin) """) long countFiltered( @Param("householdId") UUID householdId, @Param("search") String search, @Param("effort") String effort, - @Param("isChildFriendly") Boolean isChildFriendly, @Param("cookTimeMaxMin") Integer cookTimeMaxMin); } diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java index de5471b..cfd36f2 100644 --- a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java @@ -22,31 +22,31 @@ public class RecipeService { private final TagRepository tagRepository; private final IngredientCategoryRepository ingredientCategoryRepository; private final HouseholdRepository householdRepository; + private final ImageCompressor imageCompressor; public RecipeService(RecipeRepository recipeRepository, IngredientRepository ingredientRepository, TagRepository tagRepository, IngredientCategoryRepository ingredientCategoryRepository, - HouseholdRepository householdRepository) { + HouseholdRepository householdRepository, + ImageCompressor imageCompressor) { this.recipeRepository = recipeRepository; this.ingredientRepository = ingredientRepository; this.tagRepository = tagRepository; this.ingredientCategoryRepository = ingredientCategoryRepository; this.householdRepository = householdRepository; + this.imageCompressor = imageCompressor; } @Transactional(readOnly = true) public List listRecipes(UUID householdId, String search, String effort, - Boolean isChildFriendly, Integer cookTimeMaxMin, - String sort, int limit, int offset) { - return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly, - cookTimeMaxMin, sort, limit, offset); + Integer cookTimeMaxMin, String sort, int limit, int offset) { + return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset); } @Transactional(readOnly = true) - public long countRecipes(UUID householdId, String search, String effort, - Boolean isChildFriendly, Integer cookTimeMaxMin) { - return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin); + public long countRecipes(UUID householdId, String search, String effort, Integer cookTimeMaxMin) { + return recipeRepository.countFiltered(householdId, search, effort, cookTimeMaxMin); } @Transactional(readOnly = true) @@ -63,8 +63,9 @@ public class RecipeService { Recipe recipe = new Recipe(household, request.name(), request.serves() != null ? request.serves().shortValue() : 0, request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0, - request.effort(), false); + request.effort()); recipe.setHeroImageUrl(request.heroImageUrl()); + recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl())); addIngredients(recipe, household, request.ingredients()); addSteps(recipe, request.steps()); @@ -84,6 +85,7 @@ public class RecipeService { recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0); recipe.setEffort(request.effort()); recipe.setHeroImageUrl(request.heroImageUrl()); + recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl())); recipe.getIngredients().clear(); recipe.getSteps().clear(); @@ -239,7 +241,7 @@ public class RecipeService { return new RecipeDetailResponse( recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(), - recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(), + recipe.getEffort(), recipe.getHeroImageUrl(), ingredients, steps, tags); } diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeDetailResponse.java b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeDetailResponse.java index 68bbc48..1639e8b 100644 --- a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeDetailResponse.java +++ b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeDetailResponse.java @@ -10,7 +10,6 @@ public record RecipeDetailResponse( short serves, short cookTimeMin, String effort, - boolean isChildFriendly, String heroImageUrl, List ingredients, List steps, diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java index 93ca6a7..c128982 100644 --- a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java +++ b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeSummaryResponse.java @@ -8,6 +8,5 @@ public record RecipeSummaryResponse( short serves, short cookTimeMin, String effort, - boolean isChildFriendly, - String heroImageUrl + String heroImagePreview ) {} diff --git a/backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java b/backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java index 508cd79..e6ce422 100644 --- a/backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java +++ b/backend/src/main/java/com/recipeapp/recipe/entity/Recipe.java @@ -33,12 +33,12 @@ public class Recipe { @Column(nullable = false, length = 10) private String effort; - @Column(name = "is_child_friendly", nullable = false) - private boolean isChildFriendly; - @Column(name = "hero_image_url", columnDefinition = "text") private String heroImageUrl; + @Column(name = "hero_image_preview", columnDefinition = "text") + private String heroImagePreview; + @Column(name = "deleted_at") private Instant deletedAt; @@ -64,14 +64,12 @@ public class Recipe { protected Recipe() {} - public Recipe(Household household, String name, short serves, short cookTimeMin, - String effort, boolean isChildFriendly) { + public Recipe(Household household, String name, short serves, short cookTimeMin, String effort) { this.household = household; this.name = name; this.serves = serves; this.cookTimeMin = cookTimeMin; this.effort = effort; - this.isChildFriendly = isChildFriendly; } @PrePersist @@ -95,10 +93,10 @@ public class Recipe { public void setCookTimeMin(short cookTimeMin) { this.cookTimeMin = cookTimeMin; } public String getEffort() { return effort; } public void setEffort(String effort) { this.effort = effort; } - public boolean isChildFriendly() { return isChildFriendly; } - public void setChildFriendly(boolean childFriendly) { isChildFriendly = childFriendly; } public String getHeroImageUrl() { return heroImageUrl; } public void setHeroImageUrl(String heroImageUrl) { this.heroImageUrl = heroImageUrl; } + public String getHeroImagePreview() { return heroImagePreview; } + public void setHeroImagePreview(String heroImagePreview) { this.heroImagePreview = heroImagePreview; } public Instant getDeletedAt() { return deletedAt; } public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; } public Instant getCreatedAt() { return createdAt; } diff --git a/backend/src/main/resources/db/migration/V025__drop_is_child_friendly.sql b/backend/src/main/resources/db/migration/V025__drop_is_child_friendly.sql new file mode 100644 index 0000000..9c4f1ba --- /dev/null +++ b/backend/src/main/resources/db/migration/V025__drop_is_child_friendly.sql @@ -0,0 +1 @@ +ALTER TABLE recipe DROP COLUMN is_child_friendly; diff --git a/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java index 6ab3f52..7387f2d 100644 --- a/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java +++ b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java @@ -55,7 +55,7 @@ class PlanningServiceTest { } private Recipe testRecipe(Household household, String name) { - var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true); + var r = new Recipe(household, name, (short) 4, (short) 45, "medium"); setId(r, Recipe.class, UUID.randomUUID()); return r; } diff --git a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java index e86ff86..3add7f0 100644 --- a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java +++ b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java @@ -69,7 +69,7 @@ class SuggestionsTest { } private Recipe createRecipe(String name) { - var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true); + var r = new Recipe(household, name, (short) 4, (short) 30, "medium"); setId(r, Recipe.class, UUID.randomUUID()); return r; } diff --git a/backend/src/test/java/com/recipeapp/planning/VarietyScoreTest.java b/backend/src/test/java/com/recipeapp/planning/VarietyScoreTest.java index dfcc8d9..5054fd5 100644 --- a/backend/src/test/java/com/recipeapp/planning/VarietyScoreTest.java +++ b/backend/src/test/java/com/recipeapp/planning/VarietyScoreTest.java @@ -69,7 +69,7 @@ class VarietyScoreTest { } private Recipe createRecipe(String name) { - var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true); + var r = new Recipe(household, name, (short) 4, (short) 30, "medium"); setId(r, Recipe.class, UUID.randomUUID()); return r; } diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java index 6f160da..ab3c505 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java @@ -47,13 +47,13 @@ class RecipeControllerTest { @Test void listRecipesShouldReturn200WithPagination() throws Exception { var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese", - (short) 4, (short) 45, "medium", true, null); + (short) 4, (short) 45, "medium", null); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); - when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull(), + when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull(), eq(20), eq(0))) .thenReturn(List.of(summary)); - when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull())) + when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull())) .thenReturn(1L); mockMvc.perform(get("/v1/recipes") @@ -69,17 +69,16 @@ class RecipeControllerTest { @Test void listRecipesWithFiltersShouldPassParams() throws Exception { when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); - when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true), + when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(30), eq("-cookTimeMin"), eq(10), eq(5))) .thenReturn(List.of()); - when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true), eq(30))) + when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(30))) .thenReturn(0L); mockMvc.perform(get("/v1/recipes") .principal(() -> "sarah@example.com") .param("search", "pasta") .param("effort", "easy") - .param("isChildFriendly", "true") .param("cookTimeMin.lte", "30") .param("sort", "-cookTimeMin") .param("limit", "10") @@ -175,7 +174,7 @@ class RecipeControllerTest { private RecipeDetailResponse sampleDetail() { var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta"); return new RecipeDetailResponse( - RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null, + RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", null, List.of(new RecipeDetailResponse.IngredientItem( UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)), List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")), diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java index 350e135..acf3ae4 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java @@ -27,6 +27,7 @@ class RecipeServiceTest { @Mock private TagRepository tagRepository; @Mock private IngredientCategoryRepository ingredientCategoryRepository; @Mock private HouseholdRepository householdRepository; + @Mock private ImageCompressor imageCompressor; @InjectMocks private RecipeService recipeService; @@ -43,7 +44,7 @@ class RecipeServiceTest { } private Recipe testRecipe(Household household) { - var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true); + var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium"); try { var field = Recipe.class.getDeclaredField("id"); field.setAccessible(true); diff --git a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java index d5d1f0c..6865191 100644 --- a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java +++ b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java @@ -60,7 +60,7 @@ class ShoppingServiceTest { } private Recipe testRecipe(Household household, String name) { - var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true); + var r = new Recipe(household, name, (short) 4, (short) 45, "medium"); setId(r, Recipe.class, UUID.randomUUID()); return r; } diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index 9ee4ede..aadfed1 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -552,7 +552,6 @@ export interface components { /** Format: int32 */ cookTimeMin?: number; effort: string; - isChildFriendly?: boolean; heroImageUrl?: string; ingredients: components["schemas"]["IngredientEntry"][]; steps?: components["schemas"]["StepEntry"][]; @@ -587,7 +586,6 @@ export interface components { /** Format: int32 */ cookTimeMin?: number; effort?: string; - isChildFriendly?: boolean; heroImageUrl?: string; ingredients?: components["schemas"]["IngredientItem"][]; steps?: components["schemas"]["StepItem"][]; @@ -934,8 +932,7 @@ export interface components { /** Format: int32 */ cookTimeMin?: number; effort?: string; - isChildFriendly?: boolean; - heroImageUrl?: string; + heroImagePreview?: string; }; ApiResponseListAdminUserResponse: { status?: string; -- 2.49.1 From dbc78a18839d802502ec654aef4fba2be108e72e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:00:16 +0200 Subject: [PATCH 33/49] test(recipe): cover null serves/cookTimeMin and capitalised effort rejection Co-Authored-By: Claude Sonnet 4.6 --- .../recipe/RecipeControllerTest.java | 13 +++++++++++ .../recipeapp/recipe/RecipeServiceTest.java | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java index ab3c505..bd07e18 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java @@ -161,6 +161,19 @@ class RecipeControllerTest { verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID); } + @Test + void createRecipeWithCapitalisedEffortShouldReturn400() throws Exception { + var body = """ + {"name":"Test","effort":"Easy","tagIds":["%s"],"ingredients":[{"quantity":1,"unit":"g","newIngredientName":"x","sortOrder":0}]} + """.formatted(UUID.randomUUID()); + + mockMvc.perform(post("/v1/recipes") + .principal(() -> "sarah@example.com") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + private RecipeCreateRequest sampleCreateRequest() { var ingredientId = UUID.randomUUID(); return new RecipeCreateRequest( diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java index acf3ae4..8892d25 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java @@ -526,6 +526,29 @@ class RecipeServiceTest { .isInstanceOf(ResourceNotFoundException.class); } + @Test + void createRecipeWithNullServesAndCookTimeShouldStoreZero() { + var household = testHousehold(); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> { + Recipe r = i.getArgument(0); + try { + var field = Recipe.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(r, UUID.randomUUID()); + } catch (Exception e) { throw new RuntimeException(e); } + return r; + }); + + var request = new RecipeCreateRequest("Soup", null, null, "easy", null, + List.of(), List.of(), List.of()); + + RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request); + + assertThat(result.serves()).isEqualTo((short) 0); + assertThat(result.cookTimeMin()).isEqualTo((short) 0); + } + // ── Tag/Category edge cases ── @Test -- 2.49.1 From 44b3f06474f221ef14e454150ed6cca7185b2378 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:05:19 +0200 Subject: [PATCH 34/49] feat(recipes): filter ingredients with quantity <= 0 before API submission Co-Authored-By: Claude Sonnet 4.6 --- .../lib/planner/VarietyWarningCards.test.ts | 28 ++++++++++++++----- frontend/src/lib/recipes/RecipeForm.test.ts | 2 +- .../(app)/recipes/[id]/edit/+page.server.ts | 4 +-- .../recipes/[id]/edit/page.server.test.ts | 19 +++++++++++++ .../routes/(app)/recipes/new/+page.server.ts | 4 +-- .../(app)/recipes/new/page.server.test.ts | 17 +++++++++++ .../src/routes/(app)/recipes/page.test.ts | 6 ++-- 7 files changed, 65 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/planner/VarietyWarningCards.test.ts b/frontend/src/lib/planner/VarietyWarningCards.test.ts index 4ae479e..5f65fde 100644 --- a/frontend/src/lib/planner/VarietyWarningCards.test.ts +++ b/frontend/src/lib/planner/VarietyWarningCards.test.ts @@ -3,30 +3,44 @@ import { render, screen } from '@testing-library/svelte'; import VarietyWarningCards from './VarietyWarningCards.svelte'; const warnings = [ - { title: 'Chicken zweimal diese Woche', explanation: 'Mo, Mi — erwäge einen Tausch.' }, - { title: 'Tomaten in 3 Gerichten', explanation: 'Mo, Di, Mi — sorge für Abwechslung.' } + { + title: 'Chicken zweimal diese Woche', + items: [ + { dayShort: 'Mo', recipeName: 'Chicken Tikka', slotId: 1 }, + { dayShort: 'Mi', recipeName: 'Chicken Curry', slotId: 3 } + ] + }, + { + title: 'Tomaten in 3 Gerichten', + items: [ + { dayShort: 'Mo', recipeName: 'Pasta Pomodoro', slotId: 1 }, + { dayShort: 'Di', recipeName: 'Tomatensuppe', slotId: 2 }, + { dayShort: 'Mi', recipeName: 'Pizza Margherita', slotId: 3 } + ] + } ]; describe('VarietyWarningCards', () => { it('renders one card per warning', () => { - render(VarietyWarningCards, { props: { warnings } }); + render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } }); const cards = screen.getAllByTestId('warning-card'); expect(cards.length).toBe(2); }); it('renders warning titles', () => { - render(VarietyWarningCards, { props: { warnings } }); + render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } }); expect(screen.getByText(/Chicken zweimal/)).toBeTruthy(); expect(screen.getByText(/Tomaten in 3/)).toBeTruthy(); }); it('renders warning explanations', () => { - render(VarietyWarningCards, { props: { warnings } }); - expect(screen.getByText(/erwäge einen Tausch/)).toBeTruthy(); + render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } }); + expect(screen.getByText('Chicken Tikka')).toBeTruthy(); + expect(screen.getByText('Chicken Curry')).toBeTruthy(); }); it('renders nothing when warnings is empty', () => { - render(VarietyWarningCards, { props: { warnings: [] } }); + render(VarietyWarningCards, { props: { warnings: [], weekStart: '2026-04-07' } }); expect(screen.queryAllByTestId('warning-card').length).toBe(0); }); }); diff --git a/frontend/src/lib/recipes/RecipeForm.test.ts b/frontend/src/lib/recipes/RecipeForm.test.ts index 64cb585..7dcfc6d 100644 --- a/frontend/src/lib/recipes/RecipeForm.test.ts +++ b/frontend/src/lib/recipes/RecipeForm.test.ts @@ -29,7 +29,7 @@ const editProps = { name: 'Spaghetti Bolognese', serves: 4, cookTimeMin: 30, - effort: 'Medium', + effort: 'medium', heroImageUrl: undefined as string | undefined, ingredients: [ { name: 'Spaghetti', quantity: 200, unit: 'g' } diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts index 89494c7..d430fd3 100644 --- a/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts @@ -77,10 +77,10 @@ export const actions: Actions = { effort, heroImageUrl, ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) - .filter((ing) => ing.name?.trim()) + .filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0) .map((ing, i) => ({ newIngredientName: ing.name.trim(), - quantity: Number(ing.quantity) || 0, + quantity: Number(ing.quantity), unit: ing.unit || '', sortOrder: i })), diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts b/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts index 0195b1f..9594326 100644 --- a/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts @@ -204,6 +204,25 @@ describe('edit recipe page — update action', () => { })); }); + it('filters out ingredients with quantity <= 0 before PUT', async () => { + mockPut.mockResolvedValue({ error: undefined }); + const fd = makeFormData({ + ingredientsJson: JSON.stringify([ + { name: 'Spaghetti', quantity: 200, unit: 'g' }, + { name: 'Salt', quantity: 0, unit: 'tsp' }, + { name: 'Pepper', quantity: -1, unit: 'tsp' } + ]) + }); + await actions.update({ + request: { formData: async () => fd }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any).catch(() => {}); + const body = mockPut.mock.calls[0][1].body; + expect(body.ingredients).toHaveLength(1); + expect(body.ingredients[0].newIngredientName).toBe('Spaghetti'); + }); + it('returns fail(500) when API returns error', async () => { mockPut.mockResolvedValue({ error: { status: 500 } }); const result = await actions.update({ diff --git a/frontend/src/routes/(app)/recipes/new/+page.server.ts b/frontend/src/routes/(app)/recipes/new/+page.server.ts index 657e4cd..6f03c3e 100644 --- a/frontend/src/routes/(app)/recipes/new/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/new/+page.server.ts @@ -49,10 +49,10 @@ export const actions: Actions = { effort, heroImageUrl, ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) - .filter((ing) => ing.name?.trim()) + .filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0) .map((ing, i) => ({ newIngredientName: ing.name.trim(), - quantity: Number(ing.quantity) || 0, + quantity: Number(ing.quantity), unit: ing.unit || '', sortOrder: i })), diff --git a/frontend/src/routes/(app)/recipes/new/page.server.test.ts b/frontend/src/routes/(app)/recipes/new/page.server.test.ts index 3734634..10625cc 100644 --- a/frontend/src/routes/(app)/recipes/new/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/new/page.server.test.ts @@ -163,6 +163,23 @@ describe('new recipe page — create action', () => { })); }); + it('filters out ingredients with quantity <= 0 before POST', async () => { + mockPost.mockResolvedValue({ error: undefined }); + const fd = makeFormData({ + ingredientsJson: JSON.stringify([ + { name: 'Spaghetti', quantity: 200, unit: 'g' }, + { name: 'Salt', quantity: 0, unit: 'tsp' }, + { name: 'Pepper', quantity: -1, unit: 'tsp' } + ]) + }); + await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch( + () => {} + ); + const body = mockPost.mock.calls[0][1].body; + expect(body.ingredients).toHaveLength(1); + expect(body.ingredients[0].newIngredientName).toBe('Spaghetti'); + }); + it('returns fail(500) when API returns error', async () => { mockPost.mockResolvedValue({ error: { status: 500 } }); const result = await actions.create({ diff --git a/frontend/src/routes/(app)/recipes/page.test.ts b/frontend/src/routes/(app)/recipes/page.test.ts index 27e3ac9..37dffa7 100644 --- a/frontend/src/routes/(app)/recipes/page.test.ts +++ b/frontend/src/routes/(app)/recipes/page.test.ts @@ -5,9 +5,9 @@ import Page from './+page.svelte'; const mockData = { recipes: [ - { id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' }, - { id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' }, - { id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'Easy' } + { id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'easy' }, + { id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'medium' }, + { id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'easy' } ], activePlan: null }; -- 2.49.1 From b1eb9ed964d895baeeedf79c45f18695dd8cdc85 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:06:39 +0200 Subject: [PATCH 35/49] feat(recipes): send null instead of undefined for blank serves/cookTimeMin Co-Authored-By: Claude Sonnet 4.6 --- .../routes/(app)/recipes/[id]/edit/+page.server.ts | 4 ++-- .../(app)/recipes/[id]/edit/page.server.test.ts | 13 +++++++++++++ .../src/routes/(app)/recipes/new/+page.server.ts | 4 ++-- .../routes/(app)/recipes/new/page.server.test.ts | 9 +++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts index d430fd3..b062dba 100644 --- a/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts @@ -72,8 +72,8 @@ export const actions: Actions = { params: { path: { id: params.id } }, body: { name: name.trim(), - serves: serves ? Number(serves) || undefined : undefined, - cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined, + serves: serves ? Number(serves) || null : null, + cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null, effort, heroImageUrl, ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts b/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts index 9594326..5e7e1ef 100644 --- a/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts @@ -178,6 +178,19 @@ describe('edit recipe page — update action', () => { })); }); + it('sends null for serves and cookTimeMin when fields are blank', async () => { + mockPut.mockResolvedValue({ error: undefined }); + const fd = makeFormData({ serves: '', cookTimeMin: '' }); + await actions.update({ + request: { formData: async () => fd }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any).catch(() => {}); + const body = mockPut.mock.calls[0][1].body; + expect(body.serves).toBeNull(); + expect(body.cookTimeMin).toBeNull(); + }); + it('sends heroImageUrl in PUT body when provided', async () => { mockPut.mockResolvedValue({ error: undefined }); const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' }); diff --git a/frontend/src/routes/(app)/recipes/new/+page.server.ts b/frontend/src/routes/(app)/recipes/new/+page.server.ts index 6f03c3e..c1f2e30 100644 --- a/frontend/src/routes/(app)/recipes/new/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/new/+page.server.ts @@ -44,8 +44,8 @@ export const actions: Actions = { const { error: apiError } = await api.POST('/v1/recipes', { body: { name: name.trim(), - serves: serves ? Number(serves) || undefined : undefined, - cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined, + serves: serves ? Number(serves) || null : null, + cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null, effort, heroImageUrl, ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) diff --git a/frontend/src/routes/(app)/recipes/new/page.server.test.ts b/frontend/src/routes/(app)/recipes/new/page.server.test.ts index 10625cc..a7fcc4d 100644 --- a/frontend/src/routes/(app)/recipes/new/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/new/page.server.test.ts @@ -145,6 +145,15 @@ describe('new recipe page — create action', () => { expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'easy' }) })); }); + it('sends null for serves and cookTimeMin when fields are blank', async () => { + mockPost.mockResolvedValue({ error: undefined }); + const fd = makeFormData({ serves: '', cookTimeMin: '' }); + await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(() => {}); + const body = mockPost.mock.calls[0][1].body; + expect(body.serves).toBeNull(); + expect(body.cookTimeMin).toBeNull(); + }); + it('sends heroImageUrl in POST body when provided', async () => { mockPost.mockResolvedValue({ error: undefined }); const fd = makeFormData({ heroImageUrl: 'data:image/jpeg;base64,abc123' }); -- 2.49.1 From 90cff0c4d2fdd5816afd175b1fd867e029fa43bb Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:08:45 +0200 Subject: [PATCH 36/49] feat(recipe): validate heroImageUrl content type before persisting Co-Authored-By: Claude Sonnet 4.6 --- .../com/recipeapp/recipe/RecipeService.java | 17 +++++++++++++ .../recipeapp/recipe/RecipeServiceTest.java | 25 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java index cfd36f2..8228b50 100644 --- a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java @@ -2,6 +2,7 @@ package com.recipeapp.recipe; import com.recipeapp.common.ConflictException; import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.common.ValidationException; import com.recipeapp.household.HouseholdRepository; import com.recipeapp.household.entity.Household; import com.recipeapp.recipe.dto.*; @@ -60,6 +61,8 @@ public class RecipeService { Household household = householdRepository.findById(householdId) .orElseThrow(() -> new ResourceNotFoundException("Household not found")); + validateHeroImageUrl(request.heroImageUrl()); + Recipe recipe = new Recipe(household, request.name(), request.serves() != null ? request.serves().shortValue() : 0, request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0, @@ -80,6 +83,8 @@ public class RecipeService { Recipe recipe = findRecipe(householdId, recipeId); Household household = recipe.getHousehold(); + validateHeroImageUrl(request.heroImageUrl()); + recipe.setName(request.name()); recipe.setServes(request.serves() != null ? request.serves().shortValue() : 0); recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0); @@ -183,6 +188,18 @@ public class RecipeService { return new IngredientCategoryResponse(category.getId(), category.getName()); } + // ── Image validation ── + + private static final java.util.regex.Pattern ALLOWED_IMAGE_PATTERN = + java.util.regex.Pattern.compile("^data:image/(jpeg|jpg|png|gif|webp);base64,"); + + private void validateHeroImageUrl(String heroImageUrl) { + if (heroImageUrl == null || heroImageUrl.isBlank()) return; + if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).find()) { + throw new ValidationException("Ungültiger Bildtyp. Erlaubt sind: JPEG, PNG, GIF, WebP."); + } + } + // ── Private helpers ── private Recipe findRecipe(UUID householdId, UUID recipeId) { diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java index 8892d25..792be7f 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java @@ -571,6 +571,31 @@ class RecipeServiceTest { .isInstanceOf(ResourceNotFoundException.class); } + @Test + void createRecipeWithDisallowedImageTypeShouldThrowValidationException() { + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(testHousehold())); + + var request = new RecipeCreateRequest( + "Test", null, null, "easy", "data:application/pdf;base64,abc", + List.of(), List.of(), List.of()); + + assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request)) + .isInstanceOf(com.recipeapp.common.ValidationException.class); + } + + @Test + void createRecipeWithAllowedImageTypeShouldNotThrow() { + var household = testHousehold(); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0)); + + var request = new RecipeCreateRequest( + "Test", null, null, "easy", "data:image/jpeg;base64,abc", + List.of(), List.of(), List.of()); + + assertThatNoException().isThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request)); + } + @Test void listTagsShouldReturnEmptyList() { when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of()); -- 2.49.1 From 46f2ec45a330a87e4265f363f7a0f021ab239f06 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:09:14 +0200 Subject: [PATCH 37/49] feat(backend): limit multipart upload to 5 MB file / 6 MB request Co-Authored-By: Claude Sonnet 4.6 --- backend/src/main/resources/application.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 3e1d1f9..6104c5e 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -19,5 +19,10 @@ spring: enabled: true locations: classpath:db/migration + servlet: + multipart: + max-file-size: 5MB + max-request-size: 6MB + server: port: 8080 -- 2.49.1 From 822b34cd14678b90679013cc36300439b00625de Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:11:57 +0200 Subject: [PATCH 38/49] feat(recipe-form): reject files > 5 MB and show Max. 5 MB hint Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/RecipeForm.svelte | 14 +++++++++++ frontend/src/lib/recipes/RecipeForm.test.ts | 27 +++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/frontend/src/lib/recipes/RecipeForm.svelte b/frontend/src/lib/recipes/RecipeForm.svelte index 2bb39a0..7d7242c 100644 --- a/frontend/src/lib/recipes/RecipeForm.svelte +++ b/frontend/src/lib/recipes/RecipeForm.svelte @@ -61,10 +61,19 @@ ); let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']); let heroImageUrl = $state(initial?.heroImageUrl ?? null); + let imageError = $state(null); + + const MAX_IMAGE_BYTES = 5 * 1024 * 1024; function handleImageChange(e: Event) { const file = (e.currentTarget as HTMLInputElement).files?.[0]; if (!file) return; + if (file.size > MAX_IMAGE_BYTES) { + imageError = 'Datei zu groß. Maximal 5 MB erlaubt.'; + (e.currentTarget as HTMLInputElement).value = ''; + return; + } + imageError = null; const reader = new FileReader(); reader.onload = () => { heroImageUrl = reader.result as string; @@ -196,6 +205,11 @@ /> {heroImageUrl ? 'Bild ändern' : 'Bild hochladen'} + {#if imageError} +

{imageError}

+ {:else} +

Max. 5 MB

+ {/if}
diff --git a/frontend/src/lib/recipes/RecipeForm.test.ts b/frontend/src/lib/recipes/RecipeForm.test.ts index 7dcfc6d..b03aa7a 100644 --- a/frontend/src/lib/recipes/RecipeForm.test.ts +++ b/frontend/src/lib/recipes/RecipeForm.test.ts @@ -162,4 +162,31 @@ describe('RecipeForm', () => { render(RecipeForm, { props: emptyProps }); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); + + it('shows Max. 5 MB hint below upload button', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByText('Max. 5 MB')).toBeInTheDocument(); + }); + + it('shows error when selected file exceeds 5 MB', async () => { + const user = userEvent.setup(); + render(RecipeForm, { props: emptyProps }); + + const oversizedFile = new File(['x'.repeat(6 * 1024 * 1024)], 'big.jpg', { type: 'image/jpeg' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, oversizedFile); + + expect(screen.getByText(/datei zu groß/i)).toBeInTheDocument(); + }); + + it('does not show file size error for file within 5 MB', async () => { + const user = userEvent.setup(); + render(RecipeForm, { props: emptyProps }); + + const okFile = new File(['x'.repeat(1 * 1024 * 1024)], 'small.jpg', { type: 'image/jpeg' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, okFile); + + expect(screen.queryByText(/datei zu groß/i)).not.toBeInTheDocument(); + }); }); -- 2.49.1 From f11cca534f60519f5d7359f5fe4e68c12b91aad8 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:14:35 +0200 Subject: [PATCH 39/49] feat(recipe): compress hero image to 400px preview on save Adds Thumbnailator-based ImageCompressor that resizes uploaded images to a 400px-wide JPEG preview stored in hero_image_preview. The recipe list uses the preview instead of the full image URL. Co-Authored-By: Claude Sonnet 4.6 --- backend/pom.xml | 10 ++ .../com/recipeapp/recipe/ImageCompressor.java | 60 ++++++++++++ .../V024__add_hero_image_preview.sql | 1 + .../recipeapp/recipe/ImageCompressorTest.java | 98 +++++++++++++++++++ frontend/src/lib/recipes/RecipeCard.svelte | 4 +- frontend/src/lib/recipes/RecipeCard.test.ts | 12 +-- frontend/src/lib/recipes/types.ts | 2 +- .../src/routes/(app)/recipes/+page.server.ts | 2 +- 8 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 backend/src/main/java/com/recipeapp/recipe/ImageCompressor.java create mode 100644 backend/src/main/resources/db/migration/V024__add_hero_image_preview.sql create mode 100644 backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java diff --git a/backend/pom.xml b/backend/pom.xml index f4c4a47..e679e5a 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -55,6 +55,16 @@ postgresql runtime + + net.coobird + thumbnailator + 0.4.21 + + + com.twelvemonkeys.imageio + imageio-webp + 3.13.1 + org.springframework.boot spring-boot-starter-test diff --git a/backend/src/main/java/com/recipeapp/recipe/ImageCompressor.java b/backend/src/main/java/com/recipeapp/recipe/ImageCompressor.java new file mode 100644 index 0000000..3f695a3 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/recipe/ImageCompressor.java @@ -0,0 +1,60 @@ +package com.recipeapp.recipe; + +import net.coobird.thumbnailator.Thumbnails; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Base64; + +@Component +public class ImageCompressor { + + private static final Logger log = LoggerFactory.getLogger(ImageCompressor.class); + + private static final int PREVIEW_WIDTH = 400; + private static final double PREVIEW_QUALITY = 0.6; + private static final String DATA_URI_PREFIX = "data:image/"; + private static final String BASE64_MARKER = ";base64,"; + private static final String OUTPUT_PREFIX = "data:image/jpeg;base64,"; + + public String compressToPreview(String dataUri) { + if (dataUri == null || dataUri.isBlank()) return null; + if (!dataUri.startsWith(DATA_URI_PREFIX)) return null; + + int markerIdx = dataUri.indexOf(BASE64_MARKER); + if (markerIdx < 0) return null; + + byte[] imageBytes; + try { + imageBytes = Base64.getDecoder().decode(dataUri.substring(markerIdx + BASE64_MARKER.length())); + } catch (IllegalArgumentException e) { + return null; + } + + try { + BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageBytes)); + if (original == null) { + log.warn("ImageIO could not decode image — unsupported format (data URI prefix: {})", + dataUri.substring(0, Math.min(dataUri.indexOf(',') + 1, 40))); + return null; + } + + int targetWidth = Math.min(original.getWidth(), PREVIEW_WIDTH); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + Thumbnails.of(original) + .width(targetWidth) + .outputFormat("jpeg") + .outputQuality(PREVIEW_QUALITY) + .toOutputStream(bos); + return OUTPUT_PREFIX + Base64.getEncoder().encodeToString(bos.toByteArray()); + } catch (Exception e) { + log.warn("Failed to generate image preview", e); + return null; + } + } +} diff --git a/backend/src/main/resources/db/migration/V024__add_hero_image_preview.sql b/backend/src/main/resources/db/migration/V024__add_hero_image_preview.sql new file mode 100644 index 0000000..2b7ac80 --- /dev/null +++ b/backend/src/main/resources/db/migration/V024__add_hero_image_preview.sql @@ -0,0 +1 @@ +ALTER TABLE recipe ADD COLUMN hero_image_preview text; diff --git a/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java b/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java new file mode 100644 index 0000000..8d2c7c5 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java @@ -0,0 +1,98 @@ +package com.recipeapp.recipe; + +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.*; + +class ImageCompressorTest { + + private final ImageCompressor compressor = new ImageCompressor(); + + @Test + void compressToPreview_returnsJpegDataUri() throws Exception { + String dataUri = makePngDataUri(800, 600); + String result = compressor.compressToPreview(dataUri); + assertThat(result).startsWith("data:image/jpeg;base64,"); + } + + @Test + void compressToPreview_outputIsDecodableJpeg() throws Exception { + String dataUri = makePngDataUri(800, 600); + String result = compressor.compressToPreview(dataUri); + + String base64 = result.substring("data:image/jpeg;base64,".length()); + byte[] bytes = Base64.getDecoder().decode(base64); + BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes)); + + assertThat(img).isNotNull(); + assertThat(img.getWidth()).isLessThanOrEqualTo(400); + } + + @Test + void compressToPreview_preservesAspectRatio() throws Exception { + String dataUri = makePngDataUri(800, 400); // 2:1 ratio + String result = compressor.compressToPreview(dataUri); + + String base64 = result.substring("data:image/jpeg;base64,".length()); + BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64))); + + assertThat(img).isNotNull(); + double ratio = (double) img.getWidth() / img.getHeight(); + assertThat(ratio).isCloseTo(2.0, within(0.1)); + } + + @Test + void compressToPreview_doesNotUpscaleSmallImages() throws Exception { + String dataUri = makePngDataUri(200, 150); // smaller than 400px + String result = compressor.compressToPreview(dataUri); + + String base64 = result.substring("data:image/jpeg;base64,".length()); + BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64))); + + assertThat(img).isNotNull(); + assertThat(img.getWidth()).isLessThanOrEqualTo(200); + } + + @Test + void compressToPreview_returnsNullForNull() { + assertThat(compressor.compressToPreview(null)).isNull(); + } + + @Test + void compressToPreview_returnsNullForBlankString() { + assertThat(compressor.compressToPreview(" ")).isNull(); + } + + @Test + void compressToPreview_returnsNullForNonDataUri() { + assertThat(compressor.compressToPreview("https://example.com/image.jpg")).isNull(); + } + + @Test + void compressToPreview_returnsNullForInvalidBase64() { + assertThat(compressor.compressToPreview("data:image/jpeg;base64,!!!not-valid!!!")).isNull(); + } + + // ── helpers ── + + private String makePngDataUri(int width, int height) throws Exception { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + // draw gradient so PNG and JPEG both have non-trivial content + for (int x = 0; x < width; x++) { + g.setColor(new Color(x * 255 / width, (x * 128 / width + height / 2) % 256, 128)); + g.drawLine(x, 0, x, height); + } + g.dispose(); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", bos); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(bos.toByteArray()); + } +} diff --git a/frontend/src/lib/recipes/RecipeCard.svelte b/frontend/src/lib/recipes/RecipeCard.svelte index d38249b..67318c8 100644 --- a/frontend/src/lib/recipes/RecipeCard.svelte +++ b/frontend/src/lib/recipes/RecipeCard.svelte @@ -23,8 +23,8 @@ data-testid="image-area" class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}" > - {#if recipe.heroImageUrl} - {recipe.name} + {#if recipe.heroImagePreview} + {recipe.name} {:else}
{ @@ -27,18 +27,18 @@ describe('RecipeCard', () => { expect(screen.getByText(/easy/i)).toBeInTheDocument(); }); - it('shows placeholder when no heroImageUrl', () => { - render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } }); + it('shows placeholder when no heroImagePreview', () => { + render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImagePreview: undefined } } }); expect(screen.queryByRole('img')).not.toBeInTheDocument(); expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument(); }); - it('shows image when heroImageUrl is provided', () => { + it('shows image when heroImagePreview is provided', () => { render(RecipeCard, { - props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } } + props: { recipe: { ...mockRecipe, heroImagePreview: 'data:image/jpeg;base64,abc' } } }); const img = screen.getByRole('img'); - expect(img).toHaveAttribute('src', '/uploads/test.jpg'); + expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,abc'); expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese'); }); diff --git a/frontend/src/lib/recipes/types.ts b/frontend/src/lib/recipes/types.ts index 90f28b5..b01a061 100644 --- a/frontend/src/lib/recipes/types.ts +++ b/frontend/src/lib/recipes/types.ts @@ -3,7 +3,7 @@ export type RecipeSummary = { name: string; cookTimeMin?: number; effort?: string; - heroImageUrl?: string; + heroImagePreview?: string; }; export type Tag = { diff --git a/frontend/src/routes/(app)/recipes/+page.server.ts b/frontend/src/routes/(app)/recipes/+page.server.ts index df455b0..3b8bfe8 100644 --- a/frontend/src/routes/(app)/recipes/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/+page.server.ts @@ -20,7 +20,7 @@ export const load: PageServerLoad = async ({ fetch }) => { name: r.name!, cookTimeMin: r.cookTimeMin, effort: r.effort, - heroImageUrl: r.heroImageUrl + heroImagePreview: r.heroImagePreview })); const activePlan = -- 2.49.1 From ed769b18a47d1aedd2dba46a384e866f6372a1c1 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:27:35 +0200 Subject: [PATCH 40/49] fix(recipe): add server-side image size limit and use .matches() for type check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @Size(max=7_000_000) on heroImageUrl enforces ~5 MB cap at bean validation - ALLOWED_IMAGE_PATTERN uses .matches() for unambiguous full-string check - Tests: oversized image → 400, empty ingredients list → 400 Co-Authored-By: Claude Sonnet 4.6 --- .../com/recipeapp/recipe/RecipeService.java | 4 +-- .../recipe/dto/RecipeCreateRequest.java | 2 +- .../recipe/RecipeControllerTest.java | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java index 8228b50..6e3084b 100644 --- a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java @@ -191,11 +191,11 @@ public class RecipeService { // ── Image validation ── private static final java.util.regex.Pattern ALLOWED_IMAGE_PATTERN = - java.util.regex.Pattern.compile("^data:image/(jpeg|jpg|png|gif|webp);base64,"); + java.util.regex.Pattern.compile("data:image/(jpeg|jpg|png|gif|webp);base64,.*"); private void validateHeroImageUrl(String heroImageUrl) { if (heroImageUrl == null || heroImageUrl.isBlank()) return; - if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).find()) { + if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).matches()) { throw new ValidationException("Ungültiger Bildtyp. Erlaubt sind: JPEG, PNG, GIF, WebP."); } } diff --git a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java index 5852b48..af5bb7e 100644 --- a/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java +++ b/backend/src/main/java/com/recipeapp/recipe/dto/RecipeCreateRequest.java @@ -12,7 +12,7 @@ public record RecipeCreateRequest( Integer serves, Integer cookTimeMin, @NotBlank @Pattern(regexp = "easy|medium|hard") String effort, - String heroImageUrl, + @Size(max = 7_000_000) String heroImageUrl, @NotEmpty @Valid List ingredients, @Valid List steps, @NotEmpty List tagIds diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java index bd07e18..0a1b94b 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeControllerTest.java @@ -161,6 +161,33 @@ class RecipeControllerTest { verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID); } + @Test + void createRecipeWithOversizedHeroImageShouldReturn400() throws Exception { + String heroImageUrl = "data:image/jpeg;base64," + "A".repeat(7_000_000); + String body = "{\"name\":\"Test\",\"effort\":\"easy\",\"tagIds\":[\"" + UUID.randomUUID() + "\"]," + + "\"ingredients\":[{\"quantity\":1,\"unit\":\"g\",\"newIngredientName\":\"x\",\"sortOrder\":0}]," + + "\"heroImageUrl\":\"" + heroImageUrl + "\"}"; + + mockMvc.perform(post("/v1/recipes") + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + @Test + void createRecipeWithEmptyIngredientsListShouldReturn400() throws Exception { + var body = """ + {"name":"Test","effort":"easy","tagIds":["%s"],"ingredients":[]} + """.formatted(UUID.randomUUID()); + + mockMvc.perform(post("/v1/recipes") + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + @Test void createRecipeWithCapitalisedEffortShouldReturn400() throws Exception { var body = """ -- 2.49.1 From 56e6143fd2c735ed33febd969440fd1586d3a19e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:33:39 +0200 Subject: [PATCH 41/49] feat(recipes): validate image MIME type on file select Rejects non-allowlisted types (only JPEG, PNG, GIF, WebP accepted) with an inline error message. Uses image/bmp as test vector since it passes accept="image/*" but is not in the allowed set. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/RecipeForm.svelte | 6 ++++++ frontend/src/lib/recipes/RecipeForm.test.ts | 22 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/frontend/src/lib/recipes/RecipeForm.svelte b/frontend/src/lib/recipes/RecipeForm.svelte index 7d7242c..c9ca6e8 100644 --- a/frontend/src/lib/recipes/RecipeForm.svelte +++ b/frontend/src/lib/recipes/RecipeForm.svelte @@ -64,6 +64,7 @@ let imageError = $state(null); const MAX_IMAGE_BYTES = 5 * 1024 * 1024; + const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; function handleImageChange(e: Event) { const file = (e.currentTarget as HTMLInputElement).files?.[0]; @@ -73,6 +74,11 @@ (e.currentTarget as HTMLInputElement).value = ''; return; } + if (!ALLOWED_MIME_TYPES.includes(file.type)) { + imageError = 'Dateityp nicht unterstützt. Erlaubt: JPEG, PNG, GIF, WebP.'; + (e.currentTarget as HTMLInputElement).value = ''; + return; + } imageError = null; const reader = new FileReader(); reader.onload = () => { diff --git a/frontend/src/lib/recipes/RecipeForm.test.ts b/frontend/src/lib/recipes/RecipeForm.test.ts index b03aa7a..ffa8374 100644 --- a/frontend/src/lib/recipes/RecipeForm.test.ts +++ b/frontend/src/lib/recipes/RecipeForm.test.ts @@ -189,4 +189,26 @@ describe('RecipeForm', () => { expect(screen.queryByText(/datei zu groß/i)).not.toBeInTheDocument(); }); + + it('shows error when selected file has unsupported type', async () => { + const user = userEvent.setup(); + render(RecipeForm, { props: emptyProps }); + + const bmpFile = new File(['content'], 'image.bmp', { type: 'image/bmp' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, bmpFile); + + expect(screen.getByText(/dateityp/i)).toBeInTheDocument(); + }); + + it('does not show type error for supported image types', async () => { + const user = userEvent.setup(); + render(RecipeForm, { props: emptyProps }); + + const jpgFile = new File(['content'], 'photo.jpg', { type: 'image/jpeg' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, jpgFile); + + expect(screen.queryByText(/dateityp/i)).not.toBeInTheDocument(); + }); }); -- 2.49.1 From ebaf42d83d36fbe0289a58c465743e692f7f4bfc Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:36:41 +0200 Subject: [PATCH 42/49] feat(recipes): return fail(422) when all ingredients filter to empty Prevents a silent 400 from the backend when the user submits a form where every ingredient row has quantity <= 0 or blank name. Co-Authored-By: Claude Sonnet 4.6 --- .../(app)/recipes/[id]/edit/+page.server.ts | 21 ++++++++++++------- .../recipes/[id]/edit/page.server.test.ts | 20 +++++++++++++++++- .../routes/(app)/recipes/new/+page.server.ts | 21 ++++++++++++------- .../(app)/recipes/new/page.server.test.ts | 19 ++++++++++++++++- 4 files changed, 63 insertions(+), 18 deletions(-) diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts index b062dba..9faf620 100644 --- a/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts @@ -67,6 +67,13 @@ export const actions: Actions = { return fail(400, { error: 'Ungültige Formulardaten' }); } + const filteredIngredients = ( + parsedIngredients as { name: string; quantity: string; unit: string }[] + ).filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0); + + if (!filteredIngredients.length) + return fail(422, { error: 'Mindestens eine gültige Zutat ist erforderlich' }); + const api = apiClient(fetch); const { error: apiError } = await api.PUT('/v1/recipes/{id}', { params: { path: { id: params.id } }, @@ -76,14 +83,12 @@ export const actions: Actions = { cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null, effort, heroImageUrl, - ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) - .filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0) - .map((ing, i) => ({ - newIngredientName: ing.name.trim(), - quantity: Number(ing.quantity), - unit: ing.unit || '', - sortOrder: i - })), + ingredients: filteredIngredients.map((ing, i) => ({ + newIngredientName: ing.name.trim(), + quantity: Number(ing.quantity), + unit: ing.unit || '', + sortOrder: i + })), steps: (parsedSteps as string[]) .filter((s) => s?.trim()) .map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })), diff --git a/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts b/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts index 5e7e1ef..564eab6 100644 --- a/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts @@ -84,7 +84,7 @@ describe('edit recipe page — update action', () => { name: 'Test Rezept', effort: 'easy', tagIds: ['t1'], - ingredientsJson: '[]', + ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]), stepsJson: '[]', ...overrides }; @@ -236,6 +236,24 @@ describe('edit recipe page — update action', () => { expect(body.ingredients[0].newIngredientName).toBe('Spaghetti'); }); + it('returns fail(422) when all ingredients filter to empty after quantity check', async () => { + const result = await actions.update({ + request: { + formData: async () => + makeFormData({ + ingredientsJson: JSON.stringify([ + { name: 'Salt', quantity: 0, unit: 'tsp' }, + { name: '', quantity: 100, unit: 'g' } + ]) + }) + }, + fetch: vi.fn(), + params: { id: 'r1' } + } as any); + expect(result.status).toBe(422); + expect(result.data.error).toMatch(/zutat/i); + }); + it('returns fail(500) when API returns error', async () => { mockPut.mockResolvedValue({ error: { status: 500 } }); const result = await actions.update({ diff --git a/frontend/src/routes/(app)/recipes/new/+page.server.ts b/frontend/src/routes/(app)/recipes/new/+page.server.ts index c1f2e30..b3c8e56 100644 --- a/frontend/src/routes/(app)/recipes/new/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/new/+page.server.ts @@ -40,6 +40,13 @@ export const actions: Actions = { return fail(400, { error: 'Ungültige Formulardaten' }); } + const filteredIngredients = ( + parsedIngredients as { name: string; quantity: string; unit: string }[] + ).filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0); + + if (!filteredIngredients.length) + return fail(422, { error: 'Mindestens eine gültige Zutat ist erforderlich' }); + const api = apiClient(fetch); const { error: apiError } = await api.POST('/v1/recipes', { body: { @@ -48,14 +55,12 @@ export const actions: Actions = { cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null, effort, heroImageUrl, - ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) - .filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0) - .map((ing, i) => ({ - newIngredientName: ing.name.trim(), - quantity: Number(ing.quantity), - unit: ing.unit || '', - sortOrder: i - })), + ingredients: filteredIngredients.map((ing, i) => ({ + newIngredientName: ing.name.trim(), + quantity: Number(ing.quantity), + unit: ing.unit || '', + sortOrder: i + })), steps: (parsedSteps as string[]) .filter((s) => s?.trim()) .map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })), diff --git a/frontend/src/routes/(app)/recipes/new/page.server.test.ts b/frontend/src/routes/(app)/recipes/new/page.server.test.ts index a7fcc4d..3c7bb72 100644 --- a/frontend/src/routes/(app)/recipes/new/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/new/page.server.test.ts @@ -62,7 +62,7 @@ describe('new recipe page — create action', () => { name: 'Test Rezept', effort: 'easy', tagIds: ['t1'], - ingredientsJson: '[]', + ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]), stepsJson: '[]', ...overrides }; @@ -189,6 +189,23 @@ describe('new recipe page — create action', () => { expect(body.ingredients[0].newIngredientName).toBe('Spaghetti'); }); + it('returns fail(422) when all ingredients filter to empty after quantity check', async () => { + const result = await actions.create({ + request: { + formData: async () => + makeFormData({ + ingredientsJson: JSON.stringify([ + { name: 'Salt', quantity: 0, unit: 'tsp' }, + { name: '', quantity: 100, unit: 'g' } + ]) + }) + }, + fetch: vi.fn() + } as any); + expect(result.status).toBe(422); + expect(result.data.error).toMatch(/zutat/i); + }); + it('returns fail(500) when API returns error', async () => { mockPost.mockResolvedValue({ error: { status: 500 } }); const result = await actions.create({ -- 2.49.1 From 9df6d6f0c6ecdd3205635df0007afdee4704cee2 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:37:24 +0200 Subject: [PATCH 43/49] test(recipes): verify null preview is stored when compressor returns null Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/recipe/RecipeServiceTest.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java index 792be7f..4e60b79 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java @@ -604,4 +604,30 @@ class RecipeServiceTest { assertThat(result).isEmpty(); } + + @Test + void createRecipeShouldStoreNullPreviewWhenCompressorReturnsNull() { + var household = testHousehold(); + when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); + when(imageCompressor.compressToPreview(any())).thenReturn(null); + when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> { + Recipe r = i.getArgument(0); + try { + var field = Recipe.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(r, UUID.randomUUID()); + } catch (Exception e) { throw new RuntimeException(e); } + return r; + }); + + var request = new RecipeCreateRequest( + "Soup", null, null, "easy", "data:image/jpeg;base64,abc", + List.of(), List.of(), List.of()); + + RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request); + + assertThat(result.id()).isNotNull(); + // verify the recipe was saved without a preview (compressor returned null) + verify(recipeRepository).save(argThat(r -> r.getHeroImagePreview() == null)); + } } -- 2.49.1 From 23c821937f09dcb2a8dd9f55d1c40abd4824e0a7 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:38:01 +0200 Subject: [PATCH 44/49] test(recipes): add JPEG input test for ImageCompressor Confirms the compressor accepts JPEG data URIs as input (not just PNG). Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/recipe/ImageCompressorTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java b/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java index 8d2c7c5..c9f1522 100644 --- a/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java @@ -80,6 +80,17 @@ class ImageCompressorTest { assertThat(compressor.compressToPreview("data:image/jpeg;base64,!!!not-valid!!!")).isNull(); } + @Test + void compressToPreview_acceptsJpegInput() throws Exception { + String dataUri = makeJpegDataUri(800, 600); + String result = compressor.compressToPreview(dataUri); + assertThat(result).startsWith("data:image/jpeg;base64,"); + String base64 = result.substring("data:image/jpeg;base64,".length()); + BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64))); + assertThat(img).isNotNull(); + assertThat(img.getWidth()).isLessThanOrEqualTo(400); + } + // ── helpers ── private String makePngDataUri(int width, int height) throws Exception { @@ -95,4 +106,15 @@ class ImageCompressorTest { ImageIO.write(img, "png", bos); return "data:image/png;base64," + Base64.getEncoder().encodeToString(bos.toByteArray()); } + + private String makeJpegDataUri(int width, int height) throws Exception { + BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D g = img.createGraphics(); + g.setColor(java.awt.Color.ORANGE); + g.fillRect(0, 0, width, height); + g.dispose(); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageIO.write(img, "jpeg", bos); + return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bos.toByteArray()); + } } -- 2.49.1 From b2a798d90e9abbaa5e54fff33d73cb63a177e8d2 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:38:29 +0200 Subject: [PATCH 45/49] docs(tests): clarify why fake base64 is acceptable in allowed-image-type test Co-Authored-By: Claude Sonnet 4.6 --- .../src/test/java/com/recipeapp/recipe/RecipeServiceTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java index 4e60b79..1217728 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java @@ -589,6 +589,8 @@ class RecipeServiceTest { when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household)); when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0)); + // "abc" is not valid base64 for a real image; ImageCompressor will return null for the + // preview, but validateHeroImageUrl() should pass for a well-formed data URI prefix. var request = new RecipeCreateRequest( "Test", null, null, "easy", "data:image/jpeg;base64,abc", List.of(), List.of(), List.of()); -- 2.49.1 From a5bb5d45a358091d1a850a6c6b2370685c79226b Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:38:48 +0200 Subject: [PATCH 46/49] docs(config): annotate multipart limits explaining JSON body is not covered Co-Authored-By: Claude Sonnet 4.6 --- backend/src/main/resources/application.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 6104c5e..097c33a 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -21,6 +21,10 @@ spring: servlet: multipart: + # NOTE: these limits only apply to multipart/form-data uploads. + # Images sent as base64 inside a JSON body (Content-Type: application/json) + # are NOT constrained here — the @Size(max=7_000_000) annotation on + # RecipeCreateRequest.heroImageUrl enforces the limit for that path. max-file-size: 5MB max-request-size: 6MB -- 2.49.1 From 932155c559e55bfb0d786be333e3ccbb01abf3cc Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:39:01 +0200 Subject: [PATCH 47/49] chore(backend): ignore application-dev.yml to prevent leaking local secrets Co-Authored-By: Claude Sonnet 4.6 --- backend/.gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/.gitignore b/backend/.gitignore index 667aaef..63fdf0c 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -31,3 +31,6 @@ build/ ### VS Code ### .vscode/ + +### Local dev config (may contain secrets / local DB credentials) ### +src/main/resources/application-dev.yml -- 2.49.1 From 73b4fb84e734937535865b2744f494d76a176359 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 09:40:04 +0200 Subject: [PATCH 48/49] feat(recipes): add (min) unit hint to Kochzeit label Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/RecipeForm.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/recipes/RecipeForm.svelte b/frontend/src/lib/recipes/RecipeForm.svelte index c9ca6e8..89c252b 100644 --- a/frontend/src/lib/recipes/RecipeForm.svelte +++ b/frontend/src/lib/recipes/RecipeForm.svelte @@ -143,7 +143,7 @@ for="cookTimeMin" class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]" > - Kochzeit + Kochzeit (min) Date: Fri, 10 Apr 2026 09:40:51 +0200 Subject: [PATCH 49/49] feat(recipes): give 'Bild entfernen' button persistent muted-red color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was only red on hover — now always red at 60% opacity, full opacity on hover, making the destructive intent immediately visible. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/RecipeForm.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/recipes/RecipeForm.svelte b/frontend/src/lib/recipes/RecipeForm.svelte index 89c252b..d3e6e80 100644 --- a/frontend/src/lib/recipes/RecipeForm.svelte +++ b/frontend/src/lib/recipes/RecipeForm.svelte @@ -195,7 +195,7 @@ -- 2.49.1