feat: Add-to-Plan flows C4/C5/C6 — recipe picker, quick actions, day picker #44

Merged
marcel merged 13 commits from feat/issue-42-add-to-plan into master 2026-04-09 09:53:08 +02:00
35 changed files with 2378 additions and 722 deletions

View File

@@ -166,13 +166,43 @@ public class PlanningService {
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate, private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) { VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
// Build a simulated slot list: existing slots + candidate on slotDate
List<SimulatedSlot> simulatedSlots = new ArrayList<>(); List<SimulatedSlot> simulatedSlots = new ArrayList<>();
for (WeekPlanSlot slot : plan.getSlots()) { for (WeekPlanSlot slot : plan.getSlots()) {
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate())); simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
} }
simulatedSlots.add(new SimulatedSlot(candidate, slotDate)); 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(); List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue(); double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue(); double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
@@ -181,21 +211,18 @@ public class PlanningService {
// 1. Tag-type repeats on consecutive days // 1. Tag-type repeats on consecutive days
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>(); Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) { for (SimulatedSlot slot : slots) {
for (Tag tag : slot.recipe.getTags()) { for (Tag tag : slot.recipe.getTags()) {
if (checkedTagTypes.contains(tag.getTagType())) { if (checkedTagTypes.contains(tag.getTagType())) {
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()) tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()).add(slot.date);
.add(slot.date);
} }
} }
} }
long tagRepeatCount = tagDays.values().stream() long tagRepeatCount = tagDays.values().stream().filter(this::hasConsecutiveDays).count();
.filter(this::hasConsecutiveDays)
.count();
// 2. Non-staple ingredient overlaps on consecutive days // 2. Non-staple ingredient overlaps on consecutive days
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>(); Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) { for (SimulatedSlot slot : slots) {
for (RecipeIngredient ri : slot.recipe.getIngredients()) { for (RecipeIngredient ri : slot.recipe.getIngredients()) {
if (!ri.getIngredient().isStaple()) { if (!ri.getIngredient().isStaple()) {
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>()) ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
@@ -203,19 +230,17 @@ public class PlanningService {
} }
} }
} }
long ingredientOverlapCount = ingredientDays.values().stream() long ingredientOverlapCount = ingredientDays.values().stream().filter(this::hasConsecutiveDays).count();
.filter(this::hasConsecutiveDays)
.count();
// 3. Recent repeats from cooking log // 3. Recent repeats from cooking log
long recentRepeatCount = simulatedSlots.stream() long recentRepeatCount = slots.stream()
.map(s -> s.recipe.getId()) .map(s -> s.recipe.getId())
.distinct() .distinct()
.filter(recentlyCookedIds::contains) .filter(recentlyCookedIds::contains)
.count(); .count();
// 4. Duplicate recipes within the simulated plan // 4. Duplicate recipes within the plan
Map<UUID, Long> recipeCounts = simulatedSlots.stream() Map<UUID, Long> recipeCounts = slots.stream()
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting())); .collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
long duplicatePenaltyCount = recipeCounts.values().stream() long duplicatePenaltyCount = recipeCounts.values().stream()
.filter(c -> c > 1) .filter(c -> c > 1)
@@ -230,8 +255,6 @@ public class PlanningService {
return Math.max(0, Math.min(10, score)); return Math.max(0, Math.min(10, score));
} }
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) { public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
WeekPlan plan = findPlan(planId, householdId); WeekPlan plan = findPlan(planId, householdId);

View File

@@ -1,5 +1,6 @@
package com.recipeapp.planning; package com.recipeapp.planning;
import com.recipeapp.common.RequiresHouseholdRole;
import com.recipeapp.planning.dto.*; import com.recipeapp.planning.dto.*;
import com.recipeapp.recipe.HouseholdResolver; import com.recipeapp.recipe.HouseholdResolver;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -40,6 +41,7 @@ public class WeekPlanController {
} }
@PostMapping("/{id}/slots") @PostMapping("/{id}/slots")
@RequiresHouseholdRole("planner")
public ResponseEntity<SlotResponse> addSlot( public ResponseEntity<SlotResponse> addSlot(
Principal principal, Principal principal,
@PathVariable UUID id, @PathVariable UUID id,
@@ -50,6 +52,7 @@ public class WeekPlanController {
} }
@PatchMapping("/{planId}/slots/{slotId}") @PatchMapping("/{planId}/slots/{slotId}")
@RequiresHouseholdRole("planner")
public SlotResponse updateSlot( public SlotResponse updateSlot(
Principal principal, Principal principal,
@PathVariable UUID planId, @PathVariable UUID planId,
@@ -61,6 +64,7 @@ public class WeekPlanController {
@DeleteMapping("/{planId}/slots/{slotId}") @DeleteMapping("/{planId}/slots/{slotId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
@RequiresHouseholdRole("planner")
public void deleteSlot( public void deleteSlot(
Principal principal, Principal principal,
@PathVariable UUID planId, @PathVariable UUID planId,
@@ -92,4 +96,15 @@ public class WeekPlanController {
UUID householdId = householdResolver.resolve(principal.getName()); UUID householdId = householdResolver.resolve(principal.getName());
return planningService.getVarietyScore(householdId, id); 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);
}
} }

View File

@@ -0,0 +1,7 @@
package com.recipeapp.planning.dto;
public record VarietyPreviewResponse(
double currentScore,
double projectedScore,
double scoreDelta
) {}

View File

@@ -443,4 +443,93 @@ class PlanningServiceTest {
assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START)) assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START))
.isInstanceOf(ResourceNotFoundException.class); .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);
}
} }

View File

@@ -3,9 +3,11 @@ package com.recipeapp.planning;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.recipeapp.common.GlobalExceptionHandler; import com.recipeapp.common.GlobalExceptionHandler;
import com.recipeapp.common.HouseholdRoleInterceptor;
import com.recipeapp.common.ValidationException; import com.recipeapp.common.ValidationException;
import com.recipeapp.planning.dto.*; import com.recipeapp.planning.dto.*;
import com.recipeapp.recipe.HouseholdResolver; import com.recipeapp.recipe.HouseholdResolver;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -13,6 +15,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType; 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.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@@ -49,6 +53,11 @@ class WeekPlanControllerTest {
.build(); .build();
} }
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test @Test
void getWeekPlanShouldReturn200() throws Exception { void getWeekPlanShouldReturn200() throws Exception {
var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of()); var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of());
@@ -182,4 +191,79 @@ class WeekPlanControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.score").value(7.5)); .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());
}
} }

View File

@@ -79,7 +79,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
displayName: 'Max', displayName: 'Max',
householdId: 'h1', householdId: 'h1',
householdName: 'Familie Müller', householdName: 'Familie Müller',
householdRole: 'planer', householdRole: 'planner',
email: 'max@example.com', email: 'max@example.com',
systemRole: 'user' systemRole: 'user'
} }

View File

@@ -39,7 +39,7 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.benutzer = { event.locals.benutzer = {
id: user.id!, id: user.id!,
name: user.displayName!, name: user.displayName!,
rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied' rolle: user.householdRole === 'planner' ? 'planer' : 'mitglied'
}; };
event.locals.haushalt = { event.locals.haushalt = {
id: user.householdId ?? undefined, id: user.householdId ?? undefined,

View 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}
>
&times;
</button>
</div>
<!-- Body content -->
<div class="overflow-y-auto flex-1">
{@render children?.()}
</div>
</div>
</div>
{/if}

View 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();
});
});

View File

@@ -16,12 +16,14 @@
slot, slot,
isToday = false, isToday = false,
isSelected = false, isSelected = false,
readonly = false readonly = false,
onaddrecipe
}: { }: {
slot: Slot; slot: Slot;
isToday?: boolean; isToday?: boolean;
isSelected?: boolean; isSelected?: boolean;
readonly?: boolean; readonly?: boolean;
onaddrecipe?: () => void;
} = $props(); } = $props();
let metadata = $derived( let metadata = $derived(
@@ -64,23 +66,27 @@
> >
Jetzt kochen Jetzt kochen
</a> </a>
<a {#if onaddrecipe}
href="/planner/suggestions?day={slot.slotDate}" <button
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)]" type="button"
> onclick={onaddrecipe}
Tauschen 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)]"
</a> >
Tauschen
</button>
{/if}
</div> </div>
{/if} {/if}
{:else} {:else}
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p> <p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
{#if !readonly} {#if !readonly && onaddrecipe}
<a <button
href="/planner/suggestions?day={slot.slotDate}" 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)]" 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 + Gericht hinzufügen
</a> </button>
{/if} {/if}
{/if} {/if}
</div> </div>

View File

@@ -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 { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import DayMealCard from './DayMealCard.svelte'; import DayMealCard from './DayMealCard.svelte';
const slot = { const slot = {
@@ -14,22 +15,29 @@ describe('DayMealCard', () => {
expect(screen.getByText('Pasta Bolognese')).toBeTruthy(); expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
}); });
it('shows Cook now and Tauschen links when not readonly', () => { it('shows Jetzt kochen link and Tauschen button when not readonly and onaddrecipe provided', () => {
render(DayMealCard, { props: { slot, isToday: false, readonly: false } }); 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: /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 } }); render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
const link = screen.getByRole('link', { name: /Tauschen/i }); expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
expect(link.getAttribute('href')).toContain('2026-03-30');
}); });
it('hides action links when readonly', () => { 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: /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', () => { it('applies today styling when isToday is true', () => {
@@ -55,9 +63,22 @@ describe('DayMealCard', () => {
expect(screen.getByText(/Easy/)).toBeTruthy(); 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 } }); 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(screen.queryByRole('button', { name: /Gericht hinzufügen/i })).toBeNull();
expect(link.getAttribute('href')).toContain('2026-03-31');
}); });
}); });

View 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>

View 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');
});
});

View 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>

View 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();
});
});

View 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}

View 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();
});
});

View File

@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { RecipeSummary } from './types'; 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( let metadata = $derived(
[ [
@@ -13,48 +17,61 @@
); );
</script> </script>
<a <div class="rounded-[var(--radius-md)] overflow-hidden border border-[var(--color-border)] bg-[var(--color-surface)] hover:shadow-[var(--shadow-card)]">
href="/recipes/{recipe.id}" <a href="/recipes/{recipe.id}" class="block">
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"
<div class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
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" />
{#if recipe.heroImageUrl} {:else}
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" /> <div
{:else} data-testid="image-placeholder"
<div class="w-full h-full bg-[var(--color-border)] flex items-center justify-center"
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"
> >
<!-- plate --> <svg
<circle cx="12" cy="13" r="6" /> xmlns="http://www.w3.org/2000/svg"
<path d="M12 7V5" /> width="24"
<!-- fork tines --> height="24"
<path d="M8 3v3c0 1.1.9 2 2 2h4" /> viewBox="0 0 24 24"
</svg> fill="none"
</div> stroke="currentColor"
{/if} stroke-width="1.5"
</div> 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"> <div class="px-2 py-1.5">
<p class="font-medium text-[13px] text-[var(--color-text)] truncate">{recipe.name}</p> <p class="font-medium text-[13px] text-[var(--color-text)] truncate">{recipe.name}</p>
{#if metadata} {#if metadata}
<p class="text-[12px] text-[var(--color-text-muted)]">{metadata}</p> <p class="text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
{/if} {/if}
</div> </div>
</a> </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>

View File

@@ -42,12 +42,36 @@ describe('RecipeCard', () => {
expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese'); 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 } }); 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'); 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', () => { it('applies compact image height when compact prop is true', () => {
render(RecipeCard, { props: { recipe: mockRecipe, compact: true } }); render(RecipeCard, { props: { recipe: mockRecipe, compact: true } });
const imageArea = document.querySelector('[data-testid="image-area"]'); const imageArea = document.querySelector('[data-testid="image-area"]');

View File

@@ -2,13 +2,13 @@
import RecipeCard from './RecipeCard.svelte'; import RecipeCard from './RecipeCard.svelte';
import type { RecipeSummary } from './types'; import type { RecipeSummary } from './types';
let { recipes }: { recipes: RecipeSummary[] } = $props(); let { recipes, onplan }: { recipes: RecipeSummary[]; onplan?: (recipeId: string, recipeName: string) => void } = $props();
</script> </script>
{#if recipes.length > 0} {#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]"> <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)} {#each recipes as recipe (recipe.id)}
<RecipeCard {recipe} compact={true} /> <RecipeCard {recipe} compact={true} {onplan} />
{/each} {/each}
</div> </div>
{:else} {:else}

View 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 };
}

View File

@@ -1,32 +1,51 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { apiClient } from '$lib/server/api'; import { apiClient } from '$lib/server/api';
import { getWeekStart } from '$lib/planner/week'; import { getWeekStart } from '$lib/planner/week';
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
export const load: PageServerLoad = async ({ fetch, url }) => { export const load: PageServerLoad = async ({ fetch, url }) => {
const weekParam = url.searchParams.get('week'); const weekParam = url.searchParams.get('week');
const weekStart = weekParam ?? getWeekStart(new Date()); const weekStart = weekParam ?? getWeekStart(new Date());
const api = apiClient(fetch); const api = apiClient(fetch);
const { data: weekPlan, error } = await api.GET('/v1/week-plans', { const [weekPlanResult, recipesResult] = await Promise.all([
params: { query: { weekStart } } api.GET('/v1/week-plans', { params: { query: { weekStart } } }),
}); api.GET('/v1/recipes', {})
]);
if (error || !weekPlan?.id) { const recipes =
return { weekPlan: null, varietyScore: null, weekStart }; 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', { const { data: varietyScore } = await api.GET('/v1/week-plans/{id}/variety-score', {
params: { path: { id: weekPlan.id } } params: { path: { id: weekPlan.id! } }
}); });
return { return {
weekPlan, weekPlan,
varietyScore: varietyScore ?? null, varietyScore: varietyScore ?? null,
weekStart weekStart,
recipes
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
addSlot: addSlotAction,
updateSlot: updateSlotAction,
deleteSlot: deleteSlotAction,
createPlan: async ({ fetch, request, locals }) => { createPlan: async ({ fetch, request, locals }) => {
// Role guard: only planners may create week plans // Role guard: only planners may create week plans
if (locals.benutzer?.rolle !== 'planer') { if (locals.benutzer?.rolle !== 'planer') {

View File

@@ -1,14 +1,17 @@
<script lang="ts"> <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 VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
import WeekStrip from '$lib/planner/WeekStrip.svelte'; import WeekStrip from '$lib/planner/WeekStrip.svelte';
import DayMealCard from '$lib/planner/DayMealCard.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'; 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 // Use UTC date string (YYYY-MM-DD) consistently
const today: string = new Date().toISOString().slice(0, 10); 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]))); let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s])));
// Default selected day: today if in this week, else first day // 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 // When week changes via navigation, reset selected day
$effect(() => { $effect(() => {
@@ -39,8 +47,40 @@
let weekRange = $derived(formatWeekRange(weekStart)); 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) { function handleSelectDay(day: string) {
selectedDay = day; selectedDay = day;
panelState = { kind: 'day-detail', date: day };
} }
async function navigateWeek(direction: 'prev' | 'next' | 'today') { async function navigateWeek(direction: 'prev' | 'next' | 'today') {
@@ -51,6 +91,52 @@
await goto(`/planner?week=${newWeekStart}`, { invalidateAll: true }); 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> </script>
<!-- Mobile & Tablet: vertical stack --> <!-- Mobile & Tablet: vertical stack -->
@@ -63,7 +149,7 @@
type="button" type="button"
onclick={() => navigateWeek('prev')} onclick={() => navigateWeek('prev')}
aria-label="Vorherige Woche" 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> </button>
@@ -71,17 +157,18 @@
type="button" type="button"
onclick={() => navigateWeek('next')} onclick={() => navigateWeek('next')}
aria-label="Nächste Woche" 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> </button>
{#if isPlanner} {#if isPlanner}
<a <button
href="/planner/suggestions?day={selectedDay}" 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" 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 + Gericht
</a> </button>
{/if} {/if}
</div> </div>
</header> </header>
@@ -118,6 +205,7 @@
isToday={selectedDay === today} isToday={selectedDay === today}
isSelected={true} isSelected={true}
readonly={!isPlanner} readonly={!isPlanner}
onaddrecipe={isPlanner ? () => (pickerOpen = true) : undefined}
/> />
</div> </div>
@@ -128,7 +216,7 @@
Restliche Woche Restliche Woche
</h2> </h2>
<div class="space-y-2 md:grid md:grid-cols-2 md:gap-2 md:space-y-0"> <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 <button
type="button" type="button"
onclick={() => handleSelectDay(slot.slotDate)} onclick={() => handleSelectDay(slot.slotDate)}
@@ -166,6 +254,19 @@
{/if} {/if}
</div> </div>
{/if} {/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> </div>
<!-- Desktop: 3-panel layout --> <!-- Desktop: 3-panel layout -->
@@ -178,7 +279,7 @@
type="button" type="button"
onclick={() => navigateWeek('prev')} onclick={() => navigateWeek('prev')}
aria-label="Vorherige Woche" 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> </button>
@@ -187,25 +288,26 @@
type="button" type="button"
onclick={() => navigateWeek('next')} onclick={() => navigateWeek('next')}
aria-label="Nächste Woche" 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>
<button <button
type="button" type="button"
onclick={() => navigateWeek('today')} 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 Heute
</button> </button>
</div> </div>
{#if isPlanner} {#if isPlanner}
<a <button
href="/planner/suggestions?day={selectedDay}" 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" 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 + Gericht hinzufügen
</a> </button>
{/if} {/if}
</header> </header>
@@ -240,7 +342,7 @@
</div> </div>
{:else} {:else}
<div class="grid grid-cols-7 gap-[8px]"> <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 slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
{@const isTodayDay = day === today} {@const isTodayDay = day === today}
{@const isSelectedDay = day === selectedDay} {@const isSelectedDay = day === selectedDay}
@@ -266,7 +368,12 @@
<!-- Meal tile --> <!-- Meal tile -->
<button <button
type="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)}`} 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)] 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)]' : ''} {slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
@@ -293,57 +400,187 @@
<!-- Right detail panel --> <!-- 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"> <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"> {#if panelState.kind === 'idle'}
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]"> <div class="flex flex-1 flex-col items-center justify-center">
{formatDayLabel(selectedDay)} · Abendessen <p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Tag ausgewählt</p>
</p> </div>
</div>
{#if selectedSlot?.recipe} {:else if panelState.kind === 'day-detail'}
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]"> {@const detailDate = panelState.date}
{selectedSlot.recipe.name} {@const detailSlot = slotMap[detailDate] ?? { id: null, slotDate: detailDate, recipe: null }}
</h2>
{#if selectedSlot.recipe.effort || selectedSlot.recipe.cookTimeMin} <!-- Panel header with close button -->
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]"> <div class="mb-3 flex items-start justify-between">
{[selectedSlot.recipe.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null, selectedSlot.recipe.effort].filter(Boolean).join(' · ')} <p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
{formatDayLabel(detailDate)} · Abendessen
</p> </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 --> {#if detailSlot.recipe}
<div class="mt-4 space-y-2"> <h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
<a {detailSlot.recipe.name}
href="/recipes/{selectedSlot.recipe.id}" </h2>
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)]" {#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin}
> <p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
Rezept ansehen {[detailSlot.recipe.cookTimeMin ? `${detailSlot.recipe.cookTimeMin} Min` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ')}
</a> </p>
<a {/if}
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" <div class="mt-4 space-y-2">
>
Koch-Modus
</a>
<!-- Swap action: planner only -->
{#if isPlanner}
<a <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)]" 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>
<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} {/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} {/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} {/if}
</aside> </aside>
</div> </div>
</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)}
/>

View 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 });
};

View File

@@ -6,10 +6,28 @@ vi.mock('$env/dynamic/private', () => ({
const mockGet = vi.fn(); const mockGet = vi.fn();
const mockPost = vi.fn(); const mockPost = vi.fn();
const mockPatch = vi.fn();
const mockDelete = vi.fn();
vi.mock('$lib/server/api', () => ({ 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', () => { describe('planner page — load', () => {
let load: any; let load: any;
@@ -21,48 +39,44 @@ describe('planner page — load', () => {
load = mod.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 () => { it('fetches week plan for the current week by default', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); mockGet
mockGet.mockResolvedValueOnce({ .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) // weekPlan
data: { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] }, .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) // recipes
error: undefined .mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); // varietyScore
});
const url = new URL('http://localhost/planner'); const url = new URL('http://localhost/planner');
await load({ fetch: vi.fn(), url }); await load({ fetch: vi.fn(), url });
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: expect.objectContaining({ weekStart: expect.any(String) }) }) })); 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 () => { it('uses weekStart from URL search params if provided', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); mockGet
mockGet.mockResolvedValueOnce({ data: { score: 8 }, error: undefined }); .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 url = new URL('http://localhost/planner?week=2026-03-30');
await load({ fetch: vi.fn(), url }); await load({ fetch: vi.fn(), url });
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: { weekStart: '2026-03-30' } }) })); 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 () => { it('returns weekPlan with slots in page data', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); mockGet
mockGet.mockResolvedValueOnce({ data: { score: 7.5 }, error: undefined }); .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
const url = new URL('http://localhost/planner'); const url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url }); const result = await load({ fetch: vi.fn(), url });
expect(result.weekPlan).toBeDefined(); expect(result.weekPlan).toBeDefined();
expect(result.weekPlan.id).toBe('plan-1'); expect(result.weekPlan.id).toBe(PLAN_UUID);
expect(result.weekPlan.slots).toHaveLength(2); expect(result.weekPlan.slots).toHaveLength(1);
}); });
it('returns variety score in page data', async () => { it('returns variety score in page data', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); const scoreWithOverlap = { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] };
mockGet.mockResolvedValueOnce({ data: { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] }, error: undefined }); 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 url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url }); const result = await load({ fetch: vi.fn(), url });
expect(result.varietyScore.score).toBe(7.5); expect(result.varietyScore.score).toBe(7.5);
@@ -70,7 +84,9 @@ describe('planner page — load', () => {
}); });
it('returns null weekPlan when API returns 404', async () => { 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 url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url }); const result = await load({ fetch: vi.fn(), url });
expect(result.weekPlan).toBeNull(); expect(result.weekPlan).toBeNull();
@@ -78,8 +94,10 @@ describe('planner page — load', () => {
}); });
it('returns the weekStart used for the query', async () => { it('returns the weekStart used for the query', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); mockGet
mockGet.mockResolvedValueOnce({ data: { score: 6 }, error: undefined }); .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 url = new URL('http://localhost/planner?week=2026-03-30');
const result = await load({ fetch: vi.fn(), url }); const result = await load({ fetch: vi.fn(), url });
expect(result.weekStart).toBe('2026-03-30'); 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 () => { 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 // 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 url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url }); const result = await load({ fetch: vi.fn(), url });
expect(result.weekPlan).toBeNull(); 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', () => { describe('planner page — actions', () => {
@@ -106,7 +147,7 @@ describe('planner page — actions', () => {
}); });
it('createPlan action calls POST /v1/week-plans', async () => { 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(); const formData = new FormData();
formData.set('weekStart', '2026-03-30'); formData.set('weekStart', '2026-03-30');
const result = await actions.createPlan({ const result = await actions.createPlan({
@@ -176,20 +217,154 @@ describe('planner page — variety score partial failure', () => {
load = mod.load; 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 () => { it('returns weekPlan even when variety score API fails', async () => {
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); mockGet
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); .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 url = new URL('http://localhost/planner');
const result = await load({ fetch: vi.fn(), url }); const result = await load({ fetch: vi.fn(), url });
expect(result.weekPlan).toBeDefined(); expect(result.weekPlan).toBeDefined();
expect(result.weekPlan.id).toBe('plan-1'); expect(result.weekPlan.id).toBe(PLAN_UUID);
expect(result.varietyScore).toBeNull(); 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();
});
});

View File

@@ -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'}`);
}
};

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -1,21 +1,36 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { apiClient } from '$lib/server/api'; 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 }) => { export const load: PageServerLoad = async ({ fetch }) => {
const api = apiClient(fetch); const api = apiClient(fetch);
const { data, error } = await api.GET('/v1/recipes', {}); const weekStart = getWeekStart(new Date());
if (error || !data?.data) { const [recipesResult, weekPlanResult] = await Promise.all([
return { recipes: [] }; api.GET('/v1/recipes', {}),
} api.GET('/v1/week-plans', { params: { query: { weekStart } } })
]);
return { const recipes =
recipes: data.data.map((r) => ({ recipesResult.error || !recipesResult.data?.data
id: r.id!, ? []
name: r.name!, : recipesResult.data.data.map((r) => ({
cookTimeMin: r.cookTimeMin, id: r.id!,
effort: r.effort, name: r.name!,
heroImageUrl: r.heroImageUrl 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
}; };

View File

@@ -1,10 +1,18 @@
<script lang="ts"> <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 FilterChipRow from '$lib/recipes/FilterChipRow.svelte';
import RecipeGrid from '$lib/recipes/RecipeGrid.svelte'; import RecipeGrid from '$lib/recipes/RecipeGrid.svelte';
import type { RecipeSummary } from '$lib/recipes/types'; 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 searchQuery = $state('');
let activeFilter = $state('Alle'); let activeFilter = $state('Alle');
@@ -22,6 +30,77 @@
}) })
.filter((r) => r.name.toLowerCase().includes(searchQuery.toLowerCase())) .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> </script>
<svelte:head> <svelte:head>
@@ -30,18 +109,116 @@
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="font-[var(--font-display)] text-[28px] font-medium text-[var(--color-text)]">Rezepte</h1> <h1 class="font-[var(--font-display)] text-[28px] font-medium text-[var(--color-text)]">
<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> 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> </div>
<input <input type="search" placeholder="Suchen…" class="input" bind:value={searchQuery} />
type="search"
placeholder="Suchen…"
class="input"
bind:value={searchQuery}
/>
<FilterChipRow {activeFilter} onFilter={(f) => (activeFilter = f)} /> <FilterChipRow {activeFilter} onFilter={(f) => (activeFilter = f)} />
<RecipeGrid recipes={filteredRecipes} /> <RecipeGrid
recipes={filteredRecipes}
onplan={data.activePlan ? openDayPicker : undefined}
/>
</div> </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>

View 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 });
};

View File

@@ -5,10 +5,32 @@ vi.mock('$env/dynamic/private', () => ({
})); }));
const mockGet = vi.fn(); const mockGet = vi.fn();
const mockPost = vi.fn();
const mockPatch = vi.fn();
const mockDelete = vi.fn();
vi.mock('$lib/server/api', () => ({ 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', () => { describe('recipe library page — load', () => {
let load: any; let load: any;
@@ -19,27 +41,190 @@ describe('recipe library page — load', () => {
load = mod.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 () => { 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); await load({ fetch: vi.fn() } as any);
expect(mockGet).toHaveBeenCalledWith('/v1/recipes', expect.any(Object)); expect(mockGet).toHaveBeenCalledWith('/v1/recipes', expect.any(Object));
}); });
it('returns recipes in data', async () => { 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); const result = await load({ fetch: vi.fn() } as any);
expect(result.recipes).toHaveLength(2); expect(result.recipes).toHaveLength(2);
expect(result.recipes[0].name).toBe('Spaghetti'); expect(result.recipes[0].name).toBe('Spaghetti');
}); });
it('returns empty array when API fails', async () => { it('returns empty array when recipes API fails', async () => {
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } }); mockGet
.mockResolvedValueOnce({ data: undefined, error: { status: 500 } })
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
const result = await load({ fetch: vi.fn() } as any); const result = await load({ fetch: vi.fn() } as any);
expect(result.recipes).toEqual([]); 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();
});
}); });

View File

@@ -8,7 +8,8 @@ const mockData = {
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' }, { id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' }, { id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
{ id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'Easy' } { id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'Easy' }
] ],
activePlan: null
}; };
describe('recipe library page', () => { describe('recipe library page', () => {
@@ -68,7 +69,7 @@ describe('recipe library page', () => {
}); });
it('renders empty state page when no recipes at all', () => { 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(); expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
}); });

View 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' } }
}));
});
});

View File

@@ -1 +1,35 @@
import '@testing-library/jest-dom/vitest'; 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;
}
});
});