feat: Add-to-Plan flows C4/C5/C6 — recipe picker, quick actions, day picker #44
@@ -166,13 +166,43 @@ public class PlanningService {
|
||||
|
||||
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
|
||||
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
|
||||
// Build a simulated slot list: existing slots + candidate on slotDate
|
||||
List<SimulatedSlot> 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<UUID> recentlyCookedIds = cookingLogRepository
|
||||
.findByHouseholdIdAndCookedOnAfter(householdId,
|
||||
plan.getWeekStart().minusDays(config.getHistoryDays()))
|
||||
.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<SimulatedSlot> 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<SimulatedSlot> slots, VarietyScoreConfig config,
|
||||
Set<UUID> recentlyCookedIds) {
|
||||
List<String> 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<String, List<LocalDate>> 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<String, List<LocalDate>> 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<UUID, Long> recipeCounts = simulatedSlots.stream()
|
||||
// 4. Duplicate recipes within the plan
|
||||
Map<UUID, Long> 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);
|
||||
|
||||
@@ -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<SlotResponse> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.recipeapp.planning.dto;
|
||||
|
||||
public record VarietyPreviewResponse(
|
||||
double currentScore,
|
||||
double projectedScore,
|
||||
double scoreDelta
|
||||
) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
95
frontend/src/lib/components/BottomSheet.svelte
Normal file
95
frontend/src/lib/components/BottomSheet.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
open = false,
|
||||
onclose,
|
||||
height = '75vh',
|
||||
children
|
||||
}: {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
height?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
data-testid="bottom-sheet"
|
||||
aria-hidden={open ? 'false' : 'true'}
|
||||
class="fixed inset-0 z-50 flex items-end"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
data-testid="sheet-backdrop"
|
||||
class="absolute inset-0"
|
||||
style="background: rgba(28,28,24,0.4);"
|
||||
onclick={onclose}
|
||||
role="presentation"
|
||||
></div>
|
||||
|
||||
<!-- Sheet panel -->
|
||||
<div
|
||||
class="relative z-10 w-full flex flex-col overflow-hidden"
|
||||
style="
|
||||
background: var(--color-page);
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
box-shadow: var(--shadow-overlay);
|
||||
max-height: {height};
|
||||
"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Header row: drag handle + close button -->
|
||||
<div class="relative flex items-center justify-center pt-3 pb-2 px-4">
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
data-testid="drag-handle"
|
||||
aria-hidden="true"
|
||||
class="absolute"
|
||||
style="
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
"
|
||||
></div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Schließen"
|
||||
class="ml-auto text-xl leading-none"
|
||||
onclick={onclose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body content -->
|
||||
<div class="overflow-y-auto flex-1">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
52
frontend/src/lib/components/BottomSheet.test.ts
Normal file
52
frontend/src/lib/components/BottomSheet.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
</a>
|
||||
<a
|
||||
href="/planner/suggestions?day={slot.slotDate}"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)]"
|
||||
>
|
||||
Tauschen
|
||||
</a>
|
||||
{#if onaddrecipe}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onaddrecipe}
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)]"
|
||||
>
|
||||
Tauschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||
{#if !readonly}
|
||||
<a
|
||||
href="/planner/suggestions?day={slot.slotDate}"
|
||||
{#if !readonly && onaddrecipe}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onaddrecipe}
|
||||
class="mt-2 inline-block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||
>
|
||||
+ Gericht hinzufügen
|
||||
</a>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
168
frontend/src/lib/planner/DayPicker.svelte
Normal file
168
frontend/src/lib/planner/DayPicker.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { weekDays, prevWeek, nextWeek, formatDayAbbr, formatWeekRange } from './week';
|
||||
|
||||
interface Slot {
|
||||
id: string;
|
||||
slotDate: string;
|
||||
recipe: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
let {
|
||||
recipeName,
|
||||
recipeId,
|
||||
planId,
|
||||
weekStart,
|
||||
today,
|
||||
slots = [],
|
||||
onconfirm,
|
||||
onweekchange
|
||||
}: {
|
||||
recipeName: string;
|
||||
recipeId: string;
|
||||
planId: string;
|
||||
weekStart: string;
|
||||
today: string;
|
||||
slots: Slot[];
|
||||
onconfirm: (result: { date: string; slotId: string | null }) => void;
|
||||
onweekchange: (newWeekStart: string) => void;
|
||||
} = $props();
|
||||
|
||||
let selectedDate = $state<string | null>(null);
|
||||
|
||||
const slotMap = $derived(
|
||||
new Map(slots.map((s) => [s.slotDate, s]))
|
||||
);
|
||||
|
||||
const days = $derived(weekDays(weekStart));
|
||||
|
||||
function chipState(date: string): string {
|
||||
const isSelected = selectedDate === date;
|
||||
const slot = slotMap.get(date);
|
||||
const hasFilled = slot?.recipe != null;
|
||||
|
||||
if (isSelected) {
|
||||
return hasFilled ? 'sel-filled' : 'sel-empty';
|
||||
}
|
||||
if (date === today) return 'today';
|
||||
return hasFilled ? 'filled' : 'empty';
|
||||
}
|
||||
|
||||
const selectedSlot = $derived(selectedDate ? slotMap.get(selectedDate) : undefined);
|
||||
const existingRecipeName = $derived(selectedSlot?.recipe?.name ?? null);
|
||||
const existingSlotId = $derived(selectedSlot?.id ?? null);
|
||||
|
||||
function chipStyle(state: string): string {
|
||||
switch (state) {
|
||||
case 'empty':
|
||||
return 'border-style: dashed; border-color: var(--green-light); background: var(--green-tint);';
|
||||
case 'filled':
|
||||
return 'border-color: var(--color-border); background: var(--color-surface);';
|
||||
case 'today':
|
||||
return 'border-color: var(--yellow); background: var(--yellow-tint);';
|
||||
case 'sel-empty':
|
||||
return 'border: 2px solid var(--green-dark); background: var(--green-tint);';
|
||||
case 'sel-filled':
|
||||
return 'border: 2px solid var(--orange-dark); background: var(--orange-tint);';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleChipClick(date: string) {
|
||||
selectedDate = date;
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!selectedDate) return;
|
||||
onconfirm({ date: selectedDate, slotId: existingSlotId });
|
||||
}
|
||||
|
||||
function dayNumber(date: string): string {
|
||||
return date.slice(-2).replace(/^0/, '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="background: var(--color-page); font-family: var(--font-sans);">
|
||||
<!-- Header -->
|
||||
<div style="padding: 10px 12px 6px; border-bottom: 1px solid var(--color-border);">
|
||||
<p
|
||||
style="font-family: var(--font-display); font-size: 14px; font-weight: 500; color: var(--color-text); margin: 0;"
|
||||
>
|
||||
Tag wählen
|
||||
</p>
|
||||
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
|
||||
{recipeName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Week navigation -->
|
||||
<div
|
||||
style="display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--color-border);"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Vorherige Woche"
|
||||
onclick={() => onweekchange(prevWeek(weekStart))}
|
||||
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span style="font-size: 12px; font-weight: 500; color: var(--color-text);">
|
||||
{formatWeekRange(weekStart)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Nächste Woche"
|
||||
onclick={() => onweekchange(nextWeek(weekStart))}
|
||||
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Day chips -->
|
||||
<div
|
||||
style="display: flex; gap: 6px; padding: 10px 12px; overflow-x: auto;"
|
||||
>
|
||||
{#each days as date (date)}
|
||||
{@const state = chipState(date)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chip-{date}"
|
||||
data-state={state}
|
||||
onclick={() => handleChipClick(date)}
|
||||
style="flex: 1; min-width: 36px; padding: 6px 4px; border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; text-align: center; font-family: var(--font-sans); {chipStyle(state)}"
|
||||
>
|
||||
<span style="display: block; font-size: 9px; font-weight: 500; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em;">
|
||||
{formatDayAbbr(date, 'narrow')}
|
||||
</span>
|
||||
<span style="display: block; font-size: 13px; font-weight: 600; color: var(--color-text); margin-top: 2px;">
|
||||
{dayNumber(date)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Replace warning -->
|
||||
{#if selectedDate && existingRecipeName}
|
||||
<div
|
||||
data-testid="replace-warning"
|
||||
style="margin: 0 12px 10px; padding: 8px 10px; border-radius: var(--radius-md); background: var(--orange-tint); border: 1px solid var(--orange-dark); font-size: 11px; color: var(--color-text);"
|
||||
>
|
||||
Ersetzt <strong>{existingRecipeName}</strong> an diesem Tag.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirm button -->
|
||||
<div style="padding: 0 12px 12px;">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="confirm-btn"
|
||||
disabled={!selectedDate}
|
||||
onclick={handleConfirm}
|
||||
style="width: 100%; padding: 9px 12px; font-family: var(--font-sans); font-size: 13px; font-weight: 600; border-radius: var(--radius-md); border: none; cursor: {selectedDate ? 'pointer' : 'not-allowed'}; background: {selectedDate ? 'var(--green)' : 'var(--color-border)'}; color: {selectedDate ? '#fff' : 'var(--color-text-muted)'};"
|
||||
>
|
||||
Einplanen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
134
frontend/src/lib/planner/DayPicker.test.ts
Normal file
134
frontend/src/lib/planner/DayPicker.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
168
frontend/src/lib/planner/RecipePicker.svelte
Normal file
168
frontend/src/lib/planner/RecipePicker.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
recipe: Recipe;
|
||||
simulatedScore: number;
|
||||
}
|
||||
|
||||
let {
|
||||
planId,
|
||||
date,
|
||||
dateLabel,
|
||||
currentVarietyScore = 0,
|
||||
suggestions = [],
|
||||
allRecipes = [],
|
||||
onpick
|
||||
}: {
|
||||
planId: string;
|
||||
date: string;
|
||||
dateLabel: string;
|
||||
currentVarietyScore?: number;
|
||||
suggestions: Suggestion[];
|
||||
allRecipes: Recipe[];
|
||||
onpick: (recipeId: string, recipeName: string) => void;
|
||||
} = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
let filteredRecipes = $derived(
|
||||
searchQuery.trim() === ''
|
||||
? allRecipes
|
||||
: allRecipes.filter((r) =>
|
||||
r.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
function recipeMetadata(recipe: Recipe): string {
|
||||
return [
|
||||
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} Min` : null,
|
||||
recipe.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="background: var(--color-page); font-family: var(--font-sans);">
|
||||
<!-- Header -->
|
||||
<div style="padding: 10px 12px 6px; border-bottom: 1px solid var(--color-border);">
|
||||
<p style="font-family: var(--font-display); font-size: 14px; font-weight: 500; color: var(--color-text); margin: 0;">
|
||||
Rezept wählen
|
||||
</p>
|
||||
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
|
||||
{dateLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div style="padding: 8px 12px; border-bottom: 1px solid var(--color-border);">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Rezept suchen…"
|
||||
style="width: 100%; box-sizing: border-box; padding: 5px 8px; font-size: 11px; font-family: var(--font-sans); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface); color: var(--color-text);"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empfohlen section -->
|
||||
{#if suggestions.length > 0}
|
||||
<div
|
||||
style="font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
>
|
||||
Empfohlen · Beste Abwechslung
|
||||
</div>
|
||||
|
||||
{#each suggestions as suggestion (suggestion.recipe.id)}
|
||||
{@const delta = suggestion.simulatedScore - currentVarietyScore}
|
||||
{@const meta = recipeMetadata(suggestion.recipe)}
|
||||
<div
|
||||
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
|
||||
>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p
|
||||
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
|
||||
>
|
||||
{suggestion.recipe.name}
|
||||
</p>
|
||||
{#if meta}
|
||||
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
{#if delta > 0}
|
||||
<span
|
||||
data-testid="badge-{suggestion.recipe.id}"
|
||||
data-type="good"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);"
|
||||
>
|
||||
↑ +{delta.toFixed(0)} Punkte
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
data-testid="badge-{suggestion.recipe.id}"
|
||||
data-type="warning"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--yellow-tint); color: var(--yellow-text);"
|
||||
>
|
||||
⚠ Variationskonflikt
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Wählen"
|
||||
onclick={() => onpick(suggestion.recipe.id, suggestion.recipe.name)}
|
||||
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green); color: #fff; border: none; cursor: pointer;"
|
||||
>
|
||||
+ Wählen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Alle Rezepte section -->
|
||||
<div
|
||||
style="font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
>
|
||||
Alle Rezepte
|
||||
</div>
|
||||
|
||||
{#if filteredRecipes.length === 0}
|
||||
<p style="padding: 10px 12px; font-size: 11px; color: var(--color-text-muted); margin: 0;">
|
||||
Keine Treffer
|
||||
</p>
|
||||
{:else}
|
||||
{#each filteredRecipes as recipe (recipe.id)}
|
||||
{@const meta = recipeMetadata(recipe)}
|
||||
<div
|
||||
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
|
||||
>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p
|
||||
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
|
||||
>
|
||||
{recipe.name}
|
||||
</p>
|
||||
{#if meta}
|
||||
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Wählen"
|
||||
onclick={() => onpick(recipe.id, recipe.name)}
|
||||
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green); color: #fff; border: none; cursor: pointer;"
|
||||
>
|
||||
+ Wählen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
101
frontend/src/lib/planner/RecipePicker.test.ts
Normal file
101
frontend/src/lib/planner/RecipePicker.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
67
frontend/src/lib/planner/UndoBar.svelte
Normal file
67
frontend/src/lib/planner/UndoBar.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
visible,
|
||||
message,
|
||||
onundo,
|
||||
ondismiss
|
||||
}: {
|
||||
visible: boolean;
|
||||
message: string;
|
||||
onundo: () => void;
|
||||
ondismiss: () => void;
|
||||
} = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
ondismiss();
|
||||
}, 4000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
data-testid="undo-bar"
|
||||
role="status"
|
||||
style="
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-text);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
"
|
||||
>
|
||||
<span style="color: #E8E8E2; font-family: var(--font-sans); font-size: 14px;">
|
||||
{message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onundo}
|
||||
style="
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--green-dark);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
"
|
||||
onmouseenter={(e) => ((e.currentTarget as HTMLButtonElement).style.textDecoration = 'underline')}
|
||||
onmouseleave={(e) => ((e.currentTarget as HTMLButtonElement).style.textDecoration = 'none')}
|
||||
>
|
||||
Rückgängig
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
56
frontend/src/lib/planner/UndoBar.test.ts
Normal file
56
frontend/src/lib/planner/UndoBar.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { RecipeSummary } from './types';
|
||||
|
||||
let { recipe, compact = false }: { recipe: RecipeSummary; compact?: boolean } = $props();
|
||||
let { recipe, compact = false, onplan }: {
|
||||
recipe: RecipeSummary;
|
||||
compact?: boolean;
|
||||
onplan?: ((recipeId: string, recipeName: string) => void);
|
||||
} = $props();
|
||||
|
||||
let metadata = $derived(
|
||||
[
|
||||
@@ -13,48 +17,61 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/recipes/{recipe.id}"
|
||||
class="block rounded-[var(--radius-md)] overflow-hidden border border-[var(--color-border)] bg-[var(--color-surface)] hover:shadow-[var(--shadow-card)]"
|
||||
>
|
||||
<div
|
||||
data-testid="image-area"
|
||||
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
|
||||
>
|
||||
{#if recipe.heroImageUrl}
|
||||
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
data-testid="image-placeholder"
|
||||
class="w-full h-full bg-[var(--color-border)] flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
class="text-[var(--color-text-muted)] opacity-50"
|
||||
<div class="rounded-[var(--radius-md)] overflow-hidden border border-[var(--color-border)] bg-[var(--color-surface)] hover:shadow-[var(--shadow-card)]">
|
||||
<a href="/recipes/{recipe.id}" class="block">
|
||||
<div
|
||||
data-testid="image-area"
|
||||
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
|
||||
>
|
||||
{#if recipe.heroImageUrl}
|
||||
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
data-testid="image-placeholder"
|
||||
class="w-full h-full bg-[var(--color-border)] flex items-center justify-center"
|
||||
>
|
||||
<!-- plate -->
|
||||
<circle cx="12" cy="13" r="6" />
|
||||
<path d="M12 7V5" />
|
||||
<!-- fork tines -->
|
||||
<path d="M8 3v3c0 1.1.9 2 2 2h4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
class="text-[var(--color-text-muted)] opacity-50"
|
||||
>
|
||||
<!-- plate -->
|
||||
<circle cx="12" cy="13" r="6" />
|
||||
<path d="M12 7V5" />
|
||||
<!-- fork tines -->
|
||||
<path d="M8 3v3c0 1.1.9 2 2 2h4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="px-2 py-1.5">
|
||||
<p class="font-medium text-[13px] text-[var(--color-text)] truncate">{recipe.name}</p>
|
||||
{#if metadata}
|
||||
<p class="text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
<div class="px-2 py-1.5">
|
||||
<p class="font-medium text-[13px] text-[var(--color-text)] truncate">{recipe.name}</p>
|
||||
{#if metadata}
|
||||
<p class="text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{#if onplan}
|
||||
<div class="flex gap-[5px] px-2 pb-2">
|
||||
<a
|
||||
href="/cook/{recipe.id}"
|
||||
class="flex-1 text-center font-[var(--font-sans)] text-[10px] font-[500] py-[5px] px-[6px] rounded-[var(--radius-md)] bg-[var(--green)] text-white"
|
||||
>🍳 Jetzt kochen</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onplan!(recipe.id, recipe.name)}
|
||||
class="flex-1 text-center font-[var(--font-sans)] text-[10px] font-[500] py-[5px] px-[6px] rounded-[var(--radius-md)] bg-[var(--green-tint)] text-[var(--green-dark)] border border-[var(--green-light)]"
|
||||
>📅 Zur Woche +</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
@@ -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();
|
||||
</script>
|
||||
|
||||
{#if recipes.length > 0}
|
||||
<div data-testid="recipe-grid" class="grid grid-cols-2 lg:grid-cols-4 gap-[8px] lg:gap-[12px] p-[16px]">
|
||||
{#each recipes as recipe (recipe.id)}
|
||||
<RecipeCard {recipe} compact={true} />
|
||||
<RecipeCard {recipe} compact={true} {onplan} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
65
frontend/src/lib/server/slotActions.ts
Normal file
65
frontend/src/lib/server/slotActions.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import { tick } from 'svelte';
|
||||
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
||||
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
||||
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
||||
import RecipePicker from '$lib/planner/RecipePicker.svelte';
|
||||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||
import UndoBar from '$lib/planner/UndoBar.svelte';
|
||||
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week';
|
||||
|
||||
let { data } = $props();
|
||||
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props();
|
||||
|
||||
// Capture initial weekStart before reactivity for $state initialization
|
||||
const initialWeekStart: string = data.weekStart;
|
||||
// Use UTC date string (YYYY-MM-DD) consistently
|
||||
const today: string = new Date().toISOString().slice(0, 10);
|
||||
|
||||
@@ -21,7 +24,12 @@
|
||||
let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s])));
|
||||
|
||||
// Default selected day: today if in this week, else first day
|
||||
let selectedDay = $state(weekDays(initialWeekStart).includes(today) ? today : weekDays(initialWeekStart)[0]);
|
||||
// We read data.weekStart once synchronously here (before reactivity kicks in) to seed the initial value.
|
||||
let selectedDay = $state((() => {
|
||||
const init = data.weekStart;
|
||||
const d = weekDays(init);
|
||||
return d.includes(today) ? today : d[0];
|
||||
})());
|
||||
|
||||
// When week changes via navigation, reset selected day
|
||||
$effect(() => {
|
||||
@@ -39,8 +47,40 @@
|
||||
|
||||
let weekRange = $derived(formatWeekRange(weekStart));
|
||||
|
||||
// Desktop right panel state machine
|
||||
type PanelState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'day-detail'; date: string }
|
||||
| { kind: 'recipe-picker'; date: string };
|
||||
|
||||
let panelState = $state<PanelState>({ kind: 'idle' });
|
||||
|
||||
// Mobile bottom sheet for RecipePicker
|
||||
let pickerOpen = $state(false);
|
||||
|
||||
// Hidden form field bindings
|
||||
let addPlanId = $state('');
|
||||
let addSlotDate = $state('');
|
||||
let addRecipeId = $state('');
|
||||
let addRecipeName = $state('');
|
||||
let updPlanId = $state('');
|
||||
let updSlotId = $state('');
|
||||
let updRecipeId = $state('');
|
||||
let updRecipeName = $state('');
|
||||
let delPlanId = $state('');
|
||||
let delSlotId = $state('');
|
||||
|
||||
let addSlotFormEl: HTMLFormElement;
|
||||
let updateSlotFormEl: HTMLFormElement;
|
||||
let deleteSlotFormEl: HTMLFormElement;
|
||||
|
||||
// UndoBar
|
||||
let undoVisible = $state(false);
|
||||
let undoMessage = $state('');
|
||||
|
||||
function handleSelectDay(day: string) {
|
||||
selectedDay = day;
|
||||
panelState = { kind: 'day-detail', date: day };
|
||||
}
|
||||
|
||||
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
||||
@@ -51,6 +91,52 @@
|
||||
|
||||
await goto(`/planner?week=${newWeekStart}`, { invalidateAll: true });
|
||||
}
|
||||
|
||||
async function handleRecipePick(recipeId: string, recipeName: string) {
|
||||
// Capture date before modifying panel state
|
||||
const date = panelState.kind === 'recipe-picker' ? panelState.date : selectedDay;
|
||||
|
||||
// Close pickers
|
||||
pickerOpen = false;
|
||||
if (panelState.kind === 'recipe-picker') {
|
||||
panelState = { kind: 'idle' };
|
||||
}
|
||||
|
||||
const existingSlot = slotMap[date];
|
||||
|
||||
if (existingSlot?.id) {
|
||||
updPlanId = weekPlan!.id;
|
||||
updSlotId = existingSlot.id;
|
||||
updRecipeId = recipeId;
|
||||
updRecipeName = recipeName;
|
||||
await tick();
|
||||
updateSlotFormEl.requestSubmit();
|
||||
} else {
|
||||
addPlanId = weekPlan!.id;
|
||||
addSlotDate = date;
|
||||
addRecipeId = recipeId;
|
||||
addRecipeName = recipeName;
|
||||
await tick();
|
||||
addSlotFormEl.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleUndo() {
|
||||
undoVisible = false;
|
||||
deleteSlotFormEl.requestSubmit();
|
||||
}
|
||||
|
||||
function closePanelToIdle() {
|
||||
panelState = { kind: 'idle' };
|
||||
}
|
||||
|
||||
function closePanelToDayDetail() {
|
||||
if (panelState.kind === 'recipe-picker') {
|
||||
panelState = { kind: 'day-detail', date: panelState.date };
|
||||
} else {
|
||||
panelState = { kind: 'idle' };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile & Tablet: vertical stack -->
|
||||
@@ -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)]"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
@@ -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)]"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{#if isPlanner}
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (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
|
||||
</a>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
@@ -118,6 +205,7 @@
|
||||
isToday={selectedDay === today}
|
||||
isSelected={true}
|
||||
readonly={!isPlanner}
|
||||
onaddrecipe={isPlanner ? () => (pickerOpen = true) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +216,7 @@
|
||||
Restliche Woche
|
||||
</h2>
|
||||
<div class="space-y-2 md:grid md:grid-cols-2 md:gap-2 md:space-y-0">
|
||||
{#each remainingSlotsWithMeal as slot}
|
||||
{#each remainingSlotsWithMeal as slot (slot.slotDate)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelectDay(slot.slotDate)}
|
||||
@@ -166,6 +254,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile RecipePicker in BottomSheet -->
|
||||
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)}>
|
||||
<RecipePicker
|
||||
planId={weekPlan?.id ?? ''}
|
||||
date={selectedDay}
|
||||
dateLabel={formatDayLabel(selectedDay)}
|
||||
currentVarietyScore={varietyScore?.score ?? 0}
|
||||
suggestions={[]}
|
||||
allRecipes={data.recipes}
|
||||
onpick={handleRecipePick}
|
||||
/>
|
||||
</BottomSheet>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: 3-panel layout -->
|
||||
@@ -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)]"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
@@ -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)]"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('today')}
|
||||
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] items-center rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
</div>
|
||||
{#if isPlanner}
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (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
|
||||
</a>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
@@ -240,7 +342,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-7 gap-[8px]">
|
||||
{#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 @@
|
||||
<!-- Meal tile -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelectDay(day)}
|
||||
onclick={() => {
|
||||
handleSelectDay(day);
|
||||
if (!slot.recipe && isPlanner) {
|
||||
panelState = { kind: 'recipe-picker', date: day };
|
||||
}
|
||||
}}
|
||||
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(day)}`}
|
||||
class="flex flex-1 flex-col rounded-[var(--radius-lg)] border p-2 text-left shadow-[var(--shadow-card)] transition-all hover:border-[var(--green-light)] hover:shadow-[var(--shadow-raised)]
|
||||
{slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
|
||||
@@ -293,57 +400,187 @@
|
||||
|
||||
<!-- Right detail panel -->
|
||||
<aside class="flex w-[280px] flex-shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-page)] p-4">
|
||||
<div class="mb-3">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(selectedDay)} · Abendessen
|
||||
</p>
|
||||
</div>
|
||||
{#if panelState.kind === 'idle'}
|
||||
<div class="flex flex-1 flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Tag ausgewählt</p>
|
||||
</div>
|
||||
|
||||
{#if selectedSlot?.recipe}
|
||||
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
|
||||
{selectedSlot.recipe.name}
|
||||
</h2>
|
||||
{#if selectedSlot.recipe.effort || selectedSlot.recipe.cookTimeMin}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
{[selectedSlot.recipe.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null, selectedSlot.recipe.effort].filter(Boolean).join(' · ')}
|
||||
{:else if panelState.kind === 'day-detail'}
|
||||
{@const detailDate = panelState.date}
|
||||
{@const detailSlot = slotMap[detailDate] ?? { id: null, slotDate: detailDate, recipe: null }}
|
||||
|
||||
<!-- Panel header with close button -->
|
||||
<div class="mb-3 flex items-start justify-between">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(detailDate)} · Abendessen
|
||||
</p>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={closePanelToIdle}
|
||||
aria-label="Panel schließen"
|
||||
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- View and cook actions shown to all roles -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<a
|
||||
href="/recipes/{selectedSlot.recipe.id}"
|
||||
class="block rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Rezept ansehen
|
||||
</a>
|
||||
<a
|
||||
href="/recipes/{selectedSlot.recipe.id}/cook"
|
||||
class="block rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
Koch-Modus
|
||||
</a>
|
||||
<!-- Swap action: planner only -->
|
||||
{#if isPlanner}
|
||||
{#if detailSlot.recipe}
|
||||
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
|
||||
{detailSlot.recipe.name}
|
||||
</h2>
|
||||
{#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
{[detailSlot.recipe.cookTimeMin ? `${detailSlot.recipe.cookTimeMin} Min` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
href="/recipes/{detailSlot.recipe.id}"
|
||||
class="block rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Gericht tauschen
|
||||
Rezept ansehen
|
||||
</a>
|
||||
<a
|
||||
href="/recipes/{detailSlot.recipe.id}/cook"
|
||||
class="block rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
Koch-Modus
|
||||
</a>
|
||||
{#if isPlanner}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
|
||||
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Gericht tauschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||
{#if isPlanner}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
|
||||
class="mt-3 block w-full rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||
>
|
||||
+ Gericht wählen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||
{#if isPlanner}
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
class="mt-3 block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||
>
|
||||
+ Gericht wählen
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{:else if panelState.kind === 'recipe-picker'}
|
||||
{@const pickerDate = panelState.date}
|
||||
|
||||
<!-- Panel header with back/close button -->
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Rezept wählen
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closePanelToDayDetail}
|
||||
aria-label="Zurück"
|
||||
class="ml-2 flex-shrink-0 text-[18px] leading-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
|
||||
<RecipePicker
|
||||
planId={weekPlan?.id ?? ''}
|
||||
date={pickerDate}
|
||||
dateLabel={formatDayLabel(pickerDate)}
|
||||
currentVarietyScore={varietyScore?.score ?? 0}
|
||||
suggestions={[]}
|
||||
allRecipes={data.recipes}
|
||||
onpick={handleRecipePick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden forms for slot mutations -->
|
||||
<div class="hidden">
|
||||
<!-- Add slot -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addSlot"
|
||||
bind:this={addSlotFormEl}
|
||||
use:enhance={({ formData }) => {
|
||||
formData.set('planId', addPlanId);
|
||||
formData.set('slotDate', addSlotDate);
|
||||
formData.set('recipeId', addRecipeId);
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && result.data?.success) {
|
||||
delPlanId = addPlanId;
|
||||
delSlotId = (result.data as any)?.slot?.id ?? '';
|
||||
undoMessage = `${addRecipeName} hinzugefügt`;
|
||||
undoVisible = true;
|
||||
}
|
||||
await update({ reset: false });
|
||||
await invalidateAll();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="planId" value={addPlanId} />
|
||||
<input type="hidden" name="slotDate" value={addSlotDate} />
|
||||
<input type="hidden" name="recipeId" value={addRecipeId} />
|
||||
</form>
|
||||
|
||||
<!-- Update slot -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateSlot"
|
||||
bind:this={updateSlotFormEl}
|
||||
use:enhance={({ formData }) => {
|
||||
formData.set('planId', updPlanId);
|
||||
formData.set('slotId', updSlotId);
|
||||
formData.set('recipeId', updRecipeId);
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && result.data?.success) {
|
||||
delPlanId = updPlanId;
|
||||
delSlotId = (result.data as any)?.slot?.id ?? '';
|
||||
undoMessage = `${updRecipeName} eingetragen`;
|
||||
undoVisible = true;
|
||||
}
|
||||
await update({ reset: false });
|
||||
await invalidateAll();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="planId" value={updPlanId} />
|
||||
<input type="hidden" name="slotId" value={updSlotId} />
|
||||
<input type="hidden" name="recipeId" value={updRecipeId} />
|
||||
</form>
|
||||
|
||||
<!-- Delete slot (for undo) -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteSlot"
|
||||
bind:this={deleteSlotFormEl}
|
||||
use:enhance={({ formData }) => {
|
||||
formData.set('planId', delPlanId);
|
||||
formData.set('slotId', delSlotId);
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
await invalidateAll();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="planId" value={delPlanId} />
|
||||
<input type="hidden" name="slotId" value={delSlotId} />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Undo toast -->
|
||||
<UndoBar
|
||||
visible={undoVisible}
|
||||
message={undoMessage}
|
||||
onundo={handleUndo}
|
||||
ondismiss={() => (undoVisible = false)}
|
||||
/>
|
||||
|
||||
24
frontend/src/routes/(app)/planner/+server.ts
Normal file
24
frontend/src/routes/(app)/planner/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'}`);
|
||||
}
|
||||
};
|
||||
@@ -1,199 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SuggestionCard from '$lib/planner/SuggestionCard.svelte';
|
||||
import SuggestionContextBanner from '$lib/planner/SuggestionContextBanner.svelte';
|
||||
import { formatDayLabel } from '$lib/planner/week';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let weekPlan = $derived(data.weekPlan);
|
||||
let suggestions = $derived(data.suggestions ?? []);
|
||||
let selectedDay = $derived(data.selectedDay);
|
||||
let weekStart = $derived(data.weekStart);
|
||||
|
||||
// Add rank and derive reasoning from simulatedScore for display.
|
||||
// TODO: replace hardcoded threshold (7.5) with API-provided reasoning once backend supports it.
|
||||
let rankedSuggestions = $derived(
|
||||
suggestions.map((s: any, i: number) => ({
|
||||
...s,
|
||||
reasoningType: (s.simulatedScore ?? 0) >= 7.5 ? 'good' : 'warning',
|
||||
reasoningLabel:
|
||||
(s.simulatedScore ?? 0) >= 7.5
|
||||
? 'Passt gut zur Woche'
|
||||
: 'Wiederholung möglich'
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Gerichtsvorschläge — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Mobile layout: full-width list with context banner -->
|
||||
<div class="flex h-full flex-col lg:hidden">
|
||||
<!-- Mobile topbar -->
|
||||
<header class="sticky top-0 z-10 flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
|
||||
<a
|
||||
href="/planner?week={weekStart}"
|
||||
aria-label="Zurück zum Planer"
|
||||
class="font-[var(--font-sans)] text-[20px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
‹
|
||||
</a>
|
||||
<h1 class="font-[var(--font-display)] text-[18px] font-[300] text-[var(--color-text)]">
|
||||
Vorschläge für {formatDayLabel(selectedDay)}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<!-- Context banner -->
|
||||
<div class="px-4 pt-3">
|
||||
<SuggestionContextBanner {selectedDay} {weekPlan} />
|
||||
</div>
|
||||
|
||||
<!-- Suggestion list -->
|
||||
<div class="flex-1 overflow-y-auto px-4 pt-4 pb-6">
|
||||
{#if rankedSuggestions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Keine Vorschläge verfügbar.
|
||||
</p>
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Rezeptbibliothek durchsuchen →
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each rankedSuggestions as suggestion, i}
|
||||
<SuggestionCard
|
||||
{suggestion}
|
||||
rank={i + 1}
|
||||
planId={weekPlan?.id ?? ''}
|
||||
slotDate={selectedDay}
|
||||
{weekStart}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Browse full library fallback -->
|
||||
<div class="mt-6 text-center">
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Rezeptbibliothek durchsuchen →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: 2-panel layout -->
|
||||
<div class="hidden h-screen lg:flex lg:flex-col">
|
||||
<!-- Topbar -->
|
||||
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
|
||||
<a
|
||||
href="/planner?week={weekStart}"
|
||||
aria-label="Zurück zum Planer"
|
||||
class="font-[var(--font-sans)] text-[20px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
‹
|
||||
</a>
|
||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
|
||||
Vorschläge für {formatDayLabel(selectedDay)}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left context panel (280px) -->
|
||||
<aside class="flex w-[280px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-5 overflow-y-auto">
|
||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Diese Woche bisher
|
||||
</h2>
|
||||
|
||||
{#if weekPlan?.slots?.length}
|
||||
<ul class="space-y-2">
|
||||
{#each (weekPlan.slots ?? []).filter((s: any) => s.slotDate !== selectedDay) as slot}
|
||||
<li class="flex items-baseline gap-2">
|
||||
<span class="min-w-[28px] font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(slot.slotDate ?? '').split(',')[0]}
|
||||
</span>
|
||||
{#if slot.recipe}
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)] truncate">
|
||||
{slot.recipe.name}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">— Nicht geplant</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
Noch keine Gerichte diese Woche geplant.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Filter reasons -->
|
||||
<div class="mt-6">
|
||||
<h3 class="mb-2 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Filterkriterien
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
· Keine Zutatenwiederholungen (3 Tage)
|
||||
</li>
|
||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
· Protein-Abwechslung beachten
|
||||
</li>
|
||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
· Aufwandsbalance
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Browse library link in desktop panel footer -->
|
||||
<div class="mt-auto pt-6">
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Bibliothek →
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right suggestions panel -->
|
||||
<main class="flex-1 overflow-y-auto bg-[var(--color-page)] px-6 py-5">
|
||||
{#if rankedSuggestions.length === 0}
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Keine Vorschläge verfügbar.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each rankedSuggestions as suggestion, i}
|
||||
<SuggestionCard
|
||||
{suggestion}
|
||||
rank={i + 1}
|
||||
planId={weekPlan?.id ?? ''}
|
||||
slotDate={selectedDay}
|
||||
{weekStart}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Rezeptbibliothek durchsuchen →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { tick } from 'svelte';
|
||||
import FilterChipRow from '$lib/recipes/FilterChipRow.svelte';
|
||||
import RecipeGrid from '$lib/recipes/RecipeGrid.svelte';
|
||||
import type { RecipeSummary } from '$lib/recipes/types';
|
||||
import DayPicker from '$lib/planner/DayPicker.svelte';
|
||||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||
import UndoBar from '$lib/planner/UndoBar.svelte';
|
||||
|
||||
let { data }: { data: { recipes: RecipeSummary[] } } = $props();
|
||||
let { data, form = null }: { data: { recipes: RecipeSummary[]; activePlan: any }; form?: any } =
|
||||
$props();
|
||||
|
||||
// ── Search / filter ──────────────────────────────────────────────────────
|
||||
let searchQuery = $state('');
|
||||
let activeFilter = $state('Alle');
|
||||
|
||||
@@ -22,6 +30,77 @@
|
||||
})
|
||||
.filter((r) => r.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
// ── Today (computed once at module level) ─────────────────────────────────
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// ── DayPicker / BottomSheet state ─────────────────────────────────────────
|
||||
let pickerOpen = $state(false);
|
||||
let pickerRecipeId = $state('');
|
||||
let pickerRecipeName = $state('');
|
||||
let pickerPlan = $state<any>(null);
|
||||
let pickerWeekStart = $state('');
|
||||
|
||||
// ── Undo bar state ────────────────────────────────────────────────────────
|
||||
let undoVisible = $state(false);
|
||||
let undoMessage = $state('');
|
||||
let undoPlanId = $state('');
|
||||
let undoSlotId = $state('');
|
||||
|
||||
// ── Hidden form field state ───────────────────────────────────────────────
|
||||
let addPlanId = $state('');
|
||||
let addSlotDate = $state('');
|
||||
let addRecipeId = $state('');
|
||||
let updPlanId = $state('');
|
||||
let updSlotId = $state('');
|
||||
let updRecipeId = $state('');
|
||||
|
||||
// ── Form element refs ─────────────────────────────────────────────────────
|
||||
let addSlotFormEl: HTMLFormElement;
|
||||
let updateSlotFormEl: HTMLFormElement;
|
||||
let deleteSlotFormEl: HTMLFormElement;
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
function openDayPicker(recipeId: string, recipeName: string) {
|
||||
if (!data.activePlan) return;
|
||||
pickerRecipeId = recipeId;
|
||||
pickerRecipeName = recipeName;
|
||||
pickerPlan = data.activePlan;
|
||||
pickerWeekStart = data.activePlan.weekStart;
|
||||
pickerOpen = true;
|
||||
}
|
||||
|
||||
async function handleWeekChange(newWeekStart: string) {
|
||||
const res = await fetch(`/recipes?week=${newWeekStart}`);
|
||||
const { plan } = await res.json();
|
||||
pickerPlan = plan;
|
||||
pickerWeekStart = newWeekStart;
|
||||
}
|
||||
|
||||
async function handleDayPickerConfirm({ date, slotId }: { date: string; slotId: string | null }) {
|
||||
pickerOpen = false;
|
||||
|
||||
if (slotId) {
|
||||
// Replace existing slot
|
||||
updPlanId = pickerPlan?.id ?? '';
|
||||
updSlotId = slotId;
|
||||
updRecipeId = pickerRecipeId;
|
||||
await tick();
|
||||
updateSlotFormEl.requestSubmit();
|
||||
} else {
|
||||
// Add to empty slot
|
||||
addPlanId = pickerPlan?.id ?? '';
|
||||
addSlotDate = date;
|
||||
addRecipeId = pickerRecipeId;
|
||||
await tick();
|
||||
addSlotFormEl.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleUndo() {
|
||||
undoVisible = false;
|
||||
deleteSlotFormEl.requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -30,18 +109,116 @@
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="font-[var(--font-display)] text-[28px] font-medium text-[var(--color-text)]">Rezepte</h1>
|
||||
<a href="/recipes/new" class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white">Rezept hinzufügen</a>
|
||||
<h1 class="font-[var(--font-display)] text-[28px] font-medium text-[var(--color-text)]">
|
||||
Rezepte
|
||||
</h1>
|
||||
<a
|
||||
href="/recipes/new"
|
||||
class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white"
|
||||
>
|
||||
Rezept hinzufügen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Suchen…"
|
||||
class="input"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<input type="search" placeholder="Suchen…" class="input" bind:value={searchQuery} />
|
||||
|
||||
<FilterChipRow {activeFilter} onFilter={(f) => (activeFilter = f)} />
|
||||
|
||||
<RecipeGrid recipes={filteredRecipes} />
|
||||
<RecipeGrid
|
||||
recipes={filteredRecipes}
|
||||
onplan={data.activePlan ? openDayPicker : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)} height="55vh">
|
||||
{#if pickerPlan}
|
||||
<DayPicker
|
||||
recipeName={pickerRecipeName}
|
||||
recipeId={pickerRecipeId}
|
||||
planId={pickerPlan?.id ?? ''}
|
||||
weekStart={pickerWeekStart}
|
||||
{today}
|
||||
slots={pickerPlan?.slots ?? []}
|
||||
onconfirm={handleDayPickerConfirm}
|
||||
onweekchange={handleWeekChange}
|
||||
/>
|
||||
{/if}
|
||||
</BottomSheet>
|
||||
|
||||
<UndoBar
|
||||
visible={undoVisible}
|
||||
message={undoMessage}
|
||||
onundo={handleUndo}
|
||||
ondismiss={() => (undoVisible = false)}
|
||||
/>
|
||||
|
||||
<!-- Hidden forms for slot mutations -->
|
||||
<form
|
||||
bind:this={addSlotFormEl}
|
||||
method="POST"
|
||||
action="?/addSlot"
|
||||
class="hidden"
|
||||
use:enhance={({ formData }) => {
|
||||
formData.set('planId', addPlanId);
|
||||
formData.set('slotDate', addSlotDate);
|
||||
formData.set('recipeId', addRecipeId);
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') {
|
||||
undoPlanId = addPlanId;
|
||||
undoSlotId = (result.data as any)?.slot?.id ?? '';
|
||||
undoMessage = `${pickerRecipeName} hinzugefügt`;
|
||||
undoVisible = true;
|
||||
await invalidateAll();
|
||||
}
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="planId" value={addPlanId} />
|
||||
<input type="hidden" name="slotDate" value={addSlotDate} />
|
||||
<input type="hidden" name="recipeId" value={addRecipeId} />
|
||||
</form>
|
||||
|
||||
<form
|
||||
bind:this={updateSlotFormEl}
|
||||
method="POST"
|
||||
action="?/updateSlot"
|
||||
class="hidden"
|
||||
use:enhance={({ formData }) => {
|
||||
formData.set('planId', updPlanId);
|
||||
formData.set('slotId', updSlotId);
|
||||
formData.set('recipeId', updRecipeId);
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') {
|
||||
undoPlanId = updPlanId;
|
||||
undoSlotId = (result.data as any)?.slot?.id ?? '';
|
||||
undoMessage = `${pickerRecipeName} hinzugefügt`;
|
||||
undoVisible = true;
|
||||
await invalidateAll();
|
||||
}
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="planId" value={updPlanId} />
|
||||
<input type="hidden" name="slotId" value={updSlotId} />
|
||||
<input type="hidden" name="recipeId" value={updRecipeId} />
|
||||
</form>
|
||||
|
||||
<form
|
||||
bind:this={deleteSlotFormEl}
|
||||
method="POST"
|
||||
action="?/deleteSlot"
|
||||
class="hidden"
|
||||
use:enhance={({ formData }) => {
|
||||
formData.set('planId', undoPlanId);
|
||||
formData.set('slotId', undoSlotId);
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="planId" value={undoPlanId} />
|
||||
<input type="hidden" name="slotId" value={undoSlotId} />
|
||||
</form>
|
||||
|
||||
17
frontend/src/routes/(app)/recipes/+server.ts
Normal file
17
frontend/src/routes/(app)/recipes/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
71
frontend/src/routes/(app)/recipes/server.test.ts
Normal file
71
frontend/src/routes/(app)/recipes/server.test.ts
Normal file
@@ -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' } }
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>) => {
|
||||
const result = await fn();
|
||||
await tick();
|
||||
return result;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user