diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 048c058..0e39c54 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -166,13 +166,43 @@ public class PlanningService { private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate, VarietyScoreConfig config, Set recentlyCookedIds) { - // Build a simulated slot list: existing slots + candidate on slotDate List simulatedSlots = new ArrayList<>(); for (WeekPlanSlot slot : plan.getSlots()) { simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate())); } simulatedSlots.add(new SimulatedSlot(candidate, slotDate)); + return scoreFromSimulatedSlots(simulatedSlots, config, recentlyCookedIds); + } + private record SimulatedSlot(Recipe recipe, LocalDate date) {} + + @Transactional(readOnly = true) + public VarietyPreviewResponse getVarietyPreview(UUID householdId, UUID planId, UUID recipeId, LocalDate date) { + WeekPlan plan = findPlan(planId, householdId); + Recipe candidate = findRecipe(recipeId, householdId); + + VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId) + .orElse(VarietyScoreConfig.defaults(plan.getHousehold())); + + Set recentlyCookedIds = cookingLogRepository + .findByHouseholdIdAndCookedOnAfter(householdId, + plan.getWeekStart().minusDays(config.getHistoryDays())) + .stream() + .map(cl -> cl.getRecipe().getId()) + .collect(Collectors.toSet()); + + List currentSlots = plan.getSlots().stream() + .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) + .toList(); + double currentScore = currentSlots.isEmpty() ? 10.0 + : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); + double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds); + + return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore); + } + + private double scoreFromSimulatedSlots(List slots, VarietyScoreConfig config, + Set recentlyCookedIds) { List checkedTagTypes = config.getRepeatTagTypes(); double wTagRepeat = config.getWTagRepeat().doubleValue(); double wIngredientOverlap = config.getWIngredientOverlap().doubleValue(); @@ -181,21 +211,18 @@ public class PlanningService { // 1. Tag-type repeats on consecutive days Map> tagDays = new LinkedHashMap<>(); - for (SimulatedSlot slot : simulatedSlots) { + for (SimulatedSlot slot : slots) { for (Tag tag : slot.recipe.getTags()) { if (checkedTagTypes.contains(tag.getTagType())) { - tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()) - .add(slot.date); + tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()).add(slot.date); } } } - long tagRepeatCount = tagDays.values().stream() - .filter(this::hasConsecutiveDays) - .count(); + long tagRepeatCount = tagDays.values().stream().filter(this::hasConsecutiveDays).count(); // 2. Non-staple ingredient overlaps on consecutive days Map> ingredientDays = new LinkedHashMap<>(); - for (SimulatedSlot slot : simulatedSlots) { + for (SimulatedSlot slot : slots) { for (RecipeIngredient ri : slot.recipe.getIngredients()) { if (!ri.getIngredient().isStaple()) { ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>()) @@ -203,19 +230,17 @@ public class PlanningService { } } } - long ingredientOverlapCount = ingredientDays.values().stream() - .filter(this::hasConsecutiveDays) - .count(); + long ingredientOverlapCount = ingredientDays.values().stream().filter(this::hasConsecutiveDays).count(); // 3. Recent repeats from cooking log - long recentRepeatCount = simulatedSlots.stream() + long recentRepeatCount = slots.stream() .map(s -> s.recipe.getId()) .distinct() .filter(recentlyCookedIds::contains) .count(); - // 4. Duplicate recipes within the simulated plan - Map recipeCounts = simulatedSlots.stream() + // 4. Duplicate recipes within the plan + Map recipeCounts = slots.stream() .collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting())); long duplicatePenaltyCount = recipeCounts.values().stream() .filter(c -> c > 1) @@ -230,8 +255,6 @@ public class PlanningService { return Math.max(0, Math.min(10, score)); } - private record SimulatedSlot(Recipe recipe, LocalDate date) {} - @Transactional(readOnly = true) public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) { WeekPlan plan = findPlan(planId, householdId); diff --git a/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java b/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java index 0306fb0..a8dea57 100644 --- a/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java +++ b/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java @@ -1,5 +1,6 @@ package com.recipeapp.planning; +import com.recipeapp.common.RequiresHouseholdRole; import com.recipeapp.planning.dto.*; import com.recipeapp.recipe.HouseholdResolver; import jakarta.validation.Valid; @@ -40,6 +41,7 @@ public class WeekPlanController { } @PostMapping("/{id}/slots") + @RequiresHouseholdRole("planner") public ResponseEntity addSlot( Principal principal, @PathVariable UUID id, @@ -50,6 +52,7 @@ public class WeekPlanController { } @PatchMapping("/{planId}/slots/{slotId}") + @RequiresHouseholdRole("planner") public SlotResponse updateSlot( Principal principal, @PathVariable UUID planId, @@ -61,6 +64,7 @@ public class WeekPlanController { @DeleteMapping("/{planId}/slots/{slotId}") @ResponseStatus(HttpStatus.NO_CONTENT) + @RequiresHouseholdRole("planner") public void deleteSlot( Principal principal, @PathVariable UUID planId, @@ -92,4 +96,15 @@ public class WeekPlanController { UUID householdId = householdResolver.resolve(principal.getName()); return planningService.getVarietyScore(householdId, id); } + + @GetMapping("/{planId}/variety-preview") + @RequiresHouseholdRole("member") + public VarietyPreviewResponse getVarietyPreview( + Principal principal, + @PathVariable UUID planId, + @RequestParam UUID recipeId, + @RequestParam LocalDate date) { + UUID householdId = householdResolver.resolve(principal.getName()); + return planningService.getVarietyPreview(householdId, planId, recipeId, date); + } } diff --git a/backend/src/main/java/com/recipeapp/planning/dto/VarietyPreviewResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/VarietyPreviewResponse.java new file mode 100644 index 0000000..5f78407 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/dto/VarietyPreviewResponse.java @@ -0,0 +1,7 @@ +package com.recipeapp.planning.dto; + +public record VarietyPreviewResponse( + double currentScore, + double projectedScore, + double scoreDelta +) {} diff --git a/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java index 670c50b..6ab3f52 100644 --- a/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java +++ b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java @@ -443,4 +443,93 @@ class PlanningServiceTest { assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START)) .isInstanceOf(ResourceNotFoundException.class); } + + // ── Variety preview ── + + @Test + void getVarietyPreviewShouldReturnScoreDeltaForDifferentRecipe() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var planId = plan.getId(); + + // Plan already has one slot (Mon) with Spaghetti + var existingRecipe = testRecipe(household, "Spaghetti"); + var slot = new WeekPlanSlot(plan, existingRecipe, WEEK_START); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + + // Candidate is Lachsfilet (different recipe, no shared tags/ingredients) + var candidate = testRecipe(household, "Lachsfilet"); + var candidateId = candidate.getId(); + + when(weekPlanRepository.findById(planId)).thenReturn(Optional.of(plan)); + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(candidateId, HOUSEHOLD_ID)) + .thenReturn(Optional.of(candidate)); + when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty()); + when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any())) + .thenReturn(List.of()); + + var result = planningService.getVarietyPreview(HOUSEHOLD_ID, planId, candidateId, WEEK_START.plusDays(1)); + + // 1 existing slot with no conflicts → currentScore = 10.0 + // Adding a different recipe with no tags/ingredients → projectedScore = 10.0, delta = 0 + assertThat(result.currentScore()).isEqualTo(10.0); + assertThat(result.projectedScore()).isEqualTo(10.0); + assertThat(result.scoreDelta()).isEqualTo(0.0); + } + + @Test + void getVarietyPreviewShouldReturnNegativeDeltaForDuplicateRecipe() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var planId = plan.getId(); + + // Plan already has Spaghetti on Mon + var existingRecipe = testRecipe(household, "Spaghetti"); + var slot = new WeekPlanSlot(plan, existingRecipe, WEEK_START); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + + // Candidate is the same Spaghetti recipe → triggers duplicate penalty (wPlanDuplicate = 2.0) + when(weekPlanRepository.findById(planId)).thenReturn(Optional.of(plan)); + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(existingRecipe.getId(), HOUSEHOLD_ID)) + .thenReturn(Optional.of(existingRecipe)); + when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty()); + when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any())) + .thenReturn(List.of()); + + var result = planningService.getVarietyPreview( + HOUSEHOLD_ID, planId, existingRecipe.getId(), WEEK_START.plusDays(1)); + + // currentScore = 10.0 (1 slot, no conflicts) + // projectedScore = 10.0 - 1 * 2.0 (duplicate penalty) = 8.0 + assertThat(result.currentScore()).isEqualTo(10.0); + assertThat(result.projectedScore()).isEqualTo(8.0); + assertThat(result.scoreDelta()).isEqualTo(-2.0); + } + + @Test + void getVarietyPreviewShouldThrowWhenPlanNotFound() { + var planId = UUID.randomUUID(); + when(weekPlanRepository.findById(planId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.getVarietyPreview( + HOUSEHOLD_ID, planId, UUID.randomUUID(), WEEK_START)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getVarietyPreviewShouldThrowWhenRecipeNotFound() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var recipeId = UUID.randomUUID(); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, HOUSEHOLD_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> planningService.getVarietyPreview( + HOUSEHOLD_ID, plan.getId(), recipeId, WEEK_START)) + .isInstanceOf(ResourceNotFoundException.class); + } } diff --git a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java index 888e824..e827493 100644 --- a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java +++ b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java @@ -3,9 +3,11 @@ package com.recipeapp.planning; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.common.HouseholdRoleInterceptor; import com.recipeapp.common.ValidationException; import com.recipeapp.planning.dto.*; import com.recipeapp.recipe.HouseholdResolver; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -13,6 +15,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -49,6 +53,11 @@ class WeekPlanControllerTest { .build(); } + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + @Test void getWeekPlanShouldReturn200() throws Exception { var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of()); @@ -182,4 +191,79 @@ class WeekPlanControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.score").value(7.5)); } + + @Test + void getVarietyPreviewShouldReturn200() throws Exception { + var recipeId = UUID.randomUUID(); + var response = new VarietyPreviewResponse(8.0, 9.0, 1.0); + + when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); + when(planningService.getVarietyPreview(HOUSEHOLD_ID, PLAN_ID, recipeId, WEEK_START.plusDays(2))) + .thenReturn(response); + + mockMvc.perform(get("/v1/week-plans/{planId}/variety-preview", PLAN_ID) + .principal(() -> "sarah@example.com") + .param("recipeId", recipeId.toString()) + .param("date", "2026-04-08")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.currentScore").value(8.0)) + .andExpect(jsonPath("$.projectedScore").value(9.0)) + .andExpect(jsonPath("$.scoreDelta").value(1.0)); + } + + @Test + void addSlotShouldReturn403ForMemberRole() throws Exception { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("member@example.com", null)); + when(householdResolver.resolveRole("member@example.com")).thenReturn("member"); + + MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController) + .setControllerAdvice(new GlobalExceptionHandler()) + .addInterceptors(new HouseholdRoleInterceptor(householdResolver)) + .build(); + + var recipeId = UUID.randomUUID(); + mockMvcWithInterceptor.perform(post("/v1/week-plans/{id}/slots", PLAN_ID) + .principal(() -> "member@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new CreateSlotRequest(WEEK_START.plusDays(1), recipeId)))) + .andExpect(status().isForbidden()); + } + + @Test + void updateSlotShouldReturn403ForMemberRole() throws Exception { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("member@example.com", null)); + when(householdResolver.resolveRole("member@example.com")).thenReturn("member"); + + MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController) + .setControllerAdvice(new GlobalExceptionHandler()) + .addInterceptors(new HouseholdRoleInterceptor(householdResolver)) + .build(); + + var recipeId = UUID.randomUUID(); + mockMvcWithInterceptor.perform(patch("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID) + .principal(() -> "member@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new UpdateSlotRequest(recipeId)))) + .andExpect(status().isForbidden()); + } + + @Test + void deleteSlotShouldReturn403ForMemberRole() throws Exception { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("member@example.com", null)); + when(householdResolver.resolveRole("member@example.com")).thenReturn("member"); + + MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController) + .setControllerAdvice(new GlobalExceptionHandler()) + .addInterceptors(new HouseholdRoleInterceptor(householdResolver)) + .build(); + + mockMvcWithInterceptor.perform(delete("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID) + .principal(() -> "member@example.com")) + .andExpect(status().isForbidden()); + } + } diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index 68fab8a..4a59d78 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -79,7 +79,7 @@ describe('auth guard (hooks.server.ts handle)', () => { displayName: 'Max', householdId: 'h1', householdName: 'Familie Müller', - householdRole: 'planer', + householdRole: 'planner', email: 'max@example.com', systemRole: 'user' } diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 9fbd150..6f2ea43 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -39,7 +39,7 @@ export const handle: Handle = async ({ event, resolve }) => { event.locals.benutzer = { id: user.id!, name: user.displayName!, - rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied' + rolle: user.householdRole === 'planner' ? 'planer' : 'mitglied' }; event.locals.haushalt = { id: user.householdId ?? undefined, diff --git a/frontend/src/lib/components/BottomSheet.svelte b/frontend/src/lib/components/BottomSheet.svelte new file mode 100644 index 0000000..f706f65 --- /dev/null +++ b/frontend/src/lib/components/BottomSheet.svelte @@ -0,0 +1,95 @@ + + +{#if open} +
+ + + + +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-modal="true" + tabindex="-1" + > + +
+ + + + + +
+ + +
+ {@render children?.()} +
+
+
+{/if} diff --git a/frontend/src/lib/components/BottomSheet.test.ts b/frontend/src/lib/components/BottomSheet.test.ts new file mode 100644 index 0000000..4c413c3 --- /dev/null +++ b/frontend/src/lib/components/BottomSheet.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import BottomSheet from './BottomSheet.svelte'; + +describe('BottomSheet', () => { + it('is not mounted in DOM when open is false', () => { + render(BottomSheet, { props: { open: false, onclose: vi.fn() } }); + expect(screen.queryByTestId('bottom-sheet')).toBeNull(); + }); + + it('is mounted in DOM when open is true', () => { + render(BottomSheet, { props: { open: true, onclose: vi.fn() } }); + expect(screen.getByTestId('bottom-sheet')).toBeTruthy(); + }); + + it('calls onclose when close button is clicked', async () => { + const onclose = vi.fn(); + render(BottomSheet, { props: { open: true, onclose } }); + const closeBtn = screen.getByRole('button', { name: /schließen/i }); + await userEvent.click(closeBtn); + expect(onclose).toHaveBeenCalledOnce(); + }); + + it('calls onclose when backdrop is clicked', async () => { + const onclose = vi.fn(); + render(BottomSheet, { props: { open: true, onclose } }); + const backdrop = screen.getByTestId('sheet-backdrop'); + await userEvent.click(backdrop); + expect(onclose).toHaveBeenCalledOnce(); + }); + + it('calls onclose when Escape is pressed', async () => { + const onclose = vi.fn(); + render(BottomSheet, { props: { open: true, onclose } }); + await userEvent.keyboard('{Escape}'); + expect(onclose).toHaveBeenCalledOnce(); + }); + + it('drag handle has aria-hidden', () => { + render(BottomSheet, { props: { open: true, onclose: vi.fn() } }); + const handle = screen.getByTestId('drag-handle'); + expect(handle.getAttribute('aria-hidden')).toBe('true'); + }); + + it('does not call onclose when Escape is pressed while closed', async () => { + const onclose = vi.fn(); + render(BottomSheet, { props: { open: false, onclose } }); + await userEvent.keyboard('{Escape}'); + expect(onclose).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/planner/DayMealCard.svelte b/frontend/src/lib/planner/DayMealCard.svelte index eeaed6a..fb03bdf 100644 --- a/frontend/src/lib/planner/DayMealCard.svelte +++ b/frontend/src/lib/planner/DayMealCard.svelte @@ -16,12 +16,14 @@ slot, isToday = false, isSelected = false, - readonly = false + readonly = false, + onaddrecipe }: { slot: Slot; isToday?: boolean; isSelected?: boolean; readonly?: boolean; + onaddrecipe?: () => void; } = $props(); let metadata = $derived( @@ -64,23 +66,27 @@ > Jetzt kochen - - Tauschen - + {#if onaddrecipe} + + {/if} {/if} {:else}

Kein Gericht geplant

- {#if !readonly} - + Gericht hinzufügen - + {/if} {/if} diff --git a/frontend/src/lib/planner/DayMealCard.test.ts b/frontend/src/lib/planner/DayMealCard.test.ts index dbac4db..cafa53a 100644 --- a/frontend/src/lib/planner/DayMealCard.test.ts +++ b/frontend/src/lib/planner/DayMealCard.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; import DayMealCard from './DayMealCard.svelte'; const slot = { @@ -14,22 +15,29 @@ describe('DayMealCard', () => { expect(screen.getByText('Pasta Bolognese')).toBeTruthy(); }); - it('shows Cook now and Tauschen links when not readonly', () => { - render(DayMealCard, { props: { slot, isToday: false, readonly: false } }); + it('shows Jetzt kochen link and Tauschen button when not readonly and onaddrecipe provided', () => { + render(DayMealCard, { props: { slot, isToday: false, readonly: false, onaddrecipe: vi.fn() } }); expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy(); - expect(screen.getByRole('link', { name: /Tauschen/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /Tauschen/i })).toBeTruthy(); }); - it('Tauschen link navigates to suggestions for the slot day', () => { + it('Tauschen button calls onaddrecipe when clicked', async () => { + const onaddrecipe = vi.fn(); + const user = userEvent.setup(); + render(DayMealCard, { props: { slot, isToday: false, readonly: false, onaddrecipe } }); + await user.click(screen.getByRole('button', { name: /Tauschen/i })); + expect(onaddrecipe).toHaveBeenCalledOnce(); + }); + + it('hides Tauschen button when onaddrecipe not provided', () => { render(DayMealCard, { props: { slot, isToday: false, readonly: false } }); - const link = screen.getByRole('link', { name: /Tauschen/i }); - expect(link.getAttribute('href')).toContain('2026-03-30'); + expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull(); }); it('hides action links when readonly', () => { - render(DayMealCard, { props: { slot, isToday: false, readonly: true } }); + render(DayMealCard, { props: { slot, isToday: false, readonly: true, onaddrecipe: vi.fn() } }); expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull(); - expect(screen.queryByRole('link', { name: /Tauschen/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull(); }); it('applies today styling when isToday is true', () => { @@ -55,9 +63,22 @@ describe('DayMealCard', () => { expect(screen.getByText(/Easy/)).toBeTruthy(); }); - it('empty state shows add link with suggestions href', () => { + it('empty state shows add button when onaddrecipe provided', () => { + const onaddrecipe = vi.fn(); + render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false, onaddrecipe } }); + expect(screen.getByRole('button', { name: /Gericht hinzufügen/i })).toBeTruthy(); + }); + + it('add button calls onaddrecipe when clicked', async () => { + const onaddrecipe = vi.fn(); + const user = userEvent.setup(); + render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false, onaddrecipe } }); + await user.click(screen.getByRole('button', { name: /Gericht hinzufügen/i })); + expect(onaddrecipe).toHaveBeenCalledOnce(); + }); + + it('empty state hides add button when onaddrecipe not provided', () => { render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } }); - const link = screen.getByRole('link', { name: /Gericht hinzufügen/i }); - expect(link.getAttribute('href')).toContain('2026-03-31'); + expect(screen.queryByRole('button', { name: /Gericht hinzufügen/i })).toBeNull(); }); }); diff --git a/frontend/src/lib/planner/DayPicker.svelte b/frontend/src/lib/planner/DayPicker.svelte new file mode 100644 index 0000000..b1a31c7 --- /dev/null +++ b/frontend/src/lib/planner/DayPicker.svelte @@ -0,0 +1,168 @@ + + +
+ +
+

+ Tag wählen +

+

+ {recipeName} +

+
+ + +
+ + + {formatWeekRange(weekStart)} + + +
+ + +
+ {#each days as date (date)} + {@const state = chipState(date)} + + {/each} +
+ + + {#if selectedDate && existingRecipeName} +
+ Ersetzt {existingRecipeName} an diesem Tag. +
+ {/if} + + +
+ +
+
diff --git a/frontend/src/lib/planner/DayPicker.test.ts b/frontend/src/lib/planner/DayPicker.test.ts new file mode 100644 index 0000000..a896e0d --- /dev/null +++ b/frontend/src/lib/planner/DayPicker.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import DayPicker from './DayPicker.svelte'; + +const weekStart = '2026-03-30'; // Monday +const today = '2026-04-01'; // Wednesday + +// Mo: filled, Di: filled (today), Mi: filled, Do: empty, Fr: filled, Sa: empty, So: filled +const slots = [ + { id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'easy' } }, + { id: 's2', slotDate: '2026-04-01', recipe: { id: 'r2', name: 'Curry', effort: 'easy' } }, + { id: 's3', slotDate: '2026-04-02', recipe: { id: 'r3', name: 'Risotto', effort: 'medium' } }, + { id: 's5', slotDate: '2026-04-04', recipe: { id: 'r5', name: 'Suppe', effort: 'easy' } }, + { id: 's7', slotDate: '2026-04-06', recipe: { id: 'r7', name: 'Stir Fry', effort: 'easy' } } +]; + +const baseProps = { + recipeName: 'Mushroom Risotto', + recipeId: 'recipe-42', + planId: 'plan-1', + weekStart, + today, + slots, + onconfirm: vi.fn(), + onweekchange: vi.fn() +}; + +describe('DayPicker', () => { + it('shows recipe name in header', () => { + render(DayPicker, { props: baseProps }); + expect(screen.getByText('Mushroom Risotto')).toBeTruthy(); + }); + + it('shows 7 day chips', () => { + render(DayPicker, { props: baseProps }); + const chips = screen.getAllByTestId(/^chip-/); + expect(chips).toHaveLength(7); + }); + + it('marks empty slot chips with data-state="empty"', () => { + render(DayPicker, { props: baseProps }); + // Do (2026-04-03) and Sa (2026-04-05) are empty + const doChip = screen.getByTestId('chip-2026-04-03'); + expect(doChip.getAttribute('data-state')).toBe('empty'); + }); + + it('marks filled slot chips with data-state="filled"', () => { + render(DayPicker, { props: baseProps }); + const moChip = screen.getByTestId('chip-2026-03-30'); + expect(moChip.getAttribute('data-state')).toBe('filled'); + }); + + it('marks today chip with data-state="today"', () => { + render(DayPicker, { props: baseProps }); + const todayChip = screen.getByTestId('chip-2026-04-01'); + expect(todayChip.getAttribute('data-state')).toBe('today'); + }); + + it('selecting an empty chip changes its state to sel-empty', async () => { + render(DayPicker, { props: baseProps }); + const doChip = screen.getByTestId('chip-2026-04-03'); + await userEvent.click(doChip); + expect(doChip.getAttribute('data-state')).toBe('sel-empty'); + }); + + it('selecting a filled chip changes its state to sel-filled', async () => { + render(DayPicker, { props: baseProps }); + const moChip = screen.getByTestId('chip-2026-03-30'); + await userEvent.click(moChip); + expect(moChip.getAttribute('data-state')).toBe('sel-filled'); + }); + + it('shows replace warning when filled chip is selected', async () => { + render(DayPicker, { props: baseProps }); + const moChip = screen.getByTestId('chip-2026-03-30'); + await userEvent.click(moChip); + expect(screen.getByTestId('replace-warning')).toBeTruthy(); + expect(screen.getByText(/Pasta/)).toBeTruthy(); + }); + + it('does not show replace warning when empty chip is selected', async () => { + render(DayPicker, { props: baseProps }); + const doChip = screen.getByTestId('chip-2026-04-03'); + await userEvent.click(doChip); + expect(screen.queryByTestId('replace-warning')).toBeNull(); + }); + + it('confirm button is disabled when no chip is selected', () => { + render(DayPicker, { props: baseProps }); + const btn = screen.getByTestId('confirm-btn'); + expect(btn.hasAttribute('disabled')).toBe(true); + }); + + it('calls onconfirm with date and null slotId when empty chip confirmed', async () => { + const onconfirm = vi.fn(); + render(DayPicker, { props: { ...baseProps, onconfirm } }); + const doChip = screen.getByTestId('chip-2026-04-03'); + await userEvent.click(doChip); + const btn = screen.getByTestId('confirm-btn'); + await userEvent.click(btn); + expect(onconfirm).toHaveBeenCalledWith({ date: '2026-04-03', slotId: null }); + }); + + it('calls onconfirm with date and slotId when filled chip confirmed', async () => { + const onconfirm = vi.fn(); + render(DayPicker, { props: { ...baseProps, onconfirm } }); + const moChip = screen.getByTestId('chip-2026-03-30'); + await userEvent.click(moChip); + const btn = screen.getByTestId('confirm-btn'); + await userEvent.click(btn); + expect(onconfirm).toHaveBeenCalledWith({ date: '2026-03-30', slotId: 's1' }); + }); + + it('shows prev/next week navigation buttons', () => { + render(DayPicker, { props: baseProps }); + expect(screen.getByRole('button', { name: /Vorherige Woche/ })).toBeTruthy(); + expect(screen.getByRole('button', { name: /Nächste Woche/ })).toBeTruthy(); + }); + + it('calls onweekchange with prev week when prev button clicked', async () => { + const onweekchange = vi.fn(); + render(DayPicker, { props: { ...baseProps, onweekchange } }); + await userEvent.click(screen.getByRole('button', { name: /Vorherige Woche/ })); + expect(onweekchange).toHaveBeenCalledWith('2026-03-23'); + }); + + it('calls onweekchange with next week when next button clicked', async () => { + const onweekchange = vi.fn(); + render(DayPicker, { props: { ...baseProps, onweekchange } }); + await userEvent.click(screen.getByRole('button', { name: /Nächste Woche/ })); + expect(onweekchange).toHaveBeenCalledWith('2026-04-06'); + }); +}); diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte new file mode 100644 index 0000000..1d2ffde --- /dev/null +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -0,0 +1,168 @@ + + +
+ +
+

+ Rezept wählen +

+

+ {dateLabel} +

+
+ + +
+ +
+ + + {#if suggestions.length > 0} +
+ Empfohlen · Beste Abwechslung +
+ + {#each suggestions as suggestion (suggestion.recipe.id)} + {@const delta = suggestion.simulatedScore - currentVarietyScore} + {@const meta = recipeMetadata(suggestion.recipe)} +
+
+

+ {suggestion.recipe.name} +

+ {#if meta} +

+ {meta} +

+ {/if} + {#if delta > 0} + + ↑ +{delta.toFixed(0)} Punkte + + {:else} + + ⚠ Variationskonflikt + + {/if} +
+ +
+ {/each} + {/if} + + +
+ 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} +
diff --git a/frontend/src/lib/planner/RecipePicker.test.ts b/frontend/src/lib/planner/RecipePicker.test.ts new file mode 100644 index 0000000..4ef320b --- /dev/null +++ b/frontend/src/lib/planner/RecipePicker.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import RecipePicker from './RecipePicker.svelte'; + +const suggestions = [ + { recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, simulatedScore: 9.5 }, + { recipe: { id: 's2', name: 'Hähnchen-Curry', effort: 'easy', cookTimeMin: 35 }, simulatedScore: 6.0 } +]; + +const allRecipes = [ + { id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }, + { id: 'r2', name: 'Spaghetti Carbonara', effort: 'easy', cookTimeMin: 20 }, + { id: 'r3', name: 'Tomatensuppe', effort: 'easy', cookTimeMin: 30 } +]; + +const baseProps = { + planId: 'plan-1', + date: '2026-04-05', + dateLabel: 'Samstag, 5. April', + currentVarietyScore: 7.5, + suggestions, + allRecipes, + onpick: vi.fn() +}; + +describe('RecipePicker', () => { + it('shows date label in header', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.getByText('Samstag, 5. April')).toBeTruthy(); + }); + + it('shows Empfohlen section', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.getByText(/Empfohlen/i)).toBeTruthy(); + }); + + it('shows all suggestion recipe names', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.getByText('Lachsfilet')).toBeTruthy(); + expect(screen.getByText('Hähnchen-Curry')).toBeTruthy(); + }); + + it('shows green badge for suggestions with positive delta', () => { + render(RecipePicker, { props: baseProps }); + // Lachsfilet: simulatedScore 9.5 - currentVarietyScore 7.5 = +2 → green badge + const badge = screen.getByTestId('badge-s1'); + expect(badge.getAttribute('data-type')).toBe('good'); + }); + + it('shows yellow badge for suggestions with zero or negative delta', () => { + render(RecipePicker, { props: baseProps }); + // Hähnchen-Curry: 6.0 - 7.5 = -1.5 → yellow badge + const badge = screen.getByTestId('badge-s2'); + expect(badge.getAttribute('data-type')).toBe('warning'); + }); + + it('shows Alle Rezepte section', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.getByText(/Alle Rezepte/i)).toBeTruthy(); + }); + + it('shows all recipe names in Alle Rezepte', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.getByText('Beef Bourguignon')).toBeTruthy(); + expect(screen.getByText('Spaghetti Carbonara')).toBeTruthy(); + expect(screen.getByText('Tomatensuppe')).toBeTruthy(); + }); + + it('filters recipes by search query', async () => { + render(RecipePicker, { props: baseProps }); + const input = screen.getByRole('searchbox'); + await userEvent.type(input, 'Spaghetti'); + expect(screen.queryByText('Beef Bourguignon')).toBeNull(); + expect(screen.getByText('Spaghetti Carbonara')).toBeTruthy(); + }); + + it('calls onpick with recipeId and name when Wählen clicked for suggestion', async () => { + const onpick = vi.fn(); + render(RecipePicker, { props: { ...baseProps, onpick } }); + const buttons = screen.getAllByRole('button', { name: /Wählen/i }); + await userEvent.click(buttons[0]); + expect(onpick).toHaveBeenCalledWith('s1', 'Lachsfilet'); + }); + + it('calls onpick when Wählen clicked for all-recipes item', async () => { + const onpick = vi.fn(); + render(RecipePicker, { props: { ...baseProps, onpick } }); + const buttons = screen.getAllByRole('button', { name: /Wählen/i }); + // First 2 are suggestions, rest are allRecipes + await userEvent.click(buttons[2]); + expect(onpick).toHaveBeenCalledWith('r1', 'Beef Bourguignon'); + }); + + it('shows empty state when search has no results', async () => { + render(RecipePicker, { props: baseProps }); + const input = screen.getByRole('searchbox'); + await userEvent.type(input, 'xyznotfound'); + expect(screen.getByText(/Keine Treffer/i)).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/planner/UndoBar.svelte b/frontend/src/lib/planner/UndoBar.svelte new file mode 100644 index 0000000..2c6f1c6 --- /dev/null +++ b/frontend/src/lib/planner/UndoBar.svelte @@ -0,0 +1,67 @@ + + +{#if visible} +
+ + {message} + + +
+{/if} diff --git a/frontend/src/lib/planner/UndoBar.test.ts b/frontend/src/lib/planner/UndoBar.test.ts new file mode 100644 index 0000000..90a8f04 --- /dev/null +++ b/frontend/src/lib/planner/UndoBar.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import UndoBar from './UndoBar.svelte'; + +describe('UndoBar', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('is not mounted when visible is false', () => { + render(UndoBar, { props: { visible: false, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } }); + expect(screen.queryByTestId('undo-bar')).toBeNull(); + }); + + it('is mounted and shows message when visible is true', () => { + render(UndoBar, { props: { visible: true, message: 'Gericht hinzugefügt', onundo: vi.fn(), ondismiss: vi.fn() } }); + expect(screen.getByTestId('undo-bar')).toBeTruthy(); + expect(screen.getByText('Gericht hinzugefügt')).toBeTruthy(); + }); + + it('shows Rückgängig button', () => { + render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } }); + expect(screen.getByRole('button', { name: /Rückgängig/i })).toBeTruthy(); + }); + + it('calls onundo when Rückgängig is clicked', async () => { + const onundo = vi.fn(); + render(UndoBar, { props: { visible: true, message: 'Test', onundo, ondismiss: vi.fn() } }); + await userEvent.click(screen.getByRole('button', { name: /Rückgängig/i })); + expect(onundo).toHaveBeenCalledOnce(); + }); + + it('calls ondismiss after 4 seconds', async () => { + const ondismiss = vi.fn(); + render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss } }); + await act(() => { vi.advanceTimersByTime(4000); }); + expect(ondismiss).toHaveBeenCalledOnce(); + }); + + it('does not call ondismiss before 4 seconds', async () => { + const ondismiss = vi.fn(); + render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss } }); + await act(() => { vi.advanceTimersByTime(3999); }); + expect(ondismiss).not.toHaveBeenCalled(); + }); + + it('has role="status" for accessibility', () => { + render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } }); + expect(screen.getByRole('status')).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/recipes/RecipeCard.svelte b/frontend/src/lib/recipes/RecipeCard.svelte index 8140373..d38249b 100644 --- a/frontend/src/lib/recipes/RecipeCard.svelte +++ b/frontend/src/lib/recipes/RecipeCard.svelte @@ -1,7 +1,11 @@ - -
- {#if recipe.heroImageUrl} - {recipe.name} - {:else} -
-
+ {#if recipe.heroImageUrl} + {recipe.name} + {:else} +
- - - - - - -
- {/if} -
+ +
+ {/if} +
-
-

{recipe.name}

- {#if metadata} -

{metadata}

- {/if} -
-
+
+

{recipe.name}

+ {#if metadata} +

{metadata}

+ {/if} +
+ + + {#if onplan} +
+ 🍳 Jetzt kochen + +
+ {/if} + diff --git a/frontend/src/lib/recipes/RecipeCard.test.ts b/frontend/src/lib/recipes/RecipeCard.test.ts index f9792a4..d606ab9 100644 --- a/frontend/src/lib/recipes/RecipeCard.test.ts +++ b/frontend/src/lib/recipes/RecipeCard.test.ts @@ -42,12 +42,36 @@ describe('RecipeCard', () => { expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese'); }); - it('wraps in a link to the recipe detail page', () => { + it('has a link to the recipe detail page', () => { render(RecipeCard, { props: { recipe: mockRecipe } }); - const link = screen.getByRole('link'); + const link = screen.getByRole('link', { name: /Spaghetti Bolognese/i }); expect(link).toHaveAttribute('href', '/recipes/recipe-1'); }); + it('shows Jetzt kochen link when onplan provided', () => { + render(RecipeCard, { props: { recipe: mockRecipe, onplan: vi.fn() } }); + const cookLink = screen.getByRole('link', { name: /Jetzt kochen/i }); + expect(cookLink).toHaveAttribute('href', '/cook/recipe-1'); + }); + + it('does not show Jetzt kochen when onplan not provided', () => { + render(RecipeCard, { props: { recipe: mockRecipe } }); + expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull(); + }); + + it('shows Zur Woche + button when onplan provided', () => { + render(RecipeCard, { props: { recipe: mockRecipe, onplan: vi.fn() } }); + expect(screen.getByRole('button', { name: /Zur Woche/i })).toBeTruthy(); + }); + + it('calls onplan with recipeId and name when Zur Woche + clicked', async () => { + const onplan = vi.fn(); + const user = userEvent.setup(); + render(RecipeCard, { props: { recipe: mockRecipe, onplan } }); + await user.click(screen.getByRole('button', { name: /Zur Woche/i })); + expect(onplan).toHaveBeenCalledWith('recipe-1', 'Spaghetti Bolognese'); + }); + it('applies compact image height when compact prop is true', () => { render(RecipeCard, { props: { recipe: mockRecipe, compact: true } }); const imageArea = document.querySelector('[data-testid="image-area"]'); diff --git a/frontend/src/lib/recipes/RecipeGrid.svelte b/frontend/src/lib/recipes/RecipeGrid.svelte index 1c35c17..7c38f8b 100644 --- a/frontend/src/lib/recipes/RecipeGrid.svelte +++ b/frontend/src/lib/recipes/RecipeGrid.svelte @@ -2,13 +2,13 @@ import RecipeCard from './RecipeCard.svelte'; import type { RecipeSummary } from './types'; - let { recipes }: { recipes: RecipeSummary[] } = $props(); + let { recipes, onplan }: { recipes: RecipeSummary[]; onplan?: (recipeId: string, recipeName: string) => void } = $props(); {#if recipes.length > 0}
{#each recipes as recipe (recipe.id)} - + {/each}
{:else} diff --git a/frontend/src/lib/server/slotActions.ts b/frontend/src/lib/server/slotActions.ts new file mode 100644 index 0000000..be648e2 --- /dev/null +++ b/frontend/src/lib/server/slotActions.ts @@ -0,0 +1,65 @@ +import { apiClient } from '$lib/server/api'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isValidUuid(value: string | null): value is string { + return typeof value === 'string' && UUID_RE.test(value); +} + +export async function addSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) { + const formData = await request.formData(); + const planId = formData.get('planId') as string | null; + const slotDate = formData.get('slotDate') as string | null; + const recipeId = formData.get('recipeId') as string | null; + + if (!isValidUuid(planId) || !isValidUuid(recipeId) || !slotDate) { + return { success: false, error: 'Ungültige Eingabe.' }; + } + + const api = apiClient(fetch); + const { data, error } = await api.POST('/v1/week-plans/{id}/slots', { + params: { path: { id: planId } }, + body: { slotDate, recipeId } + }); + + if (error || !data) return { success: false }; + return { success: true, slot: data }; +} + +export async function updateSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) { + const formData = await request.formData(); + const planId = formData.get('planId') as string | null; + const slotId = formData.get('slotId') as string | null; + const recipeId = formData.get('recipeId') as string | null; + + if (!isValidUuid(planId) || !isValidUuid(slotId) || !isValidUuid(recipeId)) { + return { success: false, error: 'Ungültige Eingabe.' }; + } + + const api = apiClient(fetch); + const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', { + params: { path: { planId, slotId } }, + body: { recipeId } + }); + + if (error || !data) return { success: false }; + return { success: true, slot: data }; +} + +export async function deleteSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) { + const formData = await request.formData(); + const planId = formData.get('planId') as string | null; + const slotId = formData.get('slotId') as string | null; + + if (!isValidUuid(planId) || !isValidUuid(slotId)) { + return { success: false, error: 'Ungültige Eingabe.' }; + } + + const api = apiClient(fetch); + const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', { + params: { path: { planId, slotId } } + }); + + if (error) return { success: false }; + return { success: true }; +} diff --git a/frontend/src/routes/(app)/planner/+page.server.ts b/frontend/src/routes/(app)/planner/+page.server.ts index fee0c24..602dc39 100644 --- a/frontend/src/routes/(app)/planner/+page.server.ts +++ b/frontend/src/routes/(app)/planner/+page.server.ts @@ -1,32 +1,51 @@ import type { PageServerLoad, Actions } from './$types'; import { apiClient } from '$lib/server/api'; import { getWeekStart } from '$lib/planner/week'; +import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions'; export const load: PageServerLoad = async ({ fetch, url }) => { const weekParam = url.searchParams.get('week'); const weekStart = weekParam ?? getWeekStart(new Date()); const api = apiClient(fetch); - const { data: weekPlan, error } = await api.GET('/v1/week-plans', { - params: { query: { weekStart } } - }); + const [weekPlanResult, recipesResult] = await Promise.all([ + api.GET('/v1/week-plans', { params: { query: { weekStart } } }), + api.GET('/v1/recipes', {}) + ]); - if (error || !weekPlan?.id) { - return { weekPlan: null, varietyScore: null, weekStart }; + const recipes = + recipesResult.error || !recipesResult.data?.data + ? [] + : recipesResult.data.data.map((r: any) => ({ + id: r.id!, + name: r.name!, + cookTimeMin: r.cookTimeMin, + effort: r.effort, + heroImageUrl: r.heroImageUrl + })); + + if (weekPlanResult.error || !weekPlanResult.data?.id) { + return { weekPlan: null, varietyScore: null, weekStart, recipes }; } + const weekPlan = weekPlanResult.data; const { data: varietyScore } = await api.GET('/v1/week-plans/{id}/variety-score', { - params: { path: { id: weekPlan.id } } + params: { path: { id: weekPlan.id! } } }); return { weekPlan, varietyScore: varietyScore ?? null, - weekStart + weekStart, + recipes }; }; export const actions: Actions = { + addSlot: addSlotAction, + updateSlot: updateSlotAction, + deleteSlot: deleteSlotAction, + createPlan: async ({ fetch, request, locals }) => { // Role guard: only planners may create week plans if (locals.benutzer?.rolle !== 'planer') { diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index 373e481..1b69802 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -1,14 +1,17 @@ @@ -63,7 +149,7 @@ type="button" onclick={() => navigateWeek('prev')} aria-label="Vorherige Woche" - class="rounded-[var(--radius-md)] border border-[var(--color-border)] p-1.5 text-[var(--color-text)] hover:bg-[var(--color-surface)]" + class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]" > ‹ @@ -71,17 +157,18 @@ type="button" onclick={() => navigateWeek('next')} aria-label="Nächste Woche" - class="rounded-[var(--radius-md)] border border-[var(--color-border)] p-1.5 text-[var(--color-text)] hover:bg-[var(--color-surface)]" + class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]" > › {#if isPlanner} - (pickerOpen = true)} class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white" > + Gericht - + {/if} @@ -118,6 +205,7 @@ isToday={selectedDay === today} isSelected={true} readonly={!isPlanner} + onaddrecipe={isPlanner ? () => (pickerOpen = true) : undefined} /> @@ -128,7 +216,7 @@ Restliche Woche
- {#each remainingSlotsWithMeal as slot} + {#each remainingSlotsWithMeal as slot (slot.slotDate)}
{/if} + + + (pickerOpen = false)}> + + @@ -178,7 +279,7 @@ type="button" onclick={() => navigateWeek('prev')} aria-label="Vorherige Woche" - class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]" + class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]" > ‹ @@ -187,25 +288,26 @@ type="button" onclick={() => navigateWeek('next')} aria-label="Nächste Woche" - class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]" + class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]" > › {#if isPlanner} - (panelState = { kind: 'recipe-picker', date: selectedDay })} class="ml-auto rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white" > + Gericht hinzufügen - + {/if} @@ -240,7 +342,7 @@ {:else}
- {#each days as day} + {#each days as day (day)} {@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }} {@const isTodayDay = day === today} {@const isSelectedDay = day === selectedDay} @@ -266,7 +368,12 @@ +
- -
- - Rezept ansehen - - - Koch-Modus - - - {#if isPlanner} + {#if detailSlot.recipe} +

+ {detailSlot.recipe.name} +

+ {#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin} +

+ {[detailSlot.recipe.cookTimeMin ? `${detailSlot.recipe.cookTimeMin} Min` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ')} +

+ {/if} + +
- Gericht tauschen + Rezept ansehen + + Koch-Modus + + {#if isPlanner} + + {/if} +
+ {:else} +

Kein Gericht geplant

+ {#if isPlanner} + {/if} -
- {:else} -

Kein Gericht geplant

- {#if isPlanner} - - + Gericht wählen - {/if} + + {:else if panelState.kind === 'recipe-picker'} + {@const pickerDate = panelState.date} + + +
+

+ Rezept wählen +

+ +
+ +
+ +
{/if} + + + + + + (undoVisible = false)} +/> diff --git a/frontend/src/routes/(app)/planner/+server.ts b/frontend/src/routes/(app)/planner/+server.ts new file mode 100644 index 0000000..910efc5 --- /dev/null +++ b/frontend/src/routes/(app)/planner/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { apiClient } from '$lib/server/api'; + +// GET /planner?planId=&date= — returns suggestions JSON for C4 recipe picker +export const GET: RequestHandler = async ({ fetch, url }) => { + const planId = url.searchParams.get('planId'); + const date = url.searchParams.get('date'); + + if (!planId || !date) { + return json({ suggestions: [] }); + } + + 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) + ); + + return json({ suggestions }); +}; diff --git a/frontend/src/routes/(app)/planner/page.server.test.ts b/frontend/src/routes/(app)/planner/page.server.test.ts index 8cad15a..df992ad 100644 --- a/frontend/src/routes/(app)/planner/page.server.test.ts +++ b/frontend/src/routes/(app)/planner/page.server.test.ts @@ -6,10 +6,28 @@ vi.mock('$env/dynamic/private', () => ({ const mockGet = vi.fn(); const mockPost = vi.fn(); +const mockPatch = vi.fn(); +const mockDelete = vi.fn(); vi.mock('$lib/server/api', () => ({ - apiClient: () => ({ GET: mockGet, POST: mockPost }) + apiClient: () => ({ GET: mockGet, POST: mockPost, PATCH: mockPatch, DELETE: mockDelete }) })); +const PLAN_UUID = '11111111-1111-1111-1111-111111111111'; +const SLOT_UUID = '22222222-2222-2222-2222-222222222222'; +const RECIPE_UUID = '33333333-3333-3333-3333-333333333333'; + +const mockWeekPlan = { + id: PLAN_UUID, + weekStart: '2026-03-30', + status: 'draft', + slots: [ + { id: SLOT_UUID, slotDate: '2026-03-30', recipe: { id: RECIPE_UUID, name: 'Pasta', effort: 'Easy', cookTimeMin: 30 } } + ] +}; + +const mockRecipes = [{ id: RECIPE_UUID, name: 'Pasta', cookTimeMin: 30, effort: 'Easy' }]; +const mockVarietyScore = { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] }; + describe('planner page — load', () => { let load: any; @@ -21,48 +39,44 @@ describe('planner page — load', () => { load = mod.load; }); - const mockWeekPlan = { - id: 'plan-1', - weekStart: '2026-03-30', - status: 'draft', - slots: [ - { id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy', cookTimeMin: 30 } }, - { id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium', cookTimeMin: 45 } } - ] - }; - it('fetches week plan for the current week by default', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ - data: { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] }, - error: undefined - }); + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) // weekPlan + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) // recipes + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); // varietyScore const url = new URL('http://localhost/planner'); await load({ fetch: vi.fn(), url }); expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: expect.objectContaining({ weekStart: expect.any(String) }) }) })); }); it('uses weekStart from URL search params if provided', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: { score: 8 }, error: undefined }); + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); const url = new URL('http://localhost/planner?week=2026-03-30'); await load({ fetch: vi.fn(), url }); expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: { weekStart: '2026-03-30' } }) })); }); it('returns weekPlan with slots in page data', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: { score: 7.5 }, error: undefined }); + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); const url = new URL('http://localhost/planner'); const result = await load({ fetch: vi.fn(), url }); expect(result.weekPlan).toBeDefined(); - expect(result.weekPlan.id).toBe('plan-1'); - expect(result.weekPlan.slots).toHaveLength(2); + expect(result.weekPlan.id).toBe(PLAN_UUID); + expect(result.weekPlan.slots).toHaveLength(1); }); it('returns variety score in page data', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] }, error: undefined }); + const scoreWithOverlap = { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] }; + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: scoreWithOverlap, error: undefined }); const url = new URL('http://localhost/planner'); const result = await load({ fetch: vi.fn(), url }); expect(result.varietyScore.score).toBe(7.5); @@ -70,7 +84,9 @@ describe('planner page — load', () => { }); it('returns null weekPlan when API returns 404', async () => { - mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } }); + mockGet + .mockResolvedValueOnce({ data: undefined, error: { status: 404 } }) // weekPlan 404 + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }); // recipes const url = new URL('http://localhost/planner'); const result = await load({ fetch: vi.fn(), url }); expect(result.weekPlan).toBeNull(); @@ -78,8 +94,10 @@ describe('planner page — load', () => { }); it('returns the weekStart used for the query', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: { score: 6 }, error: undefined }); + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); const url = new URL('http://localhost/planner?week=2026-03-30'); const result = await load({ fetch: vi.fn(), url }); expect(result.weekStart).toBe('2026-03-30'); @@ -87,11 +105,34 @@ describe('planner page — load', () => { it('creates week plan if not found and fetches variety score after creation', async () => { // When no plan exists (404), load should return null weekPlan — creation is done via a POST action, not in load - mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } }); + mockGet + .mockResolvedValueOnce({ data: undefined, error: { status: 404 } }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }); const url = new URL('http://localhost/planner'); const result = await load({ fetch: vi.fn(), url }); expect(result.weekPlan).toBeNull(); }); + + it('returns recipes in page data', async () => { + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); + const url = new URL('http://localhost/planner'); + const result = await load({ fetch: vi.fn(), url }); + expect(result.recipes).toHaveLength(1); + expect(result.recipes[0].name).toBe('Pasta'); + }); + + it('returns empty recipes array when recipes API fails', async () => { + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: undefined, error: { status: 500 } }) // recipes fail + .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); + const url = new URL('http://localhost/planner'); + const result = await load({ fetch: vi.fn(), url }); + expect(result.recipes).toEqual([]); + }); }); describe('planner page — actions', () => { @@ -106,7 +147,7 @@ describe('planner page — actions', () => { }); it('createPlan action calls POST /v1/week-plans', async () => { - mockPost.mockResolvedValue({ data: { id: 'plan-new', weekStart: '2026-03-30', slots: [] }, error: undefined }); + mockPost.mockResolvedValue({ data: { id: PLAN_UUID, weekStart: '2026-03-30', slots: [] }, error: undefined }); const formData = new FormData(); formData.set('weekStart', '2026-03-30'); const result = await actions.createPlan({ @@ -176,20 +217,154 @@ describe('planner page — variety score partial failure', () => { load = mod.load; }); - const mockWeekPlan = { - id: 'plan-1', - weekStart: '2026-03-30', - status: 'draft', - slots: [] - }; - it('returns weekPlan even when variety score API fails', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); + mockGet + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); // variety score fails const url = new URL('http://localhost/planner'); const result = await load({ fetch: vi.fn(), url }); expect(result.weekPlan).toBeDefined(); - expect(result.weekPlan.id).toBe('plan-1'); + expect(result.weekPlan.id).toBe(PLAN_UUID); expect(result.varietyScore).toBeNull(); }); }); + +describe('planner page — slot actions', () => { + let actions: any; + + beforeEach(async () => { + mockGet.mockReset(); + mockPost.mockReset(); + mockPatch.mockReset(); + mockDelete.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + it('addSlot calls POST /v1/week-plans/{id}/slots and returns success with slot', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotDate', '2026-04-01'); + formData.set('recipeId', RECIPE_UUID); + mockPost.mockResolvedValue({ data: { id: SLOT_UUID, slotDate: '2026-04-01' }, error: undefined }); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(mockPost).toHaveBeenCalledWith( + '/v1/week-plans/{id}/slots', + expect.objectContaining({ + params: { path: { id: PLAN_UUID } }, + body: { slotDate: '2026-04-01', recipeId: RECIPE_UUID } + }) + ); + expect(result.success).toBe(true); + expect(result.slot?.id).toBe(SLOT_UUID); + }); + + it('addSlot returns failure when API errors', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotDate', '2026-04-01'); + formData.set('recipeId', RECIPE_UUID); + mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } }); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + }); + + it('addSlot returns validation error when planId is not a UUID', async () => { + const formData = new FormData(); + formData.set('planId', 'not-a-uuid'); + formData.set('slotDate', '2026-04-01'); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('addSlot returns validation error when slotDate is missing', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotId', SLOT_UUID); + formData.set('recipeId', RECIPE_UUID); + mockPatch.mockResolvedValue({ data: { id: SLOT_UUID }, error: undefined }); + const result = await actions.updateSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(mockPatch).toHaveBeenCalledWith( + '/v1/week-plans/{planId}/slots/{slotId}', + expect.objectContaining({ + params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } }, + body: { recipeId: RECIPE_UUID } + }) + ); + expect(result.success).toBe(true); + }); + + it('updateSlot returns validation error when slotId is not a UUID', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotId', 'bad-id'); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.updateSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockPatch).not.toHaveBeenCalled(); + }); + + it('deleteSlot calls DELETE /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotId', SLOT_UUID); + mockDelete.mockResolvedValue({ error: undefined }); + const result = await actions.deleteSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(mockDelete).toHaveBeenCalledWith( + '/v1/week-plans/{planId}/slots/{slotId}', + expect.objectContaining({ + params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } } + }) + ); + expect(result.success).toBe(true); + }); + + it('deleteSlot returns validation error when planId is missing', async () => { + const formData = new FormData(); + formData.set('slotId', SLOT_UUID); + const result = await actions.deleteSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/routes/(app)/planner/suggestions/+page.server.ts b/frontend/src/routes/(app)/planner/suggestions/+page.server.ts deleted file mode 100644 index 57e437d..0000000 --- a/frontend/src/routes/(app)/planner/suggestions/+page.server.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { PageServerLoad, Actions } from './$types'; -import { redirect } from '@sveltejs/kit'; -import { apiClient } from '$lib/server/api'; -import { getWeekStart } from '$lib/planner/week'; - -export const load: PageServerLoad = async ({ fetch, url, locals: _locals }) => { - const weekParam = url.searchParams.get('week'); - const weekStart = weekParam ?? getWeekStart(new Date()); - const selectedDay = url.searchParams.get('day') ?? weekStart; - - const api = apiClient(fetch); - - // Load the week plan for context (week-so-far display) - const { data: weekPlan, error: weekPlanError } = await api.GET('/v1/week-plans', { - params: { query: { weekStart } } - }); - - if (weekPlanError || !weekPlan?.id) { - return { weekPlan: null, suggestions: [], selectedDay, weekStart }; - } - - // Load variety-aware suggestions for the selected day - const { data: suggestionsData } = await api.GET('/v1/week-plans/{id}/suggestions', { - params: { path: { id: weekPlan.id }, query: { slotDate: selectedDay } } - }); - - // Sort by simulatedScore descending (highest = best variety fit) - const suggestions = (suggestionsData?.suggestions ?? []).sort( - (a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0) - ); - - return { weekPlan, suggestions, selectedDay, weekStart }; -}; - -export const actions: Actions = { - pickSuggestion: async ({ fetch, request, locals }) => { - // Role guard: only planners may assign meals - if (locals.benutzer?.rolle !== 'planer') { - return { success: false, error: 'Keine Berechtigung.' }; - } - - const formData = await request.formData(); - const planId = formData.get('planId') as string; - const recipeId = formData.get('recipeId') as string; - const slotDate = formData.get('slotDate') as string; - const weekStart = formData.get('weekStart') as string; - - // Validate slotDate format - if (!slotDate || !/^\d{4}-\d{2}-\d{2}$/.test(slotDate)) { - return { success: false, error: 'Ungültiges Datum.' }; - } - - // Validate planId is non-empty - if (!planId) { - return { success: false, error: 'Fehlende Plan-ID.' }; - } - - // Validate recipeId is UUID-like format - if (!recipeId || !/^[0-9a-f-]{36}$/.test(recipeId)) { - return { success: false, error: 'Ungültige Rezept-ID.' }; - } - - const api = apiClient(fetch); - const { data, error } = await api.POST('/v1/week-plans/{id}/slots', { - params: { path: { id: planId } }, - body: { slotDate, recipeId } - }); - - if (error || !data) { - return { success: false, error: 'Gericht konnte nicht hinzugefügt werden.' }; - } - - // Redirect back to the planner after successful pick (spec: "returns to C1") - redirect(303, `/planner?week=${weekStart || slotDate.slice(0, 7) + '-01'}`); - } -}; diff --git a/frontend/src/routes/(app)/planner/suggestions/+page.svelte b/frontend/src/routes/(app)/planner/suggestions/+page.svelte deleted file mode 100644 index b0e92cd..0000000 --- a/frontend/src/routes/(app)/planner/suggestions/+page.svelte +++ /dev/null @@ -1,199 +0,0 @@ - - - - Gerichtsvorschläge — Mealplan - - - -
- -
- - ‹ - -

- Vorschläge für {formatDayLabel(selectedDay)} -

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

- Keine Vorschläge verfügbar. -

- - Gesamte Rezeptbibliothek durchsuchen → - -
- {:else} -
- {#each rankedSuggestions as suggestion, i} - - {/each} -
- - - - {/if} -
-
- - - diff --git a/frontend/src/routes/(app)/planner/suggestions/page.server.test.ts b/frontend/src/routes/(app)/planner/suggestions/page.server.test.ts deleted file mode 100644 index 9ac3f9d..0000000 --- a/frontend/src/routes/(app)/planner/suggestions/page.server.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('$env/dynamic/private', () => ({ - env: { BACKEND_URL: 'http://localhost:8080' } -})); - -const mockGet = vi.fn(); -const mockPost = vi.fn(); -vi.mock('$lib/server/api', () => ({ - apiClient: () => ({ GET: mockGet, POST: mockPost }) -})); - -describe('suggestions page — load', () => { - let load: any; - - const mockSuggestions = { - suggestions: [ - { - recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 }, - simulatedScore: 9.2 - }, - { - recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 }, - simulatedScore: 6.1 - } - ] - }; - - const mockWeekPlan = { - id: 'plan-1', - weekStart: '2026-03-30', - status: 'draft', - slots: [ - { id: 's1', slotDate: '2026-03-30', recipe: { id: 'r3', name: 'Pasta', effort: 'Easy' } } - ] - }; - - beforeEach(async () => { - mockGet.mockReset(); - mockPost.mockReset(); - vi.resetModules(); - const mod = await import('./+page.server'); - load = mod.load; - }); - - it('fetches suggestions for the given plan and day', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({ - params: expect.objectContaining({ path: { id: 'plan-1' } }) - })); - }); - - it('returns suggestions list sorted by simulatedScore descending', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.suggestions[0].recipe.name).toBe('Pasta al Limone'); - expect(result.suggestions[1].recipe.name).toBe('Hühnchen Curry'); - }); - - it('returns the selectedDay from URL params', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.selectedDay).toBe('2026-04-01'); - }); - - it('returns empty suggestions when API fails', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.suggestions).toEqual([]); - }); - - it('returns week plan slots for context display', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.weekPlan).toBeDefined(); - expect(result.weekPlan.slots).toHaveLength(1); - }); - - it('returns null weekPlan and empty suggestions when week plan not found', async () => { - mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.weekPlan).toBeNull(); - expect(result.suggestions).toEqual([]); - }); - - it('defaults day to weekStart when no day param provided', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.selectedDay).toBe('2026-03-30'); - }); -}); - -describe('suggestions page — pickSuggestion action', () => { - let actions: any; - - beforeEach(async () => { - mockGet.mockReset(); - mockPost.mockReset(); - vi.resetModules(); - const mod = await import('./+page.server'); - actions = mod.actions; - }); - - it('adds a slot to the week plan via POST and redirects to planner', async () => { - mockPost.mockResolvedValue({ data: { id: 's-new', slotDate: '2026-04-01', recipe: {} }, error: undefined }); - const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f'); - formData.set('slotDate', '2026-04-01'); - formData.set('weekStart', '2026-03-30'); - try { - await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } - }); - expect.unreachable(); - } catch (e: any) { - expect(e.status).toBe(303); - expect(e.location).toBe('/planner?week=2026-03-30'); - } - expect(mockPost).toHaveBeenCalledWith('/v1/week-plans/{id}/slots', expect.objectContaining({ - params: { path: { id: 'plan-1' } }, - body: { slotDate: '2026-04-01', recipeId: '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f' } - })); - }); - - it('returns error when planId is missing', async () => { - const formData = new FormData(); - formData.set('planId', ''); - formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f'); - formData.set('slotDate', '2026-04-01'); - formData.set('weekStart', '2026-03-30'); - const result = await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } - }); - expect(result).toEqual({ success: false, error: 'Fehlende Plan-ID.' }); - expect(mockPost).not.toHaveBeenCalled(); - }); - - it('returns error for invalid recipeId format', async () => { - const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('recipeId', 'not-a-uuid'); - formData.set('slotDate', '2026-04-01'); - formData.set('weekStart', '2026-03-30'); - const result = await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } - }); - expect(result).toEqual({ success: false, error: 'Ungültige Rezept-ID.' }); - expect(mockPost).not.toHaveBeenCalled(); - }); - - it('returns error when API fails', async () => { - mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } }); - const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f'); - formData.set('slotDate', '2026-04-01'); - formData.set('weekStart', '2026-03-30'); - const result = await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } - }); - expect(result).toEqual({ success: false, error: expect.any(String) }); - }); - - it('returns permission error for member role', async () => { - const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f'); - formData.set('slotDate', '2026-04-01'); - formData.set('weekStart', '2026-03-30'); - const result = await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'mitglied' } } - }); - expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' }); - expect(mockPost).not.toHaveBeenCalled(); - }); - - it('returns error for invalid slotDate format', async () => { - const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f'); - formData.set('slotDate', 'not-a-date'); - formData.set('weekStart', '2026-03-30'); - const result = await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } - }); - expect(result).toEqual({ success: false, error: expect.any(String) }); - expect(mockPost).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/routes/(app)/recipes/+page.server.ts b/frontend/src/routes/(app)/recipes/+page.server.ts index bff4ccb..df455b0 100644 --- a/frontend/src/routes/(app)/recipes/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/+page.server.ts @@ -1,21 +1,36 @@ -import type { PageServerLoad } from './$types'; +import type { PageServerLoad, Actions } from './$types'; import { apiClient } from '$lib/server/api'; +import { getWeekStart } from '$lib/planner/week'; +import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions'; export const load: PageServerLoad = async ({ fetch }) => { const api = apiClient(fetch); - const { data, error } = await api.GET('/v1/recipes', {}); + const weekStart = getWeekStart(new Date()); - if (error || !data?.data) { - return { recipes: [] }; - } + const [recipesResult, weekPlanResult] = await Promise.all([ + api.GET('/v1/recipes', {}), + api.GET('/v1/week-plans', { params: { query: { weekStart } } }) + ]); - return { - recipes: data.data.map((r) => ({ - id: r.id!, - name: r.name!, - cookTimeMin: r.cookTimeMin, - effort: r.effort, - heroImageUrl: r.heroImageUrl - })) - }; + const recipes = + recipesResult.error || !recipesResult.data?.data + ? [] + : recipesResult.data.data.map((r) => ({ + id: r.id!, + name: r.name!, + cookTimeMin: r.cookTimeMin, + effort: r.effort, + heroImageUrl: r.heroImageUrl + })); + + const activePlan = + weekPlanResult.error || !weekPlanResult.data?.id ? null : weekPlanResult.data; + + return { recipes, activePlan }; +}; + +export const actions: Actions = { + addSlot: addSlotAction, + updateSlot: updateSlotAction, + deleteSlot: deleteSlotAction }; diff --git a/frontend/src/routes/(app)/recipes/+page.svelte b/frontend/src/routes/(app)/recipes/+page.svelte index c4eb2e4..28e7139 100644 --- a/frontend/src/routes/(app)/recipes/+page.svelte +++ b/frontend/src/routes/(app)/recipes/+page.svelte @@ -1,10 +1,18 @@ @@ -30,18 +109,116 @@
-

Rezepte

- Rezept hinzufügen +

+ Rezepte +

+ + Rezept hinzufügen +
- + (activeFilter = f)} /> - +
+ + (pickerOpen = false)} height="55vh"> + {#if pickerPlan} + + {/if} + + + (undoVisible = false)} +/> + + + + + + + diff --git a/frontend/src/routes/(app)/recipes/+server.ts b/frontend/src/routes/(app)/recipes/+server.ts new file mode 100644 index 0000000..5f63da9 --- /dev/null +++ b/frontend/src/routes/(app)/recipes/+server.ts @@ -0,0 +1,17 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { apiClient } from '$lib/server/api'; + +// GET /recipes?week=YYYY-MM-DD — returns week plan for DayPicker week navigation +export const GET: RequestHandler = async ({ fetch, url }) => { + const weekStart = url.searchParams.get('week'); + if (!weekStart) return json({ plan: null }); + + const api = apiClient(fetch); + const { data, error } = await api.GET('/v1/week-plans', { + params: { query: { weekStart } } + }); + + if (error || !data?.id) return json({ plan: null }); + return json({ plan: data }); +}; diff --git a/frontend/src/routes/(app)/recipes/page.server.test.ts b/frontend/src/routes/(app)/recipes/page.server.test.ts index 50c1358..e21b1b7 100644 --- a/frontend/src/routes/(app)/recipes/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/page.server.test.ts @@ -5,10 +5,32 @@ vi.mock('$env/dynamic/private', () => ({ })); const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPatch = vi.fn(); +const mockDelete = vi.fn(); vi.mock('$lib/server/api', () => ({ - apiClient: () => ({ GET: mockGet }) + apiClient: () => ({ GET: mockGet, POST: mockPost, PATCH: mockPatch, DELETE: mockDelete }) })); +vi.mock('$lib/planner/week', () => ({ + getWeekStart: () => '2026-04-07' +})); + +const PLAN_UUID = '11111111-1111-1111-1111-111111111111'; +const SLOT_UUID = '22222222-2222-2222-2222-222222222222'; +const RECIPE_UUID = '33333333-3333-3333-3333-333333333333'; + +const mockRecipes = [ + { id: RECIPE_UUID, name: 'Spaghetti', cookTimeMin: 30, effort: 'Easy' }, + { id: '44444444-4444-4444-4444-444444444444', name: 'Curry', cookTimeMin: 45, effort: 'Medium' } +]; + +const mockWeekPlan = { + id: PLAN_UUID, + weekStart: '2026-04-07', + slots: [{ id: SLOT_UUID, slotDate: '2026-04-07', recipe: { id: RECIPE_UUID, name: 'Pasta', effort: 'easy' } }] +}; + describe('recipe library page — load', () => { let load: any; @@ -19,27 +41,190 @@ describe('recipe library page — load', () => { load = mod.load; }); - const mockRecipes = [ - { id: 'r1', name: 'Spaghetti', cookTimeMin: 30, effort: 'Easy' }, - { id: 'r2', name: 'Curry', cookTimeMin: 45, effort: 'Medium' } - ]; - it('fetches recipes from GET /v1/recipes', async () => { - mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined }); + mockGet + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); await load({ fetch: vi.fn() } as any); expect(mockGet).toHaveBeenCalledWith('/v1/recipes', expect.any(Object)); }); it('returns recipes in data', async () => { - mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined }); + mockGet + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); const result = await load({ fetch: vi.fn() } as any); expect(result.recipes).toHaveLength(2); expect(result.recipes[0].name).toBe('Spaghetti'); }); - it('returns empty array when API fails', async () => { - mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } }); + it('returns empty array when recipes API fails', async () => { + mockGet + .mockResolvedValueOnce({ data: undefined, error: { status: 500 } }) + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); const result = await load({ fetch: vi.fn() } as any); expect(result.recipes).toEqual([]); }); + + it('fetches active week plan from GET /v1/week-plans', async () => { + mockGet + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); + await load({ fetch: vi.fn() } as any); + expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.any(Object)); + }); + + it('returns activePlan with id and slots in data', async () => { + mockGet + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); + const result = await load({ fetch: vi.fn() } as any); + expect(result.activePlan.id).toBe(PLAN_UUID); + expect(result.activePlan.slots).toHaveLength(1); + }); + + it('returns null activePlan when week plan API fails', async () => { + mockGet + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: undefined, error: { status: 404 } }); + const result = await load({ fetch: vi.fn() } as any); + expect(result.activePlan).toBeNull(); + }); +}); + +describe('recipe library page — actions', () => { + let actions: any; + + beforeEach(async () => { + mockGet.mockReset(); + mockPost.mockReset(); + mockPatch.mockReset(); + mockDelete.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + it('addSlot calls POST /v1/week-plans/{id}/slots and returns success', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotDate', '2026-04-01'); + formData.set('recipeId', RECIPE_UUID); + mockPost.mockResolvedValue({ data: { id: SLOT_UUID }, error: undefined }); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(mockPost).toHaveBeenCalledWith( + '/v1/week-plans/{id}/slots', + expect.objectContaining({ + params: { path: { id: PLAN_UUID } }, + body: { slotDate: '2026-04-01', recipeId: RECIPE_UUID } + }) + ); + expect(result.success).toBe(true); + }); + + it('addSlot returns error when API fails', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotDate', '2026-04-01'); + formData.set('recipeId', RECIPE_UUID); + mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } }); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + }); + + it('addSlot returns validation error when planId is not a UUID', async () => { + const formData = new FormData(); + formData.set('planId', 'plan-1'); + formData.set('slotDate', '2026-04-01'); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('addSlot returns validation error when slotDate is missing', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotId', SLOT_UUID); + formData.set('recipeId', RECIPE_UUID); + mockPatch.mockResolvedValue({ data: { id: SLOT_UUID }, error: undefined }); + const result = await actions.updateSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(mockPatch).toHaveBeenCalledWith( + '/v1/week-plans/{planId}/slots/{slotId}', + expect.objectContaining({ + params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } }, + body: { recipeId: RECIPE_UUID } + }) + ); + expect(result.success).toBe(true); + }); + + it('updateSlot returns validation error when slotId is not a UUID', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotId', 's1'); + formData.set('recipeId', RECIPE_UUID); + const result = await actions.updateSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockPatch).not.toHaveBeenCalled(); + }); + + it('deleteSlot calls DELETE /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + formData.set('slotId', SLOT_UUID); + mockDelete.mockResolvedValue({ error: undefined }); + const result = await actions.deleteSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(mockDelete).toHaveBeenCalledWith( + '/v1/week-plans/{planId}/slots/{slotId}', + expect.objectContaining({ + params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } } + }) + ); + expect(result.success).toBe(true); + }); + + it('deleteSlot returns validation error when slotId is missing', async () => { + const formData = new FormData(); + formData.set('planId', PLAN_UUID); + const result = await actions.deleteSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + expect(result.error).toBe('Ungültige Eingabe.'); + expect(mockDelete).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/routes/(app)/recipes/page.test.ts b/frontend/src/routes/(app)/recipes/page.test.ts index 8fe49fc..27e3ac9 100644 --- a/frontend/src/routes/(app)/recipes/page.test.ts +++ b/frontend/src/routes/(app)/recipes/page.test.ts @@ -8,7 +8,8 @@ const mockData = { { 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 }; describe('recipe library page', () => { @@ -68,7 +69,7 @@ describe('recipe library page', () => { }); it('renders empty state page when no recipes at all', () => { - render(Page, { props: { data: { recipes: [] } } }); + render(Page, { props: { data: { recipes: [], activePlan: null } } }); expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument(); }); diff --git a/frontend/src/routes/(app)/recipes/server.test.ts b/frontend/src/routes/(app)/recipes/server.test.ts new file mode 100644 index 0000000..d0e3224 --- /dev/null +++ b/frontend/src/routes/(app)/recipes/server.test.ts @@ -0,0 +1,71 @@ +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 mockWeekPlan = { + id: PLAN_UUID, + weekStart: '2026-04-07', + slots: [] +}; + +describe('GET /recipes (server route)', () => { + let GET: any; + + beforeEach(async () => { + mockGet.mockReset(); + vi.resetModules(); + const mod = await import('./+server'); + GET = mod.GET; + }); + + it('returns plan data when week param is provided and API succeeds', async () => { + mockGet.mockResolvedValue({ data: mockWeekPlan, error: undefined }); + const url = new URL('http://localhost/recipes?week=2026-04-07'); + const response = await GET({ fetch: vi.fn(), url } as any); + const body = await response.json(); + expect(body.plan).toBeDefined(); + expect(body.plan.id).toBe(PLAN_UUID); + }); + + it('returns { plan: null } when week param is missing', async () => { + const url = new URL('http://localhost/recipes'); + const response = await GET({ fetch: vi.fn(), url } as any); + const body = await response.json(); + expect(body.plan).toBeNull(); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('returns { plan: null } when API returns an error', async () => { + mockGet.mockResolvedValue({ data: undefined, error: { status: 404 } }); + const url = new URL('http://localhost/recipes?week=2026-04-07'); + const response = await GET({ fetch: vi.fn(), url } as any); + const body = await response.json(); + expect(body.plan).toBeNull(); + }); + + it('returns { plan: null } when API returns data without id', async () => { + mockGet.mockResolvedValue({ data: { weekStart: '2026-04-07' }, error: undefined }); + const url = new URL('http://localhost/recipes?week=2026-04-07'); + const response = await GET({ fetch: vi.fn(), url } as any); + const body = await response.json(); + expect(body.plan).toBeNull(); + }); + + it('calls GET /v1/week-plans with the provided week param', async () => { + mockGet.mockResolvedValue({ data: mockWeekPlan, error: undefined }); + const url = new URL('http://localhost/recipes?week=2026-04-14'); + await GET({ fetch: vi.fn(), url } as any); + expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ + params: { query: { weekStart: '2026-04-14' } } + })); + }); +}); diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index bb02c60..147fa4e 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -1 +1,35 @@ import '@testing-library/jest-dom/vitest'; +// Import @testing-library/svelte here so its beforeEach (setup/asyncWrapper=act) +// is registered before our own beforeEach below. +import '@testing-library/svelte'; +import { configure } from '@testing-library/dom'; +import { vi } from 'vitest'; +import { tick } from 'svelte'; +import userEvent from '@testing-library/user-event'; + +// Patch userEvent direct-API methods to use delay:null when fake timers are +// active. With delay:null, user-event's internal wait() short-circuits +// (typeof null !== 'number') and no setTimeout is scheduled — so clicks and +// other interactions work correctly under vi.useFakeTimers(). +const originalClick = userEvent.click.bind(userEvent); +// @ts-expect-error patching direct API +userEvent.click = (element: Element, options = {}) => { + if (vi.isFakeTimers()) { + // @ts-expect-error delay:null is a valid user-event option + return originalClick(element, { delay: null, ...options }); + } + return originalClick(element, options); +}; + +// Also update asyncWrapper to call tick() after async operations so Svelte +// DOM updates are flushed. @testing-library/svelte's act() already does this, +// but we re-configure after it to preserve our fake-timer behaviour. +beforeEach(() => { + configure({ + asyncWrapper: async (fn: () => Promise) => { + const result = await fn(); + await tick(); + return result; + } + }); +});